ework-daemon 0.3.0 → 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.3.0",
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
@@ -57,6 +57,11 @@ export const configSchema = z.object({
57
57
  thresholdMs: z.coerce.number().positive(),
58
58
  maxNudges: z.coerce.number().int().nonnegative(),
59
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 }),
60
65
  });
61
66
 
62
67
  export type Config = z.infer<typeof configSchema>;
@@ -170,5 +175,12 @@ export function loadConfig(): Config {
170
175
  thresholdMs: Number(process.env.DAEMON_STUCK_THRESHOLD_MS) || 30 * 60 * 1000,
171
176
  maxNudges: Number(process.env.DAEMON_MAX_STUCK_NUDGES) || 1,
172
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
+ },
173
185
  });
174
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
+ }
package/src/server.ts CHANGED
@@ -3,6 +3,7 @@ import type { Store } from "./op";
3
3
  import type { Engine } from "./opencode";
4
4
  import type { IssueTracker, TrackerEvent } from "./trackers/types";
5
5
  import { OpencodeReader, OpencodeReaderError } from "./opencode-reader";
6
+ import { listDir, readFile, readFileSince, FileApiError } from "./file-api";
6
7
  import { log, uptimeSeconds, version } from "./logger";
7
8
 
8
9
  type TrackerMap = Map<string, IssueTracker>;
@@ -48,6 +49,7 @@ export function createServer(
48
49
  }
49
50
 
50
51
  async function handleApi(req: Request, pathname: string): Promise<Response> {
52
+ const url = new URL(req.url);
51
53
  if (pathname === "/api/status") {
52
54
  const status = await engine.getStatus();
53
55
  return json({
@@ -121,7 +123,6 @@ export function createServer(
121
123
  }
122
124
 
123
125
  if (pathname === "/api/opencode/sessions") {
124
- const url = new URL(req.url);
125
126
  const limit = Math.min(Math.max(Number(url.searchParams.get("limit") ?? "50"), 1), 500);
126
127
  const sessions = await reader.listSessions(limit);
127
128
  return json(sessions);
@@ -149,6 +150,39 @@ export function createServer(
149
150
  }
150
151
  }
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
+
152
186
  return json({ error: "not found" }, 404);
153
187
  }
154
188