ework-daemon 0.4.26 → 0.4.27
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/op.ts +20 -18
- package/src/opencode.ts +46 -110
- package/src/runtime/opencode-backend.ts +129 -0
- package/src/runtime/types.ts +33 -0
package/package.json
CHANGED
package/src/op.ts
CHANGED
|
@@ -230,25 +230,27 @@ export class Store {
|
|
|
230
230
|
const existing = rowToSession(row);
|
|
231
231
|
const updated = { ...existing, ...patch };
|
|
232
232
|
|
|
233
|
+
const sets: string[] = [];
|
|
234
|
+
const vals: (string | number | null)[] = [];
|
|
235
|
+
if (updated.state !== undefined) { sets.push("state = ?"); vals.push(updated.state); }
|
|
236
|
+
if (updated.opencodeSessionId !== undefined) { sets.push("opencode_session_id = ?"); vals.push(updated.opencodeSessionId ?? null); }
|
|
237
|
+
if (updated.opencodePid !== undefined) { sets.push("opencode_pid = ?"); vals.push(updated.opencodePid ?? null); }
|
|
238
|
+
if (updated.workdir !== undefined) { sets.push("workdir = ?"); vals.push(updated.workdir ?? null); }
|
|
239
|
+
if (updated.startedAt !== undefined) { sets.push("started_at = ?"); vals.push(updated.startedAt ?? null); }
|
|
240
|
+
if (updated.progressCommentId !== undefined) { sets.push("progress_comment_id = ?"); vals.push(updated.progressCommentId ?? null); }
|
|
241
|
+
if (updated.reactionCommentId !== undefined) { sets.push("reaction_comment_id = ?"); vals.push(updated.reactionCommentId ?? null); }
|
|
242
|
+
if (updated.currentPrompt !== undefined) { sets.push("current_prompt = ?"); vals.push(updated.currentPrompt ?? null); }
|
|
243
|
+
if (updated.lastOutputAt !== undefined) { sets.push("last_output_at = ?"); vals.push(updated.lastOutputAt != null ? new Date(updated.lastOutputAt).toISOString() : null); }
|
|
244
|
+
if (updated.nudgeRounds !== undefined) { sets.push("nudge_rounds = ?"); vals.push(updated.nudgeRounds); }
|
|
245
|
+
if (updated.stuckNudgeRounds !== undefined) { sets.push("stuck_nudge_rounds = ?"); vals.push(updated.stuckNudgeRounds); }
|
|
246
|
+
if (updated.generation !== undefined) { sets.push("generation = ?"); vals.push(updated.generation); }
|
|
247
|
+
|
|
248
|
+
if (sets.length === 0) return updated;
|
|
249
|
+
|
|
250
|
+
vals.push(id);
|
|
233
251
|
await getDB().run(
|
|
234
|
-
`UPDATE {{op_sessions}} SET
|
|
235
|
-
|
|
236
|
-
last_output_at = ?, nudge_rounds = ?, stuck_nudge_rounds = ?, generation = ? WHERE uid = ?`,
|
|
237
|
-
[
|
|
238
|
-
updated.state,
|
|
239
|
-
updated.opencodeSessionId ?? null,
|
|
240
|
-
updated.opencodePid ?? null,
|
|
241
|
-
updated.workdir ?? null,
|
|
242
|
-
updated.startedAt ?? null,
|
|
243
|
-
updated.progressCommentId ?? null,
|
|
244
|
-
updated.reactionCommentId ?? null,
|
|
245
|
-
updated.currentPrompt ?? null,
|
|
246
|
-
updated.lastOutputAt != null ? new Date(updated.lastOutputAt).toISOString() : null,
|
|
247
|
-
updated.nudgeRounds ?? 0,
|
|
248
|
-
updated.stuckNudgeRounds ?? 0,
|
|
249
|
-
updated.generation ?? 0,
|
|
250
|
-
id,
|
|
251
|
-
]
|
|
252
|
+
`UPDATE {{op_sessions}} SET ${sets.join(", ")} WHERE uid = ?`,
|
|
253
|
+
vals
|
|
252
254
|
);
|
|
253
255
|
return updated;
|
|
254
256
|
}
|
package/src/opencode.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { spawn, type Subprocess } from "bun";
|
|
2
1
|
import { Database } from "bun:sqlite";
|
|
3
2
|
import { mkdirSync, writeFileSync, readdirSync, existsSync } from "fs";
|
|
4
3
|
import { join, resolve, isAbsolute } from "path";
|
|
@@ -8,6 +7,8 @@ import type { Config } from "./config";
|
|
|
8
7
|
import type { Store } from "./op";
|
|
9
8
|
import type { IssueTracker, TrackerRef, TrackerEvent, TrackerComment, Issue, OpSession, Message } from "./trackers/types";
|
|
10
9
|
import { formatKey, parseKey } from "./trackers/types";
|
|
10
|
+
import type { RuntimeBackend, RuntimeHandle } from "./runtime/types";
|
|
11
|
+
import { OpencodeBackend } from "./runtime/opencode-backend";
|
|
11
12
|
|
|
12
13
|
// ─── Types ───
|
|
13
14
|
|
|
@@ -282,6 +283,8 @@ export interface EngineOptions {
|
|
|
282
283
|
daemonId: number;
|
|
283
284
|
/** Workdir + session-resume strategy; defaults to RecloneStrategy. */
|
|
284
285
|
takeover?: TakeoverStrategy;
|
|
286
|
+
/** Runtime backend (opencode/pi); defaults to OpencodeBackend. */
|
|
287
|
+
backend?: RuntimeBackend;
|
|
285
288
|
}
|
|
286
289
|
|
|
287
290
|
export class Engine {
|
|
@@ -290,10 +293,11 @@ export class Engine {
|
|
|
290
293
|
private trackers: TrackerRegistry;
|
|
291
294
|
private readonly daemonId: number;
|
|
292
295
|
private readonly takeover: TakeoverStrategy;
|
|
296
|
+
private readonly backend: RuntimeBackend;
|
|
293
297
|
private heartbeatTimer?: ReturnType<typeof setInterval>;
|
|
294
298
|
|
|
295
299
|
// Runtime state keyed by session key (trackerType:scopeKey#issueId@sessionName)
|
|
296
|
-
private processes = new Map<string,
|
|
300
|
+
private processes = new Map<string, RuntimeHandle>();
|
|
297
301
|
private running = new Set<string>();
|
|
298
302
|
private stopping = new Set<string>();
|
|
299
303
|
private processingComments = new Set<string>();
|
|
@@ -338,6 +342,7 @@ export class Engine {
|
|
|
338
342
|
this.trackers = trackers;
|
|
339
343
|
this.daemonId = opts.daemonId;
|
|
340
344
|
this.takeover = opts.takeover ?? new RecloneStrategy(cfg);
|
|
345
|
+
this.backend = opts.backend ?? new OpencodeBackend(cfg.opencode.binary, cfg.opencode.dbPath, cfg.childEnvDeny);
|
|
341
346
|
this.startGlobalObserver();
|
|
342
347
|
void this.recover();
|
|
343
348
|
}
|
|
@@ -545,10 +550,6 @@ export class Engine {
|
|
|
545
550
|
return [...comments].reverse().find(c => tracker.isBotUser(c.author) && !this.isSystemComment(c));
|
|
546
551
|
}
|
|
547
552
|
|
|
548
|
-
private async checkSessionOutput(opencodeSessionId: string | undefined): Promise<{ hasOutput: boolean; tokenCount: number }> {
|
|
549
|
-
return checkSessionOutput(this.cfg.opencode.dbPath, opencodeSessionId);
|
|
550
|
-
}
|
|
551
|
-
|
|
552
553
|
// ─── Event Dispatch ───
|
|
553
554
|
|
|
554
555
|
async handleEvent(event: TrackerEvent, groupConfig?: GroupConfig) {
|
|
@@ -966,129 +967,69 @@ export class Engine {
|
|
|
966
967
|
const ref = this.sessionToRef(session, issue);
|
|
967
968
|
const tracker = this.getTracker(issue.trackerType);
|
|
968
969
|
|
|
969
|
-
const args = [this.cfg.opencode.binary, "run", "--format", "json", "--dir", workdir];
|
|
970
|
-
// Prefer the captured session id (resume our own previous run). Otherwise
|
|
971
|
-
// ask the takeover strategy whether a resumable session exists elsewhere
|
|
972
|
-
// (e.g. NAS-backed). Default strategy returns null → fresh session.
|
|
973
970
|
let resumeSessionId = session.opencodeSessionId;
|
|
974
971
|
if (!resumeSessionId) {
|
|
975
972
|
const fromStrategy = await this.takeover.resumeOpenCodeSession(session);
|
|
976
973
|
if (fromStrategy) resumeSessionId = fromStrategy;
|
|
977
974
|
}
|
|
978
|
-
if (resumeSessionId && !(await
|
|
979
|
-
log.warn(`stale
|
|
975
|
+
if (resumeSessionId && !(await this.backend.sessionExists(resumeSessionId))) {
|
|
976
|
+
log.warn(`stale session ${resumeSessionId} not found in db, starting fresh`);
|
|
980
977
|
resumeSessionId = undefined;
|
|
981
978
|
await this.store.updateSession(session.id, { opencodeSessionId: undefined });
|
|
982
979
|
}
|
|
983
|
-
|
|
984
|
-
args.push("--session", resumeSessionId);
|
|
985
|
-
}
|
|
986
|
-
// Resolve the model: explicit per-message > daemon default > omit.
|
|
987
|
-
// Without this, opencode's own default (influenced by plugin agent
|
|
988
|
-
// presets) can pick a non-existent model, causing ProviderModelNotFoundError.
|
|
980
|
+
|
|
989
981
|
const model = msg.model || this.cfg.opencode.defaultModel;
|
|
990
982
|
this.currentModel.set(k, model);
|
|
991
|
-
if (model) {
|
|
992
|
-
args.push("--model", model);
|
|
993
|
-
}
|
|
994
|
-
args.push(msg.content);
|
|
995
983
|
|
|
996
|
-
// Set eyes reaction on the source comment
|
|
997
984
|
if (msg.sourceCommentId) {
|
|
998
|
-
try {
|
|
999
|
-
await tracker.setReaction(ref, msg.sourceCommentId, "eyes");
|
|
1000
|
-
} catch { /* non-critical */ }
|
|
985
|
+
try { await tracker.setReaction(ref, msg.sourceCommentId, "eyes"); } catch { /* non-critical */ }
|
|
1001
986
|
}
|
|
1002
987
|
|
|
1003
|
-
let exitCode: number | null = null;
|
|
1004
|
-
|
|
1005
|
-
// M-1: build the child env explicitly. When a model IS resolved we push
|
|
1006
|
-
// --model above, so opencode ignores OPENCODE_MODEL anyway. When NO model
|
|
1007
|
-
// is resolved we must not let a leaked OPENCODE_MODEL from our own env
|
|
1008
|
-
// silently win — strip it so opencode falls back to its opencode.json
|
|
1009
|
-
// (the operator's explicit config) instead of arbitrary env pollution.
|
|
1010
|
-
// Provider keys / Gitea vars are preserved (opencode + its plugin need
|
|
1011
|
-
// them); only the explicit model-override var is neutralized.
|
|
1012
988
|
const childEnv = { ...process.env, ...this.hookEnvFor(issue, session, workdir) };
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
delete childEnv.OPENCODE_PID;
|
|
1016
|
-
delete childEnv.OPENCODE_RUN_ID;
|
|
1017
|
-
delete childEnv.OPENCODE_PROCESS_ROLE;
|
|
1018
|
-
for (const k of this.cfg.childEnvDeny) delete childEnv[k];
|
|
989
|
+
|
|
990
|
+
let exitCode: number | null = null;
|
|
1019
991
|
|
|
1020
992
|
try {
|
|
1021
|
-
const
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
993
|
+
const handle = await this.backend.spawn(
|
|
994
|
+
{
|
|
995
|
+
workdir,
|
|
996
|
+
prompt: msg.content,
|
|
997
|
+
model: model || undefined,
|
|
998
|
+
resumeSessionId: resumeSessionId || undefined,
|
|
999
|
+
env: childEnv,
|
|
1000
|
+
},
|
|
1001
|
+
{
|
|
1002
|
+
onOutput: () => { this.lastOutputAt.set(k, Date.now()); },
|
|
1003
|
+
onSessionId: async (id: string) => {
|
|
1004
|
+
if (!session.opencodeSessionId) {
|
|
1005
|
+
await this.store.updateSession(session.id, { opencodeSessionId: id });
|
|
1006
|
+
session.opencodeSessionId = id;
|
|
1007
|
+
log.info(`engine: captured sessionID=${id.slice(0, 8)} for ${k} (early persist)`);
|
|
1008
|
+
}
|
|
1009
|
+
},
|
|
1010
|
+
},
|
|
1011
|
+
);
|
|
1029
1012
|
|
|
1030
1013
|
if (this.generation.get(k) !== gen) {
|
|
1031
|
-
log.warn(`engine: spawned pid=${
|
|
1032
|
-
try { this.killProcessTree(
|
|
1014
|
+
log.warn(`engine: spawned pid=${handle.pid} but generation superseded — killing orphan for ${k}`);
|
|
1015
|
+
try { this.killProcessTree(handle.pid); } catch { /* already dead */ }
|
|
1033
1016
|
return;
|
|
1034
1017
|
}
|
|
1035
1018
|
|
|
1036
|
-
this.processes.set(k,
|
|
1019
|
+
this.processes.set(k, handle);
|
|
1037
1020
|
this.lastOutputAt.set(k, Date.now());
|
|
1038
|
-
if (!this.startedAt.has(k))
|
|
1039
|
-
this.startedAt.set(k, Date.now());
|
|
1040
|
-
}
|
|
1021
|
+
if (!this.startedAt.has(k)) this.startedAt.set(k, Date.now());
|
|
1041
1022
|
this.currentPrompt.set(k, msg.content);
|
|
1042
1023
|
|
|
1043
|
-
|
|
1044
|
-
await this.store.updateSession(session.id, { opencodePid: proc.pid });
|
|
1024
|
+
await this.store.updateSession(session.id, { opencodePid: handle.pid });
|
|
1045
1025
|
await this.persistRuntimeState(session.id);
|
|
1046
1026
|
|
|
1047
|
-
log.info(`engine: spawned pid=${
|
|
1048
|
-
|
|
1049
|
-
// Read stdout to capture session ID
|
|
1050
|
-
let opencodeSessionId: string | null = null;
|
|
1051
|
-
const stderrPromise = new Response(proc.stderr).text();
|
|
1052
|
-
const reader = proc.stdout.getReader();
|
|
1053
|
-
const decoder = new TextDecoder();
|
|
1054
|
-
let lineBuf = "";
|
|
1055
|
-
|
|
1056
|
-
while (true) {
|
|
1057
|
-
const { done, value } = await reader.read();
|
|
1058
|
-
if (done) break;
|
|
1059
|
-
|
|
1060
|
-
this.lastOutputAt.set(k, Date.now());
|
|
1061
|
-
|
|
1062
|
-
if (!opencodeSessionId) {
|
|
1063
|
-
lineBuf += decoder.decode(value, { stream: true });
|
|
1064
|
-
const lines = lineBuf.split("\n");
|
|
1065
|
-
lineBuf = lines.pop()!;
|
|
1066
|
-
for (const line of lines) {
|
|
1067
|
-
if (!line.trim()) continue;
|
|
1068
|
-
try {
|
|
1069
|
-
const ev = JSON.parse(line);
|
|
1070
|
-
if (ev.sessionID) {
|
|
1071
|
-
const sid: string = ev.sessionID;
|
|
1072
|
-
opencodeSessionId = sid;
|
|
1073
|
-
// Persist now, not at exit: a preempt/crash before exit must
|
|
1074
|
-
// not lose the ID, otherwise the re-run opens a fresh session.
|
|
1075
|
-
if (!session.opencodeSessionId) {
|
|
1076
|
-
await this.store.updateSession(session.id, { opencodeSessionId: sid });
|
|
1077
|
-
session.opencodeSessionId = sid;
|
|
1078
|
-
log.info(`engine: captured sessionID=${sid.slice(0, 8)} for ${k} (early persist)`);
|
|
1079
|
-
}
|
|
1080
|
-
break;
|
|
1081
|
-
}
|
|
1082
|
-
} catch { /* not json */ }
|
|
1083
|
-
}
|
|
1084
|
-
}
|
|
1085
|
-
}
|
|
1027
|
+
log.info(`engine: spawned pid=${handle.pid} for ${k} (backend=${this.backend.name})`);
|
|
1086
1028
|
|
|
1087
|
-
exitCode = await
|
|
1088
|
-
const stderr = await
|
|
1029
|
+
exitCode = await handle.exited;
|
|
1030
|
+
const stderr = await handle.stderrText;
|
|
1089
1031
|
|
|
1090
|
-
|
|
1091
|
-
if (this.processes.get(k) !== proc) {
|
|
1032
|
+
if (this.processes.get(k) !== handle) {
|
|
1092
1033
|
log.info(`engine: process replaced, skipping finishRun for ${k}`);
|
|
1093
1034
|
this.stopping.delete(k);
|
|
1094
1035
|
return;
|
|
@@ -1099,20 +1040,15 @@ export class Engine {
|
|
|
1099
1040
|
await this.store.updateSession(session.id, { opencodePid: undefined });
|
|
1100
1041
|
|
|
1101
1042
|
if (exitCode !== 0) {
|
|
1102
|
-
log.error(`engine: pid=${
|
|
1043
|
+
log.error(`engine: pid=${handle.pid} exited ${exitCode} for ${k}`);
|
|
1103
1044
|
log.error(` stderr: ${stderr.slice(0, 2000)}`);
|
|
1104
1045
|
await this.store.updateMessageStatus(msg.id, "failed", `exit ${exitCode}: ${stderr.slice(0, 500)}`);
|
|
1105
1046
|
} else {
|
|
1106
|
-
log.info(`engine: pid=${
|
|
1107
|
-
if (stderr) log.warn(`engine: pid=${
|
|
1108
|
-
if (!opencodeSessionId) log.warn(`engine: pid=${
|
|
1047
|
+
log.info(`engine: pid=${handle.pid} completed for ${k}`);
|
|
1048
|
+
if (stderr) log.warn(`engine: pid=${handle.pid} stderr on exit 0: ${stderr.slice(0, 500)}`);
|
|
1049
|
+
if (!session.opencodeSessionId) log.warn(`engine: pid=${handle.pid} produced NO sessionID (no stdout output)`);
|
|
1109
1050
|
await this.store.updateMessageStatus(msg.id, "done");
|
|
1110
1051
|
}
|
|
1111
|
-
|
|
1112
|
-
// Save opencode session ID for continuity
|
|
1113
|
-
if (opencodeSessionId && !session.opencodeSessionId) {
|
|
1114
|
-
await this.store.updateSession(session.id, { opencodeSessionId });
|
|
1115
|
-
}
|
|
1116
1052
|
} catch (err) {
|
|
1117
1053
|
log.error(`engine: exec failed for ${k}:`, err);
|
|
1118
1054
|
await this.store.updateMessageStatus(msg.id, "failed", (err as Error).message);
|
|
@@ -1198,7 +1134,7 @@ export class Engine {
|
|
|
1198
1134
|
await this.persistRuntimeState(session.id);
|
|
1199
1135
|
void tracker.updateStatus(ref, "");
|
|
1200
1136
|
} else {
|
|
1201
|
-
const sessionOutput = await this.
|
|
1137
|
+
const sessionOutput = await this.backend.getSessionOutputTokens(session.opencodeSessionId);
|
|
1202
1138
|
const emptyRound = this.emptyResponseRounds.get(k) ?? 0;
|
|
1203
1139
|
|
|
1204
1140
|
if (!sessionOutput.hasOutput && emptyRound < Engine.MAX_EMPTY_RESPONSE_ROUNDS) {
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { spawn } from "bun";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import { log } from "../logger";
|
|
4
|
+
import type {
|
|
5
|
+
RuntimeBackend,
|
|
6
|
+
RuntimeSpawnOpts,
|
|
7
|
+
RuntimeSpawnCallbacks,
|
|
8
|
+
RuntimeHandle,
|
|
9
|
+
SessionOutputResult,
|
|
10
|
+
} from "./types";
|
|
11
|
+
|
|
12
|
+
const ENV_DENY_ALWAYS = ["OPENCODE", "OPENCODE_PID", "OPENCODE_RUN_ID", "OPENCODE_PROCESS_ROLE"] as const;
|
|
13
|
+
|
|
14
|
+
export class OpencodeBackend implements RuntimeBackend {
|
|
15
|
+
readonly name = "opencode";
|
|
16
|
+
|
|
17
|
+
constructor(
|
|
18
|
+
private binary: string,
|
|
19
|
+
private dbPath: string,
|
|
20
|
+
private childEnvDeny: string[] = [],
|
|
21
|
+
) {}
|
|
22
|
+
|
|
23
|
+
async spawn(opts: RuntimeSpawnOpts, cb: RuntimeSpawnCallbacks): Promise<RuntimeHandle> {
|
|
24
|
+
const args = [this.binary, "run", "--format", "json", "--dir", opts.workdir];
|
|
25
|
+
|
|
26
|
+
if (opts.resumeSessionId) {
|
|
27
|
+
args.push("--session", opts.resumeSessionId);
|
|
28
|
+
}
|
|
29
|
+
if (opts.model) {
|
|
30
|
+
args.push("--model", opts.model);
|
|
31
|
+
}
|
|
32
|
+
args.push(opts.prompt);
|
|
33
|
+
|
|
34
|
+
const childEnv = { ...opts.env };
|
|
35
|
+
if (!opts.model) delete childEnv.OPENCODE_MODEL;
|
|
36
|
+
for (const key of ENV_DENY_ALWAYS) delete childEnv[key];
|
|
37
|
+
for (const key of this.childEnvDeny) delete childEnv[key];
|
|
38
|
+
|
|
39
|
+
const proc = spawn({
|
|
40
|
+
cmd: args,
|
|
41
|
+
cwd: opts.workdir,
|
|
42
|
+
env: childEnv,
|
|
43
|
+
stdout: "pipe",
|
|
44
|
+
stderr: "pipe",
|
|
45
|
+
stdin: "ignore",
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const stderrText = new Response(proc.stderr).text();
|
|
49
|
+
|
|
50
|
+
void this.readStdout(proc, cb);
|
|
51
|
+
|
|
52
|
+
return { pid: proc.pid, exited: proc.exited, stderrText };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private async readStdout(
|
|
56
|
+
proc: ReturnType<typeof spawn>,
|
|
57
|
+
cb: RuntimeSpawnCallbacks,
|
|
58
|
+
): Promise<void> {
|
|
59
|
+
let captured = false;
|
|
60
|
+
const stdout = proc.stdout;
|
|
61
|
+
if (!stdout || typeof stdout === "number") return;
|
|
62
|
+
const reader = stdout.getReader();
|
|
63
|
+
const decoder = new TextDecoder();
|
|
64
|
+
let lineBuf = "";
|
|
65
|
+
|
|
66
|
+
while (true) {
|
|
67
|
+
const { done, value } = await reader.read();
|
|
68
|
+
if (done) break;
|
|
69
|
+
|
|
70
|
+
cb.onOutput();
|
|
71
|
+
|
|
72
|
+
if (!captured) {
|
|
73
|
+
lineBuf += decoder.decode(value, { stream: true });
|
|
74
|
+
const lines = lineBuf.split("\n");
|
|
75
|
+
lineBuf = lines.pop()!;
|
|
76
|
+
for (const line of lines) {
|
|
77
|
+
if (!line.trim()) continue;
|
|
78
|
+
try {
|
|
79
|
+
const ev = JSON.parse(line);
|
|
80
|
+
if (ev.sessionID) {
|
|
81
|
+
await cb.onSessionId(ev.sessionID as string);
|
|
82
|
+
captured = true;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
} catch { /* not json */ }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async sessionExists(sessionId: string): Promise<boolean> {
|
|
92
|
+
let db: Database;
|
|
93
|
+
try {
|
|
94
|
+
db = new Database(this.dbPath, { readonly: true });
|
|
95
|
+
} catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
const row = db.prepare("SELECT 1 FROM session WHERE id = ? LIMIT 1").get(sessionId);
|
|
100
|
+
return !!row;
|
|
101
|
+
} catch {
|
|
102
|
+
return false;
|
|
103
|
+
} finally {
|
|
104
|
+
db.close();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async getSessionOutputTokens(sessionId: string | undefined): Promise<SessionOutputResult> {
|
|
109
|
+
if (!sessionId) return { hasOutput: true, tokenCount: 0 };
|
|
110
|
+
let db: Database;
|
|
111
|
+
try {
|
|
112
|
+
db = new Database(this.dbPath, { readonly: true });
|
|
113
|
+
} catch {
|
|
114
|
+
return { hasOutput: true, tokenCount: 0 };
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
const row = db.prepare(
|
|
118
|
+
"SELECT COUNT(*) AS n, COALESCE(SUM(CAST(json_extract(data,'$.tokens.output') AS INT)), 0) AS tokens " +
|
|
119
|
+
"FROM message WHERE session_id = ? AND json_extract(data,'$.role') = 'assistant'"
|
|
120
|
+
).get(sessionId) as { n: number; tokens: number } | null;
|
|
121
|
+
if (!row) return { hasOutput: true, tokenCount: 0 };
|
|
122
|
+
return { hasOutput: row.n > 0 && row.tokens > 0, tokenCount: row.tokens };
|
|
123
|
+
} catch {
|
|
124
|
+
return { hasOutput: true, tokenCount: 0 };
|
|
125
|
+
} finally {
|
|
126
|
+
db.close();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface RuntimeSpawnOpts {
|
|
2
|
+
workdir: string;
|
|
3
|
+
prompt: string;
|
|
4
|
+
model?: string;
|
|
5
|
+
resumeSessionId?: string;
|
|
6
|
+
env: Record<string, string | undefined>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface RuntimeSpawnCallbacks {
|
|
10
|
+
onOutput: () => void;
|
|
11
|
+
onSessionId: (id: string) => Promise<void> | void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface RuntimeHandle {
|
|
15
|
+
pid: number;
|
|
16
|
+
exited: Promise<number>;
|
|
17
|
+
stderrText: Promise<string>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SessionOutputResult {
|
|
21
|
+
hasOutput: boolean;
|
|
22
|
+
tokenCount: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface RuntimeBackend {
|
|
26
|
+
readonly name: string;
|
|
27
|
+
|
|
28
|
+
spawn(opts: RuntimeSpawnOpts, callbacks: RuntimeSpawnCallbacks): Promise<RuntimeHandle>;
|
|
29
|
+
|
|
30
|
+
sessionExists(sessionId: string): Promise<boolean>;
|
|
31
|
+
|
|
32
|
+
getSessionOutputTokens(sessionId: string | undefined): Promise<SessionOutputResult>;
|
|
33
|
+
}
|