patchrelay 0.8.8 → 0.9.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 (57) hide show
  1. package/README.md +64 -62
  2. package/dist/agent-session-plan.js +17 -17
  3. package/dist/build-info.json +3 -3
  4. package/dist/cli/commands/issues.js +12 -12
  5. package/dist/cli/data.js +109 -298
  6. package/dist/cli/formatters/text.js +22 -28
  7. package/dist/config.js +13 -166
  8. package/dist/db/migrations.js +46 -154
  9. package/dist/db.js +369 -45
  10. package/dist/factory-state.js +55 -0
  11. package/dist/github-webhook-handler.js +199 -0
  12. package/dist/github-webhooks.js +166 -0
  13. package/dist/hook-runner.js +28 -0
  14. package/dist/http.js +48 -22
  15. package/dist/issue-query-service.js +33 -38
  16. package/dist/linear-workflow.js +5 -118
  17. package/dist/preflight.js +1 -6
  18. package/dist/project-resolution.js +12 -1
  19. package/dist/run-orchestrator.js +446 -0
  20. package/dist/{stage-reporting.js → run-reporting.js} +11 -13
  21. package/dist/service-runtime.js +21 -54
  22. package/dist/service-webhooks.js +7 -52
  23. package/dist/service.js +39 -61
  24. package/dist/webhook-handler.js +387 -0
  25. package/dist/webhook-installation-handler.js +3 -8
  26. package/package.json +2 -1
  27. package/dist/db/authoritative-ledger-store.js +0 -536
  28. package/dist/db/issue-projection-store.js +0 -54
  29. package/dist/db/issue-workflow-coordinator.js +0 -320
  30. package/dist/db/issue-workflow-store.js +0 -194
  31. package/dist/db/run-report-store.js +0 -33
  32. package/dist/db/stage-event-store.js +0 -33
  33. package/dist/db/webhook-event-store.js +0 -59
  34. package/dist/db-ports.js +0 -5
  35. package/dist/ledger-ports.js +0 -1
  36. package/dist/reconciliation-action-applier.js +0 -68
  37. package/dist/reconciliation-actions.js +0 -1
  38. package/dist/reconciliation-engine.js +0 -350
  39. package/dist/reconciliation-snapshot-builder.js +0 -135
  40. package/dist/reconciliation-types.js +0 -1
  41. package/dist/service-stage-finalizer.js +0 -753
  42. package/dist/service-stage-runner.js +0 -336
  43. package/dist/service-webhook-processor.js +0 -411
  44. package/dist/stage-agent-activity-publisher.js +0 -59
  45. package/dist/stage-event-ports.js +0 -1
  46. package/dist/stage-failure.js +0 -92
  47. package/dist/stage-handoff.js +0 -107
  48. package/dist/stage-launch.js +0 -84
  49. package/dist/stage-lifecycle-publisher.js +0 -284
  50. package/dist/stage-turn-input-dispatcher.js +0 -104
  51. package/dist/webhook-agent-session-handler.js +0 -228
  52. package/dist/webhook-comment-handler.js +0 -141
  53. package/dist/webhook-desired-stage-recorder.js +0 -122
  54. package/dist/webhook-event-ports.js +0 -1
  55. package/dist/workflow-policy.js +0 -149
  56. package/dist/workflow-ports.js +0 -1
  57. /package/dist/{installation-ports.js → github-types.js} +0 -0
@@ -1,46 +1,15 @@
1
1
  import { SerialWorkQueue } from "./service-queue.js";
2
2
  const ISSUE_KEY_DELIMITER = "::";
3
3
  const DEFAULT_RECONCILE_INTERVAL_MS = 5_000;
