@dynamicagents/plugins 0.11.1 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -79
- package/dist/browser/index.d.ts +0 -2
- package/dist/browser/index.js +21 -27
- package/dist/claude-code/cards.d.ts +53 -0
- package/dist/claude-code/cards.js +272 -0
- package/dist/claude-code/config.d.ts +73 -31
- package/dist/claude-code/config.js +0 -2
- package/dist/claude-code/credentials.d.ts +21 -9
- package/dist/claude-code/credentials.js +43 -9
- package/dist/claude-code/egress.d.ts +15 -14
- package/dist/claude-code/egress.js +51 -14
- package/dist/claude-code/events.d.ts +153 -9
- package/dist/claude-code/events.js +289 -58
- package/dist/claude-code/github.d.ts +39 -0
- package/dist/claude-code/github.js +82 -0
- package/dist/claude-code/index.d.ts +24 -80
- package/dist/claude-code/index.js +19 -234
- package/dist/claude-code/model.d.ts +152 -0
- package/dist/claude-code/model.js +342 -0
- package/dist/claude-code/run.d.ts +283 -92
- package/dist/claude-code/run.js +620 -114
- package/dist/claude-code/session.d.ts +131 -0
- package/dist/claude-code/session.js +285 -0
- package/dist/claude-code/spec.d.ts +46 -0
- package/dist/claude-code/spec.js +83 -0
- package/dist/claude-code/verified.d.ts +15 -0
- package/dist/claude-code/verified.js +15 -0
- package/dist/computer/context.d.ts +19 -0
- package/dist/computer/context.js +222 -0
- package/dist/computer/file-lock.d.ts +5 -0
- package/dist/computer/file-lock.js +40 -0
- package/dist/computer/gate.d.ts +12 -12
- package/dist/computer/gate.js +12 -12
- package/dist/computer/index.d.ts +57 -255
- package/dist/computer/index.js +98 -1039
- package/dist/computer/paths.d.ts +47 -22
- package/dist/computer/paths.js +64 -49
- package/dist/computer/proxy.d.ts +45 -0
- package/dist/computer/proxy.js +234 -0
- package/dist/computer/render.d.ts +27 -38
- package/dist/computer/render.js +32 -65
- package/dist/computer/tools-exec.d.ts +3 -0
- package/dist/computer/tools-exec.js +268 -0
- package/dist/computer/tools-file.d.ts +12 -0
- package/dist/computer/tools-file.js +64 -0
- package/dist/computer/tools-grep.d.ts +3 -0
- package/dist/computer/tools-grep.js +129 -0
- package/dist/repo/checkout.d.ts +35 -6
- package/dist/repo/checkout.js +64 -6
- package/dist/repo/context.d.ts +135 -0
- package/dist/repo/context.js +582 -0
- package/dist/repo/index.d.ts +79 -57
- package/dist/repo/index.js +118 -1112
- package/dist/repo/tools-checks.d.ts +3 -0
- package/dist/repo/tools-checks.js +112 -0
- package/dist/repo/tools-clone.d.ts +4 -0
- package/dist/repo/tools-clone.js +192 -0
- package/dist/repo/tools-pr.d.ts +14 -0
- package/dist/repo/tools-pr.js +196 -0
- package/dist/repo/tools-review.d.ts +12 -0
- package/dist/repo/tools-review.js +393 -0
- package/dist/repo/tools-worktree.d.ts +4 -0
- package/dist/repo/tools-worktree.js +305 -0
- package/dist/repo/tools-worktrees.d.ts +12 -0
- package/dist/repo/tools-worktrees.js +48 -0
- package/dist/repo/url.d.ts +2 -2
- package/dist/repo/url.js +7 -7
- package/dist/scratch/index.d.ts +4 -4
- package/dist/scratch/index.js +12 -11
- package/dist/{computer → workspace}/advisory.d.ts +33 -20
- package/dist/{computer → workspace}/advisory.js +32 -7
- package/dist/workspace/ca-trust.d.ts +95 -0
- package/dist/workspace/ca-trust.js +312 -0
- package/dist/workspace/container-deps.d.ts +54 -0
- package/dist/workspace/container-deps.js +224 -0
- package/dist/workspace/container-fault.d.ts +38 -0
- package/dist/workspace/container-fault.js +73 -0
- package/dist/workspace/exec.d.ts +94 -0
- package/dist/workspace/exec.js +153 -0
- package/dist/workspace/format.d.ts +36 -0
- package/dist/workspace/format.js +57 -0
- package/dist/workspace/git-host.d.ts +79 -0
- package/dist/workspace/git-host.js +226 -0
- package/dist/workspace/git-identity.d.ts +57 -0
- package/dist/workspace/git-identity.js +147 -0
- package/dist/workspace/index.d.ts +43 -47
- package/dist/workspace/index.js +52 -112
- package/dist/workspace/install-job.d.ts +121 -0
- package/dist/workspace/install-job.js +866 -0
- package/dist/{computer → workspace}/install.d.ts +15 -19
- package/dist/{computer → workspace}/install.js +8 -11
- package/dist/workspace/object.d.ts +502 -0
- package/dist/workspace/object.js +1427 -0
- package/dist/workspace/open.d.ts +95 -0
- package/dist/workspace/open.js +63 -0
- package/dist/workspace/read.d.ts +16 -0
- package/dist/workspace/read.js +22 -0
- package/dist/workspace/shell.d.ts +45 -0
- package/dist/workspace/shell.js +80 -0
- package/dist/workspace/sync.d.ts +111 -0
- package/dist/workspace/sync.js +124 -0
- package/dist/workspace/wake.d.ts +19 -0
- package/dist/workspace/wake.js +10 -0
- package/package.json +28 -42
- package/dist/arc-agi/analysis.d.ts +0 -336
- package/dist/arc-agi/analysis.js +0 -829
- package/dist/arc-agi/client.d.ts +0 -82
- package/dist/arc-agi/client.js +0 -113
- package/dist/arc-agi/game-tools.d.ts +0 -25
- package/dist/arc-agi/game-tools.js +0 -26
- package/dist/arc-agi/index.d.ts +0 -42
- package/dist/arc-agi/index.js +0 -175
- package/dist/arc-agi/main-agent.d.ts +0 -27
- package/dist/arc-agi/main-agent.js +0 -65
- package/dist/arc-agi/recipe.d.ts +0 -51
- package/dist/arc-agi/recipe.js +0 -100
- package/dist/arc-agi/scorecard.d.ts +0 -118
- package/dist/arc-agi/scorecard.js +0 -163
- package/dist/arc-agi/soul.d.ts +0 -40
- package/dist/arc-agi/soul.js +0 -90
- package/dist/arc-agi/store.d.ts +0 -154
- package/dist/arc-agi/store.js +0 -216
- package/dist/arc-agi/tools.d.ts +0 -36
- package/dist/arc-agi/tools.js +0 -576
- package/dist/arc-agi/types.d.ts +0 -212
- package/dist/arc-agi/types.js +0 -19
- package/dist/claude-code/recipe.d.ts +0 -53
- package/dist/claude-code/recipe.js +0 -100
- package/dist/computer/read.d.ts +0 -137
- package/dist/computer/read.js +0 -203
- package/dist/recall/index.d.ts +0 -99
- package/dist/recall/index.js +0 -171
- package/dist/triage/index.d.ts +0 -355
- package/dist/triage/index.js +0 -302
|
@@ -0,0 +1,866 @@
|
|
|
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
|
+
}
|