ework-daemon 0.2.2 → 0.4.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.4.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),
@@ -54,6 +57,11 @@ export const configSchema = z.object({
54
57
  thresholdMs: z.coerce.number().positive(),
55
58
  maxNudges: z.coerce.number().int().nonnegative(),
56
59
  }).optional(),
60
+ file: z.object({
61
+ roots: z.array(z.string()).default([]),
62
+ maxLines: z.coerce.number().int().positive().default(2000),
63
+ maxBytes: z.coerce.number().int().positive().default(524288),
64
+ }).default({ roots: [], maxLines: 2000, maxBytes: 524288 }),
57
65
  });
58
66
 
59
67
  export type Config = z.infer<typeof configSchema>;
@@ -120,6 +128,7 @@ export function loadConfig(): Config {
120
128
  opencode: {
121
129
  binary: process.env.OPENCODE_BINARY ?? TEST_DEFAULTS.opencode.binary,
122
130
  baseWorkdir: process.env.OPENCODE_BASE_WORKDIR ?? TEST_DEFAULTS.opencode.baseWorkdir,
131
+ dbPath: process.env.OPENCODE_DB_PATH ?? `${process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share")}/opencode/opencode.db`,
123
132
  },
124
133
  work: readWorkSection(),
125
134
  db: readDbSection(TEST_DEFAULTS.db.path),
@@ -153,6 +162,7 @@ export function loadConfig(): Config {
153
162
  opencode: {
154
163
  binary: process.env.OPENCODE_BINARY ?? "opencode",
155
164
  baseWorkdir: process.env.OPENCODE_BASE_WORKDIR,
165
+ dbPath: process.env.OPENCODE_DB_PATH ?? `${process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share")}/opencode/opencode.db`,
156
166
  },
157
167
  work: readWorkSection(),
158
168
  db: readDbSection(PRODUCTION_DB_DEFAULT),
@@ -165,5 +175,12 @@ export function loadConfig(): Config {
165
175
  thresholdMs: Number(process.env.DAEMON_STUCK_THRESHOLD_MS) || 30 * 60 * 1000,
166
176
  maxNudges: Number(process.env.DAEMON_MAX_STUCK_NUDGES) || 1,
167
177
  } : undefined,
178
+ file: {
179
+ roots: (process.env.WORK_FILE_ROOTS ?? "").split(":").filter(Boolean).length > 0
180
+ ? (process.env.WORK_FILE_ROOTS ?? "").split(":").filter(Boolean)
181
+ : [process.env.OPENCODE_BASE_WORKDIR].filter(Boolean) as string[],
182
+ maxLines: Number(process.env.WORK_FILE_MAX_LINES) || 2000,
183
+ maxBytes: Number(process.env.WORK_FILE_MAX_BYTES) || 524288,
184
+ },
168
185
  });
169
186
  }
