@atolis-hq/wake 0.3.19 → 0.3.21
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/src/activities/pr/application.js +5 -1
- package/dist/src/activities/pr/policy.js +10 -0
- package/dist/src/bootstrap/activity-registry.js +32 -0
- package/dist/src/bootstrap/composition-root.js +17 -243
- package/dist/src/bootstrap/index.js +1 -0
- package/dist/src/bootstrap/integration-runtime.js +182 -0
- package/dist/src/bootstrap/persistence-composition.js +11 -0
- package/dist/src/bootstrap/resource-transition-evidence.js +17 -0
- package/dist/src/bootstrap/transcript-retention.js +29 -0
- package/dist/src/bootstrap/version.js +1 -1
- package/dist/src/orchestration/application/advance-workflow.js +12 -22
- package/dist/src/orchestration/application/orchestration-repository.js +19 -4
- package/dist/src/orchestration/application/orchestration-service.js +12 -2
- package/dist/src/orchestration/application/pull-request-transition-evidence.js +70 -0
- package/dist/src/orchestration/application/resource-transition-evidence.js +1 -0
- package/dist/src/orchestration/application/resource-transition-matching.js +83 -0
- package/dist/src/orchestration/application/resource-transition-reactor.js +65 -0
- package/dist/src/orchestration/application/watch-matching.js +27 -0
- package/dist/src/orchestration/contracts/config.js +24 -1
- package/dist/src/orchestration/contracts/event-decoder.js +18 -1
- package/dist/src/orchestration/contracts/events.js +1 -0
- package/dist/src/orchestration/domain/approval-defaults.js +1 -0
- package/dist/src/orchestration/domain/compiler.js +14 -1
- package/dist/src/orchestration/domain/resource-transition-compiler.js +12 -0
- package/dist/src/orchestration/domain/transition.js +18 -9
- package/dist/src/orchestration/domain/workflow-graph.js +5 -1
- package/dist/src/orchestration/domain/workflow-instance-events.js +3 -0
- package/dist/src/orchestration/index.js +4 -0
- package/dist/src/persistence/filesystem/file-lock.js +199 -27
- package/package.json +1 -1
- package/prompts/refine.md +8 -0
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { ActivityEventType, PullRequestCheckState, selectActivityEvent, } from '../../activities/index.js';
|
|
2
1
|
import { EventSourceKind, createEventDraft } from '../../kernel/index.js';
|
|
3
2
|
import { OrchestrationEventType } from '../contracts/events.js';
|
|
4
3
|
import { commandName, workflowInstanceId, } from '../contracts/identifiers.js';
|
|
@@ -6,6 +5,8 @@ import { workflowInstanceStream } from '../contracts/streams.js';
|
|
|
6
5
|
import { WorkflowStatus } from '../contracts/vocabulary.js';
|
|
7
6
|
import { requestChangesResume as decideChangesResume, requestOperatorRetry as decideOperatorRetry, requestSupplementalActivity as decideSupplementalActivity, } from '../domain/interpreter.js';
|
|
8
7
|
import { isAuthorisedActor } from '../domain/supplemental-policy.js';
|
|
8
|
+
import { acceptResourceTransition, matchResourceTransitions, } from './resource-transition-matching.js';
|
|
9
|
+
import { matchWatches } from './watch-matching.js';
|
|
9
10
|
export class OperatorRetryIneligibleError extends Error {
|
|
10
11
|
constructor(detail) {
|
|
11
12
|
super(detail);
|
|
@@ -182,6 +183,15 @@ export class AdvanceWorkflow {
|
|
|
182
183
|
async listAll() {
|
|
183
184
|
return (await this.listAllLoaded()).map(({ view }) => view);
|
|
184
185
|
}
|
|
186
|
+
// Matching and application are generic (see resource-transition-matching.ts):
|
|
187
|
+
// this module holds no resource-kind knowledge.
|
|
188
|
+
async listResourceTransitionMatches(event) {
|
|
189
|
+
return matchResourceTransitions(await this.listAllLoaded(), event);
|
|
190
|
+
}
|
|
191
|
+
applyResourceTransition(id, target, evidenceId, context) {
|
|
192
|
+
const { repository, workflows } = this;
|
|
193
|
+
return acceptResourceTransition(repository, workflows, id, target, evidenceId, context);
|
|
194
|
+
}
|
|
185
195
|
// One shared load per live instance, reused for both the watch match and
|
|
186
196
|
// (when a match needs blocking) the append sequence — avoids reloading
|
|
187
197
|
// every instance a second time just to recover its sequence.
|
|
@@ -189,26 +199,6 @@ export class AdvanceWorkflow {
|
|
|
189
199
|
return (await this.repository.list()).filter((loaded) => loaded.view !== null);
|
|
190
200
|
}
|
|
191
201
|
async listWatchMatches(event, context) {
|
|
192
|
-
|
|
193
|
-
const definition = context === undefined
|
|
194
|
-
? await this.workflows.definitionFor(parent)
|
|
195
|
-
: await this.workflows.definitionForOperation(parent, sequence, context);
|
|
196
|
-
if (definition === null)
|
|
197
|
-
return [];
|
|
198
|
-
return definition.watches
|
|
199
|
-
.filter((watch) => watch.on?.events.includes(event.eventType) === true &&
|
|
200
|
-
watch.while.stages.includes(parent.currentStage) &&
|
|
201
|
-
watch.while.statuses.some((status) => status === parent.status) &&
|
|
202
|
-
matchesWatchPredicate(watch.where, event))
|
|
203
|
-
.map((watch) => ({ parent, watch }));
|
|
204
|
-
}));
|
|
205
|
-
return matches.flat();
|
|
202
|
+
return matchWatches(await this.listAllLoaded(), event, this.workflows, context);
|
|
206
203
|
}
|
|
207
204
|
}
|
|
208
|
-
function matchesWatchPredicate(predicate, event) {
|
|
209
|
-
if (predicate === undefined)
|
|
210
|
-
return true;
|
|
211
|
-
const activityEvent = selectActivityEvent(event);
|
|
212
|
-
return (activityEvent?.eventType === ActivityEventType.PrChecksChanged &&
|
|
213
|
-
activityEvent.payload.checks === PullRequestCheckState.Failing);
|
|
214
|
-
}
|
|
@@ -25,10 +25,25 @@ export class OrchestrationRepository {
|
|
|
25
25
|
return events.map(decodeOrchestrationEvent).filter(isWorkflowEvent);
|
|
26
26
|
}
|
|
27
27
|
async list() {
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
const events = await this.journal.readAll(0);
|
|
29
|
+
const streams = new Map();
|
|
30
|
+
for (const event of events) {
|
|
31
|
+
if (!isWorkflowInstanceStream(event.stream))
|
|
32
|
+
continue;
|
|
33
|
+
const existing = streams.get(event.stream.id);
|
|
34
|
+
if (existing === undefined)
|
|
35
|
+
streams.set(event.stream.id, [event]);
|
|
36
|
+
else
|
|
37
|
+
existing.push(event);
|
|
38
|
+
}
|
|
39
|
+
// `sequence` counts every event on the stream, matching readStream, while the
|
|
40
|
+
// fold sees only owned orchestration events — exactly what load() computes.
|
|
41
|
+
return [...streams.values()].map((streamEvents) => ({
|
|
42
|
+
sequence: streamEvents.length,
|
|
43
|
+
view: foldWorkflowInstance(streamEvents
|
|
44
|
+
.map(selectWorkflowOrchestrationEvent)
|
|
45
|
+
.filter((event) => event !== null && isWorkflowInstanceStream(event.stream))),
|
|
46
|
+
}));
|
|
32
47
|
}
|
|
33
48
|
}
|
|
34
49
|
function isWorkflowEvent(event) {
|
|
@@ -13,12 +13,13 @@ export class OrchestrationService {
|
|
|
13
13
|
acceptWorkflowSignal;
|
|
14
14
|
advanceWorkflow;
|
|
15
15
|
childWorkflows;
|
|
16
|
+
coordinateAcceptSignal = (operation) => operation();
|
|
16
17
|
constructor(journal, work, definitions, projections) {
|
|
17
18
|
const repository = new OrchestrationRepository(journal);
|
|
18
19
|
const claims = new CoordinationClaims(journal);
|
|
19
20
|
this.startWorkflow = new StartWorkflow(repository, claims, work, new WorkflowDefinitionRegistry(journal, projections, definitions));
|
|
20
|
-
this.acceptWorkflowSignal = new AcceptSignal(repository, this.startWorkflow, work);
|
|
21
21
|
this.advanceWorkflow = new AdvanceWorkflow(repository, this.startWorkflow);
|
|
22
|
+
this.acceptWorkflowSignal = new AcceptSignal(repository, this.startWorkflow, work);
|
|
22
23
|
this.childWorkflows = new RequestChild(repository, claims, new GroupBudgetRecorder(journal), this.startWorkflow, this.advanceWorkflow);
|
|
23
24
|
this.acceptActivityOutcome = new AcceptActivityOutcome(repository, this.startWorkflow, (context) => this.childWorkflows.reconcileChildCompletions(context));
|
|
24
25
|
}
|
|
@@ -38,7 +39,10 @@ export class OrchestrationService {
|
|
|
38
39
|
return this.acceptWorkflowSignal.wait(workflowInstanceId, expectation, context);
|
|
39
40
|
}
|
|
40
41
|
acceptSignal(workflowInstanceId, signal, context) {
|
|
41
|
-
return this.acceptWorkflowSignal.execute(workflowInstanceId, signal, context);
|
|
42
|
+
return this.coordinateAcceptSignal(() => this.acceptWorkflowSignal.execute(workflowInstanceId, signal, context));
|
|
43
|
+
}
|
|
44
|
+
setAcceptSignalOperationCoordinator(coordinator) {
|
|
45
|
+
this.coordinateAcceptSignal = coordinator;
|
|
42
46
|
}
|
|
43
47
|
requestSupplementalActivity(workflowInstanceId, request, context) {
|
|
44
48
|
return this.advanceWorkflow.requestSupplementalActivity(workflowInstanceId, request, context);
|
|
@@ -82,5 +86,11 @@ export class OrchestrationService {
|
|
|
82
86
|
listWatchMatches(event, context) {
|
|
83
87
|
return this.advanceWorkflow.listWatchMatches(event, context);
|
|
84
88
|
}
|
|
89
|
+
listResourceTransitionMatches(event) {
|
|
90
|
+
return this.advanceWorkflow.listResourceTransitionMatches(event);
|
|
91
|
+
}
|
|
92
|
+
applyResourceTransition(workflowInstanceId, target, evidenceId, context) {
|
|
93
|
+
return this.advanceWorkflow.applyResourceTransition(workflowInstanceId, target, evidenceId, context);
|
|
94
|
+
}
|
|
85
95
|
}
|
|
86
96
|
export const createOrchestrationService = (journal, work, definitions, projections) => new OrchestrationService(journal, work, definitions, projections);
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { ActivityEventType, ActivityResourceRole, decidePullRequestAuthority, PullRequestCheckState, PullRequestState, selectActivityEvent, selectPrimaryPullRequest, } from '../../activities/index.js';
|
|
2
|
+
const triggers = [
|
|
3
|
+
ActivityEventType.PrReviewAccepted,
|
|
4
|
+
ActivityEventType.PrStateChanged,
|
|
5
|
+
ActivityEventType.PrChecksChanged,
|
|
6
|
+
];
|
|
7
|
+
export function createPullRequestTransitionEvidence(pullRequests) {
|
|
8
|
+
return {
|
|
9
|
+
triggers,
|
|
10
|
+
async resolve({ workItemId, transitions, fact, }) {
|
|
11
|
+
const input = await pullRequests.authorityInput(workItemId);
|
|
12
|
+
const selected = selectPrimaryPullRequest(input, workItemId);
|
|
13
|
+
if (selected === null)
|
|
14
|
+
return null;
|
|
15
|
+
const resourceId = selected.pullRequest.resourceId;
|
|
16
|
+
const candidates = fact === undefined ? await pullRequests.factsFor(resourceId) : scoped(fact, resourceId);
|
|
17
|
+
if (candidates.length === 0)
|
|
18
|
+
return null;
|
|
19
|
+
// Only pay for the authority decision when a review-accepted
|
|
20
|
+
// transition is actually in play; it doesn't vary per candidate, so
|
|
21
|
+
// it's computed once here rather than inside the matcher. Built from
|
|
22
|
+
// the `input` snapshot already read above (not re-read via the
|
|
23
|
+
// service) so the primary-resource selection and the authority
|
|
24
|
+
// decision can't observe different journal states.
|
|
25
|
+
const authorized = transitions.some((transition) => transition.event === ActivityEventType.PrReviewAccepted)
|
|
26
|
+
? decidePullRequestAuthority(input, {
|
|
27
|
+
target: ActivityResourceRole.Primary,
|
|
28
|
+
requireAcceptedReview: true,
|
|
29
|
+
requireChecks: false,
|
|
30
|
+
}).allowed
|
|
31
|
+
: false;
|
|
32
|
+
return firstMatch(candidates, transitions, selected.pullRequest, authorized);
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
// A live fact carries its own resource stream; a fact about another work
|
|
37
|
+
// item's resource is not evidence for this instance's transitions.
|
|
38
|
+
function scoped(fact, resourceId) {
|
|
39
|
+
const activity = selectActivityEvent(fact);
|
|
40
|
+
return activity !== null && activity.stream.id === resourceId ? [activity] : [];
|
|
41
|
+
}
|
|
42
|
+
function firstMatch(candidates, transitions, pullRequest, authorized) {
|
|
43
|
+
for (const candidate of candidates) {
|
|
44
|
+
const transition = transitions.find((entry) => matchesPrimaryPullRequestTransition(entry, candidate, pullRequest, authorized));
|
|
45
|
+
if (transition !== undefined)
|
|
46
|
+
return { transition, evidenceId: candidate.eventId };
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
// The closed V1 event union is intentionally matched exhaustively here.
|
|
51
|
+
// eslint-disable-next-line complexity
|
|
52
|
+
function matchesPrimaryPullRequestTransition(transition, event, pullRequest, authorized) {
|
|
53
|
+
if (transition.event !== event.eventType)
|
|
54
|
+
return false;
|
|
55
|
+
if (event.eventType === ActivityEventType.PrReviewAccepted)
|
|
56
|
+
return event.payload.revision === pullRequest.headRevision && authorized;
|
|
57
|
+
if (event.eventType === ActivityEventType.PrStateChanged)
|
|
58
|
+
return (transition.where !== undefined &&
|
|
59
|
+
'state' in transition.where &&
|
|
60
|
+
transition.where.state === event.payload.state &&
|
|
61
|
+
event.payload.state === PullRequestState.Merged &&
|
|
62
|
+
pullRequest.state === PullRequestState.Merged);
|
|
63
|
+
if (event.eventType !== ActivityEventType.PrChecksChanged)
|
|
64
|
+
return false;
|
|
65
|
+
return (transition.where !== undefined &&
|
|
66
|
+
'checks' in transition.where &&
|
|
67
|
+
transition.where.checks === event.payload.checks &&
|
|
68
|
+
event.payload.checks === PullRequestCheckState.Failing &&
|
|
69
|
+
pullRequest.checks === PullRequestCheckState.Failing);
|
|
70
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { OrchestrationEventType } from '../contracts/events.js';
|
|
2
|
+
import { isWorkflowInstanceStream } from '../contracts/streams.js';
|
|
3
|
+
import { WorkflowStatus } from '../contracts/vocabulary.js';
|
|
4
|
+
import { acceptSignal as decideSignal } from '../domain/interpreter.js';
|
|
5
|
+
// Generic: no resource-kind knowledge lives here. A `signal-wait-started`
|
|
6
|
+
// trigger on the instance's own stream returns every declared transition
|
|
7
|
+
// unfiltered, so the reactor's evidence policy can check prior journal
|
|
8
|
+
// history for a fact that arrived before the wait began. Any other event
|
|
9
|
+
// is matched against each instance's declared predicates directly.
|
|
10
|
+
export function matchResourceTransitions(loaded, event) {
|
|
11
|
+
const waitStart = event.eventType === OrchestrationEventType.SignalWaitStarted &&
|
|
12
|
+
isWorkflowInstanceStream(event.stream)
|
|
13
|
+
? event.stream.id
|
|
14
|
+
: undefined;
|
|
15
|
+
return loaded.flatMap(({ view }) => {
|
|
16
|
+
if (view.status !== WorkflowStatus.Waiting)
|
|
17
|
+
return [];
|
|
18
|
+
const declared = view.waitingFor?.resourceTransitions;
|
|
19
|
+
if (declared === undefined)
|
|
20
|
+
return [];
|
|
21
|
+
if (waitStart !== undefined)
|
|
22
|
+
return view.workflowInstanceId === waitStart
|
|
23
|
+
? [
|
|
24
|
+
{
|
|
25
|
+
workflowInstanceId: view.workflowInstanceId,
|
|
26
|
+
workItemId: view.workItemId,
|
|
27
|
+
transitions: declared,
|
|
28
|
+
},
|
|
29
|
+
]
|
|
30
|
+
: [];
|
|
31
|
+
const transitions = declared.filter((transition) => matchesFact(transition, event));
|
|
32
|
+
return transitions.length === 0
|
|
33
|
+
? []
|
|
34
|
+
: [{ workflowInstanceId: view.workflowInstanceId, workItemId: view.workItemId, transitions }];
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
// Compares the declared predicate key-wise against the event payload.
|
|
38
|
+
function matchesFact(transition, event) {
|
|
39
|
+
if (transition.event !== event.eventType)
|
|
40
|
+
return false;
|
|
41
|
+
if (transition.where === undefined)
|
|
42
|
+
return true;
|
|
43
|
+
const payload = event.payload;
|
|
44
|
+
return Object.entries(transition.where).every(([key, value]) => payload[key] === value);
|
|
45
|
+
}
|
|
46
|
+
// Applies a transition the reactor's evidence policy already confirmed. The
|
|
47
|
+
// route target comes from the matched transition, not the wait's own
|
|
48
|
+
// resume, so the decision is taken against the transition's destination.
|
|
49
|
+
// Once applied the instance leaves Waiting, so a repeated call with the
|
|
50
|
+
// same evidence is a no-op — there is no separate consumed-fact guard.
|
|
51
|
+
export async function acceptResourceTransition(repository, workflows, id, target, evidenceId, context) {
|
|
52
|
+
const loaded = await repository.load(id);
|
|
53
|
+
if (loaded.view === null || loaded.view.waitingFor === undefined)
|
|
54
|
+
return loaded.view;
|
|
55
|
+
const definition = await workflows.definitionForOperation(loaded.view, loaded.sequence, context);
|
|
56
|
+
if (definition === null)
|
|
57
|
+
return loaded.view;
|
|
58
|
+
const decision = decideSignal(definition, { ...loaded.view, waitingFor: { ...loaded.view.waitingFor, resume: target } }, {
|
|
59
|
+
signal: {
|
|
60
|
+
kind: loaded.view.waitingFor.signalKind,
|
|
61
|
+
actorId: 'resource-transition',
|
|
62
|
+
actorDecision: { authorized: true, evidenceId },
|
|
63
|
+
providerEventId: evidenceId,
|
|
64
|
+
},
|
|
65
|
+
occurredAt: context.occurredAt,
|
|
66
|
+
causationId: `${context.commandId}:${evidenceId}`,
|
|
67
|
+
consent: true,
|
|
68
|
+
});
|
|
69
|
+
if (decision.kind === 'append') {
|
|
70
|
+
try {
|
|
71
|
+
await repository.append(id, loaded.sequence, decision.events);
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
const reloaded = await repository.load(id);
|
|
75
|
+
if (reloaded.view !== null &&
|
|
76
|
+
(reloaded.view.acceptedSignalIds.includes(evidenceId) ||
|
|
77
|
+
reloaded.view.waitingFor === undefined))
|
|
78
|
+
return reloaded.view;
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return (await repository.load(id)).view;
|
|
83
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { correlationId, EventActorKind, } from '../../kernel/index.js';
|
|
2
|
+
import { selectOrchestrationEvent } from '../contracts/event-decoder.js';
|
|
3
|
+
import { OrchestrationEventType } from '../contracts/events.js';
|
|
4
|
+
const checkpoint = 'reactor:orchestration.resource-transition';
|
|
5
|
+
const batchSize = 100;
|
|
6
|
+
export function createResourceTransitionReactor(orchestration, evidence, journal, checkpoints) {
|
|
7
|
+
let queue = Promise.resolve();
|
|
8
|
+
const react = async (event, context) => {
|
|
9
|
+
const orchestrationEvent = selectOrchestrationEvent(event);
|
|
10
|
+
const isWaitStart = orchestrationEvent?.eventType === OrchestrationEventType.SignalWaitStarted;
|
|
11
|
+
if (!isWaitStart && !evidence.triggers.includes(event.eventType))
|
|
12
|
+
return;
|
|
13
|
+
const fact = isWaitStart ? undefined : event;
|
|
14
|
+
for (const match of await orchestration.listResourceTransitionMatches(event)) {
|
|
15
|
+
const resolved = await evidence.resolve({
|
|
16
|
+
workItemId: match.workItemId,
|
|
17
|
+
transitions: match.transitions,
|
|
18
|
+
...(fact === undefined ? {} : { fact }),
|
|
19
|
+
});
|
|
20
|
+
if (resolved === null)
|
|
21
|
+
continue;
|
|
22
|
+
await orchestration.applyResourceTransition(match.workflowInstanceId, resolved.transition.target, resolved.evidenceId, context);
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
const runBatch = async (limit = batchSize) => {
|
|
26
|
+
if (journal === undefined || checkpoints === undefined)
|
|
27
|
+
throw new Error('ResourceTransitionReactor journal and checkpoints are required to run');
|
|
28
|
+
const events = await journal.readAll(await checkpoints.load(checkpoint), limit);
|
|
29
|
+
for (const event of events) {
|
|
30
|
+
await react(event, commandContext(event));
|
|
31
|
+
await checkpoints.save(checkpoint, event.globalPosition);
|
|
32
|
+
}
|
|
33
|
+
return events.length;
|
|
34
|
+
};
|
|
35
|
+
const serialize = (operation) => {
|
|
36
|
+
const result = queue.then(operation);
|
|
37
|
+
queue = result.catch(() => { });
|
|
38
|
+
return result;
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
react,
|
|
42
|
+
runOnce(limit = batchSize) {
|
|
43
|
+
return serialize(() => runBatch(limit));
|
|
44
|
+
},
|
|
45
|
+
drain() {
|
|
46
|
+
return serialize(async () => {
|
|
47
|
+
let total = 0;
|
|
48
|
+
let processed;
|
|
49
|
+
do {
|
|
50
|
+
processed = await runBatch();
|
|
51
|
+
total += processed;
|
|
52
|
+
} while (processed === batchSize);
|
|
53
|
+
return total;
|
|
54
|
+
});
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function commandContext(event) {
|
|
59
|
+
return {
|
|
60
|
+
commandId: `${event.eventId}:resource-transition`,
|
|
61
|
+
correlationId: correlationId(event.correlationId),
|
|
62
|
+
occurredAt: event.occurredAt,
|
|
63
|
+
actor: { kind: EventActorKind.System, id: 'resource-transition-reactor' },
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { ActivityEventType, PullRequestCheckState, selectActivityEvent, } from '../../activities/index.js';
|
|
2
|
+
// Loads a definition per instance (operation-scoped when a command context is
|
|
3
|
+
// given, read-only otherwise) and returns every watch whose event, stage,
|
|
4
|
+
// status, and predicate all agree with the incoming event.
|
|
5
|
+
export async function matchWatches(loaded, event, workflows, context) {
|
|
6
|
+
const matches = await Promise.all(loaded.map(async ({ view: parent, sequence }) => {
|
|
7
|
+
const definition = context === undefined
|
|
8
|
+
? await workflows.definitionFor(parent)
|
|
9
|
+
: await workflows.definitionForOperation(parent, sequence, context);
|
|
10
|
+
if (definition === null)
|
|
11
|
+
return [];
|
|
12
|
+
return definition.watches
|
|
13
|
+
.filter((watch) => watch.on?.events.includes(event.eventType) === true &&
|
|
14
|
+
watch.while.stages.includes(parent.currentStage) &&
|
|
15
|
+
watch.while.statuses.some((status) => status === parent.status) &&
|
|
16
|
+
matchesWatchPredicate(watch.where, event))
|
|
17
|
+
.map((watch) => ({ parent, watch }));
|
|
18
|
+
}));
|
|
19
|
+
return matches.flat();
|
|
20
|
+
}
|
|
21
|
+
function matchesWatchPredicate(predicate, event) {
|
|
22
|
+
if (predicate === undefined)
|
|
23
|
+
return true;
|
|
24
|
+
const activityEvent = selectActivityEvent(event);
|
|
25
|
+
return (activityEvent?.eventType === ActivityEventType.PrChecksChanged &&
|
|
26
|
+
activityEvent.payload.checks === PullRequestCheckState.Failing);
|
|
27
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { ApprovalAuthorityKind, WorkflowStatus } from './vocabulary.js';
|
|
3
|
-
import { PullRequestCheckState } from '../../activities/index.js';
|
|
3
|
+
import { ActivityEventType, PullRequestCheckState, PullRequestState, } from '../../activities/index.js';
|
|
4
4
|
import { WorkspaceMode } from '../../execution/index.js';
|
|
5
5
|
import { MatchMode } from '../../kernel/index.js';
|
|
6
6
|
const identifier = z.string().trim().min(1);
|
|
@@ -31,6 +31,28 @@ const watchGateConfigSchema = z.union([
|
|
|
31
31
|
})
|
|
32
32
|
.strict(),
|
|
33
33
|
]);
|
|
34
|
+
const resourceTransitionConfigSchema = z.union([
|
|
35
|
+
z
|
|
36
|
+
.object({
|
|
37
|
+
events: z.tuple([z.literal(ActivityEventType.PrReviewAccepted)]),
|
|
38
|
+
then: identifier.optional(),
|
|
39
|
+
})
|
|
40
|
+
.strict(),
|
|
41
|
+
z
|
|
42
|
+
.object({
|
|
43
|
+
events: z.tuple([z.literal(ActivityEventType.PrStateChanged)]),
|
|
44
|
+
where: z.object({ state: z.literal(PullRequestState.Merged) }).strict(),
|
|
45
|
+
then: identifier.optional(),
|
|
46
|
+
})
|
|
47
|
+
.strict(),
|
|
48
|
+
z
|
|
49
|
+
.object({
|
|
50
|
+
events: z.tuple([z.literal(ActivityEventType.PrChecksChanged)]),
|
|
51
|
+
where: z.object({ checks: z.literal(PullRequestCheckState.Failing) }).strict(),
|
|
52
|
+
then: identifier.optional(),
|
|
53
|
+
})
|
|
54
|
+
.strict(),
|
|
55
|
+
]);
|
|
34
56
|
const commandName = z.string().regex(/^\/[a-z][a-z0-9-]*$/);
|
|
35
57
|
const canonicalEventName = z.string().regex(/^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/);
|
|
36
58
|
const watchStatus = z.enum([WorkflowStatus.Active, WorkflowStatus.Waiting, WorkflowStatus.Blocked]);
|
|
@@ -73,6 +95,7 @@ export const outcomeRouteConfigSchema = z
|
|
|
73
95
|
retry: bound.optional(),
|
|
74
96
|
await: awaitConfigSchema.optional(),
|
|
75
97
|
watchGates: z.array(watchGateConfigSchema).optional(),
|
|
98
|
+
resourceTransitions: z.array(resourceTransitionConfigSchema).min(1).optional(),
|
|
76
99
|
})
|
|
77
100
|
.strict();
|
|
78
101
|
export const stageConfigSchema = z
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { activationId, activityName, ActivityOutcomeKind } from '../../activities/index.js';
|
|
2
|
+
import { activationId, ActivityEventType, activityName, ActivityOutcomeKind, PullRequestCheckState, PullRequestState, } from '../../activities/index.js';
|
|
3
3
|
import { WorkspaceMode } from '../../execution/index.js';
|
|
4
4
|
import { brandedStringSchema, eventEnvelopeSchema, } from '../../kernel/index.js';
|
|
5
5
|
import { workItemId } from '../../work/index.js';
|
|
@@ -99,6 +99,22 @@ const transitionTargetSchema = z.discriminatedUnion('kind', [
|
|
|
99
99
|
})
|
|
100
100
|
.strict(),
|
|
101
101
|
]);
|
|
102
|
+
const resourceTransitionSchema = z
|
|
103
|
+
.object({
|
|
104
|
+
event: z.enum([
|
|
105
|
+
ActivityEventType.PrReviewAccepted,
|
|
106
|
+
ActivityEventType.PrStateChanged,
|
|
107
|
+
ActivityEventType.PrChecksChanged,
|
|
108
|
+
]),
|
|
109
|
+
where: z
|
|
110
|
+
.union([
|
|
111
|
+
z.object({ state: z.literal(PullRequestState.Merged) }).strict(),
|
|
112
|
+
z.object({ checks: z.literal(PullRequestCheckState.Failing) }).strict(),
|
|
113
|
+
])
|
|
114
|
+
.optional(),
|
|
115
|
+
target: transitionTargetSchema,
|
|
116
|
+
})
|
|
117
|
+
.strict();
|
|
102
118
|
export const expectationSchema = z
|
|
103
119
|
.object({
|
|
104
120
|
signalKind: brandedStringSchema(signalName),
|
|
@@ -107,6 +123,7 @@ export const expectationSchema = z
|
|
|
107
123
|
from: z.array(approvalAuthoritySchema).min(1).optional(),
|
|
108
124
|
resume: transitionTargetSchema.optional(),
|
|
109
125
|
onRejectResume: transitionTargetSchema.optional(),
|
|
126
|
+
resourceTransitions: z.array(resourceTransitionSchema).min(1).optional(),
|
|
110
127
|
})
|
|
111
128
|
.strict();
|
|
112
129
|
export const signalSchema = z
|
|
@@ -29,6 +29,7 @@ export const OrchestrationEventType = {
|
|
|
29
29
|
};
|
|
30
30
|
export const WatchGateVerdictSignal = signalName('orchestration.watch-gate-verdict');
|
|
31
31
|
export const ApprovedSignal = signalName('approved');
|
|
32
|
+
export const ResourceTransitionSignal = signalName('orchestration.resource-transition');
|
|
32
33
|
/**
|
|
33
34
|
* Every consumer that needs to know whether a wait renders as "awaiting
|
|
34
35
|
* approval" externally (GitHub labels, the operator board, the agent-run
|
|
@@ -11,6 +11,7 @@ export function defaultApprovalAwait(stage, outcomeKind, route) {
|
|
|
11
11
|
return route.await;
|
|
12
12
|
if (outcomeKind !== ActivityOutcomeKind.Done ||
|
|
13
13
|
route.watchGates !== undefined ||
|
|
14
|
+
route.resourceTransitions !== undefined ||
|
|
14
15
|
stage.requiresApproval === false)
|
|
15
16
|
return undefined;
|
|
16
17
|
return { signal: 'approved', from: [ApprovalAuthorityKind.Human] };
|
|
@@ -3,6 +3,7 @@ import { workflowDefinitionConfigSchema, } from '../contracts/config.js';
|
|
|
3
3
|
import { commandName, signalName, stageName, watchId, workflowName, } from '../contracts/identifiers.js';
|
|
4
4
|
import { ApprovalAuthorityKind, TransitionTargetKind } from '../contracts/vocabulary.js';
|
|
5
5
|
import { defaultApprovalAwait } from './approval-defaults.js';
|
|
6
|
+
import { compileResourceTransitions } from './resource-transition-compiler.js';
|
|
6
7
|
import { assertCyclesBounded, assertReachable } from './workflow-graph.js';
|
|
7
8
|
export function compileWorkflow(name, input, activities, knownWorkflowNames = [name]) {
|
|
8
9
|
const compiledName = workflowName(name);
|
|
@@ -72,16 +73,23 @@ function compileStages(compiledWorkflowName, configured, activities, declaredWat
|
|
|
72
73
|
compileStage(context, stageName, stage, configured),
|
|
73
74
|
]));
|
|
74
75
|
}
|
|
76
|
+
// The stage compiler keeps route validation and compilation together.
|
|
77
|
+
// eslint-disable-next-line max-lines-per-function
|
|
75
78
|
function compileStage(context, rawStageName, stage, allStages) {
|
|
76
79
|
const { workflowName: compiledWorkflowName, activities, declaredWatchIds } = context;
|
|
77
80
|
const definition = activities.describe(activityName(stage.activity));
|
|
78
|
-
const on = Object.fromEntries(Object.entries(stage.on).map(
|
|
81
|
+
const on = Object.fromEntries(Object.entries(stage.on).map(
|
|
82
|
+
// The route schema is deliberately closed and validated in one compiler pass.
|
|
83
|
+
// eslint-disable-next-line complexity
|
|
84
|
+
([outcomeKind, route]) => {
|
|
79
85
|
if (!definition.outcomeKinds.includes(outcomeKind))
|
|
80
86
|
throw new Error(`Workflow outcome route ${outcomeKind} is not declared by Activity ${definition.name}`);
|
|
81
87
|
if (!isReservedTerminal(route.then) && !(route.then in allStages))
|
|
82
88
|
throw new Error(`Unknown transition target: ${route.then}`);
|
|
83
89
|
if (route.await !== undefined && route.watchGates !== undefined)
|
|
84
90
|
throw new Error(`Route ${compiledWorkflowName}:${rawStageName}:${outcomeKind} cannot configure both await and watchGates`);
|
|
91
|
+
if (route.resourceTransitions !== undefined && outcomeKind !== ActivityOutcomeKind.Done)
|
|
92
|
+
throw new Error(`Route ${compiledWorkflowName}:${rawStageName}:${outcomeKind} resourceTransitions are only valid on done`);
|
|
85
93
|
const followOns = route.activities?.map((activity) => ({
|
|
86
94
|
use: activityName(activity.use),
|
|
87
95
|
with: activities.validateInput(activityName(activity.use), activity.with),
|
|
@@ -103,6 +111,11 @@ function compileStage(context, rawStageName, stage, allStages) {
|
|
|
103
111
|
: {
|
|
104
112
|
watchGates: compileWatchGates(compiledWorkflowName, rawStageName, outcomeKind, route.watchGates, declaredWatchIds, allStages),
|
|
105
113
|
}),
|
|
114
|
+
...(route.resourceTransitions === undefined
|
|
115
|
+
? {}
|
|
116
|
+
: {
|
|
117
|
+
resourceTransitions: compileResourceTransitions(route.resourceTransitions, route.then, outcomeKind, allStages, isReservedTerminal, compileTarget),
|
|
118
|
+
}),
|
|
106
119
|
id: `${compiledWorkflowName}:${rawStageName}:${outcomeKind}`,
|
|
107
120
|
});
|
|
108
121
|
return [outcomeKind, compiled];
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function compileResourceTransitions(entries, inheritedThen, outcomeKind, allStages, isReservedTerminal, compileTarget) {
|
|
2
|
+
return Object.freeze(entries.map((entry) => {
|
|
3
|
+
const then = entry.then ?? inheritedThen;
|
|
4
|
+
if (!isReservedTerminal(then) && !(then in allStages))
|
|
5
|
+
throw new Error(`Unknown resourceTransitions target: ${then}`);
|
|
6
|
+
return Object.freeze({
|
|
7
|
+
event: entry.events[0],
|
|
8
|
+
...(!('where' in entry) || entry.where === undefined ? {} : { where: entry.where }),
|
|
9
|
+
target: compileTarget(then, outcomeKind),
|
|
10
|
+
});
|
|
11
|
+
}));
|
|
12
|
+
}
|
|
@@ -1,17 +1,26 @@
|
|
|
1
|
-
import { OrchestrationEventType, WatchGateVerdictSignal } from '../contracts/events.js';
|
|
1
|
+
import { OrchestrationEventType, ResourceTransitionSignal, WatchGateVerdictSignal, } from '../contracts/events.js';
|
|
2
2
|
import { ApprovalAuthorityKind, TransitionTargetKind } from '../contracts/vocabulary.js';
|
|
3
3
|
import { activation, nextOrdinal, stateDraft } from './decision-events.js';
|
|
4
|
+
// Route completion combines the mutually exclusive wait, await, and target policies.
|
|
5
|
+
// eslint-disable-next-line complexity
|
|
4
6
|
export function finishRoute(events, definition, state, input, route) {
|
|
5
|
-
if (route.watchGates !== undefined) {
|
|
6
|
-
const gate = route.watchGates[0];
|
|
7
|
+
if (route.watchGates !== undefined || route.resourceTransitions !== undefined) {
|
|
8
|
+
const gate = route.watchGates?.[0];
|
|
7
9
|
events.push(stateDraft(state, input, OrchestrationEventType.SignalWaitStarted, {
|
|
8
|
-
signalKind: WatchGateVerdictSignal,
|
|
9
|
-
|
|
10
|
-
{
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
signalKind: gate === undefined ? ResourceTransitionSignal : WatchGateVerdictSignal,
|
|
11
|
+
...(gate === undefined
|
|
12
|
+
? {}
|
|
13
|
+
: {
|
|
14
|
+
from: Object.freeze([
|
|
15
|
+
{ kind: ApprovalAuthorityKind.Watch, watch: gate.watch },
|
|
16
|
+
{ kind: ApprovalAuthorityKind.Human },
|
|
17
|
+
]),
|
|
18
|
+
}),
|
|
13
19
|
resume: route.target,
|
|
14
|
-
onRejectResume: gate.onRejectTarget,
|
|
20
|
+
...(gate === undefined ? {} : { onRejectResume: gate.onRejectTarget }),
|
|
21
|
+
...(route.resourceTransitions === undefined
|
|
22
|
+
? {}
|
|
23
|
+
: { resourceTransitions: route.resourceTransitions }),
|
|
15
24
|
}, events.length + 1));
|
|
16
25
|
return;
|
|
17
26
|
}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { stageName } from '../contracts/identifiers.js';
|
|
2
2
|
import { TransitionTargetKind } from '../contracts/vocabulary.js';
|
|
3
3
|
function edges(stages, name) {
|
|
4
|
-
return Object.values(stages[name].on).flatMap((route) =>
|
|
4
|
+
return Object.values(stages[name].on).flatMap((route) => [
|
|
5
|
+
route.target,
|
|
6
|
+
...(route.watchGates ?? []).map((gate) => gate.onRejectTarget),
|
|
7
|
+
...(route.resourceTransitions ?? []).map((transition) => transition.target),
|
|
8
|
+
].flatMap((target) => (target.kind === TransitionTargetKind.Stage ? [target.stage] : [])));
|
|
5
9
|
}
|
|
6
10
|
export function assertReachable(entry, stages) {
|
|
7
11
|
const reached = new Set();
|
|
@@ -178,6 +178,9 @@ function applySignalWaitStarted(state, event) {
|
|
|
178
178
|
...(event.payload.onRejectResume === undefined
|
|
179
179
|
? {}
|
|
180
180
|
: { onRejectResume: event.payload.onRejectResume }),
|
|
181
|
+
...(event.payload.resourceTransitions === undefined
|
|
182
|
+
? {}
|
|
183
|
+
: { resourceTransitions: event.payload.resourceTransitions }),
|
|
181
184
|
};
|
|
182
185
|
}
|
|
183
186
|
function updateActivationStatus(state, activationId, status) {
|
|
@@ -2,6 +2,10 @@ export * from './application/orchestration-projection.js';
|
|
|
2
2
|
export * from './application/orchestration-repository.js';
|
|
3
3
|
export * from './application/orchestration-service.js';
|
|
4
4
|
export * from './application/advance-workflow.js';
|
|
5
|
+
export * from './application/pull-request-transition-evidence.js';
|
|
6
|
+
export * from './application/resource-transition-evidence.js';
|
|
7
|
+
export * from './application/resource-transition-matching.js';
|
|
8
|
+
export * from './application/resource-transition-reactor.js';
|
|
5
9
|
export * from './application/signal-reactor.js';
|
|
6
10
|
export * from './application/watch-reactor.js';
|
|
7
11
|
export * from './application/workflow-definition-registry.js';
|