patchrelay 0.7.0 → 0.7.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.7.0",
4
- "commit": "450840270db1",
5
- "builtAt": "2026-03-13T18:08:50.316Z"
3
+ "version": "0.7.1",
4
+ "commit": "c70c0c3117a6",
5
+ "builtAt": "2026-03-13T18:54:01.444Z"
6
6
  }
@@ -91,7 +91,7 @@ export async function handleProjectCommand(params) {
91
91
  if (!serviceState.ok) {
92
92
  throw new Error(`Project was saved, but PatchRelay could not be reloaded: ${serviceState.error}`);
93
93
  }
94
- const cliData = params.options?.data ?? (await createCliDataAccess(fullConfig));
94
+ const cliData = params.options?.data ?? (await createCliOperatorDataAccess(fullConfig));
95
95
  try {
96
96
  if (params.json) {
97
97
  const connectResult = noConnect ? undefined : await cliData.connect(projectId);
@@ -140,7 +140,7 @@ export async function handleProjectCommand(params) {
140
140
  }
141
141
  }
142
142
  }
143
- async function createCliDataAccess(config) {
144
- const { CliDataAccess } = await import("../data.js");
145
- return new CliDataAccess(config);
143
+ async function createCliOperatorDataAccess(config) {
144
+ const { CliOperatorApiClient } = await import("../operator-client.js");
145
+ return new CliOperatorApiClient(config);
146
146
  }
package/dist/cli/data.js CHANGED
@@ -3,6 +3,7 @@ import pino from "pino";
3
3
  import { CodexAppServerClient } from "../codex-app-server.js";
4
4
  import { PatchRelayDatabase } from "../db.js";
5
5
  import { WorktreeManager } from "../worktree-manager.js";
6
+ import { CliOperatorApiClient } from "./operator-client.js";
6
7
  import { resolveWorkflowStage } from "../workflow-policy.js";
7
8
  function safeJsonParse(value) {
8
9
  if (!value) {
@@ -40,12 +41,13 @@ function resolveStageFromState(config, projectId, stateName) {
40
41
  }
41
42
  return resolveWorkflowStage(project, stateName);
42
43
  }
43
- export class CliDataAccess {
44
+ export class CliDataAccess extends CliOperatorApiClient {
44
45
  config;
45
46
  db;
46
47
  codex;
47
48
  codexStarted = false;
48
49
  constructor(config, options) {
50
+ super(config);
49
51
  this.config = config;
50
52
  this.db = options?.db ?? new PatchRelayDatabase(config.database.path, config.database.wal);
51
53
  this.codex = options?.codex;
@@ -448,138 +450,6 @@ export class CliDataAccess {
448
450
  const worktreeManager = new WorktreeManager(this.config);
449
451
  await worktreeManager.ensureIssueWorktree(project.repoPath, project.worktreeRoot, worktree.workspace.worktreePath, worktree.workspace.branchName);
450
452
  }
451
- async connect(projectId) {
452
- return await this.requestJson("/api/oauth/linear/start", {
453
- ...(projectId ? { projectId } : {}),
454
- });
455
- }
456
- async connectStatus(state) {
457
- if (!state) {
458
- throw new Error("OAuth state is required.");
459
- }
460
- return await this.requestJson(`/api/oauth/linear/state/${encodeURIComponent(state)}`);
461
- }
462
- async listInstallations() {
463
- return await this.requestJson("/api/installations");
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
- }
531
- getOperatorBaseUrl() {
532
- const host = this.normalizeLocalHost(this.config.server.bind);
533
- return `http://${host}:${this.config.server.port}/`;
534
- }
535
- normalizeLocalHost(bind) {
536
- if (bind === "0.0.0.0") {
537
- return "127.0.0.1";
538
- }
539
- if (bind === "::") {
540
- return "[::1]";
541
- }
542
- if (bind.includes(":") && !bind.startsWith("[")) {
543
- return `[${bind}]`;
544
- }
545
- return bind;
546
- }
547
- async requestJson(pathname, query, init) {
548
- const url = new URL(pathname, this.getOperatorBaseUrl());
549
- for (const [key, value] of Object.entries(query ?? {})) {
550
- if (value) {
551
- url.searchParams.set(key, value);
552
- }
553
- }
554
- const response = await fetch(url, {
555
- method: init?.method ?? "GET",
556
- headers: {
557
- accept: "application/json",
558
- ...(init?.body !== undefined ? { "content-type": "application/json" } : {}),
559
- ...(this.config.operatorApi.bearerToken ? { authorization: `Bearer ${this.config.operatorApi.bearerToken}` } : {}),
560
- },
561
- ...(init?.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
562
- });
563
- const body = await response.text();
564
- if (!response.ok) {
565
- const message = this.readErrorMessage(body);
566
- throw new Error(message ?? `Request failed: ${response.status}`);
567
- }
568
- const parsed = JSON.parse(body);
569
- if (parsed.ok === false) {
570
- throw new Error(this.readErrorMessage(body) ?? "Request failed.");
571
- }
572
- return parsed;
573
- }
574
- readErrorMessage(body) {
575
- try {
576
- const parsed = JSON.parse(body);
577
- return parsed.message ?? parsed.reason;
578
- }
579
- catch {
580
- return undefined;
581
- }
582
- }
583
453
  async readLiveSummary(threadId, latestTimestampSeen) {
584
454
  const codex = await this.getCodex();
585
455
  const thread = await codex.readThread(threadId, true);
package/dist/cli/index.js CHANGED
@@ -202,6 +202,7 @@ export async function runCli(argv, options) {
202
202
  profile: getCommandConfigProfile(command),
203
203
  });
204
204
  let data = options?.data;
205
+ let ownsData = false;
205
206
  try {
206
207
  if (command === "doctor") {
207
208
  const { runPreflight } = await import("../preflight.js");
@@ -209,55 +210,111 @@ export async function runCli(argv, options) {
209
210
  writeOutput(stdout, json ? formatJson(report) : formatDoctor(report));
210
211
  return report.ok ? 0 : 1;
211
212
  }
212
- data ??= await createCliDataAccess(config);
213
213
  if (command === "inspect") {
214
- return await handleInspectCommand({ commandArgs, parsed, json, stdout, data, config, runInteractive });
214
+ const issueData = await ensureIssueDataAccess(data, config);
215
+ if (!data) {
216
+ data = issueData;
217
+ ownsData = true;
218
+ }
219
+ return await handleInspectCommand({ commandArgs, parsed, json, stdout, data: issueData, config, runInteractive });
215
220
  }
216
221
  if (command === "live") {
217
- return await handleLiveCommand({ commandArgs, parsed, json, stdout, data, config, runInteractive });
222
+ const issueData = await ensureIssueDataAccess(data, config);
223
+ if (!data) {
224
+ data = issueData;
225
+ ownsData = true;
226
+ }
227
+ return await handleLiveCommand({ commandArgs, parsed, json, stdout, data: issueData, config, runInteractive });
218
228
  }
219
229
  if (command === "report") {
220
- return await handleReportCommand({ commandArgs, parsed, json, stdout, data, config, runInteractive });
230
+ const issueData = await ensureIssueDataAccess(data, config);
231
+ if (!data) {
232
+ data = issueData;
233
+ ownsData = true;
234
+ }
235
+ return await handleReportCommand({ commandArgs, parsed, json, stdout, data: issueData, config, runInteractive });
221
236
  }
222
237
  if (command === "events") {
223
- return await handleEventsCommand({ commandArgs, parsed, json, stdout, data, config, runInteractive });
238
+ const issueData = await ensureIssueDataAccess(data, config);
239
+ if (!data) {
240
+ data = issueData;
241
+ ownsData = true;
242
+ }
243
+ return await handleEventsCommand({ commandArgs, parsed, json, stdout, data: issueData, config, runInteractive });
224
244
  }
225
245
  if (command === "worktree") {
226
- return await handleWorktreeCommand({ commandArgs, parsed, json, stdout, data, config, runInteractive });
246
+ const issueData = await ensureIssueDataAccess(data, config);
247
+ if (!data) {
248
+ data = issueData;
249
+ ownsData = true;
250
+ }
251
+ return await handleWorktreeCommand({ commandArgs, parsed, json, stdout, data: issueData, config, runInteractive });
227
252
  }
228
253
  if (command === "open") {
229
- return await handleOpenCommand({ commandArgs, parsed, json, stdout, data, config, runInteractive });
254
+ const issueData = await ensureIssueDataAccess(data, config);
255
+ if (!data) {
256
+ data = issueData;
257
+ ownsData = true;
258
+ }
259
+ return await handleOpenCommand({ commandArgs, parsed, json, stdout, data: issueData, config, runInteractive });
230
260
  }
231
261
  if (command === "connect") {
262
+ const operatorData = await ensureConnectDataAccess(data, config);
263
+ if (!data) {
264
+ data = operatorData;
265
+ ownsData = true;
266
+ }
232
267
  return await handleConnectCommand({
233
268
  parsed,
234
269
  json,
235
270
  stdout,
236
271
  config,
237
- data,
272
+ data: operatorData,
238
273
  ...(options ? { options } : {}),
239
274
  });
240
275
  }
241
276
  if (command === "installations") {
277
+ const operatorData = await ensureInstallationsDataAccess(data, config);
278
+ if (!data) {
279
+ data = operatorData;
280
+ ownsData = true;
281
+ }
242
282
  return await handleInstallationsCommand({
243
283
  json,
244
284
  stdout,
245
- data,
285
+ data: operatorData,
246
286
  });
247
287
  }
248
288
  if (command === "feed") {
289
+ const operatorData = parsed.flags.get("follow") === true
290
+ ? await ensureFeedFollowDataAccess(data, config)
291
+ : await ensureFeedListDataAccess(data, config);
292
+ if (!data) {
293
+ data = operatorData;
294
+ ownsData = true;
295
+ }
249
296
  return await handleFeedCommand({
250
297
  parsed,
251
298
  json,
252
299
  stdout,
253
- data,
300
+ data: operatorData,
254
301
  });
255
302
  }
256
303
  if (command === "retry") {
257
- return await handleRetryCommand({ commandArgs, parsed, json, stdout, data, config, runInteractive });
304
+ const issueData = await ensureIssueDataAccess(data, config);
305
+ if (!data) {
306
+ data = issueData;
307
+ ownsData = true;
308
+ }
309
+ return await handleRetryCommand({ commandArgs, parsed, json, stdout, data: issueData, config, runInteractive });
258
310
  }
259
311
  if (command === "list") {
260
- return await handleListCommand({ commandArgs, parsed, json, stdout, data, config, runInteractive });
312
+ const issueData = await ensureIssueDataAccess(data, config);
313
+ if (!data) {
314
+ data = issueData;
315
+ ownsData = true;
316
+ }
317
+ return await handleListCommand({ commandArgs, parsed, json, stdout, data: issueData, config, runInteractive });
261
318
  }
262
319
  throw new Error(`Unknown command: ${command}`);
263
320
  }
@@ -270,7 +327,7 @@ export async function runCli(argv, options) {
270
327
  return 1;
271
328
  }
272
329
  finally {
273
- if (data && !options?.data) {
330
+ if (ownsData && data) {
274
331
  data.close();
275
332
  }
276
333
  }
@@ -279,3 +336,67 @@ async function createCliDataAccess(config) {
279
336
  const { CliDataAccess } = await import("./data.js");
280
337
  return new CliDataAccess(config);
281
338
  }
339
+ async function createCliOperatorDataAccess(config) {
340
+ const { CliOperatorApiClient } = await import("./operator-client.js");
341
+ return new CliOperatorApiClient(config);
342
+ }
343
+ async function ensureIssueDataAccess(data, config) {
344
+ if (data) {
345
+ if (isIssueDataAccess(data)) {
346
+ return data;
347
+ }
348
+ throw new Error("Issue inspection commands require local SQLite-backed CLI data access.");
349
+ }
350
+ return await createCliDataAccess(config);
351
+ }
352
+ async function ensureConnectDataAccess(data, config) {
353
+ if (data) {
354
+ if (hasConnectDataAccess(data)) {
355
+ return data;
356
+ }
357
+ throw new Error("The connect command requires HTTP-backed OAuth CLI data access.");
358
+ }
359
+ return await createCliOperatorDataAccess(config);
360
+ }
361
+ function isIssueDataAccess(data) {
362
+ return !!data && typeof data === "object" && "inspect" in data && typeof data.inspect === "function";
363
+ }
364
+ async function ensureInstallationsDataAccess(data, config) {
365
+ if (data) {
366
+ if (hasInstallationsDataAccess(data)) {
367
+ return data;
368
+ }
369
+ throw new Error("The installations command requires HTTP-backed installation data access.");
370
+ }
371
+ return await createCliOperatorDataAccess(config);
372
+ }
373
+ async function ensureFeedListDataAccess(data, config) {
374
+ if (data) {
375
+ if (hasFeedListDataAccess(data)) {
376
+ return data;
377
+ }
378
+ throw new Error("The feed command requires listOperatorFeed() data access.");
379
+ }
380
+ return await createCliOperatorDataAccess(config);
381
+ }
382
+ function hasConnectDataAccess(data) {
383
+ return !!data && typeof data === "object" && "connect" in data && typeof data.connect === "function";
384
+ }
385
+ function hasInstallationsDataAccess(data) {
386
+ return !!data && typeof data === "object" && "listInstallations" in data && typeof data.listInstallations === "function";
387
+ }
388
+ async function ensureFeedFollowDataAccess(data, config) {
389
+ if (data) {
390
+ if (hasFeedFollowDataAccess(data)) {
391
+ return data;
392
+ }
393
+ throw new Error("The feed --follow command requires followOperatorFeed() data access.");
394
+ }
395
+ return await createCliOperatorDataAccess(config);
396
+ }
397
+ function hasFeedListDataAccess(data) {
398
+ return !!data && typeof data === "object" && "listOperatorFeed" in data && typeof data.listOperatorFeed === "function";
399
+ }
400
+ function hasFeedFollowDataAccess(data) {
401
+ return !!data && typeof data === "object" && "followOperatorFeed" in data && typeof data.followOperatorFeed === "function";
402
+ }
@@ -0,0 +1,140 @@
1
+ export class CliOperatorApiClient {
2
+ config;
3
+ constructor(config) {
4
+ this.config = config;
5
+ }
6
+ close() { }
7
+ async connect(projectId) {
8
+ return await this.requestJson("/api/oauth/linear/start", {
9
+ ...(projectId ? { projectId } : {}),
10
+ });
11
+ }
12
+ async connectStatus(state) {
13
+ if (!state) {
14
+ throw new Error("OAuth state is required.");
15
+ }
16
+ return await this.requestJson(`/api/oauth/linear/state/${encodeURIComponent(state)}`);
17
+ }
18
+ async listInstallations() {
19
+ return await this.requestJson("/api/installations");
20
+ }
21
+ async listOperatorFeed(options) {
22
+ return await this.requestJson("/api/feed", {
23
+ ...(options?.limit && options.limit > 0 ? { limit: String(options.limit) } : {}),
24
+ ...(options?.issueKey ? { issue: options.issueKey } : {}),
25
+ ...(options?.projectId ? { project: options.projectId } : {}),
26
+ });
27
+ }
28
+ async followOperatorFeed(onEvent, options) {
29
+ const url = new URL("/api/feed", this.getOperatorBaseUrl());
30
+ url.searchParams.set("follow", "1");
31
+ if (options?.limit && options.limit > 0) {
32
+ url.searchParams.set("limit", String(options.limit));
33
+ }
34
+ if (options?.issueKey) {
35
+ url.searchParams.set("issue", options.issueKey);
36
+ }
37
+ if (options?.projectId) {
38
+ url.searchParams.set("project", options.projectId);
39
+ }
40
+ const response = await fetch(url, {
41
+ method: "GET",
42
+ headers: {
43
+ accept: "text/event-stream",
44
+ ...(this.config.operatorApi.bearerToken ? { authorization: `Bearer ${this.config.operatorApi.bearerToken}` } : {}),
45
+ },
46
+ });
47
+ if (!response.ok || !response.body) {
48
+ const body = await response.text().catch(() => "");
49
+ const message = this.readErrorMessage(body);
50
+ throw new Error(message ?? `Request failed: ${response.status}`);
51
+ }
52
+ const reader = response.body.getReader();
53
+ const decoder = new TextDecoder();
54
+ let buffer = "";
55
+ let dataLines = [];
56
+ while (true) {
57
+ const { done, value } = await reader.read();
58
+ if (done) {
59
+ break;
60
+ }
61
+ buffer += decoder.decode(value, { stream: true });
62
+ let newlineIndex = buffer.indexOf("\n");
63
+ while (newlineIndex !== -1) {
64
+ const rawLine = buffer.slice(0, newlineIndex);
65
+ buffer = buffer.slice(newlineIndex + 1);
66
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
67
+ if (!line) {
68
+ if (dataLines.length > 0) {
69
+ const parsed = JSON.parse(dataLines.join("\n"));
70
+ onEvent(parsed);
71
+ dataLines = [];
72
+ }
73
+ newlineIndex = buffer.indexOf("\n");
74
+ continue;
75
+ }
76
+ if (line.startsWith(":")) {
77
+ newlineIndex = buffer.indexOf("\n");
78
+ continue;
79
+ }
80
+ if (line.startsWith("data:")) {
81
+ dataLines.push(line.slice(5).trimStart());
82
+ }
83
+ newlineIndex = buffer.indexOf("\n");
84
+ }
85
+ }
86
+ }
87
+ getOperatorBaseUrl() {
88
+ const host = this.normalizeLocalHost(this.config.server.bind);
89
+ return `http://${host}:${this.config.server.port}/`;
90
+ }
91
+ normalizeLocalHost(bind) {
92
+ if (bind === "0.0.0.0") {
93
+ return "127.0.0.1";
94
+ }
95
+ if (bind === "::") {
96
+ return "[::1]";
97
+ }
98
+ if (bind.includes(":") && !bind.startsWith("[")) {
99
+ return `[${bind}]`;
100
+ }
101
+ return bind;
102
+ }
103
+ async requestJson(pathname, query, init) {
104
+ const url = new URL(pathname, this.getOperatorBaseUrl());
105
+ for (const [key, value] of Object.entries(query ?? {})) {
106
+ if (value) {
107
+ url.searchParams.set(key, value);
108
+ }
109
+ }
110
+ const response = await fetch(url, {
111
+ method: init?.method ?? "GET",
112
+ headers: {
113
+ accept: "application/json",
114
+ connection: "close",
115
+ ...(init?.body !== undefined ? { "content-type": "application/json" } : {}),
116
+ ...(this.config.operatorApi.bearerToken ? { authorization: `Bearer ${this.config.operatorApi.bearerToken}` } : {}),
117
+ },
118
+ ...(init?.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
119
+ });
120
+ const body = await response.text();
121
+ if (!response.ok) {
122
+ const message = this.readErrorMessage(body);
123
+ throw new Error(message ?? `Request failed: ${response.status}`);
124
+ }
125
+ const parsed = JSON.parse(body);
126
+ if (parsed.ok === false) {
127
+ throw new Error(this.readErrorMessage(body) ?? "Request failed.");
128
+ }
129
+ return parsed;
130
+ }
131
+ readErrorMessage(body) {
132
+ try {
133
+ const parsed = JSON.parse(body);
134
+ return parsed.message ?? parsed.reason;
135
+ }
136
+ catch {
137
+ return undefined;
138
+ }
139
+ }
140
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {