@async/pipeline 0.1.5 → 0.2.1

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 (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2 -0
  3. package/dist/cli.d.ts +1 -1
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +5 -1
  6. package/dist/cli.js.map +1 -1
  7. package/dist/internal/core/index.d.ts +41 -40
  8. package/dist/internal/core/index.d.ts.map +1 -1
  9. package/dist/internal/core/index.js +98 -26
  10. package/dist/internal/core/index.js.map +1 -1
  11. package/dist/internal/lima/index.d.ts +1 -1
  12. package/dist/internal/lima/index.d.ts.map +1 -1
  13. package/dist/internal/lima/index.js +1 -1
  14. package/dist/internal/lima/index.js.map +1 -1
  15. package/dist/internal/node/cli.d.ts +5 -2
  16. package/dist/internal/node/cli.d.ts.map +1 -1
  17. package/dist/internal/node/cli.js +288 -85
  18. package/dist/internal/node/cli.js.map +1 -1
  19. package/dist/internal/node/doctor.d.ts +1 -1
  20. package/dist/internal/node/doctor.d.ts.map +1 -1
  21. package/dist/internal/node/doctor.js +50 -1
  22. package/dist/internal/node/doctor.js.map +1 -1
  23. package/dist/internal/node/github.d.ts +2 -0
  24. package/dist/internal/node/github.d.ts.map +1 -1
  25. package/dist/internal/node/github.js +59 -11
  26. package/dist/internal/node/github.js.map +1 -1
  27. package/dist/internal/node/loader.d.ts.map +1 -1
  28. package/dist/internal/node/loader.js +4 -0
  29. package/dist/internal/node/loader.js.map +1 -1
  30. package/dist/internal/node/runner.d.ts +64 -49
  31. package/dist/internal/node/runner.d.ts.map +1 -1
  32. package/dist/internal/node/runner.js +473 -131
  33. package/dist/internal/node/runner.js.map +1 -1
  34. package/dist/internal/node/sources.d.ts +3 -0
  35. package/dist/internal/node/sources.d.ts.map +1 -1
  36. package/dist/internal/node/sources.js +23 -6
  37. package/dist/internal/node/sources.js.map +1 -1
  38. package/dist/internal/node/store.d.ts +22 -0
  39. package/dist/internal/node/store.d.ts.map +1 -1
  40. package/dist/internal/node/store.js +155 -24
  41. package/dist/internal/node/store.js.map +1 -1
  42. package/package.json +4 -2
@@ -4,12 +4,12 @@ import { availableParallelism } from "node:os";
4
4
  import { posix, relative } from "node:path";
5
5
  import { setTimeout as delay } from "node:timers/promises";
6
6
  import { sh, tasksForJob } from "../core/index.js";
7
- import { computeTaskCacheKey, createStore, outputFilesExist, readCacheEntry, resolveOutputFiles, restoreCacheOutputs, writeCacheEntry, writeExecution, writeTaskLog } from "./store.js";
7
+ import { acquireRunLock, computeTaskCacheKey, createStore, outputFilesExist, readCacheEntry, resolveOutputFiles, restoreCacheOutputs, writeCacheEntry, writeExecution, writeTaskLog } from "./store.js";
8
8
  import { createRunPlan, sourceContext } from "./sources.js";
9
9
  export class HostCommandExecutor {
10
10
  name = "host";
11
11
  runShell(command, options) {
12
- return runProcess(command, { cwd: options.cwd, env: options.env, timeoutMs: options.timeoutMs });
12
+ return runProcess(command, { cwd: options.cwd, env: options.env, echo: options.echo, timeoutMs: options.timeoutMs, label: options.task?.id, redactValues: options.redactValues });
13
13
  }
14
14
  async checkTool(tool) {
15
15
  const result = await runProcess(`command -v ${shellEscape(tool)}`, { cwd: process.cwd(), env: process.env, echo: false });
@@ -23,17 +23,20 @@ export class DockerCommandExecutor {
23
23
  this.options = options;
24
24
  }
25
25
  runShell(command, options) {
26
- return runProcess(this.dockerCommand(command, options.cwd, options.env), { cwd: this.options.hostCwd, env: options.env, timeoutMs: options.timeoutMs });
26
+ return runProcess(this.dockerCommand(command, options.cwd, options.env, options.forwardEnvKeys ?? []), { cwd: this.options.hostCwd, env: options.env, echo: options.echo, timeoutMs: options.timeoutMs, label: options.task?.id, redactValues: options.redactValues });
27
27
  }
28
28
  async checkTool(tool) {
29
- const result = await runProcess(this.dockerCommand(`command -v ${shellEscape(tool)}`, this.options.hostCwd, process.env), {
29
+ const result = await runProcess(this.dockerCommand(`command -v ${shellEscape(tool)}`, this.options.hostCwd, process.env, []), {
30
30
  cwd: this.options.hostCwd,
31
31
  env: process.env,
32
32
  echo: false
33
33
  });
34
34
  return result.code === 0;
35
35
  }
36
- dockerCommand(command, cwd, env) {
36
+ dockerCommand(command, cwd, env, forwardEnvKeys) {
37
+ // Only explicitly forwarded keys cross the container boundary. Forwarding
38
+ // the whole host env would leak unrelated secrets into every container.
39
+ const forwarded = [...new Set(forwardEnvKeys)].filter((key) => env[key] !== undefined).sort();
37
40
  return [
38
41
  "docker",
39
42
  "run",
@@ -41,7 +44,7 @@ export class DockerCommandExecutor {
41
44
  "-w",
42
45
  shellEscape(this.containerCwd(cwd)),
43
46
  ...this.options.volumes.flatMap((volume) => ["-v", shellEscape(`${volume.source}:${volume.target}${volume.readonly ? ":ro" : ""}`)]),
44
- ...Object.keys(env).filter((key) => env[key] !== undefined).flatMap((key) => ["-e", shellEscape(key)]),
47
+ ...forwarded.flatMap((key) => ["-e", shellEscape(key)]),
45
48
  shellEscape(this.options.image),
46
49
  "bash",
47
50
  "-lc",
@@ -64,9 +67,8 @@ export class LimaCommandExecutor {
64
67
  this.vm = vm;
65
68
  }
66
69
  runShell(command, options) {
67
- const vm = options.task.environment?.vm ?? this.vm;
68
70
  const escaped = shellEscape(`cd ${shellEscape(options.cwd)} && ${command}`);
69
- return runProcess(`limactl shell ${shellEscape(vm)} -- bash -lc ${escaped}`, { cwd: options.cwd, env: options.env, timeoutMs: options.timeoutMs });
71
+ return runProcess(`limactl shell ${shellEscape(this.vm)} -- bash -lc ${escaped}`, { cwd: options.cwd, env: options.env, echo: options.echo, timeoutMs: options.timeoutMs, label: options.task?.id, redactValues: options.redactValues });
70
72
  }
71
73
  async checkTool(tool) {
72
74
  const result = await runProcess(`limactl shell ${shellEscape(this.vm)} -- bash -lc ${shellEscape(`command -v ${shellEscape(tool)}`)}`, {
@@ -76,37 +78,43 @@ export class LimaCommandExecutor {
76
78
  return result.code === 0;
77
79
  }
78
80
  }
79
- export function hostWorkspace(options = {}) {
80
- return {
81
- cwd: options.cwd ?? process.cwd(),
82
- env: options.env ?? process.env,
81
+ /**
82
+ * Resolve flat run options into the execution context a run uses: host by
83
+ * default, or the selected sandbox (by id from `pipeline.sandboxes`, or an
84
+ * inline definition).
85
+ */
86
+ export function resolveExecutionContext(pipeline, target = {}) {
87
+ const cwd = target.cwd ?? process.cwd();
88
+ const env = target.env ?? process.env;
89
+ const base = {
90
+ cwd,
91
+ env,
83
92
  fs: { kind: "host" },
84
- executor: options.executor ?? new HostCommandExecutor(),
85
- commands: options.commands
93
+ executor: target.executor ?? new HostCommandExecutor(),
94
+ commands: target.commands
86
95
  };
87
- }
88
- export function dockerWorkspace(options) {
89
- const cwd = options.cwd ?? process.cwd();
90
- const workdir = options.workdir ?? "/workspace";
91
- return hostWorkspace({
92
- cwd,
93
- env: options.env,
96
+ const ref = target.sandbox;
97
+ if (!ref || ref === "host")
98
+ return base;
99
+ const definition = typeof ref === "string" ? pipeline.sandboxes[ref] : ref;
100
+ if (!definition) {
101
+ throw new Error(`Unknown sandbox "${String(ref)}". Declare it under \`sandboxes\` in the pipeline config.`);
102
+ }
103
+ if (definition.kind === "host")
104
+ return base;
105
+ if (definition.kind === "lima") {
106
+ return { ...base, executor: new LimaCommandExecutor(definition.vm) };
107
+ }
108
+ const workdir = definition.workdir ?? "/workspace";
109
+ return {
110
+ ...base,
94
111
  executor: new DockerCommandExecutor({
95
- image: options.image,
112
+ image: definition.image,
96
113
  hostCwd: cwd,
97
114
  workdir,
98
- volumes: options.volumes ?? [{ source: cwd, target: workdir }]
99
- }),
100
- commands: options.commands
101
- });
102
- }
103
- export function limaWorkspace(options = {}) {
104
- return hostWorkspace({
105
- cwd: options.cwd,
106
- env: options.env,
107
- executor: new LimaCommandExecutor(options.vm),
108
- commands: options.commands
109
- });
115
+ volumes: definition.volumes ?? [{ source: cwd, target: workdir }]
116
+ })
117
+ };
110
118
  }
111
119
  export function commandProxy(policy = { rules: [] }) {
112
120
  const records = [];
@@ -116,7 +124,7 @@ export function commandProxy(policy = { rules: [] }) {
116
124
  const started = Date.now();
117
125
  const action = matchingAction(policy, invocation.argv);
118
126
  const status = commandStatus(action);
119
- const result = await runCommandAction(action, next);
127
+ const result = await runCommandAction(action, invocation, next);
120
128
  const outputPolicy = action.output ?? policy.output ?? {};
121
129
  if (policy.record) {
122
130
  records.push({
@@ -144,18 +152,34 @@ export function commandProxy(policy = { rules: [] }) {
144
152
  const memoryCacheEntries = new Map();
145
153
  const DEFAULT_MAX_CONCURRENCY = 4;
146
154
  export async function runJob(pipeline, options) {
147
- const workspace = options.workspace ?? hostWorkspace();
148
- const store = await createStore(workspace.cwd);
149
- const plan = await createRunPlan(pipeline, workspace.cwd, store);
155
+ // Install before any task spawns so an early Ctrl-C still finalizes the record.
156
+ ensureSignalForwarding();
157
+ const context = resolveExecutionContext(pipeline, options);
158
+ const store = await createStore(context.cwd);
159
+ // One run at a time per project: concurrent runs would race on the task
160
+ // cache, run records, and synced outputs. A lock whose holder process is
161
+ // dead is reclaimed automatically.
162
+ const lock = await acquireRunLock(store);
163
+ try {
164
+ return await runJobLocked(pipeline, options, context, store);
165
+ }
166
+ finally {
167
+ await lock.release();
168
+ }
169
+ }
170
+ async function runJobLocked(pipeline, options, context, store) {
171
+ const plan = await createRunPlan(pipeline, context.cwd, store);
150
172
  const graph = tasksForJob(plan.pipeline, options.id);
151
173
  const record = {
174
+ schemaVersion: 1,
152
175
  id: `${new Date().toISOString().replaceAll(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}`,
153
176
  pipelineName: plan.pipeline.name,
154
177
  jobId: options.id,
155
- cwd: workspace.cwd,
178
+ cwd: context.cwd,
179
+ pid: process.pid,
156
180
  startedAt: new Date().toISOString(),
157
181
  status: "running",
158
- mode: options.mode ?? (workspace.env.CI ? "ci" : "manual"),
182
+ mode: options.mode ?? (context.env.CI ? "ci" : "manual"),
159
183
  tasks: [],
160
184
  sources: Object.fromEntries(Object.entries(plan.sources).map(([sourceId, resolved]) => [sourceId, resolved.record]))
161
185
  };
@@ -199,10 +223,11 @@ export async function runJob(pipeline, options) {
199
223
  if (!promise) {
200
224
  promise = runSourcePrepare(source, {
201
225
  candidate: plan.candidate,
202
- executor: workspace.executor,
203
- rootCwd: workspace.cwd,
226
+ executor: context.executor,
227
+ rootCwd: context.cwd,
204
228
  runId: record.id,
205
- workspaceEnv: workspace.env,
229
+ contextEnv: context.env,
230
+ echo: options.echo,
206
231
  store
207
232
  });
208
233
  sourcePreparePromises.set(source.id, promise);
@@ -233,70 +258,188 @@ export async function runJob(pipeline, options) {
233
258
  }
234
259
  const result = await runTask(plan.pipeline, taskDefinition, {
235
260
  candidate: plan.candidate,
236
- cwd: taskDefinition.source?.dir || workspace.cwd,
237
- executor: workspace.executor,
238
- rootCwd: workspace.cwd,
261
+ cwd: taskDefinition.source?.dir || context.cwd,
262
+ executor: context.executor,
263
+ rootCwd: context.cwd,
239
264
  runId: record.id,
240
265
  source: taskDefinition.source,
241
266
  envDefinitions,
242
- workspaceEnv: workspace.env,
267
+ contextEnv: context.env,
243
268
  sourcePrepareCommands: taskSource ? await resolvePrepareCommands(taskSource, {
244
269
  candidate: plan.candidate,
245
- rootCwd: workspace.cwd,
270
+ rootCwd: context.cwd,
246
271
  runId: record.id,
247
- workspaceEnv: workspace.env
272
+ contextEnv: context.env
248
273
  }) : [],
249
274
  dependencyFingerprints: Object.fromEntries(taskDefinition.dependsOn.map((dependency) => [
250
275
  dependency,
251
276
  taskFingerprints.get(dependency) ?? null
252
277
  ])),
278
+ force: options.force,
279
+ echo: options.echo,
253
280
  store
254
281
  });
255
282
  results.push({ order: graphIndex.get(taskId) ?? results.length, result });
256
283
  return { taskId, failed: result.status === "failed", results, taskResult: result };
257
284
  };
258
- while (ready.length > 0 || running.size > 0) {
259
- while (!failed && ready.length > 0 && running.size < concurrency) {
260
- const taskId = ready.shift();
261
- if (!taskId)
285
+ const scheduleTask = (taskId) =>
286
+ // The scheduler races these promises, so they must never reject: an
287
+ // unexpected throw becomes a failed task result and the record finalizes.
288
+ runScheduledTask(taskId).catch((error) => {
289
+ const result = {
290
+ id: taskId,
291
+ status: "failed",
292
+ startedAt: new Date().toISOString(),
293
+ finishedAt: new Date().toISOString(),
294
+ durationMs: 0,
295
+ attempts: 0,
296
+ cacheHit: false,
297
+ error: error instanceof Error ? error.message : String(error)
298
+ };
299
+ return {
300
+ taskId,
301
+ failed: true,
302
+ results: [{ order: graphIndex.get(taskId) ?? 0, result }],
303
+ taskResult: result
304
+ };
305
+ });
306
+ try {
307
+ while (ready.length > 0 || running.size > 0) {
308
+ while (!failed && ready.length > 0 && running.size < concurrency) {
309
+ const taskId = ready.shift();
310
+ if (!taskId)
311
+ break;
312
+ running.set(taskId, scheduleTask(taskId));
313
+ }
314
+ if (running.size === 0)
262
315
  break;
263
- running.set(taskId, runScheduledTask(taskId));
264
- }
265
- if (running.size === 0)
266
- break;
267
- const completed = await Promise.race(running.values());
268
- running.delete(completed.taskId);
269
- for (const entry of completed.results) {
270
- recordedResults.set(entry.result.id, entry);
271
- }
272
- if (completed.taskResult) {
273
- taskFingerprints.set(completed.taskId, completed.taskResult.cacheKey ?? `${completed.taskId}:${completed.taskResult.status}`);
274
- }
275
- await updateRecord();
276
- if (completed.failed) {
277
- failed = true;
278
- ready.length = 0;
279
- continue;
280
- }
281
- if (failed || !completed.taskResult)
282
- continue;
283
- const node = graphNodes.get(completed.taskId);
284
- for (const dependent of node?.dependents ?? []) {
285
- if (!plan.pipeline.tasks[dependent])
316
+ const completed = await Promise.race(running.values());
317
+ running.delete(completed.taskId);
318
+ for (const entry of completed.results) {
319
+ recordedResults.set(entry.result.id, entry);
320
+ }
321
+ if (completed.taskResult) {
322
+ taskFingerprints.set(completed.taskId, completed.taskResult.cacheKey ?? `${completed.taskId}:${completed.taskResult.status}`);
323
+ }
324
+ await updateRecord();
325
+ if (completed.failed) {
326
+ failed = true;
327
+ ready.length = 0;
286
328
  continue;
287
- const remaining = Math.max(0, (dependencyCounts.get(dependent) ?? 0) - 1);
288
- dependencyCounts.set(dependent, remaining);
289
- if (remaining === 0) {
290
- ready.push(dependent);
291
329
  }
330
+ if (failed || !completed.taskResult)
331
+ continue;
332
+ const node = graphNodes.get(completed.taskId);
333
+ for (const dependent of node?.dependents ?? []) {
334
+ if (!plan.pipeline.tasks[dependent])
335
+ continue;
336
+ const remaining = Math.max(0, (dependencyCounts.get(dependent) ?? 0) - 1);
337
+ dependencyCounts.set(dependent, remaining);
338
+ if (remaining === 0) {
339
+ ready.push(dependent);
340
+ }
341
+ }
342
+ ready.sort((left, right) => (graphIndex.get(left) ?? 0) - (graphIndex.get(right) ?? 0));
292
343
  }
293
- ready.sort((left, right) => (graphIndex.get(left) ?? 0) - (graphIndex.get(right) ?? 0));
344
+ }
345
+ catch (error) {
346
+ // Never leave an execution record stuck in "running" on disk.
347
+ record.status = "failed";
348
+ record.finishedAt = new Date().toISOString();
349
+ await writeExecution(store, record).catch(() => { });
350
+ throw error;
294
351
  }
295
352
  record.status = failed ? "failed" : "passed";
296
353
  record.finishedAt = new Date().toISOString();
297
354
  await writeExecution(store, record);
298
355
  return record;
299
356
  }
357
+ /**
358
+ * Computes the execution order and predicted cache behavior for a job without running it.
359
+ * Predictions reuse the real cache-key chain but do not validate cached output files.
360
+ */
361
+ export async function planJob(pipeline, options) {
362
+ const context = resolveExecutionContext(pipeline, options);
363
+ const store = await createStore(context.cwd);
364
+ const plan = await createRunPlan(pipeline, context.cwd, store);
365
+ const graph = tasksForJob(plan.pipeline, options.id);
366
+ const jobDefinition = plan.pipeline.jobs[options.id];
367
+ const envDefinitions = {
368
+ ...plan.pipeline.env,
369
+ ...(jobDefinition?.env ?? {})
370
+ };
371
+ const fingerprints = new Map();
372
+ const entries = [];
373
+ for (const taskId of graph.executionOrder) {
374
+ const taskDefinition = plan.pipeline.tasks[taskId];
375
+ if (!taskDefinition)
376
+ continue;
377
+ try {
378
+ const taskCwd = taskDefinition.source?.dir || context.cwd;
379
+ const resolvedEnv = buildTaskEnv(context.env, {
380
+ candidate: plan.candidate,
381
+ envDefinitions,
382
+ rootCwd: context.cwd,
383
+ source: taskDefinition.source,
384
+ taskId
385
+ });
386
+ const taskContext = createTaskContext(taskDefinition, {
387
+ candidate: plan.candidate,
388
+ cwd: taskCwd,
389
+ env: resolvedEnv.env,
390
+ metadata: {},
391
+ rootCwd: context.cwd,
392
+ runId: "dry-run",
393
+ source: taskDefinition.source,
394
+ writeLog() { }
395
+ });
396
+ const steps = await resolveTaskSteps(taskDefinition.steps, taskContext);
397
+ const taskSource = taskDefinition.source?.name ? plan.sources[taskDefinition.source.name] : undefined;
398
+ const prepareCommands = taskSource
399
+ ? (await resolvePrepareCommands(taskSource, {
400
+ candidate: plan.candidate,
401
+ rootCwd: context.cwd,
402
+ runId: "dry-run",
403
+ contextEnv: context.env
404
+ })).map((command) => command.command)
405
+ : [];
406
+ const cacheKey = await computeTaskCacheKey(plan.pipeline, taskDefinition, taskCwd, {
407
+ candidate: plan.candidate,
408
+ dependencyFingerprints: Object.fromEntries(taskDefinition.dependsOn.map((dependency) => [
409
+ dependency,
410
+ fingerprints.get(dependency) ?? null
411
+ ])),
412
+ prepareCommands,
413
+ source: taskDefinition.source,
414
+ steps
415
+ });
416
+ fingerprints.set(taskId, cacheKey);
417
+ if (!taskDefinition.cache.enabled) {
418
+ entries.push({ id: taskId, cacheEnabled: false, predicted: "run", cacheKey });
419
+ continue;
420
+ }
421
+ const cached = await readTaskCacheEntry(taskDefinition, store, cacheKey);
422
+ const fresh = cached?.status === "passed" && isCacheEntryFresh(cached, taskDefinition.cache.ttlMs);
423
+ entries.push({
424
+ id: taskId,
425
+ cacheEnabled: true,
426
+ predicted: fresh ? "cached" : "run",
427
+ cacheKey,
428
+ reason: fresh ? undefined : cached ? "stale or unusable cache entry" : "no cache entry"
429
+ });
430
+ }
431
+ catch (error) {
432
+ fingerprints.set(taskId, null);
433
+ entries.push({
434
+ id: taskId,
435
+ cacheEnabled: taskDefinition.cache.enabled ?? false,
436
+ predicted: "unknown",
437
+ reason: error instanceof Error ? error.message : String(error)
438
+ });
439
+ }
440
+ }
441
+ return { jobId: options.id, executionOrder: graph.executionOrder, entries };
442
+ }
300
443
  export async function runSingleTask(pipeline, taskId, options = {}) {
301
444
  const syntheticJobId = `task:${taskId}`;
302
445
  const syntheticPipeline = {
@@ -312,16 +455,21 @@ async function runTask(pipeline, taskDefinition, options) {
312
455
  const started = Date.now();
313
456
  const startedAt = new Date().toISOString();
314
457
  const metadata = {};
315
- let combinedLog = "";
458
+ const taskLog = cappedBuffer(resolveMaxLogBytes(options.contextEnv));
316
459
  let taskEnv;
460
+ let envSecretValues = [];
461
+ let forwardEnvKeys = [];
317
462
  try {
318
- taskEnv = buildTaskEnv(options.workspaceEnv, {
463
+ const resolvedTaskEnv = buildTaskEnv(options.contextEnv, {
319
464
  candidate: options.candidate,
320
465
  envDefinitions: options.envDefinitions,
321
466
  rootCwd: options.rootCwd,
322
467
  source: options.source,
323
468
  taskId: taskDefinition.id
324
469
  });
470
+ taskEnv = resolvedTaskEnv.env;
471
+ envSecretValues = resolvedTaskEnv.secretValues;
472
+ forwardEnvKeys = [...resolvedTaskEnv.definedKeys, ...(taskDefinition.requires?.secrets ?? [])];
325
473
  }
326
474
  catch (error) {
327
475
  const lastError = error instanceof Error ? error.message : String(error);
@@ -347,9 +495,16 @@ async function runTask(pipeline, taskDefinition, options) {
347
495
  runId: options.runId,
348
496
  source: options.source,
349
497
  writeLog(message) {
350
- combinedLog += `${message}\n`;
498
+ taskLog.append(`${message}\n`);
351
499
  }
352
500
  });
501
+ const redactValues = [
502
+ ...envSecretValues,
503
+ ...(taskDefinition.requires?.secrets ?? [])
504
+ .map((secret) => taskEnv[secret])
505
+ .filter((value) => typeof value === "string" && value.length > 0)
506
+ ];
507
+ const redactLog = (log) => redactKnownValues(log, redactValues);
353
508
  const resolvedSteps = await resolveTaskSteps(taskDefinition.steps, context);
354
509
  const cacheKey = await computeTaskCacheKey(pipeline, taskDefinition, options.cwd, {
355
510
  candidate: options.candidate,
@@ -358,7 +513,7 @@ async function runTask(pipeline, taskDefinition, options) {
358
513
  source: options.source,
359
514
  steps: resolvedSteps
360
515
  });
361
- if (taskDefinition.cache.enabled) {
516
+ if (taskDefinition.cache.enabled && !options.force) {
362
517
  const cached = await readTaskCacheEntry(taskDefinition, options.store, cacheKey);
363
518
  if (cached?.status === "passed") {
364
519
  const cacheHit = await validateTaskCacheHit(taskDefinition, options.store, cacheKey, options.cwd, cached);
@@ -377,7 +532,7 @@ async function runTask(pipeline, taskDefinition, options) {
377
532
  await writeTaskLog(options.store, options.runId, taskDefinition.id, `[cache hit] ${cacheKey}\n`);
378
533
  return result;
379
534
  }
380
- combinedLog += `[cache miss] ${cacheHit.reason}\n`;
535
+ taskLog.append(`[cache miss] ${cacheHit.reason}\n`);
381
536
  }
382
537
  }
383
538
  let attempts = 0;
@@ -405,9 +560,9 @@ async function runTask(pipeline, taskDefinition, options) {
405
560
  if (!isShellCommand(step)) {
406
561
  throw new Error(`Deferred shell step for task "${taskDefinition.id}" was not resolved.`);
407
562
  }
408
- const result = await runShellStep(step, taskDefinition, { ...options, env: taskEnv });
409
- combinedLog += result.stdout;
410
- combinedLog += result.stderr;
563
+ const result = await runShellStep(step, taskDefinition, { executor: options.executor, cwd: options.cwd, env: taskEnv, echo: options.echo, redactValues, forwardEnvKeys });
564
+ taskLog.append(result.stdout);
565
+ taskLog.append(result.stderr);
411
566
  if (result.timedOut) {
412
567
  throw new Error(`Task "${taskDefinition.id}" timed out after ${taskDefinition.timeoutMs}ms.`);
413
568
  }
@@ -427,7 +582,7 @@ async function runTask(pipeline, taskDefinition, options) {
427
582
  cacheHit: false,
428
583
  metadata
429
584
  };
430
- await writeTaskLog(options.store, options.runId, taskDefinition.id, combinedLog);
585
+ await writeTaskLog(options.store, options.runId, taskDefinition.id, redactLog(taskLog.read()));
431
586
  if (taskDefinition.cache.enabled) {
432
587
  await writeTaskCacheEntry(taskDefinition, options.store, cacheKey, result, options.cwd);
433
588
  }
@@ -435,7 +590,11 @@ async function runTask(pipeline, taskDefinition, options) {
435
590
  }
436
591
  catch (error) {
437
592
  lastError = error instanceof Error ? error.message : String(error);
438
- combinedLog += `[attempt ${attempts}] ${lastError}\n`;
593
+ taskLog.append(`[attempt ${attempts}] ${lastError}\n`);
594
+ // Don't retry (or sleep through a retry delay) while shutting down;
595
+ // the failure is the shutdown itself, not a flaky task.
596
+ if (shutdownState)
597
+ break;
439
598
  if (attempts < maxAttempts && taskDefinition.retry.delayMs) {
440
599
  await delay(taskDefinition.retry.delayMs);
441
600
  }
@@ -453,7 +612,7 @@ async function runTask(pipeline, taskDefinition, options) {
453
612
  error: lastError,
454
613
  metadata
455
614
  };
456
- await writeTaskLog(options.store, options.runId, taskDefinition.id, combinedLog);
615
+ await writeTaskLog(options.store, options.runId, taskDefinition.id, redactLog(taskLog.read()));
457
616
  return result;
458
617
  }
459
618
  async function runShellStep(step, taskDefinition, options) {
@@ -461,7 +620,10 @@ async function runShellStep(step, taskDefinition, options) {
461
620
  cwd: options.cwd,
462
621
  env: options.env,
463
622
  task: taskDefinition,
464
- timeoutMs: taskDefinition.timeoutMs
623
+ timeoutMs: taskDefinition.timeoutMs,
624
+ echo: options.echo,
625
+ redactValues: options.redactValues,
626
+ forwardEnvKeys: options.forwardEnvKeys
465
627
  });
466
628
  }
467
629
  async function runSourcePrepare(source, options) {
@@ -471,21 +633,22 @@ async function runSourcePrepare(source, options) {
471
633
  const startedAt = new Date().toISOString();
472
634
  const taskId = `${source.id}:prepare`;
473
635
  const sourceTaskContext = sourceContext(source);
474
- let log = "";
636
+ const log = cappedBuffer(resolveMaxLogBytes(options.contextEnv));
637
+ const prepareEnv = buildTaskEnv(options.contextEnv, {
638
+ candidate: options.candidate,
639
+ rootCwd: options.rootCwd,
640
+ source: sourceTaskContext
641
+ });
475
642
  const context = createTaskContext({ id: taskId }, {
476
643
  candidate: options.candidate,
477
644
  cwd: source.dir,
478
- env: buildTaskEnv(options.workspaceEnv, {
479
- candidate: options.candidate,
480
- rootCwd: options.rootCwd,
481
- source: sourceTaskContext
482
- }),
645
+ env: prepareEnv.env,
483
646
  metadata: {},
484
647
  rootCwd: options.rootCwd,
485
648
  runId: options.runId,
486
649
  source: sourceTaskContext,
487
650
  writeLog(message) {
488
- log += `${message}\n`;
651
+ log.append(`${message}\n`);
489
652
  }
490
653
  });
491
654
  const steps = await resolveTaskSteps(source.definition.prepare, context);
@@ -500,15 +663,14 @@ async function runSourcePrepare(source, options) {
500
663
  }
501
664
  const result = await options.executor.runShell(step.command, {
502
665
  cwd: source.dir,
503
- env: buildTaskEnv(options.workspaceEnv, {
504
- candidate: options.candidate,
505
- rootCwd: options.rootCwd,
506
- source: sourceTaskContext
507
- }),
508
- task: { id: taskId }
666
+ env: prepareEnv.env,
667
+ task: { id: taskId },
668
+ echo: options.echo,
669
+ redactValues: prepareEnv.secretValues,
670
+ forwardEnvKeys: prepareEnv.definedKeys
509
671
  });
510
- log += result.stdout;
511
- log += result.stderr;
672
+ log.append(result.stdout);
673
+ log.append(result.stderr);
512
674
  if (result.code !== 0) {
513
675
  throw new Error(`Command failed with exit code ${result.code}: ${step.command}`);
514
676
  }
@@ -522,7 +684,7 @@ async function runSourcePrepare(source, options) {
522
684
  attempts: 1,
523
685
  cacheHit: false
524
686
  };
525
- await writeTaskLog(options.store, options.runId, taskId, log);
687
+ await writeTaskLog(options.store, options.runId, taskId, log.read());
526
688
  return result;
527
689
  }
528
690
  catch (error) {
@@ -536,8 +698,8 @@ async function runSourcePrepare(source, options) {
536
698
  cacheHit: false,
537
699
  error: error instanceof Error ? error.message : String(error)
538
700
  };
539
- log += `[prepare] ${result.error}\n`;
540
- await writeTaskLog(options.store, options.runId, taskId, log);
701
+ log.append(`[prepare] ${result.error}\n`);
702
+ await writeTaskLog(options.store, options.runId, taskId, log.read());
541
703
  return result;
542
704
  }
543
705
  }
@@ -545,11 +707,11 @@ async function resolvePrepareCommands(source, options) {
545
707
  const context = createTaskContext({ id: `${source.id}:prepare` }, {
546
708
  candidate: options.candidate,
547
709
  cwd: source.dir,
548
- env: buildTaskEnv(options.workspaceEnv, {
710
+ env: buildTaskEnv(options.contextEnv, {
549
711
  candidate: options.candidate,
550
712
  rootCwd: options.rootCwd,
551
713
  source: sourceContext(source)
552
- }),
714
+ }).env,
553
715
  metadata: {},
554
716
  rootCwd: options.rootCwd,
555
717
  runId: options.runId,
@@ -595,10 +757,8 @@ function createTaskContext(taskDefinition, options) {
595
757
  };
596
758
  }
597
759
  function buildTaskEnv(baseEnv, options) {
598
- const resolvedEnv = resolveEnvDefinitions(options.envDefinitions ?? {}, baseEnv, options.taskId);
599
- return {
600
- ...baseEnv,
601
- ...resolvedEnv,
760
+ const { resolved: resolvedEnv, secretValues } = resolveEnvDefinitions(options.envDefinitions ?? {}, baseEnv, options.taskId);
761
+ const contextEnv = {
602
762
  ASYNC_PIPELINE_ROOT_DIR: options.rootCwd,
603
763
  ASYNC_PIPELINE_CANDIDATE_DIR: options.candidate.dir,
604
764
  ASYNC_PIPELINE_CANDIDATE_FINGERPRINT: options.candidate.fingerprint,
@@ -607,9 +767,19 @@ function buildTaskEnv(baseEnv, options) {
607
767
  ASYNC_PIPELINE_SOURCE_REF: options.source?.ref,
608
768
  ASYNC_PIPELINE_SOURCE_COMMIT: options.source?.commit
609
769
  };
770
+ return {
771
+ env: {
772
+ ...baseEnv,
773
+ ...resolvedEnv,
774
+ ...contextEnv
775
+ },
776
+ secretValues,
777
+ definedKeys: [...Object.keys(resolvedEnv), ...Object.keys(contextEnv), "CI"]
778
+ };
610
779
  }
611
780
  function resolveEnvDefinitions(definitions, baseEnv, taskId = "unknown") {
612
781
  const resolved = {};
782
+ const secretValues = [];
613
783
  for (const [key, value] of Object.entries(definitions)) {
614
784
  if (typeof value === "string") {
615
785
  resolved[key] = value;
@@ -621,6 +791,7 @@ function resolveEnvDefinitions(definitions, baseEnv, taskId = "unknown") {
621
791
  throw new Error(`Required secret "${value.name}" for env "${key}" is not available for task "${taskId}".`);
622
792
  }
623
793
  resolved[key] = secretValue;
794
+ secretValues.push(secretValue);
624
795
  continue;
625
796
  }
626
797
  if (value.kind === "async-pipeline.env.var") {
@@ -641,7 +812,7 @@ function resolveEnvDefinitions(definitions, baseEnv, taskId = "unknown") {
641
812
  continue;
642
813
  }
643
814
  }
644
- return resolved;
815
+ return { resolved, secretValues };
645
816
  }
646
817
  function isShellCommand(step) {
647
818
  return typeof step !== "function" && step.kind === "shell";
@@ -731,52 +902,215 @@ async function runFunctionStep(step, context, timeoutMs) {
731
902
  clearTimeout(timeout);
732
903
  }
733
904
  }
905
+ // Track live task processes so terminal signals terminate the whole run.
906
+ // detached children live in their own process groups and would otherwise
907
+ // survive Ctrl-C on the CLI.
908
+ const activeKillers = new Set();
909
+ const installedSignalForwarders = new Set();
910
+ let shutdownState = null;
911
+ const SHUTDOWN_ESCALATE_DELAY_MS = 500;
912
+ const SHUTDOWN_EXIT_DEADLINE_MS = 10_000;
913
+ /**
914
+ * Abort the run: terminate every live task process group (SIGTERM-style
915
+ * signal first, SIGKILL after a grace period), refuse to start new task
916
+ * processes, and hard-exit if the run has not finalized its execution
917
+ * record by the deadline. Idempotent; the first caller wins.
918
+ */
919
+ export function beginShutdown(signal, exitCode) {
920
+ if (shutdownState)
921
+ return;
922
+ shutdownState = { signal, exitCode };
923
+ for (const kill of activeKillers)
924
+ kill(signal);
925
+ const escalate = setTimeout(() => {
926
+ for (const kill of activeKillers)
927
+ kill("SIGKILL");
928
+ }, SHUTDOWN_ESCALATE_DELAY_MS);
929
+ escalate.unref();
930
+ const deadline = setTimeout(() => process.exit(exitCode), SHUTDOWN_EXIT_DEADLINE_MS);
931
+ deadline.unref();
932
+ }
933
+ /** Exit code requested by an in-progress shutdown, if any. */
934
+ export function shutdownExitCode() {
935
+ return shutdownState ? shutdownState.exitCode : null;
936
+ }
937
+ function ensureSignalForwarding() {
938
+ for (const signal of ["SIGINT", "SIGTERM"]) {
939
+ if (installedSignalForwarders.has(signal))
940
+ continue;
941
+ installedSignalForwarders.add(signal);
942
+ process.once(signal, () => {
943
+ // 128 + signal number, the conventional interrupted-exit status.
944
+ const exitCode = signal === "SIGINT" ? 130 : 143;
945
+ beginShutdown(signal, exitCode);
946
+ process.exitCode = exitCode;
947
+ // Stay alive so the dead tasks surface as failures and the execution
948
+ // record is finalized instead of being left "running". A second
949
+ // signal or the shutdown deadline exits immediately.
950
+ process.once(signal, () => process.exit(exitCode));
951
+ });
952
+ }
953
+ }
954
+ const DEFAULT_MAX_LOG_BYTES = 8 * 1024 * 1024;
955
+ function resolveMaxLogBytes(env) {
956
+ const raw = env.ASYNC_PIPELINE_MAX_LOG_BYTES ?? process.env.ASYNC_PIPELINE_MAX_LOG_BYTES;
957
+ if (raw === undefined || raw === "")
958
+ return DEFAULT_MAX_LOG_BYTES;
959
+ const parsed = Number(raw);
960
+ if (!Number.isFinite(parsed) || parsed < 0)
961
+ return DEFAULT_MAX_LOG_BYTES;
962
+ return parsed === 0 ? Number.POSITIVE_INFINITY : Math.max(parsed, 4096);
963
+ }
964
+ /** Keeps the byte-accurate tail of a stream bounded so huge task output cannot exhaust memory. */
965
+ function cappedBuffer(maxBytes) {
966
+ const chunks = [];
967
+ let byteLength = 0;
968
+ let dropped = 0;
969
+ return {
970
+ append(chunk) {
971
+ const buffer = Buffer.from(chunk, "utf8");
972
+ chunks.push(buffer);
973
+ byteLength += buffer.byteLength;
974
+ while (byteLength > maxBytes) {
975
+ const head = chunks[0];
976
+ if (!head)
977
+ break;
978
+ const excess = byteLength - maxBytes;
979
+ if (head.byteLength <= excess) {
980
+ chunks.shift();
981
+ byteLength -= head.byteLength;
982
+ dropped += head.byteLength;
983
+ }
984
+ else {
985
+ chunks[0] = head.subarray(excess);
986
+ byteLength -= excess;
987
+ dropped += excess;
988
+ }
989
+ }
990
+ },
991
+ read() {
992
+ const text = Buffer.concat(chunks).toString("utf8");
993
+ if (dropped === 0)
994
+ return text;
995
+ return `[async-pipeline] output truncated: dropped ${dropped} leading bytes (ASYNC_PIPELINE_MAX_LOG_BYTES).\n${text}`;
996
+ }
997
+ };
998
+ }
734
999
  function runProcess(command, options) {
735
1000
  return new Promise((resolve) => {
1001
+ if (shutdownState) {
1002
+ // The run is shutting down (signal or closed output pipe): fail fast
1003
+ // instead of spawning processes that would immediately be killed.
1004
+ resolve({ code: 1, stdout: "", stderr: `[interrupted] Run is shutting down (${shutdownState.signal}); not starting: ${options.label ?? command}\n` });
1005
+ return;
1006
+ }
1007
+ // detached puts the shell in its own process group on POSIX so a timeout
1008
+ // can terminate the whole tree, not only the wrapping shell.
1009
+ const detached = process.platform !== "win32";
736
1010
  const child = spawn(command, {
737
1011
  cwd: options.cwd,
738
1012
  env: options.env,
739
1013
  shell: true,
1014
+ detached,
740
1015
  stdio: ["ignore", "pipe", "pipe"]
741
1016
  });
742
- let stdout = "";
743
- let stderr = "";
1017
+ const maxLogBytes = resolveMaxLogBytes(options.env);
1018
+ const stdout = cappedBuffer(maxLogBytes);
1019
+ const stderr = cappedBuffer(maxLogBytes);
744
1020
  let timedOut = false;
745
1021
  let timeout;
746
1022
  let forceKillTimeout;
1023
+ const redactValues = options.redactValues ?? [];
1024
+ const echoStdout = createEchoWriter(process.stdout, options.label, redactValues);
1025
+ const echoStderr = createEchoWriter(process.stderr, options.label, redactValues);
1026
+ const killTree = (signal) => {
1027
+ if (detached && child.pid) {
1028
+ try {
1029
+ process.kill(-child.pid, signal);
1030
+ return;
1031
+ }
1032
+ catch {
1033
+ // Fall through to killing the direct child.
1034
+ }
1035
+ }
1036
+ child.kill(signal);
1037
+ };
1038
+ activeKillers.add(killTree);
1039
+ ensureSignalForwarding();
747
1040
  if (options.timeoutMs) {
748
1041
  timeout = setTimeout(() => {
749
1042
  timedOut = true;
750
- child.kill("SIGTERM");
751
- forceKillTimeout = setTimeout(() => child.kill("SIGKILL"), 500);
1043
+ killTree("SIGTERM");
1044
+ forceKillTimeout = setTimeout(() => killTree("SIGKILL"), 500);
752
1045
  }, options.timeoutMs);
753
1046
  }
754
1047
  child.stdout.setEncoding("utf8");
755
1048
  child.stderr.setEncoding("utf8");
756
1049
  child.stdout.on("data", (chunk) => {
757
- stdout += chunk;
1050
+ stdout.append(chunk);
758
1051
  if (options.echo !== false)
759
- process.stdout.write(chunk);
1052
+ echoStdout.write(chunk);
760
1053
  });
761
1054
  child.stderr.on("data", (chunk) => {
762
- stderr += chunk;
1055
+ stderr.append(chunk);
763
1056
  if (options.echo !== false)
764
- process.stderr.write(chunk);
1057
+ echoStderr.write(chunk);
765
1058
  });
766
1059
  child.on("close", (code) => {
1060
+ activeKillers.delete(killTree);
767
1061
  if (timeout)
768
1062
  clearTimeout(timeout);
769
1063
  if (forceKillTimeout)
770
1064
  clearTimeout(forceKillTimeout);
1065
+ if (options.echo !== false) {
1066
+ echoStdout.flush();
1067
+ echoStderr.flush();
1068
+ }
1069
+ const finalStdout = redactKnownValues(stdout.read(), redactValues);
1070
+ const finalStderr = redactKnownValues(stderr.read(), redactValues);
771
1071
  if (timedOut) {
772
1072
  const timeoutMessage = `[timeout] Command timed out after ${options.timeoutMs}ms.\n`;
773
- resolve({ code: 124, stdout, stderr: `${stderr}${timeoutMessage}`, timedOut: true });
1073
+ resolve({ code: 124, stdout: finalStdout, stderr: `${finalStderr}${timeoutMessage}`, timedOut: true });
774
1074
  return;
775
1075
  }
776
- resolve({ code: code ?? 1, stdout, stderr });
1076
+ resolve({ code: code ?? 1, stdout: finalStdout, stderr: finalStderr });
777
1077
  });
778
1078
  });
779
1079
  }
1080
+ function createEchoWriter(stream, label, redactValues) {
1081
+ let buffered = "";
1082
+ const prefix = label ? `[${label}] ` : "";
1083
+ const writeLine = (line) => {
1084
+ stream.write(`${prefix}${redactKnownValues(line, redactValues)}\n`);
1085
+ };
1086
+ return {
1087
+ write(chunk) {
1088
+ buffered += chunk;
1089
+ const lastNewline = buffered.lastIndexOf("\n");
1090
+ if (lastNewline < 0)
1091
+ return;
1092
+ for (const line of buffered.slice(0, lastNewline).split("\n"))
1093
+ writeLine(line);
1094
+ buffered = buffered.slice(lastNewline + 1);
1095
+ },
1096
+ flush() {
1097
+ if (!buffered)
1098
+ return;
1099
+ writeLine(buffered);
1100
+ buffered = "";
1101
+ }
1102
+ };
1103
+ }
1104
+ const MIN_REDACTED_VALUE_LENGTH = 4;
1105
+ function redactKnownValues(output, values) {
1106
+ let redacted = output;
1107
+ for (const value of [...new Set(values)].sort((left, right) => right.length - left.length)) {
1108
+ if (!value || value.length < MIN_REDACTED_VALUE_LENGTH)
1109
+ continue;
1110
+ redacted = redacted.split(value).join("[redacted]");
1111
+ }
1112
+ return redacted;
1113
+ }
780
1114
  function matchingAction(policy, argv) {
781
1115
  for (const rule of policy.rules) {
782
1116
  if (matchesCommandRule(rule, argv))
@@ -806,11 +1140,19 @@ function commandStatus(action) {
806
1140
  return "approval-required";
807
1141
  return "allowed";
808
1142
  }
809
- async function runCommandAction(action, next) {
1143
+ async function runCommandAction(action, invocation, next) {
810
1144
  if (action.kind === "async-pipeline.command.allow")
811
1145
  return next();
812
- if (action.kind === "async-pipeline.command.requireEnvironment")
813
- return next();
1146
+ if (action.kind === "async-pipeline.command.requireEnvironment") {
1147
+ const current = invocation.env.ASYNC_PIPELINE_ENVIRONMENT;
1148
+ if (current === action.name)
1149
+ return next();
1150
+ return {
1151
+ code: 1,
1152
+ stdout: "",
1153
+ stderr: `Command requires environment "${action.name}" (current: ${current ? `"${current}"` : "unset"}). Set ASYNC_PIPELINE_ENVIRONMENT to allow it.\n`
1154
+ };
1155
+ }
814
1156
  if (action.kind === "async-pipeline.command.mock") {
815
1157
  return {
816
1158
  code: action.code ?? 0,