@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/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
+ }