create-workframe 0.1.17 → 0.1.20

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 (137) hide show
  1. package/README.md +1 -1
  2. package/bin/create-workframe.js +73 -9
  3. package/package.json +1 -1
  4. package/scripts/apply-update-workframe.sh +21 -18
  5. package/scripts/compose-docker-host.sh +26 -0
  6. package/workframe-api/action_proxy.py +8 -0
  7. package/workframe-api/chat_sessions.py +37 -6
  8. package/workframe-api/chat_stream.py +16 -0
  9. package/workframe-api/db_schema.py +29 -0
  10. package/workframe-api/handler_modules/handler_admin.py +32 -0
  11. package/workframe-api/handler_modules/handler_auth.py +6 -4
  12. package/workframe-api/handler_modules/handler_chat.py +17 -26
  13. package/workframe-api/handler_modules/handler_provider.py +13 -14
  14. package/workframe-api/handler_modules/handler_workspace.py +60 -2
  15. package/workframe-api/hermes_profiles.py +31 -9
  16. package/workframe-api/llm_proxy.py +18 -0
  17. package/workframe-api/mention_invoke.py +29 -24
  18. package/workframe-api/message_reactions.py +139 -0
  19. package/workframe-api/model_surface.py +329 -113
  20. package/workframe-api/package.json +4 -4
  21. package/workframe-api/profile_gateway.py +14 -6
  22. package/workframe-api/provider_bindings.py +118 -17
  23. package/workframe-api/provider_bootstrap.py +74 -38
  24. package/workframe-api/provider_model_catalog.py +261 -0
  25. package/workframe-api/rooms.py +4 -3
  26. package/workframe-api/route_registry.py +4 -0
  27. package/workframe-api/runtime_cohort.py +118 -23
  28. package/workframe-api/scripts/run-python.mjs +28 -0
  29. package/workframe-api/server.py +44 -12
  30. package/workframe-api/test_agent_identity_authority.py +96 -0
  31. package/workframe-api/test_chat_attribution_history.py +59 -0
  32. package/workframe-api/test_enrich_room_messages.py +43 -2
  33. package/workframe-api/test_llm_proxy_encoding.py +33 -0
  34. package/workframe-api/test_message_reactions.py +151 -0
  35. package/workframe-api/test_model_routing_reconcile.py +122 -0
  36. package/workframe-api/test_model_surface_consistency.py +45 -5
  37. package/workframe-api/test_oauth_llm.py +1 -1
  38. package/workframe-api/test_profile_gateway_health.py +37 -0
  39. package/workframe-api/test_provider_bindings.py +115 -1
  40. package/workframe-api/test_provider_model_catalog.py +27 -0
  41. package/workframe-api/test_provider_model_surface.py +2 -2
  42. package/workframe-api/test_runtime_lease_lifecycle.py +93 -0
  43. package/workframe-api/test_server_reexports.py +2 -0
  44. package/workframe-api/test_user_llm_prefs.py +27 -0
  45. package/workframe-api/test_workspace_file_actions.py +119 -0
  46. package/workframe-api/turn_credentials.py +41 -0
  47. package/workframe-api/turn_overlay.py +95 -34
  48. package/workframe-api/updates.py +14 -5
  49. package/workframe-api/user_prefs.py +15 -0
  50. package/workframe-api/workspace_bootstrap.py +1 -0
  51. package/workframe-api/workspace_files.py +124 -0
  52. package/workframe-supervisor/server.py +18 -2
  53. package/workframe-ui/public/assets/{arc-esSBm9C_.js → arc-BkATcoqt.js} +1 -1
  54. package/workframe-ui/public/assets/architecture-7EHR7CIX-yusDFKmK.js +1 -0
  55. package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-DVVz9RGn.js → architectureDiagram-3BPJPVTR-31_yAiUS.js} +1 -1
  56. package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-B6i10GMX.js → blockDiagram-GPEHLZMM-ClM6Si9T.js} +1 -1
  57. package/workframe-ui/public/assets/{brave-B885JYnH.svg → brave-BoYMbZi1.svg} +0 -1
  58. package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-BqwJ7JoH.js → c4Diagram-AAUBKEIU-DJzEktF3.js} +1 -1
  59. package/workframe-ui/public/assets/channel-Nb_UjZ3O.js +1 -0
  60. package/workframe-ui/public/assets/{chunk-2J33WTMH-CyhcedSa.js → chunk-2J33WTMH-DPFiyb5O.js} +1 -1
  61. package/workframe-ui/public/assets/{chunk-3OPIFGDE-1Rr6WuvI.js → chunk-3OPIFGDE-DXNJzuCn.js} +1 -1
  62. package/workframe-ui/public/assets/{chunk-4BX2VUAB-D2bRrARV.js → chunk-4BX2VUAB-DDNibGdr.js} +1 -1
  63. package/workframe-ui/public/assets/{chunk-55IACEB6-CKXeZxSr.js → chunk-55IACEB6-Co3_ulwA.js} +1 -1
  64. package/workframe-ui/public/assets/{chunk-5ZQYHXKU-DYQxxPZ4.js → chunk-5ZQYHXKU-Cmxz8o4d.js} +1 -1
  65. package/workframe-ui/public/assets/{chunk-727SXJPM-CGpIgqvD.js → chunk-727SXJPM-CDAQGesU.js} +1 -1
  66. package/workframe-ui/public/assets/{chunk-AQP2D5EJ-B88NGQ7d.js → chunk-AQP2D5EJ-x-swy3kc.js} +1 -1
  67. package/workframe-ui/public/assets/{chunk-BSJP7CBP-CCymiI0O.js → chunk-BSJP7CBP-28nU0JOE.js} +1 -1
  68. package/workframe-ui/public/assets/{chunk-CSCIHK7Q-DRgey03j.js → chunk-CSCIHK7Q-FUURFika.js} +1 -1
  69. package/workframe-ui/public/assets/{chunk-FMBD7UC4-l1adn8gM.js → chunk-FMBD7UC4-B4RgRO2R.js} +1 -1
  70. package/workframe-ui/public/assets/{chunk-KSCS5N6A-CaEvDRPK.js → chunk-KSCS5N6A-CkuppW3T.js} +1 -1
  71. package/workframe-ui/public/assets/{chunk-L5ZTLDWV-BgCnvPTv.js → chunk-L5ZTLDWV-DyrPSaz3.js} +1 -1
  72. package/workframe-ui/public/assets/{chunk-LZXEDZCA-t2zokHpn.js → chunk-LZXEDZCA-DFaZSDNV.js} +2 -2
  73. package/workframe-ui/public/assets/{chunk-ND2GUHAM-C3c9p0sv.js → chunk-ND2GUHAM-DXI0OJxg.js} +1 -1
  74. package/workframe-ui/public/assets/{chunk-NZK2D7GU-DSujF-R8.js → chunk-NZK2D7GU-CyOp1ZG2.js} +1 -1
  75. package/workframe-ui/public/assets/{chunk-O5CBEL6O-DJJTbM6p.js → chunk-O5CBEL6O-tZZMCfbx.js} +1 -1
  76. package/workframe-ui/public/assets/chunk-QZHKN3VN-C_TtOXi5.js +1 -0
  77. package/workframe-ui/public/assets/chunk-WU5MYG2G-BjSJQEIY.js +1 -0
  78. package/workframe-ui/public/assets/{chunk-XPW4576I-D2Cxn3AG.js → chunk-XPW4576I-B7_2gS3x.js} +1 -1
  79. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-JD6iZUGh.js +1 -0
  80. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-JD6iZUGh.js +1 -0
  81. package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A---dQ-pFK.js → cose-bilkent-S5V4N54A-BWJaHw_L.js} +1 -1
  82. package/workframe-ui/public/assets/{dagre-BM42HDAG-B_qe1ODY.js → dagre-BM42HDAG-Bu5MadGV.js} +1 -1
  83. package/workframe-ui/public/assets/{diagram-2AECGRRQ-Bou8vEYl.js → diagram-2AECGRRQ-ckLZkDFD.js} +1 -1
  84. package/workframe-ui/public/assets/{diagram-5GNKFQAL-DEUEzONU.js → diagram-5GNKFQAL-Cf6Abx1Y.js} +1 -1
  85. package/workframe-ui/public/assets/{diagram-KO2AKTUF-Ctq571VA.js → diagram-KO2AKTUF-TLk7_5mJ.js} +1 -1
  86. package/workframe-ui/public/assets/{diagram-LMA3HP47-CnKn89lm.js → diagram-LMA3HP47-ouNxsAb0.js} +1 -1
  87. package/workframe-ui/public/assets/{diagram-OG6HWLK6-DnKlrMx2.js → diagram-OG6HWLK6-BMCBQ35D.js} +1 -1
  88. package/workframe-ui/public/assets/{dist-ubrEgQyZ.js → dist-C9bxMexz.js} +1 -1
  89. package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-PjqO7CJd.js → erDiagram-TEJ5UH35-BN3e6jb1.js} +1 -1
  90. package/workframe-ui/public/assets/eventmodeling-FCH6USID-Bqd_sTz0.js +1 -0
  91. package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-B2PN_nNG.js → flowDiagram-I6XJVG4X-BiinzZrd.js} +1 -1
  92. package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-ThyAtNQP.js → ganttDiagram-6RSMTGT7-AYE8oBBG.js} +1 -1
  93. package/workframe-ui/public/assets/{gemini-Cq-0-rmr.svg → gemini-DqnZlvMo.svg} +0 -1
  94. package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-CB2UgoWy.js → gitGraph-WXDBUCRP-mIq_E2vs.js} +1 -1
  95. package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-DRJJ-zT-.js → gitGraphDiagram-PVQCEYII-Cf8IFEcO.js} +1 -1
  96. package/workframe-ui/public/assets/index-BnfFRGR3.js +133 -0
  97. package/workframe-ui/public/assets/index-COshPjF3.css +1 -0
  98. package/workframe-ui/public/assets/{info-J43DQDTF-CLtR33Q_.js → info-J43DQDTF--_3kCaIQ.js} +1 -1
  99. package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-Cmunqfvi.js → infoDiagram-5YYISTIA-BuIOyYMu.js} +1 -1
  100. package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-DWE_84eL.js → ishikawaDiagram-YF4QCWOH-D3bbsXQ8.js} +1 -1
  101. package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-BtBhpyRL.js → journeyDiagram-JHISSGLW-BrZ26C8L.js} +1 -1
  102. package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-B8QspDDL.js → kanban-definition-UN3LZRKU-CJiUyUOr.js} +1 -1
  103. package/workframe-ui/public/assets/{line-iOgDn4O8.js → line-Bee1snmU.js} +1 -1
  104. package/workframe-ui/public/assets/{linear-BBehZNVA.js → linear-dfD-zlI7.js} +1 -1
  105. package/workframe-ui/public/assets/{mermaid-parser.core-DQK5HcdU.js → mermaid-parser.core-D6IqqDSH.js} +2 -2
  106. package/workframe-ui/public/assets/{mermaid.core-Dsrdd1Wr.js → mermaid.core-CuuxYt2r.js} +3 -3
  107. package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-Ds_BfsJA.js → mindmap-definition-RKZ34NQL-T7W2fkwA.js} +1 -1
  108. package/workframe-ui/public/assets/{packet-YPE3B663-Bz3pi4i8.js → packet-YPE3B663-CiXMDasj.js} +1 -1
  109. package/workframe-ui/public/assets/{pie-LRSECV5Y-BFFah-6g.js → pie-LRSECV5Y-CUx1_96f.js} +1 -1
  110. package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-CVCvLPcC.js → pieDiagram-4H26LBE5-DfR96FUO.js} +1 -1
  111. package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-Cx6rrc_7.js → quadrantDiagram-W4KKPZXB-Dj6mqgQE.js} +1 -1
  112. package/workframe-ui/public/assets/{radar-GUYGQ44K-DJ5vN_cg.js → radar-GUYGQ44K-DKT2q98y.js} +1 -1
  113. package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-Cbry6b5c.js → requirementDiagram-4Y6WPE33-DYDnBVw9.js} +1 -1
  114. package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-CICUr5En.js → sankeyDiagram-5OEKKPKP-BSkxpjjO.js} +1 -1
  115. package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-BsOuB3F-.js → sequenceDiagram-3UESZ5HK-Dz6SNH9S.js} +1 -1
  116. package/workframe-ui/public/assets/{src-TS8jZspq.js → src-DC521nE_.js} +1 -1
  117. package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-avBzMQy_.js → stateDiagram-AJRCARHV-D4ZoF5NP.js} +1 -1
  118. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-DknveEiR.js +1 -0
  119. package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-DHLm6UFN.js → timeline-definition-PNZ67QCA-d0ezg1Hu.js} +1 -1
  120. package/workframe-ui/public/assets/{treeView-BLDUP644-B3Qd5t3j.js → treeView-BLDUP644-kXyaqwIy.js} +1 -1
  121. package/workframe-ui/public/assets/{treemap-LRROVOQU-D1dMQS7x.js → treemap-LRROVOQU-g168p7-O.js} +1 -1
  122. package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-DZ6SoniY.js → vennDiagram-CIIHVFJN-C0_QsmRl.js} +1 -1
  123. package/workframe-ui/public/assets/{wardley-L42UT6IY-9gK17Tp3.js → wardley-L42UT6IY-BNFCdzxQ.js} +1 -1
  124. package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-FCU_wmRE.js → wardleyDiagram-YWT4CUSO-BOwUS9sY.js} +1 -1
  125. package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-DWowvseN.js → xychartDiagram-2RQKCTM6-Cxr1ZiKh.js} +1 -1
  126. package/workframe-ui/public/index.html +2 -2
  127. package/workframe-ui/public/workframe-build.json +3 -3
  128. package/workframe-ui/public/assets/architecture-7EHR7CIX-j-DiBdGu.js +0 -1
  129. package/workframe-ui/public/assets/channel-CJ9pFXbx.js +0 -1
  130. package/workframe-ui/public/assets/chunk-QZHKN3VN-Bxk3enxo.js +0 -1
  131. package/workframe-ui/public/assets/chunk-WU5MYG2G-DdbkX1rg.js +0 -1
  132. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-bwCRiiUl.js +0 -1
  133. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-bwCRiiUl.js +0 -1
  134. package/workframe-ui/public/assets/eventmodeling-FCH6USID-g-Xo_eS4.js +0 -1
  135. package/workframe-ui/public/assets/index-1euVfd3b.js +0 -132
  136. package/workframe-ui/public/assets/index-BLVAsAeO.css +0 -1
  137. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-BFRA5TYD.js +0 -1
