Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@
],
"scripts": {
"clean": "npm run clean --workspaces",
"build": "cd packages/tui && npm run build && cd ../telemetry && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../session-backends/sqlite-node && npm run build && cd ../../protocol && npm run build && cd ../client && npm run build && cd ../server && npm run build && cd ../coding-agent && npm run build",
"build:offline": "cd packages/tui && npm run build && cd ../telemetry && npm run build && cd ../ai && npm run build:offline && cd ../agent && npm run build && cd ../session-backends/sqlite-node && npm run build && cd ../../protocol && npm run build && cd ../client && npm run build && cd ../server && npm run build && cd ../coding-agent && npm run build",
"build": "cd packages/harness-state && npm run build && cd ../tui && npm run build && cd ../telemetry && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../session-backends/sqlite-node && npm run build && cd ../../protocol && npm run build && cd ../client && npm run build && cd ../server && npm run build && cd ../coding-agent && npm run build",
"build:offline": "cd packages/harness-state && npm run build && cd ../tui && npm run build && cd ../telemetry && npm run build && cd ../ai && npm run build:offline && cd ../agent && npm run build && cd ../session-backends/sqlite-node && npm run build && cd ../../protocol && npm run build && cd ../client && npm run build && cd ../server && npm run build && cd ../coding-agent && npm run build",
"check": "biome check --write --error-on-warnings . && npm run check:pinned-deps && npm run check:ts-imports && npm run check:shrinkwrap && npm run check:install-lock:coding-agent && tsgo --noEmit && npm run check:browser-smoke",
"check:browser-smoke": "node scripts/check-browser-smoke.mjs",
"check:pinned-deps": "node scripts/check-pinned-deps.mjs",
Expand Down
37 changes: 37 additions & 0 deletions packages/harness-state/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# `@diffexai/diffex-harness-state`

Private workspace package for the Diffex harness-state schema and directory-bundle persistence.

## State and bundle format

A logical state contains only Diffex's evolving memory:

```ts
{
memory: string;
}
```

The memory consists of notes collected throughout the evolution process to improve Diffex's behavior. It must not start with U+FEFF and must contain a non-whitespace character. A portable bundle root is a direct directory, not a symbolic link, with exactly these direct regular files and exact casing:

```text
state-directory/
|-- manifest.json # SHA-256 identity and base-version metadata
`-- EVOLVE.md # memory notes as UTF-8 bytes, preserved verbatim
```

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`.

## API

- `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.

The package does not include or select a default harness. Callers must provide an explicit bundle path or state.

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.

## Limitations

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.
34 changes: 34 additions & 0 deletions packages/harness-state/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"name": "@diffexai/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",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist",
"README.md"
],
"scripts": {
"clean": "shx rm -rf dist",
"build": "tsgo -p tsconfig.build.json && shx rm -rf dist/default",
"test": "vitest --run"
},
"engines": {
"node": ">=22.19.0"
},
"dependencies": {
"typebox": "1.3.7"
},
"devDependencies": {
"shx": "0.4.0",
"vitest": "4.1.9"
}
}
150 changes: 150 additions & 0 deletions packages/harness-state/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { createHash } from "node:crypto";
import { lstat, mkdtemp, readdir, readFile, rename, rm, stat, 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 { 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}$" }));

export const HarnessStateSchema = Type.Object({ memory: EvolutionMemorySchema }, { additionalProperties: false });
export type HarnessState = Static<typeof HarnessStateSchema>;

const HarnessStateSaveOptionsSchema = Type.Object(
{ base: Type.Optional(ContentHashSchema) },
{ additionalProperties: false },
);
export type HarnessStateSaveOptions = Static<typeof HarnessStateSaveOptionsSchema>;

export const HarnessStateManifestSchema = Type.Object(
{ contentHash: ContentHashSchema, base: ContentHashSchema },
{ additionalProperties: false },
);
export type HarnessStateManifest = Static<typeof HarnessStateManifestSchema>;

export const HarnessStateBundleSchema = Type.Object(
{
state: Type.Readonly(HarnessStateSchema),
manifest: Type.Readonly(HarnessStateManifestSchema),
},
{ additionalProperties: false },
);
export type HarnessStateBundle = Static<typeof HarnessStateBundleSchema>;

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();

function computeContentHash(bytes: Uint8Array): string {
return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
}

function decodeBundleFile(bytes: Uint8Array, filePath: string): string {
let value: string;
try {
value = 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;
try {
parsed = JSON.parse(value);
} catch (cause) {
throw new Error(`Invalid JSON in harness state manifest "${manifestPath}"`, { cause });
}
if (!Check(HarnessStateManifestSchema, parsed)) {
throw new Error(`Invalid harness state manifest at "${manifestPath}"`);
}
return parsed;
}

async function inspectBundleDirectory(bundlePath: string): Promise<void> {
const bundleStats = await lstat(bundlePath);
if (!bundleStats.isDirectory() || bundleStats.isSymbolicLink()) {
throw new Error(`Harness state bundle "${bundlePath}" must be a direct directory`);
}

const entries = await readdir(bundlePath, { withFileTypes: true });
const expectedNames = new Set<string>(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`);
}
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`);
}
}
}

export async function loadHarnessState(bundlePath: string): Promise<HarnessStateBundle> {
await inspectBundleDirectory(bundlePath);
const manifestPath = join(bundlePath, MANIFEST_NAME);
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 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}"`);
}
return { state, manifest };
}

async function assertDestinationAbsent(destinationPath: string): Promise<void> {
try {
await lstat(destinationPath);
} catch (error) {
if (error instanceof Error && "code" in error && error.code === "ENOENT") return;
throw error;
}
throw new Error(`Harness state destination "${destinationPath}" already exists`);
}

export async function saveHarnessState(
state: unknown,
destinationPath: string,
options: HarnessStateSaveOptions = {},
): Promise<HarnessStateBundle> {
if (!Check(HarnessStateSchema, state)) throw new Error("Invalid harness state to save");
if (!Check(HarnessStateSaveOptionsSchema, options)) throw new Error("Invalid harness state save options");
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 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);

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;
} catch (error) {
if (temporaryPath !== undefined) {
try {
await rm(temporaryPath, { recursive: true, force: true });
} catch {}
}
throw error;
}
return { state, manifest };
}
Loading
Loading