@usine/executor 0.3.0 → 0.4.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 (185) hide show
  1. package/LICENSE +661 -21
  2. package/README.md +116 -19
  3. package/dist/child-command.d.ts +1 -1
  4. package/dist/child-command.d.ts.map +1 -1
  5. package/dist/child-command.js +6 -4
  6. package/dist/child-command.js.map +1 -1
  7. package/dist/child-environment.d.ts +19 -0
  8. package/dist/child-environment.d.ts.map +1 -0
  9. package/dist/child-environment.js +18 -0
  10. package/dist/child-environment.js.map +1 -0
  11. package/dist/claude-login-lock.d.ts +3 -2
  12. package/dist/claude-login-lock.d.ts.map +1 -1
  13. package/dist/claude-login-lock.js +10 -4
  14. package/dist/claude-login-lock.js.map +1 -1
  15. package/dist/cloudflare/client.d.ts +50 -0
  16. package/dist/cloudflare/client.d.ts.map +1 -0
  17. package/dist/cloudflare/client.js +108 -0
  18. package/dist/cloudflare/client.js.map +1 -0
  19. package/dist/cloudflare/cloudflare.d.ts +17 -0
  20. package/dist/cloudflare/cloudflare.d.ts.map +1 -0
  21. package/dist/cloudflare/cloudflare.js +704 -0
  22. package/dist/cloudflare/cloudflare.js.map +1 -0
  23. package/dist/cloudflare/model.d.ts +42 -0
  24. package/dist/cloudflare/model.d.ts.map +1 -0
  25. package/dist/cloudflare/model.js +2 -0
  26. package/dist/cloudflare/model.js.map +1 -0
  27. package/dist/cloudflare/shell/environment.d.ts +12 -0
  28. package/dist/cloudflare/shell/environment.d.ts.map +1 -0
  29. package/dist/cloudflare/shell/environment.js +12 -0
  30. package/dist/cloudflare/shell/environment.js.map +1 -0
  31. package/dist/cloudflare/shell/shell.d.ts +2 -0
  32. package/dist/cloudflare/shell/shell.d.ts.map +1 -0
  33. package/dist/cloudflare/shell/shell.js +155 -0
  34. package/dist/cloudflare/shell/shell.js.map +1 -0
  35. package/dist/durable-session.d.ts +21 -3
  36. package/dist/durable-session.d.ts.map +1 -1
  37. package/dist/durable-session.js +69 -10
  38. package/dist/durable-session.js.map +1 -1
  39. package/dist/exe-dev/commands.d.ts +1 -1
  40. package/dist/exe-dev/commands.d.ts.map +1 -1
  41. package/dist/exe-dev/exe-dev.d.ts +1 -1
  42. package/dist/exe-dev/exe-dev.d.ts.map +1 -1
  43. package/dist/exe-dev/exe-dev.js +9 -1
  44. package/dist/exe-dev/exe-dev.js.map +1 -1
  45. package/dist/exe-dev/preflight.d.ts +2 -2
  46. package/dist/exe-dev/preflight.d.ts.map +1 -1
  47. package/dist/exe-dev/preflight.js +5 -4
  48. package/dist/exe-dev/preflight.js.map +1 -1
  49. package/dist/executor.d.ts +164 -15
  50. package/dist/executor.d.ts.map +1 -1
  51. package/dist/executor.js +100 -3
  52. package/dist/executor.js.map +1 -1
  53. package/dist/guard.d.ts.map +1 -1
  54. package/dist/guard.js +1 -0
  55. package/dist/guard.js.map +1 -1
  56. package/dist/harness-preflight.d.ts +9 -8
  57. package/dist/harness-preflight.d.ts.map +1 -1
  58. package/dist/harness-preflight.js +15 -10
  59. package/dist/harness-preflight.js.map +1 -1
  60. package/dist/local/local.d.ts +1 -1
  61. package/dist/local/local.d.ts.map +1 -1
  62. package/dist/local/local.js +76 -37
  63. package/dist/local/local.js.map +1 -1
  64. package/dist/memory-preflight.d.ts +3 -1
  65. package/dist/memory-preflight.d.ts.map +1 -1
  66. package/dist/memory-preflight.js +1 -1
  67. package/dist/memory-preflight.js.map +1 -1
  68. package/dist/pre-push-hook.d.ts +1 -1
  69. package/dist/pre-push-hook.js +1 -1
  70. package/dist/prepared-attempt.d.ts +8 -0
  71. package/dist/prepared-attempt.d.ts.map +1 -1
  72. package/dist/prepared-attempt.js.map +1 -1
  73. package/dist/provision.d.ts +64 -13
  74. package/dist/provision.d.ts.map +1 -1
  75. package/dist/provision.js +99 -42
  76. package/dist/provision.js.map +1 -1
  77. package/dist/remote/commands.d.ts +1 -1
  78. package/dist/remote/commands.d.ts.map +1 -1
  79. package/dist/remote/commands.js +1 -1
  80. package/dist/remote/commands.js.map +1 -1
  81. package/dist/remote/container.d.ts +90 -0
  82. package/dist/remote/container.d.ts.map +1 -0
  83. package/dist/remote/container.js +480 -0
  84. package/dist/remote/container.js.map +1 -0
  85. package/dist/remote/liveness.d.ts +1 -1
  86. package/dist/remote/liveness.d.ts.map +1 -1
  87. package/dist/remote/liveness.js +6 -4
  88. package/dist/remote/liveness.js.map +1 -1
  89. package/dist/remote/machine.d.ts +52 -6
  90. package/dist/remote/machine.d.ts.map +1 -1
  91. package/dist/remote/remote.d.ts +11 -7
  92. package/dist/remote/remote.d.ts.map +1 -1
  93. package/dist/remote/remote.js +340 -92
  94. package/dist/remote/remote.js.map +1 -1
  95. package/dist/remote/sweep.d.ts +3 -1
  96. package/dist/remote/sweep.d.ts.map +1 -1
  97. package/dist/remote/sweep.js +2 -1
  98. package/dist/remote/sweep.js.map +1 -1
  99. package/dist/salvage.d.ts +18 -10
  100. package/dist/salvage.d.ts.map +1 -1
  101. package/dist/salvage.js +123 -29
  102. package/dist/salvage.js.map +1 -1
  103. package/dist/ssh/api-keys.d.ts +36 -0
  104. package/dist/ssh/api-keys.d.ts.map +1 -0
  105. package/dist/ssh/api-keys.js +72 -0
  106. package/dist/ssh/api-keys.js.map +1 -0
  107. package/dist/ssh/codex-homes.d.ts +21 -0
  108. package/dist/ssh/codex-homes.d.ts.map +1 -0
  109. package/dist/ssh/codex-homes.js +78 -0
  110. package/dist/ssh/codex-homes.js.map +1 -0
  111. package/dist/ssh/container.d.ts +32 -0
  112. package/dist/ssh/container.d.ts.map +1 -0
  113. package/dist/ssh/container.js +282 -0
  114. package/dist/ssh/container.js.map +1 -0
  115. package/dist/ssh/credentials.d.ts +3 -0
  116. package/dist/ssh/credentials.d.ts.map +1 -1
  117. package/dist/ssh/credentials.js +3 -0
  118. package/dist/ssh/credentials.js.map +1 -1
  119. package/dist/ssh/gateway/admin.d.ts +1 -1
  120. package/dist/ssh/gateway/admin.d.ts.map +1 -1
  121. package/dist/ssh/gateway/admin.js +2 -2
  122. package/dist/ssh/gateway/admin.js.map +1 -1
  123. package/dist/ssh/gateway/attachments.d.ts +15 -6
  124. package/dist/ssh/gateway/attachments.d.ts.map +1 -1
  125. package/dist/ssh/gateway/attachments.js +39 -25
  126. package/dist/ssh/gateway/attachments.js.map +1 -1
  127. package/dist/ssh/gateway/environment.d.ts +21 -2
  128. package/dist/ssh/gateway/environment.d.ts.map +1 -1
  129. package/dist/ssh/gateway/environment.js +76 -6
  130. package/dist/ssh/gateway/environment.js.map +1 -1
  131. package/dist/ssh/gateway/forward.d.ts +1 -1
  132. package/dist/ssh/gateway/forward.d.ts.map +1 -1
  133. package/dist/ssh/gateway/forward.js +1 -1
  134. package/dist/ssh/gateway/forward.js.map +1 -1
  135. package/dist/ssh/gateway/preflight.d.ts +51 -12
  136. package/dist/ssh/gateway/preflight.d.ts.map +1 -1
  137. package/dist/ssh/gateway/preflight.js +155 -69
  138. package/dist/ssh/gateway/preflight.js.map +1 -1
  139. package/dist/ssh/limits.d.ts +60 -0
  140. package/dist/ssh/limits.d.ts.map +1 -0
  141. package/dist/ssh/limits.js +84 -0
  142. package/dist/ssh/limits.js.map +1 -0
  143. package/dist/ssh/model.d.ts +97 -9
  144. package/dist/ssh/model.d.ts.map +1 -1
  145. package/dist/ssh/preflight.d.ts +26 -7
  146. package/dist/ssh/preflight.d.ts.map +1 -1
  147. package/dist/ssh/preflight.js +50 -10
  148. package/dist/ssh/preflight.js.map +1 -1
  149. package/dist/ssh/provisioner/boat.d.ts +33 -0
  150. package/dist/ssh/provisioner/boat.d.ts.map +1 -0
  151. package/dist/ssh/provisioner/boat.js +247 -0
  152. package/dist/ssh/provisioner/boat.js.map +1 -0
  153. package/dist/ssh/provisioner/hetzner.d.ts +32 -0
  154. package/dist/ssh/provisioner/hetzner.d.ts.map +1 -0
  155. package/dist/ssh/provisioner/hetzner.js +337 -0
  156. package/dist/ssh/provisioner/hetzner.js.map +1 -0
  157. package/dist/ssh/provisioner/host-key.d.ts +11 -0
  158. package/dist/ssh/provisioner/host-key.d.ts.map +1 -0
  159. package/dist/ssh/provisioner/host-key.js +57 -0
  160. package/dist/ssh/provisioner/host-key.js.map +1 -0
  161. package/dist/ssh/provisioner/http.d.ts +45 -0
  162. package/dist/ssh/provisioner/http.d.ts.map +1 -0
  163. package/dist/ssh/provisioner/http.js +72 -0
  164. package/dist/ssh/provisioner/http.js.map +1 -0
  165. package/dist/ssh/provisioner/machines.d.ts +48 -0
  166. package/dist/ssh/provisioner/machines.d.ts.map +1 -0
  167. package/dist/ssh/provisioner/machines.js +686 -0
  168. package/dist/ssh/provisioner/machines.js.map +1 -0
  169. package/dist/ssh/provisioner/model.d.ts +92 -0
  170. package/dist/ssh/provisioner/model.d.ts.map +1 -0
  171. package/dist/ssh/provisioner/model.js +37 -0
  172. package/dist/ssh/provisioner/model.js.map +1 -0
  173. package/dist/ssh/ssh.d.ts +14 -11
  174. package/dist/ssh/ssh.d.ts.map +1 -1
  175. package/dist/ssh/ssh.js +94 -27
  176. package/dist/ssh/ssh.js.map +1 -1
  177. package/dist/ssh/worker-records.d.ts +18 -0
  178. package/dist/ssh/worker-records.d.ts.map +1 -0
  179. package/dist/ssh/worker-records.js +100 -0
  180. package/dist/ssh/worker-records.js.map +1 -0
  181. package/dist/ssh/workers.d.ts +34 -7
  182. package/dist/ssh/workers.d.ts.map +1 -1
  183. package/dist/ssh/workers.js +92 -18
  184. package/dist/ssh/workers.js.map +1 -1
  185. package/package.json +19 -11
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @usine/executor
2
2
 
