@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.
Files changed (31) hide show
  1. package/dist/src/activities/pr/application.js +5 -1
  2. package/dist/src/activities/pr/policy.js +10 -0
  3. package/dist/src/bootstrap/activity-registry.js +32 -0
  4. package/dist/src/bootstrap/composition-root.js +17 -243
  5. package/dist/src/bootstrap/index.js +1 -0
  6. package/dist/src/bootstrap/integration-runtime.js +182 -0
  7. package/dist/src/bootstrap/persistence-composition.js +11 -0
  8. package/dist/src/bootstrap/resource-transition-evidence.js +17 -0
  9. package/dist/src/bootstrap/transcript-retention.js +29 -0
  10. package/dist/src/bootstrap/version.js +1 -1
  11. package/dist/src/orchestration/application/advance-workflow.js +12 -22
  12. package/dist/src/orchestration/application/orchestration-repository.js +19 -4
  13. package/dist/src/orchestration/application/orchestration-service.js +12 -2
  14. package/dist/src/orchestration/application/pull-request-transition-evidence.js +70 -0
  15. package/dist/src/orchestration/application/resource-transition-evidence.js +1 -0
  16. package/dist/src/orchestration/application/resource-transition-matching.js +83 -0
  17. package/dist/src/orchestration/application/resource-transition-reactor.js +65 -0
  18. package/dist/src/orchestration/application/watch-matching.js +27 -0
  19. package/dist/src/orchestration/contracts/config.js +24 -1
  20. package/dist/src/orchestration/contracts/event-decoder.js +18 -1
  21. package/dist/src/orchestration/contracts/events.js +1 -0
  22. package/dist/src/orchestration/domain/approval-defaults.js +1 -0
  23. package/dist/src/orchestration/domain/compiler.js +14 -1
  24. package/dist/src/orchestration/domain/resource-transition-compiler.js +12 -0
  25. package/dist/src/orchestration/domain/transition.js +18 -9
  26. package/dist/src/orchestration/domain/workflow-graph.js +5 -1
  27. package/dist/src/orchestration/domain/workflow-instance-events.js +3 -0
  28. package/dist/src/orchestration/index.js +4 -0
  29. package/dist/src/persistence/filesystem/file-lock.js +199 -27
  30. package/package.json +1 -1
  31. 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
- const matches = await Promise.all((await this.listAllLoaded()).map(async ({ view: parent, sequence }) => {
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 ids = new Set((await this.journal.readAll(0))
29
- .filter((event) => isWorkflowInstanceStream(event.stream))
30
- .map((event) => event.stream.id));
31
- return Promise.all([...ids].map((id) => this.load(id)));
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,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(([outcomeKind, route]) => {
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
- from: Object.freeze([
10
- { kind: ApprovalAuthorityKind.Watch, watch: gate.watch },
11
- { kind: ApprovalAuthorityKind.Human },
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) => route.target.kind === TransitionTargetKind.Stage ? [route.target.stage] : []);
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';