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 @@
1
+ export {};
@@ -0,0 +1,78 @@
1
+ import { resolveWorkflowById } from "./workflow-policy.js";
2
+ const STATUS_MARKER = "<!-- patchrelay:status-comment -->";
3
+ export function resolveActiveLinearState(project, stage) {
4
+ return resolveWorkflowById(project, stage)?.activeState;
5
+ }
6
+ export function resolveFallbackLinearState(project, stage) {
7
+ return resolveWorkflowById(project, stage)?.fallbackState;
8
+ }
9
+ export function buildRunningStatusComment(params) {
10
+ return [
11
+ STATUS_MARKER,
12
+ `PatchRelay is running the ${params.stageRun.stage} workflow.`,
13
+ "",
14
+ `- Issue: \`${params.issue.issueKey ?? params.issue.linearIssueId}\``,
15
+ `- Workflow: \`${params.stageRun.stage}\``,
16
+ `- Branch: \`${params.branchName}\``,
17
+ `- Thread: \`${params.stageRun.threadId ?? "starting"}\``,
18
+ `- Turn: \`${params.stageRun.turnId ?? "starting"}\``,
19
+ `- Started: \`${params.stageRun.startedAt}\``,
20
+ "- Status: `working`",
21
+ ].join("\n");
22
+ }
23
+ export function buildAwaitingHandoffComment(params) {
24
+ return [
25
+ STATUS_MARKER,
26
+ `PatchRelay finished the ${params.stageRun.stage} workflow, but Linear is still in \`${params.activeState}\`.`,
27
+ "",
28
+ `- Issue: \`${params.issue.issueKey ?? params.issue.linearIssueId}\``,
29
+ `- Workflow: \`${params.stageRun.stage}\``,
30
+ `- Thread: \`${params.stageRun.threadId ?? "unknown"}\``,
31
+ `- Turn: \`${params.stageRun.turnId ?? "unknown"}\``,
32
+ `- Completed: \`${params.stageRun.endedAt ?? new Date().toISOString()}\``,
33
+ "- Status: `awaiting-final-state`",
34
+ "",
35
+ "The workflow likely finished without moving the issue to its next Linear state. Please review the thread report and update the issue state.",
36
+ ].join("\n");
37
+ }
38
+ export function buildStageFailedComment(params) {
39
+ const mode = params.mode ?? "launch";
40
+ return [
41
+ STATUS_MARKER,
42
+ mode === "launch"
43
+ ? `PatchRelay could not start the ${params.stageRun.stage} workflow.`
44
+ : `PatchRelay marked the ${params.stageRun.stage} workflow as failed.`,
45
+ "",
46
+ `- Issue: \`${params.issue.issueKey ?? params.issue.linearIssueId}\``,
47
+ `- Workflow: \`${params.stageRun.stage}\``,
48
+ `- Started: \`${params.stageRun.startedAt}\``,
49
+ `- Failure: \`${params.message}\``,
50
+ `- Recommended state: \`${params.fallbackState ?? "Human Needed"}\``,
51
+ mode === "launch" ? "- Status: `launch-failed`" : "- Status: `stage-failed`",
52
+ ].join("\n");
53
+ }
54
+ export function isPatchRelayStatusComment(commentId, body, trackedCommentId) {
55
+ if (trackedCommentId && commentId === trackedCommentId) {
56
+ return true;
57
+ }
58
+ return typeof body === "string" && body.includes(STATUS_MARKER);
59
+ }
60
+ export function resolveWorkflowLabelNames(project, mode) {
61
+ const working = project.workflowLabels?.working;
62
+ const awaitingHandoff = project.workflowLabels?.awaitingHandoff;
63
+ if (mode === "working") {
64
+ return {
65
+ add: working ? [working] : [],
66
+ remove: awaitingHandoff ? [awaitingHandoff] : [],
67
+ };
68
+ }
69
+ return {
70
+ add: awaitingHandoff ? [awaitingHandoff] : [],
71
+ remove: working ? [working] : [],
72
+ };
73
+ }
74
+ export function resolveWorkflowLabelCleanup(project) {
75
+ return {
76
+ remove: [project.workflowLabels?.working, project.workflowLabels?.awaitingHandoff].filter((value) => Boolean(value)),
77
+ };
78
+ }
@@ -0,0 +1,62 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import path from "node:path";
3
+ import pino, {} from "pino";
4
+ export function createLogger(config) {
5
+ const options = {
6
+ level: config.logging.level,
7
+ base: null,
8
+ redact: {
9
+ paths: [
10
+ "headers.authorization",
11
+ "headers.cookie",
12
+ "headers.set-cookie",
13
+ "headers.linear-signature",
14
+ "req.headers.authorization",
15
+ "req.headers.cookie",
16
+ "req.headers.set-cookie",
17
+ "req.headers.linear-signature",
18
+ "authorization",
19
+ "accessToken",
20
+ "refreshToken",
21
+ "access_token",
22
+ "refresh_token",
23
+ "clientSecret",
24
+ "client_secret",
25
+ "webhookSecret",
26
+ "tokenEncryptionKey",
27
+ "bearerToken",
28
+ "accessTokenCiphertext",
29
+ "refreshTokenCiphertext",
30
+ "linear.webhookSecret",
31
+ "linear.oauth.clientSecret",
32
+ "operatorApi.bearerToken",
33
+ ],
34
+ censor: "[redacted]",
35
+ },
36
+ transport: {
37
+ targets: [
38
+ {
39
+ target: "pino-logfmt",
40
+ options: buildLogfmtOptions(1),
41
+ },
42
+ {
43
+ target: "pino-logfmt",
44
+ options: buildLogfmtOptions(config.logging.filePath),
45
+ },
46
+ ],
47
+ },
48
+ };
49
+ mkdirSync(path.dirname(config.logging.filePath), { recursive: true });
50
+ return pino(options);
51
+ }
52
+ function buildLogfmtOptions(destination) {
53
+ return {
54
+ destination,
55
+ flattenNestedObjects: true,
56
+ flattenNestedSeparator: ".",
57
+ includeLevelLabel: true,
58
+ levelLabelKey: "level",
59
+ formatTime: true,
60
+ escapeMultilineStrings: true,
61
+ };
62
+ }
@@ -0,0 +1,227 @@
1
+ import { accessSync, constants, existsSync, mkdirSync, statSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { execCommand } from "./utils.js";
4
+ export async function runPreflight(config) {
5
+ const checks = [];
6
+ if (!config.linear.webhookSecret) {
7
+ checks.push(fail("linear", "LINEAR_WEBHOOK_SECRET is missing"));
8
+ }
9
+ else {
10
+ checks.push(pass("linear", "Linear webhook secret is configured"));
11
+ }
12
+ if (!config.linear.oauth.clientId) {
13
+ checks.push(fail("linear_oauth", "LINEAR_OAUTH_CLIENT_ID is missing"));
14
+ }
15
+ else {
16
+ checks.push(pass("linear_oauth", `Linear OAuth is configured with actor=${config.linear.oauth.actor}`));
17
+ }
18
+ if (!config.linear.oauth.clientSecret) {
19
+ checks.push(fail("linear_oauth", "LINEAR_OAUTH_CLIENT_SECRET is missing"));
20
+ }
21
+ else {
22
+ checks.push(pass("linear_oauth", "Linear OAuth client secret is configured"));
23
+ }
24
+ if (!config.linear.tokenEncryptionKey) {
25
+ checks.push(fail("linear_oauth", "PATCHRELAY_TOKEN_ENCRYPTION_KEY is missing"));
26
+ }
27
+ else {
28
+ checks.push(pass("linear_oauth", "Token encryption key is configured"));
29
+ }
30
+ if (config.linear.oauth.actor === "app") {
31
+ const scopes = new Set(config.linear.oauth.scopes);
32
+ const missingScopes = ["app:assignable", "app:mentionable"].filter((scope) => !scopes.has(scope));
33
+ if (missingScopes.length > 0) {
34
+ checks.push(warn("linear_oauth", `Linear app actor is missing recommended agent scopes: ${missingScopes.join(", ")}`));
35
+ }
36
+ else {
37
+ checks.push(pass("linear_oauth", "Linear app actor includes assignable and mentionable scopes"));
38
+ }
39
+ for (const project of config.projects) {
40
+ if (!project.triggerEvents.includes("agentSessionCreated")) {
41
+ checks.push(warn(`project:${project.id}:triggers`, "App-mode delegation works best when trigger_events includes agentSessionCreated"));
42
+ }
43
+ if (!project.triggerEvents.includes("agentPrompted")) {
44
+ checks.push(warn(`project:${project.id}:triggers`, "Native follow-up agent prompts will not reach an active run unless trigger_events includes agentPrompted"));
45
+ }
46
+ }
47
+ }
48
+ if (config.operatorApi.enabled) {
49
+ if (config.operatorApi.bearerToken) {
50
+ checks.push(pass("operator_api", "Operator API is enabled with bearer token protection"));
51
+ }
52
+ else if (config.server.bind === "127.0.0.1") {
53
+ checks.push(warn("operator_api", "Operator API is enabled without a bearer token; safe only on loopback binds"));
54
+ }
55
+ else {
56
+ checks.push(fail("operator_api", "Operator API is enabled without a bearer token on a non-loopback bind"));
57
+ }
58
+ }
59
+ else {
60
+ checks.push(pass("operator_api", "Operator API is disabled"));
61
+ }
62
+ checks.push(...checkPublicBaseUrl(config));
63
+ checks.push(...checkOAuthRedirectUri(config));
64
+ checks.push(...checkPath("database", path.dirname(config.database.path), "directory", { createIfMissing: true, writable: true }));
65
+ checks.push(...checkPath("logging", path.dirname(config.logging.filePath), "directory", { createIfMissing: true, writable: true }));
66
+ if (config.logging.webhookArchiveDir) {
67
+ checks.push(...checkPath("archive", config.logging.webhookArchiveDir, "directory", { createIfMissing: true, writable: true }));
68
+ }
69
+ else {
70
+ checks.push(warn("archive", "Raw webhook archival is disabled"));
71
+ }
72
+ if (config.projects.length === 0) {
73
+ checks.push(warn("projects", "No projects are configured yet; add one with `patchrelay project apply <id> <repo-path>` before connecting Linear"));
74
+ }
75
+ for (const project of config.projects) {
76
+ checks.push(...checkPath(`project:${project.id}:repo`, project.repoPath, "directory", { writable: true }));
77
+ checks.push(...checkPath(`project:${project.id}:worktrees`, project.worktreeRoot, "directory", { createIfMissing: true, writable: true }));
78
+ for (const workflow of project.workflows) {
79
+ checks.push(...checkPath(`project:${project.id}:workflow:${workflow.id}`, workflow.workflowFile, "file", {}));
80
+ }
81
+ }
82
+ checks.push(await checkExecutable("git", config.runner.gitBin));
83
+ checks.push(await checkExecutable("codex", config.runner.codex.bin));
84
+ return {
85
+ checks,
86
+ ok: checks.every((check) => check.status !== "fail"),
87
+ };
88
+ }
89
+ function checkPath(scope, targetPath, expectedType, options) {
90
+ const checks = [];
91
+ if (!existsSync(targetPath)) {
92
+ if (expectedType === "directory" && options.createIfMissing) {
93
+ try {
94
+ mkdirSync(targetPath, { recursive: true });
95
+ checks.push(pass(scope, `Created missing directory ${targetPath}`));
96
+ }
97
+ catch (error) {
98
+ checks.push(fail(scope, `Unable to create directory ${targetPath}: ${formatError(error)}`));
99
+ return checks;
100
+ }
101
+ }
102
+ else {
103
+ checks.push(fail(scope, `Missing ${expectedType}: ${targetPath}`));
104
+ return checks;
105
+ }
106
+ }
107
+ let stats;
108
+ try {
109
+ stats = statSync(targetPath);
110
+ }
111
+ catch (error) {
112
+ checks.push(fail(scope, `Unable to stat ${targetPath}: ${formatError(error)}`));
113
+ return checks;
114
+ }
115
+ if (expectedType === "file" && !stats.isFile()) {
116
+ checks.push(fail(scope, `Expected a file: ${targetPath}`));
117
+ return checks;
118
+ }
119
+ if (expectedType === "directory" && !stats.isDirectory()) {
120
+ checks.push(fail(scope, `Expected a directory: ${targetPath}`));
121
+ return checks;
122
+ }
123
+ if (options.writable) {
124
+ try {
125
+ accessSync(targetPath, constants.W_OK);
126
+ checks.push(pass(scope, `${targetPath} is writable`));
127
+ }
128
+ catch (error) {
129
+ checks.push(fail(scope, `${targetPath} is not writable: ${formatError(error)}`));
130
+ return checks;
131
+ }
132
+ }
133
+ else {
134
+ checks.push(pass(scope, `${targetPath} exists`));
135
+ }
136
+ return checks;
137
+ }
138
+ async function checkExecutable(scope, command) {
139
+ try {
140
+ const result = await execCommand(command, ["--version"], {
141
+ timeoutMs: 5000,
142
+ });
143
+ if (result.exitCode !== 0) {
144
+ return fail(scope, `${command} --version exited with ${result.exitCode}`);
145
+ }
146
+ const firstLine = result.stdout.split(/\r?\n/, 1)[0]?.trim() || "version command succeeded";
147
+ return pass(scope, `${command} available: ${firstLine}`);
148
+ }
149
+ catch (error) {
150
+ return fail(scope, `${command} is not executable: ${formatError(error)}`);
151
+ }
152
+ }
153
+ function checkPublicBaseUrl(config) {
154
+ const publicBaseUrl = config.server.publicBaseUrl;
155
+ if (!publicBaseUrl) {
156
+ return [
157
+ warn("public_url", "server.public_base_url is not configured; set it to the public HTTPS origin that Linear should call"),
158
+ ];
159
+ }
160
+ try {
161
+ const url = new URL(publicBaseUrl);
162
+ const checks = [pass("public_url", `Public base URL configured: ${url.origin}`)];
163
+ if (url.protocol !== "https:") {
164
+ checks.push(warn("public_url", "server.public_base_url is not HTTPS; Linear-facing ingress should usually be HTTPS"));
165
+ }
166
+ if (isLoopbackHost(url.hostname)) {
167
+ checks.push(warn("public_url", "server.public_base_url points at a loopback host and will not be reachable by Linear"));
168
+ }
169
+ if (url.pathname !== "/" && url.pathname !== "") {
170
+ checks.push(warn("public_url", "server.public_base_url path is ignored; use only scheme, host, and optional port"));
171
+ }
172
+ return checks;
173
+ }
174
+ catch (error) {
175
+ return [fail("public_url", `Invalid server.public_base_url: ${formatError(error)}`)];
176
+ }
177
+ }
178
+ function checkOAuthRedirectUri(config) {
179
+ try {
180
+ const url = new URL(config.linear.oauth.redirectUri);
181
+ const checks = [];
182
+ if (url.pathname !== "/oauth/linear/callback") {
183
+ checks.push(fail("linear_oauth", 'linear.oauth.redirect_uri must use the fixed "/oauth/linear/callback" path'));
184
+ return checks;
185
+ }
186
+ if (isLoopbackHost(url.hostname)) {
187
+ checks.push(pass("linear_oauth", "Linear OAuth redirect URI is configured for local callback handling"));
188
+ }
189
+ else {
190
+ checks.push(pass("linear_oauth", `Linear OAuth redirect URI is configured for public callback handling at ${url.origin}`));
191
+ if (url.protocol !== "https:") {
192
+ checks.push(warn("linear_oauth", "Public Linear OAuth redirect URIs should usually use HTTPS"));
193
+ }
194
+ }
195
+ return checks;
196
+ }
197
+ catch (error) {
198
+ return [fail("linear_oauth", `Invalid linear.oauth.redirect_uri: ${formatError(error)}`)];
199
+ }
200
+ }
201
+ function isLoopbackHost(host) {
202
+ return host === "127.0.0.1" || host === "::1" || host === "localhost";
203
+ }
204
+ function formatError(error) {
205
+ return error instanceof Error ? error.message : String(error);
206
+ }
207
+ function pass(scope, message) {
208
+ return {
209
+ status: "pass",
210
+ scope,
211
+ message,
212
+ };
213
+ }
214
+ function warn(scope, message) {
215
+ return {
216
+ status: "warn",
217
+ scope,
218
+ message,
219
+ };
220
+ }
221
+ function fail(scope, message) {
222
+ return {
223
+ status: "fail",
224
+ scope,
225
+ message,
226
+ };
227
+ }
@@ -0,0 +1,51 @@
1
+ import { matchesProject } from "./workflow-policy.js";
2
+ export function resolveProject(config, issue) {
3
+ const matches = config.projects.filter((project) => matchesProject(issue, project));
4
+ if (matches.length === 1) {
5
+ return matches[0];
6
+ }
7
+ return undefined;
8
+ }
9
+ export function triggerEventAllowed(project, triggerEvent) {
10
+ if (project.triggerEvents.includes(triggerEvent)) {
11
+ return true;
12
+ }
13
+ if (triggerEvent === "agentSessionCreated") {
14
+ return project.triggerEvents.includes("delegateChanged") || project.triggerEvents.includes("statusChanged");
15
+ }
16
+ return project.triggerEvents.includes(triggerEvent);
17
+ }
18
+ function normalizeTrustValue(value) {
19
+ const trimmed = value?.trim();
20
+ return trimmed ? trimmed.toLowerCase() : undefined;
21
+ }
22
+ export function trustedActorAllowed(project, actor) {
23
+ const trusted = project.trustedActors;
24
+ if (!trusted) {
25
+ return true;
26
+ }
27
+ const hasRules = trusted.ids.length > 0 || trusted.names.length > 0 || trusted.emails.length > 0 || trusted.emailDomains.length > 0;
28
+ if (!hasRules) {
29
+ return true;
30
+ }
31
+ if (!actor) {
32
+ return false;
33
+ }
34
+ const actorId = actor.id?.trim();
35
+ if (actorId && trusted.ids.includes(actorId)) {
36
+ return true;
37
+ }
38
+ const actorName = normalizeTrustValue(actor.name);
39
+ if (actorName && trusted.names.map((value) => value.trim().toLowerCase()).includes(actorName)) {
40
+ return true;
41
+ }
42
+ const actorEmail = normalizeTrustValue(actor.email);
43
+ if (actorEmail && trusted.emails.map((value) => value.trim().toLowerCase()).includes(actorEmail)) {
44
+ return true;
45
+ }
46
+ const actorDomain = actorEmail?.split("@").at(-1);
47
+ if (actorDomain && trusted.emailDomains.map((value) => value.trim().toLowerCase()).includes(actorDomain)) {
48
+ return true;
49
+ }
50
+ return false;
51
+ }
@@ -0,0 +1,55 @@
1
+ export class ReconciliationActionApplier {
2
+ callbacks;
3
+ constructor(callbacks) {
4
+ this.callbacks = callbacks;
5
+ }
6
+ async apply(params) {
7
+ const { snapshot, decision } = params;
8
+ const threadId = snapshot.runLease.threadId;
9
+ const turnId = snapshot.runLease.turnId;
10
+ const obligationTargetAction = decision.actions.find((action) => action.type === "deliver_obligation" || action.type === "route_obligation");
11
+ const targetThreadId = obligationTargetAction?.type === "deliver_obligation" || obligationTargetAction?.type === "route_obligation"
12
+ ? obligationTargetAction.threadId
13
+ : threadId;
14
+ const targetTurnId = obligationTargetAction?.type === "deliver_obligation" || obligationTargetAction?.type === "route_obligation"
15
+ ? obligationTargetAction.turnId
16
+ : turnId;
17
+ const clearAction = decision.actions.find((action) => action.type === "clear_active_run" || action.type === "release_issue_ownership");
18
+ const nextLifecycleStatus = clearAction?.type === "clear_active_run" || clearAction?.type === "release_issue_ownership"
19
+ ? clearAction.nextLifecycleStatus
20
+ : undefined;
21
+ if (decision.outcome === "launch") {
22
+ this.callbacks.enqueueIssue(snapshot.runLease.projectId, snapshot.runLease.linearIssueId);
23
+ return;
24
+ }
25
+ if (decision.outcome === "continue") {
26
+ if (targetThreadId) {
27
+ await this.callbacks.deliverPendingObligations(snapshot.runLease.projectId, snapshot.runLease.linearIssueId, targetThreadId, targetTurnId);
28
+ }
29
+ return;
30
+ }
31
+ const completedAction = decision.actions.find((action) => action.type === "mark_run_completed");
32
+ if (decision.outcome === "complete" || (decision.outcome === "release" && completedAction?.type === "mark_run_completed")) {
33
+ const liveThread = snapshot.input.live?.codex?.status === "found" ? snapshot.input.live.codex.thread : undefined;
34
+ if (!liveThread) {
35
+ return;
36
+ }
37
+ const latestTurn = liveThread.turns.at(-1);
38
+ this.callbacks.completeRun(snapshot.runLease.projectId, snapshot.runLease.linearIssueId, liveThread, {
39
+ threadId: liveThread.id,
40
+ ...(latestTurn?.id ? { turnId: latestTurn.id } : {}),
41
+ ...(nextLifecycleStatus ? { nextLifecycleStatus } : {}),
42
+ });
43
+ return;
44
+ }
45
+ if (decision.outcome === "fail" || decision.outcome === "release") {
46
+ const failedAction = decision.actions.find((action) => action.type === "mark_run_failed");
47
+ if (decision.outcome === "release" && failedAction?.type !== "mark_run_failed") {
48
+ return;
49
+ }
50
+ await this.callbacks.failRunDuringReconciliation(snapshot.runLease.projectId, snapshot.runLease.linearIssueId, failedAction?.type === "mark_run_failed" && failedAction.threadId
51
+ ? failedAction.threadId
52
+ : threadId ?? `missing-thread-${snapshot.runLease.id}`, decision.reasons[0] ?? "Thread was not found during startup reconciliation", ...(failedAction?.type === "mark_run_failed" && failedAction.turnId ? [{ turnId: failedAction.turnId }] : []));
53
+ }
54
+ }
55
+ }
@@ -0,0 +1 @@
1
+ export {};