@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,1541 +0,0 @@
1
- import { DurableObject, tracing } from "cloudflare:workers";
2
- // One Durable Object has one alarm, and this object wakes for more reasons than
3
- // that. A `Scheduler` is the multiplexer; what it does *not* own is what this
4
- // object owes on waking, which is the callbacks registered on it below.
5
- import { installScheduler, namedDeadline } from "@dynamicagents/core/alarm";
6
- import { Workspace } from "@cloudflare/computer";
7
- import { ContainerBackend, withWorkspaceContainer } from "@cloudflare/computer/backends/container";
8
- import { createGitClient } from "@cloudflare/computer/git";
9
- import { createCloudflareObserver } from "@cloudflare/computer/observe/cloudflare";
10
- import { ContainerTrust } from "./ca-trust.js";
11
- import { ContainerDeps } from "./container-deps.js";
12
- import { DepsSnapshot } from "./deps-snapshot.js";
13
- import { ContainerGitIdentity } from "./git-identity.js";
14
- import { InstallJob } from "./install-job.js";
15
- import { WorkspaceGitHost } from "./git-host.js";
16
- import { SYNC_DRAIN_BUDGET_MS, SYNC_DRAIN_RESUME_MS, WorkspaceSync, syncRetryDelayMs } from "./sync.js";
17
- // Leaf modules rather than `./index.ts` — see the same import in
18
- // {@link file://./install-job.ts} for why the barrel is not reachable from here.
19
- import { deriveAdvisories } from "./advisory.js";
20
- import { pathExists } from "./read.js";
21
- /**
22
- * A workspace: one Durable Object, one container, one repository.
23
- *
24
- * **Shared by every agent in a Worker that has a container.** Each subclasses
25
- * {@link WorkspaceObjectBase}, supplies a {@link WorkspaceObjectConfig}, and
26
- * inherits everything else. Keep that seam narrow: it is the complete answer to
27
- * "what is different about this agent's container", and anything that does not
28
- * fit through it becomes a second copy of this file.
29
- *
30
- * A directory inside the plugin rather than a subpath beside it, so that one
31
- * capability stays one import path and a consumer cannot take the tools without
32
- * the object they address. The bundle cost of that is nothing: `sideEffects` is
33
- * false, so an agent that only calls `bash` carries no container backend and
34
- * no isomorphic-git.
35
- *
36
- * What this file must not do is import the plugin barrel. The barrel re-exports
37
- * this module, and the class below is built at import time — a cycle would
38
- * evaluate its base as `undefined`. The imports above reach the leaf modules
39
- * beside it instead.
40
- *
41
- * `@cloudflare/computer` pairs a SQLite-backed virtual filesystem in *this*
42
- * object's storage with a container running `computerd`, which mounts it over
43
- * FUSE at `/workspace`. Commands run against the same tree the Worker reads over
44
- * RPC, and the tree outlives the container.
45
- *
46
- * **One repository per object**, because `@cloudflare/computer` is strictly 1 DO ↔ 1
47
- * container: the id derives from caller *and* repository (see `workspaceName`),
48
- * so two repositories for one caller are two objects and two containers.
49
- *
50
- * **It constructs `Workspace` rather than using `withWorkspace`.** The mixin
51
- * stores the `Workspace` under a module-private symbol the package does not
52
- * export, so a method on the object cannot reach it — and what the host is
53
- * required to do needs exactly that: driving an outstanding pull (the library
54
- * owns no alarm and schedules nothing of its own), and the direct `runtime`
55
- * access the detached install needs. So this object owns the `Workspace` and
56
- * implements the one method the mixin otherwise provides,
57
- * `__getWorkspaceStub`. Callers outside see no difference.
58
- *
59
- * ## What is here, and what is a module next door
60
- *
61
- * This file is the Durable Object: the wake map, the deadlines, the RPC surface
62
- * and the wiring between them. The work each of those drives has its own home,
63
- * and each carries the reasoning for its own decisions:
64
- *
65
- * - `./install-job.ts` — when a dependency install runs, and every guard on it.
66
- * - `./deps-snapshot.ts` — starting a container from one that already installed.
67
- * - `./sync.ts` — the host's half of syncing, which nothing else will do.
68
- * - `./ca-trust.ts` — making a container able to speak TLS.
69
- * - `./git-identity.ts` — who a container's commits belong to.
70
- * - `./git-host.ts` — the operations that hold the GitHub credential.
71
- *
72
- * Each takes a small explicit seam, and that is the test: a module that needed
73
- * the whole object back would be a module that did not want extracting.
74
- */
75
- /** Where every checkout lives, inside the container and in the VFS. */
76
- export const WORKSPACE_DIR = "/workspace";
77
- /**
78
- * The Durable Object name for one caller's checkout of one repository.
79
- *
80
- * Exported because several places must agree on it and none can see the others:
81
- * the plugin that resolves the stub, the agent that hands it down to sub-agents,
82
- * and the cancellation path. A pipe rather than a slash, so the caller half
83
- * cannot forge a repository boundary by containing one.
84
- *
85
- * `repo` is undefined only before the first `repo_clone` or `scratch_open` of a
86
- * session — what a task works in is model-chosen, so there is genuinely nothing
87
- * to key on until it has chosen. That window resolves to a caller-level
88
- * workspace, which is never worked in: `beforeCheckout` sets the repository
89
- * before any git runs, and `scratch_open` sets its sentinel before anything
90
- * resolves a name.
91
- *
92
- * `repo` is not always a repository. A host may pass a sentinel through here as
93
- * one — a scratchpad is modelled that way, which is what keys it to its own
94
- * object and its own container. Anything that is not a GitHub name works, as long
95
- * as no caller could clone something that collides with it.
96
- */
97
- export function workspaceName(callerKey, repo) {
98
- return repo ? `${callerKey}|${repo}` : `${callerKey}|<unassigned>`;
99
- }
100
- // --- where the work is ------------------------------------------------------
101
- /**
102
- * The key the checkout record lives under.
103
- *
104
- * Deliberately **not** the install record's: what is on disk and what was
105
- * installed into it have different lifetimes, and one record cannot answer both.
106
- * See {@link WorkspaceObjectBase.noteCheckout}.
107
- */
108
- const CHECKOUT_KEY = "checkout";
109
- // --- what this object wakes for, and when ----------------------------------
110
- /**
111
- * Where the idle-reclaim deadline keeps its current schedule id.
112
- *
113
- * A schedule is a row the scheduler mints an id for, not a keyed upsert — so
114
- * "push this deadline back", which `#touch()` does on every single call into
115
- * this object, is cancel-then-set and needs the id of what stands now. See
116
- * `namedDeadline`.
117
- */
118
- const IDLE_RECLAIM_ID = "idle-reclaim-id";
119
- /**
120
- * How long a workspace survives without being used.
121
- *
122
- * A Durable Object is **never** reclaimed by the platform, and a namespace
123
- * cannot be enumerated from a Worker, so nothing else is coming to clean up.
124
- * Source-only workspaces are small (6.3 MB for slack-gatekeeper), which makes
125
- * this hygiene rather than cost control — but unbounded hygiene is still
126
- * unbounded.
127
- *
128
- * Exported because a host keeping work in a workspace has to say how long that
129
- * work survives untouched.
130
- */
131
- export const IDLE_RECLAIM_MS = 7 * 24 * 60 * 60 * 1000;
132
- /** Where the container-idle deadline keeps its current schedule id. */
133
- const CONTAINER_IDLE_ID = "container-idle-id";
134
- /** The holds on the container — see `hold` — as key → the time each lapses. */
135
- const HOLDS_KEY = "container-holds";
136
- /** When a release was asked for while a hold stood, for the last `unhold`. */
137
- const RELEASE_ASKED_KEY = "container-release-asked";
138
- /** Where the container-warm wake keeps its current schedule id. */
139
- const CONTAINER_WARM_ID = "container-warm-id";
140
- /** Where the dependency-snapshot wake keeps its current schedule id. */
141
- const DEPS_SNAPSHOT_ID = "deps-snapshot-id";
142
- /**
143
- * How long a container stays up after the last command **started**.
144
- *
145
- * Ours to schedule. The backend replaces a container but never stops one, and
146
- * there is no `@cloudflare/containers` `Container` here with a `sleepAfter` to
147
- * lean on: a container nobody is using runs until this deadline stops it.
148
- *
149
- * **This must exceed the longest command the agent allows**, and breaking that
150
- * kills work in flight. Measured from when a command *starts*: `#touch()` arms
151
- * the clock on the way into this object, and a running command touches nothing
152
- * again until it finishes — `handle.result()` is one long await and the FUSE
153
- * traffic under it never surfaces as an RPC. Set equal to `DEFAULT_TIMEOUT_MS`
154
- * in `./exec.ts`, the two timers race and whichever fires first destroys
155
- * the container the other depends on. Twenty minutes is double that ceiling;
156
- * raise it, never lower it, if `bash` is given a longer timeout.
157
- *
158
- * **The default, not the policy.** "Longest command" is a fact about the agent,
159
- * so one whose commands run longer must say so via
160
- * {@link WorkspaceObjectConfig.containerIdleMs} — `claude-coordinator` holds a
161
- * `claude -p` session open for its whole 40-minute timeout.
162
- */
163
- const CONTAINER_IDLE_MS = 20 * 60_000;
164
- /**
165
- * Refuse to grow past this, of the 10 GB a Durable Object may hold.
166
- *
167
- * Source-only workspaces run at ~6 MB, so this should never fire — which is why
168
- * it is worth having: if something does start pulling a large tree in, a
169
- * sentence naming the number beats a write failing somewhere unrelated.
170
- */
171
- const STORAGE_CAP_BYTES = 8 * 1024 * 1024 * 1024;
172
- /** Where the pending-pull drain keeps its current schedule id. */
173
- const SYNC_DRAIN_ID = "sync-drain-id";
174
- /**
175
- * How long a container may be kept alive past its idle deadline so an
176
- * outstanding pull can finish.
177
- *
178
- * The deadline defers while a drain is still moving blocks, because destroying
179
- * the container is what makes unpulled work unrecoverable. It cannot defer
180
- * forever — a pull that never completes would keep a container billing
181
- * indefinitely — so this is the wall on that, measured from the last use.
182
- */
183
- const SYNC_DRAIN_GRACE_MS = 30 * 60_000;
184
- /**
185
- * How long a container start may take before the calls waiting on it are refused.
186
- *
187
- * Late, because refusing a start that was only slow clears `#readying` while it
188
- * runs, and the next caller starts a second one beside it — and a start pushing
189
- * a large tree has taken nine minutes. Not later, because a turn is cut at
190
- * fifteen: past that the model reads no reason, and its next turn joins the
191
- * same stuck start.
192
- */
193
- const READY_DEADLINE_MS = 9 * 60_000;
194
- /**
195
- * `work`, refused once `ms` pass without it settling. The work itself goes on;
196
- * only the wait for it ends — see {@link READY_DEADLINE_MS}.
197
- */
198
- export function readyWithin(work, ms) {
199
- let timer;
200
- const expired = new Promise((_resolve, reject) => {
201
- timer = setTimeout(() => reject(new Error(`the workspace container did not become ready within ${ms / 1000}s; the next call starts it again`)), ms);
202
- });
203
- // The loser still settles, and a rejection nobody holds fails the request.
204
- work.catch(() => { });
205
- return Promise.race([work, expired]).finally(() => clearTimeout(timer));
206
- }
207
- /**
208
- * Set once the synced dependency trees are gone from this object's storage —
209
- * see {@link WorkspaceObjectBase.#purgeSyncedTrees}.
210
- */
211
- const SYNCED_TREES_PURGED_KEY = "deps:purged";
212
- /**
213
- * The `images` key a container starts from: `containers[].images.app` in the
214
- * consumer's wrangler config. Named to the backend rather than left as its
215
- * default, because {@link IMAGE_ENV} has to be read under the same key.
216
- */
217
- const IMAGE_NAME = "app";
218
- /**
219
- * The env name the prepared image's reference rides under — see `backend` for
220
- * why it rides at all.
221
- *
222
- * Nothing reads it, and the spelling keeps it that way. `computerd` hands a
223
- * command its allowlist plus every `COMPUTER_VAR_` name, and reads its own
224
- * configuration from `COMPUTERD_`, where a later release could claim this
225
- * name. Outside both, it stays in the daemon's environment, unread.
226
- */
227
- const IMAGE_ENV = "DYNAMICAGENTS_IMAGE";
228
- // --- the object -------------------------------------------------------------
229
- /**
230
- * Named rather than the anonymous class expression an app would write here.
231
- *
232
- * This package emits declarations, and `DurableObject` brings `ctx` and `env` in
233
- * protected — which makes the mixin's return type unnameable in a `.d.ts` and
234
- * fails the build with TS4094. A named base gives the emitted type something to
235
- * refer to. It costs one identifier and is invisible to a consumer.
236
- */
237
- class WorkspaceContainerHost extends DurableObject {
238
- }
239
- /**
240
- * `withWorkspaceContainer` adds one method, `getWorkspaceContainer()`, over
241
- * `this.ctx.container` — the runtime's own container handle, started with the
242
- * image and instance size this object asks for. Stopping it is this object's
243
- * job — see {@link CONTAINER_IDLE_MS}.
244
- */
245
- const WorkspaceContainerBase = withWorkspaceContainer(WorkspaceContainerHost);
246
- /**
247
- * One caller's checkout of one repository, and the container that mounts it.
248
- *
249
- * Abstract because a Durable Object class takes no constructor arguments, so
250
- * per-agent configuration cannot arrive that way. {@link workspaceConfig} is the
251
- * seam, for the same reason Think's own hooks are methods rather than options.
252
- *
253
- * ## Why `backend` and `#workspace` are lazy
254
- *
255
- * **Base class fields run before subclass fields**, so as plain fields they
256
- * would read `undefined` from any `workspaceConfig()` that touches a subclass
257
- * field — which `ClaudeCoordinatorWorkspace`'s does, for its credential store.
258
- * Memoised getters remove the hazard rather than documenting it, which is what
259
- * lets a subclass implement the seam however it likes.
260
- */
261
- export class WorkspaceObjectBase extends WorkspaceContainerBase {
262
- #configMemo;
263
- get #cfg() {
264
- return (this.#configMemo ??= this.workspaceConfig());
265
- }
266
- /** This object's log prefix, so one workspace stays tellable from another. */
267
- get #tag() {
268
- return this.#cfg.label;
269
- }
270
- /** This agent's container-idle window — see {@link WorkspaceObjectConfig}. */
271
- get #containerIdleMs() {
272
- return this.#cfg.containerIdleMs ?? CONTAINER_IDLE_MS;
273
- }
274
- /**
275
- * How long an install may run before it is killed.
276
- *
277
- * A getter because the fallback has to be the same number at every site an
278
- * install is bounded, and a `??` repeated at each is a chance to write a
279
- * different one.
280
- */
281
- get #installTimeoutMs() {
282
- return this.#cfg.installPlan.timeoutMs ?? 20 * 60_000;
283
- }
284
- /**
285
- * The one alarm, multiplexed across every reason this object wakes.
286
- *
287
- * A callback is registered under a **name**, and a schedule row persists that
288
- * name rather than a closure: the object is re-created on every wake, so
289
- * anything captured here would not survive one. Registration therefore happens
290
- * unconditionally, in a field initializer, every time.
291
- *
292
- * `hostOwns` is the acknowledgement that this class defines `alarm()` and
293
- * `fetch()` itself. A lifecycle installs its handlers only where the host has
294
- * none, so without saying so here the scheduler would be installed and never
295
- * fire, with no error anywhere. `alarm()` below calls through, which is what
296
- * makes the declaration true. **`fetch()` deliberately does not** — it serves
297
- * `computerd`'s capnweb WebSocket upgrade, and a lifecycle's `fetch` declines
298
- * any upgrade no installed capability claims.
299
- */
300
- #wake = installScheduler(this, {
301
- hostOwns: ["alarm", "fetch"],
302
- callbacks: {
303
- installRun: () => this.#install.onRun(),
304
- installWatch: () => this.#install.onWatch(),
305
- idleReclaim: () => this.#onIdleReclaim(),
306
- containerIdle: (payload) => this.#onContainerIdle(payload),
307
- containerWarm: () => this.#onContainerWarm(),
308
- depsSnapshot: () => this.#onDepsSnapshot(),
309
- syncRetry: (payload) => this.#onSyncDrain(payload)
310
- },
311
- onError: (err) => {
312
- console.error(`[${this.#tag}] a scheduled callback failed for good`, {
313
- id: this.ctx.id.toString(),
314
- err: String(err)
315
- });
316
- }
317
- });
318
- /** Reclaim a workspace nobody has touched. Moved forward by every `#touch`. */
319
- #idleReclaim = namedDeadline({
320
- storage: this.ctx.storage,
321
- scheduler: this.#wake.scheduler,
322
- key: IDLE_RECLAIM_ID,
323
- callback: "idleReclaim"
324
- });
325
- /** Stop a container nobody is using. Moved forward by every `#touch`. */
326
- #containerIdle = namedDeadline({
327
- storage: this.ctx.storage,
328
- scheduler: this.#wake.scheduler,
329
- key: CONTAINER_IDLE_ID,
330
- callback: "containerIdle"
331
- });
332
- /**
333
- * Start a cold container in the background — see {@link #warmIfCold}.
334
- *
335
- * A deadline rather than a bare `scheduler.set`, for the reason {@link #touch}
336
- * gives: armed per tool call, so anything that did not cancel the row standing
337
- * would leave one per call.
338
- */
339
- #containerWarm = namedDeadline({
340
- storage: this.ctx.storage,
341
- scheduler: this.#wake.scheduler,
342
- key: CONTAINER_WARM_ID,
343
- callback: "containerWarm"
344
- });
345
- /**
346
- * Snapshot a container that just installed — see `./deps-snapshot.ts`.
347
- *
348
- * From the alarm rather than the install's drain, which a model turn's
349
- * `waitUntil` may own.
350
- */
351
- #snapshotDue = namedDeadline({
352
- storage: this.ctx.storage,
353
- scheduler: this.#wake.scheduler,
354
- key: DEPS_SNAPSHOT_ID,
355
- callback: "depsSnapshot"
356
- });
357
- /**
358
- * Come back and move the next block of an outstanding pull.
359
- *
360
- * **The callback name is on disk in deployed objects, so it may not change.**
361
- * A schedule row persists its callback by name, and the scheduler drops a row
362
- * whose callback it cannot find with one line in the log — a rename would
363
- * strand every pull already scheduled.
364
- */
365
- #syncDrain = namedDeadline({
366
- storage: this.ctx.storage,
367
- scheduler: this.#wake.scheduler,
368
- key: SYNC_DRAIN_ID,
369
- callback: "syncRetry"
370
- });
371
- /**
372
- * The dependency install — see `./install-job.ts`, which owns every guard on it.
373
- *
374
- * The seam is what only this object can answer: where its storage and its
375
- * scheduler are, what the workspace is, and the entry-point bookkeeping an
376
- * install still owes on the way in.
377
- */
378
- #install = new InstallJob({
379
- storage: this.ctx.storage,
380
- scheduler: this.#wake.scheduler,
381
- workspace: () => this.#workspace,
382
- plan: () => this.#cfg.installPlan,
383
- timeoutMs: () => this.#installTimeoutMs,
384
- headroom: () => this.#storageHeadroom(),
385
- touch: () => this.#touch(),
386
- ready: () => this.#ready(),
387
- waitUntil: (promise) => this.ctx.waitUntil(promise),
388
- containerGone: () => this.#containerGone(),
389
- installedRecords: (tree) => this.#snapshot.dueEntries(tree),
390
- installed: async () => void (await this.#snapshotDue.set(new Date())),
391
- tag: () => this.#tag,
392
- id: () => this.ctx.id.toString()
393
- });
394
- /** The next container's dependency trees — see `./deps-snapshot.ts`. */
395
- #snapshot = new DepsSnapshot({
396
- storage: this.ctx.storage,
397
- container: () => this.ctx.container,
398
- image: () => this.#image,
399
- workspace: () => this.#workspace,
400
- rootFingerprint: (dir) => this.#install.rootFingerprint(dir),
401
- tree: () => this.#install.tree(),
402
- tag: () => this.#tag,
403
- id: () => this.ctx.id.toString()
404
- });
405
- /**
406
- * The GitHub credential, read at the moment it would be handed over.
407
- *
408
- * `#`-private, which is the only spelling actually unreachable over RPC — see
409
- * {@link WorkspaceGitConfig.tokenBinding} for why that matters here and not
410
- * for the rest of the config.
411
- *
412
- * Indexed rather than named: the subclass says which binding holds it, because
413
- * this package cannot name a consumer's `Env`.
414
- */
415
- #token() {
416
- const env = this.env;
417
- return env[this.#cfg.git.tokenBinding];
418
- }
419
- /** Clone, fetch and push, with the credential — see `./git-host.ts`. */
420
- #gitHost = new WorkspaceGitHost({
421
- git: () => this.#workspace.git,
422
- token: () => this.#token(),
423
- tag: () => this.#tag
424
- });
425
- /** The host's half of syncing — see `./sync.ts`. */
426
- #sync = new WorkspaceSync({
427
- workspace: () => this.#workspace,
428
- containerRunning: () => this.ctx.container?.running === true,
429
- deadline: () => this.#syncDrain,
430
- tag: () => this.#tag,
431
- id: () => this.ctx.id.toString()
432
- });
433
- /**
434
- * The container backend.
435
- *
436
- * `container: () => this` hands the backend this object's own container.
437
- * `workspace` is how `computerd` dials *back* in: the runtime builds a loopback
438
- * binding from the exported `WorkspaceProxy` class and the values below, which
439
- * is why the consuming Worker's entry point must re-export `WorkspaceProxy`
440
- * and why dropping that export breaks the container with no compile error.
441
- *
442
- * Nothing sets `egressHost`; the default `computer.internal` is the host the
443
- * container's outbound HTTP is intercepted on, internal to that loopback.
444
- *
445
- * **The image rides in the env so that a deploy replaces the container.** The
446
- * backend reuses a running container whose launch — env, network, size, image
447
- * key — matches the one it asks for, and the image a key resolves to is not
448
- * part of that: a container started before a deploy would go on running the
449
- * old image under the new Worker. The library here and the `computerd` in the
450
- * image are one release, and a daemon from across a version line fails to
451
- * authenticate (`[stage=auth]`). With the prepared image's reference in the
452
- * env as {@link IMAGE_ENV}, a new image is a new launch, and the next connect
453
- * replaces the container. With no image prepared under {@link IMAGE_NAME}
454
- * there is nothing to add, and the backend's start says so.
455
- *
456
- * The binding name, the egress policy and the instance size are the
457
- * subclass's — see {@link WorkspaceObjectConfig}, which carries the warnings
458
- * on each.
459
- *
460
- * `launch` is how a start restores a dependency snapshot instead of booting
461
- * the image — see `./deps-snapshot.ts`. Never in the env: the env is part of
462
- * what a running container is matched against.
463
- */
464
- #backendMemo;
465
- get backend() {
466
- return (this.#backendMemo ??= this.#newBackend());
467
- }
468
- /** The image a start boots, as the deploy prepared it. */
469
- get #image() {
470
- return this.ctx.container?.images?.[IMAGE_NAME];
471
- }
472
- #newBackend() {
473
- const image = this.#image;
474
- return new ContainerBackend({
475
- container: () => this,
476
- workspace: {
477
- binding: this.#cfg.binding,
478
- id: this.ctx.id.toString()
479
- },
480
- egress: this.#cfg.egress,
481
- name: IMAGE_NAME,
482
- instance: this.#cfg.instance,
483
- containerEnv: image === undefined ? {} : { [IMAGE_ENV]: image },
484
- launch: this.#snapshot.launch
485
- });
486
- }
487
- #workspaceMemo;
488
- get #workspace() {
489
- return (this.#workspaceMemo ??= new Workspace(this.#workspaceOptions()));
490
- }
491
- /**
492
- * The container's TLS trust, which `#ready` below keeps current.
493
- *
494
- * See `./ca-trust.ts` for why the answer lives in memory and what clears it.
495
- */
496
- #trust = new ContainerTrust({
497
- workspace: () => this.#workspace,
498
- container: () => this.ctx.container,
499
- tag: () => this.#tag,
500
- id: () => this.ctx.id.toString(),
501
- onExit: () => void this.#containerGone()
502
- });
503
- /**
504
- * The identity every repository in the container starts out attributed to.
505
- *
506
- * See `./git-identity.ts` for why the container needs one of its own when
507
- * `/repo` already configures the checkouts it clones.
508
- */
509
- #gitIdentity = new ContainerGitIdentity({
510
- workspace: () => this.#workspace,
511
- author: () => this.#cfg.git.author,
512
- tag: () => this.#tag,
513
- id: () => this.ctx.id.toString()
514
- });
515
- /** Where installs write — see `./container-deps.ts`. */
516
- #deps = new ContainerDeps({
517
- workspace: () => this.#workspace,
518
- workspaceDir: WORKSPACE_DIR,
519
- tag: () => this.#tag,
520
- id: () => this.ctx.id.toString()
521
- });
522
- /** Everything believed about a container, dropped when it goes. */
523
- async #containerGone() {
524
- this.#trust.forget();
525
- this.#gitIdentity.forget();
526
- this.#deps.forget();
527
- await this.#install.containerGone();
528
- await this.#snapshot.forgetDue();
529
- }
530
- /**
531
- * Set once {@link reclaimIfIdle} has emptied storage, until the alarm it armed
532
- * resets the isolate. Anything reaching this instance in between is addressing
533
- * storage that is gone.
534
- */
535
- #reclaimed = false;
536
- /**
537
- * The start already in flight, shared by everyone who asks for one.
538
- *
539
- * In memory, like {@link file://./ca-trust.ts}'s flag and for the same reason:
540
- * it describes a container, and an isolate that lost it asks again.
541
- */
542
- #readying;
543
- /**
544
- * Open the workspace, and make sure the container behind it can speak TLS and
545
- * installs onto its own disk.
546
- *
547
- * **Every path that might start a container goes through here**, and the CA
548
- * install hangs off container *liveness* rather than off the install job.
549
- * Whether a container is new and whether it is due dependencies are different
550
- * questions: an install declines to arm for a `skipped` or `idle` state,
551
- * returns early when one is already in flight, and again when the workspace is
552
- * full — so hanging trust off it leaves a replaced container talking to an
553
- * untrusted CA with nothing pending that would fix it, and leaves it worst
554
- * exactly when egress is what the agent needs to dig itself out.
555
- *
556
- * `ctx.container?.running` is read **before** `ready()`, because `ready()` is
557
- * what starts a stopped container — afterwards every container looks running
558
- * and the distinction is gone.
559
- *
560
- * Cheap enough to sit on the busiest entry point in the object: at most one
561
- * extra exec per isolate, plus one per container it sees start. An unchanged
562
- * container costs a boolean.
563
- *
564
- * Private, like everything else in here that is not an RPC: every non-`#`
565
- * method is reachable over RPC — see {@link WorkspaceGitConfig.tokenBinding}.
566
- *
567
- * **One start at a time**, because {@link #warmIfCold} makes overlap ordinary:
568
- * a warm and the first `bash` want the container at the same moment, as do
569
- * a warm and the install alarm. Two starts means two trust commands through a
570
- * half-established connection, the second pushing the tree again behind the
571
- * first. Cleared on settle, so the next caller asks about the container that
572
- * is there now — and on {@link READY_DEADLINE_MS}, so a start that hangs holds
573
- * its callers for that long and no longer.
574
- */
575
- async #ready() {
576
- this.#readying ??= readyWithin(this.#readyNow(), READY_DEADLINE_MS).finally(() => {
577
- this.#readying = undefined;
578
- });
579
- return this.#readying;
580
- }
581
- /**
582
- * One start, run for whoever got there first — see {@link #ready}.
583
- *
584
- * A cold start restores the dependency snapshot when one fits, and the marker
585
- * the setup reads decides whether what came up is adopted as installed — see
586
- * `./deps-snapshot.ts`.
587
- */
588
- async #readyNow() {
589
- // Read **before** `ready()`, which starts a stopped container.
590
- let restoring;
591
- if (!this.ctx.container?.running) {
592
- await this.#containerGone();
593
- restoring = await this.#snapshot.arm(() => this.#expectedTree());
594
- }
595
- try {
596
- await this.#workspace.ready();
597
- // Before the first exec, which pushes the whole tree into a new container.
598
- await this.#purgeSyncedTrees();
599
- await this.#trust.ensure();
600
- await this.#gitIdentity.ensure();
601
- const found = await this.#deps.ensure(restoring?.dir ?? (await this.#install.dir()));
602
- if (found)
603
- await this.#install.reconcile(found.marker);
604
- if (restoring &&
605
- (await this.#snapshot.restored(restoring, found?.marker)))
606
- await this.#install.adopt(restoring);
607
- }
608
- finally {
609
- this.#snapshot.disarm();
610
- }
611
- }
612
- /**
613
- * The install a cold start would owe: where the last one ran, or the checkout
614
- * when none has — a worktree seeded before its first install.
615
- */
616
- async #expectedTree() {
617
- const target = (await this.#install.target()) ?? (await this.#checkoutTarget());
618
- if (!target)
619
- return undefined;
620
- const fingerprint = await this.#install.fingerprintFor(target);
621
- return fingerprint ? { dir: target.dir, fingerprint } : undefined;
622
- }
623
- async #checkoutTarget() {
624
- const record = await this.ctx.storage.get(CHECKOUT_KEY);
625
- if (!record?.dir)
626
- return undefined;
627
- return { dir: record.dir, ...(record.repo ? { repo: record.repo } : {}) };
628
- }
629
- /**
630
- * Delete every `node_modules` that was synced into this object, once.
631
- *
632
- * Left in place, each is pushed into every new container — where the bind
633
- * mount hides it but computerd still holds it in memory.
634
- */
635
- async #purgeSyncedTrees() {
636
- if (await this.ctx.storage.get(SYNCED_TREES_PURGED_KEY))
637
- return;
638
- const startedAt = Date.now();
639
- const fs = this.#workspace.fs;
640
- try {
641
- const trees = await fs
642
- .find(WORKSPACE_DIR, "**/node_modules", {
643
- exclude: ["**/.git", "**/node_modules/*"]
644
- })
645
- // No workspace directory yet, so nothing was ever synced.
646
- .catch((err) => {
647
- if (err?.code === "ENOENT")
648
- return [];
649
- throw err;
650
- });
651
- for (const tree of trees) {
652
- if (tree.type === "dir")
653
- await fs.rm(tree.path, { recursive: true });
654
- }
655
- await this.ctx.storage.put(SYNCED_TREES_PURGED_KEY, Date.now());
656
- if (trees.length > 0)
657
- console.info(`[${this.#tag}] purged synced dependency trees`, {
658
- id: this.ctx.id.toString(),
659
- trees: trees.map((t) => t.path),
660
- ms: Date.now() - startedAt
661
- });
662
- }
663
- catch (err) {
664
- // Left unmarked, so the next start tries again; a command must not fail
665
- // for it.
666
- console.warn(`[${this.#tag}] could not purge synced dependency trees`, {
667
- id: this.ctx.id.toString(),
668
- err: String(err)
669
- });
670
- }
671
- }
672
- #workspaceOptions() {
673
- return {
674
- // `ctx.storage.sql.exec` returns a narrower row type than
675
- // `DurableObjectStorageLike` declares and the two are invariant, so the
676
- // cast goes through `unknown`. The runtime shapes match; this is the
677
- // pattern the package's own example uses.
678
- storage: this.ctx.storage,
679
- backends: [this.backend],
680
- // One line per committed sync block and one per finished operation, into
681
- // the same Workers Observability view as the spans below — blocks,
682
- // entries, bytes, and the CPU headroom each block left.
683
- //
684
- // **It covers the syncs this object drives, not the ones a command drives
685
- // for itself.** `pull()` and `push()` log; the push a `runtime.exec` does
686
- // before it spawns — the expensive one, since a fresh container takes the
687
- // whole tree — logs nothing. Its cost shows up as a `workspace.sync.push`
688
- // span, and on `ca-trust.ts`'s line.
689
- syncTelemetryEnabled: true,
690
- // One span per sync push, sync pull, exec spawn and filesystem op, into
691
- // the same Workers Observability view the rest of this Worker traces to.
692
- // Needs `observability.traces.enabled` in wrangler.jsonc; without the
693
- // feature flag `tracing` is undefined and this degrades to a no-op.
694
- observer: createCloudflareObserver({ tracing }),
695
- // Git, running **here** rather than in the container.
696
- //
697
- // This is what lets the GitHub token stay on this side of the boundary.
698
- // `createGitClient` binds isomorphic-git to `provider()` — the local
699
- // SQLite store, not the wire — so a clone, fetch or push executes next to
700
- // the data it writes, and the container never holds a credential at all.
701
- // Credentialed `git` in the container cannot: it executes whatever
702
- // `.git/config` and `.git/hooks` in the checkout name.
703
- //
704
- // Needs `@platformatic/vfs`, an optional peer of `@cloudflare/computer`:
705
- // the adapter that wraps `provider()` into an isomorphic-git FsClient
706
- // imports it lazily and throws a named error when it is absent.
707
- git: createGitClient(),
708
- // Only the commit-producing subcommands read this, and the three
709
- // operations driven from here — clone, fetch, push — are not among them.
710
- // Set anyway so that a `pull` or `merge` added later fails on the merge
711
- // itself rather than on `MissingIdentityError`, and set to the same pair
712
- // `/repo` writes into the checkout's own config at clone time (see
713
- // `author` in each agent's `plugins.ts`), so a commit cannot be
714
- // attributed differently depending on which side made it.
715
- defaultGitIdentity: this.#cfg.git.author
716
- };
717
- }
718
- /**
719
- * Repair a lost alarm on the way in.
720
- *
721
- * The one failure a scheduler cannot defend against from the inside: the
722
- * runtime retries a throwing `alarm()` a bounded number of times and then
723
- * stops for good, and a deleted-class migration takes the alarm with the
724
- * storage. Both leave schedule rows due with nothing coming for them, and the
725
- * symptom is silence.
726
- *
727
- * So every RPC into this object checks. That fully covers the sync drain,
728
- * `install-watch` and `container-idle`, which only matter while somebody is
729
- * using the workspace. It does **not** cover `idle-reclaim`, which by
730
- * definition fires when nobody is — that one has the agent's weekly cron
731
- * poking `reclaimIfIdle` as its backstop.
732
- */
733
- async #repairAlarm() {
734
- try {
735
- await this.#wake.start();
736
- await this.#wake.rearm();
737
- }
738
- catch (err) {
739
- console.error(`[${this.#tag}] could not repair the alarm`, {
740
- id: this.ctx.id.toString(),
741
- err: String(err)
742
- });
743
- }
744
- }
745
- /**
746
- * What `getWorkspace(stub)` calls from outside this object.
747
- *
748
- * The one piece of `withWorkspace` reimplemented here — see the file comment
749
- * for why the mixin is not used. `ready()` first, because the stub is only
750
- * meaningful once the workspace has opened its store.
751
- */
752
- async __getWorkspaceStub() {
753
- // The busiest entry point by far, and therefore the one that keeps both the
754
- // idle clock and the alarm honest.
755
- await this.#touch();
756
- await this.#repairAlarm();
757
- await this.#ready();
758
- await this.#install.armIfTreeMissing();
759
- return this.#workspace.stub();
760
- }
761
- /**
762
- * The same workspace, for a caller that will only touch the filesystem.
763
- *
764
- * **The container is not started, and that is the whole difference.** The
765
- * filesystem is this object's own SQLite, so a read or write of it needs no
766
- * container and an absent one cannot block it. {@link __getWorkspaceStub} adds
767
- * `#ready()`, and the first command in a fresh container re-pushes the whole
768
- * tree before it runs, with `timeoutMs` reaching the spawned process and
769
- * nothing earlier.
770
- *
771
- * A stub cannot enforce what the caller does with it: `runtime.exec` on this
772
- * one would run against a container with no CA. The exec paths take the other
773
- * method — see `openWorkspaceFs` in `./open.ts`.
774
- */
775
- async __getWorkspaceFsStub() {
776
- await this.#touch();
777
- await this.#repairAlarm();
778
- await this.#warmIfCold();
779
- // Indexes mounts and nothing else — no backend connects here, which is what
780
- // makes this cheap enough to sit in front of every file tool.
781
- await this.#workspace.ready();
782
- return this.#workspace.stub();
783
- }
784
- /**
785
- * Ask the alarm to start the container, so the first `bash` finds one that
786
- * is up and already holding the tree.
787
- *
788
- * What this avoids is the push, not the boot — see {@link __getWorkspaceFsStub}.
789
- * Moving it to the alarm overlaps it with the model's reading.
790
- *
791
- * **Only for a workspace with a checkout in it**, since an empty one has
792
- * nothing to push and nothing to run, and a container it never uses still
793
- * bills.
794
- *
795
- * Only the arming is awaited. A warm that fails costs the next `bash` the
796
- * start it would have paid for anyway.
797
- */
798
- async #warmIfCold() {
799
- // The case on every call but the first, which is what keeps the reads below
800
- // off the per-tool-call cost.
801
- if (this.ctx.container?.running)
802
- return;
803
- // Already starting — `#ready()` dedupes that, but arming again would queue a
804
- // wake-up for work in progress.
805
- if (this.#readying)
806
- return;
807
- const record = await this.ctx.storage.get(CHECKOUT_KEY);
808
- if (!record?.dir)
809
- return;
810
- await this.#containerWarm.set(new Date());
811
- }
812
- /**
813
- * Clone, fetch and push — the operations that need the GitHub token.
814
- *
815
- * The work is `./git-host.ts`, which owns why the credential stays on this
816
- * side of the boundary. What stays here is what only this object can do: the
817
- * entry-point bookkeeping, and making the workspace current before git reads
818
- * it.
819
- */
820
- async gitClone(req) {
821
- const behind = await this.#beforeGit();
822
- return behind ?? (await this.#gitHost.clone(req));
823
- }
824
- async gitFetch(req) {
825
- const behind = await this.#beforeGit();
826
- return behind ?? (await this.#gitHost.fetch(req));
827
- }
828
- async gitPush(req) {
829
- const behind = await this.#beforeGit();
830
- return behind ?? (await this.#gitHost.push(req));
831
- }
832
- /**
833
- * A checkout's `origin`, from this object's storage. Starts no container and
834
- * moves no deadline, like {@link checkoutDir}: a pull request polled for its
835
- * review is not the workspace in use.
836
- */
837
- async gitOrigin(dir) {
838
- return await this.#gitHost.origin(dir);
839
- }
840
- /**
841
- * Bring the workspace up to date, or say why git must not run.
842
- *
843
- * **Git here reads this object's storage**, so it has to be current first. The
844
- * commits these operations push were made by `git` *in the container*, and
845
- * they reach this side on that command's own post-exec pull. When that pull
846
- * did not finish, the workspace still holds the tree as it was before the
847
- * commit — and isomorphic-git, running here, would push a branch that does not
848
- * include it. That is the one failure worth stopping for: the push reports
849
- * success, GitHub shows an older tree, and nothing in either account says
850
- * why.
851
- *
852
- * Refused rather than attempted, because every alternative is worse. Pushing
853
- * anyway publishes a tree the agent did not produce; pushing "what is here"
854
- * silently redefines what the caller asked for. A refusal is recoverable — the
855
- * pull resumes on its own schedule and the operation works when retried.
856
- *
857
- * Returns the refusal to hand back, or `undefined` to proceed.
858
- */
859
- async #beforeGit() {
860
- await this.#touch();
861
- await this.#repairAlarm();
862
- /**
863
- * Drained **before** `#ready()`, which is what starts a stopped container.
864
- *
865
- * An outstanding pull belongs to the runtime that ran the command. Start a
866
- * replacement first and the drain can no longer tell: `pull()` reconnects to
867
- * whatever is running now, finds a filesystem that matches this object
868
- * because the workspace just pushed it there, and reports a clean
869
- * completion — for writes that are gone.
870
- *
871
- * Asked while the old container is still the only one there, the same call
872
- * either moves the writes or says plainly there is nothing to move them
873
- * from.
874
- */
875
- const synced = await this.#sync.drain(SYNC_DRAIN_BUDGET_MS);
876
- // `failed` refuses alongside `incomplete`, and the reason is that the two
877
- // differ only in what the *drain* should do next. To git they are one thing:
878
- // writes that may exist and are not here. Letting a failure through would
879
- // push the tree this check exists to refuse.
880
- if (synced === "incomplete" || synced === "failed") {
881
- // Plain, rather than carrying the failure ladder: a git operation is
882
- // somebody waiting, which is fresh evidence that the next attempt is worth
883
- // making soon. The ladder rebuilds from there if the fault persists, and
884
- // the rate is bounded by how often a model can call a tool.
885
- await this.#sync.arm();
886
- console.error(`[${this.#tag}] refusing git: the workspace is behind`, {
887
- id: this.ctx.id.toString(),
888
- outcome: synced
889
- });
890
- return {
891
- ok: false,
892
- message: "the container's most recent changes have not reached the workspace " +
893
- "yet, and git runs against the workspace — so this would act on a " +
894
- "tree that is missing them. The transfer resumes on its own; try " +
895
- "again in a moment."
896
- };
897
- }
898
- await this.#ready();
899
- return undefined;
900
- }
901
- /**
902
- * Start this checkout's dependency install, and return without waiting.
903
- *
904
- * Called from `repo_clone` through the repo plugin's `afterCheckout` hook, so
905
- * it runs inside a model turn — see `./install-job.ts` for why the drain's owner
906
- * is the thing that matters here.
907
- */
908
- async startInstall(req) {
909
- return await this.#install.start(req);
910
- }
911
- /**
912
- * This workspace's dependency snapshot, for another of the same class to start
913
- * from — see {@link seedDepsSnapshot}. Starts no container and moves no
914
- * deadline.
915
- */
916
- async depsSnapshot() {
917
- return await this.#snapshot.get();
918
- }
919
- /**
920
- * Start this workspace's next container from `record`, another workspace's
921
- * dependency snapshot, when it fits the checkout here — a worktree at the same
922
- * path as the checkout it was cut from. Call it after the checkout and before
923
- * the first command.
924
- *
925
- * A snapshot is that workspace's whole disk, so offer one only between
926
- * workspaces of the same caller.
927
- */
928
- async seedDepsSnapshot(record) {
929
- return await this.#snapshot.seed(record, () => this.#expectedTree());
930
- }
931
- // --- lifecycle ---------------------------------------------------------------
932
- /**
933
- * Mark this workspace as in use, and push its reclamation back.
934
- *
935
- * Every entry point calls this, which is what makes the idle clock measure
936
- * *use* rather than "when the agent last said this name". The agent hands a
937
- * workspace name to a sub-agent once and then never sees the traffic; the
938
- * workspace sees all of it.
939
- */
940
- async #touch() {
941
- // A reclaim emptied this instance and its reset has not landed yet.
942
- // Scheduling below would write to a table that is gone, so fail this call
943
- // and let the caller come back on a fresh object.
944
- if (this.#reclaimed) {
945
- this.ctx.abort("workspace reclaimed", { retryAlarm: false });
946
- }
947
- const now = Date.now();
948
- // Everything reaches this object by RPC, which bypasses `fetch` — so this
949
- // is where the lifecycle gets started, and without it the scheduler's schema
950
- // is never migrated and the first `set` below runs against nothing. Guarded
951
- // internally, so calling it on every touch costs one resolved promise.
952
- await this.#wake.start();
953
- await this.ctx.storage.put("lastUsedAt", now);
954
- // Two deadlines *moved*, not two schedules added. This is the hottest path
955
- // in the object — every entry point calls it — so a bare `scheduler.set`
956
- // here would leave one row per request, every one of them due.
957
- await this.#idleReclaim.set(new Date(now + IDLE_RECLAIM_MS));
958
- await this.#containerIdle.set(new Date(now + this.#containerIdleMs));
959
- }
960
- /**
961
- * Stop the container, and **keep everything on disk**.
962
- *
963
- * The counterpart to {@link reclaimIfIdle}, and the difference is the whole
964
- * reason both exist. A reclaim is for a workspace nobody wants again: it empties
965
- * storage, so the checkout and the dependency snapshot go with the container.
966
- * This is for a workspace that will be worked in again — the next task on the
967
- * same repository skips the clone, and the install by restoring the snapshot.
968
- *
969
- * **Why a caller needs this at all.** The idle deadline would eventually do it,
970
- * but it cannot be tuned down to meet a cost target: `containerIdleMs` must
971
- * exceed the longest command the agent allows, and a container that outlives its
972
- * work by that much bills for the difference. So a host that knows the work is
973
- * finished says so, and the deadline goes back to being a backstop for the case
974
- * where nothing got to say anything.
975
- *
976
- * Deliberately does **not** touch: `#touch()` would push the very deadline this
977
- * is standing in for.
978
- *
979
- * `released: false` means the container is still up and the standing deadline
980
- * still owns it — an install is running, or a pull is still moving blocks. Both
981
- * resolve themselves: the drain re-arms the deadline to come back and finish,
982
- * which is the same path `#onContainerIdle` takes. Under a {@link hold} the
983
- * release is deferred instead, to the last `unhold`.
984
- */
985
- async releaseContainer() {
986
- // Work this object cannot see. Deferred rather than refused: the last
987
- // `unhold` releases, unless the workspace is used again first.
988
- const holds = await this.#liveHolds();
989
- if (holds.length > 0) {
990
- await this.ctx.storage.put(RELEASE_ASKED_KEY, Date.now());
991
- console.info(`[${this.#tag}] release deferred while held`, {
992
- id: this.ctx.id.toString(),
993
- holds
994
- });
995
- return { released: false };
996
- }
997
- await this.ctx.storage.delete(RELEASE_ASKED_KEY);
998
- // An install in flight is "in use" even though nothing has called in.
999
- // Same rule, and same reason, as `#onContainerIdle`.
1000
- const install = await this.#install.read();
1001
- if (install.state === "running")
1002
- return { released: false };
1003
- // Never stop a container with a pull still moving: that is what turns an
1004
- // outstanding write from late into lost. `#drainBeforeStop` carries the
1005
- // reasoning and re-arms the deadline when it defers.
1006
- const lastUsedAt = (await this.ctx.storage.get("lastUsedAt")) ?? 0;
1007
- if (await this.#drainBeforeStop(lastUsedAt, 0))
1008
- return { released: false };
1009
- // Dropped rather than left standing: the container is about to be gone, and an
1010
- // alarm that fires afterwards logs a stop against a container that is not
1011
- // running. `#touch` re-arms it the next time this workspace is used.
1012
- await this.#containerIdle.clear();
1013
- await this.#stopContainer("idle");
1014
- return { released: true };
1015
- }
1016
- /**
1017
- * Keep the container up for work this object cannot see, until `untilMs` or
1018
- * {@link unhold}.
1019
- *
1020
- * A Claude Code session a host starts here runs detached: nothing calls in
1021
- * while it works, and the runtime cannot list
1022
- * what is running. So a host done with the workspace cannot tell that the
1023
- * container is not, and {@link releaseContainer} would stop the session with
1024
- * it. A hold is the host saying so. It defers a release; it is not use, so it
1025
- * does not touch, and the idle deadline already outlasts a session.
1026
- *
1027
- * `untilMs` bounds a hold whose `unhold` never comes — a run cut between its
1028
- * start and its end. Past it the hold is ignored.
1029
- */
1030
- async hold(key, untilMs) {
1031
- const holds = await this.#holdMap();
1032
- holds[key] = untilMs;
1033
- await this.ctx.storage.put(HOLDS_KEY, holds);
1034
- }
1035
- /**
1036
- * The work a {@link hold} stood for is over. When it was the last hold, a
1037
- * release asked for while it stood happens now — unless the workspace was
1038
- * used since, in which case the next release is asked for by whoever used it.
1039
- */
1040
- async unhold(key) {
1041
- // Read and written with no other I/O between, so the object's input gate
1042
- // keeps another call from interleaving: the map written is the one decided on.
1043
- const holds = await this.#holdMap();
1044
- delete holds[key];
1045
- await this.ctx.storage.put(HOLDS_KEY, holds);
1046
- if (Object.keys(holds).length > 0)
1047
- return { released: false };
1048
- const asked = await this.ctx.storage.get(RELEASE_ASKED_KEY);
1049
- if (asked === undefined)
1050
- return { released: false };
1051
- // A use in the same millisecond as the ask counts as after it: wrongly
1052
- // keeping a container costs its idle window, wrongly stopping one a session.
1053
- const lastUsedAt = (await this.ctx.storage.get("lastUsedAt")) ?? 0;
1054
- if (lastUsedAt >= asked) {
1055
- await this.ctx.storage.delete(RELEASE_ASKED_KEY);
1056
- return { released: false };
1057
- }
1058
- return await this.releaseContainer();
1059
- }
1060
- /** Every hold, lapsed ones pruned. */
1061
- async #holdMap() {
1062
- const now = Date.now();
1063
- const stored = (await this.ctx.storage.get(HOLDS_KEY)) ?? {};
1064
- return Object.fromEntries(Object.entries(stored).filter(([, until]) => until > now));
1065
- }
1066
- /** The keys of the holds still standing. */
1067
- async #liveHolds() {
1068
- return Object.keys(await this.#holdMap());
1069
- }
1070
- /**
1071
- * Throw this workspace away if nothing has touched it for `maxIdleMs`.
1072
- *
1073
- * Re-checks the clock rather than trusting the caller: the alarm may have been
1074
- * armed a week ago, and a use since then must win. Safe to call from anywhere
1075
- * for the same reason, which is what lets the agent's cron poke it as a
1076
- * backstop without needing to know anything.
1077
- */
1078
- async reclaimIfIdle(maxIdleMs = IDLE_RECLAIM_MS) {
1079
- const lastUsedAt = await this.ctx.storage.get("lastUsedAt");
1080
- const bytes = this.ctx.storage.sql.databaseSize;
1081
- // Nothing has ever used this object, so there is nothing to reclaim.
1082
- //
1083
- // Load-bearing, not defensive. `lastUsedAt` is written by `#touch()` and
1084
- // removed by the `deleteAll()` below, so an *already reclaimed* workspace
1085
- // reads exactly like a brand new one. Defaulting it to 0 makes both "idle
1086
- // since the epoch", and the weekly sweep then re-reclaims every workspace it
1087
- // has ever reclaimed, recreating storage just to empty it again.
1088
- if (lastUsedAt === undefined)
1089
- return { reclaimed: false, idleMs: 0, bytes };
1090
- const idleMs = Date.now() - lastUsedAt;
1091
- if (idleMs < maxIdleMs)
1092
- return { reclaimed: false, idleMs, bytes };
1093
- console.info(`[${this.#tag}] reclaiming an idle workspace`, {
1094
- id: this.ctx.id.toString(),
1095
- idleDays: Math.round(idleMs / 86_400_000),
1096
- bytes
1097
- });
1098
- await this.#stopContainer("reclaim");
1099
- // `deleteAll` does not take the alarm with it, so the alarm goes first —
1100
- // otherwise a reclaimed object wakes once more into empty storage.
1101
- await this.ctx.storage.deleteAlarm();
1102
- await this.ctx.storage.deleteAll();
1103
- // It *also* takes the lifecycle's job queue table, which this instance has
1104
- // already created and will not create again — so the next schedule set from
1105
- // this isolate would fail against a table that is gone, and the workspace
1106
- // would stop arming its timers until something evicted it. The answer is to
1107
- // stop using this instance: a later call constructs the object fresh on empty
1108
- // storage, and every table comes back with it.
1109
- //
1110
- // **The reset runs from its own alarm invocation, never from here.** An
1111
- // `abort()` on the way out of an RPC races that RPC's response, and the
1112
- // weekly sweep reads this result to decide whether to forget the workspace —
1113
- // so a reset that beat the response would report a failed reclaim for one
1114
- // that had already emptied the object. A timer would race it the same way;
1115
- // only a separate invocation is ordered after the response.
1116
- //
1117
- // Nothing durable records this. The flag exists to reset *this* isolate, and
1118
- // an isolate that went away on its own has already achieved that: a fresh
1119
- // instance creates the tables it needs on the way in.
1120
- this.#reclaimed = true;
1121
- await this.ctx.storage.setAlarm(Date.now());
1122
- return { reclaimed: true, idleMs, bytes };
1123
- }
1124
- /**
1125
- * Stop the container, keeping nothing. The workspace is what persists.
1126
- *
1127
- * Logged first because `@cloudflare/computer` reports this exit as
1128
- * `expected: false`: its own stop is not public, so a raw `destroy()` reads
1129
- * as a crash in its log.
1130
- */
1131
- async #stopContainer(reason) {
1132
- console.info(`[${this.#tag}] stopping the container`, {
1133
- id: this.ctx.id.toString(),
1134
- reason,
1135
- running: this.ctx.container?.running ?? false
1136
- });
1137
- // An install's snapshot the wake has not taken yet would go with the disk
1138
- // it is of, so it is taken here first. Not for a reclaim, which keeps
1139
- // nothing. Either way, one in flight needs the container running.
1140
- if (reason === "idle")
1141
- await this.#snapshot.takeDue();
1142
- await this.#snapshot.settled();
1143
- // Hang up the session to computerd first: a destroy that cuts it live
1144
- // surfaces as an uncaught `Network connection lost`. The workspace
1145
- // reconnects on its next use, and `close()` swallows its own failures.
1146
- await this.#workspaceMemo?.close();
1147
- try {
1148
- await this.ctx.container?.destroy();
1149
- }
1150
- catch (err) {
1151
- // Already gone, most likely, and a container that cannot be stopped must
1152
- // not turn a clean reclaim into a failed alarm.
1153
- console.warn(`[${this.#tag}] could not stop the container`, {
1154
- id: this.ctx.id.toString(),
1155
- err: String(err)
1156
- });
1157
- }
1158
- await this.#containerGone();
1159
- }
1160
- /**
1161
- * Whether this object is out of room, and by how much.
1162
- *
1163
- * Read on **two** paths, and the second is load-bearing. The install job
1164
- * consults it because an install is the operation that can move the number
1165
- * meaningfully. {@link advisories} consults it on every call, which is what
1166
- * lets a full workspace reach commands that have nothing to do with
1167
- * dependencies — the write being lost is rarely a dependency's, so a capacity
1168
- * fact delivered only alongside install state reaches everything except what
1169
- * it is about.
1170
- *
1171
- * Cheap enough for that: `databaseSize` is a local property read, not a query.
1172
- */
1173
- #storageHeadroom() {
1174
- const bytes = this.ctx.storage.sql.databaseSize;
1175
- if (bytes < STORAGE_CAP_BYTES)
1176
- return undefined;
1177
- return { bytes, capBytes: STORAGE_CAP_BYTES };
1178
- }
1179
- // --- the dependency install ------------------------------------------------
1180
- /**
1181
- * Record what was checked out here, and say whether it is actually visible.
1182
- *
1183
- * **The single writer of {@link CHECKOUT_KEY}**, called by the two paths that
1184
- * know a checkout landed: the repo plugin's `afterCheckout` hook, which fires
1185
- * only once a tree is established, and `scratch_open`, which fires once
1186
- * `git init` has exited 0.
1187
- *
1188
- * **Why the checkout is recorded apart from the install**, which call sites
1189
- * point here for rather than restating: an install is conditional and a
1190
- * checkout is not. `resolveInstallCommand` skips a checkout it finds nothing
1191
- * to install in — any without a `package.json` — so a path written only as
1192
- * part of an install is missing exactly there. What that costs is not an
1193
- * optimisation: {@link checkoutDir} is how a delegated session is told where
1194
- * to work, so a repository can clone perfectly, report itself correctly
1195
- * through the repo tools, and never be worked in. The install keeps its own
1196
- * context for the mirror-image reason — what to re-run on a cold container has
1197
- * a different lifetime from what is on disk.
1198
- *
1199
- * Returns the probe as well as the path, so a caller learns in one round trip
1200
- * whether the tree is visible rather than discovering it a delegation later.
1201
- */
1202
- async noteCheckout(req) {
1203
- await this.#touch();
1204
- const record = {
1205
- dir: req.dir,
1206
- ...(req.repo ? { repo: req.repo } : {}),
1207
- kind: req.kind,
1208
- at: Date.now()
1209
- };
1210
- await this.ctx.storage.put(CHECKOUT_KEY, record);
1211
- const present = await this.#isCheckout(req.dir);
1212
- console.info(`[${this.#tag}] checkout recorded`, {
1213
- id: this.ctx.id.toString(),
1214
- dir: req.dir,
1215
- kind: req.kind,
1216
- ...(req.repo ? { repo: req.repo } : {}),
1217
- present
1218
- });
1219
- return { dir: req.dir, present };
1220
- }
1221
- /**
1222
- * Where the work is — a directory holding a git repository, or nothing.
1223
- *
1224
- * Two callers depend on that being the meaning rather than "a path somebody
1225
- * wrote down once": a Claude Code session takes it as its cwd, and
1226
- * `discardWorkingTree` runs `git reset --hard` in it. Both want the same
1227
- * question answered, and neither can recover from a confident wrong answer.
1228
- *
1229
- * So it is **probed, not remembered**. A record is where to look; `.git` being
1230
- * there is what makes the answer true. That costs one local read of this
1231
- * object's own SQLite — everything under the checkout is durable, `.git`
1232
- * included — and it is what turns a stale record into `undefined` instead of
1233
- * into a session started in a directory that is no longer a checkout.
1234
- *
1235
- * The record is the only source. A workspace that has never recorded one
1236
- * answers `undefined` until its next checkout does, which `repo_clone` does on
1237
- * any tree it can fetch and reset.
1238
- */
1239
- async checkoutDir() {
1240
- const record = await this.ctx.storage.get(CHECKOUT_KEY);
1241
- const dir = record?.dir;
1242
- if (!dir)
1243
- return undefined;
1244
- if (await this.#isCheckout(dir))
1245
- return dir;
1246
- // Said out loud because the return value cannot say it: a caller reads
1247
- // `undefined` as "nothing has been cloned", which is also the right answer
1248
- // for a workspace nobody ever cloned into. Only this line separates them.
1249
- console.warn(`[${this.#tag}] a recorded checkout is no longer there`, {
1250
- id: this.ctx.id.toString(),
1251
- dir,
1252
- recorded: record?.kind,
1253
- // The rest of the record, because this line is read during an incident and
1254
- // "the checkout for acme/spike went missing forty minutes ago" is a
1255
- // different investigation from a bare path. It is also the only reader
1256
- // these two fields have.
1257
- ...(record?.repo ? { repo: record.repo } : {}),
1258
- ...(record ? { ageMs: Date.now() - record.at } : {})
1259
- });
1260
- return undefined;
1261
- }
1262
- /**
1263
- * Whether `dir` holds a git repository, as this object's own storage sees it.
1264
- *
1265
- * `.git` rather than the directory: an empty directory is not a checkout, and
1266
- * every caller of {@link checkoutDir} needs git to be there — to reset the
1267
- * tree, or to run a session that will commit in it. It is
1268
- * also the one probe a scratchpad and a clone answer identically, which is
1269
- * what lets them share every path below this line.
1270
- */
1271
- async #isCheckout(dir) {
1272
- try {
1273
- return await pathExists(this.#workspace.fs, `${dir}/.git`);
1274
- }
1275
- catch (err) {
1276
- // A read of local SQLite that threw says nothing about the tree. Treat it
1277
- // as absent: refusing a delegation costs a tool call, starting a session in a
1278
- // directory that may not exist costs the run.
1279
- console.warn(`[${this.#tag}] could not probe a checkout`, {
1280
- id: this.ctx.id.toString(),
1281
- dir,
1282
- err: String(err)
1283
- });
1284
- return false;
1285
- }
1286
- }
1287
- /**
1288
- * Everything currently true about this workspace that a caller must not assume
1289
- * away — the array `bash`, `edit` and the workspace's writes all consult.
1290
- *
1291
- * The policy is not here. `deriveAdvisories` decides which facts matter and
1292
- * how they are worded; this method gathers what only the object can see and
1293
- * hands it over. That split is why a host cannot get the severity of its own
1294
- * workspace wrong.
1295
- *
1296
- * **Nothing that starts a long job belongs on this path**, and the rule is
1297
- * sharper here than anywhere else in the object because every tool call reads
1298
- * it. `startInstall` in particular must never be reached from here: it hands
1299
- * its drain to `ctx.waitUntil`, whose lifetime is the invocation's, and an
1300
- * invocation on this path is a tool call that returns in milliseconds. The
1301
- * drain outlives its owner, dies mid-`npm ci` with "WritableStream RPC stub
1302
- * was disposed without calling close()", and leaves a half-written tree.
1303
- *
1304
- * A missing dependency tree arms an install here, which runs in the alarm,
1305
- * which owns no request and outlives every RPC. Here because a command reads
1306
- * this before its `#ready()` starts a container, so the gate holds it for the
1307
- * install rather than letting it outrun one.
1308
- */
1309
- async advisories() {
1310
- if (!this.ctx.container?.running)
1311
- await this.#containerGone();
1312
- await this.#install.armIfTreeMissing();
1313
- const install = await this.#install.state();
1314
- const storage = this.#storageHeadroom();
1315
- // Only when the record says failed: it is the one state whose reading a
1316
- // queued repair changes, and every other one would pay a storage read for an
1317
- // answer nothing looks at.
1318
- const reinstallArmedAt = install.state === "failed"
1319
- ? await this.#install.reinstallArmedAt()
1320
- : undefined;
1321
- return deriveAdvisories({
1322
- install,
1323
- ...(storage ? { storage } : {}),
1324
- ...(reinstallArmedAt === undefined ? {} : { reinstallArmedAt }),
1325
- // The probe qualifies `deps-broken` and nothing else, and a queued
1326
- // reinstall turns a failed record into `deps-building`, which does not
1327
- // read it. `failed` alone is no longer the condition under which the
1328
- // question is worth asking, so the caller narrows it — see
1329
- // `InstallJob.treePresentIfItMatters`, whose contract this keeps true.
1330
- dependencyTreePresent: reinstallArmedAt === undefined
1331
- ? await this.#install.treePresentIfItMatters(install)
1332
- : false
1333
- });
1334
- }
1335
- /**
1336
- * `computerd`'s outbound WebSocket upgrade, on its way back in.
1337
- *
1338
- * The container dials the loopback rather than the other way round, so this
1339
- * object is the server for its own container's capnweb session. Everything
1340
- * else on this object is RPC; this is the only HTTP it speaks.
1341
- */
1342
- fetch(request) {
1343
- return this.backend.handleFetch(request);
1344
- }
1345
- /**
1346
- * Every durable wake-up this object has, dispatched from the one alarm.
1347
- *
1348
- * **This must not throw.** The runtime retries a failing alarm handler a
1349
- * bounded number of times and then stops for good — so a throw here would
1350
- * eventually take every *future* wake-up down with it, permanently, and the
1351
- * only symptom is that nothing ever happens again.
1352
- *
1353
- * Nothing else belongs here. Each reason to wake is a registered callback the
1354
- * scheduler dispatches by name, so this method neither matches on keys nor
1355
- * catches per-callback failures — the scheduler retries a failing callback and
1356
- * reports one that fails for good through `onError`. Nor does it sweep for a
1357
- * schedule that neither rescheduled nor cleared itself: a one-shot row is
1358
- * dropped when it runs, so it cannot stay due forever.
1359
- */
1360
- async alarm() {
1361
- // The reset a reclaim asked for, in the invocation it asked for it in.
1362
- // Nothing else may run on this instance: its storage is gone, and the job
1363
- // queue's table with it.
1364
- if (this.#reclaimed) {
1365
- this.ctx.abort("workspace reclaimed", { retryAlarm: false });
1366
- return;
1367
- }
1368
- try {
1369
- await this.#wake.alarm();
1370
- }
1371
- catch (err) {
1372
- // A reclaim that ran *from* this alarm emptied storage, the job queue with
1373
- // it, so settling the job that ran it fails. That is the reclaim working,
1374
- // and the reset it armed is the next thing to happen here.
1375
- if (this.#reclaimed)
1376
- return;
1377
- console.error(`[${this.#tag}] the alarm failed`, {
1378
- id: this.ctx.id.toString(),
1379
- err: String(err)
1380
- });
1381
- // One missed wake rather than every future one: re-arm from whatever
1382
- // survived, since the throw above is what the runtime counts against the
1383
- // bounded retry that ends in the alarm being abandoned.
1384
- await this.#wake.rearm().catch(() => { });
1385
- }
1386
- }
1387
- /**
1388
- * An outstanding pull came due.
1389
- *
1390
- * The three answers want three different next moves, which is why the drain
1391
- * distinguishes them. Progress comes straight back for the next block. A
1392
- * failure backs off, carrying its own count in the schedule's payload — the
1393
- * object does not survive between wake-ups, so there is nowhere else to keep
1394
- * it. Nothing reachable ends the attempt: a container that is gone took the
1395
- * unpulled writes with it.
1396
- */
1397
- async #onSyncDrain(payload) {
1398
- const outcome = await this.#sync.drain(SYNC_DRAIN_BUDGET_MS);
1399
- if (outcome === "incomplete") {
1400
- await this.#sync.arm();
1401
- return;
1402
- }
1403
- if (outcome === "failed") {
1404
- await this.#sync.arm((payload?.attempt ?? 0) + 1);
1405
- }
1406
- }
1407
- /** An install finished in this container — see `./deps-snapshot.ts`. */
1408
- async #onDepsSnapshot() {
1409
- // The record is read from this object's storage — each root's inputs, and
1410
- // which roots there are — so the install's own pull has to have landed: it
1411
- // carries the submodules a bootstrap cloned. One still moving comes back;
1412
- // one that failed is left to the stop, which drains before it takes.
1413
- const synced = await this.#sync.drain(SYNC_DRAIN_BUDGET_MS);
1414
- if (synced === "incomplete") {
1415
- await this.#snapshotDue.set(new Date(Date.now() + SYNC_DRAIN_RESUME_MS));
1416
- return;
1417
- }
1418
- if (synced === "failed")
1419
- return;
1420
- await this.#snapshot.takeDue();
1421
- }
1422
- /** The idle-reclaim deadline came due. */
1423
- async #onIdleReclaim() {
1424
- const { reclaimed, idleMs } = await this.reclaimIfIdle();
1425
- // Not idle after all — something used it since this was armed, and that
1426
- // `#touch` already moved the deadline to a row of its own. So there is
1427
- // nothing to re-arm here, and re-arming would create a second one.
1428
- if (reclaimed)
1429
- return;
1430
- console.info(`[${this.#tag}] idle reclaim deferred`, {
1431
- id: this.ctx.id.toString(),
1432
- idleMinutes: Math.round(idleMs / 60_000)
1433
- });
1434
- }
1435
- /**
1436
- * The container-warm wake came due — see {@link #warmIfCold}.
1437
- *
1438
- * **Swallows its own failure.** Every other wake here is work somebody waits
1439
- * for; this one is early, so a container that will not start must cost the
1440
- * next `bash` a start rather than put the scheduler's retry ladder behind a
1441
- * deployment fault that will refuse for as long as it stands.
1442
- */
1443
- async #onContainerWarm() {
1444
- // Started between the arming and now, so there is nothing to be early for.
1445
- // Trust it may still be missing is `#ready()`'s, on the next call that wants
1446
- // the container.
1447
- if (this.ctx.container?.running)
1448
- return;
1449
- try {
1450
- await this.#ready();
1451
- await this.#install.armIfTreeMissing();
1452
- }
1453
- catch (err) {
1454
- console.warn(`[${this.#tag}] could not warm the container`, {
1455
- id: this.ctx.id.toString(),
1456
- err: String(err)
1457
- });
1458
- }
1459
- }
1460
- /** The container-idle deadline came due. */
1461
- async #onContainerIdle(payload) {
1462
- // An install still running is "in use" even though nothing has called in —
1463
- // stopping the container under it would throw away the work and leave the
1464
- // gate closed until something noticed.
1465
- const state = await this.#install.read();
1466
- if (state.state === "running") {
1467
- await this.#containerIdle.set(new Date(Date.now() + this.#containerIdleMs));
1468
- return;
1469
- }
1470
- // Work nothing calls in for, which the deadline cannot measure: come back
1471
- // when the first hold lapses, without counting the hold as use.
1472
- const lapses = Object.values(await this.#holdMap());
1473
- if (lapses.length > 0) {
1474
- await this.#containerIdle.set(new Date(Math.min(...lapses)));
1475
- return;
1476
- }
1477
- // Re-read the clock rather than trusting the alarm, the same way
1478
- // `reclaimIfIdle` does. `#touch` moves the deadline forward, but an alarm
1479
- // already in flight cannot be recalled — so without this a workspace that
1480
- // was used a second ago can still have its container stopped by a wake-up
1481
- // that was scheduled before that use.
1482
- const lastUsedAt = (await this.ctx.storage.get("lastUsedAt")) ?? 0;
1483
- const idleMs = Date.now() - lastUsedAt;
1484
- if (idleMs < this.#containerIdleMs) {
1485
- await this.#containerIdle.set(new Date(lastUsedAt + this.#containerIdleMs));
1486
- return;
1487
- }
1488
- /**
1489
- * The last chance to move anything the container still holds.
1490
- *
1491
- * Destroying it is what turns an outstanding pull from late into lost, and
1492
- * this is the only moment that is knowable in advance — every other way a
1493
- * container goes away is something happening *to* it. So the drain runs
1494
- * here, and the deadline defers while it is still making progress.
1495
- *
1496
- * The deferral has a wall, because a pull that cannot finish must not keep
1497
- * a container billing forever. Past it the container goes and the unpulled
1498
- * writes go with it, which is the honest outcome: they were unreachable
1499
- * either way, and the alternative is an idle container nobody stops.
1500
- */
1501
- if (await this.#drainBeforeStop(lastUsedAt, payload?.attempt ?? 0))
1502
- return;
1503
- await this.#stopContainer("idle");
1504
- }
1505
- /**
1506
- * Whether the idle deadline deferred to let an outstanding pull finish.
1507
- *
1508
- * `attempt` is how many times the drain has failed in a row, carried on the
1509
- * schedule because nothing in memory survives a wake-up.
1510
- */
1511
- async #drainBeforeStop(lastUsedAt, attempt) {
1512
- const outcome = await this.#sync.drain(SYNC_DRAIN_BUDGET_MS);
1513
- if (outcome === "complete" || outcome === "unavailable")
1514
- return false;
1515
- if (Date.now() - lastUsedAt > this.#containerIdleMs + SYNC_DRAIN_GRACE_MS) {
1516
- console.error(`[${this.#tag}] stopping a container with an unfinished pull`, {
1517
- id: this.ctx.id.toString(),
1518
- outcome,
1519
- graceMinutes: Math.round(SYNC_DRAIN_GRACE_MS / 60_000)
1520
- });
1521
- return false;
1522
- }
1523
- /**
1524
- * How soon to come back, and the two outcomes want different answers for the
1525
- * same reason they do on the drain's own schedule — except that here the
1526
- * deferral holds a **container** open, so getting it wrong bills as well as
1527
- * spins. `incomplete` is progress and clears the count; `failed` is a pull
1528
- * that threw, and returning in a second would put an alarm and an error line
1529
- * against a broken transport every second until the grace wall, half an hour
1530
- * later.
1531
- */
1532
- const failed = outcome === "failed";
1533
- const next = failed ? attempt + 1 : 0;
1534
- console.info(`[${this.#tag}] holding a container for an unfinished pull`, {
1535
- id: this.ctx.id.toString(),
1536
- outcome
1537
- });
1538
- await this.#containerIdle.set(new Date(Date.now() + (failed ? syncRetryDelayMs(next) : SYNC_DRAIN_RESUME_MS)), { attempt: next });
1539
- return true;
1540
- }
1541
- }