test(mosaic): gate #797 ledger upgrade-survival + harden manifest parity (#791)
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Fold the Mos-elevated #797 Runtime-Ledger survival sentinel into #791 PR1 and
harden the .txt-format parity test per the accepted-format conditions.
- framework-manifest.txt: annotate the existing fleet/run/** operator carve-out
to name the #797 ledger (fleet/run/sessions/) so it reads as load-bearing.
The glob already matches the #797 spec exactly — no location divergence.
- HARD GATE (test-upgrade-manifest-guard.sh): seed a populated ledger
(events.ndjson journal + ledger.json projection, 0600 under 0700) as an
operator sentinel; assert byte-identical + mtime-unchanged + dir-perms
unchanged after a keep-mode upgrade. Relabel the prune check as the explicit
negative control. 48 -> 58 checks.
- Parity (manifest-parity.spec.ts): add format-edge fixtures driven through
BOTH resolvers via MANIFEST_FILE — comments/blanks/whitespace, duplicate and
overlapping globs (deny-wins), section/glob-ordering independence, and an
explicit UNKNOWN->operator negative probe; add ledger probe paths.
- manifest.spec.ts: isolate the carve-out's load-bearing value with a resolver
red->green — under a hypothetical fleet/** framework glob, the ledger is
pruned WITHOUT the fleet/run/** carve-out and protected WITH it (deny-wins).
Gates: typecheck, lint, format:check green; mosaic vitest 1069 passed;
HARD GATE 58/58; migration 21/21. Commits forward on 34e55d4a (no rebase).
Part of #791
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -75,6 +75,11 @@ tools/_lib/credentials.json
|
||||
fleet/roster.yaml
|
||||
fleet/roster.json
|
||||
fleet/agents/**
|
||||
# Runtime state, incl. the #797 Runtime Session Ledger at fleet/run/sessions/
|
||||
# (events.ndjson journal + ledger.json projection). This carve-out is the
|
||||
# mechanism that makes the ledger upgrade-safe: an upgrade that wiped it would
|
||||
# defeat its reason to exist. The HARD GATE (test-upgrade-manifest-guard.sh)
|
||||
# proves a populated ledger survives byte-identical + mtime-unchanged.
|
||||
fleet/run/**
|
||||
fleet/backlog/**
|
||||
fleet/roles.local/**
|
||||
|
||||
@@ -27,7 +27,8 @@ SECRET='SUPER-SECRET-TOKEN-do-not-log-3f9a'
|
||||
seed_home() {
|
||||
local H="$1"
|
||||
mkdir -p "$H/agents" "$H/policy" "$H/memory" "$H/tools/_lib" \
|
||||
"$H/fleet/agents" "$H/harvester" "$H/unknown-operator-dir" "$H/guides"
|
||||
"$H/fleet/agents" "$H/fleet/run/sessions" "$H/harvester" \
|
||||
"$H/unknown-operator-dir" "$H/guides"
|
||||
printf '# persona\n' > "$H/SOUL.md" # marks a recognized existing install → keep mode
|
||||
printf 'MODEL=opus\n' > "$H/agents/coder0.conf"
|
||||
printf '# operator policy\n' > "$H/policy/custom.md"
|
||||
@@ -39,6 +40,23 @@ seed_home() {
|
||||
printf '# harvester SOP\n' > "$H/harvester/sop.md"
|
||||
printf 'operator data the manifest never anticipated\n' > "$H/unknown-operator-dir/x"
|
||||
printf 'version: 1\nagents:\n - name: mine\n' > "$H/fleet/my-fleet.yaml"
|
||||
|
||||
# #797 Runtime Session Ledger (Mos-elevated to a #791 PR1 merge-blocker): a
|
||||
# populated ledger under fleet/run/sessions/ must survive the upgrade — a
|
||||
# runtime ledger an upgrade rsync can wipe is worthless. Seed it exactly as
|
||||
# #797 writes it: a non-empty append journal + a non-empty compacted
|
||||
# projection, files 0600 under a 0700 dir.
|
||||
printf '%s\n%s\n%s\n' \
|
||||
'{"seq":1,"kind":"session.spawn","node":"sess-42","generation":7}' \
|
||||
'{"seq":2,"kind":"lease.grant","node":"sess-42","lease":"web1"}' \
|
||||
'{"seq":3,"kind":"dispatch.create","from":"sess-42","to":"disp-9"}' \
|
||||
> "$H/fleet/run/sessions/events.ndjson"
|
||||
printf '%s\n' \
|
||||
'{"generation":7,"nodes":[{"id":"sess-42","kind":"session"}],"edges":[{"from":"sess-42","to":"disp-9","kind":"dispatch"}]}' \
|
||||
> "$H/fleet/run/sessions/ledger.json"
|
||||
chmod 0700 "$H/fleet/run" "$H/fleet/run/sessions"
|
||||
chmod 0600 "$H/fleet/run/sessions/events.ndjson" "$H/fleet/run/sessions/ledger.json"
|
||||
|
||||
# A retired framework file inside a shipped subtree (absent from source) — must be pruned.
|
||||
printf '# retired guide\n' > "$H/guides/RETIRED-OLD-GUIDE.md"
|
||||
echo 3 > "$H/.framework-version"
|
||||
@@ -55,6 +73,9 @@ OPERATOR_SENTINELS=(
|
||||
"harvester/sop.md"
|
||||
"unknown-operator-dir/x"
|
||||
"fleet/my-fleet.yaml"
|
||||
# #797 Runtime Session Ledger — populated journal + projection must survive.
|
||||
"fleet/run/sessions/events.ndjson"
|
||||
"fleet/run/sessions/ledger.json"
|
||||
)
|
||||
|
||||
run_matrix() {
|
||||
@@ -69,6 +90,10 @@ run_matrix() {
|
||||
stat -c %Y "$H/$rel" > "$E/$(echo "$rel" | tr / _).mt"
|
||||
done
|
||||
|
||||
# Snapshot the ledger directory permission bits (#797 assert: perms unchanged).
|
||||
local before_dirperm after_dirperm
|
||||
before_dirperm=$(stat -c %a "$H/fleet/run/sessions")
|
||||
|
||||
# The upgrade under test (keep + sync-only = the `mosaic update` reseed path).
|
||||
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 "$@" bash "$INSTALL" >"$OUT" 2>&1
|
||||
|
||||
@@ -84,10 +109,18 @@ run_matrix() {
|
||||
"[ '$before_mt' = '$after_mt' ]"
|
||||
done
|
||||
|
||||
# Positive controls: the framework tree still updates, retired file pruned.
|
||||
chk "[$label] framework file present after upgrade (guides synced)" \
|
||||
# #797 assert 7: the ledger directory's permission bits are unchanged.
|
||||
after_dirperm=$(stat -c %a "$H/fleet/run/sessions" 2>/dev/null || echo MISSING)
|
||||
chk "[$label] ledger dir perms unchanged (#797): $before_dirperm" \
|
||||
"[ '$before_dirperm' = '$after_dirperm' ]"
|
||||
|
||||
# Positive controls / negative controls — prove the test discriminates: the
|
||||
# upgrade DOES write and prune framework-owned paths, so the operator sentinels
|
||||
# (incl. the #797 ledger) survive because of the manifest, not because the
|
||||
# upgrade is a no-op.
|
||||
chk "[$label] positive control: framework file present after upgrade (guides synced)" \
|
||||
"[ -f '$H/guides/E2E-DELIVERY.md' ]"
|
||||
chk "[$label] retired framework file inside a subtree is pruned" \
|
||||
chk "[$label] negative control: retired framework file inside a subtree IS pruned" \
|
||||
"[ ! -f '$H/guides/RETIRED-OLD-GUIDE.md' ]"
|
||||
chk "[$label] manifest itself is installed" "[ -f '$H/framework-manifest.txt' ]"
|
||||
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { afterAll, describe, it, expect } from 'vitest';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadManifest, resolveOwnership, frameworkSubtreeRoots } from './manifest.js';
|
||||
import {
|
||||
loadManifest,
|
||||
parseManifest,
|
||||
resolveOwnership,
|
||||
frameworkSubtreeRoots,
|
||||
} from './manifest.js';
|
||||
|
||||
/**
|
||||
* Bash ↔ TS parity (#791, §6.1).
|
||||
@@ -34,6 +40,14 @@ function bashResolve(relPath: string): string {
|
||||
}).trim();
|
||||
}
|
||||
|
||||
/** Drive the bash resolver against an arbitrary manifest file (MANIFEST_FILE override). */
|
||||
function bashResolveWith(manifestFile: string, relPath: string): string {
|
||||
return execFileSync('bash', [MANIFEST_SH, 'resolve', relPath], {
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, MANIFEST_FILE: manifestFile },
|
||||
}).trim();
|
||||
}
|
||||
|
||||
function bashSubtreeRoots(): string[] {
|
||||
return execFileSync('bash', [MANIFEST_SH, 'subtree-roots'], { encoding: 'utf-8' })
|
||||
.split('\n')
|
||||
@@ -75,6 +89,9 @@ const PROBE_PATHS = [
|
||||
'fleet/roster.json',
|
||||
'fleet/agents/coder0.env',
|
||||
'fleet/run/coder0.hb',
|
||||
// #797 Runtime Session Ledger — must resolve operator on both paths.
|
||||
'fleet/run/sessions/events.ndjson',
|
||||
'fleet/run/sessions/ledger.json',
|
||||
'fleet/backlog/data.db',
|
||||
'fleet/roles.local/custom.md',
|
||||
// unanticipated → operator (fail-safe)
|
||||
@@ -106,3 +123,130 @@ describe.skipIf(!hasBash)('bash ↔ TS manifest parity (§6.1)', () => {
|
||||
expect(bashSubtreeRoots().sort()).toEqual(frameworkSubtreeRoots(manifest).sort());
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Format-safety parity (#791, Decision 1 — the `.txt` line-oriented format is
|
||||
* accepted only because both resolvers agree on the format edge cases a hand-
|
||||
* edited text file invites: comments, blank lines, stray whitespace, duplicate
|
||||
* and overlapping globs (where deny-wins must resolve), and section ordering.
|
||||
* Each fixture is driven through BOTH resolvers (bash via MANIFEST_FILE, TS via
|
||||
* parseManifest) and must agree AND land on the expected ownership. Any
|
||||
* divergence here means the format itself is unsafe and must be fixed/converted.
|
||||
*/
|
||||
describe.skipIf(!hasBash)('bash ↔ TS manifest format-edge parity (§6.1, Decision 1)', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'mf-parity-'));
|
||||
afterAll(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
let fixtureSeq = 0;
|
||||
function writeFixture(text: string): string {
|
||||
const file = join(tmp, `manifest-${fixtureSeq++}.txt`);
|
||||
writeFileSync(file, text);
|
||||
return file;
|
||||
}
|
||||
|
||||
// Both resolvers must agree, and on the expected value, for every probe.
|
||||
function expectParity(text: string, cases: ReadonlyArray<readonly [string, string]>): void {
|
||||
const file = writeFixture(text);
|
||||
const manifest = parseManifest(text);
|
||||
for (const [path, expected] of cases) {
|
||||
const ts = resolveOwnership(manifest, path);
|
||||
const bash = bashResolveWith(file, path);
|
||||
expect(bash, `bash disagrees with TS on ${path}`).toBe(ts);
|
||||
expect(ts, `ownership of ${path}`).toBe(expected);
|
||||
}
|
||||
}
|
||||
|
||||
it('tolerates comments, blank lines, and leading/trailing whitespace identically', () => {
|
||||
// Entries and headers are padded with spaces/tabs; comments and blanks are
|
||||
// interleaved. Both resolvers must trim and ignore them the same way.
|
||||
const text = [
|
||||
'# leading comment',
|
||||
' ',
|
||||
'\t[framework] ',
|
||||
' tools/** ',
|
||||
'# mid-section comment',
|
||||
'',
|
||||
'\tguides/**\t',
|
||||
' [operator] ',
|
||||
'\ttools/_lib/credentials.json ',
|
||||
'*.local.md',
|
||||
'',
|
||||
].join('\n');
|
||||
expectParity(text, [
|
||||
['tools/git/pr-create.sh', 'framework'],
|
||||
['guides/E2E-DELIVERY.md', 'framework'],
|
||||
['tools/_lib/credentials.json', 'operator'], // deny-wins carve-out inside tools/**
|
||||
['SOUL.local.md', 'operator'],
|
||||
['nowhere/unknown.md', 'operator'], // negative probe: matches NO rule → operator
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves deny-wins for overlapping and duplicate globs identically', () => {
|
||||
// Framework claims tools/** (twice) and the overlapping tools/git/**;
|
||||
// operator carves out tools/_lib/**. Operator must win the overlap on both.
|
||||
const text = [
|
||||
'[framework]',
|
||||
'tools/**',
|
||||
'tools/**', // duplicate — must not change resolution
|
||||
'tools/git/**', // overlaps tools/**
|
||||
'[operator]',
|
||||
'tools/_lib/**',
|
||||
].join('\n');
|
||||
expectParity(text, [
|
||||
['tools/git/pr-create.sh', 'framework'],
|
||||
['tools/other.sh', 'framework'],
|
||||
['tools/_lib/credentials.json', 'operator'], // deny-wins over both framework globs
|
||||
['tools/_lib/nested/deep.json', 'operator'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('is independent of section and glob ordering', () => {
|
||||
// Same rule set, operator section first and entries reordered. Resolution
|
||||
// must be identical because deny-wins checks all operator globs before any
|
||||
// framework glob — order within or between sections cannot matter.
|
||||
const forward = [
|
||||
'[framework]',
|
||||
'guides/**',
|
||||
'tools/**',
|
||||
'[operator]',
|
||||
'tools/_lib/credentials.json',
|
||||
'*.local.md',
|
||||
].join('\n');
|
||||
const reversed = [
|
||||
'[operator]',
|
||||
'*.local.md',
|
||||
'tools/_lib/credentials.json',
|
||||
'[framework]',
|
||||
'tools/**',
|
||||
'guides/**',
|
||||
].join('\n');
|
||||
const probes: ReadonlyArray<readonly [string, string]> = [
|
||||
['tools/git/pr-create.sh', 'framework'],
|
||||
['guides/E2E-DELIVERY.md', 'framework'],
|
||||
['tools/_lib/credentials.json', 'operator'],
|
||||
['SOUL.local.md', 'operator'],
|
||||
['unanticipated/path.md', 'operator'],
|
||||
];
|
||||
expectParity(forward, probes);
|
||||
expectParity(reversed, probes);
|
||||
// And the two orderings agree path-for-path on both resolvers.
|
||||
const fFile = writeFixture(forward);
|
||||
const rFile = writeFixture(reversed);
|
||||
for (const [path] of probes) {
|
||||
expect(bashResolveWith(fFile, path)).toBe(bashResolveWith(rFile, path));
|
||||
}
|
||||
});
|
||||
|
||||
it('defaults an unmatched path to operator on both resolvers (UNKNOWN → operator)', () => {
|
||||
// A manifest that names only a narrow framework slice. Everything else —
|
||||
// including paths under no rule at all — must fail safe to operator.
|
||||
const text = ['[framework]', 'guides/**', '[operator]', 'agents/**'].join('\n');
|
||||
expectParity(text, [
|
||||
['guides/x.md', 'framework'],
|
||||
['agents/coder0.conf', 'operator'],
|
||||
['totally/unlisted/file.txt', 'operator'], // negative probe
|
||||
['fleet/run/sessions/ledger.json', 'operator'], // unlisted → operator
|
||||
['README.md', 'operator'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -154,6 +154,46 @@ describe('planPrune (pure prune planner)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// The #797 Runtime Session Ledger lives at fleet/run/sessions/. Today it is safe
|
||||
// twice over: it matches the explicit `fleet/run/**` operator carve-out AND, even
|
||||
// without it, the UNKNOWN→operator fail-safe. This test isolates the CARVE-OUT's
|
||||
// load-bearing value by simulating a future framework author who broadens fleet
|
||||
// ownership to `fleet/**`: without the operator carve-out the ledger would resolve
|
||||
// framework and be pruned; deny-wins is what keeps it protected. If deleting the
|
||||
// `fleet/run/**` line ever stops turning this test red, the carve-out has silently
|
||||
// stopped mattering — which is exactly the #797 regression we are gating against.
|
||||
describe('fleet/run/** carve-out is load-bearing for the #797 ledger (deny-wins)', () => {
|
||||
const LEDGER = ['fleet/run/sessions/events.ndjson', 'fleet/run/sessions/ledger.json'];
|
||||
// A framework that (hypothetically) ships all of fleet/** as a subtree.
|
||||
const withoutCarveOut: FrameworkManifest = {
|
||||
framework: ['fleet/**'],
|
||||
operator: [],
|
||||
};
|
||||
const withCarveOut: FrameworkManifest = {
|
||||
framework: ['fleet/**'],
|
||||
operator: ['fleet/run/**'],
|
||||
};
|
||||
|
||||
it('RED without the carve-out: the ledger resolves framework and is pruned', () => {
|
||||
for (const p of LEDGER) expect(resolveOwnership(withoutCarveOut, p)).toBe('framework');
|
||||
const del = planPrune({ manifest: withoutCarveOut, targetPaths: LEDGER, sourcePaths: [] });
|
||||
expect(del.sort()).toEqual([...LEDGER].sort());
|
||||
});
|
||||
|
||||
it('GREEN with the carve-out: deny-wins makes the ledger operator and unprunable', () => {
|
||||
for (const p of LEDGER) expect(resolveOwnership(withCarveOut, p)).toBe('operator');
|
||||
const del = planPrune({ manifest: withCarveOut, targetPaths: LEDGER, sourcePaths: [] });
|
||||
expect(del).toEqual([]);
|
||||
});
|
||||
|
||||
it('the SHIPPED manifest reserves fleet/run/** so the ledger is operator-owned', () => {
|
||||
const shipped = loadManifest(FRAMEWORK_ROOT);
|
||||
for (const p of LEDGER) expect(resolveOwnership(shipped, p)).toBe('operator');
|
||||
// And it is structurally unreachable by pruning even if it were in a subtree.
|
||||
expect(planPrune({ manifest: shipped, targetPaths: LEDGER, sourcePaths: [] })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('frameworkSubtreeRoots', () => {
|
||||
it('returns only the /** subtree roots, not single-file entries', () => {
|
||||
const m = parseManifest(SAMPLE);
|
||||
|
||||
Reference in New Issue
Block a user