patchrelay 0.2.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.
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) {
@@ -200,14 +229,10 @@ export class CliDataAccess {
200
229
  projectId: issue.projectId,
201
230
  linearIssueId: issue.linearIssueId,
202
231
  });
203
- this.db.issueControl.upsertIssueControl({
204
- projectId: issue.projectId,
205
- linearIssueId: issue.linearIssueId,
206
- desiredStage: stage,
232
+ this.db.workflowCoordinator.setIssueDesiredStage(issue.projectId, issue.linearIssueId, stage, {
207
233
  desiredReceiptId: receipt.id,
208
234
  lifecycleStatus: "queued",
209
235
  });
210
- this.db.issueWorkflows.setIssueDesiredStage(issue.projectId, issue.linearIssueId, stage, webhookId);
211
236
  const updated = this.db.issueWorkflows.getTrackedIssue(issue.projectId, issue.linearIssueId);
212
237
  return {
213
238
  issue: updated,
@@ -278,13 +303,19 @@ export class CliDataAccess {
278
303
  : {}),
279
304
  ...(row.latest_stage !== null
280
305
  ? { latestStage: row.latest_stage }
281
- : ledger?.mirroredStageRun
282
- ? { latestStage: ledger.mirroredStageRun.stage }
306
+ : ledger?.runLease
307
+ ? { latestStage: ledger.runLease.stage }
283
308
  : {}),
284
309
  ...(row.latest_stage_status !== null
285
310
  ? { latestStageStatus: String(row.latest_stage_status) }
286
- : ledger?.mirroredStageRun
287
- ? { latestStageStatus: ledger.mirroredStageRun.status }
311
+ : ledger?.runLease
312
+ ? {
313
+ latestStageStatus: ledger.runLease.status === "failed"
314
+ ? "failed"
315
+ : ledger.runLease.status === "completed" || ledger.runLease.status === "released" || ledger.runLease.status === "paused"
316
+ ? "completed"
317
+ : "running",
318
+ }
288
319
  : {}),
289
320
  updatedAt: String(row.updated_at),
290
321
  };
@@ -302,20 +333,16 @@ export class CliDataAccess {
302
333
  getLedgerIssueContext(projectId, linearIssueId) {
303
334
  const issueControl = this.db.issueControl.getIssueControl(projectId, linearIssueId);
304
335
  const runLease = issueControl?.activeRunLeaseId ? this.db.runLeases.getRunLease(issueControl.activeRunLeaseId) : undefined;
305
- const workspaceOwnership = issueControl?.activeWorkspaceOwnershipId
306
- ? this.db.workspaceOwnership.getWorkspaceOwnership(issueControl.activeWorkspaceOwnershipId)
307
- : undefined;
308
- const mirroredStageRun = issueControl?.activeRunLeaseId ? this.db.issueWorkflows.getStageRun(issueControl.activeRunLeaseId) : undefined;
309
336
  return {
310
337
  ...(issueControl ? { issueControl } : {}),
311
338
  ...(runLease ? { runLease } : {}),
312
- ...(workspaceOwnership ? { workspaceOwnership } : {}),
313
- ...(mirroredStageRun ? { mirroredStageRun } : {}),
314
339
  };
315
340
  }
316
341
  getActiveStageRunForIssue(issue, ledger) {
317
342
  const context = ledger ?? this.getLedgerIssueContext(issue.projectId, issue.linearIssueId);
318
- const activeStageRun = context.mirroredStageRun ?? this.synthesizeStageRunFromLease(context);
343
+ const activeStageRun = context.issueControl?.activeRunLeaseId
344
+ ? this.db.issueWorkflows.getStageRun(context.issueControl.activeRunLeaseId)
345
+ : undefined;
319
346
  if (!activeStageRun) {
320
347
  return undefined;
321
348
  }
@@ -323,59 +350,89 @@ export class CliDataAccess {
323
350
  ? activeStageRun
324
351
  : undefined;
325
352
  }
326
- synthesizeStageRunFromLease(ledger) {
327
- if (!ledger.runLease) {
328
- return undefined;
329
- }
330
- return {
331
- id: -ledger.runLease.id,
332
- pipelineRunId: 0,
333
- projectId: ledger.runLease.projectId,
334
- linearIssueId: ledger.runLease.linearIssueId,
335
- workspaceId: 0,
336
- stage: ledger.runLease.stage,
337
- status: ledger.runLease.status === "failed"
338
- ? "failed"
339
- : ledger.runLease.status === "completed" || ledger.runLease.status === "released" || ledger.runLease.status === "paused"
340
- ? "completed"
341
- : "running",
342
- triggerWebhookId: "ledger-active-run",
343
- workflowFile: ledger.runLease.workflowFile,
344
- promptText: ledger.runLease.promptText,
345
- ...(ledger.runLease.threadId ? { threadId: ledger.runLease.threadId } : {}),
346
- ...(ledger.runLease.parentThreadId ? { parentThreadId: ledger.runLease.parentThreadId } : {}),
347
- ...(ledger.runLease.turnId ? { turnId: ledger.runLease.turnId } : {}),
348
- startedAt: ledger.runLease.startedAt,
349
- ...(ledger.runLease.endedAt ? { endedAt: ledger.runLease.endedAt } : {}),
350
- };
351
- }
352
353
  getWorkspaceForIssue(issue, ledger) {
353
354
  const context = ledger ?? this.getLedgerIssueContext(issue.projectId, issue.linearIssueId);
354
- if (!context.issueControl?.activeRunLeaseId) {
355
- const activeWorkspace = this.db.issueWorkflows.getActiveWorkspaceForIssue(issue.projectId, issue.linearIssueId);
355
+ if (context.issueControl?.activeWorkspaceOwnershipId !== undefined) {
356
+ const activeWorkspace = this.db.issueWorkflows.getWorkspace(context.issueControl.activeWorkspaceOwnershipId);
356
357
  if (activeWorkspace) {
357
358
  return activeWorkspace;
358
359
  }
359
360
  }
360
- const workspaceOwnership = context.workspaceOwnership;
361
- if (!workspaceOwnership) {
362
- return this.db.issueWorkflows.getActiveWorkspaceForIssue(issue.projectId, issue.linearIssueId);
361
+ return this.db.issueWorkflows.getActiveWorkspaceForIssue(issue.projectId, issue.linearIssueId);
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;
363
373
  }
364
- return {
365
- id: workspaceOwnership.id,
366
- projectId: workspaceOwnership.projectId,
367
- linearIssueId: workspaceOwnership.linearIssueId,
368
- branchName: workspaceOwnership.branchName,
369
- worktreePath: workspaceOwnership.worktreePath,
370
- status: workspaceOwnership.status === "released"
371
- ? "closed"
372
- : workspaceOwnership.status === "paused"
373
- ? "paused"
374
- : "active",
375
- ...(context.runLease?.threadId ? { lastThreadId: context.runLease.threadId } : {}),
376
- createdAt: workspaceOwnership.createdAt,
377
- updatedAt: workspaceOwnership.updatedAt,
378
- };
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);
379
436
  }
380
437
  async connect(projectId) {
381
438
  return await this.requestJson("/api/oauth/linear/start", {