patchrelay 0.7.9 → 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.
@@ -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;
@@ -84,7 +84,7 @@ export class StageLifecyclePublisher {
84
84
  await linear.createAgentActivity({
85
85
  agentSessionId: issue.activeAgentSessionId,
86
86
  content: {
87
- type: "thought",
87
+ type: "response",
88
88
  body: `PatchRelay started the ${stage} workflow and is working in the background.`,
89
89
  },
90
90
  });
@@ -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}.`,
@@ -155,7 +162,7 @@ export class StageLifecyclePublisher {
155
162
  type: "elicitation",
156
163
  body: `PatchRelay finished the ${stageRun.stage} workflow. Move the issue to its next workflow state or leave a follow-up prompt to continue.`,
157
164
  })) || deliveredToSession;
158
- if (!deliveredToSession) {
165
+ if (!deliveredToSession && !refreshedIssue.activeAgentSessionId) {
159
166
  const result = await linear.upsertIssueComment({
160
167
  issueId: stageRun.linearIssueId,
161
168
  ...(refreshedIssue.statusCommentId ? { commentId: refreshedIssue.statusCommentId } : {}),
@@ -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) {
@@ -66,7 +74,7 @@ export class AgentSessionWebhookHandler {
66
74
  if (desiredStage) {
67
75
  await this.agentActivity.updateSession(buildSessionUpdateParams(project.id, normalized.agentSession.id, issue?.issueKey ?? normalized.issue?.identifier, buildPreparingSessionPlan(desiredStage)));
68
76
  await this.agentActivity.publishForSession(project.id, normalized.agentSession.id, {
69
- type: "thought",
77
+ type: "response",
70
78
  body: `PatchRelay started working on the ${desiredStage} workflow and is preparing the workspace.`,
71
79
  }, { ephemeral: false });
72
80
  return;
@@ -137,7 +145,7 @@ export class AgentSessionWebhookHandler {
137
145
  if (!activeRunLease && desiredStage) {
138
146
  await this.agentActivity.updateSession(buildSessionUpdateParams(project.id, normalized.agentSession.id, issue?.issueKey ?? normalized.issue?.identifier, buildPreparingSessionPlan(desiredStage)));
139
147
  await this.agentActivity.publishForSession(project.id, normalized.agentSession.id, {
140
- type: "thought",
148
+ type: "response",
141
149
  body: `PatchRelay is preparing the ${desiredStage} workflow from your latest prompt.`,
142
150
  }, { ephemeral: false });
143
151
  return;
@@ -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.9",
3
+ "version": "0.8.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {