@remnic/core 9.3.654 → 9.3.656
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 +29 -29
- 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 +10 -10
- package/dist/{access-service-C8A5hoXJ.d.ts → access-service-D_nbpexW.d.ts} +33 -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-JMQSYGXS.js → chunk-2BD7DG37.js} +2 -2
- package/dist/{chunk-FVRBLJP6.js → chunk-2MXEVL75.js} +2 -2
- package/dist/{chunk-LJCEWTG3.js → chunk-4UL7VPTD.js} +277 -58
- package/dist/chunk-4UL7VPTD.js.map +1 -0
- package/dist/{chunk-JYN7QNTA.js → chunk-54XF2FY7.js} +17 -17
- package/dist/{chunk-7WEB3FLJ.js → chunk-5PLUC5OB.js} +2 -2
- package/dist/{chunk-JX2RINDR.js → chunk-6G5JEN55.js} +2 -2
- package/dist/{chunk-ZCORQM74.js → chunk-AGJKWOKV.js} +2 -2
- package/dist/{chunk-NE2JBMLN.js → chunk-AZBV4RRY.js} +1 -1
- package/dist/chunk-AZBV4RRY.js.map +1 -0
- package/dist/{chunk-YLZLPVKK.js → chunk-CTAV55JM.js} +344 -1
- package/dist/chunk-CTAV55JM.js.map +1 -0
- package/dist/{chunk-2DSTAWNZ.js → chunk-DIBWFCLA.js} +3 -3
- package/dist/{chunk-NAZWHTYV.js → chunk-DR67OK4E.js} +5 -5
- package/dist/{chunk-XBIACVCO.js → chunk-EC2AYKRX.js} +2 -2
- package/dist/{chunk-JVRPJ7D4.js → chunk-EKQMQQ3U.js} +48 -12
- package/dist/chunk-EKQMQQ3U.js.map +1 -0
- package/dist/{chunk-RGPUQ66K.js → chunk-GCYFUTUC.js} +2 -2
- package/dist/{chunk-JBHXMCYN.js → chunk-GRYAECRV.js} +2 -2
- package/dist/{chunk-BJA6DQOC.js → chunk-GSHW5VVD.js} +5 -5
- package/dist/chunk-GYSYLGNE.js +650 -0
- package/dist/chunk-GYSYLGNE.js.map +1 -0
- package/dist/{chunk-NCGWXCSW.js → chunk-IOZ5WBWD.js} +2 -2
- package/dist/{chunk-QKK64Z6M.js → chunk-JSVFEHLL.js} +7 -5
- package/dist/chunk-JSVFEHLL.js.map +1 -0
- package/dist/{chunk-7LWRCOP7.js → chunk-LZTFCAKE.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-6CVI6BP6.js → chunk-NXCK7DO7.js} +2 -2
- package/dist/{chunk-Z5MQI7K2.js → chunk-PEPHBH2W.js} +2 -2
- package/dist/{chunk-PYWNNF2I.js → chunk-QRSKPI62.js} +99 -66
- package/dist/chunk-QRSKPI62.js.map +1 -0
- package/dist/{chunk-XWQ6ERUG.js → chunk-QZRKNA5F.js} +2 -2
- package/dist/{chunk-PS3SYNHP.js → chunk-R5DB26G6.js} +2 -2
- package/dist/{chunk-OL2364SB.js → chunk-RDW5G6DO.js} +1502 -335
- package/dist/chunk-RDW5G6DO.js.map +1 -0
- package/dist/{chunk-YM3LR4LS.js → chunk-SSSXWIBP.js} +5 -5
- package/dist/{chunk-T2C6QJG2.js → chunk-SWDHVH2P.js} +2 -2
- package/dist/{chunk-DBM2BD22.js → chunk-SXYCVRLK.js} +3 -3
- package/dist/{chunk-K6X553JB.js → chunk-TFFZUFEP.js} +7 -5
- package/dist/chunk-TFFZUFEP.js.map +1 -0
- package/dist/{chunk-ENV6RDTD.js → chunk-TIJYQXDI.js} +2 -2
- package/dist/{chunk-BP2EV6W5.js → chunk-VAEAGTEQ.js} +4 -4
- package/dist/{chunk-3RACUBII.js → chunk-WIKMCJUR.js} +2 -2
- package/dist/{chunk-QW6JZO5P.js → chunk-WWMHAMAY.js} +2 -2
- package/dist/{chunk-GPW2E4LN.js → chunk-YEZHZCUO.js} +4 -4
- package/dist/{chunk-5FOCXX5E.js → chunk-YVVQUAOO.js} +3 -3
- package/dist/{chunk-5FOCXX5E.js.map → chunk-YVVQUAOO.js.map} +1 -1
- package/dist/{chunk-3XGWCZ63.js → chunk-YXLT4EMM.js} +2 -2
- package/dist/{chunk-Y2RIIF6H.js → chunk-Z6UDTNY6.js} +2 -2
- package/dist/{cli-uQgvDFNE.d.ts → cli-aYxSuPvP.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 +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-cue-recall.js +2 -2
- 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/focused-list-recall.js +2 -2
- package/dist/identity-continuity.d.ts +1 -1
- package/dist/importance.d.ts +1 -1
- package/dist/index.d.ts +121 -121
- package/dist/index.js +39 -39
- 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/lcm-fallback-read.js +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 +1 -1
- package/dist/namespaces/storage.js +3 -3
- 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-B4Y4sWQH.d.ts → orchestrator-D1wcmPNj.d.ts} +17 -14
- package/dist/orchestrator.d.ts +3 -3
- package/dist/orchestrator.js +25 -25
- 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/resolve-auth-token.d.ts +1 -1
- package/dist/response-guidance-recall.js +2 -2
- 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-BKd0Pype.d.ts → semantic-consolidation-MWOdNtSE.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 +3 -2
- package/dist/semantic-rule-verifier.js +5 -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/targeted-fact-recall.js +2 -2
- 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-BgChEr0M.d.ts → types-CgcCpUrf.d.ts} +51 -1
- 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.d.ts +2 -1
- package/dist/verified-recall.js +5 -3
- package/package.json +1 -1
- package/src/access-service-observe-lcm-parity.test.ts +86 -1
- package/src/access-service-observe-scope.test.ts +283 -1
- package/src/access-service-raw-excerpt-read-gate.test.ts +53 -0
- package/src/access-service.ts +391 -93
- package/src/coding/coding-namespace.ts +0 -3
- package/src/config.test.ts +69 -0
- package/src/config.ts +417 -0
- package/src/lcm-fallback-read.ts +2 -6
- package/src/maintenance/namespace-planner.test.ts +1120 -0
- package/src/maintenance/namespace-planner.ts +893 -0
- package/src/namespaces/scope-profiles.test.ts +1074 -0
- package/src/namespaces/scope-profiles.ts +456 -0
- package/src/namespaces/search.test.ts +130 -2
- package/src/namespaces/search.ts +71 -10
- package/src/orchestrator-flush.test.ts +606 -44
- package/src/orchestrator-source-attribution.test.ts +73 -0
- package/src/orchestrator.ts +932 -229
- package/src/qmd-client.test.ts +59 -0
- package/src/qmd.ts +124 -84
- package/src/search/port.ts +16 -0
- package/src/semantic-rule-verifier.ts +13 -6
- package/src/types.ts +64 -0
- package/src/verified-recall.ts +10 -6
- package/dist/chunk-JVRPJ7D4.js.map +0 -1
- package/dist/chunk-K6X553JB.js.map +0 -1
- package/dist/chunk-LJCEWTG3.js.map +0 -1
- package/dist/chunk-MMJANTJX.js +0 -339
- package/dist/chunk-MMJANTJX.js.map +0 -1
- package/dist/chunk-NE2JBMLN.js.map +0 -1
- package/dist/chunk-OL2364SB.js.map +0 -1
- package/dist/chunk-PYWNNF2I.js.map +0 -1
- package/dist/chunk-QKK64Z6M.js.map +0 -1
- package/dist/chunk-YLZLPVKK.js.map +0 -1
- /package/dist/{chunk-JMQSYGXS.js.map → chunk-2BD7DG37.js.map} +0 -0
- /package/dist/{chunk-FVRBLJP6.js.map → chunk-2MXEVL75.js.map} +0 -0
- /package/dist/{chunk-JYN7QNTA.js.map → chunk-54XF2FY7.js.map} +0 -0
- /package/dist/{chunk-7WEB3FLJ.js.map → chunk-5PLUC5OB.js.map} +0 -0
- /package/dist/{chunk-JX2RINDR.js.map → chunk-6G5JEN55.js.map} +0 -0
- /package/dist/{chunk-ZCORQM74.js.map → chunk-AGJKWOKV.js.map} +0 -0
- /package/dist/{chunk-2DSTAWNZ.js.map → chunk-DIBWFCLA.js.map} +0 -0
- /package/dist/{chunk-NAZWHTYV.js.map → chunk-DR67OK4E.js.map} +0 -0
- /package/dist/{chunk-XBIACVCO.js.map → chunk-EC2AYKRX.js.map} +0 -0
- /package/dist/{chunk-RGPUQ66K.js.map → chunk-GCYFUTUC.js.map} +0 -0
- /package/dist/{chunk-JBHXMCYN.js.map → chunk-GRYAECRV.js.map} +0 -0
- /package/dist/{chunk-BJA6DQOC.js.map → chunk-GSHW5VVD.js.map} +0 -0
- /package/dist/{chunk-NCGWXCSW.js.map → chunk-IOZ5WBWD.js.map} +0 -0
- /package/dist/{chunk-7LWRCOP7.js.map → chunk-LZTFCAKE.js.map} +0 -0
- /package/dist/{chunk-6CVI6BP6.js.map → chunk-NXCK7DO7.js.map} +0 -0
- /package/dist/{chunk-Z5MQI7K2.js.map → chunk-PEPHBH2W.js.map} +0 -0
- /package/dist/{chunk-XWQ6ERUG.js.map → chunk-QZRKNA5F.js.map} +0 -0
- /package/dist/{chunk-PS3SYNHP.js.map → chunk-R5DB26G6.js.map} +0 -0
- /package/dist/{chunk-YM3LR4LS.js.map → chunk-SSSXWIBP.js.map} +0 -0
- /package/dist/{chunk-T2C6QJG2.js.map → chunk-SWDHVH2P.js.map} +0 -0
- /package/dist/{chunk-DBM2BD22.js.map → chunk-SXYCVRLK.js.map} +0 -0
- /package/dist/{chunk-ENV6RDTD.js.map → chunk-TIJYQXDI.js.map} +0 -0
- /package/dist/{chunk-BP2EV6W5.js.map → chunk-VAEAGTEQ.js.map} +0 -0
- /package/dist/{chunk-3RACUBII.js.map → chunk-WIKMCJUR.js.map} +0 -0
- /package/dist/{chunk-QW6JZO5P.js.map → chunk-WWMHAMAY.js.map} +0 -0
- /package/dist/{chunk-GPW2E4LN.js.map → chunk-YEZHZCUO.js.map} +0 -0
- /package/dist/{chunk-3XGWCZ63.js.map → chunk-YXLT4EMM.js.map} +0 -0
- /package/dist/{chunk-Y2RIIF6H.js.map → chunk-Z6UDTNY6.js.map} +0 -0
|
@@ -0,0 +1,1120 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { lutimes, mkdir, mkdtemp, open as openFile, 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
|
+
|
|
7
|
+
import type { NamespaceCatalog, NamespaceRecord } from "../namespaces/catalog.js";
|
|
8
|
+
import { namespaceIdentityToken } from "../namespaces/identity.js";
|
|
9
|
+
import type { PluginConfig } from "../types.js";
|
|
10
|
+
import {
|
|
11
|
+
__setNamespaceMaintenanceFsForTest,
|
|
12
|
+
type NamespaceMaintenanceCandidate,
|
|
13
|
+
planNamespaceMaintenance,
|
|
14
|
+
readNamespaceMaintenanceStatuses,
|
|
15
|
+
runNamespaceMaintenanceBatchPlan,
|
|
16
|
+
runNamespaceMaintenancePlan,
|
|
17
|
+
} from "./namespace-planner.js";
|
|
18
|
+
|
|
19
|
+
function makeConfig(memoryDir: string, overrides: Partial<PluginConfig> = {}): PluginConfig {
|
|
20
|
+
return {
|
|
21
|
+
memoryDir,
|
|
22
|
+
namespacesEnabled: true,
|
|
23
|
+
namespaceCatalogEnabled: true,
|
|
24
|
+
defaultNamespace: "default",
|
|
25
|
+
sharedNamespace: "shared",
|
|
26
|
+
namespacePolicies: [],
|
|
27
|
+
maintenanceNamespaceFanoutEnabled: true,
|
|
28
|
+
maintenanceMaxNamespacesPerCycle: 20,
|
|
29
|
+
maintenanceIncludeProjectNamespaces: true,
|
|
30
|
+
maintenanceIncludeBranchNamespaces: false,
|
|
31
|
+
maintenanceIncludeTeamProjectNamespaces: true,
|
|
32
|
+
maintenanceNamespaceLockStaleMs: 10 * 60_000,
|
|
33
|
+
qmdCollection: "remnic",
|
|
34
|
+
entitySchemas: {},
|
|
35
|
+
inlineSourceAttributionFormat: undefined,
|
|
36
|
+
...overrides,
|
|
37
|
+
} as unknown as PluginConfig;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function mkMemoryDir(): Promise<string> {
|
|
41
|
+
return mkdtemp(path.join(os.tmpdir(), "remnic-maintenance-planner-"));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function createNamespaceData(memoryDir: string, namespace: string): Promise<string> {
|
|
45
|
+
const storageDir = path.join(memoryDir, "namespaces", namespaceIdentityToken(namespace));
|
|
46
|
+
await mkdir(path.join(storageDir, "facts"), { recursive: true });
|
|
47
|
+
await writeFile(path.join(storageDir, "facts", "sample.md"), "# sample\n", "utf8");
|
|
48
|
+
return storageDir;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function fakeCatalog(records: NamespaceRecord[]): NamespaceCatalog {
|
|
52
|
+
return {
|
|
53
|
+
enabled: true,
|
|
54
|
+
async listNamespaces() {
|
|
55
|
+
return records;
|
|
56
|
+
},
|
|
57
|
+
async markMaintenance() {},
|
|
58
|
+
} as unknown as NamespaceCatalog;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function record(
|
|
62
|
+
memoryDir: string,
|
|
63
|
+
namespace: string,
|
|
64
|
+
kind: NamespaceRecord["kind"],
|
|
65
|
+
lastWriteAt: string
|
|
66
|
+
): NamespaceRecord {
|
|
67
|
+
return {
|
|
68
|
+
namespace,
|
|
69
|
+
identityToken: namespaceIdentityToken(namespace),
|
|
70
|
+
kind,
|
|
71
|
+
createdAt: "2026-06-30T00:00:00.000Z",
|
|
72
|
+
lastWriteAt,
|
|
73
|
+
storageDir: path.join(memoryDir, "namespaces", namespaceIdentityToken(namespace)),
|
|
74
|
+
discoveredBy: "write",
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function deferred(): { promise: Promise<void>; resolve: () => void } {
|
|
79
|
+
let resolve!: () => void;
|
|
80
|
+
const promise = new Promise<void>((r) => {
|
|
81
|
+
resolve = r;
|
|
82
|
+
});
|
|
83
|
+
return { promise, resolve };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function sleep(ms: number): Promise<void> {
|
|
87
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function writeStatus(
|
|
91
|
+
memoryDir: string,
|
|
92
|
+
status: {
|
|
93
|
+
namespace: string;
|
|
94
|
+
jobName?: string;
|
|
95
|
+
state?: "ran" | "skipped" | "failed";
|
|
96
|
+
reason?: string;
|
|
97
|
+
completedAt: string;
|
|
98
|
+
}
|
|
99
|
+
): Promise<void> {
|
|
100
|
+
const jobName = status.jobName ?? "qmd";
|
|
101
|
+
const statusDir = path.join(memoryDir, "state", "namespace-maintenance-status", jobName);
|
|
102
|
+
await mkdir(statusDir, { recursive: true });
|
|
103
|
+
await writeFile(
|
|
104
|
+
path.join(statusDir, `${namespaceIdentityToken(status.namespace)}.json`),
|
|
105
|
+
`${JSON.stringify({
|
|
106
|
+
version: 1,
|
|
107
|
+
namespace: status.namespace,
|
|
108
|
+
jobName,
|
|
109
|
+
state: status.state ?? "ran",
|
|
110
|
+
reason: status.reason,
|
|
111
|
+
startedAt: status.completedAt,
|
|
112
|
+
completedAt: status.completedAt,
|
|
113
|
+
})}\n`,
|
|
114
|
+
"utf8"
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
test("planner fans out to live cataloged project and team namespaces while skipping branches by default", async () => {
|
|
119
|
+
const memoryDir = await mkMemoryDir();
|
|
120
|
+
try {
|
|
121
|
+
await createNamespaceData(memoryDir, "project-alpha");
|
|
122
|
+
await createNamespaceData(memoryDir, "team-pi-project-alpha");
|
|
123
|
+
await createNamespaceData(memoryDir, "project-alpha-branch-main");
|
|
124
|
+
|
|
125
|
+
const plan = await planNamespaceMaintenance(makeConfig(memoryDir), {
|
|
126
|
+
jobName: "qmd",
|
|
127
|
+
catalog: fakeCatalog([
|
|
128
|
+
record(memoryDir, "project-alpha", "project", "2026-06-30T10:00:00.000Z"),
|
|
129
|
+
record(memoryDir, "team-pi-project-alpha", "team-project", "2026-06-30T11:00:00.000Z"),
|
|
130
|
+
record(memoryDir, "project-alpha-branch-main", "branch", "2026-06-30T12:00:00.000Z"),
|
|
131
|
+
]),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
assert.deepEqual(
|
|
135
|
+
plan.namespaces.map((candidate) => candidate.namespace),
|
|
136
|
+
["default", "shared", "team-pi-project-alpha", "project-alpha"]
|
|
137
|
+
);
|
|
138
|
+
assert.ok(
|
|
139
|
+
plan.skipped.some(
|
|
140
|
+
(skipped) => skipped.namespace === "project-alpha-branch-main" && skipped.reason === "branch_disabled"
|
|
141
|
+
)
|
|
142
|
+
);
|
|
143
|
+
} finally {
|
|
144
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("planner includes branch namespaces only when branch maintenance is enabled", async () => {
|
|
149
|
+
const memoryDir = await mkMemoryDir();
|
|
150
|
+
try {
|
|
151
|
+
await createNamespaceData(memoryDir, "project-alpha-branch-main");
|
|
152
|
+
|
|
153
|
+
const plan = await planNamespaceMaintenance(makeConfig(memoryDir, { maintenanceIncludeBranchNamespaces: true }), {
|
|
154
|
+
jobName: "qmd",
|
|
155
|
+
catalog: fakeCatalog([record(memoryDir, "project-alpha-branch-main", "branch", "2026-06-30T12:00:00.000Z")]),
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
assert.ok(
|
|
159
|
+
plan.namespaces.some((candidate) => candidate.namespace === "project-alpha-branch-main"),
|
|
160
|
+
"branch namespace should be selected when explicitly enabled"
|
|
161
|
+
);
|
|
162
|
+
} finally {
|
|
163
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("planner applies maxNamespacesPerCycle deterministically after default and shared", async () => {
|
|
168
|
+
const memoryDir = await mkMemoryDir();
|
|
169
|
+
try {
|
|
170
|
+
await createNamespaceData(memoryDir, "project-a");
|
|
171
|
+
await createNamespaceData(memoryDir, "project-b");
|
|
172
|
+
|
|
173
|
+
const plan = await planNamespaceMaintenance(makeConfig(memoryDir, { maintenanceMaxNamespacesPerCycle: 3 }), {
|
|
174
|
+
jobName: "qmd",
|
|
175
|
+
catalog: fakeCatalog([
|
|
176
|
+
record(memoryDir, "project-a", "project", "2026-06-30T10:00:00.000Z"),
|
|
177
|
+
record(memoryDir, "project-b", "project", "2026-06-30T11:00:00.000Z"),
|
|
178
|
+
]),
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
assert.deepEqual(
|
|
182
|
+
plan.namespaces.map((candidate) => candidate.namespace),
|
|
183
|
+
["default", "shared", "project-b"]
|
|
184
|
+
);
|
|
185
|
+
assert.ok(
|
|
186
|
+
plan.skipped.some((skipped) => skipped.namespace === "project-a" && skipped.reason === "budget_exhausted")
|
|
187
|
+
);
|
|
188
|
+
} finally {
|
|
189
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("planner preserves configured default priority when catalog metadata disagrees", async () => {
|
|
194
|
+
const memoryDir = await mkMemoryDir();
|
|
195
|
+
try {
|
|
196
|
+
const defaultRecord = record(memoryDir, "default", "project", "2026-06-30T08:00:00.000Z");
|
|
197
|
+
defaultRecord.lastMaintenanceAt = { qmd: "2026-06-30T12:00:00.000Z" };
|
|
198
|
+
|
|
199
|
+
const plan = await planNamespaceMaintenance(
|
|
200
|
+
makeConfig(memoryDir, {
|
|
201
|
+
maintenanceMaxNamespacesPerCycle: 2,
|
|
202
|
+
namespacePolicies: [
|
|
203
|
+
{
|
|
204
|
+
name: "project-explicit",
|
|
205
|
+
readPrincipals: ["*"],
|
|
206
|
+
writePrincipals: ["*"],
|
|
207
|
+
},
|
|
208
|
+
],
|
|
209
|
+
}),
|
|
210
|
+
{
|
|
211
|
+
jobName: "qmd",
|
|
212
|
+
catalog: fakeCatalog([defaultRecord]),
|
|
213
|
+
},
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
assert.deepEqual(
|
|
217
|
+
plan.namespaces.map((candidate) => [candidate.namespace, candidate.kind]),
|
|
218
|
+
[
|
|
219
|
+
["default", "default"],
|
|
220
|
+
["shared", "shared"],
|
|
221
|
+
],
|
|
222
|
+
);
|
|
223
|
+
assert.ok(
|
|
224
|
+
plan.skipped.some(
|
|
225
|
+
(skipped) => skipped.namespace === "project-explicit" && skipped.reason === "budget_exhausted"
|
|
226
|
+
),
|
|
227
|
+
);
|
|
228
|
+
} finally {
|
|
229
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("planner can return the full safe namespace union without applying the cycle budget", async () => {
|
|
234
|
+
const memoryDir = await mkMemoryDir();
|
|
235
|
+
try {
|
|
236
|
+
await createNamespaceData(memoryDir, "project-a");
|
|
237
|
+
await createNamespaceData(memoryDir, "project-b");
|
|
238
|
+
|
|
239
|
+
const plan = await planNamespaceMaintenance(makeConfig(memoryDir, { maintenanceMaxNamespacesPerCycle: 3 }), {
|
|
240
|
+
jobName: "qmd",
|
|
241
|
+
budgetMode: "unbounded",
|
|
242
|
+
catalog: fakeCatalog([
|
|
243
|
+
record(memoryDir, "project-a", "project", "2026-06-30T10:00:00.000Z"),
|
|
244
|
+
record(memoryDir, "project-b", "project", "2026-06-30T11:00:00.000Z"),
|
|
245
|
+
]),
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
assert.deepEqual(
|
|
249
|
+
plan.namespaces.map((candidate) => candidate.namespace),
|
|
250
|
+
["default", "shared", "project-b", "project-a"]
|
|
251
|
+
);
|
|
252
|
+
assert.ok(
|
|
253
|
+
!plan.skipped.some((skipped) => skipped.reason === "budget_exhausted"),
|
|
254
|
+
"startup/recovery namespace discovery must not drop safe namespaces because of the maintenance cycle budget"
|
|
255
|
+
);
|
|
256
|
+
} finally {
|
|
257
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("planner rotates budgeted dynamic namespaces by maintenance age before write recency", async () => {
|
|
262
|
+
const memoryDir = await mkMemoryDir();
|
|
263
|
+
try {
|
|
264
|
+
await createNamespaceData(memoryDir, "project-old-maintenance");
|
|
265
|
+
await createNamespaceData(memoryDir, "project-new-maintenance");
|
|
266
|
+
await createNamespaceData(memoryDir, "project-never-maintained");
|
|
267
|
+
|
|
268
|
+
const oldMaintenance = record(memoryDir, "project-old-maintenance", "project", "2026-06-30T10:00:00.000Z");
|
|
269
|
+
oldMaintenance.lastMaintenanceAt = { qmd: "2026-06-30T10:30:00.000Z" };
|
|
270
|
+
const newMaintenance = record(memoryDir, "project-new-maintenance", "project", "2026-06-30T12:00:00.000Z");
|
|
271
|
+
newMaintenance.lastMaintenanceAt = { qmd: "2026-06-30T12:30:00.000Z" };
|
|
272
|
+
const neverMaintained = record(memoryDir, "project-never-maintained", "project", "2026-06-30T09:00:00.000Z");
|
|
273
|
+
|
|
274
|
+
const plan = await planNamespaceMaintenance(makeConfig(memoryDir, { maintenanceMaxNamespacesPerCycle: 4 }), {
|
|
275
|
+
jobName: "qmd",
|
|
276
|
+
catalog: fakeCatalog([newMaintenance, oldMaintenance, neverMaintained]),
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
assert.deepEqual(
|
|
280
|
+
plan.namespaces.map((candidate) => candidate.namespace),
|
|
281
|
+
["default", "shared", "project-never-maintained", "project-old-maintenance"]
|
|
282
|
+
);
|
|
283
|
+
assert.ok(
|
|
284
|
+
plan.skipped.some(
|
|
285
|
+
(skipped) => skipped.namespace === "project-new-maintenance" && skipped.reason === "budget_exhausted"
|
|
286
|
+
),
|
|
287
|
+
"recently maintained dynamic namespaces should yield the cycle budget to never/older-maintained namespaces"
|
|
288
|
+
);
|
|
289
|
+
} finally {
|
|
290
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("planner rotates configured namespaces from status history when catalog metadata is unavailable", async () => {
|
|
295
|
+
const memoryDir = await mkMemoryDir();
|
|
296
|
+
try {
|
|
297
|
+
for (const [namespace, completedAt] of [
|
|
298
|
+
["project-a", "2026-06-30T12:00:00.000Z"],
|
|
299
|
+
["project-b", "2026-06-30T10:00:00.000Z"],
|
|
300
|
+
["project-c", "2026-06-30T11:00:00.000Z"],
|
|
301
|
+
] as const) {
|
|
302
|
+
await writeStatus(memoryDir, { namespace, completedAt });
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const plan = await planNamespaceMaintenance(
|
|
306
|
+
makeConfig(memoryDir, {
|
|
307
|
+
namespaceCatalogEnabled: false,
|
|
308
|
+
maintenanceMaxNamespacesPerCycle: 3,
|
|
309
|
+
namespacePolicies: [
|
|
310
|
+
{ name: "project-a", readPrincipals: ["*"], writePrincipals: ["*"] },
|
|
311
|
+
{ name: "project-b", readPrincipals: ["*"], writePrincipals: ["*"] },
|
|
312
|
+
{ name: "project-c", readPrincipals: ["*"], writePrincipals: ["*"] },
|
|
313
|
+
],
|
|
314
|
+
}),
|
|
315
|
+
{
|
|
316
|
+
jobName: "qmd",
|
|
317
|
+
}
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
assert.deepEqual(
|
|
321
|
+
plan.namespaces.map((candidate) => candidate.namespace),
|
|
322
|
+
["default", "shared", "project-b"]
|
|
323
|
+
);
|
|
324
|
+
assert.ok(plan.skipped.some((skipped) => skipped.namespace === "project-a" && skipped.reason === "budget_exhausted"));
|
|
325
|
+
} finally {
|
|
326
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("planner ignores unsuccessful status history when rotating configured namespaces", async () => {
|
|
331
|
+
const memoryDir = await mkMemoryDir();
|
|
332
|
+
try {
|
|
333
|
+
await writeStatus(memoryDir, {
|
|
334
|
+
namespace: "project-a",
|
|
335
|
+
state: "failed",
|
|
336
|
+
completedAt: "2026-06-30T12:00:00.000Z",
|
|
337
|
+
});
|
|
338
|
+
await writeStatus(memoryDir, {
|
|
339
|
+
namespace: "project-b",
|
|
340
|
+
state: "skipped",
|
|
341
|
+
reason: "lock_held",
|
|
342
|
+
completedAt: "2026-06-30T11:00:00.000Z",
|
|
343
|
+
});
|
|
344
|
+
await writeStatus(memoryDir, {
|
|
345
|
+
namespace: "project-c",
|
|
346
|
+
completedAt: "2026-06-30T10:00:00.000Z",
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
const plan = await planNamespaceMaintenance(
|
|
350
|
+
makeConfig(memoryDir, {
|
|
351
|
+
namespaceCatalogEnabled: false,
|
|
352
|
+
maintenanceMaxNamespacesPerCycle: 3,
|
|
353
|
+
namespacePolicies: [
|
|
354
|
+
{ name: "project-a", readPrincipals: ["*"], writePrincipals: ["*"] },
|
|
355
|
+
{ name: "project-b", readPrincipals: ["*"], writePrincipals: ["*"] },
|
|
356
|
+
{ name: "project-c", readPrincipals: ["*"], writePrincipals: ["*"] },
|
|
357
|
+
],
|
|
358
|
+
}),
|
|
359
|
+
{
|
|
360
|
+
jobName: "qmd",
|
|
361
|
+
}
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
assert.deepEqual(
|
|
365
|
+
plan.namespaces.map((candidate) => candidate.namespace),
|
|
366
|
+
["default", "shared", "project-a"]
|
|
367
|
+
);
|
|
368
|
+
assert.ok(plan.skipped.some((skipped) => skipped.namespace === "project-c" && skipped.reason === "budget_exhausted"));
|
|
369
|
+
} finally {
|
|
370
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test("planner preserves successful rotation history when later skips overwrite latest status", async () => {
|
|
375
|
+
const memoryDir = await mkMemoryDir();
|
|
376
|
+
try {
|
|
377
|
+
const config = makeConfig(memoryDir, {
|
|
378
|
+
namespaceCatalogEnabled: false,
|
|
379
|
+
maintenanceMaxNamespacesPerCycle: 3,
|
|
380
|
+
namespacePolicies: [
|
|
381
|
+
{ name: "project-a", readPrincipals: ["*"], writePrincipals: ["*"] },
|
|
382
|
+
{ name: "project-b", readPrincipals: ["*"], writePrincipals: ["*"] },
|
|
383
|
+
{ name: "project-c", readPrincipals: ["*"], writePrincipals: ["*"] },
|
|
384
|
+
],
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
await runNamespaceMaintenancePlan(
|
|
388
|
+
config,
|
|
389
|
+
{
|
|
390
|
+
jobName: "qmd",
|
|
391
|
+
generatedAt: "2026-06-30T00:00:00.000Z",
|
|
392
|
+
namespaces: [{ namespace: "project-a", kind: "explicit", source: "configured" }],
|
|
393
|
+
skipped: [],
|
|
394
|
+
budget: { maxNamespacesPerCycle: 3, selected: 1 },
|
|
395
|
+
},
|
|
396
|
+
async () => ({ itemCount: 1 }),
|
|
397
|
+
);
|
|
398
|
+
await runNamespaceMaintenancePlan(
|
|
399
|
+
config,
|
|
400
|
+
{
|
|
401
|
+
jobName: "qmd",
|
|
402
|
+
generatedAt: "2026-06-30T00:00:01.000Z",
|
|
403
|
+
namespaces: [],
|
|
404
|
+
skipped: [{ namespace: "project-a", kind: "explicit", reason: "budget_exhausted" }],
|
|
405
|
+
budget: { maxNamespacesPerCycle: 3, selected: 0 },
|
|
406
|
+
},
|
|
407
|
+
async () => {
|
|
408
|
+
throw new Error("skipped plans must not run namespace work");
|
|
409
|
+
},
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
const statuses = await readNamespaceMaintenanceStatuses(config);
|
|
413
|
+
assert.deepEqual(
|
|
414
|
+
statuses
|
|
415
|
+
.filter((status) => status.namespace === "project-a")
|
|
416
|
+
.map((status) => `${status.state}:${status.reason ?? ""}`),
|
|
417
|
+
["skipped:budget_exhausted"],
|
|
418
|
+
"public maintenance status should still report the latest observed state",
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
const plan = await planNamespaceMaintenance(config, { jobName: "qmd" });
|
|
422
|
+
|
|
423
|
+
assert.deepEqual(
|
|
424
|
+
plan.namespaces.map((candidate) => candidate.namespace),
|
|
425
|
+
["default", "shared", "project-b"],
|
|
426
|
+
"a skipped latest status must not erase the last successful rotation timestamp",
|
|
427
|
+
);
|
|
428
|
+
} finally {
|
|
429
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test("runner honors sub-minute configured stale lock thresholds", async () => {
|
|
434
|
+
const memoryDir = await mkMemoryDir();
|
|
435
|
+
try {
|
|
436
|
+
const config = makeConfig(memoryDir, { maintenanceNamespaceLockStaleMs: 100_000 });
|
|
437
|
+
const lockDir = path.join(memoryDir, "state", "maintenance-locks", "qmd");
|
|
438
|
+
const lockFile = path.join(lockDir, `${namespaceIdentityToken("project-stale")}.lock`);
|
|
439
|
+
await mkdir(lockDir, { recursive: true });
|
|
440
|
+
await writeFile(lockFile, "stale\n", "utf8");
|
|
441
|
+
const staleTime = new Date(Date.now() - 200_000);
|
|
442
|
+
await utimes(lockFile, staleTime, staleTime);
|
|
443
|
+
|
|
444
|
+
let ran = false;
|
|
445
|
+
const summary = await runNamespaceMaintenancePlan(
|
|
446
|
+
config,
|
|
447
|
+
{
|
|
448
|
+
jobName: "qmd",
|
|
449
|
+
generatedAt: "2026-06-30T00:00:00.000Z",
|
|
450
|
+
namespaces: [
|
|
451
|
+
{
|
|
452
|
+
namespace: "project-stale",
|
|
453
|
+
kind: "project",
|
|
454
|
+
source: "catalog",
|
|
455
|
+
},
|
|
456
|
+
],
|
|
457
|
+
skipped: [],
|
|
458
|
+
budget: { maxNamespacesPerCycle: 20, selected: 1 },
|
|
459
|
+
},
|
|
460
|
+
async () => {
|
|
461
|
+
ran = true;
|
|
462
|
+
return { itemCount: 1 };
|
|
463
|
+
}
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
assert.equal(ran, true);
|
|
467
|
+
assert.equal(summary.ran, 1);
|
|
468
|
+
assert.equal(summary.skipped, 0);
|
|
469
|
+
} finally {
|
|
470
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test("runner cleans up partial lock files after acquisition setup failures", async () => {
|
|
475
|
+
const memoryDir = await mkMemoryDir();
|
|
476
|
+
try {
|
|
477
|
+
const config = makeConfig(memoryDir);
|
|
478
|
+
const lockFile = path.join(
|
|
479
|
+
memoryDir,
|
|
480
|
+
"state",
|
|
481
|
+
"maintenance-locks",
|
|
482
|
+
"qmd",
|
|
483
|
+
`${namespaceIdentityToken("project-a")}.lock`,
|
|
484
|
+
);
|
|
485
|
+
const probe = await openFile(path.join(memoryDir, "probe.tmp"), "w");
|
|
486
|
+
const fileHandlePrototype = Object.getPrototypeOf(probe) as {
|
|
487
|
+
writeFile: (data: string, encoding: BufferEncoding) => Promise<void>;
|
|
488
|
+
};
|
|
489
|
+
const originalWriteFile = fileHandlePrototype.writeFile;
|
|
490
|
+
await probe.close();
|
|
491
|
+
let injectedFailure = false;
|
|
492
|
+
fileHandlePrototype.writeFile = async function patchedWriteFile(
|
|
493
|
+
this: unknown,
|
|
494
|
+
data: string,
|
|
495
|
+
encoding: BufferEncoding,
|
|
496
|
+
) {
|
|
497
|
+
await originalWriteFile.call(this, data, encoding);
|
|
498
|
+
if (!injectedFailure) {
|
|
499
|
+
injectedFailure = true;
|
|
500
|
+
throw Object.assign(new Error("simulated lock write failure"), { code: "EIO" });
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
try {
|
|
505
|
+
await assert.rejects(
|
|
506
|
+
() =>
|
|
507
|
+
runNamespaceMaintenancePlan(
|
|
508
|
+
config,
|
|
509
|
+
{
|
|
510
|
+
jobName: "qmd",
|
|
511
|
+
generatedAt: "2026-06-30T00:00:00.000Z",
|
|
512
|
+
namespaces: [{ namespace: "project-a", kind: "project", source: "catalog" }],
|
|
513
|
+
skipped: [],
|
|
514
|
+
budget: { maxNamespacesPerCycle: 20, selected: 1 },
|
|
515
|
+
},
|
|
516
|
+
async () => ({ itemCount: 1 }),
|
|
517
|
+
),
|
|
518
|
+
/simulated lock write failure/,
|
|
519
|
+
);
|
|
520
|
+
} finally {
|
|
521
|
+
fileHandlePrototype.writeFile = originalWriteFile;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
assert.equal(injectedFailure, true);
|
|
525
|
+
await assert.rejects(() => stat(lockFile), "partial setup failures must unlink the created lock file");
|
|
526
|
+
} finally {
|
|
527
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
test("runner cleans up lock directories after owner-file open failures", async () => {
|
|
532
|
+
const memoryDir = await mkMemoryDir();
|
|
533
|
+
try {
|
|
534
|
+
const config = makeConfig(memoryDir);
|
|
535
|
+
const lockFile = path.join(
|
|
536
|
+
memoryDir,
|
|
537
|
+
"state",
|
|
538
|
+
"maintenance-locks",
|
|
539
|
+
"qmd",
|
|
540
|
+
`${namespaceIdentityToken("project-a")}.lock`,
|
|
541
|
+
);
|
|
542
|
+
let injectedFailure = false;
|
|
543
|
+
const restoreFs = __setNamespaceMaintenanceFsForTest({
|
|
544
|
+
async open(target, flags) {
|
|
545
|
+
if (target.toString().startsWith(lockFile) && flags === "wx" && !injectedFailure) {
|
|
546
|
+
injectedFailure = true;
|
|
547
|
+
throw Object.assign(new Error("simulated owner open failure"), { code: "ENOSPC" });
|
|
548
|
+
}
|
|
549
|
+
return openFile(target, flags);
|
|
550
|
+
},
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
try {
|
|
554
|
+
await assert.rejects(
|
|
555
|
+
() =>
|
|
556
|
+
runNamespaceMaintenancePlan(
|
|
557
|
+
config,
|
|
558
|
+
{
|
|
559
|
+
jobName: "qmd",
|
|
560
|
+
generatedAt: "2026-06-30T00:00:00.000Z",
|
|
561
|
+
namespaces: [{ namespace: "project-a", kind: "project", source: "catalog" }],
|
|
562
|
+
skipped: [],
|
|
563
|
+
budget: { maxNamespacesPerCycle: 20, selected: 1 },
|
|
564
|
+
},
|
|
565
|
+
async () => ({ itemCount: 1 }),
|
|
566
|
+
),
|
|
567
|
+
/simulated owner open failure/,
|
|
568
|
+
);
|
|
569
|
+
} finally {
|
|
570
|
+
restoreFs();
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
assert.equal(injectedFailure, true);
|
|
574
|
+
await assert.rejects(() => stat(lockFile), "owner open failures must remove the empty lock directory");
|
|
575
|
+
} finally {
|
|
576
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
577
|
+
}
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
test("runner does not recursively retry persistent stale lock removal failures", async () => {
|
|
581
|
+
const memoryDir = await mkMemoryDir();
|
|
582
|
+
const lockDir = path.join(memoryDir, "state", "maintenance-locks", "qmd");
|
|
583
|
+
try {
|
|
584
|
+
const config = makeConfig(memoryDir, { maintenanceNamespaceLockStaleMs: 1 });
|
|
585
|
+
const lockFile = path.join(lockDir, `${namespaceIdentityToken("project-a")}.lock`);
|
|
586
|
+
await mkdir(lockDir, { recursive: true });
|
|
587
|
+
await writeFile(lockFile, "stale\n", "utf8");
|
|
588
|
+
const staleTime = new Date(Date.now() - 5_000);
|
|
589
|
+
await utimes(lockFile, staleTime, staleTime);
|
|
590
|
+
const restoreFs = __setNamespaceMaintenanceFsForTest({
|
|
591
|
+
async rm(target, options) {
|
|
592
|
+
if (target.toString() === lockFile) {
|
|
593
|
+
throw Object.assign(new Error("simulated stale lock removal failure"), { code: "EACCES" });
|
|
594
|
+
}
|
|
595
|
+
return rm(target, options);
|
|
596
|
+
},
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
try {
|
|
600
|
+
await assert.rejects(
|
|
601
|
+
() =>
|
|
602
|
+
runNamespaceMaintenancePlan(
|
|
603
|
+
config,
|
|
604
|
+
{
|
|
605
|
+
jobName: "qmd",
|
|
606
|
+
generatedAt: "2026-06-30T00:00:00.000Z",
|
|
607
|
+
namespaces: [{ namespace: "project-a", kind: "project", source: "catalog" }],
|
|
608
|
+
skipped: [],
|
|
609
|
+
budget: { maxNamespacesPerCycle: 20, selected: 1 },
|
|
610
|
+
},
|
|
611
|
+
async () => ({ itemCount: 1 }),
|
|
612
|
+
),
|
|
613
|
+
(error: unknown) => {
|
|
614
|
+
const code =
|
|
615
|
+
typeof error === "object" && error !== null && "code" in error
|
|
616
|
+
? (error as { code?: string }).code
|
|
617
|
+
: undefined;
|
|
618
|
+
return code === "EACCES";
|
|
619
|
+
},
|
|
620
|
+
);
|
|
621
|
+
} finally {
|
|
622
|
+
restoreFs();
|
|
623
|
+
}
|
|
624
|
+
} finally {
|
|
625
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
test("runner treats symlinked stale lock directories as held without traversing them", async () => {
|
|
630
|
+
const memoryDir = await mkMemoryDir();
|
|
631
|
+
const outsideDir = await mkdtemp(path.join(os.tmpdir(), "remnic-lock-symlink-target-"));
|
|
632
|
+
const lockDir = path.join(memoryDir, "state", "maintenance-locks", "qmd");
|
|
633
|
+
const sentinelPath = path.join(outsideDir, "sentinel.txt");
|
|
634
|
+
try {
|
|
635
|
+
const config = makeConfig(memoryDir, { maintenanceNamespaceLockStaleMs: 1 });
|
|
636
|
+
const lockFile = path.join(lockDir, `${namespaceIdentityToken("project-a")}.lock`);
|
|
637
|
+
await mkdir(lockDir, { recursive: true });
|
|
638
|
+
await writeFile(sentinelPath, "keep\n", "utf8");
|
|
639
|
+
await symlink(outsideDir, lockFile, "dir");
|
|
640
|
+
const staleTime = new Date(Date.now() - 5_000);
|
|
641
|
+
await lutimes(lockFile, staleTime, staleTime);
|
|
642
|
+
|
|
643
|
+
const summary = await runNamespaceMaintenancePlan(
|
|
644
|
+
config,
|
|
645
|
+
{
|
|
646
|
+
jobName: "qmd",
|
|
647
|
+
generatedAt: "2026-06-30T00:00:00.000Z",
|
|
648
|
+
namespaces: [{ namespace: "project-a", kind: "project", source: "catalog" }],
|
|
649
|
+
skipped: [],
|
|
650
|
+
budget: { maxNamespacesPerCycle: 20, selected: 1 },
|
|
651
|
+
},
|
|
652
|
+
async () => {
|
|
653
|
+
throw new Error("symlinked lock paths must not be acquired");
|
|
654
|
+
},
|
|
655
|
+
);
|
|
656
|
+
|
|
657
|
+
assert.equal(summary.ran, 0);
|
|
658
|
+
assert.equal(summary.skipped, 1);
|
|
659
|
+
assert.ok(
|
|
660
|
+
summary.statuses.some(
|
|
661
|
+
(status) =>
|
|
662
|
+
status.namespace === "project-a" &&
|
|
663
|
+
status.state === "skipped" &&
|
|
664
|
+
status.reason === "lock_held",
|
|
665
|
+
),
|
|
666
|
+
"symlinked lock paths should be treated as held/corrupt locks",
|
|
667
|
+
);
|
|
668
|
+
await assert.doesNotReject(
|
|
669
|
+
() => stat(sentinelPath),
|
|
670
|
+
"stale lock cleanup must not traverse a symlink and remove files outside memoryDir",
|
|
671
|
+
);
|
|
672
|
+
} finally {
|
|
673
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
674
|
+
await rm(outsideDir, { recursive: true, force: true });
|
|
675
|
+
}
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
test("runner release does not clear a replacement worker lock after stale takeover", async () => {
|
|
679
|
+
const memoryDir = await mkMemoryDir();
|
|
680
|
+
try {
|
|
681
|
+
const config = makeConfig(memoryDir, { maintenanceNamespaceLockStaleMs: 1 });
|
|
682
|
+
const lockFile = path.join(
|
|
683
|
+
memoryDir,
|
|
684
|
+
"state",
|
|
685
|
+
"maintenance-locks",
|
|
686
|
+
"qmd",
|
|
687
|
+
`${namespaceIdentityToken("project-stale")}.lock`
|
|
688
|
+
);
|
|
689
|
+
const plan = {
|
|
690
|
+
jobName: "qmd",
|
|
691
|
+
generatedAt: "2026-06-30T00:00:00.000Z",
|
|
692
|
+
namespaces: [
|
|
693
|
+
{
|
|
694
|
+
namespace: "project-stale",
|
|
695
|
+
kind: "project" as const,
|
|
696
|
+
source: "catalog" as const,
|
|
697
|
+
},
|
|
698
|
+
],
|
|
699
|
+
skipped: [],
|
|
700
|
+
budget: { maxNamespacesPerCycle: 20, selected: 1 },
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
const firstEntered = deferred();
|
|
704
|
+
const releaseFirst = deferred();
|
|
705
|
+
const secondEntered = deferred();
|
|
706
|
+
const releaseSecond = deferred();
|
|
707
|
+
|
|
708
|
+
const firstRun = runNamespaceMaintenancePlan(config, plan, async () => {
|
|
709
|
+
firstEntered.resolve();
|
|
710
|
+
await releaseFirst.promise;
|
|
711
|
+
return { itemCount: 1 };
|
|
712
|
+
});
|
|
713
|
+
await firstEntered.promise;
|
|
714
|
+
|
|
715
|
+
const staleTime = new Date(Date.now() - 5_000);
|
|
716
|
+
await utimes(lockFile, staleTime, staleTime);
|
|
717
|
+
|
|
718
|
+
const secondRun = runNamespaceMaintenancePlan(config, plan, async () => {
|
|
719
|
+
secondEntered.resolve();
|
|
720
|
+
await releaseSecond.promise;
|
|
721
|
+
return { itemCount: 1 };
|
|
722
|
+
});
|
|
723
|
+
await secondEntered.promise;
|
|
724
|
+
|
|
725
|
+
releaseFirst.resolve();
|
|
726
|
+
await firstRun;
|
|
727
|
+
await assert.doesNotReject(
|
|
728
|
+
() => stat(lockFile),
|
|
729
|
+
"first worker release must not delete a newer replacement lock"
|
|
730
|
+
);
|
|
731
|
+
|
|
732
|
+
releaseSecond.resolve();
|
|
733
|
+
await secondRun;
|
|
734
|
+
} finally {
|
|
735
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
736
|
+
}
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
test("runner records sanitized failure details without raw filesystem paths", async () => {
|
|
740
|
+
const memoryDir = await mkMemoryDir();
|
|
741
|
+
try {
|
|
742
|
+
const config = makeConfig(memoryDir);
|
|
743
|
+
const rawPath = path.join(memoryDir, "state", "maintenance-locks", "secret.json");
|
|
744
|
+
const error = Object.assign(new Error(`failed to open ${rawPath}`), { code: "EACCES" });
|
|
745
|
+
|
|
746
|
+
const summary = await runNamespaceMaintenancePlan(
|
|
747
|
+
config,
|
|
748
|
+
{
|
|
749
|
+
jobName: "qmd",
|
|
750
|
+
generatedAt: "2026-06-30T00:00:00.000Z",
|
|
751
|
+
namespaces: [
|
|
752
|
+
{
|
|
753
|
+
namespace: "project-failed",
|
|
754
|
+
kind: "project",
|
|
755
|
+
source: "catalog",
|
|
756
|
+
},
|
|
757
|
+
],
|
|
758
|
+
skipped: [],
|
|
759
|
+
budget: { maxNamespacesPerCycle: 20, selected: 1 },
|
|
760
|
+
},
|
|
761
|
+
async () => {
|
|
762
|
+
throw error;
|
|
763
|
+
}
|
|
764
|
+
);
|
|
765
|
+
|
|
766
|
+
assert.equal(summary.failed, 1);
|
|
767
|
+
assert.equal(summary.statuses[0]?.error, "Error (EACCES)");
|
|
768
|
+
assert.ok(!summary.statuses[0]?.error?.includes(memoryDir), "raw failure status must not leak filesystem paths");
|
|
769
|
+
|
|
770
|
+
const statuses = await readNamespaceMaintenanceStatuses(config);
|
|
771
|
+
const persisted = statuses.find((status) => status.namespace === "project-failed");
|
|
772
|
+
assert.equal(persisted?.error, "Error (EACCES)");
|
|
773
|
+
} finally {
|
|
774
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
775
|
+
}
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
test("runner records lock-held skips without running duplicate namespace work", async () => {
|
|
779
|
+
const memoryDir = await mkMemoryDir();
|
|
780
|
+
try {
|
|
781
|
+
const config = makeConfig(memoryDir);
|
|
782
|
+
const lockDir = path.join(memoryDir, "state", "maintenance-locks", "qmd");
|
|
783
|
+
await mkdir(lockDir, { recursive: true });
|
|
784
|
+
await writeFile(path.join(lockDir, `${namespaceIdentityToken("project-a")}.lock`), "held\n", "utf8");
|
|
785
|
+
|
|
786
|
+
const summary = await runNamespaceMaintenancePlan(
|
|
787
|
+
config,
|
|
788
|
+
{
|
|
789
|
+
jobName: "qmd",
|
|
790
|
+
generatedAt: "2026-06-30T00:00:00.000Z",
|
|
791
|
+
namespaces: [
|
|
792
|
+
{
|
|
793
|
+
namespace: "project-a",
|
|
794
|
+
kind: "project",
|
|
795
|
+
source: "catalog",
|
|
796
|
+
},
|
|
797
|
+
],
|
|
798
|
+
skipped: [],
|
|
799
|
+
budget: { maxNamespacesPerCycle: 20, selected: 1 },
|
|
800
|
+
},
|
|
801
|
+
async () => {
|
|
802
|
+
throw new Error("runner should not execute when lock is held");
|
|
803
|
+
}
|
|
804
|
+
);
|
|
805
|
+
|
|
806
|
+
assert.equal(summary.ran, 0);
|
|
807
|
+
assert.equal(summary.skipped, 1);
|
|
808
|
+
assert.equal(summary.statuses[0]?.reason, "lock_held");
|
|
809
|
+
|
|
810
|
+
const statuses = await readNamespaceMaintenanceStatuses(config);
|
|
811
|
+
assert.ok(
|
|
812
|
+
statuses.some(
|
|
813
|
+
(status) =>
|
|
814
|
+
status.namespace === "project-a" &&
|
|
815
|
+
status.jobName === "qmd" &&
|
|
816
|
+
status.state === "skipped" &&
|
|
817
|
+
status.reason === "lock_held"
|
|
818
|
+
),
|
|
819
|
+
"lock-held status should be persisted for doctor/dashboard consumers"
|
|
820
|
+
);
|
|
821
|
+
} finally {
|
|
822
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
823
|
+
}
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
test("runner refreshes live locks during long namespace work", async () => {
|
|
827
|
+
const memoryDir = await mkMemoryDir();
|
|
828
|
+
try {
|
|
829
|
+
const config = makeConfig(memoryDir, { maintenanceNamespaceLockStaleMs: 30 });
|
|
830
|
+
const lockFile = path.join(
|
|
831
|
+
memoryDir,
|
|
832
|
+
"state",
|
|
833
|
+
"maintenance-locks",
|
|
834
|
+
"qmd",
|
|
835
|
+
`${namespaceIdentityToken("project-a")}.lock`
|
|
836
|
+
);
|
|
837
|
+
|
|
838
|
+
await runNamespaceMaintenancePlan(
|
|
839
|
+
config,
|
|
840
|
+
{
|
|
841
|
+
jobName: "qmd",
|
|
842
|
+
generatedAt: "2026-06-30T00:00:00.000Z",
|
|
843
|
+
namespaces: [
|
|
844
|
+
{
|
|
845
|
+
namespace: "project-a",
|
|
846
|
+
kind: "project",
|
|
847
|
+
source: "catalog",
|
|
848
|
+
},
|
|
849
|
+
],
|
|
850
|
+
skipped: [],
|
|
851
|
+
budget: { maxNamespacesPerCycle: 20, selected: 1 },
|
|
852
|
+
},
|
|
853
|
+
async () => {
|
|
854
|
+
const before = (await stat(lockFile)).mtimeMs;
|
|
855
|
+
await sleep(90);
|
|
856
|
+
const after = (await stat(lockFile)).mtimeMs;
|
|
857
|
+
assert.ok(after > before, "live maintenance locks should be touched before they become stale");
|
|
858
|
+
return { itemCount: 1 };
|
|
859
|
+
}
|
|
860
|
+
);
|
|
861
|
+
} finally {
|
|
862
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
863
|
+
}
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
test("batch runner locks selected namespaces and runs one shared job", async () => {
|
|
867
|
+
const memoryDir = await mkMemoryDir();
|
|
868
|
+
try {
|
|
869
|
+
const config = makeConfig(memoryDir);
|
|
870
|
+
const runnerInputs: string[][] = [];
|
|
871
|
+
|
|
872
|
+
const summary = await runNamespaceMaintenanceBatchPlan(
|
|
873
|
+
config,
|
|
874
|
+
{
|
|
875
|
+
jobName: "qmd",
|
|
876
|
+
generatedAt: "2026-06-30T00:00:00.000Z",
|
|
877
|
+
namespaces: [
|
|
878
|
+
{ namespace: "default", kind: "default", source: "configured" },
|
|
879
|
+
{ namespace: "project-a", kind: "project", source: "catalog" },
|
|
880
|
+
],
|
|
881
|
+
skipped: [],
|
|
882
|
+
budget: { maxNamespacesPerCycle: 20, selected: 2 },
|
|
883
|
+
},
|
|
884
|
+
async (candidates) => {
|
|
885
|
+
runnerInputs.push(candidates.map((candidate) => candidate.namespace));
|
|
886
|
+
return { itemCount: 1 };
|
|
887
|
+
}
|
|
888
|
+
);
|
|
889
|
+
|
|
890
|
+
assert.equal(summary.ran, 2);
|
|
891
|
+
assert.deepEqual(runnerInputs, [["default", "project-a"]]);
|
|
892
|
+
assert.ok(summary.statuses.every((status) => status.state === "ran"));
|
|
893
|
+
} finally {
|
|
894
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
895
|
+
}
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
test("batch runner can require every selected namespace lock before running shared work", async () => {
|
|
899
|
+
const memoryDir = await mkMemoryDir();
|
|
900
|
+
try {
|
|
901
|
+
const config = makeConfig(memoryDir);
|
|
902
|
+
const lockDir = path.join(memoryDir, "state", "maintenance-locks", "qmd");
|
|
903
|
+
const defaultLock = path.join(lockDir, `${namespaceIdentityToken("default")}.lock`);
|
|
904
|
+
const projectLock = path.join(lockDir, `${namespaceIdentityToken("project-a")}.lock`);
|
|
905
|
+
await mkdir(lockDir, { recursive: true });
|
|
906
|
+
await writeFile(projectLock, "held\n", "utf8");
|
|
907
|
+
let runnerCalls = 0;
|
|
908
|
+
|
|
909
|
+
const summary = await runNamespaceMaintenanceBatchPlan(
|
|
910
|
+
config,
|
|
911
|
+
{
|
|
912
|
+
jobName: "qmd",
|
|
913
|
+
generatedAt: "2026-06-30T00:00:00.000Z",
|
|
914
|
+
namespaces: [
|
|
915
|
+
{ namespace: "default", kind: "default", source: "configured" },
|
|
916
|
+
{ namespace: "project-a", kind: "project", source: "catalog" },
|
|
917
|
+
],
|
|
918
|
+
skipped: [],
|
|
919
|
+
budget: { maxNamespacesPerCycle: 20, selected: 2 },
|
|
920
|
+
},
|
|
921
|
+
async () => {
|
|
922
|
+
runnerCalls += 1;
|
|
923
|
+
return { itemCount: 1 };
|
|
924
|
+
},
|
|
925
|
+
undefined,
|
|
926
|
+
{ requireAllLocks: true }
|
|
927
|
+
);
|
|
928
|
+
|
|
929
|
+
assert.equal(runnerCalls, 0);
|
|
930
|
+
assert.equal(summary.ran, 0);
|
|
931
|
+
assert.equal(summary.skipped, 2);
|
|
932
|
+
assert.ok(summary.statuses.every((status) => status.state === "skipped"));
|
|
933
|
+
assert.deepEqual(
|
|
934
|
+
Object.fromEntries(summary.statuses.map((status) => [status.namespace, status.reason])),
|
|
935
|
+
{
|
|
936
|
+
default: "batch_lock_incomplete",
|
|
937
|
+
"project-a": "lock_held",
|
|
938
|
+
}
|
|
939
|
+
);
|
|
940
|
+
await assert.rejects(() => stat(defaultLock), "partial acquired locks must be released");
|
|
941
|
+
await assert.doesNotReject(() => stat(projectLock), "externally held locks must be left alone");
|
|
942
|
+
} finally {
|
|
943
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
944
|
+
}
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
test("batch runner skips locked namespaces without blocking acquired batch work by default", async () => {
|
|
948
|
+
const memoryDir = await mkMemoryDir();
|
|
949
|
+
try {
|
|
950
|
+
const config = makeConfig(memoryDir);
|
|
951
|
+
const lockDir = path.join(memoryDir, "state", "maintenance-locks", "qmd");
|
|
952
|
+
const defaultLock = path.join(lockDir, `${namespaceIdentityToken("default")}.lock`);
|
|
953
|
+
const projectLock = path.join(lockDir, `${namespaceIdentityToken("project-a")}.lock`);
|
|
954
|
+
await mkdir(lockDir, { recursive: true });
|
|
955
|
+
await writeFile(projectLock, "held\n", "utf8");
|
|
956
|
+
const runnerInputs: string[][] = [];
|
|
957
|
+
|
|
958
|
+
const summary = await runNamespaceMaintenanceBatchPlan(
|
|
959
|
+
config,
|
|
960
|
+
{
|
|
961
|
+
jobName: "qmd",
|
|
962
|
+
generatedAt: "2026-06-30T00:00:00.000Z",
|
|
963
|
+
namespaces: [
|
|
964
|
+
{ namespace: "default", kind: "default", source: "configured" },
|
|
965
|
+
{ namespace: "project-a", kind: "project", source: "catalog" },
|
|
966
|
+
],
|
|
967
|
+
skipped: [],
|
|
968
|
+
budget: { maxNamespacesPerCycle: 20, selected: 2 },
|
|
969
|
+
},
|
|
970
|
+
async (candidates) => {
|
|
971
|
+
runnerInputs.push(candidates.map((candidate) => candidate.namespace));
|
|
972
|
+
return { itemCount: 1 };
|
|
973
|
+
}
|
|
974
|
+
);
|
|
975
|
+
|
|
976
|
+
assert.deepEqual(runnerInputs, [["default"]]);
|
|
977
|
+
assert.equal(summary.ran, 1);
|
|
978
|
+
assert.equal(summary.skipped, 1);
|
|
979
|
+
assert.deepEqual(
|
|
980
|
+
Object.fromEntries(summary.statuses.map((status) => [status.namespace, status.state])),
|
|
981
|
+
{
|
|
982
|
+
default: "ran",
|
|
983
|
+
"project-a": "skipped",
|
|
984
|
+
}
|
|
985
|
+
);
|
|
986
|
+
assert.deepEqual(
|
|
987
|
+
Object.fromEntries(summary.statuses.map((status) => [status.namespace, status.reason])),
|
|
988
|
+
{
|
|
989
|
+
default: undefined,
|
|
990
|
+
"project-a": "lock_held",
|
|
991
|
+
}
|
|
992
|
+
);
|
|
993
|
+
await assert.rejects(() => stat(defaultLock), "acquired locks must be released after the batch run");
|
|
994
|
+
await assert.doesNotReject(() => stat(projectLock), "externally held locks must be left alone");
|
|
995
|
+
} finally {
|
|
996
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
997
|
+
}
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
test("batch runner releases acquired locks when later lock acquisition throws", async () => {
|
|
1001
|
+
const memoryDir = await mkMemoryDir();
|
|
1002
|
+
try {
|
|
1003
|
+
const config = makeConfig(memoryDir);
|
|
1004
|
+
const lockDir = path.join(memoryDir, "state", "maintenance-locks", "qmd");
|
|
1005
|
+
const defaultLock = path.join(lockDir, `${namespaceIdentityToken("default")}.lock`);
|
|
1006
|
+
const throwingCandidate = {
|
|
1007
|
+
kind: "project",
|
|
1008
|
+
source: "catalog",
|
|
1009
|
+
} as unknown as NamespaceMaintenanceCandidate;
|
|
1010
|
+
Object.defineProperty(throwingCandidate, "namespace", {
|
|
1011
|
+
get() {
|
|
1012
|
+
throw new Error("namespace unavailable");
|
|
1013
|
+
},
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
await assert.rejects(
|
|
1017
|
+
() =>
|
|
1018
|
+
runNamespaceMaintenanceBatchPlan(
|
|
1019
|
+
config,
|
|
1020
|
+
{
|
|
1021
|
+
jobName: "qmd",
|
|
1022
|
+
generatedAt: "2026-06-30T00:00:00.000Z",
|
|
1023
|
+
namespaces: [
|
|
1024
|
+
{ namespace: "default", kind: "default", source: "configured" },
|
|
1025
|
+
throwingCandidate,
|
|
1026
|
+
],
|
|
1027
|
+
skipped: [],
|
|
1028
|
+
budget: { maxNamespacesPerCycle: 20, selected: 2 },
|
|
1029
|
+
},
|
|
1030
|
+
async () => ({ itemCount: 1 }),
|
|
1031
|
+
),
|
|
1032
|
+
/namespace unavailable/,
|
|
1033
|
+
);
|
|
1034
|
+
|
|
1035
|
+
await assert.rejects(() => stat(defaultLock), "acquired locks must be released on acquisition errors");
|
|
1036
|
+
} finally {
|
|
1037
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
1038
|
+
}
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
test("batch runner records every locked namespace as failed when the shared job fails", async () => {
|
|
1042
|
+
const memoryDir = await mkMemoryDir();
|
|
1043
|
+
try {
|
|
1044
|
+
const config = makeConfig(memoryDir);
|
|
1045
|
+
|
|
1046
|
+
const summary = await runNamespaceMaintenanceBatchPlan(
|
|
1047
|
+
config,
|
|
1048
|
+
{
|
|
1049
|
+
jobName: "qmd",
|
|
1050
|
+
generatedAt: "2026-06-30T00:00:00.000Z",
|
|
1051
|
+
namespaces: [
|
|
1052
|
+
{ namespace: "default", kind: "default", source: "configured" },
|
|
1053
|
+
{ namespace: "project-a", kind: "project", source: "catalog" },
|
|
1054
|
+
],
|
|
1055
|
+
skipped: [],
|
|
1056
|
+
budget: { maxNamespacesPerCycle: 20, selected: 2 },
|
|
1057
|
+
},
|
|
1058
|
+
async () => {
|
|
1059
|
+
throw Object.assign(new Error("qmd failed"), { code: "EIO" });
|
|
1060
|
+
}
|
|
1061
|
+
);
|
|
1062
|
+
|
|
1063
|
+
assert.equal(summary.failed, 2);
|
|
1064
|
+
assert.ok(summary.statuses.every((status) => status.state === "failed"));
|
|
1065
|
+
assert.ok(summary.statuses.every((status) => status.error === "Error (EIO)"));
|
|
1066
|
+
} finally {
|
|
1067
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
1068
|
+
}
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
test("batch runner can record classified shared-job errors as skipped", async () => {
|
|
1072
|
+
const memoryDir = await mkMemoryDir();
|
|
1073
|
+
try {
|
|
1074
|
+
const config = makeConfig(memoryDir);
|
|
1075
|
+
let markMaintenanceCalls = 0;
|
|
1076
|
+
const catalog = {
|
|
1077
|
+
enabled: true,
|
|
1078
|
+
async listNamespaces() {
|
|
1079
|
+
return [];
|
|
1080
|
+
},
|
|
1081
|
+
async markMaintenance() {
|
|
1082
|
+
markMaintenanceCalls += 1;
|
|
1083
|
+
},
|
|
1084
|
+
} as unknown as NamespaceCatalog;
|
|
1085
|
+
|
|
1086
|
+
const summary = await runNamespaceMaintenanceBatchPlan(
|
|
1087
|
+
config,
|
|
1088
|
+
{
|
|
1089
|
+
jobName: "qmd",
|
|
1090
|
+
generatedAt: "2026-06-30T00:00:00.000Z",
|
|
1091
|
+
namespaces: [
|
|
1092
|
+
{ namespace: "default", kind: "default", source: "configured" },
|
|
1093
|
+
{ namespace: "project-a", kind: "project", source: "catalog" },
|
|
1094
|
+
],
|
|
1095
|
+
skipped: [],
|
|
1096
|
+
budget: { maxNamespacesPerCycle: 20, selected: 2 },
|
|
1097
|
+
},
|
|
1098
|
+
async () => {
|
|
1099
|
+
throw new Error("QMD update skipped by global min-interval gate");
|
|
1100
|
+
},
|
|
1101
|
+
catalog,
|
|
1102
|
+
{
|
|
1103
|
+
skipReasonForError(error) {
|
|
1104
|
+
return error instanceof Error && error.message.includes("min-interval")
|
|
1105
|
+
? "throttled"
|
|
1106
|
+
: null;
|
|
1107
|
+
},
|
|
1108
|
+
},
|
|
1109
|
+
);
|
|
1110
|
+
|
|
1111
|
+
assert.equal(summary.ran, 0);
|
|
1112
|
+
assert.equal(summary.skipped, 2);
|
|
1113
|
+
assert.equal(summary.failed, 0);
|
|
1114
|
+
assert.ok(summary.statuses.every((status) => status.state === "skipped"));
|
|
1115
|
+
assert.ok(summary.statuses.every((status) => status.reason === "throttled"));
|
|
1116
|
+
assert.equal(markMaintenanceCalls, 0);
|
|
1117
|
+
} finally {
|
|
1118
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
1119
|
+
}
|
|
1120
|
+
});
|