@atolis-hq/wake 0.2.54 → 0.2.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,15 @@
1
+ import { setTimeout as delay } from 'node:timers/promises';
1
2
  import { access, appendFile, mkdir, readFile, readdir, rename } from 'node:fs/promises';
2
3
  import { dirname, join } from 'node:path';
3
4
  import { validateResourceIndex } from './resource-index.js';
4
5
  import { parseEventEnvelope, parseIssueStateRecord, parseLedger, parseRunRecord, parseSourceStateRecord, } from '../../domain/schema.js';
5
6
  import { isTerminalStage } from '../../domain/stages.js';
6
7
  import { appendJsonLine, readJsonFile, writeJsonFile } from '../../lib/json-file.js';
8
+ import { acquireFileLock } from '../../lib/lock.js';
7
9
  import { createWakePaths } from '../../lib/paths.js';
8
10
  import { isMissingPathError, stateHealthIssue, StateHealthError, throwIfUnhealthy, } from '../../lib/state-health.js';
11
+ const runSummaryIndexLockTimeoutMs = 5_000;
12
+ const runSummaryIndexLockPollMs = 10;
9
13
  async function readIssueStateFile(file) {
10
14
  try {
11
15
  return parseIssueStateRecord(await readJsonFile(file));
@@ -24,9 +28,116 @@ async function readIssueStateFile(file) {
24
28
  ]);
25
29
  }
26
30
  }
