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/LICENSE +21 -0
- package/README.md +262 -0
- package/bin/ework-daemon-server.js +2 -0
- package/package.json +65 -0
- package/scripts/ework-daemon.service +22 -0
- package/src/cli.ts +218 -0
- package/src/config.ts +134 -0
- package/src/gitea.ts +137 -0
- package/src/index.ts +48 -0
- package/src/logger.ts +64 -0
- package/src/op.ts +486 -0
- package/src/opencode.ts +1219 -0
- package/src/server.ts +160 -0
- package/src/trackers/gitea-tracker.ts +181 -0
- package/src/trackers/types.ts +155 -0
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
|
+
}
|