ework-web 0.10.10 → 0.10.12

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.10",
3
+ "version": "0.10.12",
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",
@@ -77,6 +77,7 @@ function buildEnvBlock(env: Map<string, string>, target: DeployTarget): string {
77
77
  for (const k of FORWARD_KEYS) {
78
78
  const v = env.get(k);
79
79
  if (v === undefined) continue;
80
+ if (k === "OPENCODE_BINARY") continue;
80
81
  lines.push(`${k}=${v}`);
81
82
  }
82
83
  const mysqlHost = String(target.mysqlHost).replace(/:\d+$/, "");
@@ -102,6 +103,9 @@ function buildSetupScript(envBlock: string, daemonPort: number, mysqlHostRaw: st
102
103
  `timeout 5 bash -c "echo > /dev/tcp/${mysqlHost}/${mysqlPort}" 2>/dev/null && echo "MySQL port reachable" || { echo "MySQL UNREACHABLE at ${mysqlHost}:${mysqlPort} — daemon will fail to connect"; exit 1; }`,
103
104
  `echo "[2/5] installing ework-aio..."`,
104
105
  `npm install -g ework-aio`,
106
+ `echo "[2.5/5] ensuring opencode is available..."`,
107
+ `command -v opencode >/dev/null 2>&1 || { echo "installing opencode..."; curl -fsSL https://opencode.ai/install | bash; }`,
108
+ `mkdir -p ~/.local/share/ework-aio/opencode-workdir`,
105
109
  `echo "[3/5] writing daemon config..."`,
106
110
  `mkdir -p ~/.local/share/ework-aio/ework-daemon`,
107
111
  `REMOTE_IP=$(hostname -I 2>/dev/null | awk '{print $1}')`,
@@ -121,7 +125,7 @@ function buildSetupScript(envBlock: string, daemonPort: number, mysqlHostRaw: st
121
125
  `sleep 3`,
122
126
  `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
127
  `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; }`,
128
+ `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
129
  `echo "=== hostname: $(hostname) ==="`,
126
130
  `echo DAEMON_STARTED`,
127
131
  ].join("\n");
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>;