patchrelay 0.1.0 → 0.3.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.
@@ -0,0 +1,33 @@
1
+ import { isoNow } from "./shared.js";
2
+ export class RunReportStore {
3
+ connection;
4
+ constructor(connection) {
5
+ this.connection = connection;
6
+ }
7
+ saveRunReport(params) {
8
+ const now = isoNow();
9
+ this.connection
10
+ .prepare(`
11
+ INSERT INTO run_reports (run_lease_id, summary_json, report_json, created_at, updated_at)
12
+ VALUES (?, ?, ?, ?, ?)
13
+ ON CONFLICT(run_lease_id) DO UPDATE SET
14
+ summary_json = excluded.summary_json,
15
+ report_json = excluded.report_json,
16
+ updated_at = excluded.updated_at
17
+ `)
18
+ .run(params.runLeaseId, params.summaryJson ?? null, params.reportJson ?? null, now, now);
19
+ }
20
+ getRunReport(runLeaseId) {
21
+ const row = this.connection.prepare("SELECT * FROM run_reports WHERE run_lease_id = ?").get(runLeaseId);
22
+ return row ? mapRunReport(row) : undefined;
23
+ }
24
+ }
25
+ function mapRunReport(row) {
26
+ return {
27
+ runLeaseId: Number(row.run_lease_id),
28
+ ...(row.summary_json === null ? {} : { summaryJson: String(row.summary_json) }),
29
+ ...(row.report_json === null ? {} : { reportJson: String(row.report_json) }),
30
+ createdAt: String(row.created_at),
31
+ updatedAt: String(row.updated_at),
32
+ };
33
+ }
package/dist/db.js CHANGED
@@ -1,7 +1,10 @@
1
1
  import { AuthoritativeLedgerStore } from "./db/authoritative-ledger-store.js";
2
+ import { IssueProjectionStore } from "./db/issue-projection-store.js";
3
+ import { IssueWorkflowCoordinator } from "./db/issue-workflow-coordinator.js";
2
4
  import { IssueWorkflowStore } from "./db/issue-workflow-store.js";
3
5
  import { LinearInstallationStore } from "./db/linear-installation-store.js";
4
6
  import { runPatchRelayMigrations } from "./db/migrations.js";
7
+ import { RunReportStore } from "./db/run-report-store.js";
5
8
  import { StageEventStore } from "./db/stage-event-store.js";
6
9
  import { SqliteConnection } from "./db/shared.js";
7
10
  import { WebhookEventStore } from "./db/webhook-event-store.js";
