ework-daemon 0.1.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/src/config.ts ADDED
@@ -0,0 +1,134 @@
1
+ import { z } from "zod";
2
+ import { join } from "path";
3
+ import { tmpdir, homedir } from "os";
4
+
5
+ export type DaemonEnv = "test" | "production";
6
+
7
+ export function getEnv(): DaemonEnv {
8
+ const v = process.env.DAEMON_ENV;
9
+ if (v === "production" || v === "prod") return "production";
10
+ return "test"; // default
11
+ }
12
+
13
+ export const configSchema = z.object({
14
+ env: z.enum(["test", "production"]),
15
+ gitea: z.object({
16
+ url: z.string(),
17
+ token: z.string(),
18
+ webhookSecret: z.string().default(""),
19
+ }),
20
+ bot: z.object({
21
+ username: z.string(),
22
+ token: z.string(),
23
+ }),
24
+ daemon: z.object({
25
+ port: z.coerce.number().default(3101),
26
+ host: z.string().default("0.0.0.0"),
27
+ }),
28
+ opencode: z.object({
29
+ binary: z.string().default("opencode"),
30
+ baseWorkdir: z.string(),
31
+ }),
32
+ db: z.object({
33
+ path: z.string(),
34
+ }),
35
+ completionCheck: z.object({
36
+ apiKey: z.string(),
37
+ baseURL: z.string(),
38
+ model: z.string(),
39
+ }).optional(),
40
+ stuck: z.object({
41
+ thresholdMs: z.coerce.number().positive(),
42
+ maxNudges: z.coerce.number().int().nonnegative(),
43
+ }).optional(),
44
+ });
45
+
46
+ export type Config = z.infer<typeof configSchema>;
47
+
48
+ const PRODUCTION_DB_DEFAULT = join(
49
+ process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"),
50
+ "ework-daemon",
51
+ "ework-daemon.db"
52
+ );
53
+
54
+ const TEST_DEFAULTS = {
55
+ gitea: { url: "http://localhost:9999", token: "test-token", webhookSecret: "" },
56
+ bot: { username: "ework-daemon-test", token: "test-bot-token" },
57
+ daemon: { port: 3111, host: "0.0.0.0" },
58
+ opencode: { binary: "opencode", baseWorkdir: join(tmpdir(), "ework-daemon-test") },
59
+ db: { path: join(process.cwd(), "test", "ework-daemon-test.db") },
60
+ };
61
+
62
+ export function loadConfig(): Config {
63
+ const env = getEnv();
64
+
65
+ if (env === "test") {
66
+ // Test mode: use .env.test if present, otherwise defaults.
67
+ // Never fall through to .env (production values).
68
+ return configSchema.parse({
69
+ env,
70
+ gitea: {
71
+ url: process.env.GITEA_URL ?? TEST_DEFAULTS.gitea.url,
72
+ token: process.env.GITEA_TOKEN ?? TEST_DEFAULTS.gitea.token,
73
+ webhookSecret: process.env.GITEA_WEBHOOK_SECRET ?? "",
74
+ },
75
+ bot: {
76
+ username: process.env.BOT_USERNAME ?? TEST_DEFAULTS.bot.username,
77
+ token: process.env.BOT_TOKEN ?? TEST_DEFAULTS.bot.token,
78
+ },
79
+ daemon: {
80
+ port: process.env.DAEMON_PORT ?? TEST_DEFAULTS.daemon.port,
81
+ host: process.env.DAEMON_HOST ?? TEST_DEFAULTS.daemon.host,
82
+ },
83
+ opencode: {
84
+ binary: process.env.OPENCODE_BINARY ?? TEST_DEFAULTS.opencode.binary,
85
+ baseWorkdir: process.env.OPENCODE_BASE_WORKDIR ?? TEST_DEFAULTS.opencode.baseWorkdir,
86
+ },
87
+ db: {
88
+ path: process.env.DAEMON_DB_PATH ?? TEST_DEFAULTS.db.path,
89
+ },
90
+ completionCheck: process.env.COMPLETION_CHECK_API_KEY ? {
91
+ apiKey: process.env.COMPLETION_CHECK_API_KEY,
92
+ baseURL: process.env.COMPLETION_CHECK_BASE_URL ?? "",
93
+ model: process.env.COMPLETION_CHECK_MODEL ?? "",
94
+ } : undefined,
95
+ stuck: process.env.DAEMON_STUCK_THRESHOLD_MS || process.env.DAEMON_MAX_STUCK_NUDGES ? {
96
+ thresholdMs: Number(process.env.DAEMON_STUCK_THRESHOLD_MS) || 30 * 60 * 1000,
97
+ maxNudges: Number(process.env.DAEMON_MAX_STUCK_NUDGES) || 1,
98
+ } : undefined,
99
+ });
100
+ }
101
+
102
+ return configSchema.parse({
103
+ env,
104
+ gitea: {
105
+ url: process.env.GITEA_URL,
106
+ token: process.env.GITEA_TOKEN,
107
+ webhookSecret: process.env.GITEA_WEBHOOK_SECRET ?? "",
108
+ },
109
+ bot: {
110
+ username: process.env.BOT_USERNAME,
111
+ token: process.env.BOT_TOKEN,
112
+ },
113
+ daemon: {
114
+ port: process.env.DAEMON_PORT ?? 3101,
115
+ host: process.env.DAEMON_HOST ?? "0.0.0.0",
116
+ },
117
+ opencode: {
118
+ binary: process.env.OPENCODE_BINARY ?? "opencode",
119
+ baseWorkdir: process.env.OPENCODE_BASE_WORKDIR,
120
+ },
121
+ db: {
122
+ path: process.env.DAEMON_DB_PATH ?? PRODUCTION_DB_DEFAULT,
123
+ },
124
+ completionCheck: process.env.COMPLETION_CHECK_API_KEY ? {
125
+ apiKey: process.env.COMPLETION_CHECK_API_KEY,
126
+ baseURL: process.env.COMPLETION_CHECK_BASE_URL ?? "",
127
+ model: process.env.COMPLETION_CHECK_MODEL ?? "",
128
+ } : undefined,
129
+ stuck: process.env.DAEMON_STUCK_THRESHOLD_MS || process.env.DAEMON_MAX_STUCK_NUDGES ? {
130
+ thresholdMs: Number(process.env.DAEMON_STUCK_THRESHOLD_MS) || 30 * 60 * 1000,
131
+ maxNudges: Number(process.env.DAEMON_MAX_STUCK_NUDGES) || 1,
132
+ } : undefined,
133
+ });
134
+ }
package/src/gitea.ts ADDED
@@ -0,0 +1,137 @@
1
+ import type { Config } from "./config";
2
+
3
+ export class GiteaClient {
4
+ private url: string;
5
+ private token: string;
6
+ private botToken: string;
7
+
8
+ constructor(cfg: Config["gitea"], botToken: string) {
9
+ this.url = cfg.url.replace(/\/$/, "");
10
+ this.token = cfg.token;
11
+ this.botToken = botToken;
12
+ }
13
+
14
+ private async request<T>(
15
+ method: string,
16
+ path: string,
17
+ body?: unknown,
18
+ useBotToken = false
19
+ ): Promise<T> {
20
+ const token = useBotToken ? this.botToken : this.token;
21
+ const res = await fetch(`${this.url}/api/v1${path}`, {
22
+ method,
23
+ headers: {
24
+ Authorization: `token ${token}`,
25
+ "Content-Type": "application/json",
26
+ },
27
+ body: body ? JSON.stringify(body) : undefined,
28
+ });
29
+
30
+ if (!res.ok) {
31
+ const text = await res.text();
32
+ throw new Error(`Gitea API ${method} ${path} → ${res.status}: ${text}`);
33
+ }
34
+
35
+ if (res.status === 204) return undefined as T;
36
+ return (await res.json()) as T;
37
+ }
38
+
39
+ async getIssue(owner: string, repo: string, number: number) {
40
+ return this.request<{
41
+ id: number;
42
+ number: number;
43
+ title: string;
44
+ body: string;
45
+ state: string;
46
+ html_url: string;
47
+ user: { login: string };
48
+ }>("GET", `/repos/${owner}/${repo}/issues/${number}`);
49
+ }
50
+
51
+ async createComment(
52
+ owner: string,
53
+ repo: string,
54
+ issueNumber: number,
55
+ body: string
56
+ ) {
57
+ return this.request<{
58
+ id: number;
59
+ body: string;
60
+ html_url: string;
61
+ }>("POST", `/repos/${owner}/${repo}/issues/${issueNumber}/comments`, {
62
+ body,
63
+ }, true);
64
+ }
65
+
66
+ async editComment(
67
+ owner: string,
68
+ repo: string,
69
+ commentId: number,
70
+ body: string
71
+ ) {
72
+ return this.request<{
73
+ id: number;
74
+ body: string;
75
+ }>("PATCH", `/repos/${owner}/${repo}/issues/comments/${commentId}`, {
76
+ body,
77
+ }, true);
78
+ }
79
+
80
+ async deleteComment(owner: string, repo: string, commentId: number) {
81
+ return this.request<void>(
82
+ "DELETE", `/repos/${owner}/${repo}/issues/comments/${commentId}`,
83
+ undefined, true
84
+ );
85
+ }
86
+
87
+ async listComments(owner: string, repo: string, issueNumber: number) {
88
+ return this.request<
89
+ Array<{ id: number; body: string; created_at: string; user: { login: string } }>
90
+ >("GET", `/repos/${owner}/${repo}/issues/${issueNumber}/comments`);
91
+ }
92
+
93
+ async closeIssue(owner: string, repo: string, issueNumber: number) {
94
+ return this.request<{
95
+ number: number;
96
+ state: string;
97
+ }>("PATCH", `/repos/${owner}/${repo}/issues/${issueNumber}`, {
98
+ state: "closed",
99
+ });
100
+ }
101
+
102
+ async addReaction(owner: string, repo: string, issueNumber: number, content: string) {
103
+ return this.request(
104
+ "POST",
105
+ `/repos/${owner}/${repo}/issues/${issueNumber}/reactions`,
106
+ { content },
107
+ true
108
+ );
109
+ }
110
+
111
+ async removeReaction(owner: string, repo: string, issueNumber: number, content: string) {
112
+ return this.request(
113
+ "DELETE",
114
+ `/repos/${owner}/${repo}/issues/${issueNumber}/reactions`,
115
+ { content },
116
+ true
117
+ );
118
+ }
119
+
120
+ async addCommentReaction(owner: string, repo: string, commentId: number, content: string) {
121
+ return this.request(
122
+ "POST",
123
+ `/repos/${owner}/${repo}/issues/comments/${commentId}/reactions`,
124
+ { content },
125
+ true
126
+ );
127
+ }
128
+
129
+ async removeCommentReaction(owner: string, repo: string, commentId: number, content: string) {
130
+ return this.request(
131
+ "DELETE",
132
+ `/repos/${owner}/${repo}/issues/comments/${commentId}/reactions`,
133
+ { content },
134
+ true
135
+ );
136
+ }
137
+ }
package/src/index.ts ADDED
@@ -0,0 +1,48 @@
1
+ import { loadConfig } from "./config";
2
+ import { GiteaClient } from "./gitea";
3
+ import { Store } from "./op";
4
+ import { createServer } from "./server";
5
+ import { Engine } from "./opencode";
6
+ import { log } from "./logger";
7
+ import { GiteaTracker } from "./trackers/gitea-tracker";
8
+ import type { IssueTracker } from "./trackers/types";
9
+
10
+ const config = loadConfig();
11
+ const isTest = config.env === "test";
12
+
13
+ log.info(`ework-daemon starting [${config.env}]...`);
14
+ log.info(` gitea: ${config.gitea.url}`);
15
+ log.info(` listen: ${config.daemon.host}:${config.daemon.port}`);
16
+ log.info(` opencode: ${config.opencode.binary}`);
17
+ log.info(` workdir: ${config.opencode.baseWorkdir}`);
18
+ log.info(` db: ${config.db.path}`);
19
+
20
+ const giteaClient = new GiteaClient(config.gitea, config.bot.token);
21
+ const giteaTracker = new GiteaTracker(
22
+ giteaClient,
23
+ config.gitea.url,
24
+ config.gitea.webhookSecret,
25
+ config.bot.username
26
+ );
27
+
28
+ const trackers = new Map<string, IssueTracker>();
29
+ trackers.set("gitea", giteaTracker);
30
+
31
+ const store = new Store(config.db.path);
32
+ const engine = new Engine(config, store, trackers);
33
+
34
+ const server = createServer(config, store, engine, trackers);
35
+
36
+ async function shutdown(signal: string) {
37
+ log.info(`\n${signal} received, shutting down...`);
38
+ engine.destroy();
39
+ store.close();
40
+ process.exit(0);
41
+ }
42
+
43
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
44
+ process.on("SIGINT", () => shutdown("SIGINT"));
45
+
46
+ log.info(`\n${isTest ? "🧪" : "✅"} ework-daemon ready at http://${server.hostname}:${server.port}/webhook`);
47
+ log.info(` Configure Gitea webhook to POST to /webhook/gitea`);
48
+ log.info(` Active issues: ${store.listActiveIssues().length}`);
package/src/logger.ts ADDED
@@ -0,0 +1,64 @@
1
+ import { format } from "node:util"
2
+
3
+ type Level = "debug" | "info" | "warn" | "error"
4
+
5
+ const LEVELS: Record<Level, number> = { debug: 10, info: 20, warn: 30, error: 40 }
6
+
7
+ function threshold(): number {
8
+ const configured = (process.env.WORK_LOG_LEVEL || "info").toLowerCase()
9
+ return LEVELS[configured as Level] ?? LEVELS.info
10
+ }
11
+
12
+ function encode(v: unknown): unknown {
13
+ if (v instanceof Error) return { name: v.name, message: v.message, stack: v.stack }
14
+ return v
15
+ }
16
+
17
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
18
+ if (v === null || typeof v !== "object") return false
19
+ if (Array.isArray(v) || v instanceof Error) return false
20
+ return Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null
21
+ }
22
+
23
+ function emit(level: Level, args: unknown[]): void {
24
+ if (LEVELS[level] < threshold()) return
25
+ const payload: Record<string, unknown> = { t: new Date().toISOString(), level }
26
+ if (args.length === 0) {
27
+ payload.msg = ""
28
+ } else if (args.length === 1 && args[0] instanceof Error) {
29
+ payload.msg = "error"
30
+ payload.err = encode(args[0])
31
+ } else {
32
+ const last = args[args.length - 1]
33
+ if (args.length >= 2 && typeof args[0] === "string" && isPlainObject(last)) {
34
+ const fields = args.pop() as Record<string, unknown>
35
+ payload.msg = args.length === 1 ? (args[0] as string) : format(...args)
36
+ for (const [k, v] of Object.entries(fields)) {
37
+ if (v !== undefined) payload[k] = encode(v)
38
+ }
39
+ } else {
40
+ payload.msg = format(...args)
41
+ }
42
+ }
43
+ const line = JSON.stringify(payload)
44
+ if (level === "error" || level === "warn") process.stderr.write(line + "\n")
45
+ else process.stdout.write(line + "\n")
46
+ }
47
+
48
+ export const log = {
49
+ debug: (...args: unknown[]) => emit("debug", args),
50
+ info: (...args: unknown[]) => emit("info", args),
51
+ warn: (...args: unknown[]) => emit("warn", args),
52
+ error: (...args: unknown[]) => emit("error", args),
53
+ }
54
+
55
+ export const startTime = Date.now()
56
+
57
+ export function uptimeSeconds(): number {
58
+ return Math.floor((Date.now() - startTime) / 1000)
59
+ }
60
+
61
+ const VERSION = "0.1.0"
62
+ export function version(): string {
63
+ return VERSION
64
+ }