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.
- package/coverage/base.css +224 -0
- package/coverage/block-navigation.js +87 -0
- package/coverage/clover.xml +3477 -0
- package/coverage/coverage-final.json +20 -0
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +176 -0
- package/coverage/prettify.css +1 -0
- package/coverage/prettify.js +2 -0
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +210 -0
- package/coverage/src/context/base.ts.html +1792 -0
- package/coverage/src/context/env.ts.html +1243 -0
- package/coverage/src/context/index.html +161 -0
- package/coverage/src/context/index.ts.html +229 -0
- package/coverage/src/context/secrets.ts.html +1276 -0
- package/coverage/src/index.html +131 -0
- package/coverage/src/index.ts.html +502 -0
- package/coverage/src/parser/expression.ts.html +2854 -0
- package/coverage/src/parser/index.html +161 -0
- package/coverage/src/parser/index.ts.html +163 -0
- package/coverage/src/parser/validator.ts.html +1588 -0
- package/coverage/src/parser/workflow.ts.html +616 -0
- package/coverage/src/scheduler/dependency.ts.html +1138 -0
- package/coverage/src/scheduler/index.html +221 -0
- package/coverage/src/scheduler/index.ts.html +214 -0
- package/coverage/src/scheduler/job-context.ts.html +265 -0
- package/coverage/src/scheduler/job-policy.ts.html +559 -0
- package/coverage/src/scheduler/job.ts.html +1816 -0
- package/coverage/src/scheduler/listener-registry.ts.html +199 -0
- package/coverage/src/scheduler/step.ts.html +2206 -0
- package/coverage/src/scheduler/steps-context.ts.html +217 -0
- package/coverage/src/types.ts.html +1897 -0
- package/coverage/src/utils/index.html +116 -0
- package/coverage/src/utils/needs.ts.html +127 -0
- package/dist/__tests__/context/env.test.d.ts +2 -0
- package/dist/__tests__/context/env.test.d.ts.map +1 -0
- package/dist/__tests__/context/env.test.js +28 -0
- package/dist/__tests__/context/env.test.js.map +1 -0
- package/dist/__tests__/index.test.d.ts +2 -0
- package/dist/__tests__/index.test.d.ts.map +1 -0
- package/dist/__tests__/index.test.js +50 -0
- package/dist/__tests__/index.test.js.map +1 -0
- package/dist/__tests__/parser/expression.test.d.ts +2 -0
- package/dist/__tests__/parser/expression.test.d.ts.map +1 -0
- package/dist/__tests__/parser/expression.test.js +116 -0
- package/dist/__tests__/parser/expression.test.js.map +1 -0
- package/dist/__tests__/parser/workflow.test.d.ts +2 -0
- package/dist/__tests__/parser/workflow.test.d.ts.map +1 -0
- package/dist/__tests__/parser/workflow.test.js +134 -0
- package/dist/__tests__/parser/workflow.test.js.map +1 -0
- package/dist/__tests__/scheduler/dependency.test.d.ts +2 -0
- package/dist/__tests__/scheduler/dependency.test.d.ts.map +1 -0
- package/dist/__tests__/scheduler/dependency.test.js +41 -0
- package/dist/__tests__/scheduler/dependency.test.js.map +1 -0
- package/dist/__tests__/scheduler/job-context.test.d.ts +2 -0
- package/dist/__tests__/scheduler/job-context.test.d.ts.map +1 -0
- package/dist/__tests__/scheduler/job-context.test.js +108 -0
- package/dist/__tests__/scheduler/job-context.test.js.map +1 -0
- package/dist/__tests__/scheduler/job-policy.test.d.ts +2 -0
- package/dist/__tests__/scheduler/job-policy.test.d.ts.map +1 -0
- package/dist/__tests__/scheduler/job-policy.test.js +159 -0
- package/dist/__tests__/scheduler/job-policy.test.js.map +1 -0
- package/dist/__tests__/scheduler/job.test.d.ts +2 -0
- package/dist/__tests__/scheduler/job.test.d.ts.map +1 -0
- package/dist/__tests__/scheduler/job.test.js +826 -0
- package/dist/__tests__/scheduler/job.test.js.map +1 -0
- package/dist/__tests__/scheduler/listener-registry.test.d.ts +2 -0
- package/dist/__tests__/scheduler/listener-registry.test.d.ts.map +1 -0
- package/dist/__tests__/scheduler/listener-registry.test.js +79 -0
- package/dist/__tests__/scheduler/listener-registry.test.js.map +1 -0
- package/dist/__tests__/scheduler/step.test.d.ts +2 -0
- package/dist/__tests__/scheduler/step.test.d.ts.map +1 -0
- package/dist/__tests__/scheduler/step.test.js +209 -0
- package/dist/__tests__/scheduler/step.test.js.map +1 -0
- package/dist/__tests__/scheduler/steps-context.test.d.ts +2 -0
- package/dist/__tests__/scheduler/steps-context.test.d.ts.map +1 -0
- package/dist/__tests__/scheduler/steps-context.test.js +43 -0
- package/dist/__tests__/scheduler/steps-context.test.js.map +1 -0
- package/dist/constants.d.ts +47 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +53 -0
- package/dist/constants.js.map +1 -0
- package/dist/context.d.ts +37 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +105 -0
- package/dist/context.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/parser/evaluator-builtins.d.ts +14 -0
- package/dist/parser/evaluator-builtins.d.ts.map +1 -0
- package/dist/parser/evaluator-builtins.js +258 -0
- package/dist/parser/evaluator-builtins.js.map +1 -0
- package/dist/parser/evaluator.d.ts +38 -0
- package/dist/parser/evaluator.d.ts.map +1 -0
- package/dist/parser/evaluator.js +257 -0
- package/dist/parser/evaluator.js.map +1 -0
- package/dist/parser/expression.d.ts +20 -0
- package/dist/parser/expression.d.ts.map +1 -0
- package/dist/parser/expression.js +128 -0
- package/dist/parser/expression.js.map +1 -0
- package/dist/parser/tokenizer.d.ts +26 -0
- package/dist/parser/tokenizer.d.ts.map +1 -0
- package/dist/parser/tokenizer.js +162 -0
- package/dist/parser/tokenizer.js.map +1 -0
- package/dist/parser/validator.d.ts +13 -0
- package/dist/parser/validator.d.ts.map +1 -0
- package/dist/parser/validator.js +383 -0
- package/dist/parser/validator.js.map +1 -0
- package/dist/parser/workflow.d.ts +30 -0
- package/dist/parser/workflow.d.ts.map +1 -0
- package/dist/parser/workflow.js +152 -0
- package/dist/parser/workflow.js.map +1 -0
- package/dist/scheduler/dependency.d.ts +37 -0
- package/dist/scheduler/dependency.d.ts.map +1 -0
- package/dist/scheduler/dependency.js +133 -0
- package/dist/scheduler/dependency.js.map +1 -0
- package/dist/scheduler/job-policy.d.ts +23 -0
- package/dist/scheduler/job-policy.d.ts.map +1 -0
- package/dist/scheduler/job-policy.js +117 -0
- package/dist/scheduler/job-policy.js.map +1 -0
- package/dist/scheduler/job.d.ts +151 -0
- package/dist/scheduler/job.d.ts.map +1 -0
- package/dist/scheduler/job.js +348 -0
- package/dist/scheduler/job.js.map +1 -0
- package/dist/scheduler/step-output-parser.d.ts +14 -0
- package/dist/scheduler/step-output-parser.d.ts.map +1 -0
- package/dist/scheduler/step-output-parser.js +70 -0
- package/dist/scheduler/step-output-parser.js.map +1 -0
- package/dist/scheduler/step.d.ts +74 -0
- package/dist/scheduler/step.d.ts.map +1 -0
- package/dist/scheduler/step.js +387 -0
- package/dist/scheduler/step.js.map +1 -0
- package/dist/types.d.ts +499 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/dist/workflow-models.d.ts +504 -0
- package/dist/workflow-models.d.ts.map +1 -0
- package/dist/workflow-models.js +5 -0
- package/dist/workflow-models.js.map +1 -0
- package/package.json +29 -0
- package/src/__tests__/context/env.test.ts +38 -0
- package/src/__tests__/index.test.ts +55 -0
- package/src/__tests__/parser/expression.test.ts +151 -0
- package/src/__tests__/parser/workflow.test.ts +151 -0
- package/src/__tests__/scheduler/dependency.test.ts +51 -0
- package/src/__tests__/scheduler/job-context.test.ts +119 -0
- package/src/__tests__/scheduler/job-policy.test.ts +195 -0
- package/src/__tests__/scheduler/job.test.ts +1014 -0
- package/src/__tests__/scheduler/listener-registry.test.ts +95 -0
- package/src/__tests__/scheduler/step.test.ts +258 -0
- package/src/__tests__/scheduler/steps-context.test.ts +49 -0
- package/src/constants.ts +61 -0
- package/src/context.ts +153 -0
- package/src/index.ts +64 -0
- package/src/parser/evaluator-builtins.ts +315 -0
- package/src/parser/evaluator.ts +333 -0
- package/src/parser/expression.ts +154 -0
- package/src/parser/tokenizer.ts +191 -0
- package/src/parser/validator.ts +444 -0
- package/src/parser/workflow.ts +176 -0
- package/src/scheduler/dependency.ts +180 -0
- package/src/scheduler/job-policy.ts +198 -0
- package/src/scheduler/job.ts +523 -0
- package/src/scheduler/step-output-parser.ts +94 -0
- package/src/scheduler/step.ts +543 -0
- package/src/workflow-models.ts +593 -0
- package/tsconfig.json +14 -0
- 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
|
+
}
|