@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.
@@ -0,0 +1,287 @@
1
+ /**
2
+ * Job Scheduler with dependency resolution
3
+ *
4
+ * Handles job ordering, parallel execution where possible,
5
+ * and dependency graph management.
6
+ */
7
+
8
+ import type { Job, JobRun, JobRunStatus, StepRunStatus } from './types.js';
9
+
10
+ export interface JobNode {
11
+ id: string;
12
+ job: Job;
13
+ dependencies: string[];
14
+ dependents: string[];
15
+ }
16
+
17
+ export interface ExecutionPlan {
18
+ /** Jobs that can run in parallel, grouped by level */
19
+ levels: string[][];
20
+ /** Total number of jobs */
21
+ total: number;
22
+ /** Whether the graph has cycles */
23
+ hasCycles: boolean;
24
+ }
25
+
26
+ /**
27
+ * Build a dependency graph from jobs
28
+ */
29
+ export function buildDependencyGraph(jobs: Record<string, Job>): Map<string, JobNode> {
30
+ const graph = new Map<string, JobNode>();
31
+
32
+ // Create nodes
33
+ for (const [id, job] of Object.entries(jobs)) {
34
+ graph.set(id, {
35
+ id,
36
+ job,
37
+ dependencies: job.needs ?? [],
38
+ dependents: [],
39
+ });
40
+ }
41
+
42
+ // Build reverse dependencies (dependents)
43
+ for (const [id, node] of graph) {
44
+ for (const depId of node.dependencies) {
45
+ const depNode = graph.get(depId);
46
+ if (depNode) {
47
+ depNode.dependents.push(id);
48
+ }
49
+ }
50
+ }
51
+
52
+ return graph;
53
+ }
54
+
55
+ /**
56
+ * Create an execution plan from jobs
57
+ * Groups jobs by dependency level for parallel execution
58
+ */
59
+ export function createExecutionPlan(jobs: Record<string, Job>): ExecutionPlan {
60
+ const graph = buildDependencyGraph(jobs);
61
+ const levels: string[][] = [];
62
+ const visited = new Set<string>();
63
+ const inProgress = new Set<string>();
64
+
65
+ // Detect cycles using DFS
66
+ let hasCycles = false;
67
+ function detectCycles(nodeId: string, path: string[]): boolean {
68
+ if (path.includes(nodeId)) {
69
+ hasCycles = true;
70
+ return true;
71
+ }
72
+ if (visited.has(nodeId)) return false;
73
+
74
+ const node = graph.get(nodeId);
75
+ if (!node) return false;
76
+
77
+ path.push(nodeId);
78
+ for (const depId of node.dependencies) {
79
+ if (detectCycles(depId, [...path])) {
80
+ return true;
81
+ }
82
+ }
83
+ visited.add(nodeId);
84
+ return false;
85
+ }
86
+
87
+ // Check for cycles
88
+ for (const nodeId of graph.keys()) {
89
+ if (!visited.has(nodeId)) {
90
+ detectCycles(nodeId, []);
91
+ }
92
+ }
93
+
94
+ // Reset visited for level calculation
95
+ visited.clear();
96
+
97
+ // Calculate levels using topological sort
98
+ function getLevel(nodeId: string): number {
99
+ if (visited.has(nodeId)) {
100
+ return levels.findIndex(level => level.includes(nodeId));
101
+ }
102
+
103
+ const node = graph.get(nodeId);
104
+ if (!node) return 0;
105
+
106
+ // If no dependencies, level 0
107
+ if (node.dependencies.length === 0) {
108
+ return 0;
109
+ }
110
+
111
+ // Level = max(dependency levels) + 1
112
+ let maxDepLevel = -1;
113
+ for (const depId of node.dependencies) {
114
+ const depLevel = getLevel(depId);
115
+ maxDepLevel = Math.max(maxDepLevel, depLevel);
116
+ }
117
+
118
+ return maxDepLevel + 1;
119
+ }
120
+
121
+ // Group nodes by level
122
+ for (const nodeId of graph.keys()) {
123
+ const level = getLevel(nodeId);
124
+
125
+ while (levels.length <= level) {
126
+ levels.push([]);
127
+ }
128
+
129
+ levels[level].push(nodeId);
130
+ visited.add(nodeId);
131
+ }
132
+
133
+ return {
134
+ levels,
135
+ total: graph.size,
136
+ hasCycles,
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Get jobs that are ready to run (all dependencies satisfied)
142
+ */
143
+ export function getReadyJobs(
144
+ graph: Map<string, JobNode>,
145
+ completed: Set<string>,
146
+ running: Set<string>,
147
+ failed: Set<string>
148
+ ): string[] {
149
+ const ready: string[] = [];
150
+
151
+ for (const [id, node] of graph) {
152
+ // Skip if already completed, running, or failed
153
+ if (completed.has(id) || running.has(id) || failed.has(id)) {
154
+ continue;
155
+ }
156
+
157
+ // Check if all dependencies are satisfied
158
+ const depsSatisfied = node.dependencies.every(depId => completed.has(depId));
159
+ const depsFailed = node.dependencies.some(depId => failed.has(depId));
160
+
161
+ // If any dependency failed, this job should be skipped
162
+ if (depsFailed) {
163
+ // Mark as failed (will be skipped)
164
+ continue;
165
+ }
166
+
167
+ if (depsSatisfied) {
168
+ ready.push(id);
169
+ }
170
+ }
171
+
172
+ return ready;
173
+ }
174
+
175
+ /**
176
+ * Job Scheduler class
177
+ */
178
+ export class JobScheduler {
179
+ private graph: Map<string, JobNode>;
180
+ private completed = new Set<string>();
181
+ private running = new Set<string>();
182
+ private failed = new Set<string>();
183
+ private skipped = new Set<string>();
184
+
185
+ constructor(jobs: Record<string, Job>) {
186
+ this.graph = buildDependencyGraph(jobs);
187
+ }
188
+
189
+ /**
190
+ * Get the execution plan
191
+ */
192
+ getPlan(): ExecutionPlan {
193
+ const jobs: Record<string, Job> = {};
194
+ for (const [id, node] of this.graph) {
195
+ jobs[id] = node.job;
196
+ }
197
+ return createExecutionPlan(jobs);
198
+ }
199
+
200
+ /**
201
+ * Get jobs ready to run
202
+ */
203
+ getReadyJobs(): string[] {
204
+ return getReadyJobs(this.graph, this.completed, this.running, this.failed);
205
+ }
206
+
207
+ /**
208
+ * Mark a job as started
209
+ */
210
+ startJob(jobId: string): void {
211
+ this.running.add(jobId);
212
+ }
213
+
214
+ /**
215
+ * Mark a job as completed
216
+ */
217
+ completeJob(jobId: string, success: boolean): void {
218
+ this.running.delete(jobId);
219
+
220
+ if (success) {
221
+ this.completed.add(jobId);
222
+ } else {
223
+ this.failed.add(jobId);
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Skip a job (due to failed dependencies)
229
+ */
230
+ skipJob(jobId: string): void {
231
+ this.skipped.add(jobId);
232
+ }
233
+
234
+ /**
235
+ * Check if all jobs are done
236
+ */
237
+ isComplete(): boolean {
238
+ const total = this.graph.size;
239
+ const done = this.completed.size + this.failed.size + this.skipped.size;
240
+ return done === total;
241
+ }
242
+
243
+ /**
244
+ * Get status summary
245
+ */
246
+ getStatus(): {
247
+ total: number;
248
+ completed: number;
249
+ running: number;
250
+ failed: number;
251
+ skipped: number;
252
+ pending: number;
253
+ } {
254
+ const total = this.graph.size;
255
+ const pending = total - this.completed.size - this.running.size - this.failed.size - this.skipped.size;
256
+
257
+ return {
258
+ total,
259
+ completed: this.completed.size,
260
+ running: this.running.size,
261
+ failed: this.failed.size,
262
+ skipped: this.skipped.size,
263
+ pending,
264
+ };
265
+ }
266
+
267
+ /**
268
+ * Get job by ID
269
+ */
270
+ getJob(jobId: string): Job | undefined {
271
+ return this.graph.get(jobId)?.job;
272
+ }
273
+
274
+ /**
275
+ * Get all job IDs
276
+ */
277
+ getJobIds(): string[] {
278
+ return Array.from(this.graph.keys());
279
+ }
280
+
281
+ /**
282
+ * Check for cycles in the dependency graph
283
+ */
284
+ hasCycles(): boolean {
285
+ return this.getPlan().hasCycles;
286
+ }
287
+ }
package/src/types.ts ADDED
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Core types for CI/CD Workflow System
3
+ */
4
+
5
+ import { z } from 'zod';
6
+
7
+ // ============================================================================
8
+ // Trigger Types
9
+ // ============================================================================
10
+
11
+ export const TriggerSchema = z.discriminatedUnion('type', [
12
+ z.object({ type: z.literal('manual') }),
13
+ z.object({ type: z.literal('schedule'), cron: z.string() }),
14
+ z.object({ type: z.literal('webhook'), path: z.string() }),
15
+ z.object({ type: z.literal('git'), events: z.array(z.enum(['push', 'pr', 'merge'])) }),
16
+ ]);
17
+
18
+ export type Trigger = z.infer<typeof TriggerSchema>;
19
+
20
+ // ============================================================================
21
+ // Step Types
22
+ // ============================================================================
23
+
24
+ export const StepSchema = z.object({
25
+ id: z.string().optional(),
26
+ name: z.string().optional(),
27
+ run: z.string(),
28
+ cwd: z.string().optional(),
29
+ env: z.record(z.string()).optional(),
30
+ condition: z.string().optional(), // Expression for conditional execution
31
+ retry: z.number().optional(),
32
+ timeout: z.number().optional(),
33
+ });
34
+
35
+ export type Step = z.infer<typeof StepSchema>;
36
+
37
+ // ============================================================================
38
+ // Job Types
39
+ // ============================================================================
40
+
41
+ export const JobSchema = z.object({
42
+ name: z.string(),
43
+ runsOn: z.enum(['bun', 'tsx', 'rust', 'bash']),
44
+ steps: z.array(StepSchema),
45
+ needs: z.array(z.string()).optional(), // Job dependencies
46
+ env: z.record(z.string()).optional(),
47
+ timeout: z.number().optional(),
48
+ });
49
+
50
+ export type Job = z.infer<typeof JobSchema>;
51
+
52
+ // ============================================================================
53
+ // Workflow Types
54
+ // ============================================================================
55
+
56
+ export const WorkflowSchema = z.object({
57
+ id: z.string(),
58
+ name: z.string(),
59
+ description: z.string().optional(),
60
+ triggers: z.array(TriggerSchema),
61
+ jobs: z.record(z.string(), JobSchema),
62
+ env: z.record(z.string()).optional(),
63
+ });
64
+
65
+ export type Workflow = z.infer<typeof WorkflowSchema>;
66
+
67
+ // ============================================================================
68
+ // Run Types (Execution State)
69
+ // ============================================================================
70
+
71
+ export const StepRunStatusSchema = z.enum(['pending', 'running', 'success', 'failed', 'skipped']);
72
+ export type StepRunStatus = z.infer<typeof StepRunStatusSchema>;
73
+
74
+ export const JobRunStatusSchema = z.enum(['pending', 'running', 'success', 'failed', 'skipped', 'cancelled']);
75
+ export type JobRunStatus = z.infer<typeof JobRunStatusSchema>;
76
+
77
+ export const WorkflowRunStatusSchema = z.enum(['pending', 'running', 'success', 'failed', 'cancelled']);
78
+ export type WorkflowRunStatus = z.infer<typeof WorkflowRunStatusSchema>;
79
+
80
+ export const StepRunSchema = z.object({
81
+ id: z.string(),
82
+ stepId: z.string(),
83
+ status: StepRunStatusSchema,
84
+ exitCode: z.number().optional(),
85
+ duration: z.number().optional(),
86
+ startedAt: z.date().optional(),
87
+ finishedAt: z.date().optional(),
88
+ });
89
+
90
+ export type StepRun = z.infer<typeof StepRunSchema>;
91
+
92
+ export const LogEntrySchema = z.object({
93
+ timestamp: z.date(),
94
+ jobId: z.string(),
95
+ stepId: z.string(),
96
+ stream: z.enum(['stdout', 'stderr']),
97
+ content: z.string(),
98
+ });
99
+
100
+ export type LogEntry = z.infer<typeof LogEntrySchema>;
101
+
102
+ export const JobRunSchema = z.object({
103
+ id: z.string(),
104
+ jobId: z.string(),
105
+ status: JobRunStatusSchema,
106
+ steps: z.array(StepRunSchema),
107
+ logs: z.array(LogEntrySchema).optional(),
108
+ startedAt: z.date().optional(),
109
+ finishedAt: z.date().optional(),
110
+ });
111
+
112
+ export type JobRun = z.infer<typeof JobRunSchema>;
113
+
114
+ export const WorkflowRunSchema = z.object({
115
+ id: z.string(),
116
+ workflowId: z.string(),
117
+ status: WorkflowRunStatusSchema,
118
+ trigger: TriggerSchema,
119
+ jobs: z.record(z.string(), JobRunSchema),
120
+ startedAt: z.date(),
121
+ finishedAt: z.date().optional(),
122
+ env: z.record(z.string()).optional(),
123
+ });
124
+
125
+ export type WorkflowRun = z.infer<typeof WorkflowRunSchema>;
126
+
127
+ // ============================================================================
128
+ // Execution Context Types
129
+ // ============================================================================
130
+
131
+ export interface StepContext {
132
+ workspace: string;
133
+ env: Record<string, string>;
134
+ jobId: string;
135
+ stepIndex: number;
136
+ runId: string;
137
+ }
138
+
139
+ export interface JobContext {
140
+ workspace: string;
141
+ env: Record<string, string>;
142
+ runId: string;
143
+ jobId: string;
144
+ }
145
+
146
+ export interface StepResult {
147
+ exitCode: number;
148
+ stdout: string;
149
+ stderr: string;
150
+ duration: number;
151
+ succeeded: boolean;
152
+ }
153
+
154
+ // ============================================================================
155
+ // WebSocket Message Types
156
+ // ============================================================================
157
+
158
+ export const ServerMessageSchema = z.discriminatedUnion('type', [
159
+ z.object({ type: z.literal('job_started'), jobId: z.string() }),
160
+ z.object({ type: z.literal('step_started'), jobId: z.string(), stepId: z.string() }),
161
+ z.object({ type: z.literal('log'), jobId: z.string(), stepId: z.string(), content: z.string(), stream: z.enum(['stdout', 'stderr']) }),
162
+ z.object({ type: z.literal('step_finished'), jobId: z.string(), stepId: z.string(), exitCode: z.number(), duration: z.number() }),
163
+ z.object({ type: z.literal('job_finished'), jobId: z.string(), status: z.string() }),
164
+ z.object({ type: z.literal('workflow_finished'), status: z.string() }),
165
+ ]);
166
+
167
+ export type ServerMessage = z.infer<typeof ServerMessageSchema>;
168
+
169
+ export const ClientMessageSchema = z.discriminatedUnion('type', [
170
+ z.object({ type: z.literal('subscribe'), runId: z.string() }),
171
+ z.object({ type: z.literal('cancel') }),
172
+ ]);
173
+
174
+ export type ClientMessage = z.infer<typeof ClientMessageSchema>;
175
+
176
+ // ============================================================================
177
+ // Event Emitter Types
178
+ // ============================================================================
179
+
180
+ export type WorkflowEventType =
181
+ | 'workflow:started'
182
+ | 'workflow:finished'
183
+ | 'job:started'
184
+ | 'job:finished'
185
+ | 'step:started'
186
+ | 'step:log'
187
+ | 'step:finished';
188
+
189
+ export interface WorkflowEvent {
190
+ type: WorkflowEventType;
191
+ runId: string;
192
+ jobId?: string;
193
+ stepId?: string;
194
+ data?: unknown;
195
+ }
196
+
197
+ // ============================================================================
198
+ // API Types
199
+ // ============================================================================
200
+
201
+ export interface CreateWorkflowRequest {
202
+ workflow: Workflow;
203
+ }
204
+
205
+ export interface TriggerWorkflowRequest {
206
+ trigger?: Trigger;
207
+ env?: Record<string, string>;
208
+ }
209
+
210
+ export interface WorkflowListResponse {
211
+ workflows: Workflow[];
212
+ total: number;
213
+ }
214
+
215
+ export interface RunListResponse {
216
+ runs: WorkflowRun[];
217
+ total: number;
218
+ }