patchrelay 0.8.9 → 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 +12 -61
  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
@@ -0,0 +1,387 @@
1
+ import { buildPreparingSessionPlan, buildRunningSessionPlan, } from "./agent-session-plan.js";
2
+ import { buildAgentSessionExternalUrls } from "./agent-session-presentation.js";
3
+ import { resolveProject, triggerEventAllowed, trustedActorAllowed } from "./project-resolution.js";
4
+ import { normalizeWebhook } from "./webhooks.js";
5
+ import { InstallationWebhookHandler } from "./webhook-installation-handler.js";
6
+ import { safeJsonParse, sanitizeDiagnosticText } from "./utils.js";
7
+ export class WebhookHandler {
8
+ config;
9
+ db;
10
+ linearProvider;
11
+ codex;
12
+ enqueueIssue;
13
+ logger;
14
+ feed;
15
+ installationHandler;
16
+ constructor(config, db, linearProvider, codex, enqueueIssue, logger, feed) {
17
+ this.config = config;
18
+ this.db = db;
19
+ this.linearProvider = linearProvider;
20
+ this.codex = codex;
21
+ this.enqueueIssue = enqueueIssue;
22
+ this.logger = logger;
23
+ this.feed = feed;
24
+ this.installationHandler = new InstallationWebhookHandler(config, { linearInstallations: db.linearInstallations }, logger);
25
+ }
26
+ async processWebhookEvent(webhookEventId) {
27
+ const event = this.db.getWebhookPayload(webhookEventId);
28
+ if (!event) {
29
+ this.logger.warn({ webhookEventId }, "Webhook event was not found during processing");
30
+ return;
31
+ }
32
+ try {
33
+ const payload = safeJsonParse(event.payloadJson);
34
+ if (!payload) {
35
+ this.db.markWebhookProcessed(webhookEventId, "failed");
36
+ throw new Error(`Stored webhook payload is invalid JSON: event ${webhookEventId}`);
37
+ }
38
+ let normalized = normalizeWebhook({ webhookId: event.webhookId, payload });
39
+ this.logger.info({
40
+ webhookEventId,
41
+ webhookId: event.webhookId,
42
+ triggerEvent: normalized.triggerEvent,
43
+ issueKey: normalized.issue?.identifier,
44
+ }, "Processing stored webhook event");
45
+ if (!normalized.issue) {
46
+ this.feed?.publish({
47
+ level: "info",
48
+ kind: "webhook",
49
+ status: normalized.triggerEvent,
50
+ summary: `Received ${normalized.triggerEvent} webhook`,
51
+ });
52
+ this.installationHandler.handle(normalized);
53
+ this.db.markWebhookProcessed(webhookEventId, "processed");
54
+ return;
55
+ }
56
+ let project = resolveProject(this.config, normalized.issue);
57
+ if (!project) {
58
+ const routed = await this.tryHydrateProjectRoute(normalized);
59
+ if (routed) {
60
+ normalized = routed.normalized;
61
+ project = routed.project;
62
+ }
63
+ }
64
+ if (!project) {
65
+ this.feed?.publish({
66
+ level: "warn",
67
+ kind: "webhook",
68
+ issueKey: normalized.issue?.identifier,
69
+ status: "ignored",
70
+ summary: "Ignored webhook with no matching project route",
71
+ });
72
+ this.db.markWebhookProcessed(webhookEventId, "processed");
73
+ return;
74
+ }
75
+ const routedIssue = normalized.issue;
76
+ if (!routedIssue) {
77
+ this.db.markWebhookProcessed(webhookEventId, "failed");
78
+ throw new Error(`Issue context disappeared while routing webhook ${event.webhookId}`);
79
+ }
80
+ if (!trustedActorAllowed(project, normalized.actor)) {
81
+ this.feed?.publish({
82
+ level: "warn",
83
+ kind: "webhook",
84
+ issueKey: routedIssue.identifier,
85
+ projectId: project.id,
86
+ status: "ignored",
87
+ summary: "Ignored webhook from an untrusted actor",
88
+ });
89
+ this.db.markWebhookProcessed(webhookEventId, "processed");
90
+ return;
91
+ }
92
+ this.db.assignWebhookProject(webhookEventId, project.id);
93
+ const hydrated = await this.hydrateIssueContext(project.id, normalized);
94
+ const issue = hydrated.issue ?? routedIssue;
95
+ // Record desired stage and upsert issue
96
+ const result = this.recordDesiredStage(project, hydrated);
97
+ const trackedIssue = result.issue;
98
+ // Handle agent session events
99
+ await this.handleAgentSession(hydrated, project, trackedIssue, result.desiredStage, result.delegated);
100
+ // Handle comments during active run
101
+ await this.handleComment(hydrated, project, trackedIssue);
102
+ this.db.markWebhookProcessed(webhookEventId, "processed");
103
+ if (result.desiredStage) {
104
+ this.feed?.publish({
105
+ level: "info",
106
+ kind: "stage",
107
+ issueKey: issue.identifier,
108
+ projectId: project.id,
109
+ stage: result.desiredStage,
110
+ status: "queued",
111
+ summary: `Queued ${result.desiredStage} workflow`,
112
+ detail: `Triggered by ${hydrated.triggerEvent}.`,
113
+ });
114
+ this.enqueueIssue(project.id, issue.id);
115
+ }
116
+ }
117
+ catch (error) {
118
+ this.db.markWebhookProcessed(webhookEventId, "failed");
119
+ const err = error instanceof Error ? error : new Error(String(error));
120
+ this.feed?.publish({
121
+ level: "error",
122
+ kind: "webhook",
123
+ projectId: undefined,
124
+ status: "failed",
125
+ summary: "Failed to process webhook",
126
+ detail: sanitizeDiagnosticText(err.message),
127
+ });
128
+ this.logger.error({ webhookEventId, webhookId: event.webhookId, error: sanitizeDiagnosticText(err.message) }, "Failed to process Linear webhook event");
129
+ throw err;
130
+ }
131
+ }
132
+ recordDesiredStage(project, normalized) {
133
+ const normalizedIssue = normalized.issue;
134
+ if (!normalizedIssue) {
135
+ return { issue: undefined, desiredStage: undefined, delegated: false };
136
+ }
137
+ const existingIssue = this.db.getIssue(project.id, normalizedIssue.id);
138
+ const activeRun = existingIssue?.activeRunId ? this.db.getRun(existingIssue.activeRunId) : undefined;
139
+ const delegated = this.isDelegatedToPatchRelay(project, normalized);
140
+ const triggerAllowed = triggerEventAllowed(project, normalized.triggerEvent);
141
+ // In the factory model, delegation → queue an implementation run.
142
+ // agentSessionCreated is itself a delegation signal (session exists because issue was delegated).
143
+ let pendingRunType;
144
+ const isDelegationSignal = delegated || normalized.triggerEvent === "agentSessionCreated";
145
+ if (isDelegationSignal && triggerAllowed && !activeRun && !existingIssue?.pendingRunType) {
146
+ pendingRunType = "implementation";
147
+ }
148
+ // Resolve agent session
149
+ const agentSessionId = normalized.agentSession?.id ??
150
+ (!activeRun && (pendingRunType || (normalized.triggerEvent === "delegateChanged" && !delegated)) ? null : undefined);
151
+ // Upsert the issue
152
+ const issue = this.db.upsertIssue({
153
+ projectId: project.id,
154
+ linearIssueId: normalizedIssue.id,
155
+ ...(normalizedIssue.identifier ? { issueKey: normalizedIssue.identifier } : {}),
156
+ ...(normalizedIssue.title ? { title: normalizedIssue.title } : {}),
157
+ ...(normalizedIssue.url ? { url: normalizedIssue.url } : {}),
158
+ ...(normalizedIssue.stateName ? { currentLinearState: normalizedIssue.stateName } : {}),
159
+ ...(pendingRunType ? { pendingRunType, factoryState: "delegated" } : {}),
160
+ ...(agentSessionId !== undefined ? { agentSessionId } : {}),
161
+ });
162
+ return {
163
+ issue: this.db.issueToTrackedIssue(issue),
164
+ desiredStage: pendingRunType,
165
+ delegated,
166
+ };
167
+ }
168
+ isDelegatedToPatchRelay(project, normalized) {
169
+ if (!normalized.issue)
170
+ return false;
171
+ const installation = this.db.linearInstallations.getLinearInstallationForProject(project.id);
172
+ if (!installation?.actorId)
173
+ return false;
174
+ return normalized.issue.delegateId === installation.actorId;
175
+ }
176
+ // ─── Agent session handling (inlined) ─────────────────────────────
177
+ async handleAgentSession(normalized, project, trackedIssue, desiredStage, delegated) {
178
+ if (!normalized.agentSession?.id || !normalized.issue)
179
+ return;
180
+ const linear = await this.linearProvider.forProject(project.id);
181
+ if (!linear)
182
+ return;
183
+ const existingIssue = this.db.getIssue(project.id, normalized.issue.id);
184
+ const activeRun = existingIssue?.activeRunId ? this.db.getRun(existingIssue.activeRunId) : undefined;
185
+ if (normalized.triggerEvent === "agentSessionCreated") {
186
+ if (!delegated) {
187
+ const body = "PatchRelay received your mention. Delegate the issue to PatchRelay to start work.";
188
+ await this.publishAgentActivity(linear, normalized.agentSession.id, { type: "elicitation", body });
189
+ return;
190
+ }
191
+ if (desiredStage) {
192
+ await this.updateAgentSessionPlan(linear, project, normalized.agentSession.id, trackedIssue, buildPreparingSessionPlan(desiredStage));
193
+ await this.publishAgentActivity(linear, normalized.agentSession.id, {
194
+ type: "response",
195
+ body: `PatchRelay started working on the ${desiredStage} workflow.`,
196
+ });
197
+ return;
198
+ }
199
+ if (activeRun) {
200
+ await this.updateAgentSessionPlan(linear, project, normalized.agentSession.id, trackedIssue, buildRunningSessionPlan(activeRun.runType));
201
+ await this.publishAgentActivity(linear, normalized.agentSession.id, {
202
+ type: "response",
203
+ body: `PatchRelay is already running the ${activeRun.runType} workflow for this issue.`,
204
+ });
205
+ return;
206
+ }
207
+ await this.publishAgentActivity(linear, normalized.agentSession.id, {
208
+ type: "elicitation",
209
+ body: "PatchRelay is delegated, but no work is queued. Delegate the issue or move it to Start to trigger implementation.",
210
+ });
211
+ return;
212
+ }
213
+ if (normalized.triggerEvent !== "agentPrompted")
214
+ return;
215
+ if (!triggerEventAllowed(project, normalized.triggerEvent))
216
+ return;
217
+ const promptBody = normalized.agentSession.promptBody?.trim();
218
+ if (activeRun && promptBody && activeRun.threadId && activeRun.turnId) {
219
+ // Deliver prompt directly to active Codex turn
220
+ const input = `New Linear agent prompt received while you are working.\n\n${promptBody}`;
221
+ try {
222
+ await this.codex.steerTurn({ threadId: activeRun.threadId, turnId: activeRun.turnId, input });
223
+ this.feed?.publish({
224
+ level: "info",
225
+ kind: "agent",
226
+ projectId: project.id,
227
+ issueKey: trackedIssue?.issueKey,
228
+ stage: activeRun.runType,
229
+ status: "delivered",
230
+ summary: `Delivered follow-up prompt to active ${activeRun.runType} workflow`,
231
+ });
232
+ }
233
+ catch {
234
+ this.feed?.publish({
235
+ level: "warn",
236
+ kind: "agent",
237
+ projectId: project.id,
238
+ issueKey: trackedIssue?.issueKey,
239
+ stage: activeRun.runType,
240
+ status: "delivery_failed",
241
+ summary: `Could not deliver follow-up prompt to active ${activeRun.runType} workflow`,
242
+ });
243
+ }
244
+ await this.publishAgentActivity(linear, normalized.agentSession.id, {
245
+ type: "thought",
246
+ body: `PatchRelay routed your follow-up instructions into the active ${activeRun.runType} workflow.`,
247
+ });
248
+ return;
249
+ }
250
+ if (desiredStage) {
251
+ await this.updateAgentSessionPlan(linear, project, normalized.agentSession.id, trackedIssue, buildPreparingSessionPlan(desiredStage));
252
+ await this.publishAgentActivity(linear, normalized.agentSession.id, {
253
+ type: "response",
254
+ body: `PatchRelay is preparing the ${desiredStage} workflow from your latest prompt.`,
255
+ });
256
+ }
257
+ }
258
+ // ─── Comment handling (inlined) ───────────────────────────────────
259
+ async handleComment(normalized, project, trackedIssue) {
260
+ if ((normalized.triggerEvent !== "commentCreated" && normalized.triggerEvent !== "commentUpdated") ||
261
+ !normalized.comment?.body ||
262
+ !normalized.issue) {
263
+ return;
264
+ }
265
+ if (!triggerEventAllowed(project, normalized.triggerEvent))
266
+ return;
267
+ const issue = this.db.getIssue(project.id, normalized.issue.id);
268
+ if (!issue?.activeRunId)
269
+ return;
270
+ const run = this.db.getRun(issue.activeRunId);
271
+ if (!run?.threadId || !run.turnId)
272
+ return;
273
+ const body = [
274
+ "New Linear comment received while you are working.",
275
+ normalized.comment.userName ? `Author: ${normalized.comment.userName}` : undefined,
276
+ "",
277
+ normalized.comment.body.trim(),
278
+ ].filter(Boolean).join("\n");
279
+ try {
280
+ await this.codex.steerTurn({ threadId: run.threadId, turnId: run.turnId, input: body });
281
+ this.feed?.publish({
282
+ level: "info",
283
+ kind: "comment",
284
+ projectId: project.id,
285
+ issueKey: trackedIssue?.issueKey,
286
+ stage: run.runType,
287
+ status: "delivered",
288
+ summary: `Delivered follow-up comment to active ${run.runType} workflow`,
289
+ });
290
+ }
291
+ catch {
292
+ this.feed?.publish({
293
+ level: "warn",
294
+ kind: "comment",
295
+ projectId: project.id,
296
+ issueKey: trackedIssue?.issueKey,
297
+ stage: run.runType,
298
+ status: "delivery_failed",
299
+ summary: `Could not deliver follow-up comment to active ${run.runType} workflow`,
300
+ });
301
+ }
302
+ }
303
+ // ─── Helpers ──────────────────────────────────────────────────────
304
+ async publishAgentActivity(linear, agentSessionId, content) {
305
+ try {
306
+ await linear.createAgentActivity({
307
+ agentSessionId,
308
+ content,
309
+ ephemeral: content.type === "thought",
310
+ });
311
+ }
312
+ catch (error) {
313
+ this.logger.warn({ agentSessionId, error: error instanceof Error ? error.message : String(error) }, "Failed to publish Linear agent activity");
314
+ }
315
+ }
316
+ async updateAgentSessionPlan(linear, project, agentSessionId, issue, plan) {
317
+ if (!linear.updateAgentSession)
318
+ return;
319
+ try {
320
+ const externalUrls = buildAgentSessionExternalUrls(this.config, issue?.issueKey);
321
+ await linear.updateAgentSession({
322
+ agentSessionId,
323
+ ...(externalUrls ? { externalUrls } : {}),
324
+ plan,
325
+ });
326
+ }
327
+ catch (error) {
328
+ this.logger.warn({ agentSessionId, error: error instanceof Error ? error.message : String(error) }, "Failed to update Linear agent session");
329
+ }
330
+ }
331
+ async hydrateIssueContext(projectId, normalized) {
332
+ if (!normalized.issue)
333
+ return normalized;
334
+ if (normalized.triggerEvent !== "agentSessionCreated" && normalized.triggerEvent !== "agentPrompted")
335
+ return normalized;
336
+ if (hasCompleteIssueContext(normalized.issue))
337
+ return normalized;
338
+ const linear = await this.linearProvider.forProject(projectId);
339
+ if (!linear)
340
+ return normalized;
341
+ try {
342
+ const liveIssue = await linear.getIssue(normalized.issue.id);
343
+ return { ...normalized, issue: mergeIssueMetadata(normalized.issue, liveIssue) };
344
+ }
345
+ catch {
346
+ return normalized;
347
+ }
348
+ }
349
+ async tryHydrateProjectRoute(normalized) {
350
+ if (!normalized.issue)
351
+ return undefined;
352
+ if (normalized.triggerEvent !== "agentSessionCreated" && normalized.triggerEvent !== "agentPrompted")
353
+ return undefined;
354
+ for (const candidate of this.config.projects) {
355
+ const linear = await this.linearProvider.forProject(candidate.id);
356
+ if (!linear)
357
+ continue;
358
+ try {
359
+ const liveIssue = await linear.getIssue(normalized.issue.id);
360
+ const hydrated = { ...normalized, issue: mergeIssueMetadata(normalized.issue, liveIssue) };
361
+ const resolved = resolveProject(this.config, hydrated.issue);
362
+ if (resolved)
363
+ return { project: resolved, normalized: hydrated };
364
+ }
365
+ catch { /* continue to next candidate */ }
366
+ }
367
+ return undefined;
368
+ }
369
+ }
370
+ function hasCompleteIssueContext(issue) {
371
+ return Boolean(issue.stateName && issue.delegateId && issue.teamId && issue.teamKey);
372
+ }
373
+ function mergeIssueMetadata(issue, liveIssue) {
374
+ return {
375
+ ...issue,
376
+ ...(issue.identifier ? {} : liveIssue.identifier ? { identifier: liveIssue.identifier } : {}),
377
+ ...(issue.title ? {} : liveIssue.title ? { title: liveIssue.title } : {}),
378
+ ...(issue.url ? {} : liveIssue.url ? { url: liveIssue.url } : {}),
379
+ ...(issue.teamId ? {} : liveIssue.teamId ? { teamId: liveIssue.teamId } : {}),
380
+ ...(issue.teamKey ? {} : liveIssue.teamKey ? { teamKey: liveIssue.teamKey } : {}),
381
+ ...(issue.stateId ? {} : liveIssue.stateId ? { stateId: liveIssue.stateId } : {}),
382
+ ...(issue.stateName ? {} : liveIssue.stateName ? { stateName: liveIssue.stateName } : {}),
383
+ ...(issue.delegateId ? {} : liveIssue.delegateId ? { delegateId: liveIssue.delegateId } : {}),
384
+ ...(issue.delegateName ? {} : liveIssue.delegateName ? { delegateName: liveIssue.delegateName } : {}),
385
+ labelNames: issue.labelNames.length > 0 ? issue.labelNames : (liveIssue.labels ?? []).map((l) => l.name),
386
+ };
387
+ }
@@ -8,9 +8,8 @@ export class InstallationWebhookHandler {
8
8
  this.logger = logger;
9
9
  }
