refactor(agent): isolate node filesystem session dependencies

This commit is contained in:
Mario Zechner 2026-05-15 00:53:33 +02:00
parent 0b54c87e24
commit 80c918c247
25 changed files with 340 additions and 257 deletions

View file

@ -387,10 +387,11 @@ Implemented so far:
- Added generic `Result<TValue, TError>` plus helpers.
- Updated `ExecutionEnv` and `NodeExecutionEnv` to return typed results for filesystem/process operations.
- Split filesystem/shell capabilities and moved JSONL session storage/repo onto filesystem picks instead of direct Node imports.
- Added `ExecutionEnv.appendFile()` for streaming append use cases.
- Updated skill and prompt-template loaders to consume `ExecutionEnv` results.
- Updated shell output capture to return a result and use `ExecutionEnv` instead of Node APIs directly, including full-output spill via `appendFile()`.
- Removed `NodeExecutionEnv` from the browser-safe `execution-env.ts` re-export; Node-specific callers import from `harness/env/nodejs.js`.
- Removed `NodeExecutionEnv` from browser-safe root exports; Node-specific callers use the `node` entry point or `harness/env/nodejs.js`.
- Replaced `Buffer` usage in generic truncation utilities with runtime-neutral UTF-8 handling.
- Converted compaction summary helpers to typed result returns and added error-path coverage.
- Expanded `NodeExecutionEnv` tests for file operations, exec errors, aborts, callbacks, timeouts, and shell-output full-output spill.
@ -402,7 +403,7 @@ Still needed:
- Convert structural `AgentHarness` operations to typed result returns for busy, missing-resource, auth, compaction, and branch-summary failures.
- Keep Node-specific APIs isolated under `src/harness/env/nodejs.ts` and Node-backed storage/session implementations, or move those implementations behind explicit Node-only entry points.
- Audit remaining generic harness utilities for Node globals as new APIs are added.
- Audit package exports so browser/generic-JS imports do not pull Node-only modules such as `NodeExecutionEnv` or JSONL storage.
- Audit package exports so browser/generic-JS imports do not pull Node-only modules such as `NodeExecutionEnv`.
- Keep expanding `ExecutionEnv` and shell-output contract tests as the API evolves, especially for non-Node implementations.
- Add tests proving harness APIs return `ok: false` instead of throwing for expected failure paths.

View file

@ -5,6 +5,17 @@
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./node": {
"types": "./dist/node.d.ts",
"import": "./dist/node.js"
},
"./package.json": "./package.json"
},
"files": [
"dist",
"README.md"

View file

@ -5,7 +5,7 @@
* a summary of the branch being left so context isn't lost.
*/
import type { ImageContent, Model, TextContent } from "@earendil-works/pi-ai";
import type { Model } from "@earendil-works/pi-ai";
import { completeSimple } from "@earendil-works/pi-ai";
import type { AgentMessage } from "../../types.js";
import {
@ -148,16 +148,10 @@ function getMessageFromEntry(entry: SessionTreeEntry): AgentMessage | undefined
case "message":
// Skip tool results - context is in assistant's tool call
if (entry.message.role === "toolResult") return undefined;
return entry.message as AgentMessage;
return entry.message;
case "custom_message":
return createCustomMessage(
entry.customType,
entry.content as string | (TextContent | ImageContent)[],
entry.display,
entry.details,
entry.timestamp,
);
return createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp);
case "branch_summary":
return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp);

View file

