takos-actions-engine 1.0.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.
Files changed (171) hide show
  1. package/coverage/base.css +224 -0
  2. package/coverage/block-navigation.js +87 -0
  3. package/coverage/clover.xml +3477 -0
  4. package/coverage/coverage-final.json +20 -0
  5. package/coverage/favicon.png +0 -0
  6. package/coverage/index.html +176 -0
  7. package/coverage/prettify.css +1 -0
  8. package/coverage/prettify.js +2 -0
  9. package/coverage/sort-arrow-sprite.png +0 -0
  10. package/coverage/sorter.js +210 -0
  11. package/coverage/src/context/base.ts.html +1792 -0
  12. package/coverage/src/context/env.ts.html +1243 -0
  13. package/coverage/src/context/index.html +161 -0
  14. package/coverage/src/context/index.ts.html +229 -0
  15. package/coverage/src/context/secrets.ts.html +1276 -0
  16. package/coverage/src/index.html +131 -0
  17. package/coverage/src/index.ts.html +502 -0
  18. package/coverage/src/parser/expression.ts.html +2854 -0
  19. package/coverage/src/parser/index.html +161 -0
  20. package/coverage/src/parser/index.ts.html +163 -0
  21. package/coverage/src/parser/validator.ts.html +1588 -0
  22. package/coverage/src/parser/workflow.ts.html +616 -0
  23. package/coverage/src/scheduler/dependency.ts.html +1138 -0
  24. package/coverage/src/scheduler/index.html +221 -0
  25. package/coverage/src/scheduler/index.ts.html +214 -0
  26. package/coverage/src/scheduler/job-context.ts.html +265 -0
  27. package/coverage/src/scheduler/job-policy.ts.html +559 -0
  28. package/coverage/src/scheduler/job.ts.html +1816 -0
  29. package/coverage/src/scheduler/listener-registry.ts.html +199 -0
  30. package/coverage/src/scheduler/step.ts.html +2206 -0
  31. package/coverage/src/scheduler/steps-context.ts.html +217 -0
  32. package/coverage/src/types.ts.html +1897 -0
  33. package/coverage/src/utils/index.html +116 -0
  34. package/coverage/src/utils/needs.ts.html +127 -0
  35. package/dist/__tests__/context/env.test.d.ts +2 -0
  36. package/dist/__tests__/context/env.test.d.ts.map +1 -0
  37. package/dist/__tests__/context/env.test.js +28 -0
  38. package/dist/__tests__/context/env.test.js.map +1 -0
  39. package/dist/__tests__/index.test.d.ts +2 -0
  40. package/dist/__tests__/index.test.d.ts.map +1 -0
  41. package/dist/__tests__/index.test.js +50 -0
  42. package/dist/__tests__/index.test.js.map +1 -0
  43. package/dist/__tests__/parser/expression.test.d.ts +2 -0
  44. package/dist/__tests__/parser/expression.test.d.ts.map +1 -0
  45. package/dist/__tests__/parser/expression.test.js +116 -0
  46. package/dist/__tests__/parser/expression.test.js.map +1 -0
  47. package/dist/__tests__/parser/workflow.test.d.ts +2 -0
  48. package/dist/__tests__/parser/workflow.test.d.ts.map +1 -0
  49. package/dist/__tests__/parser/workflow.test.js +134 -0
  50. package/dist/__tests__/parser/workflow.test.js.map +1 -0
  51. package/dist/__tests__/scheduler/dependency.test.d.ts +2 -0
  52. package/dist/__tests__/scheduler/dependency.test.d.ts.map +1 -0
  53. package/dist/__tests__/scheduler/dependency.test.js +41 -0
  54. package/dist/__tests__/scheduler/dependency.test.js.map +1 -0
  55. package/dist/__tests__/scheduler/job-context.test.d.ts +2 -0
  56. package/dist/__tests__/scheduler/job-context.test.d.ts.map +1 -0
  57. package/dist/__tests__/scheduler/job-context.test.js +108 -0
  58. package/dist/__tests__/scheduler/job-context.test.js.map +1 -0
  59. package/dist/__tests__/scheduler/job-policy.test.d.ts +2 -0
  60. package/dist/__tests__/scheduler/job-policy.test.d.ts.map +1 -0
  61. package/dist/__tests__/scheduler/job-policy.test.js +159 -0
  62. package/dist/__tests__/scheduler/job-policy.test.js.map +1 -0
  63. package/dist/__tests__/scheduler/job.test.d.ts +2 -0
  64. package/dist/__tests__/scheduler/job.test.d.ts.map +1 -0
  65. package/dist/__tests__/scheduler/job.test.js +826 -0
  66. package/dist/__tests__/scheduler/job.test.js.map +1 -0
  67. package/dist/__tests__/scheduler/listener-registry.test.d.ts +2 -0
  68. package/dist/__tests__/scheduler/listener-registry.test.d.ts.map +1 -0
  69. package/dist/__tests__/scheduler/listener-registry.test.js +79 -0
  70. package/dist/__tests__/scheduler/listener-registry.test.js.map +1 -0
  71. package/dist/__tests__/scheduler/step.test.d.ts +2 -0
  72. package/dist/__tests__/scheduler/step.test.d.ts.map +1 -0
  73. package/dist/__tests__/scheduler/step.test.js +209 -0
  74. package/dist/__tests__/scheduler/step.test.js.map +1 -0
  75. package/dist/__tests__/scheduler/steps-context.test.d.ts +2 -0
  76. package/dist/__tests__/scheduler/steps-context.test.d.ts.map +1 -0
  77. package/dist/__tests__/scheduler/steps-context.test.js +43 -0
  78. package/dist/__tests__/scheduler/steps-context.test.js.map +1 -0
  79. package/dist/constants.d.ts +47 -0
  80. package/dist/constants.d.ts.map +1 -0
  81. package/dist/constants.js +53 -0
  82. package/dist/constants.js.map +1 -0
  83. package/dist/context.d.ts +37 -0
  84. package/dist/context.d.ts.map +1 -0
  85. package/dist/context.js +105 -0
  86. package/dist/context.js.map +1 -0
  87. package/dist/index.d.ts +9 -0
  88. package/dist/index.d.ts.map +1 -0
  89. package/dist/index.js +10 -0
  90. package/dist/index.js.map +1 -0
  91. package/dist/parser/evaluator-builtins.d.ts +14 -0
  92. package/dist/parser/evaluator-builtins.d.ts.map +1 -0
  93. package/dist/parser/evaluator-builtins.js +258 -0
  94. package/dist/parser/evaluator-builtins.js.map +1 -0
  95. package/dist/parser/evaluator.d.ts +38 -0
  96. package/dist/parser/evaluator.d.ts.map +1 -0
  97. package/dist/parser/evaluator.js +257 -0
  98. package/dist/parser/evaluator.js.map +1 -0
  99. package/dist/parser/expression.d.ts +20 -0
  100. package/dist/parser/expression.d.ts.map +1 -0
  101. package/dist/parser/expression.js +128 -0
  102. package/dist/parser/expression.js.map +1 -0
  103. package/dist/parser/tokenizer.d.ts +26 -0
  104. package/dist/parser/tokenizer.d.ts.map +1 -0
  105. package/dist/parser/tokenizer.js +162 -0
  106. package/dist/parser/tokenizer.js.map +1 -0
  107. package/dist/parser/validator.d.ts +13 -0
  108. package/dist/parser/validator.d.ts.map +1 -0
  109. package/dist/parser/validator.js +383 -0
  110. package/dist/parser/validator.js.map +1 -0
  111. package/dist/parser/workflow.d.ts +30 -0
  112. package/dist/parser/workflow.d.ts.map +1 -0
  113. package/dist/parser/workflow.js +152 -0
  114. package/dist/parser/workflow.js.map +1 -0
  115. package/dist/scheduler/dependency.d.ts +37 -0
  116. package/dist/scheduler/dependency.d.ts.map +1 -0
  117. package/dist/scheduler/dependency.js +133 -0
  118. package/dist/scheduler/dependency.js.map +1 -0
  119. package/dist/scheduler/job-policy.d.ts +23 -0
  120. package/dist/scheduler/job-policy.d.ts.map +1 -0
  121. package/dist/scheduler/job-policy.js +117 -0
  122. package/dist/scheduler/job-policy.js.map +1 -0
  123. package/dist/scheduler/job.d.ts +151 -0
  124. package/dist/scheduler/job.d.ts.map +1 -0
  125. package/dist/scheduler/job.js +348 -0
  126. package/dist/scheduler/job.js.map +1 -0
  127. package/dist/scheduler/step-output-parser.d.ts +14 -0
  128. package/dist/scheduler/step-output-parser.d.ts.map +1 -0
  129. package/dist/scheduler/step-output-parser.js +70 -0
  130. package/dist/scheduler/step-output-parser.js.map +1 -0
  131. package/dist/scheduler/step.d.ts +74 -0
  132. package/dist/scheduler/step.d.ts.map +1 -0
  133. package/dist/scheduler/step.js +387 -0
  134. package/dist/scheduler/step.js.map +1 -0
  135. package/dist/types.d.ts +499 -0
  136. package/dist/types.d.ts.map +1 -0
  137. package/dist/types.js +5 -0
  138. package/dist/types.js.map +1 -0
  139. package/dist/workflow-models.d.ts +504 -0
  140. package/dist/workflow-models.d.ts.map +1 -0
  141. package/dist/workflow-models.js +5 -0
  142. package/dist/workflow-models.js.map +1 -0
  143. package/package.json +29 -0
  144. package/src/__tests__/context/env.test.ts +38 -0
  145. package/src/__tests__/index.test.ts +55 -0
  146. package/src/__tests__/parser/expression.test.ts +151 -0
  147. package/src/__tests__/parser/workflow.test.ts +151 -0
  148. package/src/__tests__/scheduler/dependency.test.ts +51 -0
  149. package/src/__tests__/scheduler/job-context.test.ts +119 -0
  150. package/src/__tests__/scheduler/job-policy.test.ts +195 -0
  151. package/src/__tests__/scheduler/job.test.ts +1014 -0
  152. package/src/__tests__/scheduler/listener-registry.test.ts +95 -0
  153. package/src/__tests__/scheduler/step.test.ts +258 -0
  154. package/src/__tests__/scheduler/steps-context.test.ts +49 -0
  155. package/src/constants.ts +61 -0
  156. package/src/context.ts +153 -0
  157. package/src/index.ts +64 -0
  158. package/src/parser/evaluator-builtins.ts +315 -0
  159. package/src/parser/evaluator.ts +333 -0
  160. package/src/parser/expression.ts +154 -0
  161. package/src/parser/tokenizer.ts +191 -0
  162. package/src/parser/validator.ts +444 -0
  163. package/src/parser/workflow.ts +176 -0
  164. package/src/scheduler/dependency.ts +180 -0
  165. package/src/scheduler/job-policy.ts +198 -0
  166. package/src/scheduler/job.ts +523 -0
  167. package/src/scheduler/step-output-parser.ts +94 -0
  168. package/src/scheduler/step.ts +543 -0
  169. package/src/workflow-models.ts +593 -0
  170. package/tsconfig.json +14 -0
  171. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,523 @@
