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
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env python3
2
+ """Quick TTFT probe: pre-stream phases + upstream first byte/delta (90s cap)."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sqlite3
7
+ import time
8
+ import urllib.error
9
+ import urllib.parse
10
+ import urllib.request
11
+
12
+ import server
13
+
14
+
15
+ def ms(t0: float) -> int:
16
+ return round((time.perf_counter() - t0) * 1000)
17
+
18
+
19
+ def main() -> int:
20
+ conn = sqlite3.connect(str(server.DATA_DIR / "workframe.db"))
21
+ conn.row_factory = sqlite3.Row
22
+ room = conn.execute(
23
+ """
24
+ SELECT r.id, r.workspace_id, ap.slug AS agent_slug
25
+ FROM rooms r
26
+ JOIN agent_profiles ap ON ap.id = r.agent_profile_id
27
+ JOIN room_memberships rm ON rm.room_id = r.id AND rm.status = 'active'
28
+ WHERE r.room_type = 'direct' AND r.deleted_at IS NULL
29
+ ORDER BY r.updated_at DESC
30
+ LIMIT 1
31
+ """,
32
+ ).fetchone()
33
+ if not room:
34
+ raise SystemExit("no direct room")
35
+ user = conn.execute(
36
+ """
37
+ SELECT u.id FROM users u
38
+ JOIN room_memberships rm ON rm.user_id = u.id AND rm.room_id = ?
39
+ WHERE u.status = 'active'
40
+ LIMIT 1
41
+ """,
42
+ (str(room["id"]),),
43
+ ).fetchone()
44
+ conn.close()
45
+ if not user:
46
+ raise SystemExit("no room user")
47
+
48
+ room_id = str(room["id"])
49
+ ws_id = str(room["workspace_id"])
50
+ user_id = str(user["id"])
51
+ agent_slug = str(room["agent_slug"])
52
+
53
+ t = time.perf_counter()
54
+ prof = server._resolve_chat_hermes_profile(agent_slug, user_id, room_id, ws_id)
55
+ print(f"resolve_ms={ms(t)} profile={prof}")
56
+
57
+ payload = {
58
+ "room_id": room_id,
59
+ "workspace_id": ws_id,
60
+ "source_id": "ttft-quick",
61
+ "client_id": "ttft-quick",
62
+ "binding_version": 2,
63
+ }
64
+ t = time.perf_counter()
65
+ session = server.profile_chat_session(agent_slug, payload, user_id=user_id)
66
+ sid = str(session.get("session_id") or "")
67
+ print(f"bind_ms={ms(t)} session={sid[:36]}")
68
+
69
+ provider = server._llm_billing_provider(prof, user_id=user_id, workspace_id=ws_id)
70
+ model = str(server._read_model_block(prof).get("default") or "")
71
+ print(f"provider={provider} model={model}")
72
+
73
+ for i in range(3):
74
+ t = time.perf_counter()
75
+ server._overlay_turn_provider_env(prof, user_id, ws_id, provider, f"quick-{i}")
76
+ server._overlay_turn_user_env(prof, user_id, ws_id, f"quick-{i}")
77
+ print(f"overlay_turn_{i}_ms={ms(t)}")
78
+
79
+ port = server._profile_api_port(prof)
80
+ key = server._profile_api_key(prof)
81
+ body = json.dumps(server._profile_turn_payload(prof, "Reply with exactly: pong", room_id)).encode()
82
+ url = f"http://gateway:{port}/api/sessions/{urllib.parse.quote(sid, safe='')}/chat/stream"
83
+ req = urllib.request.Request(
84
+ url,
85
+ data=body,
86
+ headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
87
+ method="POST",
88
+ )
89
+ t0 = time.perf_counter()
90
+ first_byte = first_delta = first_error = None
91
+ try:
92
+ with urllib.request.urlopen(req, timeout=90) as resp:
93
+ buf = b""
94
+ while True:
95
+ chunk = resp.read(4096)
96
+ if not chunk:
97
+ break
98
+ if first_byte is None:
99
+ first_byte = ms(t0)
100
+ buf += chunk
101
+ while b"\n\n" in buf:
102
+ frame, buf = buf.split(b"\n\n", 1)
103
+ text = frame.decode("utf-8", errors="replace")
104
+ ev = ""
105
+ data = ""
106
+ for line in text.splitlines():
107
+ if line.startswith("event:"):
108
+ ev = line[6:].strip()
109
+ elif line.startswith("data:"):
110
+ data = line[5:].lstrip()
111
+ if ev in ("message.delta", "assistant.delta") and first_delta is None:
112
+ first_delta = ms(t0)
113
+ print(f"upstream_first_delta_ms={first_delta}")
114
+ return 0
115
+ if ev in ("error", "run.failed", "concierge") and first_error is None:
116
+ first_error = ms(t0)
117
+ print(f"upstream_first_error_ms={first_error} data={data[:240]}")
118
+ return 0
119
+ except urllib.error.HTTPError as exc:
120
+ print(f"upstream_http_error_ms={ms(t0)} code={exc.code} body={exc.read()[:240]!r}")
121
+ return 1
122
+ except OSError as exc:
123
+ print(f"upstream_os_error_ms={ms(t0)} err={exc}")
124
+ return 1
125
+
126
+ print(
127
+ f"upstream_first_byte_ms={first_byte} "
128
+ f"first_delta_ms={first_delta} first_error_ms={first_error} "
129
+ f"total_ms={ms(t0)}"
130
+ )
131
+ return 0
132
+
133
+
134
+ if __name__ == "__main__":
135
+ raise SystemExit(main())
@@ -0,0 +1,250 @@
1
+ #!/usr/bin/env python3
2
+ """Measure time-to-first-token through the Workframe stream path (docker exec)."""
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import json
7
+ import sqlite3
8
+ import sys
9
+ import time
10
+ import urllib.error
11
+ import urllib.parse
12
+ import urllib.request
13
+ from http.server import BaseHTTPRequestHandler
14
+ from typing import Any
15
+
16
+ import server
17
+
18
+
19
+ def ms(t0: float) -> int:
20
+ return round((time.perf_counter() - t0) * 1000)
21
+
22
+
23
+ class _CaptureHandler(BaseHTTPRequestHandler):
24
+ protocol_version = "HTTP/1.1"
25
+
26
+ def __init__(self) -> None:
27
+ self.headers_sent = False
28
+ self.events: list[tuple[float, str, str]] = []
29
+ self._t0 = time.perf_counter()
30
+ self.rfile = io.BytesIO()
31
+ self.wfile = _WriteCapture(self)
32
+
33
+ def log_message(self, format: str, *args: Any) -> None: # noqa: A003
34
+ return
35
+
36
+ def send_response(self, code: int, message: str | None = None) -> None:
37
+ self.status_code = code
38
+
39
+ def send_header(self, keyword: str, value: str) -> None:
40
+ pass
41
+
42
+ def end_headers(self) -> None:
43
+ self.headers_sent = True
44
+ self.events.append((time.perf_counter(), "headers", ""))
45
+
46
+
47
+ class _WriteCapture(io.BufferedIOBase):
48
+ def __init__(self, handler: _CaptureHandler) -> None:
49
+ self._handler = handler
50
+ self._buf = bytearray()
51
+
52
+ def writable(self) -> bool:
53
+ return True
54
+
55
+ def write(self, b: bytes) -> int:
56
+ self._buf.extend(b)
57
+ while b"\n\n" in self._buf:
58
+ frame, self._buf = self._buf.split(b"\n\n", 1)
59
+ self._record_frame(frame + b"\n\n")
60
+ return len(b)
61
+
62
+ def flush(self) -> None:
63
+ if self._buf:
64
+ self._record_frame(bytes(self._buf))
65
+ self._buf.clear()
66
+
67
+ def _record_frame(self, frame: bytes) -> None:
68
+ text = frame.decode("utf-8", errors="replace")
69
+ event = ""
70
+ data = ""
71
+ for line in text.splitlines():
72
+ if line.startswith("event:"):
73
+ event = line[6:].strip()
74
+ elif line.startswith("data:"):
75
+ data = line[5:].lstrip()
76
+ self._handler.events.append((time.perf_counter(), event, data))
77
+
78
+
79
+ def _find_room_user() -> tuple[dict[str, Any], str]:
80
+ conn = sqlite3.connect(str(server.DATA_DIR / "workframe.db"))
81
+ conn.row_factory = sqlite3.Row
82
+ room = conn.execute(
83
+ """
84
+ SELECT r.id, r.workspace_id, ap.slug AS agent_slug
85
+ FROM rooms r
86
+ JOIN agent_profiles ap ON ap.id = r.agent_profile_id
87
+ JOIN room_memberships rm ON rm.room_id = r.id AND rm.status = 'active'
88
+ WHERE r.room_type = 'direct' AND r.deleted_at IS NULL
89
+ ORDER BY r.updated_at DESC
90
+ LIMIT 1
91
+ """,
92
+ ).fetchone()
93
+ if not room:
94
+ conn.close()
95
+ raise SystemExit("no direct room")
96
+ user = conn.execute(
97
+ """
98
+ SELECT u.id FROM users u
99
+ JOIN room_memberships rm ON rm.user_id = u.id AND rm.room_id = ?
100
+ WHERE u.status = 'active'
101
+ LIMIT 1
102
+ """,
103
+ (str(room["id"]),),
104
+ ).fetchone()
105
+ conn.close()
106
+ if not user:
107
+ raise SystemExit("no room user")
108
+ return dict(room), str(user["id"])
109
+
110
+
111
+ def _report_handler_events(handler: _CaptureHandler, t0: float, label: str) -> int | None:
112
+ first_delta: int | None = None
113
+ for at, event, _data in handler.events:
114
+ rel = ms(t0)
115
+ if event in ("run.started", "message.delta", "error", "concierge", "message.complete", "done"):
116
+ print(f" {label}_{event}_ms={rel}")
117
+ if event == "message.delta" and first_delta is None:
118
+ first_delta = rel
119
+ return first_delta
120
+
121
+
122
+ def _stream_upstream(prof: str, sid: str, text: str, room_id: str) -> dict[str, int | None]:
123
+ port = server._profile_api_port(prof)
124
+ key = server._profile_api_key(prof)
125
+ body = json.dumps(server._profile_turn_payload(prof, text, room_id)).encode()
126
+ url = f"http://gateway:{port}/api/sessions/{urllib.parse.quote(sid, safe='')}/chat/stream"
127
+ req = urllib.request.Request(
128
+ url,
129
+ data=body,
130
+ headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
131
+ method="POST",
132
+ )
133
+ t0 = time.perf_counter()
134
+ out: dict[str, int | None] = {
135
+ "first_byte_ms": None,
136
+ "first_delta_ms": None,
137
+ "run_started_ms": None,
138
+ }
139
+ try:
140
+ with urllib.request.urlopen(req, timeout=120) as resp:
141
+ buf = b""
142
+ while True:
143
+ chunk = resp.read(4096)
144
+ if not chunk:
145
+ break
146
+ if out["first_byte_ms"] is None:
147
+ out["first_byte_ms"] = ms(t0)
148
+ buf += chunk
149
+ while b"\n\n" in buf:
150
+ frame, buf = buf.split(b"\n\n", 1)
151
+ event = ""
152
+ for line in frame.decode("utf-8", errors="replace").splitlines():
153
+ if line.startswith("event:"):
154
+ event = line[6:].strip()
155
+ if event == "run.started" and out["run_started_ms"] is None:
156
+ out["run_started_ms"] = ms(t0)
157
+ if event in ("message.delta", "assistant.delta") and out["first_delta_ms"] is None:
158
+ out["first_delta_ms"] = ms(t0)
159
+ return out
160
+ except urllib.error.HTTPError as exc:
161
+ print(f"upstream_http_error={exc.code} body={exc.read()[:240]!r}")
162
+ except OSError as exc:
163
+ print(f"upstream_error={exc}")
164
+ return out
165
+
166
+
167
+ def main() -> int:
168
+ import chat_stream
169
+
170
+ room, user_id = _find_room_user()
171
+ room_id = str(room["id"])
172
+ workspace_id = str(room["workspace_id"])
173
+ agent_slug = str(room["agent_slug"])
174
+ payload_bind = {
175
+ "room_id": room_id,
176
+ "workspace_id": workspace_id,
177
+ "source_id": "ttft",
178
+ "client_id": "ttft",
179
+ "binding_version": 2,
180
+ }
181
+
182
+ t_resolve = time.perf_counter()
183
+ prof = server._resolve_chat_hermes_profile(agent_slug, user_id, room_id, workspace_id)
184
+ print(f"agent_slug={agent_slug} profile={prof} resolve_ms={ms(t_resolve)}")
185
+
186
+ healthy_before = server._profile_api_healthy(prof)
187
+ print(f"profile_api_healthy_before={healthy_before}")
188
+
189
+ t_bind = time.perf_counter()
190
+ session = server.profile_chat_session(agent_slug, payload_bind, user_id=user_id)
191
+ print(f"bind_ms={ms(t_bind)} session={str(session.get('session_id') or '')[:36]}")
192
+
193
+ provider = server._llm_billing_provider(prof, user_id=user_id, workspace_id=workspace_id)
194
+ model_block = server._read_model_block(prof)
195
+ model = str(model_block.get("default") or "")
196
+
197
+ # Phase timings (same work as stream_profile_chat pre-upstream)
198
+ t_auth = time.perf_counter()
199
+ import run_authority
200
+ import run_ledger
201
+
202
+ run_ledger.ensure_schema()
203
+ auth_req = run_authority.chat_run_request(
204
+ triggering_user_id=user_id,
205
+ profile_slug=prof,
206
+ workspace_id=workspace_id,
207
+ provider=provider,
208
+ room_id=room_id,
209
+ )
210
+ auth_ctx = chat_stream._run_authority_context_for_chat(user_id, workspace_id, provider)
211
+ run_authority.evaluate_run_authority(auth_req, auth_ctx, run_id="ttft-probe")
212
+ print(f"authority_ms={ms(t_auth)} provider={provider} model={model}")
213
+
214
+ t_api = time.perf_counter()
215
+ server.ensure_profile_api(prof, user_id, workspace_id)
216
+ ensure_ms = ms(t_api)
217
+ print(f"ensure_profile_api_ms={ensure_ms} healthy_after={server._profile_api_healthy(prof)}")
218
+
219
+ t_overlay = time.perf_counter()
220
+ server._overlay_turn_provider_env(prof, user_id, workspace_id, provider, "ttft-overlay")
221
+ server._overlay_turn_user_env(prof, user_id, workspace_id, "ttft-overlay")
222
+ print(f"overlay_ms={ms(t_overlay)}")
223
+
224
+ sid = str(session.get("session_id") or "")
225
+ stream_body = {
226
+ "session_id": sid,
227
+ "text": "Reply with exactly: pong",
228
+ "user_id": user_id,
229
+ "workspace_id": workspace_id,
230
+ "room_id": room_id,
231
+ }
232
+
233
+ # Full Workframe stream path
234
+ handler = _CaptureHandler()
235
+ t_stream = time.perf_counter()
236
+ chat_stream.stream_profile_chat(handler, agent_slug, stream_body)
237
+ wf_ttft = _report_handler_events(handler, t_stream, "wf")
238
+ print(f"wf_ttft_ms={wf_ttft}")
239
+
240
+ # Direct upstream (gateway only) for comparison
241
+ upstream = _stream_upstream(prof, sid, "Reply with exactly: pong", room_id)
242
+ print(
243
+ "upstream "
244
+ + " ".join(f"{k}={v}" for k, v in upstream.items() if v is not None)
245
+ )
246
+ return 0
247
+
248
+
249
+ if __name__ == "__main__":
250
+ raise SystemExit(main())
@@ -0,0 +1,145 @@
1
+ #!/usr/bin/env python3
2
+ """WF stream path TTFT with phase breakdown (90s upstream cap)."""
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import json
7
+ import sqlite3
8
+ import sys
9
+ import time
10
+ from http.server import BaseHTTPRequestHandler
11
+ from typing import Any
12
+
13
+ import chat_stream
14
+ import server
15
+
16
+
17
+ def ms(t0: float) -> int:
18
+ return round((time.perf_counter() - t0) * 1000)
19
+
20
+
21
+ class _CaptureHandler(BaseHTTPRequestHandler):
22
+ protocol_version = "HTTP/1.1"
23
+
24
+ def __init__(self) -> None:
25
+ self.events: list[tuple[float, str]] = []
26
+ self.rfile = io.BytesIO()
27
+ self.wfile = _WriteCapture(self)
28
+
29
+ def log_message(self, format: str, *args: Any) -> None:
30
+ return
31
+
32
+ def send_response(self, code: int, message: str | None = None) -> None:
33
+ self.status_code = code
34
+
35
+ def send_header(self, keyword: str, value: str) -> None:
36
+ pass
37
+
38
+ def end_headers(self) -> None:
39
+ self.events.append((time.perf_counter(), "headers"))
40
+
41
+ def flush(self) -> None:
42
+ pass
43
+
44
+
45
+ class _WriteCapture(io.BufferedIOBase):
46
+ def __init__(self, handler: _CaptureHandler) -> None:
47
+ self._handler = handler
48
+ self._buf = bytearray()
49
+
50
+ def writable(self) -> bool:
51
+ return True
52
+
53
+ def write(self, b: bytes) -> int:
54
+ self._buf.extend(b)
55
+ while b"\n\n" in self._buf:
56
+ frame, self._buf = self._buf.split(b"\n\n", 1)
57
+ self._record_frame(frame + b"\n\n")
58
+ return len(b)
59
+
60
+ def flush(self) -> None:
61
+ if self._buf:
62
+ self._record_frame(bytes(self._buf))
63
+ self._buf.clear()
64
+
65
+ def _record_frame(self, frame: bytes) -> None:
66
+ text = frame.decode("utf-8", errors="replace")
67
+ event = ""
68
+ for line in text.splitlines():
69
+ if line.startswith("event:"):
70
+ event = line[6:].strip()
71
+ if event:
72
+ self._handler.events.append((time.perf_counter(), event))
73
+
74
+
75
+ def main() -> int:
76
+ conn = sqlite3.connect(str(server.DATA_DIR / "workframe.db"))
77
+ conn.row_factory = sqlite3.Row
78
+ room = conn.execute(
79
+ """
80
+ SELECT r.id, r.workspace_id, ap.slug AS agent_slug
81
+ FROM rooms r
82
+ JOIN agent_profiles ap ON ap.id = r.agent_profile_id
83
+ JOIN room_memberships rm ON rm.room_id = r.id AND rm.status = 'active'
84
+ WHERE r.room_type = 'direct' AND r.deleted_at IS NULL
85
+ ORDER BY r.updated_at DESC
86
+ LIMIT 1
87
+ """,
88
+ ).fetchone()
89
+ user = conn.execute(
90
+ """
91
+ SELECT u.id FROM users u
92
+ JOIN room_memberships rm ON rm.user_id = u.id AND rm.room_id = ?
93
+ WHERE u.status = 'active'
94
+ LIMIT 1
95
+ """,
96
+ (str(room["id"]),),
97
+ ).fetchone()
98
+ conn.close()
99
+ if not room or not user:
100
+ print("no room/user", file=sys.stderr)
101
+ return 1
102
+
103
+ room_id = str(room["id"])
104
+ ws_id = str(room["workspace_id"])
105
+ user_id = str(user["id"])
106
+ agent_slug = str(room["agent_slug"])
107
+
108
+ payload_bind = {
109
+ "room_id": room_id,
110
+ "workspace_id": ws_id,
111
+ "source_id": "wf-ttft",
112
+ "client_id": "wf-ttft",
113
+ "binding_version": 2,
114
+ }
115
+ session = server.profile_chat_session(agent_slug, payload_bind, user_id=user_id)
116
+ sid = str(session.get("session_id") or "")
117
+
118
+ handler = _CaptureHandler()
119
+ stream_body = {
120
+ "session_id": sid,
121
+ "text": "Reply with exactly: pong",
122
+ "user_id": user_id,
123
+ "workspace_id": ws_id,
124
+ "room_id": room_id,
125
+ }
126
+
127
+ t0 = time.perf_counter()
128
+ try:
129
+ chat_stream.stream_profile_chat(handler, agent_slug, stream_body)
130
+ except Exception as exc:
131
+ print(f"stream_error_ms={ms(t0)} err={exc}")
132
+ return 1
133
+
134
+ for at, event in handler.events:
135
+ print(f" wf_{event}_ms={ms(t0)}")
136
+ if event == "message.delta":
137
+ break
138
+ if event in ("error", "concierge"):
139
+ break
140
+
141
+ return 0
142
+
143
+
144
+ if __name__ == "__main__":
145
+ raise SystemExit(main())