@bevel-software/ael 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +13 -0
  2. package/README.md +32 -3
  3. package/bin/bevel-agent.mjs +15 -0
  4. package/executor-entrypoint.sh +41 -0
  5. package/executor.Dockerfile +37 -0
  6. package/package.json +61 -6
  7. package/src/agent/agent-file.ts +242 -0
  8. package/src/cli/main.ts +81 -0
  9. package/src/cli/run-local.ts +224 -0
  10. package/src/cli/run-step.ts +147 -0
  11. package/src/executor/activities.ts +295 -0
  12. package/src/executor/docker-cli.ts +148 -0
  13. package/src/executor/driver.ts +80 -0
  14. package/src/executor/harness-mount.ts +66 -0
  15. package/src/executor/in-place.ts +180 -0
  16. package/src/executor/main.ts +230 -0
  17. package/src/executor/personal.ts +355 -0
  18. package/src/executor/slots.ts +60 -0
  19. package/src/harness/claude-code.ts +397 -0
  20. package/src/harness/pi-in-process.ts +444 -0
  21. package/src/harness/session-state.ts +86 -0
  22. package/src/harness/stall-guard.ts +97 -0
  23. package/src/index.ts +164 -0
  24. package/src/interpreter/contract.ts +403 -0
  25. package/src/interpreter/dispatcher.workflow.ts +412 -0
  26. package/src/interpreter/interpret.workflow.ts +907 -0
  27. package/src/interpreter/scheduled.workflow.ts +28 -0
  28. package/src/interpreter/workflows.ts +8 -0
  29. package/src/run/contract.ts +150 -0
  30. package/src/run/outcome.ts +63 -0
  31. package/src/run/reporter.ts +127 -0
  32. package/src/run/system-prompt.ts +35 -0
  33. package/src/run/verdict.ts +46 -0
  34. package/src/shared/front-matter.ts +27 -0
  35. package/src/shared/redact.ts +78 -0
  36. package/src/shared/schedule.ts +93 -0
  37. package/src/temporal/payload-codec.ts +84 -0
  38. package/src/transcript/render.ts +106 -0
  39. package/src/workflow/definition.ts +196 -0
  40. package/src/workflow/failure-policy.ts +100 -0
  41. package/src/workflow/parameters.ts +175 -0
  42. package/src/workflow/workflow-file.ts +457 -0
  43. package/src/workitem/node.ts +170 -0
