create-workframe 0.1.11 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/README.md +1 -1
  2. package/bin/create-workframe.js +2 -2
  3. package/bin/workframe.js +130 -2
  4. package/docs/security.md +85 -0
  5. package/package.json +1 -1
  6. package/scripts/bundle-workframe-ui.mjs +9 -0
  7. package/scripts/sync-canonical-to-package.mjs +9 -0
  8. package/scripts/verify-public-deploy.sh +121 -3
  9. package/workframe-api/action_proxy.py +23 -17
  10. package/workframe-api/activity_feed.py +798 -0
  11. package/workframe-api/api_meta.py +157 -0
  12. package/workframe-api/auth_gate.py +566 -0
  13. package/workframe-api/avatar_registry.py +343 -0
  14. package/workframe-api/broker_audit.py +164 -0
  15. package/workframe-api/cell_authority.py +231 -0
  16. package/workframe-api/chat_bind.py +319 -0
  17. package/workframe-api/chat_sessions.py +509 -0
  18. package/workframe-api/chat_stream.py +694 -0
  19. package/workframe-api/cleanup_dogfood_smoke.py +252 -0
  20. package/workframe-api/credential_broker.py +162 -0
  21. package/workframe-api/credential_resolve.py +202 -0
  22. package/workframe-api/credential_store.py +245 -0
  23. package/workframe-api/crew_registry.py +250 -0
  24. package/workframe-api/db_schema.py +755 -0
  25. package/workframe-api/docker_gateway.py +166 -0
  26. package/workframe-api/doctor_runtime.py +109 -0
  27. package/workframe-api/domain/__init__.py +47 -0
  28. package/workframe-api/domain/entities.py +252 -0
  29. package/workframe-api/domain/schema/workframe-domain.schema.json +278 -0
  30. package/workframe-api/egress_policy.py +42 -0
  31. package/workframe-api/handler_modules/__init__.py +17 -0
  32. package/workframe-api/handler_modules/handler_admin.py +542 -0
  33. package/workframe-api/handler_modules/handler_auth.py +512 -0
  34. package/workframe-api/handler_modules/handler_chat.py +641 -0
  35. package/workframe-api/handler_modules/handler_install.py +178 -0
  36. package/workframe-api/handler_modules/handler_provider.py +325 -0
  37. package/workframe-api/handler_modules/handler_workspace.py +1420 -0
  38. package/workframe-api/health_monitor.py +80 -0
  39. package/workframe-api/hermes_admin.py +756 -0
  40. package/workframe-api/hermes_profiles.py +1328 -0
  41. package/workframe-api/install_api.py +123 -0
  42. package/workframe-api/kanban_cron.py +473 -0
  43. package/workframe-api/lane_bindings.py +423 -0
  44. package/workframe-api/llm_proxy.py +17 -43
  45. package/workframe-api/mention_helpers.py +180 -0
  46. package/workframe-api/mention_invoke.py +431 -0
  47. package/workframe-api/model_surface.py +1139 -0
  48. package/workframe-api/oauth_pending.py +85 -0
  49. package/workframe-api/oauth_redirect.py +381 -0
  50. package/workframe-api/openrouter_catalog.py +4 -4
  51. package/workframe-api/package.json +2 -2
  52. package/workframe-api/profile_api_lifecycle.py +66 -0
  53. package/workframe-api/profile_config_yaml.py +126 -0
  54. package/workframe-api/profile_gateway.py +436 -0
  55. package/workframe-api/provider_bindings.py +673 -0
  56. package/workframe-api/provider_bootstrap.py +347 -0
  57. package/workframe-api/provider_catalog.py +180 -0
  58. package/workframe-api/rooms.py +1613 -0
  59. package/workframe-api/route_registry.py +470 -0
  60. package/workframe-api/run-typecheck.mjs +26 -0
  61. package/workframe-api/run_authority.py +287 -0
  62. package/workframe-api/run_ledger.py +470 -0
  63. package/workframe-api/run_surface_wiring.py +344 -0
  64. package/workframe-api/runtime_cohort.py +752 -0
  65. package/workframe-api/runtime_tokens.py +127 -0
  66. package/workframe-api/server.py +1376 -19305
  67. package/workframe-api/snapshot_feed.py +49 -0
  68. package/workframe-api/stack_config.py +33 -1
  69. package/workframe-api/supervisor_client.py +154 -0
  70. package/workframe-api/test_api_meta_build_stamp.py +34 -0
  71. package/workframe-api/test_cell_authority.py +79 -0
  72. package/workframe-api/test_chat_bind.py +48 -0
  73. package/workframe-api/test_credential_lease_matrix.py +171 -0
  74. package/workframe-api/test_credential_resolve.py +51 -0
  75. package/workframe-api/test_credential_store.py +27 -0
  76. package/workframe-api/test_dogfood_flows.py +346 -0
  77. package/workframe-api/test_domain_entities.py +152 -0
  78. package/workframe-api/test_exception_hygiene.py +12 -0
  79. package/workframe-api/test_hermes_admin.py +72 -0
  80. package/workframe-api/test_install_wizard_resume.py +78 -0
  81. package/workframe-api/test_local_bootstrap.py +67 -0
  82. package/workframe-api/test_mention_helpers.py +35 -0
  83. package/workframe-api/test_mention_invoke.py +26 -0
  84. package/workframe-api/test_model_surface_consistency.py +117 -0
  85. package/workframe-api/test_oauth_pending.py +41 -0
  86. package/workframe-api/test_profile_model_yaml.py +99 -0
  87. package/workframe-api/test_provider_bindings.py +52 -0
  88. package/workframe-api/test_provider_catalog.py +28 -0
  89. package/workframe-api/test_public_routes.py +41 -0
  90. package/workframe-api/test_route_registry.py +245 -0
  91. package/workframe-api/test_run_authority.py +112 -0
  92. package/workframe-api/test_run_ledger.py +157 -0
  93. package/workframe-api/test_run_surface_wiring.py +130 -0
  94. package/workframe-api/test_runtime_cohort.py +85 -0
  95. package/workframe-api/test_secure_mode_docker_boundary.py +39 -0
  96. package/workframe-api/test_server_reexports.py +99 -0
  97. package/workframe-api/test_stack_config_install_smtp.py +7 -0
  98. package/workframe-api/time-bind-steps.py +75 -0
  99. package/workframe-api/time-perf.py +81 -0
  100. package/workframe-api/time-warm.py +107 -0
  101. package/workframe-api/turn_credentials.py +28 -13
  102. package/workframe-api/turn_overlay.py +715 -0
  103. package/workframe-api/updates.py +16 -1
  104. package/workframe-api/user_prefs.py +387 -0
  105. package/workframe-api/wf032_extract_remaining_handlers.py +417 -0
  106. package/workframe-api/workspace_bootstrap.py +536 -0
  107. package/workframe-api/workspace_files.py +344 -0
  108. package/workframe-api/workspace_messaging.py +206 -0
  109. package/workframe-api/zk_auth.py +3 -2
  110. package/workframe-supervisor/server.py +10 -1
  111. package/workframe-supervisor/test_supervisor_negative.py +75 -0
  112. package/workframe-ui/public/assets/{arc-DjpS9a2b.js → arc-aUUffclm.js} +1 -1
  113. package/workframe-ui/public/assets/architecture-7EHR7CIX-BSUP4S8J.js +1 -0
  114. package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-puCKmKHV.js → architectureDiagram-3BPJPVTR-DU6oaqIb.js} +1 -1
  115. package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-C2P0g-eH.js → blockDiagram-GPEHLZMM-D2p8BU6-.js} +1 -1
  116. package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-DdvYEITH.js → c4Diagram-AAUBKEIU-ouwyPmTJ.js} +1 -1
  117. package/workframe-ui/public/assets/channel-DOunZZ0X.js +1 -0
  118. package/workframe-ui/public/assets/{chunk-2J33WTMH-Cxe8QsEF.js → chunk-2J33WTMH-DsE7U5dj.js} +1 -1
  119. package/workframe-ui/public/assets/{chunk-3OPIFGDE-DPNyTwR3.js → chunk-3OPIFGDE-DM0kkZ9h.js} +1 -1
  120. package/workframe-ui/public/assets/{chunk-4BX2VUAB-XKG9gfRN.js → chunk-4BX2VUAB-CQ8mAyXp.js} +1 -1
  121. package/workframe-ui/public/assets/{chunk-55IACEB6-oPMTulgb.js → chunk-55IACEB6-DN1BDtbH.js} +1 -1
  122. package/workframe-ui/public/assets/{chunk-5ZQYHXKU-BWwXvpa_.js → chunk-5ZQYHXKU-BzK2KqOt.js} +1 -1
  123. package/workframe-ui/public/assets/{chunk-727SXJPM-Cml2twO_.js → chunk-727SXJPM-C3AxtOi9.js} +1 -1
  124. package/workframe-ui/public/assets/{chunk-AQP2D5EJ-DzrRBhQu.js → chunk-AQP2D5EJ-4mVwEFuD.js} +1 -1
  125. package/workframe-ui/public/assets/{chunk-BSJP7CBP-EZ7STU6h.js → chunk-BSJP7CBP-23kN2q0A.js} +1 -1
  126. package/workframe-ui/public/assets/{chunk-CSCIHK7Q-BGTQzWYw.js → chunk-CSCIHK7Q-CPBKVW-L.js} +2 -2
  127. package/workframe-ui/public/assets/{chunk-FMBD7UC4-Cm4stfRa.js → chunk-FMBD7UC4-CKqcYq7K.js} +1 -1
  128. package/workframe-ui/public/assets/{chunk-KSCS5N6A-exbMijjy.js → chunk-KSCS5N6A-JRQnhxQE.js} +1 -1
  129. package/workframe-ui/public/assets/{chunk-L5ZTLDWV-C75kbQwN.js → chunk-L5ZTLDWV-BLZrdIwa.js} +1 -1
  130. package/workframe-ui/public/assets/chunk-LZXEDZCA-DisG0XJT.js +2 -0
  131. package/workframe-ui/public/assets/{chunk-ND2GUHAM-D84zyIu2.js → chunk-ND2GUHAM-ClAOxArS.js} +1 -1
  132. package/workframe-ui/public/assets/{chunk-NZK2D7GU-CUNNo1bj.js → chunk-NZK2D7GU-XUE1aNkm.js} +1 -1
  133. package/workframe-ui/public/assets/{chunk-O5CBEL6O-DN6WEIh-.js → chunk-O5CBEL6O-DDN-Ifon.js} +1 -1
  134. package/workframe-ui/public/assets/chunk-QZHKN3VN-EVQ0VMd5.js +1 -0
  135. package/workframe-ui/public/assets/chunk-WU5MYG2G-k_-ZsLOS.js +1 -0
  136. package/workframe-ui/public/assets/{chunk-XPW4576I-Ct5VQv2D.js → chunk-XPW4576I-B4_iYZ6r.js} +1 -1
  137. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-W9WVQsby.js +1 -0
  138. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-W9WVQsby.js +1 -0
  139. package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-Bj4W5oMk.js → cose-bilkent-S5V4N54A-Cy1HhS7j.js} +1 -1
  140. package/workframe-ui/public/assets/{dagre-BM42HDAG-DAjHBkAy.js → dagre-BM42HDAG-Bjal81Ie.js} +1 -1
  141. package/workframe-ui/public/assets/{diagram-2AECGRRQ-Ch2gGFPG.js → diagram-2AECGRRQ-v5Gdvzp_.js} +1 -1
  142. package/workframe-ui/public/assets/{diagram-5GNKFQAL-BzIIvgYH.js → diagram-5GNKFQAL-DuyRdfmY.js} +1 -1
  143. package/workframe-ui/public/assets/{diagram-KO2AKTUF-Df0Q8nv6.js → diagram-KO2AKTUF-WwWCGbIW.js} +1 -1
  144. package/workframe-ui/public/assets/{diagram-LMA3HP47-D-G3ekmF.js → diagram-LMA3HP47-DTYwZALT.js} +1 -1
  145. package/workframe-ui/public/assets/{diagram-OG6HWLK6-TBHhT9f5.js → diagram-OG6HWLK6-B46kBIqE.js} +1 -1
  146. package/workframe-ui/public/assets/{dist-CPbiDkEZ.js → dist-mQWmB3z6.js} +1 -1
  147. package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-YxkUmLLV.js → erDiagram-TEJ5UH35-__40xO-y.js} +1 -1
  148. package/workframe-ui/public/assets/eventmodeling-FCH6USID-Cm1uRTxU.js +1 -0
  149. package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-kfZ7-RSd.js → flowDiagram-I6XJVG4X-Cwj6dVUz.js} +1 -1
  150. package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-DmTx3hzR.js → ganttDiagram-6RSMTGT7-D_IOSwAS.js} +1 -1
  151. package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-BXa0ewp3.js → gitGraph-WXDBUCRP-C1vGecee.js} +1 -1
  152. package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-CQr2qMxU.js → gitGraphDiagram-PVQCEYII-DMTxFJxn.js} +1 -1
  153. package/workframe-ui/public/assets/index-BdiM4-lI.js +130 -0
  154. package/workframe-ui/public/assets/index-Fnad47vV.css +1 -0
  155. package/workframe-ui/public/assets/{info-J43DQDTF-t-Qlx8zv.js → info-J43DQDTF-CwjmyPmD.js} +1 -1
  156. package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-CzkYofPx.js → infoDiagram-5YYISTIA-BGzh7ZL2.js} +1 -1
  157. package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-djK3Oxgb.js → ishikawaDiagram-YF4QCWOH-BLXdnR_5.js} +1 -1
  158. package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-CWxYXxdF.js → journeyDiagram-JHISSGLW-BK3behMe.js} +1 -1
  159. package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-D7leCAD9.js → kanban-definition-UN3LZRKU-Bx172cCl.js} +1 -1
  160. package/workframe-ui/public/assets/{line-BSFj2OC5.js → line-BTCEHb7l.js} +1 -1
  161. package/workframe-ui/public/assets/{linear-pfPwnu-o.js → linear-CkbydpnK.js} +1 -1
  162. package/workframe-ui/public/assets/{mermaid-parser.core-Dm2L9Ib_.js → mermaid-parser.core-DvrtArpk.js} +2 -2
  163. package/workframe-ui/public/assets/{mermaid.core-Bevx9bif.js → mermaid.core-yj_1x_qj.js} +3 -3
  164. package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-UkjDssmP.js → mindmap-definition-RKZ34NQL-BXbMltQ0.js} +1 -1
  165. package/workframe-ui/public/assets/{packet-YPE3B663-JO2ezH3_.js → packet-YPE3B663-9pVCBQ7S.js} +1 -1
  166. package/workframe-ui/public/assets/{pie-LRSECV5Y-DdMFsfR8.js → pie-LRSECV5Y-CdqoWesP.js} +1 -1
  167. package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-CEPHFBU1.js → pieDiagram-4H26LBE5-kGs-VfXd.js} +1 -1
  168. package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-_RF9_86k.js → quadrantDiagram-W4KKPZXB-D9D8gQw5.js} +1 -1
  169. package/workframe-ui/public/assets/{radar-GUYGQ44K-Cb-YfdaA.js → radar-GUYGQ44K-d4zMZpQS.js} +1 -1
  170. package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-DBVOxmBn.js → requirementDiagram-4Y6WPE33-BI7EQ9zc.js} +1 -1
  171. package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-BTpxsGUA.js → sankeyDiagram-5OEKKPKP-Bcz19jQB.js} +1 -1
  172. package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-DHP_Bq3l.js → sequenceDiagram-3UESZ5HK-iQiCEfYp.js} +1 -1
  173. package/workframe-ui/public/assets/{src-CY_By-r7.js → src-D6mvlP96.js} +1 -1
  174. package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BVtp_LPR.js → stateDiagram-AJRCARHV-CxaTXs02.js} +1 -1
  175. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CXikj9xi.js +1 -0
  176. package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-D38HJhop.js → timeline-definition-PNZ67QCA-Bej-EAnf.js} +1 -1
  177. package/workframe-ui/public/assets/{treeView-BLDUP644-CFCG3Sc_.js → treeView-BLDUP644-C7PnnzwX.js} +1 -1
  178. package/workframe-ui/public/assets/{treemap-LRROVOQU-BeiMs_dF.js → treemap-LRROVOQU-CqISLmkO.js} +1 -1
  179. package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-CKMeT6PW.js → vennDiagram-CIIHVFJN-eJMTTEW-.js} +1 -1
  180. package/workframe-ui/public/assets/{wardley-L42UT6IY-BljDy8ph.js → wardley-L42UT6IY-D4sMroBF.js} +1 -1
  181. package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DEE8D94J.js → wardleyDiagram-YWT4CUSO-DC3DCUs7.js} +1 -1
  182. package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-BRaAWV_g.js → xychartDiagram-2RQKCTM6-BeMaM6Aq.js} +1 -1
  183. package/workframe-ui/public/index.html +7 -6
  184. package/workframe-ui/public/workframe-build.json +5 -0
  185. package/workframe-ui/public/assets/architecture-7EHR7CIX-CvhbXEvt.js +0 -1
  186. package/workframe-ui/public/assets/channel-D63s_yN9.js +0 -1
  187. package/workframe-ui/public/assets/chunk-LZXEDZCA-CsRdBqUB.js +0 -2
  188. package/workframe-ui/public/assets/chunk-QZHKN3VN-Dj_Yxhgo.js +0 -1
  189. package/workframe-ui/public/assets/chunk-WU5MYG2G-AFchshb6.js +0 -1
  190. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-Opj8t01I.js +0 -1
  191. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-Opj8t01I.js +0 -1
  192. package/workframe-ui/public/assets/eventmodeling-FCH6USID-LQOYL50n.js +0 -1
  193. package/workframe-ui/public/assets/index-B08aShJy.css +0 -1
  194. package/workframe-ui/public/assets/index-DCF3W3G_.js +0 -129
  195. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CvJhS_BJ.js +0 -1
