@atolis-hq/wake 0.1.0
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/LICENSE +201 -0
- package/README.md +140 -0
- package/dist/src/adapters/claude/claude-runner.js +388 -0
- package/dist/src/adapters/claude/prompt-templates.js +57 -0
- package/dist/src/adapters/codex/codex-runner.js +391 -0
- package/dist/src/adapters/cursor/cursor-runner.js +352 -0
- package/dist/src/adapters/docker/docker-cli.js +97 -0
- package/dist/src/adapters/fake/fake-artifact-verifier.js +14 -0
- package/dist/src/adapters/fake/fake-github-pull-request-activity-source.js +73 -0
- package/dist/src/adapters/fake/fake-resource-index.js +21 -0
- package/dist/src/adapters/fake/fake-runner.js +22 -0
- package/dist/src/adapters/fake/fake-ticketing-system.js +166 -0
- package/dist/src/adapters/fake/fake-workspace-manager.js +27 -0
- package/dist/src/adapters/fs/resource-index.js +84 -0
- package/dist/src/adapters/fs/self-update-ledger.js +17 -0
- package/dist/src/adapters/fs/state-store.js +364 -0
- package/dist/src/adapters/git/git-workspace-manager.js +168 -0
- package/dist/src/adapters/github/github-artifact-verifier.js +38 -0
- package/dist/src/adapters/github/github-auth.js +16 -0
- package/dist/src/adapters/github/github-client.js +100 -0
- package/dist/src/adapters/github/github-issues-work-source.js +410 -0
- package/dist/src/adapters/github/github-pull-request-activity-source.js +263 -0
- package/dist/src/adapters/http/ui-assets.js +302 -0
- package/dist/src/adapters/http/ui-data.js +389 -0
- package/dist/src/adapters/http/ui-server.js +151 -0
- package/dist/src/adapters/runner/cli-command.js +43 -0
- package/dist/src/adapters/runner/prompt-templates.js +76 -0
- package/dist/src/adapters/runner/runner-cli-adapter.js +112 -0
- package/dist/src/adapters/runner/runner-registry.js +141 -0
- package/dist/src/adapters/runner/stage-prompt.js +256 -0
- package/dist/src/adapters/runner/transcripts.js +25 -0
- package/dist/src/cli/correlate-command.js +62 -0
- package/dist/src/cli/init-command.js +11 -0
- package/dist/src/cli/locks-command.js +19 -0
- package/dist/src/cli/sandbox-command.js +191 -0
- package/dist/src/cli/sandbox-logging.js +30 -0
- package/dist/src/cli/sandbox-resume.js +77 -0
- package/dist/src/cli/scaffold-assets.js +173 -0
- package/dist/src/cli/self-update-command.js +158 -0
- package/dist/src/cli/startup-preflight.js +127 -0
- package/dist/src/cli/stop-command.js +42 -0
- package/dist/src/cli/ui-command.js +27 -0
- package/dist/src/config/defaults.js +11 -0
- package/dist/src/config/load-config.js +26 -0
- package/dist/src/core/contracts.js +1 -0
- package/dist/src/core/control-plane.js +127 -0
- package/dist/src/core/lifecycle-service.js +8 -0
- package/dist/src/core/policy-engine.js +200 -0
- package/dist/src/core/projection-updater.js +438 -0
- package/dist/src/core/quota-backoff.js +69 -0
- package/dist/src/core/sink-router.js +85 -0
- package/dist/src/core/tick-runner.js +1347 -0
- package/dist/src/domain/branch-naming.js +14 -0
- package/dist/src/domain/resource-uri.js +38 -0
- package/dist/src/domain/runner-routing.js +108 -0
- package/dist/src/domain/schema.js +685 -0
- package/dist/src/domain/sources.js +16 -0
- package/dist/src/domain/stages.js +39 -0
- package/dist/src/domain/types.js +1 -0
- package/dist/src/domain/workflows.js +83 -0
- package/dist/src/lib/clock.js +5 -0
- package/dist/src/lib/event-log.js +21 -0
- package/dist/src/lib/json-file.js +23 -0
- package/dist/src/lib/lock.js +118 -0
- package/dist/src/lib/paths.js +44 -0
- package/dist/src/lib/work-id.js +19 -0
- package/dist/src/main.js +523 -0
- package/dist/src/version.js +1 -0
- package/docker/Dockerfile +54 -0
- package/docker/entrypoint.sh +75 -0
- package/docker/log-command.sh +107 -0
- package/docker/setup.sh +72 -0
- package/package.json +52 -0
- package/prompts/implement.md +49 -0
- package/prompts/refine.md +44 -0
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
import { CORRELATION_PRIMARY_CONFLICT_EVENT, CORRELATION_REGISTERED_EVENT, CORRELATION_RETRACTED_EVENT, UNRESOLVED_WORK_ITEM_KEY, parseIssueStateRecord, } from '../domain/schema.js';
|
|
2
|
+
import { doneRunnerSentinel, stageFromLabels } from '../domain/stages.js';
|
|
3
|
+
import { builtInDefaultWorkflowDefinition, defaultWorkflowName, workflowStageVocabulary, } from '../domain/workflows.js';
|
|
4
|
+
import { createEventEnvelope } from '../lib/event-log.js';
|
|
5
|
+
function stringArrayFromPayload(value) {
|
|
6
|
+
return Array.isArray(value)
|
|
7
|
+
? value.filter((entry) => typeof entry === 'string')
|
|
8
|
+
: [];
|
|
9
|
+
}
|
|
10
|
+
function configuredStagesForLabels(config) {
|
|
11
|
+
if (config === undefined) {
|
|
12
|
+
return workflowStageVocabulary(builtInDefaultWorkflowDefinition);
|
|
13
|
+
}
|
|
14
|
+
const workflow = config.workflows[defaultWorkflowName(config)];
|
|
15
|
+
return workflow === undefined ? undefined : workflowStageVocabulary(workflow);
|
|
16
|
+
}
|
|
17
|
+
function createProjectionFromIssueEvent(event, config) {
|
|
18
|
+
const issue = event.sourceEventType === 'ticket.upsert'
|
|
19
|
+
? event.payload.ticket
|
|
20
|
+
: event.payload.issue;
|
|
21
|
+
if (issue === undefined || typeof issue !== 'object' || issue === null) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
const labels = Array.isArray(issue.labels)
|
|
25
|
+
? (issue.labels)
|
|
26
|
+
: [];
|
|
27
|
+
return parseIssueStateRecord({
|
|
28
|
+
schemaVersion: 1,
|
|
29
|
+
workItemKey: event.workItemKey,
|
|
30
|
+
origin: event.sourceSystem,
|
|
31
|
+
issue,
|
|
32
|
+
wake: {
|
|
33
|
+
stage: stageFromLabels(labels, configuredStagesForLabels(config)) ?? 'queue',
|
|
34
|
+
stageHistory: [],
|
|
35
|
+
recentEventIds: [event.eventId],
|
|
36
|
+
syncedAt: event.ingestedAt,
|
|
37
|
+
},
|
|
38
|
+
context: {},
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
async function applyEvent(current, event, ctx, config) {
|
|
42
|
+
// The shared sentinel for events whose resource failed mint qualification
|
|
43
|
+
// (tick-runner.ts's resolveInboundEvent, spec D1'). These are durable and
|
|
44
|
+
// inspectable via the event log but must never materialize a projection —
|
|
45
|
+
// otherwise every unqualified ticket.upsert/fake.issue.upsert would create
|
|
46
|
+
// one here on its first sighting (current === null), the same as a real
|
|
47
|
+
// mint, defeating the entire point of the gate. `current` is always null
|
|
48
|
+
// for this key (readIssueState(UNRESOLVED_WORK_ITEM_KEY) never has a file
|
|
49
|
+
// to read), so returning it unchanged is equivalent to "no projection".
|
|
50
|
+
if (event.workItemKey === UNRESOLVED_WORK_ITEM_KEY) {
|
|
51
|
+
return current;
|
|
52
|
+
}
|
|
53
|
+
if (event.sourceEventType === 'fake.issue.upsert' ||
|
|
54
|
+
event.sourceEventType === 'ticket.upsert') {
|
|
55
|
+
const next = createProjectionFromIssueEvent(event, config);
|
|
56
|
+
if (next === null) {
|
|
57
|
+
return current;
|
|
58
|
+
}
|
|
59
|
+
if (current === null) {
|
|
60
|
+
return next;
|
|
61
|
+
}
|
|
62
|
+
const nextStageFromLabels = stageFromLabels(next.issue.labels, configuredStagesForLabels(config));
|
|
63
|
+
const shouldReconcileStage = nextStageFromLabels !== undefined && nextStageFromLabels !== current.wake.stage;
|
|
64
|
+
return parseIssueStateRecord({
|
|
65
|
+
...current,
|
|
66
|
+
origin: current.origin,
|
|
67
|
+
issue: next.issue,
|
|
68
|
+
wake: {
|
|
69
|
+
...current.wake,
|
|
70
|
+
...(shouldReconcileStage ? { stage: nextStageFromLabels } : {}),
|
|
71
|
+
syncedAt: event.ingestedAt,
|
|
72
|
+
stageHistory: shouldReconcileStage
|
|
73
|
+
? [
|
|
74
|
+
...current.wake.stageHistory,
|
|
75
|
+
{
|
|
76
|
+
stage: nextStageFromLabels,
|
|
77
|
+
changedAt: event.occurredAt,
|
|
78
|
+
reason: 'github-label-sync',
|
|
79
|
+
},
|
|
80
|
+
]
|
|
81
|
+
: current.wake.stageHistory,
|
|
82
|
+
recentEventIds: [...current.wake.recentEventIds, event.eventId].slice(-10),
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
if (current === null) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
if (event.sourceEventType === 'fake.issue.comment.created' ||
|
|
90
|
+
event.sourceEventType === 'ticket.comment.created' ||
|
|
91
|
+
event.sourceEventType === 'ticket.comment.updated' ||
|
|
92
|
+
event.sourceEventType === 'pr.comment.created' ||
|
|
93
|
+
event.sourceEventType === 'pr.review.created' ||
|
|
94
|
+
event.sourceEventType === 'pr.review-comment.created') {
|
|
95
|
+
const comment = event.payload.comment;
|
|
96
|
+
if (comment === undefined || typeof comment !== 'object' || comment === null) {
|
|
97
|
+
return current;
|
|
98
|
+
}
|
|
99
|
+
const isBotAuthored = Boolean(event.derivedHints?.botAuthoredComment);
|
|
100
|
+
const nextComment = {
|
|
101
|
+
...comment,
|
|
102
|
+
isBotAuthored,
|
|
103
|
+
};
|
|
104
|
+
const existingComments = current.comments.filter((entry) => entry.id !== String(comment.id));
|
|
105
|
+
return parseIssueStateRecord({
|
|
106
|
+
...current,
|
|
107
|
+
comments: [...existingComments, nextComment],
|
|
108
|
+
latestComment: nextComment,
|
|
109
|
+
wake: {
|
|
110
|
+
...current.wake,
|
|
111
|
+
syncedAt: event.ingestedAt,
|
|
112
|
+
recentEventIds: [...current.wake.recentEventIds, event.eventId].slice(-10),
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
if (event.sourceEventType === 'wake.run.claimed') {
|
|
117
|
+
const payload = event.payload;
|
|
118
|
+
if (payload.claimedStage === undefined) {
|
|
119
|
+
return parseIssueStateRecord({
|
|
120
|
+
...current,
|
|
121
|
+
wake: {
|
|
122
|
+
...current.wake,
|
|
123
|
+
syncedAt: event.ingestedAt,
|
|
124
|
+
recentEventIds: [...current.wake.recentEventIds, event.eventId].slice(-10),
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
return parseIssueStateRecord({
|
|
129
|
+
...current,
|
|
130
|
+
wake: {
|
|
131
|
+
...current.wake,
|
|
132
|
+
stage: payload.claimedStage,
|
|
133
|
+
lastRunId: event.sourceRefs.runId ?? current.wake.lastRunId,
|
|
134
|
+
syncedAt: event.ingestedAt,
|
|
135
|
+
stageHistory: [
|
|
136
|
+
...current.wake.stageHistory,
|
|
137
|
+
{
|
|
138
|
+
stage: payload.claimedStage,
|
|
139
|
+
changedAt: event.occurredAt,
|
|
140
|
+
reason: `run:${payload.action ?? 'unknown'}:claimed`,
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
recentEventIds: [...current.wake.recentEventIds, event.eventId].slice(-10),
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
if (event.sourceEventType === 'wake.run.completed') {
|
|
148
|
+
const payload = event.payload;
|
|
149
|
+
// Clear the session when the stage moves forward (new action needed) or the
|
|
150
|
+
// run failed outright. Keep it for BLOCKED so the same action can resume
|
|
151
|
+
// the in-progress session after a human replies.
|
|
152
|
+
const isForwardProgression = payload.nextStage !== undefined &&
|
|
153
|
+
payload.nextStage !== current.wake.stage;
|
|
154
|
+
const stageChanged = payload.nextStage !== undefined && payload.nextStage !== current.wake.stage;
|
|
155
|
+
const isFailed = payload.sentinel === 'FAILED';
|
|
156
|
+
const shouldClearSession = isForwardProgression || isFailed;
|
|
157
|
+
return parseIssueStateRecord({
|
|
158
|
+
...current,
|
|
159
|
+
context: {
|
|
160
|
+
...current.context,
|
|
161
|
+
lastFailureClass: payload.failureClass,
|
|
162
|
+
...(payload.handledCommentId === undefined
|
|
163
|
+
? {}
|
|
164
|
+
: { lastHandledCommentId: payload.handledCommentId }),
|
|
165
|
+
...(payload.sentinel === undefined
|
|
166
|
+
? {}
|
|
167
|
+
: { lastRunSentinel: payload.sentinel }),
|
|
168
|
+
...(payload.action === undefined
|
|
169
|
+
? {}
|
|
170
|
+
: { lastRunAction: payload.action }),
|
|
171
|
+
...(payload.sentinel === doneRunnerSentinel && payload.action !== undefined
|
|
172
|
+
? { lastCompletedAction: payload.action }
|
|
173
|
+
: {}),
|
|
174
|
+
// Remembered so the approval path knows which action to resume or
|
|
175
|
+
// skip when a human posts /approved.
|
|
176
|
+
...(payload.sentinel === 'AWAITING_APPROVAL' && payload.action !== undefined
|
|
177
|
+
? { pendingApprovalAction: payload.action }
|
|
178
|
+
: {}),
|
|
179
|
+
},
|
|
180
|
+
wake: {
|
|
181
|
+
...current.wake,
|
|
182
|
+
stage: payload.nextStage ?? current.wake.stage,
|
|
183
|
+
lastRunId: payload.runId ?? current.wake.lastRunId,
|
|
184
|
+
sessionId: shouldClearSession
|
|
185
|
+
? undefined
|
|
186
|
+
: (payload.sessionId ?? current.wake.sessionId),
|
|
187
|
+
sessionCli: shouldClearSession
|
|
188
|
+
? undefined
|
|
189
|
+
: (payload.sessionCli ?? current.wake.sessionCli),
|
|
190
|
+
workspacePath: payload.workspacePath ?? current.wake.workspacePath,
|
|
191
|
+
blockReason: payload.blockReason ?? current.wake.blockReason,
|
|
192
|
+
syncedAt: event.ingestedAt,
|
|
193
|
+
stageHistory: stageChanged
|
|
194
|
+
? [
|
|
195
|
+
...current.wake.stageHistory,
|
|
196
|
+
{
|
|
197
|
+
stage: payload.nextStage,
|
|
198
|
+
changedAt: event.occurredAt,
|
|
199
|
+
reason: payload.reason ?? event.sourceEventType,
|
|
200
|
+
},
|
|
201
|
+
]
|
|
202
|
+
: current.wake.stageHistory,
|
|
203
|
+
recentEventIds: [...current.wake.recentEventIds, event.eventId].slice(-10),
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
if (event.sourceEventType === 'ticket.reply.published') {
|
|
208
|
+
const commentId = event.sourceRefs.commentId;
|
|
209
|
+
if (commentId === undefined) {
|
|
210
|
+
return parseIssueStateRecord({
|
|
211
|
+
...current,
|
|
212
|
+
wake: {
|
|
213
|
+
...current.wake,
|
|
214
|
+
syncedAt: event.ingestedAt,
|
|
215
|
+
recentEventIds: [...current.wake.recentEventIds, event.eventId].slice(-10),
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
return parseIssueStateRecord({
|
|
220
|
+
...current,
|
|
221
|
+
wake: {
|
|
222
|
+
...current.wake,
|
|
223
|
+
expectedEcho: {
|
|
224
|
+
...current.wake.expectedEcho,
|
|
225
|
+
commentIds: Array.from(new Set([...current.wake.expectedEcho.commentIds, commentId])),
|
|
226
|
+
},
|
|
227
|
+
syncedAt: event.ingestedAt,
|
|
228
|
+
recentEventIds: [...current.wake.recentEventIds, event.eventId].slice(-10),
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
if (event.sourceEventType === 'wake.workspace.cleaned') {
|
|
233
|
+
return parseIssueStateRecord({
|
|
234
|
+
...current,
|
|
235
|
+
wake: {
|
|
236
|
+
...current.wake,
|
|
237
|
+
workspacePath: undefined,
|
|
238
|
+
syncedAt: event.ingestedAt,
|
|
239
|
+
recentEventIds: [...current.wake.recentEventIds, event.eventId].slice(-10),
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
if (event.sourceEventType === 'ticket.labels.updated') {
|
|
244
|
+
const labels = stringArrayFromPayload(event.payload.labels);
|
|
245
|
+
return parseIssueStateRecord({
|
|
246
|
+
...current,
|
|
247
|
+
issue: {
|
|
248
|
+
...current.issue,
|
|
249
|
+
labels,
|
|
250
|
+
},
|
|
251
|
+
wake: {
|
|
252
|
+
...current.wake,
|
|
253
|
+
expectedEcho: {
|
|
254
|
+
...current.wake.expectedEcho,
|
|
255
|
+
labels,
|
|
256
|
+
},
|
|
257
|
+
syncedAt: event.ingestedAt,
|
|
258
|
+
recentEventIds: [...current.wake.recentEventIds, event.eventId].slice(-10),
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
// Purely an audit record; must never affect projection state, so a full
|
|
263
|
+
// event replay produces byte-identical output whether or not this
|
|
264
|
+
// synthesized event happens to be included in the batch being folded
|
|
265
|
+
// (it's created as a fold side effect, not iterated over on the original
|
|
266
|
+
// pass, but is naturally present on a later full replay).
|
|
267
|
+
if (event.sourceEventType === CORRELATION_PRIMARY_CONFLICT_EVENT) {
|
|
268
|
+
return current;
|
|
269
|
+
}
|
|
270
|
+
if (event.sourceEventType === CORRELATION_REGISTERED_EVENT) {
|
|
271
|
+
const payload = event.payload;
|
|
272
|
+
// ADR 0001 §6 is a *downgrade* rule, and only a downgrade rule: "a second
|
|
273
|
+
// `primary` registration on a claimed URI is downgraded to `secondary` and
|
|
274
|
+
// a warning event appended." It says nothing about promoting a requested
|
|
275
|
+
// `secondary`, and §5 lists `relation` as a payload input — so a requested
|
|
276
|
+
// `secondary` must stay `secondary`. Folding a requested `secondary` up to
|
|
277
|
+
// `primary` would silently rewrite the declaration the event made, and the
|
|
278
|
+
// downgrade rule is a corruption guard, not a merge rule — it exists to
|
|
279
|
+
// stop a second claim on a URI, not to promote unclaimed ones.
|
|
280
|
+
//
|
|
281
|
+
// (No shipped caller declares `secondary` yet — `wake correlate` hardcodes
|
|
282
|
+
// `primary`. The rule follows the spec's payload contract, which is the
|
|
283
|
+
// standard here; an emitter is #82's scope.)
|
|
284
|
+
//
|
|
285
|
+
// requested primary + held by ANOTHER work item -> secondary + conflict
|
|
286
|
+
// requested primary + unclaimed / held by THIS -> primary, indexed
|
|
287
|
+
// requested secondary -> secondary, unindexed
|
|
288
|
+
//
|
|
289
|
+
// A requested-secondary never touching the index is by design, not an
|
|
290
|
+
// orphan: the index is primary-only (ADR §5 — the resolver stamps the
|
|
291
|
+
// *primary* work item's canonical key), so a secondary has nothing to
|
|
292
|
+
// register. Promotion still requires the incumbent to retract first —
|
|
293
|
+
// never let a second registration silently steal a uri.
|
|
294
|
+
const incumbent = await ctx.resourceIndex.resolve(payload.resourceUri);
|
|
295
|
+
const heldByAnotherWorkItem = incumbent !== undefined && incumbent !== current.workItemKey;
|
|
296
|
+
const relation = payload.relation === 'primary' && !heldByAnotherWorkItem ? 'primary' : 'secondary';
|
|
297
|
+
if (relation === 'primary') {
|
|
298
|
+
await ctx.resourceIndex.register(payload.resourceUri, current.workItemKey);
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
if (payload.relation === 'primary') {
|
|
302
|
+
await ctx.appendEvent(createEventEnvelope({
|
|
303
|
+
eventId: `${event.eventId}-primary-conflict`,
|
|
304
|
+
workItemKey: current.workItemKey,
|
|
305
|
+
streamScope: 'work-item',
|
|
306
|
+
direction: 'internal',
|
|
307
|
+
sourceSystem: 'wake',
|
|
308
|
+
sourceEventType: CORRELATION_PRIMARY_CONFLICT_EVENT,
|
|
309
|
+
sourceRefs: event.sourceRefs,
|
|
310
|
+
occurredAt: event.occurredAt,
|
|
311
|
+
ingestedAt: event.ingestedAt,
|
|
312
|
+
trigger: 'context-only',
|
|
313
|
+
payload: {
|
|
314
|
+
resourceUri: payload.resourceUri,
|
|
315
|
+
incumbentWorkItemKey: incumbent,
|
|
316
|
+
},
|
|
317
|
+
}));
|
|
318
|
+
}
|
|
319
|
+
// Coherent inverse of the retraction gate below: a registration that
|
|
320
|
+
// folds to non-primary must never leave the index stranded crediting
|
|
321
|
+
// this work item. This is reachable as a genuine *self-demotion* — a
|
|
322
|
+
// work item that holds a uri primary and then registers it `secondary`
|
|
323
|
+
// — where leaving the index pointing at us would contradict our own
|
|
324
|
+
// correlatedResources[] entry. Gated on actual index ownership, so the
|
|
325
|
+
// ordinary requested-secondary-on-an-unclaimed-uri case (owner
|
|
326
|
+
// undefined) correctly leaves the index untouched (finding B).
|
|
327
|
+
const owner = await ctx.resourceIndex.resolve(payload.resourceUri);
|
|
328
|
+
if (owner === current.workItemKey) {
|
|
329
|
+
await ctx.resourceIndex.retract(payload.resourceUri);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
const entry = {
|
|
333
|
+
resourceUri: payload.resourceUri,
|
|
334
|
+
role: payload.role,
|
|
335
|
+
relation,
|
|
336
|
+
provenance: payload.provenance,
|
|
337
|
+
...(payload.registeredBy === undefined ? {} : { registeredBy: payload.registeredBy }),
|
|
338
|
+
registeredAt: event.occurredAt,
|
|
339
|
+
};
|
|
340
|
+
const currentCorrelatedResources = current.correlatedResources;
|
|
341
|
+
const existingIndex = currentCorrelatedResources.findIndex((resource) => resource.resourceUri === payload.resourceUri);
|
|
342
|
+
const correlatedResources = existingIndex === -1
|
|
343
|
+
? [...currentCorrelatedResources, entry]
|
|
344
|
+
: currentCorrelatedResources.map((resource, index) => index === existingIndex ? entry : resource);
|
|
345
|
+
return parseIssueStateRecord({
|
|
346
|
+
...current,
|
|
347
|
+
correlatedResources,
|
|
348
|
+
wake: {
|
|
349
|
+
...current.wake,
|
|
350
|
+
syncedAt: event.ingestedAt,
|
|
351
|
+
recentEventIds: [...current.wake.recentEventIds, event.eventId].slice(-10),
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
if (event.sourceEventType === CORRELATION_RETRACTED_EVENT) {
|
|
356
|
+
const payload = event.payload;
|
|
357
|
+
const currentCorrelatedResources = current.correlatedResources;
|
|
358
|
+
// Retract whenever the index currently credits this work item —
|
|
359
|
+
// regardless of the locally stored relation — so the registration and
|
|
360
|
+
// retraction gates are coherent inverses across a demotion. Gating this
|
|
361
|
+
// on `existing?.relation === 'primary'` (the locally folded relation at
|
|
362
|
+
// *registration* time) can strand the index pointing at a work item that
|
|
363
|
+
// no longer holds the resource once that relation has drifted, which is
|
|
364
|
+
// exactly the identity-critical path `resolve()` depends on (finding B).
|
|
365
|
+
const owner = await ctx.resourceIndex.resolve(payload.resourceUri);
|
|
366
|
+
if (owner === current.workItemKey) {
|
|
367
|
+
await ctx.resourceIndex.retract(payload.resourceUri);
|
|
368
|
+
}
|
|
369
|
+
return parseIssueStateRecord({
|
|
370
|
+
...current,
|
|
371
|
+
correlatedResources: currentCorrelatedResources.filter((resource) => resource.resourceUri !== payload.resourceUri),
|
|
372
|
+
wake: {
|
|
373
|
+
...current.wake,
|
|
374
|
+
syncedAt: event.ingestedAt,
|
|
375
|
+
recentEventIds: [...current.wake.recentEventIds, event.eventId].slice(-10),
|
|
376
|
+
},
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
return parseIssueStateRecord({
|
|
380
|
+
...current,
|
|
381
|
+
wake: {
|
|
382
|
+
...current.wake,
|
|
383
|
+
syncedAt: event.ingestedAt,
|
|
384
|
+
recentEventIds: [...current.wake.recentEventIds, event.eventId].slice(-10),
|
|
385
|
+
},
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
export function createProjectionUpdater(deps) {
|
|
389
|
+
const applyEventCtx = {
|
|
390
|
+
resourceIndex: deps.resourceIndex,
|
|
391
|
+
appendEvent: (event) => deps.stateStore.appendEventEnvelope(event),
|
|
392
|
+
};
|
|
393
|
+
return {
|
|
394
|
+
async rebuildFromEvents(events) {
|
|
395
|
+
// Correlation-index-affecting events (wake.correlation.registered in
|
|
396
|
+
// particular) must be folded in *globally* ordered time, not grouped by
|
|
397
|
+
// workItemKey and folded per-group. resourceIndex is shared/global
|
|
398
|
+
// across all work items, so whichever group happens to be visited first
|
|
399
|
+
// (Map insertion order — i.e. whichever work item's earliest event
|
|
400
|
+
// happens to appear first in the input array) would otherwise decide
|
|
401
|
+
// who wins a primary claim, independent of when the registration events
|
|
402
|
+
// actually occurred. That makes a full replay (this function, called
|
|
403
|
+
// with every event) diverge from the live/incremental fold (this
|
|
404
|
+
// function, called with one new event at a time as it happens) for any
|
|
405
|
+
// two work items whose creation order and registration order disagree
|
|
406
|
+
// — silently handing resumption for a resource to the wrong work item
|
|
407
|
+
// after `rm -rf state/` + replay. Sorting once, globally, by
|
|
408
|
+
// (ingestedAt, eventId) and folding in that single pass keeps replay
|
|
409
|
+
// and live agreeing by construction, for both the index and the
|
|
410
|
+
// per-work-item projection fold (a global sort is still a valid order
|
|
411
|
+
// for each individual work item's own events, since it's a stable
|
|
412
|
+
// sort over a total order that's consistent with each event's own
|
|
413
|
+
// ingestedAt).
|
|
414
|
+
// Sort is stable (ES2019+), so events sharing an ingestedAt keep their
|
|
415
|
+
// input order — which is append/arrival order, the same order the live
|
|
416
|
+
// fold saw them in. Do not add a tie-break on eventId: ids are not
|
|
417
|
+
// chronological, so that would reorder same-timestamp events within a
|
|
418
|
+
// work item against the order they actually happened.
|
|
419
|
+
const ordered = [...events].sort((left, right) => left.ingestedAt.localeCompare(right.ingestedAt));
|
|
420
|
+
const projections = new Map();
|
|
421
|
+
const touchedWorkItemKeys = [];
|
|
422
|
+
for (const event of ordered) {
|
|
423
|
+
if (!projections.has(event.workItemKey)) {
|
|
424
|
+
touchedWorkItemKeys.push(event.workItemKey);
|
|
425
|
+
projections.set(event.workItemKey, await deps.stateStore.readIssueState(event.workItemKey));
|
|
426
|
+
}
|
|
427
|
+
const current = projections.get(event.workItemKey) ?? null;
|
|
428
|
+
projections.set(event.workItemKey, await applyEvent(current, event, applyEventCtx, deps.config));
|
|
429
|
+
}
|
|
430
|
+
for (const workItemKey of touchedWorkItemKeys) {
|
|
431
|
+
const projection = projections.get(workItemKey) ?? null;
|
|
432
|
+
if (projection !== null) {
|
|
433
|
+
await deps.stateStore.writeIssueState(projection);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
},
|
|
437
|
+
};
|
|
438
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
function applyTimeOfDay(input) {
|
|
2
|
+
let hours = input.hours;
|
|
3
|
+
if (hours === 12) {
|
|
4
|
+
hours = 0;
|
|
5
|
+
}
|
|
6
|
+
if (input.meridiem === 'pm') {
|
|
7
|
+
hours += 12;
|
|
8
|
+
}
|
|
9
|
+
const reset = new Date(input.now);
|
|
10
|
+
if (input.useUtc) {
|
|
11
|
+
reset.setUTCHours(hours, input.minutes, 0, 0);
|
|
12
|
+
if (reset.getTime() <= input.now.getTime()) {
|
|
13
|
+
reset.setUTCDate(reset.getUTCDate() + 1);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
reset.setHours(hours, input.minutes, 0, 0);
|
|
18
|
+
if (reset.getTime() <= input.now.getTime()) {
|
|
19
|
+
reset.setDate(reset.getDate() + 1);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return reset.toISOString();
|
|
23
|
+
}
|
|
24
|
+
// CLIs report quota resets in prose, e.g. Claude's "resets 1:10am (UTC)" or
|
|
25
|
+
// Codex's "try again at 2:29 PM" (no zone marker at all). Only trust a time as
|
|
26
|
+
// UTC when the message says so explicitly; an unlabeled time is the CLI
|
|
27
|
+
// provider's local clock, not ours, but treating it as this machine's local
|
|
28
|
+
// time is the closest honest guess without a zone — better than silently
|
|
29
|
+
// assuming UTC and pausing for the wrong duration.
|
|
30
|
+
const utcResetPattern = /(?:resets?|try again at)\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)\s*(?:\(utc\)|utc)/i;
|
|
31
|
+
const localResetPattern = /(?:resets?|try again at)\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b/i;
|
|
32
|
+
// A cap on the exponential-backoff guess used when no reset time is reported
|
|
33
|
+
// (see below). Kept well under a working day so a stuck-forever pause is
|
|
34
|
+
// never possible even if the guess is wrong - worst case, Wake retries hourly.
|
|
35
|
+
const maxEstimatedPauseMs = 60 * 60_000;
|
|
36
|
+
export function resolveQuotaPauseUntil(input) {
|
|
37
|
+
const utcMatch = utcResetPattern.exec(input.result);
|
|
38
|
+
if (utcMatch !== null) {
|
|
39
|
+
return {
|
|
40
|
+
pausedUntil: applyTimeOfDay({
|
|
41
|
+
hours: Number(utcMatch[1]),
|
|
42
|
+
minutes: Number(utcMatch[2] ?? 0),
|
|
43
|
+
meridiem: utcMatch[3]?.toLowerCase(),
|
|
44
|
+
now: input.now,
|
|
45
|
+
useUtc: true,
|
|
46
|
+
}),
|
|
47
|
+
source: 'reported',
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const localMatch = localResetPattern.exec(input.result);
|
|
51
|
+
if (localMatch !== null) {
|
|
52
|
+
return {
|
|
53
|
+
pausedUntil: applyTimeOfDay({
|
|
54
|
+
hours: Number(localMatch[1]),
|
|
55
|
+
minutes: Number(localMatch[2] ?? 0),
|
|
56
|
+
meridiem: localMatch[3]?.toLowerCase(),
|
|
57
|
+
now: input.now,
|
|
58
|
+
useUtc: false,
|
|
59
|
+
}),
|
|
60
|
+
source: 'reported',
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const boundedFailureCount = Math.max(1, Math.min(input.failureCount, 6));
|
|
64
|
+
const delayMs = Math.min(15 * 60_000 * (2 ** (boundedFailureCount - 1)), maxEstimatedPauseMs);
|
|
65
|
+
return {
|
|
66
|
+
pausedUntil: new Date(input.now.getTime() + delayMs).toISOString(),
|
|
67
|
+
source: 'estimated',
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
export function createWorkSourceFanIn(sources) {
|
|
2
|
+
return {
|
|
3
|
+
async pollEvents(input) {
|
|
4
|
+
const batches = await Promise.all(sources.map((source) => source.pollEvents(input)));
|
|
5
|
+
return batches.flat();
|
|
6
|
+
},
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
function intentKind(event) {
|
|
10
|
+
if (event.sourceEventType !== 'wake.publish.intent.requested') {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
return typeof event.payload.kind === 'string' ? event.payload.kind : null;
|
|
14
|
+
}
|
|
15
|
+
function isTerminalStageIntent(event) {
|
|
16
|
+
const stage = event.derivedHints?.stage;
|
|
17
|
+
return stage === 'done' || event.payload.sentinel === 'BLOCKED';
|
|
18
|
+
}
|
|
19
|
+
function subscriptionMatches(event, subscription) {
|
|
20
|
+
if (subscription === intentKind(event)) {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
return subscription === 'stage.terminal' && isTerminalStageIntent(event);
|
|
24
|
+
}
|
|
25
|
+
function sinkNameForResourceUri(resourceUri, fallback) {
|
|
26
|
+
const [provider, kind] = resourceUri.split(':');
|
|
27
|
+
if (provider === undefined || kind === undefined) {
|
|
28
|
+
return fallback;
|
|
29
|
+
}
|
|
30
|
+
return kind === 'pr' || kind === 'pr-review-thread' ? `${provider}-pr` : fallback;
|
|
31
|
+
}
|
|
32
|
+
function withSinkRef(event, sink) {
|
|
33
|
+
return {
|
|
34
|
+
...event,
|
|
35
|
+
sourceRefs: {
|
|
36
|
+
...event.sourceRefs,
|
|
37
|
+
sink: event.sourceRefs.sink ?? sink,
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
export function createOutboundSinkRouter(input) {
|
|
42
|
+
const sinksByName = new Map(input.sinks.map((sink) => [sink.sink, sink]));
|
|
43
|
+
return {
|
|
44
|
+
async deliverIntent({ event }) {
|
|
45
|
+
const targetSinks = new Set();
|
|
46
|
+
const origin = typeof event.payload.origin === 'string' ? event.payload.origin : undefined;
|
|
47
|
+
const sourceOrigin = event.sourceRefs.sink ?? origin;
|
|
48
|
+
if (event.sourceEventType === 'wake.labels.requested') {
|
|
49
|
+
const projectionOrigin = typeof event.payload.origin === 'string'
|
|
50
|
+
? event.payload.origin
|
|
51
|
+
: undefined;
|
|
52
|
+
if (projectionOrigin !== undefined) {
|
|
53
|
+
targetSinks.add(projectionOrigin);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const kind = intentKind(event);
|
|
57
|
+
const resourceUri = event.sourceRefs.resourceUri;
|
|
58
|
+
if (event.sourceEventType === 'wake.publish.intent.requested' &&
|
|
59
|
+
sourceOrigin !== undefined) {
|
|
60
|
+
const resourceSink = resourceUri === undefined ? sourceOrigin : sinkNameForResourceUri(resourceUri, sourceOrigin);
|
|
61
|
+
// A resource-derived sink name (e.g. a PR surface) may not be
|
|
62
|
+
// registered — the source that owns it can be disabled independently
|
|
63
|
+
// of the origin sink. Falling back to sourceOrigin here, rather than
|
|
64
|
+
// silently skipping an unregistered sink below, is what keeps a reply
|
|
65
|
+
// from being dropped-but-marked-delivered when that happens.
|
|
66
|
+
targetSinks.add(sinksByName.has(resourceSink) ? resourceSink : sourceOrigin);
|
|
67
|
+
}
|
|
68
|
+
for (const [sinkName, sinkConfig] of Object.entries(input.config.sinks ?? {})) {
|
|
69
|
+
if (sinkConfig.subscribe.some((subscription) => subscriptionMatches(event, subscription))) {
|
|
70
|
+
targetSinks.add(sinkName);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const deliveryEvents = [];
|
|
74
|
+
for (const sinkName of targetSinks) {
|
|
75
|
+
const sink = sinksByName.get(sinkName);
|
|
76
|
+
if (sink === undefined) {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const sinkDeliveryEvents = await sink.deliverIntent({ event });
|
|
80
|
+
deliveryEvents.push(...sinkDeliveryEvents.map((deliveryEvent) => withSinkRef(deliveryEvent, sinkName)));
|
|
81
|
+
}
|
|
82
|
+
return deliveryEvents;
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|