diff options
| author | Adam Malczewski <[email protected]> | 2026-05-20 15:04:26 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-20 15:04:26 +0900 |
| commit | adc8bd185b54935e7a31aae04da3175b7989927a (patch) | |
| tree | 031fa53009a4708ce0cc15c0e3dcb2f2a73cf2d9 /packages/core/src/config | |
| parent | 52af4ca33b1f83b8f863a6267d447944f55e729d (diff) | |
| download | dispatch-adc8bd185b54935e7a31aae04da3175b7989927a.tar.gz dispatch-adc8bd185b54935e7a31aae04da3175b7989927a.zip | |
feat: phase 3 — config, skills, model groups, task list, and sidebar UI
- Config system: TOML-based dispatch.toml with hot-reload via chokidar
- Model/key resolution: tag-based model selection, key fallback chains
- Skills system: directory loader with TOML frontmatter, agent mappings
- Task list tool: add/update/list/get operations with WebSocket events
- API routes: GET /config, /skills, /skills/:name, /models, /models/resolve
- Frontend: sidebar with model status, task list, config viewer, skills browser, permission log
- Sliding sidebar animation using CSS transitions (not Svelte transitions)
Diffstat (limited to 'packages/core/src/config')
| -rw-r--r-- | packages/core/src/config/index.ts | 3 | ||||
| -rw-r--r-- | packages/core/src/config/loader.ts | 119 | ||||
| -rw-r--r-- | packages/core/src/config/schema.ts | 235 | ||||
| -rw-r--r-- | packages/core/src/config/watcher.ts | 53 |
4 files changed, 313 insertions, 97 deletions
diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts new file mode 100644 index 0000000..5b128c4 --- /dev/null +++ b/packages/core/src/config/index.ts @@ -0,0 +1,3 @@ +export { configToRuleset, loadConfig } from "./loader.js"; +export { validateConfig } from "./schema.js"; +export { createConfigWatcher } from "./watcher.js"; diff --git a/packages/core/src/config/loader.ts b/packages/core/src/config/loader.ts index 0e58ad2..3b4d733 100644 --- a/packages/core/src/config/loader.ts +++ b/packages/core/src/config/loader.ts @@ -1,25 +1,12 @@ import { readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; +import { parse } from "smol-toml"; import type { PermissionRule, Ruleset } from "../permission/index.js"; +import type { DispatchConfig } from "../types/index.js"; +import { validateConfig } from "./schema.js"; -// Strip inline comments that appear outside of quoted strings. -// Handles both single- and double-quoted values. -function stripInlineComment(line: string): string { - // Walk character by character; track whether we're inside quotes. - let inQuote: string | null = null; - for (let i = 0; i < line.length; i++) { - const ch = line[i]; - if (inQuote) { - if (ch === inQuote) inQuote = null; - } else if (ch === '"' || ch === "'") { - inQuote = ch; - } else if (ch === "#") { - return line.slice(0, i).trimEnd(); - } - } - return line; -} +const DEFAULT_CONFIG: DispatchConfig = { permissions: {} }; const VALID_ACTIONS = new Set(["allow", "deny", "ask"]); @@ -29,89 +16,27 @@ function validateAction(raw: string): "allow" | "deny" | "ask" { return "ask"; } -export interface DispatchConfig { - permissions: Record<string, string | Record<string, string>>; -} - -// Load dispatch.yaml from the given directory +// Load dispatch.toml from the given directory export function loadConfig(dir: string): DispatchConfig { - const yamlPath = join(dir, "dispatch.yaml"); + const tomlPath = join(dir, "dispatch.toml"); + let raw: unknown; try { - const content = readFileSync(yamlPath, "utf-8"); - return parseYaml(content); - } catch { - return { permissions: {} }; - } -} - -function expandHome(value: string): string { - const home = homedir(); - return value.replace(/^\$HOME(?=[\/\\]|$)/, home).replace(/^~(?=[\/\\]|$)/, home); -} - -function stripQuotes(s: string): string { - if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) { - return s.slice(1, -1); - } - return s; -} - -// Parse simple YAML for the permissions structure -function parseYaml(content: string): DispatchConfig { - const permissions: Record<string, string | Record<string, string>> = {}; - const lines = content.split("\n"); - - let inPermissions = false; - let currentKey: string | null = null; - - for (const raw of lines) { - // Skip comments and blank lines - const trimmed = raw.trimEnd(); - const stripped = trimmed.trimStart(); - if (stripped === "" || stripped.startsWith("#")) continue; - - const indent = trimmed.length - stripped.length; - - if (indent === 0) { - // Top-level key - inPermissions = stripped.startsWith("permissions:"); - currentKey = null; - continue; - } - - if (!inPermissions) continue; - - if (indent === 2) { - // permission key line: " key: value" or " key:" - const colonIdx = stripped.indexOf(":"); - if (colonIdx === -1) continue; - const key = stripQuotes(stripped.slice(0, colonIdx).trim()); - const valueRaw = stripInlineComment(stripped.slice(colonIdx + 1).trim()).trim(); - if (valueRaw === "" || valueRaw === null) { - // nested map follows - currentKey = key; - permissions[currentKey] = {}; - } else { - // inline value - const value = stripQuotes(valueRaw); - permissions[key] = value; - currentKey = null; - } - continue; - } - - if (indent >= 4 && currentKey !== null) { - // sub-key line: " "pattern": action" - const colonIdx = stripped.indexOf(":"); - if (colonIdx === -1) continue; - const pattern = expandHome(stripQuotes(stripped.slice(0, colonIdx).trim())); - const actionRaw = stripQuotes(stripInlineComment(stripped.slice(colonIdx + 1).trim()).trim()); - const action = validateAction(actionRaw); - (permissions[currentKey] as Record<string, string>)[pattern] = action; + const content = readFileSync(tomlPath, "utf-8"); + raw = parse(content); + } catch (err: unknown) { + if (err instanceof Error && (err as NodeJS.ErrnoException).code === "ENOENT") { + // File doesn't exist — return empty default + return DEFAULT_CONFIG; } + console.warn(`dispatch: failed to parse dispatch.toml: ${err instanceof Error ? err.message : String(err)}`); + throw err; } - return { permissions }; + const { config, errors } = validateConfig(raw); + for (const e of errors) { + console.warn(`dispatch: config warning at ${e.path}: ${e.message}`); + } + return config; } // Convert the config's permission block to a Ruleset @@ -126,8 +51,8 @@ export function configToRuleset(config: DispatchConfig): Ruleset { } else { for (const [rawPattern, rawAction] of Object.entries(value)) { const pattern = rawPattern - .replace(/^\$HOME(?=[\/\\]|$)/, home) - .replace(/^~(?=[\/\\]|$)/, home); + .replace(/^\$HOME(?=[/\\]|$)/, home) + .replace(/^~(?=[/\\]|$)/, home); const action = validateAction(rawAction); rules.push({ permission, pattern, action }); } diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts new file mode 100644 index 0000000..d3eea98 --- /dev/null +++ b/packages/core/src/config/schema.ts @@ -0,0 +1,235 @@ +import type { + AgentTemplate, + ConfigError, + DispatchConfig, + KeyDefinition, + ModelDefinition, +} from "../types/index.js"; + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isStringRecord(value: unknown): value is Record<string, string> { + if (!isRecord(value)) return false; + return Object.values(value).every((v) => typeof v === "string"); +} + +function isValidAction(value: string): boolean { + return value === "allow" || value === "deny" || value === "ask"; +} + +function isPermissionsValue(value: unknown): value is string | Record<string, string> { + return typeof value === "string" || isStringRecord(value); +} + +function validatePermissions( + raw: unknown, + path: string, + errors: ConfigError[], +): Record<string, string | Record<string, string>> { + if (!isRecord(raw)) { + errors.push({ path, message: "must be an object" }); + return {}; + } + const result: Record<string, string | Record<string, string>> = {}; + for (const [key, value] of Object.entries(raw)) { + if (!isPermissionsValue(value)) { + errors.push({ path: `${path}.${key}`, message: "must be a string or a flat string-keyed object" }); + continue; + } + if (typeof value === "string") { + if (!isValidAction(value)) { + errors.push({ path: `${path}.${key}`, message: `invalid action "${value}"; must be "allow", "deny", or "ask"` }); + continue; + } + } else { + let hasError = false; + for (const [pattern, action] of Object.entries(value)) { + if (!isValidAction(action)) { + errors.push({ path: `${path}.${key}.${pattern}`, message: `invalid action "${action}"; must be "allow", "deny", or "ask"` }); + hasError = true; + } + } + if (hasError) continue; + } + result[key] = value; + } + return result; +} + +function validateAgentTemplate( + raw: unknown, + path: string, + errors: ConfigError[], +): AgentTemplate | null { + if (!isRecord(raw)) { + errors.push({ path, message: "must be an object" }); + return null; + } + + const requiredStrings = ["name", "description", "system_prompt", "model_tag"] as const; + for (const field of requiredStrings) { + if (typeof raw[field] !== "string") { + errors.push({ path: `${path}.${field}`, message: "must be a string" }); + return null; + } + } + + if (!Array.isArray(raw["tools"]) || !raw["tools"].every((t) => typeof t === "string")) { + errors.push({ path: `${path}.tools`, message: "must be an array of strings" }); + return null; + } + + const perms = validatePermissions(raw["permissions"] ?? {}, `${path}.permissions`, errors); + + return { + name: raw["name"] as string, + description: raw["description"] as string, + system_prompt: raw["system_prompt"] as string, + tools: raw["tools"] as string[], + model_tag: raw["model_tag"] as string, + permissions: perms, + }; +} + +function validateModel(raw: unknown, path: string, errors: ConfigError[]): ModelDefinition | null { + if (!isRecord(raw)) { + errors.push({ path, message: "must be an object" }); + return null; + } + if (typeof raw["id"] !== "string") { + errors.push({ path: `${path}.id`, message: "must be a string" }); + return null; + } + if (typeof raw["provider"] !== "string") { + errors.push({ path: `${path}.provider`, message: "must be a string" }); + return null; + } + if ( + !Array.isArray(raw["tags"]) || + raw["tags"].length === 0 || + !raw["tags"].every((t) => typeof t === "string") + ) { + errors.push({ path: `${path}.tags`, message: "must be a non-empty array of strings" }); + return null; + } + return { + id: raw["id"] as string, + provider: raw["provider"] as string, + tags: raw["tags"] as string[], + }; +} + +function validateKey(raw: unknown, path: string, errors: ConfigError[]): KeyDefinition | null { + if (!isRecord(raw)) { + errors.push({ path, message: "must be an object" }); + return null; + } + for (const field of ["id", "provider", "env", "base_url"] as const) { + if (typeof raw[field] !== "string") { + errors.push({ path: `${path}.${field}`, message: "must be a string" }); + return null; + } + } + return { + id: raw["id"] as string, + provider: raw["provider"] as string, + env: raw["env"] as string, + base_url: raw["base_url"] as string, + }; +} + +export function validateConfig(raw: unknown): { config: DispatchConfig; errors: ConfigError[] } { + const errors: ConfigError[] = []; + + if (!isRecord(raw)) { + errors.push({ path: "", message: "config must be an object" }); + return { config: { permissions: {} }, errors }; + } + + // permissions (required, but can be empty) + const permissions = validatePermissions(raw["permissions"] ?? {}, "permissions", errors); + + // agents (optional) + let agents: Record<string, AgentTemplate> | undefined; + if (raw["agents"] !== undefined) { + if (!isRecord(raw["agents"])) { + errors.push({ path: "agents", message: "must be an object" }); + } else { + agents = {}; + for (const [key, value] of Object.entries(raw["agents"])) { + const agent = validateAgentTemplate(value, `agents.${key}`, errors); + if (agent) agents[key] = agent; + } + } + } + + // models (optional) + let models: ModelDefinition[] | undefined; + if (raw["models"] !== undefined) { + if (!Array.isArray(raw["models"])) { + errors.push({ path: "models", message: "must be an array" }); + } else { + models = []; + for (let i = 0; i < raw["models"].length; i++) { + const model = validateModel(raw["models"][i], `models[${i}]`, errors); + if (model) models.push(model); + } + } + } + + // keys (optional) + let keys: KeyDefinition[] | undefined; + if (raw["keys"] !== undefined) { + if (!Array.isArray(raw["keys"])) { + errors.push({ path: "keys", message: "must be an array" }); + } else { + keys = []; + for (let i = 0; i < raw["keys"].length; i++) { + const key = validateKey(raw["keys"][i], `keys[${i}]`, errors); + if (key) keys.push(key); + } + } + } + + // fallback (optional) + let fallback: string[] | undefined; + if (raw["fallback"] !== undefined) { + if (!Array.isArray(raw["fallback"]) || !raw["fallback"].every((f) => typeof f === "string")) { + errors.push({ path: "fallback", message: "must be an array of strings" }); + } else { + fallback = raw["fallback"] as string[]; + // Validate that referenced key IDs exist + const keyIds = new Set((keys ?? []).map((k) => k.id)); + for (const id of fallback) { + if (!keyIds.has(id)) { + errors.push({ path: "fallback", message: `key id "${id}" not found in keys` }); + } + } + } + } + + // Warn if agent model_tags don't match any model + if (agents && models) { + const allTags = new Set(models.flatMap((m) => m.tags)); + for (const [name, agent] of Object.entries(agents)) { + if (!allTags.has(agent.model_tag)) { + errors.push({ + path: `agents.${name}.model_tag`, + message: `no model found with tag "${agent.model_tag}"`, + }); + } + } + } + + const config: DispatchConfig = { + permissions, + ...(agents !== undefined && { agents }), + ...(models !== undefined && { models }), + ...(keys !== undefined && { keys }), + ...(fallback !== undefined && { fallback }), + }; + + return { config, errors }; +} diff --git a/packages/core/src/config/watcher.ts b/packages/core/src/config/watcher.ts new file mode 100644 index 0000000..42b2f87 --- /dev/null +++ b/packages/core/src/config/watcher.ts @@ -0,0 +1,53 @@ +import { watch } from "chokidar"; +import { join } from "node:path"; +import type { DispatchConfig } from "../types/index.js"; +import { loadConfig } from "./loader.js"; + +export function createConfigWatcher( + dir: string, + onChange: (config: DispatchConfig) => void, +): { close(): void } { + const tomlPath = join(dir, "dispatch.toml"); + let debounceTimer: ReturnType<typeof setTimeout> | null = null; + + const watcher = watch(tomlPath, { + ignoreInitial: true, + persistent: false, + }); + + const handleChange = () => { + if (debounceTimer !== null) { + clearTimeout(debounceTimer); + } + debounceTimer = setTimeout(() => { + debounceTimer = null; + console.log(`dispatch: reloading config from ${tomlPath}`); + try { + const config = loadConfig(dir); + onChange(config); + } catch (err) { + console.warn(`dispatch: retaining last known config due to parse error: ${err instanceof Error ? err.message : String(err)}`); + } + }, 300); + }; + + watcher.on("change", handleChange); + watcher.on("add", handleChange); + watcher.on("unlink", handleChange); + + watcher.on("error", (err) => { + console.warn(`dispatch: config watcher error: ${err instanceof Error ? err.message : String(err)}`); + }); + + return { + close() { + if (debounceTimer !== null) { + clearTimeout(debounceTimer); + debounceTimer = null; + } + watcher.close().catch((err) => { + console.warn(`dispatch: error closing config watcher: ${err instanceof Error ? err.message : String(err)}`); + }); + }, + }; +} |
