@usine/executor 0.3.0 → 0.4.1-next.240.g158c84b

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
@@ -0,0 +1,704 @@
1
+ import { RemoteShell } from "@usine/agents/child-host";
2
+ import { supportedCodexVersions } from "@usine/agents/preflight";
3
+ import { ClaudeLoginLock } from "@usine/agents/runtime";
4
+ import { ApiKeyCredential, CreateSandbox, ProviderGrant, SandboxCredentials, sandboxName, SandboxOwner, } from "@usine/cloudflare";
5
+ import { harnessEnvironment, SANDBOX_HOME } from "@usine/cloudflare/harness";
6
+ import { HarnessReady, HarnessUnavailableError } from "@usine/core/agent/capability";
7
+ import { AttemptId } from "@usine/core/attempt";
8
+ import { InfraFailure } from "@usine/core/attempt/failure";
9
+ import { safeSegment } from "@usine/core/repository";
10
+ import { harvestFacts, harvestPostedComments } from "@usine/core/step/harvest";
11
+ import { AttemptRuntime } from "@usine/durable/attempt-runtime";
12
+ import { Array as Arr, Cache, DateTime, Deferred, Effect, Exit, Layer, Option, Redacted, Ref, Schema, Scope, } from "effect";
13
+ import { fileURLToPath } from "node:url";
14
+ import { childEnvironment, commitIdentity } from "../child-environment.js";
15
+ import { durableSessionFor } from "../durable-session.js";
16
+ import { CredentialProvider, Executor, ExecutorError, ExecutorOperationError, fullPreflight, logPreflightFailure, WorkerCapabilityError, } from "../executor.js";
17
+ import { verifyHarnessReadiness } from "../harness-preflight.js";
18
+ import { cacheDirectoryName, keptCheckoutRecovery, linkedWorktree, provisionWorktree, reattachWorktree, recoverWorktree, recoveryFields, } from "../provision.js";
19
+ import { remoteCommandsFor } from "../remote/commands.js";
20
+ import { renewableCredential } from "../renewable-credential.js";
21
+ import { sandboxClientFor } from "./client.js";
22
+ import { SHELL_ENVIRONMENT, SHELL_FAILURE } from "./shell/environment.js";
23
+ /** The sandbox directory with the cached clone under `repos` and the checkout under `attempts`. */
24
+ const WORKSPACE = `${SANDBOX_HOME}/workspace`;
25
+ /** The Attempt ID of the sandbox that preflight starts to check the image's harnesses. */
26
+ const PREFLIGHT_ATTEMPT = AttemptId.make("preflight");
27
+ /** The sandbox shell under this Node.js, next to this module in the built package. */
28
+ const DEFAULT_SHELL = {
29
+ command: process.execPath,
30
+ args: [fileURLToPath(new URL("shell/shell.js", import.meta.url))],
31
+ };
32
+ /** A script that writes `$2` to the file `$1`, creating its directory, and makes it executable. */
33
+ const WRITE_EXECUTABLE_SCRIPT = String.raw `
34
+ set -e
35
+ mkdir -p "$(dirname "$1")"
36
+ printf '%s' "$2" > "$1"
37
+ chmod 755 "$1"
38
+ `.trim();
39
+ /** The provider whose API key each harness needs in a sandbox, which holds no harness login. */
40
+ const HARNESS_PROVIDERS = {
41
+ claude: Option.some("anthropic"),
42
+ codex: Option.some("openai"),
43
+ pi: Option.none(),
44
+ };
45
+ const PROVIDER_LABELS = {
46
+ anthropic: "Claude Code needs an Anthropic API key",
47
+ openai: "Codex needs an OpenAI API key",
48
+ };
49
+ /** The sandbox paths of one Attempt's checkout. */
50
+ const layoutFor = (repository, attemptId) => {
51
+ const cache = `${WORKSPACE}/repos/${cacheDirectoryName(repository)}`;
52
+ return {
53
+ cache,
54
+ staging: `${cache}.cloning`,
55
+ worktree: `${WORKSPACE}/attempts/${safeSegment(attemptId)}`,
56
+ };
57
+ };
58
+ const isExecutorError = Schema.is(ExecutorError);
59
+ const operationError = (operation) => (error) => new ExecutorOperationError({ operation, message: error.message, cause: error });
60
+ /** Returns why `address` can't run `brain`, or `None` if it can. */
61
+ const brainGap = (address, brain) => Option.match(HARNESS_PROVIDERS[brain.harness], {
62
+ onNone: () => Option.some(new WorkerCapabilityError({
63
+ worker: address.name,
64
+ capability: "toolchain",
65
+ message: `Cloudflare worker "${address.name}" can't run Pi children, which run only on local workers`,
66
+ cause: new Error(`unsupported harness ${brain.harness}`),
67
+ })),
68
+ onSome: (provider) => address.providers.some((key) => key.provider === provider)
69
+ ? Option.none()
70
+ : Option.some(new WorkerCapabilityError({
71
+ worker: address.name,
72
+ capability: "harness-login",
73
+ message: `${PROVIDER_LABELS[provider]} on cloudflare worker "${address.name}", which holds no harness login. Add ${provider} to the worker's llm_providers with its api_key_env.`,
74
+ cause: new Error(`no ${provider} key`),
75
+ })),
76
+ });
77
+ /** Returns the sandbox credentials of a running Attempt: `github` and every provider key of `address`. */
78
+ const credentialsFor = (address, github, issuedAt) => new SandboxCredentials({
79
+ issuedAt,
80
+ github: Option.some(github),
81
+ providers: address.providers.map(({ provider, apiKey }) => new ProviderGrant({ provider, credential: new ApiKeyCredential({ key: apiKey }) })),
82
+ });
83
+ /**
84
+ * Returns the credentials of a sandbox that runs no Attempt: a preflight's, or one that usine
85
+ * keeps. Its outbound handlers refuse GitHub and every provider.
86
+ */
87
+ const noCredentials = (issuedAt) => new SandboxCredentials({ issuedAt, github: Option.none(), providers: [] });
88
+ /** The message of the `InfraFailure` of an Attempt whose sandbox the platform stops. */
89
+ const DRAIN_MESSAGE = "Cloudflare is stopping the attempt's sandbox, whose disk a new container won't have; the attempt salvaged its checkout, and the next attempt resumes from the salvage";
90
+ /** A probe that checks a harness's binary and version in a sandbox. Pi never reaches it. */
91
+ const versionProbe = (operation, commands, shell) => (brain) => Effect.gen(function* () {
92
+ const command = brain.harness === "codex" ? "codex" : "claude";
93
+ const result = yield* commands.inspect(operation, shell, command, ["--version"]);
94
+ if (result.exitCode === SHELL_FAILURE)
95
+ return yield* new ExecutorOperationError({
96
+ operation,
97
+ message: `Can't reach sandbox ${shell.destination} to run ${command}`,
98
+ cause: new Error(result.stderr.trim() || `the sandbox shell exited with ${SHELL_FAILURE}`),
99
+ });
100
+ const version = result.stdout.trim();
101
+ if (result.exitCode !== 0)
102
+ return new HarnessUnavailableError({
103
+ harness: brain.harness,
104
+ capability: "binary",
105
+ message: `${command} --version failed in the sandbox image`,
106
+ });
107
+ if (brain.harness === "codex" &&
108
+ !supportedCodexVersions.some((supported) => version === `codex-cli ${supported}`))
109
+ return new HarnessUnavailableError({
110
+ harness: "codex",
111
+ capability: "version",
112
+ message: `The sandbox image has ${/^codex-cli [0-9]+\.[0-9]+\.[0-9]+$/u.test(version) ? version : "an unrecognized Codex version"}. Rebuild it with a supported Codex CLI version: ${supportedCodexVersions.join(", ")}.`,
113
+ });
114
+ yield* Effect.logInfo("Checked a harness in the sandbox image", {
115
+ sandbox: shell.destination,
116
+ harness: brain.harness,
117
+ version,
118
+ });
119
+ return new HarnessReady({ harness: brain.harness });
120
+ });
121
+ const makeCloudflareExecutor = (options) => Effect.gen(function* () {
122
+ const commands = yield* remoteCommandsFor;
123
+ const credentialProvider = yield* CredentialProvider;
124
+ const logins = yield* ClaudeLoginLock;
125
+ const session = yield* durableSessionFor({
126
+ interruptGrace: options.interruptGrace ?? "10 seconds",
127
+ });
128
+ const shell = options.shell ?? DEFAULT_SHELL;
129
+ const drainPollInterval = options.drainPollInterval ?? "30 seconds";
130
+ const workers = new Map();
131
+ for (const address of options.workers) {
132
+ if (workers.has(address.name))
133
+ return yield* Effect.die(new Error(`CloudflareExecutorOptions.workers names "${address.name}" twice`));
134
+ const owner = yield* Option.match(Schema.decodeUnknownOption(SandboxOwner)(`${options.instance}.${address.name}`), {
135
+ onNone: () => Effect.die(new Error(`CloudflareExecutorOptions.instance "${options.instance}" isn't in kebab case`)),
136
+ onSome: Effect.succeed,
137
+ });
138
+ const client = yield* sandboxClientFor(address, owner);
139
+ const sshEnvironment = {
140
+ ...Object.fromEntries(["HOME", "PATH", "TMPDIR"].flatMap((name) => Option.toArray(Option.map(Option.fromNullishOr(process.env[name]), (value) => [name, value])))),
141
+ [SHELL_ENVIRONMENT.workerUrl]: address.workerUrl.origin,
142
+ [SHELL_ENVIRONMENT.accessClientId]: Redacted.value(address.accessClientId),
143
+ [SHELL_ENVIRONMENT.accessClientSecret]: Redacted.value(address.accessClientSecret),
144
+ };
145
+ workers.set(address.name, {
146
+ address,
147
+ owner,
148
+ client,
149
+ providers: Arr.dedupe(address.providers.map(({ provider }) => provider)),
150
+ shellFor: (name) => new RemoteShell({ ssh: shell, sshEnvironment, destination: name }),
151
+ });
152
+ }
153
+ const lastIssued = yield* Ref.make(DateTime.makeUnsafe(0));
154
+ /**
155
+ * Returns the time to put on new sandbox credentials: now, or a millisecond after the last
156
+ * ones if the clock reads earlier. A sandbox refuses credentials older than the ones it holds,
157
+ * so this keeps a revocation later than the renewals before it.
158
+ */
159
+ const issueTime = Effect.gen(function* () {
160
+ const now = yield* DateTime.now;
161
+ return yield* Ref.modify(lastIssued, (last) => {
162
+ const issued = DateTime.max(now, DateTime.add(last, { milliseconds: 1 }));
163
+ return [issued, issued];
164
+ });
165
+ });
166
+ /** The sandboxes this process uses now; orphan reconciliation never destroys them. */
167
+ const active = new Set();
168
+ const claim = (name) => Effect.acquireRelease(Effect.sync(() => active.add(name)), () => Effect.sync(() => active.delete(name)));
169
+ const workerFor = (operation, name) => Option.match(Option.fromNullishOr(workers.get(name)), {
170
+ onNone: () => Effect.fail(new ExecutorOperationError({
171
+ operation,
172
+ message: `No cloudflare worker named "${name}" is configured`,
173
+ cause: new Error(name),
174
+ })),
175
+ onSome: Effect.succeed,
176
+ });
177
+ /** Returns the variables that point git, gh, and the harnesses at the sandbox's outbound handlers. */
178
+ const sandboxEnvironment = (worker) => harnessEnvironment(worker.providers);
179
+ /** Returns the credential kind the outbound handlers inject for `harness`. */
180
+ const gatewayCredential = (worker, harness) => Option.flatMap(HARNESS_PROVIDERS[harness], (provider) => worker.providers.includes(provider) ? Option.some("api-key") : Option.none());
181
+ const destroySandbox = Effect.fn("CloudflareExecutor.destroySandbox")(function* (worker, name, reason) {
182
+ yield* Effect.annotateCurrentSpan({ worker: worker.address.name, sandbox: name });
183
+ const { destroyed } = yield* worker.client
184
+ .destroy(name)
185
+ .pipe(Effect.mapError(operationError("cleanup")));
186
+ yield* Effect.logInfo(destroyed ? "Destroyed the sandbox" : "The sandbox was already gone", {
187
+ worker: worker.address.name,
188
+ sandbox: name,
189
+ reason,
190
+ });
191
+ });
192
+ /**
193
+ * Holds the sandbox, whose checkout stays, so that the Worker keeps its container running
194
+ * until usine reconciles or destroys it. Logs a failure, after which the container stops two
195
+ * hours after the sandbox's last request.
196
+ */
197
+ const holdSandbox = Effect.fn("CloudflareExecutor.holdSandbox")(function* (worker, name, reason) {
198
+ const fields = { worker: worker.address.name, sandbox: name, reason };
199
+ yield* Effect.annotateCurrentSpan(fields);
200
+ yield* worker.client.hold(name).pipe(Effect.tap(() => Effect.logInfo("Holding the sandbox until usine reconciles or destroys it", fields)), Effect.tapError((error) => Effect.logError("Could not hold the sandbox; its container and checkout stop two hours after its last request", { ...fields, message: error.message })), Effect.ignore);
201
+ });
202
+ /**
203
+ * Returns whether the platform is stopping the sandbox's container. Logs a failed request and
204
+ * returns false, so the next poll asks again.
205
+ */
206
+ const isDraining = Effect.fn("CloudflareExecutor.isDraining")(function* (worker, name) {
207
+ yield* Effect.annotateCurrentSpan({ worker: worker.address.name, sandbox: name });
208
+ return yield* worker.client.get(name).pipe(Effect.map(Option.exists(({ draining }) => draining)), Effect.tapError((error) => Effect.logWarning("Could not ask whether the sandbox is draining; asking again", {
209
+ worker: worker.address.name,
210
+ sandbox: name,
211
+ message: error.message,
212
+ })), Effect.orElseSucceed(() => false));
213
+ });
214
+ /**
215
+ * Asks every `drainPollInterval` whether the platform is stopping the Attempt's sandbox, and
216
+ * drains the Attempt when it is: the Attempt fails with a retryable `InfraFailure`, and its
217
+ * release salvages the checkout within the platform's 15 minutes.
218
+ */
219
+ const watchDrain = (worker, name, attemptId) => Effect.sleep(drainPollInterval).pipe(Effect.andThen(isDraining(worker, name)), Effect.repeat({ until: (draining) => draining }), Effect.andThen(session.drain(attemptId, DRAIN_MESSAGE)), Effect.tapError((error) => Effect.logError("Could not drain the Attempt whose sandbox the platform stops", {
220
+ attemptId,
221
+ worker: worker.address.name,
222
+ sandbox: name,
223
+ message: error.message,
224
+ })), Effect.ignore);
225
+ /** Stops every command that still runs in the sandbox, which reads its process files. */
226
+ const stopCommands = Effect.fn("CloudflareExecutor.stopCommands")(function* (operation, worker, name) {
227
+ yield* Effect.annotateCurrentSpan({ worker: worker.address.name, sandbox: name });
228
+ const { stopped } = yield* worker.client
229
+ .stop(name)
230
+ .pipe(Effect.mapError(operationError(operation)));
231
+ yield* Effect.logInfo("Confirmed no command of the Attempt runs in its sandbox", {
232
+ worker: worker.address.name,
233
+ sandbox: name,
234
+ stopped,
235
+ });
236
+ });
237
+ /** Returns whether `path` exists in the sandbox. */
238
+ const exists = Effect.fn("CloudflareExecutor.exists")(function* (operation, worker, name, path) {
239
+ const inspected = yield* commands.inspect(operation, worker.shellFor(name), "test", [
240
+ "-e",
241
+ path,
242
+ ]);
243
+ if (inspected.exitCode === 0)
244
+ return true;
245
+ if (inspected.exitCode === 1)
246
+ return false;
247
+ return yield* new ExecutorOperationError({
248
+ operation,
249
+ message: `Can't inspect sandbox ${name}`,
250
+ cause: new Error(inspected.stderr.trim() || `test exited with ${String(inspected.exitCode)}`),
251
+ });
252
+ });
253
+ /**
254
+ * Creates the sandbox `name` for `attempt` from the Worker's image without credentials, so
255
+ * that it reaches neither GitHub nor a provider until `acquireSandbox` grants credentials.
256
+ */
257
+ const createSandbox = Effect.fn("CloudflareExecutor.createSandbox")(function* (operation, worker, name, attempt) {
258
+ const fields = {
259
+ worker: worker.address.name,
260
+ sandbox: name,
261
+ attemptId: attempt,
262
+ image: worker.address.image,
263
+ instanceType: worker.address.instanceType,
264
+ };
265
+ yield* Effect.annotateCurrentSpan(fields);
266
+ yield* worker.client
267
+ .create(name, new CreateSandbox({
268
+ owner: worker.owner,
269
+ attempt,
270
+ image: worker.address.image,
271
+ instanceType: worker.address.instanceType,
272
+ credentials: noCredentials(yield* issueTime),
273
+ }))
274
+ .pipe(Effect.mapError(operationError(operation)));
275
+ yield* Effect.logInfo("Created the sandbox without credentials", fields);
276
+ });
277
+ /**
278
+ * Gives the Attempt's existing sandbox a new installation token and the provider keys, and
279
+ * publishes a new token before each expiry until the caller's scope closes. Then revokes the
280
+ * sandbox's credentials, before the caller holds or destroys the sandbox, so a sandbox that
281
+ * stays, or whose destroy fails, holds none.
282
+ */
283
+ const acquireSandbox = Effect.fn("CloudflareExecutor.acquireSandbox")(function* (worker, name, attemptId, repository, permissions, operation) {
284
+ const fields = { worker: worker.address.name, sandbox: name, attemptId, repository };
285
+ yield* Effect.annotateCurrentSpan(fields);
286
+ yield* Effect.addFinalizer(() => issueTime.pipe(Effect.flatMap((issuedAt) => worker.client.credentials(name, noCredentials(issuedAt))), Effect.tap(() => Effect.logInfo("Revoked the sandbox's credentials", fields)), Effect.tapError((error) => Effect.logError("Could not revoke the sandbox's credentials; the installation token expires within the hour", { ...fields, message: error.message })), Effect.ignore));
287
+ const publish = (token) => issueTime.pipe(Effect.flatMap((issuedAt) => worker.client.credentials(name, credentialsFor(worker.address, token, issuedAt))), Effect.mapError(operationError(operation)));
288
+ const credential = yield* renewableCredential(credentialProvider.issue, repository, attemptId, permissions, ({ value }) => publish(value));
289
+ return { refresh: credential.refresh(), scrub: credential.scrub };
290
+ });
291
+ /** Runs git in the sandbox with the handlers' environment and `author` as author and committer. */
292
+ const sandboxGit = (operation, worker, name, access, author) => (args) => Option.match(access, { onNone: () => Effect.void, onSome: ({ refresh }) => refresh }).pipe(Effect.andThen(commands.run(operation, worker.shellFor(name), "git", args, {
293
+ environment: {
294
+ ...sandboxEnvironment(worker),
295
+ ...Option.match(author, { onNone: () => ({}), onSome: commitIdentity }),
296
+ },
297
+ scrub: Option.match(access, {
298
+ onNone: () => (text) => text,
299
+ onSome: ({ scrub }) => scrub,
300
+ }),
301
+ })));
302
+ const harvestCommand = (operation, worker, name, worktree, access) => (command, args) => access.refresh.pipe(Effect.andThen(commands.run(operation, worker.shellFor(name), command, args, {
303
+ cwd: worktree,
304
+ environment: sandboxEnvironment(worker),
305
+ scrub: access.scrub,
306
+ })), Effect.map((stdout) => (stdout === "" ? [] : stdout.replace(/\n$/u, "").split("\n"))));
307
+ /**
308
+ * Stops the commands in `attempt`'s sandbox, salvages its checkout with a new credential,
309
+ * records the salvage, and removes the checkout once its work is pushed. Returns whether the
310
+ * salvage kept unpushed work in the sandbox.
311
+ */
312
+ const reconcileCheckout = Effect.fn("CloudflareExecutor.reconcileCheckout")(function* (attempt, worker, name, record, commitAuthor) {
313
+ yield* Effect.annotateCurrentSpan({
314
+ attemptId: attempt.attemptId,
315
+ worker: attempt.worker,
316
+ sandbox: name,
317
+ });
318
+ const layout = layoutFor(attempt.repository, attempt.attemptId);
319
+ const retained = yield* Ref.make(false);
320
+ const recordSalvage = (salvage) => Ref.set(retained, salvage._tag !== "Pushed").pipe(Effect.andThen(record(salvage)));
321
+ const reconcile = (git) => recoverWorktree({
322
+ attempt,
323
+ host: name,
324
+ worktree: layout.worktree,
325
+ git,
326
+ removeCheckout: Effect.asVoid(git(["--git-dir", layout.cache, "worktree", "remove", "--force", layout.worktree])),
327
+ }, recordSalvage);
328
+ if (attempt.succeeded || attempt.permissions === "read") {
329
+ yield* stopCommands("cleanup", worker, name);
330
+ yield* reconcile(sandboxGit("cleanup", worker, name, Option.none(), Option.none()));
331
+ return false;
332
+ }
333
+ const author = yield* commitAuthor;
334
+ yield* Effect.scoped(Effect.gen(function* () {
335
+ const access = yield* acquireSandbox(worker, name, attempt.attemptId, attempt.repository, attempt.permissions, "cleanup");
336
+ yield* stopCommands("cleanup", worker, name);
337
+ yield* reconcile(sandboxGit("cleanup", worker, name, Option.some(access), Option.some(author)));
338
+ }));
339
+ return yield* Ref.get(retained);
340
+ });
341
+ /**
342
+ * Destroys every sandbox of `worker` that this process doesn't use and that holds no
343
+ * checkout: one whose container stopped, which lost its disk, or one without an Attempt
344
+ * checkout, such as a failed preparation's or a preflight's. A sandbox with a checkout stays
345
+ * for durable recovery.
346
+ */
347
+ const reconcileOrphans = Effect.fn("CloudflareExecutor.reconcileOrphans")(function* (worker) {
348
+ const fields = { worker: worker.address.name, owner: worker.owner };
349
+ yield* Effect.annotateCurrentSpan(fields);
350
+ const { sandboxes } = yield* worker.client.list.pipe(Effect.mapError(operationError("cleanup")));
351
+ const candidates = sandboxes.filter(({ name }) => !active.has(name));
352
+ const destroyed = yield* Effect.forEach(candidates, (sandbox) => Effect.gen(function* () {
353
+ const holdsCheckout = sandbox.running &&
354
+ (yield* commands
355
+ .inspect("cleanup", worker.shellFor(sandbox.name), "sh", [
356
+ "-c",
357
+ 'test -n "$(ls -A "$1" 2>/dev/null)"',
358
+ "sh",
359
+ `${WORKSPACE}/attempts`,
360
+ ])
361
+ .pipe(Effect.map(({ exitCode }) => exitCode !== 1)));
362
+ if (holdsCheckout || active.has(sandbox.name)) {
363
+ yield* Effect.logWarning("Keeping a sandbox that holds a checkout for its Attempt's durable recovery", { ...fields, sandbox: sandbox.name, attemptId: sandbox.attempt });
364
+ return false;
365
+ }
366
+ yield* destroySandbox(worker, sandbox.name, sandbox.running
367
+ ? "an orphan sandbox without a checkout"
368
+ : "an orphan sandbox whose container stopped");
369
+ return true;
370
+ }).pipe(Effect.tapError((error) => Effect.logWarning("Could not reconcile an orphan sandbox; it stays until the next start", {
371
+ ...fields,
372
+ sandbox: sandbox.name,
373
+ message: error.message,
374
+ })), Effect.orElseSucceed(() => false)), { concurrency: 4 });
375
+ yield* Effect.logInfo("Reconciled the Worker's orphan sandboxes", {
376
+ ...fields,
377
+ listed: sandboxes.length,
378
+ destroyed: destroyed.filter(Boolean).length,
379
+ });
380
+ });
381
+ // Once per Worker per process; a failure is logged and the next start retries it.
382
+ const orphansReconciled = yield* Cache.make({
383
+ capacity: Number.POSITIVE_INFINITY,
384
+ lookup: (name) => workerFor("cleanup", name).pipe(Effect.flatMap(reconcileOrphans), Effect.tapError((error) => Effect.logWarning("Could not list the Worker's sandboxes; orphans stay until the next start", {
385
+ worker: name,
386
+ message: error.message,
387
+ })), Effect.ignore),
388
+ });
389
+ const recover = Effect.fn("CloudflareExecutor.recover")(function* (attempt, record, commitAuthor) {
390
+ yield* Effect.annotateCurrentSpan(recoveryFields(attempt));
391
+ yield* session.stopForRecovery(attempt);
392
+ const worker = yield* workerFor("cleanup", attempt.worker);
393
+ const name = sandboxName(worker.owner, attempt.attemptId);
394
+ yield* Effect.scoped(Effect.gen(function* () {
395
+ yield* claim(name);
396
+ yield* Cache.get(orphansReconciled, attempt.worker);
397
+ const summary = yield* worker.client
398
+ .get(name)
399
+ .pipe(Effect.mapError(operationError("cleanup")));
400
+ const layout = layoutFor(attempt.repository, attempt.attemptId);
401
+ const present = Option.exists(summary, ({ running }) => running) &&
402
+ (yield* exists("cleanup", worker, name, layout.worktree));
403
+ yield* session.recoverPostedComments(attempt, present
404
+ ? Option.some((spec, windows) => Effect.scoped(Effect.gen(function* () {
405
+ const access = yield* acquireSandbox(worker, name, attempt.attemptId, attempt.repository, attempt.permissions, "cleanup");
406
+ return yield* harvestPostedComments(spec, harvestCommand("cleanup", worker, name, layout.worktree, access), windows);
407
+ })))
408
+ : Option.none(), record);
409
+ if (Option.isNone(summary)) {
410
+ yield* Effect.logInfo("The Attempt's sandbox is gone; nothing to reconcile", {
411
+ attemptId: attempt.attemptId,
412
+ sandbox: name,
413
+ });
414
+ return;
415
+ }
416
+ if (!present) {
417
+ yield* destroySandbox(worker, name, "its checkout is gone");
418
+ return;
419
+ }
420
+ const retained = yield* reconcileCheckout(attempt, worker, name, record.salvage, commitAuthor);
421
+ if (retained) {
422
+ yield* Effect.logWarning("Keeping the sandbox, whose checkout holds unpushed work", {
423
+ attemptId: attempt.attemptId,
424
+ sandbox: name,
425
+ });
426
+ yield* holdSandbox(worker, name, "its checkout holds unpushed work");
427
+ }
428
+ else
429
+ yield* destroySandbox(worker, name, "its checkout was reconciled");
430
+ }));
431
+ });
432
+ const preflight = Effect.fn("CloudflareExecutor.preflight")(function* (workerName, brains, _maxConcurrentAttempts) {
433
+ yield* Effect.annotateCurrentSpan({ worker: workerName });
434
+ const worker = yield* workerFor("preflight", workerName);
435
+ const { address } = worker;
436
+ const health = yield* worker.client.health.pipe(Effect.mapError((error) => error.refused
437
+ ? new WorkerCapabilityError({
438
+ worker: workerName,
439
+ capability: "credentials",
440
+ message: `The Cloudflare Worker at ${address.workerUrl.origin} refused the Access service token of cloudflare worker "${workerName}". Check the variables that access_client_id_env and access_client_secret_env name, and the Worker's ACCESS_CLIENT_ID and ACCESS_CLIENT_SECRET secrets.`,
441
+ cause: error,
442
+ })
443
+ : operationError("preflight")(error)));
444
+ if (!health.images.includes(address.image))
445
+ return yield* new WorkerCapabilityError({
446
+ worker: workerName,
447
+ capability: "toolchain",
448
+ message: `The Cloudflare Worker at ${address.workerUrl.origin} has no image "${address.image}", which cloudflare worker "${workerName}" names; it has ${health.images.join(", ") || "none"}.`,
449
+ cause: new Error(`images: ${health.images.join(",")}`),
450
+ });
451
+ const distinct = Arr.dedupe(brains);
452
+ for (const brain of distinct) {
453
+ const gap = brainGap(address, brain);
454
+ if (Option.isSome(gap))
455
+ return yield* gap.value;
456
+ }
457
+ if (distinct.length > 0) {
458
+ const name = sandboxName(worker.owner, PREFLIGHT_ATTEMPT);
459
+ yield* Effect.scoped(Effect.gen(function* () {
460
+ yield* claim(name);
461
+ yield* Effect.addFinalizer(() => destroySandbox(worker, name, "preflight checked the image").pipe(Effect.tapError((error) => Effect.logWarning("Could not destroy the preflight sandbox; the next start reconciles it", {
462
+ worker: workerName,
463
+ message: error.message,
464
+ })), Effect.ignore));
465
+ yield* createSandbox("preflight", worker, name, PREFLIGHT_ATTEMPT);
466
+ yield* verifyHarnessReadiness(workerName, Option.none(), distinct, versionProbe("preflight", commands, worker.shellFor(name)), logins);
467
+ }));
468
+ }
469
+ yield* Effect.logInfo("Cloudflare Worker preflight verified", {
470
+ worker: workerName,
471
+ workerUrl: address.workerUrl.origin,
472
+ image: address.image,
473
+ protocol: health.protocol,
474
+ providers: worker.providers.join(","),
475
+ });
476
+ // Each Attempt has a sandbox of its own, so none shares a machine with another.
477
+ return fullPreflight;
478
+ }, logPreflightFailure);
479
+ const prepare = Effect.fn("CloudflareExecutor.prepare")(function* (request) {
480
+ const fields = {
481
+ attemptId: request.attemptId,
482
+ worker: request.worker,
483
+ repository: request.repository,
484
+ loop: request.loop,
485
+ permissions: request.permissions,
486
+ reattach: request.reattach,
487
+ };
488
+ yield* Effect.annotateCurrentSpan(fields);
489
+ const worker = yield* workerFor("prepare", request.worker);
490
+ const gap = brainGap(worker.address, request.agent.brain);
491
+ if (Option.isSome(gap)) {
492
+ yield* Effect.logWarning("Refused an Attempt whose brain the sandbox can't run", {
493
+ ...fields,
494
+ message: gap.value.message,
495
+ });
496
+ return yield* gap.value;
497
+ }
498
+ const name = sandboxName(worker.owner, request.attemptId);
499
+ const layout = layoutFor(request.repository, request.attemptId);
500
+ const shell = worker.shellFor(name);
501
+ yield* claim(name);
502
+ yield* Cache.get(orphansReconciled, request.worker);
503
+ yield* Effect.logInfo(request.reattach
504
+ ? "Reattaching the Attempt to its cloudflare sandbox"
505
+ : "Preparing cloudflare Attempt sandbox", { ...fields, sandbox: name, worktree: layout.worktree });
506
+ // A reattachment reuses its sandbox and checkout before any credential is granted.
507
+ const reattached = request.reattach
508
+ ? Option.some(yield* Effect.gen(function* () {
509
+ const summary = yield* worker.client
510
+ .get(name)
511
+ .pipe(Effect.mapError(operationError("prepare")));
512
+ if (!Option.exists(summary, ({ running }) => running))
513
+ return yield* new ExecutorOperationError({
514
+ operation: "prepare",
515
+ message: "The reattached attempt's sandbox no longer runs, so its checkout is gone",
516
+ cause: new Error(name),
517
+ });
518
+ const { cwd, spec } = yield* session.storedRequest(request.attemptId);
519
+ if (cwd !== layout.worktree)
520
+ return yield* new ExecutorOperationError({
521
+ operation: "prepare",
522
+ message: "The reattached attempt's checkout isn't in its cloudflare sandbox",
523
+ cause: new Error(cwd),
524
+ });
525
+ if (!(yield* exists("prepare", worker, name, layout.worktree)))
526
+ return yield* new ExecutorOperationError({
527
+ operation: "prepare",
528
+ message: "The reattached attempt's checkout no longer exists",
529
+ cause: new Error(layout.worktree),
530
+ });
531
+ yield* stopCommands("prepare", worker, name);
532
+ return spec;
533
+ }))
534
+ : Option.none();
535
+ /** Set when the sandbox must stay: its checkout is kept, or its work isn't all pushed. */
536
+ const keep = yield* Ref.make(false);
537
+ const salvaged = yield* Deferred.make();
538
+ // Added before `acquire`, so it runs last: after the checkout's release, and after
539
+ // `acquireSandbox` revoked the credentials, so a sandbox that stays holds none.
540
+ yield* Effect.addFinalizer(() => Effect.gen(function* () {
541
+ const salvage = (yield* Deferred.isDone(salvaged))
542
+ ? Option.some(yield* Effect.exit(Deferred.await(salvaged)))
543
+ : Option.none();
544
+ const retained = (yield* Ref.get(keep)) ||
545
+ Option.match(salvage, {
546
+ // Preparation failed before it acquired the checkout. A reattachment's checkout
547
+ // existed before, with the Attempt's work, and the engine's recovery salvages it.
548
+ onNone: () => request.reattach,
549
+ onSome: (exit) => Exit.isFailure(exit) || Option.exists(exit.value, ({ _tag }) => _tag !== "Pushed"),
550
+ });
551
+ if (retained) {
552
+ yield* Effect.logInfo("Keeping the Attempt's sandbox with its checkout", {
553
+ ...fields,
554
+ sandbox: name,
555
+ salvaged: Option.isSome(salvage),
556
+ });
557
+ yield* holdSandbox(worker, name, "the Attempt's checkout stays");
558
+ return;
559
+ }
560
+ yield* destroySandbox(worker, name, "the Attempt's resources were released").pipe(Effect.tapError((error) => Effect.logError("Could not destroy the Attempt's sandbox; the next start reconciles it", { ...fields, sandbox: name, message: error.message })), Effect.ignore);
561
+ }));
562
+ const acquire = Effect.gen(function* () {
563
+ // A new sandbox's harness is checked before any credential is granted; a reattached
564
+ // sandbox is the container that was checked when it was created.
565
+ if (!request.reattach) {
566
+ yield* createSandbox("prepare", worker, name, request.attemptId);
567
+ yield* verifyHarnessReadiness(request.worker, Option.none(), [request.agent.brain], versionProbe("prepare", commands, shell), logins);
568
+ }
569
+ const access = yield* acquireSandbox(worker, name, request.attemptId, request.repository, request.permissions, "prepare");
570
+ const host = {
571
+ removePath: (operation, target) => Effect.asVoid(commands.run(operation, shell, "rm", ["-rf", target])),
572
+ cacheExists: exists("prepare", worker, name, layout.cache),
573
+ git: (operation, args) => sandboxGit(operation, worker, name, Option.some(access), Option.some(request.commitAuthor))(args),
574
+ };
575
+ const ops = {
576
+ ...host,
577
+ ensureRoots: Effect.asVoid(commands.run("prepare", shell, "mkdir", [
578
+ "-p",
579
+ `${WORKSPACE}/repos`,
580
+ `${WORKSPACE}/attempts`,
581
+ ])),
582
+ promoteStaging: Effect.asVoid(commands.run("prepare", shell, "mv", [layout.staging, layout.cache])),
583
+ checkout: linkedWorktree(host, layout, (target, content) => Effect.asVoid(commands.run("prepare", shell, "sh", [
584
+ "-c",
585
+ WRITE_EXECUTABLE_SCRIPT,
586
+ "sh",
587
+ target,
588
+ content,
589
+ ]))),
590
+ };
591
+ const checkout = Option.match(reattached, {
592
+ onNone: () => provisionWorktree({
593
+ request,
594
+ url: `https://github.com/${request.repository}.git`,
595
+ host: name,
596
+ layout,
597
+ ops,
598
+ }),
599
+ onSome: (spec) => reattachWorktree({
600
+ request,
601
+ host: name,
602
+ layout: { worktree: layout.worktree },
603
+ ops,
604
+ spec,
605
+ }),
606
+ });
607
+ const releaseWorkspace = Effect.fn("CloudflareExecutor.releaseWorkspace")(function* (resource, exit) {
608
+ yield* Effect.annotateCurrentSpan({ ...fields, sandbox: name });
609
+ // Closes the checkout scope only if stopping the sandbox's commands succeeds.
610
+ yield* stopCommands("cleanup", worker, name);
611
+ yield* Scope.close(resource.scope, exit);
612
+ yield* Deferred.done(salvaged, yield* Effect.exit(resource.provisioned.salvaged));
613
+ }, (effect) => effect.pipe(Effect.tapError((error) => Effect.logError("Stopping the sandbox's commands failed; its checkout stays for recovery", {
614
+ ...fields,
615
+ sandbox: name,
616
+ message: error.message,
617
+ }).pipe(Effect.andThen(Ref.set(keep, true)))), Effect.ignore,
618
+ // Completes the salvage result if the stop failed before the checkout scope closed.
619
+ Effect.andThen(Deferred.succeed(salvaged, Option.none()))));
620
+ const { provisioned } = yield* Effect.acquireRelease(Effect.uninterruptibleMask((restore) => Effect.gen(function* () {
621
+ const scope = yield* Scope.make();
622
+ const provisioned = yield* restore(checkout).pipe(Scope.provide(scope), Effect.onError((cause) => Scope.close(scope, Exit.failCause(cause))));
623
+ return { provisioned, scope };
624
+ })), releaseWorkspace, { interruptible: true });
625
+ return { provisioned, access };
626
+ });
627
+ const { provisioned, access } = yield* acquire.pipe(Effect.mapError((cause) => isExecutorError(cause)
628
+ ? cause
629
+ : new ExecutorOperationError({
630
+ operation: "prepare",
631
+ message: "Can't prepare the cloudflare attempt sandbox",
632
+ cause,
633
+ })));
634
+ const prepared = provisioned.workspace;
635
+ yield* Effect.logInfo("Cloudflare Attempt sandbox ready", {
636
+ ...fields,
637
+ sandbox: name,
638
+ baseCommit: prepared.spec.baseCommit,
639
+ worktree: layout.worktree,
640
+ });
641
+ const keptWith = (kept) => Ref.set(keep, true).pipe(Effect.andThen(kept));
642
+ const launchIn = (io) => session.launch(prepared, io, {
643
+ host: shell,
644
+ machine: Option.none(),
645
+ environment: childEnvironment(sandboxEnvironment(worker), request.commitAuthor, provisioned.gitHooks),
646
+ gatewayCredential: Effect.succeed(gatewayCredential(worker, prepared.spec.agent.brain.harness)),
647
+ harvest: (windows) => harvestFacts(prepared.spec, harvestCommand("launch", worker, name, prepared.worktree, access), windows).pipe(Effect.mapError((error) => new InfraFailure({
648
+ message: `Can't harvest the attempt's checkout: ${error.message}`,
649
+ cause: error,
650
+ }))),
651
+ harvestComments: (windows) => harvestPostedComments(prepared.spec, harvestCommand("launch", worker, name, prepared.worktree, access), windows),
652
+ keepForReattach: keptWith(provisioned.keepForReattach),
653
+ keepUntilStopped: keptWith(provisioned.keepUntilStopped),
654
+ reconcileKept: (record) => Effect.gen(function* () {
655
+ const summary = yield* worker.client
656
+ .get(name)
657
+ .pipe(Effect.mapError(operationError("cleanup")));
658
+ if (!Option.exists(summary, ({ running }) => running)) {
659
+ yield* Effect.logInfo("The kept sandbox is gone or stopped; nothing to reconcile", {
660
+ ...fields,
661
+ sandbox: name,
662
+ });
663
+ if (Option.isSome(summary))
664
+ yield* destroySandbox(worker, name, "its container stopped");
665
+ return;
666
+ }
667
+ const retained = yield* reconcileCheckout(keptCheckoutRecovery(request), worker, name, record, Effect.succeed(request.commitAuthor));
668
+ if (retained)
669
+ yield* holdSandbox(worker, name, "its kept checkout holds unpushed work");
670
+ else
671
+ yield* destroySandbox(worker, name, "its kept checkout was reconciled");
672
+ }),
673
+ });
674
+ return {
675
+ workspace: prepared,
676
+ launch: (io) => Effect.scoped(Effect.gen(function* () {
677
+ yield* watchDrain(worker, name, request.attemptId).pipe(Effect.forkScoped);
678
+ return yield* launchIn(io);
679
+ })),
680
+ salvaged: Deferred.await(salvaged),
681
+ recordSalvage: provisioned.recordSalvage,
682
+ };
683
+ }, session.prepare);
684
+ return Executor.of({
685
+ kind: "cloudflare",
686
+ recover,
687
+ cancel: session.cancel,
688
+ intervene: session.intervene,
689
+ isLive: session.isLive,
690
+ outcome: session.outcome,
691
+ preflight,
692
+ prepare,
693
+ sweep: Effect.void,
694
+ });
695
+ });
696
+ /**
697
+ * The executor for `cloudflare` Workers. Each Attempt runs in its own sandbox, which the
698
+ * Worker deployed from `@usine/cloudflare` creates; its Pi parent is a durable task in this
699
+ * process (`AttemptRuntime`), and each child and git command runs in the sandbox through the
700
+ * sandbox shell. A live Attempt reattaches to its sandbox after an orchestrator restart.
701
+ * Requires Node.
702
+ */
703
+ export const layer = (options) => Layer.effect(Executor, makeCloudflareExecutor(options));
704
+ //# sourceMappingURL=cloudflare.js.map