@atolis-hq/wake 0.2.31 → 0.2.33
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/resource-index.js +45 -3
- package/dist/src/adapters/fs/state-store.js +225 -66
- package/dist/src/adapters/github/github-client.js +8 -0
- package/dist/src/adapters/github/github-issues-work-source.js +68 -0
- package/dist/src/core/sink-router.js +12 -0
- package/dist/src/core/tick-runner.js +52 -1
- package/dist/src/lib/state-health.js +30 -0
- package/dist/src/main.js +41 -3
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readdir } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { z } from 'zod';
|
|
2
5
|
import { acquireFileLock } from '../../lib/lock.js';
|
|
3
6
|
import { readJsonFile, writeJsonFile } from '../../lib/json-file.js';
|
|
7
|
+
import { isMissingPathError, stateHealthIssue, StateHealthError, } from '../../lib/state-health.js';
|
|
8
|
+
const shardContentsSchema = z.record(z.string(), z.string());
|
|
4
9
|
/**
|
|
5
10
|
* Addresses a resource uri to one of 256 shards.
|
|
6
11
|
*
|
|
@@ -15,11 +20,48 @@ export function shardFor(resourceUri) {
|
|
|
15
20
|
}
|
|
16
21
|
async function readShard(file) {
|
|
17
22
|
try {
|
|
18
|
-
return await readJsonFile(file);
|
|
23
|
+
return shardContentsSchema.parse(await readJsonFile(file));
|
|
19
24
|
}
|
|
20
|
-
catch {
|
|
21
|
-
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (isMissingPathError(error)) {
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
throw new StateHealthError([
|
|
30
|
+
stateHealthIssue({
|
|
31
|
+
surface: 'reverse-index',
|
|
32
|
+
kind: 'corrupted',
|
|
33
|
+
path: file,
|
|
34
|
+
message: error instanceof Error ? error.message : String(error),
|
|
35
|
+
}),
|
|
36
|
+
]);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export async function validateResourceIndex(paths) {
|
|
40
|
+
const issues = [];
|
|
41
|
+
const entries = await readdir(paths.resourceIndexRoot, { withFileTypes: true }).catch((error) => {
|
|
42
|
+
if (isMissingPathError(error)) {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
throw error;
|
|
46
|
+
});
|
|
47
|
+
for (const entry of entries) {
|
|
48
|
+
if (!entry.isFile() || !entry.name.endsWith('.json')) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const file = join(paths.resourceIndexRoot, entry.name);
|
|
52
|
+
try {
|
|
53
|
+
shardContentsSchema.parse(await readJsonFile(file));
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
issues.push(stateHealthIssue({
|
|
57
|
+
surface: 'reverse-index',
|
|
58
|
+
kind: 'corrupted',
|
|
59
|
+
path: file,
|
|
60
|
+
message: error instanceof Error ? error.message : String(error),
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
22
63
|
}
|
|
64
|
+
return issues;
|
|
23
65
|
}
|
|
24
66
|
// acquireFileLock is a non-blocking try-lock: when contended it returns
|
|
25
67
|
// { acquired: false } immediately rather than waiting. register/retract have
|
|
@@ -1,15 +1,27 @@
|
|
|
1
1
|
import { access, appendFile, mkdir, readFile, readdir, rename } from 'node:fs/promises';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
|
+
import { validateResourceIndex } from './resource-index.js';
|
|
3
4
|
import { parseEventEnvelope, parseIssueStateRecord, parseLedger, parseRunRecord, parseSourceStateRecord, } from '../../domain/schema.js';
|
|
4
5
|
import { isTerminalStage } from '../../domain/stages.js';
|
|
5
6
|
import { appendJsonLine, readJsonFile, writeJsonFile } from '../../lib/json-file.js';
|
|
6
7
|
import { createWakePaths } from '../../lib/paths.js';
|
|
8
|
+
import { isMissingPathError, stateHealthIssue, StateHealthError, throwIfUnhealthy, } from '../../lib/state-health.js';
|
|
7
9
|
async function readIssueStateFile(file) {
|
|
8
10
|
try {
|
|
9
11
|
return parseIssueStateRecord(await readJsonFile(file));
|
|
10
12
|
}
|
|
11
|
-
catch {
|
|
12
|
-
|
|
13
|
+
catch (error) {
|
|
14
|
+
if (isMissingPathError(error)) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
throw new StateHealthError([
|
|
18
|
+
stateHealthIssue({
|
|
19
|
+
surface: 'state',
|
|
20
|
+
kind: 'corrupted',
|
|
21
|
+
path: file,
|
|
22
|
+
message: error instanceof Error ? error.message : String(error),
|
|
23
|
+
}),
|
|
24
|
+
]);
|
|
13
25
|
}
|
|
14
26
|
}
|
|
15
27
|
async function readRunRecordFile(file) {
|
|
@@ -24,24 +36,136 @@ async function readEventFile(file) {
|
|
|
24
36
|
try {
|
|
25
37
|
const raw = await readFile(file, 'utf8');
|
|
26
38
|
const envelopes = [];
|
|
39
|
+
let lineNumber = 0;
|
|
27
40
|
for (const line of raw.split('\n')) {
|
|
41
|
+
lineNumber += 1;
|
|
28
42
|
const trimmed = line.trim();
|
|
29
43
|
if (trimmed.length === 0) {
|
|
30
44
|
continue;
|
|
31
45
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
typeof parsed === 'object' &&
|
|
35
|
-
'eventId' in parsed &&
|
|
36
|
-
'sourceEventType' in parsed) {
|
|
46
|
+
try {
|
|
47
|
+
const parsed = JSON.parse(trimmed);
|
|
37
48
|
envelopes.push(parseEventEnvelope(parsed));
|
|
38
49
|
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
throw new StateHealthError([
|
|
52
|
+
stateHealthIssue({
|
|
53
|
+
surface: 'events',
|
|
54
|
+
kind: 'corrupted',
|
|
55
|
+
path: `${file}:${lineNumber}`,
|
|
56
|
+
message: error instanceof Error ? error.message : String(error),
|
|
57
|
+
}),
|
|
58
|
+
]);
|
|
59
|
+
}
|
|
39
60
|
}
|
|
40
61
|
return envelopes;
|
|
41
62
|
}
|
|
42
|
-
catch {
|
|
43
|
-
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (isMissingPathError(error)) {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
if (error instanceof StateHealthError) {
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
throw new StateHealthError([
|
|
71
|
+
stateHealthIssue({
|
|
72
|
+
surface: 'events',
|
|
73
|
+
kind: 'corrupted',
|
|
74
|
+
path: file,
|
|
75
|
+
message: error instanceof Error ? error.message : String(error),
|
|
76
|
+
}),
|
|
77
|
+
]);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
async function collectIssueStateIssues(paths) {
|
|
81
|
+
const issues = [];
|
|
82
|
+
const visit = async (dir) => {
|
|
83
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch((error) => {
|
|
84
|
+
if (isMissingPathError(error)) {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
throw error;
|
|
88
|
+
});
|
|
89
|
+
for (const entry of entries) {
|
|
90
|
+
if (entry.isDirectory()) {
|
|
91
|
+
if (entry.name === 'index') {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
await visit(join(dir, entry.name));
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (!entry.isFile() || !entry.name.endsWith('.json')) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const file = join(dir, entry.name);
|
|
101
|
+
try {
|
|
102
|
+
parseIssueStateRecord(await readJsonFile(file));
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
issues.push(stateHealthIssue({
|
|
106
|
+
surface: 'state',
|
|
107
|
+
kind: 'corrupted',
|
|
108
|
+
path: file,
|
|
109
|
+
message: error instanceof Error ? error.message : String(error),
|
|
110
|
+
}));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
await visit(join(paths.dataRoot, 'state'));
|
|
115
|
+
return issues;
|
|
116
|
+
}
|
|
117
|
+
async function collectEventIssues(paths) {
|
|
118
|
+
const issues = [];
|
|
119
|
+
const eventLogFiles = await readdir(join(paths.dataRoot, 'events'), {
|
|
120
|
+
withFileTypes: true,
|
|
121
|
+
}).catch((error) => {
|
|
122
|
+
if (isMissingPathError(error)) {
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
throw error;
|
|
126
|
+
});
|
|
127
|
+
for (const entry of eventLogFiles) {
|
|
128
|
+
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
await readEventFile(join(paths.dataRoot, 'events', entry.name));
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
if (error instanceof StateHealthError) {
|
|
136
|
+
issues.push(...error.issues);
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const eventEnvelopeFiles = await readdir(join(paths.dataRoot, 'events-by-id'), {
|
|
144
|
+
withFileTypes: true,
|
|
145
|
+
}).catch((error) => {
|
|
146
|
+
if (isMissingPathError(error)) {
|
|
147
|
+
return [];
|
|
148
|
+
}
|
|
149
|
+
throw error;
|
|
150
|
+
});
|
|
151
|
+
for (const entry of eventEnvelopeFiles) {
|
|
152
|
+
if (!entry.isFile() || !entry.name.endsWith('.json')) {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const file = join(paths.dataRoot, 'events-by-id', entry.name);
|
|
156
|
+
try {
|
|
157
|
+
parseEventEnvelope(await readJsonFile(file));
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
issues.push(stateHealthIssue({
|
|
161
|
+
surface: 'events',
|
|
162
|
+
kind: 'corrupted',
|
|
163
|
+
path: file,
|
|
164
|
+
message: error instanceof Error ? error.message : String(error),
|
|
165
|
+
}));
|
|
166
|
+
}
|
|
44
167
|
}
|
|
168
|
+
return issues;
|
|
45
169
|
}
|
|
46
170
|
function issueArchiveAgeDate(item) {
|
|
47
171
|
const stageChangedAt = item.wake.stageHistory.at(-1)?.changedAt;
|
|
@@ -153,8 +277,11 @@ export function createStateStore({ wakeRoot }) {
|
|
|
153
277
|
try {
|
|
154
278
|
return parseLedger(await readJsonFile(paths.ledgerFile));
|
|
155
279
|
}
|
|
156
|
-
catch {
|
|
157
|
-
|
|
280
|
+
catch (error) {
|
|
281
|
+
if (isMissingPathError(error)) {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
throw error;
|
|
158
285
|
}
|
|
159
286
|
},
|
|
160
287
|
async writeIssueState(record) {
|
|
@@ -206,8 +333,11 @@ export function createStateStore({ wakeRoot }) {
|
|
|
206
333
|
try {
|
|
207
334
|
return parseSourceStateRecord(await readJsonFile(paths.sourceStateFile(source, key)));
|
|
208
335
|
}
|
|
209
|
-
catch {
|
|
210
|
-
|
|
336
|
+
catch (error) {
|
|
337
|
+
if (isMissingPathError(error)) {
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
throw error;
|
|
211
341
|
}
|
|
212
342
|
},
|
|
213
343
|
async appendEventEnvelope(record) {
|
|
@@ -224,66 +354,75 @@ export function createStateStore({ wakeRoot }) {
|
|
|
224
354
|
try {
|
|
225
355
|
return parseEventEnvelope(await readJsonFile(paths.eventEnvelopeFile(eventId)));
|
|
226
356
|
}
|
|
227
|
-
catch {
|
|
228
|
-
|
|
357
|
+
catch (error) {
|
|
358
|
+
if (isMissingPathError(error)) {
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
throw new StateHealthError([
|
|
362
|
+
stateHealthIssue({
|
|
363
|
+
surface: 'events',
|
|
364
|
+
kind: 'corrupted',
|
|
365
|
+
path: paths.eventEnvelopeFile(eventId),
|
|
366
|
+
message: error instanceof Error ? error.message : String(error),
|
|
367
|
+
}),
|
|
368
|
+
]);
|
|
229
369
|
}
|
|
230
370
|
},
|
|
231
371
|
async listIssueStates(options = {}) {
|
|
232
372
|
const stateRoot = join(paths.dataRoot, 'state');
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
:
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
}
|
|
256
|
-
continue;
|
|
257
|
-
}
|
|
258
|
-
if (!entry.isFile() || !entry.name.endsWith('.json')) {
|
|
259
|
-
continue;
|
|
260
|
-
}
|
|
261
|
-
const file = join(dir, entry.name);
|
|
262
|
-
const record = await readIssueStateFile(file);
|
|
263
|
-
if (record === null) {
|
|
373
|
+
const items = [];
|
|
374
|
+
const includeArchived = options.includeArchived ?? false;
|
|
375
|
+
const archiveOptions = options.archiveFreshnessDays === undefined
|
|
376
|
+
? null
|
|
377
|
+
: {
|
|
378
|
+
archiveFreshnessDays: options.archiveFreshnessDays,
|
|
379
|
+
now: options.now ?? new Date(),
|
|
380
|
+
};
|
|
381
|
+
// state/ is flat: state/<workId>.json, plus state/archive/ and the
|
|
382
|
+
// reverse index's own state/index/ shards. Only those two subdirectories
|
|
383
|
+
// exist, and index/ holds `{ resourceUri: workItemKey }` maps that are
|
|
384
|
+
// not projections at all — skip it rather than parse-and-discard.
|
|
385
|
+
const visit = async (dir, isArchive) => {
|
|
386
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch((error) => {
|
|
387
|
+
if (isMissingPathError(error)) {
|
|
388
|
+
return [];
|
|
389
|
+
}
|
|
390
|
+
throw error;
|
|
391
|
+
});
|
|
392
|
+
for (const entry of entries) {
|
|
393
|
+
if (entry.isDirectory()) {
|
|
394
|
+
if (entry.name === 'index') {
|
|
264
395
|
continue;
|
|
265
396
|
}
|
|
266
|
-
if (
|
|
267
|
-
|
|
268
|
-
shouldArchiveIssueState(record, archiveOptions)) {
|
|
269
|
-
const archivePath = paths.archivedWorkItemStateFile(record.workItemKey);
|
|
270
|
-
await mkdir(dirname(archivePath), { recursive: true });
|
|
271
|
-
await rename(file, archivePath).catch(() => undefined);
|
|
272
|
-
continue;
|
|
397
|
+
if (entry.name === 'archive' && includeArchived) {
|
|
398
|
+
await visit(join(dir, entry.name), true);
|
|
273
399
|
}
|
|
274
|
-
|
|
400
|
+
continue;
|
|
275
401
|
}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
402
|
+
if (!entry.isFile() || !entry.name.endsWith('.json')) {
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
const file = join(dir, entry.name);
|
|
406
|
+
const record = await readIssueStateFile(file);
|
|
407
|
+
if (record === null)
|
|
408
|
+
continue;
|
|
409
|
+
if (archiveOptions !== null &&
|
|
410
|
+
!isArchive &&
|
|
411
|
+
shouldArchiveIssueState(record, archiveOptions)) {
|
|
412
|
+
const archivePath = paths.archivedWorkItemStateFile(record.workItemKey);
|
|
413
|
+
await mkdir(dirname(archivePath), { recursive: true });
|
|
414
|
+
await rename(file, archivePath).catch(() => undefined);
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
items.push(record);
|
|
281
418
|
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
419
|
+
};
|
|
420
|
+
await visit(stateRoot, false);
|
|
421
|
+
const byWorkItemKey = new Map();
|
|
422
|
+
for (const item of items) {
|
|
423
|
+
byWorkItemKey.set(item.workItemKey, item);
|
|
286
424
|
}
|
|
425
|
+
return [...byWorkItemKey.values()].sort((left, right) => left.workItemKey.localeCompare(right.workItemKey));
|
|
287
426
|
},
|
|
288
427
|
async listEventEnvelopes() {
|
|
289
428
|
const eventsRoot = join(paths.dataRoot, 'events');
|
|
@@ -295,14 +434,22 @@ export function createStateStore({ wakeRoot }) {
|
|
|
295
434
|
}
|
|
296
435
|
return envelopes;
|
|
297
436
|
}
|
|
298
|
-
catch {
|
|
299
|
-
|
|
437
|
+
catch (error) {
|
|
438
|
+
if (isMissingPathError(error)) {
|
|
439
|
+
return [];
|
|
440
|
+
}
|
|
441
|
+
throw error;
|
|
300
442
|
}
|
|
301
443
|
},
|
|
302
444
|
async listRecentEventEnvelopes(filter = {}) {
|
|
303
445
|
const limit = filter.limit ?? 200;
|
|
304
446
|
const eventsRoot = join(paths.dataRoot, 'events');
|
|
305
|
-
const files = (await readdir(eventsRoot).catch(() =>
|
|
447
|
+
const files = (await readdir(eventsRoot).catch((error) => {
|
|
448
|
+
if (isMissingPathError(error)) {
|
|
449
|
+
return [];
|
|
450
|
+
}
|
|
451
|
+
throw error;
|
|
452
|
+
}))
|
|
306
453
|
.filter((file) => file.endsWith('.jsonl'))
|
|
307
454
|
.sort()
|
|
308
455
|
.reverse();
|
|
@@ -360,5 +507,17 @@ export function createStateStore({ wakeRoot }) {
|
|
|
360
507
|
async readEventLog(date) {
|
|
361
508
|
return readFile(paths.eventFile(date), 'utf8');
|
|
362
509
|
},
|
|
510
|
+
async validateStateHealth() {
|
|
511
|
+
const issues = [
|
|
512
|
+
...(await collectEventIssues(paths)),
|
|
513
|
+
...(await collectIssueStateIssues(paths)),
|
|
514
|
+
...(await validateResourceIndex(paths)),
|
|
515
|
+
];
|
|
516
|
+
return { healthy: issues.length === 0, issues };
|
|
517
|
+
},
|
|
518
|
+
async assertStateHealthy() {
|
|
519
|
+
const report = await this.validateStateHealth();
|
|
520
|
+
throwIfUnhealthy(report.issues);
|
|
521
|
+
},
|
|
363
522
|
};
|
|
364
523
|
}
|
|
@@ -66,6 +66,14 @@ export function createGitHubClient(token) {
|
|
|
66
66
|
}),
|
|
67
67
|
});
|
|
68
68
|
},
|
|
69
|
+
async getIssue(owner, repo, issueNumber) {
|
|
70
|
+
const { data } = await octokit.rest.issues.get({
|
|
71
|
+
owner,
|
|
72
|
+
repo,
|
|
73
|
+
issue_number: issueNumber,
|
|
74
|
+
});
|
|
75
|
+
return data;
|
|
76
|
+
},
|
|
69
77
|
async createComment(owner, repo, issueNumber, body) {
|
|
70
78
|
return octokit.rest.issues.createComment({
|
|
71
79
|
owner,
|
|
@@ -185,6 +185,9 @@ export async function readControlPlaneUiUrl(wakeRoot) {
|
|
|
185
185
|
return undefined;
|
|
186
186
|
}
|
|
187
187
|
}
|
|
188
|
+
function isGitHubNotFound(error) {
|
|
189
|
+
return error instanceof Error && error.status === 404;
|
|
190
|
+
}
|
|
188
191
|
export function formatGitHubError(error) {
|
|
189
192
|
if (error instanceof Error) {
|
|
190
193
|
const octokit = error;
|
|
@@ -301,6 +304,71 @@ export function createGitHubIssuesWorkSource(deps) {
|
|
|
301
304
|
return deps.stateStore.readIssueState(workItemKey);
|
|
302
305
|
}
|
|
303
306
|
return {
|
|
307
|
+
async refreshForDispatch(input) {
|
|
308
|
+
const repoRef = input.projection.issue.repo;
|
|
309
|
+
if (deps.client.getIssue === undefined) {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
if (!deps.config.sources.github.repos.includes(repoRef)) {
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
const [owner, repo] = repoRef.split('/');
|
|
316
|
+
if (owner === undefined || repo === undefined) {
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
const ingestedAt = deps.now().toISOString();
|
|
320
|
+
let issue;
|
|
321
|
+
try {
|
|
322
|
+
issue = await deps.client.getIssue(owner, repo, input.projection.issue.number);
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
if (isGitHubNotFound(error)) {
|
|
326
|
+
return {
|
|
327
|
+
events: [],
|
|
328
|
+
sourceRevision: `github:issue:${repoRef}#${input.projection.issue.number}@missing`,
|
|
329
|
+
sourceExists: false,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
throw error;
|
|
333
|
+
}
|
|
334
|
+
const events = [];
|
|
335
|
+
if (input.projection.issue.updatedAt !== issue.updated_at) {
|
|
336
|
+
events.push(normalizeTicketUpsert({
|
|
337
|
+
repo: repoRef,
|
|
338
|
+
issue,
|
|
339
|
+
ingestedAt,
|
|
340
|
+
expectedEcho: isExpectedLabelEcho(issue, input.projection),
|
|
341
|
+
}));
|
|
342
|
+
}
|
|
343
|
+
const comments = await deps.client.listComments(owner, repo, input.projection.issue.number, deps.config.sources.github.polling.commentPageSize);
|
|
344
|
+
let latestCommentRevision = '';
|
|
345
|
+
for (const comment of comments) {
|
|
346
|
+
if (comment.updated_at > latestCommentRevision) {
|
|
347
|
+
latestCommentRevision = comment.updated_at;
|
|
348
|
+
}
|
|
349
|
+
const known = input.projection.comments.find((entry) => entry.id === String(comment.id));
|
|
350
|
+
if (known?.updatedAt === comment.updated_at) {
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
if (input.projection.wake.expectedEcho.commentIds.includes(String(comment.id))) {
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
events.push(normalizeTicketCommentEvent({
|
|
357
|
+
repo: repoRef,
|
|
358
|
+
issueNumber: input.projection.issue.number,
|
|
359
|
+
comment,
|
|
360
|
+
ingestedAt,
|
|
361
|
+
...(deps.selfLogin === undefined ? {} : { selfLogin: deps.selfLogin }),
|
|
362
|
+
...(known?.updatedAt === undefined ? {} : { existingUpdatedAt: known.updatedAt }),
|
|
363
|
+
}));
|
|
364
|
+
}
|
|
365
|
+
return {
|
|
366
|
+
events,
|
|
367
|
+
sourceRevision: latestCommentRevision.length > 0
|
|
368
|
+
? `github:issue:${repoRef}#${issue.number}@${issue.updated_at};comments@${latestCommentRevision}`
|
|
369
|
+
: `github:issue:${repoRef}#${issue.number}@${issue.updated_at}`,
|
|
370
|
+
};
|
|
371
|
+
},
|
|
304
372
|
async pollEvents(_input) {
|
|
305
373
|
const ingestedAt = deps.now().toISOString();
|
|
306
374
|
const events = [];
|
|
@@ -4,6 +4,18 @@ export function createWorkSourceFanIn(sources) {
|
|
|
4
4
|
const batches = await Promise.all(sources.map((source) => source.pollEvents(input)));
|
|
5
5
|
return batches.flat();
|
|
6
6
|
},
|
|
7
|
+
async refreshForDispatch(input) {
|
|
8
|
+
for (const source of sources) {
|
|
9
|
+
if (source.refreshForDispatch === undefined) {
|
|
10
|
+
continue;
|
|
11
|
+
}
|
|
12
|
+
const result = await source.refreshForDispatch(input);
|
|
13
|
+
if (result !== null) {
|
|
14
|
+
return result;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
},
|
|
7
19
|
};
|
|
8
20
|
}
|
|
9
21
|
function intentKind(event) {
|
|
@@ -21,6 +21,15 @@ function latestHumanCommentId(candidate) {
|
|
|
21
21
|
const human = candidate.comments.filter((c) => !c.isBotAuthored);
|
|
22
22
|
return human.at(-1)?.id;
|
|
23
23
|
}
|
|
24
|
+
function projectedSourceRevision(projection) {
|
|
25
|
+
const latestCommentUpdatedAt = projection.comments
|
|
26
|
+
.map((comment) => comment.updatedAt)
|
|
27
|
+
.sort()
|
|
28
|
+
.at(-1);
|
|
29
|
+
return latestCommentUpdatedAt === undefined
|
|
30
|
+
? `${projection.issue.repo}#${projection.issue.number}@${projection.issue.updatedAt}`
|
|
31
|
+
: `${projection.issue.repo}#${projection.issue.number}@${projection.issue.updatedAt};comments@${latestCommentUpdatedAt}`;
|
|
32
|
+
}
|
|
24
33
|
function isLateralReadOnlyAction(action, config) {
|
|
25
34
|
return isCustomCommandAction(action, config);
|
|
26
35
|
}
|
|
@@ -221,6 +230,18 @@ export function createTickRunner(deps) {
|
|
|
221
230
|
processStartedAt: record.workerProcessStartedAt,
|
|
222
231
|
}));
|
|
223
232
|
}
|
|
233
|
+
async function hasSchedulerCapacity(now) {
|
|
234
|
+
const runRecords = await deps.stateStore.listRunRecords();
|
|
235
|
+
for (const record of runRecords) {
|
|
236
|
+
if (record.status !== 'running') {
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (await isRunningRecordActive(record, now)) {
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
224
245
|
async function parkConfigDriftedProjections(projections) {
|
|
225
246
|
let parked = false;
|
|
226
247
|
for (const projection of projections) {
|
|
@@ -314,10 +335,31 @@ export function createTickRunner(deps) {
|
|
|
314
335
|
if (await parkConfigDriftedProjections(projections)) {
|
|
315
336
|
return { status: 'processed' };
|
|
316
337
|
}
|
|
317
|
-
|
|
338
|
+
let candidate = projections.find((issue) => policy.resolveNextEligibleAction(issue, deps.config) !== null);
|
|
318
339
|
if (candidate === undefined) {
|
|
319
340
|
return { status: 'idle' };
|
|
320
341
|
}
|
|
342
|
+
let sourceRevision = projectedSourceRevision(candidate);
|
|
343
|
+
let refresh;
|
|
344
|
+
try {
|
|
345
|
+
refresh = await deps.workSource.refreshForDispatch?.({ projection: candidate });
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
return { status: 'idle' };
|
|
349
|
+
}
|
|
350
|
+
if (refresh !== undefined && refresh !== null) {
|
|
351
|
+
if (refresh.sourceExists === false) {
|
|
352
|
+
return { status: 'idle' };
|
|
353
|
+
}
|
|
354
|
+
sourceRevision = refresh.sourceRevision;
|
|
355
|
+
if (refresh.events.length > 0) {
|
|
356
|
+
await ingestInboundEvents(refresh.events);
|
|
357
|
+
candidate = (await deps.stateStore.readIssueState(candidate.workItemKey)) ?? candidate;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (policy.resolveNextEligibleAction(candidate, deps.config) === null) {
|
|
361
|
+
return { status: 'idle' };
|
|
362
|
+
}
|
|
321
363
|
const workflow = workflowForProjection(candidate, deps.config);
|
|
322
364
|
if (workflow === null) {
|
|
323
365
|
return { status: 'idle' };
|
|
@@ -444,6 +486,9 @@ export function createTickRunner(deps) {
|
|
|
444
486
|
if (routing === null) {
|
|
445
487
|
return { status: 'idle' };
|
|
446
488
|
}
|
|
489
|
+
if (!(await hasSchedulerCapacity(deps.clock.now()))) {
|
|
490
|
+
return { status: 'idle' };
|
|
491
|
+
}
|
|
447
492
|
const runId = `run-${candidate.issue.number}-${deps.clock.now().getTime()}`;
|
|
448
493
|
const lease = createRunLease({ clock: deps.clock, ownerInstanceId });
|
|
449
494
|
const workerIdentity = currentProcessIdentity();
|
|
@@ -461,6 +506,9 @@ export function createTickRunner(deps) {
|
|
|
461
506
|
lease,
|
|
462
507
|
workerPid: workerIdentity.pid,
|
|
463
508
|
workerProcessStartedAt: workerIdentity.processStartedAt,
|
|
509
|
+
metadata: {
|
|
510
|
+
sourceRevision,
|
|
511
|
+
},
|
|
464
512
|
};
|
|
465
513
|
await deps.stateStore.writeRunRecord(runningRecord);
|
|
466
514
|
async function transitionRunLifecycle(lifecycle) {
|
|
@@ -489,6 +537,7 @@ export function createTickRunner(deps) {
|
|
|
489
537
|
action,
|
|
490
538
|
priorStage: candidate.wake.stage,
|
|
491
539
|
claimedStage,
|
|
540
|
+
sourceRevision,
|
|
492
541
|
},
|
|
493
542
|
});
|
|
494
543
|
await deps.stateStore.appendEventEnvelope(claimedEvent);
|
|
@@ -768,6 +817,7 @@ export function createTickRunner(deps) {
|
|
|
768
817
|
executionOutcome: 'PROCESS_FAILED',
|
|
769
818
|
summary: err instanceof Error ? err.message : String(err),
|
|
770
819
|
metadata: {
|
|
820
|
+
...failedRecord.metadata,
|
|
771
821
|
failureClass: 'infra',
|
|
772
822
|
},
|
|
773
823
|
});
|
|
@@ -857,6 +907,7 @@ export function createTickRunner(deps) {
|
|
|
857
907
|
runIntakeTick,
|
|
858
908
|
runRunnerTick,
|
|
859
909
|
async runTick() {
|
|
910
|
+
await deps.stateStore.assertStateHealthy();
|
|
860
911
|
const intakeResult = await runIntakeTick();
|
|
861
912
|
if (intakeResult.status === 'locked') {
|
|
862
913
|
return intakeResult;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export class StateHealthError extends Error {
|
|
2
|
+
issues;
|
|
3
|
+
constructor(issues) {
|
|
4
|
+
super(formatStateHealthErrorMessage(issues));
|
|
5
|
+
this.name = 'StateHealthError';
|
|
6
|
+
this.issues = issues;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export function isNodeErrnoException(error) {
|
|
10
|
+
return error instanceof Error && 'code' in error;
|
|
11
|
+
}
|
|
12
|
+
export function isMissingPathError(error) {
|
|
13
|
+
return isNodeErrnoException(error) && error.code === 'ENOENT';
|
|
14
|
+
}
|
|
15
|
+
export function stateHealthIssue(input) {
|
|
16
|
+
return input;
|
|
17
|
+
}
|
|
18
|
+
export function throwIfUnhealthy(issues) {
|
|
19
|
+
if (issues.length > 0) {
|
|
20
|
+
throw new StateHealthError(issues);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function formatStateHealthErrorMessage(issues) {
|
|
24
|
+
if (issues.length === 0) {
|
|
25
|
+
return 'Wake control-plane state is healthy';
|
|
26
|
+
}
|
|
27
|
+
const first = issues[0];
|
|
28
|
+
const suffix = issues.length === 1 ? '' : ` and ${issues.length - 1} more issue(s)`;
|
|
29
|
+
return `Wake control-plane state is unhealthy: ${first.surface} ${first.kind} at ${first.path}: ${first.message}${suffix}`;
|
|
30
|
+
}
|
package/dist/src/main.js
CHANGED
|
@@ -36,6 +36,7 @@ import { systemClock } from './lib/clock.js';
|
|
|
36
36
|
import { createDetachedProcessLogSink } from './lib/detached-process-logging.js';
|
|
37
37
|
import { readJsonFile } from './lib/json-file.js';
|
|
38
38
|
import { resolveLogMaxBytes, resolveLogRotateCheckIntervalMs } from './lib/log-rotation.js';
|
|
39
|
+
import { StateHealthError } from './lib/state-health.js';
|
|
39
40
|
import { configuredTicketSource } from './domain/sources.js';
|
|
40
41
|
import { wakeVersion } from './version.js';
|
|
41
42
|
function commandArgsBeforeTerminator(args) {
|
|
@@ -578,6 +579,7 @@ export async function buildRuntime(args) {
|
|
|
578
579
|
}
|
|
579
580
|
async function runTick(args) {
|
|
580
581
|
const runtime = await buildRuntime(args);
|
|
582
|
+
await runtime.stateStore.assertStateHealthy();
|
|
581
583
|
const outcome = await runtime.tickRunner.runTick();
|
|
582
584
|
console.log(JSON.stringify(outcome, null, 2));
|
|
583
585
|
if (outcome.status !== 'processed' ||
|
|
@@ -593,6 +595,7 @@ async function runTick(args) {
|
|
|
593
595
|
}
|
|
594
596
|
async function runStart(args) {
|
|
595
597
|
const runtime = await buildRuntime(args);
|
|
598
|
+
await runtime.stateStore.assertStateHealthy();
|
|
596
599
|
const runnerOverride = readFlagBeforeCommandTerminator('--runner', args);
|
|
597
600
|
await runStartupPreflight(runtime.config, {
|
|
598
601
|
...(runnerOverride === undefined ? {} : { runnerOverride }),
|
|
@@ -625,6 +628,26 @@ async function runStart(args) {
|
|
|
625
628
|
process.on('SIGTERM', stop);
|
|
626
629
|
await controlPlane.start();
|
|
627
630
|
}
|
|
631
|
+
function printStateHealthReport(report) {
|
|
632
|
+
if (report.healthy) {
|
|
633
|
+
console.log('wake validate-state: control-plane state is healthy');
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
console.error('wake validate-state: control-plane state is unhealthy');
|
|
637
|
+
for (const issue of report.issues) {
|
|
638
|
+
console.error(` - ${issue.surface} ${issue.kind}: ${issue.path}: ${issue.message}`);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
async function runValidateState(args) {
|
|
642
|
+
const wakeRoot = resolve(readFlagBeforeCommandTerminator('--wake-root', args) ?? process.cwd());
|
|
643
|
+
const stateStore = createStateStore({ wakeRoot });
|
|
644
|
+
await stateStore.ensureWakeRoot();
|
|
645
|
+
const report = await stateStore.validateStateHealth();
|
|
646
|
+
printStateHealthReport(report);
|
|
647
|
+
if (!report.healthy) {
|
|
648
|
+
process.exitCode = 1;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
628
651
|
function resolveSmokEntry(config, kind) {
|
|
629
652
|
for (const entry of Object.values(config.runners)) {
|
|
630
653
|
if (entry.kind === 'fake') {
|
|
@@ -747,6 +770,7 @@ export function printUsage(stream) {
|
|
|
747
770
|
' wake sandbox <subcommand> Build/run/manage the Docker sandbox (build, up, update, down, stop, self-update, setup, exec, logs, resume)',
|
|
748
771
|
' wake tick Run one control-plane tick',
|
|
749
772
|
' wake start Run the resident loop',
|
|
773
|
+
' wake validate-state Validate .wake/ control-plane state health',
|
|
750
774
|
' wake stop Stop the sandbox container gracefully',
|
|
751
775
|
' wake smoke Smoke-test the configured runner',
|
|
752
776
|
' wake ui Run the control-plane UI server',
|
|
@@ -759,14 +783,14 @@ export function printUsage(stream) {
|
|
|
759
783
|
' 1. wake init ./wake-home',
|
|
760
784
|
' 2. cd wake-home && wake start',
|
|
761
785
|
'',
|
|
762
|
-
'Runtime commands (tick/start/ui/smoke/correlate) auto-delegate into the sandbox',
|
|
786
|
+
'Runtime commands (tick/start/ui/smoke/correlate/validate-state) auto-delegate into the sandbox',
|
|
763
787
|
'when docker/Dockerfile exists at --wake-root (i.e. after `wake sandbox build`),',
|
|
764
788
|
'defaulting --wake-root to the current directory. Pass --no-sandbox to run',
|
|
765
789
|
'directly on the host instead.',
|
|
766
790
|
'',
|
|
767
791
|
].join('\n'));
|
|
768
792
|
}
|
|
769
|
-
const runtimeCommands = new Set(['tick', 'start', 'ui', 'smoke', 'correlate']);
|
|
793
|
+
const runtimeCommands = new Set(['tick', 'start', 'ui', 'smoke', 'correlate', 'validate-state']);
|
|
770
794
|
export async function dispatchMainCommand(input) {
|
|
771
795
|
const command = input.args[0] ?? 'help';
|
|
772
796
|
if (command === '--version' || command === '-v') {
|
|
@@ -825,9 +849,15 @@ export async function dispatchMainCommand(input) {
|
|
|
825
849
|
else if (command === 'smoke') {
|
|
826
850
|
await input.runSmoke(hostArgs);
|
|
827
851
|
}
|
|
828
|
-
else {
|
|
852
|
+
else if (command === 'correlate') {
|
|
829
853
|
await input.runCorrelate(hostArgs);
|
|
830
854
|
}
|
|
855
|
+
else {
|
|
856
|
+
if (input.runValidateState === undefined) {
|
|
857
|
+
throw new CliUsageError('validate-state command is not available in this runtime');
|
|
858
|
+
}
|
|
859
|
+
await input.runValidateState(hostArgs);
|
|
860
|
+
}
|
|
831
861
|
return;
|
|
832
862
|
}
|
|
833
863
|
printUsage(process.stderr);
|
|
@@ -953,6 +983,7 @@ async function main() {
|
|
|
953
983
|
runSmoke,
|
|
954
984
|
runUi,
|
|
955
985
|
runCorrelate,
|
|
986
|
+
runValidateState,
|
|
956
987
|
execIntoSandbox: async (commandArgs) => {
|
|
957
988
|
const withoutBypassFlag = commandArgs.filter((arg) => arg !== '--no-sandbox');
|
|
958
989
|
const wakeRootIndex = withoutBypassFlag.indexOf('--wake-root');
|
|
@@ -975,6 +1006,13 @@ main().catch((error) => {
|
|
|
975
1006
|
if (error instanceof CliUsageError) {
|
|
976
1007
|
console.error(error.message);
|
|
977
1008
|
}
|
|
1009
|
+
else if (error instanceof StateHealthError) {
|
|
1010
|
+
console.error(error.message);
|
|
1011
|
+
for (const issue of error.issues) {
|
|
1012
|
+
console.error(` - ${issue.surface} ${issue.kind}: ${issue.path}: ${issue.message}`);
|
|
1013
|
+
}
|
|
1014
|
+
process.exitCode = 1;
|
|
1015
|
+
}
|
|
978
1016
|
else {
|
|
979
1017
|
console.error(error);
|
|
980
1018
|
}
|
package/dist/src/version.js
CHANGED