patchrelay 0.3.0 → 0.4.0

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.3.0",
4
- "commit": "c82e03b38d6e",
5
- "builtAt": "2026-03-13T01:42:32.759Z"
3
+ "version": "0.4.0",
4
+ "commit": "94bb0a0f517c",
5
+ "builtAt": "2026-03-13T09:13:16.897Z"
6
6
  }
@@ -101,18 +101,26 @@ export async function handleOpenCommand(params) {
101
101
  if (!issueKey) {
102
102
  throw new Error("open requires <issueKey>.");
103
103
  }
104
- const result = params.data.open(issueKey);
105
- if (!result) {
106
- throw new Error(`Workspace not found for ${issueKey}`);
107
- }
108
104
  if (params.json) {
105
+ const result = params.data.open(issueKey);
106
+ if (!result) {
107
+ throw new Error(`Workspace not found for ${issueKey}`);
108
+ }
109
109
  writeOutput(params.stdout, formatJson(result));
110
110
  return 0;
111
111
  }
112
112
  if (params.parsed.flags.get("print") === true) {
113
+ const result = params.data.open(issueKey);
114
+ if (!result) {
115
+ throw new Error(`Workspace not found for ${issueKey}`);
116
+ }
113
117
  writeOutput(params.stdout, formatOpen(result));
114
118
  return 0;
115
119
  }
120
+ const result = await params.data.prepareOpen(issueKey);
121
+ if (!result) {
122
+ throw new Error(`Workspace not found for ${issueKey}`);
123
+ }
116
124
  const openCommand = buildOpenCommand(params.config, result.workspace.worktreePath, result.resumeThreadId);
117
125
  return await params.runInteractive(openCommand.command, openCommand.args);
118
126
  }
package/dist/cli/data.js CHANGED
@@ -1,6 +1,8 @@
1
+ import { existsSync } from "node:fs";
1
2
  import pino from "pino";
2
3
  import { CodexAppServerClient } from "../codex-app-server.js";
3
4
  import { PatchRelayDatabase } from "../db.js";
5
+ import { WorktreeManager } from "../worktree-manager.js";
4
6
  import { resolveWorkflowStage } from "../workflow-policy.js";
