ework-web 0.4.5 → 0.5.1
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/coordination.ts +32 -0
- package/src/index.ts +26 -4
- package/src/opencode.ts +112 -0
- package/src/views/sessionLog.ts +3 -3
package/package.json
CHANGED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { getDB } from "./db";
|
|
2
|
+
import { log } from "./logger";
|
|
3
|
+
|
|
4
|
+
export interface DaemonInfo {
|
|
5
|
+
id: number;
|
|
6
|
+
displayName: string;
|
|
7
|
+
endpoint: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const HEARTBEAT_STALE_MS = 120_000;
|
|
11
|
+
|
|
12
|
+
export async function getActiveDaemons(): Promise<DaemonInfo[]> {
|
|
13
|
+
const stale = new Date(Date.now() - HEARTBEAT_STALE_MS);
|
|
14
|
+
const staleStr = stale.toISOString().slice(0, 19).replace("T", " ");
|
|
15
|
+
try {
|
|
16
|
+
const rows = await getDB().all<{ id: number; display_name: string; internal_endpoint: string }>(
|
|
17
|
+
`SELECT id, display_name, internal_endpoint FROM {{d_daemons}} WHERE status = 'active' AND last_heartbeat > ?`,
|
|
18
|
+
[staleStr],
|
|
19
|
+
);
|
|
20
|
+
return rows
|
|
21
|
+
.filter((r): r is { id: number; display_name: string; internal_endpoint: string } =>
|
|
22
|
+
r.internal_endpoint !== null && r.internal_endpoint !== undefined && r.internal_endpoint !== "")
|
|
23
|
+
.map((r) => ({
|
|
24
|
+
id: r.id,
|
|
25
|
+
displayName: r.display_name ?? `daemon-${r.id}`,
|
|
26
|
+
endpoint: r.internal_endpoint,
|
|
27
|
+
}));
|
|
28
|
+
} catch (e) {
|
|
29
|
+
log.info(`coordination: query failed (${e instanceof Error ? e.message : String(e)}) — assuming single-machine`);
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -6,10 +6,11 @@ import { homedir } from "os";
|
|
|
6
6
|
import { loadConfig, DB_OVERRIDABLE, parseOverride, resolveTtsBackend } from "./config";
|
|
7
7
|
import type { Config } from "./config";
|
|
8
8
|
import { setConfig, initDB } from "./db";
|
|
9
|
+
import { getActiveDaemons } from "./coordination";
|
|
9
10
|
import { testMysqlConnection, migrateSqliteToMysql, writeMysqlEnv, migrateMysqlToSqlite, writeSqliteEnv, migrateDaemonSqliteToMysql } from "./db-admin";
|
|
10
11
|
import type { MysqlTargetOpts } from "./db-admin";
|
|
11
12
|
import { checkAuth, makeAuthCookieHeader, clearAuthCookieHeader, loginHTML, sanitizeNext, ensureBootstrapAdmin, ensureBootstrapSystem, isReservedSystemLogin } from "./auth";
|
|
12
|
-
import {
|
|
13
|
+
import { OpencodeError, createOpencodeClient, MultiDaemonOpencodeClient, RemoteOpencodeClient, isLocalhost, type OpencodeClientInterface } from "./opencode";
|
|
13
14
|
import { renderMarkdown } from "./render/markdown";
|
|
14
15
|
import { log, uptimeSeconds, version } from "./logger";
|
|
15
16
|
import { buildIssueThread, fetchIssuePage, fetchIssueSince, errorPage } from "./views/issueThread";
|
|
@@ -95,7 +96,24 @@ const STATIC_DIR = join(__dirname, "static");
|
|
|
95
96
|
|
|
96
97
|
await initDB();
|
|
97
98
|
const cfg: Config = await loadConfig();
|
|
98
|
-
|
|
99
|
+
let opencode: OpencodeClientInterface = createOpencodeClient(cfg, cfg.daemonWebhookUrl);
|
|
100
|
+
|
|
101
|
+
async function refreshOpencodeClient(): Promise<void> {
|
|
102
|
+
const daemons = await getActiveDaemons();
|
|
103
|
+
const remote = daemons.filter((d) => !isLocalhost(d.endpoint));
|
|
104
|
+
if (remote.length > 1) {
|
|
105
|
+
opencode = new MultiDaemonOpencodeClient(remote.map((d) => d.endpoint));
|
|
106
|
+
log.info(`opencode client: multi-daemon (${remote.length} remotes)`);
|
|
107
|
+
} else if (remote.length === 1) {
|
|
108
|
+
opencode = new RemoteOpencodeClient(remote[0]!.endpoint);
|
|
109
|
+
log.info(`opencode client: single remote (${remote[0]!.endpoint})`);
|
|
110
|
+
} else {
|
|
111
|
+
opencode = createOpencodeClient(cfg, cfg.daemonWebhookUrl);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
await refreshOpencodeClient();
|
|
116
|
+
setInterval(refreshOpencodeClient, 10_000);
|
|
99
117
|
|
|
100
118
|
async function autoWireDaemon(projectId: number, origin: string): Promise<void> {
|
|
101
119
|
if (!cfg.autowireActive) {
|
|
@@ -252,8 +270,12 @@ function daemonEnvPath(): string | null {
|
|
|
252
270
|
function readDaemonSqlitePath(envPath: string): string | null {
|
|
253
271
|
try {
|
|
254
272
|
const content = readFileSync(envPath, "utf8");
|
|
255
|
-
const m = content.match(/^DAEMON_DB_PATH\s*=\s*(.+)$/m);
|
|
256
|
-
|
|
273
|
+
const m = content.match(/^(?:WORK_DB_PATH|DAEMON_DB_PATH)\s*=\s*(.+)$/m);
|
|
274
|
+
if (m?.[1]) return m[1].trim();
|
|
275
|
+
// Neither key in .env — replicate the daemon's PRODUCTION_DB_DEFAULT
|
|
276
|
+
// (config.ts:61) so we can find its SQLite DB for migration.
|
|
277
|
+
const xdg = process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share");
|
|
278
|
+
return join(xdg, "ework-daemon", "ework-daemon.db");
|
|
257
279
|
} catch {
|
|
258
280
|
return null;
|
|
259
281
|
}
|
package/src/opencode.ts
CHANGED
|
@@ -250,6 +250,118 @@ export class OpencodeClient {
|
|
|
250
250
|
}
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
+
export interface OpencodeClientInterface {
|
|
254
|
+
listSessions(limit: number): Promise<SessionListItem[]>;
|
|
255
|
+
exportSession(id: string): Promise<SessionExport>;
|
|
256
|
+
exportSessionRaw(id: string): Promise<string>;
|
|
257
|
+
listModels(): Promise<string[]>;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export class RemoteOpencodeClient implements OpencodeClientInterface {
|
|
261
|
+
private readonly base: string;
|
|
262
|
+
|
|
263
|
+
constructor(endpoint: string) {
|
|
264
|
+
this.base = endpoint.replace(/\/$/, "");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async listSessions(limit: number): Promise<SessionListItem[]> {
|
|
268
|
+
const res = await fetch(`${this.base}/api/opencode/sessions?limit=${limit}`);
|
|
269
|
+
if (!res.ok) throw new OpencodeError(`remote listSessions → ${res.status}`, res.status);
|
|
270
|
+
return (await res.json()) as SessionListItem[];
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async exportSession(id: string): Promise<SessionExport> {
|
|
274
|
+
if (!/^[A-Za-z0-9_-]+$/.test(id)) throw new OpencodeError(`bad session id: ${id}`, 400);
|
|
275
|
+
const res = await fetch(`${this.base}/api/opencode/sessions/${id}/export`);
|
|
276
|
+
if (!res.ok) throw new OpencodeError(`remote exportSession → ${res.status}`, res.status);
|
|
277
|
+
const raw = await res.json();
|
|
278
|
+
const exp = parseSessionExport(raw);
|
|
279
|
+
if (!exp) throw new OpencodeError(`malformed remote export for ${id}`, 502);
|
|
280
|
+
return exp;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async exportSessionRaw(id: string): Promise<string> {
|
|
284
|
+
if (!/^[A-Za-z0-9_-]+$/.test(id)) throw new OpencodeError(`bad session id: ${id}`, 400);
|
|
285
|
+
const res = await fetch(`${this.base}/api/opencode/sessions/${id}/raw`);
|
|
286
|
+
if (!res.ok) throw new OpencodeError(`remote exportSessionRaw → ${res.status}`, res.status);
|
|
287
|
+
const data = (await res.json()) as { raw?: string };
|
|
288
|
+
return data.raw ?? "";
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async listModels(): Promise<string[]> {
|
|
292
|
+
return [];
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function isLocalhost(endpoint: string): boolean {
|
|
297
|
+
const host = endpoint.replace(/^https?:\/\//, "").split(":")[0] ?? "";
|
|
298
|
+
return /^(127\.|localhost$|0\.0\.0\.0$|::1$|\[::1\]$)/.test(host);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export class MultiDaemonOpencodeClient implements OpencodeClientInterface {
|
|
302
|
+
private readonly clients: RemoteOpencodeClient[];
|
|
303
|
+
|
|
304
|
+
constructor(endpoints: string[]) {
|
|
305
|
+
this.clients = endpoints.map((ep) => new RemoteOpencodeClient(ep));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async listSessions(limit: number): Promise<SessionListItem[]> {
|
|
309
|
+
const results = await Promise.allSettled(
|
|
310
|
+
this.clients.map((c) => c.listSessions(limit)),
|
|
311
|
+
);
|
|
312
|
+
const all: SessionListItem[] = [];
|
|
313
|
+
const seen = new Set<string>();
|
|
314
|
+
for (const r of results) {
|
|
315
|
+
if (r.status === "fulfilled") {
|
|
316
|
+
for (const s of r.value) {
|
|
317
|
+
if (s.id && !seen.has(s.id)) {
|
|
318
|
+
seen.add(s.id);
|
|
319
|
+
all.push(s);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
all.sort((a, b) => b.updated - a.updated);
|
|
325
|
+
return all.slice(0, limit);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async exportSession(id: string): Promise<SessionExport> {
|
|
329
|
+
try {
|
|
330
|
+
return await Promise.any(this.clients.map((c) => c.exportSession(id)));
|
|
331
|
+
} catch {
|
|
332
|
+
throw new OpencodeError(`session ${id} not found on any daemon`, 404);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async exportSessionRaw(id: string): Promise<string> {
|
|
337
|
+
try {
|
|
338
|
+
return await Promise.any(this.clients.map((c) => c.exportSessionRaw(id)));
|
|
339
|
+
} catch {
|
|
340
|
+
throw new OpencodeError(`session ${id} not found on any daemon`, 404);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async listModels(): Promise<string[]> {
|
|
345
|
+
const results = await Promise.allSettled(
|
|
346
|
+
this.clients.map((c) => c.listModels()),
|
|
347
|
+
);
|
|
348
|
+
const models = new Set<string>();
|
|
349
|
+
for (const r of results) {
|
|
350
|
+
if (r.status === "fulfilled") {
|
|
351
|
+
for (const m of r.value) models.add(m);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return [...models];
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function createOpencodeClient(cfg: Config, daemonEndpoint?: string): OpencodeClientInterface {
|
|
359
|
+
if (daemonEndpoint && !isLocalhost(daemonEndpoint)) {
|
|
360
|
+
return new RemoteOpencodeClient(daemonEndpoint);
|
|
361
|
+
}
|
|
362
|
+
return new OpencodeClient(cfg);
|
|
363
|
+
}
|
|
364
|
+
|
|
253
365
|
async function readCapped(stream: ReadableStream<Uint8Array> | null, maxBytes: number): Promise<string> {
|
|
254
366
|
if (!stream) return "";
|
|
255
367
|
const reader = stream.getReader();
|
package/src/views/sessionLog.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { OpencodeClientInterface, SessionListItem, SessionExport, SessionMessage, MessagePart, ToolState } from "../opencode";
|
|
2
2
|
import { THEME_CSS, escapeHtml, escapeAttr, tabNavHTML } from "../render/layout";
|
|
3
3
|
import { renderMarkdown, linkifySessionIDs, linkifyAbsPaths } from "../render/markdown";
|
|
4
4
|
import { BUILD_ID } from "../build";
|
|
@@ -8,7 +8,7 @@ import { homedir } from "os";
|
|
|
8
8
|
|
|
9
9
|
const LIST_LIMIT = 100;
|
|
10
10
|
|
|
11
|
-
export async function buildSessionList(client:
|
|
11
|
+
export async function buildSessionList(client: OpencodeClientInterface, q: string): Promise<{ html: string }> {
|
|
12
12
|
let sessions = await client.listSessions(LIST_LIMIT);
|
|
13
13
|
const needle = q.trim().toLowerCase();
|
|
14
14
|
if (needle) {
|
|
@@ -57,7 +57,7 @@ function sessionRow(s: SessionListItem): string {
|
|
|
57
57
|
</a>`;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
export async function buildSessionView(client:
|
|
60
|
+
export async function buildSessionView(client: OpencodeClientInterface, id: string, desc: boolean, collapseLines: number, limit = 30, all = false): Promise<{ html: string }> {
|
|
61
61
|
const data = await client.exportSession(id);
|
|
62
62
|
const info = data.info;
|
|
63
63
|
const title = info.title || id;
|