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
@@ -0,0 +1,76 @@
1
+ import { ISSUE_ASSOCIATION_ENTITY_TYPES, ISSUE_PROVIDERS, isRecord, type IssueAssociationEntityType, type IssueProvider } from "../types";
2
+ import { associateIssueRef, listIssueEntities, listIssueRefsForEntity, removeIssueAssociation, resolveAssociationIssueRef } from "../services/issue-associations";
3
+ import { cleanEnum, cleanString, optionalEnum } from "../validation";
4
+ import { error, json, parseBody, type Handler } from "./_shared";
5
+
6
+ function cleanEntityType(value: unknown): IssueAssociationEntityType {
7
+ return cleanEnum(value, "entityType", ISSUE_ASSOCIATION_ENTITY_TYPES) as IssueAssociationEntityType;
8
+ }
9
+
10
+ function cleanIssueInput(value: Record<string, unknown>) {
11
+ return {
12
+ issueRefId: cleanString(value.issueRefId, "issueRefId", { max: 120 }),
13
+ ref: cleanString(value.ref, "ref", { max: 1000 }),
14
+ defaultRepo: cleanString(value.defaultRepo, "defaultRepo", { max: 500 }),
15
+ provider: optionalEnum(value.provider, "provider", ISSUE_PROVIDERS) as IssueProvider | undefined,
16
+ repo: cleanString(value.repo, "repo", { max: 500 }),
17
+ externalKey: cleanString(value.externalKey, "externalKey", { max: 240 }),
18
+ url: cleanString(value.url, "url", { max: 2000 }),
19
+ title: cleanString(value.title, "title", { max: 500 }),
20
+ };
21
+ }
22
+
23
+ function cleanAssociationBody(body: unknown) {
24
+ if (!isRecord(body)) throw new Error("issue association body required");
25
+ return {
26
+ ...cleanIssueInput(body),
27
+ entityType: cleanEntityType(body.entityType),
28
+ entityId: cleanString(body.entityId, "entityId", { required: true, max: 240 })!,
29
+ };
30
+ }
31
+
32
+ function routeError(err: unknown): Response {
33
+ return error(err instanceof Error ? err.message : "issue associations route failed");
34
+ }
35
+
36
+ export const postIssueAssociationRoute: Handler = async (req) => {
37
+ const parsed = await parseBody<unknown>(req);
38
+ if (!parsed.ok) return error(parsed.error, parsed.status);
39
+ try {
40
+ return json(associateIssueRef(cleanAssociationBody(parsed.body)));
41
+ } catch (err) {
42
+ return routeError(err);
43
+ }
44
+ };
45
+
46
+ export const getIssueAssociationEntityIssuesRoute: Handler = (_req, params) => {
47
+ try {
48
+ return json({
49
+ issues: listIssueRefsForEntity(
50
+ cleanEntityType(params.entityType),
51
+ cleanString(params.entityId, "entityId", { required: true, max: 240 })!,
52
+ ),
53
+ });
54
+ } catch (err) {
55
+ return routeError(err);
56
+ }
57
+ };
58
+
59
+ export const getIssueAssociationIssueEntitiesRoute: Handler = (_req, params) => {
60
+ try {
61
+ const issueRef = resolveAssociationIssueRef({ issueRefId: cleanString(params.issueRefId, "issueRefId", { required: true, max: 120 })! });
62
+ return json({ issueRef, entities: listIssueEntities(issueRef.id) });
63
+ } catch (err) {
64
+ return routeError(err);
65
+ }
66
+ };
67
+
68
+ export const deleteIssueAssociationRoute: Handler = async (req) => {
69
+ const parsed = await parseBody<unknown>(req);
70
+ if (!parsed.ok) return error(parsed.error, parsed.status);
71
+ try {
72
+ return json(removeIssueAssociation(cleanAssociationBody(parsed.body)));
73
+ } catch (err) {
74
+ return routeError(err);
75
+ }
76
+ };
@@ -0,0 +1,34 @@
1
+ import { reopenIssueLifecycle } from "../services/issue-lifecycle";
2
+ import { cleanEnum, cleanString, optionalEnum } from "../validation";
3
+ import { error, json, parseBody, type Handler } from "./_shared";
4
+ import { ISSUE_ASSOCIATION_ENTITY_TYPES, ISSUE_PROVIDERS, isRecord, type IssueAssociationEntityType, type IssueProvider } from "../types";
5
+
6
+ function cleanReopenBody(body: unknown) {
7
+ if (!isRecord(body)) throw new Error("issue lifecycle reopen body required");
8
+ return {
9
+ issueRefId: cleanString(body.issueRefId, "issueRefId", { max: 120 }),
10
+ ref: cleanString(body.ref, "ref", { max: 1000 }),
11
+ defaultRepo: cleanString(body.defaultRepo ?? body.repo, "defaultRepo", { max: 500 }),
12
+ provider: optionalEnum(body.provider, "provider", ISSUE_PROVIDERS) as IssueProvider | undefined,
13
+ repo: cleanString(body.repo, "repo", { max: 500 }),
14
+ externalKey: cleanString(body.externalKey, "externalKey", { max: 240 }),
15
+ url: cleanString(body.url, "url", { max: 2000 }),
16
+ title: cleanString(body.title, "title", { max: 500 }),
17
+ comment: cleanString(body.comment, "comment", { max: 2000 }),
18
+ agentId: cleanString(body.agentId, "agentId", { max: 200 }),
19
+ entityType: body.entityType === undefined ? undefined : cleanEnum(body.entityType, "entityType", ISSUE_ASSOCIATION_ENTITY_TYPES) as IssueAssociationEntityType,
20
+ entityId: cleanString(body.entityId, "entityId", { max: 240 }),
21
+ };
22
+ }
23
+
24
+ export const postIssueLifecycleReopenRoute: Handler = async (req) => {
25
+ const parsed = await parseBody<unknown>(req);
26
+ if (!parsed.ok) return error(parsed.error, parsed.status);
27
+ try {
28
+ const result = await reopenIssueLifecycle(cleanReopenBody(parsed.body));
29
+ if (result.action === "failed") return error(result.error, 502);
30
+ return json(result);
31
+ } catch (err) {
32
+ return error(err instanceof Error ? err.message : "issue lifecycle route failed");
33
+ }
34
+ };
@@ -0,0 +1,137 @@
1
+ import { deleteIssueRefMapping, getIssueRef, listIssueRefs, patchIssueRefMapping, resolveIssueRefMapping, upsertIssueRefMapping, type ListIssueRefsOptions } from "../services/issue-refs";
2
+ import { cleanEnum, cleanString, optionalEnum } from "../validation";
3
+ import { error, json, parseBody, parseQueryInt, type Handler } from "./_shared";
4
+ import { ISSUE_PROVIDERS, ISSUE_REF_STATUSES, isRecord, type IssueProvider, type IssueRefStatus } from "../types";
5
+
6
+ function cleanStatus(value: unknown): IssueRefStatus | undefined {
7
+ return optionalEnum(value, "status", ISSUE_REF_STATUSES) as IssueRefStatus | undefined;
8
+ }
9
+
10
+ function cleanProvider(value: unknown): IssueProvider {
11
+ return cleanEnum(value, "provider", ISSUE_PROVIDERS) as IssueProvider;
12
+ }
13
+
14
+ function cleanTimestamp(value: unknown): number | undefined {
15
+ if (value === undefined || value === null) return undefined;
16
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new Error("statusSyncedAt must be a non-negative integer");
17
+ return value;
18
+ }
19
+
20
+ function cleanUpsertBody(body: unknown) {
21
+ if (!isRecord(body)) throw new Error("issue ref body required");
22
+ return {
23
+ provider: cleanProvider(body.provider),
24
+ repo: cleanString(body.repo, "repo", { required: true, max: 500 })!,
25
+ externalKey: cleanString(body.externalKey, "externalKey", { required: true, max: 240 })!,
26
+ url: cleanString(body.url, "url", { required: true, max: 2000 })!,
27
+ title: cleanString(body.title, "title", { max: 500 }),
28
+ status: cleanStatus(body.status),
29
+ statusSyncedAt: cleanTimestamp(body.statusSyncedAt),
30
+ };
31
+ }
32
+
33
+ function cleanPatchBody(body: unknown) {
34
+ if (!isRecord(body)) throw new Error("issue ref patch body required");
35
+ return {
36
+ url: cleanString(body.url, "url", { max: 2000 }),
37
+ title: cleanString(body.title, "title", { max: 500 }),
38
+ status: cleanStatus(body.status),
39
+ statusSyncedAt: cleanTimestamp(body.statusSyncedAt),
40
+ };
41
+ }
42
+
43
+ function cleanResolveBody(body: unknown) {
44
+ if (!isRecord(body)) throw new Error("issue ref resolve body required");
45
+ return body;
46
+ }
47
+
48
+ export const listIssueRefsRoute: Handler = (req) => {
49
+ try {
50
+ const url = new URL(req.url);
51
+ const rawLimit = parseQueryInt(url.searchParams.get("limit"), { min: 1, max: 500, clamp: true });
52
+ if (Number.isNaN(rawLimit)) return error("limit must be an integer");
53
+ const opts: ListIssueRefsOptions = {
54
+ provider: optionalEnum(url.searchParams.get("provider") ?? undefined, "provider", ISSUE_PROVIDERS) as IssueProvider | undefined,
55
+ repo: cleanString(url.searchParams.get("repo") ?? undefined, "repo", { max: 500 }),
56
+ status: optionalEnum(url.searchParams.get("status") ?? undefined, "status", ISSUE_REF_STATUSES) as IssueRefStatus | undefined,
57
+ limit: rawLimit ?? undefined,
58
+ };
59
+ return json({ issueRefs: listIssueRefs(opts) });
60
+ } catch (err) {
61
+ return error(err instanceof Error ? err.message : "issue refs route failed");
62
+ }
63
+ };
64
+
65
+ export const postIssueRefRoute: Handler = async (req) => {
66
+ const parsed = await parseBody<unknown>(req);
67
+ if (!parsed.ok) return error(parsed.error, parsed.status);
68
+ try {
69
+ return json({ issueRef: upsertIssueRefMapping(cleanUpsertBody(parsed.body)) });
70
+ } catch (err) {
71
+ return error(err instanceof Error ? err.message : "issue refs route failed");
72
+ }
73
+ };
74
+
75
+ export const resolveIssueRefRoute: Handler = async (req) => {
76
+ const url = new URL(req.url);
77
+ const body = req.method === "POST" ? await parseBody<unknown>(req) : null;
78
+ if (body && !body.ok) return error(body.error, body.status);
79
+ try {
80
+ const value = body ? cleanResolveBody(body.body) : Object.fromEntries(url.searchParams.entries());
81
+ const ref = cleanString(value.ref, "ref", { max: 1000 });
82
+ const issueRef = ref
83
+ ? resolveIssueRefMapping({
84
+ ref,
85
+ defaultRepo: cleanString(value.defaultRepo ?? value.repo, "defaultRepo", { max: 500 }),
86
+ provider: optionalEnum(value.provider, "provider", ISSUE_PROVIDERS, "github") as IssueProvider,
87
+ title: cleanString(value.title, "title", { max: 500 }),
88
+ status: cleanStatus(value.status),
89
+ statusSyncedAt: cleanTimestamp(value.statusSyncedAt),
90
+ })
91
+ : resolveIssueRefMapping({
92
+ provider: cleanProvider(value.provider),
93
+ repo: cleanString(value.repo, "repo", { required: true, max: 500 })!,
94
+ externalKey: cleanString(value.externalKey, "externalKey", { required: true, max: 240 })!,
95
+ url: cleanString(value.url, "url", { max: 2000 }),
96
+ title: cleanString(value.title, "title", { max: 500 }),
97
+ status: cleanStatus(value.status),
98
+ statusSyncedAt: cleanTimestamp(value.statusSyncedAt),
99
+ });
100
+ return json({ issueRef });
101
+ } catch (err) {
102
+ return error(err instanceof Error ? err.message : "issue refs route failed");
103
+ }
104
+ };
105
+
106
+ export const getIssueRefRoute: Handler = (_req, params) => {
107
+ try {
108
+ const id = cleanString(params.id, "id", { required: true, max: 120 })!;
109
+ const issueRef = getIssueRef(id);
110
+ if (!issueRef) return error("issue ref not found", 404);
111
+ return json({ issueRef });
112
+ } catch (err) {
113
+ return error(err instanceof Error ? err.message : "issue refs route failed");
114
+ }
115
+ };
116
+
117
+ export const patchIssueRefRoute: Handler = async (req, params) => {
118
+ const parsed = await parseBody<unknown>(req);
119
+ if (!parsed.ok) return error(parsed.error, parsed.status);
120
+ try {
121
+ const issueRef = patchIssueRefMapping(cleanString(params.id, "id", { required: true, max: 120 })!, cleanPatchBody(parsed.body));
122
+ if (!issueRef) return error("issue ref not found", 404);
123
+ return json({ issueRef });
124
+ } catch (err) {
125
+ return error(err instanceof Error ? err.message : "issue refs route failed");
126
+ }
127
+ };
128
+
129
+ export const deleteIssueRefRoute: Handler = (_req, params) => {
130
+ try {
131
+ const deleted = deleteIssueRefMapping(cleanString(params.id, "id", { required: true, max: 120 })!);
132
+ if (!deleted) return error("issue ref not found", 404);
133
+ return json({ deleted });
134
+ } catch (err) {
135
+ return error(err instanceof Error ? err.message : "issue refs route failed");
136
+ }
137
+ };
@@ -85,7 +85,8 @@ export const postOrchestratorBootstrapExchange: Handler = async (req) => {
85
85
  baseDir,
86
86
  createdBy: auth.jti ? `bootstrap:${auth.jti}` : "bootstrap",
87
87
  });
