opencode-dashboard-server 0.4.0__tar.gz → 0.5.0__tar.gz

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 (19) hide show
  1. opencode_dashboard_server-0.5.0/LICENSE.md +21 -0
  2. {opencode_dashboard_server-0.4.0 → opencode_dashboard_server-0.5.0}/PKG-INFO +4 -1
  3. {opencode_dashboard_server-0.4.0 → opencode_dashboard_server-0.5.0}/aggregate.py +58 -19
  4. opencode_dashboard_server-0.5.0/app.py +354 -0
  5. {opencode_dashboard_server-0.4.0 → opencode_dashboard_server-0.5.0}/opencode_dashboard_server.egg-info/PKG-INFO +4 -1
  6. {opencode_dashboard_server-0.4.0 → opencode_dashboard_server-0.5.0}/opencode_dashboard_server.egg-info/SOURCES.txt +1 -0
  7. {opencode_dashboard_server-0.4.0 → opencode_dashboard_server-0.5.0}/pyproject.toml +3 -1
  8. {opencode_dashboard_server-0.4.0 → opencode_dashboard_server-0.5.0}/tests/test_aggregate.py +57 -0
  9. opencode_dashboard_server-0.5.0/tests/test_app.py +258 -0
  10. opencode_dashboard_server-0.4.0/app.py +0 -165
  11. opencode_dashboard_server-0.4.0/tests/test_app.py +0 -70
  12. {opencode_dashboard_server-0.4.0 → opencode_dashboard_server-0.5.0}/README.md +0 -0
  13. {opencode_dashboard_server-0.4.0 → opencode_dashboard_server-0.5.0}/cli.py +0 -0
  14. {opencode_dashboard_server-0.4.0 → opencode_dashboard_server-0.5.0}/db.py +0 -0
  15. {opencode_dashboard_server-0.4.0 → opencode_dashboard_server-0.5.0}/opencode_dashboard_server.egg-info/dependency_links.txt +0 -0
  16. {opencode_dashboard_server-0.4.0 → opencode_dashboard_server-0.5.0}/opencode_dashboard_server.egg-info/entry_points.txt +0 -0
  17. {opencode_dashboard_server-0.4.0 → opencode_dashboard_server-0.5.0}/opencode_dashboard_server.egg-info/requires.txt +0 -0
  18. {opencode_dashboard_server-0.4.0 → opencode_dashboard_server-0.5.0}/opencode_dashboard_server.egg-info/top_level.txt +0 -0
  19. {opencode_dashboard_server-0.4.0 → opencode_dashboard_server-0.5.0}/setup.cfg +0 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 GCS-ZHN
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,16 +1,19 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: opencode-dashboard-server
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: FastAPI aggregator over opencode's SQLite storage
5
+ License-Expression: MIT
5
6
  Project-URL: Homepage, https://github.com/GCS-ZHN/opencode-dashboard
6
7
  Project-URL: Repository, https://github.com/GCS-ZHN/opencode-dashboard
7
8
  Project-URL: Issues, https://github.com/GCS-ZHN/opencode-dashboard/issues
8
9
  Requires-Python: >=3.10
9
10
  Description-Content-Type: text/markdown
11
+ License-File: LICENSE.md
10
12
  Requires-Dist: fastapi<1,>=0.115
11
13
  Requires-Dist: uvicorn<1,>=0.30
12
14
  Requires-Dist: platformdirs<5,>=4
13
15
  Requires-Dist: PyYAML<7,>=6
16
+ Dynamic: license-file
14
17
 
15
18
  # opencode-dashboard-server
16
19
 
@@ -38,7 +38,9 @@ CASE WHEN p.worktree IS NOT NULL AND p.worktree != '/' THEN s.project_id
38
38
 
39
39
  # Join + effective-key mapping live in a derived table so `id`/`worktree` are
40
40
  # unambiguous result columns (s.id and p.id would otherwise clash in GROUP BY).
41
- ROLLUP_SQL = f"""
41
+ # `{time_filter}` ("" or a WHERE on s.time_created) is filled in per request for
42
+ # the since/until window.
43
+ ROLLUP_SQL = """
42
44
  SELECT gid AS id,
43
45
  MAX(worktree) AS worktree,
44
46
  COUNT(*) AS session_count,
@@ -57,11 +59,12 @@ FROM (
57
59
  SELECT s.project_id, s.id AS sid, s.parent_id, s.cost, s.tokens_input,
58
60
  s.tokens_output, s.tokens_reasoning, s.tokens_cache_read,
59
61
  s.tokens_cache_write,
60
- {EFF_SQL} AS gid,
62
+ {eff_sql} AS gid,
61
63
  CASE WHEN p.worktree IS NOT NULL AND p.worktree != '/' THEN p.worktree
62
64
  ELSE s.directory END AS worktree
63
65
  FROM session s
64
66
  LEFT JOIN project p ON p.id = s.project_id
67
+ {time_filter}
65
68
  ) AS eff
66
69
  """
67
70
 
@@ -72,13 +75,13 @@ SELECT id, parent_id, project_id, title, agent, model, cost,
72
75
  FROM session
73
76
  """
74
77
 
75
- PROJECT_SESSIONS_SQL = f"""
78
+ PROJECT_SESSIONS_SQL = """
76
79
  SELECT s.id, s.parent_id, s.project_id, s.title, s.agent, s.model, s.cost,
77
80
  s.tokens_input, s.tokens_output, s.tokens_reasoning, s.tokens_cache_read,
78
81
  s.tokens_cache_write, s.time_created, s.time_updated
79
82
  FROM session s
80
83
  LEFT JOIN project p ON p.id = s.project_id
81
- WHERE {EFF_SQL} = ?
84
+ WHERE {eff_sql} = ?{time_filter}
82
85
  ORDER BY s.cost DESC, s.id ASC
83
86
  """
84
87
 
@@ -99,6 +102,29 @@ WHERE json_extract(data, '$.role') = 'assistant'
99
102
  """
100
103
 
101
104
 
105
+ def _time_fragment(col: str, since, until) -> tuple[str, tuple]:
106
+ """Half-open [since, until) filter on `col`. Fragment starts with ' AND '
107
+ so it appends after an existing WHERE; callers with no WHERE strip it.
108
+ Returns ("", ()) when neither bound is given."""
109
+ conds, params = [], []
110
+ if since is not None:
111
+ conds.append(f"{col} >= ?")
112
+ params.append(since)
113
+ if until is not None:
114
+ conds.append(f"{col} < ?")
115
+ params.append(until)
116
+ if not conds:
117
+ return "", ()
118
+ return " AND " + " AND ".join(conds), tuple(params)
119
+
120
+
121
+ def _rollup(since=None, until=None) -> tuple[str, tuple]:
122
+ tf, tp = _time_fragment("s.time_created", since, until)
123
+ if tf:
124
+ tf = "WHERE " + tf[5:]
125
+ return ROLLUP_SQL.format(eff_sql=EFF_SQL, time_filter=tf), tp
126
+
127
+
102
128
  def normalize_model(mid) -> str | None:
