@atolis-hq/wake 0.1.0

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.
Files changed (75) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +140 -0
  3. package/dist/src/adapters/claude/claude-runner.js +388 -0
  4. package/dist/src/adapters/claude/prompt-templates.js +57 -0
  5. package/dist/src/adapters/codex/codex-runner.js +391 -0
  6. package/dist/src/adapters/cursor/cursor-runner.js +352 -0
  7. package/dist/src/adapters/docker/docker-cli.js +97 -0
  8. package/dist/src/adapters/fake/fake-artifact-verifier.js +14 -0
  9. package/dist/src/adapters/fake/fake-github-pull-request-activity-source.js +73 -0
  10. package/dist/src/adapters/fake/fake-resource-index.js +21 -0
  11. package/dist/src/adapters/fake/fake-runner.js +22 -0
  12. package/dist/src/adapters/fake/fake-ticketing-system.js +166 -0
  13. package/dist/src/adapters/fake/fake-workspace-manager.js +27 -0
  14. package/dist/src/adapters/fs/resource-index.js +84 -0
  15. package/dist/src/adapters/fs/self-update-ledger.js +17 -0
  16. package/dist/src/adapters/fs/state-store.js +364 -0
  17. package/dist/src/adapters/git/git-workspace-manager.js +168 -0
  18. package/dist/src/adapters/github/github-artifact-verifier.js +38 -0
  19. package/dist/src/adapters/github/github-auth.js +16 -0
  20. package/dist/src/adapters/github/github-client.js +100 -0
  21. package/dist/src/adapters/github/github-issues-work-source.js +410 -0
  22. package/dist/src/adapters/github/github-pull-request-activity-source.js +263 -0
  23. package/dist/src/adapters/http/ui-assets.js +302 -0
  24. package/dist/src/adapters/http/ui-data.js +389 -0
  25. package/dist/src/adapters/http/ui-server.js +151 -0
  26. package/dist/src/adapters/runner/cli-command.js +43 -0
  27. package/dist/src/adapters/runner/prompt-templates.js +76 -0
  28. package/dist/src/adapters/runner/runner-cli-adapter.js +112 -0
  29. package/dist/src/adapters/runner/runner-registry.js +141 -0
  30. package/dist/src/adapters/runner/stage-prompt.js +256 -0
  31. package/dist/src/adapters/runner/transcripts.js +25 -0
  32. package/dist/src/cli/correlate-command.js +62 -0
  33. package/dist/src/cli/init-command.js +11 -0
  34. package/dist/src/cli/locks-command.js +19 -0
  35. package/dist/src/cli/sandbox-command.js +191 -0
  36. package/dist/src/cli/sandbox-logging.js +30 -0
  37. package/dist/src/cli/sandbox-resume.js +77 -0
  38. package/dist/src/cli/scaffold-assets.js +173 -0
  39. package/dist/src/cli/self-update-command.js +158 -0
  40. package/dist/src/cli/startup-preflight.js +127 -0
  41. package/dist/src/cli/stop-command.js +42 -0
  42. package/dist/src/cli/ui-command.js +27 -0
  43. package/dist/src/config/defaults.js +11 -0
  44. package/dist/src/config/load-config.js +26 -0
  45. package/dist/src/core/contracts.js +1 -0
  46. package/dist/src/core/control-plane.js +127 -0
  47. package/dist/src/core/lifecycle-service.js +8 -0
  48. package/dist/src/core/policy-engine.js +200 -0
  49. package/dist/src/core/projection-updater.js +438 -0
  50. package/dist/src/core/quota-backoff.js +69 -0
  51. package/dist/src/core/sink-router.js +85 -0
  52. package/dist/src/core/tick-runner.js +1347 -0
  53. package/dist/src/domain/branch-naming.js +14 -0
  54. package/dist/src/domain/resource-uri.js +38 -0
  55. package/dist/src/domain/runner-routing.js +108 -0
  56. package/dist/src/domain/schema.js +685 -0
  57. package/dist/src/domain/sources.js +16 -0
  58. package/dist/src/domain/stages.js +39 -0
  59. package/dist/src/domain/types.js +1 -0
  60. package/dist/src/domain/workflows.js +83 -0
  61. package/dist/src/lib/clock.js +5 -0
  62. package/dist/src/lib/event-log.js +21 -0
  63. package/dist/src/lib/json-file.js +23 -0
  64. package/dist/src/lib/lock.js +118 -0
  65. package/dist/src/lib/paths.js +44 -0
  66. package/dist/src/lib/work-id.js +19 -0
  67. package/dist/src/main.js +523 -0
  68. package/dist/src/version.js +1 -0
  69. package/docker/Dockerfile +54 -0
  70. package/docker/entrypoint.sh +75 -0
  71. package/docker/log-command.sh +107 -0
  72. package/docker/setup.sh +72 -0
  73. package/package.json +52 -0
  74. package/prompts/implement.md +49 -0
  75. package/prompts/refine.md +44 -0