@@ -8,7 +8,6 @@ from typing import Any
8
8
  import action_proxy
9
9
  import internal_proxy_auth
10
10
  import llm_proxy
11
- import openrouter_catalog
12
11
  import platform_auth
13
12
  import run_surface_wiring
14
13
 
@@ -125,7 +124,8 @@ class ProviderRoutesMixin:
125
124
  self._json(401, {"ok": False, "error": "no_session"})
126
125
  return
127
126
  workspace_id = qs.get("workspace_id", [""])[0]
128
- self._json(200, srv.list_user_providers(user_id, workspace_id))
127
+ credential_scope = qs.get("credential_scope", ["effective"])[0]
128
+ self._json(200, srv.list_user_providers(user_id, workspace_id, credential_scope))
129
129
 
130
130
  def _route_post_me_credentials(self, body: dict) -> None:
131
131
  srv = _srv()
@@ -191,19 +191,18 @@ class ProviderRoutesMixin:
191
191
  self._json(500, {"ok": False, "error": f"db_error: {exc}"})
192
192
  return
193
193
  srv._invalidate_user_llm_picker_cache(user_id)
194
+ srv._revoke_runtime_llm_leases(payer_user_id=user_id, provider=provider)
194
195
  self._log_audit("credential_stored", "credential_binding", cred_id, f"provider={provider}")