103
129
  """Strip a provider prefix if present: deepseek/deepseek-v4-flash -> deepseek-v4-flash."""
104
130
  if not mid:
@@ -174,8 +200,9 @@ def updated_at(runner) -> int:
174
200
  return int(row["m"] or 0)
175
201
 
176
202
 
177
- def overview(runner) -> dict:
178
- r = runner.query(ROLLUP_SQL)[0]
203
+ def overview(runner, since=None, until=None) -> dict:
204
+ sql, tp = _rollup(since, until)
205
+ r = runner.query(sql, tp)[0]
179
206
  return {
180
207
  "projectCount": int(r["project_count"] or 0),
181
208
  "sessionCount": int(r["session_count"] or 0),
@@ -186,22 +213,29 @@ def overview(runner) -> dict:
186
213
  }
187
214
 
188
215
 
189
- def projects(runner) -> list[dict]:
216
+ def projects(runner, since=None, until=None) -> list[dict]:
217
+ sql, tp = _rollup(since, until)
190
218
  rows = runner.query(
191
- ROLLUP_SQL + " GROUP BY id, worktree"
192
- " ORDER BY cost DESC, id ASC"
219
+ sql + " GROUP BY id, worktree"
220
+ " ORDER BY cost DESC, id ASC",
221
+ tp,
193
222
  )
194
223
  return [_project(r) for r in rows]
195
224
 
196
225
 
197
- def project_detail(runner, project_id) -> tuple[dict, list[dict]] | None:
226
+ def project_detail(runner, project_id, since=None, until=None) -> tuple[dict, list[dict]] | None:
227
+ sql, tp = _rollup(since, until)
198
228
  rows = runner.query(
199
- ROLLUP_SQL + " GROUP BY id, worktree HAVING id = ?",
200
- (project_id,),
229
+ sql + " GROUP BY id, worktree HAVING id = ?",
230
+ tp + (project_id,),
201
231
  )
202
232
  if not rows:
203
233
  return None
204
- sessions = runner.query(PROJECT_SESSIONS_SQL, (project_id,))
234
+ tf, tps = _time_fragment("s.time_created", since, until)
235
+ sessions = runner.query(
236
+ PROJECT_SESSIONS_SQL.format(eff_sql=EFF_SQL, time_filter=tf),
237
+ (project_id,) + tps,
238
+ )
205
239
  return _project(rows[0]), [_session(r) for r in sessions]
206
240
 
207
241
 
@@ -216,20 +250,22 @@ def _model_entry(d: dict) -> dict:
216
250
  }
217
251
 
218
252
 
219
- def session_detail(runner, session_id) -> tuple[dict, list[dict]] | None:
220
- rows = runner.query(SESSIONS_SQL + " WHERE id = ?", (session_id,))
253
+ def session_detail(runner, session_id, since=None, until=None) -> tuple[dict, list[dict]] | None:
254
+ tf, tp = _time_fragment("time_created", since, until)
255
+ rows = runner.query(SESSIONS_SQL + " WHERE id = ?" + tf, (session_id,) + tp)
221
256
  if not rows:
222
257
  return None
258
+ mf, mp = _time_fragment("message.time_created", since, until)
223
259
  msg_rows = runner.query_tsv(
224
- MESSAGES_SQL + " AND session_id = ? GROUP BY model_id, provider, mode",
225
- (session_id,),
260
+ MESSAGES_SQL + " AND session_id = ?" + mf + " GROUP BY model_id, provider, mode",
261
+ (session_id,) + mp,
226
262
  )
227
263
  models = [_model_entry(dict(zip(_MSG_KEYS, row))) for row in msg_rows]
228
264
  models.sort(key=lambda m: (-m["cost"], m["model"] or ""))
229
265
  return _session(rows[0]), models
230
266
 
231
267
 
232
- def models(runner) -> list[dict]:
268
+ def models(runner, since=None, until=None) -> list[dict]:
233
269
  """Whole-host per-model rollup from message.data (same shape as the
234
270
  per-session model breakdown, but aggregated across all sessions).
235
271
 
@@ -238,7 +274,10 @@ def models(runner) -> list[dict]:
238
274
  deepseek/deepseek-v4-flash vs deepseek-v4-flash) don't create duplicate
239
275
  slices. On merge, the higher-cost row keeps its provider/mode label.
240
276
  """
241
- msg_rows = runner.query_tsv(MESSAGES_SQL + " GROUP BY model_id, provider, mode")
277
+ mf, mp = _time_fragment("message.time_created", since, until)
278
+ msg_rows = runner.query_tsv(
279
+ MESSAGES_SQL + mf + " GROUP BY model_id, provider, mode", mp
280
+ )
242
281
  merged: dict[str, dict] = {}
243
282
  for row in msg_rows:
244
283
  e = _model_entry(dict(zip(_MSG_KEYS, row)))
