@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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/src/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const wakeVersion = "0.1.
|
|
1
|
+
export const wakeVersion = "0.1.12+g6a4753f";
|