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.
- package/dist/build-info.json +3 -3
- package/dist/cli/commands/issues.js +12 -4
- package/dist/cli/data.js +108 -5
- package/dist/db/authoritative-ledger-store.js +95 -0
- package/dist/db/issue-workflow-coordinator.js +29 -0
- package/dist/db/migrations.js +19 -0
- package/dist/db.js +2 -0
- package/package.json +1 -1
package/dist/build-info.json
CHANGED
|
@@ -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
|
|
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({
|
package/dist/db/migrations.js
CHANGED
|
@@ -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);
|