@atolis-hq/wake 0.1.11 → 0.1.12

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.
@@ -1,6 +1,6 @@
1
1
  import { awaitingApprovalRunnerSentinel, failedRunnerSentinel } from '../domain/stages.js';
2
2
  import { resolveCustomCommand } from '../domain/custom-commands.js';
3
- import { builtInDefaultWorkflowDefinition, chooseAction as chooseWorkflowAction, } from '../domain/workflows.js';
3
+ import { builtInDefaultWorkflowDefinition, chooseAction as chooseWorkflowAction, selectWorkflowForEvent, } from '../domain/workflows.js';
4
4
  function isAwaitingApproval(issue) {
5
5
  const context = issue.context;
6
6
  return context.lastRunSentinel === awaitingApprovalRunnerSentinel;
@@ -57,6 +57,10 @@ export function createPolicyEngine() {
57
57
  if (issue.issue.state !== 'open') {
58
58
  return false;
59
59
  }
60
+ const context = issue.context;
61
+ if (config.workflowSelectors.length > 0) {
62
+ return typeof context.workflow === 'string';
63
+ }
60
64
  // Defense-in-depth: the issues source filters PR-shaped items at poll
61
65
  // time, so no NEW projection can ever have isPullRequest: true. But a
62
66
  // pre-existing state/<workId>.json written by a pre-this-branch
@@ -105,7 +109,7 @@ export function createPolicyEngine() {
105
109
  chooseAction(issue, workflow = builtInDefaultWorkflowDefinition) {
106
110
  return chooseWorkflowAction(issue, workflow)?.action ?? null;
107
111
  },
108
- chooseRetryActionAfterHumanReply(issue) {
112
+ chooseRetryActionAfterHumanReply(issue, workflow = builtInDefaultWorkflowDefinition) {
109
113
  const context = issue.context;
110
114
  const failed = context.lastRunSentinel === failedRunnerSentinel;
111
115
  const blocked = context.lastRunSentinel === 'BLOCKED';
@@ -118,7 +122,16 @@ export function createPolicyEngine() {
118
122
  if (latestUnhandledHumanComment(issue) === undefined) {
119
123
  return null;
120
124
  }
121
- return typeof context.lastRunAction === 'string' ? context.lastRunAction : null;
125
+ if (typeof context.blockedFromStage !== 'string') {
126
+ return null;
127
+ }
128
+ return (chooseWorkflowAction({
129
+ ...issue,
130
+ wake: {
131
+ ...issue.wake,
132
+ stage: context.blockedFromStage,
133
+ },
134
+ }, workflow)?.action ?? null);
122
135
  },
123
136
  resolveApprovalTransition(issue) {
124
137
  if (!isAwaitingApproval(issue)) {
@@ -179,6 +192,9 @@ export function createPolicyEngine() {
179
192
  return false;
180
193
  }
181
194
  const kind = resourceUri.split(':')[1];
195
+ if (config.workflowSelectors.length > 0) {
196
+ return selectWorkflowForEvent(unresolved, config) !== null;
197
+ }
182
198
  if (kind === 'issue') {
183
199
  // Real github source stamps payload.ticket (sourceEventType
184
200
  // 'ticket.upsert'); the fake ticketing harness stamps payload.issue
@@ -1,8 +1,9 @@
1
1
  import { CORRELATION_PRIMARY_CONFLICT_EVENT, CORRELATION_REGISTERED_EVENT, CORRELATION_RETRACTED_EVENT, UNRESOLVED_WORK_ITEM_KEY, parseIssueStateRecord, } from '../domain/schema.js';
2
2
  import { doneRunnerSentinel, stageFromLabels } from '../domain/stages.js';
3
- import { builtInDefaultWorkflowDefinition, defaultWorkflowName, workflowStageVocabulary, } from '../domain/workflows.js';
3
+ import { builtInDefaultWorkflowDefinition, defaultWorkflowName, selectWorkflowForEvent, workflowStageVocabulary, } from '../domain/workflows.js';
4
4
  import { isCustomCommandAction } from '../domain/custom-commands.js';
5
5
  import { createEventEnvelope } from '../lib/event-log.js';
6
+ const WORKFLOW_SELECTED_EVENT = 'wake.workflow.selected';
6
7
  function stringArrayFromPayload(value) {
7
8
  return Array.isArray(value)
8
9
  ? value.filter((entry) => typeof entry === 'string')
@@ -37,6 +38,33 @@ function createProjectionFromIssueEvent(event, config) {
37
38
  context: {},
38
39
  });
39
40
  }
41
+ async function pinSelectedWorkflow(projection, event, ctx, config) {
42
+ const context = projection.context;
43
+ if (config === undefined || typeof context.workflow === 'string') {
44
+ return projection;
45
+ }
46
+ const workflow = selectWorkflowForEvent(event, config);
47
+ if (workflow === null) {
48
+ return projection;
49
+ }
50
+ const selectedEvent = await ctx.appendEvent(createEventEnvelope({
51
+ eventId: `workflow-selected-${projection.workItemKey}`,
52
+ workItemKey: projection.workItemKey,
53
+ streamScope: 'work-item',
54
+ direction: 'internal',
55
+ sourceSystem: 'wake',
56
+ sourceEventType: WORKFLOW_SELECTED_EVENT,
57
+ sourceRefs: event.sourceRefs,
58
+ occurredAt: event.occurredAt,
59
+ ingestedAt: event.ingestedAt,
60
+ trigger: 'context-only',
61
+ payload: {
62
+ workflow,
63
+ selectedFromEventId: event.eventId,
64
+ },
65
+ }));
66
+ return applyEvent(projection, selectedEvent, ctx, config);
67
+ }
40
68
  async function applyEvent(current, event, ctx, config) {
41
69
  // The shared sentinel for events whose resource failed mint qualification
42
70
  // (tick-runner.ts's resolveInboundEvent, spec D1'). These are durable and
@@ -55,11 +83,11 @@ async function applyEvent(current, event, ctx, config) {
55
83
  return current;
56
84
  }
57
85
  if (current === null) {
58
- return next;
86
+ return pinSelectedWorkflow(next, event, ctx, config);
59
87
  }
60
88
  const nextStageFromLabels = stageFromLabels(next.issue.labels, configuredStagesForLabels(config));
61
89
  const shouldReconcileStage = nextStageFromLabels !== undefined && nextStageFromLabels !== current.wake.stage;
62
- return parseIssueStateRecord({
90
+ const updated = parseIssueStateRecord({
63
91
  ...current,
64
92
  origin: current.origin,
65
93
  issue: next.issue,
@@ -80,10 +108,29 @@ async function applyEvent(current, event, ctx, config) {
80
108
  recentEventIds: [...current.wake.recentEventIds, event.eventId].slice(-10),
81
109
  },
82
110
  });
111
+ return pinSelectedWorkflow(updated, event, ctx, config);
83
112
  }
84
113
  if (current === null) {
85
114
  return null;
86
115
  }
116
+ if (event.sourceEventType === WORKFLOW_SELECTED_EVENT) {
117
+ const workflow = event.payload.workflow;
118
+ if (typeof workflow !== 'string') {
119
+ return current;
120
+ }
121
+ return parseIssueStateRecord({
122
+ ...current,
123
+ context: {
124
+ ...current.context,
125
+ workflow,
126
+ },
127
+ wake: {
128
+ ...current.wake,
129
+ syncedAt: event.ingestedAt,
130
+ recentEventIds: [...current.wake.recentEventIds, event.eventId].slice(-10),
131
+ },
132
+ });
133
+ }
87
134
  if (event.sourceEventType === 'fake.issue.comment.created' ||
88
135
  event.sourceEventType === 'ticket.comment.created' ||
89
136
  event.sourceEventType === 'ticket.comment.updated' ||
@@ -156,31 +203,38 @@ async function applyEvent(current, event, ctx, config) {
156
203
  config !== undefined &&
157
204
  isCustomCommandAction(payload.action, config);
158
205
  const shouldClearSession = isForwardProgression || isFailed;
206
+ const nextContext = {
207
+ ...current.context,
208
+ lastFailureClass: payload.failureClass,
209
+ ...(payload.handledCommentId === undefined
210
+ ? {}
211
+ : { lastHandledCommentId: payload.handledCommentId }),
212
+ ...(payload.sentinel === undefined || isCompletedCustomCommand
213
+ ? {}
214
+ : { lastRunSentinel: payload.sentinel }),
215
+ ...(payload.action === undefined || isCompletedCustomCommand
216
+ ? {}
217
+ : { lastRunAction: payload.action }),
218
+ ...(payload.sentinel === doneRunnerSentinel &&
219
+ payload.action !== undefined &&
220
+ !isCompletedCustomCommand
221
+ ? { lastCompletedAction: payload.action }
222
+ : {}),
223
+ // Remembered so the approval path knows which action to resume or
224
+ // skip when a human posts /approved.
225
+ ...(payload.sentinel === 'AWAITING_APPROVAL' && payload.action !== undefined
226
+ ? { pendingApprovalAction: payload.action }
227
+ : {}),
228
+ };
229
+ if (payload.sentinel === 'BLOCKED' || payload.sentinel === 'FAILED') {
230
+ nextContext.blockedFromStage = current.wake.stage;
231
+ }
232
+ else if (payload.sentinel !== undefined) {
233
+ delete nextContext.blockedFromStage;
234
+ }
159
235
  return parseIssueStateRecord({
160
236
  ...current,
161
- context: {
162
- ...current.context,
163
- lastFailureClass: payload.failureClass,
164
- ...(payload.handledCommentId === undefined
165
- ? {}
166
- : { lastHandledCommentId: payload.handledCommentId }),
167
- ...(payload.sentinel === undefined || isCompletedCustomCommand
168
- ? {}
169
- : { lastRunSentinel: payload.sentinel }),
170
- ...(payload.action === undefined || isCompletedCustomCommand
171
- ? {}
172
- : { lastRunAction: payload.action }),
173
- ...(payload.sentinel === doneRunnerSentinel &&
174
- payload.action !== undefined &&
175
- !isCompletedCustomCommand
176
- ? { lastCompletedAction: payload.action }
177
- : {}),
178
- // Remembered so the approval path knows which action to resume or
179
- // skip when a human posts /approved.
180
- ...(payload.sentinel === 'AWAITING_APPROVAL' && payload.action !== undefined
181
- ? { pendingApprovalAction: payload.action }
182
- : {}),
183
- },
237
+ context: nextContext,
184
238
  wake: {
185
239
  ...current.wake,
186
240
  stage: payload.nextStage ?? current.wake.stage,
@@ -222,7 +222,7 @@ export function createTickRunner(deps) {
222
222
  }
223
223
  const nextAction = policy.resolveCustomCommandRequest(projection, deps.config)?.action ??
224
224
  policy.chooseAction(projection, workflow) ??
225
- policy.chooseRetryActionAfterHumanReply(projection);
225
+ policy.chooseRetryActionAfterHumanReply(projection, workflow);
226
226
  return nextAction !== null && policy.needsWakeAction(projection, workflow);
227
227
  }
228
228
  function createLabelsEvent(input) {
@@ -965,7 +965,7 @@ export function createTickRunner(deps) {
965
965
  }
966
966
  const nextAction = policy.resolveCustomCommandRequest(issue, deps.config)?.action ??
967
967
  policy.chooseAction(issue, workflow) ??
968
- policy.chooseRetryActionAfterHumanReply(issue);
968
+ policy.chooseRetryActionAfterHumanReply(issue, workflow);
969
969
  return nextAction !== null && policy.needsWakeAction(issue, workflow);
970
970
  });
971
971
  if (candidate === undefined) {
@@ -1069,7 +1069,7 @@ export function createTickRunner(deps) {
1069
1069
  // back to a full fresh `implement` run and lost the PR-feedback
1070
1070
  // context).
1071
1071
  const nextAction = policy.resolveCustomCommandRequest(candidate, deps.config)?.action ??
1072
- policy.chooseRetryActionAfterHumanReply(candidate) ??
1072
+ policy.chooseRetryActionAfterHumanReply(candidate, workflow) ??
1073
1073
  workflowAction?.action ??
1074
1074
  null;
1075
1075
  if (nextAction === null) {
@@ -316,6 +316,26 @@ const workflowDefinitionSchema = z.object({
316
316
  entryStage: identifierSchema.optional(),
317
317
  stages: z.record(identifierSchema, workflowStageSchema),
318
318
  });
319
+ const workflowSelectorMatchSchema = z
320
+ .object({
321
+ kind: z.string().min(1).optional(),
322
+ sourceEventType: z.string().min(1).optional(),
323
+ repo: z.string().min(1).optional(),
324
+ requiredLabels: z.array(z.string()).default([]),
325
+ ignoredLabels: z.array(z.string()).default([]),
326
+ requiredAssignees: z.array(z.string()).default([]),
327
+ requiredAuthors: z.array(z.string()).default([]),
328
+ })
329
+ .default({
330
+ requiredLabels: [],
331
+ ignoredLabels: [],
332
+ requiredAssignees: [],
333
+ requiredAuthors: [],
334
+ });
335
+ const workflowSelectorSchema = z.object({
336
+ workflow: identifierSchema,
337
+ match: workflowSelectorMatchSchema,
338
+ });
319
339
  function defaultPromptsRoot() {
320
340
  const sourceDir = dirname(fileURLToPath(import.meta.url));
321
341
  for (const candidate of [
@@ -515,6 +535,7 @@ export const wakeConfigSchema = z
515
535
  },
516
536
  },
517
537
  }),
538
+ workflowSelectors: z.array(workflowSelectorSchema).default([]),
518
539
  commands: z.record(identifierSchema, customCommandSchema).default({
519
540
  ask: {
520
541
  action: 'ask',
@@ -707,6 +728,15 @@ export const wakeConfigSchema = z
707
728
  });
708
729
  }
709
730
  }
731
+ for (const [index, selector] of config.workflowSelectors.entries()) {
732
+ if (config.workflows[selector.workflow] === undefined) {
733
+ ctx.addIssue({
734
+ code: z.ZodIssueCode.custom,
735
+ path: ['workflowSelectors', index, 'workflow'],
736
+ message: `Workflow selector targets unknown workflow "${selector.workflow}".`,
737
+ });
738
+ }
739
+ }
710
740
  for (const [commandName, command] of commandEntries) {
711
741
  if (reservedCommandNames.includes(commandName.toLowerCase())) {
712
742
  ctx.addIssue({
@@ -37,6 +37,75 @@ export function workflowNameForProjection(projection, config) {
37
37
  const context = projection.context;
38
38
  return typeof context.workflow === 'string' ? context.workflow : defaultWorkflowName(config);
39
39
  }
40
+ function stringArray(value) {
41
+ return Array.isArray(value)
42
+ ? value.filter((entry) => typeof entry === 'string')
43
+ : [];
44
+ }
45
+ function repoFromResourceUri(resourceUri) {
46
+ if (resourceUri === undefined) {
47
+ return undefined;
48
+ }
49
+ const [, , rest] = resourceUri.split(':', 3);
50
+ return rest?.split('#')[0];
51
+ }
52
+ export function workflowSelectorInputFromEvent(event) {
53
+ const resourceUri = event.sourceRefs.resourceUri ?? event.sourceRefs.parentResourceUri;
54
+ const kind = resourceUri?.split(':')[1];
55
+ const ticket = (event.payload.ticket ?? event.payload.issue);
56
+ const pr = event.payload.pr;
57
+ const repo = (typeof ticket?.repo === 'string' ? ticket.repo : undefined) ??
58
+ event.sourceRefs.repo ??
59
+ repoFromResourceUri(resourceUri);
60
+ return {
61
+ ...(kind === undefined ? {} : { kind }),
62
+ sourceEventType: event.sourceEventType,
63
+ ...(repo === undefined ? {} : { repo }),
64
+ labels: stringArray(ticket?.labels),
65
+ assignees: stringArray(ticket?.assignees),
66
+ ...(typeof pr?.author === 'string' ? { author: pr.author } : {}),
67
+ };
68
+ }
69
+ function labelsAndAssigneesMatch(input) {
70
+ const labels = new Set(input.labels);
71
+ const assignees = new Set(input.assignees);
72
+ if (input.requiredLabels.some((label) => !labels.has(label))) {
73
+ return false;
74
+ }
75
+ if (input.ignoredLabels.some((label) => labels.has(label))) {
76
+ return false;
77
+ }
78
+ if (input.requiredAssignees.length > 0 &&
79
+ !input.requiredAssignees.some((login) => assignees.has(login))) {
80
+ return false;
81
+ }
82
+ return true;
83
+ }
84
+ function selectorMatches(selector, input) {
85
+ const match = selector.match;
86
+ if (match.kind !== undefined && match.kind !== input.kind) {
87
+ return false;
88
+ }
89
+ if (match.sourceEventType !== undefined && match.sourceEventType !== input.sourceEventType) {
90
+ return false;
91
+ }
92
+ if (match.repo !== undefined && match.repo !== input.repo) {
93
+ return false;
94
+ }
95
+ if (!labelsAndAssigneesMatch({ ...match, labels: input.labels, assignees: input.assignees })) {
96
+ return false;
97
+ }
98
+ if (match.requiredAuthors.length > 0 &&
99
+ (input.author === undefined || !match.requiredAuthors.includes(input.author))) {
100
+ return false;
101
+ }
102
+ return true;
103
+ }
104
+ export function selectWorkflowForEvent(event, config) {
105
+ const input = workflowSelectorInputFromEvent(event);
106
+ const selector = config.workflowSelectors.find((candidate) => selectorMatches(candidate, input));
107
+ return selector?.workflow ?? null;
108
+ }
40
109
  export function configuredStageNames(workflow) {
41
110
  return Object.keys(workflow.stages);
42
111
  }
@@ -1 +1 @@
1
- export const wakeVersion = "0.1.11+gbbbf616";
1
+ export const wakeVersion = "0.1.12+g6a4753f";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atolis-hq/wake",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
4
4
  "description": "Local autonomous agent control plane for software development",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {