@dynamicagents/plugins 0.13.0 → 0.15.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.
Files changed (141) hide show
  1. package/README.md +14 -14
  2. package/dist/claude-code/cards.d.ts +4 -0
  3. package/dist/claude-code/cards.js +22 -0
  4. package/dist/claude-code/config.d.ts +10 -83
  5. package/dist/claude-code/config.js +0 -2
  6. package/dist/claude-code/credentials.d.ts +32 -50
  7. package/dist/claude-code/credentials.js +115 -74
  8. package/dist/claude-code/daemon/index.d.ts +24 -0
  9. package/dist/claude-code/daemon/index.js +203 -0
  10. package/dist/claude-code/egress.d.ts +35 -117
  11. package/dist/claude-code/egress.js +80 -325
  12. package/dist/claude-code/events.d.ts +14 -17
  13. package/dist/claude-code/events.js +19 -15
  14. package/dist/claude-code/harness-wire.d.ts +45 -0
  15. package/dist/claude-code/harness-wire.js +47 -0
  16. package/dist/claude-code/index.d.ts +19 -29
  17. package/dist/claude-code/index.js +17 -25
  18. package/dist/claude-code/language-model.d.ts +19 -0
  19. package/dist/claude-code/language-model.js +71 -0
  20. package/dist/claude-code/model.d.ts +77 -70
  21. package/dist/claude-code/model.js +328 -304
  22. package/dist/claude-code/placeholder.d.ts +12 -0
  23. package/dist/claude-code/placeholder.js +12 -0
  24. package/dist/claude-code/spec.d.ts +4 -4
  25. package/dist/claude-code/spec.js +4 -4
  26. package/dist/claude-code/verified.d.ts +1 -1
  27. package/dist/claude-code/verified.js +1 -1
  28. package/dist/computer/client.d.ts +88 -0
  29. package/dist/computer/client.js +6 -0
  30. package/dist/computer/context.d.ts +7 -7
  31. package/dist/computer/context.js +14 -18
  32. package/dist/computer/gate.d.ts +4 -23
  33. package/dist/computer/gate.js +1 -37
  34. package/dist/computer/index.d.ts +44 -43
  35. package/dist/computer/index.js +29 -50
  36. package/dist/computer/paths.d.ts +5 -27
  37. package/dist/computer/paths.js +7 -30
  38. package/dist/computer/proxy.d.ts +17 -31
  39. package/dist/computer/proxy.js +9 -44
  40. package/dist/computer/render.d.ts +3 -40
  41. package/dist/computer/render.js +3 -36
  42. package/dist/computer/sandbox.d.ts +55 -0
  43. package/dist/computer/sandbox.js +236 -0
  44. package/dist/computer/shell.d.ts +36 -0
  45. package/dist/computer/shell.js +41 -0
  46. package/dist/computer/tools-exec.js +3 -9
  47. package/dist/computer/tools-file.d.ts +1 -1
  48. package/dist/computer/tools-file.js +1 -1
  49. package/dist/{repo → repo-github}/checkout.d.ts +3 -4
  50. package/dist/{repo → repo-github}/checkout.js +3 -4
  51. package/dist/{repo → repo-github}/context.d.ts +14 -32
  52. package/dist/{repo → repo-github}/context.js +46 -81
  53. package/dist/{repo → repo-github}/index.d.ts +30 -13
  54. package/dist/{repo → repo-github}/index.js +25 -6
  55. package/dist/repo-github/mcp.d.ts +95 -0
  56. package/dist/repo-github/mcp.js +107 -0
  57. package/dist/repo-github/sandbox-egress.d.ts +68 -0
  58. package/dist/repo-github/sandbox-egress.js +272 -0
  59. package/dist/repo-github/sandbox-git.d.ts +30 -0
  60. package/dist/repo-github/sandbox-git.js +113 -0
  61. package/dist/{repo → repo-github}/tools-checks.js +43 -4
  62. package/dist/{repo → repo-github}/tools-clone.js +2 -21
  63. package/dist/{repo → repo-github}/tools-pr.d.ts +0 -1
  64. package/dist/{repo → repo-github}/tools-pr.js +85 -20
  65. package/dist/{repo → repo-github}/tools-review.d.ts +6 -0
  66. package/dist/{repo → repo-github}/tools-review.js +62 -176
  67. package/dist/{repo → repo-github}/url.d.ts +11 -0
  68. package/dist/{repo → repo-github}/url.js +12 -1
  69. package/dist/{workspace → sandbox}/advisory.d.ts +16 -35
  70. package/dist/{workspace → sandbox}/advisory.js +2 -29
  71. package/dist/sandbox/agent-exec.d.ts +31 -0
  72. package/dist/sandbox/agent-exec.js +62 -0
  73. package/dist/sandbox/ca-trust.d.ts +9 -0
  74. package/dist/sandbox/ca-trust.js +21 -0
  75. package/dist/sandbox/constants.d.ts +22 -0
  76. package/dist/sandbox/constants.js +22 -0
  77. package/dist/sandbox/daemon/composite.d.ts +9 -0
  78. package/dist/sandbox/daemon/composite.js +35 -0
  79. package/dist/sandbox/daemon/index.d.ts +47 -0
  80. package/dist/sandbox/daemon/index.js +116 -0
  81. package/dist/sandbox/egress.d.ts +81 -0
  82. package/dist/sandbox/egress.js +109 -0
  83. package/dist/sandbox/exec.d.ts +57 -0
  84. package/dist/sandbox/exec.js +52 -0
  85. package/dist/sandbox/format.d.ts +2 -0
  86. package/dist/sandbox/format.js +7 -0
  87. package/dist/sandbox/header.d.ts +50 -0
  88. package/dist/sandbox/header.js +94 -0
  89. package/dist/sandbox/index.d.ts +17 -0
  90. package/dist/sandbox/index.js +17 -0
  91. package/dist/{workspace/install.d.ts → sandbox/install-plan.d.ts} +10 -1
  92. package/dist/{workspace/install.js → sandbox/install-plan.js} +22 -4
  93. package/dist/sandbox/name.d.ts +27 -0
  94. package/dist/sandbox/name.js +32 -0
  95. package/dist/sandbox/object.d.ts +244 -0
  96. package/dist/sandbox/object.js +1139 -0
  97. package/dist/scratch/index.d.ts +1 -9
  98. package/dist/scratch/index.js +2 -18
  99. package/package.json +26 -22
  100. package/dist/claude-code/github.d.ts +0 -39
  101. package/dist/claude-code/github.js +0 -82
  102. package/dist/claude-code/run.d.ts +0 -474
  103. package/dist/claude-code/run.js +0 -901
  104. package/dist/claude-code/session.d.ts +0 -131
  105. package/dist/claude-code/session.js +0 -285
  106. package/dist/workspace/ca-trust.d.ts +0 -95
  107. package/dist/workspace/ca-trust.js +0 -312
  108. package/dist/workspace/container-deps.d.ts +0 -54
  109. package/dist/workspace/container-deps.js +0 -224
  110. package/dist/workspace/container-fault.d.ts +0 -38
  111. package/dist/workspace/container-fault.js +0 -73
  112. package/dist/workspace/exec.d.ts +0 -94
  113. package/dist/workspace/exec.js +0 -153
  114. package/dist/workspace/format.d.ts +0 -36
  115. package/dist/workspace/format.js +0 -57
  116. package/dist/workspace/git-host.d.ts +0 -79
  117. package/dist/workspace/git-host.js +0 -226
  118. package/dist/workspace/git-identity.d.ts +0 -57
  119. package/dist/workspace/git-identity.js +0 -147
  120. package/dist/workspace/index.d.ts +0 -49
  121. package/dist/workspace/index.js +0 -58
  122. package/dist/workspace/install-job.d.ts +0 -121
  123. package/dist/workspace/install-job.js +0 -866
  124. package/dist/workspace/object.d.ts +0 -502
  125. package/dist/workspace/object.js +0 -1427
  126. package/dist/workspace/open.d.ts +0 -95
  127. package/dist/workspace/open.js +0 -63
  128. package/dist/workspace/read.d.ts +0 -16
  129. package/dist/workspace/read.js +0 -22
  130. package/dist/workspace/shell.d.ts +0 -45
  131. package/dist/workspace/shell.js +0 -80
  132. package/dist/workspace/sync.d.ts +0 -111
  133. package/dist/workspace/sync.js +0 -124
  134. package/dist/workspace/wake.d.ts +0 -19
  135. package/dist/workspace/wake.js +0 -10
  136. /package/dist/{repo → repo-github}/tools-checks.d.ts +0 -0
  137. /package/dist/{repo → repo-github}/tools-clone.d.ts +0 -0
  138. /package/dist/{repo → repo-github}/tools-worktree.d.ts +0 -0
  139. /package/dist/{repo → repo-github}/tools-worktree.js +0 -0
  140. /package/dist/{repo → repo-github}/tools-worktrees.d.ts +0 -0
  141. /package/dist/{repo → repo-github}/tools-worktrees.js +0 -0
