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.
@@ -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,
@@ -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
+ }
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
- import { resolveWorkflowById } from "./workflow-policy.js";
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 = resolveWorkflowById(project, stage);
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
- "Capture a crisp summary of what you did, what changed, and what remains blocked so PatchRelay can publish a read-only report.",
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 next requested workflow.`,
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, buildCompletedSessionPlan(stageRun.stage));
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
- await this.publishAgentCompletion(refreshedIssue, {
204
- type: "response",
205
- body: `PatchRelay finished the ${stageRun.stage} workflow.`,
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 ? resolveWorkflowStage(project, normalized.issue.stateName) : undefined;
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 desiredStage = this.resolveDesiredStage(project, normalized, issue, activeStageRun, delegatedToPatchRelay);
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) {
@@ -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
- export function resolveWorkflow(project, stateName) {
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.workflows.find((workflow) => normalize(workflow.whenState) === normalized);
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 resolveWorkflowById(project, workflowId) {
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.workflows.find((workflow) => normalize(workflow.id) === normalized);
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 listRunnableStates(project) {
31
- return project.workflows.map((workflow) => workflow.whenState);
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.7.10",
3
+ "version": "0.8.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {