@desplega.ai/agent-swarm 2.5.1 → 2.5.2

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 (155) hide show
  1. package/dist/{acp-adapter-7g6bpwbk.js → acp-adapter-csh0zz3b.js} +1 -1
  2. package/dist/{actions-w8xd014k.js → actions-6xx079d1.js} +17 -17
  3. package/dist/{amp-adapter-z4qvhacj.js → amp-adapter-8n6f0hz8.js} +2 -2
  4. package/dist/{app-nndcvftq.js → app-ft6r6v23.js} +3 -3
  5. package/dist/{assistant-hjmqyscx.js → assistant-vt2cyfzd.js} +18 -18
  6. package/dist/{boot-reembed-r8rhf9h8.js → boot-reembed-r3e808ky.js} +1 -1
  7. package/dist/{boot-reembed-vwcj2y5d.js → boot-reembed-tgrfy27t.js} +1 -1
  8. package/dist/{boot-scrub-sweep-fr9q36ne.js → boot-scrub-sweep-masdwxkm.js} +2 -2
  9. package/dist/{boot-seeds-9jqwnegq.js → boot-seeds-0n82qc7q.js} +4 -4
  10. package/dist/{claude-adapter-2f8b85df.js → claude-adapter-s7j47k06.js} +2 -2
  11. package/dist/{claude-managed-setup-c3hp36ph.js → claude-managed-setup-3hzrgrnk.js} +1 -1
  12. package/dist/{claude-sdk-session-05kat8rv.js → claude-sdk-session-0zrdzyx2.js} +6905 -6855
  13. package/dist/{cli-mz2ygeyq.js → cli-0vgy5wfw.js} +2 -2
  14. package/dist/{cli-eym3jg7w.js → cli-14v1sxxy.js} +6 -6
  15. package/dist/{cli-e9btqw4y.js → cli-15xd0j7w.js} +18 -13
  16. package/dist/{cli-v2g93k4v.js → cli-3e9vs93g.js} +4 -4
  17. package/dist/{cli-1dp1zj04.js → cli-4qkg9968.js} +3 -3
  18. package/dist/{cli-ba8ne1es.js → cli-4rvr7zfx.js} +10 -0
  19. package/dist/{cli-rngde2jv.js → cli-6y43v79h.js} +1 -1
  20. package/dist/{cli-z63a269p.js → cli-7ccf7akh.js} +1 -1
  21. package/dist/{cli-z2dv25mf.js → cli-86zpfje5.js} +116 -5
  22. package/dist/{cli-fes51ya2.js → cli-aaqnqpyw.js} +2 -2
  23. package/dist/{cli-qfm6my86.js → cli-b1tv22wr.js} +1 -1
  24. package/dist/{cli-xc65x52q.js → cli-b63d08rs.js} +1 -1
  25. package/dist/{cli-gwcf52gz.js → cli-b6tnjdvx.js} +11 -7
  26. package/dist/{cli-9mm5ade4.js → cli-bpbcpd8b.js} +1 -1
  27. package/dist/{cli-qfjm4gvt.js → cli-cf63dnxj.js} +2 -2
  28. package/dist/{cli-q9pzwxvk.js → cli-csh8fnbg.js} +82 -16
  29. package/dist/{cli-0jy85zz4.js → cli-cyfgxnvb.js} +7 -7
  30. package/dist/{cli-y3zyaee5.js → cli-d172svcw.js} +5 -5
  31. package/dist/{cli-mq7kg1b1.js → cli-e91vshb1.js} +41 -6
  32. package/dist/{cli-cah5detr.js → cli-edtm741g.js} +5 -5
  33. package/dist/{cli-ajqhxtnz.js → cli-f3pfn4x3.js} +1 -1
  34. package/dist/{cli-hadv0725.js → cli-f55b6ky0.js} +1 -1
  35. package/dist/{cli-r9fy0ddc.js → cli-fgn7yg5r.js} +1 -1
  36. package/dist/{cli-kcsd1ez8.js → cli-h5d22pfs.js} +249 -68
  37. package/dist/{cli-2wba1ac9.js → cli-ha22h0eg.js} +20 -20
  38. package/dist/{cli-pyy4c9ch.js → cli-j3dwd4kz.js} +173 -134
  39. package/dist/{cli-j1eghqfm.js → cli-jmcjbjkt.js} +1 -1
  40. package/dist/{cli-22rmcjkg.js → cli-kcnwn85g.js} +4 -3
  41. package/dist/{cli-rh5smt8t.js → cli-mntxtkhn.js} +12 -2
  42. package/dist/{cli-2jg61wab.js → cli-n2918sed.js} +9 -9
  43. package/dist/{cli-3ee5z0q0.js → cli-qrjv7t18.js} +1 -1
  44. package/dist/{cli-sn1vbtg2.js → cli-vaxw3bg0.js} +3 -3
  45. package/dist/{cli-cwyafhw1.js → cli-wadzapwb.js} +1 -1
  46. package/dist/{cli-jpxsrxcb.js → cli-xw9q173h.js} +1 -1
  47. package/dist/{cli-znvq0k3t.js → cli-xzge46sj.js} +2 -2
  48. package/dist/{cli-nj12tn2c.js → cli-y3dzwyy3.js} +3 -3
  49. package/dist/{cli-xd4eqtr2.js → cli-ydg6ep51.js} +1 -1
  50. package/dist/cli.js +16 -15
  51. package/dist/{codex-adapter-p4ptwar5.js → codex-adapter-brh2tcxr.js} +1 -1
  52. package/dist/{codex-session-runner-964c7dnk.js → codex-session-runner-0dnj21qs.js} +1 -1
  53. package/dist/{commands-mbvhbrcy.js → commands-1cnvseqx.js} +2 -2
  54. package/dist/{controller-gnq1k1mf.js → controller-kc7s9xhn.js} +2 -2
  55. package/dist/{controllers-connect-6dkm71tg.js → controllers-connect-jzx7t4bn.js} +18 -18
  56. package/dist/{cursor-adapter-yq5xgfcg.js → cursor-adapter-qpwp5sfg.js} +1 -1
  57. package/dist/{db-af9y8nkf.js → db-4vq88jkc.js} +3 -3
  58. package/dist/{db-88wcymxt.js → db-v5ytg5pw.js} +1 -1
  59. package/dist/{dispatcher-606ydx5v.js → dispatcher-trtcrqae.js} +16 -16
  60. package/dist/{dsh-adapter-tvhc4k2d.js → dsh-adapter-f20xwn1z.js} +1 -1
  61. package/dist/{external-channel-y8q5z8z2.js → external-channel-n9bymc2n.js} +2 -2
  62. package/dist/{grok-adapter-39aha52t.js → grok-adapter-4kpmmnr6.js} +2 -2
  63. package/dist/{handlers-rk9dqech.js → handlers-ptsraqa8.js} +19 -19
  64. package/dist/{heartbeat-jfe9zfjr.js → heartbeat-60qtxtjf.js} +19 -19
  65. package/dist/{hook-ffevzed1.js → hook-ngtvh729.js} +1 -1
  66. package/dist/{hook-b1mez1m8.js → hook-rbktcstg.js} +1 -1
  67. package/dist/{http-d5b44f1r.js → http-z8xy72gp.js} +334 -86
  68. package/dist/{index-nq8hhv28.js → index-06qkmcx6.js} +10 -9
  69. package/dist/{index-d2qeem97.js → index-1cscynkb.js} +16 -16
  70. package/dist/{index-5en23s5z.js → index-a9nsavhx.js} +1 -1
  71. package/dist/{index-4q3vj1yr.js → index-r70q0xjx.js} +16 -16
  72. package/dist/{index-mj46rk9k.js → index-s193j5fh.js} +19 -19
  73. package/dist/{keepalive-wsby1btq.js → keepalive-a9srcphj.js} +3 -3
  74. package/dist/{lead-paa141j6.js → lead-3devneqy.js} +26 -26
  75. package/dist/{maintenance-vnshcyv9.js → maintenance-0er16h2y.js} +1 -1
  76. package/dist/{oauth-refresh-sweep-d6sqn5np.js → oauth-refresh-sweep-b65jcyw2.js} +2 -2
  77. package/dist/{onboard-nr4xhpzy.js → onboard-wqywz5py.js} +2 -2
  78. package/dist/{otel-impl-ytn9wyv9.js → otel-impl-vk32s884.js} +1 -1
  79. package/dist/{pricing-refresh-pva04zee.js → pricing-refresh-t27anv4z.js} +3 -3
  80. package/dist/rbac-roles-452665y0.js +26 -0
  81. package/dist/{rbac-roles-7rc1q3g4.js → rbac-roles-bmcgsxjn.js} +12 -2
  82. package/dist/{render-v2-pyfsg9zk.js → render-v2-0ttsk2j4.js} +3 -3
  83. package/dist/{resume-phj8xhqr.js → resume-a26kvr41.js} +5 -5
  84. package/dist/{session-access-jzx2z9h5.js → session-access-dgh9nkwk.js} +4 -4
  85. package/dist/{setup-rfg0f0sg.js → setup-9vc9qahm.js} +1 -1
  86. package/dist/{work-objects-44z2yjg8.js → work-objects-vn8nkxwn.js} +19 -19
  87. package/dist/{worker-zsfqzxhm.js → worker-1x1h8vnm.js} +26 -26
  88. package/openapi.json +463 -3
  89. package/package.json +4 -3
  90. package/src/be/db/session-tree.ts +89 -2
  91. package/src/be/db/session-visibility.ts +31 -0
  92. package/src/be/db/tasks/read.ts +18 -5
  93. package/src/be/db.ts +2 -0
  94. package/src/be/migrations/233_script_run_session_tokens.sql +8 -0
  95. package/src/be/migrations/235_chat_relay_defer.sql +86 -0
  96. package/src/be/migrations/237_session_inbox_cleanup.sql +75 -0
  97. package/src/be/rbac-roles.ts +138 -0
  98. package/src/be/scripts/run-credential.ts +64 -0
  99. package/src/be/scripts/run-saved.ts +3 -2
  100. package/src/be/users.ts +28 -4
  101. package/src/http/all-routes.ts +1 -0
  102. package/src/http/approval-requests.ts +46 -6
  103. package/src/http/index.ts +2 -0
  104. package/src/http/schedules.ts +26 -0
  105. package/src/http/scripts.ts +2 -2
  106. package/src/http/sessions.ts +93 -2
  107. package/src/http/user-roles.ts +173 -0
  108. package/src/http/users.ts +9 -0
  109. package/src/http/x.ts +2 -2
  110. package/src/integrations/chat-sdk/commands.ts +14 -1
  111. package/src/integrations/chat-sdk/inbound.ts +40 -5
  112. package/src/integrations/chat-sdk/outbound.ts +35 -10
  113. package/src/integrations/chat-sdk/reactions.ts +107 -0
  114. package/src/integrations/chat-sdk/relay.ts +75 -7
  115. package/src/integrations/chat-sdk/thread-buffer.ts +2 -0
  116. package/src/integrations/reaction-events.ts +20 -0
  117. package/src/providers/amp-adapter.ts +1 -1
  118. package/src/rbac/legacy-policy.ts +3 -0
  119. package/src/rbac/permissions.ts +8 -0
  120. package/src/scheduler/schedule-task.ts +44 -3
  121. package/src/scheduler/scheduler.ts +4 -4
  122. package/src/scripts-runtime/loader.ts +8 -1
  123. package/src/slack/ack.ts +2 -1
  124. package/src/slack/reaction-shortcode.ts +4 -7
  125. package/src/tasks/worker-follow-up.ts +30 -0
  126. package/src/tests/approval-requests-notifications.test.ts +189 -0
  127. package/src/tests/chat-sdk-commands.test.ts +12 -0
  128. package/src/tests/chat-sdk-inbound.test.ts +163 -9
  129. package/src/tests/chat-sdk-outbound-files.test.ts +9 -2
  130. package/src/tests/chat-sdk-relay.test.ts +361 -10
  131. package/src/tests/chat-sdk-tools.test.ts +104 -0
  132. package/src/tests/docs-index-footprint.test.js +3 -3
  133. package/src/tests/http-users.test.ts +38 -0
  134. package/src/tests/migration-237-session-inbox-cleanup.test.ts +136 -0
  135. package/src/tests/rbac-engine.test.ts +2 -0
  136. package/src/tests/rbac-roles.test.ts +10 -0
  137. package/src/tests/schedule-http-triage-tools.test.ts +44 -0
  138. package/src/tests/script-api-credential.test.ts +173 -0
  139. package/src/tests/script-connections.test.ts +4 -0
  140. package/src/tests/script-run-config-scope.test.ts +120 -0
  141. package/src/tests/script-run-token.test.ts +75 -0
  142. package/src/tests/scripts-http.test.ts +19 -0
  143. package/src/tests/sessions-tree.test.ts +242 -0
  144. package/src/tests/user-roles-http.test.ts +204 -0
  145. package/src/tests/workflow-hitl-dynamic-questions.test.ts +1 -1
  146. package/src/tools/chat-reply.ts +2 -0
  147. package/src/tools/schedules/patch-schedule.ts +18 -0
  148. package/src/tools/schedules/update-schedule.ts +18 -0
  149. package/src/types.ts +16 -0
  150. package/src/workflows/approval-notifications.ts +214 -0
  151. package/src/workflows/executors/human-in-the-loop.ts +11 -172
  152. package/src/workflows/executors/swarm-script.ts +2 -2
  153. package/templates/schedules/weekly-harness-upgrade-check/content.md +1 -1
  154. package/templates/skills/code-quality/content.md +1 -1
  155. package/dist/rbac-roles-ff1ffmec.js +0 -12
