ework-daemon 0.2.1 → 0.3.0
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/config.ts +5 -0
- package/src/index.ts +2 -0
- package/src/op.ts +25 -0
- package/src/opencode-reader.ts +177 -0
- package/src/server.ts +31 -0
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -28,6 +28,9 @@ export const configSchema = z.object({
|
|
|
28
28
|
opencode: z.object({
|
|
29
29
|
binary: z.string().default("opencode"),
|
|
30
30
|
baseWorkdir: z.string(),
|
|
31
|
+
dbPath: z.string().default(
|
|
32
|
+
`${process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share")}/opencode/opencode.db`
|
|
33
|
+
),
|
|
31
34
|
}),
|
|
32
35
|
work: z.object({
|
|
33
36
|
capacity: z.coerce.number().int().positive().default(4),
|
|
@@ -120,6 +123,7 @@ export function loadConfig(): Config {
|
|
|
120
123
|
opencode: {
|
|
121
124
|
binary: process.env.OPENCODE_BINARY ?? TEST_DEFAULTS.opencode.binary,
|
|
122
125
|
baseWorkdir: process.env.OPENCODE_BASE_WORKDIR ?? TEST_DEFAULTS.opencode.baseWorkdir,
|
|
126
|
+
dbPath: process.env.OPENCODE_DB_PATH ?? `${process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share")}/opencode/opencode.db`,
|
|
123
127
|
},
|
|
124
128
|
work: readWorkSection(),
|
|
125
129
|
db: readDbSection(TEST_DEFAULTS.db.path),
|
|
@@ -153,6 +157,7 @@ export function loadConfig(): Config {
|
|
|
153
157
|
opencode: {
|
|
154
158
|
binary: process.env.OPENCODE_BINARY ?? "opencode",
|
|
155
159
|
baseWorkdir: process.env.OPENCODE_BASE_WORKDIR,
|
|
160
|
+
dbPath: process.env.OPENCODE_DB_PATH ?? `${process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share")}/opencode/opencode.db`,
|
|
156
161
|
},
|
|
157
162
|
work: readWorkSection(),
|
|
158
163
|
db: readDbSection(PRODUCTION_DB_DEFAULT),
|
package/src/index.ts
CHANGED
|
@@ -44,8 +44,10 @@ async function boot() {
|
|
|
44
44
|
const displayName = hostname();
|
|
45
45
|
const internalEndpoint = `${config.daemon.host}:${config.daemon.port}`;
|
|
46
46
|
const daemonId = await store.registerDaemon(displayName, internalEndpoint, config.work.capacity, config.work.leaseTtlMs);
|
|
47
|
+
const absorbed = await store.absorbSameHostDaemons(daemonId, displayName, internalEndpoint);
|
|
47
48
|
const claimed = await store.claimAllOwnerless(daemonId);
|
|
48
49
|
log.info(` daemon registered: id=${daemonId} (adopted orphan slot if id was reused)`);
|
|
50
|
+
if (absorbed > 0) log.info(` restart recovery: absorbed ${absorbed} issue(s) from previous incarnation`);
|
|
49
51
|
if (claimed > 0) log.info(` first-boot migration: claimed ${claimed} previously-ownerless issue(s)`);
|
|
50
52
|
|
|
51
53
|
const engine = new Engine(config, store, trackers, { daemonId });
|
package/src/op.ts
CHANGED
|
@@ -378,6 +378,31 @@ export class Store {
|
|
|
378
378
|
return ins.insertId;
|
|
379
379
|
}
|
|
380
380
|
|
|
381
|
+
/**
|
|
382
|
+
* On restart, absorb ownership from a previous incarnation on the same host.
|
|
383
|
+
* Transfers issues + deletes stale daemon rows with matching display_name and
|
|
384
|
+
* endpoint. This fixes the boot-race where a killed daemon's lease hasn't
|
|
385
|
+
* expired yet, blocking the new daemon from claiming its own issues.
|
|
386
|
+
*/
|
|
387
|
+
async absorbSameHostDaemons(
|
|
388
|
+
daemonId: number,
|
|
389
|
+
displayName: string,
|
|
390
|
+
endpoint: string,
|
|
391
|
+
): Promise<number> {
|
|
392
|
+
const db = getDB();
|
|
393
|
+
const transferred = await db.run(
|
|
394
|
+
"UPDATE {{issues}} SET owner_daemon_id = ? WHERE owner_daemon_id IN (" +
|
|
395
|
+
"SELECT id FROM {{daemons}} WHERE display_name = ? AND internal_endpoint = ? AND id != ?" +
|
|
396
|
+
")",
|
|
397
|
+
[daemonId, displayName, endpoint, daemonId],
|
|
398
|
+
);
|
|
399
|
+
await db.run(
|
|
400
|
+
"DELETE FROM {{daemons}} WHERE display_name = ? AND internal_endpoint = ? AND id != ?",
|
|
401
|
+
[displayName, endpoint, daemonId],
|
|
402
|
+
);
|
|
403
|
+
return transferred.changes;
|
|
404
|
+
}
|
|
405
|
+
|
|
381
406
|
async heartbeat(daemonId: number): Promise<void> {
|
|
382
407
|
await getDB().run(
|
|
383
408
|
"UPDATE {{daemons}} SET last_heartbeat = ? WHERE id = ?",
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { openSync, closeSync, readFileSync, unlinkSync } from "fs";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
|
|
6
|
+
// Read-only OpenCode session accessor. Mirrors the subset of ework-web's
|
|
7
|
+
// OpencodeClient that the session pages need: list + export. The daemon
|
|
8
|
+
// serves this via HTTP so remote web instances can proxy session data
|
|
9
|
+
// without sharing a filesystem.
|
|
10
|
+
|
|
11
|
+
export interface SessionListItem {
|
|
12
|
+
id: string;
|
|
13
|
+
title: string;
|
|
14
|
+
created: number;
|
|
15
|
+
updated: number;
|
|
16
|
+
directory?: string;
|
|
17
|
+
peakTokens?: number;
|
|
18
|
+
msgCount?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class OpencodeReader {
|
|
22
|
+
private readonly bin: string;
|
|
23
|
+
private readonly dbPath: string;
|
|
24
|
+
private readonly timeoutMs = 30_000;
|
|
25
|
+
|
|
26
|
+
constructor(bin: string, dbPath: string) {
|
|
27
|
+
this.bin = bin;
|
|
28
|
+
this.dbPath = dbPath;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async listSessions(limit: number): Promise<SessionListItem[]> {
|
|
32
|
+
let db: Database;
|
|
33
|
+
try {
|
|
34
|
+
db = new Database(this.dbPath, { readonly: true });
|
|
35
|
+
} catch {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const rows = db
|
|
40
|
+
.prepare(
|
|
41
|
+
"SELECT s.id AS id, s.title AS title, s.time_created AS created, s.time_updated AS updated, s.directory AS directory, " +
|
|
42
|
+
"m.peak AS peakTokens, m.calls AS msgCount " +
|
|
43
|
+
"FROM session s LEFT JOIN (" +
|
|
44
|
+
"SELECT session_id, MAX(CAST(json_extract(data,'$.tokens.input') AS INT) + CAST(json_extract(data,'$.tokens.cache.read') AS INT) + CAST(json_extract(data,'$.tokens.cache.write') AS INT)) AS peak, " +
|
|
45
|
+
"COUNT(*) AS calls FROM message WHERE json_extract(data,'$.tokens.input') > 0 GROUP BY session_id" +
|
|
46
|
+
") m ON m.session_id = s.id " +
|
|
47
|
+
"WHERE s.time_archived IS NULL ORDER BY s.time_updated DESC LIMIT ?"
|
|
48
|
+
)
|
|
49
|
+
.all(limit) as Array<{ id: unknown; title: unknown; created: unknown; updated: unknown; directory: unknown; peakTokens: unknown; msgCount: unknown }>;
|
|
50
|
+
return rows
|
|
51
|
+
.map((r) => {
|
|
52
|
+
const id = typeof r.id === "string" ? r.id : "";
|
|
53
|
+
if (!id) return null;
|
|
54
|
+
return {
|
|
55
|
+
id,
|
|
56
|
+
title: typeof r.title === "string" && r.title ? r.title : "(untitled)",
|
|
57
|
+
created: typeof r.created === "number" ? r.created : 0,
|
|
58
|
+
updated: typeof r.updated === "number" ? r.updated : 0,
|
|
59
|
+
directory: typeof r.directory === "string" ? r.directory : undefined,
|
|
60
|
+
peakTokens: typeof r.peakTokens === "number" && r.peakTokens > 0 ? r.peakTokens : undefined,
|
|
61
|
+
msgCount: typeof r.msgCount === "number" && r.msgCount > 0 ? r.msgCount : undefined,
|
|
62
|
+
} as SessionListItem;
|
|
63
|
+
})
|
|
64
|
+
.filter((x): x is SessionListItem => x !== null);
|
|
65
|
+
} catch {
|
|
66
|
+
return [];
|
|
67
|
+
} finally {
|
|
68
|
+
db.close();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async exportSession(id: string): Promise<unknown> {
|
|
73
|
+
const raw = await this.runJSON(["export", id]);
|
|
74
|
+
return raw;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async exportSessionRaw(id: string): Promise<string> {
|
|
78
|
+
const { stdout, code } = await this.run(["export", id]);
|
|
79
|
+
if (code !== 0) {
|
|
80
|
+
throw new Error(`opencode export ${id} → exit ${code}`);
|
|
81
|
+
}
|
|
82
|
+
return stdout;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private async runJSON(args: string[]): Promise<unknown> {
|
|
86
|
+
const { stdout, code, stderr } = await this.run(args);
|
|
87
|
+
if (code !== 0) {
|
|
88
|
+
const why = stderr.trim() || `exit ${code}`;
|
|
89
|
+
const status = /not found|no such/i.test(why) ? 404 : 502;
|
|
90
|
+
throw new OpencodeReaderError(`opencode ${args.join(" ")} failed: ${why}`, status);
|
|
91
|
+
}
|
|
92
|
+
const text = stdout.trim();
|
|
93
|
+
if (!text) return null;
|
|
94
|
+
const jsonText = stripNonJsonPreamble(text);
|
|
95
|
+
if (jsonText === null) {
|
|
96
|
+
throw new OpencodeReaderError(`opencode ${args.join(" ")}: non-JSON output`, 502);
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
return JSON.parse(jsonText);
|
|
100
|
+
} catch (e) {
|
|
101
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
102
|
+
throw new OpencodeReaderError(`opencode ${args.join(" ")}: malformed JSON (${msg})`, 502);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private async run(args: string[]): Promise<{ stdout: string; stderr: string; code: number | null }> {
|
|
107
|
+
const tmp = join(tmpdir(), `ocd-${process.pid}-${Math.random().toString(36).slice(2)}.json`);
|
|
108
|
+
const fd = openSync(tmp, "w");
|
|
109
|
+
const proc = Bun.spawn([this.bin, ...args], {
|
|
110
|
+
stdout: fd,
|
|
111
|
+
stderr: "pipe",
|
|
112
|
+
env: process.env,
|
|
113
|
+
});
|
|
114
|
+
const killer = setTimeout(() => {
|
|
115
|
+
try { proc.kill(); } catch { /* already exited */ }
|
|
116
|
+
}, this.timeoutMs);
|
|
117
|
+
let stderr = "";
|
|
118
|
+
let code: number | null = null;
|
|
119
|
+
try {
|
|
120
|
+
stderr = await readCapped(proc.stderr, 64 * 1024);
|
|
121
|
+
code = await proc.exited;
|
|
122
|
+
} finally {
|
|
123
|
+
clearTimeout(killer);
|
|
124
|
+
try { closeSync(fd); } catch { /* already closed */ }
|
|
125
|
+
}
|
|
126
|
+
let stdout = "";
|
|
127
|
+
if (code === 0) {
|
|
128
|
+
try {
|
|
129
|
+
stdout = readFileSync(tmp, "utf-8");
|
|
130
|
+
} catch { /* temp file gone */ }
|
|
131
|
+
}
|
|
132
|
+
try { unlinkSync(tmp); } catch { /* best-effort cleanup */ }
|
|
133
|
+
return { stdout, stderr, code };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export class OpencodeReaderError extends Error {
|
|
138
|
+
status: number;
|
|
139
|
+
constructor(message: string, status: number) {
|
|
140
|
+
super(message);
|
|
141
|
+
this.name = "OpencodeReaderError";
|
|
142
|
+
this.status = status;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function readCapped(stream: ReadableStream<Uint8Array> | null, cap: number): Promise<string> {
|
|
147
|
+
if (!stream) return "";
|
|
148
|
+
const reader = stream.getReader();
|
|
149
|
+
const chunks: Uint8Array[] = [];
|
|
150
|
+
let total = 0;
|
|
151
|
+
try {
|
|
152
|
+
for (;;) {
|
|
153
|
+
const { done, value } = await reader.read();
|
|
154
|
+
if (done) break;
|
|
155
|
+
if (value) {
|
|
156
|
+
total += value.length;
|
|
157
|
+
if (total > cap) {
|
|
158
|
+
chunks.push(value.slice(0, cap - (total - value.length)));
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
chunks.push(value);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
} finally {
|
|
165
|
+
reader.releaseLock();
|
|
166
|
+
}
|
|
167
|
+
return new TextDecoder().decode(Buffer.concat(chunks));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function stripNonJsonPreamble(s: string): string | null {
|
|
171
|
+
const i = s.indexOf("[");
|
|
172
|
+
const j = s.indexOf("{");
|
|
173
|
+
if (i === -1 && j === -1) return null;
|
|
174
|
+
if (i === -1) return s.slice(j);
|
|
175
|
+
if (j === -1) return s.slice(i);
|
|
176
|
+
return s.slice(Math.min(i, j));
|
|
177
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { Config } from "./config";
|
|
|
2
2
|
import type { Store } from "./op";
|
|
3
3
|
import type { Engine } from "./opencode";
|
|
4
4
|
import type { IssueTracker, TrackerEvent } from "./trackers/types";
|
|
5
|
+
import { OpencodeReader, OpencodeReaderError } from "./opencode-reader";
|
|
5
6
|
import { log, uptimeSeconds, version } from "./logger";
|
|
6
7
|
|
|
7
8
|
type TrackerMap = Map<string, IssueTracker>;
|
|
@@ -19,6 +20,7 @@ export function createServer(
|
|
|
19
20
|
engine: Engine,
|
|
20
21
|
trackers: TrackerMap
|
|
21
22
|
) {
|
|
23
|
+
const reader = new OpencodeReader(cfg.opencode.binary, cfg.opencode.dbPath);
|
|
22
24
|
async function handleWebhook(req: Request, tracker: IssueTracker): Promise<Response> {
|
|
23
25
|
const rawBody = await req.text();
|
|
24
26
|
const headers: Record<string, string | null> = {};
|
|
@@ -118,6 +120,35 @@ export function createServer(
|
|
|
118
120
|
return json({ ok: true, stopped: wasKilled });
|
|
119
121
|
}
|
|
120
122
|
|
|
123
|
+
if (pathname === "/api/opencode/sessions") {
|
|
124
|
+
const url = new URL(req.url);
|
|
125
|
+
const limit = Math.min(Math.max(Number(url.searchParams.get("limit") ?? "50"), 1), 500);
|
|
126
|
+
const sessions = await reader.listSessions(limit);
|
|
127
|
+
return json(sessions);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const ocExportMatch = pathname.match(/^\/api\/opencode\/sessions\/([A-Za-z0-9_-]+)\/export$/);
|
|
131
|
+
if (ocExportMatch) {
|
|
132
|
+
try {
|
|
133
|
+
const data = await reader.exportSession(ocExportMatch[1]!);
|
|
134
|
+
return json(data);
|
|
135
|
+
} catch (e) {
|
|
136
|
+
if (e instanceof OpencodeReaderError) return json({ error: e.message }, e.status);
|
|
137
|
+
return json({ error: "export failed" }, 502);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const ocRawMatch = pathname.match(/^\/api\/opencode\/sessions\/([A-Za-z0-9_-]+)\/raw$/);
|
|
142
|
+
if (ocRawMatch) {
|
|
143
|
+
try {
|
|
144
|
+
const raw = await reader.exportSessionRaw(ocRawMatch[1]!);
|
|
145
|
+
return json({ raw });
|
|
146
|
+
} catch (e) {
|
|
147
|
+
if (e instanceof OpencodeReaderError) return json({ error: e.message }, e.status);
|
|
148
|
+
return json({ error: "export failed" }, 502);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
121
152
|
return json({ error: "not found" }, 404);
|
|
122
153
|
}
|
|
123
154
|
|