ework-web 0.10.10 → 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 +1 -1
- package/src/daemon-deploy.ts +1 -1
- package/src/opencode.ts +116 -10
package/package.json
CHANGED
package/src/daemon-deploy.ts
CHANGED
|
@@ -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 -
|
|
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/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 =
|
|
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
|
|
146
|
-
const
|
|
147
|
-
if (
|
|
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>;
|