agent-relay-server 0.61.2 → 0.62.1

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.
Files changed (181) hide show
  1. package/docs/openapi.json +479 -1
  2. package/package.json +2 -2
  3. package/public/assets/{MessageBubble-DVvlvi4B.js → MessageBubble-DRrxfjyT.js} +2 -2
  4. package/public/assets/{MessageBubble-DVvlvi4B.js.map → MessageBubble-DRrxfjyT.js.map} +1 -1
  5. package/public/assets/PlanGraphPanel-Dp2eYazz.js +2 -0
  6. package/public/assets/PlanGraphPanel-Dp2eYazz.js.map +1 -0
  7. package/public/assets/{activity-IYl3MEXC.js → activity--t0U2yzq.js} +2 -2
  8. package/public/assets/{activity-IYl3MEXC.js.map → activity--t0U2yzq.js.map} +1 -1
  9. package/public/assets/{agent-profiles-BfGTTbeY.js → agent-profiles-BMtJYzxn.js} +2 -2
  10. package/public/assets/{agent-profiles-BfGTTbeY.js.map → agent-profiles-BMtJYzxn.js.map} +1 -1
  11. package/public/assets/{agents-BquvREQr.js → agents-Ck9AePlc.js} +2 -2
  12. package/public/assets/{agents-BquvREQr.js.map → agents-Ck9AePlc.js.map} +1 -1
  13. package/public/assets/{analytics-De0cEZGx.js → analytics-CzfK387L.js} +3 -3
  14. package/public/assets/{analytics-De0cEZGx.js.map → analytics-CzfK387L.js.map} +1 -1
  15. package/public/assets/automation-Cgw8vtE8.js +2 -0
  16. package/public/assets/automation-Cgw8vtE8.js.map +1 -0
  17. package/public/assets/{badge-DhLyCPag.js → badge-CZhIdlTJ.js} +2 -2
  18. package/public/assets/{badge-DhLyCPag.js.map → badge-CZhIdlTJ.js.map} +1 -1
  19. package/public/assets/{branch-state-badge-DSyWKluD.js → branch-state-badge-DnEEPtOP.js} +2 -2
  20. package/public/assets/{branch-state-badge-DSyWKluD.js.map → branch-state-badge-DnEEPtOP.js.map} +1 -1
  21. package/public/assets/{button-K20wAZG1.js → button-2taQeNHW.js} +2 -2
  22. package/public/assets/{button-K20wAZG1.js.map → button-2taQeNHW.js.map} +1 -1
  23. package/public/assets/card-DzIsAI41.js +2 -0
  24. package/public/assets/{card-CcZ9hLI7.js.map → card-DzIsAI41.js.map} +1 -1
  25. package/public/assets/channels-Dc3L2MQs.js +2 -0
  26. package/public/assets/{channels-BG0Nib7y.js.map → channels-Dc3L2MQs.js.map} +1 -1
  27. package/public/assets/chat-Fc6iJ7sa.js +2 -0
  28. package/public/assets/{chat-Dx6qWNxb.js.map → chat-Fc6iJ7sa.js.map} +1 -1
  29. package/public/assets/{connectors-DWh7MwV_.js → connectors-Dc2ttGWs.js} +2 -2
  30. package/public/assets/{connectors-DWh7MwV_.js.map → connectors-Dc2ttGWs.js.map} +1 -1
  31. package/public/assets/copy-button-HEkwi6xX.js +2 -0
  32. package/public/assets/{copy-button-eF3cKp4Q.js.map → copy-button-HEkwi6xX.js.map} +1 -1
  33. package/public/assets/display-DzNvOu1j.js.map +1 -1
  34. package/public/assets/dist-1rXN5FN9.js +2 -0
  35. package/public/assets/{dist-YpHfnnIA.js.map → dist-1rXN5FN9.js.map} +1 -1
  36. package/public/assets/{dist-1dhfdhm9.js → dist-AbEQuFoK.js} +2 -2
  37. package/public/assets/{dist-1dhfdhm9.js.map → dist-AbEQuFoK.js.map} +1 -1
  38. package/public/assets/{dist-COQuunEv.js → dist-B7p1zkvf.js} +2 -2
  39. package/public/assets/{dist-COQuunEv.js.map → dist-B7p1zkvf.js.map} +1 -1
  40. package/public/assets/{dist-Ci5Y37nj.js → dist-CG10hM1-.js} +2 -2
  41. package/public/assets/{dist-Ci5Y37nj.js.map → dist-CG10hM1-.js.map} +1 -1
  42. package/public/assets/dist-Cfm5_zPB.js +2 -0
  43. package/public/assets/{dist-qd198ib9.js.map → dist-Cfm5_zPB.js.map} +1 -1
  44. package/public/assets/{dist-BrQsqBbc.js → dist-Cgw85UcE.js} +2 -2
  45. package/public/assets/{dist-BrQsqBbc.js.map → dist-Cgw85UcE.js.map} +1 -1
  46. package/public/assets/{dist-CoROafNg.js → dist-D3_TJKTC.js} +2 -2
  47. package/public/assets/{dist-CoROafNg.js.map → dist-D3_TJKTC.js.map} +1 -1
  48. package/public/assets/{dist-I-MFUv51.js → dist-DQkLq0hs.js} +2 -2
  49. package/public/assets/{dist-I-MFUv51.js.map → dist-DQkLq0hs.js.map} +1 -1
  50. package/public/assets/{dist-UQGKwqZH.js → dist-D_sl7u_f.js} +2 -2
  51. package/public/assets/{dist-UQGKwqZH.js.map → dist-D_sl7u_f.js.map} +1 -1
  52. package/public/assets/{dist-yGOq6sB3.js → dist-Ilb3r1im.js} +2 -2
  53. package/public/assets/{dist-yGOq6sB3.js.map → dist-Ilb3r1im.js.map} +1 -1
  54. package/public/assets/dist-Pf_3Gf38.js +2 -0
  55. package/public/assets/{dist-BLBF7qqr.js.map → dist-Pf_3Gf38.js.map} +1 -1
  56. package/public/assets/{es2015-CRxz9lOj.js → es2015-GSepNaIS.js} +2 -2
  57. package/public/assets/{es2015-CRxz9lOj.js.map → es2015-GSepNaIS.js.map} +1 -1
  58. package/public/assets/{formatted-body-impl-DMm6jeqi.js → formatted-body-impl-Bg-gx7rG.js} +2 -2
  59. package/public/assets/{formatted-body-impl-DMm6jeqi.js.map → formatted-body-impl-Bg-gx7rG.js.map} +1 -1
  60. package/public/assets/index-BeDncjN4.js +22 -0
  61. package/public/assets/index-BeDncjN4.js.map +1 -0
  62. package/public/assets/index-DiR-fqy_.css +2 -0
  63. package/public/assets/{input-BHYNpXAG.js → input-BsNqlONJ.js} +2 -2
  64. package/public/assets/{input-BHYNpXAG.js.map → input-BsNqlONJ.js.map} +1 -1
  65. package/public/assets/{insights-C7wKt1_l.js → insights-c08aW-7n.js} +2 -2
  66. package/public/assets/{insights-C7wKt1_l.js.map → insights-c08aW-7n.js.map} +1 -1
  67. package/public/assets/{integrations-CG9qyHUf.js → integrations-BwpgcqnF.js} +2 -2
  68. package/public/assets/{integrations-CG9qyHUf.js.map → integrations-BwpgcqnF.js.map} +1 -1
  69. package/public/assets/{lib-Df4aph5u.js → lib-bXPu6xCL.js} +2 -2
  70. package/public/assets/{lib-Df4aph5u.js.map → lib-bXPu6xCL.js.map} +1 -1
  71. package/public/assets/lucide-react-DWYjU-AF.js +9 -0
  72. package/public/assets/{lucide-react-L2UxFDic.js.map → lucide-react-DWYjU-AF.js.map} +1 -1
  73. package/public/assets/{maintenance-CjpvnV_U.js → maintenance-CTKP0kWB.js} +2 -2
  74. package/public/assets/{maintenance-CjpvnV_U.js.map → maintenance-CTKP0kWB.js.map} +1 -1
  75. package/public/assets/{managed-agents-CdfuBKJA.js → managed-agents-BofCxo8d.js} +2 -2
  76. package/public/assets/{managed-agents-CdfuBKJA.js.map → managed-agents-BofCxo8d.js.map} +1 -1
  77. package/public/assets/{markdown-preview-impl-Cf4xCmWt.js → markdown-preview-impl-CQ-6BIb8.js} +2 -2
  78. package/public/assets/{markdown-preview-impl-Cf4xCmWt.js.map → markdown-preview-impl-CQ-6BIb8.js.map} +1 -1
  79. package/public/assets/memory-DvfXjMoi.js +2 -0
  80. package/public/assets/{memory-xKWduhyr.js.map → memory-DvfXjMoi.js.map} +1 -1
  81. package/public/assets/{messages-BpZYOUtJ.js → messages-C9VsPzZ_.js} +2 -2
  82. package/public/assets/{messages-BpZYOUtJ.js.map → messages-C9VsPzZ_.js.map} +1 -1
  83. package/public/assets/orchestrators-MffVueeE.js +2 -0
  84. package/public/assets/{orchestrators-BrJvGIOc.js.map → orchestrators-MffVueeE.js.map} +1 -1
  85. package/public/assets/{overview-BUORwePi.js → overview-BSVajefM.js} +2 -2
  86. package/public/assets/{overview-BUORwePi.js.map → overview-BSVajefM.js.map} +1 -1
  87. package/public/assets/pairs-B5711-Cs.js +2 -0
  88. package/public/assets/{pairs-DR0GizDN.js.map → pairs-B5711-Cs.js.map} +1 -1
  89. package/public/assets/{react-dom-CrP_AtcL.js → react-dom-CPghDLpB.js} +2 -2
  90. package/public/assets/{react-dom-CrP_AtcL.js.map → react-dom-CPghDLpB.js.map} +1 -1
  91. package/public/assets/{security-DLwSqTc_.js → security-DkUwK9Vj.js} +2 -2
  92. package/public/assets/{security-DLwSqTc_.js.map → security-DkUwK9Vj.js.map} +1 -1
  93. package/public/assets/{select-pEru111r.js → select-DrM2RRiT.js} +2 -2
  94. package/public/assets/{select-pEru111r.js.map → select-DrM2RRiT.js.map} +1 -1
  95. package/public/assets/{settings-B-ueGvwV.js → settings-DdKCb-vy.js} +3 -3
  96. package/public/assets/{settings-B-ueGvwV.js.map → settings-DdKCb-vy.js.map} +1 -1
  97. package/public/assets/store-iTiheuTD.js +9 -0
  98. package/public/assets/store-iTiheuTD.js.map +1 -0
  99. package/public/assets/tasks-e1y1PFfO.js +2 -0
  100. package/public/assets/{tasks-C2rRQm6E.js.map → tasks-e1y1PFfO.js.map} +1 -1
  101. package/public/assets/teams-BUcxoGC_.js +2 -0
  102. package/public/assets/teams-BUcxoGC_.js.map +1 -0
  103. package/public/assets/{terminal-viewer-impl-BM_3qoeN.js → terminal-viewer-impl-CmZjQOB2.js} +3 -3
  104. package/public/assets/{terminal-viewer-impl-BM_3qoeN.js.map → terminal-viewer-impl-CmZjQOB2.js.map} +1 -1
  105. package/public/assets/{use-keyboard-viewport-DrRH_QzG.js → use-keyboard-viewport-NKw1O5gC.js} +2 -2
  106. package/public/assets/{use-keyboard-viewport-DrRH_QzG.js.map → use-keyboard-viewport-NKw1O5gC.js.map} +1 -1
  107. package/public/assets/{work-queue-CkIYDAZD.js → work-queue-CXBQx43n.js} +2 -2
  108. package/public/assets/{work-queue-CkIYDAZD.js.map → work-queue-CXBQx43n.js.map} +1 -1
  109. package/public/assets/{workspaces-CYm5bLg9.js → workspaces-D2EfZh9a.js} +3 -3
  110. package/public/assets/{workspaces-CYm5bLg9.js.map → workspaces-D2EfZh9a.js.map} +1 -1
  111. package/public/index.html +21 -21
  112. package/src/automations.ts +40 -7
  113. package/src/branch-landed.ts +5 -0
  114. package/src/bus.ts +6 -1
  115. package/src/cli/workspace.ts +9 -17
  116. package/src/db/activity.ts +191 -3
  117. package/src/db/agents.ts +14 -5
  118. package/src/db/integrations.ts +1 -0
  119. package/src/db/issue-associations.ts +128 -0
  120. package/src/db/issue-lifecycle.ts +187 -0
  121. package/src/db/issue-refs.ts +254 -0
  122. package/src/db/mappers.ts +2 -0
  123. package/src/db/migrations.ts +53 -0
  124. package/src/db/schema.ts +3 -2
  125. package/src/issue-tracker/adapter.ts +24 -0
  126. package/src/issue-tracker/github.ts +136 -0
  127. package/src/issue-tracker/index.ts +13 -0
  128. package/src/issue-tracker/registry.ts +26 -0
  129. package/src/maintenance.ts +22 -169
  130. package/src/mcp-issue-activity-tools.ts +114 -0
  131. package/src/mcp-issue-association-tools.ts +130 -0
  132. package/src/mcp-issue-lifecycle-tools.ts +55 -0
  133. package/src/mcp-issue-ref-tools.ts +174 -0
  134. package/src/mcp-team-tools.ts +1 -1
  135. package/src/mcp.ts +3 -3
  136. package/src/provider-config-store.ts +30 -8
  137. package/src/routes/activity.ts +6 -1
  138. package/src/routes/commands.ts +29 -0
  139. package/src/routes/index.ts +19 -0
  140. package/src/routes/issue-activity.ts +78 -0
  141. package/src/routes/issue-associations.ts +76 -0
  142. package/src/routes/issue-lifecycle.ts +34 -0
  143. package/src/routes/issue-refs.ts +137 -0
  144. package/src/routes/orchestrator-bootstrap.ts +2 -1
  145. package/src/routes/orchestrator.ts +42 -1
  146. package/src/routes/tokens.ts +1 -1
  147. package/src/runtime-tokens.ts +15 -11
  148. package/src/security.ts +51 -3
  149. package/src/services/issue-activity.ts +30 -0
  150. package/src/services/issue-associations.ts +72 -0
  151. package/src/services/issue-lifecycle.ts +308 -0
  152. package/src/services/issue-refs.ts +32 -0
  153. package/src/services/plan-graph.ts +40 -2
  154. package/src/services/team.ts +4 -0
  155. package/src/token-db.ts +27 -7
  156. package/src/workspace-actions.ts +8 -1
  157. package/src/workspace-auto-merge.ts +228 -0
  158. package/src/workspace-probe.ts +125 -0
  159. package/public/assets/PlanGraphPanel-Dby7wq9c.js +0 -2
  160. package/public/assets/PlanGraphPanel-Dby7wq9c.js.map +0 -1
  161. package/public/assets/automation-Ddbermbi.js +0 -2
  162. package/public/assets/automation-Ddbermbi.js.map +0 -1
  163. package/public/assets/card-CcZ9hLI7.js +0 -2
  164. package/public/assets/channels-BG0Nib7y.js +0 -2
  165. package/public/assets/chat-Dx6qWNxb.js +0 -2
  166. package/public/assets/copy-button-eF3cKp4Q.js +0 -2
  167. package/public/assets/dist-BLBF7qqr.js +0 -2
  168. package/public/assets/dist-YpHfnnIA.js +0 -2
  169. package/public/assets/dist-qd198ib9.js +0 -2
  170. package/public/assets/index-B3wOeAPO.js +0 -21
  171. package/public/assets/index-B3wOeAPO.js.map +0 -1
  172. package/public/assets/index-Dn_GDXbi.css +0 -2
  173. package/public/assets/lucide-react-L2UxFDic.js +0 -9
  174. package/public/assets/memory-xKWduhyr.js +0 -2
  175. package/public/assets/orchestrators-BrJvGIOc.js +0 -2
  176. package/public/assets/pairs-DR0GizDN.js +0 -2
  177. package/public/assets/store-B8xP6z6T.js +0 -9
  178. package/public/assets/store-B8xP6z6T.js.map +0 -1
  179. package/public/assets/tasks-C2rRQm6E.js +0 -2
  180. package/public/assets/teams-Bo0sdBTp.js +0 -2
  181. package/public/assets/teams-Bo0sdBTp.js.map +0 -1