195
- health: dict[str, Any] = {}
196
- if provider == "openrouter":
197
- secret_probe = srv._credential_secret(
198
- {
199
- "credential_ref": cred_ref,
200
- "scope": "user",
201
- "user_id": user_id,
202
- },
203
- user_id,
204
- )
205
- if secret_probe:
206
- health = openrouter_catalog.probe_account(secret_probe)
196
+ # Credential persistence must not depend on an upstream network round-trip.
197
+ # OpenRouter's account endpoints can each consume their full timeout when a
198
+ # self-hosted install has restricted egress, leaving the settings UI stuck
199
+ # in "Saving…" even though the encrypted credential is already committed.
200
+ # Model/catalog requests provide the first real provider health signal.
201
+ health: dict[str, Any] = {
202
+ "ok": True,
203
+ "provider": provider,
204
+ "status": "stored",
205
+ }
207
206
  try:
208
207
  srv._bootstrap_model_after_llm_connect(user_id, str(body.get("workspace_id") or ""), provider)
209
208
  except (OSError, RuntimeError, ValueError) as exc:
@@ -15,6 +15,7 @@ import uuid
15
15
  import activity_feed
16
16
  import api_errors
17
17
  import chat_bind
18
+ import message_reactions
18
19
  from email_sender import APP_BASE_URL, send_branded_invite_email
