mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-07-09 17:28:45 +00:00
refactor(agent): isolate node filesystem session dependencies
This commit is contained in:
parent
0b54c87e24
commit
80c918c247
25 changed files with 340 additions and 257 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
16
packages/agent/src/harness/env/nodejs.ts
vendored
16
packages/agent/src/harness/env/nodejs.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
138
packages/agent/src/harness/session/jsonl-repo.ts
Normal file
138
packages/agent/src/harness/session/jsonl-repo.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>>();
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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();
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
2
packages/agent/src/node.ts
Normal file
2
packages/agent/src/node.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { NodeExecutionEnv } from "./harness/env/nodejs.js";
|
||||
export * from "./index.js";
|
||||
|
|
@ -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 }> = [];
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue