create-workframe 0.1.14 → 0.1.15

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 (135) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/scripts/sync-canonical-to-package.mjs +3 -0
  4. package/workframe-api/activity_feed.py +53 -5
  5. package/workframe-api/api_errors.py +126 -0
  6. package/workframe-api/chat_bind.py +8 -1
  7. package/workframe-api/chat_stream.py +70 -26
  8. package/workframe-api/crew_registry.py +3 -6
  9. package/workframe-api/google_auth.py +20 -12
  10. package/workframe-api/handler_modules/handler_auth.py +66 -21
  11. package/workframe-api/handler_modules/handler_chat.py +25 -0
  12. package/workframe-api/handler_modules/handler_install.py +45 -0
  13. package/workframe-api/handler_modules/handler_provider.py +1 -0
  14. package/workframe-api/handler_modules/handler_workspace.py +30 -16
  15. package/workframe-api/hermes_admin.py +9 -2
  16. package/workframe-api/hermes_profiles.py +25 -5
  17. package/workframe-api/install_api.py +38 -4
  18. package/workframe-api/lane_bindings.py +24 -0
  19. package/workframe-api/llm_error_glossary.py +58 -4
  20. package/workframe-api/mention_invoke.py +89 -27
  21. package/workframe-api/model_surface.py +1 -1
  22. package/workframe-api/provider_bindings.py +44 -2
  23. package/workframe-api/provider_bootstrap.py +9 -0
  24. package/workframe-api/provider_catalog.py +3 -3
  25. package/workframe-api/rooms.py +3 -0
  26. package/workframe-api/route_registry.py +1 -0
  27. package/workframe-api/run_ledger.py +52 -8
  28. package/workframe-api/runtime_cohort.py +11 -1
  29. package/workframe-api/server.py +25 -1
  30. package/workframe-api/stack_config.py +4 -1
  31. package/workframe-api/test_api_errors.py +33 -0
  32. package/workframe-api/test_enrich_room_messages.py +72 -0
  33. package/workframe-api/test_exception_hygiene.py +4 -2
  34. package/workframe-api/test_hermes_admin.py +7 -0
  35. package/workframe-api/test_install_wizard_resume.py +39 -13
  36. package/workframe-api/test_model_surface_consistency.py +118 -100
  37. package/workframe-api/test_profile_model_yaml.py +4 -1
  38. package/workframe-api/test_provider_bindings.py +10 -0
  39. package/workframe-api/test_run_ledger.py +2 -1
  40. package/workframe-api/test_server_reexports.py +4 -0
  41. package/workframe-api/test_user_llm_prefs.py +99 -0
  42. package/workframe-api/time-overlay-split.py +58 -0
  43. package/workframe-api/time-ttft-quick.py +135 -0
  44. package/workframe-api/time-ttft.py +250 -0
  45. package/workframe-api/time-wf-stream.py +145 -0
  46. package/workframe-api/turn_overlay.py +96 -23
  47. package/workframe-api/updates.py +1 -2
  48. package/workframe-api/user_prefs.py +26 -5
  49. package/workframe-api/workspace_bootstrap.py +16 -10
  50. package/workframe-ui/public/assets/{arc-BT9iZUSd.js → arc-BARqpbNX.js} +1 -1
  51. package/workframe-ui/public/assets/architecture-7EHR7CIX-CYE89P6J.js +1 -0
  52. package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-CaEDd3np.js → architectureDiagram-3BPJPVTR-bbpW6hfw.js} +1 -1
  53. package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-DHJhMrIS.js → blockDiagram-GPEHLZMM-aG0oR9eA.js} +1 -1
  54. package/workframe-ui/public/assets/brave-B885JYnH.svg +11 -0
  55. package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-Cdyz9hIV.js → c4Diagram-AAUBKEIU-BDi4E5jK.js} +1 -1
  56. package/workframe-ui/public/assets/channel-B4ex0Z2H.js +1 -0
  57. package/workframe-ui/public/assets/{chunk-2J33WTMH-BbZx76LX.js → chunk-2J33WTMH-BBU5tRNe.js} +1 -1
  58. package/workframe-ui/public/assets/{chunk-3OPIFGDE-DcSxjTIP.js → chunk-3OPIFGDE-SKhe5YD8.js} +1 -1
  59. package/workframe-ui/public/assets/{chunk-4BX2VUAB-B7F-kTY1.js → chunk-4BX2VUAB-D4uKMCPc.js} +1 -1
  60. package/workframe-ui/public/assets/{chunk-55IACEB6-BwwEiRTK.js → chunk-55IACEB6-SnqJA0wG.js} +1 -1
  61. package/workframe-ui/public/assets/{chunk-5ZQYHXKU-DAi_NksX.js → chunk-5ZQYHXKU-ZyBWNsT2.js} +1 -1
  62. package/workframe-ui/public/assets/{chunk-727SXJPM-BIaF4XCN.js → chunk-727SXJPM-DbxwKsiK.js} +1 -1
  63. package/workframe-ui/public/assets/{chunk-AQP2D5EJ-DjYSk1dG.js → chunk-AQP2D5EJ-Bm2iWcL0.js} +1 -1
  64. package/workframe-ui/public/assets/{chunk-BSJP7CBP-_twnyfgV.js → chunk-BSJP7CBP-CGwyQh1d.js} +1 -1
  65. package/workframe-ui/public/assets/{chunk-CSCIHK7Q-CkNmdy5s.js → chunk-CSCIHK7Q-XWSO96VW.js} +1 -1
  66. package/workframe-ui/public/assets/{chunk-FMBD7UC4-CjLqd-gl.js → chunk-FMBD7UC4-D0OTpUqL.js} +1 -1
  67. package/workframe-ui/public/assets/{chunk-KSCS5N6A-_6oNqFjj.js → chunk-KSCS5N6A-CM75wJ6j.js} +1 -1
  68. package/workframe-ui/public/assets/{chunk-L5ZTLDWV-CeO9Am_D.js → chunk-L5ZTLDWV-BCgtwaGP.js} +1 -1
  69. package/workframe-ui/public/assets/{chunk-LZXEDZCA-sRM4YeIe.js → chunk-LZXEDZCA-CZBc86Td.js} +2 -2
  70. package/workframe-ui/public/assets/{chunk-ND2GUHAM-CH8tMb21.js → chunk-ND2GUHAM-DIq9-Qtg.js} +1 -1
  71. package/workframe-ui/public/assets/{chunk-NZK2D7GU-DAI1bsII.js → chunk-NZK2D7GU-BTOOFYOR.js} +1 -1
  72. package/workframe-ui/public/assets/{chunk-O5CBEL6O-B2e-5a2G.js → chunk-O5CBEL6O-CYriwTot.js} +1 -1
  73. package/workframe-ui/public/assets/chunk-QZHKN3VN-sPK7umZx.js +1 -0
  74. package/workframe-ui/public/assets/chunk-WU5MYG2G-DtLDSmiK.js +1 -0
  75. package/workframe-ui/public/assets/{chunk-XPW4576I-DM2-0q37.js → chunk-XPW4576I-6iEhdiBi.js} +1 -1
  76. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-DWLy8pDS.js +1 -0
  77. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-DWLy8pDS.js +1 -0
  78. package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-BnbbewLv.js → cose-bilkent-S5V4N54A-C716vjLB.js} +1 -1
  79. package/workframe-ui/public/assets/{dagre-BM42HDAG-C4zmB1Vt.js → dagre-BM42HDAG-Cs-MNVkM.js} +1 -1
  80. package/workframe-ui/public/assets/{diagram-2AECGRRQ-n-BJmsiC.js → diagram-2AECGRRQ-BqoyTWyW.js} +1 -1
  81. package/workframe-ui/public/assets/{diagram-5GNKFQAL-BzQGQnZi.js → diagram-5GNKFQAL-CDRTV5fY.js} +1 -1
  82. package/workframe-ui/public/assets/{diagram-KO2AKTUF-Cx_De-wS.js → diagram-KO2AKTUF-u9NjixEi.js} +1 -1
  83. package/workframe-ui/public/assets/{diagram-LMA3HP47-CIOI3Lok.js → diagram-LMA3HP47-rDd6T2os.js} +1 -1
  84. package/workframe-ui/public/assets/{diagram-OG6HWLK6-DkRuPLWY.js → diagram-OG6HWLK6-Bl4VX9RO.js} +1 -1
  85. package/workframe-ui/public/assets/{dist-XhCT1Q-V.js → dist-DSCcbFgV.js} +1 -1
  86. package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-C3GwhDU1.js → erDiagram-TEJ5UH35-C9Nv-5nl.js} +1 -1
  87. package/workframe-ui/public/assets/eventmodeling-FCH6USID-B7kChV1b.js +1 -0
  88. package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-CLWH8z2e.js → flowDiagram-I6XJVG4X-D8Xhbd7s.js} +1 -1
  89. package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-D4h66Vy3.js → ganttDiagram-6RSMTGT7-CMycBZqm.js} +1 -1
  90. package/workframe-ui/public/assets/gemini-Cq-0-rmr.svg +105 -0
  91. package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-IW5F-Q6c.js → gitGraph-WXDBUCRP-DnIro8tZ.js} +1 -1
  92. package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-BjNIbmRM.js → gitGraphDiagram-PVQCEYII-B74H9ISZ.js} +1 -1
  93. package/workframe-ui/public/assets/index-BLVAsAeO.css +1 -0
  94. package/workframe-ui/public/assets/index-s4167RRh.js +132 -0
  95. package/workframe-ui/public/assets/{info-J43DQDTF-CcJspM79.js → info-J43DQDTF-B6mVw1B6.js} +1 -1
  96. package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-DP4xR9jt.js → infoDiagram-5YYISTIA-Ddtfq_xy.js} +1 -1
  97. package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-D4cZb21V.js → ishikawaDiagram-YF4QCWOH-DBb6veAL.js} +1 -1
  98. package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-DsLuHkIJ.js → journeyDiagram-JHISSGLW-DMgdeS5h.js} +1 -1
  99. package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-Wu65nOsR.js → kanban-definition-UN3LZRKU-DiV_LoDN.js} +1 -1
  100. package/workframe-ui/public/assets/{line-ByBDzRpy.js → line-BR9uEes_.js} +1 -1
  101. package/workframe-ui/public/assets/{linear-DzXi2vnq.js → linear-BhjkJfy9.js} +1 -1
  102. package/workframe-ui/public/assets/{mermaid-parser.core-CaB-1Ckn.js → mermaid-parser.core-CEWtaJIq.js} +2 -2
  103. package/workframe-ui/public/assets/{mermaid.core-D7HTHC17.js → mermaid.core-B6xKLKp-.js} +3 -3
  104. package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-BfKsC9aj.js → mindmap-definition-RKZ34NQL-DypZm9OU.js} +1 -1
  105. package/workframe-ui/public/assets/{packet-YPE3B663-ClpU98TO.js → packet-YPE3B663-CCxb0WDS.js} +1 -1
  106. package/workframe-ui/public/assets/{pie-LRSECV5Y-KeOyY_-u.js → pie-LRSECV5Y-pTkr4g8B.js} +1 -1
  107. package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-CUpjnzbs.js → pieDiagram-4H26LBE5-BventgTb.js} +1 -1
  108. package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-CJdPTszF.js → quadrantDiagram-W4KKPZXB-BQnvFJYk.js} +1 -1
  109. package/workframe-ui/public/assets/{radar-GUYGQ44K-C9BpandZ.js → radar-GUYGQ44K-EX9Pzmli.js} +1 -1
  110. package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-CHDZPwry.js → requirementDiagram-4Y6WPE33-Bnw4xecM.js} +1 -1
  111. package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-CMJJh91s.js → sankeyDiagram-5OEKKPKP-spHh-EYp.js} +1 -1
  112. package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-BsV4GppP.js → sequenceDiagram-3UESZ5HK-CC97Qcbf.js} +1 -1
  113. package/workframe-ui/public/assets/{src-WHks82Up.js → src-DRcBNyeZ.js} +1 -1
  114. package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-eSX9P8z0.js → stateDiagram-AJRCARHV-C8r8rx81.js} +1 -1
  115. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CnedwxVT.js +1 -0
  116. package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-Dg1UkhGo.js → timeline-definition-PNZ67QCA-CA4JxR_w.js} +1 -1
  117. package/workframe-ui/public/assets/{treeView-BLDUP644-B7yGsBuj.js → treeView-BLDUP644-BuGSo6xP.js} +1 -1
  118. package/workframe-ui/public/assets/{treemap-LRROVOQU-j_G2oyQ_.js → treemap-LRROVOQU-JyJMArE9.js} +1 -1
  119. package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-BRrT0jqC.js → vennDiagram-CIIHVFJN-BZAUo7xL.js} +1 -1
  120. package/workframe-ui/public/assets/{wardley-L42UT6IY-DEdUqez2.js → wardley-L42UT6IY-CMgm8t9M.js} +1 -1
  121. package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-C-bBK9Bb.js → wardleyDiagram-YWT4CUSO-CqI1c2zN.js} +1 -1
  122. package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-DhH5xuR2.js → xychartDiagram-2RQKCTM6-DhTdH-jJ.js} +1 -1
  123. package/workframe-ui/public/index.html +2 -2
  124. package/workframe-ui/public/workframe-build.json +3 -3
  125. package/docs/security.md +0 -85
  126. package/workframe-ui/public/assets/architecture-7EHR7CIX-m1aPUQ_r.js +0 -1
  127. package/workframe-ui/public/assets/channel-3M69ekE5.js +0 -1
  128. package/workframe-ui/public/assets/chunk-QZHKN3VN-BMl9ed8P.js +0 -1
  129. package/workframe-ui/public/assets/chunk-WU5MYG2G-C08HH6BU.js +0 -1
  130. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BFcOE4Aq.js +0 -1
  131. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BFcOE4Aq.js +0 -1
  132. package/workframe-ui/public/assets/eventmodeling-FCH6USID-DIVBL6CF.js +0 -1
  133. package/workframe-ui/public/assets/index-DL2vJP4L.css +0 -1
  134. package/workframe-ui/public/assets/index-ElS_D59P.js +0 -130
  135. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-FzMK4PkM.js +0 -1
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  Published on npm as **create-workframe**.
4
4
 
