create-workframe 0.1.14 → 0.1.16

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 (136) hide show
  1. package/README.md +1 -1
  2. package/bin/create-workframe.js +4 -0
  3. package/package.json +1 -1
  4. package/scripts/sync-canonical-to-package.mjs +3 -0
  5. package/workframe-api/activity_feed.py +53 -5
  6. package/workframe-api/api_errors.py +126 -0
  7. package/workframe-api/chat_bind.py +8 -1
  8. package/workframe-api/chat_stream.py +70 -26
  9. package/workframe-api/crew_registry.py +3 -6
  10. package/workframe-api/google_auth.py +20 -12
  11. package/workframe-api/handler_modules/handler_auth.py +66 -21
  12. package/workframe-api/handler_modules/handler_chat.py +25 -0
  13. package/workframe-api/handler_modules/handler_install.py +45 -0
  14. package/workframe-api/handler_modules/handler_provider.py +1 -0
  15. package/workframe-api/handler_modules/handler_workspace.py +30 -16
  16. package/workframe-api/hermes_admin.py +9 -2
  17. package/workframe-api/hermes_profiles.py +25 -5
  18. package/workframe-api/install_api.py +38 -4
  19. package/workframe-api/lane_bindings.py +24 -0
  20. package/workframe-api/llm_error_glossary.py +58 -4
  21. package/workframe-api/mention_invoke.py +89 -27
  22. package/workframe-api/model_surface.py +1 -1
  23. package/workframe-api/provider_bindings.py +44 -2
  24. package/workframe-api/provider_bootstrap.py +9 -0
  25. package/workframe-api/provider_catalog.py +3 -3
  26. package/workframe-api/rooms.py +3 -0
  27. package/workframe-api/route_registry.py +1 -0
  28. package/workframe-api/run_ledger.py +52 -8
  29. package/workframe-api/runtime_cohort.py +11 -1
  30. package/workframe-api/server.py +25 -1
  31. package/workframe-api/stack_config.py +4 -1
  32. package/workframe-api/test_api_errors.py +33 -0
  33. package/workframe-api/test_enrich_room_messages.py +72 -0
  34. package/workframe-api/test_exception_hygiene.py +4 -2
  35. package/workframe-api/test_hermes_admin.py +7 -0
  36. package/workframe-api/test_install_wizard_resume.py +39 -13
  37. package/workframe-api/test_model_surface_consistency.py +118 -100
  38. package/workframe-api/test_profile_model_yaml.py +4 -1
  39. package/workframe-api/test_provider_bindings.py +10 -0
  40. package/workframe-api/test_run_ledger.py +2 -1
  41. package/workframe-api/test_server_reexports.py +4 -0
  42. package/workframe-api/test_user_llm_prefs.py +99 -0
  43. package/workframe-api/time-overlay-split.py +58 -0
  44. package/workframe-api/time-ttft-quick.py +135 -0
  45. package/workframe-api/time-ttft.py +250 -0
  46. package/workframe-api/time-wf-stream.py +145 -0
  47. package/workframe-api/turn_overlay.py +96 -23
  48. package/workframe-api/updates.py +1 -2
  49. package/workframe-api/user_prefs.py +26 -5
  50. package/workframe-api/workspace_bootstrap.py +16 -10
  51. package/workframe-ui/public/assets/{arc-BT9iZUSd.js → arc-esSBm9C_.js} +1 -1
  52. package/workframe-ui/public/assets/architecture-7EHR7CIX-j-DiBdGu.js +1 -0
  53. package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-CaEDd3np.js → architectureDiagram-3BPJPVTR-DVVz9RGn.js} +1 -1
  54. package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-DHJhMrIS.js → blockDiagram-GPEHLZMM-B6i10GMX.js} +1 -1
  55. package/workframe-ui/public/assets/brave-B885JYnH.svg +11 -0
  56. package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-Cdyz9hIV.js → c4Diagram-AAUBKEIU-BqwJ7JoH.js} +1 -1
  57. package/workframe-ui/public/assets/channel-CJ9pFXbx.js +1 -0
  58. package/workframe-ui/public/assets/{chunk-2J33WTMH-BbZx76LX.js → chunk-2J33WTMH-CyhcedSa.js} +1 -1
  59. package/workframe-ui/public/assets/{chunk-3OPIFGDE-DcSxjTIP.js → chunk-3OPIFGDE-1Rr6WuvI.js} +1 -1
  60. package/workframe-ui/public/assets/{chunk-4BX2VUAB-B7F-kTY1.js → chunk-4BX2VUAB-D2bRrARV.js} +1 -1
  61. package/workframe-ui/public/assets/{chunk-55IACEB6-BwwEiRTK.js → chunk-55IACEB6-CKXeZxSr.js} +1 -1
  62. package/workframe-ui/public/assets/{chunk-5ZQYHXKU-DAi_NksX.js → chunk-5ZQYHXKU-DYQxxPZ4.js} +1 -1
  63. package/workframe-ui/public/assets/{chunk-727SXJPM-BIaF4XCN.js → chunk-727SXJPM-CGpIgqvD.js} +1 -1
  64. package/workframe-ui/public/assets/{chunk-AQP2D5EJ-DjYSk1dG.js → chunk-AQP2D5EJ-B88NGQ7d.js} +1 -1
  65. package/workframe-ui/public/assets/{chunk-BSJP7CBP-_twnyfgV.js → chunk-BSJP7CBP-CCymiI0O.js} +1 -1
  66. package/workframe-ui/public/assets/{chunk-CSCIHK7Q-CkNmdy5s.js → chunk-CSCIHK7Q-DRgey03j.js} +1 -1
  67. package/workframe-ui/public/assets/{chunk-FMBD7UC4-CjLqd-gl.js → chunk-FMBD7UC4-l1adn8gM.js} +1 -1
  68. package/workframe-ui/public/assets/{chunk-KSCS5N6A-_6oNqFjj.js → chunk-KSCS5N6A-CaEvDRPK.js} +1 -1
  69. package/workframe-ui/public/assets/{chunk-L5ZTLDWV-CeO9Am_D.js → chunk-L5ZTLDWV-BgCnvPTv.js} +1 -1
  70. package/workframe-ui/public/assets/{chunk-LZXEDZCA-sRM4YeIe.js → chunk-LZXEDZCA-t2zokHpn.js} +2 -2
  71. package/workframe-ui/public/assets/{chunk-ND2GUHAM-CH8tMb21.js → chunk-ND2GUHAM-C3c9p0sv.js} +1 -1
  72. package/workframe-ui/public/assets/{chunk-NZK2D7GU-DAI1bsII.js → chunk-NZK2D7GU-DSujF-R8.js} +1 -1
  73. package/workframe-ui/public/assets/{chunk-O5CBEL6O-B2e-5a2G.js → chunk-O5CBEL6O-DJJTbM6p.js} +1 -1
  74. package/workframe-ui/public/assets/chunk-QZHKN3VN-Bxk3enxo.js +1 -0
  75. package/workframe-ui/public/assets/chunk-WU5MYG2G-DdbkX1rg.js +1 -0
  76. package/workframe-ui/public/assets/{chunk-XPW4576I-DM2-0q37.js → chunk-XPW4576I-D2Cxn3AG.js} +1 -1
  77. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-bwCRiiUl.js +1 -0
  78. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-bwCRiiUl.js +1 -0
  79. package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-BnbbewLv.js → cose-bilkent-S5V4N54A---dQ-pFK.js} +1 -1
  80. package/workframe-ui/public/assets/{dagre-BM42HDAG-C4zmB1Vt.js → dagre-BM42HDAG-B_qe1ODY.js} +1 -1
  81. package/workframe-ui/public/assets/{diagram-2AECGRRQ-n-BJmsiC.js → diagram-2AECGRRQ-Bou8vEYl.js} +1 -1
  82. package/workframe-ui/public/assets/{diagram-5GNKFQAL-BzQGQnZi.js → diagram-5GNKFQAL-DEUEzONU.js} +1 -1
  83. package/workframe-ui/public/assets/{diagram-KO2AKTUF-Cx_De-wS.js → diagram-KO2AKTUF-Ctq571VA.js} +1 -1
  84. package/workframe-ui/public/assets/{diagram-LMA3HP47-CIOI3Lok.js → diagram-LMA3HP47-CnKn89lm.js} +1 -1
  85. package/workframe-ui/public/assets/{diagram-OG6HWLK6-DkRuPLWY.js → diagram-OG6HWLK6-DnKlrMx2.js} +1 -1
  86. package/workframe-ui/public/assets/{dist-XhCT1Q-V.js → dist-ubrEgQyZ.js} +1 -1
  87. package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-C3GwhDU1.js → erDiagram-TEJ5UH35-PjqO7CJd.js} +1 -1
  88. package/workframe-ui/public/assets/eventmodeling-FCH6USID-g-Xo_eS4.js +1 -0
  89. package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-CLWH8z2e.js → flowDiagram-I6XJVG4X-B2PN_nNG.js} +1 -1
  90. package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-D4h66Vy3.js → ganttDiagram-6RSMTGT7-ThyAtNQP.js} +1 -1
  91. package/workframe-ui/public/assets/gemini-Cq-0-rmr.svg +105 -0
  92. package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-IW5F-Q6c.js → gitGraph-WXDBUCRP-CB2UgoWy.js} +1 -1
  93. package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-BjNIbmRM.js → gitGraphDiagram-PVQCEYII-DRJJ-zT-.js} +1 -1
  94. package/workframe-ui/public/assets/index-1euVfd3b.js +132 -0
  95. package/workframe-ui/public/assets/index-BLVAsAeO.css +1 -0
  96. package/workframe-ui/public/assets/{info-J43DQDTF-CcJspM79.js → info-J43DQDTF-CLtR33Q_.js} +1 -1
  97. package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-DP4xR9jt.js → infoDiagram-5YYISTIA-Cmunqfvi.js} +1 -1
  98. package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-D4cZb21V.js → ishikawaDiagram-YF4QCWOH-DWE_84eL.js} +1 -1
  99. package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-DsLuHkIJ.js → journeyDiagram-JHISSGLW-BtBhpyRL.js} +1 -1
  100. package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-Wu65nOsR.js → kanban-definition-UN3LZRKU-B8QspDDL.js} +1 -1
  101. package/workframe-ui/public/assets/{line-ByBDzRpy.js → line-iOgDn4O8.js} +1 -1
  102. package/workframe-ui/public/assets/{linear-DzXi2vnq.js → linear-BBehZNVA.js} +1 -1
  103. package/workframe-ui/public/assets/{mermaid-parser.core-CaB-1Ckn.js → mermaid-parser.core-DQK5HcdU.js} +2 -2
  104. package/workframe-ui/public/assets/{mermaid.core-D7HTHC17.js → mermaid.core-Dsrdd1Wr.js} +3 -3
  105. package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-BfKsC9aj.js → mindmap-definition-RKZ34NQL-Ds_BfsJA.js} +1 -1
  106. package/workframe-ui/public/assets/{packet-YPE3B663-ClpU98TO.js → packet-YPE3B663-Bz3pi4i8.js} +1 -1
  107. package/workframe-ui/public/assets/{pie-LRSECV5Y-KeOyY_-u.js → pie-LRSECV5Y-BFFah-6g.js} +1 -1
  108. package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-CUpjnzbs.js → pieDiagram-4H26LBE5-CVCvLPcC.js} +1 -1
  109. package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-CJdPTszF.js → quadrantDiagram-W4KKPZXB-Cx6rrc_7.js} +1 -1
  110. package/workframe-ui/public/assets/{radar-GUYGQ44K-C9BpandZ.js → radar-GUYGQ44K-DJ5vN_cg.js} +1 -1
  111. package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-CHDZPwry.js → requirementDiagram-4Y6WPE33-Cbry6b5c.js} +1 -1
  112. package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-CMJJh91s.js → sankeyDiagram-5OEKKPKP-CICUr5En.js} +1 -1
  113. package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-BsV4GppP.js → sequenceDiagram-3UESZ5HK-BsOuB3F-.js} +1 -1
  114. package/workframe-ui/public/assets/{src-WHks82Up.js → src-TS8jZspq.js} +1 -1
  115. package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-eSX9P8z0.js → stateDiagram-AJRCARHV-avBzMQy_.js} +1 -1
  116. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-BFRA5TYD.js +1 -0
  117. package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-Dg1UkhGo.js → timeline-definition-PNZ67QCA-DHLm6UFN.js} +1 -1
  118. package/workframe-ui/public/assets/{treeView-BLDUP644-B7yGsBuj.js → treeView-BLDUP644-B3Qd5t3j.js} +1 -1
  119. package/workframe-ui/public/assets/{treemap-LRROVOQU-j_G2oyQ_.js → treemap-LRROVOQU-D1dMQS7x.js} +1 -1
  120. package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-BRrT0jqC.js → vennDiagram-CIIHVFJN-DZ6SoniY.js} +1 -1
  121. package/workframe-ui/public/assets/{wardley-L42UT6IY-DEdUqez2.js → wardley-L42UT6IY-9gK17Tp3.js} +1 -1
  122. package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-C-bBK9Bb.js → wardleyDiagram-YWT4CUSO-FCU_wmRE.js} +1 -1
  123. package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-DhH5xuR2.js → xychartDiagram-2RQKCTM6-DWowvseN.js} +1 -1
  124. package/workframe-ui/public/index.html +2 -2
  125. package/workframe-ui/public/workframe-build.json +3 -3
  126. package/docs/security.md +0 -85
  127. package/workframe-ui/public/assets/architecture-7EHR7CIX-m1aPUQ_r.js +0 -1
  128. package/workframe-ui/public/assets/channel-3M69ekE5.js +0 -1
  129. package/workframe-ui/public/assets/chunk-QZHKN3VN-BMl9ed8P.js +0 -1
  130. package/workframe-ui/public/assets/chunk-WU5MYG2G-C08HH6BU.js +0 -1
  131. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BFcOE4Aq.js +0 -1
  132. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BFcOE4Aq.js +0 -1
  133. package/workframe-ui/public/assets/eventmodeling-FCH6USID-DIVBL6CF.js +0 -1
  134. package/workframe-ui/public/assets/index-DL2vJP4L.css +0 -1
  135. package/workframe-ui/public/assets/index-ElS_D59P.js +0 -130
  136. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-FzMK4PkM.js +0 -1
