@livingdata/pipex 0.0.8 → 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 +186 -16
- 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/clean.js +22 -0
- package/dist/cli/commands/exec.js +89 -0
- package/dist/cli/commands/export.js +32 -0
- package/dist/cli/commands/inspect.js +58 -0
- package/dist/cli/commands/list.js +39 -0
- package/dist/cli/commands/logs.js +54 -0
- package/dist/cli/commands/prune.js +26 -0
- package/dist/cli/commands/rm-step.js +41 -0
- package/dist/cli/commands/rm.js +27 -0
- package/dist/cli/commands/run-bundle.js +59 -0
- package/dist/cli/commands/run.js +44 -0
- package/dist/cli/commands/show.js +108 -0
- package/dist/cli/condition.js +11 -0
- package/dist/cli/dag.js +143 -0
- package/dist/cli/index.js +24 -105
- package/dist/cli/interactive-reporter.js +227 -0
- package/dist/cli/pipeline-loader.js +10 -110
- package/dist/cli/pipeline-runner.js +256 -111
- package/dist/cli/reporter.js +2 -107
- package/dist/cli/state.js +30 -9
- 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 +3 -0
- 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 +32 -6
- package/dist/engine/index.js +1 -0
- package/dist/engine/workspace.js +164 -66
- 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,36 +1,17 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
1
|
+
import process from 'node:process';
|
|
2
|
+
import { cpus } from 'node:os';
|
|
3
|
+
import { cp, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { setTimeout } from 'node:timers/promises';
|
|
5
|
+
import { createWriteStream } from 'node:fs';
|
|
6
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
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';
|
|
4
11
|
import { StateManager } from './state.js';
|
|
12
|
+
import { dirSize } from './utils.js';
|
|
5
13
|
/**
|
|
6
|
-
* Orchestrates pipeline execution with
|
|
7
|
-
*
|
|
8
|
-
* ## Workflow
|
|
9
|
-
*
|
|
10
|
-
* 1. **Workspace Resolution**: Determines workspace ID from CLI flag, config, or filename
|
|
11
|
-
* 2. **State Loading**: Loads cached fingerprints from state.json
|
|
12
|
-
* 3. **Step Execution**: For each step:
|
|
13
|
-
* a. Computes fingerprint (image + cmd + env + input artifact IDs)
|
|
14
|
-
* b. Checks cache (fingerprint match + artifact exists)
|
|
15
|
-
* c. If cached: skips execution
|
|
16
|
-
* d. If not cached: resolves inputs, prepares staging, executes container
|
|
17
|
-
* e. On success: commits artifact, saves state
|
|
18
|
-
* f. On failure: discards artifact, halts pipeline (unless allowFailure)
|
|
19
|
-
* 4. **Completion**: Reports final pipeline status
|
|
20
|
-
*
|
|
21
|
-
* ## Dependencies
|
|
22
|
-
*
|
|
23
|
-
* Steps declare dependencies via `inputs: [{step: "stepId"}]`.
|
|
24
|
-
* The runner:
|
|
25
|
-
* - Mounts input artifacts as read-only volumes
|
|
26
|
-
* - Optionally copies inputs to output staging (if `copyToOutput: true`)
|
|
27
|
-
* - Tracks execution order to resolve step names to artifact IDs
|
|
28
|
-
*
|
|
29
|
-
* ## Caching
|
|
30
|
-
*
|
|
31
|
-
* Cache invalidation is automatic:
|
|
32
|
-
* - Changing a step's configuration re-runs it
|
|
33
|
-
* - Re-running a step invalidates all dependent steps
|
|
14
|
+
* Orchestrates pipeline execution with DAG-based parallel execution and caching.
|
|
34
15
|
*/
|
|
35
16
|
export class PipelineRunner {
|
|
36
17
|
loader;
|
|
@@ -44,10 +25,9 @@ export class PipelineRunner {
|
|
|
44
25
|
this.workdirRoot = workdirRoot;
|
|
45
26
|
}
|
|
46
27
|
async run(pipelineFilePath, options) {
|
|
47
|
-
const { workspace: workspaceName, force } = options ?? {};
|
|
28
|
+
const { workspace: workspaceName, force, dryRun, target, concurrency } = options ?? {};
|
|
48
29
|
const config = await this.loader.load(pipelineFilePath);
|
|
49
30
|
const pipelineRoot = dirname(resolve(pipelineFilePath));
|
|
50
|
-
// Workspace ID priority: CLI arg > pipeline id
|
|
51
31
|
const workspaceId = workspaceName ?? config.id;
|
|
52
32
|
let workspace;
|
|
53
33
|
try {
|
|
@@ -57,128 +37,265 @@ export class PipelineRunner {
|
|
|
57
37
|
workspace = await Workspace.create(this.workdirRoot, workspaceId);
|
|
58
38
|
}
|
|
59
39
|
await workspace.cleanupStaging();
|
|
60
|
-
|
|
40
|
+
if (!dryRun) {
|
|
41
|
+
await this.runtime.check();
|
|
42
|
+
await this.runtime.cleanupContainers(workspace.id);
|
|
43
|
+
}
|
|
61
44
|
const state = new StateManager(workspace.root);
|
|
62
45
|
await state.load();
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
46
|
+
const stepRuns = new Map();
|
|
47
|
+
let totalArtifactSize = 0;
|
|
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;
|
|
93
76
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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 => ({
|
|
106
91
|
hostPath: resolve(pipelineRoot, m.host),
|
|
107
92
|
containerPath: m.container
|
|
108
|
-
}))
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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 });
|
|
113
114
|
});
|
|
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
|
+
}
|
|
126
|
+
}
|
|
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);
|
|
134
|
+
}
|
|
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)));
|
|
142
|
+
}
|
|
143
|
+
async executeStep({ workspace, state, step, stepRef, stepRuns, currentFingerprint, resolvedMounts, pipelineRoot }) {
|
|
144
|
+
const runId = workspace.generateRunId();
|
|
145
|
+
const stagingPath = await workspace.prepareRun(runId);
|
|
146
|
+
await this.prepareStagingWithInputs(workspace, step, workspace.runStagingArtifactsPath(runId), stepRuns);
|
|
147
|
+
if (step.caches) {
|
|
148
|
+
for (const cache of step.caches) {
|
|
149
|
+
await workspace.prepareCache(cache.name);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const { inputs, output, caches, mounts } = this.buildMounts(step, runId, stepRuns, pipelineRoot);
|
|
153
|
+
const stdoutLog = createWriteStream(join(stagingPath, 'stdout.log'));
|
|
154
|
+
const stderrLog = createWriteStream(join(stagingPath, 'stderr.log'));
|
|
155
|
+
try {
|
|
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;
|
|
186
|
+
}
|
|
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;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
114
196
|
this.reporter.result(workspace.id, stepRef, result);
|
|
197
|
+
await closeStream(stdoutLog);
|
|
198
|
+
await closeStream(stderrLog);
|
|
199
|
+
await this.writeRunMeta(stagingPath, { runId, step, stepRuns, resolvedMounts, currentFingerprint, result });
|
|
115
200
|
if (result.exitCode === 0 || step.allowFailure) {
|
|
116
|
-
await workspace.
|
|
117
|
-
await workspace.
|
|
118
|
-
|
|
119
|
-
state.setStep(step.id,
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
await workspace.discardArtifact(artifactId);
|
|
125
|
-
this.reporter.state(workspace.id, 'STEP_FAILED', stepRef, { exitCode: result.exitCode });
|
|
126
|
-
this.reporter.state(workspace.id, 'PIPELINE_FAILED');
|
|
127
|
-
throw new Error(`Step ${step.id} failed with exit code ${result.exitCode}`);
|
|
201
|
+
await workspace.commitRun(runId);
|
|
202
|
+
await workspace.linkRun(step.id, runId);
|
|
203
|
+
stepRuns.set(step.id, runId);
|
|
204
|
+
state.setStep(step.id, runId, currentFingerprint);
|
|
205
|
+
const durationMs = result.finishedAt.getTime() - result.startedAt.getTime();
|
|
206
|
+
const artifactSize = await dirSize(workspace.runArtifactsPath(runId));
|
|
207
|
+
this.reporter.emit({ event: 'STEP_FINISHED', workspaceId: workspace.id, step: stepRef, runId, durationMs, artifactSize });
|
|
208
|
+
return artifactSize;
|
|
128
209
|
}
|
|
210
|
+
await workspace.discardRun(runId);
|
|
211
|
+
this.reporter.emit({ event: 'STEP_FAILED', workspaceId: workspace.id, step: stepRef, exitCode: result.exitCode });
|
|
212
|
+
throw new ContainerCrashError(step.id, result.exitCode);
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
await closeStream(stdoutLog);
|
|
216
|
+
await closeStream(stderrLog);
|
|
217
|
+
throw error;
|
|
129
218
|
}
|
|
130
|
-
this.reporter.state(workspace.id, 'PIPELINE_FINISHED');
|
|
131
219
|
}
|
|
132
|
-
async
|
|
220
|
+
async writeRunMeta(stagingPath, { runId, step, stepRuns, resolvedMounts, currentFingerprint, result }) {
|
|
221
|
+
const meta = {
|
|
222
|
+
runId,
|
|
223
|
+
stepId: step.id,
|
|
224
|
+
stepName: step.name,
|
|
225
|
+
startedAt: result.startedAt.toISOString(),
|
|
226
|
+
finishedAt: result.finishedAt.toISOString(),
|
|
227
|
+
durationMs: result.finishedAt.getTime() - result.startedAt.getTime(),
|
|
228
|
+
exitCode: result.exitCode,
|
|
229
|
+
image: step.image,
|
|
230
|
+
cmd: step.cmd,
|
|
231
|
+
env: step.env,
|
|
232
|
+
inputs: step.inputs?.map(i => ({
|
|
233
|
+
step: i.step,
|
|
234
|
+
runId: stepRuns.get(i.step),
|
|
235
|
+
mountedAs: `/input/${i.step}`
|
|
236
|
+
})),
|
|
237
|
+
mounts: resolvedMounts,
|
|
238
|
+
caches: step.caches?.map(c => c.name),
|
|
239
|
+
allowNetwork: step.allowNetwork ?? false,
|
|
240
|
+
fingerprint: currentFingerprint,
|
|
241
|
+
status: result.exitCode === 0 ? 'success' : 'failure'
|
|
242
|
+
};
|
|
243
|
+
await writeFile(join(stagingPath, 'meta.json'), JSON.stringify(meta, null, 2), 'utf8');
|
|
244
|
+
}
|
|
245
|
+
async tryUseCache({ workspace, state, step, stepRef, currentFingerprint, stepRuns }) {
|
|
133
246
|
const cached = state.getStep(step.id);
|
|
134
247
|
if (cached?.fingerprint === currentFingerprint) {
|
|
135
248
|
try {
|
|
136
|
-
const
|
|
137
|
-
if (
|
|
138
|
-
|
|
139
|
-
await workspace.
|
|
140
|
-
this.reporter.
|
|
249
|
+
const runs = await workspace.listRuns();
|
|
250
|
+
if (runs.includes(cached.runId)) {
|
|
251
|
+
stepRuns.set(step.id, cached.runId);
|
|
252
|
+
await workspace.linkRun(step.id, cached.runId);
|
|
253
|
+
this.reporter.emit({ event: 'STEP_SKIPPED', workspaceId: workspace.id, step: stepRef, runId: cached.runId, reason: 'cached' });
|
|
141
254
|
return true;
|
|
142
255
|
}
|
|
143
256
|
}
|
|
144
257
|
catch {
|
|
145
|
-
//
|
|
258
|
+
// Run missing, proceed with execution
|
|
146
259
|
}
|
|
147
260
|
}
|
|
148
261
|
return false;
|
|
149
262
|
}
|
|
150
|
-
async prepareStagingWithInputs(workspace, step,
|
|
263
|
+
async prepareStagingWithInputs(workspace, step, stagingArtifactsPath, stepRuns) {
|
|
151
264
|
if (!step.inputs) {
|
|
152
265
|
return;
|
|
153
266
|
}
|
|
154
267
|
for (const input of step.inputs) {
|
|
155
|
-
const
|
|
156
|
-
if (!
|
|
157
|
-
|
|
268
|
+
const inputRunId = stepRuns.get(input.step);
|
|
269
|
+
if (!inputRunId) {
|
|
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;
|
|
158
276
|
}
|
|
159
277
|
if (input.copyToOutput) {
|
|
160
|
-
await cp(workspace.
|
|
278
|
+
await cp(workspace.runArtifactsPath(inputRunId), stagingArtifactsPath, { recursive: true });
|
|
161
279
|
}
|
|
162
280
|
}
|
|
163
281
|
}
|
|
164
|
-
buildMounts(step,
|
|
282
|
+
buildMounts(step, outputRunId, stepRuns, pipelineRoot) {
|
|
165
283
|
const inputs = [];
|
|
166
284
|
if (step.inputs) {
|
|
167
285
|
for (const input of step.inputs) {
|
|
168
|
-
const
|
|
169
|
-
if (
|
|
286
|
+
const inputRunId = stepRuns.get(input.step);
|
|
287
|
+
if (inputRunId) {
|
|
170
288
|
inputs.push({
|
|
171
|
-
|
|
289
|
+
runId: inputRunId,
|
|
172
290
|
containerPath: `/input/${input.step}`
|
|
173
291
|
});
|
|
174
292
|
}
|
|
175
293
|
}
|
|
176
294
|
}
|
|
177
295
|
const output = {
|
|
178
|
-
|
|
296
|
+
stagingRunId: outputRunId,
|
|
179
297
|
containerPath: step.outputPath ?? '/output'
|
|
180
298
|
};
|
|
181
|
-
// Build cache mounts
|
|
182
299
|
let caches;
|
|
183
300
|
if (step.caches) {
|
|
184
301
|
caches = step.caches.map(c => ({
|
|
@@ -196,3 +313,31 @@ export class PipelineRunner {
|
|
|
196
313
|
return { inputs, output, caches, mounts };
|
|
197
314
|
}
|
|
198
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
|
+
}
|
|
333
|
+
async function closeStream(stream) {
|
|
334
|
+
if (stream.destroyed) {
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
return new Promise((resolve, reject) => {
|
|
338
|
+
stream.end(() => {
|
|
339
|
+
resolve();
|
|
340
|
+
});
|
|
341
|
+
stream.on('error', reject);
|
|
342
|
+
});
|
|
343
|
+
}
|
package/dist/cli/reporter.js
CHANGED
|
@@ -1,15 +1,12 @@
|
|
|
1
1
|
import pino from 'pino';
|
|
2
|
-
import chalk from 'chalk';
|
|
3
|
-
import ora from 'ora';
|
|
4
2
|
/**
|
|
5
3
|
* Reporter that outputs structured JSON logs via pino.
|
|
6
4
|
* Suitable for CI/CD environments and log aggregation.
|
|
7
5
|
*/
|
|
8
6
|
export class ConsoleReporter {
|
|
9
7
|
logger = pino({ level: 'info' });
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
this.logger.info({ workspaceId, event, stepId: step?.id, stepName, ...meta });
|
|
8
|
+
emit(event) {
|
|
9
|
+
this.logger.info(event);
|
|
13
10
|
}
|
|
14
11
|
log(workspaceId, step, stream, line) {
|
|
15
12
|
this.logger.info({ workspaceId, stepId: step.id, stream, line });
|
|
@@ -18,105 +15,3 @@ export class ConsoleReporter {
|
|
|
18
15
|
this.logger.info({ workspaceId, stepId: step.id, result });
|
|
19
16
|
}
|
|
20
17
|
}
|
|
21
|
-
/**
|
|
22
|
-
* Reporter with interactive terminal UI using spinners and colors.
|
|
23
|
-
* Suitable for local development and manual execution.
|
|
24
|
-
*/
|
|
25
|
-
export class InteractiveReporter {
|
|
26
|
-
static get maxStderrLines() {
|
|
27
|
-
return 20;
|
|
28
|
-
}
|
|
29
|
-
spinner;
|
|
30
|
-
stepSpinners = new Map();
|
|
31
|
-
stderrBuffers = new Map();
|
|
32
|
-
state(workspaceId, event, step, meta) {
|
|
33
|
-
switch (event) {
|
|
34
|
-
case 'PIPELINE_START': {
|
|
35
|
-
const displayName = meta?.pipelineName ?? workspaceId;
|
|
36
|
-
console.log(chalk.bold(`\n▶ Pipeline: ${chalk.cyan(displayName)}\n`));
|
|
37
|
-
break;
|
|
38
|
-
}
|
|
39
|
-
case 'STEP_STARTING': {
|
|
40
|
-
if (step) {
|
|
41
|
-
const spinner = ora({ text: step.displayName, prefixText: ' ' }).start();
|
|
42
|
-
this.stepSpinners.set(step.id, spinner);
|
|
43
|
-
}
|
|
44
|
-
break;
|
|
45
|
-
}
|
|
46
|
-
case 'STEP_SKIPPED': {
|
|
47
|
-
if (step) {
|
|
48
|
-
const spinner = this.stepSpinners.get(step.id);
|
|
49
|
-
if (spinner) {
|
|
50
|
-
spinner.stopAndPersist({ symbol: chalk.gray('⊙'), text: chalk.gray(`${step.displayName} (cached)`) });
|
|
51
|
-
this.stepSpinners.delete(step.id);
|
|
52
|
-
}
|
|
53
|
-
else {
|
|
54
|
-
console.log(` ${chalk.gray('⊙')} ${chalk.gray(`${step.displayName} (cached)`)}`);
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
break;
|
|
58
|
-
}
|
|
59
|
-
case 'STEP_FINISHED': {
|
|
60
|
-
if (step) {
|
|
61
|
-
const spinner = this.stepSpinners.get(step.id);
|
|
62
|
-
if (spinner) {
|
|
63
|
-
spinner.stopAndPersist({ symbol: chalk.green('✓'), text: chalk.green(step.displayName) });
|
|
64
|
-
this.stepSpinners.delete(step.id);
|
|
65
|
-
}
|
|
66
|
-
this.stderrBuffers.delete(step.id);
|
|
67
|
-
}
|
|
68
|
-
break;
|
|
69
|
-
}
|
|
70
|
-
case 'STEP_FAILED': {
|
|
71
|
-
if (step) {
|
|
72
|
-
this.handleStepFailed(step, meta);
|
|
73
|
-
}
|
|
74
|
-
break;
|
|
75
|
-
}
|
|
76
|
-
case 'PIPELINE_FINISHED': {
|
|
77
|
-
console.log(chalk.bold.green('\n✓ Pipeline completed\n'));
|
|
78
|
-
break;
|
|
79
|
-
}
|
|
80
|
-
case 'PIPELINE_FAILED': {
|
|
81
|
-
console.log(chalk.bold.red('\n✗ Pipeline failed\n'));
|
|
82
|
-
break;
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
log(_workspaceId, step, stream, line) {
|
|
87
|
-
if (stream === 'stderr') {
|
|
88
|
-
let buffer = this.stderrBuffers.get(step.id);
|
|
89
|
-
if (!buffer) {
|
|
90
|
-
buffer = [];
|
|
91
|
-
this.stderrBuffers.set(step.id, buffer);
|
|
92
|
-
}
|
|
93
|
-
buffer.push(line);
|
|
94
|
-
if (buffer.length > InteractiveReporter.maxStderrLines) {
|
|
95
|
-
buffer.shift();
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
result(_workspaceId, _step, _result) {
|
|
100
|
-
// Results shown via state updates
|
|
101
|
-
}
|
|
102
|
-
handleStepFailed(step, meta) {
|
|
103
|
-
const spinner = this.stepSpinners.get(step.id);
|
|
104
|
-
const exitCode = meta?.exitCode;
|
|
105
|
-
if (spinner) {
|
|
106
|
-
const exitInfo = exitCode === undefined ? '' : ` (exit ${exitCode})`;
|
|
107
|
-
spinner.stopAndPersist({
|
|
108
|
-
symbol: chalk.red('✗'),
|
|
109
|
-
text: chalk.red(`${step.displayName}${exitInfo}`)
|
|
110
|
-
});
|
|
111
|
-
this.stepSpinners.delete(step.id);
|
|
112
|
-
}
|
|
113
|
-
const stderr = this.stderrBuffers.get(step.id);
|
|
114
|
-
if (stderr && stderr.length > 0) {
|
|
115
|
-
console.log(chalk.red(' ── stderr ──'));
|
|
116
|
-
for (const line of stderr) {
|
|
117
|
-
console.log(chalk.red(` ${line}`));
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
this.stderrBuffers.delete(step.id);
|
|
121
|
-
}
|
|
122
|
-
}
|
package/dist/cli/state.js
CHANGED
|
@@ -5,24 +5,24 @@ import { createHash } from 'node:crypto';
|
|
|
5
5
|
* Manages caching state for pipeline execution.
|
|
6
6
|
*
|
|
7
7
|
* The StateManager computes fingerprints for steps and tracks which
|
|
8
|
-
*
|
|
8
|
+
* run was produced by each step. This enables cache hits when
|
|
9
9
|
* a step's configuration hasn't changed.
|
|
10
10
|
*
|
|
11
11
|
* ## Fingerprint Algorithm
|
|
12
12
|
*
|
|
13
13
|
* A step fingerprint is computed as:
|
|
14
14
|
* ```
|
|
15
|
-
* SHA256(image + JSON(cmd) + JSON(sorted env) + JSON(sorted
|
|
15
|
+
* SHA256(image + JSON(cmd) + JSON(sorted env) + JSON(sorted inputRunIds))
|
|
16
16
|
* ```
|
|
17
17
|
*
|
|
18
18
|
* A step is re-executed when:
|
|
19
19
|
* - The fingerprint changes (image, cmd, env, or inputs modified)
|
|
20
|
-
* - The
|
|
20
|
+
* - The run no longer exists (manually deleted)
|
|
21
21
|
*
|
|
22
22
|
* ## Cache Propagation
|
|
23
23
|
*
|
|
24
24
|
* Changes propagate through dependencies. If step A is modified,
|
|
25
|
-
* all steps depending on A are invalidated automatically (via
|
|
25
|
+
* all steps depending on A are invalidated automatically (via inputRunIds).
|
|
26
26
|
*/
|
|
27
27
|
export class StateManager {
|
|
28
28
|
static fingerprint(config) {
|
|
@@ -32,8 +32,8 @@ export class StateManager {
|
|
|
32
32
|
if (config.env) {
|
|
33
33
|
hash.update(JSON.stringify(Object.entries(config.env).sort((a, b) => a[0].localeCompare(b[0]))));
|
|
34
34
|
}
|
|
35
|
-
if (config.
|
|
36
|
-
hash.update(JSON.stringify([...config.
|
|
35
|
+
if (config.inputRunIds) {
|
|
36
|
+
hash.update(JSON.stringify([...config.inputRunIds].sort((a, b) => a.localeCompare(b))));
|
|
37
37
|
}
|
|
38
38
|
if (config.mounts && config.mounts.length > 0) {
|
|
39
39
|
const sorted = [...config.mounts].sort((a, b) => a.containerPath.localeCompare(b.containerPath));
|
|
@@ -77,13 +77,34 @@ export class StateManager {
|
|
|
77
77
|
getStep(stepId) {
|
|
78
78
|
return this.state.steps[stepId];
|
|
79
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* Lists all steps with their run IDs (in insertion order).
|
|
82
|
+
* @returns Array of {stepId, runId} entries
|
|
83
|
+
*/
|
|
84
|
+
listSteps() {
|
|
85
|
+
return Object.entries(this.state.steps).map(([stepId, { runId }]) => ({ stepId, runId }));
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Returns the set of run IDs currently referenced by state.
|
|
89
|
+
*/
|
|
90
|
+
activeRunIds() {
|
|
91
|
+
return new Set(Object.values(this.state.steps).map(s => s.runId));
|
|
92
|
+
}
|
|
80
93
|
/**
|
|
81
94
|
* Updates cached state for a step.
|
|
82
95
|
* @param stepId - Step identifier
|
|
83
|
-
* @param
|
|
96
|
+
* @param runId - Run produced by the step
|
|
84
97
|
* @param fingerprint - Step configuration fingerprint
|
|
85
98
|
*/
|
|
86
|
-
setStep(stepId,
|
|
87
|
-
this.state.steps[stepId] = {
|
|
99
|
+
setStep(stepId, runId, fingerprint) {
|
|
100
|
+
this.state.steps[stepId] = { runId, fingerprint };
|
|
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];
|
|
88
109
|
}
|
|
89
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
|
+
}
|