agents-relay 1.0.177 → 2.0.1
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/README.md +4 -2
- package/dist/adapters.js +26 -2
- package/dist/blockers.js +60 -65
- package/dist/cli-support.js +33 -1
- package/dist/cli.js +72 -86
- package/dist/dashboard-actions.js +13 -35
- package/dist/dashboard-html.js +1 -1
- package/dist/dashboard.js +39 -0
- package/dist/execution-recovery.js +37 -0
- package/dist/graph-runtime.js +1 -0
- package/dist/job-actions.js +53 -0
- package/dist/manual-plan.js +15 -17
- package/dist/planner-runtime.js +135 -0
- package/dist/planner.js +76 -60
- package/dist/pool.js +54 -10
- package/dist/projected-store.js +314 -0
- package/dist/reconciler-review.js +185 -110
- package/dist/reconciler-runtime.js +67 -7
- package/dist/reconciler.js +134 -282
- package/dist/relay-store.js +380 -0
- package/dist/relayd-main.js +6 -0
- package/dist/relayd.js +122 -29
- package/dist/runtime-status.js +2 -1
- package/dist/scheduled-jobs.js +40 -31
- package/dist/scheduler.js +19 -18
- package/dist/store.js +1 -1
- package/dist/task-actions.js +139 -0
- package/dist/troubleshooter.js +14 -8
- package/dist/types.js +3 -3
- package/dist/unblock.js +31 -21
- package/dist/workflow/authoritative-unblock-v2.js +20 -0
- package/dist/workflow/blocker-resolution-v2.js +33 -0
- package/dist/workflow/contracts.js +21 -0
- package/dist/workflow/definitions-v2.js +75 -0
- package/dist/workflow/dispatcher.js +56 -0
- package/dist/workflow/entity-creation.js +32 -0
- package/dist/workflow/graph-v2.js +391 -0
- package/dist/workflow/job-reconcile-v2.js +19 -0
- package/dist/workflow/lifecycle.js +82 -4
- package/dist/workflow/planner-orchestration-v2.js +58 -0
- package/dist/workflow/planner-trigger-v2.js +24 -0
- package/dist/workflow/review-v2.js +42 -0
- package/dist/workflow/runtime.js +15 -1
- package/dist/workflow/schedule-cycle-v2.js +25 -0
- package/dist/workflow/scheduled-run-v2.js +30 -0
- package/dist/workflow/task-action-policy.js +19 -0
- package/dist/workflow/task-reconcile.js +6 -6
- package/dist/workflow/task-related-action-v2.js +42 -0
- package/dist/workflow/troubleshooter-v2.js +29 -0
- package/dist/workspace.js +6 -1
- package/package.json +3 -3
- package/skills/agents-relay/agents/planner.agent.md +5 -5
- package/skills/agents-relay/contracts/task-job-contract.md +2 -51
- package/workflow/authoritative-unblock-v2.mmd +15 -0
- package/workflow/blocker-resolution-v2.mmd +73 -0
- package/workflow/job-lifecycle-v2.mmd +70 -0
- package/workflow/job-lifecycle.mmd +2 -2
- package/workflow/planner-orchestration-v2.mmd +65 -0
- package/workflow/planner-trigger-v2.mmd +32 -0
- package/workflow/review-lifecycle-v2.mmd +124 -0
- package/workflow/schedule-cycle-v2.mmd +19 -0
- package/workflow/scheduled-run-v2.mmd +55 -0
- package/workflow/task-lifecycle-v2.mmd +201 -0
- package/workflow/task-lifecycle.mmd +3 -0
- package/workflow/task-related-action-v2.mmd +28 -0
- package/workflow/troubleshooter-v2.mmd +43 -0
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { mkdirSync } from 'node:fs';
|
|
4
|
+
import { readdir } from 'node:fs/promises';
|
|
5
|
+
const SCHEMA_VERSION = 4;
|
|
6
|
+
function newerRecord(local, remote) {
|
|
7
|
+
if (!local)
|
|
8
|
+
return structuredClone(remote);
|
|
9
|
+
return remote.updatedAt > local.updatedAt ? structuredClone(remote) : structuredClone(local);
|
|
10
|
+
}
|
|
11
|
+
function mergeRecords(local, remote) {
|
|
12
|
+
const remoteById = new Map(remote.map(item => [item.id, item]));
|
|
13
|
+
const merged = local.map(item => newerRecord(item, remoteById.get(item.id) ?? item));
|
|
14
|
+
const localIds = new Set(local.map(item => item.id));
|
|
15
|
+
for (const item of remote)
|
|
16
|
+
if (!localIds.has(item.id))
|
|
17
|
+
merged.push(structuredClone(item));
|
|
18
|
+
return merged;
|
|
19
|
+
}
|
|
20
|
+
export function mergeInboundJobSnapshot(local, remote) {
|
|
21
|
+
const base = remote.updatedAt > local.updatedAt ? structuredClone(remote) : structuredClone(local);
|
|
22
|
+
base.tasks = mergeRecords(local.tasks, remote.tasks);
|
|
23
|
+
const localPlans = local.plans ?? [];
|
|
24
|
+
const remotePlans = remote.plans ?? [];
|
|
25
|
+
if (localPlans.length > 0 || remotePlans.length > 0) {
|
|
26
|
+
base.plans = mergeRecords(localPlans, remotePlans).sort((a, b) => a.round - b.round);
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
delete base.plans;
|
|
30
|
+
}
|
|
31
|
+
return base;
|
|
32
|
+
}
|
|
33
|
+
function rowToProjection(row) {
|
|
34
|
+
return {
|
|
35
|
+
id: Number(row.id),
|
|
36
|
+
idempotencyKey: row.idempotency_key,
|
|
37
|
+
coalesceKey: row.coalesce_key,
|
|
38
|
+
operation: row.operation,
|
|
39
|
+
payload: JSON.parse(row.payload_json),
|
|
40
|
+
priority: Number(row.priority),
|
|
41
|
+
attempts: Number(row.attempts),
|
|
42
|
+
nextAttemptAt: row.next_attempt_at,
|
|
43
|
+
lastError: row.last_error,
|
|
44
|
+
createdAt: row.created_at,
|
|
45
|
+
updatedAt: row.updated_at,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
export class SQLiteRelayStore {
|
|
49
|
+
path;
|
|
50
|
+
db;
|
|
51
|
+
constructor(path) {
|
|
52
|
+
this.path = path;
|
|
53
|
+
if (path !== ':memory:')
|
|
54
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
55
|
+
this.db = new DatabaseSync(path);
|
|
56
|
+
this.db.exec('PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=5000; PRAGMA foreign_keys=ON;');
|
|
57
|
+
this.migrate();
|
|
58
|
+
this.db.prepare("UPDATE projection_outbox SET status='pending', updated_at=? WHERE status='processing'")
|
|
59
|
+
.run(new Date().toISOString());
|
|
60
|
+
}
|
|
61
|
+
migrate() {
|
|
62
|
+
this.db.exec(`
|
|
63
|
+
CREATE TABLE IF NOT EXISTS relay_meta (
|
|
64
|
+
key TEXT PRIMARY KEY,
|
|
65
|
+
value TEXT NOT NULL
|
|
66
|
+
);
|
|
67
|
+
CREATE TABLE IF NOT EXISTS job_snapshot (
|
|
68
|
+
job_id TEXT PRIMARY KEY,
|
|
69
|
+
json TEXT NOT NULL,
|
|
70
|
+
updated_at TEXT NOT NULL
|
|
71
|
+
);
|
|
72
|
+
CREATE TABLE IF NOT EXISTS pull_request_facts (
|
|
73
|
+
id INTEGER PRIMARY KEY CHECK(id=1),
|
|
74
|
+
state TEXT NOT NULL CHECK(state IN ('OPEN','MERGED','CLOSED')),
|
|
75
|
+
head_ref TEXT,
|
|
76
|
+
head_sha TEXT,
|
|
77
|
+
observed_at TEXT NOT NULL
|
|
78
|
+
);
|
|
79
|
+
CREATE TABLE IF NOT EXISTS agent_snapshot (
|
|
80
|
+
agent_id TEXT PRIMARY KEY,
|
|
81
|
+
json TEXT NOT NULL,
|
|
82
|
+
updated_at TEXT NOT NULL
|
|
83
|
+
);
|
|
84
|
+
CREATE TABLE IF NOT EXISTS event_receipt (
|
|
85
|
+
event_id TEXT PRIMARY KEY,
|
|
86
|
+
stream TEXT,
|
|
87
|
+
sequence INTEGER,
|
|
88
|
+
received_at TEXT NOT NULL
|
|
89
|
+
);
|
|
90
|
+
CREATE TABLE IF NOT EXISTS projection_outbox (
|
|
91
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
92
|
+
idempotency_key TEXT NOT NULL UNIQUE,
|
|
93
|
+
coalesce_key TEXT,
|
|
94
|
+
operation TEXT NOT NULL,
|
|
95
|
+
payload_json TEXT NOT NULL,
|
|
96
|
+
priority INTEGER NOT NULL DEFAULT 0,
|
|
97
|
+
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','processing','dead')),
|
|
98
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
99
|
+
next_attempt_at TEXT,
|
|
100
|
+
last_error TEXT,
|
|
101
|
+
created_at TEXT NOT NULL,
|
|
102
|
+
updated_at TEXT NOT NULL
|
|
103
|
+
);
|
|
104
|
+
CREATE INDEX IF NOT EXISTS projection_due_idx
|
|
105
|
+
ON projection_outbox(status, next_attempt_at, priority DESC, id);
|
|
106
|
+
CREATE INDEX IF NOT EXISTS projection_coalesce_idx
|
|
107
|
+
ON projection_outbox(coalesce_key, status);
|
|
108
|
+
`);
|
|
109
|
+
const current = this.db.prepare("SELECT value FROM relay_meta WHERE key='schema_version'").get();
|
|
110
|
+
const version = current?.value ? Number(current.value) : 0;
|
|
111
|
+
if (version > SCHEMA_VERSION)
|
|
112
|
+
throw new Error(`Relay store schema ${version} is newer than supported ${SCHEMA_VERSION}`);
|
|
113
|
+
if (version < 4) {
|
|
114
|
+
const schema = this.db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='projection_outbox'").get();
|
|
115
|
+
if (schema?.sql && !schema.sql.includes("'dead'")) {
|
|
116
|
+
this.db.exec(`
|
|
117
|
+
BEGIN IMMEDIATE;
|
|
118
|
+
DROP INDEX IF EXISTS projection_due_idx;
|
|
119
|
+
DROP INDEX IF EXISTS projection_coalesce_idx;
|
|
120
|
+
ALTER TABLE projection_outbox RENAME TO projection_outbox_v3;
|
|
121
|
+
CREATE TABLE projection_outbox (
|
|
122
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
123
|
+
idempotency_key TEXT NOT NULL UNIQUE,
|
|
124
|
+
coalesce_key TEXT,
|
|
125
|
+
operation TEXT NOT NULL,
|
|
126
|
+
payload_json TEXT NOT NULL,
|
|
127
|
+
priority INTEGER NOT NULL DEFAULT 0,
|
|
128
|
+
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','processing','dead')),
|
|
129
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
130
|
+
next_attempt_at TEXT,
|
|
131
|
+
last_error TEXT,
|
|
132
|
+
created_at TEXT NOT NULL,
|
|
133
|
+
updated_at TEXT NOT NULL
|
|
134
|
+
);
|
|
135
|
+
INSERT INTO projection_outbox(id,idempotency_key,coalesce_key,operation,payload_json,priority,status,attempts,next_attempt_at,last_error,created_at,updated_at)
|
|
136
|
+
SELECT id,idempotency_key,coalesce_key,operation,payload_json,priority,status,attempts,next_attempt_at,last_error,created_at,updated_at FROM projection_outbox_v3;
|
|
137
|
+
DROP TABLE projection_outbox_v3;
|
|
138
|
+
CREATE INDEX projection_due_idx ON projection_outbox(status, next_attempt_at, priority DESC, id);
|
|
139
|
+
CREATE INDEX projection_coalesce_idx ON projection_outbox(coalesce_key, status);
|
|
140
|
+
COMMIT;
|
|
141
|
+
`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
this.db.prepare("INSERT INTO relay_meta(key,value) VALUES('schema_version',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value")
|
|
145
|
+
.run(String(SCHEMA_VERSION));
|
|
146
|
+
}
|
|
147
|
+
async loadJob(jobId) {
|
|
148
|
+
const row = this.db.prepare('SELECT json FROM job_snapshot WHERE job_id=?').get(jobId);
|
|
149
|
+
return row?.json ? JSON.parse(row.json) : null;
|
|
150
|
+
}
|
|
151
|
+
async listJobs() {
|
|
152
|
+
const rows = this.db.prepare('SELECT json FROM job_snapshot ORDER BY job_id').all();
|
|
153
|
+
return rows.map(row => JSON.parse(row.json));
|
|
154
|
+
}
|
|
155
|
+
async saveJob(job) {
|
|
156
|
+
this.writeJobSnapshot(job, new Date().toISOString());
|
|
157
|
+
}
|
|
158
|
+
async ingestJobSnapshot(job) {
|
|
159
|
+
const now = new Date().toISOString();
|
|
160
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
161
|
+
try {
|
|
162
|
+
const row = this.db.prepare('SELECT json FROM job_snapshot WHERE job_id=?').get(job.id);
|
|
163
|
+
const local = row?.json ? JSON.parse(row.json) : null;
|
|
164
|
+
const merged = local ? mergeInboundJobSnapshot(local, job) : structuredClone(job);
|
|
165
|
+
this.writeJobSnapshot(merged, now);
|
|
166
|
+
this.db.exec('COMMIT');
|
|
167
|
+
return structuredClone(merged);
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
this.db.exec('ROLLBACK');
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
async saveJobWithProjection(job, request) {
|
|
175
|
+
const now = new Date().toISOString();
|
|
176
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
177
|
+
try {
|
|
178
|
+
this.writeJobSnapshot(job, now);
|
|
179
|
+
this.enqueueProjectionInTransaction(request, now);
|
|
180
|
+
this.db.exec('COMMIT');
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
this.db.exec('ROLLBACK');
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
writeJobSnapshot(job, now) {
|
|
188
|
+
this.db.prepare(`
|
|
189
|
+
INSERT INTO job_snapshot(job_id,json,updated_at) VALUES(?,?,?)
|
|
190
|
+
ON CONFLICT(job_id) DO UPDATE SET json=excluded.json, updated_at=excluded.updated_at
|
|
191
|
+
`).run(job.id, JSON.stringify(job), now);
|
|
192
|
+
}
|
|
193
|
+
async loadAgents() {
|
|
194
|
+
const observed = this.db.prepare("SELECT value FROM relay_meta WHERE key='agent_registry_observed_at'").get();
|
|
195
|
+
if (!observed?.value)
|
|
196
|
+
return null;
|
|
197
|
+
const rows = this.db.prepare('SELECT json FROM agent_snapshot ORDER BY agent_id').all();
|
|
198
|
+
return rows.map(row => JSON.parse(row.json));
|
|
199
|
+
}
|
|
200
|
+
async saveAgents(agents) {
|
|
201
|
+
const now = new Date().toISOString();
|
|
202
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
203
|
+
try {
|
|
204
|
+
this.db.prepare('DELETE FROM agent_snapshot').run();
|
|
205
|
+
const insert = this.db.prepare('INSERT INTO agent_snapshot(agent_id,json,updated_at) VALUES(?,?,?)');
|
|
206
|
+
for (const agent of agents)
|
|
207
|
+
insert.run(agent.id, JSON.stringify(agent), now);
|
|
208
|
+
this.markAgentRegistryObserved(now);
|
|
209
|
+
this.db.exec('COMMIT');
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
this.db.exec('ROLLBACK');
|
|
213
|
+
throw error;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
async saveAgentWithProjection(agent, request) {
|
|
217
|
+
const now = new Date().toISOString();
|
|
218
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
219
|
+
try {
|
|
220
|
+
this.writeAgentSnapshot(agent, now);
|
|
221
|
+
this.markAgentRegistryObserved(now);
|
|
222
|
+
this.enqueueProjectionInTransaction(request, now);
|
|
223
|
+
this.db.exec('COMMIT');
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
this.db.exec('ROLLBACK');
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
writeAgentSnapshot(agent, now) {
|
|
231
|
+
this.db.prepare(`
|
|
232
|
+
INSERT INTO agent_snapshot(agent_id,json,updated_at) VALUES(?,?,?)
|
|
233
|
+
ON CONFLICT(agent_id) DO UPDATE SET json=excluded.json, updated_at=excluded.updated_at
|
|
234
|
+
`).run(agent.id, JSON.stringify(agent), now);
|
|
235
|
+
}
|
|
236
|
+
markAgentRegistryObserved(now) {
|
|
237
|
+
this.db.prepare(`
|
|
238
|
+
INSERT INTO relay_meta(key,value) VALUES('agent_registry_observed_at',?)
|
|
239
|
+
ON CONFLICT(key) DO UPDATE SET value=excluded.value
|
|
240
|
+
`).run(now);
|
|
241
|
+
}
|
|
242
|
+
async loadPullRequestFacts() {
|
|
243
|
+
const row = this.db.prepare('SELECT state,head_ref,head_sha,observed_at FROM pull_request_facts WHERE id=1').get();
|
|
244
|
+
if (!row?.state || !row.observed_at)
|
|
245
|
+
return null;
|
|
246
|
+
return { state: row.state, head: row.head_ref && row.head_sha ? { ref: row.head_ref, sha: row.head_sha } : null, observedAt: row.observed_at };
|
|
247
|
+
}
|
|
248
|
+
async savePullRequestFacts(facts) {
|
|
249
|
+
this.db.prepare(`
|
|
250
|
+
INSERT INTO pull_request_facts(id,state,head_ref,head_sha,observed_at) VALUES(1,?,?,?,?)
|
|
251
|
+
ON CONFLICT(id) DO UPDATE SET state=excluded.state,head_ref=excluded.head_ref,head_sha=excluded.head_sha,observed_at=excluded.observed_at
|
|
252
|
+
`).run(facts.state, facts.head?.ref ?? null, facts.head?.sha ?? null, facts.observedAt);
|
|
253
|
+
}
|
|
254
|
+
async markEventProcessed(eventId, stream = null, sequence = null) {
|
|
255
|
+
const result = this.db.prepare(`
|
|
256
|
+
INSERT OR IGNORE INTO event_receipt(event_id,stream,sequence,received_at) VALUES(?,?,?,?)
|
|
257
|
+
`).run(eventId, stream, sequence, new Date().toISOString());
|
|
258
|
+
return Number(result.changes) === 1;
|
|
259
|
+
}
|
|
260
|
+
async enqueueProjection(request) {
|
|
261
|
+
const now = new Date().toISOString();
|
|
262
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
263
|
+
try {
|
|
264
|
+
this.enqueueProjectionInTransaction(request, now);
|
|
265
|
+
this.db.exec('COMMIT');
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
this.db.exec('ROLLBACK');
|
|
269
|
+
throw error;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
enqueueProjectionInTransaction(request, now) {
|
|
273
|
+
if (request.coalesceKey) {
|
|
274
|
+
this.db.prepare(`
|
|
275
|
+
DELETE FROM projection_outbox
|
|
276
|
+
WHERE coalesce_key=? AND status='pending' AND idempotency_key<>?
|
|
277
|
+
`).run(request.coalesceKey, request.idempotencyKey);
|
|
278
|
+
}
|
|
279
|
+
const existing = this.db.prepare('SELECT status FROM projection_outbox WHERE idempotency_key=?').get(request.idempotencyKey);
|
|
280
|
+
// A writer may be applying this key outside the transaction. Never rewrite its
|
|
281
|
+
// payload: its completion must only remove its own row, not a newer mutation.
|
|
282
|
+
const nextId = Number(this.db.prepare('SELECT COALESCE(MAX(id),0)+1 AS next_id FROM projection_outbox').get().next_id ?? 1);
|
|
283
|
+
const idempotencyKey = existing?.status === 'processing' || existing?.status === 'dead'
|
|
284
|
+
? `${request.idempotencyKey}:queued:${nextId}`
|
|
285
|
+
: request.idempotencyKey;
|
|
286
|
+
this.db.prepare(`
|
|
287
|
+
INSERT INTO projection_outbox(
|
|
288
|
+
idempotency_key,coalesce_key,operation,payload_json,priority,status,
|
|
289
|
+
attempts,next_attempt_at,last_error,created_at,updated_at
|
|
290
|
+
) VALUES(?,?,?,?,?,'pending',0,NULL,NULL,?,?)
|
|
291
|
+
ON CONFLICT(idempotency_key) DO UPDATE SET
|
|
292
|
+
coalesce_key=excluded.coalesce_key,
|
|
293
|
+
operation=excluded.operation,
|
|
294
|
+
payload_json=excluded.payload_json,
|
|
295
|
+
priority=MAX(projection_outbox.priority,excluded.priority),
|
|
296
|
+
status='pending',
|
|
297
|
+
next_attempt_at=NULL,
|
|
298
|
+
last_error=NULL,
|
|
299
|
+
updated_at=excluded.updated_at
|
|
300
|
+
`).run(idempotencyKey, request.coalesceKey ?? null, request.operation, JSON.stringify(request.payload), request.priority ?? 0, now, now);
|
|
301
|
+
}
|
|
302
|
+
async claimDueProjections(limit = 50, now = new Date()) {
|
|
303
|
+
const observedAt = now.toISOString();
|
|
304
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
305
|
+
try {
|
|
306
|
+
const rows = this.db.prepare(`
|
|
307
|
+
SELECT id,idempotency_key,coalesce_key,operation,payload_json,priority,
|
|
308
|
+
attempts,next_attempt_at,last_error,created_at,updated_at
|
|
309
|
+
FROM projection_outbox
|
|
310
|
+
WHERE status='pending' AND (next_attempt_at IS NULL OR next_attempt_at<=?)
|
|
311
|
+
ORDER BY priority DESC, id ASC
|
|
312
|
+
LIMIT ?
|
|
313
|
+
`).all(observedAt, Math.max(1, Math.floor(limit)));
|
|
314
|
+
const mark = this.db.prepare("UPDATE projection_outbox SET status='processing', updated_at=? WHERE id=? AND status='pending'");
|
|
315
|
+
const claimed = [];
|
|
316
|
+
for (const row of rows) {
|
|
317
|
+
const result = mark.run(observedAt, row.id);
|
|
318
|
+
if (Number(result.changes) === 1)
|
|
319
|
+
claimed.push({ ...row, updated_at: observedAt });
|
|
320
|
+
}
|
|
321
|
+
this.db.exec('COMMIT');
|
|
322
|
+
return claimed.map(rowToProjection);
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
this.db.exec('ROLLBACK');
|
|
326
|
+
throw error;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
async completeProjection(id) {
|
|
330
|
+
this.db.prepare('DELETE FROM projection_outbox WHERE id=?').run(id);
|
|
331
|
+
}
|
|
332
|
+
async retryProjection(id, error, nextAttemptAt) {
|
|
333
|
+
this.db.prepare(`
|
|
334
|
+
UPDATE projection_outbox
|
|
335
|
+
SET status='pending', attempts=attempts+1, next_attempt_at=?, last_error=?, updated_at=?
|
|
336
|
+
WHERE id=?
|
|
337
|
+
`).run(nextAttemptAt.toISOString(), error, new Date().toISOString(), id);
|
|
338
|
+
}
|
|
339
|
+
async deadLetterProjection(id, error) {
|
|
340
|
+
this.db.prepare(`
|
|
341
|
+
UPDATE projection_outbox
|
|
342
|
+
SET status='dead', attempts=attempts+1, next_attempt_at=NULL, last_error=?, updated_at=?
|
|
343
|
+
WHERE id=?
|
|
344
|
+
`).run(error, new Date().toISOString(), id);
|
|
345
|
+
}
|
|
346
|
+
async pendingProjectionCount() {
|
|
347
|
+
const row = this.db.prepare("SELECT COUNT(*) AS count FROM projection_outbox WHERE status IN ('pending','processing')").get();
|
|
348
|
+
return Number(row?.count ?? 0);
|
|
349
|
+
}
|
|
350
|
+
async terminalProjectionCount() {
|
|
351
|
+
const row = this.db.prepare("SELECT COUNT(*) AS count FROM projection_outbox WHERE status='dead'").get();
|
|
352
|
+
return Number(row?.count ?? 0);
|
|
353
|
+
}
|
|
354
|
+
close() { this.db.close(); }
|
|
355
|
+
}
|
|
356
|
+
export async function discoverLocalRelayStoreSnapshots(directory) {
|
|
357
|
+
let names;
|
|
358
|
+
try {
|
|
359
|
+
names = await readdir(directory);
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
return [];
|
|
363
|
+
}
|
|
364
|
+
const snapshots = [];
|
|
365
|
+
for (const name of names.filter(item => item.endsWith('.db')).sort()) {
|
|
366
|
+
const path = directory + '/' + name;
|
|
367
|
+
let store;
|
|
368
|
+
try {
|
|
369
|
+
store = new SQLiteRelayStore(path);
|
|
370
|
+
snapshots.push({ path, jobs: await store.listJobs(), facts: await store.loadPullRequestFacts() });
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
// Ignore unrelated/corrupt files so one bad file cannot block daemon startup.
|
|
374
|
+
}
|
|
375
|
+
finally {
|
|
376
|
+
store?.close();
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return snapshots;
|
|
380
|
+
}
|