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.
Files changed (188) 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 +2 -2
  5. package/package.json +1 -1
  6. package/scripts/bundle-workframe-ui.mjs +9 -0
  7. package/scripts/sync-canonical-to-package.mjs +1 -0
  8. package/scripts/verify-public-deploy.sh +65 -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/package.json +2 -2
  51. package/workframe-api/profile_api_lifecycle.py +66 -0
  52. package/workframe-api/profile_gateway.py +436 -0
  53. package/workframe-api/provider_bindings.py +673 -0
  54. package/workframe-api/provider_bootstrap.py +347 -0
  55. package/workframe-api/provider_catalog.py +180 -0
  56. package/workframe-api/rooms.py +1613 -0
  57. package/workframe-api/route_registry.py +331 -191
  58. package/workframe-api/run-typecheck.mjs +2 -1
  59. package/workframe-api/run_authority.py +287 -0
  60. package/workframe-api/run_ledger.py +470 -0
  61. package/workframe-api/run_surface_wiring.py +344 -0
  62. package/workframe-api/runtime_cohort.py +752 -0
  63. package/workframe-api/runtime_tokens.py +127 -0
  64. package/workframe-api/server.py +1453 -19513
  65. package/workframe-api/snapshot_feed.py +49 -0
  66. package/workframe-api/stack_config.py +33 -1
  67. package/workframe-api/supervisor_client.py +154 -0
  68. package/workframe-api/test_api_meta_build_stamp.py +34 -0
  69. package/workframe-api/test_cell_authority.py +79 -0
  70. package/workframe-api/test_chat_bind.py +48 -0
  71. package/workframe-api/test_credential_lease_matrix.py +171 -0
  72. package/workframe-api/test_credential_resolve.py +51 -0
  73. package/workframe-api/test_credential_store.py +27 -0
  74. package/workframe-api/test_dogfood_flows.py +346 -0
  75. package/workframe-api/test_domain_entities.py +152 -0
  76. package/workframe-api/test_exception_hygiene.py +12 -0
  77. package/workframe-api/test_hermes_admin.py +72 -0
  78. package/workframe-api/test_install_wizard_resume.py +78 -0
  79. package/workframe-api/test_local_bootstrap.py +67 -0
  80. package/workframe-api/test_mention_helpers.py +35 -0
  81. package/workframe-api/test_mention_invoke.py +26 -0
  82. package/workframe-api/test_model_surface_consistency.py +1 -0
  83. package/workframe-api/test_oauth_pending.py +41 -0
  84. package/workframe-api/test_provider_bindings.py +52 -0
  85. package/workframe-api/test_provider_catalog.py +28 -0
  86. package/workframe-api/test_route_registry.py +171 -35
  87. package/workframe-api/test_run_authority.py +112 -0
  88. package/workframe-api/test_run_ledger.py +157 -0
  89. package/workframe-api/test_run_surface_wiring.py +130 -0
  90. package/workframe-api/test_runtime_cohort.py +85 -0
  91. package/workframe-api/test_secure_mode_docker_boundary.py +39 -0
  92. package/workframe-api/test_server_reexports.py +99 -0
  93. package/workframe-api/test_stack_config_install_smtp.py +7 -0
  94. package/workframe-api/turn_credentials.py +28 -13
  95. package/workframe-api/turn_overlay.py +715 -0
  96. package/workframe-api/updates.py +16 -1
  97. package/workframe-api/user_prefs.py +387 -0
  98. package/workframe-api/wf032_extract_remaining_handlers.py +417 -0
  99. package/workframe-api/workspace_bootstrap.py +536 -0
  100. package/workframe-api/workspace_files.py +344 -0
  101. package/workframe-api/workspace_messaging.py +206 -0
  102. package/workframe-api/zk_auth.py +3 -2
  103. package/workframe-supervisor/server.py +10 -1
  104. package/workframe-supervisor/test_supervisor_negative.py +75 -0
  105. package/workframe-ui/public/assets/{arc-B0OFRGmJ.js → arc-BT9iZUSd.js} +1 -1
  106. package/workframe-ui/public/assets/architecture-7EHR7CIX-m1aPUQ_r.js +1 -0
  107. package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-DeBNltHS.js → architectureDiagram-3BPJPVTR-CaEDd3np.js} +1 -1
  108. package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-BDhCHu7I.js → blockDiagram-GPEHLZMM-DHJhMrIS.js} +1 -1
  109. package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-olcDYvtI.js → c4Diagram-AAUBKEIU-Cdyz9hIV.js} +1 -1
  110. package/workframe-ui/public/assets/channel-3M69ekE5.js +1 -0
  111. package/workframe-ui/public/assets/{chunk-2J33WTMH-D0obCBn_.js → chunk-2J33WTMH-BbZx76LX.js} +1 -1
  112. package/workframe-ui/public/assets/{chunk-3OPIFGDE-DX93f-ZZ.js → chunk-3OPIFGDE-DcSxjTIP.js} +1 -1
  113. package/workframe-ui/public/assets/{chunk-4BX2VUAB-BX9B5wUs.js → chunk-4BX2VUAB-B7F-kTY1.js} +1 -1
  114. package/workframe-ui/public/assets/{chunk-55IACEB6-C4DGc6RO.js → chunk-55IACEB6-BwwEiRTK.js} +1 -1
  115. package/workframe-ui/public/assets/{chunk-5ZQYHXKU-Crlja9Lf.js → chunk-5ZQYHXKU-DAi_NksX.js} +1 -1
  116. package/workframe-ui/public/assets/{chunk-727SXJPM-JYSm3szI.js → chunk-727SXJPM-BIaF4XCN.js} +1 -1
  117. package/workframe-ui/public/assets/{chunk-AQP2D5EJ-_KslxVEl.js → chunk-AQP2D5EJ-DjYSk1dG.js} +1 -1
  118. package/workframe-ui/public/assets/{chunk-BSJP7CBP-CpTO-jU8.js → chunk-BSJP7CBP-_twnyfgV.js} +1 -1
  119. package/workframe-ui/public/assets/{chunk-CSCIHK7Q-JGUkCmbI.js → chunk-CSCIHK7Q-CkNmdy5s.js} +2 -2
  120. package/workframe-ui/public/assets/{chunk-FMBD7UC4-BscBwGaC.js → chunk-FMBD7UC4-CjLqd-gl.js} +1 -1
  121. package/workframe-ui/public/assets/{chunk-KSCS5N6A-5ZTZ0bpX.js → chunk-KSCS5N6A-_6oNqFjj.js} +1 -1
  122. package/workframe-ui/public/assets/{chunk-L5ZTLDWV-DxvitYbW.js → chunk-L5ZTLDWV-CeO9Am_D.js} +1 -1
  123. package/workframe-ui/public/assets/chunk-LZXEDZCA-sRM4YeIe.js +2 -0
  124. package/workframe-ui/public/assets/{chunk-ND2GUHAM-Cayl0iV9.js → chunk-ND2GUHAM-CH8tMb21.js} +1 -1
  125. package/workframe-ui/public/assets/{chunk-NZK2D7GU-_7dyBR5G.js → chunk-NZK2D7GU-DAI1bsII.js} +1 -1
  126. package/workframe-ui/public/assets/{chunk-O5CBEL6O--xTpD0yi.js → chunk-O5CBEL6O-B2e-5a2G.js} +1 -1
  127. package/workframe-ui/public/assets/chunk-QZHKN3VN-BMl9ed8P.js +1 -0
  128. package/workframe-ui/public/assets/chunk-WU5MYG2G-C08HH6BU.js +1 -0
  129. package/workframe-ui/public/assets/{chunk-XPW4576I-CwxJQaeK.js → chunk-XPW4576I-DM2-0q37.js} +1 -1
  130. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BFcOE4Aq.js +1 -0
  131. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BFcOE4Aq.js +1 -0
  132. package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-CCspENYv.js → cose-bilkent-S5V4N54A-BnbbewLv.js} +1 -1
  133. package/workframe-ui/public/assets/{dagre-BM42HDAG-Dv8V6R60.js → dagre-BM42HDAG-C4zmB1Vt.js} +1 -1
  134. package/workframe-ui/public/assets/{diagram-2AECGRRQ-Da48hFPT.js → diagram-2AECGRRQ-n-BJmsiC.js} +1 -1
  135. package/workframe-ui/public/assets/{diagram-5GNKFQAL-dTihc7-3.js → diagram-5GNKFQAL-BzQGQnZi.js} +1 -1
  136. package/workframe-ui/public/assets/{diagram-KO2AKTUF-D_Jqu699.js → diagram-KO2AKTUF-Cx_De-wS.js} +1 -1
  137. package/workframe-ui/public/assets/{diagram-LMA3HP47-Bt8PtEaZ.js → diagram-LMA3HP47-CIOI3Lok.js} +1 -1
  138. package/workframe-ui/public/assets/{diagram-OG6HWLK6-BtmPjlh0.js → diagram-OG6HWLK6-DkRuPLWY.js} +1 -1
  139. package/workframe-ui/public/assets/{dist-Cz2turIT.js → dist-XhCT1Q-V.js} +1 -1
  140. package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-BxMGCflX.js → erDiagram-TEJ5UH35-C3GwhDU1.js} +1 -1
  141. package/workframe-ui/public/assets/eventmodeling-FCH6USID-DIVBL6CF.js +1 -0
  142. package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-ivyroIZt.js → flowDiagram-I6XJVG4X-CLWH8z2e.js} +1 -1
  143. package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-B9llwShx.js → ganttDiagram-6RSMTGT7-D4h66Vy3.js} +1 -1
  144. package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-BFQHk4bw.js → gitGraph-WXDBUCRP-IW5F-Q6c.js} +1 -1
  145. package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-ursegLbc.js → gitGraphDiagram-PVQCEYII-BjNIbmRM.js} +1 -1
  146. package/workframe-ui/public/assets/index-DL2vJP4L.css +1 -0
  147. package/workframe-ui/public/assets/index-ElS_D59P.js +130 -0
  148. package/workframe-ui/public/assets/{info-J43DQDTF-DQpifAsB.js → info-J43DQDTF-CcJspM79.js} +1 -1
  149. package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-C16XP2Q7.js → infoDiagram-5YYISTIA-DP4xR9jt.js} +1 -1
  150. package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-CiJoz7rP.js → ishikawaDiagram-YF4QCWOH-D4cZb21V.js} +1 -1
  151. package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-CVwEvvUL.js → journeyDiagram-JHISSGLW-DsLuHkIJ.js} +1 -1
  152. package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-CWQ6hX5i.js → kanban-definition-UN3LZRKU-Wu65nOsR.js} +1 -1
  153. package/workframe-ui/public/assets/{line-CgQsmU35.js → line-ByBDzRpy.js} +1 -1
  154. package/workframe-ui/public/assets/{linear-Bxbc77dL.js → linear-DzXi2vnq.js} +1 -1
  155. package/workframe-ui/public/assets/{mermaid-parser.core-C_jMeF0a.js → mermaid-parser.core-CaB-1Ckn.js} +2 -2
  156. package/workframe-ui/public/assets/{mermaid.core-4RKV9MZP.js → mermaid.core-D7HTHC17.js} +3 -3
  157. package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-DZ7y7Sz0.js → mindmap-definition-RKZ34NQL-BfKsC9aj.js} +1 -1
  158. package/workframe-ui/public/assets/{packet-YPE3B663-B9h2I7Vq.js → packet-YPE3B663-ClpU98TO.js} +1 -1
  159. package/workframe-ui/public/assets/{pie-LRSECV5Y-B2mP5uHm.js → pie-LRSECV5Y-KeOyY_-u.js} +1 -1
  160. package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-DyYKyKiP.js → pieDiagram-4H26LBE5-CUpjnzbs.js} +1 -1
  161. package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-CL_6nej-.js → quadrantDiagram-W4KKPZXB-CJdPTszF.js} +1 -1
  162. package/workframe-ui/public/assets/{radar-GUYGQ44K-CNKNDQ7S.js → radar-GUYGQ44K-C9BpandZ.js} +1 -1
  163. package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-eWU_mhFa.js → requirementDiagram-4Y6WPE33-CHDZPwry.js} +1 -1
  164. package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-DrjcXvEQ.js → sankeyDiagram-5OEKKPKP-CMJJh91s.js} +1 -1
  165. package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-BHWR1xTJ.js → sequenceDiagram-3UESZ5HK-BsV4GppP.js} +1 -1
  166. package/workframe-ui/public/assets/{src-DfJB8kV1.js → src-WHks82Up.js} +1 -1
  167. package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BGJbSd3I.js → stateDiagram-AJRCARHV-eSX9P8z0.js} +1 -1
  168. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-FzMK4PkM.js +1 -0
  169. package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-DkwoCjiZ.js → timeline-definition-PNZ67QCA-Dg1UkhGo.js} +1 -1
  170. package/workframe-ui/public/assets/{treeView-BLDUP644-GxfhiqoW.js → treeView-BLDUP644-B7yGsBuj.js} +1 -1
  171. package/workframe-ui/public/assets/{treemap-LRROVOQU-DxrOsars.js → treemap-LRROVOQU-j_G2oyQ_.js} +1 -1
  172. package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-BsWhpUfr.js → vennDiagram-CIIHVFJN-BRrT0jqC.js} +1 -1
  173. package/workframe-ui/public/assets/{wardley-L42UT6IY-CrFZWMHS.js → wardley-L42UT6IY-DEdUqez2.js} +1 -1
  174. package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DV8Xpf5I.js → wardleyDiagram-YWT4CUSO-C-bBK9Bb.js} +1 -1
  175. package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-DqMqnwdZ.js → xychartDiagram-2RQKCTM6-DhH5xuR2.js} +1 -1
  176. package/workframe-ui/public/index.html +11 -7
  177. package/workframe-ui/public/workframe-build.json +5 -0
  178. package/workframe-ui/public/assets/architecture-7EHR7CIX-Dwwi9yA0.js +0 -1
  179. package/workframe-ui/public/assets/channel-fNHbDpV4.js +0 -1
  180. package/workframe-ui/public/assets/chunk-LZXEDZCA-DMfdMUwz.js +0 -2
  181. package/workframe-ui/public/assets/chunk-QZHKN3VN-ewTgEQK8.js +0 -1
  182. package/workframe-ui/public/assets/chunk-WU5MYG2G-D4Tg-A0l.js +0 -1
  183. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-CFAgBpEl.js +0 -1
  184. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-CFAgBpEl.js +0 -1
  185. package/workframe-ui/public/assets/eventmodeling-FCH6USID-BIJTP5k-.js +0 -1
  186. package/workframe-ui/public/assets/index-8CuZDEIG.css +0 -1
  187. package/workframe-ui/public/assets/index-xS9lFekI.js +0 -129
  188. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CMLuNyeC.js +0 -1
