patchrelay 0.1.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/LICENSE +21 -0
- package/README.md +271 -0
- package/config/patchrelay.example.json +5 -0
- package/dist/build-info.js +29 -0
- package/dist/build-info.json +6 -0
- package/dist/cli/data.js +461 -0
- package/dist/cli/formatters/json.js +3 -0
- package/dist/cli/formatters/text.js +119 -0
- package/dist/cli/index.js +761 -0
- package/dist/codex-app-server.js +353 -0
- package/dist/codex-types.js +1 -0
- package/dist/config-types.js +1 -0
- package/dist/config.js +494 -0
- package/dist/db/authoritative-ledger-store.js +437 -0
- package/dist/db/issue-workflow-store.js +690 -0
- package/dist/db/linear-installation-store.js +184 -0
- package/dist/db/migrations.js +183 -0
- package/dist/db/shared.js +101 -0
- package/dist/db/stage-event-store.js +33 -0
- package/dist/db/webhook-event-store.js +46 -0
- package/dist/db-ports.js +5 -0
- package/dist/db-types.js +1 -0
- package/dist/db.js +40 -0
- package/dist/file-permissions.js +40 -0
- package/dist/http.js +321 -0
- package/dist/index.js +69 -0
- package/dist/install.js +302 -0
- package/dist/installation-ports.js +1 -0
- package/dist/issue-query-service.js +68 -0
- package/dist/ledger-ports.js +1 -0
- package/dist/linear-client.js +338 -0
- package/dist/linear-oauth-service.js +131 -0
- package/dist/linear-oauth.js +154 -0
- package/dist/linear-types.js +1 -0
- package/dist/linear-workflow.js +78 -0
- package/dist/logging.js +62 -0
- package/dist/preflight.js +227 -0
- package/dist/project-resolution.js +51 -0
- package/dist/reconciliation-action-applier.js +55 -0
- package/dist/reconciliation-actions.js +1 -0
- package/dist/reconciliation-engine.js +312 -0
- package/dist/reconciliation-snapshot-builder.js +96 -0
- package/dist/reconciliation-types.js +1 -0
- package/dist/runtime-paths.js +89 -0
- package/dist/service-queue.js +49 -0
- package/dist/service-runtime.js +96 -0
- package/dist/service-stage-finalizer.js +348 -0
- package/dist/service-stage-runner.js +233 -0
- package/dist/service-webhook-processor.js +181 -0
- package/dist/service-webhooks.js +148 -0
- package/dist/service.js +139 -0
- package/dist/stage-agent-activity-publisher.js +33 -0
- package/dist/stage-event-ports.js +1 -0
- package/dist/stage-failure.js +92 -0
- package/dist/stage-launch.js +54 -0
- package/dist/stage-lifecycle-publisher.js +213 -0
- package/dist/stage-reporting.js +153 -0
- package/dist/stage-turn-input-dispatcher.js +102 -0
- package/dist/token-crypto.js +21 -0
- package/dist/types.js +5 -0
- package/dist/utils.js +163 -0
- package/dist/webhook-agent-session-handler.js +157 -0
- package/dist/webhook-archive.js +24 -0
- package/dist/webhook-comment-handler.js +89 -0
- package/dist/webhook-desired-stage-recorder.js +150 -0
- package/dist/webhook-event-ports.js +1 -0
- package/dist/webhook-installation-handler.js +57 -0
- package/dist/webhooks.js +301 -0
- package/dist/workflow-policy.js +42 -0
- package/dist/workflow-ports.js +1 -0
- package/dist/workflow-types.js +1 -0
- package/dist/worktree-manager.js +66 -0
- package/infra/patchrelay-reload.service +6 -0
- package/infra/patchrelay.path +11 -0
- package/infra/patchrelay.service +28 -0
- package/package.json +55 -0
- package/runtime.env.example +8 -0
- package/service.env.example +7 -0
package/dist/cli/data.js
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
import pino from "pino";
|
|
2
|
+
import { CodexAppServerClient } from "../codex-app-server.js";
|
|
3
|
+
import { PatchRelayDatabase } from "../db.js";
|
|
4
|
+
import { resolveWorkflowStage } from "../workflow-policy.js";
|
|
5
|
+
function safeJsonParse(value) {
|
|
6
|
+
if (!value) {
|
|
7
|
+
return undefined;
|
|
8
|
+
}
|
|
9
|
+
try {
|
|
10
|
+
const parsed = JSON.parse(value);
|
|
11
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : undefined;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function summarizeThread(thread, latestTimestampSeen) {
|
|
18
|
+
const latestTurn = thread.turns.at(-1);
|
|
19
|
+
const latestAssistantMessage = latestTurn?.items
|
|
20
|
+
.filter((item) => item.type === "agentMessage")
|
|
21
|
+
.at(-1)?.text;
|
|
22
|
+
return {
|
|
23
|
+
threadId: thread.id,
|
|
24
|
+
threadStatus: thread.status,
|
|
25
|
+
...(latestTurn ? { latestTurnId: latestTurn.id, latestTurnStatus: latestTurn.status } : {}),
|
|
26
|
+
...(latestAssistantMessage ? { latestAssistantMessage } : {}),
|
|
27
|
+
...(latestTimestampSeen ? { latestTimestampSeen } : {}),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function latestEventTimestamp(db, stageRunId) {
|
|
31
|
+
const events = db.stageEvents.listThreadEvents(stageRunId);
|
|
32
|
+
return events.at(-1)?.createdAt;
|
|
33
|
+
}
|
|
34
|
+
function resolveStageFromState(config, projectId, stateName) {
|
|
35
|
+
const project = config.projects.find((entry) => entry.id === projectId);
|
|
36
|
+
if (!project) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
return resolveWorkflowStage(project, stateName);
|
|
40
|
+
}
|
|
41
|
+
export class CliDataAccess {
|
|
42
|
+
config;
|
|
43
|
+
db;
|
|
44
|
+
codex;
|
|
45
|
+
codexStarted = false;
|
|
46
|
+
constructor(config, options) {
|
|
47
|
+
this.config = config;
|
|
48
|
+
this.db = options?.db ?? new PatchRelayDatabase(config.database.path, config.database.wal);
|
|
49
|
+
this.codex = options?.codex;
|
|
50
|
+
}
|
|
51
|
+
close() {
|
|
52
|
+
if (!this.codexStarted) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
void this.codex?.stop();
|
|
56
|
+
this.codexStarted = false;
|
|
57
|
+
}
|
|
58
|
+
async inspect(issueKey) {
|
|
59
|
+
const issue = this.db.issueWorkflows.getTrackedIssueByKey(issueKey);
|
|
60
|
+
if (!issue) {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
const ledger = this.getLedgerIssueContext(issue.projectId, issue.linearIssueId);
|
|
64
|
+
const workspace = this.getWorkspaceForIssue(issue, ledger);
|
|
65
|
+
const activeStageRun = this.getActiveStageRunForIssue(issue, ledger);
|
|
66
|
+
const latestStageRun = this.db.issueWorkflows.getLatestStageRunForIssue(issue.projectId, issue.linearIssueId);
|
|
67
|
+
const latestReport = latestStageRun?.reportJson ? JSON.parse(latestStageRun.reportJson) : undefined;
|
|
68
|
+
const latestSummary = safeJsonParse(latestStageRun?.summaryJson);
|
|
69
|
+
const live = activeStageRun?.threadId &&
|
|
70
|
+
(await this.readLiveSummary(activeStageRun.threadId, latestEventTimestamp(this.db, activeStageRun.id)).catch(() => undefined));
|
|
71
|
+
const statusNote = (live && live.latestAssistantMessage) ??
|
|
72
|
+
latestReport?.assistantMessages.at(-1) ??
|
|
73
|
+
(typeof latestSummary?.latestAssistantMessage === "string" ? latestSummary.latestAssistantMessage : undefined) ??
|
|
74
|
+
(latestStageRun?.status === "failed" ? "Latest stage failed." : undefined) ??
|
|
75
|
+
(issue.desiredStage ? `Queued for ${issue.desiredStage}.` : undefined) ??
|
|
76
|
+
undefined;
|
|
77
|
+
return {
|
|
78
|
+
issue,
|
|
79
|
+
...(workspace ? { workspace } : {}),
|
|
80
|
+
...(activeStageRun ? { activeStageRun } : {}),
|
|
81
|
+
...(latestStageRun ? { latestStageRun } : {}),
|
|
82
|
+
...(latestReport ? { latestReport } : {}),
|
|
83
|
+
...(latestSummary ? { latestSummary } : {}),
|
|
84
|
+
...(live ? { live } : {}),
|
|
85
|
+
...(statusNote ? { statusNote } : {}),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
async live(issueKey) {
|
|
89
|
+
const issue = this.db.issueWorkflows.getTrackedIssueByKey(issueKey);
|
|
90
|
+
if (!issue) {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
const stageRun = this.getActiveStageRunForIssue(issue);
|
|
94
|
+
if (!stageRun) {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
const live = stageRun.threadId &&
|
|
98
|
+
(await this.readLiveSummary(stageRun.threadId, latestEventTimestamp(this.db, stageRun.id)).catch(() => undefined));
|
|
99
|
+
return {
|
|
100
|
+
issue,
|
|
101
|
+
stageRun,
|
|
102
|
+
...(live ? { live } : {}),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
report(issueKey, options) {
|
|
106
|
+
const issue = this.db.issueWorkflows.getTrackedIssueByKey(issueKey);
|
|
107
|
+
if (!issue) {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
const stages = this.db
|
|
111
|
+
.issueWorkflows.listStageRunsForIssue(issue.projectId, issue.linearIssueId)
|
|
112
|
+
.filter((stageRun) => {
|
|
113
|
+
if (options?.stageRunId !== undefined && stageRun.id !== options.stageRunId) {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
if (options?.stage !== undefined && stageRun.stage !== options.stage) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
return true;
|
|
120
|
+
})
|
|
121
|
+
.reverse()
|
|
122
|
+
.map((stageRun) => ({
|
|
123
|
+
stageRun,
|
|
124
|
+
...(stageRun.reportJson ? { report: JSON.parse(stageRun.reportJson) } : {}),
|
|
125
|
+
...(safeJsonParse(stageRun.summaryJson) ? { summary: safeJsonParse(stageRun.summaryJson) } : {}),
|
|
126
|
+
}));
|
|
127
|
+
return { issue, stages };
|
|
128
|
+
}
|
|
129
|
+
events(issueKey, options) {
|
|
130
|
+
const issue = this.db.issueWorkflows.getTrackedIssueByKey(issueKey);
|
|
131
|
+
if (!issue) {
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
const stageRun = (options?.stageRunId !== undefined ? this.db.issueWorkflows.getStageRun(options.stageRunId) : undefined) ??
|
|
135
|
+
this.getActiveStageRunForIssue(issue) ??
|
|
136
|
+
this.db.issueWorkflows.getLatestStageRunForIssue(issue.projectId, issue.linearIssueId);
|
|
137
|
+
if (!stageRun || stageRun.projectId !== issue.projectId || stageRun.linearIssueId !== issue.linearIssueId) {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
const events = this.db
|
|
141
|
+
.stageEvents.listThreadEvents(stageRun.id)
|
|
142
|
+
.filter((event) => (options?.method ? event.method === options.method : true))
|
|
143
|
+
.filter((event) => (options?.afterId !== undefined ? event.id > options.afterId : true))
|
|
144
|
+
.map((event) => ({
|
|
145
|
+
...event,
|
|
146
|
+
...(safeJsonParse(event.eventJson) ? { parsedEvent: safeJsonParse(event.eventJson) } : {}),
|
|
147
|
+
}));
|
|
148
|
+
return { issue, stageRun, events };
|
|
149
|
+
}
|
|
150
|
+
worktree(issueKey) {
|
|
151
|
+
const issue = this.db.issueWorkflows.getTrackedIssueByKey(issueKey);
|
|
152
|
+
if (!issue) {
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
const workspace = this.getWorkspaceForIssue(issue);
|
|
156
|
+
if (!workspace) {
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
issue,
|
|
161
|
+
workspace,
|
|
162
|
+
repoId: issue.projectId,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
open(issueKey) {
|
|
166
|
+
const worktree = this.worktree(issueKey);
|
|
167
|
+
if (!worktree) {
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
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;
|
|
175
|
+
return {
|
|
176
|
+
...worktree,
|
|
177
|
+
...(resumeThreadId ? { resumeThreadId } : {}),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
retry(issueKey, options) {
|
|
181
|
+
const issue = this.db.issueWorkflows.getTrackedIssueByKey(issueKey);
|
|
182
|
+
if (!issue) {
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
const ledger = this.getLedgerIssueContext(issue.projectId, issue.linearIssueId);
|
|
186
|
+
if (ledger.issueControl?.activeRunLeaseId !== undefined) {
|
|
187
|
+
throw new Error(`Issue ${issueKey} already has an active stage run.`);
|
|
188
|
+
}
|
|
189
|
+
const stage = options?.stage ?? resolveStageFromState(this.config, issue.projectId, issue.currentLinearState);
|
|
190
|
+
if (!stage) {
|
|
191
|
+
throw new Error(`Unable to infer a stage for ${issueKey}; pass --stage.`);
|
|
192
|
+
}
|
|
193
|
+
const webhookId = `cli-retry-${Date.now()}`;
|
|
194
|
+
const receipt = this.db.eventReceipts.insertEventReceipt({
|
|
195
|
+
source: "linear-webhook",
|
|
196
|
+
externalId: webhookId,
|
|
197
|
+
eventType: "cli-retry",
|
|
198
|
+
receivedAt: new Date().toISOString(),
|
|
199
|
+
acceptanceStatus: "accepted",
|
|
200
|
+
projectId: issue.projectId,
|
|
201
|
+
linearIssueId: issue.linearIssueId,
|
|
202
|
+
});
|
|
203
|
+
this.db.issueControl.upsertIssueControl({
|
|
204
|
+
projectId: issue.projectId,
|
|
205
|
+
linearIssueId: issue.linearIssueId,
|
|
206
|
+
desiredStage: stage,
|
|
207
|
+
desiredReceiptId: receipt.id,
|
|
208
|
+
lifecycleStatus: "queued",
|
|
209
|
+
});
|
|
210
|
+
this.db.issueWorkflows.setIssueDesiredStage(issue.projectId, issue.linearIssueId, stage, webhookId);
|
|
211
|
+
const updated = this.db.issueWorkflows.getTrackedIssue(issue.projectId, issue.linearIssueId);
|
|
212
|
+
return {
|
|
213
|
+
issue: updated,
|
|
214
|
+
stage,
|
|
215
|
+
...(options?.reason ? { reason: options.reason } : {}),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
list(options) {
|
|
219
|
+
const conditions = [];
|
|
220
|
+
const values = [];
|
|
221
|
+
if (options?.project) {
|
|
222
|
+
conditions.push("ai.project_id = ?");
|
|
223
|
+
values.push(options.project);
|
|
224
|
+
}
|
|
225
|
+
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
226
|
+
const rows = this.db.connection
|
|
227
|
+
.prepare(`
|
|
228
|
+
WITH all_issues AS (
|
|
229
|
+
SELECT project_id, linear_issue_id FROM issue_projection
|
|
230
|
+
UNION
|
|
231
|
+
SELECT project_id, linear_issue_id FROM issue_control
|
|
232
|
+
)
|
|
233
|
+
SELECT
|
|
234
|
+
ai.project_id,
|
|
235
|
+
ai.linear_issue_id,
|
|
236
|
+
ip.issue_key,
|
|
237
|
+
ip.title,
|
|
238
|
+
ip.current_linear_state,
|
|
239
|
+
COALESCE(ic.lifecycle_status, 'idle') AS lifecycle_status,
|
|
240
|
+
COALESCE(ic.updated_at, ip.updated_at) AS updated_at,
|
|
241
|
+
active_run.stage AS active_stage,
|
|
242
|
+
latest_run.stage AS latest_stage,
|
|
243
|
+
latest_run.status AS latest_stage_status
|
|
244
|
+
FROM all_issues ai
|
|
245
|
+
LEFT JOIN issue_projection ip
|
|
246
|
+
ON ip.project_id = ai.project_id AND ip.linear_issue_id = ai.linear_issue_id
|
|
247
|
+
LEFT JOIN issue_control ic
|
|
248
|
+
ON ic.project_id = ai.project_id AND ic.linear_issue_id = ai.linear_issue_id
|
|
249
|
+
LEFT JOIN run_leases active_run
|
|
250
|
+
ON active_run.id = ic.active_run_lease_id
|
|
251
|
+
LEFT JOIN run_leases latest_run ON latest_run.id = (
|
|
252
|
+
SELECT rl.id
|
|
253
|
+
FROM run_leases rl
|
|
254
|
+
WHERE rl.project_id = ai.project_id AND rl.linear_issue_id = ai.linear_issue_id
|
|
255
|
+
ORDER BY rl.id DESC
|
|
256
|
+
LIMIT 1
|
|
257
|
+
)
|
|
258
|
+
${whereClause}
|
|
259
|
+
ORDER BY COALESCE(ic.updated_at, ip.updated_at) DESC, ip.issue_key ASC, ai.linear_issue_id ASC
|
|
260
|
+
`)
|
|
261
|
+
.all(...values);
|
|
262
|
+
const items = rows.map((row) => {
|
|
263
|
+
const projectId = String(row.project_id);
|
|
264
|
+
const linearIssueId = String(row.linear_issue_id);
|
|
265
|
+
const issueKey = row.issue_key === null ? undefined : String(row.issue_key);
|
|
266
|
+
const issue = this.db.issueWorkflows.getTrackedIssue(projectId, linearIssueId);
|
|
267
|
+
const ledger = issue ? this.getLedgerIssueContext(issue.projectId, issue.linearIssueId) : undefined;
|
|
268
|
+
return {
|
|
269
|
+
...(issueKey ? { issueKey } : {}),
|
|
270
|
+
...(row.title === null ? {} : { title: String(row.title) }),
|
|
271
|
+
projectId,
|
|
272
|
+
...(row.current_linear_state === null ? {} : { currentLinearState: String(row.current_linear_state) }),
|
|
273
|
+
lifecycleStatus: String(row.lifecycle_status),
|
|
274
|
+
...(ledger?.runLease
|
|
275
|
+
? { activeStage: ledger.runLease.stage }
|
|
276
|
+
: row.active_stage !== null
|
|
277
|
+
? { activeStage: row.active_stage }
|
|
278
|
+
: {}),
|
|
279
|
+
...(row.latest_stage !== null
|
|
280
|
+
? { latestStage: row.latest_stage }
|
|
281
|
+
: ledger?.mirroredStageRun
|
|
282
|
+
? { latestStage: ledger.mirroredStageRun.stage }
|
|
283
|
+
: {}),
|
|
284
|
+
...(row.latest_stage_status !== null
|
|
285
|
+
? { latestStageStatus: String(row.latest_stage_status) }
|
|
286
|
+
: ledger?.mirroredStageRun
|
|
287
|
+
? { latestStageStatus: ledger.mirroredStageRun.status }
|
|
288
|
+
: {}),
|
|
289
|
+
updatedAt: String(row.updated_at),
|
|
290
|
+
};
|
|
291
|
+
});
|
|
292
|
+
return items.filter((item) => {
|
|
293
|
+
if (options?.active && !item.activeStage) {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
if (options?.failed && item.latestStageStatus !== "failed") {
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
return true;
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
getLedgerIssueContext(projectId, linearIssueId) {
|
|
303
|
+
const issueControl = this.db.issueControl.getIssueControl(projectId, linearIssueId);
|
|
304
|
+
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
|
+
return {
|
|
310
|
+
...(issueControl ? { issueControl } : {}),
|
|
311
|
+
...(runLease ? { runLease } : {}),
|
|
312
|
+
...(workspaceOwnership ? { workspaceOwnership } : {}),
|
|
313
|
+
...(mirroredStageRun ? { mirroredStageRun } : {}),
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
getActiveStageRunForIssue(issue, ledger) {
|
|
317
|
+
const context = ledger ?? this.getLedgerIssueContext(issue.projectId, issue.linearIssueId);
|
|
318
|
+
const activeStageRun = context.mirroredStageRun ?? this.synthesizeStageRunFromLease(context);
|
|
319
|
+
if (!activeStageRun) {
|
|
320
|
+
return undefined;
|
|
321
|
+
}
|
|
322
|
+
return activeStageRun.projectId === issue.projectId && activeStageRun.linearIssueId === issue.linearIssueId
|
|
323
|
+
? activeStageRun
|
|
324
|
+
: undefined;
|
|
325
|
+
}
|
|
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
|
+
getWorkspaceForIssue(issue, ledger) {
|
|
353
|
+
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);
|
|
356
|
+
if (activeWorkspace) {
|
|
357
|
+
return activeWorkspace;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
const workspaceOwnership = context.workspaceOwnership;
|
|
361
|
+
if (!workspaceOwnership) {
|
|
362
|
+
return this.db.issueWorkflows.getActiveWorkspaceForIssue(issue.projectId, issue.linearIssueId);
|
|
363
|
+
}
|
|
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
|
+
};
|
|
379
|
+
}
|
|
380
|
+
async connect(projectId) {
|
|
381
|
+
return await this.requestJson("/api/oauth/linear/start", {
|
|
382
|
+
...(projectId ? { projectId } : {}),
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
async connectStatus(state) {
|
|
386
|
+
if (!state) {
|
|
387
|
+
throw new Error("OAuth state is required.");
|
|
388
|
+
}
|
|
389
|
+
return await this.requestJson(`/api/oauth/linear/state/${encodeURIComponent(state)}`);
|
|
390
|
+
}
|
|
391
|
+
async listInstallations() {
|
|
392
|
+
return await this.requestJson("/api/installations");
|
|
393
|
+
}
|
|
394
|
+
getOperatorBaseUrl() {
|
|
395
|
+
const host = this.normalizeLocalHost(this.config.server.bind);
|
|
396
|
+
return `http://${host}:${this.config.server.port}/`;
|
|
397
|
+
}
|
|
398
|
+
normalizeLocalHost(bind) {
|
|
399
|
+
if (bind === "0.0.0.0") {
|
|
400
|
+
return "127.0.0.1";
|
|
401
|
+
}
|
|
402
|
+
if (bind === "::") {
|
|
403
|
+
return "[::1]";
|
|
404
|
+
}
|
|
405
|
+
if (bind.includes(":") && !bind.startsWith("[")) {
|
|
406
|
+
return `[${bind}]`;
|
|
407
|
+
}
|
|
408
|
+
return bind;
|
|
409
|
+
}
|
|
410
|
+
async requestJson(pathname, query, init) {
|
|
411
|
+
const url = new URL(pathname, this.getOperatorBaseUrl());
|
|
412
|
+
for (const [key, value] of Object.entries(query ?? {})) {
|
|
413
|
+
if (value) {
|
|
414
|
+
url.searchParams.set(key, value);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
const response = await fetch(url, {
|
|
418
|
+
method: init?.method ?? "GET",
|
|
419
|
+
headers: {
|
|
420
|
+
accept: "application/json",
|
|
421
|
+
...(init?.body !== undefined ? { "content-type": "application/json" } : {}),
|
|
422
|
+
...(this.config.operatorApi.bearerToken ? { authorization: `Bearer ${this.config.operatorApi.bearerToken}` } : {}),
|
|
423
|
+
},
|
|
424
|
+
...(init?.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
|
|
425
|
+
});
|
|
426
|
+
const body = await response.text();
|
|
427
|
+
if (!response.ok) {
|
|
428
|
+
const message = this.readErrorMessage(body);
|
|
429
|
+
throw new Error(message ?? `Request failed: ${response.status}`);
|
|
430
|
+
}
|
|
431
|
+
const parsed = JSON.parse(body);
|
|
432
|
+
if (parsed.ok === false) {
|
|
433
|
+
throw new Error(this.readErrorMessage(body) ?? "Request failed.");
|
|
434
|
+
}
|
|
435
|
+
return parsed;
|
|
436
|
+
}
|
|
437
|
+
readErrorMessage(body) {
|
|
438
|
+
try {
|
|
439
|
+
const parsed = JSON.parse(body);
|
|
440
|
+
return parsed.message ?? parsed.reason;
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
return undefined;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
async readLiveSummary(threadId, latestTimestampSeen) {
|
|
447
|
+
const codex = await this.getCodex();
|
|
448
|
+
const thread = await codex.readThread(threadId, true);
|
|
449
|
+
return summarizeThread(thread, latestTimestampSeen);
|
|
450
|
+
}
|
|
451
|
+
async getCodex() {
|
|
452
|
+
if (!this.codex) {
|
|
453
|
+
this.codex = new CodexAppServerClient(this.config.runner.codex, pino({ enabled: false }));
|
|
454
|
+
}
|
|
455
|
+
if (!this.codexStarted) {
|
|
456
|
+
await this.codex.start();
|
|
457
|
+
this.codexStarted = true;
|
|
458
|
+
}
|
|
459
|
+
return this.codex;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
function value(label, entry) {
|
|
2
|
+
return `${label}: ${entry ?? "-"}`;
|
|
3
|
+
}
|
|
4
|
+
function truncateLine(input) {
|
|
5
|
+
if (!input) {
|
|
6
|
+
return undefined;
|
|
7
|
+
}
|
|
8
|
+
const normalized = input.replace(/\s+/g, " ").trim();
|
|
9
|
+
return normalized.length > 240 ? `${normalized.slice(0, 237)}...` : normalized;
|
|
10
|
+
}
|
|
11
|
+
export function formatInspect(result) {
|
|
12
|
+
const header = [result.issue?.issueKey ?? result.issue?.linearIssueId ?? "unknown", result.issue?.currentLinearState]
|
|
13
|
+
.filter(Boolean)
|
|
14
|
+
.join(" ");
|
|
15
|
+
const lines = [
|
|
16
|
+
header,
|
|
17
|
+
value("Title", result.issue?.title),
|
|
18
|
+
value("Lifecycle", result.issue?.lifecycleStatus),
|
|
19
|
+
value("Active stage", result.activeStageRun?.stage),
|
|
20
|
+
value("Latest stage", result.latestStageRun?.stage),
|
|
21
|
+
value("Latest result", result.latestStageRun?.status),
|
|
22
|
+
value("Workspace", result.workspace?.worktreePath),
|
|
23
|
+
value("Branch", result.workspace?.branchName),
|
|
24
|
+
value("Latest thread", result.activeStageRun?.threadId ?? result.issue?.latestThreadId ?? result.workspace?.lastThreadId),
|
|
25
|
+
value("Latest turn", result.live?.latestTurnId ?? result.activeStageRun?.turnId ?? result.latestStageRun?.turnId),
|
|
26
|
+
result.statusNote ? value("Status", truncateLine(result.statusNote)) : undefined,
|
|
27
|
+
result.live?.latestTurnStatus ? value("Live turn", result.live.latestTurnStatus) : undefined,
|
|
28
|
+
result.live?.latestAssistantMessage ? `Latest assistant message:\n${truncateLine(result.live.latestAssistantMessage)}` : undefined,
|
|
29
|
+
].filter(Boolean);
|
|
30
|
+
return `${lines.join("\n")}\n`;
|
|
31
|
+
}
|
|
32
|
+
export function formatLive(result) {
|
|
33
|
+
const lines = [
|
|
34
|
+
value("Issue", result.issue.issueKey ?? result.issue.linearIssueId),
|
|
35
|
+
value("Stage", result.stageRun.stage),
|
|
36
|
+
value("Thread", result.stageRun.threadId),
|
|
37
|
+
value("Turn", result.live?.latestTurnId ?? result.stageRun.turnId),
|
|
38
|
+
value("Turn status", result.live?.latestTurnStatus ?? result.live?.threadStatus ?? result.stageRun.status),
|
|
39
|
+
value("Latest timestamp", result.live?.latestTimestampSeen),
|
|
40
|
+
result.live?.latestAssistantMessage ? `Latest assistant message:\n${truncateLine(result.live.latestAssistantMessage)}` : undefined,
|
|
41
|
+
].filter(Boolean);
|
|
42
|
+
return `${lines.join("\n")}\n`;
|
|
43
|
+
}
|
|
44
|
+
export function formatReport(result) {
|
|
45
|
+
const sections = result.stages.map(({ stageRun, report, summary }) => {
|
|
46
|
+
const changedFiles = report?.fileChanges
|
|
47
|
+
.map((entry) => (typeof entry.path === "string" ? entry.path : undefined))
|
|
48
|
+
.filter(Boolean)
|
|
49
|
+
.join(", ");
|
|
50
|
+
const commands = report?.commands.map((command) => command.command).join(" | ");
|
|
51
|
+
const tools = report?.toolCalls.map((tool) => `${tool.type}:${tool.name}`).join(", ");
|
|
52
|
+
return [
|
|
53
|
+
`${stageRun.stage} #${stageRun.id} ${stageRun.status}`,
|
|
54
|
+
value("Started", stageRun.startedAt),
|
|
55
|
+
value("Ended", stageRun.endedAt),
|
|
56
|
+
value("Thread", stageRun.threadId),
|
|
57
|
+
summary?.latestAssistantMessage ? value("Summary", truncateLine(String(summary.latestAssistantMessage))) : undefined,
|
|
58
|
+
report?.assistantMessages.at(-1) ? value("Assistant conclusion", truncateLine(report.assistantMessages.at(-1))) : undefined,
|
|
59
|
+
commands ? value("Commands", commands) : undefined,
|
|
60
|
+
changedFiles ? value("Changed files", changedFiles) : undefined,
|
|
61
|
+
tools ? value("Tool calls", tools) : undefined,
|
|
62
|
+
]
|
|
63
|
+
.filter(Boolean)
|
|
64
|
+
.join("\n");
|
|
65
|
+
});
|
|
66
|
+
return `${sections.join("\n\n")}\n`;
|
|
67
|
+
}
|
|
68
|
+
export function formatEvents(result) {
|
|
69
|
+
const sections = result.events.map((event) => [
|
|
70
|
+
`#${event.id} ${event.createdAt} ${event.method}`,
|
|
71
|
+
value("Thread", event.threadId),
|
|
72
|
+
value("Turn", event.turnId),
|
|
73
|
+
event.parsedEvent ? JSON.stringify(event.parsedEvent, null, 2) : event.eventJson,
|
|
74
|
+
].join("\n"));
|
|
75
|
+
return `${value("Stage run", result.stageRun.id)}\n${value("Stage", result.stageRun.stage)}\n\n${sections.join("\n\n")}\n`;
|
|
76
|
+
}
|
|
77
|
+
export function formatWorktree(result, cdOnly) {
|
|
78
|
+
if (cdOnly) {
|
|
79
|
+
return `${result.workspace.worktreePath}\n`;
|
|
80
|
+
}
|
|
81
|
+
return `${[
|
|
82
|
+
value("Issue", result.issue.issueKey ?? result.issue.linearIssueId),
|
|
83
|
+
value("Worktree", result.workspace.worktreePath),
|
|
84
|
+
value("Branch", result.workspace.branchName),
|
|
85
|
+
value("Repo", result.repoId),
|
|
86
|
+
].join("\n")}\n`;
|
|
87
|
+
}
|
|
88
|
+
export function formatOpen(result) {
|
|
89
|
+
const commands = [
|
|
90
|
+
`cd ${result.workspace.worktreePath}`,
|
|
91
|
+
"git branch --show-current",
|
|
92
|
+
"codex --dangerously-bypass-approvals-and-sandbox",
|
|
93
|
+
];
|
|
94
|
+
if (result.resumeThreadId) {
|
|
95
|
+
commands.push(`codex --dangerously-bypass-approvals-and-sandbox resume ${result.resumeThreadId}`);
|
|
96
|
+
}
|
|
97
|
+
return `${commands.join("\n")}\n`;
|
|
98
|
+
}
|
|
99
|
+
export function formatRetry(result) {
|
|
100
|
+
return `${[
|
|
101
|
+
value("Issue", result.issue.issueKey ?? result.issue.linearIssueId),
|
|
102
|
+
value("Queued stage", result.stage),
|
|
103
|
+
result.reason ? value("Reason", result.reason) : undefined,
|
|
104
|
+
]
|
|
105
|
+
.filter(Boolean)
|
|
106
|
+
.join("\n")}\n`;
|
|
107
|
+
}
|
|
108
|
+
export function formatList(items) {
|
|
109
|
+
return `${items
|
|
110
|
+
.map((item) => [
|
|
111
|
+
item.issueKey ?? "-",
|
|
112
|
+
item.currentLinearState ?? "-",
|
|
113
|
+
item.lifecycleStatus,
|
|
114
|
+
item.activeStage ?? "-",
|
|
115
|
+
item.latestStage ? `${item.latestStage}:${item.latestStageStatus ?? "-"}` : "-",
|
|
116
|
+
item.updatedAt,
|
|
117
|
+
].join("\t"))
|
|
118
|
+
.join("\n")}\n`;
|
|
119
|
+
}
|