create-workframe 0.1.11 → 0.1.13

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 (195) hide show
  1. package/README.md +1 -1
  2. package/bin/create-workframe.js +2 -2
  3. package/bin/workframe.js +130 -2
  4. package/docs/security.md +85 -0
  5. package/package.json +1 -1
  6. package/scripts/bundle-workframe-ui.mjs +9 -0
  7. package/scripts/sync-canonical-to-package.mjs +9 -0
  8. package/scripts/verify-public-deploy.sh +121 -3
  9. package/workframe-api/action_proxy.py +23 -17
  10. package/workframe-api/activity_feed.py +798 -0
  11. package/workframe-api/api_meta.py +157 -0
  12. package/workframe-api/auth_gate.py +566 -0
  13. package/workframe-api/avatar_registry.py +343 -0
  14. package/workframe-api/broker_audit.py +164 -0
  15. package/workframe-api/cell_authority.py +231 -0
  16. package/workframe-api/chat_bind.py +319 -0
  17. package/workframe-api/chat_sessions.py +509 -0
  18. package/workframe-api/chat_stream.py +694 -0
  19. package/workframe-api/cleanup_dogfood_smoke.py +252 -0
  20. package/workframe-api/credential_broker.py +162 -0
  21. package/workframe-api/credential_resolve.py +202 -0
  22. package/workframe-api/credential_store.py +245 -0
  23. package/workframe-api/crew_registry.py +250 -0
  24. package/workframe-api/db_schema.py +755 -0
  25. package/workframe-api/docker_gateway.py +166 -0
  26. package/workframe-api/doctor_runtime.py +109 -0
  27. package/workframe-api/domain/__init__.py +47 -0
  28. package/workframe-api/domain/entities.py +252 -0
  29. package/workframe-api/domain/schema/workframe-domain.schema.json +278 -0
  30. package/workframe-api/egress_policy.py +42 -0
  31. package/workframe-api/handler_modules/__init__.py +17 -0
  32. package/workframe-api/handler_modules/handler_admin.py +542 -0
  33. package/workframe-api/handler_modules/handler_auth.py +512 -0
  34. package/workframe-api/handler_modules/handler_chat.py +641 -0
  35. package/workframe-api/handler_modules/handler_install.py +178 -0
  36. package/workframe-api/handler_modules/handler_provider.py +325 -0
  37. package/workframe-api/handler_modules/handler_workspace.py +1420 -0
  38. package/workframe-api/health_monitor.py +80 -0
  39. package/workframe-api/hermes_admin.py +756 -0
  40. package/workframe-api/hermes_profiles.py +1328 -0
  41. package/workframe-api/install_api.py +123 -0
  42. package/workframe-api/kanban_cron.py +473 -0
  43. package/workframe-api/lane_bindings.py +423 -0
  44. package/workframe-api/llm_proxy.py +17 -43
  45. package/workframe-api/mention_helpers.py +180 -0
  46. package/workframe-api/mention_invoke.py +431 -0
  47. package/workframe-api/model_surface.py +1139 -0
  48. package/workframe-api/oauth_pending.py +85 -0
  49. package/workframe-api/oauth_redirect.py +381 -0
  50. package/workframe-api/openrouter_catalog.py +4 -4
  51. package/workframe-api/package.json +2 -2
  52. package/workframe-api/profile_api_lifecycle.py +66 -0
  53. package/workframe-api/profile_config_yaml.py +126 -0
  54. package/workframe-api/profile_gateway.py +436 -0
  55. package/workframe-api/provider_bindings.py +673 -0
  56. package/workframe-api/provider_bootstrap.py +347 -0
  57. package/workframe-api/provider_catalog.py +180 -0
  58. package/workframe-api/rooms.py +1613 -0
  59. package/workframe-api/route_registry.py +470 -0
  60. package/workframe-api/run-typecheck.mjs +26 -0
  61. package/workframe-api/run_authority.py +287 -0
  62. package/workframe-api/run_ledger.py +470 -0
  63. package/workframe-api/run_surface_wiring.py +344 -0
  64. package/workframe-api/runtime_cohort.py +752 -0
  65. package/workframe-api/runtime_tokens.py +127 -0
  66. package/workframe-api/server.py +1376 -19305
  67. package/workframe-api/snapshot_feed.py +49 -0
  68. package/workframe-api/stack_config.py +33 -1
  69. package/workframe-api/supervisor_client.py +154 -0
  70. package/workframe-api/test_api_meta_build_stamp.py +34 -0
  71. package/workframe-api/test_cell_authority.py +79 -0
  72. package/workframe-api/test_chat_bind.py +48 -0
  73. package/workframe-api/test_credential_lease_matrix.py +171 -0
  74. package/workframe-api/test_credential_resolve.py +51 -0
  75. package/workframe-api/test_credential_store.py +27 -0
  76. package/workframe-api/test_dogfood_flows.py +346 -0
  77. package/workframe-api/test_domain_entities.py +152 -0
  78. package/workframe-api/test_exception_hygiene.py +12 -0
  79. package/workframe-api/test_hermes_admin.py +72 -0
  80. package/workframe-api/test_install_wizard_resume.py +78 -0
  81. package/workframe-api/test_local_bootstrap.py +67 -0
  82. package/workframe-api/test_mention_helpers.py +35 -0
  83. package/workframe-api/test_mention_invoke.py +26 -0
  84. package/workframe-api/test_model_surface_consistency.py +117 -0
  85. package/workframe-api/test_oauth_pending.py +41 -0
  86. package/workframe-api/test_profile_model_yaml.py +99 -0
  87. package/workframe-api/test_provider_bindings.py +52 -0
  88. package/workframe-api/test_provider_catalog.py +28 -0
  89. package/workframe-api/test_public_routes.py +41 -0
  90. package/workframe-api/test_route_registry.py +245 -0
  91. package/workframe-api/test_run_authority.py +112 -0
  92. package/workframe-api/test_run_ledger.py +157 -0
  93. package/workframe-api/test_run_surface_wiring.py +130 -0
  94. package/workframe-api/test_runtime_cohort.py +85 -0
  95. package/workframe-api/test_secure_mode_docker_boundary.py +39 -0
  96. package/workframe-api/test_server_reexports.py +99 -0
  97. package/workframe-api/test_stack_config_install_smtp.py +7 -0
  98. package/workframe-api/time-bind-steps.py +75 -0
  99. package/workframe-api/time-perf.py +81 -0
  100. package/workframe-api/time-warm.py +107 -0
  101. package/workframe-api/turn_credentials.py +28 -13
  102. package/workframe-api/turn_overlay.py +715 -0
  103. package/workframe-api/updates.py +16 -1
  104. package/workframe-api/user_prefs.py +387 -0
  105. package/workframe-api/wf032_extract_remaining_handlers.py +417 -0
  106. package/workframe-api/workspace_bootstrap.py +536 -0
  107. package/workframe-api/workspace_files.py +344 -0
  108. package/workframe-api/workspace_messaging.py +206 -0
  109. package/workframe-api/zk_auth.py +3 -2
  110. package/workframe-supervisor/server.py +10 -1
  111. package/workframe-supervisor/test_supervisor_negative.py +75 -0
  112. package/workframe-ui/public/assets/{arc-DjpS9a2b.js → arc-aUUffclm.js} +1 -1
  113. package/workframe-ui/public/assets/architecture-7EHR7CIX-BSUP4S8J.js +1 -0
  114. package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-puCKmKHV.js → architectureDiagram-3BPJPVTR-DU6oaqIb.js} +1 -1
  115. package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-C2P0g-eH.js → blockDiagram-GPEHLZMM-D2p8BU6-.js} +1 -1
  116. package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-DdvYEITH.js → c4Diagram-AAUBKEIU-ouwyPmTJ.js} +1 -1
  117. package/workframe-ui/public/assets/channel-DOunZZ0X.js +1 -0
  118. package/workframe-ui/public/assets/{chunk-2J33WTMH-Cxe8QsEF.js → chunk-2J33WTMH-DsE7U5dj.js} +1 -1
  119. package/workframe-ui/public/assets/{chunk-3OPIFGDE-DPNyTwR3.js → chunk-3OPIFGDE-DM0kkZ9h.js} +1 -1
  120. package/workframe-ui/public/assets/{chunk-4BX2VUAB-XKG9gfRN.js → chunk-4BX2VUAB-CQ8mAyXp.js} +1 -1
  121. package/workframe-ui/public/assets/{chunk-55IACEB6-oPMTulgb.js → chunk-55IACEB6-DN1BDtbH.js} +1 -1
  122. package/workframe-ui/public/assets/{chunk-5ZQYHXKU-BWwXvpa_.js → chunk-5ZQYHXKU-BzK2KqOt.js} +1 -1
  123. package/workframe-ui/public/assets/{chunk-727SXJPM-Cml2twO_.js → chunk-727SXJPM-C3AxtOi9.js} +1 -1
  124. package/workframe-ui/public/assets/{chunk-AQP2D5EJ-DzrRBhQu.js → chunk-AQP2D5EJ-4mVwEFuD.js} +1 -1
  125. package/workframe-ui/public/assets/{chunk-BSJP7CBP-EZ7STU6h.js → chunk-BSJP7CBP-23kN2q0A.js} +1 -1
  126. package/workframe-ui/public/assets/{chunk-CSCIHK7Q-BGTQzWYw.js → chunk-CSCIHK7Q-CPBKVW-L.js} +2 -2
  127. package/workframe-ui/public/assets/{chunk-FMBD7UC4-Cm4stfRa.js → chunk-FMBD7UC4-CKqcYq7K.js} +1 -1
  128. package/workframe-ui/public/assets/{chunk-KSCS5N6A-exbMijjy.js → chunk-KSCS5N6A-JRQnhxQE.js} +1 -1
  129. package/workframe-ui/public/assets/{chunk-L5ZTLDWV-C75kbQwN.js → chunk-L5ZTLDWV-BLZrdIwa.js} +1 -1
  130. package/workframe-ui/public/assets/chunk-LZXEDZCA-DisG0XJT.js +2 -0
  131. package/workframe-ui/public/assets/{chunk-ND2GUHAM-D84zyIu2.js → chunk-ND2GUHAM-ClAOxArS.js} +1 -1
  132. package/workframe-ui/public/assets/{chunk-NZK2D7GU-CUNNo1bj.js → chunk-NZK2D7GU-XUE1aNkm.js} +1 -1
  133. package/workframe-ui/public/assets/{chunk-O5CBEL6O-DN6WEIh-.js → chunk-O5CBEL6O-DDN-Ifon.js} +1 -1
  134. package/workframe-ui/public/assets/chunk-QZHKN3VN-EVQ0VMd5.js +1 -0
  135. package/workframe-ui/public/assets/chunk-WU5MYG2G-k_-ZsLOS.js +1 -0
  136. package/workframe-ui/public/assets/{chunk-XPW4576I-Ct5VQv2D.js → chunk-XPW4576I-B4_iYZ6r.js} +1 -1
  137. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-W9WVQsby.js +1 -0
  138. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-W9WVQsby.js +1 -0
  139. package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-Bj4W5oMk.js → cose-bilkent-S5V4N54A-Cy1HhS7j.js} +1 -1
  140. package/workframe-ui/public/assets/{dagre-BM42HDAG-DAjHBkAy.js → dagre-BM42HDAG-Bjal81Ie.js} +1 -1
  141. package/workframe-ui/public/assets/{diagram-2AECGRRQ-Ch2gGFPG.js → diagram-2AECGRRQ-v5Gdvzp_.js} +1 -1
  142. package/workframe-ui/public/assets/{diagram-5GNKFQAL-BzIIvgYH.js → diagram-5GNKFQAL-DuyRdfmY.js} +1 -1
  143. package/workframe-ui/public/assets/{diagram-KO2AKTUF-Df0Q8nv6.js → diagram-KO2AKTUF-WwWCGbIW.js} +1 -1
  144. package/workframe-ui/public/assets/{diagram-LMA3HP47-D-G3ekmF.js → diagram-LMA3HP47-DTYwZALT.js} +1 -1
  145. package/workframe-ui/public/assets/{diagram-OG6HWLK6-TBHhT9f5.js → diagram-OG6HWLK6-B46kBIqE.js} +1 -1
  146. package/workframe-ui/public/assets/{dist-CPbiDkEZ.js → dist-mQWmB3z6.js} +1 -1
  147. package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-YxkUmLLV.js → erDiagram-TEJ5UH35-__40xO-y.js} +1 -1
  148. package/workframe-ui/public/assets/eventmodeling-FCH6USID-Cm1uRTxU.js +1 -0
  149. package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-kfZ7-RSd.js → flowDiagram-I6XJVG4X-Cwj6dVUz.js} +1 -1
  150. package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-DmTx3hzR.js → ganttDiagram-6RSMTGT7-D_IOSwAS.js} +1 -1
  151. package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-BXa0ewp3.js → gitGraph-WXDBUCRP-C1vGecee.js} +1 -1
  152. package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-CQr2qMxU.js → gitGraphDiagram-PVQCEYII-DMTxFJxn.js} +1 -1
  153. package/workframe-ui/public/assets/index-BdiM4-lI.js +130 -0
  154. package/workframe-ui/public/assets/index-Fnad47vV.css +1 -0
  155. package/workframe-ui/public/assets/{info-J43DQDTF-t-Qlx8zv.js → info-J43DQDTF-CwjmyPmD.js} +1 -1
  156. package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-CzkYofPx.js → infoDiagram-5YYISTIA-BGzh7ZL2.js} +1 -1
  157. package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-djK3Oxgb.js → ishikawaDiagram-YF4QCWOH-BLXdnR_5.js} +1 -1
  158. package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-CWxYXxdF.js → journeyDiagram-JHISSGLW-BK3behMe.js} +1 -1
  159. package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-D7leCAD9.js → kanban-definition-UN3LZRKU-Bx172cCl.js} +1 -1
  160. package/workframe-ui/public/assets/{line-BSFj2OC5.js → line-BTCEHb7l.js} +1 -1
  161. package/workframe-ui/public/assets/{linear-pfPwnu-o.js → linear-CkbydpnK.js} +1 -1
  162. package/workframe-ui/public/assets/{mermaid-parser.core-Dm2L9Ib_.js → mermaid-parser.core-DvrtArpk.js} +2 -2
  163. package/workframe-ui/public/assets/{mermaid.core-Bevx9bif.js → mermaid.core-yj_1x_qj.js} +3 -3
  164. package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-UkjDssmP.js → mindmap-definition-RKZ34NQL-BXbMltQ0.js} +1 -1
  165. package/workframe-ui/public/assets/{packet-YPE3B663-JO2ezH3_.js → packet-YPE3B663-9pVCBQ7S.js} +1 -1
  166. package/workframe-ui/public/assets/{pie-LRSECV5Y-DdMFsfR8.js → pie-LRSECV5Y-CdqoWesP.js} +1 -1
  167. package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-CEPHFBU1.js → pieDiagram-4H26LBE5-kGs-VfXd.js} +1 -1
  168. package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-_RF9_86k.js → quadrantDiagram-W4KKPZXB-D9D8gQw5.js} +1 -1
  169. package/workframe-ui/public/assets/{radar-GUYGQ44K-Cb-YfdaA.js → radar-GUYGQ44K-d4zMZpQS.js} +1 -1
  170. package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-DBVOxmBn.js → requirementDiagram-4Y6WPE33-BI7EQ9zc.js} +1 -1
  171. package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-BTpxsGUA.js → sankeyDiagram-5OEKKPKP-Bcz19jQB.js} +1 -1
  172. package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-DHP_Bq3l.js → sequenceDiagram-3UESZ5HK-iQiCEfYp.js} +1 -1
  173. package/workframe-ui/public/assets/{src-CY_By-r7.js → src-D6mvlP96.js} +1 -1
  174. package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BVtp_LPR.js → stateDiagram-AJRCARHV-CxaTXs02.js} +1 -1
  175. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CXikj9xi.js +1 -0
  176. package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-D38HJhop.js → timeline-definition-PNZ67QCA-Bej-EAnf.js} +1 -1
  177. package/workframe-ui/public/assets/{treeView-BLDUP644-CFCG3Sc_.js → treeView-BLDUP644-C7PnnzwX.js} +1 -1
  178. package/workframe-ui/public/assets/{treemap-LRROVOQU-BeiMs_dF.js → treemap-LRROVOQU-CqISLmkO.js} +1 -1
  179. package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-CKMeT6PW.js → vennDiagram-CIIHVFJN-eJMTTEW-.js} +1 -1
  180. package/workframe-ui/public/assets/{wardley-L42UT6IY-BljDy8ph.js → wardley-L42UT6IY-D4sMroBF.js} +1 -1
  181. package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DEE8D94J.js → wardleyDiagram-YWT4CUSO-DC3DCUs7.js} +1 -1
  182. package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-BRaAWV_g.js → xychartDiagram-2RQKCTM6-BeMaM6Aq.js} +1 -1
  183. package/workframe-ui/public/index.html +7 -6
  184. package/workframe-ui/public/workframe-build.json +5 -0
  185. package/workframe-ui/public/assets/architecture-7EHR7CIX-CvhbXEvt.js +0 -1
  186. package/workframe-ui/public/assets/channel-D63s_yN9.js +0 -1
  187. package/workframe-ui/public/assets/chunk-LZXEDZCA-CsRdBqUB.js +0 -2
  188. package/workframe-ui/public/assets/chunk-QZHKN3VN-Dj_Yxhgo.js +0 -1
  189. package/workframe-ui/public/assets/chunk-WU5MYG2G-AFchshb6.js +0 -1
  190. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-Opj8t01I.js +0 -1
  191. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-Opj8t01I.js +0 -1
  192. package/workframe-ui/public/assets/eventmodeling-FCH6USID-LQOYL50n.js +0 -1
  193. package/workframe-ui/public/assets/index-B08aShJy.css +0 -1
  194. package/workframe-ui/public/assets/index-DCF3W3G_.js +0 -129
  195. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CvJhS_BJ.js +0 -1
