holo-codex 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 (149) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/CONTRIBUTING.md +54 -0
  3. package/LICENSE +21 -0
  4. package/README.md +215 -0
  5. package/README.zh-CN.md +215 -0
  6. package/SECURITY.md +39 -0
  7. package/assets/brand/README.md +35 -0
  8. package/assets/brand/holo-codex-icon.svg +28 -0
  9. package/assets/brand/holo-codex-lockup.svg +49 -0
  10. package/assets/brand/holo-codex-mark.svg +33 -0
  11. package/assets/brand/holo-codex-plugin-card.png +0 -0
  12. package/assets/brand/holo-codex-plugin-card.svg +81 -0
  13. package/assets/brand/holo-codex-readme-hero.png +0 -0
  14. package/assets/brand/holo-codex-readme-hero.svg +140 -0
  15. package/assets/brand/holo-codex-social-preview.png +0 -0
  16. package/assets/brand/holo-codex-social-preview.svg +130 -0
  17. package/assets/brand/holo-codex-wordmark-options.svg +52 -0
  18. package/docs/checklists/agent-loop-first-delivery-audit.md +129 -0
  19. package/docs/examples/generic-loop-repo-hygiene.md +168 -0
  20. package/docs/install.md +190 -0
  21. package/docs/local-release-readiness.md +206 -0
  22. package/docs/release-checklist.md +144 -0
  23. package/docs/self-bootstrap.md +150 -0
  24. package/docs/trust-and-safety.md +45 -0
  25. package/package.json +83 -0
  26. package/plugins/autonomous-pr-loop/.codex-plugin/plugin.json +17 -0
  27. package/plugins/autonomous-pr-loop/.mcp.json +13 -0
  28. package/plugins/autonomous-pr-loop/bin/agent-loop.mjs +31 -0
  29. package/plugins/autonomous-pr-loop/core/artifacts.ts +164 -0
  30. package/plugins/autonomous-pr-loop/core/autonomy-policy.ts +206 -0
  31. package/plugins/autonomous-pr-loop/core/ci.ts +131 -0
  32. package/plugins/autonomous-pr-loop/core/cli-i18n.ts +123 -0
  33. package/plugins/autonomous-pr-loop/core/cli.ts +1413 -0
  34. package/plugins/autonomous-pr-loop/core/command-runner.ts +446 -0
  35. package/plugins/autonomous-pr-loop/core/command.ts +47 -0
  36. package/plugins/autonomous-pr-loop/core/config-editor.ts +140 -0
  37. package/plugins/autonomous-pr-loop/core/config.ts +293 -0
  38. package/plugins/autonomous-pr-loop/core/controller-host.ts +19 -0
  39. package/plugins/autonomous-pr-loop/core/dashboard-server.ts +536 -0
  40. package/plugins/autonomous-pr-loop/core/delivery-work-item.ts +217 -0
  41. package/plugins/autonomous-pr-loop/core/doctor.ts +335 -0
  42. package/plugins/autonomous-pr-loop/core/errors.ts +82 -0
  43. package/plugins/autonomous-pr-loop/core/gate-recovery.ts +176 -0
  44. package/plugins/autonomous-pr-loop/core/gates.ts +26 -0
  45. package/plugins/autonomous-pr-loop/core/generic-lifecycle.ts +399 -0
  46. package/plugins/autonomous-pr-loop/core/git.ts +213 -0
  47. package/plugins/autonomous-pr-loop/core/github.ts +269 -0
  48. package/plugins/autonomous-pr-loop/core/gitnexus.ts +90 -0
  49. package/plugins/autonomous-pr-loop/core/happy.ts +42 -0
  50. package/plugins/autonomous-pr-loop/core/hook-capture.ts +115 -0
  51. package/plugins/autonomous-pr-loop/core/hook-events.ts +22 -0
  52. package/plugins/autonomous-pr-loop/core/hook-installation.ts +85 -0
  53. package/plugins/autonomous-pr-loop/core/hook-observer.ts +84 -0
  54. package/plugins/autonomous-pr-loop/core/hook-policy.ts +423 -0
  55. package/plugins/autonomous-pr-loop/core/hook-router.ts +452 -0
  56. package/plugins/autonomous-pr-loop/core/index.ts +32 -0
  57. package/plugins/autonomous-pr-loop/core/local-install.ts +778 -0
  58. package/plugins/autonomous-pr-loop/core/locale.ts +60 -0
  59. package/plugins/autonomous-pr-loop/core/loop-shapes.ts +190 -0
  60. package/plugins/autonomous-pr-loop/core/mcp-controller.ts +1479 -0
  61. package/plugins/autonomous-pr-loop/core/notification-feed.ts +263 -0
  62. package/plugins/autonomous-pr-loop/core/plan-parser.ts +206 -0
  63. package/plugins/autonomous-pr-loop/core/plugin-paths.ts +32 -0
  64. package/plugins/autonomous-pr-loop/core/policy.ts +65 -0
  65. package/plugins/autonomous-pr-loop/core/pr-lifecycle.ts +464 -0
  66. package/plugins/autonomous-pr-loop/core/pr-selector.ts +284 -0
  67. package/plugins/autonomous-pr-loop/core/profiles.ts +439 -0
  68. package/plugins/autonomous-pr-loop/core/redaction.ts +17 -0
  69. package/plugins/autonomous-pr-loop/core/repo-root.ts +22 -0
  70. package/plugins/autonomous-pr-loop/core/review-comments.ts +77 -0
  71. package/plugins/autonomous-pr-loop/core/scope-guard.ts +179 -0
  72. package/plugins/autonomous-pr-loop/core/state-machine.ts +828 -0
  73. package/plugins/autonomous-pr-loop/core/state-types.ts +130 -0
  74. package/plugins/autonomous-pr-loop/core/storage.ts +2527 -0
  75. package/plugins/autonomous-pr-loop/core/types.ts +567 -0
  76. package/plugins/autonomous-pr-loop/core/worker-events.ts +412 -0
  77. package/plugins/autonomous-pr-loop/core/worker-policy.ts +72 -0
  78. package/plugins/autonomous-pr-loop/core/worker-prompts.ts +182 -0
  79. package/plugins/autonomous-pr-loop/core/worker.ts +809 -0
  80. package/plugins/autonomous-pr-loop/core/workflow-board.ts +1515 -0
  81. package/plugins/autonomous-pr-loop/hooks/dist/permission-request.js +2462 -0
  82. package/plugins/autonomous-pr-loop/hooks/dist/post-compact.js +2462 -0
  83. package/plugins/autonomous-pr-loop/hooks/dist/post-tool-use.js +2462 -0
  84. package/plugins/autonomous-pr-loop/hooks/dist/pre-compact.js +2462 -0
  85. package/plugins/autonomous-pr-loop/hooks/dist/pre-tool-use.js +3460 -0
  86. package/plugins/autonomous-pr-loop/hooks/dist/session-start.js +2462 -0
  87. package/plugins/autonomous-pr-loop/hooks/dist/stop.js +2462 -0
  88. package/plugins/autonomous-pr-loop/hooks/dist/user-prompt-submit.js +2462 -0
  89. package/plugins/autonomous-pr-loop/hooks/hooks.json +106 -0
  90. package/plugins/autonomous-pr-loop/hooks/observe-runner.ts +25 -0
  91. package/plugins/autonomous-pr-loop/hooks/permission-request.ts +4 -0
  92. package/plugins/autonomous-pr-loop/hooks/post-compact.ts +4 -0
  93. package/plugins/autonomous-pr-loop/hooks/post-tool-use.ts +4 -0
  94. package/plugins/autonomous-pr-loop/hooks/pre-compact.ts +4 -0
  95. package/plugins/autonomous-pr-loop/hooks/pre-tool-use.ts +44 -0
  96. package/plugins/autonomous-pr-loop/hooks/session-start.ts +4 -0
  97. package/plugins/autonomous-pr-loop/hooks/stop.ts +4 -0
  98. package/plugins/autonomous-pr-loop/hooks/user-prompt-submit.ts +4 -0
  99. package/plugins/autonomous-pr-loop/mcp-server/src/index.ts +87 -0
  100. package/plugins/autonomous-pr-loop/mcp-server/src/tools.ts +205 -0
  101. package/plugins/autonomous-pr-loop/package.json +9 -0
  102. package/plugins/autonomous-pr-loop/schemas/config.schema.json +74 -0
  103. package/plugins/autonomous-pr-loop/schemas/marketplace.schema.json +46 -0
  104. package/plugins/autonomous-pr-loop/schemas/plugin.schema.json +32 -0
  105. package/plugins/autonomous-pr-loop/schemas/state.schema.json +19 -0
  106. package/plugins/autonomous-pr-loop/schemas/worker-event.schema.json +19 -0
  107. package/plugins/autonomous-pr-loop/schemas/worker-result.schema.json +58 -0
  108. package/plugins/autonomous-pr-loop/scripts/agent-loop.ts +44 -0
  109. package/plugins/autonomous-pr-loop/skills/autonomous-pr-loop/SKILL.md +26 -0
  110. package/plugins/autonomous-pr-loop/skills/autonomous-pr-loop/agents/openai.yaml +6 -0
  111. package/plugins/autonomous-pr-loop/ui/index.html +26 -0
  112. package/plugins/autonomous-pr-loop/ui/public/favicon.svg +7 -0
  113. package/plugins/autonomous-pr-loop/ui/src/api.ts +639 -0
  114. package/plugins/autonomous-pr-loop/ui/src/app.tsx +238 -0
  115. package/plugins/autonomous-pr-loop/ui/src/components/ActivityBadge.tsx +31 -0
  116. package/plugins/autonomous-pr-loop/ui/src/components/BrandMark.tsx +36 -0
  117. package/plugins/autonomous-pr-loop/ui/src/components/Collapsible.tsx +6 -0
  118. package/plugins/autonomous-pr-loop/ui/src/components/CommandPreview.tsx +15 -0
  119. package/plugins/autonomous-pr-loop/ui/src/components/ConfigEditor.tsx +389 -0
  120. package/plugins/autonomous-pr-loop/ui/src/components/EmptyState.tsx +10 -0
  121. package/plugins/autonomous-pr-loop/ui/src/components/ErrorState.tsx +12 -0
  122. package/plugins/autonomous-pr-loop/ui/src/components/List.tsx +7 -0
  123. package/plugins/autonomous-pr-loop/ui/src/components/MetricRow.tsx +6 -0
  124. package/plugins/autonomous-pr-loop/ui/src/components/ResponsiveTable.tsx +65 -0
  125. package/plugins/autonomous-pr-loop/ui/src/components/RiskBadge.tsx +10 -0
  126. package/plugins/autonomous-pr-loop/ui/src/components/StatusBadge.tsx +29 -0
  127. package/plugins/autonomous-pr-loop/ui/src/components/TopMetric.tsx +10 -0
  128. package/plugins/autonomous-pr-loop/ui/src/fixtures.ts +1152 -0
  129. package/plugins/autonomous-pr-loop/ui/src/i18n.ts +1105 -0
  130. package/plugins/autonomous-pr-loop/ui/src/main.tsx +14 -0
  131. package/plugins/autonomous-pr-loop/ui/src/pages/CommandCenter.tsx +470 -0
  132. package/plugins/autonomous-pr-loop/ui/src/pages/CommandCenterParts.tsx +276 -0
  133. package/plugins/autonomous-pr-loop/ui/src/pages/agent-timeline/AgentTimelineView.tsx +73 -0
  134. package/plugins/autonomous-pr-loop/ui/src/pages/artifact-viewer/ArtifactViewer.tsx +44 -0
  135. package/plugins/autonomous-pr-loop/ui/src/pages/dry-run-preview/DryRunPreview.tsx +66 -0
  136. package/plugins/autonomous-pr-loop/ui/src/pages/event-ledger/EventLedger.tsx +17 -0
  137. package/plugins/autonomous-pr-loop/ui/src/pages/gate-center/GateCenter.tsx +34 -0
  138. package/plugins/autonomous-pr-loop/ui/src/pages/mission-control/MissionControl.tsx +104 -0
  139. package/plugins/autonomous-pr-loop/ui/src/pages/mission-control/WorkflowBoard.tsx +577 -0
  140. package/plugins/autonomous-pr-loop/ui/src/pages/notifications/NotificationsView.tsx +30 -0
  141. package/plugins/autonomous-pr-loop/ui/src/pages/plan-navigator/PlanNavigator.tsx +19 -0
  142. package/plugins/autonomous-pr-loop/ui/src/pages/policy-config/PolicyConfig.tsx +22 -0
  143. package/plugins/autonomous-pr-loop/ui/src/pages/pr-inbox/PrInbox.tsx +26 -0
  144. package/plugins/autonomous-pr-loop/ui/src/pages/recovery-center/RecoveryCenter.tsx +125 -0
  145. package/plugins/autonomous-pr-loop/ui/src/pages/scope-guard/ScopeGuard.tsx +16 -0
  146. package/plugins/autonomous-pr-loop/ui/src/pages/worker-runs/WorkerRuns.tsx +39 -0
  147. package/plugins/autonomous-pr-loop/ui/src/styles.css +2673 -0
  148. package/plugins/autonomous-pr-loop/ui/src/theme.ts +57 -0
  149. package/tsconfig.json +18 -0