@@ -39,8 +39,7 @@ import type {
39
39
  UpdateAutomationInput,
40
40
  } from "./types";
41
41
  const AUTOMATION_KIND = "scheduled_task";
42
- const DEFAULT_TIMEZONE = "UTC";
43
- const DEFAULT_CATCH_UP: AutomationCatchUpPolicy = "skip";
42
+ const DEFAULT_TIMEZONE = "UTC", DEFAULT_CATCH_UP: AutomationCatchUpPolicy = "skip";
44
43
  const DEFAULT_CONCURRENCY: AutomationConcurrencyPolicy = "skip";
45
44
  const BLOCKING_RUN_STATUSES = new Set<AutomationRunStatus>(["dispatching", "waiting_agent", "running"]);
46
45
  const OPEN_RUN_STATUSES = new Set<AutomationRunStatus>(["scheduled", "dispatching", "waiting_agent", "running"]);
@@ -50,7 +49,6 @@ const MIN_RUNTIME_BUDGET_MS = 60_000;
50
49
  const MAX_RUNTIME_BUDGET_MS = 24 * 60 * 60 * 1000;
51
50
 
52
51
  let initializedDb: unknown = null;
53
-
54
52
  export interface AutomationDispatchResult {
55
53
  automation: Automation;
56
54
  run: AutomationRun;
@@ -65,11 +63,9 @@ export interface AutomationReconcileResult {
65
63
  message?: Message;
66
64
  command?: Command;
67
65
  }
68
-
69
66
  function db() {
70
67
  return getDb();
71
68
  }
72
-
73
69
  function ensureAutomationTables(): void {
74
70
  const current = db();
75
71
  if (initializedDb === current) return;
@@ -563,14 +559,31 @@ function createRunTask(
563
559
  meta: Record<string, unknown>,
564
560
  ): AutomationDispatchResult {
565
561
  const template = automation.taskTemplate;
562
+ // Inject the authoritative dispatch-time run date (#480). A long-lived managed agent's
563
+ // session-start day awareness goes stale across midnight, so it can't be trusted to derive
564
+ // "today" — the briefing emitted "Monday Jun 16" when it ran on a Tuesday. The scheduled
565
+ // instant + timezone are authoritative here; surface them so the task never depends on the
566
+ // model's recollection. Templates may place it via {{runDate}}/{{runWeekday}}/{{runDateLine}};
567
+ // otherwise we prepend a Run date line so every scheduled task carries it.
568
+ const runDate = formatRunDate(run.scheduledFor, automation.timezone);
569
+ const tokens: Record<string, string> = { runDate: runDate.iso, runWeekday: runDate.weekday, runDateLine: runDate.line };
570
+ const fill = (text: string) => text.replace(/\{\{\s*(runDate|runWeekday|runDateLine)\s*\}\}/g, (_match, key) => tokens[key] ?? "");
571
+ const usesRunDateToken = /\{\{\s*(runDate|runWeekday|runDateLine)\s*\}\}/.test(`${template.title}\n${template.body}`);
572
+ const title = fill(template.title);
573
+ const body = usesRunDateToken ? fill(template.body) : `Run date: ${runDate.line}.\n\n${template.body}`;
566
574
  const result = ingestIntegrationEvent({
567
575
  source: "automation",
568
576
  type: "scheduled_task",
569
577
  severity: template.severity ?? "info",
570
- title: template.title,
571
- body: template.body,
578
+ title,
579
+ body,
572
580
  target,
573
581
  channel: "automation",
582
+ // #479 — automation tasks complete via the task-status lifecycle (claim → done), never a
583
+ // `/reply`. A reply obligation here is unsatisfiable and re-injects the task into every
584
+ // successor session when a restart-prone managed agent (e.g. the daily telegram briefing)
585
+ // mints a new agent id, re-firing the scheduled work off-schedule. Dispatch without one.
586
+ replyExpected: false,
574
587
  dedupeKey: template.dedupeKey ? `${template.dedupeKey}:${run.id}` : `automation:${automation.id}:run:${run.id}`,
575
588
  externalUrl: template.externalUrl,
576
589
  metadata: {
@@ -578,6 +591,9 @@ function createRunTask(
578
591
  automationId: automation.id,
579
592
  automationRunId: run.id,
580
593
  orchestratorId: automation.orchestratorId,
594
+ runDate: runDate.iso,
595
+ runWeekday: runDate.weekday,
596
+ runTimezone: automation.timezone,
581
597
  ...meta,
582
598
  },
583
599
  }, "automation");
@@ -979,6 +995,23 @@ function parseCronField(field: string, min: number, max: number, name: string):
979
995
  return values;
980
996
  }
981
997
 
998
+ // Authoritative localized run date for a dispatched automation task (#480). Formats the
999
+ // scheduled instant in the automation's timezone via the same Intl machinery as cron eval, so
1000
+ // scheduled output ("today is …") never depends on a long-lived agent's stale session context.
1001
+ function formatRunDate(timestamp: number, timezone: string): { iso: string; weekday: string; line: string } {
1002
+ const dtf = new Intl.DateTimeFormat("en-US", {
1003
+ timeZone: timezone,
1004
+ year: "numeric",
1005
+ month: "2-digit",
1006
+ day: "2-digit",
1007
+ weekday: "long",
1008
+ });
1009
+ const parts = Object.fromEntries(dtf.formatToParts(new Date(timestamp)).map((part) => [part.type, part.value]));
1010
+ const iso = `${parts.year}-${parts.month}-${parts.day}`;
1011
+ const weekday = parts.weekday ?? "";
1012
+ return { iso, weekday, line: `${weekday}, ${iso} (${timezone})` };
1013
+ }
1014
+
982
1015
  function localParts(timestamp: number, timezone: string): LocalTimeParts {
983
1016
  const dtf = new Intl.DateTimeFormat("en-US", {
984
1017
  timeZone: timezone,
@@ -1,8 +1,10 @@
1
+ import { errMessage } from "agent-relay-sdk";
1
2
  import { emitRelayEvent } from "./events";
2
3
  import { getNotificationsConfig } from "./config-store";
3
4
  import { routeNotification, INFORMATIONAL_TTL_MS } from "./notification-router";
4
5
  import { registerDefaultAudienceRules, resolveAudience, audiencePredicates } from "./notification-rules";
5
6
  import { createActivityEvent, getAgent, updateWorkspaceStatus } from "./db";
7
+ import { handleLandedIssueLifecycle } from "./services/issue-lifecycle";
6
8
  import type { WorkspaceMergePreview, WorkspaceRecord } from "./types";
7
9
 
8
10
  // #444 — branch.landed is the first rule-driven type: the on-main "merged" fan-out audience comes
@@ -64,6 +66,9 @@ export function notifyBranchLanded(input: BranchLandedInput): void {
64
66
  pushed: input.pushed,
65
67
  },
66
68
  });
69
+ void handleLandedIssueLifecycle(input, { log: (message) => console.warn(message) }).catch((error) => {
70
+ console.warn(`issue lifecycle land hook failed for ${workspace.id}: ${errMessage(error)}`);
71
+ });
67
72
 
68
73
  const config = getNotificationsConfig();
69
74
  if (!config.enabled || !config.branchLanded) return;
package/src/bus.ts CHANGED
@@ -14,6 +14,7 @@ import { ShutdownAuthError, ShutdownTargetError, shutdownAgent, shutdownInputFro
14
14
  import { applyCommandToRecipe } from "./recipe-runner";
15
15
  import { enqueueContinuationInjectionAfterClear } from "./self-resume";
16
16
  import { messageMatchesAgent, targetMatchesAgent } from "./agent-ref";
17
+ import { probeWorkspaceOnTurnEnd } from "./workspace-probe";
17
18
  import {
18
19
  BusProtocolError,
19
20
  parseBusFrame,
@@ -113,7 +114,9 @@ export function expireStaleBusAgents(graceMs = Number(process.env.AGENT_RELAY_ST
113
114
  .all(cutoff) as Array<{ id: string }>;
114
115
  const orphanedTasks: Task[] = [];
115
116
  for (const row of rows) {
116
- revokeRuntimeTokensForAgent(row.id);
117
+ // Bus-grace staleness across a relay restart/reconnect — the process may be alive.
118
+ // Tag "stale" so a live runner self-heals via orchestrator re-mint (#484).
119
+ revokeRuntimeTokensForAgent(row.id, Date.now(), "stale");
117
120
  setStatus(row.id, "offline");
118
121
  emitAgentStatusEvent(row.id);
119
122
  getLifecycleManager().onAgentDisappeared(row.id);
@@ -174,6 +177,8 @@ function handleFrame(ws: BusWebSocket, frame: ReturnType<typeof validateClientFr
174
177
  // merged meta — clears any pending stall watch (stale events ignored).
175
178
  noteAgentTimelineEvent(conn.agentId, after?.meta?.timelineEvent);
176
179
  emitAgentStatusEvent(conn.agentId);
180
+ // #237 stop-hook → instant ⚪↔🟡 changes detection on the turn-end edge.
181
+ probeWorkspaceOnTurnEnd(before, after);
177
182
  });
178
183
  return;
179
184
  case "ack":
@@ -5,8 +5,7 @@ import { apiRequest } from "./_shared";
5
5
  import { detectAgentId } from "./agent-detect";
6
6
  import { describeWorkspacePhase, readyContract, type WorkspacePhaseView } from "../workspace-phase";
7
7
  import type { WorkspaceDepsRefreshResult } from "agent-relay-sdk";
8
- import { AUTO_MERGE_POLICIES, resolveLandStrategy } from "../workspace-merge";
9
- import { getLandingConfig } from "../config-store";
8
+ import { AUTO_MERGE_POLICIES } from "../workspace-merge";
10
9
  import type { WorkspaceAutoMergePolicy } from "../types";
11
10
 
12
11
  export const WORKSPACE_USAGE = "Usage: agent-relay workspace <status|diagnostics|ready|land|claim|release|list|cleanup-stale|deps> [--id ID] [--strategy ...] [--auto-merge on-green|on-approval|manual] [--reviewer HANDLE] [--purpose TEXT] [--repo PATH] [--wait] [--timeout SECONDS] [--check] [--execute] [--json]";
@@ -204,19 +203,12 @@ export async function handleWorkspaceCommand(args: string[]): Promise<void> {
204
203
 
205
204
  const from = await detectAgentId();
206
205
 
207
- // For the `land` action, resolve the effective strategy via the three-level chain
208
- // (explicit > instance config > default) so a bare `land` with no --strategy still
209
- // picks up the operator's landing.strategy setting. The strategy is passed verbatim
210
- // to the server; requestWorkspaceMerge is the single resolution point for the default.
211
- let resolvedStrategy: string | undefined = strategy;
212
- if (action === "land") {
213
- const instanceConfig = getLandingConfig();
214
- resolvedStrategy = resolveLandStrategy({
215
- explicit: strategy,
216
- instanceStrategy: instanceConfig.strategy,
217
- });
218
- }
219
-
206
+ // For the `land` action, pass --strategy through verbatim (undefined when unset).
207
+ // The server is the SINGLE resolution point: requestWorkspaceMerge resolves the
208
+ // three-level chain (explicit > instance config > default) server-side. The CLI
209
+ // must NOT read landing config locally that needs the server DB, which isn't
210
+ // initialized in a CLI/steward process and fails with "database not initialized"
211
+ // (#481). resolveLandStrategy + getLandingConfig stay server-only.
220
212
  const actionBody: Record<string, unknown> =
221
213
  action === "ready" ? { action: "request-review", agentId: from }
222
214
  : action === "claim" ? { action: "claim", agentId: from, purpose }
@@ -224,7 +216,7 @@ export async function handleWorkspaceCommand(args: string[]): Promise<void> {
224
216
  : {
225
217
  action: "merge",
226
218
  agentId: from,
227
- ...(resolvedStrategy ? { strategy: resolvedStrategy } : {}),
219
+ ...(strategy ? { strategy } : {}),
228
220
  ...(autoMerge ? { autoMerge } : {}),
229
221
  ...(reviewer ? { reviewer } : {}),
230
222
  };
@@ -243,6 +235,6 @@ export async function handleWorkspaceCommand(args: string[]): Promise<void> {
243
235
  console.log(
244
236
  action === "claim" ? `Workspace ${id} claimed${purpose ? ` (${purpose})` : ""} — auto-merge will yield until released or the claim expires.`
245
237
  : action === "release" ? `Workspace ${id} claim released.`
246
- : `Workspace ${id} merge requested (${resolvedStrategy ?? "auto"}${autoMerge ? `, autoMerge: ${autoMerge}` : ""}${reviewer ? `, reviewer: ${reviewer}` : ""}).`,
238
+ : `Workspace ${id} merge requested (${strategy ?? "server default"}${autoMerge ? `, autoMerge: ${autoMerge}` : ""}${reviewer ? `, reviewer: ${reviewer}` : ""}).`,
247
239
  );
248
240
  }
@@ -14,8 +14,9 @@ import {
14
14
  } from "../contracts";
15
15
  import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_MERGE_LEASE_MS } from "../config";
16
16
  import { matchAgents } from "../agent-ref";
17
- import { getDb } from "./connection.ts";
17
+ import { getDb, ValidationError } from "./connection.ts";
18
18
  import { rowToActivityEvent } from "./mappers.ts";
19
+ import { getIssueRef, resolveIssueRefFromString } from "./issue-refs.ts";
19
20
  import type {
20
21
  AgentCard,
21
22
  ActivityEvent,
@@ -77,11 +78,49 @@ import type {
77
78
  WorkspaceMetadata,
78
79
  WorkspaceRecord,
79
80
  WorkspaceStatus,
81
+ IssueProvider,
82
+ IssueRef,
80
83
  } from "../types";
81
84
 
85
+ export const ISSUE_ACTIVITY_ACTIONS = ["read", "edit", "done"] as const;
86
+ export type IssueActivityAction = (typeof ISSUE_ACTIVITY_ACTIONS)[number];
87
+
88
+ export interface RecordIssueActivityInput {
89
+ agentId: string;
90
+ action: IssueActivityAction;
91
+ issueRefId?: string;
92
+ issueRef?: string;
93
+ defaultRepo?: string;
94
+ provider?: IssueProvider;
95
+ clientId?: string;
96
+ title?: string;
97
+ body?: string;
98
+ metadata?: Record<string, unknown>;
99
+ }
100
+
101
+ export interface IssueActivityAgentSummary {
102
+ agentId: string;
103
+ agentName?: string;
104
+ agentKind?: string;
105
+ action: IssueActivityAction;
106
+ count: number;
107
+ firstAt: number;
108
+ lastAt: number;
109
+ }
110
+
111
+ export interface AgentIssueActivitySummary {
112
+ issueRef: IssueRef;
113
+ action: IssueActivityAction;
114
+ count: number;
115
+ firstAt: number;
116
+ lastAt: number;
117
+ }
118
+
82
119
  export function listActivityEvents(input: {
83
120
  operatorId?: string;
84
121
  agentId?: string;
122
+ issueRefId?: string;
123
+ issueAction?: IssueActivityAction;
85
124
  limit?: number;
86
125
  since?: number;
87
126
  } = {}): ActivityEvent[] {
@@ -95,6 +134,14 @@ export function listActivityEvents(input: {
95
134
  conditions.push("agent_id = ?");
96
135
  params.push(input.agentId);
97
136
  }
137
+ if (input.issueRefId) {
138
+ conditions.push("issue_ref_id = ?");
139
+ params.push(input.issueRefId);
140
+ }
141
+ if (input.issueAction) {
142
+ conditions.push("issue_action = ?");
143
+ params.push(input.issueAction);
144
+ }
98
145
  if (input.since !== undefined) {
99
146
  conditions.push("created_at >= ?");
100
147
  params.push(input.since);
@@ -169,9 +216,9 @@ export function createActivityEvent(input: ActivityEventInput): ActivityEvent {
169
216
  const result = getDb().query(`
170
217
  INSERT INTO activity_events (
171
218
  operator_id, client_id, kind, title, body, meta_text, icon, view, peer_id,
172
- message_id, pair_id, task_id, agent_id, metadata, created_at
219
+ message_id, pair_id, task_id, agent_id, issue_ref_id, issue_action, metadata, created_at
173
220
  )
174
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
221
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
175
222
  `).run(
176
223
  input.operatorId ?? null,
177
224
  input.clientId ?? null,
@@ -186,9 +233,150 @@ export function createActivityEvent(input: ActivityEventInput): ActivityEvent {
186
233
  input.pairId ?? null,
187
234
  input.taskId ?? null,
188
235
  input.agentId ?? null,
236
+ input.issueRefId ?? null,
237
+ input.issueAction ?? null,
189
238
  JSON.stringify(input.metadata ?? {}),
190
239
  now,
191
240
  );
192
241
  return rowToActivityEvent(getDb().query("SELECT * FROM activity_events WHERE id = ?").get(Number(result.lastInsertRowid)));
193
242
  }
194
243
 
244
+ function requiredText(value: string | undefined, field: string, max: number): string {
245
+ if (typeof value !== "string") throw new ValidationError(`${field} required`);
246
+ const cleaned = value.trim();
247
+ if (!cleaned) throw new ValidationError(`${field} required`);
248
+ if (cleaned.length > max) throw new ValidationError(`${field} must be ${max} characters or fewer`);
249
+ return cleaned;
250
+ }
251
+
252
+ function optionalText(value: string | undefined, field: string, max: number): string | undefined {
253
+ if (value === undefined) return undefined;
254
+ const cleaned = requiredText(value, field, max);
255
+ return cleaned || undefined;
256
+ }
257
+
258
+ function cleanIssueAction(action: IssueActivityAction): IssueActivityAction {
259
+ if (!ISSUE_ACTIVITY_ACTIONS.includes(action)) {
260
+ throw new ValidationError(`action must be one of: ${ISSUE_ACTIVITY_ACTIONS.join(", ")}`);
261
+ }
262
+ return action;
263
+ }
264
+
265
+ function cleanIssueActivityLimit(limit: number | undefined): number {
266
+ if (limit === undefined) return 200;
267
+ if (!Number.isSafeInteger(limit) || limit <= 0 || limit > 500) throw new ValidationError("limit must be between 1 and 500");
268
+ return limit;
269
+ }
270
+
271
+ function resolveIssueRefForActivity(input: { issueRefId?: string; issueRef?: string; defaultRepo?: string; provider?: IssueProvider }): IssueRef {
272
+ const issueRefId = optionalText(input.issueRefId, "issueRefId", 120);
273
+ if (issueRefId) {
274
+ const issueRef = getIssueRef(issueRefId);
275
+ if (!issueRef) throw new ValidationError(`issue ref ${issueRefId} not found`);
276
+ return issueRef;
277
+ }
278
+ const ref = optionalText(input.issueRef, "issueRef", 1000);
279
+ if (!ref) throw new ValidationError("issueRefId or issueRef required");
280
+ return resolveIssueRefFromString({
281
+ ref,
282
+ defaultRepo: optionalText(input.defaultRepo, "defaultRepo", 500),
283
+ provider: input.provider,
284
+ });
285
+ }
286
+
287
+ export function resolveIssueActivityRef(input: { issueRefId?: string; issueRef?: string; defaultRepo?: string; provider?: IssueProvider }): IssueRef {
288
+ return resolveIssueRefForActivity(input);
289
+ }
290
+
291
+ export function recordIssueActivity(input: RecordIssueActivityInput): ActivityEvent {
292
+ const agentId = requiredText(input.agentId, "agentId", 200);
293
+ const action = cleanIssueAction(input.action);
294
+ const issueRef = resolveIssueRefForActivity(input);
295
+ const metadata = {
296
+ ...(input.metadata ?? {}),
297
+ issueRefId: issueRef.id,
298
+ issueAction: action,
299
+ };
300
+ return createActivityEvent({
301
+ clientId: optionalText(input.clientId, "clientId", 240),
302
+ kind: "issue",
303
+ title: optionalText(input.title, "title", 200) ?? `Issue ${action}`,
304
+ body: optionalText(input.body, "body", 1000),
305
+ agentId,
306
+ issueRefId: issueRef.id,
307
+ issueAction: action,
308
+ metadata,
309
+ });
310
+ }
311
+
312
+ export function listIssueActivityAgents(issueRefId: string, opts: { limit?: number } = {}): IssueActivityAgentSummary[] {
313
+ const id = requiredText(issueRefId, "issueRefId", 120);
314
+ if (!getIssueRef(id)) throw new ValidationError(`issue ref ${id} not found`);
315
+ const rows = getDb().query(`
316
+ SELECT
317
+ ae.agent_id,
318
+ a.name AS agent_name,
319
+ a.kind AS agent_kind,
320
+ ae.issue_action,
321
+ count(*) AS count,
322
+ min(ae.created_at) AS first_at,
323
+ max(ae.created_at) AS last_at
324
+ FROM activity_events ae
325
+ LEFT JOIN agents a ON a.id = ae.agent_id
326
+ WHERE ae.issue_ref_id = ? AND ae.agent_id IS NOT NULL AND ae.issue_action IS NOT NULL
327
+ GROUP BY ae.agent_id, ae.issue_action
328
+ ORDER BY last_at DESC, ae.agent_id ASC, ae.issue_action ASC
329
+ LIMIT ?
330
+ `).all(id, cleanIssueActivityLimit(opts.limit)) as Array<{
331
+ agent_id: string;
332
+ agent_name: string | null;
333
+ agent_kind: string | null;
334
+ issue_action: IssueActivityAction;
335
+ count: number;
336
+ first_at: number;
337
+ last_at: number;
338
+ }>;
339
+ return rows.map((row) => ({
340
+ agentId: row.agent_id,
341
+ ...(row.agent_name ? { agentName: row.agent_name } : {}),
342
+ ...(row.agent_kind ? { agentKind: row.agent_kind } : {}),
343
+ action: cleanIssueAction(row.issue_action),
344
+ count: row.count,
345
+ firstAt: row.first_at,
346
+ lastAt: row.last_at,
347
+ }));
348
+ }
349
+
350
+ export function listAgentIssueActivity(agentId: string, opts: { limit?: number } = {}): AgentIssueActivitySummary[] {
351
+ const id = requiredText(agentId, "agentId", 200);
352
+ const rows = getDb().query(`
353
+ SELECT
354
+ ae.issue_ref_id,
355
+ ae.issue_action,
356
+ count(*) AS count,
357
+ min(ae.created_at) AS first_at,
358
+ max(ae.created_at) AS last_at
359
+ FROM activity_events ae
360
+ WHERE ae.agent_id = ? AND ae.issue_ref_id IS NOT NULL AND ae.issue_action IS NOT NULL
361
+ GROUP BY ae.issue_ref_id, ae.issue_action
362
+ ORDER BY last_at DESC, ae.issue_ref_id ASC, ae.issue_action ASC
363
+ LIMIT ?
364
+ `).all(id, cleanIssueActivityLimit(opts.limit)) as Array<{
365
+ issue_ref_id: string;
366
+ issue_action: IssueActivityAction;
367
+ count: number;
368
+ first_at: number;
369
+ last_at: number;
370
+ }>;
371
+ return rows.flatMap((row) => {
372
+ const issueRef = getIssueRef(row.issue_ref_id);
373
+ if (!issueRef) return [];
374
+ return [{
375
+ issueRef,
376
+ action: cleanIssueAction(row.issue_action),
377
+ count: row.count,
378
+ firstAt: row.first_at,
379
+ lastAt: row.last_at,
380
+ }];
381
+ });
382
+ }
package/src/db/agents.ts CHANGED
@@ -23,6 +23,7 @@ import { closeOpenPairsForAgent } from "./pairs.ts";
23
23
  import { TASK_SELECT, insertTaskEvent } from "./tasks.ts";
24
24
  import { electWorkspaceStewards, electWorkspaceStewardsForAgent } from "./workspaces.ts";
25
25
  import { DEFAULT_SELF_RESUME_CONFIG } from "../self-resume-config";
26
+ import type { TokenRevokeReason } from "../security";
26
27
  import type {
27
28
  AgentCard,
28
29
  ActivityEvent,
@@ -454,7 +455,10 @@ export function reapStaleAgents(ttlMs: number = STALE_TTL_MS): string[] {
454
455
  )
455
456
  .all(cutoff) as any[];
456
457
  for (const row of rows) {
457
- revokeRuntimeTokensForAgent(row.id, now);
458
+ // Staleness == last_seen lapsed, which is exactly the relay-restart strand: the
459
+ // process may still be alive. Tag "stale" so a live runner can self-heal via an
460
+ // orchestrator re-mint (#484); a genuinely-dead one never re-mints and is pruned.
461
+ revokeRuntimeTokensForAgent(row.id, now, "stale");
458
462
  closeOpenPairsForAgent(row.id, now);
459
463
  }
460
464
  return rows.map((r: any) => r.id);
@@ -518,7 +522,8 @@ export function pruneOfflineAgents(maxOfflineMs: number = DAY_MS): string[] {
518
522
  .run(now, cutoff);
519
523
 
520
524
  for (const row of rows) {
521
- revokeRuntimeTokensForAgent(row.id, now);
525
+ // Offline past the prune horizon and being deleted — a hard kill, not recoverable.
526
+ revokeRuntimeTokensForAgent(row.id, now, "pruned");
522
527
  closeOpenPairsForAgent(row.id, now);
523
528
  }
524
529
 
@@ -551,7 +556,11 @@ export function deleteAgent(id: string): { ok: boolean; error?: string } {
551
556
  return deleted ? { ok: true } : { ok: false, error: "agent not found" };
552
557
  }
553
558
 
554
- export function revokeRuntimeTokensForAgent(agentId: string, now = Date.now()): string[] {
559
+ // #484: `reason` is stamped on each revocation so a stale-on-restart revocation
560
+ // (recoverable via orchestrator re-mint for a still-live process) is distinguishable
561
+ // from a hard kill. Default "agent-removed" (hard) — callers that revoke a possibly-
562
+ // live process across a restart MUST pass "stale".
563
+ export function revokeRuntimeTokensForAgent(agentId: string, now = Date.now(), reason: TokenRevokeReason = "agent-removed"): string[] {
555
564
  const row = getDb().query("SELECT meta FROM agents WHERE id = ?").get(agentId) as { meta?: string } | undefined;
556
565
  const jtis = runtimeTokenJtisFromMeta(parseJson(row?.meta ?? "{}", {}));
557
566
  if (jtis.length === 0) return [];
@@ -559,13 +568,13 @@ export function revokeRuntimeTokensForAgent(agentId: string, now = Date.now()):
559
568
  const placeholders = jtis.map(() => "?").join(",");
560
569
  const rows = getDb().query(`
561
570
  UPDATE tokens
562
- SET revoked_at = ?
571
+ SET revoked_at = ?, revoked_reason = ?
563
572
  WHERE revoked_at IS NULL
564
573
  AND role = 'provider'
565
574
  AND profile_id IN ('provider-agent', 'provider-interactive', 'provider-child')
566
575
  AND jti IN (${placeholders})
567
576
  RETURNING jti
568
- `).all(revokedAt, ...jtis) as Array<{ jti: string }>;
577
+ `).all(revokedAt, reason, ...jtis) as Array<{ jti: string }>;
569
578
  return rows.map((item) => item.jti);
570
579
  }
571
580
 
@@ -166,6 +166,7 @@ export function ingestIntegrationEvent(input: IntegrationEventInput, integration
166
166
  subject: `[${task.severity}] ${task.title}`,
167
167
  body: taskMessageBody(task),
168
168
  claimable: true,
169
+ replyExpected: input.replyExpected,
169
170
  payload: {
170
171
  taskId: task.id,
171
172
  source: task.source,
@@ -0,0 +1,128 @@
1
+ import { ISSUE_ASSOCIATION_ENTITY_TYPES, type IssueAssociation, type IssueAssociationEntity, type IssueAssociationEntityType, type IssueAssociationIssue } from "../types";
2
+ import { getDb, ValidationError } from "./connection.ts";
3
+ import { getIssueRef } from "./issue-refs.ts";
4
+
5
+ interface IssueAssociationRow {
6
+ issue_ref_id: string;
7
+ entity_type: IssueAssociationEntityType;
8
+ entity_id: string;
9
+ created_at: number;
10
+ }
11
+
12
+ export function planNodeIssueEntityId(planId: string, nodeId: string): string {
13
+ return `${cleanText(planId, "planId", 120)}:${cleanText(nodeId, "nodeId", 120)}`;
14
+ }
15
+
16
+ function rowToAssociation(row: IssueAssociationRow): IssueAssociation {
17
+ return {
18
+ issueRefId: row.issue_ref_id,
19
+ entityType: row.entity_type,
20
+ entityId: row.entity_id,
21
+ createdAt: row.created_at,
22
+ };
23
+ }
24
+
25
+ function cleanEntityType(value: IssueAssociationEntityType): IssueAssociationEntityType {
26
+ if (!ISSUE_ASSOCIATION_ENTITY_TYPES.includes(value)) {
27
+ throw new ValidationError(`entityType must be one of: ${ISSUE_ASSOCIATION_ENTITY_TYPES.join(", ")}`);
28
+ }
29
+ return value;
30
+ }
31
+
32
+ function cleanText(value: string | undefined, field: string, max: number): string {
33
+ if (typeof value !== "string") throw new ValidationError(`${field} required`);
34
+ const cleaned = value.trim();
35
+ if (!cleaned) throw new ValidationError(`${field} required`);
36
+ if (cleaned.length > max) throw new ValidationError(`${field} must be ${max} characters or fewer`);
37
+ return cleaned;
38
+ }
39
+
40
+ export function associateIssue(issueRefId: string, entityType: IssueAssociationEntityType, entityId: string): IssueAssociation {
41
+ const cleanedIssueRefId = cleanText(issueRefId, "issueRefId", 120);
42
+ if (!getIssueRef(cleanedIssueRefId)) throw new ValidationError(`issue ref ${cleanedIssueRefId} not found`);
43
+ const cleanedEntityType = cleanEntityType(entityType);
44
+ const cleanedEntityId = cleanText(entityId, "entityId", 240);
45
+ const now = Date.now();
46
+ getDb().query(`
47
+ INSERT INTO issue_associations (issue_ref_id, entity_type, entity_id, created_at)
48
+ VALUES (?, ?, ?, ?)
49
+ ON CONFLICT(issue_ref_id, entity_type, entity_id) DO NOTHING
50
+ `).run(cleanedIssueRefId, cleanedEntityType, cleanedEntityId, now);
51
+ const row = getDb().query(`
52
+ SELECT issue_ref_id, entity_type, entity_id, created_at
53
+ FROM issue_associations
54
+ WHERE issue_ref_id = ? AND entity_type = ? AND entity_id = ?
55
+ `).get(cleanedIssueRefId, cleanedEntityType, cleanedEntityId) as IssueAssociationRow | undefined;
56
+ if (!row) throw new ValidationError("issue association could not be created");
57
+ return rowToAssociation(row);
58
+ }
59
+
60
+ export function listIssuesForEntity(entityType: IssueAssociationEntityType, entityId: string): IssueAssociationIssue[] {
61
+ const rows = getDb().query(`
62
+ SELECT
63
+ a.issue_ref_id,
64
+ a.entity_type,
65
+ a.entity_id,
66
+ a.created_at,
67
+ r.id,
68
+ r.provider,
69
+ r.repo,
70
+ r.external_key,
71
+ r.url,
72
+ r.title,
73
+ r.status,
74
+ r.status_synced_at,
75
+ r.created_at AS issue_created_at,
76
+ r.updated_at AS issue_updated_at
77
+ FROM issue_associations a
78
+ JOIN issue_refs r ON r.id = a.issue_ref_id
79
+ WHERE a.entity_type = ? AND a.entity_id = ?
80
+ ORDER BY a.created_at DESC, a.issue_ref_id DESC
81
+ `).all(cleanEntityType(entityType), cleanText(entityId, "entityId", 240)) as Array<IssueAssociationRow & {
82
+ id: string;
83
+ provider: IssueAssociationIssue["issueRef"]["provider"];
84
+ repo: string;
85
+ external_key: string;
86
+ url: string;
87
+ title: string | null;
88
+ status: IssueAssociationIssue["issueRef"]["status"];
89
+ status_synced_at: number | null;
90
+ issue_created_at: number;
91
+ issue_updated_at: number;
92
+ }>;
93
+ return rows.map((row) => ({
94
+ association: rowToAssociation(row),
95
+ issueRef: {
96
+ id: row.id,
97
+ provider: row.provider,
98
+ repo: row.repo,
99
+ externalKey: row.external_key,
100
+ url: row.url,
101
+ ...(row.title ? { title: row.title } : {}),
102
+ status: row.status,
103
+ ...(row.status_synced_at !== null ? { statusSyncedAt: row.status_synced_at } : {}),
104
+ createdAt: row.issue_created_at,
105
+ updatedAt: row.issue_updated_at,
106
+ },
107
+ }));
108
+ }
109
+
110
+ export function listEntitiesForIssue(issueRefId: string): IssueAssociationEntity[] {
111
+ const cleanedIssueRefId = cleanText(issueRefId, "issueRefId", 120);
112
+ if (!getIssueRef(cleanedIssueRefId)) throw new ValidationError(`issue ref ${cleanedIssueRefId} not found`);
113
+ const rows = getDb().query(`
114
+ SELECT issue_ref_id, entity_type, entity_id, created_at
115
+ FROM issue_associations
116
+ WHERE issue_ref_id = ?
117
+ ORDER BY created_at DESC, entity_type ASC, entity_id ASC
118
+ `).all(cleanedIssueRefId) as IssueAssociationRow[];
119
+ return rows.map((row) => ({ entityType: row.entity_type, entityId: row.entity_id, createdAt: row.created_at }));
120
+ }
121
+
122
+ export function removeAssociation(issueRefId: string, entityType: IssueAssociationEntityType, entityId: string): boolean {
123
+ const result = getDb().query(`
124
+ DELETE FROM issue_associations
125
+ WHERE issue_ref_id = ? AND entity_type = ? AND entity_id = ?
126
+ `).run(cleanText(issueRefId, "issueRefId", 120), cleanEntityType(entityType), cleanText(entityId, "entityId", 240));
127
+ return result.changes > 0;
128
+ }