agent-relay-server 0.61.2 → 0.62.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.
Files changed (174) hide show
  1. package/docs/openapi.json +479 -1
  2. package/package.json +2 -2
  3. package/public/assets/{MessageBubble-DVvlvi4B.js → MessageBubble-BP0Ym3xb.js} +2 -2
  4. package/public/assets/{MessageBubble-DVvlvi4B.js.map → MessageBubble-BP0Ym3xb.js.map} +1 -1
  5. package/public/assets/PlanGraphPanel-DKc3gdou.js +2 -0
  6. package/public/assets/PlanGraphPanel-DKc3gdou.js.map +1 -0
  7. package/public/assets/{activity-IYl3MEXC.js → activity-CRLG6ICe.js} +2 -2
  8. package/public/assets/{activity-IYl3MEXC.js.map → activity-CRLG6ICe.js.map} +1 -1
  9. package/public/assets/{agent-profiles-BfGTTbeY.js → agent-profiles-DItyEkXP.js} +2 -2
  10. package/public/assets/{agent-profiles-BfGTTbeY.js.map → agent-profiles-DItyEkXP.js.map} +1 -1
  11. package/public/assets/{agents-BquvREQr.js → agents-5mKiZ1rz.js} +2 -2
  12. package/public/assets/{agents-BquvREQr.js.map → agents-5mKiZ1rz.js.map} +1 -1
  13. package/public/assets/{analytics-De0cEZGx.js → analytics-J5pPcS-v.js} +3 -3
  14. package/public/assets/{analytics-De0cEZGx.js.map → analytics-J5pPcS-v.js.map} +1 -1
  15. package/public/assets/{automation-Ddbermbi.js → automation-BG2HWK5N.js} +2 -2
  16. package/public/assets/{automation-Ddbermbi.js.map → automation-BG2HWK5N.js.map} +1 -1
  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-BZMoxs65.js} +2 -2
  20. package/public/assets/{branch-state-badge-DSyWKluD.js.map → branch-state-badge-BZMoxs65.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-B2PeqiRT.js +2 -0
  26. package/public/assets/{channels-BG0Nib7y.js.map → channels-B2PeqiRT.js.map} +1 -1
  27. package/public/assets/chat-CLRJVagA.js +2 -0
  28. package/public/assets/{chat-Dx6qWNxb.js.map → chat-CLRJVagA.js.map} +1 -1
  29. package/public/assets/{connectors-DWh7MwV_.js → connectors-Bzvkxsgo.js} +2 -2
  30. package/public/assets/{connectors-DWh7MwV_.js.map → connectors-Bzvkxsgo.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-QqHxdMGP.js} +2 -2
  59. package/public/assets/{formatted-body-impl-DMm6jeqi.js.map → formatted-body-impl-QqHxdMGP.js.map} +1 -1
  60. package/public/assets/index-CCp1V35c.js +22 -0
  61. package/public/assets/index-CCp1V35c.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-D-EyDfoZ.js} +2 -2
  66. package/public/assets/{insights-C7wKt1_l.js.map → insights-D-EyDfoZ.js.map} +1 -1
  67. package/public/assets/{integrations-CG9qyHUf.js → integrations-D1kc-yNi.js} +2 -2
  68. package/public/assets/{integrations-CG9qyHUf.js.map → integrations-D1kc-yNi.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-COqID7aq.js} +2 -2
  74. package/public/assets/{maintenance-CjpvnV_U.js.map → maintenance-COqID7aq.js.map} +1 -1
  75. package/public/assets/{managed-agents-CdfuBKJA.js → managed-agents-b4B8qewQ.js} +2 -2
  76. package/public/assets/{managed-agents-CdfuBKJA.js.map → managed-agents-b4B8qewQ.js.map} +1 -1
  77. package/public/assets/{markdown-preview-impl-Cf4xCmWt.js → markdown-preview-impl-D7o8jS07.js} +2 -2
  78. package/public/assets/{markdown-preview-impl-Cf4xCmWt.js.map → markdown-preview-impl-D7o8jS07.js.map} +1 -1
  79. package/public/assets/memory-Q0qF7_i3.js +2 -0
  80. package/public/assets/{memory-xKWduhyr.js.map → memory-Q0qF7_i3.js.map} +1 -1
  81. package/public/assets/{messages-BpZYOUtJ.js → messages-m4EpkK6s.js} +2 -2
  82. package/public/assets/{messages-BpZYOUtJ.js.map → messages-m4EpkK6s.js.map} +1 -1
  83. package/public/assets/{orchestrators-BrJvGIOc.js → orchestrators-CnAtGfUN.js} +2 -2
  84. package/public/assets/{orchestrators-BrJvGIOc.js.map → orchestrators-CnAtGfUN.js.map} +1 -1
  85. package/public/assets/{overview-BUORwePi.js → overview-D7CF88ik.js} +2 -2
  86. package/public/assets/{overview-BUORwePi.js.map → overview-D7CF88ik.js.map} +1 -1
  87. package/public/assets/pairs-Q6PlFJtH.js +2 -0
  88. package/public/assets/{pairs-DR0GizDN.js.map → pairs-Q6PlFJtH.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-BeV-6SdA.js} +2 -2
  92. package/public/assets/{security-DLwSqTc_.js.map → security-BeV-6SdA.js.map} +1 -1
  93. package/public/assets/{select-pEru111r.js → select-Dchc0_Jg.js} +2 -2
  94. package/public/assets/{select-pEru111r.js.map → select-Dchc0_Jg.js.map} +1 -1
  95. package/public/assets/{settings-B-ueGvwV.js → settings-hPwL-31u.js} +3 -3
  96. package/public/assets/{settings-B-ueGvwV.js.map → settings-hPwL-31u.js.map} +1 -1
  97. package/public/assets/store-Buwkqv_7.js +9 -0
  98. package/public/assets/store-Buwkqv_7.js.map +1 -0
  99. package/public/assets/tasks-DKUMPEgk.js +2 -0
  100. package/public/assets/{tasks-C2rRQm6E.js.map → tasks-DKUMPEgk.js.map} +1 -1
  101. package/public/assets/teams-T5fFsdOX.js +2 -0
  102. package/public/assets/teams-T5fFsdOX.js.map +1 -0
  103. package/public/assets/{terminal-viewer-impl-BM_3qoeN.js → terminal-viewer-impl-Bk0u_Mav.js} +3 -3
  104. package/public/assets/{terminal-viewer-impl-BM_3qoeN.js.map → terminal-viewer-impl-Bk0u_Mav.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-D26jok09.js} +2 -2
  108. package/public/assets/{work-queue-CkIYDAZD.js.map → work-queue-D26jok09.js.map} +1 -1
  109. package/public/assets/{workspaces-CYm5bLg9.js → workspaces-BMj6zx23.js} +3 -3
  110. package/public/assets/{workspaces-CYm5bLg9.js.map → workspaces-BMj6zx23.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 +3 -0
  115. package/src/cli/workspace.ts +9 -17
  116. package/src/db/activity.ts +191 -3
  117. package/src/db/integrations.ts +1 -0
  118. package/src/db/issue-associations.ts +128 -0
  119. package/src/db/issue-lifecycle.ts +187 -0
  120. package/src/db/issue-refs.ts +254 -0
  121. package/src/db/mappers.ts +2 -0
  122. package/src/db/migrations.ts +47 -0
  123. package/src/db/schema.ts +2 -2
  124. package/src/issue-tracker/adapter.ts +24 -0
  125. package/src/issue-tracker/github.ts +136 -0
  126. package/src/issue-tracker/index.ts +13 -0
  127. package/src/issue-tracker/registry.ts +26 -0
  128. package/src/maintenance.ts +22 -169
  129. package/src/mcp-issue-activity-tools.ts +114 -0
  130. package/src/mcp-issue-association-tools.ts +130 -0
  131. package/src/mcp-issue-lifecycle-tools.ts +55 -0
  132. package/src/mcp-issue-ref-tools.ts +174 -0
  133. package/src/mcp-team-tools.ts +1 -1
  134. package/src/mcp.ts +3 -3
  135. package/src/provider-config-store.ts +30 -8
  136. package/src/routes/activity.ts +6 -1
  137. package/src/routes/commands.ts +29 -0
  138. package/src/routes/index.ts +19 -0
  139. package/src/routes/issue-activity.ts +78 -0
  140. package/src/routes/issue-associations.ts +76 -0
  141. package/src/routes/issue-lifecycle.ts +34 -0
  142. package/src/routes/issue-refs.ts +137 -0
  143. package/src/routes/orchestrator.ts +42 -1
  144. package/src/security.ts +8 -0
  145. package/src/services/issue-activity.ts +30 -0
  146. package/src/services/issue-associations.ts +72 -0
  147. package/src/services/issue-lifecycle.ts +308 -0
  148. package/src/services/issue-refs.ts +32 -0
  149. package/src/services/plan-graph.ts +40 -2
  150. package/src/services/team.ts +4 -0
  151. package/src/token-db.ts +3 -3
  152. package/src/workspace-actions.ts +8 -1
  153. package/src/workspace-auto-merge.ts +228 -0
  154. package/src/workspace-probe.ts +125 -0
  155. package/public/assets/PlanGraphPanel-Dby7wq9c.js +0 -2
  156. package/public/assets/PlanGraphPanel-Dby7wq9c.js.map +0 -1
  157. package/public/assets/card-CcZ9hLI7.js +0 -2
  158. package/public/assets/channels-BG0Nib7y.js +0 -2
  159. package/public/assets/chat-Dx6qWNxb.js +0 -2
  160. package/public/assets/copy-button-eF3cKp4Q.js +0 -2
  161. package/public/assets/dist-BLBF7qqr.js +0 -2
  162. package/public/assets/dist-YpHfnnIA.js +0 -2
  163. package/public/assets/dist-qd198ib9.js +0 -2
  164. package/public/assets/index-B3wOeAPO.js +0 -21
  165. package/public/assets/index-B3wOeAPO.js.map +0 -1
  166. package/public/assets/index-Dn_GDXbi.css +0 -2
  167. package/public/assets/lucide-react-L2UxFDic.js +0 -9
  168. package/public/assets/memory-xKWduhyr.js +0 -2
  169. package/public/assets/pairs-DR0GizDN.js +0 -2
  170. package/public/assets/store-B8xP6z6T.js +0 -9
  171. package/public/assets/store-B8xP6z6T.js.map +0 -1
  172. package/public/assets/tasks-C2rRQm6E.js +0 -2
  173. package/public/assets/teams-Bo0sdBTp.js +0 -2
  174. 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,
@@ -174,6 +175,8 @@ function handleFrame(ws: BusWebSocket, frame: ReturnType<typeof validateClientFr
174
175
  // merged meta — clears any pending stall watch (stale events ignored).
175
176
  noteAgentTimelineEvent(conn.agentId, after?.meta?.timelineEvent);
176
177
  emitAgentStatusEvent(conn.agentId);
178
+ // #237 stop-hook → instant ⚪↔🟡 changes detection on the turn-end edge.
179
+ probeWorkspaceOnTurnEnd(before, after);
177
180
  });
178
181
  return;
179
182
  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
+ }
@@ -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
+ }