@atolis-hq/wake 0.1.10 → 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.
@@ -91,14 +91,22 @@ export function createFakeTicketingSystem(options) {
91
91
  const stageLabel = typeof input.event.payload.stageLabel === 'string'
92
92
  ? input.event.payload.stageLabel
93
93
  : undefined;
94
+ const workflowLabel = typeof input.event.payload.workflowLabel === 'string'
95
+ ? input.event.payload.workflowLabel
96
+ : undefined;
94
97
  const labels = [
95
- ...currentLabels.filter((label) => !label.startsWith('wake:status.') && !label.startsWith('wake:stage.')),
98
+ ...currentLabels.filter((label) => !label.startsWith('wake:status.') &&
99
+ !label.startsWith('wake:stage.') &&
100
+ !label.startsWith('wake:workflow.')),
96
101
  ...(statusLabel === undefined
97
102
  ? currentLabels.filter((label) => label.startsWith('wake:status.'))
98
103
  : [statusLabel]),
99
104
  ...(stageLabel === undefined
100
105
  ? currentLabels.filter((label) => label.startsWith('wake:stage.'))
101
106
  : [stageLabel]),
107
+ ...(workflowLabel === undefined
108
+ ? currentLabels.filter((label) => label.startsWith('wake:workflow.'))
109
+ : [workflowLabel]),
102
110
  ];
103
111
  return [
104
112
  createEventEnvelope({
@@ -3,6 +3,7 @@ import { resolve } from 'node:path';
3
3
  import { defaultAgentIdentity } from '../../domain/schema.js';
4
4
  import { buildResourceUri } from '../../domain/resource-uri.js';
5
5
  import { wakeStageLabelPrefix } from '../../domain/stages.js';
6
+ import { wakeWorkflowLabelPrefix } from '../../domain/workflows.js';
6
7
  import { createEventEnvelope, createUnkeyedEventEnvelope } from '../../lib/event-log.js';
7
8
  import { wakeVersion } from '../../version.js';
8
9
  import { buildResumeCommandForCli } from '../runner/runner-cli-adapter.js';
@@ -340,14 +341,22 @@ export function createGitHubIssuesWorkSource(deps) {
340
341
  const nextStageLabel = typeof input.event.payload.stageLabel === 'string'
341
342
  ? input.event.payload.stageLabel
342
343
  : undefined;
344
+ const nextWorkflowLabel = typeof input.event.payload.workflowLabel === 'string'
345
+ ? input.event.payload.workflowLabel
346
+ : undefined;
343
347
  const nextLabels = [
344
- ...currentLabels.filter((label) => !label.startsWith(wakeStatusLabelPrefix) && !label.startsWith(wakeStageLabelPrefix)),
348
+ ...currentLabels.filter((label) => !label.startsWith(wakeStatusLabelPrefix) &&
349
+ !label.startsWith(wakeStageLabelPrefix) &&
350
+ !label.startsWith(wakeWorkflowLabelPrefix)),
345
351
  ...(nextStatusLabel !== undefined
346
352
  ? [nextStatusLabel]
347
353
  : currentLabels.filter((label) => label.startsWith(wakeStatusLabelPrefix))),
348
354
  ...(nextStageLabel !== undefined
349
355
  ? [nextStageLabel]
350
356
  : currentLabels.filter((label) => label.startsWith(wakeStageLabelPrefix))),
357
+ ...(nextWorkflowLabel !== undefined
358
+ ? [nextWorkflowLabel]
359
+ : currentLabels.filter((label) => label.startsWith(wakeWorkflowLabelPrefix))),
351
360
  ];
352
361
  const labelsChanged = nextLabels.length !== currentLabels.length ||
353
362
  !nextLabels.every((label, index) => label === currentLabels[index]);
@@ -372,6 +381,7 @@ export function createGitHubIssuesWorkSource(deps) {
372
381
  intentEventId: input.event.eventId,
373
382
  ...(nextStatusLabel !== undefined ? { statusLabel: nextStatusLabel } : {}),
374
383
  ...(nextStageLabel !== undefined ? { stageLabel: nextStageLabel } : {}),
384
+ ...(nextWorkflowLabel !== undefined ? { workflowLabel: nextWorkflowLabel } : {}),
375
385
  labels: nextLabels,
376
386
  providerEventType: 'github.issue.labels.updated',
377
387
  },
@@ -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,
@@ -9,7 +9,7 @@ import { createWorkId } from '../lib/work-id.js';
9
9
  import { CORRELATION_REGISTERED_EVENT, UNRESOLVED_WORK_ITEM_KEY, WORK_ITEM_CREATED_EVENT, parseRunnerArtifacts, parseRunnerResult, } from '../domain/schema.js';
10
10
  import { maxConfiguredRunnerTimeoutMs, resolveRunnerRouting } from '../domain/runner-routing.js';
11
11
  import { awaitingApprovalRunnerSentinel, stageLabelForStage } from '../domain/stages.js';
12
- import { chooseAction as chooseWorkflowAction, isKnownWorkflowStage, workflowChangedBlockReason, workflowForProjection, workflowNameForProjection, } from '../domain/workflows.js';
12
+ import { chooseAction as chooseWorkflowAction, isKnownWorkflowStage, workflowChangedBlockReason, workflowForProjection, workflowLabelForWorkflowName, workflowNameForProjection, } from '../domain/workflows.js';
13
13
  import { createEventEnvelope } from '../lib/event-log.js';
14
14
  import { branchNameForIssue } from '../domain/branch-naming.js';
15
15
  import { customCommandWorkspace, isCustomCommandAction } from '../domain/custom-commands.js';
@@ -222,12 +222,12 @@ 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) {
229
229
  return createEventEnvelope({
230
- eventId: `${input.runId}-labels-${input.statusLabel.replace(/[^a-z0-9]+/gi, '-')}-${input.stageLabel.replace(/[^a-z0-9]+/gi, '-')}`,
230
+ eventId: `${input.runId}-labels-${input.statusLabel.replace(/[^a-z0-9]+/gi, '-')}-${input.stageLabel.replace(/[^a-z0-9]+/gi, '-')}-${input.workflowLabel.replace(/[^a-z0-9]+/gi, '-')}`,
231
231
  workItemKey: input.projection.workItemKey,
232
232
  streamScope: 'work-item',
233
233
  direction: 'outbound',
@@ -244,6 +244,7 @@ export function createTickRunner(deps) {
244
244
  payload: {
245
245
  statusLabel: input.statusLabel,
246
246
  stageLabel: input.stageLabel,
247
+ workflowLabel: input.workflowLabel,
247
248
  origin: input.projection.origin ?? 'github',
248
249
  },
249
250
  });
@@ -340,8 +341,11 @@ export function createTickRunner(deps) {
340
341
  for (const projection of projections) {
341
342
  const statusLabel = statusLabelForStage(projection.wake.stage);
342
343
  const stageLabel = stageLabelForStage(projection.wake.stage);
344
+ const workflowLabel = workflowLabelForWorkflowName(workflowNameForProjection(projection, deps.config));
343
345
  if (!shouldMarkPending(projection) ||
344
- (hasLabel(projection, statusLabel) && hasLabel(projection, stageLabel))) {
346
+ (hasLabel(projection, statusLabel) &&
347
+ hasLabel(projection, stageLabel) &&
348
+ hasLabel(projection, workflowLabel))) {
345
349
  continue;
346
350
  }
347
351
  await deliverOutboundEvent(createLabelsEvent({
@@ -349,6 +353,7 @@ export function createTickRunner(deps) {
349
353
  runId: `pending-${projection.workItemKey}-${deps.clock.now().getTime()}`,
350
354
  statusLabel,
351
355
  stageLabel,
356
+ workflowLabel,
352
357
  occurredAt: eventStampNow(),
353
358
  }));
354
359
  }
@@ -732,6 +737,7 @@ export function createTickRunner(deps) {
732
737
  runId: record.runId,
733
738
  statusLabel: 'wake:status.failed',
734
739
  stageLabel: stageLabelForStage(updatedProjection.wake.stage),
740
+ workflowLabel: workflowLabelForWorkflowName(workflowNameForProjection(updatedProjection, deps.config)),
735
741
  occurredAt: finishedAt,
736
742
  }));
737
743
  }
@@ -779,6 +785,7 @@ export function createTickRunner(deps) {
779
785
  runId: eventId,
780
786
  statusLabel: 'wake:status.blocked',
781
787
  stageLabel: stageLabelForStage(projection.wake.stage),
788
+ workflowLabel: workflowLabelForWorkflowName(workflowNameForProjection(projection, deps.config)),
782
789
  occurredAt,
783
790
  }));
784
791
  parked = true;
@@ -958,7 +965,7 @@ export function createTickRunner(deps) {
958
965
  }
959
966
  const nextAction = policy.resolveCustomCommandRequest(issue, deps.config)?.action ??
960
967
  policy.chooseAction(issue, workflow) ??
961
- policy.chooseRetryActionAfterHumanReply(issue);
968
+ policy.chooseRetryActionAfterHumanReply(issue, workflow);
962
969
  return nextAction !== null && policy.needsWakeAction(issue, workflow);
963
970
  });
964
971
  if (candidate === undefined) {
@@ -1031,6 +1038,7 @@ export function createTickRunner(deps) {
1031
1038
  runId: approvalId,
1032
1039
  statusLabel: statusLabelForStage(nextStage),
1033
1040
  stageLabel: stageLabelForStage(nextStage),
1041
+ workflowLabel: workflowLabelForWorkflowName(workflowName),
1034
1042
  occurredAt: approvedAt,
1035
1043
  }));
