diff --git a/src/agent/director.ts b/src/agent/director.ts index 0a14c34ff..b3ed393fd 100644 --- a/src/agent/director.ts +++ b/src/agent/director.ts @@ -28,6 +28,7 @@ import { onTurnBoundary } from "./reactor-events.js"; import { isOperatorOriginated } from "./message-provenance.js"; import { errorMessage } from "./error-message.js"; import { type } from "arktype"; +import { lazyCallable, memoSchema } from "../schema/lazy.js"; import { composeDirectedPrompt, inferWithEphemeralNudge, @@ -398,9 +399,13 @@ export const COMPACTION_FOLD_NONCONVERGED_EVENT = "custom.compaction.fold_nonconverged"; export const CREDENTIAL_RECOVERY_INTERCHANGE_TYPE = "system.credential.refresh" as const; -export const ChatTasksChangedDataSchema = type({ - tasks: TaskSchema.array(), -}); +export const ChatTasksChangedDataSchema = lazyCallable( + memoSchema(() => + type({ + tasks: TaskSchema.array(), + }), + ), +); export const ChatToolsActivateDataSchema = type({ names: "string[]", }); diff --git a/src/agent/profiles.ts b/src/agent/profiles.ts index 15b65d571..fb8f6946b 100644 --- a/src/agent/profiles.ts +++ b/src/agent/profiles.ts @@ -3,6 +3,8 @@ import { join } from "node:path"; import { type } from "arktype"; +import { lazyCallable, memoSchema } from "../schema/lazy.js"; + import { defaultAgentsPlugin as defaultPlugin } from "./default-agents.js"; import { isDirectorId } from "./directors/registry.js"; import { REASONING_EFFORTS } from "./profile-types.js"; @@ -16,45 +18,50 @@ export type { } from "./profile-types.js"; import type { AgentProfile } from "./profile-types.js"; +function buildAgentProfileSchema() { + const CapabilityFilterSchema = type({ + mode: "'exclude' | 'allow'", + tools: "string[]", + }); + + // Reasoning-effort schema derived from the canonical array. arktype's `type()` + // is statically typed for literal strings; a computed string requires a cast + // through `unknown`. The schema is exercised by src/plugins/data-only-agent + // and the runtime ReasoningEffort re-export, so drift is caught. + const reasoningEffortLiteral = REASONING_EFFORTS.map((e) => `'${e}'`).join( + " | ", + ); + const ReasoningEffortSchema = type( + reasoningEffortLiteral as unknown as "'none'", + ); + + const InferenceLegSchema = type({ + provider: "string>0", + model: "string>0", + "reasoningEffort?": ReasoningEffortSchema, + }); + + const InferenceSpecSchema = type({ + "mode?": "'pin' | 'prefer'", + order: InferenceLegSchema.array(), + }); + + return type({ + id: "string", + "description?": "string", + "inference?": InferenceSpecSchema, + "capabilities?": CapabilityFilterSchema, + "systemPromptRole?": "string", + "systemPromptPath?": "string", + "orchestrator?": "boolean", + }); +} + +const agentProfileSchema = memoSchema(buildAgentProfileSchema); + // Exported so agent-kind plugins can validate contributed profiles. -export { AgentProfileSchema }; - -const CapabilityFilterSchema = type({ - mode: "'exclude' | 'allow'", - tools: "string[]", -}); - -// Reasoning-effort schema derived from the canonical array. arktype's `type()` -// is statically typed for literal strings; a computed string requires a cast -// through `unknown`. The schema is exercised by src/plugins/data-only-agent -// and the runtime ReasoningEffort re-export, so drift is caught. -const reasoningEffortLiteral = REASONING_EFFORTS.map((e) => `'${e}'`).join( - " | ", -); -const ReasoningEffortSchema = type( - reasoningEffortLiteral as unknown as "'none'", -); - -const InferenceLegSchema = type({ - provider: "string>0", - model: "string>0", - "reasoningEffort?": ReasoningEffortSchema, -}); - -const InferenceSpecSchema = type({ - "mode?": "'pin' | 'prefer'", - order: InferenceLegSchema.array(), -}); - -const AgentProfileSchema = type({ - id: "string", - "description?": "string", - "inference?": InferenceSpecSchema, - "capabilities?": CapabilityFilterSchema, - "systemPromptRole?": "string", - "systemPromptPath?": "string", - "orchestrator?": "boolean", -}); +export const AgentProfileSchema: ReturnType = + lazyCallable(agentProfileSchema); function isENOENT(err: unknown): boolean { return ( diff --git a/src/agent/tasks.ts b/src/agent/tasks.ts index 0e09f29c0..8e6a9a87c 100644 --- a/src/agent/tasks.ts +++ b/src/agent/tasks.ts @@ -1,23 +1,35 @@ import { type } from "arktype"; import type { ToolDefinition } from "@intx/types/runtime"; +import { lazyCallable, memoSchema } from "../schema/lazy.js"; + // A task is a unit of work the agent registered for itself. The agent owns the // list — it adds, renames, cancels, and status-updates items mid-run. -export const TaskStatusSchema = type("'todo' | 'doing' | 'done' | 'cancelled'"); -export type TaskStatus = typeof TaskStatusSchema.infer; +function buildTaskStatusSchema() { + return type("'todo' | 'doing' | 'done' | 'cancelled'"); +} +const taskStatusSchema = memoSchema(buildTaskStatusSchema); +export type TaskStatus = ReturnType["infer"]; // GLM (and other Claude-shaped) payloads send in_progress; map it at the // boundary so stored tasks stay on the todo/doing/done/cancelled enum. -const TaskStatusInput = type( - "'todo' | 'doing' | 'done' | 'cancelled' | 'in_progress'", -).pipe((s): TaskStatus => (s === "in_progress" ? "doing" : s)); - -export const TaskSchema = type({ - id: "string>0", - title: "string>0", - status: TaskStatusSchema, -}); -export type Task = typeof TaskSchema.infer; +const taskStatusInput = memoSchema(() => + type("'todo' | 'doing' | 'done' | 'cancelled' | 'in_progress'").pipe( + (s): TaskStatus => (s === "in_progress" ? "doing" : s), + ), +); + +function buildTaskSchema() { + return type({ + id: "string>0", + title: "string>0", + status: taskStatusSchema(), + }); +} +const taskSchema = memoSchema(buildTaskSchema); +export const TaskSchema: ReturnType = + lazyCallable(taskSchema); +export type Task = ReturnType["infer"]; // A task list has active work while any task is still todo or doing. Terminal // tasks (done/cancelled) are resolved, so an all-terminal list is finished. @@ -27,21 +39,23 @@ export function hasActiveTasks(tasks: Task[]): boolean { // `create` overwrites the list; `update` patches by id and may append new // items (unknown id + title). One multi-purpose tool keeps the schema small. -const ManageTasksArgsSchema = type({ - action: "'create' | 'update'", - "tasks?": type({ - id: "string>0", - title: "string>0", - "status?": TaskStatusInput, - }).array(), - "updates?": type({ - id: "string>0", - "title?": "string>0", - "status?": TaskStatusInput, - }).array(), -}); - -export type ManageTasksArgs = typeof ManageTasksArgsSchema.infer; +const manageTasksArgsSchema = memoSchema(() => + type({ + action: "'create' | 'update'", + "tasks?": type({ + id: "string>0", + title: "string>0", + "status?": taskStatusInput(), + }).array(), + "updates?": type({ + id: "string>0", + "title?": "string>0", + "status?": taskStatusInput(), + }).array(), + }), +); + +export type ManageTasksArgs = ReturnType["infer"]; export const manageTasksDefinition: ToolDefinition = { name: "manage_tasks", @@ -109,7 +123,7 @@ export const manageTasksDefinition: ToolDefinition = { // Clone first: arktype's in_progress→doing morph writes onto the input, and // reactor snapshots deep-freeze tool_call.arguments before decide() runs. export function parseManageTasksArgs(rawArgs: unknown): ManageTasksArgs | null { - const result = ManageTasksArgsSchema(cloneManageTasksInput(rawArgs)); + const result = manageTasksArgsSchema()(cloneManageTasksInput(rawArgs)); return result instanceof type.errors ? null : result; } @@ -179,7 +193,7 @@ export type ManageTasksRunner = (rawArgs: Record) => Promise<{ export function createManageTasksRunner(): ManageTasksRunner { let tasks: Task[] = []; return async (rawArgs) => { - const parsed = ManageTasksArgsSchema(cloneManageTasksInput(rawArgs)); + const parsed = manageTasksArgsSchema()(cloneManageTasksInput(rawArgs)); if (parsed instanceof type.errors) { return { content: `Error: manage_tasks arguments invalid: ${parsed.summary}`, diff --git a/src/config/profiles.ts b/src/config/profiles.ts index 04d2614e6..344c85a4f 100644 --- a/src/config/profiles.ts +++ b/src/config/profiles.ts @@ -4,32 +4,37 @@ import { join } from "node:path"; import { type } from "arktype"; import { SETTINGS_DIR_NAME } from "../branding.js"; import { GUIDELINE_SUB_BLOCK_IDS } from "../agent/prompts.js"; +import { memoSchema } from "../schema/lazy.js"; -const ProfileSchema = type({ - "profile?": "string", - "model?": "string", - "systemPromptExtensions?": "string[]", - // Guideline sub-block ids to drop from the chat system prompt (see - // GUIDELINE_SUB_BLOCK_IDS in agent/prompts.ts). Omitted = full guidelines. - // Unknown ids are rejected so a profile typo fails closed at load. - "promptSectionOmit?": type.enumerated(...GUIDELINE_SUB_BLOCK_IDS).array(), - // Per-call inactivity timeout in milliseconds. If the provider yields no - // inference event for this long, the call aborts with "Request timed out". - // Default in the inference harness is 120_000 (2 min); tune higher for - // reasoning models with long silent-thinking stretches. - "inactivityTimeoutMs?": "number >= 1", - // Per-call total wall-clock cap in milliseconds. Starts at fetch. Default - // in the inference harness is 600_000 (10 min). Backstop for streams that - // never terminate. - "totalTimeoutMs?": "number >= 1", - // Per-call cap for the compaction summary call in milliseconds. Default - // 90_000 — well under totalTimeoutMs because compaction runs inline on the - // reactor and a stuck summary call freezes the session. - "summarizerTimeoutMs?": "number >= 1", - "+": "reject", -}); +function buildProfileSchema() { + return type({ + "profile?": "string", + "model?": "string", + "systemPromptExtensions?": "string[]", + // Guideline sub-block ids to drop from the chat system prompt (see + // GUIDELINE_SUB_BLOCK_IDS in agent/prompts.ts). Omitted = full guidelines. + // Unknown ids are rejected so a profile typo fails closed at load. + "promptSectionOmit?": type.enumerated(...GUIDELINE_SUB_BLOCK_IDS).array(), + // Per-call inactivity timeout in milliseconds. If the provider yields no + // inference event for this long, the call aborts with "Request timed out". + // Default in the inference harness is 120_000 (2 min); tune higher for + // reasoning models with long silent-thinking stretches. + "inactivityTimeoutMs?": "number >= 1", + // Per-call total wall-clock cap in milliseconds. Starts at fetch. Default + // in the inference harness is 600_000 (10 min). Backstop for streams that + // never terminate. + "totalTimeoutMs?": "number >= 1", + // Per-call cap for the compaction summary call in milliseconds. Default + // 90_000 — well under totalTimeoutMs because compaction runs inline on the + // reactor and a stuck summary call freezes the session. + "summarizerTimeoutMs?": "number >= 1", + "+": "reject", + }); +} + +const profileSchema = memoSchema(buildProfileSchema); -export type ProfileConfig = typeof ProfileSchema.infer; +export type ProfileConfig = ReturnType["infer"]; export function profilesDir(home: string = homedir()): string { return join(home, SETTINGS_DIR_NAME, "profiles"); @@ -49,7 +54,7 @@ function isENOENT(err: unknown): boolean { } function validateProfileConfig(value: unknown, path: string): ProfileConfig { - const result = ProfileSchema(value); + const result = profileSchema()(value); if (result instanceof type.errors) { throw new Error(`Invalid profile at ${path}: ${result.summary}`); } diff --git a/src/config/provider-inference-options.ts b/src/config/provider-inference-options.ts index fa14a256e..ae0cf4210 100644 --- a/src/config/provider-inference-options.ts +++ b/src/config/provider-inference-options.ts @@ -1,20 +1,51 @@ import { type } from "arktype"; +import { lazyCallable, memoSchema } from "../schema/lazy.js"; + +const contextWindow = memoSchema(() => type("number.integer > 0")); +const maxTokens = memoSchema(() => type("number.integer > 0")); +const temperature = memoSchema(() => type("0 <= number <= 2")); +const topP = memoSchema(() => type("0 <= number <= 1")); + export const ProviderInferenceOptionSchemas = { - contextWindow: type("number.integer > 0"), - maxTokens: type("number.integer > 0"), - temperature: type("0 <= number <= 2"), - topP: type("0 <= number <= 1"), + get contextWindow() { + return contextWindow(); + }, + get maxTokens() { + return maxTokens(); + }, + get temperature() { + return temperature(); + }, + get topP() { + return topP(); + }, }; -export const ProviderInferenceOptionsSchema = type({ - "contextWindow?": ProviderInferenceOptionSchemas.contextWindow, - "maxTokens?": ProviderInferenceOptionSchemas.maxTokens, - "temperature?": ProviderInferenceOptionSchemas.temperature, - "topP?": ProviderInferenceOptionSchemas.topP, -}).narrow( - (options, ctx) => - options.temperature === undefined || - options.topP === undefined || - ctx.mustBe("sampling options with only one of temperature and top p"), +function buildProviderInferenceOptionsSchema() { + return type({ + "contextWindow?": contextWindow(), + "maxTokens?": maxTokens(), + "temperature?": temperature(), + "topP?": topP(), + }).narrow( + (options, ctx) => + options.temperature === undefined || + options.topP === undefined || + ctx.mustBe("sampling options with only one of temperature and top p"), + ); +} + +const providerInferenceOptionsSchema = memoSchema( + buildProviderInferenceOptionsSchema, ); + +export function readProviderInferenceOptionsSchema(): ReturnType< + typeof buildProviderInferenceOptionsSchema +> { + return providerInferenceOptionsSchema(); +} + +export const ProviderInferenceOptionsSchema: ReturnType< + typeof buildProviderInferenceOptionsSchema +> = lazyCallable(providerInferenceOptionsSchema); diff --git a/src/config/settings-lazy.test.ts b/src/config/settings-lazy.test.ts new file mode 100644 index 000000000..93bdf7cbe --- /dev/null +++ b/src/config/settings-lazy.test.ts @@ -0,0 +1,22 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "bun:test"; + +import { schemaMaterializations } from "../schema/lazy.js"; + +test("importing settings does not build schemas; a malformed file fails when read", async () => { + const before = schemaMaterializations(); + const settings = await import("./settings.js"); + expect(schemaMaterializations()).toBe(before); + + const dir = await mkdtemp(join(tmpdir(), "lazy-settings-")); + try { + const path = join(dir, "settings.json"); + await writeFile(path, JSON.stringify({ providers: "nope" })); + await expect(settings.loadSettings(path)).rejects.toThrow(); + expect(schemaMaterializations()).toBeGreaterThan(before); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/src/config/settings.ts b/src/config/settings.ts index f58363b83..9cb5b1294 100644 --- a/src/config/settings.ts +++ b/src/config/settings.ts @@ -6,6 +6,8 @@ import { basename, dirname, join, resolve } from "node:path"; import { type } from "arktype"; +import { memoSchema } from "../schema/lazy.js"; + import { SETTINGS_DIR_NAME } from "../branding.js"; import { EXA_MCP_SERVER_NAME } from "../mcp/exa.js"; import { @@ -16,7 +18,7 @@ import { import type { OtelSettings } from "../perf/otel-config.js"; import type { SessionMode } from "./session-mode.js"; import { resolveDefaultModel } from "./providers.js"; -import { ProviderInferenceOptionsSchema } from "./provider-inference-options.js"; +import { readProviderInferenceOptionsSchema } from "./provider-inference-options.js"; import { OPENCODE_GO_BASE_URL, isOpenCodeGoProvider, @@ -608,132 +610,145 @@ function isENOENT(err: unknown): boolean { // arktype is the single validation vocabulary for config boundaries (see // AGENTS.md). The schemas own structural validation; the imperative helpers // that remain (transport selection, dual array/object MCP format) are -// normalization and cross-field business rules, not type checks. -const ProviderSettingsSchema = type({ - "name?": "string", - baseURL: "string", - "apiKey?": "string", - models: "string[]", - "defaultModel?": "string", - "keyless?": "boolean", - "free?": "boolean", - "bifrostVirtualKey?": "boolean", - "anthropic?": "boolean", - "opencodeGo?": "boolean", - "verified?": "boolean", - "reasoningEfforts?": type - .enumerated(...REASONING_EFFORTS) - .array() +// normalization and cross-field business rules, not type checks. Built on +// first read so importing this module does not construct them. +const providerSettingsSchema = memoSchema(() => + type({ + "name?": "string", + baseURL: "string", + "apiKey?": "string", + models: "string[]", + "defaultModel?": "string", + "keyless?": "boolean", + "free?": "boolean", + "bifrostVirtualKey?": "boolean", + "anthropic?": "boolean", + "opencodeGo?": "boolean", + "verified?": "boolean", + "reasoningEfforts?": type + .enumerated(...REASONING_EFFORTS) + .array() + .narrow( + (levels, ctx) => + levels.length > 0 || ctx.reject("at least one reasoning effort"), + ), + "defaultReasoningEffort?": type.enumerated(...REASONING_EFFORTS), + }) + .and(readProviderInferenceOptionsSchema()) .narrow( - (levels, ctx) => - levels.length > 0 || ctx.reject("at least one reasoning effort"), + (provider, ctx) => + provider.defaultReasoningEffort === undefined || + provider.reasoningEfforts?.includes(provider.defaultReasoningEffort) === + true || + ctx.reject("a default reasoning effort from the enabled levels"), ), - "defaultReasoningEffort?": type.enumerated(...REASONING_EFFORTS), -}) - .and(ProviderInferenceOptionsSchema) - .narrow( - (provider, ctx) => - provider.defaultReasoningEffort === undefined || - provider.reasoningEfforts?.includes(provider.defaultReasoningEffort) === - true || - ctx.reject("a default reasoning effort from the enabled levels"), - ); +); -const ModelRefSchema = type({ - provider: "string", - model: "string", -}); - -const SettingsSchema = type({ - "defaultProvider?": "string", - providers: type({ "[string]": ProviderSettingsSchema }), - // mcpServers accepts both array and object forms, so it is validated by - // normalizeMcpServers rather than expressed structurally here. - "mcpServers?": "unknown", - // Model tiers were removed; older files may still carry this key. Accepted - // and ignored so the file loads, then dropped on next save. - "tiers?": "unknown", - "plugins?": type({ - "[string]": type({ +const modelRefSchema = memoSchema(() => + type({ + provider: "string", + model: "string", + }), +); + +const settingsSchema = memoSchema(() => { + const ProviderSettingsSchema = providerSettingsSchema(); + const ModelRefSchema = modelRefSchema(); + return type({ + "defaultProvider?": "string", + providers: type({ "[string]": ProviderSettingsSchema }), + // mcpServers accepts both array and object forms, so it is validated by + // normalizeMcpServers rather than expressed structurally here. + "mcpServers?": "unknown", + // Model tiers were removed; older files may still carry this key. Accepted + // and ignored so the file loads, then dropped on next save. + "tiers?": "unknown", + "plugins?": type({ + "[string]": type({ + "enabled?": "boolean", + "consented?": "boolean", + "credentials?": type({ "[string]": "string" }), + }), + }), + "pluginPaths?": "string[]", + "hooks?": type({ "[string]": type({ enabled: "boolean" }) }), + "discoverClaudePlugins?": "boolean", + "web?": "string", + "hiddenCommands?": "string[]", + "onboarded?": "boolean", + "lastChangelogVersion?": "string", + // Legacy disk values still load; product resolve ignores them. + "sessionMode?": "'single' | 'orchestrator'", + + "agentModelFallback?": "'active' | 'none'", + "shell?": type({ + "timeoutMs?": "number", + "maxTimeoutMs?": "number", + "sandbox?": type({ + "enabled?": "boolean", + "network?": type({ "allowedHosts?": "string[]" }), + }), + }), + "tools?": type({ + "timeoutMs?": "number", + "maxTimeoutMs?": "number", + "waitForApproval?": "boolean", + }), + "mcp?": type({ + "timeoutMs?": "number", + }), + "telemetry?": type({ "enabled?": "boolean", - "consented?": "boolean", - "credentials?": type({ "[string]": "string" }), + "installationId?": "string", + "noticeShown?": "boolean", }), - }), - "pluginPaths?": "string[]", - "hooks?": type({ "[string]": type({ enabled: "boolean" }) }), - "discoverClaudePlugins?": "boolean", - "web?": "string", - "hiddenCommands?": "string[]", - "onboarded?": "boolean", - "lastChangelogVersion?": "string", - // Legacy disk values still load; product resolve ignores them. - "sessionMode?": "'single' | 'orchestrator'", - - "agentModelFallback?": "'active' | 'none'", - "shell?": type({ - "timeoutMs?": "number", - "maxTimeoutMs?": "number", - "sandbox?": type({ + "otel?": type({ "enabled?": "boolean", - "network?": type({ "allowedHosts?": "string[]" }), + "endpoint?": "string", + "headers?": "Record", + "serviceName?": "string", + "resourceAttributes?": "Record", }), - }), - "tools?": type({ - "timeoutMs?": "number", - "maxTimeoutMs?": "number", - "waitForApproval?": "boolean", - }), - "mcp?": type({ - "timeoutMs?": "number", - }), - "telemetry?": type({ - "enabled?": "boolean", - "installationId?": "string", - "noticeShown?": "boolean", - }), - "otel?": type({ - "enabled?": "boolean", - "endpoint?": "string", - "headers?": "Record", - "serviceName?": "string", - "resourceAttributes?": "Record", - }), - "recentModels?": ModelRefSchema.array(), - "favoriteModels?": ModelRefSchema.array(), - "showPromptCost?": "boolean", - "dangerouslySkipPermissions?": "boolean", - "anthropicCachePrompt?": "boolean", - "theme?": "'auto' | 'light' | 'dark'", + "recentModels?": ModelRefSchema.array(), + "favoriteModels?": ModelRefSchema.array(), + "showPromptCost?": "boolean", + "dangerouslySkipPermissions?": "boolean", + "anthropicCachePrompt?": "boolean", + "theme?": "'auto' | 'light' | 'dark'", + }); }); // Per-entry MCP shape without the name key. "Exactly one transport" is a // cross-field constraint enforced after the structural check. -const McpEntrySchema = type({ - "enabled?": "boolean", - "type?": "'stdio' | 'http'", - "command?": "string", - "args?": "string[]", - "env?": "Record", - "url?": "string", -}); - -const LocalSettingsSchema = type({ - "provider?": "string", - "model?": "string", - "reasoningEffort?": type.enumerated(...REASONING_EFFORTS), - "mcpServers?": "unknown", - // Legacy disk values still load; product resolve ignores them. - "sessionMode?": "'single' | 'orchestrator'", - - "env?": "Record", - "pinnedTools?": "string[]", - // Reject any other key so local settings can never smuggle credentials. - "+": "reject", -}); +const mcpEntrySchema = memoSchema(() => + type({ + "enabled?": "boolean", + "type?": "'stdio' | 'http'", + "command?": "string", + "args?": "string[]", + "env?": "Record", + "url?": "string", + }), +); + +const localSettingsSchema = memoSchema(() => + type({ + "provider?": "string", + "model?": "string", + "reasoningEffort?": type.enumerated(...REASONING_EFFORTS), + "mcpServers?": "unknown", + // Legacy disk values still load; product resolve ignores them. + "sessionMode?": "'single' | 'orchestrator'", + + "env?": "Record", + "pinnedTools?": "string[]", + // Reject any other key so local settings can never smuggle credentials. + "+": "reject", + }), +); export function isSettings(value: unknown): value is Settings { - if (!SettingsSchema.allows(value)) return false; + if (!settingsSchema().allows(value)) return false; const s = value as Record; if ( s.mcpServers !== undefined && @@ -756,7 +771,7 @@ function isMCPServerConfigEntry( name: string, value: unknown, ): value is Omit { - if (!McpEntrySchema.allows(value)) return false; + if (!mcpEntrySchema().allows(value)) return false; const s = value as Record; if (!hasMcpTransport(s)) { return name === EXA_MCP_SERVER_NAME && typeof s.enabled === "boolean"; @@ -831,7 +846,7 @@ export function normalizeMcpServers( // Local settings are selection-only for provider/model (no credentials); // mcpServers is permitted because MCP configs live in the repo. export function isLocalSettings(value: unknown): value is LocalSettings { - if (!LocalSettingsSchema.allows(value)) return false; + if (!localSettingsSchema().allows(value)) return false; const s = value as Record; if ( s.mcpServers !== undefined && @@ -955,16 +970,18 @@ export function healOpenCodeGoProviders(settings: Settings): string[] { return healed; } -const ClobberedLocalSelectionSchema = type({ - provider: "string>0", - model: "string>0", - "+": "reject", -}); +const clobberedLocalSelectionSchema = memoSchema(() => + type({ + provider: "string>0", + model: "string>0", + "+": "reject", + }), +); function isClobberedLocalSelection( value: unknown, ): value is { provider: string; model: string } { - return ClobberedLocalSelectionSchema.allows(value); + return clobberedLocalSelectionSchema().allows(value); } function recoverClobberedOAuthSelection( diff --git a/src/index.ts b/src/index.ts index 0195cc027..b1ab5bf4e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ import { } from "./config/settings.js"; import { flushFileLogSinkSync, installFileLogSink } from "./logging/sink.js"; import { flushPerfToOtel } from "./perf/index.js"; +import { endBootSpans, startBootSpans } from "./perf/boot-spans.js"; import { createTelemetry, telemetryDisabledByEnv } from "./telemetry/index.js"; import { getTelemetry, setTelemetry } from "./telemetry/singleton.js"; import { @@ -36,6 +37,7 @@ export async function mainWithRunners( argv: readonly string[], runners: Runners, ): Promise { + startBootSpans(); // Must run first: @intx/log installs a console sink on import, and // loadConfig can log (e.g. healed settings). This replaces that default so // nothing — including vendored loggers — reaches the terminal the TUI owns. @@ -113,6 +115,9 @@ export async function mainWithRunners( exitCode = await runners.runTUI(config); } + // Mount already closed these. Exec and onboarding never mount, so close + // them here before export. A second call does not end the spans again. + endBootSpans(); // Opt-in OTEL export of the PerfSpan tree; no-op when OTEL is disabled. const otelSettings = config.configured ? config.settings : null; await flushPerfToOtel(otelSettings); diff --git a/src/perf/boot-spans.test.ts b/src/perf/boot-spans.test.ts new file mode 100644 index 000000000..974e6ece1 --- /dev/null +++ b/src/perf/boot-spans.test.ts @@ -0,0 +1,145 @@ +import { EventEmitter } from "node:events"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, test } from "bun:test"; + +import { main, mainWithRunners } from "../index.js"; +import { + resetPricingMetadataRefreshForTests, + schedulePricingMetadataRefresh, +} from "../cost/pricing-metadata.js"; +import { clear, snapshot, type PerfSpan } from "./index.js"; +import { endBootSpans, startBootSpans } from "./boot-spans.js"; +import { createHarness } from "../tui/harness.js"; +import { mountRunnerHost } from "../tui/runner/host.js"; + +function bootPair(spans: readonly PerfSpan[]): PerfSpan[] { + return spans.filter( + (span) => span.name === "boot" || span.name === "first-paint", + ); +} + +test("boot starts boot and first-paint spans before config exits", async () => { + clear(); + endBootSpans(); + await expect(main(["--help"])).rejects.toThrow(); + const open = snapshot().filter((span) => span.endNs === undefined); + expect(open.some((span) => span.name === "boot")).toBe(true); + expect(open.some((span) => span.name === "first-paint")).toBe(true); + endBootSpans(); + clear(); +}); + +test("mount ends the boot and first-paint spans", async () => { + clear(); + endBootSpans(); + startBootSpans(); + const harness = await createHarness({ width: 80, height: 24 }); + const host = await mountRunnerHost({ + title: "test", + eventEmitter: new EventEmitter(), + send: () => undefined, + interrupt: () => undefined, + deliver: () => undefined, + providers: {}, + onModelSelect: () => undefined, + commands: [], + onCommand: () => undefined, + chrome: () => ({ agents: [] }), + subscribeChrome: () => () => undefined, + subAgentSessions: () => [], + createRenderer: async () => harness.renderer, + }); + try { + const done = snapshot().filter((span) => span.endNs !== undefined); + expect(done.some((span) => span.name === "boot")).toBe(true); + expect(done.some((span) => span.name === "first-paint")).toBe(true); + } finally { + host.dispose(); + harness.destroy(); + clear(); + } +}); + +let sandbox: string | undefined; + +afterEach(() => { + if (sandbox !== undefined) { + rmSync(sandbox, { recursive: true, force: true }); + sandbox = undefined; + } +}); + +function sandboxArgs( + subcommand: readonly string[], + rest: readonly string[] = [], +): string[] { + const root = mkdtempSync(join(tmpdir(), "boot-span-")); + sandbox = root; + const settingsDir = join(root, "home", ".corbits"); + mkdirSync(settingsDir, { recursive: true }); + writeFileSync( + join(settingsDir, "settings.json"), + JSON.stringify({ + providers: { + "test-provider": { + baseURL: "http://localhost:1234", + apiKey: "test-key", + models: ["test-model"], + defaultModel: "test-model", + }, + }, + defaultProvider: "test-provider", + }), + ); + mkdirSync(join(root, "project"), { recursive: true }); + resetPricingMetadataRefreshForTests(); + schedulePricingMetadataRefresh({ + cachePath: join(settingsDir, "cache", "models-pricing.json"), + fetchImpl: () => Promise.reject(new Error("network disabled in tests")), + }); + return [ + ...subcommand, + "--cwd", + join(root, "project"), + "--config", + join(settingsDir, "settings.json"), + ...rest, + ]; +} + +test("exec closes boot spans before returning, without a TUI mount", async () => { + clear(); + endBootSpans(); + const code = await mainWithRunners(sandboxArgs(["exec"], ["say hello"]), { + runTUI: async () => 0, + runExec: async () => 0, + runOnboarding: async () => 0, + }); + expect(code).toBe(0); + const pair = bootPair(snapshot()); + expect(pair).toHaveLength(2); + expect(pair.every((span) => span.endNs !== undefined)).toBe(true); + clear(); +}); + +test("a TUI that already closed boot spans is not ended twice", async () => { + clear(); + endBootSpans(); + let closedAt: bigint | undefined; + const code = await mainWithRunners(sandboxArgs([]), { + runTUI: async () => { + endBootSpans(); + closedAt = snapshot().find((span) => span.name === "boot")?.endNs; + return 0; + }, + runExec: async () => 0, + runOnboarding: async () => 0, + }); + expect(code).toBe(0); + const boot = snapshot().find((span) => span.name === "boot"); + expect(boot?.endNs).toBe(closedAt); + expect(bootPair(snapshot())).toHaveLength(2); + clear(); +}); diff --git a/src/perf/boot-spans.ts b/src/perf/boot-spans.ts new file mode 100644 index 000000000..9c77c0fb5 --- /dev/null +++ b/src/perf/boot-spans.ts @@ -0,0 +1,21 @@ +import { end, start } from "./index.js"; + +let bootId: string | undefined; +let firstPaintId: string | undefined; + +export function startBootSpans(): void { + if (bootId !== undefined) return; + bootId = start("boot"); + firstPaintId = start("first-paint"); +} + +export function endBootSpans(): void { + if (bootId !== undefined) { + end(bootId); + bootId = undefined; + } + if (firstPaintId !== undefined) { + end(firstPaintId); + firstPaintId = undefined; + } +} diff --git a/src/perf/index.ts b/src/perf/index.ts index 08d6dfdc6..0b5277505 100644 --- a/src/perf/index.ts +++ b/src/perf/index.ts @@ -52,6 +52,8 @@ export const SPAN_NAMES = [ "adapter.request_build", "adapter.first_byte", "adapter.transport", + "boot", + "first-paint", ] as const; export type SpanName = (typeof SPAN_NAMES)[number]; diff --git a/src/schema/lazy.ts b/src/schema/lazy.ts new file mode 100644 index 000000000..e946aa03f --- /dev/null +++ b/src/schema/lazy.ts @@ -0,0 +1,50 @@ +let materializations = 0; + +/** How many memoized schemas have been built in this process. */ +export function schemaMaterializations(): number { + return materializations; +} + +export function memoSchema(build: () => T): () => T { + let cached: T | undefined; + let ready = false; + return () => { + if (!ready) { + cached = build(); + ready = true; + materializations += 1; + } + return cached as T; + }; +} + +/** + * Callable stand-in that forwards to the memoized schema on first use. + * Composition that needs a real arktype instance should call the getter + * instead of passing this object into `type()`. + */ +export function lazyCallable(load: () => T): T { + function lazySchema(this: unknown, ...args: unknown[]) { + const schema = load() as (...args: unknown[]) => unknown; + return Reflect.apply(schema, this, args); + } + return new Proxy(lazySchema as unknown as T, { + apply(_target, thisArg, argArray) { + const schema = load() as (...args: unknown[]) => unknown; + return Reflect.apply(schema, thisArg, argArray); + }, + get(_target, prop) { + // Awaiting the stand-in must not materialize it. + if (prop === "then") return undefined; + const schema = load() as Record; + const value = Reflect.get(schema, prop, schema); + if (typeof value === "function") { + return (value as (...args: unknown[]) => unknown).bind(schema); + } + return value; + }, + has(_target, prop) { + return prop in (load() as object); + }, + }); +} diff --git a/src/session/isogit-boot.test.ts b/src/session/isogit-boot.test.ts new file mode 100644 index 000000000..ddb4296d9 --- /dev/null +++ b/src/session/isogit-boot.test.ts @@ -0,0 +1,51 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "bun:test"; + +import { withMockedModuleDuring } from "../../testkit/mock-module.js"; + +const isogitNode = import.meta.resolve("@intx/storage-isogit/node"); + +test("importing the session store does not construct isogit storage", async () => { + let loads = 0; + await withMockedModuleDuring( + isogitNode, + () => { + loads += 1; + return { + createIsogitStore: async () => { + throw new Error("session open reached isogit"); + }, + }; + }, + async () => { + const before = loads; + await import("./optimized-context-store.js"); + expect(loads).toBe(before); + }, + ); +}); + +test("opening a session constructs isogit storage", async () => { + const dir = await mkdtemp(join(tmpdir(), "isogit-boot-")); + try { + await withMockedModuleDuring( + isogitNode, + () => ({ + createIsogitStore: async () => { + throw new Error("session open reached isogit"); + }, + }), + async () => { + const { createSessionStores } = + await import("./optimized-context-store.js"); + await expect(createSessionStores(dir)).rejects.toThrow( + "session open reached isogit", + ); + }, + ); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/src/session/isogit-node.ts b/src/session/isogit-node.ts new file mode 100644 index 000000000..852ba79a2 --- /dev/null +++ b/src/session/isogit-node.ts @@ -0,0 +1,8 @@ +export type IsogitNodeModule = typeof import("@intx/storage-isogit/node"); + +let loading: Promise | undefined; + +export function loadIsogitNode(): Promise { + loading ??= import("@intx/storage-isogit/node"); + return loading; +} diff --git a/src/session/optimized-context-store.ts b/src/session/optimized-context-store.ts index 3fc3aefd9..4d1cde38b 100644 --- a/src/session/optimized-context-store.ts +++ b/src/session/optimized-context-store.ts @@ -2,10 +2,8 @@ import fs from "node:fs"; import path from "node:path"; import { type } from "arktype"; import git from "isomorphic-git"; -import { - createIsogitStore, - type CommitSigner, -} from "@intx/storage-isogit/node"; +import type { CommitSigner } from "@intx/storage-isogit/node"; +import { loadIsogitNode } from "./isogit-node.js"; import { ContentBlock, ConnectorThreadState, @@ -1478,6 +1476,7 @@ export async function createSessionStores( opts?: SessionStoreOptions, ): Promise { const signer = opts?.signer ?? (await loadOrCreateCommitSigner(dir)); + const { createIsogitStore } = await loadIsogitNode(); const base = await createIsogitStore(dir, signer); const pendingBlobFilepaths = new Set(); const pendingSegmentPaths = new Set(); diff --git a/src/session/state.ts b/src/session/state.ts index 678a7b7ed..05f0d0c79 100644 --- a/src/session/state.ts +++ b/src/session/state.ts @@ -12,6 +12,8 @@ import { basename, dirname, join } from "node:path"; import { type } from "arktype"; import { getLogger } from "@intx/log"; +import { memoSchema } from "../schema/lazy.js"; + import { sessionDir } from "./index.js"; import { clearActiveRun, getTestWriteGate, isCrashed } from "./active-run.js"; import { @@ -24,37 +26,45 @@ import { LOG_NAMESPACE_ROOT } from "../branding.js"; const log = getLogger([LOG_NAMESPACE_ROOT, "session", "state"]); -const ConnectedMcpServerSchema = type({ - name: "string", - toolCount: "number", -}); - -export type ConnectedMcpServer = typeof ConnectedMcpServerSchema.infer; - -const RunStateSchema = type({ - status: - "'running' | 'done' | 'failed' | 'cancelled' | 'crashed' | 'interrupted'", - turnsUsed: "number", - task: "string", - startedAt: "number", - "finishedAt?": "number", - "error?": "string", - // "provider:model" in use when the record was written; absent for old - // records or renames without prior state. - "model?": "string", - // MCP servers connected this session, with their tool counts; empty until - // the first server finishes connecting. - "mcpServers?": ConnectedMcpServerSchema.array(), - // Tools promoted on execute; a resume re-activates them before the first - // post-resume infer. Omitted by fold prune so a crash cannot restore - // dropped schemas. - "activatedTools?": "string[]", - // Last Anthropic-protocol infer time; a later process folds when this is - // TTL-old. Absent for other providers or records written before this field. - "lastCacheWriteAt?": "number", -}); - -export type RunState = typeof RunStateSchema.infer; +function buildConnectedMcpServerSchema() { + return type({ + name: "string", + toolCount: "number", + }); +} +const connectedMcpServerSchema = memoSchema(buildConnectedMcpServerSchema); + +export type ConnectedMcpServer = ReturnType< + typeof buildConnectedMcpServerSchema +>["infer"]; + +function buildRunStateSchema() { + return type({ + status: + "'running' | 'done' | 'failed' | 'cancelled' | 'crashed' | 'interrupted'", + turnsUsed: "number", + task: "string", + startedAt: "number", + "finishedAt?": "number", + "error?": "string", + // "provider:model" in use when the record was written; absent for old + // records or renames without prior state. + "model?": "string", + // MCP servers connected this session, with their tool counts; empty until + // the first server finishes connecting. + "mcpServers?": connectedMcpServerSchema().array(), + // Tools promoted on execute; a resume re-activates them before the first + // post-resume infer. Omitted by fold prune so a crash cannot restore + // dropped schemas. + "activatedTools?": "string[]", + // Last Anthropic-protocol infer time; a later process folds when this is + // TTL-old. Absent for other providers or records written before this field. + "lastCacheWriteAt?": "number", + }); +} +const runStateSchema = memoSchema(buildRunStateSchema); + +export type RunState = ReturnType["infer"]; function statePath(cwd: string, sessionId: string, home?: string): string { return join(sessionDir(cwd, sessionId, home), "run.json"); @@ -180,7 +190,7 @@ type ParseRunStateResult = // Tagged so a valid RunState.error string cannot look like a parse failure. function parseRunState(data: unknown): ParseRunStateResult { - const result = RunStateSchema(data); + const result = runStateSchema()(data); return result instanceof type.errors ? { ok: false, reason: result.summary } : { ok: true, state: result }; diff --git a/src/tui/runner/host.ts b/src/tui/runner/host.ts index 4a603cc55..c7b74ad4c 100644 --- a/src/tui/runner/host.ts +++ b/src/tui/runner/host.ts @@ -61,6 +61,7 @@ import type { QueueKind } from "../delivery-queue.js"; import type { ShellOutputFeed } from "../../session/shell-output-feed.js"; import { openModelPickerOverlay } from "../overlays.js"; import type { ReconnectScope } from "../connect-scope.js"; +import { endBootSpans } from "../../perf/boot-spans.js"; import { reconnectRecoveryItemId, reconnectRecoveryItemLabel, @@ -387,6 +388,7 @@ export async function mountRunnerHost( ? { telemetryNotice: deps.telemetryNotice } : {}), }); + endBootSpans(); const pushChrome = (): void => { host.setChrome(chromeFromSession(deps.chrome()));