agents-relay 1.0.89 → 1.0.91
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/dist/cli-help.js +192 -0
- package/dist/cli-support.js +103 -0
- package/dist/cli.js +6 -293
- package/dist/dashboard-html.js +292 -0
- package/dist/dashboard.js +2 -292
- package/dist/pool.js +4 -1
- package/dist/reconciler-review.js +158 -0
- package/dist/reconciler-runtime.js +404 -0
- package/dist/reconciler.js +65 -534
- package/dist/scheduler.js +11 -33
- package/dist/store-github.js +217 -0
- package/dist/store-task-definition.js +19 -0
- package/dist/store.js +3 -235
- package/dist/workflow/contracts.js +84 -0
- package/dist/workflow/definitions.js +19 -5
- package/dist/workflow/effects.js +74 -0
- package/dist/workflow/mermaid.js +6 -2
- package/dist/workflow/rollout.js +70 -56
- package/dist/workflow/runtime.js +39 -0
- package/dist/workflow/task-reconcile.js +48 -0
- package/package.json +1 -1
- package/workflow/job-lifecycle.mmd +10 -5
- package/workflow/task-lifecycle.mmd +12 -10
|
@@ -1,14 +1,28 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { parseExecutableMermaid, WorkflowEngine } from './mermaid.js';
|
|
3
|
-
|
|
3
|
+
import { WORKFLOW_EFFECT_NAMES, WORKFLOW_EVENT_NAMES, WORKFLOW_MUTATION_NAMES } from './contracts.js';
|
|
4
|
+
function declarativeActions() {
|
|
5
|
+
return Object.fromEntries([...WORKFLOW_MUTATION_NAMES, ...WORKFLOW_EFFECT_NAMES].map(name => [name, { declarative: true }]));
|
|
6
|
+
}
|
|
7
|
+
function load(name, guards = {}) {
|
|
4
8
|
const url = new URL(`../../workflow/${name}.mmd`, import.meta.url);
|
|
5
|
-
|
|
9
|
+
const graph = parseExecutableMermaid(readFileSync(url, 'utf8'));
|
|
10
|
+
const canonicalEvents = new Set(WORKFLOW_EVENT_NAMES);
|
|
11
|
+
for (const transition of graph.transitions) {
|
|
12
|
+
if (!canonicalEvents.has(transition.event)) {
|
|
13
|
+
throw new Error(`Unknown canonical workflow event ${transition.event} referenced at line ${transition.line}`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return new WorkflowEngine(graph, guards, declarativeActions());
|
|
6
17
|
}
|
|
7
18
|
const booleanGuard = (name) => context => context[name] === true;
|
|
8
|
-
const
|
|
9
|
-
|
|
19
|
+
export const jobLifecycleWorkflow = load('job-lifecycle', {
|
|
20
|
+
notMerged: booleanGuard('notMerged'),
|
|
21
|
+
autoMerge: booleanGuard('autoMerge'),
|
|
22
|
+
});
|
|
10
23
|
export const taskLifecycleWorkflow = load('task-lifecycle', {
|
|
11
24
|
dependenciesSatisfied: booleanGuard('dependenciesSatisfied'),
|
|
12
25
|
dependenciesUnsatisfied: booleanGuard('dependenciesUnsatisfied'),
|
|
13
26
|
runnable: booleanGuard('runnable'),
|
|
14
|
-
|
|
27
|
+
dispatchable: booleanGuard('dispatchable'),
|
|
28
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export class MemoryWorkflowEffectReceiptStore {
|
|
2
|
+
completed = new Set();
|
|
3
|
+
async has(key) { return this.completed.has(key); }
|
|
4
|
+
async markCompleted(key) { this.completed.add(key); }
|
|
5
|
+
}
|
|
6
|
+
export class WorkflowEffectHandler {
|
|
7
|
+
name;
|
|
8
|
+
constructor(name) {
|
|
9
|
+
this.name = name;
|
|
10
|
+
}
|
|
11
|
+
idempotencyKey(context) {
|
|
12
|
+
return `${this.name}:${context.jobId}:${context.taskId ?? 'job'}:${context.transitionKey}`;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export class CallbackWorkflowEffectHandler extends WorkflowEffectHandler {
|
|
16
|
+
callback;
|
|
17
|
+
key;
|
|
18
|
+
constructor(name, callback, key) {
|
|
19
|
+
super(name);
|
|
20
|
+
this.callback = callback;
|
|
21
|
+
this.key = key;
|
|
22
|
+
}
|
|
23
|
+
idempotencyKey(context) {
|
|
24
|
+
return this.key?.(context) ?? super.idempotencyKey(context);
|
|
25
|
+
}
|
|
26
|
+
async execute(context) { await this.callback(context); }
|
|
27
|
+
}
|
|
28
|
+
export class WorkflowEffectExecutor {
|
|
29
|
+
receipts;
|
|
30
|
+
handlers = new Map();
|
|
31
|
+
constructor(handlers = [], receipts = new MemoryWorkflowEffectReceiptStore()) {
|
|
32
|
+
this.receipts = receipts;
|
|
33
|
+
for (const handler of handlers)
|
|
34
|
+
this.register(handler);
|
|
35
|
+
}
|
|
36
|
+
register(handler) {
|
|
37
|
+
if (this.handlers.has(handler.name))
|
|
38
|
+
throw new Error(`Workflow effect handler already registered: ${handler.name}`);
|
|
39
|
+
this.handlers.set(handler.name, handler);
|
|
40
|
+
}
|
|
41
|
+
async execute(effects, context, onOutcome) {
|
|
42
|
+
const outcomes = [];
|
|
43
|
+
for (const effect of effects) {
|
|
44
|
+
const handler = this.handlers.get(effect);
|
|
45
|
+
if (!handler)
|
|
46
|
+
throw new Error(`No workflow effect handler registered for ${effect}`);
|
|
47
|
+
const key = handler.idempotencyKey(context);
|
|
48
|
+
if (await this.receipts.has(key)) {
|
|
49
|
+
const outcome = { effect, key, status: 'deduplicated' };
|
|
50
|
+
outcomes.push(outcome);
|
|
51
|
+
await onOutcome?.(outcome);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
await handler.execute(context);
|
|
56
|
+
await this.receipts.markCompleted(key);
|
|
57
|
+
const outcome = { effect, key, status: 'completed' };
|
|
58
|
+
outcomes.push(outcome);
|
|
59
|
+
await onOutcome?.(outcome);
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
const outcome = {
|
|
63
|
+
effect,
|
|
64
|
+
key,
|
|
65
|
+
status: 'failed',
|
|
66
|
+
error: error instanceof Error ? error.message : String(error),
|
|
67
|
+
};
|
|
68
|
+
outcomes.push(outcome);
|
|
69
|
+
await onOutcome?.(outcome);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return outcomes;
|
|
73
|
+
}
|
|
74
|
+
}
|
package/dist/workflow/mermaid.js
CHANGED
|
@@ -144,7 +144,10 @@ export class WorkflowEngine {
|
|
|
144
144
|
throw new Error(`Ambiguous workflow transition from ${state} on ${event}`);
|
|
145
145
|
const transition = enabled[0];
|
|
146
146
|
for (const actionName of transition.actions) {
|
|
147
|
-
const
|
|
147
|
+
const action = this.actions[actionName];
|
|
148
|
+
if (typeof action !== 'function')
|
|
149
|
+
continue;
|
|
150
|
+
const value = action(context, transition);
|
|
148
151
|
if (value instanceof Promise)
|
|
149
152
|
throw new Error(`Workflow action ${actionName} is asynchronous and cannot be used in a synchronous transition`);
|
|
150
153
|
}
|
|
@@ -173,7 +176,8 @@ export class WorkflowEngine {
|
|
|
173
176
|
const transition = enabled[0];
|
|
174
177
|
for (const actionName of transition.actions) {
|
|
175
178
|
const action = this.actions[actionName];
|
|
176
|
-
|
|
179
|
+
if (typeof action === 'function')
|
|
180
|
+
await action(context, transition);
|
|
177
181
|
}
|
|
178
182
|
return transition.to;
|
|
179
183
|
}
|
package/dist/workflow/rollout.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { jobLifecycleWorkflow, taskLifecycleWorkflow } from './definitions.js';
|
|
2
|
+
import { classifyWorkflowActions, emptyWorkflowDecision, } from './contracts.js';
|
|
3
|
+
import { graphWorkflowDecision } from './runtime.js';
|
|
2
4
|
let modeOverride = null;
|
|
3
5
|
let reporter = null;
|
|
4
6
|
let matches = 0;
|
|
@@ -13,108 +15,116 @@ export function parseWorkflowRolloutMode(value) {
|
|
|
13
15
|
export function workflowRolloutMode() {
|
|
14
16
|
return modeOverride ?? parseWorkflowRolloutMode(process.env.AGENTS_RELAY_WORKFLOW_MODE);
|
|
15
17
|
}
|
|
16
|
-
export function setWorkflowRolloutMode(mode) {
|
|
17
|
-
|
|
18
|
-
}
|
|
19
|
-
export function setWorkflowShadowReporter(value) {
|
|
20
|
-
reporter = value;
|
|
21
|
-
}
|
|
22
|
-
export function resetWorkflowRolloutDiagnostics() {
|
|
23
|
-
matches = 0;
|
|
24
|
-
mismatches = 0;
|
|
25
|
-
}
|
|
18
|
+
export function setWorkflowRolloutMode(mode) { modeOverride = mode; }
|
|
19
|
+
export function setWorkflowShadowReporter(value) { reporter = value; }
|
|
20
|
+
export function resetWorkflowRolloutDiagnostics() { matches = 0; mismatches = 0; }
|
|
26
21
|
export function workflowRolloutSnapshot() {
|
|
27
22
|
return { mode: workflowRolloutMode(), matches, mismatches };
|
|
28
23
|
}
|
|
29
|
-
function
|
|
30
|
-
const
|
|
31
|
-
if (enabled.length === 0)
|
|
32
|
-
return { accepted: false, target: null, guard: null, actions: [], reason: 'no_transition' };
|
|
33
|
-
if (enabled.length > 1)
|
|
34
|
-
return { accepted: false, target: null, guard: null, actions: [], reason: 'ambiguous' };
|
|
35
|
-
const transition = enabled[0];
|
|
24
|
+
function accepted(target, guard = null, actions = []) {
|
|
25
|
+
const actionList = [...actions];
|
|
36
26
|
return {
|
|
37
27
|
accepted: true,
|
|
38
|
-
target
|
|
39
|
-
guard
|
|
40
|
-
actions:
|
|
28
|
+
target,
|
|
29
|
+
guard,
|
|
30
|
+
actions: actionList,
|
|
31
|
+
...classifyWorkflowActions(actionList),
|
|
41
32
|
reason: 'accepted',
|
|
42
33
|
};
|
|
43
34
|
}
|
|
35
|
+
const CLEAR = ['mutation.clearError', 'mutation.clearFailureReason'];
|
|
44
36
|
const taskLegacy = {
|
|
45
37
|
DRAFT: {
|
|
46
|
-
release: { target: 'QUEUED' },
|
|
38
|
+
release: { target: 'QUEUED', actions: CLEAR },
|
|
47
39
|
block: { target: 'BLOCKED' },
|
|
48
40
|
cancel: { target: 'CANCELLED' },
|
|
49
41
|
},
|
|
50
42
|
QUEUED: {
|
|
51
43
|
hold: { target: 'DRAFT' },
|
|
52
|
-
'task.started': { target: 'RUNNING' },
|
|
44
|
+
'task.started': { target: 'RUNNING', actions: CLEAR },
|
|
53
45
|
block: { target: 'BLOCKED' },
|
|
54
46
|
fail: { target: 'FAILED' },
|
|
47
|
+
'effect.launchTask.failed': { target: 'FAILED' },
|
|
55
48
|
cancel: { target: 'CANCELLED' },
|
|
56
49
|
},
|
|
57
50
|
RUNNING: {
|
|
58
51
|
'task.blocked': { target: 'BLOCKED' },
|
|
59
|
-
'task.completed': { target: 'SUCCEEDED' },
|
|
52
|
+
'task.completed': { target: 'SUCCEEDED', actions: CLEAR },
|
|
60
53
|
'task.failed': { target: 'FAILED' },
|
|
61
54
|
'task.cancelled': { target: 'CANCELLED' },
|
|
62
55
|
},
|
|
63
56
|
BLOCKED: {
|
|
64
|
-
retry: { target: 'QUEUED' },
|
|
57
|
+
retry: { target: 'QUEUED', actions: CLEAR },
|
|
65
58
|
cancel: { target: 'CANCELLED' },
|
|
66
59
|
},
|
|
67
60
|
SUCCEEDED: {},
|
|
68
61
|
FAILED: {
|
|
69
|
-
retry: { target: 'QUEUED' },
|
|
70
|
-
'reconcile.started': { target: 'RUNNING' },
|
|
71
|
-
'reconcile.completed': { target: 'SUCCEEDED' },
|
|
62
|
+
retry: { target: 'QUEUED', actions: CLEAR },
|
|
63
|
+
'reconcile.started': { target: 'RUNNING', actions: CLEAR },
|
|
64
|
+
'reconcile.completed': { target: 'SUCCEEDED', actions: CLEAR },
|
|
72
65
|
'reconcile.blocked': { target: 'BLOCKED' },
|
|
73
66
|
cancel: { target: 'CANCELLED' },
|
|
74
67
|
},
|
|
75
68
|
CANCELLED: {},
|
|
76
69
|
};
|
|
70
|
+
const CANCEL_EFFECT = ['effect.cancelActiveTasks'];
|
|
71
|
+
const MERGE_EFFECT = ['effect.mergePullRequest'];
|
|
77
72
|
const jobLegacy = {
|
|
78
73
|
DRAFT: {
|
|
79
74
|
activate: { target: 'ACTIVE' },
|
|
80
|
-
'pr.merged': { target: 'COMPLETED' },
|
|
75
|
+
'pr.merged': { target: 'COMPLETED', actions: CANCEL_EFFECT },
|
|
81
76
|
cancel: { target: 'CANCELLED' },
|
|
82
77
|
},
|
|
83
78
|
ACTIVE: {
|
|
84
|
-
'pr.merged': { target: 'COMPLETED' },
|
|
79
|
+
'pr.merged': { target: 'COMPLETED', actions: CANCEL_EFFECT },
|
|
85
80
|
cancel: { target: 'CANCELLED' },
|
|
86
81
|
},
|
|
87
|
-
COMPLETED: {
|
|
82
|
+
COMPLETED: {
|
|
83
|
+
'effect.cancelActiveTasks.failed': { target: 'COMPLETED' },
|
|
84
|
+
},
|
|
88
85
|
CANCELLED: {
|
|
89
|
-
'pr.merged': { target: 'COMPLETED' },
|
|
86
|
+
'pr.merged': { target: 'COMPLETED', actions: CANCEL_EFFECT },
|
|
87
|
+
'effect.cancelActiveTasks.failed': { target: 'CANCELLED' },
|
|
90
88
|
},
|
|
91
89
|
};
|
|
92
|
-
function taskLegacyDecision(from, event,
|
|
90
|
+
function taskLegacyDecision(from, event, facts) {
|
|
93
91
|
if (event === 'reconcile') {
|
|
94
|
-
if (from === 'DRAFT' &&
|
|
95
|
-
return
|
|
96
|
-
if (from === 'QUEUED' &&
|
|
97
|
-
return
|
|
98
|
-
if (from === 'QUEUED' &&
|
|
99
|
-
return
|
|
100
|
-
return
|
|
92
|
+
if (from === 'DRAFT' && facts.dependenciesSatisfied === true)
|
|
93
|
+
return accepted('QUEUED', 'dependenciesSatisfied', CLEAR);
|
|
94
|
+
if (from === 'QUEUED' && facts.dependenciesUnsatisfied === true)
|
|
95
|
+
return accepted('DRAFT', 'dependenciesUnsatisfied');
|
|
96
|
+
if (from === 'QUEUED' && facts.runnable === true)
|
|
97
|
+
return accepted('QUEUED', 'runnable', CLEAR);
|
|
98
|
+
return emptyWorkflowDecision('no_transition');
|
|
99
|
+
}
|
|
100
|
+
if (event === 'dispatch') {
|
|
101
|
+
if (from === 'QUEUED' && facts.dispatchable === true)
|
|
102
|
+
return accepted('QUEUED', 'dispatchable', ['effect.launchTask']);
|
|
103
|
+
return emptyWorkflowDecision('no_transition');
|
|
101
104
|
}
|
|
102
105
|
const transition = taskLegacy[from][event];
|
|
103
106
|
if (!transition)
|
|
104
|
-
return
|
|
105
|
-
return
|
|
107
|
+
return emptyWorkflowDecision('no_transition');
|
|
108
|
+
return accepted(transition.target, transition.guard ?? null, transition.actions ?? []);
|
|
106
109
|
}
|
|
107
|
-
function jobLegacyDecision(from, event,
|
|
110
|
+
function jobLegacyDecision(from, event, facts) {
|
|
108
111
|
if (event === 'pr.closed') {
|
|
109
|
-
if ((from === 'DRAFT' || from === 'ACTIVE') &&
|
|
110
|
-
return
|
|
112
|
+
if ((from === 'DRAFT' || from === 'ACTIVE') && facts.notMerged === true) {
|
|
113
|
+
return accepted('CANCELLED', 'notMerged', CANCEL_EFFECT);
|
|
111
114
|
}
|
|
112
|
-
return
|
|
115
|
+
return emptyWorkflowDecision('no_transition');
|
|
116
|
+
}
|
|
117
|
+
if (event === 'review.approved' || event === 'implementation.completed') {
|
|
118
|
+
if (from === 'ACTIVE' && facts.autoMerge === true)
|
|
119
|
+
return accepted('ACTIVE', 'autoMerge', MERGE_EFFECT);
|
|
120
|
+
return emptyWorkflowDecision('no_transition');
|
|
113
121
|
}
|
|
122
|
+
if (event === 'effect.mergePullRequest.failed' && from === 'ACTIVE')
|
|
123
|
+
return accepted('ACTIVE');
|
|
114
124
|
const transition = jobLegacy[from][event];
|
|
115
125
|
if (!transition)
|
|
116
|
-
return
|
|
117
|
-
return
|
|
126
|
+
return emptyWorkflowDecision('no_transition');
|
|
127
|
+
return accepted(transition.target, transition.guard ?? null, transition.actions ?? []);
|
|
118
128
|
}
|
|
119
129
|
function sameDecision(a, b) {
|
|
120
130
|
return a.accepted === b.accepted
|
|
@@ -122,7 +132,11 @@ function sameDecision(a, b) {
|
|
|
122
132
|
&& a.guard === b.guard
|
|
123
133
|
&& a.reason === b.reason
|
|
124
134
|
&& a.actions.length === b.actions.length
|
|
125
|
-
&& a.actions.every((value, index) => value === b.actions[index])
|
|
135
|
+
&& a.actions.every((value, index) => value === b.actions[index])
|
|
136
|
+
&& a.mutations.length === b.mutations.length
|
|
137
|
+
&& a.mutations.every((value, index) => value === b.mutations[index])
|
|
138
|
+
&& a.effects.length === b.effects.length
|
|
139
|
+
&& a.effects.every((value, index) => value === b.effects[index]);
|
|
126
140
|
}
|
|
127
141
|
function report(kind, jobId, taskId, event, from, facts, legacy, graph) {
|
|
128
142
|
const match = sameDecision(legacy, graph);
|
|
@@ -145,26 +159,26 @@ function report(kind, jobId, taskId, event, from, facts, legacy, graph) {
|
|
|
145
159
|
if (!match && !reporter)
|
|
146
160
|
console.warn(JSON.stringify(diagnostic));
|
|
147
161
|
}
|
|
148
|
-
export function decideTaskLifecycle(task, event,
|
|
149
|
-
const legacy = taskLegacyDecision(task.state, event,
|
|
162
|
+
export function decideTaskLifecycle(task, event, facts = {}) {
|
|
163
|
+
const legacy = taskLegacyDecision(task.state, event, facts);
|
|
150
164
|
const mode = workflowRolloutMode();
|
|
151
165
|
if (mode === 'legacy')
|
|
152
166
|
return legacy;
|
|
153
|
-
const graph =
|
|
167
|
+
const graph = graphWorkflowDecision(taskLifecycleWorkflow, task.state, event, facts);
|
|
154
168
|
if (mode === 'shadow') {
|
|
155
|
-
report('task', task.jobId ?? 'unknown', task.id, event, task.state,
|
|
169
|
+
report('task', task.jobId ?? 'unknown', task.id, event, task.state, facts, legacy, graph);
|
|
156
170
|
return legacy;
|
|
157
171
|
}
|
|
158
172
|
return graph;
|
|
159
173
|
}
|
|
160
|
-
export function decideJobLifecycle(job, event,
|
|
161
|
-
const legacy = jobLegacyDecision(job.state, event,
|
|
174
|
+
export function decideJobLifecycle(job, event, facts = {}) {
|
|
175
|
+
const legacy = jobLegacyDecision(job.state, event, facts);
|
|
162
176
|
const mode = workflowRolloutMode();
|
|
163
177
|
if (mode === 'legacy')
|
|
164
178
|
return legacy;
|
|
165
|
-
const graph =
|
|
179
|
+
const graph = graphWorkflowDecision(jobLifecycleWorkflow, job.state, event, facts);
|
|
166
180
|
if (mode === 'shadow') {
|
|
167
|
-
report('job', job.id, undefined, event, job.state,
|
|
181
|
+
report('job', job.id, undefined, event, job.state, facts, legacy, graph);
|
|
168
182
|
return legacy;
|
|
169
183
|
}
|
|
170
184
|
return graph;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { classifyWorkflowActions, emptyWorkflowDecision, } from './contracts.js';
|
|
2
|
+
export function graphWorkflowDecision(engine, from, event, facts = {}) {
|
|
3
|
+
const enabled = engine.enabledSync(from, event, facts);
|
|
4
|
+
if (enabled.length === 0)
|
|
5
|
+
return emptyWorkflowDecision('no_transition');
|
|
6
|
+
if (enabled.length > 1)
|
|
7
|
+
return emptyWorkflowDecision('ambiguous');
|
|
8
|
+
const transition = enabled[0];
|
|
9
|
+
const actions = [...transition.actions];
|
|
10
|
+
const classified = classifyWorkflowActions(actions);
|
|
11
|
+
return {
|
|
12
|
+
accepted: true,
|
|
13
|
+
target: transition.to,
|
|
14
|
+
guard: transition.guard,
|
|
15
|
+
actions,
|
|
16
|
+
...classified,
|
|
17
|
+
reason: 'accepted',
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function applyTaskMutation(task, mutation) {
|
|
21
|
+
if (mutation === 'mutation.clearError')
|
|
22
|
+
task.error = null;
|
|
23
|
+
if (mutation === 'mutation.clearFailureReason')
|
|
24
|
+
task.failureReason = undefined;
|
|
25
|
+
}
|
|
26
|
+
export function applyTaskWorkflowDecision(task, decision, timestamp = new Date().toISOString()) {
|
|
27
|
+
if (!decision.accepted || !decision.target)
|
|
28
|
+
throw new Error(`Cannot apply rejected workflow decision to task ${task.id}`);
|
|
29
|
+
task.state = decision.target;
|
|
30
|
+
for (const mutation of decision.mutations)
|
|
31
|
+
applyTaskMutation(task, mutation);
|
|
32
|
+
task.updatedAt = timestamp;
|
|
33
|
+
}
|
|
34
|
+
export function applyJobWorkflowDecision(job, decision, timestamp = new Date().toISOString()) {
|
|
35
|
+
if (!decision.accepted || !decision.target)
|
|
36
|
+
throw new Error(`Cannot apply rejected workflow decision to job ${job.id}`);
|
|
37
|
+
job.state = decision.target;
|
|
38
|
+
job.updatedAt = timestamp;
|
|
39
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { decideTaskLifecycle } from './rollout.js';
|
|
2
|
+
import { applyTaskWorkflowDecision } from './runtime.js';
|
|
3
|
+
export function dependencyReady(task, tasks) {
|
|
4
|
+
return task.dependencies.every(id => tasks.get(id)?.state === 'SUCCEEDED');
|
|
5
|
+
}
|
|
6
|
+
export function detectCycles(tasks) {
|
|
7
|
+
const map = new Map(tasks.map(task => [task.id, task]));
|
|
8
|
+
const visiting = new Set();
|
|
9
|
+
const done = new Set();
|
|
10
|
+
const visit = (id) => {
|
|
11
|
+
if (visiting.has(id))
|
|
12
|
+
throw new Error(`Dependency cycle at ${id}`);
|
|
13
|
+
if (done.has(id))
|
|
14
|
+
return;
|
|
15
|
+
visiting.add(id);
|
|
16
|
+
for (const dependency of map.get(id)?.dependencies ?? [])
|
|
17
|
+
visit(dependency);
|
|
18
|
+
visiting.delete(id);
|
|
19
|
+
done.add(id);
|
|
20
|
+
};
|
|
21
|
+
for (const task of tasks)
|
|
22
|
+
visit(task.id);
|
|
23
|
+
}
|
|
24
|
+
function scheduledCycleTasks(job) {
|
|
25
|
+
const slot = job.schedule?.currentRunSlot ? Date.parse(job.schedule.currentRunSlot) : Number.NaN;
|
|
26
|
+
if (!Number.isFinite(slot))
|
|
27
|
+
return job.tasks;
|
|
28
|
+
return job.tasks.filter(task => Date.parse(task.createdAt) >= slot);
|
|
29
|
+
}
|
|
30
|
+
export function reconcileTaskDependencies(job) {
|
|
31
|
+
detectCycles(job.tasks);
|
|
32
|
+
const scopedTasks = scheduledCycleTasks(job);
|
|
33
|
+
const tasks = new Map(job.tasks.map(task => [task.id, task]));
|
|
34
|
+
for (const task of scopedTasks) {
|
|
35
|
+
const ready = dependencyReady(task, tasks);
|
|
36
|
+
const facts = {
|
|
37
|
+
dependenciesSatisfied: task.dependencies.length > 0 && ready,
|
|
38
|
+
dependenciesUnsatisfied: task.dependencies.length > 0 && !ready,
|
|
39
|
+
runnable: ready,
|
|
40
|
+
};
|
|
41
|
+
const decision = decideTaskLifecycle(task, 'reconcile', facts);
|
|
42
|
+
if (decision.reason === 'ambiguous')
|
|
43
|
+
throw new Error(`Ambiguous automatic task transition from ${task.state}`);
|
|
44
|
+
if (decision.accepted && decision.target)
|
|
45
|
+
applyTaskWorkflowDecision(task, decision);
|
|
46
|
+
}
|
|
47
|
+
return job;
|
|
48
|
+
}
|
package/package.json
CHANGED
|
@@ -2,14 +2,19 @@ stateDiagram-v2
|
|
|
2
2
|
[*] --> DRAFT
|
|
3
3
|
|
|
4
4
|
DRAFT --> ACTIVE: activate
|
|
5
|
-
DRAFT --> COMPLETED: pr.merged
|
|
5
|
+
DRAFT --> COMPLETED: pr.merged / effect.cancelActiveTasks
|
|
6
6
|
DRAFT --> CANCELLED: cancel
|
|
7
|
-
DRAFT --> CANCELLED: pr.closed [notMerged] /
|
|
7
|
+
DRAFT --> CANCELLED: pr.closed [notMerged] / effect.cancelActiveTasks
|
|
8
8
|
|
|
9
|
-
ACTIVE -->
|
|
9
|
+
ACTIVE --> ACTIVE: review.approved [autoMerge] / effect.mergePullRequest
|
|
10
|
+
ACTIVE --> ACTIVE: implementation.completed [autoMerge] / effect.mergePullRequest
|
|
11
|
+
ACTIVE --> ACTIVE: effect.mergePullRequest.failed
|
|
12
|
+
ACTIVE --> COMPLETED: pr.merged / effect.cancelActiveTasks
|
|
10
13
|
ACTIVE --> CANCELLED: cancel
|
|
11
|
-
ACTIVE --> CANCELLED: pr.closed [notMerged] /
|
|
14
|
+
ACTIVE --> CANCELLED: pr.closed [notMerged] / effect.cancelActiveTasks
|
|
12
15
|
|
|
13
|
-
CANCELLED --> COMPLETED: pr.merged
|
|
16
|
+
CANCELLED --> COMPLETED: pr.merged / effect.cancelActiveTasks
|
|
17
|
+
CANCELLED --> CANCELLED: effect.cancelActiveTasks.failed
|
|
18
|
+
COMPLETED --> COMPLETED: effect.cancelActiveTasks.failed
|
|
14
19
|
|
|
15
20
|
COMPLETED --> [*]
|
|
@@ -1,30 +1,32 @@
|
|
|
1
1
|
stateDiagram-v2
|
|
2
2
|
[*] --> DRAFT
|
|
3
3
|
|
|
4
|
-
DRAFT --> QUEUED: release
|
|
5
|
-
DRAFT --> QUEUED: reconcile [dependenciesSatisfied] /
|
|
4
|
+
DRAFT --> QUEUED: release / mutation.clearError,mutation.clearFailureReason
|
|
5
|
+
DRAFT --> QUEUED: reconcile [dependenciesSatisfied] / mutation.clearError,mutation.clearFailureReason
|
|
6
6
|
DRAFT --> BLOCKED: block
|
|
7
7
|
DRAFT --> CANCELLED: cancel
|
|
8
8
|
|
|
9
9
|
QUEUED --> DRAFT: hold
|
|
10
|
-
QUEUED --> DRAFT: reconcile [dependenciesUnsatisfied]
|
|
11
|
-
QUEUED --> QUEUED: reconcile [runnable] /
|
|
12
|
-
QUEUED -->
|
|
10
|
+
QUEUED --> DRAFT: reconcile [dependenciesUnsatisfied]
|
|
11
|
+
QUEUED --> QUEUED: reconcile [runnable] / mutation.clearError,mutation.clearFailureReason
|
|
12
|
+
QUEUED --> QUEUED: dispatch [dispatchable] / effect.launchTask
|
|
13
|
+
QUEUED --> RUNNING: task.started / mutation.clearError,mutation.clearFailureReason
|
|
13
14
|
QUEUED --> BLOCKED: block
|
|
14
15
|
QUEUED --> FAILED: fail
|
|
16
|
+
QUEUED --> FAILED: effect.launchTask.failed
|
|
15
17
|
QUEUED --> CANCELLED: cancel
|
|
16
18
|
|
|
17
19
|
RUNNING --> BLOCKED: task.blocked
|
|
18
|
-
RUNNING --> SUCCEEDED: task.completed
|
|
20
|
+
RUNNING --> SUCCEEDED: task.completed / mutation.clearError,mutation.clearFailureReason
|
|
19
21
|
RUNNING --> FAILED: task.failed
|
|
20
22
|
RUNNING --> CANCELLED: task.cancelled
|
|
21
23
|
|
|
22
|
-
BLOCKED --> QUEUED: retry
|
|
24
|
+
BLOCKED --> QUEUED: retry / mutation.clearError,mutation.clearFailureReason
|
|
23
25
|
BLOCKED --> CANCELLED: cancel
|
|
24
26
|
|
|
25
|
-
FAILED --> QUEUED: retry
|
|
26
|
-
FAILED --> RUNNING: reconcile.started
|
|
27
|
-
FAILED --> SUCCEEDED: reconcile.completed
|
|
27
|
+
FAILED --> QUEUED: retry / mutation.clearError,mutation.clearFailureReason
|
|
28
|
+
FAILED --> RUNNING: reconcile.started / mutation.clearError,mutation.clearFailureReason
|
|
29
|
+
FAILED --> SUCCEEDED: reconcile.completed / mutation.clearError,mutation.clearFailureReason
|
|
28
30
|
FAILED --> BLOCKED: reconcile.blocked
|
|
29
31
|
FAILED --> CANCELLED: cancel
|
|
30
32
|
|