@atolis-hq/wake 0.1.11 → 0.1.13
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.
- package/dist/src/core/event-builders.js +139 -0
- package/dist/src/core/event-resolver.js +249 -0
- package/dist/src/core/outbox.js +123 -0
- package/dist/src/core/policy-engine.js +57 -3
- package/dist/src/core/projection-updater.js +80 -26
- package/dist/src/core/stale-run-reconciler.js +102 -0
- package/dist/src/core/tick-runner.js +38 -716
- package/dist/src/core/workspace-cleanup.js +77 -0
- package/dist/src/domain/schema.js +30 -0
- package/dist/src/domain/workflows.js +69 -0
- package/dist/src/lib/format.js +41 -0
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
|
@@ -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,
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { createLabelsEvent } from './event-builders.js';
|
|
2
|
+
import { stageLabelForStage } from '../domain/stages.js';
|
|
3
|
+
import { workflowLabelForWorkflowName, workflowNameForProjection } from '../domain/workflows.js';
|
|
4
|
+
import { createEventEnvelope } from '../lib/event-log.js';
|
|
5
|
+
// Reconciles run records still marked `running` past the runner timeout: a
|
|
6
|
+
// record whose work item has already moved on is superseded, otherwise it is
|
|
7
|
+
// failed and a completion event replayed so the projection stops waiting on it.
|
|
8
|
+
// `deliverOutboundEvent` is injected rather than imported so this module does
|
|
9
|
+
// not depend on the outbox module directly.
|
|
10
|
+
export function createStaleRunReconciler(deps) {
|
|
11
|
+
function isStaleRunningRecord(record, now) {
|
|
12
|
+
if (record.status !== 'running') {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
const startedAtMs = Date.parse(record.startedAt);
|
|
16
|
+
if (!Number.isFinite(startedAtMs)) {
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
return now.getTime() - startedAtMs >= deps.runnerTimeoutMs();
|
|
20
|
+
}
|
|
21
|
+
async function reconcileStaleRunningRecords(now) {
|
|
22
|
+
const finishedAt = now.toISOString();
|
|
23
|
+
const runRecords = await deps.stateStore.listRunRecords();
|
|
24
|
+
const staleRecords = runRecords.filter((record) => isStaleRunningRecord(record, now));
|
|
25
|
+
for (const record of staleRecords) {
|
|
26
|
+
// Run records carry the work item they belong to, so this is a direct
|
|
27
|
+
// O(1) read — no scan, no index, no source ambiguity. The record's
|
|
28
|
+
// repo/issueNumber are representation content and take no part in it.
|
|
29
|
+
const projection = await deps.stateStore.readIssueState(record.workItemKey);
|
|
30
|
+
const newerCompletedRun = runRecords.some((candidate) => candidate.workItemKey === record.workItemKey &&
|
|
31
|
+
candidate.runId !== record.runId &&
|
|
32
|
+
candidate.status !== 'running' &&
|
|
33
|
+
Date.parse(candidate.startedAt) > Date.parse(record.startedAt));
|
|
34
|
+
// Equivalent to the previous `projection?.wake.lastRunId !== record.runId`,
|
|
35
|
+
// spelled out so the non-null projection is available below for its
|
|
36
|
+
// workItemKey.
|
|
37
|
+
if (projection === null || projection.wake.lastRunId !== record.runId || newerCompletedRun) {
|
|
38
|
+
await deps.stateStore.writeRunRecord({
|
|
39
|
+
...record,
|
|
40
|
+
status: 'superseded',
|
|
41
|
+
finishedAt,
|
|
42
|
+
summary: 'Stale running record was superseded by a newer run.',
|
|
43
|
+
metadata: {
|
|
44
|
+
...record.metadata,
|
|
45
|
+
reconciledBy: 'stale-running-record',
|
|
46
|
+
supersededBy: projection?.wake.lastRunId,
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
await deps.stateStore.writeRunRecord({
|
|
52
|
+
...record,
|
|
53
|
+
status: 'failed',
|
|
54
|
+
finishedAt,
|
|
55
|
+
sentinel: 'FAILED',
|
|
56
|
+
summary: `Run exceeded timeout while marked running and was reconciled by a later tick.`,
|
|
57
|
+
metadata: {
|
|
58
|
+
...record.metadata,
|
|
59
|
+
reconciledBy: 'stale-running-record',
|
|
60
|
+
timeoutMs: deps.runnerTimeoutMs(),
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
const runCompletedEvent = createEventEnvelope({
|
|
64
|
+
eventId: `${record.runId}-stale-reconciled`,
|
|
65
|
+
workItemKey: projection.workItemKey,
|
|
66
|
+
streamScope: 'work-item',
|
|
67
|
+
direction: 'internal',
|
|
68
|
+
sourceSystem: 'wake',
|
|
69
|
+
sourceEventType: 'wake.run.completed',
|
|
70
|
+
sourceRefs: {
|
|
71
|
+
repo: record.repo,
|
|
72
|
+
issueNumber: record.issueNumber,
|
|
73
|
+
runId: record.runId,
|
|
74
|
+
},
|
|
75
|
+
occurredAt: finishedAt,
|
|
76
|
+
ingestedAt: finishedAt,
|
|
77
|
+
trigger: 'immediate',
|
|
78
|
+
payload: {
|
|
79
|
+
action: record.action,
|
|
80
|
+
sentinel: 'FAILED',
|
|
81
|
+
runId: record.runId,
|
|
82
|
+
reason: 'runner:stale-timeout',
|
|
83
|
+
...(record.routing === undefined ? {} : { routing: record.routing }),
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
await deps.stateStore.appendEventEnvelope(runCompletedEvent);
|
|
87
|
+
await deps.projectionUpdater.rebuildFromEvents([runCompletedEvent]);
|
|
88
|
+
const updatedProjection = await deps.stateStore.readIssueState(projection.workItemKey);
|
|
89
|
+
if (updatedProjection !== null) {
|
|
90
|
+
await deps.deliverOutboundEvent(createLabelsEvent({
|
|
91
|
+
projection: updatedProjection,
|
|
92
|
+
runId: record.runId,
|
|
93
|
+
statusLabel: 'wake:status.failed',
|
|
94
|
+
stageLabel: stageLabelForStage(updatedProjection.wake.stage),
|
|
95
|
+
workflowLabel: workflowLabelForWorkflowName(workflowNameForProjection(updatedProjection, deps.config)),
|
|
96
|
+
occurredAt: finishedAt,
|
|
97
|
+
}));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { reconcileStaleRunningRecords };
|
|
102
|
+
}
|