@sema-agent/core 5.22.0 → 5.24.0
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/CHANGELOG.md +169 -1
- package/dist/agents/subagent.js +3 -2
- package/dist/core/checkpoint-store.d.ts +38 -3
- package/dist/core/checkpoint-store.js +2 -1
- package/dist/core/governance-codes.js +3 -0
- package/dist/core/hooks.d.ts +69 -2
- package/dist/core/hooks.js +100 -15
- package/dist/core/memory-engine/engine.d.ts +28 -1
- package/dist/core/memory-engine/engine.js +62 -3
- package/dist/core/memory-engine/index.d.ts +1 -1
- package/dist/core/memory-engine/index.js +1 -1
- package/dist/core/memory-engine/layout.d.ts +69 -3
- package/dist/core/memory-engine/layout.js +75 -6
- package/dist/core/permission-rule-consent.js +2 -1
- package/dist/core/permission-rule-org.d.ts +36 -2
- package/dist/core/permission-rule-org.js +23 -0
- package/dist/core/permission-rule-store.d.ts +25 -14
- package/dist/core/permission-rule-store.js +7 -2
- package/dist/core/permission-rule-sync.d.ts +8 -0
- package/dist/core/permission-rule-sync.js +35 -6
- package/dist/core/runner/prepare-task.d.ts +10 -2
- package/dist/core/runner/prepare-task.js +120 -11
- package/dist/core/runner/runtask.js +46 -1
- package/dist/core/runner/session-file-state-replay.js +3 -0
- package/dist/core/tool-policy.d.ts +37 -4
- package/dist/core/tool-policy.js +49 -19
- package/dist/core/tool-result-store.d.ts +17 -1
- package/dist/core/tool-result-store.js +79 -4
- package/dist/core/trace.d.ts +47 -0
- package/dist/core/types.d.ts +45 -5
- package/dist/core/wiring-manifest.d.ts +16 -1
- package/dist/core/wiring-manifest.js +7 -1
- package/dist/index.d.ts +18 -10
- package/dist/index.js +5 -3
- package/dist/orchestration/goal.d.ts +10 -0
- package/dist/orchestration/goal.js +6 -5
- package/dist/stores/file/adoption/adopt.d.ts +146 -0
- package/dist/stores/file/adoption/adopt.js +611 -0
- package/dist/stores/file/adoption/marker.d.ts +202 -0
- package/dist/stores/file/adoption/marker.js +205 -0
- package/dist/stores/file/background-agent-store.js +2 -0
- package/dist/stores/file/checkpoint-store.js +2 -0
- package/dist/stores/file/file-snapshot-store.js +2 -0
- package/dist/stores/file/index.d.ts +2 -0
- package/dist/stores/file/index.js +4 -0
- package/dist/stores/file/mailbox-store.js +2 -0
- package/dist/stores/file/memory-store.js +2 -0
- package/dist/stores/file/session-policy-store.d.ts +11 -1
- package/dist/stores/file/session-policy-store.js +9 -2
- package/dist/stores/file/session-store.js +2 -0
- package/dist/stores/file/task-list-store.js +2 -0
- package/dist/stores/file/tool-result-store.js +2 -0
- package/dist/stores/file/usage-window-store.js +2 -0
- package/dist/stores/file/workflow-journal-store.js +2 -0
- package/dist/stores/file/workflow-run-store.js +2 -0
- package/dist/tools/fs/bash-readonly-classifier.js +59 -10
- package/dist/tools/fs/fs-bash.js +7 -4
- package/dist/tools/monitor.js +3 -3
- package/package.json +3 -2
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { existsSync, readdirSync, readFileSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { adoptFilePermissionRuleStore } from "../permission-rule-adopt.js";
|
|
5
|
+
import { BootLock, atomicWriteFile, ensureDir, sanitizeScope } from "../fs-atomic.js";
|
|
6
|
+
import { AdoptionError, NOT_MIGRATED_BY_DESIGN, readRootAdoptionFile, writeRootAdoptionFile, } from "./marker.js";
|
|
7
|
+
const CONFIG_ACCOUNT_FILE = "adoption-configs.json";
|
|
8
|
+
function baselineConfigs(toPrincipal) {
|
|
9
|
+
return [
|
|
10
|
+
{ deployment: "web-client-bff", key: "injected-principal", requiredValue: toPrincipal, migrated: false },
|
|
11
|
+
{ deployment: "cli", key: "cloud-credentials+profiles", requiredValue: toPrincipal, migrated: false },
|
|
12
|
+
{ deployment: "cli", key: "SEMA_LIVE_PRINCIPAL", requiredValue: toPrincipal, migrated: false },
|
|
13
|
+
{ deployment: "env", key: "REQUIRE_PRINCIPAL", requiredValue: "true", migrated: false },
|
|
14
|
+
{ deployment: "core-decl", key: "localOwnerRules", requiredValue: "withdrawn", migrated: false },
|
|
15
|
+
];
|
|
16
|
+
}
|
|
17
|
+
function readConfigAccount(root) {
|
|
18
|
+
let raw;
|
|
19
|
+
try {
|
|
20
|
+
raw = readFileSync(join(root, CONFIG_ACCOUNT_FILE), "utf8");
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
if (err.code === "ENOENT")
|
|
24
|
+
return undefined;
|
|
25
|
+
throw err;
|
|
26
|
+
}
|
|
27
|
+
let parsed;
|
|
28
|
+
try {
|
|
29
|
+
parsed = JSON.parse(raw);
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
throw new AdoptionError("adoption_marker_corrupt", `the adoption config account in ${root} is not valid JSON (${err.message})`);
|
|
33
|
+
}
|
|
34
|
+
if (!isConfigAccountShape(parsed)) {
|
|
35
|
+
throw new AdoptionError("adoption_marker_corrupt", `the adoption config account in ${root} does not carry a readable shape`);
|
|
36
|
+
}
|
|
37
|
+
return parsed;
|
|
38
|
+
}
|
|
39
|
+
function readConfigAccountEvidence(root) {
|
|
40
|
+
try {
|
|
41
|
+
return readConfigAccount(root);
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
if (err instanceof AdoptionError)
|
|
45
|
+
return undefined;
|
|
46
|
+
throw err;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function isConfigAccountShape(v) {
|
|
50
|
+
if (v === null || typeof v !== "object")
|
|
51
|
+
return false;
|
|
52
|
+
const f = v;
|
|
53
|
+
if (f.schemaVersion !== 1 || typeof f.adoptionId !== "string" || !Array.isArray(f.configs))
|
|
54
|
+
return false;
|
|
55
|
+
const finite = (n) => typeof n === "number" && Number.isFinite(n);
|
|
56
|
+
return f.configs.every((c) => {
|
|
57
|
+
if (c === null || typeof c !== "object")
|
|
58
|
+
return false;
|
|
59
|
+
const e = c;
|
|
60
|
+
if (typeof e.deployment !== "string" || typeof e.key !== "string" || typeof e.requiredValue !== "string" || typeof e.migrated !== "boolean") {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
if (e.ack !== undefined) {
|
|
64
|
+
if (e.ack === null || typeof e.ack !== "object" || !finite(e.ack.atMs))
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
if (e.witnessed !== undefined) {
|
|
68
|
+
if (e.witnessed === null || typeof e.witnessed !== "object")
|
|
69
|
+
return false;
|
|
70
|
+
const w = e.witnessed;
|
|
71
|
+
if (!finite(w.atMs) || w.receipt === null || typeof w.receipt !== "object")
|
|
72
|
+
return false;
|
|
73
|
+
const r = w.receipt;
|
|
74
|
+
if (r.adoptionId !== f.adoptionId || r.deployment !== e.deployment || r.key !== e.key || r.observedValue !== e.requiredValue || !finite(r.atMs)) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return true;
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
function writeConfigAccount(root, account) {
|
|
82
|
+
atomicWriteFile(join(root, "tmp"), join(root, CONFIG_ACCOUNT_FILE), JSON.stringify(account, null, 2));
|
|
83
|
+
}
|
|
84
|
+
function configIdentity(c) {
|
|
85
|
+
return JSON.stringify([c.deployment, c.key]);
|
|
86
|
+
}
|
|
87
|
+
function upsertConfigAccount(root, adoptionId, toPrincipal, additional) {
|
|
88
|
+
const existing = readConfigAccount(root);
|
|
89
|
+
if (existing !== undefined && existing.adoptionId === adoptionId) {
|
|
90
|
+
const known = new Set(existing.configs.map(configIdentity));
|
|
91
|
+
let grew = false;
|
|
92
|
+
for (const c of additional) {
|
|
93
|
+
if (!known.has(configIdentity(c))) {
|
|
94
|
+
existing.configs.push({ ...c });
|
|
95
|
+
known.add(configIdentity(c));
|
|
96
|
+
grew = true;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (grew)
|
|
100
|
+
writeConfigAccount(root, existing);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const merged = new Map();
|
|
104
|
+
for (const c of baselineConfigs(toPrincipal))
|
|
105
|
+
merged.set(configIdentity(c), c);
|
|
106
|
+
for (const c of additional)
|
|
107
|
+
merged.set(configIdentity(c), c);
|
|
108
|
+
writeConfigAccount(root, { schemaVersion: 1, adoptionId, configs: [...merged.values()] });
|
|
109
|
+
}
|
|
110
|
+
function outstandingOf(account) {
|
|
111
|
+
if (account === undefined)
|
|
112
|
+
return [];
|
|
113
|
+
return account.configs.filter((c) => c.witnessed === undefined).map((c) => `${c.deployment}:${c.key}`);
|
|
114
|
+
}
|
|
115
|
+
export function ackAdoptionConfig(root, deployment, key, now) {
|
|
116
|
+
const account = readConfigAccount(root);
|
|
117
|
+
const entry = account?.configs.find((c) => c.deployment === deployment && c.key === key);
|
|
118
|
+
if (account === undefined || entry === undefined) {
|
|
119
|
+
throw new AdoptionError("adoption_witness_mismatch", `no adoption config account entry for ${deployment}:${key} in ${root}`);
|
|
120
|
+
}
|
|
121
|
+
entry.ack = { atMs: (now ?? Date.now)() };
|
|
122
|
+
writeConfigAccount(root, account);
|
|
123
|
+
}
|
|
124
|
+
export function witnessAdoptionConfig(root, receipt) {
|
|
125
|
+
const account = readConfigAccount(root);
|
|
126
|
+
const entry = account?.configs.find((c) => c.deployment === receipt.deployment && c.key === receipt.key);
|
|
127
|
+
if (account === undefined || entry === undefined) {
|
|
128
|
+
throw new AdoptionError("adoption_witness_mismatch", `no adoption config account entry for ${receipt.deployment}:${receipt.key} in ${root}`);
|
|
129
|
+
}
|
|
130
|
+
if (receipt.adoptionId !== account.adoptionId) {
|
|
131
|
+
throw new AdoptionError("adoption_witness_mismatch", `the witness receipt names adoption "${receipt.adoptionId}" but this root's adoption is "${account.adoptionId}" — a witness binds to the adoption it observed`);
|
|
132
|
+
}
|
|
133
|
+
if (typeof receipt.atMs !== "number" || !Number.isFinite(receipt.atMs)) {
|
|
134
|
+
throw new AdoptionError("adoption_witness_mismatch", `the witness receipt carries an invalid timestamp (${String(receipt.atMs)}) — refusing to record evidence the account reader would reject`);
|
|
135
|
+
}
|
|
136
|
+
if (receipt.observedValue !== entry.requiredValue) {
|
|
137
|
+
throw new AdoptionError("adoption_witness_mismatch", `the witness for ${receipt.deployment}:${receipt.key} observed "${receipt.observedValue}" but the adoption requires "${entry.requiredValue}" — refusing to clear the account on a value that would leave the consumer locked out`);
|
|
138
|
+
}
|
|
139
|
+
entry.witnessed = { atMs: receipt.atMs, receipt };
|
|
140
|
+
writeConfigAccount(root, account);
|
|
141
|
+
}
|
|
142
|
+
const QUARANTINE_DIR = "adoption-quarantine";
|
|
143
|
+
export function listAdoptionQuarantine(root) {
|
|
144
|
+
const base = join(root, QUARANTINE_DIR);
|
|
145
|
+
let stores;
|
|
146
|
+
try {
|
|
147
|
+
stores = readdirSync(base);
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
if (err.code === "ENOENT")
|
|
151
|
+
return [];
|
|
152
|
+
throw err;
|
|
153
|
+
}
|
|
154
|
+
const out = [];
|
|
155
|
+
for (const store of stores) {
|
|
156
|
+
let names;
|
|
157
|
+
try {
|
|
158
|
+
names = readdirSync(join(base, store));
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
for (const name of names)
|
|
164
|
+
out.push({ store, name, path: join(base, store, name) });
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
function quarantineRow(root, store, name, bytes) {
|
|
169
|
+
const dir = join(root, QUARANTINE_DIR, sanitizeScope(store));
|
|
170
|
+
ensureDir(dir);
|
|
171
|
+
const base = sanitizeScope(name);
|
|
172
|
+
let target = join(dir, base);
|
|
173
|
+
for (let i = 2; existsSync(target); i += 1) {
|
|
174
|
+
if (readFileSync(target, "utf8") === bytes)
|
|
175
|
+
return;
|
|
176
|
+
target = join(dir, `${base}.${i}`);
|
|
177
|
+
}
|
|
178
|
+
atomicWriteFile(join(root, "tmp"), target, bytes);
|
|
179
|
+
}
|
|
180
|
+
function rewriteSessionPolicyRows(root, fromPrincipal, toPrincipal) {
|
|
181
|
+
const dir = join(root, "session-policy");
|
|
182
|
+
let files;
|
|
183
|
+
try {
|
|
184
|
+
files = readdirSync(dir);
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
if (err.code === "ENOENT")
|
|
188
|
+
return 0;
|
|
189
|
+
throw err;
|
|
190
|
+
}
|
|
191
|
+
const tmpDir = join(dir, "tmp");
|
|
192
|
+
let rows = 0;
|
|
193
|
+
for (const file of files) {
|
|
194
|
+
if (!file.endsWith(".json"))
|
|
195
|
+
continue;
|
|
196
|
+
const source = join(dir, file);
|
|
197
|
+
let raw;
|
|
198
|
+
try {
|
|
199
|
+
raw = readFileSync(source, "utf8");
|
|
200
|
+
}
|
|
201
|
+
catch (err) {
|
|
202
|
+
if (err.code === "ENOENT")
|
|
203
|
+
continue;
|
|
204
|
+
throw err;
|
|
205
|
+
}
|
|
206
|
+
let parsed;
|
|
207
|
+
try {
|
|
208
|
+
parsed = JSON.parse(raw);
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
214
|
+
continue;
|
|
215
|
+
const row = parsed;
|
|
216
|
+
if (row.__principal !== fromPrincipal)
|
|
217
|
+
continue;
|
|
218
|
+
if (typeof row.__sid !== "string")
|
|
219
|
+
continue;
|
|
220
|
+
const rewritten = JSON.stringify({ ...row, __principal: toPrincipal });
|
|
221
|
+
const target = join(dir, `${sanitizeScope(JSON.stringify([row.__sid, toPrincipal]))}.json`);
|
|
222
|
+
if (existsSync(target)) {
|
|
223
|
+
if (readFileSync(target, "utf8") === rewritten) {
|
|
224
|
+
removeIfPresent(source);
|
|
225
|
+
rows += 1;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
throw new AdoptionError("adoption_row_collision", `a session-policy row for session "${row.__sid}" already exists under "${toPrincipal}" with different content — ` +
|
|
229
|
+
`merging two existing rows is a sync join, not a rewrite; the adoption marker is left in place for inspection`);
|
|
230
|
+
}
|
|
231
|
+
atomicWriteFile(tmpDir, target, rewritten);
|
|
232
|
+
removeIfPresent(source);
|
|
233
|
+
rows += 1;
|
|
234
|
+
}
|
|
235
|
+
return rows;
|
|
236
|
+
}
|
|
237
|
+
function removeIfPresent(path) {
|
|
238
|
+
try {
|
|
239
|
+
unlinkSync(path);
|
|
240
|
+
}
|
|
241
|
+
catch (err) {
|
|
242
|
+
if (err.code !== "ENOENT")
|
|
243
|
+
throw err;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const ZERO_ACTION_STORES = [
|
|
247
|
+
"session",
|
|
248
|
+
"checkpoint",
|
|
249
|
+
"tool-result",
|
|
250
|
+
"file-snapshot",
|
|
251
|
+
"memory",
|
|
252
|
+
"task-list",
|
|
253
|
+
"mailbox",
|
|
254
|
+
"workflow-run",
|
|
255
|
+
"workflow-journal",
|
|
256
|
+
"background-agent",
|
|
257
|
+
];
|
|
258
|
+
const SESSION_POLICY_LEG = "session-policy";
|
|
259
|
+
const CARRIAGE_BIT_PREFIX = "carriage:";
|
|
260
|
+
function sameSource(a, b) {
|
|
261
|
+
return a.kind === "principal" ? b.kind === "principal" && a.principal === b.principal : b.kind === "local-owner";
|
|
262
|
+
}
|
|
263
|
+
function sourceText(o) {
|
|
264
|
+
return o.kind === "principal" ? `principal "${o.principal}"` : "the local owner";
|
|
265
|
+
}
|
|
266
|
+
function receiptFromTerminal(root, adopted) {
|
|
267
|
+
const account = readConfigAccountEvidence(root);
|
|
268
|
+
const accountBinds = account !== undefined && account.adoptionId === adopted.immutableReport.adoptionId;
|
|
269
|
+
return {
|
|
270
|
+
status: "adopted",
|
|
271
|
+
immutableReport: adopted.immutableReport,
|
|
272
|
+
current: {
|
|
273
|
+
outstandingConfigs: accountBinds
|
|
274
|
+
? outstandingOf(account)
|
|
275
|
+
: adopted.immutableReport.affectedDeploymentConfigs.map((c) => `${c.deployment}:${c.key}`),
|
|
276
|
+
quarantined: listAdoptionQuarantine(root).length,
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
function rebuildConfigAccountIfUnbound(root, report) {
|
|
281
|
+
const account = readConfigAccountEvidence(root);
|
|
282
|
+
if (account !== undefined && account.adoptionId === report.adoptionId)
|
|
283
|
+
return;
|
|
284
|
+
writeConfigAccount(root, {
|
|
285
|
+
schemaVersion: 1,
|
|
286
|
+
adoptionId: report.adoptionId,
|
|
287
|
+
configs: report.affectedDeploymentConfigs.map((c) => ({ ...c })),
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
export function readAdoptionStatus(root) {
|
|
291
|
+
const f = readRootAdoptionFile(root);
|
|
292
|
+
if (f === undefined)
|
|
293
|
+
return { state: "none" };
|
|
294
|
+
if ("marker" in f) {
|
|
295
|
+
return { state: "in-flight", from: f.marker.from, toPrincipal: f.marker.toPrincipal, phase: f.marker.phase };
|
|
296
|
+
}
|
|
297
|
+
return { state: "adopted", receipt: receiptFromTerminal(root, f.adopted) };
|
|
298
|
+
}
|
|
299
|
+
function answerFromExisting(root, existing, from, toPrincipal) {
|
|
300
|
+
if (existing === undefined)
|
|
301
|
+
return undefined;
|
|
302
|
+
if ("adopted" in existing) {
|
|
303
|
+
if (existing.adopted.toPrincipal === toPrincipal && sameSource(existing.adopted.from, from)) {
|
|
304
|
+
return receiptFromTerminal(root, existing.adopted);
|
|
305
|
+
}
|
|
306
|
+
throw new AdoptionError("adoption_retarget", `this data root already carries a completed adoption (${sourceText(existing.adopted.from)} → "${existing.adopted.toPrincipal}") — ` +
|
|
307
|
+
`a second adoption of the same root is a multi-tenant transfer, not a shape this protocol supports`);
|
|
308
|
+
}
|
|
309
|
+
if (existing.marker.toPrincipal !== toPrincipal || !sameSource(existing.marker.from, from)) {
|
|
310
|
+
throw new AdoptionError("adoption_in_flight_mismatch", `an adoption toward "${existing.marker.toPrincipal}" is already in flight in this data root (phase ${existing.marker.phase}) — ` +
|
|
311
|
+
`refusing to start a different one (resume the original, or repair the marker by hand)`);
|
|
312
|
+
}
|
|
313
|
+
return undefined;
|
|
314
|
+
}
|
|
315
|
+
export async function adoptLocalDataRoot(opts) {
|
|
316
|
+
if (typeof opts.toPrincipal !== "string" || opts.toPrincipal === "") {
|
|
317
|
+
throw new AdoptionError("adoption_bad_request", "adoptLocalDataRoot requires the adopting principal — adoption is the act of giving this data root an identity");
|
|
318
|
+
}
|
|
319
|
+
if (opts.from.kind === "principal" && (typeof opts.from.principal !== "string" || opts.from.principal === "")) {
|
|
320
|
+
throw new AdoptionError("adoption_bad_request", "a principal-form source requires a non-empty principal — local-single-user identity is the local-owner kind, never an empty string");
|
|
321
|
+
}
|
|
322
|
+
if (opts.from.kind === "principal" && opts.from.principal === opts.toPrincipal) {
|
|
323
|
+
throw new AdoptionError("adoption_bad_request", `the data root already belongs to "${opts.toPrincipal}" — there is nothing to adopt`);
|
|
324
|
+
}
|
|
325
|
+
if (typeof opts.credentials.ref !== "string" || opts.credentials.ref === "") {
|
|
326
|
+
throw new AdoptionError("adoption_bad_request", "the credentials claim ref must be a non-empty reference (the claim is what makes the new identity redeemable)");
|
|
327
|
+
}
|
|
328
|
+
if (opts.from === null || typeof opts.from !== "object" || opts.credentials === null || typeof opts.credentials !== "object") {
|
|
329
|
+
throw new AdoptionError("adoption_bad_request", "adoptLocalDataRoot requires `from` and `credentials` objects");
|
|
330
|
+
}
|
|
331
|
+
if (opts.additionalConfigs !== undefined && !Array.isArray(opts.additionalConfigs)) {
|
|
332
|
+
throw new AdoptionError("adoption_bad_request", "additionalConfigs must be an array when supplied");
|
|
333
|
+
}
|
|
334
|
+
if (opts.carriage !== undefined && !Array.isArray(opts.carriage)) {
|
|
335
|
+
throw new AdoptionError("adoption_bad_request", "carriage must be an array when supplied");
|
|
336
|
+
}
|
|
337
|
+
if (opts.rules !== undefined && (opts.rules === null || typeof opts.rules !== "object")) {
|
|
338
|
+
throw new AdoptionError("adoption_bad_request", "rules must be an object when supplied");
|
|
339
|
+
}
|
|
340
|
+
if (opts.now !== undefined && typeof opts.now !== "function") {
|
|
341
|
+
throw new AdoptionError("adoption_bad_request", "`now` must be callable when supplied — it publishes the terminal timestamp");
|
|
342
|
+
}
|
|
343
|
+
if (opts.onError !== undefined && typeof opts.onError !== "function") {
|
|
344
|
+
throw new AdoptionError("adoption_bad_request", "`onError` must be callable when supplied");
|
|
345
|
+
}
|
|
346
|
+
if (opts.from.kind !== "local-owner" && opts.from.kind !== "principal") {
|
|
347
|
+
throw new AdoptionError("adoption_bad_request", "the adoption source must be the local-owner kind or a named principal");
|
|
348
|
+
}
|
|
349
|
+
if (opts.credentials.issuedBy !== "server") {
|
|
350
|
+
throw new AdoptionError("adoption_bad_request", `the credentials claim must be server-issued (got issuedBy: ${JSON.stringify(opts.credentials.issuedBy)}) — the server mints the new identity`);
|
|
351
|
+
}
|
|
352
|
+
for (const c of opts.additionalConfigs ?? []) {
|
|
353
|
+
if (c === null ||
|
|
354
|
+
typeof c !== "object" ||
|
|
355
|
+
typeof c.deployment !== "string" ||
|
|
356
|
+
c.deployment === "" ||
|
|
357
|
+
typeof c.key !== "string" ||
|
|
358
|
+
c.key === "" ||
|
|
359
|
+
typeof c.requiredValue !== "string" ||
|
|
360
|
+
typeof c.migrated !== "boolean") {
|
|
361
|
+
throw new AdoptionError("adoption_bad_request", `a malformed additionalConfigs entry (${JSON.stringify(c)}) — each entry is { deployment, key, requiredValue: strings; migrated: boolean }`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (opts.rules !== undefined && (typeof opts.rules.dir !== "string" || opts.rules.dir === "" || typeof opts.rules.transport !== "function")) {
|
|
365
|
+
throw new AdoptionError("adoption_bad_request", "a rules leg needs a non-empty bucket directory and a transport function");
|
|
366
|
+
}
|
|
367
|
+
{
|
|
368
|
+
const seen = new Set();
|
|
369
|
+
for (const leg of opts.carriage ?? []) {
|
|
370
|
+
if (leg === null || typeof leg !== "object" || typeof leg.store !== "string" || leg.store === "" || typeof leg.run !== "function") {
|
|
371
|
+
throw new AdoptionError("adoption_bad_request", "a carriage leg is { store: non-empty string; run: function }");
|
|
372
|
+
}
|
|
373
|
+
if (seen.has(leg.store)) {
|
|
374
|
+
throw new AdoptionError("adoption_bad_request", `two carriage legs share the store name "${leg.store}" — a leg's plan identity is its store family, one leg per store`);
|
|
375
|
+
}
|
|
376
|
+
seen.add(leg.store);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
ensureDir(opts.root);
|
|
380
|
+
const pre = answerFromExisting(opts.root, readRootAdoptionFile(opts.root), opts.from, opts.toPrincipal);
|
|
381
|
+
if (pre !== undefined) {
|
|
382
|
+
const account = readConfigAccountEvidence(opts.root);
|
|
383
|
+
if (account !== undefined && account.adoptionId === pre.immutableReport.adoptionId)
|
|
384
|
+
return pre;
|
|
385
|
+
}
|
|
386
|
+
const engineLock = new BootLock(join(opts.root, "LOCK"));
|
|
387
|
+
try {
|
|
388
|
+
engineLock.acquire();
|
|
389
|
+
}
|
|
390
|
+
catch (err) {
|
|
391
|
+
throw new AdoptionError("adoption_locked", `a live instance owns this data root — stop the engine before adopting: ${err.message}`);
|
|
392
|
+
}
|
|
393
|
+
const adoptionLock = new BootLock(join(opts.root, "ADOPTION-LOCK"));
|
|
394
|
+
try {
|
|
395
|
+
adoptionLock.acquire();
|
|
396
|
+
}
|
|
397
|
+
catch (err) {
|
|
398
|
+
engineLock.release();
|
|
399
|
+
throw new AdoptionError("adoption_locked", `another adoption owns this data root: ${err.message}`);
|
|
400
|
+
}
|
|
401
|
+
try {
|
|
402
|
+
const existing = readRootAdoptionFile(opts.root);
|
|
403
|
+
const answered = answerFromExisting(opts.root, existing, opts.from, opts.toPrincipal);
|
|
404
|
+
if (answered !== undefined) {
|
|
405
|
+
rebuildConfigAccountIfUnbound(opts.root, answered.immutableReport);
|
|
406
|
+
return receiptFromTerminal(opts.root, {
|
|
407
|
+
from: answered.immutableReport.from,
|
|
408
|
+
toPrincipal: answered.immutableReport.toPrincipal,
|
|
409
|
+
atMs: answered.immutableReport.atMs,
|
|
410
|
+
immutableReport: answered.immutableReport,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
const now = opts.now ?? Date.now;
|
|
414
|
+
const offeredRulesDir = opts.rules !== undefined ? resolve(opts.rules.dir) : undefined;
|
|
415
|
+
const offeredCarriage = (opts.carriage ?? []).map((l) => l.store);
|
|
416
|
+
let phase;
|
|
417
|
+
let adoptionId;
|
|
418
|
+
let legBits;
|
|
419
|
+
let plan;
|
|
420
|
+
if (existing !== undefined && "marker" in existing) {
|
|
421
|
+
phase = existing.marker.phase;
|
|
422
|
+
adoptionId = existing.marker.adoptionId;
|
|
423
|
+
legBits = existing.marker.legs ?? {};
|
|
424
|
+
plan = existing.marker.plan;
|
|
425
|
+
const missing = [];
|
|
426
|
+
if (plan.rulesDir !== undefined && offeredRulesDir !== plan.rulesDir) {
|
|
427
|
+
missing.push(`the permission-rule leg over "${plan.rulesDir}"${offeredRulesDir !== undefined ? ` (offered "${offeredRulesDir}")` : ""}`);
|
|
428
|
+
}
|
|
429
|
+
const offered = new Set(offeredCarriage);
|
|
430
|
+
for (const store of plan.carriage)
|
|
431
|
+
if (!offered.has(store))
|
|
432
|
+
missing.push(`the "${store}" carriage leg`);
|
|
433
|
+
if (plan.credentialsRef !== opts.credentials.ref)
|
|
434
|
+
missing.push(`the credentials claim (recorded "${plan.credentialsRef}")`);
|
|
435
|
+
if (missing.length > 0) {
|
|
436
|
+
throw new AdoptionError("adoption_plan_mismatch", `this resume supplies less than the adoption's recorded plan — missing: ${missing.join("; ")}. ` +
|
|
437
|
+
`Re-run with the original legs (growing the plan is fine, shrinking it is not), or repair the marker by hand.`);
|
|
438
|
+
}
|
|
439
|
+
const lateAdds = [];
|
|
440
|
+
if (plan.rulesDir === undefined && offeredRulesDir !== undefined) {
|
|
441
|
+
if (phase < 3)
|
|
442
|
+
plan = { ...plan, rulesDir: offeredRulesDir };
|
|
443
|
+
else
|
|
444
|
+
lateAdds.push(`the permission-rule leg (stage ③ already completed at phase ${phase})`);
|
|
445
|
+
}
|
|
446
|
+
const knownCarriage = new Set(plan.carriage);
|
|
447
|
+
const newCarriage = offeredCarriage.filter((s) => !knownCarriage.has(s));
|
|
448
|
+
if (newCarriage.length > 0) {
|
|
449
|
+
if (phase < 5)
|
|
450
|
+
plan = { ...plan, carriage: [...plan.carriage, ...newCarriage] };
|
|
451
|
+
else
|
|
452
|
+
lateAdds.push(`the ${newCarriage.map((s) => `"${s}"`).join("/")} carriage leg(s) (stage ⑤ already completed)`);
|
|
453
|
+
}
|
|
454
|
+
if (lateAdds.length > 0) {
|
|
455
|
+
throw new AdoptionError("adoption_plan_mismatch", `this resume offers legs whose stage has already completed — ${lateAdds.join("; ")}. ` +
|
|
456
|
+
`They would never run, and reporting them as done would be a lie; post-adoption carriage rides each store's normal channel.`);
|
|
457
|
+
}
|
|
458
|
+
if (plan !== existing.marker.plan) {
|
|
459
|
+
publishMarker(opts.root, adoptionId, opts.from, opts.toPrincipal, phase, plan, legBits);
|
|
460
|
+
}
|
|
461
|
+
const accountInFlight = readConfigAccount(opts.root);
|
|
462
|
+
if (accountInFlight === undefined || accountInFlight.adoptionId !== adoptionId) {
|
|
463
|
+
throw new AdoptionError("adoption_marker_corrupt", `the adoption config account is ${accountInFlight === undefined ? "missing" : "bound to a different adoption"} while an adoption is in flight — ` +
|
|
464
|
+
`it lands before the marker and never disappears mid-arc; restore it before resuming`);
|
|
465
|
+
}
|
|
466
|
+
upsertConfigAccount(opts.root, adoptionId, opts.toPrincipal, opts.additionalConfigs ?? []);
|
|
467
|
+
}
|
|
468
|
+
else {
|
|
469
|
+
adoptionId = `adp_${randomBytes(8).toString("hex")}`;
|
|
470
|
+
phase = 2;
|
|
471
|
+
legBits = {};
|
|
472
|
+
plan = { ...(offeredRulesDir !== undefined ? { rulesDir: offeredRulesDir } : {}), carriage: offeredCarriage, credentialsRef: opts.credentials.ref };
|
|
473
|
+
upsertConfigAccount(opts.root, adoptionId, opts.toPrincipal, opts.additionalConfigs ?? []);
|
|
474
|
+
publishMarker(opts.root, adoptionId, opts.from, opts.toPrincipal, 2, plan, legBits);
|
|
475
|
+
}
|
|
476
|
+
const legs = [];
|
|
477
|
+
if (phase < 3) {
|
|
478
|
+
if (opts.rules !== undefined) {
|
|
479
|
+
let nested;
|
|
480
|
+
try {
|
|
481
|
+
nested = await adoptFilePermissionRuleStore({
|
|
482
|
+
dir: opts.rules.dir,
|
|
483
|
+
from: opts.from,
|
|
484
|
+
toPrincipal: opts.toPrincipal,
|
|
485
|
+
transport: opts.rules.transport,
|
|
486
|
+
...(opts.now !== undefined ? { now: opts.now } : {}),
|
|
487
|
+
...(opts.onError !== undefined ? { onError: opts.onError } : {}),
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
catch (err) {
|
|
491
|
+
if (err instanceof AdoptionError)
|
|
492
|
+
throw err;
|
|
493
|
+
throw new AdoptionError("adoption_rule_leg_refused", `the permission-rule leg refused: ${err instanceof Error ? err.message : String(err)}`);
|
|
494
|
+
}
|
|
495
|
+
if (nested.status === "stalled") {
|
|
496
|
+
return {
|
|
497
|
+
status: "stalled",
|
|
498
|
+
phase: 2,
|
|
499
|
+
error: `the permission-rule leg stalled (its own marker holds phase ${nested.phase}): ${nested.error}`,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
if (legBits[SESSION_POLICY_LEG]?.done !== true) {
|
|
504
|
+
const rows = rewriteSessionPolicyRows(opts.root, opts.from.kind === "principal" ? opts.from.principal : null, opts.toPrincipal);
|
|
505
|
+
legBits = { ...legBits, [SESSION_POLICY_LEG]: { done: true, rows } };
|
|
506
|
+
publishMarker(opts.root, adoptionId, opts.from, opts.toPrincipal, 2, plan, legBits);
|
|
507
|
+
}
|
|
508
|
+
publishMarker(opts.root, adoptionId, opts.from, opts.toPrincipal, 3, plan, legBits);
|
|
509
|
+
phase = 3;
|
|
510
|
+
}
|
|
511
|
+
if (phase < 4) {
|
|
512
|
+
publishMarker(opts.root, adoptionId, opts.from, opts.toPrincipal, 4, plan, legBits);
|
|
513
|
+
phase = 4;
|
|
514
|
+
}
|
|
515
|
+
if (phase < 5) {
|
|
516
|
+
for (const leg of opts.carriage ?? []) {
|
|
517
|
+
if (legBits[`${CARRIAGE_BIT_PREFIX}${leg.store}`]?.done === true)
|
|
518
|
+
continue;
|
|
519
|
+
let quarantined = 0;
|
|
520
|
+
const ctx = {
|
|
521
|
+
quarantine: (name, bytes) => {
|
|
522
|
+
quarantineRow(opts.root, leg.store, name, bytes);
|
|
523
|
+
quarantined += 1;
|
|
524
|
+
},
|
|
525
|
+
};
|
|
526
|
+
let rows;
|
|
527
|
+
try {
|
|
528
|
+
const res = await leg.run(ctx);
|
|
529
|
+
rows = res === undefined ? undefined : res.rows;
|
|
530
|
+
}
|
|
531
|
+
catch (err) {
|
|
532
|
+
return {
|
|
533
|
+
status: "stalled",
|
|
534
|
+
phase: 4,
|
|
535
|
+
error: `the "${leg.store}" carriage leg failed: ${err instanceof Error ? err.message : String(err)} — resume when it is healthy`,
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
if (rows !== undefined && (!Number.isSafeInteger(rows) || rows < 0)) {
|
|
539
|
+
return {
|
|
540
|
+
status: "stalled",
|
|
541
|
+
phase: 4,
|
|
542
|
+
error: `the "${leg.store}" carriage leg reported an invalid row count (${String(rows)}) — a leg reports a non-negative integer or nothing; fix the leg and resume`,
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
legBits = {
|
|
546
|
+
...legBits,
|
|
547
|
+
[`${CARRIAGE_BIT_PREFIX}${leg.store}`]: {
|
|
548
|
+
done: true,
|
|
549
|
+
...(rows !== undefined ? { rows } : {}),
|
|
550
|
+
...(quarantined > 0 ? { quarantined } : {}),
|
|
551
|
+
},
|
|
552
|
+
};
|
|
553
|
+
publishMarker(opts.root, adoptionId, opts.from, opts.toPrincipal, 4, plan, legBits);
|
|
554
|
+
}
|
|
555
|
+
publishMarker(opts.root, adoptionId, opts.from, opts.toPrincipal, 5, plan, legBits);
|
|
556
|
+
phase = 5;
|
|
557
|
+
}
|
|
558
|
+
if (plan.rulesDir !== undefined)
|
|
559
|
+
legs.push({ store: "permission-rule", action: "bucket-rebind" });
|
|
560
|
+
legs.push({ store: SESSION_POLICY_LEG, action: "row-rewrite", rows: legBits[SESSION_POLICY_LEG]?.rows ?? 0 });
|
|
561
|
+
const carried = Object.entries(legBits)
|
|
562
|
+
.filter(([k]) => k.startsWith(CARRIAGE_BIT_PREFIX))
|
|
563
|
+
.map(([k, bit]) => ({
|
|
564
|
+
store: k.slice(CARRIAGE_BIT_PREFIX.length),
|
|
565
|
+
action: "carried",
|
|
566
|
+
...(bit.rows !== undefined ? { rows: bit.rows } : {}),
|
|
567
|
+
...(bit.quarantined !== undefined ? { quarantined: bit.quarantined } : {}),
|
|
568
|
+
}));
|
|
569
|
+
legs.push(...carried);
|
|
570
|
+
const carriedNames = new Set(carried.map((l) => l.store));
|
|
571
|
+
for (const store of ZERO_ACTION_STORES) {
|
|
572
|
+
if (!carriedNames.has(store))
|
|
573
|
+
legs.push({ store, action: "none" });
|
|
574
|
+
}
|
|
575
|
+
legs.push({ store: "usage-window", action: "reset" });
|
|
576
|
+
const account = readConfigAccount(opts.root);
|
|
577
|
+
const atMs = now();
|
|
578
|
+
if (!Number.isSafeInteger(atMs) || atMs < 0) {
|
|
579
|
+
return {
|
|
580
|
+
status: "stalled",
|
|
581
|
+
phase: 5,
|
|
582
|
+
error: `the clock returned an invalid timestamp (${String(atMs)}) — refusing to publish a terminal record the reader would reject; fix the injected clock and resume`,
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
const immutableReport = {
|
|
586
|
+
adoptionId,
|
|
587
|
+
from: opts.from,
|
|
588
|
+
toPrincipal: opts.toPrincipal,
|
|
589
|
+
atMs,
|
|
590
|
+
credentials: opts.credentials,
|
|
591
|
+
affectedDeploymentConfigs: (account?.configs ?? []).map(({ deployment, key, requiredValue, migrated }) => ({ deployment, key, requiredValue, migrated })),
|
|
592
|
+
legs,
|
|
593
|
+
notMigratedByDesign: NOT_MIGRATED_BY_DESIGN.map((e) => ({ ...e })),
|
|
594
|
+
};
|
|
595
|
+
writeRootAdoptionFile(opts.root, {
|
|
596
|
+
schemaVersion: 1,
|
|
597
|
+
adopted: { from: opts.from, toPrincipal: opts.toPrincipal, atMs: immutableReport.atMs, immutableReport },
|
|
598
|
+
});
|
|
599
|
+
return receiptFromTerminal(opts.root, { from: opts.from, toPrincipal: opts.toPrincipal, atMs: immutableReport.atMs, immutableReport });
|
|
600
|
+
}
|
|
601
|
+
finally {
|
|
602
|
+
adoptionLock.release();
|
|
603
|
+
engineLock.release();
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
function publishMarker(root, adoptionId, from, toPrincipal, phase, plan, legs) {
|
|
607
|
+
writeRootAdoptionFile(root, {
|
|
608
|
+
schemaVersion: 1,
|
|
609
|
+
marker: { adoptionId, from, toPrincipal, phase, plan, ...(Object.keys(legs).length > 0 ? { legs } : {}) },
|
|
610
|
+
});
|
|
611
|
+
}
|