@catalyst-cloud/cli 0.14.5 → 0.14.7
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 +9 -0
- package/README.md +5 -3
- package/dist/args.js +51 -13
- package/dist/cli.js +58 -10
- package/dist/config.js +16 -3
- package/dist/daemon-authority-lifetime.js +171 -0
- package/dist/daemon-authority.js +216 -0
- package/dist/daemon-cache.js +348 -0
- package/dist/daemon-store.js +370 -0
- package/dist/daemon-supervisor.js +460 -0
- package/dist/env/onboard-draft-store.js +243 -0
- package/dist/env/onboard-draft.js +33 -0
- package/dist/env/selected-import-writer.js +163 -0
- package/dist/env/settings-authority.js +203 -0
- package/dist/events-cloud-tail.js +134 -0
- package/dist/events-cloud.js +500 -0
- package/dist/events.js +13 -4
- package/dist/legacy.js +145 -32
- package/dist/oauth.js +125 -33
- package/dist/onboard-accounts.js +259 -0
- package/dist/onboard-automation-management.js +15 -0
- package/dist/onboard-bootstrap.js +162 -0
- package/dist/onboard-capabilities.js +15 -7
- package/dist/onboard-capacity.js +226 -0
- package/dist/onboard-existing.js +106 -32
- package/dist/onboard-file-snapshot.js +45 -0
- package/dist/onboard-login-candidate.js +261 -0
- package/dist/onboard-projects.js +350 -0
- package/dist/onboard-repositories.js +147 -37
- package/dist/onboard-runtime.js +104 -9
- package/dist/onboard-settings-context.js +141 -0
- package/dist/onboard-settings-import.js +127 -0
- package/dist/onboard-settings.js +234 -0
- package/dist/onboard-ui.js +99 -11
- package/dist/onboard-workflow.js +316 -0
- package/dist/onboard.js +359 -27
- package/dist/owned-fetch.js +182 -0
- package/dist/query.js +77 -27
- package/dist/ready.js +143 -35
- package/dist/replica.js +11 -4
- package/dist/sdk.js +33 -0
- package/dist/watch.js +2 -2
- package/package.json +3 -2
- package/skills/catalyst-github/SKILL.md +2 -2
- package/skills/catalyst-github/references/is-it-mergeable.md +1 -1
- package/skills/catalyst-github/references/what-a-pr-accumulates.md +1 -1
- package/skills/catalyst-github/scripts/is-it-mergeable.mjs +210 -45
- package/skills/catalyst-github/scripts/read-pr.mjs +51 -15
- package/skills/catalyst-linear/SKILL.md +3 -3
- package/skills/catalyst-linear/references/reading-a-ticket.md +3 -3
- package/skills/catalyst-linear/scripts/read-ticket.mjs +37 -12
- package/skills/catalyst-onboard/SKILL.md +5 -4
- package/skills/catalyst-onboard/references/local-sync.md +3 -1
- package/skills/run-this-project/SKILL.md +1 -1
- package/skills/unstick/SKILL.md +1 -1
- package/skills/what-needs-me/SKILL.md +1 -1
- package/skills/what-this-repo-needs/SKILL.md +1 -1
- package/skills/whats-happening/SKILL.md +2 -2
- package/skills/whats-happening/references/status-reply.md +1 -1
- package/skills/whats-happening/scripts/snapshot.mjs +104 -26
- package/vendor/settings/index.d.ts +4 -0
- package/vendor/settings/index.js +2937 -0
- package/vendor/settings/provenance.json +148 -0
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
const sameBinding = (a, b) => a.account === b.account &&
|
|
2
|
+
a.origin === b.origin &&
|
|
3
|
+
a.replicaDb === b.replicaDb &&
|
|
4
|
+
a.eventDirectory === b.eventDirectory;
|
|
5
|
+
const copy = (state) => ({
|
|
6
|
+
...state,
|
|
7
|
+
binding: { ...state.binding },
|
|
8
|
+
replica: { ...state.replica },
|
|
9
|
+
events: { ...state.events },
|
|
10
|
+
stopped: state.stopped ? { ...state.stopped } : null,
|
|
11
|
+
});
|
|
12
|
+
function deferred() {
|
|
13
|
+
let resolve;
|
|
14
|
+
const promise = new Promise((done) => {
|
|
15
|
+
resolve = done;
|
|
16
|
+
});
|
|
17
|
+
return { promise, resolve };
|
|
18
|
+
}
|
|
19
|
+
/** Exit 0 means requested or deliberate stop. Unexpected failures reject for OS crash restart.
|
|
20
|
+
* “live” describes both running clients, not an independent current cloud-head freshness proof. */
|
|
21
|
+
export async function runDaemonSupervisor(ports) {
|
|
22
|
+
let claimed = false;
|
|
23
|
+
let failed = false;
|
|
24
|
+
let primary;
|
|
25
|
+
let result = null;
|
|
26
|
+
try {
|
|
27
|
+
result = await runOwnedDaemonSupervisor({
|
|
28
|
+
...ports,
|
|
29
|
+
claim: async () => {
|
|
30
|
+
const lease = await ports.claim();
|
|
31
|
+
claimed = true;
|
|
32
|
+
return lease;
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
failed = true;
|
|
38
|
+
primary = error;
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
// Before claim succeeds the inner lease lifetime has not begun, but this call still owns
|
|
42
|
+
// the injected authority task, including its refused preflight and any entered reader IO.
|
|
43
|
+
if (!claimed && ports.authority) {
|
|
44
|
+
try {
|
|
45
|
+
await ports.authority.stop();
|
|
46
|
+
}
|
|
47
|
+
catch (cleanup) {
|
|
48
|
+
throw new AggregateError(failed ? [primary, cleanup] : [cleanup], "daemon_authority_cleanup_failed");
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (failed)
|
|
53
|
+
throw primary;
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
async function runOwnedDaemonSupervisor(ports) {
|
|
57
|
+
const maxFailures = ports.maxFailures ?? 5;
|
|
58
|
+
const liveStableMs = ports.liveStableMs ?? 60_000;
|
|
59
|
+
if (!Number.isSafeInteger(liveStableMs) ||
|
|
60
|
+
liveStableMs < 1 ||
|
|
61
|
+
liveStableMs > 900_000)
|
|
62
|
+
throw new Error("daemon_stability_budget_invalid");
|
|
63
|
+
if (!Number.isSafeInteger(maxFailures) ||
|
|
64
|
+
maxFailures < 1 ||
|
|
65
|
+
maxFailures > 100)
|
|
66
|
+
throw new Error("daemon_failure_cap_invalid");
|
|
67
|
+
if (ports.signal.aborted)
|
|
68
|
+
return null;
|
|
69
|
+
ports.authority?.assertCurrent();
|
|
70
|
+
const lease = await ports.claim();
|
|
71
|
+
const owned = new AbortController();
|
|
72
|
+
const signal = AbortSignal.any([owned.signal, ports.signal]);
|
|
73
|
+
const stop = deferred();
|
|
74
|
+
const stopped = () => stop.resolve(undefined);
|
|
75
|
+
signal.addEventListener("abort", stopped, { once: true });
|
|
76
|
+
if (signal.aborted)
|
|
77
|
+
stopped();
|
|
78
|
+
let record = null;
|
|
79
|
+
// A rejected cleanup has not acknowledged ownership release, even if start has settled.
|
|
80
|
+
let cleanupAcknowledged = true;
|
|
81
|
+
let queue = Promise.resolve();
|
|
82
|
+
const eventClient = { current: null };
|
|
83
|
+
const backgroundFailure = {
|
|
84
|
+
present: false,
|
|
85
|
+
};
|
|
86
|
+
const write = async () => {
|
|
87
|
+
if (!record)
|
|
88
|
+
return;
|
|
89
|
+
record.updatedAt = ports.now();
|
|
90
|
+
record.state =
|
|
91
|
+
record.stopped || signal.aborted
|
|
92
|
+
? "stopped"
|
|
93
|
+
: record.replica.state === "live" && record.events.state === "live"
|
|
94
|
+
? "live"
|
|
95
|
+
: record.replica.state === "backoff" ||
|
|
96
|
+
record.events.state === "backoff"
|
|
97
|
+
? "backoff"
|
|
98
|
+
: "starting";
|
|
99
|
+
const snapshot = copy(record);
|
|
100
|
+
const pending = queue.then(() => lease.write(snapshot));
|
|
101
|
+
queue = pending.catch(() => { });
|
|
102
|
+
await pending;
|
|
103
|
+
};
|
|
104
|
+
const terminal = async (reason) => {
|
|
105
|
+
if (!record || record.stopped)
|
|
106
|
+
return;
|
|
107
|
+
record.stopped = {
|
|
108
|
+
at: ports.now(),
|
|
109
|
+
reason,
|
|
110
|
+
restartWith: "catalyst daemon restart",
|
|
111
|
+
};
|
|
112
|
+
// Stop the other client immediately; the latch is saved before this lifetime releases its lease.
|
|
113
|
+
owned.abort();
|
|
114
|
+
await write();
|
|
115
|
+
};
|
|
116
|
+
const checkAuthority = () => {
|
|
117
|
+
try {
|
|
118
|
+
ports.authority?.assertCurrent();
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
const kind = ports.classify(error);
|
|
123
|
+
if (kind === "authentication" || kind === "identity")
|
|
124
|
+
return terminal(kind === "identity" ? "identity_mismatch" : "authentication_required");
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
const failure = async (component, error) => {
|
|
129
|
+
if (!record || signal.aborted)
|
|
130
|
+
return;
|
|
131
|
+
const kind = ports.classify(error);
|
|
132
|
+
if (kind === "unexpected")
|
|
133
|
+
throw error;
|
|
134
|
+
if (kind !== "transient") {
|
|
135
|
+
const reasons = {
|
|
136
|
+
authentication: "authentication_required",
|
|
137
|
+
"history-gap": "event_history_gap",
|
|
138
|
+
"writer-conflict": "writer_conflict",
|
|
139
|
+
identity: "identity_mismatch",
|
|
140
|
+
};
|
|
141
|
+
await terminal(reasons[kind]);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const state = record[component];
|
|
145
|
+
state.state = "backoff";
|
|
146
|
+
state.failures++;
|
|
147
|
+
if (state.failures >= maxFailures) {
|
|
148
|
+
await terminal(component === "replica" ? "replica_failure_cap" : "event_failure_cap");
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
await write();
|
|
152
|
+
const jitter = ports.random();
|
|
153
|
+
if (!Number.isFinite(jitter) || jitter < 0 || jitter >= 1)
|
|
154
|
+
throw new Error("daemon_jitter_invalid");
|
|
155
|
+
try {
|
|
156
|
+
await ports.sleep(Math.floor(Math.min(30_000 * 2 ** (state.failures - 1), 900_000) * jitter), signal);
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
if (!signal.aborted)
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
const replicaLoop = async () => {
|
|
164
|
+
while (!signal.aborted) {
|
|
165
|
+
let attempt = null;
|
|
166
|
+
let started = null;
|
|
167
|
+
let problem;
|
|
168
|
+
const failed = deferred();
|
|
169
|
+
try {
|
|
170
|
+
const denied = checkAuthority();
|
|
171
|
+
if (denied) {
|
|
172
|
+
await denied;
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
attempt = await ports.replica();
|
|
176
|
+
if (signal.aborted)
|
|
177
|
+
break;
|
|
178
|
+
const factoryDenied = checkAuthority();
|
|
179
|
+
if (factoryDenied) {
|
|
180
|
+
await factoryDenied;
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
record.replica.state = "starting";
|
|
184
|
+
await write();
|
|
185
|
+
const startDenied = checkAuthority();
|
|
186
|
+
if (startDenied) {
|
|
187
|
+
await startDenied;
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
started = attempt.start((state) => {
|
|
191
|
+
if (signal.aborted)
|
|
192
|
+
return;
|
|
193
|
+
if (state === "auth-required") {
|
|
194
|
+
// A known auth refusal stops both feeds and records its latch immediately, even
|
|
195
|
+
// while this attempt is still draining IO in close().
|
|
196
|
+
void terminal("authentication_required").catch((error) => {
|
|
197
|
+
backgroundFailure.present = true;
|
|
198
|
+
backgroundFailure.error = error;
|
|
199
|
+
owned.abort();
|
|
200
|
+
});
|
|
201
|
+
failed.resolve({ daemonFailure: "authentication" });
|
|
202
|
+
}
|
|
203
|
+
else if (state === "reconnecting") {
|
|
204
|
+
record.replica.state = "backoff";
|
|
205
|
+
record.state = "backoff";
|
|
206
|
+
// Queue the cleared barrier before draining this attempt. Store failures are crashes,
|
|
207
|
+
// even when a transient reconnect has already won the attempt's failure race.
|
|
208
|
+
void write().catch((error) => {
|
|
209
|
+
backgroundFailure.present = true;
|
|
210
|
+
backgroundFailure.error = error;
|
|
211
|
+
owned.abort();
|
|
212
|
+
});
|
|
213
|
+
failed.resolve({ daemonFailure: "transient" });
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
// Both branches have rejection handlers; cleanup below awaits the losing start promise too.
|
|
217
|
+
const first = await Promise.race([
|
|
218
|
+
started.then(() => ({ ready: true }), (error) => ({ error })),
|
|
219
|
+
failed.promise.then((error) => ({ error })),
|
|
220
|
+
stop.promise.then(() => ({ stopped: true })),
|
|
221
|
+
]);
|
|
222
|
+
if ("error" in first)
|
|
223
|
+
problem = first.error;
|
|
224
|
+
else if ("ready" in first && !signal.aborted) {
|
|
225
|
+
const liveDenied = checkAuthority();
|
|
226
|
+
if (liveDenied) {
|
|
227
|
+
await liveDenied;
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
record.replica.state = "live";
|
|
231
|
+
await write();
|
|
232
|
+
// A warm connection can precede a failed resync. Reset persisted failures only after a
|
|
233
|
+
// stable lifetime, so crash/reconnect loops cannot repeatedly erase their failure cap.
|
|
234
|
+
if (record.replica.failures > 0) {
|
|
235
|
+
const stable = new AbortController();
|
|
236
|
+
try {
|
|
237
|
+
const outcome = await Promise.race([
|
|
238
|
+
failed.promise.then((error) => ({ error })),
|
|
239
|
+
stop.promise.then(() => ({ stopped: true })),
|
|
240
|
+
ports
|
|
241
|
+
.sleep(liveStableMs, AbortSignal.any([signal, stable.signal]))
|
|
242
|
+
.then(() => ({ stable: true })),
|
|
243
|
+
]);
|
|
244
|
+
if ("stable" in outcome && !signal.aborted) {
|
|
245
|
+
const stableDenied = checkAuthority();
|
|
246
|
+
if (stableDenied) {
|
|
247
|
+
await stableDenied;
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
record.replica.failures = 0;
|
|
251
|
+
await write();
|
|
252
|
+
problem = await Promise.race([failed.promise, stop.promise]);
|
|
253
|
+
}
|
|
254
|
+
else if ("error" in outcome)
|
|
255
|
+
problem = outcome.error;
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
stable.abort();
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
else
|
|
262
|
+
problem = await Promise.race([failed.promise, stop.promise]);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
problem = error;
|
|
267
|
+
}
|
|
268
|
+
finally {
|
|
269
|
+
// SDK close resolves/cancels start and releases only this attempt's writer lock.
|
|
270
|
+
try {
|
|
271
|
+
if (attempt)
|
|
272
|
+
await attempt.close();
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
cleanupAcknowledged = false;
|
|
276
|
+
// A failed close cannot keep its peer running while the entered start task drains.
|
|
277
|
+
owned.abort();
|
|
278
|
+
throw error;
|
|
279
|
+
}
|
|
280
|
+
finally {
|
|
281
|
+
if (started)
|
|
282
|
+
await started.catch(() => { });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (!signal.aborted)
|
|
286
|
+
await failure("replica", problem);
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
const eventLoop = async () => {
|
|
290
|
+
if (signal.aborted)
|
|
291
|
+
return;
|
|
292
|
+
const denied = checkAuthority();
|
|
293
|
+
if (denied) {
|
|
294
|
+
await denied;
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
eventClient.current = await ports.events();
|
|
298
|
+
let idle = 1_000;
|
|
299
|
+
while (!signal.aborted) {
|
|
300
|
+
const denied = checkAuthority();
|
|
301
|
+
if (denied) {
|
|
302
|
+
await denied;
|
|
303
|
+
break;
|
|
304
|
+
}
|
|
305
|
+
const request = new AbortController();
|
|
306
|
+
const requestSignal = AbortSignal.any([signal, request.signal]);
|
|
307
|
+
const timer = setTimeout(() => request.abort(), 30_000);
|
|
308
|
+
let problem;
|
|
309
|
+
let failed = false;
|
|
310
|
+
try {
|
|
311
|
+
// Direct syncOnce is not tracked by SDK stop(). Await it here even after cancellation:
|
|
312
|
+
// releasing its lock early would permit a second writer while old filesystem IO continues.
|
|
313
|
+
const result = await eventClient.current.syncOnce(requestSignal);
|
|
314
|
+
if (signal.aborted)
|
|
315
|
+
break;
|
|
316
|
+
if (request.signal.aborted)
|
|
317
|
+
throw { daemonFailure: "transient" };
|
|
318
|
+
const resultDenied = checkAuthority();
|
|
319
|
+
if (resultDenied) {
|
|
320
|
+
await resultDenied;
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
if (![result.appended, result.cursor, result.head].every((value) => Number.isSafeInteger(value) && value >= 0) ||
|
|
324
|
+
result.cursor > result.head)
|
|
325
|
+
throw new Error("daemon_event_result_invalid");
|
|
326
|
+
record.events = {
|
|
327
|
+
state: result.cursor === result.head ? "live" : "starting",
|
|
328
|
+
failures: 0,
|
|
329
|
+
cursor: result.cursor,
|
|
330
|
+
head: result.head,
|
|
331
|
+
};
|
|
332
|
+
await write();
|
|
333
|
+
idle = result.appended > 0 ? 1_000 : Math.min(idle * 2, 30_000);
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
problem = error;
|
|
337
|
+
failed = true;
|
|
338
|
+
}
|
|
339
|
+
finally {
|
|
340
|
+
clearTimeout(timer);
|
|
341
|
+
}
|
|
342
|
+
if (signal.aborted)
|
|
343
|
+
break;
|
|
344
|
+
if (failed)
|
|
345
|
+
await failure("events", problem);
|
|
346
|
+
else {
|
|
347
|
+
try {
|
|
348
|
+
await ports.sleep(idle, signal);
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
if (!signal.aborted)
|
|
352
|
+
throw error;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
try {
|
|
358
|
+
const previous = await lease.read();
|
|
359
|
+
if (previous && !sameBinding(previous.binding, ports.binding))
|
|
360
|
+
throw new Error("daemon_record_identity_mismatch");
|
|
361
|
+
if (previous?.stopped && !ports.restart)
|
|
362
|
+
return previous;
|
|
363
|
+
if (previous &&
|
|
364
|
+
![previous.replica.failures, previous.events.failures].every((value) => Number.isSafeInteger(value) && value >= 0))
|
|
365
|
+
throw new Error("daemon_record_invalid");
|
|
366
|
+
if (signal.aborted)
|
|
367
|
+
return null;
|
|
368
|
+
record = {
|
|
369
|
+
schema: 1,
|
|
370
|
+
binding: { ...ports.binding },
|
|
371
|
+
runId: ports.runId,
|
|
372
|
+
pid: ports.pid,
|
|
373
|
+
updatedAt: ports.now(),
|
|
374
|
+
state: "starting",
|
|
375
|
+
replica: {
|
|
376
|
+
state: "starting",
|
|
377
|
+
failures: ports.restart ? 0 : (previous?.replica.failures ?? 0),
|
|
378
|
+
},
|
|
379
|
+
events: {
|
|
380
|
+
state: "starting",
|
|
381
|
+
failures: ports.restart ? 0 : (previous?.events.failures ?? 0),
|
|
382
|
+
cursor: previous?.events.cursor ?? null,
|
|
383
|
+
head: previous?.events.head ?? null,
|
|
384
|
+
},
|
|
385
|
+
stopped: null,
|
|
386
|
+
};
|
|
387
|
+
await write();
|
|
388
|
+
if (record.replica.failures >= maxFailures ||
|
|
389
|
+
record.events.failures >= maxFailures) {
|
|
390
|
+
await terminal(record.replica.failures >= maxFailures
|
|
391
|
+
? "replica_failure_cap"
|
|
392
|
+
: "event_failure_cap");
|
|
393
|
+
return record;
|
|
394
|
+
}
|
|
395
|
+
const denied = checkAuthority();
|
|
396
|
+
if (denied) {
|
|
397
|
+
await denied;
|
|
398
|
+
return record;
|
|
399
|
+
}
|
|
400
|
+
const tasks = [
|
|
401
|
+
...(ports.authority ? [ports.authority.run(signal, terminal)] : []),
|
|
402
|
+
replicaLoop(),
|
|
403
|
+
eventLoop(),
|
|
404
|
+
].map((task) => task.catch((error) => {
|
|
405
|
+
owned.abort();
|
|
406
|
+
throw error;
|
|
407
|
+
}));
|
|
408
|
+
const results = await Promise.allSettled(tasks);
|
|
409
|
+
if (backgroundFailure.present)
|
|
410
|
+
throw backgroundFailure.error;
|
|
411
|
+
const failed = results.find((result) => result.status === "rejected");
|
|
412
|
+
if (failed?.status === "rejected")
|
|
413
|
+
throw failed.reason;
|
|
414
|
+
return record;
|
|
415
|
+
}
|
|
416
|
+
finally {
|
|
417
|
+
owned.abort();
|
|
418
|
+
// Event loop has fully acknowledged direct syncOnce settlement before stop releases its lock.
|
|
419
|
+
try {
|
|
420
|
+
// Join every entered cleanup even if its peer rejects. A failed authority reader ACK
|
|
421
|
+
// retains the same lease as a failed SDK close/stop.
|
|
422
|
+
const cleanups = await Promise.allSettled([
|
|
423
|
+
...(eventClient.current
|
|
424
|
+
? [Promise.resolve().then(() => eventClient.current.stop())]
|
|
425
|
+
: []),
|
|
426
|
+
...(ports.authority
|
|
427
|
+
? [Promise.resolve().then(() => ports.authority.stop())]
|
|
428
|
+
: []),
|
|
429
|
+
]);
|
|
430
|
+
const failures = cleanups.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
|
|
431
|
+
if (failures.length === 1)
|
|
432
|
+
throw failures[0];
|
|
433
|
+
if (failures.length > 1)
|
|
434
|
+
throw new AggregateError(failures, "daemon_cleanup_failed");
|
|
435
|
+
}
|
|
436
|
+
catch (error) {
|
|
437
|
+
cleanupAcknowledged = false;
|
|
438
|
+
throw error;
|
|
439
|
+
}
|
|
440
|
+
finally {
|
|
441
|
+
try {
|
|
442
|
+
if (record && cleanupAcknowledged) {
|
|
443
|
+
record.replica.state = "stopped";
|
|
444
|
+
record.events.state = "stopped";
|
|
445
|
+
await write();
|
|
446
|
+
}
|
|
447
|
+
await queue;
|
|
448
|
+
if (backgroundFailure.present)
|
|
449
|
+
throw backgroundFailure.error;
|
|
450
|
+
}
|
|
451
|
+
finally {
|
|
452
|
+
signal.removeEventListener("abort", stopped);
|
|
453
|
+
// Retain the live PID-bound lease on cleanup failure. Only positive process death
|
|
454
|
+
// permits a later claimant to recover it; SDK writer locks remain owned as well.
|
|
455
|
+
if (cleanupAcknowledged)
|
|
456
|
+
await lease.release();
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { lstat, mkdir, open, unlink } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute, join, parse, resolve, sep } from "node:path";
|
|
4
|
+
import { onboardStateRoot } from "../onboard.js";
|
|
5
|
+
const OPAQUE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
6
|
+
const MAX_TOML_BYTES = 1024 * 1024;
|
|
7
|
+
const defaultFs = { mkdir, lstat, open, unlink };
|
|
8
|
+
/**
|
|
9
|
+
* Persist a private copy of a draft produced and fully validated by the caller. This function
|
|
10
|
+
* checks the storage boundary only; it does not validate settings or inspect TOML values.
|
|
11
|
+
*/
|
|
12
|
+
export async function storeOnboardDraft(input, fsOps = defaultFs) {
|
|
13
|
+
const home = input?.home;
|
|
14
|
+
const env = input?.env;
|
|
15
|
+
const runId = input?.runId;
|
|
16
|
+
const repoId = input?.repoId;
|
|
17
|
+
const toml = input?.toml;
|
|
18
|
+
const signal = input?.signal;
|
|
19
|
+
if (typeof runId !== "string" ||
|
|
20
|
+
!OPAQUE_ID_RE.test(runId) ||
|
|
21
|
+
typeof repoId !== "string" ||
|
|
22
|
+
!OPAQUE_ID_RE.test(repoId) ||
|
|
23
|
+
typeof home !== "string" ||
|
|
24
|
+
!isAbsolute(home) ||
|
|
25
|
+
typeof toml !== "string" ||
|
|
26
|
+
Buffer.byteLength(toml, "utf8") > MAX_TOML_BYTES ||
|
|
27
|
+
signal?.aborted)
|
|
28
|
+
return { state: "rejected" };
|
|
29
|
+
// Snapshot the expected bytes and resolved paths before the first asynchronous operation.
|
|
30
|
+
const expected = Buffer.from(toml, "utf8");
|
|
31
|
+
let stateRoot;
|
|
32
|
+
let runDir;
|
|
33
|
+
let destination;
|
|
34
|
+
try {
|
|
35
|
+
stateRoot = resolve(onboardStateRoot(home, env));
|
|
36
|
+
if (stateRoot === parse(stateRoot).root || stateRoot === resolve(home))
|
|
37
|
+
return { state: "rejected" };
|
|
38
|
+
runDir = join(stateRoot, "install", "drafts", runId);
|
|
39
|
+
destination = join(runDir, `${repoId}.toml`);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return { state: "rejected" };
|
|
43
|
+
}
|
|
44
|
+
let handle;
|
|
45
|
+
let created;
|
|
46
|
+
try {
|
|
47
|
+
await ensureDirectoryTree(runDir, stateRoot, signal, fsOps);
|
|
48
|
+
if (aborted(signal))
|
|
49
|
+
return { state: "rejected" };
|
|
50
|
+
try {
|
|
51
|
+
handle = await fsOps.open(destination, constants.O_WRONLY |
|
|
52
|
+
constants.O_CREAT |
|
|
53
|
+
constants.O_EXCL |
|
|
54
|
+
constants.O_NOFOLLOW, 0o600);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (!isAlreadyExists(error))
|
|
58
|
+
throw error;
|
|
59
|
+
return await resumeExisting(destination, runDir, stateRoot, expected, signal, fsOps);
|
|
60
|
+
}
|
|
61
|
+
const opened = await handle.stat();
|
|
62
|
+
created = { dev: opened.dev, ino: opened.ino };
|
|
63
|
+
if (!opened.isFile() || opened.nlink !== 1 || !privateMode(opened.mode))
|
|
64
|
+
throw new Error("unsafe artifact");
|
|
65
|
+
if (aborted(signal))
|
|
66
|
+
throw new Error("cancelled");
|
|
67
|
+
await handle.writeFile(expected);
|
|
68
|
+
if (aborted(signal))
|
|
69
|
+
throw new Error("cancelled");
|
|
70
|
+
await handle.sync();
|
|
71
|
+
if (aborted(signal))
|
|
72
|
+
throw new Error("cancelled");
|
|
73
|
+
await ensureDirectoryTree(runDir, stateRoot, signal, fsOps);
|
|
74
|
+
if (aborted(signal))
|
|
75
|
+
throw new Error("cancelled");
|
|
76
|
+
await verifyCreatedPath(destination, created, fsOps);
|
|
77
|
+
if (aborted(signal))
|
|
78
|
+
throw new Error("cancelled");
|
|
79
|
+
await handle.close();
|
|
80
|
+
handle = undefined;
|
|
81
|
+
if (aborted(signal))
|
|
82
|
+
throw new Error("cancelled");
|
|
83
|
+
await ensureDirectoryTree(runDir, stateRoot, signal, fsOps);
|
|
84
|
+
if (aborted(signal))
|
|
85
|
+
throw new Error("cancelled");
|
|
86
|
+
await verifyCreatedPath(destination, created, fsOps);
|
|
87
|
+
if (aborted(signal))
|
|
88
|
+
throw new Error("cancelled");
|
|
89
|
+
return { state: "stored", path: destination };
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
if (handle !== undefined) {
|
|
93
|
+
try {
|
|
94
|
+
await handle.close();
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
/* settle close before inode-scoped cleanup */
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (created !== undefined)
|
|
101
|
+
await removeCreatedFile(destination, created, fsOps);
|
|
102
|
+
return { state: "rejected" };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
async function resumeExisting(path, runDir, stateRoot, expected, signal, fsOps) {
|
|
106
|
+
let handle;
|
|
107
|
+
try {
|
|
108
|
+
if (aborted(signal))
|
|
109
|
+
return { state: "rejected" };
|
|
110
|
+
await ensureDirectoryTree(runDir, stateRoot, signal, fsOps);
|
|
111
|
+
if (aborted(signal))
|
|
112
|
+
return { state: "rejected" };
|
|
113
|
+
handle = await fsOps.open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
114
|
+
const before = await handle.stat();
|
|
115
|
+
const pathBefore = await fsOps.lstat(path);
|
|
116
|
+
if (!sameSafeFile(before, pathBefore) || aborted(signal))
|
|
117
|
+
throw new Error("unsafe resume file");
|
|
118
|
+
const chunks = [];
|
|
119
|
+
let total = 0;
|
|
120
|
+
for await (const chunk of handle.createReadStream({ autoClose: false })) {
|
|
121
|
+
if (aborted(signal))
|
|
122
|
+
throw new Error("cancelled");
|
|
123
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
124
|
+
total += bytes.length;
|
|
125
|
+
if (total > MAX_TOML_BYTES)
|
|
126
|
+
throw new Error("oversized resume file");
|
|
127
|
+
chunks.push(bytes);
|
|
128
|
+
}
|
|
129
|
+
if (aborted(signal))
|
|
130
|
+
throw new Error("cancelled");
|
|
131
|
+
const content = Buffer.concat(chunks, total);
|
|
132
|
+
if (!content.equals(expected))
|
|
133
|
+
throw new Error("different resume bytes");
|
|
134
|
+
const fdAfter = await handle.stat();
|
|
135
|
+
const pathAfter = await fsOps.lstat(path);
|
|
136
|
+
if (!sameIdentity(before, fdAfter) ||
|
|
137
|
+
!sameSafeFile(fdAfter, pathAfter) ||
|
|
138
|
+
aborted(signal))
|
|
139
|
+
throw new Error("resume path changed");
|
|
140
|
+
await ensureDirectoryTree(runDir, stateRoot, signal, fsOps);
|
|
141
|
+
if (aborted(signal))
|
|
142
|
+
throw new Error("cancelled");
|
|
143
|
+
await handle.close();
|
|
144
|
+
handle = undefined;
|
|
145
|
+
if (aborted(signal))
|
|
146
|
+
throw new Error("cancelled");
|
|
147
|
+
await ensureDirectoryTree(runDir, stateRoot, signal, fsOps);
|
|
148
|
+
if (aborted(signal))
|
|
149
|
+
throw new Error("cancelled");
|
|
150
|
+
const finalPath = await fsOps.lstat(path);
|
|
151
|
+
if (!sameSafeFile(before, finalPath) || aborted(signal))
|
|
152
|
+
throw new Error("resume path changed");
|
|
153
|
+
return { state: "stored", path };
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
if (handle !== undefined) {
|
|
157
|
+
try {
|
|
158
|
+
await handle.close();
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
/* resume never owns the existing file */
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return { state: "rejected" };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
async function ensureDirectoryTree(directory, stateRoot, signal, fsOps) {
|
|
168
|
+
const absolute = resolve(directory);
|
|
169
|
+
const root = parse(absolute).root;
|
|
170
|
+
const parts = absolute.slice(root.length).split(sep).filter(Boolean);
|
|
171
|
+
let current = root;
|
|
172
|
+
for (const part of parts) {
|
|
173
|
+
if (aborted(signal))
|
|
174
|
+
throw new Error("cancelled");
|
|
175
|
+
current = join(current, part);
|
|
176
|
+
try {
|
|
177
|
+
await fsOps.mkdir(current, { mode: 0o700 });
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
if (!isAlreadyExists(error))
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
183
|
+
if (aborted(signal))
|
|
184
|
+
throw new Error("cancelled");
|
|
185
|
+
const stat = await fsOps.lstat(current);
|
|
186
|
+
if (aborted(signal))
|
|
187
|
+
throw new Error("cancelled");
|
|
188
|
+
if (stat.isSymbolicLink() || !stat.isDirectory())
|
|
189
|
+
throw new Error("redirected directory");
|
|
190
|
+
if (isWithin(stateRoot, current) &&
|
|
191
|
+
((stat.mode & 0o022) !== 0 ||
|
|
192
|
+
(process.getuid !== undefined && stat.uid !== process.getuid())))
|
|
193
|
+
throw new Error("unsafe storage directory");
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
async function verifyCreatedPath(path, identity, fsOps) {
|
|
197
|
+
const stat = await fsOps.lstat(path);
|
|
198
|
+
if (!stat.isFile() ||
|
|
199
|
+
stat.isSymbolicLink() ||
|
|
200
|
+
stat.nlink !== 1 ||
|
|
201
|
+
!privateMode(stat.mode) ||
|
|
202
|
+
!sameIdentity(stat, identity))
|
|
203
|
+
throw new Error("artifact path changed");
|
|
204
|
+
}
|
|
205
|
+
async function removeCreatedFile(path, identity, fsOps) {
|
|
206
|
+
try {
|
|
207
|
+
const stat = await fsOps.lstat(path);
|
|
208
|
+
if (stat.isFile() && !stat.isSymbolicLink() && sameIdentity(stat, identity))
|
|
209
|
+
await fsOps.unlink(path);
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
/* missing or replaced paths are untouched */
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
function sameSafeFile(fd, path) {
|
|
216
|
+
return (fd.isFile() &&
|
|
217
|
+
path.isFile() &&
|
|
218
|
+
!path.isSymbolicLink() &&
|
|
219
|
+
fd.nlink === 1 &&
|
|
220
|
+
path.nlink === 1 &&
|
|
221
|
+
privateMode(fd.mode) &&
|
|
222
|
+
privateMode(path.mode) &&
|
|
223
|
+
(process.getuid === undefined || fd.uid === process.getuid()) &&
|
|
224
|
+
sameIdentity(fd, path));
|
|
225
|
+
}
|
|
226
|
+
function sameIdentity(a, b) {
|
|
227
|
+
return a.dev === b.dev && a.ino === b.ino;
|
|
228
|
+
}
|
|
229
|
+
function privateMode(mode) {
|
|
230
|
+
return (mode & 0o077) === 0;
|
|
231
|
+
}
|
|
232
|
+
function isWithin(root, path) {
|
|
233
|
+
return path === root || path.startsWith(`${root}${sep}`);
|
|
234
|
+
}
|
|
235
|
+
function aborted(signal) {
|
|
236
|
+
return signal?.aborted === true;
|
|
237
|
+
}
|
|
238
|
+
function isAlreadyExists(error) {
|
|
239
|
+
return (typeof error === "object" &&
|
|
240
|
+
error !== null &&
|
|
241
|
+
"code" in error &&
|
|
242
|
+
error.code === "EEXIST");
|
|
243
|
+
}
|