patchrelay 0.4.1 → 0.5.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.4.1",
4
- "commit": "13439842db03",
5
- "builtAt": "2026-03-13T09:25:15.012Z"
3
+ "version": "0.5.1",
4
+ "commit": "f9af41f91077",
5
+ "builtAt": "2026-03-13T13:10:49.667Z"
6
6
  }
package/dist/cli/args.js CHANGED
@@ -13,10 +13,12 @@ export const KNOWN_COMMANDS = new Set([
13
13
  "project",
14
14
  "connect",
15
15
  "installations",
16
+ "feed",
16
17
  "install-service",
17
18
  "restart-service",
18
19
  "help",
19
20
  ]);
21
+ const ISSUE_KEY_PATTERN = /^[A-Za-z][A-Za-z0-9]*-\d+$/;
20
22
  export function parseArgs(argv) {
21
23
  const positionals = [];
22
24
  const flags = new Map();
@@ -47,13 +49,16 @@ export function parseArgs(argv) {
47
49
  }
48
50
  export function resolveCommand(parsed) {
49
51
  const requestedCommand = parsed.positionals[0];
50
- const command = !requestedCommand
51
- ? "help"
52
- : KNOWN_COMMANDS.has(requestedCommand)
53
- ? requestedCommand
54
- : "inspect";
55
- const commandArgs = command === requestedCommand ? parsed.positionals.slice(1) : parsed.positionals;
56
- return { command, commandArgs };
52
+ if (!requestedCommand) {
53
+ return { command: "help", commandArgs: [] };
54
+ }
55
+ if (KNOWN_COMMANDS.has(requestedCommand)) {
56
+ return { command: requestedCommand, commandArgs: parsed.positionals.slice(1) };
57
+ }
58
+ if (ISSUE_KEY_PATTERN.test(requestedCommand)) {
59
+ return { command: "inspect", commandArgs: parsed.positionals };
60
+ }
61
+ throw new Error(`Unknown command: ${requestedCommand}. Run \`patchrelay help\`.`);
57
62
  }
58
63
  export function getStageFlag(value) {
59
64
  if (typeof value !== "string") {
@@ -71,3 +76,25 @@ export function parseCsvFlag(value) {
71
76
  .map((entry) => entry.trim())
72
77
  .filter(Boolean);
73
78
  }
79
+ export function assertKnownFlags(parsed, command, allowedFlags) {
80
+ const allowed = new Set(allowedFlags);
81
+ const unknownFlags = [...parsed.flags.keys()].filter((flag) => !allowed.has(flag)).sort();
82
+ if (unknownFlags.length === 0) {
83
+ return;
84
+ }
85
+ throw new Error(`Unknown flag${unknownFlags.length === 1 ? "" : "s"} for ${command}: ${unknownFlags.map((flag) => `--${flag}`).join(", ")}`);
86
+ }
87
+ export function parsePositiveIntegerFlag(value, flagName) {
88
+ if (typeof value !== "string") {
89
+ return undefined;
90
+ }
91
+ const trimmed = value.trim();
92
+ if (!/^\d+$/.test(trimmed)) {
93
+ throw new Error(`${flagName} must be a positive integer.`);
94
+ }
95
+ const parsed = Number(trimmed);
96
+ if (!Number.isSafeInteger(parsed) || parsed <= 0) {
97
+ throw new Error(`${flagName} must be a positive integer.`);
98
+ }
99
+ return parsed;
100
+ }
@@ -0,0 +1,53 @@
1
+ import { formatJson } from "../formatters/json.js";
2
+ import { formatOperatorFeed, formatOperatorFeedEvent } from "../formatters/text.js";
3
+ import { writeOutput } from "../output.js";
4
+ function parseLimit(value) {
5
+ if (typeof value !== "string") {
6
+ return 50;
7
+ }
8
+ const trimmed = value.trim();
9
+ if (!/^\d+$/.test(trimmed)) {
10
+ throw new Error("--limit must be a positive integer.");
11
+ }
12
+ const parsed = Number(trimmed);
13
+ if (!Number.isSafeInteger(parsed) || parsed <= 0) {
14
+ throw new Error("--limit must be a positive integer.");
15
+ }
16
+ return parsed;
17
+ }
18
+ export async function handleFeedCommand(params) {
19
+ const limit = parseLimit(params.parsed.flags.get("limit"));
20
+ const follow = params.parsed.flags.get("follow") === true;
21
+ const issueFlag = params.parsed.flags.get("issue");
22
+ const projectFlag = params.parsed.flags.get("project");
23
+ if (issueFlag === true) {
24
+ throw new Error("--issue requires a value.");
25
+ }
26
+ if (projectFlag === true) {
27
+ throw new Error("--project requires a value.");
28
+ }
29
+ const issueKey = typeof issueFlag === "string" ? issueFlag.trim() || undefined : undefined;
30
+ const projectId = typeof projectFlag === "string" ? projectFlag.trim() || undefined : undefined;
31
+ const query = {
32
+ limit,
33
+ ...(issueKey ? { issueKey } : {}),
34
+ ...(projectId ? { projectId } : {}),
35
+ };
36
+ if (!follow) {
37
+ const result = await params.data.listOperatorFeed(query);
38
+ writeOutput(params.stdout, params.json
39
+ ? formatJson(result)
40
+ : formatOperatorFeed(result, { color: "isTTY" in params.stdout && params.stdout.isTTY === true }));
41
+ return 0;
42
+ }
43
+ if (params.json) {
44
+ await params.data.followOperatorFeed((event) => {
45
+ writeOutput(params.stdout, formatJson(event));
46
+ }, query);
47
+ return 0;
48
+ }
49
+ await params.data.followOperatorFeed((event) => {
50
+ writeOutput(params.stdout, formatOperatorFeedEvent(event, { color: "isTTY" in params.stdout && params.stdout.isTTY === true }));
51
+ }, query);
52
+ return 0;
53
+ }
@@ -1,5 +1,5 @@
1
1
  import { setTimeout as delay } from "node:timers/promises";
2
- import { getStageFlag } from "../args.js";
2
+ import { getStageFlag, parsePositiveIntegerFlag } from "../args.js";
3
3
  import { formatJson } from "../formatters/json.js";
4
4
  import { formatEvents, formatInspect, formatList, formatLive, formatOpen, formatReport, formatRetry, formatWorktree } from "../formatters/text.js";
5
5
  import { buildOpenCommand } from "../interactive.js";
@@ -45,8 +45,9 @@ export async function handleReportCommand(params) {
45
45
  if (stage) {
46
46
  reportOptions.stage = stage;
47
47
  }
48
- if (typeof params.parsed.flags.get("stage-run") === "string") {
49
- reportOptions.stageRunId = Number(params.parsed.flags.get("stage-run"));
48
+ const stageRunId = parsePositiveIntegerFlag(params.parsed.flags.get("stage-run"), "--stage-run");
49
+ if (stageRunId !== undefined) {
50
+ reportOptions.stageRunId = stageRunId;
50
51
  }
51
52
  const result = params.data.report(issueKey, reportOptions);
52
53
  if (!result) {
@@ -62,7 +63,7 @@ export async function handleEventsCommand(params) {
62
63
  }
63
64
  const follow = params.parsed.flags.get("follow") === true;
64
65
  let afterId;
65
- let stageRunId = typeof params.parsed.flags.get("stage-run") === "string" ? Number(params.parsed.flags.get("stage-run")) : undefined;
66
+ let stageRunId = parsePositiveIntegerFlag(params.parsed.flags.get("stage-run"), "--stage-run");
66
67
  do {
67
68
  const result = params.data.events(issueKey, {
68
69
  ...(stageRunId !== undefined ? { stageRunId } : {}),
@@ -102,7 +103,7 @@ export async function handleOpenCommand(params) {
102
103
  throw new Error("open requires <issueKey>.");
103
104
  }
104
105
  if (params.json) {
105
- const result = params.data.open(issueKey);
106
+ const result = await params.data.resolveOpen(issueKey);
106
107
  if (!result) {
107
108
  throw new Error(`Workspace not found for ${issueKey}`);
108
109
  }
@@ -110,11 +111,12 @@ export async function handleOpenCommand(params) {
110
111
  return 0;
111
112
  }
112
113
  if (params.parsed.flags.get("print") === true) {
113
- const result = params.data.open(issueKey);
114
+ const result = await params.data.resolveOpen(issueKey);
114
115
  if (!result) {
115
116
  throw new Error(`Workspace not found for ${issueKey}`);
116
117
  }
117
- writeOutput(params.stdout, formatOpen(result));
118
+ const openCommand = buildOpenCommand(params.config, result.workspace.worktreePath, result.resumeThreadId);
119
+ writeOutput(params.stdout, formatOpen(result, openCommand));
118
120
  return 0;
119
121
  }
120
122
  const result = await params.data.prepareOpen(issueKey);
package/dist/cli/data.js CHANGED
@@ -175,12 +175,14 @@ export class CliDataAccess {
175
175
  ...(resumeThreadId ? { resumeThreadId } : {}),
176
176
  };
177
177
  }
178
- async prepareOpen(issueKey) {
178
+ async resolveOpen(issueKey, options) {
179
179
  const worktree = this.worktree(issueKey);
180
180
  if (!worktree) {
181
181
  return undefined;
182
182
  }
183
- await this.ensureOpenWorktree(worktree);
183
+ if (options?.ensureWorktree) {
184
+ await this.ensureOpenWorktree(worktree);
185
+ }
184
186
  const existingThreadId = await this.resolveStoredOpenThreadId(worktree);
185
187
  if (existingThreadId) {
186
188
  return {
@@ -188,6 +190,12 @@ export class CliDataAccess {
188
190
  resumeThreadId: existingThreadId,
189
191
  };
190
192
  }
193
+ if (!options?.createThreadIfMissing) {
194
+ return {
195
+ ...worktree,
196
+ needsNewSession: true,
197
+ };
198
+ }
191
199
  const codex = await this.getCodex();
192
200
  const thread = await codex.startThread({
193
201
  cwd: worktree.workspace.worktreePath,
@@ -206,6 +214,12 @@ export class CliDataAccess {
206
214
  resumeThreadId: thread.id,
207
215
  };
208
216
  }
217
+ async prepareOpen(issueKey) {
218
+ return await this.resolveOpen(issueKey, {
219
+ ensureWorktree: true,
220
+ createThreadIfMissing: true,
221
+ });
222
+ }
209
223
  retry(issueKey, options) {
210
224
  const issue = this.db.issueWorkflows.getTrackedIssueByKey(issueKey);
211
225
  if (!issue) {
@@ -448,6 +462,72 @@ export class CliDataAccess {
448
462
  async listInstallations() {
449
463
  return await this.requestJson("/api/installations");
450
464
  }
465
+ async listOperatorFeed(options) {
466
+ return await this.requestJson("/api/feed", {
467
+ ...(options?.limit && options.limit > 0 ? { limit: String(options.limit) } : {}),
468
+ ...(options?.issueKey ? { issue: options.issueKey } : {}),
469
+ ...(options?.projectId ? { project: options.projectId } : {}),
470
+ });
471
+ }
472
+ async followOperatorFeed(onEvent, options) {
473
+ const url = new URL("/api/feed", this.getOperatorBaseUrl());
474
+ url.searchParams.set("follow", "1");
475
+ if (options?.limit && options.limit > 0) {
476
+ url.searchParams.set("limit", String(options.limit));
477
+ }
478
+ if (options?.issueKey) {
479
+ url.searchParams.set("issue", options.issueKey);
480
+ }
481
+ if (options?.projectId) {
482
+ url.searchParams.set("project", options.projectId);
483
+ }
484
+ const response = await fetch(url, {
485
+ method: "GET",
486
+ headers: {
487
+ accept: "text/event-stream",
488
+ ...(this.config.operatorApi.bearerToken ? { authorization: `Bearer ${this.config.operatorApi.bearerToken}` } : {}),
489
+ },
490
+ });
491
+ if (!response.ok || !response.body) {
492
+ const body = await response.text().catch(() => "");
493
+ const message = this.readErrorMessage(body);
494
+ throw new Error(message ?? `Request failed: ${response.status}`);
495
+ }
496
+ const reader = response.body.getReader();
497
+ const decoder = new TextDecoder();
498
+ let buffer = "";
499
+ let dataLines = [];
500
+ while (true) {
501
+ const { done, value } = await reader.read();
502
+ if (done) {
503
+ break;
504
+ }
505
+ buffer += decoder.decode(value, { stream: true });
506
+ let newlineIndex = buffer.indexOf("\n");
507
+ while (newlineIndex !== -1) {
508
+ const rawLine = buffer.slice(0, newlineIndex);
509
+ buffer = buffer.slice(newlineIndex + 1);
510
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
511
+ if (!line) {
512
+ if (dataLines.length > 0) {
513
+ const parsed = JSON.parse(dataLines.join("\n"));
514
+ onEvent(parsed);
515
+ dataLines = [];
516
+ }
517
+ newlineIndex = buffer.indexOf("\n");
518
+ continue;
519
+ }
520
+ if (line.startsWith(":")) {
521
+ newlineIndex = buffer.indexOf("\n");
522
+ continue;
523
+ }
524
+ if (line.startsWith("data:")) {
525
+ dataLines.push(line.slice(5).trimStart());
526
+ }
527
+ newlineIndex = buffer.indexOf("\n");
528
+ }
529
+ }
530
+ }
451
531
  getOperatorBaseUrl() {
452
532
  const host = this.normalizeLocalHost(this.config.server.bind);
453
533
  return `http://${host}:${this.config.server.port}/`;
@@ -85,15 +85,22 @@ export function formatWorktree(result, cdOnly) {
85
85
  value("Repo", result.repoId),
86
86
  ].join("\n")}\n`;
87
87
  }
88
- export function formatOpen(result) {
88
+ function formatCommand(command, args) {
89
+ return [command, ...args].join(" ");
90
+ }
91
+ export function formatOpen(result, command) {
89
92
  const commands = [
90
93
  `cd ${result.workspace.worktreePath}`,
91
94
  "git branch --show-current",
92
- "codex --dangerously-bypass-approvals-and-sandbox",
93
95
  ];
94
- if (result.resumeThreadId) {
95
- commands.push(`codex --dangerously-bypass-approvals-and-sandbox resume ${result.resumeThreadId}`);
96
+ if (result.needsNewSession) {
97
+ commands.push(`# No resumable thread found; \`patchrelay open ${result.issue.issueKey ?? result.issue.linearIssueId}\` will create a fresh session.`);
96
98
  }
99
+ commands.push(command
100
+ ? formatCommand(command.command, command.args)
101
+ : result.resumeThreadId
102
+ ? `codex --dangerously-bypass-approvals-and-sandbox resume ${result.resumeThreadId}`
103
+ : "codex --dangerously-bypass-approvals-and-sandbox");
97
104
  return `${commands.join("\n")}\n`;
98
105
  }
99
106
  export function formatRetry(result) {
@@ -117,3 +124,45 @@ export function formatList(items) {
117
124
  ].join("\t"))
118
125
  .join("\n")}\n`;
119
126
  }
127
+ function colorize(enabled, code, value) {
128
+ return enabled ? `\u001B[${code}m${value}\u001B[0m` : value;
129
+ }
130
+ function formatFeedStatus(event, color) {
131
+ const raw = event.status ?? event.kind;
132
+ const label = raw.replaceAll("_", " ");
133
+ const padded = label.padEnd(15);
134
+ if (event.level === "error" || raw === "failed" || raw === "delivery_failed") {
135
+ return colorize(color, "31", padded);
136
+ }
137
+ if (event.level === "warn" || raw === "ignored" || raw === "fallback" || raw === "handoff") {
138
+ return colorize(color, "33", padded);
139
+ }
140
+ if (raw === "running" || raw === "started" || raw === "delegated") {
141
+ return colorize(color, "32", padded);
142
+ }
143
+ if (raw === "queued") {
144
+ return colorize(color, "36", padded);
145
+ }
146
+ return colorize(color, "2", padded);
147
+ }
148
+ export function formatOperatorFeedEvent(event, options) {
149
+ const color = options?.color === true;
150
+ const timestamp = new Date(event.at).toLocaleTimeString("en-GB", { hour12: false });
151
+ const issue = event.issueKey ?? event.projectId ?? "-";
152
+ const line = [
153
+ colorize(color, "2", timestamp),
154
+ colorize(color, "1", issue.padEnd(10)),
155
+ formatFeedStatus(event, color),
156
+ event.summary,
157
+ ].join(" ");
158
+ if (!event.detail) {
159
+ return `${line}\n`;
160
+ }
161
+ return `${line}\n${colorize(color, "2", ` ${truncateLine(event.detail)}`)}\n`;
162
+ }
163
+ export function formatOperatorFeed(result, options) {
164
+ if (result.events.length === 0) {
165
+ return "No feed events yet.\n";
166
+ }
167
+ return result.events.map((event) => formatOperatorFeedEvent(event, options)).join("");
168
+ }
package/dist/cli/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { loadConfig } from "../config.js";
2
2
  import { runPreflight } from "../preflight.js";
3
- import { parseArgs, resolveCommand } from "./args.js";
3
+ import { assertKnownFlags, parseArgs, resolveCommand } from "./args.js";
4
4
  import { handleConnectCommand, handleInstallationsCommand } from "./commands/connect.js";
5
+ import { handleFeedCommand } from "./commands/feed.js";
5
6
  import { handleEventsCommand, handleInspectCommand, handleListCommand, handleLiveCommand, handleOpenCommand, handleReportCommand, handleRetryCommand, handleWorktreeCommand, } from "./commands/issues.js";
6
7
  import { handleProjectCommand } from "./commands/project.js";
7
8
  import { handleInitCommand, handleInstallServiceCommand, handleRestartServiceCommand } from "./commands/setup.js";
@@ -39,7 +40,7 @@ function helpText() {
39
40
  "",
40
41
  "Commands:",
41
42
  " init <public-base-url> [--force] [--json] Bootstrap the machine-level PatchRelay home",
42
- " project apply <id> <repo-path> [--issue-prefix <prefixes>] [--team-id <ids>] [--no-connect] [--timeout <seconds>] [--json]",
43
+ " project apply <id> <repo-path> [--issue-prefix <prefixes>] [--team-id <ids>] [--no-connect] [--no-open] [--timeout <seconds>] [--json]",
43
44
  " Upsert one local repository and connect it to Linear when ready",
44
45
  " doctor [--json] Check secrets, paths, configured workflow files, git, and codex",
45
46
  " install-service [--force] [--write-only] [--json] Reinstall the systemd user service and watcher",
@@ -47,6 +48,8 @@ function helpText() {
47
48
  " connect [--project <projectId>] [--no-open] [--timeout <seconds>] [--json]",
48
49
  " Advanced: start or reuse a Linear installation directly",
49
50
  " installations [--json] Show connected Linear installations",
51
+ " feed [--follow] [--limit <count>] [--issue <issueKey>] [--project <projectId>] [--json]",
52
+ " Show a live operator feed from the daemon",
50
53
  " serve Run the local PatchRelay service",
51
54
  " inspect <issueKey> Show the latest known issue state",
52
55
  " live <issueKey> [--watch] [--json] Show the active run status",
@@ -69,6 +72,7 @@ function getCommandConfigProfile(command) {
69
72
  return "doctor";
70
73
  case "connect":
71
74
  case "installations":
75
+ case "feed":
72
76
  return "operator_cli";
73
77
  case "inspect":
74
78
  case "live":
@@ -83,11 +87,83 @@ function getCommandConfigProfile(command) {
83
87
  return "service";
84
88
  }
85
89
  }
90
+ function validateFlags(command, commandArgs, parsed) {
91
+ switch (command) {
92
+ case "help":
93
+ case "serve":
94
+ assertKnownFlags(parsed, command, []);
95
+ return;
96
+ case "inspect":
97
+ assertKnownFlags(parsed, command, ["json"]);
98
+ return;
99
+ case "live":
100
+ assertKnownFlags(parsed, command, ["watch", "json"]);
101
+ return;
102
+ case "report":
103
+ assertKnownFlags(parsed, command, ["stage", "stage-run", "json"]);
104
+ return;
105
+ case "events":
106
+ assertKnownFlags(parsed, command, ["stage-run", "method", "follow", "json"]);
107
+ return;
108
+ case "worktree":
109
+ assertKnownFlags(parsed, command, ["cd", "json"]);
110
+ return;
111
+ case "open":
112
+ assertKnownFlags(parsed, command, ["print", "json"]);
113
+ return;
114
+ case "retry":
115
+ assertKnownFlags(parsed, command, ["stage", "reason", "json"]);
116
+ return;
117
+ case "list":
118
+ assertKnownFlags(parsed, command, ["active", "failed", "project", "json"]);
119
+ return;
120
+ case "doctor":
121
+ assertKnownFlags(parsed, command, ["json"]);
122
+ return;
123
+ case "init":
124
+ assertKnownFlags(parsed, command, ["force", "json", "public-base-url"]);
125
+ return;
126
+ case "project":
127
+ if (commandArgs[0] === "apply") {
128
+ assertKnownFlags(parsed, "project apply", ["issue-prefix", "team-id", "no-connect", "no-open", "timeout", "json"]);
129
+ return;
130
+ }
131
+ assertKnownFlags(parsed, command, []);
132
+ return;
133
+ case "connect":
134
+ assertKnownFlags(parsed, command, ["project", "no-open", "timeout", "json"]);
135
+ return;
136
+ case "installations":
137
+ assertKnownFlags(parsed, command, ["json"]);
138
+ return;
139
+ case "feed":
140
+ assertKnownFlags(parsed, command, ["follow", "limit", "issue", "project", "json"]);
141
+ return;
142
+ case "install-service":
143
+ assertKnownFlags(parsed, command, ["force", "write-only", "json"]);
144
+ return;
145
+ case "restart-service":
146
+ assertKnownFlags(parsed, command, ["json"]);
147
+ return;
148
+ default:
149
+ return;
150
+ }
151
+ }
86
152
  export async function runCli(argv, options) {
87
153
  const stdout = options?.stdout ?? process.stdout;
88
154
  const stderr = options?.stderr ?? process.stderr;
89
- const parsed = parseArgs(argv);
90
- const { command, commandArgs } = resolveCommand(parsed);
155
+ let parsed;
156
+ let command;
157
+ let commandArgs;
158
+ try {
159
+ parsed = parseArgs(argv);
160
+ ({ command, commandArgs } = resolveCommand(parsed));
161
+ validateFlags(command, commandArgs, parsed);
162
+ }
163
+ catch (error) {
164
+ writeOutput(stderr, `${error instanceof Error ? error.message : String(error)}\n`);
165
+ return 1;
166
+ }
91
167
  if (command === "help") {
92
168
  writeOutput(stdout, `${helpText()}\n`);
93
169
  return 0;
@@ -185,6 +261,14 @@ export async function runCli(argv, options) {
185
261
  data,
186
262
  });
187
263
  }
264
+ if (command === "feed") {
265
+ return await handleFeedCommand({
266
+ parsed,
267
+ json,
268
+ stdout,
269
+ data,
270
+ });
271
+ }
188
272
  if (command === "retry") {
189
273
  return await handleRetryCommand({ commandArgs, parsed, json, stdout, data, config, runInteractive });
190
274
  }
@@ -184,6 +184,19 @@ CREATE TABLE IF NOT EXISTS oauth_states (
184
184
  error_message TEXT
185
185
  );
186
186
 
187
+ CREATE TABLE IF NOT EXISTS operator_feed_events (
188
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
189
+ at TEXT NOT NULL,
190
+ level TEXT NOT NULL,
191
+ kind TEXT NOT NULL,
192
+ summary TEXT NOT NULL,
193
+ detail TEXT,
194
+ issue_key TEXT,
195
+ project_id TEXT,
196
+ stage TEXT,
197
+ status TEXT
198
+ );
199
+
187
200
  CREATE INDEX IF NOT EXISTS idx_event_receipts_project_issue ON event_receipts(project_id, linear_issue_id);
188
201
  CREATE INDEX IF NOT EXISTS idx_issue_control_ready ON issue_control(desired_stage, active_run_lease_id);
189
202
  CREATE INDEX IF NOT EXISTS idx_issue_projection_issue_key ON issue_projection(issue_key);
@@ -192,6 +205,8 @@ CREATE INDEX IF NOT EXISTS idx_issue_sessions_last_opened ON issue_sessions(proj
192
205
  CREATE INDEX IF NOT EXISTS idx_run_leases_active ON run_leases(status, project_id, linear_issue_id);
193
206
  CREATE INDEX IF NOT EXISTS idx_run_leases_thread ON run_leases(thread_id);
194
207
  CREATE INDEX IF NOT EXISTS idx_run_thread_events_run ON run_thread_events(run_lease_id, id);
208
+ CREATE INDEX IF NOT EXISTS idx_operator_feed_events_issue ON operator_feed_events(issue_key, id);
209
+ CREATE INDEX IF NOT EXISTS idx_operator_feed_events_project ON operator_feed_events(project_id, id);
195
210
  CREATE INDEX IF NOT EXISTS idx_obligations_pending ON obligations(status, run_lease_id, kind);
196
211
  CREATE UNIQUE INDEX IF NOT EXISTS idx_obligations_dedupe
197
212
  ON obligations(run_lease_id, kind, dedupe_key)
@@ -0,0 +1,72 @@
1
+ import { isoNow } from "./shared.js";
2
+ export class OperatorFeedStore {
3
+ connection;
4
+ maxRows;
5
+ constructor(connection, maxRows = 5000) {
6
+ this.connection = connection;
7
+ this.maxRows = maxRows;
8
+ }
9
+ save(event) {
10
+ const at = event.at ?? isoNow();
11
+ const result = this.connection.prepare(`
12
+ INSERT INTO operator_feed_events (at, level, kind, summary, detail, issue_key, project_id, stage, status)
13
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
14
+ `).run(at, event.level, event.kind, event.summary, event.detail ?? null, event.issueKey ?? null, event.projectId ?? null, event.stage ?? null, event.status ?? null);
15
+ this.prune();
16
+ const stored = this.connection.prepare("SELECT * FROM operator_feed_events WHERE id = ?").get(Number(result.lastInsertRowid));
17
+ return mapOperatorFeedEvent(stored);
18
+ }
19
+ list(options) {
20
+ const clauses = [];
21
+ const params = [];
22
+ if (options?.afterId !== undefined) {
23
+ clauses.push("id > ?");
24
+ params.push(options.afterId);
25
+ }
26
+ if (options?.issueKey) {
27
+ clauses.push("issue_key = ?");
28
+ params.push(options.issueKey);
29
+ }
30
+ if (options?.projectId) {
31
+ clauses.push("project_id = ?");
32
+ params.push(options.projectId);
33
+ }
34
+ const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
35
+ const limit = options?.limit ?? 50;
36
+ const rows = this.connection.prepare(`
37
+ SELECT *
38
+ FROM operator_feed_events
39
+ ${where}
40
+ ORDER BY id DESC
41
+ LIMIT ?
42
+ `).all(...params, limit);
43
+ return rows
44
+ .map((row) => mapOperatorFeedEvent(row))
45
+ .reverse();
46
+ }
47
+ prune() {
48
+ this.connection.prepare(`
49
+ DELETE FROM operator_feed_events
50
+ WHERE id NOT IN (
51
+ SELECT id
52
+ FROM operator_feed_events
53
+ ORDER BY id DESC
54
+ LIMIT ?
55
+ )
56
+ `).run(this.maxRows);
57
+ }
58
+ }
59
+ function mapOperatorFeedEvent(row) {
60
+ return {
61
+ id: Number(row.id),
62
+ at: String(row.at),
63
+ level: row.level,
64
+ kind: row.kind,
65
+ summary: String(row.summary),
66
+ ...(row.detail === null ? {} : { detail: String(row.detail) }),
67
+ ...(row.issue_key === null ? {} : { issueKey: String(row.issue_key) }),
68
+ ...(row.project_id === null ? {} : { projectId: String(row.project_id) }),
69
+ ...(row.stage === null ? {} : { stage: row.stage }),
70
+ ...(row.status === null ? {} : { status: String(row.status) }),
71
+ };
72
+ }
package/dist/db.js CHANGED
@@ -4,6 +4,7 @@ import { IssueWorkflowCoordinator } from "./db/issue-workflow-coordinator.js";
4
4
  import { IssueWorkflowStore } from "./db/issue-workflow-store.js";
5
5
  import { LinearInstallationStore } from "./db/linear-installation-store.js";
6
6
  import { runPatchRelayMigrations } from "./db/migrations.js";
7
+ import { OperatorFeedStore } from "./db/operator-feed-store.js";
7
8
  import { RunReportStore } from "./db/run-report-store.js";
8
9
  import { StageEventStore } from "./db/stage-event-store.js";
9
10
  import { SqliteConnection } from "./db/shared.js";
@@ -24,6 +25,7 @@ export class PatchRelayDatabase {
24
25
  runReports;
25
26
  stageEvents;
26
27
  linearInstallations;
28
+ operatorFeed;
27
29
  constructor(databasePath, wal) {
28
30
  this.connection = new SqliteConnection(databasePath);
29
31
  this.connection.pragma("foreign_keys = ON");
@@ -54,6 +56,7 @@ export class PatchRelayDatabase {
54
56
  });
55
57
  this.stageEvents = new StageEventStore(this.connection);
56
58
  this.linearInstallations = new LinearInstallationStore(this.connection);
59
+ this.operatorFeed = new OperatorFeedStore(this.connection);
57
60
  }
58
61
  runMigrations() {
59
62
  runPatchRelayMigrations(this.connection);