1036
1044
  return {
@@ -1061,7 +1069,7 @@ export function createTickRunner(deps) {
1061
1069
  // back to a full fresh `implement` run and lost the PR-feedback
1062
1070
  // context).
1063
1071
  const nextAction = policy.resolveCustomCommandRequest(candidate, deps.config)?.action ??
1064
- policy.chooseRetryActionAfterHumanReply(candidate) ??
1072
+ policy.chooseRetryActionAfterHumanReply(candidate, workflow) ??
1065
1073
  workflowAction?.action ??
1066
1074
  null;
1067
1075
  if (nextAction === null) {
@@ -1130,6 +1138,7 @@ export function createTickRunner(deps) {
1130
1138
  runId,
1131
1139
  statusLabel: 'wake:status.working',
1132
1140
  stageLabel: stageLabelForStage(claimedStage),
1141
+ workflowLabel: workflowLabelForWorkflowName(workflowName),
1133
1142
  occurredAt: eventStampNow(),
1134
1143
  }));
1135
1144
  try {
@@ -1288,6 +1297,7 @@ export function createTickRunner(deps) {
1288
1297
  stage: nextStage ?? claimedStage,
1289
1298
  }),
1290
1299
  stageLabel: stageLabelForStage(nextStage ?? claimedStage),
1300
+ workflowLabel: workflowLabelForWorkflowName(workflowName),
1291
1301
  occurredAt: finishedAt,
1292
1302
  }));