@@ -0,0 +1,231 @@
1
+ """CellAuthorityGate — install/cell authority (WF-007)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Any, Literal
10
+
11
+ from domain.entities import Cell, DeploymentMode, Grant, GrantCapability
12
+
13
+ CellOperation = Literal["create", "open", "update", "connect", "adopt"]
14
+ CellDecisionKind = Literal["allow", "allow_readonly", "deny", "needs_user_action"]
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class CellAuthorityDecision:
19
+ operation: CellOperation
20
+ decision: CellDecisionKind
21
+ reason: str
22
+ cell: Cell | None
23
+ mutation_plan: tuple[str, ...] = field(default_factory=tuple)
24
+
25
+
26
+ def _read_json(path: Path) -> dict[str, Any] | None:
27
+ try:
28
+ return json.loads(path.read_text(encoding="utf-8"))
29
+ except (OSError, json.JSONDecodeError):
30
+ return None
31
+
32
+
33
+ def load_cell_from_manifest(install_root: str | Path) -> Cell | None:
34
+ root = Path(install_root).resolve()
35
+ manifest_path = root / "workframe-manifest.json"
36
+ data = _read_json(manifest_path)
37
+ if not data:
38
+ return None
39
+ layout = data.get("layout") if isinstance(data.get("layout"), dict) else {}
40
+ security = data.get("security") if isinstance(data.get("security"), dict) else {}
41
+ docker = data.get("docker") if isinstance(data.get("docker"), dict) else {}
42
+ mode_raw = str(security.get("deployment_mode") or data.get("deployment_mode") or "single_user_local")
43
+ try:
44
+ mode = DeploymentMode(mode_raw)
45
+ except ValueError:
46
+ mode = DeploymentMode.SINGLE_USER_LOCAL
47
+ return Cell(
48
+ cell_id=str(data.get("install_id") or data.get("project_name") or root.name),
49
+ install_root=str(root),
50
+ manifest_path=str(manifest_path),
51
+ package_name=str(data.get("package_name") or "create-workframe"),
52
+ package_version=str(data.get("package_version") or ""),
53
+ deployment_mode=mode,
54
+ created_at=None, # type: ignore[arg-type]
55
+ git_ref=data.get("git_ref"),
56
+ packed_artifact_digest=data.get("packed_artifact_digest"),
57
+ )
58
+
59
+
60
+ def _dir_nonempty(path: Path) -> bool:
61
+ if not path.is_dir():
62
+ return False
63
+ try:
64
+ return any(path.iterdir())
65
+ except OSError:
66
+ return False
67
+
68
+
69
+ def evaluate_create(
70
+ target: str | Path,
71
+ *,
72
+ force: bool = False,
73
+ package_version: str = "",
74
+ packed_digest: str | None = None,
75
+ ) -> CellAuthorityDecision:
76
+ root = Path(target).resolve()
77
+ if root.exists() and _dir_nonempty(root) and not force:
78
+ return CellAuthorityDecision(
79
+ operation="create",
80
+ decision="deny",
81
+ reason="target_not_empty",
82
+ cell=None,
83
+ mutation_plan=(),
84
+ )
85
+ plan = (
86
+ "assert target writable",
87
+ "write scaffold tree",
88
+ f"record package_version={package_version or 'unknown'}",
89
+ "atomic write workframe-manifest.json",
90
+ "verify layout Agents/ Files/",
91
+ )
92
+ cell = Cell(
93
+ cell_id=root.name,
94
+ install_root=str(root),
95
+ manifest_path=str(root / "workframe-manifest.json"),
96
+ package_name="create-workframe",
97
+ package_version=package_version,
98
+ deployment_mode=DeploymentMode.SINGLE_USER_LOCAL,
99
+ created_at=None, # type: ignore[arg-type]
100
+ packed_artifact_digest=packed_digest,
101
+ )
102
+ return CellAuthorityDecision(
103
+ operation="create",
104
+ decision="allow",
105
+ reason="empty_target_or_force",
106
+ cell=cell,
107
+ mutation_plan=plan,
108
+ )
109
+
110
+
111
+ def evaluate_open(install_root: str | Path, *, doctor_report: dict[str, Any] | None = None) -> CellAuthorityDecision:
112
+ root = Path(install_root).resolve()
113
+ cell = load_cell_from_manifest(root)
114
+ if not cell:
115
+ return CellAuthorityDecision(
116
+ operation="open",
117
+ decision="deny",
118
+ reason="manifest_missing",
119
+ cell=None,
120
+ )
121
+ manifest_layout = _read_json(Path(cell.manifest_path)) or {}
122
+ layout = manifest_layout.get("layout") if isinstance(manifest_layout.get("layout"), dict) else {}
123
+ expected_ws = str(layout.get("workspace") or "Files")
124
+ if not (root / expected_ws).is_dir():
125
+ return CellAuthorityDecision(
126
+ operation="open",
127
+ decision="deny",
128
+ reason="workspace_mount_missing",
129
+ cell=cell,
130
+ )
131
+ if doctor_report and doctor_report.get("decision") == "needs_user_action":
132
+ return CellAuthorityDecision(
133
+ operation="open",
134
+ decision="needs_user_action",
135
+ reason="doctor_prerequisites",
136
+ cell=cell,
137
+ )
138
+ return CellAuthorityDecision(
139
+ operation="open",
140
+ decision="allow_readonly",
141
+ reason="manifest_and_layout_ok",
142
+ cell=cell,
143
+ )
144
+
145
+
146
+ def evaluate_update(
147
+ install_root: str | Path,
148
+ *,
149
+ open_decision: CellAuthorityDecision | None = None,
150
+ user_ack: bool = False,
151
+ ) -> CellAuthorityDecision:
152
+ opened = open_decision or evaluate_open(install_root)
153
+ if opened.decision != "allow_readonly":
154
+ return CellAuthorityDecision(
155
+ operation="update",
156
+ decision="deny",
157
+ reason=f"open_blocked:{opened.reason}",
158
+ cell=opened.cell,
159
+ )
160
+ if not user_ack:
161
+ return CellAuthorityDecision(
162
+ operation="update",
163
+ decision="needs_user_action",
164
+ reason="explicit_user_ack_required",
165
+ cell=opened.cell,
166
+ mutation_plan=(
167
+ "snapshot manifest digest",
168
+ "apply bounded file mutations",
169
+ "verify health",
170
+ "record evidence artifact",
171
+ ),
172
+ )
173
+ return CellAuthorityDecision(
174
+ operation="update",
175
+ decision="allow",
176
+ reason="open_ok_and_ack",
177
+ cell=opened.cell,
178
+ mutation_plan=(
179
+ "snapshot manifest digest",
180
+ "apply bounded file mutations",
181
+ "verify health",
182
+ "record evidence artifact",
183
+ ),
184
+ )
185
+
186
+
187
+ def evaluate_connect(*, remote_attested: bool = False) -> CellAuthorityDecision:
188
+ if not remote_attested:
189
+ return CellAuthorityDecision(
190
+ operation="connect",
191
+ decision="deny",
192
+ reason="remote_not_attested",
193
+ cell=None,
194
+ )
195
+ return CellAuthorityDecision(
196
+ operation="connect",
197
+ decision="allow_readonly",
198
+ reason="remote_attested",
199
+ cell=None,
200
+ )
201
+
202
+
203
+ def evaluate_adopt(*, runtime_kind: str = "") -> CellAuthorityDecision:
204
+ # ponytail: adopt blocked until WF-014/NS-P4 — always deny non-proven adapters
205
+ if runtime_kind and runtime_kind not in ("hermes_managed", ""):
206
+ return CellAuthorityDecision(
207
+ operation="adopt",
208
+ decision="deny",
209
+ reason="adapter_not_proven",
210
+ cell=None,
211
+ )
212
+ return CellAuthorityDecision(
213
+ operation="adopt",
214
+ decision="deny",
215
+ reason="adopt_deferred_wf014",
216
+ cell=None,
217
+ )
218
+
219
+
220
+ def file_write_grant(cell: Cell) -> Grant:
221
+ return Grant(
222
+ grant_id=f"cell-fw-{cell.cell_id}",
223
+ run_id="",
224
+ capability=GrantCapability.FILE_WRITE,
225
+ scope={"install_root": cell.install_root},
226
+ granted_by="cell_authority_gate",
227
+ )
228
+
229
+
230
+ def cell_id_from_env() -> str:
231
+ return str(os.environ.get("WORKFRAME_PROJECT") or os.environ.get("PROJECT_NAME") or "default")
@@ -0,0 +1,319 @@
1
+ """WF-032 extract: chat bind and room session handlers."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ import sqlite3
6
+ import urllib.parse
7
+ import uuid
8
+ from typing import Any
9
+
10
+ import lane_bindings
11
+
12
+
13
+ def _srv():
14
+ import server as srv
15
+
16
+ return srv
17
+
18
+
19
+ def room_chat_bind(room_id: str, payload: dict[str, Any], user_id: str = "") -> dict[str, Any]:
20
+ """Room-scoped bind — identity from room.agent_profile_id, not the URL."""
21
+ body = dict(payload or {})
22
+ body["room_id"] = str(room_id or "").strip()
23
+ return profile_chat_bind("", body, user_id)
24
+
25
+
26
+ def profile_chat_bind(profile: str, payload: dict[str, Any], user_id: str = "") -> dict[str, Any]:
27
+ session = lane_bindings.profile_chat_session(profile, payload, user_id)
28
+ sid = str(session.get("session_id") or "").strip()
29
+ hermes_prof = str(session.get("profile") or _srv().resolve_validated_profile(profile))
30
+ template_prof = str(session.get("template_profile") or "").strip()
31
+ if not template_prof:
32
+ template_prof = (
33
+ _srv()._runtime_template_slug(hermes_prof)
34
+ if _srv()._is_runtime_profile_slug(hermes_prof)
35
+ else _srv().resolve_validated_profile(profile)
36
+ )
37
+ workspace_id = str(session.get("workspace_id") or payload.get("workspace_id") or "").strip()
38
+ history = _srv().chat_messages(hermes_prof, sid)
39
+ # ponytail: cohort manifest is lazy — GET /api/me/cohort; bind must stay fast
40
+ cohort: list[dict[str, Any]] = []
41
+ display_name = (
42
+ _srv()._runtime_display_label(user_id, template_prof, workspace_id)
43
+ if user_id and _srv()._is_runtime_profile_slug(hermes_prof)
44
+ else _srv()._profile_display_name(hermes_prof, workspace_id)
45
+ )
46
+ return {
47
+ "ok": True,
48
+ "profile": session.get("profile") or profile,
49
+ "template_profile": template_prof,
50
+ "agent_display_name": display_name,
51
+ "cohort": cohort,
52
+ "session_id": sid,
53
+ "title": session.get("title") or "",
54
+ "created": bool(session.get("created")),
55
+ "api_port": session.get("api_port"),
56
+ "llm_ready": bool(session.get("llm_ready")),
57
+ "has_llm_provider": bool(session.get("has_llm_provider")),
58
+ "messages": history.get("messages") or [],
59
+ "session": history.get("session") or {},
60
+ }
61
+
62
+
63
+ def _room_session_rows(conn: sqlite3.Connection, room_id: str) -> list[sqlite3.Row]:
64
+ return conn.execute(
65
+ """
66
+ SELECT rs.*, ap.slug AS agent_slug, ap.display_name AS agent_display_name
67
+ FROM room_sessions rs
68
+ LEFT JOIN agent_profiles ap ON ap.id = rs.agent_profile_id
69
+ WHERE rs.room_id = ? AND rs.deleted_at IS NULL
70
+ ORDER BY rs.updated_at DESC, rs.created_at DESC
71
+ """,
72
+ (room_id,),
73
+ ).fetchall()
74
+
75
+
76
+ def list_room_sessions(room_id: str, user_id: str) -> dict[str, Any]:
77
+ room_id = str(room_id or "").strip()
78
+ user_id = str(user_id or "").strip()
79
+ if not room_id or not user_id:
80
+ raise ValueError("room_id and user required")
81
+ conn = _srv()._workframe_db()
82
+ try:
83
+ room = conn.execute(
84
+ "SELECT * FROM rooms WHERE id = ? AND deleted_at IS NULL",
85
+ (room_id,),
86
+ ).fetchone()
87
+ if not room:
88
+ raise ValueError("room_not_found")
89
+ if not _srv()._user_can_access_room(conn, room_id, user_id):
90
+ raise ValueError("room_access_denied")
91
+ workspace_id = str(room["workspace_id"])
92
+ rows = _room_session_rows(conn, room_id)
93
+ finally:
94
+ conn.close()
95
+
96
+ sessions: list[dict[str, Any]] = []
97
+ for row in rows:
98
+ template_slug = str(row["agent_slug"] or "").strip()
99
+ agent_db_id = str(row["agent_profile_id"] or "").strip()
100
+ if not template_slug or not agent_db_id:
101
+ continue
102
+ sid = str(row["session_id"] or "").strip()
103
+ try:
104
+ hermes_prof = _srv()._resolve_chat_hermes_profile(template_slug, user_id, room_id, workspace_id)
105
+ if not sid or not _srv()._session_exists(hermes_prof, sid):
106
+ continue
107
+ info = _srv()._session_info(hermes_prof, sid)
108
+ except ValueError:
109
+ # ponytail: skip orphaned bindings (e.g. deleted smoke-agent profiles)
110
+ continue
111
+ sessions.append(
112
+ {
113
+ "id": str(row["id"]),
114
+ "room_id": room_id,
115
+ "agent_profile_id": agent_db_id,
116
+ "agent_slug": template_slug,
117
+ "hermes_profile": hermes_prof,
118
+ "session_id": sid,
119
+ "title": _srv()._resolved_session_title(hermes_prof, sid, str(row["title"] or "")),
120
+ "status": str(row["status"] or "active"),
121
+ "message_count": int(info.get("message_count") or 0),
122
+ "created_at": _srv()._iso_from_unix(row["created_at"]),
123
+ "updated_at": _srv()._iso_from_unix(row["updated_at"]),
124
+ "active": str(row["status"] or "") == "active",
125
+ }
126
+ )
127
+ return {"ok": True, "room_id": room_id, "sessions": sessions}
128
+
129
+
130
+ def profile_chat_activate_room_session(
131
+ room_id: str,
132
+ session_id: str,
133
+ user_id: str,
134
+ template_prof: str = "",
135
+ *,
136
+ source_id: str = "ui",
137
+ client_id: str = "default",
138
+ binding_version: int = 0,
139
+ ) -> dict[str, Any]:
140
+ room_id = str(room_id or "").strip()
141
+ session_id = str(session_id or "").strip()
142
+ user_id = str(user_id or "").strip()
143
+ if not room_id or not session_id or not user_id:
144
+ raise ValueError("room_id, session_id, and user required")
145
+
146
+ conn = _srv()._workframe_db()
147
+ try:
148
+ room = conn.execute(
149
+ "SELECT * FROM rooms WHERE id = ? AND deleted_at IS NULL",
150
+ (room_id,),
151
+ ).fetchone()
152
+ if not room:
153
+ raise ValueError("room_not_found")
154
+ if not _srv()._user_can_access_room(conn, room_id, user_id):
155
+ raise ValueError("room_access_denied")
156
+ workspace_id = str(room["workspace_id"])
157
+ row = conn.execute(
158
+ """
159
+ SELECT rs.*, ap.slug AS agent_slug
160
+ FROM room_sessions rs
161
+ LEFT JOIN agent_profiles ap ON ap.id = rs.agent_profile_id
162
+ WHERE rs.room_id = ? AND rs.session_id = ? AND rs.deleted_at IS NULL
163
+ LIMIT 1
164
+ """,
165
+ (room_id, session_id),
166
+ ).fetchone()
167
+ if not row:
168
+ raise ValueError("room_session_not_found")
169
+ template_slug = str(template_prof or row["agent_slug"] or "").strip()
170
+ if not template_slug:
171
+ raise ValueError("agent_profile_not_found")
172
+ agent_db_id = str(row["agent_profile_id"] or "").strip()
173
+ hermes_prof = _srv()._resolve_chat_hermes_profile(template_slug, user_id, room_id, workspace_id)
174
+ if not _srv()._session_exists(hermes_prof, session_id):
175
+ raise ValueError("session_not_found")
176
+ gateway_sid = str(row["gateway_session_id"] or f"api:{hermes_prof}:{session_id}").strip()
177
+ title = str(_srv()._session_info(hermes_prof, session_id).get("title") or row["title"] or "")
178
+ _srv()._upsert_room_session(
179
+ conn,
180
+ room_id=room_id,
181
+ agent_profile_id=agent_db_id,
182
+ session_id=session_id,
183
+ gateway_session_id=gateway_sid,
184
+ created_by=user_id,
185
+ title=title,
186
+ )
187
+ conn.commit()
188
+ finally:
189
+ conn.close()
190
+
191
+ lane_bindings._sync_lane_binding(
192
+ hermes_prof,
193
+ source_id,
194
+ client_id,
195
+ binding_version,
196
+ session_id,
197
+ gateway_sid,
198
+ )
199
+
200
+ if user_id:
201
+ _srv()._reconcile_profile_llm_for_user(hermes_prof, user_id, workspace_id)
202
+ llm_provider = _srv()._llm_billing_provider(hermes_prof, user_id=user_id, workspace_id=workspace_id)
203
+ llm_ready = _srv()._overlay_chat_llm_env(hermes_prof, user_id, workspace_id, llm_provider)
204
+
205
+ history = _srv().chat_messages(hermes_prof, session_id)
206
+ cohort = _srv().ensure_user_agent_cohort(user_id, workspace_id)
207
+ return {
208
+ "ok": True,
209
+ "profile": hermes_prof,
210
+ "template_profile": template_slug,
211
+ "agent_display_name": _srv()._runtime_display_label(user_id, template_slug, workspace_id),
212
+ "cohort": cohort,
213
+ "room_id": room_id,
214
+ "session_id": session_id,
215
+ "title": title,
216
+ "created": False,
217
+ "resumed": True,
218
+ "llm_ready": llm_ready,
219
+ "has_llm_provider": llm_ready,
220
+ "messages": history.get("messages") or [],
221
+ "session": history.get("session") or {},
222
+ }
223
+
224
+
225
+ def profile_chat_message(profile: str, payload: dict[str, Any]) -> dict[str, Any]:
226
+ source_id = str(payload.get("source_id") or "ui").strip() or "ui"
227
+ client_id = str(payload.get("client_id") or "default").strip() or "default"
228
+ session_id = str(payload.get("session_id") or "").strip()
229
+ text = str(payload.get("text") or "").strip()
230
+ if not session_id:
231
+ raise ValueError("session_id required")
232
+ if not text:
233
+ raise ValueError("text required")
234
+ payer = str(payload.get("user_id") or "").strip()
235
+ workspace_id = str(payload.get("workspace_id") or "").strip()
236
+ room_id = str(payload.get("room_id") or "").strip()
237
+ hermes_prof, _template_prof = _srv()._resolve_bind_profile_arg(
238
+ profile, payer, room_id, workspace_id,
239
+ )
240
+ if payer:
241
+ _srv()._reconcile_profile_llm_for_user(hermes_prof, payer, workspace_id)
242
+ llm_provider = _srv()._llm_billing_provider(hermes_prof, user_id=payer, workspace_id=workspace_id)
243
+ lifecycle = _srv().ensure_profile_api(
244
+ hermes_prof,
245
+ payer,
246
+ workspace_id,
247
+ )
248
+ turn_run_id = str(uuid.uuid4())
249
+ try:
250
+ if payer:
251
+ _srv()._overlay_turn_provider_env(
252
+ hermes_prof, payer, workspace_id, llm_provider, turn_run_id,
253
+ )
254
+ _srv()._overlay_turn_user_env(hermes_prof, payer, workspace_id, turn_run_id)
255
+ turn_body = _srv()._profile_turn_payload(hermes_prof, text, room_id)
256
+ if payer and workspace_id:
257
+ _srv()._inject_turn_credentials(turn_body, payer, workspace_id, llm_provider)
258
+ status, data = _srv()._profile_api_request(
259
+ hermes_prof,
260
+ "POST",
261
+ f"/api/sessions/{urllib.parse.quote(session_id, safe='')}/chat",
262
+ turn_body,
263
+ )
264
+ if status >= 300:
265
+ raise ValueError(f"session chat failed: {data}")
266
+ assistant = ""
267
+ if isinstance(data, dict):
268
+ msg = data.get("message")
269
+ if isinstance(msg, dict):
270
+ assistant = str(msg.get("content") or "")
271
+ lane_bindings.chat_dispatch(
272
+ {
273
+ "profile": hermes_prof,
274
+ "session_id": session_id,
275
+ "gateway_session_id": f"api:{hermes_prof}:{session_id}",
276
+ "source_id": source_id,
277
+ "client_id": client_id,
278
+ "room_id": room_id,
279
+ "user_id": payer,
280
+ "text": text,
281
+ }
282
+ )
283
+ return {
284
+ "ok": True,
285
+ "profile": hermes_prof,
286
+ "session_id": session_id,
287
+ "api_port": lifecycle["api_port"],
288
+ "assistant": assistant,
289
+ }
290
+ finally:
291
+ if payer:
292
+ _srv()._revoke_turn_credential_lease(turn_run_id, hermes_prof)
293
+
294
+
295
+ def _enrich_room_chat_payload(payload: dict[str, Any], user_id: str) -> dict[str, Any]:
296
+ body = dict(payload) if isinstance(payload, dict) else {}
297
+ body["user_id"] = user_id
298
+ room_id = str(body.get("room_id") or "").strip()
299
+ if room_id and not str(body.get("workspace_id") or "").strip():
300
+ try:
301
+ conn = _srv()._workframe_db()
302
+ row = conn.execute(
303
+ "SELECT workspace_id FROM rooms WHERE id = ? AND deleted_at IS NULL",
304
+ (room_id,),
305
+ ).fetchone()
306
+ conn.close()
307
+ if row:
308
+ body["workspace_id"] = str(row["workspace_id"])
309
+ except Exception: # noqa: BLE001
310
+ pass
311
+ return body
312
+
313
+
314
+ def _extract_title(text: str, fallback: str) -> str:
315
+ for line in text.splitlines():
316
+ m = re.match(r"^#\s+(.+)$", line.strip())
317
+ if m:
318
+ return m.group(1).strip()
319
+ return fallback