@atolis-hq/wake 0.2.32 → 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
  }
@@ -907,6 +907,7 @@ export function createTickRunner(deps) {
907
907
  runIntakeTick,
908
908
  runRunnerTick,
909
909
  async runTick() {
910
+ await deps.stateStore.assertStateHealthy();
910
911
  const intakeResult = await runIntakeTick();
911
912
  if (intakeResult.status === 'locked') {
912
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 = "gd481d17";
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.32",
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": {