@@ -0,0 +1,2527 @@
1
+ import { existsSync, mkdirSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { DatabaseSync, type SQLInputValue, type StatementSync } from "node:sqlite";
5
+ import { withConfigDefaults } from "./config.js";
6
+ import { AgentLoopError } from "./errors.js";
7
+ import { isSecretKey, redactSecrets } from "./redaction.js";
8
+ import type {
9
+ AgentLoopConfig,
10
+ AgentLoopArtifactRecord,
11
+ AgentLoopCiCheck,
12
+ AgentLoopDecision,
13
+ AgentLoopEvent,
14
+ AgentLoopGate,
15
+ AgentLoopGateKind,
16
+ AgentTimelineEntry,
17
+ AgentTimelineIntegrityReport,
18
+ AgentTimelinePage,
19
+ AgentTimelineQuery,
20
+ AgentTimelineSource,
21
+ AgentLoopRunCheck,
22
+ AgentLoopPrLink,
23
+ AgentLoopReviewComment,
24
+ AgentLoopRun,
25
+ AgentLoopStatus,
26
+ AgentLoopStorage,
27
+ WorkerBackend,
28
+ WorkerEvent,
29
+ WorkerRun,
30
+ WorkerStatus,
31
+ WorkerType
32
+ } from "./types.js";
33
+
34
+ export const STORAGE_SCHEMA_VERSION = 8;
35
+ export const SUPPORTED_SCHEMA_VERSIONS = [1, 2, 3, 4, 5, 6, 7, STORAGE_SCHEMA_VERSION] as const;
36
+ export type StorageOpenMode = "rw" | "ro";
37
+ const TIMELINE_SOURCES = ["event", "worker_event", "worker", "state", "gate", "artifact", "decision"] as const;
38
+ const TIMELINE_TRIGGER_NAMES = [
39
+ "timeline_events_insert",
40
+ "timeline_worker_events_insert",
41
+ "timeline_workers_insert",
42
+ "timeline_workers_status_update",
43
+ "timeline_states_insert",
44
+ "timeline_gates_insert",
45
+ "timeline_artifacts_insert",
46
+ "timeline_decisions_insert"
47
+ ] as const;
48
+ const PR_C_TABLES_SQL = `
49
+ create table if not exists pr_links (
50
+ id text primary key,
51
+ run_id text not null,
52
+ branch text not null,
53
+ pr_number integer not null,
54
+ url text not null,
55
+ head_ref text not null,
56
+ base_ref text not null,
57
+ state text not null,
58
+ draft integer not null,
59
+ created_at text not null,
60
+ updated_at text not null,
61
+ unique(run_id, pr_number),
62
+ foreign key(run_id) references runs(id)
63
+ );
64
+
65
+ create table if not exists ci_checks (
66
+ id text primary key,
67
+ run_id text not null,
68
+ pr_number integer not null,
69
+ name text not null,
70
+ status text not null,
71
+ conclusion text,
72
+ url text,
73
+ started_at text,
74
+ completed_at text,
75
+ observed_at text not null,
76
+ foreign key(run_id) references runs(id)
77
+ );
78
+
79
+ create table if not exists review_comments (
80
+ id text primary key,
81
+ run_id text not null,
82
+ pr_number integer not null,
83
+ comment_id text not null,
84
+ url text not null,
85
+ author text not null,
86
+ body text not null,
87
+ path text not null,
88
+ line integer,
89
+ diff_hunk text not null,
90
+ is_resolved integer not null,
91
+ is_outdated integer not null,
92
+ actionable integer not null,
93
+ status text not null,
94
+ observed_at text not null,
95
+ unique(run_id, comment_id),
96
+ foreign key(run_id) references runs(id)
97
+ );
98
+
99
+ create table if not exists decisions (
100
+ id text primary key,
101
+ run_id text not null,
102
+ kind text not null,
103
+ message text not null,
104
+ details_json text,
105
+ created_at text not null,
106
+ foreign key(run_id) references runs(id)
107
+ );
108
+ `;
109
+ const PR_D_TABLES_SQL = `
110
+ create table if not exists workers (
111
+ id text primary key,
112
+ run_id text not null,
113
+ type text not null,
114
+ backend text not null,
115
+ status text not null,
116
+ thread_id text,
117
+ attempt integer not null,
118
+ resume_used integer not null,
119
+ started_at text not null,
120
+ completed_at text,
121
+ exit_code integer,
122
+ result_artifact_id text,
123
+ raw_jsonl_artifact_id text,
124
+ error text,
125
+ foreign key(run_id) references runs(id)
126
+ );
127
+
128
+ create table if not exists worker_events (
129
+ seq integer primary key autoincrement,
130
+ id text not null unique,
131
+ worker_id text not null,
132
+ run_id text not null,
133
+ event_type text not null,
134
+ item_type text,
135
+ item_id text,
136
+ item_status text,
137
+ thread_id text,
138
+ backend text,
139
+ summary_json text,
140
+ usage_json text,
141
+ artifact_ids_json text,
142
+ created_at text not null,
143
+ foreign key(worker_id) references workers(id),
144
+ foreign key(run_id) references runs(id)
145
+ );
146
+
147
+ create unique index if not exists workers_single_running
148
+ on workers(status)
149
+ where status = 'running';
150
+ `;
151
+ const PR_E_INDEXES_SQL = `
152
+ create unique index if not exists runs_single_running
153
+ on runs(status)
154
+ where status = 'RUNNING';
155
+ `;
156
+ const PR_E_TABLES_SQL = `
157
+ create table if not exists run_checks (
158
+ run_id text not null,
159
+ kind text not null,
160
+ status text not null,
161
+ details_json text,
162
+ created_at text not null,
163
+ primary key(run_id, kind),
164
+ foreign key(run_id) references runs(id)
165
+ );
166
+ `;
167
+ const TIMELINE_INDEX_SQL = `
168
+ create table if not exists timeline_index (
169
+ timeline_seq integer primary key autoincrement,
170
+ source text not null,
171
+ source_id text not null,
172
+ source_seq integer,
173
+ run_id text,
174
+ worker_id text,
175
+ created_at text not null,
176
+ unique(source, source_id)
177
+ );
178
+
179
+ create index if not exists timeline_index_created
180
+ on timeline_index(created_at desc, timeline_seq desc);
181
+ create index if not exists timeline_index_run
182
+ on timeline_index(run_id, timeline_seq desc);
183
+ create index if not exists timeline_index_worker
184
+ on timeline_index(worker_id, timeline_seq desc);
185
+ `;
186
+ const TIMELINE_TRIGGERS_SQL = `
187
+ create trigger if not exists timeline_events_insert
188
+ after insert on events
189
+ begin
190
+ insert or ignore into timeline_index (source, source_id, source_seq, run_id, worker_id, created_at)
191
+ values ('event', new.id, new.seq, new.run_id, null, new.created_at);
192
+ end;
193
+
194
+ create trigger if not exists timeline_worker_events_insert
195
+ after insert on worker_events
196
+ begin
197
+ insert or ignore into timeline_index (source, source_id, source_seq, run_id, worker_id, created_at)
198
+ values ('worker_event', new.id, new.seq, new.run_id, new.worker_id, new.created_at);
199
+ end;
200
+
201
+ create trigger if not exists timeline_workers_insert
202
+ after insert on workers
203
+ begin
204
+ insert or ignore into timeline_index (source, source_id, source_seq, run_id, worker_id, created_at)
205
+ values ('worker', new.id || ':' || new.status, null, new.run_id, new.id, new.started_at);
206
+ end;
207
+
208
+ create trigger if not exists timeline_workers_status_update
209
+ after update of status on workers
210
+ when old.status is not new.status
211
+ begin
212
+ insert or ignore into timeline_index (source, source_id, source_seq, run_id, worker_id, created_at)
213
+ values (
214
+ 'worker',
215
+ new.id || ':' || new.status,
216
+ null,
217
+ new.run_id,
218
+ new.id,
219
+ coalesce(new.completed_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
220
+ );
221
+ end;
222
+
223
+ create trigger if not exists timeline_states_insert
224
+ after insert on states
225
+ begin
226
+ insert or ignore into timeline_index (source, source_id, source_seq, run_id, worker_id, created_at)
227
+ values ('state', cast(new.id as text), new.id, new.run_id, null, new.created_at);
228
+ end;
229
+
230
+ create trigger if not exists timeline_gates_insert
231
+ after insert on gates
232
+ begin
233
+ insert or ignore into timeline_index (source, source_id, source_seq, run_id, worker_id, created_at)
234
+ values ('gate', new.id, null, new.run_id, null, new.created_at);
235
+ end;
236
+
237
+ create trigger if not exists timeline_artifacts_insert
238
+ after insert on artifacts
239
+ begin
240
+ insert or ignore into timeline_index (source, source_id, source_seq, run_id, worker_id, created_at)
241
+ values ('artifact', new.id, null, new.run_id, null, new.created_at);
242
+ end;
243
+
244
+ create trigger if not exists timeline_decisions_insert
245
+ after insert on decisions
246
+ begin
247
+ insert or ignore into timeline_index (source, source_id, source_seq, run_id, worker_id, created_at)
248
+ values ('decision', new.id, null, new.run_id, null, new.created_at);
249
+ end;
250
+ `;
251
+ const SCHEMA_SQL = `
252
+ create table if not exists runs (
253
+ id text primary key,
254
+ status text not null,
255
+ current_state text,
256
+ version integer not null default 0,
257
+ branch text,
258
+ worktree_clean integer,
259
+ started_at text,
260
+ stopped_at text,
261
+ created_at text not null,
262
+ updated_at text not null
263
+ );
264
+
265
+ create table if not exists states (
266
+ id integer primary key autoincrement,
267
+ run_id text,
268
+ status text not null,
269
+ state text,
270
+ version integer not null,
271
+ payload_json text,
272
+ created_at text not null,
273
+ foreign key(run_id) references runs(id)
274
+ );
275
+
276
+ create table if not exists events (
277
+ seq integer primary key autoincrement,
278
+ id text not null unique,
279
+ run_id text,
280
+ kind text not null,
281
+ message text not null,
282
+ state_before text,
283
+ state_after text,
284
+ payload_json text,
285
+ artifact_ids_json text,
286
+ created_at text not null,
287
+ foreign key(run_id) references runs(id)
288
+ );
289
+
290
+ create table if not exists gates (
291
+ id text primary key,
292
+ run_id text,
293
+ kind text not null,
294
+ status text not null,
295
+ message text not null,
296
+ details_json text,
297
+ created_at text not null,
298
+ resolved_at text,
299
+ decision_note text,
300
+ decided_at text,
301
+ foreign key(run_id) references runs(id)
302
+ );
303
+
304
+ create table if not exists artifacts (
305
+ id text primary key,
306
+ run_id text,
307
+ kind text not null,
308
+ name text,
309
+ path text not null,
310
+ sha256 text,
311
+ metadata_json text,
312
+ created_at text not null,
313
+ foreign key(run_id) references runs(id)
314
+ );
315
+
316
+ create table if not exists repo_config (
317
+ id integer primary key check (id = 1),
318
+ schema_version integer not null,
319
+ config_json text not null,
320
+ updated_at text not null
321
+ );
322
+
323
+ ${PR_C_TABLES_SQL}
324
+ ${PR_D_TABLES_SQL}
325
+ ${PR_E_TABLES_SQL}
326
+ ${PR_E_INDEXES_SQL}
327
+ `;
328
+
329
+ /** SQLite-backed implementation of the shared Agent Loop storage contract. */
330
+ export class SqliteAgentLoopStorage implements AgentLoopStorage {
331
+ private readonly db: DatabaseSync;
332
+ private readonly mode: StorageOpenMode;
333
+ private readonly listWorkersByRunStatement: StatementSync;
334
+ private readonly listWorkersStatement: StatementSync;
335
+
336
+ constructor(private readonly path: string, options: { mode?: StorageOpenMode } = {}) {
337
+ this.mode = options.mode ?? "rw";
338
+ if (this.mode === "rw") {
339
+ mkdirSync(dirname(path), { recursive: true });
340
+ } else if (!existsSync(path)) {
341
+ throw new AgentLoopError("storage_error", "Read-only storage file does not exist.", {
342
+ details: { path }
343
+ });
344
+ }
345
+ this.db = new DatabaseSync(path, {
346
+ readOnly: this.mode === "ro",
347
+ enableForeignKeyConstraints: true,
348
+ timeout: 5000
349
+ });
350
+ try {
351
+ this.db.exec("PRAGMA foreign_keys=ON");
352
+ this.db.exec("PRAGMA busy_timeout=5000");
353
+ if (this.mode === "rw") {
354
+ this.db.exec("PRAGMA journal_mode=WAL");
355
+ }
356
+ this.ensureSchema();
357
+ if (this.mode === "rw") {
358
+ this.ensureRepoConfigVersion();
359
+ } else {
360
+ this.validateRepoConfigVersion();
361
+ }
362
+ const workersSql = `select id, run_id, type, backend, status, thread_id, attempt, resume_used,
363
+ started_at, completed_at, exit_code, result_artifact_id, raw_jsonl_artifact_id, error
364
+ from workers`;
365
+ this.listWorkersByRunStatement = this.db.prepare(`${workersSql} where run_id = ? order by started_at desc limit ?`);
366
+ this.listWorkersStatement = this.db.prepare(`${workersSql} order by started_at desc limit ?`);
367
+ } catch (error) {
368
+ this.db.close();
369
+ throw toStorageError(error, "Failed to open agent-loop storage.");
370
+ }
371
+ }
372
+
373
+ close(): void {
374
+ this.db.close();
375
+ }
376
+
377
+ writeRepoConfig(config: AgentLoopConfig): void {
378
+ const snapshot = JSON.stringify({ schemaVersion: STORAGE_SCHEMA_VERSION, ...config });
379
+ this.transaction(() => {
380
+ this.db
381
+ .prepare(
382
+ `insert into repo_config (id, schema_version, config_json, updated_at)
383
+ values (1, ?, ?, ?)
384
+ on conflict(id) do update set
385
+ schema_version = excluded.schema_version,
386
+ config_json = excluded.config_json,
387
+ updated_at = excluded.updated_at`
388
+ )
389
+ .run(STORAGE_SCHEMA_VERSION, snapshot, now());
390
+ });
391
+ }
392
+
393
+ readRepoConfig(): AgentLoopConfig | undefined {
394
+ const row = this.db
395
+ .prepare("select schema_version, config_json from repo_config where id = 1")
396
+ .get() as { schema_version: number; config_json: string } | undefined;
397
+ if (!row) {
398
+ return undefined;
399
+ }
400
+ if (!isSupportedSchemaVersion(row.schema_version)) {
401
+ throw new AgentLoopError(
402
+ "storage_schema_mismatch",
403
+ `Stored repo config schema version ${row.schema_version} is not supported.`,
404
+ { details: { expected: STORAGE_SCHEMA_VERSION, actual: row.schema_version } }
405
+ );
406
+ }
407
+ const parsed = parseJson(row.config_json, "Stored repo config JSON is invalid.") as AgentLoopConfig & {
408
+ schemaVersion?: number;
409
+ };
410
+ const { schemaVersion: _schemaVersion, ...config } = parsed;
411
+ return config;
412
+ }
413
+
414
+ createRun(
415
+ status: AgentLoopStatus,
416
+ options: {
417
+ currentState?: string;
418
+ branch?: string;
419
+ worktreeClean?: boolean;
420
+ } = {}
421
+ ): AgentLoopRun {
422
+ const createdAt = now();
423
+ const run: AgentLoopRun = {
424
+ id: randomUUID(),
425
+ status,
426
+ ...(options.currentState ? { currentState: options.currentState } : {}),
427
+ version: 0,
428
+ ...(options.branch ? { branch: options.branch } : {}),
429
+ ...(options.worktreeClean !== undefined ? { worktreeClean: options.worktreeClean } : {}),
430
+ createdAt,
431
+ updatedAt: createdAt,
432
+ startedAt: createdAt
433
+ };
434
+ try {
435
+ this.transaction(() => {
436
+ this.db
437
+ .prepare(
438
+ `insert into runs (
439
+ id, status, current_state, version, branch, worktree_clean, started_at, stopped_at, created_at, updated_at
440
+ )
441
+ values (?, ?, ?, ?, ?, ?, ?, null, ?, ?)`
442
+ )
443
+ .run(
444
+ run.id,
445
+ run.status,
446
+ run.currentState ?? null,
447
+ run.version,
448
+ run.branch ?? null,
449
+ boolToDb(run.worktreeClean),
450
+ run.startedAt ?? null,
451
+ run.createdAt,
452
+ run.updatedAt
453
+ );
454
+ this.db
455
+ .prepare(
456
+ `insert into states (run_id, status, state, version, payload_json, created_at)
457
+ values (?, ?, ?, ?, null, ?)`
458
+ )
459
+ .run(run.id, run.status, run.currentState ?? run.status, run.version, run.updatedAt);
460
+ });
461
+ } catch (error) {
462
+ if (isUniqueConstraintError(error)) {
463
+ throw new AgentLoopError("version_conflict", "Another active run already exists.", {
464
+ details: { status },
465
+ exitCode: 2
466
+ });
467
+ }
468
+ throw error;
469
+ }
470
+ return run;
471
+ }
472
+
473
+ getOrCreateActiveRun(options: {
474
+ currentState?: string;
475
+ branch?: string;
476
+ worktreeClean?: boolean;
477
+ } = {}): { run: AgentLoopRun; created: boolean } {
478
+ return this.transaction(() => {
479
+ const active = this.getActiveRun();
480
+ if (active) {
481
+ return { run: active, created: false };
482
+ }
483
+ const createdAt = now();
484
+ const run: AgentLoopRun = {
485
+ id: randomUUID(),
486
+ status: "RUNNING",
487
+ ...(options.currentState ? { currentState: options.currentState } : {}),
488
+ version: 0,
489
+ ...(options.branch ? { branch: options.branch } : {}),
490
+ ...(options.worktreeClean !== undefined ? { worktreeClean: options.worktreeClean } : {}),
491
+ createdAt,
492
+ updatedAt: createdAt,
493
+ startedAt: createdAt
494
+ };
495
+ this.db
496
+ .prepare(
497
+ `insert into runs (
498
+ id, status, current_state, version, branch, worktree_clean, started_at, stopped_at, created_at, updated_at
499
+ )
500
+ values (?, ?, ?, ?, ?, ?, ?, null, ?, ?)`
501
+ )
502
+ .run(
503
+ run.id,
504
+ run.status,
505
+ run.currentState ?? null,
506
+ run.version,
507
+ run.branch ?? null,
508
+ boolToDb(run.worktreeClean),
509
+ run.startedAt ?? null,
510
+ run.createdAt,
511
+ run.updatedAt
512
+ );
513
+ this.db
514
+ .prepare(
515
+ `insert into states (run_id, status, state, version, payload_json, created_at)
516
+ values (?, ?, ?, ?, null, ?)`
517
+ )
518
+ .run(run.id, run.status, run.currentState ?? run.status, run.version, run.updatedAt);
519
+ return { run, created: true };
520
+ });
521
+ }
522
+
523
+ recordRunCheck(check: Omit<AgentLoopRunCheck, "createdAt">): AgentLoopRunCheck {
524
+ const stored: AgentLoopRunCheck = { ...check, createdAt: now() };
525
+ this.transaction(() => {
526
+ this.db
527
+ .prepare(
528
+ `insert into run_checks (run_id, kind, status, details_json, created_at)
529
+ values (?, ?, ?, ?, ?)
530
+ on conflict(run_id, kind) do update set
531
+ status = excluded.status,
532
+ details_json = excluded.details_json,
533
+ created_at = excluded.created_at`
534
+ )
535
+ .run(
536
+ stored.runId,
537
+ stored.kind,
538
+ stored.status,
539
+ stored.details === undefined ? null : JSON.stringify(stored.details),
540
+ stored.createdAt
541
+ );
542
+ });
543
+ return stored;
544
+ }
545
+
546
+ hasRunCheck(runId: string, kind: AgentLoopRunCheck["kind"]): boolean {
547
+ const row = this.db
548
+ .prepare("select 1 from run_checks where run_id = ? and kind = ? and status in ('passed', 'skipped') limit 1")
549
+ .get(runId, kind);
550
+ return row !== undefined;
551
+ }
552
+
553
+ listRunChecks(runId: string): AgentLoopRunCheck[] {
554
+ const rows = this.db
555
+ .prepare("select run_id, kind, status, details_json, created_at from run_checks where run_id = ? order by created_at desc")
556
+ .all(runId);
557
+ return (rows as unknown as RunCheckRow[]).map(fromRunCheckRow);
558
+ }
559
+
560
+ updateRunStatus(
561
+ runId: string,
562
+ expectedVersion: number,
563
+ status: AgentLoopStatus,
564
+ options: {
565
+ currentState?: string;
566
+ branch?: string;
567
+ worktreeClean?: boolean;
568
+ stoppedAt?: string;
569
+ } = {}
570
+ ): AgentLoopRun {
571
+ const updatedAt = now();
572
+ return this.transaction(() => {
573
+ const result = this.db
574
+ .prepare(
575
+ `update runs
576
+ set status = ?,
577
+ current_state = coalesce(?, current_state),
578
+ branch = coalesce(?, branch),
579
+ worktree_clean = coalesce(?, worktree_clean),
580
+ stopped_at = coalesce(?, stopped_at),
581
+ version = version + 1,
582
+ updated_at = ?
583
+ where id = ? and version = ?`
584
+ )
585
+ .run(
586
+ status,
587
+ options.currentState ?? null,
588
+ options.branch ?? null,
589
+ boolToDb(options.worktreeClean),
590
+ options.stoppedAt ?? null,
591
+ updatedAt,
592
+ runId,
593
+ expectedVersion
594
+ );
595
+
596
+ if (result.changes !== 1) {
597
+ throw new AgentLoopError(
598
+ "version_conflict",
599
+ `Run ${runId} was updated by another writer.`,
600
+ { details: { runId, expectedVersion } }
601
+ );
602
+ }
603
+
604
+ const run = this.getRun(runId);
605
+ this.db
606
+ .prepare(
607
+ `insert into states (run_id, status, state, version, payload_json, created_at)
608
+ values (?, ?, ?, ?, null, ?)`
609
+ )
610
+ .run(run.id, run.status, run.currentState ?? run.status, run.version, run.updatedAt);
611
+ return run;
612
+ });
613
+ }
614
+
615
+ appendEvent(event: Omit<AgentLoopEvent, "id" | "seq" | "createdAt">): AgentLoopEvent {
616
+ const stored = {
617
+ id: randomUUID(),
618
+ ...event,
619
+ createdAt: now()
620
+ };
621
+ let seq = 0;
622
+ this.transaction(() => {
623
+ this.db
624
+ .prepare(
625
+ `insert into events (
626
+ id, run_id, kind, message, state_before, state_after, payload_json, artifact_ids_json, created_at
627
+ )
628
+ values (?, ?, ?, ?, ?, ?, ?, ?, ?)`
629
+ )
630
+ .run(
631
+ stored.id,
632
+ stored.runId ?? null,
633
+ stored.kind,
634
+ stored.message,
635
+ stored.stateBefore ?? null,
636
+ stored.stateAfter ?? null,
637
+ stored.payload === undefined ? null : JSON.stringify(stored.payload),
638
+ stored.artifactIds === undefined ? null : JSON.stringify(stored.artifactIds),
639
+ stored.createdAt
640
+ );
641
+ seq = Number((this.db.prepare("select last_insert_rowid() as seq").get() as { seq: number }).seq);
642
+ });
643
+ return { seq, ...stored };
644
+ }
645
+
646
+ writeGate(gate: {
647
+ runId?: string;
648
+ kind: AgentLoopGateKind;
649
+ message: string;
650
+ details?: unknown;
651
+ }): void {
652
+ this.transaction(() => {
653
+ this.db
654
+ .prepare(
655
+ `insert into gates (id, run_id, kind, status, message, details_json, created_at, resolved_at)
656
+ values (?, ?, ?, 'open', ?, ?, ?, null)`
657
+ )
658
+ .run(
659
+ randomUUID(),
660
+ gate.runId ?? null,
661
+ gate.kind,
662
+ gate.message,
663
+ gate.details === undefined ? null : JSON.stringify(gate.details),
664
+ now()
665
+ );
666
+ });
667
+ }
668
+
669
+ resolveOpenGates(runId: string): void {
670
+ this.transaction(() => {
671
+ this.db
672
+ .prepare(
673
+ `update gates
674
+ set status = 'resolved', resolved_at = ?
675
+ where run_id = ? and status = 'open'`
676
+ )
677
+ .run(now(), runId);
678
+ });
679
+ }
680
+
681
+ resolveOpenGatesByKind(
682
+ kind: AgentLoopGateKind,
683
+ options: { scope?: "repo" | "run" | "all"; runId?: string } = {}
684
+ ): void {
685
+ const scope = options.scope ?? (options.runId ? "run" : "repo");
686
+ this.transaction(() => {
687
+ if (scope === "run") {
688
+ if (!options.runId) {
689
+ throw new AgentLoopError("storage_error", "runId is required for run-scoped gate recovery.");
690
+ }
691
+ this.db
692
+ .prepare(
693
+ `update gates
694
+ set status = 'resolved', resolved_at = ?
695
+ where kind = ? and run_id = ? and status = 'open'`
696
+ )
697
+ .run(now(), kind, options.runId);
698
+ return;
699
+ }
700
+ if (scope === "repo") {
701
+ this.db
702
+ .prepare(
703
+ `update gates
704
+ set status = 'resolved', resolved_at = ?
705
+ where kind = ? and run_id is null and status = 'open'`
706
+ )
707
+ .run(now(), kind);
708
+ return;
709
+ }
710
+ this.db
711
+ .prepare(
712
+ `update gates
713
+ set status = 'resolved', resolved_at = ?
714
+ where kind = ? and status = 'open'`
715
+ )
716
+ .run(now(), kind);
717
+ });
718
+ }
719
+
720
+ listGates(runId?: string): AgentLoopGate[] {
721
+ const sql = `select id, run_id, kind, status, message, details_json, created_at,
722
+ resolved_at, decision_note, decided_at
723
+ from gates
724
+ ${runId ? "where run_id = ?" : ""}
725
+ order by created_at desc
726
+ limit 100`;
727
+ const rows = runId
728
+ ? this.db.prepare(sql).all(runId)
729
+ : this.db.prepare(sql).all();
730
+ return (rows as unknown as GateRow[]).map(fromGateRow);
731
+ }
732
+
733
+ getGate(gateId: string): AgentLoopGate | undefined {
734
+ const row = this.db
735
+ .prepare(
736
+ `select id, run_id, kind, status, message, details_json, created_at,
737
+ resolved_at, decision_note, decided_at
738
+ from gates
739
+ where id = ?`
740
+ )
741
+ .get(gateId) as GateRow | undefined;
742
+ return row ? fromGateRow(row) : undefined;
743
+ }
744
+
745
+ decideGate(gateId: string, decision: "approved" | "rejected", note: string): AgentLoopGate {
746
+ if (note.trim().length === 0) {
747
+ throw new AgentLoopError("invalid_config", "Gate decision note is required.");
748
+ }
749
+ const decidedAt = now();
750
+ this.transaction(() => {
751
+ const result = this.db
752
+ .prepare(
753
+ `update gates
754
+ set status = ?, decision_note = ?, decided_at = ?, resolved_at = coalesce(resolved_at, ?)
755
+ where id = ? and status = 'open'`
756
+ )
757
+ .run(decision, note, decidedAt, decidedAt, gateId);
758
+ if (result.changes !== 1) {
759
+ const gate = this.getGate(gateId);
760
+ if (!gate) {
761
+ throw new AgentLoopError("storage_error", `Gate not found: ${gateId}`);
762
+ }
763
+ throw new AgentLoopError("storage_error", `Gate ${gateId} is not open.`, {
764
+ details: { gateId, status: gate.status }
765
+ });
766
+ }
767
+ });
768
+ const gate = this.getGate(gateId);
769
+ if (!gate) {
770
+ throw new AgentLoopError("storage_error", `Gate not found after decision: ${gateId}`);
771
+ }
772
+ return gate;
773
+ }
774
+
775
+ getCurrentStatus(): {
776
+ status: AgentLoopStatus;
777
+ run?: AgentLoopRun;
778
+ gate?: {
779
+ kind: AgentLoopGateKind;
780
+ message: string;
781
+ details?: unknown;
782
+ };
783
+ } {
784
+ const repoGate = this.db
785
+ .prepare(
786
+ `select kind, message, details_json
787
+ from gates
788
+ where status = 'open' and run_id is null
789
+ order by created_at desc
790
+ limit 1`
791
+ )
792
+ .get() as
793
+ | { kind: AgentLoopGateKind; message: string; details_json: string | null }
794
+ | undefined;
795
+
796
+ if (repoGate) {
797
+ return {
798
+ status: "BLOCKED",
799
+ gate: statusGateFromRow(repoGate)
800
+ };
801
+ }
802
+
803
+ const row = this.db
804
+ .prepare(
805
+ `select id, status, current_state, version, branch, worktree_clean, started_at, stopped_at, created_at, updated_at
806
+ from runs
807
+ order by updated_at desc, rowid desc
808
+ limit 1`
809
+ )
810
+ .get() as RunRow | undefined;
811
+
812
+ if (!row) {
813
+ return { status: "IDLE" };
814
+ }
815
+ const run = fromRunRow(row);
816
+ const runGate = this.db
817
+ .prepare(
818
+ `select kind, message, details_json
819
+ from gates
820
+ where status = 'open' and run_id = ?
821
+ order by created_at desc
822
+ limit 1`
823
+ )
824
+ .get(run.id) as
825
+ | { kind: AgentLoopGateKind; message: string; details_json: string | null }
826
+ | undefined;
827
+
828
+ if (runGate) {
829
+ return {
830
+ status: "BLOCKED",
831
+ run,
832
+ gate: statusGateFromRow(runGate)
833
+ };
834
+ }
835
+ if (run.status === "BLOCKED" && latestGateSatisfied(this.db, run.id)) {
836
+ return { status: "READY", run: { ...run, status: "READY" } };
837
+ }
838
+ return { status: run.status, run };
839
+ }
840
+
841
+ listEvents(options: number | { sinceSeq?: number; limit?: number } = 50): AgentLoopEvent[] {
842
+ const limit = typeof options === "number" ? options : options.limit ?? 50;
843
+ const sinceSeq = typeof options === "number" ? undefined : options.sinceSeq;
844
+ const rows = sinceSeq === undefined
845
+ ? this.db
846
+ .prepare(
847
+ `select seq, id, run_id, kind, message, state_before, state_after, payload_json, artifact_ids_json, created_at
848
+ from events
849
+ order by seq desc
850
+ limit ?`
851
+ )
852
+ .all(limit) as unknown as EventRow[]
853
+ : this.db
854
+ .prepare(
855
+ `select seq, id, run_id, kind, message, state_before, state_after, payload_json, artifact_ids_json, created_at
856
+ from events
857
+ where seq > ?
858
+ order by seq asc
859
+ limit ?`
860
+ )
861
+ .all(sinceSeq, limit) as unknown as EventRow[];
862
+ return rows.map(fromEventRow);
863
+ }
864
+
865
+ findLatestEvent(runId: string, kind: string): AgentLoopEvent | undefined {
866
+ const row = this.db
867
+ .prepare(
868
+ `select seq, id, run_id, kind, message, state_before, state_after, payload_json, artifact_ids_json, created_at
869
+ from events
870
+ where run_id = ? and kind = ?
871
+ order by seq desc
872
+ limit 1`
873
+ )
874
+ .get(runId, kind) as EventRow | undefined;
875
+ return row ? fromEventRow(row) : undefined;
876
+ }
877
+
878
+ listAgentTimeline(query: AgentTimelineQuery = {}): AgentTimelinePage {
879
+ const limit = clampLimit(query.limit ?? 50);
880
+ const cursor = query.cursor ? decodeTimelineCursor(query.cursor) : undefined;
881
+ const params: SQLInputValue[] = [];
882
+ const where: string[] = [];
883
+ if (cursor) {
884
+ where.push("(created_at < ? or (created_at = ? and timeline_seq < ?))");
885
+ params.push(cursor.occurredAt, cursor.occurredAt, cursor.timelineSeq);
886
+ }
887
+ if (query.sources?.length) {
888
+ const sources = normalizeTimelineSources(query.sources);
889
+ where.push(`source in (${sources.map(() => "?").join(", ")})`);
890
+ params.push(...sources);
891
+ }
892
+ if (query.runId) {
893
+ where.push("run_id = ?");
894
+ params.push(query.runId);
895
+ }
896
+ if (query.workerId) {
897
+ where.push("worker_id = ?");
898
+ params.push(query.workerId);
899
+ }
900
+ params.push(limit + 1);
901
+ const rows = this.db
902
+ .prepare(
903
+ `select timeline_seq, source, source_id, source_seq, run_id, worker_id, created_at
904
+ from timeline_index
905
+ ${where.length ? `where ${where.join(" and ")}` : ""}
906
+ order by created_at desc, timeline_seq desc
907
+ limit ?`
908
+ )
909
+ .all(...params) as unknown as TimelineIndexRow[];
910
+ const pageRows = rows.slice(0, limit);
911
+ const entries = pageRows
912
+ .map((row) => this.timelineEntry(row))
913
+ .filter((entry): entry is AgentTimelineEntry => entry !== undefined);
914
+ const last = pageRows[pageRows.length - 1];
915
+ return {
916
+ entries,
917
+ ...(rows.length > limit && last ? { nextCursor: encodeTimelineCursor(last.timeline_seq, last.created_at) } : {})
918
+ };
919
+ }
920
+
921
+ checkTimelineIntegrity(): AgentTimelineIntegrityReport {
922
+ const missingTable = !hasTable(this.db, "timeline_index");
923
+ const triggers = new Set((this.db
924
+ .prepare("select name from sqlite_master where type = 'trigger' and name like 'timeline_%'")
925
+ .all() as Array<{ name: string }>).map((row) => row.name));
926
+ const missingTriggers = TIMELINE_TRIGGER_NAMES.filter((name) => !triggers.has(name));
927
+ const sourceCounts = Object.fromEntries(TIMELINE_SOURCES.map((source) => [source, 0])) as Record<AgentTimelineSource, number>;
928
+ const missingSourceRows: Array<{ source: AgentTimelineSource; missing: number }> = [];
929
+ if (!missingTable) {
930
+ const rows = this.db
931
+ .prepare("select source, count(*) as count from timeline_index group by source")
932
+ .all() as Array<{ source: AgentTimelineSource; count: number }>;
933
+ for (const row of rows) {
934
+ if ((TIMELINE_SOURCES as readonly string[]).includes(row.source)) {
935
+ sourceCounts[row.source] = row.count;
936
+ }
937
+ }
938
+ missingSourceRows.push(...timelineMissingSourceRows(this.db));
939
+ }
940
+ const ok = !missingTable && missingTriggers.length === 0 && missingSourceRows.length === 0;
941
+ return {
942
+ ok,
943
+ missingTable,
944
+ missingTriggers,
945
+ missingSourceRows,
946
+ sourceCounts,
947
+ repair: "Run storage migration or rebuild timeline_index by dropping timeline_index/triggers and reopening storage in read-write mode."
948
+ };
949
+ }
950
+
951
+ upsertPrLink(link: Omit<AgentLoopPrLink, "id" | "createdAt" | "updatedAt">): AgentLoopPrLink {
952
+ const createdAt = now();
953
+ const id = randomUUID();
954
+ this.transaction(() => {
955
+ this.db
956
+ .prepare(
957
+ `insert into pr_links (
958
+ id, run_id, branch, pr_number, url, head_ref, base_ref, state, draft, created_at, updated_at
959
+ )
960
+ values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
961
+ on conflict(run_id, pr_number) do update set
962
+ branch = excluded.branch,
963
+ url = excluded.url,
964
+ head_ref = excluded.head_ref,
965
+ base_ref = excluded.base_ref,
966
+ state = excluded.state,
967
+ draft = excluded.draft,
968
+ updated_at = excluded.updated_at`
969
+ )
970
+ .run(
971
+ id,
972
+ link.runId,
973
+ link.branch,
974
+ link.prNumber,
975
+ link.url,
976
+ link.headRef,
977
+ link.baseRef,
978
+ link.state,
979
+ boolToDb(link.draft),
980
+ createdAt,
981
+ createdAt
982
+ );
983
+ });
984
+ const stored = this.getPrLink(link.runId);
985
+ if (!stored) {
986
+ throw new AgentLoopError("storage_error", "PR link was not stored.");
987
+ }
988
+ return stored;
989
+ }
990
+
991
+ getPrLink(runId: string): AgentLoopPrLink | undefined {
992
+ const row = this.db
993
+ .prepare(
994
+ `select id, run_id, branch, pr_number, url, head_ref, base_ref, state, draft, created_at, updated_at
995
+ from pr_links
996
+ where run_id = ?
997
+ order by updated_at desc
998
+ limit 1`
999
+ )
1000
+ .get(runId) as PrLinkRow | undefined;
1001
+ return row ? fromPrLinkRow(row) : undefined;
1002
+ }
1003
+
1004
+ replaceCiChecks(
1005
+ runId: string,
1006
+ prNumber: number,
1007
+ checks: Array<Omit<AgentLoopCiCheck, "id" | "runId" | "prNumber" | "observedAt">>
1008
+ ): AgentLoopCiCheck[] {
1009
+ const observedAt = now();
1010
+ this.transaction(() => {
1011
+ this.db.prepare("delete from ci_checks where run_id = ? and pr_number = ?").run(runId, prNumber);
1012
+ for (const check of checks) {
1013
+ this.db
1014
+ .prepare(
1015
+ `insert into ci_checks (
1016
+ id, run_id, pr_number, name, status, conclusion, url, started_at, completed_at, observed_at
1017
+ )
1018
+ values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
1019
+ )
1020
+ .run(
1021
+ randomUUID(),
1022
+ runId,
1023
+ prNumber,
1024
+ check.name,
1025
+ check.status,
1026
+ check.conclusion ?? null,
1027
+ check.url ?? null,
1028
+ check.startedAt ?? null,
1029
+ check.completedAt ?? null,
1030
+ observedAt
1031
+ );
1032
+ }
1033
+ });
1034
+ return this.listCiChecks(runId);
1035
+ }
1036
+
1037
+ listCiChecks(runId: string): AgentLoopCiCheck[] {
1038
+ const rows = this.db
1039
+ .prepare(
1040
+ `select id, run_id, pr_number, name, status, conclusion, url, started_at, completed_at, observed_at
1041
+ from ci_checks
1042
+ where run_id = ?
1043
+ order by observed_at desc, name asc`
1044
+ )
1045
+ .all(runId) as unknown as CiCheckRow[];
1046
+ return rows.map(fromCiCheckRow);
1047
+ }
1048
+
1049
+ replaceReviewComments(
1050
+ runId: string,
1051
+ prNumber: number,
1052
+ comments: Array<Omit<AgentLoopReviewComment, "id" | "runId" | "prNumber" | "observedAt">>
1053
+ ): AgentLoopReviewComment[] {
1054
+ const observedAt = now();
1055
+ this.transaction(() => {
1056
+ this.db.prepare("delete from review_comments where run_id = ? and pr_number = ?").run(runId, prNumber);
1057
+ for (const comment of comments) {
1058
+ this.db
1059
+ .prepare(
1060
+ `insert into review_comments (
1061
+ id, run_id, pr_number, comment_id, url, author, body, path, line, diff_hunk,
1062
+ is_resolved, is_outdated, actionable, status, observed_at
1063
+ )
1064
+ values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
1065
+ )
1066
+ .run(
1067
+ randomUUID(),
1068
+ runId,
1069
+ prNumber,
1070
+ comment.commentId,
1071
+ comment.url,
1072
+ comment.author,
1073
+ comment.body,
1074
+ comment.path,
1075
+ comment.line ?? null,
1076
+ comment.diffHunk,
1077
+ boolToDb(comment.isResolved),
1078
+ boolToDb(comment.isOutdated),
1079
+ boolToDb(comment.actionable),
1080
+ comment.status,
1081
+ observedAt
1082
+ );
1083
+ }
1084
+ });
1085
+ return this.listReviewComments(runId);
1086
+ }
1087
+
1088
+ listReviewComments(runId: string): AgentLoopReviewComment[] {
1089
+ const rows = this.db
1090
+ .prepare(
1091
+ `select id, run_id, pr_number, comment_id, url, author, body, path, line, diff_hunk,
1092
+ is_resolved, is_outdated, actionable, status, observed_at
1093
+ from review_comments
1094
+ where run_id = ?
1095
+ order by observed_at desc, path asc`
1096
+ )
1097
+ .all(runId) as unknown as ReviewCommentRow[];
1098
+ return rows.map(fromReviewCommentRow);
1099
+ }
1100
+
1101
+ appendDecision(decision: Omit<AgentLoopDecision, "id" | "createdAt">): AgentLoopDecision {
1102
+ const stored: AgentLoopDecision = { id: randomUUID(), ...decision, createdAt: now() };
1103
+ this.transaction(() => {
1104
+ this.db
1105
+ .prepare(
1106
+ `insert into decisions (id, run_id, kind, message, details_json, created_at)
1107
+ values (?, ?, ?, ?, ?, ?)`
1108
+ )
1109
+ .run(
1110
+ stored.id,
1111
+ stored.runId,
1112
+ stored.kind,
1113
+ stored.message,
1114
+ stored.details === undefined ? null : JSON.stringify(stored.details),
1115
+ stored.createdAt
1116
+ );
1117
+ });
1118
+ return stored;
1119
+ }
1120
+
1121
+ listDecisions(runId: string): AgentLoopDecision[] {
1122
+ const rows = this.db
1123
+ .prepare(
1124
+ `select id, run_id, kind, message, details_json, created_at
1125
+ from decisions
1126
+ where run_id = ?
1127
+ order by created_at desc`
1128
+ )
1129
+ .all(runId) as unknown as DecisionRow[];
1130
+ return rows.map(fromDecisionRow);
1131
+ }
1132
+
1133
+ createWorker(worker: {
1134
+ runId: string;
1135
+ type: WorkerType;
1136
+ backend: string;
1137
+ attempt: number;
1138
+ resumeUsed: boolean;
1139
+ }): WorkerRun {
1140
+ const id = randomUUID();
1141
+ const startedAt = now();
1142
+ try {
1143
+ this.transaction(() => {
1144
+ this.db
1145
+ .prepare(
1146
+ `insert into workers (
1147
+ id, run_id, type, backend, status, thread_id, attempt, resume_used,
1148
+ started_at, completed_at, exit_code, result_artifact_id, raw_jsonl_artifact_id, error
1149
+ )
1150
+ values (?, ?, ?, ?, 'running', null, ?, ?, ?, null, null, null, null, null)`
1151
+ )
1152
+ .run(id, worker.runId, worker.type, worker.backend, worker.attempt, boolToDb(worker.resumeUsed), startedAt);
1153
+ });
1154
+ } catch (error) {
1155
+ if (isUniqueConstraintError(error)) {
1156
+ throw new AgentLoopError("worker_already_running", "Another worker is already running.", {
1157
+ details: { runId: worker.runId },
1158
+ exitCode: 2
1159
+ });
1160
+ }
1161
+ throw error;
1162
+ }
1163
+ return this.getWorker(id);
1164
+ }
1165
+
1166
+ updateWorker(workerId: string, patch: {
1167
+ status?: WorkerStatus;
1168
+ threadId?: string;
1169
+ completedAt?: string;
1170
+ exitCode?: number;
1171
+ resultArtifactId?: string;
1172
+ rawJsonlArtifactId?: string;
1173
+ error?: string;
1174
+ }): WorkerRun {
1175
+ this.transaction(() => {
1176
+ this.db
1177
+ .prepare(
1178
+ `update workers
1179
+ set status = coalesce(?, status),
1180
+ thread_id = coalesce(?, thread_id),
1181
+ completed_at = coalesce(?, completed_at),
1182
+ exit_code = coalesce(?, exit_code),
1183
+ result_artifact_id = coalesce(?, result_artifact_id),
1184
+ raw_jsonl_artifact_id = coalesce(?, raw_jsonl_artifact_id),
1185
+ error = coalesce(?, error)
1186
+ where id = ?`
1187
+ )
1188
+ .run(
1189
+ patch.status ?? null,
1190
+ patch.threadId ?? null,
1191
+ patch.completedAt ?? null,
1192
+ patch.exitCode ?? null,
1193
+ patch.resultArtifactId ?? null,
1194
+ patch.rawJsonlArtifactId ?? null,
1195
+ patch.error ?? null,
1196
+ workerId
1197
+ );
1198
+ });
1199
+ return this.getWorker(workerId);
1200
+ }
1201
+
1202
+ getRunningWorker(): WorkerRun | undefined {
1203
+ const row = this.db
1204
+ .prepare(
1205
+ `select id, run_id, type, backend, status, thread_id, attempt, resume_used,
1206
+ started_at, completed_at, exit_code, result_artifact_id, raw_jsonl_artifact_id, error
1207
+ from workers
1208
+ where status = 'running'
1209
+ order by started_at desc
1210
+ limit 1`
1211
+ )
1212
+ .get() as WorkerRow | undefined;
1213
+ return row ? fromWorkerRow(row) : undefined;
1214
+ }
1215
+
1216
+ listWorkers(runId?: string, limit = 50): WorkerRun[] {
1217
+ const rows = runId
1218
+ ? this.listWorkersByRunStatement.all(runId, limit) as unknown as WorkerRow[]
1219
+ : this.listWorkersStatement.all(limit) as unknown as WorkerRow[];
1220
+ return rows.map(fromWorkerRow);
1221
+ }
1222
+
1223
+ appendWorkerEvent(event: Omit<WorkerEvent, "id" | "seq" | "createdAt">): WorkerEvent {
1224
+ const existing = this.findDuplicateWorkerEvent(event);
1225
+ if (existing) {
1226
+ return existing;
1227
+ }
1228
+ const stored = { id: randomUUID(), ...event, createdAt: now() };
1229
+ let seq = 0;
1230
+ this.transaction(() => {
1231
+ this.db
1232
+ .prepare(
1233
+ `insert into worker_events (
1234
+ id, worker_id, run_id, event_type, item_type, item_id, item_status,
1235
+ thread_id, backend, summary_json, usage_json, artifact_ids_json, created_at
1236
+ )
1237
+ values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
1238
+ )
1239
+ .run(
1240
+ stored.id,
1241
+ stored.workerId,
1242
+ stored.runId,
1243
+ stored.eventType,
1244
+ stored.itemType ?? null,
1245
+ stored.itemId ?? null,
1246
+ stored.itemStatus ?? null,
1247
+ stored.threadId ?? null,
1248
+ stored.backend ?? null,
1249
+ stored.summary === undefined ? null : JSON.stringify(stored.summary),
1250
+ stored.usage === undefined ? null : JSON.stringify(stored.usage),
1251
+ stored.artifactIds === undefined ? null : JSON.stringify(stored.artifactIds),
1252
+ stored.createdAt
1253
+ );
1254
+ seq = Number((this.db.prepare("select last_insert_rowid() as seq").get() as { seq: number }).seq);
1255
+ });
1256
+ return { seq, ...stored };
1257
+ }
1258
+
1259
+ listWorkerEvents(workerId: string): WorkerEvent[] {
1260
+ const rows = this.db
1261
+ .prepare(
1262
+ `select seq, id, worker_id, run_id, event_type, item_type, item_id, item_status,
1263
+ thread_id, backend, summary_json, usage_json, artifact_ids_json, created_at
1264
+ from worker_events
1265
+ where worker_id = ?
1266
+ order by seq asc`
1267
+ )
1268
+ .all(workerId) as unknown as WorkerEventRow[];
1269
+ return rows.map(fromWorkerEventRow);
1270
+ }
1271
+
1272
+ private findDuplicateWorkerEvent(event: Omit<WorkerEvent, "id" | "seq" | "createdAt">): WorkerEvent | undefined {
1273
+ if (!event.threadId) {
1274
+ return undefined;
1275
+ }
1276
+ const row = event.itemId
1277
+ ? this.db
1278
+ .prepare(
1279
+ `select seq, id, worker_id, run_id, event_type, item_type, item_id, item_status,
1280
+ thread_id, backend, summary_json, usage_json, artifact_ids_json, created_at
1281
+ from worker_events
1282
+ where thread_id = ? and item_id = ? and coalesce(item_status, '') = ?
1283
+ limit 1`
1284
+ )
1285
+ .get(event.threadId, event.itemId, event.itemStatus ?? "") as WorkerEventRow | undefined
1286
+ : this.db
1287
+ .prepare(
1288
+ `select seq, id, worker_id, run_id, event_type, item_type, item_id, item_status,
1289
+ thread_id, backend, summary_json, usage_json, artifact_ids_json, created_at
1290
+ from worker_events
1291
+ where thread_id = ? and event_type = ? and item_id is null
1292
+ limit 1`
1293
+ )
1294
+ .get(event.threadId, event.eventType) as WorkerEventRow | undefined;
1295
+ return row ? fromWorkerEventRow(row) : undefined;
1296
+ }
1297
+
1298
+ insertArtifact(record: AgentLoopArtifactRecord): void {
1299
+ this.transaction(() => {
1300
+ this.db
1301
+ .prepare(
1302
+ `insert into artifacts (id, run_id, kind, name, path, sha256, metadata_json, created_at)
1303
+ values (?, ?, ?, ?, ?, ?, null, ?)`
1304
+ )
1305
+ .run(
1306
+ record.id,
1307
+ record.runId,
1308
+ record.kind,
1309
+ record.name,
1310
+ record.path,
1311
+ record.sha256,
1312
+ record.createdAt
1313
+ );
1314
+ });
1315
+ }
1316
+
1317
+ getArtifact(artifactId: string): AgentLoopArtifactRecord {
1318
+ const row = this.db
1319
+ .prepare(
1320
+ `select id, run_id, kind, name, path, sha256, created_at
1321
+ from artifacts
1322
+ where id = ?`
1323
+ )
1324
+ .get(artifactId) as ArtifactRow | undefined;
1325
+ if (!row) {
1326
+ throw new AgentLoopError("storage_error", `Artifact not found: ${artifactId}`);
1327
+ }
1328
+ return fromArtifactRow(row);
1329
+ }
1330
+
1331
+ listArtifacts(runId: string): AgentLoopArtifactRecord[] {
1332
+ const rows = this.db
1333
+ .prepare(
1334
+ `select id, run_id, kind, name, path, sha256, created_at
1335
+ from artifacts
1336
+ where run_id = ?
1337
+ order by created_at asc`
1338
+ )
1339
+ .all(runId) as unknown as ArtifactRow[];
1340
+ return rows.map(fromArtifactRow);
1341
+ }
1342
+
1343
+ linkArtifactToEvent(eventId: string, artifactId: string): void {
1344
+ this.transaction(() => {
1345
+ const row = this.db
1346
+ .prepare("select artifact_ids_json from events where id = ?")
1347
+ .get(eventId) as { artifact_ids_json: string | null } | undefined;
1348
+ if (!row) {
1349
+ throw new AgentLoopError("storage_error", `Event not found: ${eventId}`);
1350
+ }
1351
+ const ids = row.artifact_ids_json
1352
+ ? (parseJson(row.artifact_ids_json, "Stored artifact id list is invalid.") as string[])
1353
+ : [];
1354
+ if (!ids.includes(artifactId)) {
1355
+ ids.push(artifactId);
1356
+ }
1357
+ this.db
1358
+ .prepare("update events set artifact_ids_json = ? where id = ?")
1359
+ .run(JSON.stringify(ids), eventId);
1360
+ });
1361
+ }
1362
+
1363
+ getCurrentRun(): AgentLoopRun | undefined {
1364
+ const row = this.db
1365
+ .prepare(
1366
+ `select id, status, current_state, version, branch, worktree_clean, started_at, stopped_at, created_at, updated_at
1367
+ from runs
1368
+ order by updated_at desc
1369
+ limit 1`
1370
+ )
1371
+ .get() as RunRow | undefined;
1372
+ return row ? fromRunRow(row) : undefined;
1373
+ }
1374
+
1375
+ listRuns(limit = 50): AgentLoopRun[] {
1376
+ const rows = this.db
1377
+ .prepare(
1378
+ `select id, status, current_state, version, branch, worktree_clean, started_at, stopped_at, created_at, updated_at
1379
+ from runs
1380
+ order by updated_at desc
1381
+ limit ?`
1382
+ )
1383
+ .all(limit) as unknown as RunRow[];
1384
+ return rows.map(fromRunRow);
1385
+ }
1386
+
1387
+ /** Run a group of read queries against one SQLite snapshot. */
1388
+ readTransaction<T>(fn: () => T): T {
1389
+ this.db.exec("BEGIN");
1390
+ try {
1391
+ const result = fn();
1392
+ this.db.exec("COMMIT");
1393
+ return result;
1394
+ } catch (error) {
1395
+ try {
1396
+ this.db.exec("ROLLBACK");
1397
+ } catch (rollbackError) {
1398
+ throw new AgentLoopError("storage_error", "Read transaction rollback failed.", {
1399
+ details: {
1400
+ cause: error instanceof Error ? error.message : String(error),
1401
+ rollback: rollbackError instanceof Error ? rollbackError.message : String(rollbackError)
1402
+ }
1403
+ });
1404
+ }
1405
+ throw error;
1406
+ }
1407
+ }
1408
+
1409
+ private ensureSchema(): void {
1410
+ const currentVersion = this.getUserVersion();
1411
+ if (currentVersion !== 0 && !isSupportedSchemaVersion(currentVersion)) {
1412
+ throw new AgentLoopError(
1413
+ "storage_schema_mismatch",
1414
+ `SQLite schema version ${currentVersion} is not supported.`,
1415
+ { details: { expected: STORAGE_SCHEMA_VERSION, actual: currentVersion } }
1416
+ );
1417
+ }
1418
+
1419
+ if (currentVersion === STORAGE_SCHEMA_VERSION) {
1420
+ if (this.mode !== "ro") {
1421
+ this.transaction(() => this.reconcileHighFidelityWorkerEventsV8());
1422
+ }
1423
+ return;
1424
+ }
1425
+
1426
+ if (this.mode === "ro") {
1427
+ throw new AgentLoopError(
1428
+ "storage_schema_mismatch",
1429
+ `SQLite schema version ${currentVersion} requires migration before read-only use.`,
1430
+ { details: { expected: STORAGE_SCHEMA_VERSION, actual: currentVersion } }
1431
+ );
1432
+ }
1433
+
1434
+ this.transaction(() => {
1435
+ const lockedVersion = this.getUserVersion();
1436
+ if (lockedVersion === STORAGE_SCHEMA_VERSION) {
1437
+ return;
1438
+ }
1439
+ if (lockedVersion !== 0 && !isSupportedSchemaVersion(lockedVersion)) {
1440
+ throw new AgentLoopError(
1441
+ "storage_schema_mismatch",
1442
+ `SQLite schema version ${lockedVersion} is not supported.`,
1443
+ { details: { expected: STORAGE_SCHEMA_VERSION, actual: lockedVersion } }
1444
+ );
1445
+ }
1446
+ this.db.exec(SCHEMA_SQL);
1447
+ this.migratePrC();
1448
+ this.migratePrD();
1449
+ this.migratePrE();
1450
+ this.migrateF0();
1451
+ this.migrateTimelineV7();
1452
+ this.migrateHighFidelityWorkerEventsV8();
1453
+ this.markSchemaVersion();
1454
+ });
1455
+ }
1456
+
1457
+ private migratePrC(): void {
1458
+ addColumnIfMissing(this.db, "runs", "current_state", "text");
1459
+ addColumnIfMissing(this.db, "runs", "branch", "text");
1460
+ addColumnIfMissing(this.db, "runs", "worktree_clean", "integer");
1461
+ addColumnIfMissing(this.db, "runs", "started_at", "text");
1462
+ addColumnIfMissing(this.db, "runs", "stopped_at", "text");
1463
+ addColumnIfMissing(this.db, "states", "state", "text");
1464
+ addColumnIfMissing(this.db, "states", "payload_json", "text");
1465
+ addColumnIfMissing(this.db, "events", "state_before", "text");
1466
+ addColumnIfMissing(this.db, "events", "state_after", "text");
1467
+ addColumnIfMissing(this.db, "events", "artifact_ids_json", "text");
1468
+ addColumnIfMissing(this.db, "artifacts", "name", "text");
1469
+ addColumnIfMissing(this.db, "artifacts", "sha256", "text");
1470
+ this.db.exec(PR_C_TABLES_SQL);
1471
+ }
1472
+
1473
+ private migratePrD(): void {
1474
+ this.db.exec(PR_D_TABLES_SQL);
1475
+ }
1476
+
1477
+ private migratePrE(): void {
1478
+ addColumnIfMissing(this.db, "gates", "decision_note", "text");
1479
+ addColumnIfMissing(this.db, "gates", "decided_at", "text");
1480
+ this.db.exec(PR_E_TABLES_SQL);
1481
+ this.db.exec(PR_E_INDEXES_SQL);
1482
+ }
1483
+
1484
+ private migrateF0(): void {
1485
+ rebuildEventsWithSeq(this.db);
1486
+ rebuildWorkerEventsWithSeq(this.db);
1487
+ }
1488
+
1489
+ private migrateTimelineV7(): void {
1490
+ this.db.exec(TIMELINE_INDEX_SQL);
1491
+ this.db.exec(TIMELINE_TRIGGERS_SQL);
1492
+ backfillTimelineIndex(this.db);
1493
+ }
1494
+
1495
+ private migrateHighFidelityWorkerEventsV8(): void {
1496
+ addColumnIfMissing(this.db, "worker_events", "item_id", "text");
1497
+ addColumnIfMissing(this.db, "worker_events", "item_status", "text");
1498
+ addColumnIfMissing(this.db, "worker_events", "thread_id", "text");
1499
+ addColumnIfMissing(this.db, "worker_events", "backend", "text");
1500
+ addColumnIfMissing(this.db, "worker_events", "artifact_ids_json", "text");
1501
+ this.reconcileHighFidelityWorkerEventsV8();
1502
+ }
1503
+
1504
+ private reconcileHighFidelityWorkerEventsV8(): void {
1505
+ dedupeHighFidelityWorkerEventsV8(this.db);
1506
+ this.db.exec(`
1507
+ drop index if exists worker_events_thread_item_unique;
1508
+ create unique index if not exists worker_events_thread_item_status_unique
1509
+ on worker_events(thread_id, item_id, coalesce(item_status, ''))
1510
+ where item_id is not null;
1511
+ create unique index if not exists worker_events_thread_event_unique
1512
+ on worker_events(thread_id, event_type)
1513
+ where item_id is null;
1514
+ `);
1515
+ }
1516
+
1517
+ private markSchemaVersion(): void {
1518
+ this.db.exec(`PRAGMA user_version = ${STORAGE_SCHEMA_VERSION}`);
1519
+ }
1520
+
1521
+ private ensureRepoConfigVersion(): void {
1522
+ this.validateRepoConfigVersion(true);
1523
+ }
1524
+
1525
+ private validateRepoConfigVersion(rewrite = false): void {
1526
+ let row: { schema_version: number; config_json: string } | undefined;
1527
+ try {
1528
+ row = this.db
1529
+ .prepare("select schema_version, config_json from repo_config where id = 1")
1530
+ .get() as { schema_version: number; config_json: string } | undefined;
1531
+ } catch (error) {
1532
+ throw toStorageError(error, "Could not read stored repo config metadata.");
1533
+ }
1534
+ if (!row) {
1535
+ return;
1536
+ }
1537
+ if (!isSupportedSchemaVersion(row.schema_version)) {
1538
+ throw new AgentLoopError(
1539
+ "storage_schema_mismatch",
1540
+ `Stored repo config schema version ${row.schema_version} is not supported.`,
1541
+ { details: { expected: STORAGE_SCHEMA_VERSION, actual: row.schema_version } }
1542
+ );
1543
+ }
1544
+ const parsed = parseJson(row.config_json, "Stored repo config snapshot JSON is invalid.") as {
1545
+ schemaVersion?: number;
1546
+ repoId?: string;
1547
+ };
1548
+ if (parsed.schemaVersion === STORAGE_SCHEMA_VERSION) {
1549
+ return;
1550
+ }
1551
+ if (rewrite && isSupportedSchemaVersion(parsed.schemaVersion ?? 0) && typeof parsed.repoId === "string") {
1552
+ this.writeRepoConfig(withConfigDefaults(parsed as Partial<AgentLoopConfig> & { repoId: string }));
1553
+ return;
1554
+ }
1555
+ throw new AgentLoopError("storage_error", "Stored repo config snapshot schemaVersion is invalid.", {
1556
+ details: { expected: STORAGE_SCHEMA_VERSION, actual: parsed.schemaVersion }
1557
+ });
1558
+ }
1559
+
1560
+ private getUserVersion(): number {
1561
+ const row = this.db.prepare("PRAGMA user_version").get() as { user_version: number };
1562
+ return row.user_version;
1563
+ }
1564
+
1565
+ private getRun(runId: string): AgentLoopRun {
1566
+ const row = this.db
1567
+ .prepare(
1568
+ `select id, status, current_state, version, branch, worktree_clean, started_at, stopped_at, created_at, updated_at
1569
+ from runs
1570
+ where id = ?`
1571
+ )
1572
+ .get(runId) as RunRow | undefined;
1573
+ if (!row) {
1574
+ throw new AgentLoopError("storage_error", `Run not found: ${runId}`);
1575
+ }
1576
+ return fromRunRow(row);
1577
+ }
1578
+
1579
+ private getActiveRun(): AgentLoopRun | undefined {
1580
+ const row = this.db
1581
+ .prepare(
1582
+ `select id, status, current_state, version, branch, worktree_clean, started_at, stopped_at, created_at, updated_at
1583
+ from runs
1584
+ where status = 'RUNNING'
1585
+ order by updated_at desc
1586
+ limit 1`
1587
+ )
1588
+ .get() as RunRow | undefined;
1589
+ return row ? fromRunRow(row) : undefined;
1590
+ }
1591
+
1592
+ private getWorker(workerId: string): WorkerRun {
1593
+ const row = this.db
1594
+ .prepare(
1595
+ `select id, run_id, type, backend, status, thread_id, attempt, resume_used,
1596
+ started_at, completed_at, exit_code, result_artifact_id, raw_jsonl_artifact_id, error
1597
+ from workers
1598
+ where id = ?`
1599
+ )
1600
+ .get(workerId) as WorkerRow | undefined;
1601
+ if (!row) {
1602
+ throw new AgentLoopError("storage_error", `Worker not found: ${workerId}`);
1603
+ }
1604
+ return fromWorkerRow(row);
1605
+ }
1606
+
1607
+ private timelineEntry(row: TimelineIndexRow): AgentTimelineEntry | undefined {
1608
+ if (!isTimelineSource(row.source)) {
1609
+ return undefined;
1610
+ }
1611
+ if (row.source === "event") {
1612
+ const sourceRow = this.db
1613
+ .prepare(
1614
+ `select seq, id, run_id, kind, message, artifact_ids_json, created_at
1615
+ from events where id = ?`
1616
+ )
1617
+ .get(row.source_id) as Pick<EventRow, "seq" | "id" | "run_id" | "kind" | "message" | "artifact_ids_json" | "created_at"> | undefined;
1618
+ if (!sourceRow) return undefined;
1619
+ const artifactIds = sourceRow.artifact_ids_json
1620
+ ? parseJson(sourceRow.artifact_ids_json, "Stored event artifact list JSON is invalid.") as string[]
1621
+ : undefined;
1622
+ return timelineEntry(row, {
1623
+ kind: sourceRow.kind,
1624
+ title: sourceRow.kind,
1625
+ summary: sourceRow.message,
1626
+ ...(sourceRow.run_id ? { runId: sourceRow.run_id } : {}),
1627
+ ...(artifactIds ? { artifactIds } : {}),
1628
+ rawRef: { table: "events", id: sourceRow.id, seq: sourceRow.seq }
1629
+ });
1630
+ }
1631
+ if (row.source === "worker_event") {
1632
+ const sourceRow = this.db
1633
+ .prepare(
1634
+ `select seq, id, worker_id, run_id, event_type, item_type, item_id, item_status,
1635
+ thread_id, backend, summary_json, usage_json, artifact_ids_json, created_at
1636
+ from worker_events where id = ?`
1637
+ )
1638
+ .get(row.source_id) as WorkerEventRow | undefined;
1639
+ if (!sourceRow) return undefined;
1640
+ const worker = this.db
1641
+ .prepare("select thread_id from workers where id = ?")
1642
+ .get(sourceRow.worker_id) as { thread_id: string | null } | undefined;
1643
+ const summary = sourceRow.summary_json
1644
+ ? summarizeTimelinePayload(parseJson(sourceRow.summary_json, "Stored worker event summary JSON is invalid."))
1645
+ : sourceRow.event_type;
1646
+ const artifactIds = sourceRow.artifact_ids_json
1647
+ ? parseJson(sourceRow.artifact_ids_json, "Stored worker event artifact list JSON is invalid.") as string[]
1648
+ : undefined;
1649
+ return timelineEntry(row, {
1650
+ kind: sourceRow.item_type ?? sourceRow.event_type,
1651
+ title: workerEventTimelineTitle(sourceRow),
1652
+ summary,
1653
+ runId: sourceRow.run_id,
1654
+ workerId: sourceRow.worker_id,
1655
+ ...(sourceRow.thread_id ? { threadId: sourceRow.thread_id } : worker?.thread_id ? { threadId: worker.thread_id } : {}),
1656
+ ...(sourceRow.item_status ? { status: sourceRow.item_status } : {}),
1657
+ ...(artifactIds?.length ? { artifactIds } : {}),
1658
+ rawRef: { table: "worker_events", id: sourceRow.id, seq: sourceRow.seq }
1659
+ });
1660
+ }
1661
+ if (row.source === "worker") {
1662
+ const sourceRow = this.db
1663
+ .prepare(
1664
+ `select id, run_id, type, backend, status, thread_id, attempt, resume_used,
1665
+ started_at, completed_at, exit_code, result_artifact_id, raw_jsonl_artifact_id, error
1666
+ from workers where id = ?`
1667
+ )
1668
+ .get(row.worker_id ?? workerIdFromSourceId(row.source_id)) as WorkerRow | undefined;
1669
+ if (!sourceRow) return undefined;
1670
+ const status = statusFromWorkerSourceId(row.source_id) ?? sourceRow.status;
1671
+ return timelineEntry(row, {
1672
+ kind: sourceRow.type,
1673
+ title: `${sourceRow.type} worker ${status}`,
1674
+ summary: summarizeTimelinePayload({
1675
+ status,
1676
+ attempt: sourceRow.attempt,
1677
+ backend: sourceRow.backend,
1678
+ exitCode: sourceRow.exit_code,
1679
+ error: sourceRow.error
1680
+ }),
1681
+ runId: sourceRow.run_id,
1682
+ workerId: sourceRow.id,
1683
+ ...(sourceRow.thread_id ? { threadId: sourceRow.thread_id } : {}),
1684
+ status,
1685
+ artifactIds: [sourceRow.result_artifact_id, sourceRow.raw_jsonl_artifact_id].filter((id): id is string => Boolean(id)),
1686
+ rawRef: { table: "workers", id: row.source_id }
1687
+ });
1688
+ }
1689
+ if (row.source === "state") {
1690
+ const sourceRow = this.db
1691
+ .prepare("select id, run_id, status, state, version, created_at from states where id = ?")
1692
+ .get(Number(row.source_id)) as Pick<StateRow, "id" | "run_id" | "status" | "state" | "version" | "created_at"> | undefined;
1693
+ if (!sourceRow) return undefined;
1694
+ return timelineEntry(row, {
1695
+ kind: sourceRow.state ?? sourceRow.status,
1696
+ title: "State changed",
1697
+ summary: summarizeTimelinePayload({ status: sourceRow.status, state: sourceRow.state, version: sourceRow.version }),
1698
+ ...(sourceRow.run_id ? { runId: sourceRow.run_id } : {}),
1699
+ status: sourceRow.status,
1700
+ rawRef: { table: "states", id: String(sourceRow.id), seq: sourceRow.id }
1701
+ });
1702
+ }
1703
+ if (row.source === "gate") {
1704
+ const sourceRow = this.db
1705
+ .prepare(
1706
+ `select id, run_id, kind, status, message, details_json, created_at,
1707
+ resolved_at, decision_note, decided_at
1708
+ from gates where id = ?`
1709
+ )
1710
+ .get(row.source_id) as GateRow | undefined;
1711
+ if (!sourceRow) return undefined;
1712
+ return timelineEntry(row, {
1713
+ kind: sourceRow.kind,
1714
+ title: `Gate opened: ${sourceRow.kind}`,
1715
+ summary: sourceRow.message,
1716
+ ...(sourceRow.run_id ? { runId: sourceRow.run_id } : {}),
1717
+ status: sourceRow.status,
1718
+ rawRef: { table: "gates", id: sourceRow.id }
1719
+ });
1720
+ }
1721
+ if (row.source === "artifact") {
1722
+ const sourceRow = this.db
1723
+ .prepare("select id, run_id, kind, name, path, sha256, created_at from artifacts where id = ?")
1724
+ .get(row.source_id) as ArtifactRow | undefined;
1725
+ if (!sourceRow) return undefined;
1726
+ return timelineEntry(row, {
1727
+ kind: sourceRow.kind,
1728
+ title: `Artifact: ${sourceRow.name ?? sourceRow.id}`,
1729
+ summary: summarizeTimelinePayload({ name: sourceRow.name ?? sourceRow.id, kind: sourceRow.kind, sha256: sourceRow.sha256 }),
1730
+ runId: sourceRow.run_id,
1731
+ artifactIds: [sourceRow.id],
1732
+ rawRef: { table: "artifacts", id: sourceRow.id }
1733
+ });
1734
+ }
1735
+ const sourceRow = this.db
1736
+ .prepare("select id, run_id, kind, message, created_at from decisions where id = ?")
1737
+ .get(row.source_id) as Pick<DecisionRow, "id" | "run_id" | "kind" | "message" | "created_at"> | undefined;
1738
+ if (!sourceRow) return undefined;
1739
+ return timelineEntry(row, {
1740
+ kind: sourceRow.kind,
1741
+ title: sourceRow.kind,
1742
+ summary: sourceRow.message,
1743
+ runId: sourceRow.run_id,
1744
+ rawRef: { table: "decisions", id: sourceRow.id }
1745
+ });
1746
+ }
1747
+
1748
+ private transaction<T>(fn: () => T): T {
1749
+ this.db.exec("BEGIN IMMEDIATE");
1750
+ try {
1751
+ const result = fn();
1752
+ this.db.exec("COMMIT");
1753
+ return result;
1754
+ } catch (error) {
1755
+ try {
1756
+ this.db.exec("ROLLBACK");
1757
+ } catch (rollbackError) {
1758
+ throw new AgentLoopError("storage_error", "Transaction rollback failed.", {
1759
+ details: {
1760
+ cause: error instanceof Error ? error.message : String(error),
1761
+ rollback: rollbackError instanceof Error ? rollbackError.message : String(rollbackError)
1762
+ }
1763
+ });
1764
+ }
1765
+ throw error;
1766
+ }
1767
+ }
1768
+ }
1769
+
1770
+ interface TimelineIndexRow {
1771
+ timeline_seq: number;
1772
+ source: string;
1773
+ source_id: string;
1774
+ source_seq: number | null;
1775
+ run_id: string | null;
1776
+ worker_id: string | null;
1777
+ created_at: string;
1778
+ }
1779
+
1780
+ interface StateRow {
1781
+ id: number;
1782
+ run_id: string | null;
1783
+ status: string;
1784
+ state: string | null;
1785
+ version: number;
1786
+ payload_json: string | null;
1787
+ created_at: string;
1788
+ }
1789
+
1790
+ function timelineEntry(
1791
+ row: TimelineIndexRow,
1792
+ entry: {
1793
+ kind: string;
1794
+ title: string;
1795
+ summary: string;
1796
+ runId?: string;
1797
+ workerId?: string;
1798
+ threadId?: string;
1799
+ status?: string;
1800
+ artifactIds?: string[];
1801
+ rawRef: AgentTimelineEntry["rawRef"];
1802
+ }
1803
+ ): AgentTimelineEntry {
1804
+ return {
1805
+ timelineSeq: row.timeline_seq,
1806
+ occurredAt: row.created_at,
1807
+ cursor: encodeTimelineCursor(row.timeline_seq, row.created_at),
1808
+ source: row.source as AgentTimelineSource,
1809
+ kind: entry.kind,
1810
+ ...(entry.runId ? { runId: entry.runId } : {}),
1811
+ ...(entry.workerId ? { workerId: entry.workerId } : {}),
1812
+ ...(entry.threadId ? { threadId: entry.threadId } : {}),
1813
+ title: truncateTimelineText(redactTimelineText(entry.title), 160),
1814
+ summary: truncateTimelineText(redactTimelineText(entry.summary), 1000),
1815
+ ...(entry.status ? { status: entry.status } : {}),
1816
+ ...(entry.artifactIds?.length ? { artifactIds: entry.artifactIds } : {}),
1817
+ createdAt: row.created_at,
1818
+ rawRef: entry.rawRef
1819
+ };
1820
+ }
1821
+
1822
+ function backfillTimelineIndex(db: DatabaseSync): void {
1823
+ db.exec(`
1824
+ insert or ignore into timeline_index (source, source_id, source_seq, run_id, worker_id, created_at)
1825
+ select source, source_id, source_seq, run_id, worker_id, created_at
1826
+ from (
1827
+ select 'event' as source, id as source_id, seq as source_seq, run_id, null as worker_id, created_at
1828
+ from events
1829
+ union all
1830
+ select 'worker_event' as source, id as source_id, seq as source_seq, run_id, worker_id, created_at
1831
+ from worker_events
1832
+ union all
1833
+ select 'worker' as source, id || ':' || status as source_id, null as source_seq, run_id, id as worker_id, started_at as created_at
1834
+ from workers
1835
+ union all
1836
+ select 'state' as source, cast(id as text) as source_id, id as source_seq, run_id, null as worker_id, created_at
1837
+ from states
1838
+ union all
1839
+ select 'gate' as source, id as source_id, null as source_seq, run_id, null as worker_id, created_at
1840
+ from gates
1841
+ union all
1842
+ select 'artifact' as source, id as source_id, null as source_seq, run_id, null as worker_id, created_at
1843
+ from artifacts
1844
+ union all
1845
+ select 'decision' as source, id as source_id, null as source_seq, run_id, null as worker_id, created_at
1846
+ from decisions
1847
+ )
1848
+ order by created_at asc, source asc, source_id asc;
1849
+ `);
1850
+ }
1851
+
1852
+ function normalizeTimelineSources(sources: AgentTimelineSource[]): AgentTimelineSource[] {
1853
+ const unique = [...new Set(sources)];
1854
+ if (unique.some((source) => !isTimelineSource(source))) {
1855
+ throw new AgentLoopError("invalid_config", "Unsupported timeline source.", { details: { sources } });
1856
+ }
1857
+ return unique;
1858
+ }
1859
+
1860
+ function isTimelineSource(value: string): value is AgentTimelineSource {
1861
+ return (TIMELINE_SOURCES as readonly string[]).includes(value);
1862
+ }
1863
+
1864
+ function clampLimit(value: number): number {
1865
+ if (!Number.isFinite(value)) {
1866
+ return 50;
1867
+ }
1868
+ return Math.min(Math.max(Math.trunc(value), 1), 200);
1869
+ }
1870
+
1871
+ interface TimelineCursor {
1872
+ timelineSeq: number;
1873
+ occurredAt: string;
1874
+ }
1875
+
1876
+ function encodeTimelineCursor(timelineSeq: number, occurredAt?: string): string {
1877
+ return Buffer.from(JSON.stringify({ timelineSeq, ...(occurredAt ? { occurredAt } : {}) }), "utf8").toString("base64url");
1878
+ }
1879
+
1880
+ function decodeTimelineCursor(cursor: string): TimelineCursor {
1881
+ try {
1882
+ const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as unknown;
1883
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
1884
+ const timelineSeq = (parsed as { timelineSeq?: unknown; occurredAt?: unknown }).timelineSeq;
1885
+ const occurredAt = (parsed as { timelineSeq?: unknown; occurredAt?: unknown }).occurredAt;
1886
+ if (
1887
+ typeof timelineSeq === "number" &&
1888
+ Number.isInteger(timelineSeq) &&
1889
+ timelineSeq > 0 &&
1890
+ typeof occurredAt === "string" &&
1891
+ occurredAt.length > 0
1892
+ ) {
1893
+ return { timelineSeq, occurredAt };
1894
+ }
1895
+ }
1896
+ } catch {
1897
+ // Fall through to the structured storage error below.
1898
+ }
1899
+ throw new AgentLoopError("invalid_config", "Timeline cursor is invalid.");
1900
+ }
1901
+
1902
+ function timelineMissingSourceRows(db: DatabaseSync): Array<{ source: AgentTimelineSource; missing: number }> {
1903
+ const checks: Array<{ source: AgentTimelineSource; sql: string }> = [
1904
+ {
1905
+ source: "event",
1906
+ sql: `select count(*) as count
1907
+ from events source
1908
+ left join timeline_index ti on ti.source = 'event' and ti.source_id = source.id
1909
+ where ti.timeline_seq is null`
1910
+ },
1911
+ {
1912
+ source: "worker_event",
1913
+ sql: `select count(*) as count
1914
+ from worker_events source
1915
+ left join timeline_index ti on ti.source = 'worker_event' and ti.source_id = source.id
1916
+ where ti.timeline_seq is null`
1917
+ },
1918
+ {
1919
+ source: "worker",
1920
+ sql: `select count(*) as count
1921
+ from workers source
1922
+ left join timeline_index ti on ti.source = 'worker' and ti.source_id = source.id || ':' || source.status
1923
+ where ti.timeline_seq is null`
1924
+ },
1925
+ {
1926
+ source: "state",
1927
+ sql: `select count(*) as count
1928
+ from states source
1929
+ left join timeline_index ti on ti.source = 'state' and ti.source_id = cast(source.id as text)
1930
+ where ti.timeline_seq is null`
1931
+ },
1932
+ {
1933
+ source: "gate",
1934
+ sql: `select count(*) as count
1935
+ from gates source
1936
+ left join timeline_index ti on ti.source = 'gate' and ti.source_id = source.id
1937
+ where ti.timeline_seq is null`
1938
+ },
1939
+ {
1940
+ source: "artifact",
1941
+ sql: `select count(*) as count
1942
+ from artifacts source
1943
+ left join timeline_index ti on ti.source = 'artifact' and ti.source_id = source.id
1944
+ where ti.timeline_seq is null`
1945
+ },
1946
+ {
1947
+ source: "decision",
1948
+ sql: `select count(*) as count
1949
+ from decisions source
1950
+ left join timeline_index ti on ti.source = 'decision' and ti.source_id = source.id
1951
+ where ti.timeline_seq is null`
1952
+ }
1953
+ ];
1954
+ return checks.flatMap((check) => {
1955
+ const row = db.prepare(check.sql).get() as { count: number } | undefined;
1956
+ const missing = row?.count ?? 0;
1957
+ return missing > 0 ? [{ source: check.source, missing }] : [];
1958
+ });
1959
+ }
1960
+
1961
+ function summarizeTimelinePayload(value: unknown): string {
1962
+ if (typeof value === "string") {
1963
+ return value;
1964
+ }
1965
+ if (value === undefined || value === null) {
1966
+ return "";
1967
+ }
1968
+ return JSON.stringify(redactTimelineValue(value));
1969
+ }
1970
+
1971
+ function redactTimelineValue(value: unknown): unknown {
1972
+ if (Array.isArray(value)) {
1973
+ return value.slice(0, 20).map(redactTimelineValue);
1974
+ }
1975
+ if (typeof value !== "object" || value === null) {
1976
+ return value;
1977
+ }
1978
+ const redacted: Record<string, unknown> = {};
1979
+ for (const [key, nested] of Object.entries(value as Record<string, unknown>).slice(0, 40)) {
1980
+ redacted[key] = isSecretKey(key) ? "[redacted]" : redactTimelineValue(nested);
1981
+ }
1982
+ return redacted;
1983
+ }
1984
+
1985
+ function redactTimelineText(value: string): string {
1986
+ return redactSecrets(value);
1987
+ }
1988
+
1989
+ function truncateTimelineText(value: string, maxLength: number): string {
1990
+ return value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value;
1991
+ }
1992
+
1993
+ function statusFromWorkerSourceId(sourceId: string): WorkerStatus | undefined {
1994
+ const status = sourceId.split(":").at(-1);
1995
+ return status && ["running", "succeeded", "failed", "timed_out", "invalid_output"].includes(status)
1996
+ ? status as WorkerStatus
1997
+ : undefined;
1998
+ }
1999
+
2000
+ function workerIdFromSourceId(sourceId: string): string {
2001
+ return sourceId.split(":")[0] ?? sourceId;
2002
+ }
2003
+
2004
+ interface RunRow {
2005
+ id: string;
2006
+ status: AgentLoopStatus;
2007
+ current_state: string | null;
2008
+ version: number;
2009
+ branch: string | null;
2010
+ worktree_clean: number | null;
2011
+ started_at: string | null;
2012
+ stopped_at: string | null;
2013
+ created_at: string;
2014
+ updated_at: string;
2015
+ }
2016
+
2017
+ function fromRunRow(row: RunRow): AgentLoopRun {
2018
+ return {
2019
+ id: row.id,
2020
+ status: row.status,
2021
+ ...(row.current_state ? { currentState: row.current_state } : {}),
2022
+ version: row.version,
2023
+ ...(row.branch ? { branch: row.branch } : {}),
2024
+ ...(row.worktree_clean !== null ? { worktreeClean: row.worktree_clean === 1 } : {}),
2025
+ createdAt: row.created_at,
2026
+ updatedAt: row.updated_at,
2027
+ ...(row.started_at ? { startedAt: row.started_at } : {}),
2028
+ ...(row.stopped_at ? { stoppedAt: row.stopped_at } : {})
2029
+ };
2030
+ }
2031
+
2032
+ interface EventRow {
2033
+ seq: number;
2034
+ id: string;
2035
+ run_id: string | null;
2036
+ kind: string;
2037
+ message: string;
2038
+ state_before: string | null;
2039
+ state_after: string | null;
2040
+ payload_json: string | null;
2041
+ artifact_ids_json: string | null;
2042
+ created_at: string;
2043
+ }
2044
+
2045
+ function fromEventRow(row: EventRow): AgentLoopEvent {
2046
+ return {
2047
+ id: row.id,
2048
+ seq: row.seq,
2049
+ ...(row.run_id ? { runId: row.run_id } : {}),
2050
+ kind: row.kind,
2051
+ message: row.message,
2052
+ ...(row.state_before ? { stateBefore: row.state_before } : {}),
2053
+ ...(row.state_after ? { stateAfter: row.state_after } : {}),
2054
+ ...(row.payload_json
2055
+ ? { payload: parseJson(row.payload_json, "Stored event payload JSON is invalid.") }
2056
+ : {}),
2057
+ ...(row.artifact_ids_json
2058
+ ? { artifactIds: parseJson(row.artifact_ids_json, "Stored event artifact list JSON is invalid.") as string[] }
2059
+ : {}),
2060
+ createdAt: row.created_at
2061
+ };
2062
+ }
2063
+
2064
+ interface GateRow {
2065
+ id: string;
2066
+ run_id: string | null;
2067
+ kind: AgentLoopGateKind;
2068
+ status: "open" | "resolved" | "approved" | "rejected";
2069
+ message: string;
2070
+ details_json: string | null;
2071
+ created_at: string;
2072
+ resolved_at: string | null;
2073
+ decision_note: string | null;
2074
+ decided_at: string | null;
2075
+ }
2076
+
2077
+ function statusGateFromRow(row: Pick<GateRow, "kind" | "message" | "details_json">): {
2078
+ kind: AgentLoopGateKind;
2079
+ message: string;
2080
+ details?: unknown;
2081
+ } {
2082
+ return {
2083
+ kind: row.kind,
2084
+ message: row.message,
2085
+ ...(row.details_json
2086
+ ? { details: parseJson(row.details_json, "Stored gate details JSON is invalid.") }
2087
+ : {})
2088
+ };
2089
+ }
2090
+
2091
+ function latestGateSatisfied(db: DatabaseSync, runId: string): boolean {
2092
+ const row = db
2093
+ .prepare(
2094
+ `select status
2095
+ from gates
2096
+ where run_id = ?
2097
+ order by created_at desc
2098
+ limit 1`
2099
+ )
2100
+ .get(runId) as { status: GateRow["status"] } | undefined;
2101
+ return row?.status === "approved" || row?.status === "resolved";
2102
+ }
2103
+
2104
+ function fromGateRow(row: GateRow): AgentLoopGate {
2105
+ return {
2106
+ id: row.id,
2107
+ ...(row.run_id ? { runId: row.run_id } : {}),
2108
+ kind: row.kind,
2109
+ status: row.status,
2110
+ message: row.message,
2111
+ ...(row.details_json
2112
+ ? { details: parseJson(row.details_json, "Stored gate details JSON is invalid.") }
2113
+ : {}),
2114
+ createdAt: row.created_at,
2115
+ ...(row.resolved_at ? { resolvedAt: row.resolved_at } : {}),
2116
+ ...(row.decision_note ? { decisionNote: row.decision_note } : {}),
2117
+ ...(row.decided_at ? { decidedAt: row.decided_at } : {})
2118
+ };
2119
+ }
2120
+
2121
+ interface ArtifactRow {
2122
+ id: string;
2123
+ run_id: string;
2124
+ kind: string;
2125
+ name: string | null;
2126
+ path: string;
2127
+ sha256: string | null;
2128
+ created_at: string;
2129
+ }
2130
+
2131
+ function fromArtifactRow(row: ArtifactRow): AgentLoopArtifactRecord {
2132
+ return {
2133
+ id: row.id,
2134
+ runId: row.run_id,
2135
+ kind: row.kind,
2136
+ name: row.name ?? row.id,
2137
+ path: row.path,
2138
+ sha256: row.sha256 ?? "",
2139
+ createdAt: row.created_at
2140
+ };
2141
+ }
2142
+
2143
+ interface PrLinkRow {
2144
+ id: string;
2145
+ run_id: string;
2146
+ branch: string;
2147
+ pr_number: number;
2148
+ url: string;
2149
+ head_ref: string;
2150
+ base_ref: string;
2151
+ state: string;
2152
+ draft: number;
2153
+ created_at: string;
2154
+ updated_at: string;
2155
+ }
2156
+
2157
+ function fromPrLinkRow(row: PrLinkRow): AgentLoopPrLink {
2158
+ return {
2159
+ id: row.id,
2160
+ runId: row.run_id,
2161
+ branch: row.branch,
2162
+ prNumber: row.pr_number,
2163
+ url: row.url,
2164
+ headRef: row.head_ref,
2165
+ baseRef: row.base_ref,
2166
+ state: row.state,
2167
+ draft: row.draft === 1,
2168
+ createdAt: row.created_at,
2169
+ updatedAt: row.updated_at
2170
+ };
2171
+ }
2172
+
2173
+ interface CiCheckRow {
2174
+ id: string;
2175
+ run_id: string;
2176
+ pr_number: number;
2177
+ name: string;
2178
+ status: string;
2179
+ conclusion: string | null;
2180
+ url: string | null;
2181
+ started_at: string | null;
2182
+ completed_at: string | null;
2183
+ observed_at: string;
2184
+ }
2185
+
2186
+ function fromCiCheckRow(row: CiCheckRow): AgentLoopCiCheck {
2187
+ return {
2188
+ id: row.id,
2189
+ runId: row.run_id,
2190
+ prNumber: row.pr_number,
2191
+ name: row.name,
2192
+ status: row.status,
2193
+ ...(row.conclusion ? { conclusion: row.conclusion } : {}),
2194
+ ...(row.url ? { url: row.url } : {}),
2195
+ ...(row.started_at ? { startedAt: row.started_at } : {}),
2196
+ ...(row.completed_at ? { completedAt: row.completed_at } : {}),
2197
+ observedAt: row.observed_at
2198
+ };
2199
+ }
2200
+
2201
+ interface ReviewCommentRow {
2202
+ id: string;
2203
+ run_id: string;
2204
+ pr_number: number;
2205
+ comment_id: string;
2206
+ url: string;
2207
+ author: string;
2208
+ body: string;
2209
+ path: string;
2210
+ line: number | null;
2211
+ diff_hunk: string;
2212
+ is_resolved: number;
2213
+ is_outdated: number;
2214
+ actionable: number;
2215
+ status: "open" | "handled" | "out_of_scope" | "stale";
2216
+ observed_at: string;
2217
+ }
2218
+
2219
+ function fromReviewCommentRow(row: ReviewCommentRow): AgentLoopReviewComment {
2220
+ return {
2221
+ id: row.id,
2222
+ runId: row.run_id,
2223
+ prNumber: row.pr_number,
2224
+ commentId: row.comment_id,
2225
+ url: row.url,
2226
+ author: row.author,
2227
+ body: row.body,
2228
+ path: row.path,
2229
+ ...(row.line === null ? {} : { line: row.line }),
2230
+ diffHunk: row.diff_hunk,
2231
+ isResolved: row.is_resolved === 1,
2232
+ isOutdated: row.is_outdated === 1,
2233
+ actionable: row.actionable === 1,
2234
+ status: row.status,
2235
+ observedAt: row.observed_at
2236
+ };
2237
+ }
2238
+
2239
+ interface DecisionRow {
2240
+ id: string;
2241
+ run_id: string;
2242
+ kind: string;
2243
+ message: string;
2244
+ details_json: string | null;
2245
+ created_at: string;
2246
+ }
2247
+
2248
+ function fromDecisionRow(row: DecisionRow): AgentLoopDecision {
2249
+ return {
2250
+ id: row.id,
2251
+ runId: row.run_id,
2252
+ kind: row.kind,
2253
+ message: row.message,
2254
+ ...(row.details_json
2255
+ ? { details: parseJson(row.details_json, "Stored decision details JSON is invalid.") }
2256
+ : {}),
2257
+ createdAt: row.created_at
2258
+ };
2259
+ }
2260
+
2261
+ interface RunCheckRow {
2262
+ run_id: string;
2263
+ kind: AgentLoopRunCheck["kind"];
2264
+ status: AgentLoopRunCheck["status"];
2265
+ details_json: string | null;
2266
+ created_at: string;
2267
+ }
2268
+
2269
+ function fromRunCheckRow(row: RunCheckRow): AgentLoopRunCheck {
2270
+ return {
2271
+ runId: row.run_id,
2272
+ kind: row.kind,
2273
+ status: row.status,
2274
+ ...(row.details_json ? { details: JSON.parse(row.details_json) } : {}),
2275
+ createdAt: row.created_at
2276
+ };
2277
+ }
2278
+
2279
+ interface WorkerRow {
2280
+ id: string;
2281
+ run_id: string;
2282
+ type: WorkerType;
2283
+ backend: string;
2284
+ status: WorkerStatus;
2285
+ thread_id: string | null;
2286
+ attempt: number;
2287
+ resume_used: number;
2288
+ started_at: string;
2289
+ completed_at: string | null;
2290
+ exit_code: number | null;
2291
+ result_artifact_id: string | null;
2292
+ raw_jsonl_artifact_id: string | null;
2293
+ error: string | null;
2294
+ }
2295
+
2296
+ function fromWorkerRow(row: WorkerRow): WorkerRun {
2297
+ return {
2298
+ id: row.id,
2299
+ runId: row.run_id,
2300
+ type: row.type,
2301
+ backend: row.backend,
2302
+ status: row.status,
2303
+ ...(row.thread_id ? { threadId: row.thread_id } : {}),
2304
+ attempt: row.attempt,
2305
+ resumeUsed: row.resume_used === 1,
2306
+ startedAt: row.started_at,
2307
+ ...(row.completed_at ? { completedAt: row.completed_at } : {}),
2308
+ ...(row.exit_code === null ? {} : { exitCode: row.exit_code }),
2309
+ ...(row.result_artifact_id ? { resultArtifactId: row.result_artifact_id } : {}),
2310
+ ...(row.raw_jsonl_artifact_id ? { rawJsonlArtifactId: row.raw_jsonl_artifact_id } : {}),
2311
+ ...(row.error ? { error: row.error } : {})
2312
+ };
2313
+ }
2314
+
2315
+ interface WorkerEventRow {
2316
+ seq: number;
2317
+ id: string;
2318
+ worker_id: string;
2319
+ run_id: string;
2320
+ event_type: string;
2321
+ item_type: string | null;
2322
+ item_id: string | null;
2323
+ item_status: string | null;
2324
+ thread_id: string | null;
2325
+ backend: WorkerBackend | null;
2326
+ summary_json: string | null;
2327
+ usage_json: string | null;
2328
+ artifact_ids_json: string | null;
2329
+ created_at: string;
2330
+ }
2331
+
2332
+ function fromWorkerEventRow(row: WorkerEventRow): WorkerEvent {
2333
+ return {
2334
+ id: row.id,
2335
+ seq: row.seq,
2336
+ workerId: row.worker_id,
2337
+ runId: row.run_id,
2338
+ eventType: row.event_type,
2339
+ ...(row.item_type ? { itemType: row.item_type } : {}),
2340
+ ...(row.item_id ? { itemId: row.item_id } : {}),
2341
+ ...(row.item_status ? { itemStatus: row.item_status } : {}),
2342
+ ...(row.thread_id ? { threadId: row.thread_id } : {}),
2343
+ ...(row.backend ? { backend: row.backend } : {}),
2344
+ ...(row.summary_json ? { summary: parseJson(row.summary_json, "Stored worker event summary JSON is invalid.") } : {}),
2345
+ ...(row.usage_json ? { usage: parseJson(row.usage_json, "Stored worker event usage JSON is invalid.") } : {}),
2346
+ ...(row.artifact_ids_json ? { artifactIds: parseJson(row.artifact_ids_json, "Stored worker event artifact list JSON is invalid.") as string[] } : {}),
2347
+ createdAt: row.created_at
2348
+ };
2349
+ }
2350
+
2351
+ function workerEventTimelineTitle(row: WorkerEventRow): string {
2352
+ const item = row.item_type ?? row.event_type;
2353
+ return row.item_status ? `${row.item_status} ${item}` : item;
2354
+ }
2355
+
2356
+ function isSupportedSchemaVersion(value: number): boolean {
2357
+ return (SUPPORTED_SCHEMA_VERSIONS as readonly number[]).includes(value);
2358
+ }
2359
+
2360
+ function rebuildEventsWithSeq(db: DatabaseSync): void {
2361
+ if (hasColumn(db, "events", "seq")) {
2362
+ return;
2363
+ }
2364
+ db.exec(`
2365
+ alter table events rename to events_legacy_v6;
2366
+ create table events (
2367
+ seq integer primary key autoincrement,
2368
+ id text not null unique,
2369
+ run_id text,
2370
+ kind text not null,
2371
+ message text not null,
2372
+ state_before text,
2373
+ state_after text,
2374
+ payload_json text,
2375
+ artifact_ids_json text,
2376
+ created_at text not null,
2377
+ foreign key(run_id) references runs(id)
2378
+ );
2379
+ insert into events (
2380
+ id, run_id, kind, message, state_before, state_after, payload_json, artifact_ids_json, created_at
2381
+ )
2382
+ select id, run_id, kind, message, state_before, state_after, payload_json, artifact_ids_json, created_at
2383
+ from events_legacy_v6
2384
+ order by created_at asc, id asc;
2385
+ drop table events_legacy_v6;
2386
+ `);
2387
+ }
2388
+
2389
+ function rebuildWorkerEventsWithSeq(db: DatabaseSync): void {
2390
+ if (hasColumn(db, "worker_events", "seq")) {
2391
+ return;
2392
+ }
2393
+ db.exec(`
2394
+ alter table worker_events rename to worker_events_legacy_v6;
2395
+ create table worker_events (
2396
+ seq integer primary key autoincrement,
2397
+ id text not null unique,
2398
+ worker_id text not null,
2399
+ run_id text not null,
2400
+ event_type text not null,
2401
+ item_type text,
2402
+ summary_json text,
2403
+ usage_json text,
2404
+ created_at text not null,
2405
+ foreign key(worker_id) references workers(id),
2406
+ foreign key(run_id) references runs(id)
2407
+ );
2408
+ insert into worker_events (
2409
+ id, worker_id, run_id, event_type, item_type, summary_json, usage_json, created_at
2410
+ )
2411
+ select id, worker_id, run_id, event_type, item_type, summary_json, usage_json, created_at
2412
+ from worker_events_legacy_v6
2413
+ order by created_at asc, id asc;
2414
+ drop table worker_events_legacy_v6;
2415
+ `);
2416
+ }
2417
+
2418
+ function dedupeHighFidelityWorkerEventsV8(db: DatabaseSync): void {
2419
+ db.exec(`
2420
+ create temp table if not exists worker_event_dedupe_ids (
2421
+ id text primary key
2422
+ );
2423
+ delete from worker_event_dedupe_ids;
2424
+ insert or ignore into worker_event_dedupe_ids (id)
2425
+ select id from (
2426
+ select id from (
2427
+ select id,
2428
+ seq,
2429
+ row_number() over (
2430
+ partition by thread_id, item_id, coalesce(item_status, '')
2431
+ order by seq asc
2432
+ ) as duplicate_rank
2433
+ from worker_events
2434
+ where thread_id is not null and item_id is not null
2435
+ )
2436
+ where duplicate_rank > 1
2437
+ );
2438
+ insert or ignore into worker_event_dedupe_ids (id)
2439
+ select id from (
2440
+ select id from (
2441
+ select id,
2442
+ seq,
2443
+ row_number() over (
2444
+ partition by thread_id, event_type
2445
+ order by seq asc
2446
+ ) as duplicate_rank
2447
+ from worker_events
2448
+ where thread_id is not null and item_id is null
2449
+ )
2450
+ where duplicate_rank > 1
2451
+ );
2452
+ delete from timeline_index
2453
+ where source = 'worker_event'
2454
+ and source_id in (select id from worker_event_dedupe_ids);
2455
+ delete from worker_events
2456
+ where id in (select id from worker_event_dedupe_ids);
2457
+ delete from worker_event_dedupe_ids;
2458
+ `);
2459
+ }
2460
+
2461
+ function hasColumn(db: DatabaseSync, tableName: string, columnName: string): boolean {
2462
+ validateSqlIdentifier(tableName);
2463
+ validateSqlIdentifier(columnName);
2464
+ const columns = db.prepare(`pragma table_info(${tableName})`).all() as Array<{ name: string }>;
2465
+ return columns.some((column) => column.name === columnName);
2466
+ }
2467
+
2468
+ function hasTable(db: DatabaseSync, tableName: string): boolean {
2469
+ validateSqlIdentifier(tableName);
2470
+ const row = db
2471
+ .prepare("select 1 from sqlite_master where type = 'table' and name = ? limit 1")
2472
+ .get(tableName);
2473
+ return row !== undefined;
2474
+ }
2475
+
2476
+ function boolToDb(value: boolean | undefined): number | null {
2477
+ if (value === undefined) {
2478
+ return null;
2479
+ }
2480
+ return value ? 1 : 0;
2481
+ }
2482
+
2483
+ function addColumnIfMissing(
2484
+ db: DatabaseSync,
2485
+ tableName: string,
2486
+ columnName: string,
2487
+ definition: string
2488
+ ): void {
2489
+ validateSqlIdentifier(tableName);
2490
+ validateSqlIdentifier(columnName);
2491
+ if (!hasColumn(db, tableName, columnName)) {
2492
+ db.exec(`alter table ${tableName} add column ${columnName} ${definition}`);
2493
+ }
2494
+ }
2495
+
2496
+ function validateSqlIdentifier(value: string): void {
2497
+ if (!/^[a-z0-9_]+$/.test(value)) {
2498
+ throw new AgentLoopError("storage_error", `Unsafe SQLite identifier: ${value}`);
2499
+ }
2500
+ }
2501
+
2502
+ function now(): string {
2503
+ return new Date().toISOString();
2504
+ }
2505
+
2506
+ function parseJson(value: string, message: string): unknown {
2507
+ try {
2508
+ return JSON.parse(value);
2509
+ } catch (error) {
2510
+ throw new AgentLoopError("storage_error", message, {
2511
+ details: { cause: error instanceof Error ? error.message : String(error) }
2512
+ });
2513
+ }
2514
+ }
2515
+
2516
+ function isUniqueConstraintError(error: unknown): boolean {
2517
+ return error instanceof Error && /unique constraint/i.test(error.message);
2518
+ }
2519
+
2520
+ function toStorageError(error: unknown, message: string): AgentLoopError {
2521
+ if (error instanceof AgentLoopError) {
2522
+ return error;
2523
+ }
2524
+ return new AgentLoopError("storage_error", message, {
2525
+ details: { cause: error instanceof Error ? error.message : String(error) }
2526
+ });
2527
+ }