ework-web 0.10.9 → 0.10.11

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ework-web",
3
- "version": "0.10.9",
3
+ "version": "0.10.11",
4
4
  "type": "module",
5
5
  "description": "ework-web — standalone multi-project issue tracker. Local SQLite-backed, no external API dependency. Bun + TypeScript + SSR HTML.",
6
6
  "license": "MIT",
@@ -121,7 +121,7 @@ function buildSetupScript(envBlock: string, daemonPort: number, mysqlHostRaw: st
121
121
  `sleep 3`,
122
122
  `STATUS=$(curl -sf --max-time 5 http://127.0.0.1:${String(daemonPort)}/api/status 2>/dev/null) || { echo "DAEMON_FAILED: status check failed"; echo "=== daemon log (last 20 lines) ==="; tail -20 ~/.local/share/ework-aio/run/daemon.log 2>/dev/null || echo "(no log)"; exit 1; }`,
123
123
  `echo "$STATUS"`,
124
- `echo "$STATUS" | grep -q '"driver":"mysql"' || { echo "DAEMON_FAILED: daemon is NOT on MySQL (still SQLite?)"; tail -20 ~/.local/share/ework-aio/run/daemon.log 2>/dev/null; exit 1; }`,
124
+ `echo "$STATUS" | grep -Eq '"driver" *: *"mysql"' || { echo "DAEMON_FAILED: daemon is NOT on MySQL (still SQLite?)"; tail -20 ~/.local/share/ework-aio/run/daemon.log 2>/dev/null; exit 1; }`,
125
125
  `echo "=== hostname: $(hostname) ==="`,
126
126
  `echo DAEMON_STARTED`,
127
127
  ].join("\n");
package/src/index.ts CHANGED
@@ -1004,6 +1004,41 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1004
1004
  });
1005
1005
  }
1006
1006
 
