diff --git a/README.md b/README.md index c4c7950d5..e33683af2 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Use `./diffex-test.sh` to run the Diffex-branded coding agent directly from this | [`packages/telemetry`](packages/telemetry) | Vendor-neutral telemetry contracts and typed schemas. | | [`packages/ai`](packages/ai) | Unified LLM API with provider and model discovery. | | [`packages/agent`](packages/agent) | Agent runtime, tool calling, state management, and attachments. | +| [`packages/harness-state`](packages/harness-state) | Immutable, content-addressed harness revisions and global selection. | | [`packages/protocol`](packages/protocol) | Transport-neutral CBOR protocol for remote sessions. | | [`packages/client`](packages/client) | Transport-neutral client for remote sessions. | | [`packages/server`](packages/server) | Experimental remote session server. | diff --git a/package-lock.json b/package-lock.json index e5e7f408b..c642cf054 100644 --- a/package-lock.json +++ b/package-lock.json @@ -719,10 +719,6 @@ "node": ">=20" } }, - "node_modules/@diffexai/diffex-harness-state": { - "resolved": "packages/harness-state", - "link": true - }, "node_modules/@earendil-works/diffex-agent-core": { "resolved": "packages/agent", "link": true @@ -743,6 +739,10 @@ "resolved": "packages/evals", "link": true }, + "node_modules/@earendil-works/diffex-harness-state": { + "resolved": "packages/harness-state", + "link": true + }, "node_modules/@earendil-works/diffex-protocol": { "resolved": "packages/protocol", "link": true @@ -5547,6 +5547,7 @@ "@earendil-works/diffex-agent-core": "^0.2.0", "@earendil-works/diffex-ai": "^0.2.0", "@earendil-works/diffex-client": "^0.2.0", + "@earendil-works/diffex-harness-state": "^0.2.0", "@earendil-works/diffex-protocol": "^0.2.0", "@earendil-works/diffex-tui": "^0.2.0", "@silvia-odwyer/photon-node": "0.3.4", @@ -5715,12 +5716,15 @@ "license": "MIT" }, "packages/harness-state": { - "name": "@diffexai/diffex-harness-state", + "name": "@earendil-works/diffex-harness-state", "version": "0.2.0", + "license": "MIT", "dependencies": { + "proper-lockfile": "4.1.2", "typebox": "1.3.7" }, "devDependencies": { + "@types/proper-lockfile": "4.1.4", "shx": "0.4.0", "vitest": "4.1.9" }, diff --git a/packages/ai/test/baseten-models.test.ts b/packages/ai/test/baseten-models.test.ts index 60e8cf745..698ec382e 100644 --- a/packages/ai/test/baseten-models.test.ts +++ b/packages/ai/test/baseten-models.test.ts @@ -31,7 +31,7 @@ describe("Baseten models", () => { xhigh: null, max: "max", }, - input: ["text"], + input: expect.arrayContaining(["text"]), contextWindow: 1048576, maxTokens: 262144, cost: { diff --git a/packages/coding-agent/install-lock/package-lock.json b/packages/coding-agent/install-lock/package-lock.json index 6c90c1ae4..5044a2800 100644 --- a/packages/coding-agent/install-lock/package-lock.json +++ b/packages/coding-agent/install-lock/package-lock.json @@ -509,6 +509,7 @@ "@earendil-works/diffex-agent-core": "^0.2.0", "@earendil-works/diffex-ai": "^0.2.0", "@earendil-works/diffex-client": "^0.2.0", + "@earendil-works/diffex-harness-state": "^0.2.0", "@earendil-works/diffex-protocol": "^0.2.0", "@earendil-works/diffex-tui": "^0.2.0", "@silvia-odwyer/photon-node": "0.3.4", @@ -538,6 +539,18 @@ "node": ">=22.19.0" } }, + "node_modules/@earendil-works/diffex-harness-state": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@earendil-works/diffex-harness-state/-/diffex-harness-state-0.2.0.tgz", + "license": "MIT", + "dependencies": { + "proper-lockfile": "4.1.2", + "typebox": "1.3.7" + }, + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/@earendil-works/diffex-protocol": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@earendil-works/diffex-protocol/-/diffex-protocol-0.2.0.tgz", diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index 0375b9316..67565d914 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -12,6 +12,7 @@ "@earendil-works/diffex-agent-core": "^0.2.0", "@earendil-works/diffex-ai": "^0.2.0", "@earendil-works/diffex-client": "^0.2.0", + "@earendil-works/diffex-harness-state": "^0.2.0", "@earendil-works/diffex-protocol": "^0.2.0", "@earendil-works/diffex-tui": "^0.2.0", "@silvia-odwyer/photon-node": "0.3.4", @@ -528,6 +529,18 @@ "node": ">=22.19.0" } }, + "node_modules/@earendil-works/diffex-harness-state": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@earendil-works/diffex-harness-state/-/diffex-harness-state-0.2.0.tgz", + "license": "MIT", + "dependencies": { + "proper-lockfile": "4.1.2", + "typebox": "1.3.7" + }, + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/@earendil-works/diffex-protocol": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@earendil-works/diffex-protocol/-/diffex-protocol-0.2.0.tgz", diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 9db1092f3..7a842781a 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -35,7 +35,7 @@ "scripts": { "clean": "shx rm -rf dist", "build": "tsgo -p tsconfig.build.json && shx chmod +x dist/cli.js dist/rpc-entry.js && npm run copy-assets", - "build:binary": "npm --prefix ../tui run build && npm --prefix ../telemetry run build && npm --prefix ../ai run build && npm --prefix ../agent run build && npm --prefix ../protocol run build && npm --prefix ../client run build && npm run build && bun build --compile --no-compile-autoload-bunfig ./dist/bun/cli.js ./src/utils/image-resize-worker.ts --outfile dist/pi && npm run copy-binary-assets", + "build:binary": "npm --prefix ../harness-state run build && npm --prefix ../tui run build && npm --prefix ../telemetry run build && npm --prefix ../ai run build && npm --prefix ../agent run build && npm --prefix ../protocol run build && npm --prefix ../client run build && npm run build && bun build --compile --no-compile-autoload-bunfig ./dist/bun/cli.js ./src/utils/image-resize-worker.ts --outfile dist/pi && npm run copy-binary-assets", "copy-assets": "shx mkdir -p dist/modes/interactive/theme && shx cp src/modes/interactive/theme/*.json dist/modes/interactive/theme/ && shx mkdir -p dist/modes/interactive/assets && shx cp src/modes/interactive/assets/*.png dist/modes/interactive/assets/ && shx mkdir -p dist/core/export-html/vendor && shx cp src/core/export-html/template.html src/core/export-html/template.css src/core/export-html/template.js dist/core/export-html/ && shx cp src/core/export-html/vendor/*.js dist/core/export-html/vendor/", "copy-binary-assets": "shx cp package.json dist/ && shx cp README.md dist/ && shx cp CHANGELOG.md dist/ && shx mkdir -p dist/theme && shx cp src/modes/interactive/theme/*.json dist/theme/ && shx mkdir -p dist/assets && shx cp src/modes/interactive/assets/*.png dist/assets/ && shx mkdir -p dist/export-html/vendor && shx cp src/core/export-html/template.html dist/export-html/ && shx cp src/core/export-html/vendor/*.js dist/export-html/vendor/ && shx cp -r docs dist/ && shx cp -r examples dist/ && shx cp ../../node_modules/@silvia-odwyer/photon-node/photon_rs_bg.wasm dist/", "test": "vitest --run", @@ -46,6 +46,7 @@ "@earendil-works/diffex-agent-core": "^0.2.0", "@earendil-works/diffex-ai": "^0.2.0", "@earendil-works/diffex-client": "^0.2.0", + "@earendil-works/diffex-harness-state": "^0.2.0", "@earendil-works/diffex-protocol": "^0.2.0", "@earendil-works/diffex-tui": "^0.2.0", "@silvia-odwyer/photon-node": "0.3.4", diff --git a/packages/coding-agent/src/core/agent-session-services.ts b/packages/coding-agent/src/core/agent-session-services.ts index 22a7882c2..5876eaefa 100644 --- a/packages/coding-agent/src/core/agent-session-services.ts +++ b/packages/coding-agent/src/core/agent-session-services.ts @@ -1,9 +1,11 @@ import { join } from "node:path"; import type { ThinkingLevel } from "@earendil-works/diffex-agent-core"; import type { Model } from "@earendil-works/diffex-ai"; +import type { HarnessStateStore, HarnessVersionBundle } from "@earendil-works/diffex-harness-state"; import { getAgentDir } from "../config.ts"; import { resolvePath } from "../utils/paths.ts"; import type { SessionStartEvent, ToolDefinition } from "./extensions/index.ts"; +import { loadGlobalHarnessState } from "./harness-state-runtime.ts"; import { ModelRuntime } from "./model-runtime.ts"; import { DefaultResourceLoader, @@ -77,6 +79,8 @@ export interface AgentSessionServices { modelRuntime: ModelRuntime; settingsManager: SettingsManager; resourceLoader: ResourceLoader; + harnessStateStore: HarnessStateStore; + harnessVersionBundle: HarnessVersionBundle | undefined; diagnostics: AgentSessionRuntimeDiagnostic[]; } @@ -146,6 +150,7 @@ export async function createAgentSessionServices( signal: options.modelRuntimeSignal, })); const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir); + const harnessState = await loadGlobalHarnessState(agentDir); const resourceLoader = new DefaultResourceLoader({ ...(options.resourceLoaderOptions ?? {}), cwd, @@ -155,6 +160,9 @@ export async function createAgentSessionServices( await resourceLoader.reload(options.resourceLoaderReloadOptions); const diagnostics: AgentSessionRuntimeDiagnostic[] = []; + if (harnessState.warning) { + diagnostics.push({ type: "warning", message: harnessState.warning }); + } const extensionsResult = resourceLoader.getExtensions(); let registeredProvider = false; for (const { name, config, extensionPath } of extensionsResult.runtime.pendingProviderRegistrations) { @@ -194,6 +202,8 @@ export async function createAgentSessionServices( modelRuntime, settingsManager, resourceLoader, + harnessStateStore: harnessState.store, + harnessVersionBundle: harnessState.bundle, diagnostics, }; } @@ -214,6 +224,8 @@ export async function createAgentSessionFromServices( modelRuntime: options.services.modelRuntime, settingsManager: options.services.settingsManager, resourceLoader: options.services.resourceLoader, + harnessStateStore: options.services.harnessStateStore, + harnessVersionBundle: options.services.harnessVersionBundle, sessionManager: options.sessionManager, model: options.model, thinkingLevel: options.thinkingLevel, diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index a498b85ec..984843d8b 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -46,6 +46,12 @@ import { resetApiProviders, streamSimple, } from "@earendil-works/diffex-ai/compat"; +import type { + HarnessStateStore, + HarnessVersion, + HarnessVersionBundle, + HarnessVersionList, +} from "@earendil-works/diffex-harness-state"; import { getThemeByName, theme } from "../modes/interactive/theme/theme.ts"; import { stripFrontmatter } from "../utils/frontmatter.ts"; import { resolvePath } from "../utils/paths.ts"; @@ -240,6 +246,10 @@ export interface AgentSessionConfig { sessionStartEvent?: SessionStartEvent; /** Lifecycle-owned child-agent controller. */ subagentController?: SubagentController; + /** Global immutable harness revision store. */ + harnessStateStore?: HarnessStateStore; + /** Harness revision activated for this session. */ + harnessVersionBundle?: HarnessVersionBundle; } export interface ExtensionBindings { @@ -393,6 +403,9 @@ export class AgentSession { private _baseSystemPromptOptions!: BuildSystemPromptOptions; private _systemPromptOverride?: string; private readonly _subagentController: SubagentController | undefined; + private readonly _harnessStateStore: HarnessStateStore | undefined; + private _harnessVersionBundle: HarnessVersionBundle | undefined; + private _harnessSwitchInProgress = false; private _subagentWaitAbortController: AbortController | undefined; private _agentRunAborted = false; private _disposePromise: Promise | undefined; @@ -413,6 +426,8 @@ export class AgentSession { this._baseToolsOverride = config.baseToolsOverride; this._sessionStartEvent = config.sessionStartEvent ?? { type: "session_start", reason: "startup" }; this._subagentController = config.subagentController; + this._harnessStateStore = config.harnessStateStore; + this._harnessVersionBundle = config.harnessVersionBundle; this._armInitialCurrentPlanRestoration(); @@ -1071,6 +1086,11 @@ export class AgentSession { return this.agent.state.systemPrompt; } + /** Globally selected harness revision activated for this session. */ + get activeHarnessVersion(): HarnessVersion | undefined { + return this._harnessVersionBundle?.manifest.version; + } + /** Current retry attempt (0 if not retrying) */ get retryAttempt(): number { return this._retryAttempt; @@ -1106,6 +1126,62 @@ export class AgentSession { return this._subagentController?.getSummaries() ?? Object.freeze([]); } + /** List valid harness revisions and diagnostics for unavailable revision paths. */ + async listHarnessVersions(): Promise { + if (!this._harnessStateStore) { + return { versions: Object.freeze([]), diagnostics: Object.freeze([]), currentVersion: null }; + } + return this._harnessStateStore.listVersions(); + } + + /** + * Atomically select and activate a complete harness revision for future requests. + * Conversation history and unrelated resources remain unchanged. + */ + async switchHarnessVersion(version: HarnessVersion): Promise { + if (version === this.activeHarnessVersion) { + return; + } + if (!this._harnessStateStore) { + throw new Error("Harness version switching is unavailable for this session."); + } + if (this._harnessSwitchInProgress) { + throw new Error("Cannot switch harness versions while another switch is in progress."); + } + if (this.isStreaming) { + throw new Error("Cannot switch harness versions while a response is streaming."); + } + if (this.isCompacting) { + throw new Error("Cannot switch harness versions while compaction is in progress."); + } + if (this.isBashRunning) { + throw new Error("Cannot switch harness versions while a bash command is running."); + } + + this._harnessSwitchInProgress = true; + try { + const listing = await this._harnessStateStore.listVersions(); + const target = listing.versions.find((bundle) => bundle.manifest.version === version); + if (!target) { + const diagnostic = listing.diagnostics.find((entry) => entry.version === version); + throw new Error( + diagnostic + ? `Harness version ${version} is unavailable: ${diagnostic.message}` + : `Harness version ${version} was not found.`, + ); + } + + await this._subagentController?.reset(); + const selected = await this._harnessStateStore.selectVersion(version); + this._harnessVersionBundle = selected; + this._baseSystemPrompt = this._rebuildSystemPrompt(this.getActiveToolNames()); + this._systemPromptOverride = undefined; + this.agent.state.systemPrompt = this._baseSystemPrompt; + } finally { + this._harnessSwitchInProgress = false; + } + } + /** * Set active tools by name. * Only tools in the registry can be enabled. Unknown tool names are ignored. @@ -1239,6 +1315,12 @@ export class AgentSession { selectedTools: validToolNames, toolSnippets, promptGuidelines, + evolutionMemory: this._harnessVersionBundle + ? { + version: this._harnessVersionBundle.manifest.version, + memory: this._harnessVersionBundle.state.memory, + } + : undefined, }; return buildSystemPrompt(this._baseSystemPromptOptions); } @@ -1406,6 +1488,9 @@ export class AgentSession { * @throws Error if no model selected or no API key available (when not streaming) */ async prompt(text: string, options?: PromptOptions): Promise { + if (this._harnessSwitchInProgress) { + throw new Error("Cannot submit a prompt while a harness version switch is in progress."); + } const expandPromptTemplates = options?.expandPromptTemplates ?? true; const preflightResult = options?.preflightResult; let messages: AgentMessage[] | undefined; diff --git a/packages/coding-agent/src/core/harness-state-runtime.ts b/packages/coding-agent/src/core/harness-state-runtime.ts new file mode 100644 index 000000000..d9875223a --- /dev/null +++ b/packages/coding-agent/src/core/harness-state-runtime.ts @@ -0,0 +1,26 @@ +import { HarnessStateStore, type HarnessVersionBundle } from "@earendil-works/diffex-harness-state"; + +export interface LoadedHarnessState { + store: HarnessStateStore; + bundle: HarnessVersionBundle | undefined; + warning: string | undefined; +} + +/** + * Initialize the global harness store and load its selected revision. + * Invalid selected state is deliberately left untouched so `/version` can repair it. + */ +export async function loadGlobalHarnessState(agentDir: string): Promise { + const store = new HarnessStateStore(agentDir); + try { + await store.initialize(); + return { store, bundle: await store.loadCurrent(), warning: undefined }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + store, + bundle: undefined, + warning: `Harness state could not be activated: ${message}. Continuing with the built-in harness.`, + }; + } +} diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index a72899ef3..07593b147 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -1,12 +1,14 @@ import { join } from "node:path"; import { Agent, type AgentMessage, setDefaultStreamFn, type ThinkingLevel } from "@earendil-works/diffex-agent-core"; import { clampThinkingLevel, type Message, type Model, streamSimple } from "@earendil-works/diffex-ai/compat"; +import type { HarnessStateStore, HarnessVersionBundle } from "@earendil-works/diffex-harness-state"; import { getAgentDir } from "../config.ts"; import { resolvePath } from "../utils/paths.ts"; import { AgentSession } from "./agent-session.ts"; import { formatNoModelsAvailableMessage } from "./auth-guidance.ts"; import { DEFAULT_THINKING_LEVEL } from "./defaults.ts"; import type { ExtensionRunner, LoadExtensionsResult, SessionStartEvent, ToolDefinition } from "./extensions/index.ts"; +import { loadGlobalHarnessState } from "./harness-state-runtime.ts"; import { convertToLlm } from "./messages.ts"; import { findInitialModel } from "./model-resolver.ts"; import { ModelRuntime } from "./model-runtime.ts"; @@ -96,6 +98,10 @@ export interface CreateAgentSessionOptions { settingsManager?: SettingsManager; /** Session start event metadata for extension runtime startup. */ sessionStartEvent?: SessionStartEvent; + /** Preloaded global harness store. Primarily used by runtime service composition. */ + harnessStateStore?: HarnessStateStore; + /** Activated harness revision from the preloaded store. */ + harnessVersionBundle?: HarnessVersionBundle; } /** Result from createAgentSession */ @@ -106,6 +112,8 @@ export interface CreateAgentSessionResult { extensionsResult: LoadExtensionsResult; /** Warning if session was restored with a different model than saved */ modelFallbackMessage?: string; + /** Non-fatal warning when optional harness state could not be activated. */ + harnessStateWarning?: string; } // Re-exports @@ -229,6 +237,9 @@ function createRootSubagentController(maxConcurrent: number, agentDir: string): export async function createAgentSession(options: CreateAgentSessionOptions = {}): Promise { const cwd = resolvePath(options.cwd ?? options.sessionManager?.getCwd() ?? process.cwd()); const agentDir = options.agentDir ? resolvePath(options.agentDir) : getDefaultAgentDir(); + const loadedHarnessState = options.harnessStateStore + ? { store: options.harnessStateStore, bundle: options.harnessVersionBundle, warning: undefined } + : await loadGlobalHarnessState(agentDir); const subagentOptions = normalizeSubagentOptions(options.subagents); assertNoReservedSubagentTools(options.customTools); const subagentController = @@ -453,6 +464,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} extensionRunnerRef, sessionStartEvent: options.sessionStartEvent, subagentController, + harnessStateStore: loadedHarnessState.store, + harnessVersionBundle: loadedHarnessState.bundle, }); subagentController?.bindParent(session); const extensionsResult = resourceLoader.getExtensions(); @@ -461,5 +474,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} session, extensionsResult, modelFallbackMessage, + harnessStateWarning: loadedHarnessState.warning, }; } diff --git a/packages/coding-agent/src/core/slash-commands.ts b/packages/coding-agent/src/core/slash-commands.ts index 861972e4c..7aab17e72 100644 --- a/packages/coding-agent/src/core/slash-commands.ts +++ b/packages/coding-agent/src/core/slash-commands.ts @@ -26,6 +26,7 @@ export const BUILTIN_SLASH_COMMANDS: ReadonlyArray = [ { name: "copy", description: "Copy last agent message to clipboard" }, { name: "name", description: "Set session display name" }, { name: "session", description: "Show session info and stats" }, + { name: "version", description: "Select harness version" }, { name: "subagents", description: "Show sub-agents in the current session" }, { name: "changelog", description: "Show changelog entries" }, { name: "hotkeys", description: "Show all keyboard shortcuts" }, diff --git a/packages/coding-agent/src/core/system-prompt.ts b/packages/coding-agent/src/core/system-prompt.ts index 81c581aa0..7c913bf4d 100644 --- a/packages/coding-agent/src/core/system-prompt.ts +++ b/packages/coding-agent/src/core/system-prompt.ts @@ -16,6 +16,8 @@ export interface BuildSystemPromptOptions { promptGuidelines?: string[]; /** Text to append to system prompt. */ appendSystemPrompt?: string; + /** Activated harness evolution memory. Empty memory is omitted from the prompt. */ + evolutionMemory?: { version: string; memory: string }; /** Working directory. */ cwd: string; /** Pre-loaded context files. */ @@ -32,6 +34,7 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions): string { toolSnippets, promptGuidelines, appendSystemPrompt, + evolutionMemory, cwd, contextFiles: providedContextFiles, skills: providedSkills, @@ -39,6 +42,9 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions): string { const promptCwd = cwd.replace(/\\/g, "/"); const appendSection = appendSystemPrompt ? `\n\n${appendSystemPrompt}` : ""; + const evolutionSection = evolutionMemory?.memory + ? `\n\n\n${evolutionMemory.memory}\n` + : ""; const contextFiles = providedContextFiles ?? []; const skills = providedSkills ?? []; @@ -49,6 +55,7 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions): string { if (appendSection) { prompt += appendSection; } + prompt += evolutionSection; // Append project context files if (contextFiles.length > 0) { @@ -118,7 +125,7 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions): string { const guidelines = guidelinesList.map((g) => `- ${g}`).join("\n"); - let prompt = `You are an expert coding assistant operating inside Diffex, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files. + let prompt = `You are Diffex, an expert coding agent. You help users by reading files, executing commands, editing code, and writing new files. Available tools: ${toolsList} @@ -135,11 +142,12 @@ Diffex documentation (read only when the user asks about Diffex itself, its SDK, - When reading Diffex docs or examples, resolve docs/... under Additional docs and examples/... under Examples, not the current working directory - When asked about: extensions (docs/extensions.md, examples/extensions/), themes (docs/themes.md), skills (docs/skills.md), prompt templates (docs/prompt-templates.md), TUI components (docs/tui.md), keybindings (docs/keybindings.md), SDK integrations (docs/sdk.md), custom providers (docs/custom-provider.md), adding models (docs/models.md), Diffex packages (docs/packages.md), environment variables (docs/environment-variables.md) - When working on Diffex topics, read the docs and examples, and follow .md cross-references before implementing -- Always read Diffex .md files completely and follow links to related docs (e.g., tui.md for TUI API details)`; +- Always read Diffex.md files completely and follow links to related docs (e.g., tui.md for TUI API details)`; if (appendSection) { prompt += appendSection; } + prompt += evolutionSection; // Append project context files if (contextFiles.length > 0) { diff --git a/packages/coding-agent/src/modes/interactive/components/harness-version-selector.ts b/packages/coding-agent/src/modes/interactive/components/harness-version-selector.ts new file mode 100644 index 000000000..62509d6f5 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/harness-version-selector.ts @@ -0,0 +1,267 @@ +import { + Container, + type Focusable, + fuzzyFilter, + getKeybindings, + Input, + SELECTED_LIST_PREFIX, + Spacer, + TruncatedText, + UNSELECTED_LIST_PREFIX, +} from "@earendil-works/diffex-tui"; +import { theme } from "../theme/theme.ts"; +import { DynamicBorder } from "./dynamic-border.ts"; +import { keyHint, rawKeyHint } from "./keybinding-hints.ts"; + +export interface HarnessVersionSelectorItem { + version: string; + base: string | null | undefined; + memory: string | undefined; + diagnostic?: string; +} + +interface HarnessVersionSelectorBundle { + manifest: { + version: string; + base: string | null; + }; + state: { + memory: string; + }; +} + +interface HarnessVersionSelectorDiagnostic { + version: string | null; + message: string; + manifest?: HarnessVersionSelectorBundle["manifest"] | null; + state?: HarnessVersionSelectorBundle["state"] | null; +} + +export function createHarnessVersionSelectorItems( + versions: readonly HarnessVersionSelectorBundle[], + diagnostics: readonly HarnessVersionSelectorDiagnostic[], +): HarnessVersionSelectorItem[] { + const items = new Map(); + for (const bundle of versions) { + items.set(bundle.manifest.version, { + version: bundle.manifest.version, + base: bundle.manifest.base, + memory: bundle.state.memory, + }); + } + + for (const diagnostic of diagnostics) { + if (!diagnostic.version) continue; + const existing = items.get(diagnostic.version); + if (existing) { + existing.diagnostic = existing.diagnostic + ? `${existing.diagnostic}; ${diagnostic.message}` + : diagnostic.message; + continue; + } + items.set(diagnostic.version, { + version: diagnostic.version, + base: diagnostic.manifest?.base, + memory: diagnostic.state?.memory, + diagnostic: diagnostic.message, + }); + } + + return [...items.values()]; +} + +function abbreviateVersion(version: string): string { + const [algorithm, digest] = version.split(":", 2); + if (!algorithm || !digest) return version; + return `${algorithm}:${digest.slice(0, 12)}`; +} + +function sanitizeTerminalText(value: string): string { + return value.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, " "); +} + +function compareVersions(left: HarnessVersionSelectorItem, right: HarnessVersionSelectorItem): number { + if (left.version < right.version) return -1; + if (left.version > right.version) return 1; + return 0; +} + +function getMemoryPreview(memory: string | undefined): string { + if (memory === undefined) return "(EVOLVE.md unavailable)"; + return ( + memory + .split(/\r?\n/) + .map((line) => sanitizeTerminalText(line).trim()) + .find(Boolean) ?? "(empty EVOLVE.md)" + ); +} + +export function orderHarnessVersions( + items: readonly HarnessVersionSelectorItem[], + currentVersion: string | undefined, +): HarnessVersionSelectorItem[] { + const byVersion = new Map(items.map((item) => [item.version, item])); + const ordered: HarnessVersionSelectorItem[] = []; + const seen = new Set(); + let version = currentVersion; + + while (version && !seen.has(version)) { + const item = byVersion.get(version); + if (!item) break; + ordered.push(item); + seen.add(version); + version = item.base ?? undefined; + } + + const remaining = items + .filter((item) => !seen.has(item.version)) + .sort((a, b) => { + if (Boolean(a.diagnostic) !== Boolean(b.diagnostic)) return a.diagnostic ? 1 : -1; + return compareVersions(a, b); + }); + return [...ordered, ...remaining]; +} + +export class HarnessVersionSelectorComponent extends Container implements Focusable { + private readonly searchInput = new Input(); + private readonly listContainer = new Container(); + private readonly allItems: HarnessVersionSelectorItem[]; + private filteredItems: HarnessVersionSelectorItem[]; + private selectedIndex = 0; + private readonly currentVersion: string | undefined; + private readonly onSelectCallback: (version: string) => void; + private readonly onCancelCallback: () => void; + private _focused = false; + + get focused(): boolean { + return this._focused; + } + + set focused(value: boolean) { + this._focused = value; + this.searchInput.focused = value; + } + + constructor( + items: readonly HarnessVersionSelectorItem[], + currentVersion: string | undefined, + onSelect: (version: string) => void, + onCancel: () => void, + ) { + super(); + this.allItems = orderHarnessVersions(items, currentVersion); + this.filteredItems = this.allItems; + this.currentVersion = currentVersion; + this.onSelectCallback = onSelect; + this.onCancelCallback = onCancel; + + this.addChild(new DynamicBorder()); + this.addChild(new Spacer(1)); + this.addChild(new TruncatedText(theme.fg("accent", theme.bold("Select harness version:")), 1, 0)); + this.addChild(new Spacer(1)); + + this.searchInput.onSubmit = () => this.selectCurrentItem(); + this.addChild(this.searchInput); + this.addChild(new Spacer(1)); + this.addChild(this.listContainer); + this.addChild(new Spacer(1)); + this.addChild( + new TruncatedText( + rawKeyHint("↑↓", "navigate") + + " " + + keyHint("tui.select.confirm", "select") + + " " + + keyHint("tui.select.cancel", "cancel"), + 1, + 0, + ), + ); + this.addChild(new Spacer(1)); + this.addChild(new DynamicBorder()); + + this.updateList(); + } + + private filterItems(query: string): void { + this.filteredItems = query + ? fuzzyFilter(this.allItems, query, (item) => `${item.version} ${getMemoryPreview(item.memory)}`) + : this.allItems; + this.selectedIndex = query ? 0 : Math.min(this.selectedIndex, Math.max(0, this.filteredItems.length - 1)); + this.updateList(); + } + + private updateList(): void { + this.listContainer.clear(); + const maxVisible = 10; + const startIndex = Math.max( + 0, + Math.min(this.selectedIndex - Math.floor(maxVisible / 2), this.filteredItems.length - maxVisible), + ); + const endIndex = Math.min(startIndex + maxVisible, this.filteredItems.length); + + for (let index = startIndex; index < endIndex; index += 1) { + const item = this.filteredItems[index]; + if (!item) continue; + const selected = index === this.selectedIndex; + const prefix = selected ? SELECTED_LIST_PREFIX : UNSELECTED_LIST_PREFIX; + const version = abbreviateVersion(item.version); + const relation = + item.base === null + ? "root" + : item.base === undefined + ? "base unknown" + : `base ${abbreviateVersion(item.base)}`; + const current = item.version === this.currentVersion ? " [current]" : ""; + const preview = getMemoryPreview(item.memory); + const unavailable = item.diagnostic ? ` - unavailable: ${sanitizeTerminalText(item.diagnostic)}` : ""; + const line = `${prefix}${version}${current} - ${relation} - ${preview}${unavailable}`; + this.listContainer.addChild( + new TruncatedText( + selected + ? theme.fg(item.diagnostic ? "warning" : "selectionAccent", line) + : theme.fg(item.diagnostic ? "warning" : "text", line), + 1, + 0, + ), + ); + } + + if (startIndex > 0 || endIndex < this.filteredItems.length) { + this.listContainer.addChild( + new TruncatedText(theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredItems.length})`), 1, 0), + ); + } + if (this.filteredItems.length === 0) { + this.listContainer.addChild(new TruncatedText(theme.fg("muted", " No matching versions"), 1, 0)); + } + } + + private selectCurrentItem(): void { + const item = this.filteredItems[this.selectedIndex]; + if (item && !item.diagnostic) this.onSelectCallback(item.version); + } + + handleInput(keyData: string): void { + const keybindings = getKeybindings(); + if (keybindings.matches(keyData, "tui.select.up")) { + if (this.filteredItems.length === 0) return; + this.selectedIndex = Math.max(0, this.selectedIndex - 1); + this.updateList(); + } else if (keybindings.matches(keyData, "tui.select.down")) { + if (this.filteredItems.length === 0) return; + this.selectedIndex = Math.min(this.filteredItems.length - 1, this.selectedIndex + 1); + this.updateList(); + } else if (keybindings.matches(keyData, "tui.select.confirm")) { + this.selectCurrentItem(); + } else if (keybindings.matches(keyData, "tui.select.cancel")) { + this.onCancelCallback(); + } else { + this.searchInput.handleInput(keyData); + this.filterItems(this.searchInput.getValue()); + } + } + + getSearchInput(): Input { + return this.searchInput; + } +} diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 669476f22..a88cf854c 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -138,6 +138,10 @@ import { ExtensionEditorComponent } from "./components/extension-editor.ts"; import { ExtensionInputComponent } from "./components/extension-input.ts"; import { ExtensionSelectorComponent } from "./components/extension-selector.ts"; import { FooterComponent, formatCwdForFooter, formatTokens } from "./components/footer.ts"; +import { + createHarnessVersionSelectorItems, + HarnessVersionSelectorComponent, +} from "./components/harness-version-selector.ts"; import { formatKeyText, keyDisplayText } from "./components/keybinding-hints.ts"; import { LoginDialogComponent } from "./components/login-dialog.ts"; import { createMermaidMarkdownTransformer } from "./components/mermaid.ts"; @@ -2944,6 +2948,11 @@ export class InteractiveMode { this.editor.setText(""); return; } + if (text === "/version") { + this.editor.setText(""); + await this.showHarnessVersionSelector(); + return; + } if (text === "/subagents") { this.handleSubagentsCommand(); this.editor.setText(""); @@ -4624,6 +4633,51 @@ export class InteractiveMode { }); } + private async showHarnessVersionSelector(): Promise { + try { + const { versions, diagnostics, currentVersion } = await this.session.listHarnessVersions(); + const items = createHarnessVersionSelectorItems(versions, diagnostics); + if (items.length === 0) { + this.showStatus("No harness versions available"); + return; + } + + this.showSelector((done) => { + let switching = false; + const selector = new HarnessVersionSelectorComponent( + items, + currentVersion ?? this.session.activeHarnessVersion, + (version) => { + if (switching) return; + if (version === this.session.activeHarnessVersion) { + done(); + this.showStatus(`Harness version already active: ${version}`); + return; + } + switching = true; + void this.session + .switchHarnessVersion(version) + .then(() => { + done(); + this.showStatus(`Harness version: ${version}`); + }) + .catch((error: unknown) => { + done(); + this.showError(error instanceof Error ? error.message : String(error)); + }); + }, + () => { + done(); + this.ui.requestRender(); + }, + ); + return { component: selector, focus: selector }; + }); + } catch (error: unknown) { + this.showError(`Could not list harness versions: ${error instanceof Error ? error.message : String(error)}`); + } + } + private async handleModelCommand(searchTerm?: string): Promise { if (!searchTerm) { this.showModelSelector(); diff --git a/packages/coding-agent/test/harness-state-runtime.test.ts b/packages/coding-agent/test/harness-state-runtime.test.ts new file mode 100644 index 000000000..b53d38f22 --- /dev/null +++ b/packages/coding-agent/test/harness-state-runtime.test.ts @@ -0,0 +1,137 @@ +import { createHash } from "node:crypto"; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + computeHarnessVersion, + HarnessStateStore, + harnessVersionDirectoryName, +} from "@earendil-works/diffex-harness-state"; +import { afterEach, describe, expect, it } from "vitest"; +import { loadGlobalHarnessState } from "../src/core/harness-state-runtime.ts"; + +describe("global harness state startup", () => { + const agentDirs: string[] = []; + + afterEach(() => { + while (agentDirs.length > 0) { + const agentDir = agentDirs.pop(); + if (agentDir) rmSync(agentDir, { recursive: true, force: true }); + } + }); + + function createAgentDir(): string { + const agentDir = join(tmpdir(), `diffex-harness-startup-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(agentDir, { recursive: true }); + agentDirs.push(agentDir); + return agentDir; + } + + function contentHash(content: string): string { + return `sha256:${createHash("sha256").update(content, "utf8").digest("hex")}`; + } + + it("initializes and activates the empty root when current.json is missing", async () => { + const loaded = await loadGlobalHarnessState(createAgentDir()); + + expect(loaded.warning).toBeUndefined(); + expect(loaded.bundle?.state.memory).toBe(""); + expect(loaded.bundle?.manifest.base).toBeNull(); + }); + + it("loads the globally selected non-empty revision", async () => { + const agentDir = createAgentDir(); + const store = new HarnessStateStore(agentDir); + const root = await store.initialize(); + const advance = await store.advance({ memory: "Remember this revision." }, root.manifest.version); + if (advance.status !== "advanced") throw new Error("Expected harness advance to succeed"); + + const loaded = await loadGlobalHarnessState(agentDir); + + expect(loaded.warning).toBeUndefined(); + expect(loaded.bundle).toEqual(advance.bundle); + }); + + it("warns and falls back without rewriting a corrupt current pointer", async () => { + const agentDir = createAgentDir(); + const store = new HarnessStateStore(agentDir); + await store.initialize(); + const pointerPath = join(agentDir, "harness-state", "current.json"); + writeFileSync(pointerPath, '{"version":"invalid"}\n'); + const corruptPointer = readFileSync(pointerPath, "utf8"); + + const loaded = await loadGlobalHarnessState(agentDir); + + expect(loaded.bundle).toBeUndefined(); + expect(loaded.warning).toContain("Continuing with the built-in harness."); + expect(readFileSync(pointerPath, "utf8")).toBe(corruptPointer); + }); + + it("warns and falls back when the selected revision is missing", async () => { + const agentDir = createAgentDir(); + const store = new HarnessStateStore(agentDir); + await store.initialize(); + const pointerPath = join(agentDir, "harness-state", "current.json"); + const missingVersion = `sha256:${"f".repeat(64)}`; + writeFileSync(pointerPath, `${JSON.stringify({ version: missingVersion }, null, 2)}\n`); + const missingPointer = readFileSync(pointerPath, "utf8"); + + const loaded = await loadGlobalHarnessState(agentDir); + + expect(loaded.bundle).toBeUndefined(); + expect(loaded.warning).toContain("Continuing with the built-in harness."); + expect(readFileSync(pointerPath, "utf8")).toBe(missingPointer); + }); + + it("warns and falls back when the selected bundle is corrupt", async () => { + const agentDir = createAgentDir(); + const store = new HarnessStateStore(agentDir); + const root = await store.initialize(); + const pointerPath = join(agentDir, "harness-state", "current.json"); + const memoryPath = join( + agentDir, + "harness-state", + "versions", + harnessVersionDirectoryName(root.manifest.version), + "EVOLVE.md", + ); + writeFileSync(memoryPath, "corrupt bytes"); + const selectedPointer = readFileSync(pointerPath, "utf8"); + + const loaded = await loadGlobalHarnessState(agentDir); + + expect(loaded.bundle).toBeUndefined(); + expect(loaded.warning).toContain("content hash mismatch"); + expect(readFileSync(pointerPath, "utf8")).toBe(selectedPointer); + }); + + it("warns and falls back when the selected revision uses an unsupported artifact", async () => { + const agentDir = createAgentDir(); + const store = new HarnessStateStore(agentDir); + const root = await store.initialize(); + const memory = "Future-compatible memory.\n"; + const skill = "Future skill.\n"; + const artifacts = [ + { kind: "evolution-memory", path: "EVOLVE.md", contentHash: contentHash(memory) }, + { kind: "skill", path: "skills/future/SKILL.md", contentHash: contentHash(skill) }, + ]; + const version = computeHarnessVersion({ schemaVersion: 1, base: root.manifest.version, artifacts }); + const bundlePath = join(agentDir, "harness-state", "versions", harnessVersionDirectoryName(version)); + mkdirSync(join(bundlePath, "skills", "future"), { recursive: true }); + writeFileSync( + join(bundlePath, "manifest.json"), + `${JSON.stringify({ schemaVersion: 1, version, base: root.manifest.version, artifacts }, null, 2)}\n`, + ); + writeFileSync(join(bundlePath, "EVOLVE.md"), memory); + writeFileSync(join(bundlePath, "skills", "future", "SKILL.md"), skill); + const pointerPath = join(agentDir, "harness-state", "current.json"); + writeFileSync(pointerPath, `${JSON.stringify({ version }, null, 2)}\n`); + const incompatiblePointer = readFileSync(pointerPath, "utf8"); + + const loaded = await loadGlobalHarnessState(agentDir); + + expect(loaded.bundle).toBeUndefined(); + expect(loaded.warning).toContain('Unsupported harness artifact kind "skill"'); + expect(readFileSync(pointerPath, "utf8")).toBe(incompatiblePointer); + }); +}); diff --git a/packages/coding-agent/test/harness-version-selector.test.ts b/packages/coding-agent/test/harness-version-selector.test.ts new file mode 100644 index 000000000..3b281ebad --- /dev/null +++ b/packages/coding-agent/test/harness-version-selector.test.ts @@ -0,0 +1,160 @@ +import { setKeybindings } from "@earendil-works/diffex-tui"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { KeybindingsManager } from "../src/core/keybindings.ts"; +import { + createHarnessVersionSelectorItems, + HarnessVersionSelectorComponent, + type HarnessVersionSelectorItem, + orderHarnessVersions, +} from "../src/modes/interactive/components/harness-version-selector.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; +import { stripAnsi } from "../src/utils/ansi.ts"; + +const root: HarnessVersionSelectorItem = { + version: `sha256:${"a".repeat(64)}`, + base: null, + memory: "", +}; + +const child: HarnessVersionSelectorItem = { + version: `sha256:${"b".repeat(64)}`, + base: root.version, + memory: "\nPrefer precise answers.\nMore detail.", +}; + +const branch: HarnessVersionSelectorItem = { + version: `sha256:${"c".repeat(64)}`, + base: root.version, + memory: "Use branch memory.", +}; + +const unavailable: HarnessVersionSelectorItem = { + version: `sha256:${"0".repeat(64)}`, + base: child.version, + memory: undefined, + diagnostic: "unsupported artifact kind: skill", +}; + +describe("HarnessVersionSelectorComponent", () => { + beforeAll(() => { + initTheme("dark"); + }); + + beforeEach(() => { + setKeybindings(new KeybindingsManager()); + }); + + it("orders the current version, its ancestors, and deterministic remainder", () => { + expect(orderHarnessVersions([unavailable, branch, root, child], child.version)).toEqual([ + child, + root, + branch, + unavailable, + ]); + }); + + it("maps valid bundles and version diagnostics without exposing pointer-only diagnostics", () => { + expect( + createHarnessVersionSelectorItems( + [{ manifest: { version: child.version, base: root.version }, state: { memory: child.memory ?? "" } }], + [ + { + version: unavailable.version, + message: unavailable.diagnostic!, + manifest: { version: unavailable.version, base: child.version }, + state: { memory: "Future skill revision." }, + }, + { version: null, message: "current.json is malformed" }, + ], + ), + ).toEqual([child, { ...unavailable, memory: "Future skill revision." }]); + }); + + it("renders lineage, memory previews, current state, and unavailable diagnostics", () => { + const selector = new HarnessVersionSelectorComponent( + [unavailable, branch, root, child], + child.version, + () => {}, + () => {}, + ); + + const output = stripAnsi(selector.render(180).join("\n")); + const childIndex = output.indexOf("sha256:bbbbbbbbbbbb [current]"); + const rootIndex = output.indexOf("sha256:aaaaaaaaaaaa"); + const branchIndex = output.indexOf("sha256:cccccccccccc"); + + expect(childIndex).toBeGreaterThanOrEqual(0); + expect(rootIndex).toBeGreaterThan(childIndex); + expect(branchIndex).toBeGreaterThan(rootIndex); + expect(output).toContain("base sha256:aaaaaaaaaaaa - Prefer precise answers."); + expect(output).toContain("root - (empty EVOLVE.md)"); + expect(output).toContain("(EVOLVE.md unavailable)"); + expect(output).toContain("unavailable: unsupported artifact kind: skill"); + }); + + it("removes terminal control sequences from memory and diagnostics", () => { + const selector = new HarnessVersionSelectorComponent( + [ + { + ...unavailable, + memory: "\u001b]8;;https://example.com\u0007unsafe\u001b]8;;\u0007 preview", + diagnostic: "bad\u001b[2J bundle\rdiagnostic", + }, + ], + undefined, + () => {}, + () => {}, + ); + + const output = selector.render(180).join("\n"); + expect(output).not.toContain("\u001b]8;;https://example.com"); + expect(output).not.toContain("\u001b[2J"); + expect(output).not.toContain("\r"); + expect(stripAnsi(output)).toContain("unsafe"); + expect(stripAnsi(output)).toContain("bundle diagnostic"); + }); + + it("searches the full hash and displayed memory preview", () => { + const selector = new HarnessVersionSelectorComponent( + [root, child, branch], + child.version, + () => {}, + () => {}, + ); + + selector.handleInput("c".repeat(20)); + let output = stripAnsi(selector.render(180).join("\n")); + expect(output).toContain("sha256:cccccccccccc"); + expect(output).not.toContain("sha256:bbbbbbbbbbbb"); + + selector.getSearchInput().setValue(""); + selector.handleInput("branch"); + output = stripAnsi(selector.render(180).join("\n")); + expect(output).toContain("Use branch memory."); + expect(output).not.toContain("Prefer precise answers."); + }); + + it("navigates, selects available versions, and ignores unavailable versions", () => { + const onSelect = vi.fn(); + const selector = new HarnessVersionSelectorComponent([child, unavailable], child.version, onSelect, () => {}); + + selector.handleInput("\u001b[B"); + selector.handleInput("\n"); + expect(onSelect).not.toHaveBeenCalled(); + + selector.handleInput("\u001b[A"); + selector.handleInput("\n"); + expect(onSelect).toHaveBeenCalledWith(child.version); + }); + + it("cancels without selecting", () => { + const onSelect = vi.fn(); + const onCancel = vi.fn(); + const selector = new HarnessVersionSelectorComponent([root], root.version, onSelect, onCancel); + + selector.handleInput("\u001b"); + + expect(onCancel).toHaveBeenCalledOnce(); + expect(onSelect).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/coding-agent/test/interactive-mode-harness-version.test.ts b/packages/coding-agent/test/interactive-mode-harness-version.test.ts new file mode 100644 index 000000000..7370fb13b --- /dev/null +++ b/packages/coding-agent/test/interactive-mode-harness-version.test.ts @@ -0,0 +1,141 @@ +import { setKeybindings } from "@earendil-works/diffex-tui"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { KeybindingsManager } from "../src/core/keybindings.ts"; +import type { HarnessVersionSelectorComponent } from "../src/modes/interactive/components/harness-version-selector.ts"; +import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; + +const rootVersion = `sha256:${"a".repeat(64)}`; +const childVersion = `sha256:${"b".repeat(64)}`; + +interface HarnessModeFixture { + session: { + activeHarnessVersion: string | undefined; + listHarnessVersions: ReturnType; + switchHarnessVersion: ReturnType; + }; + ui: { + requestRender: ReturnType; + }; + showSelector: ( + create: (done: () => void) => { + component: HarnessVersionSelectorComponent; + focus: HarnessVersionSelectorComponent; + }, + ) => void; + showStatus: ReturnType; + showError: ReturnType; +} + +const showHarnessVersionSelector = ( + InteractiveMode as unknown as { + prototype: { + showHarnessVersionSelector(this: HarnessModeFixture): Promise; + }; + } +).prototype.showHarnessVersionSelector; + +function createFixture(switchHarnessVersion = vi.fn().mockResolvedValue(undefined)): { + fixture: HarnessModeFixture; + getSelector: () => HarnessVersionSelectorComponent; + done: ReturnType; +} { + let selector: HarnessVersionSelectorComponent | undefined; + const done = vi.fn(); + const fixture: HarnessModeFixture = { + session: { + activeHarnessVersion: rootVersion, + listHarnessVersions: vi.fn().mockResolvedValue({ + currentVersion: rootVersion, + versions: [ + { + manifest: { version: rootVersion, base: null }, + state: { memory: "" }, + }, + { + manifest: { version: childVersion, base: rootVersion }, + state: { memory: "Use the child memory." }, + }, + ], + diagnostics: [], + }), + switchHarnessVersion, + }, + ui: { requestRender: vi.fn() }, + showSelector: (create) => { + selector = create(done).component; + }, + showStatus: vi.fn(), + showError: vi.fn(), + }; + return { + fixture, + getSelector: () => { + if (!selector) throw new Error("Harness version selector was not shown"); + return selector; + }, + done, + }; +} + +describe("InteractiveMode harness version selector", () => { + beforeAll(() => { + initTheme("dark"); + }); + + beforeEach(() => { + setKeybindings(new KeybindingsManager()); + }); + + it("treats selecting the current revision as a no-op", async () => { + const { fixture, getSelector, done } = createFixture(); + await showHarnessVersionSelector.call(fixture); + + getSelector().handleInput("\n"); + + expect(done).toHaveBeenCalledOnce(); + expect(fixture.session.switchHarnessVersion).not.toHaveBeenCalled(); + expect(fixture.showStatus).toHaveBeenCalledWith(`Harness version already active: ${rootVersion}`); + }); + + it("switches the active revision selected through list navigation", async () => { + const { fixture, getSelector, done } = createFixture(); + await showHarnessVersionSelector.call(fixture); + + getSelector().handleInput("\u001b[B"); + getSelector().handleInput("\n"); + + await vi.waitFor(() => { + expect(fixture.session.switchHarnessVersion).toHaveBeenCalledWith(childVersion); + expect(fixture.showStatus).toHaveBeenCalledWith(`Harness version: ${childVersion}`); + }); + expect(done).toHaveBeenCalledOnce(); + }); + + it.each([ + "Cannot switch harness versions while a response is streaming.", + "Cannot switch harness versions while compaction is in progress.", + "Cannot switch harness versions while a bash command is running.", + ])("surfaces the AgentSession operation guard: %s", async (message) => { + const switchHarnessVersion = vi.fn().mockRejectedValue(new Error(message)); + const { fixture, getSelector } = createFixture(switchHarnessVersion); + await showHarnessVersionSelector.call(fixture); + + getSelector().handleInput("\u001b[B"); + getSelector().handleInput("\n"); + + await vi.waitFor(() => { + expect(fixture.showError).toHaveBeenCalledWith(message); + }); + }); + + it("cancels without changing the active revision", async () => { + const { fixture, getSelector, done } = createFixture(); + await showHarnessVersionSelector.call(fixture); + + getSelector().handleInput("\u001b"); + + expect(done).toHaveBeenCalledOnce(); + expect(fixture.session.switchHarnessVersion).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/coding-agent/test/session-file-invalid.test.ts b/packages/coding-agent/test/session-file-invalid.test.ts index 87e90a438..448c69a44 100644 --- a/packages/coding-agent/test/session-file-invalid.test.ts +++ b/packages/coding-agent/test/session-file-invalid.test.ts @@ -23,7 +23,7 @@ function createTempDir(): string { async function runCli(args: string[], cwd: string, agentDir: string): Promise<{ code: number | null; stderr: string }> { let stderr = ""; const code = await new Promise((resolvePromise, reject) => { - const child = spawn(process.execPath, [cliPath, ...args], { + const child = spawn(process.execPath, [`--import=${import.meta.resolve("tsx")}`, cliPath, ...args], { cwd, env: { ...process.env, diff --git a/packages/coding-agent/test/session-id-readonly.test.ts b/packages/coding-agent/test/session-id-readonly.test.ts index a83200120..ab40be8c1 100644 --- a/packages/coding-agent/test/session-id-readonly.test.ts +++ b/packages/coding-agent/test/session-id-readonly.test.ts @@ -73,7 +73,7 @@ async function runCli( let stderr = ""; const code = await new Promise((resolvePromise, reject) => { - const child = spawn(process.execPath, [cliPath, ...resolvedArgs], { + const child = spawn(process.execPath, [`--import=${import.meta.resolve("tsx")}`, cliPath, ...resolvedArgs], { cwd: dirs.projectDir, env: { ...process.env, diff --git a/packages/coding-agent/test/slash-commands.test.ts b/packages/coding-agent/test/slash-commands.test.ts index a2b48dcd2..1a744a211 100644 --- a/packages/coding-agent/test/slash-commands.test.ts +++ b/packages/coding-agent/test/slash-commands.test.ts @@ -17,4 +17,11 @@ describe("BUILTIN_SLASH_COMMANDS", () => { description: "Show sub-agents in the current session", }); }); + + it("includes the harness version selector command", () => { + expect(BUILTIN_SLASH_COMMANDS).toContainEqual({ + name: "version", + description: "Select harness version", + }); + }); }); diff --git a/packages/coding-agent/test/startup-session-name.test.ts b/packages/coding-agent/test/startup-session-name.test.ts index 56b8a6658..71f976ecc 100644 --- a/packages/coding-agent/test/startup-session-name.test.ts +++ b/packages/coding-agent/test/startup-session-name.test.ts @@ -65,7 +65,7 @@ function readSessionInfoNames(sessionFile: string): string[] { async function runCli(args: string[], dirs: CliDirs): Promise { let stderr = ""; - const child = spawn(process.execPath, [cliPath, ...args], { + const child = spawn(process.execPath, [`--import=${import.meta.resolve("tsx")}`, cliPath, ...args], { cwd: dirs.projectDir, env: { ...process.env, diff --git a/packages/coding-agent/test/stdout-cleanliness.test.ts b/packages/coding-agent/test/stdout-cleanliness.test.ts index 7c94fcb39..27b957061 100644 --- a/packages/coding-agent/test/stdout-cleanliness.test.ts +++ b/packages/coding-agent/test/stdout-cleanliness.test.ts @@ -59,7 +59,7 @@ async function runCli(args: string[]): Promise<{ stdout: string; stderr: string; ); return await new Promise((resolvePromise, reject) => { - const child = spawn(process.execPath, [cliPath, ...args], { + const child = spawn(process.execPath, [`--import=${import.meta.resolve("tsx")}`, cliPath, ...args], { cwd: projectDir, env: { ...process.env, diff --git a/packages/coding-agent/test/suite/agent-session-harness-version.test.ts b/packages/coding-agent/test/suite/agent-session-harness-version.test.ts new file mode 100644 index 000000000..5ba58b734 --- /dev/null +++ b/packages/coding-agent/test/suite/agent-session-harness-version.test.ts @@ -0,0 +1,127 @@ +import { mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fauxAssistantMessage } from "@earendil-works/diffex-ai"; +import { HarnessStateStore, type HarnessVersion } from "@earendil-works/diffex-harness-state"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createHarness, type Harness } from "./harness.ts"; + +describe("AgentSession harness revisions", () => { + const harnesses: Harness[] = []; + const agentDirs: string[] = []; + + afterEach(async () => { + while (harnesses.length > 0) { + await harnesses.pop()?.cleanup(); + } + while (agentDirs.length > 0) { + const agentDir = agentDirs.pop(); + if (agentDir) rmSync(agentDir, { recursive: true, force: true }); + } + vi.restoreAllMocks(); + }); + + async function createVersionedHarness(): Promise<{ + harness: Harness; + store: HarnessStateStore; + rootVersion: HarnessVersion; + }> { + const agentDir = join(tmpdir(), `diffex-harness-session-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(agentDir, { recursive: true }); + agentDirs.push(agentDir); + const store = new HarnessStateStore(agentDir); + const root = await store.initialize(); + const harness = await createHarness({ + harnessStateStore: store, + harnessVersionBundle: root, + }); + harnesses.push(harness); + return { harness, store, rootVersion: root.manifest.version }; + } + + it("applies an advanced EVOLVE revision only to requests after a live switch", async () => { + const { harness, store, rootVersion } = await createVersionedHarness(); + const providerPrompts: string[] = []; + harness.setResponses([ + (context) => { + providerPrompts.push(context.systemPrompt ?? ""); + return fauxAssistantMessage("before switch"); + }, + (context) => { + providerPrompts.push(context.systemPrompt ?? ""); + return fauxAssistantMessage("after switch"); + }, + ]); + + await harness.session.prompt("first request"); + const advance = await store.advance({ memory: "Always mention the activated revision." }, rootVersion); + expect(advance.status).toBe("advanced"); + if (advance.status !== "advanced") throw new Error("Expected harness advance to succeed"); + + const historyBeforeSwitch = harness.session.messages.slice(); + await harness.session.switchHarnessVersion(advance.bundle.manifest.version); + expect(harness.session.messages).toEqual(historyBeforeSwitch); + expect(harness.session.activeHarnessVersion).toBe(advance.bundle.manifest.version); + + await harness.session.prompt("second request"); + + expect(providerPrompts).toHaveLength(2); + expect(providerPrompts[0]).not.toContain("\nAlways mention the activated revision.\n`, + ); + }); + + it.each([ + { property: "isStreaming", message: "while a response is streaming" }, + { property: "isCompacting", message: "while compaction is in progress" }, + { property: "isBashRunning", message: "while a bash command is running" }, + ] as const)("rejects switching $property when an operation is active", async ({ property, message }) => { + const { harness, store, rootVersion } = await createVersionedHarness(); + const successor = await store.saveVersion({ memory: "New memory." }, rootVersion); + vi.spyOn(harness.session, property, "get").mockReturnValue(true); + + await expect(harness.session.switchHarnessVersion(successor.manifest.version)).rejects.toThrow(message); + expect(harness.session.activeHarnessVersion).toBe(rootVersion); + }); + + it("treats selecting the active revision as a no-op during an active operation", async () => { + const { harness, rootVersion } = await createVersionedHarness(); + vi.spyOn(harness.session, "isStreaming", "get").mockReturnValue(true); + + await expect(harness.session.switchHarnessVersion(rootVersion)).resolves.toBeUndefined(); + }); + + it("rejects prompts while a harness switch is in progress", async () => { + const { harness, store, rootVersion } = await createVersionedHarness(); + const successor = await store.saveVersion({ memory: "New memory." }, rootVersion); + const listing = await store.listVersions(); + let releaseListing: (() => void) | undefined; + const listingGate = new Promise((resolve) => { + releaseListing = resolve; + }); + vi.spyOn(store, "listVersions").mockImplementation(async () => { + await listingGate; + return listing; + }); + + const switching = harness.session.switchHarnessVersion(successor.manifest.version); + await vi.waitFor(() => expect(store.listVersions).toHaveBeenCalledOnce()); + await expect(harness.session.prompt("racing request")).rejects.toThrow( + "Cannot submit a prompt while a harness version switch is in progress.", + ); + + releaseListing?.(); + await switching; + }); + + it("reports an unavailable revision without changing the active bundle", async () => { + const { harness, rootVersion } = await createVersionedHarness(); + const missingVersion = `sha256:${"f".repeat(64)}` as HarnessVersion; + + await expect(harness.session.switchHarnessVersion(missingVersion)).rejects.toThrow( + `Harness version ${missingVersion} was not found.`, + ); + expect(harness.session.activeHarnessVersion).toBe(rootVersion); + }); +}); diff --git a/packages/coding-agent/test/suite/harness.ts b/packages/coding-agent/test/suite/harness.ts index ffb7e3261..4596f1ce8 100644 --- a/packages/coding-agent/test/suite/harness.ts +++ b/packages/coding-agent/test/suite/harness.ts @@ -15,6 +15,7 @@ import type { Model, } from "@earendil-works/diffex-ai/compat"; import { registerFauxProvider, streamSimple } from "@earendil-works/diffex-ai/compat"; +import type { HarnessStateStore, HarnessVersionBundle } from "@earendil-works/diffex-harness-state"; import { AgentSession, type AgentSessionEvent } from "../../src/core/agent-session.ts"; import { AuthStorage } from "../../src/core/auth-storage.ts"; import type { ExtensionRunner, SessionStartEvent } from "../../src/core/extensions/index.ts"; @@ -74,6 +75,8 @@ export interface HarnessOptions { withConfiguredAuth?: boolean; modelsJson?: Record; sessionStartEvent?: SessionStartEvent; + harnessStateStore?: HarnessStateStore; + harnessVersionBundle?: HarnessVersionBundle; } export interface Harness { @@ -194,6 +197,8 @@ export async function createHarness(options: HarnessOptions = {}): Promise { it("preserves cancellation before the subagent wait is installed", async () => { const { root, harness } = await createRoot(); - const childStarted = deferred(); const abortStarted = deferred(); const lifecycle: Array<{ type: "start" } | { type: "end"; cancelled: boolean }> = []; let abortPromise: Promise | undefined; @@ -666,7 +665,6 @@ describe("automatic subagent coordination", () => { harness.setResponses( Array.from({ length: 6 }, () => async (context: Context, options?: SimpleStreamOptions) => { if (!hasTool(context, "spawn_agent")) { - childStarted.resolve(); return await new Promise>((resolve) => { options?.signal?.addEventListener( "abort", @@ -686,7 +684,7 @@ describe("automatic subagent coordination", () => { ); const prompt = root.prompt("cancel before post-run coordination"); - await Promise.all([childStarted.promise, abortStarted.promise]); + await abortStarted.promise; await prompt; await abortPromise; diff --git a/packages/coding-agent/test/system-prompt.test.ts b/packages/coding-agent/test/system-prompt.test.ts index 22f2da8f3..fa0ced448 100644 --- a/packages/coding-agent/test/system-prompt.test.ts +++ b/packages/coding-agent/test/system-prompt.test.ts @@ -114,4 +114,40 @@ describe("buildSystemPrompt", () => { expect(prompt.match(/- Use dynamic_tool for summaries\./g)).toHaveLength(1); }); }); + + describe("evolution memory", () => { + test.each([ + { label: "default", customPrompt: undefined }, + { label: "custom", customPrompt: "Custom base prompt." }, + ])("places non-empty memory before project context for the $label prompt", ({ customPrompt }) => { + const prompt = buildSystemPrompt({ + customPrompt, + appendSystemPrompt: "Appended instructions.", + evolutionMemory: { + version: "sha256:1234", + memory: "Prefer focused changes.", + }, + contextFiles: [{ path: "AGENTS.md", content: "Project instructions." }], + skills: [], + cwd: process.cwd(), + }); + + const evolutionBlock = + '\nPrefer focused changes.\n'; + expect(prompt).toContain(evolutionBlock); + expect(prompt.indexOf("Appended instructions.")).toBeLessThan(prompt.indexOf(evolutionBlock)); + expect(prompt.indexOf(evolutionBlock)).toBeLessThan(prompt.indexOf("")); + }); + + test("omits the evolution block for empty root memory", () => { + const prompt = buildSystemPrompt({ + evolutionMemory: { version: "sha256:root", memory: "" }, + contextFiles: [], + skills: [], + cwd: process.cwd(), + }); + + expect(prompt).not.toContain("/harness-state/ +|-- current.json +`-- versions/ + `-- sha256-/ + |-- manifest.json + `-- EVOLVE.md ``` -Both files must be fatal, BOM-free UTF-8. The manifest must contain only `contentHash` and `base`, each formatted as `sha256:` followed by 64 lowercase hexadecimal characters. Loading verifies that `contentHash` matches the exact `EVOLVE.md` bytes, including line endings and trailing whitespace. `base` identifies the predecessor's `contentHash`; a root harness conventionally uses its own `contentHash` as `base`. +Revision identifiers use canonical `sha256:` strings in APIs and data. +Directory names replace the colon with a dash for Windows portability. -## API +Each manifest contains `schemaVersion`, `version`, `base`, and a deterministic artifact inventory. +The revision hash covers the schema version, parent revision, and sorted artifact descriptors, but not the `version` field itself. +Each artifact retains its own content hash over its exact bytes. +Including the parent revision makes repeated evolution with identical artifact content produce a distinct successor. -- `HarnessStateSchema`, `HarnessStateManifestSchema`, and `HarnessStateBundleSchema` are strict TypeBox schemas. Their inferred types expose readonly fields. -- `loadHarnessState(bundlePath)` validates a bundle, verifies its content hash, and returns `{ state, manifest }` without normalizing the memory. -- `saveHarnessState(state, destinationPath, options?)` derives the content hash, stages a canonical two-file bundle beside the destination, and returns `{ state, manifest }`. Passing `options.base` identifies the predecessor; omitting it creates a self-based root. The parent must already be a directory, any existing destination entry is rejected, and a failed save attempts to remove its owned staging directory. +Only one `evolution-memory` artifact at `EVOLVE.md` is supported for activation today. +The manifest schema can represent future artifact kinds so incompatible revisions can be inspected and reported, but activation rejects every kind the runtime cannot fully understand. -The package does not include or select a default harness. Callers must provide an explicit bundle path or state. +## Validation -Invalid state, layout, encoding, manifest, or memory data rejects with an ordinary descriptive `Error`. JSON syntax failures retain the original syntax error as their cause. Other filesystem errors propagate unchanged. +Activation validates the complete bundle before returning any state. +It rejects malformed or noncanonical manifests, revision and content hash mismatches, undeclared or missing files, duplicate and nonportable paths, incorrect casing, symbolic links, non-regular files, malformed UTF-8, leading BOMs, and unsupported artifact kinds. +`EVOLVE.md` may be exactly empty for the root revision. +Non-empty whitespace-only memory remains invalid. -## Limitations +## API -The state stores only the evolving memory notes. The manifest records one base hash but does not resolve predecessors, verify their existence, detect cycles, or otherwise manage lineage. The package has no migration or compatibility behavior and does not represent tools, models, context, sessions, or provenance. Persistence does not provide cross-process coordination, retries, or durability guarantees. +- `computeHarnessVersion(input)` computes a canonical revision identifier. +- `serializeHarnessVersion(input)` exposes the canonical serialization used by the hash. +- `harnessVersionDirectoryName(version)` creates the portable directory name. +- `saveHarnessState(state, destinationPath, base)` writes an immutable, idempotent revision bundle. +- `loadHarnessState(bundlePath)` validates and loads one complete revision bundle. +- `new HarnessStateStore(agentDir)` manages the global store beneath an effective agent directory. + +The store provides these operations: + +- `initialize()` creates and selects the empty root if no pointer exists. +- `loadCurrent()` strictly validates and loads the selected revision. +- `listVersions()` returns compatible bundles and per-path diagnostics for corrupt or incompatible entries. + Diagnostics retain validated manifest and EVOLVE state when an incompatible future artifact is the only activation blocker. +- `saveVersion(state, base)` creates an immutable revision without selecting it. +- `selectVersion(version)` validates the complete target and atomically replaces the pointer. +- `advance(state, expectedBase)` commits and selects a successor only when the selected revision still matches `expectedBase`. + +Initialization, selection, and advancement share a cross-process store lock. +Version writes stage beside their destination and are idempotent only when an existing revision validates to the same canonical content. +Pointer replacement uses a staged file and atomic rename. +An expected-base race returns a discriminated `conflict` result and does not create or select a silent branch. + +The package does not implement skill generation, source mutation, orchestration loading, build execution, restart handling, rollback, garbage collection, aliases, project-local stores, or session-level revision pinning. diff --git a/packages/harness-state/package.json b/packages/harness-state/package.json index 8fd03034e..ccf97bcc6 100644 --- a/packages/harness-state/package.json +++ b/packages/harness-state/package.json @@ -1,8 +1,7 @@ { - "name": "@diffexai/diffex-harness-state", + "name": "@earendil-works/diffex-harness-state", "version": "0.2.0", "description": "Persistent harness state schema and bundle storage for Diffex", - "private": true, "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -19,15 +18,30 @@ "scripts": { "clean": "shx rm -rf dist", "build": "tsgo -p tsconfig.build.json && shx rm -rf dist/default", - "test": "vitest --run" + "test": "vitest --run", + "prepublishOnly": "npm run clean && npm run build" + }, + "keywords": [ + "diffex", + "harness", + "versioning" + ], + "author": "Earendil Works", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/diffexai/diffex.git", + "directory": "packages/harness-state" }, "engines": { "node": ">=22.19.0" }, "dependencies": { + "proper-lockfile": "4.1.2", "typebox": "1.3.7" }, "devDependencies": { + "@types/proper-lockfile": "4.1.4", "shx": "0.4.0", "vitest": "4.1.9" } diff --git a/packages/harness-state/src/index.ts b/packages/harness-state/src/index.ts index c7ec00108..e1227936a 100644 --- a/packages/harness-state/src/index.ts +++ b/packages/harness-state/src/index.ts @@ -1,143 +1,454 @@ -import { createHash } from "node:crypto"; -import { lstat, mkdtemp, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; +import { createHash, randomUUID } from "node:crypto"; +import type { Dirent } from "node:fs"; +import { lstat, mkdir, mkdtemp, readdir, readFile, rename, rm, stat, unlink, writeFile } from "node:fs/promises"; import { basename, dirname, join, resolve } from "node:path"; import { TextDecoder, TextEncoder } from "node:util"; -import Type, { type Static } from "typebox"; +import lockfile from "proper-lockfile"; +import Type from "typebox"; import { Check } from "typebox/value"; -const EvolutionMemorySchema = Type.Readonly(Type.String({ pattern: "^(?!\\uFEFF)(?=[\\s\\S]*\\S)[\\s\\S]*$" })); -const ContentHashSchema = Type.Readonly(Type.String({ pattern: "^sha256:[0-9a-f]{64}$" })); +const SHA256_PATTERN = "^sha256:[0-9a-f]{64}$"; +const VERSION_DIRECTORY_PATTERN = /^sha256-([0-9a-f]{64})$/; +const EVOLUTION_MEMORY_NAME = "EVOLVE.md"; +const MANIFEST_NAME = "manifest.json"; +const CURRENT_NAME = "current.json"; +const STORE_DIRECTORY_NAME = "harness-state"; +const VERSIONS_DIRECTORY_NAME = "versions"; +const LOCK_NAME = ".harness-state.lock"; +const LOCK_STALE_AFTER_MS = 30_000; +const SUPPORTED_ARTIFACT_KIND = "evolution-memory"; +const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); +const UTF8_ENCODER = new TextEncoder(); -export const HarnessStateSchema = Type.Object({ memory: EvolutionMemorySchema }, { additionalProperties: false }); -export type HarnessState = Static; +export const HarnessVersionSchema = Type.Readonly(Type.String({ pattern: SHA256_PATTERN })); +export type HarnessVersion = string; -const HarnessStateSaveOptionsSchema = Type.Object( - { base: Type.Optional(ContentHashSchema) }, - { additionalProperties: false }, +const NonEmptyEvolutionMemorySchema = Type.String({ pattern: "^(?!\\uFEFF)(?=[\\s\\S]*\\S)[\\s\\S]+$" }); +export const HarnessStateSchema = Type.Readonly( + Type.Object( + { memory: Type.Union([Type.Literal(""), NonEmptyEvolutionMemorySchema]) }, + { additionalProperties: false }, + ), ); -export type HarnessStateSaveOptions = Static; +export interface HarnessState { + readonly memory: string; +} -export const HarnessStateManifestSchema = Type.Object( - { contentHash: ContentHashSchema, base: ContentHashSchema }, - { additionalProperties: false }, +export const HarnessArtifactManifestSchema = Type.Readonly( + Type.Object( + { + kind: Type.String({ minLength: 1 }), + path: Type.String({ minLength: 1 }), + contentHash: HarnessVersionSchema, + }, + { additionalProperties: false }, + ), ); -export type HarnessStateManifest = Static; - -export const HarnessStateBundleSchema = Type.Object( - { - state: Type.Readonly(HarnessStateSchema), - manifest: Type.Readonly(HarnessStateManifestSchema), - }, - { additionalProperties: false }, +export interface HarnessArtifactManifest { + readonly kind: string; + readonly path: string; + readonly contentHash: HarnessVersion; +} + +export const HarnessVersionManifestSchema = Type.Readonly( + Type.Object( + { + schemaVersion: Type.Literal(1), + version: HarnessVersionSchema, + base: Type.Union([Type.Null(), HarnessVersionSchema]), + artifacts: Type.Readonly(Type.Array(HarnessArtifactManifestSchema)), + }, + { additionalProperties: false }, + ), ); -export type HarnessStateBundle = Static; +export interface HarnessVersionManifest { + readonly schemaVersion: 1; + readonly version: HarnessVersion; + readonly base: HarnessVersion | null; + readonly artifacts: readonly HarnessArtifactManifest[]; +} -const MANIFEST_NAME = "manifest.json"; -const EVOLUTION_MEMORY_NAME = "EVOLVE.md"; -const BUNDLE_ENTRY_NAMES = [MANIFEST_NAME, EVOLUTION_MEMORY_NAME] as const; -const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); -const UTF8_ENCODER = new TextEncoder(); +export const HarnessVersionPointerSchema = Type.Readonly( + Type.Object({ version: HarnessVersionSchema }, { additionalProperties: false }), +); +export interface HarnessVersionPointer { + readonly version: HarnessVersion; +} + +export const HarnessVersionBundleSchema = Type.Readonly( + Type.Object( + { + state: HarnessStateSchema, + manifest: HarnessVersionManifestSchema, + }, + { additionalProperties: false }, + ), +); +export interface HarnessVersionBundle { + readonly state: HarnessState; + readonly manifest: HarnessVersionManifest; +} + +export interface HarnessVersionDiagnostic { + readonly path: string; + readonly version: HarnessVersion | null; + readonly message: string; + readonly manifest: HarnessVersionManifest | null; + readonly state: HarnessState | null; +} -function computeContentHash(bytes: Uint8Array): string { +export interface HarnessVersionList { + readonly versions: readonly HarnessVersionBundle[]; + readonly diagnostics: readonly HarnessVersionDiagnostic[]; + readonly currentVersion: HarnessVersion | null; +} + +export interface HarnessAdvanceSuccess { + readonly status: "advanced"; + readonly bundle: HarnessVersionBundle; +} + +export interface HarnessAdvanceConflict { + readonly status: "conflict"; + readonly expectedBase: HarnessVersion; + readonly actualBase: HarnessVersion; +} + +export type HarnessAdvanceResult = HarnessAdvanceSuccess | HarnessAdvanceConflict; + +export interface HarnessVersionInput { + readonly schemaVersion: 1; + readonly base: HarnessVersion | null; + readonly artifacts: readonly HarnessArtifactManifest[]; +} + +function computeContentHash(bytes: Uint8Array): HarnessVersion { return `sha256:${createHash("sha256").update(bytes).digest("hex")}`; } -function decodeBundleFile(bytes: Uint8Array, filePath: string): string { - let value: string; +function compareText(left: string, right: string): number { + if (left < right) return -1; + if (left > right) return 1; + return 0; +} + +function compareArtifacts(left: HarnessArtifactManifest, right: HarnessArtifactManifest): number { + return ( + compareText(left.kind, right.kind) || + compareText(left.path, right.path) || + compareText(left.contentHash, right.contentHash) + ); +} + +export function serializeHarnessVersion(input: HarnessVersionInput): string { + if (input.schemaVersion !== 1 || !Check(Type.Union([Type.Null(), HarnessVersionSchema]), input.base)) { + throw new Error("Invalid harness version input"); + } + if ( + !Array.isArray(input.artifacts) || + input.artifacts.some((artifact) => !Check(HarnessArtifactManifestSchema, artifact)) + ) { + throw new Error("Invalid harness version artifacts"); + } + const artifacts = [...input.artifacts].sort(compareArtifacts).map(({ kind, path, contentHash }) => ({ + kind, + path, + contentHash, + })); + return JSON.stringify({ schemaVersion: 1, base: input.base, artifacts }); +} + +export function computeHarnessVersion(input: HarnessVersionInput): HarnessVersion { + return computeContentHash(UTF8_ENCODER.encode(serializeHarnessVersion(input))); +} + +export function harnessVersionDirectoryName(version: HarnessVersion): string { + if (!Check(HarnessVersionSchema, version)) throw new Error(`Invalid harness version "${version}"`); + return version.replace("sha256:", "sha256-"); +} + +function versionFromDirectoryName(name: string): HarnessVersion | null { + const match = VERSION_DIRECTORY_PATTERN.exec(name); + return match === null ? null : `sha256:${match[1]}`; +} + +function hasErrorCode(error: unknown, code: string): boolean { + return error instanceof Error && "code" in error && error.code === code; +} + +async function pathExists(path: string): Promise { + try { + await lstat(path); + return true; + } catch (error) { + if (hasErrorCode(error, "ENOENT")) return false; + throw error; + } +} + +function decodeUtf8(bytes: Uint8Array, filePath: string): string { + if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) { + throw new Error(`Harness state file "${filePath}" must not start with a UTF-8 BOM`); + } try { - value = UTF8_DECODER.decode(bytes); + return UTF8_DECODER.decode(bytes); } catch (cause) { throw new Error(`Invalid UTF-8 in harness state file "${filePath}"`, { cause }); } - if (value.startsWith("\uFEFF")) throw new Error(`Harness state file "${filePath}" must not start with a UTF-8 BOM`); - return value; } -function parseManifest(value: string, manifestPath: string): HarnessStateManifest { - let parsed: unknown; +function parseJson(value: string, filePath: string): unknown { try { - parsed = JSON.parse(value); + return JSON.parse(value); } catch (cause) { - throw new Error(`Invalid JSON in harness state manifest "${manifestPath}"`, { cause }); + throw new Error(`Invalid JSON in harness state file "${filePath}"`, { cause }); + } +} + +function parseManifest(bytes: Uint8Array, manifestPath: string): HarnessVersionManifest { + const parsed = parseJson(decodeUtf8(bytes, manifestPath), manifestPath); + if (!Check(HarnessVersionManifestSchema, parsed)) { + throw new Error(`Invalid harness version manifest at "${manifestPath}"`); + } + return parsed as HarnessVersionManifest; +} + +function validateArtifactPath(path: string): void { + const segments = path.split("/"); + if ( + path === "" || + path.startsWith("/") || + path.startsWith("\\") || + path.includes("\\") || + /^[A-Za-z]:/.test(path) || + segments.some((segment) => segment === "" || segment === "." || segment === "..") + ) { + throw new Error(`Invalid harness artifact path "${path}"`); } - if (!Check(HarnessStateManifestSchema, parsed)) { - throw new Error(`Invalid harness state manifest at "${manifestPath}"`); + for (const segment of segments) { + if ( + /[<>:"|?*\u0000-\u001f]/.test(segment) || + /[. ]$/.test(segment) || + /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i.test(segment) + ) { + throw new Error(`Harness artifact path "${path}" is not portable across supported platforms`); + } } - return parsed; } -async function inspectBundleDirectory(bundlePath: string): Promise { - const bundleStats = await lstat(bundlePath); - if (!bundleStats.isDirectory() || bundleStats.isSymbolicLink()) { - throw new Error(`Harness state bundle "${bundlePath}" must be a direct directory`); +async function inspectBundleRoot(bundlePath: string): Promise { + const stats = await lstat(bundlePath); + if (!stats.isDirectory() || stats.isSymbolicLink()) { + throw new Error(`Harness version bundle "${bundlePath}" must be a direct directory`); } +} - const entries = await readdir(bundlePath, { withFileTypes: true }); - const expectedNames = new Set(BUNDLE_ENTRY_NAMES); - if (entries.length !== BUNDLE_ENTRY_NAMES.length || entries.some((entry) => !expectedNames.has(entry.name))) { - throw new Error(`Harness state bundle "${bundlePath}" must contain exactly manifest.json and EVOLVE.md`); +interface BundleInventory { + readonly files: readonly string[]; + readonly directories: readonly string[]; +} + +async function collectBundleInventory(bundlePath: string, relativeDirectory = ""): Promise { + const directoryPath = relativeDirectory === "" ? bundlePath : join(bundlePath, ...relativeDirectory.split("/")); + const entries = await readdir(directoryPath, { withFileTypes: true }); + const files: string[] = []; + const directories: string[] = []; + for (const entry of entries) { + const relativePath = relativeDirectory === "" ? entry.name : `${relativeDirectory}/${entry.name}`; + const entryPath = join(directoryPath, entry.name); + const entryStats = await lstat(entryPath); + if (entryStats.isSymbolicLink()) { + throw new Error(`Harness version bundle entry "${entryPath}" must not be a symbolic link`); + } + if (entryStats.isDirectory()) { + directories.push(relativePath); + const nested = await collectBundleInventory(bundlePath, relativePath); + files.push(...nested.files); + directories.push(...nested.directories); + continue; + } + if (!entryStats.isFile()) { + throw new Error(`Harness version bundle entry "${entryPath}" must be a regular file`); + } + files.push(relativePath); } - for (const name of BUNDLE_ENTRY_NAMES) { - const entry = entries.find((candidate) => candidate.name === name); - if (!entry?.isFile() || entry.isSymbolicLink()) { - throw new Error(`Harness state bundle entry "${join(bundlePath, name)}" must be a direct regular file`); + return { files, directories }; +} + +function validateManifestInventory(manifest: HarnessVersionManifest): void { + const paths = new Set(); + const portablePaths = new Set(); + for (const artifact of manifest.artifacts) { + validateArtifactPath(artifact.path); + if (artifact.path === MANIFEST_NAME) { + throw new Error(`Harness artifact path "${artifact.path}" is reserved`); + } + if (paths.has(artifact.path)) throw new Error(`Duplicate harness artifact path "${artifact.path}"`); + const portablePath = artifact.path.normalize("NFC").toLowerCase(); + if (portablePaths.has(portablePath)) { + throw new Error(`Harness artifact path "${artifact.path}" collides on a case-insensitive filesystem`); } + paths.add(artifact.path); + portablePaths.add(portablePath); + } + const computedVersion = computeHarnessVersion(manifest); + if (computedVersion !== manifest.version) { + throw new Error(`Harness version hash mismatch: declared ${manifest.version}, computed ${computedVersion}`); } } -export async function loadHarnessState(bundlePath: string): Promise { - await inspectBundleDirectory(bundlePath); +function validateSupportedArtifacts(manifest: HarnessVersionManifest): void { + const evolutionArtifacts = manifest.artifacts.filter((artifact) => artifact.kind === SUPPORTED_ARTIFACT_KIND); + const unsupported = manifest.artifacts.find((artifact) => artifact.kind !== SUPPORTED_ARTIFACT_KIND); + if (unsupported !== undefined) { + throw new Error(`Unsupported harness artifact kind "${unsupported.kind}"`); + } + if ( + evolutionArtifacts.length !== 1 || + evolutionArtifacts[0]?.path !== EVOLUTION_MEMORY_NAME || + manifest.artifacts.length !== 1 + ) { + throw new Error(`Harness version must declare exactly one ${SUPPORTED_ARTIFACT_KIND} artifact at EVOLVE.md`); + } +} + +export async function loadHarnessState(bundlePath: string): Promise { + await inspectBundleRoot(bundlePath); const manifestPath = join(bundlePath, MANIFEST_NAME); + const manifestStats = await lstat(manifestPath); + if (!manifestStats.isFile() || manifestStats.isSymbolicLink()) { + throw new Error(`Harness version manifest "${manifestPath}" must be a direct regular file`); + } + const manifest = parseManifest(await readFile(manifestPath), manifestPath); + validateManifestInventory(manifest); + + const inventory = await collectBundleInventory(bundlePath); + const files = [...inventory.files].sort(compareText); + const declaredFiles = [MANIFEST_NAME, ...manifest.artifacts.map((artifact) => artifact.path)].sort(compareText); + const declaredDirectories = new Set( + manifest.artifacts.flatMap((artifact) => { + const segments = artifact.path.split("/"); + return segments.slice(0, -1).map((_segment, index) => segments.slice(0, index + 1).join("/")); + }), + ); + if (files.length !== declaredFiles.length || files.some((file, index) => file !== declaredFiles[index])) { + const undeclared = files.filter((file) => !declaredFiles.includes(file)); + const missing = declaredFiles.filter((file) => !files.includes(file)); + const details = [ + undeclared.length === 0 ? "" : ` undeclared: ${undeclared.join(", ")};`, + missing.length === 0 ? "" : ` missing: ${missing.join(", ")};`, + ] + .join("") + .trim(); + throw new Error(`Harness version inventory mismatch at "${bundlePath}"${details === "" ? "" : ` (${details})`}`); + } + const undeclaredDirectories = inventory.directories.filter((path) => !declaredDirectories.has(path)); + if (undeclaredDirectories.length > 0) { + throw new Error( + `Harness version inventory mismatch at "${bundlePath}" (undeclared directories: ${undeclaredDirectories.join(", ")})`, + ); + } + + for (const artifact of manifest.artifacts) { + const artifactPath = join(bundlePath, ...artifact.path.split("/")); + const artifactStats = await lstat(artifactPath); + if (!artifactStats.isFile() || artifactStats.isSymbolicLink()) { + throw new Error(`Harness artifact "${artifactPath}" must be a direct regular file`); + } + const bytes = await readFile(artifactPath); + if (computeContentHash(bytes) !== artifact.contentHash) { + throw new Error(`Harness artifact content hash mismatch at "${artifactPath}"`); + } + } + + validateSupportedArtifacts(manifest); const memoryPath = join(bundlePath, EVOLUTION_MEMORY_NAME); - const manifestText = decodeBundleFile(await readFile(manifestPath), manifestPath); - const manifest = parseManifest(manifestText, manifestPath); - const memoryBytes = await readFile(memoryPath); - const memory = decodeBundleFile(memoryBytes, memoryPath); + const memory = decodeUtf8(await readFile(memoryPath), memoryPath); const state = { memory }; - if (!Check(HarnessStateSchema, state)) throw new Error(`Invalid harness state memory at "${memoryPath}"`); - if (manifest.contentHash !== computeContentHash(memoryBytes)) { - throw new Error(`Harness state content hash mismatch at "${memoryPath}"`); - } + if (!Check(HarnessStateSchema, state)) throw new Error(`Invalid harness evolution memory at "${memoryPath}"`); return { state, manifest }; } -async function assertDestinationAbsent(destinationPath: string): Promise { - try { - await lstat(destinationPath); - } catch (error) { - if (error instanceof Error && "code" in error && error.code === "ENOENT") return; - throw error; +async function assertDirectoryParent(destinationPath: string): Promise { + const parentPath = dirname(destinationPath); + const parentStats = await stat(parentPath); + if (!parentStats.isDirectory()) { + throw new Error(`Harness version destination parent "${parentPath}" must be a directory`); + } +} + +function bundlesMatch(left: HarnessVersionBundle, right: HarnessVersionBundle): boolean { + return left.manifest.version === right.manifest.version && left.state.memory === right.state.memory; +} + +async function loadIdempotentDestination( + destinationPath: string, + expected: HarnessVersionBundle, +): Promise { + const existing = await loadHarnessState(destinationPath); + if (!bundlesMatch(existing, expected)) { + throw new Error(`Immutable harness version destination "${destinationPath}" has different content`); } - throw new Error(`Harness state destination "${destinationPath}" already exists`); + return existing; +} + +async function inspectIncompatibleBundle(bundlePath: string): Promise<{ + readonly manifest: HarnessVersionManifest; + readonly state: HarnessState | null; +}> { + const manifestPath = join(bundlePath, MANIFEST_NAME); + const manifest = parseManifest(await readFile(manifestPath), manifestPath); + const evolutionArtifacts = manifest.artifacts.filter( + (artifact) => artifact.kind === SUPPORTED_ARTIFACT_KIND && artifact.path === EVOLUTION_MEMORY_NAME, + ); + if (evolutionArtifacts.length !== 1) return { manifest, state: null }; + const memoryPath = join(bundlePath, EVOLUTION_MEMORY_NAME); + const memoryBytes = await readFile(memoryPath); + if (computeContentHash(memoryBytes) !== evolutionArtifacts[0]?.contentHash) return { manifest, state: null }; + const state = { memory: decodeUtf8(memoryBytes, memoryPath) }; + return { manifest, state: Check(HarnessStateSchema, state) ? state : null }; } export async function saveHarnessState( state: unknown, destinationPath: string, - options: HarnessStateSaveOptions = {}, -): Promise { + base: HarnessVersion | null, +): Promise { if (!Check(HarnessStateSchema, state)) throw new Error("Invalid harness state to save"); - if (!Check(HarnessStateSaveOptionsSchema, options)) throw new Error("Invalid harness state save options"); + if (!Check(Type.Union([Type.Null(), HarnessVersionSchema]), base)) throw new Error("Invalid harness base version"); const memoryBytes = UTF8_ENCODER.encode(state.memory); - const contentHash = computeContentHash(memoryBytes); - const manifest = { contentHash, base: options.base ?? contentHash }; - const manifestText = `${JSON.stringify(manifest)}\n`; + const artifact: HarnessArtifactManifest = { + kind: SUPPORTED_ARTIFACT_KIND, + path: EVOLUTION_MEMORY_NAME, + contentHash: computeContentHash(memoryBytes), + }; + const version = computeHarnessVersion({ schemaVersion: 1, base, artifacts: [artifact] }); + const manifest: HarnessVersionManifest = { schemaVersion: 1, version, base, artifacts: [artifact] }; + const bundle: HarnessVersionBundle = { state, manifest }; const absoluteDestinationPath = resolve(destinationPath); - const parentPath = dirname(absoluteDestinationPath); - const parentStats = await stat(parentPath); - if (!parentStats.isDirectory()) { - throw new Error(`Harness state destination parent "${parentPath}" must be a directory`); - } - await assertDestinationAbsent(absoluteDestinationPath); + await assertDirectoryParent(absoluteDestinationPath); + if (await pathExists(absoluteDestinationPath)) return loadIdempotentDestination(absoluteDestinationPath, bundle); let temporaryPath: string | undefined; try { - temporaryPath = await mkdtemp(join(parentPath, `.${basename(absoluteDestinationPath)}.harness-state.tmp-`)); - await writeFile(join(temporaryPath, MANIFEST_NAME), manifestText, "utf8"); - await writeFile(join(temporaryPath, EVOLUTION_MEMORY_NAME), memoryBytes); - await assertDestinationAbsent(absoluteDestinationPath); - await rename(temporaryPath, absoluteDestinationPath); - temporaryPath = undefined; + temporaryPath = await mkdtemp( + join(dirname(absoluteDestinationPath), `.${basename(absoluteDestinationPath)}.tmp-`), + ); + await writeFile(join(temporaryPath, MANIFEST_NAME), `${JSON.stringify(manifest, null, 2)}\n`, { + encoding: "utf8", + flag: "wx", + }); + await writeFile(join(temporaryPath, EVOLUTION_MEMORY_NAME), memoryBytes, { flag: "wx" }); + try { + await rename(temporaryPath, absoluteDestinationPath); + temporaryPath = undefined; + } catch (error) { + if (!(await pathExists(absoluteDestinationPath))) throw error; + const existing = await loadIdempotentDestination(absoluteDestinationPath, bundle); + if (temporaryPath !== undefined) await rm(temporaryPath, { recursive: true, force: true }); + temporaryPath = undefined; + return existing; + } } catch (error) { if (temporaryPath !== undefined) { try { @@ -146,5 +457,247 @@ export async function saveHarnessState( } throw error; } - return { state, manifest }; + return bundle; +} + +export class HarnessStateStore { + readonly #agentDir: string; + readonly #storePath: string; + readonly #versionsPath: string; + readonly #currentPath: string; + readonly #lockPath: string; + + constructor(agentDir: string) { + this.#agentDir = resolve(agentDir); + this.#storePath = join(this.#agentDir, STORE_DIRECTORY_NAME); + this.#versionsPath = join(this.#storePath, VERSIONS_DIRECTORY_NAME); + this.#currentPath = join(this.#storePath, CURRENT_NAME); + this.#lockPath = join(this.#agentDir, LOCK_NAME); + } + + async #ensureDirectories(): Promise { + await mkdir(this.#agentDir, { recursive: true }); + await mkdir(this.#storePath, { recursive: true }); + const storeStats = await lstat(this.#storePath); + if (!storeStats.isDirectory() || storeStats.isSymbolicLink()) { + throw new Error(`Harness state path "${this.#storePath}" must be a direct directory`); + } + await mkdir(this.#versionsPath, { recursive: true }); + const versionsStats = await lstat(this.#versionsPath); + if (!versionsStats.isDirectory() || versionsStats.isSymbolicLink()) { + throw new Error(`Harness state path "${this.#versionsPath}" must be a direct directory`); + } + } + + async #withLock(operation: () => Promise): Promise { + await mkdir(this.#agentDir, { recursive: true }); + const release = await lockfile.lock(this.#agentDir, { + lockfilePath: this.#lockPath, + realpath: false, + stale: LOCK_STALE_AFTER_MS, + update: 10_000, + retries: { + retries: 2_000, + factor: 1, + minTimeout: 10, + maxTimeout: 25, + randomize: true, + }, + }); + try { + return await operation(); + } finally { + await release(); + } + } + + #versionPath(version: HarnessVersion): string { + return join(this.#versionsPath, harnessVersionDirectoryName(version)); + } + + async #loadVersion(version: HarnessVersion): Promise { + if (!Check(HarnessVersionSchema, version)) throw new Error(`Invalid harness version "${version}"`); + const bundlePath = this.#versionPath(version); + const bundle = await loadHarnessState(bundlePath); + if (bundle.manifest.version !== version) { + throw new Error(`Harness version directory "${bundlePath}" contains ${bundle.manifest.version}`); + } + return bundle; + } + + async #loadPointer(): Promise { + const pointerStats = await lstat(this.#currentPath); + if (!pointerStats.isFile() || pointerStats.isSymbolicLink()) { + throw new Error(`Harness version pointer "${this.#currentPath}" must be a direct regular file`); + } + const parsed = parseJson(decodeUtf8(await readFile(this.#currentPath), this.#currentPath), this.#currentPath); + if (!Check(HarnessVersionPointerSchema, parsed)) { + throw new Error(`Invalid harness version pointer at "${this.#currentPath}"`); + } + return parsed as HarnessVersionPointer; + } + + async #replacePointer(version: HarnessVersion): Promise { + const temporaryPath = join(this.#storePath, `.${CURRENT_NAME}.${randomUUID()}.tmp`); + let displacedDirectoryPath: string | undefined; + try { + await writeFile(temporaryPath, `${JSON.stringify({ version }, null, 2)}\n`, { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }); + if (await pathExists(this.#currentPath)) { + const currentStats = await lstat(this.#currentPath); + if (currentStats.isDirectory() && !currentStats.isSymbolicLink()) { + displacedDirectoryPath = join(this.#storePath, `.${CURRENT_NAME}.${randomUUID()}.invalid`); + await rename(this.#currentPath, displacedDirectoryPath); + } + } + await rename(temporaryPath, this.#currentPath); + if (displacedDirectoryPath !== undefined) { + await rm(displacedDirectoryPath, { recursive: true, force: true }); + displacedDirectoryPath = undefined; + } + } catch (error) { + try { + await unlink(temporaryPath); + } catch (cleanupError) { + if (!hasErrorCode(cleanupError, "ENOENT")) throw cleanupError; + } + if (displacedDirectoryPath !== undefined && !(await pathExists(this.#currentPath))) { + await rename(displacedDirectoryPath, this.#currentPath); + } + throw error; + } + } + + async initialize(): Promise { + return this.#withLock(async () => { + await this.#ensureDirectories(); + if (await pathExists(this.#currentPath)) { + const pointer = await this.#loadPointer(); + return this.#loadVersion(pointer.version); + } + const root = await this.saveVersion({ memory: "" }, null); + await this.#replacePointer(root.manifest.version); + return root; + }); + } + + async loadCurrent(): Promise { + const pointer = await this.#loadPointer(); + return this.#loadVersion(pointer.version); + } + + async listVersions(): Promise { + let entries: Dirent[]; + try { + entries = await readdir(this.#versionsPath, { withFileTypes: true }); + } catch (error) { + if (hasErrorCode(error, "ENOENT")) entries = []; + else throw error; + } + const versions: HarnessVersionBundle[] = []; + const diagnostics: HarnessVersionDiagnostic[] = []; + for (const entry of [...entries].sort((left, right) => compareText(left.name, right.name))) { + if (/^\.sha256-[0-9a-f]{64}\.tmp-[A-Za-z0-9]{6}$/.test(entry.name)) continue; + const path = join(this.#versionsPath, entry.name); + const inferredVersion = versionFromDirectoryName(entry.name); + try { + if (!entry.isDirectory() || entry.isSymbolicLink()) { + throw new Error(`Harness version path "${path}" must be a direct directory`); + } + if (inferredVersion === null) throw new Error(`Invalid harness version directory name "${entry.name}"`); + versions.push(await this.#loadVersion(inferredVersion)); + } catch (error) { + let incompatible: Awaited> | null = null; + if (error instanceof Error && error.message.startsWith("Unsupported harness artifact kind")) { + try { + incompatible = await inspectIncompatibleBundle(path); + } catch {} + } + diagnostics.push({ + path, + version: inferredVersion, + message: error instanceof Error ? error.message : String(error), + manifest: incompatible?.manifest ?? null, + state: incompatible?.state ?? null, + }); + } + } + versions.sort((left, right) => compareText(left.manifest.version, right.manifest.version)); + let currentVersion: HarnessVersion | null = null; + if (await pathExists(this.#currentPath)) { + try { + const current = await this.#loadPointer(); + currentVersion = current.version; + if ( + !versions.some((bundle) => bundle.manifest.version === current.version) && + !diagnostics.some((diagnostic) => diagnostic.version === current.version) + ) { + diagnostics.push({ + path: this.#currentPath, + version: current.version, + message: `Selected harness version ${current.version} does not exist`, + manifest: null, + state: null, + }); + } + } catch (error) { + diagnostics.push({ + path: this.#currentPath, + version: null, + message: error instanceof Error ? error.message : String(error), + manifest: null, + state: null, + }); + } + } + return { versions, diagnostics, currentVersion }; + } + + async saveVersion(state: HarnessState, base: HarnessVersion | null): Promise { + await this.#ensureDirectories(); + if (!Check(HarnessStateSchema, state)) throw new Error("Invalid harness state to save"); + if (!Check(Type.Union([Type.Null(), HarnessVersionSchema]), base)) + throw new Error("Invalid harness base version"); + const artifact: HarnessArtifactManifest = { + kind: SUPPORTED_ARTIFACT_KIND, + path: EVOLUTION_MEMORY_NAME, + contentHash: computeContentHash(UTF8_ENCODER.encode(state.memory)), + }; + const version = computeHarnessVersion({ schemaVersion: 1, base, artifacts: [artifact] }); + return saveHarnessState(state, this.#versionPath(version), base); + } + + async selectVersion(version: HarnessVersion): Promise { + return this.#withLock(async () => { + await this.#ensureDirectories(); + const bundle = await this.#loadVersion(version); + let current: HarnessVersionPointer | null = null; + if (await pathExists(this.#currentPath)) { + try { + current = await this.#loadPointer(); + } catch {} + } + if (current?.version !== version) await this.#replacePointer(version); + return bundle; + }); + } + + async advance(state: HarnessState, expectedBase: HarnessVersion): Promise { + return this.#withLock(async () => { + await this.#ensureDirectories(); + if (!Check(HarnessStateSchema, state)) throw new Error("Invalid harness state to advance"); + if (!Check(HarnessVersionSchema, expectedBase)) throw new Error("Invalid expected harness base version"); + const current = await this.#loadPointer(); + if (current.version !== expectedBase) { + return { status: "conflict", expectedBase, actualBase: current.version }; + } + await this.#loadVersion(current.version); + const bundle = await this.saveVersion(state, expectedBase); + await this.#replacePointer(bundle.manifest.version); + return { status: "advanced", bundle }; + }); + } } diff --git a/packages/harness-state/test/harness-state.test.ts b/packages/harness-state/test/harness-state.test.ts index 124b6ad7d..c7af064ca 100644 --- a/packages/harness-state/test/harness-state.test.ts +++ b/packages/harness-state/test/harness-state.test.ts @@ -1,31 +1,30 @@ import { createHash } from "node:crypto"; -import { lstat, mkdir, mkdtemp, readdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { lstat, mkdir, mkdtemp, readdir, readFile, rename, rm, symlink, utimes, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; import { Check } from "typebox/value"; import { afterEach, describe, expect, test } from "vitest"; import { + computeHarnessVersion, + type HarnessArtifactManifest, + HarnessArtifactManifestSchema, type HarnessState, - type HarnessStateBundle, - HarnessStateBundleSchema, - type HarnessStateManifest, - HarnessStateManifestSchema, - type HarnessStateSaveOptions, HarnessStateSchema, + HarnessStateStore, + type HarnessVersion, + type HarnessVersionBundle, + HarnessVersionBundleSchema, + type HarnessVersionManifest, + HarnessVersionManifestSchema, + HarnessVersionPointerSchema, + harnessVersionDirectoryName, loadHarnessState, saveHarnessState, + serializeHarnessVersion, } from "../src/index.ts"; -const EXTERNAL_BASE = `sha256:${"0".repeat(64)}`; const temporaryRoots: string[] = []; - -function computeContentHash(memory: string): string { - return `sha256:${createHash("sha256").update(memory, "utf8").digest("hex")}`; -} - -function createManifest(memory: string, base = computeContentHash(memory)): string { - return `${JSON.stringify({ contentHash: computeContentHash(memory), base })}\n`; -} +const EXTERNAL_VERSION = `sha256:${"0".repeat(64)}`; afterEach(async () => { await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); @@ -37,17 +36,40 @@ async function createTemporaryRoot(): Promise { return root; } -async function createBundle( - root: string, - name = "state", - manifest: string | Uint8Array = createManifest("Prefer focused changes.\n"), - memory: string | Uint8Array = "Prefer focused changes.\n", -): Promise { - const bundlePath = join(root, name); - await mkdir(bundlePath); - await writeFile(join(bundlePath, "manifest.json"), manifest); - await writeFile(join(bundlePath, "EVOLVE.md"), memory); - return bundlePath; +function hash(bytes: string | Uint8Array): HarnessVersion { + return `sha256:${createHash("sha256").update(bytes).digest("hex")}`; +} + +function createArtifact( + bytes: string | Uint8Array, + kind = "evolution-memory", + path = "EVOLVE.md", +): HarnessArtifactManifest { + return { kind, path, contentHash: hash(bytes) }; +} + +function createManifest( + artifacts: readonly HarnessArtifactManifest[], + base: HarnessVersion | null = null, +): HarnessVersionManifest { + return { + schemaVersion: 1, + version: computeHarnessVersion({ schemaVersion: 1, base, artifacts }), + base, + artifacts: [...artifacts], + }; +} + +async function writeRawBundle( + bundlePath: string, + manifest: HarnessVersionManifest, + files: Readonly>, +): Promise { + await mkdir(bundlePath, { recursive: true }); + await writeFile(join(bundlePath, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`); + for (const [path, content] of Object.entries(files)) { + await writeFile(join(bundlePath, path), content); + } } async function pathExists(path: string): Promise { @@ -62,143 +84,112 @@ async function pathExists(path: string): Promise { function assertReadonlyTypes( state: HarnessState, - manifest: HarnessStateManifest, - options: HarnessStateSaveOptions, - bundle: HarnessStateBundle, + artifact: HarnessArtifactManifest, + manifest: HarnessVersionManifest, + bundle: HarnessVersionBundle, ): void { // @ts-expect-error HarnessState fields are readonly. state.memory = "changed"; - // @ts-expect-error HarnessStateManifest fields are readonly. - manifest.contentHash = "sha256:changed"; - // @ts-expect-error HarnessStateManifest fields are readonly. - manifest.base = "sha256:changed"; - // @ts-expect-error HarnessStateSaveOptions fields are readonly. - options.base = "sha256:changed"; - // @ts-expect-error HarnessStateBundle fields are readonly. + // @ts-expect-error HarnessArtifactManifest fields are readonly. + artifact.path = "changed"; + // @ts-expect-error HarnessVersionManifest fields are readonly. + manifest.version = EXTERNAL_VERSION; + // @ts-expect-error HarnessVersionBundle fields are readonly. bundle.state = state; - // @ts-expect-error HarnessStateBundle fields are readonly. - bundle.manifest = manifest; } void assertReadonlyTypes; -describe("schemas", () => { - test("exports strict schemas with readonly inferred fields", () => { - const state = { memory: "Focused changes produced better results." }; - const manifest = { contentHash: computeContentHash(state.memory), base: EXTERNAL_BASE }; - expect(Check(HarnessStateSchema, state)).toBe(true); - expect(Check(HarnessStateManifestSchema, manifest)).toBe(true); - expect(Check(HarnessStateBundleSchema, { state, manifest })).toBe(true); - expect(Check(HarnessStateSchema, { memory: "Prefer focused changes.", extra: true })).toBe(false); - expect(Check(HarnessStateManifestSchema, { ...manifest, extra: true })).toBe(false); - expect(Check(HarnessStateBundleSchema, { state, manifest, extra: true })).toBe(false); - }); +describe("canonical harness versions", () => { + test("hashes the exact canonical schema, base, and sorted artifact descriptors", () => { + const first = createArtifact("first", "future-kind", "nested/z.txt"); + const second = createArtifact("second", "evolution-memory", "EVOLVE.md"); + const input = { schemaVersion: 1 as const, base: EXTERNAL_VERSION, artifacts: [first, second] }; + const canonical = serializeHarnessVersion(input); - test.each([ - ["blank memory", { memory: "" }], - ["whitespace-only memory", { memory: " \t\r\n" }], - ["leading U+FEFF memory", { memory: "\uFEFFObservation." }], - ] as const)("rejects %s", (_label, state) => { - expect(Check(HarnessStateSchema, state)).toBe(false); + expect(canonical).toBe(JSON.stringify({ schemaVersion: 1, base: EXTERNAL_VERSION, artifacts: [second, first] })); + expect(computeHarnessVersion(input)).toBe(hash(canonical)); + expect(computeHarnessVersion({ ...input, artifacts: [second, first] })).toBe(computeHarnessVersion(input)); }); - test.each([ - ["wrong algorithm", { contentHash: "sha1:0123456789abcdef0123456789abcdef01234567" }], - ["short digest", { contentHash: "sha256:0123456789abcdef" }], - ["uppercase digest", { contentHash: "sha256:0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF" }], - ["missing content hash", {}], - ] as const)("rejects %s", (_label, manifest) => { - expect(Check(HarnessStateManifestSchema, { base: EXTERNAL_BASE, ...manifest })).toBe(false); + test("changes independently for base, bytes, and artifact metadata", () => { + const original = createArtifact("memory"); + const version = computeHarnessVersion({ schemaVersion: 1, base: null, artifacts: [original] }); + + expect(computeHarnessVersion({ schemaVersion: 1, base: EXTERNAL_VERSION, artifacts: [original] })).not.toBe( + version, + ); + expect(computeHarnessVersion({ schemaVersion: 1, base: null, artifacts: [createArtifact("changed")] })).not.toBe( + version, + ); + expect( + computeHarnessVersion({ + schemaVersion: 1, + base: null, + artifacts: [{ ...original, kind: "future-kind" }], + }), + ).not.toBe(version); + expect( + computeHarnessVersion({ + schemaVersion: 1, + base: null, + artifacts: [{ ...original, path: "nested/EVOLVE.md" }], + }), + ).not.toBe(version); }); - test.each([ - ["missing base", { contentHash: computeContentHash("Work.") }], - ["wrong base algorithm", { contentHash: computeContentHash("Work."), base: "sha1:0123456789abcdef" }], - ["short base", { contentHash: computeContentHash("Work."), base: "sha256:0123456789abcdef" }], - ["uppercase base", { contentHash: computeContentHash("Work."), base: `sha256:${"A".repeat(64)}` }], - ] as const)("rejects a manifest with %s", (_label, manifest) => { - expect(Check(HarnessStateManifestSchema, manifest)).toBe(false); + test("exports strict schemas", () => { + const artifact = createArtifact(""); + const manifest = createManifest([artifact]); + const bundle = { state: { memory: "" }, manifest }; + + expect(Check(HarnessStateSchema, { memory: "" })).toBe(true); + expect(Check(HarnessStateSchema, { memory: " \t\n" })).toBe(false); + expect(Check(HarnessStateSchema, { memory: "\uFEFFmemory" })).toBe(false); + expect(Check(HarnessStateSchema, { memory: "memory", extra: true })).toBe(false); + expect(Check(HarnessArtifactManifestSchema, artifact)).toBe(true); + expect(Check(HarnessArtifactManifestSchema, { ...artifact, extra: true })).toBe(false); + expect(Check(HarnessVersionManifestSchema, manifest)).toBe(true); + expect(Check(HarnessVersionManifestSchema, { ...manifest, extra: true })).toBe(false); + expect(Check(HarnessVersionPointerSchema, { version: manifest.version })).toBe(true); + expect(Check(HarnessVersionPointerSchema, { version: manifest.version, extra: true })).toBe(false); + expect(Check(HarnessVersionBundleSchema, bundle)).toBe(true); }); }); -describe("saving and round trips", () => { +describe("bundle persistence and validation", () => { test.each([ - ["Unicode", "Inspect caf\u00e9, \u6c34, and \u{10151}."], - ["LF", "First line\nSecond line\n"], - ["CRLF", "First line\r\nSecond line\r\n"], - ["multiline", "One\n\nThree\nFour"], - ["trailing whitespace", "Keep this exactly. \n\t "], - ] as const)("preserves %s memory content verbatim", async (_label, memory) => { + ["empty", ""], + ["Unicode", "Inspect café, 水, and 𐅑."], + ["CRLF", "First\r\nSecond\r\n"], + ["trailing whitespace", "Keep exactly. \n\t "], + ] as const)("preserves %s EVOLVE bytes", async (_label, memory) => { const root = await createTemporaryRoot(); - const destination = join(root, "saved-state"); - const state = { memory }; - const manifest = { contentHash: computeContentHash(memory), base: EXTERNAL_BASE }; - const saved = await saveHarnessState(state, destination, { base: EXTERNAL_BASE }); + const destination = join(root, "bundle"); + const saved = await saveHarnessState({ memory }, destination, EXTERNAL_VERSION); - expect(await readFile(join(destination, "manifest.json"), "utf8")).toBe(createManifest(memory, EXTERNAL_BASE)); expect(await readFile(join(destination, "EVOLVE.md"), "utf8")).toBe(memory); - expect(saved).toEqual({ state, manifest }); - expect(await loadHarnessState(destination)).toEqual(saved); - }); - - test("uses the computed content hash as the base for a root state", async () => { - const root = await createTemporaryRoot(); - const destination = join(root, "root-state"); - const state = { memory: "Initial evolution notes.\n" }; - const contentHash = computeContentHash(state.memory); - - const saved = await saveHarnessState(state, destination); - - expect(saved).toEqual({ state, manifest: { contentHash, base: contentHash } }); - expect(await readFile(join(destination, "manifest.json"), "utf8")).toBe(createManifest(state.memory)); + expect(saved.manifest.base).toBe(EXTERNAL_VERSION); + expect(saved.manifest.artifacts).toEqual([createArtifact(memory)]); + expect(saved.manifest.version).toBe(computeHarnessVersion(saved.manifest)); expect(await loadHarnessState(destination)).toEqual(saved); }); - test("validates state before filesystem access", async () => { - const root = await createTemporaryRoot(); - const destination = join(root, "missing-parent", "state"); - await expect(saveHarnessState({ memory: " " }, destination)).rejects.toThrow(/invalid harness state/i); - expect(await pathExists(destination)).toBe(false); - }); - - test("validates an explicit base before filesystem access", async () => { - const root = await createTemporaryRoot(); - const destination = join(root, "missing-parent", "state"); - await expect( - saveHarnessState({ memory: "Valid observation." }, destination, { base: "sha256:short" }), - ).rejects.toThrow(/invalid harness state save options/i); - expect(await pathExists(destination)).toBe(false); - }); - - test("requires an existing directory parent", async () => { - const root = await createTemporaryRoot(); - const parentFile = join(root, "parent-file"); - await writeFile(parentFile, "not a directory"); - await expect(saveHarnessState({ memory: "Valid observation." }, join(parentFile, "state"))).rejects.toThrow(); - }); - - test.each(["file", "directory"] as const)("refuses to overwrite an existing %s destination", async (kind) => { + test("is idempotent only for the same immutable revision", async () => { const root = await createTemporaryRoot(); - const destination = join(root, "state"); - if (kind === "file") await writeFile(destination, "original"); - else await mkdir(destination); + const destination = join(root, "bundle"); + const first = await saveHarnessState({ memory: "same" }, destination, null); + const second = await saveHarnessState({ memory: "same" }, destination, null); + expect(second).toEqual(first); - await expect(saveHarnessState({ memory: "Replacement notes." }, destination)).rejects.toThrow(/already exists/i); - if (kind === "file") expect(await readFile(destination, "utf8")).toBe("original"); - else expect(await readdir(destination)).toEqual([]); + await expect(saveHarnessState({ memory: "different" }, destination, null)).rejects.toThrow(/different content/i); + expect((await loadHarnessState(destination)).state.memory).toBe("same"); }); - test("refuses a dangling linked destination", async () => { + test("cleans its staged directory after a destination race", async () => { const root = await createTemporaryRoot(); - const destination = join(root, "state"); - await symlink(join(root, "missing-target"), destination, process.platform === "win32" ? "junction" : "dir"); - await expect(saveHarnessState({ memory: "Replacement notes." }, destination)).rejects.toThrow(/already exists/i); - }); - - test("cleans its staged directory after a late failure without exposing a partial bundle", async () => { - const root = await createTemporaryRoot(); - const destination = join(root, "state"); - const temporaryPrefix = `.${basename(destination)}.harness-state.tmp-`; - const saving = saveHarnessState({ memory: "x".repeat(16 * 1024 * 1024) }, destination).then( + const destination = join(root, "bundle"); + const temporaryPrefix = `.${basename(destination)}.tmp-`; + const saving = saveHarnessState({ memory: "x".repeat(16 * 1024 * 1024) }, destination, null).then( () => ({ ok: true as const }), (error: unknown) => ({ ok: false as const, error }), ); @@ -218,143 +209,329 @@ describe("saving and round trips", () => { await writeFile(join(destination, "unrelated"), "preserved"); const result = await saving; expect(result.ok).toBe(false); - if (result.ok) throw new Error("Expected the concurrent destination to make saving fail"); - expect(result.error).toBeInstanceOf(Error); - expect(await readdir(destination)).toEqual(["unrelated"]); + expect(await readFile(join(destination, "unrelated"), "utf8")).toBe("preserved"); expect((await readdir(root)).filter((entry) => entry.startsWith(temporaryPrefix))).toEqual([]); }); -}); -describe("bundle layout", () => { - test("rejects a missing root", async () => { + test.each([" \t\r\n", "\uFEFFmemory"])("rejects invalid memory %j before writing", async (memory) => { const root = await createTemporaryRoot(); - await expect(loadHarnessState(join(root, "missing"))).rejects.toThrow(); + const destination = join(root, "bundle"); + await expect(saveHarnessState({ memory }, destination, null)).rejects.toThrow(/invalid harness state/i); + expect(await pathExists(destination)).toBe(false); }); - test("rejects a non-directory root", async () => { + test("rejects an undeclared file and exact-casing mismatch", async () => { const root = await createTemporaryRoot(); - const bundlePath = join(root, "state"); - await writeFile(bundlePath, "not a directory"); - await expect(loadHarnessState(bundlePath)).rejects.toThrow(/direct directory/i); + const destination = join(root, "bundle"); + await saveHarnessState({ memory: "memory" }, destination, null); + await writeFile(join(destination, "extra.txt"), "extra"); + await expect(loadHarnessState(destination)).rejects.toThrow(/inventory mismatch.*undeclared/i); + await rm(join(destination, "extra.txt")); + await rename(join(destination, "EVOLVE.md"), join(destination, "evolve.md")); + await expect(loadHarnessState(destination)).rejects.toThrow(/inventory mismatch/i); }); - test("rejects a linked root", async () => { + test("rejects an undeclared empty directory", async () => { const root = await createTemporaryRoot(); - const target = await createBundle(root, "target"); - const bundlePath = join(root, "linked-state"); - await symlink(target, bundlePath, process.platform === "win32" ? "junction" : "dir"); - await expect(loadHarnessState(bundlePath)).rejects.toThrow(/direct directory/i); + const destination = join(root, "bundle"); + await saveHarnessState({ memory: "memory" }, destination, null); + await mkdir(join(destination, "undeclared")); + await expect(loadHarnessState(destination)).rejects.toThrow(/undeclared directories/i); }); - test.each(["manifest.json", "EVOLVE.md"] as const)("rejects a missing %s entry", async (missingName) => { + test.each([ + "../outside", + "/absolute", + "nested\\file", + "C:/drive", + "nested//file", + "CON", + "nested/a:b", + "trailing.", + "trailing ", + "wild*card", + ])("rejects traversal or non-portable artifact path %j", async (artifactPath) => { const root = await createTemporaryRoot(); - const bundlePath = await createBundle(root); - await rm(join(bundlePath, missingName)); - await expect(loadHarnessState(bundlePath)).rejects.toThrow(/exactly manifest\.json and EVOLVE\.md/i); + const artifact = createArtifact("memory", "evolution-memory", artifactPath); + const manifest = createManifest([artifact]); + const destination = join(root, "bundle"); + await mkdir(destination); + await writeFile(join(destination, "manifest.json"), JSON.stringify(manifest)); + await expect(loadHarnessState(destination)).rejects.toThrow(/harness artifact path/i); }); - test("rejects additional entries", async () => { + test("rejects artifact paths that collide on case-insensitive filesystems", async () => { const root = await createTemporaryRoot(); - const bundlePath = await createBundle(root); - await writeFile(join(bundlePath, "extra.txt"), "extra"); - await expect(loadHarnessState(bundlePath)).rejects.toThrow(/exactly manifest\.json and EVOLVE\.md/i); + const destination = join(root, "bundle"); + const manifest = createManifest([ + createArtifact("first", "evolution-memory", "EVOLVE.md"), + createArtifact("second", "future-skill", "evolve.md"), + ]); + await mkdir(destination); + await writeFile(join(destination, "manifest.json"), JSON.stringify(manifest)); + + await expect(loadHarnessState(destination)).rejects.toThrow(/case-insensitive filesystem/i); }); - test("rejects mis-cased entries", async () => { + test("rejects duplicate paths and unsupported artifact kinds", async () => { const root = await createTemporaryRoot(); - const bundlePath = join(root, "state"); - await mkdir(bundlePath); - await writeFile(join(bundlePath, "MANIFEST.json"), createManifest("Valid.")); - await writeFile(join(bundlePath, "EVOLVE.md"), "Valid."); - await expect(loadHarnessState(bundlePath)).rejects.toThrow(/exactly manifest\.json and EVOLVE\.md/i); + const duplicatePath = join(root, "duplicate"); + const duplicateArtifact = createArtifact("memory"); + await writeRawBundle(duplicatePath, createManifest([duplicateArtifact, duplicateArtifact]), { + "EVOLVE.md": "memory", + }); + await expect(loadHarnessState(duplicatePath)).rejects.toThrow(/duplicate/i); + + const unsupportedPath = join(root, "unsupported"); + const unsupported = createArtifact("memory", "future-skill"); + await writeRawBundle(unsupportedPath, createManifest([unsupported]), { "EVOLVE.md": "memory" }); + await expect(loadHarnessState(unsupportedPath)).rejects.toThrow(/unsupported.*future-skill/i); }); - test.each(["manifest.json", "EVOLVE.md"] as const)("rejects a linked %s entry", async (linkedName) => { + test("rejects symbolic links and non-regular artifact entries", async () => { const root = await createTemporaryRoot(); - const bundlePath = await createBundle(root); - await rm(join(bundlePath, linkedName)); - const linkTarget = join(root, `${linkedName}-target`); - await mkdir(linkTarget); - await symlink(linkTarget, join(bundlePath, linkedName), process.platform === "win32" ? "junction" : "dir"); - await expect(loadHarnessState(bundlePath)).rejects.toThrow(/direct regular file/i); + const destination = join(root, "bundle"); + await saveHarnessState({ memory: "memory" }, destination, null); + await rm(join(destination, "EVOLVE.md")); + await writeFile(join(root, "outside"), "memory"); + await symlink(join(root, "outside"), join(destination, "EVOLVE.md")); + await expect(loadHarnessState(destination)).rejects.toThrow(/symbolic link/i); + + await rm(join(destination, "EVOLVE.md")); + await mkdir(join(destination, "EVOLVE.md")); + await expect(loadHarnessState(destination)).rejects.toThrow(/inventory mismatch|regular file/i); }); - test.each(["manifest.json", "EVOLVE.md"] as const)("rejects a non-regular %s entry", async (entryName) => { + test("rejects content hash and revision hash mismatches", async () => { const root = await createTemporaryRoot(); - const bundlePath = await createBundle(root); - await rm(join(bundlePath, entryName)); - await mkdir(join(bundlePath, entryName)); - await expect(loadHarnessState(bundlePath)).rejects.toThrow(/direct regular file/i); + const destination = join(root, "bundle"); + await saveHarnessState({ memory: "memory" }, destination, null); + await writeFile(join(destination, "EVOLVE.md"), "changed"); + await expect(loadHarnessState(destination)).rejects.toThrow(/content hash mismatch/i); + + const manifestPath = join(destination, "manifest.json"); + const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as HarnessVersionManifest; + await writeFile(join(destination, "EVOLVE.md"), "memory"); + await writeFile(manifestPath, JSON.stringify({ ...manifest, version: EXTERNAL_VERSION })); + await expect(loadHarnessState(destination)).rejects.toThrow(/version hash mismatch/i); }); -}); -describe("bundle data validation", () => { - test.each(["manifest.json", "EVOLVE.md"] as const)("rejects malformed UTF-8 in %s", async (entryName) => { + test.each([ + ["malformed UTF-8", new Uint8Array([0xc3, 0x28]), /UTF-8/i], + ["BOM", new Uint8Array([0xef, 0xbb, 0xbf, 0x61]), /BOM/i], + ["whitespace-only", new TextEncoder().encode(" \t\n"), /evolution memory/i], + ] as const)("rejects %s EVOLVE bytes", async (_label, bytes, expected) => { const root = await createTemporaryRoot(); - const bundlePath = await createBundle(root); - await writeFile(join(bundlePath, entryName), new Uint8Array([0xc3, 0x28])); - await expect(loadHarnessState(bundlePath)).rejects.toThrow(new RegExp(entryName.replace(".", "\\."), "i")); + const destination = join(root, "bundle"); + const manifest = createManifest([createArtifact(bytes)]); + await writeRawBundle(destination, manifest, { "EVOLVE.md": bytes }); + await expect(loadHarnessState(destination)).rejects.toThrow(expected); }); - test.each(["manifest.json", "EVOLVE.md"] as const)("rejects a UTF-8 BOM in %s", async (entryName) => { + test("rejects malformed, BOM-prefixed, and non-strict manifests", async () => { const root = await createTemporaryRoot(); - const bundlePath = await createBundle(root); - const validContent = - entryName === "manifest.json" ? createManifest("Prefer focused changes.\n") : "Valid memory."; + const destination = join(root, "bundle"); + await mkdir(destination); + await writeFile(join(destination, "EVOLVE.md"), "memory"); + await writeFile(join(destination, "manifest.json"), "{not-json}"); + await expect(loadHarnessState(destination)).rejects.toThrow(/invalid JSON/i); + await writeFile(join(destination, "manifest.json"), new Uint8Array([0xef, 0xbb, 0xbf, 0x7b, 0x7d])); + await expect(loadHarnessState(destination)).rejects.toThrow(/BOM/i); await writeFile( - join(bundlePath, entryName), - Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from(validContent)]), + join(destination, "manifest.json"), + JSON.stringify({ ...createManifest([createArtifact("memory")]), extra: true }), ); - await expect(loadHarnessState(bundlePath)).rejects.toThrow(/BOM/i); + await expect(loadHarnessState(destination)).rejects.toThrow(/invalid harness version manifest/i); }); +}); - test("wraps malformed manifest JSON with its path and syntax error cause", async () => { - const root = await createTemporaryRoot(); - const bundlePath = await createBundle(root, "state", "{not-json}\n"); - const manifestPath = join(bundlePath, "manifest.json"); - let thrown: unknown; - try { - await loadHarnessState(bundlePath); - } catch (error) { - thrown = error; - } - expect(thrown).toBeInstanceOf(Error); - expect((thrown as Error).message).toContain(manifestPath); - expect((thrown as Error).cause).toBeInstanceOf(SyntaxError); +describe("HarnessStateStore", () => { + test("initializes and selects a portable empty root idempotently", async () => { + const agentDir = await createTemporaryRoot(); + const stores = Array.from({ length: 8 }, () => new HarnessStateStore(agentDir)); + const roots = await Promise.all(stores.map((store) => store.initialize())); + const root = roots[0]!; + const versionDirectory = harnessVersionDirectoryName(root.manifest.version); + + expect(roots.every((candidate) => candidate.manifest.version === root.manifest.version)).toBe(true); + expect(root.state.memory).toBe(""); + expect(root.manifest.base).toBeNull(); + expect(root.manifest.artifacts[0]?.contentHash).toBe( + "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + ); + expect(versionDirectory).toMatch(/^sha256-[0-9a-f]{64}$/); + expect(await readdir(join(agentDir, "harness-state", "versions"))).toEqual([versionDirectory]); + expect(JSON.parse(await readFile(join(agentDir, "harness-state", "current.json"), "utf8"))).toEqual({ + version: root.manifest.version, + }); + expect(await stores[0]!.loadCurrent()).toEqual(root); }); - test.each([ - [ - "wrong algorithm", - `{"contentHash":"sha1:0123456789abcdef0123456789abcdef01234567","base":"${EXTERNAL_BASE}"}\n`, - ], - ["unknown field", `${createManifest("Act carefully.\n").trimEnd().slice(0, -1)},"extra":true}\n`], - ["missing content hash", `{"base":"${EXTERNAL_BASE}"}\n`], - ["missing base", `{"contentHash":"${computeContentHash("Act carefully.\n")}"}\n`], - [ - "wrong base algorithm", - `{"contentHash":"${computeContentHash("Act carefully.\n")}","base":"sha1:0123456789abcdef"}\n`, - ], - ["non-object", "[]\n"], - ] as const)("rejects a manifest with %s", async (_label, manifest) => { - const root = await createTemporaryRoot(); - const bundlePath = await createBundle(root, "state", manifest); - await expect(loadHarnessState(bundlePath)).rejects.toThrow(/manifest/i); + test("advances lineage, including identical content, and reports stale-base conflicts", async () => { + const agentDir = await createTemporaryRoot(); + const store = new HarnessStateStore(agentDir); + const root = await store.initialize(); + const first = await store.advance({ memory: "learned" }, root.manifest.version); + expect(first.status).toBe("advanced"); + if (first.status !== "advanced") throw new Error("Expected first advance to succeed"); + expect(first.bundle.manifest.base).toBe(root.manifest.version); + + const second = await store.advance({ memory: "learned" }, first.bundle.manifest.version); + expect(second.status).toBe("advanced"); + if (second.status !== "advanced") throw new Error("Expected second advance to succeed"); + expect(second.bundle.manifest.version).not.toBe(first.bundle.manifest.version); + expect(second.bundle.manifest.base).toBe(first.bundle.manifest.version); + + expect(await store.advance({ memory: "stale" }, root.manifest.version)).toEqual({ + status: "conflict", + expectedBase: root.manifest.version, + actualBase: second.bundle.manifest.version, + }); + expect((await store.loadCurrent()).manifest.version).toBe(second.bundle.manifest.version); }); - test.each([ - ["blank", ""], - ["whitespace-only", " \t\r\n"], - ["leading U+FEFF", "\uFEFFObservation."], - ] as const)("rejects a %s memory", async (_label, memory) => { - const root = await createTemporaryRoot(); - const bundlePath = await createBundle(root, "state", createManifest(String(memory)), memory); - await expect(loadHarnessState(bundlePath)).rejects.toThrow(/EVOLVE\.md/i); + test("serializes concurrent advances so exactly one succeeds", async () => { + const agentDir = await createTemporaryRoot(); + const firstStore = new HarnessStateStore(agentDir); + const secondStore = new HarnessStateStore(agentDir); + const root = await firstStore.initialize(); + const results = await Promise.all([ + firstStore.advance({ memory: "first" }, root.manifest.version), + secondStore.advance({ memory: "second" }, root.manifest.version), + ]); + expect(results.filter((result) => result.status === "advanced")).toHaveLength(1); + expect(results.filter((result) => result.status === "conflict")).toHaveLength(1); }); - test("rejects memory content that does not match the manifest hash", async () => { - const root = await createTemporaryRoot(); - const bundlePath = await createBundle(root, "state", createManifest("Original."), "Changed."); - await expect(loadHarnessState(bundlePath)).rejects.toThrow(/content hash mismatch/i); + test("recovers a stale lock whose owner is no longer valid", async () => { + const agentDir = await createTemporaryRoot(); + const lockPath = join(agentDir, ".harness-state.lock"); + await mkdir(lockPath); + const staleTime = new Date(Date.now() - 60_000); + await utimes(lockPath, staleTime, staleTime); + + await expect(new HarnessStateStore(agentDir).initialize()).resolves.toMatchObject({ state: { memory: "" } }); + expect(await pathExists(lockPath)).toBe(false); + }); + + test("saves without selecting and atomically switches a validated revision", async () => { + const agentDir = await createTemporaryRoot(); + const store = new HarnessStateStore(agentDir); + const root = await store.initialize(); + const branch = await store.saveVersion({ memory: "branch" }, root.manifest.version); + expect((await store.loadCurrent()).manifest.version).toBe(root.manifest.version); + + expect(await store.selectVersion(branch.manifest.version)).toEqual(branch); + expect((await store.loadCurrent()).manifest.version).toBe(branch.manifest.version); + expect((await readdir(join(agentDir, "harness-state"))).filter((name) => name.includes(".tmp"))).toEqual([]); + }); + + test("leaves invalid current state untouched", async () => { + const agentDir = await createTemporaryRoot(); + const store = new HarnessStateStore(agentDir); + await store.initialize(); + const pointerPath = join(agentDir, "harness-state", "current.json"); + await writeFile(pointerPath, '{"version":"sha256:missing","extra":true}\n'); + const before = await readFile(pointerPath); + + await expect(store.loadCurrent()).rejects.toThrow(/invalid harness version pointer/i); + await expect(store.initialize()).rejects.toThrow(/invalid harness version pointer/i); + expect(await readFile(pointerPath)).toEqual(before); + }); + + test("allows explicit selection to repair an invalid pointer", async () => { + const agentDir = await createTemporaryRoot(); + const store = new HarnessStateStore(agentDir); + const root = await store.initialize(); + const pointerPath = join(agentDir, "harness-state", "current.json"); + await writeFile(pointerPath, "not JSON\n"); + + expect(await store.selectVersion(root.manifest.version)).toEqual(root); + expect(await store.loadCurrent()).toEqual(root); + }); + + test("allows explicit selection to repair a directory at current.json", async () => { + const agentDir = await createTemporaryRoot(); + const store = new HarnessStateStore(agentDir); + const root = await store.initialize(); + const pointerPath = join(agentDir, "harness-state", "current.json"); + await rm(pointerPath); + await mkdir(pointerPath); + await writeFile(join(pointerPath, "invalid-entry"), "invalid pointer directory"); + + expect(await store.selectVersion(root.manifest.version)).toEqual(root); + expect(await store.loadCurrent()).toEqual(root); + }); + + test("rejects and preserves an incompatible selected bundle", async () => { + const agentDir = await createTemporaryRoot(); + const store = new HarnessStateStore(agentDir); + await store.initialize(); + const artifact = createArtifact("future", "future-skill"); + const manifest = createManifest([artifact]); + const bundlePath = join(agentDir, "harness-state", "versions", harnessVersionDirectoryName(manifest.version)); + await writeRawBundle(bundlePath, manifest, { "EVOLVE.md": "future" }); + const pointerPath = join(agentDir, "harness-state", "current.json"); + await writeFile(pointerPath, `${JSON.stringify({ version: manifest.version })}\n`); + const before = await readFile(pointerPath); + + await expect(store.loadCurrent()).rejects.toThrow(/unsupported harness artifact kind/i); + expect(await readFile(pointerPath)).toEqual(before); + }); + + test("lists compatible versions and per-path diagnostics deterministically", async () => { + const agentDir = await createTemporaryRoot(); + const store = new HarnessStateStore(agentDir); + const root = await store.initialize(); + const branch = await store.saveVersion({ memory: "branch" }, root.manifest.version); + const versionsPath = join(agentDir, "harness-state", "versions"); + await mkdir(join(versionsPath, "not-a-version")); + const corruptVersion = `sha256:${"f".repeat(64)}`; + await mkdir(join(versionsPath, harnessVersionDirectoryName(corruptVersion))); + + const listed = await store.listVersions(); + expect(listed.versions.map((bundle) => bundle.manifest.version).sort()).toEqual( + [root.manifest.version, branch.manifest.version].sort(), + ); + expect(listed.diagnostics).toHaveLength(2); + expect(listed.diagnostics.map((diagnostic) => diagnostic.version)).toContain(corruptVersion); + expect(listed.diagnostics.map((diagnostic) => diagnostic.version)).toContain(null); + }); + + test("does not expose owned staging directories and diagnoses a missing selected version", async () => { + const agentDir = await createTemporaryRoot(); + const store = new HarnessStateStore(agentDir); + await store.initialize(); + const versionsPath = join(agentDir, "harness-state", "versions"); + await mkdir(join(versionsPath, `.sha256-${"a".repeat(64)}.tmp-Ab12Cd`)); + await mkdir(join(versionsPath, `.sha256-${"c".repeat(64)}.tmp-persistent-corrupt`)); + const missingVersion = `sha256:${"b".repeat(64)}`; + await writeFile( + join(agentDir, "harness-state", "current.json"), + `${JSON.stringify({ version: missingVersion })}\n`, + ); + + const listed = await store.listVersions(); + expect(listed.currentVersion).toBe(missingVersion); + expect(listed.diagnostics).toHaveLength(2); + expect(listed.diagnostics.some((diagnostic) => diagnostic.version === missingVersion)).toBe(true); + expect(listed.diagnostics.some((diagnostic) => diagnostic.message.match(/does not exist/i))).toBe(true); + }); + + test("retains validated lineage and EVOLVE preview data for an incompatible revision", async () => { + const agentDir = await createTemporaryRoot(); + const store = new HarnessStateStore(agentDir); + const root = await store.initialize(); + const artifacts = [createArtifact("preview memory"), createArtifact("future data", "future-skill", "skill.txt")]; + const manifest = createManifest(artifacts, root.manifest.version); + const bundlePath = join(agentDir, "harness-state", "versions", harnessVersionDirectoryName(manifest.version)); + await writeRawBundle(bundlePath, manifest, { "EVOLVE.md": "preview memory", "skill.txt": "future data" }); + + const diagnostic = (await store.listVersions()).diagnostics.find((entry) => entry.version === manifest.version); + expect(diagnostic).toMatchObject({ + manifest, + state: { memory: "preview memory" }, + }); + expect(diagnostic?.message).toMatch(/unsupported harness artifact kind/i); }); }); diff --git a/scripts/local-release.mjs b/scripts/local-release.mjs index 7953633b5..9e2f9a873 100644 --- a/scripts/local-release.mjs +++ b/scripts/local-release.mjs @@ -6,6 +6,7 @@ import { isAbsolute, join, relative, resolve } from "node:path"; import { spawnSync } from "node:child_process"; const packages = [ + { directory: "packages/harness-state", name: "@earendil-works/diffex-harness-state" }, { directory: "packages/telemetry", name: "@earendil-works/diffex-telemetry" }, { directory: "packages/ai", name: "@earendil-works/diffex-ai" }, { directory: "packages/tui", name: "@earendil-works/diffex-tui" }, diff --git a/scripts/profile-coding-agent-node.mjs b/scripts/profile-coding-agent-node.mjs index b452889f6..490fa3d04 100644 --- a/scripts/profile-coding-agent-node.mjs +++ b/scripts/profile-coding-agent-node.mjs @@ -279,7 +279,9 @@ async function waitForExit(child, errorPrefix) { } async function runBuild() { - process.stdout.write("Building packages/tui, packages/telemetry, packages/ai, packages/agent, and packages/coding-agent...\n"); + process.stdout.write( + "Building packages/harness-state, packages/tui, packages/telemetry, packages/ai, packages/agent, and packages/coding-agent...\n", + ); const startedAt = performance.now(); const child = spawn( "npm", @@ -287,6 +289,8 @@ async function runBuild() { "run", "build", "--workspace", + "packages/harness-state", + "--workspace", "packages/tui", "--workspace", "packages/telemetry", diff --git a/tsconfig.json b/tsconfig.json index e0593e724..4b90fa29a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,7 @@ "moduleResolution": "NodeNext", "paths": { "*": ["./*"], - "@diffexai/diffex-harness-state": ["./packages/harness-state/src/index.ts"], + "@earendil-works/diffex-harness-state": ["./packages/harness-state/src/index.ts"], "@earendil-works/diffex-telemetry": ["./packages/telemetry/src/index.ts"], "@earendil-works/diffex-telemetry/testing": ["./packages/telemetry/src/testing/index.ts"], "@earendil-works/diffex-ai": ["./packages/ai/src/index.ts"], diff --git a/vitest.base.ts b/vitest.base.ts index 7837ccc88..b4916fee8 100644 --- a/vitest.base.ts +++ b/vitest.base.ts @@ -9,6 +9,7 @@ export const workspaceSourcePaths = { aiOAuth: fileURLToPath(new URL("./packages/ai/src/oauth.ts", import.meta.url)), aiProviders: fileURLToPath(new URL("./packages/ai/src/providers", import.meta.url)), agentIndex: fileURLToPath(new URL("./packages/agent/src/index.ts", import.meta.url)), + harnessStateIndex: fileURLToPath(new URL("./packages/harness-state/src/index.ts", import.meta.url)), codingAgentIndex: fileURLToPath(new URL("./packages/coding-agent/src/index.ts", import.meta.url)), tuiIndex: fileURLToPath(new URL("./packages/tui/src/index.ts", import.meta.url)), } as const; @@ -26,6 +27,7 @@ export default defineConfig({ replacement: `${workspaceSourcePaths.aiProviders}/$1.ts`, }, { find: /^@earendil-works\/diffex-agent-core$/, replacement: workspaceSourcePaths.agentIndex }, + { find: /^@earendil-works\/diffex-harness-state$/, replacement: workspaceSourcePaths.harnessStateIndex }, { find: /^@earendil-works\/diffex-tui$/, replacement: workspaceSourcePaths.tuiIndex }, ], },