@@ -0,0 +1,354 @@
1
+ """FastAPI app implementing the API.md contract over the opencode aggregator.
2
+
3
+ Aggregation runs against a large DB, so results are cached in memory and
4
+ refreshed on a fixed poll interval instead of being recomputed per request.
5
+
6
+ Run: uv run uvicorn app:app --reload
7
+ """
8
+
9
+ import asyncio
10
+ import importlib.metadata
11
+ import json
12
+ import logging
13
+ import os
14
+ import socket
15
+ import subprocess
16
+ import threading
17
+ import time
18
+ from collections.abc import Callable
19
+ from contextlib import asynccontextmanager
20
+ from functools import lru_cache
21
+
22
+ from fastapi import FastAPI, HTTPException, Query
23
+ from fastapi.middleware.cors import CORSMiddleware
24
+ from fastapi.responses import StreamingResponse
25
+
26
+ import aggregate
27
+ from db import CliRunner
28
+
29
+ logger = logging.getLogger("dashboard")
30
+
31
+ # Comma-separated CORS allow-list; defaults to the loopback dev origins. Tight
32
+ # by default so a random webpage can't exfiltrate local project/session data.
33
+ def default_cors_origins() -> list[str]:
34
+ env = os.environ.get("DASHBOARD_CORS_ORIGINS", "").strip()
35
+ if env:
36
+ return [o.strip() for o in env.split(",") if o.strip()]
37
+ return [
38
+ "http://localhost:5173", "http://127.0.0.1:5173",
39
+ "http://localhost:4173", "http://127.0.0.1:4173",
40
+ ]
41
+
42
+
43
+ @lru_cache(maxsize=8)
44
+ def opencode_version(executable: str = "opencode") -> str:
45
+ try:
46
+ return subprocess.run(
47
+ [executable, "--version"], capture_output=True, text=True, check=True
48
+ ).stdout.strip()
49
+ except (OSError, subprocess.CalledProcessError):
50
+ return "unknown" # e.g. CI without the opencode CLI; don't fail the request
51
+
52
+
53
+ def dashboard_version() -> str:
54
+ try:
55
+ return importlib.metadata.version("opencode-dashboard-server")
56
+ except importlib.metadata.PackageNotFoundError:
57
+ return "unknown" # e.g. pytest from a plain checkout; don't fail the request
58
+
59
+
60
+ class Cache:
61
+ """In-memory result cache keyed by (kind, id, since, until) where kind is
62
+ the endpoint, id the project/session id (None for whole-dataset endpoints),
63
+ and since/until the time-range query params (None = unbounded) — so
64
+ `/overview?since=A` and `/overview?since=B` are distinct entries and never
65
+ collide with each other or with the all-time result.
66
+
67
+ Values fill lazily on first request. Whole-dataset kinds (overview,
68
+ projects, models) are refreshed by the background poll loop across every
69
+ observed range variant (bounded); per-id kinds (project, session) are
70
+ refreshed only when a request finds them older than ttl, so the heavy
71
+ session_detail SQL runs at most once per ttl per session and _values stays
72
+ bounded. A failed refresh keeps the previous value (stale-serve); a failed
73
+ first fill propagates to the caller (500 — nothing cached yet).
74
+ """
75
+
76
+ _WHOLE = frozenset({"overview", "projects", "models"})
77
+ _MAX_PER_KIND = 512 # per-id kinds: evict oldest beyond this cap
78
+ _MAX_RANGE_VARIANTS = 16 # whole kinds: bound distinct since/until combos
79
+
80
+ def __init__(self, ttl=None):
81
+ self._lock = threading.Lock()
82
+ self._loaders: dict[str, Callable] = {}
83
+ self._values: dict[tuple, tuple] = {}
84
+ self._ranges: dict[str, list[tuple]] = {} # whole kind -> observed (since, until), oldest first
85
+ self._ttl = ttl # per-id kinds refresh on request when older than this
86
+
87
+ def register(self, kind: str, loader):
88
+ self._loaders[kind] = loader
89
+
90
+ def get(self, kind: str, key=None, since=None, until=None):
91
+ full = (kind, key, since, until)
92
+ with self._lock:
93
+ entry = self._values.get(full) # None = never cached
94
+ if entry is not None:
95
+ value, stored_at = entry
96
+ if kind in self._WHOLE or self._ttl is None \
97
+ or time.monotonic() - stored_at < self._ttl:
98
+ return value
99
+ try: # stale per-id entry: refresh on request, keep stale on failure
100
+ value = self._loaders[kind](key, since, until)
101
+ except Exception:
102
+ logger.exception("stale cache refresh failed for %s; serving stale", full)
103
+ return value
104
+ with self._lock:
105
+ self._values[full] = (value, time.monotonic())
106
+ return value
107
+ value = self._loaders[kind](key, since, until)
108
+ with self._lock:
109
+ self._values[full] = (value, time.monotonic())
110
+ self._evict_if_grown(kind, full)
111
+ return value
112
+
113
+ def refresh(self, full: tuple):
114
+ kind, key, since, until = full
115
+ value = self._loaders[kind](key, since, until)
116
+ with self._lock:
117
+ self._values[full] = (value, time.monotonic())
118
+ self._evict_if_grown(kind, full)
119
+
120
+ def _evict_if_grown(self, kind, full):
121
+ """Bound _values: whole kinds by distinct range-variant count, per-id
122
+ kinds by entry count. Drop the least-recently-stored entry."""
123
+ if kind in self._WHOLE:
124
+ ranges = self._ranges.setdefault(kind, [])
125
+ variant = full[2:]
126
+ if variant not in ranges:
127
+ ranges.append(variant)
128
+ if len(ranges) > self._MAX_RANGE_VARIANTS:
129
+ self._values.pop((kind, None) + ranges.pop(0), None)
130
+ return
131
+ entries = [k for k in self._values if k[0] == kind]
132
+ if len(entries) > self._MAX_PER_KIND:
133
+ del self._values[min(entries, key=lambda k: self._values[k][1])]
134
+
135
+ def poll_keys(self):
136
+ """Keys the background loop refreshes: whole-dataset kinds across every
137
+ observed range variant, plus the all-time variant (always included, so
138
+ pre-warm and the stream's updatedAt signal never depend on a prior
139
+ request); per-id entries are handled lazily in get()."""
140
+ with self._lock:
141
+ keys = []
142
+ for kind in self._loaders:
143
+ if kind not in self._WHOLE:
144
+ continue
145
+ variants = set(self._ranges.get(kind) or [])
146
+ variants.add((None, None)) # all-time variant always refreshed
147
+ keys += [(kind, None, since, until) for since, until in variants]
148
+ return keys
149
+
150
+ def overview(self):
151
+ """All-time overview (stream signal). updatedAt is a DB-wide max and
152
+ range-independent, so the all-time entry drives /stream correctly."""
153
+ with self._lock:
154
+ entry = self._values.get(("overview", None, None, None))
155
+ return entry[0] if entry else None
156
+
157
+
158
+ class StreamHub:
159
+ """Fan-out for /stream subscribers. The poll loop broadcasts to every
160
+ connected SSE client; clients never query the DB themselves."""
161
+
162
+ def __init__(self):
163
+ self._lock = asyncio.Lock()
164
+ self._subs: set[asyncio.Queue] = set()
165
+
166
+ async def subscribe(self) -> asyncio.Queue:
167
+ q: asyncio.Queue = asyncio.Queue()
168
+ async with self._lock:
169
+ self._subs.add(q)
170
+ return q
171
+
172
+ async def unsubscribe(self, q: asyncio.Queue) -> None:
173
+ async with self._lock:
174
+ self._subs.discard(q)
175
+
176
+ async def broadcast(self, payload) -> None:
177
+ async with self._lock:
178
+ subs = list(self._subs)
179
+ for q in subs:
180
+ q.put_nowait(payload)
181
+
182
+
183
+ def poll_event(overview, last_updated, ticks):
184
+ """Stream event for one cache-refresh tick. Emits `updated` only when the
185
+ overview's updatedAt advances; otherwise a heartbeat every 3rd tick (None
186
+ between). Returns (payload | None, new_last_updated)."""
187
+ if overview and overview.get("updatedAt") and overview["updatedAt"] != last_updated:
188
+ return {"type": "updated", "at": overview["updatedAt"], "scope": "overview"}, \
189
+ overview["updatedAt"]
190
+ if ticks % 3 == 0:
191
+ return {"type": "heartbeat"}, last_updated
192
+ return None, last_updated
193
+
194
+
195
+ def create_app(runner=None, cors_origins=None, poll_seconds=None, opencode_bin=None) -> FastAPI:
196
+ bin = opencode_bin or os.environ.get("OPENCODE_BIN") or "opencode"
197
+ if runner is None:
198
+ runner = CliRunner(executable=bin)
199
+ origins = cors_origins if cors_origins is not None else default_cors_origins()
200
+ if poll_seconds is None:
201
+ try:
202
+ poll_seconds = float(os.environ.get("DASHBOARD_POLL_SECONDS", "5"))
203
+ except ValueError:
204
+ poll_seconds = 5.0
205
+
206
+ cache = Cache(ttl=poll_seconds)
207
+ hub = StreamHub()
208
+
209
+ cache.register("overview", lambda key, since, until: (
210
+ aggregate.overview(runner, since, until)
211
+ | {"host": socket.gethostname(), "opencodeVersion": opencode_version(bin),
212
+ "dashboardVersion": dashboard_version()}
213
+ ))
214
+ cache.register("projects", lambda key, since, until: aggregate.projects(runner, since, until))
215
+ cache.register("models", lambda key, since, until: aggregate.models(runner, since, until))
216
+
217
+ def load_project(key, since, until):
218
+ res = aggregate.project_detail(runner, key, since, until)
219
+ if res is None:
220
+ return None
221
+ proj, sessions = res
222
+ return {"project": proj, "sessions": sessions}
223
+
224
+ def load_session(key, since, until):
225
+ res = aggregate.session_detail(runner, key, since, until)
226
+ if res is None:
227
+ return None
228
+ sess, models = res
229
+ return {"session": sess, "models": models}
230
+
231
+ cache.register("project", load_project)
232
+ cache.register("session", load_session)
233
+
234
+ async def poll():
235
+ loop = asyncio.get_running_loop()
236
+ last_updated = None
237
+ ticks = 0
238
+ while True:
239
+ # Only whole-dataset kinds refresh on the clock, across every
240
+ # observed range variant; per-id entries are refreshed lazily in
241
+ # Cache.get, so session_detail's heavy SQL runs once per ttl per
242
+ # session and _values stays bounded.
243
+ for full in cache.poll_keys():
244
+ try:
245
+ await loop.run_in_executor(None, cache.refresh, full)
246
+ except Exception:
247
+ logger.exception("cache refresh failed for %s; serving stale", full)
248
+ ticks += 1
249
+ event, last_updated = poll_event(cache.overview(), last_updated, ticks)
250
+ if event is not None:
251
+ await hub.broadcast(event)
252
+ # True period is refresh-time + poll_seconds (refresh is synchronous
253
+ # on the executor above, so this sleep starts only after it).
254
+ await asyncio.sleep(poll_seconds)
255
+
256
+ @asynccontextmanager
257
+ async def lifespan(app):
258
+ loop = asyncio.get_running_loop()
259
+ # Pre-warm whole-dataset keys so /stream's first updated event (and the
260
+ # first request) don't depend on someone GETting /overview first.
261
+ for full in cache.poll_keys():
262
+ try:
263
+ await loop.run_in_executor(None, cache.refresh, full)
264
+ except Exception:
265
+ logger.exception("initial cache fill failed for %s; will retry on request", full)
266
+ task = asyncio.create_task(poll())
267
+ try:
268
+ yield
269
+ finally:
270
+ task.cancel()
271
+ try:
272
+ await task
273
+ except asyncio.CancelledError:
274
+ pass
275
+
276
+ app = FastAPI(title="opencode token dashboard", lifespan=lifespan)
277
+ app.state.cache = cache
278
+ # Loopback-only API; restrict origins so a random webpage can't exfiltrate
279
+ # local project/session data from the browser (the client runs from Vite).
280
+ app.add_middleware(
281
+ CORSMiddleware,
282
+ allow_origins=origins,
283
+ allow_methods=["*"],
284
+ allow_headers=["*"],
285
+ )
286
+
287
+ def handle(fn):
288
+ try:
289
+ return fn()
290
+ except HTTPException:
291
+ raise
292
+ except Exception:
293
+ logger.exception("aggregation failed")
294
+ raise HTTPException(500, detail="aggregation failed")
295
+
296
+ @app.get("/health")
297
+ def health():
298
+ return {"status": "ok", "version": opencode_version(bin)}
299
+
300
+ @app.get("/overview")
301
+ def overview(since: int | None = Query(None), until: int | None = Query(None)):
302
+ return handle(lambda: cache.get("overview", since=since, until=until))
303
+
304
+ @app.get("/projects")
305
+ def projects(since: int | None = Query(None), until: int | None = Query(None)):
306
+ return handle(lambda: cache.get("projects", since=since, until=until))
307
+
308
+ @app.get("/models")
309
+ def models(since: int | None = Query(None), until: int | None = Query(None)):
310
+ return handle(lambda: cache.get("models", since=since, until=until))
311
+
312
+ @app.get("/projects/{project_id}")
313
+ def project(project_id: str, since: int | None = Query(None), until: int | None = Query(None)):
314
+ def run():
315
+ res = cache.get("project", project_id, since, until)
316
+ if res is None:
317
+ raise HTTPException(404, detail=f"project {project_id} not found")
318
+ return res
319
+
320
+ return handle(run)
321
+
322
+ @app.get("/sessions/{session_id}")
323
+ def session(session_id: str, since: int | None = Query(None), until: int | None = Query(None)):
324
+ def run():
325
+ res = cache.get("session", session_id, since, until)
326
+ if res is None:
327
+ raise HTTPException(404, detail=f"session {session_id} not found")
328
+ return res
329
+
330
+ return handle(run)
331
+
332
+ @app.get("/stream")
333
+ async def stream():
334
+ q = await hub.subscribe()
335
+ await q.put({"type": "heartbeat"})
336
+
337
+ async def gen():
338
+ try:
339
+ while True:
340
+ payload = await q.get()
341
+ data = json.dumps(payload)
342
+ if payload["type"] == "updated":
343
+ yield f"event: update\ndata: {data}\n\n"
344
+ else:
345
+ yield f"data: {data}\n\n"
346
+ finally:
347
+ await hub.unsubscribe(q)
348
+
349
+ return StreamingResponse(gen(), media_type="text/event-stream")
350
+
351
+ return app
352
+
353
+
354
+ app = create_app()
@@ -1,16 +1,19 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: opencode-dashboard-server
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: FastAPI aggregator over opencode's SQLite storage
5
+ License-Expression: MIT
5
6
  Project-URL: Homepage, https://github.com/GCS-ZHN/opencode-dashboard
