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/server.ts ADDED
@@ -0,0 +1,160 @@
1
+ import type { Config } from "./config";
2
+ import type { Store } from "./op";
3
+ import type { Engine } from "./opencode";
4
+ import type { IssueTracker, TrackerEvent } from "./trackers/types";
5
+ import { log, uptimeSeconds, version } from "./logger";
6
+
7
+ type TrackerMap = Map<string, IssueTracker>;
8
+
9
+ function json(data: unknown, status = 200) {
10
+ return new Response(JSON.stringify(data, null, 2), {
11
+ status,
12
+ headers: { "Content-Type": "application/json" },
13
+ });
14
+ }
15
+
16
+ export function createServer(
17
+ cfg: Config,
18
+ store: Store,
19
+ engine: Engine,
20
+ trackers: TrackerMap
21
+ ) {
22
+ async function handleWebhook(req: Request, tracker: IssueTracker): Promise<Response> {
23
+ const rawBody = await req.text();
24
+ const headers: Record<string, string | null> = {};
25
+ req.headers.forEach((v, k) => { headers[k] = v; });
26
+
27
+ if (!tracker.verifyWebhookSignature(rawBody, headers)) {
28
+ log.warn("webhook: invalid signature");
29
+ return new Response("invalid signature", { status: 403 });
30
+ }
31
+
32
+ const event = tracker.parseWebhookEvent(rawBody);
33
+ if (!event) {
34
+ return new Response("unrecognized event", { status: 400 });
35
+ }
36
+
37
+ log.info(
38
+ `webhook: type=${event.type} ref=${event.ref.trackerType}:${event.ref.scope.owner ?? ""}/${event.ref.scope.repo ?? ""}#${event.ref.issueId}`
39
+ );
40
+
41
+ engine.handleEvent(event).catch((err) => {
42
+ log.error("webhook: handler error:", err);
43
+ });
44
+
45
+ return new Response("ok", { status: 200 });
46
+ }
47
+
48
+ async function handleApi(req: Request, pathname: string): Promise<Response> {
49
+ if (pathname === "/api/status") {
50
+ const status = engine.getStatus();
51
+ return json({
52
+ env: cfg.env,
53
+ daemon: { host: cfg.daemon.host, port: cfg.daemon.port },
54
+ db: cfg.db.path,
55
+ running: status.runningCount,
56
+ pending: status.pendingCount,
57
+ processes: status.processCount,
58
+ observedIssues: status.observedIssues,
59
+ issues: store.listAllIssues().length,
60
+ sessions: store.listAllSessions().length,
61
+ });
62
+ }
63
+
64
+ if (pathname === "/api/issues") {
65
+ return json(store.listAllIssues());
66
+ }
67
+
68
+ if (pathname === "/api/sessions") {
69
+ return json(store.listAllSessions());
70
+ }
71
+
72
+ const issueIdMatch = pathname.match(/^\/api\/issues\/([0-9a-f-]+)$/);
73
+ if (issueIdMatch) {
74
+ const issue = store.getIssue(issueIdMatch[1]!);
75
+ if (!issue) return json({ error: "not found" }, 404);
76
+ const sessions = store.getSessionsForIssue(issue.id);
77
+ return json({ ...issue, sessions });
78
+ }
79
+
80
+ const sessionIdMatch = pathname.match(/^\/api\/sessions\/([0-9a-f-]+)$/);
81
+ if (sessionIdMatch) {
82
+ const session = store.getSession(sessionIdMatch[1]!);
83
+ if (!session) return json({ error: "not found" }, 404);
84
+ return json(session);
85
+ }
86
+
87
+ if (pathname === "/api/queue") {
88
+ return json(engine.getQueue());
89
+ }
90
+
91
+ if (pathname === "/api/processes") {
92
+ return json(engine.getProcesses());
93
+ }
94
+
95
+ const sessionMsgsMatch = pathname.match(/^\/api\/sessions\/([0-9a-f-]+)\/messages$/);
96
+ if (sessionMsgsMatch) {
97
+ const session = store.getSession(sessionMsgsMatch[1]!);
98
+ if (!session) return json({ error: "not found" }, 404);
99
+ return json(store.getMessagesForSession(session.id));
100
+ }
101
+
102
+ const msgRetryMatch = pathname.match(/^\/api\/messages\/([0-9a-f-]+)\/retry$/);
103
+ if (msgRetryMatch && req.method === "PATCH") {
104
+ const result = engine.retryMessage(msgRetryMatch[1]!);
105
+ if (!result) {
106
+ const msg = store.getMessage(msgRetryMatch[1]!);
107
+ if (!msg) return json({ error: "not found" }, 404);
108
+ if (msg.status !== "failed") return json({ error: "only failed messages can be retried" }, 400);
109
+ }
110
+ return json({ ok: true, id: msgRetryMatch[1] });
111
+ }
112
+
113
+ const forceStopMatch = pathname.match(/^\/api\/processes\/(.+)$/);
114
+ if (forceStopMatch && req.method === "DELETE") {
115
+ const key = decodeURIComponent(forceStopMatch[1]!);
116
+ log.warn(`api: DELETE /api/processes/${key} (force-stop request)`);
117
+ const wasKilled = engine.forceStop(key);
118
+ return json({ ok: true, stopped: wasKilled });
119
+ }
120
+
121
+ return json({ error: "not found" }, 404);
122
+ }
123
+
124
+ const server = Bun.serve({
125
+ port: cfg.daemon.port,
126
+ hostname: cfg.daemon.host,
127
+ async fetch(req) {
128
+ const url = new URL(req.url);
129
+ const pathname = url.pathname;
130
+
131
+ if (pathname === "/healthz") {
132
+ return new Response(
133
+ JSON.stringify({ ok: true, version: version(), uptime: uptimeSeconds() }),
134
+ { status: 200, headers: { "content-type": "application/json", "cache-control": "no-store" } },
135
+ );
136
+ }
137
+
138
+ if (req.method === "POST") {
139
+ if (pathname === "/webhook/gitea" || pathname === "/webhook") {
140
+ const tracker = trackers.get("gitea");
141
+ if (!tracker) return json({ error: "gitea tracker not configured" }, 500);
142
+ return handleWebhook(req, tracker);
143
+ }
144
+ if (pathname === "/webhook/plane") {
145
+ const tracker = trackers.get("plane");
146
+ if (!tracker) return json({ error: "plane tracker not configured" }, 500);
147
+ return handleWebhook(req, tracker);
148
+ }
149
+ }
150
+
151
+ if (pathname.startsWith("/api/")) {
152
+ return handleApi(req, pathname);
153
+ }
154
+
155
+ return new Response("not found", { status: 404 });
156
+ },
157
+ });
158
+
159
+ return server;
160
+ }
@@ -0,0 +1,181 @@
1
+ import { createHmac, timingSafeEqual } from "crypto";
2
+ import type { GiteaClient } from "../gitea";
3
+ import type {
4
+ IssueTracker,
5
+ TrackerRef,
6
+ TrackerEvent,
7
+ TrackerComment,
8
+ TrackerInstructions,
9
+ } from "./types";
10
+
11
+ export class GiteaTracker implements IssueTracker {
12
+ readonly type = "gitea";
13
+
14
+ private client: GiteaClient;
15
+ private url: string;
16
+ private webhookSecret: string;
17
+ private botUsername: string;
18
+
19
+ constructor(client: GiteaClient, url: string, webhookSecret: string, botUsername: string) {
20
+ this.client = client;
21
+ this.url = url.replace(/\/$/, "");
22
+ this.webhookSecret = webhookSecret;
23
+ this.botUsername = botUsername;
24
+ }
25
+
26
+ formatScopeKey(scope: Record<string, string>): string {
27
+ return `${scope.owner}/${scope.repo}`;
28
+ }
29
+
30
+ private owner(ref: TrackerRef) { return ref.scope["owner"]!; }
31
+ private repo(ref: TrackerRef) { return ref.scope["repo"]!; }
32
+
33
+ createComment(ref: TrackerRef, body: string) {
34
+ return this.client.createComment(
35
+ this.owner(ref), this.repo(ref), Number(ref.issueId), body
36
+ ).then(r => ({ id: String(r.id) }));
37
+ }
38
+
39
+ async editComment(ref: TrackerRef, commentId: string, body: string) {
40
+ await this.client.editComment(
41
+ this.owner(ref), this.repo(ref), Number(commentId), body
42
+ );
43
+ }
44
+
45
+ async deleteComment(ref: TrackerRef, commentId: string) {
46
+ await this.client.deleteComment(
47
+ this.owner(ref), this.repo(ref), Number(commentId)
48
+ );
49
+ }
50
+
51
+ listComments(ref: TrackerRef): Promise<TrackerComment[]> {
52
+ return this.client.listComments(
53
+ this.owner(ref), this.repo(ref), Number(ref.issueId)
54
+ ).then(comments => comments.map(c => ({
55
+ id: String(c.id),
56
+ body: c.body,
57
+ author: c.user.login,
58
+ createdAt: c.created_at,
59
+ })));
60
+ }
61
+
62
+ async closeIssue(ref: TrackerRef) {
63
+ await this.client.closeIssue(
64
+ this.owner(ref), this.repo(ref), Number(ref.issueId)
65
+ );
66
+ }
67
+
68
+ async setReaction(ref: TrackerRef, commentId: string, content: string, remove = false) {
69
+ if (remove) {
70
+ await this.client.removeCommentReaction(
71
+ this.owner(ref), this.repo(ref), Number(commentId), content
72
+ );
73
+ } else {
74
+ await this.client.addCommentReaction(
75
+ this.owner(ref), this.repo(ref), Number(commentId), content
76
+ );
77
+ }
78
+ }
79
+
80
+ getTrackerInstructions(ref: TrackerRef): TrackerInstructions {
81
+ const owner = this.owner(ref);
82
+ const repo = this.repo(ref);
83
+ const num = ref.issueId;
84
+ return {
85
+ clone: `git clone ${this.url}/${owner}/${repo}.git .`,
86
+ issueRef: `${owner}/${repo}#${num}`,
87
+ closeIssue: `tea issues close ${num} --repo ${owner}/${repo}`,
88
+ };
89
+ }
90
+
91
+ verifyWebhookSignature(rawBody: string, headers: Record<string, string | null>): boolean {
92
+ const signature = headers["x-gitea-signature"];
93
+ if (!this.webhookSecret) return true;
94
+ if (!signature) return false;
95
+
96
+ const expected = createHmac("sha256", this.webhookSecret).update(rawBody).digest("hex");
97
+ if (expected.length !== signature.length) return false;
98
+ return timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
99
+ }
100
+
101
+ parseWebhookEvent(rawBody: string): TrackerEvent | null {
102
+ let payload: Record<string, unknown>;
103
+ try {
104
+ payload = JSON.parse(rawBody);
105
+ } catch {
106
+ return null;
107
+ }
108
+
109
+ const action = payload.action;
110
+ const issue = payload.issue as Record<string, unknown> | undefined;
111
+ const comment = payload.comment as Record<string, unknown> | undefined;
112
+ const repository = payload.repository as Record<string, unknown> | undefined;
113
+ if (typeof action !== "string") return null;
114
+ if (!repository || typeof repository !== "object") return null;
115
+ if (!issue || typeof issue !== "object") return null;
116
+ if (issue.number == null) return null;
117
+
118
+ const repoOwner = (repository.owner as Record<string, string>)?.login;
119
+ const repoName = repository.name as string;
120
+ if (!repoOwner || !repoName) return null;
121
+
122
+ const ref: TrackerRef = {
123
+ trackerType: "gitea",
124
+ scope: { owner: repoOwner, repo: repoName },
125
+ issueId: String(issue.number),
126
+ };
127
+
128
+ const issueUser = issue.user as Record<string, string>;
129
+
130
+ if (action === "opened" || action === "reopened") {
131
+ return {
132
+ type: "issue_opened",
133
+ ref,
134
+ issue: {
135
+ title: issue.title as string,
136
+ body: (issue.body as string) ?? "",
137
+ state: (issue.state as string) ?? "open",
138
+ author: issueUser?.login ?? "",
139
+ },
140
+ };
141
+ }
142
+
143
+ if (action === "created" && comment) {
144
+ const commentUser = comment.user as Record<string, string>;
145
+ return {
146
+ type: "comment_created",
147
+ ref,
148
+ issue: {
149
+ title: issue.title as string,
150
+ body: (issue.body as string) ?? "",
151
+ state: (issue.state as string) ?? "open",
152
+ author: issueUser?.login ?? "",
153
+ },
154
+ comment: {
155
+ id: String(comment.id),
156
+ body: comment.body as string,
157
+ author: commentUser?.login ?? "",
158
+ },
159
+ };
160
+ }
161
+
162
+ if (action === "closed") {
163
+ return {
164
+ type: "issue_closed",
165
+ ref,
166
+ issue: {
167
+ title: issue.title as string,
168
+ body: (issue.body as string) ?? "",
169
+ state: "closed",
170
+ author: issueUser?.login ?? "",
171
+ },
172
+ };
173
+ }
174
+
175
+ return null;
176
+ }
177
+
178
+ isBotUser(userIdentifier: string): boolean {
179
+ return userIdentifier === this.botUsername;
180
+ }
181
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Issue Tracker Abstraction Layer.
3
+ * Three-entity model: Issue → OpSession → Process
4
+ */
5
+
6
+ // ─── Core Data Types ───
7
+
8
+ /** Tracker-agnostic issue reference */
9
+ export interface TrackerRef {
10
+ trackerType: string;
11
+ scope: Record<string, string>;
12
+ issueId: string;
13
+ }
14
+
15
+ /** Parsed, tracker-agnostic webhook event */
16
+ export type TrackerEventType = "issue_opened" | "comment_created" | "issue_closed";
17
+
18
+ export interface TrackerEvent {
19
+ type: TrackerEventType;
20
+ ref: TrackerRef;
21
+ issue: {
22
+ title: string;
23
+ body: string;
24
+ state: string;
25
+ author: string;
26
+ };
27
+ comment?: {
28
+ id: string;
29
+ body: string;
30
+ author: string;
31
+ };
32
+ }
33
+
34
+ /** Tracker-agnostic comment */
35
+ export interface TrackerComment {
36
+ id: string;
37
+ body: string;
38
+ author: string;
39
+ createdAt?: string; // ISO timestamp
40
+ }
41
+
42
+ /** Context injected into opencode prompts */
43
+ export interface TrackerInstructions {
44
+ clone: string;
45
+ issueRef: string;
46
+ closeIssue?: string;
47
+ }
48
+
49
+ // ─── Three-Entity Model ───
50
+
51
+ export type IssueState = "created" | "active" | "closed";
52
+
53
+ /** Issue = tracker resource with cyclic lifecycle (created → active → closed → active) */
54
+ export interface Issue {
55
+ id: string;
56
+ trackerType: string;
57
+ trackerScope: Record<string, string>;
58
+ trackerScopeKey: string;
59
+ trackerIssueId: string;
60
+ state: IssueState;
61
+ title: string;
62
+ createdAt: Date;
63
+ updatedAt: Date;
64
+ }
65
+
66
+ export type SessionState = "idle" | "running";
67
+
68
+ /** OpSession = persistent agent binding to one issue. Never destroyed. */
69
+ export interface OpSession {
70
+ id: string;
71
+ issueId: string;
72
+ name: string;
73
+ state: SessionState;
74
+ opencodeSessionId?: string;
75
+ opencodePid?: number;
76
+ workdir?: string;
77
+ createdAt: Date;
78
+ // Runtime state (persisted for crash recovery)
79
+ startedAt?: number;
80
+ progressCommentId?: string;
81
+ reactionCommentId?: string;
82
+ currentPrompt?: string;
83
+ }
84
+
85
+ /** Message = a prompt enqueued for a session */
86
+ export interface Message {
87
+ id: string;
88
+ sessionId: string;
89
+ content: string;
90
+ sourceCommentId?: string;
91
+ reactionCommentId?: string;
92
+ status: "pending" | "running" | "done" | "failed" | "interrupted";
93
+ attempts: number;
94
+ error?: string;
95
+ createdAt: Date;
96
+ updatedAt: Date;
97
+ }
98
+
99
+ // ─── Adapter Interface ───
100
+
101
+ export interface IssueTracker {
102
+ readonly type: string;
103
+
104
+ formatScopeKey(scope: Record<string, string>): string;
105
+
106
+ createComment(ref: TrackerRef, body: string): Promise<{ id: string }>;
107
+ editComment(ref: TrackerRef, commentId: string, body: string): Promise<void>;
108
+ deleteComment(ref: TrackerRef, commentId: string): Promise<void>;
109
+ listComments(ref: TrackerRef): Promise<TrackerComment[]>;
110
+ closeIssue(ref: TrackerRef): Promise<void>;
111
+
112
+ setReaction(ref: TrackerRef, commentId: string, content: string, remove?: boolean): Promise<void>;
113
+
114
+ getTrackerInstructions(ref: TrackerRef): TrackerInstructions;
115
+
116
+ verifyWebhookSignature(rawBody: string, headers: Record<string, string | null>): boolean;
117
+ parseWebhookEvent(rawBody: string): TrackerEvent | null;
118
+
119
+ isBotUser(userIdentifier: string): boolean;
120
+ }
121
+
122
+ // ─── Runtime Key Format ───
123
+
124
+ /**
125
+ * Runtime key: `trackerType:scopeKey#issueId@sessionName`
126
+ * gitea:owner/repo#123@ework-daemon
127
+ * plane:test-ws/proj-uuid#wi-456@ework-daemon
128
+ * Keys are NEVER stored in DB — purely runtime Map/Set indices.
129
+ */
130
+ export function formatKey(trackerType: string, scopeKey: string, issueId: string, sessionName: string): string {
131
+ return `${trackerType}:${scopeKey}#${issueId}@${sessionName}`;
132
+ }
133
+
134
+ export interface ParsedKey {
135
+ trackerType: string;
136
+ scopeKey: string;
137
+ issueId: string;
138
+ sessionName: string;
139
+ }
140
+
141
+ export function parseKey(k: string): ParsedKey | null {
142
+ const colonIdx = k.indexOf(":");
143
+ const hashIdx = k.lastIndexOf("#");
144
+ const atIdx = k.lastIndexOf("@");
145
+ if (colonIdx < 0 || hashIdx < 0 || atIdx < 0) return null;
146
+ if (colonIdx > hashIdx || hashIdx > atIdx) return null;
147
+
148
+ const trackerType = k.slice(0, colonIdx);
149
+ const scopeKey = k.slice(colonIdx + 1, hashIdx);
150
+ const issueId = k.slice(hashIdx + 1, atIdx);
151
+ const sessionName = k.slice(atIdx + 1);
152
+
153
+ if (!trackerType || !scopeKey || !issueId || !sessionName) return null;
154
+ return { trackerType, scopeKey, issueId, sessionName };
155
+ }