@@ -0,0 +1,200 @@
1
+ import { realpathSync, statSync, readFileSync, openSync, readSync, closeSync, readdirSync } from "fs";
2
+ import { isAbsolute, relative } from "path";
3
+ import type { Config } from "./config";
4
+
5
+ export class FileApiError extends Error {
6
+ status: number;
7
+ constructor(message: string, status: number) {
8
+ super(message);
9
+ this.name = "FileApiError";
10
+ this.status = status;
11
+ }
12
+ }
13
+
14
+ const DENY = [
15
+ /(^|\/)\.env\b/i,
16
+ /(^|\/)\.(ssh|gnupg|aws|config\/gitea)\b/i,
17
+ /(id_rsa|id_ed25519|id_ecdsa|id_dsa)\b/i,
18
+ /^\/(?:etc|proc|sys|dev|boot|root)\b/i,
19
+ /\/\.git\/(?:config|hooks|HEAD)\b/i,
20
+ ];
21
+
22
+ function validateFilePath(cfg: Config, rawPath: string): string {
23
+ if (!rawPath || !isAbsolute(rawPath)) {
24
+ throw new FileApiError("path must be absolute", 400);
25
+ }
26
+ for (const re of DENY) if (re.test(rawPath)) {
27
+ throw new FileApiError("denied: sensitive path", 403);
28
+ }
29
+ let rp: string;
30
+ try {
31
+ rp = realpathSync(rawPath);
32
+ } catch {
33
+ throw new FileApiError("file not found", 404);
34
+ }
35
+ for (const re of DENY) if (re.test(rp)) {
36
+ throw new FileApiError("denied: sensitive path", 403);
37
+ }
38
+ const inRoot = cfg.file.roots.some((r) => {
39
+ if (!isAbsolute(r)) return false;
40
+ let rr: string;
41
+ try { rr = realpathSync(r); } catch { return false; }
42
+ const rel = relative(rr, rp);
43
+ return rel.length > 0 && !rel.startsWith("..") && !isAbsolute(rel);
44
+ });
45
+ if (!inRoot) throw new FileApiError("denied: outside allowlisted roots", 403);
46
+ return rp;
47
+ }
48
+
49
+ function readSlice(path: string, start: number, len: number): Buffer {
50
+ const fd = openSync(path, "r");
51
+ try {
52
+ const buf = Buffer.alloc(len);
53
+ const got = readSync(fd, buf, 0, len, start);
54
+ return buf.subarray(0, got);
55
+ } finally {
56
+ closeSync(fd);
57
+ }
58
+ }
59
+
60
+ export interface DirEntry {
61
+ name: string;
62
+ isDir: boolean;
63
+ size: number;
64
+ mtime: number;
65
+ }
66
+
67
+ export function listDir(cfg: Config, rawPath: string): { path: string; entries: DirEntry[] } {
68
+ const rp = validateFilePath(cfg, rawPath);
69
+ let st: ReturnType<typeof statSync>;
70
+ try { st = statSync(rp); } catch { throw new FileApiError("stat failed", 404); }
71
+ if (!st.isDirectory()) throw new FileApiError("not a directory", 400);
72
+
73
+ const entries = readdirSync(rp, { withFileTypes: true }).map((d) => {
74
+ const full = `${rp}/${d.name}`;
75
+ try {
76
+ const s = statSync(full);
77
+ return { name: d.name, isDir: s.isDirectory(), size: s.size, mtime: s.mtimeMs };
78
+ } catch {
79
+ return { name: d.name, isDir: d.isDirectory(), size: 0, mtime: 0 };
80
+ }
81
+ });
82
+ entries.sort((a, b) => {
83
+ if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
84
+ return a.name.localeCompare(b.name);
85
+ });
86
+ return { path: rp, entries };
87
+ }
88
+
89
+ export interface FileContent {
90
+ path: string;
91
+ size: number;
92
+ rows: { n: number; t: string }[];
93
+ mode: "tail" | "head";
94
+ byteCapped: boolean;
95
+ note: string;
96
+ }
97
+
98
+ export function readFile(
99
+ cfg: Config,
100
+ rawPath: string,
101
+ mode: "tail" | "head",
102
+ order: "asc" | "desc"
103
+ ): FileContent {
104
+ const rp = validateFilePath(cfg, rawPath);
105
+ let st: ReturnType<typeof statSync>;
106
+ try { st = statSync(rp); } catch { throw new FileApiError("stat failed", 404); }
107
+ if (!st.isFile()) throw new FileApiError("not a regular file", 400);
108
+
109
+ const cap = cfg.file.maxBytes;
110
+ let buf: Buffer;
111
+ let byteCapped = false;
112
+ if (st.size > cap) {
113
+ const start = mode === "tail" ? st.size - cap : 0;
114
+ buf = readSlice(rp, start, cap);
115
+ byteCapped = true;
116
+ } else {
117
+ try { buf = readFileSync(rp); } catch (e) {
118
+ throw new FileApiError(`read failed: ${(e as Error).message}`, 500);
119
+ }
120
+ }
121
+
122
+ const scanLen = Math.min(buf.length, 8192);
123
+ for (let i = 0; i < scanLen; i++) if (buf[i] === 0) {
124
+ throw new FileApiError("binary file (not viewable as text)", 415);
125
+ }
126
+
127
+ const text = buf.toString("utf-8");
128
+ let lines = text.split("\n");
129
+ if (lines.length && lines[lines.length - 1] === "") lines.pop();
130
+
131
+ const totalLines = lines.length;
132
+ const n = cfg.file.maxLines;
133
+ let shown: string[];
134
+ let note: string;
135
+ let firstNum: number;
136
+
137
+ if (byteCapped) {
138
+ if (mode === "tail") {
139
+ shown = lines.slice(-n);
140
+ note = `(显示最后 ${shown.length} 行,共 ~${totalLines} 行,文件 ${st.size} 字节)`;
141
+ firstNum = totalLines - shown.length + 1;
142
+ } else {
143
+ shown = lines.slice(0, n);
144
+ note = `(显示最前 ${shown.length} 行,共 ~${totalLines} 行,文件 ${st.size} 字节)`;
145
+ firstNum = 1;
146
+ }
147
+ } else {
148
+ if (lines.length > n) {
149
+ shown = mode === "tail" ? lines.slice(-n) : lines.slice(0, n);
150
+ note = `(${totalLines} 行,显示 ${shown.length})`;
151
+ firstNum = mode === "tail" ? totalLines - shown.length + 1 : 1;
152
+ } else {
153
+ shown = lines;
154
+ note = "";
155
+ firstNum = 1;
156
+ }
157
+ }
158
+
159
+ let rows = shown.map((t, i) => ({ n: firstNum + i, t }));
160
+ if (order === "desc") rows = rows.reverse();
161
+
162
+ return { path: rp, size: st.size, rows, mode, byteCapped, note };
163
+ }
164
+
165
+ export interface FileDelta {
166
+ rows: { n: number; t: string }[];
167
+ size: number;
168
+ rotated: boolean;
169
+ capped: boolean;
170
+ }
171
+
172
+ export function readFileSince(cfg: Config, rawPath: string, afterOffset: number): FileDelta {
173
+ if (!Number.isFinite(afterOffset) || afterOffset < 0) {
174
+ throw new FileApiError("bad after offset", 400);
175
+ }
176
+ const rp = validateFilePath(cfg, rawPath);
177
+ let st: ReturnType<typeof statSync>;
178
+ try { st = statSync(rp); } catch { throw new FileApiError("stat failed", 404); }
179
+ if (!st.isFile()) throw new FileApiError("not a regular file", 400);
180
+
181
+ const size = st.size;
182
+ if (size < afterOffset) return { rows: [], size, rotated: true, capped: false };
183
+ if (size === afterOffset) return { rows: [], size, rotated: false, capped: false };
184
+ const cap = cfg.file.maxBytes;
185
+ const want = Math.min(size - afterOffset, cap);
186
+ const buf = readSlice(rp, afterOffset, want);
187
+ const scanLen = Math.min(buf.length, 8192);
188
+ for (let i = 0; i < scanLen; i++) if (buf[i] === 0) {
189
+ throw new FileApiError("binary file (not viewable as text)", 415);
190
+ }
191
+ const text = buf.toString("utf-8");
192
+ let lines = text.split("\n");
193
+ if (lines.length && lines[lines.length - 1] === "") lines.pop();
194
+ const capped = size - afterOffset >= cap;
195
+ if (capped && lines.length > 1) lines[0] = "(…前文已截断)";
196
+ const n = cfg.file.maxLines;
197
+ if (lines.length > n) lines = lines.slice(-n);
198
+ const rows = lines.map((t) => ({ n: 0, t }));
199
+ return { rows, size, rotated: false, capped };
200
+ }
@@ -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,8 @@ 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";
6
+ import { listDir, readFile, readFileSince, FileApiError } from "./file-api";
5
7
  import { log, uptimeSeconds, version } from "./logger";