5
7
  function safeJsonParse(value) {
6
8
  if (!value) {
@@ -167,16 +169,43 @@ export class CliDataAccess {
167
169
  if (!worktree) {
168
170
  return undefined;
169
171
  }
170
- const ledger = this.getLedgerIssueContext(worktree.issue.projectId, worktree.issue.linearIssueId);
171
- const resumeThreadId = (ledger.issueControl?.activeRunLeaseId ? ledger.runLease?.threadId : undefined) ??
172
- worktree.workspace.lastThreadId ??
173
- worktree.issue.latestThreadId ??
174
- ledger.runLease?.threadId;
172
+ const resumeThreadId = this.getStoredOpenThreadId(worktree);
175
173
  return {
176
174
  ...worktree,
177
175
  ...(resumeThreadId ? { resumeThreadId } : {}),
178
176
  };
179
177
  }
178
+ async prepareOpen(issueKey) {
179
+ const worktree = this.worktree(issueKey);
180
+ if (!worktree) {
181
+ return undefined;
182
+ }
183
+ await this.ensureOpenWorktree(worktree);
184
+ const existingThreadId = await this.resolveStoredOpenThreadId(worktree);
185
+ if (existingThreadId) {
186
+ return {
187
+ ...worktree,
188
+ resumeThreadId: existingThreadId,
189
+ };
190
+ }
191
+ const codex = await this.getCodex();
192
+ const thread = await codex.startThread({
193
+ cwd: worktree.workspace.worktreePath,
194
+ });
195
+ this.db.issueSessions.upsertIssueSession({
196
+ projectId: worktree.issue.projectId,
197
+ linearIssueId: worktree.issue.linearIssueId,
198
+ workspaceOwnershipId: worktree.workspace.id,
199
+ threadId: thread.id,
200
+ source: "operator_open",
201
+ ...(worktree.issue.activeAgentSessionId ? { linkedAgentSessionId: worktree.issue.activeAgentSessionId } : {}),
202
+ });
203
+ this.db.issueSessions.touchIssueSession(thread.id);
204
+ return {
205
+ ...worktree,
206
+ resumeThreadId: thread.id,
207
+ };
208
+ }
180
209
  retry(issueKey, options) {
181
210
  const issue = this.db.issueWorkflows.getTrackedIssueByKey(issueKey);
182
211
  if (!issue) {
@@ -331,6 +360,80 @@ export class CliDataAccess {
331
360
  }
332
361
  return this.db.issueWorkflows.getActiveWorkspaceForIssue(issue.projectId, issue.linearIssueId);
333
362
  }
363
+ getStoredOpenThreadId(worktree) {
364
+ return this.listOpenCandidateThreadIds(worktree).at(0);
365
+ }
366
+ async resolveStoredOpenThreadId(worktree) {
367
+ for (const threadId of this.listOpenCandidateThreadIds(worktree)) {
368
+ if (!(await this.canReadThread(threadId))) {
369
+ continue;
370
+ }
371
+ this.recordOpenThreadForIssue(worktree, threadId);
372
+ return threadId;
373
+ }
374
+ return undefined;
375
+ }
376
+ listOpenCandidateThreadIds(worktree) {
377
+ const ledger = this.getLedgerIssueContext(worktree.issue.projectId, worktree.issue.linearIssueId);
378
+ const sessions = this.db.issueSessions.listIssueSessionsForIssue(worktree.issue.projectId, worktree.issue.linearIssueId);
379
+ const candidates = [
380
+ ledger.issueControl?.activeRunLeaseId ? ledger.runLease?.threadId : undefined,
381
+ ...sessions.map((session) => session.threadId),
382
+ worktree.workspace.lastThreadId,
383
+ worktree.issue.latestThreadId,
384
+ ledger.runLease?.threadId,
385
+ ];
386
+ const seen = new Set();
387
+ const ordered = [];
388
+ for (const candidate of candidates) {
389
+ if (!candidate || seen.has(candidate)) {
390
+ continue;
391
+ }
392
+ seen.add(candidate);
393
+ ordered.push(candidate);
394
+ }
395
+ return ordered;
396
+ }
397
+ recordOpenThreadForIssue(worktree, threadId) {
398
+ const existing = this.db.issueSessions.getIssueSessionByThreadId(threadId);
399
+ if (existing) {
400
+ this.db.issueSessions.touchIssueSession(threadId);
401
+ return;
402
+ }
403
+ const runLease = this.db.runLeases.getRunLeaseByThreadId(threadId);
404
+ this.db.issueSessions.upsertIssueSession({
405
+ projectId: worktree.issue.projectId,
406
+ linearIssueId: worktree.issue.linearIssueId,
407
+ workspaceOwnershipId: runLease?.workspaceOwnershipId ?? worktree.workspace.id,
408
+ threadId,
409
+ source: runLease ? "stage_run" : "operator_open",
410
+ ...(runLease?.id !== undefined ? { runLeaseId: runLease.id } : {}),
411
+ ...(runLease?.parentThreadId ? { parentThreadId: runLease.parentThreadId } : {}),
412
+ ...(worktree.issue.activeAgentSessionId ? { linkedAgentSessionId: worktree.issue.activeAgentSessionId } : {}),
413
+ });
414
+ this.db.issueSessions.touchIssueSession(threadId);
415
+ }
416
+ async canReadThread(threadId) {
417
+ try {
418
+ const codex = await this.getCodex();
419
+ await codex.readThread(threadId, false);
420
+ return true;
421
+ }
422
+ catch {
423
+ return false;
424
+ }
425
+ }
426
+ async ensureOpenWorktree(worktree) {
427
+ if (existsSync(worktree.workspace.worktreePath)) {
428
+ return;
429
+ }
430
+ const project = this.config.projects.find((entry) => entry.id === worktree.repoId);
431
+ if (!project) {
432
+ throw new Error(`Project not found for ${worktree.repoId}`);
433
+ }
434
+ const worktreeManager = new WorktreeManager(this.config);
435
+ await worktreeManager.ensureIssueWorktree(project.repoPath, project.worktreeRoot, worktree.workspace.worktreePath, worktree.workspace.branchName);
436
+ }
334
437
  async connect(projectId) {
335
438
  return await this.requestJson("/api/oauth/linear/start", {
336
439
  ...(projectId ? { projectId } : {}),
@@ -141,6 +141,85 @@ export class AuthoritativeLedgerStore {
141
141
  .get(projectId, linearIssueId);
142
142
  return row ? mapWorkspaceOwnership(row) : undefined;
143
143
  }
144
+ upsertIssueSession(params) {
145
+ const now = isoNow();
146
+ this.connection
147
+ .prepare(`
148
+ INSERT INTO issue_sessions (
149
+ project_id, linear_issue_id, workspace_ownership_id, run_lease_id, thread_id, parent_thread_id,
150
+ source, linked_agent_session_id, created_at, updated_at, last_opened_at
151
+ ) VALUES (
152
+ @projectId, @linearIssueId, @workspaceOwnershipId, @runLeaseId, @threadId, @parentThreadId,
153
+ @source, @linkedAgentSessionId, @createdAt, @updatedAt, NULL
154
+ )
155
+ ON CONFLICT(thread_id) DO UPDATE SET
156
+ project_id = @projectId,
157
+ linear_issue_id = @linearIssueId,
158
+ workspace_ownership_id = @workspaceOwnershipId,
159
+ run_lease_id = CASE WHEN @setRunLeaseId = 1 THEN @runLeaseId ELSE issue_sessions.run_lease_id END,
160
+ parent_thread_id = CASE WHEN @setParentThreadId = 1 THEN @parentThreadId ELSE issue_sessions.parent_thread_id END,
161
+ source = @source,
162
+ linked_agent_session_id = CASE
163
+ WHEN @setLinkedAgentSessionId = 1 THEN @linkedAgentSessionId
164
+ ELSE issue_sessions.linked_agent_session_id
165
+ END,
166
+ updated_at = @updatedAt
167
+ `)
168
+ .run({
169
+ projectId: params.projectId,
170
+ linearIssueId: params.linearIssueId,
171
+ workspaceOwnershipId: params.workspaceOwnershipId,
172
+ runLeaseId: params.runLeaseId ?? null,
173
+ threadId: params.threadId,
174
+ parentThreadId: params.parentThreadId ?? null,
175
+ source: params.source,
176
+ linkedAgentSessionId: params.linkedAgentSessionId ?? null,
177
+ createdAt: now,
178
+ updatedAt: now,
179
+ setRunLeaseId: Number("runLeaseId" in params),
180
+ setParentThreadId: Number("parentThreadId" in params),
181
+ setLinkedAgentSessionId: Number("linkedAgentSessionId" in params),
182
+ });
183
+ return this.getIssueSessionByThreadId(params.threadId);
184
+ }
185
+ getIssueSessionByThreadId(threadId) {
186
+ const row = this.connection
187
+ .prepare("SELECT * FROM issue_sessions WHERE thread_id = ?")
188
+ .get(threadId);
189
+ return row ? mapIssueSession(row) : undefined;
190
+ }
191
+ listIssueSessionsForIssue(projectId, linearIssueId) {
192
+ const rows = this.connection
193
+ .prepare(`
194
+ SELECT * FROM issue_sessions
195
+ WHERE project_id = ? AND linear_issue_id = ?
196
+ ORDER BY
197
+ CASE WHEN last_opened_at IS NULL THEN 1 ELSE 0 END,
198
+ last_opened_at DESC,
199
+ id DESC
200
+ `)
201
+ .all(projectId, linearIssueId);
202
+ return rows.map((row) => mapIssueSession(row));
203
+ }
204
+ touchIssueSession(threadId) {
205
+ const now = isoNow();
206
+ const result = this.connection
207
+ .prepare(`
208
+ UPDATE issue_sessions
209
+ SET updated_at = @updatedAt,
210
+ last_opened_at = @lastOpenedAt
211
+ WHERE thread_id = @threadId
212
+ `)
213
+ .run({
214
+ threadId,
215
+ updatedAt: now,
216
+ lastOpenedAt: now,
217
+ });
218
+ if (result.changes < 1) {
219
+ return undefined;
220
+ }
221
+ return this.getIssueSessionByThreadId(threadId);
222
+ }
144
223
  createRunLease(params) {
145
224
  const result = this.connection
146
225
  .prepare(`
@@ -416,6 +495,22 @@ function mapRunLease(row) {
416
495
  ...(row.failure_reason === null ? {} : { failureReason: String(row.failure_reason) }),
417
496
  };
418
497
  }
498
+ function mapIssueSession(row) {
499
+ return {
500
+ id: Number(row.id),
501
+ projectId: String(row.project_id),
502
+ linearIssueId: String(row.linear_issue_id),
503
+ workspaceOwnershipId: Number(row.workspace_ownership_id),
504
+ threadId: String(row.thread_id),
505
+ source: row.source,
506
+ ...(row.run_lease_id === null ? {} : { runLeaseId: Number(row.run_lease_id) }),
507
+ ...(row.parent_thread_id === null ? {} : { parentThreadId: String(row.parent_thread_id) }),
508
+ ...(row.linked_agent_session_id === null ? {} : { linkedAgentSessionId: String(row.linked_agent_session_id) }),
509
+ createdAt: String(row.created_at),
510
+ updatedAt: String(row.updated_at),
511
+ ...(row.last_opened_at === null ? {} : { lastOpenedAt: String(row.last_opened_at) }),
512
+ };
513
+ }
419
514
  function mapObligation(row) {
420
515
  return {
421
516
  id: Number(row.id),
@@ -146,6 +146,21 @@ export class IssueWorkflowCoordinator {
146
146
  ...(params.parentThreadId !== undefined ? { parentThreadId: params.parentThreadId } : {}),
147
147
  ...(params.turnId !== undefined ? { turnId: params.turnId } : {}),
148
148
  });
149
+ const stageRun = this.issueWorkflows.getStageRun(params.stageRunId);
150
+ if (!stageRun) {
151
+ return;
152
+ }
153
+ const issue = this.issueWorkflows.getTrackedIssue(stageRun.projectId, stageRun.linearIssueId);
154
+ this.authoritativeLedger.upsertIssueSession({
155
+ projectId: stageRun.projectId,
156
+ linearIssueId: stageRun.linearIssueId,
157
+ workspaceOwnershipId: stageRun.workspaceId,
158
+ runLeaseId: stageRun.id,
159
+ threadId: params.threadId,
160
+ ...(params.parentThreadId !== undefined ? { parentThreadId: params.parentThreadId } : {}),
161
+ ...(issue?.activeAgentSessionId ? { linkedAgentSessionId: issue.activeAgentSessionId } : {}),
162
+ source: "stage_run",
163
+ });
149
164
  }
150
165
  finishStageRun(params) {
151
166
  const stageRun = this.issueWorkflows.getStageRun(params.stageRunId);
@@ -163,6 +178,20 @@ export class IssueWorkflowCoordinator {
163
178
  threadId: params.threadId,
164
179
  ...(params.turnId !== undefined ? { turnId: params.turnId } : {}),
165
180
  });
181
+ const runLease = this.authoritativeLedger.getRunLease(params.stageRunId);
182
+ if (runLease?.workspaceOwnershipId !== undefined) {
183
+ const issue = this.issueWorkflows.getTrackedIssue(stageRun.projectId, stageRun.linearIssueId);
184
+ this.authoritativeLedger.upsertIssueSession({
185
+ projectId: stageRun.projectId,
186
+ linearIssueId: stageRun.linearIssueId,
187
+ workspaceOwnershipId: runLease.workspaceOwnershipId,
188
+ runLeaseId: params.stageRunId,
189
+ threadId: params.threadId,
190
+ ...(runLease.parentThreadId ? { parentThreadId: runLease.parentThreadId } : {}),
191
+ ...(issue?.activeAgentSessionId ? { linkedAgentSessionId: issue.activeAgentSessionId } : {}),
192
+ source: "stage_run",
193
+ });
194
+ }
166
195
  const workspace = this.authoritativeLedger.getWorkspaceOwnership(stageRun.workspaceId);
167
196
  if (workspace) {
168
197
  this.authoritativeLedger.upsertWorkspaceOwnership({
@@ -91,6 +91,23 @@ CREATE TABLE IF NOT EXISTS run_leases (
91
91
  FOREIGN KEY(trigger_receipt_id) REFERENCES event_receipts(id) ON DELETE SET NULL
92
92
  );
93
93
 
94
+ CREATE TABLE IF NOT EXISTS issue_sessions (
95
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
96
+ project_id TEXT NOT NULL,
97
+ linear_issue_id TEXT NOT NULL,
98
+ workspace_ownership_id INTEGER NOT NULL,
99
+ run_lease_id INTEGER,
100
+ thread_id TEXT NOT NULL UNIQUE,
101
+ parent_thread_id TEXT,
102
+ source TEXT NOT NULL,
103
+ linked_agent_session_id TEXT,
104
+ created_at TEXT NOT NULL,
105
+ updated_at TEXT NOT NULL,
106
+ last_opened_at TEXT,
107
+ FOREIGN KEY(workspace_ownership_id) REFERENCES workspace_ownership(id) ON DELETE CASCADE,
108
+ FOREIGN KEY(run_lease_id) REFERENCES run_leases(id) ON DELETE SET NULL
109
+ );
110
+
94
111
  CREATE TABLE IF NOT EXISTS run_reports (
95
112
  run_lease_id INTEGER PRIMARY KEY,
96
113
  summary_json TEXT,
@@ -170,6 +187,8 @@ CREATE TABLE IF NOT EXISTS oauth_states (
170
187
  CREATE INDEX IF NOT EXISTS idx_event_receipts_project_issue ON event_receipts(project_id, linear_issue_id);
171
188
  CREATE INDEX IF NOT EXISTS idx_issue_control_ready ON issue_control(desired_stage, active_run_lease_id);
172
189
  CREATE INDEX IF NOT EXISTS idx_issue_projection_issue_key ON issue_projection(issue_key);
190
+ CREATE INDEX IF NOT EXISTS idx_issue_sessions_issue ON issue_sessions(project_id, linear_issue_id, id DESC);
191
+ CREATE INDEX IF NOT EXISTS idx_issue_sessions_last_opened ON issue_sessions(project_id, linear_issue_id, last_opened_at DESC, id DESC);
173
192
  CREATE INDEX IF NOT EXISTS idx_run_leases_active ON run_leases(status, project_id, linear_issue_id);
174
193
  CREATE INDEX IF NOT EXISTS idx_run_leases_thread ON run_leases(thread_id);
175
194
  CREATE INDEX IF NOT EXISTS idx_run_thread_events_run ON run_thread_events(run_lease_id, id);
package/dist/db.js CHANGED
@@ -14,6 +14,7 @@ export class PatchRelayDatabase {
14
14
  eventReceipts;
15
15
  issueControl;
16
16
  workspaceOwnership;
17
+ issueSessions;
17
18
  runLeases;
18
19
  obligations;
19
20
  webhookEvents;
@@ -33,6 +34,7 @@ export class PatchRelayDatabase {
33
34
  this.eventReceipts = this.authoritativeLedger;
34
35
  this.issueControl = this.authoritativeLedger;
35
36
  this.workspaceOwnership = this.authoritativeLedger;
37
+ this.issueSessions = this.authoritativeLedger;
36
38
  this.runLeases = this.authoritativeLedger;
37
39
  this.obligations = this.authoritativeLedger;
38
40
  this.webhookEvents = new WebhookEventStore(this.connection);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {