patchrelay 0.75.0 → 0.75.2

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.75.0",
4
- "commit": "ad9b09da1d53",
5
- "builtAt": "2026-05-29T13:21:05.647Z"
3
+ "version": "0.75.2",
4
+ "commit": "ba00b4a18609",
5
+ "builtAt": "2026-06-05T23:03:05.291Z"
6
6
  }
package/dist/cli/args.js CHANGED
@@ -11,6 +11,7 @@ export const KNOWN_COMMANDS = new Set([
11
11
  "repos",
12
12
  "linear",
13
13
  "repo",
14
+ "maintenance",
14
15
  "dashboard",
15
16
  "dash",
16
17
  "d",
@@ -120,7 +121,9 @@ export function assertKnownFlags(parsed, command, allowedFlags) {
120
121
  ? "issue"
121
122
  : command === "service"
122
123
  ? "service"
123
- : "root");
124
+ : command === "maintenance"
125
+ ? "maintenance"
126
+ : "root");
124
127
  }
125
128
  export function parsePositiveIntegerFlag(value, flagName) {
126
129
  if (typeof value !== "string") {
@@ -1,6 +1,6 @@
1
1
  import { hasOpenPr } from "../../pr-state.js";
2
2
  import { collectActiveOverlapFindings } from "./active-overlap.js";
3
- import { evaluateLocalIssueHealth, isResolvedDependency, needsReviewAutomation, } from "./local-issue-health.js";
3
+ import { evaluateLocalIssueHealth, evaluateTerminalIssueHealth, isActiveWorkflowIssue, isTerminalFailureIssue, isResolvedDependency, needsReviewAutomation, } from "./local-issue-health.js";
4
4
  import { evaluateGitHubIssueHealth } from "./github-issue-health.js";
5
5
  import { collectReviewQuillAttemptOwners, } from "./review-quill-probe.js";
6
6
  import { probeOptionalService, probePatchRelayService, } from "./service-probe.js";
@@ -10,7 +10,8 @@ export async function collectClusterHealth(config, db, runCommand) {
10
10
  const ciEntries = [];
11
11
  const now = Date.now();
12
12
  const issues = db.listIssues();
13
- const openIssues = issues.filter((issue) => issue.factoryState !== "done");
13
+ const activeWorkflowIssues = issues.filter((issue) => isActiveWorkflowIssue(issue));
14
+ const historicalTerminalIssues = issues.filter((issue) => isTerminalFailureIssue(issue));
14
15
  const trackedByKey = new Map(issues
15
16
  .filter((issue) => issue.issueKey)
16
17
  .map((issue) => [issue.issueKey, issue]));
@@ -21,7 +22,7 @@ export async function collectClusterHealth(config, db, runCommand) {
21
22
  scope: "service:patchrelay",
22
23
  message: patchRelayProbe.message,
23
24
  });
24
- const snapshots = openIssues.map((issue) => {
25
+ const snapshots = activeWorkflowIssues.map((issue) => {
25
26
  const tracked = db.getTrackedIssue(issue.projectId, issue.linearIssueId);
26
27
  const deps = db.issues.listIssueDependencies(issue.projectId, issue.linearIssueId);
27
28
  const blockedBy = deps.filter((dep) => !isResolvedDependency(dep));
@@ -99,6 +100,17 @@ export async function collectClusterHealth(config, db, runCommand) {
99
100
  });
100
101
  }
101
102
  }
103
+ for (const issue of historicalTerminalIssues) {
104
+ const finding = evaluateTerminalIssueHealth(issue);
105
+ if (finding) {
106
+ checks.push({
107
+ ...finding,
108
+ ...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
109
+ projectId: issue.projectId,
110
+ ...(issue.prNumber !== undefined ? { prNumber: issue.prNumber } : {}),
111
+ });
112
+ }
113
+ }
102
114
  checks.push(...await collectActiveOverlapFindings(snapshots, runCommand));
103
115
  for (const snapshot of snapshots) {
104
116
  if (!hasOpenPr(snapshot.issue.prNumber, snapshot.issue.prState)) {
@@ -118,18 +130,18 @@ export async function collectClusterHealth(config, db, runCommand) {
118
130
  }
119
131
  }
120
132
  const workflowFailures = checks.filter((check) => check.scope.startsWith("issue:") || check.scope.startsWith("github:"));
121
- if (workflowFailures.every((check) => check.status === "pass" || check.status === "warn") && openIssues.length > 0) {
133
+ if (workflowFailures.every((check) => check.status === "pass" || check.status === "warn") && activeWorkflowIssues.length > 0) {
122
134
  checks.push({
123
135
  status: "pass",
124
136
  scope: "workflow",
125
- message: `All ${openIssues.length} non-done issues currently have active work, a tracked blocker, or a downstream owner`,
137
+ message: `All ${activeWorkflowIssues.length} active workflow issues currently have active work, a tracked blocker, or a downstream owner`,
126
138
  });
127
139
  }
128
- if (openIssues.length === 0) {
140
+ if (activeWorkflowIssues.length === 0) {
129
141
  checks.push({
130
142
  status: "pass",
131
143
  scope: "workflow",
132
- message: "No non-done issues are currently tracked",
144
+ message: "No active workflow issues are currently tracked",
133
145
  });
134
146
  }
135
147
  if (ciEntries.length > 0) {
@@ -144,8 +156,8 @@ export async function collectClusterHealth(config, db, runCommand) {
144
156
  }
145
157
  const summary = {
146
158
  trackedIssues: issues.length,
147
- openIssues: openIssues.length,
148
- activeRuns: openIssues.filter((issue) => issue.activeRunId !== undefined).length,
159
+ openIssues: activeWorkflowIssues.length,
160
+ activeRuns: activeWorkflowIssues.filter((issue) => issue.activeRunId !== undefined).length,
149
161
  blockedIssues: snapshots.filter((snapshot) => snapshot.blockedBy.length > 0).length,
150
162
  readyIssues: snapshots.filter((snapshot) => snapshot.readyForExecution).length,
151
163
  ciTrackedPrs: ciEntries.length,
@@ -13,21 +13,30 @@ export function isResolvedDependency(dep) {
13
13
  || state === "cancelled";
14
14
  }
15
15
  export function needsReviewAutomation(issue) {
16
- if (issue.factoryState === "awaiting_queue" || issue.factoryState === "done") {
16
+ if (issue.factoryState === "awaiting_queue" || !isActiveWorkflowIssue(issue)) {
17
17
  return false;
18
18
  }
19
19
  return hasOpenPr(issue.prNumber, issue.prState);
20
20
  }
21
- export function evaluateLocalIssueHealth(snapshot) {
22
- const { issue, session, missingTrackedBlockers, blockedBy, ageMs, readyForExecution } = snapshot;
23
- const pausedNoPrWork = isUndelegatedPausedNoPrWork(issue);
21
+ export function isActiveWorkflowIssue(issue) {
22
+ return issue.factoryState !== "done" && !isTerminalFailureIssue(issue);
23
+ }
24
+ export function isTerminalFailureIssue(issue) {
25
+ return issue.factoryState === "failed" || issue.factoryState === "escalated";
26
+ }
27
+ export function evaluateTerminalIssueHealth(issue) {
24
28
  if (issue.factoryState === "failed" || issue.factoryState === "escalated") {
25
29
  return {
26
- status: "fail",
30
+ status: "warn",
27
31
  scope: "issue:terminal",
28
- message: `Issue is in terminal failure state ${issue.factoryState}`,
32
+ message: `Historical terminal issue is in failure state ${issue.factoryState}`,
29
33
  };
30
34
  }
35
+ return undefined;
36
+ }
37
+ export function evaluateLocalIssueHealth(snapshot) {
38
+ const { issue, session, missingTrackedBlockers, blockedBy, ageMs, readyForExecution } = snapshot;
39
+ const pausedNoPrWork = isUndelegatedPausedNoPrWork(issue);
31
40
  if (missingTrackedBlockers.length > 0) {
32
41
  return {
33
42
  status: "fail",
@@ -0,0 +1,55 @@
1
+ import { runWebhookEventRetention } from "../../event-retention.js";
2
+ import { CliUsageError } from "../errors.js";
3
+ import { formatJson } from "../formatters/json.js";
4
+ import { writeOutput } from "../output.js";
5
+ export async function handleMaintenanceCommand(params) {
6
+ const [subcommand] = params.commandArgs;
7
+ if (subcommand !== "prune-events") {
8
+ throw new CliUsageError(`Unknown maintenance command: ${subcommand ?? ""}`.trim(), "maintenance");
9
+ }
10
+ const retentionDays = readPositiveIntegerFlag(params.parsed, "retention-days");
11
+ const batchSize = readPositiveIntegerFlag(params.parsed, "batch-size");
12
+ const archive = params.parsed.flags.get("archive") === true;
13
+ const discard = params.parsed.flags.get("discard") === true;
14
+ if (archive && discard) {
15
+ throw new CliUsageError("Use either --archive or --discard, not both", "maintenance");
16
+ }
17
+ const result = await runWebhookEventRetention({
18
+ db: params.data.db,
19
+ config: params.config,
20
+ options: {
21
+ dryRun: params.parsed.flags.get("dry-run") === true,
22
+ ...(retentionDays !== undefined ? { retentionDays } : {}),
23
+ ...(batchSize !== undefined ? { batchSize } : {}),
24
+ ...(archive ? { archiveOldEvents: true } : {}),
25
+ ...(discard ? { archiveOldEvents: false } : {}),
26
+ },
27
+ });
28
+ if (params.json) {
29
+ writeOutput(params.stdout, formatJson(result));
30
+ return 0;
31
+ }
32
+ writeOutput(params.stdout, [
33
+ `Cutoff: ${result.cutoffIso}`,
34
+ `Scanned: ${result.scanned}`,
35
+ `Archived: ${result.archived}`,
36
+ `Deleted: ${result.deleted}`,
37
+ `Remaining: ${result.remaining}`,
38
+ result.archiveFile ? `Archive: ${result.archiveFile}` : undefined,
39
+ result.dryRun ? "Dry run: yes" : undefined,
40
+ ].filter(Boolean).join("\n") + "\n");
41
+ return 0;
42
+ }
43
+ function readPositiveIntegerFlag(parsed, flag) {
44
+ const value = parsed.flags.get(flag);
45
+ if (value === undefined || value === false)
46
+ return undefined;
47
+ if (value === true) {
48
+ throw new CliUsageError(`--${flag} requires a value`, "maintenance");
49
+ }
50
+ const parsedValue = Number(value);
51
+ if (!Number.isInteger(parsedValue) || parsedValue <= 0) {
52
+ throw new CliUsageError(`--${flag} must be a positive integer`, "maintenance");
53
+ }
54
+ return parsedValue;
55
+ }
package/dist/cli/help.js CHANGED
@@ -45,6 +45,8 @@ export function rootHelpText() {
45
45
  " service status [--json] Show systemd state and local health",
46
46
  " service codex-status [--json] Show Codex account and usage snapshot from this service",
47
47
  " cluster [--json] Check service + workflow health across all tracked issues",
48
+ " maintenance prune-events [--dry-run] [--archive|--discard] [--retention-days <days>] [--json]",
49
+ " Prune or archive old processed webhook events",
48
50
  " service logs [--lines <count>] [--json] Show recent service logs",
49
51
  " serve Run the local PatchRelay service",
50
52
  "",
@@ -79,6 +81,7 @@ export function rootHelpText() {
79
81
  " patchrelay help issue",
80
82
  " patchrelay help service",
81
83
  " patchrelay help cluster",
84
+ " patchrelay help maintenance",
82
85
  ].join("\n");
83
86
  }
84
87
  export function linearHelpText() {
@@ -204,10 +207,35 @@ export function clusterHelpText() {
204
207
  " patchrelay cluster --json",
205
208
  ].join("\n");
206
209
  }
210
+ export function maintenanceHelpText() {
211
+ return [
212
+ "Usage:",
213
+ " patchrelay maintenance prune-events [options]",
214
+ "",
215
+ "Options:",
216
+ " --dry-run Count archiveable events without deleting",
217
+ " --archive Write old events to cold JSONL gzip storage before deleting",
218
+ " --discard Delete old events without archiving",
219
+ " --retention-days <days> Override configured retention window (default 7)",
220
+ " --batch-size <count> Override maintenance batch size",
221
+ " --json Emit structured JSON output",
222
+ " --help, -h Show this help",
223
+ "",
224
+ "Behavior:",
225
+ " Only processed webhook events older than the retention cutoff are touched.",
226
+ " Pending/unprocessed webhook events are never pruned.",
227
+ "",
228
+ "Examples:",
229
+ " patchrelay maintenance prune-events --dry-run",
230
+ " patchrelay maintenance prune-events --archive",
231
+ ].join("\n");
232
+ }
207
233
  export function helpTextFor(topic) {
208
234
  switch (topic) {
209
235
  case "cluster":
210
236
  return clusterHelpText();
237
+ case "maintenance":
238
+ return maintenanceHelpText();
211
239
  case "linear":
212
240
  return linearHelpText();
213
241
  case "repo":
package/dist/cli/index.js CHANGED
@@ -6,6 +6,7 @@ import { handleClusterCommand } from "./commands/cluster.js";
6
6
  import { handleLinearCommand } from "./commands/linear.js";
7
7
  import { handleSequenceCheckCommand } from "./commands/sequence-check.js";
8
8
  import { handleRepoCommand } from "./commands/repo.js";
9
+ import { handleMaintenanceCommand } from "./commands/maintenance.js";
9
10
  import { handleInitCommand, handleServiceCommand } from "./commands/setup.js";
10
11
  import { CliUsageError } from "./errors.js";
11
12
  import { formatJson } from "./formatters/json.js";
@@ -25,6 +26,7 @@ function getCommandConfigProfile(command) {
25
26
  case "cluster":
26
27
  case "repo":
27
28
  case "issue":
29
+ case "maintenance":
28
30
  case "sequence-check":
29
31
  return "cli";
30
32
  default:
@@ -89,6 +91,13 @@ function validateFlags(command, commandArgs, parsed) {
89
91
  case "cluster":
90
92
  assertKnownFlags(parsed, command, ["json"]);
91
93
  return;
94
+ case "maintenance":
95
+ if (commandArgs[0] === "prune-events") {
96
+ assertKnownFlags(parsed, command, ["json", "dry-run", "archive", "discard", "retention-days", "batch-size"]);
97
+ return;
98
+ }
99
+ assertKnownFlags(parsed, command, []);
100
+ return;
92
101
  case "sequence-check":
93
102
  assertKnownFlags(parsed, command, ["json", "base"]);
94
103
  return;
@@ -185,7 +194,7 @@ export async function runCli(argv, options) {
185
194
  const json = parsed.flags.get("json") === true;
186
195
  if (command === "help") {
187
196
  const topic = commandArgs[0];
188
- if (topic === "linear" || topic === "repo" || topic === "issue" || topic === "service" || topic === "cluster") {
197
+ if (topic === "linear" || topic === "repo" || topic === "issue" || topic === "service" || topic === "cluster" || topic === "maintenance") {
189
198
  writeOutput(stdout, `${helpTextFor(topic)}\n`);
190
199
  return 0;
191
200
  }
@@ -206,7 +215,7 @@ export async function runCli(argv, options) {
206
215
  ? "linear"
207
216
  : command === "repo"
208
217
  ? "repo"
209
- : command === "issue" || command === "service" || command === "cluster"
218
+ : command === "issue" || command === "service" || command === "cluster" || command === "maintenance"
210
219
  ? command
211
220
  : "root";
212
221
  writeOutput(stdout, `${helpTextFor(helpTopic)}\n`);
@@ -354,6 +363,21 @@ export async function runCli(argv, options) {
354
363
  runCommand,
355
364
  });
356
365
  }
366
+ if (command === "maintenance") {
367
+ const issueData = await ensureIssueDataAccess(data, config);
368
+ if (!data) {
369
+ data = issueData;
370
+ ownsData = true;
371
+ }
372
+ return await handleMaintenanceCommand({
373
+ commandArgs,
374
+ parsed,
375
+ json,
376
+ stdout,
377
+ data: issueData,
378
+ config,
379
+ });
380
+ }
357
381
  if (command === "dashboard") {
358
382
  const { handleWatchCommand } = await import("./commands/watch.js");
359
383
  return await handleWatchCommand({ config, parsed });
@@ -34,7 +34,7 @@ export function formatClusterHealth(report) {
34
34
  lines.push(`${marker} [${detail}] ${check.message}`);
35
35
  }
36
36
  lines.push("");
37
- lines.push(`Summary: tracked=${report.summary.trackedIssues} non_done=${report.summary.openIssues} active_runs=${report.summary.activeRuns} blocked=${report.summary.blockedIssues} ready=${report.summary.readyIssues}`);
37
+ lines.push(`Summary: tracked=${report.summary.trackedIssues} active=${report.summary.openIssues} active_runs=${report.summary.activeRuns} blocked=${report.summary.blockedIssues} ready=${report.summary.readyIssues}`);
38
38
  if (report.summary.ciTrackedPrs > 0) {
39
39
  lines.push(`CI summary: prs=${report.summary.ciTrackedPrs} pending=${report.summary.ciPending} success=${report.summary.ciSuccess} failure=${report.summary.ciFailure} unknown=${report.summary.ciUnknown} missing_owner=${report.summary.ciOrphaned}`);
40
40
  for (const entry of report.ci) {
@@ -23,6 +23,7 @@ const FOLLOWUP_INTENT_DEVELOPER_INSTRUCTIONS = [
23
23
  "Use only the text and state facts in the current prompt.",
24
24
  "Return only the requested JSON object.",
25
25
  ].join("\n");
26
+ const MAX_STDOUT_MESSAGES_PER_DRAIN = 100;
26
27
  export function resolveCodexAppServerLaunch(config) {
27
28
  if (!config.sourceBashrc) {
28
29
  return {
@@ -50,6 +51,7 @@ export class CodexAppServerClient extends EventEmitter {
50
51
  nextRequestId = 1;
51
52
  pending = new Map();
52
53
  stdoutBuffer = "";
54
+ drainScheduled = false;
53
55
  started = false;
54
56
  stopping = false;
55
57
  startPromise;
@@ -360,6 +362,7 @@ export class CodexAppServerClient extends EventEmitter {
360
362
  this.started = false;
361
363
  this.child = undefined;
362
364
  this.stdoutBuffer = "";
365
+ this.drainScheduled = false;
363
366
  const log = this.stopping ? this.logger.info.bind(this.logger) : this.logger.warn.bind(this.logger);
364
367
  log({
365
368
  code: code ?? 1,
@@ -374,11 +377,12 @@ export class CodexAppServerClient extends EventEmitter {
374
377
  if (this.stdoutBuffer.length > 50 * 1024 * 1024) {
375
378
  this.logger.error({ bufferSize: this.stdoutBuffer.length }, "Codex app-server stdout buffer exceeded 50 MB — killing process");
376
379
  this.stdoutBuffer = "";
380
+ this.drainScheduled = false;
377
381
  this.rejectAllPending(new Error("Codex app-server stdout buffer overflow"));
378
382
  this.child?.kill("SIGTERM");
379
383
  return;
380
384
  }
381
- this.drainMessages();
385
+ this.scheduleDrainMessages();
382
386
  });
383
387
  const initializeResponse = await this.sendRequest("initialize", {
384
388
  clientInfo: {
@@ -438,6 +442,7 @@ export class CodexAppServerClient extends EventEmitter {
438
442
  this.started = false;
439
443
  this.child = undefined;
440
444
  this.stdoutBuffer = "";
445
+ this.drainScheduled = false;
441
446
  this.logger.error({
442
447
  error: sanitizeDiagnosticText(error.message),
443
448
  pendingRequestCount: this.pending.size,
@@ -446,7 +451,8 @@ export class CodexAppServerClient extends EventEmitter {
446
451
  child?.kill("SIGTERM");
447
452
  }
448
453
  drainMessages() {
449
- while (true) {
454
+ let processed = 0;
455
+ while (processed < MAX_STDOUT_MESSAGES_PER_DRAIN) {
450
456
  const newlineIndex = this.stdoutBuffer.indexOf("\n");
451
457
  if (newlineIndex === -1) {
452
458
  return;
@@ -458,6 +464,7 @@ export class CodexAppServerClient extends EventEmitter {
458
464
  }
459
465
  try {
460
466
  this.handleMessage(JSON.parse(line));
467
+ processed += 1;
461
468
  }
462
469
  catch (error) {
463
470
  const err = error instanceof Error ? error : new Error(String(error));
@@ -471,6 +478,19 @@ export class CodexAppServerClient extends EventEmitter {
471
478
  return;
472
479
  }
473
480
  }
481
+ if (this.stdoutBuffer.includes("\n")) {
482
+ this.scheduleDrainMessages();
483
+ }
484
+ }
485
+ scheduleDrainMessages() {
486
+ if (this.drainScheduled) {
487
+ return;
488
+ }
489
+ this.drainScheduled = true;
490
+ setImmediate(() => {
491
+ this.drainScheduled = false;
492
+ this.drainMessages();
493
+ });
474
494
  }
475
495
  handleMessage(message) {
476
496
  if ("method" in message && "id" in message) {
package/dist/config.js CHANGED
@@ -131,6 +131,9 @@ const configSchema = z.object({
131
131
  database: z.object({
132
132
  path: z.string().min(1).default(getDefaultDatabasePath()),
133
133
  wal: z.boolean().default(true),
134
+ event_retention_days: z.number().int().positive().default(7),
135
+ archive_old_events: z.boolean().default(false),
136
+ archive_path: z.string().min(1).optional(),
134
137
  }),
135
138
  linear: z.object({
136
139
  webhook_secret_env: z.string().default("LINEAR_WEBHOOK_SECRET"),
@@ -555,6 +558,9 @@ export function loadConfig(configPath = process.env.PATCHRELAY_CONFIG ?? getDefa
555
558
  database: {
556
559
  path: ensureAbsolutePath(env.PATCHRELAY_DB_PATH ?? parsed.database.path),
557
560
  wal: parsed.database.wal,
561
+ eventRetentionDays: parsed.database.event_retention_days,
562
+ archiveOldEvents: parsed.database.archive_old_events,
563
+ archivePath: ensureAbsolutePath(parsed.database.archive_path ?? path.join(path.dirname(env.PATCHRELAY_DB_PATH ?? parsed.database.path), "archive")),
558
564
  },
559
565
  linear: {
560
566
  webhookSecret: webhookSecret ?? "",
@@ -49,6 +49,7 @@ CREATE TABLE IF NOT EXISTS runs (
49
49
  linear_issue_id TEXT NOT NULL,
50
50
  run_type TEXT NOT NULL DEFAULT 'implementation',
51
51
  status TEXT NOT NULL,
52
+ launch_phase TEXT,
52
53
  source_head_sha TEXT,
53
54
  prompt_text TEXT,
54
55
  thread_id TEXT,
@@ -248,6 +249,7 @@ CREATE INDEX IF NOT EXISTS idx_issue_sessions_key ON issue_sessions(issue_key);
248
249
  CREATE INDEX IF NOT EXISTS idx_issue_sessions_lease ON issue_sessions(leased_until, session_state);
249
250
  CREATE INDEX IF NOT EXISTS idx_issue_session_events_issue ON issue_session_events(project_id, linear_issue_id, id);
250
251
  CREATE INDEX IF NOT EXISTS idx_issue_session_events_pending ON issue_session_events(processed_at, project_id, linear_issue_id, id);
252
+ CREATE INDEX IF NOT EXISTS idx_webhook_events_retention ON webhook_events(processing_status, received_at, id);
251
253
  CREATE INDEX IF NOT EXISTS idx_run_thread_events_run ON run_thread_events(run_id, id);
252
254
  CREATE INDEX IF NOT EXISTS idx_operator_feed_events_issue ON operator_feed_events(issue_key, id);
253
255
  CREATE INDEX IF NOT EXISTS idx_operator_feed_events_project ON operator_feed_events(project_id, id);
@@ -295,6 +297,7 @@ export function runPatchRelayMigrations(connection) {
295
297
  WHERE display_updated_at IS NULL
296
298
  `).run();
297
299
  addColumnIfMissing(connection, "runs", "source_head_sha", "TEXT");
300
+ addColumnIfMissing(connection, "runs", "launch_phase", "TEXT");
298
301
  addColumnIfMissing(connection, "runs", "completion_check_thread_id", "TEXT");
299
302
  addColumnIfMissing(connection, "runs", "completion_check_turn_id", "TEXT");
300
303
  addColumnIfMissing(connection, "runs", "completion_check_outcome", "TEXT");
@@ -20,8 +20,8 @@ export class RunStore {
20
20
  createRun(params) {
21
21
  const now = isoNow();
22
22
  const result = this.connection.prepare(`
23
- INSERT INTO runs (issue_id, project_id, linear_issue_id, run_type, status, source_head_sha, prompt_text, started_at)
24
- VALUES (?, ?, ?, ?, 'queued', ?, ?, ?)
23
+ INSERT INTO runs (issue_id, project_id, linear_issue_id, run_type, status, launch_phase, source_head_sha, prompt_text, started_at)
24
+ VALUES (?, ?, ?, ?, 'queued', 'claimed', ?, ?, ?)
25
25
  `).run(params.issueId, params.projectId, params.linearIssueId, params.runType, params.sourceHeadSha ?? null, params.promptText ?? null, now);
26
26
  const run = this.getRunById(Number(result.lastInsertRowid));
27
27
  const issue = this.issues.getIssue(params.projectId, params.linearIssueId);
@@ -76,7 +76,8 @@ export class RunStore {
76
76
  thread_id = ?,
77
77
  parent_thread_id = COALESCE(?, parent_thread_id),
78
78
  turn_id = COALESCE(?, turn_id),
79
- status = 'running'
79
+ status = 'running',
80
+ launch_phase = 'running'
80
81
  WHERE id = ?
81
82
  AND ended_at IS NULL
82
83
  AND status IN ('queued', 'running')
@@ -100,6 +101,15 @@ export class RunStore {
100
101
  updateRunTurnId(runId, turnId) {
101
102
  this.connection.prepare("UPDATE runs SET turn_id = ? WHERE id = ?").run(turnId, runId);
102
103
  }
104
+ updateLaunchPhase(runId, launchPhase) {
105
+ this.connection.prepare(`
106
+ UPDATE runs
107
+ SET launch_phase = ?
108
+ WHERE id = ?
109
+ AND ended_at IS NULL
110
+ AND status IN ('queued', 'running')
111
+ `).run(launchPhase, runId);
112
+ }
103
113
  finishRun(runId, params) {
104
114
  const now = isoNow();
105
115
  this.connection.prepare(`
@@ -42,6 +42,46 @@ export class WebhookEventStore {
42
42
  assignWebhookProject(id, projectId) {
43
43
  this.connection.prepare("UPDATE webhook_events SET project_id = ? WHERE id = ?").run(projectId, id);
44
44
  }
45
+ listArchiveableEventsBefore(cutoffIso, limit) {
46
+ const rows = this.connection.prepare(`
47
+ SELECT id, webhook_id, received_at, project_id, payload_json, processing_status
48
+ FROM webhook_events
49
+ WHERE received_at < ?
50
+ AND processing_status != 'pending'
51
+ ORDER BY received_at ASC, id ASC
52
+ LIMIT ?
53
+ `).all(cutoffIso, limit);
54
+ return rows.map((row) => ({
55
+ id: Number(row.id),
56
+ webhookId: String(row.webhook_id),
57
+ receivedAt: String(row.received_at),
58
+ ...(row.project_id != null ? { projectId: String(row.project_id) } : {}),
59
+ ...(row.payload_json != null ? { payloadJson: String(row.payload_json) } : {}),
60
+ processingStatus: String(row.processing_status),
61
+ }));
62
+ }
63
+ countArchiveableEventsBefore(cutoffIso) {
64
+ const row = this.connection.prepare(`
65
+ SELECT COUNT(*) AS count
66
+ FROM webhook_events
67
+ WHERE received_at < ?
68
+ AND processing_status != 'pending'
69
+ `).get(cutoffIso);
70
+ return Number(row?.count ?? 0);
71
+ }
72
+ deleteWebhookEventsByIds(ids) {
73
+ if (ids.length === 0)
74
+ return 0;
75
+ return this.connection.transaction(() => {
76
+ let deleted = 0;
77
+ const statement = this.connection.prepare("DELETE FROM webhook_events WHERE id = ?");
78
+ for (const id of ids) {
79
+ const result = statement.run(id);
80
+ deleted += Number(result.changes ?? 0);
81
+ }
82
+ return deleted;
83
+ })();
84
+ }
45
85
  findLatestAgentSessionIdForIssue(linearIssueId) {
46
86
  const row = this.connection.prepare(`
47
87
  SELECT COALESCE(
package/dist/db.js CHANGED
@@ -16,6 +16,7 @@ import { TrackedIssueQuery } from "./tracked-issue-query.js";
16
16
  import { WorkflowWakeResolver } from "./workflow-wake-resolver.js";
17
17
  export class PatchRelayDatabase {
18
18
  connection;
19
+ issueSessionProjection;
19
20
  telemetry = noopTelemetry;
20
21
  telemetryProxy = {
21
22
  emit: (event) => this.telemetry.emit(event),
@@ -38,12 +39,13 @@ export class PatchRelayDatabase {
38
39
  this.connection.pragma("foreign_keys = ON");
39
40
  if (wal) {
40
41
  this.connection.pragma("journal_mode = WAL");
42
+ this.connection.pragma("synchronous = NORMAL");
41
43
  }
42
44
  this.linearInstallations = new LinearInstallationStore(this.connection);
43
45
  this.operatorFeed = new OperatorFeedStore(this.connection);
44
46
  this.repositories = new RepositoryLinkStore(this.connection);
45
47
  this.webhookEvents = new WebhookEventStore(this.connection);
46
- const issueSessionProjection = new ImmediateIssueSessionProjectionInvalidator({
48
+ this.issueSessionProjection = new ImmediateIssueSessionProjectionInvalidator({
47
49
  getIssue: (projectId, linearIssueId) => this.issues.getIssue(projectId, linearIssueId),
48
50
  listDependents: (projectId, blockerLinearIssueId) => this.issues.listDependents(projectId, blockerLinearIssueId),
49
51
  countUnresolvedBlockers: (projectId, linearIssueId) => this.issues.countUnresolvedBlockers(projectId, linearIssueId),
@@ -58,9 +60,9 @@ export class PatchRelayDatabase {
58
60
  }),
59
61
  telemetry: this.telemetryProxy,
60
62
  });
61
- this.issues = new IssueStore(this.connection, issueSessionProjection);
62
- this.runs = new RunStore(this.connection, mapRunRow, this.issues, issueSessionProjection, this.telemetryProxy);
63
- this.issueSessions = new IssueSessionStore(this.connection, mapIssueSessionRow, mapIssueSessionEventRow, this.issues, this.runs, issueSessionProjection, this.telemetryProxy);
63
+ this.issues = new IssueStore(this.connection, this.issueSessionProjection);
64
+ this.runs = new RunStore(this.connection, mapRunRow, this.issues, this.issueSessionProjection, this.telemetryProxy);
65
+ this.issueSessions = new IssueSessionStore(this.connection, mapIssueSessionRow, mapIssueSessionEventRow, this.issues, this.runs, this.issueSessionProjection, this.telemetryProxy);
64
66
  this.workflowWakes = new WorkflowWakeResolver(this.issues, this.issueSessions);
65
67
  this.trackedIssues = new TrackedIssueQuery(this.issues, this.issueSessions, this.workflowWakes, this.runs);
66
68
  }
@@ -78,6 +80,12 @@ export class PatchRelayDatabase {
78
80
  transaction(fn) {
79
81
  return this.connection.transaction(fn)();
80
82
  }
83
+ batchIssueSessionProjections(fn) {
84
+ return this.issueSessionProjection.batch(fn);
85
+ }
86
+ runWalCheckpoint(mode = "PASSIVE") {
87
+ return this.connection.prepare(`PRAGMA wal_checkpoint(${mode})`).all();
88
+ }
81
89
  close() {
82
90
  this.connection.close();
83
91
  }
@@ -248,6 +256,7 @@ function mapRunRow(row) {
248
256
  linearIssueId: String(row.linear_issue_id),
249
257
  runType: String(row.run_type ?? "implementation"),
250
258
  status: String(row.status),
259
+ ...(row.launch_phase !== null && row.launch_phase !== undefined ? { launchPhase: String(row.launch_phase) } : {}),
251
260
  ...(row.source_head_sha !== null ? { sourceHeadSha: String(row.source_head_sha) } : {}),
252
261
  ...(row.prompt_text !== null ? { promptText: String(row.prompt_text) } : {}),
253
262
  ...(row.thread_id !== null ? { threadId: String(row.thread_id) } : {}),