@dynamicagents/plugins 0.13.0 → 0.14.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/dist/claude-code/cards.d.ts +2 -0
- package/dist/claude-code/cards.js +11 -0
- package/dist/claude-code/model.js +26 -2
- package/dist/claude-code/run.d.ts +21 -4
- package/dist/claude-code/run.js +51 -39
- package/dist/claude-code/session.js +4 -1
- package/dist/claude-code/verified.d.ts +1 -1
- package/dist/claude-code/verified.js +1 -1
- package/dist/workspace/container-deps.d.ts +4 -0
- package/dist/workspace/container-deps.js +26 -1
- package/dist/workspace/deps-snapshot.d.ts +110 -0
- package/dist/workspace/deps-snapshot.js +424 -0
- package/dist/workspace/index.d.ts +2 -1
- package/dist/workspace/install-job.d.ts +49 -0
- package/dist/workspace/install-job.js +75 -7
- package/dist/workspace/install.d.ts +9 -0
- package/dist/workspace/install.js +21 -3
- package/dist/workspace/object.d.ts +24 -4
- package/dist/workspace/object.js +130 -16
- package/dist/workspace/wake.d.ts +2 -0
- package/package.json +11 -11
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
|
|
2
|
+
if (value !== null && value !== void 0) {
|
|
3
|
+
if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
|
|
4
|
+
var dispose, inner;
|
|
5
|
+
if (async) {
|
|
6
|
+
if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
|
|
7
|
+
dispose = value[Symbol.asyncDispose];
|
|
8
|
+
}
|
|
9
|
+
if (dispose === void 0) {
|
|
10
|
+
if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
|
|
11
|
+
dispose = value[Symbol.dispose];
|
|
12
|
+
if (async) inner = dispose;
|
|
13
|
+
}
|
|
14
|
+
if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
|
|
15
|
+
if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };
|
|
16
|
+
env.stack.push({ value: value, dispose: dispose, async: async });
|
|
17
|
+
}
|
|
18
|
+
else if (async) {
|
|
19
|
+
env.stack.push({ async: true });
|
|
20
|
+
}
|
|
21
|
+
return value;
|
|
22
|
+
};
|
|
23
|
+
var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) {
|
|
24
|
+
return function (env) {
|
|
25
|
+
function fail(e) {
|
|
26
|
+
env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
|
|
27
|
+
env.hasError = true;
|
|
28
|
+
}
|
|
29
|
+
var r, s = 0;
|
|
30
|
+
function next() {
|
|
31
|
+
while (r = env.stack.pop()) {
|
|
32
|
+
try {
|
|
33
|
+
if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
|
|
34
|
+
if (r.dispose) {
|
|
35
|
+
var result = r.dispose.call(r.value);
|
|
36
|
+
if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });
|
|
37
|
+
}
|
|
38
|
+
else s |= 1;
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
fail(e);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
|
|
45
|
+
if (env.hasError) throw env.error;
|
|
46
|
+
}
|
|
47
|
+
return next();
|
|
48
|
+
};
|
|
49
|
+
})(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
|
|
50
|
+
var e = new Error(message);
|
|
51
|
+
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
|
|
52
|
+
});
|
|
53
|
+
import { DEPS_ROOTS_COMMAND, parseDepsRoots } from "./container-deps.js";
|
|
54
|
+
/**
|
|
55
|
+
* A snapshot of a container that has just installed, so the next container
|
|
56
|
+
* starts from it instead of installing again.
|
|
57
|
+
*
|
|
58
|
+
* Dependency trees live on the container's disk (see `./container-deps.ts`), so
|
|
59
|
+
* a new container reinstalls before its first command can use them — what that
|
|
60
|
+
* costs is in `./install-job.ts`. A Containers snapshot is that disk: restored,
|
|
61
|
+
* the trees are back. It holds nothing else worth having — the checkout is this
|
|
62
|
+
* object's storage, mounted over FUSE, and a mount is not in a snapshot.
|
|
63
|
+
*
|
|
64
|
+
* ## When a snapshot is restored
|
|
65
|
+
*
|
|
66
|
+
* Only into a start that would otherwise install the same thing, which takes
|
|
67
|
+
* all of:
|
|
68
|
+
*
|
|
69
|
+
* - **The image it was taken on.** A snapshot replaces the image rather than
|
|
70
|
+
* layering on it, so one from before a deploy boots the old image's
|
|
71
|
+
* `computerd`, which nothing downstream catches.
|
|
72
|
+
* - **The same checkout and install fingerprint**, so the marker it carries is
|
|
73
|
+
* the one an install would write now.
|
|
74
|
+
* - **Every package root's inputs unchanged.** A bootstrap installs roots the
|
|
75
|
+
* fingerprint does not cover — a submodule's lockfile — and a restore brings
|
|
76
|
+
* all of them back at once.
|
|
77
|
+
* - **The same set of package roots.** A root added since — a new submodule —
|
|
78
|
+
* has no tree in the snapshot, and restoring it would skip the install that
|
|
79
|
+
* gives it one: see {@link packageRootSet}.
|
|
80
|
+
*
|
|
81
|
+
* What came up is then checked, not assumed: the setup mounts the restored trees
|
|
82
|
+
* and reads the marker, and only a match is adopted as installed. Anything else —
|
|
83
|
+
* an expired snapshot, which the platform reports as a failed start, after which
|
|
84
|
+
* the backend's restart takes the image — drops the record, and the install runs
|
|
85
|
+
* and snapshots again.
|
|
86
|
+
*
|
|
87
|
+
* ## Taken of the tree it was asked for, and nothing since
|
|
88
|
+
*
|
|
89
|
+
* An install asks for its snapshot in the same write as its verdict, so a stop
|
|
90
|
+
* that comes before the wake still sees the request. Snapshots run one at a
|
|
91
|
+
* time, and one is recorded only if the install record still names the tree it
|
|
92
|
+
* was taken of: a reinstall empties the tree before it rebuilds it, and a
|
|
93
|
+
* snapshot taken across that is of neither.
|
|
94
|
+
*
|
|
95
|
+
* ## The record outlives the container, and the snapshot outlives the record
|
|
96
|
+
*
|
|
97
|
+
* Unlike the tree record, which goes with its container, this is what a later
|
|
98
|
+
* container starts from. The platform keeps a snapshot for thirty days from its
|
|
99
|
+
* last restore and offers no way to delete one, so a dropped record leaves its
|
|
100
|
+
* snapshot to expire.
|
|
101
|
+
*/
|
|
102
|
+
/** Where the record lives. */
|
|
103
|
+
const RECORD_KEY = "deps:snapshot";
|
|
104
|
+
/** The tree a finished install asked to have snapshotted, until it is. */
|
|
105
|
+
const DUE_KEY = "deps:snapshot-due";
|
|
106
|
+
/**
|
|
107
|
+
* Which package roots a checkout has, as one digest: every directory under
|
|
108
|
+
* `dir` with a `package.json`, and the submodules `.gitmodules` declares — one a
|
|
109
|
+
* bootstrap clones in the container has no `package.json` here until then.
|
|
110
|
+
*/
|
|
111
|
+
export async function packageRootSet(fs, dir) {
|
|
112
|
+
const found = await fs
|
|
113
|
+
.find(dir, "**/package.json", { exclude: ["**/.git", "**/node_modules"] })
|
|
114
|
+
.catch((err) => {
|
|
115
|
+
if (err?.code === "ENOENT")
|
|
116
|
+
return [];
|
|
117
|
+
throw err;
|
|
118
|
+
});
|
|
119
|
+
const gitmodules = await fs
|
|
120
|
+
.readFile(`${dir}/.gitmodules`, "utf8")
|
|
121
|
+
.catch(() => "");
|
|
122
|
+
const input = [
|
|
123
|
+
...found.map((entry) => entry.path).sort(),
|
|
124
|
+
"\0.gitmodules",
|
|
125
|
+
gitmodules
|
|
126
|
+
].join("\n");
|
|
127
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input));
|
|
128
|
+
return [...new Uint8Array(digest)]
|
|
129
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
130
|
+
.join("");
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* The launch options `ContainerBackend` spreads into every start, whose
|
|
134
|
+
* `containerSnapshot` is read at that moment.
|
|
135
|
+
*
|
|
136
|
+
* A getter, because the backend keeps this object for its lifetime and builds
|
|
137
|
+
* each start from it — the initial one and every restart after a failed health
|
|
138
|
+
* check — and only the first of those may restore: a restart is the backend
|
|
139
|
+
* recovering from a start that did not come up, which an expired snapshot is.
|
|
140
|
+
*/
|
|
141
|
+
function launchOptions(take) {
|
|
142
|
+
return {
|
|
143
|
+
get containerSnapshot() {
|
|
144
|
+
return take();
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
export class DepsSnapshot {
|
|
149
|
+
deps;
|
|
150
|
+
/** Restore with the next start, once. */
|
|
151
|
+
#pending;
|
|
152
|
+
/** Whether a start has taken {@link #pending} since it was armed. */
|
|
153
|
+
#launched = false;
|
|
154
|
+
#taking;
|
|
155
|
+
launch = launchOptions(() => {
|
|
156
|
+
const pending = this.#pending;
|
|
157
|
+
if (!pending)
|
|
158
|
+
return undefined;
|
|
159
|
+
this.#pending = undefined;
|
|
160
|
+
this.#launched = true;
|
|
161
|
+
return pending;
|
|
162
|
+
});
|
|
163
|
+
constructor(deps) {
|
|
164
|
+
this.deps = deps;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Restore with the next start if the record fits it, and say which record.
|
|
168
|
+
*
|
|
169
|
+
* `expected` is a thunk because it reads the checkout, and a workspace with no
|
|
170
|
+
* snapshot or one from another image should not pay for that.
|
|
171
|
+
*/
|
|
172
|
+
async arm(expected) {
|
|
173
|
+
this.disarm();
|
|
174
|
+
const record = await this.get();
|
|
175
|
+
if (!record)
|
|
176
|
+
return undefined;
|
|
177
|
+
const refused = await this.#refusal(record, expected);
|
|
178
|
+
if (refused) {
|
|
179
|
+
console.info(`[${this.deps.tag()}] not restoring the dependency snapshot`, {
|
|
180
|
+
id: this.deps.id(),
|
|
181
|
+
snapshot: record.id,
|
|
182
|
+
reason: refused
|
|
183
|
+
});
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
this.#pending = { id: record.id };
|
|
187
|
+
console.info(`[${this.deps.tag()}] restoring the dependency snapshot`, {
|
|
188
|
+
id: this.deps.id(),
|
|
189
|
+
snapshot: record.id,
|
|
190
|
+
bytes: record.size,
|
|
191
|
+
dir: record.dir
|
|
192
|
+
});
|
|
193
|
+
return record;
|
|
194
|
+
}
|
|
195
|
+
/** Forget an armed restore, taken or not. */
|
|
196
|
+
disarm() {
|
|
197
|
+
this.#pending = undefined;
|
|
198
|
+
this.#launched = false;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Whether the container that came up holds `record`'s tree, judged by the
|
|
202
|
+
* marker the setup read in it.
|
|
203
|
+
*
|
|
204
|
+
* Drops the record when a start took it and the tree is not there; leaves it
|
|
205
|
+
* when no start did, since nothing was learned about it.
|
|
206
|
+
*/
|
|
207
|
+
async restored(record, marker) {
|
|
208
|
+
if (!this.#launched)
|
|
209
|
+
return false;
|
|
210
|
+
if (marker === record.fingerprint)
|
|
211
|
+
return true;
|
|
212
|
+
await this.#drop(record, marker === undefined
|
|
213
|
+
? "the restored container could not be read"
|
|
214
|
+
: "the restored container does not hold the tree");
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Snapshot the running container, which has just installed `tree`, after any
|
|
219
|
+
* snapshot already in flight — a later install's request is never dropped for
|
|
220
|
+
* an earlier one's.
|
|
221
|
+
*
|
|
222
|
+
* Never throws: a container that cannot be snapshotted installs again next
|
|
223
|
+
* time, which is today's cost and no worse.
|
|
224
|
+
*/
|
|
225
|
+
async take(tree) {
|
|
226
|
+
const turn = (this.#taking ?? Promise.resolve()).then(() => this.#take(tree));
|
|
227
|
+
this.#taking = turn;
|
|
228
|
+
try {
|
|
229
|
+
await turn;
|
|
230
|
+
}
|
|
231
|
+
finally {
|
|
232
|
+
if (this.#taking === turn)
|
|
233
|
+
this.#taking = undefined;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/** Snapshots in flight, finished — for whatever is about to stop the container. */
|
|
237
|
+
async settled() {
|
|
238
|
+
await this.#taking;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* The entries that ask for `tree`'s snapshot, for the install to write with
|
|
242
|
+
* its verdict — see the file comment, and {@link takeDue}.
|
|
243
|
+
*/
|
|
244
|
+
dueEntries(tree) {
|
|
245
|
+
return { [DUE_KEY]: tree };
|
|
246
|
+
}
|
|
247
|
+
/** Take the snapshot an install asked for, if one is waiting. */
|
|
248
|
+
async takeDue() {
|
|
249
|
+
const due = await this.deps.storage.get(DUE_KEY);
|
|
250
|
+
if (!due)
|
|
251
|
+
return;
|
|
252
|
+
await this.deps.storage.delete(DUE_KEY);
|
|
253
|
+
await this.take(due);
|
|
254
|
+
}
|
|
255
|
+
/** The container went, and the disk an asked-for snapshot was of with it. */
|
|
256
|
+
async forgetDue() {
|
|
257
|
+
await this.deps.storage.delete(DUE_KEY);
|
|
258
|
+
}
|
|
259
|
+
async get() {
|
|
260
|
+
return await this.deps.storage.get(RECORD_KEY);
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Start from another workspace's snapshot — one taken for the same checkout
|
|
264
|
+
* path on the same image, as a worktree shares with the checkout it was cut
|
|
265
|
+
* from.
|
|
266
|
+
*
|
|
267
|
+
* Kept out when this workspace's own snapshot already fits, and refused when
|
|
268
|
+
* the offered one does not.
|
|
269
|
+
*/
|
|
270
|
+
async seed(record, expected) {
|
|
271
|
+
if (!isRecord(record))
|
|
272
|
+
return { seeded: false, reason: "not a snapshot" };
|
|
273
|
+
const now = await expected();
|
|
274
|
+
const fits = async () => now;
|
|
275
|
+
const own = await this.get();
|
|
276
|
+
if (own && !(await this.#refusal(own, fits)))
|
|
277
|
+
return { seeded: false, reason: "this workspace's own snapshot fits" };
|
|
278
|
+
const refused = await this.#refusal(record, fits);
|
|
279
|
+
if (refused)
|
|
280
|
+
return { seeded: false, reason: refused };
|
|
281
|
+
await this.deps.storage.put(RECORD_KEY, record);
|
|
282
|
+
console.info(`[${this.deps.tag()}] seeded a dependency snapshot`, {
|
|
283
|
+
id: this.deps.id(),
|
|
284
|
+
snapshot: record.id,
|
|
285
|
+
dir: record.dir
|
|
286
|
+
});
|
|
287
|
+
return { seeded: true };
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Why `record` must not be restored now, or `undefined` if it may. A check
|
|
291
|
+
* that throws refuses: a start must not fail for a snapshot it can go without.
|
|
292
|
+
*/
|
|
293
|
+
async #refusal(record, expected) {
|
|
294
|
+
if (record.image !== this.deps.image())
|
|
295
|
+
return "taken on another image";
|
|
296
|
+
try {
|
|
297
|
+
const tree = await expected();
|
|
298
|
+
if (!tree)
|
|
299
|
+
return "there is nothing to install here";
|
|
300
|
+
if (record.dir !== tree.dir)
|
|
301
|
+
return "taken for another checkout";
|
|
302
|
+
if (record.fingerprint !== tree.fingerprint)
|
|
303
|
+
return "the install's inputs have changed";
|
|
304
|
+
for (const root of record.roots) {
|
|
305
|
+
if ((await this.deps.rootFingerprint(root.dir)) !== root.fingerprint)
|
|
306
|
+
return `${root.dir} has changed`;
|
|
307
|
+
}
|
|
308
|
+
if ((await packageRootSet(this.deps.workspace().fs, tree.dir)) !==
|
|
309
|
+
record.rootSet)
|
|
310
|
+
return "the checkout's package roots have changed";
|
|
311
|
+
return undefined;
|
|
312
|
+
}
|
|
313
|
+
catch (err) {
|
|
314
|
+
return `it could not be checked: ${String(err)}`;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
/** Whether the install record still names `tree`. */
|
|
318
|
+
async #holds(tree) {
|
|
319
|
+
const now = await this.deps.tree();
|
|
320
|
+
return (now?.dir === tree.dir &&
|
|
321
|
+
now.fingerprint === tree.fingerprint &&
|
|
322
|
+
now.at === tree.at);
|
|
323
|
+
}
|
|
324
|
+
async #drop(record, reason) {
|
|
325
|
+
// Only this record: a later install may have put a newer one there.
|
|
326
|
+
if ((await this.get())?.id === record.id)
|
|
327
|
+
await this.deps.storage.delete(RECORD_KEY);
|
|
328
|
+
console.warn(`[${this.deps.tag()}] dropped the dependency snapshot`, {
|
|
329
|
+
id: this.deps.id(),
|
|
330
|
+
snapshot: record.id,
|
|
331
|
+
reason
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
async #take(tree) {
|
|
335
|
+
const container = this.deps.container();
|
|
336
|
+
const image = this.deps.image();
|
|
337
|
+
if (!container?.running || image === undefined)
|
|
338
|
+
return;
|
|
339
|
+
// Replaced, or reinstalling, since the install asked.
|
|
340
|
+
if (!(await this.#holds(tree)))
|
|
341
|
+
return;
|
|
342
|
+
const startedAt = Date.now();
|
|
343
|
+
try {
|
|
344
|
+
const roots = await this.#roots();
|
|
345
|
+
// Without a root to mount, a restore would read no marker and be dropped.
|
|
346
|
+
if (roots.length === 0) {
|
|
347
|
+
console.warn(`[${this.deps.tag()}] no dependency tree to snapshot on this disk`, { id: this.deps.id(), dir: tree.dir });
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
const fingerprinted = [];
|
|
351
|
+
for (const dir of roots)
|
|
352
|
+
fingerprinted.push({
|
|
353
|
+
dir,
|
|
354
|
+
fingerprint: await this.deps.rootFingerprint(dir)
|
|
355
|
+
});
|
|
356
|
+
const rootSet = await packageRootSet(this.deps.workspace().fs, tree.dir);
|
|
357
|
+
const snapshot = await container.snapshotContainer({
|
|
358
|
+
name: `deps-${tree.fingerprint.slice(0, 16)}`
|
|
359
|
+
});
|
|
360
|
+
if (!(await this.#holds(tree))) {
|
|
361
|
+
console.warn(`[${this.deps.tag()}] discarded a dependency snapshot taken while the tree changed`, { id: this.deps.id(), snapshot: snapshot.id, dir: tree.dir });
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
await this.deps.storage.put(RECORD_KEY, {
|
|
365
|
+
id: snapshot.id,
|
|
366
|
+
size: snapshot.size,
|
|
367
|
+
image,
|
|
368
|
+
dir: tree.dir,
|
|
369
|
+
fingerprint: tree.fingerprint,
|
|
370
|
+
roots: fingerprinted,
|
|
371
|
+
rootSet,
|
|
372
|
+
at: Date.now()
|
|
373
|
+
});
|
|
374
|
+
console.info(`[${this.deps.tag()}] took a dependency snapshot`, {
|
|
375
|
+
id: this.deps.id(),
|
|
376
|
+
snapshot: snapshot.id,
|
|
377
|
+
bytes: snapshot.size,
|
|
378
|
+
roots,
|
|
379
|
+
ms: Date.now() - startedAt
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
catch (err) {
|
|
383
|
+
console.warn(`[${this.deps.tag()}] could not take a dependency snapshot`, {
|
|
384
|
+
id: this.deps.id(),
|
|
385
|
+
err: String(err)
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
/** The package roots whose trees are on this container's disk. */
|
|
390
|
+
async #roots() {
|
|
391
|
+
const env_1 = { stack: [], error: void 0, hasError: false };
|
|
392
|
+
try {
|
|
393
|
+
const handle = __addDisposableResource(env_1, await this.deps
|
|
394
|
+
.workspace()
|
|
395
|
+
.runtime.exec(DEPS_ROOTS_COMMAND, {
|
|
396
|
+
cwd: "/",
|
|
397
|
+
encoding: "utf8",
|
|
398
|
+
timeoutMs: 30_000
|
|
399
|
+
}), false);
|
|
400
|
+
const result = await handle.result();
|
|
401
|
+
if (result.exitCode !== 0)
|
|
402
|
+
throw new Error(`could not list the dependency trees: ${result.stderr || `exit ${result.exitCode}`}`);
|
|
403
|
+
return parseDepsRoots(result.stdout ?? "");
|
|
404
|
+
}
|
|
405
|
+
catch (e_1) {
|
|
406
|
+
env_1.error = e_1;
|
|
407
|
+
env_1.hasError = true;
|
|
408
|
+
}
|
|
409
|
+
finally {
|
|
410
|
+
__disposeResources(env_1);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
/** Enough of a record's shape to restore from — it arrives over RPC. */
|
|
415
|
+
function isRecord(value) {
|
|
416
|
+
const r = value;
|
|
417
|
+
return (typeof r?.id === "string" &&
|
|
418
|
+
typeof r.image === "string" &&
|
|
419
|
+
typeof r.dir === "string" &&
|
|
420
|
+
typeof r.fingerprint === "string" &&
|
|
421
|
+
typeof r.rootSet === "string" &&
|
|
422
|
+
Array.isArray(r.roots) &&
|
|
423
|
+
r.roots.every((root) => typeof root?.dir === "string"));
|
|
424
|
+
}
|
|
@@ -42,7 +42,8 @@ export { workspaceExec, DEFAULT_CWD, DEFAULT_TIMEOUT_MS, type WorkspaceClientCon
|
|
|
42
42
|
* import time is a base that evaluates `undefined`.
|
|
43
43
|
*/
|
|
44
44
|
export { WorkspaceObjectBase, IDLE_RECLAIM_MS, WORKSPACE_DIR, workspaceName, type WorkspaceObjectConfig, type WorkspaceGitConfig } from "./object.js";
|
|
45
|
-
export { InstallJob, type InstallJobDeps } from "./install-job.js";
|
|
45
|
+
export { InstallJob, type InstallJobDeps, type InstallTarget } from "./install-job.js";
|
|
46
|
+
export type { DepsSnapshotRecord } from "./deps-snapshot.js";
|
|
46
47
|
export { WorkspaceSync, SYNC_DRAIN_BUDGET_MS, SYNC_DRAIN_RESUME_MS, SYNC_DRAIN_MAX_BACKOFF_MS, syncRetryDelayMs, type DrainOutcome, type SyncDrainIntent, type WorkspaceSyncDeps } from "./sync.js";
|
|
47
48
|
export { ContainerTrust, TRUST_CA_COMMAND, type ContainerTrustDeps } from "./ca-trust.js";
|
|
48
49
|
export { WorkspaceGitHost, type GitHostDeps } from "./git-host.js";
|
|
@@ -22,9 +22,30 @@ export interface InstallJobDeps {
|
|
|
22
22
|
waitUntil: (promise: Promise<unknown>) => void;
|
|
23
23
|
/** A container was replaced, so nothing believed about it still holds. */
|
|
24
24
|
containerGone: () => Promise<void>;
|
|
25
|
+
/**
|
|
26
|
+
* Storage entries the host keeps about a tree an install finished, written in
|
|
27
|
+
* the same write as that install's verdict — nothing can read the one without
|
|
28
|
+
* the other.
|
|
29
|
+
*/
|
|
30
|
+
installedRecords: (tree: {
|
|
31
|
+
dir: string;
|
|
32
|
+
fingerprint: string;
|
|
33
|
+
at: number;
|
|
34
|
+
}) => Record<string, unknown>;
|
|
35
|
+
/** After that write: an install exited 0, so this container holds the tree. */
|
|
36
|
+
installed: (tree: {
|
|
37
|
+
dir: string;
|
|
38
|
+
fingerprint: string;
|
|
39
|
+
at: number;
|
|
40
|
+
}) => Promise<void>;
|
|
25
41
|
tag: () => string;
|
|
26
42
|
id: () => string;
|
|
27
43
|
}
|
|
44
|
+
/** Where an install goes: the checkout, and the repository for its override. */
|
|
45
|
+
export interface InstallTarget {
|
|
46
|
+
dir: string;
|
|
47
|
+
repo?: string;
|
|
48
|
+
}
|
|
28
49
|
export declare class InstallJob {
|
|
29
50
|
#private;
|
|
30
51
|
private readonly deps;
|
|
@@ -48,6 +69,34 @@ export declare class InstallJob {
|
|
|
48
69
|
armIfTreeMissing(): Promise<void>;
|
|
49
70
|
/** Where the last install ran, for the container setup's marker read. */
|
|
50
71
|
dir(): Promise<string | undefined>;
|
|
72
|
+
/** Where the last install ran, and for which repository's override. */
|
|
73
|
+
target(): Promise<InstallTarget | undefined>;
|
|
74
|
+
/**
|
|
75
|
+
* The fingerprint an install into `target` would write as its marker, from the
|
|
76
|
+
* checkout as it stands — `null` when there is nothing to install there.
|
|
77
|
+
*/
|
|
78
|
+
fingerprintFor(target: InstallTarget): Promise<string | null>;
|
|
79
|
+
/** What `dir`'s install inputs hash to — see `rootFingerprint`. */
|
|
80
|
+
rootFingerprint(dir: string): Promise<string | null>;
|
|
81
|
+
/** The tree this container holds, if a finished install says it does. */
|
|
82
|
+
tree(): Promise<{
|
|
83
|
+
dir: string;
|
|
84
|
+
fingerprint: string | null;
|
|
85
|
+
at: number;
|
|
86
|
+
} | undefined>;
|
|
87
|
+
/**
|
|
88
|
+
* Record a tree this container came up holding — restored with a snapshot of
|
|
89
|
+
* the container that installed it — as if this container had installed it.
|
|
90
|
+
*
|
|
91
|
+
* Only for a tree whose marker the setup read and matched against the checkout
|
|
92
|
+
* as it stands; see `./deps-snapshot.ts`. So the last install's context moves
|
|
93
|
+
* to it too: a checkout that moved since that install would otherwise read as
|
|
94
|
+
* missing its tree, and arm an install that finds the tree already there.
|
|
95
|
+
*/
|
|
96
|
+
adopt(tree: {
|
|
97
|
+
dir: string;
|
|
98
|
+
fingerprint: string;
|
|
99
|
+
}): Promise<void>;
|
|
51
100
|
/** The container went away, and its tree with it. */
|
|
52
101
|
containerGone(): Promise<void>;
|
|
53
102
|
/**
|
|
@@ -4,7 +4,7 @@ import { JobLifecycle } from "@dynamicagents/core/job";
|
|
|
4
4
|
// re-exports this directory, so reaching it from here would be a cycle — through
|
|
5
5
|
// a module that builds a class at import time, where initialisation order
|
|
6
6
|
// decides whether the base is `undefined`.
|
|
7
|
-
import { installFingerprint, resolveInstallCommand } from "./install.js";
|
|
7
|
+
import { installFingerprint, resolveInstallCommand, rootFingerprint } from "./install.js";
|
|
8
8
|
import { pathExists } from "./read.js";
|
|
9
9
|
import { deploymentFault } from "./container-fault.js";
|
|
10
10
|
import { INSTALLED_MARKER } from "./container-deps.js";
|
|
@@ -38,8 +38,9 @@ function execWasLost(err) {
|
|
|
38
38
|
* ## What decides that an install is needed
|
|
39
39
|
*
|
|
40
40
|
* The tree lives on the container's disk (see `./container-deps.ts`), so it goes
|
|
41
|
-
* with the container. {@link CONTAINER_TREE_KEY} records a successful install
|
|
42
|
-
*
|
|
41
|
+
* with the container. {@link CONTAINER_TREE_KEY} records a successful install — or
|
|
42
|
+
* a tree restored with a snapshot of the container that ran one, through
|
|
43
|
+
* {@link InstallJob.adopt} — and is cleared whenever that container is seen gone.
|
|
43
44
|
*/
|
|
44
45
|
/**
|
|
45
46
|
* The install's job id, which is also the key its state record lives under.
|
|
@@ -212,6 +213,57 @@ export class InstallJob {
|
|
|
212
213
|
async dir() {
|
|
213
214
|
return (await this.#job.context())?.dir;
|
|
214
215
|
}
|
|
216
|
+
/** Where the last install ran, and for which repository's override. */
|
|
217
|
+
async target() {
|
|
218
|
+
const context = await this.#job.context();
|
|
219
|
+
if (!context?.dir)
|
|
220
|
+
return undefined;
|
|
221
|
+
return {
|
|
222
|
+
dir: context.dir,
|
|
223
|
+
...(context.repo ? { repo: context.repo } : {})
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* The fingerprint an install into `target` would write as its marker, from the
|
|
228
|
+
* checkout as it stands — `null` when there is nothing to install there.
|
|
229
|
+
*/
|
|
230
|
+
async fingerprintFor(target) {
|
|
231
|
+
const probe = this.#probe();
|
|
232
|
+
const resolution = await resolveInstallCommand(probe, target.dir, this.deps.plan(), target.repo);
|
|
233
|
+
return await installFingerprint(probe, target.dir, resolution);
|
|
234
|
+
}
|
|
235
|
+
/** What `dir`'s install inputs hash to — see `rootFingerprint`. */
|
|
236
|
+
async rootFingerprint(dir) {
|
|
237
|
+
return await rootFingerprint(this.#probe(), dir, this.deps.plan());
|
|
238
|
+
}
|
|
239
|
+
/** The tree this container holds, if a finished install says it does. */
|
|
240
|
+
async tree() {
|
|
241
|
+
const tree = await this.#tree();
|
|
242
|
+
return (tree && { dir: tree.dir, fingerprint: tree.fingerprint, at: tree.at });
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Record a tree this container came up holding — restored with a snapshot of
|
|
246
|
+
* the container that installed it — as if this container had installed it.
|
|
247
|
+
*
|
|
248
|
+
* Only for a tree whose marker the setup read and matched against the checkout
|
|
249
|
+
* as it stands; see `./deps-snapshot.ts`. So the last install's context moves
|
|
250
|
+
* to it too: a checkout that moved since that install would otherwise read as
|
|
251
|
+
* missing its tree, and arm an install that finds the tree already there.
|
|
252
|
+
*/
|
|
253
|
+
async adopt(tree) {
|
|
254
|
+
await this.deps.storage.put(CONTAINER_TREE_KEY, {
|
|
255
|
+
dir: tree.dir,
|
|
256
|
+
fingerprint: tree.fingerprint,
|
|
257
|
+
at: Date.now()
|
|
258
|
+
});
|
|
259
|
+
const context = await this.#job.context();
|
|
260
|
+
if (context?.dir === tree.dir && context.fingerprint !== tree.fingerprint)
|
|
261
|
+
await this.#job.putContext({ ...context, fingerprint: tree.fingerprint });
|
|
262
|
+
console.info(`[${this.deps.tag()}] adopted a restored dependency tree`, {
|
|
263
|
+
id: this.deps.id(),
|
|
264
|
+
dir: tree.dir
|
|
265
|
+
});
|
|
266
|
+
}
|
|
215
267
|
/** The container went away, and its tree with it. */
|
|
216
268
|
async containerGone() {
|
|
217
269
|
await this.deps.storage.delete(CONTAINER_TREE_KEY);
|
|
@@ -673,8 +725,10 @@ export class InstallJob {
|
|
|
673
725
|
return false;
|
|
674
726
|
};
|
|
675
727
|
const settle = async (verdict, also) => {
|
|
676
|
-
if (
|
|
677
|
-
|
|
728
|
+
if (await run.write(verdict, also))
|
|
729
|
+
return true;
|
|
730
|
+
await superseded();
|
|
731
|
+
return false;
|
|
678
732
|
};
|
|
679
733
|
try {
|
|
680
734
|
const result = await handle.result();
|
|
@@ -700,14 +754,28 @@ export class InstallJob {
|
|
|
700
754
|
command,
|
|
701
755
|
seconds: Math.round((Date.now() - startedAt) / 1000)
|
|
702
756
|
});
|
|
703
|
-
|
|
757
|
+
const installed = tree?.fingerprint
|
|
758
|
+
? { dir: tree.dir, fingerprint: tree.fingerprint, at: tree.at }
|
|
759
|
+
: undefined;
|
|
760
|
+
const kept = await settle({
|
|
704
761
|
state: "done",
|
|
705
762
|
command,
|
|
706
763
|
exitCode: 0,
|
|
707
764
|
finishedAt: Date.now(),
|
|
708
765
|
ms: Date.now() - startedAt,
|
|
709
766
|
tail
|
|
710
|
-
}, tree
|
|
767
|
+
}, tree
|
|
768
|
+
? {
|
|
769
|
+
...(installed ? this.deps.installedRecords(installed) : {}),
|
|
770
|
+
[CONTAINER_TREE_KEY]: tree
|
|
771
|
+
}
|
|
772
|
+
: undefined);
|
|
773
|
+
// Outside the verdict: a host that cannot act on the news must not turn
|
|
774
|
+
// a finished install into a failed one, which the catch below would.
|
|
775
|
+
if (kept && installed)
|
|
776
|
+
await this.deps.installed(installed).catch((err) => {
|
|
777
|
+
console.warn(`[${this.deps.tag()}] could not act on a finished install`, { id: this.deps.id(), err: String(err) });
|
|
778
|
+
});
|
|
711
779
|
}
|
|
712
780
|
else {
|
|
713
781
|
// Logged, and this line is not optional. This is the *ordinary* way an
|
|
@@ -187,3 +187,12 @@ export declare function resolveInstallCommand(fs: InstallProbe, dir: string, pla
|
|
|
187
187
|
* finished it — see `./install-job.ts`.
|
|
188
188
|
*/
|
|
189
189
|
export declare function installFingerprint(fs: InstallProbe, dir: string, resolution: InstallResolution): Promise<string | null>;
|
|
190
|
+
/**
|
|
191
|
+
* The same digest for a package root an install reached without being pointed
|
|
192
|
+
* at it — a submodule a bootstrap installs, say — where there is no command to
|
|
193
|
+
* fold in, and every lockfile the plan knows stands in for the one it would pick.
|
|
194
|
+
*
|
|
195
|
+
* For `./deps-snapshot.ts`, which restores every root's tree at once and so has
|
|
196
|
+
* to know none of them moved.
|
|
197
|
+
*/
|
|
198
|
+
export declare function rootFingerprint(fs: InstallProbe, dir: string, plan: InstallPlan): Promise<string | null>;
|
|
@@ -96,6 +96,10 @@ function pinnedManager(packageJson) {
|
|
|
96
96
|
return undefined;
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
|
+
/** Every lockfile the plan names, each once. */
|
|
100
|
+
function planLockfiles(plan) {
|
|
101
|
+
return [...new Set(plan.rules.flatMap((r) => [...r.lockfiles]))];
|
|
102
|
+
}
|
|
99
103
|
/**
|
|
100
104
|
* Every lockfile the plan knows of that is present in `dir`.
|
|
101
105
|
*
|
|
@@ -103,9 +107,8 @@ function pinnedManager(packageJson) {
|
|
|
103
107
|
* about which manager it drives.
|
|
104
108
|
*/
|
|
105
109
|
async function knownLockfiles(fs, dir, plan) {
|
|
106
|
-
const names = [...new Set(plan.rules.flatMap((r) => [...r.lockfiles]))];
|
|
107
110
|
const present = [];
|
|
108
|
-
for (const name of
|
|
111
|
+
for (const name of planLockfiles(plan)) {
|
|
109
112
|
if (await fs.exists(`${dir}/${name}`))
|
|
110
113
|
present.push(name);
|
|
111
114
|
}
|
|
@@ -273,10 +276,25 @@ export async function installFingerprint(fs, dir, resolution) {
|
|
|
273
276
|
...resolution.lockfiles,
|
|
274
277
|
...INSTALL_CONFIG_FILES
|
|
275
278
|
];
|
|
279
|
+
return await digestFiles(fs, dir, names, resolution.command);
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* The same digest for a package root an install reached without being pointed
|
|
283
|
+
* at it — a submodule a bootstrap installs, say — where there is no command to
|
|
284
|
+
* fold in, and every lockfile the plan knows stands in for the one it would pick.
|
|
285
|
+
*
|
|
286
|
+
* For `./deps-snapshot.ts`, which restores every root's tree at once and so has
|
|
287
|
+
* to know none of them moved.
|
|
288
|
+
*/
|
|
289
|
+
export async function rootFingerprint(fs, dir, plan) {
|
|
290
|
+
return await digestFiles(fs, dir, ["package.json", ...planLockfiles(plan), ...INSTALL_CONFIG_FILES], "");
|
|
291
|
+
}
|
|
292
|
+
/** `prefix`, then every file of `names` present in `dir`; null when none is. */
|
|
293
|
+
async function digestFiles(fs, dir, names, prefix) {
|
|
276
294
|
// Names as well as contents. Two lockfiles will not collide on content in
|
|
277
295
|
// practice, but a digest that cannot say *which* file it read is one that
|
|
278
296
|
// silently depends on the caller resolving them in the same order forever.
|
|
279
|
-
let input =
|
|
297
|
+
let input = prefix;
|
|
280
298
|
let found = false;
|
|
281
299
|
for (const name of names) {
|
|
282
300
|
if (!(await fs.exists(`${dir}/${name}`)))
|