5
5
  ```bash
6
- npx create-workframe@0.1.14 MyProject
6
+ npx create-workframe@0.1.15 MyProject
7
7
  ```
8
8
 
9
9
  Scaffolds an isolated Workframe + Hermes project on Windows, macOS, and Linux.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-workframe",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "description": "Scaffold a Workframe + Hermes workspace with guided onboarding",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,6 +22,9 @@ const SKIP_DIRS = new Set([
22
22
  '.pytest_cache',
23
23
  '.venv',
24
24
  'node_modules',
25
+ '.tmp',
26
+ '.tmp-test-data',
27
+ '.tmp-test-hermes',
25
28
  ]);
26
29
 
27
30
  const SKIP_FILES = new Set([
@@ -338,7 +338,7 @@ def _kanban_activity(
338
338
  assignee = str(r["assignee"] or "kanban")
339
339
  member = crew_lookup.get(assignee.lower()) or crew_lookup.get(_srv()._profile_slug(assignee).lower())
340
340
  agent = _resolve_agent(member, _srv()._profile_display_name(assignee) if assignee != "kanban" else "Kanban")
341
- desc = f"{r['title'] or 'task'} · {r['kind']}"
341
+ desc = f"{r['title'] or 'task'} · {r['kind']}"
342
342
  if len(desc) > 140:
343
343
  desc = desc[:137] + "..."
344
344
  st = str(r["status"] or r["kind"] or "event").lower()
@@ -638,9 +638,9 @@ def _room_session_activity_label(
638
638
  ) -> str:
639
639
  base = str(title or room_name or "Session").strip()
640
640
  if msg_count > 0:
641
- return f"{base} · {msg_count} message{'s' if msg_count != 1 else ''}"
641
+ return f"{base} · {msg_count} message{'s' if msg_count != 1 else ''}"
642
642
  if status == "active":
643
- return f"{base} · active"
643
+ return f"{base} · active"
644
644
  return base
645
645
 
646
646
 
@@ -689,7 +689,7 @@ def room_activity_data(
689
689
  ended = info.get("ended_at")
690
690
  row_status = str(row["status"] or "active")
691
691
  if row_status == "active" and not ended:
692
- status = "active"
692
+ status = "idle"
693
693
  elif ended:
694
694
  status = "completed"
695
695
  else:
@@ -721,7 +721,7 @@ def room_activity_data(
721
721
  merged.extend(_message_activity_for_sessions(prof, crew_lookup, sids, ACTIVITY_ROOM_LIMIT))
722
722
 
723
723
  try:
724
- merged.extend(run_ledger.list_run_events_for_room(room_id, limit=ACTIVITY_ROOM_LIMIT))
724
+ merged.extend(_run_ledger_activity(room_id, crew_lookup, ACTIVITY_ROOM_LIMIT))
725
725
  except Exception: # noqa: BLE001
726
726
  pass
727
727
 
@@ -729,6 +729,54 @@ def room_activity_data(
729
729
  return merged[:ACTIVITY_ROOM_LIMIT]
730
730
 
731
731
 
732
+ def _run_event_activity_status(event_type: str, run_status: str) -> str:
733
+ et = str(event_type or "").strip().lower()
734
+ if et == "run.completed":
735
+ return "completed"
736
+ if et in ("run.failed", "run.denied"):
737
+ return "failed"
738
+ rs = str(run_status or "").strip().lower()
739
+ if rs == "running":
740
+ return "active"
741
+ if rs == "completed":
742
+ return "completed"
743
+ if rs in ("failed", "denied", "cancelled"):
744
+ return "failed"
745
+ return "idle"
746
+
747
+
748
+ def _run_ledger_activity(
749
+ room_id: str,
750
+ crew_lookup: dict[str, dict[str, Any]],
751
+ limit: int,
752
+ ) -> list[dict[str, Any]]:
753
+ """User-facing run ledger rows — skip internal authorize noise, resolve agent names."""
754
+ rows = run_ledger.list_run_events_for_room(room_id, limit=limit)
755
+ out: list[dict[str, Any]] = []
756
+ for row in rows:
757
+ event_type = str(row.get("event_type") or "")
758
+ if event_type == "run.authorized":
759
+ continue
760
+ prof = str(row.get("profile") or "").strip()
761
+ member = crew_lookup.get(prof.lower()) or crew_lookup.get(_srv()._profile_slug(prof).lower())
762
+ agent = _resolve_agent(member, _srv()._profile_display_name(prof))
763
+ desc = str(row.get("task_description") or "").strip()
764
+ if not desc:
765
+ continue
766
+ out.append(
767
+ {
768
+ **row,
769
+ "agent_name": agent["agent_name"],
770
+ "key": agent["key"],
771
+ "profile": prof or agent["profile"],
772
+ "task_description": desc,
773
+ "status": _run_event_activity_status(event_type, str(row.get("run_status") or row.get("status") or "")),
774
+ "source": "run_ledger",
775
+ }
776
+ )
777
+ return out
778
+
779
+
732
780
  def _room_agent_dm_owner_user_id(conn: sqlite3.Connection, room_id: str) -> str | None:
733
781
  row = conn.execute(
734
782
  """
@@ -0,0 +1,126 @@
1
+ """Public API error codes and sanitization — never leak tracebacks to clients."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from typing import Any
6
+
7
+ _TRACE = re.compile(r"\n(?:Traceback| File )", re.MULTILINE)
8
+ _SNAKE_CODE = re.compile(r"^[a-z][a-z0-9_]{2,63}$")
9
+
10
+ PUBLIC: dict[str, dict[str, str]] = {
11
+ "request_failed": {
12
+ "message": "Something went wrong.",
13
+ "hint": "Try again in a moment. If it keeps happening, contact your admin.",
14
+ },
15
+ "runtime_profile_permission_denied": {
16
+ "message": "Workframe could not prepare your agent workspace on disk.",
17
+ "hint": "An admin should restart the stack or fix permissions on the Agents data volume.",
18
+ },
19
+ "runtime_profile_create_failed": {
20
+ "message": "Workframe could not create your personal agent profile.",
21
+ "hint": "Try again in a moment. If it persists, restart Workframe or contact your admin.",
22
+ },
23
+ "runtime_profile_api_config_failed": {
24
+ "message": "Workframe could not start the agent API for this profile.",
25
+ "hint": "Check that the Hermes gateway is running and try again.",
26
+ },
27
+ "runtime_profile_bootstrap_failed": {
28
+ "message": "Workframe could not finish setting up this agent profile.",
29
+ "hint": "Try again or pick another agent from the crew list.",
30
+ },
31
+ "session_bootstrap_failed": {
32
+ "message": "Workframe could not start this chat session.",
33
+ "hint": "Refresh the page or open another agent room.",
34
+ },
35
+ "room_not_found": {
36
+ "message": "That chat room was not found.",
37
+ "hint": "Refresh the room list or open another chat.",
38
+ },
39
+ "room_access_denied": {
40
+ "message": "You cannot access this room.",
41
+ "hint": "Ask a workspace admin for access.",
42
+ },
43
+ "no_session": {
44
+ "message": "You are not signed in.",
45
+ "hint": "Refresh the page and sign in again.",
46
+ },
47
+ "model_required": {
48
+ "message": "Choose a model for this agent.",
49
+ "hint": "Open LLM models, select a connected provider and model, then create the agent.",
50
+ },
51
+ }
52
+
53
+
54
+ def scrub_internal(text: str) -> str:
55
+ """Drop tracebacks and multiline internals — keep a single safe line for logs."""
56
+ cleaned = str(text or "").strip()
57
+ if not cleaned:
58
+ return ""
59
+ if _TRACE.search(cleaned):
60
+ cleaned = _TRACE.split(cleaned, maxsplit=1)[0].strip()
61
+ if "\n" in cleaned:
62
+ cleaned = cleaned.split("\n", 1)[0].strip()
63
+ return cleaned
64
+
65
+
66
+ def classify_error(text: str) -> str | None:
67
+ raw_full = str(text or "")
68
+ lowered_full = raw_full.lower()
69
+ if "permissionerror" in lowered_full or "permission denied" in lowered_full:
70
+ return "runtime_profile_permission_denied"
71
+ raw = scrub_internal(text)
72
+ if not raw:
73
+ return None
74
+ lowered = raw.lower()
75
+ if "runtime profile create failed" in lowered:
76
+ return "runtime_profile_create_failed"
77
+ if "runtime profile api config failed" in lowered:
78
+ return "runtime_profile_api_config_failed"
79
+ if "runtime profile bootstrap failed" in lowered:
80
+ return "runtime_profile_bootstrap_failed"
81
+ if "connection refused" in lowered_full or "errno 111" in lowered_full:
82
+ return "runtime_profile_api_config_failed"
83
+ if "profile api did not become healthy" in lowered or "profile api unavailable" in lowered:
84
+ return "runtime_profile_api_config_failed"
85
+ if "session creation failed" in lowered or "create session failed" in lowered:
86
+ return "session_bootstrap_failed"
87
+ if "room_not_found" in lowered or "room not found" in lowered:
88
+ return "room_not_found"
89
+ if "room_access_denied" in lowered or "access denied" in lowered:
90
+ return "room_access_denied"
91
+ if "no_session" in lowered:
92
+ return "no_session"
93
+ if _SNAKE_CODE.fullmatch(raw):
94
+ return raw
95
+ return None
96
+
97
+
98
+ def public_api_error_payload(exc: BaseException, *, context: str = "") -> dict[str, Any]:
99
+ """Map an exception to a client-safe {error, message, hint} payload."""
100
+ full = str(exc)
101
+ code = classify_error(full)
102
+ if code and code in PUBLIC:
103
+ row = PUBLIC[code]
104
+ return {"error": code, "message": row["message"], "hint": row.get("hint", "")}
105
+ if code and _SNAKE_CODE.fullmatch(code):
106
+ human = code.replace("_", " ").strip().capitalize() + "."
107
+ return {"error": code, "message": human, "hint": PUBLIC["request_failed"]["hint"]}
108
+ fallback = PUBLIC["request_failed"]
109
+ payload: dict[str, Any] = {
110
+ "error": "request_failed",
111
+ "message": fallback["message"],
112
+ "hint": fallback["hint"],
113
+ }
114
+ if context.strip():
115
+ payload["context"] = context.strip()
116
+ return payload
117
+
118
+
119
+ def http_status_for_code(code: str) -> int:
120
+ if code in ("no_session",):
121
+ return 401
122
+ if code in ("room_access_denied", "forbidden"):
123
+ return 403
124
+ if code in ("room_not_found",):
125
+ return 404
126
+ return 400
@@ -27,6 +27,7 @@ def profile_chat_bind(profile: str, payload: dict[str, Any], user_id: str = "")
27
27
  session = lane_bindings.profile_chat_session(profile, payload, user_id)
28
28
  sid = str(session.get("session_id") or "").strip()
29
29
  hermes_prof = str(session.get("profile") or _srv().resolve_validated_profile(profile))
30
+ workspace_id = str(session.get("workspace_id") or payload.get("workspace_id") or "").strip()
30
31
  template_prof = str(session.get("template_profile") or "").strip()
31
32
  if not template_prof:
32
33
  template_prof = (
@@ -34,7 +35,13 @@ def profile_chat_bind(profile: str, payload: dict[str, Any], user_id: str = "")
34
35
  if _srv()._is_runtime_profile_slug(hermes_prof)
35
36
  else _srv().resolve_validated_profile(profile)
36
37
  )
37
- workspace_id = str(session.get("workspace_id") or payload.get("workspace_id") or "").strip()
38
+ if user_id and workspace_id and session.get("llm_ready"):
39
+ llm_provider = _srv()._llm_billing_provider(
40
+ hermes_prof, user_id=user_id, workspace_id=workspace_id,
41
+ )
42
+ _srv()._schedule_profile_lease_yaml_reconcile(
43
+ hermes_prof, user_id, workspace_id, llm_provider,
44
+ )
38
45
  history = _srv().chat_messages(hermes_prof, sid)
39
46
  # ponytail: cohort manifest is lazy — GET /api/me/cohort; bind must stay fast
40
47
  cohort: list[dict[str, Any]] = []
@@ -205,6 +205,34 @@ def _iter_profile_stream_frames(upstream: Any) -> Iterator[tuple[str, dict[str,
205
205
  if parsed:
206
206
  yield parsed
207
207
 
208
+ def _stream_data_text(data_lines: list[str]) -> str:
209
+ raw = "\n".join(line.strip() for line in data_lines if line.strip())
210
+ if not raw:
211
+ return ""
212
+ try:
213
+ data = json.loads(raw)
214
+ except json.JSONDecodeError:
215
+ return raw
216
+ if not isinstance(data, dict):
217
+ return raw
218
+ err = data.get("error")
219
+ if isinstance(err, dict):
220
+ nested = str(err.get("message") or err.get("detail") or "").strip()
221
+ if nested:
222
+ return nested
223
+ for key in ("message", "error", "text", "detail", "summary"):
224
+ val = data.get(key)
225
+ if isinstance(val, str) and val.strip():
226
+ return val.strip()
227
+ return raw
228
+
229
+
230
+ def _concierge_notice_from_error(text: str, *, provider: str) -> dict[str, Any]:
231
+ entry = llm_error_glossary.classify_exception_text(str(text or "").strip())
232
+ entry = llm_error_glossary.tailor_provider_entry(entry, provider)
233
+ return concierge.respond("", last_error=entry)
234
+
235
+
208
236
  def _emit_stream_chat_error(
209
237
  handler: BaseHTTPRequestHandler,
210
238
  message: str = "",
@@ -276,18 +304,13 @@ def _emit_stream_error_body(
276
304
 
277
305
 
278
306
  def _emit_stream_concierge(handler: BaseHTTPRequestHandler, entry: dict[str, Any]) -> None:
279
- """Deterministic assistant reply when LLM path is unavailable."""
307
+ """Deterministic assistant reply when LLM path is unavailable (SSE headers already open)."""
280
308
  payload = llm_error_glossary.notice_payload(entry)
281
309
  text = str(payload.get("message") or "")
282
310
  hint = str(payload.get("hint") or "").strip()
283
311
  if hint:
284
312
  text = f"{text}\n\n{hint}"
285
313
  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
314
  handler.wfile.write(
292
315
  f"event: concierge\ndata: {json.dumps(payload)}\n\n".encode("utf-8"),
293
316
  )
@@ -373,6 +396,14 @@ def stream_profile_chat(handler: BaseHTTPRequestHandler, profile: str, payload:
373
396
  prof, user_id=_triggering_user, workspace_id=_workspace_id, block=model_block,
374
397
  )
375
398
  model_used = str(model_block.get("default") or "").strip()
399
+ api_port = _srv()._profile_api_port(prof)
400
+ _open_profile_stream(
401
+ handler,
402
+ prof,
403
+ model_used=model_used,
404
+ llm_provider=llm_provider,
405
+ api_port=api_port,
406
+ )
376
407
  _auth_decision: run_authority.RunAuthorityDecision | None = None
377
408
  if _triggering_user:
378
409
  run_ledger.ensure_schema()
@@ -427,14 +458,6 @@ def stream_profile_chat(handler: BaseHTTPRequestHandler, profile: str, payload:
427
458
  _log_llm_failure(handler, str(entry.get("code") or "no_llm_provider"), provider=llm_provider, profile=prof)
428
459
  _emit_stream_concierge(handler, entry)
429
460
  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
461
  try:
439
462
  _srv().ensure_profile_api(
440
463
  prof,
@@ -455,6 +478,7 @@ def stream_profile_chat(handler: BaseHTTPRequestHandler, profile: str, payload:
455
478
  _emit_stream_error_body(handler, entry=entry)
456
479
  return
457
480
  entry = llm_error_glossary.classify_exception_text(err_text)
481
+ entry = llm_error_glossary.tailor_provider_entry(entry, llm_provider)
458
482
  _log_llm_failure(handler, str(entry.get("code") or ""), provider=llm_provider, profile=prof)
459
483
  _emit_stream_error_body(handler, entry=entry)
460
484
  return
@@ -484,6 +508,7 @@ def stream_profile_chat(handler: BaseHTTPRequestHandler, profile: str, payload:
484
508
  req = urllib.request.Request(url, data=upstream_body, headers=headers, method="POST")
485
509
  saw_complete = False
486
510
  complete_text = ""
511
+ last_classified_error: dict[str, Any] | None = None
487
512
  try:
488
513
  upstream = urllib.request.urlopen(req, timeout=3600)
489
514
  except urllib.error.HTTPError as exc:
@@ -492,11 +517,16 @@ def stream_profile_chat(handler: BaseHTTPRequestHandler, profile: str, payload:
492
517
  data = json.loads(raw)
493
518
  except Exception: # noqa: BLE001
494
519
  data = {"error": raw or f"upstream stream failed: {exc.code}"}
495
- entry = llm_error_glossary.classify_exception_text(str(data))
520
+ if not isinstance(data, dict):
521
+ data = {"error": raw or f"upstream stream failed: {exc.code}"}
522
+ err_text = llm_error_glossary._extract_error_message(data, raw)
523
+ if not err_text:
524
+ err_text = raw or f"HTTP {exc.code}"
525
+ entry = _concierge_notice_from_error(err_text, provider=llm_provider)
496
526
  _emit_stream_error_body(handler, entry=entry)
497
527
  return
498
528
  except OSError as exc:
499
- entry = llm_error_glossary.classify_exception_text(str(exc))
529
+ entry = _concierge_notice_from_error(str(exc), provider=llm_provider)
500
530
  _emit_stream_error_body(handler, entry=entry)
501
531
  return
502
532
 
@@ -504,7 +534,7 @@ def stream_profile_chat(handler: BaseHTTPRequestHandler, profile: str, payload:
504
534
  buffer = b""
505
535
 
506
536
  def _rewrite_event_frame(frame: bytes) -> bytes:
507
- nonlocal saw_complete, complete_text
537
+ nonlocal saw_complete, complete_text, last_classified_error
508
538
  text = frame.decode("utf-8", errors="replace")
509
539
  lines = text.splitlines()
510
540
  event_name = ""
@@ -539,6 +569,13 @@ def stream_profile_chat(handler: BaseHTTPRequestHandler, profile: str, payload:
539
569
  elif event_name == "error":
540
570
  normalized = "error"
541
571
 
572
+ if normalized == "error" and data_lines:
573
+ err_text = _stream_data_text(data_lines)
574
+ if err_text:
575
+ notice = _concierge_notice_from_error(err_text, provider=llm_provider)
576
+ last_classified_error = notice
577
+ data_lines = [json.dumps(notice, separators=(",", ":"))]
578
+
542
579
  output_lines: list[str] = []
543
580
  if normalized:
544
581
  output_lines.append(f"event: {normalized}")
@@ -575,22 +612,29 @@ def stream_profile_chat(handler: BaseHTTPRequestHandler, profile: str, payload:
575
612
  handler.wfile.write(rewritten)
576
613
  handler.wfile.flush()
577
614
  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")
615
+ if last_classified_error:
616
+ entry = last_classified_error
584
617
  else:
585
- entry = llm_error_glossary.playbook_entry("provider_empty_reply")
618
+ config_key = _srv()._read_config_model_api_key(prof)
619
+ _, model_name = _srv()._read_model_from_config(prof)
620
+ if config_key.startswith(turn_credentials.LEASE_PREFIX) and not turn_credentials.validate_lease(
621
+ config_key,
622
+ ):
623
+ entry = llm_error_glossary.notice_payload(
624
+ llm_error_glossary.playbook_entry("invalid_lease"),
625
+ )
626
+ else:
627
+ entry = llm_error_glossary.notice_payload(
628
+ llm_error_glossary.playbook_entry("provider_empty_reply"),
629
+ )
586
630
  _log_llm_failure(
587
631
  handler,
588
632
  str(entry.get("code") or "provider_empty_reply"),
589
633
  provider=llm_provider,
590
- model=str(model_name or ""),
634
+ model=str(model_name if not last_classified_error else ""),
591
635
  profile=prof,
592
636
  )
593
- err_payload = json.dumps(llm_error_glossary.notice_payload(entry))
637
+ err_payload = json.dumps(entry)
594
638
  handler.wfile.write(f"event: error\ndata: {err_payload}\n\n".encode("utf-8"))
595
639
  handler.wfile.flush()
596
640
  handler.wfile.write(b"event: done\ndata: {}\n\n")
@@ -96,12 +96,9 @@ def _workspace_agent_identities(workspace_id: str | None = None) -> dict[str, di
96
96
  def _agent_identity_fields(profile: str, workspace_id: str | None = None, user_id: str = "") -> dict[str, Any]:
97
97
  slug = _srv().safe_profile_slug(str(profile or "").strip())
98
98
  template = _srv()._runtime_template_slug(slug) if _srv()._is_runtime_profile_slug(slug) else slug
99
- reg = _agent_registry_row(slug)
100
- if user_id and template:
101
- runtime = _srv()._runtime_profile_slug(user_id, template)
102
- reg_user = _agent_registry_row(runtime)
103
- if reg_user.get("display_name") or reg_user.get("tagline") or reg_user.get("avatar_url"):
104
- reg = {**reg, **{k: v for k, v in reg_user.items() if v}}
99
+ # Runtime profiles are credential/session proxies, not alternate agent
100
+ # identities. Shared template metadata is authoritative for all users.
101
+ reg = _agent_registry_row(template)
105
102
  ident = _workspace_agent_identities(workspace_id).get(template, {})
106
103
  reg_template = _agent_registry_row(template)
107
104
  avatar_url: Any = None
@@ -3,17 +3,16 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
- import os
7
6
  import secrets
8
7
  import urllib.parse
9
8
  import urllib.request
10
9
  from typing import Any
11
10
 
11
+ import oauth_pending
12
12
  import oidc_jwt
13
13
  import stack_config
14
-
15
- APP_BASE_URL = os.environ.get("APP_BASE_URL", "http://127.0.0.1:18644").rstrip("/")
16
- _PENDING: dict[str, dict[str, str]] = {}
14
+ from email_sender import APP_BASE_URL
15
+ GOOGLE_SIGNIN_PROVIDER = "google_signin"
17
16
 
18
17
 
19
18
  def _google_creds() -> tuple[str, str]:
@@ -28,10 +27,13 @@ def _google_creds() -> tuple[str, str]:
28
27
  def start_google_auth(invite_email: str = "", invite_token: str = "") -> dict[str, Any]:
29
28
  client_id, _ = _google_creds()
30
29
  state = secrets.token_urlsafe(24)
31
- _PENDING[state] = {
32
- "invite_email": invite_email.strip().lower(),
33
- "invite_token": invite_token.strip(),
34
- }
30
+ meta = json.dumps(
31
+ {
32
+ "invite_email": invite_email.strip().lower(),
33
+ "invite_token": invite_token.strip(),
34
+ },
35
+ )
36
+ oauth_pending.store_pending(state, "", GOOGLE_SIGNIN_PROVIDER, "", meta)
35
37
  redirect_uri = f"{APP_BASE_URL}/api/auth/google/callback"
36
38
  params = {
37
39
  "client_id": client_id,
@@ -49,9 +51,15 @@ def start_google_auth(invite_email: str = "", invite_token: str = "") -> dict[st
49
51
 
50
52
 
51
53
  def exchange_google_code(code: str, state: str) -> dict[str, Any]:
52
- pending = _PENDING.pop(state, None)
53
- if not pending:
54
+ pending = oauth_pending.take_pending(state)
55
+ if not pending or str(pending.get("provider") or "") != GOOGLE_SIGNIN_PROVIDER:
54
56
  raise ValueError("invalid or expired OAuth state")
57
+ try:
58
+ meta = json.loads(str(pending.get("workspace_id") or "{}"))
59
+ except json.JSONDecodeError:
60
+ meta = {}
61
+ if not isinstance(meta, dict):
62
+ meta = {}
55
63
  client_id, client_secret = _google_creds()
56
64
  redirect_uri = f"{APP_BASE_URL}/api/auth/google/callback"
57
65
  data = urllib.parse.urlencode(
@@ -80,11 +88,11 @@ def exchange_google_code(code: str, state: str) -> dict[str, Any]:
80
88
  raise ValueError("Google account has no email")
81
89
  if claims.get("email_verified") is False:
82
90
  raise ValueError("Google email is not verified")
83
- invite_email = pending.get("invite_email") or ""
91
+ invite_email = str(meta.get("invite_email") or "").strip().lower()
84
92
  if invite_email and email != invite_email:
85
93
  raise ValueError("Google email does not match the invited address")
86
94
  return {
87
95
  "email": email,
88
96
  "display_name": str(claims.get("name") or email),
89
- "invite_token": pending.get("invite_token") or "",
97
+ "invite_token": str(meta.get("invite_token") or ""),
90
98
  }