create-workframe 0.1.12 → 0.1.14
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 +2 -2
- package/package.json +1 -1
- package/scripts/bundle-workframe-ui.mjs +9 -0
- package/scripts/sync-canonical-to-package.mjs +1 -0
- package/scripts/verify-public-deploy.sh +65 -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/package.json +2 -2
- package/workframe-api/profile_api_lifecycle.py +66 -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 +331 -191
- package/workframe-api/run-typecheck.mjs +2 -1
- 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 +1453 -19513
- 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 +1 -0
- package/workframe-api/test_oauth_pending.py +41 -0
- package/workframe-api/test_provider_bindings.py +52 -0
- package/workframe-api/test_provider_catalog.py +28 -0
- package/workframe-api/test_route_registry.py +171 -35
- 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/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-B0OFRGmJ.js → arc-BT9iZUSd.js} +1 -1
- package/workframe-ui/public/assets/architecture-7EHR7CIX-m1aPUQ_r.js +1 -0
- package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-DeBNltHS.js → architectureDiagram-3BPJPVTR-CaEDd3np.js} +1 -1
- package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-BDhCHu7I.js → blockDiagram-GPEHLZMM-DHJhMrIS.js} +1 -1
- package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-olcDYvtI.js → c4Diagram-AAUBKEIU-Cdyz9hIV.js} +1 -1
- package/workframe-ui/public/assets/channel-3M69ekE5.js +1 -0
- package/workframe-ui/public/assets/{chunk-2J33WTMH-D0obCBn_.js → chunk-2J33WTMH-BbZx76LX.js} +1 -1
- package/workframe-ui/public/assets/{chunk-3OPIFGDE-DX93f-ZZ.js → chunk-3OPIFGDE-DcSxjTIP.js} +1 -1
- package/workframe-ui/public/assets/{chunk-4BX2VUAB-BX9B5wUs.js → chunk-4BX2VUAB-B7F-kTY1.js} +1 -1
- package/workframe-ui/public/assets/{chunk-55IACEB6-C4DGc6RO.js → chunk-55IACEB6-BwwEiRTK.js} +1 -1
- package/workframe-ui/public/assets/{chunk-5ZQYHXKU-Crlja9Lf.js → chunk-5ZQYHXKU-DAi_NksX.js} +1 -1
- package/workframe-ui/public/assets/{chunk-727SXJPM-JYSm3szI.js → chunk-727SXJPM-BIaF4XCN.js} +1 -1
- package/workframe-ui/public/assets/{chunk-AQP2D5EJ-_KslxVEl.js → chunk-AQP2D5EJ-DjYSk1dG.js} +1 -1
- package/workframe-ui/public/assets/{chunk-BSJP7CBP-CpTO-jU8.js → chunk-BSJP7CBP-_twnyfgV.js} +1 -1
- package/workframe-ui/public/assets/{chunk-CSCIHK7Q-JGUkCmbI.js → chunk-CSCIHK7Q-CkNmdy5s.js} +2 -2
- package/workframe-ui/public/assets/{chunk-FMBD7UC4-BscBwGaC.js → chunk-FMBD7UC4-CjLqd-gl.js} +1 -1
- package/workframe-ui/public/assets/{chunk-KSCS5N6A-5ZTZ0bpX.js → chunk-KSCS5N6A-_6oNqFjj.js} +1 -1
- package/workframe-ui/public/assets/{chunk-L5ZTLDWV-DxvitYbW.js → chunk-L5ZTLDWV-CeO9Am_D.js} +1 -1
- package/workframe-ui/public/assets/chunk-LZXEDZCA-sRM4YeIe.js +2 -0
- package/workframe-ui/public/assets/{chunk-ND2GUHAM-Cayl0iV9.js → chunk-ND2GUHAM-CH8tMb21.js} +1 -1
- package/workframe-ui/public/assets/{chunk-NZK2D7GU-_7dyBR5G.js → chunk-NZK2D7GU-DAI1bsII.js} +1 -1
- package/workframe-ui/public/assets/{chunk-O5CBEL6O--xTpD0yi.js → chunk-O5CBEL6O-B2e-5a2G.js} +1 -1
- package/workframe-ui/public/assets/chunk-QZHKN3VN-BMl9ed8P.js +1 -0
- package/workframe-ui/public/assets/chunk-WU5MYG2G-C08HH6BU.js +1 -0
- package/workframe-ui/public/assets/{chunk-XPW4576I-CwxJQaeK.js → chunk-XPW4576I-DM2-0q37.js} +1 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BFcOE4Aq.js +1 -0
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BFcOE4Aq.js +1 -0
- package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-CCspENYv.js → cose-bilkent-S5V4N54A-BnbbewLv.js} +1 -1
- package/workframe-ui/public/assets/{dagre-BM42HDAG-Dv8V6R60.js → dagre-BM42HDAG-C4zmB1Vt.js} +1 -1
- package/workframe-ui/public/assets/{diagram-2AECGRRQ-Da48hFPT.js → diagram-2AECGRRQ-n-BJmsiC.js} +1 -1
- package/workframe-ui/public/assets/{diagram-5GNKFQAL-dTihc7-3.js → diagram-5GNKFQAL-BzQGQnZi.js} +1 -1
- package/workframe-ui/public/assets/{diagram-KO2AKTUF-D_Jqu699.js → diagram-KO2AKTUF-Cx_De-wS.js} +1 -1
- package/workframe-ui/public/assets/{diagram-LMA3HP47-Bt8PtEaZ.js → diagram-LMA3HP47-CIOI3Lok.js} +1 -1
- package/workframe-ui/public/assets/{diagram-OG6HWLK6-BtmPjlh0.js → diagram-OG6HWLK6-DkRuPLWY.js} +1 -1
- package/workframe-ui/public/assets/{dist-Cz2turIT.js → dist-XhCT1Q-V.js} +1 -1
- package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-BxMGCflX.js → erDiagram-TEJ5UH35-C3GwhDU1.js} +1 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-DIVBL6CF.js +1 -0
- package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-ivyroIZt.js → flowDiagram-I6XJVG4X-CLWH8z2e.js} +1 -1
- package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-B9llwShx.js → ganttDiagram-6RSMTGT7-D4h66Vy3.js} +1 -1
- package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-BFQHk4bw.js → gitGraph-WXDBUCRP-IW5F-Q6c.js} +1 -1
- package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-ursegLbc.js → gitGraphDiagram-PVQCEYII-BjNIbmRM.js} +1 -1
- package/workframe-ui/public/assets/index-DL2vJP4L.css +1 -0
- package/workframe-ui/public/assets/index-ElS_D59P.js +130 -0
- package/workframe-ui/public/assets/{info-J43DQDTF-DQpifAsB.js → info-J43DQDTF-CcJspM79.js} +1 -1
- package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-C16XP2Q7.js → infoDiagram-5YYISTIA-DP4xR9jt.js} +1 -1
- package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-CiJoz7rP.js → ishikawaDiagram-YF4QCWOH-D4cZb21V.js} +1 -1
- package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-CVwEvvUL.js → journeyDiagram-JHISSGLW-DsLuHkIJ.js} +1 -1
- package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-CWQ6hX5i.js → kanban-definition-UN3LZRKU-Wu65nOsR.js} +1 -1
- package/workframe-ui/public/assets/{line-CgQsmU35.js → line-ByBDzRpy.js} +1 -1
- package/workframe-ui/public/assets/{linear-Bxbc77dL.js → linear-DzXi2vnq.js} +1 -1
- package/workframe-ui/public/assets/{mermaid-parser.core-C_jMeF0a.js → mermaid-parser.core-CaB-1Ckn.js} +2 -2
- package/workframe-ui/public/assets/{mermaid.core-4RKV9MZP.js → mermaid.core-D7HTHC17.js} +3 -3
- package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-DZ7y7Sz0.js → mindmap-definition-RKZ34NQL-BfKsC9aj.js} +1 -1
- package/workframe-ui/public/assets/{packet-YPE3B663-B9h2I7Vq.js → packet-YPE3B663-ClpU98TO.js} +1 -1
- package/workframe-ui/public/assets/{pie-LRSECV5Y-B2mP5uHm.js → pie-LRSECV5Y-KeOyY_-u.js} +1 -1
- package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-DyYKyKiP.js → pieDiagram-4H26LBE5-CUpjnzbs.js} +1 -1
- package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-CL_6nej-.js → quadrantDiagram-W4KKPZXB-CJdPTszF.js} +1 -1
- package/workframe-ui/public/assets/{radar-GUYGQ44K-CNKNDQ7S.js → radar-GUYGQ44K-C9BpandZ.js} +1 -1
- package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-eWU_mhFa.js → requirementDiagram-4Y6WPE33-CHDZPwry.js} +1 -1
- package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-DrjcXvEQ.js → sankeyDiagram-5OEKKPKP-CMJJh91s.js} +1 -1
- package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-BHWR1xTJ.js → sequenceDiagram-3UESZ5HK-BsV4GppP.js} +1 -1
- package/workframe-ui/public/assets/{src-DfJB8kV1.js → src-WHks82Up.js} +1 -1
- package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BGJbSd3I.js → stateDiagram-AJRCARHV-eSX9P8z0.js} +1 -1
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-FzMK4PkM.js +1 -0
- package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-DkwoCjiZ.js → timeline-definition-PNZ67QCA-Dg1UkhGo.js} +1 -1
- package/workframe-ui/public/assets/{treeView-BLDUP644-GxfhiqoW.js → treeView-BLDUP644-B7yGsBuj.js} +1 -1
- package/workframe-ui/public/assets/{treemap-LRROVOQU-DxrOsars.js → treemap-LRROVOQU-j_G2oyQ_.js} +1 -1
- package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-BsWhpUfr.js → vennDiagram-CIIHVFJN-BRrT0jqC.js} +1 -1
- package/workframe-ui/public/assets/{wardley-L42UT6IY-CrFZWMHS.js → wardley-L42UT6IY-DEdUqez2.js} +1 -1
- package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DV8Xpf5I.js → wardleyDiagram-YWT4CUSO-C-bBK9Bb.js} +1 -1
- package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-DqMqnwdZ.js → xychartDiagram-2RQKCTM6-DhH5xuR2.js} +1 -1
- package/workframe-ui/public/index.html +11 -7
- package/workframe-ui/public/workframe-build.json +5 -0
- package/workframe-ui/public/assets/architecture-7EHR7CIX-Dwwi9yA0.js +0 -1
- package/workframe-ui/public/assets/channel-fNHbDpV4.js +0 -1
- package/workframe-ui/public/assets/chunk-LZXEDZCA-DMfdMUwz.js +0 -2
- package/workframe-ui/public/assets/chunk-QZHKN3VN-ewTgEQK8.js +0 -1
- package/workframe-ui/public/assets/chunk-WU5MYG2G-D4Tg-A0l.js +0 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-CFAgBpEl.js +0 -1
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-CFAgBpEl.js +0 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-BIJTP5k-.js +0 -1
- package/workframe-ui/public/assets/index-8CuZDEIG.css +0 -1
- package/workframe-ui/public/assets/index-xS9lFekI.js +0 -129
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CMLuNyeC.js +0 -1
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
"""WF-032 extract: lane_bindings."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import queue
|
|
7
|
+
import re
|
|
8
|
+
import shlex
|
|
9
|
+
import shutil
|
|
10
|
+
import sqlite3
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from http.server import BaseHTTPRequestHandler
|
|
18
|
+
|
|
19
|
+
import profile_config_yaml
|
|
20
|
+
import user_prefs
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _srv():
|
|
24
|
+
import server as srv
|
|
25
|
+
|
|
26
|
+
return srv
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _load_lane_registry() -> dict[str, Any]:
|
|
30
|
+
path = _srv()._lane_registry_json()
|
|
31
|
+
if not path.is_file():
|
|
32
|
+
return {"profiles": {}}
|
|
33
|
+
try:
|
|
34
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
35
|
+
if isinstance(data, dict) and isinstance(data.get("profiles"), dict):
|
|
36
|
+
return data
|
|
37
|
+
except Exception: # noqa: BLE001
|
|
38
|
+
pass
|
|
39
|
+
return {"profiles": {}}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _save_lane_registry(data: dict[str, Any]) -> None:
|
|
43
|
+
path = _srv()._lane_registry_json()
|
|
44
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
46
|
+
|
|
47
|
+
def _binding_version(raw: Any) -> int:
|
|
48
|
+
try:
|
|
49
|
+
value = int(raw or 0)
|
|
50
|
+
except (TypeError, ValueError):
|
|
51
|
+
return 0
|
|
52
|
+
return value if value > 0 else 0
|
|
53
|
+
|
|
54
|
+
def _binding_row_for(
|
|
55
|
+
profile: str,
|
|
56
|
+
source_id: str,
|
|
57
|
+
client_id: str,
|
|
58
|
+
binding_version: int = 0,
|
|
59
|
+
) -> dict[str, Any] | None:
|
|
60
|
+
prof = _srv().resolve_hermes_profile(profile)
|
|
61
|
+
registry = _load_lane_registry()
|
|
62
|
+
bucket = _registry_profile_bucket(registry, prof)
|
|
63
|
+
bindings = bucket.get("bindings") if isinstance(bucket.get("bindings"), dict) else {}
|
|
64
|
+
binding_key = _source_binding_key(source_id, client_id)
|
|
65
|
+
row = bindings.get(binding_key) if isinstance(bindings, dict) else None
|
|
66
|
+
if not isinstance(row, dict):
|
|
67
|
+
return None
|
|
68
|
+
if binding_version and _binding_version(row.get("binding_version")) != binding_version:
|
|
69
|
+
return None
|
|
70
|
+
session_id = str(row.get("session_id") or "").strip()
|
|
71
|
+
if session_id and not _srv()._session_exists(prof, session_id):
|
|
72
|
+
return None
|
|
73
|
+
return row
|
|
74
|
+
|
|
75
|
+
def _binding_session_for(profile: str, source_id: str, client_id: str, binding_version: int = 0) -> str:
|
|
76
|
+
row = _binding_row_for(profile, source_id, client_id, binding_version)
|
|
77
|
+
return str((row or {}).get("session_id") or "").strip()
|
|
78
|
+
|
|
79
|
+
def profile_chat_session(profile: str, payload: dict[str, Any], user_id: str = "") -> dict[str, Any]:
|
|
80
|
+
source_id = str(payload.get("source_id") or "ui").strip() or "ui"
|
|
81
|
+
client_id = str(payload.get("client_id") or "default").strip() or "default"
|
|
82
|
+
binding_version = _binding_version(payload.get("binding_version"))
|
|
83
|
+
force_new = bool(payload.get("new_session") or False)
|
|
84
|
+
room_id = str(payload.get("room_id") or "").strip()
|
|
85
|
+
user_id = str(user_id or payload.get("user_id") or "").strip()
|
|
86
|
+
workspace_id = str(payload.get("workspace_id") or "").strip()
|
|
87
|
+
if room_id and not workspace_id:
|
|
88
|
+
try:
|
|
89
|
+
conn_ws = _srv()._workframe_db()
|
|
90
|
+
row = conn_ws.execute(
|
|
91
|
+
"SELECT workspace_id FROM rooms WHERE id = ? AND deleted_at IS NULL",
|
|
92
|
+
(room_id,),
|
|
93
|
+
).fetchone()
|
|
94
|
+
conn_ws.close()
|
|
95
|
+
if row:
|
|
96
|
+
workspace_id = str(row["workspace_id"])
|
|
97
|
+
except Exception: # noqa: BLE001
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
hermes_prof = ""
|
|
101
|
+
template_prof = ""
|
|
102
|
+
agent_db_id = ""
|
|
103
|
+
room_conn: sqlite3.Connection | None = None
|
|
104
|
+
|
|
105
|
+
if room_id:
|
|
106
|
+
if not user_id:
|
|
107
|
+
raise ValueError("room_id requires authenticated user")
|
|
108
|
+
room_conn = _srv()._workframe_db()
|
|
109
|
+
try:
|
|
110
|
+
room = room_conn.execute(
|
|
111
|
+
"SELECT * FROM rooms WHERE id = ? AND deleted_at IS NULL",
|
|
112
|
+
(room_id,),
|
|
113
|
+
).fetchone()
|
|
114
|
+
if not room:
|
|
115
|
+
raise ValueError("room_not_found")
|
|
116
|
+
if not _srv()._user_can_access_room(room_conn, room_id, user_id):
|
|
117
|
+
raise ValueError("room_access_denied")
|
|
118
|
+
if not workspace_id:
|
|
119
|
+
workspace_id = str(room["workspace_id"])
|
|
120
|
+
if _srv()._is_space_room(str(room["room_type"]), room["agent_profile_id"]):
|
|
121
|
+
raw_prof = str(profile or _srv()._primary_profile()).strip()
|
|
122
|
+
if _srv()._is_runtime_profile_slug(raw_prof):
|
|
123
|
+
template_prof = _srv().resolve_validated_profile(_srv()._runtime_template_slug(raw_prof))
|
|
124
|
+
hermes_prof = raw_prof
|
|
125
|
+
else:
|
|
126
|
+
template_prof = _srv().resolve_validated_profile(raw_prof)
|
|
127
|
+
hermes_prof = _srv()._resolve_chat_hermes_profile(
|
|
128
|
+
template_prof, user_id, room_id, workspace_id,
|
|
129
|
+
)
|
|
130
|
+
agent_row = _srv()._lookup_agent_profile(room_conn, workspace_id, template_prof)
|
|
131
|
+
if not agent_row:
|
|
132
|
+
raise ValueError("agent_profile_not_found")
|
|
133
|
+
agent_db_id = str(agent_row["id"])
|
|
134
|
+
else:
|
|
135
|
+
_room, template_prof, hermes_prof, agent_db_id, workspace_id = _srv()._resolve_room_agent_chat(
|
|
136
|
+
room_conn, user_id, room_id,
|
|
137
|
+
)
|
|
138
|
+
except Exception:
|
|
139
|
+
if room_conn is not None:
|
|
140
|
+
room_conn.close()
|
|
141
|
+
raise
|
|
142
|
+
else:
|
|
143
|
+
hermes_prof, template_prof = _srv()._resolve_bind_profile_arg(
|
|
144
|
+
profile, user_id, "", workspace_id,
|
|
145
|
+
)
|
|
146
|
+
payer = user_id
|
|
147
|
+
model_block = _srv()._read_model_block(hermes_prof) if hermes_prof else {}
|
|
148
|
+
llm_provider = _srv()._llm_billing_provider(
|
|
149
|
+
hermes_prof, user_id=payer, workspace_id=workspace_id, block=model_block,
|
|
150
|
+
)
|
|
151
|
+
llm_ready = _srv()._user_can_use_llm(payer, workspace_id, llm_provider) if payer else False
|
|
152
|
+
# ponytail: bind = session lookup only; gateway + billing reconcile happen on send/model-save.
|
|
153
|
+
api_port = _srv()._profile_api_port(hermes_prof) if hermes_prof else 0
|
|
154
|
+
lifecycle = {"ok": True, "profile": hermes_prof, "api_port": api_port, "started": False}
|
|
155
|
+
session_extra = {"llm_ready": llm_ready, "has_llm_provider": llm_ready}
|
|
156
|
+
|
|
157
|
+
if room_id:
|
|
158
|
+
conn = room_conn if room_conn is not None else _srv()._workframe_db()
|
|
159
|
+
close_conn = room_conn is None
|
|
160
|
+
try:
|
|
161
|
+
existing_row = (
|
|
162
|
+
None
|
|
163
|
+
if force_new
|
|
164
|
+
else _srv()._get_active_room_session(conn, room_id, agent_db_id)
|
|
165
|
+
)
|
|
166
|
+
if existing_row:
|
|
167
|
+
sid = str(existing_row["session_id"]).strip()
|
|
168
|
+
if not sid:
|
|
169
|
+
existing_row = None
|
|
170
|
+
if existing_row:
|
|
171
|
+
sid = str(existing_row["session_id"]).strip()
|
|
172
|
+
gateway_sid = str(existing_row["gateway_session_id"] or f"api:{hermes_prof}:{sid}").strip()
|
|
173
|
+
session_title = str(existing_row["title"] or _srv()._default_session_title(template_prof))
|
|
174
|
+
if _srv()._is_blank_session_title(str(existing_row["title"] or "")):
|
|
175
|
+
_srv()._ensure_hermes_session_title(hermes_prof, sid, session_title)
|
|
176
|
+
_srv()._upsert_room_session(
|
|
177
|
+
conn,
|
|
178
|
+
room_id=room_id,
|
|
179
|
+
agent_profile_id=agent_db_id,
|
|
180
|
+
session_id=sid,
|
|
181
|
+
gateway_session_id=gateway_sid,
|
|
182
|
+
created_by=user_id,
|
|
183
|
+
title=session_title,
|
|
184
|
+
)
|
|
185
|
+
conn.commit()
|
|
186
|
+
_sync_lane_binding(
|
|
187
|
+
hermes_prof,
|
|
188
|
+
source_id,
|
|
189
|
+
client_id,
|
|
190
|
+
binding_version,
|
|
191
|
+
sid,
|
|
192
|
+
gateway_sid,
|
|
193
|
+
)
|
|
194
|
+
display_title = (
|
|
195
|
+
session_title
|
|
196
|
+
if not _srv()._is_blank_session_title(session_title)
|
|
197
|
+
else _srv()._resolved_session_title(hermes_prof, sid, session_title)
|
|
198
|
+
)
|
|
199
|
+
return {
|
|
200
|
+
"ok": True,
|
|
201
|
+
"profile": hermes_prof,
|
|
202
|
+
"template_profile": template_prof,
|
|
203
|
+
"workspace_id": workspace_id,
|
|
204
|
+
"room_id": room_id,
|
|
205
|
+
"session_id": sid,
|
|
206
|
+
"title": display_title,
|
|
207
|
+
"api_port": lifecycle["api_port"],
|
|
208
|
+
"created": False,
|
|
209
|
+
"resumed": str(existing_row["status"]) != "active",
|
|
210
|
+
**session_extra,
|
|
211
|
+
}
|
|
212
|
+
new_id = f"wf_room_{room_id[:8]}_{int(time.time())}_{uuid.uuid4().hex[:6]}"
|
|
213
|
+
requested_title = str(payload.get("title") or "").strip() or _srv()._default_session_title(template_prof)
|
|
214
|
+
status, data, session_title = _srv()._create_profile_session_via_api(hermes_prof, new_id, requested_title)
|
|
215
|
+
if status >= 300:
|
|
216
|
+
raise ValueError(f"create session failed: {data}")
|
|
217
|
+
gateway_sid = f"api:{hermes_prof}:{new_id}"
|
|
218
|
+
_srv()._upsert_room_session(
|
|
219
|
+
conn,
|
|
220
|
+
room_id=room_id,
|
|
221
|
+
agent_profile_id=agent_db_id,
|
|
222
|
+
session_id=new_id,
|
|
223
|
+
gateway_session_id=gateway_sid,
|
|
224
|
+
created_by=user_id,
|
|
225
|
+
title=session_title or requested_title,
|
|
226
|
+
)
|
|
227
|
+
conn.commit()
|
|
228
|
+
finally:
|
|
229
|
+
if close_conn:
|
|
230
|
+
conn.close()
|
|
231
|
+
elif room_conn is not None:
|
|
232
|
+
room_conn.close()
|
|
233
|
+
_sync_lane_binding(
|
|
234
|
+
hermes_prof,
|
|
235
|
+
source_id,
|
|
236
|
+
client_id,
|
|
237
|
+
binding_version,
|
|
238
|
+
new_id,
|
|
239
|
+
gateway_sid,
|
|
240
|
+
)
|
|
241
|
+
return {
|
|
242
|
+
"ok": True,
|
|
243
|
+
"profile": hermes_prof,
|
|
244
|
+
"template_profile": template_prof,
|
|
245
|
+
"workspace_id": workspace_id,
|
|
246
|
+
"room_id": room_id,
|
|
247
|
+
"session_id": new_id,
|
|
248
|
+
"title": session_title,
|
|
249
|
+
"api_port": lifecycle["api_port"],
|
|
250
|
+
"created": True,
|
|
251
|
+
**session_extra,
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
# ponytail: legacy client_id binding when room_id omitted (tests / old clients)
|
|
255
|
+
existing = "" if force_new else _binding_session_for(hermes_prof, source_id, client_id, binding_version)
|
|
256
|
+
if existing and _srv()._session_exists(hermes_prof, existing):
|
|
257
|
+
return {
|
|
258
|
+
"ok": True,
|
|
259
|
+
"profile": hermes_prof,
|
|
260
|
+
"session_id": existing,
|
|
261
|
+
"title": _srv()._session_info(hermes_prof, existing).get("title") or _srv()._default_session_title(template_prof),
|
|
262
|
+
"api_port": lifecycle["api_port"],
|
|
263
|
+
"created": False,
|
|
264
|
+
**session_extra,
|
|
265
|
+
}
|
|
266
|
+
new_id = f"wf_{source_id}_{client_id}_{int(time.time())}_{uuid.uuid4().hex[:6]}"
|
|
267
|
+
requested_title = str(payload.get("title") or "").strip() or _srv()._default_session_title(template_prof)
|
|
268
|
+
status, data, session_title = _srv()._create_profile_session_via_api(hermes_prof, new_id, requested_title)
|
|
269
|
+
if status >= 300:
|
|
270
|
+
raise ValueError(f"create session failed: {data}")
|
|
271
|
+
chat_dispatch(
|
|
272
|
+
{
|
|
273
|
+
"profile": hermes_prof,
|
|
274
|
+
"session_id": new_id,
|
|
275
|
+
"gateway_session_id": f"api:{hermes_prof}:{new_id}",
|
|
276
|
+
"source_id": source_id,
|
|
277
|
+
"client_id": client_id,
|
|
278
|
+
"binding_version": binding_version,
|
|
279
|
+
"text": "",
|
|
280
|
+
}
|
|
281
|
+
)
|
|
282
|
+
return {
|
|
283
|
+
"ok": True,
|
|
284
|
+
"profile": hermes_prof,
|
|
285
|
+
"session_id": new_id,
|
|
286
|
+
"title": session_title,
|
|
287
|
+
"api_port": lifecycle["api_port"],
|
|
288
|
+
"created": True,
|
|
289
|
+
**session_extra,
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
def _sync_lane_binding(
|
|
293
|
+
profile: str,
|
|
294
|
+
source_id: str,
|
|
295
|
+
client_id: str,
|
|
296
|
+
binding_version: int,
|
|
297
|
+
session_id: str,
|
|
298
|
+
gateway_session_id: str,
|
|
299
|
+
text_preview: str = "",
|
|
300
|
+
) -> None:
|
|
301
|
+
"""Keep lane-registry aligned with room_sessions for UI client/source binding."""
|
|
302
|
+
# ponytail: registry bookkeeping — slug only, no Hermes disk gate
|
|
303
|
+
prof = _srv().safe_profile_slug(profile)
|
|
304
|
+
sid = str(session_id or "").strip()
|
|
305
|
+
if not sid:
|
|
306
|
+
return
|
|
307
|
+
gw = str(gateway_session_id or f"api:{prof}:{sid}").strip()
|
|
308
|
+
registry = _load_lane_registry()
|
|
309
|
+
bucket = _registry_profile_bucket(registry, prof)
|
|
310
|
+
binding_key = _source_binding_key(source_id, client_id)
|
|
311
|
+
bindings = bucket.setdefault("bindings", {})
|
|
312
|
+
bindings[binding_key] = {
|
|
313
|
+
"session_id": sid,
|
|
314
|
+
"gateway_session_id": gw,
|
|
315
|
+
"binding_version": binding_version,
|
|
316
|
+
"last_text_preview": str(text_preview or "")[:240],
|
|
317
|
+
"source_id": source_id,
|
|
318
|
+
"client_id": client_id,
|
|
319
|
+
"updated_at": _srv()._utc_now(),
|
|
320
|
+
}
|
|
321
|
+
_save_lane_registry(registry)
|
|
322
|
+
|
|
323
|
+
def _source_binding_key(source_id: str, client_id: str) -> str:
|
|
324
|
+
src = (source_id or "ui").strip().lower()
|
|
325
|
+
cli = (client_id or "default").strip().lower()
|
|
326
|
+
safe_src = re.sub(r"[^a-z0-9._-]+", "-", src)[:64] or "ui"
|
|
327
|
+
safe_cli = re.sub(r"[^a-z0-9._-]+", "-", cli)[:96] or "default"
|
|
328
|
+
return f"{safe_src}:{safe_cli}"
|
|
329
|
+
|
|
330
|
+
def _registry_profile_bucket(registry: dict[str, Any], profile: str) -> dict[str, Any]:
|
|
331
|
+
profiles = registry.setdefault("profiles", {})
|
|
332
|
+
bucket = profiles.setdefault(profile, {})
|
|
333
|
+
bindings = bucket.setdefault("bindings", {})
|
|
334
|
+
if not isinstance(bindings, dict):
|
|
335
|
+
bucket["bindings"] = {}
|
|
336
|
+
return bucket
|
|
337
|
+
|
|
338
|
+
def chat_resolve(payload: dict[str, Any]) -> dict[str, Any]:
|
|
339
|
+
profile = _srv().resolve_hermes_profile(str(payload.get("profile") or ""))
|
|
340
|
+
source_id = str(payload.get("source_id") or "ui").strip() or "ui"
|
|
341
|
+
client_id = str(payload.get("client_id") or "default").strip() or "default"
|
|
342
|
+
binding_version = _binding_version(payload.get("binding_version"))
|
|
343
|
+
binding_key = _source_binding_key(source_id, client_id)
|
|
344
|
+
row = _binding_row_for(profile, source_id, client_id, binding_version)
|
|
345
|
+
session_id = str((row or {}).get("session_id") or "").strip()
|
|
346
|
+
return {
|
|
347
|
+
"ok": True,
|
|
348
|
+
"profile": profile,
|
|
349
|
+
"source_id": source_id,
|
|
350
|
+
"client_id": client_id,
|
|
351
|
+
"binding_key": binding_key,
|
|
352
|
+
"session_id": session_id,
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
def chat_dispatch(payload: dict[str, Any]) -> dict[str, Any]:
|
|
356
|
+
profile = _srv().resolve_hermes_profile(str(payload.get("profile") or ""))
|
|
357
|
+
session_id = str(payload.get("session_id") or "").strip()
|
|
358
|
+
gateway_session_id = str(payload.get("gateway_session_id") or "").strip()
|
|
359
|
+
source_id = str(payload.get("source_id") or "ui").strip() or "ui"
|
|
360
|
+
client_id = str(payload.get("client_id") or "default").strip() or "default"
|
|
361
|
+
binding_version = _binding_version(payload.get("binding_version"))
|
|
362
|
+
room_id = str(payload.get("room_id") or "").strip()
|
|
363
|
+
user_id = str(payload.get("user_id") or "").strip()
|
|
364
|
+
text = str(payload.get("text") or "").strip()
|
|
365
|
+
if not session_id:
|
|
366
|
+
raise ValueError("session_id required")
|
|
367
|
+
if not gateway_session_id:
|
|
368
|
+
raise ValueError("gateway_session_id required")
|
|
369
|
+
|
|
370
|
+
if room_id:
|
|
371
|
+
conn = _srv()._workframe_db()
|
|
372
|
+
try:
|
|
373
|
+
room = conn.execute(
|
|
374
|
+
"SELECT workspace_id, agent_profile_id FROM rooms WHERE id = ? AND deleted_at IS NULL",
|
|
375
|
+
(room_id,),
|
|
376
|
+
).fetchone()
|
|
377
|
+
agent_profile_id = ""
|
|
378
|
+
if room:
|
|
379
|
+
room_agent = str(room["agent_profile_id"] or "").strip()
|
|
380
|
+
if room_agent:
|
|
381
|
+
agent_profile_id = room_agent
|
|
382
|
+
else:
|
|
383
|
+
agent_row = _srv()._lookup_agent_profile(conn, str(room["workspace_id"]), profile)
|
|
384
|
+
agent_profile_id = str(agent_row["id"]) if agent_row else ""
|
|
385
|
+
if agent_profile_id:
|
|
386
|
+
_srv()._upsert_room_session(
|
|
387
|
+
conn,
|
|
388
|
+
room_id=room_id,
|
|
389
|
+
agent_profile_id=agent_profile_id,
|
|
390
|
+
session_id=session_id,
|
|
391
|
+
gateway_session_id=gateway_session_id,
|
|
392
|
+
created_by=user_id or "system",
|
|
393
|
+
)
|
|
394
|
+
conn.commit()
|
|
395
|
+
finally:
|
|
396
|
+
conn.close()
|
|
397
|
+
_sync_lane_binding(
|
|
398
|
+
profile,
|
|
399
|
+
source_id,
|
|
400
|
+
client_id,
|
|
401
|
+
binding_version,
|
|
402
|
+
session_id,
|
|
403
|
+
gateway_session_id,
|
|
404
|
+
text,
|
|
405
|
+
)
|
|
406
|
+
return {"ok": True, "profile": profile, "room_id": room_id, "session_id": session_id}
|
|
407
|
+
|
|
408
|
+
registry = _load_lane_registry()
|
|
409
|
+
bucket = _registry_profile_bucket(registry, profile)
|
|
410
|
+
binding_key = _source_binding_key(source_id, client_id)
|
|
411
|
+
bindings = bucket.setdefault("bindings", {})
|
|
412
|
+
bindings[binding_key] = {
|
|
413
|
+
"session_id": session_id,
|
|
414
|
+
"gateway_session_id": gateway_session_id,
|
|
415
|
+
"binding_version": binding_version,
|
|
416
|
+
"last_text_preview": text[:240],
|
|
417
|
+
"source_id": source_id,
|
|
418
|
+
"client_id": client_id,
|
|
419
|
+
"updated_at": _srv()._utc_now(),
|
|
420
|
+
}
|
|
421
|
+
_save_lane_registry(registry)
|
|
422
|
+
return {"ok": True, "profile": profile, "session_id": session_id, "binding_key": binding_key}
|
|
423
|
+
|
|
@@ -13,6 +13,7 @@ from typing import Any, Callable
|
|
|
13
13
|
from http.server import BaseHTTPRequestHandler
|
|
14
14
|
|
|
15
15
|
import internal_proxy_auth
|
|
16
|
+
import credential_broker
|
|
16
17
|
import turn_credentials
|
|
17
18
|
|
|
18
19
|
LEASE_PREFIX = turn_credentials.LEASE_PREFIX
|
|
@@ -52,38 +53,14 @@ def authorize_internal_proxy(handler: BaseHTTPRequestHandler) -> tuple[bool, str
|
|
|
52
53
|
return internal_proxy_auth.authorize_internal_proxy(handler)
|
|
53
54
|
|
|
54
55
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
or headers.get("x-workframe-profile")
|
|
59
|
-
or ""
|
|
60
|
-
).strip()
|
|
56
|
+
extract_bearer = credential_broker.extract_bearer
|
|
57
|
+
extract_profile_slug = credential_broker.extract_profile_slug
|
|
58
|
+
validate_lease_profile = credential_broker.validate_lease_profile
|
|
61
59
|
|
|
62
60
|
|
|
63
|
-
def
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
) -> tuple[bool, str, int]:
|
|
67
|
-
"""Bind bearer lease to calling Hermes profile (0022 N2 / 0023 C1)."""
|
|
68
|
-
want = str(lease.get("profile_slug") or "").strip()
|
|
69
|
-
if not want:
|
|
70
|
-
return True, "", 0
|
|
71
|
-
got = extract_profile_slug(headers)
|
|
72
|
-
if not got:
|
|
73
|
-
return False, "profile header required", 403
|
|
74
|
-
if got != want:
|
|
75
|
-
return False, "profile mismatch", 403
|
|
76
|
-
return True, "", 0
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def extract_bearer(headers: dict[str, str]) -> str:
|
|
80
|
-
auth = str(headers.get("Authorization") or headers.get("authorization") or "").strip()
|
|
81
|
-
if auth.lower().startswith("bearer "):
|
|
82
|
-
return auth[7:].strip()
|
|
83
|
-
api_key = str(headers.get("X-Api-Key") or headers.get("x-api-key") or "").strip()
|
|
84
|
-
if api_key:
|
|
85
|
-
return api_key
|
|
86
|
-
return ""
|
|
61
|
+
def _upstream_host(provider: str) -> str:
|
|
62
|
+
base = UPSTREAM_BASE.get(str(provider or "").strip().lower(), "")
|
|
63
|
+
return str(base).split("://", 1)[-1].split("/", 1)[0].lower()
|
|
87
64
|
|
|
88
65
|
|
|
89
66
|
def upstream_auth_header(provider: str, secret: str) -> dict[str, str]:
|
|
@@ -114,20 +91,17 @@ def _build_upstream_request(
|
|
|
114
91
|
if not base:
|
|
115
92
|
return None, _error_response(404, "unknown provider")
|
|
116
93
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
return None, _error_response(profile_status, profile_err)
|
|
94
|
+
auth = credential_broker.authorize_broker_lease(
|
|
95
|
+
provider,
|
|
96
|
+
headers,
|
|
97
|
+
resolve_secret=resolve_secret,
|
|
98
|
+
broker_kind="llm",
|
|
99
|
+
upstream_host=_upstream_host(provider),
|
|
100
|
+
)
|
|
101
|
+
if not auth.ok:
|
|
102
|
+
return None, _error_response(auth.status, auth.error)
|
|
127
103
|
|
|
128
|
-
|
|
129
|
-
if not secret:
|
|
130
|
-
return None, _error_response(402, "no credential")
|
|
104
|
+
secret = auth.secret
|
|
131
105
|
|
|
132
106
|
path = normalize_upstream_path(base, subpath)
|
|
133
107
|
url = f"{base.rstrip('/')}{path}"
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""WF-032 extract: @mention handle resolution for space rooms."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import sqlite3
|
|
7
|
+
import threading
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _srv():
|
|
12
|
+
import server as srv
|
|
13
|
+
|
|
14
|
+
return srv
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def mention_handle(label: str, email: str = "") -> str:
|
|
18
|
+
label = str(label or "").strip().lower()
|
|
19
|
+
if label:
|
|
20
|
+
handle = re.sub(r"[^a-z0-9]+", "", label.replace(" ", ""))
|
|
21
|
+
if handle:
|
|
22
|
+
return handle[:32]
|
|
23
|
+
normalized = str(email or "").strip().lower()
|
|
24
|
+
if normalized and "@" in normalized:
|
|
25
|
+
local = normalized.split("@", 1)[0]
|
|
26
|
+
handle = re.sub(r"[^a-z0-9]+", "", local)
|
|
27
|
+
if handle:
|
|
28
|
+
return handle[:32]
|
|
29
|
+
return ""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def room_agents_for_mentions(
|
|
33
|
+
conn: sqlite3.Connection,
|
|
34
|
+
room_id: str,
|
|
35
|
+
workspace_id: str,
|
|
36
|
+
) -> list[dict[str, Any]]:
|
|
37
|
+
rows = conn.execute(
|
|
38
|
+
"""
|
|
39
|
+
SELECT ap.id, ap.slug, ap.display_name, ap.model_provider, ap.model_name
|
|
40
|
+
FROM room_memberships rm
|
|
41
|
+
JOIN agent_profiles ap ON ap.id = rm.agent_profile_id
|
|
42
|
+
WHERE rm.room_id = ? AND rm.deleted_at IS NULL AND ap.deleted_at IS NULL
|
|
43
|
+
""",
|
|
44
|
+
(room_id,),
|
|
45
|
+
).fetchall()
|
|
46
|
+
if rows:
|
|
47
|
+
return [dict(row) for row in rows]
|
|
48
|
+
# ponytail: until membership backfill completes, allow all workspace agents in spaces
|
|
49
|
+
fallback = conn.execute(
|
|
50
|
+
"""
|
|
51
|
+
SELECT id, slug, display_name, model_provider, model_name
|
|
52
|
+
FROM agent_profiles
|
|
53
|
+
WHERE workspace_id = ? AND deleted_at IS NULL
|
|
54
|
+
""",
|
|
55
|
+
(workspace_id,),
|
|
56
|
+
).fetchall()
|
|
57
|
+
return [dict(row) for row in fallback]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def room_users_for_mentions(conn: sqlite3.Connection, room_id: str) -> list[dict[str, Any]]:
|
|
61
|
+
rows = conn.execute(
|
|
62
|
+
"""
|
|
63
|
+
SELECT u.id, u.display_name, u.email
|
|
64
|
+
FROM room_memberships rm
|
|
65
|
+
JOIN users u ON u.id = rm.user_id
|
|
66
|
+
WHERE rm.room_id = ? AND rm.deleted_at IS NULL AND rm.user_id IS NOT NULL
|
|
67
|
+
""",
|
|
68
|
+
(room_id,),
|
|
69
|
+
).fetchall()
|
|
70
|
+
users: list[dict[str, Any]] = []
|
|
71
|
+
for row in rows:
|
|
72
|
+
users.append(
|
|
73
|
+
{
|
|
74
|
+
"id": str(row["id"]),
|
|
75
|
+
"display_name": str(row["display_name"] or ""),
|
|
76
|
+
"email": str(row["email"] or ""),
|
|
77
|
+
"handle": mention_handle(str(row["display_name"] or ""), str(row["email"] or "")),
|
|
78
|
+
}
|
|
79
|
+
)
|
|
80
|
+
return users
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def parse_room_mentions(
|
|
84
|
+
text: str,
|
|
85
|
+
agents: list[dict[str, Any]],
|
|
86
|
+
users: list[dict[str, Any]],
|
|
87
|
+
*,
|
|
88
|
+
triggered_by_user_id: str = "",
|
|
89
|
+
workspace_id: str = "",
|
|
90
|
+
) -> dict[str, list[dict[str, Any]]]:
|
|
91
|
+
"""Resolve @handles in a space message to agents (invoke) and users (notify-only)."""
|
|
92
|
+
handles = {m.group(1).lower() for m in re.finditer(r"@([\w-]+)", str(text or ""))}
|
|
93
|
+
if not handles:
|
|
94
|
+
return {"agents": [], "users": []}
|
|
95
|
+
agent_by_handle: dict[str, dict[str, Any]] = {}
|
|
96
|
+
for agent in agents:
|
|
97
|
+
slug = str(agent.get("slug") or "").strip().lower()
|
|
98
|
+
if slug:
|
|
99
|
+
agent_by_handle[slug] = agent
|
|
100
|
+
display = str(agent.get("display_name") or "").strip().lower()
|
|
101
|
+
if display:
|
|
102
|
+
agent_by_handle[display] = agent
|
|
103
|
+
compact = re.sub(r"[^a-z0-9]+", "", display)
|
|
104
|
+
if compact:
|
|
105
|
+
agent_by_handle[compact] = agent
|
|
106
|
+
if triggered_by_user_id and slug:
|
|
107
|
+
personal = _srv()._runtime_display_label(triggered_by_user_id, slug, workspace_id).strip().lower()
|
|
108
|
+
if personal:
|
|
109
|
+
agent_by_handle[personal] = agent
|
|
110
|
+
personal_compact = re.sub(r"[^a-z0-9]+", "", personal)
|
|
111
|
+
if personal_compact:
|
|
112
|
+
agent_by_handle[personal_compact] = agent
|
|
113
|
+
user_by_handle = {str(u["handle"]).lower(): u for u in users if u.get("handle")}
|
|
114
|
+
matched_agents: list[dict[str, Any]] = []
|
|
115
|
+
seen_agents: set[str] = set()
|
|
116
|
+
for handle in handles:
|
|
117
|
+
agent = agent_by_handle.get(handle)
|
|
118
|
+
if agent and str(agent["id"]) not in seen_agents:
|
|
119
|
+
seen_agents.add(str(agent["id"]))
|
|
120
|
+
matched_agents.append(agent)
|
|
121
|
+
matched_users: list[dict[str, Any]] = []
|
|
122
|
+
seen_users: set[str] = set()
|
|
123
|
+
for handle in handles:
|
|
124
|
+
user = user_by_handle.get(handle)
|
|
125
|
+
if user and str(user["id"]) not in seen_users:
|
|
126
|
+
seen_users.add(str(user["id"]))
|
|
127
|
+
matched_users.append(user)
|
|
128
|
+
return {"agents": matched_agents, "users": matched_users}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def parse_mentions(text: str) -> list[dict]:
|
|
132
|
+
"""Extract @agent mentions from message text."""
|
|
133
|
+
mentions = []
|
|
134
|
+
for match in re.finditer(r"@(\w+)", text):
|
|
135
|
+
slug = match.group(1)
|
|
136
|
+
mentions.append({"type": "agent", "slug": slug})
|
|
137
|
+
return mentions
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def process_space_message_mentions(
|
|
141
|
+
room_id: str,
|
|
142
|
+
workspace_id: str,
|
|
143
|
+
user_id: str,
|
|
144
|
+
content: str,
|
|
145
|
+
message_id: str,
|
|
146
|
+
*,
|
|
147
|
+
invoke_agents: bool = True,
|
|
148
|
+
) -> dict[str, Any]:
|
|
149
|
+
conn = _srv()._workframe_db()
|
|
150
|
+
try:
|
|
151
|
+
agents = room_agents_for_mentions(conn, room_id, workspace_id)
|
|
152
|
+
users = room_users_for_mentions(conn, room_id)
|
|
153
|
+
finally:
|
|
154
|
+
conn.close()
|
|
155
|
+
mentions = parse_room_mentions(
|
|
156
|
+
content,
|
|
157
|
+
agents,
|
|
158
|
+
users,
|
|
159
|
+
triggered_by_user_id=user_id,
|
|
160
|
+
workspace_id=workspace_id,
|
|
161
|
+
)
|
|
162
|
+
invoked: list[str] = []
|
|
163
|
+
for agent in mentions["agents"]:
|
|
164
|
+
invoked.append(str(agent["slug"]))
|
|
165
|
+
if invoke_agents:
|
|
166
|
+
threading.Thread(
|
|
167
|
+
target=_srv()._invoke_room_agent_mention,
|
|
168
|
+
kwargs={
|
|
169
|
+
"room_id": room_id,
|
|
170
|
+
"workspace_id": workspace_id,
|
|
171
|
+
"agent_row": agent,
|
|
172
|
+
"triggered_by_user_id": user_id,
|
|
173
|
+
"parent_message_id": message_id,
|
|
174
|
+
},
|
|
175
|
+
daemon=True,
|
|
176
|
+
).start()
|
|
177
|
+
return {
|
|
178
|
+
"agent_mentions": invoked,
|
|
179
|
+
"user_mentions": [str(u.get("handle") or "") for u in mentions["users"]],
|
|
180
|
+
}
|