@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.
- package/LICENSE +661 -21
- package/README.md +116 -19
- package/dist/child-command.d.ts +1 -1
- package/dist/child-command.d.ts.map +1 -1
- package/dist/child-command.js +6 -4
- package/dist/child-command.js.map +1 -1
- package/dist/child-environment.d.ts +19 -0
- package/dist/child-environment.d.ts.map +1 -0
- package/dist/child-environment.js +18 -0
- package/dist/child-environment.js.map +1 -0
- package/dist/claude-login-lock.d.ts +3 -2
- package/dist/claude-login-lock.d.ts.map +1 -1
- package/dist/claude-login-lock.js +10 -4
- package/dist/claude-login-lock.js.map +1 -1
- package/dist/cloudflare/client.d.ts +50 -0
- package/dist/cloudflare/client.d.ts.map +1 -0
- package/dist/cloudflare/client.js +108 -0
- package/dist/cloudflare/client.js.map +1 -0
- package/dist/cloudflare/cloudflare.d.ts +17 -0
- package/dist/cloudflare/cloudflare.d.ts.map +1 -0
- package/dist/cloudflare/cloudflare.js +704 -0
- package/dist/cloudflare/cloudflare.js.map +1 -0
- package/dist/cloudflare/model.d.ts +42 -0
- package/dist/cloudflare/model.d.ts.map +1 -0
- package/dist/cloudflare/model.js +2 -0
- package/dist/cloudflare/model.js.map +1 -0
- package/dist/cloudflare/shell/environment.d.ts +12 -0
- package/dist/cloudflare/shell/environment.d.ts.map +1 -0
- package/dist/cloudflare/shell/environment.js +12 -0
- package/dist/cloudflare/shell/environment.js.map +1 -0
- package/dist/cloudflare/shell/shell.d.ts +2 -0
- package/dist/cloudflare/shell/shell.d.ts.map +1 -0
- package/dist/cloudflare/shell/shell.js +155 -0
- package/dist/cloudflare/shell/shell.js.map +1 -0
- package/dist/durable-session.d.ts +21 -3
- package/dist/durable-session.d.ts.map +1 -1
- package/dist/durable-session.js +69 -10
- package/dist/durable-session.js.map +1 -1
- package/dist/exe-dev/commands.d.ts +1 -1
- package/dist/exe-dev/commands.d.ts.map +1 -1
- package/dist/exe-dev/exe-dev.d.ts +1 -1
- package/dist/exe-dev/exe-dev.d.ts.map +1 -1
- package/dist/exe-dev/exe-dev.js +9 -1
- package/dist/exe-dev/exe-dev.js.map +1 -1
- package/dist/exe-dev/preflight.d.ts +2 -2
- package/dist/exe-dev/preflight.d.ts.map +1 -1
- package/dist/exe-dev/preflight.js +5 -4
- package/dist/exe-dev/preflight.js.map +1 -1
- package/dist/executor.d.ts +164 -15
- package/dist/executor.d.ts.map +1 -1
- package/dist/executor.js +100 -3
- package/dist/executor.js.map +1 -1
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +1 -0
- package/dist/guard.js.map +1 -1
- package/dist/harness-preflight.d.ts +9 -8
- package/dist/harness-preflight.d.ts.map +1 -1
- package/dist/harness-preflight.js +15 -10
- package/dist/harness-preflight.js.map +1 -1
- package/dist/local/local.d.ts +1 -1
- package/dist/local/local.d.ts.map +1 -1
- package/dist/local/local.js +76 -37
- package/dist/local/local.js.map +1 -1
- package/dist/memory-preflight.d.ts +3 -1
- package/dist/memory-preflight.d.ts.map +1 -1
- package/dist/memory-preflight.js +1 -1
- package/dist/memory-preflight.js.map +1 -1
- package/dist/pre-push-hook.d.ts +1 -1
- package/dist/pre-push-hook.js +1 -1
- package/dist/prepared-attempt.d.ts +8 -0
- package/dist/prepared-attempt.d.ts.map +1 -1
- package/dist/prepared-attempt.js.map +1 -1
- package/dist/provision.d.ts +64 -13
- package/dist/provision.d.ts.map +1 -1
- package/dist/provision.js +99 -42
- package/dist/provision.js.map +1 -1
- package/dist/remote/commands.d.ts +1 -1
- package/dist/remote/commands.d.ts.map +1 -1
- package/dist/remote/commands.js +1 -1
- package/dist/remote/commands.js.map +1 -1
- package/dist/remote/container.d.ts +90 -0
- package/dist/remote/container.d.ts.map +1 -0
- package/dist/remote/container.js +480 -0
- package/dist/remote/container.js.map +1 -0
- package/dist/remote/liveness.d.ts +1 -1
- package/dist/remote/liveness.d.ts.map +1 -1
- package/dist/remote/liveness.js +6 -4
- package/dist/remote/liveness.js.map +1 -1
- package/dist/remote/machine.d.ts +52 -6
- package/dist/remote/machine.d.ts.map +1 -1
- package/dist/remote/remote.d.ts +11 -7
- package/dist/remote/remote.d.ts.map +1 -1
- package/dist/remote/remote.js +340 -92
- package/dist/remote/remote.js.map +1 -1
- package/dist/remote/sweep.d.ts +3 -1
- package/dist/remote/sweep.d.ts.map +1 -1
- package/dist/remote/sweep.js +2 -1
- package/dist/remote/sweep.js.map +1 -1
- package/dist/salvage.d.ts +18 -10
- package/dist/salvage.d.ts.map +1 -1
- package/dist/salvage.js +123 -29
- package/dist/salvage.js.map +1 -1
- package/dist/ssh/api-keys.d.ts +36 -0
- package/dist/ssh/api-keys.d.ts.map +1 -0
- package/dist/ssh/api-keys.js +72 -0
- package/dist/ssh/api-keys.js.map +1 -0
- package/dist/ssh/codex-homes.d.ts +21 -0
- package/dist/ssh/codex-homes.d.ts.map +1 -0
- package/dist/ssh/codex-homes.js +78 -0
- package/dist/ssh/codex-homes.js.map +1 -0
- package/dist/ssh/container.d.ts +32 -0
- package/dist/ssh/container.d.ts.map +1 -0
- package/dist/ssh/container.js +282 -0
- package/dist/ssh/container.js.map +1 -0
- package/dist/ssh/credentials.d.ts +3 -0
- package/dist/ssh/credentials.d.ts.map +1 -1
- package/dist/ssh/credentials.js +3 -0
- package/dist/ssh/credentials.js.map +1 -1
- package/dist/ssh/gateway/admin.d.ts +1 -1
- package/dist/ssh/gateway/admin.d.ts.map +1 -1
- package/dist/ssh/gateway/admin.js +2 -2
- package/dist/ssh/gateway/admin.js.map +1 -1
- package/dist/ssh/gateway/attachments.d.ts +15 -6
- package/dist/ssh/gateway/attachments.d.ts.map +1 -1
- package/dist/ssh/gateway/attachments.js +39 -25
- package/dist/ssh/gateway/attachments.js.map +1 -1
- package/dist/ssh/gateway/environment.d.ts +21 -2
- package/dist/ssh/gateway/environment.d.ts.map +1 -1
- package/dist/ssh/gateway/environment.js +76 -6
- package/dist/ssh/gateway/environment.js.map +1 -1
- package/dist/ssh/gateway/forward.d.ts +1 -1
- package/dist/ssh/gateway/forward.d.ts.map +1 -1
- package/dist/ssh/gateway/forward.js +1 -1
- package/dist/ssh/gateway/forward.js.map +1 -1
- package/dist/ssh/gateway/preflight.d.ts +51 -12
- package/dist/ssh/gateway/preflight.d.ts.map +1 -1
- package/dist/ssh/gateway/preflight.js +155 -69
- package/dist/ssh/gateway/preflight.js.map +1 -1
- package/dist/ssh/limits.d.ts +60 -0
- package/dist/ssh/limits.d.ts.map +1 -0
- package/dist/ssh/limits.js +84 -0
- package/dist/ssh/limits.js.map +1 -0
- package/dist/ssh/model.d.ts +97 -9
- package/dist/ssh/model.d.ts.map +1 -1
- package/dist/ssh/preflight.d.ts +26 -7
- package/dist/ssh/preflight.d.ts.map +1 -1
- package/dist/ssh/preflight.js +50 -10
- package/dist/ssh/preflight.js.map +1 -1
- package/dist/ssh/provisioner/boat.d.ts +33 -0
- package/dist/ssh/provisioner/boat.d.ts.map +1 -0
- package/dist/ssh/provisioner/boat.js +247 -0
- package/dist/ssh/provisioner/boat.js.map +1 -0
- package/dist/ssh/provisioner/hetzner.d.ts +32 -0
- package/dist/ssh/provisioner/hetzner.d.ts.map +1 -0
- package/dist/ssh/provisioner/hetzner.js +337 -0
- package/dist/ssh/provisioner/hetzner.js.map +1 -0
- package/dist/ssh/provisioner/host-key.d.ts +11 -0
- package/dist/ssh/provisioner/host-key.d.ts.map +1 -0
- package/dist/ssh/provisioner/host-key.js +57 -0
- package/dist/ssh/provisioner/host-key.js.map +1 -0
- package/dist/ssh/provisioner/http.d.ts +45 -0
- package/dist/ssh/provisioner/http.d.ts.map +1 -0
- package/dist/ssh/provisioner/http.js +72 -0
- package/dist/ssh/provisioner/http.js.map +1 -0
- package/dist/ssh/provisioner/machines.d.ts +48 -0
- package/dist/ssh/provisioner/machines.d.ts.map +1 -0
- package/dist/ssh/provisioner/machines.js +686 -0
- package/dist/ssh/provisioner/machines.js.map +1 -0
- package/dist/ssh/provisioner/model.d.ts +92 -0
- package/dist/ssh/provisioner/model.d.ts.map +1 -0
- package/dist/ssh/provisioner/model.js +37 -0
- package/dist/ssh/provisioner/model.js.map +1 -0
- package/dist/ssh/ssh.d.ts +14 -11
- package/dist/ssh/ssh.d.ts.map +1 -1
- package/dist/ssh/ssh.js +94 -27
- package/dist/ssh/ssh.js.map +1 -1
- package/dist/ssh/worker-records.d.ts +18 -0
- package/dist/ssh/worker-records.d.ts.map +1 -0
- package/dist/ssh/worker-records.js +100 -0
- package/dist/ssh/worker-records.js.map +1 -0
- package/dist/ssh/workers.d.ts +34 -7
- package/dist/ssh/workers.d.ts.map +1 -1
- package/dist/ssh/workers.js +92 -18
- package/dist/ssh/workers.js.map +1 -1
- 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
|