@ebowwa/workflows 0.1.0
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 +257 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4641 -0
- package/dist/mcp/index.d.ts +57 -0
- package/dist/mcp/index.d.ts.map +1 -0
- package/dist/mcp/index.js +17545 -0
- package/dist/runner.d.ts +62 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/sandbox.d.ts +67 -0
- package/dist/sandbox.d.ts.map +1 -0
- package/dist/scheduler.d.ts +93 -0
- package/dist/scheduler.d.ts.map +1 -0
- package/dist/types.d.ts +823 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +62 -0
- package/src/index.ts +72 -0
- package/src/mcp/index.ts +572 -0
- package/src/runner.ts +308 -0
- package/src/sandbox.ts +305 -0
- package/src/scheduler.ts +287 -0
- package/src/types.ts +218 -0
package/src/runner.ts
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow Runner - Execution Engine
|
|
3
|
+
*
|
|
4
|
+
* Orchestrates workflow execution with job scheduling,
|
|
5
|
+
* step execution, and event emission for real-time updates.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { EventEmitter } from 'node:events';
|
|
9
|
+
import { randomUUID } from 'node:crypto';
|
|
10
|
+
import type {
|
|
11
|
+
Workflow,
|
|
12
|
+
WorkflowRun,
|
|
13
|
+
JobRun,
|
|
14
|
+
StepRun,
|
|
15
|
+
Trigger,
|
|
16
|
+
WorkflowEvent,
|
|
17
|
+
WorkflowRunStatus,
|
|
18
|
+
JobRunStatus,
|
|
19
|
+
StepRunStatus,
|
|
20
|
+
LogEntry,
|
|
21
|
+
} from './types.js';
|
|
22
|
+
import { ProcessSandbox, evaluateCondition } from './sandbox.js';
|
|
23
|
+
import { JobScheduler } from './scheduler.js';
|
|
24
|
+
|
|
25
|
+
export interface RunnerOptions {
|
|
26
|
+
/** Default workspace directory */
|
|
27
|
+
workspace?: string;
|
|
28
|
+
/** Default environment variables */
|
|
29
|
+
env?: Record<string, string>;
|
|
30
|
+
/** Maximum concurrent jobs */
|
|
31
|
+
maxConcurrentJobs?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface RunOptions {
|
|
35
|
+
/** Trigger that initiated this run */
|
|
36
|
+
trigger?: Trigger;
|
|
37
|
+
/** Environment variables for this run */
|
|
38
|
+
env?: Record<string, string>;
|
|
39
|
+
/** Workspace override */
|
|
40
|
+
workspace?: string;
|
|
41
|
+
/** Pre-generated run ID (optional) */
|
|
42
|
+
runId?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Workflow Runner - executes workflows with process isolation
|
|
47
|
+
*/
|
|
48
|
+
export class WorkflowRunner extends EventEmitter {
|
|
49
|
+
private sandbox: ProcessSandbox;
|
|
50
|
+
private workspace: string;
|
|
51
|
+
private env: Record<string, string>;
|
|
52
|
+
private maxConcurrentJobs: number;
|
|
53
|
+
private activeRuns = new Map<string, { cancelled: boolean }>();
|
|
54
|
+
|
|
55
|
+
constructor(options: RunnerOptions = {}) {
|
|
56
|
+
super();
|
|
57
|
+
this.workspace = options.workspace ?? process.cwd();
|
|
58
|
+
this.env = options.env ?? {};
|
|
59
|
+
this.maxConcurrentJobs = options.maxConcurrentJobs ?? 4;
|
|
60
|
+
|
|
61
|
+
// Create sandbox with log forwarding
|
|
62
|
+
this.sandbox = new ProcessSandbox({
|
|
63
|
+
onLog: (entry) => this.emitLog(entry),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Run a workflow
|
|
69
|
+
*/
|
|
70
|
+
async runWorkflow(workflow: Workflow, options: RunOptions = {}): Promise<WorkflowRun> {
|
|
71
|
+
const runId = options.runId ?? randomUUID();
|
|
72
|
+
const trigger: Trigger = options.trigger ?? { type: 'manual' };
|
|
73
|
+
const workspace = options.workspace ?? this.workspace;
|
|
74
|
+
const env = { ...this.env, ...workflow.env, ...options.env };
|
|
75
|
+
|
|
76
|
+
// Track this run
|
|
77
|
+
this.activeRuns.set(runId, { cancelled: false });
|
|
78
|
+
|
|
79
|
+
// Initialize run state
|
|
80
|
+
const run: WorkflowRun = {
|
|
81
|
+
id: runId,
|
|
82
|
+
workflowId: workflow.id,
|
|
83
|
+
status: 'pending',
|
|
84
|
+
trigger,
|
|
85
|
+
jobs: {},
|
|
86
|
+
startedAt: new Date(),
|
|
87
|
+
env,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// Initialize job runs
|
|
91
|
+
const scheduler = new JobScheduler(workflow.jobs);
|
|
92
|
+
|
|
93
|
+
for (const jobId of scheduler.getJobIds()) {
|
|
94
|
+
const job = workflow.jobs[jobId];
|
|
95
|
+
run.jobs[jobId] = {
|
|
96
|
+
id: randomUUID(),
|
|
97
|
+
jobId,
|
|
98
|
+
status: 'pending',
|
|
99
|
+
steps: job.steps.map((step, index) => ({
|
|
100
|
+
id: step.id ?? `step-${index}`,
|
|
101
|
+
stepId: step.id ?? `step-${index}`,
|
|
102
|
+
status: 'pending' as StepRunStatus,
|
|
103
|
+
})),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Check for cycles
|
|
108
|
+
if (scheduler.hasCycles()) {
|
|
109
|
+
run.status = 'failed';
|
|
110
|
+
run.finishedAt = new Date();
|
|
111
|
+
this.activeRuns.delete(runId);
|
|
112
|
+
return run;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Emit workflow started
|
|
116
|
+
this.emitEvent('workflow:started', runId);
|
|
117
|
+
|
|
118
|
+
// Start execution
|
|
119
|
+
run.status = 'running';
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
await this.executeJobs(workflow, run, scheduler, workspace, env);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
run.status = 'failed';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Determine final status
|
|
128
|
+
if (this.activeRuns.get(runId)?.cancelled) {
|
|
129
|
+
run.status = 'cancelled';
|
|
130
|
+
} else {
|
|
131
|
+
const allSuccess = Object.values(run.jobs).every(j => j.status === 'success');
|
|
132
|
+
const anyFailed = Object.values(run.jobs).some(j => j.status === 'failed');
|
|
133
|
+
run.status = anyFailed ? 'failed' : allSuccess ? 'success' : 'cancelled';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
run.finishedAt = new Date();
|
|
137
|
+
|
|
138
|
+
// Emit workflow finished
|
|
139
|
+
this.emitEvent('workflow:finished', runId, { status: run.status });
|
|
140
|
+
|
|
141
|
+
this.activeRuns.delete(runId);
|
|
142
|
+
return run;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private async executeJobs(
|
|
146
|
+
workflow: Workflow,
|
|
147
|
+
run: WorkflowRun,
|
|
148
|
+
scheduler: JobScheduler,
|
|
149
|
+
workspace: string,
|
|
150
|
+
env: Record<string, string>
|
|
151
|
+
): Promise<void> {
|
|
152
|
+
const runState = this.activeRuns.get(run.id);
|
|
153
|
+
if (!runState) return;
|
|
154
|
+
|
|
155
|
+
// Execute jobs in waves based on dependencies
|
|
156
|
+
while (!scheduler.isComplete() && !runState.cancelled) {
|
|
157
|
+
// Get jobs ready to run
|
|
158
|
+
const readyJobs = scheduler.getReadyJobs();
|
|
159
|
+
|
|
160
|
+
if (readyJobs.length === 0) {
|
|
161
|
+
// No jobs ready - either all done or deadlock
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Execute ready jobs (up to max concurrent)
|
|
166
|
+
const toExecute = readyJobs.slice(0, this.maxConcurrentJobs);
|
|
167
|
+
|
|
168
|
+
await Promise.all(
|
|
169
|
+
toExecute.map(jobId => this.executeJob(workflow, run, jobId, scheduler, workspace, env))
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private async executeJob(
|
|
175
|
+
workflow: Workflow,
|
|
176
|
+
run: WorkflowRun,
|
|
177
|
+
jobId: string,
|
|
178
|
+
scheduler: JobScheduler,
|
|
179
|
+
workspace: string,
|
|
180
|
+
env: Record<string, string>
|
|
181
|
+
): Promise<void> {
|
|
182
|
+
const runState = this.activeRuns.get(run.id);
|
|
183
|
+
if (!runState || runState.cancelled) return;
|
|
184
|
+
|
|
185
|
+
const job = workflow.jobs[jobId];
|
|
186
|
+
const jobRun = run.jobs[jobId];
|
|
187
|
+
|
|
188
|
+
if (!job || !jobRun) return;
|
|
189
|
+
|
|
190
|
+
// Check if dependencies failed
|
|
191
|
+
const depsFailed = (job.needs ?? []).some(depId => run.jobs[depId]?.status === 'failed');
|
|
192
|
+
if (depsFailed) {
|
|
193
|
+
jobRun.status = 'skipped';
|
|
194
|
+
scheduler.skipJob(jobId);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Mark as started
|
|
199
|
+
scheduler.startJob(jobId);
|
|
200
|
+
jobRun.status = 'running';
|
|
201
|
+
jobRun.startedAt = new Date();
|
|
202
|
+
|
|
203
|
+
this.emitEvent('job:started', run.id, { jobId });
|
|
204
|
+
|
|
205
|
+
// Execute steps
|
|
206
|
+
let allStepsSuccess = true;
|
|
207
|
+
const previousSteps: { succeeded: boolean }[] = [];
|
|
208
|
+
|
|
209
|
+
for (let i = 0; i < job.steps.length; i++) {
|
|
210
|
+
if (runState.cancelled) {
|
|
211
|
+
jobRun.status = 'cancelled';
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const step = job.steps[i];
|
|
216
|
+
const stepRun = jobRun.steps[i];
|
|
217
|
+
|
|
218
|
+
// Evaluate condition
|
|
219
|
+
const shouldRun = evaluateCondition(step.condition, { previousSteps });
|
|
220
|
+
|
|
221
|
+
if (!shouldRun) {
|
|
222
|
+
stepRun.status = 'skipped';
|
|
223
|
+
previousSteps.push({ succeeded: true }); // Skipped counts as success for subsequent conditions
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Execute step
|
|
228
|
+
stepRun.status = 'running';
|
|
229
|
+
stepRun.startedAt = new Date();
|
|
230
|
+
|
|
231
|
+
this.emitEvent('step:started', run.id, { jobId, stepId: stepRun.stepId });
|
|
232
|
+
|
|
233
|
+
const result = await this.sandbox.executeStep(step, {
|
|
234
|
+
workspace,
|
|
235
|
+
env: { ...env, ...job.env },
|
|
236
|
+
jobId,
|
|
237
|
+
stepIndex: i,
|
|
238
|
+
runId: run.id,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
stepRun.exitCode = result.exitCode;
|
|
242
|
+
stepRun.duration = result.duration;
|
|
243
|
+
stepRun.status = result.succeeded ? 'success' : 'failed';
|
|
244
|
+
stepRun.finishedAt = new Date();
|
|
245
|
+
|
|
246
|
+
this.emitEvent('step:finished', run.id, {
|
|
247
|
+
jobId,
|
|
248
|
+
stepId: stepRun.stepId,
|
|
249
|
+
data: { exitCode: result.exitCode, duration: result.duration },
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
previousSteps.push({ succeeded: result.succeeded });
|
|
253
|
+
|
|
254
|
+
if (!result.succeeded) {
|
|
255
|
+
allStepsSuccess = false;
|
|
256
|
+
break; // Stop on first failure
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Mark job as complete
|
|
261
|
+
jobRun.status = allStepsSuccess ? 'success' : 'failed';
|
|
262
|
+
jobRun.finishedAt = new Date();
|
|
263
|
+
scheduler.completeJob(jobId, allStepsSuccess);
|
|
264
|
+
|
|
265
|
+
this.emitEvent('job:finished', run.id, { jobId, data: { status: jobRun.status } });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Cancel a running workflow
|
|
270
|
+
*/
|
|
271
|
+
cancelRun(runId: string): boolean {
|
|
272
|
+
const runState = this.activeRuns.get(runId);
|
|
273
|
+
if (!runState) return false;
|
|
274
|
+
|
|
275
|
+
runState.cancelled = true;
|
|
276
|
+
this.sandbox.cancelRun(runId);
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Check if a run is active
|
|
282
|
+
*/
|
|
283
|
+
isRunActive(runId: string): boolean {
|
|
284
|
+
return this.activeRuns.has(runId);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Get all active run IDs
|
|
289
|
+
*/
|
|
290
|
+
getActiveRuns(): string[] {
|
|
291
|
+
return Array.from(this.activeRuns.keys());
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private emitEvent(type: WorkflowEvent['type'], runId: string, data?: unknown): void {
|
|
295
|
+
this.emit('event', { type, runId, data } as WorkflowEvent);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private emitLog(entry: LogEntry): void {
|
|
299
|
+
this.emit('log', entry);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Create a workflow runner
|
|
305
|
+
*/
|
|
306
|
+
export function createRunner(options?: RunnerOptions): WorkflowRunner {
|
|
307
|
+
return new WorkflowRunner(options);
|
|
308
|
+
}
|
package/src/sandbox.ts
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-level isolation for step execution
|
|
3
|
+
*
|
|
4
|
+
* Provides secure execution of workflow steps with process isolation,
|
|
5
|
+
* timeout handling, retry logic, and real-time log streaming.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import type { ChildProcess } from 'node:child_process';
|
|
10
|
+
import type { Step, StepContext, StepResult, LogEntry } from './types.js';
|
|
11
|
+
|
|
12
|
+
export interface SandboxOptions {
|
|
13
|
+
/** Default timeout for steps (ms) */
|
|
14
|
+
defaultTimeout?: number;
|
|
15
|
+
/** Default retry count */
|
|
16
|
+
defaultRetry?: number;
|
|
17
|
+
/** Log callback for real-time streaming */
|
|
18
|
+
onLog?: (entry: LogEntry) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ActiveProcess {
|
|
22
|
+
pid: number;
|
|
23
|
+
jobId: string;
|
|
24
|
+
stepId: string;
|
|
25
|
+
process: ChildProcess;
|
|
26
|
+
cancelled: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Process Sandbox for isolated step execution
|
|
31
|
+
*/
|
|
32
|
+
export class ProcessSandbox {
|
|
33
|
+
private activeProcesses = new Map<string, ActiveProcess>();
|
|
34
|
+
private defaultTimeout: number;
|
|
35
|
+
private defaultRetry: number;
|
|
36
|
+
private onLog?: (entry: LogEntry) => void;
|
|
37
|
+
|
|
38
|
+
constructor(options: SandboxOptions = {}) {
|
|
39
|
+
this.defaultTimeout = options.defaultTimeout ?? 300000; // 5 minutes default
|
|
40
|
+
this.defaultRetry = options.defaultRetry ?? 0;
|
|
41
|
+
this.onLog = options.onLog;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Execute a step in an isolated process
|
|
46
|
+
*/
|
|
47
|
+
async executeStep(
|
|
48
|
+
step: Step,
|
|
49
|
+
context: StepContext
|
|
50
|
+
): Promise<StepResult> {
|
|
51
|
+
const stepId = step.id ?? `step-${context.stepIndex}`;
|
|
52
|
+
const timeout = step.timeout ?? this.defaultTimeout;
|
|
53
|
+
const maxRetries = step.retry ?? this.defaultRetry;
|
|
54
|
+
|
|
55
|
+
let lastResult: StepResult = {
|
|
56
|
+
exitCode: 1,
|
|
57
|
+
stdout: '',
|
|
58
|
+
stderr: '',
|
|
59
|
+
duration: 0,
|
|
60
|
+
succeeded: false,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
64
|
+
lastResult = await this.executeStepOnce(step, context, stepId, timeout);
|
|
65
|
+
|
|
66
|
+
if (lastResult.succeeded) {
|
|
67
|
+
return lastResult;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Log retry attempt
|
|
71
|
+
if (attempt < maxRetries) {
|
|
72
|
+
this.emitLog({
|
|
73
|
+
timestamp: new Date(),
|
|
74
|
+
jobId: context.jobId,
|
|
75
|
+
stepId,
|
|
76
|
+
stream: 'stderr',
|
|
77
|
+
content: `[RETRY] Attempt ${attempt + 1}/${maxRetries} failed, retrying...\n`,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return lastResult;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private async executeStepOnce(
|
|
86
|
+
step: Step,
|
|
87
|
+
context: StepContext,
|
|
88
|
+
stepId: string,
|
|
89
|
+
timeout: number
|
|
90
|
+
): Promise<StepResult> {
|
|
91
|
+
const startTime = Date.now();
|
|
92
|
+
const processKey = `${context.runId}:${context.jobId}:${stepId}`;
|
|
93
|
+
|
|
94
|
+
return new Promise((resolve) => {
|
|
95
|
+
let stdout = '';
|
|
96
|
+
let stderr = '';
|
|
97
|
+
let cancelled = false;
|
|
98
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
99
|
+
|
|
100
|
+
// Determine shell based on runs-on (from job context, default to bash)
|
|
101
|
+
const shell = process.env.SHELL ?? '/bin/bash';
|
|
102
|
+
|
|
103
|
+
// Merge environment variables
|
|
104
|
+
const env = {
|
|
105
|
+
...process.env,
|
|
106
|
+
...context.env,
|
|
107
|
+
...step.env,
|
|
108
|
+
// Add workflow context variables
|
|
109
|
+
WORKFLOW_RUN_ID: context.runId,
|
|
110
|
+
WORKFLOW_JOB_ID: context.jobId,
|
|
111
|
+
WORKFLOW_STEP_ID: stepId,
|
|
112
|
+
WORKFLOW_WORKSPACE: context.workspace,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// Spawn process with isolation
|
|
116
|
+
const proc = spawn(step.run, [], {
|
|
117
|
+
cwd: step.cwd ?? context.workspace,
|
|
118
|
+
env,
|
|
119
|
+
shell,
|
|
120
|
+
detached: false, // Process-level isolation (not process group)
|
|
121
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Track active process
|
|
125
|
+
const activeProcess: ActiveProcess = {
|
|
126
|
+
pid: proc.pid ?? 0,
|
|
127
|
+
jobId: context.jobId,
|
|
128
|
+
stepId,
|
|
129
|
+
process: proc,
|
|
130
|
+
cancelled: false,
|
|
131
|
+
};
|
|
132
|
+
this.activeProcesses.set(processKey, activeProcess);
|
|
133
|
+
|
|
134
|
+
// Set timeout
|
|
135
|
+
timeoutId = setTimeout(() => {
|
|
136
|
+
cancelled = true;
|
|
137
|
+
this.emitLog({
|
|
138
|
+
timestamp: new Date(),
|
|
139
|
+
jobId: context.jobId,
|
|
140
|
+
stepId,
|
|
141
|
+
stream: 'stderr',
|
|
142
|
+
content: `[TIMEOUT] Step exceeded ${timeout}ms, terminating...\n`,
|
|
143
|
+
});
|
|
144
|
+
this.killProcess(proc, processKey);
|
|
145
|
+
}, timeout);
|
|
146
|
+
|
|
147
|
+
// Stream stdout
|
|
148
|
+
if (proc.stdout) {
|
|
149
|
+
proc.stdout.on('data', (data: Buffer) => {
|
|
150
|
+
const content = data.toString();
|
|
151
|
+
stdout += content;
|
|
152
|
+
this.emitLog({
|
|
153
|
+
timestamp: new Date(),
|
|
154
|
+
jobId: context.jobId,
|
|
155
|
+
stepId,
|
|
156
|
+
stream: 'stdout',
|
|
157
|
+
content,
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Stream stderr
|
|
163
|
+
if (proc.stderr) {
|
|
164
|
+
proc.stderr.on('data', (data: Buffer) => {
|
|
165
|
+
const content = data.toString();
|
|
166
|
+
stderr += content;
|
|
167
|
+
this.emitLog({
|
|
168
|
+
timestamp: new Date(),
|
|
169
|
+
jobId: context.jobId,
|
|
170
|
+
stepId,
|
|
171
|
+
stream: 'stderr',
|
|
172
|
+
content,
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Handle process completion
|
|
178
|
+
proc.on('close', (code) => {
|
|
179
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
180
|
+
this.activeProcesses.delete(processKey);
|
|
181
|
+
|
|
182
|
+
const duration = Date.now() - startTime;
|
|
183
|
+
const exitCode = code ?? (cancelled ? 137 : 1);
|
|
184
|
+
const succeeded = exitCode === 0;
|
|
185
|
+
|
|
186
|
+
resolve({
|
|
187
|
+
exitCode,
|
|
188
|
+
stdout,
|
|
189
|
+
stderr,
|
|
190
|
+
duration,
|
|
191
|
+
succeeded,
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// Handle process errors
|
|
196
|
+
proc.on('error', (err) => {
|
|
197
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
198
|
+
this.activeProcesses.delete(processKey);
|
|
199
|
+
|
|
200
|
+
const duration = Date.now() - startTime;
|
|
201
|
+
stderr += `Process error: ${err.message}\n`;
|
|
202
|
+
|
|
203
|
+
resolve({
|
|
204
|
+
exitCode: 1,
|
|
205
|
+
stdout,
|
|
206
|
+
stderr,
|
|
207
|
+
duration,
|
|
208
|
+
succeeded: false,
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Cancel a running step
|
|
216
|
+
*/
|
|
217
|
+
cancelStep(runId: string, jobId: string, stepId: string): boolean {
|
|
218
|
+
const processKey = `${runId}:${jobId}:${stepId}`;
|
|
219
|
+
const active = this.activeProcesses.get(processKey);
|
|
220
|
+
|
|
221
|
+
if (active) {
|
|
222
|
+
active.cancelled = true;
|
|
223
|
+
this.killProcess(active.process, processKey);
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Cancel all steps for a run
|
|
232
|
+
*/
|
|
233
|
+
cancelRun(runId: string): number {
|
|
234
|
+
let cancelled = 0;
|
|
235
|
+
|
|
236
|
+
for (const [key, active] of this.activeProcesses) {
|
|
237
|
+
if (key.startsWith(`${runId}:`)) {
|
|
238
|
+
active.cancelled = true;
|
|
239
|
+
this.killProcess(active.process, key);
|
|
240
|
+
cancelled++;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return cancelled;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Get all active processes
|
|
249
|
+
*/
|
|
250
|
+
getActiveProcesses(): ActiveProcess[] {
|
|
251
|
+
return Array.from(this.activeProcesses.values());
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private killProcess(proc: ChildProcess, key: string): void {
|
|
255
|
+
try {
|
|
256
|
+
// Try graceful termination first
|
|
257
|
+
proc.kill('SIGTERM');
|
|
258
|
+
|
|
259
|
+
// Force kill after 5 seconds
|
|
260
|
+
setTimeout(() => {
|
|
261
|
+
if (this.activeProcesses.has(key)) {
|
|
262
|
+
proc.kill('SIGKILL');
|
|
263
|
+
}
|
|
264
|
+
}, 5000);
|
|
265
|
+
} catch {
|
|
266
|
+
// Process may already be dead
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private emitLog(entry: LogEntry): void {
|
|
271
|
+
if (this.onLog) {
|
|
272
|
+
this.onLog(entry);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Evaluate a condition expression
|
|
279
|
+
* Supports simple expressions like "success()", "failure()", "always()"
|
|
280
|
+
* and basic comparisons
|
|
281
|
+
*/
|
|
282
|
+
export function evaluateCondition(
|
|
283
|
+
condition: string | undefined,
|
|
284
|
+
context: { previousSteps: { succeeded: boolean }[] }
|
|
285
|
+
): boolean {
|
|
286
|
+
if (!condition) return true;
|
|
287
|
+
|
|
288
|
+
const expr = condition.trim().toLowerCase();
|
|
289
|
+
|
|
290
|
+
// Built-in functions
|
|
291
|
+
if (expr === 'always()') return true;
|
|
292
|
+
if (expr === 'success()') return context.previousSteps.every(s => s.succeeded);
|
|
293
|
+
if (expr === 'failure()') return context.previousSteps.some(s => !s.succeeded);
|
|
294
|
+
|
|
295
|
+
// Basic comparison (env vars, etc.) - could be extended
|
|
296
|
+
// For now, just return true for unknown expressions
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Create a sandbox instance with log streaming
|
|
302
|
+
*/
|
|
303
|
+
export function createSandbox(onLog?: (entry: LogEntry) => void): ProcessSandbox {
|
|
304
|
+
return new ProcessSandbox({ onLog });
|
|
305
|
+
}
|