1007
+ if (url.pathname === "/api/router/daemons" && req.method === "GET") {
1008
+ if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
1009
+ try {
1010
+ const routerUrl = cfg.daemonWebhookUrl.replace(/\/$/, "");
1011
+ const res = await fetch(`${routerUrl}/api/daemons`, { signal: AbortSignal.timeout(5000) });
1012
+ const data = await res.json();
1013
+ return json(data);
1014
+ } catch (e) {
1015
+ return json({ daemons: [], error: errMsg(e) });
1016
+ }
1017
+ }
1018
+
1019
+ if (url.pathname === "/api/router/strategy") {
1020
+ if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
1021
+ const routerUrl = cfg.daemonWebhookUrl.replace(/\/$/, "");
1022
+ try {
1023
+ if (req.method === "GET") {
1024
+ const res = await fetch(`${routerUrl}/api/strategy`, { signal: AbortSignal.timeout(5000) });
1025
+ return json(await res.json());
1026
+ }
1027
+ if (req.method === "POST") {
1028
+ const body = await req.text();
1029
+ const res = await fetch(`${routerUrl}/api/strategy`, {
1030
+ method: "POST",
1031
+ headers: { "Content-Type": "application/json" },
1032
+ body,
1033
+ signal: AbortSignal.timeout(5000),
1034
+ });
1035
+ return json(await res.json(), res.ok ? 200 : 400);
1036
+ }
1037
+ } catch (e) {
1038
+ return json({ error: errMsg(e) }, 502);
1039
+ }
1040
+ }
1041
+
1007
1042
  if (url.pathname === "/settings") {
1008
1043
  if (req.method === "GET") {
1009
1044
  return html(buildSettingsPage(cfg, url.searchParams.get("saved") === "1", ctx.user!, await listCachedModels()).html);
package/src/opencode.ts CHANGED
@@ -84,7 +84,16 @@ export class OpencodeClient {
84
84
  private readonly bin: string;
85
85
  private readonly dbPath: string;
86
86
  private readonly timeoutMs: number;
87
- private readonly maxBytes = 50 * 1024 * 1024;
87
+ private readonly maxBytes = 100 * 1024 * 1024;
88
+ // exportSession is called on every page view, poll (/since), and load-more
89
+ // (/batch). For huge sessions (7000+ msgs → 50MB+ JSON) re-running the
90
+ // opencode export subprocess per request hammers the server. Cache the parsed
91
+ // result briefly so repeated requests within a viewing session reuse it.
92
+ // Staleness: a poll misses within CACHE_TTL_MS, so new messages appear with
93
+ // at most that delay — acceptable for a session viewer (not real-time-critical).
94
+ private readonly exportCache = new Map<string, { data: SessionExport; expires: number }>();
95
+ private static readonly CACHE_TTL_MS = 30_000;
96
+ private static readonly CACHE_MAX = 2;
88
97
 
89
98
  constructor(cfg: Config) {
90
99
  this.bin = cfg.opencodeBin;
@@ -142,9 +151,33 @@ export class OpencodeClient {
142
151
  if (!/^[A-Za-z0-9_-]+$/.test(id)) {
143
152
  throw new OpencodeError(`bad session id: ${id}`, 400);
144
153
  }
145
- const raw = await this.runJSON(["export", id]);
146
- const exp = parseSessionExport(raw);
147
- if (!exp) throw new OpencodeError(`malformed export for ${id}`, 502);
154
+ const now = Date.now();
155
+ const hit = this.exportCache.get(id);
156
+ if (hit && hit.expires > now) return hit.data;
157
+ // Prefer a direct read-only DB pass: no subprocess, no 50MB stdout cap, no
158
+ // per-poll re-export cost beyond the SQL + JSON.parse. Fall back to the
159
+ // `opencode export` CLI if the DB path rejects (e.g. schema drift after an
160
+ // opencode upgrade) so the viewer stays robust.
161
+ let exp: SessionExport | null = null;
162
+ try {
163
+ exp = this.exportSessionFromDB(id);
164
+ } catch (e) {
165
+ if (e instanceof OpencodeError && (e.status === 400 || e.status === 404)) throw e;
166
+ }
167
+ if (!exp) {
168
+ const raw = await this.runJSON(["export", id]);
169
+ exp = parseSessionExport(raw);
170
+ if (!exp) throw new OpencodeError(`malformed export for ${id}`, 502);
171
+ }
172
+ if (this.exportCache.size >= OpencodeClient.CACHE_MAX) {
173
+ let oldestKey: string | null = null;
174
+ let oldestExp = Infinity;
175
+ for (const [k, v] of this.exportCache) {
176
+ if (v.expires < oldestExp) { oldestExp = v.expires; oldestKey = k; }
177
+ }
178
+ if (oldestKey) this.exportCache.delete(oldestKey);
179
+ }
180
+ this.exportCache.set(id, { data: exp, expires: now + OpencodeClient.CACHE_TTL_MS });
148
181
  return exp;
149
182
  }
150
183
 
@@ -159,12 +192,6 @@ export class OpencodeClient {
159
192
  return stdout;
160
193
  }
161
194
 
162
- // List available models from `opencode models`. Output is plain text — one
163
- // `provider/model` per line — with plugin banners (e.g. "[opencode-ework]
164
- // registered 5 tools: ...") on stderr. We strip any line that doesn't
165
- // match the `provider/model` shape, dedupe, sort. Errors (binary missing,
166
- // non-zero exit) return an empty array — the settings UI degrades to a
167
- // free-text input.
168
195
  async listModels(): Promise<string[]> {
169
196
  try {
170
197
  const { stdout, code } = await this.run(["models"]);
@@ -189,6 +216,65 @@ export class OpencodeClient {
189
216
  }
190
217
  }
191
218
 
219
+ // Direct read-only DB pass producing the same SessionExport shape as the CLI,
220
+ // so the rest of the renderer is unchanged. message.data carries role/agent/
221
+ // time/tokens; part.data carries {type,text/tool/state}. User messages store
222
+ // the model as a bare `model` string, assistant messages as `modelID` — accept
223
+ // either. 404 (no such session) propagates so the caller can return a real
224
+ // not-found; other query errors bubble up to trigger the CLI fallback.
225
+ private exportSessionFromDB(id: string): SessionExport {
226
+ let db: Database;
227
+ try {
228
+ db = new Database(this.dbPath, { readonly: true });
229
+ } catch (e) {
230
+ const msg = e instanceof Error ? e.message : String(e);
231
+ throw new OpencodeError(`cannot open opencode DB (${this.dbPath}): ${msg}`, 502);
232
+ }
233
+ try {
234
+ const srow = db
235
+ .prepare("SELECT id, title, directory, version, time_created, time_updated FROM session WHERE id = ?")
236
+ .get(id) as { id: string; title: string; directory: string; version: string; time_created: number; time_updated: number } | null;
237
+ if (!srow) throw new OpencodeError(`session not found: ${id}`, 404);
238
+ const info: SessionInfo = {
239
+ id: srow.id,
240
+ title: srow.title || "(untitled)",
241
+ directory: srow.directory ?? "",
242
+ version: srow.version ?? "",
243
+ time: { created: srow.time_created, updated: srow.time_updated },
244
+ };
245
+ const mrows = db
246
+ .prepare("SELECT id, data FROM message WHERE session_id = ? ORDER BY time_created, id")
247
+ .all(id) as Array<{ id: string; data: string }>;
248
+ const prows = db
249
+ .prepare("SELECT message_id, data FROM part WHERE session_id = ? ORDER BY message_id, id")
250
+ .all(id) as Array<{ message_id: string; data: string }>;
251
+ const partsByMsg = new Map<string, MessagePart[]>();
252
+ for (const p of prows) {
253
+ let pd: unknown;
254
+ try { pd = JSON.parse(p.data); } catch { continue; }
255
+ const part = parsePart(pd);
256
+ if (!part) continue;
257
+ const arr = partsByMsg.get(p.message_id);
258
+ if (arr) arr.push(part); else partsByMsg.set(p.message_id, [part]);
259
+ }
260
+ const messages: SessionMessage[] = [];
261
+ for (const m of mrows) {
262
+ let md: unknown;
263
+ try { md = JSON.parse(m.data); } catch { continue; }
264
+ const mi = parseMessageInfoDB(m.id, md);
265
+ if (!mi) continue;
266
+ messages.push({ info: mi, parts: partsByMsg.get(m.id) ?? [] });
267
+ }
268
+ return { info, messages };
269
+ } catch (e) {
270
+ if (e instanceof OpencodeError) throw e;
271
+ const msg = e instanceof Error ? e.message : String(e);
272
+ throw new OpencodeError(`session export query failed: ${msg}`, 502);
273
+ } finally {
274
+ db.close();
275
+ }
276
+ }
277
+
192
278
  // --- subprocess plumbing ---
193
279
 
194
280
  private async runJSON(args: string[]): Promise<unknown> {
@@ -447,6 +533,26 @@ function parseSessionInfo(v: unknown): SessionInfo | null {
447
533
  };
448
534
  }
449
535
 
536
+ // DB-direct counterpart of parseMessageInfo: message.data stores the model as a
537
+ // bare `model` string (user msgs) or `modelID` string (assistant msgs) — accept
538
+ // either so both roles render their model label correctly.
539
+ function parseMessageInfoDB(id: string, v: unknown): MessageInfo | null {
540
+ if (!v || typeof v !== "object") return null;
541
+ const o = v as Record<string, unknown>;
542
+ const role = typeof o.role === "string" ? o.role : "";
543
+ if (!role) return null;
544
+ const timeRaw = o.time && typeof o.time === "object" ? (o.time as Record<string, unknown>) : null;
545
+ const modelID = typeof o.modelID === "string" ? o.modelID : (typeof o.model === "string" ? o.model : undefined);
546
+ return {
547
+ role,
548
+ id,
549
+ agent: typeof o.agent === "string" ? o.agent : undefined,
550
+ modelID,
551
+ time: timeRaw && typeof timeRaw.created === "number" ? { created: timeRaw.created } : undefined,
552
+ tokens: parseTokens(o.tokens),
553
+ };
554
+ }
555
+
450
556
  function parseMessage(v: unknown): SessionMessage | null {
451
557
  if (!v || typeof v !== "object") return null;
452
558
  const o = v as Record<string, unknown>;
@@ -0,0 +1,100 @@
1
+ (async function() {
2
+ const tbody = document.getElementById("groups-tbody");
3
+ const bindingsList = document.getElementById("bindings-list");
4
+ const strategySelect = document.getElementById("strategy-select");
5
+ const result = document.getElementById("strategy-result");
6
+ const saveBtn = document.getElementById("strategy-save");
7
+ const addBindingBtn = document.getElementById("binding-add");
8
+
9
+ let currentStrategy = { strategy: "least-loaded", groupBindings: {}, daemonGroups: {} };
10
+
11
+ function showResult(msg, ok) {
12
+ result.textContent = msg;
13
+ result.className = "db-result " + (ok ? "db-ok" : "db-err");
14
+ setTimeout(() => { result.textContent = ""; result.className = "db-result"; }, 3000);
15
+ }
16
+
17
+ async function load() {
18
+ try {
19
+ const [daemonsRes, strategyRes] = await Promise.all([
20
+ fetch("/api/router/daemons").then(r => r.json()),
21
+ fetch("/api/router/strategy").then(r => r.json()),
22
+ ]);
23
+ const daemons = daemonsRes.daemons || [];
24
+ currentStrategy = strategyRes;
25
+ strategySelect.value = currentStrategy.strategy || "least-loaded";
26
+
27
+ tbody.innerHTML = daemons.map(d => {
28
+ const groups = (currentStrategy.daemonGroups || {})[d.id] || [];
29
+ return `<tr>
30
+ <td>${d.id}</td>
31
+ <td>${d.displayName || "?"}<br><span style="font-size:11px;color:var(--text-muted)">${d.endpoint}</span></td>
32
+ <td><span class="pill ${d.status}">${d.status}</span></td>
33
+ <td><input type="text" data-daemon-id="${d.id}" value="${groups.join(", ")}" placeholder="group-a, group-b" style="width:100%;padding:.25rem .4rem;border:1px solid var(--border);border-radius:4px;background:var(--bg);color:var(--text);font-size:12px"></td>
34
+ </tr>`;
35
+ }).join("") || '<tr><td colspan="4" class="daemon-empty">没有已注册的 daemon(节点启动后自动出现)</td></tr>';
36
+
37
+ renderBindings();
38
+ } catch (e) {
39
+ tbody.innerHTML = '<tr><td colspan="4" class="daemon-empty">无法连接 router — 确认 router 已启动</td></tr>';
40
+ }
41
+ }
42
+
43
+ function renderBindings() {
44
+ const bindings = currentStrategy.groupBindings || {};
45
+ const keys = Object.keys(bindings);
46
+ bindingsList.innerHTML = keys.length === 0
47
+ ? '<p class="hint" style="margin:0 0 .5rem">暂无绑定</p>'
48
+ : keys.map(k => `<div style="display:flex;align-items:center;gap:.5rem;margin:.3rem 0;font-size:13px">
49
+ <code>${k}</code> → <span style="color:var(--accent)">${bindings[k]}</span>
50
+ <button type="button" class="secondary" data-binding-key="${k}" style="padding:.2rem .5rem;font-size:11px">删除</button>
51
+ </div>`).join("");
52
+
53
+ bindingsList.querySelectorAll("button[data-binding-key]").forEach(btn => {
54
+ btn.onclick = () => {
55
+ delete currentStrategy.groupBindings[btn.dataset.bindingKey];
56
+ renderBindings();
57
+ };
58
+ });
59
+ }
60
+
61
+ addBindingBtn.onclick = () => {
62
+ const repo = document.getElementById("binding-repo").value.trim();
63
+ const group = document.getElementById("binding-group").value.trim();
64
+ if (!repo || !group) return;
65
+ if (!currentStrategy.groupBindings) currentStrategy.groupBindings = {};
66
+ currentStrategy.groupBindings[repo] = group;
67
+ document.getElementById("binding-repo").value = "";
68
+ document.getElementById("binding-group").value = "";
69
+ renderBindings();
70
+ };
71
+
72
+ saveBtn.onclick = async () => {
73
+ currentStrategy.strategy = strategySelect.value;
74
+ const newGroups = {};
75
+ tbody.querySelectorAll("input[data-daemon-id]").forEach(input => {
76
+ const id = parseInt(input.dataset.daemonId, 10);
77
+ const groups = input.value.split(",").map(s => s.trim()).filter(Boolean);
78
+ if (groups.length > 0) newGroups[id] = groups;
79
+ });
80
+ currentStrategy.daemonGroups = newGroups;
81
+
82
+ try {
83
+ const res = await fetch("/api/router/strategy", {
84
+ method: "POST",
85
+ headers: { "Content-Type": "application/json" },
86
+ body: JSON.stringify(currentStrategy),
87
+ });
88
+ const data = await res.json();
89
+ if (res.ok) {
90
+ showResult("✓ 策略已保存", true);
91
+ } else {
92
+ showResult("✗ " + (data.error || "保存失败"), false);
93
+ }
94
+ } catch (e) {
95
+ showResult("✗ " + e.message, false);
96
+ }
97
+ };
98
+
99
+ load();
100
+ })();
@@ -170,6 +170,44 @@ ${modelRefreshForm}
170
170
  ${ttsLink}
171
171
  ${buildDbSection(viewer)}
172
172
  ${buildDaemonSection(viewer)}
173
+ ${viewer.is_admin === 1 ? buildGroupsSection() : ""}
173
174
  </main></body></html>`;
174
175
  return { html };
175
176
  }
177
+
178
+ function buildGroupsSection(): string {
179
+ return `
180
+ <section class="sg" id="groups-section">
181
+ <h2>Daemon 分组与路由策略</h2>
182
+ <p class="hint">配置 daemon 分组和 webhook 派发策略。Router 自动摘除心跳超时的节点(120s)。</p>
183
+ <div class="sf">
184
+ <span>路由策略</span>
185
+ <select id="strategy-select">
186
+ <option value="least-loaded">least-loaded(负载最低优先)</option>
187
+ <option value="round-robin">round-robin(轮询)</option>
188
+ <option value="first-available">first-available(按 ID 顺序)</option>
189
+ <option value="group">group(按分组路由)</option>
190
+ </select>
191
+ </div>
192
+ <div id="daemon-groups-editor" style="margin-top:.7rem">
193
+ <table class="daemon-list" id="groups-table">
194
+ <thead><tr><th>ID</th><th>节点</th><th>状态</th><th>分组</th></tr></thead>
195
+ <tbody id="groups-tbody"></tbody>
196
+ </table>
197
+ </div>
198
+ <div id="bindings-editor" style="margin-top:.7rem">
199
+ <h2 style="margin-bottom:.5rem">Repo → 分组绑定</h2>
200
+ <div id="bindings-list"></div>
201
+ <div class="db-controls">
202
+ <input type="text" id="binding-repo" placeholder="owner/repo" style="padding:.35rem .5rem;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text);font-size:13px;flex:1">
203
+ <input type="text" id="binding-group" placeholder="group-name" style="padding:.35rem .5rem;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text);font-size:13px;flex:1">
204
+ <button type="button" id="binding-add" class="secondary">添加绑定</button>
205
+ </div>
206
+ </div>
207
+ <div class="db-controls" style="margin-top:.7rem">
208
+ <button type="button" id="strategy-save">保存策略</button>
209
+ </div>
210
+ <div class="db-result" id="strategy-result"></div>
211
+ </section>
212
+ <script src="/static/daemon-groups.js"></script>`;
213
+ }