patchrelay 0.7.10 → 0.8.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/commands/feed.js +17 -10
- package/dist/cli/formatters/text.js +16 -3
- package/dist/cli/help.js +1 -1
- package/dist/cli/index.js +1 -1
- package/dist/cli/operator-client.js +16 -0
- package/dist/config.js +169 -56
- package/dist/db/authoritative-ledger-store.js +6 -2
- package/dist/db/issue-workflow-coordinator.js +11 -0
- package/dist/db/issue-workflow-store.js +1 -0
- package/dist/db/migrations.js +22 -1
- package/dist/db/operator-feed-store.js +21 -3
- package/dist/db/webhook-event-store.js +13 -0
- package/dist/http.js +20 -10
- package/dist/install.js +18 -3
- package/dist/linear-workflow.js +20 -5
- package/dist/operator-feed.js +30 -12
- package/dist/preflight.js +5 -2
- package/dist/reconciliation-snapshot-builder.js +2 -1
- package/dist/service-stage-finalizer.js +243 -2
- package/dist/service-stage-runner.js +19 -29
- package/dist/service-webhook-processor.js +20 -0
- package/dist/service.js +1 -0
- package/dist/stage-failure.js +2 -2
- package/dist/stage-handoff.js +107 -0
- package/dist/stage-launch.js +38 -8
- package/dist/stage-lifecycle-publisher.js +35 -10
- package/dist/webhook-agent-session-handler.js +9 -1
- package/dist/webhook-desired-stage-recorder.js +24 -4
- package/dist/workflow-policy.js +115 -8
- package/package.json +1 -1
|
@@ -138,6 +138,7 @@ export class ServiceWebhookProcessor {
|
|
|
138
138
|
const receipt = this.ensureEventReceipt(event, project.id, routedIssue.id);
|
|
139
139
|
const hydrated = await this.hydrateIssueContext(project.id, normalized);
|
|
140
140
|
const hydratedIssue = hydrated.issue ?? routedIssue;
|
|
141
|
+
const priorIssue = this.stores.issueWorkflows.getTrackedIssue(project.id, hydratedIssue.id);
|
|
141
142
|
const issueState = this.desiredStageRecorder.record(project, hydrated, receipt ? { eventReceiptId: receipt.id } : undefined);
|
|
142
143
|
const observation = describeWebhookObservation(hydrated, issueState.delegatedToPatchRelay);
|
|
143
144
|
if (observation) {
|
|
@@ -146,11 +147,29 @@ export class ServiceWebhookProcessor {
|
|
|
146
147
|
kind: observation.kind,
|
|
147
148
|
issueKey: hydratedIssue.identifier,
|
|
148
149
|
projectId: project.id,
|
|
150
|
+
...(issueState.issue?.selectedWorkflowId ? { workflowId: issueState.issue.selectedWorkflowId } : {}),
|
|
149
151
|
...(observation.status ? { status: observation.status } : {}),
|
|
150
152
|
summary: observation.summary,
|
|
151
153
|
...(observation.detail ? { detail: observation.detail } : {}),
|
|
152
154
|
});
|
|
153
155
|
}
|
|
156
|
+
if (issueState.issue?.selectedWorkflowId &&
|
|
157
|
+
issueState.issue.selectedWorkflowId !== priorIssue?.selectedWorkflowId &&
|
|
158
|
+
(hydrated.triggerEvent === "agentSessionCreated" || hydrated.triggerEvent === "agentPrompted")) {
|
|
159
|
+
this.feed?.publish({
|
|
160
|
+
level: "info",
|
|
161
|
+
kind: "workflow",
|
|
162
|
+
issueKey: hydratedIssue.identifier,
|
|
163
|
+
projectId: project.id,
|
|
164
|
+
workflowId: issueState.issue.selectedWorkflowId,
|
|
165
|
+
...(issueState.desiredStage ? { nextStage: issueState.desiredStage } : {}),
|
|
166
|
+
status: "selected",
|
|
167
|
+
summary: `Selected ${issueState.issue.selectedWorkflowId} workflow`,
|
|
168
|
+
detail: issueState.desiredStage
|
|
169
|
+
? `PatchRelay will start with ${issueState.desiredStage} from ${hydratedIssue.stateName ?? "the current Linear state"}.`
|
|
170
|
+
: undefined,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
154
173
|
await this.agentSessionHandler.handle({
|
|
155
174
|
normalized: hydrated,
|
|
156
175
|
project,
|
|
@@ -168,6 +187,7 @@ export class ServiceWebhookProcessor {
|
|
|
168
187
|
issueKey: hydratedIssue.identifier,
|
|
169
188
|
projectId: project.id,
|
|
170
189
|
stage: issueState.desiredStage,
|
|
190
|
+
...(issueState.issue?.selectedWorkflowId ? { workflowId: issueState.issue.selectedWorkflowId } : {}),
|
|
171
191
|
status: "queued",
|
|
172
192
|
summary: `Queued ${issueState.desiredStage} workflow`,
|
|
173
193
|
detail: `Triggered by ${hydrated.triggerEvent}${hydratedIssue.stateName ? ` from ${hydratedIssue.stateName}` : ""}.`,
|
package/dist/service.js
CHANGED
|
@@ -12,6 +12,7 @@ function createServiceStores(db) {
|
|
|
12
12
|
webhookEvents: db.webhookEvents,
|
|
13
13
|
eventReceipts: db.eventReceipts,
|
|
14
14
|
issueControl: db.issueControl,
|
|
15
|
+
issueSessions: db.issueSessions,
|
|
15
16
|
workspaceOwnership: db.workspaceOwnership,
|
|
16
17
|
runLeases: db.runLeases,
|
|
17
18
|
obligations: db.obligations,
|
package/dist/stage-failure.js
CHANGED
|
@@ -9,10 +9,10 @@ export async function syncFailedStageToLinear(params) {
|
|
|
9
9
|
if (!linear) {
|
|
10
10
|
return;
|
|
11
11
|
}
|
|
12
|
-
const fallbackState = resolveFallbackLinearState(params.project, params.stageRun.stage);
|
|
12
|
+
const fallbackState = resolveFallbackLinearState(params.project, params.stageRun.stage, params.issue.selectedWorkflowId);
|
|
13
13
|
let shouldWriteFailureState = true;
|
|
14
14
|
if (params.requireActiveLinearStateMatch) {
|
|
15
|
-
const activeState = resolveActiveLinearState(params.project, params.stageRun.stage);
|
|
15
|
+
const activeState = resolveActiveLinearState(params.project, params.stageRun.stage, params.issue.selectedWorkflowId);
|
|
16
16
|
if (!activeState) {
|
|
17
17
|
shouldWriteFailureState = false;
|
|
18
18
|
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { safeJsonParse } from "./utils.js";
|
|
2
|
+
import { listAllowedTransitionTargets, listWorkflowStageIds, resolveWorkflowStageCandidate } from "./workflow-policy.js";
|
|
3
|
+
function normalize(value) {
|
|
4
|
+
const trimmed = value?.trim();
|
|
5
|
+
return trimmed ? trimmed.toLowerCase() : undefined;
|
|
6
|
+
}
|
|
7
|
+
function stripListPrefix(value) {
|
|
8
|
+
return value.replace(/^[-*•]\s+/, "").replace(/^\d+\.\s+/, "").trim();
|
|
9
|
+
}
|
|
10
|
+
function resolveTerminalTarget(value) {
|
|
11
|
+
const normalized = normalize(value)?.replace(/[\s_-]+/g, "");
|
|
12
|
+
if (!normalized) {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
if (["done", "complete", "completed", "shipped", "ship"].includes(normalized)) {
|
|
16
|
+
return "done";
|
|
17
|
+
}
|
|
18
|
+
if (["humanneeded", "humaninput", "needsinput", "unclear", "unknown", "blocked", "ambiguous"].includes(normalized)) {
|
|
19
|
+
return "human_needed";
|
|
20
|
+
}
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
export function resolveWorkflowTarget(project, value) {
|
|
24
|
+
return resolveWorkflowTargetForDefinition(project, value);
|
|
25
|
+
}
|
|
26
|
+
export function resolveWorkflowTargetForDefinition(project, value, workflowDefinitionId) {
|
|
27
|
+
return resolveWorkflowStageCandidate(project, value, workflowDefinitionId) ?? resolveTerminalTarget(value);
|
|
28
|
+
}
|
|
29
|
+
function summarizeSignalsHumanNeeded(lines) {
|
|
30
|
+
const joined = normalize(lines.join(" "));
|
|
31
|
+
if (!joined) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
return ["blocked", "unclear", "ambiguous", "human input", "human needed", "need human", "cannot determine"].some((token) => joined.includes(token));
|
|
35
|
+
}
|
|
36
|
+
export function parseStageHandoff(project, assistantMessages, workflowDefinitionId) {
|
|
37
|
+
const latestMessage = [...assistantMessages].reverse().find((message) => typeof message === "string" && message.trim().length > 0);
|
|
38
|
+
if (!latestMessage) {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
const lines = latestMessage
|
|
42
|
+
.split(/\r?\n/)
|
|
43
|
+
.map((line) => line.trimEnd());
|
|
44
|
+
const markerIndex = lines.findIndex((line) => /^stage result\s*:?\s*$/i.test(line.trim()));
|
|
45
|
+
const relevantLines = (markerIndex >= 0 ? lines.slice(markerIndex + 1) : lines)
|
|
46
|
+
.map((line) => stripListPrefix(line.trim()))
|
|
47
|
+
.filter(Boolean);
|
|
48
|
+
if (relevantLines.length === 0) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
const summaryLines = [];
|
|
52
|
+
let nextLikelyStageText;
|
|
53
|
+
let nextAttention;
|
|
54
|
+
for (const line of relevantLines) {
|
|
55
|
+
const nextStageMatch = line.match(/^next likely stage\s*:\s*(.+)$/i);
|
|
56
|
+
if (nextStageMatch) {
|
|
57
|
+
nextLikelyStageText = nextStageMatch[1]?.trim();
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const attentionMatch = line.match(/^(next attention|what to watch|watch carefully|pay attention|human attention)\s*:\s*(.+)$/i);
|
|
61
|
+
if (attentionMatch) {
|
|
62
|
+
nextAttention = attentionMatch[2]?.trim();
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
summaryLines.push(line);
|
|
66
|
+
}
|
|
67
|
+
const resolvedNextStage = resolveWorkflowTargetForDefinition(project, nextLikelyStageText, workflowDefinitionId);
|
|
68
|
+
return {
|
|
69
|
+
sourceText: latestMessage,
|
|
70
|
+
summaryLines,
|
|
71
|
+
...(nextLikelyStageText ? { nextLikelyStageText } : {}),
|
|
72
|
+
...(nextAttention ? { nextAttention } : {}),
|
|
73
|
+
suggestsHumanNeeded: summarizeSignalsHumanNeeded(summaryLines),
|
|
74
|
+
...(resolvedNextStage ? { resolvedNextStage } : {}),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
export function extractPriorStageHandoff(project, stageRun, workflowDefinitionId) {
|
|
78
|
+
if (!stageRun?.reportJson) {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
const report = safeJsonParse(stageRun.reportJson);
|
|
82
|
+
return report ? parseStageHandoff(project, report.assistantMessages, workflowDefinitionId) : undefined;
|
|
83
|
+
}
|
|
84
|
+
export function buildCarryForwardPrompt(params) {
|
|
85
|
+
const availableStages = listWorkflowStageIds(params.project, params.workflowDefinitionId);
|
|
86
|
+
const attemptNumber = params.stageHistory.filter((stageRun) => stageRun.stage === params.currentStage).length + 1;
|
|
87
|
+
const recentHistory = params.stageHistory.slice(-4).map((stageRun) => stageRun.stage);
|
|
88
|
+
const previousHandoff = extractPriorStageHandoff(params.project, params.previousStageRun, params.workflowDefinitionId);
|
|
89
|
+
const lines = [
|
|
90
|
+
`Workflow stage ids: ${availableStages.join(", ")}`,
|
|
91
|
+
`Allowed next targets from ${params.currentStage}: ${listAllowedTransitionTargets(params.project, params.currentStage, params.workflowDefinitionId).join(", ")}`,
|
|
92
|
+
`This is attempt ${attemptNumber} for the ${params.currentStage} stage.`,
|
|
93
|
+
recentHistory.length > 0 ? `Recent workflow history: ${recentHistory.join(" -> ")}` : undefined,
|
|
94
|
+
params.workspace?.branchName ? `Branch: ${params.workspace.branchName}` : undefined,
|
|
95
|
+
params.workspace?.worktreePath ? `Worktree: ${params.workspace.worktreePath}` : undefined,
|
|
96
|
+
params.previousStageRun ? "" : undefined,
|
|
97
|
+
params.previousStageRun ? "Carry-forward from the previous stage:" : undefined,
|
|
98
|
+
params.previousStageRun ? `- Prior stage: ${params.previousStageRun.stage}` : undefined,
|
|
99
|
+
previousHandoff?.summaryLines[0] ? `- Outcome: ${previousHandoff.summaryLines[0]}` : undefined,
|
|
100
|
+
previousHandoff && previousHandoff.summaryLines.length > 1
|
|
101
|
+
? `- Key facts: ${previousHandoff.summaryLines.slice(1, 3).join(" ")}`
|
|
102
|
+
: undefined,
|
|
103
|
+
previousHandoff?.nextAttention ? `- Watch next: ${previousHandoff.nextAttention}` : undefined,
|
|
104
|
+
params.previousStageRun?.threadId ? `- Prior thread: ${params.previousStageRun.threadId}` : undefined,
|
|
105
|
+
].filter((value) => Boolean(value));
|
|
106
|
+
return lines.length > 0 ? lines.join("\n") : undefined;
|
|
107
|
+
}
|
package/dist/stage-launch.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { buildCarryForwardPrompt } from "./stage-handoff.js";
|
|
4
|
+
import { listWorkflowStageIds, resolveWorkflowStageConfig } from "./workflow-policy.js";
|
|
4
5
|
function slugify(value) {
|
|
5
6
|
return value
|
|
6
7
|
.toLowerCase()
|
|
@@ -17,8 +18,8 @@ export function isCodexThreadId(value) {
|
|
|
17
18
|
}
|
|
18
19
|
return !value.startsWith("missing-thread-") && !value.startsWith("launch-failed-");
|
|
19
20
|
}
|
|
20
|
-
export function buildStageLaunchPlan(project, issue, stage) {
|
|
21
|
-
const workflow =
|
|
21
|
+
export function buildStageLaunchPlan(project, issue, stage, options) {
|
|
22
|
+
const workflow = resolveWorkflowStageConfig(project, stage, issue.selectedWorkflowId);
|
|
22
23
|
if (!workflow) {
|
|
23
24
|
throw new Error(`Workflow "${stage}" is not configured for project ${project.id}`);
|
|
24
25
|
}
|
|
@@ -26,15 +27,31 @@ export function buildStageLaunchPlan(project, issue, stage) {
|
|
|
26
27
|
const slug = issue.title ? slugify(issue.title) : "";
|
|
27
28
|
const branchSuffix = slug ? `${issueRef}-${slug}` : issueRef;
|
|
28
29
|
return {
|
|
29
|
-
branchName: `${project.branchPrefix}/${branchSuffix}`,
|
|
30
|
-
worktreePath: path.join(project.worktreeRoot, issueRef),
|
|
30
|
+
branchName: options?.branchName ?? `${project.branchPrefix}/${branchSuffix}`,
|
|
31
|
+
worktreePath: options?.worktreePath ?? path.join(project.worktreeRoot, issueRef),
|
|
31
32
|
workflowFile: workflow.workflowFile,
|
|
32
33
|
stage,
|
|
33
|
-
prompt: buildStagePrompt(issue, workflow.id, workflow.whenState, workflow.workflowFile
|
|
34
|
+
prompt: buildStagePrompt(project, issue, workflow.id, workflow.whenState, workflow.workflowFile, {
|
|
35
|
+
branchName: options?.branchName ?? `${project.branchPrefix}/${branchSuffix}`,
|
|
36
|
+
worktreePath: options?.worktreePath ?? path.join(project.worktreeRoot, issueRef),
|
|
37
|
+
...(issue.selectedWorkflowId ? { workflowDefinitionId: issue.selectedWorkflowId } : {}),
|
|
38
|
+
...(options?.previousStageRun ? { previousStageRun: options.previousStageRun } : {}),
|
|
39
|
+
...(options?.workspace ? { workspace: options.workspace } : {}),
|
|
40
|
+
stageHistory: options?.stageHistory ?? [],
|
|
41
|
+
}),
|
|
34
42
|
};
|
|
35
43
|
}
|
|
36
|
-
export function buildStagePrompt(issue, stage, triggerState, workflowFile) {
|
|
44
|
+
export function buildStagePrompt(project, issue, stage, triggerState, workflowFile, options) {
|
|
37
45
|
const workflowBody = existsSync(workflowFile) ? readFileSync(workflowFile, "utf8").trim() : "";
|
|
46
|
+
const carryForward = buildCarryForwardPrompt({
|
|
47
|
+
project,
|
|
48
|
+
currentStage: stage,
|
|
49
|
+
...(options?.workflowDefinitionId ? { workflowDefinitionId: options.workflowDefinitionId } : {}),
|
|
50
|
+
...(options?.previousStageRun ? { previousStageRun: options.previousStageRun } : {}),
|
|
51
|
+
...(options?.workspace ? { workspace: options.workspace } : {}),
|
|
52
|
+
stageHistory: options?.stageHistory ?? [],
|
|
53
|
+
});
|
|
54
|
+
const availableStages = listWorkflowStageIds(project, options?.workflowDefinitionId).join(", ");
|
|
38
55
|
return [
|
|
39
56
|
`Issue: ${issue.issueKey ?? issue.linearIssueId}`,
|
|
40
57
|
issue.title ? `Title: ${issue.title}` : undefined,
|
|
@@ -42,9 +59,22 @@ export function buildStagePrompt(issue, stage, triggerState, workflowFile) {
|
|
|
42
59
|
issue.currentLinearState ? `Current Linear State: ${issue.currentLinearState}` : undefined,
|
|
43
60
|
`Workflow: ${stage}`,
|
|
44
61
|
`Triggered By State: ${triggerState}`,
|
|
62
|
+
options?.branchName ? `Branch: ${options.branchName}` : undefined,
|
|
63
|
+
options?.worktreePath ? `Worktree: ${options.worktreePath}` : undefined,
|
|
64
|
+
"",
|
|
65
|
+
"Complete only the current workflow stage. Do not invent a new workflow or skip directly to another stage.",
|
|
66
|
+
"If the correct next step is unclear, say so plainly and use `human_needed` as the next likely stage.",
|
|
67
|
+
"",
|
|
68
|
+
carryForward ? "Carry-forward Context:" : undefined,
|
|
69
|
+
carryForward,
|
|
45
70
|
"",
|
|
46
71
|
"Operate only inside the prepared worktree for this issue. Continue the issue lifecycle in this workspace.",
|
|
47
|
-
"
|
|
72
|
+
"Use the repo workflow instructions below for this stage.",
|
|
73
|
+
"End with a short `Stage result:` section in plain text with exactly four bullets:",
|
|
74
|
+
"- what happened",
|
|
75
|
+
"- key facts or artifacts",
|
|
76
|
+
`- Next likely stage: one of ${availableStages}, done, or human_needed`,
|
|
77
|
+
"- what the next stage or human should pay attention to",
|
|
48
78
|
"",
|
|
49
79
|
`Workflow File: ${path.basename(workflowFile)}`,
|
|
50
80
|
workflowBody,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { buildAwaitingHandoffSessionPlan, buildCompletedSessionPlan, buildRunningSessionPlan, } from "./agent-session-plan.js";
|
|
1
|
+
import { buildAwaitingHandoffSessionPlan, buildCompletedSessionPlan, buildPreparingSessionPlan, buildRunningSessionPlan, } from "./agent-session-plan.js";
|
|
2
2
|
import { buildAgentSessionExternalUrls } from "./agent-session-presentation.js";
|
|
3
|
-
import { buildAwaitingHandoffComment, buildRunningStatusComment, resolveActiveLinearState, resolveWorkflowLabelCleanup, resolveWorkflowLabelNames, } from "./linear-workflow.js";
|
|
3
|
+
import { buildAwaitingHandoffComment, buildHumanNeededComment, buildRunningStatusComment, resolveActiveLinearState, resolveWorkflowLabelCleanup, resolveWorkflowLabelNames, } from "./linear-workflow.js";
|
|
4
4
|
import { sanitizeDiagnosticText } from "./utils.js";
|
|
5
5
|
export class StageLifecyclePublisher {
|
|
6
6
|
config;
|
|
@@ -16,7 +16,7 @@ export class StageLifecyclePublisher {
|
|
|
16
16
|
this.feed = feed;
|
|
17
17
|
}
|
|
18
18
|
async markStageActive(project, issue, stageRun) {
|
|
19
|
-
const activeState = resolveActiveLinearState(project, stageRun.stage);
|
|
19
|
+
const activeState = resolveActiveLinearState(project, stageRun.stage, issue.selectedWorkflowId);
|
|
20
20
|
const linear = await this.linearProvider.forProject(stageRun.projectId);
|
|
21
21
|
if (!activeState || !linear) {
|
|
22
22
|
return;
|
|
@@ -103,24 +103,30 @@ export class StageLifecyclePublisher {
|
|
|
103
103
|
async publishStageCompletion(stageRun, enqueueIssue) {
|
|
104
104
|
const refreshedIssue = this.stores.issueWorkflows.getTrackedIssue(stageRun.projectId, stageRun.linearIssueId);
|
|
105
105
|
if (refreshedIssue?.desiredStage) {
|
|
106
|
+
const linear = await this.linearProvider.forProject(stageRun.projectId);
|
|
107
|
+
if (refreshedIssue.activeAgentSessionId && linear) {
|
|
108
|
+
await this.updateAgentSession(linear, refreshedIssue, buildPreparingSessionPlan(refreshedIssue.desiredStage), refreshedIssue.desiredStage);
|
|
109
|
+
}
|
|
106
110
|
this.feed?.publish({
|
|
107
111
|
level: "info",
|
|
108
112
|
kind: "stage",
|
|
109
113
|
issueKey: refreshedIssue.issueKey,
|
|
110
114
|
projectId: refreshedIssue.projectId,
|
|
111
115
|
stage: stageRun.stage,
|
|
116
|
+
...(refreshedIssue.selectedWorkflowId ? { workflowId: refreshedIssue.selectedWorkflowId } : {}),
|
|
117
|
+
nextStage: refreshedIssue.desiredStage,
|
|
112
118
|
status: "queued",
|
|
113
119
|
summary: `Completed ${stageRun.stage} workflow and queued ${refreshedIssue.desiredStage}`,
|
|
114
120
|
});
|
|
115
121
|
await this.publishAgentCompletion(refreshedIssue, {
|
|
116
122
|
type: "thought",
|
|
117
|
-
body: `The ${stageRun.stage} workflow finished. PatchRelay is preparing the
|
|
123
|
+
body: `The ${stageRun.stage} workflow finished. PatchRelay is preparing the ${refreshedIssue.desiredStage} workflow next.`,
|
|
118
124
|
});
|
|
119
125
|
enqueueIssue(stageRun.projectId, stageRun.linearIssueId);
|
|
120
126
|
return;
|
|
121
127
|
}
|
|
122
128
|
const project = this.config.projects.find((candidate) => candidate.id === stageRun.projectId);
|
|
123
|
-
const activeState = project ? resolveActiveLinearState(project, stageRun.stage) : undefined;
|
|
129
|
+
const activeState = project ? resolveActiveLinearState(project, stageRun.stage, refreshedIssue?.selectedWorkflowId) : undefined;
|
|
124
130
|
const linear = project ? await this.linearProvider.forProject(stageRun.projectId) : undefined;
|
|
125
131
|
if (refreshedIssue && linear && project && activeState) {
|
|
126
132
|
try {
|
|
@@ -146,6 +152,7 @@ export class StageLifecyclePublisher {
|
|
|
146
152
|
issueKey: refreshedIssue.issueKey,
|
|
147
153
|
projectId: refreshedIssue.projectId,
|
|
148
154
|
stage: stageRun.stage,
|
|
155
|
+
...(refreshedIssue.selectedWorkflowId ? { workflowId: refreshedIssue.selectedWorkflowId } : {}),
|
|
149
156
|
status: "handoff",
|
|
150
157
|
summary: `Completed ${stageRun.stage} workflow`,
|
|
151
158
|
detail: `Waiting for a Linear state change or follow-up input while the issue remains in ${activeState}.`,
|
|
@@ -188,8 +195,11 @@ export class StageLifecyclePublisher {
|
|
|
188
195
|
}
|
|
189
196
|
}
|
|
190
197
|
if (refreshedIssue) {
|
|
198
|
+
let deliveredToSession = false;
|
|
191
199
|
if (refreshedIssue.activeAgentSessionId && linear) {
|
|
192
|
-
await this.updateAgentSession(linear, refreshedIssue,
|
|
200
|
+
deliveredToSession = await this.updateAgentSession(linear, refreshedIssue, refreshedIssue.lifecycleStatus === "paused"
|
|
201
|
+
? buildAwaitingHandoffSessionPlan(stageRun.stage)
|
|
202
|
+
: buildCompletedSessionPlan(stageRun.stage));
|
|
193
203
|
}
|
|
194
204
|
this.feed?.publish({
|
|
195
205
|
level: "info",
|
|
@@ -197,13 +207,28 @@ export class StageLifecyclePublisher {
|
|
|
197
207
|
issueKey: refreshedIssue.issueKey,
|
|
198
208
|
projectId: refreshedIssue.projectId,
|
|
199
209
|
stage: stageRun.stage,
|
|
210
|
+
...(refreshedIssue.selectedWorkflowId ? { workflowId: refreshedIssue.selectedWorkflowId } : {}),
|
|
200
211
|
status: "completed",
|
|
201
212
|
summary: `Completed ${stageRun.stage} workflow`,
|
|
202
213
|
});
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
214
|
+
deliveredToSession =
|
|
215
|
+
(await this.publishAgentCompletion(refreshedIssue, {
|
|
216
|
+
type: refreshedIssue.lifecycleStatus === "paused" ? "elicitation" : "response",
|
|
217
|
+
body: refreshedIssue.lifecycleStatus === "paused"
|
|
218
|
+
? `PatchRelay finished the ${stageRun.stage} workflow and now needs human input before it can continue.`
|
|
219
|
+
: `PatchRelay finished the ${stageRun.stage} workflow.`,
|
|
220
|
+
})) || deliveredToSession;
|
|
221
|
+
if (refreshedIssue.lifecycleStatus === "paused" && linear && !deliveredToSession) {
|
|
222
|
+
const result = await linear.upsertIssueComment({
|
|
223
|
+
issueId: stageRun.linearIssueId,
|
|
224
|
+
...(refreshedIssue.statusCommentId ? { commentId: refreshedIssue.statusCommentId } : {}),
|
|
225
|
+
body: buildHumanNeededComment({
|
|
226
|
+
issue: refreshedIssue,
|
|
227
|
+
stageRun: this.stores.issueWorkflows.getStageRun(stageRun.id) ?? stageRun,
|
|
228
|
+
}),
|
|
229
|
+
});
|
|
230
|
+
this.stores.workflowCoordinator.setIssueStatusComment(stageRun.projectId, stageRun.linearIssueId, result.id);
|
|
231
|
+
}
|
|
207
232
|
}
|
|
208
233
|
}
|
|
209
234
|
async updateAgentSession(linear, issue, plan, stage) {
|
|
@@ -35,7 +35,15 @@ export class AgentSessionWebhookHandler {
|
|
|
35
35
|
const issueControl = normalized.issue ? this.stores.issueControl.getIssueControl(project.id, normalized.issue.id) : undefined;
|
|
36
36
|
const activeRunLease = issueControl?.activeRunLeaseId !== undefined ? this.stores.runLeases.getRunLease(issueControl.activeRunLeaseId) : undefined;
|
|
37
37
|
const activeStage = activeRunLease?.stage;
|
|
38
|
-
const runnableWorkflow = normalized.issue?.stateName
|
|
38
|
+
const runnableWorkflow = normalized.issue?.stateName
|
|
39
|
+
? resolveWorkflowStage(project, normalized.issue.stateName, {
|
|
40
|
+
...(issue?.selectedWorkflowId
|
|
41
|
+
? { workflowDefinitionId: issue.selectedWorkflowId }
|
|
42
|
+
: normalized.issue
|
|
43
|
+
? { issue: normalized.issue }
|
|
44
|
+
: {}),
|
|
45
|
+
})
|
|
46
|
+
: undefined;
|
|
39
47
|
if (normalized.triggerEvent === "agentSessionCreated") {
|
|
40
48
|
if (!delegatedToPatchRelay) {
|
|
41
49
|
if (activeStage) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { triggerEventAllowed } from "./project-resolution.js";
|
|
2
|
-
import { resolveWorkflowStage } from "./workflow-policy.js";
|
|
2
|
+
import { resolveWorkflowStage, selectWorkflowDefinition } from "./workflow-policy.js";
|
|
3
3
|
function trimPrompt(value) {
|
|
4
4
|
const trimmed = value?.trim();
|
|
5
5
|
return trimmed ? trimmed : undefined;
|
|
@@ -25,7 +25,8 @@ export class WebhookDesiredStageRecorder {
|
|
|
25
25
|
const activeStageRun = issueControl?.activeRunLeaseId !== undefined ? this.stores.issueWorkflows.getStageRun(issueControl.activeRunLeaseId) : undefined;
|
|
26
26
|
const delegatedToPatchRelay = this.isDelegatedToPatchRelay(project, normalized);
|
|
27
27
|
const stageAllowed = triggerEventAllowed(project, normalized.triggerEvent);
|
|
28
|
-
const
|
|
28
|
+
const selectedWorkflowId = this.resolveSelectedWorkflowId(project, normalized, issue, activeStageRun, delegatedToPatchRelay);
|
|
29
|
+
const desiredStage = this.resolveDesiredStage(project, normalized, issue, activeStageRun, delegatedToPatchRelay, selectedWorkflowId);
|
|
29
30
|
const launchInput = this.resolveLaunchInput(normalized.agentSession);
|
|
30
31
|
const activeAgentSessionId = normalized.agentSession?.id ??
|
|
31
32
|
(!activeStageRun && (desiredStage || (normalized.triggerEvent === "delegateChanged" && !delegatedToPatchRelay)) ? null : undefined);
|
|
@@ -36,6 +37,7 @@ export class WebhookDesiredStageRecorder {
|
|
|
36
37
|
...(normalizedIssue.title ? { title: normalizedIssue.title } : {}),
|
|
37
38
|
...(normalizedIssue.url ? { issueUrl: normalizedIssue.url } : {}),
|
|
38
39
|
...(normalizedIssue.stateName ? { currentLinearState: normalizedIssue.stateName } : {}),
|
|
40
|
+
...(selectedWorkflowId !== undefined ? { selectedWorkflowId } : {}),
|
|
39
41
|
...(desiredStage ? { desiredStage } : {}),
|
|
40
42
|
...(options?.eventReceiptId !== undefined ? { desiredReceiptId: options.eventReceiptId } : {}),
|
|
41
43
|
...(activeAgentSessionId !== undefined ? { activeAgentSessionId } : {}),
|
|
@@ -71,7 +73,7 @@ export class WebhookDesiredStageRecorder {
|
|
|
71
73
|
}
|
|
72
74
|
return normalizedIssue.delegateId === installation.actorId;
|
|
73
75
|
}
|
|
74
|
-
resolveDesiredStage(project, normalized, issue, activeStageRun, delegatedToPatchRelay) {
|
|
76
|
+
resolveDesiredStage(project, normalized, issue, activeStageRun, delegatedToPatchRelay, selectedWorkflowId) {
|
|
75
77
|
const normalizedIssue = normalized.issue;
|
|
76
78
|
if (!normalizedIssue) {
|
|
77
79
|
return undefined;
|
|
@@ -82,7 +84,9 @@ export class WebhookDesiredStageRecorder {
|
|
|
82
84
|
if (!delegatedToPatchRelay || !triggerEventAllowed(project, normalized.triggerEvent)) {
|
|
83
85
|
return undefined;
|
|
84
86
|
}
|
|
85
|
-
const desiredStage = resolveWorkflowStage(project, normalizedIssue.stateName
|
|
87
|
+
const desiredStage = resolveWorkflowStage(project, normalizedIssue.stateName, {
|
|
88
|
+
...(selectedWorkflowId ? { workflowDefinitionId: selectedWorkflowId } : {}),
|
|
89
|
+
});
|
|
86
90
|
if (!desiredStage) {
|
|
87
91
|
return undefined;
|
|
88
92
|
}
|
|
@@ -94,6 +98,22 @@ export class WebhookDesiredStageRecorder {
|
|
|
94
98
|
}
|
|
95
99
|
return desiredStage;
|
|
96
100
|
}
|
|
101
|
+
resolveSelectedWorkflowId(project, normalized, issue, activeStageRun, delegatedToPatchRelay) {
|
|
102
|
+
if (activeStageRun) {
|
|
103
|
+
return issue?.selectedWorkflowId;
|
|
104
|
+
}
|
|
105
|
+
if (normalized.triggerEvent !== "agentSessionCreated" && normalized.triggerEvent !== "agentPrompted") {
|
|
106
|
+
return issue?.selectedWorkflowId;
|
|
107
|
+
}
|
|
108
|
+
if (!delegatedToPatchRelay || !triggerEventAllowed(project, normalized.triggerEvent) || !normalized.issue) {
|
|
109
|
+
return issue?.selectedWorkflowId;
|
|
110
|
+
}
|
|
111
|
+
const selectedWorkflow = selectWorkflowDefinition(project, normalized.issue);
|
|
112
|
+
if (selectedWorkflow) {
|
|
113
|
+
return selectedWorkflow.id;
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
97
117
|
resolveLaunchInput(agentSession) {
|
|
98
118
|
const promptBody = trimPrompt(agentSession?.promptBody);
|
|
99
119
|
if (promptBody) {
|
package/dist/workflow-policy.js
CHANGED
|
@@ -2,6 +2,9 @@ function normalize(value) {
|
|
|
2
2
|
const trimmed = value?.trim();
|
|
3
3
|
return trimmed ? trimmed.toLowerCase() : undefined;
|
|
4
4
|
}
|
|
5
|
+
function normalizeWorkflowLabel(value) {
|
|
6
|
+
return normalize(value)?.replace(/[\s_-]+/g, "");
|
|
7
|
+
}
|
|
5
8
|
function extractIssuePrefix(identifier) {
|
|
6
9
|
const value = identifier?.trim();
|
|
7
10
|
if (!value) {
|
|
@@ -10,25 +13,129 @@ function extractIssuePrefix(identifier) {
|
|
|
10
13
|
const [prefix] = value.split("-", 1);
|
|
11
14
|
return prefix ? prefix.toUpperCase() : undefined;
|
|
12
15
|
}
|
|
13
|
-
|
|
16
|
+
function withWorkflowDefinitionId(workflowDefinitionId) {
|
|
17
|
+
return workflowDefinitionId ? { workflowDefinitionId } : undefined;
|
|
18
|
+
}
|
|
19
|
+
export function listProjectWorkflowDefinitions(project) {
|
|
20
|
+
if (project.workflowDefinitions && project.workflowDefinitions.length > 0) {
|
|
21
|
+
return project.workflowDefinitions;
|
|
22
|
+
}
|
|
23
|
+
return [
|
|
24
|
+
{
|
|
25
|
+
id: project.workflowSelection?.defaultWorkflowId ?? "default",
|
|
26
|
+
stages: project.workflows,
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
export function resolveWorkflowDefinitionById(project, workflowDefinitionId) {
|
|
31
|
+
const normalized = normalize(workflowDefinitionId);
|
|
32
|
+
if (!normalized) {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
return listProjectWorkflowDefinitions(project).find((definition) => normalize(definition.id) === normalized);
|
|
36
|
+
}
|
|
37
|
+
export function selectWorkflowDefinition(project, issue) {
|
|
38
|
+
const workflowDefinitions = listProjectWorkflowDefinitions(project);
|
|
39
|
+
if (workflowDefinitions.length === 0) {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
const labelNames = new Set((issue?.labelNames ?? []).map((label) => label.trim().toLowerCase()).filter(Boolean));
|
|
43
|
+
const matchedWorkflowIds = new Set();
|
|
44
|
+
for (const rule of project.workflowSelection?.byLabel ?? []) {
|
|
45
|
+
if (labelNames.has(rule.label.trim().toLowerCase())) {
|
|
46
|
+
matchedWorkflowIds.add(rule.workflowId);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (matchedWorkflowIds.size === 1) {
|
|
50
|
+
const [workflowId] = [...matchedWorkflowIds];
|
|
51
|
+
return resolveWorkflowDefinitionById(project, workflowId);
|
|
52
|
+
}
|
|
53
|
+
if (matchedWorkflowIds.size > 1) {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
if (project.workflowSelection?.defaultWorkflowId) {
|
|
57
|
+
return resolveWorkflowDefinitionById(project, project.workflowSelection.defaultWorkflowId);
|
|
58
|
+
}
|
|
59
|
+
return workflowDefinitions[0];
|
|
60
|
+
}
|
|
61
|
+
function resolveStageList(project, options) {
|
|
62
|
+
if (options?.workflowDefinitionId) {
|
|
63
|
+
return resolveWorkflowDefinitionById(project, options.workflowDefinitionId)?.stages ?? [];
|
|
64
|
+
}
|
|
65
|
+
if (options?.issue) {
|
|
66
|
+
return selectWorkflowDefinition(project, options.issue)?.stages ?? [];
|
|
67
|
+
}
|
|
68
|
+
return project.workflows;
|
|
69
|
+
}
|
|
70
|
+
export function resolveWorkflow(project, stateName, options) {
|
|
14
71
|
const normalized = normalize(stateName);
|
|
15
72
|
if (!normalized) {
|
|
16
73
|
return undefined;
|
|
17
74
|
}
|
|
18
|
-
return project.
|
|
75
|
+
return resolveStageList(project, options).find((workflow) => normalize(workflow.whenState) === normalized);
|
|
19
76
|
}
|
|
20
|
-
export function resolveWorkflowStage(project, stateName) {
|
|
21
|
-
return resolveWorkflow(project, stateName)?.id;
|
|
77
|
+
export function resolveWorkflowStage(project, stateName, options) {
|
|
78
|
+
return resolveWorkflow(project, stateName, options)?.id;
|
|
22
79
|
}
|
|
23
|
-
export function
|
|
80
|
+
export function resolveWorkflowStageConfig(project, workflowId, workflowDefinitionId) {
|
|
24
81
|
const normalized = normalize(workflowId);
|
|
25
82
|
if (!normalized) {
|
|
26
83
|
return undefined;
|
|
27
84
|
}
|
|
28
|
-
return project.
|
|
85
|
+
return resolveStageList(project, withWorkflowDefinitionId(workflowDefinitionId)).find((workflow) => normalize(workflow.id) === normalized);
|
|
86
|
+
}
|
|
87
|
+
export function listRunnableStates(project, options) {
|
|
88
|
+
return [...new Set(resolveStageList(project, options).map((workflow) => workflow.whenState))];
|
|
89
|
+
}
|
|
90
|
+
export function listWorkflowStageIds(project, workflowDefinitionId) {
|
|
91
|
+
return resolveStageList(project, withWorkflowDefinitionId(workflowDefinitionId)).map((workflow) => workflow.id);
|
|
92
|
+
}
|
|
93
|
+
export function resolveWorkflowIndex(project, workflowId, workflowDefinitionId) {
|
|
94
|
+
if (!workflowId) {
|
|
95
|
+
return -1;
|
|
96
|
+
}
|
|
97
|
+
return resolveStageList(project, withWorkflowDefinitionId(workflowDefinitionId)).findIndex((workflow) => workflow.id === workflowId);
|
|
29
98
|
}
|
|
30
|
-
export function
|
|
31
|
-
|
|
99
|
+
export function resolveDefaultTransitionTarget(project, currentStage, workflowDefinitionId) {
|
|
100
|
+
const stages = resolveStageList(project, withWorkflowDefinitionId(workflowDefinitionId));
|
|
101
|
+
const currentIndex = resolveWorkflowIndex(project, currentStage, workflowDefinitionId);
|
|
102
|
+
if (currentIndex < 0) {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
const nextStage = stages[currentIndex + 1]?.id;
|
|
106
|
+
return nextStage ?? "done";
|
|
107
|
+
}
|
|
108
|
+
export function listAllowedTransitionTargets(project, currentStage, workflowDefinitionId) {
|
|
109
|
+
const stages = resolveStageList(project, withWorkflowDefinitionId(workflowDefinitionId));
|
|
110
|
+
const currentIndex = resolveWorkflowIndex(project, currentStage, workflowDefinitionId);
|
|
111
|
+
if (currentIndex < 0) {
|
|
112
|
+
return ["human_needed"];
|
|
113
|
+
}
|
|
114
|
+
const targets = new Set(["human_needed"]);
|
|
115
|
+
const defaultTarget = resolveDefaultTransitionTarget(project, currentStage, workflowDefinitionId);
|
|
116
|
+
if (defaultTarget) {
|
|
117
|
+
targets.add(defaultTarget);
|
|
118
|
+
}
|
|
119
|
+
if (currentIndex > 0) {
|
|
120
|
+
targets.add(stages[currentIndex - 1].id);
|
|
121
|
+
}
|
|
122
|
+
if (currentIndex > 1) {
|
|
123
|
+
targets.add(stages[0].id);
|
|
124
|
+
}
|
|
125
|
+
return [...targets];
|
|
126
|
+
}
|
|
127
|
+
export function transitionTargetAllowed(project, currentStage, nextTarget, workflowDefinitionId) {
|
|
128
|
+
return listAllowedTransitionTargets(project, currentStage, workflowDefinitionId).includes(nextTarget);
|
|
129
|
+
}
|
|
130
|
+
export function resolveWorkflowStageCandidate(project, value, workflowDefinitionId) {
|
|
131
|
+
const normalized = normalizeWorkflowLabel(value);
|
|
132
|
+
if (!normalized) {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
return resolveStageList(project, withWorkflowDefinitionId(workflowDefinitionId)).find((workflow) => {
|
|
136
|
+
const candidates = [workflow.id, workflow.whenState, workflow.activeState];
|
|
137
|
+
return candidates.some((candidate) => normalizeWorkflowLabel(candidate) === normalized);
|
|
138
|
+
})?.id;
|
|
32
139
|
}
|
|
33
140
|
export function matchesProject(issue, project) {
|
|
34
141
|
const issuePrefix = extractIssuePrefix(issue.identifier);
|