27
- async function readRunRecordFile(file) {
31
+ // The board/runs/metrics UI list many run records at once purely for their
32
+ // small fields (status, timing, routing) - metadata.stdout/stderr/raw and
33
+ // runtimeEvents are captured agent output that can run into single-digit MB
34
+ // per run, and holding all of them in memory for a full-history listing is
35
+ // what took the UI process OOM. Stripped only for bulk-list reads; single-run
36
+ // reads (readRunRecord) keep the full record since reconciliation code
37
+ // spreads a record's metadata back into a rewrite (see stale-run-reconciler).
38
+ function stripHeavyRunRecordFields(record) {
39
+ if (record.metadata === undefined && record.runtimeEvents === undefined) {
40
+ return record;
41
+ }
42
+ const { runtimeEvents: _runtimeEvents, metadata, ...rest } = record;
43
+ if (metadata === undefined) {
44
+ return rest;
45
+ }
46
+ const { stdout: _stdout, stderr: _stderr, raw: _raw, ...restMetadata } = metadata;
47
+ return { ...rest, metadata: restMetadata };
48
+ }
49
+ function parseRunRecordSummaryIndex(input, date) {
50
+ if (input === null || typeof input !== 'object') {
51
+ throw new Error('Run summary index must be an object');
52
+ }
53
+ const record = input;
54
+ if (record.schemaVersion !== 1) {
55
+ throw new Error('Run summary index schemaVersion must be 1');
56
+ }
57
+ if (record.date !== date) {
58
+ throw new Error(`Run summary index date must be ${date}`);
59
+ }
60
+ if (!Array.isArray(record.entries)) {
61
+ throw new Error('Run summary index entries must be an array');
62
+ }
63
+ return {
64
+ schemaVersion: 1,
65
+ date,
66
+ entries: record.entries.map((entry) => parseRunRecord(entry)),
67
+ };
68
+ }
69
+ async function withRunSummaryIndexLock(paths, date, operation) {
70
+ const deadline = Date.now() + runSummaryIndexLockTimeoutMs;
71
+ while (true) {
72
+ const lock = await acquireFileLock(paths.runDateIndexLockFile(date), {
73
+ staleAfterMs: runSummaryIndexLockTimeoutMs,
74
+ });
75
+ if (lock.acquired) {
76
+ try {
77
+ return await operation();
78
+ }
79
+ finally {
80
+ await lock.release();
81
+ }
82
+ }
83
+ if (Date.now() >= deadline) {
84
+ throw new Error(`Timed out acquiring run summary index lock for ${date}`);
85
+ }
86
+ await delay(runSummaryIndexLockPollMs);
87
+ }
88
+ }
89
+ async function readRunSummaryIndex(paths, date) {
90
+ return parseRunRecordSummaryIndex(await readJsonFile(paths.runDateIndexFile(date)), date);
91
+ }
92
+ async function writeRunSummaryIndex(paths, date, entries) {
93
+ await writeJsonFile(paths.runDateIndexFile(date), {
94
+ schemaVersion: 1,
95
+ date,
96
+ entries: entries.sort((left, right) => left.startedAt.localeCompare(right.startedAt)),
97
+ });
98
+ }
99
+ async function countRunDateBucketFiles(paths, date) {
100
+ return (await readdir(join(paths.dataRoot, 'runs', 'by-date', date), { withFileTypes: true }).catch((error) => {
101
+ if (isMissingPathError(error)) {
102
+ return [];
103
+ }
104
+ throw error;
105
+ })).filter((file) => file.isFile() && file.name.endsWith('.json') && file.name !== 'index.json')
106
+ .length;
107
+ }
108
+ async function rebuildRunSummaryIndexForDate(paths, date) {
109
+ const recordsById = new Map();
110
+ const bucketFiles = (await readdir(join(paths.dataRoot, 'runs', 'by-date', date)).catch(() => []))
111
+ .filter((file) => file.endsWith('.json') && file !== 'index.json')
112
+ .sort();
113
+ for (const file of bucketFiles) {
114
+ const record = await readRunRecordFile(join(paths.dataRoot, 'runs', 'by-date', date, file), {
115
+ summarize: true,
116
+ });
117
+ if (record !== null) {
118
+ recordsById.set(record.runId, record);
119
+ }
120
+ }
121
+ const entries = [...recordsById.values()].sort((left, right) => left.startedAt.localeCompare(right.startedAt));
122
+ await writeRunSummaryIndex(paths, date, entries);
123
+ return entries;
124
+ }
125
+ async function upsertRunSummaryIndexEntry(paths, record) {
126
+ const date = record.startedAt.slice(0, 10);
127
+ const summary = stripHeavyRunRecordFields(record);
128
+ await withRunSummaryIndexLock(paths, date, async () => {
129
+ const entries = await readRunSummaryIndex(paths, date)
130
+ .then((index) => index.entries)
131
+ .catch(() => []);
132
+ const nextById = new Map(entries.map((entry) => [entry.runId, entry]));
133
+ nextById.set(summary.runId, summary);
134
+ await writeRunSummaryIndex(paths, date, [...nextById.values()]);
135
+ });
136
+ }
137
+ async function readRunRecordFile(file, options) {
28
138
  try {
29
- return parseRunRecord(await readJsonFile(file));
139
+ const record = parseRunRecord(await readJsonFile(file));
140
+ return options?.summarize === true ? stripHeavyRunRecordFields(record) : record;
30
141
  }
31
142
  catch {
32
143
  return null;
@@ -167,6 +278,44 @@ async function collectEventIssues(paths) {
167
278
  }
168
279
  return issues;
169
280
  }
281
+ async function collectRunSummaryIndexIssues(paths) {
282
+ const issues = [];
283
+ const byDateRoot = join(paths.dataRoot, 'runs', 'by-date');
284
+ const dateDirs = await readdir(byDateRoot, { withFileTypes: true }).catch((error) => {
285
+ if (isMissingPathError(error)) {
286
+ return [];
287
+ }
288
+ throw error;
289
+ });
290
+ for (const entry of dateDirs) {
291
+ if (!entry.isDirectory()) {
292
+ continue;
293
+ }
294
+ const date = entry.name;
295
+ const runFileCount = await countRunDateBucketFiles(paths, date);
296
+ const indexFile = paths.runDateIndexFile(date);
297
+ try {
298
+ const index = await readRunSummaryIndex(paths, date);
299
+ if (index.entries.length !== runFileCount) {
300
+ issues.push(stateHealthIssue({
301
+ surface: 'runs',
302
+ kind: 'incomplete',
303
+ path: indexFile,
304
+ message: `Run summary index has ${index.entries.length} entries but ${runFileCount} run files exist for ${date}`,
305
+ }));
306
+ }
307
+ }
308
+ catch (error) {
309
+ issues.push(stateHealthIssue({
310
+ surface: 'runs',
311
+ kind: isMissingPathError(error) ? 'incomplete' : 'corrupted',
312
+ path: indexFile,
313
+ message: error instanceof Error ? error.message : String(error),
314
+ }));
315
+ }
316
+ }
317
+ return issues;
318
+ }
170
319
  function issueArchiveAgeDate(item) {
171
320
  const stageChangedAt = item.wake.stageHistory.at(-1)?.changedAt;
172
321
  return ([stageChangedAt, item.wake.syncedAt, item.issue.updatedAt]
@@ -181,13 +330,13 @@ function shouldArchiveIssueState(item, options) {
181
330
  const ageMs = options.now.getTime() - Date.parse(issueArchiveAgeDate(item));
182
331
  return Number.isFinite(ageMs) && ageMs > options.archiveFreshnessDays * 24 * 60 * 60 * 1000;
183
332
  }
184
- export async function listRunRecords(wakeRoot) {
333
+ async function listRunRecordsImpl(wakeRoot, options) {
185
334
  const runsRoot = join(createWakePaths(wakeRoot).dataRoot, 'runs');
186
335
  const recordsById = new Map();
187
336
  try {
188
337
  const files = (await readdir(runsRoot)).filter((file) => file.endsWith('.json')).sort();
189
338
  for (const file of files) {
190
- const record = await readRunRecordFile(join(runsRoot, file));
339
+ const record = await readRunRecordFile(join(runsRoot, file), options);
191
340
  if (record !== null) {
192
341
  recordsById.set(record.runId, record);
193
342
  }
@@ -204,7 +353,7 @@ export async function listRunRecords(wakeRoot) {
204
353
  .filter((file) => file.endsWith('.json'))
205
354
  .sort();
206
355
  for (const file of files) {
207
- const record = await readRunRecordFile(join(byDateRoot, dateDir, file));
356
+ const record = await readRunRecordFile(join(byDateRoot, dateDir, file), options);
208
357
  if (record !== null) {
209
358
  recordsById.set(record.runId, record);
210
359
  }
@@ -216,14 +365,38 @@ export async function listRunRecords(wakeRoot) {
216
365
  }
217
366
  return [...recordsById.values()].sort((left, right) => left.startedAt.localeCompare(right.startedAt));
218
367
  }
219
- async function listRunRecordsForDate(wakeRoot, date) {
368
+ export async function listRunRecords(wakeRoot) {
369
+ return listRunRecordsImpl(wakeRoot);
370
+ }
371
+ // Same records as listRunRecords, but with metadata.stdout/stderr/raw and
372
+ // runtimeEvents stripped per-file as they're read - for callers (board/runs/
373
+ // metrics UI) that list every run and only need the small fields. See
374
+ // stripHeavyRunRecordFields for why this can't just be a post-hoc .map() over
375
+ // listRunRecords: that would still hold every full record in memory at once.
376
+ export async function listRunRecordSummaries(wakeRoot) {
377
+ const paths = createWakePaths(wakeRoot);
378
+ const byDateRoot = join(paths.dataRoot, 'runs', 'by-date');
379
+ const dateDirs = (await readdir(byDateRoot).catch(() => [])).sort();
380
+ if (dateDirs.length === 0) {
381
+ return listRunRecordsImpl(wakeRoot, { summarize: true });
382
+ }
383
+ const recordsById = new Map();
384
+ for (const dateDir of dateDirs) {
385
+ const records = await listRunRecordSummariesForDate(wakeRoot, dateDir);
386
+ for (const record of records) {
387
+ recordsById.set(record.runId, record);
388
+ }
389
+ }
390
+ return [...recordsById.values()].sort((left, right) => left.startedAt.localeCompare(right.startedAt));
391
+ }
392
+ async function listRunRecordsForDateImpl(wakeRoot, date, options) {
220
393
  const runsRoot = join(createWakePaths(wakeRoot).dataRoot, 'runs');
221
394
  const recordsById = new Map();
222
395
  const bucketFiles = (await readdir(join(runsRoot, 'by-date', date)).catch(() => []))
223
396
  .filter((file) => file.endsWith('.json'))
224
397
  .sort();
225
398
  for (const file of bucketFiles) {
226
- const record = await readRunRecordFile(join(runsRoot, 'by-date', date, file));
399
+ const record = await readRunRecordFile(join(runsRoot, 'by-date', date, file), options);
227
400
  if (record !== null) {
228
401
  recordsById.set(record.runId, record);
229
402
  }
@@ -233,7 +406,7 @@ async function listRunRecordsForDate(wakeRoot, date) {
233
406
  .filter((file) => file.endsWith('.json'))
234
407
  .sort();
235
408
  for (const file of legacyFiles) {
236
- const record = await readRunRecordFile(join(runsRoot, file));
409
+ const record = await readRunRecordFile(join(runsRoot, file), options);
237
410
  if (record?.startedAt.slice(0, 10) === date) {
238
411
  recordsById.set(record.runId, record);
239
412
  }
@@ -241,6 +414,42 @@ async function listRunRecordsForDate(wakeRoot, date) {
241
414
  }
242
415
  return [...recordsById.values()].sort((left, right) => left.startedAt.localeCompare(right.startedAt));
243
416
  }
417
+ async function listRunRecordsForDate(wakeRoot, date) {
418
+ return listRunRecordsForDateImpl(wakeRoot, date);
419
+ }
420
+ async function listRunRecordSummariesForDate(wakeRoot, date) {
421
+ const paths = createWakePaths(wakeRoot);
422
+ try {
423
+ const index = await readRunSummaryIndex(paths, date);
424
+ const runFileCount = await countRunDateBucketFiles(paths, date);
425
+ if (index.entries.length === runFileCount && (index.entries.length > 0 || runFileCount > 0)) {
426
+ return index.entries;
427
+ }
428
+ }
429
+ catch {
430
+ // Rebuild below under the per-date lock.
431
+ }
432
+ const rebuilt = await withRunSummaryIndexLock(paths, date, async () => {
433
+ const runFileCount = await countRunDateBucketFiles(paths, date);
434
+ if (runFileCount === 0) {
435
+ return null;
436
+ }
437
+ try {
438
+ const index = await readRunSummaryIndex(paths, date);
439
+ if (index.entries.length === runFileCount) {
440
+ return index.entries;
441
+ }
442
+ }
443
+ catch {
444
+ // Rebuild below.
445
+ }
446
+ return rebuildRunSummaryIndexForDate(paths, date);
447
+ });
448
+ if (rebuilt !== null) {
449
+ return rebuilt;
450
+ }
451
+ return listRunRecordsForDateImpl(wakeRoot, date, { summarize: true });
452
+ }
244
453
  async function listRecentRunRecords(wakeRoot, limit) {
245
454
  const runsRoot = join(createWakePaths(wakeRoot).dataRoot, 'runs');
246
455
  const recordsById = new Map();
@@ -297,6 +506,7 @@ export function createStateStore({ wakeRoot }) {
297
506
  const parsed = parseRunRecord(record);
298
507
  await writeJsonFile(paths.runFile(parsed.runId), parsed);
299
508
  await writeJsonFile(paths.runDateFile(parsed.startedAt.slice(0, 10), parsed.runId), parsed);
509
+ await upsertRunSummaryIndexEntry(paths, parsed);
300
510
  return parsed;
301
511
  },
302
512
  async updateRunRecordIf(runId, input) {
@@ -316,16 +526,38 @@ export function createStateStore({ wakeRoot }) {
316
526
  return parseRunRecord(await readJsonFile(paths.runFile(runId)));
317
527
  }
318
528
  catch {
319
- const recent = await listRecentRunRecords(wakeRoot, 500);
320
- return recent.find((record) => record.runId === runId) ?? null;
529
+ // buildBoard calls this once per work item (readRunRecord per
530
+ // lastRunId), so a missing flat file used to mean a full,
531
+ // unstripped listRecentRunRecords(500) scan - every heavy
532
+ // stdout/raw payload in the 500 most recent runs, parsed again for
533
+ // every item on the board. Scan summaries to locate the record's
534
+ // date bucket instead, then do one targeted full read of just that
535
+ // file so the caller still gets full fidelity.
536
+ const summaries = await listRunRecordSummaries(wakeRoot);
537
+ const match = summaries.find((record) => record.runId === runId);
538
+ if (match === undefined) {
539
+ return null;
540
+ }
541
+ try {
542
+ return parseRunRecord(await readJsonFile(paths.runDateFile(match.startedAt.slice(0, 10), runId)));
543
+ }
544
+ catch {
545
+ return match;
546
+ }
321
547
  }
322
548
  },
323
549
  async listRunRecords() {
324
550
  return listRunRecords(wakeRoot);
325
551
  },
552
+ async listRunRecordSummaries() {
553
+ return listRunRecordSummaries(wakeRoot);
554
+ },
326
555
  async listRunRecordsForDate(date) {
327
556
  return listRunRecordsForDate(wakeRoot, date);
328
557
  },
558
+ async listRunRecordSummariesForDate(date) {
559
+ return listRunRecordSummariesForDate(wakeRoot, date);
560
+ },
329
561
  async listRecentRunRecords(limit = 10) {
330
562
  return listRecentRunRecords(wakeRoot, limit);
331
563
  },
@@ -511,6 +743,7 @@ export function createStateStore({ wakeRoot }) {
511
743
  const issues = [
512
744
  ...(await collectEventIssues(paths)),
513
745
  ...(await collectIssueStateIssues(paths)),
746
+ ...(await collectRunSummaryIndexIssues(paths)),
514
747
  ...(await validateResourceIndex(paths)),
515
748
  ];
516
749
  return { healthy: issues.length === 0, issues };
@@ -82,6 +82,53 @@ export function createGitHubPullRequestActivitySource(deps) {
82
82
  rootId: Number(rootIdStr),
83
83
  };
84
84
  }
85
+ function prCommentPublishedEvent(input) {
86
+ return createEventEnvelope({
87
+ eventId: `${input.intent.eventId}-published`,
88
+ workItemKey: input.intent.workItemKey,
89
+ streamScope: 'work-item',
90
+ direction: 'outbound',
91
+ sourceSystem: githubPrSource,
92
+ sourceEventType: 'pr.comment.reply.published',
93
+ sourceRefs: { repo: input.repoRef, resourceUri: input.resourceUri },
94
+ occurredAt: input.publishedAt,
95
+ ingestedAt: input.publishedAt,
96
+ trigger: 'context-only',
97
+ payload: {
98
+ intentEventId: input.intent.eventId,
99
+ idempotencyKey: input.intent.payload.idempotencyKey,
100
+ deliveryState: 'CONFIRMED',
101
+ kind: input.intent.payload.kind,
102
+ body: input.intent.payload.body,
103
+ providerId: input.providerId,
104
+ },
105
+ });
106
+ }
107
+ function reviewCommentPublishedEvent(input) {
108
+ return createEventEnvelope({
109
+ eventId: `${input.intent.eventId}-published`,
110
+ workItemKey: input.intent.workItemKey,
111
+ streamScope: 'work-item',
112
+ direction: 'outbound',
113
+ sourceSystem: githubPrSource,
114
+ sourceEventType: 'pr.review-comment.reply.published',
115
+ sourceRefs: {
116
+ resourceUri: input.resourceUri,
117
+ sourceUrl: input.sourceUrl,
118
+ },
119
+ occurredAt: input.publishedAt,
120
+ ingestedAt: input.publishedAt,
121
+ trigger: 'context-only',
122
+ payload: {
123
+ intentEventId: input.intent.eventId,
124
+ idempotencyKey: input.intent.payload.idempotencyKey,
125
+ deliveryState: 'CONFIRMED',
126
+ kind: input.intent.payload.kind,
127
+ body: input.intent.payload.body,
128
+ providerId: input.providerId,
129
+ },
130
+ });
131
+ }
85
132
  async function discoverPullRequests(ingestedAt) {
86
133
  const seenPrData = new Map();
87
134
  const confirmedOpenRepos = new Set();
@@ -429,30 +476,30 @@ export function createGitHubPullRequestActivitySource(deps) {
429
476
  if (ref === null) {
430
477
  throw new Error(`cannot deliver intent ${input.event.eventId}: malformed review-thread uri ${resourceUri}`);
431
478
  }
479
+ const marker = wakeIdempotencyMarker(input.event.payload.idempotencyKey);
480
+ if (marker !== undefined) {
481
+ const comments = await deps.client.listReviewComments(ref.owner, ref.repo, ref.number, deps.config.sources.github.pullRequests.commentPageSize);
482
+ const existing = comments.find((comment) => (comment.body ?? '').includes(marker));
483
+ if (existing !== undefined) {
484
+ return [
485
+ reviewCommentPublishedEvent({
486
+ intent: input.event,
487
+ resourceUri,
488
+ sourceUrl: existing.html_url,
489
+ publishedAt,
490
+ providerId: existing.id,
491
+ }),
492
+ ];
493
+ }
494
+ }
432
495
  const response = await deps.client.replyToReviewComment(ref.owner, ref.repo, ref.number, ref.rootId, formatWakeComment(input.event.payload, await readControlPlaneUiUrl(deps.config.paths.wakeRoot)));
433
496
  return [
434
- createEventEnvelope({
435
- eventId: `${input.event.eventId}-published`,
436
- workItemKey: input.event.workItemKey,
437
- streamScope: 'work-item',
438
- direction: 'outbound',
439
- sourceSystem: githubPrSource,
440
- sourceEventType: 'pr.review-comment.reply.published',
441
- sourceRefs: {
442
- resourceUri,
443
- sourceUrl: response?.html_url,
444
- },
445
- occurredAt: publishedAt,
446
- ingestedAt: publishedAt,
447
- trigger: 'context-only',
448
- payload: {
449
- intentEventId: input.event.eventId,
450
- idempotencyKey: input.event.payload.idempotencyKey,
451
- deliveryState: 'CONFIRMED',
452
- kind: input.event.payload.kind,
453
- body: input.event.payload.body,
454
- providerId: response?.id,
455
- },
497
+ reviewCommentPublishedEvent({
498
+ intent: input.event,
499
+ resourceUri,
500
+ sourceUrl: response?.html_url,
501
+ publishedAt,
502
+ providerId: response?.id,
456
503
  }),
457
504
  ];
458
505
  }
@@ -460,27 +507,30 @@ export function createGitHubPullRequestActivitySource(deps) {
460
507
  if (ref === null) {
461
508
  throw new Error(`cannot deliver intent ${input.event.eventId}: malformed pr uri ${resourceUri}`);
462
509
  }
510
+ const marker = wakeIdempotencyMarker(input.event.payload.idempotencyKey);
511
+ if (marker !== undefined) {
512
+ const comments = await deps.client.listComments(ref.owner, ref.repo, ref.number, deps.config.sources.github.pullRequests.commentPageSize);
513
+ const existing = comments.find((comment) => (comment.body ?? '').includes(marker));
514
+ if (existing !== undefined) {
515
+ return [
516
+ prCommentPublishedEvent({
517
+ intent: input.event,
518
+ resourceUri,
519
+ repoRef: ref.repoRef,
520
+ publishedAt,
521
+ providerId: existing.id,
522
+ }),
523
+ ];
524
+ }
525
+ }
463
526
  const response = await deps.client.createComment(ref.owner, ref.repo, ref.number, formatWakeComment(input.event.payload, await readControlPlaneUiUrl(deps.config.paths.wakeRoot)));
464
527
  return [
465
- createEventEnvelope({
466
- eventId: `${input.event.eventId}-published`,
467
- workItemKey: input.event.workItemKey,
468
- streamScope: 'work-item',
469
- direction: 'outbound',
470
- sourceSystem: githubPrSource,
471
- sourceEventType: 'pr.comment.reply.published',
472
- sourceRefs: { repo: ref.repoRef, resourceUri },
473
- occurredAt: publishedAt,
474
- ingestedAt: publishedAt,
475
- trigger: 'context-only',
476
- payload: {
477
- intentEventId: input.event.eventId,
478
- idempotencyKey: input.event.payload.idempotencyKey,
479
- deliveryState: 'CONFIRMED',
480
- kind: input.event.payload.kind,
481
- body: input.event.payload.body,
482
- providerId: response?.data?.id,
483
- },
528
+ prCommentPublishedEvent({
529
+ intent: input.event,
530
+ resourceUri,
531
+ repoRef: ref.repoRef,
532
+ publishedAt,
533
+ providerId: response?.data?.id,
484
534
  }),
485
535
  ];
486
536
  },
@@ -502,25 +552,12 @@ export function createGitHubPullRequestActivitySource(deps) {
502
552
  return [];
503
553
  }
504
554
  return [
505
- createEventEnvelope({
506
- eventId: `${input.event.eventId}-published`,
507
- workItemKey: input.event.workItemKey,
508
- streamScope: 'work-item',
509
- direction: 'outbound',
510
- sourceSystem: githubPrSource,
511
- sourceEventType: 'pr.review-comment.reply.published',
512
- sourceRefs: { resourceUri, sourceUrl: existing.html_url },
513
- occurredAt: publishedAt,
514
- ingestedAt: publishedAt,
515
- trigger: 'context-only',
516
- payload: {
517
- intentEventId: input.event.eventId,
518
- idempotencyKey: input.event.payload.idempotencyKey,
519
- deliveryState: 'CONFIRMED',
520
- kind: input.event.payload.kind,
521
- body: input.event.payload.body,
522
- providerId: existing.id,
523
- },
555
+ reviewCommentPublishedEvent({
556
+ intent: input.event,
557
+ resourceUri,
558
+ sourceUrl: existing.html_url,
559
+ publishedAt,
560
+ providerId: existing.id,
524
561
  }),
525
562
  ];
526
563
  }
@@ -534,25 +571,12 @@ export function createGitHubPullRequestActivitySource(deps) {
534
571
  return [];
535
572
  }
536
573
  return [
537
- createEventEnvelope({
538
- eventId: `${input.event.eventId}-published`,
539
- workItemKey: input.event.workItemKey,
540
- streamScope: 'work-item',
541
- direction: 'outbound',
542
- sourceSystem: githubPrSource,
543
- sourceEventType: 'pr.comment.reply.published',
544
- sourceRefs: { repo: ref.repoRef, resourceUri },
545
- occurredAt: publishedAt,
546
- ingestedAt: publishedAt,
547
- trigger: 'context-only',
548
- payload: {
549
- intentEventId: input.event.eventId,
550
- idempotencyKey: input.event.payload.idempotencyKey,
551
- deliveryState: 'CONFIRMED',
552
- kind: input.event.payload.kind,
553
- body: input.event.payload.body,
554
- providerId: existing.id,
555
- },
574
+ prCommentPublishedEvent({
575
+ intent: input.event,
576
+ resourceUri,
577
+ repoRef: ref.repoRef,
578
+ publishedAt,
579
+ providerId: existing.id,
556
580
  }),
557
581
  ];
558
582
  },
@@ -59,6 +59,10 @@ export const indexHtml = `<!DOCTYPE html>
59
59
  .card:hover { border-color: var(--accent); }
60
60
  .card .title { font-weight: 600; margin-bottom: 0.25rem; }
61
61
  .card .meta { color: #9aa2ad; font-size: 0.72rem; }
62
+ .child-run { display: grid; grid-template-columns: auto 1fr; gap: 0.25rem 0.45rem; align-items: center; margin-top: 0.45rem; padding: 0.4rem; border-radius: 6px; background: #181c22; border: 1px solid #334155; color: #cbd5e1; font-size: 0.72rem; }
63
+ .child-run .dot { width: 0.48rem; height: 0.48rem; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 3px rgba(45, 212, 191, 0.14); }
64
+ .child-run .run-title { font-weight: 650; color: #e5e7eb; }
65
+ .child-run .run-meta { grid-column: 2; color: #94a3b8; }
62
66
  .chip { display: inline-block; background: #2c313a; border-radius: 4px; padding: 0.05rem 0.35rem; font-size: 0.68rem; margin-right: 0.2rem; }
63
67
  .chip-label { background: transparent; border: 1px solid #3a4150; color: #9aa2ad; margin-bottom: 0.2rem; }
64
68
  table { border-collapse: collapse; width: 100%; font-size: 0.8rem; }
@@ -286,6 +290,14 @@ async function renderBoard(context) {
286
290
  const board = await getJson('/board', context.signal);
287
291
  if (!isActiveRequest(context.requestId)) return;
288
292
  const main = document.getElementById('main');
293
+ const renderChildRun = (run) => el('div', { class: 'child-run' }, [
294
+ el('span', { class: 'dot' }),
295
+ el('div', { class: 'run-title', text: run.action + ' running' }),
296
+ el('div', {
297
+ class: 'run-meta',
298
+ text: [run.runnerName, run.tier, fmtMs(run.ageMs)].filter(Boolean).join(' · '),
299
+ }),
300
+ ]);
289
301
  const columns = el('div', { class: 'columns' }, CONDITIONS.map((cond) => {
290
302
  const items = board.filter((c) => c.condition === cond);
291
303
  const cards = items.map((item) => el('div', {
@@ -301,6 +313,7 @@ async function renderBoard(context) {
301
313
  ? [el('div', { class: 'meta' }, item.labels.map((label) => el('span', { class: 'chip chip-label', text: label })))]
302
314
  : []),
303
315
  el('div', { class: 'meta', text: item.lastRunSentinel ? 'last: ' + item.lastRunAction + ' → ' + item.lastRunSentinel : item.conditionReason }),
316
+ ...((item.activeChildRuns || []).map(renderChildRun)),
304
317
  ]));
305
318
  return el('div', { class: 'col' + (items.length === 0 ? ' col-empty' : '') }, [
306
319
  el('h2', { text: cond + ' (' + items.length + ')' }),
@@ -74,17 +74,39 @@ function timeInStageMs(item, now) {
74
74
  const lastChange = item.wake.stageHistory.at(-1)?.changedAt ?? item.wake.syncedAt;
75
75
  return now.getTime() - Date.parse(lastChange);
76
76
  }
77
+ function activeChildRunsForItem(item, runs, now) {
78
+ return runs
79
+ .filter((run) => run.workItemKey === item.workItemKey &&
80
+ run.status === 'running' &&
81
+ run.runId !== item.wake.lastRunId)
82
+ .sort((left, right) => left.startedAt.localeCompare(right.startedAt))
83
+ .map((run) => ({
84
+ runId: run.runId,
85
+ action: run.action,
86
+ status: run.status,
87
+ startedAt: run.startedAt,
88
+ ageMs: now.getTime() - Date.parse(run.startedAt),
89
+ ...(run.routing?.runnerName === undefined ? {} : { runnerName: run.routing.runnerName }),
90
+ ...(run.routing?.runnerKind === undefined ? {} : { runnerKind: run.routing.runnerKind }),
91
+ ...(run.routing?.tier === undefined ? {} : { tier: run.routing.tier }),
92
+ }));
93
+ }
77
94
  export async function buildBoard(input) {
78
95
  const items = await input.stateStore.listIssueStates({
79
96
  archiveFreshnessDays: input.config.ui.archiveFreshnessDays,
80
97
  now: input.now,
81
98
  });
82
- const lastRuns = await Promise.all(items.map((item) => item.wake.lastRunId === undefined
83
- ? Promise.resolve(null)
84
- : input.stateStore.readRunRecord(item.wake.lastRunId)));
85
- return items.map((item, index) => {
86
- const lastRun = lastRuns[index] ?? null;
99
+ // One bulk summarized scan shared by every item, instead of readRunRecord()
100
+ // per item - deriveCondition only reads status/sentinel, so this doesn't
101
+ // need full records, and doing a separate lookup (with its own fallback
102
+ // scan when a flat run file is missing) per item made board loads scale
103
+ // with items x run-history size instead of just run-history size.
104
+ const runs = await input.stateStore.listRunRecordSummaries();
105
+ const runsById = new Map(runs.map((run) => [run.runId, run]));
106
+ return items.map((item) => {
107
+ const lastRun = item.wake.lastRunId === undefined ? null : (runsById.get(item.wake.lastRunId) ?? null);
87
108
  const { condition, reason } = deriveCondition(item, lastRun, input.config);
109
+ const activeChildRuns = activeChildRunsForItem(item, runs, input.now);
88
110
  return {
89
111
  repo: item.issue.repo,
90
112
  number: item.issue.number,
@@ -98,6 +120,7 @@ export async function buildBoard(input) {
98
120
  lastRunAction: lastRun?.action,
99
121
  lastRunSentinel: lastRun?.sentinel,
100
122
  lastRunStatus: lastRun?.status,
123
+ ...(activeChildRuns.length === 0 ? {} : { activeChildRuns }),
101
124
  sessionId: item.wake.sessionId,
102
125
  workspacePath: item.wake.workspacePath,
103
126
  };
@@ -109,7 +132,7 @@ export async function buildStatus(input) {
109
132
  input.stateStore.readLedger(),
110
133
  input.stateStore.isPaused(),
111
134
  input.stateStore.listRecentEventEnvelopes({ limit: 1 }),
112
- input.stateStore.listRunRecordsForDate(today),
135
+ input.stateStore.listRunRecordSummariesForDate(today),
113
136
  input.stateStore.listRecentRunRecords(1),
114
137
  buildBoard(input),
115
138
  ]);
@@ -245,7 +268,7 @@ export async function buildItemDetail(input) {
245
268
  if (item === null) {
246
269
  return null;
247
270
  }
248
- const allRuns = await input.stateStore.listRunRecords();
271
+ const allRuns = await input.stateStore.listRunRecordSummaries();
249
272
  const runs = allRuns
250
273
  .filter((run) => run.workItemKey === item.workItemKey)
251
274
  .sort((left, right) => left.startedAt.localeCompare(right.startedAt));
@@ -282,7 +305,7 @@ export async function buildItemTranscripts(input) {
282
305
  if (!input.config.transcripts.enabled) {
283
306
  return { enabled: false, sessions: [] };
284
307
  }
285
- const runs = (await input.stateStore.listRunRecords()).filter((run) => run.workItemKey === input.workItemKey);
308
+ const runs = (await input.stateStore.listRunRecordSummaries()).filter((run) => run.workItemKey === input.workItemKey);
286
309
  const runsById = new Map(runs.map((run) => [run.runId, run]));
287
310
  const workDir = input.stateStore.paths.transcriptWorkDir(input.workItemKey);
288
311
  const sessionDirs = (await readdir(workDir, { withFileTypes: true }).catch(() => []))
@@ -357,7 +380,7 @@ export async function buildEventsFeed(input) {
357
380
  });
358
381
  }
359
382
  export async function buildRuns(input) {
360
- const runs = await input.stateStore.listRunRecords();
383
+ const runs = await input.stateStore.listRunRecordSummaries();
361
384
  return runs
362
385
  .filter((run) => input.status === undefined || run.status === input.status)
363
386
  .filter((run) => input.action === undefined || run.action === input.action)
@@ -476,7 +499,7 @@ function findBucket(timestamp, buckets) {
476
499
  async function listRunsForBuckets(stateStore, buckets) {
477
500
  const dates = [...new Set(buckets.map((bucket) => bucket.bucket.slice(0, 10)))];
478
501
  const runsById = new Map();
479
- const recordsByDate = await Promise.all(dates.map((date) => stateStore.listRunRecordsForDate(date)));
502
+ const recordsByDate = await Promise.all(dates.map((date) => stateStore.listRunRecordSummariesForDate(date)));
480
503
  for (const records of recordsByDate) {
481
504
  for (const record of records) {
482
505
  runsById.set(record.runId, record);
@@ -144,7 +144,14 @@ export function createStaleRunReconciler(deps) {
144
144
  }
145
145
  async function reconcileStaleRunningRecords(now) {
146
146
  const finishedAt = now.toISOString();
147
- const runRecords = await deps.stateStore.listRunRecords();
147
+ // Summarized: the scan below (staleReason/classifyReconciledFailure/the
148
+ // newerCompletedRun comparison) only reads status/lifecycle/timestamps/pids
149
+ // and metadata.workspacePath, none of which are stripped - this runs every
150
+ // tick, so loading every run's captured stdout/raw here just to check
151
+ // staleness on all of them was a real OOM risk. The stale ones actually
152
+ // rewritten below re-fetch the full record first so captured output isn't
153
+ // dropped on write.
154
+ const runRecords = await deps.stateStore.listRunRecordSummaries();
148
155
  await recoverMissingRunRecordClaims(runRecords, finishedAt);
149
156
  const staleRecords = [];
150
157
  for (const record of runRecords) {
@@ -166,15 +173,16 @@ export function createStaleRunReconciler(deps) {
166
173
  // spelled out so the non-null projection is available below for its
167
174
  // workItemKey.
168
175
  if (projection === null || projection.wake.lastRunId !== record.runId || newerCompletedRun) {
176
+ const fullRecord = (await deps.stateStore.readRunRecord(record.runId)) ?? record;
169
177
  await deps.stateStore.writeRunRecord({
170
- ...record,
178
+ ...fullRecord,
171
179
  lifecycle: 'TERMINAL',
172
180
  status: 'superseded',
173
181
  finishedAt,
174
182
  executionOutcome: 'SUPERSEDED',
175
183
  summary: 'Stale running record was superseded by a newer run.',
176
184
  metadata: {
177
- ...record.metadata,
185
+ ...fullRecord.metadata,
178
186
  reconciledBy: 'stale-running-record',
179
187
  supersededBy: projection?.wake.lastRunId,
180
188
  },
@@ -183,8 +191,9 @@ export function createStaleRunReconciler(deps) {
183
191
  }
184
192
  const staleExecutionOutcome = reason === 'timeout' ? 'TIMED_OUT' : recoveryOutcomeForLifecycle(record.lifecycle);
185
193
  const failureContext = classifyReconciledFailure(record);
194
+ const fullRecord = (await deps.stateStore.readRunRecord(record.runId)) ?? record;
186
195
  await deps.stateStore.writeRunRecord({
187
- ...record,
196
+ ...fullRecord,
188
197
  lifecycle: 'TERMINAL',
189
198
  status: 'failed',
190
199
  finishedAt,
@@ -193,7 +202,7 @@ export function createStaleRunReconciler(deps) {
193
202
  ...failureContext,
194
203
  summary: `Run exceeded timeout while marked running and was reconciled by a later tick.`,
195
204
  metadata: {
196
- ...record.metadata,
205
+ ...fullRecord.metadata,
197
206
  reconciledBy: 'stale-running-record',
198
207
  recoveryLifecycle: record.lifecycle,
199
208
  staleReason: reason,
@@ -26,11 +26,28 @@ import { currentProcessIdentity, processIdentityMatches } from '../lib/process-i
26
26
  import { readJsonFile, writeJsonFile } from '../lib/json-file.js';
27
27
  import { isMissingPathError } from '../lib/state-health.js';
28
28
  const prReviewApprovalMarker = '<!-- wake:pr-review-approved -->';
29
+ const prReviewChangesMarker = '<!-- wake:pr-review-changes-requested -->';
29
30
  const activeRunSourceRefreshIntervalMs = 1_000;
30
31
  function latestHumanCommentId(candidate) {
31
32
  const human = candidate.comments.filter((c) => !c.isBotAuthored);
32
33
  return human.at(-1)?.id;
33
34
  }
35
+ function latestActionableCommentId(candidate) {
36
+ const handledCommentId = typeof candidate.context.lastHandledCommentId === 'string'
37
+ ? candidate.context.lastHandledCommentId
38
+ : undefined;
39
+ const lastBotIndex = candidate.comments.reduce((acc, comment, index) => {
40
+ return comment.isBotAuthored ? index : acc;
41
+ }, -1);
42
+ const latestComment = candidate.comments.slice(lastBotIndex).at(-1);
43
+ if (latestComment?.id !== handledCommentId &&
44
+ latestComment?.isBotAuthored === true &&
45
+ latestComment.resourceUri !== undefined &&
46
+ latestComment.body.includes(prReviewChangesMarker)) {
47
+ return latestComment.id;
48
+ }
49
+ return latestHumanCommentId(candidate);
50
+ }
34
51
  function projectedSourceRevision(projection) {
35
52
  const latestCommentUpdatedAt = projection.comments
36
53
  .map((comment) => comment.updatedAt)
@@ -43,6 +60,12 @@ function projectedSourceRevision(projection) {
43
60
  function isLateralReadOnlyAction(action, config) {
44
61
  return isCustomCommandAction(action, config);
45
62
  }
63
+ function isEventFromWatcherRun(event) {
64
+ return (typeof event.payload === 'object' &&
65
+ event.payload !== null &&
66
+ 'watcherRun' in event.payload &&
67
+ event.payload.watcherRun === true);
68
+ }
46
69
  function shouldPublishRunResult(input) {
47
70
  if (input.failureClass === 'quota') {
48
71
  return false;
@@ -172,8 +195,10 @@ export function createTickRunner(deps) {
172
195
  return projection.issue.labels.includes(label);
173
196
  }
174
197
  function approvedMergePolicyForStage(stage) {
175
- return (stage?.watch?.find((watch) => watch.onApproved?.merge !== undefined)?.onApproved?.merge ??
176
- null);
198
+ // Schema defaults materialize a disabled merge block whenever onSuccess is
199
+ // set (e.g. approve-only watchers); treat it as no merge policy at all.
200
+ const merge = stage?.watch?.find((watch) => watch.onSuccess?.merge !== undefined)?.onSuccess?.merge ?? null;
201
+ return merge !== null && (merge.approve || merge.autoMerge) ? merge : null;
177
202
  }
178
203
  function escapeRegex(input) {
179
204
  return input.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
@@ -367,6 +392,50 @@ export function createTickRunner(deps) {
367
392
  }));
368
393
  }
369
394
  }
395
+ // The one deterministic approval transition: /approved, wake:auto, and a
396
+ // watcher child's onSuccess.approve all resolve a pending approval through
397
+ // this same event shape, so replay folds them identically.
398
+ async function applyApprovalTransition(input) {
399
+ const nextStage = lifecycle.nextStageFromSentinel(input.projection.wake.stage, 'DONE', input.workflow);
400
+ if (nextStage === null) {
401
+ return null;
402
+ }
403
+ const approvalCompletedEvent = createEventEnvelope({
404
+ eventId: `${input.approvalId}-completed`,
405
+ workItemKey: input.projection.workItemKey,
406
+ streamScope: 'work-item',
407
+ direction: 'internal',
408
+ sourceSystem: 'wake',
409
+ sourceEventType: RUN_COMPLETED_EVENT,
410
+ sourceRefs: {
411
+ repo: input.projection.issue.repo,
412
+ issueNumber: input.projection.issue.number,
413
+ runId: input.approvalId,
414
+ },
415
+ occurredAt: input.approvedAt,
416
+ ingestedAt: input.approvedAt,
417
+ trigger: 'immediate',
418
+ payload: {
419
+ action: input.pendingAction,
420
+ sentinel: 'DONE',
421
+ nextStage,
422
+ runId: input.approvalId,
423
+ reason: input.reason,
424
+ ...input.payloadExtras,
425
+ },
426
+ });
427
+ await deps.stateStore.appendEventEnvelope(approvalCompletedEvent);
428
+ await projectionUpdater.rebuildFromEvents([approvalCompletedEvent]);
429
+ await deliverOutboundEvent(createLabelsEvent({
430
+ projection: input.projection,
431
+ runId: input.approvalId,
432
+ statusLabel: statusLabelForStage(nextStage),
433
+ stageLabel: stageLabelForStage(nextStage),
434
+ workflowLabel: workflowLabelForWorkflowName(input.workflowName),
435
+ occurredAt: input.approvedAt,
436
+ }));
437
+ return { nextStage };
438
+ }
370
439
  // Closes the loop on #82's review feedback: rather than scrape a PR link
371
440
  // out of the agent's free text, the agent emits a `wake-artifacts` fence
372
441
  // (domain/schema.ts's parseRunnerArtifacts) and Wake verifies each claim
@@ -462,7 +531,7 @@ export function createTickRunner(deps) {
462
531
  async function markPendingActionableIssues(projections) {
463
532
  const activeRunWorkItemKeys = new Set();
464
533
  const now = deps.clock.now();
465
- for (const record of await deps.stateStore.listRunRecords()) {
534
+ for (const record of await deps.stateStore.listRunRecordSummaries()) {
466
535
  if (record.status === 'running' && (await isRunningRecordActive(record, now))) {
467
536
  activeRunWorkItemKeys.add(record.workItemKey);
468
537
  }
@@ -501,7 +570,7 @@ export function createTickRunner(deps) {
501
570
  async function exceedsDispatchRateLimit(now) {
502
571
  const { windowMs, maxDispatches } = deps.config.scheduler.dispatchRateLimit;
503
572
  const windowStartMs = now.getTime() - windowMs;
504
- const runRecords = await deps.stateStore.listRunRecords();
573
+ const runRecords = await deps.stateStore.listRunRecordSummaries();
505
574
  const recentCount = runRecords.reduce((count, record) => {
506
575
  const startedAtMs = Date.parse(record.startedAt);
507
576
  return Number.isFinite(startedAtMs) &&
@@ -541,7 +610,7 @@ export function createTickRunner(deps) {
541
610
  }));
542
611
  }
543
612
  async function hasSchedulerCapacity(now) {
544
- const runRecords = await deps.stateStore.listRunRecords();
613
+ const runRecords = await deps.stateStore.listRunRecordSummaries();
545
614
  for (const record of runRecords) {
546
615
  if (record.status !== 'running') {
547
616
  continue;
@@ -763,6 +832,7 @@ export function createTickRunner(deps) {
763
832
  });
764
833
  const matchingEvents = events
765
834
  .filter((event) => watcher.on.event.includes(event.sourceEventType))
835
+ .filter((event) => !isEventFromWatcherRun(event))
766
836
  .sort((left, right) => left.ingestedAt.localeCompare(right.ingestedAt));
767
837
  const cursorIndex = state?.lastDispatchedEventId === undefined
768
838
  ? -1
@@ -881,12 +951,12 @@ export function createTickRunner(deps) {
881
951
  if (await parkConfigDriftedProjections(projections)) {
882
952
  return { status: 'processed' };
883
953
  }
884
- const watcherDispatch = await nextWatcherDispatch(projections, tickStartedAt);
885
- let candidate = watcherDispatch?.projection;
954
+ let candidate = projections.find((issue) => policy.resolveNextEligibleAction(issue, deps.config) !== null);
955
+ const watcherDispatch = candidate === undefined ? await nextWatcherDispatch(projections, tickStartedAt) : null;
956
+ candidate ??= watcherDispatch?.projection;
886
957
  let watcherStateKeyForRun;
887
958
  let watcherTriggerForRun;
888
959
  const watcherRun = watcherDispatch !== null;
889
- candidate ??= projections.find((issue) => policy.resolveNextEligibleAction(issue, deps.config) !== null);
890
960
  if (candidate === undefined) {
891
961
  return { status: 'idle' };
892
962
  }
@@ -1017,48 +1087,29 @@ export function createTickRunner(deps) {
1017
1087
  const approvalId = `approval-${candidate.issue.number}-${deps.clock.now().getTime()}`;
1018
1088
  const approvedAt = deps.clock.now().toISOString();
1019
1089
  const automaticApproval = approvalResolution.automatic === true;
1020
- const nextStage = lifecycle.nextStageFromSentinel(candidate.wake.stage, 'DONE', workflow);
1021
- if (nextStage === null) {
1090
+ const applied = await applyApprovalTransition({
1091
+ projection: candidate,
1092
+ pendingAction: approvalResolution.pendingAction,
1093
+ approvalId,
1094
+ approvedAt,
1095
+ reason: automaticApproval ? 'auto:approved' : 'human:approved',
1096
+ workflow,
1097
+ workflowName,
1098
+ payloadExtras: automaticApproval
1099
+ ? {
1100
+ autoResolution: {
1101
+ kind: 'awaiting-approval',
1102
+ classification: 'auto-approval',
1103
+ reasoning: approvalResolution.reason,
1104
+ },
1105
+ }
1106
+ : {
1107
+ handledCommentId: approvalResolution.triggeringCommentId ?? latestHumanCommentId(candidate),
1108
+ },
1109
+ });
1110
+ if (applied === null) {
1022
1111
  return { status: 'idle' };
1023
1112
  }
1024
- const approvalCompletedEvent = createEventEnvelope({
1025
- eventId: `${approvalId}-completed`,
1026
- workItemKey: candidate.workItemKey,
1027
- streamScope: 'work-item',
1028
- direction: 'internal',
1029
- sourceSystem: 'wake',
1030
- sourceEventType: RUN_COMPLETED_EVENT,
1031
- sourceRefs: {
1032
- repo: candidate.issue.repo,
1033
- issueNumber: candidate.issue.number,
1034
- runId: approvalId,
1035
- },
1036
- occurredAt: approvedAt,
1037
- ingestedAt: approvedAt,
1038
- trigger: 'immediate',
1039
- payload: {
1040
- action: approvalResolution.pendingAction,
1041
- sentinel: 'DONE',
1042
- nextStage,
1043
- runId: approvalId,
1044
- reason: automaticApproval ? 'auto:approved' : 'human:approved',
1045
- ...(automaticApproval
1046
- ? {}
1047
- : {
1048
- handledCommentId: approvalResolution.triggeringCommentId ?? latestHumanCommentId(candidate),
1049
- }),
1050
- ...(automaticApproval
1051
- ? {
1052
- autoResolution: {
1053
- kind: 'awaiting-approval',
1054
- classification: 'auto-approval',
1055
- reasoning: approvalResolution.reason,
1056
- },
1057
- }
1058
- : {}),
1059
- },
1060
- });
1061
- await deps.stateStore.appendEventEnvelope(approvalCompletedEvent);
1062
1113
  if (automaticApproval) {
1063
1114
  await appendAuditEvent({
1064
1115
  eventId: `${approvalId}-audit-auto-resolution`,
@@ -1078,7 +1129,7 @@ export function createTickRunner(deps) {
1078
1129
  },
1079
1130
  outcome: {
1080
1131
  approved: true,
1081
- nextStage,
1132
+ nextStage: applied.nextStage,
1082
1133
  reason: approvalResolution.reason,
1083
1134
  },
1084
1135
  timestamp: approvedAt,
@@ -1088,20 +1139,11 @@ export function createTickRunner(deps) {
1088
1139
  },
1089
1140
  });
1090
1141
  }
1091
- await projectionUpdater.rebuildFromEvents([approvalCompletedEvent]);
1092
- await deliverOutboundEvent(createLabelsEvent({
1093
- projection: candidate,
1094
- runId: approvalId,
1095
- statusLabel: statusLabelForStage(nextStage),
1096
- stageLabel: stageLabelForStage(nextStage),
1097
- workflowLabel: workflowLabelForWorkflowName(workflowName),
1098
- occurredAt: approvedAt,
1099
- }));
1100
1142
  return {
1101
1143
  status: 'processed',
1102
1144
  runId: approvalId,
1103
1145
  sentinel: 'DONE',
1104
- nextStage,
1146
+ nextStage: applied.nextStage,
1105
1147
  };
1106
1148
  }
1107
1149
  else {
@@ -1669,7 +1711,7 @@ export function createTickRunner(deps) {
1669
1711
  // unset lets the next tick retry instead of silently eating the request (S9).
1670
1712
  ...(runnerResult.failureClass === 'quota' || runnerResult.failureClass === 'infra'
1671
1713
  ? {}
1672
- : { handledCommentId: latestHumanCommentId(candidate) }),
1714
+ : { handledCommentId: latestActionableCommentId(candidate) }),
1673
1715
  body: parsedRunnerResult.body,
1674
1716
  envelope: parsedRunnerResult.envelope,
1675
1717
  executionOutcome,
@@ -1709,6 +1751,10 @@ export function createTickRunner(deps) {
1709
1751
  if (watcherRun) {
1710
1752
  // Watcher-dispatched runs own PR verdict delivery and correlation
1711
1753
  // registration regardless of the target workflow/action name.
1754
+ const pendingApprovalAction = candidate.context.pendingApprovalAction;
1755
+ const watcherSuccessPolicy = watcherDispatch === null
1756
+ ? undefined
1757
+ : deps.config.workflows[watcherDispatch.parentWorkflowName]?.stages[watcherDispatch.parentStage]?.watch?.[watcherDispatch.watcherIndex]?.onSuccess;
1712
1758
  if (prReviewTargetResourceUri !== null &&
1713
1759
  (sentinel === 'DONE' || sentinel === 'FAILED')) {
1714
1760
  await deliverOutboundEvent({
@@ -1727,6 +1773,58 @@ export function createTickRunner(deps) {
1727
1773
  },
1728
1774
  });
1729
1775
  }
1776
+ else if (watcherDispatch !== null &&
1777
+ watcherSuccessPolicy?.approve === true &&
1778
+ (sentinel === 'DONE' || sentinel === 'FAILED' || sentinel === 'BLOCKED')) {
1779
+ // No PR surface to carry the verdict comment: the child's sentinel
1780
+ // is its verdict. Publish the review body for every verdict so the
1781
+ // human sees why; only DONE resolves the parent's pending gate.
1782
+ await deliverOutboundEvent(publishIntent);
1783
+ const approvalId = `${runId}-parent-approval`;
1784
+ const parentWorkflow = deps.config.workflows[watcherDispatch.parentWorkflowName];
1785
+ if (sentinel === 'DONE' &&
1786
+ isAwaitingApproval(candidate) &&
1787
+ typeof pendingApprovalAction === 'string' &&
1788
+ parentWorkflow !== undefined &&
1789
+ (await deps.stateStore.readEventEnvelope(`${approvalId}-completed`)) === null) {
1790
+ const approvedAt = eventStampNow();
1791
+ const applied = await applyApprovalTransition({
1792
+ projection: candidate,
1793
+ pendingAction: pendingApprovalAction,
1794
+ approvalId,
1795
+ approvedAt,
1796
+ reason: 'watcher:approved',
1797
+ workflow: parentWorkflow,
1798
+ workflowName: watcherDispatch.parentWorkflowName,
1799
+ });
1800
+ if (applied !== null) {
1801
+ await appendAuditEvent({
1802
+ eventId: `${approvalId}-audit-watcher-approval`,
1803
+ decisionType: 'approval.watcher-resolved',
1804
+ workItemKey: candidate.workItemKey,
1805
+ runId,
1806
+ workflowRevision: await computeWorkflowRevision({
1807
+ config: deps.config,
1808
+ workflowName: watcherDispatch.parentWorkflowName,
1809
+ workflow: parentWorkflow,
1810
+ action: pendingApprovalAction,
1811
+ }),
1812
+ inputsConsidered: {
1813
+ watcherWorkflow: watcherDispatch.targetWorkflowName,
1814
+ pendingAction: pendingApprovalAction,
1815
+ childRunId: runId,
1816
+ childSentinel: sentinel,
1817
+ },
1818
+ outcome: { approved: true, nextStage: applied.nextStage },
1819
+ timestamp: approvedAt,
1820
+ sourceRefs: {
1821
+ repo: candidate.issue.repo,
1822
+ issueNumber: candidate.issue.number,
1823
+ },
1824
+ });
1825
+ }
1826
+ }
1827
+ }
1730
1828
  else {
1731
1829
  await suppressOutboundEvent(publishIntent, {
1732
1830
  suppressedPublishReason: 'pr-review-no-actionable-verdict',
@@ -430,6 +430,12 @@ const approvedMergePolicySchema = z
430
430
  blockedPaths: [],
431
431
  blockedLabels: [],
432
432
  });
433
+ const watchSuccessPolicySchema = z.object({
434
+ // Resolve the watched parent stage's pending approval gate when the child
435
+ // workflow run completes DONE — the child's sentinel is its verdict.
436
+ approve: z.boolean().default(false),
437
+ merge: approvedMergePolicySchema.optional(),
438
+ });
433
439
  const workflowStageSchema = stageRouteSchema.extend({
434
440
  workspace: workflowWorkspaceSchema,
435
441
  onDone: identifierSchema,
@@ -445,11 +451,7 @@ const workflowStageSchema = stageRouteSchema.extend({
445
451
  .optional(),
446
452
  schedule: workflowTriggerScheduleSchema.optional(),
447
453
  workflow: identifierSchema,
448
- onApproved: z
449
- .object({
450
- merge: approvedMergePolicySchema.optional(),
451
- })
452
- .optional(),
454
+ onSuccess: watchSuccessPolicySchema.optional(),
453
455
  }))
454
456
  .optional(),
455
457
  });
@@ -32,6 +32,8 @@ export function createWakePaths(wakeRoot) {
32
32
  sourceStateFile: (source, key) => join(dataRoot, 'sources', sanitizePathKey(source), `${sanitizePathKey(key)}.json`),
33
33
  runFile: (runId) => join(dataRoot, 'runs', `${runId}.json`),
34
34
  runDateFile: (date, runId) => join(dataRoot, 'runs', 'by-date', date, `${runId}.json`),
35
+ runDateIndexFile: (date) => join(dataRoot, 'runs', 'by-date', date, 'index.json'),
36
+ runDateIndexLockFile: (date) => join(dataRoot, 'locks', `run-index-${date}.lock`),
35
37
  eventFile: (date) => join(dataRoot, 'events', `${date}.jsonl`),
36
38
  eventEnvelopeFile: (eventId) => join(dataRoot, 'events-by-id', `${sanitizePathKey(eventId)}.json`),
37
39
  logFile: (date) => join(dataRoot, 'logs', `${date}.log`),
@@ -124,4 +124,4 @@ export function resolveWakeVersion(options = {}) {
124
124
  }
125
125
  return '0.1.0-dev';
126
126
  }
127
- export const wakeVersion = "g950074e";
127
+ export const wakeVersion = "g38b681f";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atolis-hq/wake",
3
- "version": "0.2.54",
3
+ "version": "0.2.56",
4
4
  "description": "Local autonomous agent control plane for software development",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -0,0 +1,27 @@
1
+ ---
2
+ permissionMode: default
3
+ allowedTools: Read, Glob, Grep, Bash(git fetch), Bash(git status), Bash(gh issue view *), Bash(gh api repos/*/issues/*), WebSearch, WebFetch
4
+ maxTurns: 8
5
+ skipApproval: true
6
+ ---
7
+ You are Wake, in the PLAN-REVIEW workflow for work item {{workItemKey}}.
8
+ {{toolCapabilityNote}}
9
+
10
+ Your job is only to determine whether the pending plan on this work item is ready to proceed to the next stage.
11
+
12
+ Assess whether it is safe and correct to approve as-is, letting Wake proceed unattended. Weigh:
13
+ - Does the proposed plan actually address the issue as written, without silently narrowing, widening, or misreading the scope?
14
+ - Are there open questions in Wake's comment that were never actually answered (a refine pass sometimes states assumptions instead of asking — treat unstated but load-bearing assumptions the same as open questions)?
15
+ - Does anything look unsafe to let proceed unattended (touches security-sensitive paths, proposes skipping tests/validation, makes an irreversible-sounding decision)?
16
+ - Are the architectural choices sound and aligned with the repo's long-term direction?
17
+ - Is this the kind of decision the operator would obviously make the same way every time, or does it need their specific judgment?
18
+ - Are there obvious better solutions that were not considered?
19
+
20
+ Write your assessment as your response body — it is posted to the issue for the record. Do not post comments yourself, and do not use `/approved` or `/changes` commands: Wake applies the outcome from your verdict.
21
+
22
+ Verdict mapping:
23
+ - Use `DONE` only when you are confident the plan should be approved; Wake resolves the pending approval and advances the stage.
24
+ - Use `FAILED` when the plan needs changes; explain the required changes clearly.
25
+ - Use `BLOCKED` when the decision needs human judgment.
26
+
27
+ {{feedbackCommandNote}}