ework-daemon 0.4.4 → 0.4.6
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 +1 -1
- package/src/db.ts +105 -0
- package/src/opencode.ts +132 -5
- package/src/schema-mysql.sql +6 -3
- package/src/schema-sqlite.sql +6 -3
- package/src/server.ts +22 -1
package/package.json
CHANGED
package/src/db.ts
CHANGED
|
@@ -323,6 +323,111 @@ async function runMigrations(db: AsyncDatabase): Promise<void> {
|
|
|
323
323
|
await db.exec(`ALTER TABLE ${table} ADD COLUMN ${ddl}`);
|
|
324
324
|
};
|
|
325
325
|
|
|
326
|
+
// ── uid: surrogate AUTO_INCREMENT PK on issues/op_sessions/messages ──
|
|
327
|
+
// SQLite cannot ADD PRIMARY KEY via ALTER — must rebuild. Runs before
|
|
328
|
+
// owner_daemon_id etc. so rebuild only copies base columns; ephemeral
|
|
329
|
+
// coordination data (owner_daemon_id, nudge state) is re-added below.
|
|
330
|
+
const tMessages = `${prefix}messages`;
|
|
331
|
+
if (sqlite) {
|
|
332
|
+
const UID_REBUILDS: { table: string; createSql: string; dataCols: string }[] = [
|
|
333
|
+
{
|
|
334
|
+
table: tIssues,
|
|
335
|
+
createSql: `CREATE TABLE ${tIssues} (
|
|
336
|
+
uid INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
337
|
+
id TEXT NOT NULL UNIQUE,
|
|
338
|
+
tracker_type TEXT NOT NULL,
|
|
339
|
+
tracker_scope_key TEXT NOT NULL,
|
|
340
|
+
tracker_scope TEXT NOT NULL,
|
|
341
|
+
tracker_issue_id TEXT NOT NULL,
|
|
342
|
+
state TEXT NOT NULL DEFAULT 'created',
|
|
343
|
+
title TEXT NOT NULL DEFAULT '',
|
|
344
|
+
created_at TEXT NOT NULL,
|
|
345
|
+
updated_at TEXT NOT NULL,
|
|
346
|
+
UNIQUE(tracker_type, tracker_scope_key, tracker_issue_id)
|
|
347
|
+
)`,
|
|
348
|
+
dataCols:
|
|
349
|
+
"id, tracker_type, tracker_scope_key, tracker_scope, tracker_issue_id, state, title, created_at, updated_at",
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
table: tSessions,
|
|
353
|
+
createSql: `CREATE TABLE ${tSessions} (
|
|
354
|
+
uid INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
355
|
+
id TEXT NOT NULL UNIQUE,
|
|
356
|
+
issue_id TEXT NOT NULL REFERENCES ${tIssues}(id) ON DELETE CASCADE,
|
|
357
|
+
name TEXT NOT NULL,
|
|
358
|
+
state TEXT NOT NULL DEFAULT 'idle',
|
|
359
|
+
opencode_session_id TEXT,
|
|
360
|
+
opencode_pid INTEGER,
|
|
361
|
+
workdir TEXT,
|
|
362
|
+
created_at TEXT NOT NULL,
|
|
363
|
+
started_at INTEGER,
|
|
364
|
+
progress_comment_id TEXT,
|
|
365
|
+
reaction_comment_id TEXT,
|
|
366
|
+
current_prompt TEXT,
|
|
367
|
+
UNIQUE(issue_id, name)
|
|
368
|
+
)`,
|
|
369
|
+
dataCols:
|
|
370
|
+
"id, issue_id, name, state, opencode_session_id, opencode_pid, workdir, created_at, started_at, progress_comment_id, reaction_comment_id, current_prompt",
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
table: tMessages,
|
|
374
|
+
createSql: `CREATE TABLE ${tMessages} (
|
|
375
|
+
uid INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
376
|
+
id TEXT NOT NULL UNIQUE,
|
|
377
|
+
session_id TEXT NOT NULL REFERENCES ${tSessions}(id) ON DELETE CASCADE,
|
|
378
|
+
content TEXT NOT NULL,
|
|
379
|
+
source_comment_id TEXT,
|
|
380
|
+
reaction_comment_id TEXT,
|
|
381
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
382
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
383
|
+
error TEXT,
|
|
384
|
+
created_at TEXT NOT NULL,
|
|
385
|
+
updated_at TEXT NOT NULL
|
|
386
|
+
)`,
|
|
387
|
+
dataCols:
|
|
388
|
+
"id, session_id, content, source_comment_id, reaction_comment_id, status, attempts, error, created_at, updated_at",
|
|
389
|
+
},
|
|
390
|
+
];
|
|
391
|
+
|
|
392
|
+
for (const { table, createSql, dataCols } of UID_REBUILDS) {
|
|
393
|
+
if (await hasColumn(table, "uid")) continue;
|
|
394
|
+
const exists = await db.get<{ cnt: number }>(
|
|
395
|
+
`SELECT COUNT(*) AS cnt FROM sqlite_master WHERE type='table' AND name='${table}'`
|
|
396
|
+
);
|
|
397
|
+
if (Number(exists?.cnt ?? 0) === 0) continue;
|
|
398
|
+
|
|
399
|
+
await db.exec("PRAGMA foreign_keys = OFF");
|
|
400
|
+
try {
|
|
401
|
+
await db.transaction(async () => {
|
|
402
|
+
await db.exec(`ALTER TABLE ${table} RENAME TO ${table}__old_uid`);
|
|
403
|
+
await db.exec(createSql);
|
|
404
|
+
await db.exec(
|
|
405
|
+
`CREATE INDEX IF NOT EXISTS idx_${table}__id ON ${table}(id)`
|
|
406
|
+
);
|
|
407
|
+
await db.exec(`INSERT INTO ${table} (${dataCols}) SELECT ${dataCols} FROM ${table}__old_uid`);
|
|
408
|
+
await db.exec(`DROP TABLE ${table}__old_uid`);
|
|
409
|
+
});
|
|
410
|
+
} finally {
|
|
411
|
+
await db.exec("PRAGMA foreign_keys = ON");
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
} else {
|
|
415
|
+
const uidMySqlTables = [tIssues, tSessions, tMessages];
|
|
416
|
+
for (const tbl of uidMySqlTables) {
|
|
417
|
+
if (await hasColumn(tbl, "uid")) continue;
|
|
418
|
+
try {
|
|
419
|
+
await db.exec(
|
|
420
|
+
`ALTER TABLE ${tbl} DROP PRIMARY KEY, ` +
|
|
421
|
+
`ADD COLUMN uid BIGINT AUTO_INCREMENT PRIMARY KEY FIRST, ` +
|
|
422
|
+
`ADD UNIQUE INDEX uk_${tbl}_id (id)`
|
|
423
|
+
);
|
|
424
|
+
} catch (e) {
|
|
425
|
+
if (e && typeof e === "object" && "errno" in e && (e as { errno: number }).errno === 1146) continue;
|
|
426
|
+
throw e;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
326
431
|
// issues.owner_daemon_id — points at the leasing daemon (nullable = unclaimed).
|
|
327
432
|
await ensureColumn(
|
|
328
433
|
tIssues,
|
package/src/opencode.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn, type Subprocess } from "bun";
|
|
2
|
-
import { mkdirSync, writeFileSync, readdirSync } from "fs";
|
|
2
|
+
import { mkdirSync, writeFileSync, readdirSync, existsSync } from "fs";
|
|
3
3
|
import { join, resolve, isAbsolute } from "path";
|
|
4
4
|
import { homedir } from "os";
|
|
5
5
|
import { log } from "./logger";
|
|
@@ -27,6 +27,71 @@ export interface TakeoverStrategy {
|
|
|
27
27
|
resumeOpenCodeSession(session: OpSession): Promise<string | null>;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
export interface GroupConfig {
|
|
31
|
+
workdirTemplate?: string;
|
|
32
|
+
initScript?: string;
|
|
33
|
+
destroyScript?: string;
|
|
34
|
+
envInitScript?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Substitute {owner}/{repo}/{issue}/{session} in a workdir template. */
|
|
38
|
+
export function resolveTemplatedWorkdir(
|
|
39
|
+
template: string,
|
|
40
|
+
issue: { trackerScopeKey: string; trackerScope: Record<string, unknown>; trackerIssueId: string | number },
|
|
41
|
+
session: { name: string },
|
|
42
|
+
baseWorkdir?: string,
|
|
43
|
+
): string {
|
|
44
|
+
const parts = issue.trackerScopeKey.split("/");
|
|
45
|
+
const owner = (issue.trackerScope["owner"] as string) || parts[0] || "default";
|
|
46
|
+
const repo = (issue.trackerScope["repo"] as string) || parts[parts.length - 1] || "default";
|
|
47
|
+
// Use replacer functions to avoid `$&`/`$1` interpretation in replacement strings.
|
|
48
|
+
let dir = template
|
|
49
|
+
.replace(/\{owner\}/g, () => String(owner))
|
|
50
|
+
.replace(/\{repo\}/g, () => String(repo))
|
|
51
|
+
.replace(/\{issue\}/g, () => String(issue.trackerIssueId))
|
|
52
|
+
.replace(/\{session\}/g, () => session.name);
|
|
53
|
+
if (dir.startsWith("~")) {
|
|
54
|
+
dir = join(homedir(), dir.slice(1));
|
|
55
|
+
} else if (!isAbsolute(dir) && baseWorkdir) {
|
|
56
|
+
dir = resolve(baseWorkdir, dir);
|
|
57
|
+
}
|
|
58
|
+
return dir;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Run a lifecycle script via `bash -c` with cwd=workdir. Uses async Bun.spawn
|
|
62
|
+
* (not spawnSync) so the event loop is not blocked. A 60s hard timeout kills
|
|
63
|
+
* hung scripts. Failures are logged and swallowed — never throws — so a broken
|
|
64
|
+
* init/destroy never blocks the opencode task flow. */
|
|
65
|
+
const HOOK_SCRIPT_TIMEOUT_MS = 60_000;
|
|
66
|
+
export async function runHookScript(script: string | undefined, workdir: string, label: string, env: Record<string, string> = {}, timeoutMs?: number): Promise<void> {
|
|
67
|
+
if (!script || !script.trim()) return;
|
|
68
|
+
const effectiveTimeout = timeoutMs ?? HOOK_SCRIPT_TIMEOUT_MS;
|
|
69
|
+
try {
|
|
70
|
+
mkdirSync(workdir, { recursive: true });
|
|
71
|
+
const proc = Bun.spawn({
|
|
72
|
+
cmd: ["bash", "-c", script],
|
|
73
|
+
cwd: workdir,
|
|
74
|
+
stdout: "pipe",
|
|
75
|
+
stderr: "pipe",
|
|
76
|
+
env: { ...process.env, ...env },
|
|
77
|
+
});
|
|
78
|
+
const timer = setTimeout(() => { try { proc.kill("SIGKILL"); } catch { /* already dead */ } }, effectiveTimeout);
|
|
79
|
+
try {
|
|
80
|
+
const exitCode = await proc.exited;
|
|
81
|
+
const stderr = await new Response(proc.stderr).text().catch(() => "");
|
|
82
|
+
if (exitCode !== 0) {
|
|
83
|
+
log.warn(`engine: ${label} exited ${exitCode}: ${stderr.slice(0, 500)}`);
|
|
84
|
+
} else if (stderr) {
|
|
85
|
+
log.info(`engine: ${label} stderr: ${stderr.slice(0, 300)}`);
|
|
86
|
+
}
|
|
87
|
+
} finally {
|
|
88
|
+
clearTimeout(timer);
|
|
89
|
+
}
|
|
90
|
+
} catch (e) {
|
|
91
|
+
log.warn(`engine: ${label} failed: ${(e as Error).message}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
30
95
|
/**
|
|
31
96
|
* Default TakeoverStrategy: deterministic per-issue workdir under
|
|
32
97
|
* `<baseWorkdir>/<owner>--<repo>/<issueId>/<sessionName>`, with a best-effort
|
|
@@ -158,6 +223,8 @@ export class Engine {
|
|
|
158
223
|
private observedIssues = new Set<string>();
|
|
159
224
|
private observerTimer?: ReturnType<typeof setInterval>;
|
|
160
225
|
|
|
226
|
+
private groupConfigs = new Map<string, GroupConfig>();
|
|
227
|
+
|
|
161
228
|
private static MAX_INLINE_SIZE = 4000;
|
|
162
229
|
private static MAX_NUDGE_ROUNDS = 1;
|
|
163
230
|
private static MAX_STUCK_NUDGE_ROUNDS = 1;
|
|
@@ -234,9 +301,44 @@ export class Engine {
|
|
|
234
301
|
}
|
|
235
302
|
|
|
236
303
|
private async resolveWorkdir(session: OpSession, issue: Issue): Promise<string> {
|
|
304
|
+
const gc = this.groupConfigFor(issue);
|
|
305
|
+
if (gc?.workdirTemplate && !session.workdir) {
|
|
306
|
+
const dir = resolveTemplatedWorkdir(gc.workdirTemplate, issue, session, this.cfg.opencode.baseWorkdir);
|
|
307
|
+
mkdirSync(dir, { recursive: true });
|
|
308
|
+
return dir;
|
|
309
|
+
}
|
|
237
310
|
return this.takeover.acquireWorkdir(session, issue);
|
|
238
311
|
}
|
|
239
312
|
|
|
313
|
+
private hookEnvFor(issue: Issue, session: OpSession, workdir: string): Record<string, string> {
|
|
314
|
+
const parts = issue.trackerScopeKey.split("/");
|
|
315
|
+
const owner = (issue.trackerScope["owner"] as string) || parts[0] || "";
|
|
316
|
+
const repo = (issue.trackerScope["repo"] as string) || parts[parts.length - 1] || "";
|
|
317
|
+
return {
|
|
318
|
+
EWORK_OWNER: String(owner),
|
|
319
|
+
EWORK_REPO: String(repo),
|
|
320
|
+
EWORK_ISSUE: String(issue.trackerIssueId),
|
|
321
|
+
EWORK_SESSION: session.name,
|
|
322
|
+
EWORK_WORKDIR: workdir,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private workdirPathFor(session: OpSession, issue: Issue): string {
|
|
327
|
+
if (session.workdir) {
|
|
328
|
+
let dir = session.workdir;
|
|
329
|
+
if (dir.startsWith("~")) dir = join(homedir(), dir.slice(1));
|
|
330
|
+
return isAbsolute(dir) ? dir : resolve(this.cfg.opencode.baseWorkdir, dir);
|
|
331
|
+
}
|
|
332
|
+
const gc = this.groupConfigFor(issue);
|
|
333
|
+
if (gc?.workdirTemplate) {
|
|
334
|
+
return resolveTemplatedWorkdir(gc.workdirTemplate, issue, session, this.cfg.opencode.baseWorkdir);
|
|
335
|
+
}
|
|
336
|
+
const parts = issue.trackerScopeKey.split("/");
|
|
337
|
+
const owner = (issue.trackerScope["owner"] as string) || parts[0] || "default";
|
|
338
|
+
const repo = (issue.trackerScope["repo"] as string) || parts[parts.length - 1] || "default";
|
|
339
|
+
return join(this.cfg.opencode.baseWorkdir, `${owner}--${repo}`, String(issue.trackerIssueId), session.name);
|
|
340
|
+
}
|
|
341
|
+
|
|
240
342
|
private async persistRuntimeState(sessionId: string) {
|
|
241
343
|
const session = await this.store.getSession(sessionId);
|
|
242
344
|
if (!session) return;
|
|
@@ -314,11 +416,15 @@ export class Engine {
|
|
|
314
416
|
|
|
315
417
|
// ─── Event Dispatch ───
|
|
316
418
|
|
|
317
|
-
async handleEvent(event: TrackerEvent) {
|
|
419
|
+
async handleEvent(event: TrackerEvent, groupConfig?: GroupConfig) {
|
|
318
420
|
const { ref, issue: issueData } = event;
|
|
319
421
|
const tracker = this.getTracker(ref.trackerType);
|
|
320
422
|
const scopeKey = tracker.formatScopeKey(ref.scope);
|
|
321
423
|
|
|
424
|
+
if (groupConfig) {
|
|
425
|
+
this.groupConfigs.set(`${ref.trackerType}:${scopeKey}#${ref.issueId}`, groupConfig);
|
|
426
|
+
}
|
|
427
|
+
|
|
322
428
|
switch (event.type) {
|
|
323
429
|
case "issue_opened":
|
|
324
430
|
return this.handleOpened(ref, scopeKey, issueData, tracker, event.model);
|
|
@@ -329,6 +435,10 @@ export class Engine {
|
|
|
329
435
|
}
|
|
330
436
|
}
|
|
331
437
|
|
|
438
|
+
private groupConfigFor(issue: Issue): GroupConfig | undefined {
|
|
439
|
+
return this.groupConfigs.get(`${issue.trackerType}:${issue.trackerScopeKey}#${issue.trackerIssueId}`);
|
|
440
|
+
}
|
|
441
|
+
|
|
332
442
|
private async handleOpened(
|
|
333
443
|
ref: TrackerRef,
|
|
334
444
|
scopeKey: string,
|
|
@@ -515,6 +625,7 @@ export class Engine {
|
|
|
515
625
|
) {
|
|
516
626
|
const issue = await this.store.findIssue(ref.trackerType, scopeKey, ref.issueId);
|
|
517
627
|
if (!issue) return;
|
|
628
|
+
if (issue.state === "closed") return;
|
|
518
629
|
|
|
519
630
|
await this.store.updateIssueState(issue.id, "closed");
|
|
520
631
|
this.stopObserver(issue.id);
|
|
@@ -528,19 +639,30 @@ export class Engine {
|
|
|
528
639
|
this.stopping.add(k);
|
|
529
640
|
try { this.killProcessTree(proc.pid, "SIGTERM"); } catch { /* already dead */ }
|
|
530
641
|
}
|
|
531
|
-
// Clear runtime state
|
|
532
642
|
this.clearRuntimeState(k);
|
|
533
|
-
// Mark pending/running messages as interrupted
|
|
534
643
|
const msgs = await this.store.getMessagesForSession(session.id);
|
|
535
644
|
for (const msg of msgs) {
|
|
536
645
|
if (msg.status === "pending" || msg.status === "running") {
|
|
537
646
|
await this.store.updateMessageStatus(msg.id, "interrupted", "issue closed");
|
|
538
647
|
}
|
|
539
648
|
}
|
|
540
|
-
// Update session state
|
|
541
649
|
await this.store.updateSession(session.id, { state: "idle", opencodePid: undefined });
|
|
542
650
|
}
|
|
543
651
|
|
|
652
|
+
const gcKey = `${ref.trackerType}:${scopeKey}#${ref.issueId}`;
|
|
653
|
+
const gc = this.groupConfigFor(issue);
|
|
654
|
+
if (gc?.destroyScript) {
|
|
655
|
+
const workdirs = new Set<string>();
|
|
656
|
+
for (const session of sessions) {
|
|
657
|
+
const workdir = this.workdirPathFor(session, issue);
|
|
658
|
+
if (existsSync(workdir)) workdirs.add(workdir);
|
|
659
|
+
}
|
|
660
|
+
for (const workdir of workdirs) {
|
|
661
|
+
await runHookScript(gc.destroyScript, workdir, `destroyScript for ${scopeKey}#${ref.issueId}`, this.hookEnvFor(issue, { name: "" } as OpSession, workdir));
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
if (this.groupConfigs.get(gcKey) === gc) this.groupConfigs.delete(gcKey);
|
|
665
|
+
|
|
544
666
|
log.info(`engine: issue closed, ${sessions.length} sessions paused for ${scopeKey}#${ref.issueId}`);
|
|
545
667
|
}
|
|
546
668
|
|
|
@@ -635,6 +757,11 @@ export class Engine {
|
|
|
635
757
|
|
|
636
758
|
const workdir = await this.resolveWorkdir(session, issue);
|
|
637
759
|
|
|
760
|
+
const gc = this.groupConfigFor(issue);
|
|
761
|
+
if (gc?.initScript) {
|
|
762
|
+
await runHookScript(gc.initScript, workdir, `initScript for ${k}`, this.hookEnvFor(issue, session, workdir));
|
|
763
|
+
}
|
|
764
|
+
|
|
638
765
|
const ref = this.sessionToRef(session, issue);
|
|
639
766
|
const tracker = this.getTracker(issue.trackerType);
|
|
640
767
|
|
package/src/schema-mysql.sql
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
-- tables InnoDB + utf8mb4 for FK CASCADE + full Unicode (emoji).
|
|
9
9
|
|
|
10
10
|
CREATE TABLE IF NOT EXISTS {{issues}} (
|
|
11
|
-
|
|
11
|
+
uid BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
12
|
+
id VARCHAR(36) NOT NULL UNIQUE,
|
|
12
13
|
tracker_type VARCHAR(64) NOT NULL,
|
|
13
14
|
tracker_scope_key VARCHAR(255) NOT NULL,
|
|
14
15
|
tracker_scope TEXT NOT NULL,
|
|
@@ -21,7 +22,8 @@ CREATE TABLE IF NOT EXISTS {{issues}} (
|
|
|
21
22
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
22
23
|
|
|
23
24
|
CREATE TABLE IF NOT EXISTS {{op_sessions}} (
|
|
24
|
-
|
|
25
|
+
uid BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
26
|
+
id VARCHAR(36) NOT NULL UNIQUE,
|
|
25
27
|
issue_id VARCHAR(36) NOT NULL,
|
|
26
28
|
name VARCHAR(64) NOT NULL,
|
|
27
29
|
state VARCHAR(16) NOT NULL DEFAULT 'idle',
|
|
@@ -41,7 +43,8 @@ CREATE TABLE IF NOT EXISTS {{op_sessions}} (
|
|
|
41
43
|
-- idx_sessions_issue is needed on MySQL.
|
|
42
44
|
|
|
43
45
|
CREATE TABLE IF NOT EXISTS {{messages}} (
|
|
44
|
-
|
|
46
|
+
uid BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
47
|
+
id VARCHAR(36) NOT NULL UNIQUE,
|
|
45
48
|
session_id VARCHAR(36) NOT NULL,
|
|
46
49
|
content LONGTEXT NOT NULL,
|
|
47
50
|
source_comment_id VARCHAR(64),
|
package/src/schema-sqlite.sql
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
-- share one DB across multiple daemon instances.
|
|
5
5
|
|
|
6
6
|
CREATE TABLE IF NOT EXISTS {{issues}} (
|
|
7
|
-
|
|
7
|
+
uid INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
8
|
+
id TEXT NOT NULL UNIQUE,
|
|
8
9
|
tracker_type TEXT NOT NULL,
|
|
9
10
|
tracker_scope_key TEXT NOT NULL,
|
|
10
11
|
tracker_scope TEXT NOT NULL,
|
|
@@ -17,7 +18,8 @@ CREATE TABLE IF NOT EXISTS {{issues}} (
|
|
|
17
18
|
);
|
|
18
19
|
|
|
19
20
|
CREATE TABLE IF NOT EXISTS {{op_sessions}} (
|
|
20
|
-
|
|
21
|
+
uid INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
22
|
+
id TEXT NOT NULL UNIQUE,
|
|
21
23
|
issue_id TEXT NOT NULL REFERENCES {{issues}}(id) ON DELETE CASCADE,
|
|
22
24
|
name TEXT NOT NULL,
|
|
23
25
|
state TEXT NOT NULL DEFAULT 'idle',
|
|
@@ -33,7 +35,8 @@ CREATE TABLE IF NOT EXISTS {{op_sessions}} (
|
|
|
33
35
|
);
|
|
34
36
|
|
|
35
37
|
CREATE TABLE IF NOT EXISTS {{messages}} (
|
|
36
|
-
|
|
38
|
+
uid INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
39
|
+
id TEXT NOT NULL UNIQUE,
|
|
37
40
|
session_id TEXT NOT NULL REFERENCES {{op_sessions}}(id) ON DELETE CASCADE,
|
|
38
41
|
content TEXT NOT NULL,
|
|
39
42
|
source_comment_id TEXT,
|
package/src/server.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Config } from "./config";
|
|
2
2
|
import type { Store } from "./op";
|
|
3
3
|
import type { Engine } from "./opencode";
|
|
4
|
+
import type { GroupConfig } from "./opencode";
|
|
4
5
|
import type { IssueTracker, TrackerEvent } from "./trackers/types";
|
|
5
6
|
import { OpencodeReader, OpencodeReaderError } from "./opencode-reader";
|
|
6
7
|
import { listDir, readFile, readFileSince, FileApiError } from "./file-api";
|
|
@@ -8,6 +9,24 @@ import { log, uptimeSeconds, version } from "./logger";
|
|
|
8
9
|
|
|
9
10
|
type TrackerMap = Map<string, IssueTracker>;
|
|
10
11
|
|
|
12
|
+
export function parseGroupConfigHeader(raw: string | null): GroupConfig | undefined {
|
|
13
|
+
if (!raw) return undefined;
|
|
14
|
+
if (raw.length > 16_384) return undefined;
|
|
15
|
+
try {
|
|
16
|
+
const decoded = Buffer.from(raw, "base64").toString("utf8");
|
|
17
|
+
const parsed = JSON.parse(decoded);
|
|
18
|
+
if (typeof parsed !== "object" || parsed === null) return undefined;
|
|
19
|
+
const gc = parsed as Record<string, unknown>;
|
|
20
|
+
if (gc.workdirTemplate !== undefined && typeof gc.workdirTemplate !== "string") return undefined;
|
|
21
|
+
if (gc.initScript !== undefined && typeof gc.initScript !== "string") return undefined;
|
|
22
|
+
if (gc.destroyScript !== undefined && typeof gc.destroyScript !== "string") return undefined;
|
|
23
|
+
if (gc.envInitScript !== undefined && typeof gc.envInitScript !== "string") return undefined;
|
|
24
|
+
return gc as GroupConfig;
|
|
25
|
+
} catch {
|
|
26
|
+
}
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
11
30
|
function json(data: unknown, status = 200) {
|
|
12
31
|
return new Response(JSON.stringify(data, null, 2), {
|
|
13
32
|
status,
|
|
@@ -41,7 +60,9 @@ export function createServer(
|
|
|
41
60
|
`webhook: type=${event.type} ref=${event.ref.trackerType}:${event.ref.scope.owner ?? ""}/${event.ref.scope.repo ?? ""}#${event.ref.issueId}`
|
|
42
61
|
);
|
|
43
62
|
|
|
44
|
-
|
|
63
|
+
const groupConfig = parseGroupConfigHeader(req.headers.get("x-ework-group-config"));
|
|
64
|
+
|
|
65
|
+
engine.handleEvent(event, groupConfig).catch((err) => {
|
|
45
66
|
log.error("webhook: handler error:", err);
|
|
46
67
|
});
|
|
47
68
|
|