@atolis-hq/wake 0.3.6 → 0.3.8

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 (30) hide show
  1. package/dist/src/bootstrap/composition-root.js +1 -1
  2. package/dist/src/bootstrap/projection-runtime.js +2 -1
  3. package/dist/src/bootstrap/version.js +1 -1
  4. package/dist/src/integrations/github/application/inbound-review-signals.js +17 -13
  5. package/dist/src/integrations/github/application/inbound-translator.js +1 -0
  6. package/dist/src/integrations/github/application/pull-request-translation.js +1 -0
  7. package/dist/src/integrations/github/contracts/events.js +1 -0
  8. package/dist/src/integrations/github/infrastructure/client-reads.js +14 -0
  9. package/dist/src/integrations/github/infrastructure/client.js +2 -1
  10. package/dist/src/integrations/github/infrastructure/pr-source.js +14 -1
  11. package/dist/src/integrations/github/provider.js +1 -0
  12. package/dist/src/orchestration/application/accept-activity-outcome.js +11 -8
  13. package/dist/src/orchestration/application/accept-signal.js +4 -1
  14. package/dist/src/orchestration/application/advance-workflow.js +59 -14
  15. package/dist/src/orchestration/application/orchestration-repository.js +1 -1
  16. package/dist/src/orchestration/application/orchestration-service.js +9 -5
  17. package/dist/src/orchestration/application/request-child.js +4 -1
  18. package/dist/src/orchestration/application/start-workflow.js +42 -7
  19. package/dist/src/orchestration/application/watch-reactor.js +1 -1
  20. package/dist/src/orchestration/application/workflow-definition-registry.js +108 -0
  21. package/dist/src/orchestration/contracts/event-decoder.js +22 -0
  22. package/dist/src/orchestration/contracts/events.js +1 -0
  23. package/dist/src/orchestration/contracts/streams.js +5 -0
  24. package/dist/src/orchestration/domain/activation-policy.js +3 -0
  25. package/dist/src/orchestration/domain/interpreter.js +1 -1
  26. package/dist/src/orchestration/domain/operator-retry-policy.js +33 -1
  27. package/dist/src/orchestration/domain/workflow-instance-events.js +3 -0
  28. package/dist/src/orchestration/domain/workflow-instance.js +3 -0
  29. package/dist/src/orchestration/index.js +1 -0
  30. package/package.json +1 -1
@@ -46,7 +46,7 @@ export async function createCompositionRoot(wakeRoot, options = {}) {
46
46
  name,
47
47
  compileWorkflow(name, definition, activities, Object.keys(config.orchestration.workflows)),
48
48
  ]));
49
- const orchestration = createOrchestrationService(journal, work, definitions);
49
+ const orchestration = createOrchestrationService(journal, work, definitions, projections);
50
50
  const workspaces = new GitWorkspaceProvider(paths.workspacesRoot, {
51
51
  async cloneLocator(id) {
52
52
  const resource = await resources.get(resourceId(id));
@@ -2,7 +2,7 @@ import { activityProjectionDefinitions } from '../activities/index.js';
2
2
  import { controlPlaneProjectionDefinitions } from '../control-plane/index.js';
3
3
  import { executionProjection } from '../execution/index.js';
4
4
  import { deliveryProjectionDefinitions } from '../integrations/index.js';
5
- import { orchestrationProjection } from '../orchestration/index.js';
5
+ import { orchestrationProjection, workflowDefinitionsProjection } from '../orchestration/index.js';
6
6
  import { ProjectionRunner } from '../persistence/index.js';
7
7
  import { resourceCorrelationProjection, resourceProjection, resourcesByExternalKeyProjection, workCorrelationsProjection, } from '../resources/index.js';
8
8
  import { workProjection } from '../work/index.js';
@@ -18,6 +18,7 @@ export const runtimeProjectionDefinitions = [
18
18
  ...deliveryProjectionDefinitions,
19
19
  ...controlPlaneProjectionDefinitions,
20
20
  orchestrationProjection,
21
+ workflowDefinitionsProjection,
21
22
  executionProjection,
22
23
  boardProjection,
23
24
  analyticsProjection,
@@ -108,4 +108,4 @@ export function resolveWakeVersion(options = {}) {
108
108
  return `g${headHash.slice(0, 7)}`;
109
109
  return '0.1.0-dev';
110
110
  }
111
- export const wakeVersion = "gd042cce";
111
+ export const wakeVersion = "g42cf498";
@@ -101,6 +101,7 @@ async function applyIssueApprovalSignal(input) {
101
101
  orchestration,
102
102
  resourceId: resourceIdValue,
103
103
  outcome: command === '/approved' ? ActivityOutcomeKind.Done : ActivityOutcomeKind.Rejected,
104
+ resumeBlockedOnChanges: command === '/changes',
104
105
  });
105
106
  }
106
107
  async function applyPullRequestWorkflowSignal(input) {
@@ -125,25 +126,28 @@ async function applyPullRequestWorkflowSignal(input) {
125
126
  });
126
127
  }
127
128
  async function applyWorkflowSignal(input) {
128
- const { event, resources, orchestration, resourceId: resourceIdValue, outcome } = input;
129
+ const { event, resources, orchestration, resourceId: resourceIdValue, outcome, resumeBlockedOnChanges = false, } = input;
129
130
  const workItemIds = (await resources.correlations(resourceIdValue))
130
131
  .filter((correlation) => correlation.role === ResourceCorrelationRole.Primary)
131
132
  .map((correlation) => correlation.workItemId);
132
133
  for (const workflow of await orchestration.listAll()) {
133
134
  if (!workItemIds.includes(workflow.workItemId))
134
135
  continue;
135
- if (workflow.waitingFor === undefined)
136
- continue;
137
- await orchestration.acceptSignal(workflow.workflowInstanceId, {
138
- kind: workflow.waitingFor.signalKind,
139
- outcome,
140
- actorId: event.payload.actor.id,
141
- actorDecision: {
142
- authorized: event.payload.actor.kind === ReviewActorKind.Human,
143
- evidenceId: event.eventId,
144
- },
145
- providerEventId: event.eventId,
146
- }, commandContext(event));
136
+ if (workflow.waitingFor !== undefined) {
137
+ await orchestration.acceptSignal(workflow.workflowInstanceId, {
138
+ kind: workflow.waitingFor.signalKind,
139
+ outcome,
140
+ actorId: event.payload.actor.id,
141
+ actorDecision: {
142
+ authorized: event.payload.actor.kind === ReviewActorKind.Human,
143
+ evidenceId: event.eventId,
144
+ },
145
+ providerEventId: event.eventId,
146
+ }, commandContext(event));
147
+ }
148
+ else if (resumeBlockedOnChanges) {
149
+ await orchestration.resumeBlockedStageForChanges(workflow.workflowInstanceId, commandContext(event));
150
+ }
147
151
  }
148
152
  }
149
153
  function isWakeDelivery(body) {
@@ -138,6 +138,7 @@ export class InboundTranslator {
138
138
  BuiltInResourceCapability.Commentable,
139
139
  BuiltInResourceCapability.Reviewable,
140
140
  BuiltInResourceCapability.Revisioned,
141
+ BuiltInResourceCapability.ChangedFiles,
141
142
  ]
142
143
  : [BuiltInResourceCapability.Commentable],
143
144
  objective: payload.title,
@@ -6,5 +6,6 @@ export function observePullRequest(resourceId, workItemId, payload) {
6
6
  headRevision: payload.headRevision ?? payload.revision,
7
7
  baseRevision: payload.baseRevision ?? 'unknown',
8
8
  checks: payload.checks ?? 'unknown',
9
+ ...(payload.changedFiles === undefined ? {} : { changedFiles: payload.changedFiles }),
9
10
  };
10
11
  }
@@ -67,6 +67,7 @@ const eventSchema = z.discriminatedUnion('eventType', [
67
67
  actor: actorSchema,
68
68
  labels: z.array(z.string()).readonly().optional(),
69
69
  assignees: z.array(z.string()).readonly().optional(),
70
+ changedFiles: z.array(z.string()).readonly().optional(),
70
71
  raw: rawSchema,
71
72
  })
72
73
  .strict(),
@@ -89,6 +89,20 @@ export async function listReviewComments(octokit, cache, owner, repo, pullNumber
89
89
  ...(comment.side === undefined ? {} : { side: comment.side }),
90
90
  }));