19
20
 
20
21
  list_room_sessions = chat_bind.list_room_sessions
@@ -31,6 +32,56 @@ def _srv():
31
32
 
32
33
  class WorkspaceRoutesMixin:
33
34
 
35
+ def _route_get_message_reactions(self, qs: dict[str, list[str]]) -> None:
36
+ srv = _srv()
37
+ user_id = str(getattr(self, "auth_user", "") or "")
38
+ if not user_id:
39
+ self._json(401, {"ok": False, "error": "no_session"})
40
+ return
41
+ conn = srv._workframe_db()
42
+ try:
43
+ payload = message_reactions.list_reactions(
44
+ conn,
45
+ qs.get("scope", [""])[0],
46
+ user_id,
47
+ srv._user_can_access_room,
48
+ )
49
+ except PermissionError:
50
+ self._json(403, {"ok": False, "error": "forbidden"})
51
+ return
52
+ except ValueError as exc:
53
+ self._json(400, {"ok": False, "error": str(exc)})
54
+ return
55
+ finally:
56
+ conn.close()
57
+ self._json(200, payload)
58
+
59
+ def _route_post_message_reactions_toggle(self, body: dict) -> None:
60
+ srv = _srv()
61
+ user_id = str(getattr(self, "auth_user", "") or "")
62
+ if not user_id:
63
+ self._json(401, {"ok": False, "error": "no_session"})
64
+ return
65
+ conn = srv._workframe_db()
66
+ try:
67
+ payload = message_reactions.toggle_reaction(
68
+ conn,
69
+ str(body.get("scope") or ""),
70
+ str(body.get("message_id") or ""),
71
+ str(body.get("emoji") or ""),
72
+ user_id,
73
+ srv._user_can_access_room,
74
+ )
75
+ except PermissionError:
76
+ self._json(403, {"ok": False, "error": "forbidden"})
77
+ return
78
+ except ValueError as exc:
79
+ self._json(400, {"ok": False, "error": str(exc)})
80
+ return
81
+ finally:
82
+ conn.close()
83
+ self._json(200, payload)
84
+
34
85
  def _route_get_board(self, qs: dict[str, list[str]]) -> None:
