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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ework-daemon",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Issue-driven AI development daemon. Spawns opencode subprocesses to resolve Gitea issues.",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
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