@ -218,6 +218,20 @@ export class NodeExecutionEnv implements ExecutionEnv {
this.shellEnv = options.shellEnv;
}
async absolutePath(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>> {
const resolved = resolvePath(this.cwd, path);
const aborted = abortResult<string>(abortSignal, resolved);
if (aborted) return aborted;
return ok(resolved);
}
async joinPath(parts: string[], abortSignal?: AbortSignal): Promise<Result<string, FileError>> {
const joined = join(...parts);
const aborted = abortResult<string>(abortSignal, joined);
if (aborted) return aborted;
return ok(joined);
}
async exec(
command: string,
options?: {
@ -438,7 +452,7 @@ export class NodeExecutionEnv implements ExecutionEnv {
}
}
async realPath(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>> {
async canonicalPath(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>> {
const resolved = resolvePath(this.cwd, path);
const aborted = abortResult<string>(abortSignal, resolved);
if (aborted) return aborted;

View file

@ -1,10 +0,0 @@
export type {
ExecutionEnv,
ExecutionEnvExecOptions,
ExecutionErrorCode,
FileErrorCode,
FileInfo,
FileKind,
Result,
} from "./types.js";
export { ExecutionError, err, FileError, getOrThrow, getOrUndefined, ok } from "./types.js";

View file

@ -148,9 +148,9 @@ async function loadTemplateFromFile(
async function resolveKind(env: ExecutionEnv, info: FileInfo): Promise<"file" | "directory" | undefined> {
if (info.kind === "file" || info.kind === "directory") return info.kind;
const realPath = await env.realPath(info.path);
if (!realPath.ok) return undefined;
const target = getOrUndefined(await env.fileInfo(realPath.value));
const canonicalPath = await env.canonicalPath(info.path);
if (!canonicalPath.ok) return undefined;
const target = getOrUndefined(await env.fileInfo(canonicalPath.value));
if (!target) return undefined;
return target.kind === "file" || target.kind === "directory" ? target.kind : undefined;
}

View file

@ -0,0 +1,138 @@
import type {
FileSystem,
JsonlSessionCreateOptions,
JsonlSessionListOptions,
JsonlSessionMetadata,
JsonlSessionRepoApi,
Session,
} from "../types.js";
import { getOrThrow } from "../types.js";
import { JsonlSessionStorage, loadJsonlSessionMetadata } from "./jsonl-storage.js";
import { createSessionId, createTimestamp, getEntriesToFork, toSession } from "./repo-utils.js";
type JsonlSessionRepoFileSystem = Pick<
FileSystem,
| "cwd"
| "absolutePath"
| "joinPath"
| "readTextFile"
| "writeFile"
| "appendFile"
| "listDir"
| "exists"
| "createDir"
| "remove"
>;
function encodeCwd(cwd: string): string {
return `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
}
export class JsonlSessionRepo implements JsonlSessionRepoApi {
private readonly fs: JsonlSessionRepoFileSystem;
private readonly sessionsRootInput: string;
private sessionsRoot: string | undefined;
constructor(options: { fs: JsonlSessionRepoFileSystem; sessionsRoot: string }) {
this.fs = options.fs;
this.sessionsRootInput = options.sessionsRoot;
}
private async getSessionsRoot(): Promise<string> {
if (!this.sessionsRoot) {
this.sessionsRoot = getOrThrow(await this.fs.absolutePath(this.sessionsRootInput));
}
return this.sessionsRoot;
}
private async getSessionDir(cwd: string): Promise<string> {
return getOrThrow(await this.fs.joinPath([await this.getSessionsRoot(), encodeCwd(cwd)]));
}
private async createSessionFilePath(cwd: string, sessionId: string, timestamp: string): Promise<string> {
return getOrThrow(
await this.fs.joinPath([
await this.getSessionDir(cwd),
`${timestamp.replace(/[:.]/g, "-")}_${sessionId}.jsonl`,
]),
);
}
async create(options: JsonlSessionCreateOptions): Promise<Session<JsonlSessionMetadata>> {
const id = options.id ?? createSessionId();
const createdAt = createTimestamp();
const sessionDir = await this.getSessionDir(options.cwd);
getOrThrow(await this.fs.createDir(sessionDir, { recursive: true }));
const filePath = await this.createSessionFilePath(options.cwd, id, createdAt);
const storage = await JsonlSessionStorage.create(this.fs, filePath, {
cwd: options.cwd,
sessionId: id,
parentSessionPath: options.parentSessionPath,
});
return toSession(storage);
}
async open(metadata: JsonlSessionMetadata): Promise<Session<JsonlSessionMetadata>> {
if (!getOrThrow(await this.fs.exists(metadata.path))) {
throw new Error(`Session not found: ${metadata.path}`);
}
const storage = await JsonlSessionStorage.open(this.fs, metadata.path);
return toSession(storage);
}
async list(options: JsonlSessionListOptions = {}): Promise<JsonlSessionMetadata[]> {
const dirs = options.cwd ? [await this.getSessionDir(options.cwd)] : await this.listSessionDirs();
const sessions: JsonlSessionMetadata[] = [];
for (const dir of dirs) {
if (!getOrThrow(await this.fs.exists(dir))) continue;
const files = getOrThrow(await this.fs.listDir(dir)).filter(
(file) => file.kind !== "directory" && file.name.endsWith(".jsonl"),
);
for (const file of files) {
try {
sessions.push(await loadJsonlSessionMetadata(this.fs, file.path));
} catch {
// Ignore invalid session files when listing a directory.
}
}
}
sessions.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
return sessions;
}
async delete(metadata: JsonlSessionMetadata): Promise<void> {
getOrThrow(await this.fs.remove(metadata.path, { force: true }));
}
async fork(
sourceMetadata: JsonlSessionMetadata,
options: JsonlSessionCreateOptions & { entryId?: string; position?: "before" | "at"; id?: string },
): Promise<Session<JsonlSessionMetadata>> {
const source = await this.open(sourceMetadata);
const forkedEntries = await getEntriesToFork(source.getStorage(), options);
const id = options.id ?? createSessionId();
const createdAt = createTimestamp();
const sessionDir = await this.getSessionDir(options.cwd);
getOrThrow(await this.fs.createDir(sessionDir, { recursive: true }));
const storage = await JsonlSessionStorage.create(
this.fs,
await this.createSessionFilePath(options.cwd, id, createdAt),
{
cwd: options.cwd,
sessionId: id,
parentSessionPath: options.parentSessionPath ?? sourceMetadata.path,
},
);
for (const entry of forkedEntries) {
await storage.appendEntry(entry);
}
return toSession(storage);
}
private async listSessionDirs(): Promise<string[]> {
const sessionsRoot = await this.getSessionsRoot();
if (!getOrThrow(await this.fs.exists(sessionsRoot))) return [];
const entries = getOrThrow(await this.fs.listDir(sessionsRoot));
return entries.filter((entry) => entry.kind === "directory").map((entry) => entry.path);
}
}

View file

@ -1,9 +1,8 @@
import { randomUUID } from "node:crypto";
import { createReadStream } from "node:fs";
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { createInterface } from "node:readline";
import type { JsonlSessionMetadata, SessionStorage, SessionTreeEntry } from "../../types.js";
import type { FileSystem, JsonlSessionMetadata, SessionStorage, SessionTreeEntry } from "../types.js";
import { getOrThrow } from "../types.js";
import { uuidv7 } from "./uuid.js";
type JsonlSessionStorageFileSystem = Pick<FileSystem, "readTextFile" | "writeFile" | "appendFile">;
interface SessionHeader {
type: "session";
@ -34,10 +33,10 @@ function buildLabelsById(entries: SessionTreeEntry[]): Map<string, string> {
function generateEntryId(byId: { has(id: string): boolean }): string {
for (let i = 0; i < 100; i++) {
const id = randomUUID().slice(0, 8);
const id = uuidv7().slice(0, 8);
if (!byId.has(id)) return id;
}
return randomUUID();
return uuidv7();
}
function headerToSessionMetadata(header: SessionHeader, path: string): JsonlSessionMetadata {
@ -50,32 +49,32 @@ function headerToSessionMetadata(header: SessionHeader, path: string): JsonlSess
};
}
export async function loadJsonlSessionMetadata(filePath: string): Promise<JsonlSessionMetadata> {
const stream = createReadStream(filePath, { encoding: "utf8" });
const lines = createInterface({ input: stream, crlfDelay: Infinity });
try {
for await (const line of lines) {
if (!line.trim()) break;
try {
const header = JSON.parse(line) as SessionHeader;
return headerToSessionMetadata(header, resolve(filePath));
} catch {
throw new Error(`Invalid JSONL session file ${filePath}: first line is not a valid session header`);
}
export async function loadJsonlSessionMetadata(
fs: JsonlSessionStorageFileSystem,
filePath: string,
): Promise<JsonlSessionMetadata> {
const content = getOrThrow(await fs.readTextFile(filePath));
for (const line of content.split("\n")) {
if (!line.trim()) break;
try {
const header = JSON.parse(line) as SessionHeader;
return headerToSessionMetadata(header, filePath);
} catch {
throw new Error(`Invalid JSONL session file ${filePath}: first line is not a valid session header`);
}
throw new Error(`Invalid JSONL session file ${filePath}: missing session header`);
} finally {
lines.close();
stream.destroy();
}
throw new Error(`Invalid JSONL session file ${filePath}: missing session header`);
}
async function loadJsonlStorage(filePath: string): Promise<{
async function loadJsonlStorage(
fs: JsonlSessionStorageFileSystem,
filePath: string,
): Promise<{
header: SessionHeader;
entries: SessionTreeEntry[];
leafId: string | null;
}> {
const content = await readFile(filePath, "utf8");
const content = getOrThrow(await fs.readTextFile(filePath));
const lines = content.split("\n").filter((line) => line.trim());
if (lines.length === 0) {
throw new Error(`Invalid JSONL session file ${filePath}: missing session header`);
@ -103,6 +102,7 @@ async function loadJsonlStorage(filePath: string): Promise<{
}
export class JsonlSessionStorage implements SessionStorage<JsonlSessionMetadata> {
private readonly fs: JsonlSessionStorageFileSystem;
private readonly filePath: string;
private readonly metadata: JsonlSessionMetadata;
private entries: SessionTreeEntry[];
@ -110,8 +110,15 @@ export class JsonlSessionStorage implements SessionStorage<JsonlSessionMetadata>
private labelsById: Map<string, string>;
private currentLeafId: string | null;
private constructor(filePath: string, header: SessionHeader, entries: SessionTreeEntry[], leafId: string | null) {
this.filePath = resolve(filePath);
private constructor(
fs: JsonlSessionStorageFileSystem,
filePath: string,
header: SessionHeader,
entries: SessionTreeEntry[],
leafId: string | null,
) {
this.fs = fs;
this.filePath = filePath;
this.metadata = headerToSessionMetadata(header, this.filePath);
this.entries = entries;
this.byId = new Map(entries.map((entry) => [entry.id, entry]));
@ -119,13 +126,13 @@ export class JsonlSessionStorage implements SessionStorage<JsonlSessionMetadata>
this.currentLeafId = leafId;
}
static async open(filePath: string): Promise<JsonlSessionStorage> {
const resolvedPath = resolve(filePath);
const loaded = await loadJsonlStorage(resolvedPath);
return new JsonlSessionStorage(resolvedPath, loaded.header, loaded.entries, loaded.leafId);
static async open(fs: JsonlSessionStorageFileSystem, filePath: string): Promise<JsonlSessionStorage> {
const loaded = await loadJsonlStorage(fs, filePath);
return new JsonlSessionStorage(fs, filePath, loaded.header, loaded.entries, loaded.leafId);
}
static async create(
fs: JsonlSessionStorageFileSystem,
filePath: string,
options: {
cwd: string;
@ -133,7 +140,6 @@ export class JsonlSessionStorage implements SessionStorage<JsonlSessionMetadata>
parentSessionPath?: string;
},
): Promise<JsonlSessionStorage> {
const resolvedPath = resolve(filePath);
const header: SessionHeader = {
type: "session",
version: 3,
@ -142,9 +148,8 @@ export class JsonlSessionStorage implements SessionStorage<JsonlSessionMetadata>
cwd: options.cwd,
parentSession: options.parentSessionPath,
};
await mkdir(dirname(resolvedPath), { recursive: true });
await writeFile(resolvedPath, `${JSON.stringify(header)}\n`);
return new JsonlSessionStorage(resolvedPath, header, [], null);
getOrThrow(await fs.writeFile(filePath, `${JSON.stringify(header)}\n`));
return new JsonlSessionStorage(fs, filePath, header, [], null);
}
async getMetadata(): Promise<JsonlSessionMetadata> {
@ -167,7 +172,7 @@ export class JsonlSessionStorage implements SessionStorage<JsonlSessionMetadata>
}
async appendEntry(entry: SessionTreeEntry): Promise<void> {
await appendFile(this.filePath, `${JSON.stringify(entry)}\n`);
getOrThrow(await this.fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`));
this.entries.push(entry);
this.byId.set(entry.id, entry);
updateLabelCache(this.labelsById, entry);

View file

@ -1,6 +1,6 @@
import type { Session, SessionMetadata, SessionRepo } from "../../types.js";
import { InMemorySessionStorage } from "../storage/memory.js";
import { createSessionId, createTimestamp, getEntriesToFork, toSession } from "./shared.js";
import type { Session, SessionMetadata, SessionRepo } from "../types.js";
import { InMemorySessionStorage } from "./memory-storage.js";
import { createSessionId, createTimestamp, getEntriesToFork, toSession } from "./repo-utils.js";
export class InMemorySessionRepo implements SessionRepo<SessionMetadata, { id?: string }, void> {
private sessions = new Map<string, Session<SessionMetadata>>();

View file

@ -1,6 +1,5 @@
import { randomUUID } from "node:crypto";
import type { SessionMetadata, SessionStorage, SessionTreeEntry } from "../../types.js";
import { uuidv7 } from "../uuid.js";
import type { SessionMetadata, SessionStorage, SessionTreeEntry } from "../types.js";
import { uuidv7 } from "./uuid.js";
function updateLabelCache(labelsById: Map<string, string>, entry: SessionTreeEntry): void {
if (entry.type !== "label") return;
@ -22,10 +21,10 @@ function buildLabelsById(entries: SessionTreeEntry[]): Map<string, string> {
function generateEntryId(byId: { has(id: string): boolean }): string {
for (let i = 0; i < 100; i++) {
const id = randomUUID().slice(0, 8);
const id = uuidv7().slice(0, 8);
if (!byId.has(id)) return id;
}
return randomUUID();
return uuidv7();
}
export class InMemorySessionStorage implements SessionStorage {

View file

@ -1,6 +1,6 @@
import type { SessionMetadata, SessionStorage, SessionTreeEntry } from "../../types.js";
import { Session } from "../session.js";
import { uuidv7 } from "../uuid.js";
import type { SessionMetadata, SessionStorage, SessionTreeEntry } from "../types.js";
import { Session } from "./session.js";
import { uuidv7 } from "./uuid.js";
export function createSessionId(): string {
return uuidv7();

View file

@ -1,109 +0,0 @@
import { constants } from "node:fs";
import { access, mkdir, readdir, rm } from "node:fs/promises";
import { join, resolve } from "node:path";
import type {
JsonlSessionCreateOptions,
JsonlSessionListOptions,
JsonlSessionMetadata,
JsonlSessionRepoApi,
Session,
} from "../../types.js";
import { JsonlSessionStorage, loadJsonlSessionMetadata } from "../storage/jsonl.js";
import { createSessionId, createTimestamp, getEntriesToFork, toSession } from "./shared.js";
async function exists(path: string): Promise<boolean> {
try {
await access(path, constants.F_OK);
return true;
} catch {
return false;
}
}
function encodeCwd(cwd: string): string {
return `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
}
export class JsonlSessionRepo implements JsonlSessionRepoApi {
private sessionsRoot: string;
constructor(options: { sessionsRoot: string }) {
this.sessionsRoot = resolve(options.sessionsRoot);
}
private getSessionDir(cwd: string): string {
return join(this.sessionsRoot, encodeCwd(cwd));
}
private createSessionFilePath(cwd: string, sessionId: string, timestamp: string): string {
return join(this.getSessionDir(cwd), `${timestamp.replace(/[:.]/g, "-")}_${sessionId}.jsonl`);
}
async create(options: JsonlSessionCreateOptions): Promise<Session<JsonlSessionMetadata>> {
await mkdir(this.sessionsRoot, { recursive: true });
const id = options.id ?? createSessionId();
const createdAt = createTimestamp();
const filePath = this.createSessionFilePath(options.cwd, id, createdAt);
const storage = await JsonlSessionStorage.create(filePath, {
cwd: options.cwd,
sessionId: id,
parentSessionPath: options.parentSessionPath,
});
return toSession(storage);
}
async open(metadata: JsonlSessionMetadata): Promise<Session<JsonlSessionMetadata>> {
if (!(await exists(metadata.path))) {
throw new Error(`Session not found: ${metadata.path}`);
}
const storage = await JsonlSessionStorage.open(metadata.path);
return toSession(storage);
}
async list(options: JsonlSessionListOptions = {}): Promise<JsonlSessionMetadata[]> {
const dirs = options.cwd ? [this.getSessionDir(options.cwd)] : await this.listSessionDirs();
const sessions: JsonlSessionMetadata[] = [];
for (const dir of dirs) {
if (!(await exists(dir))) continue;
const files = (await readdir(dir)).filter((file) => file.endsWith(".jsonl")).map((file) => join(dir, file));
for (const filePath of files) {
try {
sessions.push(await loadJsonlSessionMetadata(filePath));
} catch {
// Ignore invalid session files when listing a directory.
}
}
}
sessions.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
return sessions;
}
async delete(metadata: JsonlSessionMetadata): Promise<void> {
await rm(metadata.path, { force: true });
}
async fork(
sourceMetadata: JsonlSessionMetadata,
options: JsonlSessionCreateOptions & { entryId?: string; position?: "before" | "at"; id?: string },
): Promise<Session<JsonlSessionMetadata>> {
const source = await this.open(sourceMetadata);
const forkedEntries = await getEntriesToFork(source.getStorage(), options);
const id = options.id ?? createSessionId();
const createdAt = createTimestamp();
const storage = await JsonlSessionStorage.create(this.createSessionFilePath(options.cwd, id, createdAt), {
cwd: options.cwd,
sessionId: id,
parentSessionPath: options.parentSessionPath ?? sourceMetadata.path,
});
for (const entry of forkedEntries) {
await storage.appendEntry(entry);
}
return toSession(storage);
}
private async listSessionDirs(): Promise<string[]> {
if (!(await exists(this.sessionsRoot))) return [];
const entries = await readdir(this.sessionsRoot, { withFileTypes: true });
return entries.filter((entry) => entry.isDirectory()).map((entry) => join(this.sessionsRoot, entry.name));
}
}

View file

@ -1,10 +1,20 @@
import { randomBytes } from "node:crypto";
let lastTimestamp = -Infinity;
let sequence = 0;
function fillRandomBytes(bytes: Uint8Array): void {
const crypto = globalThis.crypto;
if (crypto?.getRandomValues) {
crypto.getRandomValues(bytes);
return;
}
for (let i = 0; i < bytes.length; i++) {
bytes[i] = Math.floor(Math.random() * 256);
}
}
export function uuidv7(): string {
const random = randomBytes(16);
const random = new Uint8Array(16);
fillRandomBytes(random);
const timestamp = Date.now();
if (timestamp > lastTimestamp) {

View file

@ -261,9 +261,9 @@ function parseFrontmatter<T extends Record<string, unknown>>(
async function resolveKind(env: ExecutionEnv, info: FileInfo): Promise<"file" | "directory" | undefined> {
if (info.kind === "file" || info.kind === "directory") return info.kind;
const realPath = await env.realPath(info.path);
if (!realPath.ok) return undefined;
const target = getOrUndefined(await env.fileInfo(realPath.value));
const canonicalPath = await env.canonicalPath(info.path);
if (!canonicalPath.ok) return undefined;
const target = getOrUndefined(await env.fileInfo(canonicalPath.value));
if (!target) return undefined;
return target.kind === "file" || target.kind === "directory" ? target.kind : undefined;
}

View file

@ -93,10 +93,10 @@ export interface AgentHarnessStreamOptionsPatch
metadata?: Record<string, unknown | undefined>;
}
/** Kind of filesystem object as addressed by an {@link ExecutionEnv}. Symlinks are not followed automatically. */
/** Kind of filesystem object as addressed by a {@link FileSystem}. Symlinks are not followed automatically. */
export type FileKind = "file" | "directory" | "symlink";
/** Stable, backend-independent file error codes returned by {@link ExecutionEnv} file operations. */
/** Stable, backend-independent file error codes returned by {@link FileSystem} file operations. */
export type FileErrorCode =
| "aborted"
| "not_found"
@ -107,7 +107,7 @@ export type FileErrorCode =
| "not_supported"
| "unknown";
/** Error returned by {@link ExecutionEnv} file operations. */
/** Error returned by {@link FileSystem} file operations. */
export class FileError extends Error {
constructor(
/** Backend-independent error code. */
@ -154,13 +154,13 @@ export class CompactionError extends Error {
}
}
/** Metadata for one filesystem object in an {@link ExecutionEnv}. */
/** Metadata for one filesystem object in a {@link FileSystem}. */
export interface FileInfo {
/** Basename of {@link path}. */
name: string;
/** Absolute, syntactically normalized addressed path in the execution environment. Symlinks are not followed. */
path: string;
/** Object kind. Symlink targets are not followed; use {@link ExecutionEnv.resolvePath} explicitly. */
/** Object kind. Symlink targets are not followed; use {@link FileSystem.canonicalPath} explicitly. */
kind: FileKind;
/** Size in bytes for the addressed filesystem object. */
size: number;
@ -168,7 +168,7 @@ export interface FileInfo {
mtimeMs: number;
}
/** Options for {@link ExecutionEnv.exec}. */
/** Options for {@link Shell.exec}. */
export interface ExecutionEnvExecOptions {
/** Working directory for the command. Relative paths are resolved against {@link ExecutionEnv.cwd}. Defaults to {@link ExecutionEnv.cwd}. */
cwd?: string;
@ -185,24 +185,22 @@ export interface ExecutionEnvExecOptions {
}
/**
* Filesystem and process execution environment used by the harness.
* Filesystem capability used by the harness.
*
* Paths passed to methods may be absolute or relative to {@link cwd}. Paths returned by this interface are absolute
* addressed paths in the environment, but are not canonicalized through symlinks unless returned by {@link resolvePath}.
* Paths passed to methods may be absolute or relative to {@link cwd}. Paths returned by file operations are addressed paths
* in the filesystem namespace, but are not canonicalized through symlinks unless returned by {@link canonicalPath}.
*
* Operation methods must never throw or reject. All filesystem/process failures, including unexpected backend failures,
* must be encoded in the returned {@link Result}. Implementations must preserve this invariant.
* Operation methods must never throw or reject. All filesystem failures, including unexpected backend failures, must be
* encoded in the returned {@link Result}. Implementations must preserve this invariant.
*/
export interface ExecutionEnv {
/** Current working directory for relative paths and command execution. */
export interface FileSystem {
/** Current working directory for relative paths. */
cwd: string;
/** Execute a shell command in {@link cwd} unless `options.cwd` is provided. */
exec(
command: string,
options?: ExecutionEnvExecOptions,
): Promise<Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>>;
/** Return an absolute addressed path without requiring it to exist and without resolving symlinks. */
absolutePath(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
/** Join path segments in the filesystem namespace without requiring the result to exist. */
joinPath(parts: string[], abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
/** Read a UTF-8 text file. */
readTextFile(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
/** Read a binary file. */
@ -215,8 +213,8 @@ export interface ExecutionEnv {
fileInfo(path: string, abortSignal?: AbortSignal): Promise<Result<FileInfo, FileError>>;
/** List direct children of a directory without following symlinks. */
listDir(path: string, abortSignal?: AbortSignal): Promise<Result<FileInfo[], FileError>>;
/** Return the canonical path for a path, following symlinks. */
realPath(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
/** Return the canonical path for an existing path, resolving symlinks where supported. */
canonicalPath(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
/** Return false for missing paths. Other errors, such as permission failures, return a {@link FileError}. */
exists(path: string, abortSignal?: AbortSignal): Promise<Result<boolean, FileError>>;
/** Create a directory. Defaults: `recursive: true`, no abort signal. */
@ -238,10 +236,24 @@ export interface ExecutionEnv {
abortSignal?: AbortSignal;
}): Promise<Result<string, FileError>>;
/** Release resources owned by the environment. Must be best-effort and must not throw or reject. */
/** Release filesystem resources. Must be best-effort and must not throw or reject. */
cleanup(): Promise<void>;
}
/** Shell execution capability used by the harness. */
export interface Shell {
/** Execute a shell command in {@link FileSystem.cwd} unless `options.cwd` is provided. */
exec(
command: string,
options?: ExecutionEnvExecOptions,
): Promise<Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>>;
/** Release shell resources. Must be best-effort and must not throw or reject. */
cleanup(): Promise<void>;
}
/** Filesystem and process execution environment used by the harness. */
export interface ExecutionEnv extends FileSystem, Shell {}
export interface SessionTreeEntryBase {
type: string;
id: string;

View file

@ -22,12 +22,11 @@ export {
serializeConversation,
shouldCompact,
} from "./harness/compaction/compaction.js";
export * from "./harness/execution-env.js";
export * from "./harness/messages.js";
export * from "./harness/prompt-templates.js";
export * from "./harness/session/repo/jsonl.js";
export * from "./harness/session/repo/memory.js";
export * from "./harness/session/repo/shared.js";
export * from "./harness/session/jsonl-repo.js";
export * from "./harness/session/memory-repo.js";
export * from "./harness/session/repo-utils.js";
export * from "./harness/session/session.js";
export { uuidv7 } from "./harness/session/uuid.js";
export * from "./harness/skills.js";

View file

@ -0,0 +1,2 @@
export { NodeExecutionEnv } from "./harness/env/nodejs.js";
export * from "./index.js";

View file

@ -2,8 +2,8 @@ import { fauxAssistantMessage, fauxToolCall, registerFauxProvider, type StreamOp
import { afterEach, describe, expect, it } from "vitest";
import { AgentHarness } from "../../src/harness/agent-harness.js";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.js";
import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.js";
import { Session } from "../../src/harness/session/session.js";
import { InMemorySessionStorage } from "../../src/harness/session/storage/memory.js";
import { calculateTool } from "../utils/calculate.js";
const registrations: Array<{ unregister(): void }> = [];

View file

@ -2,8 +2,8 @@ import { fauxAssistantMessage, fauxToolCall, getModel, registerFauxProvider } fr
import { afterEach, describe, expect, it } from "vitest";
import { AgentHarness } from "../../src/harness/agent-harness.js";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.js";
import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.js";
import { Session } from "../../src/harness/session/session.js";
import { InMemorySessionStorage } from "../../src/harness/session/storage/memory.js";
import type { PromptTemplate, Skill } from "../../src/harness/types.js";
import type { AgentMessage, AgentTool } from "../../src/types.js";
import { calculateTool } from "../utils/calculate.js";

View file

@ -2,7 +2,7 @@ import { access, chmod, realpath, symlink } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.js";
import { FileError, getOrThrow } from "../../src/harness/execution-env.js";
import { FileError, getOrThrow } from "../../src/harness/types.js";
import { executeShellWithCapture } from "../../src/harness/utils/shell-output.js";
import { createTempDir } from "./session-test-utils.js";
@ -21,6 +21,8 @@ describe("NodeExecutionEnv", () => {
it("reads, writes, lists, and removes files and directories", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
expect(getOrThrow(await env.absolutePath("nested/child"))).toBe(join(root, "nested/child"));
expect(getOrThrow(await env.joinPath([root, "nested", "child"]))).toBe(join(root, "nested", "child"));
getOrThrow(await env.createDir("nested/child"));
getOrThrow(await env.writeFile("nested/child/file.txt", "hel"));
getOrThrow(await env.appendFile("nested/child/file.txt", "lo"));
@ -71,7 +73,7 @@ describe("NodeExecutionEnv", () => {
path: join(root, "dir-link"),
kind: "symlink",
});
expect(getOrThrow(await env.realPath("file-link"))).toBe(await realpath(join(root, "dir/file.txt")));
expect(getOrThrow(await env.canonicalPath("file-link"))).toBe(await realpath(join(root, "dir/file.txt")));
});
it("lists symlinks as symlinks", async () => {
@ -168,7 +170,7 @@ describe("NodeExecutionEnv", () => {
env.appendFile("other.txt", "hello", signal),
env.fileInfo("file.txt", signal),
env.listDir(".", signal),
env.realPath("file.txt", signal),
env.canonicalPath("file.txt", signal),
env.exists("file.txt", signal),
env.createDir("dir", { abortSignal: signal }),
env.remove("file.txt", { abortSignal: signal }),

View file

@ -1,7 +1,8 @@
import { existsSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { JsonlSessionRepo } from "../../src/harness/session/repo/jsonl.js";
import { InMemorySessionRepo } from "../../src/harness/session/repo/memory.js";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.js";
import { JsonlSessionRepo } from "../../src/harness/session/jsonl-repo.js";
import { InMemorySessionRepo } from "../../src/harness/session/memory-repo.js";
import { createAssistantMessage, createTempDir, createUserMessage } from "./session-test-utils.js";
describe("InMemorySessionRepo", () => {
@ -26,9 +27,10 @@ describe("InMemorySessionRepo", () => {
describe("JsonlSessionRepo", () => {
it("stores sessions below encoded cwd directories and lists by cwd", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });
const cwd = "/tmp/my-project";
const otherCwd = "/tmp/other-project";
const repo = new JsonlSessionRepo({ sessionsRoot: root });
const repo = new JsonlSessionRepo({ fs: env, sessionsRoot: root });
const session = await repo.create({ cwd, id: "019de8c2-de29-73e9-ae0c-e134db34c447" });
const otherSession = await repo.create({ cwd: otherCwd, id: "other-session" });
const metadata = await session.getMetadata();
@ -44,7 +46,8 @@ describe("JsonlSessionRepo", () => {
it("opens, deletes, and forks by metadata", async () => {
const root = createTempDir();
const repo = new JsonlSessionRepo({ sessionsRoot: root });
const env = new NodeExecutionEnv({ cwd: root });
const repo = new JsonlSessionRepo({ fs: env, sessionsRoot: root });
const source = await repo.create({ cwd: "/tmp/source", id: "source-session" });
const sourceMetadata = await source.getMetadata();
const user1 = await source.appendMessage(createUserMessage("one"));

View file

@ -1,11 +1,4 @@
import { describe, expect, it, vi } from "vitest";
const randomBytesMock = vi.hoisted(() => vi.fn<(size: number) => Buffer>());
vi.mock("node:crypto", () => ({
randomBytes: randomBytesMock,
}));
import { afterEach, describe, expect, it, vi } from "vitest";
import { uuidv7 } from "../../src/harness/session/uuid.js";
const UUID_V7_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
@ -15,14 +8,22 @@ function parseTimestamp(uuid: string): number {
return Number.parseInt(uuid.replaceAll("-", "").slice(0, 12), 16);
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe("uuidv7", () => {
it("uses the RFC 9562 layout and preserves monotonic order", () => {
randomBytesMock
.mockReturnValueOnce(
Buffer.from([0, 0, 0, 0, 0, 0, 0xff, 0xff, 0xff, 0xfe, 0x01, 0x11, 0x22, 0x33, 0x44, 0x55]),
)
.mockReturnValueOnce(Buffer.alloc(16))
.mockReturnValueOnce(Buffer.alloc(16));
const randomValues = [
new Uint8Array([0, 0, 0, 0, 0, 0, 0xff, 0xff, 0xff, 0xfe, 0x01, 0x11, 0x22, 0x33, 0x44, 0x55]),
new Uint8Array(16),
new Uint8Array(16),
];
const getRandomValues = vi.fn((bytes: Uint8Array) => {
bytes.set(randomValues.shift() ?? new Uint8Array(bytes.length));
return bytes;
});
vi.stubGlobal("crypto", { getRandomValues });
const dateNow = vi.spyOn(Date, "now").mockReturnValue(TIMESTAMP);
try {
@ -41,8 +42,7 @@ describe("uuidv7", () => {
expect(parseTimestamp(third)).toBe(TIMESTAMP + 1);
expect(first < second).toBe(true);
expect(second < third).toBe(true);
expect(randomBytesMock).toHaveBeenCalledTimes(3);
expect(randomBytesMock).toHaveBeenCalledWith(16);
expect(getRandomValues).toHaveBeenCalledTimes(3);
} finally {
dateNow.mockRestore();
}

View file

@ -1,9 +1,10 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.js";
import { JsonlSessionStorage } from "../../src/harness/session/jsonl-storage.js";
import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.js";
import { Session } from "../../src/harness/session/session.js";
import { JsonlSessionStorage } from "../../src/harness/session/storage/jsonl.js";
import { InMemorySessionStorage } from "../../src/harness/session/storage/memory.js";
import type { SessionStorage } from "../../src/harness/types.js";
import { createAssistantMessage, createTempDir, createUserMessage, getLatestTempDir } from "./session-test-utils.js";
@ -128,7 +129,8 @@ runSessionSuite(
"Session with JSONL storage",
async () => {
const dir = createTempDir();
return await JsonlSessionStorage.create(join(dir, "session.jsonl"), { cwd: dir, sessionId: "session-1" });
const env = new NodeExecutionEnv({ cwd: dir });
return await JsonlSessionStorage.create(env, join(dir, "session.jsonl"), { cwd: dir, sessionId: "session-1" });
},
() => {
const dir = getLatestTempDir();

View file

@ -1,8 +1,9 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { JsonlSessionStorage, loadJsonlSessionMetadata } from "../../src/harness/session/storage/jsonl.js";
import { InMemorySessionStorage } from "../../src/harness/session/storage/memory.js";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.js";
import { JsonlSessionStorage, loadJsonlSessionMetadata } from "../../src/harness/session/jsonl-storage.js";
import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.js";
import type { MessageEntry, SessionMetadata } from "../../src/harness/types.js";
import { createAssistantMessage, createTempDir, createUserMessage } from "./session-test-utils.js";
@ -102,14 +103,16 @@ describe("InMemorySessionStorage", () => {
describe("JsonlSessionStorage", () => {
it("throws for missing files when opening", async () => {
const dir = createTempDir();
const env = new NodeExecutionEnv({ cwd: dir });
const filePath = join(dir, "session.jsonl");
await expect(JsonlSessionStorage.open(filePath)).rejects.toMatchObject({ code: "ENOENT" });
await expect(JsonlSessionStorage.open(env, filePath)).rejects.toMatchObject({ code: "not_found" });
});
it("writes the header on create", async () => {
const dir = createTempDir();
const env = new NodeExecutionEnv({ cwd: dir });
const filePath = join(dir, "session.jsonl");
const storage = await JsonlSessionStorage.create(filePath, { cwd: dir, sessionId: "session-1" });
const storage = await JsonlSessionStorage.create(env, filePath, { cwd: dir, sessionId: "session-1" });
expect(existsSync(filePath)).toBe(true);
expect(readFileSync(filePath, "utf8").trim().split("\n")).toHaveLength(1);
expect(await storage.getLeafId()).toBeNull();
@ -129,13 +132,15 @@ describe("JsonlSessionStorage", () => {
it("throws for malformed session headers", async () => {
const dir = createTempDir();
const env = new NodeExecutionEnv({ cwd: dir });
const filePath = join(dir, "session.jsonl");
writeFileSync(filePath, "not json\n");
await expect(JsonlSessionStorage.open(filePath)).rejects.toThrow("first line is not a valid session header");
await expect(JsonlSessionStorage.open(env, filePath)).rejects.toThrow("first line is not a valid session header");
});
it("ignores malformed entry lines", async () => {
const dir = createTempDir();
const env = new NodeExecutionEnv({ cwd: dir });
const filePath = join(dir, "session.jsonl");
const header = {
type: "session",
@ -152,15 +157,16 @@ describe("JsonlSessionStorage", () => {
message: createUserMessage("one"),
};
writeFileSync(filePath, `${JSON.stringify(header)}\nnot json\n${JSON.stringify(entry)}\n`);
const storage = await JsonlSessionStorage.open(filePath);
const storage = await JsonlSessionStorage.open(env, filePath);
expect((await storage.getEntries()).map((loadedEntry) => loadedEntry.id)).toEqual(["entry-1"]);
expect(await storage.getLeafId()).toBe("entry-1");
});
it("creates and reads session metadata from the header", async () => {
const dir = createTempDir();
const env = new NodeExecutionEnv({ cwd: dir });
const filePath = join(dir, "session.jsonl");
const storage = await JsonlSessionStorage.create(filePath, {
const storage = await JsonlSessionStorage.create(env, filePath, {
cwd: dir,
sessionId: "session-1",
parentSessionPath: "/tmp/parent.jsonl",
@ -179,13 +185,14 @@ describe("JsonlSessionStorage", () => {
timestamp: "2026-01-01T00:00:00.000Z",
message: createUserMessage("one"),
});
expect(await loadJsonlSessionMetadata(filePath)).toEqual(metadata);
expect(await loadJsonlSessionMetadata(env, filePath)).toEqual(metadata);
});
it("loads existing entries and reconstructs leaf", async () => {
const dir = createTempDir();
const env = new NodeExecutionEnv({ cwd: dir });
const filePath = join(dir, "session.jsonl");
const storage = await JsonlSessionStorage.create(filePath, { cwd: dir, sessionId: "session-1" });
const storage = await JsonlSessionStorage.create(env, filePath, { cwd: dir, sessionId: "session-1" });
const root: MessageEntry = {
type: "message",
id: "root",
@ -201,7 +208,7 @@ describe("JsonlSessionStorage", () => {
};
await storage.appendEntry(root);
await storage.appendEntry(child);
const loaded = await JsonlSessionStorage.open(filePath);
const loaded = await JsonlSessionStorage.open(env, filePath);
expect(await loaded.getLeafId()).toBe("child");
expect((await loaded.getEntries()).map((entry) => entry.id)).toEqual(["root", "child"]);
expect((await loaded.getPathToRoot("child")).map((entry) => entry.id)).toEqual(["root", "child"]);
@ -209,8 +216,9 @@ describe("JsonlSessionStorage", () => {
it("finds entries by type", async () => {
const dir = createTempDir();
const env = new NodeExecutionEnv({ cwd: dir });
const filePath = join(dir, "session.jsonl");
const storage = await JsonlSessionStorage.create(filePath, { cwd: dir, sessionId: "session-1" });
const storage = await JsonlSessionStorage.create(env, filePath, { cwd: dir, sessionId: "session-1" });
await storage.appendEntry({
type: "message",
id: "entry-1",
@ -224,8 +232,9 @@ describe("JsonlSessionStorage", () => {
it("maintains label lookup", async () => {
const dir = createTempDir();
const env = new NodeExecutionEnv({ cwd: dir });
const filePath = join(dir, "session.jsonl");
const storage = await JsonlSessionStorage.create(filePath, { cwd: dir, sessionId: "session-1" });
const storage = await JsonlSessionStorage.create(env, filePath, { cwd: dir, sessionId: "session-1" });
await storage.appendEntry({
type: "message",
id: "entry-1",
@ -252,12 +261,13 @@ describe("JsonlSessionStorage", () => {
label: undefined,
});
expect(await storage.getLabel("entry-1")).toBeUndefined();
const loaded = await JsonlSessionStorage.open(filePath);
const loaded = await JsonlSessionStorage.open(env, filePath);
expect(await loaded.getLabel("entry-1")).toBeUndefined();
});
it("reads session metadata from only the first JSONL line", async () => {
const dir = createTempDir();
const env = new NodeExecutionEnv({ cwd: dir });
const filePath = join(dir, "session.jsonl");
const header = {
type: "session",
@ -268,7 +278,7 @@ describe("JsonlSessionStorage", () => {
};
const malformedSecondLine = "{".repeat(10000);
writeFileSync(filePath, `${JSON.stringify(header)}\n${malformedSecondLine}\n`);
expect(await loadJsonlSessionMetadata(filePath)).toEqual({
expect(await loadJsonlSessionMetadata(env, filePath)).toEqual({
id: "session-1",
createdAt: "2026-01-01T00:00:00.000Z",
cwd: dir,

View file

@ -2,7 +2,7 @@ import { homedir } from "node:os";
import { join } from "node:path";
import { getModel } from "@earendil-works/pi-ai";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.js";
import { InMemorySessionStorage } from "../../src/harness/session/storage/memory.js";
import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.js";
import {
AgentHarness,
formatSkillsForSystemPrompt,