@atolis-hq/wake 0.2.54 → 0.2.55

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;
@@ -462,7 +485,7 @@ export function createTickRunner(deps) {
462
485
  async function markPendingActionableIssues(projections) {
463
486
  const activeRunWorkItemKeys = new Set();
464
487
  const now = deps.clock.now();
465
- for (const record of await deps.stateStore.listRunRecords()) {
488
+ for (const record of await deps.stateStore.listRunRecordSummaries()) {
466
489
  if (record.status === 'running' && (await isRunningRecordActive(record, now))) {
467
490
  activeRunWorkItemKeys.add(record.workItemKey);
468
491
  }
@@ -501,7 +524,7 @@ export function createTickRunner(deps) {
501
524
  async function exceedsDispatchRateLimit(now) {
502
525
  const { windowMs, maxDispatches } = deps.config.scheduler.dispatchRateLimit;
503
526
  const windowStartMs = now.getTime() - windowMs;
504
- const runRecords = await deps.stateStore.listRunRecords();
527
+ const runRecords = await deps.stateStore.listRunRecordSummaries();
505
528
  const recentCount = runRecords.reduce((count, record) => {
506
529
  const startedAtMs = Date.parse(record.startedAt);
507
530
  return Number.isFinite(startedAtMs) &&
@@ -541,7 +564,7 @@ export function createTickRunner(deps) {
541
564
  }));
542
565
  }
543
566
  async function hasSchedulerCapacity(now) {
544
- const runRecords = await deps.stateStore.listRunRecords();
567
+ const runRecords = await deps.stateStore.listRunRecordSummaries();
545
568
  for (const record of runRecords) {
546
569
  if (record.status !== 'running') {
547
570
  continue;
@@ -763,6 +786,7 @@ export function createTickRunner(deps) {
763
786
  });
764
787
  const matchingEvents = events
765
788
  .filter((event) => watcher.on.event.includes(event.sourceEventType))
789
+ .filter((event) => !isEventFromWatcherRun(event))
766
790
  .sort((left, right) => left.ingestedAt.localeCompare(right.ingestedAt));
767
791
  const cursorIndex = state?.lastDispatchedEventId === undefined
768
792
  ? -1
@@ -881,12 +905,12 @@ export function createTickRunner(deps) {
881
905
  if (await parkConfigDriftedProjections(projections)) {
882
906
  return { status: 'processed' };
883
907
  }
884
- const watcherDispatch = await nextWatcherDispatch(projections, tickStartedAt);
885
- let candidate = watcherDispatch?.projection;
908
+ let candidate = projections.find((issue) => policy.resolveNextEligibleAction(issue, deps.config) !== null);
909
+ const watcherDispatch = candidate === undefined ? await nextWatcherDispatch(projections, tickStartedAt) : null;
910
+ candidate ??= watcherDispatch?.projection;
886
911
  let watcherStateKeyForRun;
887
912
  let watcherTriggerForRun;
888
913
  const watcherRun = watcherDispatch !== null;
889
- candidate ??= projections.find((issue) => policy.resolveNextEligibleAction(issue, deps.config) !== null);
890
914
  if (candidate === undefined) {
891
915
  return { status: 'idle' };
892
916
  }
@@ -1669,7 +1693,7 @@ export function createTickRunner(deps) {
1669
1693
  // unset lets the next tick retry instead of silently eating the request (S9).
1670
1694
  ...(runnerResult.failureClass === 'quota' || runnerResult.failureClass === 'infra'
1671
1695
  ? {}
1672
- : { handledCommentId: latestHumanCommentId(candidate) }),
1696
+ : { handledCommentId: latestActionableCommentId(candidate) }),
1673
1697
  body: parsedRunnerResult.body,
1674
1698
  envelope: parsedRunnerResult.envelope,
1675
1699
  executionOutcome,
@@ -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 = "ga7c1356";
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.55",
4
4
  "description": "Local autonomous agent control plane for software development",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {