@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.
@@ -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,
@@ -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
+ }