@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.
@@ -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
- return {};
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
- return null;
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
- const parsed = JSON.parse(trimmed);
33
- if (parsed !== null &&
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
- return [];
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
- return null;
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
- return null;
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
- return null;
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
- try {
234
- const items = [];
235
- const includeArchived = options.includeArchived ?? false;
236
- const archiveOptions = options.archiveFreshnessDays === undefined
237
- ? null
238
- : {
239
- archiveFreshnessDays: options.archiveFreshnessDays,
240
- now: options.now ?? new Date(),
241
- };
242
- // state/ is flat: state/<workId>.json, plus state/archive/ and the
243
- // reverse index's own state/index/ shards. Only those two subdirectories
244
- // exist, and index/ holds `{ resourceUri: workItemKey }` maps that are
245
- // not projections at all skip it rather than parse-and-discard.
246
- const visit = async (dir, isArchive) => {
247
- const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
248
- for (const entry of entries) {
249
- if (entry.isDirectory()) {
250
- if (entry.name === 'index') {
251
- continue;
252
- }
253
- if (entry.name === 'archive' && includeArchived) {
254
- await visit(join(dir, entry.name), true);
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 (archiveOptions !== null &&
267
- !isArchive &&
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
- items.push(record);
400
+ continue;
275
401
  }
276
- };
277
- await visit(stateRoot, false);
278
- const byWorkItemKey = new Map();
279
- for (const item of items) {
280
- byWorkItemKey.set(item.workItemKey, item);
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
- return [...byWorkItemKey.values()].sort((left, right) => left.workItemKey.localeCompare(right.workItemKey));
283
- }
284
- catch {
285
- return [];
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
- return [];
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
- const candidate = projections.find((issue) => policy.resolveNextEligibleAction(issue, deps.config) !== null);
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
  }
@@ -124,4 +124,4 @@ export function resolveWakeVersion(options = {}) {
124
124
  }
125
125
  return '0.1.0-dev';
126
126
  }
127
- export const wakeVersion = "g18ca5a7";
127
+ export const wakeVersion = "g03d77d6";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atolis-hq/wake",
3
- "version": "0.2.31",
3
+ "version": "0.2.33",
4
4
  "description": "Local autonomous agent control plane for software development",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {