@bevel-software/ael 0.1.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bevel-software/ael",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "The agentic execution layer of the Bevel platform: the one parser for .agent and .workflow files, the run contract, the harnesses, the interpreter the platform runs on Temporal, the executor, and the bevel-agent command line.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "repository": {
package/src/cli/main.ts CHANGED
@@ -17,6 +17,12 @@ import { OUTCOME_MARKER, readAll, runStep } from './run-step.js';
17
17
  * What an executor execs inside a run container.
18
18
  * bevel-agent executor an executor on this host: polls the executor
19
19
  * queues and runs container steps on its engine.
20
+ * bevel-agent ci-executor
21
+ * the executor job of a CI pipeline: runs no step,
22
+ * starts a worker pipeline per run with work.
23
+ * bevel-agent ci-worker <run id>
24
+ * a worker job of a CI pipeline: that run's steps,
25
+ * in the job itself, until it waits or is over.
20
26
  *
21
27
  * Two doors in: `bin/bevel-agent.mjs` (what npm installs; registers tsx and
22
28
  * calls `main`), and this file as the script tsx runs directly (the executor
@@ -50,12 +56,14 @@ export async function main(argv: string[]): Promise<number> {
50
56
  process.stdout.write(`${OUTCOME_MARKER}${JSON.stringify(outcome)}\n`);
51
57
  return 0;
52
58
  }
53
- case 'executor': {
59
+ case 'executor':
60
+ case 'ci-executor':
61
+ case 'ci-worker': {
54
62
  const { executorMain } = await import('../executor/main.js');
55
- return executorMain();
63
+ return executorMain(command, argv[1]);
56
64
  }
57
65
  default:
58
- process.stderr.write('usage: bevel-agent run <file> [input] | run-step | executor\n');
66
+ process.stderr.write('usage: bevel-agent run <file> [input] | run-step | executor | ci-executor | ci-worker <run id>\n');
59
67
  return 2;
60
68
  }
61
69
  }
@@ -35,23 +35,55 @@ import { PERSONAL_EXECUTOR_NAME } from '../workflow/definition.js';
35
35
  * beyond the defaults, such as running a rootless Docker daemon inside a step.
36
36
  * Split on spaces and nothing else: no quoting, so no value with a space in it
37
37
  *
38
- * An executor inside a CI job, which is the environment already and ends with the work:
39
- *
40
38
  * EXECUTOR_IN_PLACE `true`: each step runs as a process of this executor, in a directory per run,
41
39
  * with no container engine; the engine settings above (bundle, limits, run
42
40
  * 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)
41
+ * EXECUTOR_EXIT_WHEN_IDLE seconds without work after which it leaves (default: never)
50
42
  * EXECUTOR_TOKEN_FILE a file holding the key, in place of EXECUTOR_TOKEN: read once and removed, so
51
43
  * a step running beside the executor finds it neither in a variable nor on disk
44
+ *
45
+ * In a CI pipeline the executor is two jobs under one name, each its own command, so neither can become
46
+ * the other through a variable set in the wrong place:
47
+ *
48
+ * `bevel-agent ci-executor` runs no step. It looks for runs with a step waiting and starts a worker for each:
49
+ *
50
+ * EXECUTOR_START_WORKER a shell command (required), run once per run, to start a worker elsewhere (another
51
+ * pipeline); it gets BEVEL_RUN_ID, BEVEL_WORKFLOW, BEVEL_STEP and BEVEL_ENVIRONMENT
52
+ * (the workflow's `environment:` as written: the worker job to start)
53
+ * EXECUTOR_EXIT_WHEN_IDLE seconds without a waiting run after which it leaves (default 480)
54
+ *
55
+ * `bevel-agent ci-worker <run id>` takes that run's steps alone, one after another, each in place, and leaves
56
+ * when none comes — the run waits at a gate, or is over:
57
+ *
58
+ * EXECUTOR_EXIT_WHEN_IDLE seconds without a step after which it leaves (default 90)
52
59
  */
53
- export async function executorMain(): Promise<number> {
60
+ export type ExecutorRole = 'executor' | 'ci-executor' | 'ci-worker';
61
+
62
+ /** A run id as an executor may name one. */
63
+ const RUN_ID = /^[A-Za-z0-9_-]{1,80}$/;
64
+
65
+ export async function executorMain(role: ExecutorRole = 'executor', runArg?: string): Promise<number> {
54
66
  const env = process.env;
67
+ // The role's own inputs first, before anything is read or removed: each command says what it lacks,
68
+ // and refuses the other one's settings rather than quietly becoming it.
69
+ const startWorker = env.EXECUTOR_START_WORKER?.trim();
70
+ const run = runArg?.trim();
71
+ if (role === 'executor' && (startWorker || env.EXECUTOR_RUN?.trim())) {
72
+ process.stderr.write('EXECUTOR_START_WORKER and EXECUTOR_RUN are not settings of `bevel-agent executor`: a CI pipeline runs `bevel-agent ci-executor` (starts workers) and `bevel-agent ci-worker <run id>` (works one run)\n');
73
+ return 2;
74
+ }
75
+ if (role === 'ci-executor' && !startWorker) {
76
+ process.stderr.write('EXECUTOR_START_WORKER is required for `bevel-agent ci-executor`: the command that starts a worker pipeline for a run (it gets BEVEL_RUN_ID and BEVEL_ENVIRONMENT)\n');
77
+ return 2;
78
+ }
79
+ if (role === 'ci-worker' && startWorker) {
80
+ process.stderr.write('EXECUTOR_START_WORKER is set on a worker job: a worker that started workers would start pipelines without end. Set it on the `bevel-agent ci-executor` job alone\n');
81
+ return 2;
82
+ }
83
+ if (role === 'ci-worker' && (!run || !RUN_ID.test(run))) {
84
+ process.stderr.write('usage: bevel-agent ci-worker <run id> — the run this worker was started for (BEVEL_RUN_ID of the command that started it)\n');
85
+ return 2;
86
+ }
55
87
  const platformUrl = env.PLATFORM_URL?.trim().replace(/\/+$/, '');
56
88
  if (!platformUrl) {
57
89
  process.stderr.write("PLATFORM_URL is required: the platform's address, which every executor asks for its steps\n");
@@ -92,14 +124,14 @@ export async function executorMain(): Promise<number> {
92
124
  const kind = rawKind;
93
125
  const log = (line: string) => process.stderr.write(`[executor ${name}] ${line}\n`);
94
126
  const idleSeconds = Number(env.EXECUTOR_EXIT_WHEN_IDLE);
95
- const exitWhenIdleMs = Number.isFinite(idleSeconds) && idleSeconds > 0 ? idleSeconds * 1000 : undefined;
127
+ // A CI job ends by itself, set or not: one that stayed would hold a runner until the job's own limit.
128
+ const idleDefaultMs = role === 'ci-executor' ? 480_000 : role === 'ci-worker' ? 90_000 : undefined;
129
+ const exitWhenIdleMs = Number.isFinite(idleSeconds) && idleSeconds > 0 ? idleSeconds * 1000 : idleDefaultMs;
96
130
  const queueName = kind === 'deployment' ? `executor-${name}` : `personal-${name}`;
97
131
  const stop = new AbortController();
98
132
  process.once('SIGINT', () => stop.abort());
99
133
  process.once('SIGTERM', () => stop.abort());
100
- const run = env.EXECUTOR_RUN?.trim() || undefined;
101
- const startWorker = env.EXECUTOR_START_WORKER?.trim();
102
- if (startWorker) {
134
+ if (role === 'ci-executor' && startWorker) {
103
135
  await runWorkerStarter(
104
136
  {
105
137
  apiUrl: `${platformUrl}/api`,
@@ -116,7 +148,8 @@ export async function executorMain(): Promise<number> {
116
148
  log('stopped');
117
149
  return 0;
118
150
  }
119
- if (/^(1|true|yes)$/i.test(env.EXECUTOR_IN_PLACE?.trim() ?? '')) {
151
+ // A worker's job is the environment, always: there is no engine in it to ask for a container.
152
+ if (role === 'ci-worker' || /^(1|true|yes)$/i.test(env.EXECUTOR_IN_PLACE?.trim() ?? '')) {
120
153
  const executor = createInPlaceExecutor({
121
154
  queueName,
122
155
  workspacesDir: path.join(tmpdir(), 'bevel-runs'),
@@ -126,7 +159,7 @@ export async function executorMain(): Promise<number> {
126
159
  cancellation: personalExecutorHooks.cancellation,
127
160
  });
128
161
  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);
162
+ await runPersonalExecutor({ apiUrl: `${platformUrl}/api`, token, name, kind, slots: Number(env.EXECUTOR_MAX_STEPS) || 1, executor, log, exitWhenIdleMs, ...(role === 'ci-worker' ? { run } : {}) }, stop.signal);
130
163
  log('stopped');
131
164
  return 0;
132
165
  }
@@ -150,7 +183,7 @@ export async function executorMain(): Promise<number> {
150
183
  heartbeat: personalExecutorHooks.heartbeat,
151
184
  cancellation: personalExecutorHooks.cancellation,
152
185
  });
153
- await runPersonalExecutor({ apiUrl: `${platformUrl}/api`, token, name, kind, slots: Number(env.EXECUTOR_MAX_STEPS) || 2, executor, log, exitWhenIdleMs, run }, stop.signal);
186
+ await runPersonalExecutor({ apiUrl: `${platformUrl}/api`, token, name, kind, slots: Number(env.EXECUTOR_MAX_STEPS) || 2, executor, log, exitWhenIdleMs }, stop.signal);
154
187
  log('stopped');
155
188
  return 0;
156
189
  }