@livingdata/pipex 0.0.9 → 0.0.10
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/README.md +154 -14
- package/dist/__tests__/errors.js +162 -0
- package/dist/__tests__/helpers.js +41 -0
- package/dist/__tests__/types.js +8 -0
- package/dist/cli/__tests__/condition.js +23 -0
- package/dist/cli/__tests__/dag.js +154 -0
- package/dist/cli/__tests__/pipeline-loader.js +267 -0
- package/dist/cli/__tests__/pipeline-runner.js +257 -0
- package/dist/cli/__tests__/state-persistence.js +80 -0
- package/dist/cli/__tests__/state.js +58 -0
- package/dist/cli/__tests__/step-runner.js +116 -0
- package/dist/cli/commands/bundle.js +35 -0
- package/dist/cli/commands/cat.js +54 -0
- package/dist/cli/commands/exec.js +89 -0
- package/dist/cli/commands/export.js +2 -2
- package/dist/cli/commands/inspect.js +1 -1
- package/dist/cli/commands/list.js +2 -1
- package/dist/cli/commands/logs.js +1 -1
- package/dist/cli/commands/prune.js +1 -1
- package/dist/cli/commands/rm-step.js +41 -0
- package/dist/cli/commands/run-bundle.js +59 -0
- package/dist/cli/commands/run.js +9 -4
- package/dist/cli/commands/show.js +42 -7
- package/dist/cli/condition.js +11 -0
- package/dist/cli/dag.js +143 -0
- package/dist/cli/index.js +6 -0
- package/dist/cli/interactive-reporter.js +227 -0
- package/dist/cli/pipeline-loader.js +10 -110
- package/dist/cli/pipeline-runner.js +164 -78
- package/dist/cli/reporter.js +2 -158
- package/dist/cli/state.js +8 -0
- package/dist/cli/step-loader.js +25 -0
- package/dist/cli/step-resolver.js +111 -0
- package/dist/cli/step-runner.js +226 -0
- package/dist/cli/utils.js +0 -46
- package/dist/core/__tests__/bundle.js +663 -0
- package/dist/core/__tests__/condition.js +23 -0
- package/dist/core/__tests__/dag.js +154 -0
- package/dist/core/__tests__/env-file.test.js +41 -0
- package/dist/core/__tests__/event-aggregator.js +244 -0
- package/dist/core/__tests__/pipeline-loader.js +267 -0
- package/dist/core/__tests__/pipeline-runner.js +257 -0
- package/dist/core/__tests__/state-persistence.js +80 -0
- package/dist/core/__tests__/state.js +58 -0
- package/dist/core/__tests__/step-runner.js +118 -0
- package/dist/core/__tests__/stream-reporter.js +142 -0
- package/dist/core/__tests__/transport.js +50 -0
- package/dist/core/__tests__/utils.js +40 -0
- package/dist/core/bundle.js +130 -0
- package/dist/core/condition.js +11 -0
- package/dist/core/dag.js +143 -0
- package/dist/core/env-file.js +6 -0
- package/dist/core/event-aggregator.js +114 -0
- package/dist/core/index.js +14 -0
- package/dist/core/pipeline-loader.js +81 -0
- package/dist/core/pipeline-runner.js +360 -0
- package/dist/core/reporter.js +11 -0
- package/dist/core/state.js +110 -0
- package/dist/core/step-loader.js +25 -0
- package/dist/core/step-resolver.js +117 -0
- package/dist/core/step-runner.js +225 -0
- package/dist/core/stream-reporter.js +41 -0
- package/dist/core/transport.js +9 -0
- package/dist/core/utils.js +56 -0
- package/dist/engine/__tests__/workspace.js +288 -0
- package/dist/engine/docker-executor.js +10 -2
- package/dist/engine/index.js +1 -0
- package/dist/engine/workspace.js +76 -12
- package/dist/errors.js +122 -0
- package/dist/index.js +3 -0
- package/dist/kits/__tests__/index.js +23 -0
- package/dist/kits/builtin/__tests__/node.js +74 -0
- package/dist/kits/builtin/__tests__/python.js +67 -0
- package/dist/kits/builtin/__tests__/shell.js +74 -0
- package/dist/kits/builtin/node.js +10 -5
- package/dist/kits/builtin/python.js +10 -5
- package/dist/kits/builtin/shell.js +2 -1
- package/dist/kits/index.js +2 -1
- package/package.json +6 -3
- package/dist/cli/types.js +0 -3
- package/dist/engine/docker-runtime.js +0 -65
- package/dist/engine/runtime.js +0 -2
- package/dist/kits/bash.js +0 -19
- package/dist/kits/builtin/bash.js +0 -19
- package/dist/kits/node.js +0 -56
- package/dist/kits/python.js +0 -51
- package/dist/kits/types.js +0 -1
- package/dist/reporter.js +0 -13
|
@@ -1,31 +1,17 @@
|
|
|
1
|
+
import process from 'node:process';
|
|
2
|
+
import { cpus } from 'node:os';
|
|
1
3
|
import { cp, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { setTimeout } from 'node:timers/promises';
|
|
2
5
|
import { createWriteStream } from 'node:fs';
|
|
3
6
|
import { dirname, join, resolve } from 'node:path';
|
|
4
7
|
import { Workspace } from '../engine/index.js';
|
|
8
|
+
import { ContainerCrashError, PipexError } from '../errors.js';
|
|
9
|
+
import { buildGraph, topologicalLevels, subgraph, leafNodes } from './dag.js';
|
|
10
|
+
import { evaluateCondition } from './condition.js';
|
|
5
11
|
import { StateManager } from './state.js';
|
|
6
12
|
import { dirSize } from './utils.js';
|
|
7
13
|
/**
|
|
8
|
-
* Orchestrates pipeline execution with
|
|
9
|
-
*
|
|
10
|
-
* ## Workflow
|
|
11
|
-
*
|
|
12
|
-
* 1. **Workspace Resolution**: Determines workspace ID from CLI flag, config, or filename
|
|
13
|
-
* 2. **State Loading**: Loads cached fingerprints from state.json
|
|
14
|
-
* 3. **Step Execution**: For each step:
|
|
15
|
-
* a. Computes fingerprint (image + cmd + env + input run IDs)
|
|
16
|
-
* b. Checks cache (fingerprint match + run exists)
|
|
17
|
-
* c. If cached: skips execution
|
|
18
|
-
* d. If not cached: resolves inputs, prepares staging, executes container
|
|
19
|
-
* e. On success: writes meta.json, commits run, saves state
|
|
20
|
-
* f. On failure: discards run, halts pipeline (unless allowFailure)
|
|
21
|
-
* 4. **Completion**: Reports final pipeline status
|
|
22
|
-
*
|
|
23
|
-
* ## Runs
|
|
24
|
-
*
|
|
25
|
-
* Each step execution produces a **run** containing:
|
|
26
|
-
* - `artifacts/` — files produced by the step
|
|
27
|
-
* - `stdout.log` / `stderr.log` — captured container logs
|
|
28
|
-
* - `meta.json` — structured execution metadata
|
|
14
|
+
* Orchestrates pipeline execution with DAG-based parallel execution and caching.
|
|
29
15
|
*/
|
|
30
16
|
export class PipelineRunner {
|
|
31
17
|
loader;
|
|
@@ -39,7 +25,7 @@ export class PipelineRunner {
|
|
|
39
25
|
this.workdirRoot = workdirRoot;
|
|
40
26
|
}
|
|
41
27
|
async run(pipelineFilePath, options) {
|
|
42
|
-
const { workspace: workspaceName, force, dryRun } = options ?? {};
|
|
28
|
+
const { workspace: workspaceName, force, dryRun, target, concurrency } = options ?? {};
|
|
43
29
|
const config = await this.loader.load(pipelineFilePath);
|
|
44
30
|
const pipelineRoot = dirname(resolve(pipelineFilePath));
|
|
45
31
|
const workspaceId = workspaceName ?? config.id;
|
|
@@ -59,36 +45,100 @@ export class PipelineRunner {
|
|
|
59
45
|
await state.load();
|
|
60
46
|
const stepRuns = new Map();
|
|
61
47
|
let totalArtifactSize = 0;
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
48
|
+
// Build DAG and determine execution scope
|
|
49
|
+
const graph = buildGraph(config.steps);
|
|
50
|
+
const targets = target ?? leafNodes(graph);
|
|
51
|
+
const activeSteps = subgraph(graph, targets);
|
|
52
|
+
this.reporter.emit({
|
|
53
|
+
event: 'PIPELINE_START',
|
|
54
|
+
workspaceId: workspace.id,
|
|
55
|
+
pipelineName: config.name ?? config.id,
|
|
56
|
+
steps: config.steps
|
|
57
|
+
.filter(s => activeSteps.has(s.id))
|
|
58
|
+
.map(s => ({ id: s.id, displayName: s.name ?? s.id }))
|
|
59
|
+
});
|
|
60
|
+
const levels = topologicalLevels(graph)
|
|
61
|
+
.map(level => level.filter(id => activeSteps.has(id)))
|
|
62
|
+
.filter(level => level.length > 0);
|
|
63
|
+
const stepMap = new Map(config.steps.map(s => [s.id, s]));
|
|
64
|
+
const failed = new Set();
|
|
65
|
+
const skipped = new Set();
|
|
66
|
+
const maxConcurrency = concurrency ?? cpus().length;
|
|
67
|
+
for (const level of levels) {
|
|
68
|
+
const tasks = level.map(stepId => async () => {
|
|
69
|
+
const step = stepMap.get(stepId);
|
|
70
|
+
const stepRef = { id: step.id, displayName: step.name ?? step.id };
|
|
71
|
+
// Check if blocked by a failed/skipped required dependency
|
|
72
|
+
if (this.isDependencyBlocked(step, failed, skipped)) {
|
|
73
|
+
skipped.add(step.id);
|
|
74
|
+
this.reporter.emit({ event: 'STEP_SKIPPED', workspaceId: workspace.id, step: stepRef, reason: 'dependency' });
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
77
|
+
// Evaluate condition
|
|
78
|
+
if (step.if) {
|
|
79
|
+
const conditionMet = await evaluateCondition(step.if, { env: process.env });
|
|
80
|
+
if (!conditionMet) {
|
|
81
|
+
skipped.add(step.id);
|
|
82
|
+
this.reporter.emit({ event: 'STEP_SKIPPED', workspaceId: workspace.id, step: stepRef, reason: 'condition' });
|
|
83
|
+
return 0;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// Compute fingerprint
|
|
87
|
+
const inputRunIds = step.inputs
|
|
88
|
+
?.map(i => stepRuns.get(i.step))
|
|
89
|
+
.filter((id) => id !== undefined);
|
|
90
|
+
const resolvedMounts = step.mounts?.map(m => ({
|
|
91
|
+
hostPath: resolve(pipelineRoot, m.host),
|
|
92
|
+
containerPath: m.container
|
|
93
|
+
}));
|
|
94
|
+
const currentFingerprint = StateManager.fingerprint({
|
|
95
|
+
image: step.image,
|
|
96
|
+
cmd: step.cmd,
|
|
97
|
+
env: step.env,
|
|
98
|
+
inputRunIds,
|
|
99
|
+
mounts: resolvedMounts
|
|
100
|
+
});
|
|
101
|
+
// Cache check
|
|
102
|
+
const skipCache = force === true || (Array.isArray(force) && force.includes(step.id));
|
|
103
|
+
if (!skipCache && await this.tryUseCache({ workspace, state, step, stepRef, currentFingerprint, stepRuns })) {
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
106
|
+
// Dry run
|
|
107
|
+
if (dryRun) {
|
|
108
|
+
this.reporter.emit({ event: 'STEP_WOULD_RUN', workspaceId: workspace.id, step: stepRef });
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
// Execute
|
|
112
|
+
this.reporter.emit({ event: 'STEP_STARTING', workspaceId: workspace.id, step: stepRef });
|
|
113
|
+
return this.executeStep({ workspace, state, step, stepRef, stepRuns, currentFingerprint, resolvedMounts, pipelineRoot });
|
|
78
114
|
});
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
115
|
+
const results = await withConcurrency(tasks, maxConcurrency);
|
|
116
|
+
// Collect results
|
|
117
|
+
for (const [i, result] of results.entries()) {
|
|
118
|
+
const stepId = level[i];
|
|
119
|
+
if (result.status === 'fulfilled') {
|
|
120
|
+
totalArtifactSize += result.value;
|
|
121
|
+
}
|
|
122
|
+
else if (!failed.has(stepId)) {
|
|
123
|
+
// Step threw an error (ContainerCrashError if not allowFailure)
|
|
124
|
+
failed.add(stepId);
|
|
125
|
+
}
|
|
86
126
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
127
|
+
await state.save();
|
|
128
|
+
}
|
|
129
|
+
if (failed.size > 0) {
|
|
130
|
+
this.reporter.emit({ event: 'PIPELINE_FAILED', workspaceId: workspace.id });
|
|
131
|
+
// Re-throw the first failure to signal pipeline failure to the CLI
|
|
132
|
+
const firstFailedId = [...failed][0];
|
|
133
|
+
throw new ContainerCrashError(firstFailedId, 1);
|
|
90
134
|
}
|
|
91
|
-
this.reporter.
|
|
135
|
+
this.reporter.emit({ event: 'PIPELINE_FINISHED', workspaceId: workspace.id, totalArtifactSize });
|
|
136
|
+
}
|
|
137
|
+
isDependencyBlocked(step, failed, skippedSteps) {
|
|
138
|
+
if (!step.inputs) {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
return step.inputs.some(input => !input.optional && (failed.has(input.step) || skippedSteps.has(input.step)));
|
|
92
142
|
}
|
|
93
143
|
async executeStep({ workspace, state, step, stepRef, stepRuns, currentFingerprint, resolvedMounts, pipelineRoot }) {
|
|
94
144
|
const runId = workspace.generateRunId();
|
|
@@ -103,30 +153,46 @@ export class PipelineRunner {
|
|
|
103
153
|
const stdoutLog = createWriteStream(join(stagingPath, 'stdout.log'));
|
|
104
154
|
const stderrLog = createWriteStream(join(stagingPath, 'stderr.log'));
|
|
105
155
|
try {
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
156
|
+
const maxRetries = step.retries ?? 0;
|
|
157
|
+
const retryDelay = step.retryDelayMs ?? 5000;
|
|
158
|
+
let result;
|
|
159
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
160
|
+
try {
|
|
161
|
+
result = await this.runtime.run(workspace, {
|
|
162
|
+
name: `pipex-${workspace.id}-${step.id}-${Date.now()}`,
|
|
163
|
+
image: step.image,
|
|
164
|
+
cmd: step.cmd,
|
|
165
|
+
env: step.env,
|
|
166
|
+
inputs,
|
|
167
|
+
output,
|
|
168
|
+
caches,
|
|
169
|
+
mounts,
|
|
170
|
+
sources: step.sources?.map(m => ({
|
|
171
|
+
hostPath: resolve(pipelineRoot, m.host),
|
|
172
|
+
containerPath: m.container
|
|
173
|
+
})),
|
|
174
|
+
network: step.allowNetwork ? 'bridge' : 'none',
|
|
175
|
+
timeoutSec: step.timeoutSec
|
|
176
|
+
}, ({ stream, line }) => {
|
|
177
|
+
if (stream === 'stdout') {
|
|
178
|
+
stdoutLog.write(line + '\n');
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
stderrLog.write(line + '\n');
|
|
182
|
+
}
|
|
183
|
+
this.reporter.log(workspace.id, stepRef, stream, line);
|
|
184
|
+
});
|
|
185
|
+
break;
|
|
124
186
|
}
|
|
125
|
-
|
|
126
|
-
|
|
187
|
+
catch (error) {
|
|
188
|
+
if (error instanceof PipexError && error.transient && attempt < maxRetries) {
|
|
189
|
+
this.reporter.emit({ event: 'STEP_RETRYING', workspaceId: workspace.id, step: stepRef, attempt: attempt + 1, maxRetries });
|
|
190
|
+
await setTimeout(retryDelay);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
throw error;
|
|
127
194
|
}
|
|
128
|
-
|
|
129
|
-
});
|
|
195
|
+
}
|
|
130
196
|
this.reporter.result(workspace.id, stepRef, result);
|
|
131
197
|
await closeStream(stdoutLog);
|
|
132
198
|
await closeStream(stderrLog);
|
|
@@ -136,16 +202,14 @@ export class PipelineRunner {
|
|
|
136
202
|
await workspace.linkRun(step.id, runId);
|
|
137
203
|
stepRuns.set(step.id, runId);
|
|
138
204
|
state.setStep(step.id, runId, currentFingerprint);
|
|
139
|
-
await state.save();
|
|
140
205
|
const durationMs = result.finishedAt.getTime() - result.startedAt.getTime();
|
|
141
206
|
const artifactSize = await dirSize(workspace.runArtifactsPath(runId));
|
|
142
|
-
this.reporter.
|
|
207
|
+
this.reporter.emit({ event: 'STEP_FINISHED', workspaceId: workspace.id, step: stepRef, runId, durationMs, artifactSize });
|
|
143
208
|
return artifactSize;
|
|
144
209
|
}
|
|
145
210
|
await workspace.discardRun(runId);
|
|
146
|
-
this.reporter.
|
|
147
|
-
|
|
148
|
-
throw new Error(`Step ${step.id} failed with exit code ${result.exitCode}`);
|
|
211
|
+
this.reporter.emit({ event: 'STEP_FAILED', workspaceId: workspace.id, step: stepRef, exitCode: result.exitCode });
|
|
212
|
+
throw new ContainerCrashError(step.id, result.exitCode);
|
|
149
213
|
}
|
|
150
214
|
catch (error) {
|
|
151
215
|
await closeStream(stdoutLog);
|
|
@@ -186,7 +250,7 @@ export class PipelineRunner {
|
|
|
186
250
|
if (runs.includes(cached.runId)) {
|
|
187
251
|
stepRuns.set(step.id, cached.runId);
|
|
188
252
|
await workspace.linkRun(step.id, cached.runId);
|
|
189
|
-
this.reporter.
|
|
253
|
+
this.reporter.emit({ event: 'STEP_SKIPPED', workspaceId: workspace.id, step: stepRef, runId: cached.runId, reason: 'cached' });
|
|
190
254
|
return true;
|
|
191
255
|
}
|
|
192
256
|
}
|
|
@@ -203,7 +267,12 @@ export class PipelineRunner {
|
|
|
203
267
|
for (const input of step.inputs) {
|
|
204
268
|
const inputRunId = stepRuns.get(input.step);
|
|
205
269
|
if (!inputRunId) {
|
|
206
|
-
|
|
270
|
+
if (input.optional) {
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
// Non-optional input without a run — this shouldn't happen in DAG mode
|
|
274
|
+
// but keep the continue for safety (the step may still work with bind mounts)
|
|
275
|
+
continue;
|
|
207
276
|
}
|
|
208
277
|
if (input.copyToOutput) {
|
|
209
278
|
await cp(workspace.runArtifactsPath(inputRunId), stagingArtifactsPath, { recursive: true });
|
|
@@ -244,6 +313,23 @@ export class PipelineRunner {
|
|
|
244
313
|
return { inputs, output, caches, mounts };
|
|
245
314
|
}
|
|
246
315
|
}
|
|
316
|
+
async function withConcurrency(tasks, limit) {
|
|
317
|
+
const results = Array.from({ length: tasks.length });
|
|
318
|
+
let next = 0;
|
|
319
|
+
async function worker() {
|
|
320
|
+
while (next < tasks.length) {
|
|
321
|
+
const i = next++;
|
|
322
|
+
try {
|
|
323
|
+
results[i] = { status: 'fulfilled', value: await tasks[i]() };
|
|
324
|
+
}
|
|
325
|
+
catch (error) {
|
|
326
|
+
results[i] = { status: 'rejected', reason: error };
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
await Promise.all(Array.from({ length: Math.min(limit, tasks.length) }, async () => worker()));
|
|
331
|
+
return results;
|
|
332
|
+
}
|
|
247
333
|
async function closeStream(stream) {
|
|
248
334
|
if (stream.destroyed) {
|
|
249
335
|
return;
|
package/dist/cli/reporter.js
CHANGED
|
@@ -1,16 +1,12 @@
|
|
|
1
1
|
import pino from 'pino';
|
|
2
|
-
import chalk from 'chalk';
|
|
3
|
-
import ora from 'ora';
|
|
4
|
-
import { formatDuration, formatSize } from './utils.js';
|
|
5
2
|
/**
|
|
6
3
|
* Reporter that outputs structured JSON logs via pino.
|
|
7
4
|
* Suitable for CI/CD environments and log aggregation.
|
|
8
5
|
*/
|
|
9
6
|
export class ConsoleReporter {
|
|
10
7
|
logger = pino({ level: 'info' });
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
this.logger.info({ workspaceId, event, stepId: step?.id, stepName, ...meta });
|
|
8
|
+
emit(event) {
|
|
9
|
+
this.logger.info(event);
|
|
14
10
|
}
|
|
15
11
|
log(workspaceId, step, stream, line) {
|
|
16
12
|
this.logger.info({ workspaceId, stepId: step.id, stream, line });
|
|
@@ -19,155 +15,3 @@ export class ConsoleReporter {
|
|
|
19
15
|
this.logger.info({ workspaceId, stepId: step.id, result });
|
|
20
16
|
}
|
|
21
17
|
}
|
|
22
|
-
/**
|
|
23
|
-
* Reporter with interactive terminal UI using spinners and colors.
|
|
24
|
-
* Suitable for local development and manual execution.
|
|
25
|
-
*/
|
|
26
|
-
export class InteractiveReporter {
|
|
27
|
-
static get maxStderrLines() {
|
|
28
|
-
return 20;
|
|
29
|
-
}
|
|
30
|
-
verbose;
|
|
31
|
-
spinner;
|
|
32
|
-
stepSpinners = new Map();
|
|
33
|
-
stderrBuffers = new Map();
|
|
34
|
-
constructor(options) {
|
|
35
|
-
this.verbose = options?.verbose ?? false;
|
|
36
|
-
}
|
|
37
|
-
state(workspaceId, event, step, meta) {
|
|
38
|
-
switch (event) {
|
|
39
|
-
case 'PIPELINE_START': {
|
|
40
|
-
const displayName = meta?.pipelineName ?? workspaceId;
|
|
41
|
-
console.log(chalk.bold(`\n▶ Pipeline: ${chalk.cyan(displayName)}\n`));
|
|
42
|
-
break;
|
|
43
|
-
}
|
|
44
|
-
case 'STEP_STARTING': {
|
|
45
|
-
if (step) {
|
|
46
|
-
const spinner = ora({ text: step.displayName, prefixText: ' ' }).start();
|
|
47
|
-
this.stepSpinners.set(step.id, spinner);
|
|
48
|
-
}
|
|
49
|
-
break;
|
|
50
|
-
}
|
|
51
|
-
case 'STEP_SKIPPED': {
|
|
52
|
-
if (step) {
|
|
53
|
-
const spinner = this.stepSpinners.get(step.id);
|
|
54
|
-
if (spinner) {
|
|
55
|
-
spinner.stopAndPersist({ symbol: chalk.gray('⊙'), text: chalk.gray(`${step.displayName} (cached)`) });
|
|
56
|
-
this.stepSpinners.delete(step.id);
|
|
57
|
-
}
|
|
58
|
-
else {
|
|
59
|
-
console.log(` ${chalk.gray('⊙')} ${chalk.gray(`${step.displayName} (cached)`)}`);
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
break;
|
|
63
|
-
}
|
|
64
|
-
case 'STEP_FINISHED': {
|
|
65
|
-
if (step) {
|
|
66
|
-
this.handleStepFinished(step, meta);
|
|
67
|
-
}
|
|
68
|
-
break;
|
|
69
|
-
}
|
|
70
|
-
case 'STEP_FAILED': {
|
|
71
|
-
if (step) {
|
|
72
|
-
this.handleStepFailed(step, meta);
|
|
73
|
-
}
|
|
74
|
-
break;
|
|
75
|
-
}
|
|
76
|
-
case 'STEP_WOULD_RUN': {
|
|
77
|
-
if (step) {
|
|
78
|
-
this.handleStepWouldRun(step);
|
|
79
|
-
}
|
|
80
|
-
break;
|
|
81
|
-
}
|
|
82
|
-
case 'PIPELINE_FINISHED': {
|
|
83
|
-
this.handlePipelineFinished(meta);
|
|
84
|
-
break;
|
|
85
|
-
}
|
|
86
|
-
case 'PIPELINE_FAILED': {
|
|
87
|
-
console.log(chalk.bold.red('\n✗ Pipeline failed\n'));
|
|
88
|
-
break;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
log(_workspaceId, step, stream, line) {
|
|
93
|
-
if (this.verbose) {
|
|
94
|
-
const spinner = this.stepSpinners.get(step.id);
|
|
95
|
-
const prefix = chalk.gray(` [${step.id}]`);
|
|
96
|
-
if (spinner) {
|
|
97
|
-
spinner.clear();
|
|
98
|
-
console.log(`${prefix} ${line}`);
|
|
99
|
-
spinner.render();
|
|
100
|
-
}
|
|
101
|
-
else {
|
|
102
|
-
console.log(`${prefix} ${line}`);
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
if (stream === 'stderr') {
|
|
106
|
-
let buffer = this.stderrBuffers.get(step.id);
|
|
107
|
-
if (!buffer) {
|
|
108
|
-
buffer = [];
|
|
109
|
-
this.stderrBuffers.set(step.id, buffer);
|
|
110
|
-
}
|
|
111
|
-
buffer.push(line);
|
|
112
|
-
if (buffer.length > InteractiveReporter.maxStderrLines) {
|
|
113
|
-
buffer.shift();
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
result(_workspaceId, _step, _result) {
|
|
118
|
-
// Results shown via state updates
|
|
119
|
-
}
|
|
120
|
-
handleStepFinished(step, meta) {
|
|
121
|
-
const spinner = this.stepSpinners.get(step.id);
|
|
122
|
-
if (spinner) {
|
|
123
|
-
const details = [];
|
|
124
|
-
if (typeof meta?.durationMs === 'number') {
|
|
125
|
-
details.push(formatDuration(meta.durationMs));
|
|
126
|
-
}
|
|
127
|
-
if (typeof meta?.artifactSize === 'number' && meta.artifactSize > 0) {
|
|
128
|
-
details.push(formatSize(meta.artifactSize));
|
|
129
|
-
}
|
|
130
|
-
const suffix = details.length > 0 ? ` (${details.join(', ')})` : '';
|
|
131
|
-
spinner.stopAndPersist({ symbol: chalk.green('✓'), text: chalk.green(`${step.displayName}${suffix}`) });
|
|
132
|
-
this.stepSpinners.delete(step.id);
|
|
133
|
-
}
|
|
134
|
-
this.stderrBuffers.delete(step.id);
|
|
135
|
-
}
|
|
136
|
-
handleStepWouldRun(step) {
|
|
137
|
-
const spinner = this.stepSpinners.get(step.id);
|
|
138
|
-
if (spinner) {
|
|
139
|
-
spinner.stopAndPersist({ symbol: chalk.yellow('○'), text: chalk.yellow(`${step.displayName} (would run)`) });
|
|
140
|
-
this.stepSpinners.delete(step.id);
|
|
141
|
-
}
|
|
142
|
-
else {
|
|
143
|
-
console.log(` ${chalk.yellow('○')} ${chalk.yellow(`${step.displayName} (would run)`)}`);
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
handlePipelineFinished(meta) {
|
|
147
|
-
const parts = ['Pipeline completed'];
|
|
148
|
-
if (typeof meta?.totalArtifactSize === 'number' && meta.totalArtifactSize > 0) {
|
|
149
|
-
parts.push(`(${formatSize(meta.totalArtifactSize)})`);
|
|
150
|
-
}
|
|
151
|
-
console.log(chalk.bold.green(`\n✓ ${parts.join(' ')}\n`));
|
|
152
|
-
}
|
|
153
|
-
handleStepFailed(step, meta) {
|
|
154
|
-
const spinner = this.stepSpinners.get(step.id);
|
|
155
|
-
const exitCode = meta?.exitCode;
|
|
156
|
-
if (spinner) {
|
|
157
|
-
const exitInfo = exitCode === undefined ? '' : ` (exit ${exitCode})`;
|
|
158
|
-
spinner.stopAndPersist({
|
|
159
|
-
symbol: chalk.red('✗'),
|
|
160
|
-
text: chalk.red(`${step.displayName}${exitInfo}`)
|
|
161
|
-
});
|
|
162
|
-
this.stepSpinners.delete(step.id);
|
|
163
|
-
}
|
|
164
|
-
const stderr = this.stderrBuffers.get(step.id);
|
|
165
|
-
if (stderr && stderr.length > 0) {
|
|
166
|
-
console.log(chalk.red(' ── stderr ──'));
|
|
167
|
-
for (const line of stderr) {
|
|
168
|
-
console.log(chalk.red(` ${line}`));
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
this.stderrBuffers.delete(step.id);
|
|
172
|
-
}
|
|
173
|
-
}
|
package/dist/cli/state.js
CHANGED
|
@@ -99,4 +99,12 @@ export class StateManager {
|
|
|
99
99
|
setStep(stepId, runId, fingerprint) {
|
|
100
100
|
this.state.steps[stepId] = { runId, fingerprint };
|
|
101
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* Removes a step's cached state.
|
|
104
|
+
* @param stepId - Step identifier
|
|
105
|
+
*/
|
|
106
|
+
removeStep(stepId) {
|
|
107
|
+
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
|
108
|
+
delete this.state.steps[stepId];
|
|
109
|
+
}
|
|
102
110
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { ValidationError } from '../errors.js';
|
|
3
|
+
import { parsePipelineFile } from './pipeline-loader.js';
|
|
4
|
+
import { resolveStep, validateStep } from './step-resolver.js';
|
|
5
|
+
/**
|
|
6
|
+
* Loads and resolves a single step definition from a file.
|
|
7
|
+
*/
|
|
8
|
+
export async function loadStepFile(filePath, stepIdOverride) {
|
|
9
|
+
const content = await readFile(filePath, 'utf8');
|
|
10
|
+
const raw = parsePipelineFile(content, filePath);
|
|
11
|
+
if (!raw || typeof raw !== 'object') {
|
|
12
|
+
throw new ValidationError('Step file must contain an object');
|
|
13
|
+
}
|
|
14
|
+
// If no id/name provided, require --step override
|
|
15
|
+
if (!('id' in raw && raw.id) && !('name' in raw && raw.name) && !stepIdOverride) {
|
|
16
|
+
throw new ValidationError('Step file must have "id" or "name", or use --step to set an ID');
|
|
17
|
+
}
|
|
18
|
+
// Apply step ID override
|
|
19
|
+
if (stepIdOverride) {
|
|
20
|
+
raw.id = stepIdOverride;
|
|
21
|
+
}
|
|
22
|
+
const step = resolveStep(raw);
|
|
23
|
+
validateStep(step);
|
|
24
|
+
return step;
|
|
25
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { ValidationError } from '../errors.js';
|
|
2
|
+
import { getKit } from '../kits/index.js';
|
|
3
|
+
import { isKitStep } from '../types.js';
|
|
4
|
+
import { slugify, mergeEnv, mergeCaches, mergeMounts } from './pipeline-loader.js';
|
|
5
|
+
/**
|
|
6
|
+
* Resolves a step definition into a fully resolved Step.
|
|
7
|
+
* Kit steps (`uses`) are expanded into image + cmd.
|
|
8
|
+
*/
|
|
9
|
+
export function resolveStep(step) {
|
|
10
|
+
if (!step.id && !step.name) {
|
|
11
|
+
throw new ValidationError('Invalid step: at least one of "id" or "name" must be defined');
|
|
12
|
+
}
|
|
13
|
+
const id = step.id ?? slugify(step.name);
|
|
14
|
+
const { name } = step;
|
|
15
|
+
if (!isKitStep(step)) {
|
|
16
|
+
return { ...step, id, name };
|
|
17
|
+
}
|
|
18
|
+
return resolveKitStep(step, id, name);
|
|
19
|
+
}
|
|
20
|
+
function resolveKitStep(step, id, name) {
|
|
21
|
+
const kit = getKit(step.uses);
|
|
22
|
+
const kitOutput = kit.resolve(step.with ?? {});
|
|
23
|
+
return {
|
|
24
|
+
id,
|
|
25
|
+
name,
|
|
26
|
+
image: kitOutput.image,
|
|
27
|
+
cmd: kitOutput.cmd,
|
|
28
|
+
env: mergeEnv(kitOutput.env, step.env),
|
|
29
|
+
inputs: step.inputs,
|
|
30
|
+
outputPath: step.outputPath,
|
|
31
|
+
caches: mergeCaches(kitOutput.caches, step.caches),
|
|
32
|
+
mounts: mergeMounts(kitOutput.mounts, step.mounts),
|
|
33
|
+
sources: mergeMounts(kitOutput.sources, step.sources),
|
|
34
|
+
timeoutSec: step.timeoutSec,
|
|
35
|
+
allowFailure: step.allowFailure,
|
|
36
|
+
allowNetwork: step.allowNetwork ?? kitOutput.allowNetwork,
|
|
37
|
+
retries: step.retries,
|
|
38
|
+
retryDelayMs: step.retryDelayMs,
|
|
39
|
+
if: step.if
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Validates a resolved step for correctness and security.
|
|
44
|
+
*/
|
|
45
|
+
export function validateStep(step) {
|
|
46
|
+
validateIdentifier(step.id, 'step id');
|
|
47
|
+
if (!step.image || typeof step.image !== 'string') {
|
|
48
|
+
throw new ValidationError(`Invalid step ${step.id}: image is required`);
|
|
49
|
+
}
|
|
50
|
+
if (!Array.isArray(step.cmd) || step.cmd.length === 0) {
|
|
51
|
+
throw new ValidationError(`Invalid step ${step.id}: cmd must be a non-empty array`);
|
|
52
|
+
}
|
|
53
|
+
if (step.inputs) {
|
|
54
|
+
for (const input of step.inputs) {
|
|
55
|
+
validateIdentifier(input.step, `input step name in step ${step.id}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (step.mounts) {
|
|
59
|
+
validateMounts(step.id, step.mounts);
|
|
60
|
+
}
|
|
61
|
+
if (step.sources) {
|
|
62
|
+
validateMounts(step.id, step.sources);
|
|
63
|
+
}
|
|
64
|
+
if (step.caches) {
|
|
65
|
+
validateCaches(step.id, step.caches);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function validateIdentifier(id, context) {
|
|
69
|
+
if (!/^[\w-]+$/.test(id)) {
|
|
70
|
+
throw new ValidationError(`Invalid ${context}: '${id}' must contain only alphanumeric characters, underscore, and hyphen`);
|
|
71
|
+
}
|
|
72
|
+
if (id.includes('..')) {
|
|
73
|
+
throw new ValidationError(`Invalid ${context}: '${id}' cannot contain '..'`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function validateMounts(stepId, mounts) {
|
|
77
|
+
for (const mount of mounts) {
|
|
78
|
+
if (!mount.host || typeof mount.host !== 'string') {
|
|
79
|
+
throw new ValidationError(`Step ${stepId}: mount.host is required and must be a string`);
|
|
80
|
+
}
|
|
81
|
+
if (mount.host.startsWith('/')) {
|
|
82
|
+
throw new ValidationError(`Step ${stepId}: mount.host '${mount.host}' must be a relative path`);
|
|
83
|
+
}
|
|
84
|
+
if (mount.host.includes('..')) {
|
|
85
|
+
throw new ValidationError(`Step ${stepId}: mount.host '${mount.host}' must not contain '..'`);
|
|
86
|
+
}
|
|
87
|
+
if (!mount.container || typeof mount.container !== 'string') {
|
|
88
|
+
throw new ValidationError(`Step ${stepId}: mount.container is required and must be a string`);
|
|
89
|
+
}
|
|
90
|
+
if (!mount.container.startsWith('/')) {
|
|
91
|
+
throw new ValidationError(`Step ${stepId}: mount.container '${mount.container}' must be an absolute path`);
|
|
92
|
+
}
|
|
93
|
+
if (mount.container.includes('..')) {
|
|
94
|
+
throw new ValidationError(`Step ${stepId}: mount.container '${mount.container}' must not contain '..'`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function validateCaches(stepId, caches) {
|
|
99
|
+
for (const cache of caches) {
|
|
100
|
+
if (!cache.name || typeof cache.name !== 'string') {
|
|
101
|
+
throw new ValidationError(`Step ${stepId}: cache.name is required and must be a string`);
|
|
102
|
+
}
|
|
103
|
+
validateIdentifier(cache.name, `cache name in step ${stepId}`);
|
|
104
|
+
if (!cache.path || typeof cache.path !== 'string') {
|
|
105
|
+
throw new ValidationError(`Step ${stepId}: cache.path is required and must be a string`);
|
|
106
|
+
}
|
|
107
|
+
if (!cache.path.startsWith('/')) {
|
|
108
|
+
throw new ValidationError(`Step ${stepId}: cache.path '${cache.path}' must be an absolute path`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|