From 0793e7ad497b1954d67ea2dfad5d2ae9504e4a23 Mon Sep 17 00:00:00 2001 From: PerishCode Date: Mon, 29 Jun 2026 15:07:51 +0800 Subject: [PATCH 1/2] fix: correct santi-cli send body and provider_state label - santi-cli `session send` posted {"text":...}; the API expects {"content":[{"type":"text",...}]}. Build the MessagePart body so send returns 200 instead of 422. - complete_turn hardcoded provider_state.provider = "openai"; thread the real provider name from ProviderClient::metadata() (e.g. "siliconflow"). Co-Authored-By: Claude Opus 4.8 --- crates/santi-cli/src/main.rs | 4 +++- crates/santi-core/src/service.rs | 1 + crates/santi-core/src/store/runtime.rs | 3 ++- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/santi-cli/src/main.rs b/crates/santi-cli/src/main.rs index 499e5c5..7e6b702 100644 --- a/crates/santi-cli/src/main.rs +++ b/crates/santi-cli/src/main.rs @@ -76,7 +76,9 @@ async fn main() -> Result<()> { get(&client, &format!("{base}/api/v1/sessions/{id}/runtime")).await } Command::Session(SessionCommand::Send { id, text }) => { - let body = serde_json::json!({ "text": text }); + let body = serde_json::json!({ + "content": [{ "type": "text", "text": text }] + }); post( &client, &format!("{base}/api/v1/sessions/{id}/send"), diff --git a/crates/santi-core/src/service.rs b/crates/santi-core/src/service.rs index 2ed7f8c..8d49ec6 100644 --- a/crates/santi-core/src/service.rs +++ b/crates/santi-core/src/service.rs @@ -236,6 +236,7 @@ impl SantiService { if let Err(error) = self.store.complete_turn( &turn_id, assistant_message.relation.session_seq, + &self.provider.metadata().provider, provider_response_id, ) { self.fail_background_turn(&session_id, &turn_id, error, String::new()); diff --git a/crates/santi-core/src/store/runtime.rs b/crates/santi-core/src/store/runtime.rs index fa58c73..bf25fec 100644 --- a/crates/santi-core/src/store/runtime.rs +++ b/crates/santi-core/src/store/runtime.rs @@ -187,13 +187,14 @@ impl SantiStore { &self, turn_id: &str, assistant_message_seq: i64, + provider: &str, provider_response_id: Option, ) -> Result { let conn = self.conn.lock().unwrap(); let now = timestamp_now(); let provider_state = provider_response_id.map(|response_id| { json!({ - "provider": "openai", + "provider": provider, "opaque": { "response_id": response_id }, "schema_version": "santi-v1" }) From 500b302c9aa2ba620c1239f200b78c1f19bb0f5e Mon Sep 17 00:00:00 2001 From: PerishCode Date: Mon, 29 Jun 2026 15:07:51 +0800 Subject: [PATCH 2/2] feat: add :dev runseal wrapper for local server lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First runseal integration for santi (runseal.toml + .runseal/, Deno-native, zero remote deps). `runseal :dev start|stop|restart|logs|status` is a pm2-like lightweight process manager. The runtime stays untouched: the arg-stamp rides a Deno launcher process, not santi-api. The single source of truth is the stamp on the launcher's argv (--santi-stamp=v=1;a=santi-api;n=…;port=…), discovered via `ps`; the .tmp/dev/santi-api.json file is cache only, always reconciled against `ps`. stop SIGTERMs the launcher, which forwards to its santi-api child. Logs are pumped with writeSync (pipeTo to a FsFile buffers until close) and truncated per run. Co-Authored-By: Claude Opus 4.8 --- .runseal/deno.json | 12 + .runseal/lib/dev/manager.ts | 432 ++++++++++++++++++++++++++++++++++++ .runseal/lib/dev/stamp.ts | 71 ++++++ .runseal/lib/std/proc.ts | 51 +++++ .runseal/wrappers/dev.ts | 8 + runseal.toml | 15 ++ 6 files changed, 589 insertions(+) create mode 100644 .runseal/deno.json create mode 100644 .runseal/lib/dev/manager.ts create mode 100644 .runseal/lib/dev/stamp.ts create mode 100644 .runseal/lib/std/proc.ts create mode 100644 .runseal/wrappers/dev.ts create mode 100644 runseal.toml diff --git a/.runseal/deno.json b/.runseal/deno.json new file mode 100644 index 0000000..5394aa1 --- /dev/null +++ b/.runseal/deno.json @@ -0,0 +1,12 @@ +{ + "imports": { + "@/lib/": "./lib/" + }, + "compilerOptions": { + "strict": true + }, + "fmt": { + "lineWidth": 100, + "semiColons": true + } +} diff --git a/.runseal/lib/dev/manager.ts b/.runseal/lib/dev/manager.ts new file mode 100644 index 0000000..805f397 --- /dev/null +++ b/.runseal/lib/dev/manager.ts @@ -0,0 +1,432 @@ +//! A pm2-like, lightweight process manager for the local santi-api dev server, +//! built on Deno-native APIs. +//! +//! Process tree: +//! runseal :dev start (short-lived operator) +//! └─ deno dev.ts __run --santi-stamp=… (stamped launcher, persistent) +//! └─ target/debug/santi-api serve (the actual server) +//! +//! The launcher carries the arg-stamp, so `ps` discovery is the single source +//! of truth for liveness and identity. The JSON cache holds only convenience +//! metadata (log path, start time, server pid) and is always reconciled +//! against `ps`, never trusted over it. + +import { + APP, + DEFAULT_NAMESPACE, + readStampFromCommand, + type Stamp, + stampArg, +} from "@/lib/dev/stamp.ts"; +import { killHard, psList, term } from "@/lib/std/proc.ts"; + +const BIN_REL = "target/debug/santi-api"; +const HEALTH_TIMEOUT_MS = 15_000; +const STOP_TIMEOUT_MS = 15_000; + +interface Paths { + repo: string; + runDir: string; + log: string; + cache: string; + bin: string; + wrapper: string; + denoConfig: string; +} + +interface Cache { + launcherPid: number; + serverPid?: number; + port: number; + log: string; + startedAt: string; +} + +interface Found { + pid: number; + stamp: Stamp; +} + +export async function run(argv: string[]): Promise { + const [command, ...rest] = argv; + let code = 0; + switch (command) { + case "start": + code = await start(); + break; + case "stop": + code = await stop(); + break; + case "restart": + code = await restart(); + break; + case "status": + code = await status(); + break; + case "logs": + code = await logs(rest); + break; + case "__run": + code = await launcherRun(); + break; + default: + console.error("usage: runseal :dev "); + code = 2; + } + Deno.exit(code); +} + +async function start(): Promise { + const paths = resolvePaths(); + const running = await discover(); + if (running.length > 0) { + console.log(`santi-api already running (launcher pid ${running[0].pid})`); + return 0; + } + Deno.mkdirSync(paths.runDir, { recursive: true }); + + console.log("building santi-api ..."); + const build = await new Deno.Command("cargo", { + args: ["build", "-p", "santi-api"], + cwd: paths.repo, + stdout: "inherit", + stderr: "inherit", + }).output(); + if (!build.success) { + console.error("build failed"); + return 1; + } + + const port = readPort(paths.repo); + const host = readHost(paths.repo); + const stamp: Stamp = { version: 1, app: APP, namespace: DEFAULT_NAMESPACE, port }; + + const launcher = new Deno.Command(Deno.execPath(), { + args: [ + "run", + "--no-prompt", + "--config", + paths.denoConfig, + "--allow-run", + "--allow-read", + "--allow-write", + "--allow-env", + paths.wrapper, + "__run", + stampArg(stamp), + ], + cwd: paths.repo, + stdin: "null", + stdout: "null", + stderr: "null", + }); + const child = launcher.spawn(); + child.unref(); + + writeCache(paths, { + launcherPid: child.pid, + port, + log: paths.log, + startedAt: new Date().toISOString(), + }); + + const healthy = await waitHealth(host, port, HEALTH_TIMEOUT_MS); + if (healthy) { + console.log( + `santi-api up on http://${host}:${port} (launcher pid ${child.pid})\nlogs: ${paths.log}`, + ); + } else { + console.log( + `santi-api launched (launcher pid ${child.pid}) but health was not confirmed within ` + + `${HEALTH_TIMEOUT_MS / 1000}s — inspect: runseal :dev logs`, + ); + } + return 0; +} + +async function stop(): Promise { + const paths = resolvePaths(); + const cache = readCache(paths); + const running = await discover(); + if (running.length === 0) { + if (cache?.serverPid) term(cache.serverPid); // defensive: clear a possible orphan + clearCache(paths); + console.log("santi-api not running"); + return 0; + } + for (const found of running) { + term(found.pid); + } + if (!(await waitGone(STOP_TIMEOUT_MS))) { + for (const found of await discover()) { + killHard(found.pid); + } + await waitGone(3_000); + } + if (cache?.serverPid) term(cache.serverPid); // belt-and-suspenders against SIGKILL orphans + clearCache(paths); + console.log("santi-api stopped"); + return 0; +} + +async function restart(): Promise { + await stop(); + return await start(); +} + +async function status(): Promise { + const paths = resolvePaths(); + const cache = readCache(paths); + const running = await discover(); + if (running.length === 0) { + clearCache(paths); // stale cache, if any + console.log("santi-api: stopped"); + return 0; + } + const found = running[0]; + const host = readHost(paths.repo); + const port = found.stamp.port || cache?.port || readPort(paths.repo); + const healthy = await waitHealth(host, port, 1_000); + const uptime = cache?.startedAt + ? `${Math.round((Date.now() - Date.parse(cache.startedAt)) / 1000)}s` + : "?"; + console.log("santi-api: running"); + console.log(` launcher pid : ${found.pid}`); + if (cache?.serverPid) console.log(` server pid : ${cache.serverPid}`); + console.log( + ` endpoint : http://${host}:${port} (health: ${healthy ? "ok" : "unreachable"})`, + ); + console.log(` uptime : ${uptime}`); + console.log(` logs : ${paths.log}`); + return 0; +} + +async function logs(args: string[]): Promise { + const paths = resolvePaths(); + const follow = args.includes("-f") || args.includes("--follow"); + let lines = 50; + const nIndex = args.findIndex((arg) => arg === "-n"); + if (nIndex >= 0 && args[nIndex + 1]) { + lines = Number(args[nIndex + 1]) || lines; + } + if (!exists(paths.log)) { + console.log(`no log yet: ${paths.log}`); + return 0; + } + if (follow) { + const child = new Deno.Command("tail", { + args: ["-n", String(lines), "-f", paths.log], + stdout: "inherit", + stderr: "inherit", + }).spawn(); + return (await child.status).code ?? 0; + } + const text = Deno.readTextFileSync(paths.log).split("\n"); + console.log(text.slice(Math.max(0, text.length - lines - 1)).join("\n").trimEnd()); + return 0; +} + +/** + * Launcher mode (internal). Carries the arg-stamp, supervises one santi-api + * child, pumps its output to the log file, and forwards termination so a kill + * of the launcher cleanly stops the server. + */ +async function launcherRun(): Promise { + const paths = resolvePaths(); + // nohup-equivalent: survive a terminal hang-up. + try { + Deno.addSignalListener("SIGHUP", () => {}); + } catch { + // not supported on this platform; ignore + } + + // Truncate on each launch so the log reflects only the current run. + const logFile = Deno.openSync(paths.log, { create: true, write: true, truncate: true }); + + const server = new Deno.Command(paths.bin, { + args: ["serve"], + cwd: paths.repo, + stdin: "null", + stdout: "piped", + stderr: "piped", + }).spawn(); + + const cache = readCache(paths); + if (cache) writeCache(paths, { ...cache, serverPid: server.pid }); + + // Pump with direct `writeSync` rather than `pipeTo`: a FsFile writable stream + // buffers and only flushes on close, which would hide all logs until the + // server exits. `writeSync` issues one write syscall per chunk, landing + // output immediately. + const pumps = [pump(server.stdout, logFile), pump(server.stderr, logFile)]; + + let shuttingDown = false; + const forward = () => { + if (shuttingDown) return; + shuttingDown = true; + try { + server.kill("SIGTERM"); + } catch { + // already gone + } + }; + for (const signal of ["SIGTERM", "SIGINT"] as const) { + try { + Deno.addSignalListener(signal, forward); + } catch { + // ignore + } + } + + const result = await server.status; + await Promise.allSettled(pumps); + try { + logFile.close(); + } catch { + // already closed + } + return result.code ?? 0; +} + +/** Append a child stream to the log file, flushing each chunk immediately. */ +async function pump(stream: ReadableStream, file: Deno.FsFile): Promise { + const reader = stream.getReader(); + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + if (value) { + let offset = 0; + while (offset < value.length) { + offset += file.writeSync(value.subarray(offset)); + } + } + } + } catch { + // stream ended or process gone + } finally { + reader.releaseLock(); + } +} + +async function discover(): Promise { + const found: Found[] = []; + for (const row of await psList()) { + const stamp = readStampFromCommand(row.command); + if (stamp && stamp.app === APP && stamp.namespace === DEFAULT_NAMESPACE) { + found.push({ pid: row.pid, stamp }); + } + } + return found; +} + +async function waitGone(timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if ((await discover()).length === 0) return true; + await sleep(200); + } + return (await discover()).length === 0; +} + +async function waitHealth(host: string, port: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + const url = `http://${host}:${port}/api/v1/health`; + while (Date.now() < deadline) { + try { + const response = await fetch(url); + const ok = response.ok; + await response.body?.cancel(); + if (ok) return true; + } catch { + // not accepting connections yet + } + await sleep(300); + } + return false; +} + +function resolvePaths(): Paths { + const repo = repoRoot(); + const runDir = join(repo, ".tmp/dev"); + return { + repo, + runDir, + log: join(runDir, "santi-api.log"), + cache: join(runDir, "santi-api.json"), + bin: join(repo, BIN_REL), + wrapper: Deno.env.get("RUNSEAL_WRAPPER_FILE") ?? join(repo, ".runseal/wrappers/dev.ts"), + denoConfig: join(repo, ".runseal/deno.json"), + }; +} + +function repoRoot(): string { + const profile = Deno.env.get("RUNSEAL_PROFILE_PATH"); + return profile ? dirname(profile) : Deno.cwd(); +} + +function readPort(repo: string): number { + const fromEnv = Deno.env.get("SANTI_PORT"); + if (fromEnv && Number.isInteger(Number(fromEnv))) return Number(fromEnv); + const fromFile = readEnvFile(repo, "SANTI_PORT"); + if (fromFile && Number.isInteger(Number(fromFile))) return Number(fromFile); + return 43307; +} + +function readHost(repo: string): string { + return Deno.env.get("SANTI_HOST") ?? readEnvFile(repo, "SANTI_HOST") ?? "127.0.0.1"; +} + +function readEnvFile(repo: string, key: string): string | null { + try { + const text = Deno.readTextFileSync(join(repo, ".env")); + const match = text.match(new RegExp(`^\\s*${key}\\s*=\\s*(\\S+)\\s*$`, "m")); + return match ? match[1] : null; + } catch { + return null; + } +} + +function readCache(paths: Paths): Cache | null { + try { + return JSON.parse(Deno.readTextFileSync(paths.cache)) as Cache; + } catch { + return null; + } +} + +function writeCache(paths: Paths, cache: Cache): void { + Deno.mkdirSync(paths.runDir, { recursive: true }); + Deno.writeTextFileSync(paths.cache, `${JSON.stringify(cache, null, 2)}\n`); +} + +function clearCache(paths: Paths): void { + try { + Deno.removeSync(paths.cache); + } catch { + // already absent + } +} + +function exists(path: string): boolean { + try { + Deno.statSync(path); + return true; + } catch { + return false; + } +} + +function dirname(path: string): string { + const trimmed = path.replace(/\/+$/, ""); + const index = trimmed.lastIndexOf("/"); + return index <= 0 ? "/" : trimmed.slice(0, index); +} + +function join(...parts: string[]): string { + return parts.join("/").replace(/(? { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/.runseal/lib/dev/stamp.ts b/.runseal/lib/dev/stamp.ts new file mode 100644 index 0000000..77f1937 --- /dev/null +++ b/.runseal/lib/dev/stamp.ts @@ -0,0 +1,71 @@ +//! The arg-stamp: a packed identity injected into the launcher process's argv +//! so process management can discover and operate on it via `ps`. +//! +//! The stamp is the single source of truth for "is santi running, and which +//! one". It rides on the process command line (not a file), so it cannot drift +//! out of sync with reality the way a pidfile can. Any cache file is secondary. +//! +//! Canonical form: `--santi-stamp=v=1;a=santi-api;n=;port=`. +//! Values are percent-encoded so the whole token stays whitespace-free and +//! survives `ps` command-line splitting. + +export const STAMP_FLAG = "--santi-stamp"; +export const STAMP_VERSION = 1; +export const APP = "santi-api"; +export const DEFAULT_NAMESPACE = "default"; + +export interface Stamp { + version: number; + app: string; + namespace: string; + port: number; +} + +export function encodeStamp(stamp: Stamp): string { + return [ + `v=${stamp.version}`, + `a=${encodeValue(stamp.app)}`, + `n=${encodeValue(stamp.namespace)}`, + `port=${stamp.port}`, + ].join(";"); +} + +export function stampArg(stamp: Stamp): string { + return `${STAMP_FLAG}=${encodeStamp(stamp)}`; +} + +export function decodeStamp(value: string): Stamp | null { + const fields: Record = {}; + for (const part of value.split(";")) { + const index = part.indexOf("="); + if (index < 0) return null; + fields[part.slice(0, index)] = decodeValue(part.slice(index + 1)); + } + if (fields.v === undefined || fields.a === undefined || fields.n === undefined) return null; + const version = Number(fields.v); + if (!Number.isInteger(version) || version !== STAMP_VERSION) return null; + const port = Number(fields.port ?? "0"); + return { + version, + app: fields.a, + namespace: fields.n, + port: Number.isFinite(port) ? port : 0, + }; +} + +/** Extract a stamp from a full `ps` command line, if one is present. */ +export function readStampFromCommand(command: string): Stamp | null { + const match = command.match(/--santi-stamp=(\S+)/); + return match ? decodeStamp(match[1]) : null; +} + +function encodeValue(value: string): string { + return value.replace( + /[^A-Za-z0-9._-]/g, + (char) => `%${char.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`, + ); +} + +function decodeValue(value: string): string { + return value.replace(/%([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16))); +} diff --git a/.runseal/lib/std/proc.ts b/.runseal/lib/std/proc.ts new file mode 100644 index 0000000..2dc566d --- /dev/null +++ b/.runseal/lib/std/proc.ts @@ -0,0 +1,51 @@ +//! Minimal process primitives over Deno-native APIs. Discovery is `ps`-based: +//! the process table is the authority, never a pidfile. + +export interface PsRow { + pid: number; + command: string; +} + +/** Snapshot the process table as (pid, full command line) rows. Unix only. */ +export async function psList(): Promise { + const output = await new Deno.Command("ps", { + args: ["-axo", "pid=,command="], + stdout: "piped", + stderr: "null", + }).output(); + if (!output.success) { + throw new Error("ps failed"); + } + const text = new TextDecoder().decode(output.stdout); + const rows: PsRow[] = []; + for (const line of text.split("\n")) { + const trimmed = line.trimStart(); + if (trimmed === "") continue; + const space = trimmed.search(/\s/); + if (space < 0) continue; + const pid = Number(trimmed.slice(0, space)); + if (!Number.isInteger(pid)) continue; + rows.push({ pid, command: trimmed.slice(space + 1).trim() }); + } + return rows; +} + +/** Send SIGTERM; returns false if the process is already gone. */ +export function term(pid: number): boolean { + try { + Deno.kill(pid, "SIGTERM"); + return true; + } catch { + return false; + } +} + +/** Send SIGKILL; returns false if the process is already gone. */ +export function killHard(pid: number): boolean { + try { + Deno.kill(pid, "SIGKILL"); + return true; + } catch { + return false; + } +} diff --git a/.runseal/wrappers/dev.ts b/.runseal/wrappers/dev.ts new file mode 100644 index 0000000..daada29 --- /dev/null +++ b/.runseal/wrappers/dev.ts @@ -0,0 +1,8 @@ +//! `runseal :dev ` +//! +//! A thin entry point. All process-management logic lives in the manager so the +//! wrapper stays a dispatcher. + +import { run } from "@/lib/dev/manager.ts"; + +await run(Deno.args); diff --git a/runseal.toml b/runseal.toml new file mode 100644 index 0000000..c0b3442 --- /dev/null +++ b/runseal.toml @@ -0,0 +1,15 @@ +# runseal profile for santi — dev-plane tooling only. +# The runtime (santi-api) stays product-unaware and runseal-unaware; this +# profile drives local development wrappers, not the runtime itself. + +[deno] +config = ".runseal/deno.json" +# No lock: the wrappers carry zero remote dependencies, so a frozen lock would +# add friction with no benefit. Add one here if remote imports are introduced. +permissions = [ + "--allow-run", + "--allow-read", + "--allow-write", + "--allow-env", + "--allow-net", +]