1
+ /**
2
+ * Job scheduler and execution management
3
+ */
4
+ import type {
5
+ Workflow,
6
+ Job,
7
+ JobResult,
8
+ ExecutionPlan,
9
+ ExecutionContext,
10
+ Conclusion,
11
+ } from '../workflow-models.js';
12
+ import { evaluateCondition } from '../parser/expression.js';
13
+ import {
14
+ buildDependencyGraph,
15
+ groupIntoPhases,
16
+ type DependencyGraph,
17
+ } from './dependency.js';
18
+ import { StepRunner, type StepRunnerOptions } from './step.js';
19
+ import {
20
+ buildNeedsContext,
21
+ buildJobExecutionContext,
22
+ buildStepsContext,
23
+ createCompletedJobResult,
24
+ createInProgressJobResult,
25
+ classifyStepControl,
26
+ finalizeJobResult,
27
+ getDependencySkipReason,
28
+ type JobExecutionState,
29
+ } from './job-policy.js';
30
+
31
+ // --- normalizeNeedsInput ---
32
+
33
+ export function normalizeNeedsInput(needs: unknown): string[] {
34
+ if (typeof needs === 'string') return [needs];
35
+ if (Array.isArray(needs)) return needs.filter((need): need is string => typeof need === 'string');
36
+ return [];
37
+ }
38
+
39
+ // --- Job scheduler ---
40
+
41
+ /**
42
+ * Job scheduler options
43
+ */
44
+ export interface JobSchedulerOptions {
45
+ /** Maximum parallel jobs (0 = unlimited) */
46
+ maxParallel?: number;
47
+ /** Fail fast - cancel remaining jobs on first failure */
48
+ failFast?: boolean;
49
+ /** Step runner options */
50
+ stepRunner?: StepRunnerOptions;
51
+ }
52
+
53
+ /**
54
+ * Job scheduler event types
55
+ */
56
+ export type JobSchedulerEvent =
57
+ | { type: 'job:start'; jobId: string; job: Job }
58
+ | { type: 'job:complete'; jobId: string; result: JobResult }
59
+ | { type: 'job:skip'; jobId: string; reason: string; result: JobResult }
60
+ | { type: 'phase:start'; phase: number; jobs: string[] }
61
+ | { type: 'phase:complete'; phase: number }
62
+ | { type: 'workflow:start'; phases: string[][] }
63
+ | { type: 'workflow:complete'; results: Record<string, JobResult> };
64
+
65
+ /**
66
+ * Job scheduler event listener
67
+ */
68
+ export type JobSchedulerListener = (event: JobSchedulerEvent) => void;
69
+
70
+ /**
71
+ * Job scheduler for workflow execution
72
+ */
73
+ export class JobScheduler {
74
+ private workflow: Workflow;
75
+ private options: JobSchedulerOptions;
76
+ private graph: DependencyGraph;
77
+ private results: Map<string, JobResult>;
78
+ private listeners: JobSchedulerListener[];
79
+ private cancelled: boolean;
80
+ private running: boolean;
81
+ private stepRunner: StepRunner;
82
+
83
+ constructor(workflow: Workflow, options: JobSchedulerOptions = {}) {
84
+ this.workflow = workflow;
85
+ this.options = {
86
+ maxParallel: options.maxParallel ?? 0,
87
+ failFast: options.failFast ?? true,
88
+ stepRunner: options.stepRunner ?? {},
89
+ };
90
+ this.graph = buildDependencyGraph(workflow);
91
+ this.results = new Map();
92
+ this.listeners = [];
93
+ this.cancelled = false;
94
+ this.running = false;
95
+ this.stepRunner = new StepRunner(this.options.stepRunner);
96
+ }
97
+
98
+ /**
99
+ * Add event listener. Returns an unsubscribe function.
100
+ */
101
+ on(listener: JobSchedulerListener): () => void {
102
+ this.listeners.push(listener);
103
+ return () => {
104
+ const index = this.listeners.indexOf(listener);
105
+ if (index >= 0) {
106
+ this.listeners.splice(index, 1);
107
+ }
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Emit event to all listeners
113
+ */
114
+ private emit(event: JobSchedulerEvent): void {
115
+ const snapshot = [...this.listeners];
116
+ for (const listener of snapshot) {
117
+ try {
118
+ listener(event);
119
+ } catch {
120
+ // Ignore listener errors
121
+ }
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Cancel workflow execution
127
+ */
128
+ cancel(): void {
129
+ this.cancelled = true;
130
+ }
131
+
132
+ /**
133
+ * Reset scheduler runtime state for a new run.
134
+ * Keeps listeners and configuration intact.
135
+ */
136
+ private reset(): void {
137
+ this.results.clear();
138
+ this.cancelled = false;
139
+ }
140
+
141
+ /**
142
+ * Create execution plan
143
+ */
144
+ createPlan(): ExecutionPlan {
145
+ // groupIntoPhases already detects cycles via assertAcyclic
146
+ const phases = groupIntoPhases(this.graph);
147
+
148
+ return { phases };
149
+ }
150
+
151
+ /**
152
+ * Run all jobs in workflow
153
+ */
154
+ async run(context: ExecutionContext): Promise<Record<string, JobResult>> {
155
+ if (this.running) {
156
+ throw new Error('JobScheduler is already running');
157
+ }
158
+
159
+ this.running = true;
160
+ this.reset();
161
+
162
+ try {
163
+ const plan = this.createPlan();
164
+ this.emit({ type: 'workflow:start', phases: plan.phases });
165
+
166
+ for (let phaseIndex = 0; phaseIndex < plan.phases.length; phaseIndex++) {
167
+ if (this.cancelled) break;
168
+
169
+ const phase = plan.phases[phaseIndex];
170
+ this.emit({ type: 'phase:start', phase: phaseIndex, jobs: phase });
171
+
172
+ // Run jobs in phase (potentially in parallel)
173
+ await this.runPhase(phase, context);
174
+
175
+ this.emit({ type: 'phase:complete', phase: phaseIndex });
176
+
177
+ // Check for failures in fail-fast mode
178
+ if (this.options.failFast) {
179
+ const phaseFailed = phase.some(
180
+ (jobId) => this.results.get(jobId)?.conclusion === 'failure'
181
+ );
182
+ if (!phaseFailed) {
183
+ continue;
184
+ }
185
+
186
+ this.cancelled = true;
187
+ for (let i = phaseIndex + 1; i < plan.phases.length; i++) {
188
+ this.markJobsCancelled(plan.phases[i]);
189
+ }
190
+ break;
191
+ }
192
+ }
193
+
194
+ const results = this.getResults();
195
+ this.emit({
196
+ type: 'workflow:complete',
197
+ results: structuredClone(results),
198
+ });
199
+ return results;
200
+ } finally {
201
+ this.running = false;
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Run jobs in a single phase
207
+ */
208
+ private async runPhase(
209
+ jobIds: string[],
210
+ context: ExecutionContext
211
+ ): Promise<void> {
212
+ const maxParallel = this.options.maxParallel || jobIds.length;
213
+ const chunks: string[][] = [];
214
+
215
+ // Split into chunks based on max parallel
216
+ for (let i = 0; i < jobIds.length; i += maxParallel) {
217
+ chunks.push(jobIds.slice(i, i + maxParallel));
218
+ }
219
+
220
+ for (let index = 0; index < chunks.length; index++) {
221
+ if (this.cancelled) {
222
+ this.markPendingChunksCancelled(chunks, index);
223
+ break;
224
+ }
225
+
226
+ const chunk = chunks[index];
227
+
228
+ await Promise.all(chunk.map((jobId) => this.runJob(jobId, context)));
229
+
230
+ if (this.cancelled) {
231
+ this.markPendingChunksCancelled(chunks, index + 1);
232
+ break;
233
+ }
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Mark pending chunks as cancelled from the specified index.
239
+ */
240
+ private markPendingChunksCancelled(
241
+ chunks: string[][],
242
+ startIndex: number
243
+ ): void {
244
+ for (let pending = startIndex; pending < chunks.length; pending++) {
245
+ this.markJobsCancelled(chunks[pending]);
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Mark jobs as cancelled if they don't already have a result.
251
+ */
252
+ private markJobsCancelled(jobIds: string[]): void {
253
+ for (const jobId of jobIds) {
254
+ if (this.results.has(jobId)) {
255
+ continue;
256
+ }
257
+
258
+ this.completeTerminalJob(
259
+ jobId,
260
+ createCompletedJobResult(
261
+ jobId,
262
+ this.workflow.jobs[jobId].name,
263
+ 'cancelled'
264
+ )
265
+ );
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Run a single job
271
+ */
272
+ private async runJob(
273
+ jobId: string,
274
+ context: ExecutionContext
275
+ ): Promise<JobResult> {
276
+ const job = this.workflow.jobs[jobId];
277
+ const existingResult = this.results.get(jobId);
278
+ const cancellationShortCircuitResult =
279
+ this.getCancellationShortCircuitResult(jobId, job.name, existingResult);
280
+
281
+ if (cancellationShortCircuitResult) {
282
+ return cancellationShortCircuitResult;
283
+ }
284
+
285
+ // Build job-specific context with needs
286
+ const jobContext = this.buildJobContext(jobId, context);
287
+
288
+ // Check if job should be skipped
289
+ if (!evaluateCondition(job.if, jobContext)) {
290
+ return this.skipJob(jobId, job.name, 'Condition not met');
291
+ }
292
+
293
+ // Dependencies are success-only: any non-success dependency conclusion skips this job.
294
+ const needs = normalizeNeedsInput(job.needs);
295
+ const dependencySkipReason = getDependencySkipReason(needs, this.results);
296
+ if (dependencySkipReason) {
297
+ return this.skipJob(jobId, job.name, dependencySkipReason);
298
+ }
299
+
300
+ this.emit({ type: 'job:start', jobId, job });
301
+
302
+ const result = createInProgressJobResult(jobId, job.name);
303
+ let executionState: JobExecutionState;
304
+
305
+ try {
306
+ executionState = await this.executeJobSteps(job, jobContext, result);
307
+ } catch {
308
+ executionState = { failed: true, cancelled: false };
309
+ }
310
+
311
+ return this.finalizeAndStoreJobResult(jobId, result, executionState);
312
+ }
313
+
314
+ /**
315
+ * Resolve runJob short-circuit result when cancellation state allows bypassing execution.
316
+ */
317
+ private getCancellationShortCircuitResult(
318
+ jobId: string,
319
+ jobName: JobResult['name'],
320
+ existingResult?: JobResult
321
+ ): JobResult | undefined {
322
+ if (existingResult?.conclusion === 'cancelled') {
323
+ return structuredClone(existingResult);
324
+ }
325
+
326
+ if (!this.cancelled) {
327
+ return undefined;
328
+ }
329
+
330
+ if (existingResult) {
331
+ return structuredClone(existingResult);
332
+ }
333
+
334
+ return this.completeTerminalJob(
335
+ jobId,
336
+ createCompletedJobResult(jobId, jobName, 'cancelled')
337
+ );
338
+ }
339
+
340
+ /**
341
+ * Execute all steps for a job and return the final execution state.
342
+ */
343
+ private async executeJobSteps(
344
+ job: Job,
345
+ jobContext: ExecutionContext,
346
+ result: JobResult
347
+ ): Promise<JobExecutionState> {
348
+ const executionState: JobExecutionState = { failed: false, cancelled: false };
349
+
350
+ for (let i = 0; i < job.steps.length; i++) {
351
+ if (this.cancelled) {
352
+ executionState.cancelled = true;
353
+ break;
354
+ }
355
+
356
+ const step = job.steps[i];
357
+ const stepContext = this.buildStepContext(jobContext, result);
358
+ const stepResult = await this.stepRunner.runStep(step, stepContext, {
359
+ index: i,
360
+ });
361
+ result.steps.push(stepResult);
362
+
363
+ const stepControl = classifyStepControl(
364
+ step,
365
+ stepResult,
366
+ this.options.failFast ?? true
367
+ );
368
+ if (!stepControl.shouldStopJob) {
369
+ continue;
370
+ }
371
+
372
+ if (stepControl.shouldMarkJobFailed) {
373
+ executionState.failed = true;
374
+ }
375
+ if (stepControl.shouldCancelWorkflow) {
376
+ this.cancelled = true;
377
+ }
378
+ break;
379
+ }
380
+
381
+ return executionState;
382
+ }
383
+
384
+ /**
385
+ * Finalize and record a completed job result.
386
+ */
387
+ private finalizeAndStoreJobResult(
388
+ jobId: string,
389
+ result: JobResult,
390
+ executionState: JobExecutionState
391
+ ): JobResult {
392
+ finalizeJobResult(result, executionState);
393
+ return this.completeTerminalJob(jobId, result);
394
+ }
395
+
396
+ /**
397
+ * Create, store, and emit skip result for a job.
398
+ */
399
+ private skipJob(
400
+ jobId: string,
401
+ jobName: JobResult['name'],
402
+ reason: string
403
+ ): JobResult {
404
+ return this.completeTerminalJob(
405
+ jobId,
406
+ createCompletedJobResult(jobId, jobName, 'skipped'),
407
+ { skipReason: reason }
408
+ );
409
+ }
410
+
411
+ /**
412
+ * Store terminal job result and emit terminal job events.
413
+ */
414
+ private completeTerminalJob(
415
+ jobId: string,
416
+ result: JobResult,
417
+ options: { skipReason?: string } = {}
418
+ ): JobResult {
419
+ const storedResult = structuredClone(result);
420
+ this.results.set(jobId, storedResult);
421
+ this.emitTerminalObservationEvents(
422
+ jobId,
423
+ storedResult,
424
+ options.skipReason
425
+ );
426
+ return structuredClone(storedResult);
427
+ }
428
+
429
+ /**
430
+ * Emit terminal observation events for a job.
431
+ */
432
+ private emitTerminalObservationEvents(
433
+ jobId: string,
434
+ storedResult: JobResult,
435
+ skipReason?: string
436
+ ): void {
437
+ if (skipReason !== undefined) {
438
+ this.emit({
439
+ type: 'job:skip',
440
+ jobId,
441
+ reason: skipReason,
442
+ result: structuredClone(storedResult),
443
+ });
444
+ }
445
+
446
+ this.emit({
447
+ type: 'job:complete',
448
+ jobId,
449
+ result: structuredClone(storedResult),
450
+ });
451
+ }
452
+
453
+ /**
454
+ * Build execution context with needs data
455
+ */
456
+ private buildJobContext(
457
+ jobId: string,
458
+ context: ExecutionContext
459
+ ): ExecutionContext {
460
+ const job = this.workflow.jobs[jobId];
461
+ const needs = normalizeNeedsInput(job.needs);
462
+ const needsContext = buildNeedsContext(needs, this.results);
463
+ return buildJobExecutionContext(context, needsContext, [
464
+ context.env,
465
+ this.workflow.env,
466
+ job.env,
467
+ ]);
468
+ }
469
+
470
+ /**
471
+ * Build step context with previous step outputs
472
+ */
473
+ private buildStepContext(
474
+ jobContext: ExecutionContext,
475
+ jobResult: JobResult
476
+ ): ExecutionContext {
477
+ const stepsContext = buildStepsContext(jobResult.steps);
478
+
479
+ return {
480
+ ...jobContext,
481
+ steps: stepsContext,
482
+ };
483
+ }
484
+
485
+ /**
486
+ * Get current results
487
+ */
488
+ getResults(): Record<string, JobResult> {
489
+ return structuredClone(Object.fromEntries(this.results));
490
+ }
491
+
492
+ /**
493
+ * Get overall conclusion
494
+ */
495
+ getConclusion(): Conclusion {
496
+ let hasFailure = false;
497
+ for (const result of this.results.values()) {
498
+ if (result.conclusion === 'failure') {
499
+ hasFailure = true;
500
+ break;
501
+ }
502
+ }
503
+
504
+ if (hasFailure) {
505
+ return 'failure';
506
+ }
507
+
508
+ if (this.cancelled) {
509
+ return 'cancelled';
510
+ }
511
+
512
+ return 'success';
513
+ }
514
+ }
515
+
516
+ /**
517
+ * Create execution plan for workflow
518
+ */
519
+ export function createExecutionPlan(workflow: Workflow): ExecutionPlan {
520
+ const graph = buildDependencyGraph(workflow);
521
+ const phases = groupIntoPhases(graph);
522
+ return { phases };
523
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Output parsing functions for step execution
3
+ */
4
+
5
+ const SIMPLE_OUTPUT_NAME_REGEX = /^[A-Za-z_][A-Za-z0-9_]*$/;
6
+
7
+ /**
8
+ * Parse GitHub Actions output format from stdout
9
+ * Format: ::set-output name=<name>::<value>
10
+ * Or: echo "name=value" >> $GITHUB_OUTPUT
11
+ */
12
+ export function parseOutputs(stdout: string): Record<string, string> {
13
+ const outputs: Record<string, string> = {};
14
+
15
+ iterateNormalizedLines(stdout, (line) => {
16
+ parseLegacyOutputLine(line, outputs);
17
+ parseSimpleOutputLine(line, outputs);
18
+ });
19
+
20
+ return outputs;
21
+ }
22
+
23
+ export function iterateNormalizedLines(
24
+ content: string,
25
+ iterate: (line: string) => void
26
+ ): void {
27
+ if (content.length === 0) {
28
+ return;
29
+ }
30
+
31
+ const lines = content.split('\n');
32
+ for (let line of lines) {
33
+ if (line.endsWith('\r')) {
34
+ line = line.slice(0, -1);
35
+ }
36
+ iterate(line);
37
+ }
38
+ }
39
+
40
+ export function parseLegacyOutputLine(
41
+ line: string,
42
+ outputs: Record<string, string>
43
+ ): void {
44
+ const prefix = '::set-output name=';
45
+ if (!line.startsWith(prefix)) {
46
+ return;
47
+ }
48
+
49
+ const separatorIndex = line.indexOf('::', prefix.length);
50
+ if (separatorIndex === -1) {
51
+ return;
52
+ }
53
+
54
+ const name = line.slice(prefix.length, separatorIndex);
55
+ if (name.length === 0 || name.includes(':')) {
56
+ return;
57
+ }
58
+
59
+ const value = line.slice(separatorIndex + 2);
60
+ outputs[name] = value;
61
+ }
62
+
63
+ export function parseSimpleOutputLine(
64
+ line: string,
65
+ outputs: Record<string, string>
66
+ ): void {
67
+ const separatorIndex = line.indexOf('=');
68
+ if (separatorIndex <= 0) {
69
+ return;
70
+ }
71
+
72
+ const name = line.slice(0, separatorIndex);
73
+ if (!SIMPLE_OUTPUT_NAME_REGEX.test(name)) {
74
+ return;
75
+ }
76
+
77
+ const value = line.slice(separatorIndex + 1);
78
+ if (!(name in outputs)) {
79
+ outputs[name] = value;
80
+ }
81
+ }
82
+
83
+ export function parsePathFile(content: string): string[] {
84
+ const entries: string[] = [];
85
+
86
+ iterateNormalizedLines(content, (line) => {
87
+ if (line.trim().length === 0) {
88
+ return;
89
+ }
90
+ entries.push(line);
91
+ });
92
+
93
+ return entries;
94
+ }