6
8
 
7
9
  type TrackerMap = Map<string, IssueTracker>;
@@ -19,6 +21,7 @@ export function createServer(
19
21
  engine: Engine,
20
22
  trackers: TrackerMap
21
23
  ) {
24
+ const reader = new OpencodeReader(cfg.opencode.binary, cfg.opencode.dbPath);
22
25
  async function handleWebhook(req: Request, tracker: IssueTracker): Promise<Response> {
23
26
  const rawBody = await req.text();
24
27
  const headers: Record<string, string | null> = {};
@@ -46,6 +49,7 @@ export function createServer(
46
49
  }
47
50
 
48
51
  async function handleApi(req: Request, pathname: string): Promise<Response> {
52
+ const url = new URL(req.url);
49
53
  if (pathname === "/api/status") {
50
54
  const status = await engine.getStatus();
51
55
  return json({
@@ -118,6 +122,67 @@ export function createServer(
118
122
  return json({ ok: true, stopped: wasKilled });
119
123
  }
120
124
 
125
+ if (pathname === "/api/opencode/sessions") {
126
+ const limit = Math.min(Math.max(Number(url.searchParams.get("limit") ?? "50"), 1), 500);
127
+ const sessions = await reader.listSessions(limit);
128
+ return json(sessions);
129
+ }
130
+
131
+ const ocExportMatch = pathname.match(/^\/api\/opencode\/sessions\/([A-Za-z0-9_-]+)\/export$/);
132
+ if (ocExportMatch) {
133
+ try {
134
+ const data = await reader.exportSession(ocExportMatch[1]!);
135
+ return json(data);
136
+ } catch (e) {
137
+ if (e instanceof OpencodeReaderError) return json({ error: e.message }, e.status);
138
+ return json({ error: "export failed" }, 502);
139
+ }
140
+ }
141
+
142
+ const ocRawMatch = pathname.match(/^\/api\/opencode\/sessions\/([A-Za-z0-9_-]+)\/raw$/);
143
+ if (ocRawMatch) {
144
+ try {
145
+ const raw = await reader.exportSessionRaw(ocRawMatch[1]!);
146
+ return json({ raw });
147
+ } catch (e) {
148
+ if (e instanceof OpencodeReaderError) return json({ error: e.message }, e.status);
149
+ return json({ error: "export failed" }, 502);
150
+ }
151
+ }
152
+
153
+ if (pathname === "/api/files/list") {
154
+ const filePath = url.searchParams.get("path") ?? "";
155
+ try {
156
+ return json(listDir(cfg, filePath));
157
+ } catch (e) {
158
+ if (e instanceof FileApiError) return json({ error: e.message }, e.status);
159
+ return json({ error: "list failed" }, 500);
160
+ }
161
+ }
162
+
163
+ if (pathname === "/api/files/read") {
164
+ const filePath = url.searchParams.get("path") ?? "";
165
+ const mode = (url.searchParams.get("mode") === "head" ? "head" : "tail") as "head" | "tail";
166
+ const order = (url.searchParams.get("order") === "asc" ? "asc" : "desc") as "asc" | "desc";
167
+ try {
168
+ return json(readFile(cfg, filePath, mode, order));
169
+ } catch (e) {
170
+ if (e instanceof FileApiError) return json({ error: e.message }, e.status);
171
+ return json({ error: "read failed" }, 500);
172
+ }
173
+ }
174
+
175
+ if (pathname === "/api/files/since") {
176
+ const filePath = url.searchParams.get("path") ?? "";
177
+ const after = Number(url.searchParams.get("after") ?? "0");
178
+ try {
179
+ return json(readFileSince(cfg, filePath, after));
180
+ } catch (e) {
181
+ if (e instanceof FileApiError) return json({ error: e.message }, e.status);
182
+ return json({ error: "read failed" }, 500);
183
+ }
184
+ }
185
+
121
186
  return json({ error: "not found" }, 404);
122
187
  }
123
188