Apply-path = den destruktive frontieren: eksekver en operatør-godkjent
(status:approved) ledger-entry → faktisk skills/-mutasjon. Staget per
operatør-beslutning til single-skill ops (sanitize+retire); merge-apply
isoleres til S19 (krever taksonomi-persistering + cross-skill flytt).
- revalidateApply (ren, skill-ops.mjs): idempotent revalidering mot fersk
re-plan — nekter ved status≠approved, op-mismatch, fersk guardrail≠ok,
eller drift (isDeepStrictEqual fresh vs approved guardrail-snapshot).
- applyApprovedAction (impur): arkiver-så-slett (rename skills/…→archive/…
atomisk; retire rmdir'er tom katalog sist) + flipp ledger approved→applied.
apply:false = preview (0 mutasjon). merge_skills → throw (S19).
- setActionStatus (ren, decisions-io.mjs): ledger status-transisjon, bevarer
targets+guardrail, audit-record beholdes.
- CLI apply-skill-op.mjs {list|sanitize|retire} — default PREVIEW, --apply =
dobbel-gate utover ledger-approved.
TDD, tmpdir-fixtures: 0 ekte skills/-mutasjon. Tester: kb-eval 66→78,
kb-update 132→137; validate 239 · kb-integrity 192/192 uendret.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
215 lines
8.3 KiB
JavaScript
215 lines
8.3 KiB
JavaScript
// tests/kb-update/test-decisions-actions.test.mjs
|
|
// Spor B / B3 (Sesjon 16): the ADDITIVE skill-level action layer on the lag-2
|
|
// decision ledger. The URL-keyed `decisions` map (Spor A) is untouched — its
|
|
// contract is covered by test-decisions-io.test.mjs, which stays green
|
|
// unmodified, proving the extension is non-regressive. Here we cover only the
|
|
// new `actions` collection: actionKey dedup, recordAction (pure), isActionDecided,
|
|
// listActions, and backward-compatible normalization of pre-action ledgers.
|
|
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import {
|
|
createLedger,
|
|
loadDecisions,
|
|
saveDecisions,
|
|
recordDecision,
|
|
isDecided,
|
|
actionKey,
|
|
isActionDecided,
|
|
recordAction,
|
|
listActions,
|
|
setActionStatus,
|
|
} from '../../scripts/kb-update/lib/decisions-io.mjs';
|
|
|
|
function withTmp(fn) {
|
|
const dir = mkdtempSync(join(tmpdir(), 'dec-act-test-'));
|
|
try {
|
|
return fn(dir);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
const MERGE = {
|
|
operation_type: 'merge_skills',
|
|
status: 'pending',
|
|
decided_at: '2026-06-20',
|
|
targets: { absorber: 'ms-ai-infrastructure', absorbed: 'ms-ai-engineering' },
|
|
guardrail: { ok: true },
|
|
note: 'dry-run',
|
|
};
|
|
|
|
// --- scaffold now carries an empty actions collection (additive) ---
|
|
|
|
test('createLedger — additive actions:{} alongside untouched decisions:{}', () => {
|
|
const led = createLedger();
|
|
assert.equal(led.version, 1, 'version stays 1 — no migration needed');
|
|
assert.deepEqual(led.decisions, {}, 'URL-keyed map unchanged');
|
|
assert.deepEqual(led.actions, {}, 'new skill-op collection present and empty');
|
|
});
|
|
|
|
// --- actionKey — deterministic dedup key ---
|
|
|
|
test('actionKey — merge_skills key is symmetric on the pair (direction-agnostic dedup)', () => {
|
|
const k1 = actionKey(MERGE);
|
|
const k2 = actionKey({
|
|
...MERGE,
|
|
targets: { absorber: 'ms-ai-engineering', absorbed: 'ms-ai-infrastructure' },
|
|
});
|
|
assert.equal(k1, k2, 'merging A<-B and B<-A must dedup to the same key');
|
|
assert.equal(k1, 'merge_skills:ms-ai-engineering+ms-ai-infrastructure');
|
|
});
|
|
|
|
test('actionKey — single-skill ops keyed by their one target', () => {
|
|
assert.equal(
|
|
actionKey({ operation_type: 'sanitize_skill', targets: { skill: 'ms-ai-governance' } }),
|
|
'sanitize_skill:ms-ai-governance',
|
|
);
|
|
assert.equal(
|
|
actionKey({ operation_type: 'create_skill', targets: { name: 'ms-power-platform' } }),
|
|
'create_skill:ms-power-platform',
|
|
);
|
|
});
|
|
|
|
// --- recordAction — pure, mirrors recordDecision ---
|
|
|
|
test('recordAction — records under actionKey, returns new ledger, does not mutate input', () => {
|
|
const led = createLedger();
|
|
const next = recordAction(led, MERGE);
|
|
assert.deepEqual(led.actions, {}, 'input untouched');
|
|
assert.notEqual(next, led);
|
|
assert.equal(next.actions['merge_skills:ms-ai-engineering+ms-ai-infrastructure'].status, 'pending');
|
|
});
|
|
|
|
test('recordAction — advances updated_at to the action date', () => {
|
|
const next = recordAction(createLedger(), MERGE);
|
|
assert.equal(next.updated_at, '2026-06-20');
|
|
});
|
|
|
|
test('recordAction — does NOT touch the URL-keyed decisions map', () => {
|
|
let led = recordDecision(createLedger(), 'https://learn.microsoft.com/x', {
|
|
status: 'approved',
|
|
decided_at: '2026-06-19',
|
|
});
|
|
const next = recordAction(led, MERGE);
|
|
assert.equal(isDecided(next, 'https://learn.microsoft.com/x'), true, 'URL decision survives');
|
|
assert.equal(isActionDecided(next, actionKey(MERGE)), true, 'action recorded alongside');
|
|
});
|
|
|
|
// --- isActionDecided — dedup gate (policy A: any status counts) ---
|
|
|
|
test('isActionDecided — true for ANY recorded status, false when absent', () => {
|
|
let led = createLedger();
|
|
assert.equal(isActionDecided(led, actionKey(MERGE)), false);
|
|
led = recordAction(led, { ...MERGE, status: 'rejected' });
|
|
assert.equal(isActionDecided(led, actionKey(MERGE)), true, 'rejected merge still dedups (policy A)');
|
|
});
|
|
|
|
// --- listActions — flat list, optional status filter ---
|
|
|
|
test('listActions — returns all entries, filters by status when given', () => {
|
|
let led = createLedger();
|
|
led = recordAction(led, MERGE); // pending
|
|
led = recordAction(led, {
|
|
operation_type: 'sanitize_skill',
|
|
status: 'approved',
|
|
decided_at: '2026-06-20',
|
|
targets: { skill: 'ms-ai-governance' },
|
|
});
|
|
assert.equal(listActions(led).length, 2);
|
|
assert.deepEqual(
|
|
listActions(led, 'pending').map((a) => a.operation_type),
|
|
['merge_skills'],
|
|
);
|
|
assert.deepEqual(
|
|
listActions(led, 'approved').map((a) => a.operation_type),
|
|
['sanitize_skill'],
|
|
);
|
|
});
|
|
|
|
// --- backward compatibility: a pre-action ledger on disk loads cleanly ---
|
|
|
|
test('loadDecisions — normalizes a pre-action (no actions key) ledger to actions:{}', () => {
|
|
withTmp((dir) => {
|
|
// Simulate an old on-disk ledger written before the action layer existed.
|
|
writeFileSync(
|
|
join(dir, 'decisions.json'),
|
|
JSON.stringify({ version: 1, updated_at: null, decisions: {} }, null, 2),
|
|
);
|
|
const led = loadDecisions(dir);
|
|
assert.deepEqual(led.actions, {}, 'missing actions key is backfilled, not undefined');
|
|
// and recording an action on the loaded ledger works
|
|
const next = recordAction(led, MERGE);
|
|
assert.equal(isActionDecided(next, actionKey(MERGE)), true);
|
|
});
|
|
});
|
|
|
|
test('saveDecisions + loadDecisions — actions round-trip through disk', () => {
|
|
withTmp((dir) => {
|
|
const led = recordAction(createLedger(), MERGE);
|
|
saveDecisions(led, dir);
|
|
const back = loadDecisions(dir);
|
|
assert.deepEqual(back, led);
|
|
});
|
|
});
|
|
|
|
// --- setActionStatus — pure status transition (Sesjon 18: gates the apply-path) ---
|
|
// The apply-path flips an operator-approved entry approved -> applied AFTER a
|
|
// successful skills/ mutation. setActionStatus is the one pure primitive that
|
|
// records that transition; it preserves every other field (targets, guardrail).
|
|
|
|
test('setActionStatus — transitions status, preserves targets+guardrail, is pure', () => {
|
|
const led = recordAction(createLedger(), { ...MERGE, status: 'approved' });
|
|
const key = actionKey(MERGE);
|
|
const next = setActionStatus(led, key, 'applied', '2026-06-21');
|
|
|
|
assert.equal(led.actions[key].status, 'approved', 'input ledger untouched (pure)');
|
|
assert.notEqual(next, led);
|
|
assert.equal(next.actions[key].status, 'applied');
|
|
assert.deepEqual(next.actions[key].targets, MERGE.targets, 'targets preserved');
|
|
assert.deepEqual(next.actions[key].guardrail, MERGE.guardrail, 'guardrail preserved');
|
|
});
|
|
|
|
test('setActionStatus — advances updated_at to the transition date', () => {
|
|
const led = recordAction(createLedger(), { ...MERGE, status: 'approved' });
|
|
const next = setActionStatus(led, actionKey(MERGE), 'applied', '2026-06-21');
|
|
assert.equal(next.actions[actionKey(MERGE)].decided_at, '2026-06-21', 'entry date advances');
|
|
assert.equal(next.updated_at, '2026-06-21');
|
|
});
|
|
|
|
test('setActionStatus — without a date, keeps the entry decided_at and ledger updated_at', () => {
|
|
let led = recordAction(createLedger(), { ...MERGE, status: 'approved' }); // decided_at 2026-06-20
|
|
const next = setActionStatus(led, actionKey(MERGE), 'applied');
|
|
assert.equal(next.actions[actionKey(MERGE)].decided_at, '2026-06-20', 'no date -> keep original');
|
|
});
|
|
|
|
test('setActionStatus — throws on an unknown action key (cannot transition a non-entry)', () => {
|
|
const led = recordAction(createLedger(), { ...MERGE, status: 'approved' });
|
|
assert.throws(() => setActionStatus(led, 'sanitize_skill:ghost', 'applied'), /no action|ghost/i);
|
|
});
|
|
|
|
test('setActionStatus — leaves the URL-keyed decisions map and sibling actions untouched', () => {
|
|
let led = recordDecision(createLedger(), 'https://learn.microsoft.com/x', {
|
|
status: 'approved',
|
|
decided_at: '2026-06-19',
|
|
});
|
|
led = recordAction(led, { ...MERGE, status: 'approved' });
|
|
led = recordAction(led, {
|
|
operation_type: 'sanitize_skill',
|
|
status: 'approved',
|
|
decided_at: '2026-06-20',
|
|
targets: { skill: 'ms-ai-governance' },
|
|
guardrail: { ok: true },
|
|
});
|
|
const next = setActionStatus(led, actionKey(MERGE), 'applied', '2026-06-21');
|
|
|
|
assert.equal(isDecided(next, 'https://learn.microsoft.com/x'), true, 'URL decision survives');
|
|
assert.equal(
|
|
next.actions['sanitize_skill:ms-ai-governance'].status,
|
|
'approved',
|
|
'sibling action untouched',
|
|
);
|
|
});
|