@@ -0,0 +1,166 @@
1
+ import { access } from 'node:fs/promises';
2
+ import { buildResourceUri } from '../../domain/resource-uri.js';
3
+ import { createEventEnvelope, createUnkeyedEventEnvelope } from '../../lib/event-log.js';
4
+ import { readJsonFile } from '../../lib/json-file.js';
5
+ const fakeSource = 'fake-ticketing';
6
+ function normalizeIssueEvents(issue, nowIso) {
7
+ const sourceUrl = `https://example.test/${issue.repo}/issues/${issue.number}`;
8
+ // The source names the *resource* it saw, never the work item: the resolver
9
+ // in tick-runner turns this into the canonical workItemKey (spec D1).
10
+ const resourceUri = buildResourceUri(fakeSource, 'issue', `${issue.repo}#${issue.number}`);
11
+ return [
12
+ createUnkeyedEventEnvelope({
13
+ eventId: `fake-issue-${issue.repo}-${issue.number}`,
14
+ streamScope: 'global-intake',
15
+ direction: 'inbound',
16
+ sourceSystem: 'fake-ticketing',
17
+ sourceEventType: 'fake.issue.upsert',
18
+ sourceRefs: {
19
+ repo: issue.repo,
20
+ issueNumber: issue.number,
21
+ sourceUrl,
22
+ resourceUri,
23
+ },
24
+ occurredAt: nowIso,
25
+ ingestedAt: nowIso,
26
+ trigger: 'immediate',
27
+ payload: {
28
+ issue: {
29
+ repo: issue.repo,
30
+ number: issue.number,
31
+ title: issue.title,
32
+ body: issue.body,
33
+ labels: issue.labels,
34
+ assignees: [],
35
+ state: 'open',
36
+ url: sourceUrl,
37
+ createdAt: nowIso,
38
+ updatedAt: nowIso,
39
+ },
40
+ },
41
+ raw: {
42
+ title: issue.title,
43
+ body: issue.body,
44
+ labels: issue.labels,
45
+ },
46
+ }),
47
+ ...issue.comments.map((comment, index) => createUnkeyedEventEnvelope({
48
+ eventId: `fake-comment-${issue.repo}-${issue.number}-${comment.id}-${index}`,
49
+ streamScope: 'work-item',
50
+ direction: 'inbound',
51
+ sourceSystem: 'fake-ticketing',
52
+ sourceEventType: 'fake.issue.comment.created',
53
+ sourceRefs: {
54
+ repo: issue.repo,
55
+ issueNumber: issue.number,
56
+ commentId: comment.id,
57
+ sourceUrl,
58
+ resourceUri,
59
+ },
60
+ occurredAt: nowIso,
61
+ ingestedAt: nowIso,
62
+ trigger: 'context-only',
63
+ payload: {
64
+ comment: {
65
+ ...comment,
66
+ createdAt: nowIso,
67
+ updatedAt: nowIso,
68
+ },
69
+ },
70
+ derivedHints: {
71
+ botAuthoredComment: false,
72
+ },
73
+ })),
74
+ ];
75
+ }
76
+ export function createFakeTicketingSystem(options) {
77
+ return {
78
+ async pollEvents(_input) {
79
+ const nowIso = (options.now ?? (() => new Date()))().toISOString();
80
+ return options.tickets.flatMap((issue) => normalizeIssueEvents(issue, nowIso));
81
+ },
82
+ async deliverIntent(input) {
83
+ const publishedAt = (options.now ?? (() => new Date()))().toISOString();
84
+ if (input.event.sourceEventType === 'wake.labels.requested') {
85
+ const ticket = options.tickets.find((issue) => issue.repo === input.event.sourceRefs.repo &&
86
+ issue.number === input.event.sourceRefs.issueNumber);
87
+ const currentLabels = ticket?.labels ?? [];
88
+ const statusLabel = typeof input.event.payload.statusLabel === 'string'
89
+ ? input.event.payload.statusLabel
90
+ : undefined;
91
+ const stageLabel = typeof input.event.payload.stageLabel === 'string'
92
+ ? input.event.payload.stageLabel
93
+ : undefined;
94
+ const labels = [
95
+ ...currentLabels.filter((label) => !label.startsWith('wake:status.') &&
96
+ !label.startsWith('wake:stage.')),
97
+ ...(statusLabel === undefined
98
+ ? currentLabels.filter((label) => label.startsWith('wake:status.'))
99
+ : [statusLabel]),
100
+ ...(stageLabel === undefined
101
+ ? currentLabels.filter((label) => label.startsWith('wake:stage.'))
102
+ : [stageLabel]),
103
+ ];
104
+ return [
105
+ createEventEnvelope({
106
+ eventId: `${input.event.eventId}-labels-updated`,
107
+ workItemKey: input.event.workItemKey,
108
+ streamScope: 'work-item',
109
+ direction: 'outbound',
110
+ sourceSystem: 'fake-ticketing',
111
+ sourceEventType: 'ticket.labels.updated',
112
+ sourceRefs: {
113
+ ...input.event.sourceRefs,
114
+ sink: 'fake-ticketing',
115
+ },
116
+ occurredAt: publishedAt,
117
+ ingestedAt: publishedAt,
118
+ trigger: 'context-only',
119
+ payload: {
120
+ intentEventId: input.event.eventId,
121
+ labels,
122
+ },
123
+ }),
124
+ ];
125
+ }
126
+ const commentId = `${input.event.eventId}-comment`;
127
+ return [
128
+ createEventEnvelope({
129
+ eventId: `${input.event.eventId}-delivery`,
130
+ workItemKey: input.event.workItemKey,
131
+ streamScope: 'work-item',
132
+ direction: 'outbound',
133
+ sourceSystem: 'fake-ticketing',
134
+ sourceEventType: 'ticket.reply.published',
135
+ sourceRefs: {
136
+ ...input.event.sourceRefs,
137
+ commentId,
138
+ sink: 'fake-ticketing',
139
+ },
140
+ occurredAt: publishedAt,
141
+ ingestedAt: publishedAt,
142
+ trigger: 'context-only',
143
+ payload: {
144
+ intentEventId: input.event.eventId,
145
+ kind: input.event.payload.kind,
146
+ body: input.event.payload.body,
147
+ },
148
+ }),
149
+ ];
150
+ },
151
+ };
152
+ }
153
+ export async function createFileBackedFakeTicketingSystem(options) {
154
+ try {
155
+ await access(options.fixturePath);
156
+ }
157
+ catch {
158
+ return createFakeTicketingSystem(options.now === undefined
159
+ ? { tickets: [] }
160
+ : { tickets: [], now: options.now });
161
+ }
162
+ const raw = await readJsonFile(options.fixturePath);
163
+ return createFakeTicketingSystem(options.now === undefined
164
+ ? { tickets: raw }
165
+ : { tickets: raw, now: options.now });
166
+ }
@@ -0,0 +1,27 @@
1
+ import { mkdir, rm } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ export function createFakeWorkspaceManager(root) {
4
+ return {
5
+ async prepareWorkspace({ workId, }) {
6
+ // Keyed on the work id, symmetrically with the real git-backed manager.
7
+ const workspacePath = join(root, workId);
8
+ await mkdir(workspacePath, { recursive: true });
9
+ return { workspacePath, mergeConflictDetected: false };
10
+ },
11
+ async prepareReadOnlyClone({ repo }) {
12
+ const workspacePath = join(root, repo.replace(/[\\/]/g, '__'), 'canonical');
13
+ await mkdir(workspacePath, { recursive: true });
14
+ return { workspacePath };
15
+ },
16
+ async cleanupWorkspace({ workspacePath }) {
17
+ // Retry on Windows EBUSY/EPERM (AV/indexer holding a brief handle) to
18
+ // match the real git-backed workspace manager's cleanup behavior.
19
+ await rm(workspacePath, {
20
+ recursive: true,
21
+ force: true,
22
+ maxRetries: 5,
23
+ retryDelay: 200,
24
+ });
25
+ },
26
+ };
27
+ }
@@ -0,0 +1,84 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { acquireFileLock } from '../../lib/lock.js';
3
+ import { readJsonFile, writeJsonFile } from '../../lib/json-file.js';
4
+ /**
5
+ * Addresses a resource uri to one of 256 shards.
6
+ *
7
+ * The uri is hashed as opaque bytes — this never splits on ':' and never
8
+ * inspects the locator, which is what lets core shard without violating
9
+ * "core compares uris for equality only" (ADR 0001 §1). Hashing also yields
10
+ * a filename-safe shard name for free; the raw uri contains '/', '#' and ':'
11
+ * and could not be a filename without escaping.
12
+ */
13
+ export function shardFor(resourceUri) {
14
+ return createHash('sha256').update(resourceUri, 'utf8').digest('hex').slice(0, 2);
15
+ }
16
+ async function readShard(file) {
17
+ try {
18
+ return await readJsonFile(file);
19
+ }
20
+ catch {
21
+ return {};
22
+ }
23
+ }
24
+ // acquireFileLock is a non-blocking try-lock: when contended it returns
25
+ // { acquired: false } immediately rather than waiting. register/retract have
26
+ // no sensible "try again later" behavior at their call sites — skipping the
27
+ // write here is exactly the silent-drop corruption this index exists to
28
+ // avoid — so withShardLock must retry until it actually holds the lock.
29
+ // staleAfterMs (60s) already reclaims a lock from a crashed holder, so this
30
+ // bound only needs to outlast normal contention between live processes, not
31
+ // a dead one.
32
+ const SHARD_LOCK_RETRY_INTERVAL_MS = 25;
33
+ const SHARD_LOCK_RETRY_BUDGET_MS = 10_000;
34
+ function delay(ms) {
35
+ return new Promise((resolve) => setTimeout(resolve, ms));
36
+ }
37
+ export function createResourceIndex({ paths }) {
38
+ async function withShardLock(shard, fn) {
39
+ const lockPath = `${paths.resourceIndexShardFile(shard)}.lock`;
40
+ const deadline = Date.now() + SHARD_LOCK_RETRY_BUDGET_MS;
41
+ for (;;) {
42
+ const lock = await acquireFileLock(lockPath, { staleAfterMs: 60_000 });
43
+ if (lock.acquired) {
44
+ try {
45
+ return await fn();
46
+ }
47
+ finally {
48
+ await lock.release();
49
+ }
50
+ }
51
+ if (Date.now() >= deadline) {
52
+ throw new Error(`resource-index: timed out after ${SHARD_LOCK_RETRY_BUDGET_MS}ms waiting for lock on shard '${shard}' (${lockPath})`);
53
+ }
54
+ await delay(SHARD_LOCK_RETRY_INTERVAL_MS);
55
+ }
56
+ }
57
+ return {
58
+ async resolve(resourceUri) {
59
+ const shard = await readShard(paths.resourceIndexShardFile(shardFor(resourceUri)));
60
+ return shard[resourceUri];
61
+ },
62
+ async register(resourceUri, workItemKey) {
63
+ const shard = shardFor(resourceUri);
64
+ const file = paths.resourceIndexShardFile(shard);
65
+ await withShardLock(shard, async () => {
66
+ const contents = await readShard(file);
67
+ contents[resourceUri] = workItemKey;
68
+ await writeJsonFile(file, contents);
69
+ });
70
+ },
71
+ async retract(resourceUri) {
72
+ const shard = shardFor(resourceUri);
73
+ const file = paths.resourceIndexShardFile(shard);
74
+ await withShardLock(shard, async () => {
75
+ const contents = await readShard(file);
76
+ if (!(resourceUri in contents)) {
77
+ return;
78
+ }
79
+ delete contents[resourceUri];
80
+ await writeJsonFile(file, contents);
81
+ });
82
+ },
83
+ };
84
+ }
@@ -0,0 +1,17 @@
1
+ import { readJsonFile, writeJsonFile } from '../../lib/json-file.js';
2
+ const EMPTY_LEDGER = {
3
+ lastAppliedTag: null,
4
+ lastKnownGoodTag: null,
5
+ badTags: [],
6
+ };
7
+ export async function readSelfUpdateLedger(path) {
8
+ try {
9
+ return await readJsonFile(path);
10
+ }
11
+ catch {
12
+ return EMPTY_LEDGER;
13
+ }
14
+ }
15
+ export async function writeSelfUpdateLedger(path, ledger) {
16
+ await writeJsonFile(path, ledger);
17
+ }
@@ -0,0 +1,364 @@
1
+ import { access, appendFile, mkdir, readFile, readdir, rename } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ import { parseEventEnvelope, parseIssueStateRecord, parseLedger, parseRunRecord, parseSourceStateRecord, parseWakeConfig, } from '../../domain/schema.js';
4
+ import { isTerminalStage } from '../../domain/stages.js';
5
+ import { appendJsonLine, readJsonFile, writeJsonFile } from '../../lib/json-file.js';
6
+ import { createWakePaths } from '../../lib/paths.js';
7
+ async function readIssueStateFile(file) {
8
+ try {
9
+ return parseIssueStateRecord(await readJsonFile(file));
10
+ }
11
+ catch {
12
+ return null;
13
+ }
14
+ }
15
+ async function readRunRecordFile(file) {
16
+ try {
17
+ return parseRunRecord(await readJsonFile(file));
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
23
+ async function readEventFile(file) {
24
+ try {
25
+ const raw = await readFile(file, 'utf8');
26
+ const envelopes = [];
27
+ for (const line of raw.split('\n')) {
28
+ const trimmed = line.trim();
29
+ if (trimmed.length === 0) {
30
+ continue;
31
+ }
32
+ const parsed = JSON.parse(trimmed);
33
+ if (parsed !== null &&
34
+ typeof parsed === 'object' &&
35
+ 'eventId' in parsed &&
36
+ 'sourceEventType' in parsed) {
37
+ envelopes.push(parseEventEnvelope(parsed));
38
+ }
39
+ }
40
+ return envelopes;
41
+ }
42
+ catch {
43
+ return [];
44
+ }
45
+ }
46
+ function issueArchiveAgeDate(item) {
47
+ const stageChangedAt = item.wake.stageHistory.at(-1)?.changedAt;
48
+ return [stageChangedAt, item.wake.syncedAt, item.issue.updatedAt]
49
+ .filter((value) => value !== undefined)
50
+ .sort()
51
+ .at(-1) ?? item.wake.syncedAt;
52
+ }
53
+ function shouldArchiveIssueState(item, options) {
54
+ if (!isTerminalStage(item.wake.stage) && item.issue.state !== 'closed') {
55
+ return false;
56
+ }
57
+ const ageMs = options.now.getTime() - Date.parse(issueArchiveAgeDate(item));
58
+ return Number.isFinite(ageMs) && ageMs > options.archiveFreshnessDays * 24 * 60 * 60 * 1000;
59
+ }
60
+ export async function listRunRecords(wakeRoot) {
61
+ const runsRoot = join(wakeRoot, 'runs');
62
+ const recordsById = new Map();
63
+ try {
64
+ const files = (await readdir(runsRoot))
65
+ .filter((file) => file.endsWith('.json'))
66
+ .sort();
67
+ for (const file of files) {
68
+ const record = await readRunRecordFile(join(runsRoot, file));
69
+ if (record !== null) {
70
+ recordsById.set(record.runId, record);
71
+ }
72
+ }
73
+ }
74
+ catch {
75
+ // The date-bucketed layout below may still exist.
76
+ }
77
+ try {
78
+ const byDateRoot = join(runsRoot, 'by-date');
79
+ const dateDirs = (await readdir(byDateRoot)).sort();
80
+ for (const dateDir of dateDirs) {
81
+ const files = (await readdir(join(byDateRoot, dateDir)).catch(() => []))
82
+ .filter((file) => file.endsWith('.json'))
83
+ .sort();
84
+ for (const file of files) {
85
+ const record = await readRunRecordFile(join(byDateRoot, dateDir, file));
86
+ if (record !== null) {
87
+ recordsById.set(record.runId, record);
88
+ }
89
+ }
90
+ }
91
+ }
92
+ catch {
93
+ // Old wake homes only have root-level run files.
94
+ }
95
+ return [...recordsById.values()].sort((left, right) => left.startedAt.localeCompare(right.startedAt));
96
+ }
97
+ async function listRunRecordsForDate(wakeRoot, date) {
98
+ const runsRoot = join(wakeRoot, 'runs');
99
+ const recordsById = new Map();
100
+ const bucketFiles = (await readdir(join(runsRoot, 'by-date', date)).catch(() => []))
101
+ .filter((file) => file.endsWith('.json'))
102
+ .sort();
103
+ for (const file of bucketFiles) {
104
+ const record = await readRunRecordFile(join(runsRoot, 'by-date', date, file));
105
+ if (record !== null) {
106
+ recordsById.set(record.runId, record);
107
+ }
108
+ }
109
+ if (recordsById.size === 0) {
110
+ const legacyFiles = (await readdir(runsRoot).catch(() => []))
111
+ .filter((file) => file.endsWith('.json'))
112
+ .sort();
113
+ for (const file of legacyFiles) {
114
+ const record = await readRunRecordFile(join(runsRoot, file));
115
+ if (record?.startedAt.slice(0, 10) === date) {
116
+ recordsById.set(record.runId, record);
117
+ }
118
+ }
119
+ }
120
+ return [...recordsById.values()].sort((left, right) => left.startedAt.localeCompare(right.startedAt));
121
+ }
122
+ async function listRecentRunRecords(wakeRoot, limit) {
123
+ const runsRoot = join(wakeRoot, 'runs');
124
+ const recordsById = new Map();
125
+ const dateDirs = (await readdir(join(runsRoot, 'by-date')).catch(() => []))
126
+ .sort()
127
+ .reverse();
128
+ for (const dateDir of dateDirs) {
129
+ const records = await listRunRecordsForDate(wakeRoot, dateDir);
130
+ for (const record of records.reverse()) {
131
+ recordsById.set(record.runId, record);
132
+ if (recordsById.size >= limit) {
133
+ return [...recordsById.values()].sort((left, right) => right.startedAt.localeCompare(left.startedAt));
134
+ }
135
+ }
136
+ }
137
+ if (recordsById.size === 0) {
138
+ return (await listRunRecords(wakeRoot))
139
+ .sort((left, right) => right.startedAt.localeCompare(left.startedAt))
140
+ .slice(0, limit);
141
+ }
142
+ return [...recordsById.values()].sort((left, right) => right.startedAt.localeCompare(left.startedAt));
143
+ }
144
+ export function createStateStore({ wakeRoot }) {
145
+ const paths = createWakePaths(wakeRoot);
146
+ return {
147
+ paths,
148
+ async ensureWakeRoot() {
149
+ await mkdir(wakeRoot, { recursive: true });
150
+ },
151
+ async writeConfig(record) {
152
+ const parsed = parseWakeConfig(record);
153
+ await writeJsonFile(paths.configFile, parsed);
154
+ return parsed;
155
+ },
156
+ async writeLedger(record) {
157
+ const parsed = parseLedger(record);
158
+ await writeJsonFile(paths.ledgerFile, parsed);
159
+ return parsed;
160
+ },
161
+ async readLedger() {
162
+ try {
163
+ return parseLedger(await readJsonFile(paths.ledgerFile));
164
+ }
165
+ catch {
166
+ return null;
167
+ }
168
+ },
169
+ async writeIssueState(record) {
170
+ const parsed = parseIssueStateRecord(record);
171
+ await writeJsonFile(paths.workItemStateFile(parsed.workItemKey), parsed);
172
+ return parsed;
173
+ },
174
+ async readIssueState(workId) {
175
+ return ((await readIssueStateFile(paths.workItemStateFile(workId))) ??
176
+ (await readIssueStateFile(paths.archivedWorkItemStateFile(workId))));
177
+ },
178
+ async writeRunRecord(record) {
179
+ const parsed = parseRunRecord(record);
180
+ await writeJsonFile(paths.runFile(parsed.runId), parsed);
181
+ await writeJsonFile(paths.runDateFile(parsed.startedAt.slice(0, 10), parsed.runId), parsed);
182
+ return parsed;
183
+ },
184
+ async writeSourceState(record) {
185
+ const parsed = parseSourceStateRecord(record);
186
+ await writeJsonFile(paths.sourceStateFile(parsed.source, parsed.key), parsed);
187
+ return parsed;
188
+ },
189
+ async readRunRecord(runId) {
190
+ try {
191
+ return parseRunRecord(await readJsonFile(paths.runFile(runId)));
192
+ }
193
+ catch {
194
+ const recent = await listRecentRunRecords(wakeRoot, 500);
195
+ return recent.find((record) => record.runId === runId) ?? null;
196
+ }
197
+ },
198
+ async listRunRecords() {
199
+ return listRunRecords(wakeRoot);
200
+ },
201
+ async listRunRecordsForDate(date) {
202
+ return listRunRecordsForDate(wakeRoot, date);
203
+ },
204
+ async listRecentRunRecords(limit = 10) {
205
+ return listRecentRunRecords(wakeRoot, limit);
206
+ },
207
+ async readSourceState(source, key) {
208
+ try {
209
+ return parseSourceStateRecord(await readJsonFile(paths.sourceStateFile(source, key)));
210
+ }
211
+ catch {
212
+ return null;
213
+ }
214
+ },
215
+ async appendEventEnvelope(record) {
216
+ const parsed = parseEventEnvelope(record);
217
+ const existing = await this.readEventEnvelope(parsed.eventId);
218
+ if (existing !== null) {
219
+ return existing;
220
+ }
221
+ await appendJsonLine(paths.eventFile(parsed.ingestedAt.slice(0, 10)), parsed);
222
+ await writeJsonFile(paths.eventEnvelopeFile(parsed.eventId), parsed);
223
+ return parsed;
224
+ },
225
+ async readEventEnvelope(eventId) {
226
+ try {
227
+ return parseEventEnvelope(await readJsonFile(paths.eventEnvelopeFile(eventId)));
228
+ }
229
+ catch {
230
+ return null;
231
+ }
232
+ },
233
+ async listIssueStates(options = {}) {
234
+ const stateRoot = join(wakeRoot, 'state');
235
+ try {
236
+ const items = [];
237
+ const includeArchived = options.includeArchived ?? false;
238
+ const archiveOptions = options.archiveFreshnessDays === undefined
239
+ ? null
240
+ : {
241
+ archiveFreshnessDays: options.archiveFreshnessDays,
242
+ now: options.now ?? new Date(),
243
+ };
244
+ // state/ is flat: state/<workId>.json, plus state/archive/ and the
245
+ // reverse index's own state/index/ shards. Only those two subdirectories
246
+ // exist, and index/ holds `{ resourceUri: workItemKey }` maps that are
247
+ // not projections at all — skip it rather than parse-and-discard.
248
+ const visit = async (dir, isArchive) => {
249
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
250
+ for (const entry of entries) {
251
+ if (entry.isDirectory()) {
252
+ if (entry.name === 'index') {
253
+ continue;
254
+ }
255
+ if (entry.name === 'archive' && includeArchived) {
256
+ await visit(join(dir, entry.name), true);
257
+ }
258
+ continue;
259
+ }
260
+ if (!entry.isFile() || !entry.name.endsWith('.json')) {
261
+ continue;
262
+ }
263
+ const file = join(dir, entry.name);
264
+ const record = await readIssueStateFile(file);
265
+ if (record === null) {
266
+ continue;
267
+ }
268
+ if (archiveOptions !== null && !isArchive && 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;
273
+ }
274
+ items.push(record);
275
+ }
276
+ };
277
+ await visit(stateRoot, false);
278
+ const byWorkItemKey = new Map();
279
+ for (const item of items) {
280
+ byWorkItemKey.set(item.workItemKey, item);
281
+ }
282
+ return [...byWorkItemKey.values()].sort((left, right) => left.workItemKey.localeCompare(right.workItemKey));
283
+ }
284
+ catch {
285
+ return [];
286
+ }
287
+ },
288
+ async listEventEnvelopes() {
289
+ const eventsRoot = join(wakeRoot, 'events');
290
+ try {
291
+ const files = (await readdir(eventsRoot)).sort();
292
+ const envelopes = [];
293
+ for (const file of files) {
294
+ envelopes.push(...await readEventFile(join(eventsRoot, file)));
295
+ }
296
+ return envelopes;
297
+ }
298
+ catch {
299
+ return [];
300
+ }
301
+ },
302
+ async listRecentEventEnvelopes(filter = {}) {
303
+ const limit = filter.limit ?? 200;
304
+ const eventsRoot = join(wakeRoot, 'events');
305
+ const files = (await readdir(eventsRoot).catch(() => []))
306
+ .filter((file) => file.endsWith('.jsonl'))
307
+ .sort()
308
+ .reverse();
309
+ const results = [];
310
+ for (const file of files) {
311
+ const events = await readEventFile(join(eventsRoot, file));
312
+ for (const event of events.reverse()) {
313
+ if (filter.workItemKey !== undefined && event.workItemKey !== filter.workItemKey) {
314
+ continue;
315
+ }
316
+ if (filter.direction !== undefined && event.direction !== filter.direction) {
317
+ continue;
318
+ }
319
+ if (filter.type !== undefined && event.sourceEventType !== filter.type) {
320
+ continue;
321
+ }
322
+ results.push(event);
323
+ if (results.length >= limit) {
324
+ return results;
325
+ }
326
+ }
327
+ }
328
+ return results;
329
+ },
330
+ async listEventEnvelopesForWorkItem(workItemKey, limit = 10) {
331
+ const projection = await this.readIssueState(workItemKey);
332
+ const recentEventIds = projection?.wake.recentEventIds.slice(-limit) ?? [];
333
+ const envelopes = [];
334
+ for (const eventId of recentEventIds) {
335
+ const envelope = await this.readEventEnvelope(eventId);
336
+ if (envelope?.workItemKey === workItemKey) {
337
+ envelopes.push(envelope);
338
+ }
339
+ }
340
+ return envelopes;
341
+ },
342
+ async appendLog(date, line) {
343
+ await mkdir(wakeRoot, { recursive: true });
344
+ await appendFile(paths.logFile(date), `${line}\n`, 'utf8');
345
+ },
346
+ // Only the manual pause file is a hard stop on the whole loop. Quota
347
+ // pauses are now per-runner (ledger.runners, #67): a paused runner skips
348
+ // itself via routing fallback inside a tick, but the tick loop itself must
349
+ // keep running so polling, delivery retries, and other tiers still make
350
+ // progress while one runner is paused.
351
+ async isPaused(_now = new Date()) {
352
+ try {
353
+ await access(paths.pauseFile);
354
+ return true;
355
+ }
356
+ catch {
357
+ return false;
358
+ }
359
+ },
360
+ async readEventLog(date) {
361
+ return readFile(paths.eventFile(date), 'utf8');
362
+ },
363
+ };
364
+ }