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,312 @@
1
+ export class ReconciliationEngine {
2
+ reconcile(input) {
3
+ const actions = [];
4
+ const issue = input.issue;
5
+ const policy = input.policy ?? {};
6
+ const liveLinear = input.live?.linear ?? { status: "unknown" };
7
+ const liveCodex = input.live?.codex ?? { status: "unknown" };
8
+ const obligations = relevantObligations(issue, input.obligations ?? []);
9
+ if (!issue.activeRun) {
10
+ if (!issue.desiredStage) {
11
+ return {
12
+ outcome: "noop",
13
+ reasons: ["issue has no active run and no desired stage"],
14
+ actions,
15
+ };
16
+ }
17
+ return {
18
+ outcome: "launch",
19
+ reasons: [`desired stage ${issue.desiredStage} is ready to launch`],
20
+ actions: [
21
+ {
22
+ type: "launch_desired_stage",
23
+ projectId: issue.projectId,
24
+ linearIssueId: issue.linearIssueId,
25
+ stage: issue.desiredStage,
26
+ reason: "desired stage exists without an active run",
27
+ },
28
+ ],
29
+ };
30
+ }
31
+ if (needsLinearState(issue, policy, liveLinear)) {
32
+ actions.push({
33
+ type: "read_linear_issue",
34
+ projectId: issue.projectId,
35
+ linearIssueId: issue.linearIssueId,
36
+ reason: "active reconciliation needs the live Linear state",
37
+ });
38
+ }
39
+ if (needsCodexState(issue.activeRun, liveCodex)) {
40
+ actions.push({
41
+ type: "read_codex_thread",
42
+ projectId: issue.projectId,
43
+ linearIssueId: issue.linearIssueId,
44
+ runId: issue.activeRun.id,
45
+ threadId: issue.activeRun.threadId,
46
+ reason: "active reconciliation needs the live Codex thread",
47
+ });
48
+ }
49
+ if (actions.length > 0) {
50
+ return {
51
+ outcome: "hydrate_live_state",
52
+ reasons: ["reconciliation needs fresh live state before deciding"],
53
+ actions,
54
+ };
55
+ }
56
+ return reconcileActiveRun({
57
+ issue,
58
+ liveLinear,
59
+ liveCodex,
60
+ obligations,
61
+ policy,
62
+ });
63
+ }
64
+ }
65
+ export function reconcileIssue(input) {
66
+ return new ReconciliationEngine().reconcile(input);
67
+ }
68
+ function reconcileActiveRun(params) {
69
+ const { issue, liveLinear, liveCodex, obligations, policy } = params;
70
+ const run = issue.activeRun;
71
+ if (run.status === "queued") {
72
+ return {
73
+ outcome: "launch",
74
+ reasons: ["queued run has not been materialized yet"],
75
+ actions: [
76
+ {
77
+ type: "launch_desired_stage",
78
+ projectId: issue.projectId,
79
+ linearIssueId: issue.linearIssueId,
80
+ stage: run.stage,
81
+ runId: run.id,
82
+ reason: "active run is queued and should be launched",
83
+ },
84
+ ],
85
+ };
86
+ }
87
+ if (!run.threadId) {
88
+ return failRun(issue, run, liveLinear, policy, "active run is missing a persisted thread id");
89
+ }
90
+ if (liveCodex.status === "error") {
91
+ return {
92
+ outcome: "continue",
93
+ reasons: [liveCodex.errorMessage ?? "codex thread lookup failed"],
94
+ actions: [
95
+ {
96
+ type: "await_codex_retry",
97
+ projectId: issue.projectId,
98
+ linearIssueId: issue.linearIssueId,
99
+ runId: run.id,
100
+ reason: liveCodex.errorMessage ?? "codex thread lookup failed",
101
+ },
102
+ ],
103
+ };
104
+ }
105
+ if (liveCodex.status === "missing" || !liveCodex.thread) {
106
+ return failRun(issue, run, liveLinear, policy, "thread was not found during reconciliation");
107
+ }
108
+ const latestTurn = latestThreadTurn(liveCodex.thread);
109
+ const targetTurnId = latestTurn?.id ?? run.turnId;
110
+ if (!latestTurn || latestTurn.status === "inProgress") {
111
+ const actions = routePendingObligations(issue, run, obligations, liveCodex.thread.id, targetTurnId);
112
+ actions.push({
113
+ type: "keep_run_active",
114
+ projectId: issue.projectId,
115
+ linearIssueId: issue.linearIssueId,
116
+ runId: run.id,
117
+ reason: !latestTurn ? "thread has not produced a turn yet" : "latest turn is still in progress",
118
+ });
119
+ return {
120
+ outcome: "continue",
121
+ reasons: [!latestTurn ? "thread has no completed turns yet" : "latest turn is still in progress"],
122
+ actions,
123
+ };
124
+ }
125
+ if (latestTurn.status !== "completed") {
126
+ return failRun(issue, run, liveLinear, policy, "thread completed reconciliation in a failed state", latestTurn.id);
127
+ }
128
+ const actions = [
129
+ {
130
+ type: "mark_run_completed",
131
+ projectId: issue.projectId,
132
+ linearIssueId: issue.linearIssueId,
133
+ runId: run.id,
134
+ threadId: liveCodex.thread.id,
135
+ ...(latestTurn.id ? { turnId: latestTurn.id } : {}),
136
+ reason: "latest turn completed successfully during reconciliation",
137
+ },
138
+ ];
139
+ if (shouldAwaitHandoff(liveLinear, policy)) {
140
+ actions.push({
141
+ type: "clear_active_run",
142
+ projectId: issue.projectId,
143
+ linearIssueId: issue.linearIssueId,
144
+ runId: run.id,
145
+ nextLifecycleStatus: "paused",
146
+ reason: "stage completed while the issue still matches the service-owned active Linear state",
147
+ }, {
148
+ type: "refresh_status_comment",
149
+ projectId: issue.projectId,
150
+ linearIssueId: issue.linearIssueId,
151
+ runId: run.id,
152
+ ...(issue.statusCommentId ? { commentId: issue.statusCommentId } : {}),
153
+ mode: "awaiting_handoff",
154
+ reason: "stage completed and should publish an awaiting handoff status",
155
+ });
156
+ return {
157
+ outcome: "complete",
158
+ reasons: ["stage completed and should pause for human handoff"],
159
+ actions,
160
+ };
161
+ }
162
+ actions.push({
163
+ type: "release_issue_ownership",
164
+ projectId: issue.projectId,
165
+ linearIssueId: issue.linearIssueId,
166
+ runId: run.id,
167
+ nextLifecycleStatus: "completed",
168
+ reason: "stage completed after the live Linear state moved on",
169
+ });
170
+ return {
171
+ outcome: "release",
172
+ reasons: ["stage completed and the live Linear state already moved on"],
173
+ actions,
174
+ };
175
+ }
176
+ function failRun(issue, run, liveLinear, policy, message, turnId) {
177
+ const actions = [
178
+ {
179
+ type: "mark_run_failed",
180
+ projectId: issue.projectId,
181
+ linearIssueId: issue.linearIssueId,
182
+ runId: run.id,
183
+ ...(run.threadId ? { threadId: run.threadId } : {}),
184
+ ...(turnId ? { turnId } : run.turnId ? { turnId: run.turnId } : {}),
185
+ reason: message,
186
+ },
187
+ ];
188
+ if (shouldFailBack(liveLinear, policy)) {
189
+ actions.push({
190
+ type: "sync_linear_failure",
191
+ projectId: issue.projectId,
192
+ linearIssueId: issue.linearIssueId,
193
+ runId: run.id,
194
+ ...(policy.activeLinearStateName ? { expectedStateName: policy.activeLinearStateName } : {}),
195
+ ...(policy.fallbackLinearStateName ? { fallbackStateName: policy.fallbackLinearStateName } : {}),
196
+ message,
197
+ }, {
198
+ type: "clear_active_run",
199
+ projectId: issue.projectId,
200
+ linearIssueId: issue.linearIssueId,
201
+ runId: run.id,
202
+ nextLifecycleStatus: "failed",
203
+ reason: "run failed while PatchRelay still owned the expected active Linear state",
204
+ });
205
+ if (issue.statusCommentId) {
206
+ actions.push({
207
+ type: "refresh_status_comment",
208
+ projectId: issue.projectId,
209
+ linearIssueId: issue.linearIssueId,
210
+ runId: run.id,
211
+ commentId: issue.statusCommentId,
212
+ mode: "failed",
213
+ reason: "run failed and should refresh the service-owned status comment",
214
+ });
215
+ }
216
+ return {
217
+ outcome: "fail",
218
+ reasons: [message],
219
+ actions,
220
+ };
221
+ }
222
+ actions.push({
223
+ type: "release_issue_ownership",
224
+ projectId: issue.projectId,
225
+ linearIssueId: issue.linearIssueId,
226
+ runId: run.id,
227
+ nextLifecycleStatus: "failed",
228
+ reason: "run failed after the live Linear state moved on",
229
+ });
230
+ return {
231
+ outcome: "release",
232
+ reasons: [message, "live Linear state no longer matches the expected service-owned active state"],
233
+ actions,
234
+ };
235
+ }
236
+ function routePendingObligations(issue, run, obligations, threadId, turnId) {
237
+ if (!turnId) {
238
+ return [];
239
+ }
240
+ const actions = [];
241
+ for (const obligation of obligations) {
242
+ const needsRouting = obligation.threadId !== threadId || obligation.turnId !== turnId;
243
+ if (needsRouting) {
244
+ actions.push({
245
+ type: "route_obligation",
246
+ projectId: issue.projectId,
247
+ linearIssueId: issue.linearIssueId,
248
+ obligationId: obligation.id,
249
+ runId: run.id,
250
+ threadId,
251
+ turnId,
252
+ reason: "pending obligation should target the latest live turn",
253
+ });
254
+ }
255
+ actions.push({
256
+ type: "deliver_obligation",
257
+ projectId: issue.projectId,
258
+ linearIssueId: issue.linearIssueId,
259
+ obligationId: obligation.id,
260
+ runId: run.id,
261
+ threadId,
262
+ turnId,
263
+ reason: "pending obligation can be delivered to the active turn",
264
+ });
265
+ }
266
+ return actions;
267
+ }
268
+ function needsLinearState(issue, policy, liveLinear) {
269
+ if (!issue.activeRun) {
270
+ return false;
271
+ }
272
+ if (!policy.activeLinearStateName && !policy.fallbackLinearStateName) {
273
+ return false;
274
+ }
275
+ return liveLinear.status === "unknown";
276
+ }
277
+ function needsCodexState(run, liveCodex) {
278
+ if (!run.threadId) {
279
+ return false;
280
+ }
281
+ return liveCodex.status === "unknown";
282
+ }
283
+ function shouldFailBack(liveLinear, policy) {
284
+ return matchesActiveLinearOwnership(liveLinear, policy);
285
+ }
286
+ function shouldAwaitHandoff(liveLinear, policy) {
287
+ return matchesActiveLinearOwnership(liveLinear, policy);
288
+ }
289
+ function matchesActiveLinearOwnership(liveLinear, policy) {
290
+ if (!policy.activeLinearStateName) {
291
+ return true;
292
+ }
293
+ if (liveLinear.status !== "known") {
294
+ return false;
295
+ }
296
+ return liveLinear.issue?.stateName === policy.activeLinearStateName;
297
+ }
298
+ function relevantObligations(issue, obligations) {
299
+ const activeRunId = issue.activeRun?.id;
300
+ return obligations.filter((obligation) => {
301
+ if (obligation.status === "completed" || obligation.status === "cancelled") {
302
+ return false;
303
+ }
304
+ if (activeRunId === undefined) {
305
+ return false;
306
+ }
307
+ return obligation.runId === undefined || obligation.runId === activeRunId;
308
+ });
309
+ }
310
+ function latestThreadTurn(thread) {
311
+ return thread.turns.at(-1);
312
+ }
@@ -0,0 +1,96 @@
1
+ import { safeJsonParse } from "./utils.js";
2
+ export async function buildReconciliationSnapshot(params) {
3
+ const runLease = params.stores.runLeases.getRunLease(params.runLeaseId);
4
+ if (!runLease) {
5
+ return undefined;
6
+ }
7
+ const issueControl = params.stores.issueControl.getIssueControl(runLease.projectId, runLease.linearIssueId);
8
+ if (!issueControl) {
9
+ return undefined;
10
+ }
11
+ const workspaceOwnership = params.stores.workspaceOwnership.getWorkspaceOwnership(runLease.workspaceOwnershipId);
12
+ const project = params.config.projects.find((candidate) => candidate.id === runLease.projectId);
13
+ const workflowConfig = project?.workflows.find((workflow) => workflow.id === runLease.stage);
14
+ const liveLinear = project
15
+ ? await params.linearProvider
16
+ .forProject(runLease.projectId)
17
+ .then((linear) => linear
18
+ ? linear.getIssue(runLease.linearIssueId).then((issue) => issue.stateName
19
+ ? {
20
+ status: "known",
21
+ issue: {
22
+ id: issue.id,
23
+ stateName: issue.stateName,
24
+ },
25
+ }
26
+ : ({ status: "unknown" }))
27
+ : ({ status: "unknown" }))
28
+ .catch(() => ({ status: "unknown" }))
29
+ : ({ status: "unknown" });
30
+ const liveCodex = runLease.threadId
31
+ ? await params.codex
32
+ .readThread(runLease.threadId, true)
33
+ .then((thread) => ({ status: "found", thread }))
34
+ .catch((error) => mapCodexReadFailure(error))
35
+ : ({ status: "unknown" });
36
+ const obligations = params.stores.obligations
37
+ .listPendingObligations({ runLeaseId: runLease.id, includeInProgress: true })
38
+ .map((obligation) => {
39
+ const payload = safeJsonParse(obligation.payloadJson);
40
+ return {
41
+ id: obligation.id,
42
+ kind: obligation.kind,
43
+ status: obligation.status,
44
+ ...(obligation.runLeaseId !== undefined ? { runId: obligation.runLeaseId } : {}),
45
+ ...(obligation.threadId ? { threadId: obligation.threadId } : {}),
46
+ ...(obligation.turnId ? { turnId: obligation.turnId } : {}),
47
+ ...(payload !== undefined ? { payload } : {}),
48
+ };
49
+ });
50
+ return {
51
+ issueControl,
52
+ runLease,
53
+ ...(workspaceOwnership ? { workspaceOwnership } : {}),
54
+ input: {
55
+ issue: {
56
+ projectId: runLease.projectId,
57
+ linearIssueId: runLease.linearIssueId,
58
+ ...(issueControl.desiredStage ? { desiredStage: issueControl.desiredStage } : {}),
59
+ lifecycleStatus: issueControl.lifecycleStatus,
60
+ ...(issueControl.serviceOwnedCommentId ? { statusCommentId: issueControl.serviceOwnedCommentId } : {}),
61
+ activeRun: {
62
+ id: runLease.id,
63
+ stage: runLease.stage,
64
+ status: runLease.status,
65
+ ...(runLease.threadId ? { threadId: runLease.threadId } : {}),
66
+ ...(runLease.turnId ? { turnId: runLease.turnId } : {}),
67
+ ...(runLease.parentThreadId ? { parentThreadId: runLease.parentThreadId } : {}),
68
+ },
69
+ },
70
+ ...(obligations.length > 0 ? { obligations } : {}),
71
+ ...(workflowConfig
72
+ ? {
73
+ policy: {
74
+ ...(workflowConfig.activeState ? { activeLinearStateName: workflowConfig.activeState } : {}),
75
+ ...(workflowConfig.fallbackState ? { fallbackLinearStateName: workflowConfig.fallbackState } : {}),
76
+ },
77
+ }
78
+ : {}),
79
+ live: {
80
+ linear: liveLinear,
81
+ codex: liveCodex,
82
+ },
83
+ },
84
+ };
85
+ }
86
+ function mapCodexReadFailure(error) {
87
+ const message = error instanceof Error ? error.message : String(error);
88
+ const normalized = message.trim().toLowerCase();
89
+ if (normalized.includes("not found") || normalized.includes("missing")) {
90
+ return { status: "missing" };
91
+ }
92
+ return {
93
+ status: "error",
94
+ errorMessage: message,
95
+ };
96
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,89 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { ensureAbsolutePath } from "./utils.js";
6
+ export function getPatchRelayPathLayout() {
7
+ const homeDir = homedir();
8
+ const xdgConfigHome = process.env.XDG_CONFIG_HOME ?? path.join(homeDir, ".config");
9
+ const xdgStateHome = process.env.XDG_STATE_HOME ?? path.join(homeDir, ".local", "state");
10
+ const xdgDataHome = process.env.XDG_DATA_HOME ?? path.join(homeDir, ".local", "share");
11
+ const configPath = ensureAbsolutePath(process.env.PATCHRELAY_CONFIG ?? path.join(xdgConfigHome, "patchrelay", "patchrelay.json"));
12
+ const configDir = path.dirname(configPath);
13
+ const runtimeEnvPath = path.join(configDir, "runtime.env");
14
+ const serviceEnvPath = path.join(configDir, "service.env");
15
+ const stateDir = path.join(xdgStateHome, "patchrelay");
16
+ const shareDir = path.join(xdgDataHome, "patchrelay");
17
+ const systemdUserDir = path.join(xdgConfigHome, "systemd", "user");
18
+ return {
19
+ homeDir,
20
+ configDir,
21
+ configPath,
22
+ runtimeEnvPath,
23
+ serviceEnvPath,
24
+ stateDir,
25
+ shareDir,
26
+ databasePath: ensureAbsolutePath(process.env.PATCHRELAY_DB_PATH ?? path.join(stateDir, "patchrelay.sqlite")),
27
+ logFilePath: ensureAbsolutePath(process.env.PATCHRELAY_LOG_FILE ?? path.join(stateDir, "patchrelay.log")),
28
+ systemdUserDir,
29
+ systemdUnitPath: path.join(systemdUserDir, "patchrelay.service"),
30
+ systemdReloadUnitPath: path.join(systemdUserDir, "patchrelay-reload.service"),
31
+ systemdPathUnitPath: path.join(systemdUserDir, "patchrelay.path"),
32
+ };
33
+ }
34
+ export function getPatchRelayConfigDir() {
35
+ return getPatchRelayPathLayout().configDir;
36
+ }
37
+ export function getDefaultConfigPath() {
38
+ return getPatchRelayPathLayout().configPath;
39
+ }
40
+ export function getDefaultRuntimeEnvPath() {
41
+ return getPatchRelayPathLayout().runtimeEnvPath;
42
+ }
43
+ export function getDefaultServiceEnvPath() {
44
+ return getPatchRelayPathLayout().serviceEnvPath;
45
+ }
46
+ export function getPatchRelayStateDir() {
47
+ return getPatchRelayPathLayout().stateDir;
48
+ }
49
+ export function getPatchRelayDataDir() {
50
+ return getPatchRelayPathLayout().shareDir;
51
+ }
52
+ export function getDefaultDatabasePath() {
53
+ return getPatchRelayPathLayout().databasePath;
54
+ }
55
+ export function getDefaultLogPath() {
56
+ return getPatchRelayPathLayout().logFilePath;
57
+ }
58
+ export function getDefaultWebhookArchiveDir() {
59
+ return path.join(getPatchRelayStateDir(), "webhooks");
60
+ }
61
+ export function getSystemdUserUnitPath() {
62
+ return getPatchRelayPathLayout().systemdUnitPath;
63
+ }
64
+ export function getSystemdUserReloadUnitPath() {
65
+ return getPatchRelayPathLayout().systemdReloadUnitPath;
66
+ }
67
+ export function getSystemdUserPathUnitPath() {
68
+ return getPatchRelayPathLayout().systemdPathUnitPath;
69
+ }
70
+ export function getPackageRoot() {
71
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
72
+ }
73
+ export function getBundledAssetPath(relativePath) {
74
+ return path.join(getPackageRoot(), relativePath);
75
+ }
76
+ export function readBundledAsset(relativePath) {
77
+ const assetPath = getBundledAssetPath(relativePath);
78
+ if (!existsSync(assetPath)) {
79
+ throw new Error(`Bundled asset not found: ${assetPath}`);
80
+ }
81
+ return readFileSync(assetPath, "utf8");
82
+ }
83
+ export function getBuiltCliEntryPath() {
84
+ const entryPath = getBundledAssetPath("dist/index.js");
85
+ if (!existsSync(entryPath)) {
86
+ throw new Error(`Built PatchRelay entrypoint not found: ${entryPath}. Run npm run build before installing the service.`);
87
+ }
88
+ return entryPath;
89
+ }
@@ -0,0 +1,49 @@
1
+ export class SerialWorkQueue {
2
+ onDequeue;
3
+ logger;
4
+ getKey;
5
+ items = [];
6
+ queuedKeys = new Set();
7
+ pending = false;
8
+ constructor(onDequeue, logger, getKey) {
9
+ this.onDequeue = onDequeue;
10
+ this.logger = logger;
11
+ this.getKey = getKey;
12
+ }
13
+ enqueue(item) {
14
+ const key = this.getKey?.(item);
15
+ if (key && this.queuedKeys.has(key)) {
16
+ return;
17
+ }
18
+ this.items.push(item);
19
+ if (key) {
20
+ this.queuedKeys.add(key);
21
+ }
22
+ if (!this.pending) {
23
+ this.pending = true;
24
+ queueMicrotask(() => {
25
+ void this.drain();
26
+ });
27
+ }
28
+ }
29
+ async drain() {
30
+ while (this.items.length > 0) {
31
+ const next = this.items.shift();
32
+ if (next === undefined) {
33
+ continue;
34
+ }
35
+ const key = this.getKey?.(next);
36
+ if (key) {
37
+ this.queuedKeys.delete(key);
38
+ }
39
+ try {
40
+ await this.onDequeue(next);
41
+ }
42
+ catch (error) {
43
+ const err = error instanceof Error ? error : new Error(String(error));
44
+ this.logger.error({ item: next, error: err.message, stack: err.stack }, "Queue item processing failed");
45
+ }
46
+ }
47
+ this.pending = false;
48
+ }
49
+ }
@@ -0,0 +1,96 @@
1
+ import { SerialWorkQueue } from "./service-queue.js";
2
+ const ISSUE_KEY_DELIMITER = "::";
3
+ function toReconciler(value) {
4
+ if (typeof value === "function") {
5
+ return {
6
+ reconcileActiveStageRuns: value,
7
+ };
8
+ }
9
+ return value;
10
+ }
11
+ function toReadyIssueSource(value) {
12
+ if (typeof value === "function") {
13
+ return {
14
+ listIssuesReadyForExecution: value,
15
+ };
16
+ }
17
+ return value;
18
+ }
19
+ function toWebhookProcessor(value) {
20
+ if (typeof value === "function") {
21
+ return {
22
+ processWebhookEvent: value,
23
+ };
24
+ }
25
+ return value;
26
+ }
27
+ function toIssueProcessor(value) {
28
+ if (typeof value === "function") {
29
+ return {
30
+ processIssue: value,
31
+ };
32
+ }
33
+ return value;
34
+ }
35
+ function makeIssueQueueKey(item) {
36
+ return `${item.projectId}${ISSUE_KEY_DELIMITER}${item.issueId}`;
37
+ }
38
+ // ServiceRuntime is the coordination seam for the harness. It is responsible for
39
+ // startup reconciliation, queue ownership, and handing eligible work to the stage runner.
40
+ export class ServiceRuntime {
41
+ codex;
42
+ logger;
43
+ webhookQueue;
44
+ issueQueue;
45
+ ready = false;
46
+ startupError;
47
+ constructor(codex, logger, stageRunReconciler, readyIssueSource, webhookProcessor, issueProcessor) {
48
+ this.codex = codex;
49
+ this.logger = logger;
50
+ this.stageRunReconciler = toReconciler(stageRunReconciler);
51
+ this.readyIssueSource = toReadyIssueSource(readyIssueSource);
52
+ this.webhookProcessor = toWebhookProcessor(webhookProcessor);
53
+ this.issueProcessor = toIssueProcessor(issueProcessor);
54
+ this.webhookQueue = new SerialWorkQueue((eventId) => this.webhookProcessor.processWebhookEvent(eventId), logger, (eventId) => String(eventId));
55
+ this.issueQueue = new SerialWorkQueue((item) => this.issueProcessor.processIssue(item), logger, makeIssueQueueKey);
56
+ }
57
+ stageRunReconciler;
58
+ readyIssueSource;
59
+ webhookProcessor;
60
+ issueProcessor;
61
+ async start() {
62
+ try {
63
+ await this.codex.start();
64
+ // Reconciliation happens before new work is enqueued so restart recovery can
65
+ // resolve or release any previously claimed work deterministically.
66
+ await this.stageRunReconciler.reconcileActiveStageRuns();
67
+ for (const issue of this.readyIssueSource.listIssuesReadyForExecution()) {
68
+ this.enqueueIssue(issue.projectId, issue.linearIssueId);
69
+ }
70
+ this.ready = true;
71
+ this.startupError = undefined;
72
+ }
73
+ catch (error) {
74
+ this.ready = false;
75
+ this.startupError = error instanceof Error ? error.message : String(error);
76
+ throw error;
77
+ }
78
+ }
79
+ stop() {
80
+ this.ready = false;
81
+ void this.codex.stop();
82
+ }
83
+ enqueueWebhookEvent(eventId) {
84
+ this.webhookQueue.enqueue(eventId);
85
+ }
86
+ enqueueIssue(projectId, issueId) {
87
+ this.issueQueue.enqueue({ projectId, issueId });
88
+ }
89
+ getReadiness() {
90
+ return {
91
+ ready: this.ready && this.codex.isStarted(),
92
+ codexStarted: this.codex.isStarted(),
93
+ ...(this.startupError ? { startupError: this.startupError } : {}),
94
+ };
95
+ }
96
+ }