88
- if (auth.jti) revokeToken(auth.jti);
88
+ // One-shot bootstrap token consumed: hard-revoke (rotation), never re-mintable.
89
+ if (auth.jti) revokeToken(auth.jti, "rotation");
89
90
  return json(runtimeToken, 201);
90
91
  } catch (e) {
91
92
  if (e instanceof ValidationError) return error(e.message, 400);
@@ -364,6 +364,26 @@ function orchestratorSelfUpgradeError(orch: ReturnType<typeof getOrchestrator>):
364
364
  return undefined;
365
365
  }
366
366
 
367
+ // One-time, server-authority migration of host-local provider config files into the
368
+ // central provider-config rows (#465). The orchestrator reports its files via the
369
+ // command result; the server seeds the rows (see patchCommand). Capability-gated so
370
+ // pre-#465 orchestrators that lack the handler never get a command they'd reject.
371
+ function dispatchProviderConfigMigration(
372
+ orch: NonNullable<ReturnType<typeof getOrchestrator>>,
373
+ requestedBy = "system",
374
+ ): ReturnType<typeof createCommand> | null {
375
+ if (!orch.capabilities?.migrateProviderConfig) return null;
376
+ const command = createCommand({
377
+ type: "orchestrator.migrate-provider-config",
378
+ source: "system",
379
+ target: orch.agentId,
380
+ ttlMs: 10 * 60_000,
381
+ params: { orchestratorId: orch.id, requestedBy },
382
+ });
383
+ emitCommand(command);
384
+ return command;
385
+ }
386
+
367
387
  function reconcileOrchestratorUpgrade(orch: ReturnType<typeof getOrchestrator>): void {
368
388
  if (!orch) return;
369
389
  const up = orch.upgrade;
@@ -389,6 +409,9 @@ function reconcileOrchestratorUpgrade(orch: ReturnType<typeof getOrchestrator>):
389
409
  view: "orchestrators",
390
410
  metadata: { orchestratorId: orch.id, version: reported, commandId: up.commandId },
391
411
  });