35
86
  srv = _srv()
36
87
  self._json(200, srv.board_list())
@@ -452,8 +503,9 @@ class WorkspaceRoutesMixin:
452
503
  return
453
504
  sender_agent = str(body.get("sender_agent_id", "")).strip()
454
505
  content = str(body.get("content", "")).strip()
455
- content_type = str(body.get("content_type", "text")).strip()
456
- parent_id = str(body.get("parent_message_id", "") or None)
506
+ content_type = str(body.get("content_type") or "text").strip()
507
+ parent_value = body.get("parent_message_id")
508
+ parent_id = str(parent_value).strip() if parent_value else None
457
509
  if not content:
458
510
  self._json(400, {"error": "content required"})
459
511
  return
@@ -875,6 +927,7 @@ class WorkspaceRoutesMixin:
875
927
  self._json(400, {"error": str(exc)})
876
928
  return
877
929
  cred_id = str(payload["credential_id"])
930
+ srv._revoke_runtime_llm_leases(workspace_id=ws_id, provider=provider)
878
931
  self._log_audit("credential_stored", "credential_binding", cred_id, f"provider={provider}")
879
932
  try:
880
933
  srv._bootstrap_model_after_llm_connect("", ws_id, provider)
@@ -923,6 +976,11 @@ class WorkspaceRoutesMixin:
923
976
  if not affected:
924
977
  self._json(404, {"error": "credential_not_found"})
925
978
  return
979
+ srv._revoke_runtime_llm_leases(
980
+ workspace_id=ws_id,
981
+ provider=provider,
982
+ credential_binding_id=binding_id,
983
+ )
926
984
  self._log_audit("credential_revoked", "credential_binding", binding_id, f"workspace={ws_id}")
927
985
  spec = srv._catalog_provider(provider)
928
986
  if spec and str(spec.get("category") or "") == "messaging":
@@ -10,6 +10,7 @@ import shutil
10
10
  import sqlite3
11
11
  import time
12
12
  import uuid
13
+ from datetime import datetime, timezone
13
14
  from pathlib import Path
14
15
  from typing import Any
15
16
 
@@ -223,12 +224,19 @@ def _scrub_orphan_top_level_yaml_lines(profile: str) -> None:
223
224
  return
224
225
  out: list[str] = []
225
226
  changed = False
227
+ root_list_key = ""
226
228
  for line in lines:
227
229
  stripped = line.lstrip()
228
230
  indent = len(line) - len(stripped)
229
231
  if indent == 0 and stripped.startswith("- "):
232
+ if root_list_key == "fallback_providers":
233
+ out.append(line)
234
+ continue
230
235
  changed = True
231
236
  continue
237
+ if indent == 0 and stripped and not stripped.startswith("#") and ":" in stripped:
238
+ key, _, value = stripped.partition(":")
239
+ root_list_key = key.strip() if not value.strip() else ""
232
240
  out.append(line)
233
241
  if changed:
234
242
  try:
@@ -724,6 +732,14 @@ def _agent_label(profile: str) -> str:
724
732
 
725
733
 
726
734
  def _native_display_name() -> str:
735
+ slug = str(_srv().NATIVE_PROFILE or "").strip()
736
+ if slug:
737
+ db_name = _agent_db_display_name(slug)
738
+ if db_name:
739
+ return db_name
740
+ reg_name = str(_srv()._agent_registry_row(slug).get("display_name") or "").strip()
741
+ if reg_name:
742
+ return reg_name
727
743
  return f"{_srv().PROJECT_NAME} Agent"
728
744
 
729
745
 
