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