3
- Worker execution for [usine](https://github.com/basaltbytes/usine). The platform-free root defines worker preflight, scoped attempt preparation, and cancellation, plus the credential provider, worker capability errors, and adapter-neutral request types. Each prepared attempt runs its Pi parent in this process as a durable task, tells it where the worker runs children (local processes, or SSH on an exe.dev VM or any Linux machine), and releases its resources at completion or when its scope closes. [Attempt execution](https://docs.usine.dev/architecture/execution/) and [Workers and the gateway](https://docs.usine.dev/architecture/workers-and-gateway/) cover the executors.
3
+ Worker execution for [usine](https://github.com/basaltbytes/usine). The platform-free root defines worker preflight, scoped attempt preparation, and cancellation, plus the credential provider, worker capability errors, and adapter-neutral request types. Each prepared attempt runs its Pi parent in this process as a durable task, tells it where the worker runs children (local processes, SSH on an exe.dev VM or any Linux machine, or a Cloudflare sandbox), and releases its resources at completion or when its scope closes. [Attempt execution](https://docs.usine.dev/architecture/execution/) and [Workers and the gateway](https://docs.usine.dev/architecture/workers-and-gateway/) cover the executors.
4
4
 
5
5
  0.x APIs can change. Supply `effect` at the workspace's pinned version.
6
6
 
@@ -21,33 +21,41 @@ Preparation stays interruptible during clone, fetch, credential requests, and lo
21
21
 
22
22
  `CheckoutRequest.maxAttemptMinutes` bounds live execution, excluding preparation and cleanup. Both runaway ceilings are optional: an absent one leaves that half of the guard off, and an armed one steers the parent once at 80%. The clock starts once launch registers the session. The guard delivers its warning apart from its checks, once an `execute` relays the parent's channel (`AttemptRuntime.attached`): no trip waits for delivery, and the append that reaches the token warning returns before the steer is delivered, since the durable runtime records the steer in order with its envelopes.
23
23
 
24
+ `CheckoutRequest.commitAuthor` is the GitHub App's bot account. Every worker kind sets it as `GIT_AUTHOR_NAME`, `GIT_AUTHOR_EMAIL`, `GIT_COMMITTER_NAME`, and `GIT_COMMITTER_EMAIL` in the parent's and children's environment, and in the environment of the executor's own git commands on the checkout, which git prefers to any `user.name` and `user.email` config on the machine. On a remote worker they're exported in each command line, since they aren't secret.
25
+
24
26
  `CheckoutRequest.permissions` is the loop's permission level. A `read` attempt's credential has `contents: read`, so it can't push. An exe.dev worker can't grant that, and refuses the attempt with a `credentials` `WorkerCapabilityError` before attaching the integration. A `read` checkout also gets a pre-push hook that stops `git push` with a message naming the loop, instead of GitHub's 403; `git push --no-verify` skips it, and the token still refuses. A `read` attempt is never salvaged.
25
27
 
26
28
  ### Salvage
27
29
 
28
- An attempt that ends without success is salvaged before its worktree is removed: uncommitted changes are committed as `wip(usine): salvage attempt <id>`, and HEAD is pushed to `CheckoutRequest.salvageRef` with the worker's credentials. The result reaches the engine through `Sink.salvage`. A failed push keeps the worktree and reports its path and the failure: git's exit code and stderr, so a deleted remote reads differently from a revoked app, or a push stalled past two minutes. Local and SSH adapters scrub the token from that stderr; an exe.dev VM never has one. The startup sweep leaves such a worktree alone. Each later startup retries the push with fresh credentials, for open and closed runs, and rewrites the record from `Retained` to `Pushed` on success; until then the record shows the latest failure in the run drawer.
30
+ An attempt that ends without success is salvaged before its worktree is removed: uncommitted changes are committed as `wip(usine): salvage attempt <id>` by `CheckoutRequest.commitAuthor`, and HEAD is pushed to `CheckoutRequest.salvageRef` with the worker's credentials. The result reaches the engine through `Sink.salvage`.
31
+
32
+ The salvage commit leaves out what Git ignores and, at any depth, the `.pnpm-store`, `node_modules`, and `.turbo` directories, because they hold downloads and caches, not work. It leaves those three directories out even if the agent staged them; a file that the agent force-added despite `.gitignore` stays in the commit. pnpm creates `.pnpm-store` in the checkout when its default store is on another filesystem, as in an SSH worker's container. A checkout whose only changes are in those directories has nothing to salvage. The commit also leaves out each file larger than GitHub's 100 MiB limit, instead of a push that GitHub would refuse. The checkout has the only copy of such a file, so the salvage pushes the rest and records `PartlyPushed`, with the salvage ref, the pushed commit, the worktree's path, and a message that names the files and their sizes. The worktree stays on the worker, and the run's next attempt resumes from the pushed commit. A failed push keeps the worktree and reports its path and the failure: git's exit code and stderr, so a deleted remote reads differently from a revoked app, or a push stalled past two minutes. Local and SSH adapters scrub the token from that stderr; an exe.dev VM never has one. The startup sweep leaves such a worktree alone. Each later startup retries the push with fresh credentials, for open and closed runs, and rewrites the record from `Retained` to `Pushed` on success; until then the record shows the latest failure in the run drawer.
33
+
34
+ A push that GitHub refuses for a file over its size limit (`GH001`) fails the same way at every retry, so the salvage records it as `Rejected`, with the commit and GitHub's message, and keeps the worktree. Recovery gets that record in `AttemptRecovery.rejected` and doesn't push the commit again: it records the same `Rejected` salvage and keeps the worktree. A recovery that can't inspect the checkout, or can't commit its changes while its head is the refused commit, also records the same `Rejected` salvage, so that a later startup doesn't push the commit either. If the checkout changes, for example after the operator removes the large file from its history, the next startup salvages and pushes the new commit.
29
35
 
30
- A request whose `resume` names a pushed salvage fetches the revision and applies the salvage on top. A salvage forked from the fetched commit is checked out as is; one from an older base is rebased onto it. A salvage with no changes beyond the revision is dropped. One that doesn't rebase cleanly is left out: the worktree starts from the revision, `RunSpec.conflictingSalvage` names it, and its branch keeps the work. A missing ref falls back to the revision.
36
+ A request whose `resume` names a pushed salvage fetches the revision and applies the salvage on top. A salvage forked from the fetched commit is checked out as is; one from an older base is rebased onto it, with `commitAuthor` as the committer. A salvage with no changes beyond the revision is dropped. One that doesn't rebase cleanly is left out: the worktree starts from the revision, `RunSpec.conflictingSalvage` names it, and its branch keeps the work. A missing ref falls back to the revision.
31
37
 
32
- Startup reconciliation lists each worker's `attempts/` once per executor instance, and adapters skip historical attempts missing from the listing without checking paths. A failed listing stays cached for that startup and is retried after a restart. Existing checkouts keep the record-before-remove salvage order; succeeded attempts only need removal.
38
+ Startup reconciliation lists each worker's `attempts/` once per executor instance, and adapters skip historical attempts missing from the listing without checking paths. A failed listing stays cached for that startup and is retried after a restart. Existing checkouts keep the record-before-remove salvage order; succeeded attempts only need removal. `recover` has no `CheckoutRequest`, so its caller passes the salvage commit's author as an effect, which runs only before a salvage: the orchestrator reads the App's bot account there.
39
+
40
+ `Executor.sweep` removes the files on the workers that no attempt holds or can use. The orchestrator runs it after startup recovery and at each reconcile. It never fails and logs what it couldn't remove. The SSH executor removes Codex homes there (see [Behind a gateway](#behind-a-gateway)); the local, exe.dev, and Cloudflare executors remove nothing.
33
41
 
34
42
  ### Cancel
35
43
 
36
- `cancel(attemptId)` applies from the start of preparation: harness probes, credential acquisition, repo lock waits, and checkout creation. It stops preparation before releasing acquired resources and blocks a later launch. `cancel` and the canceled `prepare` or `launch` return only after release. For a live attempt, it cancels the parent task and waits the configured grace before forcing termination. Concurrent and repeated cancels wait for cleanup and keep the launch's typed failure; after release, cancel does nothing.
44
+ `cancel(attemptId, reason)` applies from the start of preparation: harness probes, credential acquisition, repo lock waits, and checkout creation. It stops preparation before releasing acquired resources and blocks a later launch. `cancel` and the canceled `prepare` or `launch` return only after release. For a live attempt, it cancels the parent task and waits the configured grace before forcing termination. Concurrent and repeated cancels wait for cleanup and keep the launch's typed failure; after release, cancel does nothing.
37
45
 
38
46
  This replaces the 0.x `Executor.launch(prepared, io)` and `Executor.cleanup(prepared)`; the engine's `StepExecutor` contract is unchanged.
39
47
 
40
48
  ## Worker preflight
41
49
 
42
- Call `executor.preflight(worker, brains, maxConcurrentAttempts)` with the roster's `BrainSpec` values and the worker's `max_concurrent_attempts` before scheduling.
50
+ Call `executor.preflight(worker, brains, maxConcurrentAttempts)` with the roster's `BrainSpec` values and the worker's `max_concurrent_attempts` before scheduling. It returns a `PreflightReport`, whose `degraded` gives the reason when the worker runs attempts without a capability that keeps them apart, such as an SSH worker without Docker, and whose `containerLimits` gives the CPU, memory, and process limits of each attempt container on an SSH worker with Docker; `fullPreflight` is the report without either.
43
51
 
44
52
  Every adapter compares the machine's memory with `maxConcurrentAttempts`. The local adapter reads `/proc/meminfo` on Linux and physical memory on macOS, where swap grows on demand; exe.dev and SSH read `/proc/meminfo` over SSH. Under 1.5 GiB per concurrent attempt, or no swap with more than one, is a `memory` `WorkerCapabilityError`; under 3 GiB logs a warning ([#270](https://github.com/basaltbytes/usine/issues/270)).
45
53
 
46
- Every adapter runs the `@usine/agents/preflight` checks where children run: local on this machine (override with `LocalExecutorOptions.harnessReadiness`), exe.dev and SSH over SSH (`SshExecutorOptions.harnessReadiness`). Codex needs CLI 0.144.0 or 0.153.4 and a ChatGPT login, Claude its CLI and subscription login, and Pi children API keys for their models. Pi children run tools in the orchestrator, so exe.dev and SSH workers report Pi brains unavailable.
54
+ Every adapter runs the `@usine/agents/preflight` checks where children run: local on this machine (override with `LocalExecutorOptions.harnessReadiness`), exe.dev and SSH over SSH (`SshExecutorOptions.harnessReadiness`). Codex needs CLI 0.153.4 or 0.159.0 through 0.159.3 and a ChatGPT login, Claude its CLI and subscription login, and Pi children API keys for their models. Pi children run tools in the orchestrator, so exe.dev and SSH workers report Pi brains unavailable.
47
55
 
48
56
  `prepare` repeats the assigned brain's probe before acquiring credentials or a checkout, to catch roster changes and logouts. A capability gap returns `WorkerCapabilityError`, which needs the operator. Transport failures, such as an SSH client that can't reach the VM, and probe timeouts are retryable infrastructure failures.
49
57
 
50
- A Claude check holds the worker's `ClaudeLoginLock` (`@usine/agents/runtime`) until it returns, so two preparing attempts, or a preparing attempt and a starting Claude child, never refresh the login at once. The local, exe.dev, and SSH layers need that service: build `claudeLoginLockLayer` from the root once and give it to the executor and the attempt runtime.
58
+ A Claude check holds the worker's `ClaudeLoginLock` (`@usine/agents/runtime`) until it returns, so two preparing attempts, or a preparing attempt and a starting Claude child, never refresh the login at once. On a provisioned worker, each machine has its own login and lock, so attempts on different machines don't wait for each other. The local, exe.dev, and SSH layers need that service: build `claudeLoginLockLayer` from the root once and give it to the executor and the attempt runtime.
51
59
 
52
60
  It replaces exe.dev's `HarnessLoginCheck` and `harnessLoginChecks` options.
53
61
 
@@ -58,7 +66,7 @@ For each attempt, `@usine/executor/local`:
58
66
  - checks Git and the assigned harness before accepting work;
59
67
  - updates a per-repo bare cache, then creates a detached worktree at the resolved revision;
60
68
  - renews repo credentials before expiry. Git reads an attempt token file and `gh` its private `GH_CONFIG_DIR` on each call, so a salvage push after hours uses the latest credential; startup reconciliation issues a fresh one;
61
- - runs the Pi parent here through `AttemptRuntime` (`@usine/durable`) with the attempt's environment: allowlisted variables, credential settings, and `TURBO_CACHE_DIR`, never the orchestrator's environment;
69
+ - runs the Pi parent here through `AttemptRuntime` (`@usine/durable`) with the attempt's environment: allowlisted variables, credential settings, `TURBO_CACHE_DIR`, and the request's `commitAuthor` as `GIT_AUTHOR_*` and `GIT_COMMITTER_*`, never the orchestrator's environment;
62
70
  - harvests commits and pull requests from the worktree, and the attempt's comments and reviews on the item, with `@usine/core/step/harvest`, running `git` and `gh` in that environment. A failed or canceled attempt (unless this process stopped) gets only its comments harvested, after its parent task stops, through `Sink.comments`. `Sink.windows` reads the attempt windows after GitHub lists the comments, and the harvest keeps comments by the window rule of `@usine/core` (#429). If the windows can't be read, a finished attempt's harvest fails and a failed attempt records no comment;
63
71
  - cancels the parent task before forced interruption, then removes the worktree and wipes the credential.
64
72
 
@@ -78,7 +86,7 @@ A reattachment reads only what its armed ceilings use: the transcript for either
78
86
 
79
87
  After the harvest, the result is recorded in durable storage before the worktree is released, and `outcome` returns it, or the parent's failure, to an engine that stopped before recording the attempt. If storage has the parent's failure, `recover` first records the open attempt's comments, harvested in its worktree with a fresh credential, through the `RecoverySink`'s `comments`, filtered by the windows that its `windows` reads after GitHub lists them. A missing worktree or credential, or unreadable windows, is logged and records none.
80
88
 
81
- Every other end, whether failure, cancel, or tripped ceiling, cancels the parent task. The engine calls `recover` only for attempts that no run reattaches, so `recover` cancels a live parent task first and reconciles the worktree even if `isLive` still reports the attempt, as for an unharvested report. If the stop can't be confirmed (liveness unreadable, or the cancel fails or outlasts the interrupt grace), `recover` and `cancel` fail with a `cancel` `ExecutorOperationError`, and `recover` leaves the worktree, which the task might still write.
89
+ Every other end, whether failure, cancel, or tripped ceiling, cancels the parent task. The engine calls `recover` only for attempts that no run reattaches, so `recover` cancels a live parent task first and reconciles the worktree even if `isLive` still reports the attempt, as for an unharvested report. If the stop can't be confirmed (liveness unreadable, or the cancel fails or outlasts the interrupt grace), `recover` and `cancel` fail with a `cancel` `ExecutorOperationError`, and `recover` leaves the worktree, which the task might still write. A launch whose stop isn't confirmed also leaves the worktree, without salvage; the next `cancel` that confirms the stop salvages and removes it as `recover` does, and logs a failure without failing (#523). `cancel` logs the engine's reason for the stop: `cancel`, `failed`, or `startup`.
82
90
 
83
91
  ## exe.dev worker
84
92
 
@@ -92,7 +100,7 @@ An exited SSH client doesn't prove its remote command stopped, so each remote ch
92
100
 
93
101
  After a restart, `prepare` with `reattach` checks that the stored request names this worktree and that it still exists on the VM, stops leftover children, and takes it over with a fresh grant. If the VM or worktree is gone, the reattachment fails and cancels the parent task, and the engine retries with a new attempt.
94
102
 
95
- Startup reconciles unsuccessful attempts before the engine recovers their runs: each checkout is snapshotted and pushed, and its salvage recorded, before removal. Failed pushes and unknown crash leftovers stay on disk. Cache sweeps prune registrations of missing directories and never delete checkouts. One-shot lobby and SSH commands time out after ten minutes, and the SSH client sends keepalives, so a dead link fails the operation.
103
+ Startup reconciles unsuccessful attempts before the engine recovers their runs: each checkout is snapshotted and pushed, and its salvage recorded, before removal. Failed pushes and unknown crash leftovers stay on disk. Cache sweeps prune registrations of missing directories and never delete checkouts. One-shot lobby and SSH commands time out after ten minutes with an `ExecutorOperationError` whose message is `<command> didn't finish within 10 minutes`, and the SSH client sends keepalives, so a dead link fails the operation. A salvage git command that fails this way keeps the worktree and records `Retained`, as any other failed salvage command does.
96
104
 
97
105
  Concurrent attempts on different repos keep their integrations attached together. Each grant renews at half its lifetime while its attempt runs, a failed renewal retries every minute, and the refcounted release detaches an integration after its last attempt. The grant length, `credentialGrantMinutes` (65 by default), is a renewal window, unrelated to `max_attempt_minutes`; an attempt without a ceiling keeps its grant for its whole life. A failed detach is logged and lapses with its time-box. If stopping the remote children can't be confirmed, cleanup keeps the checkout and its Git registration, logs the location, and still releases the grant. Partial preparation rolls back without stopping children, since none started.
98
106
 
@@ -120,13 +128,33 @@ Salvage inspection, staging, or snapshot failures keep the checkout, uncommitted
120
128
 
121
129
  ## SSH worker
122
130
 
123
- `@usine/executor/ssh` runs the same contract on any Linux machine that the orchestrator reaches over SSH ([ADR 0026](../../docs/adr/0026-eu-ssh-worker.md)); `infra/hetzner` creates one on Hetzner Cloud. Each worker has an SSH destination, an optional identity file and port, and a root under which it keeps `workers/<name>/repos`, `attempts`, and `credentials`. The machine needs Git, gh, the roster's `claude` and `codex` CLIs, and their logins for the SSH user.
131
+ `@usine/executor/ssh` runs the same contract on any Linux machine that the orchestrator reaches over SSH ([ADR 0026](../../docs/adr/0026-eu-ssh-worker.md)); `infra/hetzner` creates one on Hetzner Cloud. Each worker has an SSH destination, an optional identity file and port, and a root under which it keeps `workers/<name>/repos`, `attempts`, and `credentials`. The machine needs Git, Docker, and the roster's harness logins for the SSH user.
132
+
133
+ Preflight checks the SSH connection, a writable root, Git, memory for `maxConcurrentAttempts`, Docker, and the roster's harnesses where children run. With Docker, it pulls the attempt image, checks that the image's user has the SSH user's ID, finds the container limits, and probes the harnesses in a container from the image that removes itself. Without Docker, it probes them over SSH and reports the worker as degraded. A Docker that doesn't answer the SSH user, the `docker` group or a writable socket without the `docker` CLI, an image that can't be pulled and isn't on the machine, or another user ID is a `container` `WorkerCapabilityError`: an SSH user that can use Docker never runs an attempt.
134
+
135
+ ### Attempt containers
136
+
137
+ With Docker on the machine, each attempt runs in its own container from `SshWorkerAddress.container.image`, or `ATTEMPT_IMAGE`, the image of `infra/worker-image/Dockerfile` with this release's harness versions, with CPU, memory, and process limits. The container mounts the attempt's checkout and the SSH user's login files for the attempt's harness read-write, and its cached clone and credential directory read-only; behind a gateway, also gh's configuration directory and socket read-only. It uses the machine's network, and gets the attempt's variables, secrets included, on `docker run`'s standard input. Every command for the attempt runs in it through the shell's `container` (`docker exec -i <name> sh -c <command line>`): the children, the checkout's git, the harvest, the salvage, the pre-push hook, and the attempt's harness probe, which runs once the checkout exists. Only the container's start and removal and the cached clone's fetch run as the SSH user.
138
+
139
+ `SshWorkerAddress.container` sets each limit as an `Option`. Preflight derives each one that is `None` from the machine: it runs `nproc` and reads `/proc/sys/kernel/threads-max`, `/proc/sys/kernel/pid_max`, and `/proc/meminfo` over SSH, and divides the machine's total, less a reserve for the machine's own processes, by `maxConcurrentAttempts`:
140
+
141
+ | Limit | Machine total | Reserve | Rounded down to |
142
+ | --- | --- | --- | --- |
143
+ | `cpus` (`docker run --cpus`) | the CPUs that `nproc` counts | 0.5 CPU | a hundredth of a CPU, at least 0.01 |
144
+ | `memoryMib` (`--memory`) | `MemTotal` | 1024 MiB | a MiB, at least 256 |
145
+ | `pids` (`--pids-limit`) | the lower of `threads-max` and `pid_max` | 1024 tasks | a task, at least 64 |
124
146
 
125
- Preflight changes nothing. It checks the SSH connection, a writable root, Git, memory for `maxConcurrentAttempts`, and the roster's harnesses where children run.
147
+ A machine with 4 CPUs and 8 GiB gets 3.5 CPUs and 7168 MiB with one slot, and 1.16 CPUs and 2389 MiB with three. Preflight reads the machine only if a limit is unset, and fails with a `container` `WorkerCapabilityError` if it can't. It logs the limits, which ones it derived, and the machine's totals with `SSH Worker runs each Attempt in its own container` and `SSH Worker preflight verified`, and returns them as the report's `containerLimits`. The worker's containers get these limits until its next preflight; a container started before any preflight on the worker, such as the recovery of a provisioned worker's machine, gets limits derived for one attempt on the machine, which `SSH Worker runs each Attempt in its own container` logs the same way.
148
+
149
+ Preflight prepares `usine-pnpm-store`, a named Docker volume shared by every attempt container on the worker's machine, and logs its allocated size as `sizeKiB`. Containers mount it read-write at `/home/usine/.local/share/pnpm/store`. The image's default user must match the SSH user's UID. Volume preparation reads the image's `usine` UID and assigns it to an empty, root-owned volume. It refuses a volume owned by another UID and directs the operator to remove it with `docker volume rm usine-pnpm-store`. Preparation changes only the volume root and runs at first use on machines that skip full preflight. Later attempts on the same machine reuse that preparation. Failures at preflight are `container` capability errors. Attempt cleanup removes containers but preserves the volume.
150
+
151
+ The store is trusted shared state across jobs and instances using the same Docker daemon. Provisioned workers delete it with their machine after each attempt. To reclaim disk space on a long-lived worker, finish its attempts, remove containers that reference the volume, and run `docker volume rm usine-pnpm-store` on that worker. Run preflight again after removal to recreate it. There is no automatic pruning or quota; Turborepo's cache stays in each container. See the [worker guide](https://docs.usine.dev/guides/add-a-worker/#shared-pnpm-store).
152
+
153
+ The checkout is a repository of its own whose objects come from the cached clone through an alternate, so commits never write the read-only clone. The container is named `usine-<attempt id>` and labeled with `SshExecutorOptions.instance`, `SshExecutorOptions.instanceId`, the worker, and the attempt. It's removed after the checkout's release, which salvages in it, and before the credential. If the container stopped before the release, for example because the kernel killed it for memory or a command signaled its init, the release starts it again and salvages in it, and logs the container's status, exit code, and whether it was killed for memory. Before it removes the container, the release reads how many processes the kernel killed in it for memory: the `oom_kill` count of the container's cgroup in `/sys/fs/cgroup/memory.events`, plus one for each start after the kernel killed the container's init, since a start gives the container a new cgroup. A count above zero goes to `Sink.outOfMemory` with the container's ID and memory limit. The run store keeps the count of each container: a reattachment's new container adds its kills to those of the earlier one, and a second record of the same container replaces its count. A container that only a recovery started isn't read. A count that can't be read or recorded is logged as a warning, and the container is still removed. The salvage never runs git in the checkout as the SSH user. A process's first use of a worker removes the containers with both its instance labels that belong to that worker, or to a worker that started no container in this process, so a running attempt's container on a shared machine stays. Instances that share a GitHub App, and so `instance`, have different `instanceId`s, so they don't remove each other's containers. Before its first container starts, the executor writes `<root>/workers/<name>/container-checkouts/<attempt>`, outside every container's mounts, and removes it with the checkout. A reattachment or recovery reads that marker, never the checkout, which the attempt can change: with it, commands run in a new container; without it, as the SSH user, on a machine without Docker only. A mismatch, such as a checkout from before Docker on a machine that now has it, fails with an `ExecutorOperationError` and leaves the checkout.
126
154
 
127
155
  Prepare mints the attempt's single-repo token through the `CredentialProvider`, writes it with a gh `hosts.yml` to `<root>/workers/<name>/credentials/<attempt>`, and fetches the bare cached clone from `https://github.com/<repository>.git` with git's credential helper reading that file. The token goes over the writing command's stdin, so it's in no command line, process environment, or log, and git and gh output is scrubbed of it. As on the local worker, git and harvest commands renew a token that expires within five minutes; children, harvesting, and the salvage push export the same file paths, so they read the latest token. Cleanup stops the children, removes the checkout, then the credential directory. A process's first use of a worker, for an attempt or startup reconciliation, removes credentials an earlier process left; a reattached attempt gets a fresh one.
128
156
 
129
- Children, pid files, the kill script, sweeps, salvage, recovery, and reattachment share the exe.dev worker's code.
157
+ Children, pid files, the kill script, sweeps, salvage, recovery, and reattachment share the exe.dev worker's code; in a container, the pid files and the kill script are inside it.
130
158
 
131
159
  ### Behind a gateway
132
160
 
@@ -134,32 +162,101 @@ A worker whose address has a `gateway` is on a private network behind a gateway
134
162
 
135
163
  The cached clone fetches `http://git.<domain>/<repository>.git`. Children, harvesting, and the executor's git commands export settings that point git, gh, and package managers at the gateway: git sends the caller token only to `git.<domain>` and rewrites `https://github.com/` there, gh uses `git.<domain>` through the worker's socket with its config in `<root>/workers/<name>/gh`, and `HTTPS_PROXY` is the forward proxy.
136
164
 
137
- If the gateway has the Anthropic credential, Claude Code calls `llm.<domain>/anthropic`, and preflight checks only its binary. Otherwise Claude Code, and Codex always, use the operator's login on the machine through the forward proxy: preflight checks the login as without a gateway, then that each of the login's hosts answers HTTP through the proxy (`api.anthropic.com` and `platform.claude.com`, or `chatgpt.com` and `auth.openai.com`). A refused host is a `gateway` capability error naming the host to add to the egress allowlist. The caller token is in no command line: each SSH command sends `GIT_CONFIG_VALUE_0`, `GH_ENTERPRISE_TOKEN`, `GH_TOKEN`, and `ANTHROPIC_AUTH_TOKEN` from the client's environment (`SendEnv`), and the machine's sshd must accept them (`AcceptEnv`).
165
+ If the gateway has the Anthropic credential, Claude Code calls `llm.<domain>/anthropic`, and preflight checks only its binary. If it has the OpenAI credential, prepare writes Codex's `config.toml` to the attempt's Codex home, `<root>/workers/<name>/codex/<attempt>`, children get that directory as `CODEX_HOME`, and Codex sends the Responses API to `llm.<domain>/openai/v1` with the caller token from `USINE_GATEWAY_TOKEN`; preflight checks only its binary and version. The file has a `model_provider` and no token. Codex also writes its sessions there, so an attempt's container mounts the directory read-write, and cleanup removes it after the container. If the orchestrator crashes, the directory stays. `Executor.sweep` removes each Codex home on a worker with its own machine whose attempt has no checkout there and that no attempt in this process holds; an attempt holds its Codex home from before it's written until cleanup removes it. A checkout kept for a reattachment or with unpreserved work keeps its Codex home, and a later sweep removes it after the checkout is gone. The sweep visits only the workers the executor has. For a worker that `usine.yaml` no longer declares, `sshWorkerRecordsFor(directory).discard(worker, attempts)` removes the Codex homes of the attempts whose leftovers the operator discarded, over SSH with the worker's port and key from its record in `directory`, and never fails. Without a record, it removes nothing. Otherwise each harness uses the operator's login on the machine through the forward proxy: preflight checks the login as without a gateway, then that each of the login's hosts answers HTTP through the proxy (`api.anthropic.com` and `platform.claude.com`, or `chatgpt.com` and `auth.openai.com`). A refused host is a `gateway` capability error naming the host to add to the egress allowlist. The caller token is in no command line: each SSH command sends `GIT_CONFIG_VALUE_0`, `GH_ENTERPRISE_TOKEN`, `GH_TOKEN`, `ANTHROPIC_AUTH_TOKEN`, and `USINE_GATEWAY_TOKEN` from the client's environment (`SendEnv`), and the machine's sshd must accept them (`AcceptEnv`). With Docker, only the cached clone's fetch sends them; the attempt's container gets them when it starts.
138
166
 
139
167
  The admin API listens on the gateway's loopback. With the first worker behind a gateway, the executor opens its own forward, `ssh -N -L 127.0.0.1:<port>:127.0.0.1:<adminPort> <destination>`, with the same SSH command and environment as its other clients, on a local port the kernel picks; workers behind that gateway share it. When the client exits, the executor starts another after a pause of one second, doubling up to one minute, and logs each open, exit, and reopen with the gateway's destination. An admin request that finds the forward closed, or gets no answer, reopens it at once and is sent once more. A second failure fails the request, with a message giving since when the forward has been closed, or, if it opens but the admin API doesn't answer, since when the API last answered through it. The forward closes when the last worker behind the gateway is removed, or with the layer.
140
168
 
141
169
  Preflight also checks, without attaching, that the forward opens (giving the `ssh` command to run if it doesn't), that the admin API accepts the admin token, that sshd accepts the caller token variables, and that the machine reaches `git.<domain>`, which refuses requests without a caller token. A gap is a `gateway` `WorkerCapabilityError`.
142
170
 
143
- If the worker's `llmProviders` lists `anthropic`, preflight also reads the gateway's Anthropic credential kind (`GET /v1/providers`) and records it with the worker. A gateway with none is a `gateway` capability error. For a Claude Code brain, each child reads the recorded kind as `AttemptRequest.gatewayCredential` when it starts (none for Codex and Pi), so its session records the credential, and usage counts a Claude Code child on the gateway's API key in the API keys pool ([ADR 0013](../../docs/adr/0013-cost-accounting-native-meters-guardrails.md)).
171
+ If the worker's `llmProviders` lists a provider, preflight also reads the gateway's credential kinds (`GET /v1/providers`) and records them with the worker, and logs each provider's mode in `SSH Worker Gateway verified`: `gateway-api-key`, `gateway-subscription`, or `worker-login`. A gateway without a listed provider's credential is a `gateway` capability error, and so is one too old to report its credentials while `llmProviders` lists `openai`. Each child reads its provider's recorded kind as `AttemptRequest.gatewayCredential` when it starts: Anthropic's for Claude Code, OpenAI's for Codex, none for Pi. Its session records the credential, and usage counts a child on the gateway's API key in the API keys pool, and on a subscription in the Claude or ChatGPT plan pool ([ADR 0013](../../docs/adr/0013-cost-accounting-native-meters-guardrails.md)).
144
172
 
145
173
  A gateway released before that endpoint answers 404: the worker's first such preflight logs `SSH Worker's Gateway does not report the kind of its Anthropic credential` at warning level, later ones at debug, and its Claude Code children count in the Claude plan pool. Each preflight asks again, so the first one after an upgrade records the kind. A gateway reload that changes the credential reaches the record at the worker's next preflight: at orchestrator start, or when a configuration reload adds the worker, gives it more slots, or adds a brain. A running attempt's next Claude Code child records the new kind; earlier children keep theirs. Local and exe.dev workers have no gateway, so their children record none.
146
174
 
147
- The SSH layer needs a `ChildProcessSpawner`, a `CredentialProvider`, an `AttemptRuntime`, and an `HttpClient` for admin APIs. It provides `SshWorkers` beside the `Executor`, starting with the workers of `workers`. `addWorker(address)` adds a worker at runtime; behind a gateway, the worker shares that gateway's forward, or opens one if it's the first. `removeWorker(name)` removes a worker once no attempt runs on it, and closes its gateway's forward if no other worker uses it. Adding a worker the executor has with the same address changes nothing; with another address, it fails with a `machine` capability error until the worker is removed.
175
+ ### Provisioned machines
176
+
177
+ A worker whose address has `machine: { _tag: "Provisioned" }` has no machine of its own: it creates a Hetzner Cloud server or a Boat sandbox for each attempt and deletes it afterwards ([#550](https://github.com/basaltbytes/usine/issues/550)). The layer provides `WorkerMachines` for the scheduler:
178
+
179
+ - `create(worker, runId, brains)` creates the machine, tagged with `SshExecutorOptions.instance`, the worker, the run, and a random value of this process, and waits up to `machineReadyTimeout` (10 minutes by default) until it answers over SSH. It then waits for cloud-init, creates the root, and waits for Docker. The worker's first machine each day, and its first after the layer starts, also gets the machine preflight with `brains`, whose failure is a `WorkerCapabilityError`. Each machine runs one attempt, so that preflight derives the unset container limits for one slot, and the worker's machines get them until the next such preflight. The grant's `checked` is then a `MachineCheck` with the brains and the limits. A provider's refusal, no capacity or quota, no answer, or a timeout is a `MachineUnavailableError` with its `reason`. On failure or interruption, the machine is deleted.
180
+ - `assign(hold, attemptId)` tags the machine with a hash of the attempt ID; `prepare` uses it from then on.
181
+ - `adopt(worker, attemptId)` holds the machine of an attempt from an earlier process, found by that tag in one listing per worker. If the provider can't be read, the hold still keeps `sweep` from deleting the machine, and the next use looks it up again.
182
+ - `release(hold)` deletes the machine, unless it has the attempt's checkout: one kept for a reattachment, until its parent task's stop is confirmed, or with unpreserved work. That machine stays for the next process. A failed deletion is retried by `sweep`.
183
+ - `sweep` lists each provisioned worker's machines and deletes those that no hold or creation of this process has. The engine runs it after startup recovery and at each reconcile.
184
+
185
+ `recover` finds an earlier attempt's machine by its tag, reconciles the checkout there, and deletes the machine unless the checkout stays.
186
+
187
+ A Hetzner server is created from `image` with the project's SSH key, in `network` and with no public address behind a gateway, which the executor jumps through with `ssh -J <gateway.destination>`; without a gateway it has a public IPv4 address and `firewall`. Its `user_data` sets an Ed25519 host key that the executor generates for it. A Boat sandbox is created with `noEnv`, without snapshots or monitoring, and without automatic stop, and gets `<identity_file>.pub` through `POST /sandboxes/{id}/sshkey`, which returns its host key; a sandbox without one fails its creation. The executor writes each machine's host key to a file under `knownHostsDirectory` before the first connection, and connects with `StrictHostKeyChecking=yes`. A machine of an earlier process without a file there gets no connection. Boat has no labels, so the sandbox name carries the tags.
188
+
189
+ Preflight of a provisioned worker creates no machine: it checks the provider token, the image, SSH key, network, and firewall or the account's limits, the gateway's admin API, and that each harness has a credential outside the machine: Claude Code needs `anthropic` in the gateway's `llmProviders` or `apiKeys.anthropic`, which each attempt gets as `ANTHROPIC_API_KEY`; Codex needs `openai` there or `apiKeys.openai`, which each attempt gets as `OPENAI_API_KEY` with a `CODEX_HOME` whose `config.toml` reads it; and Pi is refused. Its report's `deferred` says that the harnesses are checked on the first machine each day.
190
+
191
+ The SSH layer needs a `ChildProcessSpawner`, a `CredentialProvider`, an `AttemptRuntime`, an `HttpClient` for admin and provider APIs, and a `FileSystem` and `Path` for host key, public key, and worker record files. It provides `SshWorkers` and `WorkerMachines` beside the `Executor`, starting with the workers of `workers`. `addWorker(address)` adds a worker at runtime and records how to reach a long-lived worker's machine (destination, port, identity file, and root, without secrets) in `workerRecordsDirectory`, a temporary directory by default, for `sshWorkerRecordsFor`; behind a gateway, the worker shares that gateway's forward, or opens one if it's the first. `removeWorker(name)` removes a worker once no attempt runs on it, and closes its gateway's forward if no other worker uses it. Adding a worker the executor has with the same address changes nothing; with another address, it fails with a `machine` capability error until the worker is removed.
148
192
 
149
193
  ```ts
150
- import { SshDestination, SshIdentityFile, WorkerName, WorkerRoot } from "@usine/core/worker"
194
+ import { InstanceId } from "@usine/core/instance"
195
+ import {
196
+ ContainerMemoryMib,
197
+ SshDestination,
198
+ SshIdentityFile,
199
+ WorkerName,
200
+ WorkerRoot,
201
+ } from "@usine/core/worker"
151
202
  import { layer } from "@usine/executor/ssh"
152
203
  import { Option } from "effect"
153
204
 
154
205
  const executorLayer = layer({
206
+ instance: "github-app-123456",
207
+ instanceId: InstanceId.make("0b4e7f3c-5a2d-4c1e-9f6b-8d7a2e1c3b4f"),
155
208
  workers: [
156
209
  {
157
210
  name: WorkerName.make("eu-one"),
158
- destination: SshDestination.make("usine@203.0.113.7"),
211
+ machine: { _tag: "Fixed", destination: SshDestination.make("usine@203.0.113.7") },
159
212
  identityFile: Option.some(SshIdentityFile.make("~/.ssh/usine_worker")),
160
213
  port: Option.none(),
161
214
  root: WorkerRoot.make("/home/usine/.usine"),
162
215
  gateway: Option.none(),
216
+ // Each attempt container gets 6 GiB; preflight derives its CPUs and processes.
217
+ container: {
218
+ image: Option.none(),
219
+ cpus: Option.none(),
220
+ memoryMib: Option.some(ContainerMemoryMib.make(6144)),
221
+ pids: Option.none(),
222
+ },
223
+ },
224
+ ],
225
+ })
226
+ ```
227
+
228
+ ## Cloudflare worker
229
+
230
+ `@usine/executor/cloudflare` runs the same contract in Cloudflare sandboxes, through the Worker that the operator deploys from `@usine/cloudflare` ([#551](https://github.com/basaltbytes/usine/issues/551)). Each worker address has the Worker's origin, the Cloudflare Access service token, the image name, the instance type, and the provider API keys. The executor calls the Worker's sandbox API with `HttpApiClient` and the token's headers; a request fails with `SandboxRequestError`, whose message contains no credential.
231
+
232
+ Each attempt gets its own sandbox, named `<instance>.<worker>.<attempt>`, where `CloudflareExecutorOptions.instance` identifies the usine instance, so that instances that share a deployed Worker never use or remove each other's sandboxes. Prepare creates it without credentials and runs the assigned harness's version probe in it, as preflight does, so an unsupported Codex fails with a `WorkerCapabilityError` before any token is issued. Then it gives the sandbox the attempt's installation token and the provider keys, which only the Worker's outbound handlers hold, and clones and checks out under `/home/usine/workspace` in the sandbox. Renewal publishes a new token to the sandbox before each expiry. Each credential change carries the time the executor issued it, later than the change before even if the clock goes back, and the Worker refuses credentials older than the ones the sandbox holds, so a renewal that's still on its way can't undo the revocation that follows it. Every child, git, gh, and probe command runs through the sandbox shell, `cloudflare/shell/shell.js`: a stand-in for `ssh` that the executor gives `RemoteShell` as its SSH command. It opens the Worker's exec WebSocket, relays stdin, stdout, and stderr, and exits with the command's exit code, or 255 if it can't reach the sandbox. If the stream drops after the command started, it attaches to the command's process files from the bytes it already wrote, up to five times. The Worker's origin and the token reach it in its environment, never in its arguments.
233
+
234
+ Cleanup stops every command in the sandbox, with the processes that the commands left running (`POST .../stop`), salvages and removes the checkout, and destroys the sandbox. A checkout kept for reattachment, or with unpushed work, keeps its sandbox: the executor holds it (`POST .../hold`), so the Worker keeps its container running until usine reconciles or destroys it, and replaces its credentials with none, so its handlers refuse GitHub and every provider. A reattached attempt reads its stored request, checks that its checkout exists in the running sandbox, stops the commands left from before the restart, and publishes a new token. If the reattachment fails after that check, for example because no token can be issued, the executor holds the sandbox with its checkout, and the engine's `recover` salvages it. A process's first use of a worker lists the Worker's sandboxes for this instance and worker, and destroys each one that no attempt of this process uses and that holds no checkout; one with a checkout stays for `recover`.
235
+
236
+ While an attempt runs, the executor asks the Worker every 30 seconds (`CloudflareExecutorOptions.drainPollInterval`) whether the platform sent the sandbox's container SIGTERM, which it does up to 15 minutes before it stops the container. If it did, the executor drains the attempt through the durable session: the attempt fails with a retryable `InfraFailure`, and its release salvages the checkout and destroys the sandbox, so the next attempt resumes from the salvage on a new sandbox.
237
+
238
+ Preflight calls `GET /v1/health`, checks that the Worker has the image, that the worker has an API key for each harness in the roster (`anthropic` for Claude Code, `openai` for Codex; Pi can't run), and runs `claude --version` and `codex --version` in a preflight sandbox that it destroys afterwards. A refused token is a `credentials` capability error, a missing image a `toolchain` one, and a missing key a `harness-login` one. Each child's `gatewayCredential` is `api-key` when the worker holds its harness's key, so usage counts it in the API keys pool.
239
+
240
+ The Cloudflare layer needs a `ChildProcessSpawner`, a `CredentialProvider`, an `AttemptRuntime`, a `ClaudeLoginLock`, and an `HttpClient` for the sandbox API.
241
+
242
+ ```ts
243
+ import { CloudflareImageName, WorkerName } from "@usine/core/worker"
244
+ import { layer } from "@usine/executor/cloudflare"
245
+ import { Redacted } from "effect"
246
+
247
+ const executorLayer = layer({
248
+ instance: "github-app-123",
249
+ workers: [
250
+ {
251
+ name: WorkerName.make("cf-one"),
252
+ workerUrl: new URL("https://usine-sandboxes.acme.workers.dev"),
253
+ accessClientId: Redacted.make(process.env.USINE_CF_ACCESS_CLIENT_ID ?? ""),
254
+ accessClientSecret: Redacted.make(process.env.USINE_CF_ACCESS_CLIENT_SECRET ?? ""),
255
+ image: CloudflareImageName.make("usine"),
256
+ instanceType: "standard-4",
257
+ providers: [
258
+ { provider: "anthropic", apiKey: Redacted.make(process.env.ANTHROPIC_API_KEY ?? "") },
259
+ ],
163
260
  },
164
261
  ],
165
262
  })
@@ -1,5 +1,5 @@
1
1
  import { Effect } from "effect";
2
- import { ChildProcess, type ChildProcessSpawner } from "effect/unstable/process";
2
+ import { ChildProcess, type ChildProcessSpawner } from "effect/process";
3
3
  import { ExecutorOperationError, type ExecutorOperation } from "./executor.ts";
4
4
  export interface CommandResult {
5
5
  readonly exitCode: number;
@@ -1 +1 @@
1
- {"version":3,"file":"child-command.d.ts","sourceRoot":"","sources":["../src/child-command.ts"],"names":[],"mappings":"AAAA,OAAO,EAAwB,MAAM,EAAyB,MAAM,QAAQ,CAAC;AAC7E,OAAO,EAAE,YAAY,EAAE,KAAK,mBAAmB,EAAE,MAAM,yBAAyB,CAAC;AACjF,OAAO,EAAE,sBAAsB,EAAE,KAAK,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAE/E,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAID,gFAAgF;AAChF,eAAO,MAAM,sBAAsB,UAC1B,aAAa,CAAC,MAAM,CAAC,KAC3B,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAQpC,CAAC;AAEJ;;;GAGG;AACH,eAAO,MAAM,cAAc,YAChB,mBAAmB,CAAC,mBAAmB,CAAC,SAAS,CAAC,aAChD,iBAAiB,WACnB,MAAM,QACT,aAAa,CAAC,MAAM,CAAC,WAClB,YAAY,CAAC,cAAc,KACnC,MAAM,CAAC,MAAM,CAAC,aAAa,EAAE,sBAAsB,CA+BnD,CAAC;AAEJ,0HAA0H;AAC1H,eAAO,MAAM,cAAc,+MAsBzB,CAAC"}
1
+ {"version":3,"file":"child-command.d.ts","sourceRoot":"","sources":["../src/child-command.ts"],"names":[],"mappings":"AAAA,OAAO,EAA+B,MAAM,EAAyB,MAAM,QAAQ,CAAC;AACpF,OAAO,EAAE,YAAY,EAAE,KAAK,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AACxE,OAAO,EAAE,sBAAsB,EAAE,KAAK,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAE/E,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAID,gFAAgF;AAChF,eAAO,MAAM,sBAAsB,UAC1B,aAAa,CAAC,MAAM,CAAC,KAC3B,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAQpC,CAAC;AAEJ;;;GAGG;AACH,eAAO,MAAM,cAAc,YAChB,mBAAmB,CAAC,mBAAmB,CAAC,SAAS,CAAC,aAChD,iBAAiB,WACnB,MAAM,QACT,aAAa,CAAC,MAAM,CAAC,WAClB,YAAY,CAAC,cAAc,KACnC,MAAM,CAAC,MAAM,CAAC,aAAa,EAAE,sBAAsB,CAiCnD,CAAC;AAEJ,0HAA0H;AAC1H,eAAO,MAAM,cAAc,+MAsBzB,CAAC"}
@@ -1,9 +1,9 @@
1
- import { Array as Arr, Config, Effect, Fiber, Option, Stream } from "effect";
2
- import { ChildProcess } from "effect/unstable/process";
1
+ import { Array as Arr, Cause, Config, Effect, Fiber, Option, Stream } from "effect";
2
+ import { ChildProcess } from "effect/process";
3
3
  import { ExecutorOperationError } from "./executor.js";
4
4
  const COMMAND_TIMEOUT = "10 minutes";
5
5
  /** Returns the variables in `names` that are set in the process environment. */
6
- export const allowlistedEnvironment = (names) => Effect.forEach(names, (name) => Effect.map(Config.option(Config.string(name)), (value) => Option.map(value, (found) => [name, found]))).pipe(Effect.map((entries) => Object.fromEntries(Arr.getSomes(entries))), Effect.orDie);
6
+ export const allowlistedEnvironment = (names) => Effect.forEach(names, (name) => Effect.map(Config.option(Config.String(name)), (value) => Option.map(value, (found) => [name, found]))).pipe(Effect.map((entries) => Object.fromEntries(Arr.getSomes(entries))), Effect.orDie);
7
7
  /**
8
8
  * Runs `command` with a 10-minute timeout and returns its exit code and output without checking
9
9
  * the exit code. Fails with `ExecutorOperationError` if the command can't run or times out.
@@ -20,7 +20,9 @@ export const collectCommand = (spawner, operation, command, args, options) => Ef
20
20
  return { exitCode: Number(exitCode), stderr, stdout };
21
21
  })).pipe(Effect.timeout(COMMAND_TIMEOUT), Effect.mapError((cause) => new ExecutorOperationError({
22
22
  operation,
23
- message: `Can't run ${command}`,
23
+ message: Cause.isTimeoutError(cause)
24
+ ? `${command} didn't finish within ${COMMAND_TIMEOUT}`
25
+ : `Can't run ${command}`,
24
26
  cause,
25
27
  })));
26
28
  /** Returns the collected stdout. If the exit code is non-zero, logs a warning and fails with `ExecutorOperationError`. */
@@ -1 +1 @@
1
- {"version":3,"file":"child-command.js","sourceRoot":"","sources":["../src/child-command.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,IAAI,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAC7E,OAAO,EAAE,YAAY,EAA4B,MAAM,yBAAyB,CAAC;AACjF,OAAO,EAAE,sBAAsB,EAA0B,MAAM,eAAe,CAAC;AAQ/E,MAAM,eAAe,GAAG,YAAY,CAAC;AAErC,gFAAgF;AAChF,MAAM,CAAC,MAAM,sBAAsB,GAAG,CACpC,KAA4B,EACW,EAAE,CACzC,MAAM,CAAC,OAAO,CAAC,KAAK,EAAE,CAAC,IAAI,EAAE,EAAE,CAC7B,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,EAAE,CAAC,KAAK,EAAE,EAAE,CACvD,MAAM,CAAC,GAAG,CAAC,KAAK,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,KAAK,CAAU,CAAC,CACrD,CACF,CAAC,IAAI,CACJ,MAAM,CAAC,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,MAAM,CAAC,WAAW,CAAC,GAAG,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,CAAC,EAClE,MAAM,CAAC,KAAK,CACb,CAAC;AAEJ;;;GAGG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,CAC5B,OAA2D,EAC3D,SAA4B,EAC5B,OAAe,EACf,IAA2B,EAC3B,OAAoC,EACkB,EAAE,CACxD,MAAM,CAAC,MAAM,CACX,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC;IAClB,MAAM,MAAM,GAAG,KAAK,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,YAAY,CAAC,IAAI,CAAC,OAAO,EAAE,IAAI,EAAE,OAAO,CAAC,CAAC,CAAC;IAC/E,MAAM,WAAW,GAAG,KAAK,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,CAC3C,MAAM,CAAC,UAAU,EAAE,EACnB,MAAM,CAAC,QAAQ,EACf,MAAM,CAAC,SAAS,CACjB,CAAC;IACF,MAAM,WAAW,GAAG,KAAK,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,CAC3C,MAAM,CAAC,UAAU,EAAE,EACnB,MAAM,CAAC,QAAQ,EACf,MAAM,CAAC,SAAS,CACjB,CAAC;IACF,MAAM,QAAQ,GAAG,KAAK,CAAC,CAAC,MAAM,CAAC,QAAQ,CAAC;IACxC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,KAAK,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC;QACzC,KAAK,CAAC,IAAI,CAAC,WAAW,CAAC;QACvB,KAAK,CAAC,IAAI,CAAC,WAAW,CAAC;KACxB,CAAC,CAAC;IACH,OAAO,EAAE,QAAQ,EAAE,MAAM,CAAC,QAAQ,CAAC,EAAE,MAAM,EAAE,MAAM,EAA0B,CAAC;AAChF,CAAC,CAAC,CACH,CAAC,IAAI,CACJ,MAAM,CAAC,OAAO,CAAC,eAAe,CAAC,EAC/B,MAAM,CAAC,QAAQ,CACb,CAAC,KAAK,EAAE,EAAE,CACR,IAAI,sBAAsB,CAAC;IACzB,SAAS;IACT,OAAO,EAAE,aAAa,OAAO,EAAE;IAC/B,KAAK;CACN,CAAC,CACL,CACF,CAAC;AAEJ,0HAA0H;AAC1H,MAAM,CAAC,MAAM,cAAc,GAAG,MAAM,CAAC,EAAE,CAAC,yBAAyB,CAAC,CAAC,QAAQ,CAAC,EAC1E,SAA4B,EAC5B,IAAY,EACZ,OAAe,EACf,IAA2B,EAC3B,MAAqB;IAErB,IAAI,MAAM,CAAC,QAAQ,KAAK,CAAC,EAAE,CAAC;QAC1B,KAAK,CAAC,CAAC,MAAM,CAAC,UAAU,CAAC,kCAAkC,EAAE;YAC3D,SAAS;YACT,IAAI;YACJ,OAAO;YACP,IAAI,EAAE,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC;YACpB,QAAQ,EAAE,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC;SAClC,CAAC,CAAC;QACH,OAAO,KAAK,CAAC,CAAC,IAAI,sBAAsB,CAAC;YACvC,SAAS;YACT,OAAO,EAAE,GAAG,OAAO,qBAAqB,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC,EAAE;YACjE,KAAK,EAAE,IAAI,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,kCAAkC,CAAC;SACvF,CAAC,CAAC;IACL,CAAC;IACD,OAAO,MAAM,CAAC,MAAM,CAAC;AACvB,CAAC,CAAC,CAAC"}
1
+ {"version":3,"file":"child-command.js","sourceRoot":"","sources":["../src/child-command.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,19 @@
1
+ import type { BotAccount } from "@usine/core/worker";
2
+ import type { Option } from "effect";
3
+ /**
4
+ * Returns the variables that make `author` the author and committer of every commit git makes
5
+ * with them. They take precedence over `user.name` and `user.email` in every git config,
6
+ * including the Worker user's global one.
7
+ */
8
+ export declare const commitIdentity: (author: BotAccount) => {
9
+ GIT_AUTHOR_NAME: string & import("effect/Brand").Brand<"BotLogin">;
10
+ GIT_AUTHOR_EMAIL: string;
11
+ GIT_COMMITTER_NAME: string & import("effect/Brand").Brand<"BotLogin">;
12
+ GIT_COMMITTER_EMAIL: string;
13
+ };
14
+ /**
15
+ * Returns the environment for an Attempt's parent and children on every Worker kind:
16
+ * `environment`, with `commitIdentity(author)`, and with `gitHooks` as `core.hooksPath` if set.
17
+ */
18
+ export declare const childEnvironment: (environment: Readonly<Record<string, string>>, author: BotAccount, gitHooks: Option.Option<string>) => Record<string, string>;
19
+ //# sourceMappingURL=child-environment.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"child-environment.d.ts","sourceRoot":"","sources":["../src/child-environment.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AACrD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAGrC;;;;GAIG;AACH,eAAO,MAAM,cAAc,WAAY,UAAU;;;;;CAMZ,CAAC;AAEtC;;;GAGG;AACH,eAAO,MAAM,gBAAgB,gBACd,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,UACrC,UAAU,YACR,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,KAC9B,MAAM,CAAC,MAAM,EAAE,MAAM,CAA0E,CAAC"}
@@ -0,0 +1,18 @@
1
+ import { withGitHooks } from "./pre-push-hook.js";
2
+ /**
3
+ * Returns the variables that make `author` the author and committer of every commit git makes
4
+ * with them. They take precedence over `user.name` and `user.email` in every git config,
5
+ * including the Worker user's global one.
6
+ */
7
+ export const commitIdentity = (author) => ({
8
+ GIT_AUTHOR_NAME: author.login,
9
+ GIT_AUTHOR_EMAIL: author.noreplyEmail,
10
+ GIT_COMMITTER_NAME: author.login,
11
+ GIT_COMMITTER_EMAIL: author.noreplyEmail,
12
+ });
13
+ /**
14
+ * Returns the environment for an Attempt's parent and children on every Worker kind:
15
+ * `environment`, with `commitIdentity(author)`, and with `gitHooks` as `core.hooksPath` if set.
16
+ */
17
+ export const childEnvironment = (environment, author, gitHooks) => withGitHooks({ ...environment, ...commitIdentity(author) }, gitHooks);
18
+ //# sourceMappingURL=child-environment.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"child-environment.js","sourceRoot":"","sources":["../src/child-environment.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAElD;;;;GAIG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,CAAC,MAAkB,EAAE,EAAE,CACnD,CAAC;IACC,eAAe,EAAE,MAAM,CAAC,KAAK;IAC7B,gBAAgB,EAAE,MAAM,CAAC,YAAY;IACrC,kBAAkB,EAAE,MAAM,CAAC,KAAK;IAChC,mBAAmB,EAAE,MAAM,CAAC,YAAY;CACzC,CAAkC,CAAC;AAEtC;;;GAGG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAC9B,WAA6C,EAC7C,MAAkB,EAClB,QAA+B,EACP,EAAE,CAAC,YAAY,CAAC,EAAE,GAAG,WAAW,EAAE,GAAG,cAAc,CAAC,MAAM,CAAC,EAAE,EAAE,QAAQ,CAAC,CAAC"}
@@ -1,8 +1,9 @@
1
1
  import { ClaudeLoginLock } from "@usine/agents/runtime";
2
2
  import { Layer } from "effect";
3
3
  /**
4
- * A `ClaudeLoginLock` with one mutex per Worker name. Each build creates new locks, so build it
5
- * once per process and provide it to both the executor and the Attempt runtime.
4
+ * A `ClaudeLoginLock` with one mutex per Worker name, and one per provider machine ID for the
5
+ * machines of provisioned Workers. Each build creates new locks, so build it once per process and
6
+ * provide it to both the executor and the Attempt runtime.
6
7
  */
7
8
  export declare const claudeLoginLockLayer: Layer.Layer<ClaudeLoginLock>;
8
9
  //# sourceMappingURL=claude-login-lock.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"claude-login-lock.d.ts","sourceRoot":"","sources":["../src/claude-login-lock.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAExD,OAAO,EAAE,KAAK,EAAE,MAAM,QAAQ,CAAC;AAG/B;;;GAGG;AACH,eAAO,MAAM,oBAAoB,EAAE,KAAK,CAAC,KAAK,CAAC,eAAe,CAG7D,CAAC"}
1
+ {"version":3,"file":"claude-login-lock.d.ts","sourceRoot":"","sources":["../src/claude-login-lock.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAExD,OAAO,EAAU,KAAK,EAAU,MAAM,QAAQ,CAAC;AAG/C;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,EAAE,KAAK,CAAC,KAAK,CAAC,eAAe,CAW7D,CAAC"}
@@ -1,9 +1,15 @@
1
1
  import { ClaudeLoginLock } from "@usine/agents/runtime";
2
- import { Layer } from "effect";
2
+ import { Effect, Layer, Option } from "effect";
3
3
  import { keyedLockFor } from "./keyed-lock.js";
4
4
  /**
5
- * A `ClaudeLoginLock` with one mutex per Worker name. Each build creates new locks, so build it
6
- * once per process and provide it to both the executor and the Attempt runtime.
5
+ * A `ClaudeLoginLock` with one mutex per Worker name, and one per provider machine ID for the
6
+ * machines of provisioned Workers. Each build creates new locks, so build it once per process and
7
+ * provide it to both the executor and the Attempt runtime.
7
8
  */
8
- export const claudeLoginLockLayer = Layer.effect(ClaudeLoginLock, keyedLockFor());
9
+ export const claudeLoginLockLayer = Layer.effect(ClaudeLoginLock, Effect.zipWith(keyedLockFor(), keyedLockFor(), (workers, machines) => ClaudeLoginLock.of({
10
+ withLock: (worker, machine) => Option.match(machine, {
11
+ onNone: () => workers.withLock(worker),
12
+ onSome: machines.withLock,
13
+ }),
14
+ })));
9
15
  //# sourceMappingURL=claude-login-lock.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"claude-login-lock.js","sourceRoot":"","sources":["../src/claude-login-lock.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAExD,OAAO,EAAE,KAAK,EAAE,MAAM,QAAQ,CAAC;AAC/B,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAE/C;;;GAGG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAiC,KAAK,CAAC,MAAM,CAC5E,eAAe,EACf,YAAY,EAAc,CAC3B,CAAC"}
1
+ {"version":3,"file":"claude-login-lock.js","sourceRoot":"","sources":["../src/claude-login-lock.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAExD,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAC/C,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAE/C;;;;GAIG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAiC,KAAK,CAAC,MAAM,CAC5E,eAAe,EACf,MAAM,CAAC,OAAO,CAAC,YAAY,EAAc,EAAE,YAAY,EAAa,EAAE,CAAC,OAAO,EAAE,QAAQ,EAAE,EAAE,CAC1F,eAAe,CAAC,EAAE,CAAC;IACjB,QAAQ,EAAE,CAAC,MAAM,EAAE,OAAO,EAAE,EAAE,CAC5B,MAAM,CAAC,KAAK,CAAC,OAAO,EAAE;QACpB,MAAM,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC;QACtC,MAAM,EAAE,QAAQ,CAAC,QAAQ;KAC1B,CAAC;CACL,CAAC,CACH,CACF,CAAC"}
@@ -0,0 +1,50 @@
1
+ import { type CreateSandbox, type Destroyed, type HealthReport, type SandboxCredentials, type SandboxList, type SandboxName, type SandboxSummary, type Stopped } from "@usine/cloudflare";
2
+ import { Effect, Option, Schema } from "effect";
3
+ import { HttpClient } from "effect/http";
4
+ import type { CloudflareWorkerAddress } from "./model.ts";
5
+ export declare const SandboxOperation: Schema.Literals<readonly ["health", "create", "get", "list", "credentials", "hold", "stop", "destroy"]>;
6
+ export type SandboxOperation = typeof SandboxOperation.Type;
7
+ declare const SandboxRequestError_base: Schema.Class<SandboxRequestError, Schema.TaggedStruct<"SandboxRequestError", {
8
+ readonly worker: Schema.brand<Schema.String, "WorkerName">;
9
+ readonly operation: Schema.Literals<readonly ["health", "create", "get", "list", "credentials", "hold", "stop", "destroy"]>;
10
+ /** Whether the Worker refused the Access service token. */
11
+ readonly refused: Schema.Boolean;
12
+ readonly message: Schema.NonEmptyString;
13
+ }>, import("effect/Cause").YieldableError>;
14
+ /**
15
+ * A failed request to the Worker. `message` contains no credential; the request and raw failure
16
+ * are discarded because create and credential requests contain the installation token and the
17
+ * provider keys.
18
+ */
19
+ export declare class SandboxRequestError extends SandboxRequestError_base {
20
+ }
21
+ /** The sandbox API of one deployed Worker. Every method fails with `SandboxRequestError`. */
22
+ export interface SandboxClient {
23
+ readonly health: Effect.Effect<HealthReport, SandboxRequestError>;
24
+ readonly create: (name: SandboxName, request: CreateSandbox) => Effect.Effect<SandboxSummary, SandboxRequestError>;
25
+ /** Returns `None` if the sandbox doesn't exist. */
26
+ readonly get: (name: SandboxName) => Effect.Effect<Option.Option<SandboxSummary>, SandboxRequestError>;
27
+ /** Lists the sandboxes of this usine instance and Worker. */
28
+ readonly list: Effect.Effect<SandboxList, SandboxRequestError>;
29
+ readonly credentials: (name: SandboxName, credentials: SandboxCredentials) => Effect.Effect<SandboxSummary, SandboxRequestError>;
30
+ /** Keeps the sandbox's container running past its lease, until it's destroyed. */
31
+ readonly hold: (name: SandboxName) => Effect.Effect<SandboxSummary, SandboxRequestError>;
32
+ readonly stop: (name: SandboxName) => Effect.Effect<Stopped, SandboxRequestError>;
33
+ readonly destroy: (name: SandboxName) => Effect.Effect<Destroyed, SandboxRequestError>;
34
+ }
35
+ /**
36
+ * Returns the client for `address`'s Worker, which sends the Access service token with each
37
+ * request and lists only `owner`'s sandboxes.
38
+ */
39
+ export declare const sandboxClientFor: (address: CloudflareWorkerAddress, owner: string & import("effect/Brand").Brand<"SandboxOwner">) => Effect.Effect<{
40
+ health: Effect.Effect<HealthReport, SandboxRequestError, never>;
41
+ create: (name: string & import("effect/Brand").Brand<"SandboxName">, body: CreateSandbox) => Effect.Effect<SandboxSummary, SandboxRequestError, never>;
42
+ get: (name: string & import("effect/Brand").Brand<"SandboxName">) => Effect.Effect<Option.Option<never> | Option.Option<SandboxSummary>, SandboxRequestError, never>;
43
+ list: Effect.Effect<SandboxList, SandboxRequestError, never>;
44
+ credentials: (name: string & import("effect/Brand").Brand<"SandboxName">, body: SandboxCredentials) => Effect.Effect<SandboxSummary, SandboxRequestError, never>;
45
+ hold: (name: string & import("effect/Brand").Brand<"SandboxName">) => Effect.Effect<SandboxSummary, SandboxRequestError, never>;
46
+ stop: (name: string & import("effect/Brand").Brand<"SandboxName">) => Effect.Effect<Stopped, SandboxRequestError, never>;
47
+ destroy: (name: string & import("effect/Brand").Brand<"SandboxName">) => Effect.Effect<Destroyed, SandboxRequestError, never>;
48
+ }, never, HttpClient.HttpClient>;
49
+ export {};
50
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/cloudflare/client.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,aAAa,EAClB,KAAK,SAAS,EACd,KAAK,YAAY,EAEjB,KAAK,kBAAkB,EACvB,KAAK,WAAW,EAChB,KAAK,WAAW,EAEhB,KAAK,cAAc,EACnB,KAAK,OAAO,EACb,MAAM,mBAAmB,CAAC;AAE3B,OAAO,EAAE,MAAM,EAAE,MAAM,EAAY,MAAM,EAAE,MAAM,QAAQ,CAAC;AAC1D,OAAO,EAAE,UAAU,EAAsC,MAAM,aAAa,CAAC;AAE7E,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,YAAY,CAAC;AAE1D,eAAO,MAAM,gBAAgB,yGAS3B,CAAC;AACH,MAAM,MAAM,gBAAgB,GAAG,OAAO,gBAAgB,CAAC,IAAI,CAAC;;;;IAuB1D,2DAA2D;;;;AAV7D;;;;GAIG;AACH,qBAAa,mBAAoB,SAAQ,wBAQvC;CAAG;AAoCL,6FAA6F;AAC7F,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,YAAY,EAAE,mBAAmB,CAAC,CAAC;IAClE,QAAQ,CAAC,MAAM,EAAE,CACf,IAAI,EAAE,WAAW,EACjB,OAAO,EAAE,aAAa,KACnB,MAAM,CAAC,MAAM,CAAC,cAAc,EAAE,mBAAmB,CAAC,CAAC;IACxD,mDAAmD;IACnD,QAAQ,CAAC,GAAG,EAAE,CACZ,IAAI,EAAE,WAAW,KACd,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,cAAc,CAAC,EAAE,mBAAmB,CAAC,CAAC;IACvE,6DAA6D;IAC7D,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,CAAC,WAAW,EAAE,mBAAmB,CAAC,CAAC;IAC/D,QAAQ,CAAC,WAAW,EAAE,CACpB,IAAI,EAAE,WAAW,EACjB,WAAW,EAAE,kBAAkB,KAC5B,MAAM,CAAC,MAAM,CAAC,cAAc,EAAE,mBAAmB,CAAC,CAAC;IACxD,kFAAkF;IAClF,QAAQ,CAAC,IAAI,EAAE,CAAC,IAAI,EAAE,WAAW,KAAK,MAAM,CAAC,MAAM,CAAC,cAAc,EAAE,mBAAmB,CAAC,CAAC;IACzF,QAAQ,CAAC,IAAI,EAAE,CAAC,IAAI,EAAE,WAAW,KAAK,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,mBAAmB,CAAC,CAAC;IAClF,QAAQ,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,WAAW,KAAK,MAAM,CAAC,MAAM,CAAC,SAAS,EAAE,mBAAmB,CAAC,CAAC;CACxF;AAED;;;GAGG;AACH,eAAO,MAAM,gBAAgB;;;;;;;;;gCAiE3B,CAAC"}
@@ -0,0 +1,108 @@
1
+ import { ACCESS_CLIENT_ID_HEADER, ACCESS_CLIENT_SECRET_HEADER, SandboxApi, } from "@usine/cloudflare";
2
+ import { WorkerName } from "@usine/core/worker";
3
+ import { Effect, Option, Redacted, Schema } from "effect";
4
+ import { HttpClient, HttpClientError, HttpClientRequest } from "effect/http";
5
+ import { HttpApiClient } from "effect/http-api";
6
+ export const SandboxOperation = Schema.Literals([
7
+ "health",
8
+ "create",
9
+ "get",
10
+ "list",
11
+ "credentials",
12
+ "hold",
13
+ "stop",
14
+ "destroy",
15
+ ]);
16
+ const ACTIONS = {
17
+ health: "check the Worker's health",
18
+ create: "create the sandbox",
19
+ get: "read the sandbox",
20
+ list: "list the sandboxes",
21
+ credentials: "renew the sandbox's credentials",
22
+ hold: "hold the sandbox",
23
+ stop: "stop the sandbox's commands",
24
+ destroy: "destroy the sandbox",
25
+ };
26
+ /**
27
+ * A failed request to the Worker. `message` contains no credential; the request and raw failure
28
+ * are discarded because create and credential requests contain the installation token and the
29
+ * provider keys.
30
+ */
31
+ export class SandboxRequestError extends Schema.TaggedError("@usine/executor/SandboxRequestError")("SandboxRequestError", {
32
+ worker: WorkerName,
33
+ operation: SandboxOperation,
34
+ /** Whether the Worker refused the Access service token. */
35
+ refused: Schema.Boolean,
36
+ message: Schema.NonEmptyString,
37
+ }) {
38
+ }
39
+ /** The timeout for one request. Creating a sandbox starts its container. */
40
+ const REQUEST_TIMEOUT = "2 minutes";
41
+ /** Returns the failure reason, built only from the failure's tag, status, and typed fields. */
42
+ const reason = (failure) => {
43
+ if (HttpClientError.isHttpClientError(failure))
44
+ return failure.response === undefined
45
+ ? `no response (${failure.reason._tag})`
46
+ : failure.response.status === 401
47
+ ? "the Worker refused the Access service token"
48
+ : `the Worker returned status ${String(failure.response.status)}`;
49
+ switch (failure._tag) {
50
+ case "TimeoutError":
51
+ return `no response within ${REQUEST_TIMEOUT}`;
52
+ case "SandboxNotFound":
53
+ return "the sandbox doesn't exist";
54
+ case "SandboxConflict":
55
+ return "the sandbox exists for another Attempt";
56
+ case "StaleCredentials":
57
+ return "the sandbox holds credentials that usine issued later";
58
+ case "ImageNotFound":
59
+ return "the Worker has no image with that name";
60
+ case "ContainerUnavailable":
61
+ return "the sandbox's container isn't running or failed the request";
62
+ case "SchemaError":
63
+ return "the Worker's response doesn't match its schema";
64
+ default:
65
+ return `the request failed with ${failure._tag}`;
66
+ }
67
+ };
68
+ const refusedToken = (failure) => HttpClientError.isHttpClientError(failure) && failure.response?.status === 401;
69
+ /**
70
+ * Returns the client for `address`'s Worker, which sends the Access service token with each
71
+ * request and lists only `owner`'s sandboxes.
72
+ */
73
+ export const sandboxClientFor = Effect.fn("CloudflareExecutor.sandboxClientFor")(function* (address, owner) {
74
+ const client = yield* HttpApiClient.make(SandboxApi, {
75
+ baseUrl: address.workerUrl,
76
+ transformClient: HttpClient.mapRequest(HttpClientRequest.setHeaders({
77
+ [ACCESS_CLIENT_ID_HEADER]: Redacted.value(address.accessClientId),
78
+ [ACCESS_CLIENT_SECRET_HEADER]: Redacted.value(address.accessClientSecret),
79
+ })),
80
+ });
81
+ const worker = address.name;
82
+ const request = (operation, effect, fields = {}) => effect.pipe(Effect.timeout(REQUEST_TIMEOUT), Effect.mapError((failure) => new SandboxRequestError({
83
+ worker,
84
+ operation,
85
+ refused: refusedToken(failure),
86
+ message: `Can't ${ACTIONS[operation]} for cloudflare worker "${worker}": ${reason(failure)}`,
87
+ })), Effect.tapError((error) => Effect.logWarning("Cloudflare Worker request failed", {
88
+ worker,
89
+ operation,
90
+ message: error.message,
91
+ ...fields,
92
+ })), Effect.withSpan(`CloudflareExecutor.${operation}`, { attributes: { worker, ...fields } }));
93
+ return {
94
+ health: request("health", client.health.health()),
95
+ create: (name, body) => request("create", client.sandbox.create({ params: { name }, payload: body }), {
96
+ sandbox: name,
97
+ }),
98
+ get: (name) => request("get", client.sandbox.get({ params: { name } }).pipe(Effect.map(Option.some), Effect.catchTag("SandboxNotFound", () => Effect.succeedNone)), { sandbox: name }),
99
+ list: request("list", client.registry.list({ query: { owner } }), { owner }),
100
+ credentials: (name, body) => request("credentials", client.sandbox.credentials({ params: { name }, payload: body }), {
101
+ sandbox: name,
102
+ }),
103
+ hold: (name) => request("hold", client.sandbox.hold({ params: { name } }), { sandbox: name }),
104
+ stop: (name) => request("stop", client.sandbox.stop({ params: { name } }), { sandbox: name }),
105
+ destroy: (name) => request("destroy", client.sandbox.destroy({ params: { name } }), { sandbox: name }),
106
+ };
107
+ });
108
+ //# sourceMappingURL=client.js.map