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.
- package/.agents/plugins/marketplace.json +20 -0
- package/CONTRIBUTING.md +54 -0
- package/LICENSE +21 -0
- package/README.md +215 -0
- package/README.zh-CN.md +215 -0
- package/SECURITY.md +39 -0
- package/assets/brand/README.md +35 -0
- package/assets/brand/holo-codex-icon.svg +28 -0
- package/assets/brand/holo-codex-lockup.svg +49 -0
- package/assets/brand/holo-codex-mark.svg +33 -0
- package/assets/brand/holo-codex-plugin-card.png +0 -0
- package/assets/brand/holo-codex-plugin-card.svg +81 -0
- package/assets/brand/holo-codex-readme-hero.png +0 -0
- package/assets/brand/holo-codex-readme-hero.svg +140 -0
- package/assets/brand/holo-codex-social-preview.png +0 -0
- package/assets/brand/holo-codex-social-preview.svg +130 -0
- package/assets/brand/holo-codex-wordmark-options.svg +52 -0
- package/docs/checklists/agent-loop-first-delivery-audit.md +129 -0
- package/docs/examples/generic-loop-repo-hygiene.md +168 -0
- package/docs/install.md +190 -0
- package/docs/local-release-readiness.md +206 -0
- package/docs/release-checklist.md +144 -0
- package/docs/self-bootstrap.md +150 -0
- package/docs/trust-and-safety.md +45 -0
- package/package.json +83 -0
- package/plugins/autonomous-pr-loop/.codex-plugin/plugin.json +17 -0
- package/plugins/autonomous-pr-loop/.mcp.json +13 -0
- package/plugins/autonomous-pr-loop/bin/agent-loop.mjs +31 -0
- package/plugins/autonomous-pr-loop/core/artifacts.ts +164 -0
- package/plugins/autonomous-pr-loop/core/autonomy-policy.ts +206 -0
- package/plugins/autonomous-pr-loop/core/ci.ts +131 -0
- package/plugins/autonomous-pr-loop/core/cli-i18n.ts +123 -0
- package/plugins/autonomous-pr-loop/core/cli.ts +1413 -0
- package/plugins/autonomous-pr-loop/core/command-runner.ts +446 -0
- package/plugins/autonomous-pr-loop/core/command.ts +47 -0
- package/plugins/autonomous-pr-loop/core/config-editor.ts +140 -0
- package/plugins/autonomous-pr-loop/core/config.ts +293 -0
- package/plugins/autonomous-pr-loop/core/controller-host.ts +19 -0
- package/plugins/autonomous-pr-loop/core/dashboard-server.ts +536 -0
- package/plugins/autonomous-pr-loop/core/delivery-work-item.ts +217 -0
- package/plugins/autonomous-pr-loop/core/doctor.ts +335 -0
- package/plugins/autonomous-pr-loop/core/errors.ts +82 -0
- package/plugins/autonomous-pr-loop/core/gate-recovery.ts +176 -0
- package/plugins/autonomous-pr-loop/core/gates.ts +26 -0
- package/plugins/autonomous-pr-loop/core/generic-lifecycle.ts +399 -0
- package/plugins/autonomous-pr-loop/core/git.ts +213 -0
- package/plugins/autonomous-pr-loop/core/github.ts +269 -0
- package/plugins/autonomous-pr-loop/core/gitnexus.ts +90 -0
- package/plugins/autonomous-pr-loop/core/happy.ts +42 -0
- package/plugins/autonomous-pr-loop/core/hook-capture.ts +115 -0
- package/plugins/autonomous-pr-loop/core/hook-events.ts +22 -0
- package/plugins/autonomous-pr-loop/core/hook-installation.ts +85 -0
- package/plugins/autonomous-pr-loop/core/hook-observer.ts +84 -0
- package/plugins/autonomous-pr-loop/core/hook-policy.ts +423 -0
- package/plugins/autonomous-pr-loop/core/hook-router.ts +452 -0
- package/plugins/autonomous-pr-loop/core/index.ts +32 -0
- package/plugins/autonomous-pr-loop/core/local-install.ts +778 -0
- package/plugins/autonomous-pr-loop/core/locale.ts +60 -0
- package/plugins/autonomous-pr-loop/core/loop-shapes.ts +190 -0
- package/plugins/autonomous-pr-loop/core/mcp-controller.ts +1479 -0
- package/plugins/autonomous-pr-loop/core/notification-feed.ts +263 -0
- package/plugins/autonomous-pr-loop/core/plan-parser.ts +206 -0
- package/plugins/autonomous-pr-loop/core/plugin-paths.ts +32 -0
- package/plugins/autonomous-pr-loop/core/policy.ts +65 -0
- package/plugins/autonomous-pr-loop/core/pr-lifecycle.ts +464 -0
- package/plugins/autonomous-pr-loop/core/pr-selector.ts +284 -0
- package/plugins/autonomous-pr-loop/core/profiles.ts +439 -0
- package/plugins/autonomous-pr-loop/core/redaction.ts +17 -0
- package/plugins/autonomous-pr-loop/core/repo-root.ts +22 -0
- package/plugins/autonomous-pr-loop/core/review-comments.ts +77 -0
- package/plugins/autonomous-pr-loop/core/scope-guard.ts +179 -0
- package/plugins/autonomous-pr-loop/core/state-machine.ts +828 -0
- package/plugins/autonomous-pr-loop/core/state-types.ts +130 -0
- package/plugins/autonomous-pr-loop/core/storage.ts +2527 -0
- package/plugins/autonomous-pr-loop/core/types.ts +567 -0
- package/plugins/autonomous-pr-loop/core/worker-events.ts +412 -0
- package/plugins/autonomous-pr-loop/core/worker-policy.ts +72 -0
- package/plugins/autonomous-pr-loop/core/worker-prompts.ts +182 -0
- package/plugins/autonomous-pr-loop/core/worker.ts +809 -0
- package/plugins/autonomous-pr-loop/core/workflow-board.ts +1515 -0
- package/plugins/autonomous-pr-loop/hooks/dist/permission-request.js +2462 -0
- package/plugins/autonomous-pr-loop/hooks/dist/post-compact.js +2462 -0
- package/plugins/autonomous-pr-loop/hooks/dist/post-tool-use.js +2462 -0
- package/plugins/autonomous-pr-loop/hooks/dist/pre-compact.js +2462 -0
- package/plugins/autonomous-pr-loop/hooks/dist/pre-tool-use.js +3460 -0
- package/plugins/autonomous-pr-loop/hooks/dist/session-start.js +2462 -0
- package/plugins/autonomous-pr-loop/hooks/dist/stop.js +2462 -0
- package/plugins/autonomous-pr-loop/hooks/dist/user-prompt-submit.js +2462 -0
- package/plugins/autonomous-pr-loop/hooks/hooks.json +106 -0
- package/plugins/autonomous-pr-loop/hooks/observe-runner.ts +25 -0
- package/plugins/autonomous-pr-loop/hooks/permission-request.ts +4 -0
- package/plugins/autonomous-pr-loop/hooks/post-compact.ts +4 -0
- package/plugins/autonomous-pr-loop/hooks/post-tool-use.ts +4 -0
- package/plugins/autonomous-pr-loop/hooks/pre-compact.ts +4 -0
- package/plugins/autonomous-pr-loop/hooks/pre-tool-use.ts +44 -0
- package/plugins/autonomous-pr-loop/hooks/session-start.ts +4 -0
- package/plugins/autonomous-pr-loop/hooks/stop.ts +4 -0
- package/plugins/autonomous-pr-loop/hooks/user-prompt-submit.ts +4 -0
- package/plugins/autonomous-pr-loop/mcp-server/src/index.ts +87 -0
- package/plugins/autonomous-pr-loop/mcp-server/src/tools.ts +205 -0
- package/plugins/autonomous-pr-loop/package.json +9 -0
- package/plugins/autonomous-pr-loop/schemas/config.schema.json +74 -0
- package/plugins/autonomous-pr-loop/schemas/marketplace.schema.json +46 -0
- package/plugins/autonomous-pr-loop/schemas/plugin.schema.json +32 -0
- package/plugins/autonomous-pr-loop/schemas/state.schema.json +19 -0
- package/plugins/autonomous-pr-loop/schemas/worker-event.schema.json +19 -0
- package/plugins/autonomous-pr-loop/schemas/worker-result.schema.json +58 -0
- package/plugins/autonomous-pr-loop/scripts/agent-loop.ts +44 -0
- package/plugins/autonomous-pr-loop/skills/autonomous-pr-loop/SKILL.md +26 -0
- package/plugins/autonomous-pr-loop/skills/autonomous-pr-loop/agents/openai.yaml +6 -0
- package/plugins/autonomous-pr-loop/ui/index.html +26 -0
- package/plugins/autonomous-pr-loop/ui/public/favicon.svg +7 -0
- package/plugins/autonomous-pr-loop/ui/src/api.ts +639 -0
- package/plugins/autonomous-pr-loop/ui/src/app.tsx +238 -0
- package/plugins/autonomous-pr-loop/ui/src/components/ActivityBadge.tsx +31 -0
- package/plugins/autonomous-pr-loop/ui/src/components/BrandMark.tsx +36 -0
- package/plugins/autonomous-pr-loop/ui/src/components/Collapsible.tsx +6 -0
- package/plugins/autonomous-pr-loop/ui/src/components/CommandPreview.tsx +15 -0
- package/plugins/autonomous-pr-loop/ui/src/components/ConfigEditor.tsx +389 -0
- package/plugins/autonomous-pr-loop/ui/src/components/EmptyState.tsx +10 -0
- package/plugins/autonomous-pr-loop/ui/src/components/ErrorState.tsx +12 -0
- package/plugins/autonomous-pr-loop/ui/src/components/List.tsx +7 -0
- package/plugins/autonomous-pr-loop/ui/src/components/MetricRow.tsx +6 -0
- package/plugins/autonomous-pr-loop/ui/src/components/ResponsiveTable.tsx +65 -0
- package/plugins/autonomous-pr-loop/ui/src/components/RiskBadge.tsx +10 -0
- package/plugins/autonomous-pr-loop/ui/src/components/StatusBadge.tsx +29 -0
- package/plugins/autonomous-pr-loop/ui/src/components/TopMetric.tsx +10 -0
- package/plugins/autonomous-pr-loop/ui/src/fixtures.ts +1152 -0
- package/plugins/autonomous-pr-loop/ui/src/i18n.ts +1105 -0
- package/plugins/autonomous-pr-loop/ui/src/main.tsx +14 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/CommandCenter.tsx +470 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/CommandCenterParts.tsx +276 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/agent-timeline/AgentTimelineView.tsx +73 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/artifact-viewer/ArtifactViewer.tsx +44 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/dry-run-preview/DryRunPreview.tsx +66 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/event-ledger/EventLedger.tsx +17 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/gate-center/GateCenter.tsx +34 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/mission-control/MissionControl.tsx +104 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/mission-control/WorkflowBoard.tsx +577 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/notifications/NotificationsView.tsx +30 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/plan-navigator/PlanNavigator.tsx +19 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/policy-config/PolicyConfig.tsx +22 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/pr-inbox/PrInbox.tsx +26 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/recovery-center/RecoveryCenter.tsx +125 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/scope-guard/ScopeGuard.tsx +16 -0
- package/plugins/autonomous-pr-loop/ui/src/pages/worker-runs/WorkerRuns.tsx +39 -0
- package/plugins/autonomous-pr-loop/ui/src/styles.css +2673 -0
- package/plugins/autonomous-pr-loop/ui/src/theme.ts +57 -0
- 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
|
+
}
|