@atolis-hq/wake 0.2.77 → 0.2.79
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/adapters/fake/fake-ticketing-system.js +11 -1
- package/dist/src/adapters/fs/state-store.js +4 -1
- package/dist/src/adapters/github/github-issues-work-source.js +21 -1
- package/dist/src/adapters/http/ui-assets.js +1 -1
- package/dist/src/adapters/http/ui-server.js +2 -6
- package/dist/src/core/event-builders.js +2 -0
- package/dist/src/core/policy-engine.js +29 -1
- package/dist/src/core/projection-updater.js +68 -7
- package/dist/src/core/stale-run-reconciler.js +2 -5
- package/dist/src/core/tick-runner.js +142 -67
- package/dist/src/domain/schema.js +59 -19
- package/dist/src/domain/work-item-labels.js +48 -0
- package/dist/src/domain/work-item-lifecycle.js +2 -2
- package/dist/src/domain/work-item-status.js +30 -0
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
|
@@ -95,10 +95,18 @@ export function createFakeTicketingSystem(options) {
|
|
|
95
95
|
const workflowLabel = typeof input.event.payload.workflowLabel === 'string'
|
|
96
96
|
? input.event.payload.workflowLabel
|
|
97
97
|
: undefined;
|
|
98
|
+
const frozenLabel = typeof input.event.payload.frozenLabel === 'string'
|
|
99
|
+
? input.event.payload.frozenLabel
|
|
100
|
+
: undefined;
|
|
101
|
+
const scheduledLabel = typeof input.event.payload.scheduledLabel === 'string'
|
|
102
|
+
? input.event.payload.scheduledLabel
|
|
103
|
+
: undefined;
|
|
98
104
|
const labels = [
|
|
99
105
|
...currentLabels.filter((label) => !label.startsWith('wake:status.') &&
|
|
100
106
|
!label.startsWith('wake:stage.') &&
|
|
101
|
-
!label.startsWith('wake:workflow.')
|
|
107
|
+
!label.startsWith('wake:workflow.') &&
|
|
108
|
+
label !== 'wake:frozen' &&
|
|
109
|
+
label !== 'wake:scheduled-workflow'),
|
|
102
110
|
...(statusLabel === undefined
|
|
103
111
|
? currentLabels.filter((label) => label.startsWith('wake:status.'))
|
|
104
112
|
: [statusLabel]),
|
|
@@ -108,6 +116,8 @@ export function createFakeTicketingSystem(options) {
|
|
|
108
116
|
...(workflowLabel === undefined
|
|
109
117
|
? currentLabels.filter((label) => label.startsWith('wake:workflow.'))
|
|
110
118
|
: [workflowLabel]),
|
|
119
|
+
...(frozenLabel === undefined ? [] : [frozenLabel]),
|
|
120
|
+
...(scheduledLabel === undefined ? [] : [scheduledLabel]),
|
|
111
121
|
];
|
|
112
122
|
return [
|
|
113
123
|
createEventEnvelope({
|
|
@@ -46,6 +46,9 @@ function stripHeavyRunRecordFields(record) {
|
|
|
46
46
|
const { stdout: _stdout, stderr: _stderr, raw: _raw, ...restMetadata } = metadata;
|
|
47
47
|
return { ...rest, metadata: restMetadata };
|
|
48
48
|
}
|
|
49
|
+
function compareIssueStatesForListing(left, right) {
|
|
50
|
+
return left.workItemKey.localeCompare(right.workItemKey);
|
|
51
|
+
}
|
|
49
52
|
function parseRunRecordSummaryIndex(input, date) {
|
|
50
53
|
if (input === null || typeof input !== 'object') {
|
|
51
54
|
throw new Error('Run summary index must be an object');
|
|
@@ -670,7 +673,7 @@ export function createStateStore({ wakeRoot }) {
|
|
|
670
673
|
for (const item of items) {
|
|
671
674
|
byWorkItemKey.set(item.workItemKey, item);
|
|
672
675
|
}
|
|
673
|
-
return [...byWorkItemKey.values()].sort(
|
|
676
|
+
return [...byWorkItemKey.values()].sort(compareIssueStatesForListing);
|
|
674
677
|
},
|
|
675
678
|
async listEventEnvelopes() {
|
|
676
679
|
const eventsRoot = join(paths.dataRoot, 'events');
|
|
@@ -5,10 +5,12 @@ import { defaultAgentIdentity } from '../../domain/schema.js';
|
|
|
5
5
|
import { buildResourceUri } from '../../domain/resource-uri.js';
|
|
6
6
|
import { wakeStageLabelPrefix } from '../../domain/stages.js';
|
|
7
7
|
import { wakeWorkflowLabelPrefix } from '../../domain/workflows.js';
|
|
8
|
+
import { FROZEN_WORK_ITEM_LABEL } from '../../domain/work-item-lifecycle.js';
|
|
8
9
|
import { createEventEnvelope, createUnkeyedEventEnvelope } from '../../lib/event-log.js';
|
|
9
10
|
import { createWakePaths } from '../../lib/paths.js';
|
|
10
11
|
import { wakeVersion } from '../../version.js';
|
|
11
12
|
import { buildResumeCommandForCli } from '../runner/runner-cli-adapter.js';
|
|
13
|
+
import { SCHEDULED_WORKFLOW_LABEL } from '../../domain/work-item-labels.js';
|
|
12
14
|
const wakeStatusLabelPrefix = 'wake:status.';
|
|
13
15
|
const pollOverlapMs = 60 * 60 * 1000;
|
|
14
16
|
// Hidden marker appended to every comment Wake posts. `expectedEcho` normally
|
|
@@ -524,10 +526,22 @@ export function createGitHubIssuesWorkSource(deps) {
|
|
|
524
526
|
const nextWorkflowLabel = typeof input.event.payload.workflowLabel === 'string'
|
|
525
527
|
? input.event.payload.workflowLabel
|
|
526
528
|
: undefined;
|
|
529
|
+
// Unlike the three label families above, frozen/scheduled are single
|
|
530
|
+
// toggle labels: every call site now computes the full authoritative
|
|
531
|
+
// desired state via labelsForWorkItem, so their absence here means
|
|
532
|
+
// "should not be present" (not "leave unspecified as before").
|
|
533
|
+
const nextFrozenLabel = typeof input.event.payload.frozenLabel === 'string'
|
|
534
|
+
? input.event.payload.frozenLabel
|
|
535
|
+
: undefined;
|
|
536
|
+
const nextScheduledLabel = typeof input.event.payload.scheduledLabel === 'string'
|
|
537
|
+
? input.event.payload.scheduledLabel
|
|
538
|
+
: undefined;
|
|
527
539
|
const nextLabels = [
|
|
528
540
|
...currentLabels.filter((label) => !label.startsWith(wakeStatusLabelPrefix) &&
|
|
529
541
|
!label.startsWith(wakeStageLabelPrefix) &&
|
|
530
|
-
!label.startsWith(wakeWorkflowLabelPrefix)
|
|
542
|
+
!label.startsWith(wakeWorkflowLabelPrefix) &&
|
|
543
|
+
label !== FROZEN_WORK_ITEM_LABEL &&
|
|
544
|
+
label !== SCHEDULED_WORKFLOW_LABEL),
|
|
531
545
|
...(nextStatusLabel !== undefined
|
|
532
546
|
? [nextStatusLabel]
|
|
533
547
|
: currentLabels.filter((label) => label.startsWith(wakeStatusLabelPrefix))),
|
|
@@ -537,6 +551,8 @@ export function createGitHubIssuesWorkSource(deps) {
|
|
|
537
551
|
...(nextWorkflowLabel !== undefined
|
|
538
552
|
? [nextWorkflowLabel]
|
|
539
553
|
: currentLabels.filter((label) => label.startsWith(wakeWorkflowLabelPrefix))),
|
|
554
|
+
...(nextFrozenLabel !== undefined ? [nextFrozenLabel] : []),
|
|
555
|
+
...(nextScheduledLabel !== undefined ? [nextScheduledLabel] : []),
|
|
540
556
|
];
|
|
541
557
|
const labelsChanged = nextLabels.length !== currentLabels.length ||
|
|
542
558
|
!nextLabels.every((label, index) => label === currentLabels[index]);
|
|
@@ -564,6 +580,8 @@ export function createGitHubIssuesWorkSource(deps) {
|
|
|
564
580
|
...(nextStatusLabel !== undefined ? { statusLabel: nextStatusLabel } : {}),
|
|
565
581
|
...(nextStageLabel !== undefined ? { stageLabel: nextStageLabel } : {}),
|
|
566
582
|
...(nextWorkflowLabel !== undefined ? { workflowLabel: nextWorkflowLabel } : {}),
|
|
583
|
+
...(nextFrozenLabel !== undefined ? { frozenLabel: nextFrozenLabel } : {}),
|
|
584
|
+
...(nextScheduledLabel !== undefined ? { scheduledLabel: nextScheduledLabel } : {}),
|
|
567
585
|
labels: nextLabels,
|
|
568
586
|
providerEventType: 'github.issue.labels.updated',
|
|
569
587
|
},
|
|
@@ -602,6 +620,8 @@ export function createGitHubIssuesWorkSource(deps) {
|
|
|
602
620
|
input.event.payload.statusLabel,
|
|
603
621
|
input.event.payload.stageLabel,
|
|
604
622
|
input.event.payload.workflowLabel,
|
|
623
|
+
input.event.payload.frozenLabel,
|
|
624
|
+
input.event.payload.scheduledLabel,
|
|
605
625
|
].filter((label) => typeof label === 'string');
|
|
606
626
|
if (expected.every((label) => currentLabels.includes(label))) {
|
|
607
627
|
return [
|
|
@@ -477,7 +477,7 @@ function renderItemDetails(detail, boardItem) {
|
|
|
477
477
|
el('dd', { text: value }),
|
|
478
478
|
])));
|
|
479
479
|
}
|
|
480
|
-
const isFrozen =
|
|
480
|
+
const isFrozen = detail.item.context.frozen !== undefined && detail.item.context.frozen !== null;
|
|
481
481
|
const actionBar = el('div', { class: 'action-bar' });
|
|
482
482
|
const lastRun = detail.runs.at(-1);
|
|
483
483
|
if (lastRun && lastRun.sentinel === 'FAILED') {
|
|
@@ -6,11 +6,10 @@ import { createLabelsEvent } from '../../core/event-builders.js';
|
|
|
6
6
|
import { createProjectionUpdater } from '../../core/projection-updater.js';
|
|
7
7
|
import { CORRELATION_RETRACTED_EVENT, RETRY_REQUESTED_EVENT, RUN_REQUESTED_EVENT, WORK_ITEM_DELETED_EVENT, WORK_ITEM_FROZEN_EVENT, WORK_ITEM_UNFROZEN_EVENT, } from '../../domain/event-types.js';
|
|
8
8
|
import { configuredTicketSource } from '../../domain/sources.js';
|
|
9
|
-
import { stageLabelForStage } from '../../domain/stages.js';
|
|
10
9
|
import { isWorkItemDeleted, isWorkItemFrozen } from '../../domain/work-item-lifecycle.js';
|
|
11
|
-
import { workflowLabelForWorkflowName, workflowNameForProjection } from '../../domain/workflows.js';
|
|
12
10
|
import { createEventEnvelope } from '../../lib/event-log.js';
|
|
13
11
|
import { writeJsonFile } from '../../lib/json-file.js';
|
|
12
|
+
import { labelsForWorkItem } from '../../domain/work-item-labels.js';
|
|
14
13
|
import { indexHtml } from './ui-assets.js';
|
|
15
14
|
import { buildBoard, buildConfigView, buildEventsFeed, buildHealth, buildItemDetail, buildItemTranscripts, buildMetrics, buildRuns, buildStatus, buildWorkspaces, } from './ui-data.js';
|
|
16
15
|
function sendJson(res, status, body) {
|
|
@@ -81,13 +80,10 @@ function buildUiWorkItemEvent(input) {
|
|
|
81
80
|
}
|
|
82
81
|
async function appendLabelSyncEvent(input) {
|
|
83
82
|
const occurredAt = input.now().toISOString();
|
|
84
|
-
const workflowName = workflowNameForProjection(input.item, input.config);
|
|
85
83
|
const labelEvent = createLabelsEvent({
|
|
86
84
|
projection: input.item,
|
|
87
85
|
runId: `${input.action}-${input.item.workItemKey}-${input.now().getTime()}`,
|
|
88
|
-
|
|
89
|
-
stageLabel: stageLabelForStage(input.item.wake.stage),
|
|
90
|
-
workflowLabel: workflowLabelForWorkflowName(workflowName),
|
|
86
|
+
...labelsForWorkItem(input.item, input.config),
|
|
91
87
|
occurredAt,
|
|
92
88
|
});
|
|
93
89
|
const appended = await input.stateStore.appendEventEnvelope(labelEvent);
|
|
@@ -143,6 +143,8 @@ export function createLabelsEvent(input) {
|
|
|
143
143
|
statusLabel: input.statusLabel,
|
|
144
144
|
stageLabel: input.stageLabel,
|
|
145
145
|
workflowLabel: input.workflowLabel,
|
|
146
|
+
...(input.frozenLabel === undefined ? {} : { frozenLabel: input.frozenLabel }),
|
|
147
|
+
...(input.scheduledLabel === undefined ? {} : { scheduledLabel: input.scheduledLabel }),
|
|
146
148
|
origin: input.projection.origin ?? 'github',
|
|
147
149
|
idempotencyKey: `${input.runId}:${labelKind}`,
|
|
148
150
|
deliveryState: 'PENDING',
|
|
@@ -218,7 +218,17 @@ export function createPolicyEngine() {
|
|
|
218
218
|
if (!approved && !changesRequested) {
|
|
219
219
|
return null;
|
|
220
220
|
}
|
|
221
|
-
return {
|
|
221
|
+
return {
|
|
222
|
+
approved,
|
|
223
|
+
pendingAction,
|
|
224
|
+
...(changesRequested
|
|
225
|
+
? {
|
|
226
|
+
changesRequested: true,
|
|
227
|
+
triggeringCommentId: latestHumanComment.id,
|
|
228
|
+
triggeringCommentBody: latestHumanComment.body,
|
|
229
|
+
}
|
|
230
|
+
: {}),
|
|
231
|
+
};
|
|
222
232
|
},
|
|
223
233
|
// Callers must try resolveApprovalTransition first and only fall back to
|
|
224
234
|
// this when it returns null. resolveApprovalTransition doesn't check
|
|
@@ -247,6 +257,20 @@ export function createPolicyEngine() {
|
|
|
247
257
|
}
|
|
248
258
|
return reviewFeedbackAction;
|
|
249
259
|
},
|
|
260
|
+
// A rejecting review watcher (or an already-folded /changes reply) sets
|
|
261
|
+
// context.status = 'changes-requested' with no fresh comment required —
|
|
262
|
+
// this is the gap #472 described: nothing distinguished a plan-review
|
|
263
|
+
// rejection from "never reviewed yet." Bounding (escalating to 'blocked'
|
|
264
|
+
// once config.retry.maxChangesRequestedRetries is exceeded) already
|
|
265
|
+
// happened at fold time, so this only has to check the current status.
|
|
266
|
+
resolveChangesRequestedAction(issue) {
|
|
267
|
+
if (!isAwaitingApproval(issue) || issue.context.status !== 'changes-requested') {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
return typeof issue.context.pendingApprovalAction === 'string'
|
|
271
|
+
? issue.context.pendingApprovalAction
|
|
272
|
+
: null;
|
|
273
|
+
},
|
|
250
274
|
resolveCustomCommandRequest(issue, config) {
|
|
251
275
|
return resolveCustomCommand(issue, config);
|
|
252
276
|
},
|
|
@@ -274,6 +298,10 @@ export function createPolicyEngine() {
|
|
|
274
298
|
if (approval !== null) {
|
|
275
299
|
return { action: approval.pendingAction, workflow };
|
|
276
300
|
}
|
|
301
|
+
const changesRequestedAction = this.resolveChangesRequestedAction(issue);
|
|
302
|
+
if (changesRequestedAction !== null) {
|
|
303
|
+
return { action: changesRequestedAction, workflow };
|
|
304
|
+
}
|
|
277
305
|
const reviewAction = this.resolvePendingReviewFeedback(issue);
|
|
278
306
|
if (reviewAction !== null) {
|
|
279
307
|
return { action: reviewAction, workflow };
|
|
@@ -2,6 +2,7 @@ import { CORRELATION_PRIMARY_CONFLICT_EVENT, CORRELATION_REGISTERED_EVENT, CORRE
|
|
|
2
2
|
import { UNRESOLVED_WORK_ITEM_KEY, parseIssueStateRecord } from '../domain/schema.js';
|
|
3
3
|
import { doneRunnerSentinel, stageFromLabels } from '../domain/stages.js';
|
|
4
4
|
import { FROZEN_WORK_ITEM_LABEL } from '../domain/work-item-lifecycle.js';
|
|
5
|
+
import { workItemStatusForRunOutcome } from '../domain/work-item-status.js';
|
|
5
6
|
import { builtInDefaultWorkflowDefinition, defaultWorkflowName, selectWorkflowForEvent, workflowStageVocabulary, } from '../domain/workflows.js';
|
|
6
7
|
import { isCustomCommandAction } from '../domain/custom-commands.js';
|
|
7
8
|
import { createEventEnvelope } from '../lib/event-log.js';
|
|
@@ -25,6 +26,10 @@ function createProjectionFromIssueEvent(event, config) {
|
|
|
25
26
|
const labels = Array.isArray(issue.labels)
|
|
26
27
|
? issue.labels
|
|
27
28
|
: [];
|
|
29
|
+
// Only the scheduled-workflow-source's synthetic ticket carries a
|
|
30
|
+
// wake:schedule resourceUri; no other mint path sets this, so `scheduled`
|
|
31
|
+
// has a real backing context field instead of only ever living in a label.
|
|
32
|
+
const scheduled = event.sourceRefs.resourceUri?.split(':')[1] === 'schedule';
|
|
28
33
|
return parseIssueStateRecord({
|
|
29
34
|
schemaVersion: 1,
|
|
30
35
|
workItemKey: event.workItemKey,
|
|
@@ -36,7 +41,7 @@ function createProjectionFromIssueEvent(event, config) {
|
|
|
36
41
|
recentEventIds: [event.eventId],
|
|
37
42
|
syncedAt: event.ingestedAt,
|
|
38
43
|
},
|
|
39
|
-
context: {},
|
|
44
|
+
context: scheduled ? { scheduled: true } : {},
|
|
40
45
|
});
|
|
41
46
|
}
|
|
42
47
|
async function pinSelectedWorkflow(projection, event, ctx, config) {
|
|
@@ -174,6 +179,10 @@ async function applyEvent(current, event, ctx, config) {
|
|
|
174
179
|
}
|
|
175
180
|
return parseIssueStateRecord({
|
|
176
181
|
...current,
|
|
182
|
+
context: {
|
|
183
|
+
...current.context,
|
|
184
|
+
status: 'working',
|
|
185
|
+
},
|
|
177
186
|
wake: {
|
|
178
187
|
...current.wake,
|
|
179
188
|
stage: payload.claimedStage,
|
|
@@ -193,6 +202,42 @@ async function applyEvent(current, event, ctx, config) {
|
|
|
193
202
|
}
|
|
194
203
|
if (event.sourceEventType === RUN_COMPLETED_EVENT) {
|
|
195
204
|
const payload = event.payload;
|
|
205
|
+
// A rejecting plan-review/pr-review watcher sub-run (§5, trigger 1) or a
|
|
206
|
+
// human /changes reply (§5, trigger 2) folds onto the parent's context
|
|
207
|
+
// without touching wake.stage/lastRunId/session — same isolation
|
|
208
|
+
// guarantee PR #479 established for watcher runs generally. Checked
|
|
209
|
+
// before the watcherRun early-return below so a rejecting watcher run
|
|
210
|
+
// (which also carries watcherRun: true) still updates status/feedback,
|
|
211
|
+
// while every other watcher-run completion (e.g. an approving DONE
|
|
212
|
+
// verdict) still hits that early-return untouched.
|
|
213
|
+
if (payload.changesRequested === true) {
|
|
214
|
+
const currentChangesRequestedCount = typeof current.context.changesRequestedCount === 'number' &&
|
|
215
|
+
Number.isInteger(current.context.changesRequestedCount)
|
|
216
|
+
? current.context.changesRequestedCount
|
|
217
|
+
: 0;
|
|
218
|
+
const nextChangesRequestedCount = currentChangesRequestedCount + 1;
|
|
219
|
+
const maxRetries = config?.retry.maxChangesRequestedRetries ?? Infinity;
|
|
220
|
+
const escalate = nextChangesRequestedCount > maxRetries;
|
|
221
|
+
return parseIssueStateRecord({
|
|
222
|
+
...current,
|
|
223
|
+
context: {
|
|
224
|
+
...current.context,
|
|
225
|
+
status: escalate ? 'blocked' : 'changes-requested',
|
|
226
|
+
changesRequestedCount: nextChangesRequestedCount,
|
|
227
|
+
...(payload.reviewFeedbackBody === undefined
|
|
228
|
+
? {}
|
|
229
|
+
: { changesRequestedFeedback: payload.reviewFeedbackBody }),
|
|
230
|
+
...(payload.handledCommentId === undefined
|
|
231
|
+
? {}
|
|
232
|
+
: { lastHandledCommentId: payload.handledCommentId }),
|
|
233
|
+
},
|
|
234
|
+
wake: {
|
|
235
|
+
...current.wake,
|
|
236
|
+
syncedAt: event.ingestedAt,
|
|
237
|
+
recentEventIds: [...current.wake.recentEventIds, event.eventId].slice(-10),
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
}
|
|
196
241
|
if (payload.watcherRun === true) {
|
|
197
242
|
return parseIssueStateRecord({
|
|
198
243
|
...current,
|
|
@@ -265,6 +310,19 @@ async function applyEvent(current, event, ctx, config) {
|
|
|
265
310
|
? { lastExternalSideEffects: payload.externalSideEffects }
|
|
266
311
|
: {}),
|
|
267
312
|
...(payload.retrySafety !== undefined ? { lastRetrySafety: payload.retrySafety } : {}),
|
|
313
|
+
...(payload.sentinel === undefined || isCompletedCustomCommand
|
|
314
|
+
? {}
|
|
315
|
+
: {
|
|
316
|
+
status: workItemStatusForRunOutcome({
|
|
317
|
+
sentinel: payload.sentinel,
|
|
318
|
+
stage: payload.nextStage ?? current.wake.stage,
|
|
319
|
+
}),
|
|
320
|
+
}),
|
|
321
|
+
// A fresh DONE/AWAITING_APPROVAL cycle resolves whatever changes were
|
|
322
|
+
// previously requested — reset the loop counter and stored feedback.
|
|
323
|
+
...(payload.sentinel === doneRunnerSentinel || payload.sentinel === 'AWAITING_APPROVAL'
|
|
324
|
+
? { changesRequestedCount: 0, changesRequestedFeedback: undefined }
|
|
325
|
+
: {}),
|
|
268
326
|
};
|
|
269
327
|
if (payload.sentinel === 'BLOCKED' || payload.sentinel === 'FAILED') {
|
|
270
328
|
nextContext.blockedFromStage = current.wake.stage;
|
|
@@ -359,8 +417,10 @@ async function applyEvent(current, event, ctx, config) {
|
|
|
359
417
|
...current,
|
|
360
418
|
context: {
|
|
361
419
|
...current.context,
|
|
362
|
-
|
|
363
|
-
|
|
420
|
+
deleted: {
|
|
421
|
+
at: event.occurredAt,
|
|
422
|
+
by: typeof event.payload.requestedBy === 'string' ? event.payload.requestedBy : 'unknown',
|
|
423
|
+
},
|
|
364
424
|
},
|
|
365
425
|
wake: {
|
|
366
426
|
...current.wake,
|
|
@@ -380,8 +440,10 @@ async function applyEvent(current, event, ctx, config) {
|
|
|
380
440
|
},
|
|
381
441
|
context: {
|
|
382
442
|
...current.context,
|
|
383
|
-
|
|
384
|
-
|
|
443
|
+
frozen: {
|
|
444
|
+
at: event.occurredAt,
|
|
445
|
+
by: typeof event.payload.requestedBy === 'string' ? event.payload.requestedBy : 'unknown',
|
|
446
|
+
},
|
|
385
447
|
},
|
|
386
448
|
wake: {
|
|
387
449
|
...current.wake,
|
|
@@ -392,8 +454,7 @@ async function applyEvent(current, event, ctx, config) {
|
|
|
392
454
|
}
|
|
393
455
|
if (event.sourceEventType === WORK_ITEM_UNFROZEN_EVENT) {
|
|
394
456
|
const nextContext = { ...current.context };
|
|
395
|
-
delete nextContext.
|
|
396
|
-
delete nextContext.frozenBy;
|
|
457
|
+
delete nextContext.frozen;
|
|
397
458
|
return parseIssueStateRecord({
|
|
398
459
|
...current,
|
|
399
460
|
issue: {
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { createLabelsEvent } from './event-builders.js';
|
|
2
2
|
import { RUN_COMPLETED_EVENT } from '../domain/event-types.js';
|
|
3
|
-
import {
|
|
4
|
-
import { workflowLabelForWorkflowName, workflowNameForProjection } from '../domain/workflows.js';
|
|
3
|
+
import { labelsForWorkItem } from '../domain/work-item-labels.js';
|
|
5
4
|
import { createEventEnvelope } from '../lib/event-log.js';
|
|
6
5
|
// Reconciles run records still marked `running` past the runner timeout: a
|
|
7
6
|
// record whose work item has already moved on is superseded, otherwise it is
|
|
@@ -89,9 +88,7 @@ export function createStaleRunReconciler(deps) {
|
|
|
89
88
|
await deps.deliverOutboundEvent(createLabelsEvent({
|
|
90
89
|
projection,
|
|
91
90
|
runId,
|
|
92
|
-
|
|
93
|
-
stageLabel: stageLabelForStage(projection.wake.stage),
|
|
94
|
-
workflowLabel: workflowLabelForWorkflowName(workflowNameForProjection(projection, deps.config)),
|
|
91
|
+
...labelsForWorkItem(projection, deps.config),
|
|
95
92
|
occurredAt: finishedAt,
|
|
96
93
|
}));
|
|
97
94
|
}
|
|
@@ -8,10 +8,11 @@ import { acquireFileLock } from '../lib/lock.js';
|
|
|
8
8
|
import { CORRELATION_REGISTERED_EVENT, CORRELATION_PRIMARY_CONFLICT_EVENT, PR_AUTO_MERGE_ENABLED_EVENT, PR_REVIEW_APPROVED_EVENT, PUBLISH_INTENT_REQUESTED_EVENT, RUN_CLAIMED_EVENT, RUN_COMPLETED_EVENT, } from '../domain/event-types.js';
|
|
9
9
|
import { parseRunnerArtifacts, parseRunnerResult } from '../domain/schema.js';
|
|
10
10
|
import { maxConfiguredRunnerTimeoutMs, resolveRunnerRouting } from '../domain/runner-routing.js';
|
|
11
|
-
import { awaitingApprovalRunnerSentinel
|
|
12
|
-
import { isWorkItemDeleted, isWorkItemRunnable } from '../domain/work-item-lifecycle.js';
|
|
11
|
+
import { awaitingApprovalRunnerSentinel } from '../domain/stages.js';
|
|
12
|
+
import { FROZEN_WORK_ITEM_LABEL, isWorkItemDeleted, isWorkItemRunnable, } from '../domain/work-item-lifecycle.js';
|
|
13
13
|
import { isMeaningfulRuntimeEvent } from '../domain/runtime-events.js';
|
|
14
|
-
import { chooseAction as chooseWorkflowAction, entryStage as workflowEntryStage, isKnownWorkflowStage, workflowChangedBlockReason, workflowForProjection,
|
|
14
|
+
import { chooseAction as chooseWorkflowAction, entryStage as workflowEntryStage, isKnownWorkflowStage, workflowChangedBlockReason, workflowForProjection, workflowNameForProjection, } from '../domain/workflows.js';
|
|
15
|
+
import { labelsForWorkItem, SCHEDULED_WORKFLOW_LABEL, } from '../domain/work-item-labels.js';
|
|
15
16
|
import { createEventEnvelope } from '../lib/event-log.js';
|
|
16
17
|
import { branchNameForIssue } from '../domain/branch-naming.js';
|
|
17
18
|
import { customCommandWorkspace, isCustomCommandAction } from '../domain/custom-commands.js';
|
|
@@ -205,23 +206,13 @@ export function createTickRunner(deps) {
|
|
|
205
206
|
function isAwaitingApproval(projection) {
|
|
206
207
|
return projection.context.lastRunSentinel === awaitingApprovalRunnerSentinel;
|
|
207
208
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
if (input.sentinel === 'AWAITING_APPROVAL') {
|
|
216
|
-
return 'wake:status.awaiting-approval';
|
|
217
|
-
}
|
|
218
|
-
if (input.sentinel === 'BLOCKED') {
|
|
219
|
-
return 'wake:status.blocked';
|
|
220
|
-
}
|
|
221
|
-
if (input.sentinel === 'FAILED') {
|
|
222
|
-
return 'wake:status.failed';
|
|
223
|
-
}
|
|
224
|
-
return statusLabelForStage(input.stage);
|
|
209
|
+
// Always reads the current projection at write time (never a threaded
|
|
210
|
+
// local like a stale `workflowName`/`claimedStage`), so a mid-run
|
|
211
|
+
// WORKFLOW_SELECTED_EVENT or freeze/schedule change can never be
|
|
212
|
+
// stamped with a label reflecting a snapshot from before it took effect.
|
|
213
|
+
async function currentLabelsForWorkItem(workItemKey, fallback) {
|
|
214
|
+
const projection = (await deps.stateStore.readIssueState(workItemKey)) ?? fallback;
|
|
215
|
+
return labelsForWorkItem(projection, deps.config);
|
|
225
216
|
}
|
|
226
217
|
function hasLabel(projection, label) {
|
|
227
218
|
return projection.issue.labels.includes(label);
|
|
@@ -354,9 +345,7 @@ export function createTickRunner(deps) {
|
|
|
354
345
|
await deliverOutboundEvent(createLabelsEvent({
|
|
355
346
|
projection: input.projection,
|
|
356
347
|
runId,
|
|
357
|
-
|
|
358
|
-
stageLabel: stageLabelForStage(input.projection.wake.stage),
|
|
359
|
-
workflowLabel: workflowLabelForWorkflowName(input.workflowName),
|
|
348
|
+
...(await currentLabelsForWorkItem(input.projection.workItemKey, input.projection)),
|
|
360
349
|
occurredAt,
|
|
361
350
|
}));
|
|
362
351
|
return {
|
|
@@ -486,13 +475,51 @@ export function createTickRunner(deps) {
|
|
|
486
475
|
await deliverOutboundEvent(createLabelsEvent({
|
|
487
476
|
projection: input.projection,
|
|
488
477
|
runId: input.approvalId,
|
|
489
|
-
|
|
490
|
-
stageLabel: stageLabelForStage(nextStage),
|
|
491
|
-
workflowLabel: workflowLabelForWorkflowName(input.workflowName),
|
|
478
|
+
...(await currentLabelsForWorkItem(input.projection.workItemKey, input.projection)),
|
|
492
479
|
occurredAt: input.approvedAt,
|
|
493
480
|
}));
|
|
494
481
|
return { nextStage };
|
|
495
482
|
}
|
|
483
|
+
// Folds a human /changes reply onto the parent's context via the same
|
|
484
|
+
// `changesRequested` RUN_COMPLETED payload shape a rejecting review watcher
|
|
485
|
+
// uses (projection-updater.ts) — same fold, same bounding against
|
|
486
|
+
// config.retry.maxChangesRequestedRetries, so a human bouncing /changes
|
|
487
|
+
// repeatedly escalates to 'blocked' exactly like an auto-revise loop would.
|
|
488
|
+
async function applyChangesRequestedTransition(input) {
|
|
489
|
+
const currentCount = typeof input.projection.context.changesRequestedCount === 'number' &&
|
|
490
|
+
Number.isInteger(input.projection.context.changesRequestedCount)
|
|
491
|
+
? input.projection.context.changesRequestedCount
|
|
492
|
+
: 0;
|
|
493
|
+
const escalated = currentCount + 1 > deps.config.retry.maxChangesRequestedRetries;
|
|
494
|
+
const event = createEventEnvelope({
|
|
495
|
+
eventId: `${input.requestId}-changes-requested`,
|
|
496
|
+
workItemKey: input.projection.workItemKey,
|
|
497
|
+
streamScope: 'work-item',
|
|
498
|
+
direction: 'internal',
|
|
499
|
+
sourceSystem: 'wake',
|
|
500
|
+
sourceEventType: RUN_COMPLETED_EVENT,
|
|
501
|
+
sourceRefs: {
|
|
502
|
+
repo: input.projection.issue.repo,
|
|
503
|
+
issueNumber: input.projection.issue.number,
|
|
504
|
+
runId: input.requestId,
|
|
505
|
+
},
|
|
506
|
+
occurredAt: input.requestedAt,
|
|
507
|
+
ingestedAt: input.requestedAt,
|
|
508
|
+
trigger: 'immediate',
|
|
509
|
+
payload: {
|
|
510
|
+
changesRequested: true,
|
|
511
|
+
runId: input.requestId,
|
|
512
|
+
reason: 'human:changes-requested',
|
|
513
|
+
...(input.feedbackBody === undefined ? {} : { reviewFeedbackBody: input.feedbackBody }),
|
|
514
|
+
...(input.triggeringCommentId === undefined
|
|
515
|
+
? {}
|
|
516
|
+
: { handledCommentId: input.triggeringCommentId }),
|
|
517
|
+
},
|
|
518
|
+
});
|
|
519
|
+
await deps.stateStore.appendEventEnvelope(event);
|
|
520
|
+
await projectionUpdater.rebuildFromEvents([event]);
|
|
521
|
+
return { escalated };
|
|
522
|
+
}
|
|
496
523
|
// Closes the loop on #82's review feedback: rather than scrape a PR link
|
|
497
524
|
// out of the agent's free text, the agent emits a `wake-artifacts` fence
|
|
498
525
|
// (domain/schema.ts's parseRunnerArtifacts) and Wake verifies each claim
|
|
@@ -585,6 +612,11 @@ export function createTickRunner(deps) {
|
|
|
585
612
|
}
|
|
586
613
|
return watch;
|
|
587
614
|
}
|
|
615
|
+
// Runs against every open, non-deleted item every tick (not just ones with
|
|
616
|
+
// an eligible next action) so a stale label self-heals even on a parked
|
|
617
|
+
// item nothing else would otherwise re-check — this is also where
|
|
618
|
+
// wake:frozen/wake:scheduled-workflow get their only reconciliation
|
|
619
|
+
// coverage, since freeze/unfreeze no longer writes labels inline (§6).
|
|
588
620
|
async function markPendingActionableIssues(projections) {
|
|
589
621
|
const activeRunWorkItemKeys = new Set();
|
|
590
622
|
const now = deps.clock.now();
|
|
@@ -594,24 +626,28 @@ export function createTickRunner(deps) {
|
|
|
594
626
|
}
|
|
595
627
|
}
|
|
596
628
|
for (const projection of projections) {
|
|
597
|
-
if (
|
|
629
|
+
if (isWorkItemDeleted(projection) || activeRunWorkItemKeys.has(projection.workItemKey)) {
|
|
598
630
|
continue;
|
|
599
631
|
}
|
|
600
|
-
const
|
|
601
|
-
const
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
(hasLabel(projection,
|
|
605
|
-
|
|
606
|
-
|
|
632
|
+
const labels = labelsForWorkItem(projection, deps.config);
|
|
633
|
+
const matchesDesired = hasLabel(projection, labels.statusLabel) &&
|
|
634
|
+
hasLabel(projection, labels.stageLabel) &&
|
|
635
|
+
hasLabel(projection, labels.workflowLabel) &&
|
|
636
|
+
(labels.frozenLabel === undefined || hasLabel(projection, labels.frozenLabel)) &&
|
|
637
|
+
(labels.scheduledLabel === undefined || hasLabel(projection, labels.scheduledLabel));
|
|
638
|
+
// A toggle label (frozen/scheduled) whose desired state is "absent"
|
|
639
|
+
// still needs to be checked for stray presence — the family-prefix
|
|
640
|
+
// labels above don't need this since removing them is folded into
|
|
641
|
+
// "does the current one match" by construction.
|
|
642
|
+
const hasStrayToggleLabel = (labels.frozenLabel === undefined && hasLabel(projection, FROZEN_WORK_ITEM_LABEL)) ||
|
|
643
|
+
(labels.scheduledLabel === undefined && hasLabel(projection, SCHEDULED_WORKFLOW_LABEL));
|
|
644
|
+
if (matchesDesired && !hasStrayToggleLabel) {
|
|
607
645
|
continue;
|
|
608
646
|
}
|
|
609
647
|
await deliverOutboundEvent(createLabelsEvent({
|
|
610
648
|
projection,
|
|
611
649
|
runId: `pending-${projection.workItemKey}-${deps.clock.now().getTime()}`,
|
|
612
|
-
|
|
613
|
-
stageLabel,
|
|
614
|
-
workflowLabel,
|
|
650
|
+
...labels,
|
|
615
651
|
occurredAt: eventStampNow(),
|
|
616
652
|
}));
|
|
617
653
|
}
|
|
@@ -786,9 +822,7 @@ export function createTickRunner(deps) {
|
|
|
786
822
|
await deliverOutboundEvent(createLabelsEvent({
|
|
787
823
|
projection,
|
|
788
824
|
runId: eventId,
|
|
789
|
-
|
|
790
|
-
stageLabel: stageLabelForStage(projection.wake.stage),
|
|
791
|
-
workflowLabel: workflowLabelForWorkflowName(workflowNameForProjection(projection, deps.config)),
|
|
825
|
+
...(await currentLabelsForWorkItem(projection.workItemKey, projection)),
|
|
792
826
|
occurredAt,
|
|
793
827
|
}));
|
|
794
828
|
parked = true;
|
|
@@ -842,9 +876,10 @@ export function createTickRunner(deps) {
|
|
|
842
876
|
}
|
|
843
877
|
const projections = await deps.stateStore.listIssueStates();
|
|
844
878
|
await cleanupClosedIssueWorkspaces(projections);
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
879
|
+
// Runs every tick, not only when this tick happened to poll a fresh
|
|
880
|
+
// inbound event — a stale label on a parked item (nothing else ever
|
|
881
|
+
// re-checks it once there's no next action) otherwise never self-heals.
|
|
882
|
+
await markPendingActionableIssues(projections);
|
|
848
883
|
return {
|
|
849
884
|
status: inboundEvents.length > 0 ? 'processed' : 'idle',
|
|
850
885
|
};
|
|
@@ -1191,15 +1226,30 @@ export function createTickRunner(deps) {
|
|
|
1191
1226
|
else {
|
|
1192
1227
|
const approvalResolution = policy.resolveApprovalTransition(candidate);
|
|
1193
1228
|
if (approvalResolution === null) {
|
|
1194
|
-
const
|
|
1195
|
-
if (
|
|
1196
|
-
|
|
1229
|
+
const changesRequestedAction = policy.resolveChangesRequestedAction(candidate);
|
|
1230
|
+
if (changesRequestedAction !== null) {
|
|
1231
|
+
action = changesRequestedAction;
|
|
1232
|
+
const workflowAction = chooseWorkflowAction(candidate, workflow);
|
|
1233
|
+
claimedStage = workflowAction?.stage ?? candidate.wake.stage;
|
|
1234
|
+
workspaceMode = workflowAction?.workspace ?? 'none';
|
|
1235
|
+
promptContextOverrides = {
|
|
1236
|
+
...workflowAction?.promptContext,
|
|
1237
|
+
...(typeof candidate.context.changesRequestedFeedback === 'string'
|
|
1238
|
+
? { parentPendingReviewBody: candidate.context.changesRequestedFeedback }
|
|
1239
|
+
: {}),
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
else {
|
|
1243
|
+
const reviewAction = policy.resolvePendingReviewFeedback(candidate);
|
|
1244
|
+
if (reviewAction === null) {
|
|
1245
|
+
return { status: 'idle' };
|
|
1246
|
+
}
|
|
1247
|
+
action = reviewAction;
|
|
1248
|
+
const workflowAction = chooseWorkflowAction(candidate, workflow);
|
|
1249
|
+
claimedStage = workflowAction?.stage ?? candidate.wake.stage;
|
|
1250
|
+
workspaceMode = workflowAction?.workspace ?? 'none';
|
|
1251
|
+
promptContextOverrides = workflowAction?.promptContext;
|
|
1197
1252
|
}
|
|
1198
|
-
action = reviewAction;
|
|
1199
|
-
const workflowAction = chooseWorkflowAction(candidate, workflow);
|
|
1200
|
-
claimedStage = workflowAction?.stage ?? candidate.wake.stage;
|
|
1201
|
-
workspaceMode = workflowAction?.workspace ?? 'none';
|
|
1202
|
-
promptContextOverrides = workflowAction?.promptContext;
|
|
1203
1253
|
}
|
|
1204
1254
|
else if (approvalResolution.approved) {
|
|
1205
1255
|
const mergePolicy = approvedMergePolicyForStage(workflow.stages[candidate.wake.stage]);
|
|
@@ -1294,11 +1344,34 @@ export function createTickRunner(deps) {
|
|
|
1294
1344
|
};
|
|
1295
1345
|
}
|
|
1296
1346
|
else {
|
|
1347
|
+
const changesRequestId = `changes-requested-${candidate.issue.number}-${deps.clock.now().getTime()}`;
|
|
1348
|
+
const requestedAt = deps.clock.now().toISOString();
|
|
1349
|
+
const changesRequestedTriggeringCommentId = approvalResolution.triggeringCommentId ?? latestHumanCommentId(candidate);
|
|
1350
|
+
const { escalated } = await applyChangesRequestedTransition({
|
|
1351
|
+
projection: candidate,
|
|
1352
|
+
requestId: changesRequestId,
|
|
1353
|
+
requestedAt,
|
|
1354
|
+
...(approvalResolution.triggeringCommentBody === undefined
|
|
1355
|
+
? {}
|
|
1356
|
+
: { feedbackBody: approvalResolution.triggeringCommentBody }),
|
|
1357
|
+
...(changesRequestedTriggeringCommentId === undefined
|
|
1358
|
+
? {}
|
|
1359
|
+
: { triggeringCommentId: changesRequestedTriggeringCommentId }),
|
|
1360
|
+
});
|
|
1361
|
+
if (escalated) {
|
|
1362
|
+
return { status: 'idle' };
|
|
1363
|
+
}
|
|
1297
1364
|
action = approvalResolution.pendingAction;
|
|
1298
1365
|
const workflowAction = chooseWorkflowAction(candidate, workflow);
|
|
1299
1366
|
claimedStage = workflowAction?.stage ?? candidate.wake.stage;
|
|
1300
1367
|
workspaceMode = workflowAction?.workspace ?? 'none';
|
|
1301
|
-
promptContextOverrides =
|
|
1368
|
+
promptContextOverrides = {
|
|
1369
|
+
...workflowAction?.promptContext,
|
|
1370
|
+
...(approvalResolution.changesRequested === true &&
|
|
1371
|
+
approvalResolution.triggeringCommentBody !== undefined
|
|
1372
|
+
? { parentPendingReviewBody: approvalResolution.triggeringCommentBody }
|
|
1373
|
+
: {}),
|
|
1374
|
+
};
|
|
1302
1375
|
}
|
|
1303
1376
|
}
|
|
1304
1377
|
}
|
|
@@ -1480,9 +1553,7 @@ export function createTickRunner(deps) {
|
|
|
1480
1553
|
await deliverOutboundEvent(createLabelsEvent({
|
|
1481
1554
|
projection: candidate,
|
|
1482
1555
|
runId,
|
|
1483
|
-
|
|
1484
|
-
stageLabel: stageLabelForStage(claimedStage),
|
|
1485
|
-
workflowLabel: workflowLabelForWorkflowName(workflowName),
|
|
1556
|
+
...(await currentLabelsForWorkItem(candidate.workItemKey, candidate)),
|
|
1486
1557
|
occurredAt: eventStampNow(),
|
|
1487
1558
|
}));
|
|
1488
1559
|
}
|
|
@@ -1865,6 +1936,17 @@ export function createTickRunner(deps) {
|
|
|
1865
1936
|
...resultMetadata,
|
|
1866
1937
|
},
|
|
1867
1938
|
});
|
|
1939
|
+
// Computed before the payload so a rejecting review verdict (either
|
|
1940
|
+
// the PR-artifact-verified path or a plain onSuccess.approve watcher
|
|
1941
|
+
// with no PR) can fold context.status = 'changes-requested' on the
|
|
1942
|
+
// parent via the same RUN_COMPLETED event, instead of requiring a
|
|
1943
|
+
// separate marker comment round-trip (§5).
|
|
1944
|
+
const watcherSuccessPolicy = watcherRun && watcherDispatch !== null
|
|
1945
|
+
? deps.config.workflows[watcherDispatch.parentWorkflowName]?.stages[watcherDispatch.parentStage]?.watch?.[watcherDispatch.watcherIndex]?.onSuccess
|
|
1946
|
+
: undefined;
|
|
1947
|
+
const isReviewRejection = watcherRun &&
|
|
1948
|
+
(sentinel === 'FAILED' || sentinel === 'BLOCKED') &&
|
|
1949
|
+
(prReviewTargetResourceUri !== null || watcherSuccessPolicy?.approve === true);
|
|
1868
1950
|
const runCompletedEvent = createEventEnvelope({
|
|
1869
1951
|
eventId: `${runId}-completed`,
|
|
1870
1952
|
workItemKey: candidate.workItemKey,
|
|
@@ -1912,6 +1994,9 @@ export function createTickRunner(deps) {
|
|
|
1912
1994
|
executionOutcome,
|
|
1913
1995
|
...(workflowOutcome !== undefined ? { workflowOutcome } : {}),
|
|
1914
1996
|
...(watcherRun ? { watcherRun: true, watcherTrigger: watcherTriggerForRun } : {}),
|
|
1997
|
+
...(isReviewRejection
|
|
1998
|
+
? { changesRequested: true, reviewFeedbackBody: parsedRunnerResult.body }
|
|
1999
|
+
: {}),
|
|
1915
2000
|
},
|
|
1916
2001
|
});
|
|
1917
2002
|
await deps.stateStore.appendEventEnvelope(runCompletedEvent);
|
|
@@ -1920,12 +2005,7 @@ export function createTickRunner(deps) {
|
|
|
1920
2005
|
await deliverOutboundEvent(createLabelsEvent({
|
|
1921
2006
|
projection: candidate,
|
|
1922
2007
|
runId,
|
|
1923
|
-
|
|
1924
|
-
sentinel,
|
|
1925
|
-
stage: nextStage ?? claimedStage,
|
|
1926
|
-
}),
|
|
1927
|
-
stageLabel: stageLabelForStage(nextStage ?? claimedStage),
|
|
1928
|
-
workflowLabel: workflowLabelForWorkflowName(workflowName),
|
|
2008
|
+
...(await currentLabelsForWorkItem(candidate.workItemKey, candidate)),
|
|
1929
2009
|
occurredAt: finishedAt,
|
|
1930
2010
|
}));
|
|
1931
2011
|
}
|
|
@@ -1947,9 +2027,6 @@ export function createTickRunner(deps) {
|
|
|
1947
2027
|
// Watcher-dispatched runs own PR verdict delivery and correlation
|
|
1948
2028
|
// registration regardless of the target workflow/action name.
|
|
1949
2029
|
const pendingApprovalAction = candidate.context.pendingApprovalAction;
|
|
1950
|
-
const watcherSuccessPolicy = watcherDispatch === null
|
|
1951
|
-
? undefined
|
|
1952
|
-
: deps.config.workflows[watcherDispatch.parentWorkflowName]?.stages[watcherDispatch.parentStage]?.watch?.[watcherDispatch.watcherIndex]?.onSuccess;
|
|
1953
2030
|
if (prReviewTargetResourceUri !== null &&
|
|
1954
2031
|
(sentinel === 'DONE' || sentinel === 'FAILED')) {
|
|
1955
2032
|
await deliverOutboundEvent({
|
|
@@ -2125,9 +2202,7 @@ export function createTickRunner(deps) {
|
|
|
2125
2202
|
await deliverOutboundEvent(createLabelsEvent({
|
|
2126
2203
|
projection: candidate,
|
|
2127
2204
|
runId,
|
|
2128
|
-
|
|
2129
|
-
stageLabel: stageLabelForStage(claimedStage),
|
|
2130
|
-
workflowLabel: workflowLabelForWorkflowName(workflowName),
|
|
2205
|
+
...(await currentLabelsForWorkItem(candidate.workItemKey, candidate)),
|
|
2131
2206
|
occurredAt: finishedAt,
|
|
2132
2207
|
}));
|
|
2133
2208
|
}
|
|
@@ -7,6 +7,7 @@ import { runnerSentinelValues } from './stages.js';
|
|
|
7
7
|
import { correlationProvenanceSchema, correlationRelationSchema, correlationRoleSchema, resourceUriSchema, } from './resource-uri.js';
|
|
8
8
|
import { runtimeEventTypeValues } from './runtime-events.js';
|
|
9
9
|
import { alwaysManualIgnoredLabels } from './manual-labels.js';
|
|
10
|
+
import { workItemStatusSchema } from './work-item-status.js';
|
|
10
11
|
export { AUTONOMOUS_DECISION_AUDIT_EVENT, CORRELATION_PRIMARY_CONFLICT_EVENT, CORRELATION_REGISTERED_EVENT, CORRELATION_RETRACTED_EVENT, WORK_ITEM_CREATED_EVENT, } from './event-types.js';
|
|
11
12
|
const isoTimestampSchema = z.string().datetime({ offset: true });
|
|
12
13
|
const identifierSchema = z.string().min(1);
|
|
@@ -275,6 +276,61 @@ export const eventEnvelopeSchema = z.object({
|
|
|
275
276
|
raw: z.record(z.string(), z.unknown()).optional(),
|
|
276
277
|
derivedHints: z.record(z.string(), z.unknown()).optional(),
|
|
277
278
|
});
|
|
279
|
+
export const failurePhaseSchema = z.enum([
|
|
280
|
+
'workspace-validation',
|
|
281
|
+
'workspace-prep',
|
|
282
|
+
'process-starting',
|
|
283
|
+
'running',
|
|
284
|
+
'result-parsing',
|
|
285
|
+
'publishing',
|
|
286
|
+
'unknown',
|
|
287
|
+
]);
|
|
288
|
+
export const externalSideEffectsSchema = z.enum(['none', 'confirmed', 'unknown']);
|
|
289
|
+
export const retrySafetySchema = z.enum([
|
|
290
|
+
'SAFE_TO_RETRY',
|
|
291
|
+
'SAFE_TO_RESUME',
|
|
292
|
+
'REQUIRES_RECONCILIATION',
|
|
293
|
+
'MANUAL_REVIEW_REQUIRED',
|
|
294
|
+
'NOT_RETRYABLE',
|
|
295
|
+
]);
|
|
296
|
+
// Typed `context` — every field is optional so an on-disk projection missing
|
|
297
|
+
// some/all of them (pre-dating this schema, or simply untouched since) still
|
|
298
|
+
// parses. See docs/superpowers/specs/2026-07-28-canonical-work-item-status-design.md §4/§8.
|
|
299
|
+
export const issueContextSchema = z.object({
|
|
300
|
+
// Retry/failure bookkeeping, folded from RUN_COMPLETED (projection-updater.ts).
|
|
301
|
+
failureCount: z.number().int().nonnegative().optional(),
|
|
302
|
+
lastRunAction: z.string().optional(),
|
|
303
|
+
lastCompletedAction: z.string().optional(),
|
|
304
|
+
lastExecutionOutcome: executionOutcomeSchema.optional(),
|
|
305
|
+
lastWorkflowOutcome: workflowOutcomeSchema.optional(),
|
|
306
|
+
lastFailurePhase: failurePhaseSchema.optional(),
|
|
307
|
+
lastProcessStarted: z.boolean().optional(),
|
|
308
|
+
lastWorkspaceChanged: z.boolean().optional(),
|
|
309
|
+
lastExternalSideEffects: externalSideEffectsSchema.optional(),
|
|
310
|
+
lastRetrySafety: retrySafetySchema.optional(),
|
|
311
|
+
blockedFromStage: z.string().optional(),
|
|
312
|
+
lastFailureClass: z.enum(['task', 'quota', 'infra']).optional(),
|
|
313
|
+
lastHandledCommentId: z.string().optional(),
|
|
314
|
+
lastRunSentinel: runnerSentinelSchema.optional(),
|
|
315
|
+
// Set while AWAITING_APPROVAL so the approval path knows which action to
|
|
316
|
+
// resume (or skip) once a human responds.
|
|
317
|
+
pendingApprovalAction: z.string().optional(),
|
|
318
|
+
pendingApprovalAllowAutoApproval: z.boolean().optional(),
|
|
319
|
+
// Workflow pinned at mint time (WORKFLOW_SELECTED_EVENT); re-read at every
|
|
320
|
+
// label write instead of trusting a threaded local (workflow-name-drift, §6).
|
|
321
|
+
workflow: z.string().optional(),
|
|
322
|
+
// Canonical status — see work-item-status.ts. Additive alongside
|
|
323
|
+
// lastRunSentinel, not a replacement for it (§3).
|
|
324
|
+
status: workItemStatusSchema.optional(),
|
|
325
|
+
changesRequestedCount: z.number().int().nonnegative().optional(),
|
|
326
|
+
// Feedback body threaded into the next auto-revise prompt as
|
|
327
|
+
// promptContextOverrides.parentPendingReviewBody (§5).
|
|
328
|
+
changesRequestedFeedback: z.string().optional(),
|
|
329
|
+
// Orthogonal facts — not folded into `status` (§2).
|
|
330
|
+
frozen: z.object({ at: isoTimestampSchema, by: z.string() }).optional(),
|
|
331
|
+
deleted: z.object({ at: isoTimestampSchema, by: z.string() }).optional(),
|
|
332
|
+
scheduled: z.boolean().optional(),
|
|
333
|
+
});
|
|
278
334
|
export const issueStateRecordSchema = z.object({
|
|
279
335
|
schemaVersion: z.literal(1),
|
|
280
336
|
workItemKey: z.string(),
|
|
@@ -299,7 +355,7 @@ export const issueStateRecordSchema = z.object({
|
|
|
299
355
|
})
|
|
300
356
|
.default({ commentIds: [], labels: [] }),
|
|
301
357
|
}),
|
|
302
|
-
context:
|
|
358
|
+
context: issueContextSchema.default({}),
|
|
303
359
|
correlatedResources: z.array(correlatedResourceSchema).default([]),
|
|
304
360
|
});
|
|
305
361
|
export const runInputSnapshotSchema = z.object({
|
|
@@ -338,23 +394,6 @@ const runLeaseSchema = z.object({
|
|
|
338
394
|
lastRenewedAt: isoTimestampSchema,
|
|
339
395
|
expiresAt: isoTimestampSchema,
|
|
340
396
|
});
|
|
341
|
-
export const failurePhaseSchema = z.enum([
|
|
342
|
-
'workspace-validation',
|
|
343
|
-
'workspace-prep',
|
|
344
|
-
'process-starting',
|
|
345
|
-
'running',
|
|
346
|
-
'result-parsing',
|
|
347
|
-
'publishing',
|
|
348
|
-
'unknown',
|
|
349
|
-
]);
|
|
350
|
-
export const externalSideEffectsSchema = z.enum(['none', 'confirmed', 'unknown']);
|
|
351
|
-
export const retrySafetySchema = z.enum([
|
|
352
|
-
'SAFE_TO_RETRY',
|
|
353
|
-
'SAFE_TO_RESUME',
|
|
354
|
-
'REQUIRES_RECONCILIATION',
|
|
355
|
-
'MANUAL_REVIEW_REQUIRED',
|
|
356
|
-
'NOT_RETRYABLE',
|
|
357
|
-
]);
|
|
358
397
|
function legacyRunLifecycle(input) {
|
|
359
398
|
if (input.lifecycle !== undefined) {
|
|
360
399
|
return input;
|
|
@@ -649,8 +688,9 @@ const wakeConfigBaseSchema = z.object({
|
|
|
649
688
|
retry: z
|
|
650
689
|
.object({
|
|
651
690
|
maxFailureRetries: z.number().int().positive().default(5),
|
|
691
|
+
maxChangesRequestedRetries: z.number().int().positive().default(5),
|
|
652
692
|
})
|
|
653
|
-
.default({ maxFailureRetries: 5 }),
|
|
693
|
+
.default({ maxFailureRetries: 5, maxChangesRequestedRetries: 5 }),
|
|
654
694
|
runners: z.record(z.string(), runnerEntrySchema).default({
|
|
655
695
|
fake: { kind: 'fake', cli: 'Fake' },
|
|
656
696
|
'claude-haiku': {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { stageLabelForStage } from './stages.js';
|
|
2
|
+
import { FROZEN_WORK_ITEM_LABEL } from './work-item-lifecycle.js';
|
|
3
|
+
import { workflowLabelForWorkflowName, workflowNameForProjection } from './workflows.js';
|
|
4
|
+
// The single place that translates the canonical, typed context fields
|
|
5
|
+
// (status/frozen/deleted/scheduled) into wake:* label strings. Lives in
|
|
6
|
+
// domain/ (not adapters/github/) so core/ can call it directly without
|
|
7
|
+
// importing a concrete adapter — the wake: naming convention is Wake's own,
|
|
8
|
+
// not GitHub-specific, matching stageLabelForStage/workflowLabelForWorkflowName
|
|
9
|
+
// which already live here for the same reason.
|
|
10
|
+
export const SCHEDULED_WORKFLOW_LABEL = 'wake:scheduled-workflow';
|
|
11
|
+
const statusLabelByStatus = {
|
|
12
|
+
queued: 'wake:status.pending',
|
|
13
|
+
working: 'wake:status.working',
|
|
14
|
+
'awaiting-approval': 'wake:status.awaiting-approval',
|
|
15
|
+
'changes-requested': 'wake:status.changes-requested',
|
|
16
|
+
blocked: 'wake:status.blocked',
|
|
17
|
+
done: 'wake:status.completed',
|
|
18
|
+
failed: 'wake:status.failed',
|
|
19
|
+
};
|
|
20
|
+
// A projection that predates context.status being folded (or hasn't had a
|
|
21
|
+
// run complete yet since this design shipped) has no status field — fall
|
|
22
|
+
// back to the same sentinel+stage heuristic the old statusLabelForOutcome
|
|
23
|
+
// used, so an untouched legacy item is translated to the *same* label it
|
|
24
|
+
// already carries (e.g. still-AWAITING_APPROVAL) instead of drifting to a
|
|
25
|
+
// wrong one just because context.status hasn't been folded onto it yet.
|
|
26
|
+
function legacyStatusFallback(projection) {
|
|
27
|
+
const sentinel = projection.context.lastRunSentinel;
|
|
28
|
+
if (sentinel === 'AWAITING_APPROVAL') {
|
|
29
|
+
return 'awaiting-approval';
|
|
30
|
+
}
|
|
31
|
+
if (sentinel === 'BLOCKED') {
|
|
32
|
+
return 'blocked';
|
|
33
|
+
}
|
|
34
|
+
if (sentinel === 'FAILED') {
|
|
35
|
+
return 'failed';
|
|
36
|
+
}
|
|
37
|
+
return projection.wake.stage === 'done' ? 'done' : 'queued';
|
|
38
|
+
}
|
|
39
|
+
export function labelsForWorkItem(projection, config) {
|
|
40
|
+
const statusLabel = statusLabelByStatus[projection.context.status ?? legacyStatusFallback(projection)];
|
|
41
|
+
return {
|
|
42
|
+
statusLabel,
|
|
43
|
+
stageLabel: stageLabelForStage(projection.wake.stage),
|
|
44
|
+
workflowLabel: workflowLabelForWorkflowName(workflowNameForProjection(projection, config)),
|
|
45
|
+
...(projection.context.frozen !== undefined ? { frozenLabel: FROZEN_WORK_ITEM_LABEL } : {}),
|
|
46
|
+
...(projection.context.scheduled === true ? { scheduledLabel: SCHEDULED_WORKFLOW_LABEL } : {}),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
export const FROZEN_WORK_ITEM_LABEL = 'wake:frozen';
|
|
2
2
|
export function isWorkItemDeleted(item) {
|
|
3
|
-
return
|
|
3
|
+
return item.context.deleted !== undefined;
|
|
4
4
|
}
|
|
5
5
|
export function isWorkItemFrozen(item) {
|
|
6
|
-
return
|
|
6
|
+
return item.context.frozen !== undefined;
|
|
7
7
|
}
|
|
8
8
|
export function isWorkItemRunnable(item) {
|
|
9
9
|
return !isWorkItemDeleted(item) && !isWorkItemFrozen(item);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
// Canonical, typed status for a work item — folded once in
|
|
3
|
+
// projection-updater.ts, translated (never recomputed) everywhere else
|
|
4
|
+
// (e.g. GitHub labels via labelsForWorkItem). See
|
|
5
|
+
// docs/superpowers/specs/2026-07-28-canonical-work-item-status-design.md.
|
|
6
|
+
export const workItemStatusValues = [
|
|
7
|
+
'queued',
|
|
8
|
+
'working',
|
|
9
|
+
'awaiting-approval',
|
|
10
|
+
'changes-requested',
|
|
11
|
+
'blocked',
|
|
12
|
+
'done',
|
|
13
|
+
'failed',
|
|
14
|
+
];
|
|
15
|
+
export const workItemStatusSchema = z.enum(workItemStatusValues);
|
|
16
|
+
// Mirrors the sentinel/stage mapping that used to live only in
|
|
17
|
+
// tick-runner.ts's statusLabelForOutcome/statusLabelForStage. Folded onto
|
|
18
|
+
// context.status alongside (not replacing) context.lastRunSentinel.
|
|
19
|
+
export function workItemStatusForRunOutcome(input) {
|
|
20
|
+
if (input.sentinel === 'AWAITING_APPROVAL') {
|
|
21
|
+
return 'awaiting-approval';
|
|
22
|
+
}
|
|
23
|
+
if (input.sentinel === 'BLOCKED') {
|
|
24
|
+
return 'blocked';
|
|
25
|
+
}
|
|
26
|
+
if (input.sentinel === 'FAILED') {
|
|
27
|
+
return 'failed';
|
|
28
|
+
}
|
|
29
|
+
return input.stage === 'done' ? 'done' : 'queued';
|
|
30
|
+
}
|
package/dist/src/version.js
CHANGED