@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.
- package/LICENSE +21 -0
- package/README.md +2 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +5 -1
- package/dist/cli.js.map +1 -1
- package/dist/internal/core/index.d.ts +41 -40
- package/dist/internal/core/index.d.ts.map +1 -1
- package/dist/internal/core/index.js +98 -26
- package/dist/internal/core/index.js.map +1 -1
- package/dist/internal/lima/index.d.ts +1 -1
- package/dist/internal/lima/index.d.ts.map +1 -1
- package/dist/internal/lima/index.js +1 -1
- package/dist/internal/lima/index.js.map +1 -1
- package/dist/internal/node/cli.d.ts +5 -2
- package/dist/internal/node/cli.d.ts.map +1 -1
- package/dist/internal/node/cli.js +288 -85
- package/dist/internal/node/cli.js.map +1 -1
- package/dist/internal/node/doctor.d.ts +1 -1
- package/dist/internal/node/doctor.d.ts.map +1 -1
- package/dist/internal/node/doctor.js +50 -1
- package/dist/internal/node/doctor.js.map +1 -1
- package/dist/internal/node/github.d.ts +2 -0
- package/dist/internal/node/github.d.ts.map +1 -1
- package/dist/internal/node/github.js +59 -11
- package/dist/internal/node/github.js.map +1 -1
- package/dist/internal/node/loader.d.ts.map +1 -1
- package/dist/internal/node/loader.js +4 -0
- package/dist/internal/node/loader.js.map +1 -1
- package/dist/internal/node/runner.d.ts +64 -49
- package/dist/internal/node/runner.d.ts.map +1 -1
- package/dist/internal/node/runner.js +473 -131
- package/dist/internal/node/runner.js.map +1 -1
- package/dist/internal/node/sources.d.ts +3 -0
- package/dist/internal/node/sources.d.ts.map +1 -1
- package/dist/internal/node/sources.js +23 -6
- package/dist/internal/node/sources.js.map +1 -1
- package/dist/internal/node/store.d.ts +22 -0
- package/dist/internal/node/store.d.ts.map +1 -1
- package/dist/internal/node/store.js +155 -24
- package/dist/internal/node/store.js.map +1 -1
- 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
|
-
...
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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:
|
|
85
|
-
commands:
|
|
93
|
+
executor: target.executor ?? new HostCommandExecutor(),
|
|
94
|
+
commands: target.commands
|
|
86
95
|
};
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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:
|
|
112
|
+
image: definition.image,
|
|
96
113
|
hostCwd: cwd,
|
|
97
114
|
workdir,
|
|
98
|
-
volumes:
|
|
99
|
-
})
|
|
100
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
const
|
|
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:
|
|
178
|
+
cwd: context.cwd,
|
|
179
|
+
pid: process.pid,
|
|
156
180
|
startedAt: new Date().toISOString(),
|
|
157
181
|
status: "running",
|
|
158
|
-
mode: options.mode ?? (
|
|
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:
|
|
203
|
-
rootCwd:
|
|
226
|
+
executor: context.executor,
|
|
227
|
+
rootCwd: context.cwd,
|
|
204
228
|
runId: record.id,
|
|
205
|
-
|
|
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 ||
|
|
237
|
-
executor:
|
|
238
|
-
rootCwd:
|
|
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
|
-
|
|
267
|
+
contextEnv: context.env,
|
|
243
268
|
sourcePrepareCommands: taskSource ? await resolvePrepareCommands(taskSource, {
|
|
244
269
|
candidate: plan.candidate,
|
|
245
|
-
rootCwd:
|
|
270
|
+
rootCwd: context.cwd,
|
|
246
271
|
runId: record.id,
|
|
247
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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.
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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
|
-
|
|
458
|
+
const taskLog = cappedBuffer(resolveMaxLogBytes(options.contextEnv));
|
|
316
459
|
let taskEnv;
|
|
460
|
+
let envSecretValues = [];
|
|
461
|
+
let forwardEnvKeys = [];
|
|
317
462
|
try {
|
|
318
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
|
409
|
-
|
|
410
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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:
|
|
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
|
|
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:
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
|
511
|
-
log
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
743
|
-
|
|
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
|
-
|
|
751
|
-
forceKillTimeout = setTimeout(() =>
|
|
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
|
|
1050
|
+
stdout.append(chunk);
|
|
758
1051
|
if (options.echo !== false)
|
|
759
|
-
|
|
1052
|
+
echoStdout.write(chunk);
|
|
760
1053
|
});
|
|
761
1054
|
child.stderr.on("data", (chunk) => {
|
|
762
|
-
stderr
|
|
1055
|
+
stderr.append(chunk);
|
|
763
1056
|
if (options.echo !== false)
|
|
764
|
-
|
|
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: `${
|
|
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
|
-
|
|
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,
|