patchrelay 0.4.1 → 0.6.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.
- package/dist/build-info.json +3 -3
- package/dist/cli/args.js +35 -7
- package/dist/cli/commands/feed.js +53 -0
- package/dist/cli/commands/issues.js +9 -7
- package/dist/cli/data.js +82 -2
- package/dist/cli/formatters/text.js +53 -4
- package/dist/cli/index.js +101 -5
- package/dist/db/migrations.js +15 -0
- package/dist/db/operator-feed-store.js +72 -0
- package/dist/db.js +3 -0
- package/dist/http.js +52 -0
- package/dist/operator-feed.js +85 -0
- package/dist/service-stage-finalizer.js +37 -2
- package/dist/service-stage-runner.js +43 -2
- package/dist/service-webhook-processor.js +105 -3
- package/dist/service.js +12 -3
- package/dist/stage-lifecycle-publisher.js +31 -1
- package/dist/stage-turn-input-dispatcher.js +5 -3
- package/dist/webhook-agent-session-handler.js +51 -1
- package/dist/webhook-comment-handler.js +55 -3
- package/package.json +3 -2
package/dist/http.js
CHANGED
|
@@ -220,6 +220,50 @@ export async function buildHttpServer(config, service, logger) {
|
|
|
220
220
|
});
|
|
221
221
|
}
|
|
222
222
|
if (managementRoutesEnabled) {
|
|
223
|
+
app.get("/api/feed", async (request, reply) => {
|
|
224
|
+
const limit = getPositiveIntegerQueryParam(request, "limit") ?? 50;
|
|
225
|
+
const issueKey = getQueryParam(request, "issue")?.trim() || undefined;
|
|
226
|
+
const projectId = getQueryParam(request, "project")?.trim() || undefined;
|
|
227
|
+
const feedQuery = {
|
|
228
|
+
limit,
|
|
229
|
+
...(issueKey ? { issueKey } : {}),
|
|
230
|
+
...(projectId ? { projectId } : {}),
|
|
231
|
+
};
|
|
232
|
+
if (getQueryParam(request, "follow") !== "1") {
|
|
233
|
+
return reply.send({ ok: true, events: service.listOperatorFeed(feedQuery) });
|
|
234
|
+
}
|
|
235
|
+
reply.hijack();
|
|
236
|
+
reply.raw.writeHead(200, {
|
|
237
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
238
|
+
"cache-control": "no-cache, no-transform",
|
|
239
|
+
connection: "keep-alive",
|
|
240
|
+
"x-accel-buffering": "no",
|
|
241
|
+
});
|
|
242
|
+
const writeEvent = (event) => {
|
|
243
|
+
reply.raw.write(`event: feed\n`);
|
|
244
|
+
reply.raw.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
245
|
+
};
|
|
246
|
+
for (const event of service.listOperatorFeed(feedQuery)) {
|
|
247
|
+
writeEvent(event);
|
|
248
|
+
}
|
|
249
|
+
const unsubscribe = service.subscribeOperatorFeed((event) => {
|
|
250
|
+
if (issueKey && event.issueKey !== issueKey) {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (projectId && event.projectId !== projectId) {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
writeEvent(event);
|
|
257
|
+
});
|
|
258
|
+
const keepAlive = setInterval(() => {
|
|
259
|
+
reply.raw.write(": keepalive\n\n");
|
|
260
|
+
}, 15000);
|
|
261
|
+
request.raw.on("close", () => {
|
|
262
|
+
clearInterval(keepAlive);
|
|
263
|
+
unsubscribe();
|
|
264
|
+
reply.raw.end();
|
|
265
|
+
});
|
|
266
|
+
});
|
|
223
267
|
app.get("/api/installations", async (_request, reply) => {
|
|
224
268
|
return reply.send({ ok: true, installations: service.listLinearInstallations() });
|
|
225
269
|
});
|
|
@@ -298,6 +342,14 @@ function getQueryParam(request, key) {
|
|
|
298
342
|
const value = request.query?.[key];
|
|
299
343
|
return typeof value === "string" ? value : undefined;
|
|
300
344
|
}
|
|
345
|
+
function getPositiveIntegerQueryParam(request, key) {
|
|
346
|
+
const value = getQueryParam(request, key);
|
|
347
|
+
if (!value || !/^\d+$/.test(value)) {
|
|
348
|
+
return undefined;
|
|
349
|
+
}
|
|
350
|
+
const parsed = Number(value);
|
|
351
|
+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
|
|
352
|
+
}
|
|
301
353
|
function renderOAuthResult(message) {
|
|
302
354
|
return `<!doctype html>
|
|
303
355
|
<html lang="en">
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
export class OperatorEventFeed {
|
|
2
|
+
store;
|
|
3
|
+
maxEvents;
|
|
4
|
+
persistedFallbackEvents = [];
|
|
5
|
+
listeners = new Set();
|
|
6
|
+
nextFallbackId = -1;
|
|
7
|
+
constructor(store, maxEvents = 500) {
|
|
8
|
+
this.store = store;
|
|
9
|
+
this.maxEvents = maxEvents;
|
|
10
|
+
}
|
|
11
|
+
publish(event) {
|
|
12
|
+
const fullEvent = this.persist(event);
|
|
13
|
+
for (const listener of this.listeners) {
|
|
14
|
+
listener(fullEvent);
|
|
15
|
+
}
|
|
16
|
+
return fullEvent;
|
|
17
|
+
}
|
|
18
|
+
list(options) {
|
|
19
|
+
const persisted = this.store?.list(options) ?? [];
|
|
20
|
+
const fallback = this.listFallback(options);
|
|
21
|
+
const combined = [...persisted, ...fallback].sort(compareFeedEvents);
|
|
22
|
+
const limit = options?.limit ?? 50;
|
|
23
|
+
return combined.slice(Math.max(0, combined.length - limit));
|
|
24
|
+
}
|
|
25
|
+
subscribe(listener) {
|
|
26
|
+
this.listeners.add(listener);
|
|
27
|
+
return () => {
|
|
28
|
+
this.listeners.delete(listener);
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
persist(event) {
|
|
32
|
+
const normalizedEvent = {
|
|
33
|
+
at: event.at ?? new Date().toISOString(),
|
|
34
|
+
level: event.level,
|
|
35
|
+
kind: event.kind,
|
|
36
|
+
summary: event.summary,
|
|
37
|
+
...(event.detail ? { detail: event.detail } : {}),
|
|
38
|
+
...(event.issueKey ? { issueKey: event.issueKey } : {}),
|
|
39
|
+
...(event.projectId ? { projectId: event.projectId } : {}),
|
|
40
|
+
...(event.stage ? { stage: event.stage } : {}),
|
|
41
|
+
...(event.status ? { status: event.status } : {}),
|
|
42
|
+
};
|
|
43
|
+
if (!this.store) {
|
|
44
|
+
return this.pushFallbackEvent(normalizedEvent);
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
return this.store.save(normalizedEvent);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return this.pushFallbackEvent(normalizedEvent);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
pushFallbackEvent(event) {
|
|
54
|
+
const fullEvent = {
|
|
55
|
+
id: this.nextFallbackId,
|
|
56
|
+
...event,
|
|
57
|
+
};
|
|
58
|
+
this.nextFallbackId -= 1;
|
|
59
|
+
this.persistedFallbackEvents.push(fullEvent);
|
|
60
|
+
if (this.persistedFallbackEvents.length > this.maxEvents) {
|
|
61
|
+
this.persistedFallbackEvents.shift();
|
|
62
|
+
}
|
|
63
|
+
return fullEvent;
|
|
64
|
+
}
|
|
65
|
+
listFallback(options) {
|
|
66
|
+
return this.persistedFallbackEvents.filter((event) => {
|
|
67
|
+
if (options?.afterId !== undefined && event.id <= options.afterId) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
if (options?.issueKey && event.issueKey !== options.issueKey) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
if (options?.projectId && event.projectId !== options.projectId) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
return true;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function compareFeedEvents(left, right) {
|
|
81
|
+
if (left.at !== right.at) {
|
|
82
|
+
return left.at.localeCompare(right.at);
|
|
83
|
+
}
|
|
84
|
+
return left.id - right.id;
|
|
85
|
+
}
|
|
@@ -11,20 +11,22 @@ export class ServiceStageFinalizer {
|
|
|
11
11
|
codex;
|
|
12
12
|
linearProvider;
|
|
13
13
|
enqueueIssue;
|
|
14
|
+
feed;
|
|
14
15
|
inputDispatcher;
|
|
15
16
|
lifecyclePublisher;
|
|
16
17
|
actionApplier;
|
|
17
18
|
runAtomically;
|
|
18
|
-
constructor(config, stores, codex, linearProvider, enqueueIssue, logger, runAtomically = (fn) => fn()) {
|
|
19
|
+
constructor(config, stores, codex, linearProvider, enqueueIssue, logger, feed, runAtomically = (fn) => fn()) {
|
|
19
20
|
this.config = config;
|
|
20
21
|
this.stores = stores;
|
|
21
22
|
this.codex = codex;
|
|
22
23
|
this.linearProvider = linearProvider;
|
|
23
24
|
this.enqueueIssue = enqueueIssue;
|
|
25
|
+
this.feed = feed;
|
|
24
26
|
this.runAtomically = runAtomically;
|
|
25
27
|
const lifecycleLogger = logger ?? consoleLogger();
|
|
26
28
|
this.inputDispatcher = new StageTurnInputDispatcher(stores, codex, lifecycleLogger);
|
|
27
|
-
this.lifecyclePublisher = new StageLifecyclePublisher(config, stores, linearProvider, lifecycleLogger);
|
|
29
|
+
this.lifecyclePublisher = new StageLifecyclePublisher(config, stores, linearProvider, lifecycleLogger, feed);
|
|
28
30
|
this.actionApplier = new ReconciliationActionApplier({
|
|
29
31
|
enqueueIssue,
|
|
30
32
|
deliverPendingObligations: (projectId, linearIssueId, threadId, turnId) => this.deliverPendingObligations(projectId, linearIssueId, threadId, turnId),
|
|
@@ -71,6 +73,19 @@ export class ServiceStageFinalizer {
|
|
|
71
73
|
});
|
|
72
74
|
}
|
|
73
75
|
if (notification.method === "turn/started" || notification.method.startsWith("item/")) {
|
|
76
|
+
if (notification.method === "turn/started") {
|
|
77
|
+
const issue = this.stores.issueWorkflows.getTrackedIssue(stageRun.projectId, stageRun.linearIssueId);
|
|
78
|
+
this.feed?.publish({
|
|
79
|
+
level: "info",
|
|
80
|
+
kind: "turn",
|
|
81
|
+
issueKey: issue?.issueKey,
|
|
82
|
+
projectId: stageRun.projectId,
|
|
83
|
+
stage: stageRun.stage,
|
|
84
|
+
status: "started",
|
|
85
|
+
summary: `Turn started for ${stageRun.stage}`,
|
|
86
|
+
detail: turnId ? `Turn ${turnId} is now live.` : undefined,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
74
89
|
await this.flushQueuedTurnInputs(stageRun);
|
|
75
90
|
}
|
|
76
91
|
if (notification.method !== "turn/completed") {
|
|
@@ -84,11 +99,31 @@ export class ServiceStageFinalizer {
|
|
|
84
99
|
const completedTurnId = extractTurnId(notification.params);
|
|
85
100
|
const status = resolveStageRunStatus(notification.params);
|
|
86
101
|
if (status === "failed") {
|
|
102
|
+
this.feed?.publish({
|
|
103
|
+
level: "error",
|
|
104
|
+
kind: "turn",
|
|
105
|
+
issueKey: issue.issueKey,
|
|
106
|
+
projectId: stageRun.projectId,
|
|
107
|
+
stage: stageRun.stage,
|
|
108
|
+
status: "failed",
|
|
109
|
+
summary: `Turn failed for ${stageRun.stage}`,
|
|
110
|
+
detail: completedTurnId ? `Turn ${completedTurnId} completed in a failed state.` : undefined,
|
|
111
|
+
});
|
|
87
112
|
await this.failStageRunAndSync(stageRun, issue, threadId, "Codex reported the turn completed in a failed state", {
|
|
88
113
|
...(completedTurnId ? { turnId: completedTurnId } : {}),
|
|
89
114
|
});
|
|
90
115
|
return;
|
|
91
116
|
}
|
|
117
|
+
this.feed?.publish({
|
|
118
|
+
level: "info",
|
|
119
|
+
kind: "turn",
|
|
120
|
+
issueKey: issue.issueKey,
|
|
121
|
+
projectId: stageRun.projectId,
|
|
122
|
+
stage: stageRun.stage,
|
|
123
|
+
status: "completed",
|
|
124
|
+
summary: `Turn completed for ${stageRun.stage}`,
|
|
125
|
+
detail: summarizeCurrentThread(thread).latestAgentMessage,
|
|
126
|
+
});
|
|
92
127
|
this.completeStageRun(stageRun, issue, thread, status, {
|
|
93
128
|
threadId,
|
|
94
129
|
...(completedTurnId ? { turnId: completedTurnId } : {}),
|
|
@@ -10,20 +10,22 @@ export class ServiceStageRunner {
|
|
|
10
10
|
codex;
|
|
11
11
|
linearProvider;
|
|
12
12
|
logger;
|
|
13
|
+
feed;
|
|
13
14
|
worktreeManager;
|
|
14
15
|
inputDispatcher;
|
|
15
16
|
lifecyclePublisher;
|
|
16
17
|
runAtomically;
|
|
17
|
-
constructor(config, stores, codex, linearProvider, logger, runAtomically = (fn) => fn()) {
|
|
18
|
+
constructor(config, stores, codex, linearProvider, logger, runAtomically = (fn) => fn(), feed) {
|
|
18
19
|
this.config = config;
|
|
19
20
|
this.stores = stores;
|
|
20
21
|
this.codex = codex;
|
|
21
22
|
this.linearProvider = linearProvider;
|
|
22
23
|
this.logger = logger;
|
|
24
|
+
this.feed = feed;
|
|
23
25
|
this.runAtomically = runAtomically;
|
|
24
26
|
this.worktreeManager = new WorktreeManager(config);
|
|
25
27
|
this.inputDispatcher = new StageTurnInputDispatcher(stores, codex, logger);
|
|
26
|
-
this.lifecyclePublisher = new StageLifecyclePublisher(config, stores, linearProvider, logger);
|
|
28
|
+
this.lifecyclePublisher = new StageLifecyclePublisher(config, stores, linearProvider, logger, feed);
|
|
27
29
|
}
|
|
28
30
|
async run(item) {
|
|
29
31
|
const project = this.config.projects.find((candidate) => candidate.id === item.projectId);
|
|
@@ -45,6 +47,16 @@ export class ServiceStageRunner {
|
|
|
45
47
|
return;
|
|
46
48
|
}
|
|
47
49
|
const plan = buildStageLaunchPlan(project, issue, desiredStage);
|
|
50
|
+
this.feed?.publish({
|
|
51
|
+
level: "info",
|
|
52
|
+
kind: "stage",
|
|
53
|
+
issueKey: issue.issueKey,
|
|
54
|
+
projectId: item.projectId,
|
|
55
|
+
stage: desiredStage,
|
|
56
|
+
status: "starting",
|
|
57
|
+
summary: `Starting ${desiredStage} workflow`,
|
|
58
|
+
detail: `Preparing ${plan.branchName}`,
|
|
59
|
+
});
|
|
48
60
|
const claim = this.stores.workflowCoordinator.claimStageRun({
|
|
49
61
|
projectId: item.projectId,
|
|
50
62
|
linearIssueId: item.issueId,
|
|
@@ -81,6 +93,16 @@ export class ServiceStageRunner {
|
|
|
81
93
|
error: err.message,
|
|
82
94
|
stack: err.stack,
|
|
83
95
|
}, "Failed to launch Codex stage run");
|
|
96
|
+
this.feed?.publish({
|
|
97
|
+
level: "error",
|
|
98
|
+
kind: "stage",
|
|
99
|
+
issueKey: issue.issueKey,
|
|
100
|
+
projectId: item.projectId,
|
|
101
|
+
stage: claim.stageRun.stage,
|
|
102
|
+
status: "failed",
|
|
103
|
+
summary: `Failed to launch ${claim.stageRun.stage} workflow`,
|
|
104
|
+
detail: err.message,
|
|
105
|
+
});
|
|
84
106
|
throw err;
|
|
85
107
|
}
|
|
86
108
|
this.stores.workflowCoordinator.updateStageRunThread({
|
|
@@ -111,6 +133,16 @@ export class ServiceStageRunner {
|
|
|
111
133
|
threadId: threadLaunch.threadId,
|
|
112
134
|
turnId: turn.turnId,
|
|
113
135
|
}, "Started Codex stage run");
|
|
136
|
+
this.feed?.publish({
|
|
137
|
+
level: "info",
|
|
138
|
+
kind: "stage",
|
|
139
|
+
issueKey: issue.issueKey,
|
|
140
|
+
projectId: item.projectId,
|
|
141
|
+
stage: claim.stageRun.stage,
|
|
142
|
+
status: "running",
|
|
143
|
+
summary: `Started ${claim.stageRun.stage} workflow`,
|
|
144
|
+
detail: `Turn ${turn.turnId} is running in ${plan.branchName}.`,
|
|
145
|
+
});
|
|
114
146
|
}
|
|
115
147
|
async ensureLaunchIssueMirror(project, linearIssueId, _desiredStage, _desiredWebhookId) {
|
|
116
148
|
const existing = this.stores.issueWorkflows.getTrackedIssue(project.id, linearIssueId);
|
|
@@ -158,6 +190,15 @@ export class ServiceStageRunner {
|
|
|
158
190
|
parentThreadId,
|
|
159
191
|
error: err.message,
|
|
160
192
|
}, "Falling back to a fresh Codex thread after parent thread fork failed");
|
|
193
|
+
this.feed?.publish({
|
|
194
|
+
level: "warn",
|
|
195
|
+
kind: "turn",
|
|
196
|
+
issueKey,
|
|
197
|
+
projectId,
|
|
198
|
+
status: "fallback",
|
|
199
|
+
summary: "Could not fork the previous Codex thread",
|
|
200
|
+
detail: "Starting a fresh thread instead.",
|
|
201
|
+
});
|
|
161
202
|
}
|
|
162
203
|
}
|
|
163
204
|
const thread = await this.codex.startThread({ cwd: worktreePath });
|
|
@@ -12,20 +12,22 @@ export class ServiceWebhookProcessor {
|
|
|
12
12
|
stores;
|
|
13
13
|
enqueueIssue;
|
|
14
14
|
logger;
|
|
15
|
+
feed;
|
|
15
16
|
desiredStageRecorder;
|
|
16
17
|
agentSessionHandler;
|
|
17
18
|
commentHandler;
|
|
18
19
|
installationHandler;
|
|
19
|
-
constructor(config, stores, linearProvider, codex, enqueueIssue, logger) {
|
|
20
|
+
constructor(config, stores, linearProvider, codex, enqueueIssue, logger, feed) {
|
|
20
21
|
this.config = config;
|
|
21
22
|
this.stores = stores;
|
|
22
23
|
this.enqueueIssue = enqueueIssue;
|
|
23
24
|
this.logger = logger;
|
|
25
|
+
this.feed = feed;
|
|
24
26
|
const turnInputDispatcher = new StageTurnInputDispatcher(stores, codex, logger);
|
|
25
27
|
const agentActivity = new StageAgentActivityPublisher(linearProvider, logger);
|
|
26
28
|
this.desiredStageRecorder = new WebhookDesiredStageRecorder(stores);
|
|
27
|
-
this.agentSessionHandler = new AgentSessionWebhookHandler(stores, turnInputDispatcher, agentActivity);
|
|
28
|
-
this.commentHandler = new CommentWebhookHandler(stores, turnInputDispatcher);
|
|
29
|
+
this.agentSessionHandler = new AgentSessionWebhookHandler(stores, turnInputDispatcher, agentActivity, feed);
|
|
30
|
+
this.commentHandler = new CommentWebhookHandler(stores, turnInputDispatcher, feed);
|
|
29
31
|
this.installationHandler = new InstallationWebhookHandler(config, stores, logger);
|
|
30
32
|
}
|
|
31
33
|
async processWebhookEvent(webhookEventId) {
|
|
@@ -54,6 +56,12 @@ export class ServiceWebhookProcessor {
|
|
|
54
56
|
issueId: normalized.issue?.id,
|
|
55
57
|
}, "Processing stored webhook event");
|
|
56
58
|
if (!normalized.issue) {
|
|
59
|
+
this.feed?.publish({
|
|
60
|
+
level: "info",
|
|
61
|
+
kind: "webhook",
|
|
62
|
+
status: normalized.triggerEvent,
|
|
63
|
+
summary: `Received ${normalized.triggerEvent} webhook`,
|
|
64
|
+
});
|
|
57
65
|
this.installationHandler.handle(normalized);
|
|
58
66
|
this.stores.webhookEvents.markWebhookProcessed(webhookEventId, "processed");
|
|
59
67
|
this.markEventReceiptProcessed(event.webhookId, "processed");
|
|
@@ -61,6 +69,14 @@ export class ServiceWebhookProcessor {
|
|
|
61
69
|
}
|
|
62
70
|
const project = resolveProject(this.config, normalized.issue);
|
|
63
71
|
if (!project) {
|
|
72
|
+
this.feed?.publish({
|
|
73
|
+
level: "warn",
|
|
74
|
+
kind: "webhook",
|
|
75
|
+
issueKey: normalized.issue.identifier,
|
|
76
|
+
status: "ignored",
|
|
77
|
+
summary: "Ignored webhook with no matching project route",
|
|
78
|
+
detail: normalized.triggerEvent,
|
|
79
|
+
});
|
|
64
80
|
this.logger.info({
|
|
65
81
|
webhookEventId,
|
|
66
82
|
webhookId: event.webhookId,
|
|
@@ -75,6 +91,15 @@ export class ServiceWebhookProcessor {
|
|
|
75
91
|
return;
|
|
76
92
|
}
|
|
77
93
|
if (!trustedActorAllowed(project, normalized.actor)) {
|
|
94
|
+
this.feed?.publish({
|
|
95
|
+
level: "warn",
|
|
96
|
+
kind: "webhook",
|
|
97
|
+
issueKey: normalized.issue.identifier,
|
|
98
|
+
projectId: project.id,
|
|
99
|
+
status: "ignored",
|
|
100
|
+
summary: "Ignored webhook from an untrusted actor",
|
|
101
|
+
detail: normalized.actor?.name ?? normalized.actor?.email ?? normalized.triggerEvent,
|
|
102
|
+
});
|
|
78
103
|
this.logger.info({
|
|
79
104
|
webhookId: normalized.webhookId,
|
|
80
105
|
projectId: project.id,
|
|
@@ -91,6 +116,18 @@ export class ServiceWebhookProcessor {
|
|
|
91
116
|
this.stores.webhookEvents.assignWebhookProject(webhookEventId, project.id);
|
|
92
117
|
const receipt = this.ensureEventReceipt(event, project.id, normalized.issue.id);
|
|
93
118
|
const issueState = this.desiredStageRecorder.record(project, normalized, receipt ? { eventReceiptId: receipt.id } : undefined);
|
|
119
|
+
const observation = describeWebhookObservation(normalized, issueState.delegatedToPatchRelay);
|
|
120
|
+
if (observation) {
|
|
121
|
+
this.feed?.publish({
|
|
122
|
+
level: "info",
|
|
123
|
+
kind: observation.kind,
|
|
124
|
+
issueKey: normalized.issue.identifier,
|
|
125
|
+
projectId: project.id,
|
|
126
|
+
...(observation.status ? { status: observation.status } : {}),
|
|
127
|
+
summary: observation.summary,
|
|
128
|
+
...(observation.detail ? { detail: observation.detail } : {}),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
94
131
|
await this.agentSessionHandler.handle({
|
|
95
132
|
normalized,
|
|
96
133
|
project,
|
|
@@ -102,6 +139,16 @@ export class ServiceWebhookProcessor {
|
|
|
102
139
|
this.stores.webhookEvents.markWebhookProcessed(webhookEventId, "processed");
|
|
103
140
|
this.markEventReceiptProcessed(event.webhookId, "processed");
|
|
104
141
|
if (issueState.desiredStage) {
|
|
142
|
+
this.feed?.publish({
|
|
143
|
+
level: "info",
|
|
144
|
+
kind: "stage",
|
|
145
|
+
issueKey: normalized.issue.identifier,
|
|
146
|
+
projectId: project.id,
|
|
147
|
+
stage: issueState.desiredStage,
|
|
148
|
+
status: "queued",
|
|
149
|
+
summary: `Queued ${issueState.desiredStage} workflow`,
|
|
150
|
+
detail: `Triggered by ${normalized.triggerEvent}${normalized.issue.stateName ? ` from ${normalized.issue.stateName}` : ""}.`,
|
|
151
|
+
});
|
|
105
152
|
this.logger.info({
|
|
106
153
|
webhookEventId,
|
|
107
154
|
webhookId: event.webhookId,
|
|
@@ -128,6 +175,14 @@ export class ServiceWebhookProcessor {
|
|
|
128
175
|
this.stores.webhookEvents.markWebhookProcessed(webhookEventId, "failed");
|
|
129
176
|
this.markEventReceiptProcessed(event.webhookId, "failed");
|
|
130
177
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
178
|
+
this.feed?.publish({
|
|
179
|
+
level: "error",
|
|
180
|
+
kind: "webhook",
|
|
181
|
+
projectId: event.projectId ?? undefined,
|
|
182
|
+
status: "failed",
|
|
183
|
+
summary: "Failed to process webhook",
|
|
184
|
+
detail: sanitizeDiagnosticText(err.message),
|
|
185
|
+
});
|
|
131
186
|
this.logger.error({
|
|
132
187
|
webhookEventId,
|
|
133
188
|
webhookId: event.webhookId,
|
|
@@ -179,3 +234,50 @@ export class ServiceWebhookProcessor {
|
|
|
179
234
|
return this.stores.eventReceipts.getEventReceipt(inserted.id);
|
|
180
235
|
}
|
|
181
236
|
}
|
|
237
|
+
function describeWebhookObservation(normalized, delegatedToPatchRelay) {
|
|
238
|
+
switch (normalized.triggerEvent) {
|
|
239
|
+
case "delegateChanged":
|
|
240
|
+
return delegatedToPatchRelay
|
|
241
|
+
? {
|
|
242
|
+
kind: "agent",
|
|
243
|
+
status: "delegated",
|
|
244
|
+
summary: "Delegated to PatchRelay",
|
|
245
|
+
detail: normalized.issue?.stateName ? `Current Linear state: ${normalized.issue.stateName}.` : undefined,
|
|
246
|
+
}
|
|
247
|
+
: {
|
|
248
|
+
kind: "agent",
|
|
249
|
+
status: "undelegated",
|
|
250
|
+
summary: "Delegation moved away from PatchRelay",
|
|
251
|
+
};
|
|
252
|
+
case "agentSessionCreated":
|
|
253
|
+
return {
|
|
254
|
+
kind: "agent",
|
|
255
|
+
status: delegatedToPatchRelay ? "session" : "mention",
|
|
256
|
+
summary: delegatedToPatchRelay ? "Opened a delegated agent session" : "Mentioned PatchRelay in Linear",
|
|
257
|
+
detail: normalized.agentSession?.promptBody ?? normalized.agentSession?.promptContext,
|
|
258
|
+
};
|
|
259
|
+
case "agentPrompted":
|
|
260
|
+
return {
|
|
261
|
+
kind: "agent",
|
|
262
|
+
status: "prompted",
|
|
263
|
+
summary: "Received follow-up agent instructions",
|
|
264
|
+
detail: normalized.agentSession?.promptBody ?? normalized.agentSession?.promptContext,
|
|
265
|
+
};
|
|
266
|
+
case "commentCreated":
|
|
267
|
+
case "commentUpdated":
|
|
268
|
+
return {
|
|
269
|
+
kind: "comment",
|
|
270
|
+
status: "received",
|
|
271
|
+
summary: "Received a Linear comment",
|
|
272
|
+
detail: normalized.comment?.userName ?? normalized.comment?.body,
|
|
273
|
+
};
|
|
274
|
+
case "statusChanged":
|
|
275
|
+
return {
|
|
276
|
+
kind: "webhook",
|
|
277
|
+
status: "status_changed",
|
|
278
|
+
summary: normalized.issue?.stateName ? `Linear state changed to ${normalized.issue.stateName}` : "Linear state changed",
|
|
279
|
+
};
|
|
280
|
+
default:
|
|
281
|
+
return undefined;
|
|
282
|
+
}
|
|
283
|
+
}
|
package/dist/service.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { IssueQueryService } from "./issue-query-service.js";
|
|
2
2
|
import { LinearOAuthService } from "./linear-oauth-service.js";
|
|
3
|
+
import { OperatorEventFeed } from "./operator-feed.js";
|
|
3
4
|
import { ServiceRuntime } from "./service-runtime.js";
|
|
4
5
|
import { ServiceStageFinalizer } from "./service-stage-finalizer.js";
|
|
5
6
|
import { ServiceStageRunner } from "./service-stage-runner.js";
|
|
@@ -44,19 +45,21 @@ export class PatchRelayService {
|
|
|
44
45
|
oauthService;
|
|
45
46
|
queryService;
|
|
46
47
|
runtime;
|
|
48
|
+
feed;
|
|
47
49
|
constructor(config, db, codex, linearProvider, logger) {
|
|
48
50
|
this.config = config;
|
|
49
51
|
this.db = db;
|
|
50
52
|
this.codex = codex;
|
|
51
53
|
this.logger = logger;
|
|
52
54
|
this.linearProvider = toLinearClientProvider(linearProvider);
|
|
55
|
+
this.feed = new OperatorEventFeed(db.operatorFeed);
|
|
53
56
|
const stores = createServiceStores(db);
|
|
54
|
-
this.stageRunner = new ServiceStageRunner(config, stores, codex, this.linearProvider, logger, (fn) => db.connection.transaction(fn)());
|
|
57
|
+
this.stageRunner = new ServiceStageRunner(config, stores, codex, this.linearProvider, logger, (fn) => db.connection.transaction(fn)(), this.feed);
|
|
55
58
|
let enqueueIssue = () => {
|
|
56
59
|
throw new Error("Service runtime enqueueIssue is not initialized");
|
|
57
60
|
};
|
|
58
|
-
this.stageFinalizer = new ServiceStageFinalizer(config, stores, codex, this.linearProvider, (projectId, issueId) => enqueueIssue(projectId, issueId), logger, (fn) => db.connection.transaction(fn)());
|
|
59
|
-
this.webhookProcessor = new ServiceWebhookProcessor(config, stores, this.linearProvider, codex, (projectId, issueId) => enqueueIssue(projectId, issueId), logger);
|
|
61
|
+
this.stageFinalizer = new ServiceStageFinalizer(config, stores, codex, this.linearProvider, (projectId, issueId) => enqueueIssue(projectId, issueId), logger, this.feed, (fn) => db.connection.transaction(fn)());
|
|
62
|
+
this.webhookProcessor = new ServiceWebhookProcessor(config, stores, this.linearProvider, codex, (projectId, issueId) => enqueueIssue(projectId, issueId), logger, this.feed);
|
|
60
63
|
const runtime = new ServiceRuntime(codex, logger, this.stageFinalizer, createReadyIssueSource(stores), this.webhookProcessor, {
|
|
61
64
|
processIssue: (item) => this.stageRunner.run(item),
|
|
62
65
|
});
|
|
@@ -89,6 +92,12 @@ export class PatchRelayService {
|
|
|
89
92
|
getReadiness() {
|
|
90
93
|
return this.runtime.getReadiness();
|
|
91
94
|
}
|
|
95
|
+
listOperatorFeed(options) {
|
|
96
|
+
return this.feed.list(options);
|
|
97
|
+
}
|
|
98
|
+
subscribeOperatorFeed(listener) {
|
|
99
|
+
return this.feed.subscribe(listener);
|
|
100
|
+
}
|
|
92
101
|
async acceptWebhook(params) {
|
|
93
102
|
const result = await acceptIncomingWebhook({
|
|
94
103
|
config: this.config,
|
|
@@ -5,11 +5,13 @@ export class StageLifecyclePublisher {
|
|
|
5
5
|
stores;
|
|
6
6
|
linearProvider;
|
|
7
7
|
logger;
|
|
8
|
-
|
|
8
|
+
feed;
|
|
9
|
+
constructor(config, stores, linearProvider, logger, feed) {
|
|
9
10
|
this.config = config;
|
|
10
11
|
this.stores = stores;
|
|
11
12
|
this.linearProvider = linearProvider;
|
|
12
13
|
this.logger = logger;
|
|
14
|
+
this.feed = feed;
|
|
13
15
|
}
|
|
14
16
|
async markStageActive(project, issue, stageRun) {
|
|
15
17
|
const activeState = resolveActiveLinearState(project, stageRun.stage);
|
|
@@ -99,6 +101,15 @@ export class StageLifecyclePublisher {
|
|
|
99
101
|
async publishStageCompletion(stageRun, enqueueIssue) {
|
|
100
102
|
const refreshedIssue = this.stores.issueWorkflows.getTrackedIssue(stageRun.projectId, stageRun.linearIssueId);
|
|
101
103
|
if (refreshedIssue?.desiredStage) {
|
|
104
|
+
this.feed?.publish({
|
|
105
|
+
level: "info",
|
|
106
|
+
kind: "stage",
|
|
107
|
+
issueKey: refreshedIssue.issueKey,
|
|
108
|
+
projectId: refreshedIssue.projectId,
|
|
109
|
+
stage: stageRun.stage,
|
|
110
|
+
status: "queued",
|
|
111
|
+
summary: `Completed ${stageRun.stage} workflow and queued ${refreshedIssue.desiredStage}`,
|
|
112
|
+
});
|
|
102
113
|
await this.publishAgentCompletion(refreshedIssue, {
|
|
103
114
|
type: "thought",
|
|
104
115
|
body: `The ${stageRun.stage} workflow finished. PatchRelay is preparing the next requested workflow.`,
|
|
@@ -133,6 +144,16 @@ export class StageLifecyclePublisher {
|
|
|
133
144
|
}),
|
|
134
145
|
});
|
|
135
146
|
this.stores.workflowCoordinator.setIssueStatusComment(stageRun.projectId, stageRun.linearIssueId, result.id);
|
|
147
|
+
this.feed?.publish({
|
|
148
|
+
level: "info",
|
|
149
|
+
kind: "stage",
|
|
150
|
+
issueKey: refreshedIssue.issueKey,
|
|
151
|
+
projectId: refreshedIssue.projectId,
|
|
152
|
+
stage: stageRun.stage,
|
|
153
|
+
status: "handoff",
|
|
154
|
+
summary: `Completed ${stageRun.stage} workflow`,
|
|
155
|
+
detail: `Waiting for a Linear state change or follow-up input while the issue remains in ${activeState}.`,
|
|
156
|
+
});
|
|
136
157
|
await this.publishAgentCompletion(refreshedIssue, {
|
|
137
158
|
type: "elicitation",
|
|
138
159
|
body: `PatchRelay finished the ${stageRun.stage} workflow. Move the issue to its next workflow state or leave a follow-up prompt to continue.`,
|
|
@@ -158,6 +179,15 @@ export class StageLifecyclePublisher {
|
|
|
158
179
|
}
|
|
159
180
|
}
|
|
160
181
|
if (refreshedIssue) {
|
|
182
|
+
this.feed?.publish({
|
|
183
|
+
level: "info",
|
|
184
|
+
kind: "stage",
|
|
185
|
+
issueKey: refreshedIssue.issueKey,
|
|
186
|
+
projectId: refreshedIssue.projectId,
|
|
187
|
+
stage: stageRun.stage,
|
|
188
|
+
status: "completed",
|
|
189
|
+
summary: `Completed ${stageRun.stage} workflow`,
|
|
190
|
+
});
|
|
161
191
|
await this.publishAgentCompletion(refreshedIssue, {
|
|
162
192
|
type: "response",
|
|
163
193
|
body: `PatchRelay finished the ${stageRun.stage} workflow.`,
|
|
@@ -24,14 +24,15 @@ export class StageTurnInputDispatcher {
|
|
|
24
24
|
}
|
|
25
25
|
async flush(stageRun, options) {
|
|
26
26
|
if (!stageRun.threadId || !stageRun.turnId) {
|
|
27
|
-
return { deliveredInputIds: [], deliveredObligationIds: [], deliveredCount: 0 };
|
|
27
|
+
return { deliveredInputIds: [], deliveredObligationIds: [], deliveredCount: 0, failedObligationIds: [] };
|
|
28
28
|
}
|
|
29
29
|
const issueControl = this.inputs.issueControl.getIssueControl(stageRun.projectId, stageRun.linearIssueId);
|
|
30
30
|
if (!issueControl?.activeRunLeaseId) {
|
|
31
|
-
return { deliveredInputIds: [], deliveredObligationIds: [], deliveredCount: 0 };
|
|
31
|
+
return { deliveredInputIds: [], deliveredObligationIds: [], deliveredCount: 0, failedObligationIds: [] };
|
|
32
32
|
}
|
|
33
33
|
const deliveredInputIds = [];
|
|
34
34
|
const deliveredObligationIds = [];
|
|
35
|
+
const failedObligationIds = [];
|
|
35
36
|
let deliveredCount = 0;
|
|
36
37
|
const obligationQuery = options?.retryInProgress ? { includeInProgress: true } : undefined;
|
|
37
38
|
for (const obligation of this.listPendingInputObligations(stageRun.projectId, stageRun.linearIssueId, issueControl.activeRunLeaseId, obligationQuery)) {
|
|
@@ -76,6 +77,7 @@ export class StageTurnInputDispatcher {
|
|
|
76
77
|
}
|
|
77
78
|
catch (error) {
|
|
78
79
|
this.inputs.obligations.markObligationStatus(obligation.id, "pending", error instanceof Error ? error.message : String(error));
|
|
80
|
+
failedObligationIds.push(obligation.id);
|
|
79
81
|
this.logger.warn({
|
|
80
82
|
issueKey: options?.issueKey,
|
|
81
83
|
threadId: stageRun.threadId,
|
|
@@ -87,7 +89,7 @@ export class StageTurnInputDispatcher {
|
|
|
87
89
|
break;
|
|
88
90
|
}
|
|
89
91
|
}
|
|
90
|
-
return { deliveredInputIds, deliveredObligationIds, deliveredCount };
|
|
92
|
+
return { deliveredInputIds, deliveredObligationIds, deliveredCount, failedObligationIds };
|
|
91
93
|
}
|
|
92
94
|
listPendingInputObligations(projectId, linearIssueId, activeRunLeaseId, options) {
|
|
93
95
|
const query = options?.includeInProgress
|