91
91
  }
92
+ export async function listPullRequestFiles(octokit, cache, owner, repo, pullNumber) {
93
+ const files = await fetchPaginatedWithEtag({
94
+ cache,
95
+ key: `pull-files:${owner}/${repo}#${pullNumber}`,
96
+ pages: (headers) => octokit.paginate.iterator(octokit.rest.pulls.listFiles, {
97
+ owner,
98
+ repo,
99
+ pull_number: pullNumber,
100
+ per_page: 100,
101
+ ...(headers === undefined ? {} : { headers }),
102
+ }),
103
+ });
104
+ return files.map((file) => file.filename);
105
+ }
92
106
  export function listCheckRunsForRef(octokit, cache, owner, repo, ref) {
93
107
  return fetchPaginatedWithEtag({
94
108
  cache,
@@ -1,7 +1,7 @@
1
1
  import { Octokit } from '@octokit/rest';
2
2
  import { MergeMethod } from '../../../activities/index.js';
3
3
  import { GitHubOutboundAction } from '../contracts/vocabulary.js';
4
- import { branch, getCombinedStatusForRef, getIssueLabels, getPullRequest, listCheckRunsForRef, listIssueComments, listIssues, listPullRequests, listReviewComments, listReviews, } from './client-reads.js';
4
+ import { branch, getCombinedStatusForRef, getIssueLabels, getPullRequest, listCheckRunsForRef, listIssueComments, listIssues, listPullRequestFiles, listPullRequests, listReviewComments, listReviews, } from './client-reads.js';
5
5
  import { createEtagCache } from './etag-cache.js';
6
6
  // Octokit's request-log plugin reports every non-2xx response through this
7
7
  // callback, including the expected 304 responses produced by conditional ETag
@@ -50,6 +50,7 @@ export function createGitHubClient(token) {
50
50
  getPullRequest: (owner, repo, pullNumber) => getPullRequest(octokit, cache, owner, repo, pullNumber),
51
51
  listReviews: (owner, repo, pullNumber, pageSize) => listReviews(octokit, cache, owner, repo, pullNumber, pageSize),
52
52
  listCheckRunsForRef: (owner, repo, ref) => listCheckRunsForRef(octokit, cache, owner, repo, ref),
53
+ listPullRequestFiles: (owner, repo, pullNumber) => listPullRequestFiles(octokit, cache, owner, repo, pullNumber),
53
54
  getCombinedStatusForRef: (owner, repo, ref) => getCombinedStatusForRef(octokit, cache, owner, repo, ref),
54
55
  branch: (owner, repo, name) => branch(octokit, cache, owner, repo, name),
55
56
  deliver: (command) => deliver(octokit, command),
@@ -17,11 +17,15 @@ export function createGitHubPullRequestSource(input) {
17
17
  return mapConcurrent(pullRequests, input.maxConcurrency ?? 4, async (pullRequest) => {
18
18
  signal.throwIfAborted();
19
19
  const headRevision = fallback(pullRequest.head?.sha, pullRequest.updated_at);
20
- const evidence = await readCheckEvidence(input.client, owner, repo, headRevision);
20
+ const [evidence, changedFiles] = await Promise.all([
21
+ readCheckEvidence(input.client, owner, repo, headRevision),
22
+ readChangedFiles(input.client, owner, repo, pullRequest.number),
23
+ ]);
21
24
  return pullRequestObservation({
22
25
  repository: input.repository,
23
26
  pullRequest,
24
27
  evidence,
28
+ changedFiles,
25
29
  ...(input.adapter === undefined ? {} : { adapter: input.adapter }),
26
30
  });
27
31
  });
@@ -54,6 +58,7 @@ function pullRequestObservation(input) {
54
58
  },
55
59
  labels: gitHubLabelNames(pullRequest),
56
60
  assignees: gitHubAssigneeLogins(pullRequest),
61
+ ...(input.changedFiles === undefined ? {} : { changedFiles: input.changedFiles }),
57
62
  raw: {
58
63
  number: pullRequest.number,
59
64
  checkRuns: boundedDiagnosticEvidence(input.evidence.checkRuns),
@@ -98,6 +103,14 @@ async function readCheckEvidence(client, owner, repo, headRevision) {
98
103
  return { available: false, checkRuns: [], statuses: [] };
99
104
  }
100
105
  }
106
+ async function readChangedFiles(client, owner, repo, pullNumber) {
107
+ try {
108
+ return await client.listPullRequestFiles(owner, repo, pullNumber);
109
+ }
110
+ catch {
111
+ return undefined;
112
+ }
113
+ }
101
114
  async function mapConcurrent(values, concurrency, transform) {
102
115
  const output = new Array(values.length);
103
116
  let nextIndex = 0;
@@ -54,6 +54,7 @@ export const gitHubProviderDefinition = {
54
54
  BuiltInResourceCapability.Approvable,
55
55
  BuiltInResourceCapability.Mergeable,
56
56
  BuiltInResourceCapability.Revisioned,
57
+ BuiltInResourceCapability.ChangedFiles,
57
58
  ],
58
59
  ...(pullRequest.head.sha === undefined ? {} : { revision: pullRequest.head.sha }),
59
60
  };
@@ -11,14 +11,17 @@ export class AcceptActivityOutcome {
11
11
  }
12
12
  async execute(command, context) {
13
13
  const loaded = await this.repository.loadRequired(command.workflowInstanceId);
14
- const decision = decideActivityOutcome(this.workflows.definition(loaded.view.workflowName), loaded.view, {
15
- ...command,
16
- outcome: orchestrationActivityOutcome(command.outcome),
17
- occurredAt: context.occurredAt,
18
- causationId: context.commandId,
19
- });
20
- if (decision.kind === 'append')
21
- await this.repository.append(command.workflowInstanceId, loaded.sequence, decision.events);
14
+ const definition = await this.workflows.definitionForOperation(loaded.view, loaded.sequence, context);
15
+ if (definition !== null) {
16
+ const decision = decideActivityOutcome(definition, loaded.view, {
17
+ ...command,
18
+ outcome: orchestrationActivityOutcome(command.outcome),
19
+ occurredAt: context.occurredAt,
20
+ causationId: context.commandId,
21
+ });
22
+ if (decision.kind === 'append')
23
+ await this.repository.append(command.workflowInstanceId, loaded.sequence, decision.events);
24
+ }
22
25
  await this.reconcileChildCompletions(context);
23
26
  return (await this.repository.loadRequired(command.workflowInstanceId)).view;
24
27
  }
@@ -22,7 +22,10 @@ export class AcceptSignal {
22
22
  async execute(workflowInstanceId, signal, context) {
23
23
  const loaded = await this.repository.loadRequired(workflowInstanceId);
24
24
  const item = await this.work.get(loaded.view.workItemId);
25
- const decision = decideSignal(this.workflows.definition(loaded.view.workflowName), loaded.view, {
25
+ const definition = await this.workflows.definitionForOperation(loaded.view, loaded.sequence, context);
26
+ if (definition === null)
27
+ return (await this.repository.loadRequired(workflowInstanceId)).view;
28
+ const decision = decideSignal(definition, loaded.view, {
26
29
  signal,
27
30
  occurredAt: context.occurredAt,
28
31
  causationId: context.commandId,
@@ -4,7 +4,7 @@ import { OrchestrationEventType } from '../contracts/events.js';
4
4
  import { commandName, workflowInstanceId, } from '../contracts/identifiers.js';
5
5
  import { workflowInstanceStream } from '../contracts/streams.js';
6
6
  import { WorkflowStatus } from '../contracts/vocabulary.js';
7
- import { requestOperatorRetry as decideOperatorRetry, requestSupplementalActivity as decideSupplementalActivity, } from '../domain/interpreter.js';
7
+ import { requestChangesResume as decideChangesResume, requestOperatorRetry as decideOperatorRetry, requestSupplementalActivity as decideSupplementalActivity, } from '../domain/interpreter.js';
8
8
  import { isAuthorisedActor } from '../domain/supplemental-policy.js';
9
9
  export class OperatorRetryIneligibleError extends Error {
10
10
  constructor(detail) {
@@ -21,7 +21,10 @@ export class AdvanceWorkflow {
21
21
  }
22
22
  async requestSupplementalActivity(id, request, context) {
23
23
  const loaded = await this.repository.loadRequired(id);
24
- const configured = this.workflows.definition(loaded.view.workflowName).commands[commandName(request.command)];
24
+ const definition = await this.workflows.definitionForOperation(loaded.view, loaded.sequence, context);
25
+ if (definition === null)
26
+ return (await this.repository.loadRequired(id)).view;
27
+ const configured = definition.commands[commandName(request.command)];
25
28
  if (configured === undefined)
26
29
  throw new Error(`Unknown supplemental command: ${request.command}`);
27
30
  if (!isAuthorisedActor(configured.allowedActors, context.actor.kind))
@@ -112,7 +115,10 @@ export class AdvanceWorkflow {
112
115
  throw new OperatorRetryIneligibleError('WorkflowInstance does not exist');
113
116
  if (loaded.view.operatorRetryCommandIds.includes(context.commandId))
114
117
  return loaded.view;
115
- const decision = decideOperatorRetry(this.workflows.definition(loaded.view.workflowName), loaded.view, {
118
+ const definition = await this.workflows.definitionForOperation(loaded.view, loaded.sequence, context);
119
+ if (definition === null)
120
+ return (await this.repository.loadRequired(id)).view;
121
+ const decision = decideOperatorRetry(definition, loaded.view, {
116
122
  commandId: context.commandId,
117
123
  occurredAt: context.occurredAt,
118
124
  causationId: context.commandId,
@@ -130,34 +136,73 @@ export class AdvanceWorkflow {
130
136
  }
131
137
  return (await this.repository.loadRequired(id)).view;
132
138
  }
139
+ async resumeBlockedStageForChanges(id, context) {
140
+ const loaded = await this.repository.load(id);
141
+ if (loaded.view === null)
142
+ return null;
143
+ if (loaded.view.operatorRetryCommandIds.includes(context.commandId))
144
+ return loaded.view;
145
+ const definition = await this.workflows.definitionForOperation(loaded.view, loaded.sequence, context);
146
+ if (definition === null)
147
+ return (await this.repository.loadRequired(id)).view;
148
+ const decision = decideChangesResume(definition, loaded.view, {
149
+ commandId: context.commandId,
150
+ occurredAt: context.occurredAt,
151
+ causationId: context.commandId,
152
+ });
153
+ if (decision.kind === 'ignored')
154
+ return loaded.view;
155
+ try {
156
+ await this.repository.append(id, loaded.sequence, decision.events);
157
+ }
158
+ catch (error) {
159
+ const reloaded = await this.repository.loadRequired(id);
160
+ if (reloaded.view.operatorRetryCommandIds.includes(context.commandId))
161
+ return reloaded.view;
162
+ throw error;
163
+ }
164
+ return (await this.repository.loadRequired(id)).view;
165
+ }
133
166
  async get(id) {
134
167
  return (await this.repository.load(id)).view;
135
168
  }
136
169
  async listPendingActivations(workItemId) {
137
- return (await this.repository.list())
138
- .filter((view) => view !== null &&
139
- view.status === WorkflowStatus.Active &&
170
+ return (await this.listAllLoaded())
171
+ .filter(({ view }) => view.status === WorkflowStatus.Active &&
140
172
  view.pendingActivation !== undefined &&
141
173
  (workItemId === undefined || view.workItemId === workItemId))
142
- .map((view) => ({ workflow: view, activation: view.pendingActivation }));
174
+ .map(({ view }) => ({ workflow: view, activation: view.pendingActivation }));
143
175
  }
144
176
  async listWaiting(signalKind) {
145
- return (await this.repository.list()).filter((view) => view?.status === WorkflowStatus.Waiting &&
146
- (signalKind === undefined || view.waitingFor?.signalKind === signalKind));
177
+ return (await this.listAllLoaded())
178
+ .filter(({ view }) => view.status === WorkflowStatus.Waiting &&
179
+ (signalKind === undefined || view.waitingFor?.signalKind === signalKind))
180
+ .map(({ view }) => view);
147
181
  }
148
182
  async listAll() {
149
- return (await this.repository.list()).filter((view) => view !== null);
183
+ return (await this.listAllLoaded()).map(({ view }) => view);
150
184
  }
151
- async listWatchMatches(event) {
152
- return (await this.listAll()).flatMap((parent) => {
153
- const definition = this.workflows.definition(parent.workflowName);
185
+ // One shared load per live instance, reused for both the watch match and
186
+ // (when a match needs blocking) the append sequence — avoids reloading
187
+ // every instance a second time just to recover its sequence.
188
+ async listAllLoaded() {
189
+ return (await this.repository.list()).filter((loaded) => loaded.view !== null);
190
+ }
191
+ 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 [];
154
198
  return definition.watches
155
199
  .filter((watch) => watch.on?.events.includes(event.eventType) === true &&
156
200
  watch.while.stages.includes(parent.currentStage) &&
157
201
  watch.while.statuses.some((status) => status === parent.status) &&
158
202
  matchesWatchPredicate(watch.where, event))
159
203
  .map((watch) => ({ parent, watch }));
160
- });
204
+ }));
205
+ return matches.flat();
161
206
  }
162
207
  }
163
208
  function matchesWatchPredicate(predicate, event) {
@@ -28,7 +28,7 @@ export class OrchestrationRepository {
28
28
  const ids = new Set((await this.journal.readAll(0))
29
29
  .filter((event) => isWorkflowInstanceStream(event.stream))
30
30
  .map((event) => event.stream.id));
31
- return Promise.all([...ids].map(async (id) => (await this.load(id)).view));
31
+ return Promise.all([...ids].map((id) => this.load(id)));
32
32
  }
33
33
  }
34
34
  function isWorkflowEvent(event) {
@@ -6,16 +6,17 @@ import { GroupBudgetRecorder } from './group-budget-recorder.js';
6
6
  import { OrchestrationRepository } from './orchestration-repository.js';
7
7
  import { RequestChild } from './request-child.js';
8
8
  import { StartWorkflow } from './start-workflow.js';
9
+ import { WorkflowDefinitionRegistry } from './workflow-definition-registry.js';
9
10
  export class OrchestrationService {
10
11
  startWorkflow;
11
12
  acceptActivityOutcome;
12
13
  acceptWorkflowSignal;
13
14
  advanceWorkflow;
14
15
  childWorkflows;
15
- constructor(journal, work, definitions) {
16
+ constructor(journal, work, definitions, projections) {
16
17
  const repository = new OrchestrationRepository(journal);
17
18
  const claims = new CoordinationClaims(journal);
18
- this.startWorkflow = new StartWorkflow(repository, claims, work, definitions);
19
+ this.startWorkflow = new StartWorkflow(repository, claims, work, new WorkflowDefinitionRegistry(journal, projections, definitions));
19
20
  this.acceptWorkflowSignal = new AcceptSignal(repository, this.startWorkflow, work);
20
21
  this.advanceWorkflow = new AdvanceWorkflow(repository, this.startWorkflow);
21
22
  this.childWorkflows = new RequestChild(repository, claims, new GroupBudgetRecorder(journal), this.startWorkflow, this.advanceWorkflow);
@@ -54,6 +55,9 @@ export class OrchestrationService {
54
55
  retryBlockedFailedStage(workflowInstanceId, context) {
55
56
  return this.advanceWorkflow.retryBlockedFailedStage(workflowInstanceId, context);
56
57
  }
58
+ resumeBlockedStageForChanges(workflowInstanceId, context) {
59
+ return this.advanceWorkflow.resumeBlockedStageForChanges(workflowInstanceId, context);
60
+ }
57
61
  get(id) {
58
62
  return this.advanceWorkflow.get(id);
59
63
  }
@@ -75,8 +79,8 @@ export class OrchestrationService {
75
79
  isCausalRepeat(workflowInstanceId, triggerId, causalCycleId, requestId) {
76
80
  return this.childWorkflows.isCausalRepeat(workflowInstanceId, triggerId, causalCycleId, requestId);
77
81
  }
78
- listWatchMatches(event) {
79
- return this.advanceWorkflow.listWatchMatches(event);
82
+ listWatchMatches(event, context) {
83
+ return this.advanceWorkflow.listWatchMatches(event, context);
80
84
  }
81
85
  }
82
- export const createOrchestrationService = (journal, work, definitions) => new OrchestrationService(journal, work, definitions);
86
+ export const createOrchestrationService = (journal, work, definitions, projections) => new OrchestrationService(journal, work, definitions, projections);
@@ -94,7 +94,10 @@ export class RequestChild {
94
94
  childWorkflowInstanceId: child.workflowInstanceId,
95
95
  requestId: metadata.requestId,
96
96
  };
97
- const decision = decideSignal(this.workflows.definition(parent.view.workflowName), parent.view, {
97
+ const definition = await this.workflows.definitionForOperation(parent.view, parent.sequence, context);
98
+ if (definition === null)
99
+ return;
100
+ const decision = decideSignal(definition, parent.view, {
98
101
  signal,
99
102
  occurredAt: context.occurredAt,
100
103
  causationId: context.commandId,
@@ -1,7 +1,16 @@
1
+ import { createEventDraft, EventSourceKind } from '../../kernel/index.js';
1
2
  import { WorkStatus } from '../../work/index.js';
2
- import { WorkflowInstanceKind } from '../contracts/vocabulary.js';
3
+ import { OrchestrationEventType } from '../contracts/events.js';
4
+ import { workflowInstanceStream } from '../contracts/streams.js';
5
+ import { WorkflowInstanceKind, WorkflowStatus } from '../contracts/vocabulary.js';
3
6
  import { validateChildProvenance } from '../domain/child-policy.js';
4
7
  import { startInstance } from '../domain/interpreter.js';
8
+ import { WorkflowDefinitionUnavailableError } from './workflow-definition-registry.js';
9
+ const TERMINAL_OR_BLOCKED_STATUSES = new Set([
10
+ WorkflowStatus.Blocked,
11
+ WorkflowStatus.Completed,
12
+ WorkflowStatus.Superseded,
13
+ ]);
5
14
  export class StartWorkflow {
6
15
  repository;
7
16
  claims;
@@ -17,7 +26,7 @@ export class StartWorkflow {
17
26
  const item = await this.work.get(command.workItemId);
18
27
  if (item === null || item.state !== WorkStatus.Open)
19
28
  throw new Error('WorkItem must exist and be open');
20
- const definition = this.definition(command.workflowName);
29
+ const { definition, fingerprint } = this.definitions.currentDefinition(command.workflowName);
21
30
  const existing = await this.repository.load(command.workflowInstanceId);
22
31
  if (existing.view !== null)
23
32
  return existing.view;
@@ -31,9 +40,11 @@ export class StartWorkflow {
31
40
  parent.view.orchestrationGroupId !== command.orchestrationGroupId)
32
41
  throw new Error('Child workflow must share its parent WorkItem and orchestration group');
33
42
  }
43
+ await this.definitions.register(command.workflowName, fingerprint, definition, context);
34
44
  const decision = startInstance({
35
45
  ...command,
36
46
  definition,
47
+ workflowDefinitionFingerprint: fingerprint,
37
48
  occurredAt: context.occurredAt,
38
49
  correlationId: context.correlationId,
39
50
  causationId: context.commandId,
@@ -42,10 +53,34 @@ export class StartWorkflow {
42
53
  await this.repository.append(command.workflowInstanceId, 0, decision.events);
43
54
  return (await this.repository.loadRequired(command.workflowInstanceId)).view;
44
55
  }
45
- definition(name) {
46
- const definition = this.definitions[name];
47
- if (definition === undefined)
48
- throw new Error(`Unknown workflow: ${name}`);
49
- return definition;
56
+ definitionFor(view) {
57
+ return this.definitions.resolve(view);
58
+ }
59
+ async definitionForOperation(view, sequence, context) {
60
+ try {
61
+ return await this.definitions.resolve(view);
62
+ }
63
+ catch (error) {
64
+ if (!(error instanceof WorkflowDefinitionUnavailableError))
65
+ throw error;
66
+ if (!TERMINAL_OR_BLOCKED_STATUSES.has(view.status))
67
+ await this.repository.append(view.workflowInstanceId, sequence, [
68
+ createEventDraft({
69
+ // workflowInstanceId is included because listWatchMatches shares one
70
+ // CommandContext across every matched parent in a batch; commandId
71
+ // alone would collide when two instances block in the same pass.
72
+ eventId: `${context.commandId}:${view.workflowInstanceId}:${OrchestrationEventType.InstanceBlocked}`,
73
+ eventType: OrchestrationEventType.InstanceBlocked,
74
+ occurredAt: context.occurredAt,
75
+ correlationId: context.correlationId,
76
+ causationId: context.commandId,
77
+ actor: context.actor,
78
+ source: { kind: EventSourceKind.Internal, id: 'orchestration-service' },
79
+ stream: workflowInstanceStream(view.workflowInstanceId),
80
+ payload: { reason: 'workflow-definition-unavailable' },
81
+ }),
82
+ ]);
83
+ return null;
84
+ }
50
85
  }
51
86
  }
@@ -8,7 +8,7 @@ export function createWatchReactor(orchestration, journal, checkpoints, runs) {
8
8
  async react(event, context) {
9
9
  const causalCycle = orchestrationCausalCycleId(selectOrchestrationEvent(event));
10
10
  const sourceWorkflowInstanceId = await resolveRunWorkflowInstanceId(event, runs);
11
- for (const match of await orchestration.listWatchMatches(event)) {
11
+ for (const match of await orchestration.listWatchMatches(event, context)) {
12
12
  if (sourceWorkflowInstanceId !== undefined &&
13
13
  match.parent.workflowInstanceId !== sourceWorkflowInstanceId)
14
14
  continue;
@@ -0,0 +1,108 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { createEventDraft, EventActorKind, EventSourceKind, WrongExpectedSequenceError, } from '../../kernel/index.js';
3
+ import { selectOrchestrationEvent } from '../contracts/event-decoder.js';
4
+ import { OrchestrationEventType } from '../contracts/events.js';
5
+ import { OrchestrationStreamKind, workflowDefinitionsStream } from '../contracts/streams.js';
6
+ export const workflowDefinitionFingerprint = (definition) => createHash('sha256').update(JSON.stringify(definition)).digest('hex');
7
+ export const workflowDefinitionKey = (name, fingerprint) => `${name}:${fingerprint}`;
8
+ export const workflowDefinitionsProjection = {
9
+ name: OrchestrationStreamKind.WorkflowDefinitions,
10
+ select(event) {
11
+ const owned = selectOrchestrationEvent(event);
12
+ return owned?.eventType === OrchestrationEventType.WorkflowDefinitionRegistered
13
+ ? { key: workflowDefinitionKey(owned.payload.workflowName, owned.payload.fingerprint) }
14
+ : null;
15
+ },
16
+ initial: () => undefined,
17
+ project(_previous, event) {
18
+ const owned = selectOrchestrationEvent(event);
19
+ if (owned?.eventType !== OrchestrationEventType.WorkflowDefinitionRegistered)
20
+ throw new Error('Expected workflow definition registration');
21
+ return owned.payload.compiledDefinition;
22
+ },
23
+ };
24
+ export class WorkflowDefinitionUnavailableError extends Error {
25
+ constructor() {
26
+ super('workflow-definition-unavailable');
27
+ this.name = 'WorkflowDefinitionUnavailableError';
28
+ }
29
+ }
30
+ export class WorkflowDefinitionRegistry {
31
+ journal;
32
+ projections;
33
+ current;
34
+ // WorkflowDefinitionRegistered events are never retracted, so once a
35
+ // (name, fingerprint) pair is observed registered it stays registered for
36
+ // the life of this process — caching it skips the shared-stream scan that
37
+ // register() would otherwise repeat on every workflow/child start.
38
+ knownRegistered = new Set();
39
+ constructor(journal, projections, current) {
40
+ this.journal = journal;
41
+ this.projections = projections;
42
+ this.current = current;
43
+ }
44
+ currentDefinition(name) {
45
+ const definition = this.current[name];
46
+ if (definition === undefined)
47
+ throw new Error(`Unknown workflow: ${name}`);
48
+ return { definition, fingerprint: workflowDefinitionFingerprint(definition) };
49
+ }
50
+ async register(name, fingerprint, definition, context) {
51
+ if (await this.isRegistered(name, fingerprint))
52
+ return;
53
+ const stream = workflowDefinitionsStream();
54
+ const draft = createEventDraft({
55
+ eventId: `workflow-definition:${name}:${fingerprint}`,
56
+ eventType: OrchestrationEventType.WorkflowDefinitionRegistered,
57
+ occurredAt: context.occurredAt,
58
+ correlationId: context.correlationId,
59
+ causationId: context.commandId,
60
+ actor: { kind: EventActorKind.System, id: 'orchestration' },
61
+ source: { kind: EventSourceKind.Internal, id: 'orchestration' },
62
+ stream,
63
+ payload: { workflowName: name, fingerprint, compiledDefinition: definition },
64
+ });
65
+ // The deterministic event id makes retries idempotent; CAS protects the
66
+ // one shared registration stream when different definitions race to start.
67
+ for (;;) {
68
+ const sequence = (await this.journal.readStream(stream)).length;
69
+ try {
70
+ await this.journal.append(stream, sequence, [draft]);
71
+ this.knownRegistered.add(workflowDefinitionKey(name, fingerprint));
72
+ return;
73
+ }
74
+ catch (error) {
75
+ if (await this.isRegistered(name, fingerprint))
76
+ return;
77
+ if (!(error instanceof WrongExpectedSequenceError))
78
+ throw error;
79
+ }
80
+ }
81
+ }
82
+ async isRegistered(name, fingerprint) {
83
+ const key = workflowDefinitionKey(name, fingerprint);
84
+ if (this.knownRegistered.has(key))
85
+ return true;
86
+ const found = (await this.journal.readStream(workflowDefinitionsStream())).some((event) => {
87
+ const owned = selectOrchestrationEvent(event);
88
+ return (owned?.eventType === OrchestrationEventType.WorkflowDefinitionRegistered &&
89
+ owned.payload.workflowName === name &&
90
+ owned.payload.fingerprint === fingerprint);
91
+ });
92
+ if (found)
93
+ this.knownRegistered.add(key);
94
+ return found;
95
+ }
96
+ async resolve(view) {
97
+ if (view.workflowDefinitionFingerprint === undefined)
98
+ return this.currentDefinition(view.workflowName).definition;
99
+ const current = this.current[view.workflowName];
100
+ if (current !== undefined &&
101
+ workflowDefinitionFingerprint(current) === view.workflowDefinitionFingerprint)
102
+ return current;
103
+ const stored = await this.projections?.read(workflowDefinitionsProjection.name, workflowDefinitionKey(view.workflowName, view.workflowDefinitionFingerprint));
104
+ if (stored === null || stored === undefined)
105
+ throw new WorkflowDefinitionUnavailableError();
106
+ return stored.value;
107
+ }
108
+ }
@@ -29,6 +29,12 @@ export const childGroupStreamSchema = z
29
29
  id: childGroupIdSchema,
30
30
  })
31
31
  .strict();
32
+ const workflowDefinitionsStreamSchema = z
33
+ .object({
34
+ kind: z.literal(OrchestrationStreamKind.WorkflowDefinitions),
35
+ id: z.literal('registry'),
36
+ })
37
+ .strict();
32
38
  export const childMetadataShape = {
33
39
  parentWorkflowInstanceId: workflowInstanceIdSchema,
34
40
  watchId: z.string().min(1),
@@ -169,6 +175,7 @@ const eventSchema = z.discriminatedUnion('eventType', [
169
175
  workflowName: brandedStringSchema(workflowName),
170
176
  orchestrationGroupId: brandedStringSchema(orchestrationGroupId),
171
177
  entry: brandedStringSchema(stageName),
178
+ workflowDefinitionFingerprint: z.string().min(1).optional(),
172
179
  })
173
180
  .strict(),
174
181
  z
@@ -176,10 +183,22 @@ const eventSchema = z.discriminatedUnion('eventType', [
176
183
  workItemId: brandedStringSchema(workItemId),
177
184
  workflowName: brandedStringSchema(workflowName),
178
185
  entry: brandedStringSchema(stageName),
186
+ workflowDefinitionFingerprint: z.string().min(1).optional(),
179
187
  ...childMetadataShape,
180
188
  })
181
189
  .strict(),
182
190
  ])),
191
+ eventEnvelopeSchema.extend({
192
+ eventType: z.literal(OrchestrationEventType.WorkflowDefinitionRegistered),
193
+ stream: workflowDefinitionsStreamSchema,
194
+ payload: z
195
+ .object({
196
+ workflowName: brandedStringSchema(workflowName),
197
+ fingerprint: z.string().min(1),
198
+ compiledDefinition: z.custom(),
199
+ })
200
+ .strict(),
201
+ }),
183
202
  workflowEnvelope(OrchestrationEventType.StageEntered, z.object({ stage: brandedStringSchema(stageName) }).strict()),
184
203
  workflowEnvelope(OrchestrationEventType.ActivityRequested, activityRequestedSchema),
185
204
  workflowEnvelope(OrchestrationEventType.ActivityStarted, z.object({ activationId: brandedStringSchema(activationId) }).strict()),
@@ -241,6 +260,8 @@ export function decodeOrchestrationEvent(event) {
241
260
  const result = eventSchema.safeParse(event);
242
261
  if (!result.success)
243
262
  throw invalidOrchestrationEvent(event, result.error);
263
+ // Zod represents optional object members as `T | undefined`; the durable
264
+ // contract uses exact optional members for compatibility with old events.
244
265
  return result.data;
245
266
  }
246
267
  export function selectOrchestrationEvent(event) {
@@ -253,6 +274,7 @@ export function selectWorkflowOrchestrationEvent(event) {
253
274
  switch (owned.eventType) {
254
275
  case OrchestrationEventType.PrimaryClaimed:
255
276
  case OrchestrationEventType.GroupClaimed:
277
+ case OrchestrationEventType.WorkflowDefinitionRegistered:
256
278
  return null;
257
279
  default:
258
280
  return owned;
@@ -1,5 +1,6 @@
1
1
  import { signalName } from './identifiers.js';
2
2
  export const OrchestrationEventType = {
3
+ WorkflowDefinitionRegistered: 'orchestration.workflow-definition-registered',
3
4
  InstanceStarted: 'orchestration.instance-started',
4
5
  StageEntered: 'orchestration.stage-entered',
5
6
  ActivityRequested: 'orchestration.activity-requested',
@@ -1,7 +1,12 @@
1
1
  export const OrchestrationStreamKind = {
2
2
  WorkflowInstance: 'workflow-instance',
3
3
  Group: 'orchestration-group',
4
+ WorkflowDefinitions: 'workflow-definitions',
4
5
  };
6
+ export const workflowDefinitionsStream = () => ({
7
+ kind: OrchestrationStreamKind.WorkflowDefinitions,
8
+ id: 'registry',
9
+ });
5
10
  export const orchestrationGroupStreamId = (value) => {
6
11
  return value.startsWith('primary:')
7
12
  ? primaryOrchestrationGroupStreamId(value)
@@ -23,6 +23,9 @@ export function startInstance(input) {
23
23
  workflowName: input.definition.name,
24
24
  orchestrationGroupId: input.orchestrationGroupId,
25
25
  entry: input.definition.entry,
26
+ ...(input.workflowDefinitionFingerprint === undefined
27
+ ? {}
28
+ : { workflowDefinitionFingerprint: input.workflowDefinitionFingerprint }),
26
29
  ...child,
27
30
  }, childEvents.length + 1),
28
31
  startDraft(input, OrchestrationEventType.StageEntered, { stage: input.definition.entry }, childEvents.length + 2),
@@ -10,7 +10,7 @@ import { finishRoute } from './transition.js';
10
10
  export { startInstance } from './activation-policy.js';
11
11
  export { acceptSignal, waitForSignal } from './signal-policy.js';
12
12
  export { requestSupplementalActivity } from './supplemental-policy.js';
13
- export { isOperatorRetryEligible, requestOperatorRetry } from './operator-retry-policy.js';
13
+ export { isChangesResumeEligible, isOperatorRetryEligible, requestChangesResume, requestOperatorRetry, } from './operator-retry-policy.js';
14
14
  export function acceptActivityOutcome(definition, state, input) {
15
15
  if (!isPendingOutcome(state, input))
16
16
  return { kind: 'ignored', reason: 'outcome is not for the pending activation' };
@@ -1,4 +1,4 @@
1
- import { ActivityOutcomeKind } from '../../activities/index.js';
1
+ import { ActivityOutcomeKind, BuiltInActivityName } from '../../activities/index.js';
2
2
  import { OrchestrationEventType } from '../contracts/events.js';
3
3
  import { stageName } from '../contracts/identifiers.js';
4
4
  import { ActivityActivationStatus, WorkflowStatus } from '../contracts/vocabulary.js';
@@ -14,6 +14,18 @@ export function isOperatorRetryEligible(view) {
14
14
  view.lastOutcome?.kind === ActivityOutcomeKind.Failed &&
15
15
  view.acceptedOutcomes.includes(pending.activationId));
16
16
  }
17
+ export function isChangesResumeEligible(view) {
18
+ const pending = view.pendingActivation;
19
+ return (view.status === WorkflowStatus.Blocked &&
20
+ view.blockReason === 'unconfigured outcome blocked' &&
21
+ pending !== undefined &&
22
+ pending.activity === BuiltInActivityName.Agent &&
23
+ pending.status === ActivityActivationStatus.Completed &&
24
+ pending.supplemental !== true &&
25
+ pending.followOnIndex === undefined &&
26
+ view.lastOutcome?.kind === ActivityOutcomeKind.Blocked &&
27
+ view.acceptedOutcomes.includes(pending.activationId));
28
+ }
17
29
  export function requestOperatorRetry(definition, state, input) {
18
30
  if (input.commandId.length === 0)
19
31
  return { kind: 'ignored', reason: 'operator retry command id is required' };
@@ -32,3 +44,23 @@ export function requestOperatorRetry(definition, state, input) {
32
44
  ];
33
45
  return { kind: 'append', events };
34
46
  }
47
+ export function requestChangesResume(definition, state, input) {
48
+ if (input.commandId.length === 0)
49
+ return { kind: 'ignored', reason: 'changes resume command id is required' };
50
+ if (!isChangesResumeEligible(state))
51
+ return {
52
+ kind: 'ignored',
53
+ reason: 'workflow is not blocked for an unconfigured agent blocked outcome',
54
+ };
55
+ const stage = definition.stages[stageName(state.currentStage)];
56
+ return {
57
+ kind: 'append',
58
+ events: [
59
+ stateDraft(state, input, OrchestrationEventType.OperatorRetryRequested, { activationId: state.pendingActivation.activationId, commandId: input.commandId }, 1),
60
+ stateDraft(state, input, OrchestrationEventType.ActivityRequested, activation(state.workflowInstanceId, nextOrdinal(state), stage.activity, stage.with, {
61
+ execution: stage.execution,
62
+ stage: stageName(state.currentStage),
63
+ }), 2),
64
+ ],
65
+ };
66
+ }
@@ -204,6 +204,9 @@ export function immutableWorkflowInstanceView(state) {
204
204
  workflowInstanceId: state.workflowInstanceId,
205
205
  workItemId: state.workItemId,
206
206
  workflowName: state.workflowName,
207
+ ...(state.workflowDefinitionFingerprint === undefined
208
+ ? {}
209
+ : { workflowDefinitionFingerprint: state.workflowDefinitionFingerprint }),
207
210
  orchestrationGroupId: state.orchestrationGroupId,
208
211
  ...(state.parentWorkflowInstanceId === undefined
209
212
  ? {}
@@ -10,6 +10,9 @@ export function foldWorkflowInstance(events) {
10
10
  workflowInstanceId: first.stream.id,
11
11
  workItemId: first.payload.workItemId,
12
12
  workflowName: first.payload.workflowName,
13
+ ...(first.payload.workflowDefinitionFingerprint === undefined
14
+ ? {}
15
+ : { workflowDefinitionFingerprint: first.payload.workflowDefinitionFingerprint }),
13
16
  orchestrationGroupId: first.payload.orchestrationGroupId,
14
17
  ...optionalChildFields(first.payload),
15
18
  status: WorkflowStatus.Active,
@@ -4,6 +4,7 @@ export * from './application/orchestration-service.js';
4
4
  export * from './application/advance-workflow.js';
5
5
  export * from './application/signal-reactor.js';
6
6
  export * from './application/watch-reactor.js';
7
+ export * from './application/workflow-definition-registry.js';
7
8
  export * from './contracts/activity-outcome.js';
8
9
  export * from './contracts/commands.js';
9
10
  export * from './contracts/config.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atolis-hq/wake",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
4
4
  "description": "Local autonomous agent control plane for software development",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {