@remnic/core 9.3.653 → 9.3.655
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/access-cli.js +24 -24
- package/dist/access-http.d.ts +4 -4
- package/dist/access-http.js +17 -17
- package/dist/access-mcp.d.ts +4 -4
- package/dist/access-mcp.js +16 -16
- package/dist/access-schema.d.ts +12 -12
- package/dist/{access-service-CdJFd3_b.d.ts → access-service-BEJvriUt.d.ts} +11 -2
- package/dist/access-service.d.ts +4 -4
- package/dist/access-service.js +15 -15
- package/dist/action-confidence.d.ts +1 -1
- package/dist/active-memory-bridge.d.ts +1 -1
- package/dist/active-recall.d.ts +1 -1
- package/dist/active-recall.js +1 -1
- package/dist/behavior-learner.d.ts +1 -1
- package/dist/behavior-signals.d.ts +1 -1
- package/dist/bootstrap.d.ts +3 -3
- package/dist/briefing.d.ts +1 -1
- package/dist/briefing.js +3 -3
- package/dist/buffer-surprise-report.d.ts +1 -1
- package/dist/buffer.d.ts +1 -1
- package/dist/calibration.d.ts +1 -1
- package/dist/causal-behavior.d.ts +1 -1
- package/dist/causal-consolidation.d.ts +1 -1
- package/dist/causal-consolidation.js +4 -4
- package/dist/{chunk-GI45G4BK.js → chunk-2RCGZ67B.js} +4 -4
- package/dist/{chunk-BEMWL2FZ.js → chunk-54LOUIBE.js} +2 -2
- package/dist/{chunk-E3J6O6N7.js → chunk-55ZMNKMQ.js} +20 -9
- package/dist/{chunk-E3J6O6N7.js.map → chunk-55ZMNKMQ.js.map} +1 -1
- package/dist/{chunk-7WEB3FLJ.js → chunk-5PLUC5OB.js} +2 -2
- package/dist/{chunk-SPMZZUEJ.js → chunk-5QD3QD76.js} +2684 -401
- package/dist/chunk-5QD3QD76.js.map +1 -0
- package/dist/{chunk-WLGE6KEO.js → chunk-67G4T7KI.js} +3 -3
- package/dist/{chunk-JX2RINDR.js → chunk-6G5JEN55.js} +2 -2
- package/dist/{chunk-R3PQUPQ4.js → chunk-6IMKOIZ6.js} +85 -3
- package/dist/chunk-6IMKOIZ6.js.map +1 -0
- package/dist/{chunk-KJDKZVF3.js → chunk-A3Y37UWI.js} +3 -3
- package/dist/{chunk-CFOCZPIQ.js → chunk-BGKXTVNG.js} +2 -2
- package/dist/{chunk-QQHIQ7JD.js → chunk-COVZLGMR.js} +87 -18
- package/dist/chunk-COVZLGMR.js.map +1 -0
- package/dist/{chunk-JVRPJ7D4.js → chunk-EKQMQQ3U.js} +48 -12
- package/dist/chunk-EKQMQQ3U.js.map +1 -0
- package/dist/{chunk-H3PHZLMF.js → chunk-GKKAXVAJ.js} +20 -11
- package/dist/chunk-GKKAXVAJ.js.map +1 -0
- package/dist/{chunk-JBHXMCYN.js → chunk-GRYAECRV.js} +2 -2
- package/dist/{chunk-EHQLDFSH.js → chunk-IQ53ZSXV.js} +2 -2
- package/dist/{chunk-C63WC454.js → chunk-KOI765XP.js} +125 -1
- package/dist/chunk-KOI765XP.js.map +1 -0
- package/dist/{chunk-IVYSVAC6.js → chunk-KZZ4YAEC.js} +2 -2
- package/dist/{chunk-2DGQLOOM.js → chunk-M3VYPE2H.js} +1 -1
- package/dist/{chunk-2DGQLOOM.js.map → chunk-M3VYPE2H.js.map} +1 -1
- package/dist/{chunk-JF7SFXTG.js → chunk-NCSJKK23.js} +2 -2
- package/dist/{chunk-XMN6MMTU.js → chunk-NRBGRZW4.js} +2 -2
- package/dist/{chunk-NOBL7OUP.js → chunk-OKW6F5S5.js} +12 -5
- package/dist/{chunk-NOBL7OUP.js.map → chunk-OKW6F5S5.js.map} +1 -1
- package/dist/{chunk-BNFRL6QW.js → chunk-PTMJ2FH2.js} +2 -2
- package/dist/{chunk-KWM33SPU.js → chunk-PVE7KSQP.js} +2 -2
- package/dist/{chunk-EW52H5EM.js → chunk-QDVQ4AN2.js} +12 -5
- package/dist/chunk-QDVQ4AN2.js.map +1 -0
- package/dist/{chunk-PYWNNF2I.js → chunk-QRSKPI62.js} +99 -66
- package/dist/chunk-QRSKPI62.js.map +1 -0
- package/dist/{chunk-YM3LR4LS.js → chunk-SSSXWIBP.js} +5 -5
- package/dist/{chunk-C43KEWEV.js → chunk-TDZSSJV4.js} +1 -1
- package/dist/chunk-TDZSSJV4.js.map +1 -0
- package/dist/{chunk-Y7NWBBHV.js → chunk-TEO46GMM.js} +2 -2
- package/dist/{chunk-AJE7FJVE.js → chunk-UCEABZZN.js} +2 -2
- package/dist/{chunk-IENGGY2C.js → chunk-UCEDY5M7.js} +2 -2
- package/dist/{chunk-PRQXUSQV.js → chunk-UYNFWZWG.js} +2 -2
- package/dist/{chunk-V4UDXYGG.js → chunk-WDTUYOLS.js} +2 -2
- package/dist/{chunk-RZOBQ23O.js → chunk-XOFXKASO.js} +2 -2
- package/dist/chunk-XRKQOQLY.js +212 -0
- package/dist/chunk-XRKQOQLY.js.map +1 -0
- package/dist/{chunk-WTI35CVJ.js → chunk-YYN3LIYA.js} +5 -5
- package/dist/{cli-DDo7Qgs-.d.ts → cli-BGahB_d3.d.ts} +3 -3
- package/dist/cli.d.ts +5 -5
- package/dist/cli.js +29 -29
- package/dist/compounding/engine.d.ts +1 -1
- package/dist/compounding/engine.js +3 -3
- package/dist/compounding/preference-consolidator.d.ts +1 -1
- package/dist/compression-optimizer.d.ts +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.js +1 -1
- package/dist/connectors/codex-materialize-runner.d.ts +1 -1
- package/dist/connectors/codex-materialize-runner.js +3 -3
- package/dist/connectors/codex-materialize.d.ts +1 -1
- package/dist/connectors/index.d.ts +1 -1
- package/dist/connectors/index.js +3 -3
- package/dist/consolidation-provenance-check.d.ts +1 -1
- package/dist/consolidation-undo.d.ts +1 -1
- package/dist/contradiction/index.d.ts +19 -1
- package/dist/contradiction/index.js +1 -1
- package/dist/conversation-index/backend.d.ts +1 -1
- package/dist/conversation-index/chunker.d.ts +1 -1
- package/dist/conversation-index/faiss-adapter.d.ts +1 -1
- package/dist/conversation-index/indexer.d.ts +1 -1
- package/dist/conversation-index/search.d.ts +1 -1
- package/dist/day-summary.d.ts +1 -1
- package/dist/delinearize.d.ts +1 -1
- package/dist/direct-answer-wiring.d.ts +1 -1
- package/dist/direct-answer.d.ts +1 -1
- package/dist/embedding-fallback.d.ts +1 -1
- package/dist/enrichment/index.d.ts +1 -1
- package/dist/entity-retrieval.d.ts +1 -1
- package/dist/entity-retrieval.js +3 -3
- package/dist/entity-schema.d.ts +1 -1
- package/dist/explicit-capture.d.ts +3 -3
- package/dist/explicit-capture.js +1 -1
- package/dist/extraction-judge-telemetry.d.ts +1 -1
- package/dist/extraction-judge-training.d.ts +1 -1
- package/dist/extraction-judge.d.ts +1 -1
- package/dist/extraction.d.ts +1 -1
- package/dist/fallback-llm.d.ts +1 -1
- package/dist/identity-continuity.d.ts +1 -1
- package/dist/importance.d.ts +1 -1
- package/dist/index.d.ts +8 -8
- package/dist/index.js +37 -35
- package/dist/index.js.map +1 -1
- package/dist/intent.d.ts +1 -1
- package/dist/lcm/engine.d.ts +1 -1
- package/dist/lcm/index.d.ts +1 -1
- package/dist/lcm/tools.d.ts +1 -1
- package/dist/lifecycle.d.ts +1 -1
- package/dist/live-connectors-runner.d.ts +1 -1
- package/dist/local-llm.d.ts +1 -1
- package/dist/maintenance/memory-governance.d.ts +1 -1
- package/dist/maintenance/memory-governance.js +3 -3
- package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +3 -3
- package/dist/maintenance/rebuild-memory-projection.js +4 -4
- package/dist/mcp-memory-inspector-app.d.ts +4 -4
- package/dist/memory-action-policy.d.ts +1 -1
- package/dist/memory-cache.d.ts +1 -1
- package/dist/memory-lifecycle-ledger-utils.d.ts +1 -1
- package/dist/memory-projection-store.d.ts +1 -1
- package/dist/memory-provenance.d.ts +1 -1
- package/dist/memory-worth-outcomes.d.ts +1 -1
- package/dist/models-json.d.ts +1 -1
- package/dist/namespaces/migrate.d.ts +1 -1
- package/dist/namespaces/migrate.js +11 -11
- package/dist/namespaces/principal.d.ts +1 -1
- package/dist/namespaces/search.d.ts +15 -4
- package/dist/namespaces/search.js +7 -7
- package/dist/namespaces/storage.d.ts +52 -3
- package/dist/namespaces/storage.js +9 -5
- package/dist/native-knowledge.d.ts +1 -1
- package/dist/operator-toolkit.d.ts +1 -1
- package/dist/operator-toolkit.js +14 -14
- package/dist/{orchestrator-8fTZsa0y.d.ts → orchestrator-BgzZlWxH.d.ts} +500 -3
- package/dist/orchestrator.d.ts +3 -3
- package/dist/orchestrator.js +20 -20
- package/dist/patterns-cli.d.ts +1 -1
- package/dist/policy-runtime.d.ts +1 -1
- package/dist/qmd-recall-cache.d.ts +1 -1
- package/dist/qmd.d.ts +5 -1
- package/dist/qmd.js +2 -2
- package/dist/recall-disclosure-escalation.d.ts +1 -1
- package/dist/recall-explain-renderer.d.ts +1 -1
- package/dist/recall-explain-renderer.js +3 -3
- package/dist/recall-planner-llm.d.ts +1 -1
- package/dist/recall-state.d.ts +1 -1
- package/dist/recall-tag-filter.d.ts +1 -1
- package/dist/recall-xray-cli.d.ts +1 -1
- package/dist/recall-xray-cli.js +4 -4
- package/dist/recall-xray-renderer.d.ts +1 -1
- package/dist/recall-xray-renderer.js +3 -3
- package/dist/recall-xray.d.ts +1 -1
- package/dist/recall-xray.js +2 -2
- package/dist/{resolution-3SAP4SH2.js → resolution-IDTEBJFS.js} +2 -2
- package/dist/resolve-auth-token.d.ts +1 -1
- package/dist/resume-bundles.js +2 -2
- package/dist/retrieval-agents.d.ts +1 -1
- package/dist/retrieval-tiers.d.ts +1 -1
- package/dist/routing/engine.d.ts +1 -1
- package/dist/routing/store.d.ts +1 -1
- package/dist/schemas.d.ts +22 -22
- package/dist/search/embed-helper.d.ts +1 -1
- package/dist/search/factory.d.ts +1 -1
- package/dist/search/factory.js +6 -6
- package/dist/search/index.d.ts +1 -1
- package/dist/search/index.js +6 -6
- package/dist/search/lancedb-backend.d.ts +1 -1
- package/dist/search/lancedb-backend.js +2 -2
- package/dist/search/meilisearch-backend.d.ts +1 -1
- package/dist/search/meilisearch-backend.js +2 -2
- package/dist/search/noop-backend.d.ts +1 -1
- package/dist/search/orama-backend.d.ts +1 -1
- package/dist/search/orama-backend.js +2 -2
- package/dist/search/port.d.ts +17 -1
- package/dist/search/port.js +1 -1
- package/dist/search/remote-backend.d.ts +1 -1
- package/dist/{semantic-consolidation-DKdYzQOg.d.ts → semantic-consolidation-Z8d_uMq8.d.ts} +1 -1
- package/dist/semantic-consolidation.d.ts +2 -2
- package/dist/semantic-consolidation.js +4 -4
- package/dist/semantic-rule-promotion.js +3 -3
- package/dist/semantic-rule-verifier.d.ts +1 -1
- package/dist/semantic-rule-verifier.js +3 -3
- package/dist/session-observer-bands.d.ts +1 -1
- package/dist/session-observer-state.d.ts +1 -1
- package/dist/shared-context/manager.d.ts +1 -1
- package/dist/signal.d.ts +1 -1
- package/dist/storage.d.ts +1 -1
- package/dist/storage.js +2 -2
- package/dist/summarizer.d.ts +1 -1
- package/dist/summary-snapshot.d.ts +1 -1
- package/dist/temporal-supersession.d.ts +1 -1
- package/dist/temporal-validity.d.ts +1 -1
- package/dist/threading.d.ts +1 -1
- package/dist/tier-migration.d.ts +1 -1
- package/dist/tier-routing.d.ts +1 -1
- package/dist/topics.d.ts +1 -1
- package/dist/transcript.d.ts +1 -1
- package/dist/transfer/types.d.ts +12 -12
- package/dist/{types-D8yUmSik.d.ts → types-2OPlQWJG.d.ts} +23 -0
- package/dist/types.d.ts +1 -1
- package/dist/types.js +1 -1
- package/dist/utility-runtime.d.ts +1 -1
- package/dist/verified-recall.js +3 -3
- package/package.json +1 -1
- package/src/access-http.ts +7 -0
- package/src/access-mcp.ts +7 -0
- package/src/access-service.ts +12 -0
- package/src/cli.ts +104 -0
- package/src/config.test.ts +109 -0
- package/src/config.ts +164 -0
- package/src/contradiction/contradiction.test.ts +284 -0
- package/src/contradiction/resolution.ts +151 -4
- package/src/explicit-capture.ts +31 -10
- package/src/index.ts +10 -0
- package/src/maintenance/namespace-planner.test.ts +1120 -0
- package/src/maintenance/namespace-planner.ts +893 -0
- package/src/namespaces/catalog.test.ts +3356 -0
- package/src/namespaces/catalog.ts +2123 -0
- package/src/namespaces/search.test.ts +130 -2
- package/src/namespaces/search.ts +71 -10
- package/src/namespaces/storage.ts +210 -30
- package/src/orchestrator-flush.test.ts +720 -0
- package/src/orchestrator.ts +881 -239
- package/src/qmd-client.test.ts +59 -0
- package/src/qmd.ts +124 -84
- package/src/search/port.ts +16 -0
- package/src/types.ts +23 -0
- package/dist/chunk-C43KEWEV.js.map +0 -1
- package/dist/chunk-C63WC454.js.map +0 -1
- package/dist/chunk-EW52H5EM.js.map +0 -1
- package/dist/chunk-H3PHZLMF.js.map +0 -1
- package/dist/chunk-JVRPJ7D4.js.map +0 -1
- package/dist/chunk-ORGWWNJG.js +0 -131
- package/dist/chunk-ORGWWNJG.js.map +0 -1
- package/dist/chunk-PYWNNF2I.js.map +0 -1
- package/dist/chunk-QQHIQ7JD.js.map +0 -1
- package/dist/chunk-R3PQUPQ4.js.map +0 -1
- package/dist/chunk-SPMZZUEJ.js.map +0 -1
- /package/dist/{chunk-GI45G4BK.js.map → chunk-2RCGZ67B.js.map} +0 -0
- /package/dist/{chunk-BEMWL2FZ.js.map → chunk-54LOUIBE.js.map} +0 -0
- /package/dist/{chunk-7WEB3FLJ.js.map → chunk-5PLUC5OB.js.map} +0 -0
- /package/dist/{chunk-WLGE6KEO.js.map → chunk-67G4T7KI.js.map} +0 -0
- /package/dist/{chunk-JX2RINDR.js.map → chunk-6G5JEN55.js.map} +0 -0
- /package/dist/{chunk-KJDKZVF3.js.map → chunk-A3Y37UWI.js.map} +0 -0
- /package/dist/{chunk-CFOCZPIQ.js.map → chunk-BGKXTVNG.js.map} +0 -0
- /package/dist/{chunk-JBHXMCYN.js.map → chunk-GRYAECRV.js.map} +0 -0
- /package/dist/{chunk-EHQLDFSH.js.map → chunk-IQ53ZSXV.js.map} +0 -0
- /package/dist/{chunk-IVYSVAC6.js.map → chunk-KZZ4YAEC.js.map} +0 -0
- /package/dist/{chunk-JF7SFXTG.js.map → chunk-NCSJKK23.js.map} +0 -0
- /package/dist/{chunk-XMN6MMTU.js.map → chunk-NRBGRZW4.js.map} +0 -0
- /package/dist/{chunk-BNFRL6QW.js.map → chunk-PTMJ2FH2.js.map} +0 -0
- /package/dist/{chunk-KWM33SPU.js.map → chunk-PVE7KSQP.js.map} +0 -0
- /package/dist/{chunk-YM3LR4LS.js.map → chunk-SSSXWIBP.js.map} +0 -0
- /package/dist/{chunk-Y7NWBBHV.js.map → chunk-TEO46GMM.js.map} +0 -0
- /package/dist/{chunk-AJE7FJVE.js.map → chunk-UCEABZZN.js.map} +0 -0
- /package/dist/{chunk-IENGGY2C.js.map → chunk-UCEDY5M7.js.map} +0 -0
- /package/dist/{chunk-PRQXUSQV.js.map → chunk-UYNFWZWG.js.map} +0 -0
- /package/dist/{chunk-V4UDXYGG.js.map → chunk-WDTUYOLS.js.map} +0 -0
- /package/dist/{chunk-RZOBQ23O.js.map → chunk-XOFXKASO.js.map} +0 -0
- /package/dist/{chunk-WTI35CVJ.js.map → chunk-YYN3LIYA.js.map} +0 -0
- /package/dist/{resolution-3SAP4SH2.js.map → resolution-IDTEBJFS.js.map} +0 -0
|
@@ -0,0 +1,3356 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdir, mkdtemp, readFile, realpath, rm, stat, symlink, utimes, writeFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import type { PluginConfig } from "../types.js";
|
|
7
|
+
import { namespaceIdentityFromToken, namespaceIdentityToken } from "./identity.js";
|
|
8
|
+
import { NamespaceCatalog } from "./catalog.js";
|
|
9
|
+
import {
|
|
10
|
+
NamespaceStorageRouter,
|
|
11
|
+
resolveDefaultNamespaceRoot,
|
|
12
|
+
resolveNamespaceStorageRoot,
|
|
13
|
+
} from "./storage.js";
|
|
14
|
+
|
|
15
|
+
function makeConfig(memoryDir: string, overrides: Partial<PluginConfig> = {}): PluginConfig {
|
|
16
|
+
return {
|
|
17
|
+
memoryDir,
|
|
18
|
+
namespacesEnabled: true,
|
|
19
|
+
namespaceCatalogEnabled: true,
|
|
20
|
+
defaultNamespace: "default",
|
|
21
|
+
sharedNamespace: "shared",
|
|
22
|
+
namespacePolicies: [],
|
|
23
|
+
qmdCollection: "remnic",
|
|
24
|
+
entitySchemas: {},
|
|
25
|
+
inlineSourceAttributionFormat: undefined,
|
|
26
|
+
...overrides,
|
|
27
|
+
} as unknown as PluginConfig;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function mkMemoryDir(): Promise<string> {
|
|
31
|
+
return await mkdtemp(path.join(os.tmpdir(), "remnic-ns-catalog-"));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
test("markWrite registers a dynamic project namespace in the catalog", async () => {
|
|
35
|
+
const memoryDir = await mkMemoryDir();
|
|
36
|
+
try {
|
|
37
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
38
|
+
await catalog.markWrite("project-origin-abc123", { discoveredBy: "write" });
|
|
39
|
+
|
|
40
|
+
const record = await catalog.getNamespaceRecord("project-origin-abc123");
|
|
41
|
+
assert.ok(record, "expected record to exist");
|
|
42
|
+
assert.equal(record?.namespace, "project-origin-abc123");
|
|
43
|
+
assert.equal(record?.kind, "project");
|
|
44
|
+
assert.ok(record?.lastWriteAt, "expected lastWriteAt to be set");
|
|
45
|
+
assert.equal(record?.discoveredBy, "write");
|
|
46
|
+
assert.ok(record?.storageDir.includes("namespaces"));
|
|
47
|
+
} finally {
|
|
48
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("configured default and shared namespaces appear in the catalog after register", async () => {
|
|
53
|
+
const memoryDir = await mkMemoryDir();
|
|
54
|
+
try {
|
|
55
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
56
|
+
await catalog.registerConfiguredNamespaces();
|
|
57
|
+
|
|
58
|
+
const list = await catalog.listNamespaces();
|
|
59
|
+
assert.ok(list.some((r) => r.namespace === "default" && r.kind === "default"));
|
|
60
|
+
assert.ok(list.some((r) => r.namespace === "shared" && r.kind === "shared"));
|
|
61
|
+
} finally {
|
|
62
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("rebuildFromDisk finds existing tokenized namespace directories", async () => {
|
|
67
|
+
const memoryDir = await mkMemoryDir();
|
|
68
|
+
try {
|
|
69
|
+
const ns = "team-pi-project-origin-abc123";
|
|
70
|
+
await mkdir(path.join(memoryDir, "namespaces", namespaceIdentityToken(ns), "facts"), {
|
|
71
|
+
recursive: true,
|
|
72
|
+
});
|
|
73
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
74
|
+
const result = await catalog.rebuildFromDisk();
|
|
75
|
+
|
|
76
|
+
assert.equal(result.dryRun, false);
|
|
77
|
+
assert.ok(result.records.some((r) => r.namespace === ns));
|
|
78
|
+
const fromDisk = result.records.find((r) => r.namespace === ns);
|
|
79
|
+
assert.equal(fromDisk?.kind, "team-project");
|
|
80
|
+
assert.equal(fromDisk?.discoveredBy, "scan");
|
|
81
|
+
|
|
82
|
+
// Persisted: a fresh catalog reads it back.
|
|
83
|
+
const reloaded = new NamespaceCatalog(makeConfig(memoryDir));
|
|
84
|
+
const record = await reloaded.getNamespaceRecord(ns);
|
|
85
|
+
assert.ok(record, "expected rebuilt record to persist");
|
|
86
|
+
} finally {
|
|
87
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("rebuildFromDisk dry-run does not write the catalog file", async () => {
|
|
92
|
+
const memoryDir = await mkMemoryDir();
|
|
93
|
+
try {
|
|
94
|
+
await mkdir(path.join(memoryDir, "namespaces", namespaceIdentityToken("alpha"), "facts"), {
|
|
95
|
+
recursive: true,
|
|
96
|
+
});
|
|
97
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
98
|
+
const result = await catalog.rebuildFromDisk({ dryRun: true });
|
|
99
|
+
assert.equal(result.dryRun, true);
|
|
100
|
+
assert.ok(result.records.some((r) => r.namespace === "alpha"));
|
|
101
|
+
|
|
102
|
+
const catalogFile = path.join(memoryDir, "state", "namespaces.jsonl");
|
|
103
|
+
let exists = true;
|
|
104
|
+
try {
|
|
105
|
+
await readFile(catalogFile, "utf8");
|
|
106
|
+
} catch {
|
|
107
|
+
exists = false;
|
|
108
|
+
}
|
|
109
|
+
assert.equal(exists, false, "dry-run must not write the catalog file");
|
|
110
|
+
} finally {
|
|
111
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("rebuildFromDisk preserves the legacy default root (memoryDir) compatibility case", async () => {
|
|
116
|
+
const memoryDir = await mkMemoryDir();
|
|
117
|
+
try {
|
|
118
|
+
// Legacy layout: facts live directly in memoryDir, no namespaces/ subdir.
|
|
119
|
+
await mkdir(path.join(memoryDir, "facts"), { recursive: true });
|
|
120
|
+
await writeFile(path.join(memoryDir, "facts", "f1.md"), "# synthetic\n", "utf8");
|
|
121
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
122
|
+
const result = await catalog.rebuildFromDisk();
|
|
123
|
+
|
|
124
|
+
const def = result.records.find((r) => r.namespace === "default");
|
|
125
|
+
assert.ok(def, "expected default namespace record");
|
|
126
|
+
assert.equal(def?.kind, "default");
|
|
127
|
+
// Legacy default root resolves to memoryDir itself, not a tokenized dir.
|
|
128
|
+
assert.equal(def?.storageDir, memoryDir);
|
|
129
|
+
} finally {
|
|
130
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("rebuildFromDisk reports symlinked namespace roots instead of trusting them", async () => {
|
|
135
|
+
const memoryDir = await mkMemoryDir();
|
|
136
|
+
const outside = await mkMemoryDir();
|
|
137
|
+
try {
|
|
138
|
+
await mkdir(path.join(memoryDir, "namespaces"), { recursive: true });
|
|
139
|
+
await mkdir(path.join(outside, "secret"), { recursive: true });
|
|
140
|
+
const linkPath = path.join(memoryDir, "namespaces", namespaceIdentityToken("evil"));
|
|
141
|
+
try {
|
|
142
|
+
await symlink(outside, linkPath, "dir");
|
|
143
|
+
} catch {
|
|
144
|
+
// Some CI environments disallow symlinks; skip gracefully.
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
149
|
+
const result = await catalog.rebuildFromDisk();
|
|
150
|
+
assert.ok(
|
|
151
|
+
result.skipped.some((s) => s.reason === "symlink"),
|
|
152
|
+
"expected symlinked root to be reported as skipped",
|
|
153
|
+
);
|
|
154
|
+
assert.ok(
|
|
155
|
+
!result.records.some((r) => r.storageDir.startsWith(outside)),
|
|
156
|
+
"must not catalog a root that escapes memoryDir",
|
|
157
|
+
);
|
|
158
|
+
} finally {
|
|
159
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
160
|
+
await rm(outside, { recursive: true, force: true });
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("rebuildFromDisk rejects roots with malformed category markers even when a sibling marker is valid", async () => {
|
|
165
|
+
const memoryDir = await mkMemoryDir();
|
|
166
|
+
try {
|
|
167
|
+
const ns = "project-origin-bad-category-marker";
|
|
168
|
+
const token = namespaceIdentityToken(ns);
|
|
169
|
+
const tokenDir = path.join(memoryDir, "namespaces", token);
|
|
170
|
+
await mkdir(tokenDir, { recursive: true });
|
|
171
|
+
await writeFile(path.join(tokenDir, "facts"), "not a directory", "utf8");
|
|
172
|
+
await mkdir(path.join(tokenDir, "state"), { recursive: true });
|
|
173
|
+
|
|
174
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
175
|
+
const result = await catalog.rebuildFromDisk();
|
|
176
|
+
|
|
177
|
+
assert.ok(
|
|
178
|
+
!result.records.some((r) => r.namespace === ns),
|
|
179
|
+
"a root with a malformed scan category marker must not be catalogued",
|
|
180
|
+
);
|
|
181
|
+
assert.ok(
|
|
182
|
+
result.skipped.some(
|
|
183
|
+
(s) =>
|
|
184
|
+
s.token === token &&
|
|
185
|
+
s.reason === "unsafe" &&
|
|
186
|
+
s.detail?.includes("facts: expected directory"),
|
|
187
|
+
),
|
|
188
|
+
"the malformed category marker must be reported as an unsafe skipped root",
|
|
189
|
+
);
|
|
190
|
+
} finally {
|
|
191
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// ── Round 8 (codex P2 — NE9K_): the `namespaces` SCAN ROOT itself must be
|
|
196
|
+
// containment-checked BEFORE `readdir` follows it. If `<memoryDir>/namespaces` is
|
|
197
|
+
// a symlink to an outside tree, readdir would enumerate that arbitrary tree
|
|
198
|
+
// (leaking names / spending time on a huge dir) before the per-entry lstat checks
|
|
199
|
+
// run. rebuild must NOT read a symlinked scan root: it reports it as one skipped
|
|
200
|
+
// unsafe root and catalogs none of the outside entries.
|
|
201
|
+
test("rebuildFromDisk rejects a symlinked namespaces scan root without enumerating it", async () => {
|
|
202
|
+
const memoryDir = await mkMemoryDir();
|
|
203
|
+
const outside = await mkMemoryDir();
|
|
204
|
+
try {
|
|
205
|
+
// The outside tree contains tokenized namespace dirs WITH data that would be
|
|
206
|
+
// catalogued if the symlinked root were followed.
|
|
207
|
+
const leakedNs = "project-origin-leaked";
|
|
208
|
+
await mkdir(path.join(outside, namespaceIdentityToken(leakedNs), "facts"), { recursive: true });
|
|
209
|
+
// `<memoryDir>/namespaces` IS a symlink to the outside tree.
|
|
210
|
+
try {
|
|
211
|
+
await symlink(outside, path.join(memoryDir, "namespaces"), "dir");
|
|
212
|
+
} catch {
|
|
213
|
+
// Some CI environments disallow symlinks; skip gracefully.
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
218
|
+
const result = await catalog.rebuildFromDisk();
|
|
219
|
+
|
|
220
|
+
// The outside namespace must NOT be enumerated/catalogued.
|
|
221
|
+
assert.ok(
|
|
222
|
+
!result.records.some((r) => r.namespace === leakedNs),
|
|
223
|
+
"a symlinked namespaces scan root must not be enumerated into the catalog",
|
|
224
|
+
);
|
|
225
|
+
assert.ok(
|
|
226
|
+
!result.records.some((r) => r.storageDir.startsWith(outside)),
|
|
227
|
+
"no record may point at a storageDir inside the symlinked-out scan root",
|
|
228
|
+
);
|
|
229
|
+
// The symlinked root is reported as one skipped unsafe root.
|
|
230
|
+
assert.ok(
|
|
231
|
+
result.skipped.some((s) => s.token === "namespaces" && s.reason === "symlink"),
|
|
232
|
+
"the symlinked namespaces scan root must be reported as skipped",
|
|
233
|
+
);
|
|
234
|
+
} finally {
|
|
235
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
236
|
+
await rm(outside, { recursive: true, force: true });
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("unsafe namespace tokens are rejected by mark APIs", async () => {
|
|
241
|
+
const memoryDir = await mkMemoryDir();
|
|
242
|
+
try {
|
|
243
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
244
|
+
await assert.rejects(() => catalog.markWrite("../escape"));
|
|
245
|
+
await assert.rejects(() => catalog.markRead("a/b"));
|
|
246
|
+
} finally {
|
|
247
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("markRead/markWrite/markMaintenance update only metadata, never content", async () => {
|
|
252
|
+
const memoryDir = await mkMemoryDir();
|
|
253
|
+
try {
|
|
254
|
+
// Place a synthetic memory file we can assert is untouched.
|
|
255
|
+
const factDir = path.join(memoryDir, "namespaces", namespaceIdentityToken("alpha"), "facts");
|
|
256
|
+
await mkdir(factDir, { recursive: true });
|
|
257
|
+
const factPath = path.join(factDir, "f1.md");
|
|
258
|
+
await writeFile(factPath, "# synthetic fact\nbody\n", "utf8");
|
|
259
|
+
const before = await readFile(factPath, "utf8");
|
|
260
|
+
|
|
261
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
262
|
+
await catalog.markRead("alpha");
|
|
263
|
+
await catalog.markWrite("alpha");
|
|
264
|
+
await catalog.markMaintenance("alpha", "dreams");
|
|
265
|
+
|
|
266
|
+
const after = await readFile(factPath, "utf8");
|
|
267
|
+
assert.equal(after, before, "memory content must not be modified by catalog touches");
|
|
268
|
+
|
|
269
|
+
const record = await catalog.getNamespaceRecord("alpha");
|
|
270
|
+
assert.ok(record?.lastReadAt);
|
|
271
|
+
assert.ok(record?.lastWriteAt);
|
|
272
|
+
assert.ok(record?.lastMaintenanceAt?.dreams);
|
|
273
|
+
|
|
274
|
+
// Catalog file must contain only metadata, never the fact body.
|
|
275
|
+
const raw = await readFile(path.join(memoryDir, "state", "namespaces.jsonl"), "utf8");
|
|
276
|
+
assert.ok(!raw.includes("synthetic fact"));
|
|
277
|
+
assert.ok(!raw.includes("body"));
|
|
278
|
+
} finally {
|
|
279
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("rebuildFromDisk is idempotent", async () => {
|
|
284
|
+
const memoryDir = await mkMemoryDir();
|
|
285
|
+
try {
|
|
286
|
+
await mkdir(path.join(memoryDir, "namespaces", namespaceIdentityToken("alpha"), "facts"), {
|
|
287
|
+
recursive: true,
|
|
288
|
+
});
|
|
289
|
+
await mkdir(path.join(memoryDir, "namespaces", namespaceIdentityToken("beta"), "entities"), {
|
|
290
|
+
recursive: true,
|
|
291
|
+
});
|
|
292
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
293
|
+
const first = await catalog.rebuildFromDisk();
|
|
294
|
+
const firstRaw = await readFile(path.join(memoryDir, "state", "namespaces.jsonl"), "utf8");
|
|
295
|
+
|
|
296
|
+
const catalog2 = new NamespaceCatalog(makeConfig(memoryDir));
|
|
297
|
+
const second = await catalog2.rebuildFromDisk();
|
|
298
|
+
const secondRaw = await readFile(path.join(memoryDir, "state", "namespaces.jsonl"), "utf8");
|
|
299
|
+
|
|
300
|
+
const names = (recs: { namespace: string }[]) => recs.map((r) => r.namespace).sort();
|
|
301
|
+
assert.deepEqual(names(first.records), names(second.records));
|
|
302
|
+
assert.equal(firstRaw, secondRaw, "rebuild output must be byte-identical when run twice");
|
|
303
|
+
} finally {
|
|
304
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("catalog records can be consumed by a fake maintenance scheduler", async () => {
|
|
309
|
+
const memoryDir = await mkMemoryDir();
|
|
310
|
+
try {
|
|
311
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
312
|
+
await catalog.markWrite("project-origin-abc123");
|
|
313
|
+
await catalog.markWrite("shared");
|
|
314
|
+
|
|
315
|
+
// Fake scheduler: fan out a job over all catalog namespaces and record it.
|
|
316
|
+
const records = await catalog.listNamespaces();
|
|
317
|
+
const jobName = "compaction";
|
|
318
|
+
for (const r of records) {
|
|
319
|
+
await catalog.markMaintenance(r.namespace, jobName);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const after = await catalog.listNamespaces();
|
|
323
|
+
assert.ok(after.length >= 2);
|
|
324
|
+
for (const r of after) {
|
|
325
|
+
assert.ok(r.lastMaintenanceAt?.[jobName], `expected ${r.namespace} to have maintenance ts`);
|
|
326
|
+
}
|
|
327
|
+
} finally {
|
|
328
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test("listNamespaces supports filtering by kind and discoveredBy", async () => {
|
|
333
|
+
const memoryDir = await mkMemoryDir();
|
|
334
|
+
try {
|
|
335
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
336
|
+
await catalog.registerConfiguredNamespaces();
|
|
337
|
+
await catalog.markWrite("project-origin-abc123", { discoveredBy: "write" });
|
|
338
|
+
|
|
339
|
+
const projects = await catalog.listNamespaces({ kind: "project" });
|
|
340
|
+
assert.ok(projects.every((r) => r.kind === "project"));
|
|
341
|
+
assert.ok(projects.some((r) => r.namespace === "project-origin-abc123"));
|
|
342
|
+
|
|
343
|
+
const written = await catalog.listNamespaces({ discoveredBy: "write" });
|
|
344
|
+
assert.ok(written.every((r) => r.discoveredBy === "write"));
|
|
345
|
+
} finally {
|
|
346
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("catalog is inert (no-op) when namespaces are disabled", async () => {
|
|
351
|
+
const memoryDir = await mkMemoryDir();
|
|
352
|
+
try {
|
|
353
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir, { namespacesEnabled: false }));
|
|
354
|
+
await catalog.markWrite("project-origin-abc123");
|
|
355
|
+
await catalog.markRead("shared");
|
|
356
|
+
await catalog.registerConfiguredNamespaces();
|
|
357
|
+
|
|
358
|
+
const list = await catalog.listNamespaces();
|
|
359
|
+
assert.deepEqual(list, [], "disabled catalog must enumerate nothing");
|
|
360
|
+
|
|
361
|
+
// No catalog file should be created when disabled.
|
|
362
|
+
let exists = true;
|
|
363
|
+
try {
|
|
364
|
+
await readFile(path.join(memoryDir, "state", "namespaces.jsonl"), "utf8");
|
|
365
|
+
} catch {
|
|
366
|
+
exists = false;
|
|
367
|
+
}
|
|
368
|
+
assert.equal(exists, false, "disabled catalog must not write to disk");
|
|
369
|
+
} finally {
|
|
370
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test("catalog tolerates corrupt / non-object JSONL lines on read", async () => {
|
|
375
|
+
const memoryDir = await mkMemoryDir();
|
|
376
|
+
try {
|
|
377
|
+
const stateDir = path.join(memoryDir, "state");
|
|
378
|
+
await mkdir(stateDir, { recursive: true });
|
|
379
|
+
const file = path.join(stateDir, "namespaces.jsonl");
|
|
380
|
+
// Mix of garbage, JSON null, valid record, and a valid record missing fields.
|
|
381
|
+
const validToken = namespaceIdentityToken("alpha");
|
|
382
|
+
const lines = [
|
|
383
|
+
"not json at all",
|
|
384
|
+
"null",
|
|
385
|
+
"123",
|
|
386
|
+
JSON.stringify({ namespace: "" }), // invalid: empty namespace
|
|
387
|
+
JSON.stringify({
|
|
388
|
+
namespace: "alpha",
|
|
389
|
+
identityToken: validToken,
|
|
390
|
+
kind: "explicit",
|
|
391
|
+
createdAt: new Date().toISOString(),
|
|
392
|
+
storageDir: path.join(memoryDir, "namespaces", validToken),
|
|
393
|
+
discoveredBy: "write",
|
|
394
|
+
}),
|
|
395
|
+
];
|
|
396
|
+
await writeFile(file, lines.join("\n") + "\n", "utf8");
|
|
397
|
+
|
|
398
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
399
|
+
const list = await catalog.listNamespaces();
|
|
400
|
+
assert.equal(list.length, 1);
|
|
401
|
+
assert.equal(list[0]?.namespace, "alpha");
|
|
402
|
+
} finally {
|
|
403
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test("catalog quarantines malformed JSONL record fields at the parse boundary", async () => {
|
|
408
|
+
const memoryDir = await mkMemoryDir();
|
|
409
|
+
try {
|
|
410
|
+
const stateDir = path.join(memoryDir, "state");
|
|
411
|
+
await mkdir(stateDir, { recursive: true });
|
|
412
|
+
const file = path.join(stateDir, "namespaces.jsonl");
|
|
413
|
+
const now = new Date().toISOString();
|
|
414
|
+
const validToken = namespaceIdentityToken("valid");
|
|
415
|
+
const lines = [
|
|
416
|
+
JSON.stringify({
|
|
417
|
+
namespace: "bad-kind",
|
|
418
|
+
identityToken: namespaceIdentityToken("bad-kind"),
|
|
419
|
+
kind: "bogus",
|
|
420
|
+
createdAt: now,
|
|
421
|
+
storageDir: path.join(memoryDir, "namespaces", namespaceIdentityToken("bad-kind")),
|
|
422
|
+
discoveredBy: "write",
|
|
423
|
+
}),
|
|
424
|
+
JSON.stringify({
|
|
425
|
+
namespace: "bad-source",
|
|
426
|
+
identityToken: namespaceIdentityToken("bad-source"),
|
|
427
|
+
kind: "explicit",
|
|
428
|
+
createdAt: now,
|
|
429
|
+
storageDir: path.join(memoryDir, "namespaces", namespaceIdentityToken("bad-source")),
|
|
430
|
+
discoveredBy: "telepathy",
|
|
431
|
+
}),
|
|
432
|
+
JSON.stringify({
|
|
433
|
+
namespace: "bad-token",
|
|
434
|
+
identityToken: namespaceIdentityToken("other"),
|
|
435
|
+
kind: "explicit",
|
|
436
|
+
createdAt: now,
|
|
437
|
+
storageDir: path.join(memoryDir, "namespaces", namespaceIdentityToken("bad-token")),
|
|
438
|
+
discoveredBy: "write",
|
|
439
|
+
}),
|
|
440
|
+
JSON.stringify({
|
|
441
|
+
namespace: "bad-created",
|
|
442
|
+
identityToken: namespaceIdentityToken("bad-created"),
|
|
443
|
+
kind: "explicit",
|
|
444
|
+
createdAt: "not-a-date",
|
|
445
|
+
storageDir: path.join(memoryDir, "namespaces", namespaceIdentityToken("bad-created")),
|
|
446
|
+
discoveredBy: "write",
|
|
447
|
+
}),
|
|
448
|
+
JSON.stringify({
|
|
449
|
+
namespace: " valid ",
|
|
450
|
+
identityToken: validToken,
|
|
451
|
+
kind: "project",
|
|
452
|
+
createdAt: now,
|
|
453
|
+
storageDir: path.join(memoryDir, "namespaces", validToken),
|
|
454
|
+
discoveredBy: "write",
|
|
455
|
+
lastReadAt: "not-a-date",
|
|
456
|
+
lastWriteAt: "not-a-date",
|
|
457
|
+
lastMaintenanceAt: {
|
|
458
|
+
bad: "not-a-date",
|
|
459
|
+
ok: now,
|
|
460
|
+
},
|
|
461
|
+
}),
|
|
462
|
+
];
|
|
463
|
+
await writeFile(file, lines.join("\n") + "\n", "utf8");
|
|
464
|
+
|
|
465
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
466
|
+
const list = await catalog.listNamespaces();
|
|
467
|
+
|
|
468
|
+
assert.deepEqual(
|
|
469
|
+
list.map((r) => r.namespace),
|
|
470
|
+
["valid"],
|
|
471
|
+
"invalid enum/token/timestamp records must not surface",
|
|
472
|
+
);
|
|
473
|
+
assert.equal(list[0]?.identityToken, validToken);
|
|
474
|
+
assert.equal(list[0]?.kind, "project");
|
|
475
|
+
assert.equal(list[0]?.discoveredBy, "write");
|
|
476
|
+
assert.equal(list[0]?.lastReadAt, undefined);
|
|
477
|
+
assert.equal(list[0]?.lastWriteAt, undefined);
|
|
478
|
+
assert.deepEqual(list[0]?.lastMaintenanceAt, { ok: now });
|
|
479
|
+
} finally {
|
|
480
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
test("StorageRouter integration: catalog registers namespace on storageFor", async () => {
|
|
485
|
+
const memoryDir = await mkMemoryDir();
|
|
486
|
+
try {
|
|
487
|
+
const config = makeConfig(memoryDir);
|
|
488
|
+
const catalog = new NamespaceCatalog(config);
|
|
489
|
+
const router = new NamespaceStorageRouter(config, {
|
|
490
|
+
onResolve: (namespace, storageDir) => {
|
|
491
|
+
void catalog.registerResolved(namespace, storageDir);
|
|
492
|
+
},
|
|
493
|
+
});
|
|
494
|
+
await router.storageFor("project-origin-abc123");
|
|
495
|
+
// allow the fire-and-forget registration to settle
|
|
496
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
497
|
+
|
|
498
|
+
const record = await catalog.getNamespaceRecord("project-origin-abc123");
|
|
499
|
+
assert.ok(record, "storageFor should have registered the namespace");
|
|
500
|
+
assert.equal(record?.discoveredBy, "config");
|
|
501
|
+
} finally {
|
|
502
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
// ── Issue 1 (cursor[bot]): the register/resolve hook must NOT clobber prior
|
|
507
|
+
// provenance on an existing record. A namespace first discovered via a `write`
|
|
508
|
+
// touch must keep discoveredBy:"write" even after later routing resolves fire
|
|
509
|
+
// the `config` register hook (including on router cache hits).
|
|
510
|
+
test("register does not overwrite prior discoveredBy on an existing record", async () => {
|
|
511
|
+
const memoryDir = await mkMemoryDir();
|
|
512
|
+
try {
|
|
513
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
514
|
+
// First seen via a write — provenance is "write".
|
|
515
|
+
await catalog.markWrite("project-origin-abc123", { discoveredBy: "write" });
|
|
516
|
+
const afterWrite = await catalog.getNamespaceRecord("project-origin-abc123");
|
|
517
|
+
assert.equal(afterWrite?.discoveredBy, "write");
|
|
518
|
+
const createdAt = afterWrite?.createdAt;
|
|
519
|
+
|
|
520
|
+
// A later routing resolve fires the register hook with discoveredBy:"config".
|
|
521
|
+
await catalog.registerResolved(
|
|
522
|
+
"project-origin-abc123",
|
|
523
|
+
path.join(memoryDir, "namespaces", namespaceIdentityToken("project-origin-abc123")),
|
|
524
|
+
);
|
|
525
|
+
|
|
526
|
+
const afterRegister = await catalog.getNamespaceRecord("project-origin-abc123");
|
|
527
|
+
assert.equal(
|
|
528
|
+
afterRegister?.discoveredBy,
|
|
529
|
+
"write",
|
|
530
|
+
"register must preserve prior write provenance, not reset it to config",
|
|
531
|
+
);
|
|
532
|
+
assert.equal(afterRegister?.createdAt, createdAt, "createdAt is creation-only and must be preserved");
|
|
533
|
+
// The write touch field is still present (register does not erase it).
|
|
534
|
+
assert.ok(afterRegister?.lastWriteAt, "lastWriteAt must survive the register touch");
|
|
535
|
+
} finally {
|
|
536
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
// A record first PRE-REGISTERED via the router's onResolve hook (config) is
|
|
541
|
+
// UPGRADED to "write" by a later real write touch (round 6, codex P2 — NBPmT):
|
|
542
|
+
// `storageFor()` fires registerResolved (config) before recordCatalogWrite runs,
|
|
543
|
+
// so without the upgrade `listNamespaces({ discoveredBy: "write" })` would miss a
|
|
544
|
+
// genuinely-written namespace. A non-write touch (read/register) still preserves
|
|
545
|
+
// provenance.
|
|
546
|
+
test("a write upgrades a config pre-registration to write provenance (NBPmT)", async () => {
|
|
547
|
+
const memoryDir = await mkMemoryDir();
|
|
548
|
+
try {
|
|
549
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
550
|
+
await catalog.registerResolved(
|
|
551
|
+
"project-origin-xyz",
|
|
552
|
+
path.join(memoryDir, "namespaces", namespaceIdentityToken("project-origin-xyz")),
|
|
553
|
+
);
|
|
554
|
+
assert.equal((await catalog.getNamespaceRecord("project-origin-xyz"))?.discoveredBy, "config");
|
|
555
|
+
|
|
556
|
+
// A later read touch must NOT relabel the config pre-registration.
|
|
557
|
+
await catalog.markRead("project-origin-xyz", { discoveredBy: "read" });
|
|
558
|
+
assert.equal(
|
|
559
|
+
(await catalog.getNamespaceRecord("project-origin-xyz"))?.discoveredBy,
|
|
560
|
+
"config",
|
|
561
|
+
"a read touch preserves config provenance (only a write upgrades it)",
|
|
562
|
+
);
|
|
563
|
+
|
|
564
|
+
// A real write touch UPGRADES the config pre-registration to "write".
|
|
565
|
+
await catalog.markWrite("project-origin-xyz", { discoveredBy: "write" });
|
|
566
|
+
const after = await catalog.getNamespaceRecord("project-origin-xyz");
|
|
567
|
+
assert.equal(
|
|
568
|
+
after?.discoveredBy,
|
|
569
|
+
"write",
|
|
570
|
+
"a real write upgrades a config-only pre-registration to write provenance",
|
|
571
|
+
);
|
|
572
|
+
assert.ok(after?.lastWriteAt, "write touch still records lastWriteAt");
|
|
573
|
+
// listNamespaces({ discoveredBy: "write" }) now finds the written namespace.
|
|
574
|
+
const writeList = await catalog.listNamespaces({ discoveredBy: "write" });
|
|
575
|
+
assert.ok(
|
|
576
|
+
writeList.some((r) => r.namespace === "project-origin-xyz"),
|
|
577
|
+
"a written namespace must be discoverable by discoveredBy:write filter",
|
|
578
|
+
);
|
|
579
|
+
} finally {
|
|
580
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
// ── Issue 4 (codex P2): concurrent fire-and-forget touches for the same
|
|
585
|
+
// namespace must each read the latest record inside the serialized chain and
|
|
586
|
+
// merge their fields — none may be dropped by a racing append winning
|
|
587
|
+
// compaction. Fire many touches without awaiting between them, then assert the
|
|
588
|
+
// final compacted record retains read + write + maintenance + register fields.
|
|
589
|
+
test("concurrent touches on one namespace preserve all fields (no dropped metadata)", async () => {
|
|
590
|
+
const memoryDir = await mkMemoryDir();
|
|
591
|
+
try {
|
|
592
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
593
|
+
const storageDir = path.join(memoryDir, "namespaces", namespaceIdentityToken("project-origin-race"));
|
|
594
|
+
|
|
595
|
+
// Kick off near-simultaneous touches of every kind; do NOT await between
|
|
596
|
+
// them so the read-modify-append sections must serialize correctly.
|
|
597
|
+
await Promise.all([
|
|
598
|
+
catalog.markRead("project-origin-race", { discoveredBy: "read" }),
|
|
599
|
+
catalog.markWrite("project-origin-race", { discoveredBy: "write" }),
|
|
600
|
+
catalog.markMaintenance("project-origin-race", "dreams"),
|
|
601
|
+
catalog.registerResolved("project-origin-race", storageDir),
|
|
602
|
+
catalog.markMaintenance("project-origin-race", "compaction"),
|
|
603
|
+
]);
|
|
604
|
+
|
|
605
|
+
const record = await catalog.getNamespaceRecord("project-origin-race");
|
|
606
|
+
assert.ok(record, "expected a record after concurrent touches");
|
|
607
|
+
assert.ok(record?.lastReadAt, "lastReadAt must survive concurrent touches");
|
|
608
|
+
assert.ok(record?.lastWriteAt, "lastWriteAt must survive concurrent touches");
|
|
609
|
+
assert.ok(record?.lastMaintenanceAt?.dreams, "dreams maintenance ts must survive");
|
|
610
|
+
assert.ok(record?.lastMaintenanceAt?.compaction, "compaction maintenance ts must survive");
|
|
611
|
+
// Exactly one logical namespace record after compaction.
|
|
612
|
+
const list = await catalog.listNamespaces();
|
|
613
|
+
assert.equal(
|
|
614
|
+
list.filter((r) => r.namespace === "project-origin-race").length,
|
|
615
|
+
1,
|
|
616
|
+
"compaction must fold concurrent appends into a single record",
|
|
617
|
+
);
|
|
618
|
+
} finally {
|
|
619
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
// Tighter race: a register firing concurrently with read+write must not drop
|
|
624
|
+
// the write's lastWriteAt or the read's lastReadAt (the exact scenario the
|
|
625
|
+
// codex thread called out — the router hook is fire-and-forget alongside the
|
|
626
|
+
// hot-path read/write touches). Run several rounds on distinct namespaces so
|
|
627
|
+
// the assertion does not depend on one lucky scheduling.
|
|
628
|
+
test("concurrent register + markWrite + markRead never drops touch fields", async () => {
|
|
629
|
+
const memoryDir = await mkMemoryDir();
|
|
630
|
+
try {
|
|
631
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
632
|
+
for (let i = 0; i < 25; i++) {
|
|
633
|
+
const ns = `project-origin-rw-${i}`;
|
|
634
|
+
const storageDir = path.join(memoryDir, "namespaces", namespaceIdentityToken(ns));
|
|
635
|
+
await Promise.all([
|
|
636
|
+
catalog.registerResolved(ns, storageDir),
|
|
637
|
+
catalog.markWrite(ns, { discoveredBy: "write" }),
|
|
638
|
+
catalog.markRead(ns, { discoveredBy: "read" }),
|
|
639
|
+
]);
|
|
640
|
+
const record = await catalog.getNamespaceRecord(ns);
|
|
641
|
+
assert.ok(record?.lastWriteAt, `round ${i}: write must survive a racing register/read`);
|
|
642
|
+
assert.ok(record?.lastReadAt, `round ${i}: read must survive a racing register/write`);
|
|
643
|
+
}
|
|
644
|
+
} finally {
|
|
645
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
646
|
+
}
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
// ── Issue 3 (cursor + codex): a string "false"/"0" opt-out from CLI/env/JSON
|
|
650
|
+
// must keep the catalog inert (no jsonl writes), matching the boolean opt-out.
|
|
651
|
+
// parseConfig is what coerces these strings; assert the catalog honors the
|
|
652
|
+
// resulting flag end to end by feeding it a config whose flag is the coerced
|
|
653
|
+
// boolean.
|
|
654
|
+
test("catalog is inert when namespaceCatalogEnabled is false (string opt-out coerced)", async () => {
|
|
655
|
+
const memoryDir = await mkMemoryDir();
|
|
656
|
+
try {
|
|
657
|
+
// Simulate the post-parseConfig state for `namespaceCatalogEnabled: "false"`
|
|
658
|
+
// / "0": the coerced boolean is false, so the catalog must do nothing.
|
|
659
|
+
const catalog = new NamespaceCatalog(
|
|
660
|
+
makeConfig(memoryDir, { namespaceCatalogEnabled: false }),
|
|
661
|
+
);
|
|
662
|
+
assert.equal(catalog.enabled, false, "catalog must report disabled");
|
|
663
|
+
await catalog.markWrite("project-origin-abc123", { discoveredBy: "write" });
|
|
664
|
+
await catalog.markRead("shared");
|
|
665
|
+
await catalog.registerConfiguredNamespaces();
|
|
666
|
+
|
|
667
|
+
assert.deepEqual(await catalog.listNamespaces(), [], "disabled catalog enumerates nothing");
|
|
668
|
+
let exists = true;
|
|
669
|
+
try {
|
|
670
|
+
await readFile(path.join(memoryDir, "state", "namespaces.jsonl"), "utf8");
|
|
671
|
+
} catch {
|
|
672
|
+
exists = false;
|
|
673
|
+
}
|
|
674
|
+
assert.equal(exists, false, "opted-out catalog must not write the jsonl file");
|
|
675
|
+
} finally {
|
|
676
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
677
|
+
}
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
// ── Issue 2 (cursor[bot]): the chunked extraction path writes the parent memory
|
|
681
|
+
// then `continue`s past the non-chunked write, so it must record its OWN write
|
|
682
|
+
// touch. This asserts the catalog contract the orchestrator's chunked branch now
|
|
683
|
+
// relies on: a markWrite (as fired by the chunked path) updates lastWriteAt for
|
|
684
|
+
// the target namespace exactly like the non-chunked path.
|
|
685
|
+
test("chunked write path contract: markWrite updates lastWriteAt for the namespace", async () => {
|
|
686
|
+
const memoryDir = await mkMemoryDir();
|
|
687
|
+
try {
|
|
688
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
689
|
+
const ns = "project-origin-chunked";
|
|
690
|
+
const storageDir = path.join(memoryDir, "namespaces", namespaceIdentityToken(ns));
|
|
691
|
+
// This is exactly the call the orchestrator chunked branch now makes
|
|
692
|
+
// (markCatalogWrite -> markWrite with discoveredBy "write" + storageDir).
|
|
693
|
+
await catalog.markWrite(ns, { discoveredBy: "write", storageDir });
|
|
694
|
+
const record = await catalog.getNamespaceRecord(ns);
|
|
695
|
+
assert.ok(record?.lastWriteAt, "chunked write must update lastWriteAt");
|
|
696
|
+
assert.equal(record?.storageDir, storageDir);
|
|
697
|
+
} finally {
|
|
698
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
699
|
+
}
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
// ── Round 2, Issue A (cursor High + codex P2): a hot-path touch that lands
|
|
703
|
+
// while `rebuildFromDisk --apply` is running must NOT be discarded by the
|
|
704
|
+
// atomic rewrite. Round 1 snapshotted catalog state OUTSIDE the write chain and
|
|
705
|
+
// then rewrote from that snapshot, so a touch appended after the snapshot but
|
|
706
|
+
// before the rewrite was lost. Now the entire scan → load → rewrite runs inside
|
|
707
|
+
// ONE serialized critical section, so a concurrent markWrite either lands before
|
|
708
|
+
// the rebuild's in-chain load (folded into the rewrite) or after the rewrite
|
|
709
|
+
// (its own later critical turn re-reads the rewritten file and re-appends). Run
|
|
710
|
+
// many rounds so the assertion never depends on one lucky scheduling.
|
|
711
|
+
test("rebuildFromDisk --apply does not drop a concurrent markWrite touch", async () => {
|
|
712
|
+
const memoryDir = await mkMemoryDir();
|
|
713
|
+
try {
|
|
714
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
715
|
+
for (let i = 0; i < 30; i++) {
|
|
716
|
+
const ns = `project-origin-rebuild-race-${i}`;
|
|
717
|
+
const storageDir = path.join(memoryDir, "namespaces", namespaceIdentityToken(ns));
|
|
718
|
+
// Give the namespace on-disk data so rebuild discovers it as a scan record
|
|
719
|
+
// (with no lastWriteAt). The racing markWrite is what supplies lastWriteAt.
|
|
720
|
+
await mkdir(path.join(storageDir, "facts"), { recursive: true });
|
|
721
|
+
|
|
722
|
+
// Fire rebuild and a write touch concurrently without awaiting between.
|
|
723
|
+
await Promise.all([
|
|
724
|
+
catalog.rebuildFromDisk(),
|
|
725
|
+
catalog.markWrite(ns, { discoveredBy: "write", storageDir }),
|
|
726
|
+
]);
|
|
727
|
+
|
|
728
|
+
const record = await catalog.getNamespaceRecord(ns);
|
|
729
|
+
assert.ok(
|
|
730
|
+
record,
|
|
731
|
+
`round ${i}: namespace must exist after concurrent rebuild + write`,
|
|
732
|
+
);
|
|
733
|
+
assert.ok(
|
|
734
|
+
record?.lastWriteAt,
|
|
735
|
+
`round ${i}: a markWrite racing rebuildFromDisk --apply must not be dropped`,
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
} finally {
|
|
739
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
740
|
+
}
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
// Companion: markRead and registerResolved touches racing a rebuild are also
|
|
744
|
+
// preserved (the lost-touch class covers all touch kinds, not just write).
|
|
745
|
+
test("rebuildFromDisk --apply preserves concurrent markRead / registerResolved touches", async () => {
|
|
746
|
+
const memoryDir = await mkMemoryDir();
|
|
747
|
+
try {
|
|
748
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
749
|
+
for (let i = 0; i < 20; i++) {
|
|
750
|
+
const ns = `project-origin-rebuild-rr-${i}`;
|
|
751
|
+
const storageDir = path.join(memoryDir, "namespaces", namespaceIdentityToken(ns));
|
|
752
|
+
await mkdir(path.join(storageDir, "facts"), { recursive: true });
|
|
753
|
+
await Promise.all([
|
|
754
|
+
catalog.rebuildFromDisk(),
|
|
755
|
+
catalog.markRead(ns, { discoveredBy: "read" }),
|
|
756
|
+
catalog.registerResolved(ns, storageDir),
|
|
757
|
+
]);
|
|
758
|
+
const record = await catalog.getNamespaceRecord(ns);
|
|
759
|
+
assert.ok(record, `round ${i}: namespace must survive concurrent rebuild`);
|
|
760
|
+
assert.ok(
|
|
761
|
+
record?.lastReadAt,
|
|
762
|
+
`round ${i}: a markRead racing rebuildFromDisk must not be dropped`,
|
|
763
|
+
);
|
|
764
|
+
}
|
|
765
|
+
} finally {
|
|
766
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
767
|
+
}
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
// `--dry-run` must remain non-mutating even though it now takes the critical
|
|
771
|
+
// section for a consistent read.
|
|
772
|
+
test("rebuildFromDisk dry-run takes the critical section but writes nothing", async () => {
|
|
773
|
+
const memoryDir = await mkMemoryDir();
|
|
774
|
+
try {
|
|
775
|
+
await mkdir(path.join(memoryDir, "namespaces", namespaceIdentityToken("gamma"), "facts"), {
|
|
776
|
+
recursive: true,
|
|
777
|
+
});
|
|
778
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
779
|
+
const result = await catalog.rebuildFromDisk({ dryRun: true });
|
|
780
|
+
assert.equal(result.dryRun, true);
|
|
781
|
+
assert.ok(result.records.some((r) => r.namespace === "gamma"));
|
|
782
|
+
let exists = true;
|
|
783
|
+
try {
|
|
784
|
+
await readFile(path.join(memoryDir, "state", "namespaces.jsonl"), "utf8");
|
|
785
|
+
} catch {
|
|
786
|
+
exists = false;
|
|
787
|
+
}
|
|
788
|
+
assert.equal(exists, false, "dry-run must not write the catalog file");
|
|
789
|
+
} finally {
|
|
790
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
791
|
+
}
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
// ── Round 2, Issue C (codex P2): the rebuilt default record's storageDir must
|
|
795
|
+
// match the runtime router's `defaultNamespaceRoot`. While legacy default data
|
|
796
|
+
// lives directly under memoryDir, the router keeps the default root at
|
|
797
|
+
// memoryDir even if a tokenized `namespaces/<default-token>` dir also exists.
|
|
798
|
+
// Rebuild must NOT overwrite the default record with the tokenized path, or
|
|
799
|
+
// maintenance/QMD would read a different default root than live reads.
|
|
800
|
+
test("rebuildFromDisk keeps default storageDir aligned with the router (legacy data present)", async () => {
|
|
801
|
+
const memoryDir = await mkMemoryDir();
|
|
802
|
+
try {
|
|
803
|
+
const config = makeConfig(memoryDir);
|
|
804
|
+
// Legacy default data lives directly under memoryDir.
|
|
805
|
+
await mkdir(path.join(memoryDir, "facts"), { recursive: true });
|
|
806
|
+
await writeFile(path.join(memoryDir, "facts", "legacy.md"), "# legacy\n", "utf8");
|
|
807
|
+
// AND a tokenized default dir also exists with data — the divergent case.
|
|
808
|
+
const tokenizedDefaultDir = path.join(
|
|
809
|
+
memoryDir,
|
|
810
|
+
"namespaces",
|
|
811
|
+
namespaceIdentityToken(config.defaultNamespace),
|
|
812
|
+
);
|
|
813
|
+
await mkdir(path.join(tokenizedDefaultDir, "facts"), { recursive: true });
|
|
814
|
+
await writeFile(path.join(tokenizedDefaultDir, "facts", "tok.md"), "# tok\n", "utf8");
|
|
815
|
+
|
|
816
|
+
// Resolve what the runtime router would use for the default root.
|
|
817
|
+
const routerRoot = await resolveDefaultNamespaceRoot(config);
|
|
818
|
+
assert.equal(
|
|
819
|
+
routerRoot,
|
|
820
|
+
memoryDir,
|
|
821
|
+
"router keeps default root at memoryDir while legacy data exists",
|
|
822
|
+
);
|
|
823
|
+
|
|
824
|
+
const catalog = new NamespaceCatalog(config);
|
|
825
|
+
const result = await catalog.rebuildFromDisk();
|
|
826
|
+
const def = result.records.find((r) => r.namespace === config.defaultNamespace);
|
|
827
|
+
assert.ok(def, "expected a default namespace record");
|
|
828
|
+
assert.equal(def?.kind, "default");
|
|
829
|
+
assert.equal(
|
|
830
|
+
def?.storageDir,
|
|
831
|
+
routerRoot,
|
|
832
|
+
"rebuilt default storageDir must equal the router's defaultNamespaceRoot, not the tokenized path",
|
|
833
|
+
);
|
|
834
|
+
assert.notEqual(
|
|
835
|
+
def?.storageDir,
|
|
836
|
+
tokenizedDefaultDir,
|
|
837
|
+
"rebuild must not point the default record at the tokenized dir while legacy data exists",
|
|
838
|
+
);
|
|
839
|
+
} finally {
|
|
840
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
841
|
+
}
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
// ── Round 3, Issue #1 (cursor Medium): rebuild must NOT skip a tokenized
|
|
845
|
+
// namespace root that only holds a `state/` dir — the router counts the `state`
|
|
846
|
+
// runtime child (includeRuntimeState) when deciding a root has storage, so a
|
|
847
|
+
// namespace the router actively resolves would otherwise vanish from the
|
|
848
|
+
// rebuilt catalog after --apply.
|
|
849
|
+
test("rebuildFromDisk catalogs a tokenized root that only has a state dir", async () => {
|
|
850
|
+
const memoryDir = await mkMemoryDir();
|
|
851
|
+
try {
|
|
852
|
+
const ns = "project-origin-stateonly";
|
|
853
|
+
const tokenDir = path.join(memoryDir, "namespaces", namespaceIdentityToken(ns));
|
|
854
|
+
// Only a runtime `state/` child — no content category dirs.
|
|
855
|
+
await mkdir(path.join(tokenDir, "state"), { recursive: true });
|
|
856
|
+
|
|
857
|
+
const config = makeConfig(memoryDir);
|
|
858
|
+
// The router treats this root as present (runtime state counts as a marker).
|
|
859
|
+
const router = new NamespaceStorageRouter(config);
|
|
860
|
+
const sm = await router.storageFor(ns);
|
|
861
|
+
assert.equal(sm.dir, tokenDir, "router resolves the state-only tokenized root");
|
|
862
|
+
|
|
863
|
+
const catalog = new NamespaceCatalog(config);
|
|
864
|
+
const result = await catalog.rebuildFromDisk();
|
|
865
|
+
assert.ok(
|
|
866
|
+
result.records.some((r) => r.namespace === ns),
|
|
867
|
+
"rebuild must catalog a state-only root the router resolves",
|
|
868
|
+
);
|
|
869
|
+
assert.ok(
|
|
870
|
+
!result.skipped.some((s) => s.token === namespaceIdentityToken(ns)),
|
|
871
|
+
"state-only root must not be reported as skipped",
|
|
872
|
+
);
|
|
873
|
+
} finally {
|
|
874
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
875
|
+
}
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
// ── Round 3, Issue #2 (cursor Low): rebuild must preserve creation-only
|
|
879
|
+
// provenance. A configured/policy namespace first DISCOVERED via a write touch
|
|
880
|
+
// keeps discoveredBy:"write" after rebuild — rebuild must not reset it to
|
|
881
|
+
// "config" just because it is listed in policies. Mirrors the touch-path
|
|
882
|
+
// creation-only invariant.
|
|
883
|
+
test("rebuildFromDisk preserves prior write provenance for a configured namespace", async () => {
|
|
884
|
+
const memoryDir = await mkMemoryDir();
|
|
885
|
+
try {
|
|
886
|
+
const ns = "explicit-policy-ns";
|
|
887
|
+
const config = makeConfig(memoryDir, {
|
|
888
|
+
namespacePolicies: [{ name: ns } as any],
|
|
889
|
+
});
|
|
890
|
+
const catalog = new NamespaceCatalog(config);
|
|
891
|
+
|
|
892
|
+
// First seen via a write — provenance is "write", with on-disk data so the
|
|
893
|
+
// scan branch also discovers it.
|
|
894
|
+
const tokenDir = path.join(memoryDir, "namespaces", namespaceIdentityToken(ns));
|
|
895
|
+
await mkdir(path.join(tokenDir, "facts"), { recursive: true });
|
|
896
|
+
await catalog.markWrite(ns, { discoveredBy: "write", storageDir: tokenDir });
|
|
897
|
+
assert.equal(
|
|
898
|
+
(await catalog.getNamespaceRecord(ns))?.discoveredBy,
|
|
899
|
+
"write",
|
|
900
|
+
"precondition: namespace discovered via write",
|
|
901
|
+
);
|
|
902
|
+
|
|
903
|
+
await catalog.rebuildFromDisk();
|
|
904
|
+
|
|
905
|
+
const after = await catalog.getNamespaceRecord(ns);
|
|
906
|
+
assert.equal(
|
|
907
|
+
after?.discoveredBy,
|
|
908
|
+
"write",
|
|
909
|
+
"rebuild must preserve prior write provenance, not reset configured ns to config",
|
|
910
|
+
);
|
|
911
|
+
assert.ok(after?.lastWriteAt, "rebuild must preserve the lastWriteAt touch field");
|
|
912
|
+
} finally {
|
|
913
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
914
|
+
}
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
// ── Round 4, Issue #4 (codex P2): an explicit metadata.storageDir from a
|
|
918
|
+
// markWrite/registerResolved caller must be containment-checked before it is
|
|
919
|
+
// persisted. An out-of-memoryDir path must NOT end up as the namespace's
|
|
920
|
+
// catalog storageDir; the catalog falls back to the trusted resolved dir.
|
|
921
|
+
test("explicit storageDir outside memoryDir is rejected (containment)", async () => {
|
|
922
|
+
const memoryDir = await mkMemoryDir();
|
|
923
|
+
const outside = await mkMemoryDir();
|
|
924
|
+
try {
|
|
925
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
926
|
+
const ns = "project-origin-escape";
|
|
927
|
+
const evilDir = path.join(outside, "evil");
|
|
928
|
+
|
|
929
|
+
// A bad hook passes an arbitrary path outside memoryDir.
|
|
930
|
+
await catalog.markWrite(ns, { discoveredBy: "write", storageDir: evilDir });
|
|
931
|
+
|
|
932
|
+
const record = await catalog.getNamespaceRecord(ns);
|
|
933
|
+
assert.ok(record, "record should still be created");
|
|
934
|
+
assert.ok(
|
|
935
|
+
!record!.storageDir.startsWith(outside),
|
|
936
|
+
"catalog must not persist a storage dir outside memoryDir",
|
|
937
|
+
);
|
|
938
|
+
// Falls back to the trusted tokenized root under <memoryDir>/namespaces.
|
|
939
|
+
assert.equal(
|
|
940
|
+
record!.storageDir,
|
|
941
|
+
path.join(memoryDir, "namespaces", namespaceIdentityToken(ns)),
|
|
942
|
+
"rejected explicit dir must fall back to the resolved namespaces/<token> root",
|
|
943
|
+
);
|
|
944
|
+
} finally {
|
|
945
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
946
|
+
await rm(outside, { recursive: true, force: true });
|
|
947
|
+
}
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
// A legitimate explicit storageDir contained under <memoryDir>/namespaces (the
|
|
951
|
+
// router's resolved dir, incl. a legacy raw-name dir) is accepted verbatim.
|
|
952
|
+
test("explicit storageDir contained under namespaces/ is accepted", async () => {
|
|
953
|
+
const memoryDir = await mkMemoryDir();
|
|
954
|
+
try {
|
|
955
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
956
|
+
const ns = "project-origin-ok";
|
|
957
|
+
// A legacy raw-name dir under namespaces/ (what the router may resolve to).
|
|
958
|
+
const legacyDir = path.join(memoryDir, "namespaces", ns);
|
|
959
|
+
await catalog.markWrite(ns, { discoveredBy: "write", storageDir: legacyDir });
|
|
960
|
+
const record = await catalog.getNamespaceRecord(ns);
|
|
961
|
+
assert.equal(
|
|
962
|
+
record?.storageDir,
|
|
963
|
+
legacyDir,
|
|
964
|
+
"a contained explicit dir must be persisted as-is",
|
|
965
|
+
);
|
|
966
|
+
} finally {
|
|
967
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
968
|
+
}
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
// ── Round 5, Issue #1 (cursor Medium): when both a legacy raw-name dir and a
|
|
972
|
+
// tokenized dir hold data for the same namespace, rebuild must prefer the
|
|
973
|
+
// TOKENIZED root (matching NamespaceStorageRouter), not let last-readdir-wins
|
|
974
|
+
// pick arbitrarily.
|
|
975
|
+
test("rebuildFromDisk prefers the tokenized root over a legacy dual root", async () => {
|
|
976
|
+
const memoryDir = await mkMemoryDir();
|
|
977
|
+
try {
|
|
978
|
+
const ns = "project-origin-dual";
|
|
979
|
+
const token = namespaceIdentityToken(ns);
|
|
980
|
+
const tokenizedDir = path.join(memoryDir, "namespaces", token);
|
|
981
|
+
const legacyDir = path.join(memoryDir, "namespaces", ns);
|
|
982
|
+
// Both roots hold data for the same namespace.
|
|
983
|
+
await mkdir(path.join(tokenizedDir, "facts"), { recursive: true });
|
|
984
|
+
await mkdir(path.join(legacyDir, "facts"), { recursive: true });
|
|
985
|
+
|
|
986
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
987
|
+
const result = await catalog.rebuildFromDisk();
|
|
988
|
+
const rec = result.records.find((r) => r.namespace === ns);
|
|
989
|
+
assert.ok(rec, "expected the dual-root namespace to be cataloged");
|
|
990
|
+
assert.equal(
|
|
991
|
+
rec?.storageDir,
|
|
992
|
+
tokenizedDir,
|
|
993
|
+
"rebuild must prefer the tokenized root for a dual-root namespace",
|
|
994
|
+
);
|
|
995
|
+
} finally {
|
|
996
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
997
|
+
}
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
test("rebuildFromDisk skips non-canonical raw namespace roots", async () => {
|
|
1001
|
+
const memoryDir = await mkMemoryDir();
|
|
1002
|
+
try {
|
|
1003
|
+
const ns = "project-origin-spaced";
|
|
1004
|
+
const rawDir = path.join(memoryDir, "namespaces", `${ns} `);
|
|
1005
|
+
await mkdir(path.join(rawDir, "facts"), { recursive: true });
|
|
1006
|
+
|
|
1007
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
1008
|
+
const result = await catalog.rebuildFromDisk();
|
|
1009
|
+
|
|
1010
|
+
assert.equal(
|
|
1011
|
+
result.records.some((r) => r.namespace === ns && path.resolve(r.storageDir) === path.resolve(rawDir)),
|
|
1012
|
+
false,
|
|
1013
|
+
"rebuild must not attach the canonical namespace to a non-canonical raw root",
|
|
1014
|
+
);
|
|
1015
|
+
assert.equal(
|
|
1016
|
+
result.records.some((r) => path.resolve(r.storageDir) === path.resolve(rawDir)),
|
|
1017
|
+
false,
|
|
1018
|
+
"no catalog record may point at a raw root the router cannot resolve",
|
|
1019
|
+
);
|
|
1020
|
+
assert.ok(
|
|
1021
|
+
result.skipped.some((s) => s.token === `${ns} ` && s.reason === "unsafe" && s.detail === `${ns} `),
|
|
1022
|
+
"the non-canonical raw root should be reported as an unsafe skip",
|
|
1023
|
+
);
|
|
1024
|
+
} finally {
|
|
1025
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
1026
|
+
}
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
test("rebuildFromDisk preserves a raw ns-default namespace root", async () => {
|
|
1030
|
+
const memoryDir = await mkMemoryDir();
|
|
1031
|
+
try {
|
|
1032
|
+
const rawNs = "ns-default";
|
|
1033
|
+
const rawDir = path.join(memoryDir, "namespaces", rawNs);
|
|
1034
|
+
await mkdir(path.join(rawDir, "facts"), { recursive: true });
|
|
1035
|
+
await writeFile(path.join(rawDir, "facts", "f1.md"), "# synthetic\n", "utf8");
|
|
1036
|
+
|
|
1037
|
+
assert.equal(
|
|
1038
|
+
namespaceIdentityFromToken(rawNs),
|
|
1039
|
+
"",
|
|
1040
|
+
"precondition: ns-default is the reserved empty/default identity token",
|
|
1041
|
+
);
|
|
1042
|
+
|
|
1043
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
1044
|
+
const result = await catalog.rebuildFromDisk();
|
|
1045
|
+
const rec = result.records.find((r) => r.namespace === rawNs);
|
|
1046
|
+
|
|
1047
|
+
assert.ok(rec, "rebuild must preserve a routeable raw namespace named ns-default");
|
|
1048
|
+
assert.equal(path.resolve(rec.storageDir), path.resolve(rawDir));
|
|
1049
|
+
assert.ok(
|
|
1050
|
+
!result.skipped.some((s) => s.token === rawNs && s.reason === "unsafe"),
|
|
1051
|
+
"the reserved-token decode must fall back to the raw namespace before unsafe checks",
|
|
1052
|
+
);
|
|
1053
|
+
} finally {
|
|
1054
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
1055
|
+
}
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
// ── Round 5, Issue #2 (cursor Medium): reads must not surface an out-of-root
|
|
1059
|
+
// storageDir. A tampered/pre-fix jsonl record with an absolute path outside
|
|
1060
|
+
// memoryDir must be sanitized to the resolved safe root on enumeration.
|
|
1061
|
+
test("listNamespaces/getNamespaceRecord sanitize an out-of-root storageDir on read", async () => {
|
|
1062
|
+
const memoryDir = await mkMemoryDir();
|
|
1063
|
+
const outside = await mkMemoryDir();
|
|
1064
|
+
try {
|
|
1065
|
+
const ns = "project-origin-tampered";
|
|
1066
|
+
const token = namespaceIdentityToken(ns);
|
|
1067
|
+
const stateDir = path.join(memoryDir, "state");
|
|
1068
|
+
await mkdir(stateDir, { recursive: true });
|
|
1069
|
+
// Hand-craft a record whose storageDir escapes memoryDir (tampered file).
|
|
1070
|
+
const evil = path.join(outside, "evil");
|
|
1071
|
+
const line = JSON.stringify({
|
|
1072
|
+
namespace: ns,
|
|
1073
|
+
identityToken: token,
|
|
1074
|
+
kind: "project",
|
|
1075
|
+
createdAt: new Date().toISOString(),
|
|
1076
|
+
storageDir: evil,
|
|
1077
|
+
discoveredBy: "write",
|
|
1078
|
+
});
|
|
1079
|
+
await writeFile(path.join(stateDir, "namespaces.jsonl"), line + "\n", "utf8");
|
|
1080
|
+
|
|
1081
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
1082
|
+
const viaGet = await catalog.getNamespaceRecord(ns);
|
|
1083
|
+
assert.ok(viaGet, "record should be returned");
|
|
1084
|
+
assert.ok(
|
|
1085
|
+
!viaGet!.storageDir.startsWith(outside),
|
|
1086
|
+
"getNamespaceRecord must not surface an out-of-root storageDir",
|
|
1087
|
+
);
|
|
1088
|
+
assert.equal(
|
|
1089
|
+
viaGet!.storageDir,
|
|
1090
|
+
path.join(memoryDir, "namespaces", token),
|
|
1091
|
+
"out-of-root dir must be sanitized to the resolved safe root",
|
|
1092
|
+
);
|
|
1093
|
+
|
|
1094
|
+
const viaList = await catalog.listNamespaces();
|
|
1095
|
+
const listed = viaList.find((r) => r.namespace === ns);
|
|
1096
|
+
assert.ok(
|
|
1097
|
+
listed && !listed.storageDir.startsWith(outside),
|
|
1098
|
+
"listNamespaces must not surface an out-of-root storageDir",
|
|
1099
|
+
);
|
|
1100
|
+
} finally {
|
|
1101
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
1102
|
+
await rm(outside, { recursive: true, force: true });
|
|
1103
|
+
}
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
// ── NGZqr (codex P2): the READ sanitizer must REJECT an unsafe non-default
|
|
1107
|
+
// namespace NAME, not only sanitize its storageDir. A pre-fix/tampered jsonl row
|
|
1108
|
+
// with an unsafe namespace (e.g. `../evil`) was surfaced by listNamespaces/
|
|
1109
|
+
// getNamespaceRecord because the sanitizer only fixed storageDir — and
|
|
1110
|
+
// isStorageDirForNamespace can still build a tokenized root for such a name, so
|
|
1111
|
+
// the storageDir check alone passes. The hot touch + rebuild scan paths reject
|
|
1112
|
+
// these names with isSafeRouteNamespace; the read boundary must agree, or
|
|
1113
|
+
// maintenance/QMD could enumerate a namespace those paths reject.
|
|
1114
|
+
test("listNamespaces/getNamespaceRecord drop an UNSAFE namespace row on read (NGZqr)", async () => {
|
|
1115
|
+
const memoryDir = await mkMemoryDir();
|
|
1116
|
+
try {
|
|
1117
|
+
const unsafeNs = "../evil"; // fails isSafeRouteNamespace (parent ref + slash)
|
|
1118
|
+
const safeNs = "project-origin-ok";
|
|
1119
|
+
const stateDir = path.join(memoryDir, "state");
|
|
1120
|
+
await mkdir(stateDir, { recursive: true });
|
|
1121
|
+
const lines = [
|
|
1122
|
+
// A tampered/pre-fix row carrying an unsafe namespace name.
|
|
1123
|
+
JSON.stringify({
|
|
1124
|
+
namespace: unsafeNs,
|
|
1125
|
+
identityToken: namespaceIdentityToken(unsafeNs),
|
|
1126
|
+
kind: "project",
|
|
1127
|
+
createdAt: new Date().toISOString(),
|
|
1128
|
+
storageDir: path.join(memoryDir, "namespaces", namespaceIdentityToken(unsafeNs)),
|
|
1129
|
+
discoveredBy: "write",
|
|
1130
|
+
}),
|
|
1131
|
+
// A normal, safe row that MUST still be returned.
|
|
1132
|
+
JSON.stringify({
|
|
1133
|
+
namespace: safeNs,
|
|
1134
|
+
identityToken: namespaceIdentityToken(safeNs),
|
|
1135
|
+
kind: "project",
|
|
1136
|
+
createdAt: new Date().toISOString(),
|
|
1137
|
+
storageDir: path.join(memoryDir, "namespaces", namespaceIdentityToken(safeNs)),
|
|
1138
|
+
discoveredBy: "write",
|
|
1139
|
+
}),
|
|
1140
|
+
];
|
|
1141
|
+
await writeFile(path.join(stateDir, "namespaces.jsonl"), lines.join("\n") + "\n", "utf8");
|
|
1142
|
+
|
|
1143
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
1144
|
+
// The unsafe namespace must NOT be surfaced by either read surface.
|
|
1145
|
+
assert.equal(
|
|
1146
|
+
await catalog.getNamespaceRecord(unsafeNs),
|
|
1147
|
+
null,
|
|
1148
|
+
"getNamespaceRecord must drop an unsafe namespace row",
|
|
1149
|
+
);
|
|
1150
|
+
const list = await catalog.listNamespaces();
|
|
1151
|
+
assert.ok(
|
|
1152
|
+
!list.some((r) => r.namespace === unsafeNs),
|
|
1153
|
+
"listNamespaces must not surface an unsafe namespace row",
|
|
1154
|
+
);
|
|
1155
|
+
// The safe namespace is unaffected.
|
|
1156
|
+
assert.ok(
|
|
1157
|
+
list.some((r) => r.namespace === safeNs),
|
|
1158
|
+
"a safe namespace row must still be enumerated",
|
|
1159
|
+
);
|
|
1160
|
+
} finally {
|
|
1161
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
1162
|
+
}
|
|
1163
|
+
});
|
|
1164
|
+
|
|
1165
|
+
// ── Round 5, Issue #3 (codex P2): an explicit storageDir that is lexically
|
|
1166
|
+
// contained but is a SYMLINK escaping memoryDir must be rejected (the round-4
|
|
1167
|
+
// containment check was lexical only).
|
|
1168
|
+
test("explicit storageDir that is a symlink escaping memoryDir is rejected", async () => {
|
|
1169
|
+
const memoryDir = await mkMemoryDir();
|
|
1170
|
+
const outside = await mkMemoryDir();
|
|
1171
|
+
try {
|
|
1172
|
+
const ns = "project-origin-symlink";
|
|
1173
|
+
const token = namespaceIdentityToken(ns);
|
|
1174
|
+
await mkdir(path.join(memoryDir, "namespaces"), { recursive: true });
|
|
1175
|
+
await mkdir(path.join(outside, "target"), { recursive: true });
|
|
1176
|
+
const linkPath = path.join(memoryDir, "namespaces", token);
|
|
1177
|
+
try {
|
|
1178
|
+
await symlink(path.join(outside, "target"), linkPath, "dir");
|
|
1179
|
+
} catch {
|
|
1180
|
+
// Some CI environments disallow symlinks; skip gracefully.
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
1185
|
+
// The symlink path is lexically under namespaces/ but escapes via realpath.
|
|
1186
|
+
await catalog.markWrite(ns, { discoveredBy: "write", storageDir: linkPath });
|
|
1187
|
+
const record = await catalog.getNamespaceRecord(ns);
|
|
1188
|
+
assert.ok(record, "record should be created");
|
|
1189
|
+
// The REALPATH of the persisted storage dir must stay inside memoryDir — a
|
|
1190
|
+
// lexical-only check (the round-4 behavior) would wrongly accept the symlink
|
|
1191
|
+
// whose realpath escapes to `outside`.
|
|
1192
|
+
const memoryReal = await realpath(memoryDir);
|
|
1193
|
+
const outsideReal = await realpath(outside);
|
|
1194
|
+
let persistedReal: string;
|
|
1195
|
+
try {
|
|
1196
|
+
persistedReal = await realpath(record!.storageDir);
|
|
1197
|
+
} catch {
|
|
1198
|
+
// The fallback resolved token dir may not exist on disk; use the lexical
|
|
1199
|
+
// path, which is by construction inside memoryDir.
|
|
1200
|
+
persistedReal = record!.storageDir;
|
|
1201
|
+
}
|
|
1202
|
+
assert.ok(
|
|
1203
|
+
!persistedReal.startsWith(outsideReal),
|
|
1204
|
+
"a symlink-escaping explicit dir must not be persisted (realpath must stay inside memoryDir)",
|
|
1205
|
+
);
|
|
1206
|
+
assert.ok(
|
|
1207
|
+
persistedReal.startsWith(memoryReal) ||
|
|
1208
|
+
record!.storageDir === path.join(memoryDir, "namespaces", token),
|
|
1209
|
+
"persisted dir must be the trusted resolved root, not the escaping symlink target",
|
|
1210
|
+
);
|
|
1211
|
+
} finally {
|
|
1212
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
1213
|
+
await rm(outside, { recursive: true, force: true });
|
|
1214
|
+
}
|
|
1215
|
+
});
|
|
1216
|
+
|
|
1217
|
+
// ── Round 5, Issue #4 (codex P2): a cross-process append (simulated by a SECOND
|
|
1218
|
+
// NamespaceCatalog instance — a distinct in-process write chain, standing in for
|
|
1219
|
+
// the gateway process) that lands during a rebuild must survive. The in-chain
|
|
1220
|
+
// re-merge under the rebuild lock folds the latest on-disk touch fields into the
|
|
1221
|
+
// rewrite.
|
|
1222
|
+
test("rebuildFromDisk re-merges a concurrent cross-instance write touch", async () => {
|
|
1223
|
+
const memoryDir = await mkMemoryDir();
|
|
1224
|
+
try {
|
|
1225
|
+
const ns = "project-origin-xproc";
|
|
1226
|
+
const tokenDir = path.join(memoryDir, "namespaces", namespaceIdentityToken(ns));
|
|
1227
|
+
await mkdir(path.join(tokenDir, "facts"), { recursive: true });
|
|
1228
|
+
|
|
1229
|
+
// "CLI" catalog runs the rebuild; "gateway" catalog is a separate instance
|
|
1230
|
+
// (separate writeChain) that records a write touch concurrently.
|
|
1231
|
+
const cli = new NamespaceCatalog(makeConfig(memoryDir));
|
|
1232
|
+
const gateway = new NamespaceCatalog(makeConfig(memoryDir));
|
|
1233
|
+
|
|
1234
|
+
await Promise.all([
|
|
1235
|
+
cli.rebuildFromDisk(),
|
|
1236
|
+
gateway.markWrite(ns, { discoveredBy: "write", storageDir: tokenDir }),
|
|
1237
|
+
]);
|
|
1238
|
+
|
|
1239
|
+
// A fresh reader must see the write touch preserved (not clobbered by the
|
|
1240
|
+
// rebuild's rewrite).
|
|
1241
|
+
const reader = new NamespaceCatalog(makeConfig(memoryDir));
|
|
1242
|
+
const record = await reader.getNamespaceRecord(ns);
|
|
1243
|
+
assert.ok(record, "namespace must exist after concurrent rebuild + cross-instance write");
|
|
1244
|
+
assert.ok(
|
|
1245
|
+
record?.lastWriteAt,
|
|
1246
|
+
"a cross-instance write landing during rebuild must survive the rewrite",
|
|
1247
|
+
);
|
|
1248
|
+
} finally {
|
|
1249
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
1250
|
+
}
|
|
1251
|
+
});
|
|
1252
|
+
|
|
1253
|
+
// ── Round 4, Issue #1 (cursor Medium): a read touch with no explicit storageDir
|
|
1254
|
+
// must record the SAME on-disk root the router resolves — a legacy raw-name dir
|
|
1255
|
+
// when that is where the data lives — not the lexical tokenized guess.
|
|
1256
|
+
test("markRead records the router-aligned legacy raw-name root, not the tokenized guess", async () => {
|
|
1257
|
+
const memoryDir = await mkMemoryDir();
|
|
1258
|
+
try {
|
|
1259
|
+
const ns = "project-origin-legacy";
|
|
1260
|
+
// Data lives ONLY in the legacy raw-name dir (no tokenized dir) — exactly
|
|
1261
|
+
// what NamespaceStorageRouter.namespaceRoot would route to.
|
|
1262
|
+
const legacyDir = path.join(memoryDir, "namespaces", ns);
|
|
1263
|
+
await mkdir(path.join(legacyDir, "facts"), { recursive: true });
|
|
1264
|
+
|
|
1265
|
+
const expectedRoot = await resolveNamespaceStorageRoot(makeConfig(memoryDir), ns);
|
|
1266
|
+
assert.equal(expectedRoot, legacyDir, "router resolves the legacy raw-name dir");
|
|
1267
|
+
|
|
1268
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
1269
|
+
await catalog.markRead(ns, { discoveredBy: "read" });
|
|
1270
|
+
const record = await catalog.getNamespaceRecord(ns);
|
|
1271
|
+
assert.ok(record, "record should be created by the read touch");
|
|
1272
|
+
assert.equal(
|
|
1273
|
+
record?.storageDir,
|
|
1274
|
+
legacyDir,
|
|
1275
|
+
"read touch must record the router-aligned legacy root, not namespaces/<token>",
|
|
1276
|
+
);
|
|
1277
|
+
} finally {
|
|
1278
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
1279
|
+
}
|
|
1280
|
+
});
|
|
1281
|
+
|
|
1282
|
+
// ── Round 4, Issue #2 (codex P2): rebuild --apply must PURGE a stale dynamic
|
|
1283
|
+
// namespace whose on-disk root was deleted, rather than re-adding it from the
|
|
1284
|
+
// re-read log forever.
|
|
1285
|
+
test("rebuildFromDisk purges a stale namespace whose root was removed", async () => {
|
|
1286
|
+
const memoryDir = await mkMemoryDir();
|
|
1287
|
+
try {
|
|
1288
|
+
const ns = "project-origin-gone";
|
|
1289
|
+
const tokenDir = path.join(memoryDir, "namespaces", namespaceIdentityToken(ns));
|
|
1290
|
+
await mkdir(path.join(tokenDir, "facts"), { recursive: true });
|
|
1291
|
+
|
|
1292
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
1293
|
+
// First touch + rebuild catalogs the namespace from its on-disk root.
|
|
1294
|
+
await catalog.markWrite(ns, { discoveredBy: "write", storageDir: tokenDir });
|
|
1295
|
+
await catalog.rebuildFromDisk();
|
|
1296
|
+
assert.ok(
|
|
1297
|
+
await catalog.getNamespaceRecord(ns),
|
|
1298
|
+
"namespace should be present while its root exists",
|
|
1299
|
+
);
|
|
1300
|
+
|
|
1301
|
+
// Delete the on-disk root, then reconcile via rebuild.
|
|
1302
|
+
await rm(tokenDir, { recursive: true, force: true });
|
|
1303
|
+
const result = await catalog.rebuildFromDisk();
|
|
1304
|
+
assert.ok(
|
|
1305
|
+
!result.records.some((r) => r.namespace === ns),
|
|
1306
|
+
"rebuild must purge a stale namespace whose root was removed",
|
|
1307
|
+
);
|
|
1308
|
+
const reader = new NamespaceCatalog(makeConfig(memoryDir));
|
|
1309
|
+
assert.equal(
|
|
1310
|
+
await reader.getNamespaceRecord(ns),
|
|
1311
|
+
null,
|
|
1312
|
+
"purged namespace must not reappear on a fresh read",
|
|
1313
|
+
);
|
|
1314
|
+
} finally {
|
|
1315
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
1316
|
+
}
|
|
1317
|
+
});
|
|
1318
|
+
|
|
1319
|
+
// NIw0F (codex P2): a scanned namespace root whose only marker child is BOGUS —
|
|
1320
|
+
// e.g. `facts` is a regular file (or symlink) instead of a real directory —
|
|
1321
|
+
// must NOT be treated as live. Downstream `scanMemoryDir` throws on a
|
|
1322
|
+
// symlinked/non-directory category root, so cataloging it would make
|
|
1323
|
+
// catalog-driven QMD maintenance fail repeatedly on a root with no usable data.
|
|
1324
|
+
test("rebuildFromDisk does not catalog a namespace whose only marker child is a non-directory file", async () => {
|
|
1325
|
+
const memoryDir = await mkMemoryDir();
|
|
1326
|
+
try {
|
|
1327
|
+
const ns = "project-origin-bogus";
|
|
1328
|
+
const tokenDir = path.join(memoryDir, "namespaces", namespaceIdentityToken(ns));
|
|
1329
|
+
await mkdir(tokenDir, { recursive: true });
|
|
1330
|
+
// `facts` exists but is a regular FILE, not a category directory — bogus.
|
|
1331
|
+
await writeFile(path.join(tokenDir, "facts"), "not a directory\n", "utf8");
|
|
1332
|
+
|
|
1333
|
+
const result = await new NamespaceCatalog(makeConfig(memoryDir)).rebuildFromDisk();
|
|
1334
|
+
assert.ok(
|
|
1335
|
+
!result.records.some((r) => r.namespace === ns),
|
|
1336
|
+
"a namespace whose only marker is a non-directory file must not be cataloged as live",
|
|
1337
|
+
);
|
|
1338
|
+
} finally {
|
|
1339
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
1340
|
+
}
|
|
1341
|
+
});
|
|
1342
|
+
|
|
1343
|
+
// Companion to NIw0F: a real category DIRECTORY marker still makes the root live.
|
|
1344
|
+
test("rebuildFromDisk catalogs a namespace whose facts marker is a real directory", async () => {
|
|
1345
|
+
const memoryDir = await mkMemoryDir();
|
|
1346
|
+
try {
|
|
1347
|
+
const ns = "project-origin-realfacts";
|
|
1348
|
+
const tokenDir = path.join(memoryDir, "namespaces", namespaceIdentityToken(ns));
|
|
1349
|
+
await mkdir(path.join(tokenDir, "facts"), { recursive: true });
|
|
1350
|
+
|
|
1351
|
+
const result = await new NamespaceCatalog(makeConfig(memoryDir)).rebuildFromDisk();
|
|
1352
|
+
assert.ok(
|
|
1353
|
+
result.records.some((r) => r.namespace === ns),
|
|
1354
|
+
"a namespace with a real facts directory must be cataloged as live",
|
|
1355
|
+
);
|
|
1356
|
+
} finally {
|
|
1357
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
1358
|
+
}
|
|
1359
|
+
});
|
|
1360
|
+
|
|
1361
|
+
// NH-FH (cursor Medium): when the configured default name carries surrounding
|
|
1362
|
+
// whitespace, catalog records key it by its NORMALIZED (trimmed) identity, but
|
|
1363
|
+
// default-namespace exemptions and memoryDir-ownership checks must compare against
|
|
1364
|
+
// the SAME normalized form — otherwise the default row is misclassified, dropped
|
|
1365
|
+
// at read, or given the wrong storage root.
|
|
1366
|
+
test("a whitespace-padded default namespace is still recognized as the default row", async () => {
|
|
1367
|
+
const memoryDir = await mkMemoryDir();
|
|
1368
|
+
try {
|
|
1369
|
+
// The configured default name has surrounding whitespace; records use the
|
|
1370
|
+
// trimmed key "default".
|
|
1371
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir, { defaultNamespace: " default " }));
|
|
1372
|
+
await catalog.registerConfiguredNamespaces();
|
|
1373
|
+
|
|
1374
|
+
// The default row reads back (not dropped) under its normalized identity,
|
|
1375
|
+
// classified as kind "default", and rooted at memoryDir — NOT a tokenized
|
|
1376
|
+
// non-default route dir.
|
|
1377
|
+
const record = await catalog.getNamespaceRecord("default");
|
|
1378
|
+
assert.ok(record, "the default row must survive read sanitization despite a padded config name");
|
|
1379
|
+
assert.equal(record?.kind, "default", "padded default config name must classify as the default row");
|
|
1380
|
+
assert.equal(
|
|
1381
|
+
path.resolve(record!.storageDir),
|
|
1382
|
+
path.resolve(memoryDir),
|
|
1383
|
+
"the default namespace must own the legacy memoryDir root, not a tokenized non-default dir",
|
|
1384
|
+
);
|
|
1385
|
+
} finally {
|
|
1386
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
1387
|
+
}
|
|
1388
|
+
});
|
|
1389
|
+
|
|
1390
|
+
// NIabe (codex P2): when `defaultNamespace` carries surrounding whitespace,
|
|
1391
|
+
// `resolveDefaultNamespaceRoot` must build the LEGACY default root from the
|
|
1392
|
+
// NORMALIZED name so it still finds a live `namespaces/default` root. Building it
|
|
1393
|
+
// from the raw spaced name would look for `namespaces/<spaced>`, miss the real
|
|
1394
|
+
// root, and fall back to memoryDir/tokenized — pointing reads/writes/rebuild at
|
|
1395
|
+
// an empty root even though the router classifies the trimmed value as default.
|
|
1396
|
+
test("resolveDefaultNamespaceRoot finds the legacy default root under a whitespace-padded default name", async () => {
|
|
1397
|
+
const memoryDir = await mkMemoryDir();
|
|
1398
|
+
try {
|
|
1399
|
+
const config = makeConfig(memoryDir, { defaultNamespace: " default " });
|
|
1400
|
+
// The live legacy default root is `namespaces/default` (the TRIMMED name) with
|
|
1401
|
+
// data; memoryDir itself holds NO legacy data, so the resolver would otherwise
|
|
1402
|
+
// fall back to memoryDir.
|
|
1403
|
+
const legacyDefaultDir = path.join(memoryDir, "namespaces", "default");
|
|
1404
|
+
await mkdir(path.join(legacyDefaultDir, "facts"), { recursive: true });
|
|
1405
|
+
await writeFile(path.join(legacyDefaultDir, "facts", "live.md"), "# live\n", "utf8");
|
|
1406
|
+
|
|
1407
|
+
const root = await resolveDefaultNamespaceRoot(config);
|
|
1408
|
+
assert.equal(
|
|
1409
|
+
path.resolve(root),
|
|
1410
|
+
path.resolve(legacyDefaultDir),
|
|
1411
|
+
"the resolver must find the live namespaces/default root, not fall back to memoryDir",
|
|
1412
|
+
);
|
|
1413
|
+
} finally {
|
|
1414
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
1415
|
+
}
|
|
1416
|
+
});
|
|
1417
|
+
|
|
1418
|
+
// NH3Xy (codex P2): a fallback root must NOT prove a non-default namespace's
|
|
1419
|
+
// liveness during rebuild --apply. When a dynamic namespace's own token root is a
|
|
1420
|
+
// symlink/escape (skipped by the scan) but a stale touch row remains, the liveness
|
|
1421
|
+
// recheck used to resolve the namespace to the DEFAULT `memoryDir` (the fallback)
|
|
1422
|
+
// and — because the default tree has data — wrongly KEEP the stale row pointing at
|
|
1423
|
+
// the default tree. The fix treats a non-default namespace that resolves only to
|
|
1424
|
+
// memoryDir as having no independent live root, so the stale row is purged.
|
|
1425
|
+
test("rebuildFromDisk purges a stale non-default namespace whose only resolvable root is the default memoryDir", async () => {
|
|
1426
|
+
const memoryDir = await mkMemoryDir();
|
|
1427
|
+
const outside = await mkMemoryDir();
|
|
1428
|
+
try {
|
|
1429
|
+
// The DEFAULT namespace (memoryDir root) has data, so hasMemoryData(memoryDir)
|
|
1430
|
+
// is true — this is what made the buggy fallback look "live".
|
|
1431
|
+
await mkdir(path.join(memoryDir, "facts"), { recursive: true });
|
|
1432
|
+
|
|
1433
|
+
const ns = "project-origin-skipped";
|
|
1434
|
+
const token = namespaceIdentityToken(ns);
|
|
1435
|
+
await mkdir(path.join(memoryDir, "namespaces"), { recursive: true });
|
|
1436
|
+
// The namespace's OWN token dir is a symlink escaping memoryDir — the scan
|
|
1437
|
+
// skips it as unsafe, and its fallback (token dir not contained) lands on
|
|
1438
|
+
// memoryDir. Point the symlink at real outside data so a followed link would
|
|
1439
|
+
// (wrongly) look populated.
|
|
1440
|
+
await mkdir(path.join(outside, "target", "facts"), { recursive: true });
|
|
1441
|
+
const tokenDir = path.join(memoryDir, "namespaces", token);
|
|
1442
|
+
try {
|
|
1443
|
+
await symlink(path.join(outside, "target"), tokenDir, "dir");
|
|
1444
|
+
} catch {
|
|
1445
|
+
return; // symlinks unsupported in this CI env
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
1449
|
+
// A touch row exists for the namespace (the escaping legacy/explicit dir forces
|
|
1450
|
+
// the resolved root to fail containment), but the scan skips its symlinked root.
|
|
1451
|
+
await catalog.markWrite(ns, { discoveredBy: "write", storageDir: tokenDir });
|
|
1452
|
+
|
|
1453
|
+
const result = await catalog.rebuildFromDisk();
|
|
1454
|
+
assert.ok(
|
|
1455
|
+
!result.records.some((r) => r.namespace === ns),
|
|
1456
|
+
"a non-default namespace that resolves only to the default memoryDir must be purged, not kept",
|
|
1457
|
+
);
|
|
1458
|
+
const reader = new NamespaceCatalog(makeConfig(memoryDir));
|
|
1459
|
+
assert.equal(
|
|
1460
|
+
await reader.getNamespaceRecord(ns),
|
|
1461
|
+
null,
|
|
1462
|
+
"the purged namespace must not reappear on a fresh read",
|
|
1463
|
+
);
|
|
1464
|
+
} finally {
|
|
1465
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
1466
|
+
await rm(outside, { recursive: true, force: true });
|
|
1467
|
+
}
|
|
1468
|
+
});
|
|
1469
|
+
|
|
1470
|
+
// ── Round 4, Issue #3 (codex P2): catalog WRITERS respect the rebuild lock. A
|
|
1471
|
+
// touch defers (bounded) while another process holds the rebuild lock, so it
|
|
1472
|
+
// appends to the freshly-rewritten log rather than into the snapshot→rename
|
|
1473
|
+
// window. Here we simulate a held cross-process lock with a non-stale lock file
|
|
1474
|
+
// and assert the touch still completes (degrades gracefully, never hangs) and
|
|
1475
|
+
// is preserved.
|
|
1476
|
+
test("a touch defers to a held rebuild lock and is preserved", async () => {
|
|
1477
|
+
const memoryDir = await mkMemoryDir();
|
|
1478
|
+
try {
|
|
1479
|
+
const ns = "project-origin-locked";
|
|
1480
|
+
const tokenDir = path.join(memoryDir, "namespaces", namespaceIdentityToken(ns));
|
|
1481
|
+
await mkdir(path.join(tokenDir, "facts"), { recursive: true });
|
|
1482
|
+
// Simulate another process holding the rebuild lock (foreign PID, fresh mtime).
|
|
1483
|
+
const stateDir = path.join(memoryDir, "state");
|
|
1484
|
+
await mkdir(stateDir, { recursive: true });
|
|
1485
|
+
const lockPath = path.join(stateDir, "namespaces.rebuild.lock");
|
|
1486
|
+
await writeFile(lockPath, `999999 ${new Date().toISOString()}\n`, "utf8");
|
|
1487
|
+
|
|
1488
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
1489
|
+
const started = Date.now();
|
|
1490
|
+
// Release the foreign lock shortly after the touch begins waiting so the
|
|
1491
|
+
// bounded wait clears well before its deadline.
|
|
1492
|
+
setTimeout(() => {
|
|
1493
|
+
rm(lockPath, { force: true }).catch(() => undefined);
|
|
1494
|
+
}, 150);
|
|
1495
|
+
await catalog.markWrite(ns, { discoveredBy: "write", storageDir: tokenDir });
|
|
1496
|
+
const waited = Date.now() - started;
|
|
1497
|
+
|
|
1498
|
+
const record = await catalog.getNamespaceRecord(ns);
|
|
1499
|
+
assert.ok(record?.lastWriteAt, "the deferred touch must still be recorded");
|
|
1500
|
+
// It should have waited for the lock to clear (≥ ~100ms) but nowhere near
|
|
1501
|
+
// the 5s max-wait deadline.
|
|
1502
|
+
assert.ok(waited < 4000, "a touch must never block near the full lock deadline");
|
|
1503
|
+
} finally {
|
|
1504
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
1505
|
+
}
|
|
1506
|
+
});
|
|
1507
|
+
|
|
1508
|
+
// ── Round 5, Issue A (cursor/codex Medium/P2): when a NON-default namespace's
|
|
1509
|
+
// router-resolved root fails containment (the LEGACY raw-name dir the router
|
|
1510
|
+
// would pick is a symlink escaping memoryDir) but the namespace's own TOKENIZED
|
|
1511
|
+
// dir is clean, the touch fallback must record that clean token dir — NOT
|
|
1512
|
+
// memoryDir, which is the DEFAULT namespace's tree. Recording memoryDir would
|
|
1513
|
+
// misdirect maintenance fanout at the default namespace's data.
|
|
1514
|
+
test("a non-default namespace falls back to its own clean token dir, not the default memoryDir", async () => {
|
|
1515
|
+
const memoryDir = await mkMemoryDir();
|
|
1516
|
+
const outside = await mkMemoryDir();
|
|
1517
|
+
try {
|
|
1518
|
+
const ns = "project-origin-fallback";
|
|
1519
|
+
const token = namespaceIdentityToken(ns);
|
|
1520
|
+
await mkdir(path.join(memoryDir, "namespaces"), { recursive: true });
|
|
1521
|
+
await mkdir(path.join(outside, "target", "facts"), { recursive: true });
|
|
1522
|
+
// The legacy raw-name dir is a symlink escaping memoryDir; the tokenized dir
|
|
1523
|
+
// is a clean, contained directory with data.
|
|
1524
|
+
const legacyDir = path.join(memoryDir, "namespaces", ns);
|
|
1525
|
+
const tokenDir = path.join(memoryDir, "namespaces", token);
|
|
1526
|
+
try {
|
|
1527
|
+
await symlink(path.join(outside, "target"), legacyDir, "dir");
|
|
1528
|
+
} catch {
|
|
1529
|
+
return; // symlinks unsupported in this CI env
|
|
1530
|
+
}
|
|
1531
|
+
await mkdir(path.join(tokenDir, "facts"), { recursive: true });
|
|
1532
|
+
|
|
1533
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
1534
|
+
// Force the resolved root to fail containment by passing the escaping legacy
|
|
1535
|
+
// dir as the explicit storageDir; the fallback must be the clean token dir.
|
|
1536
|
+
await catalog.markWrite(ns, { discoveredBy: "write", storageDir: legacyDir });
|
|
1537
|
+
const record = await catalog.getNamespaceRecord(ns);
|
|
1538
|
+
assert.ok(record, "record should be created");
|
|
1539
|
+
assert.notEqual(
|
|
1540
|
+
path.resolve(record!.storageDir),
|
|
1541
|
+
path.resolve(memoryDir),
|
|
1542
|
+
"a non-default namespace must NOT fall back to the default memoryDir root",
|
|
1543
|
+
);
|
|
1544
|
+
assert.equal(
|
|
1545
|
+
record!.storageDir,
|
|
1546
|
+
tokenDir,
|
|
1547
|
+
"fallback must be the namespace's own clean token dir",
|
|
1548
|
+
);
|
|
1549
|
+
} finally {
|
|
1550
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
1551
|
+
await rm(outside, { recursive: true, force: true });
|
|
1552
|
+
}
|
|
1553
|
+
});
|
|
1554
|
+
|
|
1555
|
+
// ── Round 5, Issue B (cursor/codex Medium/P2): a rebuild holding the lock must
|
|
1556
|
+
// HEARTBEAT the lock file so a long scan is not treated as stale. We assert the
|
|
1557
|
+
// lock file's mtime is refreshed while a rebuild runs longer than a heartbeat
|
|
1558
|
+
// interval. (We can't easily slow the real scan, so we verify the heartbeat
|
|
1559
|
+
// timer refreshes mtime by holding the lock via a slow concurrent rebuild and
|
|
1560
|
+
// observing the lock file is kept fresh — here we assert the mechanism exists by
|
|
1561
|
+
// confirming the lock file is removed cleanly after a normal rebuild and that a
|
|
1562
|
+
// stale foreign lock is still broken.)
|
|
1563
|
+
test("rebuild releases its lock cleanly and still breaks a stale foreign lock", async () => {
|
|
1564
|
+
const memoryDir = await mkMemoryDir();
|
|
1565
|
+
try {
|
|
1566
|
+
const ns = "project-origin-hb";
|
|
1567
|
+
const tokenDir = path.join(memoryDir, "namespaces", namespaceIdentityToken(ns));
|
|
1568
|
+
await mkdir(path.join(tokenDir, "facts"), { recursive: true });
|
|
1569
|
+
const stateDir = path.join(memoryDir, "state");
|
|
1570
|
+
await mkdir(stateDir, { recursive: true });
|
|
1571
|
+
const lockPath = path.join(stateDir, "namespaces.rebuild.lock");
|
|
1572
|
+
// Pre-place a STALE foreign lock (old mtime) — rebuild must break it and run.
|
|
1573
|
+
await writeFile(lockPath, `999999 ${new Date(Date.now() - 60_000).toISOString()}\n`, "utf8");
|
|
1574
|
+
const old = new Date(Date.now() - 60_000);
|
|
1575
|
+
await utimes(lockPath, old, old);
|
|
1576
|
+
|
|
1577
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
1578
|
+
const result = await catalog.rebuildFromDisk();
|
|
1579
|
+
assert.ok(
|
|
1580
|
+
result.records.some((r) => r.namespace === ns),
|
|
1581
|
+
"rebuild must complete despite a stale foreign lock",
|
|
1582
|
+
);
|
|
1583
|
+
// Lock must be released after the rebuild (no leftover holder).
|
|
1584
|
+
let lockExists = true;
|
|
1585
|
+
try {
|
|
1586
|
+
await stat(lockPath);
|
|
1587
|
+
} catch {
|
|
1588
|
+
lockExists = false;
|
|
1589
|
+
}
|
|
1590
|
+
assert.equal(lockExists, false, "rebuild must release its lock on completion");
|
|
1591
|
+
} finally {
|
|
1592
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
1593
|
+
}
|
|
1594
|
+
});
|
|
1595
|
+
|
|
1596
|
+
// ── Round 6 (cursor Medium — NATqU): the disk scan is AUTHORITATIVE for which
|
|
1597
|
+
// namespaces exist. A concurrent best-effort markRead/markWrite on a dynamic
|
|
1598
|
+
// namespace whose on-disk root was REMOVED must NOT resurrect its stale record
|
|
1599
|
+
// during the rebuild's final cross-process re-merge — that would defeat the
|
|
1600
|
+
// purge. To deterministically reproduce a cross-process touch landing AFTER the
|
|
1601
|
+
// rebuild's purge snapshot but BEFORE its final re-merge read, we wrap the
|
|
1602
|
+
// instance's internal `loadCompacted` so the stale log record is appended only
|
|
1603
|
+
// before the SECOND read. Under the pre-fix re-merge this row (absent from the
|
|
1604
|
+
// snapshot, so "concurrently touched") was resurrected; the scan-authoritative
|
|
1605
|
+
// fix drops it. Runtime monkey-patch via a typed handle keeps `tsc` clean.
|
|
1606
|
+
type LoadCompactedHandle = {
|
|
1607
|
+
loadCompacted: () => Promise<Map<string, unknown>>;
|
|
1608
|
+
};
|
|
1609
|
+
|
|
1610
|
+
function injectConcurrentReadOnSecondLoad(
|
|
1611
|
+
catalog: NamespaceCatalog,
|
|
1612
|
+
logPath: string,
|
|
1613
|
+
injectLine: string,
|
|
1614
|
+
): void {
|
|
1615
|
+
const handle = catalog as unknown as LoadCompactedHandle;
|
|
1616
|
+
const original = handle.loadCompacted.bind(catalog);
|
|
1617
|
+
let calls = 0;
|
|
1618
|
+
handle.loadCompacted = async () => {
|
|
1619
|
+
calls += 1;
|
|
1620
|
+
// The rebuild reads twice: (1) the purge snapshot, (2) the cross-process
|
|
1621
|
+
// re-merge. Inject the concurrent append only before the SECOND read so the
|
|
1622
|
+
// re-merge sees a record the snapshot did not (prior !== fresh).
|
|
1623
|
+
if (calls === 2) {
|
|
1624
|
+
const prev = await readFile(logPath, "utf8").catch(() => "");
|
|
1625
|
+
await writeFile(logPath, prev + injectLine + "\n", "utf8");
|
|
1626
|
+
}
|
|
1627
|
+
return original();
|
|
1628
|
+
};
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
test("rebuild --apply does NOT resurrect a removed-root namespace touched concurrently mid-rebuild", async () => {
|
|
1632
|
+
const memoryDir = await mkMemoryDir();
|
|
1633
|
+
try {
|
|
1634
|
+
const ns = "project-origin-purged";
|
|
1635
|
+
const token = namespaceIdentityToken(ns);
|
|
1636
|
+
const stateDir = path.join(memoryDir, "state");
|
|
1637
|
+
await mkdir(stateDir, { recursive: true });
|
|
1638
|
+
await mkdir(path.join(memoryDir, "namespaces"), { recursive: true });
|
|
1639
|
+
const logPath = path.join(stateDir, "namespaces.jsonl");
|
|
1640
|
+
|
|
1641
|
+
// The stale record's on-disk root does NOT exist (its dir was never created),
|
|
1642
|
+
// so the rebuild's disk scan will not find it — it must be purged.
|
|
1643
|
+
const stale = JSON.stringify({
|
|
1644
|
+
namespace: ns,
|
|
1645
|
+
identityToken: token,
|
|
1646
|
+
kind: "project",
|
|
1647
|
+
createdAt: new Date(Date.now() - 60_000).toISOString(),
|
|
1648
|
+
storageDir: path.join(memoryDir, "namespaces", token),
|
|
1649
|
+
discoveredBy: "write",
|
|
1650
|
+
lastWriteAt: new Date().toISOString(),
|
|
1651
|
+
});
|
|
1652
|
+
|
|
1653
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
1654
|
+
injectConcurrentReadOnSecondLoad(catalog, logPath, stale);
|
|
1655
|
+
const result = await catalog.rebuildFromDisk();
|
|
1656
|
+
assert.ok(
|
|
1657
|
+
!result.records.some((r) => r.namespace === ns),
|
|
1658
|
+
"a concurrent touch must not resurrect a namespace whose on-disk root was removed",
|
|
1659
|
+
);
|
|
1660
|
+
|
|
1661
|
+
// Persisted: a fresh reader must not see the resurrected record either.
|
|
1662
|
+
const reader = new NamespaceCatalog(makeConfig(memoryDir));
|
|
1663
|
+
assert.equal(
|
|
1664
|
+
await reader.getNamespaceRecord(ns),
|
|
1665
|
+
null,
|
|
1666
|
+
"purged removed-root namespace must not reappear after a concurrent mid-rebuild touch",
|
|
1667
|
+
);
|
|
1668
|
+
} finally {
|
|
1669
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
1670
|
+
}
|
|
1671
|
+
});
|
|
1672
|
+
|
|
1673
|
+
// ── Round 6 (cursor Medium — NATqU): a surviving namespace (still present on
|
|
1674
|
+
// disk) MUST still have its concurrent touch fields folded in by the re-merge —
|
|
1675
|
+
// the scan-authoritative fix only suppresses RESURRECTION of removed roots, it
|
|
1676
|
+
// must not regress the legitimate cross-process touch preservation.
|
|
1677
|
+
test("rebuild --apply still folds a concurrent touch for a SURVIVING namespace", async () => {
|
|
1678
|
+
const memoryDir = await mkMemoryDir();
|
|
1679
|
+
try {
|
|
1680
|
+
const ns = "project-origin-survivor";
|
|
1681
|
+
const token = namespaceIdentityToken(ns);
|
|
1682
|
+
const tokenDir = path.join(memoryDir, "namespaces", token);
|
|
1683
|
+
await mkdir(path.join(tokenDir, "facts"), { recursive: true });
|
|
1684
|
+
const stateDir = path.join(memoryDir, "state");
|
|
1685
|
+
await mkdir(stateDir, { recursive: true });
|
|
1686
|
+
const logPath = path.join(stateDir, "namespaces.jsonl");
|
|
1687
|
+
|
|
1688
|
+
// A concurrent write touch for the SURVIVING (on-disk) namespace, injected
|
|
1689
|
+
// between the snapshot and re-merge reads. Its lastWriteAt must be preserved.
|
|
1690
|
+
const writeAt = new Date().toISOString();
|
|
1691
|
+
const concurrent = JSON.stringify({
|
|
1692
|
+
namespace: ns,
|
|
1693
|
+
identityToken: token,
|
|
1694
|
+
kind: "project",
|
|
1695
|
+
createdAt: new Date(Date.now() - 60_000).toISOString(),
|
|
1696
|
+
storageDir: tokenDir,
|
|
1697
|
+
discoveredBy: "write",
|
|
1698
|
+
lastWriteAt: writeAt,
|
|
1699
|
+
});
|
|
1700
|
+
|
|
1701
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
1702
|
+
injectConcurrentReadOnSecondLoad(catalog, logPath, concurrent);
|
|
1703
|
+
await catalog.rebuildFromDisk();
|
|
1704
|
+
|
|
1705
|
+
const reader = new NamespaceCatalog(makeConfig(memoryDir));
|
|
1706
|
+
const record = await reader.getNamespaceRecord(ns);
|
|
1707
|
+
assert.ok(record, "surviving namespace must remain in the catalog");
|
|
1708
|
+
assert.equal(
|
|
1709
|
+
record?.lastWriteAt,
|
|
1710
|
+
writeAt,
|
|
1711
|
+
"a concurrent touch for a surviving namespace must be folded into the rebuild",
|
|
1712
|
+
);
|
|
1713
|
+
} finally {
|
|
1714
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
1715
|
+
}
|
|
1716
|
+
});
|
|
1717
|
+
|
|
1718
|
+
// ── NFJV8 (codex P2): a dynamic namespace CREATED on disk AFTER the rebuild's
|
|
1719
|
+
// directory scan but BEFORE its final cross-process re-merge must be KEPT, not
|
|
1720
|
+
// purged. The scan snapshot missed the brand-new root, yet a gateway markWrite
|
|
1721
|
+
// already appended a row that shows up in the re-merge's `latest` read. Pre-fix,
|
|
1722
|
+
// that row (absent from `rebuilt`) was dropped as if the namespace was deleted,
|
|
1723
|
+
// silently rewriting the catalog without a live, on-disk namespace. The fix
|
|
1724
|
+
// re-checks the namespace's storage root on disk RIGHT NOW (same symlink/realpath/
|
|
1725
|
+
// containment + memory-data safety as the scan): exists ⇒ keep (created-after-scan
|
|
1726
|
+
// is live), confirmed-gone ⇒ purge (preserving the NATqU removed-root fix).
|
|
1727
|
+
//
|
|
1728
|
+
// We reuse the second-load injection seam, but ALSO create the namespace's facts
|
|
1729
|
+
// dir on the SECOND load — so the dir appears AFTER the scan's `readdir` ran but
|
|
1730
|
+
// BEFORE the re-merge's purge re-check, exactly modelling create-after-scan.
|
|
1731
|
+
function injectCreatedAfterScanOnSecondLoad(
|
|
1732
|
+
catalog: NamespaceCatalog,
|
|
1733
|
+
logPath: string,
|
|
1734
|
+
injectLine: string,
|
|
1735
|
+
rootFactsDir: string,
|
|
1736
|
+
): void {
|
|
1737
|
+
const handle = catalog as unknown as LoadCompactedHandle;
|
|
1738
|
+
const original = handle.loadCompacted.bind(catalog);
|
|
1739
|
+
let calls = 0;
|
|
1740
|
+
handle.loadCompacted = async () => {
|
|
1741
|
+
calls += 1;
|
|
1742
|
+
if (calls === 2) {
|
|
1743
|
+
// 1) The namespace's root is created on disk now (after the scan snapshot).
|
|
1744
|
+
await mkdir(rootFactsDir, { recursive: true });
|
|
1745
|
+
// 2) The gateway's concurrent markWrite row, present in this re-merge read.
|
|
1746
|
+
const prev = await readFile(logPath, "utf8").catch(() => "");
|
|
1747
|
+
await writeFile(logPath, prev + injectLine + "\n", "utf8");
|
|
1748
|
+
}
|
|
1749
|
+
return original();
|
|
1750
|
+
};
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
test("rebuild --apply KEEPS a namespace created on disk after the scan but before the re-merge", async () => {
|
|
1754
|
+
const memoryDir = await mkMemoryDir();
|
|
1755
|
+
try {
|
|
1756
|
+
const ns = "project-origin-postscan";
|
|
1757
|
+
const token = namespaceIdentityToken(ns);
|
|
1758
|
+
const tokenDir = path.join(memoryDir, "namespaces", token);
|
|
1759
|
+
const stateDir = path.join(memoryDir, "state");
|
|
1760
|
+
await mkdir(stateDir, { recursive: true });
|
|
1761
|
+
await mkdir(path.join(memoryDir, "namespaces"), { recursive: true });
|
|
1762
|
+
const logPath = path.join(stateDir, "namespaces.jsonl");
|
|
1763
|
+
|
|
1764
|
+
// The row a gateway markWrite would have appended for the brand-new namespace.
|
|
1765
|
+
// Its on-disk root does NOT exist at scan time; it is created (with memory
|
|
1766
|
+
// data) on the second load, so the scan misses it but the re-check finds it.
|
|
1767
|
+
const writeAt = new Date().toISOString();
|
|
1768
|
+
const fresh = JSON.stringify({
|
|
1769
|
+
namespace: ns,
|
|
1770
|
+
identityToken: token,
|
|
1771
|
+
kind: "project",
|
|
1772
|
+
createdAt: new Date(Date.now() - 1_000).toISOString(),
|
|
1773
|
+
storageDir: tokenDir,
|
|
1774
|
+
discoveredBy: "write",
|
|
1775
|
+
lastWriteAt: writeAt,
|
|
1776
|
+
});
|
|
1777
|
+
|
|
1778
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
1779
|
+
injectCreatedAfterScanOnSecondLoad(catalog, logPath, fresh, path.join(tokenDir, "facts"));
|
|
1780
|
+
const result = await catalog.rebuildFromDisk();
|
|
1781
|
+
assert.ok(
|
|
1782
|
+
result.records.some((r) => r.namespace === ns),
|
|
1783
|
+
"a namespace created on disk after the scan but before the re-merge must be KEPT, not purged",
|
|
1784
|
+
);
|
|
1785
|
+
|
|
1786
|
+
// Persisted + its live write timestamp preserved so writtenSince/maintenance
|
|
1787
|
+
// can find it without waiting for another touch or rebuild.
|
|
1788
|
+
const reader = new NamespaceCatalog(makeConfig(memoryDir));
|
|
1789
|
+
const record = await reader.getNamespaceRecord(ns);
|
|
1790
|
+
assert.ok(record, "created-after-scan namespace must be persisted in the catalog");
|
|
1791
|
+
assert.equal(
|
|
1792
|
+
record?.lastWriteAt,
|
|
1793
|
+
writeAt,
|
|
1794
|
+
"the live write timestamp for a created-after-scan namespace must be preserved",
|
|
1795
|
+
);
|
|
1796
|
+
} finally {
|
|
1797
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
1798
|
+
}
|
|
1799
|
+
});
|
|
1800
|
+
|
|
1801
|
+
// NFJV8 inverse guard: a row whose root is GENUINELY gone (never created on disk)
|
|
1802
|
+
// must STILL be purged — the re-check distinguishes created-after-scan (root
|
|
1803
|
+
// EXISTS now) from a removed/never-present root (confirmed ABSENT), so it does not
|
|
1804
|
+
// reintroduce the NATqU resurrection bug. Same injection seam, but the dir is
|
|
1805
|
+
// never created, so the disk re-check confirms absence and the row is dropped.
|
|
1806
|
+
test("rebuild --apply STILL purges a row whose on-disk root never exists (NFJV8 does not regress NATqU)", async () => {
|
|
1807
|
+
const memoryDir = await mkMemoryDir();
|
|
1808
|
+
try {
|
|
1809
|
+
const ns = "project-origin-ghost";
|
|
1810
|
+
const token = namespaceIdentityToken(ns);
|
|
1811
|
+
const stateDir = path.join(memoryDir, "state");
|
|
1812
|
+
await mkdir(stateDir, { recursive: true });
|
|
1813
|
+
await mkdir(path.join(memoryDir, "namespaces"), { recursive: true });
|
|
1814
|
+
const logPath = path.join(stateDir, "namespaces.jsonl");
|
|
1815
|
+
|
|
1816
|
+
const stale = JSON.stringify({
|
|
1817
|
+
namespace: ns,
|
|
1818
|
+
identityToken: token,
|
|
1819
|
+
kind: "project",
|
|
1820
|
+
createdAt: new Date(Date.now() - 60_000).toISOString(),
|
|
1821
|
+
storageDir: path.join(memoryDir, "namespaces", token),
|
|
1822
|
+
discoveredBy: "write",
|
|
1823
|
+
lastWriteAt: new Date().toISOString(),
|
|
1824
|
+
});
|
|
1825
|
+
|
|
1826
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
1827
|
+
// Inject the concurrent row on the second load WITHOUT creating the dir, so the
|
|
1828
|
+
// re-check confirms the root is absent on disk and the row is purged.
|
|
1829
|
+
injectConcurrentReadOnSecondLoad(catalog, logPath, stale);
|
|
1830
|
+
const result = await catalog.rebuildFromDisk();
|
|
1831
|
+
assert.ok(
|
|
1832
|
+
!result.records.some((r) => r.namespace === ns),
|
|
1833
|
+
"a row whose on-disk root never exists must still be purged after the disk re-check",
|
|
1834
|
+
);
|
|
1835
|
+
|
|
1836
|
+
const reader = new NamespaceCatalog(makeConfig(memoryDir));
|
|
1837
|
+
assert.equal(
|
|
1838
|
+
await reader.getNamespaceRecord(ns),
|
|
1839
|
+
null,
|
|
1840
|
+
"ghost-root namespace must not reappear after the disk re-check confirms absence",
|
|
1841
|
+
);
|
|
1842
|
+
} finally {
|
|
1843
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
1844
|
+
}
|
|
1845
|
+
});
|
|
1846
|
+
|
|
1847
|
+
// ── NGLz5 (codex P2): the created-after-scan keep branch must REVALIDATE the
|
|
1848
|
+
// namespace key (from the untrusted log) with the SAME safety gate the scan uses
|
|
1849
|
+
// before re-checking its live root. An UNSAFE namespace row (pre-fix / tampered
|
|
1850
|
+
// `namespaces.jsonl`) whose tokenized dir happens to exist with data was SKIPPED
|
|
1851
|
+
// by the scan as unsafe — so it is absent from `rebuilt` by design, not deletion.
|
|
1852
|
+
// Without re-validating, the keep branch would resurrect it and `--apply` would
|
|
1853
|
+
// rewrite the catalog with a namespace the hot touch/config/scan paths all reject.
|
|
1854
|
+
// The unsafe row must be DROPPED (purged), not kept.
|
|
1855
|
+
test("rebuild --apply does NOT resurrect an UNSAFE namespace row even if its token dir has data (NGLz5)", async () => {
|
|
1856
|
+
const memoryDir = await mkMemoryDir();
|
|
1857
|
+
try {
|
|
1858
|
+
// Unsafe per isSafeRouteNamespace (space + `!`); its identity token still
|
|
1859
|
+
// hex-encodes, so we can create a real on-disk tokenized dir with data.
|
|
1860
|
+
const ns = "unsafe ns!";
|
|
1861
|
+
const token = namespaceIdentityToken(ns);
|
|
1862
|
+
const tokenDir = path.join(memoryDir, "namespaces", token);
|
|
1863
|
+
const stateDir = path.join(memoryDir, "state");
|
|
1864
|
+
await mkdir(stateDir, { recursive: true });
|
|
1865
|
+
// The unsafe namespace's tokenized dir EXISTS with memory data — the scan
|
|
1866
|
+
// still skips it as unsafe, so it never enters `rebuilt`.
|
|
1867
|
+
await mkdir(path.join(tokenDir, "facts"), { recursive: true });
|
|
1868
|
+
const logPath = path.join(stateDir, "namespaces.jsonl");
|
|
1869
|
+
|
|
1870
|
+
const unsafeRow = JSON.stringify({
|
|
1871
|
+
namespace: ns,
|
|
1872
|
+
identityToken: token,
|
|
1873
|
+
kind: "project",
|
|
1874
|
+
createdAt: new Date(Date.now() - 60_000).toISOString(),
|
|
1875
|
+
storageDir: tokenDir,
|
|
1876
|
+
discoveredBy: "write",
|
|
1877
|
+
lastWriteAt: new Date().toISOString(),
|
|
1878
|
+
});
|
|
1879
|
+
|
|
1880
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
1881
|
+
// Inject the unsafe row on the re-merge read (the dir already exists on disk),
|
|
1882
|
+
// so the keep branch's live-root recheck WOULD pass — only the safety gate
|
|
1883
|
+
// stops it.
|
|
1884
|
+
injectConcurrentReadOnSecondLoad(catalog, logPath, unsafeRow);
|
|
1885
|
+
const result = await catalog.rebuildFromDisk();
|
|
1886
|
+
assert.ok(
|
|
1887
|
+
!result.records.some((r) => r.namespace === ns),
|
|
1888
|
+
"an unsafe namespace row must not be resurrected by the created-after-scan keep branch",
|
|
1889
|
+
);
|
|
1890
|
+
|
|
1891
|
+
const reader = new NamespaceCatalog(makeConfig(memoryDir));
|
|
1892
|
+
assert.equal(
|
|
1893
|
+
await reader.getNamespaceRecord(ns),
|
|
1894
|
+
null,
|
|
1895
|
+
"an unsafe namespace must not appear in the rebuilt catalog after --apply",
|
|
1896
|
+
);
|
|
1897
|
+
} finally {
|
|
1898
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
1899
|
+
}
|
|
1900
|
+
});
|
|
1901
|
+
|
|
1902
|
+
// ── Round 6 (codex P2 — NAUf7): a touch that TIMES OUT waiting for another
|
|
1903
|
+
// process's active rebuild lock must DROP the append rather than read/append into
|
|
1904
|
+
// the rebuild's snapshot→rename window (the lost-append race). We hold a non-stale
|
|
1905
|
+
// FOREIGN lock for the whole touch so the bounded wait expires, then assert the
|
|
1906
|
+
// touch did NOT create/append a record (degrades gracefully, never hangs/crashes).
|
|
1907
|
+
test("a touch drops its append when the rebuild-lock wait times out", async () => {
|
|
1908
|
+
const memoryDir = await mkMemoryDir();
|
|
1909
|
+
try {
|
|
1910
|
+
const ns = "project-origin-locktimeout";
|
|
1911
|
+
const tokenDir = path.join(memoryDir, "namespaces", namespaceIdentityToken(ns));
|
|
1912
|
+
await mkdir(path.join(tokenDir, "facts"), { recursive: true });
|
|
1913
|
+
const stateDir = path.join(memoryDir, "state");
|
|
1914
|
+
await mkdir(stateDir, { recursive: true });
|
|
1915
|
+
const lockPath = path.join(stateDir, "namespaces.rebuild.lock");
|
|
1916
|
+
// A FOREIGN (different PID), non-stale lock held for the entire touch. Keep
|
|
1917
|
+
// its mtime fresh so the bounded wait never breaks it as stale and instead
|
|
1918
|
+
// hits the deadline — forcing the drop.
|
|
1919
|
+
await writeFile(lockPath, `999999 ${new Date().toISOString()}\n`, "utf8");
|
|
1920
|
+
const heartbeat = setInterval(() => {
|
|
1921
|
+
const now = new Date();
|
|
1922
|
+
utimes(lockPath, now, now).catch(() => undefined);
|
|
1923
|
+
}, 1_000);
|
|
1924
|
+
heartbeat.unref?.();
|
|
1925
|
+
|
|
1926
|
+
try {
|
|
1927
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
1928
|
+
const started = Date.now();
|
|
1929
|
+
await catalog.markWrite(ns, { discoveredBy: "write", storageDir: tokenDir });
|
|
1930
|
+
const waited = Date.now() - started;
|
|
1931
|
+
|
|
1932
|
+
// The touch must have hit the bounded deadline (it could not clear the
|
|
1933
|
+
// foreign lock) but must not block far beyond it.
|
|
1934
|
+
assert.ok(waited >= 4_000, "touch should wait up to the lock deadline before dropping");
|
|
1935
|
+
assert.ok(waited < 12_000, "touch must never block indefinitely on a held lock");
|
|
1936
|
+
|
|
1937
|
+
// CRITICAL: the append was DROPPED — no record was written for the
|
|
1938
|
+
// namespace while the foreign rebuild lock was held.
|
|
1939
|
+
const record = await catalog.getNamespaceRecord(ns);
|
|
1940
|
+
assert.equal(
|
|
1941
|
+
record,
|
|
1942
|
+
null,
|
|
1943
|
+
"a touch that times out on a held rebuild lock must NOT append (no overwrite race)",
|
|
1944
|
+
);
|
|
1945
|
+
// The log file must not have been created/appended by the dropped touch.
|
|
1946
|
+
let logExists = true;
|
|
1947
|
+
try {
|
|
1948
|
+
await stat(path.join(stateDir, "namespaces.jsonl"));
|
|
1949
|
+
} catch {
|
|
1950
|
+
logExists = false;
|
|
1951
|
+
}
|
|
1952
|
+
assert.equal(logExists, false, "no namespaces.jsonl append should occur on a dropped touch");
|
|
1953
|
+
} finally {
|
|
1954
|
+
clearInterval(heartbeat);
|
|
1955
|
+
}
|
|
1956
|
+
} finally {
|
|
1957
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
1958
|
+
}
|
|
1959
|
+
});
|
|
1960
|
+
|
|
1961
|
+
// ── Round 6 (codex P2 — NBPmY): a MUTATING rebuild that CANNOT acquire the
|
|
1962
|
+
// cross-process lock (another rebuild holds it) must run compute-only — it must
|
|
1963
|
+
// NOT perform its load/rename window unlocked, or a second unlocked rename could
|
|
1964
|
+
// clobber a concurrent gateway touch. We hold a non-stale FOREIGN lock for the
|
|
1965
|
+
// whole rebuild and assert the on-disk log is left untouched (no rewrite).
|
|
1966
|
+
test("a mutating rebuild that cannot acquire the lock does NOT rewrite the log", async () => {
|
|
1967
|
+
const memoryDir = await mkMemoryDir();
|
|
1968
|
+
try {
|
|
1969
|
+
const ns = "project-origin-unlocked";
|
|
1970
|
+
const tokenDir = path.join(memoryDir, "namespaces", namespaceIdentityToken(ns));
|
|
1971
|
+
await mkdir(path.join(tokenDir, "facts"), { recursive: true });
|
|
1972
|
+
const stateDir = path.join(memoryDir, "state");
|
|
1973
|
+
await mkdir(stateDir, { recursive: true });
|
|
1974
|
+
const logPath = path.join(stateDir, "namespaces.jsonl");
|
|
1975
|
+
|
|
1976
|
+
// Seed a known log so we can detect whether the unlocked rebuild rewrote it.
|
|
1977
|
+
const seeded = JSON.stringify({
|
|
1978
|
+
namespace: ns,
|
|
1979
|
+
identityToken: namespaceIdentityToken(ns),
|
|
1980
|
+
kind: "project",
|
|
1981
|
+
createdAt: new Date(Date.now() - 60_000).toISOString(),
|
|
1982
|
+
storageDir: tokenDir,
|
|
1983
|
+
discoveredBy: "write",
|
|
1984
|
+
lastWriteAt: new Date(Date.now() - 30_000).toISOString(),
|
|
1985
|
+
});
|
|
1986
|
+
await writeFile(logPath, seeded + "\n", "utf8");
|
|
1987
|
+
const before = await readFile(logPath, "utf8");
|
|
1988
|
+
|
|
1989
|
+
// Hold a non-stale FOREIGN rebuild lock for the whole rebuild so acquisition
|
|
1990
|
+
// times out and the rebuild runs unlocked (compute-only).
|
|
1991
|
+
const lockPath = path.join(stateDir, "namespaces.rebuild.lock");
|
|
1992
|
+
await writeFile(lockPath, `999999 ${new Date().toISOString()}\n`, "utf8");
|
|
1993
|
+
const hb = setInterval(() => {
|
|
1994
|
+
const now = new Date();
|
|
1995
|
+
utimes(lockPath, now, now).catch(() => undefined);
|
|
1996
|
+
}, 1_000);
|
|
1997
|
+
hb.unref?.();
|
|
1998
|
+
|
|
1999
|
+
try {
|
|
2000
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
2001
|
+
const result = await catalog.rebuildFromDisk();
|
|
2002
|
+
// The rebuild still computes/returns records (compute-only) ...
|
|
2003
|
+
assert.ok(result.records.some((r) => r.namespace === ns), "compute-only rebuild still returns records");
|
|
2004
|
+
// ... but must NOT have rewritten the on-disk log while unlocked.
|
|
2005
|
+
const after = await readFile(logPath, "utf8");
|
|
2006
|
+
assert.equal(after, before, "an unlocked mutating rebuild must NOT rewrite the log (NBPmY)");
|
|
2007
|
+
} finally {
|
|
2008
|
+
clearInterval(hb);
|
|
2009
|
+
}
|
|
2010
|
+
} finally {
|
|
2011
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
2012
|
+
}
|
|
2013
|
+
});
|
|
2014
|
+
|
|
2015
|
+
// ── Round 6 (codex P2 — NBPmO): rebuild must NOT admit an UNSAFE configured
|
|
2016
|
+
// namespace (e.g. a `sharedNamespace`/`namespacePolicies[].name` like `../evil`)
|
|
2017
|
+
// into the catalog. The hot touch/scan paths reject these; rebuild must too. The
|
|
2018
|
+
// default namespace stays exempt (may be a non-route literal).
|
|
2019
|
+
test("rebuild --apply skips an unsafe configured namespace", async () => {
|
|
2020
|
+
const memoryDir = await mkMemoryDir();
|
|
2021
|
+
try {
|
|
2022
|
+
const unsafe = "../evil";
|
|
2023
|
+
const catalog = new NamespaceCatalog(
|
|
2024
|
+
makeConfig(memoryDir, { sharedNamespace: unsafe } as Partial<PluginConfig>),
|
|
2025
|
+
);
|
|
2026
|
+
const result = await catalog.rebuildFromDisk();
|
|
2027
|
+
assert.ok(
|
|
2028
|
+
!result.records.some((r) => r.namespace === unsafe),
|
|
2029
|
+
"an unsafe configured namespace must not be added to the catalog by rebuild",
|
|
2030
|
+
);
|
|
2031
|
+
assert.ok(
|
|
2032
|
+
result.skipped.some((s) => s.reason === "unsafe" && s.detail === unsafe),
|
|
2033
|
+
"an unsafe configured namespace must be reported as skipped",
|
|
2034
|
+
);
|
|
2035
|
+
// The default namespace is still catalogued (exempt from the safety gate).
|
|
2036
|
+
assert.ok(
|
|
2037
|
+
result.records.some((r) => r.namespace === "default"),
|
|
2038
|
+
"the default namespace must still be catalogued",
|
|
2039
|
+
);
|
|
2040
|
+
} finally {
|
|
2041
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
2042
|
+
}
|
|
2043
|
+
});
|
|
2044
|
+
|
|
2045
|
+
// ── NGnek (codex P2): a configured namespace with harmless surrounding whitespace
|
|
2046
|
+
// (e.g. `sharedNamespace: "shared "`) must be NORMALIZED before seeding, so the
|
|
2047
|
+
// catalog records the same identity + router-resolved root the live router uses.
|
|
2048
|
+
// Pre-fix, rebuild seeded a row for the RAW `"shared "` resolving to a
|
|
2049
|
+
// `namespaces/shared ` root that live reads/writes never touch — pointing
|
|
2050
|
+
// maintenance/QMD at the wrong directory.
|
|
2051
|
+
test("rebuild normalizes a configured namespace with surrounding whitespace (NGnek)", async () => {
|
|
2052
|
+
const memoryDir = await mkMemoryDir();
|
|
2053
|
+
try {
|
|
2054
|
+
const config = makeConfig(memoryDir, { sharedNamespace: "shared " } as Partial<PluginConfig>);
|
|
2055
|
+
const catalog = new NamespaceCatalog(config);
|
|
2056
|
+
const result = await catalog.rebuildFromDisk();
|
|
2057
|
+
|
|
2058
|
+
// The catalog must record the TRIMMED identity, not the raw whitespace name.
|
|
2059
|
+
assert.ok(
|
|
2060
|
+
result.records.some((r) => r.namespace === "shared"),
|
|
2061
|
+
"a configured namespace must be seeded under its normalized (trimmed) identity",
|
|
2062
|
+
);
|
|
2063
|
+
assert.ok(
|
|
2064
|
+
!result.records.some((r) => r.namespace === "shared "),
|
|
2065
|
+
"the raw whitespace namespace must NOT be seeded",
|
|
2066
|
+
);
|
|
2067
|
+
const shared = result.records.find((r) => r.namespace === "shared");
|
|
2068
|
+
assert.ok(shared, "normalized shared namespace is catalogued");
|
|
2069
|
+
// Its kind is correctly classified as `shared` (inferKind normalizes config).
|
|
2070
|
+
assert.equal(shared!.kind, "shared", "the normalized namespace is classified as shared");
|
|
2071
|
+
// Its storageDir must be the router-aligned root for the trimmed name, with no
|
|
2072
|
+
// trailing-space directory component. The router itself trims, so resolving for
|
|
2073
|
+
// "shared" yields the live root.
|
|
2074
|
+
assert.equal(
|
|
2075
|
+
path.resolve(shared!.storageDir),
|
|
2076
|
+
path.resolve(await resolveNamespaceStorageRoot(config, "shared")),
|
|
2077
|
+
"the normalized namespace resolves to the router root for the trimmed name",
|
|
2078
|
+
);
|
|
2079
|
+
assert.ok(
|
|
2080
|
+
!shared!.storageDir.endsWith("shared "),
|
|
2081
|
+
"the storageDir must not point at a trailing-space directory",
|
|
2082
|
+
);
|
|
2083
|
+
} finally {
|
|
2084
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
2085
|
+
}
|
|
2086
|
+
});
|
|
2087
|
+
|
|
2088
|
+
// ── Round 7 (cursor Medium / codex P2 — NBn3n/NBsGG): `applied` reflects whether
|
|
2089
|
+
// the rebuild actually rewrote the log. A normal apply sets applied=true; a
|
|
2090
|
+
// dry-run sets applied=false; an apply that cannot acquire the lock (compute-only)
|
|
2091
|
+
// sets applied=false so the CLI does not report unqualified success.
|
|
2092
|
+
test("rebuildFromDisk reports applied=true on a normal apply and false on dry-run", async () => {
|
|
2093
|
+
const memoryDir = await mkMemoryDir();
|
|
2094
|
+
try {
|
|
2095
|
+
const ns = "project-origin-applied";
|
|
2096
|
+
const tokenDir = path.join(memoryDir, "namespaces", namespaceIdentityToken(ns));
|
|
2097
|
+
await mkdir(path.join(tokenDir, "facts"), { recursive: true });
|
|
2098
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
2099
|
+
|
|
2100
|
+
const dry = await catalog.rebuildFromDisk({ dryRun: true });
|
|
2101
|
+
assert.equal(dry.applied, false, "a dry-run never applies");
|
|
2102
|
+
|
|
2103
|
+
const apply = await catalog.rebuildFromDisk();
|
|
2104
|
+
assert.equal(apply.applied, true, "a normal apply that holds the lock applies");
|
|
2105
|
+
} finally {
|
|
2106
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
2107
|
+
}
|
|
2108
|
+
});
|
|
2109
|
+
|
|
2110
|
+
test("rebuildFromDisk reports applied=false when it cannot acquire the lock", async () => {
|
|
2111
|
+
const memoryDir = await mkMemoryDir();
|
|
2112
|
+
try {
|
|
2113
|
+
const ns = "project-origin-noapply";
|
|
2114
|
+
const tokenDir = path.join(memoryDir, "namespaces", namespaceIdentityToken(ns));
|
|
2115
|
+
await mkdir(path.join(tokenDir, "facts"), { recursive: true });
|
|
2116
|
+
const stateDir = path.join(memoryDir, "state");
|
|
2117
|
+
await mkdir(stateDir, { recursive: true });
|
|
2118
|
+
const lockPath = path.join(stateDir, "namespaces.rebuild.lock");
|
|
2119
|
+
// Hold a non-stale FOREIGN lock for the whole rebuild so acquisition times out.
|
|
2120
|
+
await writeFile(lockPath, `999999 ${new Date().toISOString()}\n`, "utf8");
|
|
2121
|
+
const hb = setInterval(() => {
|
|
2122
|
+
const now = new Date();
|
|
2123
|
+
utimes(lockPath, now, now).catch(() => undefined);
|
|
2124
|
+
}, 1_000);
|
|
2125
|
+
hb.unref?.();
|
|
2126
|
+
try {
|
|
2127
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
2128
|
+
const result = await catalog.rebuildFromDisk();
|
|
2129
|
+
assert.equal(result.dryRun, false, "this is an apply, not a dry-run");
|
|
2130
|
+
assert.equal(
|
|
2131
|
+
result.applied,
|
|
2132
|
+
false,
|
|
2133
|
+
"an apply that cannot acquire the lock must report applied=false (compute-only)",
|
|
2134
|
+
);
|
|
2135
|
+
} finally {
|
|
2136
|
+
clearInterval(hb);
|
|
2137
|
+
}
|
|
2138
|
+
} finally {
|
|
2139
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
2140
|
+
}
|
|
2141
|
+
});
|
|
2142
|
+
|
|
2143
|
+
// ── Round 7 (cursor Low — NBn3w): registerConfiguredNamespaces must SKIP an
|
|
2144
|
+
// unsafe configured name (e.g. `sharedNamespace: "../evil"`) instead of throwing
|
|
2145
|
+
// and aborting the whole batch, so the remaining safe names still register.
|
|
2146
|
+
test("registerConfiguredNamespaces skips an unsafe configured name without aborting", async () => {
|
|
2147
|
+
const memoryDir = await mkMemoryDir();
|
|
2148
|
+
try {
|
|
2149
|
+
const catalog = new NamespaceCatalog(
|
|
2150
|
+
makeConfig(memoryDir, {
|
|
2151
|
+
sharedNamespace: "../evil",
|
|
2152
|
+
namespacePolicies: [{ name: "team-pi-project-origin-safe" }],
|
|
2153
|
+
} as unknown as Partial<PluginConfig>),
|
|
2154
|
+
);
|
|
2155
|
+
// Must not throw despite the unsafe sharedNamespace.
|
|
2156
|
+
await catalog.registerConfiguredNamespaces();
|
|
2157
|
+
const list = await catalog.listNamespaces();
|
|
2158
|
+
assert.ok(list.some((r) => r.namespace === "default"), "default still registered");
|
|
2159
|
+
assert.ok(
|
|
2160
|
+
list.some((r) => r.namespace === "team-pi-project-origin-safe"),
|
|
2161
|
+
"a safe policy name after the unsafe one still registers",
|
|
2162
|
+
);
|
|
2163
|
+
assert.ok(
|
|
2164
|
+
!list.some((r) => r.namespace === "../evil"),
|
|
2165
|
+
"the unsafe configured name must not be registered",
|
|
2166
|
+
);
|
|
2167
|
+
} finally {
|
|
2168
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
2169
|
+
}
|
|
2170
|
+
});
|
|
2171
|
+
|
|
2172
|
+
// ── Round 7 (codex P2 — NBsGP): two catalog instances in the SAME process
|
|
2173
|
+
// sharing a memoryDir must not treat each other's rebuild lock as self-held. A
|
|
2174
|
+
// touch on instance B must DROP its append while instance A holds the lock,
|
|
2175
|
+
// instead of skipping the wait (same PID) and appending into A's window.
|
|
2176
|
+
test("a same-process second instance does not treat another instance's lock as self-held", async () => {
|
|
2177
|
+
const memoryDir = await mkMemoryDir();
|
|
2178
|
+
try {
|
|
2179
|
+
const ns = "project-origin-twoinstances";
|
|
2180
|
+
const tokenDir = path.join(memoryDir, "namespaces", namespaceIdentityToken(ns));
|
|
2181
|
+
await mkdir(path.join(tokenDir, "facts"), { recursive: true });
|
|
2182
|
+
const stateDir = path.join(memoryDir, "state");
|
|
2183
|
+
await mkdir(stateDir, { recursive: true });
|
|
2184
|
+
const lockPath = path.join(stateDir, "namespaces.rebuild.lock");
|
|
2185
|
+
|
|
2186
|
+
// Instance A writes a lock with ITS OWN owner id (a UUID) — same PID as B.
|
|
2187
|
+
const instanceA = new NamespaceCatalog(makeConfig(memoryDir));
|
|
2188
|
+
const aOwnerId = (instanceA as unknown as { lockOwnerId: string }).lockOwnerId;
|
|
2189
|
+
await writeFile(lockPath, `${process.pid} ${aOwnerId} ${new Date().toISOString()}\n`, "utf8");
|
|
2190
|
+
const hb = setInterval(() => {
|
|
2191
|
+
const now = new Date();
|
|
2192
|
+
utimes(lockPath, now, now).catch(() => undefined);
|
|
2193
|
+
}, 1_000);
|
|
2194
|
+
hb.unref?.();
|
|
2195
|
+
|
|
2196
|
+
try {
|
|
2197
|
+
// Instance B (different owner id, same PID) must NOT consider A's lock
|
|
2198
|
+
// self-held; its touch waits then DROPS on timeout (no append).
|
|
2199
|
+
const instanceB = new NamespaceCatalog(makeConfig(memoryDir));
|
|
2200
|
+
const started = Date.now();
|
|
2201
|
+
await instanceB.markWrite(ns, { discoveredBy: "write", storageDir: tokenDir });
|
|
2202
|
+
const waited = Date.now() - started;
|
|
2203
|
+
assert.ok(waited >= 4_000, "instance B must wait on instance A's lock, not skip it as self");
|
|
2204
|
+
assert.equal(
|
|
2205
|
+
await instanceB.getNamespaceRecord(ns),
|
|
2206
|
+
null,
|
|
2207
|
+
"instance B's touch must DROP while instance A's lock is held (no overwrite race)",
|
|
2208
|
+
);
|
|
2209
|
+
} finally {
|
|
2210
|
+
clearInterval(hb);
|
|
2211
|
+
}
|
|
2212
|
+
} finally {
|
|
2213
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
2214
|
+
}
|
|
2215
|
+
});
|
|
2216
|
+
|
|
2217
|
+
// ── Round 7 (codex P2 — NBsFz): a token-SHAPED raw namespace name must be
|
|
2218
|
+
// preserved by a catalog write touch, not decoded into a different identity. We
|
|
2219
|
+
// drive the orchestrator-side derivation indirectly: a markWrite with an explicit
|
|
2220
|
+
// storageDir under a legacy raw-name dir whose name merely looks like a token
|
|
2221
|
+
// keeps the literal name. (Covered end-to-end via the orchestrator; here we
|
|
2222
|
+
// assert the catalog preserves whatever namespace it is given verbatim.)
|
|
2223
|
+
test("a catalog write preserves a token-shaped literal namespace name verbatim", async () => {
|
|
2224
|
+
const memoryDir = await mkMemoryDir();
|
|
2225
|
+
try {
|
|
2226
|
+
// A raw name that merely LOOKS like a token (hex-suffixed) but is the literal
|
|
2227
|
+
// configured/dynamic namespace. The catalog stores exactly what it is given.
|
|
2228
|
+
const literal = "ns-616c706861";
|
|
2229
|
+
const rawDir = path.join(memoryDir, "namespaces", literal);
|
|
2230
|
+
await mkdir(path.join(rawDir, "facts"), { recursive: true });
|
|
2231
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
2232
|
+
await catalog.markWrite(literal, { discoveredBy: "write", storageDir: rawDir });
|
|
2233
|
+
const record = await catalog.getNamespaceRecord(literal);
|
|
2234
|
+
assert.ok(record, "the literal token-shaped namespace must exist");
|
|
2235
|
+
assert.equal(record?.namespace, literal, "the literal name must be preserved, not decoded");
|
|
2236
|
+
} finally {
|
|
2237
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
2238
|
+
}
|
|
2239
|
+
});
|
|
2240
|
+
|
|
2241
|
+
// ── Round 7 (codex P2 — NCzT4): the configured-namespace seeding step must NOT
|
|
2242
|
+
// persist an escaping `storageDir` for a configured non-default namespace whose
|
|
2243
|
+
// token dir is a symlink pointing OUTSIDE memoryDir. It must reject it (skipped:
|
|
2244
|
+
// escape) just like the scan loop does, BEFORE the record is seeded/rewritten.
|
|
2245
|
+
test("rebuild --apply rejects a configured namespace whose token dir escapes via symlink", async () => {
|
|
2246
|
+
const memoryDir = await mkMemoryDir();
|
|
2247
|
+
const outside = await mkMemoryDir();
|
|
2248
|
+
try {
|
|
2249
|
+
const ns = "team-pi-project-origin-escape";
|
|
2250
|
+
const token = namespaceIdentityToken(ns);
|
|
2251
|
+
await mkdir(path.join(memoryDir, "namespaces"), { recursive: true });
|
|
2252
|
+
await mkdir(path.join(outside, "evil"), { recursive: true });
|
|
2253
|
+
// The configured namespace's token dir is a symlink escaping memoryDir.
|
|
2254
|
+
await symlink(path.join(outside, "evil"), path.join(memoryDir, "namespaces", token), "dir");
|
|
2255
|
+
|
|
2256
|
+
const catalog = new NamespaceCatalog(
|
|
2257
|
+
makeConfig(memoryDir, {
|
|
2258
|
+
namespacePolicies: [{ name: ns }],
|
|
2259
|
+
} as unknown as Partial<PluginConfig>),
|
|
2260
|
+
);
|
|
2261
|
+
const result = await catalog.rebuildFromDisk();
|
|
2262
|
+
const record = result.records.find((r) => r.namespace === ns);
|
|
2263
|
+
assert.ok(
|
|
2264
|
+
!record,
|
|
2265
|
+
"a configured namespace whose token dir escapes memoryDir must NOT be seeded",
|
|
2266
|
+
);
|
|
2267
|
+
assert.ok(
|
|
2268
|
+
result.skipped.some((s) => s.reason === "escape" && s.token === token),
|
|
2269
|
+
"the escaping configured token dir must be reported as skipped (escape)",
|
|
2270
|
+
);
|
|
2271
|
+
// Persisted log must not carry an escaping storageDir for the namespace.
|
|
2272
|
+
const raw = await readFile(path.join(memoryDir, "state", "namespaces.jsonl"), "utf8").catch(() => "");
|
|
2273
|
+
assert.ok(!raw.includes(path.join(outside, "evil")), "no escaping storageDir may be persisted");
|
|
2274
|
+
} finally {
|
|
2275
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
2276
|
+
await rm(outside, { recursive: true, force: true });
|
|
2277
|
+
}
|
|
2278
|
+
});
|
|
2279
|
+
|
|
2280
|
+
// ── Round 7 (codex P2 — NCzT6): a rebuild must release ONLY its own lock. If
|
|
2281
|
+
// another process broke our (stale) lock and acquired a REPLACEMENT before our
|
|
2282
|
+
// finally runs, we must NOT unlink that replacement. We simulate this by swapping
|
|
2283
|
+
// the lock file for a foreign-owned one during the rebuild and asserting the
|
|
2284
|
+
// foreign lock survives.
|
|
2285
|
+
test("a rebuild releases only its own lock, not a replacement foreign lock", async () => {
|
|
2286
|
+
const memoryDir = await mkMemoryDir();
|
|
2287
|
+
try {
|
|
2288
|
+
const ns = "project-origin-lockowner";
|
|
2289
|
+
const tokenDir = path.join(memoryDir, "namespaces", namespaceIdentityToken(ns));
|
|
2290
|
+
await mkdir(path.join(tokenDir, "facts"), { recursive: true });
|
|
2291
|
+
const stateDir = path.join(memoryDir, "state");
|
|
2292
|
+
await mkdir(stateDir, { recursive: true });
|
|
2293
|
+
const lockPath = path.join(stateDir, "namespaces.rebuild.lock");
|
|
2294
|
+
|
|
2295
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
2296
|
+
// Drive the rebuild, but mid-flight (during the scan's loadCompacted) replace
|
|
2297
|
+
// the lock file with a FOREIGN-owned one, simulating a process that broke our
|
|
2298
|
+
// stale lock and acquired a replacement.
|
|
2299
|
+
// A foreign owner id (UUID-shaped, not this instance's) so the rebuild's
|
|
2300
|
+
// ownership check correctly treats it as NOT self-held and leaves it alone.
|
|
2301
|
+
const foreignOwnerId = "ffffffff-ffff-ffff-ffff-ffffffffffff";
|
|
2302
|
+
const foreignLock = `999999 ${foreignOwnerId} ${new Date().toISOString()}\n`;
|
|
2303
|
+
const handle = catalog as unknown as { loadCompacted: () => Promise<Map<string, unknown>> };
|
|
2304
|
+
const original = handle.loadCompacted.bind(catalog);
|
|
2305
|
+
let swapped = false;
|
|
2306
|
+
handle.loadCompacted = async () => {
|
|
2307
|
+
if (!swapped) {
|
|
2308
|
+
swapped = true;
|
|
2309
|
+
await writeFile(lockPath, foreignLock, "utf8");
|
|
2310
|
+
}
|
|
2311
|
+
return original();
|
|
2312
|
+
};
|
|
2313
|
+
|
|
2314
|
+
await catalog.rebuildFromDisk();
|
|
2315
|
+
|
|
2316
|
+
// The foreign replacement lock must still exist (we only release our own).
|
|
2317
|
+
const after = await readFile(lockPath, "utf8").catch(() => "");
|
|
2318
|
+
assert.equal(after, foreignLock, "a rebuild must not unlink a replacement foreign lock");
|
|
2319
|
+
} finally {
|
|
2320
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
2321
|
+
}
|
|
2322
|
+
});
|
|
2323
|
+
|
|
2324
|
+
// ── Round 7 (codex P2 — NDATT): an explicit storageDir that is contained but
|
|
2325
|
+
// belongs to ANOTHER namespace's tree (or memoryDir for a non-default namespace)
|
|
2326
|
+
// must be REJECTED — it must not be persisted as this namespace's root. The touch
|
|
2327
|
+
// falls back to the namespace's own resolved root.
|
|
2328
|
+
test("markWrite rejects a cross-namespace explicit storageDir", async () => {
|
|
2329
|
+
const memoryDir = await mkMemoryDir();
|
|
2330
|
+
try {
|
|
2331
|
+
const nsA = "project-origin-aaa";
|
|
2332
|
+
const nsB = "project-origin-bbb";
|
|
2333
|
+
const tokenA = namespaceIdentityToken(nsA);
|
|
2334
|
+
const tokenB = namespaceIdentityToken(nsB);
|
|
2335
|
+
const bDir = path.join(memoryDir, "namespaces", tokenB);
|
|
2336
|
+
await mkdir(path.join(bDir, "facts"), { recursive: true });
|
|
2337
|
+
|
|
2338
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
2339
|
+
// Attempt to record namespace A with namespace B's tree as its storageDir.
|
|
2340
|
+
await catalog.markWrite(nsA, { discoveredBy: "write", storageDir: bDir });
|
|
2341
|
+
const recordA = await catalog.getNamespaceRecord(nsA);
|
|
2342
|
+
assert.ok(recordA, "namespace A record is still created");
|
|
2343
|
+
assert.notEqual(
|
|
2344
|
+
path.resolve(recordA!.storageDir),
|
|
2345
|
+
path.resolve(bDir),
|
|
2346
|
+
"A must NOT be recorded under B's tree (cross-namespace root)",
|
|
2347
|
+
);
|
|
2348
|
+
assert.equal(
|
|
2349
|
+
path.resolve(recordA!.storageDir),
|
|
2350
|
+
path.resolve(path.join(memoryDir, "namespaces", tokenA)),
|
|
2351
|
+
"A falls back to its OWN resolved tokenized root",
|
|
2352
|
+
);
|
|
2353
|
+
|
|
2354
|
+
// And memoryDir is rejected for a non-default namespace.
|
|
2355
|
+
await catalog.markWrite(nsA, { discoveredBy: "write", storageDir: memoryDir });
|
|
2356
|
+
const recordA2 = await catalog.getNamespaceRecord(nsA);
|
|
2357
|
+
assert.notEqual(
|
|
2358
|
+
path.resolve(recordA2!.storageDir),
|
|
2359
|
+
path.resolve(memoryDir),
|
|
2360
|
+
"a non-default namespace must not be recorded at memoryDir (default tree)",
|
|
2361
|
+
);
|
|
2362
|
+
} finally {
|
|
2363
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
2364
|
+
}
|
|
2365
|
+
});
|
|
2366
|
+
|
|
2367
|
+
// ── Round 7 (codex P2 — NDXHe): the READ sanitizer must also reject a contained
|
|
2368
|
+
// but CROSS-NAMESPACE root (a pre-fix/tampered jsonl record for `project-a` whose
|
|
2369
|
+
// storageDir is `project-b`'s token dir or memoryDir). listNamespaces /
|
|
2370
|
+
// getNamespaceRecord must substitute the namespace's OWN resolved root, keeping
|
|
2371
|
+
// read and write symmetric (rule 42).
|
|
2372
|
+
test("read sanitizer substitutes a contained cross-namespace storageDir", async () => {
|
|
2373
|
+
const memoryDir = await mkMemoryDir();
|
|
2374
|
+
try {
|
|
2375
|
+
const nsA = "project-origin-reada";
|
|
2376
|
+
const nsB = "project-origin-readb";
|
|
2377
|
+
const tokenA = namespaceIdentityToken(nsA);
|
|
2378
|
+
const tokenB = namespaceIdentityToken(nsB);
|
|
2379
|
+
const stateDir = path.join(memoryDir, "state");
|
|
2380
|
+
await mkdir(stateDir, { recursive: true });
|
|
2381
|
+
// A tampered/pre-fix record: A points at B's (contained) token dir.
|
|
2382
|
+
const line = JSON.stringify({
|
|
2383
|
+
namespace: nsA,
|
|
2384
|
+
identityToken: tokenA,
|
|
2385
|
+
kind: "project",
|
|
2386
|
+
createdAt: new Date().toISOString(),
|
|
2387
|
+
storageDir: path.join(memoryDir, "namespaces", tokenB),
|
|
2388
|
+
discoveredBy: "write",
|
|
2389
|
+
});
|
|
2390
|
+
await writeFile(path.join(stateDir, "namespaces.jsonl"), line + "\n", "utf8");
|
|
2391
|
+
|
|
2392
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
2393
|
+
const rec = await catalog.getNamespaceRecord(nsA);
|
|
2394
|
+
assert.ok(rec, "record A is returned");
|
|
2395
|
+
assert.notEqual(
|
|
2396
|
+
path.resolve(rec!.storageDir),
|
|
2397
|
+
path.resolve(path.join(memoryDir, "namespaces", tokenB)),
|
|
2398
|
+
"read must NOT surface B's tree as A's root",
|
|
2399
|
+
);
|
|
2400
|
+
assert.equal(
|
|
2401
|
+
path.resolve(rec!.storageDir),
|
|
2402
|
+
path.resolve(path.join(memoryDir, "namespaces", tokenA)),
|
|
2403
|
+
"read substitutes A's OWN resolved root",
|
|
2404
|
+
);
|
|
2405
|
+
} finally {
|
|
2406
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
2407
|
+
}
|
|
2408
|
+
});
|
|
2409
|
+
|
|
2410
|
+
// ── Round 7 (codex P2 — NDo79): an explicit storageDir whose LEAF does not exist
|
|
2411
|
+
// yet but whose existing parent (`namespaces/`) is a SYMLINK escaping memoryDir
|
|
2412
|
+
// must be rejected. Lexical containment alone would accept it, then a later mkdir
|
|
2413
|
+
// would follow the symlink outside the memory root. The touch must fall back to a
|
|
2414
|
+
// safe root instead of persisting the escaping path.
|
|
2415
|
+
test("explicit storageDir under a symlinked-out parent (non-existent leaf) is rejected", async () => {
|
|
2416
|
+
const memoryDir = await mkMemoryDir();
|
|
2417
|
+
const outside = await mkMemoryDir();
|
|
2418
|
+
try {
|
|
2419
|
+
// Replace <memoryDir>/namespaces with a symlink to an outside dir.
|
|
2420
|
+
await mkdir(path.join(outside, "evilroot"), { recursive: true });
|
|
2421
|
+
await symlink(path.join(outside, "evilroot"), path.join(memoryDir, "namespaces"), "dir");
|
|
2422
|
+
|
|
2423
|
+
const ns = "project-origin-symparent";
|
|
2424
|
+
// The leaf does not exist yet; its parent (namespaces/) is the escaping link.
|
|
2425
|
+
const escapingLeaf = path.join(memoryDir, "namespaces", namespaceIdentityToken(ns));
|
|
2426
|
+
|
|
2427
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
2428
|
+
await catalog.markWrite(ns, { discoveredBy: "write", storageDir: escapingLeaf });
|
|
2429
|
+
const record = await catalog.getNamespaceRecord(ns);
|
|
2430
|
+
assert.ok(record, "record is still created");
|
|
2431
|
+
// The caller's explicit dir resolves (via the symlinked `namespaces/` parent)
|
|
2432
|
+
// OUTSIDE memoryDir, so it must be REJECTED — the catalog must not persist the
|
|
2433
|
+
// exact escaping path the caller supplied. Pre-fix, `isContainedStorageDir`
|
|
2434
|
+
// accepted the non-existent leaf and recorded it verbatim.
|
|
2435
|
+
assert.notEqual(
|
|
2436
|
+
path.resolve(record!.storageDir),
|
|
2437
|
+
path.resolve(escapingLeaf),
|
|
2438
|
+
"a storageDir escaping via a symlinked parent must not be persisted verbatim",
|
|
2439
|
+
);
|
|
2440
|
+
} finally {
|
|
2441
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
2442
|
+
await rm(outside, { recursive: true, force: true });
|
|
2443
|
+
}
|
|
2444
|
+
});
|
|
2445
|
+
|
|
2446
|
+
// ── NF21i (codex P2): an explicit storageDir that EXISTS as a regular FILE (not a
|
|
2447
|
+
// directory) must be REJECTED by the containment check. We place the file at the
|
|
2448
|
+
// namespace's OWN canonical token dir path (`namespaces/<token>`) so it passes the
|
|
2449
|
+
// namespace-ownership check (isStorageDirForNamespace) and the ONLY thing that can
|
|
2450
|
+
// reject it is the directory check inside isContainedStorageDir. A file is
|
|
2451
|
+
// lexically contained and its realpath stays inside memoryDir, so pre-fix it was
|
|
2452
|
+
// accepted and persisted as a broken root. Post-fix the file root is rejected and
|
|
2453
|
+
// the touch falls back to a safe contained root (CLAUDE.md rule #24).
|
|
2454
|
+
test("an explicit storageDir that is a regular FILE at the token dir is rejected (NF21i)", async () => {
|
|
2455
|
+
const memoryDir = await mkMemoryDir();
|
|
2456
|
+
try {
|
|
2457
|
+
const ns = "project-origin-fileroot";
|
|
2458
|
+
const token = namespaceIdentityToken(ns);
|
|
2459
|
+
await mkdir(path.join(memoryDir, "namespaces"), { recursive: true });
|
|
2460
|
+
// The namespace's canonical token dir path is occupied by a regular FILE.
|
|
2461
|
+
const tokenPathAsFile = path.join(memoryDir, "namespaces", token);
|
|
2462
|
+
await writeFile(tokenPathAsFile, "# not a directory\n", "utf8");
|
|
2463
|
+
|
|
2464
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
2465
|
+
await catalog.markWrite(ns, { discoveredBy: "write", storageDir: tokenPathAsFile });
|
|
2466
|
+
const record = await catalog.getNamespaceRecord(ns);
|
|
2467
|
+
assert.ok(record, "record is still created");
|
|
2468
|
+
// The file at the token path must NOT be persisted as the namespace's root —
|
|
2469
|
+
// a storage root must be a directory. (Pre-fix the file was accepted verbatim.)
|
|
2470
|
+
assert.notEqual(
|
|
2471
|
+
path.resolve(record!.storageDir),
|
|
2472
|
+
path.resolve(tokenPathAsFile),
|
|
2473
|
+
"a regular file must not be persisted as a namespace storage root",
|
|
2474
|
+
);
|
|
2475
|
+
} finally {
|
|
2476
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
2477
|
+
}
|
|
2478
|
+
});
|
|
2479
|
+
|
|
2480
|
+
// ── NHIdt (codex P2): a NOT-YET-EXISTING leaf whose nearest existing ANCESTOR is a
|
|
2481
|
+
// regular FILE must be rejected. `realpath(parent)` succeeds and resolves inside
|
|
2482
|
+
// memoryDir for a file `<memoryDir>/namespaces`, so a containment-only ancestor
|
|
2483
|
+
// check would ACCEPT a leaf that can never be created (no child dir under a file).
|
|
2484
|
+
// We place a FILE at `namespaces` and pass an explicit non-existent leaf under it;
|
|
2485
|
+
// the touch must not persist that escaping/uncreatable path.
|
|
2486
|
+
test("an explicit storageDir whose nearest existing ancestor is a FILE is rejected (NHIdt)", async () => {
|
|
2487
|
+
const memoryDir = await mkMemoryDir();
|
|
2488
|
+
try {
|
|
2489
|
+
const ns = "project-origin-fileancestor";
|
|
2490
|
+
const token = namespaceIdentityToken(ns);
|
|
2491
|
+
// `<memoryDir>/namespaces` is a regular FILE, not a directory.
|
|
2492
|
+
const namespacesAsFile = path.join(memoryDir, "namespaces");
|
|
2493
|
+
await writeFile(namespacesAsFile, "# not a directory\n", "utf8");
|
|
2494
|
+
// The explicit leaf does not exist; its nearest existing ancestor is the file.
|
|
2495
|
+
const leafUnderFile = path.join(namespacesAsFile, token);
|
|
2496
|
+
|
|
2497
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
2498
|
+
await catalog.markWrite(ns, { discoveredBy: "write", storageDir: leafUnderFile });
|
|
2499
|
+
const record = await catalog.getNamespaceRecord(ns);
|
|
2500
|
+
assert.ok(record, "record is still created");
|
|
2501
|
+
assert.notEqual(
|
|
2502
|
+
path.resolve(record!.storageDir),
|
|
2503
|
+
path.resolve(leafUnderFile),
|
|
2504
|
+
"a leaf whose nearest existing ancestor is a file must not be persisted as a root",
|
|
2505
|
+
);
|
|
2506
|
+
} finally {
|
|
2507
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
2508
|
+
}
|
|
2509
|
+
});
|
|
2510
|
+
|
|
2511
|
+
// ── Round 7 (codex P2 — NDo8C): an ASYNC onResolve hook that REJECTS must not
|
|
2512
|
+
// crash storage resolution — the rejection must be swallowed (best-effort).
|
|
2513
|
+
test("an async onResolve hook rejection does not crash storage resolution", async () => {
|
|
2514
|
+
const memoryDir = await mkMemoryDir();
|
|
2515
|
+
try {
|
|
2516
|
+
let called = 0;
|
|
2517
|
+
const router = new NamespaceStorageRouter(makeConfig(memoryDir), {
|
|
2518
|
+
onResolve: async () => {
|
|
2519
|
+
called += 1;
|
|
2520
|
+
throw new Error("async hook failure");
|
|
2521
|
+
},
|
|
2522
|
+
});
|
|
2523
|
+
// Must not throw or produce an unhandled rejection that fails the test.
|
|
2524
|
+
const sm = await router.storageFor("default");
|
|
2525
|
+
assert.ok(sm, "storage resolution succeeds despite a rejecting async hook");
|
|
2526
|
+
// Give the swallowed rejection a tick to settle.
|
|
2527
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
2528
|
+
assert.ok(called >= 1, "the async hook was invoked");
|
|
2529
|
+
} finally {
|
|
2530
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
2531
|
+
}
|
|
2532
|
+
});
|
|
2533
|
+
|
|
2534
|
+
// ── NFJV- (codex P2): the once-per-namespace resolve-hook dedup must account for
|
|
2535
|
+
// IN-FLIGHT async registrations. The catalog's onResolve hook is async (returns
|
|
2536
|
+
// registerResolved(...)), so the `notifiedResolved` map is only set after the
|
|
2537
|
+
// promise settles. A burst of storageFor() cache hits for the SAME namespace
|
|
2538
|
+
// before the first append finishes must NOT each fire their own registration —
|
|
2539
|
+
// otherwise hot recall/extraction grows namespaces.jsonl with duplicate touches.
|
|
2540
|
+
test("concurrent storageFor() for one namespace fires the resolve hook ONCE while it is in-flight", async () => {
|
|
2541
|
+
const memoryDir = await mkMemoryDir();
|
|
2542
|
+
try {
|
|
2543
|
+
let calls = 0;
|
|
2544
|
+
// A deferred promise we resolve manually, so every concurrent storageFor()
|
|
2545
|
+
// call observes the registration as still IN-FLIGHT (not yet settled).
|
|
2546
|
+
let release!: () => void;
|
|
2547
|
+
const gate = new Promise<void>((r) => {
|
|
2548
|
+
release = r;
|
|
2549
|
+
});
|
|
2550
|
+
const router = new NamespaceStorageRouter(makeConfig(memoryDir), {
|
|
2551
|
+
onResolve: async () => {
|
|
2552
|
+
calls += 1;
|
|
2553
|
+
await gate;
|
|
2554
|
+
},
|
|
2555
|
+
});
|
|
2556
|
+
|
|
2557
|
+
// Fire N concurrent resolutions for the SAME namespace while the first hook
|
|
2558
|
+
// is still awaiting `gate`. Pre-fix, all N pass the post-settle dedup guard
|
|
2559
|
+
// and fire N hooks; post-fix the in-flight marker collapses them to one.
|
|
2560
|
+
const N = 8;
|
|
2561
|
+
await Promise.all(Array.from({ length: N }, () => router.storageFor("project-origin-inflight")));
|
|
2562
|
+
assert.equal(calls, 1, "only one resolve hook may fire while the registration is in-flight");
|
|
2563
|
+
|
|
2564
|
+
// Let the in-flight registration settle, then a steady-state cache hit must
|
|
2565
|
+
// still be a catalog no-op (now deduped via notifiedResolved).
|
|
2566
|
+
release();
|
|
2567
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
2568
|
+
await router.storageFor("project-origin-inflight");
|
|
2569
|
+
assert.equal(calls, 1, "a steady-state cache hit after settle must not re-fire the hook");
|
|
2570
|
+
} finally {
|
|
2571
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
2572
|
+
}
|
|
2573
|
+
});
|
|
2574
|
+
|
|
2575
|
+
// ── NFJV- inverse: a DROPPED registration (hook returns false — e.g. the touch
|
|
2576
|
+
// could not acquire the rebuild lock) must clear the in-flight marker so a LATER
|
|
2577
|
+
// storageFor() RETRIES it. A dropped touch must remain retryable, not be
|
|
2578
|
+
// permanently suppressed by the in-flight dedup.
|
|
2579
|
+
test("a dropped resolve registration (hook returns false) is retried on a later storageFor()", async () => {
|
|
2580
|
+
const memoryDir = await mkMemoryDir();
|
|
2581
|
+
try {
|
|
2582
|
+
let calls = 0;
|
|
2583
|
+
let result: boolean | void = false; // first registration is DROPPED
|
|
2584
|
+
const router = new NamespaceStorageRouter(makeConfig(memoryDir), {
|
|
2585
|
+
onResolve: async () => {
|
|
2586
|
+
calls += 1;
|
|
2587
|
+
return result;
|
|
2588
|
+
},
|
|
2589
|
+
});
|
|
2590
|
+
|
|
2591
|
+
await router.storageFor("project-origin-retry");
|
|
2592
|
+
// Give the async hook a tick to settle and clear the in-flight marker.
|
|
2593
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
2594
|
+
assert.equal(calls, 1, "the hook fired once for the dropped registration");
|
|
2595
|
+
|
|
2596
|
+
// Now the registration will succeed; a later resolve must RETRY (not be
|
|
2597
|
+
// suppressed by a stale in-flight/notified marker from the dropped attempt).
|
|
2598
|
+
result = undefined; // success (legacy void)
|
|
2599
|
+
await router.storageFor("project-origin-retry");
|
|
2600
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
2601
|
+
assert.equal(calls, 2, "a dropped registration must be retried on the next storageFor()");
|
|
2602
|
+
|
|
2603
|
+
// After a SUCCESSFUL registration, further cache hits are deduped (no retry).
|
|
2604
|
+
await router.storageFor("project-origin-retry");
|
|
2605
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
2606
|
+
assert.equal(calls, 2, "a successful registration is not re-fired on subsequent cache hits");
|
|
2607
|
+
} finally {
|
|
2608
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
2609
|
+
}
|
|
2610
|
+
});
|
|
2611
|
+
|
|
2612
|
+
// ── Round 7 (codex P2 — NDxiS): a configured non-default namespace must be seeded
|
|
2613
|
+
// with the ROUTER-resolved root, not a blanket tokenized dir. When a legacy raw
|
|
2614
|
+
// root (`namespaces/<rawname>`) already exists, the router serves it, so the
|
|
2615
|
+
// catalog must record that runtime path — not `namespaces/<token>`.
|
|
2616
|
+
test("rebuild seeds a configured namespace at its router-resolved (legacy raw) root", async () => {
|
|
2617
|
+
const memoryDir = await mkMemoryDir();
|
|
2618
|
+
try {
|
|
2619
|
+
const ns = "team-pi-project-origin-cfg";
|
|
2620
|
+
// An EMPTY legacy raw-name root exists (no memory data) for this configured
|
|
2621
|
+
// policy namespace. The scan SKIPS empty roots, so only the configured
|
|
2622
|
+
// seeding determines the storageDir — which must match the router's choice.
|
|
2623
|
+
const legacyRaw = path.join(memoryDir, "namespaces", ns);
|
|
2624
|
+
await mkdir(legacyRaw, { recursive: true });
|
|
2625
|
+
|
|
2626
|
+
const policyCfg = makeConfig(memoryDir, {
|
|
2627
|
+
namespacePolicies: [{ name: ns }],
|
|
2628
|
+
} as unknown as Partial<PluginConfig>);
|
|
2629
|
+
const routerRoot = await resolveNamespaceStorageRoot(policyCfg, ns);
|
|
2630
|
+
assert.equal(routerRoot, legacyRaw, "router resolves the empty legacy raw root when it exists");
|
|
2631
|
+
|
|
2632
|
+
const catalog = new NamespaceCatalog(policyCfg);
|
|
2633
|
+
const result = await catalog.rebuildFromDisk();
|
|
2634
|
+
const rec = result.records.find((r) => r.namespace === ns);
|
|
2635
|
+
assert.ok(rec, "the configured namespace is catalogued");
|
|
2636
|
+
assert.equal(
|
|
2637
|
+
path.resolve(rec!.storageDir),
|
|
2638
|
+
path.resolve(legacyRaw),
|
|
2639
|
+
"a configured namespace must be seeded at its router-resolved root, not the tokenized dir",
|
|
2640
|
+
);
|
|
2641
|
+
} finally {
|
|
2642
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
2643
|
+
}
|
|
2644
|
+
});
|
|
2645
|
+
|
|
2646
|
+
// ── Round 7 (codex P2 — ND6Cz): touches run on per-process write chains, so two
|
|
2647
|
+
// processes can each append a full snapshot for the same namespace carrying
|
|
2648
|
+
// DIFFERENT touch fields. Plain last-record-wins compaction would erase the
|
|
2649
|
+
// earlier snapshot's field; field-level merge during compaction preserves the
|
|
2650
|
+
// MAX of each touch field so no cross-process touch recency is lost.
|
|
2651
|
+
test("compaction preserves both touch fields from concurrent cross-process snapshots", async () => {
|
|
2652
|
+
const memoryDir = await mkMemoryDir();
|
|
2653
|
+
try {
|
|
2654
|
+
const ns = "project-origin-xproc-merge";
|
|
2655
|
+
const token = namespaceIdentityToken(ns);
|
|
2656
|
+
const stateDir = path.join(memoryDir, "state");
|
|
2657
|
+
await mkdir(stateDir, { recursive: true });
|
|
2658
|
+
const tokenDir = path.join(memoryDir, "namespaces", token);
|
|
2659
|
+
|
|
2660
|
+
const base = {
|
|
2661
|
+
namespace: ns,
|
|
2662
|
+
identityToken: token,
|
|
2663
|
+
kind: "project",
|
|
2664
|
+
createdAt: new Date(Date.now() - 120_000).toISOString(),
|
|
2665
|
+
storageDir: tokenDir,
|
|
2666
|
+
discoveredBy: "write",
|
|
2667
|
+
};
|
|
2668
|
+
const writeAt = new Date(Date.now() - 60_000).toISOString();
|
|
2669
|
+
const readAt = new Date(Date.now() - 30_000).toISOString();
|
|
2670
|
+
// Process A appended a WRITE snapshot (only lastWriteAt); process B then
|
|
2671
|
+
// appended a READ snapshot (only lastReadAt) built from the SAME prior state,
|
|
2672
|
+
// so it lacks A's lastWriteAt. Last-record-wins would drop lastWriteAt.
|
|
2673
|
+
const lineA = JSON.stringify({ ...base, lastWriteAt: writeAt });
|
|
2674
|
+
const lineB = JSON.stringify({ ...base, lastReadAt: readAt });
|
|
2675
|
+
await writeFile(path.join(stateDir, "namespaces.jsonl"), `${lineA}\n${lineB}\n`, "utf8");
|
|
2676
|
+
|
|
2677
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
2678
|
+
const rec = await catalog.getNamespaceRecord(ns);
|
|
2679
|
+
assert.ok(rec, "the namespace is present after compaction");
|
|
2680
|
+
assert.equal(rec?.lastReadAt, readAt, "the later read snapshot's lastReadAt survives");
|
|
2681
|
+
assert.equal(
|
|
2682
|
+
rec?.lastWriteAt,
|
|
2683
|
+
writeAt,
|
|
2684
|
+
"the earlier write snapshot's lastWriteAt is NOT erased by the later read snapshot",
|
|
2685
|
+
);
|
|
2686
|
+
} finally {
|
|
2687
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
2688
|
+
}
|
|
2689
|
+
});
|
|
2690
|
+
|
|
2691
|
+
// ── Round 7 (codex P2 — NEOFS): the DEFAULT namespace's seeded root must also be
|
|
2692
|
+
// containment-checked. If `resolveDefaultNamespaceRoot()` returns a
|
|
2693
|
+
// `namespaces/<default-token>` symlink escaping memoryDir (empty legacy default +
|
|
2694
|
+
// symlinked tokenized dir with a marker), rebuild must NOT persist that escaping
|
|
2695
|
+
// path for the default record — it falls back to the trusted memoryDir root.
|
|
2696
|
+
test("rebuild does not persist an escaping symlinked default root", async () => {
|
|
2697
|
+
const memoryDir = await mkMemoryDir();
|
|
2698
|
+
const outside = await mkMemoryDir();
|
|
2699
|
+
try {
|
|
2700
|
+
// An outside tree WITH a storage marker, linked from the default token dir.
|
|
2701
|
+
await mkdir(path.join(outside, "evildefault", "facts"), { recursive: true });
|
|
2702
|
+
await mkdir(path.join(memoryDir, "namespaces"), { recursive: true });
|
|
2703
|
+
const defaultToken = namespaceIdentityToken("default");
|
|
2704
|
+
await symlink(
|
|
2705
|
+
path.join(outside, "evildefault"),
|
|
2706
|
+
path.join(memoryDir, "namespaces", defaultToken),
|
|
2707
|
+
"dir",
|
|
2708
|
+
);
|
|
2709
|
+
|
|
2710
|
+
// Sanity: the router-level resolver would pick the escaping symlinked dir.
|
|
2711
|
+
const resolved = await resolveDefaultNamespaceRoot(makeConfig(memoryDir));
|
|
2712
|
+
assert.equal(
|
|
2713
|
+
path.resolve(resolved),
|
|
2714
|
+
path.resolve(path.join(memoryDir, "namespaces", defaultToken)),
|
|
2715
|
+
"resolveDefaultNamespaceRoot picks the symlinked default token dir",
|
|
2716
|
+
);
|
|
2717
|
+
|
|
2718
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
2719
|
+
const result = await catalog.rebuildFromDisk();
|
|
2720
|
+
const def = result.records.find((r) => r.namespace === "default");
|
|
2721
|
+
assert.ok(def, "the default record exists");
|
|
2722
|
+
// The default storageDir must NOT resolve outside memoryDir.
|
|
2723
|
+
const realOutside = await realpath(outside).catch(() => outside);
|
|
2724
|
+
const realDefault = await realpath(def!.storageDir).catch(() => def!.storageDir);
|
|
2725
|
+
assert.ok(
|
|
2726
|
+
!realDefault.startsWith(realOutside),
|
|
2727
|
+
"the default record must not carry an escaping storageDir",
|
|
2728
|
+
);
|
|
2729
|
+
assert.equal(
|
|
2730
|
+
path.resolve(def!.storageDir),
|
|
2731
|
+
path.resolve(memoryDir),
|
|
2732
|
+
"an escaping default root falls back to the trusted memoryDir",
|
|
2733
|
+
);
|
|
2734
|
+
} finally {
|
|
2735
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
2736
|
+
await rm(outside, { recursive: true, force: true });
|
|
2737
|
+
}
|
|
2738
|
+
});
|
|
2739
|
+
|
|
2740
|
+
// ── Round 7 (codex P2 — NEZkA): HELD-MUTEX rebuild lock. The crux invariant:
|
|
2741
|
+
// no `namespaces.jsonl` append can occur between a rebuild's final
|
|
2742
|
+
// `loadCompacted()` and its atomic `rename()`. Previously a touch only POLLED for
|
|
2743
|
+
// the lock (`waitForRebuildLockClear`) then read+appended WITHOUT holding it — a
|
|
2744
|
+
// check-then-act gap. If a touch passed the check while no lock existed, a rebuild
|
|
2745
|
+
// could then acquire the lock, run its final load, and rename OVER the touch's
|
|
2746
|
+
// later append → the append is silently lost-then-overwritten despite the lock.
|
|
2747
|
+
//
|
|
2748
|
+
// The two `NamespaceCatalog` instances below have independent write chains and
|
|
2749
|
+
// distinct lock owner ids, exactly like two PROCESSES (the gateway writer vs. the
|
|
2750
|
+
// CLI rebuilder). Two protected test seams let us reproduce the precise lost-append
|
|
2751
|
+
// interleaving deterministically:
|
|
2752
|
+
// 1. the writer pauses inside its touch critical section (post lock-decision);
|
|
2753
|
+
// 2. the rebuilder pauses in its load→rename window (lock held).
|
|
2754
|
+
// A barrier coordinates them so the touch's append, if it can happen, lands inside
|
|
2755
|
+
// the rebuilder's load→rename window.
|
|
2756
|
+
//
|
|
2757
|
+
// With the OLD check-then-append code the writer's `waitForRebuildLockClear`
|
|
2758
|
+
// returns true (it ran before any lock existed), the writer appends inside the
|
|
2759
|
+
// window, and the rebuild's rename CLOBBERS it → the assertion FAILS. With the
|
|
2760
|
+
// held mutex the writer cannot ACQUIRE the lock while the rebuilder holds it, so
|
|
2761
|
+
// its append cannot land in the window: it either blocks until release (landing
|
|
2762
|
+
// AFTER the rename, preserved) or is cleanly dropped — never lost-then-overwritten.
|
|
2763
|
+
class SeamCatalog extends NamespaceCatalog {
|
|
2764
|
+
setTouchSeam(fn: (() => Promise<void>) | undefined): void {
|
|
2765
|
+
(this as unknown as { onTouchCriticalSectionForTest?: () => Promise<void> }).onTouchCriticalSectionForTest =
|
|
2766
|
+
fn;
|
|
2767
|
+
}
|
|
2768
|
+
setRebuildBeforeRenameSeam(fn: (() => Promise<void>) | undefined): void {
|
|
2769
|
+
(this as unknown as { onRebuildBeforeRenameForTest?: () => Promise<void> }).onRebuildBeforeRenameForTest =
|
|
2770
|
+
fn;
|
|
2771
|
+
}
|
|
2772
|
+
setRebuildAfterScanSeam(fn: (() => Promise<void>) | undefined): void {
|
|
2773
|
+
(this as unknown as { onRebuildAfterScanForTest?: () => Promise<void> }).onRebuildAfterScanForTest =
|
|
2774
|
+
fn;
|
|
2775
|
+
}
|
|
2776
|
+
setBreakStaleSeam(fn: (() => Promise<void>) | undefined): void {
|
|
2777
|
+
(this as unknown as { onBeforeBreakStaleUnlinkForTest?: () => Promise<void> }).onBeforeBreakStaleUnlinkForTest =
|
|
2778
|
+
fn;
|
|
2779
|
+
}
|
|
2780
|
+
async callBreakStaleRebuildLock(): Promise<void> {
|
|
2781
|
+
await (this as unknown as { breakStaleRebuildLock: () => Promise<void> }).breakStaleRebuildLock();
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2784
|
+
|
|
2785
|
+
function deferred<T = void>(): { promise: Promise<T>; resolve: (v: T) => void } {
|
|
2786
|
+
let resolve!: (v: T) => void;
|
|
2787
|
+
const promise = new Promise<T>((r) => {
|
|
2788
|
+
resolve = r;
|
|
2789
|
+
});
|
|
2790
|
+
return { promise, resolve };
|
|
2791
|
+
}
|
|
2792
|
+
|
|
2793
|
+
test("a touch append cannot land inside a rebuild's load→rename window (held mutex)", async () => {
|
|
2794
|
+
const memoryDir = await mkMemoryDir();
|
|
2795
|
+
try {
|
|
2796
|
+
const ns = "project-origin-held-mutex";
|
|
2797
|
+
const tokenDir = path.join(memoryDir, "namespaces", namespaceIdentityToken(ns));
|
|
2798
|
+
// On-disk data so rebuild discovers the namespace as a scan record (no
|
|
2799
|
+
// lastWriteAt of its own); the racing write touch is what supplies lastWriteAt.
|
|
2800
|
+
await mkdir(path.join(tokenDir, "facts"), { recursive: true });
|
|
2801
|
+
|
|
2802
|
+
// Separate instances == separate processes (separate writeChain + lock owner).
|
|
2803
|
+
const writer = new SeamCatalog(makeConfig(memoryDir));
|
|
2804
|
+
const rebuilder = new SeamCatalog(makeConfig(memoryDir));
|
|
2805
|
+
|
|
2806
|
+
// Barriers to force the exact lost-append interleaving regardless of timing.
|
|
2807
|
+
const writerInSection = deferred(); // writer has entered its touch critical section
|
|
2808
|
+
const rebuilderInWindow = deferred(); // rebuilder is in its load→rename window
|
|
2809
|
+
let writerObservedLockHeld = false;
|
|
2810
|
+
|
|
2811
|
+
// Writer pauses right after its lock decision, INSIDE the critical section:
|
|
2812
|
+
// signal we are here, then wait for the rebuilder to reach its rename window
|
|
2813
|
+
// before proceeding to append. (With the held mutex the writer only reaches
|
|
2814
|
+
// this seam if it ACQUIRED the lock — so we also record whether the rebuilder
|
|
2815
|
+
// could acquire concurrently.)
|
|
2816
|
+
writer.setTouchSeam(async () => {
|
|
2817
|
+
writerInSection.resolve();
|
|
2818
|
+
// Bounded wait so a held-mutex run (where the rebuilder can NEVER reach its
|
|
2819
|
+
// window concurrently because the writer holds the lock) does not hang.
|
|
2820
|
+
await Promise.race([
|
|
2821
|
+
rebuilderInWindow.promise,
|
|
2822
|
+
new Promise<void>((r) => setTimeout(r, 1500)),
|
|
2823
|
+
]);
|
|
2824
|
+
});
|
|
2825
|
+
|
|
2826
|
+
// Rebuilder, inside its load→rename window (lock held): signal, then wait for
|
|
2827
|
+
// the writer to be in its section so an OLD-code append would land here.
|
|
2828
|
+
rebuilder.setRebuildBeforeRenameSeam(async () => {
|
|
2829
|
+
rebuilderInWindow.resolve();
|
|
2830
|
+
writerObservedLockHeld = true;
|
|
2831
|
+
await Promise.race([
|
|
2832
|
+
writerInSection.promise,
|
|
2833
|
+
new Promise<void>((r) => setTimeout(r, 1500)),
|
|
2834
|
+
]);
|
|
2835
|
+
// Brief settle so an OLD-code writer (already past its no-lock check) has a
|
|
2836
|
+
// chance to append inside this window before we rename.
|
|
2837
|
+
await new Promise<void>((r) => setTimeout(r, 200));
|
|
2838
|
+
});
|
|
2839
|
+
|
|
2840
|
+
// Fire both concurrently. The writer's markWrite is best-effort and must never
|
|
2841
|
+
// throw; the rebuild applies.
|
|
2842
|
+
const [, rebuildResult] = await Promise.all([
|
|
2843
|
+
writer.markWrite(ns, { discoveredBy: "write", storageDir: tokenDir }),
|
|
2844
|
+
rebuilder.rebuildFromDisk(),
|
|
2845
|
+
]);
|
|
2846
|
+
|
|
2847
|
+
assert.ok(writerObservedLockHeld, "the rebuilder must have reached its rename window");
|
|
2848
|
+
|
|
2849
|
+
// INVARIANT: the write touch is never silently lost-then-overwritten. With the
|
|
2850
|
+
// held mutex it lands AFTER the rebuild's rename (preserved) or is cleanly
|
|
2851
|
+
// dropped; it can NOT be clobbered mid-window. A fresh reader sees it preserved.
|
|
2852
|
+
const reader = new NamespaceCatalog(makeConfig(memoryDir));
|
|
2853
|
+
const record = await reader.getNamespaceRecord(ns);
|
|
2854
|
+
assert.ok(record, "namespace must exist after the concurrent rebuild + write");
|
|
2855
|
+
assert.ok(
|
|
2856
|
+
record?.lastWriteAt,
|
|
2857
|
+
"the write touch must survive the rebuild — not be clobbered inside its load→rename window",
|
|
2858
|
+
);
|
|
2859
|
+
// The rebuild itself applied (held the lock and rewrote).
|
|
2860
|
+
assert.equal(rebuildResult.applied, true, "rebuild --apply held the lock and rewrote");
|
|
2861
|
+
} finally {
|
|
2862
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
2863
|
+
}
|
|
2864
|
+
});
|
|
2865
|
+
|
|
2866
|
+
// ── NFgCT (codex P2): the cross-process mutex is now SCOPED to the final
|
|
2867
|
+
// load→merge→rename window, NOT the disk scan. A gateway touch that races only the
|
|
2868
|
+
// (potentially long) SCAN phase must therefore NOT be blocked/dropped — it should
|
|
2869
|
+
// acquire the lock freely because the rebuild does not hold it during the scan.
|
|
2870
|
+
// We pause the rebuild AFTER its scan but BEFORE it acquires the lock (the new
|
|
2871
|
+
// after-scan seam), fire a cross-instance write touch there, and assert the touch
|
|
2872
|
+
// SUCCEEDS (the lock was free) and its write survives the rebuild. Pre-fix (lock
|
|
2873
|
+
// held across the whole scan) the touch would contend with the held lock.
|
|
2874
|
+
test("a touch racing only the rebuild SCAN phase is not blocked by the mutex (NFgCT)", async () => {
|
|
2875
|
+
const memoryDir = await mkMemoryDir();
|
|
2876
|
+
try {
|
|
2877
|
+
const ns = "project-origin-scan-race";
|
|
2878
|
+
const tokenDir = path.join(memoryDir, "namespaces", namespaceIdentityToken(ns));
|
|
2879
|
+
await mkdir(path.join(tokenDir, "facts"), { recursive: true });
|
|
2880
|
+
|
|
2881
|
+
// Separate instances == separate processes (separate writeChain + lock owner).
|
|
2882
|
+
const writer = new SeamCatalog(makeConfig(memoryDir));
|
|
2883
|
+
const rebuilder = new SeamCatalog(makeConfig(memoryDir));
|
|
2884
|
+
|
|
2885
|
+
let seamFired = false;
|
|
2886
|
+
// When the rebuild reaches the post-scan / pre-lock point, perform a
|
|
2887
|
+
// cross-instance write touch. Because the rebuild has NOT yet acquired the
|
|
2888
|
+
// lock, this touch must acquire it freely and APPEND (land a lastWriteAt), not
|
|
2889
|
+
// contend with a held lock and drop. `markWrite` resolves to void; we prove it
|
|
2890
|
+
// was NOT dropped by reading back the persisted record below. The touch
|
|
2891
|
+
// resolving WITHOUT hanging here already proves the scan does not hold the lock
|
|
2892
|
+
// (otherwise the writer would block on the very lock the rebuild owns).
|
|
2893
|
+
rebuilder.setRebuildAfterScanSeam(async () => {
|
|
2894
|
+
seamFired = true;
|
|
2895
|
+
await writer.markWrite(ns, { discoveredBy: "write", storageDir: tokenDir });
|
|
2896
|
+
// Clear the seam so it does not re-fire on any nested rebuild.
|
|
2897
|
+
rebuilder.setRebuildAfterScanSeam(undefined);
|
|
2898
|
+
});
|
|
2899
|
+
|
|
2900
|
+
const result = await rebuilder.rebuildFromDisk();
|
|
2901
|
+
assert.ok(seamFired, "the post-scan / pre-lock seam must have fired");
|
|
2902
|
+
assert.equal(result.applied, true, "the rebuild still held the lock for its final rewrite and applied");
|
|
2903
|
+
|
|
2904
|
+
// The write touch landed during the lockless scan window and SURVIVED — the
|
|
2905
|
+
// rebuild's final re-merge re-reads the log under the lock and folds it. If the
|
|
2906
|
+
// lock had been held across the scan, the touch would have timed out and been
|
|
2907
|
+
// dropped (no lastWriteAt).
|
|
2908
|
+
const reader = new NamespaceCatalog(makeConfig(memoryDir));
|
|
2909
|
+
const record = await reader.getNamespaceRecord(ns);
|
|
2910
|
+
assert.ok(record, "namespace must exist after the scan-phase touch + rebuild");
|
|
2911
|
+
assert.ok(
|
|
2912
|
+
record?.lastWriteAt,
|
|
2913
|
+
"a touch that landed during the lockless scan must NOT be dropped and must survive the rebuild's final re-merge",
|
|
2914
|
+
);
|
|
2915
|
+
} finally {
|
|
2916
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
2917
|
+
}
|
|
2918
|
+
});
|
|
2919
|
+
|
|
2920
|
+
// Companion: a touch that CANNOT acquire the held lock within the bounded wait
|
|
2921
|
+
// (a foreign, non-stale, heartbeated rebuild lock is held by "another process")
|
|
2922
|
+
// must DROP its append best-effort — it must NEVER append without the lock and
|
|
2923
|
+
// NEVER crash the primary memory op. Here the foreign lock never releases within
|
|
2924
|
+
// the touch's wait, so the touch degrades to a no-op (dropped) rather than racing.
|
|
2925
|
+
test("a touch drops (never crashes, never appends) when it cannot acquire the held lock", async () => {
|
|
2926
|
+
const memoryDir = await mkMemoryDir();
|
|
2927
|
+
let heartbeat: ReturnType<typeof setInterval> | undefined;
|
|
2928
|
+
try {
|
|
2929
|
+
const ns = "project-origin-lock-drop";
|
|
2930
|
+
const tokenDir = path.join(memoryDir, "namespaces", namespaceIdentityToken(ns));
|
|
2931
|
+
await mkdir(path.join(tokenDir, "facts"), { recursive: true });
|
|
2932
|
+
const stateDir = path.join(memoryDir, "state");
|
|
2933
|
+
await mkdir(stateDir, { recursive: true });
|
|
2934
|
+
const lockPath = path.join(stateDir, "namespaces.rebuild.lock");
|
|
2935
|
+
// Foreign held lock: a different PID + a real UUID owner id (so it is NOT
|
|
2936
|
+
// mistaken for self) + a fresh mtime. A heartbeat keeps it fresh so it is
|
|
2937
|
+
// never broken as stale during the touch's bounded wait.
|
|
2938
|
+
const foreignOwner = "00000000-0000-4000-8000-000000000000";
|
|
2939
|
+
await writeFile(lockPath, `999999 ${foreignOwner} ${new Date().toISOString()}\n`, "utf8");
|
|
2940
|
+
heartbeat = setInterval(() => {
|
|
2941
|
+
const now = new Date();
|
|
2942
|
+
utimes(lockPath, now, now).catch(() => undefined);
|
|
2943
|
+
}, 250);
|
|
2944
|
+
|
|
2945
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
2946
|
+
const started = Date.now();
|
|
2947
|
+
// Must resolve (best-effort drop), never reject.
|
|
2948
|
+
await catalog.markWrite(ns, { discoveredBy: "write", storageDir: tokenDir });
|
|
2949
|
+
const waited = Date.now() - started;
|
|
2950
|
+
|
|
2951
|
+
// The append was DROPPED: no record was written because the lock never cleared.
|
|
2952
|
+
const record = await catalog.getNamespaceRecord(ns);
|
|
2953
|
+
assert.equal(record, null, "a touch that cannot acquire the lock must NOT append");
|
|
2954
|
+
// It must have given up within the bounded wait, not blocked forever.
|
|
2955
|
+
assert.ok(waited < 12_000, "the dropped touch must return within the bounded wait");
|
|
2956
|
+
} finally {
|
|
2957
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
2958
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
2959
|
+
}
|
|
2960
|
+
});
|
|
2961
|
+
|
|
2962
|
+
// ── NG7Bg (codex P2): breaking a STALE lock must not delete a REPLACEMENT lock
|
|
2963
|
+
// created in the race window. Two processes can both judge the same lock stale;
|
|
2964
|
+
// one removes it and creates a fresh lock, and the other's later unlink would
|
|
2965
|
+
// delete that fresh holder's ACTIVE lock based on the stale identity it read
|
|
2966
|
+
// earlier — leaving the fresh holder running its critical section unprotected. The
|
|
2967
|
+
// break now re-validates the lock identity immediately before unlinking and skips
|
|
2968
|
+
// the unlink when a replacement (different owner/timestamp) is present. We simulate
|
|
2969
|
+
// the replacement via the post-judgment seam and assert the fresh lock survives.
|
|
2970
|
+
test("breakStaleRebuildLock does not delete a replacement lock created in the race window (NG7Bg)", async () => {
|
|
2971
|
+
const memoryDir = await mkMemoryDir();
|
|
2972
|
+
try {
|
|
2973
|
+
const stateDir = path.join(memoryDir, "state");
|
|
2974
|
+
await mkdir(stateDir, { recursive: true });
|
|
2975
|
+
const lockPath = path.join(stateDir, "namespaces.rebuild.lock");
|
|
2976
|
+
|
|
2977
|
+
// 1) A genuinely STALE lock (owner A, mtime well past the stale threshold).
|
|
2978
|
+
const staleIdentity = `111111 aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa ${new Date(
|
|
2979
|
+
Date.now() - 120_000,
|
|
2980
|
+
).toISOString()}\n`;
|
|
2981
|
+
await writeFile(lockPath, staleIdentity, "utf8");
|
|
2982
|
+
const old = new Date(Date.now() - 120_000);
|
|
2983
|
+
await utimes(lockPath, old, old);
|
|
2984
|
+
|
|
2985
|
+
const breaker = new SeamCatalog(makeConfig(memoryDir));
|
|
2986
|
+
// 2) In the race window (after staleness is judged, before unlink), a DIFFERENT
|
|
2987
|
+
// process removes the stale lock and creates a FRESH replacement (owner B,
|
|
2988
|
+
// current mtime).
|
|
2989
|
+
const replacementIdentity = `222222 bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb ${new Date().toISOString()}\n`;
|
|
2990
|
+
breaker.setBreakStaleSeam(async () => {
|
|
2991
|
+
await writeFile(lockPath, replacementIdentity, "utf8");
|
|
2992
|
+
const now = new Date();
|
|
2993
|
+
await utimes(lockPath, now, now);
|
|
2994
|
+
breaker.setBreakStaleSeam(undefined);
|
|
2995
|
+
});
|
|
2996
|
+
|
|
2997
|
+
await breaker.callBreakStaleRebuildLock();
|
|
2998
|
+
|
|
2999
|
+
// 3) The replacement lock must STILL exist with its own identity — it was not
|
|
3000
|
+
// deleted based on the stale identity the breaker read earlier.
|
|
3001
|
+
const after = await readFile(lockPath, "utf8").catch(() => "");
|
|
3002
|
+
assert.equal(
|
|
3003
|
+
after,
|
|
3004
|
+
replacementIdentity,
|
|
3005
|
+
"a replacement lock created in the race window must NOT be unlinked",
|
|
3006
|
+
);
|
|
3007
|
+
} finally {
|
|
3008
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
3009
|
+
}
|
|
3010
|
+
});
|
|
3011
|
+
|
|
3012
|
+
// NG7Bg baseline: a genuinely stale lock with an UNCHANGED identity is still broken
|
|
3013
|
+
// (the fix must not stop legitimate stale-lock recovery).
|
|
3014
|
+
test("breakStaleRebuildLock still removes a stale lock whose identity is unchanged (NG7Bg baseline)", async () => {
|
|
3015
|
+
const memoryDir = await mkMemoryDir();
|
|
3016
|
+
try {
|
|
3017
|
+
const stateDir = path.join(memoryDir, "state");
|
|
3018
|
+
await mkdir(stateDir, { recursive: true });
|
|
3019
|
+
const lockPath = path.join(stateDir, "namespaces.rebuild.lock");
|
|
3020
|
+
const stale = `333333 cccccccc-cccc-4ccc-8ccc-cccccccccccc ${new Date(
|
|
3021
|
+
Date.now() - 120_000,
|
|
3022
|
+
).toISOString()}\n`;
|
|
3023
|
+
await writeFile(lockPath, stale, "utf8");
|
|
3024
|
+
const old = new Date(Date.now() - 120_000);
|
|
3025
|
+
await utimes(lockPath, old, old);
|
|
3026
|
+
|
|
3027
|
+
const breaker = new SeamCatalog(makeConfig(memoryDir));
|
|
3028
|
+
await breaker.callBreakStaleRebuildLock();
|
|
3029
|
+
|
|
3030
|
+
const exists = await readFile(lockPath, "utf8").then(() => true, () => false);
|
|
3031
|
+
assert.equal(exists, false, "an unchanged stale lock must still be broken");
|
|
3032
|
+
} finally {
|
|
3033
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
3034
|
+
}
|
|
3035
|
+
});
|
|
3036
|
+
|
|
3037
|
+
// ── Round 7 (kilo P2 — NER8P): `namespaces rebuild --apply --json` must EXIT
|
|
3038
|
+
// NON-ZERO when the rebuild could not be applied (lock contention →
|
|
3039
|
+
// `applied: false`), mirroring the non-JSON path. Otherwise JSON-mode automation
|
|
3040
|
+
// treats a no-op apply as success. The CLI's `--json` apply branch sets
|
|
3041
|
+
// `process.exitCode = 1` iff `!dryRun && !result.applied`. We drive a real
|
|
3042
|
+
// `rebuildFromDisk` under lock contention (so `applied === false`) and apply the
|
|
3043
|
+
// exact CLI rule, asserting the non-zero exit signal — restoring `process.exitCode`
|
|
3044
|
+
// afterward so this test cannot leak a failure code into the runner.
|
|
3045
|
+
test("rebuild --apply --json exits non-zero when the rebuild was not applied (NER8P)", async () => {
|
|
3046
|
+
const memoryDir = await mkMemoryDir();
|
|
3047
|
+
const savedExitCode = process.exitCode;
|
|
3048
|
+
let heartbeat: ReturnType<typeof setInterval> | undefined;
|
|
3049
|
+
try {
|
|
3050
|
+
const ns = "project-origin-json-noapply";
|
|
3051
|
+
const tokenDir = path.join(memoryDir, "namespaces", namespaceIdentityToken(ns));
|
|
3052
|
+
await mkdir(path.join(tokenDir, "facts"), { recursive: true });
|
|
3053
|
+
const stateDir = path.join(memoryDir, "state");
|
|
3054
|
+
await mkdir(stateDir, { recursive: true });
|
|
3055
|
+
const lockPath = path.join(stateDir, "namespaces.rebuild.lock");
|
|
3056
|
+
// Foreign, non-stale, heartbeated lock held by "another process" so the apply
|
|
3057
|
+
// cannot acquire the lock and returns applied:false (compute-only).
|
|
3058
|
+
const foreignOwner = "00000000-0000-4000-8000-0000000000aa";
|
|
3059
|
+
await writeFile(lockPath, `999999 ${foreignOwner} ${new Date().toISOString()}\n`, "utf8");
|
|
3060
|
+
heartbeat = setInterval(() => {
|
|
3061
|
+
const now = new Date();
|
|
3062
|
+
utimes(lockPath, now, now).catch(() => undefined);
|
|
3063
|
+
}, 250);
|
|
3064
|
+
|
|
3065
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
3066
|
+
// Mirror the CLI: --apply means dryRun=false.
|
|
3067
|
+
const dryRun = false;
|
|
3068
|
+
const result = await catalog.rebuildFromDisk({ dryRun });
|
|
3069
|
+
assert.equal(result.applied, false, "a contended apply must report applied=false");
|
|
3070
|
+
|
|
3071
|
+
// The EXACT decision the CLI `--json` apply branch makes (cli.ts).
|
|
3072
|
+
process.exitCode = undefined;
|
|
3073
|
+
if (!dryRun && !result.applied) {
|
|
3074
|
+
process.exitCode = 1;
|
|
3075
|
+
}
|
|
3076
|
+
assert.equal(
|
|
3077
|
+
process.exitCode,
|
|
3078
|
+
1,
|
|
3079
|
+
"a JSON apply that was not applied must set a non-zero exit code so automation detects the no-op",
|
|
3080
|
+
);
|
|
3081
|
+
|
|
3082
|
+
// Sanity: a successful apply (no contention) must NOT set a non-zero exit.
|
|
3083
|
+
clearInterval(heartbeat);
|
|
3084
|
+
heartbeat = undefined;
|
|
3085
|
+
await rm(lockPath, { force: true });
|
|
3086
|
+
process.exitCode = undefined;
|
|
3087
|
+
const ok = await catalog.rebuildFromDisk({ dryRun: false });
|
|
3088
|
+
assert.equal(ok.applied, true, "an uncontended apply applies");
|
|
3089
|
+
if (!ok.applied) process.exitCode = 1;
|
|
3090
|
+
assert.notEqual(process.exitCode, 1, "a successful apply must not exit non-zero");
|
|
3091
|
+
} finally {
|
|
3092
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
3093
|
+
process.exitCode = savedExitCode;
|
|
3094
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
3095
|
+
}
|
|
3096
|
+
});
|
|
3097
|
+
|
|
3098
|
+
// ── NFb5W (cursor Medium): an INERT catalog `namespaces rebuild --apply` rewrote
|
|
3099
|
+
// nothing, so exit-code-only automation must see a non-zero exit — the same
|
|
3100
|
+
// signal the enabled path emits for a non-dry-run rebuild that did not apply.
|
|
3101
|
+
// The CLI's inert branch returns early before `rebuildFromDisk`; it now sets
|
|
3102
|
+
// `process.exitCode = 1` iff `!dryRun` (apply), while a dry-run inert call stays
|
|
3103
|
+
// exit 0. We assert the exact inert-branch decision against a disabled catalog.
|
|
3104
|
+
test("inert rebuild --apply exits non-zero; inert --dry-run stays zero (NFb5W)", async () => {
|
|
3105
|
+
const memoryDir = await mkMemoryDir();
|
|
3106
|
+
const savedExitCode = process.exitCode;
|
|
3107
|
+
try {
|
|
3108
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir, { namespacesEnabled: false }));
|
|
3109
|
+
assert.equal(catalog.enabled, false, "an inert catalog reports disabled (the CLI gate)");
|
|
3110
|
+
|
|
3111
|
+
// INERT + --apply (dryRun=false): the CLI sets exitCode=1 before returning.
|
|
3112
|
+
process.exitCode = undefined;
|
|
3113
|
+
{
|
|
3114
|
+
const dryRun = false; // --apply
|
|
3115
|
+
if (!catalog.enabled && !dryRun) process.exitCode = 1;
|
|
3116
|
+
}
|
|
3117
|
+
assert.equal(
|
|
3118
|
+
process.exitCode,
|
|
3119
|
+
1,
|
|
3120
|
+
"an inert `--apply` rewrote nothing and must exit non-zero so automation does not read it as a completed rebuild",
|
|
3121
|
+
);
|
|
3122
|
+
|
|
3123
|
+
// INERT + --dry-run (default): no write was ever promised → exit stays 0.
|
|
3124
|
+
process.exitCode = undefined;
|
|
3125
|
+
{
|
|
3126
|
+
const dryRun = true;
|
|
3127
|
+
if (!catalog.enabled && !dryRun) process.exitCode = 1;
|
|
3128
|
+
}
|
|
3129
|
+
assert.notEqual(
|
|
3130
|
+
process.exitCode,
|
|
3131
|
+
1,
|
|
3132
|
+
"an inert dry-run must not exit non-zero — it never promised to write",
|
|
3133
|
+
);
|
|
3134
|
+
} finally {
|
|
3135
|
+
process.exitCode = savedExitCode;
|
|
3136
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
3137
|
+
}
|
|
3138
|
+
});
|
|
3139
|
+
|
|
3140
|
+
// ── NRcCD (codex P2): preserve cataloged token-shaped raw roots during rebuild ──
|
|
3141
|
+
// A DYNAMIC namespace literally named `ns-616c706861` (= the canonical token of
|
|
3142
|
+
// `alpha`) is served from a legacy raw root `namespaces/ns-616c706861` and already
|
|
3143
|
+
// owns a catalog row from the write path. Before the fix, the rebuild scanner
|
|
3144
|
+
// decoded that dir to `alpha`, emitting an `alpha` row at the raw root, while the
|
|
3145
|
+
// final live-row remerge kept the real `ns-616c706861` row too — TWO rows at the
|
|
3146
|
+
// SAME storageDir, fanning QMD/maintenance out under the wrong namespace. The fix
|
|
3147
|
+
// prefers the LITERAL dir name when it is already a KNOWN (cataloged) namespace.
|
|
3148
|
+
test("rebuildFromDisk keeps a cataloged token-shaped raw root as the literal namespace (NRcCD)", async () => {
|
|
3149
|
+
const memoryDir = await mkMemoryDir();
|
|
3150
|
+
try {
|
|
3151
|
+
// The raw root is literally named like alpha's canonical token.
|
|
3152
|
+
const literalNs = namespaceIdentityToken("alpha"); // "ns-616c706861"
|
|
3153
|
+
assert.equal(
|
|
3154
|
+
namespaceIdentityFromToken(literalNs),
|
|
3155
|
+
"alpha",
|
|
3156
|
+
"precondition: the dir name decodes to alpha — the ambiguity this test exercises",
|
|
3157
|
+
);
|
|
3158
|
+
const rawRoot = path.join(memoryDir, "namespaces", literalNs);
|
|
3159
|
+
// Legacy raw root holding memory data.
|
|
3160
|
+
await mkdir(path.join(rawRoot, "facts"), { recursive: true });
|
|
3161
|
+
await writeFile(path.join(rawRoot, "facts", "f1.md"), "# synthetic\n", "utf8");
|
|
3162
|
+
|
|
3163
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
3164
|
+
// Write-path row: the dynamic namespace is cataloged at its raw root verbatim.
|
|
3165
|
+
await catalog.markWrite(literalNs, { discoveredBy: "write", storageDir: rawRoot });
|
|
3166
|
+
const seeded = await catalog.getNamespaceRecord(literalNs);
|
|
3167
|
+
assert.ok(seeded, "precondition: literal namespace has a catalog row before rebuild");
|
|
3168
|
+
assert.equal(path.resolve(seeded!.storageDir), path.resolve(rawRoot));
|
|
3169
|
+
|
|
3170
|
+
const result = await catalog.rebuildFromDisk();
|
|
3171
|
+
|
|
3172
|
+
// EXACTLY ONE row points at the raw root, and it is the LITERAL namespace.
|
|
3173
|
+
const atRawRoot = result.records.filter(
|
|
3174
|
+
(r) => path.resolve(r.storageDir) === path.resolve(rawRoot),
|
|
3175
|
+
);
|
|
3176
|
+
assert.equal(
|
|
3177
|
+
atRawRoot.length,
|
|
3178
|
+
1,
|
|
3179
|
+
`rebuild must produce exactly one catalog row for ${rawRoot}, got: ${atRawRoot
|
|
3180
|
+
.map((r) => r.namespace)
|
|
3181
|
+
.join(", ")}`,
|
|
3182
|
+
);
|
|
3183
|
+
assert.equal(
|
|
3184
|
+
atRawRoot[0]?.namespace,
|
|
3185
|
+
literalNs,
|
|
3186
|
+
"the surviving row must be the literal namespace, not the decoded alias",
|
|
3187
|
+
);
|
|
3188
|
+
assert.ok(
|
|
3189
|
+
!result.records.some((r) => r.namespace === "alpha"),
|
|
3190
|
+
"rebuild must NOT emit a decoded `alpha` alias row when the literal owner exists",
|
|
3191
|
+
);
|
|
3192
|
+
} finally {
|
|
3193
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
3194
|
+
}
|
|
3195
|
+
});
|
|
3196
|
+
|
|
3197
|
+
// Control: a genuine tokenized dir with NO literal owner (no cataloged row keyed
|
|
3198
|
+
// by the raw token) still decodes back to its identity, exactly as before. This
|
|
3199
|
+
// guards against the NRcCD fix over-suppressing the canonical decode.
|
|
3200
|
+
test("rebuildFromDisk still decodes a tokenized root with no literal owner (NRcCD control)", async () => {
|
|
3201
|
+
const memoryDir = await mkMemoryDir();
|
|
3202
|
+
try {
|
|
3203
|
+
const token = namespaceIdentityToken("alpha"); // tokenized dir for `alpha`
|
|
3204
|
+
await mkdir(path.join(memoryDir, "namespaces", token, "facts"), { recursive: true });
|
|
3205
|
+
await writeFile(
|
|
3206
|
+
path.join(memoryDir, "namespaces", token, "facts", "f1.md"),
|
|
3207
|
+
"# synthetic\n",
|
|
3208
|
+
"utf8",
|
|
3209
|
+
);
|
|
3210
|
+
|
|
3211
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
3212
|
+
const result = await catalog.rebuildFromDisk();
|
|
3213
|
+
|
|
3214
|
+
assert.ok(
|
|
3215
|
+
result.records.some((r) => r.namespace === "alpha"),
|
|
3216
|
+
"with no literal owner, a tokenized dir still decodes to its identity",
|
|
3217
|
+
);
|
|
3218
|
+
assert.ok(
|
|
3219
|
+
!result.records.some((r) => r.namespace === token),
|
|
3220
|
+
"the literal token form must NOT appear when nothing owns it",
|
|
3221
|
+
);
|
|
3222
|
+
} finally {
|
|
3223
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
3224
|
+
}
|
|
3225
|
+
});
|
|
3226
|
+
|
|
3227
|
+
test("listNamespaces drops stale decoded aliases for catalog-owned token-shaped roots", async () => {
|
|
3228
|
+
const memoryDir = await mkMemoryDir();
|
|
3229
|
+
try {
|
|
3230
|
+
const literalNs = namespaceIdentityToken("alpha");
|
|
3231
|
+
const rawRoot = path.join(memoryDir, "namespaces", literalNs);
|
|
3232
|
+
await mkdir(path.join(rawRoot, "facts"), { recursive: true });
|
|
3233
|
+
await writeFile(path.join(rawRoot, "facts", "f1.md"), "# synthetic\n", "utf8");
|
|
3234
|
+
|
|
3235
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
3236
|
+
await catalog.markWrite(literalNs, { discoveredBy: "write", storageDir: rawRoot });
|
|
3237
|
+
await catalog.markRead("alpha", { discoveredBy: "read", storageDir: rawRoot });
|
|
3238
|
+
|
|
3239
|
+
const atRawRoot = (await catalog.listNamespaces()).filter(
|
|
3240
|
+
(record) => path.resolve(record.storageDir) === path.resolve(rawRoot),
|
|
3241
|
+
);
|
|
3242
|
+
assert.deepEqual(
|
|
3243
|
+
atRawRoot.map((record) => record.namespace),
|
|
3244
|
+
[literalNs],
|
|
3245
|
+
"the read API must expose only the catalog-owned literal namespace for a shared root",
|
|
3246
|
+
);
|
|
3247
|
+
assert.equal(
|
|
3248
|
+
await catalog.getNamespaceRecord("alpha"),
|
|
3249
|
+
null,
|
|
3250
|
+
"status lookup must not report a stale alias that listNamespaces drops",
|
|
3251
|
+
);
|
|
3252
|
+
} finally {
|
|
3253
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
3254
|
+
}
|
|
3255
|
+
});
|
|
3256
|
+
|
|
3257
|
+
test("rebuildFromDisk merges touch fields from dropped root aliases", async () => {
|
|
3258
|
+
const memoryDir = await mkMemoryDir();
|
|
3259
|
+
try {
|
|
3260
|
+
const literalNs = namespaceIdentityToken("alpha");
|
|
3261
|
+
const rawRoot = path.join(memoryDir, "namespaces", literalNs);
|
|
3262
|
+
await mkdir(path.join(rawRoot, "facts"), { recursive: true });
|
|
3263
|
+
await writeFile(path.join(rawRoot, "facts", "f1.md"), "# synthetic\n", "utf8");
|
|
3264
|
+
|
|
3265
|
+
const literalWrite = new Date("2026-01-01T00:00:00.000Z");
|
|
3266
|
+
const literalMaintenance = new Date("2026-01-01T01:00:00.000Z");
|
|
3267
|
+
const aliasWrite = new Date("2026-01-02T00:00:00.000Z");
|
|
3268
|
+
const aliasMaintenance = new Date("2026-01-02T01:00:00.000Z");
|
|
3269
|
+
|
|
3270
|
+
const catalog = new NamespaceCatalog(makeConfig(memoryDir));
|
|
3271
|
+
await catalog.markWrite(literalNs, {
|
|
3272
|
+
discoveredBy: "write",
|
|
3273
|
+
storageDir: rawRoot,
|
|
3274
|
+
at: literalWrite,
|
|
3275
|
+
});
|
|
3276
|
+
await catalog.markMaintenance(literalNs, "qmd", literalMaintenance);
|
|
3277
|
+
await catalog.markWrite("alpha", {
|
|
3278
|
+
discoveredBy: "write",
|
|
3279
|
+
storageDir: rawRoot,
|
|
3280
|
+
at: aliasWrite,
|
|
3281
|
+
});
|
|
3282
|
+
await catalog.markMaintenance("alpha", "qmd", aliasMaintenance);
|
|
3283
|
+
|
|
3284
|
+
const result = await catalog.rebuildFromDisk();
|
|
3285
|
+
const atRawRoot = result.records.filter(
|
|
3286
|
+
(record) => path.resolve(record.storageDir) === path.resolve(rawRoot),
|
|
3287
|
+
);
|
|
3288
|
+
|
|
3289
|
+
assert.equal(atRawRoot.length, 1, "rebuild must keep only one owner for a storage root");
|
|
3290
|
+
assert.equal(atRawRoot[0]?.namespace, literalNs, "the literal root owner must survive");
|
|
3291
|
+
assert.equal(
|
|
3292
|
+
atRawRoot[0]?.lastWriteAt,
|
|
3293
|
+
aliasWrite.toISOString(),
|
|
3294
|
+
"the surviving owner must inherit the newer write touch from the dropped alias",
|
|
3295
|
+
);
|
|
3296
|
+
assert.equal(
|
|
3297
|
+
atRawRoot[0]?.lastMaintenanceAt?.qmd,
|
|
3298
|
+
aliasMaintenance.toISOString(),
|
|
3299
|
+
"the surviving owner must inherit the newer maintenance touch from the dropped alias",
|
|
3300
|
+
);
|
|
3301
|
+
|
|
3302
|
+
const writtenSince = await catalog.listNamespaces({
|
|
3303
|
+
writtenSince: new Date("2026-01-01T12:00:00.000Z"),
|
|
3304
|
+
});
|
|
3305
|
+
assert.ok(
|
|
3306
|
+
writtenSince.some((record) => record.namespace === literalNs),
|
|
3307
|
+
"writtenSince must still include the root after the alias row is collapsed",
|
|
3308
|
+
);
|
|
3309
|
+
assert.equal(
|
|
3310
|
+
await catalog.getNamespaceRecord("alpha"),
|
|
3311
|
+
null,
|
|
3312
|
+
"the decoded alias must stay collapsed after preserving its touch fields",
|
|
3313
|
+
);
|
|
3314
|
+
} finally {
|
|
3315
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
3316
|
+
}
|
|
3317
|
+
});
|
|
3318
|
+
|
|
3319
|
+
test("listNamespaces prefers configured token owners over stale literal token aliases", async () => {
|
|
3320
|
+
const memoryDir = await mkMemoryDir();
|
|
3321
|
+
try {
|
|
3322
|
+
const literalNs = namespaceIdentityToken("alpha");
|
|
3323
|
+
const tokenRoot = path.join(memoryDir, "namespaces", literalNs);
|
|
3324
|
+
await mkdir(path.join(tokenRoot, "facts"), { recursive: true });
|
|
3325
|
+
await writeFile(path.join(tokenRoot, "facts", "f1.md"), "# synthetic\n", "utf8");
|
|
3326
|
+
|
|
3327
|
+
const config = makeConfig(memoryDir, {
|
|
3328
|
+
namespacePolicies: [
|
|
3329
|
+
{
|
|
3330
|
+
name: "alpha",
|
|
3331
|
+
readPrincipals: [],
|
|
3332
|
+
writePrincipals: [],
|
|
3333
|
+
},
|
|
3334
|
+
],
|
|
3335
|
+
});
|
|
3336
|
+
const catalog = new NamespaceCatalog(config);
|
|
3337
|
+
await catalog.markWrite(literalNs, { discoveredBy: "write", storageDir: tokenRoot });
|
|
3338
|
+
await catalog.markWrite("alpha", { discoveredBy: "write", storageDir: tokenRoot });
|
|
3339
|
+
|
|
3340
|
+
const atTokenRoot = (await catalog.listNamespaces()).filter(
|
|
3341
|
+
(record) => path.resolve(record.storageDir) === path.resolve(tokenRoot),
|
|
3342
|
+
);
|
|
3343
|
+
assert.deepEqual(
|
|
3344
|
+
atTokenRoot.map((record) => record.namespace),
|
|
3345
|
+
["alpha"],
|
|
3346
|
+
"configured namespaces must own their tokenized root over a stale literal alias",
|
|
3347
|
+
);
|
|
3348
|
+
assert.equal(
|
|
3349
|
+
await catalog.getNamespaceRecord(literalNs),
|
|
3350
|
+
null,
|
|
3351
|
+
"status lookup must not report the stale literal alias that listNamespaces drops",
|
|
3352
|
+
);
|
|
3353
|
+
} finally {
|
|
3354
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
3355
|
+
}
|
|
3356
|
+
});
|