@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.
- package/dist/src/adapters/fs/state-store.js +243 -10
- package/dist/src/adapters/github/github-pull-request-activity-source.js +103 -79
- package/dist/src/adapters/http/ui-assets.js +13 -0
- package/dist/src/adapters/http/ui-data.js +33 -10
- package/dist/src/core/stale-run-reconciler.js +14 -5
- package/dist/src/core/tick-runner.js +158 -60
- package/dist/src/domain/schema.js +7 -5
- package/dist/src/lib/paths.js +2 -0
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
- package/prompts/plan-review.md +27 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
320
|
-
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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
|
-
|
|
176
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
885
|
-
|
|
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
|
|
1021
|
-
|
|
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:
|
|
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
|
-
|
|
449
|
-
.object({
|
|
450
|
-
merge: approvedMergePolicySchema.optional(),
|
|
451
|
-
})
|
|
452
|
-
.optional(),
|
|
454
|
+
onSuccess: watchSuccessPolicySchema.optional(),
|
|
453
455
|
}))
|
|
454
456
|
.optional(),
|
|
455
457
|
});
|
package/dist/src/lib/paths.js
CHANGED
|
@@ -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`),
|
package/dist/src/version.js
CHANGED
package/package.json
CHANGED
|
@@ -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}}
|