@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,224 @@
1
+ import { constants } from 'node:fs';
2
+ import { open, readFile, realpath } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { parseAgentFile, type AgentDefinition } from '../agent/agent-file.js';
5
+ import { PiInProcessHarness } from '../harness/pi-in-process.js';
6
+ import type { Harness, RunEvent } from '../run/contract.js';
7
+ import { parseWorkflowFile } from '../workflow/workflow-file.js';
8
+ import { interpolate, nextAfter, stripReservedKeys, type StepDef, type WorkflowDefinition } from '../workflow/definition.js';
9
+ import { INTERPRETER_STATE_KEYS, MAX_RUN_STEPS } from '../interpreter/contract.js';
10
+ import { outcomeFromResult } from '../run/outcome.js';
11
+ import { SkillNotReadable, composeSystemPrompt } from '../run/system-prompt.js';
12
+ import { Redactor } from '../shared/redact.js';
13
+
14
+ /**
15
+ * `bevel-agent run <file>`: a workflow file run here, on this machine,
16
+ * against a platform — the way to try a file before a trigger does.
17
+ *
18
+ * What runs locally is the happy path of a workflow whose steps are all
19
+ * agent steps: each step's session runs with pi, its tools on this working
20
+ * directory and the platform's tools over MCP as the connection key's
21
+ * person, in order, stopping at the first step that does not pass. Gates,
22
+ * tool chains, choices, retries and work items are the platform's — a
23
+ * workflow with any of them is refused here with the step named, since a
24
+ * local run that silently skipped them would say nothing true about it.
25
+ *
26
+ * The configuration's `env:` comes from this process's environment by
27
+ * variable name (vault entries included: a local run has no vault).
28
+ */
29
+ export interface LocalRunOptions {
30
+ /** The platform's API root, e.g. `https://kb.example.com/api`. */
31
+ apiUrl: string;
32
+ /** A connection key of the person the run acts as. */
33
+ token: string;
34
+ gatewayModel?: string;
35
+ /** The input the first step starts with. */
36
+ input?: string;
37
+ cwd?: string;
38
+ /** This process's environment, where `env:` values come from. */
39
+ env?: Record<string, string | undefined>;
40
+ harness?: Harness;
41
+ /** Reads a repo-relative path (an `.agent` the workflow names) from the knowledge base checkout at `cwd`. */
42
+ readFile?: (repoPath: string) => Promise<string>;
43
+ onEvent?: (step: string, event: RunEvent) => void;
44
+ }
45
+
46
+ export interface LocalRunResult {
47
+ status: 'done' | 'failed' | 'blocked';
48
+ steps: Array<{ name: string; outcome: string; reason: string | null }>;
49
+ }
50
+
51
+ const DEFAULT_AGENT: AgentDefinition = { id: 'default', name: 'Default', description: '', harness: 'pi', skills: [], plugins: [], connectors: [], platformTools: null, harnessTools: [], env: [] };
52
+
53
+ /**
54
+ * The reason a workflow cannot run locally, or null when it can. What runs
55
+ * here is exactly what the file says and nothing the platform would add:
56
+ * a step that routes on its verdict, a retry, a catch or an entry budget is
57
+ * refused rather than silently ignored.
58
+ */
59
+ export function unrunnableLocally(def: WorkflowDefinition): string | null {
60
+ if (def.environment.kind !== 'none') return `the workflow runs in a container (environment: ${def.environment.kind === 'image' ? def.environment.ref : def.environment.path}), which only an executor runs; this machine runs \`environment: none\``;
61
+ for (const step of def.steps) {
62
+ if (step.kind !== 'agent') return `step "${step.name}" is a ${step.kind} step, which only the platform runs`;
63
+ if (step.then !== undefined && typeof step.then !== 'string') return `step "${step.name}" routes on its verdict (\`then:\` is a map), which only the platform runs`;
64
+ if ((step.retry?.length ?? 0) > 0 || (step.catch?.length ?? 0) > 0 || step.entries) return `step "${step.name}" has a retry, catch or entries policy, which only the platform runs`;
65
+ }
66
+ if (def.defaults.retry.length > 0 || def.defaults.catch.length > 0) return 'the workflow has a retry or catch policy, which only the platform runs';
67
+ if (def.parameters.require.length > 0) return `the workflow requires parameters (${def.parameters.require.map((r) => r.name).join(', ')}), which come from a work item`;
68
+ return null;
69
+ }
70
+
71
+ /** The ways a configuration narrows the platform's tools, named; null when it does not. */
72
+ function narrowing(agent: AgentDefinition): string | null {
73
+ const ways = [agent.skills.length > 0 && 'skills', agent.plugins.length > 0 && 'plugins', agent.connectors.length > 0 && 'connectors', agent.platformTools !== null && 'platformTools'].filter((w): w is string => typeof w === 'string');
74
+ return ways.length > 0 ? ways.join(', ') : null;
75
+ }
76
+
77
+ export async function runLocally(workflowPath: string, options: LocalRunOptions): Promise<LocalRunResult> {
78
+ const cwd = options.cwd ?? process.cwd();
79
+ // Contained to the checkout: the paths read here come from file content (a
80
+ // step's `agent:`, a configuration's skill), and a file must not make a
81
+ // local run read `~/.ssh/id_rsa` into a system prompt. The check is made
82
+ // on what would really be opened — the real path, symlinks followed,
83
+ // against the checkout's real path — so a link inside the checkout that
84
+ // points out of it is refused like a `../` is. It fails closed: a path
85
+ // whose real location cannot be told is not read. And the open itself
86
+ // refuses a link at the final component (`O_NOFOLLOW`), so a link put in
87
+ // place between the check and the open is not followed either.
88
+ const read =
89
+ options.readFile ??
90
+ (async (repoPath: string) => {
91
+ const root = await realpath(cwd);
92
+ const asked = path.resolve(root, repoPath);
93
+ let target: string;
94
+ try {
95
+ target = await realpath(asked);
96
+ } catch (err) {
97
+ throw new Error(`${repoPath} is not readable in the checkout at ${cwd} (${(err as NodeJS.ErrnoException).code ?? 'unknown'})`);
98
+ }
99
+ const rel = path.relative(root, target);
100
+ if (rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) throw new Error(`${repoPath} is outside the checkout at ${cwd}`);
101
+ const handle = await open(target, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
102
+ try {
103
+ return await handle.readFile('utf8');
104
+ } finally {
105
+ await handle.close();
106
+ }
107
+ });
108
+ const def = parseWorkflowFile(await readFile(path.resolve(cwd, workflowPath), 'utf8'), workflowPath);
109
+ const refusal = unrunnableLocally(def);
110
+ if (refusal) throw new Error(`${workflowPath} cannot run locally: ${refusal}`);
111
+ const harness = options.harness ?? new PiInProcessHarness({ builtinTools: true });
112
+ const processEnv = options.env ?? process.env;
113
+ const platform = { mcpUrl: `${options.apiUrl.replace(/\/$/, '')}/mcp`, gatewayUrl: `${options.apiUrl.replace(/\/$/, '')}/llm/v1`, gatewayModel: options.gatewayModel ?? 'default' };
114
+
115
+ const steps: LocalRunResult['steps'] = [];
116
+ const state: Record<string, unknown> = {};
117
+ const reserved = [...INTERPRETER_STATE_KEYS, ...def.reservedStateKeys];
118
+ // The checkout's files, or null: a path that is not there reads as unreadable, the way the platform reads one the actor may not see.
119
+ const readOrNull = (repoPath: string) => read(repoPath).then((c) => c, () => null);
120
+ // Where the platform starts: the happy path's first step, not the first one listed (that may be off-path).
121
+ let current: StepDef | undefined = def.steps.find((s) => s.name === def.happyPath[0]);
122
+ // Bounded as the platform bounds a run: a `then: <earlier step>` that every
123
+ // step passes through would otherwise start model sessions without end.
124
+ let executed = 0;
125
+ while (current) {
126
+ const step: StepDef = current;
127
+ if (++executed > MAX_RUN_STEPS) {
128
+ steps.push({ name: step.name, outcome: 'blocked', reason: `the run exceeded ${MAX_RUN_STEPS} steps; the file's routing loops` });
129
+ return { status: 'blocked', steps };
130
+ }
131
+ const agent = step.agent ? parseAgentFile(await read(step.agent), step.agent) : DEFAULT_AGENT;
132
+ // What runs here is pi; a configuration asking for another harness is refused, as the platform refuses it.
133
+ if (agent.harness !== 'pi') {
134
+ steps.push({ name: step.name, outcome: 'blocked', reason: `configuration "${agent.id}" asks for the ${agent.harness} harness, which this runner does not have; only pi runs` });
135
+ return { status: 'blocked', steps };
136
+ }
137
+ // The key given here is the person's, not one the platform scoped to the
138
+ // configuration: a configuration that narrows the platform's tools (its
139
+ // skills, plugins, connectors or tool list) would run wider than written.
140
+ const narrows = narrowing(agent);
141
+ if (narrows) {
142
+ steps.push({ name: step.name, outcome: 'blocked', reason: `configuration "${agent.id}" narrows the platform's tools (${narrows}), which only a key the platform scopes to it honours; run it on the platform` });
143
+ return { status: 'blocked', steps };
144
+ }
145
+ const env: Record<string, string> = {};
146
+ const missing: string[] = [];
147
+ for (const entry of agent.env) {
148
+ const value = processEnv[entry.param ?? entry.name];
149
+ if (value) env[entry.name] = value;
150
+ else missing.push(entry.name);
151
+ }
152
+ if (missing.length > 0) {
153
+ steps.push({ name: step.name, outcome: 'blocked', reason: `configuration "${agent.id}" needs ${missing.join(', ')} in the environment` });
154
+ return { status: 'blocked', steps };
155
+ }
156
+ let systemPrompt: string;
157
+ try {
158
+ systemPrompt = await composeSystemPrompt(agent, step, readOrNull);
159
+ } catch (err) {
160
+ if (!(err instanceof SkillNotReadable)) throw err;
161
+ steps.push({ name: step.name, outcome: 'blocked', reason: `skill ${err.skill} is not in the checkout at ${cwd}` });
162
+ return { status: 'blocked', steps };
163
+ }
164
+ // Redacted from every event and outcome this run reports: the configuration's env values and the connection key the session runs on — as the platform redacts a step's run key.
165
+ const redactor = new Redactor([...Object.values(env), options.token]);
166
+ const message = [interpolate(step.instructions, state), '', 'Current STATE:', '```json', JSON.stringify(state, null, 2), '```', ...(step.verdict ? ['', `End your final message with exactly one of: ${step.verdict}.`] : []), ...(step.index === 1 && options.input ? ['', 'Input:', options.input] : [])].join('\n');
167
+ const result = await harness.run({
168
+ input: { text: message },
169
+ systemPrompt,
170
+ agent,
171
+ actorKey: { token: options.token },
172
+ platform,
173
+ env,
174
+ cwd,
175
+ expectVerdict: !!step.verdict,
176
+ onEvent: (event) => options.onEvent?.(step.name, redactor.json(event)),
177
+ });
178
+ const outcome = outcomeFromResult(result, { threadId: step.name, expectVerdict: !!step.verdict, redactor });
179
+ const reason = 'reason' in outcome ? outcome.reason : null;
180
+ steps.push({ name: step.name, outcome: outcome.kind === 'verdict' ? outcome.verdict : outcome.kind, reason });
181
+ if (outcome.kind === 'verdict' && outcome.outputs) {
182
+ // The interpreter's rule: a step's outputs never write the run's own STATE keys, nor the file's reserved ones.
183
+ Object.assign(state, stripReservedKeys(outcome.outputs, reserved, (key) => options.onEvent?.(step.name, { type: 'error', at: new Date().toISOString(), message: `output "${key}" is a reserved STATE key — dropped` })));
184
+ }
185
+ if (outcome.kind !== 'verdict' || outcome.verdict !== 'PASS') {
186
+ return { status: outcome.kind === 'verdict' && outcome.verdict === 'BLOCKED' ? 'blocked' : 'failed', steps };
187
+ }
188
+ // Where the file says to go on a pass: the next step, a named step, or an end — done, failed or blocked, as the file says.
189
+ const then = (step.then ?? 'continue') as string; // a map was refused by `unrunnableLocally`
190
+ if (then === 'done') break;
191
+ if (then === 'failed' || then === 'blocked') return { status: then, steps };
192
+ const next: string | null = then === 'continue' ? nextAfter(def, step.name) : then;
193
+ current = next ? def.steps.find((s) => s.name === next) : undefined;
194
+ }
195
+ return { status: 'done', steps };
196
+ }
197
+
198
+ /** One line per event, for a terminal. */
199
+ export function formatEvent(step: string, event: RunEvent): string {
200
+ switch (event.type) {
201
+ case 'session_started':
202
+ return `[${step}] session started (${event.harness}${event.model ? `, ${event.model}` : ''})`;
203
+ case 'user':
204
+ return `[${step}] > ${event.text.split('\n')[0]}`;
205
+ case 'assistant':
206
+ return `[${step}] ${event.text}`;
207
+ case 'reasoning':
208
+ return `[${step}] (weighed) ${event.text.split('\n')[0]}`;
209
+ case 'tool_call':
210
+ return `[${step}] → ${event.name}`;
211
+ case 'tool_result':
212
+ return `[${step}] ← ${event.name}${event.isError ? ' (error)' : ''}`;
213
+ case 'verdict':
214
+ return `[${step}] verdict ${event.verdict}`;
215
+ case 'outputs':
216
+ return `[${step}] outputs ${JSON.stringify(event.values)}`;
217
+ case 'error':
218
+ return `[${step}] error: ${event.message}`;
219
+ case 'session_ended':
220
+ return `[${step}] session ended${event.ok ? '' : ' with an error'}`;
221
+ case 'transition':
222
+ return `[${step}] ${event.log}`;
223
+ }
224
+ }
@@ -0,0 +1,147 @@
1
+ import { createRequire } from 'node:module';
2
+ import { tmpdir } from 'node:os';
3
+ import path from 'node:path';
4
+ import { PiInProcessHarness } from '../harness/pi-in-process.js';
5
+ import { ClaudeCodeHarness } from '../harness/claude-code.js';
6
+ import type { AgentStepOutcome, ContainerStepInput } from '../interpreter/contract.js';
7
+ import type { Harness, LocalMcpServer } from '../run/contract.js';
8
+ import { outcomeFromResult } from '../run/outcome.js';
9
+ import { PlatformRunReporter, type FetchLike } from '../run/reporter.js';
10
+ import { Redactor } from '../shared/redact.js';
11
+
12
+ /**
13
+ * One step inside a run container: what the executor execs with the harness
14
+ * bundle mounted. The prepared input arrives on stdin; the session runs with
15
+ * the harness's own tools on the workspace and the knowledge base's tools
16
+ * through the local Hexis MCP server as the actor (local tools such as `git`
17
+ * run here, in the container); every event and every turn's state go to the platform with the run
18
+ * key; the outcome is the last line of stdout, marked, for the executor to
19
+ * read back.
20
+ */
21
+
22
+ export const OUTCOME_MARKER = '__BEVEL_OUTCOME__ ';
23
+
24
+ export interface RunStepDeps {
25
+ /** The harness to run with; pi with its built-in tools when absent. */
26
+ harness?: Harness;
27
+ fetch?: FetchLike;
28
+ log?: (line: string) => void;
29
+ /** The workspace directory. */
30
+ cwd?: string;
31
+ /** The local MCP server for this step; the bundle's hexis-mcp when absent. */
32
+ localMcp?: (input: ContainerStepInput) => LocalMcpServer;
33
+ }
34
+
35
+ /**
36
+ * The local Hexis MCP server, as the execution layer always ran it: the one
37
+ * the harness bundle carries, on the node running this, signed in as the
38
+ * actor with the run key. The key travels in its environment (argv is
39
+ * readable by every process in the container); its materialized local
40
+ * tools live in the container's own temp, outside the workspace a step
41
+ * commits from.
42
+ */
43
+ export function localHexisMcp(input: ContainerStepInput): LocalMcpServer {
44
+ const require = createRequire(import.meta.url);
45
+ const manifest = require.resolve('@bevel-software/hexis-mcp/package.json');
46
+ const bin = (require(manifest) as { bin: Record<string, string> }).bin['hexis-mcp']!;
47
+ return {
48
+ command: process.execPath,
49
+ args: [path.join(path.dirname(manifest), bin)],
50
+ env: {
51
+ // The deployment's base address: hexis-mcp finds the endpoint it advertises from there.
52
+ HEXIS_URL: input.platform.apiUrl.replace(/\/+$/, '').replace(/\/api$/, ''),
53
+ HEXIS_CONNECTION_KEY: input.actorKey.token,
54
+ HEXIS_HOME: path.join(tmpdir(), 'hexis'),
55
+ },
56
+ };
57
+ }
58
+
59
+ const FRESH_WORKSPACE_NOTE = 'Note: this step runs in a fresh workspace on another host. Whatever an earlier step left on disk is gone; start from the repository or the knowledge base again where you need it.';
60
+
61
+ export async function runStep(input: ContainerStepInput, deps: RunStepDeps = {}): Promise<AgentStepOutcome> {
62
+ // Redacted from everything reported: the configuration's env values and the
63
+ // run key itself — a session that echoes its own credential would otherwise
64
+ // put it in the stored transcript.
65
+ const redactor = new Redactor([...Object.values(input.env), input.actorKey.token]);
66
+ const reporter = new PlatformRunReporter({
67
+ apiUrl: input.platform.apiUrl,
68
+ threadId: input.threadId,
69
+ token: input.actorKey.token,
70
+ fetch: deps.fetch,
71
+ log: deps.log,
72
+ });
73
+ const harness = harnessFor(input.agent.harness) && (deps.harness ?? harnessFor(input.agent.harness));
74
+ const text = input.freshWorkspace ? `${input.message}\n\n${FRESH_WORKSPACE_NOTE}` : input.message;
75
+ // A configuration asking for a harness the container does not run is never run on another one in its place.
76
+ if (!harness) {
77
+ const reason = `configuration ${input.agent.id} asks for the ${input.agent.harness} harness, which this executor does not run; it runs pi and claude-code`;
78
+ reporter.onEvent({ type: 'error', at: new Date().toISOString(), message: reason });
79
+ // A harness error whatever the report's fate: the note is for the person, the outcome is the same.
80
+ await reporter.flush().catch(() => undefined);
81
+ return { kind: 'harness-error', threadId: input.threadId, reason };
82
+ }
83
+ let outcome: AgentStepOutcome;
84
+ try {
85
+ const result = await harness.run({
86
+ input: { text, images: input.step.input.images },
87
+ systemPrompt: input.systemPrompt,
88
+ agent: input.agent,
89
+ actorKey: input.actorKey,
90
+ platform: { ...input.platform, localMcp: (deps.localMcp ?? localHexisMcp)(input) },
91
+ env: input.env,
92
+ cwd: deps.cwd,
93
+ state: input.state ?? undefined,
94
+ timeoutMs: input.timeoutMs,
95
+ expectVerdict: input.expectVerdict,
96
+ onEvent: (event) => reporter.onEvent(redactor.json(event)),
97
+ // The state is the session itself — tool calls and results included —
98
+ // and it is stored: a secret the session echoed would be in it.
99
+ onState: (state) => reporter.onState(redactor.json(state)),
100
+ });
101
+ outcome = outcomeFromResult(result, { threadId: input.threadId, expectVerdict: input.expectVerdict, redactor });
102
+ } catch (err) {
103
+ const reason = redactor.text(err instanceof Error ? err.message : String(err));
104
+ reporter.onEvent({ type: 'error', at: new Date().toISOString(), message: reason });
105
+ outcome = { kind: 'harness-error', threadId: input.threadId, reason };
106
+ }
107
+ // The thread is the record: the platform reads the step's answer and the
108
+ // state a retry resumes from out of what was reported. A report that was
109
+ // lost makes the step a harness error for the file's policy, as it does
110
+ // for a step on the platform's own worker, never a finished step with its
111
+ // answer gone.
112
+ try {
113
+ await reporter.flush();
114
+ } catch (err) {
115
+ return { kind: 'harness-error', threadId: input.threadId, reason: `the step's transcript could not be reported: ${err instanceof Error ? err.message : String(err)}` };
116
+ }
117
+ return outcome;
118
+ }
119
+
120
+ /** The harness a configuration names, as a container runs it; null for one it does not run. Cheap: building one starts nothing. */
121
+ function harnessFor(name: string): Harness | null {
122
+ if (name === 'pi') return new PiInProcessHarness({ builtinTools: true });
123
+ if (name === 'claude-code') return new ClaudeCodeHarness();
124
+ return null;
125
+ }
126
+
127
+ /** The outcome a `run-step` process printed, from its stdout; null when it printed none (it died first). */
128
+ export function parseOutcomeLine(stdout: string): AgentStepOutcome | null {
129
+ const lines = stdout.split(/\r?\n/);
130
+ for (let i = lines.length - 1; i >= 0; i--) {
131
+ const line = lines[i]!.trim();
132
+ if (!line.startsWith(OUTCOME_MARKER)) continue;
133
+ try {
134
+ return JSON.parse(line.slice(OUTCOME_MARKER.length)) as AgentStepOutcome;
135
+ } catch {
136
+ return null;
137
+ }
138
+ }
139
+ return null;
140
+ }
141
+
142
+ /** Read the whole of a stream as text. */
143
+ export async function readAll(stream: NodeJS.ReadableStream): Promise<string> {
144
+ const chunks: Buffer[] = [];
145
+ for await (const chunk of stream) chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
146
+ return Buffer.concat(chunks).toString('utf8');
147
+ }
@@ -0,0 +1,295 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { ApplicationFailure, Context, heartbeat as temporalHeartbeat } from '@temporalio/activity';
3
+ import { parseOutcomeLine } from '../cli/run-step.js';
4
+ import type { AgentStepOutcome, ContainerStepInput, ExecutorActivities } from '../interpreter/contract.js';
5
+ import { Redactor } from '../shared/redact.js';
6
+ import type { ContainerDriver, ContainerLimits } from './driver.js';
7
+
8
+ /**
9
+ * An executor's half of the contract: one container per step on this host.
10
+ * The image is pulled once, or the workflow's Dockerfile built and tagged by
11
+ * its content hash (rebuilt only when the text changes); the harness bundle
12
+ * is mounted read-only and `bevel-agent run-step` exec'd inside; the run's
13
+ * workspace is a volume that lives while the run is pinned here; the limits
14
+ * are this executor's, the same for every run; the session inside reports
15
+ * to the platform itself, and the outcome comes back on stdout. A provider
16
+ * refusing the ACCOUNT pauses this executor until the reset time, so the
17
+ * item is left alone and nothing else is claimed meanwhile.
18
+ */
19
+
20
+ export interface ExecutorOptions {
21
+ driver: ContainerDriver;
22
+ /** This executor's own task queue, the one runs are pinned to. */
23
+ queueName: string;
24
+ /**
25
+ * The harness bundle — the library, its dependencies and tsx — as the
26
+ * engine mounts it into a run container: a path on the host, or a named
27
+ * volume the executor filled (the executor's own container is not the
28
+ * host, so a path inside it means nothing to the engine).
29
+ */
30
+ harnessBundle: { kind: 'bind' | 'volume'; source: string };
31
+ /**
32
+ * The deployment this executor serves (its compose project, as the engine
33
+ * names it). Workspace volumes are labelled with it, and the sweep judges
34
+ * only its own: several deployments on one engine each ask their own
35
+ * Temporal, which knows none of the others' runs. Unknown, the sweep of a
36
+ * deployment's executor is off rather than guessing.
37
+ */
38
+ deployment?: string | null;
39
+ limits: ContainerLimits;
40
+ /** `uid:gid` run containers run as. */
41
+ user?: string;
42
+ /** How long to pause when a provider refuses the account and names no reset time. */
43
+ defaultPauseMs?: number;
44
+ log?: (line: string) => void;
45
+ now?: () => number;
46
+ /** The activity heartbeat; Temporal's when absent. */
47
+ heartbeat?: (details?: unknown) => void;
48
+ /** The activity's cancellation, as a signal the container is stopped on; Temporal's when absent. */
49
+ cancellation?: () => AbortSignal | undefined;
50
+ }
51
+
52
+ /** How often a running step says it is alive without output, so a cancel reaches it. */
53
+ const ALIVE_HEARTBEAT_MS = 30_000;
54
+
55
+ export const VOLUME_LABEL = 'software.bevel.run';
56
+ /** The person whose run a workspace volume belongs to: what a person's own executor sweeps by. */
57
+ export const OWNER_LABEL = 'software.bevel.owner';
58
+ /** The deployment whose executor made a workspace volume: the only one whose sweep may judge it. */
59
+ export const DEPLOYMENT_LABEL = 'software.bevel.deployment';
60
+ export const IMAGE_TAG_PREFIX = 'bevel-workflow';
61
+ const HARNESS_MOUNT = '/harness';
62
+ const WORKSPACE_MOUNT = '/workspace';
63
+
64
+ /**
65
+ * The command that runs `args` on node inside a run container: the node the
66
+ * executor ships in the harness bundle (`bin/node`, copied from its own
67
+ * image) when the image is glibc-based, so an image needs no node of its own
68
+ * (`python:3.12`, `ubuntu`); the image's own `node` otherwise — an image on
69
+ * musl (Alpine), where the shipped glibc build cannot start, brings its own,
70
+ * and so does a bundle without one (a checkout mounted for a test). Needs
71
+ * `sh`, as the workspace hand-over already does.
72
+ */
73
+ export function harnessNodeCommand(args: string[], harness = HARNESS_MOUNT): string[] {
74
+ return [
75
+ 'sh',
76
+ '-c',
77
+ `if [ -x "${harness}/bin/node" ] && ! ls /lib/ld-musl-* >/dev/null 2>&1; then exec "${harness}/bin/node" "$@"; fi; exec node "$@"`,
78
+ 'node',
79
+ ...args,
80
+ ];
81
+ }
82
+ /**
83
+ * Left in a fresh workspace by the hand-over below. The engine mounts an
84
+ * EMPTY volume by copying the image path's ownership over it — root, in an
85
+ * image without the directory — on every mount, so a bare chown would be
86
+ * undone by the step's own mount. A volume with a file in it is left alone.
87
+ */
88
+ const WORKSPACE_MARKER = '.bevel-workspace';
89
+ const STDERR_TAIL = 2_000;
90
+
91
+ /** The image a Dockerfile's text builds to: one tag per content, so an unchanged file is never rebuilt. */
92
+ export function imageTagFor(dockerfile: string): string {
93
+ return `${IMAGE_TAG_PREFIX}:${createHash('sha256').update(dockerfile).digest('hex').slice(0, 16)}`;
94
+ }
95
+
96
+ export function volumeNameFor(runId: string): string {
97
+ return `bevel-run-${runId}`;
98
+ }
99
+
100
+ export interface Executor extends ExecutorActivities {
101
+ /** Until when this executor refuses steps, or null. */
102
+ pausedUntil(): number | null;
103
+ /**
104
+ * Remove the workspace volumes of runs that are no longer open — the
105
+ * ones whose `releaseRun` never reached this executor (it was down when
106
+ * the run ended). `runIsOpen` asks the platform's Temporal. Returns the
107
+ * names removed.
108
+ */
109
+ /**
110
+ * Only volumes this executor's deployment made (see `deployment`), and
111
+ * nothing at all when the deployment is unknown and no `owner` narrows it.
112
+ * `owner`: a person's own executor sweeps only the volumes of that person's
113
+ * runs (labelled when made), and nothing while it does not know the person
114
+ * yet (`null`).
115
+ */
116
+ sweepVolumes(runIsOpen: (runId: string) => Promise<boolean>, owner?: string | null): Promise<string[]>;
117
+ }
118
+
119
+ export function createExecutor(options: ExecutorOptions): Executor {
120
+ const { driver, queueName } = options;
121
+ const log = options.log ?? (() => undefined);
122
+ const now = options.now ?? (() => Date.now());
123
+ const heartbeat = options.heartbeat ?? ((details?: unknown) => temporalHeartbeat(details));
124
+ // Outside an activity (a test, a dry run) there is no context and no signal.
125
+ const cancellation =
126
+ options.cancellation ??
127
+ (() => {
128
+ try {
129
+ return Context.current().cancellationSignal;
130
+ } catch {
131
+ return undefined;
132
+ }
133
+ });
134
+ const defaultPauseMs = options.defaultPauseMs ?? 30 * 60_000;
135
+ let pausedUntil: number | null = null;
136
+
137
+ async function imageFor(input: ContainerStepInput): Promise<string> {
138
+ if (input.dockerfile !== null) {
139
+ const tag = imageTagFor(input.dockerfile);
140
+ if (!(await driver.hasImage(tag))) {
141
+ log(`building ${tag} for ${input.step.workflowPath}`);
142
+ heartbeat('build');
143
+ await driver.buildImage(tag, input.dockerfile);
144
+ }
145
+ return tag;
146
+ }
147
+ const ref = input.image ?? 'node:22';
148
+ if (!(await driver.hasImage(ref))) {
149
+ log(`pulling ${ref}`);
150
+ heartbeat('pull');
151
+ await driver.pullImage(ref);
152
+ }
153
+ return ref;
154
+ }
155
+
156
+ return {
157
+ pausedUntil: () => (pausedUntil !== null && pausedUntil > now() ? pausedUntil : null),
158
+
159
+ async runContainerStep(input: ContainerStepInput): Promise<AgentStepOutcome> {
160
+ const until = pausedUntil !== null && pausedUntil > now() ? pausedUntil : null;
161
+ if (until !== null) {
162
+ // Retryable: the task comes back later, here or on another executor
163
+ // of the shared queue; the item is not charged.
164
+ // The reset time travels as the failure's detail: the run reads it
165
+ // and waits until then, as it does for a limit hit mid-step.
166
+ throw ApplicationFailure.retryable(`executor ${queueName} is paused until ${new Date(until).toISOString()}`, 'ExecutorPaused', until);
167
+ }
168
+
169
+ // Every outcome a step reaches once its workspace exists carries the
170
+ // pin: a limit hit on the first step would otherwise send the retry to
171
+ // any host. An environment that could not be made has no workspace to
172
+ // pin to, and stays unpinned on purpose.
173
+ const pin = (outcome: AgentStepOutcome): AgentStepOutcome =>
174
+ outcome.kind === 'verdict' || outcome.kind === 'no-verdict' || outcome.kind === 'timeout' || outcome.kind === 'account-limit' || outcome.kind === 'harness-error' ? { ...outcome, executorQueue: queueName } : outcome;
175
+ // What a container printed on stderr becomes a reason the platform
176
+ // stores, logs and mails: the secrets this executor handed the step (its
177
+ // `env:` and the run key) are scrubbed from it first, as `run-step`
178
+ // scrubs its own events. A crash that echoes the environment must not
179
+ // copy a vault value into a work item.
180
+ const redactor = new Redactor([...Object.values(input.env), input.actorKey.token]);
181
+ // Redacted whole, then cut: a secret straddling the cut would otherwise leave its suffix in the tail.
182
+ const stderrTail = (stderr: string): string => redactor.text(stderr).trim().slice(-STDERR_TAIL);
183
+
184
+ // Alive from the first moment to the last, the pull, the build and the workspace hand-over included:
185
+ // a cold build takes minutes, and a step silent that long is given up (a cancel is delivered on a heartbeat too).
186
+ const alive = setInterval(() => heartbeat('alive'), ALIVE_HEARTBEAT_MS);
187
+ alive.unref?.();
188
+ try {
189
+ return await runPrepared();
190
+ } finally {
191
+ clearInterval(alive);
192
+ }
193
+
194
+ async function runPrepared(): Promise<AgentStepOutcome> {
195
+ let image: string;
196
+ try {
197
+ image = await imageFor(input);
198
+ } catch (err) {
199
+ return { kind: 'harness-error', threadId: input.threadId, reason: `the environment could not be made: ${err instanceof Error ? err.message : String(err)}` };
200
+ }
201
+ const volume = volumeNameFor(input.step.runId);
202
+ const user = options.user ?? '1000:1000';
203
+ const labels = { [VOLUME_LABEL]: input.step.runId, 'software.bevel.thread': input.threadId };
204
+ const workspaceMount = { kind: 'volume' as const, source: volume, target: WORKSPACE_MOUNT, readonly: false };
205
+ if (await driver.ensureVolume(volume, { [VOLUME_LABEL]: input.step.runId, [OWNER_LABEL]: input.step.actor.userId, ...(options.deployment ? { [DEPLOYMENT_LABEL]: options.deployment } : {}) })) {
206
+ // A new volume is owned by root; the run user could not write it.
207
+ // One command as root, once, over the mount alone, hands it over —
208
+ // the only time anything runs as root here. The marker file keeps
209
+ // the volume from being empty (see WORKSPACE_MARKER).
210
+ const prepared = await driver.run({
211
+ name: `bevel-init-${input.step.runId}`,
212
+ image,
213
+ command: ['sh', '-c', 'touch "$2/$3" && chown -R "$1" "$2"', 'prepare', user, WORKSPACE_MOUNT, WORKSPACE_MARKER],
214
+ stdin: '',
215
+ env: {},
216
+ mounts: [workspaceMount],
217
+ workdir: '/',
218
+ user: '0:0',
219
+ limits: options.limits,
220
+ labels,
221
+ timeoutMs: 60_000,
222
+ });
223
+ if (prepared.exitCode !== 0) {
224
+ return pin({ kind: 'harness-error', threadId: input.threadId, reason: `the workspace could not be prepared (${prepared.exitCode ?? 'killed'}): ${stderrTail(prepared.stderr)}` });
225
+ }
226
+ }
227
+
228
+ // Cancelled while the image was pulled or built: the step never starts. (The pull or build itself runs to its end.)
229
+ if (cancellation()?.aborted) return { kind: 'harness-error', threadId: input.threadId, reason: 'the run was cancelled before the step started' };
230
+ const result = await driver.run({
231
+ name: `bevel-step-${input.threadId}-${input.step.attempt}`,
232
+ image,
233
+ operatorOptions: true,
234
+ command: harnessNodeCommand([`${HARNESS_MOUNT}/packages/ael/node_modules/tsx/dist/cli.mjs`, `${HARNESS_MOUNT}/packages/ael/src/cli/main.ts`, 'run-step']),
235
+ stdin: JSON.stringify(input),
236
+ env: { ...input.env, HOME: WORKSPACE_MOUNT },
237
+ mounts: [{ kind: options.harnessBundle.kind, source: options.harnessBundle.source, target: HARNESS_MOUNT, readonly: true }, workspaceMount],
238
+ workdir: WORKSPACE_MOUNT,
239
+ user,
240
+ limits: options.limits,
241
+ labels,
242
+ timeoutMs: input.timeoutMs,
243
+ // A cancelled run stops its container; the signal is the activity's own.
244
+ signal: cancellation(),
245
+ onAlive: () => heartbeat('alive'),
246
+ });
247
+
248
+ if (result.timedOut) {
249
+ return pin({ kind: 'timeout', threadId: input.threadId, reason: `the step exceeded its budget of ${Math.round(input.timeoutMs / 60_000)} minutes` });
250
+ }
251
+ const outcome = parseOutcomeLine(result.stdout);
252
+ if (!outcome) {
253
+ const tail = stderrTail(result.stderr);
254
+ return pin({ kind: 'harness-error', threadId: input.threadId, reason: `the container ended (${result.exitCode ?? 'killed'}) without an outcome${tail ? `: ${tail}` : ''}` });
255
+ }
256
+ if (outcome.kind === 'account-limit') {
257
+ pausedUntil = outcome.resetAt ?? now() + defaultPauseMs;
258
+ log(`paused until ${new Date(pausedUntil).toISOString()}: ${outcome.text}`);
259
+ }
260
+ return pin(outcome);
261
+ }
262
+ },
263
+
264
+ // The run is over, whatever its outcome: nothing reads the workspace
265
+ // again (the transcript is on the thread, the work is on the branch),
266
+ // so the volume goes now rather than after a grace period.
267
+ async releaseRun(input: { runId: string }): Promise<void> {
268
+ await driver.removeVolume(volumeNameFor(input.runId));
269
+ log(`released the workspace of run ${input.runId}`);
270
+ },
271
+
272
+ async sweepVolumes(runIsOpen: (runId: string) => Promise<boolean>, owner?: string | null): Promise<string[]> {
273
+ const removed: string[] = [];
274
+ // A person's own executor knows only that person's runs: another person's volume on the same engine is never its to judge.
275
+ if (owner === null) return removed;
276
+ // Another deployment's volume on the same engine belongs to runs our Temporal has never heard of: never ours to judge.
277
+ const deployment = options.deployment || null;
278
+ if (!deployment && owner === undefined) {
279
+ log('workspace sweep off: this executor does not know its deployment (set EXECUTOR_DEPLOYMENT)');
280
+ return removed;
281
+ }
282
+ for (const v of await driver.listVolumes(VOLUME_LABEL)) {
283
+ const runId = v.labels[VOLUME_LABEL];
284
+ if (deployment && v.labels[DEPLOYMENT_LABEL] !== deployment) continue;
285
+ if (owner !== undefined && v.labels[OWNER_LABEL] !== owner) continue;
286
+ // Age says nothing: a run parked on a gate keeps its workspace for weeks.
287
+ if (!runId || (await runIsOpen(runId))) continue;
288
+ await driver.removeVolume(v.name);
289
+ removed.push(v.name);
290
+ }
291
+ if (removed.length > 0) log(`removed ${removed.length} workspace volume${removed.length === 1 ? '' : 's'} of runs that are over`);
292
+ return removed;
293
+ },
294
+ };
295
+ }