@@ -1122,6 +1138,9 @@ def profile_create(
1122
1138
  if lane.get("room"):
1123
1139
  results["room"] = lane["room"]
1124
1140
  results["steps"].extend(lane.get("steps") or [])
1141
+ if lane.get("ok") is False:
1142
+ results["ok"] = False
1143
+ results["error"] = str(lane.get("error") or "bootstrap_dm_lane_failed")
1125
1144
  except Exception as exc:
1126
1145
  results["steps"].append({"step": "bootstrap_dm_lane", "ok": False, "error": str(exc)})
1127
1146
  results["ok"] = False
@@ -1159,15 +1178,18 @@ def profile_create(
1159
1178
  if _install_child_base_artifacts(prof, display_name=child_label, role=child_role):
1160
1179
  results["steps"].append({"step": "install_child_base", "ok": True})
1161
1180
 
1162
- # 2. Start the gateway for this profile
1163
- try:
1164
- ok, out, port = _srv()._configure_profile_api(prof)
1165
- results["steps"].append({"step": "configure_api", "ok": ok, "api_port": port, "output": out.strip()})
1166
- if ok:
1167
- ok2, out2 = _srv()._patch_profile_gateway_run_script(prof)
1168
- results["steps"].append({"step": "patch_run_script", "ok": ok2, "output": out2.strip()})
1169
- except Exception as exc:
1170
- results["steps"].append({"step": "configure_api", "ok": False, "error": str(exc)})
1181
+ # 2. A per-user DM runtime owns its gateway. The child template is identity/config
1182
+ # authority only, so starting a second template gateway is both redundant and a
1183
+ # secure-mode false failure. Standalone profile creation still provisions one.
1184
+ if not bootstrap_dm:
1185
+ try:
1186
+ ok, out, port = _srv()._configure_profile_api(prof)
1187
+ results["steps"].append({"step": "configure_api", "ok": ok, "api_port": port, "output": out.strip()})
1188
+ if ok:
1189
+ ok2, out2 = _srv()._patch_profile_gateway_run_script(prof)
1190
+ results["steps"].append({"step": "patch_run_script", "ok": ok2, "output": out2.strip()})
1191
+ except Exception as exc:
1192
+ results["steps"].append({"step": "configure_api", "ok": False, "error": str(exc)})
1171
1193
 
1172
1194
  # 3–4. Template model/gateway only when not bootstrapping a per-user DM runtime (C1).
1173
1195
  if not bootstrap_dm:
@@ -115,8 +115,14 @@ def _build_upstream_request(
115
115
  "authorization",
116
116
  "x-api-key",
117
117
  "x-goog-api-key",
118
+ "accept-encoding",
118
119
  }
119
120
  }
121
+ # urllib does not transparently decode compressed upstream responses. If
122
+ # the caller's Accept-Encoding reaches OpenRouter, gzip bytes can be sent
123
+ # back as JSON after this proxy normalizes response headers. Prefer an
124
+ # identity response and still preserve Content-Encoding defensively below.
125
+ upstream_headers["Accept-Encoding"] = "identity"
120
126
  upstream_headers.update(upstream_auth_header(provider, secret))
121
127
  if provider == "openrouter":
122
128
  upstream_headers.setdefault("HTTP-Referer", "https://workfra.me")
@@ -150,10 +156,16 @@ def forward_request(
150
156
  with urllib.request.urlopen(req, timeout=600) as resp:
151
157
  resp_body = resp.read()
152
158
  out_headers = {"Content-Type": resp.headers.get("Content-Type", "application/octet-stream")}
159
+ content_encoding = resp.headers.get("Content-Encoding", "").strip()
160
+ if content_encoding:
161
+ out_headers["Content-Encoding"] = content_encoding
153
162
  return resp.status, out_headers, resp_body
154
163
  except urllib.error.HTTPError as exc:
155
164
  raw = exc.read()
156
165
  out_headers = {"Content-Type": exc.headers.get("Content-Type", "application/json")}
166
+ content_encoding = exc.headers.get("Content-Encoding", "").strip()
167
+ if content_encoding:
168
+ out_headers["Content-Encoding"] = content_encoding
157
169
  return exc.code, out_headers, raw
158
170
 
159
171
 
@@ -190,6 +202,9 @@ def stream_request_to_handler(
190
202
  content_type = resp.headers.get("Content-Type", "application/octet-stream")
191
203
  handler.send_response(resp.status)
192
204
  handler.send_header("Content-Type", content_type)
205
+ content_encoding = resp.headers.get("Content-Encoding", "").strip()
206
+ if content_encoding:
207
+ handler.send_header("Content-Encoding", content_encoding)
193
208
  handler.end_headers()
194
209
  if "text/event-stream" in content_type.lower():
195
210
  while True:
@@ -209,6 +224,9 @@ def stream_request_to_handler(
209
224
  raw = exc.read()
210
225
  handler.send_response(exc.code)
211
226
  handler.send_header("Content-Type", exc.headers.get("Content-Type", "application/json"))
227
+ content_encoding = exc.headers.get("Content-Encoding", "").strip()
228
+ if content_encoding:
229
+ handler.send_header("Content-Encoding", content_encoding)
212
230
  handler.end_headers()
213
231
  handler.wfile.write(raw)
214
232
  except urllib.error.URLError as exc:
@@ -77,22 +77,31 @@ def _invoke_room_agent_mention(
77
77
  # session. The triggering user's vault credential is overlaid per turn
78
78
  # below; do not switch the room onto that user's private runtime profile.
79
79
  hermes_slug = template_slug
80
- display_model = str(agent_row.get("model_name") or "").strip()
81
- display_provider = str(agent_row.get("model_provider") or "").strip()
82
- if not display_model:
83
- display_model = str(_srv()._read_model_block(hermes_slug).get("default") or "").strip()
84
- if not display_provider:
85
- display_provider = _srv()._llm_billing_provider(
86
- hermes_slug,
87
- user_id=triggered_by_user_id,
88
- workspace_id=workspace_id,
89
- )
80
+ # The Hermes agent template is the provider/model source of truth. The DB
81
+ # columns are a UI index and may lag a just-completed save, so never let
82
+ # them choose execution routing for a room mention.
83
+ model_block = _srv()._read_model_block(hermes_slug)
84
+ display_model = str(model_block.get("default") or agent_row.get("model_name") or "").strip()
85
+ display_provider = _srv()._llm_billing_provider(
86
+ hermes_slug,
87
+ user_id=triggered_by_user_id,
88
+ workspace_id=workspace_id,
89
+ block=model_block,
90
+ )
90
91
 
91
92
  turn_id = str(uuid.uuid4())
92
93
  segments: list[dict[str, Any]] = []
93
94
  last_flush = 0.0
94
95
  flush_interval = 0.05
95
96
  run_id = str(uuid.uuid4())
97
+ turn_metadata = json.dumps(
98
+ {
99
+ "run_id": run_id,
100
+ "model": display_model,
101
+ "llm_provider": display_provider,
102
+ },
103
+ separators=(",", ":"),
104
+ )
96
105
  session_id = ""
97
106
  user_text = ""
98
107
  provider = ""
@@ -152,8 +161,8 @@ def _invoke_room_agent_mention(
152
161
  """
153
162
  INSERT INTO messages (
154
163
  id, room_id, sender_user_id, sender_agent_id, parent_message_id,
155
- content, content_type, is_edited, created_at, updated_at
156
- ) VALUES (?,?,?,?,?,?,?,?,?,?)
164
+ content, content_type, metadata, is_edited, created_at, updated_at
165
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?)
157
166
  """,
158
167
  (
159
168
  mid,
@@ -163,6 +172,7 @@ def _invoke_room_agent_mention(
163
172
  parent_message_id,
164
173
  error,
165
174
  "text",
175
+ turn_metadata,
166
176
  0,
167
177
  now_ts,
168
178
  now_ts,
@@ -241,13 +251,7 @@ def _invoke_room_agent_mention(
241
251
  raise ValueError("session_bootstrap_failed: could not start agent session for this room")
242
252
 
243
253
  turn_body = profile_gateway._profile_turn_payload(hermes_slug, user_text, room_id)
244
- provider = str(agent_row.get("model_provider") or "").strip()
245
- if not provider:
246
- provider = _srv()._llm_billing_provider(
247
- hermes_slug,
248
- user_id=triggered_by_user_id,
249
- workspace_id=workspace_id,
250
- )
254
+ provider = display_provider
251
255
  if triggered_by_user_id:
252
256
  run_ledger.ensure_schema()
253
257
  auth_req = run_authority.mention_run_request(
@@ -396,8 +400,8 @@ def _invoke_room_agent_mention(
396
400
  """
397
401
  INSERT INTO messages (
398
402
  id, room_id, sender_user_id, sender_agent_id, parent_message_id,
399
- content, content_type, is_edited, created_at, updated_at
400
- ) VALUES (?,?,?,?,?,?,?,?,?,?)
403
+ content, content_type, metadata, is_edited, created_at, updated_at
404
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?)
401
405
  """,
402
406
  (
403
407
  mid,
@@ -407,6 +411,7 @@ def _invoke_room_agent_mention(
407
411
  parent_message_id,
408
412
  assistant,
409
413
  "text",
414
+ turn_metadata,
410
415
  0,
411
416
  now_ts,
412
417
  now_ts,
@@ -427,7 +432,7 @@ def _invoke_room_agent_mention(
427
432
  session_id,
428
433
  "completed",
429
434
  provider,
430
- str(agent_row.get("model_name") or "default"),
435
+ display_model or "default",
431
436
  error=None,
432
437
  )
433
438
  lane_bindings.chat_dispatch(
@@ -459,8 +464,8 @@ def _invoke_room_agent_mention(
459
464
  triggered_by_user_id,
460
465
  session_id,
461
466
  "failed",
462
- str(agent_row.get("model_provider") or "openrouter"),
463
- str(agent_row.get("model_name") or "default"),
467
+ display_provider or "openrouter",
468
+ display_model or "default",
464
469
  error=err_text,
465
470
  )
466
471
  except Exception: # noqa: BLE001
@@ -0,0 +1,139 @@
1
+ """Authenticated message reactions shared by room and agent-DM chat surfaces."""
2
+ from __future__ import annotations
3
+
4
+ import sqlite3
5
+ import time
6
+ from typing import Any, Callable
7
+
8
+
9
+ RoomAccessCheck = Callable[[sqlite3.Connection, str, str], bool]
10
+
11
+
12
+ def _resolved_scope(
13
+ conn: sqlite3.Connection,
14
+ scope: str,
15
+ user_id: str,
16
+ can_access_room: RoomAccessCheck,
17
+ ) -> tuple[str, str | None]:
18
+ raw = str(scope or "").strip()
19
+ if not raw or len(raw) > 512:
20
+ raise ValueError("invalid_scope")
21
+ if raw.startswith("room:"):
22
+ room_id = raw[5:].strip()
23
+ if not room_id or not can_access_room(conn, room_id, user_id):
24
+ raise PermissionError("forbidden")
25
+ return f"room:{room_id}", room_id
26
+ if raw.startswith("dm:"):
27
+ dm_key = raw[3:].strip()
28
+ if not dm_key:
29
+ raise ValueError("invalid_scope")
30
+ # DM session identifiers are external Hermes ids. Namespacing them by
31
+ # the authenticated user prevents another account from reading or
32
+ # mutating a user's private interaction metadata.
33
+ return f"dm:{user_id}:{dm_key}", None
34
+ raise ValueError("invalid_scope")
35
+
36
+
37
+ def _reaction_rows(conn: sqlite3.Connection, scope_key: str, user_id: str) -> list[dict[str, Any]]:
38
+ rows = conn.execute(
39
+ """
40
+ SELECT mr.message_id, mr.emoji, mr.user_id, mr.created_at,
41
+ u.display_name, u.avatar_url
42
+ FROM message_reactions AS mr
43
+ LEFT JOIN users AS u ON u.id = mr.user_id
44
+ WHERE mr.scope_key = ?
45
+ ORDER BY mr.created_at, mr.emoji, mr.user_id
46
+ """,
47
+ (scope_key,),
48
+ ).fetchall()
49
+ grouped: dict[tuple[str, str], dict[str, Any]] = {}
50
+ for row in rows:
51
+ message_id = str(row["message_id"])
52
+ emoji = str(row["emoji"])
53
+ key = (message_id, emoji)
54
+ reaction = grouped.setdefault(
55
+ key,
56
+ {
57
+ "message_id": message_id,
58
+ "emoji": emoji,
59
+ "count": 0,
60
+ "reacted": False,
61
+ "reactors": [],
62
+ },
63
+ )
64
+ reactor_user_id = str(row["user_id"])
65
+ reaction["count"] += 1
66
+ reaction["reacted"] = reaction["reacted"] or reactor_user_id == user_id
67
+ reaction["reactors"].append(
68
+ {
69
+ "user_id": reactor_user_id,
70
+ "display_name": str(row["display_name"] or "Member"),
71
+ "avatar_url": row["avatar_url"],
72
+ }
73
+ )
74
+ return list(grouped.values())
75
+
76
+
77
+ def list_reactions(
78
+ conn: sqlite3.Connection,
79
+ scope: str,
80
+ user_id: str,
81
+ can_access_room: RoomAccessCheck,
82
+ ) -> dict[str, Any]:
83
+ scope_key, _ = _resolved_scope(conn, scope, user_id, can_access_room)
84
+ return {"ok": True, "reactions": _reaction_rows(conn, scope_key, user_id)}
85
+
86
+
87
+ def toggle_reaction(
88
+ conn: sqlite3.Connection,
89
+ scope: str,
90
+ message_id: str,
91
+ emoji: str,
92
+ user_id: str,
93
+ can_access_room: RoomAccessCheck,
94
+ ) -> dict[str, Any]:
95
+ clean_message_id = str(message_id or "").strip()
96
+ clean_emoji = str(emoji or "").strip()
97
+ if not clean_message_id or len(clean_message_id) > 512:
98
+ raise ValueError("invalid_message_id")
99
+ if not clean_emoji or len(clean_emoji) > 24:
100
+ raise ValueError("invalid_emoji")
101
+
102
+ scope_key, room_id = _resolved_scope(conn, scope, user_id, can_access_room)
103
+ if room_id:
104
+ exists = conn.execute(
105
+ "SELECT 1 FROM messages WHERE id = ? AND room_id = ? AND deleted_at IS NULL",
106
+ (clean_message_id, room_id),
107
+ ).fetchone()
108
+ if not exists:
109
+ raise ValueError("message_not_found")
110
+
111
+ existing = conn.execute(
112
+ """
113
+ SELECT 1 FROM message_reactions
114
+ WHERE scope_key = ? AND message_id = ? AND user_id = ? AND emoji = ?
115
+ """,
116
+ (scope_key, clean_message_id, user_id, clean_emoji),
117
+ ).fetchone()
118
+ if existing:
119
+ conn.execute(
120
+ """
121
+ DELETE FROM message_reactions
122
+ WHERE scope_key = ? AND message_id = ? AND user_id = ? AND emoji = ?
123
+ """,
124
+ (scope_key, clean_message_id, user_id, clean_emoji),
125
+ )
126
+ reacted = False
127
+ else:
128
+ conn.execute(
129
+ """
130
+ INSERT INTO message_reactions (scope_key, message_id, user_id, emoji, created_at)
131
+ VALUES (?, ?, ?, ?, ?)
132
+ """,
133
+ (scope_key, clean_message_id, user_id, clean_emoji, str(int(time.time()))),
134
+ )
135
+ reacted = True
136
+ conn.commit()
137
+
138
+ rows = [row for row in _reaction_rows(conn, scope_key, user_id) if row["message_id"] == clean_message_id]
139
+ return {"ok": True, "reacted": reacted, "message_id": clean_message_id, "reactions": rows}