@@ -0,0 +1,126 @@
1
+ """Single write path for Hermes profile config.yaml model surface (WF-033).
2
+
3
+ Read path stays in server._parse_model_block_from_disk (text scan, no import cycle).
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import yaml
11
+
12
+
13
+ def _salvage_top_level_keys(text: str) -> dict[str, Any]:
14
+ """Keep non-model keys when safe_load cannot parse corrupt yaml (wizard duplicates)."""
15
+ out: dict[str, Any] = {}
16
+ skip = {"model", "fallback_providers"}
17
+ lines = text.splitlines()
18
+ i = 0
19
+ while i < len(lines):
20
+ line = lines[i]
21
+ stripped = line.lstrip()
22
+ if not stripped or stripped.startswith("#") or line.startswith((" ", "\t")):
23
+ i += 1
24
+ continue
25
+ if ":" not in stripped:
26
+ i += 1
27
+ continue
28
+ key = stripped.split(":", 1)[0].strip()
29
+ if key in skip:
30
+ i += 1
31
+ while i < len(lines) and (not lines[i].strip() or lines[i].startswith((" ", "\t", "#"))):
32
+ i += 1
33
+ continue
34
+ after = stripped.split(":", 1)[1].strip()
35
+ if after:
36
+ try:
37
+ out[key] = yaml.safe_load(after)
38
+ except yaml.YAMLError:
39
+ out[key] = after.strip().strip("'\"")
40
+ i += 1
41
+ continue
42
+ block = [line]
43
+ i += 1
44
+ while i < len(lines) and (not lines[i].strip() or lines[i].startswith((" ", "\t", "#"))):
45
+ block.append(lines[i])
46
+ i += 1
47
+ try:
48
+ frag = yaml.safe_load("\n".join(block))
49
+ if isinstance(frag, dict) and key in frag:
50
+ out[key] = frag[key]
51
+ else:
52
+ out[key] = None
53
+ except yaml.YAMLError:
54
+ out[key] = None
55
+ return out
56
+
57
+
58
+ def _load_cfg(path: Path) -> dict[str, Any]:
59
+ if not path.is_file():
60
+ return {}
61
+ text = path.read_text(encoding="utf-8")
62
+ if not text.strip():
63
+ return {}
64
+ try:
65
+ data = yaml.safe_load(text)
66
+ except yaml.YAMLError:
67
+ return _salvage_top_level_keys(text)
68
+ return data if isinstance(data, dict) else {}
69
+
70
+
71
+ def update_model_surface(
72
+ config_path: Path,
73
+ *,
74
+ default: str | None = None,
75
+ provider: str | None = None,
76
+ base_url: str | None = None,
77
+ api_key: str | None = None,
78
+ fallback_chain: list[dict[str, str]] | None = None,
79
+ strip_proxy_fields: bool = False,
80
+ coalesce: bool = False,
81
+ ) -> None:
82
+ """Merge model + fallback_providers keys; preserve other top-level yaml keys.
83
+
84
+ ponytail: comments are not preserved (safe_load/safe_dump); read path uses text scan.
85
+ coalesce=True drops duplicate model:/fallback_providers: blocks before rewrite.
86
+ """
87
+ cfg = _load_cfg(config_path)
88
+ if coalesce:
89
+ cfg.pop("model", None)
90
+ cfg.pop("fallback_providers", None)
91
+ model: dict[str, Any] = {}
92
+ else:
93
+ model = cfg.get("model")
94
+ if not isinstance(model, dict):
95
+ model = {}
96
+ if default is not None:
97
+ model["default"] = default
98
+ if provider is not None:
99
+ model["provider"] = provider
100
+ if base_url is not None:
101
+ if base_url:
102
+ model["base_url"] = base_url
103
+ else:
104
+ model.pop("base_url", None)
105
+ if api_key is not None:
106
+ if api_key:
107
+ model["api_key"] = api_key
108
+ else:
109
+ model.pop("api_key", None)
110
+ if strip_proxy_fields:
111
+ model.pop("api_key", None)
112
+ model.pop("base_url", None)
113
+ if model:
114
+ cfg["model"] = model
115
+ if fallback_chain is not None:
116
+ rows = [
117
+ {"provider": str(e.get("provider", "")).strip(), "model": str(e.get("model", "")).strip()}
118
+ for e in fallback_chain
119
+ if isinstance(e, dict) and str(e.get("provider", "")).strip() and str(e.get("model", "")).strip()
120
+ ]
121
+ if rows:
122
+ cfg["fallback_providers"] = rows
123
+ else:
124
+ cfg.pop("fallback_providers", None)
125
+ config_path.parent.mkdir(parents=True, exist_ok=True)
126
+ config_path.write_text(yaml.safe_dump(cfg, sort_keys=False), encoding="utf-8")
@@ -0,0 +1,436 @@
1
+ """WF-032 extract: profile_gateway."""
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 urllib.error
14
+ import urllib.parse
15
+ import urllib.request
16
+ import uuid
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from http.server import BaseHTTPRequestHandler
21
+
22
+ import user_prefs
23
+
24
+
25
+ def _srv():
26
+ import server as srv
27
+
28
+ return srv
29
+
30
+
31
+ def _reload_runtime_profile_gateway(profile: str, *, wait_healthy: bool = True) -> None:
32
+ """Reload Hermes after config.yaml change — gateway restart before stop+start."""
33
+ prof = _srv().resolve_hermes_profile(profile)
34
+ if not _srv()._is_runtime_profile_slug(prof) or prof == _srv()._primary_profile():
35
+ return
36
+ with _srv()._gateway_lifecycle_lock:
37
+ if _profile_api_healthy(prof, timeout=0.5, use_cache=False):
38
+ code, _out = _srv()._gateway_exec(prof, ["gateway", "restart"])
39
+ if code == 0:
40
+ _srv()._invalidate_profile_health_cache(prof)
41
+ if wait_healthy:
42
+ _wait_profile_api_healthy(prof, attempts=48, delay=0.25)
43
+ return
44
+ _srv()._invalidate_profile_health_cache(prof)
45
+ try:
46
+ _srv().profile_gateway_lifecycle(prof, "stop", bootstrap_providers=False)
47
+ except ValueError:
48
+ pass
49
+ _srv().profile_gateway_lifecycle(prof, "start", bootstrap_providers=False)
50
+ if wait_healthy:
51
+ _wait_profile_api_healthy(prof)
52
+
53
+
54
+ def _schedule_gateway_reload(profile: str) -> None:
55
+ """ponytail: model-save hot path — yaml is sync; reload async (~7s, not ~45s)."""
56
+ prof = _srv().resolve_hermes_profile(profile)
57
+ if not _profile_api_healthy(prof, timeout=0.5, use_cache=False):
58
+ return # cold start on next bind via ensure_profile_api
59
+
60
+ def _run() -> None:
61
+ try:
62
+ with _srv()._gateway_lifecycle_lock:
63
+ _reload_runtime_profile_gateway(prof, wait_healthy=False)
64
+ except Exception as exc: # noqa: BLE001
65
+ print(f"[workframe-api] gateway reload failed for {prof}: {exc}")
66
+
67
+ threading.Thread(target=_run, name=f"gw-reload-{prof}", daemon=True).start()
68
+
69
+
70
+ def _restart_runtime_profile_gateway(profile: str) -> None:
71
+ """Hermes api_server caches config.yaml — reload after lease rotation."""
72
+ _reload_runtime_profile_gateway(profile, wait_healthy=True)
73
+ def _profile_api_port(profile: str) -> int:
74
+ if profile == _srv()._primary_profile():
75
+ return 8642
76
+ # Stable per-profile range: 18610..18709
77
+ base = 18610
78
+ span = 100
79
+ h = sum(ord(c) for c in profile) % span
80
+ return base + h
81
+
82
+
83
+ def _configure_profile_api(profile: str) -> tuple[bool, str, int]:
84
+ _srv()._normalize_profile_config_yaml(profile)
85
+ prof = _srv().resolve_hermes_profile(profile)
86
+ port = _profile_api_port(prof)
87
+ cfg_path = _srv()._profile_gateway_config_path(prof)
88
+ if cfg_path is None:
89
+ return False, f"profile not found: {prof}", port
90
+ try:
91
+ import yaml
92
+
93
+ cfg: dict[str, Any] = {}
94
+ if cfg_path.is_file():
95
+ loaded = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
96
+ cfg = loaded if isinstance(loaded, dict) else {}
97
+ plats = cfg.setdefault("platforms", {})
98
+ if not isinstance(plats, dict):
99
+ plats = {}
100
+ cfg["platforms"] = plats
101
+ native = prof == _srv()._primary_profile()
102
+ if not native:
103
+ api_only = plats.get("api_server") if isinstance(plats.get("api_server"), dict) else {}
104
+ plats = {"api_server": api_only}
105
+ cfg["platforms"] = plats
106
+ for name in ("discord", "telegram", "slack", "whatsapp", "webhook", "cron"):
107
+ plats[name] = {"enabled": False}
108
+ api = plats.setdefault("api_server", {})
109
+ if not isinstance(api, dict):
110
+ api = {}
111
+ plats["api_server"] = api
112
+ api["enabled"] = True
113
+ extra = api.setdefault("extra", {})
114
+ if not isinstance(extra, dict):
115
+ extra = {}
116
+ api["extra"] = extra
117
+ extra["host"] = "0.0.0.0"
118
+ extra["port"] = port
119
+ if not str(extra.get("key") or "").strip():
120
+ extra["key"] = "workframe-local-key"
121
+ cfg_path.write_text(yaml.safe_dump(cfg, sort_keys=False), encoding="utf-8")
122
+ return True, "ok", port
123
+ except (OSError, ImportError) as exc:
124
+ return False, str(exc), port
125
+
126
+
127
+ def _patch_profile_gateway_run_script(profile: str) -> tuple[bool, str]:
128
+ if profile == _srv()._primary_profile():
129
+ return True, "native profile unchanged"
130
+ profile_home = f"/opt/data/profiles/{profile}"
131
+ script = (
132
+ "from pathlib import Path\n"
133
+ f"p=Path('/run/service/gateway-{profile}/run')\n"
134
+ "if not p.exists():\n"
135
+ " print('missing run script')\n"
136
+ " raise SystemExit(1)\n"
137
+ "text=p.read_text(encoding='utf-8')\n"
138
+ "needle='export HERMES_S6_SUPERVISED_CHILD=1\\n'\n"
139
+ f"inject=('export HERMES_S6_SUPERVISED_CHILD=1\\n'"
140
+ f"'export HERMES_HOME={profile_home}\\n'"
141
+ f"'export HOME={profile_home}\\n'"
142
+ f"'cd {profile_home}\\n'"
143
+ "'if [ -z \"$WORKFRAME_PROXY_TOKEN\" ] && [ -f /run/workframe-proxy/token ]; then '\n"
144
+ "'export WORKFRAME_PROXY_TOKEN=\"$(tr -d \\'\\r\\n\\' < /run/workframe-proxy/token)\"; fi\\n'"
145
+ "'unset DISCORD_BOT_TOKEN DISCORD_ALLOWED_USERS DISCORD_HOME_CHANNEL\\n'"
146
+ "'unset TELEGRAM_BOT_TOKEN TELEGRAM_ALLOWED_USERS TELEGRAM_HOME_CHANNEL\\n'"
147
+ "'unset SLACK_BOT_TOKEN SLACK_APP_TOKEN WHATSAPP_MODE\\n'"
148
+ "'unset HERMES_DASHBOARD HERMES_DASHBOARD_HOST HERMES_DASHBOARD_PORT HERMES_DASHBOARD_INSECURE HERMES_DASHBOARD_TUI\\n')\n"
149
+ "if inject not in text:\n"
150
+ " text=text.replace(needle, inject)\n"
151
+ " p.write_text(text, encoding='utf-8')\n"
152
+ "print('ok')\n"
153
+ )
154
+ code, out = _srv()._gateway_container_exec(
155
+ ["/opt/hermes/.venv/bin/python", "-c", script],
156
+ )
157
+ return code == 0, out
158
+
159
+
160
+ def _disable_profile(prof: str) -> dict[str, Any]:
161
+ port = _profile_api_port(prof)
162
+ state = _srv().gateway_data(prof)
163
+ if str(state.get("state") or "").lower() in {"running", "starting"}:
164
+ code, out = _srv()._gateway_exec(prof, ["gateway", "stop"])
165
+ if code != 0:
166
+ raise ValueError(f"gateway stop failed: {out}")
167
+ script = (
168
+ "import yaml\n"
169
+ "from pathlib import Path\n"
170
+ f"d=Path('/opt/data/profiles/{prof}')\n"
171
+ "p=d/'config.yaml'\n"
172
+ "cfg=yaml.safe_load(p.read_text(encoding='utf-8')) if p.exists() else {}\n"
173
+ "plats=cfg.setdefault('platforms',{})\n"
174
+ "for name, value in list(plats.items()):\n"
175
+ " if isinstance(value, dict):\n"
176
+ " value['enabled']=False\n"
177
+ " else:\n"
178
+ " plats[name]={'enabled': False}\n"
179
+ "cfg['platforms']=plats\n"
180
+ "p.write_text(yaml.safe_dump(cfg, sort_keys=False), encoding='utf-8')\n"
181
+ f"Path('/opt/data/profiles/{prof}/.disabled').write_text('disabled by workframe-api\n', encoding='utf-8')\n"
182
+ "print('ok')\n"
183
+ )
184
+ code, out = _srv()._gateway_container_exec(
185
+ ["/opt/hermes/.venv/bin/python", "-c", script],
186
+ )
187
+ if code != 0:
188
+ raise ValueError(f"disable profile config failed: {out}")
189
+ return {"ok": True, "profile": prof, "action": "disable", "state": "disabled", "api_port": port, "output": out.strip()}
190
+
191
+
192
+ def profile_gateway_lifecycle(profile: str, action: str, *, bootstrap_providers: bool = True) -> dict[str, Any]:
193
+ prof = _srv().resolve_hermes_profile(profile)
194
+ if action in {"start", "stop", "disable"}:
195
+ _srv()._invalidate_gateway_registered_cache(prof)
196
+ _srv()._invalidate_profile_health_cache(prof)
197
+ if action not in {"start", "stop", "status", "disable"}:
198
+ raise ValueError("invalid action")
199
+ port = _profile_api_port(prof)
200
+ if _srv().SECURE_MODE and prof != _srv()._primary_profile() and action in {"start", "stop", "disable"}:
201
+ if action == "start":
202
+ _srv()._normalize_profile_config_yaml(prof)
203
+ return _srv()._supervisor_profile_lifecycle(prof, action)
204
+ if prof == _srv()._primary_profile():
205
+ if action == "disable":
206
+ raise ValueError("cannot disable the native profile")
207
+ if action == "status":
208
+ state = _srv().gateway_data(prof)
209
+ ok = bool(state.get("ok")) and str(state.get("state") or "").lower() == "running"
210
+ return {
211
+ "ok": ok,
212
+ "profile": prof,
213
+ "action": action,
214
+ "api_port": port,
215
+ "state": state.get("state") or "unknown",
216
+ "details": state,
217
+ }
218
+ if action == "stop":
219
+ raise ValueError("native gateway stop is not supported via profile api lifecycle")
220
+ return {"ok": True, "profile": prof, "action": action, "api_port": port, "output": "native gateway already managed by compose"}
221
+ if action == "disable":
222
+ return _disable_profile(prof)
223
+ with _srv()._gateway_lifecycle_lock:
224
+ if action == "start":
225
+ if bootstrap_providers:
226
+ _srv()._bootstrap_profile_providers(prof)
227
+ ok, out, port = _configure_profile_api(prof)
228
+ if not ok:
229
+ raise ValueError(f"profile api config failed: {out}")
230
+ ok, out = _patch_profile_gateway_run_script(prof)
231
+ if not ok:
232
+ raise ValueError(f"profile api run patch failed: {out}")
233
+ state = _srv().gateway_data(prof)
234
+ if str(state.get("state") or "").lower() in {"running", "starting"} and not _profile_api_healthy(prof):
235
+ code, out = _srv()._gateway_exec(prof, ["gateway", "stop"])
236
+ if code != 0:
237
+ raise ValueError(f"gateway stop failed: {out}")
238
+ time.sleep(1.0)
239
+ if action == "status":
240
+ state = _srv().gateway_data(prof)
241
+ ok = bool(state.get("ok")) and str(state.get("state") or "").lower() == "running"
242
+ return {
243
+ "ok": ok,
244
+ "profile": prof,
245
+ "action": action,
246
+ "api_port": port,
247
+ "state": state.get("state") or "unknown",
248
+ "details": state,
249
+ }
250
+ code, out = _srv()._gateway_exec(prof, ["gateway", action])
251
+ if code != 0:
252
+ raise ValueError(f"gateway {action} failed: {out}")
253
+ if action == "start" and not _wait_profile_api_healthy(prof):
254
+ raise ValueError(f"profile api did not become healthy: {prof}")
255
+ return {"ok": True, "profile": prof, "action": action, "api_port": port, "output": out.strip()}
256
+
257
+
258
+
259
+
260
+ def _profile_api_key(profile: str) -> str:
261
+ return "workframe-local-key"
262
+
263
+
264
+ def _profile_turn_payload(profile: str, text: str, room_id: str = "") -> dict[str, Any]:
265
+ message = text
266
+ room_id = str(room_id or "").strip()
267
+ if room_id:
268
+ try:
269
+ conn = _srv()._workframe_db()
270
+ try:
271
+ room = conn.execute(
272
+ """
273
+ SELECT room_type, agent_profile_id
274
+ FROM rooms WHERE id = ? AND deleted_at IS NULL
275
+ """,
276
+ (room_id,),
277
+ ).fetchone()
278
+ if room and _srv()._is_space_room(str(room["room_type"]), room["agent_profile_id"]):
279
+ transcript = _srv()._room_recent_transcript(conn, room_id)
280
+ message = (
281
+ "You are in a group chat. Reply only to the message that @mentioned you.\n\n"
282
+ f"Recent messages:\n{transcript}\n\n"
283
+ "Respond concisely."
284
+ )
285
+ finally:
286
+ conn.close()
287
+ except Exception: # noqa: BLE001
288
+ pass
289
+ payload: dict[str, Any] = {"message": message}
290
+ soul = _srv()._profile_soul_text(profile)
291
+ if soul:
292
+ payload["instructions"] = soul
293
+ return payload
294
+
295
+
296
+ def _profile_api_request(
297
+ profile: str,
298
+ method: str,
299
+ path: str,
300
+ body: dict[str, Any] | None = None,
301
+ ) -> tuple[int, Any]:
302
+ port = _profile_api_port(profile)
303
+ url = f"http://gateway:{port}{path}"
304
+ data = json.dumps(body).encode("utf-8") if body is not None else None
305
+ headers = {
306
+ "Authorization": f"Bearer {_profile_api_key(profile)}",
307
+ "Content-Type": "application/json",
308
+ }
309
+ last_error: Exception | None = None
310
+ for attempt in range(4):
311
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
312
+ try:
313
+ with urllib.request.urlopen(req, timeout=1800) as resp:
314
+ raw = resp.read().decode("utf-8", errors="replace")
315
+ return int(resp.status), (json.loads(raw) if raw else {})
316
+ except urllib.error.HTTPError as exc:
317
+ raw = exc.read().decode("utf-8", errors="replace")
318
+ try:
319
+ return int(exc.code), json.loads(raw)
320
+ except Exception: # noqa: BLE001
321
+ return int(exc.code), raw
322
+ except urllib.error.URLError as exc:
323
+ last_error = exc
324
+ if attempt < 3 and _is_transient_profile_api_error(exc):
325
+ time.sleep(0.35 * (attempt + 1))
326
+ continue
327
+ raise
328
+ if last_error:
329
+ raise last_error
330
+ return 503, {"error": "profile api unavailable"}
331
+
332
+
333
+ def _is_transient_profile_api_error(exc: Exception) -> bool:
334
+ reason = str(getattr(exc, "reason", exc) or exc).lower()
335
+ return any(token in reason for token in ("connection refused", "timed out", "temporarily unavailable"))
336
+
337
+
338
+ def _profile_api_healthy(profile: str, timeout: float = 1.5, *, use_cache: bool = True) -> bool:
339
+ try:
340
+ prof = _srv().resolve_hermes_profile(profile)
341
+ except ValueError:
342
+ return False
343
+ wait = max(1.0, float(timeout))
344
+ if use_cache and wait >= 1.0:
345
+ cached = _srv()._profile_health_cache.get(prof)
346
+ if cached and time.monotonic() - cached[1] < _srv()._PROFILE_HEALTH_TTL_SEC:
347
+ return cached[0]
348
+ port = _profile_api_port(prof)
349
+ key = _profile_api_key(prof)
350
+ url = f"http://gateway:{port}/v1/health"
351
+ req = urllib.request.Request(
352
+ url,
353
+ headers={"Authorization": f"Bearer {key}"},
354
+ method="GET",
355
+ )
356
+ try:
357
+ with urllib.request.urlopen(req, timeout=wait) as resp:
358
+ ok = int(resp.status) < 300
359
+ except Exception: # noqa: BLE001
360
+ ok = False
361
+ if use_cache and wait >= 1.0:
362
+ _srv()._profile_health_cache[prof] = (ok, time.monotonic())
363
+ return ok
364
+
365
+
366
+ def _wait_profile_api_healthy(profile: str, attempts: int = 60, delay: float = 0.5) -> bool:
367
+ try:
368
+ prof = _srv().resolve_hermes_profile(profile)
369
+ except ValueError:
370
+ return False
371
+ if _profile_api_healthy(prof):
372
+ return True
373
+ for _ in range(attempts):
374
+ if _profile_api_healthy(prof):
375
+ return True
376
+ time.sleep(delay)
377
+ return False
378
+ def profile_gateway_stop(profile: str, run_id: str) -> dict[str, Any]:
379
+ """Stop a running agent via the Hermes /v1/runs/{run_id}/stop API."""
380
+ prof = _srv().resolve_validated_profile(profile)
381
+ lifecycle = _srv().ensure_profile_api(prof)
382
+ port = _profile_api_port(prof)
383
+ api_base = f"http://gateway:{port}"
384
+ auth_headers = {
385
+ "Authorization": f"Bearer {_profile_api_key(prof)}",
386
+ "Content-Type": "application/json",
387
+ }
388
+ try:
389
+ req = urllib.request.Request(
390
+ f"{api_base}/v1/runs/{urllib.parse.quote(run_id, safe='')}/stop",
391
+ data=b"{}",
392
+ headers=auth_headers,
393
+ method="POST",
394
+ )
395
+ with urllib.request.urlopen(req, timeout=15) as resp:
396
+ body = resp.read().decode("utf-8", errors="replace")
397
+ return {"ok": True, "profile": prof, "run_id": run_id, "status": "stopped", "detail": body[:500]}
398
+ except urllib.error.HTTPError as exc:
399
+ raw = exc.read().decode("utf-8", errors="replace")
400
+ return {"ok": False, "profile": prof, "run_id": run_id, "error": raw or f"stop failed: {exc.code}"}
401
+ except Exception as exc:
402
+ return {"ok": False, "profile": prof, "run_id": run_id, "error": str(exc)}
403
+
404
+
405
+ def profile_gateway_steer(profile: str, run_id: str, text: str) -> dict[str, Any]:
406
+ """Steer a running agent: stop the current run and append a new input via /v1/runs."""
407
+ prof = _srv().resolve_validated_profile(profile)
408
+ # First stop the current run
409
+ stop_result = _srv().profile_gateway_stop(prof, run_id)
410
+ if not stop_result.get("ok"):
411
+ return stop_result
412
+ # Then submit the steered input as a new run
413
+ lifecycle = _srv().ensure_profile_api(prof)
414
+ port = _profile_api_port(prof)
415
+ api_base = f"http://gateway:{port}"
416
+ auth_headers = {
417
+ "Authorization": f"Bearer {_profile_api_key(prof)}",
418
+ "Content-Type": "application/json",
419
+ }
420
+ payload = json.dumps({"input": text}).encode("utf-8")
421
+ try:
422
+ req = urllib.request.Request(
423
+ f"{api_base}/v1/runs",
424
+ data=payload,
425
+ headers=auth_headers,
426
+ method="POST",
427
+ )
428
+ with urllib.request.urlopen(req, timeout=30) as resp:
429
+ body = json.loads(resp.read().decode("utf-8", errors="replace"))
430
+ new_run_id = str(body.get("run_id") or "").strip()
431
+ return {"ok": True, "profile": prof, "run_id": new_run_id, "message": "steered", "detail": str(body)[:500]}
432
+ except urllib.error.HTTPError as exc:
433
+ raw = exc.read().decode("utf-8", errors="replace")
434
+ return {"ok": False, "profile": prof, "error": raw or f"steer failed: {exc.code}"}
435
+ except Exception as exc:
436
+ return {"ok": False, "profile": prof, "error": str(exc)}