@livingdata/pipex 0.0.8 → 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 -64
- 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 +94 -60
- 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,76 +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
|
-
await workspace.
|
|
118
|
-
|
|
119
|
-
state.setStep(step.id,
|
|
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);
|
|
120
139
|
await state.save();
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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}`);
|
|
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;
|
|
128
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;
|
|
129
154
|
}
|
|
130
|
-
this.reporter.state(workspace.id, 'PIPELINE_FINISHED');
|
|
131
155
|
}
|
|
132
|
-
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 }) {
|
|
133
182
|
const cached = state.getStep(step.id);
|
|
134
183
|
if (cached?.fingerprint === currentFingerprint) {
|
|
135
184
|
try {
|
|
136
|
-
const
|
|
137
|
-
if (
|
|
138
|
-
|
|
139
|
-
await workspace.
|
|
140
|
-
this.reporter.state(workspace.id, 'STEP_SKIPPED', stepRef, {
|
|
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' });
|
|
141
190
|
return true;
|
|
142
191
|
}
|
|
143
192
|
}
|
|
144
193
|
catch {
|
|
145
|
-
//
|
|
194
|
+
// Run missing, proceed with execution
|
|
146
195
|
}
|
|
147
196
|
}
|
|
148
197
|
return false;
|
|
149
198
|
}
|
|
150
|
-
async prepareStagingWithInputs(workspace, step,
|
|
199
|
+
async prepareStagingWithInputs(workspace, step, stagingArtifactsPath, stepRuns) {
|
|
151
200
|
if (!step.inputs) {
|
|
152
201
|
return;
|
|
153
202
|
}
|
|
154
203
|
for (const input of step.inputs) {
|
|
155
|
-
const
|
|
156
|
-
if (!
|
|
204
|
+
const inputRunId = stepRuns.get(input.step);
|
|
205
|
+
if (!inputRunId) {
|
|
157
206
|
throw new Error(`Step ${step.id}: input step '${input.step}' not found or not yet executed`);
|
|
158
207
|
}
|
|
159
208
|
if (input.copyToOutput) {
|
|
160
|
-
await cp(workspace.
|
|
209
|
+
await cp(workspace.runArtifactsPath(inputRunId), stagingArtifactsPath, { recursive: true });
|
|
161
210
|
}
|
|
162
211
|
}
|
|
163
212
|
}
|
|
164
|
-
buildMounts(step,
|
|
213
|
+
buildMounts(step, outputRunId, stepRuns, pipelineRoot) {
|
|
165
214
|
const inputs = [];
|
|
166
215
|
if (step.inputs) {
|
|
167
216
|
for (const input of step.inputs) {
|
|
168
|
-
const
|
|
169
|
-
if (
|
|
217
|
+
const inputRunId = stepRuns.get(input.step);
|
|
218
|
+
if (inputRunId) {
|
|
170
219
|
inputs.push({
|
|
171
|
-
|
|
220
|
+
runId: inputRunId,
|
|
172
221
|
containerPath: `/input/${input.step}`
|
|
173
222
|
});
|
|
174
223
|
}
|
|
175
224
|
}
|
|
176
225
|
}
|
|
177
226
|
const output = {
|
|
178
|
-
|
|
227
|
+
stagingRunId: outputRunId,
|
|
179
228
|
containerPath: step.outputPath ?? '/output'
|
|
180
229
|
};
|
|
181
|
-
// Build cache mounts
|
|
182
230
|
let caches;
|
|
183
231
|
if (step.caches) {
|
|
184
232
|
caches = step.caches.map(c => ({
|
|
@@ -196,3 +244,14 @@ export class PipelineRunner {
|
|
|
196
244
|
return { inputs, output, caches, mounts };
|
|
197
245
|
}
|
|
198
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;
|