diff --git a/CLAUDE.md b/CLAUDE.md index 20240a16..afe28d57 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,7 @@ Bridges are **system-wide**, not per-mind. Config lives in `~/.volute/system/bri ### Centralized state directory -Volute per-mind system state (logs, env, bridge PIDs) lives in `~/.volute/state//`, separate from mind directories. This keeps mind projects portable — they contain only mind-owned state (sessions, cursors). The `stateDir(name)` helper in `packages/daemon/src/lib/mind/registry.ts` resolves state paths. On daemon startup, `migrateDotVoluteDir()` renames any legacy `/.volute/` to `/.mind/`, then `migrateMindState()` copies `env.json` and `logs/` from the mind's `.mind/` to the centralized state dir. +Volute per-mind system state (logs, env, bridge PIDs) lives in `~/.volute/state//`, separate from mind directories. This keeps mind projects portable — they contain only mind-owned state (sessions, cursors). The `stateDir(name)` helper in `packages/daemon/src/lib/mind/registry.ts` resolves state paths. Minds receive `VOLUTE_MIND`, `VOLUTE_STATE_DIR`, `VOLUTE_MIND_DIR`, `VOLUTE_MIND_PORT`, `VOLUTE_DAEMON_PORT`, and `VOLUTE_MIND_TOKEN` env vars from the daemon. The mind env is built from an allowlist (benign system vars, outbound proxy / custom-CA vars, + `VOLUTE_*`), not a full `process.env` spread, so ambient host secrets are withheld. `VOLUTE_MIND_TOKEN` is a per-mind, non-admin token — distinct from the daemon's own admin `VOLUTE_DAEMON_TOKEN`, which is never handed to minds. Instead of file-based IPC (restart.json, merged.json), minds call the daemon's REST API via `daemonRestart()` and `daemonSend()` from `templates/_base/src/lib/daemon-client.ts`. The daemon delivers post-restart context (merge info) to minds via HTTP POST to the mind's `/message` endpoint. @@ -220,7 +220,7 @@ Extensions add functionality to Volute — custom UI sections, API routes, datab | `volute service status` | Show service status | | `volute update` | Check for updates | -Mind-scoped commands (`chat`, `clock`, `skill`) use `--mind ` or `VOLUTE_MIND` env var. Legacy aliases (`volute mind seed/sprout/sleep/wake`, `volute variant ...`) forward to the current nouns. Full flags: `volute --help`. +Mind-scoped commands (`chat`, `clock`, `skill`) use `--mind ` or `VOLUTE_MIND` env var. Full flags: `volute --help`. ## Directory guide diff --git a/package-lock.json b/package-lock.json index e1cc192b..6bf282e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1767,7 +1767,7 @@ "typebox": "1.1.38" }, "bin": { - "pi-ai": "./dist/cli.js" + "pi-ai": "dist/cli.js" }, "engines": { "node": ">=22.19.0" @@ -1890,6 +1890,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1907,6 +1910,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1924,6 +1930,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1941,6 +1950,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1958,6 +1970,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ diff --git a/packages/cli/src/cli-remote.ts b/packages/cli/src/cli-remote.ts index cf6e1201..0f7c24f1 100644 --- a/packages/cli/src/cli-remote.ts +++ b/packages/cli/src/cli-remote.ts @@ -1,6 +1,7 @@ // Stripped-down CLI entry point for remote use (no local daemon required). // Connects to a daemon via VOLUTE_DAEMON_URL or stored session URL. export {}; + process.noDeprecation = true; const command = process.argv[2]; @@ -24,9 +25,6 @@ switch (command) { case "chat": await import("./commands/chat.js").then((m) => m.run(args)); break; - case "variant": - await import("./commands/variant.js").then((m) => m.run(args)); - break; case "clock": await import("./commands/clock.js").then((m) => m.run(args)); break; diff --git a/packages/cli/src/commands/mind.ts b/packages/cli/src/commands/mind.ts index c835ab2e..745e99b2 100644 --- a/packages/cli/src/commands/mind.ts +++ b/packages/cli/src/commands/mind.ts @@ -72,22 +72,6 @@ const cmd = subcommands({ description: "Join a variant back into its parent", run: (args) => import("./join.js").then((m) => m.run(args)), }, - sleep: { - description: "(legacy) Use 'volute clock sleep' instead", - run: (args) => import("./mind-sleep.js").then((m) => m.run(args)), - }, - wake: { - description: "(legacy) Use 'volute clock wake' instead", - run: (args) => import("./mind-wake.js").then((m) => m.run(args)), - }, - seed: { - description: "(legacy) Use 'volute seed create' instead", - run: (args) => import("./seed.js").then((m) => m.run(args)), - }, - sprout: { - description: "(legacy) Use 'volute seed sprout' instead", - run: (args) => import("./sprout.js").then((m) => m.run(args)), - }, }, footer: "Mind name can be omitted (where applicable) if VOLUTE_MIND is set.", }); diff --git a/packages/cli/src/commands/seed.ts b/packages/cli/src/commands/seed.ts deleted file mode 100644 index 8383a0f3..00000000 --- a/packages/cli/src/commands/seed.ts +++ /dev/null @@ -1,5 +0,0 @@ -// Legacy alias — redirect to seed create -export async function run(args: string[]) { - console.error("Note: `volute mind seed` is now `volute seed create`"); - await import("./seed-create.js").then((m) => m.run(args)); -} diff --git a/packages/cli/src/commands/service.ts b/packages/cli/src/commands/service.ts index 0e7a1331..6eb34787 100644 --- a/packages/cli/src/commands/service.ts +++ b/packages/cli/src/commands/service.ts @@ -88,27 +88,6 @@ const cmd = subcommands({ description: "Check service status", run: async () => status(), }, - install: { - description: "(deprecated) Use 'volute setup' instead", - run: async () => { - console.log("'volute service install' has been replaced by 'volute setup'."); - console.log("Run `volute setup` to configure your installation."); - }, - }, - uninstall: { - description: "(deprecated) Use 'volute setup' instead", - run: async () => { - console.log("'volute service uninstall' has been replaced by 'volute setup'."); - console.log("To uninstall the service, remove the service file manually:"); - if (process.platform === "darwin") { - console.log(" launchctl unload ~/Library/LaunchAgents/com.volute.daemon.plist"); - console.log(" rm ~/Library/LaunchAgents/com.volute.daemon.plist"); - } else { - console.log(" systemctl --user disable --now volute"); - console.log(" rm ~/.config/systemd/user/volute.service"); - } - }, - }, }, }); diff --git a/packages/cli/src/commands/sprout.ts b/packages/cli/src/commands/sprout.ts deleted file mode 100644 index 86863692..00000000 --- a/packages/cli/src/commands/sprout.ts +++ /dev/null @@ -1,5 +0,0 @@ -// Legacy alias — redirect to seed sprout -export async function run(args: string[]) { - console.error("Note: `volute mind sprout` is now `volute seed sprout`"); - await import("./seed-sprout.js").then((m) => m.run(args)); -} diff --git a/packages/cli/src/commands/variant.ts b/packages/cli/src/commands/variant.ts deleted file mode 100644 index 61139525..00000000 --- a/packages/cli/src/commands/variant.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { subcommands } from "../lib/command.js"; - -const cmd = subcommands({ - name: "volute variant", - description: "(deprecated) Use 'volute mind split/join' instead", - commands: { - create: { - description: "(deprecated) Use 'volute mind split'", - run: async () => { - console.error( - "'volute variant create' has been replaced. Use 'volute mind split' to create variants.", - ); - console.error( - "Usage: volute mind split [--from ] [--soul '...'] [--no-start]", - ); - process.exit(1); - }, - }, - merge: { - description: "(deprecated) Use 'volute mind join'", - run: async () => { - console.error( - "'volute variant merge' has been replaced. Use 'volute mind join' to merge variants.", - ); - console.error( - "Usage: volute mind join [--summary '...' --memory '...' --justification '...']", - ); - process.exit(1); - }, - }, - list: { - description: "(deprecated) Use 'volute mind list'", - run: async () => { - console.error("'volute variant list' is no longer available."); - console.error( - "Use 'volute mind list' to see variants, or 'volute mind delete ' to delete.", - ); - process.exit(1); - }, - }, - delete: { - description: "(deprecated) Use 'volute mind delete'", - run: async () => { - console.error("'volute variant delete' is no longer available."); - console.error( - "Use 'volute mind list' to see variants, or 'volute mind delete ' to delete.", - ); - process.exit(1); - }, - }, - }, -}); - -export const run = cmd.execute; diff --git a/packages/daemon/src/daemon.ts b/packages/daemon/src/daemon.ts index 8bfa72ed..9c7864aa 100644 --- a/packages/daemon/src/daemon.ts +++ b/packages/daemon/src/daemon.ts @@ -109,19 +109,6 @@ export async function startDaemon(opts: { mkdirSync(home, { recursive: true }); ensureSystemDir(); - // Split provider credentials into secrets.json and relax config.json to 0644 so - // non-root host CLI commands can read it (v0.41.1 regression). Runs as root. - const { migrateConfigSecrets, migrateSetupCompleted } = await import("./lib/config/setup.js"); - migrateConfigSecrets(); - - // Migrate pre-existing installations (setup field without setupCompleted) - migrateSetupCompleted(); - - // Migrate bare model ids to provider-qualified form before the spirit starts, - // so syncSpiritTemplate reads a qualified spiritModel. - const { migrateAiModelQualification } = await import("./lib/ai-service.js"); - migrateAiModelQualification(); - // Initialize database (runs drizzle migrations + creates raw connection) await (await import("./lib/db.js")).getDb(); @@ -146,38 +133,6 @@ export async function startDaemon(opts: { log.warn("avatar size migration failed", log.errorData(err)); } - // Rename legacy "session" keys to "thread" in each mind's routes.json and - // volute.json (#493) — a leftover `session` rule key makes the whole rule - // unmatchable, which gates the channel's messages. Non-fatal per file. - try { - const { migrateThreadConfigs } = await import("./lib/mind/migrate-thread-config.js"); - await migrateThreadConfigs(); - } catch (err) { - log.warn("session→thread config migration failed", log.errorData(err)); - } - - // Substitute the leftover `{{name}}` placeholder in each mind's routes.json — - // template .init/ files shipped it unsubstituted, leaving every mind with a - // channel batch trigger ("@{{name}}") that can never match. Non-fatal per mind. - try { - const { migrateNamePlaceholders } = await import("./lib/mind/migrate-name-placeholder.js"); - await migrateNamePlaceholders(); - } catch (err) { - log.warn("{{name}} placeholder migration failed", log.errorData(err)); - } - - // Add `.init/` infrastructure files (hooks, bin shims) minds never received — - // the upgrade's `.init/` exclusion protects identity files but also blocked - // these, leaving minds created before a hook existed permanently without it. - // Notably the notices drain hook, the sole reader of next-turn system events - // (#808). Adds only what's missing, never overwrites. Non-fatal per mind. - try { - const { migrateInitInfrastructure } = await import("./lib/mind/migrate-init-infrastructure.js"); - await migrateInitInfrastructure(); - } catch (err) { - log.warn("`.init/` infrastructure backfill failed", log.errorData(err)); - } - // Initialize sandbox runtime for mind process isolation const { initSandbox } = await import("./lib/mind/sandbox.js"); await initSandbox(); diff --git a/packages/daemon/src/lib/ai-service.ts b/packages/daemon/src/lib/ai-service.ts index db108db0..7bfa9ffa 100644 --- a/packages/daemon/src/lib/ai-service.ts +++ b/packages/daemon/src/lib/ai-service.ts @@ -284,63 +284,6 @@ export function setEnabledModels(modelIds: string[]): void { writeGlobalConfig({ ...config, ai }); } -/** - * One-time migration: convert bare model ids in `ai.models`, `spiritModel`, and - * `ai.utilityModel` to provider-qualified `provider:model` form. A bare id is - * ambiguous (many providers serve the same id), so this pins each to the - * explicitly-configured provider(s) that serve it: the enabled list expands to - * one entry per configured provider, and the single-value defaults qualify to - * the first configured provider that serves them. Idempotent — a no-op once - * every stored id already contains a ":". - */ -export function migrateAiModelQualification(): void { - const config = readGlobalConfig(); - const ai = config.ai; - const isBare = (id?: string): id is string => id != null && !id.includes(":"); - - const hasBareInList = (ai?.models ?? []).some(isBare); - if (!hasBareInList && !isBare(config.spiritModel) && !isBare(ai?.utilityModel)) return; - - // Only providers the admin explicitly added (with credentials) — not ambient - // env/OAuth providers — are candidates, matching what the user configured. - const providers = ai ? Object.keys(ai.providers) : []; - const customModels = ai?.customModels ?? []; - const providersServing = (bareId: string): string[] => - providers.filter( - (p) => - getBuiltinModel(p as never, bareId as never) != null || - customModels.some((cm) => cm.provider === p && cm.id === bareId), - ); - - let changed = false; - - if (ai?.models) { - const expanded: string[] = []; - for (const id of ai.models) { - if (!isBare(id)) { - expanded.push(id); - continue; - } - changed = true; - for (const p of providersServing(id)) expanded.push(`${p}:${id}`); - } - ai.models = expanded.length > 0 ? [...new Set(expanded)] : undefined; - if (!ai.models) delete ai.models; - } - - const qualifyOne = (id: string | undefined): string | undefined => { - if (!isBare(id)) return id; - const [provider] = providersServing(id); - if (!provider) return id; // no configured provider serves it — leave as-is - changed = true; - return `${provider}:${id}`; - }; - config.spiritModel = qualifyOne(config.spiritModel); - if (ai) ai.utilityModel = qualifyOne(ai.utilityModel); - - if (changed) writeGlobalConfig(config); -} - /** Get the admin-defined custom models (not in pi-ai's built-in catalog). */ export function getCustomModels(): CustomModel[] { return getAiConfig()?.customModels ?? []; diff --git a/packages/daemon/src/lib/config/setup.ts b/packages/daemon/src/lib/config/setup.ts index b95046e8..3ff0d55e 100644 --- a/packages/daemon/src/lib/config/setup.ts +++ b/packages/daemon/src/lib/config/setup.ts @@ -1,4 +1,4 @@ -import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; import { voluteSystemDir } from "../mind/registry.js"; import type { CognitionConfig, Schedule, SleepConfig } from "../mind/volute-config.js"; @@ -266,39 +266,6 @@ export function writeGlobalConfig(config: GlobalConfig): void { _cachedConfig = { config, ts: Date.now() }; } -/** - * Move provider credentials out of a legacy single-file config.json (which - * v0.41.1 locked to 0600 root-only, breaking non-root host CLI commands) - * into secrets.json, and relax config.json back to 0644. Idempotent; must run as - * the config owner (root on a system install), so it lives on the daemon startup - * path. A no-op once config.json is already split and 0644. - */ -export function migrateConfigSecrets(): void { - const path = configPath(); - if (!existsSync(path)) return; - let raw: string; - try { - raw = readFileSync(path, "utf-8"); // skip silently if we're not the owner - } catch { - return; - } - let parsed: GlobalConfig; - try { - parsed = JSON.parse(raw); - } catch { - return; - } - const embedsSecrets = - hasEntries(parsed.ai?.providers) || - hasEntries(parsed.imagegen?.providers) || - !!parsed.backup?.password || - hasEntries(parsed.backup?.env); - const mode = statSync(path).mode & 0o777; - if (!embedsSecrets && mode === 0o644) return; // already migrated - _resetConfigCache(); - writeGlobalConfig(readGlobalConfig()); // re-split with correct perms -} - /** * Setup lifecycle state: * - `complete` — the full flow finished (or a legacy install predating the flag). @@ -313,8 +280,6 @@ export type SetupStatus = "complete" | "in-progress" | "none"; export function computeSetupStatus(config: GlobalConfig): SetupStatus { if (config.setupCompleted === true) return "complete"; // Legacy install: has a setup block but predates the `setupCompleted` flag. - // migrateSetupCompleted() persists these as complete on daemon start; the CLI - // gate must agree even before the daemon has run that migration. if (config.setup != null && config.setupCompleted == null) return "complete"; // A setup entry point ran but the browser wizard hasn't finished. if (config.setup != null) return "in-progress"; @@ -408,12 +373,3 @@ export function mindLimitError(count: number, limit: number | undefined): string } return null; } - -/** Migrate pre-existing installations that have setup but not setupCompleted. */ -export function migrateSetupCompleted(): void { - const config = readGlobalConfig(); - if (config.setup != null && config.setupCompleted == null) { - config.setupCompleted = true; - writeGlobalConfig(config); - } -} diff --git a/packages/daemon/src/lib/delivery/delivery-manager.ts b/packages/daemon/src/lib/delivery/delivery-manager.ts index e4124472..71b3c3d4 100644 --- a/packages/daemon/src/lib/delivery/delivery-manager.ts +++ b/packages/daemon/src/lib/delivery/delivery-manager.ts @@ -854,8 +854,8 @@ export class DeliveryManager { let config: RoutingConfig; try { const parsed: unknown = JSON.parse(await readFile(path, "utf-8")); - // Valid JSON that isn't an object (an array — a shape this codebase has seen on disk, - // see migrate-thread-config.ts — or null, or a string) would let the rule silently + // Valid JSON that isn't an object (an array — a shape this codebase has seen on disk + // — or null, or a string) would let the rule silently // vanish at stringify time while we reported success. And an array-form config is // exactly a mind with no `rules`, i.e. one gating everything: the case this exists for. if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) { diff --git a/packages/daemon/src/lib/mind/migrate-init-infrastructure.ts b/packages/daemon/src/lib/mind/migrate-init-infrastructure.ts deleted file mode 100644 index ba9f7061..00000000 --- a/packages/daemon/src/lib/mind/migrate-init-infrastructure.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { resolve } from "node:path"; -import { backfillInitInfrastructure } from "../template/template.js"; -import log from "../util/logger.js"; -import { chownMindDir } from "./isolation.js"; -import { mindDir, readAllMinds } from "./registry.js"; - -const mlog = log.child("migrate"); - -/** - * One-time on-disk repair for `.init/` infrastructure files a mind never received. - * - * `.init/` is stripped from the upgrade's template branch so the merge can never - * overwrite a mind's identity files. That exclusion also blocked `.local/` hooks - * and bin shims, which no mind authored — so any capability shipped as a new hook - * reached only minds created after it existed, while the daemon-side half shipped - * and looked healthy. `home/.local/hooks/pre-prompt/notices.ts` is the sole reader - * of the next-turn system-event drain, so minds predating it were silently deaf to - * every crash notice, delivery failure, and extension notice ever recorded for them - * (#808 — 11 events, four minds, zero deliveries). - * - * This runs at startup rather than only on upgrade because upgrade does not reach - * every mind: `selectEligible` in auto-upgrade skips the spirit, seeds, variants, - * and any mind with `"upgrades": "manual"`. On the production host the spirit was - * one of the deaf minds, so an upgrade-only fix would have left it deaf - * indefinitely. Same reasoning as {@link migrateNamePlaceholders}. - * - * Adds only what is missing and never overwrites, so it is safe and idempotent — - * a mind that edited its own hook keeps its version. Non-fatal per mind. - */ -export async function migrateInitInfrastructure(): Promise { - const entries = await readAllMinds(); - for (const entry of entries) { - const dir = entry.dir ?? mindDir(entry.name); - const template = entry.template ?? "claude"; - try { - const added = backfillInitInfrastructure(resolve(dir, "home"), template, entry.name); - if (added.length > 0) { - mlog.info( - `added ${added.length} missing infrastructure file(s) for ${entry.name}: ${added.join(", ")}`, - ); - // The daemon writes these as root under `user` isolation; hand them to - // the mind so it can read, run, and edit its own hooks. - await chownMindDir(dir, entry.name); - } - } catch (err) { - mlog.warn( - `failed to backfill infrastructure files for ${entry.name} — it may be unable to ` + - "receive next-turn system events", - log.errorData(err), - ); - } - } -} diff --git a/packages/daemon/src/lib/mind/migrate-name-placeholder.ts b/packages/daemon/src/lib/mind/migrate-name-placeholder.ts deleted file mode 100644 index 69797e2c..00000000 --- a/packages/daemon/src/lib/mind/migrate-name-placeholder.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { readFileSync, writeFileSync } from "node:fs"; -import { resolve } from "node:path"; -import log from "../util/logger.js"; -import { mindDir, readAllMinds } from "./registry.js"; - -const mlog = log.child("migrate"); - -/** - * One-time on-disk repair for the `{{name}}` placeholder that template `.init/` - * files shipped unsubstituted. `applyInitFiles()` overlays `.init/` onto `home/` - * *after* copyTemplateToDir() runs its substitution pass, so a `.init/` file the - * manifest listed under its `home/` path was substituted and then overwritten by - * the raw `.init/` copy. Every mind ever created therefore got - * `"triggers": ["@{{name}}"]` in `home/.config/routes.json` — a batch trigger - * that can never match (matchesTrigger does a substring test against message - * text), so an @-mention in a #channel never flushed the batch early and the - * mind only ever answered after the full debounce/maxWait. - * - * The template fix only helps new minds: `home/.config/routes.json` is - * mind-owned and the upgrade merge deliberately never touches it, so existing - * minds would keep the dead trigger forever. This rewrites the literal string in - * place. Safe and idempotent — `{{name}}` is a template artifact no mind would - * author on purpose, and a repaired file no longer contains it. - * - * Runs on daemon startup for every registered mind, alongside - * migrateThreadConfigs(). - */ -export async function migrateNamePlaceholders(): Promise { - const entries = await readAllMinds(); - for (const entry of entries) { - const dir = entry.dir ?? mindDir(entry.name); - const path = resolve(dir, "home/.config/routes.json"); - try { - const raw = readFileSync(path, "utf-8"); - if (!raw.includes("{{name}}")) continue; - writeFileSync(path, raw.replaceAll("{{name}}", entry.name)); - mlog.info(`substituted leftover {{name}} placeholder in routes.json for ${entry.name}`); - } catch (err) { - if ((err as NodeJS.ErrnoException).code === "ENOENT") continue; // no routes.json - mlog.warn( - `failed to substitute {{name}} in routes.json for ${entry.name} — ` + - "channel batch triggers will not match a mention of its name", - log.errorData(err), - ); - } - } -} diff --git a/packages/daemon/src/lib/mind/migrate-thread-config.ts b/packages/daemon/src/lib/mind/migrate-thread-config.ts deleted file mode 100644 index 6d49394c..00000000 --- a/packages/daemon/src/lib/mind/migrate-thread-config.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { readFileSync, writeFileSync } from "node:fs"; -import { resolve } from "node:path"; -import log from "../util/logger.js"; -import { mindDir, readAllMinds } from "./registry.js"; - -const mlog = log.child("migrate"); - -/** - * One-time on-disk migration for the session→thread rename (#493) in files the - * daemon does NOT own — each mind's `home/.config/routes.json` and - * `home/.config/volute.json`. Without this, a pre-rename rule like - * `{ "channel": "*", "session": "..." }` doesn't just lose its thread target: - * ruleMatches rejects the whole rule on the unknown `session` key, and with - * gateUnmatched defaulting on, every message the rule used to route gets gated. - * - * Runs on daemon startup for every registered mind (idempotent — a migrated - * file is left untouched). Follows the existing startup-migration precedent - * (migrateConfigSecrets, avatar sizes, system-user role). - */ - -/** Rename `session` → `thread` on one object (rule or schedule). Returns true if changed. */ -function renameSessionKey(obj: Record): boolean { - if (obj == null || typeof obj !== "object" || !("session" in obj)) return false; - // If both keys exist, `thread` wins — but the stale `session` key must still - // go (an unknown rule key makes the whole rule unmatchable). - if (!("thread" in obj)) obj.thread = obj.session; - delete obj.session; - return true; -} - -/** Migrate a parsed routes.json config in place. Returns true if changed. */ -export function migrateRoutesConfig(config: unknown): boolean { - if (config == null || typeof config !== "object") return false; - let changed = false; - - // Flat-array form: [{channel, session}, ...] - const rules = Array.isArray(config) ? config : (config as { rules?: unknown }).rules; - if (Array.isArray(rules)) { - for (const rule of rules) { - if (renameSessionKey(rule)) changed = true; - } - } - - // Top-level thread-config map: `sessions` → `threads` - if (!Array.isArray(config)) { - const cfg = config as Record; - if ("sessions" in cfg) { - if (!("threads" in cfg)) cfg.threads = cfg.sessions; - delete cfg.sessions; - changed = true; - } - } - - return changed; -} - -/** Migrate a parsed volute.json config in place. Returns true if changed. */ -export function migrateVoluteConfig(config: unknown): boolean { - if (config == null || typeof config !== "object" || Array.isArray(config)) return false; - const schedules = (config as { schedules?: unknown }).schedules; - if (!Array.isArray(schedules)) return false; - let changed = false; - for (const schedule of schedules) { - if (renameSessionKey(schedule)) changed = true; - } - return changed; -} - -function migrateJsonFile(path: string, migrate: (config: unknown) => boolean): boolean { - let raw: string; - try { - raw = readFileSync(path, "utf-8"); - } catch { - return false; // no file — nothing to migrate - } - const config = JSON.parse(raw); // parse errors propagate to the caller's warn - if (!migrate(config)) return false; - writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`); - return true; -} - -/** Run the session→thread config migration for every registered mind. */ -export async function migrateThreadConfigs(): Promise { - const entries = await readAllMinds(); - for (const entry of entries) { - const dir = entry.dir ?? mindDir(entry.name); - for (const [file, migrate] of [ - ["routes.json", migrateRoutesConfig], - ["volute.json", migrateVoluteConfig], - ] as const) { - const path = resolve(dir, "home/.config", file); - try { - if (migrateJsonFile(path, migrate)) { - mlog.info(`migrated legacy session keys to thread in ${file} for ${entry.name}`); - } - } catch (err) { - mlog.warn( - `failed to migrate ${file} for ${entry.name} — legacy "session" keys may stop matching`, - log.errorData(err), - ); - } - } - } -} diff --git a/packages/daemon/src/lib/mind/upgrade.ts b/packages/daemon/src/lib/mind/upgrade.ts index 4b848ddf..a9cc3e94 100644 --- a/packages/daemon/src/lib/mind/upgrade.ts +++ b/packages/daemon/src/lib/mind/upgrade.ts @@ -375,9 +375,7 @@ async function mergeUpgradeAndRestart( // merge can't do this: `.init/` is stripped from the template branch so the // merge never overwrites identity files, which also meant a mind created // before a hook existed could never acquire it (#808). Runs after any template - // switch so it picks up the *new* template's composition — which is why this is - // here and not only in the startup pass (migrate-init-infrastructure.ts), which - // is the one that reaches minds upgrade never touches. Untracked paths, so no + // switch so it picks up the *new* template's composition. Untracked paths, so no // commit is needed; backfillInitInfrastructure throws rather than exiting, so a // broken template install is a warning here, not a failed upgrade. try { diff --git a/packages/daemon/src/lib/mind/volute-config.ts b/packages/daemon/src/lib/mind/volute-config.ts index 50240e1b..441f140f 100644 --- a/packages/daemon/src/lib/mind/volute-config.ts +++ b/packages/daemon/src/lib/mind/volute-config.ts @@ -10,7 +10,6 @@ export type Schedule = { script?: string; enabled: boolean; whileSleeping?: "skip" | "queue" | "trigger-wake"; - channel?: string; // deprecated — use thread instead thread?: string; // target thread name (e.g. "$new" for isolated thread) }; diff --git a/packages/daemon/src/web/api/minds.ts b/packages/daemon/src/web/api/minds.ts index 93861041..c891471b 100644 --- a/packages/daemon/src/web/api/minds.ts +++ b/packages/daemon/src/web/api/minds.ts @@ -2018,7 +2018,6 @@ const app = new Hono() template?: string; continue?: boolean; abort?: boolean; - accept?: boolean; diff?: boolean; } = {}; try { @@ -2077,18 +2076,6 @@ const app = new Hono() } } - if (body.accept) { - // Legacy — upgrades now auto-merge. Clean up any old-style upgrade state. - if (upgradeInProgress(mindName)) { - try { - await abortUpgrade(mindName); - } catch (err) { - log.warn(`failed to clean up legacy upgrade variant for ${mindName}`, log.errorData(err)); - } - } - return c.json({ error: "Upgrades now auto-merge. Run 'volute mind upgrade' again." }, 400); - } - if (body.diff) { // Initialize git repo if missing if (!existsSync(resolve(dir, ".git"))) { @@ -2698,38 +2685,16 @@ const app = new Hono() ? undefined : sql`${mindHistory.type} IN ('inbound','event','outbound','tool_use','tool_result','text','thinking','activity')`; - // Prefer turn_id-based query; fall back to legacy session+range - let rows: Array; - if (turnId) { - rows = await db - .select() - .from(mindHistory) - .where(and(eq(mindHistory.mind, name), eq(mindHistory.turn_id, turnId), typeFilter)) - .orderBy(mindHistory.id); - } else { - // Legacy: session + from_id/to_id range - const session = c.req.query("session"); - const fromId = parseInt(c.req.query("from_id") ?? "", 10); - const toId = parseInt(c.req.query("to_id") ?? "", 10); - if (!session || Number.isNaN(fromId) || Number.isNaN(toId)) { - return c.json({ error: "turn_id, or session with from_id and to_id, required" }, 400); - } - - rows = await db - .select() - .from(mindHistory) - .where( - and( - eq(mindHistory.mind, name), - eq(mindHistory.thread, session), - sql`${mindHistory.id} >= ${fromId}`, - sql`${mindHistory.id} <= ${toId}`, - typeFilter, - ), - ) - .orderBy(mindHistory.id); + if (!turnId) { + return c.json({ error: "turn_id required" }, 400); } + const rows = await db + .select() + .from(mindHistory) + .where(and(eq(mindHistory.mind, name), eq(mindHistory.turn_id, turnId), typeFilter)) + .orderBy(mindHistory.id); + return c.json(rows); }) .get("/:name/history/cross-session", requireSelf(), async (c) => { diff --git a/packages/daemon/src/web/api/schedules.ts b/packages/daemon/src/web/api/schedules.ts index aea0aca8..e8f1dd1f 100644 --- a/packages/daemon/src/web/api/schedules.ts +++ b/packages/daemon/src/web/api/schedules.ts @@ -305,7 +305,6 @@ const app = new Hono() if (body.message) schedule.message = body.message; if (body.messages?.length) schedule.messages = body.messages; if (body.script) schedule.script = body.script; - if (body.channel) schedule.channel = body.channel; if (body.thread) schedule.thread = body.thread; if (body.whileSleeping) schedule.whileSleeping = body.whileSleeping; schedules.push(schedule); @@ -374,7 +373,6 @@ const app = new Hono() ); } if (body.enabled !== undefined) schedules[idx].enabled = body.enabled; - if (body.channel !== undefined) schedules[idx].channel = body.channel || undefined; if (body.thread !== undefined) schedules[idx].thread = body.thread || undefined; if (body.whileSleeping !== undefined) schedules[idx].whileSleeping = body.whileSleeping || undefined; diff --git a/packages/daemon/src/web/api/setup.ts b/packages/daemon/src/web/api/setup.ts index 75f49359..21d16b22 100644 --- a/packages/daemon/src/web/api/setup.ts +++ b/packages/daemon/src/web/api/setup.ts @@ -9,7 +9,6 @@ import { isSetupComplete, readGlobalConfig, type SetupConfig, - type SetupType, writeGlobalConfig, } from "../../lib/config/setup.js"; import { @@ -91,39 +90,6 @@ setup.get("/status", async (c) => { }); }); -// Legacy configure endpoint (kept for backwards compatibility with CLI setup) -setup.post("/configure", async (c) => { - if (isSetupComplete()) { - return c.json({ error: "Setup already complete" }, 400); - } - - let body: { name: string; type?: SetupType }; - try { - body = await c.req.json(); - } catch { - return c.json({ error: "Invalid JSON in request body" }, 400); - } - - if (!body.name?.trim()) { - return c.json({ error: "System name is required" }, 400); - } - - const setupType: SetupType = body.type ?? "local"; - - if (setupType !== "local") { - return c.json({ error: "Web setup only supports local install type" }, 400); - } - - try { - const config = writeSetupConfig(body.name.trim()); - config.setupCompleted = true; - writeGlobalConfig(config); - return c.json({ ok: true, config: { name: config.name, setup: config.setup } }); - } catch (err) { - return c.json({ error: `Failed to write configuration: ${(err as Error).message}` }, 500); - } -}); - // Step 1: Configure system (name + description + remote access) setup.post("/system", async (c) => { if (isSetupComplete()) { diff --git a/packages/daemon/src/web/middleware/auth.ts b/packages/daemon/src/web/middleware/auth.ts index f2e73636..6d5685a5 100644 --- a/packages/daemon/src/web/middleware/auth.ts +++ b/packages/daemon/src/web/middleware/auth.ts @@ -8,28 +8,10 @@ import { resolveMindToken } from "../../lib/daemon/mind-tokens.js"; import { getDb } from "../../lib/db.js"; import { getBaseName } from "../../lib/mind/registry.js"; import { sessions } from "../../lib/schema.js"; -import log from "../../lib/util/logger.js"; const MIND_USER_CACHE_TTL = 5 * 60 * 1000; const mindUserCache = new Map(); -const alog = log.child("auth"); - -// Minds already warned about sending the pre-rename X-Volute-Session header, so -// a chatty un-upgraded mind logs once per daemon lifetime, not once per request. -// No fallback read — the hard cut stands; such minds degrade to marker-based -// turn attribution until upgraded. -const legacySessionHeaderWarned = new Set(); - -function warnLegacySessionHeader(legacyHeader: string | undefined, mindName: string): void { - if (!legacyHeader || legacySessionHeaderWarned.has(mindName)) return; - legacySessionHeaderWarned.add(mindName); - alog.warn( - `mind ${mindName} sent legacy X-Volute-Session header (now X-Volute-Thread); ` + - `run \`volute mind upgrade ${mindName}\` to update its template`, - ); -} - export function invalidateMindUserCache(mindName: string): void { mindUserCache.delete(mindName); } @@ -176,7 +158,6 @@ export const authMiddleware = createMiddleware(async (c, next) => { // Capture mind session for turn resolution const mindSessionHeader = c.req.header("X-Volute-Thread"); if (mindSessionHeader) c.set("mindSession", mindSessionHeader); - else warnLegacySessionHeader(c.req.header("X-Volute-Session"), mindName); await next(); return; } diff --git a/packages/web/src/ui/components/HistoryEvent.svelte b/packages/web/src/ui/components/HistoryEvent.svelte index b6ec4ec1..b31fc13e 100644 --- a/packages/web/src/ui/components/HistoryEvent.svelte +++ b/packages/web/src/ui/components/HistoryEvent.svelte @@ -155,22 +155,14 @@ async function handleClick() { if (event.type === "summary" && expandable) { turnExpanded = !turnExpanded; if (turnExpanded && turnEvents.length === 0) { - // Prefer turn_id when available; fall back to legacy session+range - const hasTurnId = !!event.turn_id; - const hasLegacy = event.thread && meta?.from_id && meta?.to_id; - if (!hasTurnId && !hasLegacy) { + if (!event.turn_id) { turnError = "Missing turn data"; return; } turnLoading = true; turnError = ""; try { - turnEvents = await fetchTurnEvents( - mindName, - hasTurnId - ? { turnId: event.turn_id! } - : { session: event.thread!, fromId: meta.from_id, toId: meta.to_id }, - ); + turnEvents = await fetchTurnEvents(mindName, { turnId: event.turn_id }); } catch (e) { turnError = "Failed to load turn details"; console.warn("Failed to fetch turn events:", e); @@ -248,15 +240,11 @@ async function handleClick() { if (!fullDetail && detailEvents.length === 0) { detailLoading = true; try { - const hasTurnId = !!event.turn_id; - const hasLegacy = event.thread && meta?.from_id && meta?.to_id; - if (hasTurnId || hasLegacy) { - detailEvents = await fetchTurnEvents( - mindName, - hasTurnId - ? { turnId: event.turn_id!, detail: true } - : { session: event.thread!, fromId: meta.from_id, toId: meta.to_id, detail: true }, - ); + if (event.turn_id) { + detailEvents = await fetchTurnEvents(mindName, { + turnId: event.turn_id, + detail: true, + }); } } catch (err) { console.warn("Failed to fetch detail events:", err); diff --git a/packages/web/src/ui/components/mind/MindClock.svelte b/packages/web/src/ui/components/mind/MindClock.svelte index 4a7e26aa..dba7e858 100644 --- a/packages/web/src/ui/components/mind/MindClock.svelte +++ b/packages/web/src/ui/components/mind/MindClock.svelte @@ -63,14 +63,14 @@ function formatAction(s: { message?: string; messages?: string[]; script?: string; - channel?: string; + thread?: string; whileSleeping?: string; }): string { const lines: string[] = []; if (s.script) lines.push(`Script: ${s.script}`); else if (s.messages?.length) lines.push(`Messages (rotating):\n${s.messages.join("\n")}`); else if (s.message) lines.push(`Message: ${s.message}`); - if (s.channel) lines.push(`Channel: ${s.channel}`); + if (s.thread) lines.push(`Thread: ${s.thread}`); if (s.whileSleeping) lines.push(`While sleeping: ${s.whileSleeping}`); return lines.join("\n"); } diff --git a/packages/web/src/ui/lib/client.ts b/packages/web/src/ui/lib/client.ts index 656010bd..641e8aa9 100644 --- a/packages/web/src/ui/lib/client.ts +++ b/packages/web/src/ui/lib/client.ts @@ -260,18 +260,10 @@ export function fetchHistory( export function fetchTurnEvents( name: string, - opts: ({ turnId: string } | { session: string; fromId: number; toId: number }) & { - detail?: boolean; - }, + opts: { turnId: string; detail?: boolean }, ): Promise { const params = new URLSearchParams(); - if ("turnId" in opts) { - params.set("turn_id", opts.turnId); - } else { - params.set("session", opts.session); - params.set("from_id", String(opts.fromId)); - params.set("to_id", String(opts.toId)); - } + params.set("turn_id", opts.turnId); if (opts.detail) params.set("detail", "1"); return get(`${V1}/minds/${enc(name)}/history/turn?${params}`); } diff --git a/src/cli.ts b/src/cli.ts index 1406d23d..11878bbd 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -78,9 +78,6 @@ switch (command) { case "chat": await import("@volute/cli/commands/chat.js").then((m) => m.run(args)); break; - case "variant": - await import("@volute/cli/commands/variant.js").then((m) => m.run(args)); - break; case "clock": await import("@volute/cli/commands/clock.js").then((m) => m.run(args)); break; diff --git a/test/ai-service.test.ts b/test/ai-service.test.ts index d61d6e1b..5f40c023 100644 --- a/test/ai-service.test.ts +++ b/test/ai-service.test.ts @@ -9,8 +9,6 @@ import { getAvailableModels, getCustomModels, getEnabledModels, - getUtilityModel, - migrateAiModelQualification, missingCredentialWarning, OAuthRefreshError, qualifyModelId, @@ -21,10 +19,8 @@ import { resolveOAuthCredentials, saveProviderConfig, setEnabledModels, - setUtilityModel, unqualifyModelId, } from "../packages/daemon/src/lib/ai-service.js"; -import { readGlobalConfig, writeGlobalConfig } from "../packages/daemon/src/lib/config/setup.js"; describe("ai-service config", () => { it("returns null when not configured", () => { @@ -354,59 +350,6 @@ describe("findModel provider-qualified resolution", () => { }); }); -describe("migrateAiModelQualification", () => { - it("expands a bare enabled id to every configured provider serving it", () => { - removeAiConfig(); - saveProviderConfig("openai-codex", { apiKey: "sk-codex" }); - saveProviderConfig("github-copilot", { apiKey: "sk-copilot" }); - setEnabledModels(["gpt-5.5"]); - - migrateAiModelQualification(); - - assert.deepEqual(getEnabledModels(), ["openai-codex:gpt-5.5", "github-copilot:gpt-5.5"]); - }); - - it("qualifies bare spiritModel and utilityModel to the first configured provider", () => { - removeAiConfig(); - saveProviderConfig("openai-codex", { apiKey: "sk-codex" }); - saveProviderConfig("github-copilot", { apiKey: "sk-copilot" }); - setUtilityModel("gpt-5.5"); - const cfg = readGlobalConfig(); - writeGlobalConfig({ ...cfg, spiritModel: "gpt-5.5" }); - - migrateAiModelQualification(); - - assert.equal(readGlobalConfig().spiritModel, "openai-codex:gpt-5.5"); - assert.equal(getUtilityModel(), "openai-codex:gpt-5.5"); - }); - - it("leaves already-qualified values untouched and is idempotent", () => { - removeAiConfig(); - saveProviderConfig("openai-codex", { apiKey: "sk-codex" }); - setEnabledModels(["openai-codex:gpt-5.5"]); - const cfg = readGlobalConfig(); - writeGlobalConfig({ ...cfg, spiritModel: "openai-codex:gpt-5.5" }); - - migrateAiModelQualification(); - assert.deepEqual(getEnabledModels(), ["openai-codex:gpt-5.5"]); - - // Second run is a no-op. - migrateAiModelQualification(); - assert.deepEqual(getEnabledModels(), ["openai-codex:gpt-5.5"]); - assert.equal(readGlobalConfig().spiritModel, "openai-codex:gpt-5.5"); - }); - - it("drops a bare enabled id no configured provider serves", () => { - removeAiConfig(); - saveProviderConfig("anthropic", { apiKey: "sk-ant" }); - setEnabledModels(["gpt-5.5"]); // anthropic does not serve gpt-5.5 - - migrateAiModelQualification(); - - assert.deepEqual(getEnabledModels(), []); - }); -}); - describe("spirit config.model derivation from a qualified spiritModel", () => { // syncSpiritTemplate writes `template === "pi" ? qualifyModelId(m) : unqualifyModelId(m)`. // With a provider-qualified spiritModel, pi keeps the qualified form and codex/claude diff --git a/test/daemon-e2e.test.ts b/test/daemon-e2e.test.ts index b136e9e8..842b443d 100644 --- a/test/daemon-e2e.test.ts +++ b/test/daemon-e2e.test.ts @@ -2028,7 +2028,7 @@ describe("daemon e2e", { timeout: 420000 }, () => { message: "dream", id: "test-sleep-sched", whileSleeping: "trigger-wake", - channel: "system:dream", + thread: "system:dream", }), }); assert.equal(addRes.status, 201, `Add: ${await addRes.clone().text()}`); @@ -2038,12 +2038,12 @@ describe("daemon e2e", { timeout: 420000 }, () => { const schedules = (await listRes.json()) as { id: string; whileSleeping?: string; - channel?: string; + thread?: string; }[]; const sched = schedules.find((s) => s.id === "test-sleep-sched"); assert.ok(sched); assert.equal(sched.whileSleeping, "trigger-wake"); - assert.equal(sched.channel, "system:dream"); + assert.equal(sched.thread, "system:dream"); // Update whileSleeping const updateRes = await daemonRequest(`/api/minds/${TEST_MIND}/schedules/test-sleep-sched`, { diff --git a/test/global-config.test.ts b/test/global-config.test.ts index d2b7550e..7cf1feaa 100644 --- a/test/global-config.test.ts +++ b/test/global-config.test.ts @@ -4,7 +4,6 @@ import { resolve } from "node:path"; import { afterEach, describe, it } from "node:test"; import { _resetConfigCache, - migrateConfigSecrets, secretsPath, writeGlobalConfig, } from "../packages/daemon/src/lib/config/setup.js"; @@ -126,40 +125,6 @@ describe("readGlobalConfig", () => { assert.equal(config.backup?.enabled, true); }); - it("migrateConfigSecrets splits a legacy single-file config and relaxes perms", () => { - // Simulate a v0.41.1 install: everything (incl. secrets) in a 0600 config.json. - mkdirSync(voluteSystemDir(), { recursive: true }); - writeFileSync( - configPath(), - JSON.stringify({ - hostname: "legacy", - ai: { providers: { anthropic: { apiKey: "sk-legacy" } } }, - }), - { mode: 0o600 }, - ); - _resetConfigCache(); - migrateConfigSecrets(); - // config.json is now host-readable and stripped of secrets. - assert.equal(statSync(configPath()).mode & 0o777, 0o644); - assert.ok(!readFileSync(configPath(), "utf-8").includes("sk-legacy")); - // secrets.json now holds the key at 0600. - assert.equal(statSync(secretsPath()).mode & 0o777, 0o600); - assert.ok(readFileSync(secretsPath(), "utf-8").includes("sk-legacy")); - // The merged view is unchanged for callers. - _resetConfigCache(); - assert.equal(readGlobalConfig().ai?.providers.anthropic.apiKey, "sk-legacy"); - }); - - it("migrateConfigSecrets is a no-op once already split and 0644", () => { - mkdirSync(voluteSystemDir(), { recursive: true }); - writeGlobalConfig({ hostname: "h", ai: { providers: { anthropic: { apiKey: "sk" } } } }); - const before = readFileSync(secretsPath(), "utf-8"); - _resetConfigCache(); - migrateConfigSecrets(); - assert.equal(statSync(configPath()).mode & 0o777, 0o644); - assert.equal(readFileSync(secretsPath(), "utf-8"), before); - }); - it("cached config is not corrupted by caller mutation", () => { mkdirSync(voluteSystemDir(), { recursive: true }); writeGlobalConfig({ hostname: "original" }); diff --git a/test/integration-setup.sh b/test/integration-setup.sh index a519d439..7048ac11 100755 --- a/test/integration-setup.sh +++ b/test/integration-setup.sh @@ -141,10 +141,9 @@ fi # Write setup config so CLI commands work inside the container. The `setupCompleted` # field is what `isSetupComplete()` checks; the container entrypoint never runs -# `volute setup`, and `migrateSetupCompleted()` only runs at daemon startup (after -# config.json already exists), so we must write the flag explicitly. config.json was -# absent when the daemon started, so its config cache is empty and this fresh write -# is picked up without a daemon restart. +# `volute setup`, so we must write the flag explicitly. config.json was absent when +# the daemon started, so its config cache is empty and this fresh write is picked up +# without a daemon restart. echo "Writing setup config..." docker exec "$CONTAINER" sh -c \ 'mkdir -p /data/system && echo '"'"'{"setup":{"type":"system","isolation":"user"},"setupCompleted":true}'"'"' > /data/system/config.json' diff --git a/test/migrate-name-placeholder.test.ts b/test/migrate-name-placeholder.test.ts deleted file mode 100644 index 700b5e05..00000000 --- a/test/migrate-name-placeholder.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import assert from "node:assert/strict"; -import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { afterEach, describe, it } from "node:test"; -import { migrateNamePlaceholders } from "../packages/daemon/src/lib/mind/migrate-name-placeholder.js"; -import { addMind, removeMind } from "../packages/daemon/src/lib/mind/registry.js"; - -describe("migrateNamePlaceholders", () => { - const name = `placeholder-test-${process.pid}`; - - afterEach(async () => { - await removeMind(name); - }); - - function writeRoutes(content: unknown): string { - const configDir = resolve(process.env.VOLUTE_HOME!, "minds", name, "home/.config"); - mkdirSync(configDir, { recursive: true }); - const path = resolve(configDir, "routes.json"); - writeFileSync(path, JSON.stringify(content, null, 2)); - return path; - } - - it("substitutes the mind's name into a leftover {{name}} batch trigger", async () => { - await addMind(name, 4198); - const path = writeRoutes({ - rules: [{ channel: "#*", thread: "garden" }], - threads: { - "#*": { batch: { debounce: 20, maxWait: 120, triggers: ["@{{name}}"] } }, - }, - }); - - await migrateNamePlaceholders(); - - const routes = JSON.parse(readFileSync(path, "utf-8")); - assert.deepEqual(routes.threads["#*"].batch.triggers, [`@${name}`]); - // The rest of the mind-owned file is untouched. - assert.deepEqual(routes.rules, [{ channel: "#*", thread: "garden" }]); - }); - - it("leaves an already-correct routes.json byte-identical", async () => { - await addMind(name, 4198); - const path = writeRoutes({ - threads: { "#*": { batch: { triggers: [`@${name}`] } } }, - }); - const before = readFileSync(path, "utf-8"); - - await migrateNamePlaceholders(); - - assert.equal(readFileSync(path, "utf-8"), before); - }); - - it("does not fail on a mind with no routes.json", async () => { - await addMind(name, 4198); - mkdirSync(resolve(process.env.VOLUTE_HOME!, "minds", name, "home/.config"), { - recursive: true, - }); - - await migrateNamePlaceholders(); - }); -}); diff --git a/test/migrate-thread-config.test.ts b/test/migrate-thread-config.test.ts deleted file mode 100644 index 1538ea87..00000000 --- a/test/migrate-thread-config.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -import assert from "node:assert/strict"; -import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { afterEach, describe, it } from "node:test"; -import { - clearConfigCache, - getRoutingConfig, - resolveRoute, -} from "../packages/daemon/src/lib/delivery/delivery-router.js"; -import { - migrateRoutesConfig, - migrateThreadConfigs, - migrateVoluteConfig, -} from "../packages/daemon/src/lib/mind/migrate-thread-config.js"; -import { addMind, removeMind } from "../packages/daemon/src/lib/mind/registry.js"; -import log from "../packages/daemon/src/lib/util/logger.js"; - -describe("migrateRoutesConfig", () => { - it("renames rule session keys and the sessions map", () => { - const config = { - gateUnmatched: true, - rules: [ - { channel: "*", isDM: true, session: "${channel}" }, - { channel: "#*", session: "${channel}" }, - { channel: "discord:logs", destination: "file", path: "notes/log.md" }, - ], - sessions: { "#*": { batch: { debounce: 20 } } }, - default: "main", - }; - assert.equal(migrateRoutesConfig(config), true); - assert.deepEqual(config.rules[0], { channel: "*", isDM: true, thread: "${channel}" }); - assert.deepEqual(config.rules[1], { channel: "#*", thread: "${channel}" }); - // Non-session rules untouched - assert.deepEqual(config.rules[2], { - channel: "discord:logs", - destination: "file", - path: "notes/log.md", - }); - assert.deepEqual((config as Record).threads, { - "#*": { batch: { debounce: 20 } }, - }); - assert.ok(!("sessions" in config), "legacy sessions map removed"); - }); - - it("handles the flat-array rules form", () => { - const config = [{ channel: "web", session: "web" }]; - assert.equal(migrateRoutesConfig(config), true); - assert.deepEqual(config, [{ channel: "web", thread: "web" }]); - }); - - it("prefers an existing thread key but still drops the stale session key", () => { - const config = { rules: [{ channel: "web", session: "old", thread: "new" }] }; - assert.equal(migrateRoutesConfig(config), true); - assert.deepEqual(config.rules[0], { channel: "web", thread: "new" }); - }); - - it("is a no-op on an already-migrated config", () => { - const config = { rules: [{ channel: "#*", thread: "${channel}" }], threads: {} }; - assert.equal(migrateRoutesConfig(config), false); - }); -}); - -describe("migrateVoluteConfig", () => { - it("renames schedule session keys", () => { - const config = { - schedules: [ - { id: "dream", cron: "0 3 * * *", message: "dream", enabled: true, session: "$new" }, - { id: "morning", cron: "0 9 * * *", message: "morning", enabled: true }, - ], - }; - assert.equal(migrateVoluteConfig(config), true); - assert.equal((config.schedules[0] as Record).thread, "$new"); - assert.ok(!("session" in config.schedules[0])); - assert.ok(!("thread" in config.schedules[1]), "untargeted schedule untouched"); - }); - - it("is a no-op without schedules or session keys", () => { - assert.equal(migrateVoluteConfig({}), false); - assert.equal(migrateVoluteConfig({ schedules: [{ id: "x", enabled: true }] }), false); - }); -}); - -describe("migrateThreadConfigs (on-disk)", () => { - const name = `migrate-test-${process.pid}`; - - afterEach(async () => { - await removeMind(name); - clearConfigCache(); - }); - - it("rewrites a legacy mind's routes.json and volute.json once", async () => { - await addMind(name, 4197); - const configDir = resolve(process.env.VOLUTE_HOME!, "minds", name, "home/.config"); - mkdirSync(configDir, { recursive: true }); - const routesPath = resolve(configDir, "routes.json"); - const volutePath = resolve(configDir, "volute.json"); - writeFileSync( - routesPath, - JSON.stringify({ - gateUnmatched: true, - rules: [{ channel: "*", session: "${channel}" }], - sessions: { "#*": { delivery: "batch" } }, - default: "main", - }), - ); - writeFileSync( - volutePath, - JSON.stringify({ - schedules: [ - { id: "dream", cron: "0 3 * * *", message: "m", enabled: true, session: "$new" }, - ], - }), - ); - - await migrateThreadConfigs(); - - const routes = JSON.parse(readFileSync(routesPath, "utf-8")); - assert.deepEqual(routes.rules, [{ channel: "*", thread: "${channel}" }]); - assert.deepEqual(routes.threads, { "#*": { delivery: "batch" } }); - assert.ok(!("sessions" in routes)); - const volute = JSON.parse(readFileSync(volutePath, "utf-8")); - assert.equal(volute.schedules[0].thread, "$new"); - assert.ok(!("session" in volute.schedules[0])); - - // The migrated rule actually routes again (the pre-migration form would - // have been rejected as an unknown-key rule and gated everything). - clearConfigCache(); - const route = resolveRoute(getRoutingConfig(name), { channel: "@alice" }); - assert.deepEqual(route, { - destination: "mind", - session: "@alice", - matched: true, - mode: undefined, - rule: { channel: "*", thread: "${channel}" }, - }); - - // Idempotent: a second run leaves the files byte-identical. - const routesBefore = readFileSync(routesPath, "utf-8"); - const voluteBefore = readFileSync(volutePath, "utf-8"); - await migrateThreadConfigs(); - assert.equal(readFileSync(routesPath, "utf-8"), routesBefore); - assert.equal(readFileSync(volutePath, "utf-8"), voluteBefore); - }); -}); - -describe("unknown rule keys", () => { - const name = `unknown-key-${process.pid}`; - - afterEach(async () => { - await removeMind(name); - clearConfigCache(); - }); - - it("logs a warning at config load and the rule never matches", async () => { - await addMind(name, 4196); - const configDir = resolve(process.env.VOLUTE_HOME!, "minds", name, "home/.config"); - mkdirSync(configDir, { recursive: true }); - writeFileSync( - resolve(configDir, "routes.json"), - JSON.stringify({ rules: [{ channel: "web", sesion: "typo" }], default: "fallback" }), - ); - - const lines: string[] = []; - log.setOutput((line) => lines.push(line)); - try { - clearConfigCache(); - const config = getRoutingConfig(name); - const route = resolveRoute(config, { channel: "web" }); - // The rule with the unknown key is skipped entirely — fallback applies. - assert.equal(route.destination, "mind"); - assert.equal((route as { session: string }).session, "fallback"); - assert.equal((route as { matched: boolean }).matched, false); - - const warns = lines.map((l) => JSON.parse(l)).filter((e) => e.level === "warn"); - assert.equal(warns.length, 1, "exactly one warning per config load"); - assert.match(warns[0].msg, /unrecognized key/); - assert.match(warns[0].msg, /"sesion"/); - - // Cached loads do not re-warn. - getRoutingConfig(name); - const warnsAfter = lines.map((l) => JSON.parse(l)).filter((e) => e.level === "warn"); - assert.equal(warnsAfter.length, 1); - } finally { - log.setOutput((line) => process.stderr.write(`${line}\n`)); - } - }); -}); diff --git a/test/setup.test.ts b/test/setup.test.ts index 18476abb..e9d118ae 100644 --- a/test/setup.test.ts +++ b/test/setup.test.ts @@ -80,8 +80,7 @@ describe("setup config", () => { }); it("isSetupComplete treats a legacy config (setup block, no setupCompleted flag) as complete", () => { - // Predates the setupCompleted flag; migrateSetupCompleted() persists these as - // complete on daemon start, so the CLI gate must agree even before that runs. + // Predates the setupCompleted flag; a setup block with no flag reads as complete. writeGlobalConfig({ name: "test", setup: { type: "local", mindsDir: "/tmp", isolation: "sandbox", service: false }, diff --git a/test/web-setup.test.ts b/test/web-setup.test.ts index b0279379..5b4d6226 100644 --- a/test/web-setup.test.ts +++ b/test/web-setup.test.ts @@ -54,60 +54,6 @@ describe("web setup routes", () => { assert.ok(body.config); }); - it("POST /api/setup/configure — creates config", async () => { - const app = createApp(); - const res = await app.request("/api/setup/configure", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: "my-system" }), - }); - assert.equal(res.status, 200); - const body = await res.json(); - assert.equal(body.ok, true); - assert.equal(body.config.name, "my-system"); - assert.equal(body.config.setup.type, "local"); - assert.equal(body.config.setup.isolation, "sandbox"); - - // Verify config was written - const config = readGlobalConfig(); - assert.equal(config.name, "my-system"); - }); - - it("POST /api/setup/configure — rejects empty name", async () => { - const app = createApp(); - const res = await app.request("/api/setup/configure", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: "" }), - }); - assert.equal(res.status, 400); - }); - - it("POST /api/setup/configure — rejects duplicate setup", async () => { - writeGlobalConfig({ - name: "test", - setup: { type: "local", mindsDir: "/tmp/minds", isolation: "sandbox", service: false }, - setupCompleted: true, - }); - const app = createApp(); - const res = await app.request("/api/setup/configure", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: "another" }), - }); - assert.equal(res.status, 400); - }); - - it("POST /api/setup/configure — rejects system type", async () => { - const app = createApp(); - const res = await app.request("/api/setup/configure", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: "test", type: "system" }), - }); - assert.equal(res.status, 400); - }); - it("POST /api/setup/spirit — stores name and temperament", async () => { writeGlobalConfig({ name: "test",