ework-daemon 0.2.2 → 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.2",
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),
@@ -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