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,752 @@
|
|
|
1
|
+
"""WF-032 extract: runtime profiles, user cohort, and delegation grants."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import sqlite3
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ponytail: docker exec per bind was ~10s; cache gateway registration briefly.
|
|
18
|
+
_gateway_registered_cache: dict[str, tuple[bool, float]] = {}
|
|
19
|
+
_GATEWAY_REG_TTL_SEC = 45.0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _srv():
|
|
23
|
+
import server as srv
|
|
24
|
+
|
|
25
|
+
return srv
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _runtime_profile_slug(user_id: str, template_slug: str) -> str:
|
|
29
|
+
template_slug = _srv().safe_profile_slug(template_slug)
|
|
30
|
+
user_part = re.sub(r"[^a-z0-9]+", "-", _srv()._user_hermes_dir_slug(user_id).lower()).strip("-")[:20] or "user"
|
|
31
|
+
slug = f"u-{user_part}-{template_slug}"
|
|
32
|
+
if len(slug) > 64:
|
|
33
|
+
slug = slug[:64].rstrip("-")
|
|
34
|
+
return _srv().safe_profile_slug(slug)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _prepare_runtime_profile_credentials(
|
|
38
|
+
runtime: str,
|
|
39
|
+
user_id: str,
|
|
40
|
+
workspace_id: str = "",
|
|
41
|
+
) -> bool:
|
|
42
|
+
"""Strip stale profile secrets, then overlay the runtime profile owner's keys.
|
|
43
|
+
|
|
44
|
+
Delegation assigns work to another user's runtime slug; provider keys and
|
|
45
|
+
billing follow the profile owner (assignee), not the user who delegated.
|
|
46
|
+
"""
|
|
47
|
+
runtime = _srv().safe_profile_slug(str(runtime or "").strip())
|
|
48
|
+
user = str(user_id or "").strip()
|
|
49
|
+
if not runtime or not user or not _srv()._is_runtime_profile_slug(runtime):
|
|
50
|
+
return False
|
|
51
|
+
try:
|
|
52
|
+
_srv().resolve_hermes_profile(runtime)
|
|
53
|
+
except ValueError:
|
|
54
|
+
return False
|
|
55
|
+
_srv()._strip_profile_llm_env(runtime)
|
|
56
|
+
_srv()._strip_profile_action_env(runtime)
|
|
57
|
+
_srv()._reconcile_profile_llm_for_user(runtime, user, workspace_id)
|
|
58
|
+
prov = _srv()._llm_billing_provider(runtime, user_id=user, workspace_id=workspace_id)
|
|
59
|
+
_srv()._ensure_profile_llm_proxy(runtime, prov)
|
|
60
|
+
return _srv()._user_can_use_llm(user, workspace_id, prov)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _resolve_runtime_owner(runtime: str) -> tuple[str, str] | None:
|
|
64
|
+
"""Map a u-* runtime slug to (owner_user_id, workspace_id)."""
|
|
65
|
+
runtime = _srv().safe_profile_slug(str(runtime or "").strip())
|
|
66
|
+
if not _srv()._is_runtime_profile_slug(runtime):
|
|
67
|
+
return None
|
|
68
|
+
conn = _srv()._workframe_db()
|
|
69
|
+
try:
|
|
70
|
+
for row in conn.execute("SELECT id FROM workspaces WHERE deleted_at IS NULL").fetchall():
|
|
71
|
+
wid = str(row["id"])
|
|
72
|
+
uid = _user_id_for_runtime_slug(runtime, wid)
|
|
73
|
+
if uid:
|
|
74
|
+
return uid, wid
|
|
75
|
+
finally:
|
|
76
|
+
conn.close()
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _user_handle(user_id: str) -> str:
|
|
81
|
+
"""Short lowercase handle for cohort aliases (display_name or email local-part)."""
|
|
82
|
+
user_id = str(user_id or "").strip()
|
|
83
|
+
if not user_id:
|
|
84
|
+
return "user"
|
|
85
|
+
conn = _srv()._workframe_db()
|
|
86
|
+
try:
|
|
87
|
+
row = conn.execute(
|
|
88
|
+
"SELECT display_name, email FROM users WHERE id = ?",
|
|
89
|
+
(user_id,),
|
|
90
|
+
).fetchone()
|
|
91
|
+
finally:
|
|
92
|
+
conn.close()
|
|
93
|
+
if not row:
|
|
94
|
+
return "user"
|
|
95
|
+
display = str(row["display_name"] or "").strip()
|
|
96
|
+
if display:
|
|
97
|
+
handle = re.sub(r"[^a-z0-9]+", "-", display.lower()).strip("-")[:24]
|
|
98
|
+
if handle:
|
|
99
|
+
return handle
|
|
100
|
+
email = str(row["email"] or "").strip().lower()
|
|
101
|
+
if "@" in email:
|
|
102
|
+
handle = re.sub(r"[^a-z0-9]+", "-", email.split("@", 1)[0]).strip("-")[:24]
|
|
103
|
+
if handle:
|
|
104
|
+
return handle
|
|
105
|
+
return "user"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _user_owner_name(user_id: str) -> str:
|
|
109
|
+
return _user_handle(user_id).replace("-", " ").strip().title() or "User"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _runtime_display_label(user_id: str, template_slug: str, workspace_id: str = "") -> str:
|
|
113
|
+
template = _srv().safe_profile_slug(str(template_slug or "").strip())
|
|
114
|
+
user_id = str(user_id or "").strip()
|
|
115
|
+
if user_id:
|
|
116
|
+
runtime = _runtime_profile_slug(user_id, template)
|
|
117
|
+
reg_runtime = _srv()._agent_registry_row(runtime)
|
|
118
|
+
if str(reg_runtime.get("display_name") or "").strip():
|
|
119
|
+
return str(reg_runtime["display_name"]).strip()
|
|
120
|
+
custom = _srv()._agent_db_display_name(template, workspace_id)
|
|
121
|
+
if custom:
|
|
122
|
+
return custom
|
|
123
|
+
reg = _srv()._agent_registry_row(template)
|
|
124
|
+
if reg.get("display_name"):
|
|
125
|
+
return str(reg["display_name"])
|
|
126
|
+
if _srv()._is_native_profile(template) or template.endswith("-agent"):
|
|
127
|
+
return f"{_user_owner_name(user_id)}'s {_srv()._native_display_name().replace(' Agent', '')} Agent"
|
|
128
|
+
role = _srv()._agent_db_display_name(template, workspace_id) or _srv()._agent_registry_row(template).get("display_name")
|
|
129
|
+
if not role:
|
|
130
|
+
role = template.replace("-", " ").title()
|
|
131
|
+
return f"{_user_owner_name(user_id)}'s {role}"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _cohort_alias(user_id: str, template_slug: str) -> str:
|
|
135
|
+
"""Human alias for docs/prompts — not the Hermes profile dir slug."""
|
|
136
|
+
template = _srv().safe_profile_slug(template_slug)
|
|
137
|
+
short = _srv()._profile_slug(template).replace("_", "-")
|
|
138
|
+
return f"{_user_handle(user_id)}-{short}"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def resolve_runtime_assignee(template_slug: str, user_id: str, workspace_id: str = "") -> str:
|
|
142
|
+
"""Map template specialist slug → per-user runtime profile (has overlaid credentials)."""
|
|
143
|
+
user_id = str(user_id or "").strip()
|
|
144
|
+
template = _srv().safe_profile_slug(str(template_slug or "").strip())
|
|
145
|
+
if not user_id:
|
|
146
|
+
return template
|
|
147
|
+
runtime = _runtime_profile_slug(user_id, template)
|
|
148
|
+
ensure_runtime_profile(runtime, template, user_id, workspace_id)
|
|
149
|
+
return runtime
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _user_id_for_runtime_slug(runtime: str, workspace_id: str) -> str | None:
|
|
153
|
+
"""Resolve owning user for a per-user runtime Hermes profile slug."""
|
|
154
|
+
runtime = _srv().safe_profile_slug(str(runtime or "").strip())
|
|
155
|
+
workspace_id = str(workspace_id or "").strip()
|
|
156
|
+
if not _srv()._is_runtime_profile_slug(runtime) or not workspace_id:
|
|
157
|
+
return None
|
|
158
|
+
conn = _srv()._workframe_db()
|
|
159
|
+
try:
|
|
160
|
+
members = [
|
|
161
|
+
str(row["user_id"] or "").strip()
|
|
162
|
+
for row in conn.execute(
|
|
163
|
+
"""
|
|
164
|
+
SELECT user_id FROM workspace_memberships
|
|
165
|
+
WHERE workspace_id = ? AND status = 'active' AND deleted_at IS NULL
|
|
166
|
+
""",
|
|
167
|
+
(workspace_id,),
|
|
168
|
+
).fetchall()
|
|
169
|
+
if str(row["user_id"] or "").strip()
|
|
170
|
+
]
|
|
171
|
+
templates = [
|
|
172
|
+
str(row["slug"] or "").strip()
|
|
173
|
+
for row in conn.execute(
|
|
174
|
+
"""
|
|
175
|
+
SELECT slug FROM agent_profiles
|
|
176
|
+
WHERE workspace_id = ? AND deleted_at IS NULL
|
|
177
|
+
""",
|
|
178
|
+
(workspace_id,),
|
|
179
|
+
).fetchall()
|
|
180
|
+
if str(row["slug"] or "").strip()
|
|
181
|
+
]
|
|
182
|
+
finally:
|
|
183
|
+
conn.close()
|
|
184
|
+
for uid in members:
|
|
185
|
+
for template in templates:
|
|
186
|
+
if _runtime_profile_slug(uid, template) == runtime:
|
|
187
|
+
return uid
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def cohort_runtime_slugs(user_id: str, workspace_id: str) -> set[str]:
|
|
192
|
+
"""Runtime Hermes slugs this user may assign kanban/cron/delegation to (own cohort)."""
|
|
193
|
+
return {
|
|
194
|
+
str(row.get("runtime_slug") or "").strip()
|
|
195
|
+
for row in ensure_user_agent_cohort(user_id, workspace_id)
|
|
196
|
+
if str(row.get("runtime_slug") or "").strip()
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _user_is_workspace_member(user_id: str, workspace_id: str) -> bool:
|
|
201
|
+
user_id = str(user_id or "").strip()
|
|
202
|
+
workspace_id = str(workspace_id or "").strip()
|
|
203
|
+
if not user_id or not workspace_id:
|
|
204
|
+
return False
|
|
205
|
+
conn = _srv()._workframe_db()
|
|
206
|
+
try:
|
|
207
|
+
return _srv()._workspace_member_role(conn, workspace_id, user_id) is not None
|
|
208
|
+
finally:
|
|
209
|
+
conn.close()
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _delegation_grantor_ids_for_grantee(
|
|
213
|
+
grantee_user_id: str,
|
|
214
|
+
workspace_id: str,
|
|
215
|
+
scope: str | None = None,
|
|
216
|
+
) -> set[str]:
|
|
217
|
+
grantee_user_id = str(grantee_user_id or "").strip()
|
|
218
|
+
workspace_id = str(workspace_id or "").strip()
|
|
219
|
+
if scope is None:
|
|
220
|
+
scope = _srv().DELEGATION_SCOPE_AGENTS_DELEGATE
|
|
221
|
+
if not grantee_user_id or not workspace_id:
|
|
222
|
+
return set()
|
|
223
|
+
conn = _srv()._workframe_db()
|
|
224
|
+
try:
|
|
225
|
+
rows = conn.execute(
|
|
226
|
+
"""
|
|
227
|
+
SELECT grantor_user_id FROM agent_delegation_grants
|
|
228
|
+
WHERE workspace_id = ? AND grantee_user_id = ? AND scope = ?
|
|
229
|
+
AND deleted_at IS NULL
|
|
230
|
+
AND (expires_at IS NULL OR expires_at > datetime('now'))
|
|
231
|
+
""",
|
|
232
|
+
(workspace_id, grantee_user_id, scope),
|
|
233
|
+
).fetchall()
|
|
234
|
+
finally:
|
|
235
|
+
conn.close()
|
|
236
|
+
return {str(row["grantor_user_id"]).strip() for row in rows if str(row["grantor_user_id"] or "").strip()}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _delegation_grant_payload(row: sqlite3.Row) -> dict[str, Any]:
|
|
240
|
+
return {
|
|
241
|
+
"id": row["id"],
|
|
242
|
+
"workspace_id": row["workspace_id"],
|
|
243
|
+
"grantor_user_id": row["grantor_user_id"],
|
|
244
|
+
"grantee_user_id": row["grantee_user_id"],
|
|
245
|
+
"scope": row["scope"],
|
|
246
|
+
"expires_at": row["expires_at"],
|
|
247
|
+
"created_at": row["created_at"],
|
|
248
|
+
"updated_at": row["updated_at"],
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def list_delegation_grants(workspace_id: str, user_id: str) -> dict[str, Any]:
|
|
253
|
+
workspace_id = str(workspace_id or "").strip()
|
|
254
|
+
user_id = str(user_id or "").strip()
|
|
255
|
+
if not _user_is_workspace_member(user_id, workspace_id):
|
|
256
|
+
raise PermissionError("forbidden")
|
|
257
|
+
conn = _srv()._workframe_db()
|
|
258
|
+
try:
|
|
259
|
+
rows = conn.execute(
|
|
260
|
+
"""
|
|
261
|
+
SELECT * FROM agent_delegation_grants
|
|
262
|
+
WHERE workspace_id = ? AND deleted_at IS NULL
|
|
263
|
+
AND (grantor_user_id = ? OR grantee_user_id = ?)
|
|
264
|
+
ORDER BY created_at DESC
|
|
265
|
+
""",
|
|
266
|
+
(workspace_id, user_id, user_id),
|
|
267
|
+
).fetchall()
|
|
268
|
+
finally:
|
|
269
|
+
conn.close()
|
|
270
|
+
return {
|
|
271
|
+
"ok": True,
|
|
272
|
+
"workspace_id": workspace_id,
|
|
273
|
+
"grants": [_delegation_grant_payload(row) for row in rows],
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def create_delegation_grant(
|
|
278
|
+
workspace_id: str,
|
|
279
|
+
grantor_user_id: str,
|
|
280
|
+
grantee_user_id: str,
|
|
281
|
+
scope: str | None = None,
|
|
282
|
+
) -> dict[str, Any]:
|
|
283
|
+
workspace_id = str(workspace_id or "").strip()
|
|
284
|
+
grantor_user_id = str(grantor_user_id or "").strip()
|
|
285
|
+
grantee_user_id = str(grantee_user_id or "").strip()
|
|
286
|
+
if scope is None:
|
|
287
|
+
scope = _srv().DELEGATION_SCOPE_AGENTS_DELEGATE
|
|
288
|
+
scope = str(scope or _srv().DELEGATION_SCOPE_AGENTS_DELEGATE).strip()
|
|
289
|
+
if not workspace_id or not grantor_user_id or not grantee_user_id:
|
|
290
|
+
raise ValueError("grantor_grantee_required")
|
|
291
|
+
if grantor_user_id == grantee_user_id:
|
|
292
|
+
raise ValueError("self_grant_forbidden")
|
|
293
|
+
if scope != _srv().DELEGATION_SCOPE_AGENTS_DELEGATE:
|
|
294
|
+
raise ValueError("unsupported_scope")
|
|
295
|
+
if not _user_is_workspace_member(grantor_user_id, workspace_id):
|
|
296
|
+
raise PermissionError("forbidden")
|
|
297
|
+
if not _user_is_workspace_member(grantee_user_id, workspace_id):
|
|
298
|
+
raise ValueError("grantee_not_member")
|
|
299
|
+
now = _srv()._utc_now()
|
|
300
|
+
conn = _srv()._workframe_db()
|
|
301
|
+
try:
|
|
302
|
+
existing = conn.execute(
|
|
303
|
+
"""
|
|
304
|
+
SELECT id FROM agent_delegation_grants
|
|
305
|
+
WHERE workspace_id = ? AND grantor_user_id = ? AND grantee_user_id = ?
|
|
306
|
+
AND scope = ? AND deleted_at IS NULL
|
|
307
|
+
""",
|
|
308
|
+
(workspace_id, grantor_user_id, grantee_user_id, scope),
|
|
309
|
+
).fetchone()
|
|
310
|
+
if existing:
|
|
311
|
+
grant_id = str(existing["id"])
|
|
312
|
+
conn.execute(
|
|
313
|
+
"UPDATE agent_delegation_grants SET updated_at = ?, expires_at = NULL WHERE id = ?",
|
|
314
|
+
(now, grant_id),
|
|
315
|
+
)
|
|
316
|
+
else:
|
|
317
|
+
grant_id = str(uuid.uuid4())
|
|
318
|
+
conn.execute(
|
|
319
|
+
"""
|
|
320
|
+
INSERT INTO agent_delegation_grants (
|
|
321
|
+
id, workspace_id, grantor_user_id, grantee_user_id, scope,
|
|
322
|
+
expires_at, created_at, updated_at
|
|
323
|
+
) VALUES (?, ?, ?, ?, ?, NULL, ?, ?)
|
|
324
|
+
""",
|
|
325
|
+
(grant_id, workspace_id, grantor_user_id, grantee_user_id, scope, now, now),
|
|
326
|
+
)
|
|
327
|
+
conn.commit()
|
|
328
|
+
row = conn.execute(
|
|
329
|
+
"SELECT * FROM agent_delegation_grants WHERE id = ?",
|
|
330
|
+
(grant_id,),
|
|
331
|
+
).fetchone()
|
|
332
|
+
finally:
|
|
333
|
+
conn.close()
|
|
334
|
+
if not row:
|
|
335
|
+
raise ValueError("grant_persist_failed")
|
|
336
|
+
return {"ok": True, "grant": _delegation_grant_payload(row)}
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def revoke_delegation_grant(workspace_id: str, grant_id: str, user_id: str) -> dict[str, Any]:
|
|
340
|
+
workspace_id = str(workspace_id or "").strip()
|
|
341
|
+
grant_id = str(grant_id or "").strip()
|
|
342
|
+
user_id = str(user_id or "").strip()
|
|
343
|
+
conn = _srv()._workframe_db()
|
|
344
|
+
try:
|
|
345
|
+
row = conn.execute(
|
|
346
|
+
"""
|
|
347
|
+
SELECT * FROM agent_delegation_grants
|
|
348
|
+
WHERE id = ? AND workspace_id = ? AND deleted_at IS NULL
|
|
349
|
+
""",
|
|
350
|
+
(grant_id, workspace_id),
|
|
351
|
+
).fetchone()
|
|
352
|
+
if not row:
|
|
353
|
+
raise ValueError("not_found")
|
|
354
|
+
if user_id not in {str(row["grantor_user_id"]), str(row["grantee_user_id"])}:
|
|
355
|
+
raise PermissionError("forbidden")
|
|
356
|
+
now = _srv()._utc_now()
|
|
357
|
+
conn.execute(
|
|
358
|
+
"UPDATE agent_delegation_grants SET deleted_at = ?, updated_at = ? WHERE id = ?",
|
|
359
|
+
(now, now, grant_id),
|
|
360
|
+
)
|
|
361
|
+
conn.commit()
|
|
362
|
+
finally:
|
|
363
|
+
conn.close()
|
|
364
|
+
return {"ok": True, "id": grant_id}
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _allowed_runtime_profiles_for_workspace(workspace_id: str) -> set[str]:
|
|
368
|
+
allowed: set[str] = set()
|
|
369
|
+
conn = _srv()._workframe_db()
|
|
370
|
+
try:
|
|
371
|
+
members = [
|
|
372
|
+
str(row["user_id"] or "").strip()
|
|
373
|
+
for row in conn.execute(
|
|
374
|
+
"""
|
|
375
|
+
SELECT user_id FROM workspace_memberships
|
|
376
|
+
WHERE workspace_id = ? AND status = 'active' AND deleted_at IS NULL
|
|
377
|
+
""",
|
|
378
|
+
(workspace_id,),
|
|
379
|
+
).fetchall()
|
|
380
|
+
if str(row["user_id"] or "").strip()
|
|
381
|
+
]
|
|
382
|
+
templates = [
|
|
383
|
+
str(row["slug"] or "").strip()
|
|
384
|
+
for row in conn.execute(
|
|
385
|
+
"""
|
|
386
|
+
SELECT slug FROM agent_profiles
|
|
387
|
+
WHERE workspace_id = ? AND deleted_at IS NULL
|
|
388
|
+
""",
|
|
389
|
+
(workspace_id,),
|
|
390
|
+
).fetchall()
|
|
391
|
+
if str(row["slug"] or "").strip()
|
|
392
|
+
]
|
|
393
|
+
finally:
|
|
394
|
+
conn.close()
|
|
395
|
+
for uid in members:
|
|
396
|
+
for template in templates:
|
|
397
|
+
allowed.add(_runtime_profile_slug(uid, template))
|
|
398
|
+
return allowed
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def purge_stale_runtime_profiles(workspace_id: str) -> dict[str, Any]:
|
|
402
|
+
workspace_id = str(workspace_id or "").strip()
|
|
403
|
+
allowed = _allowed_runtime_profiles_for_workspace(workspace_id)
|
|
404
|
+
purged: list[str] = []
|
|
405
|
+
for profile in _srv()._list_profiles():
|
|
406
|
+
if not _srv()._is_runtime_profile_slug(profile):
|
|
407
|
+
continue
|
|
408
|
+
if profile in allowed:
|
|
409
|
+
continue
|
|
410
|
+
_purge_runtime_profile(profile)
|
|
411
|
+
purged.append(profile)
|
|
412
|
+
for orphan in ("u-testalan-workframe-agent", "andy"):
|
|
413
|
+
if orphan in _srv()._list_profiles() and orphan not in allowed and orphan not in purged:
|
|
414
|
+
_purge_runtime_profile(orphan)
|
|
415
|
+
purged.append(orphan)
|
|
416
|
+
profiles_dir = _srv().HERMES_DATA / "profiles"
|
|
417
|
+
if profiles_dir.is_dir():
|
|
418
|
+
for entry in profiles_dir.iterdir():
|
|
419
|
+
name = entry.name
|
|
420
|
+
if not entry.is_dir() or name in allowed or name in purged:
|
|
421
|
+
continue
|
|
422
|
+
if name == "andy" or (name.startswith("u-test") and _srv()._is_runtime_profile_slug(name)):
|
|
423
|
+
_purge_runtime_profile(name)
|
|
424
|
+
purged.append(name)
|
|
425
|
+
return {"ok": True, "purged": purged, "allowed_count": len(allowed)}
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _cohort_manifest_markdown(user_id: str, cohort: list[dict[str, Any]]) -> str:
|
|
429
|
+
owner = _user_owner_name(user_id)
|
|
430
|
+
lines = [
|
|
431
|
+
f"# {owner}'s Workframe cohort",
|
|
432
|
+
"",
|
|
433
|
+
"This file is auto-generated by Workframe. Read it before kanban, cron, or delegation.",
|
|
434
|
+
"",
|
|
435
|
+
"## Rules",
|
|
436
|
+
"",
|
|
437
|
+
"- Use **runtime_slug** (column below) for kanban `--assignee`, cron agent targets, and `delegate_task` profile — never bare template names like `architect`.",
|
|
438
|
+
"- Template profiles (`architect`, `dev`, …) are shared disks **without your API keys**; workers exit immediately.",
|
|
439
|
+
"- Only interact with profiles listed here. Do not read other users' `u-*` profile `.env` files.",
|
|
440
|
+
"- Valid kanban `workspace_kind`: `scratch`, `dir:/absolute/path`, `worktree` — not `project`.",
|
|
441
|
+
"- Kanban workers must call `kanban_complete` or `kanban_block` before exit.",
|
|
442
|
+
"- Hermes CLI: `/opt/hermes/.venv/bin/hermes -p <runtime_slug> …`",
|
|
443
|
+
"",
|
|
444
|
+
"## Your agents",
|
|
445
|
+
"",
|
|
446
|
+
"| Role | You call them | runtime_slug (kanban/delegate) | alias |",
|
|
447
|
+
"|------|---------------|--------------------------------|-------|",
|
|
448
|
+
]
|
|
449
|
+
for row in cohort:
|
|
450
|
+
lines.append(
|
|
451
|
+
f"| {row.get('role', '')} | {row.get('display_name', '')} | `{row.get('runtime_slug', '')}` | `{row.get('alias', '')}` |"
|
|
452
|
+
)
|
|
453
|
+
lines.extend(["", f"Owner user_id: `{user_id}`", ""])
|
|
454
|
+
return "\n".join(lines)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _write_workframe_cohort_manifest(user_id: str, workspace_id: str, cohort: list[dict[str, Any]]) -> None:
|
|
458
|
+
if not cohort:
|
|
459
|
+
return
|
|
460
|
+
body = _cohort_manifest_markdown(user_id, cohort)
|
|
461
|
+
for row in cohort:
|
|
462
|
+
runtime = str(row.get("runtime_slug") or "").strip()
|
|
463
|
+
if not runtime:
|
|
464
|
+
continue
|
|
465
|
+
path = _srv()._profile_dir(runtime) / "WORKFRAME_COHORT.md"
|
|
466
|
+
try:
|
|
467
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
468
|
+
path.write_text(body, encoding="utf-8")
|
|
469
|
+
except OSError:
|
|
470
|
+
pass
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def ensure_user_agent_cohort(user_id: str, workspace_id: str) -> list[dict[str, Any]]:
|
|
474
|
+
"""Provision runtime profiles + credentials for every workspace agent this user may orchestrate."""
|
|
475
|
+
user_id = str(user_id or "").strip()
|
|
476
|
+
workspace_id = str(workspace_id or "").strip()
|
|
477
|
+
if not user_id or not workspace_id:
|
|
478
|
+
return []
|
|
479
|
+
cohort: list[dict[str, Any]] = []
|
|
480
|
+
conn = _srv()._workframe_db()
|
|
481
|
+
try:
|
|
482
|
+
rows = conn.execute(
|
|
483
|
+
"""
|
|
484
|
+
SELECT slug, display_name, tagline, role, avatar_url, is_native
|
|
485
|
+
FROM agent_profiles
|
|
486
|
+
WHERE workspace_id = ? AND deleted_at IS NULL
|
|
487
|
+
ORDER BY is_native DESC, created_at ASC
|
|
488
|
+
""",
|
|
489
|
+
(workspace_id,),
|
|
490
|
+
).fetchall()
|
|
491
|
+
finally:
|
|
492
|
+
conn.close()
|
|
493
|
+
for row in rows:
|
|
494
|
+
template = str(row["slug"] or "").strip()
|
|
495
|
+
if not template:
|
|
496
|
+
continue
|
|
497
|
+
runtime = _runtime_profile_slug(user_id, template)
|
|
498
|
+
if not _runtime_profile_ready(runtime):
|
|
499
|
+
try:
|
|
500
|
+
ensure_runtime_profile(runtime, template, user_id, workspace_id)
|
|
501
|
+
except ValueError:
|
|
502
|
+
continue
|
|
503
|
+
ident = _srv()._agent_identity_fields(template, workspace_id, user_id)
|
|
504
|
+
role = str(ident.get("role") or row["display_name"] or template).strip()
|
|
505
|
+
cohort.append(
|
|
506
|
+
{
|
|
507
|
+
"template_slug": template,
|
|
508
|
+
"runtime_slug": runtime,
|
|
509
|
+
"display_name": _runtime_display_label(user_id, template, workspace_id),
|
|
510
|
+
"kanban_assignee": runtime,
|
|
511
|
+
"alias": _cohort_alias(user_id, template),
|
|
512
|
+
"role": role,
|
|
513
|
+
"tagline": str(ident.get("tagline") or row["tagline"] or ""),
|
|
514
|
+
"avatar_url": ident.get("avatar_url"),
|
|
515
|
+
"avatar_id": ident.get("avatar_id"),
|
|
516
|
+
"is_native": bool(int(row["is_native"] or 0)),
|
|
517
|
+
}
|
|
518
|
+
)
|
|
519
|
+
_write_workframe_cohort_manifest(user_id, workspace_id, cohort)
|
|
520
|
+
return cohort
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _runtime_gateway_registered(runtime: str) -> bool:
|
|
524
|
+
runtime = _srv().safe_profile_slug(str(runtime or "").strip())
|
|
525
|
+
if not runtime:
|
|
526
|
+
return False
|
|
527
|
+
now = time.monotonic()
|
|
528
|
+
cached = _gateway_registered_cache.get(runtime)
|
|
529
|
+
if cached and now - cached[1] < _GATEWAY_REG_TTL_SEC:
|
|
530
|
+
return cached[0]
|
|
531
|
+
if _srv().SECURE_MODE and os.environ.get("WORKFRAME_SUPERVISOR_ALLOW_RAW_EXEC", "0") != "1":
|
|
532
|
+
state = str(_srv().gateway_data(runtime).get("state") or "").lower()
|
|
533
|
+
ok = state in {"running", "starting"} or _srv()._profile_api_healthy(runtime, timeout=0.8)
|
|
534
|
+
_gateway_registered_cache[runtime] = (ok, now)
|
|
535
|
+
return ok
|
|
536
|
+
script = (
|
|
537
|
+
"from pathlib import Path\n"
|
|
538
|
+
f"p=Path('/run/service/gateway-{runtime}/run')\n"
|
|
539
|
+
"raise SystemExit(0 if p.is_file() else 1)\n"
|
|
540
|
+
)
|
|
541
|
+
code, _ = _srv()._gateway_container_exec(["/opt/hermes/.venv/bin/python", "-c", script])
|
|
542
|
+
ok = code == 0
|
|
543
|
+
_gateway_registered_cache[runtime] = (ok, now)
|
|
544
|
+
return ok
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _invalidate_gateway_registered_cache(runtime: str = "") -> None:
|
|
548
|
+
if runtime:
|
|
549
|
+
_gateway_registered_cache.pop(_srv().safe_profile_slug(runtime), None)
|
|
550
|
+
else:
|
|
551
|
+
_gateway_registered_cache.clear()
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _runtime_profile_on_disk(runtime: str) -> bool:
|
|
555
|
+
p = _srv()._profile_dir(runtime)
|
|
556
|
+
return p.is_dir() and _srv()._profile_config_path(runtime) is not None
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _runtime_profile_ready(runtime: str) -> bool:
|
|
560
|
+
return _runtime_profile_on_disk(runtime) and _runtime_gateway_registered(runtime)
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _purge_runtime_profile(runtime: str) -> None:
|
|
564
|
+
"""Drop Hermes registry entry and on-disk dir (best-effort)."""
|
|
565
|
+
try:
|
|
566
|
+
_srv()._gateway_exec(_srv()._primary_profile(), ["profile", "delete", "-y", runtime])
|
|
567
|
+
except Exception: # noqa: BLE001
|
|
568
|
+
pass
|
|
569
|
+
shutil.rmtree(_srv()._profile_dir(runtime), ignore_errors=True)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _register_runtime_profile(runtime: str, template: str) -> None:
|
|
573
|
+
cmd = ["profile", "create", "--clone-from", template, runtime]
|
|
574
|
+
code, out = _srv()._gateway_exec(_srv()._primary_profile(), cmd)
|
|
575
|
+
if code == 0:
|
|
576
|
+
_inherit_runtime_profile_config(runtime, template)
|
|
577
|
+
return
|
|
578
|
+
out_l = out.lower()
|
|
579
|
+
if "already exists" in out_l:
|
|
580
|
+
if _srv()._profile_config_path(runtime) is not None:
|
|
581
|
+
return # ponytail: Hermes registry + on-disk clone — adopt, don't recreate
|
|
582
|
+
_purge_runtime_profile(runtime)
|
|
583
|
+
code, out = _srv()._gateway_exec(_srv()._primary_profile(), cmd)
|
|
584
|
+
if code == 0:
|
|
585
|
+
_inherit_runtime_profile_config(runtime, template)
|
|
586
|
+
return
|
|
587
|
+
if code != 0:
|
|
588
|
+
raise ValueError(f"runtime profile create failed: {out.strip()}")
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _copy_tree_missing(src: Path, dst: Path) -> None:
|
|
592
|
+
"""Copy files from src into dst only when dst path is absent (never overwrite SOUL edits)."""
|
|
593
|
+
if not src.is_dir():
|
|
594
|
+
return
|
|
595
|
+
dst.mkdir(parents=True, exist_ok=True)
|
|
596
|
+
for item in src.rglob("*"):
|
|
597
|
+
if not item.is_file():
|
|
598
|
+
continue
|
|
599
|
+
rel = item.relative_to(src)
|
|
600
|
+
target = dst / rel
|
|
601
|
+
if target.is_file():
|
|
602
|
+
continue
|
|
603
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
604
|
+
try:
|
|
605
|
+
shutil.copy2(item, target)
|
|
606
|
+
except OSError:
|
|
607
|
+
pass
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _copy_text_without_bom(src: Path, dst: Path) -> None:
|
|
611
|
+
"""Copy markdown identity files without UTF-8 BOM (Hermes blocks BOM in context files)."""
|
|
612
|
+
raw = src.read_bytes()
|
|
613
|
+
if raw.startswith(b"\xef\xbb\xbf"):
|
|
614
|
+
raw = raw[3:]
|
|
615
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
616
|
+
dst.write_bytes(raw)
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _ensure_profile_terminal_cwd(profile: str, *, cwd: str = "/workspace") -> None:
|
|
620
|
+
"""Hermes terminal + code_execution project root must be the shared workspace."""
|
|
621
|
+
config_path = _srv()._profile_gateway_config_path(profile)
|
|
622
|
+
if not config_path or not config_path.is_file():
|
|
623
|
+
return
|
|
624
|
+
try:
|
|
625
|
+
raw = config_path.read_text(encoding="utf-8")
|
|
626
|
+
except OSError:
|
|
627
|
+
return
|
|
628
|
+
fixed = re.sub(r"(?m)^ cwd: .*$", f" cwd: {cwd}", raw, count=1)
|
|
629
|
+
if fixed == raw:
|
|
630
|
+
return
|
|
631
|
+
try:
|
|
632
|
+
config_path.write_text(fixed, encoding="utf-8")
|
|
633
|
+
except OSError:
|
|
634
|
+
pass
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _backfill_runtime_identity(runtime: str, template: str) -> None:
|
|
638
|
+
"""Fill missing SOUL/skills from template — credentials overlay never touches these."""
|
|
639
|
+
runtime = _srv().safe_profile_slug(runtime)
|
|
640
|
+
template = _srv().safe_profile_slug(template)
|
|
641
|
+
rdir = _srv()._profile_dir(runtime)
|
|
642
|
+
tdir = _srv()._profile_dir(template)
|
|
643
|
+
if not rdir.is_dir() or not tdir.is_dir():
|
|
644
|
+
return
|
|
645
|
+
for name in ("SOUL.md", "AGENTS.md", "SETUP.md"):
|
|
646
|
+
src = tdir / name
|
|
647
|
+
dst = rdir / name
|
|
648
|
+
if name == "SOUL.md":
|
|
649
|
+
if _srv()._write_profile_soul_if_stub(runtime, template):
|
|
650
|
+
continue
|
|
651
|
+
if src.is_file() and not dst.is_file():
|
|
652
|
+
try:
|
|
653
|
+
_copy_text_without_bom(src, dst)
|
|
654
|
+
except OSError:
|
|
655
|
+
pass
|
|
656
|
+
_copy_tree_missing(tdir / "skills", rdir / "skills")
|
|
657
|
+
_ensure_profile_terminal_cwd(runtime)
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _load_profile_yaml_dict(profile: str) -> dict[str, Any]:
|
|
661
|
+
_srv()._normalize_profile_config_yaml(profile)
|
|
662
|
+
cfg = _srv()._profile_config_path(profile)
|
|
663
|
+
if not cfg or not cfg.is_file():
|
|
664
|
+
return {}
|
|
665
|
+
try:
|
|
666
|
+
data = yaml.safe_load(cfg.read_text(encoding="utf-8"))
|
|
667
|
+
return data if isinstance(data, dict) else {}
|
|
668
|
+
except Exception: # noqa: BLE001
|
|
669
|
+
return {}
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def _profile_toolsets_ready(profile: str, want: tuple[str, ...] | list[str]) -> bool:
|
|
673
|
+
cfg = _load_profile_yaml_dict(profile)
|
|
674
|
+
if not cfg:
|
|
675
|
+
return False
|
|
676
|
+
names = [str(n) for n in want if str(n).strip()]
|
|
677
|
+
if not names:
|
|
678
|
+
return True
|
|
679
|
+
ts = cfg.get("toolsets") if isinstance(cfg.get("toolsets"), list) else []
|
|
680
|
+
if not all(name in ts for name in names):
|
|
681
|
+
return False
|
|
682
|
+
pts = cfg.get("platform_toolsets") if isinstance(cfg.get("platform_toolsets"), dict) else {}
|
|
683
|
+
for platform in ("api_server", "cli"):
|
|
684
|
+
plat = pts.get(platform) if isinstance(pts.get(platform), list) else []
|
|
685
|
+
if not all(name in plat for name in names):
|
|
686
|
+
return False
|
|
687
|
+
agent = cfg.get("agent") if isinstance(cfg.get("agent"), dict) else {}
|
|
688
|
+
disabled = agent.get("disabled_toolsets") if isinstance(agent.get("disabled_toolsets"), list) else []
|
|
689
|
+
return not any(name in disabled for name in names)
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _inherit_runtime_profile_config(runtime: str, template: str) -> None:
|
|
693
|
+
"""Hermes clone-from copies .env/SOUL/skills but may omit profile.yaml on u-* slugs."""
|
|
694
|
+
runtime = _srv().safe_profile_slug(runtime)
|
|
695
|
+
template = _srv().safe_profile_slug(template)
|
|
696
|
+
if _srv()._profile_config_path(runtime) is not None:
|
|
697
|
+
return
|
|
698
|
+
src = _srv()._profile_config_path(template)
|
|
699
|
+
if src is None:
|
|
700
|
+
raise ValueError(f"template profile has no config: {template}")
|
|
701
|
+
dst = _srv()._profile_dir(runtime) / src.name
|
|
702
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
703
|
+
shutil.copy2(src, dst)
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def ensure_runtime_profile(
|
|
707
|
+
runtime_slug: str,
|
|
708
|
+
template_slug: str,
|
|
709
|
+
user_id: str,
|
|
710
|
+
workspace_id: str = "",
|
|
711
|
+
) -> None:
|
|
712
|
+
runtime = _srv().safe_profile_slug(runtime_slug)
|
|
713
|
+
template = _srv().resolve_validated_profile(template_slug)
|
|
714
|
+
if _runtime_profile_on_disk(runtime):
|
|
715
|
+
if not _runtime_gateway_registered(runtime):
|
|
716
|
+
try:
|
|
717
|
+
_register_runtime_profile(runtime, template)
|
|
718
|
+
except ValueError:
|
|
719
|
+
pass
|
|
720
|
+
ok, out, _port = _srv()._configure_profile_api(runtime)
|
|
721
|
+
if not ok:
|
|
722
|
+
raise ValueError(f"runtime profile api config failed: {out}")
|
|
723
|
+
skills_dir = _srv()._profile_dir(runtime) / "skills"
|
|
724
|
+
if not skills_dir.is_dir() or not any(skills_dir.iterdir()):
|
|
725
|
+
_backfill_runtime_identity(runtime, template)
|
|
726
|
+
if not _profile_toolsets_ready(runtime, _srv()._chat_toolsets_for_profile(runtime)):
|
|
727
|
+
_srv()._ensure_profile_toolsets(runtime)
|
|
728
|
+
_ensure_profile_terminal_cwd(runtime)
|
|
729
|
+
# ponytail: do not re-sync template model on every bind — destroys per-user reconcile
|
|
730
|
+
return
|
|
731
|
+
_purge_runtime_profile(runtime)
|
|
732
|
+
_register_runtime_profile(runtime, template)
|
|
733
|
+
if _srv()._profile_config_path(runtime) is None:
|
|
734
|
+
raise ValueError(f"runtime profile bootstrap failed: {runtime}")
|
|
735
|
+
# SOUL: clone inherits template at profile create; do not auto-overwrite on disk later.
|
|
736
|
+
soul_src = _srv()._profile_dir(template) / "SOUL.md"
|
|
737
|
+
soul_dst = _srv()._profile_dir(runtime) / "SOUL.md"
|
|
738
|
+
if soul_src.is_file() and soul_dst.parent.is_dir() and not soul_dst.is_file():
|
|
739
|
+
try:
|
|
740
|
+
shutil.copy2(soul_src, soul_dst)
|
|
741
|
+
except OSError:
|
|
742
|
+
pass
|
|
743
|
+
_srv()._write_profile_soul_if_stub(runtime, template)
|
|
744
|
+
_backfill_runtime_identity(runtime, template)
|
|
745
|
+
_srv()._strip_profile_llm_env(runtime)
|
|
746
|
+
_srv()._strip_profile_action_env(runtime)
|
|
747
|
+
ok, out, _port = _srv()._configure_profile_api(runtime)
|
|
748
|
+
if not ok:
|
|
749
|
+
raise ValueError(f"runtime profile api config failed: {out}")
|
|
750
|
+
_srv()._ensure_profile_toolsets(runtime)
|
|
751
|
+
_ensure_profile_terminal_cwd(runtime)
|
|
752
|
+
_prepare_runtime_profile_credentials(runtime, user_id, workspace_id)
|