4
- function toReconciler(value) {
5
- if (typeof value === "function") {
6
- return {
7
- reconcileActiveStageRuns: value,
8
- };
9
- }
10
- return value;
11
- }
12
- function toReadyIssueSource(value) {
13
- if (typeof value === "function") {
14
- return {
15
- listIssuesReadyForExecution: value,
16
- };
17
- }
18
- return value;
19
- }
20
- function toWebhookProcessor(value) {
21
- if (typeof value === "function") {
22
- return {
23
- processWebhookEvent: value,
24
- };
25
- }
26
- return value;
27
- }
28
- function toIssueProcessor(value) {
29
- if (typeof value === "function") {
30
- return {
31
- processIssue: value,
32
- };
33
- }
34
- return value;
35
- }
4
+ const DEFAULT_RECONCILE_TIMEOUT_MS = 60_000;
36
5
  function makeIssueQueueKey(item) {
37
6
  return `${item.projectId}${ISSUE_KEY_DELIMITER}${item.issueId}`;
38
7
  }
39
- // ServiceRuntime is the coordination seam for the harness. It is responsible for
40
- // startup reconciliation, queue ownership, and handing eligible work to the stage runner.
41
8
  export class ServiceRuntime {
42
9
  codex;
43
10
  logger;
11
+ runReconciler;
12
+ readyIssueSource;
44
13
  options;
45
14
  webhookQueue;
46
15
  issueQueue;
@@ -48,27 +17,19 @@ export class ServiceRuntime {
48
17
  startupError;
49
18
  reconcileTimer;
50
19
  reconcileInProgress = false;
51
- constructor(codex, logger, stageRunReconciler, readyIssueSource, webhookProcessor, issueProcessor, options = {}) {
20
+ constructor(codex, logger, runReconciler, readyIssueSource, webhookProcessor, issueProcessor, options = {}) {
52
21
  this.codex = codex;
53
22
  this.logger = logger;
23
+ this.runReconciler = runReconciler;
24
+ this.readyIssueSource = readyIssueSource;
54
25
  this.options = options;
55
- this.stageRunReconciler = toReconciler(stageRunReconciler);
56
- this.readyIssueSource = toReadyIssueSource(readyIssueSource);
57
- this.webhookProcessor = toWebhookProcessor(webhookProcessor);
58
- this.issueProcessor = toIssueProcessor(issueProcessor);
59
- this.webhookQueue = new SerialWorkQueue((eventId) => this.webhookProcessor.processWebhookEvent(eventId), logger, (eventId) => String(eventId));
60
- this.issueQueue = new SerialWorkQueue((item) => this.issueProcessor.processIssue(item), logger, makeIssueQueueKey);
26
+ this.webhookQueue = new SerialWorkQueue((eventId) => webhookProcessor.processWebhookEvent(eventId), logger, (eventId) => String(eventId));
27
+ this.issueQueue = new SerialWorkQueue((item) => issueProcessor.processIssue(item), logger, makeIssueQueueKey);
61
28
  }
62
- stageRunReconciler;
63
- readyIssueSource;
64
- webhookProcessor;
65
- issueProcessor;
66
29
  async start() {
67
30
  try {
68
31
  await this.codex.start();
69
- // Reconciliation happens before new work is enqueued so restart recovery can
70
- // resolve or release any previously claimed work deterministically.
71
- await this.stageRunReconciler.reconcileActiveStageRuns();
32
+ await this.runReconciler.reconcileActiveRuns();
72
33
  for (const issue of this.readyIssueSource.listIssuesReadyForExecution()) {
73
34
  this.enqueueIssue(issue.projectId, issue.linearIssueId);
74
35
  }
@@ -115,21 +76,18 @@ export class ServiceRuntime {
115
76
  }
116
77
  }
117
78
  async runBackgroundReconcile() {
118
- if (!this.ready || !this.codex.isStarted()) {
79
+ if (!this.ready || !this.codex.isStarted())
119
80
  return;
120
- }
121
81
  if (this.reconcileInProgress) {
122
82
  this.scheduleBackgroundReconcile();
123
83
  return;
124
84
  }
125
85
  this.reconcileInProgress = true;
126
86
  try {
127
- await this.stageRunReconciler.reconcileActiveStageRuns();
87
+ await promiseWithTimeout(this.runReconciler.reconcileActiveRuns(), this.options.reconcileTimeoutMs ?? DEFAULT_RECONCILE_TIMEOUT_MS, "Background active-run reconciliation");
128
88
  }
129
89
  catch (error) {
130
- this.logger.warn({
131
- error: error instanceof Error ? error.message : String(error),
132
- }, "Background active-stage reconciliation failed");
90
+ this.logger.warn({ error: error instanceof Error ? error.message : String(error) }, "Background active-run reconciliation failed");
133
91
  }
134
92
  finally {
135
93
  this.reconcileInProgress = false;
@@ -139,3 +97,12 @@ export class ServiceRuntime {
139
97
  }
140
98
  }
141
99
  }
100
+ function promiseWithTimeout(promise, timeoutMs, label) {
101
+ return new Promise((resolve, reject) => {
102
+ const timeout = setTimeout(() => {
103
+ reject(new Error(`${label} timed out after ${timeoutMs}ms`));
104
+ }, timeoutMs);
105
+ timeout.unref?.();
106
+ promise.then((value) => { clearTimeout(timeout); resolve(value); }, (error) => { clearTimeout(timeout); reject(error); });
107
+ });
108
+ }
@@ -1,5 +1,5 @@
1
1
  import { normalizeWebhook } from "./webhooks.js";
2
- import { redactSensitiveHeaders, timestampMsWithinSkew, verifyHmacSha256Hex } from "./utils.js";
2
+ import { timestampMsWithinSkew, verifyHmacSha256Hex } from "./utils.js";
3
3
  export async function acceptIncomingWebhook(params) {
4
4
  const receivedAt = new Date().toISOString();
5
5
  const signature = typeof params.headers["linear-signature"] === "string" ? params.headers["linear-signature"] : "";
@@ -21,47 +21,24 @@ export async function acceptIncomingWebhook(params) {
21
21
  }
22
22
  let normalized;
23
23
  try {
24
- normalized = normalizeWebhook({
25
- webhookId: params.webhookId,
26
- payload,
27
- });
24
+ normalized = normalizeWebhook({ webhookId: params.webhookId, payload });
28
25
  }
29
26
  catch (error) {
30
27
  params.logger.warn({ webhookId: params.webhookId, error }, "Rejecting unsupported webhook payload");
31
28
  return { status: 400, body: { ok: false, reason: "unsupported_payload" } };
32
29
  }
33
- const sanitizedHeaders = redactSensitiveHeaders(params.headers);
34
- const headersJson = JSON.stringify(sanitizedHeaders);
35
30
  const payloadJson = JSON.stringify(payload);
36
31
  logWebhookSummary(params.logger, normalized);
37
32
  const stored = params.stores.webhookEvents.insertWebhookEvent({
38
33
  webhookId: params.webhookId,
39
34
  receivedAt,
40
- eventType: normalized.eventType,
41
- ...(normalized.issue ? { issueId: normalized.issue.id } : {}),
42
- headersJson,
43
35
  payloadJson,
44
- signatureValid: true,
45
- dedupeStatus: "accepted",
46
36
  });
47
- if (!stored.inserted) {
48
- recordEventReceipt(params.stores, {
49
- webhookId: params.webhookId,
50
- receivedAt,
51
- normalized,
52
- headersJson,
53
- payloadJson,
54
- });
37
+ const isDuplicate = stored.dedupeStatus === "duplicate";
38
+ if (isDuplicate) {
55
39
  params.logger.info({ webhookId: params.webhookId, webhookEventId: stored.id }, "Ignoring duplicate webhook delivery");
56
40
  return { status: 200, body: { ok: true, duplicate: true } };
57
41
  }
58
- recordEventReceipt(params.stores, {
59
- webhookId: params.webhookId,
60
- receivedAt,
61
- normalized,
62
- headersJson,
63
- payloadJson,
64
- });
65
42
  params.logger.info({
66
43
  webhookId: params.webhookId,
67
44
  webhookEventId: stored.id,
@@ -70,27 +47,11 @@ export async function acceptIncomingWebhook(params) {
70
47
  issueId: normalized.issue?.id,
71
48
  }, "Accepted Linear webhook for asynchronous processing");
72
49
  return {
73
- accepted: {
74
- id: stored.id,
75
- normalized,
76
- payload,
77
- },
50
+ accepted: { id: stored.id, normalized, payload },
78
51
  status: 200,
79
52
  body: { ok: true, accepted: true, webhookEventId: stored.id },
80
53
  };
81
54
  }
82
- function recordEventReceipt(stores, params) {
83
- stores.eventReceipts.insertEventReceipt({
84
- source: "linear-webhook",
85
- externalId: params.webhookId,
86
- eventType: params.normalized.eventType,
87
- receivedAt: params.receivedAt,
88
- acceptanceStatus: "accepted",
89
- ...(params.normalized.issue ? { linearIssueId: params.normalized.issue.id } : {}),
90
- headersJson: params.headersJson,
91
- payloadJson: params.payloadJson,
92
- });
93
- }
94
55
  function logWebhookSummary(logger, normalized) {
95
56
  const issueRef = normalized.issue?.identifier ?? normalized.issue?.id ?? normalized.installation?.appUserId ?? normalized.entityType;
96
57
  const stateName = normalized.issue?.stateName;
@@ -100,9 +61,7 @@ function logWebhookSummary(logger, normalized) {
100
61
  normalized.triggerEvent,
101
62
  stateName ? `to ${stateName}` : undefined,
102
63
  title ? `(${title})` : undefined,
103
- ]
104
- .filter(Boolean)
105
- .join(" ");
64
+ ].filter(Boolean).join(" ");
106
65
  logger.info({
107
66
  issueKey: normalized.issue?.identifier,
108
67
  triggerEvent: normalized.triggerEvent,
@@ -111,9 +70,5 @@ function logWebhookSummary(logger, normalized) {
111
70
  appUserId: normalized.installation?.appUserId,
112
71
  notificationType: normalized.installation?.notificationType,
113
72
  }, summary);
114
- logger.debug({
115
- webhookId: normalized.webhookId,
116
- eventType: normalized.eventType,
117
- issueId: normalized.issue?.id,
118
- }, "Webhook metadata");
73
+ logger.debug({ webhookId: normalized.webhookId, eventType: normalized.eventType, issueId: normalized.issue?.id }, "Webhook metadata");
119
74
  }
package/dist/service.js CHANGED
@@ -1,49 +1,21 @@
1
+ import { GitHubWebhookHandler } from "./github-webhook-handler.js";
1
2
  import { IssueQueryService } from "./issue-query-service.js";
2
3
  import { LinearOAuthService } from "./linear-oauth-service.js";
4
+ import { RunOrchestrator } from "./run-orchestrator.js";
3
5
  import { OperatorEventFeed } from "./operator-feed.js";
4
6
  import { buildSessionStatusUrl, createSessionStatusToken, deriveSessionStatusSigningSecret, verifySessionStatusToken, } from "./public-agent-session-status.js";
5
7
  import { ServiceRuntime } from "./service-runtime.js";
6
- import { ServiceStageFinalizer } from "./service-stage-finalizer.js";
7
- import { ServiceStageRunner } from "./service-stage-runner.js";
8
- import { ServiceWebhookProcessor } from "./service-webhook-processor.js";
8
+ import { WebhookHandler } from "./webhook-handler.js";
9
9
  import { acceptIncomingWebhook } from "./service-webhooks.js";
10
- function createServiceStores(db) {
11
- return {
12
- webhookEvents: db.webhookEvents,
13
- eventReceipts: db.eventReceipts,
14
- issueControl: db.issueControl,
15
- issueSessions: db.issueSessions,
16
- workspaceOwnership: db.workspaceOwnership,
17
- runLeases: db.runLeases,
18
- obligations: db.obligations,
19
- workflowCoordinator: db.workflowCoordinator,
20
- issueWorkflows: db.issueWorkflows,
21
- stageEvents: db.stageEvents,
22
- linearInstallations: db.linearInstallations,
23
- };
24
- }
25
- function createReadyIssueSource(stores) {
26
- return {
27
- listIssuesReadyForExecution: () => stores.issueControl.listIssueControlsReadyForLaunch().map((issue) => ({
28
- projectId: issue.projectId,
29
- linearIssueId: issue.linearIssueId,
30
- })),
31
- };
32
- }
33
- // PatchRelayService wires together the harness layers:
34
- // - integration: webhook intake, OAuth, Linear client access
35
- // - coordination: runtime queueing, stage launch, completion, reconciliation
36
- // - execution: Codex app-server and worktree-backed stage runs
37
- // - observability: issue/report query surfaces
38
10
  export class PatchRelayService {
39
11
  config;
40
12
  db;
41
13
  codex;
42
14
  logger;
43
15
  linearProvider;
44
- stageRunner;
45
- stageFinalizer;
46
- webhookProcessor;
16
+ orchestrator;
17
+ webhookHandler;
18
+ githubWebhookHandler;
47
19
  oauthService;
48
20
  queryService;
49
21
  runtime;
@@ -55,22 +27,19 @@ export class PatchRelayService {
55
27
  this.logger = logger;
56
28
  this.linearProvider = toLinearClientProvider(linearProvider);
57
29
  this.feed = new OperatorEventFeed(db.operatorFeed);
58
- const stores = createServiceStores(db);
59
- this.stageRunner = new ServiceStageRunner(config, stores, codex, this.linearProvider, logger, (fn) => db.connection.transaction(fn)(), this.feed);
60
30
  let enqueueIssue = () => {
61
31
  throw new Error("Service runtime enqueueIssue is not initialized");
62
32
  };
63
- this.stageFinalizer = new ServiceStageFinalizer(config, stores, codex, this.linearProvider, (projectId, issueId) => enqueueIssue(projectId, issueId), logger, this.feed, (fn) => db.connection.transaction(fn)());
64
- this.webhookProcessor = new ServiceWebhookProcessor(config, stores, this.linearProvider, codex, (projectId, issueId) => enqueueIssue(projectId, issueId), logger, this.feed);
65
- const runtime = new ServiceRuntime(codex, logger, this.stageFinalizer, createReadyIssueSource(stores), this.webhookProcessor, {
66
- processIssue: (item) => this.stageRunner.run(item),
67
- });
33
+ this.orchestrator = new RunOrchestrator(config, db, codex, this.linearProvider, (projectId, issueId) => enqueueIssue(projectId, issueId), logger, this.feed);
34
+ this.webhookHandler = new WebhookHandler(config, db, this.linearProvider, codex, (projectId, issueId) => enqueueIssue(projectId, issueId), logger, this.feed);
35
+ this.githubWebhookHandler = new GitHubWebhookHandler(config, db, this.linearProvider, (projectId, issueId) => enqueueIssue(projectId, issueId), logger, this.feed);
36
+ const runtime = new ServiceRuntime(codex, logger, this.orchestrator, { listIssuesReadyForExecution: () => db.listIssuesReadyForExecution() }, this.webhookHandler, { processIssue: (item) => this.orchestrator.run(item) });
68
37
  enqueueIssue = (projectId, issueId) => runtime.enqueueIssue(projectId, issueId);
69
- this.oauthService = new LinearOAuthService(config, stores, logger);
70
- this.queryService = new IssueQueryService(stores, codex, this.stageFinalizer);
38
+ this.oauthService = new LinearOAuthService(config, { linearInstallations: db.linearInstallations }, logger);
39
+ this.queryService = new IssueQueryService(db, codex, this.orchestrator);
71
40
  this.runtime = runtime;
72
41
  this.codex.on("notification", (notification) => {
73
- void this.stageFinalizer.handleCodexNotification(notification);
42
+ void this.orchestrator.handleCodexNotification(notification);
74
43
  });
75
44
  }
76
45
  async start() {
@@ -104,8 +73,12 @@ export class PatchRelayService {
104
73
  const result = await acceptIncomingWebhook({
105
74
  config: this.config,
106
75
  stores: {
107
- webhookEvents: this.db.webhookEvents,
108
- eventReceipts: this.db.eventReceipts,
76
+ webhookEvents: {
77
+ insertWebhookEvent: (p) => {
78
+ const r = this.db.insertFullWebhookEvent(p);
79
+ return { id: r.id, dedupeStatus: r.dedupeStatus };
80
+ },
81
+ },
109
82
  },
110
83
  logger: this.logger,
111
84
  webhookId: params.webhookId,
@@ -122,11 +95,22 @@ export class PatchRelayService {
122
95
  body: result.body,
123
96
  };
124
97
  }
98
+ async acceptGitHubWebhook(params) {
99
+ const result = await this.githubWebhookHandler.acceptGitHubWebhook(params);
100
+ if (result.body.accepted && result.body.webhookEventId) {
101
+ // Process inline since GitHub events are lightweight (just PR state updates)
102
+ await this.githubWebhookHandler.processGitHubWebhookEvent({
103
+ eventType: params.eventType,
104
+ rawBody: params.rawBody.toString("utf8"),
105
+ });
106
+ }
107
+ return result;
108
+ }
125
109
  async processWebhookEvent(webhookEventId) {
126
- await this.webhookProcessor.processWebhookEvent(webhookEventId);
110
+ await this.webhookHandler.processWebhookEvent(webhookEventId);
127
111
  }
128
112
  async processIssue(item) {
129
- await this.stageRunner.run(item);
113
+ await this.orchestrator.run(item);
130
114
  }
131
115
  async getIssueOverview(issueKey) {
132
116
  return await this.queryService.getIssueOverview(issueKey);
@@ -134,16 +118,15 @@ export class PatchRelayService {
134
118
  async getIssueReport(issueKey) {
135
119
  return await this.queryService.getIssueReport(issueKey);
136
120
  }
137
- async getStageEvents(issueKey, stageRunId) {
138
- return await this.queryService.getStageEvents(issueKey, stageRunId);
121
+ async getRunEvents(issueKey, runId) {
122
+ return await this.queryService.getRunEvents(issueKey, runId);
139
123
  }
140
- async getActiveStageStatus(issueKey) {
141
- return await this.queryService.getActiveStageStatus(issueKey);
124
+ async getActiveRunStatus(issueKey) {
125
+ return await this.orchestrator.getActiveRunStatus(issueKey);
142
126
  }
143
127
  createPublicAgentSessionStatusLink(issueKey, options) {
144
- if (!this.config.server.publicBaseUrl) {
128
+ if (!this.config.server.publicBaseUrl)
145
129
  return undefined;
146
- }
147
130
  const signingSecret = deriveSessionStatusSigningSecret(this.config.linear.tokenEncryptionKey);
148
131
  const token = createSessionStatusToken({
149
132
  issueKey,
@@ -152,11 +135,7 @@ export class PatchRelayService {
152
135
  ...(options?.ttlSeconds !== undefined ? { ttlSeconds: options.ttlSeconds } : {}),
153
136
  });
154
137
  return {
155
- url: buildSessionStatusUrl({
156
- publicBaseUrl: this.config.server.publicBaseUrl,
157
- issueKey,
158
- token: token.token,
159
- }),
138
+ url: buildSessionStatusUrl({ publicBaseUrl: this.config.server.publicBaseUrl, issueKey, token: token.token }),
160
139
  issueKey: token.issueKey,
161
140
  expiresAt: token.expiresAt,
162
141
  };
@@ -168,9 +147,8 @@ export class PatchRelayService {
168
147
  return { status: "invalid_token" };
169
148
  }
170
149
  const sessionStatus = await this.queryService.getPublicAgentSessionStatus(params.issueKey);
171
- if (!sessionStatus) {
150
+ if (!sessionStatus)
172
151
  return { status: "issue_not_found" };
173
- }
174
152
  return {
175
153
  status: "ok",
176
154
  issueKey: params.issueKey,