412
+ // Upgrade settled — the orchestrator is now running new-enough code to handle
413
+ // the provider-config migration, so seed the central rows from its local files (#465).
414
+ dispatchProviderConfigMigration(orch);
392
415
  emitOrchestratorStatus(orch.id);
393
416
  } else if (up.status === "pending" && Date.now() - up.requestedAt > ORCH_UPGRADE_DEADLINE_MS) {
394
417
  const errMsg = `upgrade timed out; orchestrator still reports ${reported ?? "unknown"} (wanted ${up.desiredVersion})`;
@@ -416,9 +439,27 @@ export const postOrchestratorAction: Handler = async (req, params) => {
416
439
  if (!orch) return error("orchestrator not found", 404);
417
440
 
418
441
  if (!isRecord(parsed.body)) return error("body required");
419
- const action = optionalEnum(parsed.body.action, "action", ["restart", "shutdown", "upgrade"] as const);
442
+ const action = optionalEnum(parsed.body.action, "action", ["restart", "shutdown", "upgrade", "migrate-provider-config"] as const);
420
443
  if (!action) return error("action required");
421
444
 
445
+ if (action === "migrate-provider-config") {
446
+ const denied = authorizeRoute(req, { scope: "command:write", resource: { orchestratorId: orch.id } });
447
+ if (denied) return denied;
448
+ const command = dispatchProviderConfigMigration(orch, "dashboard");
449
+ if (!command) return error(`orchestrator ${orch.id} does not support provider-config migration; upgrade it first`, 409);
450
+ auditEvent({
451
+ clientId: `server-orchestrator-migrate-provider-config-${orch.id}-${command.id}`,
452
+ kind: "state",
453
+ title: "Provider config migration requested",
454
+ body: orch.id,
455
+ meta: orch.id,
456
+ icon: "ti-arrow-up-circle",
457
+ view: "orchestrators",
458
+ metadata: { orchestratorId: orch.id, commandId: command.id, ...authAuditMetadata(req), ...dashboardAttribution(req, parsed.body.surface) },
459
+ });
460
+ return json({ ok: true, action, command }, 202);
461
+ }
462
+
422
463
  if (action === "upgrade") {
423
464
  const denied = authorizeRoute(req, { scope: "command:write", resource: { orchestratorId: orch.id } });
424
465
  if (denied) return denied;
@@ -337,6 +337,6 @@ export const deleteTokenProfileRoute: Handler = (_req, params) => {
337
337
  };
338
338
 
339
339
  export const postTokenRevoke: Handler = (_req, params) => {
340
- if (!revokeToken(params.jti!)) return error("token not found or already revoked", 404);
340
+ if (!revokeToken(params.jti!, "admin")) return error("token not found or already revoked", 404);
341
341
  return json({ ok: true });
342
342
  };
@@ -1,4 +1,4 @@
1
- import { createToken, getTokenProfile, revokeToken } from "./token-db";
1
+ import { createToken, getTokenProfile, hardenStaleRevocation, revokeToken } from "./token-db";
2
2
  import { verifyComponentTokenAllowExpired } from "./security";
3
3
  import { spawnGrantForProfile } from "./config-store";
4
4
  import type { TokenRecord } from "./types";
@@ -26,20 +26,22 @@ interface RuntimeTokenResult {
26
26
  // a long-dead token to a sane window — a live session re-mints well within this.
27
27
  const REMINT_MAX_EXPIRED_AGE_SECONDS = 30 * 24 * 60 * 60; // 30 days
28
28
 
29
- // Orchestrator-mediated re-mint: given a runner's current (possibly expired) token
30
- // and the calling orchestrator's id, verify the token belongs to a runner of THIS
31
- // orchestrator and issue a fresh provider-agent token cloning its scope. The old
32
- // token is revoked. The caller MUST already be authenticated/authorized as the
33
- // orchestrator this only establishes that the presented token is a genuine,
34
- // non-revoked runner token owned by that orchestrator. See approach #1 in the
35
- // runner self-heal design: the relay stays strict, the orchestrator's standing
36
- // privilege is the authorization, the signed token is the identity.
29
+ // Orchestrator-mediated re-mint: given a runner's current (possibly expired OR
30
+ // stale-revoked) token and the calling orchestrator's id, verify the token belongs
31
+ // to a runner of THIS orchestrator and issue a fresh provider-agent token cloning
32
+ // its scope. The old token is then hard-revoked so it cannot be re-minted twice.
33
+ // The caller MUST already be authenticated/authorized as the orchestrator this
34
+ // only establishes that the presented token is a genuine runner token owned by that
35
+ // orchestrator. See approach #1/#2 in the runner self-heal design (#484): the relay
36
+ // stays strict, the orchestrator's standing privilege is the authorization, the
37
+ // signed token is the identity. A token revoked as "stale" (the restart-strand of a
38
+ // still-live process) is heal-able here; a hard kill (admin/rotation/removal) is not.
37
39
  export function reissueRunnerRuntimeToken(input: {
38
40
  expiredToken: string;
39
41
  orchestratorId: string;
40
42
  createdBy?: string;
41
43
  }): RuntimeTokenResult | { error: string } {
42
- const payload = verifyComponentTokenAllowExpired(input.expiredToken);
44
+ const payload = verifyComponentTokenAllowExpired(input.expiredToken, { allowStaleRevoked: true });
43
45
  if (!payload) return { error: "invalid or revoked runner token" };
44
46
  if (payload.role !== "provider" || !payload.sub.startsWith("runner:")) {
45
47
  return { error: "not a runner token" };
@@ -60,7 +62,9 @@ export function reissueRunnerRuntimeToken(input: {
60
62
  constraints: payload.constraints,
61
63
  createdBy: input.createdBy ?? `remint:${payload.jti ?? "unknown"}`,
62
64
  });
63
- if (payload.jti) revokeToken(payload.jti);
65
+ // Hard-revoke the consumed token so it can't mint again. revokeToken no-ops on an
66
+ // already-revoked (stale) token, so harden that case explicitly to "rotation".
67
+ if (payload.jti && !revokeToken(payload.jti, "rotation")) hardenStaleRevocation(payload.jti);
64
68
  return reissued;
65
69
  }
66
70
 
package/src/security.ts CHANGED
@@ -188,6 +188,10 @@ export function requiredScopeFor(method: string, pathname: string): string | nul
188
188
  if (pathname.startsWith("/api/pairs")) return method === "GET" ? "pairs:read" : "pairs:write";
189
189
  if (pathname.startsWith("/api/teams")) return method === "GET" ? "teams:read" : "teams:write";
190
190
  if (pathname.startsWith("/api/blackboard")) return method === "GET" ? "blackboard:read" : "blackboard:write";
191
+ if (pathname.startsWith("/api/issue-associations")) return method === "GET" ? "issue:read" : "issue:write";
192
+ if (pathname.startsWith("/api/issue-activity")) return method === "GET" ? "issue:read" : "issue:write";
193
+ if (pathname.startsWith("/api/issue-lifecycle")) return method === "GET" ? "issue:read" : "issue:write";
194
+ if (pathname.startsWith("/api/issue-refs")) return method === "GET" ? "issue:read" : "issue:write";
191
195
  if (pathname.startsWith("/api/projects")) return method === "GET" ? "project:read" : "project:write";
192
196
  // Insights config (the feature toggle) stays admin-only via the default; only the
193
197
  // mechanical observation feed is writable by lower-privilege callers.
@@ -269,6 +273,10 @@ export function requiredComponentScopeFor(method: string, pathname: string): str
269
273
  if (pathname.startsWith("/api/plans")) return method === "GET" ? "plans:read" : "plans:write";
270
274
  if (pathname.startsWith("/api/teams")) return method === "GET" ? "teams:read" : "teams:write";
271
275
  if (pathname.startsWith("/api/blackboard")) return method === "GET" ? "blackboard:read" : "blackboard:write";
276
+ if (pathname.startsWith("/api/issue-associations")) return method === "GET" ? "issue:read" : "issue:write";
277
+ if (pathname.startsWith("/api/issue-activity")) return method === "GET" ? "issue:read" : "issue:write";
278
+ if (pathname.startsWith("/api/issue-lifecycle")) return method === "GET" ? "issue:read" : "issue:write";
279
+ if (pathname.startsWith("/api/issue-refs")) return method === "GET" ? "issue:read" : "issue:write";
272
280
  // Fail-closed default returns system:admin, so this case is load-bearing: without it
273
281
  // every /api/projects call from a scoped component token would 403-blackhole (#405).
274
282
  if (pathname.startsWith("/api/projects")) return method === "GET" ? "project:read" : "project:write";
@@ -331,20 +339,60 @@ export function verifyComponentToken(token: string, nowSeconds = Math.floor(Date
331
339
  return payload;
332
340
  }
333
341
 
342
+ // Why a token was revoked (#484). The reason decides whether an orchestrator may
343
+ // re-mint it: a "stale" revocation (relay marked an agent stale across its own
344
+ // restart/reconnect) is recoverable for a process the orchestrator can confirm is
345
+ // alive; every other reason is a hard kill. This is the single home for the
346
+ // taxonomy — callers import the type, never re-declare the strings.
347
+ export type TokenRevokeReason = "stale" | "rotation" | "admin" | "agent-removed" | "pruned";
348
+
349
+ // The ONLY revocation reason an orchestrator-mediated re-mint may heal. A genuine
350
+ // kill — admin revoke, rotation, agent removal/prune, or a legacy NULL reason —
351
+ // stays an unconditional hard kill.
352
+ const REMINTABLE_REVOKE_REASONS: ReadonlySet<TokenRevokeReason> = new Set(["stale"]);
353
+
354
+ // Returns undefined if the token is not revoked, otherwise its reason string
355
+ // (NULL/legacy → null, treated as a hard kill).
356
+ function tokenRevocationReason(jti: string): string | null | undefined {
357
+ try {
358
+ const row = getDb().query("SELECT revoked_at, revoked_reason FROM tokens WHERE jti = ?").get(jti) as
359
+ | { revoked_at?: number | null; revoked_reason?: string | null }
360
+ | undefined;
361
+ if (!row?.revoked_at) return undefined;
362
+ return row.revoked_reason ?? null;
363
+ } catch {
364
+ return undefined;
365
+ }
366
+ }
367
+
334
368
  // Verify a component token's signature and revocation WITHOUT enforcing expiry.
335
369
  // Used only for orchestrator-mediated runner-token re-minting: an expired runner
336
370
  // token is unforgeable proof of identity (it is HMAC-signed by this relay), even
337
371
  // though it can no longer authenticate a request. The caller (an authenticated
338
372
  // orchestrator) supplies the authorization; this just establishes which runner.
339
- // Revoked tokens are still rejected revocation is a hard kill.
340
- export function verifyComponentTokenAllowExpired(token: string): ComponentToken | null {
373
+ // Revoked tokens are rejected as a hard kill EXCEPT when the caller opts into
374
+ // `allowStaleRevoked` AND the token was revoked as "stale" (#484) that case is the
375
+ // restart-strand of a still-live process, which the orchestrator heals by re-minting.
376
+ export function verifyComponentTokenAllowExpired(
377
+ token: string,
378
+ options?: { allowStaleRevoked?: boolean },
379
+ ): ComponentToken | null {
341
380
  const parts = token.split(".");
342
381
  if (parts.length !== 3) return null;
343
382
  const [headerRaw, payloadRaw, signature] = parts as [string, string, string];
344
383
  if (!safeEqual(signature, hmac(`${headerRaw}.${payloadRaw}`))) return null;
345
384
  const payload = parseBase64urlJson(payloadRaw);
346
385
  if (!isComponentToken(payload)) return null;
347
- if (payload.jti && isTokenRevoked(payload.jti)) return null;
386
+ if (payload.jti) {
387
+ const reason = tokenRevocationReason(payload.jti);
388
+ if (reason !== undefined) {
389
+ const remintable =
390
+ options?.allowStaleRevoked === true &&
391
+ typeof reason === "string" &&
392
+ REMINTABLE_REVOKE_REASONS.has(reason as TokenRevokeReason);
393
+ if (!remintable) return null;
394
+ }
395
+ }
348
396
  return payload;
349
397
  }
350
398
 
@@ -0,0 +1,30 @@
1
+ import {
2
+ ISSUE_ACTIVITY_ACTIONS,
3
+ listAgentIssueActivity,
4
+ listIssueActivityAgents,
5
+ recordIssueActivity,
6
+ resolveIssueActivityRef,
7
+ type IssueActivityAction,
8
+ type RecordIssueActivityInput,
9
+ } from "../db/activity";
10
+ import { emitActivityEvent } from "../sse";
11
+ import type { IssueProvider } from "../types";
12
+
13
+ export {
14
+ ISSUE_ACTIVITY_ACTIONS,
15
+ listAgentIssueActivity,
16
+ listIssueActivityAgents,
17
+ recordIssueActivity,
18
+ type IssueActivityAction,
19
+ type RecordIssueActivityInput,
20
+ };
21
+
22
+ export function recordAndEmitIssueActivity(input: RecordIssueActivityInput) {
23
+ const event = recordIssueActivity(input);
24
+ emitActivityEvent(event);
25
+ return event;
26
+ }
27
+
28
+ export function resolveIssueActivityMapping(input: { issueRefId?: string; issueRef?: string; defaultRepo?: string; provider?: IssueProvider }) {
29
+ return resolveIssueActivityRef(input);
30
+ }
@@ -0,0 +1,72 @@
1
+ import {
2
+ associateIssue,
3
+ listEntitiesForIssue,
4
+ listIssuesForEntity,
5
+ removeAssociation,
6
+ } from "../db/issue-associations";
7
+ import { getIssueRef, resolveIssueRefByExternalKey, resolveIssueRefFromString } from "../db/issue-refs";
8
+ import type { IssueAssociationEntityType, IssueAssociationIssue, IssueProvider } from "../types";
9
+ import { ValidationError } from "../db/connection";
10
+
11
+ export interface ResolveAssociationIssueInput {
12
+ issueRefId?: string;
13
+ ref?: string;
14
+ defaultRepo?: string;
15
+ provider?: IssueProvider;
16
+ repo?: string;
17
+ externalKey?: string;
18
+ url?: string;
19
+ title?: string;
20
+ }
21
+
22
+ export interface AssociateIssueInput extends ResolveAssociationIssueInput {
23
+ entityType: IssueAssociationEntityType;
24
+ entityId: string;
25
+ }
26
+
27
+ export function resolveAssociationIssueRef(input: ResolveAssociationIssueInput) {
28
+ if (input.issueRefId) {
29
+ const issueRef = getIssueRef(input.issueRefId);
30
+ if (!issueRef) throw new ValidationError(`issue ref ${input.issueRefId} not found`);
31
+ return issueRef;
32
+ }
33
+ if (input.ref) {
34
+ return resolveIssueRefFromString({
35
+ ref: input.ref,
36
+ defaultRepo: input.defaultRepo ?? input.repo,
37
+ provider: input.provider,
38
+ title: input.title,
39
+ });
40
+ }
41
+ if (input.provider && input.repo && input.externalKey) {
42
+ return resolveIssueRefByExternalKey({
43
+ provider: input.provider,
44
+ repo: input.repo,
45
+ externalKey: input.externalKey,
46
+ url: input.url,
47
+ title: input.title,
48
+ });
49
+ }
50
+ throw new ValidationError("issueRefId, ref, or provider/repo/externalKey required");
51
+ }
52
+
53
+ export function associateIssueRef(input: AssociateIssueInput) {
54
+ const issueRef = resolveAssociationIssueRef(input);
55
+ return {
56
+ association: associateIssue(issueRef.id, input.entityType, input.entityId),
57
+ issueRef,
58
+ };
59
+ }
60
+
61
+ export function listIssueRefsForEntity(entityType: IssueAssociationEntityType, entityId: string): IssueAssociationIssue[] {
62
+ return listIssuesForEntity(entityType, entityId);
63
+ }
64
+
65
+ export function listIssueEntities(issueRefId: string) {
66
+ return listEntitiesForIssue(issueRefId);
67
+ }
68
+
69
+ export function removeIssueAssociation(input: AssociateIssueInput): { deleted: boolean } {
70
+ const issueRef = resolveAssociationIssueRef(input);
71
+ return { deleted: removeAssociation(issueRef.id, input.entityType, input.entityId) };
72
+ }