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,181 @@
1
+ import { resolveProject, trustedActorAllowed } from "./project-resolution.js";
2
+ import { StageAgentActivityPublisher } from "./stage-agent-activity-publisher.js";
3
+ import { StageTurnInputDispatcher } from "./stage-turn-input-dispatcher.js";
4
+ import { safeJsonParse, sanitizeDiagnosticText } from "./utils.js";
5
+ import { AgentSessionWebhookHandler } from "./webhook-agent-session-handler.js";
6
+ import { CommentWebhookHandler } from "./webhook-comment-handler.js";
7
+ import { WebhookDesiredStageRecorder } from "./webhook-desired-stage-recorder.js";
8
+ import { InstallationWebhookHandler } from "./webhook-installation-handler.js";
9
+ import { normalizeWebhook } from "./webhooks.js";
10
+ export class ServiceWebhookProcessor {
11
+ config;
12
+ stores;
13
+ enqueueIssue;
14
+ logger;
15
+ desiredStageRecorder;
16
+ agentSessionHandler;
17
+ commentHandler;
18
+ installationHandler;
19
+ constructor(config, stores, linearProvider, codex, enqueueIssue, logger) {
20
+ this.config = config;
21
+ this.stores = stores;
22
+ this.enqueueIssue = enqueueIssue;
23
+ this.logger = logger;
24
+ const turnInputDispatcher = new StageTurnInputDispatcher(stores, codex, logger);
25
+ const agentActivity = new StageAgentActivityPublisher(linearProvider, logger);
26
+ this.desiredStageRecorder = new WebhookDesiredStageRecorder(stores);
27
+ this.agentSessionHandler = new AgentSessionWebhookHandler(stores, turnInputDispatcher, agentActivity);
28
+ this.commentHandler = new CommentWebhookHandler(stores, turnInputDispatcher);
29
+ this.installationHandler = new InstallationWebhookHandler(config, stores, logger);
30
+ }
31
+ async processWebhookEvent(webhookEventId) {
32
+ const event = this.stores.webhookEvents.getWebhookEvent(webhookEventId);
33
+ if (!event) {
34
+ this.logger.warn({ webhookEventId }, "Webhook event was not found during processing");
35
+ return;
36
+ }
37
+ try {
38
+ const payload = safeJsonParse(event.payloadJson);
39
+ if (!payload) {
40
+ this.stores.webhookEvents.markWebhookProcessed(webhookEventId, "failed");
41
+ this.markEventReceiptProcessed(event.webhookId, "failed");
42
+ throw new Error(`Stored webhook payload is invalid JSON: event ${webhookEventId}`);
43
+ }
44
+ const normalized = normalizeWebhook({
45
+ webhookId: event.webhookId,
46
+ payload,
47
+ });
48
+ this.logger.info({
49
+ webhookEventId,
50
+ webhookId: event.webhookId,
51
+ eventType: normalized.eventType,
52
+ triggerEvent: normalized.triggerEvent,
53
+ issueKey: normalized.issue?.identifier,
54
+ issueId: normalized.issue?.id,
55
+ }, "Processing stored webhook event");
56
+ if (!normalized.issue) {
57
+ this.installationHandler.handle(normalized);
58
+ this.stores.webhookEvents.markWebhookProcessed(webhookEventId, "processed");
59
+ this.markEventReceiptProcessed(event.webhookId, "processed");
60
+ return;
61
+ }
62
+ const project = resolveProject(this.config, normalized.issue);
63
+ if (!project) {
64
+ this.logger.info({
65
+ webhookEventId,
66
+ webhookId: event.webhookId,
67
+ issueKey: normalized.issue.identifier,
68
+ issueId: normalized.issue.id,
69
+ teamId: normalized.issue.teamId,
70
+ teamKey: normalized.issue.teamKey,
71
+ triggerEvent: normalized.triggerEvent,
72
+ }, "Ignoring webhook because no project route matched the Linear issue");
73
+ this.stores.webhookEvents.markWebhookProcessed(webhookEventId, "processed");
74
+ this.markEventReceiptProcessed(event.webhookId, "processed");
75
+ return;
76
+ }
77
+ if (!trustedActorAllowed(project, normalized.actor)) {
78
+ this.logger.info({
79
+ webhookId: normalized.webhookId,
80
+ projectId: project.id,
81
+ triggerEvent: normalized.triggerEvent,
82
+ actorId: normalized.actor?.id,
83
+ actorName: normalized.actor?.name,
84
+ actorEmail: normalized.actor?.email,
85
+ }, "Ignoring webhook from untrusted Linear actor");
86
+ this.stores.webhookEvents.markWebhookProcessed(webhookEventId, "processed");
87
+ this.assignEventReceiptContext(event.webhookId, project.id, normalized.issue.id);
88
+ this.markEventReceiptProcessed(event.webhookId, "processed");
89
+ return;
90
+ }
91
+ this.stores.webhookEvents.assignWebhookProject(webhookEventId, project.id);
92
+ const receipt = this.ensureEventReceipt(event, project.id, normalized.issue.id);
93
+ const issueState = this.desiredStageRecorder.record(project, normalized, receipt ? { eventReceiptId: receipt.id } : undefined);
94
+ await this.agentSessionHandler.handle({
95
+ normalized,
96
+ project,
97
+ issue: issueState.issue,
98
+ desiredStage: issueState.desiredStage,
99
+ delegatedToPatchRelay: issueState.delegatedToPatchRelay,
100
+ });
101
+ await this.commentHandler.handle(normalized, project);
102
+ this.stores.webhookEvents.markWebhookProcessed(webhookEventId, "processed");
103
+ this.markEventReceiptProcessed(event.webhookId, "processed");
104
+ if (issueState.desiredStage) {
105
+ this.logger.info({
106
+ webhookEventId,
107
+ webhookId: event.webhookId,
108
+ projectId: project.id,
109
+ issueKey: normalized.issue.identifier,
110
+ issueId: normalized.issue.id,
111
+ desiredStage: issueState.desiredStage,
112
+ delegatedToPatchRelay: issueState.delegatedToPatchRelay,
113
+ }, "Recorded desired stage from webhook and enqueued issue execution");
114
+ this.enqueueIssue(project.id, normalized.issue.id);
115
+ return;
116
+ }
117
+ this.logger.info({
118
+ webhookEventId,
119
+ webhookId: event.webhookId,
120
+ projectId: project.id,
121
+ issueKey: normalized.issue.identifier,
122
+ issueId: normalized.issue.id,
123
+ triggerEvent: normalized.triggerEvent,
124
+ delegatedToPatchRelay: issueState.delegatedToPatchRelay,
125
+ }, "Processed webhook without enqueuing a new stage run");
126
+ }
127
+ catch (error) {
128
+ this.stores.webhookEvents.markWebhookProcessed(webhookEventId, "failed");
129
+ this.markEventReceiptProcessed(event.webhookId, "failed");
130
+ const err = error instanceof Error ? error : new Error(String(error));
131
+ this.logger.error({
132
+ webhookEventId,
133
+ webhookId: event.webhookId,
134
+ issueId: event.issueId,
135
+ projectId: event.projectId,
136
+ error: sanitizeDiagnosticText(err.message),
137
+ stack: err.stack,
138
+ }, "Failed to process Linear webhook event");
139
+ throw err;
140
+ }
141
+ }
142
+ assignEventReceiptContext(webhookId, projectId, linearIssueId) {
143
+ const receipt = this.lookupEventReceipt(webhookId);
144
+ if (!receipt) {
145
+ return;
146
+ }
147
+ this.stores.eventReceipts.assignEventReceiptContext(receipt.id, {
148
+ ...(projectId ? { projectId } : {}),
149
+ ...(linearIssueId ? { linearIssueId } : {}),
150
+ });
151
+ }
152
+ markEventReceiptProcessed(webhookId, status) {
153
+ const receipt = this.lookupEventReceipt(webhookId);
154
+ if (!receipt) {
155
+ return;
156
+ }
157
+ this.stores.eventReceipts.markEventReceiptProcessed(receipt.id, status);
158
+ }
159
+ lookupEventReceipt(webhookId) {
160
+ return this.stores.eventReceipts.getEventReceiptBySourceExternalId("linear-webhook", webhookId);
161
+ }
162
+ ensureEventReceipt(event, projectId, linearIssueId) {
163
+ const existing = this.lookupEventReceipt(event.webhookId);
164
+ if (existing) {
165
+ this.assignEventReceiptContext(event.webhookId, projectId, linearIssueId);
166
+ return existing;
167
+ }
168
+ const inserted = this.stores.eventReceipts.insertEventReceipt({
169
+ source: "linear-webhook",
170
+ externalId: event.webhookId,
171
+ eventType: event.eventType,
172
+ receivedAt: event.receivedAt,
173
+ acceptanceStatus: "accepted",
174
+ ...(projectId ? { projectId } : {}),
175
+ ...(linearIssueId ? { linearIssueId } : {}),
176
+ headersJson: event.headersJson,
177
+ payloadJson: event.payloadJson,
178
+ });
179
+ return this.stores.eventReceipts.getEventReceipt(inserted.id);
180
+ }
181
+ }
@@ -0,0 +1,148 @@
1
+ import { archiveWebhook } from "./webhook-archive.js";
2
+ import { normalizeWebhook } from "./webhooks.js";
3
+ import { redactSensitiveHeaders, timestampMsWithinSkew, verifyHmacSha256Hex } from "./utils.js";
4
+ export async function acceptIncomingWebhook(params) {
5
+ const receivedAt = new Date().toISOString();
6
+ const signature = typeof params.headers["linear-signature"] === "string" ? params.headers["linear-signature"] : "";
7
+ if (!verifyHmacSha256Hex(params.rawBody, params.config.linear.webhookSecret, signature)) {
8
+ params.logger.warn({ webhookId: params.webhookId }, "Rejecting webhook with invalid signature");
9
+ return { status: 401, body: { ok: false, reason: "invalid_signature" } };
10
+ }
11
+ let payload;
12
+ try {
13
+ payload = JSON.parse(params.rawBody.toString("utf8"));
14
+ }
15
+ catch {
16
+ params.logger.warn({ webhookId: params.webhookId }, "Rejecting malformed webhook payload");
17
+ return { status: 400, body: { ok: false, reason: "invalid_json" } };
18
+ }
19
+ if (!timestampMsWithinSkew(payload.webhookTimestamp, params.config.ingress.maxTimestampSkewSeconds)) {
20
+ params.logger.warn({ webhookId: params.webhookId, webhookTimestamp: payload.webhookTimestamp }, "Rejecting stale webhook payload");
21
+ return { status: 401, body: { ok: false, reason: "stale_timestamp" } };
22
+ }
23
+ let normalized;
24
+ try {
25
+ normalized = normalizeWebhook({
26
+ webhookId: params.webhookId,
27
+ payload,
28
+ });
29
+ }
30
+ catch (error) {
31
+ params.logger.warn({ webhookId: params.webhookId, error }, "Rejecting unsupported webhook payload");
32
+ return { status: 400, body: { ok: false, reason: "unsupported_payload" } };
33
+ }
34
+ const sanitizedHeaders = redactSensitiveHeaders(params.headers);
35
+ const headersJson = JSON.stringify(sanitizedHeaders);
36
+ const payloadJson = JSON.stringify(payload);
37
+ logWebhookSummary(params.logger, normalized);
38
+ await archiveAcceptedPayload({
39
+ config: params.config,
40
+ logger: params.logger,
41
+ webhookId: params.webhookId,
42
+ receivedAt,
43
+ headers: params.headers,
44
+ rawBody: params.rawBody,
45
+ payload,
46
+ });
47
+ const stored = params.stores.webhookEvents.insertWebhookEvent({
48
+ webhookId: params.webhookId,
49
+ receivedAt,
50
+ eventType: normalized.eventType,
51
+ ...(normalized.issue ? { issueId: normalized.issue.id } : {}),
52
+ headersJson,
53
+ payloadJson,
54
+ signatureValid: true,
55
+ dedupeStatus: "accepted",
56
+ });
57
+ if (!stored.inserted) {
58
+ recordEventReceipt(params.stores, {
59
+ webhookId: params.webhookId,
60
+ receivedAt,
61
+ normalized,
62
+ headersJson,
63
+ payloadJson,
64
+ });
65
+ params.logger.info({ webhookId: params.webhookId, webhookEventId: stored.id }, "Ignoring duplicate webhook delivery");
66
+ return { status: 200, body: { ok: true, duplicate: true } };
67
+ }
68
+ recordEventReceipt(params.stores, {
69
+ webhookId: params.webhookId,
70
+ receivedAt,
71
+ normalized,
72
+ headersJson,
73
+ payloadJson,
74
+ });
75
+ params.logger.info({
76
+ webhookId: params.webhookId,
77
+ webhookEventId: stored.id,
78
+ triggerEvent: normalized.triggerEvent,
79
+ issueKey: normalized.issue?.identifier,
80
+ issueId: normalized.issue?.id,
81
+ }, "Accepted Linear webhook for asynchronous processing");
82
+ return {
83
+ accepted: {
84
+ id: stored.id,
85
+ normalized,
86
+ payload,
87
+ },
88
+ status: 200,
89
+ body: { ok: true, accepted: true, webhookEventId: stored.id },
90
+ };
91
+ }
92
+ function recordEventReceipt(stores, params) {
93
+ stores.eventReceipts.insertEventReceipt({
94
+ source: "linear-webhook",
95
+ externalId: params.webhookId,
96
+ eventType: params.normalized.eventType,
97
+ receivedAt: params.receivedAt,
98
+ acceptanceStatus: "accepted",
99
+ ...(params.normalized.issue ? { linearIssueId: params.normalized.issue.id } : {}),
100
+ headersJson: params.headersJson,
101
+ payloadJson: params.payloadJson,
102
+ });
103
+ }
104
+ function logWebhookSummary(logger, normalized) {
105
+ const issueRef = normalized.issue?.identifier ?? normalized.issue?.id ?? normalized.installation?.appUserId ?? normalized.entityType;
106
+ const stateName = normalized.issue?.stateName;
107
+ const title = normalized.issue?.title;
108
+ const summary = [
109
+ `Linear webhook for ${issueRef}`,
110
+ normalized.triggerEvent,
111
+ stateName ? `to ${stateName}` : undefined,
112
+ title ? `(${title})` : undefined,
113
+ ]
114
+ .filter(Boolean)
115
+ .join(" ");
116
+ logger.info({
117
+ issueKey: normalized.issue?.identifier,
118
+ triggerEvent: normalized.triggerEvent,
119
+ state: stateName,
120
+ title,
121
+ appUserId: normalized.installation?.appUserId,
122
+ notificationType: normalized.installation?.notificationType,
123
+ }, summary);
124
+ logger.debug({
125
+ webhookId: normalized.webhookId,
126
+ eventType: normalized.eventType,
127
+ issueId: normalized.issue?.id,
128
+ }, "Webhook metadata");
129
+ }
130
+ async function archiveAcceptedPayload(params) {
131
+ if (!params.config.logging.webhookArchiveDir) {
132
+ return;
133
+ }
134
+ try {
135
+ const archivePath = await archiveWebhook({
136
+ archiveDir: params.config.logging.webhookArchiveDir,
137
+ webhookId: params.webhookId,
138
+ receivedAt: params.receivedAt,
139
+ headers: redactSensitiveHeaders(params.headers),
140
+ rawBody: params.rawBody,
141
+ payload: params.payload,
142
+ });
143
+ params.logger.debug({ webhookId: params.webhookId, archivePath }, "Archived webhook to local file");
144
+ }
145
+ catch (error) {
146
+ params.logger.error({ webhookId: params.webhookId, error }, "Failed to archive webhook to local file");
147
+ }
148
+ }
@@ -0,0 +1,139 @@
1
+ import { IssueQueryService } from "./issue-query-service.js";
2
+ import { LinearOAuthService } from "./linear-oauth-service.js";
3
+ import { ServiceRuntime } from "./service-runtime.js";
4
+ import { ServiceStageFinalizer } from "./service-stage-finalizer.js";
5
+ import { ServiceStageRunner } from "./service-stage-runner.js";
6
+ import { ServiceWebhookProcessor } from "./service-webhook-processor.js";
7
+ import { acceptIncomingWebhook } from "./service-webhooks.js";
8
+ function createServiceStores(db) {
9
+ return {
10
+ webhookEvents: db.webhookEvents,
11
+ eventReceipts: db.eventReceipts,
12
+ issueControl: db.issueControl,
13
+ workspaceOwnership: db.workspaceOwnership,
14
+ runLeases: db.runLeases,
15
+ obligations: db.obligations,
16
+ issueWorkflows: db.issueWorkflows,
17
+ stageEvents: db.stageEvents,
18
+ linearInstallations: db.linearInstallations,
19
+ };
20
+ }
21
+ function createReadyIssueSource(stores) {
22
+ return {
23
+ listIssuesReadyForExecution: () => stores.issueControl.listIssueControlsReadyForLaunch().map((issue) => ({
24
+ projectId: issue.projectId,
25
+ linearIssueId: issue.linearIssueId,
26
+ })),
27
+ };
28
+ }
29
+ // PatchRelayService wires together the harness layers:
30
+ // - integration: webhook intake, OAuth, Linear client access
31
+ // - coordination: runtime queueing, stage launch, completion, reconciliation
32
+ // - execution: Codex app-server and worktree-backed stage runs
33
+ // - observability: issue/report query surfaces
34
+ export class PatchRelayService {
35
+ config;
36
+ db;
37
+ codex;
38
+ logger;
39
+ linearProvider;
40
+ stageRunner;
41
+ stageFinalizer;
42
+ webhookProcessor;
43
+ oauthService;
44
+ queryService;
45
+ runtime;
46
+ constructor(config, db, codex, linearProvider, logger) {
47
+ this.config = config;
48
+ this.db = db;
49
+ this.codex = codex;
50
+ this.logger = logger;
51
+ this.linearProvider = toLinearClientProvider(linearProvider);
52
+ const stores = createServiceStores(db);
53
+ this.stageRunner = new ServiceStageRunner(config, stores, codex, this.linearProvider, logger, (fn) => db.connection.transaction(fn)());
54
+ let enqueueIssue = () => {
55
+ throw new Error("Service runtime enqueueIssue is not initialized");
56
+ };
57
+ this.stageFinalizer = new ServiceStageFinalizer(config, stores, codex, this.linearProvider, (projectId, issueId) => enqueueIssue(projectId, issueId), logger, (fn) => db.connection.transaction(fn)());
58
+ this.webhookProcessor = new ServiceWebhookProcessor(config, stores, this.linearProvider, codex, (projectId, issueId) => enqueueIssue(projectId, issueId), logger);
59
+ const runtime = new ServiceRuntime(codex, logger, this.stageFinalizer, createReadyIssueSource(stores), this.webhookProcessor, {
60
+ processIssue: (item) => this.stageRunner.run(item),
61
+ });
62
+ enqueueIssue = (projectId, issueId) => runtime.enqueueIssue(projectId, issueId);
63
+ this.oauthService = new LinearOAuthService(config, stores, logger);
64
+ this.queryService = new IssueQueryService(stores, codex, this.stageFinalizer);
65
+ this.runtime = runtime;
66
+ this.codex.on("notification", (notification) => {
67
+ void this.stageFinalizer.handleCodexNotification(notification);
68
+ });
69
+ }
70
+ async start() {
71
+ await this.runtime.start();
72
+ }
73
+ stop() {
74
+ this.runtime.stop();
75
+ }
76
+ createLinearOAuthStart(params) {
77
+ return this.oauthService.createStart(params);
78
+ }
79
+ async completeLinearOAuth(params) {
80
+ return await this.oauthService.complete(params);
81
+ }
82
+ getLinearOAuthStateStatus(state) {
83
+ return this.oauthService.getStateStatus(state);
84
+ }
85
+ listLinearInstallations() {
86
+ return this.oauthService.listInstallations();
87
+ }
88
+ getReadiness() {
89
+ return this.runtime.getReadiness();
90
+ }
91
+ async acceptWebhook(params) {
92
+ const result = await acceptIncomingWebhook({
93
+ config: this.config,
94
+ stores: {
95
+ webhookEvents: this.db.webhookEvents,
96
+ eventReceipts: this.db.eventReceipts,
97
+ },
98
+ logger: this.logger,
99
+ webhookId: params.webhookId,
100
+ headers: params.headers,
101
+ rawBody: params.rawBody,
102
+ });
103
+ if (result.accepted) {
104
+ this.runtime.enqueueWebhookEvent(result.accepted.id);
105
+ }
106
+ return {
107
+ status: result.status,
108
+ body: result.body,
109
+ };
110
+ }
111
+ async processWebhookEvent(webhookEventId) {
112
+ await this.webhookProcessor.processWebhookEvent(webhookEventId);
113
+ }
114
+ async processIssue(item) {
115
+ await this.stageRunner.run(item);
116
+ }
117
+ async getIssueOverview(issueKey) {
118
+ return await this.queryService.getIssueOverview(issueKey);
119
+ }
120
+ async getIssueReport(issueKey) {
121
+ return await this.queryService.getIssueReport(issueKey);
122
+ }
123
+ async getStageEvents(issueKey, stageRunId) {
124
+ return await this.queryService.getStageEvents(issueKey, stageRunId);
125
+ }
126
+ async getActiveStageStatus(issueKey) {
127
+ return await this.queryService.getActiveStageStatus(issueKey);
128
+ }
129
+ }
130
+ function toLinearClientProvider(linear) {
131
+ if (linear && typeof linear.forProject === "function") {
132
+ return linear;
133
+ }
134
+ return {
135
+ async forProject() {
136
+ return linear;
137
+ },
138
+ };
139
+ }
@@ -0,0 +1,33 @@
1
+ export class StageAgentActivityPublisher {
2
+ linearProvider;
3
+ logger;
4
+ constructor(linearProvider, logger) {
5
+ this.linearProvider = linearProvider;
6
+ this.logger = logger;
7
+ }
8
+ async publishForSession(projectId, agentSessionId, content) {
9
+ const linear = await this.linearProvider.forProject(projectId);
10
+ if (!linear) {
11
+ return;
12
+ }
13
+ try {
14
+ await linear.createAgentActivity({
15
+ agentSessionId,
16
+ content,
17
+ ephemeral: content.type === "thought" || content.type === "action",
18
+ });
19
+ }
20
+ catch (error) {
21
+ this.logger.warn({
22
+ agentSessionId,
23
+ error: error instanceof Error ? error.message : String(error),
24
+ }, "Failed to publish Linear agent activity");
25
+ }
26
+ }
27
+ async publishForIssue(issue, content) {
28
+ if (!issue.activeAgentSessionId) {
29
+ return;
30
+ }
31
+ await this.publishForSession(issue.projectId, issue.activeAgentSessionId, content);
32
+ }
33
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,92 @@
1
+ import { buildStageFailedComment, resolveActiveLinearState, resolveFallbackLinearState, resolveWorkflowLabelCleanup, } from "./linear-workflow.js";
2
+ function normalizeStateName(value) {
3
+ const trimmed = value?.trim();
4
+ return trimmed ? trimmed.toLowerCase() : undefined;
5
+ }
6
+ export async function syncFailedStageToLinear(params) {
7
+ const linear = await params.linearProvider.forProject(params.stageRun.projectId);
8
+ if (!linear) {
9
+ return;
10
+ }
11
+ const fallbackState = resolveFallbackLinearState(params.project, params.stageRun.stage);
12
+ let shouldWriteFailureState = true;
13
+ if (params.requireActiveLinearStateMatch) {
14
+ const activeState = resolveActiveLinearState(params.project, params.stageRun.stage);
15
+ if (!activeState) {
16
+ shouldWriteFailureState = false;
17
+ }
18
+ else {
19
+ try {
20
+ const linearIssue = await linear.getIssue(params.stageRun.linearIssueId);
21
+ shouldWriteFailureState = normalizeStateName(linearIssue.stateName) === normalizeStateName(activeState);
22
+ }
23
+ catch {
24
+ shouldWriteFailureState = false;
25
+ }
26
+ }
27
+ }
28
+ const cleanup = resolveWorkflowLabelCleanup(params.project);
29
+ if (cleanup.remove.length > 0) {
30
+ await linear
31
+ .updateIssueLabels({
32
+ issueId: params.stageRun.linearIssueId,
33
+ removeNames: cleanup.remove,
34
+ })
35
+ .catch(() => undefined);
36
+ }
37
+ if (!shouldWriteFailureState) {
38
+ return;
39
+ }
40
+ if (fallbackState) {
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({
44
+ projectId: params.stageRun.projectId,
45
+ linearIssueId: params.stageRun.linearIssueId,
46
+ currentLinearState: fallbackState,
47
+ statusCommentId: params.issue.statusCommentId ?? null,
48
+ lifecycleStatus: "failed",
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
+ }
58
+ const result = await linear
59
+ .upsertIssueComment({
60
+ issueId: params.stageRun.linearIssueId,
61
+ ...(params.issue.statusCommentId ? { commentId: params.issue.statusCommentId } : {}),
62
+ body: buildStageFailedComment({
63
+ issue: params.issue,
64
+ stageRun: params.stageRun,
65
+ message: params.message,
66
+ ...(fallbackState ? { fallbackState } : {}),
67
+ ...(params.mode ? { mode: params.mode } : {}),
68
+ }),
69
+ })
70
+ .catch(() => undefined);
71
+ 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
+ });
80
+ }
81
+ if (params.issue.activeAgentSessionId) {
82
+ await linear
83
+ .createAgentActivity({
84
+ agentSessionId: params.issue.activeAgentSessionId,
85
+ content: {
86
+ type: "error",
87
+ body: `PatchRelay could not complete the ${params.stageRun.stage} workflow: ${params.message}`,
88
+ },
89
+ })
90
+ .catch(() => undefined);
91
+ }
92
+ }
@@ -0,0 +1,54 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { resolveWorkflowById } from "./workflow-policy.js";
4
+ function slugify(value) {
5
+ return value
6
+ .toLowerCase()
7
+ .replace(/[^a-z0-9]+/g, "-")
8
+ .replace(/^-+|-+$/g, "")
9
+ .slice(0, 60);
10
+ }
11
+ function sanitizePathSegment(value) {
12
+ return value.replace(/[^a-zA-Z0-9._-]+/g, "-");
13
+ }
14
+ export function isCodexThreadId(value) {
15
+ if (!value) {
16
+ return false;
17
+ }
18
+ return !value.startsWith("missing-thread-") && !value.startsWith("launch-failed-");
19
+ }
20
+ export function buildStageLaunchPlan(project, issue, stage) {
21
+ const workflow = resolveWorkflowById(project, stage);
22
+ if (!workflow) {
23
+ throw new Error(`Workflow "${stage}" is not configured for project ${project.id}`);
24
+ }
25
+ const issueRef = sanitizePathSegment(issue.issueKey ?? issue.linearIssueId);
26
+ const slug = issue.title ? slugify(issue.title) : "";
27
+ const branchSuffix = slug ? `${issueRef}-${slug}` : issueRef;
28
+ return {
29
+ branchName: `${project.branchPrefix}/${branchSuffix}`,
30
+ worktreePath: path.join(project.worktreeRoot, issueRef),
31
+ workflowFile: workflow.workflowFile,
32
+ stage,
33
+ prompt: buildStagePrompt(issue, workflow.id, workflow.whenState, workflow.workflowFile),
34
+ };
35
+ }
36
+ export function buildStagePrompt(issue, stage, triggerState, workflowFile) {
37
+ const workflowBody = existsSync(workflowFile) ? readFileSync(workflowFile, "utf8").trim() : "";
38
+ return [
39
+ `Issue: ${issue.issueKey ?? issue.linearIssueId}`,
40
+ issue.title ? `Title: ${issue.title}` : undefined,
41
+ issue.issueUrl ? `Linear URL: ${issue.issueUrl}` : undefined,
42
+ issue.currentLinearState ? `Current Linear State: ${issue.currentLinearState}` : undefined,
43
+ `Workflow: ${stage}`,
44
+ `Triggered By State: ${triggerState}`,
45
+ "",
46
+ "Operate only inside the prepared worktree for this issue. Continue the issue lifecycle in this workspace.",
47
+ "Capture a crisp summary of what you did, what changed, and what remains blocked so PatchRelay can publish a read-only report.",
48
+ "",
49
+ `Workflow File: ${path.basename(workflowFile)}`,
50
+ workflowBody,
51
+ ]
52
+ .filter(Boolean)
53
+ .join("\n");
54
+ }