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.
Files changed (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +271 -0
  3. package/config/patchrelay.example.json +5 -0
  4. package/dist/build-info.js +29 -0
  5. package/dist/build-info.json +6 -0
  6. package/dist/cli/data.js +461 -0
  7. package/dist/cli/formatters/json.js +3 -0
  8. package/dist/cli/formatters/text.js +119 -0
  9. package/dist/cli/index.js +761 -0
  10. package/dist/codex-app-server.js +353 -0
  11. package/dist/codex-types.js +1 -0
  12. package/dist/config-types.js +1 -0
  13. package/dist/config.js +494 -0
  14. package/dist/db/authoritative-ledger-store.js +437 -0
  15. package/dist/db/issue-workflow-store.js +690 -0
  16. package/dist/db/linear-installation-store.js +184 -0
  17. package/dist/db/migrations.js +183 -0
  18. package/dist/db/shared.js +101 -0
  19. package/dist/db/stage-event-store.js +33 -0
  20. package/dist/db/webhook-event-store.js +46 -0
  21. package/dist/db-ports.js +5 -0
  22. package/dist/db-types.js +1 -0
  23. package/dist/db.js +40 -0
  24. package/dist/file-permissions.js +40 -0
  25. package/dist/http.js +321 -0
  26. package/dist/index.js +69 -0
  27. package/dist/install.js +302 -0
  28. package/dist/installation-ports.js +1 -0
  29. package/dist/issue-query-service.js +68 -0
  30. package/dist/ledger-ports.js +1 -0
  31. package/dist/linear-client.js +338 -0
  32. package/dist/linear-oauth-service.js +131 -0
  33. package/dist/linear-oauth.js +154 -0
  34. package/dist/linear-types.js +1 -0
  35. package/dist/linear-workflow.js +78 -0
  36. package/dist/logging.js +62 -0
  37. package/dist/preflight.js +227 -0
  38. package/dist/project-resolution.js +51 -0
  39. package/dist/reconciliation-action-applier.js +55 -0
  40. package/dist/reconciliation-actions.js +1 -0
  41. package/dist/reconciliation-engine.js +312 -0
  42. package/dist/reconciliation-snapshot-builder.js +96 -0
  43. package/dist/reconciliation-types.js +1 -0
  44. package/dist/runtime-paths.js +89 -0
  45. package/dist/service-queue.js +49 -0
  46. package/dist/service-runtime.js +96 -0
  47. package/dist/service-stage-finalizer.js +348 -0
  48. package/dist/service-stage-runner.js +233 -0
  49. package/dist/service-webhook-processor.js +181 -0
  50. package/dist/service-webhooks.js +148 -0
  51. package/dist/service.js +139 -0
  52. package/dist/stage-agent-activity-publisher.js +33 -0
  53. package/dist/stage-event-ports.js +1 -0
  54. package/dist/stage-failure.js +92 -0
  55. package/dist/stage-launch.js +54 -0
  56. package/dist/stage-lifecycle-publisher.js +213 -0
  57. package/dist/stage-reporting.js +153 -0
  58. package/dist/stage-turn-input-dispatcher.js +102 -0
  59. package/dist/token-crypto.js +21 -0
  60. package/dist/types.js +5 -0
  61. package/dist/utils.js +163 -0
  62. package/dist/webhook-agent-session-handler.js +157 -0
  63. package/dist/webhook-archive.js +24 -0
  64. package/dist/webhook-comment-handler.js +89 -0
  65. package/dist/webhook-desired-stage-recorder.js +150 -0
  66. package/dist/webhook-event-ports.js +1 -0
  67. package/dist/webhook-installation-handler.js +57 -0
  68. package/dist/webhooks.js +301 -0
  69. package/dist/workflow-policy.js +42 -0
  70. package/dist/workflow-ports.js +1 -0
  71. package/dist/workflow-types.js +1 -0
  72. package/dist/worktree-manager.js +66 -0
  73. package/infra/patchrelay-reload.service +6 -0
  74. package/infra/patchrelay.path +11 -0
  75. package/infra/patchrelay.service +28 -0
  76. package/package.json +55 -0
  77. package/runtime.env.example +8 -0
  78. package/service.env.example +7 -0
@@ -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,3 @@
1
+ export function formatJson(value) {
2
+ return `${JSON.stringify(value, null, 2)}\n`;
3
+ }
@@ -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
+ }