patchrelay 0.36.7 → 0.36.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.36.7",
4
- "commit": "93dd74dadf6f",
5
- "builtAt": "2026-04-09T11:23:49.821Z"
3
+ "version": "0.36.8",
4
+ "commit": "a62440fe88ab",
5
+ "builtAt": "2026-04-09T12:45:27.217Z"
6
6
  }
@@ -31,7 +31,7 @@ export async function collectClusterHealth(config, db, runCommand) {
31
31
  });
32
32
  return {
33
33
  issue,
34
- session: db.getIssueSession(issue.projectId, issue.linearIssueId),
34
+ session: db.issueSessions.getIssueSession(issue.projectId, issue.linearIssueId),
35
35
  blockedBy,
36
36
  missingTrackedBlockers,
37
37
  ageMs: Math.max(0, now - Date.parse(issue.updatedAt || new Date(0).toISOString())),
package/dist/cli/data.js CHANGED
@@ -42,7 +42,7 @@ function summarizeThread(thread, latestTimestampSeen) {
42
42
  };
43
43
  }
44
44
  function latestEventTimestamp(db, runId) {
45
- const events = db.listThreadEvents(runId);
45
+ const events = db.runs.listThreadEvents(runId);
46
46
  return events.at(-1)?.createdAt;
47
47
  }
