@remnic/core 9.3.688 → 9.3.690
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-boundary.d.ts +5 -4
- package/dist/access-boundary.js +12 -11
- package/dist/access-cli.js +90 -26
- package/dist/access-cli.js.map +1 -1
- package/dist/access-http.d.ts +4 -3
- package/dist/access-http.js +15 -14
- package/dist/access-mcp.d.ts +11 -3
- package/dist/access-mcp.js +14 -13
- package/dist/access-operations.d.ts +11 -5
- package/dist/access-operations.js +15 -12
- package/dist/{access-service-DmCHJ4cH.d.ts → access-service-Dujr3MTm.d.ts} +62 -2
- package/dist/access-service.d.ts +4 -3
- package/dist/access-service.js +11 -10
- package/dist/access-surface-catalog.d.ts +4 -3
- package/dist/access-surface-catalog.js +2 -0
- package/dist/access-surface-catalog.js.map +1 -1
- package/dist/bootstrap.d.ts +3 -2
- package/dist/briefing.d.ts +1 -0
- package/dist/briefing.js +3 -2
- package/dist/buffer.d.ts +1 -0
- package/dist/{catalog-COqWZlZ6.d.ts → catalog-CKPtJ114.d.ts} +1 -1
- package/dist/causal-consolidation.js +4 -3
- package/dist/causal-consolidation.js.map +1 -1
- package/dist/{chunk-B4XVLHJA.js → chunk-2N6UNJSX.js} +2 -2
- package/dist/{chunk-NN7QYW5W.js → chunk-46URPRE6.js} +2 -2
- package/dist/chunk-46URPRE6.js.map +1 -0
- package/dist/{chunk-4N3TFFPH.js → chunk-54PVJDO5.js} +2 -2
- package/dist/{chunk-PH3HOKYW.js → chunk-67MMWC74.js} +2 -2
- package/dist/{chunk-STOEE37X.js → chunk-BKAMHZYR.js} +2 -2
- package/dist/{chunk-B43NZNMG.js → chunk-CE6CBRCV.js} +432 -2240
- package/dist/chunk-CE6CBRCV.js.map +1 -0
- package/dist/{chunk-RVYD6LR3.js → chunk-CP2NZQLT.js} +15 -4
- package/dist/chunk-CP2NZQLT.js.map +1 -0
- package/dist/{chunk-BLIWOONZ.js → chunk-CUNTLEJP.js} +4 -12
- package/dist/chunk-CUNTLEJP.js.map +1 -0
- package/dist/{chunk-2SJCWLQD.js → chunk-CWE74HRG.js} +3 -3
- package/dist/{chunk-VX6OBUDW.js → chunk-GR77Z2BM.js} +2 -2
- package/dist/{chunk-CTOQEZSN.js → chunk-LXIEXSHU.js} +2 -2
- package/dist/{chunk-KFBOZYME.js → chunk-NSVXK7M5.js} +33 -4
- package/dist/chunk-NSVXK7M5.js.map +1 -0
- package/dist/{chunk-473JIN2U.js → chunk-OMKUJTVJ.js} +37 -5
- package/dist/chunk-OMKUJTVJ.js.map +1 -0
- package/dist/{chunk-GS55WYRL.js → chunk-PHZKALOE.js} +2 -2
- package/dist/{chunk-6O6A6YUO.js → chunk-RGNEARXW.js} +3 -3
- package/dist/{chunk-4FE2K57M.js → chunk-RTFAZOIR.js} +2 -2
- package/dist/{chunk-UTYBJR7M.js → chunk-SB6CQTKP.js} +2 -2
- package/dist/chunk-SVOZFLIQ.js +16 -0
- package/dist/chunk-SVOZFLIQ.js.map +1 -0
- package/dist/{chunk-2KAYTPPT.js → chunk-TYF3D4MS.js} +5 -5
- package/dist/{chunk-WIHPNY65.js → chunk-UD5OKH4J.js} +2 -2
- package/dist/{chunk-QANVLERJ.js → chunk-UPIBE2DK.js} +2 -2
- package/dist/{chunk-CCWHPGT4.js → chunk-WROKD3XC.js} +45 -30
- package/dist/chunk-WROKD3XC.js.map +1 -0
- package/dist/{chunk-ZPTISBQU.js → chunk-X5J3JZR3.js} +7 -7
- package/dist/{chunk-OV4D5T7V.js → chunk-X7RLU5CR.js} +2 -2
- package/dist/{chunk-S2OU5DZY.js → chunk-XKUKJIOY.js} +30 -7
- package/dist/chunk-XKUKJIOY.js.map +1 -0
- package/dist/{chunk-FUCUR2OZ.js → chunk-Z7KILAOU.js} +603 -9
- package/dist/chunk-Z7KILAOU.js.map +1 -0
- package/dist/chunk-ZU7N3S6V.js +2190 -0
- package/dist/chunk-ZU7N3S6V.js.map +1 -0
- package/dist/{chunk-DCWIQFNA.js → chunk-ZUDM75KG.js} +4 -4
- package/dist/{cli-D8nZ2MPH.d.ts → cli-BkDp6WNi.d.ts} +2 -2
- package/dist/cli.d.ts +5 -4
- package/dist/cli.js +32 -30
- package/dist/compounding/engine.d.ts +1 -0
- package/dist/compounding/engine.js +3 -2
- package/dist/connectors/codex-materialize-runner.js +3 -2
- package/dist/connectors/index.js +3 -2
- package/dist/consolidation-provenance-check.d.ts +1 -0
- package/dist/consolidation-undo.d.ts +1 -0
- package/dist/contradiction/index.d.ts +1 -0
- package/dist/conversation-index/backend.js +2 -2
- package/dist/entity-retrieval.d.ts +1 -0
- package/dist/entity-retrieval.js +3 -2
- package/dist/explicit-capture.d.ts +3 -2
- package/dist/index.d.ts +6 -5
- package/dist/index.js +47 -44
- package/dist/index.js.map +1 -1
- package/dist/lcm/engine.js +3 -3
- package/dist/lcm/index.js +5 -5
- package/dist/maintenance/memory-governance.js +3 -2
- package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +3 -2
- package/dist/maintenance/rebuild-memory-projection.js +4 -3
- package/dist/mcp-memory-inspector-app.d.ts +4 -3
- package/dist/memory-worth-outcomes.d.ts +1 -0
- package/dist/namespaces/migrate.d.ts +2 -1
- package/dist/namespaces/migrate.js +10 -9
- package/dist/namespaces/search.js +6 -6
- package/dist/namespaces/storage.d.ts +2 -1
- package/dist/namespaces/storage.js +3 -2
- package/dist/operator-toolkit.d.ts +1 -0
- package/dist/operator-toolkit.js +13 -11
- package/dist/{orchestrator-CA6ouzBn.d.ts → orchestrator-B7ixmUkP.d.ts} +145 -1
- package/dist/orchestrator.d.ts +3 -2
- package/dist/orchestrator.js +22 -20
- package/dist/recall-pipeline-stages.d.ts +167 -0
- package/dist/recall-pipeline-stages.js +61 -0
- package/dist/recall-pipeline-stages.js.map +1 -0
- package/dist/schemas.d.ts +10 -10
- package/dist/search/factory.js +5 -5
- package/dist/search/index.js +9 -9
- package/dist/semantic-consolidation.js +4 -3
- package/dist/semantic-rule-promotion.js +3 -2
- package/dist/semantic-rule-verifier.js +3 -2
- package/dist/storage.d.ts +3 -27
- package/dist/storage.js +5 -3
- package/dist/structured-attributes.d.ts +29 -0
- package/dist/structured-attributes.js +8 -0
- package/dist/structured-attributes.js.map +1 -0
- package/dist/temporal-supersession.d.ts +1 -0
- package/dist/tier-migration.d.ts +1 -0
- package/dist/verified-recall.js +3 -2
- package/package.json +2 -2
- package/src/access-boundary.ts +2 -1
- package/src/access-cli.test.ts +40 -0
- package/src/access-cli.ts +90 -2
- package/src/access-http.ts +35 -6
- package/src/access-mcp.ts +34 -0
- package/src/access-operations.ts +45 -0
- package/src/access-service.ts +60 -0
- package/src/access-surface-catalog.test.ts +1 -1
- package/src/access-surface-catalog.ts +2 -0
- package/src/cli.ts +18 -0
- package/src/coding/architecture-card.test.ts +544 -0
- package/src/coding/architecture-card.ts +687 -0
- package/src/coding/architecture-surfaces.test.ts +579 -0
- package/src/coding/architecture-surfaces.ts +457 -0
- package/src/maintenance/namespace-maintenance-fanout.test.ts +595 -0
- package/src/maintenance/namespace-maintenance-fanout.ts +318 -0
- package/src/maintenance/namespace-planner.ts +74 -16
- package/src/operator-toolkit.ts +25 -0
- package/src/orchestrator.ts +144 -0
- package/src/recall-pipeline-stages.test.ts +269 -0
- package/src/recall-pipeline-stages.ts +294 -0
- package/src/storage.ts +6 -20
- package/src/structured-attributes.ts +39 -0
- package/dist/chunk-473JIN2U.js.map +0 -1
- package/dist/chunk-B43NZNMG.js.map +0 -1
- package/dist/chunk-BLIWOONZ.js.map +0 -1
- package/dist/chunk-CCWHPGT4.js.map +0 -1
- package/dist/chunk-FUCUR2OZ.js.map +0 -1
- package/dist/chunk-KFBOZYME.js.map +0 -1
- package/dist/chunk-NN7QYW5W.js.map +0 -1
- package/dist/chunk-RVYD6LR3.js.map +0 -1
- package/dist/chunk-S2OU5DZY.js.map +0 -1
- /package/dist/{chunk-B4XVLHJA.js.map → chunk-2N6UNJSX.js.map} +0 -0
- /package/dist/{chunk-4N3TFFPH.js.map → chunk-54PVJDO5.js.map} +0 -0
- /package/dist/{chunk-PH3HOKYW.js.map → chunk-67MMWC74.js.map} +0 -0
- /package/dist/{chunk-STOEE37X.js.map → chunk-BKAMHZYR.js.map} +0 -0
- /package/dist/{chunk-2SJCWLQD.js.map → chunk-CWE74HRG.js.map} +0 -0
- /package/dist/{chunk-VX6OBUDW.js.map → chunk-GR77Z2BM.js.map} +0 -0
- /package/dist/{chunk-CTOQEZSN.js.map → chunk-LXIEXSHU.js.map} +0 -0
- /package/dist/{chunk-GS55WYRL.js.map → chunk-PHZKALOE.js.map} +0 -0
- /package/dist/{chunk-6O6A6YUO.js.map → chunk-RGNEARXW.js.map} +0 -0
- /package/dist/{chunk-4FE2K57M.js.map → chunk-RTFAZOIR.js.map} +0 -0
- /package/dist/{chunk-UTYBJR7M.js.map → chunk-SB6CQTKP.js.map} +0 -0
- /package/dist/{chunk-2KAYTPPT.js.map → chunk-TYF3D4MS.js.map} +0 -0
- /package/dist/{chunk-WIHPNY65.js.map → chunk-UD5OKH4J.js.map} +0 -0
- /package/dist/{chunk-QANVLERJ.js.map → chunk-UPIBE2DK.js.map} +0 -0
- /package/dist/{chunk-ZPTISBQU.js.map → chunk-X5J3JZR3.js.map} +0 -0
- /package/dist/{chunk-OV4D5T7V.js.map → chunk-X7RLU5CR.js.map} +0 -0
- /package/dist/{chunk-DCWIQFNA.js.map → chunk-ZUDM75KG.js.map} +0 -0
|
@@ -0,0 +1,2190 @@
|
|
|
1
|
+
import {
|
|
2
|
+
resolveDefaultNamespaceRoot,
|
|
3
|
+
resolveNamespaceStorageRoot
|
|
4
|
+
} from "./chunk-UD5OKH4J.js";
|
|
5
|
+
import {
|
|
6
|
+
namespaceIdentityFromToken,
|
|
7
|
+
namespaceIdentityToken,
|
|
8
|
+
normalizeNamespaceIdentity
|
|
9
|
+
} from "./chunk-ZFXCQPNO.js";
|
|
10
|
+
import {
|
|
11
|
+
displayErrorDetail
|
|
12
|
+
} from "./chunk-6KYMPV2O.js";
|
|
13
|
+
import {
|
|
14
|
+
ALL_CATEGORY_DIRS
|
|
15
|
+
} from "./chunk-VS2IYZRU.js";
|
|
16
|
+
import {
|
|
17
|
+
isSafeRouteNamespace
|
|
18
|
+
} from "./chunk-U3PN77QT.js";
|
|
19
|
+
|
|
20
|
+
// src/namespaces/catalog.ts
|
|
21
|
+
import path from "path";
|
|
22
|
+
import { randomUUID } from "crypto";
|
|
23
|
+
import {
|
|
24
|
+
appendFile,
|
|
25
|
+
lstat,
|
|
26
|
+
mkdir,
|
|
27
|
+
open,
|
|
28
|
+
readdir,
|
|
29
|
+
readFile,
|
|
30
|
+
realpath,
|
|
31
|
+
rename,
|
|
32
|
+
stat,
|
|
33
|
+
unlink,
|
|
34
|
+
utimes,
|
|
35
|
+
writeFile
|
|
36
|
+
} from "fs/promises";
|
|
37
|
+
var NAMESPACE_KINDS = [
|
|
38
|
+
"default",
|
|
39
|
+
"self",
|
|
40
|
+
"shared",
|
|
41
|
+
"project",
|
|
42
|
+
"branch",
|
|
43
|
+
"team-project",
|
|
44
|
+
"explicit",
|
|
45
|
+
"legacy"
|
|
46
|
+
];
|
|
47
|
+
var NAMESPACE_DISCOVERY_SOURCES = [
|
|
48
|
+
"config",
|
|
49
|
+
"write",
|
|
50
|
+
"read",
|
|
51
|
+
"scan",
|
|
52
|
+
"migration"
|
|
53
|
+
];
|
|
54
|
+
var CATALOG_FILE = "namespaces.jsonl";
|
|
55
|
+
var STATE_DIR = "state";
|
|
56
|
+
var REBUILD_LOCK_FILE = "namespaces.rebuild.lock";
|
|
57
|
+
var REBUILD_LOCK_STALE_MS = 3e4;
|
|
58
|
+
var REBUILD_LOCK_MAX_WAIT_MS = 5e3;
|
|
59
|
+
var REBUILD_LOCK_POLL_MS = 50;
|
|
60
|
+
var REBUILD_LOCK_HEARTBEAT_MS = 1e4;
|
|
61
|
+
var MEMORY_DATA_CHILDREN = [
|
|
62
|
+
...ALL_CATEGORY_DIRS,
|
|
63
|
+
"entities",
|
|
64
|
+
"artifacts",
|
|
65
|
+
"identity",
|
|
66
|
+
"config",
|
|
67
|
+
"summaries",
|
|
68
|
+
"profile.md",
|
|
69
|
+
"state"
|
|
70
|
+
];
|
|
71
|
+
function isCatalogEnabled(config) {
|
|
72
|
+
if (config.namespacesEnabled !== true) return false;
|
|
73
|
+
return config.namespaceCatalogEnabled !== false;
|
|
74
|
+
}
|
|
75
|
+
var FILE_MEMORY_DATA_CHILDREN = /* @__PURE__ */ new Set(["profile.md"]);
|
|
76
|
+
function isNotFoundError(err) {
|
|
77
|
+
return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
|
|
78
|
+
}
|
|
79
|
+
async function inspectMemoryDataMarker(rootDir, child) {
|
|
80
|
+
const childPath = path.join(rootDir, child);
|
|
81
|
+
let entry;
|
|
82
|
+
try {
|
|
83
|
+
entry = await lstat(childPath);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
return isNotFoundError(err) ? { state: "absent" } : { state: "invalid", detail: `${child}: ${err instanceof Error ? err.message : String(err)}` };
|
|
86
|
+
}
|
|
87
|
+
if (entry.isSymbolicLink()) return { state: "invalid", detail: `${child}: symlink` };
|
|
88
|
+
if (FILE_MEMORY_DATA_CHILDREN.has(child)) {
|
|
89
|
+
return entry.isFile() ? { state: "valid" } : { state: "invalid", detail: `${child}: expected file` };
|
|
90
|
+
}
|
|
91
|
+
if (!entry.isDirectory()) return { state: "invalid", detail: `${child}: expected directory` };
|
|
92
|
+
try {
|
|
93
|
+
const rootReal = await realpath(rootDir);
|
|
94
|
+
const childReal = await realpath(childPath);
|
|
95
|
+
return isPathInside(rootReal, childReal) ? { state: "valid" } : { state: "invalid", detail: `${child}: escapes namespace root` };
|
|
96
|
+
} catch (err) {
|
|
97
|
+
return { state: "invalid", detail: `${child}: ${err instanceof Error ? err.message : String(err)}` };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async function inspectMemoryDataRoot(rootDir) {
|
|
101
|
+
let hasData = false;
|
|
102
|
+
for (const child of MEMORY_DATA_CHILDREN) {
|
|
103
|
+
const marker = await inspectMemoryDataMarker(rootDir, child);
|
|
104
|
+
if (marker.state === "invalid") {
|
|
105
|
+
return { hasData: false, invalidMarker: marker.detail };
|
|
106
|
+
}
|
|
107
|
+
if (marker.state === "valid") {
|
|
108
|
+
hasData = true;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return { hasData };
|
|
112
|
+
}
|
|
113
|
+
async function hasMemoryData(rootDir) {
|
|
114
|
+
return (await inspectMemoryDataRoot(rootDir)).hasData;
|
|
115
|
+
}
|
|
116
|
+
function isValidIsoTimestamp(value) {
|
|
117
|
+
const ms = Date.parse(value);
|
|
118
|
+
return Number.isFinite(ms);
|
|
119
|
+
}
|
|
120
|
+
function isNamespaceKind(value) {
|
|
121
|
+
return typeof value === "string" && NAMESPACE_KINDS.includes(value);
|
|
122
|
+
}
|
|
123
|
+
function isNamespaceDiscoverySource(value) {
|
|
124
|
+
return typeof value === "string" && NAMESPACE_DISCOVERY_SOURCES.includes(value);
|
|
125
|
+
}
|
|
126
|
+
function coerceRecord(value) {
|
|
127
|
+
if (typeof value !== "object" || value === null) return null;
|
|
128
|
+
const v = value;
|
|
129
|
+
if (typeof v.namespace !== "string") return null;
|
|
130
|
+
const namespace = normalizeNamespaceIdentity(v.namespace);
|
|
131
|
+
if (namespace.length === 0) return null;
|
|
132
|
+
if (typeof v.identityToken !== "string" || v.identityToken.length === 0) return null;
|
|
133
|
+
const expectedIdentityToken = namespaceIdentityToken(namespace);
|
|
134
|
+
if (v.identityToken !== expectedIdentityToken) return null;
|
|
135
|
+
if (typeof v.storageDir !== "string" || v.storageDir.length === 0) return null;
|
|
136
|
+
if (typeof v.createdAt !== "string" || v.createdAt.length === 0) return null;
|
|
137
|
+
if (!isValidIsoTimestamp(v.createdAt)) return null;
|
|
138
|
+
const kind = v.kind === void 0 ? "explicit" : isNamespaceKind(v.kind) ? v.kind : null;
|
|
139
|
+
if (!kind) return null;
|
|
140
|
+
const discoveredBy = v.discoveredBy === void 0 ? "scan" : isNamespaceDiscoverySource(v.discoveredBy) ? v.discoveredBy : null;
|
|
141
|
+
if (!discoveredBy) return null;
|
|
142
|
+
const record = {
|
|
143
|
+
namespace,
|
|
144
|
+
identityToken: expectedIdentityToken,
|
|
145
|
+
kind,
|
|
146
|
+
createdAt: v.createdAt,
|
|
147
|
+
storageDir: v.storageDir,
|
|
148
|
+
discoveredBy
|
|
149
|
+
};
|
|
150
|
+
if (typeof v.principal === "string") record.principal = v.principal;
|
|
151
|
+
if (typeof v.projectId === "string") record.projectId = v.projectId;
|
|
152
|
+
if (typeof v.branch === "string") record.branch = v.branch;
|
|
153
|
+
if (typeof v.parentNamespace === "string") record.parentNamespace = v.parentNamespace;
|
|
154
|
+
if (typeof v.lastReadAt === "string" && isValidIsoTimestamp(v.lastReadAt)) {
|
|
155
|
+
record.lastReadAt = v.lastReadAt;
|
|
156
|
+
}
|
|
157
|
+
if (typeof v.lastWriteAt === "string" && isValidIsoTimestamp(v.lastWriteAt)) {
|
|
158
|
+
record.lastWriteAt = v.lastWriteAt;
|
|
159
|
+
}
|
|
160
|
+
if (v.lastMaintenanceAt && typeof v.lastMaintenanceAt === "object") {
|
|
161
|
+
const out = {};
|
|
162
|
+
for (const [k, val] of Object.entries(v.lastMaintenanceAt)) {
|
|
163
|
+
if (typeof val === "string" && isValidIsoTimestamp(val)) out[k] = val;
|
|
164
|
+
}
|
|
165
|
+
if (Object.keys(out).length > 0) record.lastMaintenanceAt = out;
|
|
166
|
+
}
|
|
167
|
+
return record;
|
|
168
|
+
}
|
|
169
|
+
function laterIso(a, b) {
|
|
170
|
+
if (!a) return b;
|
|
171
|
+
if (!b) return a;
|
|
172
|
+
const am = Date.parse(a);
|
|
173
|
+
const bm = Date.parse(b);
|
|
174
|
+
if (!Number.isFinite(am)) return b;
|
|
175
|
+
if (!Number.isFinite(bm)) return a;
|
|
176
|
+
return bm > am ? b : a;
|
|
177
|
+
}
|
|
178
|
+
function mergeNewerTouchFields(base, fresh) {
|
|
179
|
+
const merged = { ...base };
|
|
180
|
+
const lr = laterIso(base.lastReadAt, fresh.lastReadAt);
|
|
181
|
+
if (lr) merged.lastReadAt = lr;
|
|
182
|
+
const lw = laterIso(base.lastWriteAt, fresh.lastWriteAt);
|
|
183
|
+
if (lw) merged.lastWriteAt = lw;
|
|
184
|
+
if (base.lastMaintenanceAt || fresh.lastMaintenanceAt) {
|
|
185
|
+
const jobs = { ...base.lastMaintenanceAt ?? {} };
|
|
186
|
+
for (const [job, ts] of Object.entries(fresh.lastMaintenanceAt ?? {})) {
|
|
187
|
+
const latest = laterIso(jobs[job], ts);
|
|
188
|
+
if (latest) jobs[job] = latest;
|
|
189
|
+
}
|
|
190
|
+
if (Object.keys(jobs).length > 0) merged.lastMaintenanceAt = jobs;
|
|
191
|
+
}
|
|
192
|
+
return merged;
|
|
193
|
+
}
|
|
194
|
+
function serializeRecord(record) {
|
|
195
|
+
const ordered = {};
|
|
196
|
+
const source = record;
|
|
197
|
+
for (const key of Object.keys(source).sort()) {
|
|
198
|
+
const value = source[key];
|
|
199
|
+
if (value === void 0) continue;
|
|
200
|
+
if (key === "lastMaintenanceAt" && value && typeof value === "object") {
|
|
201
|
+
const sortedJobs = {};
|
|
202
|
+
for (const jobKey of Object.keys(value).sort()) {
|
|
203
|
+
sortedJobs[jobKey] = value[jobKey];
|
|
204
|
+
}
|
|
205
|
+
ordered[key] = sortedJobs;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
ordered[key] = value;
|
|
209
|
+
}
|
|
210
|
+
return JSON.stringify(ordered);
|
|
211
|
+
}
|
|
212
|
+
function inferKind(namespace, config) {
|
|
213
|
+
if (namespace === normalizeNamespaceIdentity(config.defaultNamespace)) return "default";
|
|
214
|
+
if (namespace === normalizeNamespaceIdentity(config.sharedNamespace)) return "shared";
|
|
215
|
+
if (config.namespacePolicies.some((p) => normalizeNamespaceIdentity(p.name) === namespace)) {
|
|
216
|
+
return "explicit";
|
|
217
|
+
}
|
|
218
|
+
if (/-branch-|^project-[^-]+-branch-/.test(namespace) || namespace.includes("-branch-")) {
|
|
219
|
+
return "branch";
|
|
220
|
+
}
|
|
221
|
+
if (/^team-.*-project-/.test(namespace) || /^team-.*project-/.test(namespace)) {
|
|
222
|
+
return "team-project";
|
|
223
|
+
}
|
|
224
|
+
if (/^project-/.test(namespace) || /-project-/.test(namespace)) {
|
|
225
|
+
return "project";
|
|
226
|
+
}
|
|
227
|
+
return "explicit";
|
|
228
|
+
}
|
|
229
|
+
var NamespaceCatalog = class {
|
|
230
|
+
constructor(config) {
|
|
231
|
+
this.config = config;
|
|
232
|
+
this.memoryDir = config.memoryDir;
|
|
233
|
+
this.stateDir = path.join(this.memoryDir, STATE_DIR);
|
|
234
|
+
this.catalogPath = path.join(this.stateDir, CATALOG_FILE);
|
|
235
|
+
this.rebuildLockPath = path.join(this.stateDir, REBUILD_LOCK_FILE);
|
|
236
|
+
this.defaultNamespaceIdentity = normalizeNamespaceIdentity(config.defaultNamespace);
|
|
237
|
+
}
|
|
238
|
+
config;
|
|
239
|
+
memoryDir;
|
|
240
|
+
stateDir;
|
|
241
|
+
catalogPath;
|
|
242
|
+
rebuildLockPath;
|
|
243
|
+
// Per-INSTANCE lock owner id (round 6, codex P2 — NBsGP). The rebuild lock
|
|
244
|
+
// file records this id, not just `process.pid`, so two NamespaceCatalog
|
|
245
|
+
// instances in the SAME process sharing a memoryDir are NOT mistaken for each
|
|
246
|
+
// other: a touch on instance B must still wait for instance A's rebuild lock
|
|
247
|
+
// (different owner id, same PID) instead of skipping as "self-held".
|
|
248
|
+
lockOwnerId = randomUUID();
|
|
249
|
+
// Serialized write chain that recovers from rejection (CLAUDE.md rule #40)
|
|
250
|
+
// so a single failed append cannot permanently poison subsequent writes.
|
|
251
|
+
writeChain = Promise.resolve();
|
|
252
|
+
// Test-only seam (round 7 — NEZkA): fires inside a touch's HELD-lock critical
|
|
253
|
+
// section, after the lock is acquired but BEFORE the read→merge→append. A
|
|
254
|
+
// deterministic concurrency test installs a hook here to widen the (otherwise
|
|
255
|
+
// microscopic) window and prove that a cross-process rebuild CANNOT run its
|
|
256
|
+
// load→rename while a touch holds the lock. Never set in production code.
|
|
257
|
+
onTouchCriticalSectionForTest;
|
|
258
|
+
// Test-only seam (round 7 — NEZkA): fires inside a mutating rebuild's HELD-lock
|
|
259
|
+
// critical section, after the final cross-process re-merge `loadCompacted()` and
|
|
260
|
+
// BEFORE the atomic `rename()`. This is the EXACT window in which a check-then-
|
|
261
|
+
// append touch (the old bug) would clobber its append. A deterministic test
|
|
262
|
+
// installs a hook here to attempt a cross-instance touch in this window and
|
|
263
|
+
// assert the held mutex blocks it. Never set in production code.
|
|
264
|
+
onRebuildBeforeRenameForTest;
|
|
265
|
+
// Test-only seam (NFgCT, codex P2): fires AFTER the lockless disk scan but
|
|
266
|
+
// BEFORE the rebuild acquires the cross-process file lock for its final
|
|
267
|
+
// load→merge→rename window. A deterministic test installs a hook here to attempt
|
|
268
|
+
// a cross-instance touch DURING the scan window and assert it is NOT blocked or
|
|
269
|
+
// dropped — proving the scan no longer holds the mutex. Never set in production.
|
|
270
|
+
onRebuildAfterScanForTest;
|
|
271
|
+
// Test-only seam (NG7Bg, codex P2): fires inside `breakStaleRebuildLock` AFTER it
|
|
272
|
+
// has judged the lock stale and captured its identity, but BEFORE the final
|
|
273
|
+
// re-validation+unlink. A deterministic test installs a hook here to REPLACE the
|
|
274
|
+
// lock file (a fresh holder created a new lock in the race window) and assert the
|
|
275
|
+
// break is skipped — the replacement's active lock is not deleted. Never set in
|
|
276
|
+
// production.
|
|
277
|
+
onBeforeBreakStaleUnlinkForTest;
|
|
278
|
+
// Normalized (trimmed) default namespace identity (NH-FH, cursor Medium).
|
|
279
|
+
// Catalog records key namespaces by their NORMALIZED identity
|
|
280
|
+
// (`normalizeNamespaceIdentity`), but several default-namespace exemptions and
|
|
281
|
+
// memoryDir-ownership checks compared against the RAW `config.defaultNamespace`.
|
|
282
|
+
// If the configured default name carries surrounding whitespace the record key
|
|
283
|
+
// is trimmed while the comparison string is not, so the default row is
|
|
284
|
+
// misclassified, dropped at read time, or given the wrong storage root. Compare
|
|
285
|
+
// against this normalized form everywhere instead.
|
|
286
|
+
defaultNamespaceIdentity;
|
|
287
|
+
/** Whether the catalog is active (namespaces enabled and catalog not opted out). */
|
|
288
|
+
get enabled() {
|
|
289
|
+
return isCatalogEnabled(this.config);
|
|
290
|
+
}
|
|
291
|
+
// ── Public enumeration API ──────────────────────────────────────────────
|
|
292
|
+
/**
|
|
293
|
+
* Sanitize a record at the enumeration boundary (round 5, cursor Medium + codex
|
|
294
|
+
* P2; round 6 — NDXHe). Reads return whatever is in `namespaces.jsonl` after
|
|
295
|
+
* schema checks only, so a tampered or pre-fix row could surface unsafe data to
|
|
296
|
+
* maintenance/QMD until a rewrite occurs. Two distinct defenses:
|
|
297
|
+
*
|
|
298
|
+
* 1. UNSAFE NAMESPACE NAME (NGZqr, codex P2): an unsafe non-default namespace
|
|
299
|
+
* (e.g. `../evil`, a name with separators, or >64 chars) is REJECTED outright
|
|
300
|
+
* — return `null` so the caller drops it. The disk SCAN and the hot touch
|
|
301
|
+
* path both reject such names with the SAME default-exempt `isSafeRouteNamespace`
|
|
302
|
+
* gate, so the read boundary MUST agree, or `listNamespaces()`/`getNamespaceRecord()`
|
|
303
|
+
* would expose a namespace those paths reject (note `isStorageDirForNamespace`
|
|
304
|
+
* can still build a tokenized root even for `../evil`, so storageDir sanitation
|
|
305
|
+
* alone does not catch it). The default namespace is exempt (it may be a
|
|
306
|
+
* non-route literal), matching every other validation site.
|
|
307
|
+
*
|
|
308
|
+
* 2. UNSAFE storageDir: for an otherwise-valid namespace, apply the SAME contract
|
|
309
|
+
* as the write path — full containment (`isContainedStorageDir`: lexical +
|
|
310
|
+
* symlink/realpath) AND namespace ownership (`isStorageDirForNamespace`). When
|
|
311
|
+
* a record fails EITHER check we substitute the trusted resolved-and-safe root
|
|
312
|
+
* for that namespace (rule 42: read and write stay symmetric).
|
|
313
|
+
*/
|
|
314
|
+
async sanitizeRecordForRead(record) {
|
|
315
|
+
if (record.namespace !== this.defaultNamespaceIdentity && !isSafeRouteNamespace(record.namespace)) {
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
if (await this.isContainedStorageDir(record.storageDir) && await this.isStorageDirForNamespace(record.namespace, record.storageDir)) {
|
|
319
|
+
return record;
|
|
320
|
+
}
|
|
321
|
+
const safe = await this.resolveSafeStorageDir(record.namespace);
|
|
322
|
+
return { ...record, storageDir: safe };
|
|
323
|
+
}
|
|
324
|
+
storageRootOwnershipRank(record, resolvedStorageDir, configured) {
|
|
325
|
+
if (resolvedStorageDir === path.resolve(this.memoryDir)) {
|
|
326
|
+
return record.namespace === this.defaultNamespaceIdentity ? 0 : 3;
|
|
327
|
+
}
|
|
328
|
+
const leaf = path.basename(resolvedStorageDir);
|
|
329
|
+
const tokenOwnsRoot = namespaceIdentityToken(record.namespace) === leaf;
|
|
330
|
+
if (tokenOwnsRoot && configured.has(record.namespace)) {
|
|
331
|
+
return 0;
|
|
332
|
+
}
|
|
333
|
+
if (record.namespace === leaf) return 1;
|
|
334
|
+
if (tokenOwnsRoot) return 2;
|
|
335
|
+
return 3;
|
|
336
|
+
}
|
|
337
|
+
configuredNamespaceIdentities() {
|
|
338
|
+
return new Set(
|
|
339
|
+
[
|
|
340
|
+
this.config.defaultNamespace,
|
|
341
|
+
this.config.sharedNamespace,
|
|
342
|
+
...this.config.namespacePolicies.map((p) => p.name)
|
|
343
|
+
].map((n) => normalizeNamespaceIdentity(n)).filter((n) => n.length > 0)
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
preferStorageRootOwner(current, candidate, resolvedStorageDir, configured) {
|
|
347
|
+
const currentRank = this.storageRootOwnershipRank(current, resolvedStorageDir, configured);
|
|
348
|
+
const candidateRank = this.storageRootOwnershipRank(candidate, resolvedStorageDir, configured);
|
|
349
|
+
if (candidateRank < currentRank) return candidate;
|
|
350
|
+
if (candidateRank > currentRank) return current;
|
|
351
|
+
const byName = candidate.namespace.localeCompare(current.namespace);
|
|
352
|
+
if (byName < 0) return candidate;
|
|
353
|
+
if (byName > 0) return current;
|
|
354
|
+
return candidate.identityToken.localeCompare(current.identityToken) < 0 ? candidate : current;
|
|
355
|
+
}
|
|
356
|
+
dropDuplicateStorageRootAliases(records) {
|
|
357
|
+
const byStorageDir = /* @__PURE__ */ new Map();
|
|
358
|
+
const configured = this.configuredNamespaceIdentities();
|
|
359
|
+
for (const record of records) {
|
|
360
|
+
const resolvedStorageDir = path.resolve(record.storageDir);
|
|
361
|
+
const current = byStorageDir.get(resolvedStorageDir);
|
|
362
|
+
if (!current) {
|
|
363
|
+
byStorageDir.set(resolvedStorageDir, record);
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
const owner = this.preferStorageRootOwner(current, record, resolvedStorageDir, configured);
|
|
367
|
+
const alias = owner === current ? record : current;
|
|
368
|
+
byStorageDir.set(resolvedStorageDir, mergeNewerTouchFields(owner, alias));
|
|
369
|
+
}
|
|
370
|
+
return [...byStorageDir.values()];
|
|
371
|
+
}
|
|
372
|
+
async loadSanitizedRecords() {
|
|
373
|
+
const records = await this.loadCompacted();
|
|
374
|
+
const sanitized = await Promise.all(
|
|
375
|
+
[...records.values()].map((r) => this.sanitizeRecordForRead(r))
|
|
376
|
+
);
|
|
377
|
+
return this.dropDuplicateStorageRootAliases(
|
|
378
|
+
sanitized.filter((r) => r !== null)
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
async listNamespaces(filter) {
|
|
382
|
+
if (!this.enabled) return [];
|
|
383
|
+
let out = await this.loadSanitizedRecords();
|
|
384
|
+
if (filter?.kind) out = out.filter((r) => r.kind === filter.kind);
|
|
385
|
+
if (filter?.discoveredBy) out = out.filter((r) => r.discoveredBy === filter.discoveredBy);
|
|
386
|
+
if (filter?.writtenSince) {
|
|
387
|
+
const sinceMs = filter.writtenSince.getTime();
|
|
388
|
+
out = out.filter((r) => {
|
|
389
|
+
if (!r.lastWriteAt) return false;
|
|
390
|
+
const ms = Date.parse(r.lastWriteAt);
|
|
391
|
+
return Number.isFinite(ms) && ms >= sinceMs;
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
return out.sort((a, b) => {
|
|
395
|
+
const byName = a.namespace.localeCompare(b.namespace);
|
|
396
|
+
if (byName !== 0) return byName;
|
|
397
|
+
return a.identityToken.localeCompare(b.identityToken);
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
async getNamespaceRecord(namespace) {
|
|
401
|
+
if (!this.enabled) return null;
|
|
402
|
+
const ns = normalizeNamespaceIdentity(namespace);
|
|
403
|
+
return (await this.loadSanitizedRecords()).find((record) => record.namespace === ns) ?? null;
|
|
404
|
+
}
|
|
405
|
+
// ── Touch API (cheap, failure-tolerant) ─────────────────────────────────
|
|
406
|
+
async markRead(namespace, metadata) {
|
|
407
|
+
await this.touch(namespace, "read", metadata);
|
|
408
|
+
}
|
|
409
|
+
async markWrite(namespace, metadata) {
|
|
410
|
+
await this.touch(namespace, "write", metadata);
|
|
411
|
+
}
|
|
412
|
+
async markMaintenance(namespace, jobName, at) {
|
|
413
|
+
if (typeof jobName !== "string" || jobName.trim().length === 0) {
|
|
414
|
+
throw new Error("markMaintenance requires a non-empty jobName");
|
|
415
|
+
}
|
|
416
|
+
await this.touch(namespace, "maintenance", { at }, jobName.trim());
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Register namespaces known purely from config (default, shared, explicit
|
|
420
|
+
* policies). Source `config`. Cheap and idempotent.
|
|
421
|
+
*/
|
|
422
|
+
async registerConfiguredNamespaces() {
|
|
423
|
+
if (!this.enabled) return;
|
|
424
|
+
const names = /* @__PURE__ */ new Set([
|
|
425
|
+
this.config.defaultNamespace,
|
|
426
|
+
this.config.sharedNamespace,
|
|
427
|
+
...this.config.namespacePolicies.map((p) => p.name)
|
|
428
|
+
]);
|
|
429
|
+
for (const ns of names) {
|
|
430
|
+
if (!ns) continue;
|
|
431
|
+
if (normalizeNamespaceIdentity(ns) !== this.defaultNamespaceIdentity && !isSafeRouteNamespace(ns)) {
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
try {
|
|
435
|
+
await this.register(ns, { discoveredBy: "config" });
|
|
436
|
+
} catch {
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Register a namespace whose storage was just resolved by the router. Used as
|
|
442
|
+
* the router's integration hook (`discoveredBy: config`). Storage dir is
|
|
443
|
+
* provided so we do not re-resolve it. Failure-tolerant. Returns whether the
|
|
444
|
+
* registration actually APPENDED (round 6, codex P2 — NEFoX), so the router's
|
|
445
|
+
* resolve-hook dedup only marks a namespace notified when it truly persisted —
|
|
446
|
+
* a dropped append (disabled catalog or rebuild-lock-timeout drop) returns
|
|
447
|
+
* `false` and is retried on the next resolve.
|
|
448
|
+
*/
|
|
449
|
+
async registerResolved(namespace, storageDir) {
|
|
450
|
+
if (!this.enabled) return false;
|
|
451
|
+
return this.register(namespace, { discoveredBy: "config", storageDir });
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Generic register/touch without changing read/write timestamps unless the
|
|
455
|
+
* source implies it. Validates the namespace and resolves a storage dir.
|
|
456
|
+
* Returns whether the touch actually appended.
|
|
457
|
+
*/
|
|
458
|
+
async register(namespace, metadata) {
|
|
459
|
+
return this.touch(namespace, "register", metadata);
|
|
460
|
+
}
|
|
461
|
+
validateNamespace(namespace) {
|
|
462
|
+
const ns = normalizeNamespaceIdentity(namespace);
|
|
463
|
+
if (ns.length === 0) throw new Error("empty namespace");
|
|
464
|
+
if (ns !== this.defaultNamespaceIdentity && !isSafeRouteNamespace(ns)) {
|
|
465
|
+
throw new Error(`unsafe namespace: ${ns}`);
|
|
466
|
+
}
|
|
467
|
+
return ns;
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Resolve the on-disk storage dir for a namespace WITHOUT trusting caller
|
|
471
|
+
* input. The default namespace may use the legacy memoryDir root; everything
|
|
472
|
+
* else lives under `<memoryDir>/namespaces/<token>`. Containment is enforced
|
|
473
|
+
* by rejecting separators/parent-refs in the token.
|
|
474
|
+
*/
|
|
475
|
+
resolveStorageDir(namespace) {
|
|
476
|
+
if (normalizeNamespaceIdentity(namespace) === this.defaultNamespaceIdentity) {
|
|
477
|
+
return this.memoryDir;
|
|
478
|
+
}
|
|
479
|
+
const token = namespaceIdentityToken(namespace);
|
|
480
|
+
return this.namespaceTokenDir(token);
|
|
481
|
+
}
|
|
482
|
+
namespaceTokenDir(token) {
|
|
483
|
+
if (token.length === 0 || token.includes("/") || token.includes("\\") || token.includes("..") || path.isAbsolute(token)) {
|
|
484
|
+
throw new Error(`unsafe namespace token: ${token}`);
|
|
485
|
+
}
|
|
486
|
+
return path.join(this.memoryDir, "namespaces", token);
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Whether a candidate storage dir is LEXICALLY contained: it is either the
|
|
490
|
+
* legacy default root (`memoryDir`) or a strict descendant of
|
|
491
|
+
* `<memoryDir>/namespaces/`. The router legitimately resolves a namespace to
|
|
492
|
+
* EITHER the tokenized dir or a legacy raw-name dir under `namespaces/`, so we
|
|
493
|
+
* accept any contained child rather than a single exact token path. This is a
|
|
494
|
+
* pure string check — symlink escape is checked separately via realpath.
|
|
495
|
+
*/
|
|
496
|
+
isLexicallyContained(candidate) {
|
|
497
|
+
const resolved = path.resolve(candidate);
|
|
498
|
+
if (resolved === path.resolve(this.memoryDir)) return true;
|
|
499
|
+
const nsBase = path.resolve(path.join(this.memoryDir, "namespaces"));
|
|
500
|
+
const rel = path.relative(nsBase, resolved);
|
|
501
|
+
return rel.length > 0 && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Whether a candidate storage dir satisfies the catalog containment contract,
|
|
505
|
+
* including SYMLINK-escape rejection (round 5, codex P2). A lexically-contained
|
|
506
|
+
* path that is actually a symlink to an outside directory would let maintenance
|
|
507
|
+
* or QMD follow it outside `memoryDir`. We mirror `rebuildFromDisk`'s posture:
|
|
508
|
+
* the path must be lexically contained AND, if it exists on disk, neither the
|
|
509
|
+
* path itself a symlink nor its realpath escaping the memory root. Non-existent
|
|
510
|
+
* paths pass the realpath stage (nothing to follow yet) but still must be
|
|
511
|
+
* lexically contained.
|
|
512
|
+
*/
|
|
513
|
+
async isContainedStorageDir(candidate) {
|
|
514
|
+
if (!this.isLexicallyContained(candidate)) return false;
|
|
515
|
+
if (path.resolve(candidate) === path.resolve(this.memoryDir)) return true;
|
|
516
|
+
let memoryReal;
|
|
517
|
+
try {
|
|
518
|
+
memoryReal = await realpath(this.memoryDir);
|
|
519
|
+
} catch {
|
|
520
|
+
memoryReal = path.resolve(this.memoryDir);
|
|
521
|
+
}
|
|
522
|
+
if (await this.hasSymlinkedAncestor(candidate)) return false;
|
|
523
|
+
try {
|
|
524
|
+
const stat3 = await lstat(candidate);
|
|
525
|
+
if (stat3.isSymbolicLink()) return false;
|
|
526
|
+
if (!stat3.isDirectory()) return false;
|
|
527
|
+
} catch {
|
|
528
|
+
return this.isNearestExistingAncestorContained(candidate, memoryReal);
|
|
529
|
+
}
|
|
530
|
+
try {
|
|
531
|
+
const real = await realpath(candidate);
|
|
532
|
+
return isPathInside(memoryReal, real);
|
|
533
|
+
} catch {
|
|
534
|
+
return false;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Reject a candidate whose path crosses a SYMLINKED ancestor strictly between
|
|
539
|
+
* memoryDir and the leaf (codex NVuq5). `realpath`-based containment accepts a
|
|
540
|
+
* symlinked `<memoryDir>/namespaces` that currently resolves back inside
|
|
541
|
+
* memoryDir, but the disk scanner rejects such a root and a later retarget would
|
|
542
|
+
* escape the memory tree — so refuse it here too. The leaf itself is
|
|
543
|
+
* symlink-checked by the caller; this walks only the intermediate ancestors.
|
|
544
|
+
*/
|
|
545
|
+
async hasSymlinkedAncestor(candidate) {
|
|
546
|
+
const stopAt = path.resolve(this.memoryDir);
|
|
547
|
+
let dir = path.dirname(path.resolve(candidate));
|
|
548
|
+
const root = path.parse(dir).root;
|
|
549
|
+
while (dir !== stopAt && dir !== root && dir !== path.dirname(dir)) {
|
|
550
|
+
try {
|
|
551
|
+
if ((await lstat(dir)).isSymbolicLink()) return true;
|
|
552
|
+
} catch {
|
|
553
|
+
}
|
|
554
|
+
dir = path.dirname(dir);
|
|
555
|
+
}
|
|
556
|
+
return false;
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Walk up from a not-yet-existing candidate to the nearest ancestor that exists
|
|
560
|
+
* on disk and verify its realpath stays inside `memoryReal` (round 6, codex P2
|
|
561
|
+
* — NDo79). Rejects a non-existent leaf whose existing parent chain escapes
|
|
562
|
+
* memoryDir via a symlink. Stops at memoryDir's resolved root.
|
|
563
|
+
*
|
|
564
|
+
* The nearest existing ancestor must also be a DIRECTORY (NHIdt, codex P2): if
|
|
565
|
+
* an existing parent such as `<memoryDir>/namespaces` is a regular FILE (or
|
|
566
|
+
* socket/fifo), `realpath(parent)` still succeeds and resolves inside memoryDir,
|
|
567
|
+
* so a containment-only check would ACCEPT a leaf that can never be created — you
|
|
568
|
+
* cannot mkdir a child under a file. We `lstat` the nearest existing ancestor and
|
|
569
|
+
* reject when it is not a directory, mirroring the leaf non-directory rejection
|
|
570
|
+
* (NF21i) and the disk scan, so every containment consumer agrees.
|
|
571
|
+
*/
|
|
572
|
+
async isNearestExistingAncestorContained(candidate, memoryReal) {
|
|
573
|
+
let dir = path.resolve(candidate);
|
|
574
|
+
const root = path.parse(dir).root;
|
|
575
|
+
for (; ; ) {
|
|
576
|
+
const parent = path.dirname(dir);
|
|
577
|
+
if (parent === dir || dir === root) return false;
|
|
578
|
+
let real;
|
|
579
|
+
try {
|
|
580
|
+
real = await realpath(parent);
|
|
581
|
+
} catch {
|
|
582
|
+
dir = parent;
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
if (!(isPathInside(memoryReal, real) || real === memoryReal)) return false;
|
|
586
|
+
try {
|
|
587
|
+
const stat3 = await lstat(real);
|
|
588
|
+
return stat3.isDirectory();
|
|
589
|
+
} catch {
|
|
590
|
+
return false;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Resolve the storage dir to persist for a touch, validating any caller-
|
|
596
|
+
* provided `metadata.storageDir` against the catalog containment contract
|
|
597
|
+
* (round 4 + round 5, codex P2). `markWrite`/`registerResolved` accept an
|
|
598
|
+
* explicit storageDir, but persisting it verbatim would let a bad hook or
|
|
599
|
+
* external consumer write an arbitrary path — including one outside `memoryDir`
|
|
600
|
+
* or a symlink that escapes it — into the catalog, handing maintenance/QMD an
|
|
601
|
+
* unsafe root. We accept an explicit (or previously-stored) dir ONLY when it
|
|
602
|
+
* stays contained under memoryDir (lexically AND via realpath); otherwise we
|
|
603
|
+
* drop it and fall back to the trusted resolved dir.
|
|
604
|
+
*/
|
|
605
|
+
async resolveTouchStorageDir(namespace, explicit, existingDir) {
|
|
606
|
+
if (explicit !== void 0 && await this.isContainedStorageDir(explicit) && await this.isStorageDirForNamespace(namespace, explicit)) {
|
|
607
|
+
return explicit;
|
|
608
|
+
}
|
|
609
|
+
if (existingDir !== void 0 && await this.isContainedStorageDir(existingDir) && await this.isStorageDirForNamespace(namespace, existingDir)) {
|
|
610
|
+
return existingDir;
|
|
611
|
+
}
|
|
612
|
+
return this.resolveSafeStorageDir(namespace);
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* Whether `candidate` is a legitimate storage root FOR `namespace` (round 6,
|
|
616
|
+
* codex P2 — NDATT). Accepts the namespace's router-resolved root, its canonical
|
|
617
|
+
* lexical tokenized dir, and (for the default namespace only) memoryDir. This
|
|
618
|
+
* prevents a contained-but-CROSS-NAMESPACE path — another namespace's tree, or
|
|
619
|
+
* memoryDir for a non-default namespace — from being persisted as this
|
|
620
|
+
* namespace's root. Compared on resolved (absolute) paths.
|
|
621
|
+
*/
|
|
622
|
+
async isStorageDirForNamespace(namespace, candidate) {
|
|
623
|
+
const resolvedCandidate = path.resolve(candidate);
|
|
624
|
+
const valid = /* @__PURE__ */ new Set();
|
|
625
|
+
try {
|
|
626
|
+
valid.add(path.resolve(this.namespaceTokenDir(namespaceIdentityToken(namespace))));
|
|
627
|
+
} catch {
|
|
628
|
+
}
|
|
629
|
+
try {
|
|
630
|
+
valid.add(path.resolve(this.namespaceTokenDir(namespace)));
|
|
631
|
+
} catch {
|
|
632
|
+
}
|
|
633
|
+
try {
|
|
634
|
+
valid.add(path.resolve(await resolveNamespaceStorageRoot(this.config, namespace)));
|
|
635
|
+
} catch {
|
|
636
|
+
}
|
|
637
|
+
if (normalizeNamespaceIdentity(namespace) === this.defaultNamespaceIdentity) {
|
|
638
|
+
valid.add(path.resolve(this.memoryDir));
|
|
639
|
+
try {
|
|
640
|
+
valid.add(path.resolve(await resolveDefaultNamespaceRoot(this.config)));
|
|
641
|
+
} catch {
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
return valid.has(resolvedCandidate);
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Resolve the canonical storage dir for a namespace as the LIVE ROUTER would,
|
|
648
|
+
* but NEVER return a path that escapes the memory root.
|
|
649
|
+
*
|
|
650
|
+
* Router alignment (round 4, cursor Medium): a read/register touch with no
|
|
651
|
+
* explicit storageDir previously used the lexical `resolveStorageDir`, which
|
|
652
|
+
* always picks `<memoryDir>/namespaces/<token>` (or `memoryDir` for the
|
|
653
|
+
* default). That diverges from `NamespaceStorageRouter`, which can route to a
|
|
654
|
+
* legacy raw-name dir or a migrated default root — so a recall touch could
|
|
655
|
+
* record a contained-but-WRONG root that maintenance/rebuild then targets. We
|
|
656
|
+
* now delegate to the shared `resolveNamespaceStorageRoot` (the very helper the
|
|
657
|
+
* router uses) so the catalog records the same on-disk root the router serves.
|
|
658
|
+
*
|
|
659
|
+
* Containment (round 5, codex P2): the resolved path can still be a symlink
|
|
660
|
+
* escaping memoryDir, so we run the full (lexical + realpath) containment
|
|
661
|
+
* contract. When it FAILS we fall back to a NAMESPACE-SPECIFIC safe root, NOT
|
|
662
|
+
* a blanket `memoryDir`. Recording `memoryDir` for a non-default namespace
|
|
663
|
+
* would point enumeration/maintenance at the DEFAULT namespace's tree (round 5,
|
|
664
|
+
* cursor/codex Medium/P2) — a cross-namespace fanout error. The correct safe
|
|
665
|
+
* root is the namespace's own lexical tokenized dir
|
|
666
|
+
* (`<memoryDir>/namespaces/<token>`), which is always contained and is that
|
|
667
|
+
* namespace's canonical location (we record the lexical PATH as metadata; we do
|
|
668
|
+
* not follow the escaping symlink). Only the default namespace — or a token so
|
|
669
|
+
* unsafe even the lexical dir cannot be built — falls back to `memoryDir`.
|
|
670
|
+
*/
|
|
671
|
+
async resolveSafeStorageDir(namespace) {
|
|
672
|
+
let resolved;
|
|
673
|
+
try {
|
|
674
|
+
resolved = await resolveNamespaceStorageRoot(this.config, namespace);
|
|
675
|
+
} catch {
|
|
676
|
+
return this.safeFallbackStorageDir(namespace);
|
|
677
|
+
}
|
|
678
|
+
if (await this.isContainedStorageDir(resolved)) return resolved;
|
|
679
|
+
return this.safeFallbackStorageDir(namespace);
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* The namespace-specific contained fallback root, used when the router-resolved
|
|
683
|
+
* root fails containment (round 5, cursor/codex Medium/P2).
|
|
684
|
+
*
|
|
685
|
+
* Preference order:
|
|
686
|
+
* 1. The namespace's OWN lexical tokenized dir (`namespaces/<token>`) — so a
|
|
687
|
+
* non-default namespace is NOT pointed at the DEFAULT namespace's `memoryDir`
|
|
688
|
+
* tree (which would misdirect maintenance fanout). Returned only when the
|
|
689
|
+
* token dir itself stays CONTAINED (it is not a symlink, and its realpath
|
|
690
|
+
* does not escape memoryDir — e.g. via a symlinked `namespaces/` parent).
|
|
691
|
+
* 2. `memoryDir` as a LAST resort — for the default namespace, an unsafe token
|
|
692
|
+
* that cannot build a contained path, OR the irreparable case where the
|
|
693
|
+
* token dir's realpath escapes the root (so even its lexical path resolves
|
|
694
|
+
* outside). NF21m note (codex P2): we deliberately do NOT record the lexical
|
|
695
|
+
* token dir in that irreparable case — its realpath escapes memoryDir, and
|
|
696
|
+
* the NDo79 contract REQUIRES that an escaping path is never persisted (a
|
|
697
|
+
* later mkdir/maintenance/QMD op would follow it outside the root). Since no
|
|
698
|
+
* contained namespace-specific path exists, containment wins: `memoryDir` is
|
|
699
|
+
* the only safe root left. A namespace whose token dir's realpath escapes is
|
|
700
|
+
* an irreparable on-disk state; recording the contained default root is
|
|
701
|
+
* strictly safer than persisting an escaping one. The common case where the
|
|
702
|
+
* token dir IS contained is handled by branch 1, so a healthy non-default
|
|
703
|
+
* namespace never reaches `memoryDir`.
|
|
704
|
+
*/
|
|
705
|
+
async safeFallbackStorageDir(namespace) {
|
|
706
|
+
if (normalizeNamespaceIdentity(namespace) === this.defaultNamespaceIdentity) return this.memoryDir;
|
|
707
|
+
let tokenDir;
|
|
708
|
+
try {
|
|
709
|
+
tokenDir = this.namespaceTokenDir(namespaceIdentityToken(namespace));
|
|
710
|
+
} catch {
|
|
711
|
+
return this.memoryDir;
|
|
712
|
+
}
|
|
713
|
+
if (await this.isContainedStorageDir(tokenDir)) return tokenDir;
|
|
714
|
+
return this.memoryDir;
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* Re-check, NOW, whether a namespace's storage root currently EXISTS on disk
|
|
718
|
+
* with the SAME safety the directory scan uses (NFJV8, codex P2).
|
|
719
|
+
*
|
|
720
|
+
* The rebuild's final re-merge runs under the held lock and folds the freshly
|
|
721
|
+
* re-read log (`latest`) into the scanned `rebuilt` set. A namespace present in
|
|
722
|
+
* `latest` (a live touch row) but ABSENT from `rebuilt` is normally PURGED as
|
|
723
|
+
* deleted (the NATqU "disk scan is authoritative" rule). But there is a TOCTOU
|
|
724
|
+
* window: a dynamic namespace can be CREATED on disk AFTER `rebuildFromDisk()`
|
|
725
|
+
* already enumerated `namespaces/` but BEFORE this re-merge. The scan snapshot
|
|
726
|
+
* missed its new root, yet a gateway `markWrite` already appended a row for it.
|
|
727
|
+
* Blindly purging that row would rewrite the catalog WITHOUT a live namespace
|
|
728
|
+
* that now has data on disk, so `writtenSince`/maintenance/QMD consumers miss
|
|
729
|
+
* it until another touch or rebuild.
|
|
730
|
+
*
|
|
731
|
+
* So before purging, we re-resolve the namespace's safe storage root (the same
|
|
732
|
+
* router-aligned, containment-checked path the scan would have catalogued) and
|
|
733
|
+
* confirm it is a real, contained, non-symlink directory that actually holds
|
|
734
|
+
* memory data RIGHT NOW. If so the namespace was created-after-scan and is LIVE
|
|
735
|
+
* — KEEP its row. This is the precise inverse of NATqU and does NOT reintroduce
|
|
736
|
+
* it: a touch on a REMOVED root re-checks as ABSENT (no data on disk) and is
|
|
737
|
+
* still purged; only a root that EXISTS on a fresh re-check is kept.
|
|
738
|
+
*
|
|
739
|
+
* Mirrors the per-entry scan checks (symlink rejection + realpath containment +
|
|
740
|
+
* `hasMemoryData`) so a symlinked/escaping root is never resurrected.
|
|
741
|
+
*/
|
|
742
|
+
async liveStorageRootExistsForRebuild(namespace, memoryReal) {
|
|
743
|
+
let root;
|
|
744
|
+
try {
|
|
745
|
+
root = await this.resolveSafeStorageDir(namespace);
|
|
746
|
+
} catch {
|
|
747
|
+
return false;
|
|
748
|
+
}
|
|
749
|
+
if (normalizeNamespaceIdentity(namespace) !== this.defaultNamespaceIdentity && path.resolve(root) === path.resolve(this.memoryDir)) {
|
|
750
|
+
return false;
|
|
751
|
+
}
|
|
752
|
+
let stat3;
|
|
753
|
+
try {
|
|
754
|
+
stat3 = await lstat(root);
|
|
755
|
+
} catch {
|
|
756
|
+
return false;
|
|
757
|
+
}
|
|
758
|
+
if (stat3.isSymbolicLink()) return false;
|
|
759
|
+
if (!stat3.isDirectory()) return false;
|
|
760
|
+
try {
|
|
761
|
+
const real = await realpath(root);
|
|
762
|
+
if (memoryReal && !isPathInside(memoryReal, real)) return false;
|
|
763
|
+
} catch {
|
|
764
|
+
return false;
|
|
765
|
+
}
|
|
766
|
+
return hasMemoryData(root);
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* Record a namespace touch. Returns whether the touch actually APPENDED to the
|
|
770
|
+
* log (round 6, codex P2 — NEFoX): a disabled catalog or a dropped append (the
|
|
771
|
+
* NAUf7 rebuild-lock-timeout drop) returns `false`, so callers (e.g. the router
|
|
772
|
+
* resolve-hook dedup) can avoid marking a dropped registration as completed and
|
|
773
|
+
* suppressing its retry.
|
|
774
|
+
*/
|
|
775
|
+
async touch(namespace, kind, metadata, jobName) {
|
|
776
|
+
if (!this.enabled) return false;
|
|
777
|
+
const ns = this.validateNamespace(namespace);
|
|
778
|
+
const nowIso = (metadata?.at ?? /* @__PURE__ */ new Date()).toISOString();
|
|
779
|
+
return this.queueCritical(
|
|
780
|
+
async () => this.withHeldCatalogLock(async (acquired) => {
|
|
781
|
+
if (!acquired) return false;
|
|
782
|
+
if (this.onTouchCriticalSectionForTest) {
|
|
783
|
+
await this.onTouchCriticalSectionForTest();
|
|
784
|
+
}
|
|
785
|
+
const records = await this.loadCompacted();
|
|
786
|
+
const existing = records.get(ns);
|
|
787
|
+
const storageDir = await this.resolveTouchStorageDir(
|
|
788
|
+
ns,
|
|
789
|
+
metadata?.storageDir,
|
|
790
|
+
existing?.storageDir
|
|
791
|
+
);
|
|
792
|
+
const record = existing ? { ...existing } : {
|
|
793
|
+
namespace: ns,
|
|
794
|
+
identityToken: namespaceIdentityToken(ns),
|
|
795
|
+
kind: metadata?.kind ?? inferKind(ns, this.config),
|
|
796
|
+
createdAt: nowIso,
|
|
797
|
+
storageDir,
|
|
798
|
+
discoveredBy: metadata?.discoveredBy ?? (kind === "register" ? "config" : kind === "maintenance" ? "scan" : kind)
|
|
799
|
+
};
|
|
800
|
+
record.storageDir = storageDir;
|
|
801
|
+
if (metadata?.kind) record.kind = metadata.kind;
|
|
802
|
+
if (metadata?.principal !== void 0) record.principal = metadata.principal;
|
|
803
|
+
if (metadata?.projectId !== void 0) record.projectId = metadata.projectId;
|
|
804
|
+
if (metadata?.branch !== void 0) record.branch = metadata.branch;
|
|
805
|
+
if (metadata?.parentNamespace !== void 0)
|
|
806
|
+
record.parentNamespace = metadata.parentNamespace;
|
|
807
|
+
if (kind === "write" && existing && record.discoveredBy === "config") {
|
|
808
|
+
record.discoveredBy = "write";
|
|
809
|
+
}
|
|
810
|
+
if (kind === "read") record.lastReadAt = nowIso;
|
|
811
|
+
if (kind === "write") record.lastWriteAt = nowIso;
|
|
812
|
+
if (kind === "maintenance" && jobName) {
|
|
813
|
+
record.lastMaintenanceAt = { ...record.lastMaintenanceAt ?? {}, [jobName]: nowIso };
|
|
814
|
+
}
|
|
815
|
+
await this.appendUnchained(record);
|
|
816
|
+
return true;
|
|
817
|
+
})
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
// ── Rebuild from disk ────────────────────────────────────────────────────
|
|
821
|
+
async rebuildFromDisk(options) {
|
|
822
|
+
const dryRun = options?.dryRun === true;
|
|
823
|
+
if (!this.enabled) {
|
|
824
|
+
return { dryRun, records: [], skipped: [], applied: false };
|
|
825
|
+
}
|
|
826
|
+
if (dryRun) {
|
|
827
|
+
return this.queueCritical(async () => this.rebuildInsideChain(dryRun, false));
|
|
828
|
+
}
|
|
829
|
+
return this.queueCritical(async () => this.rebuildInsideChain(dryRun, true));
|
|
830
|
+
}
|
|
831
|
+
/**
|
|
832
|
+
* Body of `rebuildFromDisk`, run inside a single `queueCritical` turn. MUST
|
|
833
|
+
* only be invoked from within the serialized chain so the load and the
|
|
834
|
+
* rewrite are atomic with respect to concurrent touches (in-process).
|
|
835
|
+
*
|
|
836
|
+
* `wantMutate` is true for an `--apply` (the caller intends to rewrite). The
|
|
837
|
+
* cross-process file lock is acquired LATE — only around the final
|
|
838
|
+
* load→merge→rename window (NFgCT, codex P2) — never across the disk scan, so a
|
|
839
|
+
* long scan does not force concurrent gateway touches to wait (and drop their
|
|
840
|
+
* append). Whether the rewrite actually happened is reported via the result's
|
|
841
|
+
* `applied`: true only when `wantMutate` AND the lock was acquired.
|
|
842
|
+
*/
|
|
843
|
+
async rebuildInsideChain(dryRun, wantMutate) {
|
|
844
|
+
const existing = await this.loadCompacted();
|
|
845
|
+
const skipped = [];
|
|
846
|
+
const rebuilt = /* @__PURE__ */ new Map();
|
|
847
|
+
const nowIso = (/* @__PURE__ */ new Date()).toISOString();
|
|
848
|
+
let memoryReal = null;
|
|
849
|
+
try {
|
|
850
|
+
memoryReal = await realpath(this.memoryDir);
|
|
851
|
+
} catch {
|
|
852
|
+
memoryReal = this.memoryDir;
|
|
853
|
+
}
|
|
854
|
+
const defaultNs = normalizeNamespaceIdentity(this.config.defaultNamespace);
|
|
855
|
+
const configured = new Set(
|
|
856
|
+
[
|
|
857
|
+
this.config.defaultNamespace,
|
|
858
|
+
this.config.sharedNamespace,
|
|
859
|
+
...this.config.namespacePolicies.map((p) => p.name)
|
|
860
|
+
].map((n) => normalizeNamespaceIdentity(n)).filter((n) => n.length > 0)
|
|
861
|
+
);
|
|
862
|
+
const resolvedDefaultRoot = await resolveDefaultNamespaceRoot(this.config);
|
|
863
|
+
const defaultStorageDir = await this.isContainedStorageDir(resolvedDefaultRoot) ? resolvedDefaultRoot : this.memoryDir;
|
|
864
|
+
const legacyDefaultHasData = defaultStorageDir === this.memoryDir;
|
|
865
|
+
for (const ns of configured) {
|
|
866
|
+
if (!ns) continue;
|
|
867
|
+
if (ns !== defaultNs && !isSafeRouteNamespace(ns)) {
|
|
868
|
+
let token;
|
|
869
|
+
try {
|
|
870
|
+
token = namespaceIdentityToken(ns);
|
|
871
|
+
} catch {
|
|
872
|
+
token = ns;
|
|
873
|
+
}
|
|
874
|
+
skipped.push({ token, reason: "unsafe", detail: ns });
|
|
875
|
+
continue;
|
|
876
|
+
}
|
|
877
|
+
let storageDir;
|
|
878
|
+
if (ns === defaultNs) {
|
|
879
|
+
storageDir = defaultStorageDir;
|
|
880
|
+
} else {
|
|
881
|
+
try {
|
|
882
|
+
storageDir = await resolveNamespaceStorageRoot(this.config, ns);
|
|
883
|
+
} catch {
|
|
884
|
+
storageDir = this.namespaceTokenDir(namespaceIdentityToken(ns));
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
if (!await this.isContainedStorageDir(storageDir)) {
|
|
888
|
+
if (ns === defaultNs) {
|
|
889
|
+
storageDir = this.memoryDir;
|
|
890
|
+
} else {
|
|
891
|
+
skipped.push({ token: namespaceIdentityToken(ns), reason: "escape", detail: storageDir });
|
|
892
|
+
continue;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
rebuilt.set(
|
|
896
|
+
ns,
|
|
897
|
+
this.mergeForRebuild(existing.get(ns), {
|
|
898
|
+
namespace: ns,
|
|
899
|
+
identityToken: namespaceIdentityToken(ns),
|
|
900
|
+
kind: inferKind(ns, this.config),
|
|
901
|
+
createdAt: existing.get(ns)?.createdAt ?? nowIso,
|
|
902
|
+
storageDir,
|
|
903
|
+
discoveredBy: "config"
|
|
904
|
+
})
|
|
905
|
+
);
|
|
906
|
+
}
|
|
907
|
+
const namespacesDir = path.join(this.memoryDir, "namespaces");
|
|
908
|
+
let entries = [];
|
|
909
|
+
let namespacesDirSafe = true;
|
|
910
|
+
try {
|
|
911
|
+
const rootStat = await lstat(namespacesDir);
|
|
912
|
+
if (rootStat.isSymbolicLink()) {
|
|
913
|
+
namespacesDirSafe = false;
|
|
914
|
+
} else {
|
|
915
|
+
const realNamespacesDir = await realpath(namespacesDir);
|
|
916
|
+
if (memoryReal && !isPathInside(memoryReal, realNamespacesDir)) {
|
|
917
|
+
namespacesDirSafe = false;
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
} catch {
|
|
921
|
+
namespacesDirSafe = true;
|
|
922
|
+
}
|
|
923
|
+
if (!namespacesDirSafe) {
|
|
924
|
+
skipped.push({ token: "namespaces", reason: "symlink", detail: namespacesDir });
|
|
925
|
+
} else {
|
|
926
|
+
try {
|
|
927
|
+
entries = await readdir(namespacesDir, { withFileTypes: true });
|
|
928
|
+
} catch {
|
|
929
|
+
entries = [];
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
const scannedFromTokenized = /* @__PURE__ */ new Set();
|
|
933
|
+
for (const entry of entries) {
|
|
934
|
+
const token = entry.name;
|
|
935
|
+
const fullPath = path.join(namespacesDir, token);
|
|
936
|
+
let stat3;
|
|
937
|
+
try {
|
|
938
|
+
stat3 = await lstat(fullPath);
|
|
939
|
+
} catch (err) {
|
|
940
|
+
skipped.push({ token, reason: "error", detail: err instanceof Error ? err.message : String(err) });
|
|
941
|
+
continue;
|
|
942
|
+
}
|
|
943
|
+
if (stat3.isSymbolicLink()) {
|
|
944
|
+
skipped.push({ token, reason: "symlink", detail: fullPath });
|
|
945
|
+
continue;
|
|
946
|
+
}
|
|
947
|
+
if (!stat3.isDirectory()) continue;
|
|
948
|
+
try {
|
|
949
|
+
const real = await realpath(fullPath);
|
|
950
|
+
if (memoryReal && !isPathInside(memoryReal, real)) {
|
|
951
|
+
skipped.push({ token, reason: "escape", detail: real });
|
|
952
|
+
continue;
|
|
953
|
+
}
|
|
954
|
+
} catch (err) {
|
|
955
|
+
skipped.push({ token, reason: "error", detail: err instanceof Error ? err.message : String(err) });
|
|
956
|
+
continue;
|
|
957
|
+
}
|
|
958
|
+
const literalRecord = existing.get(token);
|
|
959
|
+
const literalOwnsRoot = configured.has(token) || literalRecord !== void 0 && path.resolve(literalRecord.storageDir) === path.resolve(fullPath);
|
|
960
|
+
const tokenDecoded = literalOwnsRoot ? null : namespaceIdentityFromToken(token);
|
|
961
|
+
const rawDecoded = tokenDecoded && tokenDecoded.length > 0 ? tokenDecoded : token;
|
|
962
|
+
const decoded = normalizeNamespaceIdentity(rawDecoded);
|
|
963
|
+
if (decoded.length === 0 || rawDecoded !== decoded) {
|
|
964
|
+
skipped.push({ token, reason: "unsafe", detail: rawDecoded });
|
|
965
|
+
continue;
|
|
966
|
+
}
|
|
967
|
+
if (decoded !== defaultNs && !isSafeRouteNamespace(decoded)) {
|
|
968
|
+
skipped.push({ token, reason: "unsafe", detail: decoded });
|
|
969
|
+
continue;
|
|
970
|
+
}
|
|
971
|
+
const memoryData = await inspectMemoryDataRoot(fullPath);
|
|
972
|
+
if (memoryData.invalidMarker) {
|
|
973
|
+
skipped.push({
|
|
974
|
+
token,
|
|
975
|
+
reason: "unsafe",
|
|
976
|
+
detail: `invalid memory marker: ${memoryData.invalidMarker}`
|
|
977
|
+
});
|
|
978
|
+
continue;
|
|
979
|
+
}
|
|
980
|
+
if (!memoryData.hasData) continue;
|
|
981
|
+
if (decoded === defaultNs) {
|
|
982
|
+
const def = rebuilt.get(defaultNs);
|
|
983
|
+
if (def) {
|
|
984
|
+
def.storageDir = defaultStorageDir;
|
|
985
|
+
def.kind = "default";
|
|
986
|
+
}
|
|
987
|
+
continue;
|
|
988
|
+
}
|
|
989
|
+
const isTokenizedEntry = token === namespaceIdentityToken(decoded);
|
|
990
|
+
if (rebuilt.has(decoded) && scannedFromTokenized.has(decoded) && !isTokenizedEntry) {
|
|
991
|
+
continue;
|
|
992
|
+
}
|
|
993
|
+
if (isTokenizedEntry) scannedFromTokenized.add(decoded);
|
|
994
|
+
const prior = existing.get(decoded);
|
|
995
|
+
rebuilt.set(
|
|
996
|
+
decoded,
|
|
997
|
+
this.mergeForRebuild(prior, {
|
|
998
|
+
namespace: decoded,
|
|
999
|
+
identityToken: namespaceIdentityToken(decoded),
|
|
1000
|
+
kind: inferKind(decoded, this.config),
|
|
1001
|
+
createdAt: prior?.createdAt ?? nowIso,
|
|
1002
|
+
storageDir: fullPath,
|
|
1003
|
+
// Configured-and-present namespaces keep config provenance; purely
|
|
1004
|
+
// discovered ones are scan.
|
|
1005
|
+
discoveredBy: configured.has(decoded) ? "config" : prior?.discoveredBy ?? "scan"
|
|
1006
|
+
})
|
|
1007
|
+
);
|
|
1008
|
+
}
|
|
1009
|
+
if (legacyDefaultHasData && defaultStorageDir === this.memoryDir) {
|
|
1010
|
+
const def = rebuilt.get(defaultNs);
|
|
1011
|
+
if (def) def.kind = "default";
|
|
1012
|
+
}
|
|
1013
|
+
if (!wantMutate) {
|
|
1014
|
+
return this.finishRebuild(rebuilt, skipped, dryRun, false, memoryReal, nowIso);
|
|
1015
|
+
}
|
|
1016
|
+
if (this.onRebuildAfterScanForTest) {
|
|
1017
|
+
await this.onRebuildAfterScanForTest();
|
|
1018
|
+
}
|
|
1019
|
+
return this.withHeldCatalogLock(
|
|
1020
|
+
(acquired) => this.finishRebuild(rebuilt, skipped, dryRun, acquired, memoryReal, nowIso)
|
|
1021
|
+
);
|
|
1022
|
+
}
|
|
1023
|
+
/**
|
|
1024
|
+
* Final load→merge→rename window of a rebuild, factored out so the caller can
|
|
1025
|
+
* run it WITHIN the cross-process file lock (NFgCT, codex P2) without holding
|
|
1026
|
+
* that lock across the preceding disk scan. Re-reads the latest on-disk state,
|
|
1027
|
+
* folds concurrent touches, then (when `canMutate`) atomically rewrites the log.
|
|
1028
|
+
*
|
|
1029
|
+
* `canMutate` records that the cross-process lock was actually held. The
|
|
1030
|
+
* re-merge + rewrite run only when it is true — a dry-run, or an unlocked apply
|
|
1031
|
+
* (lock-acquisition timeout), computes records but does NOT rename, so it can
|
|
1032
|
+
* never clobber a concurrent lock holder's window. `applied` mirrors `canMutate`.
|
|
1033
|
+
*/
|
|
1034
|
+
async finishRebuild(rebuilt, skipped, dryRun, canMutate, memoryReal, nowIso) {
|
|
1035
|
+
if (canMutate) {
|
|
1036
|
+
const latest = await this.loadCompacted();
|
|
1037
|
+
for (const [ns, fresh] of latest) {
|
|
1038
|
+
const current = rebuilt.get(ns);
|
|
1039
|
+
if (!current) {
|
|
1040
|
+
if (ns !== this.defaultNamespaceIdentity && !isSafeRouteNamespace(ns)) {
|
|
1041
|
+
continue;
|
|
1042
|
+
}
|
|
1043
|
+
if (await this.liveStorageRootExistsForRebuild(ns, memoryReal)) {
|
|
1044
|
+
const safeDir = await this.resolveSafeStorageDir(ns);
|
|
1045
|
+
const resolvedSafe = path.resolve(safeDir);
|
|
1046
|
+
let owningNamespace = null;
|
|
1047
|
+
for (const [otherNs, otherRec] of rebuilt) {
|
|
1048
|
+
if (otherNs !== ns && path.resolve(otherRec.storageDir) === resolvedSafe) {
|
|
1049
|
+
owningNamespace = otherNs;
|
|
1050
|
+
break;
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
if (owningNamespace) {
|
|
1054
|
+
const owner = rebuilt.get(owningNamespace);
|
|
1055
|
+
if (owner) rebuilt.set(owningNamespace, mergeNewerTouchFields(owner, fresh));
|
|
1056
|
+
continue;
|
|
1057
|
+
}
|
|
1058
|
+
rebuilt.set(ns, {
|
|
1059
|
+
...fresh,
|
|
1060
|
+
storageDir: safeDir,
|
|
1061
|
+
identityToken: namespaceIdentityToken(ns),
|
|
1062
|
+
kind: fresh.kind ?? inferKind(ns, this.config),
|
|
1063
|
+
createdAt: fresh.createdAt ?? nowIso
|
|
1064
|
+
});
|
|
1065
|
+
continue;
|
|
1066
|
+
}
|
|
1067
|
+
continue;
|
|
1068
|
+
}
|
|
1069
|
+
rebuilt.set(ns, mergeNewerTouchFields(current, fresh));
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
const records = [...rebuilt.values()].sort((a, b) => {
|
|
1073
|
+
const byName = a.namespace.localeCompare(b.namespace);
|
|
1074
|
+
if (byName !== 0) return byName;
|
|
1075
|
+
return a.identityToken.localeCompare(b.identityToken);
|
|
1076
|
+
});
|
|
1077
|
+
if (canMutate) {
|
|
1078
|
+
if (this.onRebuildBeforeRenameForTest) {
|
|
1079
|
+
await this.onRebuildBeforeRenameForTest();
|
|
1080
|
+
}
|
|
1081
|
+
await this.rewriteUnchained(records);
|
|
1082
|
+
}
|
|
1083
|
+
return { dryRun, records, skipped, applied: canMutate };
|
|
1084
|
+
}
|
|
1085
|
+
// ── Cross-process catalog write lock (held mutex) ────────────────────────
|
|
1086
|
+
/**
|
|
1087
|
+
* Run `fn` while HOLDING the shared cross-process advisory lock (round 5, codex
|
|
1088
|
+
* P2; generalized round 7 — NEZkA). This is the SINGLE mutex shared by BOTH the
|
|
1089
|
+
* touch read→merge→append window AND the rebuild final load→merge→rename window,
|
|
1090
|
+
* so a touch and a rebuild in different processes are mutually exclusive over
|
|
1091
|
+
* their respective critical sections — closing the check-then-append gap where a
|
|
1092
|
+
* polled-only touch could append into a rebuild's load→rename window.
|
|
1093
|
+
*
|
|
1094
|
+
* Acquisition is atomic via `open(..., "wx")`. A lock older than
|
|
1095
|
+
* `REBUILD_LOCK_STALE_MS` is treated as a crashed holder and broken. After
|
|
1096
|
+
* `REBUILD_LOCK_MAX_WAIT_MS` of contention we proceed best-effort WITHOUT the
|
|
1097
|
+
* lock rather than block forever. The lock is always released in `finally`.
|
|
1098
|
+
*
|
|
1099
|
+
* IN-PROCESS SAFETY: every caller invokes this from inside (or wrapping) the
|
|
1100
|
+
* per-process `queueCritical` chain, which serializes all catalog mutations in
|
|
1101
|
+
* THIS process. So within one process only one logical holder attempts OS-lock
|
|
1102
|
+
* acquisition at a time — the file lock is never self-contended in-process, and
|
|
1103
|
+
* the lock is acquired and released within a single in-process turn. The file
|
|
1104
|
+
* lock adds only the missing CROSS-process exclusion.
|
|
1105
|
+
*
|
|
1106
|
+
* HEARTBEAT (round 5, cursor/codex Medium/P2): while WE hold the lock a timer
|
|
1107
|
+
* refreshes its mtime every `REBUILD_LOCK_HEARTBEAT_MS`, so a legitimately long
|
|
1108
|
+
* holder (> `REBUILD_LOCK_STALE_MS`) is not treated as a crashed holder and
|
|
1109
|
+
* unlinked by another process — which would let overlapping windows lose
|
|
1110
|
+
* appends. Heartbeat failures are swallowed; the timer is always cleared in
|
|
1111
|
+
* `finally`.
|
|
1112
|
+
*
|
|
1113
|
+
* ACQUISITION RESULT (round 6, codex P2 — NBPmY): `fn` receives whether WE
|
|
1114
|
+
* actually hold the lock. When acquisition TIMED OUT (another holder is active),
|
|
1115
|
+
* a MUTATING rebuild must NOT perform its load/rename window unlocked, and a
|
|
1116
|
+
* touch must NOT append unlocked — both would recreate the lost-append race. The
|
|
1117
|
+
* caller uses `acquired` to run compute-only (rebuild) or DROP the append
|
|
1118
|
+
* (touch) when unlocked.
|
|
1119
|
+
*/
|
|
1120
|
+
async withHeldCatalogLock(fn) {
|
|
1121
|
+
const acquired = await this.acquireRebuildLock();
|
|
1122
|
+
let heartbeat;
|
|
1123
|
+
if (acquired) {
|
|
1124
|
+
heartbeat = setInterval(() => {
|
|
1125
|
+
const now = /* @__PURE__ */ new Date();
|
|
1126
|
+
utimes(this.rebuildLockPath, now, now).catch(() => void 0);
|
|
1127
|
+
}, REBUILD_LOCK_HEARTBEAT_MS);
|
|
1128
|
+
heartbeat.unref?.();
|
|
1129
|
+
}
|
|
1130
|
+
try {
|
|
1131
|
+
return await fn(acquired);
|
|
1132
|
+
} finally {
|
|
1133
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
1134
|
+
if (acquired) {
|
|
1135
|
+
try {
|
|
1136
|
+
if (await this.rebuildLockHeldBySelf()) {
|
|
1137
|
+
await unlink(this.rebuildLockPath);
|
|
1138
|
+
}
|
|
1139
|
+
} catch {
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
/** Try to acquire the rebuild lock; returns true if WE created it. */
|
|
1145
|
+
async acquireRebuildLock() {
|
|
1146
|
+
const deadline = Date.now() + REBUILD_LOCK_MAX_WAIT_MS;
|
|
1147
|
+
await mkdir(this.stateDir, { recursive: true });
|
|
1148
|
+
for (; ; ) {
|
|
1149
|
+
try {
|
|
1150
|
+
const handle = await open(this.rebuildLockPath, "wx");
|
|
1151
|
+
try {
|
|
1152
|
+
await handle.writeFile(
|
|
1153
|
+
`${process.pid} ${this.lockOwnerId} ${(/* @__PURE__ */ new Date()).toISOString()}
|
|
1154
|
+
`,
|
|
1155
|
+
"utf8"
|
|
1156
|
+
);
|
|
1157
|
+
} catch {
|
|
1158
|
+
} finally {
|
|
1159
|
+
await handle.close();
|
|
1160
|
+
}
|
|
1161
|
+
return true;
|
|
1162
|
+
} catch (err) {
|
|
1163
|
+
if (err?.code !== "EEXIST") {
|
|
1164
|
+
return false;
|
|
1165
|
+
}
|
|
1166
|
+
await this.breakStaleRebuildLock();
|
|
1167
|
+
if (Date.now() >= deadline) return false;
|
|
1168
|
+
await new Promise((r) => setTimeout(r, REBUILD_LOCK_POLL_MS));
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
/**
|
|
1173
|
+
* Remove the lock file if its mtime is older than the stale threshold.
|
|
1174
|
+
*
|
|
1175
|
+
* REPLACEMENT-SAFE (NG7Bg, codex P2): a plain `stat` → `unlink` has a TOCTOU
|
|
1176
|
+
* window — two processes can both observe the SAME stale lock; one removes it and
|
|
1177
|
+
* creates a FRESH lock, and the other's later `unlink` then deletes that fresh
|
|
1178
|
+
* holder's ACTIVE lock based on the stale identity it read earlier, leaving the
|
|
1179
|
+
* fresh holder running its critical section with no visible lock and reopening the
|
|
1180
|
+
* lost-update race the mutex prevents. We therefore capture the lock's IDENTITY
|
|
1181
|
+
* (its full content line: `<pid> <owner-uuid> <iso>`) when we judge it stale, then
|
|
1182
|
+
* RE-READ immediately before unlinking and only remove it when the content is
|
|
1183
|
+
* byte-identical AND still stale. A replacement lock has a different owner id /
|
|
1184
|
+
* timestamp, so its content differs and we leave it untouched. We never unlink a
|
|
1185
|
+
* lock whose mtime is now fresh (a heartbeat refreshed it) or whose identity
|
|
1186
|
+
* changed (a replacement was created). This is best-effort: any mismatch/vanish
|
|
1187
|
+
* simply skips the break and the caller polls again.
|
|
1188
|
+
*/
|
|
1189
|
+
async breakStaleRebuildLock() {
|
|
1190
|
+
let staleIdentity;
|
|
1191
|
+
try {
|
|
1192
|
+
const info = await stat(this.rebuildLockPath);
|
|
1193
|
+
if (Date.now() - info.mtimeMs <= REBUILD_LOCK_STALE_MS) {
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
staleIdentity = await readFile(this.rebuildLockPath, "utf8");
|
|
1197
|
+
} catch {
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
if (this.onBeforeBreakStaleUnlinkForTest) {
|
|
1201
|
+
await this.onBeforeBreakStaleUnlinkForTest();
|
|
1202
|
+
}
|
|
1203
|
+
try {
|
|
1204
|
+
const current = await readFile(this.rebuildLockPath, "utf8");
|
|
1205
|
+
if (current !== staleIdentity) return;
|
|
1206
|
+
const recheck = await stat(this.rebuildLockPath);
|
|
1207
|
+
if (Date.now() - recheck.mtimeMs <= REBUILD_LOCK_STALE_MS) return;
|
|
1208
|
+
await unlink(this.rebuildLockPath).catch(() => void 0);
|
|
1209
|
+
} catch {
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
/**
|
|
1213
|
+
* Whether the rebuild lock file was written by THIS instance (round 6, codex
|
|
1214
|
+
* P2 — NBsGP). Matches the per-instance owner id, NOT just `process.pid`: two
|
|
1215
|
+
* NamespaceCatalog instances in the same process share a PID, so a PID-only
|
|
1216
|
+
* check would wrongly treat instance A's lock as self-held by instance B and
|
|
1217
|
+
* let B's touch skip the wait and append into A's rebuild window. Falls back to
|
|
1218
|
+
* the legacy PID-only form for lock files written before owner ids existed.
|
|
1219
|
+
*/
|
|
1220
|
+
async rebuildLockHeldBySelf() {
|
|
1221
|
+
try {
|
|
1222
|
+
const body = await readFile(this.rebuildLockPath, "utf8");
|
|
1223
|
+
const parts = body.trim().split(/\s+/);
|
|
1224
|
+
const pid = Number.parseInt(parts[0] ?? "", 10);
|
|
1225
|
+
const ownerId = parts[1];
|
|
1226
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1227
|
+
if (ownerId && UUID_RE.test(ownerId)) {
|
|
1228
|
+
return ownerId === this.lockOwnerId;
|
|
1229
|
+
}
|
|
1230
|
+
return Number.isFinite(pid) && pid === process.pid;
|
|
1231
|
+
} catch {
|
|
1232
|
+
return false;
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
/**
|
|
1236
|
+
* Merge a prior record's preserved metadata (timestamps, principal hints)
|
|
1237
|
+
* onto a freshly-discovered record. Disk-derived fields (storageDir, kind)
|
|
1238
|
+
* take precedence from the new record.
|
|
1239
|
+
*
|
|
1240
|
+
* PROVENANCE (round 3, cursor Low): `discoveredBy` and `createdAt` are
|
|
1241
|
+
* CREATION-ONLY — identical to the touch path's invariant. A rebuild must NOT
|
|
1242
|
+
* reset a namespace first seen via a `write`/`read` touch back to `config`
|
|
1243
|
+
* just because it is also listed in policies. So when a prior record exists we
|
|
1244
|
+
* carry its `discoveredBy` forward; only brand-new records keep the fresh
|
|
1245
|
+
* (config/scan) provenance.
|
|
1246
|
+
*/
|
|
1247
|
+
mergeForRebuild(prior, fresh) {
|
|
1248
|
+
if (!prior) return fresh;
|
|
1249
|
+
const merged = {
|
|
1250
|
+
...fresh,
|
|
1251
|
+
createdAt: prior.createdAt ?? fresh.createdAt,
|
|
1252
|
+
discoveredBy: prior.discoveredBy ?? fresh.discoveredBy
|
|
1253
|
+
};
|
|
1254
|
+
if (prior.lastReadAt) merged.lastReadAt = prior.lastReadAt;
|
|
1255
|
+
if (prior.lastWriteAt) merged.lastWriteAt = prior.lastWriteAt;
|
|
1256
|
+
if (prior.lastMaintenanceAt) merged.lastMaintenanceAt = { ...prior.lastMaintenanceAt };
|
|
1257
|
+
if (prior.principal !== void 0) merged.principal = prior.principal;
|
|
1258
|
+
if (prior.projectId !== void 0) merged.projectId = prior.projectId;
|
|
1259
|
+
if (prior.branch !== void 0) merged.branch = prior.branch;
|
|
1260
|
+
if (prior.parentNamespace !== void 0) merged.parentNamespace = prior.parentNamespace;
|
|
1261
|
+
return merged;
|
|
1262
|
+
}
|
|
1263
|
+
// ── Persistence ──────────────────────────────────────────────────────────
|
|
1264
|
+
/** Load the JSONL log and fold it into current state (last-record-wins). */
|
|
1265
|
+
async loadCompacted() {
|
|
1266
|
+
const records = /* @__PURE__ */ new Map();
|
|
1267
|
+
let raw;
|
|
1268
|
+
try {
|
|
1269
|
+
raw = await readFile(this.catalogPath, "utf8");
|
|
1270
|
+
} catch {
|
|
1271
|
+
return records;
|
|
1272
|
+
}
|
|
1273
|
+
for (const line of raw.split("\n")) {
|
|
1274
|
+
const trimmed = line.trim();
|
|
1275
|
+
if (trimmed.length === 0) continue;
|
|
1276
|
+
let parsed;
|
|
1277
|
+
try {
|
|
1278
|
+
parsed = JSON.parse(trimmed);
|
|
1279
|
+
} catch {
|
|
1280
|
+
continue;
|
|
1281
|
+
}
|
|
1282
|
+
const record = coerceRecord(parsed);
|
|
1283
|
+
if (!record) continue;
|
|
1284
|
+
const prior = records.get(record.namespace);
|
|
1285
|
+
records.set(record.namespace, prior ? mergeNewerTouchFields(record, prior) : record);
|
|
1286
|
+
}
|
|
1287
|
+
return records;
|
|
1288
|
+
}
|
|
1289
|
+
/**
|
|
1290
|
+
* Serialize an arbitrary read-modify-write critical section through the single
|
|
1291
|
+
* write chain. Every catalog mutation (touch read+merge+append, full rewrite)
|
|
1292
|
+
* runs through this so they are mutually exclusive: a touch always reads the
|
|
1293
|
+
* latest persisted state before appending, and a rebuild rewrite cannot
|
|
1294
|
+
* interleave with a touch's append. The chain recovers from rejection
|
|
1295
|
+
* (CLAUDE.md rule #40) — one failed section never poisons subsequent ones —
|
|
1296
|
+
* while still surfacing the error to that section's awaited promise.
|
|
1297
|
+
*/
|
|
1298
|
+
queueCritical(fn) {
|
|
1299
|
+
const run = this.writeChain.then(fn);
|
|
1300
|
+
this.writeChain = run.then(
|
|
1301
|
+
() => void 0,
|
|
1302
|
+
() => void 0
|
|
1303
|
+
);
|
|
1304
|
+
return run;
|
|
1305
|
+
}
|
|
1306
|
+
/**
|
|
1307
|
+
* Append a single record to the JSONL log WITHOUT re-serializing through the
|
|
1308
|
+
* write chain. MUST only be called from inside a `queueCritical(...)` section
|
|
1309
|
+
* (which already holds the serialized turn); calling it directly would bypass
|
|
1310
|
+
* the read-before-append ordering that prevents lost-field races.
|
|
1311
|
+
*/
|
|
1312
|
+
async appendUnchained(record) {
|
|
1313
|
+
const line = serializeRecord(record) + "\n";
|
|
1314
|
+
await mkdir(this.stateDir, { recursive: true });
|
|
1315
|
+
await appendFile(this.catalogPath, line, "utf8");
|
|
1316
|
+
}
|
|
1317
|
+
/**
|
|
1318
|
+
* Atomic temp-file + rename rewrite (CLAUDE.md rule #54: write temp, then
|
|
1319
|
+
* rename — never delete-before-write) WITHOUT re-entering the write chain.
|
|
1320
|
+
* MUST only be called from inside a `queueCritical(...)` turn (e.g. the
|
|
1321
|
+
* rebuild critical section, which already holds the serialized turn so its
|
|
1322
|
+
* load and rewrite are atomic against concurrent touches). Re-entering the
|
|
1323
|
+
* chain from within a held turn would deadlock.
|
|
1324
|
+
*/
|
|
1325
|
+
async rewriteUnchained(records) {
|
|
1326
|
+
const body = records.map((r) => serializeRecord(r)).join("\n") + (records.length > 0 ? "\n" : "");
|
|
1327
|
+
await mkdir(this.stateDir, { recursive: true });
|
|
1328
|
+
const tmp = `${this.catalogPath}.${process.pid}.${Date.now()}.tmp`;
|
|
1329
|
+
await writeFile(tmp, body, "utf8");
|
|
1330
|
+
await rename(tmp, this.catalogPath);
|
|
1331
|
+
}
|
|
1332
|
+
};
|
|
1333
|
+
function isPathInside(root, child) {
|
|
1334
|
+
const relative = path.relative(root, child);
|
|
1335
|
+
return relative === "" || !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
// src/maintenance/namespace-planner.ts
|
|
1339
|
+
import { createHash, randomUUID as randomUUID2 } from "crypto";
|
|
1340
|
+
import { lstat as lstat2, mkdir as mkdir2, open as open2, readFile as readFile2, readdir as readdir2, rename as rename2, rm, rmdir, utimes as utimes2, writeFile as writeFile2 } from "fs/promises";
|
|
1341
|
+
import path2 from "path";
|
|
1342
|
+
var DEFAULT_MAX_NAMESPACES_PER_CYCLE = 20;
|
|
1343
|
+
var DEFAULT_LOCK_STALE_MS = 10 * 6e4;
|
|
1344
|
+
var LOCK_BASE = "maintenance-locks";
|
|
1345
|
+
var STATUS_BASE = "namespace-maintenance-status";
|
|
1346
|
+
var namespaceMaintenanceFs = { open: open2, rm };
|
|
1347
|
+
function configuredNamespaces(config) {
|
|
1348
|
+
return Array.from(
|
|
1349
|
+
new Set(
|
|
1350
|
+
[config.defaultNamespace, config.sharedNamespace, ...config.namespacePolicies.map((policy) => policy.name)].map((value) => value.trim()).filter(Boolean)
|
|
1351
|
+
)
|
|
1352
|
+
);
|
|
1353
|
+
}
|
|
1354
|
+
function inferConfiguredKind(config, namespace) {
|
|
1355
|
+
if (namespace === config.defaultNamespace.trim()) return "default";
|
|
1356
|
+
if (namespace === config.sharedNamespace.trim()) return "shared";
|
|
1357
|
+
return "explicit";
|
|
1358
|
+
}
|
|
1359
|
+
function maxNamespacesPerCycle(config) {
|
|
1360
|
+
return Math.max(
|
|
1361
|
+
1,
|
|
1362
|
+
Math.floor(
|
|
1363
|
+
typeof config.maintenanceMaxNamespacesPerCycle === "number" && Number.isFinite(config.maintenanceMaxNamespacesPerCycle) ? config.maintenanceMaxNamespacesPerCycle : DEFAULT_MAX_NAMESPACES_PER_CYCLE
|
|
1364
|
+
)
|
|
1365
|
+
);
|
|
1366
|
+
}
|
|
1367
|
+
function namespaceKindAllowed(config, kind) {
|
|
1368
|
+
switch (kind) {
|
|
1369
|
+
case "branch":
|
|
1370
|
+
return config.maintenanceIncludeBranchNamespaces === true;
|
|
1371
|
+
case "project":
|
|
1372
|
+
return config.maintenanceIncludeProjectNamespaces !== false;
|
|
1373
|
+
case "team-project":
|
|
1374
|
+
return config.maintenanceIncludeTeamProjectNamespaces !== false;
|
|
1375
|
+
default:
|
|
1376
|
+
return true;
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
function disabledReasonForKind(kind) {
|
|
1380
|
+
if (kind === "branch") return "branch_disabled";
|
|
1381
|
+
if (kind === "project") return "project_disabled";
|
|
1382
|
+
if (kind === "team-project") return "team_project_disabled";
|
|
1383
|
+
return "fanout_disabled";
|
|
1384
|
+
}
|
|
1385
|
+
async function catalogRootIsLive(config, record) {
|
|
1386
|
+
if (typeof record.storageDir !== "string" || record.storageDir.length === 0) {
|
|
1387
|
+
return false;
|
|
1388
|
+
}
|
|
1389
|
+
try {
|
|
1390
|
+
const liveRoot = await resolveNamespaceStorageRoot(config, record.namespace);
|
|
1391
|
+
if (path2.resolve(liveRoot) !== path2.resolve(record.storageDir)) return false;
|
|
1392
|
+
return hasMemoryData(liveRoot);
|
|
1393
|
+
} catch {
|
|
1394
|
+
return false;
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
function candidateSortKey(candidate) {
|
|
1398
|
+
const write = candidate.lastWriteAt ?? "";
|
|
1399
|
+
return `${write}\0${candidate.namespace}`;
|
|
1400
|
+
}
|
|
1401
|
+
function candidatePriority(candidate) {
|
|
1402
|
+
if (candidate.kind === "default") return 0;
|
|
1403
|
+
if (candidate.kind === "shared") return 1;
|
|
1404
|
+
if (candidate.source === "configured") return 2;
|
|
1405
|
+
if (candidate.kind === "team-project") return 3;
|
|
1406
|
+
if (candidate.kind === "project") return 4;
|
|
1407
|
+
if (candidate.kind === "self") return 5;
|
|
1408
|
+
if (candidate.kind === "legacy") return 6;
|
|
1409
|
+
if (candidate.kind === "branch") return 8;
|
|
1410
|
+
return 7;
|
|
1411
|
+
}
|
|
1412
|
+
function sortCandidates(a, b) {
|
|
1413
|
+
const priority = candidatePriority(a) - candidatePriority(b);
|
|
1414
|
+
if (priority !== 0) return priority;
|
|
1415
|
+
const am = Date.parse(a.lastMaintenanceAt ?? "");
|
|
1416
|
+
const bm = Date.parse(b.lastMaintenanceAt ?? "");
|
|
1417
|
+
const aMaintained = Number.isFinite(am);
|
|
1418
|
+
const bMaintained = Number.isFinite(bm);
|
|
1419
|
+
if (aMaintained && bMaintained && am !== bm) return am - bm;
|
|
1420
|
+
if (aMaintained !== bMaintained) return aMaintained ? 1 : -1;
|
|
1421
|
+
const aw = Date.parse(a.lastWriteAt ?? "");
|
|
1422
|
+
const bw = Date.parse(b.lastWriteAt ?? "");
|
|
1423
|
+
const aValid = Number.isFinite(aw);
|
|
1424
|
+
const bValid = Number.isFinite(bw);
|
|
1425
|
+
if (aValid && bValid && aw !== bw) return bw - aw;
|
|
1426
|
+
if (aValid !== bValid) return aValid ? -1 : 1;
|
|
1427
|
+
const byKey = candidateSortKey(a).localeCompare(candidateSortKey(b));
|
|
1428
|
+
if (byKey !== 0) return byKey;
|
|
1429
|
+
return a.namespace.localeCompare(b.namespace);
|
|
1430
|
+
}
|
|
1431
|
+
async function planNamespaceMaintenance(config, options) {
|
|
1432
|
+
const generatedAt = (options.now ?? /* @__PURE__ */ new Date()).toISOString();
|
|
1433
|
+
const configured = config.namespacesEnabled ? configuredNamespaces(config) : [config.defaultNamespace.trim()].filter(Boolean);
|
|
1434
|
+
const byNamespace = /* @__PURE__ */ new Map();
|
|
1435
|
+
const skipped = [];
|
|
1436
|
+
for (const namespace of configured) {
|
|
1437
|
+
const kind = inferConfiguredKind(config, namespace);
|
|
1438
|
+
byNamespace.set(namespace, {
|
|
1439
|
+
namespace,
|
|
1440
|
+
kind,
|
|
1441
|
+
source: "configured"
|
|
1442
|
+
});
|
|
1443
|
+
}
|
|
1444
|
+
if (config.namespacesEnabled && config.maintenanceNamespaceFanoutEnabled !== false) {
|
|
1445
|
+
const configuredSet = new Set(configured);
|
|
1446
|
+
try {
|
|
1447
|
+
const records = options.catalog?.enabled ? await options.catalog.listNamespaces() : [];
|
|
1448
|
+
for (const record of records) {
|
|
1449
|
+
const namespace = record.namespace.trim();
|
|
1450
|
+
if (!namespace) continue;
|
|
1451
|
+
const isConfigured = configuredSet.has(namespace);
|
|
1452
|
+
const kind = isConfigured ? inferConfiguredKind(config, namespace) : record.kind;
|
|
1453
|
+
if (!namespaceKindAllowed(config, kind)) {
|
|
1454
|
+
skipped.push({
|
|
1455
|
+
namespace,
|
|
1456
|
+
kind,
|
|
1457
|
+
reason: disabledReasonForKind(kind)
|
|
1458
|
+
});
|
|
1459
|
+
continue;
|
|
1460
|
+
}
|
|
1461
|
+
if (!isConfigured && !await catalogRootIsLive(config, record)) {
|
|
1462
|
+
skipped.push({
|
|
1463
|
+
namespace,
|
|
1464
|
+
kind,
|
|
1465
|
+
reason: "unsafe_or_stale_root"
|
|
1466
|
+
});
|
|
1467
|
+
continue;
|
|
1468
|
+
}
|
|
1469
|
+
byNamespace.set(namespace, {
|
|
1470
|
+
namespace,
|
|
1471
|
+
kind,
|
|
1472
|
+
storageDir: record.storageDir,
|
|
1473
|
+
source: isConfigured ? "configured" : "catalog",
|
|
1474
|
+
lastWriteAt: record.lastWriteAt,
|
|
1475
|
+
lastMaintenanceAt: record.lastMaintenanceAt?.[options.jobName]
|
|
1476
|
+
});
|
|
1477
|
+
}
|
|
1478
|
+
} catch (error) {
|
|
1479
|
+
skipped.push({
|
|
1480
|
+
namespace: "*",
|
|
1481
|
+
reason: "catalog_read_failed",
|
|
1482
|
+
detail: error instanceof Error ? error.message : String(error)
|
|
1483
|
+
});
|
|
1484
|
+
}
|
|
1485
|
+
} else if (config.namespacesEnabled) {
|
|
1486
|
+
skipped.push({
|
|
1487
|
+
namespace: "*",
|
|
1488
|
+
reason: "fanout_disabled"
|
|
1489
|
+
});
|
|
1490
|
+
}
|
|
1491
|
+
if (options.budgetMode !== "unbounded") {
|
|
1492
|
+
const latestStatusAtByNamespace = await readLatestStatusAtByNamespace(config, options.jobName);
|
|
1493
|
+
for (const candidate of byNamespace.values()) {
|
|
1494
|
+
if (!candidate.lastMaintenanceAt) {
|
|
1495
|
+
candidate.lastMaintenanceAt = latestStatusAtByNamespace.get(candidate.namespace);
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
const candidates = [...byNamespace.values()].filter((candidate) => namespaceKindAllowed(config, candidate.kind)).sort(sortCandidates);
|
|
1500
|
+
const max = maxNamespacesPerCycle(config);
|
|
1501
|
+
const applyCycleBudget = options.budgetMode !== "unbounded";
|
|
1502
|
+
const selected = applyCycleBudget ? candidates.slice(0, max) : candidates;
|
|
1503
|
+
if (applyCycleBudget) {
|
|
1504
|
+
for (const candidate of candidates.slice(max)) {
|
|
1505
|
+
skipped.push({
|
|
1506
|
+
namespace: candidate.namespace,
|
|
1507
|
+
kind: candidate.kind,
|
|
1508
|
+
reason: "budget_exhausted"
|
|
1509
|
+
});
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
return {
|
|
1513
|
+
jobName: options.jobName,
|
|
1514
|
+
generatedAt,
|
|
1515
|
+
namespaces: selected,
|
|
1516
|
+
skipped,
|
|
1517
|
+
budget: {
|
|
1518
|
+
maxNamespacesPerCycle: max,
|
|
1519
|
+
selected: selected.length
|
|
1520
|
+
}
|
|
1521
|
+
};
|
|
1522
|
+
}
|
|
1523
|
+
function stablePathSegment(value) {
|
|
1524
|
+
const sanitized = value.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 128) || "unnamed";
|
|
1525
|
+
if (sanitized.length <= 128 && sanitized === value) return sanitized;
|
|
1526
|
+
return `${sanitized.slice(0, 80)}-${createHash("sha256").update(value).digest("hex").slice(0, 16)}`;
|
|
1527
|
+
}
|
|
1528
|
+
function namespacePathSegment(namespace) {
|
|
1529
|
+
const token = namespaceIdentityToken(namespace);
|
|
1530
|
+
if (token.length <= 160) return token;
|
|
1531
|
+
return `ns-${createHash("sha256").update(namespace).digest("hex")}`;
|
|
1532
|
+
}
|
|
1533
|
+
function lockPath(config, jobName, namespace) {
|
|
1534
|
+
return path2.join(
|
|
1535
|
+
config.memoryDir,
|
|
1536
|
+
"state",
|
|
1537
|
+
LOCK_BASE,
|
|
1538
|
+
stablePathSegment(jobName),
|
|
1539
|
+
`${namespacePathSegment(namespace)}.lock`
|
|
1540
|
+
);
|
|
1541
|
+
}
|
|
1542
|
+
function namespaceMaintenanceLockStaleMs(config) {
|
|
1543
|
+
if (typeof config.maintenanceNamespaceLockStaleMs === "number" && Number.isFinite(config.maintenanceNamespaceLockStaleMs) && config.maintenanceNamespaceLockStaleMs > 0) {
|
|
1544
|
+
return Math.floor(config.maintenanceNamespaceLockStaleMs);
|
|
1545
|
+
}
|
|
1546
|
+
return DEFAULT_LOCK_STALE_MS;
|
|
1547
|
+
}
|
|
1548
|
+
function namespaceMaintenanceLockHeartbeatMs(config) {
|
|
1549
|
+
const staleMs = namespaceMaintenanceLockStaleMs(config);
|
|
1550
|
+
return Math.max(1, Math.min(3e4, Math.floor(staleMs / 3) || 1));
|
|
1551
|
+
}
|
|
1552
|
+
function errorCode(error) {
|
|
1553
|
+
return typeof error === "object" && error !== null && "code" in error ? error.code : void 0;
|
|
1554
|
+
}
|
|
1555
|
+
async function withNamespaceMaintenanceLockHeartbeat(config, locks, task) {
|
|
1556
|
+
const activeLocks = Array.isArray(locks) ? locks : [locks];
|
|
1557
|
+
const interval = setInterval(() => {
|
|
1558
|
+
for (const lock of activeLocks) {
|
|
1559
|
+
void lock.touch().catch(() => void 0);
|
|
1560
|
+
}
|
|
1561
|
+
}, namespaceMaintenanceLockHeartbeatMs(config));
|
|
1562
|
+
interval.unref?.();
|
|
1563
|
+
try {
|
|
1564
|
+
return await task();
|
|
1565
|
+
} finally {
|
|
1566
|
+
clearInterval(interval);
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
async function removeStaleLockDirectory(filePath) {
|
|
1570
|
+
let entries;
|
|
1571
|
+
try {
|
|
1572
|
+
entries = await readdir2(filePath, { withFileTypes: true });
|
|
1573
|
+
} catch (error) {
|
|
1574
|
+
if (errorCode(error) === "ENOENT") return;
|
|
1575
|
+
throw error;
|
|
1576
|
+
}
|
|
1577
|
+
for (const entry of entries) {
|
|
1578
|
+
if (!entry.isFile()) continue;
|
|
1579
|
+
await namespaceMaintenanceFs.rm(path2.join(filePath, entry.name), { force: true });
|
|
1580
|
+
}
|
|
1581
|
+
try {
|
|
1582
|
+
await rmdir(filePath);
|
|
1583
|
+
} catch (error) {
|
|
1584
|
+
if (errorCode(error) === "ENOENT") return;
|
|
1585
|
+
throw error;
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
async function tryAcquireNamespaceMaintenanceLock(config, jobName, namespace) {
|
|
1589
|
+
const filePath = lockPath(config, jobName, namespace);
|
|
1590
|
+
await mkdir2(path2.dirname(filePath), { recursive: true });
|
|
1591
|
+
try {
|
|
1592
|
+
const lockId = randomUUID2();
|
|
1593
|
+
await mkdir2(filePath);
|
|
1594
|
+
const ownerPath = path2.join(filePath, `${lockId}.json`);
|
|
1595
|
+
let handle;
|
|
1596
|
+
try {
|
|
1597
|
+
handle = await namespaceMaintenanceFs.open(ownerPath, "wx");
|
|
1598
|
+
await handle.writeFile(
|
|
1599
|
+
`${JSON.stringify({
|
|
1600
|
+
lockId,
|
|
1601
|
+
pid: process.pid,
|
|
1602
|
+
jobName,
|
|
1603
|
+
namespace,
|
|
1604
|
+
acquiredAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1605
|
+
})}
|
|
1606
|
+
`,
|
|
1607
|
+
"utf8"
|
|
1608
|
+
);
|
|
1609
|
+
await handle.close();
|
|
1610
|
+
} catch (setupError) {
|
|
1611
|
+
await handle?.close().catch(() => void 0);
|
|
1612
|
+
await namespaceMaintenanceFs.rm(ownerPath, { force: true }).catch(() => void 0);
|
|
1613
|
+
await rmdir(filePath).catch(() => void 0);
|
|
1614
|
+
throw setupError;
|
|
1615
|
+
}
|
|
1616
|
+
return {
|
|
1617
|
+
path: filePath,
|
|
1618
|
+
async touch() {
|
|
1619
|
+
try {
|
|
1620
|
+
const parsed = JSON.parse(await readFile2(ownerPath, "utf8"));
|
|
1621
|
+
if (parsed.lockId === lockId) {
|
|
1622
|
+
const now = /* @__PURE__ */ new Date();
|
|
1623
|
+
await utimes2(ownerPath, now, now);
|
|
1624
|
+
await utimes2(filePath, now, now);
|
|
1625
|
+
}
|
|
1626
|
+
} catch {
|
|
1627
|
+
}
|
|
1628
|
+
},
|
|
1629
|
+
async release() {
|
|
1630
|
+
try {
|
|
1631
|
+
const parsed = JSON.parse(await readFile2(ownerPath, "utf8"));
|
|
1632
|
+
if (parsed.lockId === lockId) {
|
|
1633
|
+
await namespaceMaintenanceFs.rm(ownerPath, { force: true });
|
|
1634
|
+
await rmdir(filePath).catch(() => void 0);
|
|
1635
|
+
}
|
|
1636
|
+
} catch {
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
};
|
|
1640
|
+
} catch (error) {
|
|
1641
|
+
if (errorCode(error) === "EEXIST") {
|
|
1642
|
+
const staleMs = namespaceMaintenanceLockStaleMs(config);
|
|
1643
|
+
try {
|
|
1644
|
+
const s = await lstat2(filePath);
|
|
1645
|
+
if (s.isSymbolicLink()) {
|
|
1646
|
+
return null;
|
|
1647
|
+
}
|
|
1648
|
+
if ((s.isFile() || s.isDirectory()) && Date.now() - s.mtimeMs > staleMs) {
|
|
1649
|
+
try {
|
|
1650
|
+
if (s.isDirectory()) {
|
|
1651
|
+
await removeStaleLockDirectory(filePath);
|
|
1652
|
+
} else {
|
|
1653
|
+
await namespaceMaintenanceFs.rm(filePath, { force: true });
|
|
1654
|
+
}
|
|
1655
|
+
} catch (removeError) {
|
|
1656
|
+
if (errorCode(removeError) === "ENOENT") {
|
|
1657
|
+
return tryAcquireNamespaceMaintenanceLock(config, jobName, namespace);
|
|
1658
|
+
}
|
|
1659
|
+
if (errorCode(removeError) === "ENOTEMPTY") {
|
|
1660
|
+
return null;
|
|
1661
|
+
}
|
|
1662
|
+
throw removeError;
|
|
1663
|
+
}
|
|
1664
|
+
return tryAcquireNamespaceMaintenanceLock(config, jobName, namespace);
|
|
1665
|
+
}
|
|
1666
|
+
} catch (statError) {
|
|
1667
|
+
if (errorCode(statError) === "ENOENT") {
|
|
1668
|
+
return tryAcquireNamespaceMaintenanceLock(config, jobName, namespace);
|
|
1669
|
+
}
|
|
1670
|
+
throw statError;
|
|
1671
|
+
}
|
|
1672
|
+
return null;
|
|
1673
|
+
}
|
|
1674
|
+
throw error;
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
function statusBasePath(config) {
|
|
1678
|
+
return path2.join(config.memoryDir, "state", STATUS_BASE);
|
|
1679
|
+
}
|
|
1680
|
+
function statusPath(config, jobName, namespace) {
|
|
1681
|
+
return path2.join(statusBasePath(config), stablePathSegment(jobName), `${namespacePathSegment(namespace)}.json`);
|
|
1682
|
+
}
|
|
1683
|
+
function lastRanStatusPath(config, jobName, namespace) {
|
|
1684
|
+
return path2.join(statusBasePath(config), stablePathSegment(jobName), `${namespacePathSegment(namespace)}.last-ran.json`);
|
|
1685
|
+
}
|
|
1686
|
+
function parseStatus(value) {
|
|
1687
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
1688
|
+
const v = value;
|
|
1689
|
+
if (typeof v.namespace === "string" && typeof v.jobName === "string" && (v.state === "ran" || v.state === "skipped" || v.state === "failed") && typeof v.startedAt === "string" && typeof v.completedAt === "string") {
|
|
1690
|
+
return v;
|
|
1691
|
+
}
|
|
1692
|
+
return null;
|
|
1693
|
+
}
|
|
1694
|
+
async function readLatestStatusAtByNamespace(config, jobName) {
|
|
1695
|
+
const latest = /* @__PURE__ */ new Map();
|
|
1696
|
+
const latestMs = /* @__PURE__ */ new Map();
|
|
1697
|
+
for (const status of [...await readStatusFiles(config), ...await readLastRanStatusFiles(config)]) {
|
|
1698
|
+
if (status.state !== "ran") continue;
|
|
1699
|
+
if (status.jobName !== jobName) continue;
|
|
1700
|
+
const completedAtMs = Date.parse(status.completedAt);
|
|
1701
|
+
if (!Number.isFinite(completedAtMs)) continue;
|
|
1702
|
+
const previousMs = latestMs.get(status.namespace);
|
|
1703
|
+
if (previousMs !== void 0 && previousMs >= completedAtMs) continue;
|
|
1704
|
+
latestMs.set(status.namespace, completedAtMs);
|
|
1705
|
+
latest.set(status.namespace, status.completedAt);
|
|
1706
|
+
}
|
|
1707
|
+
return latest;
|
|
1708
|
+
}
|
|
1709
|
+
async function readStatusFile(filePath) {
|
|
1710
|
+
try {
|
|
1711
|
+
const parsed = JSON.parse(await readFile2(filePath, "utf8"));
|
|
1712
|
+
return parseStatus(parsed);
|
|
1713
|
+
} catch {
|
|
1714
|
+
return null;
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
async function readStatusFiles(config) {
|
|
1718
|
+
if (typeof config.memoryDir !== "string" || config.memoryDir.length === 0) {
|
|
1719
|
+
return [];
|
|
1720
|
+
}
|
|
1721
|
+
const root = statusBasePath(config);
|
|
1722
|
+
const statuses = [];
|
|
1723
|
+
let jobDirs;
|
|
1724
|
+
try {
|
|
1725
|
+
jobDirs = await readdir2(root, { withFileTypes: true });
|
|
1726
|
+
} catch {
|
|
1727
|
+
return statuses;
|
|
1728
|
+
}
|
|
1729
|
+
for (const jobDir of jobDirs) {
|
|
1730
|
+
if (!jobDir.isDirectory()) continue;
|
|
1731
|
+
let files;
|
|
1732
|
+
try {
|
|
1733
|
+
files = await readdir2(path2.join(root, jobDir.name), { withFileTypes: true });
|
|
1734
|
+
} catch {
|
|
1735
|
+
continue;
|
|
1736
|
+
}
|
|
1737
|
+
for (const file of files) {
|
|
1738
|
+
if (!file.isFile() || !file.name.endsWith(".json")) continue;
|
|
1739
|
+
if (file.name.endsWith(".last-ran.json")) continue;
|
|
1740
|
+
const status = await readStatusFile(path2.join(root, jobDir.name, file.name));
|
|
1741
|
+
if (status) statuses.push(status);
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
return statuses;
|
|
1745
|
+
}
|
|
1746
|
+
async function readLastRanStatusFiles(config) {
|
|
1747
|
+
if (typeof config.memoryDir !== "string" || config.memoryDir.length === 0) {
|
|
1748
|
+
return [];
|
|
1749
|
+
}
|
|
1750
|
+
const root = statusBasePath(config);
|
|
1751
|
+
const statuses = [];
|
|
1752
|
+
let jobDirs;
|
|
1753
|
+
try {
|
|
1754
|
+
jobDirs = await readdir2(root, { withFileTypes: true });
|
|
1755
|
+
} catch {
|
|
1756
|
+
return statuses;
|
|
1757
|
+
}
|
|
1758
|
+
for (const jobDir of jobDirs) {
|
|
1759
|
+
if (!jobDir.isDirectory()) continue;
|
|
1760
|
+
let files;
|
|
1761
|
+
try {
|
|
1762
|
+
files = await readdir2(path2.join(root, jobDir.name), { withFileTypes: true });
|
|
1763
|
+
} catch {
|
|
1764
|
+
continue;
|
|
1765
|
+
}
|
|
1766
|
+
for (const file of files) {
|
|
1767
|
+
if (!file.isFile() || !file.name.endsWith(".last-ran.json")) continue;
|
|
1768
|
+
const status = await readStatusFile(path2.join(root, jobDir.name, file.name));
|
|
1769
|
+
if (status) statuses.push(status);
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
return statuses;
|
|
1773
|
+
}
|
|
1774
|
+
async function writeStatusPayload(target, status) {
|
|
1775
|
+
const dir = path2.dirname(target);
|
|
1776
|
+
await mkdir2(dir, { recursive: true });
|
|
1777
|
+
const temp = `${target}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
1778
|
+
const payload = {
|
|
1779
|
+
version: 1,
|
|
1780
|
+
...status
|
|
1781
|
+
};
|
|
1782
|
+
await writeFile2(temp, `${JSON.stringify(payload, null, 2)}
|
|
1783
|
+
`, "utf8");
|
|
1784
|
+
await rename2(temp, target);
|
|
1785
|
+
}
|
|
1786
|
+
async function writeStatusFile(config, status) {
|
|
1787
|
+
await writeStatusPayload(statusPath(config, status.jobName, status.namespace), status);
|
|
1788
|
+
if (status.state === "ran") {
|
|
1789
|
+
await writeStatusPayload(lastRanStatusPath(config, status.jobName, status.namespace), status);
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
async function recordNamespaceMaintenanceStatusSafely(config, status) {
|
|
1793
|
+
try {
|
|
1794
|
+
await writeStatusFile(config, status);
|
|
1795
|
+
} catch {
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
function maintenanceErrorDetail(error) {
|
|
1799
|
+
return displayErrorDetail(error) || "Error";
|
|
1800
|
+
}
|
|
1801
|
+
async function readNamespaceMaintenanceStatuses(config) {
|
|
1802
|
+
return (await readStatusFiles(config)).sort((a, b) => {
|
|
1803
|
+
const byJob = a.jobName.localeCompare(b.jobName);
|
|
1804
|
+
if (byJob !== 0) return byJob;
|
|
1805
|
+
return a.namespace.localeCompare(b.namespace);
|
|
1806
|
+
});
|
|
1807
|
+
}
|
|
1808
|
+
async function readNamespaceMaintenanceLastRanStatuses(config) {
|
|
1809
|
+
return (await readLastRanStatusFiles(config)).sort((a, b) => {
|
|
1810
|
+
const byJob = a.jobName.localeCompare(b.jobName);
|
|
1811
|
+
if (byJob !== 0) return byJob;
|
|
1812
|
+
return a.namespace.localeCompare(b.namespace);
|
|
1813
|
+
});
|
|
1814
|
+
}
|
|
1815
|
+
async function runNamespaceMaintenancePlan(config, plan, runner, catalog) {
|
|
1816
|
+
const statuses = [];
|
|
1817
|
+
for (const skipped of plan.skipped) {
|
|
1818
|
+
if (skipped.namespace === "*") continue;
|
|
1819
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1820
|
+
const status = {
|
|
1821
|
+
namespace: skipped.namespace,
|
|
1822
|
+
jobName: plan.jobName,
|
|
1823
|
+
state: "skipped",
|
|
1824
|
+
reason: skipped.reason,
|
|
1825
|
+
startedAt: now,
|
|
1826
|
+
completedAt: now
|
|
1827
|
+
};
|
|
1828
|
+
statuses.push(status);
|
|
1829
|
+
await recordNamespaceMaintenanceStatusSafely(config, status);
|
|
1830
|
+
}
|
|
1831
|
+
for (const candidate of plan.namespaces) {
|
|
1832
|
+
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1833
|
+
const lock = await tryAcquireNamespaceMaintenanceLock(config, plan.jobName, candidate.namespace);
|
|
1834
|
+
if (!lock) {
|
|
1835
|
+
const completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1836
|
+
const status = {
|
|
1837
|
+
namespace: candidate.namespace,
|
|
1838
|
+
jobName: plan.jobName,
|
|
1839
|
+
state: "skipped",
|
|
1840
|
+
reason: "lock_held",
|
|
1841
|
+
startedAt,
|
|
1842
|
+
completedAt
|
|
1843
|
+
};
|
|
1844
|
+
statuses.push(status);
|
|
1845
|
+
await recordNamespaceMaintenanceStatusSafely(config, status);
|
|
1846
|
+
continue;
|
|
1847
|
+
}
|
|
1848
|
+
try {
|
|
1849
|
+
const result = await withNamespaceMaintenanceLockHeartbeat(config, lock, () => runner(candidate));
|
|
1850
|
+
const completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1851
|
+
if (result?.skipped) {
|
|
1852
|
+
const status = {
|
|
1853
|
+
namespace: candidate.namespace,
|
|
1854
|
+
jobName: plan.jobName,
|
|
1855
|
+
state: "skipped",
|
|
1856
|
+
reason: result.skipReason ?? "throttled",
|
|
1857
|
+
startedAt,
|
|
1858
|
+
completedAt
|
|
1859
|
+
};
|
|
1860
|
+
statuses.push(status);
|
|
1861
|
+
await recordNamespaceMaintenanceStatusSafely(config, status);
|
|
1862
|
+
} else {
|
|
1863
|
+
const status = {
|
|
1864
|
+
namespace: candidate.namespace,
|
|
1865
|
+
jobName: plan.jobName,
|
|
1866
|
+
state: "ran",
|
|
1867
|
+
startedAt,
|
|
1868
|
+
completedAt,
|
|
1869
|
+
itemCount: result?.itemCount
|
|
1870
|
+
};
|
|
1871
|
+
statuses.push(status);
|
|
1872
|
+
await recordNamespaceMaintenanceStatusSafely(config, status);
|
|
1873
|
+
try {
|
|
1874
|
+
await catalog?.markMaintenance(candidate.namespace, plan.jobName, new Date(completedAt));
|
|
1875
|
+
} catch {
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
} catch (error) {
|
|
1879
|
+
const completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1880
|
+
const status = {
|
|
1881
|
+
namespace: candidate.namespace,
|
|
1882
|
+
jobName: plan.jobName,
|
|
1883
|
+
state: "failed",
|
|
1884
|
+
reason: "job_failed",
|
|
1885
|
+
startedAt,
|
|
1886
|
+
completedAt,
|
|
1887
|
+
error: maintenanceErrorDetail(error)
|
|
1888
|
+
};
|
|
1889
|
+
statuses.push(status);
|
|
1890
|
+
await recordNamespaceMaintenanceStatusSafely(config, status);
|
|
1891
|
+
} finally {
|
|
1892
|
+
await lock.release().catch(() => void 0);
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
return {
|
|
1896
|
+
jobName: plan.jobName,
|
|
1897
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1898
|
+
ran: statuses.filter((s) => s.state === "ran").length,
|
|
1899
|
+
skipped: statuses.filter((s) => s.state === "skipped").length,
|
|
1900
|
+
failed: statuses.filter((s) => s.state === "failed").length,
|
|
1901
|
+
statuses
|
|
1902
|
+
};
|
|
1903
|
+
}
|
|
1904
|
+
async function runNamespaceMaintenanceBatchPlan(config, plan, runner, catalog, options = {}) {
|
|
1905
|
+
const statuses = [];
|
|
1906
|
+
for (const skipped of plan.skipped) {
|
|
1907
|
+
if (skipped.namespace === "*") continue;
|
|
1908
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1909
|
+
const status = {
|
|
1910
|
+
namespace: skipped.namespace,
|
|
1911
|
+
jobName: plan.jobName,
|
|
1912
|
+
state: "skipped",
|
|
1913
|
+
reason: skipped.reason,
|
|
1914
|
+
startedAt: now,
|
|
1915
|
+
completedAt: now
|
|
1916
|
+
};
|
|
1917
|
+
statuses.push(status);
|
|
1918
|
+
await recordNamespaceMaintenanceStatusSafely(config, status);
|
|
1919
|
+
}
|
|
1920
|
+
const acquired = [];
|
|
1921
|
+
try {
|
|
1922
|
+
for (const candidate of plan.namespaces) {
|
|
1923
|
+
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1924
|
+
const lock = await tryAcquireNamespaceMaintenanceLock(config, plan.jobName, candidate.namespace);
|
|
1925
|
+
if (!lock) {
|
|
1926
|
+
const completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1927
|
+
const status = {
|
|
1928
|
+
namespace: candidate.namespace,
|
|
1929
|
+
jobName: plan.jobName,
|
|
1930
|
+
state: "skipped",
|
|
1931
|
+
reason: "lock_held",
|
|
1932
|
+
startedAt,
|
|
1933
|
+
completedAt
|
|
1934
|
+
};
|
|
1935
|
+
statuses.push(status);
|
|
1936
|
+
await recordNamespaceMaintenanceStatusSafely(config, status);
|
|
1937
|
+
continue;
|
|
1938
|
+
}
|
|
1939
|
+
acquired.push({ candidate, lock, startedAt });
|
|
1940
|
+
}
|
|
1941
|
+
} catch (error) {
|
|
1942
|
+
await Promise.all(acquired.map(({ lock }) => lock.release().catch(() => void 0)));
|
|
1943
|
+
throw error;
|
|
1944
|
+
}
|
|
1945
|
+
if (options.requireAllLocks && acquired.length > 0 && acquired.length < plan.namespaces.length) {
|
|
1946
|
+
for (const { candidate, startedAt } of acquired) {
|
|
1947
|
+
const completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1948
|
+
const status = {
|
|
1949
|
+
namespace: candidate.namespace,
|
|
1950
|
+
jobName: plan.jobName,
|
|
1951
|
+
state: "skipped",
|
|
1952
|
+
reason: "batch_lock_incomplete",
|
|
1953
|
+
startedAt,
|
|
1954
|
+
completedAt
|
|
1955
|
+
};
|
|
1956
|
+
statuses.push(status);
|
|
1957
|
+
await recordNamespaceMaintenanceStatusSafely(config, status);
|
|
1958
|
+
}
|
|
1959
|
+
await Promise.all(acquired.map(({ lock }) => lock.release().catch(() => void 0)));
|
|
1960
|
+
return {
|
|
1961
|
+
jobName: plan.jobName,
|
|
1962
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1963
|
+
ran: statuses.filter((s) => s.state === "ran").length,
|
|
1964
|
+
skipped: statuses.filter((s) => s.state === "skipped").length,
|
|
1965
|
+
failed: statuses.filter((s) => s.state === "failed").length,
|
|
1966
|
+
statuses
|
|
1967
|
+
};
|
|
1968
|
+
}
|
|
1969
|
+
if (acquired.length === 0) {
|
|
1970
|
+
return {
|
|
1971
|
+
jobName: plan.jobName,
|
|
1972
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1973
|
+
ran: statuses.filter((s) => s.state === "ran").length,
|
|
1974
|
+
skipped: statuses.filter((s) => s.state === "skipped").length,
|
|
1975
|
+
failed: statuses.filter((s) => s.state === "failed").length,
|
|
1976
|
+
statuses
|
|
1977
|
+
};
|
|
1978
|
+
}
|
|
1979
|
+
try {
|
|
1980
|
+
const result = await withNamespaceMaintenanceLockHeartbeat(
|
|
1981
|
+
config,
|
|
1982
|
+
acquired.map(({ lock }) => lock),
|
|
1983
|
+
() => runner(acquired.map(({ candidate }) => candidate))
|
|
1984
|
+
);
|
|
1985
|
+
for (const { candidate, startedAt } of acquired) {
|
|
1986
|
+
const completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1987
|
+
const status = {
|
|
1988
|
+
namespace: candidate.namespace,
|
|
1989
|
+
jobName: plan.jobName,
|
|
1990
|
+
state: "ran",
|
|
1991
|
+
startedAt,
|
|
1992
|
+
completedAt,
|
|
1993
|
+
itemCount: itemCountForNamespace(result, candidate.namespace)
|
|
1994
|
+
};
|
|
1995
|
+
statuses.push(status);
|
|
1996
|
+
await recordNamespaceMaintenanceStatusSafely(config, status);
|
|
1997
|
+
try {
|
|
1998
|
+
await catalog?.markMaintenance(candidate.namespace, plan.jobName, new Date(completedAt));
|
|
1999
|
+
} catch {
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
} catch (error) {
|
|
2003
|
+
const skipReason = options.skipReasonForError?.(error);
|
|
2004
|
+
for (const { candidate, startedAt } of acquired) {
|
|
2005
|
+
const completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2006
|
+
const status = skipReason ? {
|
|
2007
|
+
namespace: candidate.namespace,
|
|
2008
|
+
jobName: plan.jobName,
|
|
2009
|
+
state: "skipped",
|
|
2010
|
+
reason: skipReason,
|
|
2011
|
+
startedAt,
|
|
2012
|
+
completedAt
|
|
2013
|
+
} : {
|
|
2014
|
+
namespace: candidate.namespace,
|
|
2015
|
+
jobName: plan.jobName,
|
|
2016
|
+
state: "failed",
|
|
2017
|
+
reason: "job_failed",
|
|
2018
|
+
startedAt,
|
|
2019
|
+
completedAt,
|
|
2020
|
+
error: maintenanceErrorDetail(error)
|
|
2021
|
+
};
|
|
2022
|
+
statuses.push(status);
|
|
2023
|
+
await recordNamespaceMaintenanceStatusSafely(config, status);
|
|
2024
|
+
}
|
|
2025
|
+
} finally {
|
|
2026
|
+
await Promise.all(acquired.map(({ lock }) => lock.release().catch(() => void 0)));
|
|
2027
|
+
}
|
|
2028
|
+
return {
|
|
2029
|
+
jobName: plan.jobName,
|
|
2030
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2031
|
+
ran: statuses.filter((s) => s.state === "ran").length,
|
|
2032
|
+
skipped: statuses.filter((s) => s.state === "skipped").length,
|
|
2033
|
+
failed: statuses.filter((s) => s.state === "failed").length,
|
|
2034
|
+
statuses
|
|
2035
|
+
};
|
|
2036
|
+
}
|
|
2037
|
+
function itemCountForNamespace(result, namespace) {
|
|
2038
|
+
const itemCounts = result?.itemCounts;
|
|
2039
|
+
if (itemCounts instanceof Map) return itemCounts.get(namespace);
|
|
2040
|
+
if (itemCounts && Object.prototype.hasOwnProperty.call(itemCounts, namespace)) {
|
|
2041
|
+
return itemCounts[namespace];
|
|
2042
|
+
}
|
|
2043
|
+
return result?.itemCount;
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
// src/maintenance/namespace-maintenance-fanout.ts
|
|
2047
|
+
var NAMESPACE_MAINTENANCE_JOBS = [
|
|
2048
|
+
"qmd",
|
|
2049
|
+
"pattern-reinforcement",
|
|
2050
|
+
"contradiction-scan",
|
|
2051
|
+
"semantic-consolidation",
|
|
2052
|
+
"governance",
|
|
2053
|
+
"lifecycle",
|
|
2054
|
+
"graph-decay",
|
|
2055
|
+
"fact-archival",
|
|
2056
|
+
"tier-migration"
|
|
2057
|
+
];
|
|
2058
|
+
async function runNamespaceMaintenanceFanout(options) {
|
|
2059
|
+
if (options.enabled === false) {
|
|
2060
|
+
return {
|
|
2061
|
+
jobName: options.jobName,
|
|
2062
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2063
|
+
ran: 0,
|
|
2064
|
+
skipped: 0,
|
|
2065
|
+
failed: 0,
|
|
2066
|
+
statuses: []
|
|
2067
|
+
};
|
|
2068
|
+
}
|
|
2069
|
+
const plan = await planNamespaceMaintenance(options.config, {
|
|
2070
|
+
jobName: options.jobName,
|
|
2071
|
+
catalog: options.catalog
|
|
2072
|
+
});
|
|
2073
|
+
return runNamespaceMaintenancePlan(
|
|
2074
|
+
options.config,
|
|
2075
|
+
plan,
|
|
2076
|
+
async (candidate) => {
|
|
2077
|
+
return options.runner({
|
|
2078
|
+
config: options.config,
|
|
2079
|
+
candidate,
|
|
2080
|
+
resolveStorage: options.resolveStorage
|
|
2081
|
+
});
|
|
2082
|
+
},
|
|
2083
|
+
options.catalog
|
|
2084
|
+
);
|
|
2085
|
+
}
|
|
2086
|
+
async function summarizeNamespaceMaintenanceHealth(config) {
|
|
2087
|
+
const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2088
|
+
const statuses = await readNamespaceMaintenanceStatuses(config);
|
|
2089
|
+
const lastRanStatuses = await readNamespaceMaintenanceLastRanStatuses(config);
|
|
2090
|
+
const lastRanByJobNs = /* @__PURE__ */ new Map();
|
|
2091
|
+
for (const lr of lastRanStatuses) {
|
|
2092
|
+
lastRanByJobNs.set(`${lr.jobName}\0${lr.namespace}`, lr);
|
|
2093
|
+
}
|
|
2094
|
+
const byJob = /* @__PURE__ */ new Map();
|
|
2095
|
+
for (const status of statuses) {
|
|
2096
|
+
const bucket = byJob.get(status.jobName);
|
|
2097
|
+
if (bucket) {
|
|
2098
|
+
bucket.push(status);
|
|
2099
|
+
} else {
|
|
2100
|
+
byJob.set(status.jobName, [status]);
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
const knownJobs = new Set(NAMESPACE_MAINTENANCE_JOBS);
|
|
2104
|
+
for (const jobName of byJob.keys()) {
|
|
2105
|
+
knownJobs.add(jobName);
|
|
2106
|
+
}
|
|
2107
|
+
const jobs = [];
|
|
2108
|
+
let totalRan = 0;
|
|
2109
|
+
let totalSkipped = 0;
|
|
2110
|
+
let totalFailed = 0;
|
|
2111
|
+
for (const jobName of [...knownJobs].sort()) {
|
|
2112
|
+
const jobStatuses = byJob.get(jobName) ?? [];
|
|
2113
|
+
const ran = jobStatuses.filter((s) => s.state === "ran").length;
|
|
2114
|
+
const skipped = jobStatuses.filter((s) => s.state === "skipped").length;
|
|
2115
|
+
const failed = jobStatuses.filter((s) => s.state === "failed").length;
|
|
2116
|
+
const candidates = [];
|
|
2117
|
+
for (const st of jobStatuses) {
|
|
2118
|
+
if (typeof st.completedAt === "string") candidates.push(st.completedAt);
|
|
2119
|
+
const lr = lastRanByJobNs.get(`${jobName}\0${st.namespace}`);
|
|
2120
|
+
if (lr && typeof lr.completedAt === "string") candidates.push(lr.completedAt);
|
|
2121
|
+
}
|
|
2122
|
+
const lastRunAt = candidates.sort().at(-1) ?? null;
|
|
2123
|
+
totalRan += ran;
|
|
2124
|
+
totalSkipped += skipped;
|
|
2125
|
+
totalFailed += failed;
|
|
2126
|
+
jobs.push({
|
|
2127
|
+
jobName,
|
|
2128
|
+
ran,
|
|
2129
|
+
skipped,
|
|
2130
|
+
failed,
|
|
2131
|
+
lastRunAt,
|
|
2132
|
+
namespaces: jobStatuses
|
|
2133
|
+
});
|
|
2134
|
+
}
|
|
2135
|
+
return {
|
|
2136
|
+
generatedAt,
|
|
2137
|
+
fanoutEnabled: config.maintenanceNamespaceFanoutEnabled !== false,
|
|
2138
|
+
namespacesEnabled: config.namespacesEnabled,
|
|
2139
|
+
maxNamespacesPerCycle: config.maintenanceMaxNamespacesPerCycle,
|
|
2140
|
+
jobs,
|
|
2141
|
+
totalRan,
|
|
2142
|
+
totalSkipped,
|
|
2143
|
+
totalFailed
|
|
2144
|
+
};
|
|
2145
|
+
}
|
|
2146
|
+
function formatNamespaceMaintenanceHealthText(summary) {
|
|
2147
|
+
const lines = [
|
|
2148
|
+
"=== Namespace Maintenance ===",
|
|
2149
|
+
"",
|
|
2150
|
+
` fanout: ${summary.fanoutEnabled ? "enabled" : "disabled"}`,
|
|
2151
|
+
` namespaces: ${summary.namespacesEnabled ? "enabled" : "disabled"}`,
|
|
2152
|
+
` max/cycle: ${summary.maxNamespacesPerCycle}`,
|
|
2153
|
+
` total ran: ${summary.totalRan}`,
|
|
2154
|
+
` total skipped: ${summary.totalSkipped}`,
|
|
2155
|
+
` total failed: ${summary.totalFailed}`,
|
|
2156
|
+
""
|
|
2157
|
+
];
|
|
2158
|
+
if (summary.totalRan === 0 && summary.totalSkipped === 0 && summary.totalFailed === 0) {
|
|
2159
|
+
lines.push(" (no maintenance status recorded yet)");
|
|
2160
|
+
return lines.join("\n");
|
|
2161
|
+
}
|
|
2162
|
+
lines.push(" Per-job breakdown:");
|
|
2163
|
+
for (const job of summary.jobs) {
|
|
2164
|
+
const parts = [
|
|
2165
|
+
`ran=${job.ran}`,
|
|
2166
|
+
`skipped=${job.skipped}`,
|
|
2167
|
+
`failed=${job.failed}`
|
|
2168
|
+
];
|
|
2169
|
+
if (job.lastRunAt) {
|
|
2170
|
+
parts.push(`last=${job.lastRunAt}`);
|
|
2171
|
+
}
|
|
2172
|
+
lines.push(` ${job.jobName}: ${parts.join(", ")}`);
|
|
2173
|
+
const failedNs = job.namespaces.filter((n) => n.state === "failed");
|
|
2174
|
+
for (const ns of failedNs) {
|
|
2175
|
+
const detail = ns.error ? `: ${ns.error}` : "";
|
|
2176
|
+
lines.push(` ! ${ns.namespace} failed (reason=${ns.reason ?? "unknown"})${detail}`);
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
return lines.join("\n");
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
export {
|
|
2183
|
+
NamespaceCatalog,
|
|
2184
|
+
planNamespaceMaintenance,
|
|
2185
|
+
runNamespaceMaintenanceBatchPlan,
|
|
2186
|
+
runNamespaceMaintenanceFanout,
|
|
2187
|
+
summarizeNamespaceMaintenanceHealth,
|
|
2188
|
+
formatNamespaceMaintenanceHealthText
|
|
2189
|
+
};
|
|
2190
|
+
//# sourceMappingURL=chunk-ZU7N3S6V.js.map
|