@@ -0,0 +1,33 @@
1
+ """api_errors public payload self-check."""
2
+ from __future__ import annotations
3
+
4
+ import urllib.error
5
+
6
+ import api_errors
7
+
8
+
9
+ def main() -> None:
10
+ raw = (
11
+ "runtime profile create failed: Traceback (most recent call last):\n"
12
+ ' File "/opt/hermes/hermes_cli/profiles.py", line 1\n'
13
+ "PermissionError: [Errno 13] Permission denied: '/opt/data/profiles/u-test-agent'"
14
+ )
15
+ payload = api_errors.public_api_error_payload(ValueError(raw))
16
+ assert payload["error"] == "runtime_profile_permission_denied"
17
+ assert "Traceback" not in payload["message"]
18
+ assert payload["message"]
19
+ assert payload["hint"]
20
+
21
+ assert api_errors.http_status_for_code("no_session") == 401
22
+ assert api_errors.http_status_for_code("room_not_found") == 404
23
+
24
+ refused = api_errors.public_api_error_payload(
25
+ urllib.error.URLError("[Errno 111] Connection refused"),
26
+ )
27
+ assert refused["error"] == "runtime_profile_api_config_failed"
28
+
29
+ print("test_api_errors: ok")
30
+
31
+
32
+ if __name__ == "__main__":
33
+ main()
@@ -0,0 +1,72 @@
1
+ """Regression: _enrich_room_messages must not reference undefined _srv in server.py."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sqlite3
7
+ import sys
8
+ import uuid
9
+ from pathlib import Path
10
+
11
+ API_DIR = Path(__file__).resolve().parent
12
+ if str(API_DIR) not in sys.path:
13
+ sys.path.insert(0, str(API_DIR))
14
+
15
+ os.environ.setdefault("WORKFRAME_API_DATA_DIR", str(API_DIR / ".tmp-test-data"))
16
+ os.environ.setdefault("HERMES_DATA", str(API_DIR / ".tmp-test-hermes"))
17
+ os.environ.setdefault("DEV_LOCAL_UNSAFE", "true")
18
+
19
+ import server # noqa: E402
20
+
21
+
22
+ def test_enrich_room_messages_with_agent_sender(tmp_path: Path) -> None:
23
+ db = tmp_path / "workframe.db"
24
+ conn = sqlite3.connect(db)
25
+ conn.row_factory = sqlite3.Row
26
+ now = "1700000000"
27
+ agent_id = str(uuid.uuid4())
28
+ room_id = str(uuid.uuid4())
29
+ msg_id = str(uuid.uuid4())
30
+ conn.executescript(
31
+ f"""
32
+ CREATE TABLE agent_profiles (
33
+ id TEXT PRIMARY KEY,
34
+ slug TEXT,
35
+ display_name TEXT,
36
+ model_name TEXT,
37
+ model_provider TEXT,
38
+ deleted_at TEXT
39
+ );
40
+ CREATE TABLE messages (
41
+ id TEXT PRIMARY KEY,
42
+ room_id TEXT,
43
+ sender_user_id TEXT,
44
+ sender_agent_id TEXT,
45
+ parent_message_id TEXT,
46
+ content TEXT,
47
+ content_type TEXT,
48
+ metadata TEXT,
49
+ is_edited INTEGER,
50
+ created_at TEXT,
51
+ updated_at TEXT,
52
+ deleted_at TEXT
53
+ );
54
+ INSERT INTO agent_profiles (id, slug, display_name, model_name, model_provider, deleted_at)
55
+ VALUES ('{agent_id}', 'test-agent', 'Test Agent', 'gpt-4o-mini', 'openai', NULL);
56
+ INSERT INTO messages (
57
+ id, room_id, sender_user_id, sender_agent_id, parent_message_id,
58
+ content, content_type, metadata, is_edited, created_at, updated_at, deleted_at
59
+ ) VALUES (
60
+ '{msg_id}', '{room_id}', NULL, '{agent_id}', NULL,
61
+ 'Hello!', 'text', '{{}}', 0, '{now}', '{now}', NULL
62
+ );
63
+ """
64
+ )
65
+ rows = conn.execute("SELECT * FROM messages").fetchall()
66
+ enriched = server._enrich_room_messages(conn, rows)
67
+ conn.close()
68
+
69
+ assert len(enriched) == 1
70
+ assert enriched[0]["content"] == "Hello!"
71
+ assert enriched[0]["sender_agent_slug"] == "test-agent"
72
+ assert enriched[0]["sender_agent_name"] == "Test Agent"
@@ -4,9 +4,11 @@ from pathlib import Path
4
4
 
5
5
  ROOT = Path(__file__).resolve().parent
6
6
  src = (ROOT / "server.py").read_text(encoding="utf-8")
7
+ auth_src = (ROOT / "handler_modules" / "handler_auth.py").read_text(encoding="utf-8")
8
+ admin_src = (ROOT / "handler_modules" / "handler_admin.py").read_text(encoding="utf-8")
7
9
  messaging_src = (ROOT / "workspace_messaging.py").read_text(encoding="utf-8")
8
10
  assert "_log_handler_error" in src
9
- assert 'POST /api/auth/start' in src
10
- assert 'POST /api/admin/vault/init' in src
11
+ assert 'POST /api/auth/start' in auth_src
12
+ assert 'POST /api/admin/vault/init' in admin_src
11
13
  assert "_sync_workspace_messaging_gateway restart" in messaging_src
12
14
  print("exception hygiene self-check ok")
@@ -41,6 +41,12 @@ def test_commands_catalog_shape() -> None:
41
41
  assert isinstance(catalog["entries"], list)
42
42
  assert all(entry.get("wired") is True for entry in catalog["entries"])
43
43
  assert not any(entry.get("wip") for entry in catalog["entries"])
44
+ assert not any(entry.get("dispatch") in {"gateway", "client:noOp"} for entry in catalog["entries"])
45
+
46
+
47
+ def test_unproven_gateway_commands_stay_hidden() -> None:
48
+ assert hermes_admin._resolve_command("/retry") is None
49
+ assert hermes_admin._resolve_command("/quit") is None
44
50
 
45
51
 
46
52
  def test_commands_exec_client_dispatch() -> None:
@@ -66,6 +72,7 @@ if __name__ == "__main__":
66
72
  test_resolve_command_alias()
67
73
  test_resolve_command_unknown()
68
74
  test_commands_catalog_shape()
75
+ test_unproven_gateway_commands_stay_hidden()
69
76
  test_commands_exec_client_dispatch()
70
77
  test_commands_exec_not_slash()
71
78
  test_gquota_stub()
@@ -17,6 +17,33 @@ stack_config.DATA_DIR = td
17
17
  stack_config.CONFIG_PATH = td / "stack_config.json"
18
18
  db_path = str(td / "workframe.db")
19
19
 
20
+ assert install_api.resolve_install_wizard_step(db_path) == "intro"
21
+
22
+ # Registered admin + stale saved smtp must not skip deployment.
23
+ import sqlite3
24
+
25
+ conn = sqlite3.connect(db_path)
26
+ conn.executescript(
27
+ """
28
+ CREATE TABLE workspaces (
29
+ id TEXT PRIMARY KEY, slug TEXT, display_name TEXT, owner_id TEXT,
30
+ settings_json TEXT, deleted_at INTEGER, status TEXT, created_at TEXT, updated_at TEXT
31
+ );
32
+ """
33
+ )
34
+ conn.execute(
35
+ "INSERT INTO workspaces (id, slug, display_name, owner_id, settings_json, deleted_at, status, created_at, updated_at) "
36
+ "VALUES ('ws1', 'default', 'Workframe', 'u1', '{}', NULL, 'active', '1', '1')",
37
+ )
38
+ conn.commit()
39
+ conn.close()
40
+
41
+ stack_config.patch_stack_config(
42
+ {"smtp": {"admin_email": "owner@example.com"}, "wizard_step": "smtp"},
43
+ )
44
+ assert install_api.install_owner_claimed(db_path)
45
+ assert install_api.resolve_install_wizard_step(db_path) == "welcome"
46
+
20
47
  # SMTP saved + tested
21
48
  stack_config.patch_stack_config(
22
49
  {
@@ -36,7 +63,11 @@ stack_config.patch_stack_config({"smtp": {"admin_email": "owner@example.com"}})
36
63
 
37
64
  assert stack_config.smtp_setup_complete()
38
65
  assert not stack_config.install_admin_verified()
39
- assert install_api.resolve_install_wizard_step(db_path) == "smtp"
66
+ assert install_api.resolve_install_wizard_step(db_path) == "admin_auth"
67
+
68
+ # Registered owner before verify still resumes at OTP (register ≠ verify).
69
+ assert install_api.install_owner_claimed(db_path)
70
+ assert install_api.resolve_install_wizard_step(db_path) == "admin_auth"
40
71
 
41
72
  stack_config.mark_install_admin_verified("owner@example.com")
42
73
  assert stack_config.install_admin_verified()
@@ -50,24 +81,19 @@ assert payload["smtp"]["admin_verified"] is True
50
81
 
51
82
  wizard = install_api.install_wizard_public_payload(db_path)
52
83
  assert wizard["admin_verified"] is True
84
+ assert wizard["owner_claimed"] is True
53
85
  assert wizard["resume_step"] == "workframe"
54
86
 
55
- # Workspace progress → billing
56
- import sqlite3
87
+ allowed, _ = install_api.install_auth_email_allowed("owner@example.com")
88
+ assert allowed
89
+ denied, deny = install_api.install_auth_email_allowed("other@example.com")
90
+ assert not denied and deny.get("error") == "install_admin_email_required"
57
91
 
92
+ # Workspace progress → billing
58
93
  conn = sqlite3.connect(db_path)
59
- conn.executescript(
60
- """
61
- CREATE TABLE workspaces (
62
- id TEXT PRIMARY KEY, slug TEXT, display_name TEXT, owner_id TEXT,
63
- settings_json TEXT, deleted_at INTEGER, status TEXT, created_at TEXT, updated_at TEXT
64
- );
65
- """
66
- )
67
94
  settings = {"admin_onboarding_done": True}
68
95
  conn.execute(
69
- "INSERT INTO workspaces (id, slug, display_name, owner_id, settings_json, deleted_at, status, created_at, updated_at) "
70
- "VALUES ('ws1', 'default', 'Workframe', 'u1', ?, NULL, 'active', '1', '1')",
96
+ "UPDATE workspaces SET settings_json = ? WHERE id = 'ws1'",
71
97
  (json.dumps(settings),),
72
98
  )
73
99
  conn.commit()
@@ -10,108 +10,126 @@ import tempfile
10
10
  from pathlib import Path
11
11
 
12
12
  ROOT = Path(__file__).resolve().parent
13
- tmpdir = tempfile.mkdtemp(prefix="wf-model-surface-")
14
- os.environ["WORKFRAME_AGENTS_DIR"] = tmpdir
15
- os.environ["WORKFRAME_PROJECT"] = "ModelSurfaceTest"
16
- os.environ["NATIVE_PROFILE"] = "surface-test-agent"
13
+ PROF = "surface-test-agent"
17
14
 
18
- spec = importlib.util.spec_from_file_location("wf_server_model_surface", ROOT / "server.py")
19
- server = importlib.util.module_from_spec(spec)
20
- sys.modules["wf_server_model_surface"] = server
21
- spec.loader.exec_module(server)
22
- sys.modules["server"] = server # ponytail: extracted modules resolve _srv() to this instance
23
15
 
24
- PROF = "surface-test-agent"
25
- prof_dir = server._profile_dir(PROF)
26
- prof_dir.mkdir(parents=True, exist_ok=True)
27
- (prof_dir / "config.yaml").write_text(
28
- "model:\n default: gpt-5.4-medium\n provider: openai-codex\n",
29
- encoding="utf-8",
30
- )
31
- cfg_path = prof_dir / "config.yaml"
32
- before = cfg_path.read_bytes()
33
-
34
- # A: GET models must not mutate config.yaml
35
- server.hermes_models(PROF, user_id="", workspace_id="")
36
- assert cfg_path.read_bytes() == before, "hermes_models must not rewrite config on read"
37
-
38
- # B: reconcile idempotent when routing already matches codex oauth
39
- assert server._profile_routing_matches_billing(PROF, "codex")
40
- assert server._reconcile_profile_llm_for_user(PROF, "user-1", "") is False
41
-
42
- # C: billing resolution honors prefer= when model exists in that catalog
43
- connected = {"codex", "openrouter"}
44
- assert server._resolve_billing_provider_for_model(
45
- "gpt-5.4-medium", connected, prefer="codex",
46
- ) == "codex"
47
- assert server._resolve_billing_provider_for_model(
48
- "openrouter/owl-alpha", connected, prefer="openrouter",
49
- ) == "openrouter"
50
- assert server._resolve_billing_provider_for_model(
51
- "openai-codex/gpt-5.4-mini", {"codex", "openrouter"},
52
- ) == "codex"
53
-
54
- # D: set with explicit billing_provider returns billing id in response
55
- applied = server.hermes_model_set(
56
- PROF, "gpt-5.4-medium", user_id="", workspace_id="", billing_provider="codex",
57
- )
58
- assert applied.get("ok"), applied
59
- assert applied.get("billing_provider") == "codex"
60
- assert applied.get("provider") == "codex"
61
-
62
- # E: reconcile picks model billing over stale openrouter proxy yaml
63
- (prof_dir / "config.yaml").write_text(
64
- "model:\n default: openai-codex/gpt-5.4-mini\n provider: custom\n"
65
- " base_url: http://workframe-api:8080/internal/llm/openrouter/v1\n",
66
- encoding="utf-8",
67
- )
68
- _orig_picker = server._user_llm_providers_for_picker
69
- _orig_can = server._user_can_use_llm
70
- _orig_sync = server._sync_oauth_llm_to_profile
71
- server._user_llm_providers_for_picker = lambda uid: {"codex"} if uid == "user-1" else set()
72
- server._user_can_use_llm = lambda uid, ws, prov: uid == "user-1" and prov == "codex"
73
- server._sync_oauth_llm_to_profile = lambda *a, **k: None
74
- try:
75
- assert server._reconcile_profile_llm_for_user(PROF, "user-1", "") is True
76
- block = server._read_model_block(PROF)
77
- assert block.get("provider") == "openai-codex"
78
- assert block.get("default") == "gpt-5.4-mini"
79
- assert not block.get("base_url")
80
- finally:
81
- server._user_llm_providers_for_picker = _orig_picker
82
- server._user_can_use_llm = _orig_can
83
- server._sync_oauth_llm_to_profile = _orig_sync
84
-
85
- # F: runtime disk wins over template in _read_model_block
86
- runtime_prof = "u-test-user-surface-test-agent"
87
- runtime_dir = server._profile_dir(runtime_prof)
88
- runtime_dir.mkdir(parents=True, exist_ok=True)
89
- (runtime_dir / "config.yaml").write_text(
90
- "model:\n default: gpt-5.4-mini\n provider: openai-codex\n",
91
- encoding="utf-8",
92
- )
93
- block = server._read_model_block(runtime_prof)
94
- assert block.get("default") == "gpt-5.4-mini", block
95
- assert block.get("provider") == "openai-codex", block
96
-
97
- # G: template model save mirrors to runtime (same model id, not stale template re-read)
98
- runtime_prof = "u-test-user2-surface-test-agent"
99
- runtime_dir = server._profile_dir(runtime_prof)
100
- runtime_dir.mkdir(parents=True, exist_ok=True)
101
- (runtime_dir / "config.yaml").write_text(
102
- "model:\n default: gpt-5.4-medium\n provider: openai-codex\n",
103
- encoding="utf-8",
104
- )
105
- _orig_restart = server._restart_runtime_profile_gateway
106
- server._restart_runtime_profile_gateway = lambda *a, **k: None
107
- try:
16
+ def _run_checks(server) -> None:
17
+ prof_dir = server._profile_dir(PROF)
18
+ prof_dir.mkdir(parents=True, exist_ok=True)
19
+ (prof_dir / "config.yaml").write_text(
20
+ "model:\n default: gpt-5.4-medium\n provider: openai-codex\n",
21
+ encoding="utf-8",
22
+ )
23
+ cfg_path = prof_dir / "config.yaml"
24
+ before = cfg_path.read_bytes()
25
+
26
+ # A: GET models must not mutate config.yaml
27
+ server.hermes_models(PROF, user_id="", workspace_id="")
28
+ assert cfg_path.read_bytes() == before, "hermes_models must not rewrite config on read"
29
+
30
+ # B: reconcile idempotent when routing already matches codex oauth
31
+ assert server._profile_routing_matches_billing(PROF, "codex")
32
+ assert server._reconcile_profile_llm_for_user(PROF, "user-1", "") is False
33
+
34
+ # C: billing resolution honors prefer= when model exists in that catalog
35
+ connected = {"codex", "openrouter"}
36
+ assert server._resolve_billing_provider_for_model(
37
+ "gpt-5.4-medium", connected, prefer="codex",
38
+ ) == "codex"
39
+ assert server._resolve_billing_provider_for_model(
40
+ "openrouter/owl-alpha", connected, prefer="openrouter",
41
+ ) == "openrouter"
42
+ assert server._resolve_billing_provider_for_model(
43
+ "openai-codex/gpt-5.4-mini", {"codex", "openrouter"},
44
+ ) == "codex"
45
+
46
+ # D: set with explicit billing_provider returns billing id in response
108
47
  applied = server.hermes_model_set(
109
- PROF, "gpt-5.4-mini", user_id="test-user2", workspace_id="", billing_provider="codex",
48
+ PROF, "gpt-5.4-medium", user_id="", workspace_id="", billing_provider="codex",
49
+ )
50
+ assert applied.get("ok"), applied
51
+ assert applied.get("billing_provider") == "codex"
52
+ assert applied.get("provider") == "codex"
53
+
54
+ # E: reconcile picks model billing over stale openrouter proxy yaml
55
+ (prof_dir / "config.yaml").write_text(
56
+ "model:\n default: openai-codex/gpt-5.4-mini\n provider: custom\n"
57
+ " base_url: http://workframe-api:8080/internal/llm/openrouter/v1\n",
58
+ encoding="utf-8",
110
59
  )
111
- finally:
112
- server._restart_runtime_profile_gateway = _orig_restart
113
- assert applied.get("ok"), applied
114
- runtime_block = server._parse_model_block_from_disk(runtime_prof)
115
- assert runtime_block.get("default") == "gpt-5.4-mini", runtime_block
60
+ _orig_picker = server._user_llm_providers_for_picker
61
+ _orig_can = server._user_can_use_llm
62
+ _orig_sync = server._sync_oauth_llm_to_profile
63
+ server._user_llm_providers_for_picker = lambda uid: {"codex"} if uid == "user-1" else set()
64
+ server._user_can_use_llm = lambda uid, ws, prov: uid == "user-1" and prov == "codex"
65
+ server._sync_oauth_llm_to_profile = lambda *a, **k: None
66
+ try:
67
+ assert server._reconcile_profile_llm_for_user(PROF, "user-1", "") is True
68
+ block = server._read_model_block(PROF)
69
+ assert block.get("provider") == "openai-codex"
70
+ assert block.get("default") == "gpt-5.4-mini"
71
+ assert not block.get("base_url")
72
+ finally:
73
+ server._user_llm_providers_for_picker = _orig_picker
74
+ server._user_can_use_llm = _orig_can
75
+ server._sync_oauth_llm_to_profile = _orig_sync
76
+
77
+ # F: runtime disk wins over template in _read_model_block
78
+ runtime_prof = "u-test-user-surface-test-agent"
79
+ runtime_dir = server._profile_dir(runtime_prof)
80
+ runtime_dir.mkdir(parents=True, exist_ok=True)
81
+ (runtime_dir / "config.yaml").write_text(
82
+ "model:\n default: gpt-5.4-mini\n provider: openai-codex\n",
83
+ encoding="utf-8",
84
+ )
85
+ block = server._read_model_block(runtime_prof)
86
+ assert block.get("default") == "gpt-5.4-mini", block
87
+ assert block.get("provider") == "openai-codex", block
88
+
89
+ # G: template model save mirrors to runtime (same model id, not stale template re-read)
90
+ runtime_prof = "u-test-user2-surface-test-agent"
91
+ runtime_dir = server._profile_dir(runtime_prof)
92
+ runtime_dir.mkdir(parents=True, exist_ok=True)
93
+ (runtime_dir / "config.yaml").write_text(
94
+ "model:\n default: gpt-5.4-medium\n provider: openai-codex\n",
95
+ encoding="utf-8",
96
+ )
97
+ _orig_restart = server._restart_runtime_profile_gateway
98
+ server._restart_runtime_profile_gateway = lambda *a, **k: None
99
+ try:
100
+ applied = server.hermes_model_set(
101
+ PROF, "gpt-5.4-mini", user_id="test-user2", workspace_id="", billing_provider="codex",
102
+ )
103
+ finally:
104
+ server._restart_runtime_profile_gateway = _orig_restart
105
+ assert applied.get("ok"), applied
106
+ runtime_block = server._parse_model_block_from_disk(runtime_prof)
107
+ assert runtime_block.get("default") == "gpt-5.4-mini", runtime_block
108
+
109
+
110
+ def _run_self_check() -> None:
111
+ tmpdir = tempfile.mkdtemp(prefix="wf-model-surface-")
112
+ os.environ["WORKFRAME_AGENTS_DIR"] = tmpdir
113
+ os.environ["HERMES_DATA"] = tmpdir
114
+ os.environ["WORKFRAME_API_DATA_DIR"] = str(Path(tmpdir) / "api")
115
+ os.environ["WORKFRAME_PROJECT"] = "ModelSurfaceTest"
116
+ os.environ["NATIVE_PROFILE"] = "surface-test-agent"
117
+
118
+ spec = importlib.util.spec_from_file_location("wf_server_model_surface", ROOT / "server.py")
119
+ server = importlib.util.module_from_spec(spec)
120
+ sys.modules["wf_server_model_surface"] = server
121
+ spec.loader.exec_module(server)
122
+ prev_server = sys.modules.get("server")
123
+ sys.modules["server"] = server # ponytail: extracted modules resolve _srv() to this instance
124
+ try:
125
+ _run_checks(server)
126
+ finally:
127
+ if prev_server is None:
128
+ sys.modules.pop("server", None)
129
+ else:
130
+ sys.modules["server"] = prev_server
131
+
116
132
 
117
- print("model surface consistency self-check ok")
133
+ if __name__ == "__main__":
134
+ _run_self_check()
135
+ print("model surface consistency self-check ok")
@@ -9,7 +9,10 @@ from pathlib import Path
9
9
  import yaml
10
10
 
11
11
  ROOT = Path(__file__).resolve().parent
12
- os.environ.setdefault("WORKFRAME_AGENTS_DIR", tempfile.mkdtemp(prefix="wf-yaml-test-"))
12
+ _test_root = tempfile.mkdtemp(prefix="wf-yaml-test-")
13
+ os.environ["WORKFRAME_AGENTS_DIR"] = _test_root
14
+ os.environ["HERMES_DATA"] = _test_root
15
+ os.environ["WORKFRAME_API_DATA_DIR"] = str(Path(_test_root) / "api")
13
16
  os.environ.setdefault("WORKFRAME_PROJECT", "YamlTest")
14
17
  os.environ.setdefault("NATIVE_PROFILE", "yaml-test-agent")
15
18
 
@@ -44,9 +44,19 @@ def test_parse_device_oauth_log() -> None:
44
44
  assert parsed["user_code"] == "ABCD-1234"
45
45
 
46
46
 
47
+ def test_device_oauth_error_from_log() -> None:
48
+ text = (
49
+ "hermes_cli.auth.AuthError: OpenAI is rate-limiting Codex login requests (HTTP 429). "
50
+ "Wait a minute and run the login again."
51
+ )
52
+ msg = provider_bindings._device_oauth_error_from_log(text)
53
+ assert msg and "429" in msg
54
+
55
+
47
56
  if __name__ == "__main__":
48
57
  test_hermes_oauth_auth_keys()
49
58
  test_auth_json_has_oauth_material()
50
59
  test_merge_oauth_auth_into_profile()
51
60
  test_parse_device_oauth_log()
61
+ test_device_oauth_error_from_log()
52
62
  print("test_provider_bindings: ok")
@@ -88,7 +88,8 @@ def test_schema_and_round_trip() -> None:
88
88
  conn.close()
89
89
 
90
90
  events = run_ledger.list_run_events_for_room("room-1")
91
- assert len(events) >= 2
91
+ assert len(events) == 1
92
+ assert events[0]["event_type"] == "run.completed"
92
93
  assert events[0]["source"] == "run_ledger"
93
94
 
94
95
 
@@ -86,6 +86,10 @@ _REQUIRED = (
86
86
  "_promote_workspace_owner_if_unclaimed",
87
87
  "_sync_workspace_home_room",
88
88
  "_onboard_workspace_member_rooms",
89
+ "_profile_model",
90
+ "_USER_LLM_PICKER_TTL_SEC",
91
+ "_invalidate_profile_health_cache",
92
+ "_profile_toolsets_ready",
89
93
  )
90
94
 
91
95
 
@@ -0,0 +1,99 @@
1
+ """LLM prefs persist in users.platform_ids without messaging-only validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sys
8
+ import tempfile
9
+ import time
10
+ import uuid
11
+ from pathlib import Path
12
+
13
+ import pytest
14
+
15
+ API_DIR = Path(__file__).resolve().parent
16
+ if str(API_DIR) not in sys.path:
17
+ sys.path.insert(0, str(API_DIR))
18
+
19
+ os.environ.setdefault("WORKFRAME_API_DATA_DIR", str(API_DIR / ".tmp-test-data"))
20
+ os.environ.setdefault("HERMES_DATA", str(API_DIR / ".tmp-test-hermes"))
21
+ os.environ.setdefault("DEV_LOCAL_UNSAFE", "true")
22
+
23
+ import server # noqa: E402
24
+
25
+
26
+ @pytest.fixture()
27
+ def llm_prefs_db(monkeypatch: pytest.MonkeyPatch):
28
+ with tempfile.TemporaryDirectory(prefix="wf-llm-prefs-") as tmp:
29
+ data_dir = Path(tmp) / "api"
30
+ data_dir.mkdir(parents=True, exist_ok=True)
31
+ monkeypatch.setattr(server, "DATA_DIR", data_dir)
32
+ monkeypatch.setattr(server, "AUTH_DB_PATH", data_dir / "auth.db")
33
+ server._ensure_workframe_db_schema()
34
+ yield data_dir
35
+
36
+
37
+ def _insert_user(*, email: str, display_name: str, platform_ids: str) -> str:
38
+ user_id = str(uuid.uuid4())
39
+ now = str(int(time.time()))
40
+ conn = server._workframe_db()
41
+ try:
42
+ conn.execute(
43
+ "INSERT INTO users (id, email, display_name, role, status, platform_ids, created_at, updated_at) "
44
+ "VALUES (?,?,?,?,?,?,?,?)",
45
+ (user_id, email, display_name, "owner", "active", platform_ids, now, now),
46
+ )
47
+ conn.commit()
48
+ finally:
49
+ conn.close()
50
+ return user_id
51
+
52
+
53
+ def test_user_llm_prefs_round_trip(llm_prefs_db) -> None: # noqa: ARG001
54
+ import user_prefs # noqa: WPS433
55
+
56
+ user_id = _insert_user(email="prefs@test.local", display_name="Prefs", platform_ids="{}")
57
+
58
+ user_prefs.write_user_llm_prefs(
59
+ user_id,
60
+ primary="openrouter/google/gemini-2.5-flash",
61
+ fallback_chain=[{"provider": "openrouter", "model": "anthropic/claude-sonnet-4.5"}],
62
+ )
63
+
64
+ conn = server._workframe_db()
65
+ try:
66
+ row = conn.execute(
67
+ "SELECT platform_ids FROM users WHERE id = ?", (user_id,),
68
+ ).fetchone()
69
+ finally:
70
+ conn.close()
71
+ pids = json.loads(row["platform_ids"])
72
+ assert pids["llm_primary"] == "openrouter/google/gemini-2.5-flash"
73
+ chain = json.loads(pids["llm_fallback_chain"])
74
+ assert chain[0]["model"] == "anthropic/claude-sonnet-4.5"
75
+
76
+ primary, fb = user_prefs.read_user_llm_prefs(user_id)
77
+ assert primary == "openrouter/google/gemini-2.5-flash"
78
+ assert fb[0]["model"] == "anthropic/claude-sonnet-4.5"
79
+
80
+
81
+ def test_user_llm_prefs_infers_primary_from_fallback_chain(llm_prefs_db) -> None: # noqa: ARG001
82
+ import user_prefs # noqa: WPS433
83
+
84
+ platform_ids = json.dumps({
85
+ "llm_fallback_chain": json.dumps([{"provider": "openrouter", "model": "google/gemini-2.5-flash"}]),
86
+ })
87
+ user_id = _insert_user(
88
+ email="fallback@test.local",
89
+ display_name="Fallback",
90
+ platform_ids=platform_ids,
91
+ )
92
+
93
+ inferred, chain = user_prefs.read_user_llm_prefs(user_id)
94
+ assert inferred == "openrouter/google/gemini-2.5-flash"
95
+ assert chain[0]["model"] == "google/gemini-2.5-flash"
96
+
97
+
98
+ if __name__ == "__main__":
99
+ raise SystemExit(pytest.main([__file__, "-q"]))
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env python3
2
+ """Split provider vs user overlay timing."""
3
+ from __future__ import annotations
4
+
5
+ import sqlite3
6
+ import time
7
+
8
+ import server
9
+
10
+
11
+ def ms(t0: float) -> int:
12
+ return round((time.perf_counter() - t0) * 1000)
13
+
14
+
15
+ def main() -> int:
16
+ conn = sqlite3.connect(str(server.DATA_DIR / "workframe.db"))
17
+ conn.row_factory = sqlite3.Row
18
+ room = conn.execute(
19
+ """
20
+ SELECT r.id, r.workspace_id, ap.slug AS agent_slug
21
+ FROM rooms r
22
+ JOIN agent_profiles ap ON ap.id = r.agent_profile_id
23
+ JOIN room_memberships rm ON rm.room_id = r.id AND rm.status = 'active'
24
+ WHERE r.room_type = 'direct' AND r.deleted_at IS NULL
25
+ ORDER BY r.updated_at DESC
26
+ LIMIT 1
27
+ """,
28
+ ).fetchone()
29
+ user = conn.execute(
30
+ """
31
+ SELECT u.id FROM users u
32
+ JOIN room_memberships rm ON rm.user_id = u.id AND rm.room_id = ?
33
+ WHERE u.status = 'active'
34
+ LIMIT 1
35
+ """,
36
+ (str(room["id"]),),
37
+ ).fetchone()
38
+ conn.close()
39
+ room_id = str(room["id"])
40
+ ws_id = str(room["workspace_id"])
41
+ user_id = str(user["id"])
42
+ prof = server._resolve_chat_hermes_profile(str(room["agent_slug"]), user_id, room_id, ws_id)
43
+ provider = server._llm_billing_provider(prof, user_id=user_id, workspace_id=ws_id)
44
+
45
+ for i in range(5):
46
+ t = time.perf_counter()
47
+ server._overlay_turn_provider_env(prof, user_id, ws_id, provider, f"p-{i}")
48
+ p_ms = ms(t)
49
+ t = time.perf_counter()
50
+ server._overlay_turn_user_env(prof, user_id, ws_id, f"u-{i}")
51
+ u_ms = ms(t)
52
+ print(f"turn_{i} provider_ms={p_ms} user_ms={u_ms} total_ms={p_ms + u_ms}")
53
+
54
+ return 0
55
+
56
+
57
+ if __name__ == "__main__":
58
+ raise SystemExit(main())