48
48
  function parseObjectJson(value) {
@@ -95,8 +95,8 @@ export class CliDataAccess extends CliOperatorApiClient {
95
95
  if (!issue)
96
96
  return undefined;
97
97
  const dbIssue = this.db.getIssueByKey(issueKey);
98
- const activeRun = dbIssue.activeRunId ? this.db.getRun(dbIssue.activeRunId) : undefined;
99
- const latestRun = this.db.getLatestRunForIssue(issue.projectId, issue.linearIssueId);
98
+ const activeRun = dbIssue.activeRunId ? this.db.runs.getRunById(dbIssue.activeRunId) : undefined;
99
+ const latestRun = this.db.runs.getLatestRunForIssue(issue.projectId, issue.linearIssueId);
100
100
  const latestReport = normalizeStageReport(latestRun?.reportJson, latestRun?.status);
101
101
  const latestSummary = safeJsonParse(latestRun?.summaryJson);
102
102
  const statusNote = latestReport?.assistantMessages.at(-1) ??
@@ -121,7 +121,7 @@ export class CliDataAccess extends CliOperatorApiClient {
121
121
  if (!issue)
122
122
  return undefined;
123
123
  const dbIssue = this.db.getIssueByKey(issueKey);
124
- const run = dbIssue.activeRunId ? this.db.getRun(dbIssue.activeRunId) : undefined;
124
+ const run = dbIssue.activeRunId ? this.db.runs.getRunById(dbIssue.activeRunId) : undefined;
125
125
  if (!run)
126
126
  return undefined;
127
127
  const live = run.threadId &&
@@ -180,7 +180,7 @@ export class CliDataAccess extends CliOperatorApiClient {
180
180
  if (!issue)
181
181
  return undefined;
182
182
  const dbIssue = this.db.getIssueByKey(issueKey);
183
- const issueSession = this.db.getIssueSession(issue.projectId, issue.linearIssueId);
183
+ const issueSession = this.db.issueSessions.getIssueSession(issue.projectId, issue.linearIssueId);
184
184
  if (dbIssue.activeRunId !== undefined) {
185
185
  throw new Error(`Issue ${issueKey} already has an active run.`);
186
186
  }
@@ -215,7 +215,7 @@ export class CliDataAccess extends CliOperatorApiClient {
215
215
  if (!issue)
216
216
  return undefined;
217
217
  const dbIssue = this.db.getIssueByKey(issueKey);
218
- const runs = this.db.listRunsForIssue(issue.projectId, issue.linearIssueId);
218
+ const runs = this.db.runs.listRunsForIssue(issue.projectId, issue.linearIssueId);
219
219
  const sessions = runs
220
220
  .slice()
221
221
  .reverse()
@@ -230,7 +230,7 @@ export class CliDataAccess extends CliOperatorApiClient {
230
230
  ...(run.parentThreadId ? { parentThreadId: run.parentThreadId } : {}),
231
231
  ...(summary ? { summary } : {}),
232
232
  ...(run.failureReason ? { failureReason: run.failureReason } : {}),
233
- eventCount: this.db.listThreadEvents(run.id).length,
233
+ eventCount: this.db.runs.listThreadEvents(run.id).length,
234
234
  startedAt: run.startedAt,
235
235
  ...(run.endedAt ? { endedAt: run.endedAt } : {}),
236
236
  isCurrentThread: run.threadId !== undefined && run.threadId === dbIssue.threadId,
@@ -247,7 +247,7 @@ export class CliDataAccess extends CliOperatorApiClient {
247
247
  if (runType === "queue_repair") {
248
248
  const queueIncident = parseObjectJson(issue.lastQueueIncidentJson);
249
249
  const failureContext = parseObjectJson(issue.lastGitHubFailureContextJson);
250
- this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
250
+ this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
251
251
  projectId: issue.projectId,
252
252
  linearIssueId: issue.linearIssueId,
253
253
  eventType: "merge_steward_incident",
@@ -262,7 +262,7 @@ export class CliDataAccess extends CliOperatorApiClient {
262
262
  }
263
263
  if (runType === "ci_repair") {
264
264
  const failureContext = parseObjectJson(issue.lastGitHubFailureContextJson);
265
- this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
265
+ this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
266
266
  projectId: issue.projectId,
267
267
  linearIssueId: issue.linearIssueId,
268
268
  eventType: "settled_red_ci",
@@ -275,7 +275,7 @@ export class CliDataAccess extends CliOperatorApiClient {
275
275
  return;
276
276
  }
277
277
  if (runType === "review_fix" || runType === "branch_upkeep") {
278
- this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
278
+ this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
279
279
  projectId: issue.projectId,
280
280
  linearIssueId: issue.linearIssueId,
281
281
  eventType: "review_changes_requested",
@@ -290,7 +290,7 @@ export class CliDataAccess extends CliOperatorApiClient {
290
290
  });
291
291
  return;
292
292
  }
293
- this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
293
+ this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
294
294
  projectId: issue.projectId,
295
295
  linearIssueId: issue.linearIssueId,
296
296
  eventType: "delegated",
@@ -0,0 +1,292 @@
1
+ import { deriveSessionWakePlan } from "../issue-session-events.js";
2
+ import { isoNow } from "./shared.js";
3
+ export class IssueSessionStore {
4
+ connection;
5
+ mapIssueSessionRow;
6
+ mapIssueSessionEventRow;
7
+ getIssue;
8
+ deriveImplicitReactiveWake;
9
+ transaction;
10
+ upsertIssue;
11
+ finishRun;
12
+ updateRunThread;
13
+ setBranchOwner;
14
+ constructor(connection, mapIssueSessionRow, mapIssueSessionEventRow, getIssue, deriveImplicitReactiveWake, transaction, upsertIssue, finishRun, updateRunThread, setBranchOwner) {
15
+ this.connection = connection;
16
+ this.mapIssueSessionRow = mapIssueSessionRow;
17
+ this.mapIssueSessionEventRow = mapIssueSessionEventRow;
18
+ this.getIssue = getIssue;
19
+ this.deriveImplicitReactiveWake = deriveImplicitReactiveWake;
20
+ this.transaction = transaction;
21
+ this.upsertIssue = upsertIssue;
22
+ this.finishRun = finishRun;
23
+ this.updateRunThread = updateRunThread;
24
+ this.setBranchOwner = setBranchOwner;
25
+ }
26
+ getIssueSession(projectId, linearIssueId) {
27
+ const row = this.connection
28
+ .prepare("SELECT * FROM issue_sessions WHERE project_id = ? AND linear_issue_id = ?")
29
+ .get(projectId, linearIssueId);
30
+ return row ? this.mapIssueSessionRow(row) : undefined;
31
+ }
32
+ getIssueSessionByKey(issueKey) {
33
+ const row = this.connection.prepare("SELECT * FROM issue_sessions WHERE issue_key = ?").get(issueKey);
34
+ return row ? this.mapIssueSessionRow(row) : undefined;
35
+ }
36
+ appendIssueSessionEvent(params) {
37
+ if (params.dedupeKey) {
38
+ const existing = this.connection.prepare(`
39
+ SELECT * FROM issue_session_events
40
+ WHERE project_id = ? AND linear_issue_id = ? AND dedupe_key = ? AND processed_at IS NULL
41
+ ORDER BY id DESC LIMIT 1
42
+ `).get(params.projectId, params.linearIssueId, params.dedupeKey);
43
+ if (existing)
44
+ return this.mapIssueSessionEventRow(existing);
45
+ }
46
+ const now = isoNow();
47
+ const result = this.connection.prepare(`
48
+ INSERT INTO issue_session_events (
49
+ project_id, linear_issue_id, event_type, event_json, dedupe_key, created_at
50
+ ) VALUES (?, ?, ?, ?, ?, ?)
51
+ `).run(params.projectId, params.linearIssueId, params.eventType, params.eventJson ?? null, params.dedupeKey ?? null, now);
52
+ return this.getIssueSessionEvent(Number(result.lastInsertRowid));
53
+ }
54
+ appendIssueSessionEventWithLease(lease, params) {
55
+ return this.withIssueSessionLease(lease.projectId, lease.linearIssueId, lease.leaseId, () => this.appendIssueSessionEvent(params));
56
+ }
57
+ appendIssueSessionEventRespectingActiveLease(projectId, linearIssueId, params) {
58
+ const lease = this.getActiveIssueSessionLease(projectId, linearIssueId);
59
+ if (!lease) {
60
+ return this.appendIssueSessionEvent(params);
61
+ }
62
+ return this.appendIssueSessionEventWithLease(lease, params);
63
+ }
64
+ getIssueSessionEvent(id) {
65
+ const row = this.connection.prepare("SELECT * FROM issue_session_events WHERE id = ?").get(id);
66
+ return row ? this.mapIssueSessionEventRow(row) : undefined;
67
+ }
68
+ listIssueSessionEvents(projectId, linearIssueId, options) {
69
+ const conditions = ["project_id = ?", "linear_issue_id = ?"];
70
+ const values = [projectId, linearIssueId];
71
+ if (options?.pendingOnly) {
72
+ conditions.push("processed_at IS NULL");
73
+ }
74
+ let query = `SELECT * FROM issue_session_events WHERE ${conditions.join(" AND ")} ORDER BY id`;
75
+ if (options?.limit !== undefined) {
76
+ query += " LIMIT ?";
77
+ values.push(options.limit);
78
+ }
79
+ const rows = this.connection.prepare(query).all(...values);
80
+ return rows.map(this.mapIssueSessionEventRow);
81
+ }
82
+ consumeIssueSessionEvents(projectId, linearIssueId, eventIds, runId) {
83
+ if (eventIds.length === 0)
84
+ return;
85
+ const now = isoNow();
86
+ const placeholders = eventIds.map(() => "?").join(", ");
87
+ this.connection.prepare(`
88
+ UPDATE issue_session_events
89
+ SET processed_at = ?, consumed_by_run_id = ?
90
+ WHERE project_id = ? AND linear_issue_id = ? AND id IN (${placeholders}) AND processed_at IS NULL
91
+ `).run(now, runId, projectId, linearIssueId, ...eventIds);
92
+ }
93
+ clearPendingIssueSessionEvents(projectId, linearIssueId) {
94
+ this.connection.prepare(`
95
+ UPDATE issue_session_events
96
+ SET processed_at = ?, consumed_by_run_id = NULL
97
+ WHERE project_id = ? AND linear_issue_id = ? AND processed_at IS NULL
98
+ `).run(isoNow(), projectId, linearIssueId);
99
+ }
100
+ hasPendingIssueSessionEvents(projectId, linearIssueId) {
101
+ const row = this.connection.prepare(`
102
+ SELECT 1
103
+ FROM issue_session_events
104
+ WHERE project_id = ? AND linear_issue_id = ? AND processed_at IS NULL
105
+ LIMIT 1
106
+ `).get(projectId, linearIssueId);
107
+ return row !== undefined;
108
+ }
109
+ peekIssueSessionWake(projectId, linearIssueId) {
110
+ const issue = this.getIssue(projectId, linearIssueId);
111
+ if (!issue)
112
+ return undefined;
113
+ const events = this.listIssueSessionEvents(projectId, linearIssueId, { pendingOnly: true });
114
+ const plan = deriveSessionWakePlan(issue, events);
115
+ if (plan?.runType) {
116
+ return {
117
+ eventIds: events.map((event) => event.id),
118
+ runType: plan.runType,
119
+ context: plan.context,
120
+ ...(plan.wakeReason ? { wakeReason: plan.wakeReason } : {}),
121
+ resumeThread: plan.resumeThread,
122
+ };
123
+ }
124
+ const implicitWake = this.deriveImplicitReactiveWake(issue);
125
+ if (!implicitWake)
126
+ return undefined;
127
+ return {
128
+ eventIds: [],
129
+ runType: implicitWake.runType,
130
+ context: implicitWake.context,
131
+ wakeReason: implicitWake.wakeReason,
132
+ resumeThread: false,
133
+ };
134
+ }
135
+ acquireIssueSessionLease(params) {
136
+ const now = params.now ?? isoNow();
137
+ const result = this.connection.prepare(`
138
+ UPDATE issue_sessions
139
+ SET lease_id = ?, worker_id = ?, leased_until = ?, updated_at = ?
140
+ WHERE project_id = ? AND linear_issue_id = ?
141
+ AND (leased_until IS NULL OR leased_until <= ? OR lease_id = ?)
142
+ `).run(params.leaseId, params.workerId, params.leasedUntil, now, params.projectId, params.linearIssueId, now, params.leaseId);
143
+ return Number(result.changes ?? 0) > 0;
144
+ }
145
+ forceAcquireIssueSessionLease(params) {
146
+ const now = params.now ?? isoNow();
147
+ const result = this.connection.prepare(`
148
+ UPDATE issue_sessions
149
+ SET lease_id = ?, worker_id = ?, leased_until = ?, updated_at = ?
150
+ WHERE project_id = ? AND linear_issue_id = ?
151
+ `).run(params.leaseId, params.workerId, params.leasedUntil, now, params.projectId, params.linearIssueId);
152
+ return Number(result.changes ?? 0) > 0;
153
+ }
154
+ renewIssueSessionLease(params) {
155
+ const now = params.now ?? isoNow();
156
+ const result = this.connection.prepare(`
157
+ UPDATE issue_sessions
158
+ SET leased_until = ?, updated_at = ?
159
+ WHERE project_id = ? AND linear_issue_id = ? AND lease_id = ?
160
+ `).run(params.leasedUntil, now, params.projectId, params.linearIssueId, params.leaseId);
161
+ return Number(result.changes ?? 0) > 0;
162
+ }
163
+ releaseIssueSessionLease(projectId, linearIssueId, leaseId) {
164
+ this.connection.prepare(`
165
+ UPDATE issue_sessions
166
+ SET lease_id = NULL, worker_id = NULL, leased_until = NULL, updated_at = ?
167
+ WHERE project_id = ? AND linear_issue_id = ? AND (? IS NULL OR lease_id = ?)
168
+ `).run(isoNow(), projectId, linearIssueId, leaseId ?? null, leaseId ?? null);
169
+ }
170
+ releaseExpiredIssueSessionLeases(now = isoNow()) {
171
+ this.connection.prepare(`
172
+ UPDATE issue_sessions
173
+ SET lease_id = NULL, worker_id = NULL, leased_until = NULL, updated_at = ?
174
+ WHERE leased_until IS NOT NULL AND leased_until <= ?
175
+ `).run(now, now);
176
+ }
177
+ hasActiveIssueSessionLease(projectId, linearIssueId, leaseId, now = isoNow()) {
178
+ const row = this.connection.prepare(`
179
+ SELECT 1
180
+ FROM issue_sessions
181
+ WHERE project_id = ? AND linear_issue_id = ? AND lease_id = ?
182
+ AND leased_until IS NOT NULL
183
+ AND leased_until > ?
184
+ LIMIT 1
185
+ `).get(projectId, linearIssueId, leaseId, now);
186
+ return row !== undefined;
187
+ }
188
+ getActiveIssueSessionLease(projectId, linearIssueId, now = isoNow()) {
189
+ const row = this.connection.prepare(`
190
+ SELECT lease_id
191
+ FROM issue_sessions
192
+ WHERE project_id = ? AND linear_issue_id = ?
193
+ AND lease_id IS NOT NULL
194
+ AND leased_until IS NOT NULL
195
+ AND leased_until > ?
196
+ LIMIT 1
197
+ `).get(projectId, linearIssueId, now);
198
+ const leaseId = typeof row?.lease_id === "string" ? row.lease_id : undefined;
199
+ if (!leaseId)
200
+ return undefined;
201
+ return { projectId, linearIssueId, leaseId };
202
+ }
203
+ withIssueSessionLease(projectId, linearIssueId, leaseId, fn) {
204
+ return this.transaction(() => {
205
+ if (!this.hasActiveIssueSessionLease(projectId, linearIssueId, leaseId)) {
206
+ return undefined;
207
+ }
208
+ return fn();
209
+ });
210
+ }
211
+ upsertIssueWithLease(lease, params) {
212
+ return this.withIssueSessionLease(lease.projectId, lease.linearIssueId, lease.leaseId, () => this.upsertIssue(params));
213
+ }
214
+ upsertIssueRespectingActiveLease(projectId, linearIssueId, params) {
215
+ const lease = this.getActiveIssueSessionLease(projectId, linearIssueId);
216
+ if (!lease) {
217
+ return this.upsertIssue(params);
218
+ }
219
+ return this.upsertIssueWithLease(lease, params);
220
+ }
221
+ finishRunWithLease(lease, runId, params) {
222
+ return this.withIssueSessionLease(lease.projectId, lease.linearIssueId, lease.leaseId, () => {
223
+ this.finishRun(runId, params);
224
+ return true;
225
+ }) ?? false;
226
+ }
227
+ finishRunRespectingActiveLease(projectId, linearIssueId, runId, params) {
228
+ const lease = this.getActiveIssueSessionLease(projectId, linearIssueId);
229
+ if (!lease) {
230
+ this.finishRun(runId, params);
231
+ return true;
232
+ }
233
+ return this.finishRunWithLease(lease, runId, params);
234
+ }
235
+ updateRunThreadWithLease(lease, runId, params) {
236
+ return this.withIssueSessionLease(lease.projectId, lease.linearIssueId, lease.leaseId, () => {
237
+ this.updateRunThread(runId, params);
238
+ return true;
239
+ }) ?? false;
240
+ }
241
+ consumeIssueSessionEventsWithLease(lease, eventIds, runId) {
242
+ return this.withIssueSessionLease(lease.projectId, lease.linearIssueId, lease.leaseId, () => {
243
+ this.consumeIssueSessionEvents(lease.projectId, lease.linearIssueId, eventIds, runId);
244
+ return true;
245
+ }) ?? false;
246
+ }
247
+ clearPendingIssueSessionEventsWithLease(lease) {
248
+ return this.withIssueSessionLease(lease.projectId, lease.linearIssueId, lease.leaseId, () => {
249
+ this.clearPendingIssueSessionEvents(lease.projectId, lease.linearIssueId);
250
+ return true;
251
+ }) ?? false;
252
+ }
253
+ clearPendingIssueSessionEventsRespectingActiveLease(projectId, linearIssueId) {
254
+ const lease = this.getActiveIssueSessionLease(projectId, linearIssueId);
255
+ if (!lease) {
256
+ this.clearPendingIssueSessionEvents(projectId, linearIssueId);
257
+ return true;
258
+ }
259
+ return this.clearPendingIssueSessionEventsWithLease(lease);
260
+ }
261
+ setIssueSessionLastWakeReasonWithLease(lease, lastWakeReason) {
262
+ return this.withIssueSessionLease(lease.projectId, lease.linearIssueId, lease.leaseId, () => {
263
+ this.setIssueSessionLastWakeReason(lease.projectId, lease.linearIssueId, lastWakeReason);
264
+ return true;
265
+ }) ?? false;
266
+ }
267
+ setIssueSessionLastWakeReason(projectId, linearIssueId, lastWakeReason) {
268
+ this.connection.prepare(`
269
+ UPDATE issue_sessions
270
+ SET last_wake_reason = ?, updated_at = ?
271
+ WHERE project_id = ? AND linear_issue_id = ?
272
+ `).run(lastWakeReason ?? null, isoNow(), projectId, linearIssueId);
273
+ }
274
+ setBranchOwnerWithLease(lease, owner) {
275
+ return this.withIssueSessionLease(lease.projectId, lease.linearIssueId, lease.leaseId, () => {
276
+ this.setBranchOwner(lease.projectId, lease.linearIssueId, owner);
277
+ return true;
278
+ }) ?? false;
279
+ }
280
+ setBranchOwnerRespectingActiveLease(projectId, linearIssueId, owner) {
281
+ const lease = this.getActiveIssueSessionLease(projectId, linearIssueId);
282
+ if (!lease) {
283
+ this.setBranchOwner(projectId, linearIssueId, owner);
284
+ return true;
285
+ }
286
+ return this.setBranchOwnerWithLease(lease, owner);
287
+ }
288
+ releaseIssueSessionLeaseRespectingActiveLease(projectId, linearIssueId) {
289
+ const lease = this.getActiveIssueSessionLease(projectId, linearIssueId);
290
+ this.releaseIssueSessionLease(projectId, linearIssueId, lease?.leaseId);
291
+ }
292
+ }
@@ -0,0 +1,127 @@
1
+ import { extractLatestAssistantSummary } from "../issue-session-events.js";
2
+ import { isoNow } from "./shared.js";
3
+ export class RunStore {
4
+ connection;
5
+ mapRunRow;
6
+ getRun;
7
+ getIssue;
8
+ syncIssueSessionFromIssue;
9
+ constructor(connection, mapRunRow, getRun, getIssue, syncIssueSessionFromIssue) {
10
+ this.connection = connection;
11
+ this.mapRunRow = mapRunRow;
12
+ this.getRun = getRun;
13
+ this.getIssue = getIssue;
14
+ this.syncIssueSessionFromIssue = syncIssueSessionFromIssue;
15
+ }
16
+ createRun(params) {
17
+ const now = isoNow();
18
+ const result = this.connection.prepare(`
19
+ INSERT INTO runs (issue_id, project_id, linear_issue_id, run_type, status, source_head_sha, prompt_text, started_at)
20
+ VALUES (?, ?, ?, ?, 'queued', ?, ?, ?)
21
+ `).run(params.issueId, params.projectId, params.linearIssueId, params.runType, params.sourceHeadSha ?? null, params.promptText ?? null, now);
22
+ const run = this.getRun(Number(result.lastInsertRowid));
23
+ const issue = this.getIssue(params.projectId, params.linearIssueId);
24
+ if (issue) {
25
+ this.syncIssueSessionFromIssue(issue, { lastRunType: run.runType });
26
+ }
27
+ return run;
28
+ }
29
+ getRunById(id) {
30
+ const row = this.connection.prepare("SELECT * FROM runs WHERE id = ?").get(id);
31
+ return row ? this.mapRunRow(row) : undefined;
32
+ }
33
+ getRunByThreadId(threadId) {
34
+ const row = this.connection.prepare("SELECT * FROM runs WHERE thread_id = ?").get(threadId);
35
+ return row ? this.mapRunRow(row) : undefined;
36
+ }
37
+ listRunsForIssue(projectId, linearIssueId) {
38
+ const rows = this.connection
39
+ .prepare("SELECT * FROM runs WHERE project_id = ? AND linear_issue_id = ? ORDER BY id")
40
+ .all(projectId, linearIssueId);
41
+ return rows.map(this.mapRunRow);
42
+ }
43
+ getLatestRunForIssue(projectId, linearIssueId) {
44
+ const row = this.connection
45
+ .prepare("SELECT * FROM runs WHERE project_id = ? AND linear_issue_id = ? ORDER BY id DESC LIMIT 1")
46
+ .get(projectId, linearIssueId);
47
+ return row ? this.mapRunRow(row) : undefined;
48
+ }
49
+ listActiveRuns() {
50
+ const rows = this.connection
51
+ .prepare("SELECT * FROM runs WHERE status IN ('queued', 'running')")
52
+ .all();
53
+ return rows.map(this.mapRunRow);
54
+ }
55
+ listRunningRuns() {
56
+ const rows = this.connection
57
+ .prepare("SELECT * FROM runs WHERE status IN ('running', 'queued')")
58
+ .all();
59
+ return rows.map(this.mapRunRow);
60
+ }
61
+ updateRunThread(runId, params) {
62
+ this.connection.prepare(`
63
+ UPDATE runs SET
64
+ thread_id = ?,
65
+ parent_thread_id = COALESCE(?, parent_thread_id),
66
+ turn_id = COALESCE(?, turn_id),
67
+ status = 'running'
68
+ WHERE id = ?
69
+ AND ended_at IS NULL
70
+ AND status IN ('queued', 'running')
71
+ `).run(params.threadId, params.parentThreadId ?? null, params.turnId ?? null, runId);
72
+ const run = this.getRun(runId);
73
+ if (!run)
74
+ return;
75
+ const issue = this.getIssue(run.projectId, run.linearIssueId);
76
+ if (issue) {
77
+ this.syncIssueSessionFromIssue(issue);
78
+ }
79
+ }
80
+ updateRunTurnId(runId, turnId) {
81
+ this.connection.prepare("UPDATE runs SET turn_id = ? WHERE id = ?").run(turnId, runId);
82
+ }
83
+ finishRun(runId, params) {
84
+ const now = isoNow();
85
+ this.connection.prepare(`
86
+ UPDATE runs SET
87
+ status = ?,
88
+ thread_id = COALESCE(?, thread_id),
89
+ turn_id = COALESCE(?, turn_id),
90
+ failure_reason = COALESCE(?, failure_reason),
91
+ summary_json = COALESCE(?, summary_json),
92
+ report_json = COALESCE(?, report_json),
93
+ ended_at = ?
94
+ WHERE id = ?
95
+ `).run(params.status, params.threadId ?? null, params.turnId ?? null, params.failureReason ?? null, params.summaryJson ?? null, params.reportJson ?? null, now, runId);
96
+ const run = this.getRun(runId);
97
+ if (!run)
98
+ return;
99
+ const issue = this.getIssue(run.projectId, run.linearIssueId);
100
+ if (issue) {
101
+ this.syncIssueSessionFromIssue(issue, {
102
+ summaryText: extractLatestAssistantSummary(this.getRun(runId) ?? run),
103
+ lastRunType: run.runType,
104
+ });
105
+ }
106
+ }
107
+ saveThreadEvent(params) {
108
+ this.connection.prepare(`
109
+ INSERT INTO run_thread_events (run_id, thread_id, turn_id, method, event_json, created_at)
110
+ VALUES (?, ?, ?, ?, ?, ?)
111
+ `).run(params.runId, params.threadId, params.turnId ?? null, params.method, params.eventJson, isoNow());
112
+ }
113
+ listThreadEvents(runId) {
114
+ const rows = this.connection
115
+ .prepare("SELECT * FROM run_thread_events WHERE run_id = ? ORDER BY id")
116
+ .all(runId);
117
+ return rows.map((row) => ({
118
+ id: Number(row.id),
119
+ runId: Number(row.run_id),
120
+ threadId: String(row.thread_id),
121
+ ...(row.turn_id !== null ? { turnId: String(row.turn_id) } : {}),
122
+ method: String(row.method),
123
+ eventJson: String(row.event_json),
124
+ createdAt: String(row.created_at),
125
+ }));
126
+ }
127
+ }
@@ -0,0 +1,71 @@
1
+ export class WebhookEventStore {
2
+ connection;
3
+ constructor(connection) {
4
+ this.connection = connection;
5
+ }
6
+ insertWebhookEvent(webhookId, receivedAt) {
7
+ const existing = this.connection
8
+ .prepare("SELECT id FROM webhook_events WHERE webhook_id = ?")
9
+ .get(webhookId);
10
+ if (existing) {
11
+ return { id: existing.id, duplicate: true };
12
+ }
13
+ const result = this.connection
14
+ .prepare("INSERT INTO webhook_events (webhook_id, received_at, processing_status) VALUES (?, ?, 'processed')")
15
+ .run(webhookId, receivedAt);
16
+ return { id: Number(result.lastInsertRowid), duplicate: false };
17
+ }
18
+ insertFullWebhookEvent(params) {
19
+ const existing = this.connection
20
+ .prepare("SELECT id FROM webhook_events WHERE webhook_id = ?")
21
+ .get(params.webhookId);
22
+ if (existing) {
23
+ return { id: existing.id, dedupeStatus: "duplicate" };
24
+ }
25
+ const result = this.connection
26
+ .prepare("INSERT INTO webhook_events (webhook_id, received_at, payload_json) VALUES (?, ?, ?)")
27
+ .run(params.webhookId, params.receivedAt, params.payloadJson);
28
+ return { id: Number(result.lastInsertRowid), dedupeStatus: "accepted" };
29
+ }
30
+ getWebhookPayload(id) {
31
+ const row = this.connection.prepare("SELECT webhook_id, payload_json FROM webhook_events WHERE id = ?").get(id);
32
+ if (!row || !row.payload_json)
33
+ return undefined;
34
+ return { webhookId: String(row.webhook_id), payloadJson: String(row.payload_json) };
35
+ }
36
+ isWebhookDuplicate(webhookId) {
37
+ return this.connection.prepare("SELECT 1 FROM webhook_events WHERE webhook_id = ?").get(webhookId) !== undefined;
38
+ }
39
+ markWebhookProcessed(id, status) {
40
+ this.connection.prepare("UPDATE webhook_events SET processing_status = ? WHERE id = ?").run(status, id);
41
+ }
42
+ assignWebhookProject(id, projectId) {
43
+ this.connection.prepare("UPDATE webhook_events SET project_id = ? WHERE id = ?").run(projectId, id);
44
+ }
45
+ findLatestAgentSessionIdForIssue(linearIssueId) {
46
+ const row = this.connection.prepare(`
47
+ SELECT COALESCE(
48
+ json_extract(payload_json, '$.agentSession.id'),
49
+ json_extract(payload_json, '$.data.agentSession.id'),
50
+ json_extract(payload_json, '$.agentSessionId'),
51
+ json_extract(payload_json, '$.data.agentSessionId')
52
+ ) AS agent_session_id
53
+ FROM webhook_events
54
+ WHERE COALESCE(
55
+ json_extract(payload_json, '$.agentSession.issueId'),
56
+ json_extract(payload_json, '$.data.agentSession.issueId'),
57
+ json_extract(payload_json, '$.agentSession.issue.id'),
58
+ json_extract(payload_json, '$.data.agentSession.issue.id')
59
+ ) = ?
60
+ AND COALESCE(
61
+ json_extract(payload_json, '$.agentSession.id'),
62
+ json_extract(payload_json, '$.data.agentSession.id'),
63
+ json_extract(payload_json, '$.agentSessionId'),
64
+ json_extract(payload_json, '$.data.agentSessionId')
65
+ ) IS NOT NULL
66
+ ORDER BY id DESC
67
+ LIMIT 1
68
+ `).get(linearIssueId);
69
+ return row?.agent_session_id != null ? String(row.agent_session_id) : undefined;
70
+ }
71
+ }