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
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.7.9",
4
- "commit": "9f2abbe93f17",
5
- "builtAt": "2026-03-17T10:42:10.173Z"
3
+ "version": "0.8.0",
4
+ "commit": "c4fcada0fce7",
5
+ "builtAt": "2026-03-18T10:29:36.175Z"
6
6
  }
@@ -15,23 +15,30 @@ function parseLimit(value) {
15
15
  }
16
16
  return parsed;
17
17
  }
18
+ function readOptionalStringFlag(parsed, name) {
19
+ const value = parsed.flags.get(name);
20
+ if (value === true) {
21
+ throw new Error(`--${name} requires a value.`);
22
+ }
23
+ return typeof value === "string" ? value.trim() || undefined : undefined;
24
+ }
18
25
  export async function handleFeedCommand(params) {
19
26
  const limit = parseLimit(params.parsed.flags.get("limit"));
20
27
  const follow = params.parsed.flags.get("follow") === true;
21
- const issueFlag = params.parsed.flags.get("issue");
22
- const projectFlag = params.parsed.flags.get("project");
23
- if (issueFlag === true) {
24
- throw new Error("--issue requires a value.");
25
- }
26
- if (projectFlag === true) {
27
- throw new Error("--project requires a value.");
28
- }
29
- const issueKey = typeof issueFlag === "string" ? issueFlag.trim() || undefined : undefined;
30
- const projectId = typeof projectFlag === "string" ? projectFlag.trim() || undefined : undefined;
28
+ const issueKey = readOptionalStringFlag(params.parsed, "issue");
29
+ const projectId = readOptionalStringFlag(params.parsed, "project");
30
+ const kind = readOptionalStringFlag(params.parsed, "kind");
31
+ const stage = readOptionalStringFlag(params.parsed, "stage");
32
+ const status = readOptionalStringFlag(params.parsed, "status");
33
+ const workflowId = readOptionalStringFlag(params.parsed, "workflow");
31
34
  const query = {
32
35
  limit,
33
36
  ...(issueKey ? { issueKey } : {}),
34
37
  ...(projectId ? { projectId } : {}),
38
+ ...(kind ? { kind } : {}),
39
+ ...(stage ? { stage } : {}),
40
+ ...(status ? { status } : {}),
41
+ ...(workflowId ? { workflowId } : {}),
35
42
  };
36
43
  if (!follow) {
37
44
  const result = await params.data.listOperatorFeed(query);
@@ -134,26 +134,39 @@ function formatFeedStatus(event, color) {
134
134
  if (event.level === "error" || raw === "failed" || raw === "delivery_failed") {
135
135
  return colorize(color, "31", padded);
136
136
  }
137
- if (event.level === "warn" || raw === "ignored" || raw === "fallback" || raw === "handoff") {
137
+ if (event.level === "warn" || raw === "ignored" || raw === "fallback" || raw === "handoff" || raw === "transition_suppressed") {
138
138
  return colorize(color, "33", padded);
139
139
  }
140
- if (raw === "running" || raw === "started" || raw === "delegated") {
140
+ if (raw === "running" || raw === "started" || raw === "delegated" || raw === "transition_chosen" || raw === "completed") {
141
141
  return colorize(color, "32", padded);
142
142
  }
143
- if (raw === "queued") {
143
+ if (raw === "queued" || raw === "selected") {
144
144
  return colorize(color, "36", padded);
145
145
  }
146
146
  return colorize(color, "2", padded);
147
147
  }
148
+ function formatFeedMeta(event, color) {
149
+ const parts = [
150
+ event.workflowId ? `workflow:${event.workflowId}` : undefined,
151
+ event.stage ? `stage:${event.stage}` : undefined,
152
+ event.nextStage ? `next:${event.nextStage}` : undefined,
153
+ ].filter(Boolean);
154
+ if (parts.length === 0) {
155
+ return undefined;
156
+ }
157
+ return colorize(color, "2", `[${parts.join(" ")}]`);
158
+ }
148
159
  export function formatOperatorFeedEvent(event, options) {
149
160
  const color = options?.color === true;
150
161
  const timestamp = new Date(event.at).toLocaleTimeString("en-GB", { hour12: false });
151
162
  const issue = event.issueKey ?? event.projectId ?? "-";
163
+ const meta = formatFeedMeta(event, color);
152
164
  const line = [
153
165
  colorize(color, "2", timestamp),
154
166
  colorize(color, "1", issue.padEnd(10)),
155
167
  formatFeedStatus(event, color),
156
168
  event.summary,
169
+ ...(meta ? [meta] : []),
157
170
  ].join(" ");
158
171
  if (!event.detail) {
159
172
  return `${line}\n`;
package/dist/cli/help.js CHANGED
@@ -37,7 +37,7 @@ export function rootHelpText() {
37
37
  " connect [--project <projectId>] [--no-open] [--timeout <seconds>] [--json]",
38
38
  " Advanced: start or reuse a Linear installation directly",
39
39
  " installations [--json] Show connected Linear installations",
40
- " feed [--follow] [--limit <count>] [--issue <issueKey>] [--project <projectId>] [--json]",
40
+ " feed [--follow] [--limit <count>] [--issue <issueKey>] [--project <projectId>] [--kind <kind>] [--stage <stage>] [--status <status>] [--workflow <id>] [--json]",
41
41
  " Show a live operator feed from the daemon",
42
42
  " serve Run the local PatchRelay service",
43
43
  " inspect <issueKey> Show the latest known issue state",
package/dist/cli/index.js CHANGED
@@ -88,7 +88,7 @@ function validateFlags(command, commandArgs, parsed) {
88
88
  assertKnownFlags(parsed, command, ["json"]);
89
89
  return;
90
90
  case "feed":
91
- assertKnownFlags(parsed, command, ["follow", "limit", "issue", "project", "json"]);
91
+ assertKnownFlags(parsed, command, ["follow", "limit", "issue", "project", "kind", "stage", "status", "workflow", "json"]);
92
92
  return;
93
93
  case "install-service":
94
94
  assertKnownFlags(parsed, command, ["force", "write-only", "json"]);
@@ -23,6 +23,10 @@ export class CliOperatorApiClient {
23
23
  ...(options?.limit && options.limit > 0 ? { limit: String(options.limit) } : {}),
24
24
  ...(options?.issueKey ? { issue: options.issueKey } : {}),
25
25
  ...(options?.projectId ? { project: options.projectId } : {}),
26
+ ...(options?.kind ? { kind: options.kind } : {}),
27
+ ...(options?.stage ? { stage: options.stage } : {}),
28
+ ...(options?.status ? { status: options.status } : {}),
29
+ ...(options?.workflowId ? { workflow: options.workflowId } : {}),
26
30
  });
27
31
  }
28
32
  async followOperatorFeed(onEvent, options) {
@@ -37,6 +41,18 @@ export class CliOperatorApiClient {
37
41
  if (options?.projectId) {
38
42
  url.searchParams.set("project", options.projectId);
39
43
  }
44
+ if (options?.kind) {
45
+ url.searchParams.set("kind", options.kind);
46
+ }
47
+ if (options?.stage) {
48
+ url.searchParams.set("stage", options.stage);
49
+ }
50
+ if (options?.status) {
51
+ url.searchParams.set("status", options.status);
52
+ }
53
+ if (options?.workflowId) {
54
+ url.searchParams.set("workflow", options.workflowId);
55
+ }
40
56
  const response = await fetch(url, {
41
57
  method: "GET",
42
58
  headers: {
package/dist/config.js CHANGED
@@ -5,6 +5,8 @@ import { z } from "zod";
5
5
  import { getDefaultConfigPath, getDefaultDatabasePath, getDefaultLogPath, getDefaultRuntimeEnvPath, getDefaultServiceEnvPath, getPatchRelayDataDir, } from "./runtime-paths.js";
6
6
  import { ensureAbsolutePath } from "./utils.js";
7
7
  const LINEAR_OAUTH_CALLBACK_PATH = "/oauth/linear/callback";
8
+ const REPO_SETTINGS_DIRNAME = ".patchrelay";
9
+ const REPO_SETTINGS_FILENAME = "project.json";
8
10
  const workflowSchema = z.object({
9
11
  id: z.string().min(1),
10
12
  when_state: z.string().min(1),
@@ -12,25 +14,51 @@ const workflowSchema = z.object({
12
14
  workflow_file: z.string().min(1),
13
15
  fallback_state: z.string().min(1).nullable().optional(),
14
16
  });
17
+ const workflowDefinitionSchema = z.object({
18
+ id: z.string().min(1),
19
+ stages: z.array(workflowSchema).min(1),
20
+ });
21
+ const workflowSelectionSchema = z.object({
22
+ default_workflow: z.string().min(1).optional(),
23
+ by_label: z
24
+ .array(z.object({
25
+ label: z.string().min(1),
26
+ workflow: z.string().min(1),
27
+ }))
28
+ .default([]),
29
+ });
30
+ const workflowLabelsSchema = z
31
+ .object({
32
+ working: z.string().min(1).optional(),
33
+ awaiting_handoff: z.string().min(1).optional(),
34
+ })
35
+ .optional();
36
+ const trustedActorsSchema = z
37
+ .object({
38
+ ids: z.array(z.string().min(1)).default([]),
39
+ names: z.array(z.string().min(1)).default([]),
40
+ emails: z.array(z.string().email()).default([]),
41
+ email_domains: z.array(z.string().min(1)).default([]),
42
+ })
43
+ .optional();
44
+ const repoSettingsSchema = z.object({
45
+ workflows: z.array(workflowSchema).min(1).optional(),
46
+ workflow_definitions: z.array(workflowDefinitionSchema).min(1).optional(),
47
+ workflow_selection: workflowSelectionSchema.optional(),
48
+ workflow_labels: workflowLabelsSchema,
49
+ trusted_actors: trustedActorsSchema,
50
+ trigger_events: z.array(z.string().min(1)).min(1).optional(),
51
+ branch_prefix: z.string().min(1).optional(),
52
+ });
15
53
  const projectSchema = z.object({
16
54
  id: z.string().min(1),
17
55
  repo_path: z.string().min(1),
18
56
  worktree_root: z.string().min(1).optional(),
19
57
  workflows: z.array(workflowSchema).min(1).optional(),
20
- workflow_labels: z
21
- .object({
22
- working: z.string().min(1).optional(),
23
- awaiting_handoff: z.string().min(1).optional(),
24
- })
25
- .optional(),
26
- trusted_actors: z
27
- .object({
28
- ids: z.array(z.string().min(1)).default([]),
29
- names: z.array(z.string().min(1)).default([]),
30
- emails: z.array(z.string().email()).default([]),
31
- email_domains: z.array(z.string().min(1)).default([]),
32
- })
33
- .optional(),
58
+ workflow_definitions: z.array(workflowDefinitionSchema).min(1).optional(),
59
+ workflow_selection: workflowSelectionSchema.optional(),
60
+ workflow_labels: workflowLabelsSchema,
61
+ trusted_actors: trustedActorsSchema,
34
62
  issue_key_prefixes: z.array(z.string().min(1)).default([]),
35
63
  linear_team_ids: z.array(z.string().min(1)).default([]),
36
64
  allow_labels: z.array(z.string().min(1)).default([]),
@@ -134,6 +162,12 @@ const builtinWorkflows = [
134
162
  fallback_state: "Human Needed",
135
163
  },
136
164
  ];
165
+ const builtinWorkflowDefinitions = [
166
+ {
167
+ id: "default",
168
+ stages: builtinWorkflows,
169
+ },
170
+ ];
137
171
  function withSectionDefaults(input) {
138
172
  const source = input && typeof input === "object" ? input : {};
139
173
  const { linear: _linear, runner: _runner, ...rest } = source;
@@ -159,6 +193,9 @@ function withSectionDefaults(input) {
159
193
  },
160
194
  };
161
195
  }
196
+ function resolveRepoSettingsPath(repoPath) {
197
+ return path.join(repoPath, REPO_SETTINGS_DIRNAME, REPO_SETTINGS_FILENAME);
198
+ }
162
199
  function expandEnv(value, env) {
163
200
  if (typeof value === "string") {
164
201
  return value.replace(/\$\{([A-Z0-9_]+)(?::-(.*?))?\}/g, (_match, name, fallback) => {
@@ -230,6 +267,72 @@ function readEnvFilesForProfile(configPath, profile) {
230
267
  function resolveWorkflowFilePath(repoPath, workflowFile) {
231
268
  return path.isAbsolute(workflowFile) ? ensureAbsolutePath(workflowFile) : path.resolve(repoPath, workflowFile);
232
269
  }
270
+ function parseJsonFile(filePath, label) {
271
+ const raw = readFileSync(filePath, "utf8");
272
+ try {
273
+ return JSON.parse(raw);
274
+ }
275
+ catch (error) {
276
+ const message = error instanceof Error ? error.message : String(error);
277
+ throw new Error(`Invalid JSON ${label}: ${filePath}: ${message}`, {
278
+ cause: error,
279
+ });
280
+ }
281
+ }
282
+ function readRepoSettings(repoPath, env) {
283
+ const configPath = resolveRepoSettingsPath(repoPath);
284
+ if (!existsSync(configPath)) {
285
+ return undefined;
286
+ }
287
+ const parsed = repoSettingsSchema.parse(expandEnv(parseJsonFile(configPath, "repo settings file"), env));
288
+ return {
289
+ ...parsed,
290
+ configPath,
291
+ };
292
+ }
293
+ function mergeWorkflowStages(repoPath, workflows) {
294
+ return workflows.map((workflow) => ({
295
+ id: workflow.id,
296
+ whenState: workflow.when_state,
297
+ activeState: workflow.active_state,
298
+ workflowFile: resolveWorkflowFilePath(repoPath, workflow.workflow_file),
299
+ ...(workflow.fallback_state ? { fallbackState: workflow.fallback_state } : {}),
300
+ }));
301
+ }
302
+ function mergeWorkflowDefinitions(repoPath, definitions) {
303
+ return definitions.map((definition) => ({
304
+ id: definition.id,
305
+ stages: mergeWorkflowStages(repoPath, definition.stages),
306
+ }));
307
+ }
308
+ function resolveProjectWorkflowConfig(repoPath, project, repoSettings) {
309
+ const selectedDefinitions = repoSettings?.workflow_definitions ??
310
+ project.workflow_definitions ??
311
+ (repoSettings?.workflows
312
+ ? [{ id: "default", stages: repoSettings.workflows }]
313
+ : project.workflows
314
+ ? [{ id: "default", stages: project.workflows }]
315
+ : builtinWorkflowDefinitions.map((definition) => ({ id: definition.id, stages: [...definition.stages] })));
316
+ const workflowDefinitions = mergeWorkflowDefinitions(repoPath, selectedDefinitions);
317
+ const selectionSource = repoSettings?.workflow_selection ?? project.workflow_selection;
318
+ const defaultWorkflowId = selectionSource?.default_workflow ?? workflowDefinitions[0]?.id;
319
+ const workflows = workflowDefinitions.find((definition) => definition.id === defaultWorkflowId)?.stages ?? workflowDefinitions[0]?.stages ?? [];
320
+ return {
321
+ workflows,
322
+ ...(workflowDefinitions.length > 0 ? { workflowDefinitions } : {}),
323
+ ...(defaultWorkflowId || (selectionSource?.by_label?.length ?? 0) > 0
324
+ ? {
325
+ workflowSelection: {
326
+ ...(defaultWorkflowId ? { defaultWorkflowId } : {}),
327
+ byLabel: (selectionSource?.by_label ?? []).map((entry) => ({
328
+ label: entry.label,
329
+ workflowId: entry.workflow,
330
+ })),
331
+ },
332
+ }
333
+ : {}),
334
+ };
335
+ }
233
336
  function defaultWorktreeRoot(projectId) {
234
337
  return path.join(getPatchRelayDataDir(), "worktrees", projectId);
235
338
  }
@@ -260,15 +363,6 @@ function deriveLinearOAuthRedirectUri(server) {
260
363
  const host = normalizeLocalRedirectHost(server.bind);
261
364
  return new URL(LINEAR_OAUTH_CALLBACK_PATH, `http://${formatUrlHost(host)}:${server.port}`).toString();
262
365
  }
263
- function mergeWorkflows(repoPath, workflows) {
264
- return workflows.map((workflow) => ({
265
- id: workflow.id,
266
- whenState: workflow.when_state,
267
- activeState: workflow.active_state,
268
- workflowFile: resolveWorkflowFilePath(repoPath, workflow.workflow_file),
269
- ...(workflow.fallback_state ? { fallbackState: workflow.fallback_state } : {}),
270
- }));
271
- }
272
366
  export function loadConfig(configPath = process.env.PATCHRELAY_CONFIG ?? getDefaultConfigPath(), options) {
273
367
  const requestedPath = ensureAbsolutePath(configPath);
274
368
  if (!existsSync(requestedPath)) {
@@ -280,17 +374,7 @@ export function loadConfig(configPath = process.env.PATCHRELAY_CONFIG ?? getDefa
280
374
  ...adjacentEnv,
281
375
  ...process.env,
282
376
  };
283
- const raw = readFileSync(requestedPath, "utf8");
284
- let parsedFile;
285
- try {
286
- parsedFile = JSON.parse(raw);
287
- }
288
- catch (error) {
289
- const message = error instanceof Error ? error.message : String(error);
290
- throw new Error(`Invalid JSON config file: ${requestedPath}: ${message}`, {
291
- cause: error,
292
- });
293
- }
377
+ const parsedFile = parseJsonFile(requestedPath, "config file");
294
378
  const parsed = configSchema.parse(withSectionDefaults(expandEnv(parsedFile, env)));
295
379
  const requirements = getLoadProfileRequirements(profile);
296
380
  const webhookSecret = env[parsed.linear.webhook_secret_env];
@@ -373,35 +457,43 @@ export function loadConfig(configPath = process.env.PATCHRELAY_CONFIG ?? getDefa
373
457
  },
374
458
  projects: parsed.projects.map((project) => {
375
459
  const repoPath = ensureAbsolutePath(project.repo_path);
460
+ const repoSettings = readRepoSettings(repoPath, env);
461
+ const workflowConfig = resolveProjectWorkflowConfig(repoPath, project, repoSettings);
462
+ const workflowLabels = repoSettings?.workflow_labels ?? project.workflow_labels;
463
+ const trustedActors = repoSettings?.trusted_actors ?? project.trusted_actors;
376
464
  return {
377
465
  id: project.id,
378
466
  repoPath,
379
467
  worktreeRoot: ensureAbsolutePath(project.worktree_root ?? defaultWorktreeRoot(project.id)),
380
- workflows: mergeWorkflows(repoPath, project.workflows ?? builtinWorkflows),
381
- ...(project.workflow_labels
468
+ workflows: workflowConfig.workflows,
469
+ ...(workflowConfig.workflowDefinitions ? { workflowDefinitions: workflowConfig.workflowDefinitions } : {}),
470
+ ...(workflowConfig.workflowSelection ? { workflowSelection: workflowConfig.workflowSelection } : {}),
471
+ ...(workflowLabels
382
472
  ? {
383
473
  workflowLabels: {
384
- ...(project.workflow_labels.working ? { working: project.workflow_labels.working } : {}),
385
- ...(project.workflow_labels.awaiting_handoff ? { awaitingHandoff: project.workflow_labels.awaiting_handoff } : {}),
474
+ ...(workflowLabels.working ? { working: workflowLabels.working } : {}),
475
+ ...(workflowLabels.awaiting_handoff ? { awaitingHandoff: workflowLabels.awaiting_handoff } : {}),
386
476
  },
387
477
  }
388
478
  : {}),
389
- ...(project.trusted_actors
479
+ ...(trustedActors
390
480
  ? {
391
481
  trustedActors: {
392
- ids: project.trusted_actors.ids,
393
- names: project.trusted_actors.names,
394
- emails: project.trusted_actors.emails,
395
- emailDomains: project.trusted_actors.email_domains,
482
+ ids: trustedActors.ids,
483
+ names: trustedActors.names,
484
+ emails: trustedActors.emails,
485
+ emailDomains: trustedActors.email_domains,
396
486
  },
397
487
  }
398
488
  : {}),
399
489
  issueKeyPrefixes: project.issue_key_prefixes,
400
490
  linearTeamIds: project.linear_team_ids,
401
491
  allowLabels: project.allow_labels,
402
- triggerEvents: project.trigger_events ??
492
+ triggerEvents: repoSettings?.trigger_events ??
493
+ project.trigger_events ??
403
494
  defaultTriggerEvents(parsed.linear.oauth.actor),
404
- branchPrefix: project.branch_prefix ?? defaultBranchPrefix(project.id),
495
+ branchPrefix: repoSettings?.branch_prefix ?? project.branch_prefix ?? defaultBranchPrefix(project.id),
496
+ ...(repoSettings?.configPath ? { repoSettingsPath: repoSettings.configPath } : {}),
405
497
  };
406
498
  }),
407
499
  };
@@ -467,19 +559,40 @@ function validateConfigSemantics(config, options) {
467
559
  }
468
560
  linearTeamIds.set(teamId, project.id);
469
561
  }
470
- const workflowIds = new Set();
471
- const workflowStates = new Set();
472
- for (const workflow of project.workflows) {
473
- const normalizedWorkflowId = workflow.id.trim().toLowerCase();
474
- if (workflowIds.has(normalizedWorkflowId)) {
475
- throw new Error(`Workflow id "${workflow.id}" is configured more than once in project ${project.id}`);
562
+ const workflowDefinitions = project.workflowDefinitions ?? [{ id: "default", stages: project.workflows }];
563
+ const definitionIds = new Set();
564
+ for (const definition of workflowDefinitions) {
565
+ const normalizedDefinitionId = definition.id.trim().toLowerCase();
566
+ if (definitionIds.has(normalizedDefinitionId)) {
567
+ throw new Error(`Workflow definition "${definition.id}" is configured more than once in project ${project.id}`);
568
+ }
569
+ definitionIds.add(normalizedDefinitionId);
570
+ const workflowIds = new Set();
571
+ const workflowStates = new Set();
572
+ for (const workflow of definition.stages) {
573
+ const normalizedWorkflowId = workflow.id.trim().toLowerCase();
574
+ if (workflowIds.has(normalizedWorkflowId)) {
575
+ throw new Error(`Workflow id "${workflow.id}" is configured more than once in project ${project.id}`);
576
+ }
577
+ workflowIds.add(normalizedWorkflowId);
578
+ const normalizedState = workflow.whenState.trim().toLowerCase();
579
+ if (workflowStates.has(normalizedState)) {
580
+ throw new Error(`Linear state "${workflow.whenState}" is configured for more than one workflow in project ${project.id}`);
581
+ }
582
+ workflowStates.add(normalizedState);
476
583
  }
477
- workflowIds.add(normalizedWorkflowId);
478
- const normalizedState = workflow.whenState.trim().toLowerCase();
479
- if (workflowStates.has(normalizedState)) {
480
- throw new Error(`Linear state "${workflow.whenState}" is configured for more than one workflow in project ${project.id}`);
584
+ }
585
+ if (project.workflowSelection?.defaultWorkflowId) {
586
+ const normalizedDefaultWorkflowId = project.workflowSelection.defaultWorkflowId.trim().toLowerCase();
587
+ if (!workflowDefinitions.some((definition) => definition.id.trim().toLowerCase() === normalizedDefaultWorkflowId)) {
588
+ throw new Error(`Default workflow "${project.workflowSelection.defaultWorkflowId}" does not exist in project ${project.id}`);
589
+ }
590
+ }
591
+ for (const rule of project.workflowSelection?.byLabel ?? []) {
592
+ const normalizedWorkflowId = rule.workflowId.trim().toLowerCase();
593
+ if (!workflowDefinitions.some((definition) => definition.id.trim().toLowerCase() === normalizedWorkflowId)) {
594
+ throw new Error(`Workflow selection for label "${rule.label}" points to unknown workflow "${rule.workflowId}" in project ${project.id}`);
481
595
  }
482
- workflowStates.add(normalizedState);
483
596
  }
484
597
  }
485
598
  if (config.operatorApi.enabled &&
@@ -54,13 +54,14 @@ export class AuthoritativeLedgerStore {
54
54
  this.connection
55
55
  .prepare(`
56
56
  INSERT INTO issue_control (
57
- project_id, linear_issue_id, desired_stage, desired_receipt_id, active_workspace_ownership_id,
57
+ project_id, linear_issue_id, selected_workflow_id, desired_stage, desired_receipt_id, active_workspace_ownership_id,
58
58
  active_run_lease_id, service_owned_comment_id, active_agent_session_id, lifecycle_status, updated_at
59
59
  ) VALUES (
60
- @projectId, @linearIssueId, @desiredStage, @desiredReceiptId, @activeWorkspaceOwnershipId,
60
+ @projectId, @linearIssueId, @selectedWorkflowId, @desiredStage, @desiredReceiptId, @activeWorkspaceOwnershipId,
61
61
  @activeRunLeaseId, @serviceOwnedCommentId, @activeAgentSessionId, @lifecycleStatus, @updatedAt
62
62
  )
63
63
  ON CONFLICT(project_id, linear_issue_id) DO UPDATE SET
64
+ selected_workflow_id = CASE WHEN @setSelectedWorkflowId = 1 THEN @selectedWorkflowId ELSE issue_control.selected_workflow_id END,
64
65
  desired_stage = CASE WHEN @setDesiredStage = 1 THEN @desiredStage ELSE issue_control.desired_stage END,
65
66
  desired_receipt_id = CASE WHEN @setDesiredReceiptId = 1 THEN @desiredReceiptId ELSE issue_control.desired_receipt_id END,
66
67
  active_workspace_ownership_id = CASE WHEN @setActiveWorkspaceOwnershipId = 1 THEN @activeWorkspaceOwnershipId ELSE issue_control.active_workspace_ownership_id END,
@@ -73,6 +74,7 @@ export class AuthoritativeLedgerStore {
73
74
  .run({
74
75
  projectId: params.projectId,
75
76
  linearIssueId: params.linearIssueId,
77
+ selectedWorkflowId: params.selectedWorkflowId ?? null,
76
78
  desiredStage: params.desiredStage ?? null,
77
79
  desiredReceiptId: params.desiredReceiptId ?? null,
78
80
  activeWorkspaceOwnershipId: params.activeWorkspaceOwnershipId ?? null,
@@ -81,6 +83,7 @@ export class AuthoritativeLedgerStore {
81
83
  activeAgentSessionId: params.activeAgentSessionId ?? null,
82
84
  lifecycleStatus: params.lifecycleStatus,
83
85
  updatedAt: now,
86
+ setSelectedWorkflowId: Number("selectedWorkflowId" in params),
84
87
  setDesiredStage: Number("desiredStage" in params),
85
88
  setDesiredReceiptId: Number("desiredReceiptId" in params),
86
89
  setActiveWorkspaceOwnershipId: Number("activeWorkspaceOwnershipId" in params),
@@ -452,6 +455,7 @@ function mapIssueControl(row) {
452
455
  id: Number(row.id),
453
456
  projectId: String(row.project_id),
454
457
  linearIssueId: String(row.linear_issue_id),
458
+ ...(row.selected_workflow_id === null ? {} : { selectedWorkflowId: String(row.selected_workflow_id) }),
455
459
  ...(row.desired_stage === null ? {} : { desiredStage: row.desired_stage }),
456
460
  ...(row.desired_receipt_id === null ? {} : { desiredReceiptId: Number(row.desired_receipt_id) }),
457
461
  ...(row.active_run_lease_id === null ? {} : { activeRunLeaseId: Number(row.active_run_lease_id) }),
@@ -31,6 +31,7 @@ export class IssueWorkflowCoordinator {
31
31
  this.authoritativeLedger.upsertIssueControl({
32
32
  projectId: params.projectId,
33
33
  linearIssueId: params.linearIssueId,
34
+ ...(params.selectedWorkflowId !== undefined ? { selectedWorkflowId: params.selectedWorkflowId } : {}),
34
35
  ...(params.desiredStage !== undefined ? { desiredStage: params.desiredStage } : {}),
35
36
  ...(desiredReceiptId !== undefined ? { desiredReceiptId } : {}),
36
37
  ...(params.activeWorkspaceId !== undefined ? { activeWorkspaceOwnershipId: params.activeWorkspaceId } : {}),
@@ -69,6 +70,11 @@ export class IssueWorkflowCoordinator {
69
70
  this.authoritativeLedger.upsertIssueControl({
70
71
  projectId: params.projectId,
71
72
  linearIssueId: params.linearIssueId,
73
+ ...(params.selectedWorkflowId !== undefined
74
+ ? { selectedWorkflowId: params.selectedWorkflowId }
75
+ : existing?.selectedWorkflowId
76
+ ? { selectedWorkflowId: existing.selectedWorkflowId }
77
+ : {}),
72
78
  ...(params.desiredStage !== undefined ? { desiredStage: params.desiredStage } : {}),
73
79
  ...(desiredReceiptId !== undefined ? { desiredReceiptId } : {}),
74
80
  lifecycleStatus,
@@ -123,6 +129,7 @@ export class IssueWorkflowCoordinator {
123
129
  this.authoritativeLedger.upsertIssueControl({
124
130
  projectId: params.projectId,
125
131
  linearIssueId: params.linearIssueId,
132
+ ...(issue.selectedWorkflowId ? { selectedWorkflowId: issue.selectedWorkflowId } : {}),
126
133
  desiredStage: null,
127
134
  desiredReceiptId: null,
128
135
  activeWorkspaceOwnershipId: workspaceOwnership.id,
@@ -216,6 +223,7 @@ export class IssueWorkflowCoordinator {
216
223
  this.authoritativeLedger.upsertIssueControl({
217
224
  projectId,
218
225
  linearIssueId,
226
+ ...(existing?.selectedWorkflowId ? { selectedWorkflowId: existing.selectedWorkflowId } : {}),
219
227
  ...(desiredStage !== undefined ? { desiredStage } : { desiredStage: null }),
220
228
  ...(desiredReceiptId !== undefined
221
229
  ? { desiredReceiptId }
@@ -236,6 +244,7 @@ export class IssueWorkflowCoordinator {
236
244
  this.authoritativeLedger.upsertIssueControl({
237
245
  projectId,
238
246
  linearIssueId,
247
+ ...(existing?.selectedWorkflowId ? { selectedWorkflowId: existing.selectedWorkflowId } : {}),
239
248
  lifecycleStatus,
240
249
  ...(existing?.desiredStage ? { desiredStage: existing.desiredStage } : {}),
241
250
  ...(existingIssueControl?.desiredReceiptId !== undefined ? { desiredReceiptId: existingIssueControl.desiredReceiptId } : {}),
@@ -251,6 +260,7 @@ export class IssueWorkflowCoordinator {
251
260
  this.authoritativeLedger.upsertIssueControl({
252
261
  projectId,
253
262
  linearIssueId,
263
+ ...(existing?.selectedWorkflowId ? { selectedWorkflowId: existing.selectedWorkflowId } : {}),
254
264
  lifecycleStatus: existing?.lifecycleStatus ?? "idle",
255
265
  ...(existing?.desiredStage ? { desiredStage: existing.desiredStage } : {}),
256
266
  ...(existingIssueControl?.desiredReceiptId !== undefined ? { desiredReceiptId: existingIssueControl.desiredReceiptId } : {}),
@@ -266,6 +276,7 @@ export class IssueWorkflowCoordinator {
266
276
  this.authoritativeLedger.upsertIssueControl({
267
277
  projectId,
268
278
  linearIssueId,
279
+ ...(existing?.selectedWorkflowId ? { selectedWorkflowId: existing.selectedWorkflowId } : {}),
269
280
  lifecycleStatus: existing?.lifecycleStatus ?? "idle",
270
281
  ...(existing?.desiredStage ? { desiredStage: existing.desiredStage } : {}),
271
282
  ...(existingIssueControl?.desiredReceiptId !== undefined ? { desiredReceiptId: existingIssueControl.desiredReceiptId } : {}),
@@ -107,6 +107,7 @@ export class IssueWorkflowStore {
107
107
  id: issueControl?.id ?? projection?.id ?? 0,
108
108
  projectId,
109
109
  linearIssueId,
110
+ ...(issueControl?.selectedWorkflowId ? { selectedWorkflowId: issueControl.selectedWorkflowId } : {}),
110
111
  ...(projection?.issueKey ? { issueKey: projection.issueKey } : {}),
111
112
  ...(projection?.title ? { title: projection.title } : {}),
112
113
  ...(projection?.issueUrl ? { issueUrl: projection.issueUrl } : {}),
@@ -32,6 +32,7 @@ CREATE TABLE IF NOT EXISTS issue_control (
32
32
  id INTEGER PRIMARY KEY AUTOINCREMENT,
33
33
  project_id TEXT NOT NULL,
34
34
  linear_issue_id TEXT NOT NULL,
35
+ selected_workflow_id TEXT,
35
36
  desired_stage TEXT,
36
37
  desired_receipt_id INTEGER,
37
38
  active_run_lease_id INTEGER,
@@ -194,7 +195,9 @@ CREATE TABLE IF NOT EXISTS operator_feed_events (
194
195
  issue_key TEXT,
195
196
  project_id TEXT,
196
197
  stage TEXT,
197
- status TEXT
198
+ status TEXT,
199
+ workflow_id TEXT,
200
+ next_stage TEXT
198
201
  );
199
202
 
200
203
  CREATE INDEX IF NOT EXISTS idx_event_receipts_project_issue ON event_receipts(project_id, linear_issue_id);
@@ -214,4 +217,22 @@ WHERE dedupe_key IS NOT NULL;
214
217
  `;
215
218
  export function runPatchRelayMigrations(connection) {
216
219
  connection.exec(baseMigration);
220
+ try {
221
+ connection.exec("ALTER TABLE issue_control ADD COLUMN selected_workflow_id TEXT");
222
+ }
223
+ catch {
224
+ // Column already exists on upgraded installs.
225
+ }
226
+ try {
227
+ connection.exec("ALTER TABLE operator_feed_events ADD COLUMN workflow_id TEXT");
228
+ }
229
+ catch {
230
+ // Column already exists on upgraded installs.
231
+ }
232
+ try {
233
+ connection.exec("ALTER TABLE operator_feed_events ADD COLUMN next_stage TEXT");
234
+ }
235
+ catch {
236
+ // Column already exists on upgraded installs.
237
+ }
217
238
  }
@@ -9,9 +9,9 @@ export class OperatorFeedStore {
9
9
  save(event) {
10
10
  const at = event.at ?? isoNow();
11
11
  const result = this.connection.prepare(`
12
- INSERT INTO operator_feed_events (at, level, kind, summary, detail, issue_key, project_id, stage, status)
13
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
14
- `).run(at, event.level, event.kind, event.summary, event.detail ?? null, event.issueKey ?? null, event.projectId ?? null, event.stage ?? null, event.status ?? null);
12
+ INSERT INTO operator_feed_events (at, level, kind, summary, detail, issue_key, project_id, stage, status, workflow_id, next_stage)
13
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
14
+ `).run(at, event.level, event.kind, event.summary, event.detail ?? null, event.issueKey ?? null, event.projectId ?? null, event.stage ?? null, event.status ?? null, event.workflowId ?? null, event.nextStage ?? null);
15
15
  this.prune();
16
16
  const stored = this.connection.prepare("SELECT * FROM operator_feed_events WHERE id = ?").get(Number(result.lastInsertRowid));
17
17
  return mapOperatorFeedEvent(stored);
@@ -31,6 +31,22 @@ export class OperatorFeedStore {
31
31
  clauses.push("project_id = ?");
32
32
  params.push(options.projectId);
33
33
  }
34
+ if (options?.kind) {
35
+ clauses.push("kind = ?");
36
+ params.push(options.kind);
37
+ }
38
+ if (options?.stage) {
39
+ clauses.push("stage = ?");
40
+ params.push(options.stage);
41
+ }
42
+ if (options?.status) {
43
+ clauses.push("status = ?");
44
+ params.push(options.status);
45
+ }
46
+ if (options?.workflowId) {
47
+ clauses.push("workflow_id = ?");
48
+ params.push(options.workflowId);
49
+ }
34
50
  const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
35
51
  const limit = options?.limit ?? 50;
36
52
  const rows = this.connection.prepare(`
@@ -68,5 +84,7 @@ function mapOperatorFeedEvent(row) {
68
84
  ...(row.project_id === null ? {} : { projectId: String(row.project_id) }),
69
85
  ...(row.stage === null ? {} : { stage: row.stage }),
70
86
  ...(row.status === null ? {} : { status: String(row.status) }),
87
+ ...(row.workflow_id === null ? {} : { workflowId: String(row.workflow_id) }),
88
+ ...(row.next_stage === null ? {} : { nextStage: row.next_stage }),
71
89
  };
72
90
  }