patchrelay 0.7.5 → 0.7.7
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/README.md +5 -5
- package/dist/agent-session-plan.js +34 -0
- package/dist/agent-session-presentation.js +22 -0
- package/dist/build-info.json +3 -3
- package/dist/config.js +1 -1
- package/dist/http.js +180 -0
- package/dist/issue-query-service.js +15 -0
- package/dist/linear-client.js +26 -0
- package/dist/project-resolution.js +0 -6
- package/dist/public-agent-session-status.js +77 -0
- package/dist/service-queue.js +7 -2
- package/dist/service-runtime.js +2 -2
- package/dist/service-stage-runner.js +4 -2
- package/dist/service-webhook-processor.js +1 -1
- package/dist/service.js +42 -1
- package/dist/stage-agent-activity-publisher.js +27 -1
- package/dist/stage-failure.js +37 -23
- package/dist/stage-lifecycle-publisher.js +67 -25
- package/dist/webhook-agent-session-handler.js +17 -4
- package/dist/webhook-desired-stage-recorder.js +9 -20
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ PatchRelay is the system around the model:
|
|
|
11
11
|
- issue-to-repo routing
|
|
12
12
|
- issue worktree and branch lifecycle
|
|
13
13
|
- stage orchestration and thread continuity
|
|
14
|
-
-
|
|
14
|
+
- native Linear agent input forwarding into active runs
|
|
15
15
|
- read-only inspection and stage reporting
|
|
16
16
|
|
|
17
17
|
If you want Codex to work inside your real repos with your real tools, secrets, SSH access, and deployment surface, PatchRelay is the harness that makes that loop reliable.
|
|
@@ -22,7 +22,7 @@ If you want Codex to work inside your real repos with your real tools, secrets,
|
|
|
22
22
|
- Use your existing machine, repos, secrets, SSH config, shell tools, and deployment access.
|
|
23
23
|
- Keep deterministic workflow logic outside the model: routing, staging, worktree ownership, and reporting.
|
|
24
24
|
- Choose the Codex approval and sandbox settings that match your risk tolerance.
|
|
25
|
-
- Let Linear drive the loop through delegation,
|
|
25
|
+
- Let Linear drive the loop through delegation, mentions, and workflow stages.
|
|
26
26
|
- Drop into the exact issue worktree and resume control manually when needed.
|
|
27
27
|
|
|
28
28
|
## What PatchRelay Owns
|
|
@@ -34,7 +34,7 @@ PatchRelay does the deterministic harness work that you do not want to re-implem
|
|
|
34
34
|
- creates and reuses one durable worktree and branch per issue lifecycle
|
|
35
35
|
- starts or forks Codex threads for the workflows you bind to Linear states
|
|
36
36
|
- persists enough state to correlate the Linear issue, local workspace, stage run, and Codex thread
|
|
37
|
-
- reports progress back to Linear and forwards follow-up
|
|
37
|
+
- reports progress back to Linear and forwards follow-up agent input into active runs
|
|
38
38
|
- exposes CLI and optional read-only inspection surfaces so operators can understand what happened
|
|
39
39
|
|
|
40
40
|
## System Layers
|
|
@@ -77,7 +77,7 @@ For the exact OAuth app settings and webhook categories, use the Linear onboardi
|
|
|
77
77
|
2. PatchRelay verifies the webhook and routes the issue to the right local project.
|
|
78
78
|
3. Delegated issues create or reuse the issue worktree and launch the matching workflow through `codex app-server`.
|
|
79
79
|
4. PatchRelay persists thread ids, run state, and observations so the work stays inspectable and resumable.
|
|
80
|
-
5. Mentions stay conversational, while delegated sessions and
|
|
80
|
+
5. Mentions stay conversational, while delegated sessions and native agent prompts can steer the active run. An operator can take over from the exact same worktree when needed.
|
|
81
81
|
|
|
82
82
|
## Restart And Reconciliation
|
|
83
83
|
|
|
@@ -221,7 +221,7 @@ Important:
|
|
|
221
221
|
1. Delegate a Linear issue to the PatchRelay app.
|
|
222
222
|
2. PatchRelay reads the current Linear state like `Start`, `Ready for QA`, or `Deploy` to choose the matching workflow.
|
|
223
223
|
3. Linear sends the delegation and agent-session webhooks to PatchRelay, which creates or reuses the issue worktree and launches the matching workflow.
|
|
224
|
-
4. Follow up in the
|
|
224
|
+
4. Follow up in the Linear agent session to steer the active run or wake it with fresh input while it remains delegated.
|
|
225
225
|
5. Watch progress from the terminal or open the same worktree and take over manually.
|
|
226
226
|
|
|
227
227
|
Useful commands:
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
function formatStageLabel(stage) {
|
|
2
|
+
return stage.replace(/[-_]+/g, " ");
|
|
3
|
+
}
|
|
4
|
+
function titleCase(value) {
|
|
5
|
+
return value
|
|
6
|
+
.split(/\s+/)
|
|
7
|
+
.filter(Boolean)
|
|
8
|
+
.map((word) => word[0].toUpperCase() + word.slice(1))
|
|
9
|
+
.join(" ");
|
|
10
|
+
}
|
|
11
|
+
function buildPlan(stage, statuses) {
|
|
12
|
+
const stageLabel = titleCase(formatStageLabel(stage));
|
|
13
|
+
return [
|
|
14
|
+
{ content: "Prepare workspace", status: statuses[0] },
|
|
15
|
+
{ content: `Run ${stageLabel} workflow`, status: statuses[1] },
|
|
16
|
+
{ content: "Review next Linear step", status: statuses[2] },
|
|
17
|
+
];
|
|
18
|
+
}
|
|
19
|
+
export function buildPreparingSessionPlan(stage) {
|
|
20
|
+
return buildPlan(stage, ["inProgress", "pending", "pending"]);
|
|
21
|
+
}
|
|
22
|
+
export function buildRunningSessionPlan(stage) {
|
|
23
|
+
return buildPlan(stage, ["completed", "inProgress", "pending"]);
|
|
24
|
+
}
|
|
25
|
+
export function buildCompletedSessionPlan(stage) {
|
|
26
|
+
return buildPlan(stage, ["completed", "completed", "completed"]);
|
|
27
|
+
}
|
|
28
|
+
export function buildAwaitingHandoffSessionPlan(stage) {
|
|
29
|
+
return buildPlan(stage, ["completed", "completed", "inProgress"]);
|
|
30
|
+
}
|
|
31
|
+
export function buildFailedSessionPlan(stage, stageRun) {
|
|
32
|
+
const workflowStepStatus = stageRun?.threadId || stageRun?.turnId ? "completed" : "inProgress";
|
|
33
|
+
return buildPlan(stage, ["completed", workflowStepStatus, "pending"]);
|
|
34
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { buildSessionStatusUrl, createSessionStatusToken, deriveSessionStatusSigningSecret, } from "./public-agent-session-status.js";
|
|
2
|
+
const SESSION_STATUS_TTL_SECONDS = 60 * 60 * 24 * 7;
|
|
3
|
+
export function buildAgentSessionExternalUrls(config, issueKey) {
|
|
4
|
+
if (!issueKey || !config.server.publicBaseUrl) {
|
|
5
|
+
return undefined;
|
|
6
|
+
}
|
|
7
|
+
const token = createSessionStatusToken({
|
|
8
|
+
issueKey,
|
|
9
|
+
secret: deriveSessionStatusSigningSecret(config.linear.tokenEncryptionKey),
|
|
10
|
+
ttlSeconds: SESSION_STATUS_TTL_SECONDS,
|
|
11
|
+
});
|
|
12
|
+
return [
|
|
13
|
+
{
|
|
14
|
+
label: "PatchRelay status",
|
|
15
|
+
url: buildSessionStatusUrl({
|
|
16
|
+
publicBaseUrl: config.server.publicBaseUrl,
|
|
17
|
+
issueKey,
|
|
18
|
+
token: token.token,
|
|
19
|
+
}),
|
|
20
|
+
},
|
|
21
|
+
];
|
|
22
|
+
}
|
package/dist/build-info.json
CHANGED
package/dist/config.js
CHANGED
|
@@ -101,7 +101,7 @@ const configSchema = z.object({
|
|
|
101
101
|
});
|
|
102
102
|
function defaultTriggerEvents(actor) {
|
|
103
103
|
if (actor === "app") {
|
|
104
|
-
return ["
|
|
104
|
+
return ["agentSessionCreated", "agentPrompted"];
|
|
105
105
|
}
|
|
106
106
|
return ["statusChanged"];
|
|
107
107
|
}
|
package/dist/http.js
CHANGED
|
@@ -177,6 +177,36 @@ export async function buildHttpServer(config, service, logger) {
|
|
|
177
177
|
});
|
|
178
178
|
return reply.code(result.status).send(result.body);
|
|
179
179
|
});
|
|
180
|
+
app.get("/agent/session/:issueKey", async (request, reply) => {
|
|
181
|
+
const issueKey = request.params.issueKey;
|
|
182
|
+
const token = getQueryParam(request, "token");
|
|
183
|
+
if (!token) {
|
|
184
|
+
return reply
|
|
185
|
+
.code(401)
|
|
186
|
+
.type("text/html; charset=utf-8")
|
|
187
|
+
.send(renderAgentSessionStatusErrorPage("Missing access token."));
|
|
188
|
+
}
|
|
189
|
+
const status = await service.getPublicAgentSessionStatus({ issueKey, token });
|
|
190
|
+
if (status.status === "invalid_token") {
|
|
191
|
+
return reply
|
|
192
|
+
.code(401)
|
|
193
|
+
.type("text/html; charset=utf-8")
|
|
194
|
+
.send(renderAgentSessionStatusErrorPage("The access token is invalid or expired."));
|
|
195
|
+
}
|
|
196
|
+
if (status.status === "issue_not_found") {
|
|
197
|
+
return reply
|
|
198
|
+
.code(404)
|
|
199
|
+
.type("text/html; charset=utf-8")
|
|
200
|
+
.send(renderAgentSessionStatusErrorPage("Issue status is not available."));
|
|
201
|
+
}
|
|
202
|
+
return reply
|
|
203
|
+
.type("text/html; charset=utf-8")
|
|
204
|
+
.send(renderAgentSessionStatusPage({
|
|
205
|
+
issueKey,
|
|
206
|
+
expiresAt: status.expiresAt,
|
|
207
|
+
sessionStatus: status.sessionStatus,
|
|
208
|
+
}));
|
|
209
|
+
});
|
|
180
210
|
if (config.operatorApi.enabled) {
|
|
181
211
|
app.addHook("onRequest", async (request, reply) => {
|
|
182
212
|
if (!request.url.startsWith("/api/")) {
|
|
@@ -218,6 +248,19 @@ export async function buildHttpServer(config, service, logger) {
|
|
|
218
248
|
}
|
|
219
249
|
return reply.send({ ok: true, ...result });
|
|
220
250
|
});
|
|
251
|
+
app.get("/api/issues/:issueKey/session-url", async (request, reply) => {
|
|
252
|
+
const issueKey = request.params.issueKey;
|
|
253
|
+
const ttlSeconds = getPositiveIntegerQueryParam(request, "ttlSeconds");
|
|
254
|
+
const issue = await service.getIssueOverview(issueKey);
|
|
255
|
+
if (!issue) {
|
|
256
|
+
return reply.code(404).send({ ok: false, reason: "issue_not_found" });
|
|
257
|
+
}
|
|
258
|
+
const link = service.createPublicAgentSessionStatusLink(issueKey, ttlSeconds ? { ttlSeconds } : undefined);
|
|
259
|
+
if (!link) {
|
|
260
|
+
return reply.code(503).send({ ok: false, reason: "public_base_url_not_configured" });
|
|
261
|
+
}
|
|
262
|
+
return reply.send({ ok: true, ...link });
|
|
263
|
+
});
|
|
221
264
|
}
|
|
222
265
|
if (managementRoutesEnabled) {
|
|
223
266
|
app.get("/api/feed", async (request, reply) => {
|
|
@@ -371,3 +414,140 @@ function renderOAuthResult(message) {
|
|
|
371
414
|
</body>
|
|
372
415
|
</html>`;
|
|
373
416
|
}
|
|
417
|
+
function renderAgentSessionStatusErrorPage(message) {
|
|
418
|
+
return `<!doctype html>
|
|
419
|
+
<html lang="en">
|
|
420
|
+
<head>
|
|
421
|
+
<meta charset="utf-8">
|
|
422
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
423
|
+
<title>PatchRelay Agent Status</title>
|
|
424
|
+
<style>
|
|
425
|
+
body { font-family: Georgia, "Times New Roman", serif; background: #f7f4ef; color: #1f1d1a; margin: 0; padding: 32px; }
|
|
426
|
+
main { max-width: 720px; margin: 10vh auto; background: rgba(255,255,255,0.86); border: 1px solid rgba(31,29,26,0.12); border-radius: 20px; padding: 32px; box-shadow: 0 28px 80px rgba(49,42,30,0.10); }
|
|
427
|
+
p { font-size: 18px; line-height: 1.6; color: #4d483f; }
|
|
428
|
+
</style>
|
|
429
|
+
</head>
|
|
430
|
+
<body>
|
|
431
|
+
<main>
|
|
432
|
+
<h1>PatchRelay Agent Session</h1>
|
|
433
|
+
<p>${escapeHtml(message)}</p>
|
|
434
|
+
</main>
|
|
435
|
+
</body>
|
|
436
|
+
</html>`;
|
|
437
|
+
}
|
|
438
|
+
function renderAgentSessionStatusPage(params) {
|
|
439
|
+
const issueTitle = params.sessionStatus.issue.title ?? params.sessionStatus.issue.issueKey ?? params.issueKey;
|
|
440
|
+
const issueUrl = params.sessionStatus.issue.issueUrl;
|
|
441
|
+
const activeStage = formatStageChip(params.sessionStatus.activeStageRun);
|
|
442
|
+
const latestStage = formatStageChip(params.sessionStatus.latestStageRun);
|
|
443
|
+
const threadInfo = formatThread(params.sessionStatus.liveThread);
|
|
444
|
+
const stagesRows = params.sessionStatus.stages.slice(-8).map((entry) => formatStageRow(entry.stageRun)).join("");
|
|
445
|
+
return `<!doctype html>
|
|
446
|
+
<html lang="en">
|
|
447
|
+
<head>
|
|
448
|
+
<meta charset="utf-8">
|
|
449
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
450
|
+
<title>PatchRelay Agent Session ${escapeHtml(params.issueKey)}</title>
|
|
451
|
+
<style>
|
|
452
|
+
:root {
|
|
453
|
+
color-scheme: light;
|
|
454
|
+
--bg: #f5f1e8;
|
|
455
|
+
--panel: rgba(255,255,255,0.84);
|
|
456
|
+
--ink: #1f1d1a;
|
|
457
|
+
--muted: #5b554b;
|
|
458
|
+
--accent: #1f6d57;
|
|
459
|
+
--line: rgba(31,29,26,0.15);
|
|
460
|
+
}
|
|
461
|
+
* { box-sizing: border-box; }
|
|
462
|
+
body {
|
|
463
|
+
margin: 0;
|
|
464
|
+
min-height: 100vh;
|
|
465
|
+
font-family: Georgia, "Times New Roman", serif;
|
|
466
|
+
color: var(--ink);
|
|
467
|
+
background:
|
|
468
|
+
radial-gradient(circle at top left, rgba(184, 139, 68, 0.22), transparent 36%),
|
|
469
|
+
radial-gradient(circle at bottom right, rgba(31, 109, 87, 0.16), transparent 30%),
|
|
470
|
+
linear-gradient(150deg, #eee3cf 0%, var(--bg) 52%, #f8f6f1 100%);
|
|
471
|
+
padding: 24px;
|
|
472
|
+
}
|
|
473
|
+
main {
|
|
474
|
+
width: min(920px, 100%);
|
|
475
|
+
margin: 0 auto;
|
|
476
|
+
background: var(--panel);
|
|
477
|
+
border: 1px solid var(--line);
|
|
478
|
+
border-radius: 22px;
|
|
479
|
+
padding: 32px;
|
|
480
|
+
box-shadow: 0 30px 86px rgba(49,42,30,0.12);
|
|
481
|
+
}
|
|
482
|
+
h1 { margin: 0; font-size: clamp(34px, 7vw, 52px); line-height: 1.05; }
|
|
483
|
+
p { color: var(--muted); font-size: 17px; line-height: 1.6; margin: 12px 0 0; }
|
|
484
|
+
a { color: var(--accent); text-decoration: none; }
|
|
485
|
+
a:hover { text-decoration: underline; }
|
|
486
|
+
.chips { display: flex; flex-wrap: wrap; gap: 10px; margin: 20px 0 6px; }
|
|
487
|
+
.chip { border: 1px solid var(--line); border-radius: 999px; padding: 9px 14px; background: rgba(255,255,255,0.74); font-size: 14px; }
|
|
488
|
+
.section { margin-top: 24px; padding-top: 18px; border-top: 1px solid var(--line); }
|
|
489
|
+
.section h2 { margin: 0; font-size: 22px; }
|
|
490
|
+
table { width: 100%; border-collapse: collapse; margin-top: 14px; }
|
|
491
|
+
th, td { text-align: left; border-bottom: 1px solid var(--line); padding: 10px 8px; vertical-align: top; }
|
|
492
|
+
th { font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: #5f594e; }
|
|
493
|
+
td { font-size: 15px; color: #2a2622; }
|
|
494
|
+
code { font-family: "SFMono-Regular", "Cascadia Code", "Fira Code", monospace; font-size: 0.95em; }
|
|
495
|
+
</style>
|
|
496
|
+
</head>
|
|
497
|
+
<body>
|
|
498
|
+
<main>
|
|
499
|
+
<h1>${escapeHtml(issueTitle)}</h1>
|
|
500
|
+
<p>PatchRelay read-only agent session status for <code>${escapeHtml(params.issueKey)}</code>.</p>
|
|
501
|
+
${issueUrl ? `<p><a href="${escapeHtml(issueUrl)}" target="_blank" rel="noopener noreferrer">Open issue in Linear</a></p>` : ""}
|
|
502
|
+
<div class="chips">
|
|
503
|
+
<span class="chip"><strong>Active:</strong> ${activeStage}</span>
|
|
504
|
+
<span class="chip"><strong>Latest:</strong> ${latestStage}</span>
|
|
505
|
+
<span class="chip"><strong>Thread:</strong> ${threadInfo}</span>
|
|
506
|
+
</div>
|
|
507
|
+
<div class="section">
|
|
508
|
+
<h2>Recent Stages</h2>
|
|
509
|
+
<table>
|
|
510
|
+
<thead>
|
|
511
|
+
<tr>
|
|
512
|
+
<th>Stage</th>
|
|
513
|
+
<th>Status</th>
|
|
514
|
+
<th>Started</th>
|
|
515
|
+
<th>Ended</th>
|
|
516
|
+
</tr>
|
|
517
|
+
</thead>
|
|
518
|
+
<tbody>
|
|
519
|
+
${stagesRows || '<tr><td colspan="4">No completed stage runs yet.</td></tr>'}
|
|
520
|
+
</tbody>
|
|
521
|
+
</table>
|
|
522
|
+
</div>
|
|
523
|
+
<p>Snapshot generated at <code>${escapeHtml(params.sessionStatus.generatedAt)}</code>. Link valid until <code>${escapeHtml(params.expiresAt)}</code>.</p>
|
|
524
|
+
</main>
|
|
525
|
+
</body>
|
|
526
|
+
</html>`;
|
|
527
|
+
}
|
|
528
|
+
function formatStageChip(stageRun) {
|
|
529
|
+
if (!stageRun) {
|
|
530
|
+
return "none";
|
|
531
|
+
}
|
|
532
|
+
const stage = stageRun.stage ?? "unknown";
|
|
533
|
+
const status = stageRun.status ?? "unknown";
|
|
534
|
+
return `<code>${escapeHtml(stage)}</code> (${escapeHtml(status)})`;
|
|
535
|
+
}
|
|
536
|
+
function formatThread(liveThread) {
|
|
537
|
+
if (!liveThread) {
|
|
538
|
+
return "idle";
|
|
539
|
+
}
|
|
540
|
+
const threadId = liveThread.threadId ?? "unknown";
|
|
541
|
+
const status = liveThread.threadStatus ?? "unknown";
|
|
542
|
+
return `<code>${escapeHtml(threadId)}</code> (${escapeHtml(status)})`;
|
|
543
|
+
}
|
|
544
|
+
function formatStageRow(stageRun) {
|
|
545
|
+
if (!stageRun) {
|
|
546
|
+
return '<tr><td colspan="4">Unknown stage record</td></tr>';
|
|
547
|
+
}
|
|
548
|
+
const stage = stageRun.stage ?? "unknown";
|
|
549
|
+
const status = stageRun.status ?? "unknown";
|
|
550
|
+
const startedAt = stageRun.startedAt ?? "-";
|
|
551
|
+
const endedAt = stageRun.endedAt ?? "-";
|
|
552
|
+
return `<tr><td><code>${escapeHtml(stage)}</code></td><td>${escapeHtml(status)}</td><td><code>${escapeHtml(startedAt)}</code></td><td><code>${escapeHtml(endedAt)}</code></td></tr>`;
|
|
553
|
+
}
|
|
@@ -65,4 +65,19 @@ export class IssueQueryService {
|
|
|
65
65
|
async getActiveStageStatus(issueKey) {
|
|
66
66
|
return await this.stageFinalizer.getActiveStageStatus(issueKey);
|
|
67
67
|
}
|
|
68
|
+
async getPublicAgentSessionStatus(issueKey) {
|
|
69
|
+
const overview = await this.getIssueOverview(issueKey);
|
|
70
|
+
if (!overview) {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
const report = await this.getIssueReport(issueKey);
|
|
74
|
+
return {
|
|
75
|
+
issue: overview.issue,
|
|
76
|
+
...(overview.activeStageRun ? { activeStageRun: overview.activeStageRun } : {}),
|
|
77
|
+
...(overview.latestStageRun ? { latestStageRun: overview.latestStageRun } : {}),
|
|
78
|
+
...(overview.liveThread ? { liveThread: overview.liveThread } : {}),
|
|
79
|
+
stages: report?.stages ?? [],
|
|
80
|
+
generatedAt: new Date().toISOString(),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
68
83
|
}
|
package/dist/linear-client.js
CHANGED
|
@@ -156,6 +156,32 @@ export class LinearGraphqlClient {
|
|
|
156
156
|
}
|
|
157
157
|
return response.agentActivityCreate.agentActivity;
|
|
158
158
|
}
|
|
159
|
+
async updateAgentSession(params) {
|
|
160
|
+
const input = {};
|
|
161
|
+
if ("externalUrls" in params) {
|
|
162
|
+
input.externalUrls = params.externalUrls;
|
|
163
|
+
}
|
|
164
|
+
if ("plan" in params) {
|
|
165
|
+
input.plan = params.plan;
|
|
166
|
+
}
|
|
167
|
+
const response = await this.request(`
|
|
168
|
+
mutation PatchRelayUpdateAgentSession($id: String!, $input: AgentSessionUpdateInput!) {
|
|
169
|
+
agentSessionUpdate(id: $id, input: $input) {
|
|
170
|
+
success
|
|
171
|
+
agentSession {
|
|
172
|
+
id
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
`, {
|
|
177
|
+
id: params.agentSessionId,
|
|
178
|
+
input,
|
|
179
|
+
});
|
|
180
|
+
if (!response.agentSessionUpdate.success || !response.agentSessionUpdate.agentSession) {
|
|
181
|
+
throw new Error(`Linear rejected agent session update for session ${params.agentSessionId}`);
|
|
182
|
+
}
|
|
183
|
+
return response.agentSessionUpdate.agentSession;
|
|
184
|
+
}
|
|
159
185
|
async updateIssueLabels(params) {
|
|
160
186
|
const issue = await this.getIssue(params.issueId);
|
|
161
187
|
const addIds = this.resolveLabelIds(issue, params.addNames ?? []);
|
|
@@ -7,12 +7,6 @@ export function resolveProject(config, issue) {
|
|
|
7
7
|
return undefined;
|
|
8
8
|
}
|
|
9
9
|
export function triggerEventAllowed(project, triggerEvent) {
|
|
10
|
-
if (project.triggerEvents.includes(triggerEvent)) {
|
|
11
|
-
return true;
|
|
12
|
-
}
|
|
13
|
-
if (triggerEvent === "agentSessionCreated") {
|
|
14
|
-
return project.triggerEvents.includes("delegateChanged") || project.triggerEvents.includes("statusChanged");
|
|
15
|
-
}
|
|
16
10
|
return project.triggerEvents.includes(triggerEvent);
|
|
17
11
|
}
|
|
18
12
|
function normalizeTrustValue(value) {
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
const TOKEN_VERSION = 1;
|
|
3
|
+
const DEFAULT_TTL_SECONDS = 60 * 60 * 24;
|
|
4
|
+
const PURPOSE = "patchrelay-agent-session-status";
|
|
5
|
+
export function createSessionStatusToken(params) {
|
|
6
|
+
const nowSeconds = Math.floor((params.nowMs ?? Date.now()) / 1000);
|
|
7
|
+
const ttlSeconds = Number.isFinite(params.ttlSeconds) ? Math.max(60, Math.floor(params.ttlSeconds ?? DEFAULT_TTL_SECONDS)) : DEFAULT_TTL_SECONDS;
|
|
8
|
+
const payload = {
|
|
9
|
+
v: TOKEN_VERSION,
|
|
10
|
+
i: params.issueKey,
|
|
11
|
+
exp: nowSeconds + ttlSeconds,
|
|
12
|
+
};
|
|
13
|
+
const payloadEncoded = encodeBase64Url(Buffer.from(JSON.stringify(payload), "utf8"));
|
|
14
|
+
const signature = signPayload(payloadEncoded, params.secret);
|
|
15
|
+
return {
|
|
16
|
+
token: `${payloadEncoded}.${signature}`,
|
|
17
|
+
issueKey: payload.i,
|
|
18
|
+
expiresAt: new Date(payload.exp * 1000).toISOString(),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export function verifySessionStatusToken(token, secret, nowMs) {
|
|
22
|
+
const [payloadEncoded, signatureEncoded] = token.split(".", 2);
|
|
23
|
+
if (!payloadEncoded || !signatureEncoded) {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
const expectedSignature = signPayload(payloadEncoded, secret);
|
|
27
|
+
if (!timingSafeEqualUtf8(signatureEncoded, expectedSignature)) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
const payload = parsePayload(payloadEncoded);
|
|
31
|
+
if (!payload || payload.v !== TOKEN_VERSION || typeof payload.i !== "string" || typeof payload.exp !== "number") {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
const nowSeconds = Math.floor((nowMs ?? Date.now()) / 1000);
|
|
35
|
+
if (payload.exp < nowSeconds) {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
issueKey: payload.i,
|
|
40
|
+
expiresAt: new Date(payload.exp * 1000).toISOString(),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export function buildSessionStatusUrl(params) {
|
|
44
|
+
const url = new URL(`/agent/session/${encodeURIComponent(params.issueKey)}`, params.publicBaseUrl);
|
|
45
|
+
url.searchParams.set("token", params.token);
|
|
46
|
+
return url.toString();
|
|
47
|
+
}
|
|
48
|
+
export function deriveSessionStatusSigningSecret(tokenEncryptionKey) {
|
|
49
|
+
return `${PURPOSE}:${tokenEncryptionKey}`;
|
|
50
|
+
}
|
|
51
|
+
function signPayload(payloadEncoded, secret) {
|
|
52
|
+
const digest = createHmac("sha256", secret).update(payloadEncoded).digest();
|
|
53
|
+
return encodeBase64Url(digest);
|
|
54
|
+
}
|
|
55
|
+
function parsePayload(payloadEncoded) {
|
|
56
|
+
try {
|
|
57
|
+
const decoded = decodeBase64Url(payloadEncoded).toString("utf8");
|
|
58
|
+
return JSON.parse(decoded);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function encodeBase64Url(value) {
|
|
65
|
+
return value.toString("base64url");
|
|
66
|
+
}
|
|
67
|
+
function decodeBase64Url(value) {
|
|
68
|
+
return Buffer.from(value, "base64url");
|
|
69
|
+
}
|
|
70
|
+
function timingSafeEqualUtf8(left, right) {
|
|
71
|
+
const leftBuffer = Buffer.from(left, "utf8");
|
|
72
|
+
const rightBuffer = Buffer.from(right, "utf8");
|
|
73
|
+
if (leftBuffer.length !== rightBuffer.length) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
return timingSafeEqual(leftBuffer, rightBuffer);
|
|
77
|
+
}
|
package/dist/service-queue.js
CHANGED
|
@@ -10,12 +10,17 @@ export class SerialWorkQueue {
|
|
|
10
10
|
this.logger = logger;
|
|
11
11
|
this.getKey = getKey;
|
|
12
12
|
}
|
|
13
|
-
enqueue(item) {
|
|
13
|
+
enqueue(item, options) {
|
|
14
14
|
const key = this.getKey?.(item);
|
|
15
15
|
if (key && this.queuedKeys.has(key)) {
|
|
16
16
|
return;
|
|
17
17
|
}
|
|
18
|
-
|
|
18
|
+
if (options?.priority) {
|
|
19
|
+
this.items.unshift(item);
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
this.items.push(item);
|
|
23
|
+
}
|
|
19
24
|
if (key) {
|
|
20
25
|
this.queuedKeys.add(key);
|
|
21
26
|
}
|
package/dist/service-runtime.js
CHANGED
|
@@ -80,8 +80,8 @@ export class ServiceRuntime {
|
|
|
80
80
|
this.ready = false;
|
|
81
81
|
void this.codex.stop();
|
|
82
82
|
}
|
|
83
|
-
enqueueWebhookEvent(eventId) {
|
|
84
|
-
this.webhookQueue.enqueue(eventId);
|
|
83
|
+
enqueueWebhookEvent(eventId, options) {
|
|
84
|
+
this.webhookQueue.enqueue(eventId, options);
|
|
85
85
|
}
|
|
86
86
|
enqueueIssue(projectId, issueId) {
|
|
87
87
|
this.issueQueue.enqueue({ projectId, issueId });
|
|
@@ -133,8 +133,10 @@ export class ServiceStageRunner {
|
|
|
133
133
|
failureMessage: "Failed to deliver queued Linear comment during stage startup",
|
|
134
134
|
...(claim.issue.issueKey ? { issueKey: claim.issue.issueKey } : {}),
|
|
135
135
|
});
|
|
136
|
-
await this.lifecyclePublisher.
|
|
137
|
-
|
|
136
|
+
const deliveredToSession = await this.lifecyclePublisher.publishStageStarted(claim.issue, claim.stageRun.stage);
|
|
137
|
+
if (!deliveredToSession) {
|
|
138
|
+
await this.lifecyclePublisher.refreshRunningStatusComment(item.projectId, item.issueId, claim.stageRun.id, issue.issueKey);
|
|
139
|
+
}
|
|
138
140
|
this.logger.info({
|
|
139
141
|
issueKey: issue.issueKey,
|
|
140
142
|
stage: claim.stageRun.stage,
|
|
@@ -24,7 +24,7 @@ export class ServiceWebhookProcessor {
|
|
|
24
24
|
this.logger = logger;
|
|
25
25
|
this.feed = feed;
|
|
26
26
|
const turnInputDispatcher = new StageTurnInputDispatcher(stores, codex, logger);
|
|
27
|
-
const agentActivity = new StageAgentActivityPublisher(linearProvider, logger);
|
|
27
|
+
const agentActivity = new StageAgentActivityPublisher(config, linearProvider, logger);
|
|
28
28
|
this.desiredStageRecorder = new WebhookDesiredStageRecorder(stores);
|
|
29
29
|
this.agentSessionHandler = new AgentSessionWebhookHandler(stores, turnInputDispatcher, agentActivity, feed);
|
|
30
30
|
this.commentHandler = new CommentWebhookHandler(stores, turnInputDispatcher, feed);
|
package/dist/service.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { IssueQueryService } from "./issue-query-service.js";
|
|
2
2
|
import { LinearOAuthService } from "./linear-oauth-service.js";
|
|
3
3
|
import { OperatorEventFeed } from "./operator-feed.js";
|
|
4
|
+
import { buildSessionStatusUrl, createSessionStatusToken, deriveSessionStatusSigningSecret, verifySessionStatusToken, } from "./public-agent-session-status.js";
|
|
4
5
|
import { ServiceRuntime } from "./service-runtime.js";
|
|
5
6
|
import { ServiceStageFinalizer } from "./service-stage-finalizer.js";
|
|
6
7
|
import { ServiceStageRunner } from "./service-stage-runner.js";
|
|
@@ -111,7 +112,9 @@ export class PatchRelayService {
|
|
|
111
112
|
rawBody: params.rawBody,
|
|
112
113
|
});
|
|
113
114
|
if (result.accepted) {
|
|
114
|
-
this.runtime.enqueueWebhookEvent(result.accepted.id
|
|
115
|
+
this.runtime.enqueueWebhookEvent(result.accepted.id, {
|
|
116
|
+
priority: result.accepted.normalized.triggerEvent === "agentSessionCreated",
|
|
117
|
+
});
|
|
115
118
|
}
|
|
116
119
|
return {
|
|
117
120
|
status: result.status,
|
|
@@ -136,6 +139,44 @@ export class PatchRelayService {
|
|
|
136
139
|
async getActiveStageStatus(issueKey) {
|
|
137
140
|
return await this.queryService.getActiveStageStatus(issueKey);
|
|
138
141
|
}
|
|
142
|
+
createPublicAgentSessionStatusLink(issueKey, options) {
|
|
143
|
+
if (!this.config.server.publicBaseUrl) {
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
const signingSecret = deriveSessionStatusSigningSecret(this.config.linear.tokenEncryptionKey);
|
|
147
|
+
const token = createSessionStatusToken({
|
|
148
|
+
issueKey,
|
|
149
|
+
secret: signingSecret,
|
|
150
|
+
...(options?.nowMs !== undefined ? { nowMs: options.nowMs } : {}),
|
|
151
|
+
...(options?.ttlSeconds !== undefined ? { ttlSeconds: options.ttlSeconds } : {}),
|
|
152
|
+
});
|
|
153
|
+
return {
|
|
154
|
+
url: buildSessionStatusUrl({
|
|
155
|
+
publicBaseUrl: this.config.server.publicBaseUrl,
|
|
156
|
+
issueKey,
|
|
157
|
+
token: token.token,
|
|
158
|
+
}),
|
|
159
|
+
issueKey: token.issueKey,
|
|
160
|
+
expiresAt: token.expiresAt,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
async getPublicAgentSessionStatus(params) {
|
|
164
|
+
const signingSecret = deriveSessionStatusSigningSecret(this.config.linear.tokenEncryptionKey);
|
|
165
|
+
const parsed = verifySessionStatusToken(params.token, signingSecret, params.nowMs);
|
|
166
|
+
if (!parsed || parsed.issueKey.trim().toLowerCase() !== params.issueKey.trim().toLowerCase()) {
|
|
167
|
+
return { status: "invalid_token" };
|
|
168
|
+
}
|
|
169
|
+
const sessionStatus = await this.queryService.getPublicAgentSessionStatus(params.issueKey);
|
|
170
|
+
if (!sessionStatus) {
|
|
171
|
+
return { status: "issue_not_found" };
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
status: "ok",
|
|
175
|
+
issueKey: params.issueKey,
|
|
176
|
+
expiresAt: parsed.expiresAt,
|
|
177
|
+
sessionStatus,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
139
180
|
}
|
|
140
181
|
function toLinearClientProvider(linear) {
|
|
141
182
|
if (linear && typeof linear.forProject === "function") {
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import { buildAgentSessionExternalUrls } from "./agent-session-presentation.js";
|
|
1
2
|
export class StageAgentActivityPublisher {
|
|
3
|
+
config;
|
|
2
4
|
linearProvider;
|
|
3
5
|
logger;
|
|
4
|
-
constructor(linearProvider, logger) {
|
|
6
|
+
constructor(config, linearProvider, logger) {
|
|
7
|
+
this.config = config;
|
|
5
8
|
this.linearProvider = linearProvider;
|
|
6
9
|
this.logger = logger;
|
|
7
10
|
}
|
|
@@ -30,4 +33,27 @@ export class StageAgentActivityPublisher {
|
|
|
30
33
|
}
|
|
31
34
|
await this.publishForSession(issue.projectId, issue.activeAgentSessionId, content);
|
|
32
35
|
}
|
|
36
|
+
async updateSession(params) {
|
|
37
|
+
const linear = await this.linearProvider.forProject(params.projectId);
|
|
38
|
+
if (!linear?.updateAgentSession) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const externalUrls = buildAgentSessionExternalUrls(this.config, params.issueKey);
|
|
42
|
+
if (!externalUrls && !params.plan) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
await linear.updateAgentSession({
|
|
47
|
+
agentSessionId: params.agentSessionId,
|
|
48
|
+
...(externalUrls ? { externalUrls } : {}),
|
|
49
|
+
...(params.plan ? { plan: params.plan } : {}),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
this.logger.warn({
|
|
54
|
+
agentSessionId: params.agentSessionId,
|
|
55
|
+
error: error instanceof Error ? error.message : String(error),
|
|
56
|
+
}, "Failed to update Linear agent session");
|
|
57
|
+
}
|
|
58
|
+
}
|
|
33
59
|
}
|
package/dist/stage-failure.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { buildFailedSessionPlan } from "./agent-session-plan.js";
|
|
1
2
|
import { buildStageFailedComment, resolveActiveLinearState, resolveFallbackLinearState, resolveWorkflowLabelCleanup, } from "./linear-workflow.js";
|
|
2
3
|
function normalizeStateName(value) {
|
|
3
4
|
const trimmed = value?.trim();
|
|
@@ -48,31 +49,44 @@ export async function syncFailedStageToLinear(params) {
|
|
|
48
49
|
lifecycleStatus: "failed",
|
|
49
50
|
});
|
|
50
51
|
}
|
|
51
|
-
|
|
52
|
-
.upsertIssueComment({
|
|
53
|
-
issueId: params.stageRun.linearIssueId,
|
|
54
|
-
...(params.issue.statusCommentId ? { commentId: params.issue.statusCommentId } : {}),
|
|
55
|
-
body: buildStageFailedComment({
|
|
56
|
-
issue: params.issue,
|
|
57
|
-
stageRun: params.stageRun,
|
|
58
|
-
message: params.message,
|
|
59
|
-
...(fallbackState ? { fallbackState } : {}),
|
|
60
|
-
...(params.mode ? { mode: params.mode } : {}),
|
|
61
|
-
}),
|
|
62
|
-
})
|
|
63
|
-
.catch(() => undefined);
|
|
64
|
-
if (result) {
|
|
65
|
-
params.stores.workflowCoordinator.setIssueStatusComment(params.stageRun.projectId, params.stageRun.linearIssueId, result.id);
|
|
66
|
-
}
|
|
52
|
+
let deliveredToSession = false;
|
|
67
53
|
if (params.issue.activeAgentSessionId) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
54
|
+
deliveredToSession =
|
|
55
|
+
(await linear
|
|
56
|
+
.updateAgentSession?.({
|
|
57
|
+
agentSessionId: params.issue.activeAgentSessionId,
|
|
58
|
+
plan: buildFailedSessionPlan(params.stageRun.stage, params.stageRun),
|
|
59
|
+
})
|
|
60
|
+
.then(() => true)
|
|
61
|
+
.catch(() => false)) ?? false;
|
|
62
|
+
deliveredToSession =
|
|
63
|
+
(await linear
|
|
64
|
+
.createAgentActivity({
|
|
65
|
+
agentSessionId: params.issue.activeAgentSessionId,
|
|
66
|
+
content: {
|
|
67
|
+
type: "error",
|
|
68
|
+
body: `PatchRelay could not complete the ${params.stageRun.stage} workflow: ${params.message}`,
|
|
69
|
+
},
|
|
70
|
+
})
|
|
71
|
+
.then(() => true)
|
|
72
|
+
.catch(() => false)) || deliveredToSession;
|
|
73
|
+
}
|
|
74
|
+
if (!deliveredToSession) {
|
|
75
|
+
const result = await linear
|
|
76
|
+
.upsertIssueComment({
|
|
77
|
+
issueId: params.stageRun.linearIssueId,
|
|
78
|
+
...(params.issue.statusCommentId ? { commentId: params.issue.statusCommentId } : {}),
|
|
79
|
+
body: buildStageFailedComment({
|
|
80
|
+
issue: params.issue,
|
|
81
|
+
stageRun: params.stageRun,
|
|
82
|
+
message: params.message,
|
|
83
|
+
...(fallbackState ? { fallbackState } : {}),
|
|
84
|
+
...(params.mode ? { mode: params.mode } : {}),
|
|
85
|
+
}),
|
|
75
86
|
})
|
|
76
87
|
.catch(() => undefined);
|
|
88
|
+
if (result) {
|
|
89
|
+
params.stores.workflowCoordinator.setIssueStatusComment(params.stageRun.projectId, params.stageRun.linearIssueId, result.id);
|
|
90
|
+
}
|
|
77
91
|
}
|
|
78
92
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { buildAwaitingHandoffSessionPlan, buildCompletedSessionPlan, buildRunningSessionPlan, } from "./agent-session-plan.js";
|
|
2
|
+
import { buildAgentSessionExternalUrls } from "./agent-session-presentation.js";
|
|
1
3
|
import { buildAwaitingHandoffComment, buildRunningStatusComment, resolveActiveLinearState, resolveWorkflowLabelCleanup, resolveWorkflowLabelNames, } from "./linear-workflow.js";
|
|
2
4
|
import { sanitizeDiagnosticText } from "./utils.js";
|
|
3
5
|
export class StageLifecyclePublisher {
|
|
@@ -71,12 +73,13 @@ export class StageLifecyclePublisher {
|
|
|
71
73
|
}
|
|
72
74
|
async publishStageStarted(issue, stage) {
|
|
73
75
|
if (!issue.activeAgentSessionId) {
|
|
74
|
-
return;
|
|
76
|
+
return false;
|
|
75
77
|
}
|
|
76
78
|
const linear = await this.linearProvider.forProject(issue.projectId);
|
|
77
79
|
if (!linear) {
|
|
78
|
-
return;
|
|
80
|
+
return false;
|
|
79
81
|
}
|
|
82
|
+
const sessionUpdated = await this.updateAgentSession(linear, issue, buildRunningSessionPlan(stage), stage);
|
|
80
83
|
try {
|
|
81
84
|
await linear.createAgentActivity({
|
|
82
85
|
agentSessionId: issue.activeAgentSessionId,
|
|
@@ -88,6 +91,7 @@ export class StageLifecyclePublisher {
|
|
|
88
91
|
},
|
|
89
92
|
ephemeral: true,
|
|
90
93
|
});
|
|
94
|
+
return true;
|
|
91
95
|
}
|
|
92
96
|
catch (error) {
|
|
93
97
|
this.logger.warn({
|
|
@@ -96,6 +100,7 @@ export class StageLifecyclePublisher {
|
|
|
96
100
|
agentSessionId: issue.activeAgentSessionId,
|
|
97
101
|
error: error instanceof Error ? error.message : String(error),
|
|
98
102
|
}, "Failed to publish Linear agent activity after stage startup");
|
|
103
|
+
return sessionUpdated;
|
|
99
104
|
}
|
|
100
105
|
}
|
|
101
106
|
async publishStageCompletion(stageRun, enqueueIssue) {
|
|
@@ -134,16 +139,10 @@ export class StageLifecyclePublisher {
|
|
|
134
139
|
}
|
|
135
140
|
this.stores.workflowCoordinator.setIssueLifecycleStatus(stageRun.projectId, stageRun.linearIssueId, "paused");
|
|
136
141
|
const finalStageRun = this.stores.issueWorkflows.getStageRun(stageRun.id) ?? stageRun;
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
issue: refreshedIssue,
|
|
142
|
-
stageRun: finalStageRun,
|
|
143
|
-
activeState,
|
|
144
|
-
}),
|
|
145
|
-
});
|
|
146
|
-
this.stores.workflowCoordinator.setIssueStatusComment(stageRun.projectId, stageRun.linearIssueId, result.id);
|
|
142
|
+
let deliveredToSession = false;
|
|
143
|
+
if (refreshedIssue.activeAgentSessionId) {
|
|
144
|
+
deliveredToSession = await this.updateAgentSession(linear, refreshedIssue, buildAwaitingHandoffSessionPlan(stageRun.stage));
|
|
145
|
+
}
|
|
147
146
|
this.feed?.publish({
|
|
148
147
|
level: "info",
|
|
149
148
|
kind: "stage",
|
|
@@ -154,10 +153,23 @@ export class StageLifecyclePublisher {
|
|
|
154
153
|
summary: `Completed ${stageRun.stage} workflow`,
|
|
155
154
|
detail: `Waiting for a Linear state change or follow-up input while the issue remains in ${activeState}.`,
|
|
156
155
|
});
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
156
|
+
deliveredToSession =
|
|
157
|
+
(await this.publishAgentCompletion(refreshedIssue, {
|
|
158
|
+
type: "elicitation",
|
|
159
|
+
body: `PatchRelay finished the ${stageRun.stage} workflow. Move the issue to its next workflow state or leave a follow-up prompt to continue.`,
|
|
160
|
+
})) || deliveredToSession;
|
|
161
|
+
if (!deliveredToSession) {
|
|
162
|
+
const result = await linear.upsertIssueComment({
|
|
163
|
+
issueId: stageRun.linearIssueId,
|
|
164
|
+
...(refreshedIssue.statusCommentId ? { commentId: refreshedIssue.statusCommentId } : {}),
|
|
165
|
+
body: buildAwaitingHandoffComment({
|
|
166
|
+
issue: refreshedIssue,
|
|
167
|
+
stageRun: finalStageRun,
|
|
168
|
+
activeState,
|
|
169
|
+
}),
|
|
170
|
+
});
|
|
171
|
+
this.stores.workflowCoordinator.setIssueStatusComment(stageRun.projectId, stageRun.linearIssueId, result.id);
|
|
172
|
+
}
|
|
161
173
|
return;
|
|
162
174
|
}
|
|
163
175
|
const cleanup = resolveWorkflowLabelCleanup(project);
|
|
@@ -179,6 +191,9 @@ export class StageLifecyclePublisher {
|
|
|
179
191
|
}
|
|
180
192
|
}
|
|
181
193
|
if (refreshedIssue) {
|
|
194
|
+
if (refreshedIssue.activeAgentSessionId && linear) {
|
|
195
|
+
await this.updateAgentSession(linear, refreshedIssue, buildCompletedSessionPlan(stageRun.stage));
|
|
196
|
+
}
|
|
182
197
|
this.feed?.publish({
|
|
183
198
|
level: "info",
|
|
184
199
|
kind: "stage",
|
|
@@ -194,20 +209,46 @@ export class StageLifecyclePublisher {
|
|
|
194
209
|
});
|
|
195
210
|
}
|
|
196
211
|
}
|
|
212
|
+
async updateAgentSession(linear, issue, plan, stage) {
|
|
213
|
+
if (!issue.activeAgentSessionId) {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
const externalUrls = buildAgentSessionExternalUrls(this.config, issue.issueKey);
|
|
218
|
+
await linear.updateAgentSession?.({
|
|
219
|
+
agentSessionId: issue.activeAgentSessionId,
|
|
220
|
+
...(externalUrls ? { externalUrls } : {}),
|
|
221
|
+
plan,
|
|
222
|
+
});
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
this.logger.warn({
|
|
227
|
+
issueKey: issue.issueKey,
|
|
228
|
+
issueId: issue.linearIssueId,
|
|
229
|
+
...(stage ? { stage } : {}),
|
|
230
|
+
agentSessionId: issue.activeAgentSessionId,
|
|
231
|
+
error: sanitizeDiagnosticText(error instanceof Error ? error.message : String(error)),
|
|
232
|
+
}, "Failed to update Linear agent session");
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
197
236
|
async publishAgentCompletion(issue, content) {
|
|
198
237
|
if (!issue.activeAgentSessionId) {
|
|
199
|
-
return;
|
|
238
|
+
return false;
|
|
200
239
|
}
|
|
201
240
|
const linear = await this.linearProvider.forProject(issue.projectId);
|
|
202
241
|
if (!linear) {
|
|
203
|
-
return;
|
|
242
|
+
return false;
|
|
204
243
|
}
|
|
205
|
-
|
|
206
|
-
.createAgentActivity({
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
244
|
+
try {
|
|
245
|
+
await linear.createAgentActivity({
|
|
246
|
+
agentSessionId: issue.activeAgentSessionId,
|
|
247
|
+
content,
|
|
248
|
+
});
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
catch (error) {
|
|
211
252
|
this.logger.warn({
|
|
212
253
|
issueKey: issue.issueKey,
|
|
213
254
|
issueId: issue.linearIssueId,
|
|
@@ -215,6 +256,7 @@ export class StageLifecyclePublisher {
|
|
|
215
256
|
activityType: content.type,
|
|
216
257
|
error: sanitizeDiagnosticText(error instanceof Error ? error.message : String(error)),
|
|
217
258
|
}, "Failed to publish Linear agent activity");
|
|
218
|
-
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
219
261
|
}
|
|
220
262
|
}
|
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { buildPreparingSessionPlan, buildRunningSessionPlan } from "./agent-session-plan.js";
|
|
2
3
|
import { triggerEventAllowed } from "./project-resolution.js";
|
|
3
4
|
import { listRunnableStates, resolveWorkflowStage } from "./workflow-policy.js";
|
|
4
5
|
function trimPrompt(value) {
|
|
5
6
|
const trimmed = value?.trim();
|
|
6
7
|
return trimmed ? trimmed : undefined;
|
|
7
8
|
}
|
|
9
|
+
function buildSessionUpdateParams(projectId, agentSessionId, issueKey, plan) {
|
|
10
|
+
return {
|
|
11
|
+
projectId,
|
|
12
|
+
agentSessionId,
|
|
13
|
+
...(issueKey ? { issueKey } : {}),
|
|
14
|
+
plan,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
8
17
|
export class AgentSessionWebhookHandler {
|
|
9
18
|
stores;
|
|
10
19
|
turnInputDispatcher;
|
|
@@ -30,6 +39,7 @@ export class AgentSessionWebhookHandler {
|
|
|
30
39
|
if (normalized.triggerEvent === "agentSessionCreated") {
|
|
31
40
|
if (!delegatedToPatchRelay) {
|
|
32
41
|
if (activeStage) {
|
|
42
|
+
await this.agentActivity.updateSession(buildSessionUpdateParams(project.id, normalized.agentSession.id, issue?.issueKey ?? normalized.issue?.identifier, buildRunningSessionPlan(activeStage)));
|
|
33
43
|
await this.agentActivity.publishForSession(project.id, normalized.agentSession.id, {
|
|
34
44
|
type: "thought",
|
|
35
45
|
body: `PatchRelay is already running the ${activeStage} workflow for this issue. Delegate it to PatchRelay if you want automation to own the workflow, or keep replying here to steer the active run.`,
|
|
@@ -54,15 +64,17 @@ export class AgentSessionWebhookHandler {
|
|
|
54
64
|
return;
|
|
55
65
|
}
|
|
56
66
|
if (desiredStage) {
|
|
67
|
+
await this.agentActivity.updateSession(buildSessionUpdateParams(project.id, normalized.agentSession.id, issue?.issueKey ?? normalized.issue?.identifier, buildPreparingSessionPlan(desiredStage)));
|
|
57
68
|
await this.agentActivity.publishForSession(project.id, normalized.agentSession.id, {
|
|
58
|
-
type: "
|
|
59
|
-
body: `PatchRelay
|
|
69
|
+
type: "response",
|
|
70
|
+
body: `PatchRelay picked this up and is preparing the ${desiredStage} workflow.`,
|
|
60
71
|
});
|
|
61
72
|
return;
|
|
62
73
|
}
|
|
63
74
|
if (activeStage) {
|
|
75
|
+
await this.agentActivity.updateSession(buildSessionUpdateParams(project.id, normalized.agentSession.id, issue?.issueKey ?? normalized.issue?.identifier, buildRunningSessionPlan(activeStage)));
|
|
64
76
|
await this.agentActivity.publishForSession(project.id, normalized.agentSession.id, {
|
|
65
|
-
type: "
|
|
77
|
+
type: "response",
|
|
66
78
|
body: `PatchRelay is already running the ${activeStage} workflow for this issue.`,
|
|
67
79
|
});
|
|
68
80
|
}
|
|
@@ -123,8 +135,9 @@ export class AgentSessionWebhookHandler {
|
|
|
123
135
|
return;
|
|
124
136
|
}
|
|
125
137
|
if (!activeRunLease && desiredStage) {
|
|
138
|
+
await this.agentActivity.updateSession(buildSessionUpdateParams(project.id, normalized.agentSession.id, issue?.issueKey ?? normalized.issue?.identifier, buildPreparingSessionPlan(desiredStage)));
|
|
126
139
|
await this.agentActivity.publishForSession(project.id, normalized.agentSession.id, {
|
|
127
|
-
type: "
|
|
140
|
+
type: "response",
|
|
128
141
|
body: `PatchRelay received your prompt and is preparing the ${desiredStage} workflow.`,
|
|
129
142
|
});
|
|
130
143
|
return;
|
|
@@ -27,6 +27,8 @@ export class WebhookDesiredStageRecorder {
|
|
|
27
27
|
const stageAllowed = triggerEventAllowed(project, normalized.triggerEvent);
|
|
28
28
|
const desiredStage = this.resolveDesiredStage(project, normalized, issue, activeStageRun, delegatedToPatchRelay);
|
|
29
29
|
const launchInput = this.resolveLaunchInput(normalized.agentSession);
|
|
30
|
+
const activeAgentSessionId = normalized.agentSession?.id ??
|
|
31
|
+
(!activeStageRun && (desiredStage || (normalized.triggerEvent === "delegateChanged" && !delegatedToPatchRelay)) ? null : undefined);
|
|
30
32
|
const refreshedIssue = this.stores.workflowCoordinator.recordDesiredStage({
|
|
31
33
|
projectId: project.id,
|
|
32
34
|
linearIssueId: normalizedIssue.id,
|
|
@@ -36,7 +38,7 @@ export class WebhookDesiredStageRecorder {
|
|
|
36
38
|
...(normalizedIssue.stateName ? { currentLinearState: normalizedIssue.stateName } : {}),
|
|
37
39
|
...(desiredStage ? { desiredStage } : {}),
|
|
38
40
|
...(options?.eventReceiptId !== undefined ? { desiredReceiptId: options.eventReceiptId } : {}),
|
|
39
|
-
...(
|
|
41
|
+
...(activeAgentSessionId !== undefined ? { activeAgentSessionId } : {}),
|
|
40
42
|
lastWebhookAt: new Date().toISOString(),
|
|
41
43
|
});
|
|
42
44
|
if (launchInput && !activeStageRun && delegatedToPatchRelay && stageAllowed) {
|
|
@@ -74,27 +76,14 @@ export class WebhookDesiredStageRecorder {
|
|
|
74
76
|
if (!normalizedIssue) {
|
|
75
77
|
return undefined;
|
|
76
78
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (normalized.triggerEvent === "delegateChanged") {
|
|
80
|
-
desiredStage = delegatedToPatchRelay ? resolveWorkflowStage(project, normalizedIssue.stateName) : undefined;
|
|
81
|
-
if (!desiredStage) {
|
|
82
|
-
return undefined;
|
|
83
|
-
}
|
|
84
|
-
if (!stageAllowed && !project.triggerEvents.includes("statusChanged")) {
|
|
85
|
-
return undefined;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
else if (normalized.triggerEvent === "agentSessionCreated" || normalized.triggerEvent === "agentPrompted") {
|
|
89
|
-
if (!delegatedToPatchRelay || !stageAllowed) {
|
|
90
|
-
return undefined;
|
|
91
|
-
}
|
|
92
|
-
desiredStage = resolveWorkflowStage(project, normalizedIssue.stateName);
|
|
79
|
+
if (normalized.triggerEvent !== "agentSessionCreated" && normalized.triggerEvent !== "agentPrompted") {
|
|
80
|
+
return undefined;
|
|
93
81
|
}
|
|
94
|
-
|
|
95
|
-
|
|
82
|
+
if (!delegatedToPatchRelay || !triggerEventAllowed(project, normalized.triggerEvent)) {
|
|
83
|
+
return undefined;
|
|
96
84
|
}
|
|
97
|
-
|
|
85
|
+
const desiredStage = resolveWorkflowStage(project, normalizedIssue.stateName);
|
|
86
|
+
if (!desiredStage) {
|
|
98
87
|
return undefined;
|
|
99
88
|
}
|
|
100
89
|
if (activeStageRun && desiredStage === activeStageRun.stage) {
|