6
7
  Project-URL: Repository, https://github.com/GCS-ZHN/opencode-dashboard
7
8
  Project-URL: Issues, https://github.com/GCS-ZHN/opencode-dashboard/issues
8
9
  Requires-Python: >=3.10
9
10
  Description-Content-Type: text/markdown
11
+ License-File: LICENSE.md
10
12
  Requires-Dist: fastapi<1,>=0.115
11
13
  Requires-Dist: uvicorn<1,>=0.30
12
14
  Requires-Dist: platformdirs<5,>=4
13
15
  Requires-Dist: PyYAML<7,>=6
16
+ Dynamic: license-file
14
17
 
15
18
  # opencode-dashboard-server
16
19
 
@@ -4,9 +4,11 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "opencode-dashboard-server"
7
- version = "0.4.0"
7
+ version = "0.5.0"
8
8
  description = "FastAPI aggregator over opencode's SQLite storage"
9
9
  readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE.md"]
10
12
  requires-python = ">=3.10"
11
13
  dependencies = [
12
14
  "fastapi>=0.115,<1",
@@ -149,3 +149,60 @@ def test_models_hostwide_rollup(runner):
149
149
  assert kimi["messageCount"] == 1 # only m4; the m5 user message is excluded
150
150
  assert kimi["tokens"] == ZERO_TOKENS
151
151
  assert kimi["cost"] == 0.0
152
+
153
+
154
+ def test_overview_time_filter(runner):
155
+ o = overview(runner, since=140000)
156
+ assert o["sessionCount"] == 4 # s4, s5, s6, s8
157
+ assert o["mainSessionCount"] == 4
158
+ assert o["projectCount"] == 3
159
+ assert o["cost"] == 4.6
160
+ assert overview(runner, since=150000)["sessionCount"] == 3
161
+ assert overview(runner, until=150000)["sessionCount"] == 5 # s1..s4 + s7
162
+
163
+
164
+ def test_overview_window_is_half_open(runner):
165
+ # s3.time_created == 115000 must NOT match until=115000
166
+ o = overview(runner, until=115000)
167
+ assert o["sessionCount"] == 2 # s1, s7
168
+ assert o["cost"] == 2.1
169
+
170
+
171
+ def test_projects_time_filter(runner):
172
+ ps = projects(runner, since=140000)
173
+ assert [p["id"] for p in ps] == ["proj-ccc", "proj-bbb", DIR_DASH]
174
+ bbb = ps[1]
175
+ assert bbb["sessionCount"] == 2 # s4, s5
176
+ assert bbb["cost"] == 0.9
177
+
178
+
179
+ def test_project_detail_time_filter(runner):
180
+ proj, sessions = project_detail(runner, "proj-aaa", since=115000)
181
+ assert proj["cost"] == 0.7 # s2 + s3 (s1 outside the window)
182
+ # s3 stays a subagent: its parent s1 is only outside the window, not the DB
183
+ assert proj["mainSessionCount"] == 1
184
+ assert [s["id"] for s in sessions] == ["s2", "s3"] # cost desc
185
+ assert project_detail(runner, "proj-aaa", since=130000) is None
186
+
187
+
188
+ def test_session_detail_time_filter(runner):
189
+ sess, ms = session_detail(runner, "s4", until=143500)
190
+ assert sess["id"] == "s4" # s4.time_created (140000) < until
191
+ # m6 (144000) excluded; m1+m2 merge into the deepseek/build entry
192
+ assert [m["model"] for m in ms] == ["deepseek-v4-flash", "claude-sonnet-4.5"]
193
+ assert ms[0]["messageCount"] == 2
194
+ assert ms[0]["cost"] == 0.6
195
+ assert ms[1]["messageCount"] == 1
196
+ assert ms[1]["cost"] == 0.3
197
+ assert session_detail(runner, "s4", since=150000) is None # session itself out of window
198
+
199
+
200
+ def test_models_message_time_filter(runner):
201
+ ms = models(runner, since=143000)
202
+ assert [m["model"] for m in ms] == ["claude-sonnet-4.5", "deepseek-v4-flash", "kimi-k3"]
203
+ assert ms[0]["cost"] == 0.3
204
+ assert ms[1]["provider"] == "openrouter" # m6 only; m1+m2 are before since
205
+ assert ms[1]["messageCount"] == 1
206
+ assert ms[1]["cost"] == 0.1
207
+ assert ms[2]["messageCount"] == 1 # m4 only
208
+ assert models(runner, since=152000) == [] # no token-bearing messages left
@@ -0,0 +1,258 @@
1
+ """HTTP-level smoke tests + the SQL-inlining boundary (CliRunner's only injection
2
+ surface). Driven via create_app(SqliteRunner) over the in-memory fixture, so no
3
+ `opencode db` CLI spawn happens."""
4
+
5
+ import asyncio
6
+ import sqlite3
7
+
8
+ import pytest
9
+ from fastapi.testclient import TestClient
10
+
11
+ import app as appmod
12
+ from db import SqliteRunner, _inline
13
+ from tests.conftest import SCHEMA, seed
14
+
15
+
16
+ def make_client() -> TestClient:
17
+ conn = sqlite3.connect(":memory:", check_same_thread=False)
18
+ conn.executescript(SCHEMA)
19
+ seed(conn)
20
+ return TestClient(appmod.create_app(SqliteRunner(conn))), conn
21
+
22
+
23
+ class CountingRunner:
24
+ """Wraps a SqliteRunner and counts query calls (proves cache hits)."""
25
+
26
+ def __init__(self, inner):
27
+ self._inner = inner
28
+ self.queries = 0
29
+
30
+ def query(self, sql, params=()):
31
+ self.queries += 1
32
+ return self._inner.query(sql, params)
33
+
34
+ def query_tsv(self, sql, params=()):
35
+ self.queries += 1
36
+ return self._inner.query_tsv(sql, params)
37
+
38
+
39
+ def test_inline_quotes_and_escapes():
40
+ sql = _inline("WHERE id = ? AND cost > ?", ("a'b\";\nDROP", 5))
41
+ assert sql == "WHERE id = 'a''b\";\nDROP' AND cost > 5"
42
+ assert 'DROP' in sql # payload stays inside one quoted literal
43
+
44
+
45
+ def test_unknown_project_404():
46
+ c, _ = make_client()
47
+ r = c.get("/projects/nope")
48
+ assert r.status_code == 404
49
+ assert r.json() == {"detail": "project nope not found"}
50
+
51
+
52
+ def test_overview_keys():
53
+ c, _ = make_client()
54
+ r = c.get("/overview")
55
+ assert r.status_code == 200
56
+ assert {"host", "opencodeVersion", "dashboardVersion", "projectCount", "sessionCount",
57
+ "tokens", "cost", "updatedAt"} <= set(r.json())
58
+
59
+
60
+ def test_models_keys():
61
+ c, _ = make_client()
62
+ r = c.get("/models")
63
+ assert r.status_code == 200
64
+ rows = r.json()
65
+ assert rows # fixture seeds assistant token-bearing messages
66
+ assert {"model", "provider", "mode", "messageCount", "tokens", "cost"} <= set(rows[0])
67
+ assert rows == sorted(rows, key=lambda m: (-m["cost"], m["model"] or ""))
68
+
69
+
70
+ def test_foreign_origin_not_allowed_by_cors():
71
+ c, _ = make_client()
72
+ r = c.options("/projects", headers={
73
+ "Origin": "https://evil.example",
74
+ "Access-Control-Request-Method": "GET",
75
+ })
76
+ assert "access-control-allow-origin" not in r.headers
77
+
78
+
79
+ def test_cors_origins_injection():
80
+ conn = sqlite3.connect(":memory:", check_same_thread=False)
81
+ conn.executescript(SCHEMA)
82
+ seed(conn)
83
+ app = appmod.create_app(SqliteRunner(conn), cors_origins=["http://a.com"])
84
+ c = TestClient(app)
85
+ ok = c.options("/projects", headers={"Origin": "http://a.com", "Access-Control-Request-Method": "GET"})
86
+ assert ok.headers.get("access-control-allow-origin") == "http://a.com"
87
+ nope = c.options("/projects", headers={"Origin": "http://a.co", "Access-Control-Request-Method": "GET"})
88
+ assert "access-control-allow-origin" not in nope.headers
89
+
90
+
91
+ def test_since_until_pass_through():
92
+ c, _ = make_client()
93
+ r = c.get("/projects", params={"since": 140000})
94
+ assert r.status_code == 200
95
+ assert [p["id"] for p in r.json()] == ["proj-ccc", "proj-bbb", "dir:2f55736572732f746573742f70726f6a656374732f6f70656e636f64652d64617368626f617264"]
96
+ r = c.get("/overview", params={"since": 140000, "until": 150000})
97
+ assert r.status_code == 200
98
+ assert r.json()["sessionCount"] == 1 # only s4
99
+ r = c.get("/sessions/s4", params={"until": 143500})
100
+ assert r.status_code == 200
101
+ assert [m["model"] for m in r.json()["models"]] == ["deepseek-v4-flash", "claude-sonnet-4.5"]
102
+ # session outside the window 404s
103
+ r = c.get("/sessions/s4", params={"since": 150000})
104
+ assert r.status_code == 404
105
+
106
+
107
+ def test_non_integer_since_is_422():
108
+ c, _ = make_client()
109
+ assert c.get("/overview", params={"since": "abc"}).status_code == 422
110
+
111
+
112
+ def make_cached_client():
113
+ """App over a counting runner so cache behavior is observable via query count.
114
+
115
+ Note: TestClient is used WITHOUT a context manager, so lifespan (background
116
+ poll + pre-warm) never runs — cache state is driven purely by the requests
117
+ below. The one lifespan-driven test is test_lifespan_prewarms_whole_dataset.
118
+ """
119
+ conn = sqlite3.connect(":memory:", check_same_thread=False)
120
+ conn.executescript(SCHEMA)
121
+ seed(conn)
122
+ counter = CountingRunner(SqliteRunner(conn))
123
+ return TestClient(appmod.create_app(counter)), conn, counter
124
+
125
+
126
+ def test_repeated_overview_served_from_cache():
127
+ c, _, counter = make_cached_client()
128
+ first = c.get("/overview")
129
+ assert first.status_code == 200
130
+ after_first = counter.queries
131
+ assert after_first > 0
132
+ second = c.get("/overview")
133
+ assert second.json() == first.json()
134
+ assert counter.queries == after_first # cache hit: no re-aggregation
135
+
136
+
137
+ def test_refresh_recomputes_overview_after_db_change():
138
+ c, conn, counter = make_cached_client()
139
+ before = c.get("/overview").json()
140
+ assert before["sessionCount"] == 8
141
+ c.get("/overview") # warm the cache
142
+ c.get("/overview")
143
+ counter.queries = 0
144
+ conn.execute(
145
+ "INSERT INTO session (id, project_id, slug, directory, title, version,"
146
+ " time_created, time_updated) VALUES ('s9','proj-aaa','s9','/x','new',"
147
+ " 'local', 200000, 200000)"
148
+ )
149
+ appmod.Cache.refresh(c.app.state.cache, ("overview", None, None, None))
150
+ counter.queries = 0
151
+ after = c.get("/overview").json()
152
+ assert after["sessionCount"] == 9
153
+ assert after["updatedAt"] == 200000
154
+ assert counter.queries == 0 # served from the refreshed cache, no request-time query
155
+
156
+
157
+ def test_failed_refresh_serves_stale_value():
158
+ c, _, counter = make_cached_client()
159
+ before = c.get("/overview").json()
160
+ c.get("/overview")
161
+ orig_query = counter._inner.query
162
+ def boom(sql, params=()):
163
+ raise RuntimeError("db down")
164
+ counter._inner.query = boom
165
+ with pytest.raises(RuntimeError):
166
+ appmod.Cache.refresh(c.app.state.cache, ("overview", None, None, None)) # refresh fails...
167
+ counter._inner.query = orig_query
168
+ after = c.get("/overview").json()
169
+ assert after == before # ...but the stale value is still served
170
+
171
+
172
+ def test_unknown_project_404_cached():
173
+ c, _, counter = make_cached_client()
174
+ assert c.get("/projects/nope").status_code == 404
175
+ after_first = counter.queries
176
+ assert after_first > 0
177
+ assert c.get("/projects/nope").status_code == 404
178
+ assert counter.queries == after_first # 404s are cached too, no re-scan
179
+
180
+
181
+ def test_first_fill_failure_returns_500():
182
+ c, _, counter = make_cached_client()
183
+ def boom(sql, params=()):
184
+ raise RuntimeError("db down")
185
+ counter._inner.query = boom
186
+ r = c.get("/overview") # nothing cached yet, so the fill failure must surface
187
+ assert r.status_code == 500
188
+ assert r.json() == {"detail": "aggregation failed"}
189
+
190
+
191
+ def test_lifespan_prewarms_whole_dataset_kinds():
192
+ """with TestClient(...) runs lifespan: whole-dataset keys are pre-warmed (no
193
+ request needed) and poll_seconds is huge so the background loop just idles."""
194
+ conn = sqlite3.connect(":memory:", check_same_thread=False)
195
+ conn.executescript(SCHEMA)
196
+ seed(conn)
197
+ counter = CountingRunner(SqliteRunner(conn))
198
+ app = appmod.create_app(counter, poll_seconds=3600)
199
+ with TestClient(app) as c:
200
+ assert counter.queries > 0 # pre-warm queried the DB with no request
201
+ assert c.app.state.cache.overview()["sessionCount"] == 8
202
+ assert {("projects", None, None, None), ("models", None, None, None)} <= set(c.app.state.cache._values)
203
+ first = c.get("/overview")
204
+ assert first.status_code == 200
205
+ assert first.json()["sessionCount"] == 8
206
+
207
+
208
+ def test_poll_event_emits_updated_on_change_only():
209
+ payload, last = appmod.poll_event({"updatedAt": 100}, None, 1)
210
+ assert payload == {"type": "updated", "at": 100, "scope": "overview"}
211
+ assert last == 100
212
+ # no change, not a heartbeat tick -> nothing
213
+ assert appmod.poll_event({"updatedAt": 100}, 100, 2) == (None, 100)
214
+ # no change on a heartbeat tick (every 3rd) -> heartbeat
215
+ assert appmod.poll_event({"updatedAt": 100}, 100, 3) == ({"type": "heartbeat"}, 100)
216
+ # uncached overview -> nothing
217
+ assert appmod.poll_event(None, None, 3) == ({"type": "heartbeat"}, None)
218
+
219
+
220
+ def test_stream_hub_fans_out_to_subscribers():
221
+ async def scenario():
222
+ hub = appmod.StreamHub()
223
+ q1, q2 = await hub.subscribe(), await hub.subscribe()
224
+ await hub.broadcast({"type": "updated", "at": 1, "scope": "overview"})
225
+ return await q1.get(), await q2.get()
226
+
227
+ e1, e2 = asyncio.run(scenario())
228
+ assert e1 == e2 == {"type": "updated", "at": 1, "scope": "overview"}
229
+
230
+
231
+
232
+ def test_cache_keys_are_range_specific():
233
+ """The cache must key by (kind, id, since, until): two different ranges (and
234
+ the all-time view) on the same endpoint must not collide."""
235
+ c, _, counter = make_cached_client()
236
+ all_time = c.get("/overview").json()
237
+ window = c.get("/overview", params={"since": 140000, "until": 150000}).json()
238
+ assert all_time["sessionCount"] == 8
239
+ assert window["sessionCount"] == 1 # only s4
240
+ assert c.get("/overview").json() == all_time # all-time entry untouched
241
+ assert c.get("/overview", params={"since": 140000, "until": 150000}).json() == window
242
+ after = counter.queries
243
+ # all variants are cached now — no re-aggregation on repeat hits
244
+ c.get("/overview")
245
+ c.get("/overview", params={"since": 140000, "until": 150000})
246
+ assert counter.queries == after
247
+ # distinct keys in the cache
248
+ keys = {k for k in c.app.state.cache._values if k[0] == "overview"}
249
+ assert ("overview", None, None, None) in keys
250
+ assert ("overview", None, 140000, 150000) in keys
251
+
252
+
253
+ def test_poll_keys_cover_observed_range_variants():
254
+ c, _, _ = make_cached_client()
255
+ c.get("/overview", params={"since": 140000})
256
+ keys = c.app.state.cache.poll_keys()
257
+ assert ("overview", None, 140000, None) in keys # observed variant refreshed
258
+ assert ("overview", None, None, None) in keys # all-time always refreshed
@@ -1,165 +0,0 @@
1
- """FastAPI app implementing the API.md contract over the opencode aggregator.
2
-
3
- Run: uv run uvicorn app:app --reload
4
- """
5
-
6
- import asyncio
7
- import json
8
- import logging
9
- import os
10
- import socket
11
- import subprocess
12
- from functools import lru_cache
13
-
14
- from fastapi import FastAPI, HTTPException
15
- from fastapi.middleware.cors import CORSMiddleware
16
- from fastapi.responses import StreamingResponse
17
-
18
- import aggregate
19
- from db import CliRunner
20
-
21
- logger = logging.getLogger("dashboard")
22
-
23
- # Comma-separated CORS allow-list; defaults to the loopback dev origins. Tight
24
- # by default so a random webpage can't exfiltrate local project/session data.
25
- def default_cors_origins() -> list[str]:
26
- env = os.environ.get("DASHBOARD_CORS_ORIGINS", "").strip()
27
- if env:
28
- return [o.strip() for o in env.split(",") if o.strip()]
29
- return [
30
- "http://localhost:5173", "http://127.0.0.1:5173",
31
- "http://localhost:4173", "http://127.0.0.1:4173",
32
- ]
33
-
34
-
35
- @lru_cache(maxsize=8)
36
- def opencode_version(executable: str = "opencode") -> str:
37
- try:
38
- return subprocess.run(
39
- [executable, "--version"], capture_output=True, text=True, check=True
40
- ).stdout.strip()
41
- except (OSError, subprocess.CalledProcessError):
42
- return "unknown" # e.g. CI without the opencode CLI; don't fail the request
43
-
44
-
45
- def create_app(runner=None, cors_origins=None, poll_seconds=None, opencode_bin=None) -> FastAPI:
46
- bin = opencode_bin or os.environ.get("OPENCODE_BIN") or "opencode"
47
- if runner is None:
48
- runner = CliRunner(executable=bin)
49
- origins = cors_origins if cors_origins is not None else default_cors_origins()
50
- if poll_seconds is None:
51
- try:
52
- poll_seconds = float(os.environ.get("DASHBOARD_POLL_SECONDS", "5"))
53
- except ValueError:
54
- poll_seconds = 5.0
55
- app = FastAPI(title="opencode token dashboard")
56
- # Loopback-only API; restrict origins so a random webpage can't exfiltrate
57
- # local project/session data from the browser (the client runs from Vite).
58
- app.add_middleware(
59
- CORSMiddleware,
60
- allow_origins=origins,
61
- allow_methods=["*"],
62
- allow_headers=["*"],
63
- )
64
-
65
- def handle(fn):
66
- try:
67
- return fn()
68
- except HTTPException:
69
- raise
70
- except Exception:
71
- logger.exception("aggregation failed")
72
- raise HTTPException(500, detail="aggregation failed")
73
-
74
- @app.get("/health")
75
- def health():
76
- return {"status": "ok", "version": opencode_version(bin)}
77
-
78
- @app.get("/overview")
79
- def overview():
80
- def run():
81
- data = aggregate.overview(runner)
82
- data["host"] = socket.gethostname()
83
- data["opencodeVersion"] = opencode_version(bin)
84
- return data
85
-
86
- return handle(run)
87
-
88
- @app.get("/projects")
89
- def projects():
90
- return handle(lambda: aggregate.projects(runner))
91
-
92
- @app.get("/models")
93
- def models():
94
- return handle(lambda: aggregate.models(runner))
95
-
96
- @app.get("/projects/{project_id}")
97
- def project(project_id: str):
98
- def run():
99
- res = aggregate.project_detail(runner, project_id)
100
- if res is None:
101
- raise HTTPException(404, detail=f"project {project_id} not found")
102
- proj, sessions = res
103
- return {"project": proj, "sessions": sessions}
104
-
105
- return handle(run)
106
-
107
- @app.get("/sessions/{session_id}")
108
- def session(session_id: str):
109
- def run():
110
- res = aggregate.session_detail(runner, session_id)
111
- if res is None:
112
- raise HTTPException(404, detail=f"session {session_id} not found")
113
- sess, models = res
114
- return {"session": sess, "models": models}
115
-
116
- return handle(run)
117
-
118
- @app.get("/stream")
119
- async def stream():
120
- q: asyncio.Queue = asyncio.Queue()
121
- stop = asyncio.Event()
122
-
123
- async def poll():
124
- loop = asyncio.get_running_loop()
125
- last = None
126
- ticks = 0
127
- while not stop.is_set():
128
- try:
129
- ts = await loop.run_in_executor(None, aggregate.updated_at, runner)
130
- if ts and ts != last:
131
- last = ts
132
- await q.put({"type": "updated", "at": ts, "scope": "overview"})
133
- else:
134
- ticks += 1
135
- if ticks % 3 == 0: # heartbeat every ~15s (poll = 5s)
136
- await q.put({"type": "heartbeat"})
137
- except Exception:
138
- logger.exception("stream poll failed")
139
- try:
140
- await asyncio.wait_for(stop.wait(), poll_seconds)
141
- except asyncio.TimeoutError:
142
- pass
143
-
144
- task = asyncio.create_task(poll())
145
- await q.put({"type": "heartbeat"})
146
-
147
- async def gen():
148
- try:
149
- while True:
150
- payload = await q.get()
151
- data = json.dumps(payload)
152
- if payload["type"] == "updated":
153
- yield f"event: update\ndata: {data}\n\n"
154
- else:
155
- yield f"data: {data}\n\n"
156
- finally:
157
- stop.set()
158
- task.cancel()
159
-
160
- return StreamingResponse(gen(), media_type="text/event-stream")
161
-
162
- return app
163
-
164
-
165
- app = create_app()
@@ -1,70 +0,0 @@
1
- """HTTP-level smoke tests + the SQL-inlining boundary (CliRunner's only injection
2
- surface). Driven via create_app(SqliteRunner) over the in-memory fixture, so no
3
- `opencode db` CLI spawn happens."""
4
-
5
- import sqlite3
6
-
7
- from fastapi.testclient import TestClient
8
-
9
- import app as appmod
10
- from db import SqliteRunner, _inline
11
- from tests.conftest import SCHEMA, seed
12
-
13
-
14
- def make_client() -> TestClient:
15
- conn = sqlite3.connect(":memory:", check_same_thread=False)
16
- conn.executescript(SCHEMA)
17
- seed(conn)
18
- return TestClient(appmod.create_app(SqliteRunner(conn))), conn
19
-
20
-
21
- def test_inline_quotes_and_escapes():
22
- sql = _inline("WHERE id = ? AND cost > ?", ("a'b\";\nDROP", 5))
23
- assert sql == "WHERE id = 'a''b\";\nDROP' AND cost > 5"
24
- assert 'DROP' in sql # payload stays inside one quoted literal
25
-
26
-
27
- def test_unknown_project_404():
28
- c, _ = make_client()
29
- r = c.get("/projects/nope")
30
- assert r.status_code == 404
31
- assert r.json() == {"detail": "project nope not found"}
32
-
33
-
34
- def test_overview_keys():
35
- c, _ = make_client()
36
- r = c.get("/overview")
37
- assert r.status_code == 200
38
- assert {"host", "opencodeVersion", "projectCount", "sessionCount",
39
- "tokens", "cost", "updatedAt"} <= set(r.json())
40
-
41
-
42
- def test_models_keys():
43
- c, _ = make_client()
44
- r = c.get("/models")
45
- assert r.status_code == 200
46
- rows = r.json()
47
- assert rows # fixture seeds assistant token-bearing messages
48
- assert {"model", "provider", "mode", "messageCount", "tokens", "cost"} <= set(rows[0])
49
- assert rows == sorted(rows, key=lambda m: (-m["cost"], m["model"] or ""))
50
-
51
-
52
- def test_foreign_origin_not_allowed_by_cors():
53
- c, _ = make_client()
54
- r = c.options("/projects", headers={
55
- "Origin": "https://evil.example",
56
- "Access-Control-Request-Method": "GET",
57
- })
58
- assert "access-control-allow-origin" not in r.headers
59
-
60
-
61
- def test_cors_origins_injection():
62
- conn = sqlite3.connect(":memory:", check_same_thread=False)
63
- conn.executescript(SCHEMA)
64
- seed(conn)
65
- app = appmod.create_app(SqliteRunner(conn), cors_origins=["http://a.com"])
66
- c = TestClient(app)
67
- ok = c.options("/projects", headers={"Origin": "http://a.com", "Access-Control-Request-Method": "GET"})
68
- assert ok.headers.get("access-control-allow-origin") == "http://a.com"
69
- nope = c.options("/projects", headers={"Origin": "http://a.co", "Access-Control-Request-Method": "GET"})
70
- assert "access-control-allow-origin" not in nope.headers