@@ -491,3 +491,245 @@ test("limit clamps to 200 and count without chain filters does not recurse", asy
491
491
  expect(body.sessions).toHaveLength(200);
492
492
  expect(body.total).toBe(205);
493
493
  });
494
+
495
+ describe("Mine view hides machine-started roots", () => {
496
+ test("a workflow root attributed to the viewer is hidden by default and shown under All", async () => {
497
+ // Positive control: pr-review-demo fan-out carries the trigger user as requester.
498
+ const demo = await task("shared/", { source: "workflow", tags: ["pr-review-demo"] });
499
+ const scheduled = await task("shared/", { source: "schedule" });
500
+ const heartbeat = await task("shared/", { source: "system" });
501
+ // Negative controls: the viewer's own human sessions stay.
502
+ const slack = await task("shared/", { source: "slack" });
503
+ const ui = await task("shared/", { source: "ui" });
504
+ const whatsapp = await task("shared/", { source: "system", taskType: "kapso-inbound" });
505
+ // A workflow root someone else shared with the viewer stays too.
506
+ const sharedRun = await task("shared/", { source: "workflow", requestedByUserId: other.id });
507
+ await share(sharedRun);
508
+ // Ownerless roots stay out of both views, as before.
509
+ await task("shared/", { source: "workflow", requestedByUserId: undefined });
510
+
511
+ const headers = { "x-swarm-user-id": owner.id };
512
+ const list = async (query: string) => {
513
+ const res = await fetch(`${baseUrl}/api/sessions?scope=tree&limit=50&${query}`, { headers });
514
+ const body = (await res.json()) as { total: number; sessions: { root: { id: string } }[] };
515
+ expect(body.sessions).toHaveLength(body.total);
516
+ return body.sessions.map((s) => s.root.id).sort();
517
+ };
518
+ const tree = async (query: string) => {
519
+ const res = await fetch(`${baseUrl}/api/session-tree?${query}`, { headers });
520
+ return ((await res.json()) as Awaited<ReturnType<typeof getSessionTree>>).noGroup.total;
521
+ };
522
+
523
+ const mine = [slack.id, ui.id, whatsapp.id, sharedRun.id].sort();
524
+ const all = [...mine, demo.id, scheduled.id, heartbeat.id].sort();
525
+ expect(await list("")).toEqual(mine);
526
+ expect(await list("show=mine")).toEqual(mine);
527
+ expect(await list("show=all")).toEqual(all);
528
+ expect(await tree("")).toBe(mine.length);
529
+ expect(await tree("show=all")).toBe(all.length);
530
+
531
+ // No picked user: the bypass actor still sees every root, ownerless included.
532
+ const bypass = await fetch(`${baseUrl}/api/sessions?scope=tree&limit=50`);
533
+ expect(((await bypass.json()) as { total: number }).total).toBe(all.length + 1);
534
+ // Access is unchanged: the hidden session still opens by direct link.
535
+ const detail = await fetch(`${baseUrl}/api/sessions/${demo.id}`, { headers });
536
+ expect(detail.status).toBe(200);
537
+ });
538
+ });
539
+
540
+ describe("source filter and list triage", () => {
541
+ const asOwner = () => ({ "x-swarm-user-id": owner.id, "content-type": "application/json" });
542
+ const treeOf = async (query = "") => {
543
+ const res = await fetch(`${baseUrl}/api/session-tree?${query}`, { headers: asOwner() });
544
+ expect(res.status).toBe(200);
545
+ return (await res.json()) as Awaited<ReturnType<typeof getSessionTree>>;
546
+ };
547
+ const listOf = async (query: string) => {
548
+ const res = await fetch(`${baseUrl}/api/sessions?scope=tree&limit=50&${query}`, {
549
+ headers: asOwner(),
550
+ });
551
+ const body = (await res.json()) as {
552
+ total: number;
553
+ sessions: { root: { id: string }; readState?: string }[];
554
+ };
555
+ return body;
556
+ };
557
+ const triage = (body: Record<string, unknown>, withUser = true) =>
558
+ fetch(`${baseUrl}/api/session-triage`, {
559
+ method: "POST",
560
+ headers: withUser ? asOwner() : { "content-type": "application/json" },
561
+ body: JSON.stringify(body),
562
+ });
563
+
564
+ test("hideSource leaves a source out of sections and lists, and sources still count it", async () => {
565
+ const slackA = await task("shared/", { source: "slack" });
566
+ const slackB = await task("shared/a/", { source: "slack" });
567
+ const ui = await task("shared/", { source: "ui" });
568
+ const chat = await task("shared/", { source: "chat" });
569
+
570
+ const full = await treeOf();
571
+ expect(full.inbox.total).toBe(4);
572
+ expect(full.sources).toEqual([
573
+ { source: "slack", total: 2 },
574
+ { source: "chat", total: 1 },
575
+ { source: "ui", total: 1 },
576
+ ]);
577
+
578
+ const hidden = await treeOf("hideSource=slack,chat");
579
+ expect(hidden.inbox.total).toBe(1);
580
+ expect(hidden.noGroup.total).toBe(1);
581
+ expect(hidden.groups).toEqual([]);
582
+ // The menu still offers the hidden sources back.
583
+ expect(hidden.sources).toEqual(full.sources);
584
+
585
+ const inbox = await listOf("triage=inbox&hideSource=slack,chat");
586
+ expect(inbox.total).toBe(1);
587
+ expect(inbox.sessions.map((s) => s.root.id)).toEqual([ui.id]);
588
+ const all = await listOf("triage=inbox");
589
+ expect(all.sessions.map((s) => s.root.id).sort()).toEqual(
590
+ [slackA.id, slackB.id, ui.id, chat.id].sort(),
591
+ );
592
+ });
593
+
594
+ test("triage by list writes every matching session, honoring the list and the filters", async () => {
595
+ const a = await task("shared/", { source: "ui" });
596
+ const b = await task("shared/", { source: "ui" }, OLD);
597
+ const slack = await task("shared/", { source: "slack" });
598
+ const grouped = await task("shared/a/", { source: "ui" });
599
+ const workflow = await task("shared/", { source: "workflow" });
600
+ const later = await task("shared/", { source: "ui" });
601
+ await state(later.id, "snoozed", NOW, { snoozeUntil: "2999-01-01T00:00:00.000Z" });
602
+
603
+ // No group, Slack hidden, Mine view: a and b only.
604
+ let res = await triage({ action: "done", noGroup: true, hideSource: ["slack"] });
605
+ expect(res.status).toBe(200);
606
+ expect(await res.json()).toEqual({ updated: 2 });
607
+ let done = await listOf("triage=done");
608
+ expect(done.sessions.map((s) => s.root.id).sort()).toEqual([a.id, b.id].sort());
609
+ // The rest keep their state; the workflow root (All only) was not touched.
610
+ const inbox = await listOf("triage=inbox&show=all");
611
+ expect(inbox.sessions.map((s) => s.root.id).sort()).toEqual(
612
+ [slack.id, grouped.id, workflow.id].sort(),
613
+ );
614
+ expect((await listOf("triage=later")).sessions.map((s) => s.root.id)).toEqual([later.id]);
615
+
616
+ // Back to the Inbox from the Done list, then read and unread across the Inbox.
617
+ res = await triage({ action: "inbox", triage: "done" });
618
+ expect(await res.json()).toEqual({ updated: 2 });
619
+ expect((await listOf("triage=done")).total).toBe(0);
620
+ res = await triage({ action: "unread", triage: "inbox" });
621
+ expect(await res.json()).toEqual({ updated: 4 });
622
+ expect((await treeOf()).inbox.unread).toBe(4);
623
+ res = await triage({ action: "read", triage: "inbox" });
624
+ expect(await res.json()).toEqual({ updated: 4 });
625
+ const tree = await treeOf();
626
+ expect(tree.inbox.unread).toBe(0);
627
+ expect(tree.inbox.total).toBe(4);
628
+
629
+ // One group only.
630
+ res = await triage({ action: "done", groupKey: "shared/a/" });
631
+ expect(await res.json()).toEqual({ updated: 1 });
632
+ done = await listOf("triage=done");
633
+ expect(done.sessions.map((s) => s.root.id)).toEqual([grouped.id]);
634
+ // Show All reaches the workflow root.
635
+ res = await triage({ action: "done", triage: "inbox", show: "all" });
636
+ expect(await res.json()).toEqual({ updated: 4 });
637
+ expect((await listOf("triage=inbox&show=all")).total).toBe(0);
638
+ });
639
+
640
+ test("triage by list acts on the Later list", async () => {
641
+ const snoozedA = await task("shared/", { source: "ui" });
642
+ const snoozedB = await task("shared/", { source: "ui" });
643
+ const open = await task("shared/", { source: "ui" });
644
+ for (const id of [snoozedA.id, snoozedB.id]) {
645
+ await state(id, "snoozed", NOW, { snoozeUntil: "2999-01-01T00:00:00.000Z" });
646
+ }
647
+
648
+ let res = await triage({ action: "done", triage: "later" });
649
+ expect(await res.json()).toEqual({ updated: 2 });
650
+ expect((await listOf("triage=later")).total).toBe(0);
651
+ const done = await listOf("triage=done");
652
+ expect(done.sessions.map((s) => s.root.id).sort()).toEqual([snoozedA.id, snoozedB.id].sort());
653
+ const cleared = await getDbClient().query<{ snoozeUntil: string | null }>(
654
+ "SELECT snoozeUntil FROM inbox_item_state WHERE itemId IN (?, ?)",
655
+ [snoozedA.id, snoozedB.id],
656
+ );
657
+ expect(cleared.map((row) => row.snoozeUntil)).toEqual([null, null]);
658
+
659
+ await state(snoozedA.id, "snoozed", NOW, { snoozeUntil: "2999-01-01T00:00:00.000Z" });
660
+ res = await triage({ action: "inbox", triage: "later" });
661
+ expect(await res.json()).toEqual({ updated: 1 });
662
+ const inbox = await listOf("triage=inbox");
663
+ expect(inbox.sessions.map((s) => s.root.id).sort()).toEqual([snoozedA.id, open.id].sort());
664
+ });
665
+
666
+ test("triage by list writes the same row fields as the one-session PATCH", async () => {
667
+ type Row = {
668
+ status: string;
669
+ snoozeUntil: string | null;
670
+ dismissedAt: string | null;
671
+ doneAt: string | null;
672
+ readAt: string | null;
673
+ lastReadAt: string | null;
674
+ };
675
+ const rowOf = async (id: string) =>
676
+ (await getDbClient().get<Row>("SELECT * FROM inbox_item_state WHERE itemId = ?", [
677
+ id,
678
+ ])) as Row;
679
+ const unreadCursor = "1970-01-01T00:00:00.000Z";
680
+ for (const action of ["done", "inbox", "read", "unread"] as const) {
681
+ // Two sessions with the same earlier state: read once, then snoozed past
682
+ // the wake time (back in the Inbox). One gets the dashboard's PATCH
683
+ // write, the other the list write.
684
+ const one = await task(`shared/${action}-one/`, { source: "ui" });
685
+ const list = await task(`shared/${action}-list/`, { source: "ui" });
686
+ for (const id of [one.id, list.id]) {
687
+ await state(id, "snoozed", NOW, { snoozeUntil: OLD, lastReadAt: OLD });
688
+ await getDbClient().run("UPDATE inbox_item_state SET readAt = ? WHERE itemId = ?", [
689
+ OLD,
690
+ id,
691
+ ]);
692
+ }
693
+ const listed = (await fetch(
694
+ `${baseUrl}/api/sessions?scope=tree&groupKey=shared/${action}-one/`,
695
+ {
696
+ headers: asOwner(),
697
+ },
698
+ ).then((res) => res.json())) as { sessions: { lastActivityAt: string }[] };
699
+ const lastActivityAt = listed.sessions[0]?.lastActivityAt;
700
+ expect(lastActivityAt).toBeTruthy();
701
+ // The write of `writeFor` in apps/ui/src/api/hooks/use-session-triage.ts.
702
+ const write =
703
+ action === "done"
704
+ ? { status: "done" as const }
705
+ : action === "read"
706
+ ? { status: "open" as const, readAt: NOW, lastReadAt: lastActivityAt }
707
+ : action === "unread"
708
+ ? { status: "open" as const, lastReadAt: unreadCursor }
709
+ : { status: "open" as const };
710
+ await upsertInboxState({ userId: owner.id, itemType: "to_read", itemId: one.id, ...write });
711
+ const res = await triage({ action, groupKey: `shared/${action}-list/` });
712
+ expect(await res.json()).toEqual({ updated: 1 });
713
+
714
+ const [a, b] = [await rowOf(one.id), await rowOf(list.id)];
715
+ expect({ ...b, doneAt: b.doneAt !== null }).toMatchObject({
716
+ status: a.status,
717
+ snoozeUntil: a.snoozeUntil,
718
+ dismissedAt: a.dismissedAt,
719
+ doneAt: a.doneAt !== null,
720
+ readAt: a.readAt,
721
+ lastReadAt: a.lastReadAt,
722
+ });
723
+ // readAt is sticky: the first view stays.
724
+ expect(b.readAt).toBe(OLD);
725
+ }
726
+ });
727
+
728
+ test("triage by list needs a picked user and exactly one list", async () => {
729
+ await task("shared/", { source: "ui" });
730
+ expect((await triage({ action: "done", triage: "inbox" }, false)).status).toBe(400);
731
+ expect((await triage({ action: "done" })).status).toBe(400);
732
+ expect((await triage({ action: "done", triage: "inbox", noGroup: true })).status).toBe(400);
733
+ expect((await listOf("triage=inbox")).total).toBe(1);
734
+ });
735
+ });
@@ -0,0 +1,204 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
3
+ import { closeDb, createAgent, createUser, getDbClient, initDb } from "../be/db";
4
+ import { ensureRbacSeedsSynced, listUserRoles, setUserRoles } from "../be/rbac-roles";
5
+ import { type IdentityActor, listUserEvents, mintToken } from "../be/users";
6
+ import { handleApprovalRequests } from "../http/approval-requests";
7
+ import { handleCore } from "../http/core";
8
+ import { handleTasks } from "../http/tasks";
9
+ import { handleUserRoles } from "../http/user-roles";
10
+ import { getPathSegments, parseQueryParams } from "../http/utils";
11
+ import { listenOnFreePort } from "./test-net";
12
+
13
+ const key = `user-roles-test-${crypto.randomUUID()}`;
14
+ const ACTOR: IdentityActor = { kind: "operator", id: "user-roles-test" };
15
+ let server: Server;
16
+ let baseUrl: string;
17
+ let leadId: string;
18
+
19
+ async function request(
20
+ path: string,
21
+ bearer: string,
22
+ options: { method?: string; body?: unknown; headers?: Record<string, string> } = {},
23
+ ) {
24
+ const res = await fetch(`${baseUrl}${path}`, {
25
+ method: options.method ?? (options.body === undefined ? "GET" : "POST"),
26
+ headers: {
27
+ Authorization: `Bearer ${bearer}`,
28
+ "Content-Type": "application/json",
29
+ ...options.headers,
30
+ },
31
+ body: options.body === undefined ? undefined : JSON.stringify(options.body),
32
+ });
33
+ const text = await res.text();
34
+ let body: any = text;
35
+ try {
36
+ body = JSON.parse(text);
37
+ } catch {}
38
+ return { status: res.status, text, body };
39
+ }
40
+
41
+ const putRoles = (userId: string, bearer: string, roles: string[], headers = {}) =>
42
+ request(`/api/users/${userId}/roles`, bearer, { method: "PUT", body: { roles }, headers });
43
+
44
+ async function newUser(name: string, roles?: string[]) {
45
+ const user = await createUser({ name });
46
+ if (roles) await setUserRoles(user.id, roles, null);
47
+ const token = (await mintToken(user.id, name, ACTOR)).plaintext;
48
+ return { id: user.id, token };
49
+ }
50
+
51
+ async function dispatch(req: IncomingMessage, res: ServerResponse) {
52
+ const id = req.headers["x-agent-id"] as string | undefined;
53
+ if (await handleCore(req, res, id, key)) return;
54
+ const segments = getPathSegments(req.url ?? "");
55
+ const query = parseQueryParams(req.url ?? "");
56
+ if (await handleUserRoles(req, res, segments, query)) return;
57
+ if (await handleApprovalRequests(req, res, segments, query)) return;
58
+ if (await handleTasks(req, res, segments, query, id)) return;
59
+ res.writeHead(404).end();
60
+ }
61
+
62
+ beforeAll(async () => {
63
+ initDb(":memory:");
64
+ ensureRbacSeedsSynced({ quiet: true });
65
+ leadId = (await createAgent({ name: "roles-lead", isLead: true, status: "idle" })).id;
66
+ server = createServer((req, res) => {
67
+ void dispatch(req, res).catch(() => res.writeHead(500).end());
68
+ });
69
+ baseUrl = `http://127.0.0.1:${await listenOnFreePort(server)}`;
70
+ });
71
+
72
+ afterAll(async () => {
73
+ await new Promise<void>((resolve) => server.close(() => resolve()));
74
+ closeDb();
75
+ });
76
+
77
+ describe("PUT /api/users/{id}/roles", () => {
78
+ test("a role change changes what the user may do", async () => {
79
+ await newUser("keeper-admin");
80
+ const alice = await newUser("alice");
81
+ const task = { task: "do a thing" };
82
+ expect((await request("/api/tasks", alice.token, { body: task })).status).not.toBe(403);
83
+
84
+ const set = await putRoles(alice.id, key, ["viewer"]);
85
+ expect(set.status).toBe(200);
86
+ expect(set.body.roles.map((r: { name: string }) => r.name)).toEqual(["viewer"]);
87
+ expect((await request("/api/tasks", alice.token, { body: task })).status).toBe(403);
88
+ expect((await request("/api/tasks", alice.token)).status).not.toBe(403);
89
+
90
+ expect((await putRoles(alice.id, key, ["admin"])).status).toBe(200);
91
+ expect((await request("/api/tasks", alice.token, { body: task })).status).not.toBe(403);
92
+ });
93
+
94
+ test("a demoted user can no longer answer approvals", async () => {
95
+ const bob = await newUser("bob", ["requester"]);
96
+ const res = await request(`/api/approval-requests/${crypto.randomUUID()}/respond`, bob.token, {
97
+ body: { responses: {} },
98
+ });
99
+ expect(res.status).toBe(403);
100
+ expect(res.text).toContain("approval.respond");
101
+ });
102
+
103
+ test("only an operator or a grant-all user may change roles", async () => {
104
+ const target = await newUser("target");
105
+ const requester = await newUser("requester-user", ["requester"]);
106
+ const viewer = await newUser("viewer-user", ["viewer"]);
107
+ const admin = await newUser("admin-user");
108
+ expect((await putRoles(target.id, requester.token, ["viewer"])).status).toBe(403);
109
+ expect((await putRoles(target.id, viewer.token, ["viewer"])).status).toBe(403);
110
+ // The shared key acting as an agent is not the operator.
111
+ expect((await putRoles(target.id, key, ["viewer"], { "X-Agent-ID": leadId })).status).toBe(403);
112
+ expect((await putRoles(target.id, admin.token, ["requester"])).status).toBe(200);
113
+ });
114
+
115
+ test("a custom role holding user.role.manage cannot grant admin", async () => {
116
+ const target = await newUser("grant-target");
117
+ const manager = await newUser("manager", ["viewer"]);
118
+ const client = getDbClient();
119
+ await client.run(
120
+ "INSERT INTO roles (id, name, description, isBuiltin, grantsAll) VALUES ('role-rm', 'role-manager', 'x', 0, 0)",
121
+ );
122
+ await client.run(
123
+ "INSERT INTO role_permissions (roleId, verb) VALUES ('role-rm', 'user.role.manage')",
124
+ );
125
+ await client.run(
126
+ "INSERT INTO principal_roles (principalType, principalId, roleId) VALUES ('user', ?, 'role-rm')",
127
+ [manager.id],
128
+ );
129
+ expect((await putRoles(target.id, manager.token, ["admin"])).status).toBe(403);
130
+ });
131
+
132
+ test("last-admin guard", async () => {
133
+ const client = getDbClient();
134
+ await client.run("UPDATE users SET status = 'suspended'");
135
+ const sole = await newUser("sole-admin");
136
+ expect((await putRoles(sole.id, key, ["viewer"])).status).toBe(409);
137
+ expect((await listUserRoles(sole.id)).map((r) => r.name)).toEqual(["admin"]);
138
+
139
+ const second = await newUser("second-admin");
140
+ expect((await putRoles(sole.id, key, ["viewer"])).status).toBe(200);
141
+ // second is now the sole active admin; a suspended admin may still be demoted.
142
+ await client.run("UPDATE users SET status = 'suspended' WHERE id = ?", [second.id]);
143
+ const third = await newUser("third");
144
+ await client.run("UPDATE users SET status = 'suspended' WHERE id = ?", [third.id]);
145
+ expect((await putRoles(third.id, key, ["requester"])).status).toBe(200);
146
+ await client.run("UPDATE users SET status = 'active'");
147
+ });
148
+
149
+ test("validation", async () => {
150
+ const user = await newUser("validated");
151
+ for (const roles of [[], ["nope"], ["admin", "viewer"]]) {
152
+ expect((await putRoles(user.id, key, roles)).status).toBe(400);
153
+ }
154
+ expect((await putRoles("missing-user", key, ["viewer"])).status).toBe(404);
155
+ expect((await listUserRoles(user.id)).map((r) => r.name)).toEqual(["admin"]);
156
+ });
157
+
158
+ test("agent rows in principal_roles are neither listed nor counted as admins", async () => {
159
+ const client = getDbClient();
160
+ await client.run("UPDATE users SET status = 'suspended'");
161
+ const sole = await newUser("sole-with-agent-twin");
162
+ await client.run(
163
+ `INSERT INTO principal_roles (principalType, principalId, roleId)
164
+ VALUES ('agent', 'agent-twin', 'rbac-role-admin')`,
165
+ );
166
+ const twin = await client.get<{ id: string }>("SELECT id FROM users WHERE id = ?", [sole.id]);
167
+ await client.run("UPDATE principal_roles SET principalId = ? WHERE principalType = 'agent'", [
168
+ twin!.id,
169
+ ]);
170
+ expect((await putRoles(sole.id, key, ["viewer"])).status).toBe(409);
171
+ const roles = await request(`/api/users/${sole.id}/roles`, key);
172
+ expect(roles.body.roles.map((r: { name: string }) => r.name)).toEqual(["admin"]);
173
+ await client.run("DELETE FROM principal_roles WHERE principalType = 'agent'");
174
+ await client.run("UPDATE users SET status = 'active'");
175
+ });
176
+
177
+ test("audit event and role catalog", async () => {
178
+ const user = await newUser("audited");
179
+ await putRoles(user.id, key, ["requester"]);
180
+ const events = await listUserEvents(user.id);
181
+ const change = events.find((e) => e.eventType === "profile_changed");
182
+ expect(JSON.stringify(change)).toContain("requester");
183
+ expect(JSON.stringify(change)).toContain("admin");
184
+
185
+ const catalog = await request("/api/rbac/roles", key);
186
+ const primary = catalog.body.roles.filter((r: { primary: boolean }) => r.primary);
187
+ expect(primary.map((r: { name: string }) => r.name).sort()).toEqual([
188
+ "admin",
189
+ "requester",
190
+ "viewer",
191
+ ]);
192
+ });
193
+
194
+ test("persists with RBAC disabled", async () => {
195
+ process.env.RBAC_ENABLED = "false";
196
+ try {
197
+ const user = await newUser("rbac-off");
198
+ expect((await putRoles(user.id, key, ["viewer"])).status).toBe(200);
199
+ expect((await listUserRoles(user.id)).map((r) => r.name)).toEqual(["viewer"]);
200
+ } finally {
201
+ delete process.env.RBAC_ENABLED;
202
+ }
203
+ });
204
+ });
@@ -11,6 +11,7 @@ import {
11
11
  initDb,
12
12
  } from "../be/db";
