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 +1 -1
- package/src/daemon-deploy.ts +5 -1
- package/src/opencode.ts +116 -10
package/package.json
CHANGED
package/src/daemon-deploy.ts
CHANGED
|
@@ -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 -
|
|
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 =
|
|
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>;
|