@@ -0,0 +1,148 @@
1
+ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
2
+ import type { ContainerDriver, ContainerRunResult, ContainerRunSpec, ContainerVolume } from './driver.js';
3
+
4
+ /**
5
+ * The container engine through the `docker` command on the executor's host.
6
+ *
7
+ * An image is built from the Dockerfile's text on stdin and nothing else as
8
+ * context (no COPY from the knowledge base, by design). A run container gets
9
+ * its input on stdin, its environment through `--env NAME` with the values in
10
+ * this process's environment (never on the command line), and is stopped
11
+ * with `docker kill` when it outlives its budget. The executor never mounts
12
+ * the engine's socket into a run container.
13
+ *
14
+ * `runOptions` are extra `docker run` options this server's operator set for
15
+ * every step's container (`EXECUTOR_RUN_OPTIONS`; a spec says it is a step's
16
+ * with `operatorOptions`, and the executor's own root command never does): what a step needs that the
17
+ * defaults withhold, such as the relaxed confinement a rootless Docker daemon
18
+ * inside the step needs. They come before the executor's own, so where both
19
+ * name the same thing (the container's name, its user, a limit) the
20
+ * executor's is the one the engine takes.
21
+ */
22
+ export class DockerCliDriver implements ContainerDriver {
23
+ constructor(private readonly runOptions: readonly string[] = []) {}
24
+
25
+ hasImage(ref: string): Promise<boolean> {
26
+ return succeeds(['image', 'inspect', ref]);
27
+ }
28
+
29
+ async pullImage(ref: string): Promise<void> {
30
+ await docker(['pull', '--quiet', ref]);
31
+ }
32
+
33
+ async buildImage(tag: string, dockerfile: string): Promise<void> {
34
+ await docker(['build', '--quiet', '-t', tag, '-'], { stdin: dockerfile });
35
+ }
36
+
37
+ async ensureVolume(name: string, labels: Record<string, string>): Promise<boolean> {
38
+ if (await succeeds(['volume', 'inspect', name])) return false;
39
+ await docker(['volume', 'create', ...labelArgs(labels), name]);
40
+ return true;
41
+ }
42
+
43
+ async listVolumes(labelKey: string): Promise<ContainerVolume[]> {
44
+ const { stdout } = await docker(['volume', 'ls', '--quiet', '--filter', `label=${labelKey}`]);
45
+ const names = stdout.split('\n').map((line) => line.trim()).filter(Boolean);
46
+ if (names.length === 0) return [];
47
+ const inspected = JSON.parse((await docker(['volume', 'inspect', ...names])).stdout) as Array<{ Name: string; CreatedAt?: string; Labels?: Record<string, string> | null }>;
48
+ return inspected.map((v) => ({ name: v.Name, createdAt: (v.CreatedAt && Date.parse(v.CreatedAt)) || 0, labels: v.Labels ?? {} }));
49
+ }
50
+
51
+ async removeVolume(name: string): Promise<void> {
52
+ const args = ['volume', 'rm', name];
53
+ const result = await start(args).done;
54
+ if (result.code !== 0 && !/no such volume/i.test(result.stderr)) throw failure(args, result);
55
+ }
56
+
57
+ async run(spec: ContainerRunSpec): Promise<ContainerRunResult> {
58
+ // The operator's options first: where one names something the executor sets itself (the name it stops the
59
+ // container by, the user, the limits), the engine takes the later one, which is the executor's.
60
+ const args = ['run', ...(spec.operatorOptions ? this.runOptions : []), '--rm', '--interactive', '--name', spec.name, '--user', spec.user, '--workdir', spec.workdir];
61
+ if (spec.limits.memory) args.push('--memory', spec.limits.memory);
62
+ if (spec.limits.cpus) args.push('--cpus', spec.limits.cpus);
63
+ if (spec.limits.pids !== undefined) args.push('--pids-limit', String(spec.limits.pids));
64
+ args.push(...labelArgs(spec.labels));
65
+ for (const m of spec.mounts) args.push('--mount', `type=${m.kind},source=${m.source},target=${m.target}${m.readonly ? ',readonly' : ''}`);
66
+ // `--env NAME` with no value takes it from the client's environment.
67
+ for (const name of Object.keys(spec.env)) args.push('--env', name);
68
+ args.push(spec.image, ...spec.command);
69
+
70
+ const { child, done } = start(args, { stdin: spec.stdin, env: { ...process.env, ...spec.env }, onOutput: spec.onAlive });
71
+ let timedOut = false;
72
+ const stop = () =>
73
+ void start(['kill', spec.name]).done.then((killed) => {
74
+ // No container by that name (it never started): end the client instead.
75
+ if (killed.code !== 0) child.kill('SIGKILL');
76
+ });
77
+ const timer = setTimeout(() => {
78
+ timedOut = true;
79
+ stop();
80
+ }, spec.timeoutMs);
81
+ // A cancelled run: the container goes the same way it does on timeout.
82
+ if (spec.signal?.aborted) stop();
83
+ else spec.signal?.addEventListener('abort', stop, { once: true });
84
+ try {
85
+ const result = await done;
86
+ return { exitCode: result.code, stdout: result.stdout, stderr: result.stderr, timedOut };
87
+ } finally {
88
+ clearTimeout(timer);
89
+ spec.signal?.removeEventListener('abort', stop);
90
+ }
91
+ }
92
+ }
93
+
94
+ interface Output {
95
+ code: number | null;
96
+ stdout: string;
97
+ stderr: string;
98
+ }
99
+
100
+ interface StartOptions {
101
+ /** Written to the process's stdin, which is then closed. */
102
+ stdin?: string;
103
+ env?: NodeJS.ProcessEnv;
104
+ /** Called on every chunk of stdout or stderr. */
105
+ onOutput?: () => void;
106
+ }
107
+
108
+ /** Start `docker <args>`; `done` settles when the process has ended, whatever its exit code. */
109
+ function start(args: string[], options: StartOptions = {}): { child: ChildProcessWithoutNullStreams; done: Promise<Output> } {
110
+ const child = spawn('docker', args, { env: options.env ?? process.env });
111
+ const out: Buffer[] = [];
112
+ const err: Buffer[] = [];
113
+ child.stdout.on('data', (chunk: Buffer) => {
114
+ out.push(chunk);
115
+ options.onOutput?.();
116
+ });
117
+ child.stderr.on('data', (chunk: Buffer) => {
118
+ err.push(chunk);
119
+ options.onOutput?.();
120
+ });
121
+ // The process may end before it has read its input; its exit says what happened.
122
+ child.stdin.on('error', () => undefined);
123
+ child.stdin.end(options.stdin ?? '');
124
+ const done = new Promise<Output>((resolve, reject) => {
125
+ child.once('error', reject);
126
+ child.once('close', (code) => resolve({ code, stdout: Buffer.concat(out).toString('utf8'), stderr: Buffer.concat(err).toString('utf8') }));
127
+ });
128
+ return { child, done };
129
+ }
130
+
131
+ /** Run `docker <args>` to its end; a non-zero exit is an error carrying the engine's message. */
132
+ async function docker(args: string[], options: StartOptions = {}): Promise<Output> {
133
+ const result = await start(args, options).done;
134
+ if (result.code !== 0) throw failure(args, result);
135
+ return result;
136
+ }
137
+
138
+ async function succeeds(args: string[]): Promise<boolean> {
139
+ return (await start(args).done).code === 0;
140
+ }
141
+
142
+ function failure(args: string[], result: Output): Error {
143
+ return new Error(`docker ${args.slice(0, 2).join(' ')} failed (${result.code ?? 'killed'}): ${result.stderr.trim() || result.stdout.trim()}`);
144
+ }
145
+
146
+ function labelArgs(labels: Record<string, string>): string[] {
147
+ return Object.entries(labels).flatMap(([key, value]) => ['--label', `${key}=${value}`]);
148
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * What an executor needs from the container engine on its host, and nothing
3
+ * more: an image comes by pull or by building one Dockerfile's text with no
4
+ * context; a run gets a labelled volume for its workspace; a step is one
5
+ * container that reads its input on stdin and answers on stdout. The host's
6
+ * engine implements it (`docker-cli.ts`); the tests use a fake.
7
+ */
8
+
9
+ export interface ContainerMount {
10
+ /** A host path (`bind`) or a volume name (`volume`). */
11
+ source: string;
12
+ target: string;
13
+ readonly: boolean;
14
+ kind: 'bind' | 'volume';
15
+ }
16
+
17
+ export interface ContainerLimits {
18
+ /** As the engine spells it, e.g. `4g`. */
19
+ memory?: string;
20
+ /** e.g. `2` */
21
+ cpus?: string;
22
+ pids?: number;
23
+ }
24
+
25
+ export interface ContainerRunSpec {
26
+ /** The container's name, unique per step attempt. */
27
+ name: string;
28
+ image: string;
29
+ command: string[];
30
+ /** Written to the process's stdin, then closed. */
31
+ stdin: string;
32
+ /** Passed as the process's environment; never on the command line. */
33
+ env: Record<string, string>;
34
+ mounts: ContainerMount[];
35
+ workdir: string;
36
+ /** `uid:gid` the process runs as; never root. */
37
+ user: string;
38
+ limits: ContainerLimits;
39
+ labels: Record<string, string>;
40
+ /**
41
+ * True for a step's own container: it also gets the options the server's
42
+ * operator set for steps. Absent for the executor's own short commands
43
+ * (handing a new workspace to the run user, as root), which get none.
44
+ */
45
+ operatorOptions?: boolean;
46
+ /** Stop the container at this age; the result then says `timedOut`. */
47
+ timeoutMs: number;
48
+ /** Stop the container when this fires: the run was cancelled. */
49
+ signal?: AbortSignal;
50
+ /** Called whenever the container produces output, for the activity's heartbeat. */
51
+ onAlive?: () => void;
52
+ }
53
+
54
+ export interface ContainerRunResult {
55
+ exitCode: number | null;
56
+ stdout: string;
57
+ stderr: string;
58
+ timedOut: boolean;
59
+ }
60
+
61
+ export interface ContainerVolume {
62
+ name: string;
63
+ /** Epoch milliseconds; 0 when the engine does not say. */
64
+ createdAt: number;
65
+ labels: Record<string, string>;
66
+ }
67
+
68
+ export interface ContainerDriver {
69
+ hasImage(ref: string): Promise<boolean>;
70
+ pullImage(ref: string): Promise<void>;
71
+ /** Build `dockerfile` (its text, nothing else as context) and tag it. */
72
+ buildImage(tag: string, dockerfile: string): Promise<void>;
73
+ /** Create the volume when it does not exist; an existing one is kept as it is. True when this call made it. */
74
+ ensureVolume(name: string, labels: Record<string, string>): Promise<boolean>;
75
+ /** The volumes carrying this label key. */
76
+ listVolumes(labelKey: string): Promise<ContainerVolume[]>;
77
+ /** Remove a volume; one that is already gone is not an error. */
78
+ removeVolume(name: string): Promise<void>;
79
+ run(spec: ContainerRunSpec): Promise<ContainerRunResult>;
80
+ }
@@ -0,0 +1,66 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { hostname } from 'node:os';
3
+ import { promisify } from 'node:util';
4
+
5
+ /** Where the harness bundle is for the engine: a volume by name, or a path on the host. */
6
+ export interface HarnessMount {
7
+ kind: 'bind' | 'volume';
8
+ source: string;
9
+ }
10
+
11
+ /**
12
+ * Where the harness bundle really is, asked of the engine: the mount at
13
+ * `dir` in THIS container, which is what run containers must be handed.
14
+ * The compose file names the volume outright, but a deployment platform
15
+ * may still create it under a name of its own — Coolify did: the executor
16
+ * filled `<app>_bevel-harness` while every run container was handed an
17
+ * empty `bevel-harness` the engine created on first use, and no step could
18
+ * find the harness. The configured name is the fallback for an engine that
19
+ * cannot be asked, and only then.
20
+ */
21
+ export async function discoverHarnessMount(dir: string, inspect: (containerId: string) => Promise<string> = inspectMounts, self: string = hostname()): Promise<HarnessMount | null> {
22
+ let mounts: Array<{ Type?: string; Name?: string; Source?: string; Destination?: string }>;
23
+ try {
24
+ mounts = JSON.parse(await inspect(self)) as typeof mounts;
25
+ } catch {
26
+ return null;
27
+ }
28
+ if (!Array.isArray(mounts)) return null;
29
+ const mount = mounts.find((m) => m.Destination === dir);
30
+ if (!mount) return null;
31
+ if (mount.Type === 'volume' && mount.Name) return { kind: 'volume', source: mount.Name };
32
+ if (mount.Type === 'bind' && mount.Source) return { kind: 'bind', source: mount.Source };
33
+ return null;
34
+ }
35
+
36
+ /** The engine's view of a container's mounts, as JSON. The container id is this container's hostname, the engine's default. */
37
+ async function inspectMounts(containerId: string): Promise<string> {
38
+ const { stdout } = await promisify(execFile)('docker', ['inspect', '--format', '{{json .Mounts}}', containerId]);
39
+ return stdout;
40
+ }
41
+
42
+ /** The label Compose puts on every container of a project: one per deployment, also under Coolify (the application's id). */
43
+ const COMPOSE_PROJECT_LABEL = 'com.docker.compose.project';
44
+
45
+ /**
46
+ * The deployment THIS executor belongs to, asked of the engine: its compose
47
+ * project. Several deployments may share one engine (a host running more
48
+ * than one tenant), and each one's executor must tell its own workspace
49
+ * volumes from the others'. Null when the engine cannot be asked or the
50
+ * container was not started by Compose.
51
+ */
52
+ export async function discoverDeployment(inspect: (containerId: string) => Promise<string> = inspectLabels, self: string = hostname()): Promise<string | null> {
53
+ try {
54
+ const labels = JSON.parse(await inspect(self)) as unknown;
55
+ if (!labels || typeof labels !== 'object' || Array.isArray(labels)) return null;
56
+ const project = (labels as Record<string, unknown>)[COMPOSE_PROJECT_LABEL];
57
+ return typeof project === 'string' && project.trim() ? project.trim() : null;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ async function inspectLabels(containerId: string): Promise<string> {
64
+ const { stdout } = await promisify(execFile)('docker', ['inspect', '--format', '{{json .Config.Labels}}', containerId]);
65
+ return stdout;
66
+ }
@@ -0,0 +1,180 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { mkdir, rm } from 'node:fs/promises';
3
+ import { createRequire } from 'node:module';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { ApplicationFailure } from '@temporalio/activity';
7
+ import { parseOutcomeLine } from '../cli/run-step.js';
8
+ import type { AgentStepOutcome, ContainerStepInput } from '../interpreter/contract.js';
9
+ import { Redactor } from '../shared/redact.js';
10
+ import type { Executor } from './activities.js';
11
+
12
+ /**
13
+ * An executor that runs each step where it runs itself: a process of its
14
+ * own, in a directory per run, with no container engine. For a place that IS
15
+ * the environment already and ends with the work — a CI job, whose image,
16
+ * services and network its own configuration chose. The workflow's
17
+ * `environment:` (an image, a Dockerfile) is not used here; the job's is.
18
+ *
19
+ * What a container gave and this does not: the step is not confined from
20
+ * the executor or from the rest of the job. It runs as the same user and
21
+ * sees the same files, so everything in the job is the step's to read.
22
+ * The names in `withhold` are taken out of the step's environment, which
23
+ * keeps the executor's own key out of a session that prints its variables.
24
+ *
25
+ * A run's directory lasts as long as this process's host keeps it: a later
26
+ * job starts with none, and the step is told its workspace is new.
27
+ */
28
+
29
+ export interface InPlaceExecutorOptions {
30
+ /** This executor's own task queue, the one runs are pinned to. */
31
+ queueName: string;
32
+ /** Where each run's workspace directory is made. */
33
+ workspacesDir: string;
34
+ /** Variables of this process the step does not get: the executor's own secrets. */
35
+ withhold?: string[];
36
+ /** The command a step is; `run-step` of this package on this node when absent (a test names another). */
37
+ command?: string[];
38
+ /** How long to pause when a provider refuses the account and names no reset time. */
39
+ defaultPauseMs?: number;
40
+ log?: (line: string) => void;
41
+ now?: () => number;
42
+ /** Says the step is alive: the platform gives a silent one up. */
43
+ heartbeat?: () => void;
44
+ /** The step's cancellation, as a signal its process is stopped on. */
45
+ cancellation?: () => AbortSignal | undefined;
46
+ }
47
+
48
+ const ALIVE_HEARTBEAT_MS = 30_000;
49
+ const STDERR_TAIL = 2_000;
50
+ const STDOUT_KEPT = 4_000_000;
51
+ /** Variables of the job whose values are kept out of a stored reason: the step inherits them, and a crash may print them. */
52
+ const SECRET_NAME = /TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|PRIVATE|_KEY$|^KEY_|APIKEY/i;
53
+ /** A run id as the platform makes them; anything else is not used to name a directory. */
54
+ const RUN_ID = /^[A-Za-z0-9_-]{1,80}$/;
55
+
56
+ function runStepCommand(): string[] {
57
+ const require = createRequire(import.meta.url);
58
+ return [process.execPath, require.resolve('tsx/cli'), fileURLToPath(new URL('../cli/main.ts', import.meta.url)), 'run-step'];
59
+ }
60
+
61
+ export function createInPlaceExecutor(options: InPlaceExecutorOptions): Executor {
62
+ const { queueName } = options;
63
+ const log = options.log ?? (() => undefined);
64
+ const now = options.now ?? (() => Date.now());
65
+ const heartbeat = options.heartbeat ?? (() => undefined);
66
+ const cancellation = options.cancellation ?? (() => undefined);
67
+ const defaultPauseMs = options.defaultPauseMs ?? 30 * 60_000;
68
+ const withhold = new Set(options.withhold ?? []);
69
+ let pausedUntil: number | null = null;
70
+
71
+ const workspaceOf = (runId: string): string | null => (RUN_ID.test(runId) ? path.join(options.workspacesDir, `run-${runId}`) : null);
72
+
73
+ /** One step as a process: its input on stdin, its outcome the marked last line of stdout. */
74
+ function runProcess(input: ContainerStepInput, workspace: string): Promise<{ exitCode: number | null; stdout: string; stderr: string; timedOut: boolean; failed: string | null }> {
75
+ const env: Record<string, string> = {};
76
+ for (const [name, value] of Object.entries(process.env)) if (value !== undefined && !withhold.has(name)) env[name] = value;
77
+ // The session's own files (its settings, its saved conversation) sit with the run, as in a run container.
78
+ Object.assign(env, input.env, { HOME: workspace });
79
+ const command = options.command ?? runStepCommand();
80
+ return new Promise((resolve) => {
81
+ // Its own process group, so the session's children go with it when it is stopped.
82
+ const group = process.platform !== 'win32';
83
+ const child = spawn(command[0]!, command.slice(1), { cwd: workspace, env, stdio: ['pipe', 'pipe', 'pipe'], detached: group });
84
+ let stdout = '';
85
+ let stderr = '';
86
+ let timedOut = false;
87
+ const stopChild = () => {
88
+ try {
89
+ if (group && child.pid) process.kill(-child.pid, 'SIGKILL');
90
+ else child.kill('SIGKILL');
91
+ } catch {
92
+ // already gone
93
+ }
94
+ };
95
+ const timer = setTimeout(() => {
96
+ timedOut = true;
97
+ stopChild();
98
+ }, input.timeoutMs);
99
+ const alive = setInterval(heartbeat, ALIVE_HEARTBEAT_MS);
100
+ const signal = cancellation();
101
+ if (signal?.aborted) stopChild();
102
+ else signal?.addEventListener('abort', stopChild, { once: true });
103
+ const finish = (exitCode: number | null, failed: string | null) => {
104
+ clearTimeout(timer);
105
+ clearInterval(alive);
106
+ signal?.removeEventListener('abort', stopChild);
107
+ resolve({ exitCode, stdout, stderr, timedOut, failed });
108
+ };
109
+ // Decoded by the stream, which keeps a character whose bytes arrive in two chunks whole.
110
+ child.stdout.setEncoding('utf8');
111
+ child.stderr.setEncoding('utf8');
112
+ child.stdout.on('data', (chunk: string) => {
113
+ // The outcome is the last line: the end is kept, however much a step prints before it.
114
+ stdout = (stdout + chunk).slice(-STDOUT_KEPT);
115
+ heartbeat();
116
+ });
117
+ child.stderr.on('data', (chunk: string) => {
118
+ // The tail is all that is read, and a session can print a great deal.
119
+ stderr = (stderr + chunk).slice(-64_000);
120
+ heartbeat();
121
+ });
122
+ // A process that died before reading its input: its exit says so.
123
+ child.stdin.on('error', () => undefined);
124
+ child.on('error', (err) => finish(null, err.message));
125
+ child.on('close', (code) => finish(code, null));
126
+ child.stdin.end(JSON.stringify(input));
127
+ });
128
+ }
129
+
130
+ return {
131
+ pausedUntil: () => (pausedUntil !== null && pausedUntil > now() ? pausedUntil : null),
132
+
133
+ async runContainerStep(input: ContainerStepInput): Promise<AgentStepOutcome> {
134
+ const until = pausedUntil !== null && pausedUntil > now() ? pausedUntil : null;
135
+ if (until !== null) throw ApplicationFailure.retryable(`executor ${queueName} is paused until ${new Date(until).toISOString()}`, 'ExecutorPaused', until);
136
+ const pin = (outcome: AgentStepOutcome): AgentStepOutcome =>
137
+ outcome.kind === 'verdict' || outcome.kind === 'no-verdict' || outcome.kind === 'timeout' || outcome.kind === 'account-limit' || outcome.kind === 'harness-error' ? { ...outcome, executorQueue: queueName } : outcome;
138
+ const workspace = workspaceOf(input.step.runId);
139
+ if (!workspace) return { kind: 'harness-error', threadId: input.threadId, reason: `run id "${input.step.runId}" cannot name a workspace directory` };
140
+ let made: string | undefined;
141
+ try {
142
+ made = await mkdir(workspace, { recursive: true });
143
+ } catch (err) {
144
+ return { kind: 'harness-error', threadId: input.threadId, reason: `the workspace could not be made: ${err instanceof Error ? err.message : String(err)}` };
145
+ }
146
+ // A directory made now for a step that is not the run's first: whatever an earlier step left was in another job.
147
+ const stepInput: ContainerStepInput = made !== undefined && input.step.stepIndex > 1 ? { ...input, freshWorkspace: true } : input;
148
+ if (cancellation()?.aborted) return { kind: 'harness-error', threadId: input.threadId, reason: 'the run was cancelled before the step started' };
149
+
150
+ log(`step "${input.step.stepName}" of run ${input.step.runId} started`);
151
+ const result = await runProcess(stepInput, workspace);
152
+ log(`step "${input.step.stepName}" of run ${input.step.runId} ended`);
153
+ if (result.timedOut) return pin({ kind: 'timeout', threadId: input.threadId, reason: `the step exceeded its budget of ${Math.round(input.timeoutMs / 60_000)} minutes` });
154
+ const outcome = result.failed === null ? parseOutcomeLine(result.stdout) : null;
155
+ if (!outcome) {
156
+ // What the process printed becomes a stored reason: the secrets it was handed are scrubbed from it first,
157
+ // the job's own among them (a CI job token, a registry password), which a container step never saw.
158
+ const inherited = Object.entries(process.env).flatMap(([name, value]) => (value && SECRET_NAME.test(name) ? [value] : []));
159
+ const tail = new Redactor([...Object.values(input.env), input.actorKey.token, ...inherited]).text(result.stderr).trim().slice(-STDERR_TAIL);
160
+ const how = result.failed !== null ? `could not be started: ${result.failed}` : `ended (${result.exitCode ?? 'killed'}) without an outcome${tail ? `: ${tail}` : ''}`;
161
+ return pin({ kind: 'harness-error', threadId: input.threadId, reason: `the step's process ${how}` });
162
+ }
163
+ if (outcome.kind === 'account-limit') {
164
+ pausedUntil = outcome.resetAt ?? now() + defaultPauseMs;
165
+ log(`paused until ${new Date(pausedUntil).toISOString()}: ${outcome.text}`);
166
+ }
167
+ return pin(outcome);
168
+ },
169
+
170
+ async releaseRun(input: { runId: string }): Promise<void> {
171
+ const workspace = workspaceOf(input.runId);
172
+ if (!workspace) return;
173
+ await rm(workspace, { recursive: true, force: true });
174
+ log(`released the workspace of run ${input.runId}`);
175
+ },
176
+
177
+ // Nothing outlives the place this runs in: no volumes to look for.
178
+ sweepVolumes: async () => [],
179
+ };
180
+ }