patchrelay 0.8.9 → 0.9.1

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 (60) 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/args.js +1 -1
  5. package/dist/cli/commands/issues.js +18 -18
  6. package/dist/cli/data.js +109 -298
  7. package/dist/cli/formatters/text.js +22 -28
  8. package/dist/cli/help.js +7 -7
  9. package/dist/cli/index.js +3 -3
  10. package/dist/config.js +13 -166
  11. package/dist/db/migrations.js +46 -154
  12. package/dist/db.js +369 -45
  13. package/dist/factory-state.js +55 -0
  14. package/dist/github-webhook-handler.js +199 -0
  15. package/dist/github-webhooks.js +166 -0
  16. package/dist/hook-runner.js +28 -0
  17. package/dist/http.js +48 -22
  18. package/dist/issue-query-service.js +33 -38
  19. package/dist/linear-workflow.js +5 -118
  20. package/dist/preflight.js +1 -6
  21. package/dist/project-resolution.js +12 -1
  22. package/dist/run-orchestrator.js +446 -0
  23. package/dist/{stage-reporting.js → run-reporting.js} +11 -13
  24. package/dist/service-runtime.js +12 -61
  25. package/dist/service-webhooks.js +7 -52
  26. package/dist/service.js +39 -61
  27. package/dist/webhook-handler.js +387 -0
  28. package/dist/webhook-installation-handler.js +3 -8
  29. package/package.json +2 -1
  30. package/dist/db/authoritative-ledger-store.js +0 -536
  31. package/dist/db/issue-projection-store.js +0 -54
  32. package/dist/db/issue-workflow-coordinator.js +0 -320
  33. package/dist/db/issue-workflow-store.js +0 -194
  34. package/dist/db/run-report-store.js +0 -33
  35. package/dist/db/stage-event-store.js +0 -33
  36. package/dist/db/webhook-event-store.js +0 -59
  37. package/dist/db-ports.js +0 -5
  38. package/dist/ledger-ports.js +0 -1
  39. package/dist/reconciliation-action-applier.js +0 -68
  40. package/dist/reconciliation-actions.js +0 -1
  41. package/dist/reconciliation-engine.js +0 -350
  42. package/dist/reconciliation-snapshot-builder.js +0 -135
  43. package/dist/reconciliation-types.js +0 -1
  44. package/dist/service-stage-finalizer.js +0 -753
  45. package/dist/service-stage-runner.js +0 -336
  46. package/dist/service-webhook-processor.js +0 -411
  47. package/dist/stage-agent-activity-publisher.js +0 -59
  48. package/dist/stage-event-ports.js +0 -1
  49. package/dist/stage-failure.js +0 -92
  50. package/dist/stage-handoff.js +0 -107
  51. package/dist/stage-launch.js +0 -84
  52. package/dist/stage-lifecycle-publisher.js +0 -284
  53. package/dist/stage-turn-input-dispatcher.js +0 -104
  54. package/dist/webhook-agent-session-handler.js +0 -228
  55. package/dist/webhook-comment-handler.js +0 -141
  56. package/dist/webhook-desired-stage-recorder.js +0 -122
  57. package/dist/webhook-event-ports.js +0 -1
  58. package/dist/workflow-policy.js +0 -149
  59. package/dist/workflow-ports.js +0 -1
  60. /package/dist/{installation-ports.js → github-types.js} +0 -0
@@ -2,46 +2,14 @@ import { SerialWorkQueue } from "./service-queue.js";
2
2
  const ISSUE_KEY_DELIMITER = "::";
3
3
  const DEFAULT_RECONCILE_INTERVAL_MS = 5_000;
4
4
  const DEFAULT_RECONCILE_TIMEOUT_MS = 60_000;
5
- function toReconciler(value) {
6
- if (typeof value === "function") {
7
- return {
8
- reconcileActiveStageRuns: value,
9
- };
10
- }
11
- return value;
12
- }
13
- function toReadyIssueSource(value) {
14
- if (typeof value === "function") {
15
- return {
16
- listIssuesReadyForExecution: value,
17
- };
18
- }
19
- return value;
20
- }
21
- function toWebhookProcessor(value) {
22
- if (typeof value === "function") {
23
- return {
24
- processWebhookEvent: value,
25
- };
26
- }
27
- return value;
28
- }
29
- function toIssueProcessor(value) {
30
- if (typeof value === "function") {
31
- return {
32
- processIssue: value,
33
- };
34
- }
35
- return value;
36
- }
37
5
  function makeIssueQueueKey(item) {
38
6
  return `${item.projectId}${ISSUE_KEY_DELIMITER}${item.issueId}`;
39
7
  }
