@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.
Files changed (75) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +140 -0
  3. package/dist/src/adapters/claude/claude-runner.js +388 -0
  4. package/dist/src/adapters/claude/prompt-templates.js +57 -0
  5. package/dist/src/adapters/codex/codex-runner.js +391 -0
  6. package/dist/src/adapters/cursor/cursor-runner.js +352 -0
  7. package/dist/src/adapters/docker/docker-cli.js +97 -0
  8. package/dist/src/adapters/fake/fake-artifact-verifier.js +14 -0
  9. package/dist/src/adapters/fake/fake-github-pull-request-activity-source.js +73 -0
  10. package/dist/src/adapters/fake/fake-resource-index.js +21 -0
  11. package/dist/src/adapters/fake/fake-runner.js +22 -0
  12. package/dist/src/adapters/fake/fake-ticketing-system.js +166 -0
  13. package/dist/src/adapters/fake/fake-workspace-manager.js +27 -0
  14. package/dist/src/adapters/fs/resource-index.js +84 -0
  15. package/dist/src/adapters/fs/self-update-ledger.js +17 -0
  16. package/dist/src/adapters/fs/state-store.js +364 -0
  17. package/dist/src/adapters/git/git-workspace-manager.js +168 -0
  18. package/dist/src/adapters/github/github-artifact-verifier.js +38 -0
  19. package/dist/src/adapters/github/github-auth.js +16 -0
  20. package/dist/src/adapters/github/github-client.js +100 -0
  21. package/dist/src/adapters/github/github-issues-work-source.js +410 -0
  22. package/dist/src/adapters/github/github-pull-request-activity-source.js +263 -0
  23. package/dist/src/adapters/http/ui-assets.js +302 -0
  24. package/dist/src/adapters/http/ui-data.js +389 -0
  25. package/dist/src/adapters/http/ui-server.js +151 -0
  26. package/dist/src/adapters/runner/cli-command.js +43 -0
  27. package/dist/src/adapters/runner/prompt-templates.js +76 -0
  28. package/dist/src/adapters/runner/runner-cli-adapter.js +112 -0
  29. package/dist/src/adapters/runner/runner-registry.js +141 -0
  30. package/dist/src/adapters/runner/stage-prompt.js +256 -0
  31. package/dist/src/adapters/runner/transcripts.js +25 -0
  32. package/dist/src/cli/correlate-command.js +62 -0
  33. package/dist/src/cli/init-command.js +11 -0
  34. package/dist/src/cli/locks-command.js +19 -0
  35. package/dist/src/cli/sandbox-command.js +191 -0
  36. package/dist/src/cli/sandbox-logging.js +30 -0
  37. package/dist/src/cli/sandbox-resume.js +77 -0
  38. package/dist/src/cli/scaffold-assets.js +173 -0
  39. package/dist/src/cli/self-update-command.js +158 -0
  40. package/dist/src/cli/startup-preflight.js +127 -0
  41. package/dist/src/cli/stop-command.js +42 -0
  42. package/dist/src/cli/ui-command.js +27 -0
  43. package/dist/src/config/defaults.js +11 -0
  44. package/dist/src/config/load-config.js +26 -0
  45. package/dist/src/core/contracts.js +1 -0
  46. package/dist/src/core/control-plane.js +127 -0
  47. package/dist/src/core/lifecycle-service.js +8 -0
  48. package/dist/src/core/policy-engine.js +200 -0
  49. package/dist/src/core/projection-updater.js +438 -0
  50. package/dist/src/core/quota-backoff.js +69 -0
  51. package/dist/src/core/sink-router.js +85 -0
  52. package/dist/src/core/tick-runner.js +1347 -0
  53. package/dist/src/domain/branch-naming.js +14 -0
  54. package/dist/src/domain/resource-uri.js +38 -0
  55. package/dist/src/domain/runner-routing.js +108 -0
  56. package/dist/src/domain/schema.js +685 -0
  57. package/dist/src/domain/sources.js +16 -0
  58. package/dist/src/domain/stages.js +39 -0
  59. package/dist/src/domain/types.js +1 -0
  60. package/dist/src/domain/workflows.js +83 -0
  61. package/dist/src/lib/clock.js +5 -0
  62. package/dist/src/lib/event-log.js +21 -0
  63. package/dist/src/lib/json-file.js +23 -0
  64. package/dist/src/lib/lock.js +118 -0
  65. package/dist/src/lib/paths.js +44 -0
  66. package/dist/src/lib/work-id.js +19 -0
  67. package/dist/src/main.js +523 -0
  68. package/dist/src/version.js +1 -0
  69. package/docker/Dockerfile +54 -0
  70. package/docker/entrypoint.sh +75 -0
  71. package/docker/log-command.sh +107 -0
  72. package/docker/setup.sh +72 -0
  73. package/package.json +52 -0
  74. package/prompts/implement.md +49 -0
  75. 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
+ }