1293
1303
  if (runnerResult.failureClass !== 'quota') {
@@ -1358,6 +1368,7 @@ export function createTickRunner(deps) {
1358
1368
  runId,
1359
1369
  statusLabel: 'wake:status.failed',
1360
1370
  stageLabel: stageLabelForStage(claimedStage),
1371
+ workflowLabel: workflowLabelForWorkflowName(workflowName),
1361
1372
  occurredAt: finishedAt,
1362
1373
  }));
1363
1374
  const errorMessage = err instanceof Error ? err.message : String(err);
@@ -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({
@@ -2,6 +2,7 @@ import { stageLabelForStage } from './stages.js';
2
2
  export const universalQueueStage = 'queue';
3
3
  export const universalDoneStage = 'done';
4
4
  export const workflowChangedBlockReason = 'workflow-changed';
5
+ export const wakeWorkflowLabelPrefix = 'wake:workflow.';
5
6
  export const builtInDefaultWorkflowDefinition = {
6
7
  stages: {
7
8
  refine: {
@@ -36,6 +37,75 @@ export function workflowNameForProjection(projection, config) {
36
37
  const context = projection.context;
37
38
  return typeof context.workflow === 'string' ? context.workflow : defaultWorkflowName(config);
38
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
+ }
39
109
  export function configuredStageNames(workflow) {
40
110
  return Object.keys(workflow.stages);
41
111
  }
@@ -45,6 +115,9 @@ export function workflowStageVocabulary(workflow) {
45
115
  export function stageLabelsForWorkflow(workflow) {
46
116
  return workflowStageVocabulary(workflow).map((stage) => stageLabelForStage(stage));
47
117
  }
118
+ export function workflowLabelForWorkflowName(workflowName) {
119
+ return `${wakeWorkflowLabelPrefix}${workflowName}`;
120
+ }
48
121
  export function isKnownWorkflowStage(stage, workflow) {
49
122
  return workflowStageVocabulary(workflow).includes(stage);
50
123
  }
@@ -1 +1 @@
1
- export const wakeVersion = "0.1.10+g7ed704e";
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.10",
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": {