40
- // ServiceRuntime is the coordination seam for the harness. It is responsible for
41
- // startup reconciliation, queue ownership, and handing eligible work to the stage runner.
42
8
  export class ServiceRuntime {
43
9
  codex;
44
10
  logger;
11
+ runReconciler;
12
+ readyIssueSource;
45
13
  options;
46
14
  webhookQueue;
47
15
  issueQueue;
@@ -49,27 +17,19 @@ export class ServiceRuntime {
49
17
  startupError;
50
18
  reconcileTimer;
51
19
  reconcileInProgress = false;
52
- constructor(codex, logger, stageRunReconciler, readyIssueSource, webhookProcessor, issueProcessor, options = {}) {
20
+ constructor(codex, logger, runReconciler, readyIssueSource, webhookProcessor, issueProcessor, options = {}) {
53
21
  this.codex = codex;
54
22
  this.logger = logger;
23
+ this.runReconciler = runReconciler;
24
+ this.readyIssueSource = readyIssueSource;
55
25
  this.options = options;
56
- this.stageRunReconciler = toReconciler(stageRunReconciler);
57
- this.readyIssueSource = toReadyIssueSource(readyIssueSource);
58
- this.webhookProcessor = toWebhookProcessor(webhookProcessor);
59
- this.issueProcessor = toIssueProcessor(issueProcessor);
60
- this.webhookQueue = new SerialWorkQueue((eventId) => this.webhookProcessor.processWebhookEvent(eventId), logger, (eventId) => String(eventId));
61
- 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);
62
28
  }
63
- stageRunReconciler;
64
- readyIssueSource;
65
- webhookProcessor;
66
- issueProcessor;
67
29
  async start() {
68
30
  try {
69
31
  await this.codex.start();
70
- // Reconciliation happens before new work is enqueued so restart recovery can
71
- // resolve or release any previously claimed work deterministically.
72
- await this.stageRunReconciler.reconcileActiveStageRuns();
32
+ await this.runReconciler.reconcileActiveRuns();
73
33
  for (const issue of this.readyIssueSource.listIssuesReadyForExecution()) {
74
34
  this.enqueueIssue(issue.projectId, issue.linearIssueId);
75
35
  }
@@ -116,21 +76,18 @@ export class ServiceRuntime {
116
76
  }
117
77
  }
118
78
  async runBackgroundReconcile() {
119
- if (!this.ready || !this.codex.isStarted()) {
79
+ if (!this.ready || !this.codex.isStarted())
120
80
  return;
121
- }
122
81
  if (this.reconcileInProgress) {
123
82
  this.scheduleBackgroundReconcile();
124
83
  return;
125
84
  }
126
85
  this.reconcileInProgress = true;
127
86
  try {
128
- await promiseWithTimeout(this.stageRunReconciler.reconcileActiveStageRuns(), this.options.reconcileTimeoutMs ?? DEFAULT_RECONCILE_TIMEOUT_MS, "Background active-stage reconciliation");
87
+ await promiseWithTimeout(this.runReconciler.reconcileActiveRuns(), this.options.reconcileTimeoutMs ?? DEFAULT_RECONCILE_TIMEOUT_MS, "Background active-run reconciliation");
129
88
  }
130
89
  catch (error) {
131
- this.logger.warn({
132
- error: error instanceof Error ? error.message : String(error),
133
- }, "Background active-stage reconciliation failed");
90
+ this.logger.warn({ error: error instanceof Error ? error.message : String(error) }, "Background active-run reconciliation failed");
134
91
  }
135
92
  finally {
136
93
  this.reconcileInProgress = false;
@@ -146,12 +103,6 @@ function promiseWithTimeout(promise, timeoutMs, label) {
146
103
  reject(new Error(`${label} timed out after ${timeoutMs}ms`));
147
104
  }, timeoutMs);
148
105
  timeout.unref?.();
149
- promise.then((value) => {
150
- clearTimeout(timeout);
151
- resolve(value);
152
- }, (error) => {
153
- clearTimeout(timeout);
154
- reject(error);
155
- });
106
+ promise.then((value) => { clearTimeout(timeout); resolve(value); }, (error) => { clearTimeout(timeout); reject(error); });
156
107
  });
157
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,