@livingdata/pipex 0.0.7 → 0.0.9
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 +33 -3
- package/dist/cli/commands/clean.js +22 -0
- package/dist/cli/commands/export.js +32 -0
- package/dist/cli/commands/inspect.js +58 -0
- package/dist/cli/commands/list.js +38 -0
- package/dist/cli/commands/logs.js +54 -0
- package/dist/cli/commands/prune.js +26 -0
- package/dist/cli/commands/rm.js +27 -0
- package/dist/cli/commands/run.js +39 -0
- package/dist/cli/commands/show.js +73 -0
- package/dist/cli/index.js +18 -105
- package/dist/cli/pipeline-runner.js +123 -62
- package/dist/cli/reporter.js +58 -7
- package/dist/cli/state.js +22 -9
- package/dist/cli/utils.js +49 -0
- package/dist/engine/docker-executor.js +23 -5
- package/dist/engine/workspace.js +103 -56
- package/package.json +1 -1
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { cp } from 'node:fs/promises';
|
|
2
|
-
import {
|
|
1
|
+
import { cp, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { createWriteStream } from 'node:fs';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
4
|
import { Workspace } from '../engine/index.js';
|
|
4
5
|
import { StateManager } from './state.js';
|
|
6
|
+
import { dirSize } from './utils.js';
|
|
5
7
|
/**
|
|
6
8
|
* Orchestrates pipeline execution with dependency resolution and caching.
|
|
7
9
|
*
|
|
@@ -10,27 +12,20 @@ import { StateManager } from './state.js';
|
|
|
10
12
|
* 1. **Workspace Resolution**: Determines workspace ID from CLI flag, config, or filename
|
|
11
13
|
* 2. **State Loading**: Loads cached fingerprints from state.json
|
|
12
14
|
* 3. **Step Execution**: For each step:
|
|
13
|
-
* a. Computes fingerprint (image + cmd + env + input
|
|
14
|
-
* b. Checks cache (fingerprint match +
|
|
15
|
+
* a. Computes fingerprint (image + cmd + env + input run IDs)
|
|
16
|
+
* b. Checks cache (fingerprint match + run exists)
|
|
15
17
|
* c. If cached: skips execution
|
|
16
18
|
* d. If not cached: resolves inputs, prepares staging, executes container
|
|
17
|
-
* e. On success: commits
|
|
18
|
-
* f. On failure: discards
|
|
19
|
+
* e. On success: writes meta.json, commits run, saves state
|
|
20
|
+
* f. On failure: discards run, halts pipeline (unless allowFailure)
|
|
19
21
|
* 4. **Completion**: Reports final pipeline status
|
|
20
22
|
*
|
|
21
|
-
* ##
|
|
23
|
+
* ## Runs
|
|
22
24
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* -
|
|
26
|
-
* -
|
|
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
|
|
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
|
|
34
29
|
*/
|
|
35
30
|
export class PipelineRunner {
|
|
36
31
|
loader;
|
|
@@ -44,10 +39,9 @@ export class PipelineRunner {
|
|
|
44
39
|
this.workdirRoot = workdirRoot;
|
|
45
40
|
}
|
|
46
41
|
async run(pipelineFilePath, options) {
|
|
47
|
-
const { workspace: workspaceName, force } = options ?? {};
|
|
42
|
+
const { workspace: workspaceName, force, dryRun } = options ?? {};
|
|
48
43
|
const config = await this.loader.load(pipelineFilePath);
|
|
49
44
|
const pipelineRoot = dirname(resolve(pipelineFilePath));
|
|
50
|
-
// Workspace ID priority: CLI arg > pipeline id
|
|
51
45
|
const workspaceId = workspaceName ?? config.id;
|
|
52
46
|
let workspace;
|
|
53
47
|
try {
|
|
@@ -57,15 +51,19 @@ export class PipelineRunner {
|
|
|
57
51
|
workspace = await Workspace.create(this.workdirRoot, workspaceId);
|
|
58
52
|
}
|
|
59
53
|
await workspace.cleanupStaging();
|
|
60
|
-
|
|
54
|
+
if (!dryRun) {
|
|
55
|
+
await this.runtime.check();
|
|
56
|
+
await this.runtime.cleanupContainers(workspace.id);
|
|
57
|
+
}
|
|
61
58
|
const state = new StateManager(workspace.root);
|
|
62
59
|
await state.load();
|
|
63
|
-
const
|
|
60
|
+
const stepRuns = new Map();
|
|
61
|
+
let totalArtifactSize = 0;
|
|
64
62
|
this.reporter.state(workspace.id, 'PIPELINE_START', undefined, { pipelineName: config.name ?? config.id });
|
|
65
63
|
for (const step of config.steps) {
|
|
66
64
|
const stepRef = { id: step.id, displayName: step.name ?? step.id };
|
|
67
|
-
const
|
|
68
|
-
?.map(i =>
|
|
65
|
+
const inputRunIds = step.inputs
|
|
66
|
+
?.map(i => stepRuns.get(i.step))
|
|
69
67
|
.filter((id) => id !== undefined);
|
|
70
68
|
const resolvedMounts = step.mounts?.map(m => ({
|
|
71
69
|
hostPath: resolve(pipelineRoot, m.host),
|
|
@@ -75,24 +73,36 @@ export class PipelineRunner {
|
|
|
75
73
|
image: step.image,
|
|
76
74
|
cmd: step.cmd,
|
|
77
75
|
env: step.env,
|
|
78
|
-
|
|
76
|
+
inputRunIds,
|
|
79
77
|
mounts: resolvedMounts
|
|
80
78
|
});
|
|
81
79
|
const skipCache = force === true || (Array.isArray(force) && force.includes(step.id));
|
|
82
|
-
if (!skipCache && await this.tryUseCache({ workspace, state, step, stepRef, currentFingerprint,
|
|
80
|
+
if (!skipCache && await this.tryUseCache({ workspace, state, step, stepRef, currentFingerprint, stepRuns })) {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (dryRun) {
|
|
84
|
+
this.reporter.state(workspace.id, 'STEP_WOULD_RUN', stepRef);
|
|
83
85
|
continue;
|
|
84
86
|
}
|
|
85
87
|
this.reporter.state(workspace.id, 'STEP_STARTING', stepRef);
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
88
|
+
const stepArtifactSize = await this.executeStep({ workspace, state, step, stepRef, stepRuns, currentFingerprint, resolvedMounts, pipelineRoot });
|
|
89
|
+
totalArtifactSize += stepArtifactSize;
|
|
90
|
+
}
|
|
91
|
+
this.reporter.state(workspace.id, 'PIPELINE_FINISHED', undefined, { totalArtifactSize });
|
|
92
|
+
}
|
|
93
|
+
async executeStep({ workspace, state, step, stepRef, stepRuns, currentFingerprint, resolvedMounts, pipelineRoot }) {
|
|
94
|
+
const runId = workspace.generateRunId();
|
|
95
|
+
const stagingPath = await workspace.prepareRun(runId);
|
|
96
|
+
await this.prepareStagingWithInputs(workspace, step, workspace.runStagingArtifactsPath(runId), stepRuns);
|
|
97
|
+
if (step.caches) {
|
|
98
|
+
for (const cache of step.caches) {
|
|
99
|
+
await workspace.prepareCache(cache.name);
|
|
94
100
|
}
|
|
95
|
-
|
|
101
|
+
}
|
|
102
|
+
const { inputs, output, caches, mounts } = this.buildMounts(step, runId, stepRuns, pipelineRoot);
|
|
103
|
+
const stdoutLog = createWriteStream(join(stagingPath, 'stdout.log'));
|
|
104
|
+
const stderrLog = createWriteStream(join(stagingPath, 'stderr.log'));
|
|
105
|
+
try {
|
|
96
106
|
const result = await this.runtime.run(workspace, {
|
|
97
107
|
name: `pipex-${workspace.id}-${step.id}-${Date.now()}`,
|
|
98
108
|
image: step.image,
|
|
@@ -109,74 +119,114 @@ export class PipelineRunner {
|
|
|
109
119
|
network: step.allowNetwork ? 'bridge' : 'none',
|
|
110
120
|
timeoutSec: step.timeoutSec
|
|
111
121
|
}, ({ stream, line }) => {
|
|
122
|
+
if (stream === 'stdout') {
|
|
123
|
+
stdoutLog.write(line + '\n');
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
stderrLog.write(line + '\n');
|
|
127
|
+
}
|
|
112
128
|
this.reporter.log(workspace.id, stepRef, stream, line);
|
|
113
129
|
});
|
|
114
130
|
this.reporter.result(workspace.id, stepRef, result);
|
|
131
|
+
await closeStream(stdoutLog);
|
|
132
|
+
await closeStream(stderrLog);
|
|
133
|
+
await this.writeRunMeta(stagingPath, { runId, step, stepRuns, resolvedMounts, currentFingerprint, result });
|
|
115
134
|
if (result.exitCode === 0 || step.allowFailure) {
|
|
116
|
-
await workspace.
|
|
117
|
-
|
|
118
|
-
|
|
135
|
+
await workspace.commitRun(runId);
|
|
136
|
+
await workspace.linkRun(step.id, runId);
|
|
137
|
+
stepRuns.set(step.id, runId);
|
|
138
|
+
state.setStep(step.id, runId, currentFingerprint);
|
|
119
139
|
await state.save();
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
this.reporter.state(workspace.id, 'STEP_FAILED', stepRef, { exitCode: result.exitCode });
|
|
125
|
-
this.reporter.state(workspace.id, 'PIPELINE_FAILED');
|
|
126
|
-
throw new Error(`Step ${step.id} failed with exit code ${result.exitCode}`);
|
|
140
|
+
const durationMs = result.finishedAt.getTime() - result.startedAt.getTime();
|
|
141
|
+
const artifactSize = await dirSize(workspace.runArtifactsPath(runId));
|
|
142
|
+
this.reporter.state(workspace.id, 'STEP_FINISHED', stepRef, { runId, durationMs, artifactSize });
|
|
143
|
+
return artifactSize;
|
|
127
144
|
}
|
|
145
|
+
await workspace.discardRun(runId);
|
|
146
|
+
this.reporter.state(workspace.id, 'STEP_FAILED', stepRef, { exitCode: result.exitCode });
|
|
147
|
+
this.reporter.state(workspace.id, 'PIPELINE_FAILED');
|
|
148
|
+
throw new Error(`Step ${step.id} failed with exit code ${result.exitCode}`);
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
await closeStream(stdoutLog);
|
|
152
|
+
await closeStream(stderrLog);
|
|
153
|
+
throw error;
|
|
128
154
|
}
|
|
129
|
-
this.reporter.state(workspace.id, 'PIPELINE_FINISHED');
|
|
130
155
|
}
|
|
131
|
-
async
|
|
156
|
+
async writeRunMeta(stagingPath, { runId, step, stepRuns, resolvedMounts, currentFingerprint, result }) {
|
|
157
|
+
const meta = {
|
|
158
|
+
runId,
|
|
159
|
+
stepId: step.id,
|
|
160
|
+
stepName: step.name,
|
|
161
|
+
startedAt: result.startedAt.toISOString(),
|
|
162
|
+
finishedAt: result.finishedAt.toISOString(),
|
|
163
|
+
durationMs: result.finishedAt.getTime() - result.startedAt.getTime(),
|
|
164
|
+
exitCode: result.exitCode,
|
|
165
|
+
image: step.image,
|
|
166
|
+
cmd: step.cmd,
|
|
167
|
+
env: step.env,
|
|
168
|
+
inputs: step.inputs?.map(i => ({
|
|
169
|
+
step: i.step,
|
|
170
|
+
runId: stepRuns.get(i.step),
|
|
171
|
+
mountedAs: `/input/${i.step}`
|
|
172
|
+
})),
|
|
173
|
+
mounts: resolvedMounts,
|
|
174
|
+
caches: step.caches?.map(c => c.name),
|
|
175
|
+
allowNetwork: step.allowNetwork ?? false,
|
|
176
|
+
fingerprint: currentFingerprint,
|
|
177
|
+
status: result.exitCode === 0 ? 'success' : 'failure'
|
|
178
|
+
};
|
|
179
|
+
await writeFile(join(stagingPath, 'meta.json'), JSON.stringify(meta, null, 2), 'utf8');
|
|
180
|
+
}
|
|
181
|
+
async tryUseCache({ workspace, state, step, stepRef, currentFingerprint, stepRuns }) {
|
|
132
182
|
const cached = state.getStep(step.id);
|
|
133
183
|
if (cached?.fingerprint === currentFingerprint) {
|
|
134
184
|
try {
|
|
135
|
-
const
|
|
136
|
-
if (
|
|
137
|
-
|
|
138
|
-
|
|
185
|
+
const runs = await workspace.listRuns();
|
|
186
|
+
if (runs.includes(cached.runId)) {
|
|
187
|
+
stepRuns.set(step.id, cached.runId);
|
|
188
|
+
await workspace.linkRun(step.id, cached.runId);
|
|
189
|
+
this.reporter.state(workspace.id, 'STEP_SKIPPED', stepRef, { runId: cached.runId, reason: 'cached' });
|
|
139
190
|
return true;
|
|
140
191
|
}
|
|
141
192
|
}
|
|
142
193
|
catch {
|
|
143
|
-
//
|
|
194
|
+
// Run missing, proceed with execution
|
|
144
195
|
}
|
|
145
196
|
}
|
|
146
197
|
return false;
|
|
147
198
|
}
|
|
148
|
-
async prepareStagingWithInputs(workspace, step,
|
|
199
|
+
async prepareStagingWithInputs(workspace, step, stagingArtifactsPath, stepRuns) {
|
|
149
200
|
if (!step.inputs) {
|
|
150
201
|
return;
|
|
151
202
|
}
|
|
152
203
|
for (const input of step.inputs) {
|
|
153
|
-
const
|
|
154
|
-
if (!
|
|
204
|
+
const inputRunId = stepRuns.get(input.step);
|
|
205
|
+
if (!inputRunId) {
|
|
155
206
|
throw new Error(`Step ${step.id}: input step '${input.step}' not found or not yet executed`);
|
|
156
207
|
}
|
|
157
208
|
if (input.copyToOutput) {
|
|
158
|
-
await cp(workspace.
|
|
209
|
+
await cp(workspace.runArtifactsPath(inputRunId), stagingArtifactsPath, { recursive: true });
|
|
159
210
|
}
|
|
160
211
|
}
|
|
161
212
|
}
|
|
162
|
-
buildMounts(step,
|
|
213
|
+
buildMounts(step, outputRunId, stepRuns, pipelineRoot) {
|
|
163
214
|
const inputs = [];
|
|
164
215
|
if (step.inputs) {
|
|
165
216
|
for (const input of step.inputs) {
|
|
166
|
-
const
|
|
167
|
-
if (
|
|
217
|
+
const inputRunId = stepRuns.get(input.step);
|
|
218
|
+
if (inputRunId) {
|
|
168
219
|
inputs.push({
|
|
169
|
-
|
|
220
|
+
runId: inputRunId,
|
|
170
221
|
containerPath: `/input/${input.step}`
|
|
171
222
|
});
|
|
172
223
|
}
|
|
173
224
|
}
|
|
174
225
|
}
|
|
175
226
|
const output = {
|
|
176
|
-
|
|
227
|
+
stagingRunId: outputRunId,
|
|
177
228
|
containerPath: step.outputPath ?? '/output'
|
|
178
229
|
};
|
|
179
|
-
// Build cache mounts
|
|
180
230
|
let caches;
|
|
181
231
|
if (step.caches) {
|
|
182
232
|
caches = step.caches.map(c => ({
|
|
@@ -194,3 +244,14 @@ export class PipelineRunner {
|
|
|
194
244
|
return { inputs, output, caches, mounts };
|
|
195
245
|
}
|
|
196
246
|
}
|
|
247
|
+
async function closeStream(stream) {
|
|
248
|
+
if (stream.destroyed) {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
return new Promise((resolve, reject) => {
|
|
252
|
+
stream.end(() => {
|
|
253
|
+
resolve();
|
|
254
|
+
});
|
|
255
|
+
stream.on('error', reject);
|
|
256
|
+
});
|
|
257
|
+
}
|
package/dist/cli/reporter.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import pino from 'pino';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
3
|
import ora from 'ora';
|
|
4
|
+
import { formatDuration, formatSize } from './utils.js';
|
|
4
5
|
/**
|
|
5
6
|
* Reporter that outputs structured JSON logs via pino.
|
|
6
7
|
* Suitable for CI/CD environments and log aggregation.
|
|
@@ -26,9 +27,13 @@ export class InteractiveReporter {
|
|
|
26
27
|
static get maxStderrLines() {
|
|
27
28
|
return 20;
|
|
28
29
|
}
|
|
30
|
+
verbose;
|
|
29
31
|
spinner;
|
|
30
32
|
stepSpinners = new Map();
|
|
31
33
|
stderrBuffers = new Map();
|
|
34
|
+
constructor(options) {
|
|
35
|
+
this.verbose = options?.verbose ?? false;
|
|
36
|
+
}
|
|
32
37
|
state(workspaceId, event, step, meta) {
|
|
33
38
|
switch (event) {
|
|
34
39
|
case 'PIPELINE_START': {
|
|
@@ -58,12 +63,7 @@ export class InteractiveReporter {
|
|
|
58
63
|
}
|
|
59
64
|
case 'STEP_FINISHED': {
|
|
60
65
|
if (step) {
|
|
61
|
-
|
|
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);
|
|
66
|
+
this.handleStepFinished(step, meta);
|
|
67
67
|
}
|
|
68
68
|
break;
|
|
69
69
|
}
|
|
@@ -73,8 +73,14 @@ export class InteractiveReporter {
|
|
|
73
73
|
}
|
|
74
74
|
break;
|
|
75
75
|
}
|
|
76
|
+
case 'STEP_WOULD_RUN': {
|
|
77
|
+
if (step) {
|
|
78
|
+
this.handleStepWouldRun(step);
|
|
79
|
+
}
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
76
82
|
case 'PIPELINE_FINISHED': {
|
|
77
|
-
|
|
83
|
+
this.handlePipelineFinished(meta);
|
|
78
84
|
break;
|
|
79
85
|
}
|
|
80
86
|
case 'PIPELINE_FAILED': {
|
|
@@ -84,6 +90,18 @@ export class InteractiveReporter {
|
|
|
84
90
|
}
|
|
85
91
|
}
|
|
86
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
|
+
}
|
|
87
105
|
if (stream === 'stderr') {
|
|
88
106
|
let buffer = this.stderrBuffers.get(step.id);
|
|
89
107
|
if (!buffer) {
|
|
@@ -99,6 +117,39 @@ export class InteractiveReporter {
|
|
|
99
117
|
result(_workspaceId, _step, _result) {
|
|
100
118
|
// Results shown via state updates
|
|
101
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
|
+
}
|
|
102
153
|
handleStepFailed(step, meta) {
|
|
103
154
|
const spinner = this.stepSpinners.get(step.id);
|
|
104
155
|
const exitCode = meta?.exitCode;
|
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,26 @@ 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 };
|
|
88
101
|
}
|
|
89
102
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
export function getGlobalOptions(cmd) {
|
|
4
|
+
return cmd.optsWithGlobals();
|
|
5
|
+
}
|
|
6
|
+
export async function dirSize(dirPath) {
|
|
7
|
+
let total = 0;
|
|
8
|
+
try {
|
|
9
|
+
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
10
|
+
for (const entry of entries) {
|
|
11
|
+
const fullPath = join(dirPath, entry.name);
|
|
12
|
+
if (entry.isDirectory()) {
|
|
13
|
+
total += await dirSize(fullPath);
|
|
14
|
+
}
|
|
15
|
+
else if (entry.isFile()) {
|
|
16
|
+
const s = await stat(fullPath);
|
|
17
|
+
total += s.size;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// Directory doesn't exist or isn't readable
|
|
23
|
+
}
|
|
24
|
+
return total;
|
|
25
|
+
}
|
|
26
|
+
export function formatSize(bytes) {
|
|
27
|
+
if (bytes < 1024) {
|
|
28
|
+
return `${bytes} B`;
|
|
29
|
+
}
|
|
30
|
+
if (bytes < 1024 * 1024) {
|
|
31
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
32
|
+
}
|
|
33
|
+
if (bytes < 1024 * 1024 * 1024) {
|
|
34
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
35
|
+
}
|
|
36
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
|
37
|
+
}
|
|
38
|
+
export function formatDuration(ms) {
|
|
39
|
+
if (ms < 1000) {
|
|
40
|
+
return `${ms}ms`;
|
|
41
|
+
}
|
|
42
|
+
const seconds = ms / 1000;
|
|
43
|
+
if (seconds < 60) {
|
|
44
|
+
return `${seconds.toFixed(1)}s`;
|
|
45
|
+
}
|
|
46
|
+
const minutes = Math.floor(seconds / 60);
|
|
47
|
+
const remainingSeconds = Math.round(seconds % 60);
|
|
48
|
+
return `${minutes}m ${remainingSeconds}s`;
|
|
49
|
+
}
|
|
@@ -26,20 +26,38 @@ export class DockerCliExecutor extends ContainerExecutor {
|
|
|
26
26
|
throw new Error('Docker CLI not found. Please install Docker.');
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Remove any leftover pipex containers for the given workspace.
|
|
31
|
+
* Called before pipeline execution to clean up after crashes.
|
|
32
|
+
*/
|
|
33
|
+
async cleanupContainers(workspaceId) {
|
|
34
|
+
try {
|
|
35
|
+
const { stdout } = await execa('docker', [
|
|
36
|
+
'ps', '-a', '--filter', `label=pipex.workspace=${workspaceId}`, '-q'
|
|
37
|
+
], { env: this.env });
|
|
38
|
+
const ids = stdout.trim().split('\n').filter(Boolean);
|
|
39
|
+
if (ids.length > 0) {
|
|
40
|
+
await execa('docker', ['rm', '-f', ...ids], { env: this.env, reject: false });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// Best effort
|
|
45
|
+
}
|
|
46
|
+
}
|
|
29
47
|
async run(workspace, request, onLogLine) {
|
|
30
48
|
const startedAt = new Date();
|
|
31
49
|
// Use create+start instead of run: docker run cannot create mountpoints
|
|
32
50
|
// for anonymous volumes inside read-only bind mounts (shadow paths).
|
|
33
51
|
// docker create sets up the filesystem layer before readonly applies.
|
|
34
|
-
const args = ['create', '--name', request.name, '--network', request.network];
|
|
52
|
+
const args = ['create', '--name', request.name, '--network', request.network, '--label', 'pipex=true', '--label', `pipex.workspace=${workspace.id}`];
|
|
35
53
|
if (request.env) {
|
|
36
54
|
for (const [key, value] of Object.entries(request.env)) {
|
|
37
55
|
args.push('-e', `${key}=${value}`);
|
|
38
56
|
}
|
|
39
57
|
}
|
|
40
|
-
// Mount inputs (committed artifacts, read-only)
|
|
58
|
+
// Mount inputs (committed run artifacts, read-only)
|
|
41
59
|
for (const input of request.inputs) {
|
|
42
|
-
const hostPath = workspace.
|
|
60
|
+
const hostPath = workspace.runArtifactsPath(input.runId);
|
|
43
61
|
args.push('-v', `${hostPath}:${input.containerPath}:ro`);
|
|
44
62
|
}
|
|
45
63
|
// Mount caches (persistent, read-write)
|
|
@@ -55,8 +73,8 @@ export class DockerCliExecutor extends ContainerExecutor {
|
|
|
55
73
|
args.push('-v', `${mount.hostPath}:${mount.containerPath}:ro`);
|
|
56
74
|
}
|
|
57
75
|
}
|
|
58
|
-
// Mount output (staging
|
|
59
|
-
const outputHostPath = workspace.
|
|
76
|
+
// Mount output (staging run artifacts, read-write)
|
|
77
|
+
const outputHostPath = workspace.runStagingArtifactsPath(request.output.stagingRunId);
|
|
60
78
|
args.push('-v', `${outputHostPath}:${request.output.containerPath}:rw`, request.image, ...request.cmd);
|
|
61
79
|
let exitCode = 0;
|
|
62
80
|
let error;
|