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.
- package/README.md +1 -1
- package/bin/create-workframe.js +2 -2
- package/bin/workframe.js +130 -2
- package/docs/security.md +85 -0
- package/package.json +1 -1
- package/scripts/bundle-workframe-ui.mjs +9 -0
- package/scripts/sync-canonical-to-package.mjs +9 -0
- package/scripts/verify-public-deploy.sh +121 -3
- package/workframe-api/action_proxy.py +23 -17
- package/workframe-api/activity_feed.py +798 -0
- package/workframe-api/api_meta.py +157 -0
- package/workframe-api/auth_gate.py +566 -0
- package/workframe-api/avatar_registry.py +343 -0
- package/workframe-api/broker_audit.py +164 -0
- package/workframe-api/cell_authority.py +231 -0
- package/workframe-api/chat_bind.py +319 -0
- package/workframe-api/chat_sessions.py +509 -0
- package/workframe-api/chat_stream.py +694 -0
- package/workframe-api/cleanup_dogfood_smoke.py +252 -0
- package/workframe-api/credential_broker.py +162 -0
- package/workframe-api/credential_resolve.py +202 -0
- package/workframe-api/credential_store.py +245 -0
- package/workframe-api/crew_registry.py +250 -0
- package/workframe-api/db_schema.py +755 -0
- package/workframe-api/docker_gateway.py +166 -0
- package/workframe-api/doctor_runtime.py +109 -0
- package/workframe-api/domain/__init__.py +47 -0
- package/workframe-api/domain/entities.py +252 -0
- package/workframe-api/domain/schema/workframe-domain.schema.json +278 -0
- package/workframe-api/egress_policy.py +42 -0
- package/workframe-api/handler_modules/__init__.py +17 -0
- package/workframe-api/handler_modules/handler_admin.py +542 -0
- package/workframe-api/handler_modules/handler_auth.py +512 -0
- package/workframe-api/handler_modules/handler_chat.py +641 -0
- package/workframe-api/handler_modules/handler_install.py +178 -0
- package/workframe-api/handler_modules/handler_provider.py +325 -0
- package/workframe-api/handler_modules/handler_workspace.py +1420 -0
- package/workframe-api/health_monitor.py +80 -0
- package/workframe-api/hermes_admin.py +756 -0
- package/workframe-api/hermes_profiles.py +1328 -0
- package/workframe-api/install_api.py +123 -0
- package/workframe-api/kanban_cron.py +473 -0
- package/workframe-api/lane_bindings.py +423 -0
- package/workframe-api/llm_proxy.py +17 -43
- package/workframe-api/mention_helpers.py +180 -0
- package/workframe-api/mention_invoke.py +431 -0
- package/workframe-api/model_surface.py +1139 -0
- package/workframe-api/oauth_pending.py +85 -0
- package/workframe-api/oauth_redirect.py +381 -0
- package/workframe-api/openrouter_catalog.py +4 -4
- package/workframe-api/package.json +2 -2
- package/workframe-api/profile_api_lifecycle.py +66 -0
- package/workframe-api/profile_config_yaml.py +126 -0
- package/workframe-api/profile_gateway.py +436 -0
- package/workframe-api/provider_bindings.py +673 -0
- package/workframe-api/provider_bootstrap.py +347 -0
- package/workframe-api/provider_catalog.py +180 -0
- package/workframe-api/rooms.py +1613 -0
- package/workframe-api/route_registry.py +470 -0
- package/workframe-api/run-typecheck.mjs +26 -0
- package/workframe-api/run_authority.py +287 -0
- package/workframe-api/run_ledger.py +470 -0
- package/workframe-api/run_surface_wiring.py +344 -0
- package/workframe-api/runtime_cohort.py +752 -0
- package/workframe-api/runtime_tokens.py +127 -0
- package/workframe-api/server.py +1376 -19305
- package/workframe-api/snapshot_feed.py +49 -0
- package/workframe-api/stack_config.py +33 -1
- package/workframe-api/supervisor_client.py +154 -0
- package/workframe-api/test_api_meta_build_stamp.py +34 -0
- package/workframe-api/test_cell_authority.py +79 -0
- package/workframe-api/test_chat_bind.py +48 -0
- package/workframe-api/test_credential_lease_matrix.py +171 -0
- package/workframe-api/test_credential_resolve.py +51 -0
- package/workframe-api/test_credential_store.py +27 -0
- package/workframe-api/test_dogfood_flows.py +346 -0
- package/workframe-api/test_domain_entities.py +152 -0
- package/workframe-api/test_exception_hygiene.py +12 -0
- package/workframe-api/test_hermes_admin.py +72 -0
- package/workframe-api/test_install_wizard_resume.py +78 -0
- package/workframe-api/test_local_bootstrap.py +67 -0
- package/workframe-api/test_mention_helpers.py +35 -0
- package/workframe-api/test_mention_invoke.py +26 -0
- package/workframe-api/test_model_surface_consistency.py +117 -0
- package/workframe-api/test_oauth_pending.py +41 -0
- package/workframe-api/test_profile_model_yaml.py +99 -0
- package/workframe-api/test_provider_bindings.py +52 -0
- package/workframe-api/test_provider_catalog.py +28 -0
- package/workframe-api/test_public_routes.py +41 -0
- package/workframe-api/test_route_registry.py +245 -0
- package/workframe-api/test_run_authority.py +112 -0
- package/workframe-api/test_run_ledger.py +157 -0
- package/workframe-api/test_run_surface_wiring.py +130 -0
- package/workframe-api/test_runtime_cohort.py +85 -0
- package/workframe-api/test_secure_mode_docker_boundary.py +39 -0
- package/workframe-api/test_server_reexports.py +99 -0
- package/workframe-api/test_stack_config_install_smtp.py +7 -0
- package/workframe-api/time-bind-steps.py +75 -0
- package/workframe-api/time-perf.py +81 -0
- package/workframe-api/time-warm.py +107 -0
- package/workframe-api/turn_credentials.py +28 -13
- package/workframe-api/turn_overlay.py +715 -0
- package/workframe-api/updates.py +16 -1
- package/workframe-api/user_prefs.py +387 -0
- package/workframe-api/wf032_extract_remaining_handlers.py +417 -0
- package/workframe-api/workspace_bootstrap.py +536 -0
- package/workframe-api/workspace_files.py +344 -0
- package/workframe-api/workspace_messaging.py +206 -0
- package/workframe-api/zk_auth.py +3 -2
- package/workframe-supervisor/server.py +10 -1
- package/workframe-supervisor/test_supervisor_negative.py +75 -0
- package/workframe-ui/public/assets/{arc-DjpS9a2b.js → arc-aUUffclm.js} +1 -1
- package/workframe-ui/public/assets/architecture-7EHR7CIX-BSUP4S8J.js +1 -0
- package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-puCKmKHV.js → architectureDiagram-3BPJPVTR-DU6oaqIb.js} +1 -1
- package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-C2P0g-eH.js → blockDiagram-GPEHLZMM-D2p8BU6-.js} +1 -1
- package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-DdvYEITH.js → c4Diagram-AAUBKEIU-ouwyPmTJ.js} +1 -1
- package/workframe-ui/public/assets/channel-DOunZZ0X.js +1 -0
- package/workframe-ui/public/assets/{chunk-2J33WTMH-Cxe8QsEF.js → chunk-2J33WTMH-DsE7U5dj.js} +1 -1
- package/workframe-ui/public/assets/{chunk-3OPIFGDE-DPNyTwR3.js → chunk-3OPIFGDE-DM0kkZ9h.js} +1 -1
- package/workframe-ui/public/assets/{chunk-4BX2VUAB-XKG9gfRN.js → chunk-4BX2VUAB-CQ8mAyXp.js} +1 -1
- package/workframe-ui/public/assets/{chunk-55IACEB6-oPMTulgb.js → chunk-55IACEB6-DN1BDtbH.js} +1 -1
- package/workframe-ui/public/assets/{chunk-5ZQYHXKU-BWwXvpa_.js → chunk-5ZQYHXKU-BzK2KqOt.js} +1 -1
- package/workframe-ui/public/assets/{chunk-727SXJPM-Cml2twO_.js → chunk-727SXJPM-C3AxtOi9.js} +1 -1
- package/workframe-ui/public/assets/{chunk-AQP2D5EJ-DzrRBhQu.js → chunk-AQP2D5EJ-4mVwEFuD.js} +1 -1
- package/workframe-ui/public/assets/{chunk-BSJP7CBP-EZ7STU6h.js → chunk-BSJP7CBP-23kN2q0A.js} +1 -1
- package/workframe-ui/public/assets/{chunk-CSCIHK7Q-BGTQzWYw.js → chunk-CSCIHK7Q-CPBKVW-L.js} +2 -2
- package/workframe-ui/public/assets/{chunk-FMBD7UC4-Cm4stfRa.js → chunk-FMBD7UC4-CKqcYq7K.js} +1 -1
- package/workframe-ui/public/assets/{chunk-KSCS5N6A-exbMijjy.js → chunk-KSCS5N6A-JRQnhxQE.js} +1 -1
- package/workframe-ui/public/assets/{chunk-L5ZTLDWV-C75kbQwN.js → chunk-L5ZTLDWV-BLZrdIwa.js} +1 -1
- package/workframe-ui/public/assets/chunk-LZXEDZCA-DisG0XJT.js +2 -0
- package/workframe-ui/public/assets/{chunk-ND2GUHAM-D84zyIu2.js → chunk-ND2GUHAM-ClAOxArS.js} +1 -1
- package/workframe-ui/public/assets/{chunk-NZK2D7GU-CUNNo1bj.js → chunk-NZK2D7GU-XUE1aNkm.js} +1 -1
- package/workframe-ui/public/assets/{chunk-O5CBEL6O-DN6WEIh-.js → chunk-O5CBEL6O-DDN-Ifon.js} +1 -1
- package/workframe-ui/public/assets/chunk-QZHKN3VN-EVQ0VMd5.js +1 -0
- package/workframe-ui/public/assets/chunk-WU5MYG2G-k_-ZsLOS.js +1 -0
- package/workframe-ui/public/assets/{chunk-XPW4576I-Ct5VQv2D.js → chunk-XPW4576I-B4_iYZ6r.js} +1 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-W9WVQsby.js +1 -0
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-W9WVQsby.js +1 -0
- package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-Bj4W5oMk.js → cose-bilkent-S5V4N54A-Cy1HhS7j.js} +1 -1
- package/workframe-ui/public/assets/{dagre-BM42HDAG-DAjHBkAy.js → dagre-BM42HDAG-Bjal81Ie.js} +1 -1
- package/workframe-ui/public/assets/{diagram-2AECGRRQ-Ch2gGFPG.js → diagram-2AECGRRQ-v5Gdvzp_.js} +1 -1
- package/workframe-ui/public/assets/{diagram-5GNKFQAL-BzIIvgYH.js → diagram-5GNKFQAL-DuyRdfmY.js} +1 -1
- package/workframe-ui/public/assets/{diagram-KO2AKTUF-Df0Q8nv6.js → diagram-KO2AKTUF-WwWCGbIW.js} +1 -1
- package/workframe-ui/public/assets/{diagram-LMA3HP47-D-G3ekmF.js → diagram-LMA3HP47-DTYwZALT.js} +1 -1
- package/workframe-ui/public/assets/{diagram-OG6HWLK6-TBHhT9f5.js → diagram-OG6HWLK6-B46kBIqE.js} +1 -1
- package/workframe-ui/public/assets/{dist-CPbiDkEZ.js → dist-mQWmB3z6.js} +1 -1
- package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-YxkUmLLV.js → erDiagram-TEJ5UH35-__40xO-y.js} +1 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-Cm1uRTxU.js +1 -0
- package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-kfZ7-RSd.js → flowDiagram-I6XJVG4X-Cwj6dVUz.js} +1 -1
- package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-DmTx3hzR.js → ganttDiagram-6RSMTGT7-D_IOSwAS.js} +1 -1
- package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-BXa0ewp3.js → gitGraph-WXDBUCRP-C1vGecee.js} +1 -1
- package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-CQr2qMxU.js → gitGraphDiagram-PVQCEYII-DMTxFJxn.js} +1 -1
- package/workframe-ui/public/assets/index-BdiM4-lI.js +130 -0
- package/workframe-ui/public/assets/index-Fnad47vV.css +1 -0
- package/workframe-ui/public/assets/{info-J43DQDTF-t-Qlx8zv.js → info-J43DQDTF-CwjmyPmD.js} +1 -1
- package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-CzkYofPx.js → infoDiagram-5YYISTIA-BGzh7ZL2.js} +1 -1
- package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-djK3Oxgb.js → ishikawaDiagram-YF4QCWOH-BLXdnR_5.js} +1 -1
- package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-CWxYXxdF.js → journeyDiagram-JHISSGLW-BK3behMe.js} +1 -1
- package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-D7leCAD9.js → kanban-definition-UN3LZRKU-Bx172cCl.js} +1 -1
- package/workframe-ui/public/assets/{line-BSFj2OC5.js → line-BTCEHb7l.js} +1 -1
- package/workframe-ui/public/assets/{linear-pfPwnu-o.js → linear-CkbydpnK.js} +1 -1
- package/workframe-ui/public/assets/{mermaid-parser.core-Dm2L9Ib_.js → mermaid-parser.core-DvrtArpk.js} +2 -2
- package/workframe-ui/public/assets/{mermaid.core-Bevx9bif.js → mermaid.core-yj_1x_qj.js} +3 -3
- package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-UkjDssmP.js → mindmap-definition-RKZ34NQL-BXbMltQ0.js} +1 -1
- package/workframe-ui/public/assets/{packet-YPE3B663-JO2ezH3_.js → packet-YPE3B663-9pVCBQ7S.js} +1 -1
- package/workframe-ui/public/assets/{pie-LRSECV5Y-DdMFsfR8.js → pie-LRSECV5Y-CdqoWesP.js} +1 -1
- package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-CEPHFBU1.js → pieDiagram-4H26LBE5-kGs-VfXd.js} +1 -1
- package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-_RF9_86k.js → quadrantDiagram-W4KKPZXB-D9D8gQw5.js} +1 -1
- package/workframe-ui/public/assets/{radar-GUYGQ44K-Cb-YfdaA.js → radar-GUYGQ44K-d4zMZpQS.js} +1 -1
- package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-DBVOxmBn.js → requirementDiagram-4Y6WPE33-BI7EQ9zc.js} +1 -1
- package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-BTpxsGUA.js → sankeyDiagram-5OEKKPKP-Bcz19jQB.js} +1 -1
- package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-DHP_Bq3l.js → sequenceDiagram-3UESZ5HK-iQiCEfYp.js} +1 -1
- package/workframe-ui/public/assets/{src-CY_By-r7.js → src-D6mvlP96.js} +1 -1
- package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BVtp_LPR.js → stateDiagram-AJRCARHV-CxaTXs02.js} +1 -1
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CXikj9xi.js +1 -0
- package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-D38HJhop.js → timeline-definition-PNZ67QCA-Bej-EAnf.js} +1 -1
- package/workframe-ui/public/assets/{treeView-BLDUP644-CFCG3Sc_.js → treeView-BLDUP644-C7PnnzwX.js} +1 -1
- package/workframe-ui/public/assets/{treemap-LRROVOQU-BeiMs_dF.js → treemap-LRROVOQU-CqISLmkO.js} +1 -1
- package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-CKMeT6PW.js → vennDiagram-CIIHVFJN-eJMTTEW-.js} +1 -1
- package/workframe-ui/public/assets/{wardley-L42UT6IY-BljDy8ph.js → wardley-L42UT6IY-D4sMroBF.js} +1 -1
- package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DEE8D94J.js → wardleyDiagram-YWT4CUSO-DC3DCUs7.js} +1 -1
- package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-BRaAWV_g.js → xychartDiagram-2RQKCTM6-BeMaM6Aq.js} +1 -1
- package/workframe-ui/public/index.html +7 -6
- package/workframe-ui/public/workframe-build.json +5 -0
- package/workframe-ui/public/assets/architecture-7EHR7CIX-CvhbXEvt.js +0 -1
- package/workframe-ui/public/assets/channel-D63s_yN9.js +0 -1
- package/workframe-ui/public/assets/chunk-LZXEDZCA-CsRdBqUB.js +0 -2
- package/workframe-ui/public/assets/chunk-QZHKN3VN-Dj_Yxhgo.js +0 -1
- package/workframe-ui/public/assets/chunk-WU5MYG2G-AFchshb6.js +0 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-Opj8t01I.js +0 -1
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-Opj8t01I.js +0 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-LQOYL50n.js +0 -1
- package/workframe-ui/public/assets/index-B08aShJy.css +0 -1
- package/workframe-ui/public/assets/index-DCF3W3G_.js +0 -129
- 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
|