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,694 @@
1
+ """WF-032 extract: chat_stream."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import queue
7
+ import re
8
+ import secrets
9
+ import shlex
10
+ import shutil
11
+ import sqlite3
12
+ import threading
13
+ import time
14
+ import urllib.error
15
+ import urllib.parse
16
+ import urllib.request
17
+ import uuid
18
+ from pathlib import Path
19
+ from typing import Any, Iterator
20
+
21
+ from http.server import BaseHTTPRequestHandler
22
+
23
+ import concierge
24
+ import lane_bindings
25
+ import llm_error_glossary
26
+ import run_authority
27
+ import run_ledger
28
+ import turn_credentials
29
+ import user_prefs
30
+ from domain.entities import RunStatus
31
+
32
+
33
+ def _srv():
34
+ import server as srv
35
+
36
+ return srv
37
+
38
+
39
+ def _live_stream_text(data: dict[str, Any]) -> str:
40
+ return str(data.get("delta") or data.get("text") or data.get("content") or "")
41
+
42
+
43
+ def _live_strip_placeholders(segments: list[dict[str, Any]]) -> list[dict[str, Any]]:
44
+ return [
45
+ segment
46
+ for segment in segments
47
+ if not (
48
+ segment.get("kind") == "text"
49
+ and str(segment.get("text") or "") in ("Thinking…", "…", "")
50
+ )
51
+ ]
52
+
53
+
54
+ def _live_reduce_stream_event(
55
+ segments: list[dict[str, Any]],
56
+ event: str,
57
+ data: dict[str, Any],
58
+ ) -> list[dict[str, Any]]:
59
+ if event in ("thinking.delta", "reasoning.delta"):
60
+ text = _live_stream_text(data)
61
+ if not text:
62
+ return segments
63
+ base = _live_strip_placeholders(segments)
64
+ if base and base[-1].get("kind") == "thinking":
65
+ merged = dict(base[-1])
66
+ merged["text"] = str(merged.get("text") or "") + text
67
+ return [*base[:-1], merged]
68
+ return [*base, {"kind": "thinking", "text": text}]
69
+ if event in ("message.delta", "assistant.delta"):
70
+ text = _live_stream_text(data)
71
+ if not text:
72
+ return segments
73
+ base = _live_strip_placeholders(segments)
74
+ if base and base[-1].get("kind") == "text":
75
+ merged = dict(base[-1])
76
+ merged["text"] = str(merged.get("text") or "") + text
77
+ return [*base[:-1], merged]
78
+ return [*base, {"kind": "text", "text": text}]
79
+ if event == "tool.started":
80
+ name = str(data.get("tool_name") or "tool")
81
+ preview = str(data.get("preview") or "")
82
+ base = _live_strip_placeholders(segments)
83
+ for idx in range(len(base) - 1, -1, -1):
84
+ segment = base[idx]
85
+ if segment.get("kind") == "tool" and segment.get("name") == name:
86
+ updated = dict(segment)
87
+ updated["status"] = "running"
88
+ if preview:
89
+ updated["preview"] = preview
90
+ return [*base[:idx], updated, *base[idx + 1 :]]
91
+ tool = {"kind": "tool", "name": name, "status": "running"}
92
+ if preview:
93
+ tool["preview"] = preview
94
+ return [*base, tool]
95
+ if event in ("tool.completed", "tool.failed"):
96
+ name = str(data.get("tool_name") or "tool")
97
+ output = str(data.get("preview") or data.get("output") or "")
98
+ base = _live_strip_placeholders(segments)
99
+ for idx in range(len(base) - 1, -1, -1):
100
+ segment = base[idx]
101
+ if segment.get("kind") == "tool" and segment.get("name") == name:
102
+ updated = dict(segment)
103
+ updated["status"] = "done"
104
+ updated["output"] = output or str(segment.get("output") or segment.get("preview") or "")
105
+ updated.pop("preview", None)
106
+ return [*base[:idx], updated, *base[idx + 1 :]]
107
+ return [*base, {"kind": "tool", "name": name, "status": "done", "output": output}]
108
+ if event == "tool.progress":
109
+ name = str(data.get("tool_name") or "tool")
110
+ if name == "_thinking":
111
+ return segments
112
+ preview = _live_stream_text(data)
113
+ base = _live_strip_placeholders(segments)
114
+ for idx in range(len(base) - 1, -1, -1):
115
+ segment = base[idx]
116
+ if segment.get("kind") == "tool" and segment.get("name") == name:
117
+ updated = dict(segment)
118
+ updated["status"] = "running"
119
+ if preview:
120
+ updated["preview"] = preview
121
+ return [*base[:idx], updated, *base[idx + 1 :]]
122
+ tool = {"kind": "tool", "name": name, "status": "running"}
123
+ if preview:
124
+ tool["preview"] = preview
125
+ return [*base, tool]
126
+ if event in ("message.complete", "assistant.completed"):
127
+ final = str(data.get("content") or data.get("text") or "").strip()
128
+ if not final:
129
+ return segments
130
+ base = _live_strip_placeholders(segments)
131
+ if base and base[-1].get("kind") == "text":
132
+ merged = dict(base[-1])
133
+ merged["text"] = final
134
+ return [*base[:-1], merged]
135
+ return [*base, {"kind": "text", "text": final}]
136
+ if event == "error":
137
+ text = str(data.get("error") or data.get("message") or "Stream error")
138
+ return _live_strip_placeholders(segments) + [{"kind": "text", "text": text}]
139
+ return segments
140
+
141
+
142
+ def _segments_to_reply_text(segments: list[dict[str, Any]]) -> str:
143
+ return "".join(
144
+ str(segment.get("text") or "")
145
+ for segment in segments
146
+ if segment.get("kind") == "text"
147
+ ).strip()
148
+
149
+
150
+ def _parse_profile_stream_frame(frame: bytes) -> tuple[str, dict[str, Any]] | None:
151
+ text = frame.decode("utf-8", errors="replace")
152
+ lines = text.splitlines()
153
+ event_name = ""
154
+ data_lines: list[str] = []
155
+ for line in lines:
156
+ if line.startswith("event:"):
157
+ event_name = line[6:].strip()
158
+ elif line.startswith("data:"):
159
+ data_lines.append(line[5:].lstrip())
160
+ if not event_name and not data_lines:
161
+ return None
162
+ data: dict[str, Any] = {}
163
+ if data_lines:
164
+ try:
165
+ parsed = json.loads("\n".join(data_lines))
166
+ data = parsed if isinstance(parsed, dict) else {"text": str(parsed)}
167
+ except Exception: # noqa: BLE001
168
+ data = {"text": "\n".join(data_lines)}
169
+ normalized = event_name
170
+ if event_name in ("assistant.delta", "message.delta"):
171
+ normalized = "message.delta"
172
+ elif event_name in ("assistant.completed", "message.complete"):
173
+ normalized = "message.complete"
174
+ elif event_name in ("thinking.delta", "reasoning.delta"):
175
+ normalized = "thinking.delta"
176
+ return normalized, data
177
+
178
+
179
+ def _iter_profile_stream_frames(upstream: Any) -> Iterator[tuple[str, dict[str, Any]]]:
180
+ buffer = b""
181
+ while True:
182
+ chunk = upstream.read(4096)
183
+ if not chunk:
184
+ break
185
+ buffer += chunk
186
+ while True:
187
+ sep = buffer.find(b"\n\n")
188
+ crlf_sep = buffer.find(b"\r\n\r\n")
189
+ if sep == -1 and crlf_sep == -1:
190
+ break
191
+ if crlf_sep != -1 and (sep == -1 or crlf_sep < sep):
192
+ idx = crlf_sep
193
+ delimiter_len = 4
194
+ else:
195
+ idx = sep
196
+ delimiter_len = 2
197
+ frame = buffer[:idx]
198
+ buffer = buffer[idx + delimiter_len :]
199
+ parsed = _parse_profile_stream_frame(frame)
200
+ if parsed:
201
+ yield parsed
202
+ tail = buffer.strip()
203
+ if tail:
204
+ parsed = _parse_profile_stream_frame(tail)
205
+ if parsed:
206
+ yield parsed
207
+
208
+ def _emit_stream_chat_error(
209
+ handler: BaseHTTPRequestHandler,
210
+ message: str = "",
211
+ *,
212
+ entry: dict[str, Any] | None = None,
213
+ ) -> None:
214
+ """SSE error frame — structured playbook when entry supplied."""
215
+ payload = llm_error_glossary.notice_payload(entry) if entry else {}
216
+ if not payload:
217
+ text = str(message or "").strip() or "Chat failed."
218
+ payload = {"error": text, "text": text, "message": text}
219
+ try:
220
+ handler.send_response(200)
221
+ handler.send_header("Content-Type", "text/event-stream; charset=utf-8")
222
+ handler.send_header("Cache-Control", "no-cache")
223
+ handler.send_header("Connection", "keep-alive")
224
+ handler.end_headers()
225
+ body = json.dumps(payload)
226
+ handler.wfile.write(f"event: error\ndata: {body}\n\n".encode("utf-8"))
227
+ handler.wfile.write(b"event: done\ndata: {}\n\n")
228
+ handler.wfile.flush()
229
+ except (BrokenPipeError, ConnectionResetError, OSError):
230
+ return
231
+
232
+
233
+ def _open_profile_stream(
234
+ handler: BaseHTTPRequestHandler,
235
+ prof: str,
236
+ *,
237
+ model_used: str,
238
+ llm_provider: str,
239
+ api_port: int,
240
+ ) -> None:
241
+ """Flush SSE headers + run.started before blocking on gateway/model."""
242
+ handler.send_response(200)
243
+ handler.send_header("Content-Type", "text/event-stream; charset=utf-8")
244
+ handler.send_header("Cache-Control", "no-cache")
245
+ handler.send_header("Connection", "keep-alive")
246
+ handler.send_header("X-Workframe-Profile", prof)
247
+ handler.send_header("X-Workframe-Api-Port", str(api_port))
248
+ handler.end_headers()
249
+ handler.wfile.write(
250
+ (
251
+ "event: run.started\n"
252
+ f"data: {json.dumps({'text': 'Contacting model...', 'model': model_used, 'llm_provider': llm_provider})}\n\n"
253
+ ).encode("utf-8"),
254
+ )
255
+ handler.wfile.flush()
256
+
257
+
258
+ def _emit_stream_error_body(
259
+ handler: BaseHTTPRequestHandler,
260
+ *,
261
+ entry: dict[str, Any] | None = None,
262
+ message: str = "",
263
+ ) -> None:
264
+ """Error/done frames when SSE headers were already sent."""
265
+ payload = llm_error_glossary.notice_payload(entry) if entry else {}
266
+ if not payload:
267
+ text = str(message or "").strip() or "Chat failed."
268
+ payload = {"error": text, "text": text, "message": text}
269
+ try:
270
+ body = json.dumps(payload)
271
+ handler.wfile.write(f"event: error\ndata: {body}\n\n".encode("utf-8"))
272
+ handler.wfile.write(b"event: done\ndata: {}\n\n")
273
+ handler.wfile.flush()
274
+ except (BrokenPipeError, ConnectionResetError, OSError):
275
+ return
276
+
277
+
278
+ def _emit_stream_concierge(handler: BaseHTTPRequestHandler, entry: dict[str, Any]) -> None:
279
+ """Deterministic assistant reply when LLM path is unavailable."""
280
+ payload = llm_error_glossary.notice_payload(entry)
281
+ text = str(payload.get("message") or "")
282
+ hint = str(payload.get("hint") or "").strip()
283
+ if hint:
284
+ text = f"{text}\n\n{hint}"
285
+ try:
286
+ handler.send_response(200)
287
+ handler.send_header("Content-Type", "text/event-stream; charset=utf-8")
288
+ handler.send_header("Cache-Control", "no-cache")
289
+ handler.send_header("Connection", "keep-alive")
290
+ handler.end_headers()
291
+ handler.wfile.write(
292
+ f"event: concierge\ndata: {json.dumps(payload)}\n\n".encode("utf-8"),
293
+ )
294
+ handler.wfile.write(
295
+ f"event: message.complete\ndata: {json.dumps({'content': text, 'text': text})}\n\n".encode(
296
+ "utf-8",
297
+ ),
298
+ )
299
+ handler.wfile.write(b"event: done\ndata: {}\n\n")
300
+ handler.wfile.flush()
301
+ except (BrokenPipeError, ConnectionResetError, OSError):
302
+ return
303
+
304
+
305
+ def _log_llm_failure(
306
+ handler: BaseHTTPRequestHandler,
307
+ code: str,
308
+ *,
309
+ provider: str = "",
310
+ model: str = "",
311
+ profile: str = "",
312
+ ) -> None:
313
+ if hasattr(handler, "_log_audit"):
314
+ handler._log_audit( # type: ignore[attr-defined]
315
+ "llm_failure",
316
+ "llm",
317
+ profile or provider or "unknown",
318
+ f"code={code} provider={provider} model={model}",
319
+ )
320
+
321
+
322
+ def _run_authority_context_for_chat(
323
+ user_id: str,
324
+ workspace_id: str,
325
+ provider: str,
326
+ ) -> run_authority.RunAuthorityContext:
327
+ user = str(user_id or "").strip()
328
+ ws = str(workspace_id or "").strip()
329
+ provider_name = str(provider or "openrouter").strip().lower()
330
+ mode = _srv()._workspace_credential_mode(None, ws)
331
+ user_only = _srv()._provider_user_only(provider_name)
332
+ oauth_connected = False
333
+ oauth_spec = _srv()._oauth_llm_provider_spec(provider_name)
334
+ if oauth_spec and user:
335
+ oauth_connected = _srv()._hermes_oauth_tokens_present(user, _srv()._hermes_auth_id_for_spec(oauth_spec))
336
+ user_resolved = _srv()._resolve_credential(user, ws, provider_name, user_only=True) if user else None
337
+ user_has = bool(user_resolved and _srv()._credential_secret(user_resolved, user))
338
+ ws_has = False
339
+ if ws and not user_only:
340
+ ws_resolved = _srv()._resolve_credential(user, ws, provider_name, user_only=False)
341
+ ws_has = bool(ws_resolved and _srv()._credential_secret(ws_resolved, user))
342
+ grantors: dict[str, bool] = {}
343
+ if user and ws:
344
+ for grantor_id in _srv()._delegation_grantor_ids_for_grantee(user, ws):
345
+ g_resolved = _srv()._resolve_credential(grantor_id, ws, provider_name, user_only=True)
346
+ grantors[grantor_id] = bool(g_resolved and _srv()._credential_secret(g_resolved, grantor_id))
347
+ return run_authority.RunAuthorityContext(
348
+ workspace_credential_mode=mode,
349
+ provider_user_only=user_only,
350
+ user_has_credential=user_has,
351
+ workspace_has_credential=ws_has,
352
+ grantor_has_credential=grantors,
353
+ oauth_connected=oauth_connected,
354
+ )
355
+
356
+
357
+ def stream_profile_chat(handler: BaseHTTPRequestHandler, profile: str, payload: dict[str, Any]) -> None:
358
+ _triggering_user = str(payload.get("user_id", "") or "")
359
+ _workspace_id = str(payload.get("workspace_id", "") or "")
360
+ _room_id = str(payload.get("room_id") or "").strip()
361
+ _turn_run_id = str(uuid.uuid4())
362
+ prof, _template_prof = _srv()._resolve_bind_profile_arg(
363
+ profile, _triggering_user, _room_id, _workspace_id,
364
+ )
365
+ session_id = str(payload.get("session_id") or "").strip()
366
+ text = str(payload.get("text") or "").strip()
367
+ if not session_id:
368
+ raise ValueError("session_id required")
369
+ if not text:
370
+ raise ValueError("text required")
371
+ model_block = _srv()._read_model_block(prof)
372
+ llm_provider = _srv()._llm_billing_provider(
373
+ prof, user_id=_triggering_user, workspace_id=_workspace_id, block=model_block,
374
+ )
375
+ model_used = str(model_block.get("default") or "").strip()
376
+ _auth_decision: run_authority.RunAuthorityDecision | None = None
377
+ if _triggering_user:
378
+ run_ledger.ensure_schema()
379
+ auth_req = run_authority.chat_run_request(
380
+ triggering_user_id=_triggering_user,
381
+ profile_slug=prof,
382
+ workspace_id=_workspace_id,
383
+ provider=llm_provider,
384
+ room_id=_room_id or None,
385
+ )
386
+ auth_ctx = _run_authority_context_for_chat(_triggering_user, _workspace_id, llm_provider)
387
+ _auth_decision = run_authority.evaluate_run_authority(
388
+ auth_req, auth_ctx, run_id=_turn_run_id,
389
+ )
390
+ conn = _srv()._workframe_db()
391
+ try:
392
+ run_ledger.record_authority_decision(
393
+ conn,
394
+ run_id=_turn_run_id,
395
+ request_surface=auth_req.surface,
396
+ actor_type=auth_req.actor_type,
397
+ actor_id=auth_req.actor_id,
398
+ triggering_user_id=_triggering_user,
399
+ workspace_id=_workspace_id or "default",
400
+ agent_id=auth_req.agent_id,
401
+ runtime_binding_id=auth_req.runtime_binding_id,
402
+ profile_slug=prof,
403
+ provider=llm_provider,
404
+ room_id=_room_id or None,
405
+ session_id=session_id,
406
+ decision=_auth_decision,
407
+ )
408
+ conn.commit()
409
+ finally:
410
+ conn.close()
411
+ if not _auth_decision.allowed:
412
+ entry = concierge.respond(text, situation="no_provider")
413
+ _log_llm_failure(
414
+ handler,
415
+ str(_auth_decision.deny_reason or "run_denied"),
416
+ provider=llm_provider,
417
+ profile=prof,
418
+ )
419
+ _emit_stream_concierge(handler, entry)
420
+ return
421
+ llm_ready = bool(
422
+ _triggering_user
423
+ and _srv()._user_can_use_llm(_triggering_user, _workspace_id, llm_provider)
424
+ )
425
+ if _triggering_user and not llm_ready:
426
+ entry = concierge.respond(text, situation="no_provider")
427
+ _log_llm_failure(handler, str(entry.get("code") or "no_llm_provider"), provider=llm_provider, profile=prof)
428
+ _emit_stream_concierge(handler, entry)
429
+ return
430
+ api_port = _srv()._profile_api_port(prof)
431
+ _open_profile_stream(
432
+ handler,
433
+ prof,
434
+ model_used=model_used,
435
+ llm_provider=llm_provider,
436
+ api_port=api_port,
437
+ )
438
+ try:
439
+ _srv().ensure_profile_api(
440
+ prof,
441
+ _triggering_user,
442
+ _workspace_id,
443
+ )
444
+ if _triggering_user:
445
+ _srv()._require_user_provider(_triggering_user, _workspace_id, llm_provider)
446
+ _srv()._overlay_turn_provider_env(
447
+ prof, _triggering_user, _workspace_id, llm_provider, _turn_run_id,
448
+ )
449
+ _srv()._overlay_turn_user_env(prof, _triggering_user, _workspace_id, _turn_run_id)
450
+ except ValueError as exc:
451
+ err_text = str(exc)
452
+ if "no_llm_provider_for_user" in err_text:
453
+ entry = concierge.respond(text, situation="no_provider")
454
+ _log_llm_failure(handler, "no_llm_provider", provider=llm_provider, profile=prof)
455
+ _emit_stream_error_body(handler, entry=entry)
456
+ return
457
+ entry = llm_error_glossary.classify_exception_text(err_text)
458
+ _log_llm_failure(handler, str(entry.get("code") or ""), provider=llm_provider, profile=prof)
459
+ _emit_stream_error_body(handler, entry=entry)
460
+ return
461
+ upstream_body = json.dumps(_srv()._profile_turn_payload(prof, text, _room_id)).encode("utf-8")
462
+
463
+ if _triggering_user and _workspace_id:
464
+ _resolved_cred = _srv()._resolve_credential(
465
+ _triggering_user,
466
+ _workspace_id,
467
+ llm_provider,
468
+ user_only=True,
469
+ )
470
+ if _resolved_cred:
471
+ try:
472
+ _body = json.loads(upstream_body)
473
+ _body["_credential_override"] = _resolved_cred["credential_ref"]
474
+ _body["_credential_scope"] = _resolved_cred["scope"]
475
+ upstream_body = json.dumps(_body).encode("utf-8")
476
+ except Exception:
477
+ pass
478
+
479
+ url = f"http://gateway:{api_port}/api/sessions/{urllib.parse.quote(session_id, safe='')}/chat/stream"
480
+ headers = {
481
+ "Authorization": f"Bearer {_srv()._profile_api_key(prof)}",
482
+ "Content-Type": "application/json",
483
+ }
484
+ req = urllib.request.Request(url, data=upstream_body, headers=headers, method="POST")
485
+ saw_complete = False
486
+ complete_text = ""
487
+ try:
488
+ upstream = urllib.request.urlopen(req, timeout=3600)
489
+ except urllib.error.HTTPError as exc:
490
+ raw = exc.read().decode("utf-8", errors="replace")
491
+ try:
492
+ data = json.loads(raw)
493
+ except Exception: # noqa: BLE001
494
+ data = {"error": raw or f"upstream stream failed: {exc.code}"}
495
+ entry = llm_error_glossary.classify_exception_text(str(data))
496
+ _emit_stream_error_body(handler, entry=entry)
497
+ return
498
+ except OSError as exc:
499
+ entry = llm_error_glossary.classify_exception_text(str(exc))
500
+ _emit_stream_error_body(handler, entry=entry)
501
+ return
502
+
503
+ try:
504
+ buffer = b""
505
+
506
+ def _rewrite_event_frame(frame: bytes) -> bytes:
507
+ nonlocal saw_complete, complete_text
508
+ text = frame.decode("utf-8", errors="replace")
509
+ lines = text.splitlines()
510
+ event_name = ""
511
+ data_lines: list[str] = []
512
+ other_lines: list[str] = []
513
+ for line in lines:
514
+ if line.startswith("event:"):
515
+ event_name = line[6:].strip()
516
+ elif line.startswith("data:"):
517
+ data_lines.append(line[5:].lstrip())
518
+ elif line.strip():
519
+ other_lines.append(line)
520
+
521
+ normalized = event_name
522
+ if event_name in ("assistant.delta", "message.delta"):
523
+ normalized = "message.delta"
524
+ elif event_name in ("assistant.completed", "message.complete"):
525
+ normalized = "message.complete"
526
+ saw_complete = True
527
+ if data_lines:
528
+ try:
529
+ payload = json.loads("\n".join(data_lines))
530
+ complete_text = str(
531
+ payload.get("content") or payload.get("text") or ""
532
+ ).strip()
533
+ except Exception: # noqa: BLE001
534
+ complete_text = ""
535
+ elif event_name in ("thinking.delta", "reasoning.delta"):
536
+ normalized = "thinking.delta"
537
+ elif event_name in ("run.failed",):
538
+ normalized = "error"
539
+ elif event_name == "error":
540
+ normalized = "error"
541
+
542
+ output_lines: list[str] = []
543
+ if normalized:
544
+ output_lines.append(f"event: {normalized}")
545
+ output_lines.extend(other_lines)
546
+ output_lines.extend(f"data: {line}" for line in data_lines)
547
+ return ("\n".join(output_lines) + "\n\n").encode("utf-8") if output_lines else b""
548
+
549
+ while True:
550
+ chunk = upstream.read(4096)
551
+ if not chunk:
552
+ break
553
+ buffer += chunk
554
+ while True:
555
+ sep = buffer.find(b"\n\n")
556
+ crlf_sep = buffer.find(b"\r\n\r\n")
557
+ if sep == -1 and crlf_sep == -1:
558
+ break
559
+ if crlf_sep != -1 and (sep == -1 or crlf_sep < sep):
560
+ idx = crlf_sep
561
+ delimiter_len = 4
562
+ else:
563
+ idx = sep
564
+ delimiter_len = 2
565
+ frame = buffer[:idx]
566
+ buffer = buffer[idx + delimiter_len :]
567
+ rewritten = _rewrite_event_frame(frame)
568
+ if rewritten:
569
+ handler.wfile.write(rewritten)
570
+ handler.wfile.flush()
571
+ tail = buffer.strip()
572
+ if tail:
573
+ rewritten = _rewrite_event_frame(tail)
574
+ if rewritten:
575
+ handler.wfile.write(rewritten)
576
+ handler.wfile.flush()
577
+ if not saw_complete or not complete_text:
578
+ config_key = _srv()._read_config_model_api_key(prof)
579
+ _, model_name = _srv()._read_model_from_config(prof)
580
+ if config_key.startswith(turn_credentials.LEASE_PREFIX) and not turn_credentials.validate_lease(
581
+ config_key,
582
+ ):
583
+ entry = llm_error_glossary.playbook_entry("invalid_lease")
584
+ else:
585
+ entry = llm_error_glossary.playbook_entry("provider_empty_reply")
586
+ _log_llm_failure(
587
+ handler,
588
+ str(entry.get("code") or "provider_empty_reply"),
589
+ provider=llm_provider,
590
+ model=str(model_name or ""),
591
+ profile=prof,
592
+ )
593
+ err_payload = json.dumps(llm_error_glossary.notice_payload(entry))
594
+ handler.wfile.write(f"event: error\ndata: {err_payload}\n\n".encode("utf-8"))
595
+ handler.wfile.flush()
596
+ handler.wfile.write(b"event: done\ndata: {}\n\n")
597
+ handler.wfile.flush()
598
+ except (BrokenPipeError, ConnectionResetError, OSError):
599
+ return
600
+ finally:
601
+ try:
602
+ upstream.close()
603
+ except Exception: # noqa: BLE001
604
+ pass
605
+ if _triggering_user:
606
+ try:
607
+ _srv()._revoke_turn_credential_lease(_turn_run_id, prof)
608
+ except Exception: # noqa: BLE001
609
+ pass
610
+ if _auth_decision and _auth_decision.allowed:
611
+ try:
612
+ conn = _srv()._workframe_db()
613
+ try:
614
+ existing = run_ledger.get_run(conn, _turn_run_id)
615
+ if existing and existing.status == RunStatus.RUNNING:
616
+ if saw_complete and complete_text:
617
+ run_ledger.complete_run(
618
+ conn,
619
+ _turn_run_id,
620
+ model=model_used,
621
+ provider=llm_provider,
622
+ funding_source=_auth_decision.funding_source,
623
+ payer_user_id=_auth_decision.payer_user_id,
624
+ receipt={
625
+ "session_id": session_id,
626
+ "room_id": _room_id,
627
+ "credential_scope": _auth_decision.credential_scope,
628
+ },
629
+ )
630
+ else:
631
+ run_ledger.fail_run(
632
+ conn, _turn_run_id, reason="stream_incomplete",
633
+ )
634
+ conn.commit()
635
+ finally:
636
+ conn.close()
637
+ except Exception: # noqa: BLE001
638
+ pass
639
+
640
+ room_id = str(payload.get("room_id") or "").strip()
641
+ if room_id:
642
+ try:
643
+ conn = _srv()._workframe_db()
644
+ try:
645
+ row = conn.execute(
646
+ """
647
+ SELECT id FROM room_sessions
648
+ WHERE room_id = ? AND session_id = ? AND deleted_at IS NULL
649
+ LIMIT 1
650
+ """,
651
+ (room_id, session_id),
652
+ ).fetchone()
653
+ if row:
654
+ now = str(int(time.time()))
655
+ conn.execute(
656
+ "UPDATE room_sessions SET updated_at = ? WHERE id = ?",
657
+ (now, row["id"]),
658
+ )
659
+ conn.commit()
660
+ finally:
661
+ conn.close()
662
+ except Exception: # noqa: BLE001
663
+ pass
664
+ try:
665
+ lane_bindings._sync_lane_binding(
666
+ prof,
667
+ str(payload.get("source_id") or "ui").strip() or "ui",
668
+ str(payload.get("client_id") or "default").strip() or "default",
669
+ lane_bindings._binding_version(payload.get("binding_version")),
670
+ session_id,
671
+ f"api:{prof}:{session_id}",
672
+ str(payload.get("text") or ""),
673
+ )
674
+ except Exception: # noqa: BLE001
675
+ pass
676
+ return
677
+ # ponytail: legacy global rebind when room_id omitted
678
+ try:
679
+ latest = _srv()._latest_api_session_id(prof)
680
+ if latest and latest != session_id:
681
+ source_id = str(payload.get("source_id") or "ui").strip() or "ui"
682
+ client_id = str(payload.get("client_id") or "default").strip() or "default"
683
+ binding_version = lane_bindings._binding_version(payload.get("binding_version"))
684
+ lane_bindings.chat_dispatch({
685
+ "profile": prof,
686
+ "session_id": latest,
687
+ "gateway_session_id": f"api:{prof}:{latest}",
688
+ "source_id": source_id,
689
+ "client_id": client_id,
690
+ "binding_version": binding_version,
691
+ "text": "",
692
+ })
693
+ except Exception: # noqa: BLE001
694
+ pass