13
13
  import type { WorkflowDefinition } from "../types";
14
+ import { buildSlackQuestionsSummary } from "../workflows/approval-notifications";
14
15
  import { validateDefinition } from "../workflows/definition";
15
16
  import { interpolateNodeConfig, startWorkflowExecution } from "../workflows/engine";
16
17
  import { InProcessEventBus } from "../workflows/event-bus";
@@ -20,7 +21,6 @@ import {
20
21
  type ExecutorResult,
21
22
  } from "../workflows/executors/base";
22
23
  import {
23
- buildSlackQuestionsSummary,
24
24
  HumanInTheLoopExecutor,
25
25
  MAX_HITL_QUESTIONS,
26
26
  resolveHitlQuestions,
@@ -70,6 +70,8 @@ export const registerChatReplyTool = (server: McpServer) => {
70
70
  text,
71
71
  taskId: task.id,
72
72
  files: resolved.files,
73
+ // Same anchor as the final answer: the message that started this task.
74
+ replyToMessageId: task.mentionMessageId,
73
75
  });
74
76
  return toolOk(
75
77
  `Posted to ${thread.adapter} thread ${sent.threadId}.${unsentFilesSummary(sent.unsentFiles)}`,
@@ -15,6 +15,7 @@ import {
15
15
  import { explicitModelErrorForAgent } from "@/be/model-validation";
16
16
  import { mergeScheduleTiming, validateRecurringTiming } from "@/be/schedules/validate";
17
17
  import { getScript } from "@/be/scripts/db";
18
+ import { can } from "@/rbac";
18
19
  import { calculateNextRun } from "@/scheduler";
19
20
  import { createToolRegistrar, swarmToolOutputSchema, toolErr, toolOk } from "@/tools/utils";
20
21
  import {
@@ -174,6 +175,23 @@ export const registerPatchScheduleTool = (server: McpServer) => {
174
175
  return toolErr("Agent not found.");
175
176
  }
176
177
 
178
+ // A deferral continues its owner's task, in that task's chat thread when
179
+ // it has one (src/scheduler/schedule-task.ts). Only the owner or a lead
180
+ // may change it.
181
+ if (
182
+ schedule.taskType === "deferred" &&
183
+ !can({
184
+ principal: { kind: "agent", agentId: caller.id, isLead: caller.isLead },
185
+ verb: "schedule.deferral.update.any",
186
+ resource: { kind: "owned", ownerAgentId: schedule.createdByAgentId },
187
+ source: "mcp",
188
+ }).allow
189
+ ) {
190
+ return toolErr(
191
+ `Schedule "${schedule.name}" defers another agent's task. Only that agent or a lead can change it.`,
192
+ );
193
+ }
194
+
177
195
  // Reject updates on completed one-time schedules
178
196
  if (schedule.scheduleType === "one_time" && !schedule.enabled && schedule.lastRunAt) {
179
197
  return toolErr(
@@ -15,6 +15,7 @@ import {
15
15
  import { explicitModelErrorForAgent } from "@/be/model-validation";
16
16
  import { mergeScheduleTiming, validateRecurringTiming } from "@/be/schedules/validate";
17
17
  import { getScript } from "@/be/scripts/db";
18
+ import { can } from "@/rbac";
18
19
  import { calculateNextRun } from "@/scheduler";
19
20
  import { createToolRegistrar, swarmToolOutputSchema, toolErr, toolOk } from "@/tools/utils";
20
21
  import {
@@ -173,6 +174,23 @@ export const registerUpdateScheduleTool = (server: McpServer) => {
173
174
  return toolErr("Agent not found.");
174
175
  }
175
176
 
177
+ // A deferral continues its owner's task, in that task's chat thread when
178
+ // it has one (src/scheduler/schedule-task.ts). Only the owner or a lead
179
+ // may change it.
180
+ if (
181
+ schedule.taskType === "deferred" &&
182
+ !can({
183
+ principal: { kind: "agent", agentId: caller.id, isLead: caller.isLead },
184
+ verb: "schedule.deferral.update.any",
185
+ resource: { kind: "owned", ownerAgentId: schedule.createdByAgentId },
186
+ source: "mcp",
187
+ }).allow
188
+ ) {
189
+ return toolErr(
190
+ `Schedule "${schedule.name}" defers another agent's task. Only that agent or a lead can change it.`,
191
+ );
192
+ }
193
+
176
194
  // Reject updates on completed one-time schedules
177
195
  if (schedule.scheduleType === "one_time" && !schedule.enabled && schedule.lastRunAt) {
178
196
  return toolErr(
package/src/types.ts CHANGED
@@ -1076,6 +1076,22 @@ export const UserSchema = z
1076
1076
 
1077
1077
  export type User = z.infer<typeof UserSchema>;
1078
1078
 
1079
+ export const RbacRoleSchema = z
1080
+ .object({
1081
+ id: z.string(),
1082
+ name: z.string(),
1083
+ description: z.string().nullable(),
1084
+ isBuiltin: z.boolean(),
1085
+ grantsAll: z.boolean(),
1086
+ primary: z.boolean(),
1087
+ verbs: z.array(z.string()),
1088
+ })
1089
+ .openapi("RbacRole");
1090
+
1091
+ export const UserRbacRolesSchema = z
1092
+ .object({ userId: z.string(), roles: z.array(RbacRoleSchema) })
1093
+ .openapi("UserRbacRoles");
1094
+
1079
1095
  /**
1080
1096
  * Structured communication preferences read from `users.metadata.comms`.
1081
1097
  * All fields are free-form strings; values render verbatim into the requester