@@ -1,866 +0,0 @@
1
- import { shellQuote } from "@cloudflare/computer";
2
- import { JobLifecycle } from "@dynamicagents/core/job";
3
- // Leaf modules rather than `./index.ts`, and that is structural: the barrel
4
- // re-exports this directory, so reaching it from here would be a cycle — through
5
- // a module that builds a class at import time, where initialisation order
6
- // decides whether the base is `undefined`.
7
- import { installFingerprint, resolveInstallCommand } from "./install.js";
8
- import { pathExists } from "./read.js";
9
- import { deploymentFault } from "./container-fault.js";
10
- import { INSTALLED_MARKER } from "./container-deps.js";
11
- import { truncateOutput } from "./format.js";
12
- /**
13
- * Whether a thrown value is the runtime reporting a replaced container.
14
- *
15
- * `code` rather than the message: the property the package sets deliberately,
16
- * and the one that survives a reworded string. What it means here is narrower
17
- * than "the command failed" — the container that command was running in is gone,
18
- * so anything this object believed about *that* container is now about nothing.
19
- */
20
- function execWasLost(err) {
21
- return err?.code === "EEXEC_LOST";
22
- }
23
- /**
24
- * The dependency install: when it runs, what it runs, and what it owes on
25
- * waking.
26
- *
27
- * ## The rule every guard here serves
28
- *
29
- * `running` is the one install state that **blocks work**: the plugin's gate
30
- * waits on it and then refuses to run. Every other state is a fact an agent
31
- * can act on. So a `running` record must never outlive the command it describes,
32
- * and the ways it can are not all reachable from one place — the spawn can fail
33
- * before a drain is attached, a drain can be cut short by an eviction, `getExec`
34
- * can hand back a handle to a container that never answers, and installs can
35
- * displace each other. Each guard below names the one it closes; the staleness
36
- * bound in {@link InstallJob.state} is the proof that covers the rest.
37
- *
38
- * ## What decides that an install is needed
39
- *
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 and
42
- * is cleared whenever that container is seen gone.
43
- */
44
- /**
45
- * The install's job id, which is also the key its state record lives under.
46
- *
47
- * Every other key is derived from it by `JobLifecycle`: `install:armed`,
48
- * `install:last-armed`, `install:context`, and the wake intents `install-run`
49
- * and `install-watch`. Changing this value renames all of them, which is a
50
- * storage migration — the specs that read these keys directly would fail first.
51
- */
52
- const INSTALL_KEY = "install";
53
- /**
54
- * How long after arming an install before arming another.
55
- *
56
- * The bound on a *failing* install. A successful one is self-limiting — it
57
- * leaves a tree in the workspace, so the arming probe short-circuits every later
58
- * call — but a failure lands back on `failed` with the tree still absent, and
59
- * `__getWorkspaceStub` is the busiest entry point in the object, so it would
60
- * re-arm on essentially every tool call. Five minutes is far longer than an
61
- * install (88s measured), so a broken install retries at roughly the rate a
62
- * human would and a later task still gets a fresh attempt.
63
- */
64
- const INSTALL_ARM_COOLDOWN_MS = 5 * 60_000;
65
- /** How often the watchdog checks on a running install. */
66
- const INSTALL_WATCH_MS = 60_000;
67
- /**
68
- * How far past its own timeout a `running` install is still given the benefit of
69
- * the doubt. Generous on purpose: declaring a live install dead costs a
70
- * duplicate `npm ci`, and the margin only has to cover the slack between the
71
- * runtime killing a command and this object hearing about it.
72
- */
73
- const INSTALL_STALE_MS = 5 * 60_000;
74
- /**
75
- * What the running container holds: the tree a host install finished, for which
76
- * directory and lockfile. Written on exit 0, deleted when the container goes.
77
- */
78
- const CONTAINER_TREE_KEY = "install:tree";
79
- /** Records a deployed object may still hold, deleted on the next install. */
80
- const RETIRED_KEYS = ["install:syncing", "install:completed"];
81
- /**
82
- * The command a reservation's placeholder names, until the install's own is
83
- * resolved. The gate renders it while it waits.
84
- */
85
- const RESOLVING = "(being resolved)";
86
- /**
87
- * The exec id an install runs under.
88
- *
89
- * Fixed rather than generated, because the point is to find it again: an
90
- * isolate that dies mid-drain leaves the command running in the container, and
91
- * `getExec(id, { resume: "tail" })` is how the next invocation re-attaches
92
- * instead of starting a second `npm ci` alongside the first.
93
- */
94
- const INSTALL_EXEC_ID = "dependency-install";
95
- export class InstallJob {
96
- deps;
97
- /**
98
- * The dependency install, as a job this object owns through its alarm.
99
- *
100
- * `JobLifecycle` is core's, and what it owns is the choreography that is wrong
101
- * the same ways every time: arming before anything runs, one job at a time
102
- * under a staleness bound, a drain that can outlive its job, and a job nobody
103
- * is draining. The timings below are this install's. The **drain
104
- * loop stays here**, because an install runs to completion and writes a single
105
- * verdict rather than reporting progress between bounded windows.
106
- *
107
- * **The alarm runs the install, and that is not a detail.** An install takes
108
- * ~85 seconds and must not be owned by the request that noticed it was needed
109
- * — a drain handed to `ctx.waitUntil` from a gate poll that returns in
110
- * milliseconds is disposed mid-`npm ci`. An alarm invocation belongs to the
111
- * object rather than to any caller, so nothing it awaits can be cut short by a
112
- * response being sent.
113
- *
114
- * **Arming writes `running` before anything is running**, which holds the gate
115
- * shut in the moments before the alarm fires. The alarm then presents the stamp
116
- * arming wrote to `reserve`, which recognises its own placeholder and nothing
117
- * else — taking over any `running` record instead would be displacement again.
118
- *
119
- * Every write a run makes goes through the lifecycle's `generation`, under
120
- * the `startedAt` its reservation returned, so a run that has been displaced
121
- * writes nothing.
122
- */
123
- #job;
124
- // Built here rather than as a field initializer, which would run before the
125
- // constructor's own parameter property and read `deps` as undefined.
126
- constructor(deps) {
127
- this.deps = deps;
128
- this.#job = new JobLifecycle({
129
- id: INSTALL_KEY,
130
- storage: deps.storage,
131
- scheduler: deps.scheduler,
132
- run: "installRun",
133
- watch: "installWatch",
134
- staleMs: INSTALL_STALE_MS,
135
- watchMs: INSTALL_WATCH_MS,
136
- armCooldownMs: INSTALL_ARM_COOLDOWN_MS
137
- });
138
- }
139
- /**
140
- * Start a dependency install the moment we can see one will be needed.
141
- *
142
- * The signal is {@link CONTAINER_TREE_KEY}: a local read, true only while the
143
- * container that ran the install is the one running.
144
- *
145
- * Armed as soon as a container is up or a command is about to start one,
146
- * because the model's first minute is README-reading and `git status` — an
147
- * install armed there runs *through* that minute, where one armed on the first
148
- * `npm` command charges its full time to that command.
149
- *
150
- * It writes `running` before anything is running: the alarm has not fired yet,
151
- * and a `done` record would let an `npm` command through against a tree that
152
- * is not there. That also makes this self-limiting — the next call sees
153
- * `running` and stops.
154
- */
155
- async armIfTreeMissing() {
156
- // Where the install went, and the only record of it: there is no caller here
157
- // to ask, which is why `repo` is persisted alongside `dir`. Nothing to arm
158
- // for a workspace that has never installed anything — that is `repo_clone`'s
159
- // job, not this one's.
160
- const context = await this.#job.context();
161
- if (!context?.dir)
162
- return;
163
- // The branch that runs on almost every call, and it costs one local read.
164
- const tree = await this.#tree();
165
- if (tree?.dir === context.dir && tree.fingerprint === context.fingerprint)
166
- return;
167
- /**
168
- * `done` **or** `failed`, matching core's `isRearmable`, and narrowed to the
169
- * pair that carries a `state.command` — the placeholder needs one, since the
170
- * gate renders it while the alarm is still pending.
171
- *
172
- * **`failed` has to be in there.** Leaving it out means a workspace whose
173
- * install failed once declines to arm ever again, and one bad install
174
- * poisons every task after it. Re-driving a failure cannot loop here: the
175
- * arming cooldown is the floor under how often that can happen.
176
- *
177
- * `skipped` and `idle` stay out for reasons rather than caution: `skipped`
178
- * means the resolver found nothing to install, so a missing tree is correct
179
- * and permanent; `idle` means nothing has ever been installed, so there is
180
- * no `install:context` naming where to do it.
181
- *
182
- * {@link state} rather than the lifecycle's raw `read()`, so a `running`
183
- * record left by a dead isolate is repaired to `failed` here and can arm.
184
- */
185
- const state = await this.state();
186
- if (state.state !== "done" && state.state !== "failed")
187
- return;
188
- console.info(`[${this.deps.tag()}] no dependency tree — arming an install`, {
189
- id: this.deps.id(),
190
- dir: context.dir
191
- });
192
- // Everything the arming handshake needs — the placeholder write, the stamp
193
- // the alarm presents to `reserve`, the cooldown floor and the run intent — in
194
- // one call, and unwound as a unit if the intent cannot be scheduled.
195
- await this.#job.arm({ command: state.command });
196
- }
197
- /** `resolveInstallCommand` reads the checkout through this. */
198
- #probe() {
199
- const fs = this.deps.workspace().fs;
200
- return {
201
- // The plugin's own, which asks for the stub's `exists` and only falls back
202
- // to `stat` when there is none — the local `WorkspaceFilesystem` here being
203
- // exactly that case.
204
- exists: (path) => pathExists(fs, path),
205
- readFile: (path) => fs.readFile(path, "utf8")
206
- };
207
- }
208
- async #tree() {
209
- return await this.deps.storage.get(CONTAINER_TREE_KEY);
210
- }
211
- /** Where the last install ran, for the container setup's marker read. */
212
- async dir() {
213
- return (await this.#job.context())?.dir;
214
- }
215
- /** The container went away, and its tree with it. */
216
- async containerGone() {
217
- await this.deps.storage.delete(CONTAINER_TREE_KEY);
218
- }
219
- /**
220
- * Check the record against the marker a new isolate's setup read.
221
- *
222
- * The record can outlive its container in an isolate that did not watch it
223
- * exit; the marker cannot.
224
- */
225
- async reconcile(marker) {
226
- const tree = await this.#tree();
227
- if (!tree || (tree.fingerprint ?? "none") === marker)
228
- return;
229
- console.warn(`[${this.deps.tag()}] the container does not hold the recorded tree`, {
230
- id: this.deps.id(),
231
- dir: tree.dir
232
- });
233
- await this.containerGone();
234
- }
235
- /**
236
- * The record exactly as the job wrote it, with no staleness bound applied.
237
- *
238
- * {@link state} is what almost everything wants. This is for the one caller
239
- * that must not repair anything on the way past: the container-idle handler,
240
- * which only needs to know whether something is running before it destroys the
241
- * container underneath it.
242
- */
243
- async read() {
244
- return await this.#job.read();
245
- }
246
- /**
247
- * Start installing this checkout's dependencies, and return without waiting.
248
- *
249
- * Called from `repo_clone` through the repo plugin's `afterCheckout` hook, so
250
- * it runs inside a model turn and must not block on the install — 225 seconds
251
- * for slack-gatekeeper, against a turn that is cut at fifteen minutes.
252
- *
253
- * That caller is a model turn, which lives long enough to hold the drain handed
254
- * to `ctx.waitUntil` below. **A short-lived caller cannot**, which is why an
255
- * install the alarm drives is awaited inside that alarm rather than calling
256
- * this.
257
- */
258
- async start(req) {
259
- return this.#beginInstall(req, (handle, startedAt) => {
260
- // Drained here, in this object, on nobody's step budget. The watchdog picks
261
- // it up if this isolate does not survive the command.
262
- this.deps.waitUntil(this.#drainInstall(handle, startedAt));
263
- });
264
- }
265
- /**
266
- * The same install, drained **inside the caller** rather than after it.
267
- *
268
- * For the alarm, which owns no request: nothing it awaits can be cut short by a
269
- * response being sent, so the drain cannot be disposed out from under an
270
- * `npm ci` half-way through. Returns once the command has actually finished.
271
- */
272
- async #awaited(req, armedAt) {
273
- await this.#beginInstall(req, (handle, startedAt) => this.#drainInstall(handle, startedAt), { takeOverArmedAt: armedAt });
274
- return this.state();
275
- }
276
- /**
277
- * Resolve, guard, spawn — and hand the running command to `own`, which decides
278
- * whether the drain outlives this call or is awaited within it. That choice is
279
- * the only difference between the two entry points above, and it is the
280
- * difference that broke production, so it is the one thing this parameterises.
281
- */
282
- async #beginInstall(req, own, opts) {
283
- await this.deps.touch();
284
- await this.deps.ready();
285
- // {@link state} rather than the lifecycle's raw `read()`: a `running` record
286
- // left by a dead isolate is repaired on the way past, and one nobody is
287
- // draining is re-attached.
288
- const current = await this.state();
289
- /**
290
- * A full workspace refuses the install and **writes nothing**.
291
- *
292
- * Capacity is not an install outcome, and recording it as one costs twice
293
- * over. `skipped` is the variant meaning "this checkout has nothing to
294
- * install", so a hard wall about the Durable Object would arrive wearing the
295
- * label of a routine fact about the repository; and writing any record here
296
- * erases what the record held, so a real install failure would disappear the
297
- * moment the object filled up.
298
- *
299
- * It travels as its own advisory instead, from {@link advisories}, which
300
- * reads it fresh on every call and therefore reaches commands that have
301
- * nothing to do with dependencies.
302
- */
303
- const full = this.deps.headroom();
304
- if (full) {
305
- console.error(`[${this.deps.tag()}] refusing to install: the workspace is full`, {
306
- id: this.deps.id(),
307
- bytes: full.bytes,
308
- capBytes: full.capBytes
309
- });
310
- return current;
311
- }
312
- /**
313
- * One install at a time — the hazard is displacement.
314
- *
315
- * `repo_clone` calls this and a recovered turn calls it again. Every call
316
- * spawns with the same {@link INSTALL_EXEC_ID}, so without this each would
317
- * displace the last while the displaced command's drain stayed attached
318
- * through `ctx.waitUntil` — then wrote *its* outcome over a record
319
- * describing an install still running perfectly well.
320
- *
321
- * Reserved rather than checked: everything between here and the spawn —
322
- * probing the checkout, hashing the lockfile — awaits the container, and a
323
- * check that wrote nothing would let a second caller through in that time.
324
- *
325
- * `takeOverArmedAt` is the one exemption, narrow on purpose: the alarm's
326
- * placeholder is a `running` record for an install that has not started, so
327
- * the alarm must pass its own guard and only its own. Matching the exact
328
- * `startedAt` it wrote is what stops that becoming "take over any running
329
- * install", which is displacement again in a new hat.
330
- */
331
- const reserved = await this.#job.reserve({ command: RESOLVING }, this.deps.timeoutMs(), opts?.takeOverArmedAt);
332
- if (!reserved.ok) {
333
- console.info(`[${this.deps.tag()}] an install is already in flight`, {
334
- id: this.deps.id(),
335
- command: reserved.current.command,
336
- seconds: Math.round((Date.now() - reserved.current.startedAt) / 1000)
337
- });
338
- return reserved.current;
339
- }
340
- const { startedAt } = reserved;
341
- const run = this.#job.generation(startedAt);
342
- this.#owned.add(startedAt);
343
- let handed = false;
344
- try {
345
- return await this.#spawnReserved(req, run, reserved, own, () => {
346
- handed = true;
347
- });
348
- }
349
- finally {
350
- if (!handed)
351
- this.#owned.delete(startedAt);
352
- }
353
- }
354
- /**
355
- * Everything after the reservation: prepare, spawn, and hand the command to
356
- * `own`. Split out so {@link #beginInstall} can hold the generation for exactly
357
- * this long — `handing` is called as the drain takes it over.
358
- */
359
- async #spawnReserved(req, run, reserved, own, handing) {
360
- const { startedAt } = reserved;
361
- // Nothing trusts the interception CA here, deliberately: the workspace's own
362
- // `ready` does it, above every early return in this method. An install
363
- // already in flight and a full workspace both return before this line, and
364
- // neither of them means the container cannot speak TLS — the full workspace
365
- // least of all, since egress is what the agent needs to dig itself out.
366
- let prepared;
367
- try {
368
- prepared = await this.#prepare(req, run, startedAt);
369
- }
370
- catch (err) {
371
- await this.#release(run, reserved.previous, err);
372
- throw err;
373
- }
374
- if (prepared.kind === "settled")
375
- return prepared.state;
376
- const { resolution, fingerprint, state } = prepared;
377
- let handle;
378
- try {
379
- // The marker lets a new isolate check {@link CONTAINER_TREE_KEY} against
380
- // the container; see `reconcile`.
381
- const marked = `${resolution.command} && printf %s ` +
382
- `${shellQuote(fingerprint ?? "none")} > node_modules/${INSTALLED_MARKER}`;
383
- handle = await this.deps.workspace().runtime.exec(marked, {
384
- id: INSTALL_EXEC_ID,
385
- cwd: req.dir,
386
- encoding: "utf8",
387
- timeoutMs: this.deps.timeoutMs()
388
- });
389
- }
390
- catch (err) {
391
- // The command never started, so nothing will ever drain it and no
392
- // re-attach can find it. Close the record here: a `failed` install is
393
- // recoverable — the agent is told what happened and can run the command
394
- // itself — where a `running` one that nobody owns is not.
395
- //
396
- // Which sentence, though, depends on why. The default advice — run it
397
- // yourself with `bash` — is good for a container that is merely
398
- // unreachable, and useless for a deployment fault: the same container is
399
- // what `bash` would have to reach. Under one of those, say what an
400
- // operator has to do and do not send the agent after a command that
401
- // cannot run either.
402
- const fault = deploymentFault(err);
403
- console.error(`[${this.deps.tag()}] ${fault?.summary ?? "the install could not be started"}`, {
404
- id: this.deps.id(),
405
- command: resolution.command,
406
- ...(fault ? { remedy: fault.remedy } : {}),
407
- err: String(err)
408
- });
409
- const failed = {
410
- state: "failed",
411
- command: resolution.command,
412
- finishedAt: Date.now(),
413
- error: fault
414
- ? `the install could not be started: ${fault.remedy}`
415
- : `the install could not be started (${String(err)}). The container ` +
416
- "was most likely unreachable. Run the command yourself with bash, " +
417
- "or clone again to retry it."
418
- };
419
- return await this.#settle(run, failed);
420
- }
421
- handing();
422
- await own(handle, startedAt);
423
- return state;
424
- }
425
- /**
426
- * Everything between a reservation and the spawn: resolve the command, and
427
- * settle the run here when there is nothing to install or the tree is already
428
- * this lockfile's. Otherwise write this install's own `running` record and
429
- * context, and arm the watchdog.
430
- */
431
- async #prepare(req, run, startedAt) {
432
- const probe = this.#probe();
433
- const resolution = await resolveInstallCommand(probe, req.dir, this.deps.plan(), req.repo);
434
- if (resolution.kind === "skip") {
435
- const state = {
436
- state: "skipped",
437
- reason: resolution.reason
438
- };
439
- /**
440
- * The common branch, and the one whose absence is indistinguishable from
441
- * never having been called.
442
- *
443
- * Every other outcome here leaves a line — an install in flight, a full
444
- * workspace, a spawn that failed, each end of the drain — so a workspace
445
- * that skipped and a workspace that never installed read identically in the
446
- * logs, and any checkout without a `package.json` takes this path. A skip
447
- * is routine; establishing that one happened should not require an argument
448
- * from silence.
449
- */
450
- console.info(`[${this.deps.tag()}] install skipped`, {
451
- id: this.deps.id(),
452
- dir: req.dir,
453
- ...(req.repo ? { repo: req.repo } : {}),
454
- reason: resolution.reason
455
- });
456
- return { kind: "settled", state: await this.#settle(run, state) };
457
- }
458
- const fingerprint = await installFingerprint(probe, req.dir, resolution);
459
- // Skipped only for this container's own tree, for this lockfile.
460
- const tree = await this.#tree();
461
- if (fingerprint &&
462
- tree?.dir === req.dir &&
463
- tree.fingerprint === fingerprint) {
464
- const state = {
465
- state: "done",
466
- command: resolution.command,
467
- exitCode: 0,
468
- finishedAt: Date.now(),
469
- ms: 0,
470
- tail: "dependencies already installed in this container for this lockfile"
471
- };
472
- return { kind: "settled", state: await this.#settle(run, state) };
473
- }
474
- // The tree is rebuilt from here, so nothing vouches for it until exit 0.
475
- await this.deps.storage.delete([CONTAINER_TREE_KEY, ...RETIRED_KEYS]);
476
- const state = {
477
- state: "running",
478
- command: resolution.command,
479
- startedAt
480
- };
481
- if (!(await run.write(state))) {
482
- return { kind: "settled", state: await this.#job.read() };
483
- }
484
- await this.#job.putContext({
485
- dir: req.dir,
486
- // Kept so a reinstall the alarm drives — which has no caller to ask —
487
- // resolves the same command this one did. Without it a repository
488
- // with an `INSTALL_PLAN` override would silently fall back to the default
489
- // on every later run, installing a different tree than the first time.
490
- ...(req.repo ? { repo: req.repo } : {}),
491
- fingerprint,
492
- command: resolution.command
493
- });
494
- /**
495
- * Armed **before** the spawn — the hazard is an isolate that dies between
496
- * the two.
497
- *
498
- * The record above already says `running`, so from here until something
499
- * writes a terminal state the gate is shut and the alarm is the only thing
500
- * that can open it. Arming afterwards leaves a window with nothing scheduled
501
- * to recover: a `runtime.exec` that threw on the container's WebSocket left
502
- * a workspace `running` for half an hour, refusing every command.
503
- *
504
- * Arming early is free — the handler clears the intent if the record is not
505
- * `running`, so finishing first costs one wake-up.
506
- */
507
- await this.#job.armWatch();
508
- return { kind: "spawn", resolution, fingerprint, state };
509
- }
510
- /** Settle `run` with `state`, or say what stands if it was displaced. */
511
- async #settle(run, state) {
512
- return (await run.write(state)) ? state : await this.#job.read();
513
- }
514
- /**
515
- * Hand a reservation back when nothing was spawned under it: the record as it
516
- * was, unless that was a run of its own — the alarm's placeholder — which
517
- * nothing would now finish, so `failed`.
518
- */
519
- async #release(run, previous, err) {
520
- const back = previous.state === "running"
521
- ? {
522
- state: "failed",
523
- command: previous.command,
524
- finishedAt: Date.now(),
525
- error: `the install could not be prepared (${String(err)}). Run the ` +
526
- "command yourself with bash, or clone again to retry it."
527
- }
528
- : previous;
529
- try {
530
- await run.write(back);
531
- }
532
- catch (rollback) {
533
- throw new AggregateError([err, rollback], "the install could not be prepared, and its reservation could not be released");
534
- }
535
- }
536
- /**
537
- * Whether this container holds a finished tree, asked only when it qualifies a
538
- * `deps-broken` advisory (see `deriveAdvisories`).
539
- */
540
- async treePresentIfItMatters(install) {
541
- if (install.state !== "failed")
542
- return false;
543
- const dir = await this.dir();
544
- return dir !== undefined && (await this.#tree())?.dir === dir;
545
- }
546
- /**
547
- * The install record itself, with its staleness bound and re-attach applied.
548
- *
549
- * Split from {@link advisories} so `startInstall` can consult the record
550
- * without going through the `node_modules` probe, which needs a container and
551
- * has nothing to say about whether an install may start.
552
- *
553
- * Re-attaches on the way past. An isolate reset leaves the record saying
554
- * `running` with nothing draining it, and without this the state would say
555
- * `running` forever while the command had long since finished.
556
- */
557
- async state() {
558
- const state = await this.#job.read();
559
- if (state.state !== "running")
560
- return state;
561
- /**
562
- * `running` has an expiry — the proof the file header refers to.
563
- *
564
- * The command carries a `timeoutMs` the runtime enforces, so past that plus
565
- * a wide margin a live install is not a possibility: whatever the record
566
- * says, nobody is coming back with an exit code. Writing `failed` here is
567
- * not a guess about what happened, it is the only accurate thing left to
568
- * say — which is what makes it a bound rather than another guard.
569
- */
570
- if (this.#job.isStale(state, this.deps.timeoutMs())) {
571
- const minutes = Math.round((Date.now() - state.startedAt) / 60_000);
572
- console.error(`[${this.deps.tag()}] abandoning a stale install`, {
573
- id: this.deps.id(),
574
- command: state.command,
575
- minutes
576
- });
577
- const failed = {
578
- state: "failed",
579
- command: state.command,
580
- finishedAt: Date.now(),
581
- error: `the install has been running for ${minutes} minutes without ` +
582
- "reporting, which is past its timeout — it is not going to finish. " +
583
- "Run the command yourself with bash if you still need it."
584
- };
585
- // Under the record's own generation, so a reservation that took the record
586
- // since it was read is not overwritten.
587
- const repaired = await this.#job
588
- .generation(state.startedAt)
589
- .write(failed);
590
- return repaired ? failed : await this.#job.read();
591
- }
592
- if (await this.#drivenHere(state.startedAt))
593
- return state;
594
- await this.#reattachInstall(state.startedAt);
595
- return await this.#job.read();
596
- }
597
- /**
598
- * When a reinstall was queued, if one is waiting to run.
599
- *
600
- * Read by {@link file://./advisory.ts deriveAdvisories}, which is the only
601
- * caller that needs it: a `failed` record with a repair already queued is a
602
- * transient condition wearing a permanent record's clothes.
603
- */
604
- async reinstallArmedAt() {
605
- return await this.#job.armedAt();
606
- }
607
- /**
608
- * The generations this isolate is driving: reserved, taken over from the
609
- * alarm's placeholder, or draining — until the drain ends.
610
- *
611
- * The re-attach is for a `running` record nobody here is driving, which is one
612
- * a dead isolate left. Asked of a generation that is reserved but not yet
613
- * spawned, it finds no exec, or the last container's, and writes `failed` over
614
- * an install about to start, whose own verdict is then discarded as
615
- * superseded. Owning from the reservation rather than from the drain is what
616
- * closes that window: resolving and spawning await the container for seconds.
617
- */
618
- #owned = new Set();
619
- /**
620
- * Whether a `running` record's install is this isolate's to finish: one it
621
- * owns, or the alarm's placeholder still waiting for the alarm, whose
622
- * `startedAt` is the arming stamp.
623
- */
624
- async #drivenHere(startedAt) {
625
- return (this.#owned.has(startedAt) || (await this.#job.armedAt()) === startedAt);
626
- }
627
- async #drainInstall(handle,
628
- /**
629
- * The generation this drain writes under: the `startedAt` its reservation
630
- * returned, or — re-attached — the running record's.
631
- *
632
- * Passed in rather than read below: this method's first `await` is reached
633
- * long after `#beginInstall` returned, and a record read then may already be
634
- * a later install's, whose generation this drain would adopt.
635
- */
636
- startedAt) {
637
- this.#owned.add(startedAt);
638
- const context = await this.#job.context();
639
- const command = context?.command ?? "(unknown)";
640
- /**
641
- * Whether this drain still owns the record.
642
- *
643
- * The reservation in `#beginInstall` stops two installs overlapping in the
644
- * first place; this makes it harmless if one ever does. A drain can outlive
645
- * the command it was watching — `ctx.waitUntil` keeps running after the RPC
646
- * returns — and the damage a late one does is silent: it writes a verdict
647
- * about a finished command over a record describing a live one, and every
648
- * `bash` then reads a result that belongs to nothing.
649
- *
650
- * Every write goes through `run.write`, which checks and writes in one step
651
- * and clears the watchdog with a verdict. `stillMine` is asked only before
652
- * this drain's own side effects. The superseded line is logged once — a
653
- * superseded drain can be refused on both the success and the error path.
654
- */
655
- const run = this.#job.generation(startedAt);
656
- let logged = false;
657
- const superseded = async () => {
658
- if (logged)
659
- return;
660
- logged = true;
661
- const current = await this.#job.read();
662
- console.warn(`[${this.deps.tag()}] discarding a superseded install drain`, {
663
- id: this.deps.id(),
664
- command,
665
- startedAt,
666
- current: current.state === "running" ? current.startedAt : current.state
667
- });
668
- };
669
- const stillMine = async () => {
670
- if (await run.stillMine())
671
- return true;
672
- await superseded();
673
- return false;
674
- };
675
- const settle = async (verdict, also) => {
676
- if (!(await run.write(verdict, also)))
677
- await superseded();
678
- };
679
- try {
680
- const result = await handle.result();
681
- // Middle-out rather than a tail cut, and it marks what it dropped: an
682
- // install's diagnosis is split between the two ends — the first error and
683
- // the summary that follows it — and a plain `slice(-n)` silently keeps
684
- // only the half that happens to be last.
685
- const tail = truncateOutput(result.stdout + result.stderr, 2000);
686
- if (!(await stillMine()))
687
- return;
688
- if (result.exitCode === 0) {
689
- // With the verdict, in the same write: the tree is this install's only if
690
- // the verdict is.
691
- const tree = context?.dir
692
- ? {
693
- dir: context.dir,
694
- fingerprint: context.fingerprint,
695
- at: Date.now()
696
- }
697
- : undefined;
698
- console.info(`[${this.deps.tag()}] install finished`, {
699
- id: this.deps.id(),
700
- command,
701
- seconds: Math.round((Date.now() - startedAt) / 1000)
702
- });
703
- await settle({
704
- state: "done",
705
- command,
706
- exitCode: 0,
707
- finishedAt: Date.now(),
708
- ms: Date.now() - startedAt,
709
- tail
710
- }, tree ? { [CONTAINER_TREE_KEY]: tree } : undefined);
711
- }
712
- else {
713
- // Logged, and this line is not optional. This is the *ordinary* way an
714
- // install fails — the other paths are all exceptional — and it used to
715
- // write the record and say nothing, so an operator looking at why the
716
- // agent was complaining found the complaint and no cause. The tail
717
- // is the install's own last words; without it the only copy is inside a
718
- // Durable Object nobody can query.
719
- console.error(`[${this.deps.tag()}] install failed`, {
720
- id: this.deps.id(),
721
- command,
722
- exitCode: result.exitCode,
723
- seconds: Math.round((Date.now() - startedAt) / 1000),
724
- tail: truncateOutput(tail, 1000)
725
- });
726
- await settle({
727
- state: "failed",
728
- command,
729
- finishedAt: Date.now(),
730
- exitCode: result.exitCode,
731
- error: `the install command exited ${result.exitCode}`,
732
- tail
733
- });
734
- }
735
- }
736
- catch (err) {
737
- // Superseded drains fail here constantly — replacing an exec is what
738
- // breaks the old handle — so this check matters more on the error path
739
- // than on the success one.
740
- if (!(await stillMine()))
741
- return;
742
- // The drain itself broke — the container went away mid-install, most
743
- // likely. Distinct from a non-zero exit above, and worth telling apart in
744
- // the logs, because this one says nothing about the repository.
745
- //
746
- // If it went away, nothing this isolate believes about it holds any more.
747
- if (execWasLost(err))
748
- await this.deps.containerGone();
749
- console.error(`[${this.deps.tag()}] install drain failed`, {
750
- id: this.deps.id(),
751
- command,
752
- seconds: Math.round((Date.now() - startedAt) / 1000),
753
- err: String(err)
754
- });
755
- await settle({
756
- state: "failed",
757
- command,
758
- finishedAt: Date.now(),
759
- error: String(err)
760
- });
761
- }
762
- finally {
763
- this.#owned.delete(startedAt);
764
- handle[Symbol.dispose]();
765
- }
766
- }
767
- /**
768
- * Pick up an install this isolate did not start.
769
- *
770
- * `getExec` with `resume: "tail"` re-opens the stream of a command that is
771
- * still running in the container — or replays the end of one that finished
772
- * while nobody was listening, which is the case that would otherwise leave the
773
- * record stuck at `running` and every `bash` blocked behind it.
774
- */
775
- async #reattachInstall(
776
- /** The running record's `startedAt`: the generation it picks up. */
777
- startedAt) {
778
- if (await this.#drivenHere(startedAt))
779
- return;
780
- try {
781
- const handle = await this.deps
782
- .workspace()
783
- .runtime.getExec(INSTALL_EXEC_ID, {
784
- encoding: "utf8",
785
- resume: "tail"
786
- });
787
- this.deps.waitUntil(this.#drainInstall(handle, startedAt));
788
- }
789
- catch (err) {
790
- // The exec is gone entirely — the container was replaced under it. Say so
791
- // rather than leaving the gate closed forever; the next checkout starts a
792
- // new install, and `bash` can run in the meantime.
793
- if (execWasLost(err))
794
- await this.deps.containerGone();
795
- // Same split as the spawn path: a replaced container is worth re-running
796
- // into, a deployment fault is not, and only one of them is the container's
797
- // own doing.
798
- const fault = deploymentFault(err);
799
- if (fault)
800
- console.error(`[${this.deps.tag()}] ${fault.summary}`, {
801
- id: this.deps.id(),
802
- remedy: fault.remedy,
803
- err: String(err)
804
- });
805
- else
806
- console.warn(`[${this.deps.tag()}] could not re-attach to the install`, {
807
- id: this.deps.id(),
808
- err: String(err)
809
- });
810
- const context = await this.#job.context();
811
- await this.#job.generation(startedAt).write({
812
- state: "failed",
813
- command: context?.command ?? "(unknown)",
814
- finishedAt: Date.now(),
815
- error: fault
816
- ? `the install stopped without reporting: ${fault.remedy}`
817
- : "the install stopped without reporting — its container was most " +
818
- "likely replaced. Re-run it with bash, or clone again to restart it."
819
- });
820
- }
821
- }
822
- /**
823
- * The armed reinstall came due.
824
- *
825
- * `clearArmed` first and unconditionally: this handler runs for minutes, and
826
- * the stamp left in place is what a second arming would recognise as its own.
827
- * The reservation in `#beginInstall` would catch a double, but the cheaper
828
- * answer is not to schedule one.
829
- */
830
- async onRun() {
831
- const context = await this.#job.context();
832
- const armedAt = await this.#job.armedAt();
833
- // Owned before the arm is cleared: from then until the reservation takes it
834
- // over, the placeholder is a `running` record that only this call will finish.
835
- if (armedAt !== undefined)
836
- this.#owned.add(armedAt);
837
- try {
838
- await this.#job.clearArmed();
839
- if (!context?.dir || armedAt === undefined)
840
- return;
841
- const state = await this.#awaited({
842
- dir: context.dir,
843
- ...(context.repo ? { repo: context.repo } : {})
844
- }, armedAt);
845
- console.info(`[${this.deps.tag()}] armed reinstall finished`, {
846
- id: this.deps.id(),
847
- dir: context.dir,
848
- state: state.state
849
- });
850
- }
851
- finally {
852
- if (armedAt !== undefined)
853
- this.#owned.delete(armedAt);
854
- }
855
- }
856
- /** The watchdog: an install still running that nobody is draining. */
857
- async onWatch() {
858
- const state = await this.#job.read();
859
- if (state.state !== "running")
860
- return;
861
- // Still running and nobody draining it: this isolate is new since the
862
- // command started. Re-attach, and come back if it is still going.
863
- await this.#reattachInstall(state.startedAt);
864
- await this.#job.armWatch();
865
- }
866
- }