@@ -14,7 +17,10 @@ export class PatchRelayDatabase {
14
17
  runLeases;
15
18
  obligations;
16
19
  webhookEvents;
20
+ issueProjections;
17
21
  issueWorkflows;
22
+ workflowCoordinator;
23
+ runReports;
18
24
  stageEvents;
19
25
  linearInstallations;
20
26
  constructor(databasePath, wal) {
@@ -30,7 +36,20 @@ export class PatchRelayDatabase {
30
36
  this.runLeases = this.authoritativeLedger;
31
37
  this.obligations = this.authoritativeLedger;
32
38
  this.webhookEvents = new WebhookEventStore(this.connection);
33
- this.issueWorkflows = new IssueWorkflowStore(this.connection);
39
+ this.issueProjections = new IssueProjectionStore(this.connection);
40
+ this.runReports = new RunReportStore(this.connection);
41
+ this.issueWorkflows = new IssueWorkflowStore({
42
+ authoritativeLedger: this.authoritativeLedger,
43
+ issueProjections: this.issueProjections,
44
+ runReports: this.runReports,
45
+ });
46
+ this.workflowCoordinator = new IssueWorkflowCoordinator({
47
+ connection: this.connection,
48
+ authoritativeLedger: this.authoritativeLedger,
49
+ issueProjections: this.issueProjections,
50
+ issueWorkflows: this.issueWorkflows,
51
+ runReports: this.runReports,
52
+ });
34
53
  this.stageEvents = new StageEventStore(this.connection);
35
54
  this.linearInstallations = new LinearInstallationStore(this.connection);
36
55
  }
package/dist/index.js CHANGED
@@ -42,10 +42,19 @@ async function main() {
42
42
  const service = new PatchRelayService(config, db, codex, linearProvider, logger);
43
43
  await service.start();
44
44
  const app = await buildHttpServer(config, service, logger);
45
- await app.listen({
46
- host: config.server.bind,
47
- port: config.server.port,
48
- });
45
+ try {
46
+ await app.listen({
47
+ host: config.server.bind,
48
+ port: config.server.port,
49
+ });
50
+ }
51
+ catch (error) {
52
+ if (error && typeof error === "object" && "code" in error && error.code === "EADDRINUSE") {
53
+ throw new Error(`Port ${config.server.port} on ${config.server.bind} is already in use. ` +
54
+ `Another patchrelay process may be running. Check with: ss -tlnp | grep ${config.server.port}`, { cause: error });
55
+ }
56
+ throw error;
57
+ }
49
58
  logger.info({
50
59
  bind: config.server.bind,
51
60
  port: config.server.port,
package/dist/install.js CHANGED
@@ -193,11 +193,12 @@ export async function upsertProjectInConfig(options) {
193
193
  const original = await readFile(configPath, "utf8");
194
194
  const parsed = parseConfigObject(original, configPath);
195
195
  const existingProjects = Array.isArray(parsed.projects) ? parsed.projects : [];
196
- const existingIndex = existingProjects.findIndex((project) => String(project.id ?? "") === projectId);
196
+ const existingIndex = existingProjects.findIndex((project) => String(project.id ?? "").toLowerCase() === projectId.toLowerCase());
197
197
  const existingProject = existingIndex >= 0 ? existingProjects[existingIndex] : undefined;
198
+ const resolvedProjectId = existingProject ? String(existingProject.id ?? projectId) : projectId;
198
199
  const nextProject = {
199
200
  ...(existingProject ?? {}),
200
- id: projectId,
201
+ id: resolvedProjectId,
201
202
  repo_path: repoPath,
202
203
  workflows: Array.isArray(existingProject?.workflows) && existingProject.workflows.length > 0
203
204
  ? existingProject.workflows
@@ -293,7 +294,7 @@ export async function upsertProjectInConfig(options) {
293
294
  configPath,
294
295
  status,
295
296
  project: {
296
- id: projectId,
297
+ id: resolvedProjectId,
297
298
  repoPath,
298
299
  issueKeyPrefixes,
299
300
  linearTeamIds,
@@ -20,19 +20,20 @@ export async function exchangeLinearOAuthCode(config, params) {
20
20
  const response = await fetch(DEFAULT_LINEAR_TOKEN_URL, {
21
21
  method: "POST",
22
22
  headers: {
23
- "content-type": "application/json",
23
+ "content-type": "application/x-www-form-urlencoded",
24
24
  },
25
- body: JSON.stringify({
25
+ body: new URLSearchParams({
26
26
  grant_type: "authorization_code",
27
27
  code: params.code,
28
28
  client_id: config.linear.oauth.clientId,
29
29
  client_secret: config.linear.oauth.clientSecret,
30
30
  redirect_uri: params.redirectUri,
31
- }),
31
+ }).toString(),
32
32
  });
33
33
  const payload = (await response.json().catch(() => undefined));
34
34
  if (!response.ok || !payload) {
35
- throw new Error(`Linear OAuth code exchange failed with HTTP ${response.status}`);
35
+ const detail = payload?.error ? `: ${String(payload.error)}` : "";
36
+ throw new Error(`Linear OAuth code exchange failed with HTTP ${response.status}${detail}`);
36
37
  }
37
38
  const accessToken = typeof payload.access_token === "string" ? payload.access_token : undefined;
38
39
  if (!accessToken) {
@@ -53,14 +54,14 @@ export async function refreshLinearOAuthToken(config, refreshToken) {
53
54
  const response = await fetch(DEFAULT_LINEAR_TOKEN_URL, {
54
55
  method: "POST",
55
56
  headers: {
56
- "content-type": "application/json",
57
+ "content-type": "application/x-www-form-urlencoded",
57
58
  },
58
- body: JSON.stringify({
59
+ body: new URLSearchParams({
59
60
  grant_type: "refresh_token",
60
61
  refresh_token: refreshToken,
61
62
  client_id: config.linear.oauth.clientId,
62
63
  client_secret: config.linear.oauth.clientSecret,
63
- }),
64
+ }).toString(),
64
65
  });
65
66
  const payload = (await response.json().catch(() => undefined));
66
67
  if (!response.ok || !payload) {
@@ -5,7 +5,6 @@ import { syncFailedStageToLinear } from "./stage-failure.js";
5
5
  import { buildFailedStageReport, buildPendingMaterializationThread, buildStageReport, countEventMethods, extractStageSummary, extractTurnId, resolveStageRunStatus, summarizeCurrentThread, } from "./stage-reporting.js";
6
6
  import { StageLifecyclePublisher } from "./stage-lifecycle-publisher.js";
7
7
  import { StageTurnInputDispatcher } from "./stage-turn-input-dispatcher.js";
8
- import { safeJsonParse } from "./utils.js";
9
8
  export class ServiceStageFinalizer {
10
9
  config;
11
10
  stores;
@@ -115,7 +114,7 @@ export class ServiceStageFinalizer {
115
114
  ...(params.turnId ? { turnId: params.turnId } : {}),
116
115
  nextLifecycleStatus: params.nextLifecycleStatus ?? (issue.desiredStage ? "queued" : "completed"),
117
116
  });
118
- this.stores.issueWorkflows.finishStageRun({
117
+ this.stores.workflowCoordinator.finishStageRun({
119
118
  stageRunId: stageRun.id,
120
119
  status,
121
120
  threadId: params.threadId,
@@ -134,7 +133,7 @@ export class ServiceStageFinalizer {
134
133
  failureReason: message,
135
134
  nextLifecycleStatus: "failed",
136
135
  });
137
- this.stores.issueWorkflows.finishStageRun({
136
+ this.stores.workflowCoordinator.finishStageRun({
138
137
  stageRunId: stageRun.id,
139
138
  status: "failed",
140
139
  threadId,
@@ -45,7 +45,7 @@ export class ServiceStageRunner {
45
45
  return;
46
46
  }
47
47
  const plan = buildStageLaunchPlan(project, issue, desiredStage);
48
- const claim = this.stores.issueWorkflows.claimStageRun({
48
+ const claim = this.stores.workflowCoordinator.claimStageRun({
49
49
  projectId: item.projectId,
50
50
  linearIssueId: item.issueId,
51
51
  stage: desiredStage,
@@ -83,7 +83,7 @@ export class ServiceStageRunner {
83
83
  }, "Failed to launch Codex stage run");
84
84
  throw err;
85
85
  }
86
- this.stores.issueWorkflows.updateStageRunThread({
86
+ this.stores.workflowCoordinator.updateStageRunThread({
87
87
  stageRunId: claim.stageRun.id,
88
88
  threadId: threadLaunch.threadId,
89
89
  ...(threadLaunch.parentThreadId ? { parentThreadId: threadLaunch.parentThreadId } : {}),
@@ -121,7 +121,7 @@ export class ServiceStageRunner {
121
121
  .forProject(project.id)
122
122
  .then((linear) => linear?.getIssue(linearIssueId))
123
123
  .catch(() => undefined);
124
- return this.stores.issueWorkflows.recordDesiredStage({
124
+ return this.stores.workflowCoordinator.recordDesiredStage({
125
125
  projectId: project.id,
126
126
  linearIssueId,
127
127
  ...(liveIssue?.identifier ? { issueKey: liveIssue.identifier } : existing?.issueKey ? { issueKey: existing.issueKey } : {}),
@@ -168,7 +168,7 @@ export class ServiceStageRunner {
168
168
  async markLaunchFailed(project, issue, stageRun, message, threadId) {
169
169
  const failureThreadId = threadId ?? `launch-failed-${stageRun.id}`;
170
170
  this.runAtomically(() => {
171
- this.stores.issueWorkflows.finishStageRun({
171
+ this.stores.workflowCoordinator.finishStageRun({
172
172
  stageRunId: stageRun.id,
173
173
  status: "failed",
174
174
  threadId: failureThreadId,
package/dist/service.js CHANGED
@@ -13,6 +13,7 @@ function createServiceStores(db) {
13
13
  workspaceOwnership: db.workspaceOwnership,
14
14
  runLeases: db.runLeases,
15
15
  obligations: db.obligations,
16
+ workflowCoordinator: db.workflowCoordinator,
16
17
  issueWorkflows: db.issueWorkflows,
17
18
  stageEvents: db.stageEvents,
18
19
  linearInstallations: db.linearInstallations,
@@ -39,21 +39,14 @@ export async function syncFailedStageToLinear(params) {
39
39
  }
40
40
  if (fallbackState) {
41
41
  await linear.setIssueState(params.stageRun.linearIssueId, fallbackState).catch(() => undefined);
42
- params.stores.issueWorkflows.setIssueLifecycleStatus(params.stageRun.projectId, params.stageRun.linearIssueId, "failed");
43
- params.stores.issueWorkflows.upsertTrackedIssue({
42
+ params.stores.workflowCoordinator.upsertTrackedIssue({
44
43
  projectId: params.stageRun.projectId,
45
44
  linearIssueId: params.stageRun.linearIssueId,
46
45
  currentLinearState: fallbackState,
47
46
  statusCommentId: params.issue.statusCommentId ?? null,
47
+ activeAgentSessionId: params.issue.activeAgentSessionId ?? null,
48
48
  lifecycleStatus: "failed",
49
49
  });
50
- params.stores.issueControl.upsertIssueControl({
51
- projectId: params.stageRun.projectId,
52
- linearIssueId: params.stageRun.linearIssueId,
53
- lifecycleStatus: "failed",
54
- ...(params.issue.statusCommentId ? { serviceOwnedCommentId: params.issue.statusCommentId } : {}),
55
- ...(params.issue.activeAgentSessionId ? { activeAgentSessionId: params.issue.activeAgentSessionId } : {}),
56
- });
57
50
  }
58
51
  const result = await linear
59
52
  .upsertIssueComment({
@@ -69,14 +62,7 @@ export async function syncFailedStageToLinear(params) {
69
62
  })
70
63
  .catch(() => undefined);
71
64
  if (result) {
72
- params.stores.issueWorkflows.setIssueStatusComment(params.stageRun.projectId, params.stageRun.linearIssueId, result.id);
73
- params.stores.issueControl.upsertIssueControl({
74
- projectId: params.stageRun.projectId,
75
- linearIssueId: params.stageRun.linearIssueId,
76
- serviceOwnedCommentId: result.id,
77
- lifecycleStatus: "failed",
78
- ...(params.issue.activeAgentSessionId ? { activeAgentSessionId: params.issue.activeAgentSessionId } : {}),
79
- });
65
+ params.stores.workflowCoordinator.setIssueStatusComment(params.stageRun.projectId, params.stageRun.linearIssueId, result.id);
80
66
  }
81
67
  if (params.issue.activeAgentSessionId) {
82
68
  await linear
@@ -26,18 +26,12 @@ export class StageLifecyclePublisher {
26
26
  ...(labels.remove.length > 0 ? { removeNames: labels.remove } : {}),
27
27
  });
28
28
  }
29
- this.stores.issueWorkflows.upsertTrackedIssue({
29
+ this.stores.workflowCoordinator.upsertTrackedIssue({
30
30
  projectId: stageRun.projectId,
31
31
  linearIssueId: stageRun.linearIssueId,
32
32
  currentLinearState: activeState,
33
33
  statusCommentId: issue.statusCommentId ?? null,
34
- lifecycleStatus: "running",
35
- });
36
- this.stores.issueControl.upsertIssueControl({
37
- projectId: stageRun.projectId,
38
- linearIssueId: stageRun.linearIssueId,
39
- ...(issue.statusCommentId ? { serviceOwnedCommentId: issue.statusCommentId } : {}),
40
- ...(issue.activeAgentSessionId ? { activeAgentSessionId: issue.activeAgentSessionId } : {}),
34
+ activeAgentSessionId: issue.activeAgentSessionId ?? null,
41
35
  lifecycleStatus: "running",
42
36
  });
43
37
  }
@@ -62,13 +56,7 @@ export class StageLifecyclePublisher {
62
56
  branchName: workspace.branchName,
63
57
  }),
64
58
  });
65
- this.stores.issueWorkflows.setIssueStatusComment(projectId, issueId, result.id);
66
- this.stores.issueControl.upsertIssueControl({
67
- projectId,
68
- linearIssueId: issueId,
69
- serviceOwnedCommentId: result.id,
70
- lifecycleStatus: issue.lifecycleStatus,
71
- });
59
+ this.stores.workflowCoordinator.setIssueStatusComment(projectId, issueId, result.id);
72
60
  }
73
61
  catch (error) {
74
62
  this.logger.warn({
@@ -133,12 +121,7 @@ export class StageLifecyclePublisher {
133
121
  ...(labels.remove.length > 0 ? { removeNames: labels.remove } : {}),
134
122
  });
135
123
  }
136
- this.stores.issueWorkflows.setIssueLifecycleStatus(stageRun.projectId, stageRun.linearIssueId, "paused");
137
- this.stores.issueControl.upsertIssueControl({
138
- projectId: stageRun.projectId,
139
- linearIssueId: stageRun.linearIssueId,
140
- lifecycleStatus: "paused",
141
- });
124
+ this.stores.workflowCoordinator.setIssueLifecycleStatus(stageRun.projectId, stageRun.linearIssueId, "paused");
142
125
  const finalStageRun = this.stores.issueWorkflows.getStageRun(stageRun.id) ?? stageRun;
143
126
  const result = await linear.upsertIssueComment({
144
127
  issueId: stageRun.linearIssueId,
@@ -149,13 +132,7 @@ export class StageLifecyclePublisher {
149
132
  activeState,
150
133
  }),
151
134
  });
152
- this.stores.issueWorkflows.setIssueStatusComment(stageRun.projectId, stageRun.linearIssueId, result.id);
153
- this.stores.issueControl.upsertIssueControl({
154
- projectId: stageRun.projectId,
155
- linearIssueId: stageRun.linearIssueId,
156
- serviceOwnedCommentId: result.id,
157
- lifecycleStatus: "paused",
158
- });
135
+ this.stores.workflowCoordinator.setIssueStatusComment(stageRun.projectId, stageRun.linearIssueId, result.id);
159
136
  await this.publishAgentCompletion(refreshedIssue, {
160
137
  type: "elicitation",
161
138
  body: `PatchRelay finished the ${stageRun.stage} workflow. Move the issue to its next workflow state or leave a follow-up prompt to continue.`,
@@ -27,19 +27,18 @@ export class WebhookDesiredStageRecorder {
27
27
  const stageAllowed = triggerEventAllowed(project, normalized.triggerEvent);
28
28
  const desiredStage = this.resolveDesiredStage(project, normalized, issue, activeStageRun, delegatedToPatchRelay);
29
29
  const launchInput = this.resolveLaunchInput(normalized.agentSession);
30
- this.persistIssueControlFirst(project.id, normalizedIssue.id, issue, activeStageRun, desiredStage, normalized.agentSession?.id, options?.eventReceiptId);
31
- this.stores.issueWorkflows.recordDesiredStage({
30
+ const refreshedIssue = this.stores.workflowCoordinator.recordDesiredStage({
32
31
  projectId: project.id,
33
32
  linearIssueId: normalizedIssue.id,
34
33
  ...(normalizedIssue.identifier ? { issueKey: normalizedIssue.identifier } : {}),
35
34
  ...(normalizedIssue.title ? { title: normalizedIssue.title } : {}),
36
35
  ...(normalizedIssue.url ? { issueUrl: normalizedIssue.url } : {}),
37
36
  ...(normalizedIssue.stateName ? { currentLinearState: normalizedIssue.stateName } : {}),
37
+ ...(desiredStage ? { desiredStage } : {}),
38
+ ...(options?.eventReceiptId !== undefined ? { desiredReceiptId: options.eventReceiptId } : {}),
39
+ ...(normalized.agentSession?.id ? { activeAgentSessionId: normalized.agentSession.id } : {}),
38
40
  lastWebhookAt: new Date().toISOString(),
39
41
  });
40
- if (normalized.agentSession?.id) {
41
- this.stores.issueWorkflows.setIssueActiveAgentSession(project.id, normalizedIssue.id, normalized.agentSession.id);
42
- }
43
42
  if (launchInput && !activeStageRun && delegatedToPatchRelay && stageAllowed) {
44
43
  this.stores.obligations.enqueueObligation({
45
44
  projectId: project.id,
@@ -51,8 +50,6 @@ export class WebhookDesiredStageRecorder {
51
50
  }),
52
51
  });
53
52
  }
54
- const refreshedIssue = this.stores.issueWorkflows.getTrackedIssue(project.id, normalizedIssue.id);
55
- this.syncIssueControl(project.id, normalizedIssue.id, refreshedIssue, desiredStage, normalized.agentSession?.id, options?.eventReceiptId);
56
53
  return {
57
54
  issue: refreshedIssue ?? issue,
58
55
  activeStageRun,
@@ -119,32 +116,4 @@ export class WebhookDesiredStageRecorder {
119
116
  }
120
117
  return undefined;
121
118
  }
122
- persistIssueControlFirst(projectId, linearIssueId, issue, activeStageRun, desiredStage, activeAgentSessionId, eventReceiptId) {
123
- if (!desiredStage) {
124
- return;
125
- }
126
- const lifecycleStatus = issue?.lifecycleStatus ?? "queued";
127
- this.stores.issueControl.upsertIssueControl({
128
- projectId,
129
- linearIssueId,
130
- desiredStage,
131
- ...(eventReceiptId !== undefined ? { desiredReceiptId: eventReceiptId } : {}),
132
- ...(issue?.statusCommentId ? { serviceOwnedCommentId: issue.statusCommentId } : {}),
133
- ...(activeAgentSessionId ? { activeAgentSessionId } : {}),
134
- lifecycleStatus,
135
- });
136
- }
137
- syncIssueControl(projectId, linearIssueId, issue, desiredStage, activeAgentSessionId, eventReceiptId) {
138
- if (!issue) {
139
- return;
140
- }
141
- this.stores.issueControl.upsertIssueControl({
142
- projectId,
143
- linearIssueId,
144
- ...(desiredStage ? { desiredStage } : {}),
145
- ...(eventReceiptId !== undefined && desiredStage ? { desiredReceiptId: eventReceiptId } : {}),
146
- ...(activeAgentSessionId ? { activeAgentSessionId } : {}),
147
- lifecycleStatus: issue.lifecycleStatus,
148
- });
149
- }
150
119
  }
@@ -6,6 +6,8 @@ Unit=patchrelay-reload.service
6
6
  PathChanged=/home/your-user/.config/patchrelay/runtime.env
7
7
  PathChanged=/home/your-user/.config/patchrelay/service.env
8
8
  PathChanged=/home/your-user/.config/patchrelay/patchrelay.json
9
+ TriggerLimitIntervalSec=5
10
+ TriggerLimitBurst=1
9
11
 
10
12
  [Install]
11
13
  WantedBy=default.target
@@ -2,6 +2,8 @@
2
2
  Description=PatchRelay (systemd user service)
3
3
  After=network-online.target
4
4
  Wants=network-online.target
5
+ StartLimitIntervalSec=60
6
+ StartLimitBurst=8
5
7
 
6
8
  [Service]
7
9
  Type=simple
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {