@bevel-software/ael 0.0.0-stage → 0.1.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.
- package/LICENSE +13 -0
- package/README.md +32 -3
- package/bin/bevel-agent.mjs +15 -0
- package/executor-entrypoint.sh +41 -0
- package/executor.Dockerfile +37 -0
- package/package.json +61 -6
- package/src/agent/agent-file.ts +242 -0
- package/src/cli/main.ts +73 -0
- package/src/cli/run-local.ts +224 -0
- package/src/cli/run-step.ts +147 -0
- package/src/executor/activities.ts +295 -0
- package/src/executor/docker-cli.ts +148 -0
- package/src/executor/driver.ts +80 -0
- package/src/executor/harness-mount.ts +66 -0
- package/src/executor/in-place.ts +180 -0
- package/src/executor/main.ts +197 -0
- package/src/executor/personal.ts +355 -0
- package/src/executor/slots.ts +60 -0
- package/src/harness/claude-code.ts +397 -0
- package/src/harness/pi-in-process.ts +444 -0
- package/src/harness/session-state.ts +86 -0
- package/src/harness/stall-guard.ts +97 -0
- package/src/index.ts +164 -0
- package/src/interpreter/contract.ts +403 -0
- package/src/interpreter/dispatcher.workflow.ts +412 -0
- package/src/interpreter/interpret.workflow.ts +907 -0
- package/src/interpreter/scheduled.workflow.ts +28 -0
- package/src/interpreter/workflows.ts +8 -0
- package/src/run/contract.ts +150 -0
- package/src/run/outcome.ts +63 -0
- package/src/run/reporter.ts +127 -0
- package/src/run/system-prompt.ts +35 -0
- package/src/run/verdict.ts +46 -0
- package/src/shared/front-matter.ts +27 -0
- package/src/shared/redact.ts +78 -0
- package/src/shared/schedule.ts +93 -0
- package/src/temporal/payload-codec.ts +84 -0
- package/src/transcript/render.ts +106 -0
- package/src/workflow/definition.ts +196 -0
- package/src/workflow/failure-policy.ts +100 -0
- package/src/workflow/parameters.ts +175 -0
- package/src/workflow/workflow-file.ts +457 -0
- package/src/workitem/node.ts +170 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { readFile, unlink } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { createExecutor } from './activities.js';
|
|
6
|
+
import { DockerCliDriver } from './docker-cli.js';
|
|
7
|
+
import { createInPlaceExecutor } from './in-place.js';
|
|
8
|
+
import { discoverDeployment, discoverHarnessMount } from './harness-mount.js';
|
|
9
|
+
import { personalExecutorHooks, runPersonalExecutor, runWorkerStarter } from './personal.js';
|
|
10
|
+
import { PERSONAL_EXECUTOR_NAME } from '../workflow/definition.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `bevel-agent executor`: one executor on this host, configured from the
|
|
14
|
+
* environment. Every executor asks the platform for its steps over HTTPS and
|
|
15
|
+
* never talks to Temporal.
|
|
16
|
+
*
|
|
17
|
+
* PLATFORM_URL the deployment's address, `https://…` (required)
|
|
18
|
+
* EXECUTOR_TOKEN a connection key its owner made by hand (required): for a deployment
|
|
19
|
+
* executor, an account with the Infrastructure Engineer role
|
|
20
|
+
* EXECUTOR_NAME this executor's name, unique on the deployment (required): what runs and
|
|
21
|
+
* triggers name it by; lowercase letters, digits and dashes
|
|
22
|
+
* EXECUTOR_KIND `deployment` (anyone's steps) or `personal` (its owner's runs that name
|
|
23
|
+
* it; the default)
|
|
24
|
+
* EXECUTOR_DEPLOYMENT the deployment it serves, labelled on its workspace volumes so a sweep never
|
|
25
|
+
* touches another deployment's on a shared engine (default: its compose project)
|
|
26
|
+
* HARNESS_BUNDLE_VOLUME the named volume holding the harness bundle (default `bevel-harness`;
|
|
27
|
+
* the executor image fills it at start), or
|
|
28
|
+
* HARNESS_BUNDLE_PATH a path ON THE HOST holding the bundle instead
|
|
29
|
+
* EXECUTOR_MAX_STEPS container steps at once (default 2)
|
|
30
|
+
* EXECUTOR_MEMORY memory per run container (default `4g`)
|
|
31
|
+
* EXECUTOR_CPUS CPUs per run container (default `2`)
|
|
32
|
+
* EXECUTOR_PIDS pids per run container (default 512)
|
|
33
|
+
* EXECUTOR_RUN_OPTIONS extra `docker run` options for every run container on this server, separated
|
|
34
|
+
* by spaces (default none): the operator's choice of what steps here may do
|
|
35
|
+
* beyond the defaults, such as running a rootless Docker daemon inside a step.
|
|
36
|
+
* Split on spaces and nothing else: no quoting, so no value with a space in it
|
|
37
|
+
*
|
|
38
|
+
* An executor inside a CI job, which is the environment already and ends with the work:
|
|
39
|
+
*
|
|
40
|
+
* EXECUTOR_IN_PLACE `true`: each step runs as a process of this executor, in a directory per run,
|
|
41
|
+
* with no container engine; the engine settings above (bundle, limits, run
|
|
42
|
+
* options, deployment) do not apply. One step at once unless EXECUTOR_MAX_STEPS says more
|
|
43
|
+
* EXECUTOR_EXIT_WHEN_IDLE seconds without a step after which the executor leaves (default: never)
|
|
44
|
+
* EXECUTOR_RUN a run id: take that run's steps alone, one after another — a worker started
|
|
45
|
+
* for one run, which leaves (EXECUTOR_EXIT_WHEN_IDLE) when the run waits or is over
|
|
46
|
+
* EXECUTOR_START_WORKER a shell command: this executor runs no step. It looks for runs with a step
|
|
47
|
+
* waiting and runs the command once per run, to start a worker elsewhere (another
|
|
48
|
+
* pipeline); the command gets BEVEL_RUN_ID, BEVEL_WORKFLOW, BEVEL_STEP and
|
|
49
|
+
* BEVEL_ENVIRONMENT (the workflow's `environment:` as written: the worker job to start)
|
|
50
|
+
* EXECUTOR_TOKEN_FILE a file holding the key, in place of EXECUTOR_TOKEN: read once and removed, so
|
|
51
|
+
* a step running beside the executor finds it neither in a variable nor on disk
|
|
52
|
+
*/
|
|
53
|
+
export async function executorMain(): Promise<number> {
|
|
54
|
+
const env = process.env;
|
|
55
|
+
const platformUrl = env.PLATFORM_URL?.trim().replace(/\/+$/, '');
|
|
56
|
+
if (!platformUrl) {
|
|
57
|
+
process.stderr.write("PLATFORM_URL is required: the platform's address, which every executor asks for its steps\n");
|
|
58
|
+
return 2;
|
|
59
|
+
}
|
|
60
|
+
let token = env.EXECUTOR_TOKEN?.trim();
|
|
61
|
+
const tokenFile = env.EXECUTOR_TOKEN_FILE?.trim();
|
|
62
|
+
if (!token && tokenFile) {
|
|
63
|
+
try {
|
|
64
|
+
token = (await readFile(tokenFile, 'utf8')).trim();
|
|
65
|
+
} catch (err) {
|
|
66
|
+
process.stderr.write(`EXECUTOR_TOKEN_FILE could not be read: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
67
|
+
return 2;
|
|
68
|
+
}
|
|
69
|
+
// Read once: a step running beside this executor must not find the key on disk. A file that
|
|
70
|
+
// cannot be removed (a read-only mount) would stay there for every step to read, so nothing starts.
|
|
71
|
+
try {
|
|
72
|
+
await unlink(tokenFile);
|
|
73
|
+
} catch (err) {
|
|
74
|
+
process.stderr.write(`EXECUTOR_TOKEN_FILE was read but could not be removed (${err instanceof Error ? err.message : String(err)}): a step would find the key there, so the executor does not start\n`);
|
|
75
|
+
return 2;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (!token) {
|
|
79
|
+
process.stderr.write('EXECUTOR_TOKEN (or EXECUTOR_TOKEN_FILE) is required: a connection key made by hand on the platform (for a deployment executor, by an account with the Infrastructure Engineer role)\n');
|
|
80
|
+
return 2;
|
|
81
|
+
}
|
|
82
|
+
const name = env.EXECUTOR_NAME?.trim() ?? '';
|
|
83
|
+
if (!PERSONAL_EXECUTOR_NAME.test(name)) {
|
|
84
|
+
process.stderr.write('EXECUTOR_NAME is required: the name runs and triggers give this executor (lowercase letters, digits and dashes, at most 40)\n');
|
|
85
|
+
return 2;
|
|
86
|
+
}
|
|
87
|
+
const rawKind = (env.EXECUTOR_KIND?.trim() || 'personal').toLowerCase();
|
|
88
|
+
if (rawKind !== 'personal' && rawKind !== 'deployment') {
|
|
89
|
+
process.stderr.write(`EXECUTOR_KIND is "${rawKind}": it is \`deployment\` or \`personal\`\n`);
|
|
90
|
+
return 2;
|
|
91
|
+
}
|
|
92
|
+
const kind = rawKind;
|
|
93
|
+
const log = (line: string) => process.stderr.write(`[executor ${name}] ${line}\n`);
|
|
94
|
+
const idleSeconds = Number(env.EXECUTOR_EXIT_WHEN_IDLE);
|
|
95
|
+
const exitWhenIdleMs = Number.isFinite(idleSeconds) && idleSeconds > 0 ? idleSeconds * 1000 : undefined;
|
|
96
|
+
const queueName = kind === 'deployment' ? `executor-${name}` : `personal-${name}`;
|
|
97
|
+
const stop = new AbortController();
|
|
98
|
+
process.once('SIGINT', () => stop.abort());
|
|
99
|
+
process.once('SIGTERM', () => stop.abort());
|
|
100
|
+
const run = env.EXECUTOR_RUN?.trim() || undefined;
|
|
101
|
+
const startWorker = env.EXECUTOR_START_WORKER?.trim();
|
|
102
|
+
if (startWorker) {
|
|
103
|
+
await runWorkerStarter(
|
|
104
|
+
{
|
|
105
|
+
apiUrl: `${platformUrl}/api`,
|
|
106
|
+
token,
|
|
107
|
+
name,
|
|
108
|
+
kind,
|
|
109
|
+
log,
|
|
110
|
+
exitWhenIdleMs,
|
|
111
|
+
// The operator's own command, as they wrote it; the run reaches it in variables, never spliced into the line.
|
|
112
|
+
startWorker: (waiting) => shell(startWorker, { BEVEL_RUN_ID: waiting.runId, BEVEL_WORKFLOW: waiting.workflowPath, BEVEL_STEP: waiting.stepName, BEVEL_ENVIRONMENT: waiting.environment ?? '' }, stop.signal),
|
|
113
|
+
},
|
|
114
|
+
stop.signal,
|
|
115
|
+
);
|
|
116
|
+
log('stopped');
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
if (/^(1|true|yes)$/i.test(env.EXECUTOR_IN_PLACE?.trim() ?? '')) {
|
|
120
|
+
const executor = createInPlaceExecutor({
|
|
121
|
+
queueName,
|
|
122
|
+
workspacesDir: path.join(tmpdir(), 'bevel-runs'),
|
|
123
|
+
withhold: ['EXECUTOR_TOKEN', 'EXECUTOR_TOKEN_FILE'],
|
|
124
|
+
log,
|
|
125
|
+
heartbeat: personalExecutorHooks.heartbeat,
|
|
126
|
+
cancellation: personalExecutorHooks.cancellation,
|
|
127
|
+
});
|
|
128
|
+
log('steps run in place: as processes of this executor, with no container engine');
|
|
129
|
+
await runPersonalExecutor({ apiUrl: `${platformUrl}/api`, token, name, kind, slots: Number(env.EXECUTOR_MAX_STEPS) || 1, executor, log, exitWhenIdleMs, run }, stop.signal);
|
|
130
|
+
log('stopped');
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
|
133
|
+
const runOptions = (env.EXECUTOR_RUN_OPTIONS ?? '').split(/\s+/).filter(Boolean);
|
|
134
|
+
// The executor waits on each run container and removes it when it ends: one started detached would outlive
|
|
135
|
+
// its step, unwatched.
|
|
136
|
+
if (runOptions.some((option) => option === '-d' || option === '--detach' || option.startsWith('--detach='))) {
|
|
137
|
+
process.stderr.write('EXECUTOR_RUN_OPTIONS has --detach: the executor waits on every run container, so that option is refused\n');
|
|
138
|
+
return 2;
|
|
139
|
+
}
|
|
140
|
+
// The count, never the options: one may carry a value that should not be in a log.
|
|
141
|
+
if (runOptions.length > 0) log(`every run container here also gets ${runOptions.length} option(s) from EXECUTOR_RUN_OPTIONS`);
|
|
142
|
+
const executor = createExecutor({
|
|
143
|
+
driver: new DockerCliDriver(runOptions),
|
|
144
|
+
// What the platform pins a run to is its own queue for this executor; it rewrites the pin on the way back.
|
|
145
|
+
queueName,
|
|
146
|
+
harnessBundle: await harnessBundleOf(env, log),
|
|
147
|
+
deployment: await deploymentOf(env, log),
|
|
148
|
+
limits: limitsOf(env),
|
|
149
|
+
log,
|
|
150
|
+
heartbeat: personalExecutorHooks.heartbeat,
|
|
151
|
+
cancellation: personalExecutorHooks.cancellation,
|
|
152
|
+
});
|
|
153
|
+
await runPersonalExecutor({ apiUrl: `${platformUrl}/api`, token, name, kind, slots: Number(env.EXECUTOR_MAX_STEPS) || 2, executor, log, exitWhenIdleMs, run }, stop.signal);
|
|
154
|
+
log('stopped');
|
|
155
|
+
return 0;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The bundle run containers get: the one the engine mounted into THIS
|
|
160
|
+
* container, asked of the engine — a deployment platform may have created
|
|
161
|
+
* the volume under a name of its own, and the configured name would then
|
|
162
|
+
* reach run containers as an empty volume. Configured names are the fallback
|
|
163
|
+
* for an engine that cannot be asked.
|
|
164
|
+
*/
|
|
165
|
+
async function harnessBundleOf(env: NodeJS.ProcessEnv, log: (line: string) => void): Promise<{ kind: 'bind' | 'volume'; source: string }> {
|
|
166
|
+
const configured: { kind: 'bind' | 'volume'; source: string } = env.HARNESS_BUNDLE_PATH?.trim() ? { kind: 'bind', source: env.HARNESS_BUNDLE_PATH.trim() } : { kind: 'volume', source: env.HARNESS_BUNDLE_VOLUME?.trim() || 'bevel-harness' };
|
|
167
|
+
const bundleDir = env.HARNESS_BUNDLE_DIR?.trim() || '/harness';
|
|
168
|
+
const mounted = await discoverHarnessMount(bundleDir);
|
|
169
|
+
if (!mounted) log(`the engine did not say what is mounted at ${bundleDir}; run containers get the configured ${configured.kind} ${configured.source}`);
|
|
170
|
+
else if (mounted.source !== configured.source) log(`run containers get the ${mounted.kind} ${mounted.source} mounted at ${bundleDir}, not the configured ${configured.source}`);
|
|
171
|
+
return mounted ?? configured;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The deployment this executor serves: the configured one, else its compose project as the engine names it. */
|
|
175
|
+
async function deploymentOf(env: NodeJS.ProcessEnv, log: (line: string) => void): Promise<string | null> {
|
|
176
|
+
const configured = env.EXECUTOR_DEPLOYMENT?.trim();
|
|
177
|
+
if (configured) return configured;
|
|
178
|
+
const discovered = await discoverDeployment();
|
|
179
|
+
log(discovered ? `serving deployment ${discovered}` : 'the engine did not name this executor\'s compose project; set EXECUTOR_DEPLOYMENT, or its workspace sweep stays off');
|
|
180
|
+
return discovered;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Run the operator's command in a shell, with `vars` added to this process's environment; rejects when it fails.
|
|
185
|
+
* It starts a worker elsewhere and returns: one that hangs is ended after two minutes, or when the executor stops.
|
|
186
|
+
*/
|
|
187
|
+
function shell(command: string, vars: Record<string, string>, stop: AbortSignal): Promise<void> {
|
|
188
|
+
return new Promise((resolve, reject) => {
|
|
189
|
+
const child = spawn(command, { shell: true, stdio: ['ignore', 'inherit', 'inherit'], env: { ...process.env, ...vars }, signal: AbortSignal.any([stop, AbortSignal.timeout(120_000)]), killSignal: 'SIGKILL' });
|
|
190
|
+
child.on('error', reject);
|
|
191
|
+
child.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`the command ended with ${code ?? 'a signal'}`))));
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function limitsOf(env: NodeJS.ProcessEnv) {
|
|
196
|
+
return { memory: env.EXECUTOR_MEMORY?.trim() || '4g', cpus: env.EXECUTOR_CPUS?.trim() || '2', pids: Number(env.EXECUTOR_PIDS) || 512 };
|
|
197
|
+
}
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import type { AgentStepOutcome, ContainerStepInput } from '../interpreter/contract.js';
|
|
4
|
+
import type { Executor } from './activities.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* An executor's line to the platform. Every executor talks to the platform
|
|
8
|
+
* over HTTPS with a connection key its owner made by hand, and never to
|
|
9
|
+
* Temporal: no payload key, no certificate, no Temporal port reachable from
|
|
10
|
+
* outside. Two kinds, by name in one namespace:
|
|
11
|
+
*
|
|
12
|
+
* - a person's own (`personal`), run on their machine so the container steps
|
|
13
|
+
* of their runs that name it run there — behind their VPN, inside their
|
|
14
|
+
* network; the platform hands it only that person's runs;
|
|
15
|
+
* - one of the deployment's (`deployment`), on any server, whose key's owner
|
|
16
|
+
* holds the Infrastructure Engineer role; the platform hands it anyone's
|
|
17
|
+
* steps from the shared queue, and every step of a run that names it.
|
|
18
|
+
*
|
|
19
|
+
* Everything else — the image, the harness bundle, the workspace volume, the
|
|
20
|
+
* limits, the account-limit pause — is the ordinary executor's.
|
|
21
|
+
*
|
|
22
|
+
* The protocol, under the platform's `/api`:
|
|
23
|
+
*
|
|
24
|
+
* POST /agents/executors/personal/poll { name, kind, instance, run? } → 200 { job } | 204
|
|
25
|
+
* POST /agents/executors/personal/waiting { name, kind, instance } → { runs }
|
|
26
|
+
* POST /agents/executors/personal/jobs/:id/alive {} → { cancelled }
|
|
27
|
+
* POST /agents/executors/personal/jobs/:id/result PersonalJobResult
|
|
28
|
+
* POST /agents/executors/personal/runs-open { runIds } → { open }
|
|
29
|
+
*
|
|
30
|
+
* `instance` is made once per executor process. Several processes of one
|
|
31
|
+
* owner may ask under one name: with `run`, a process takes that run's jobs
|
|
32
|
+
* alone, and `waiting` lists the runs with a step waiting that no process
|
|
33
|
+
* asks for (it takes nothing). That is how an executor in a CI pipeline
|
|
34
|
+
* works: one job looks and starts a worker job per run. A name held by
|
|
35
|
+
* another owner or kind is refused (409).
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
export const PERSONAL_EXECUTOR_API = '/agents/executors/personal';
|
|
39
|
+
|
|
40
|
+
/** What an executor process's `instance` looks like: a UUID fits. */
|
|
41
|
+
export const EXECUTOR_INSTANCE = /^[A-Za-z0-9-]{8,64}$/;
|
|
42
|
+
|
|
43
|
+
/** What the platform hands a personal executor: a step to run, or a finished run's workspace to remove. */
|
|
44
|
+
export type PersonalJob = { id: string; kind: 'step'; input: ContainerStepInput } | { id: string; kind: 'release'; input: { runId: string } };
|
|
45
|
+
|
|
46
|
+
/** What the executor answers for a job: the step's outcome, a release done, or why it could not. */
|
|
47
|
+
export type PersonalJobResult = { outcome: AgentStepOutcome } | { done: true } | { error: string; pausedUntil?: number | null };
|
|
48
|
+
|
|
49
|
+
type Fetch = (url: string, init: { method: string; headers: Record<string, string>; body?: string; signal?: AbortSignal }) => Promise<{ status: number; ok: boolean; json(): Promise<unknown>; text(): Promise<string> }>;
|
|
50
|
+
|
|
51
|
+
export interface PersonalExecutorOptions {
|
|
52
|
+
/** The platform's API base, `https://<deployment>/api`. */
|
|
53
|
+
apiUrl: string;
|
|
54
|
+
/** A connection key its owner made by hand: a personal executor runs that person's steps. */
|
|
55
|
+
token: string;
|
|
56
|
+
/** This executor's name, unique on the deployment: what a run or trigger names it by. */
|
|
57
|
+
name: string;
|
|
58
|
+
/** A person's own executor, or one of the deployment's. */
|
|
59
|
+
kind: 'personal' | 'deployment';
|
|
60
|
+
/** Steps at once. */
|
|
61
|
+
slots: number;
|
|
62
|
+
/** The executor, built with {@link personalExecutorHooks}' heartbeat and cancellation. */
|
|
63
|
+
executor: Executor;
|
|
64
|
+
log: (line: string) => void;
|
|
65
|
+
fetch?: Fetch;
|
|
66
|
+
/** How long to wait before asking again after the platform did not answer. */
|
|
67
|
+
retryMs?: number;
|
|
68
|
+
/** How often the workspace volumes of runs that are over are swept. */
|
|
69
|
+
sweepIntervalMs?: number;
|
|
70
|
+
/** Least time between two "still alive" reports of one step. */
|
|
71
|
+
aliveEveryMs?: number;
|
|
72
|
+
/** How long one attempt to deliver a result may take before it is given up and tried again. */
|
|
73
|
+
resultTimeoutMs?: number;
|
|
74
|
+
/**
|
|
75
|
+
* Leave once no step is running and none came for this long: an executor
|
|
76
|
+
* inside a CI job, which should end when the work has. Judged between two
|
|
77
|
+
* asks, never during one, so no step is handed to an executor that left.
|
|
78
|
+
* Absent: run until stopped.
|
|
79
|
+
*/
|
|
80
|
+
exitWhenIdleMs?: number;
|
|
81
|
+
/**
|
|
82
|
+
* Take the jobs of this run alone: a worker started for one run, which
|
|
83
|
+
* works it step after step and leaves (`exitWhenIdleMs`) when no step
|
|
84
|
+
* comes — the run waits at a gate, or is over.
|
|
85
|
+
*/
|
|
86
|
+
run?: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface JobScope {
|
|
90
|
+
heartbeat(): void;
|
|
91
|
+
signal: AbortSignal;
|
|
92
|
+
}
|
|
93
|
+
const scope = new AsyncLocalStorage<JobScope>();
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The heartbeat and cancellation an executor running under
|
|
97
|
+
* {@link runPersonalExecutor} is built with: each step's own, reported to
|
|
98
|
+
* the platform rather than to Temporal.
|
|
99
|
+
*/
|
|
100
|
+
export const personalExecutorHooks = {
|
|
101
|
+
heartbeat: (): void => scope.getStore()?.heartbeat(),
|
|
102
|
+
cancellation: (): AbortSignal | undefined => scope.getStore()?.signal,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** Run until `stop` aborts: `slots` loops, each asking the platform for a step, running it and answering. */
|
|
106
|
+
export async function runPersonalExecutor(options: PersonalExecutorOptions, stop: AbortSignal): Promise<void> {
|
|
107
|
+
const fetchImpl: Fetch = options.fetch ?? (globalThis.fetch as unknown as Fetch);
|
|
108
|
+
const retryMs = options.retryMs ?? 5_000;
|
|
109
|
+
const aliveEveryMs = options.aliveEveryMs ?? 10_000;
|
|
110
|
+
const resultTimeoutMs = options.resultTimeoutMs ?? 30_000;
|
|
111
|
+
/** The person this executor runs for, once a step has said so. */
|
|
112
|
+
let owner: string | null = null;
|
|
113
|
+
/** This process, the same on every poll of every slot: a second machine under the name is refused. */
|
|
114
|
+
const instance = randomUUID();
|
|
115
|
+
const base = `${options.apiUrl.replace(/\/+$/, '')}${PERSONAL_EXECUTOR_API}`;
|
|
116
|
+
const headers = { authorization: `Bearer ${options.token}`, 'content-type': 'application/json' };
|
|
117
|
+
const post = (path: string, body: unknown, signal?: AbortSignal) => fetchImpl(`${base}${path}`, { method: 'POST', headers, body: JSON.stringify(body), signal });
|
|
118
|
+
const wait = (ms: number) => new Promise<void>((resolve) => {
|
|
119
|
+
const t = setTimeout(resolve, ms);
|
|
120
|
+
stop.addEventListener('abort', () => { clearTimeout(t); resolve(); }, { once: true });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
async function answer(job: PersonalJob, result: PersonalJobResult, stillAlive: () => void = () => undefined): Promise<void> {
|
|
124
|
+
// The result must land: the platform's step waits on it. Retried until it does, or the executor stops —
|
|
125
|
+
// saying "still alive" the whole time, or the platform gives the finished step up as silent; and no
|
|
126
|
+
// attempt may hang, or it would hold the delivery up without end.
|
|
127
|
+
const keepAlive = setInterval(stillAlive, Math.max(aliveEveryMs, 1));
|
|
128
|
+
keepAlive.unref?.();
|
|
129
|
+
try {
|
|
130
|
+
await deliver();
|
|
131
|
+
} finally {
|
|
132
|
+
clearInterval(keepAlive);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function deliver(): Promise<void> {
|
|
136
|
+
for (;;) {
|
|
137
|
+
try {
|
|
138
|
+
const res = await post(`/jobs/${encodeURIComponent(job.id)}/result`, result, AbortSignal.timeout(resultTimeoutMs));
|
|
139
|
+
if (res.ok || res.status === 404) return; // 404: the platform gave the step up meanwhile; nothing waits on it
|
|
140
|
+
if (res.status === 408 || res.status === 429 || res.status >= 500) {
|
|
141
|
+
// Busy, restarting, or a proxy in between: the answer is still wanted.
|
|
142
|
+
if (stop.aborted) return;
|
|
143
|
+
options.log(`job ${job.id}: the platform could not take the result yet (${res.status}); trying again`);
|
|
144
|
+
await wait(retryMs);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
// Any other refusal is about the answer itself, and the same answer would be refused again.
|
|
148
|
+
options.log(`job ${job.id}: the result was refused (${res.status}): ${await res.text()}`);
|
|
149
|
+
return;
|
|
150
|
+
} catch (err) {
|
|
151
|
+
if (stop.aborted) return;
|
|
152
|
+
options.log(`job ${job.id}: the result did not reach the platform (${errText(err)}); trying again`);
|
|
153
|
+
await wait(retryMs);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function run(job: PersonalJob): Promise<void> {
|
|
160
|
+
// Whose executor this is, learnt from the first step it is given (every step is that person's run).
|
|
161
|
+
if (job.kind === 'step') owner ??= job.input.step?.actor?.userId ?? null;
|
|
162
|
+
if (job.kind === 'release') {
|
|
163
|
+
try {
|
|
164
|
+
await options.executor.releaseRun(job.input);
|
|
165
|
+
await answer(job, { done: true });
|
|
166
|
+
} catch (err) {
|
|
167
|
+
await answer(job, { error: errText(err) });
|
|
168
|
+
}
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
// The step's cancellation: the platform says so in its answer to "still alive".
|
|
172
|
+
const cancelled = new AbortController();
|
|
173
|
+
let lastAlive = 0;
|
|
174
|
+
const heartbeat = () => {
|
|
175
|
+
const now = Date.now();
|
|
176
|
+
if (now - lastAlive < aliveEveryMs) return;
|
|
177
|
+
lastAlive = now;
|
|
178
|
+
void post(`/jobs/${encodeURIComponent(job.id)}/alive`, {})
|
|
179
|
+
.then(async (res) => {
|
|
180
|
+
if (res.status === 404) cancelled.abort(); // the platform no longer holds the step
|
|
181
|
+
else if (res.ok && ((await res.json()) as { cancelled?: boolean }).cancelled) cancelled.abort();
|
|
182
|
+
})
|
|
183
|
+
.catch((err) => options.log(`job ${job.id}: alive report failed: ${errText(err)}`));
|
|
184
|
+
};
|
|
185
|
+
let result: PersonalJobResult;
|
|
186
|
+
try {
|
|
187
|
+
const outcome = await scope.run({ heartbeat, signal: cancelled.signal }, () => options.executor.runContainerStep(job.input));
|
|
188
|
+
result = { outcome };
|
|
189
|
+
} catch (err) {
|
|
190
|
+
// The executor's account-limit pause names its end as the failure's detail; the platform waits for it.
|
|
191
|
+
const details = (err as { details?: unknown[] })?.details;
|
|
192
|
+
const type = (err as { type?: string })?.type;
|
|
193
|
+
result = { error: errText(err), ...(type === 'ExecutorPaused' && typeof details?.[0] === 'number' ? { pausedUntil: details[0] } : {}) };
|
|
194
|
+
}
|
|
195
|
+
await answer(job, result, heartbeat);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Steps running now, over every slot, and when the last one ended (or this executor started). */
|
|
199
|
+
let running = 0;
|
|
200
|
+
let lastWork = Date.now();
|
|
201
|
+
let left = false;
|
|
202
|
+
/** Whether it is time to leave; says so once. Asked only between two asks for work. */
|
|
203
|
+
const leaves = (): boolean => {
|
|
204
|
+
if (left) return true;
|
|
205
|
+
if (options.exitWhenIdleMs === undefined || running > 0 || Date.now() - lastWork < options.exitWhenIdleMs) return false;
|
|
206
|
+
left = true;
|
|
207
|
+
options.log(`no step for ${Math.round(options.exitWhenIdleMs / 1000)} s: leaving`);
|
|
208
|
+
return true;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
async function loop(): Promise<void> {
|
|
212
|
+
while (!stop.aborted && !leaves()) {
|
|
213
|
+
let job: PersonalJob | null = null;
|
|
214
|
+
try {
|
|
215
|
+
const res = await post('/poll', { name: options.name, slots: options.slots, kind: options.kind, instance, ...(options.run ? { run: options.run } : {}) }, stop);
|
|
216
|
+
if (res.status === 204) continue;
|
|
217
|
+
if (!res.ok) {
|
|
218
|
+
options.log(`the platform refused the poll (${res.status}): ${(await res.text()).slice(0, 300)}`);
|
|
219
|
+
await wait(retryMs);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
job = ((await res.json()) as { job?: PersonalJob }).job ?? null;
|
|
223
|
+
} catch (err) {
|
|
224
|
+
if (stop.aborted) return;
|
|
225
|
+
options.log(`the platform did not answer (${errText(err)}); asking again in ${Math.round(retryMs / 1000)} s`);
|
|
226
|
+
await wait(retryMs);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (!job) continue;
|
|
230
|
+
running += 1;
|
|
231
|
+
try {
|
|
232
|
+
await run(job);
|
|
233
|
+
} finally {
|
|
234
|
+
running -= 1;
|
|
235
|
+
lastWork = Date.now();
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// The workspace volumes of runs that ended while this machine was off: their release never came.
|
|
241
|
+
const runIsOpen = async (runId: string): Promise<boolean> => {
|
|
242
|
+
try {
|
|
243
|
+
const res = await post('/runs-open', { runIds: [runId] });
|
|
244
|
+
if (!res.ok) return true;
|
|
245
|
+
return ((await res.json()) as { open?: string[] }).open?.includes(runId) ?? true;
|
|
246
|
+
} catch {
|
|
247
|
+
return true; // unknown is kept: a volume removed under a live run loses its work
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
const sweep = setInterval(() => {
|
|
251
|
+
// A person's: only their volumes, and none before it knows the person (another person's executor may share the engine).
|
|
252
|
+
// The deployment's: only the volumes its deployment labelled (another deployment's may share the engine).
|
|
253
|
+
void options.executor.sweepVolumes(runIsOpen, options.kind === 'personal' ? owner : undefined).catch((err) => options.log(`volume sweep failed: ${errText(err)}`));
|
|
254
|
+
}, options.sweepIntervalMs ?? 60 * 60_000);
|
|
255
|
+
sweep.unref?.();
|
|
256
|
+
options.log(`${options.kind === 'personal' ? 'personal' : "the deployment's"} executor "${options.name}" asking ${options.apiUrl} for steps, up to ${options.slots} at once`);
|
|
257
|
+
try {
|
|
258
|
+
await Promise.all(Array.from({ length: Math.max(1, options.slots) }, () => loop()));
|
|
259
|
+
} finally {
|
|
260
|
+
clearInterval(sweep);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** A run with a step waiting for an executor and no worker asking for it, as the platform lists them. */
|
|
265
|
+
export interface WaitingRun {
|
|
266
|
+
runId: string;
|
|
267
|
+
workflowPath: string;
|
|
268
|
+
stepName: string;
|
|
269
|
+
/** The workflow's `environment:` as written; null for none. */
|
|
270
|
+
environment: string | null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export interface WorkerStarterOptions {
|
|
274
|
+
apiUrl: string;
|
|
275
|
+
token: string;
|
|
276
|
+
name: string;
|
|
277
|
+
kind: 'personal' | 'deployment';
|
|
278
|
+
/** Start a worker for a run: a process elsewhere (a CI pipeline) that asks for that run's steps under the same name. */
|
|
279
|
+
startWorker: (run: WaitingRun) => Promise<void>;
|
|
280
|
+
log: (line: string) => void;
|
|
281
|
+
fetch?: Fetch;
|
|
282
|
+
/** How often to look for waiting runs. */
|
|
283
|
+
everyMs?: number;
|
|
284
|
+
/** How long a started worker is given to come up before another is started for the same run. */
|
|
285
|
+
workerStartMs?: number;
|
|
286
|
+
/** Leave once no run waited for this long. Absent: run until stopped. */
|
|
287
|
+
exitWhenIdleMs?: number;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* An executor that runs no step itself: it looks for the runs with a step
|
|
292
|
+
* waiting under its name and starts a worker for each, until `stop` aborts
|
|
293
|
+
* or nothing waited for the idle time. Its looking keeps the name served, so
|
|
294
|
+
* the steps stay held while the workers come up.
|
|
295
|
+
*/
|
|
296
|
+
export async function runWorkerStarter(options: WorkerStarterOptions, stop: AbortSignal): Promise<void> {
|
|
297
|
+
const fetchImpl: Fetch = options.fetch ?? (globalThis.fetch as unknown as Fetch);
|
|
298
|
+
const everyMs = options.everyMs ?? 10_000;
|
|
299
|
+
const workerStartMs = options.workerStartMs ?? 5 * 60_000;
|
|
300
|
+
const instance = randomUUID();
|
|
301
|
+
const url = `${options.apiUrl.replace(/\/+$/, '')}${PERSONAL_EXECUTOR_API}/waiting`;
|
|
302
|
+
const headers = { authorization: `Bearer ${options.token}`, 'content-type': 'application/json' };
|
|
303
|
+
/** When a worker was last started for each run. */
|
|
304
|
+
const started = new Map<string, number>();
|
|
305
|
+
let lastWork = Date.now();
|
|
306
|
+
options.log(`executor "${options.name}" looking at ${options.apiUrl} for runs to start workers for`);
|
|
307
|
+
while (!stop.aborted) {
|
|
308
|
+
try {
|
|
309
|
+
const res = await fetchImpl(url, { method: 'POST', headers, body: JSON.stringify({ name: options.name, kind: options.kind, instance }), signal: stop });
|
|
310
|
+
if (!res.ok) {
|
|
311
|
+
options.log(`the platform refused the look (${res.status}): ${(await res.text()).slice(0, 300)}`);
|
|
312
|
+
// Not idle: nothing says no run waits. Only a look that was answered counts towards leaving.
|
|
313
|
+
lastWork = Date.now();
|
|
314
|
+
} else {
|
|
315
|
+
const runs = ((await res.json()) as { runs?: WaitingRun[] }).runs ?? [];
|
|
316
|
+
if (runs.length > 0) lastWork = Date.now();
|
|
317
|
+
for (const run of runs) {
|
|
318
|
+
const at = started.get(run.runId);
|
|
319
|
+
// A worker is on its way for it: the platform stops listing the run once that worker asks.
|
|
320
|
+
if (at !== undefined && Date.now() - at < workerStartMs) continue;
|
|
321
|
+
started.set(run.runId, Date.now());
|
|
322
|
+
try {
|
|
323
|
+
await options.startWorker(run);
|
|
324
|
+
options.log(`started a worker for run ${run.runId} (${run.stepName})`);
|
|
325
|
+
} catch (err) {
|
|
326
|
+
// Tried again at the next look, not after the wait a started worker is given.
|
|
327
|
+
started.delete(run.runId);
|
|
328
|
+
options.log(`a worker for run ${run.runId} could not be started: ${errText(err)}`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
} catch (err) {
|
|
333
|
+
if (stop.aborted) return;
|
|
334
|
+
options.log(`the platform did not answer (${errText(err)})`);
|
|
335
|
+
lastWork = Date.now();
|
|
336
|
+
}
|
|
337
|
+
if (options.exitWhenIdleMs !== undefined && Date.now() - lastWork >= options.exitWhenIdleMs) {
|
|
338
|
+
options.log(`no run waited for ${Math.round(options.exitWhenIdleMs / 1000)} s: leaving`);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
await new Promise<void>((resolve) => {
|
|
342
|
+
const done = () => {
|
|
343
|
+
clearTimeout(t);
|
|
344
|
+
stop.removeEventListener('abort', done);
|
|
345
|
+
resolve();
|
|
346
|
+
};
|
|
347
|
+
const t = setTimeout(done, everyMs);
|
|
348
|
+
stop.addEventListener('abort', done, { once: true });
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function errText(err: unknown): string {
|
|
354
|
+
return err instanceof Error ? err.message : String(err);
|
|
355
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One limit for everything an executor runs at once. Its two Temporal workers
|
|
3
|
+
* (the shared queue and its own) each cap their activities on their own, so
|
|
4
|
+
* without this each queue could fill the host by itself; a step that arrives
|
|
5
|
+
* while every slot is taken waits here, and says so while it waits so the
|
|
6
|
+
* activity's heartbeat keeps the task alive. A waiter whose activity is
|
|
7
|
+
* cancelled (the run cancelled, the activity timed out) leaves the queue at
|
|
8
|
+
* once: a slot must never go to a step nobody waits for.
|
|
9
|
+
*/
|
|
10
|
+
export class Slots {
|
|
11
|
+
private free: number;
|
|
12
|
+
private readonly waiting: Array<{ grant: () => void; drop: () => void }> = [];
|
|
13
|
+
|
|
14
|
+
constructor(size: number) {
|
|
15
|
+
this.free = Math.max(1, size);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async run<T>(fn: () => Promise<T>, whileWaiting?: { every: number; tick: () => void }, signal?: AbortSignal): Promise<T> {
|
|
19
|
+
await this.take(whileWaiting, signal);
|
|
20
|
+
try {
|
|
21
|
+
return await fn();
|
|
22
|
+
} finally {
|
|
23
|
+
this.free += 1;
|
|
24
|
+
this.waiting.shift()?.grant();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private take(whileWaiting?: { every: number; tick: () => void }, signal?: AbortSignal): Promise<void> {
|
|
29
|
+
if (signal?.aborted) return Promise.reject(abortReason(signal));
|
|
30
|
+
if (this.free > 0) {
|
|
31
|
+
this.free -= 1;
|
|
32
|
+
return Promise.resolve();
|
|
33
|
+
}
|
|
34
|
+
return new Promise<void>((resolve, reject) => {
|
|
35
|
+
const timer = whileWaiting ? setInterval(whileWaiting.tick, whileWaiting.every) : null;
|
|
36
|
+
timer?.unref?.();
|
|
37
|
+
const waiter = {
|
|
38
|
+
grant: () => {
|
|
39
|
+
if (timer) clearInterval(timer);
|
|
40
|
+
signal?.removeEventListener('abort', onAbort);
|
|
41
|
+
this.free -= 1;
|
|
42
|
+
resolve();
|
|
43
|
+
},
|
|
44
|
+
drop: () => {
|
|
45
|
+
if (timer) clearInterval(timer);
|
|
46
|
+
const at = this.waiting.indexOf(waiter);
|
|
47
|
+
if (at !== -1) this.waiting.splice(at, 1);
|
|
48
|
+
reject(abortReason(signal!));
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
const onAbort = () => waiter.drop();
|
|
52
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
53
|
+
this.waiting.push(waiter);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function abortReason(signal: AbortSignal): unknown {
|
|
59
|
+
return signal.reason ?? new Error('cancelled while waiting for a slot');
|
|
60
|
+
}
|