@@ -0,0 +1,1613 @@
1
+ """WF-032 extract: rooms."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import queue
7
+ import re
8
+ import sqlite3
9
+ import threading
10
+ import time
11
+ import uuid
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from http.server import BaseHTTPRequestHandler
16
+
17
+ import user_prefs
18
+ import zk_auth as _zk
19
+ from auth_gate import OWNER_ADMIN_ROLES, SECURE_MODE, role_allows as _role_allows
20
+
21
+ _room_live_lock = threading.Lock()
22
+ _room_live_queues: dict[str, list[queue.SimpleQueue[str]]] = {}
23
+ _room_live_turns: dict[str, dict[str, Any]] = {}
24
+
25
+
26
+ def _srv():
27
+ import server as srv
28
+
29
+ return srv
30
+
31
+
32
+ def _room_live_subscribe(room_id: str) -> queue.SimpleQueue[str]:
33
+ q: queue.SimpleQueue[str] = queue.SimpleQueue()
34
+ with _room_live_lock:
35
+ _room_live_queues.setdefault(room_id, []).append(q)
36
+ return q
37
+
38
+
39
+ def _room_live_unsubscribe(room_id: str, q: queue.SimpleQueue[str]) -> None:
40
+ with _room_live_lock:
41
+ subs = _room_live_queues.get(room_id, [])
42
+ if q in subs:
43
+ subs.remove(q)
44
+ if not subs:
45
+ _room_live_queues.pop(room_id, None)
46
+
47
+
48
+ def _room_live_publish(room_id: str, event: dict[str, Any]) -> None:
49
+ line = json.dumps(event)
50
+ with _room_live_lock:
51
+ subs = list(_room_live_queues.get(room_id, []))
52
+ for sub in subs:
53
+ try:
54
+ sub.put(line)
55
+ except Exception: # noqa: BLE001
56
+ pass
57
+
58
+
59
+ def _room_live_set_turn(turn: dict[str, Any]) -> None:
60
+ with _room_live_lock:
61
+ _room_live_turns[str(turn["turn_id"])] = turn
62
+
63
+
64
+ def _room_live_clear_turn(turn_id: str) -> None:
65
+ with _room_live_lock:
66
+ _room_live_turns.pop(str(turn_id), None)
67
+
68
+
69
+ def _room_live_active_for_room(room_id: str) -> list[dict[str, Any]]:
70
+ with _room_live_lock:
71
+ return [dict(turn) for turn in _room_live_turns.values() if str(turn.get("room_id") or "") == room_id]
72
+
73
+
74
+
75
+
76
+ def _room_payload(row: sqlite3.Row) -> dict[str, Any]:
77
+ """Return the API-safe payload for a room row."""
78
+ platform_ids_raw = row["platform_ids"] or "{}"
79
+ try:
80
+ platform_ids = json.loads(platform_ids_raw) if isinstance(platform_ids_raw, str) else platform_ids_raw
81
+ except (TypeError, json.JSONDecodeError):
82
+ platform_ids = {}
83
+ if not isinstance(platform_ids, dict):
84
+ platform_ids = {}
85
+ return {
86
+ "id": row["id"],
87
+ "workspace_id": row["workspace_id"],
88
+ "agent_profile_id": row["agent_profile_id"],
89
+ "name": row["name"],
90
+ "slug": row["slug"],
91
+ "topic": row["topic"] or "",
92
+ "avatar_url": row["avatar_url"] if "avatar_url" in row.keys() else None,
93
+ "room_type": row["room_type"],
94
+ "platform_ids": platform_ids,
95
+ "status": row["status"],
96
+ "created_by": row["created_by"],
97
+ "created_at": row["created_at"],
98
+ "updated_at": row["updated_at"],
99
+ }
100
+
101
+
102
+ def _sanitize_space_room_agent_fields(room: dict[str, Any]) -> None:
103
+ """Spaces never bind a single agent on the room row — agents live in memberships."""
104
+ if str(room.get("room_type") or "") in {"channel", "group"}:
105
+ room["agent_profile_id"] = None
106
+ room["hermes_profile"] = None
107
+
108
+
109
+ def _workspace_payload(row: sqlite3.Row, *, viewer_role: str = "") -> dict[str, Any]:
110
+ keys = row.keys()
111
+ settings = _srv()._parse_workspace_settings(row)
112
+ payload: dict[str, Any] = {
113
+ "id": row["id"],
114
+ "slug": row["slug"],
115
+ "display_name": row["display_name"] or "",
116
+ "description": row["description"] if "description" in keys else "",
117
+ "avatar_url": row["avatar_url"] if "avatar_url" in keys else None,
118
+ "status": row["status"],
119
+ "owner_id": row["owner_id"],
120
+ "created_at": row["created_at"],
121
+ "updated_at": row["updated_at"],
122
+ }
123
+ gh = settings.get("github_oauth") if isinstance(settings.get("github_oauth"), dict) else {}
124
+ stack_gh = _srv()._github_oauth_app_config("")
125
+ integrations: dict[str, Any] = {
126
+ "github_oauth_configured": _srv()._github_oauth_configured(str(row["id"]))
127
+ or bool(stack_gh.get("client_id") and stack_gh.get("client_secret")),
128
+ }
129
+ if viewer_role in OWNER_ADMIN_ROLES:
130
+ integrations["github_oauth_client_id"] = str(gh.get("client_id") or stack_gh.get("client_id") or "")
131
+ integrations["github_oauth_has_secret"] = bool(
132
+ str(gh.get("client_secret") or "").strip()
133
+ or (not gh and stack_gh.get("client_secret"))
134
+ )
135
+ integrations["messaging"] = _srv()._workspace_messaging_integrations_payload(str(row["id"]), settings)
136
+ payload["integrations"] = integrations
137
+ payload["credential_mode"] = str(settings.get("credential_mode") or "byok").strip() or "byok"
138
+ payload["tagline"] = str(settings.get("tagline") or "")
139
+ if viewer_role in OWNER_ADMIN_ROLES:
140
+ payload["admin_onboarding_done"] = bool(settings.get("admin_onboarding_done"))
141
+ return payload
142
+
143
+
144
+ def _normalize_room_slug(value: str, fallback: str = "") -> str:
145
+ slug = re.sub(r"[^a-z0-9]+", "-", (value or "").strip().lower())
146
+ slug = slug.strip("-")
147
+ if not slug:
148
+ slug = f"room-{fallback[:8]}" if fallback else "room"
149
+ if len(slug) > 64:
150
+ slug = slug[:64].rstrip("-") or "room"
151
+ return slug
152
+
153
+
154
+ def _parse_room_platform_ids(value: Any) -> dict[str, Any]:
155
+ if value in (None, ""):
156
+ return {}
157
+ if isinstance(value, dict):
158
+ return value
159
+ if isinstance(value, str):
160
+ try:
161
+ parsed = json.loads(value)
162
+ except json.JSONDecodeError as exc:
163
+ raise ValueError("platform_ids must be a JSON object") from exc
164
+ if not isinstance(parsed, dict):
165
+ raise ValueError("platform_ids must be a JSON object")
166
+ return parsed
167
+ raise ValueError("platform_ids must be an object")
168
+
169
+
170
+ def _resolve_wid(workspace_id_or_slug: str) -> str | None:
171
+ """Resolve a workspace identifier (UUID or slug) to its UUID.
172
+
173
+ Returns the UUID string if found, or None if the workspace doesn't exist.
174
+ Opens and closes its own DB connection — use once per request, or pass
175
+ the resolved UUID downstream.
176
+ """
177
+ val = str(workspace_id_or_slug or "").strip()
178
+ if not val:
179
+ return None
180
+ try:
181
+ conn = _srv()._workframe_db()
182
+ row = conn.execute(
183
+ "SELECT id FROM workspaces WHERE (id = ? OR slug = ?) AND deleted_at IS NULL",
184
+ (val, val),
185
+ ).fetchone()
186
+ conn.close()
187
+ return str(row["id"]) if row else None
188
+ except Exception:
189
+ return None
190
+
191
+
192
+ def _workspace_exists(conn: sqlite3.Connection, workspace_id: str) -> bool:
193
+ row = conn.execute(
194
+ "SELECT id FROM workspaces WHERE id = ? AND deleted_at IS NULL",
195
+ (workspace_id,),
196
+ ).fetchone()
197
+ return row is not None
198
+
199
+
200
+ def _workspace_member_payload(row: sqlite3.Row) -> dict[str, Any]:
201
+ return {
202
+ "membership_id": row["membership_id"],
203
+ "workspace_id": row["workspace_id"],
204
+ "user_id": row["user_id"],
205
+ "email": row["email"],
206
+ "display_name": row["display_name"] or "",
207
+ "avatar_url": row["avatar_url"],
208
+ "role": row["role"],
209
+ "status": row["status"],
210
+ "invited_by": row["invited_by"],
211
+ "created_at": row["created_at"],
212
+ "updated_at": row["updated_at"],
213
+ }
214
+
215
+
216
+ def _workspace_member_lookup_sql() -> str:
217
+ return """
218
+ SELECT
219
+ wm.membership_id,
220
+ wm.workspace_id,
221
+ wm.user_id,
222
+ wm.role,
223
+ wm.status,
224
+ wm.invited_by,
225
+ wm.created_at,
226
+ wm.updated_at,
227
+ wm.email,
228
+ wm.display_name,
229
+ wm.avatar_url
230
+ FROM (
231
+ SELECT
232
+ wm.id AS membership_id,
233
+ wm.workspace_id,
234
+ wm.user_id,
235
+ wm.role,
236
+ wm.status,
237
+ wm.invited_by,
238
+ wm.created_at,
239
+ wm.updated_at,
240
+ u.email,
241
+ u.display_name,
242
+ u.avatar_url,
243
+ ROW_NUMBER() OVER (
244
+ PARTITION BY wm.workspace_id, wm.user_id
245
+ ORDER BY wm.created_at ASC, wm.id ASC
246
+ ) AS membership_rank
247
+ FROM workspace_memberships wm
248
+ LEFT JOIN users u ON u.id = wm.user_id
249
+ WHERE wm.workspace_id = ?
250
+ AND wm.deleted_at IS NULL
251
+ ) wm
252
+ WHERE wm.membership_rank = 1
253
+ """
254
+
255
+
256
+ def _workspace_member_role(
257
+ conn: sqlite3.Connection,
258
+ workspace_id: str,
259
+ user_id: str,
260
+ ) -> str | None:
261
+ row = conn.execute(
262
+ """
263
+ SELECT wm.role
264
+ FROM workspace_memberships wm
265
+ JOIN workspaces w ON w.id = wm.workspace_id
266
+ WHERE wm.workspace_id = ?
267
+ AND wm.user_id = ?
268
+ AND wm.deleted_at IS NULL
269
+ AND wm.status = 'active'
270
+ AND w.deleted_at IS NULL
271
+ AND w.status = 'active'
272
+ """,
273
+ (workspace_id, user_id),
274
+ ).fetchone()
275
+ return str(row["role"]) if row else None
276
+
277
+
278
+ _ONBOARDING_PROGRESS_KEYS = frozenset({
279
+ "admin_integrations_done",
280
+ "admin_onboarding_done",
281
+ "credential_mode",
282
+ })
283
+
284
+
285
+ def _resolve_workspace_integrations_role(
286
+ conn: sqlite3.Connection,
287
+ workspace_id: str,
288
+ user_id: str,
289
+ body: dict[str, Any],
290
+ ) -> str | None:
291
+ """Owner/admin for integrations; repair stale membership during install onboarding."""
292
+ role = _srv()._workspace_member_role(conn, workspace_id, user_id)
293
+ row = conn.execute(
294
+ "SELECT owner_id FROM workspaces WHERE id = ? AND deleted_at IS NULL",
295
+ (workspace_id,),
296
+ ).fetchone()
297
+ owner_id = str(row["owner_id"] or "").strip() if row else ""
298
+ now = str(int(time.time()))
299
+
300
+ if owner_id and owner_id == user_id and role not in OWNER_ADMIN_ROLES:
301
+ mem = conn.execute(
302
+ "SELECT id FROM workspace_memberships WHERE workspace_id = ? AND user_id = ? AND deleted_at IS NULL",
303
+ (workspace_id, user_id),
304
+ ).fetchone()
305
+ if mem:
306
+ conn.execute(
307
+ "UPDATE workspace_memberships SET role = 'owner', updated_at = ? WHERE id = ?",
308
+ (now, mem["id"]),
309
+ )
310
+ role = "owner"
311
+ elif not owner_id and _srv()._install_window_open():
312
+ if _srv()._promote_workspace_owner_if_unclaimed(conn, workspace_id, user_id):
313
+ role = _srv()._workspace_member_role(conn, workspace_id, user_id)
314
+
315
+ if role in OWNER_ADMIN_ROLES:
316
+ return role
317
+ if (
318
+ role
319
+ and _srv()._install_window_open()
320
+ and set(body.keys()) <= _ONBOARDING_PROGRESS_KEYS
321
+ ):
322
+ return role
323
+ return role
324
+
325
+
326
+ def _can_read_workspace_members(
327
+ handler: BaseHTTPRequestHandler,
328
+ conn: sqlite3.Connection,
329
+ workspace_id: str,
330
+ ) -> bool:
331
+ if not SECURE_MODE:
332
+ return True
333
+ if _role_allows(handler, OWNER_ADMIN_ROLES):
334
+ return True
335
+ user_id = str(getattr(handler, "auth_user", "") or "")
336
+ return _srv()._workspace_member_role(conn, workspace_id, user_id) is not None
337
+
338
+
339
+ def _can_manage_workspace_members(
340
+ handler: BaseHTTPRequestHandler,
341
+ conn: sqlite3.Connection,
342
+ workspace_id: str,
343
+ ) -> bool:
344
+ if not SECURE_MODE:
345
+ return True
346
+ if _role_allows(handler, OWNER_ADMIN_ROLES):
347
+ return True
348
+ user_id = str(getattr(handler, "auth_user", "") or "")
349
+ return _srv()._workspace_member_role(conn, workspace_id, user_id) in OWNER_ADMIN_ROLES
350
+
351
+
352
+ def _list_workspace_members(
353
+ workspace_id: str,
354
+ handler: BaseHTTPRequestHandler | None = None,
355
+ ) -> tuple[int, dict[str, Any]]:
356
+ workspace_id = str(workspace_id or "").strip()
357
+ if not workspace_id:
358
+ return 400, {"ok": False, "error": "workspace_id required"}
359
+ try:
360
+ conn = _srv()._workframe_db()
361
+ except sqlite3.Error as exc:
362
+ return 500, {"ok": False, "error": f"workframe_db_unavailable: {exc}"}
363
+ try:
364
+ if not _workspace_exists(conn, workspace_id):
365
+ return 404, {"ok": False, "error": "workspace_not_found", "workspace_id": workspace_id}
366
+ if handler is not None and not _can_read_workspace_members(handler, conn, workspace_id):
367
+ return 403, {"ok": False, "error": "forbidden", "required_role": "workspace_member_or_owner_or_admin"}
368
+ rows = conn.execute(
369
+ f"{_workspace_member_lookup_sql()} ORDER BY COALESCE(wm.display_name, wm.email, wm.user_id) ASC, wm.created_at ASC",
370
+ (workspace_id,),
371
+ ).fetchall()
372
+ members: list[dict[str, Any]] = []
373
+ for row in rows:
374
+ payload = _workspace_member_payload(row)
375
+ prof = _zk.get_profile(str(row["user_id"]))
376
+ if isinstance(prof, dict):
377
+ if prof.get("tagline"):
378
+ payload["tagline"] = str(prof["tagline"])
379
+ zk_avatar = str(prof.get("avatar_url") or "").strip()
380
+ if zk_avatar:
381
+ payload["avatar_url"] = _srv()._normalize_user_avatar_url(zk_avatar)
382
+ members.append(payload)
383
+ return 200, {
384
+ "ok": True,
385
+ "workspace_id": workspace_id,
386
+ "members": members,
387
+ }
388
+ except sqlite3.Error as exc:
389
+ return 500, {"ok": False, "error": f"workspace_member_list_failed: {exc}"}
390
+ finally:
391
+ conn.close()
392
+
393
+
394
+ def _active_owner_count(conn: sqlite3.Connection, workspace_id: str) -> int:
395
+ row = conn.execute(
396
+ """
397
+ SELECT COUNT(*) AS c
398
+ FROM workspace_memberships
399
+ WHERE workspace_id = ?
400
+ AND deleted_at IS NULL
401
+ AND status = 'active'
402
+ AND role = 'owner'
403
+ """,
404
+ (workspace_id,),
405
+ ).fetchone()
406
+ return int(row["c"] if row else 0)
407
+
408
+
409
+ def _workspace_membership_update_payload(
410
+ conn: sqlite3.Connection,
411
+ workspace_id: str,
412
+ user_id: str,
413
+ updates: dict[str, Any],
414
+ actor_user_id: str,
415
+ ) -> tuple[int, dict[str, Any]]:
416
+ membership_id = str(updates.get("membership_id", "") or "").strip()
417
+ if membership_id:
418
+ row = conn.execute(
419
+ f"{_workspace_member_lookup_sql()} AND wm.membership_id = ?",
420
+ (workspace_id, membership_id),
421
+ ).fetchone()
422
+ else:
423
+ row = conn.execute(
424
+ f"{_workspace_member_lookup_sql()} AND wm.user_id = ?",
425
+ (workspace_id, user_id),
426
+ ).fetchone()
427
+ if not row:
428
+ return 404, {"ok": False, "error": "member_not_found", "workspace_id": workspace_id, "user_id": user_id}
429
+
430
+ role = str(row["role"])
431
+ status = str(row["status"])
432
+ if "role" in updates:
433
+ role = str(updates.get("role", "") or "").strip()
434
+ if role not in WORKSPACE_MEMBER_ROLES:
435
+ return 400, {"ok": False, "error": "invalid_role", "allowed": sorted(WORKSPACE_MEMBER_ROLES)}
436
+ if "status" in updates:
437
+ status = str(updates.get("status", "") or "").strip()
438
+ if status not in WORKSPACE_MEMBER_STATUSES:
439
+ return 400, {"ok": False, "error": "invalid_status", "allowed": sorted(WORKSPACE_MEMBER_STATUSES)}
440
+ if bool(updates.get("remove", False)) or bool(updates.get("delete", False)):
441
+ status = "removed"
442
+
443
+ if role == "owner" and status == "active":
444
+ owners = _active_owner_count(conn, workspace_id)
445
+ existing_role = str(row["role"])
446
+ existing_status = str(row["status"])
447
+ if existing_role != "owner" or existing_status != "active":
448
+ owners += 1
449
+ else:
450
+ owners = _active_owner_count(conn, workspace_id)
451
+ existing_role = str(row["role"])
452
+ existing_status = str(row["status"])
453
+ if existing_role == "owner" and existing_status == "active":
454
+ owners -= 1
455
+ if owners < 1:
456
+ return 409, {"ok": False, "error": "cannot_remove_last_owner", "workspace_id": workspace_id}
457
+
458
+ now_ts = str(int(time.time()))
459
+ deleted_at = now_ts if status == "removed" else None
460
+ try:
461
+ conn.execute(
462
+ """
463
+ UPDATE workspace_memberships
464
+ SET role = ?, status = ?, deleted_at = ?, invited_by = COALESCE(invited_by, ?), updated_at = ?
465
+ WHERE id = ?
466
+ """,
467
+ (role, status, deleted_at, actor_user_id or None, now_ts, row["membership_id"]),
468
+ )
469
+ except sqlite3.IntegrityError as exc:
470
+ return 409, {"ok": False, "error": "workspace_membership_update_conflict", "detail": str(exc)}
471
+ except sqlite3.Error as exc:
472
+ return 500, {"ok": False, "error": f"workspace_membership_update_failed: {exc}"}
473
+
474
+ updated = conn.execute(
475
+ """
476
+ SELECT
477
+ wm.id AS membership_id,
478
+ wm.workspace_id,
479
+ wm.user_id,
480
+ wm.role,
481
+ wm.status,
482
+ wm.invited_by,
483
+ wm.created_at,
484
+ wm.updated_at,
485
+ u.email,
486
+ u.display_name,
487
+ u.avatar_url
488
+ FROM workspace_memberships wm
489
+ LEFT JOIN users u ON u.id = wm.user_id
490
+ WHERE wm.workspace_id = ? AND wm.id = ?
491
+ """,
492
+ (workspace_id, row["membership_id"]),
493
+ ).fetchone()
494
+ if not updated:
495
+ return 404, {"ok": False, "error": "member_not_found", "workspace_id": workspace_id, "user_id": user_id}
496
+ return 200, {"ok": True, "membership": _workspace_member_payload(updated)}
497
+
498
+
499
+ def _patch_workspace_members(
500
+ workspace_id: str,
501
+ body: dict[str, Any],
502
+ handler: BaseHTTPRequestHandler,
503
+ ) -> tuple[int, dict[str, Any]]:
504
+ workspace_id = str(workspace_id or "").strip()
505
+ if not workspace_id:
506
+ return 400, {"ok": False, "error": "workspace_id required"}
507
+ if not isinstance(body, dict):
508
+ body = {}
509
+
510
+ bulk = body.get("memberships")
511
+ if bulk is None:
512
+ bulk = body.get("members")
513
+ if bulk is not None and not isinstance(bulk, list):
514
+ return 400, {"ok": False, "error": "memberships must be a list"}
515
+
516
+ try:
517
+ conn = _srv()._workframe_db()
518
+ actor_user_id = getattr(handler, "auth_user", "")
519
+ except sqlite3.Error as exc:
520
+ return 500, {"ok": False, "error": f"workframe_db_unavailable: {exc}"}
521
+ try:
522
+ if not _workspace_exists(conn, workspace_id):
523
+ return 404, {"ok": False, "error": "workspace_not_found", "workspace_id": workspace_id}
524
+ if not _can_manage_workspace_members(handler, conn, workspace_id):
525
+ return 403, {"ok": False, "error": "forbidden", "required_role": "owner_or_admin"}
526
+
527
+ if isinstance(bulk, list):
528
+ if not bulk:
529
+ return 400, {"ok": False, "error": "memberships list cannot be empty"}
530
+ results = []
531
+ for item in bulk:
532
+ if not isinstance(item, dict):
533
+ return 400, {"ok": False, "error": "each membership update must be an object"}
534
+ user_id = str(item.get("user_id", "") or "").strip()
535
+ if not user_id and not str(item.get("membership_id", "") or "").strip():
536
+ return 400, {"ok": False, "error": "user_id or membership_id required"}
537
+ status, payload = _workspace_membership_update_payload(conn, workspace_id, user_id, item, actor_user_id)
538
+ if status != 200:
539
+ return status, payload
540
+ results.append(payload["membership"])
541
+ conn.commit()
542
+ return 200, {"ok": True, "workspace_id": workspace_id, "memberships": results}
543
+
544
+ user_id = str(body.get("user_id", "") or "").strip()
545
+ membership_id = str(body.get("membership_id", "") or "").strip()
546
+ if not user_id and not membership_id:
547
+ return 400, {"ok": False, "error": "user_id or membership_id required"}
548
+ update_body = dict(body)
549
+ if membership_id:
550
+ update_body["membership_id"] = membership_id
551
+ status, payload = _workspace_membership_update_payload(conn, workspace_id, user_id, update_body, actor_user_id)
552
+ if status == 200:
553
+ conn.commit()
554
+ return status, payload
555
+ except sqlite3.Error as exc:
556
+ conn.rollback()
557
+ return 500, {"ok": False, "error": f"workspace_member_patch_failed: {exc}"}
558
+ finally:
559
+ conn.close()
560
+
561
+
562
+ _AGENT_PROFILE_UUID_RE = re.compile(
563
+ r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
564
+ re.IGNORECASE,
565
+ )
566
+
567
+
568
+ def _lookup_agent_profile(
569
+ conn: sqlite3.Connection,
570
+ workspace_id: str,
571
+ ref: str,
572
+ ) -> sqlite3.Row | None:
573
+ """Resolve workspace agent_profiles row by canonical id or Hermes slug."""
574
+ ref = str(ref or "").strip()
575
+ workspace_id = str(workspace_id or "").strip()
576
+ if not ref or not workspace_id:
577
+ return None
578
+ if _AGENT_PROFILE_UUID_RE.match(ref):
579
+ return conn.execute(
580
+ """
581
+ SELECT id, workspace_id, slug, display_name, role, tagline, is_native, status
582
+ FROM agent_profiles
583
+ WHERE id = ? AND workspace_id = ? AND deleted_at IS NULL
584
+ """,
585
+ (ref, workspace_id),
586
+ ).fetchone()
587
+ return conn.execute(
588
+ """
589
+ SELECT id, workspace_id, slug, display_name, role, tagline, is_native, status
590
+ FROM agent_profiles
591
+ WHERE slug = ? AND workspace_id = ? AND deleted_at IS NULL
592
+ """,
593
+ (ref, workspace_id),
594
+ ).fetchone()
595
+
596
+
597
+ def _hermes_slug_from_agent_ref(ref: str) -> str:
598
+ """Map workspace agent_profiles.id to Hermes profile slug."""
599
+ ref = str(ref or "").strip()
600
+ if not ref or not _AGENT_PROFILE_UUID_RE.match(ref):
601
+ return ref
602
+ try:
603
+ conn = _srv()._workframe_db()
604
+ row = conn.execute(
605
+ "SELECT slug FROM agent_profiles WHERE id = ? AND deleted_at IS NULL LIMIT 1",
606
+ (ref,),
607
+ ).fetchone()
608
+ conn.close()
609
+ return str(row["slug"]) if row else ""
610
+ except Exception:
611
+ return ""
612
+
613
+
614
+ def _enrich_rooms_hermes_profile(conn: sqlite3.Connection, rooms: list[dict[str, Any]]) -> None:
615
+ ids = {
616
+ str(room["agent_profile_id"])
617
+ for room in rooms
618
+ if room.get("agent_profile_id") and str(room.get("room_type") or "") == "direct"
619
+ }
620
+ by_id: dict[str, str] = {}
621
+ if ids:
622
+ placeholders = ",".join("?" * len(ids))
623
+ rows = conn.execute(
624
+ f"""
625
+ SELECT id, slug FROM agent_profiles
626
+ WHERE id IN ({placeholders}) AND deleted_at IS NULL
627
+ """,
628
+ tuple(ids),
629
+ ).fetchall()
630
+ by_id = {str(row["id"]): str(row["slug"]) for row in rows}
631
+ for room in rooms:
632
+ ref = str(room.get("agent_profile_id") or "")
633
+ if not ref:
634
+ room["hermes_profile"] = None
635
+ elif ref in by_id:
636
+ room["hermes_profile"] = by_id[ref]
637
+ elif not _AGENT_PROFILE_UUID_RE.match(ref):
638
+ room["hermes_profile"] = ref
639
+ else:
640
+ room["hermes_profile"] = None
641
+ for room in rooms:
642
+ _sanitize_space_room_agent_fields(room)
643
+
644
+
645
+ def _ensure_workspace_agent_profile_row(
646
+ slug: str,
647
+ *,
648
+ workspace_id: str = "",
649
+ display_name: str = "",
650
+ role: str = "",
651
+ tagline: str = "",
652
+ is_native: bool = False,
653
+ ) -> str | None:
654
+ """Ensure agent_profiles row exists for a workspace (default workspace when id omitted)."""
655
+ slug = str(slug or "").strip()
656
+ if not slug:
657
+ return None
658
+ conn: sqlite3.Connection | None = None
659
+ try:
660
+ conn = _srv()._workframe_db()
661
+ ws_id = str(workspace_id or "").strip()
662
+ if ws_id:
663
+ ws = conn.execute(
664
+ "SELECT id FROM workspaces WHERE id = ? AND deleted_at IS NULL",
665
+ (ws_id,),
666
+ ).fetchone()
667
+ else:
668
+ ws = conn.execute(
669
+ """
670
+ SELECT id FROM workspaces
671
+ WHERE deleted_at IS NULL
672
+ ORDER BY CASE WHEN slug = 'default' THEN 0 ELSE 1 END, created_at ASC
673
+ LIMIT 1
674
+ """,
675
+ ).fetchone()
676
+ if not ws:
677
+ return None
678
+ ws_id = str(ws["id"])
679
+ existing = conn.execute(
680
+ """
681
+ SELECT id FROM agent_profiles
682
+ WHERE workspace_id = ? AND slug = ? AND deleted_at IS NULL
683
+ """,
684
+ (ws_id, slug),
685
+ ).fetchone()
686
+ if existing:
687
+ agent_id = str(existing["id"])
688
+ patches: dict[str, Any] = {}
689
+ if display_name.strip():
690
+ patches["display_name"] = display_name.strip()
691
+ if tagline.strip():
692
+ patches["tagline"] = tagline.strip()
693
+ if role.strip():
694
+ patches["role"] = role.strip()
695
+ if patches:
696
+ sets = [f"{col} = ?" for col in patches]
697
+ vals = list(patches.values()) + [str(int(time.time())), ws_id, slug]
698
+ conn.execute(
699
+ f"UPDATE agent_profiles SET {', '.join(sets)}, updated_at = ? WHERE workspace_id = ? AND slug = ? AND deleted_at IS NULL",
700
+ vals,
701
+ )
702
+ _srv()._add_workspace_agents_to_space_rooms(conn, ws_id, agent_id)
703
+ conn.commit()
704
+ return agent_id
705
+ agent_id = str(uuid.uuid4())
706
+ now = str(int(time.time()))
707
+ reg = _srv()._agent_registry_row(slug)
708
+ avatar_url = str(reg.get("avatar_url") or "").strip() or None
709
+ if not avatar_url and not reg.get("avatar_id"):
710
+ try:
711
+ picked = _srv()._assign_agent_avatar(slug)
712
+ avatar_url = picked.get("avatar_url")
713
+ except Exception:
714
+ avatar_url = None
715
+ conn.execute(
716
+ """
717
+ INSERT INTO agent_profiles (
718
+ id, workspace_id, slug, display_name, tagline, role, avatar_url,
719
+ is_native, status, created_at, updated_at
720
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'available', ?, ?)
721
+ """,
722
+ (
723
+ agent_id,
724
+ ws_id,
725
+ slug,
726
+ display_name.strip() or slug,
727
+ tagline.strip(),
728
+ role.strip(),
729
+ avatar_url,
730
+ 1 if is_native else 0,
731
+ now,
732
+ now,
733
+ ),
734
+ )
735
+ conn.commit()
736
+ _srv()._add_workspace_agents_to_space_rooms(conn, ws_id, agent_id)
737
+ return agent_id
738
+ except Exception:
739
+ return None
740
+ finally:
741
+ if conn is not None:
742
+ conn.close()
743
+
744
+
745
+ def _room_api_payload(conn: sqlite3.Connection, row: sqlite3.Row) -> dict[str, Any]:
746
+ room = _room_payload(row)
747
+ _enrich_rooms_hermes_profile(conn, [room])
748
+ return room
749
+
750
+
751
+ def _agent_profile_belongs(conn: sqlite3.Connection, agent_profile_id: str, workspace_id: str) -> bool:
752
+ return _srv()._lookup_agent_profile(conn, workspace_id, agent_profile_id) is not None
753
+
754
+
755
+ def _room_slug_available(conn: sqlite3.Connection, workspace_id: str, slug: str, room_id: str = "") -> bool:
756
+ row = conn.execute(
757
+ """
758
+ SELECT id FROM rooms
759
+ WHERE workspace_id = ? AND slug = ? AND deleted_at IS NULL
760
+ """,
761
+ (workspace_id, slug),
762
+ ).fetchone()
763
+ return row is None or (room_id and row["id"] == room_id)
764
+
765
+
766
+ def _list_rooms(workspace_id: str, *, include_members: bool = False) -> tuple[int, dict[str, Any]]:
767
+ workspace_id = str(workspace_id or "").strip()
768
+ if not workspace_id:
769
+ return 400, {"ok": False, "error": "workspace_id required"}
770
+ try:
771
+ conn = _srv()._workframe_db()
772
+ except sqlite3.Error as exc:
773
+ return 500, {"ok": False, "error": f"workframe_db_unavailable: {exc}"}
774
+ try:
775
+ if not _workspace_exists(conn, workspace_id):
776
+ return 404, {"ok": False, "error": "workspace_not_found", "workspace_id": workspace_id}
777
+ rows = conn.execute(
778
+ """
779
+ SELECT id, workspace_id, agent_profile_id, name, slug, topic, avatar_url, room_type,
780
+ platform_ids, status, created_by, created_at, updated_at
781
+ FROM rooms
782
+ WHERE workspace_id = ? AND deleted_at IS NULL
783
+ ORDER BY created_at ASC, name ASC
784
+ """,
785
+ (workspace_id,),
786
+ ).fetchall()
787
+ rooms = [_room_payload(row) for row in rows]
788
+ _enrich_rooms_hermes_profile(conn, rooms)
789
+ if include_members and rooms:
790
+ room_ids = [str(room["id"]) for room in rooms]
791
+ placeholders = ",".join("?" * len(room_ids))
792
+ member_rows = conn.execute(
793
+ f"""
794
+ SELECT room_id, user_id
795
+ FROM room_memberships
796
+ WHERE deleted_at IS NULL AND room_id IN ({placeholders})
797
+ """,
798
+ room_ids,
799
+ ).fetchall()
800
+ members_by_room: dict[str, list[str]] = {}
801
+ for member_row in member_rows:
802
+ members_by_room.setdefault(str(member_row["room_id"]), []).append(str(member_row["user_id"]))
803
+ for room in rooms:
804
+ room["member_user_ids"] = members_by_room.get(str(room["id"]), [])
805
+ return 200, {"ok": True, "workspace_id": workspace_id, "rooms": rooms}
806
+ except sqlite3.Error as exc:
807
+ return 500, {"ok": False, "error": f"room_list_failed: {exc}"}
808
+ finally:
809
+ conn.close()
810
+
811
+
812
+ def _patch_room(room_id: str, body: dict[str, Any], user_id: str) -> tuple[int, dict[str, Any]]:
813
+ room_id = str(room_id or "").strip()
814
+ user_id = str(user_id or "").strip()
815
+ if not room_id:
816
+ return 400, {"ok": False, "error": "room_id required"}
817
+ if not user_id:
818
+ return 401, {"ok": False, "error": "no_session"}
819
+ allowed = {"name", "topic", "avatar_url"}
820
+ updates = {key: str(body[key]).strip() for key in allowed if key in body}
821
+ if not updates:
822
+ return 400, {"ok": False, "error": "no_allowed_fields", "allowed": list(allowed)}
823
+ if "avatar_url" in updates:
824
+ _srv()._validate_me_profile_updates({"avatar_url": updates["avatar_url"]})
825
+ updates["avatar_url"] = _srv()._normalize_logo_url(updates["avatar_url"])
826
+ if "name" in updates and not updates["name"]:
827
+ return 400, {"ok": False, "error": "name required"}
828
+ try:
829
+ conn = _srv()._workframe_db()
830
+ except sqlite3.Error as exc:
831
+ return 500, {"ok": False, "error": f"workframe_db_unavailable: {exc}"}
832
+ try:
833
+ row = conn.execute(
834
+ "SELECT id, workspace_id FROM rooms WHERE id = ? AND deleted_at IS NULL",
835
+ (room_id,),
836
+ ).fetchone()
837
+ if not row:
838
+ return 404, {"ok": False, "error": "room_not_found", "room_id": room_id}
839
+ if not _user_can_access_room(conn, room_id, user_id):
840
+ return 403, {"ok": False, "error": "forbidden"}
841
+ workspace_id = str(row["workspace_id"])
842
+ member = conn.execute(
843
+ """
844
+ SELECT role FROM workspace_memberships
845
+ WHERE workspace_id = ? AND user_id = ? AND deleted_at IS NULL AND status = 'active'
846
+ """,
847
+ (workspace_id, user_id),
848
+ ).fetchone()
849
+ if not member or str(member["role"]) not in {"owner", "admin"}:
850
+ return 403, {"ok": False, "error": "forbidden", "required_role": "owner_or_admin"}
851
+ sets = [f"{key} = ?" for key in updates]
852
+ vals = list(updates.values())
853
+ now_ts = str(int(time.time()))
854
+ sets.extend(["updated_at = ?"])
855
+ vals.extend([now_ts, room_id])
856
+ conn.execute(f"UPDATE rooms SET {', '.join(sets)} WHERE id = ?", vals)
857
+ conn.commit()
858
+ updated = conn.execute(
859
+ """
860
+ SELECT id, workspace_id, agent_profile_id, name, slug, topic, avatar_url, room_type,
861
+ platform_ids, status, created_by, created_at, updated_at
862
+ FROM rooms WHERE id = ?
863
+ """,
864
+ (room_id,),
865
+ ).fetchone()
866
+ if not updated:
867
+ return 500, {"ok": False, "error": "room_lookup_failed"}
868
+ return 200, {"ok": True, "room": _room_api_payload(conn, updated)}
869
+ except sqlite3.Error as exc:
870
+ return 500, {"ok": False, "error": f"room_update_failed: {exc}"}
871
+ finally:
872
+ conn.close()
873
+
874
+
875
+ def _get_workspace(workspace_id: str, user_id: str) -> tuple[int, dict[str, Any]]:
876
+ workspace_id = str(workspace_id or "").strip()
877
+ user_id = str(user_id or "").strip()
878
+ if not workspace_id:
879
+ return 400, {"ok": False, "error": "workspace_id required"}
880
+ if not user_id:
881
+ return 401, {"ok": False, "error": "no_session"}
882
+ try:
883
+ conn = _srv()._workframe_db()
884
+ except sqlite3.Error as exc:
885
+ return 500, {"ok": False, "error": f"workframe_db_unavailable: {exc}"}
886
+ try:
887
+ if not _workspace_exists(conn, workspace_id):
888
+ return 404, {"ok": False, "error": "workspace_not_found", "workspace_id": workspace_id}
889
+ if _srv()._workspace_member_role(conn, workspace_id, user_id) is None:
890
+ return 403, {"ok": False, "error": "forbidden"}
891
+ role = _srv()._workspace_member_role(conn, workspace_id, user_id) or ""
892
+ row = conn.execute(
893
+ "SELECT * FROM workspaces WHERE id = ? AND deleted_at IS NULL",
894
+ (workspace_id,),
895
+ ).fetchone()
896
+ if not row:
897
+ return 404, {"ok": False, "error": "workspace_not_found", "workspace_id": workspace_id}
898
+ return 200, {"ok": True, "workspace": _workspace_payload(row, viewer_role=role)}
899
+ except sqlite3.Error as exc:
900
+ return 500, {"ok": False, "error": f"workspace_lookup_failed: {exc}"}
901
+ finally:
902
+ conn.close()
903
+
904
+
905
+ def _patch_workspace(workspace_id: str, body: dict[str, Any], user_id: str) -> tuple[int, dict[str, Any]]:
906
+ workspace_id = str(workspace_id or "").strip()
907
+ user_id = str(user_id or "").strip()
908
+ if not workspace_id:
909
+ return 400, {"ok": False, "error": "workspace_id required"}
910
+ if not user_id:
911
+ return 401, {"ok": False, "error": "no_session"}
912
+ allowed = {"display_name", "description", "avatar_url", "tagline"}
913
+ updates = {key: str(body[key]).strip() for key in ("display_name", "description", "avatar_url") if key in body}
914
+ tagline = str(body.get("tagline") or "").strip() if "tagline" in body else None
915
+ if not updates and tagline is None:
916
+ return 400, {"ok": False, "error": "no_allowed_fields", "allowed": list(allowed)}
917
+ if "avatar_url" in updates:
918
+ _srv()._validate_me_profile_updates({"avatar_url": updates["avatar_url"]})
919
+ updates["avatar_url"] = _srv()._normalize_logo_url(updates["avatar_url"])
920
+ if "display_name" in updates and not updates["display_name"]:
921
+ return 400, {"ok": False, "error": "display_name required"}
922
+ try:
923
+ conn = _srv()._workframe_db()
924
+ except sqlite3.Error as exc:
925
+ return 500, {"ok": False, "error": f"workframe_db_unavailable: {exc}"}
926
+ try:
927
+ if not _workspace_exists(conn, workspace_id):
928
+ return 404, {"ok": False, "error": "workspace_not_found", "workspace_id": workspace_id}
929
+ role = _resolve_workspace_integrations_role(conn, workspace_id, user_id, {})
930
+ if not role or (
931
+ role not in OWNER_ADMIN_ROLES and not _srv()._install_window_open()
932
+ ):
933
+ return 403, {"ok": False, "error": "forbidden", "required_role": "owner_or_admin"}
934
+ member_role = str(role)
935
+ row = conn.execute(
936
+ "SELECT * FROM workspaces WHERE id = ? AND deleted_at IS NULL",
937
+ (workspace_id,),
938
+ ).fetchone()
939
+ if not row:
940
+ return 404, {"ok": False, "error": "workspace_not_found", "workspace_id": workspace_id}
941
+ sets = [f"{key} = ?" for key in updates]
942
+ vals = list(updates.values())
943
+ now_ts = str(int(time.time()))
944
+ if tagline is not None:
945
+ settings = _srv()._parse_workspace_settings(row)
946
+ settings["tagline"] = tagline
947
+ sets.append("settings_json = ?")
948
+ vals.append(json.dumps(settings, sort_keys=True))
949
+ sets.append("updated_at = ?")
950
+ vals.extend([now_ts, workspace_id])
951
+ conn.execute(f"UPDATE workspaces SET {', '.join(sets)} WHERE id = ?", vals)
952
+ _srv()._sync_workspace_home_room(conn, workspace_id)
953
+ conn.commit()
954
+ row = conn.execute(
955
+ "SELECT * FROM workspaces WHERE id = ? AND deleted_at IS NULL",
956
+ (workspace_id,),
957
+ ).fetchone()
958
+ if not row:
959
+ return 500, {"ok": False, "error": "workspace_lookup_failed"}
960
+ return 200, {"ok": True, "workspace": _workspace_payload(row, viewer_role=member_role)}
961
+ except sqlite3.Error as exc:
962
+ return 500, {"ok": False, "error": f"workspace_update_failed: {exc}"}
963
+ finally:
964
+ conn.close()
965
+
966
+
967
+ def _patch_workspace_integrations(
968
+ workspace_id: str,
969
+ body: dict[str, Any],
970
+ user_id: str,
971
+ ) -> tuple[int, dict[str, Any]]:
972
+ workspace_id = str(workspace_id or "").strip()
973
+ user_id = str(user_id or "").strip()
974
+ if not workspace_id or not user_id:
975
+ return 401, {"ok": False, "error": "no_session"}
976
+ try:
977
+ conn = _srv()._workframe_db()
978
+ except sqlite3.Error as exc:
979
+ return 500, {"ok": False, "error": f"workframe_db_unavailable: {exc}"}
980
+ try:
981
+ if not _workspace_exists(conn, workspace_id):
982
+ return 404, {"ok": False, "error": "workspace_not_found"}
983
+ role = _resolve_workspace_integrations_role(conn, workspace_id, user_id, body)
984
+ if role not in OWNER_ADMIN_ROLES and not (
985
+ role and _srv()._install_window_open() and set(body.keys()) <= _ONBOARDING_PROGRESS_KEYS
986
+ ):
987
+ return 403, {"ok": False, "error": "forbidden", "required_role": "owner_or_admin"}
988
+ row = conn.execute(
989
+ "SELECT * FROM workspaces WHERE id = ? AND deleted_at IS NULL",
990
+ (workspace_id,),
991
+ ).fetchone()
992
+ if not row:
993
+ return 404, {"ok": False, "error": "workspace_not_found"}
994
+ settings = _srv()._parse_workspace_settings(row)
995
+ gh = settings.get("github_oauth") if isinstance(settings.get("github_oauth"), dict) else {}
996
+ if "github_oauth_client_id" in body:
997
+ gh["client_id"] = str(body.get("github_oauth_client_id") or "").strip()
998
+ if "github_oauth_client_secret" in body:
999
+ secret = str(body.get("github_oauth_client_secret") or "").strip()
1000
+ if secret:
1001
+ gh["client_secret"] = secret
1002
+ if gh:
1003
+ settings["github_oauth"] = gh
1004
+ if "credential_mode" in body:
1005
+ mode = str(body.get("credential_mode") or "byok").strip().lower()
1006
+ if mode not in {"byok", "workspace"}:
1007
+ return 400, {"ok": False, "error": "invalid_credential_mode"}
1008
+ settings["credential_mode"] = mode
1009
+ if body.get("admin_onboarding_done") is True:
1010
+ settings["admin_onboarding_done"] = True
1011
+ if body.get("admin_integrations_done") is True:
1012
+ settings["admin_integrations_done"] = True
1013
+ settings = _srv()._parse_messaging_settings_patch(body, settings)
1014
+ now_ts = str(int(time.time()))
1015
+ conn.execute(
1016
+ "UPDATE workspaces SET settings_json = ?, updated_at = ? WHERE id = ?",
1017
+ (json.dumps(settings, sort_keys=True), now_ts, workspace_id),
1018
+ )
1019
+ conn.commit()
1020
+ if "messaging" in body:
1021
+ sync_result = _srv()._sync_workspace_messaging_gateway(workspace_id)
1022
+ if not sync_result.get("ok"):
1023
+ return 500, {"ok": False, "error": sync_result.get("error") or "messaging_sync_failed"}
1024
+ updated = conn.execute(
1025
+ "SELECT * FROM workspaces WHERE id = ? AND deleted_at IS NULL",
1026
+ (workspace_id,),
1027
+ ).fetchone()
1028
+ if not updated:
1029
+ return 500, {"ok": False, "error": "workspace_lookup_failed"}
1030
+ return 200, {"ok": True, "workspace": _workspace_payload(updated, viewer_role=role or "")}
1031
+ except sqlite3.Error as exc:
1032
+ return 500, {"ok": False, "error": f"workspace_integrations_update_failed: {exc}"}
1033
+ finally:
1034
+ conn.close()
1035
+
1036
+
1037
+ def _get_room(room_id: str) -> tuple[int, dict[str, Any]]:
1038
+ room_id = str(room_id or "").strip()
1039
+ if not room_id:
1040
+ return 400, {"ok": False, "error": "room_id required"}
1041
+ try:
1042
+ conn = _srv()._workframe_db()
1043
+ except sqlite3.Error as exc:
1044
+ return 500, {"ok": False, "error": f"workframe_db_unavailable: {exc}"}
1045
+ try:
1046
+ row = conn.execute(
1047
+ """
1048
+ SELECT id, workspace_id, agent_profile_id, name, slug, topic, avatar_url, room_type,
1049
+ platform_ids, status, created_by, created_at, updated_at
1050
+ FROM rooms
1051
+ WHERE id = ? AND deleted_at IS NULL
1052
+ """,
1053
+ (room_id,),
1054
+ ).fetchone()
1055
+ if not row:
1056
+ return 404, {"ok": False, "error": "room_not_found", "room_id": room_id}
1057
+ return 200, {"ok": True, "room": _room_api_payload(conn, row)}
1058
+ except sqlite3.Error as exc:
1059
+ return 500, {"ok": False, "error": f"room_lookup_failed: {exc}"}
1060
+ finally:
1061
+ conn.close()
1062
+
1063
+
1064
+ def _create_room(workspace_id: str, body: dict[str, Any], created_by: str) -> tuple[int, dict[str, Any]]:
1065
+ workspace_id = str(workspace_id or "").strip()
1066
+ if not workspace_id:
1067
+ return 400, {"ok": False, "error": "workspace_id required"}
1068
+ name = str(body.get("name", "") or "").strip()
1069
+ if not name:
1070
+ return 400, {"ok": False, "error": "name required"}
1071
+
1072
+ room_type = str(body.get("room_type", "channel") or "channel").strip() or "channel"
1073
+ if room_type not in {"lane", "group", "direct", "channel"}:
1074
+ return 400, {"ok": False, "error": "invalid_room_type", "allowed": ["lane", "group", "direct", "channel"]}
1075
+
1076
+ topic = str(body.get("topic", "") or "").strip()
1077
+ agent_profile_id = str(body.get("agent_profile_id", "") or "").strip() or None
1078
+ status = str(body.get("status", "active") or "active").strip() or "active"
1079
+ if status not in {"active", "archived", "locked"}:
1080
+ return 400, {"ok": False, "error": "invalid_room_status", "allowed": ["active", "archived", "locked"]}
1081
+
1082
+ member_user_ids = body.get("member_user_ids", [])
1083
+ if member_user_ids in (None, ""):
1084
+ member_user_ids = []
1085
+ if not isinstance(member_user_ids, list):
1086
+ return 400, {"ok": False, "error": "member_user_ids must be a list"}
1087
+ member_user_ids = [str(user_id).strip() for user_id in member_user_ids if str(user_id).strip()]
1088
+
1089
+ try:
1090
+ platform_ids = _parse_room_platform_ids(body.get("platform_ids", {}))
1091
+ except ValueError as exc:
1092
+ return 400, {"ok": False, "error": str(exc)}
1093
+
1094
+ base_slug = _normalize_room_slug(str(body.get("slug", "") or ""), name)
1095
+ rid = str(uuid.uuid4())
1096
+ now_ts = str(int(time.time()))
1097
+ try:
1098
+ conn = _srv()._workframe_db()
1099
+ except sqlite3.Error as exc:
1100
+ return 500, {"ok": False, "error": f"workframe_db_unavailable: {exc}"}
1101
+ try:
1102
+ if not _workspace_exists(conn, workspace_id):
1103
+ return 404, {"ok": False, "error": "workspace_not_found", "workspace_id": workspace_id}
1104
+ if agent_profile_id and not _agent_profile_belongs(conn, agent_profile_id, workspace_id):
1105
+ return 400, {"ok": False, "error": "agent_profile_not_found", "agent_profile_id": agent_profile_id}
1106
+ agent_row = (
1107
+ _srv()._lookup_agent_profile(conn, workspace_id, agent_profile_id) if agent_profile_id else None
1108
+ )
1109
+ agent_profile_id = str(agent_row["id"]) if agent_row else agent_profile_id
1110
+
1111
+ direct_member_ids = sorted({user_id for user_id in member_user_ids if user_id})
1112
+ if room_type == 'direct':
1113
+ existing_row = None
1114
+ if agent_profile_id and direct_member_ids:
1115
+ existing_row = conn.execute(
1116
+ """
1117
+ SELECT id, workspace_id, agent_profile_id, name, slug, topic, avatar_url, room_type,
1118
+ platform_ids, status, created_by, created_at, updated_at
1119
+ FROM rooms
1120
+ WHERE workspace_id = ?
1121
+ AND room_type = 'direct'
1122
+ AND deleted_at IS NULL
1123
+ AND agent_profile_id = ?
1124
+ AND EXISTS (
1125
+ SELECT 1 FROM room_memberships rm
1126
+ WHERE rm.room_id = rooms.id
1127
+ AND rm.deleted_at IS NULL
1128
+ AND rm.user_id = ?
1129
+ )
1130
+ LIMIT 1
1131
+ """,
1132
+ (workspace_id, agent_profile_id, direct_member_ids[0]),
1133
+ ).fetchone()
1134
+ elif len(direct_member_ids) == 2:
1135
+ left_id, right_id = direct_member_ids
1136
+ existing_row = conn.execute(
1137
+ """
1138
+ SELECT id, workspace_id, agent_profile_id, name, slug, topic, avatar_url, room_type,
1139
+ platform_ids, status, created_by, created_at, updated_at
1140
+ FROM rooms
1141
+ WHERE workspace_id = ?
1142
+ AND room_type = 'direct'
1143
+ AND deleted_at IS NULL
1144
+ AND agent_profile_id IS NULL
1145
+ AND EXISTS (
1146
+ SELECT 1 FROM room_memberships rm
1147
+ WHERE rm.room_id = rooms.id
1148
+ AND rm.deleted_at IS NULL
1149
+ AND rm.user_id = ?
1150
+ )
1151
+ AND EXISTS (
1152
+ SELECT 1 FROM room_memberships rm
1153
+ WHERE rm.room_id = rooms.id
1154
+ AND rm.deleted_at IS NULL
1155
+ AND rm.user_id = ?
1156
+ )
1157
+ AND NOT EXISTS (
1158
+ SELECT 1 FROM room_memberships rm
1159
+ WHERE rm.room_id = rooms.id
1160
+ AND rm.deleted_at IS NULL
1161
+ AND rm.user_id NOT IN (?, ?)
1162
+ )
1163
+ LIMIT 1
1164
+ """,
1165
+ (workspace_id, left_id, right_id, left_id, right_id),
1166
+ ).fetchone()
1167
+ if existing_row:
1168
+ return 200, {"ok": True, "room": _room_api_payload(conn, existing_row)}
1169
+
1170
+ slug = base_slug
1171
+ if room_type == 'direct' and direct_member_ids:
1172
+ if agent_profile_id and len(direct_member_ids) == 1:
1173
+ slug = _normalize_room_slug(f"dm-{direct_member_ids[0]}-{agent_profile_id}", name)
1174
+ elif len(direct_member_ids) == 2:
1175
+ slug = _normalize_room_slug(f"dm-{direct_member_ids[0]}-{direct_member_ids[1]}", name)
1176
+ for attempt in range(1, 26):
1177
+ if _room_slug_available(conn, workspace_id, slug):
1178
+ break
1179
+ slug = f"{base_slug}-{attempt}"
1180
+ else:
1181
+ return 409, {"ok": False, "error": "room_slug_conflict", "slug": base_slug}
1182
+
1183
+ platform_json = json.dumps(platform_ids, sort_keys=True)
1184
+ explicit_avatar = str(body.get("avatar_url", "") or "").strip()
1185
+ if explicit_avatar:
1186
+ explicit_avatar = _srv()._normalize_logo_url(explicit_avatar)
1187
+ room_avatar_url = explicit_avatar or (
1188
+ _srv()._pick_logo_url() if _srv()._is_space_room(room_type, agent_profile_id) else ""
1189
+ )
1190
+ conn.execute(
1191
+ """
1192
+ INSERT INTO rooms (
1193
+ id, workspace_id, agent_profile_id, name, slug, topic, avatar_url, room_type,
1194
+ platform_ids, status, created_by, created_at, updated_at
1195
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
1196
+ """,
1197
+ (
1198
+ rid,
1199
+ workspace_id,
1200
+ agent_profile_id,
1201
+ name,
1202
+ slug,
1203
+ topic,
1204
+ room_avatar_url or None,
1205
+ room_type,
1206
+ platform_json,
1207
+ status,
1208
+ created_by or None,
1209
+ now_ts,
1210
+ now_ts,
1211
+ ),
1212
+ )
1213
+ if created_by:
1214
+ _ensure_user_in_room(conn, rid, created_by)
1215
+ if member_user_ids:
1216
+ seen: set[str] = set()
1217
+ for user_id in member_user_ids:
1218
+ if user_id in seen:
1219
+ continue
1220
+ seen.add(user_id)
1221
+ membership_id = str(uuid.uuid4())
1222
+ conn.execute(
1223
+ """
1224
+ INSERT INTO room_memberships (id, room_id, user_id, role, status, joined_at, updated_at)
1225
+ VALUES (?,?,?,?,?,?,?)
1226
+ """,
1227
+ (membership_id, rid, user_id, "member", "active", now_ts, now_ts),
1228
+ )
1229
+ if _srv()._is_space_room(room_type, agent_profile_id):
1230
+ agents = conn.execute(
1231
+ "SELECT id FROM agent_profiles WHERE workspace_id = ? AND deleted_at IS NULL",
1232
+ (workspace_id,),
1233
+ ).fetchall()
1234
+ for agent in agents:
1235
+ _srv()._ensure_agent_in_space_room(conn, rid, str(agent["id"]))
1236
+ _srv()._add_workspace_members_to_space_room(conn, workspace_id, rid)
1237
+ elif room_type == "direct" and agent_profile_id and direct_member_ids:
1238
+ _srv()._provision_agent_dm_runtimes(workspace_id, agent_profile_id, direct_member_ids)
1239
+ conn.commit()
1240
+ row = conn.execute(
1241
+ """
1242
+ SELECT id, workspace_id, agent_profile_id, name, slug, topic, avatar_url, room_type,
1243
+ platform_ids, status, created_by, created_at, updated_at
1244
+ FROM rooms
1245
+ WHERE id = ?
1246
+ """,
1247
+ (rid,),
1248
+ ).fetchone()
1249
+ if not row:
1250
+ return 500, {"ok": False, "error": "room_create_failed"}
1251
+ return 201, {"ok": True, "room": _room_api_payload(conn, row)}
1252
+ except sqlite3.IntegrityError as exc:
1253
+ return 409, {"ok": False, "error": "room_already_exists", "detail": str(exc)}
1254
+ except sqlite3.Error as exc:
1255
+ return 500, {"ok": False, "error": f"room_create_failed: {exc}"}
1256
+ finally:
1257
+ conn.close()
1258
+
1259
+
1260
+ def _default_workspace_room(conn: sqlite3.Connection, workspace_id: str) -> sqlite3.Row | None:
1261
+ return conn.execute(
1262
+ """
1263
+ SELECT id, slug, name
1264
+ FROM rooms
1265
+ WHERE workspace_id = ? AND deleted_at IS NULL
1266
+ ORDER BY CASE slug WHEN 'general' THEN 0 ELSE 1 END, created_at ASC
1267
+ LIMIT 1
1268
+ """,
1269
+ (workspace_id,),
1270
+ ).fetchone()
1271
+
1272
+
1273
+ def _ensure_user_in_room(conn: sqlite3.Connection, room_id: str, user_id: str, role: str = "member") -> bool:
1274
+ existing = conn.execute(
1275
+ "SELECT id FROM room_memberships WHERE room_id = ? AND user_id = ? AND deleted_at IS NULL",
1276
+ (room_id, user_id),
1277
+ ).fetchone()
1278
+ if existing:
1279
+ return False
1280
+ now = str(int(time.time()))
1281
+ conn.execute(
1282
+ """
1283
+ INSERT INTO room_memberships (id, room_id, user_id, role, status, joined_at, updated_at)
1284
+ VALUES (?,?,?,?,?,?,?)
1285
+ """,
1286
+ (str(uuid.uuid4()), room_id, user_id, role, "active", now, now),
1287
+ )
1288
+ return True
1289
+
1290
+
1291
+ def _workspace_credential_mode(conn: sqlite3.Connection | None, workspace_id: str) -> str:
1292
+ workspace_id = str(workspace_id or "").strip()
1293
+ if not workspace_id:
1294
+ return "byok"
1295
+ own_conn = False
1296
+ if conn is None:
1297
+ conn = _srv()._workframe_db()
1298
+ own_conn = True
1299
+ try:
1300
+ row = conn.execute(
1301
+ "SELECT settings_json FROM workspaces WHERE id = ? AND deleted_at IS NULL",
1302
+ (workspace_id,),
1303
+ ).fetchone()
1304
+ settings = _srv()._parse_workspace_settings(row) if row else {}
1305
+ mode = str(settings.get("credential_mode") or "byok").strip().lower()
1306
+ return mode if mode in ("byok", "workspace") else "byok"
1307
+ finally:
1308
+ if own_conn:
1309
+ conn.close()
1310
+
1311
+
1312
+ def _user_can_access_room(conn: sqlite3.Connection, room_id: str, user_id: str) -> bool:
1313
+ user_id = str(user_id or "").strip()
1314
+ if not user_id:
1315
+ return False
1316
+ row = conn.execute(
1317
+ """
1318
+ SELECT 1 FROM room_memberships
1319
+ WHERE room_id = ? AND user_id = ? AND deleted_at IS NULL AND status = 'active'
1320
+ LIMIT 1
1321
+ """,
1322
+ (room_id, user_id),
1323
+ ).fetchone()
1324
+ if row is not None:
1325
+ return True
1326
+ # ponytail: repair pre-fix rooms that never added creator membership
1327
+ creator = conn.execute(
1328
+ "SELECT created_by FROM rooms WHERE id = ? AND deleted_at IS NULL",
1329
+ (room_id,),
1330
+ ).fetchone()
1331
+ if creator and str(creator["created_by"] or "") == user_id:
1332
+ _ensure_user_in_room(conn, room_id, user_id)
1333
+ conn.commit()
1334
+ return True
1335
+ return False
1336
+
1337
+
1338
+ def _user_can_manage_room_members(conn: sqlite3.Connection, room_id: str, user_id: str) -> bool:
1339
+ user_id = str(user_id or "").strip()
1340
+ if not user_id or not _user_can_access_room(conn, room_id, user_id):
1341
+ return False
1342
+ row = conn.execute(
1343
+ """
1344
+ SELECT role FROM room_memberships
1345
+ WHERE room_id = ? AND user_id = ? AND deleted_at IS NULL AND status = 'active'
1346
+ LIMIT 1
1347
+ """,
1348
+ (room_id, user_id),
1349
+ ).fetchone()
1350
+ if row and str(row["role"] or "").strip().lower() in {"admin", "owner"}:
1351
+ return True
1352
+ room = conn.execute(
1353
+ "SELECT workspace_id FROM rooms WHERE id = ? AND deleted_at IS NULL",
1354
+ (room_id,),
1355
+ ).fetchone()
1356
+ if room:
1357
+ ws_id = str(room["workspace_id"] or "").strip()
1358
+ if ws_id and _srv()._workspace_member_role(conn, ws_id, user_id) in OWNER_ADMIN_ROLES:
1359
+ return True
1360
+ return False
1361
+
1362
+
1363
+ def _get_active_room_session(
1364
+ conn: sqlite3.Connection,
1365
+ room_id: str,
1366
+ agent_profile_id: str = "",
1367
+ ) -> sqlite3.Row | None:
1368
+ room_id = str(room_id or "").strip()
1369
+ agent_profile_id = str(agent_profile_id or "").strip()
1370
+ if not room_id:
1371
+ return None
1372
+ if agent_profile_id:
1373
+ return conn.execute(
1374
+ """
1375
+ SELECT * FROM room_sessions
1376
+ WHERE room_id = ? AND agent_profile_id = ? AND deleted_at IS NULL AND status = 'active'
1377
+ ORDER BY updated_at DESC
1378
+ LIMIT 1
1379
+ """,
1380
+ (room_id, agent_profile_id),
1381
+ ).fetchone()
1382
+ return conn.execute(
1383
+ """
1384
+ SELECT * FROM room_sessions
1385
+ WHERE room_id = ? AND deleted_at IS NULL AND status = 'active'
1386
+ ORDER BY updated_at DESC
1387
+ LIMIT 1
1388
+ """,
1389
+ (room_id,),
1390
+ ).fetchone()
1391
+
1392
+
1393
+ def _list_room_sessions(
1394
+ conn: sqlite3.Connection,
1395
+ room_id: str,
1396
+ agent_profile_id: str,
1397
+ ) -> list[sqlite3.Row]:
1398
+ room_id = str(room_id or "").strip()
1399
+ agent_profile_id = str(agent_profile_id or "").strip()
1400
+ if not room_id or not agent_profile_id:
1401
+ return []
1402
+ return conn.execute(
1403
+ """
1404
+ SELECT * FROM room_sessions
1405
+ WHERE room_id = ? AND agent_profile_id = ? AND deleted_at IS NULL
1406
+ ORDER BY updated_at DESC, created_at DESC
1407
+ """,
1408
+ (room_id, agent_profile_id),
1409
+ ).fetchall()
1410
+
1411
+
1412
+ def _resolve_latest_room_session(
1413
+ conn: sqlite3.Connection,
1414
+ profile: str,
1415
+ room_id: str,
1416
+ agent_profile_id: str,
1417
+ ) -> sqlite3.Row | None:
1418
+ """Active room_sessions row for this room+agent whose Hermes session still exists."""
1419
+ for row in _list_room_sessions(conn, room_id, agent_profile_id):
1420
+ if str(row["status"] or "").strip() != "active":
1421
+ continue
1422
+ sid = str(row["session_id"] or "").strip()
1423
+ if sid and _session_exists(profile, sid):
1424
+ return row
1425
+ return None
1426
+
1427
+
1428
+ def _archive_room_sessions(
1429
+ conn: sqlite3.Connection,
1430
+ room_id: str,
1431
+ agent_profile_id: str = "",
1432
+ ) -> None:
1433
+ now = str(int(time.time()))
1434
+ room_id = str(room_id or "").strip()
1435
+ agent_profile_id = str(agent_profile_id or "").strip()
1436
+ if agent_profile_id:
1437
+ conn.execute(
1438
+ """
1439
+ UPDATE room_sessions
1440
+ SET status = 'archived', updated_at = ?
1441
+ WHERE room_id = ? AND agent_profile_id = ? AND deleted_at IS NULL AND status = 'active'
1442
+ """,
1443
+ (now, room_id, agent_profile_id),
1444
+ )
1445
+ return
1446
+ conn.execute(
1447
+ """
1448
+ UPDATE room_sessions
1449
+ SET status = 'archived', updated_at = ?
1450
+ WHERE room_id = ? AND deleted_at IS NULL AND status = 'active'
1451
+ """,
1452
+ (now, room_id),
1453
+ )
1454
+
1455
+
1456
+ def _upsert_room_session(
1457
+ conn: sqlite3.Connection,
1458
+ *,
1459
+ room_id: str,
1460
+ agent_profile_id: str,
1461
+ session_id: str,
1462
+ gateway_session_id: str,
1463
+ created_by: str,
1464
+ title: str = "",
1465
+ ) -> None:
1466
+ now = str(int(time.time()))
1467
+ room_id = str(room_id or "").strip()
1468
+ agent_profile_id = str(agent_profile_id or "").strip()
1469
+ session_id = str(session_id or "").strip()
1470
+ prior = conn.execute(
1471
+ """
1472
+ SELECT id, session_id, status FROM room_sessions
1473
+ WHERE room_id = ? AND agent_profile_id = ? AND session_id = ? AND deleted_at IS NULL
1474
+ LIMIT 1
1475
+ """,
1476
+ (room_id, agent_profile_id, session_id),
1477
+ ).fetchone()
1478
+ if prior:
1479
+ _archive_room_sessions(conn, room_id, agent_profile_id)
1480
+ conn.execute(
1481
+ """
1482
+ UPDATE room_sessions
1483
+ SET status = 'active', gateway_session_id = ?, updated_at = ?,
1484
+ title = COALESCE(NULLIF(?, ''), title), created_by = COALESCE(?, created_by)
1485
+ WHERE id = ?
1486
+ """,
1487
+ (gateway_session_id, now, title, created_by or None, prior["id"]),
1488
+ )
1489
+ return
1490
+ _archive_room_sessions(conn, room_id, agent_profile_id)
1491
+ conn.execute(
1492
+ """
1493
+ INSERT INTO room_sessions (
1494
+ id, room_id, agent_profile_id, session_id, gateway_session_id,
1495
+ title, status, created_by, created_at, updated_at
1496
+ ) VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)
1497
+ """,
1498
+ (
1499
+ str(uuid.uuid4()),
1500
+ room_id,
1501
+ agent_profile_id,
1502
+ session_id,
1503
+ gateway_session_id,
1504
+ title,
1505
+ created_by,
1506
+ now,
1507
+ now,
1508
+ ),
1509
+ )
1510
+
1511
+
1512
+ def _room_session_context(
1513
+ conn: sqlite3.Connection,
1514
+ user_id: str,
1515
+ room_id: str,
1516
+ hermes_slug: str,
1517
+ ) -> tuple[sqlite3.Row, str, str]:
1518
+ """Validate access and resolve (room, hermes slug, agent_profiles.id) for session bind."""
1519
+ room_id = str(room_id or "").strip()
1520
+ user_id = str(user_id or "").strip()
1521
+ hermes_slug = str(hermes_slug or "").strip()
1522
+ if not room_id:
1523
+ raise ValueError("room_id required")
1524
+ if not user_id:
1525
+ raise ValueError("authenticated user required")
1526
+ if not hermes_slug:
1527
+ raise ValueError("profile required")
1528
+ if not _user_can_access_room(conn, room_id, user_id):
1529
+ raise ValueError("room_access_denied")
1530
+ room = conn.execute(
1531
+ "SELECT * FROM rooms WHERE id = ? AND deleted_at IS NULL",
1532
+ (room_id,),
1533
+ ).fetchone()
1534
+ if not room:
1535
+ raise ValueError("room_not_found")
1536
+ if _srv()._is_space_room(str(room["room_type"]), None):
1537
+ raise ValueError("room_not_agent_chat")
1538
+ workspace_id = str(room["workspace_id"])
1539
+ agent_row = _srv()._lookup_agent_profile(conn, workspace_id, hermes_slug)
1540
+ if not agent_row:
1541
+ raise ValueError("agent_profile_not_found")
1542
+ agent_db_id = str(agent_row["id"])
1543
+ room_agent_ref = str(room["agent_profile_id"] or "").strip()
1544
+ if room_agent_ref:
1545
+ room_agent = _srv()._lookup_agent_profile(conn, workspace_id, room_agent_ref)
1546
+ if not room_agent or str(room_agent["id"]) != agent_db_id:
1547
+ raise ValueError("profile does not match room agent")
1548
+ elif not _srv()._is_space_room(str(room["room_type"]), room_agent_ref):
1549
+ raise ValueError("room_not_agent_chat")
1550
+ return room, hermes_slug, agent_db_id
1551
+
1552
+
1553
+ def _resolve_agent_room_for_user(
1554
+ conn: sqlite3.Connection,
1555
+ user_id: str,
1556
+ room_id: str,
1557
+ ) -> tuple[sqlite3.Row, str, str]:
1558
+ """Agent DM rooms only — infer Hermes slug from room.agent_profile_id."""
1559
+ room_id = str(room_id or "").strip()
1560
+ user_id = str(user_id or "").strip()
1561
+ if not room_id:
1562
+ raise ValueError("room_id required")
1563
+ if not user_id:
1564
+ raise ValueError("authenticated user required")
1565
+ if not _user_can_access_room(conn, room_id, user_id):
1566
+ raise ValueError("room_access_denied")
1567
+ room = conn.execute(
1568
+ "SELECT * FROM rooms WHERE id = ? AND deleted_at IS NULL",
1569
+ (room_id,),
1570
+ ).fetchone()
1571
+ if not room:
1572
+ raise ValueError("room_not_found")
1573
+ agent_ref = str(room["agent_profile_id"] or "").strip()
1574
+ if not agent_ref:
1575
+ raise ValueError("room_not_agent_chat")
1576
+ agent_row = _srv()._lookup_agent_profile(conn, str(room["workspace_id"]), agent_ref)
1577
+ if not agent_row:
1578
+ raise ValueError("agent_profile_not_found")
1579
+ return room, str(agent_row["slug"]), str(agent_row["id"])
1580
+
1581
+
1582
+ def _resolve_room_agent_chat(
1583
+ conn: sqlite3.Connection,
1584
+ user_id: str,
1585
+ room_id: str,
1586
+ ) -> tuple[sqlite3.Row, str, str, str, str]:
1587
+ """Agent DM bind context from room SSOT — (room, template, runtime, agent_db_id, workspace_id)."""
1588
+ user_id = str(user_id or "").strip()
1589
+ room_id = str(room_id or "").strip()
1590
+ if not user_id or not room_id:
1591
+ raise ValueError("room_id and user required")
1592
+ if not _user_can_access_room(conn, room_id, user_id):
1593
+ raise ValueError("room_access_denied")
1594
+ room = conn.execute(
1595
+ "SELECT * FROM rooms WHERE id = ? AND deleted_at IS NULL",
1596
+ (room_id,),
1597
+ ).fetchone()
1598
+ if not room:
1599
+ raise ValueError("room_not_found")
1600
+ if _srv()._is_space_room(str(room["room_type"]), room["agent_profile_id"]):
1601
+ raise ValueError("room_not_agent_chat")
1602
+ agent_ref = str(room["agent_profile_id"] or "").strip()
1603
+ if str(room["room_type"]) != "direct" or not agent_ref:
1604
+ raise ValueError("room_not_agent_chat")
1605
+ workspace_id = str(room["workspace_id"])
1606
+ agent_row = _srv()._lookup_agent_profile(conn, workspace_id, agent_ref)
1607
+ if not agent_row:
1608
+ raise ValueError("agent_profile_not_found")
1609
+ template = _srv().resolve_validated_profile(str(agent_row["slug"]))
1610
+ runtime = _srv()._resolve_chat_hermes_profile(template, user_id, room_id, workspace_id)
1611
+ if not _srv()._runtime_profile_on_disk(runtime) and runtime != template:
1612
+ raise ValueError("runtime_profile_not_provisioned")
1613
+ return room, template, runtime, str(agent_row["id"]), workspace_id