10
10
  handle(normalized) {
11
- if (!normalized.installation) {
11
+ if (!normalized.installation)
12
12
  return;
13
- }
14
13
  if (normalized.triggerEvent === "installationPermissionsChanged") {
15
14
  const matchingInstallations = normalized.installation.appUserId
16
15
  ? this.stores.linearInstallations
@@ -24,11 +23,7 @@ export class InstallationWebhookHandler {
24
23
  const project = this.config.projects.find((entry) => entry.id === link.projectId);
25
24
  const removedMatches = normalized.installation?.removedTeamIds.some((teamId) => project?.linearTeamIds.includes(teamId)) ?? false;
26
25
  const addedMatches = normalized.installation?.addedTeamIds.some((teamId) => project?.linearTeamIds.includes(teamId)) ?? false;
27
- return {
28
- projectId: link.projectId,
29
- removedMatches,
30
- addedMatches,
31
- };
26
+ return { projectId: link.projectId, removedMatches, addedMatches };
32
27
  }));
33
28
  this.logger.warn({
34
29
  appUserId: normalized.installation.appUserId,
@@ -43,7 +38,7 @@ export class InstallationWebhookHandler {
43
38
  this.logger.warn({
44
39
  organizationId: normalized.installation.organizationId,
45
40
  oauthClientId: normalized.installation.oauthClientId,
46
- }, "Linear OAuth app installation was revoked; reconnect affected projects with `patchrelay project apply <id> <repo-path>` or `patchrelay connect --project <id>`");
41
+ }, "Linear OAuth app installation was revoked; reconnect affected projects");
47
42
  return;
48
43
  }
49
44
  if (normalized.triggerEvent === "appUserNotification") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.8.9",
3
+ "version": "0.9.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -33,6 +33,7 @@
33
33
  "start": "node dist/index.js serve",
34
34
  "doctor": "node dist/index.js doctor",
35
35
  "restart": "node dist/index.js restart-service",
36
+ "deploy": "npm run build && npm install -g . && node dist/index.js restart-service",
36
37
  "lint": "eslint .",
37
38
  "typecheck": "tsc -p tsconfig.json --noEmit",
38
39
  "check": "npm run typecheck",