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