mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
refactor: repo owns session and storage separately
This commit is contained in:
parent
3627c01179
commit
6a9591ae78
13 changed files with 266 additions and 237 deletions
|
|
@ -3,23 +3,16 @@ import type {
|
|||
JsonlSessionCreateOptions,
|
||||
JsonlSessionListOptions,
|
||||
JsonlSessionMetadata,
|
||||
JsonlSessionRepoApi,
|
||||
Session,
|
||||
JsonlSessionStoreApi,
|
||||
SessionSearch,
|
||||
SessionSearchOptions,
|
||||
SessionStorage,
|
||||
} from "../types.ts";
|
||||
import { SessionError, toError } from "../types.ts";
|
||||
import { JsonlSessionStorage, loadJsonlSessionMetadata } from "./jsonl-storage.ts";
|
||||
import {
|
||||
createSessionId,
|
||||
createTimestamp,
|
||||
getEntriesToFork,
|
||||
getFileSystemResultOrThrow,
|
||||
toRepoSession,
|
||||
} from "./repo-utils.ts";
|
||||
import { createSessionId, createTimestamp, getEntriesToFork, getFileSystemResultOrThrow } from "./repo-utils.ts";
|
||||
import { ScanningSessionSearch } from "./search-backend.ts";
|
||||
|
||||
type JsonlSessionRepoFileSystem = Pick<
|
||||
type JsonlSessionStoreFileSystem = Pick<
|
||||
FileSystem,
|
||||
| "cwd"
|
||||
| "absolutePath"
|
||||
|
|
@ -38,25 +31,19 @@ function encodeCwd(cwd: string): string {
|
|||
return `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
|
||||
}
|
||||
|
||||
export class JsonlSessionRepo implements JsonlSessionRepoApi {
|
||||
private readonly fs: JsonlSessionRepoFileSystem;
|
||||
export class JsonlSessionStore implements JsonlSessionStoreApi {
|
||||
private readonly fs: JsonlSessionStoreFileSystem;
|
||||
private readonly sessionsRootInput: string;
|
||||
private readonly sessionSearch: SessionSearch<JsonlSessionMetadata>;
|
||||
readonly defaultSearch: SessionSearch<JsonlSessionMetadata>;
|
||||
private sessionsRoot: string | undefined;
|
||||
|
||||
constructor(options: {
|
||||
fs: JsonlSessionRepoFileSystem;
|
||||
sessionsRoot: string;
|
||||
search?: SessionSearch<JsonlSessionMetadata>;
|
||||
}) {
|
||||
constructor(options: { fs: JsonlSessionStoreFileSystem; sessionsRoot: string }) {
|
||||
this.fs = options.fs;
|
||||
this.sessionsRootInput = options.sessionsRoot;
|
||||
this.sessionSearch =
|
||||
options.search ??
|
||||
new ScanningSessionSearch({
|
||||
open: (metadata) => this.open(metadata),
|
||||
list: () => this.list(),
|
||||
});
|
||||
this.defaultSearch = new ScanningSessionSearch({
|
||||
open: (metadata) => this.open(metadata),
|
||||
list: () => this.list(),
|
||||
});
|
||||
}
|
||||
|
||||
private async getSessionsRoot(): Promise<string> {
|
||||
|
|
@ -86,7 +73,7 @@ export class JsonlSessionRepo implements JsonlSessionRepoApi {
|
|||
);
|
||||
}
|
||||
|
||||
async create(options: JsonlSessionCreateOptions): Promise<Session<JsonlSessionMetadata>> {
|
||||
async create(options: JsonlSessionCreateOptions): Promise<SessionStorage<JsonlSessionMetadata>> {
|
||||
const id = options.id ?? createSessionId();
|
||||
const createdAt = createTimestamp();
|
||||
const sessionDir = await this.getSessionDir(options.cwd);
|
||||
|
|
@ -95,23 +82,21 @@ export class JsonlSessionRepo implements JsonlSessionRepoApi {
|
|||
`Failed to create session directory ${sessionDir}`,
|
||||
);
|
||||
const filePath = await this.createSessionFilePath(options.cwd, id, createdAt);
|
||||
const storage = await JsonlSessionStorage.create(this.fs, filePath, {
|
||||
return await JsonlSessionStorage.create(this.fs, filePath, {
|
||||
cwd: options.cwd,
|
||||
sessionId: id,
|
||||
parentSessionPath: options.parentSessionPath,
|
||||
metadata: options.metadata,
|
||||
});
|
||||
return await toRepoSession(storage, this.sessionSearch);
|
||||
}
|
||||
|
||||
async open(metadata: JsonlSessionMetadata): Promise<Session<JsonlSessionMetadata>> {
|
||||
async open(metadata: JsonlSessionMetadata): Promise<SessionStorage<JsonlSessionMetadata>> {
|
||||
if (
|
||||
!getFileSystemResultOrThrow(await this.fs.exists(metadata.path), `Failed to check session ${metadata.path}`)
|
||||
) {
|
||||
throw new SessionError("not_found", `Session not found: ${metadata.path}`);
|
||||
}
|
||||
const storage = await JsonlSessionStorage.open(this.fs, metadata.path);
|
||||
return await toRepoSession(storage, this.sessionSearch);
|
||||
return await JsonlSessionStorage.open(this.fs, metadata.path);
|
||||
}
|
||||
|
||||
async list(options: JsonlSessionListOptions = {}): Promise<JsonlSessionMetadata[]> {
|
||||
|
|
@ -138,12 +123,7 @@ export class JsonlSessionRepo implements JsonlSessionRepoApi {
|
|||
return sessions;
|
||||
}
|
||||
|
||||
async search(options: SessionSearchOptions) {
|
||||
return await this.sessionSearch.search(options);
|
||||
}
|
||||
|
||||
async delete(metadata: JsonlSessionMetadata): Promise<void> {
|
||||
await this.sessionSearch.index?.removeSession(metadata);
|
||||
getFileSystemResultOrThrow(
|
||||
await this.fs.remove(metadata.path, { force: true }),
|
||||
`Failed to delete session ${metadata.path}`,
|
||||
|
|
@ -153,9 +133,9 @@ export class JsonlSessionRepo implements JsonlSessionRepoApi {
|
|||
async fork(
|
||||
sourceMetadata: JsonlSessionMetadata,
|
||||
options: JsonlSessionCreateOptions & { entryId?: string; position?: "before" | "at"; id?: string },
|
||||
): Promise<Session<JsonlSessionMetadata>> {
|
||||
): Promise<SessionStorage<JsonlSessionMetadata>> {
|
||||
const source = await this.open(sourceMetadata);
|
||||
const forkedEntries = await getEntriesToFork(source.getStorage(), options);
|
||||
const forkedEntries = await getEntriesToFork(source, options);
|
||||
const id = options.id ?? createSessionId();
|
||||
const createdAt = createTimestamp();
|
||||
const sessionDir = await this.getSessionDir(options.cwd);
|
||||
|
|
@ -176,8 +156,7 @@ export class JsonlSessionRepo implements JsonlSessionRepoApi {
|
|||
for (const entry of forkedEntries) {
|
||||
await storage.appendEntry(entry);
|
||||
}
|
||||
await this.sessionSearch.index?.replaceSession(await storage.getMetadata(), forkedEntries);
|
||||
return await toRepoSession(storage, this.sessionSearch);
|
||||
return storage;
|
||||
}
|
||||
|
||||
private async listSessionDirs(): Promise<string[]> {
|
||||
|
|
|
|||
|
|
@ -1,72 +1,61 @@
|
|||
import {
|
||||
type Session,
|
||||
SessionError,
|
||||
type SessionMetadata,
|
||||
type SessionRepo,
|
||||
type SessionSearch,
|
||||
type SessionSearchOptions,
|
||||
type SessionStorage,
|
||||
type SessionStore,
|
||||
} from "../types.ts";
|
||||
import { InMemorySessionStorage } from "./memory-storage.ts";
|
||||
import { createSessionId, createTimestamp, getEntriesToFork, toRepoSession } from "./repo-utils.ts";
|
||||
import { createSessionId, createTimestamp, getEntriesToFork } from "./repo-utils.ts";
|
||||
import { ScanningSessionSearch } from "./search-backend.ts";
|
||||
|
||||
export class InMemorySessionRepo implements SessionRepo<SessionMetadata, { id?: string }, void> {
|
||||
private sessions = new Map<string, Session<SessionMetadata>>();
|
||||
private readonly sessionSearch: SessionSearch<SessionMetadata>;
|
||||
export class MemorySessionStore implements SessionStore<SessionMetadata, { id?: string }, void> {
|
||||
private sessions = new Map<string, InMemorySessionStorage<SessionMetadata>>();
|
||||
readonly defaultSearch: SessionSearch<SessionMetadata>;
|
||||
|
||||
constructor(options: { search?: SessionSearch<SessionMetadata> } = {}) {
|
||||
this.sessionSearch =
|
||||
options.search ??
|
||||
new ScanningSessionSearch({
|
||||
open: (metadata) => this.open(metadata),
|
||||
list: () => this.list(),
|
||||
});
|
||||
constructor() {
|
||||
this.defaultSearch = new ScanningSessionSearch({
|
||||
open: (metadata) => this.open(metadata),
|
||||
list: () => this.list(),
|
||||
});
|
||||
}
|
||||
|
||||
async create(options: { id?: string } = {}): Promise<Session<SessionMetadata>> {
|
||||
async create(options: { id?: string } = {}): Promise<SessionStorage<SessionMetadata>> {
|
||||
const metadata: SessionMetadata = {
|
||||
id: options.id ?? createSessionId(),
|
||||
createdAt: createTimestamp(),
|
||||
};
|
||||
const storage = new InMemorySessionStorage({ metadata });
|
||||
const session = await toRepoSession(storage, this.sessionSearch);
|
||||
this.sessions.set(metadata.id, session);
|
||||
return session;
|
||||
this.sessions.set(metadata.id, storage);
|
||||
return storage;
|
||||
}
|
||||
|
||||
async open(metadata: SessionMetadata): Promise<Session<SessionMetadata>> {
|
||||
const session = this.sessions.get(metadata.id);
|
||||
if (!session) throw new SessionError("not_found", `Session not found: ${metadata.id}`);
|
||||
return session;
|
||||
async open(metadata: SessionMetadata): Promise<SessionStorage<SessionMetadata>> {
|
||||
const storage = this.sessions.get(metadata.id);
|
||||
if (!storage) throw new SessionError("not_found", `Session not found: ${metadata.id}`);
|
||||
return storage;
|
||||
}
|
||||
|
||||
async list(): Promise<SessionMetadata[]> {
|
||||
return Promise.all([...this.sessions.values()].map((session) => session.getMetadata()));
|
||||
}
|
||||
|
||||
async search(options: SessionSearchOptions) {
|
||||
return await this.sessionSearch.search(options);
|
||||
return Promise.all([...this.sessions.values()].map((storage) => storage.getMetadata()));
|
||||
}
|
||||
|
||||
async delete(metadata: SessionMetadata): Promise<void> {
|
||||
await this.sessionSearch.index?.removeSession(metadata);
|
||||
this.sessions.delete(metadata.id);
|
||||
}
|
||||
|
||||
async fork(
|
||||
sourceMetadata: SessionMetadata,
|
||||
options: { entryId?: string; position?: "before" | "at"; id?: string },
|
||||
): Promise<Session<SessionMetadata>> {
|
||||
): Promise<SessionStorage<SessionMetadata>> {
|
||||
const source = await this.open(sourceMetadata);
|
||||
const forkedEntries = await getEntriesToFork(source.getStorage(), options);
|
||||
const forkedEntries = await getEntriesToFork(source, options);
|
||||
const metadata: SessionMetadata = {
|
||||
id: options.id ?? createSessionId(),
|
||||
createdAt: createTimestamp(),
|
||||
};
|
||||
const storage = new InMemorySessionStorage({ metadata, entries: forkedEntries });
|
||||
await this.sessionSearch.index?.replaceSession(metadata, forkedEntries);
|
||||
const session = await toRepoSession(storage, this.sessionSearch);
|
||||
this.sessions.set(metadata.id, session);
|
||||
return session;
|
||||
this.sessions.set(metadata.id, storage);
|
||||
return storage;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,15 @@ import { uuidv7 } from "@earendil-works/pi-ai";
|
|||
import {
|
||||
type FileError,
|
||||
type Result,
|
||||
type SessionCreateOptions,
|
||||
SessionError,
|
||||
type SessionForkOptions,
|
||||
type SessionMetadata,
|
||||
type SessionSearch,
|
||||
type SessionSearchHit,
|
||||
type SessionSearchIndex,
|
||||
type SessionStorage,
|
||||
type SessionStore,
|
||||
type SessionTreeEntry,
|
||||
type SessionWriter,
|
||||
} from "../types.ts";
|
||||
|
|
@ -61,6 +64,70 @@ export async function toRepoSession<TMetadata extends SessionMetadata>(
|
|||
return toSession(storage, new SessionRepoWriter(storage, metadata, search.index));
|
||||
}
|
||||
|
||||
type BoundSessionStore<
|
||||
TMetadata extends SessionMetadata,
|
||||
TCreateOptions extends SessionCreateOptions,
|
||||
TListOptions,
|
||||
> = Omit<SessionStore<TMetadata, TCreateOptions, TListOptions>, "defaultSearch" | "create" | "open" | "fork"> & {
|
||||
create(options: TCreateOptions): Promise<Session<TMetadata>>;
|
||||
open(metadata: TMetadata): Promise<Session<TMetadata>>;
|
||||
fork(source: TMetadata, options: SessionForkOptions & TCreateOptions): Promise<Session<TMetadata>>;
|
||||
};
|
||||
|
||||
function bindSessionStore<TMetadata extends SessionMetadata, TCreateOptions extends SessionCreateOptions, TListOptions>(
|
||||
storage: SessionStore<TMetadata, TCreateOptions, TListOptions>,
|
||||
search: SessionSearch<TMetadata> | null,
|
||||
): BoundSessionStore<TMetadata, TCreateOptions, TListOptions> {
|
||||
const wrap = async (sessionStorage: SessionStorage<TMetadata>): Promise<Session<TMetadata>> =>
|
||||
search ? await toRepoSession(sessionStorage, search) : toSession(sessionStorage);
|
||||
return new Proxy(storage, {
|
||||
get(target, property) {
|
||||
if (property === "create") {
|
||||
return async (options: TCreateOptions) => await wrap(await target.create(options));
|
||||
}
|
||||
if (property === "open") {
|
||||
return async (metadata: TMetadata) => await wrap(await target.open(metadata));
|
||||
}
|
||||
if (property === "fork") {
|
||||
return async (source: TMetadata, options: SessionForkOptions & TCreateOptions) => {
|
||||
const sessionStorage = await target.fork(source, options);
|
||||
await search?.index?.replaceSession(
|
||||
await sessionStorage.getMetadata(),
|
||||
await sessionStorage.getEntries(),
|
||||
);
|
||||
return await wrap(sessionStorage);
|
||||
};
|
||||
}
|
||||
if (property === "delete") {
|
||||
return async (metadata: TMetadata) => {
|
||||
await search?.index?.removeSession(metadata);
|
||||
await target.delete(metadata);
|
||||
};
|
||||
}
|
||||
const value = Reflect.get(target, property, target) as unknown;
|
||||
return typeof value === "function" ? value.bind(target) : value;
|
||||
},
|
||||
}) as unknown as BoundSessionStore<TMetadata, TCreateOptions, TListOptions>;
|
||||
}
|
||||
|
||||
/** Composes canonical storage and optional search as independently accessible capabilities. */
|
||||
export class SessionRepo<
|
||||
TMetadata extends SessionMetadata = SessionMetadata,
|
||||
TCreateOptions extends SessionCreateOptions = SessionCreateOptions,
|
||||
TListOptions = void,
|
||||
> {
|
||||
readonly storage: BoundSessionStore<TMetadata, TCreateOptions, TListOptions>;
|
||||
readonly search: SessionSearch<TMetadata> | null;
|
||||
|
||||
constructor(options: {
|
||||
storage: SessionStore<TMetadata, TCreateOptions, TListOptions>;
|
||||
search?: SessionSearch<TMetadata> | null;
|
||||
}) {
|
||||
this.search = options.search === undefined ? (options.storage.defaultSearch ?? null) : options.search;
|
||||
this.storage = bindSessionStore(options.storage, this.search);
|
||||
}
|
||||
}
|
||||
|
||||
export function findSessionEntryMatches<TMetadata extends SessionMetadata>(
|
||||
metadata: TMetadata,
|
||||
entries: SessionTreeEntry[],
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
import type {
|
||||
Session,
|
||||
SessionMetadata,
|
||||
SessionSearch,
|
||||
SessionSearchHit,
|
||||
SessionSearchIndex,
|
||||
SessionSearchOptions,
|
||||
SessionSearchRecord,
|
||||
SessionStorage,
|
||||
SessionTreeEntry,
|
||||
} from "../types.ts";
|
||||
import { findSessionEntryMatches } from "./repo-utils.ts";
|
||||
|
||||
type SessionSearchSource<TMetadata extends SessionMetadata> = {
|
||||
open(metadata: TMetadata): Promise<Session<TMetadata>>;
|
||||
open(metadata: TMetadata): Promise<SessionStorage<TMetadata>>;
|
||||
list(): Promise<TMetadata[]>;
|
||||
};
|
||||
|
||||
|
|
@ -30,12 +30,12 @@ export class ScanningSessionSearch<TMetadata extends SessionMetadata = SessionMe
|
|||
for (const metadata of await this.source.list()) {
|
||||
const cwd = (metadata as { cwd?: unknown }).cwd;
|
||||
if (options.cwd !== undefined && cwd !== options.cwd) continue;
|
||||
const session = await this.source.open(metadata);
|
||||
const storage = await this.source.open(metadata);
|
||||
try {
|
||||
hits.push(...findSessionEntryMatches(metadata, await session.getEntries(), options.text));
|
||||
hits.push(...findSessionEntryMatches(metadata, await storage.getEntries(), options.text));
|
||||
} finally {
|
||||
const storage = session.getStorage() as { cleanup?: () => Promise<void> };
|
||||
if (typeof storage.cleanup === "function") await storage.cleanup();
|
||||
const maybeClosable = storage as { cleanup?: () => Promise<void> };
|
||||
if (typeof maybeClosable.cleanup === "function") await maybeClosable.cleanup();
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
|
|
|
|||
|
|
@ -559,17 +559,17 @@ export interface SessionForkOptions {
|
|||
id?: string;
|
||||
}
|
||||
|
||||
export interface SessionRepo<
|
||||
export interface SessionStore<
|
||||
TMetadata extends SessionMetadata = SessionMetadata,
|
||||
TCreateOptions extends SessionCreateOptions = SessionCreateOptions,
|
||||
TListOptions = void,
|
||||
> {
|
||||
create(options: TCreateOptions): Promise<Session<TMetadata>>;
|
||||
open(metadata: TMetadata): Promise<Session<TMetadata>>;
|
||||
readonly defaultSearch?: SessionSearch<TMetadata>;
|
||||
create(options: TCreateOptions): Promise<SessionStorage<TMetadata>>;
|
||||
open(metadata: TMetadata): Promise<SessionStorage<TMetadata>>;
|
||||
list(options?: TListOptions): Promise<TMetadata[]>;
|
||||
search(options: SessionSearchOptions): Promise<SessionSearchHit<TMetadata>[]>;
|
||||
delete(metadata: TMetadata): Promise<void>;
|
||||
fork(source: TMetadata, options: SessionForkOptions & TCreateOptions): Promise<Session<TMetadata>>;
|
||||
fork(source: TMetadata, options: SessionForkOptions & TCreateOptions): Promise<SessionStorage<TMetadata>>;
|
||||
}
|
||||
|
||||
export interface JsonlSessionCreateOptions extends SessionCreateOptions {
|
||||
|
|
@ -582,8 +582,8 @@ export interface JsonlSessionListOptions {
|
|||
cwd?: string;
|
||||
}
|
||||
|
||||
export interface JsonlSessionRepoApi
|
||||
extends SessionRepo<JsonlSessionMetadata, JsonlSessionCreateOptions, JsonlSessionListOptions> {}
|
||||
export interface JsonlSessionStoreApi
|
||||
extends SessionStore<JsonlSessionMetadata, JsonlSessionCreateOptions, JsonlSessionListOptions> {}
|
||||
|
||||
export type AgentHarnessPhase = "idle" | "turn" | "compaction" | "branch_summary" | "retry";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,45 +1,46 @@
|
|||
import { existsSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
|
||||
import { JsonlSessionRepo } from "../../src/harness/session/jsonl-repo.ts";
|
||||
import { InMemorySessionRepo } from "../../src/harness/session/memory-repo.ts";
|
||||
import { JsonlSessionStore } from "../../src/harness/session/jsonl-repo.ts";
|
||||
import { MemorySessionStore } from "../../src/harness/session/memory-repo.ts";
|
||||
import { SessionRepo } from "../../src/harness/session/repo-utils.ts";
|
||||
import { createAssistantMessage, createTempDir, createUserMessage } from "./session-test-utils.ts";
|
||||
|
||||
describe("InMemorySessionRepo", () => {
|
||||
describe("MemorySessionStore", () => {
|
||||
it("opens, deletes, and forks by metadata", async () => {
|
||||
const repo = new InMemorySessionRepo();
|
||||
const session = await repo.create({ id: "session-1" });
|
||||
const repo = new SessionRepo({ storage: new MemorySessionStore() });
|
||||
const session = await repo.storage.create({ id: "session-1" });
|
||||
const metadata = await session.getMetadata();
|
||||
const user1 = await session.appendMessage(createUserMessage("one"));
|
||||
const assistant1 = await session.appendMessage(createAssistantMessage("two"));
|
||||
const user2 = await session.appendMessage(createUserMessage("three"));
|
||||
expect(await repo.open(metadata)).toBe(session);
|
||||
expect((await repo.list()).map((info) => info.id)).toEqual(["session-1"]);
|
||||
const fork = await repo.fork(metadata, { entryId: user2, id: "session-2" });
|
||||
await expect((await repo.storage.open(metadata)).getMetadata()).resolves.toEqual(metadata);
|
||||
expect((await repo.storage.list()).map((info) => info.id)).toEqual(["session-1"]);
|
||||
const fork = await repo.storage.fork(metadata, { entryId: user2, id: "session-2" });
|
||||
expect((await fork.getEntries()).map((entry) => entry.id)).toEqual([user1, assistant1]);
|
||||
const fullFork = await repo.fork(metadata, { id: "session-3" });
|
||||
const fullFork = await repo.storage.fork(metadata, { id: "session-3" });
|
||||
expect((await fullFork.getEntries()).map((entry) => entry.id)).toEqual([user1, assistant1, user2]);
|
||||
await repo.delete(metadata);
|
||||
await expect(repo.open(metadata)).rejects.toThrow("Session not found: session-1");
|
||||
await repo.storage.delete(metadata);
|
||||
await expect(repo.storage.open(metadata)).rejects.toThrow("Session not found: session-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("JsonlSessionRepo", () => {
|
||||
describe("JsonlSessionStore", () => {
|
||||
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({ 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 repo = new SessionRepo({ storage: new JsonlSessionStore({ fs: env, sessionsRoot: root }) });
|
||||
const session = await repo.storage.create({ cwd, id: "019de8c2-de29-73e9-ae0c-e134db34c447" });
|
||||
const otherSession = await repo.storage.create({ cwd: otherCwd, id: "other-session" });
|
||||
const metadata = await session.getMetadata();
|
||||
const otherMetadata = await otherSession.getMetadata();
|
||||
expect(metadata.path).toContain("--tmp-my-project--");
|
||||
expect(otherMetadata.path).toContain("--tmp-other-project--");
|
||||
expect(existsSync(metadata.path)).toBe(true);
|
||||
expect((await repo.list({ cwd })).map((sessionMetadata) => sessionMetadata.id)).toEqual([metadata.id]);
|
||||
expect((await repo.list()).map((sessionMetadata) => sessionMetadata.id).sort()).toEqual(
|
||||
expect((await repo.storage.list({ cwd })).map((sessionMetadata) => sessionMetadata.id)).toEqual([metadata.id]);
|
||||
expect((await repo.storage.list()).map((sessionMetadata) => sessionMetadata.id).sort()).toEqual(
|
||||
[metadata.id, otherMetadata.id].sort(),
|
||||
);
|
||||
});
|
||||
|
|
@ -47,42 +48,42 @@ describe("JsonlSessionRepo", () => {
|
|||
it("opens, deletes, and forks by metadata", async () => {
|
||||
const root = createTempDir();
|
||||
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 repo = new SessionRepo({ storage: new JsonlSessionStore({ fs: env, sessionsRoot: root }) });
|
||||
const source = await repo.storage.create({ cwd: "/tmp/source", id: "source-session" });
|
||||
const sourceMetadata = await source.getMetadata();
|
||||
const user1 = await source.appendMessage(createUserMessage("one"));
|
||||
const assistant1 = await source.appendMessage(createAssistantMessage("two"));
|
||||
const user2 = await source.appendMessage(createUserMessage("three"));
|
||||
await expect((await repo.open(sourceMetadata)).getMetadata()).resolves.toEqual(sourceMetadata);
|
||||
const fork = await repo.fork(sourceMetadata, { cwd: "/tmp/target", id: "fork-session", entryId: user2 });
|
||||
await expect((await repo.storage.open(sourceMetadata)).getMetadata()).resolves.toEqual(sourceMetadata);
|
||||
const fork = await repo.storage.fork(sourceMetadata, { cwd: "/tmp/target", id: "fork-session", entryId: user2 });
|
||||
const forkMetadata = await fork.getMetadata();
|
||||
expect(forkMetadata.cwd).toBe("/tmp/target");
|
||||
expect(forkMetadata.parentSessionPath).toBe(sourceMetadata.path);
|
||||
expect((await fork.getEntries()).map((entry) => entry.id)).toEqual([user1, assistant1]);
|
||||
const fullFork = await repo.fork(sourceMetadata, { cwd: "/tmp/target", id: "full-fork-session" });
|
||||
const fullFork = await repo.storage.fork(sourceMetadata, { cwd: "/tmp/target", id: "full-fork-session" });
|
||||
expect((await fullFork.getEntries()).map((entry) => entry.id)).toEqual([user1, assistant1, user2]);
|
||||
await repo.delete(sourceMetadata);
|
||||
await repo.storage.delete(sourceMetadata);
|
||||
expect(existsSync(sourceMetadata.path)).toBe(false);
|
||||
await expect(repo.open(sourceMetadata)).rejects.toThrow("Session not found");
|
||||
await expect(repo.storage.open(sourceMetadata)).rejects.toThrow("Session not found");
|
||||
});
|
||||
|
||||
it("persists header metadata through create, list, and fork", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const repo = new JsonlSessionRepo({ fs: env, sessionsRoot: root });
|
||||
const source = await repo.create({
|
||||
const repo = new SessionRepo({ storage: new JsonlSessionStore({ fs: env, sessionsRoot: root }) });
|
||||
const source = await repo.storage.create({
|
||||
cwd: "/tmp/source",
|
||||
id: "source-session",
|
||||
metadata: { profile: "reviewer" },
|
||||
});
|
||||
const sourceMetadata = await source.getMetadata();
|
||||
expect(sourceMetadata.metadata).toEqual({ profile: "reviewer" });
|
||||
expect((await repo.list({ cwd: "/tmp/source" })).map((listed) => listed.metadata)).toEqual([
|
||||
expect((await repo.storage.list({ cwd: "/tmp/source" })).map((listed) => listed.metadata)).toEqual([
|
||||
{ profile: "reviewer" },
|
||||
]);
|
||||
const fork = await repo.fork(sourceMetadata, { cwd: "/tmp/target", id: "fork-session" });
|
||||
const fork = await repo.storage.fork(sourceMetadata, { cwd: "/tmp/target", id: "fork-session" });
|
||||
expect((await fork.getMetadata()).metadata).toEqual({ profile: "reviewer" });
|
||||
const overridden = await repo.fork(sourceMetadata, {
|
||||
const overridden = await repo.storage.fork(sourceMetadata, {
|
||||
cwd: "/tmp/target",
|
||||
id: "overridden-session",
|
||||
metadata: { profile: "writer" },
|
||||
|
|
|
|||
|
|
@ -9,11 +9,12 @@ import {
|
|||
type SqliteDatabaseFactory,
|
||||
type SqliteRunResult,
|
||||
type SqliteSessionMetadata,
|
||||
SqliteSessionRepo,
|
||||
SqliteSessionStorage,
|
||||
SqliteSessionStore,
|
||||
type SqliteStatement,
|
||||
} from "../../../storage/sqlite-node/src/index.ts";
|
||||
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
|
||||
import { SessionRepo } from "../../src/harness/session/repo-utils.ts";
|
||||
import { createAssistantMessage, createUserMessage } from "./session-test-utils.ts";
|
||||
|
||||
function createTempDir(): string {
|
||||
|
|
@ -69,8 +70,8 @@ describe("SQLite migrations", () => {
|
|||
const databasePath = join(root, "sessions.sqlite");
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const sqlite = createNodeSqliteFactory();
|
||||
const repo = new SqliteSessionRepo({ env, sqlite, databasePath });
|
||||
await repo.create({ cwd: root, id: "session-1" });
|
||||
const repo = new SessionRepo({ storage: new SqliteSessionStore({ env, sqlite, databasePath }) });
|
||||
await repo.storage.create({ cwd: root, id: "session-1" });
|
||||
|
||||
const db = await sqlite.open(databasePath);
|
||||
try {
|
||||
|
|
@ -111,19 +112,23 @@ describe("SQLite migrations", () => {
|
|||
const root = createTempDir();
|
||||
const databasePath = join(root, "sessions.sqlite");
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const repo = new SqliteSessionRepo({ env, sqlite: createNodeSqliteFactory(), databasePath });
|
||||
const source = await repo.create({
|
||||
const repo = new SessionRepo({
|
||||
storage: new SqliteSessionStore({ env, sqlite: createNodeSqliteFactory(), databasePath }),
|
||||
});
|
||||
const source = await repo.storage.create({
|
||||
cwd: root,
|
||||
id: "session-1",
|
||||
metadata: { profile: "reviewer" },
|
||||
});
|
||||
const sourceMetadata = await source.getMetadata();
|
||||
expect(sourceMetadata.metadata).toEqual({ profile: "reviewer" });
|
||||
expect((await repo.list({ cwd: root })).map((listed) => listed.metadata)).toEqual([{ profile: "reviewer" }]);
|
||||
expect((await (await repo.open(sourceMetadata)).getMetadata()).metadata).toEqual({ profile: "reviewer" });
|
||||
const fork = await repo.fork(sourceMetadata, { cwd: root, id: "session-2" });
|
||||
expect((await repo.storage.list({ cwd: root })).map((listed) => listed.metadata)).toEqual([
|
||||
{ profile: "reviewer" },
|
||||
]);
|
||||
expect((await (await repo.storage.open(sourceMetadata)).getMetadata()).metadata).toEqual({ profile: "reviewer" });
|
||||
const fork = await repo.storage.fork(sourceMetadata, { cwd: root, id: "session-2" });
|
||||
expect((await fork.getMetadata()).metadata).toEqual({ profile: "reviewer" });
|
||||
const overridden = await repo.fork(sourceMetadata, {
|
||||
const overridden = await repo.storage.fork(sourceMetadata, {
|
||||
cwd: root,
|
||||
id: "session-3",
|
||||
metadata: { profile: "writer" },
|
||||
|
|
@ -136,8 +141,8 @@ describe("SQLite migrations", () => {
|
|||
const databasePath = join(root, "sessions.sqlite");
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const sqlite = createNodeSqliteFactory();
|
||||
const repo = new SqliteSessionRepo({ env, sqlite, databasePath });
|
||||
const session = await repo.create({ cwd: root, id: "session-1" });
|
||||
const repo = new SessionRepo({ storage: new SqliteSessionStore({ env, sqlite, databasePath }) });
|
||||
const session = await repo.storage.create({ cwd: root, id: "session-1" });
|
||||
const rootId = await session.appendMessage(createUserMessage("root"));
|
||||
const childId = await session.appendMessage(createAssistantMessage("child"));
|
||||
await session.getStorage().setLeafId(rootId);
|
||||
|
|
@ -162,7 +167,7 @@ describe("SQLite migrations", () => {
|
|||
await db.close();
|
||||
}
|
||||
|
||||
const reopened = await repo.open(await session.getMetadata());
|
||||
const reopened = await repo.storage.open(await session.getMetadata());
|
||||
expect(await reopened.getLeafId()).toBe(rootId);
|
||||
expect(childId).not.toBe(rootId);
|
||||
});
|
||||
|
|
@ -172,8 +177,8 @@ describe("SQLite migrations", () => {
|
|||
const databasePath = join(root, "sessions.sqlite");
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const sqlite = createNodeSqliteFactory();
|
||||
const repo = new SqliteSessionRepo({ env, sqlite, databasePath });
|
||||
const session = await repo.create({ cwd: root, id: "session-1" });
|
||||
const repo = new SessionRepo({ storage: new SqliteSessionStore({ env, sqlite, databasePath }) });
|
||||
const session = await repo.storage.create({ cwd: root, id: "session-1" });
|
||||
const rootId = await session.appendMessage(createUserMessage("root"));
|
||||
const firstChildId = await session.appendMessage(createAssistantMessage("first child"));
|
||||
await session.getStorage().setLeafId(rootId);
|
||||
|
|
@ -200,15 +205,17 @@ describe("SQLite migrations", () => {
|
|||
const root = createTempDir();
|
||||
const databasePath = join(root, "sessions.sqlite");
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const repo = new SqliteSessionRepo({ env, sqlite: createNodeSqliteFactory(), databasePath });
|
||||
const session = await repo.create({ cwd: root, id: "session-1" });
|
||||
const repo = new SessionRepo({
|
||||
storage: new SqliteSessionStore({ env, sqlite: createNodeSqliteFactory(), databasePath }),
|
||||
});
|
||||
const session = await repo.storage.create({ cwd: root, id: "session-1" });
|
||||
const rootId = await session.appendMessage(createUserMessage("root"));
|
||||
await session.appendMessage(createAssistantMessage("first child"));
|
||||
await session.appendSessionName(" Reopened Session ");
|
||||
await session.getStorage().setLeafId(rootId);
|
||||
await session.appendMessage(createAssistantMessage("branched child"));
|
||||
|
||||
const reopened = await repo.open(await session.getMetadata());
|
||||
const reopened = await repo.storage.open(await session.getMetadata());
|
||||
expect(await reopened.getSessionName()).toBe("Reopened Session");
|
||||
expect((await reopened.buildContext()).messages.map((message) => message.role)).toEqual(["user", "assistant"]);
|
||||
expect((await reopened.buildContext()).messages.at(-1)).toMatchObject({
|
||||
|
|
@ -220,8 +227,10 @@ describe("SQLite migrations", () => {
|
|||
const root = createTempDir();
|
||||
const databasePath = join(root, "sessions.sqlite");
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const repo = new SqliteSessionRepo({ env, sqlite: createNodeSqliteFactory(), databasePath });
|
||||
const session = await repo.create({ cwd: root, id: "session-1" });
|
||||
const repo = new SessionRepo({
|
||||
storage: new SqliteSessionStore({ env, sqlite: createNodeSqliteFactory(), databasePath }),
|
||||
});
|
||||
const session = await repo.storage.create({ cwd: root, id: "session-1" });
|
||||
await session.appendMessage(createUserMessage("one"));
|
||||
await session.appendMessage(createAssistantMessage("two"));
|
||||
await session.appendMessage(createUserMessage("three"));
|
||||
|
|
@ -247,9 +256,11 @@ describe("SQLite migrations", () => {
|
|||
open: async () => db,
|
||||
};
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const repo = new SqliteSessionRepo({ env, sqlite, databasePath: join(root, "sessions.sqlite") });
|
||||
const repo = new SessionRepo({
|
||||
storage: new SqliteSessionStore({ env, sqlite, databasePath: join(root, "sessions.sqlite") }),
|
||||
});
|
||||
|
||||
await expect(repo.create({ cwd: root, id: "session-1" })).rejects.toThrow("insert failed");
|
||||
await expect(repo.storage.create({ cwd: root, id: "session-1" })).rejects.toThrow("insert failed");
|
||||
expect(db.closeCount).toBe(1);
|
||||
});
|
||||
|
||||
|
|
@ -265,7 +276,9 @@ describe("SQLite migrations", () => {
|
|||
open: async () => db,
|
||||
};
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const repo = new SqliteSessionRepo({ env, sqlite, databasePath: join(root, "sessions.sqlite") });
|
||||
const repo = new SessionRepo({
|
||||
storage: new SqliteSessionStore({ env, sqlite, databasePath: join(root, "sessions.sqlite") }),
|
||||
});
|
||||
const metadata: SqliteSessionMetadata = {
|
||||
id: "missing",
|
||||
createdAt: new Date().toISOString(),
|
||||
|
|
@ -274,7 +287,7 @@ describe("SQLite migrations", () => {
|
|||
};
|
||||
writeFileSync(metadata.path, "");
|
||||
|
||||
await expect(repo.open(metadata)).rejects.toThrow("Session not found: missing");
|
||||
await expect(repo.storage.open(metadata)).rejects.toThrow("Session not found: missing");
|
||||
expect(db.closeCount).toBe(1);
|
||||
});
|
||||
|
||||
|
|
@ -282,7 +295,8 @@ describe("SQLite migrations", () => {
|
|||
const root = createTempDir();
|
||||
const databasePath = join(root, "sessions.sqlite");
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const repo = new SqliteSessionRepo({ env, sqlite: createNodeSqliteFactory(), databasePath });
|
||||
const storage = new SqliteSessionStore({ env, sqlite: createNodeSqliteFactory(), databasePath });
|
||||
const repo = new SessionRepo({ storage });
|
||||
let cleanupCount = 0;
|
||||
const sourceStorage = {
|
||||
async getEntries() {
|
||||
|
|
@ -295,19 +309,11 @@ describe("SQLite migrations", () => {
|
|||
cleanupCount += 1;
|
||||
},
|
||||
} as const;
|
||||
const originalOpen = repo.open.bind(repo);
|
||||
repo.open = async () =>
|
||||
({
|
||||
getStorage() {
|
||||
return sourceStorage;
|
||||
},
|
||||
async getEntries() {
|
||||
return [];
|
||||
},
|
||||
}) as never;
|
||||
const originalOpen = storage.open.bind(storage);
|
||||
storage.open = async () => sourceStorage as never;
|
||||
|
||||
try {
|
||||
await repo.fork(
|
||||
await repo.storage.fork(
|
||||
{
|
||||
id: "session-1",
|
||||
createdAt: new Date().toISOString(),
|
||||
|
|
@ -317,7 +323,7 @@ describe("SQLite migrations", () => {
|
|||
{ cwd: root, id: "session-2" },
|
||||
);
|
||||
} finally {
|
||||
repo.open = originalOpen;
|
||||
storage.open = originalOpen;
|
||||
}
|
||||
|
||||
expect(cleanupCount).toBe(1);
|
||||
|
|
@ -363,8 +369,8 @@ describe("SQLite migrations", () => {
|
|||
const databasePath = join(root, "sessions.sqlite");
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const sqlite = createNodeSqliteFactory();
|
||||
const repo = new SqliteSessionRepo({ env, sqlite, databasePath });
|
||||
const session = await repo.create({ cwd: root, id: "session-1" });
|
||||
const repo = new SessionRepo({ storage: new SqliteSessionStore({ env, sqlite, databasePath }) });
|
||||
const session = await repo.storage.create({ cwd: root, id: "session-1" });
|
||||
const userId = await session.appendMessage(createUserMessage("one"));
|
||||
await session.appendThinkingLevelChange("high");
|
||||
await session.appendModelChange("anthropic", "claude-sonnet-4-5");
|
||||
|
|
|
|||
|
|
@ -3,11 +3,12 @@ import { describe, expect, it } from "vitest";
|
|||
import {
|
||||
createNodeSqliteFactory,
|
||||
type SqliteSessionMetadata,
|
||||
SqliteSessionRepo,
|
||||
SqliteSessionSearch,
|
||||
SqliteSessionStore,
|
||||
} from "../../../storage/sqlite-node/src/index.ts";
|
||||
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
|
||||
import { JsonlSessionRepo } from "../../src/harness/session/jsonl-repo.ts";
|
||||
import { JsonlSessionStore } from "../../src/harness/session/jsonl-repo.ts";
|
||||
import { SessionRepo } from "../../src/harness/session/repo-utils.ts";
|
||||
import { FanoutSessionSearch } from "../../src/harness/session/search-backend.ts";
|
||||
import type {
|
||||
JsonlSessionMetadata,
|
||||
|
|
@ -20,39 +21,41 @@ import type {
|
|||
} from "../../src/harness/types.ts";
|
||||
import { createTempDir, createUserMessage } from "./session-test-utils.ts";
|
||||
|
||||
describe("JsonlSessionRepo with scanning search", () => {
|
||||
describe("JsonlSessionStore with scanning search", () => {
|
||||
it("searches canonical session entries by scanning", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const repo = new JsonlSessionRepo({ fs: env, sessionsRoot: join(root, "sessions") });
|
||||
const included = await repo.create({ cwd: root, id: "included" });
|
||||
const excluded = await repo.create({ cwd: `${root}/other`, id: "excluded" });
|
||||
const repo = new SessionRepo({
|
||||
storage: new JsonlSessionStore({ fs: env, sessionsRoot: join(root, "sessions") }),
|
||||
});
|
||||
const included = await repo.storage.create({ cwd: root, id: "included" });
|
||||
const excluded = await repo.storage.create({ cwd: `${root}/other`, id: "excluded" });
|
||||
const entryId = await included.appendMessage(createUserMessage("Find the auth defect"));
|
||||
await excluded.appendMessage(createUserMessage("Find the auth defect"));
|
||||
|
||||
await expect(repo.search({ text: "AUTH", cwd: root })).resolves.toEqual([
|
||||
await expect(repo.search?.search({ text: "AUTH", cwd: root })).resolves.toEqual([
|
||||
expect.objectContaining({ entryId, metadata: expect.objectContaining({ id: "included" }) }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SqliteSessionRepo with default SQLite FTS5 search", () => {
|
||||
describe("SqliteSessionStore with default SQLite FTS5 search", () => {
|
||||
it("uses SQLite FTS5 by default", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const sqlite = createNodeSqliteFactory();
|
||||
const databasePath = join(root, "sessions.sqlite");
|
||||
const repo = new SqliteSessionRepo({ env, sqlite, databasePath });
|
||||
const included = await repo.create({ cwd: root, id: "included" });
|
||||
const excluded = await repo.create({ cwd: `${root}/other`, id: "excluded" });
|
||||
const repo = new SessionRepo({ storage: new SqliteSessionStore({ env, sqlite, databasePath }) });
|
||||
const included = await repo.storage.create({ cwd: root, id: "included" });
|
||||
const excluded = await repo.storage.create({ cwd: `${root}/other`, id: "excluded" });
|
||||
const metadata = await included.getMetadata();
|
||||
const entryId = await included.appendMessage(createUserMessage("Find the auth defect"));
|
||||
await excluded.appendMessage(createUserMessage("Find the auth defect"));
|
||||
|
||||
await expect(repo.search({ text: "auth", cwd: root })).resolves.toEqual([
|
||||
await expect(repo.search?.search({ text: "auth", cwd: root })).resolves.toEqual([
|
||||
expect.objectContaining({ entryId, metadata: expect.objectContaining({ id: "included" }) }),
|
||||
]);
|
||||
await expect(repo.search({ text: "uth", cwd: root })).resolves.toEqual([
|
||||
await expect(repo.search?.search({ text: "uth", cwd: root })).resolves.toEqual([
|
||||
expect.objectContaining({ entryId, metadata: expect.objectContaining({ id: "included" }) }),
|
||||
]);
|
||||
|
||||
|
|
@ -67,33 +70,32 @@ describe("SqliteSessionRepo with default SQLite FTS5 search", () => {
|
|||
await db.close();
|
||||
}
|
||||
|
||||
await repo.delete(metadata);
|
||||
await expect(repo.search({ text: "auth", cwd: root })).resolves.toEqual([]);
|
||||
await repo.storage.delete(metadata);
|
||||
await expect(repo.search?.search({ text: "auth", cwd: root })).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("JsonlSessionRepo with SQLite search index", () => {
|
||||
describe("JsonlSessionStore with SQLite search index", () => {
|
||||
// This is not the intended production pairing; it exists to demonstrate that
|
||||
// Canonical session storage and search are independently swappable/composable.
|
||||
it("writes JSONL session entries into the configured SQLite search", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
const sqlite = createNodeSqliteFactory();
|
||||
const repo = new JsonlSessionRepo({
|
||||
fs: env,
|
||||
sessionsRoot: join(root, "sessions"),
|
||||
const repo = new SessionRepo({
|
||||
storage: new JsonlSessionStore({ fs: env, sessionsRoot: join(root, "sessions") }),
|
||||
search: new SqliteSessionSearch({ env, sqlite, databasePath: join(root, "search.sqlite") }),
|
||||
});
|
||||
const session = await repo.create({ cwd: root, id: "jsonl-session" });
|
||||
const session = await repo.storage.create({ cwd: root, id: "jsonl-session" });
|
||||
const entryId = await session.appendMessage(createUserMessage("Find the auth defect"));
|
||||
|
||||
await expect(repo.search({ text: "auth", cwd: root })).resolves.toEqual([
|
||||
await expect(repo.search?.search({ text: "auth", cwd: root })).resolves.toEqual([
|
||||
expect.objectContaining({ entryId, metadata: expect.objectContaining({ id: "jsonl-session" }) }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("JsonlSessionRepo with multiple search indexes", () => {
|
||||
describe("JsonlSessionStore with multiple search indexes", () => {
|
||||
it("queries one search implementation and fans index writes out to both", async () => {
|
||||
const root = createTempDir();
|
||||
const env = new NodeExecutionEnv({ cwd: root });
|
||||
|
|
@ -108,15 +110,14 @@ describe("JsonlSessionRepo with multiple search indexes", () => {
|
|||
sqlite,
|
||||
databasePath: join(root, "secondary-search.sqlite"),
|
||||
});
|
||||
const repo = new JsonlSessionRepo({
|
||||
fs: env,
|
||||
sessionsRoot: join(root, "sessions"),
|
||||
const repo = new SessionRepo({
|
||||
storage: new JsonlSessionStore({ fs: env, sessionsRoot: join(root, "sessions") }),
|
||||
search: new FanoutSessionSearch({ reader: primary, writers: [primary, secondary] }),
|
||||
});
|
||||
const session = await repo.create({ cwd: root, id: "jsonl-session" });
|
||||
const session = await repo.storage.create({ cwd: root, id: "jsonl-session" });
|
||||
const entryId = await session.appendMessage(createUserMessage("indexed in both places"));
|
||||
|
||||
await expect(repo.search({ text: "both" })).resolves.toEqual([
|
||||
await expect(repo.search?.search({ text: "both" })).resolves.toEqual([
|
||||
expect.objectContaining({ entryId, metadata: expect.objectContaining({ id: "jsonl-session" }) }),
|
||||
]);
|
||||
await expect(secondary.search({ text: "both" })).resolves.toEqual([
|
||||
|
|
@ -125,7 +126,7 @@ describe("JsonlSessionRepo with multiple search indexes", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("SqliteSessionRepo with custom search", () => {
|
||||
describe("SqliteSessionStore with custom search", () => {
|
||||
it("can swap out the default search implementation", async () => {
|
||||
const root = createTempDir();
|
||||
const upserts: SessionSearchRecord<SqliteSessionMetadata>[] = [];
|
||||
|
|
@ -149,13 +150,15 @@ describe("SqliteSessionRepo with custom search", () => {
|
|||
return [];
|
||||
},
|
||||
};
|
||||
const repo = new SqliteSessionRepo({
|
||||
env: new NodeExecutionEnv({ cwd: root }),
|
||||
sqlite: createNodeSqliteFactory(),
|
||||
databasePath: join(root, "sessions.sqlite"),
|
||||
const repo = new SessionRepo({
|
||||
storage: new SqliteSessionStore({
|
||||
env: new NodeExecutionEnv({ cwd: root }),
|
||||
sqlite: createNodeSqliteFactory(),
|
||||
databasePath: join(root, "sessions.sqlite"),
|
||||
}),
|
||||
search,
|
||||
});
|
||||
const session = await repo.create({ cwd: root, id: "session-1" });
|
||||
const session = await repo.storage.create({ cwd: root, id: "session-1" });
|
||||
const metadata = await session.getMetadata();
|
||||
const entryId = await session.appendMessage(createUserMessage("indexed remotely"));
|
||||
await session.getStorage().setLeafId(null);
|
||||
|
|
@ -166,9 +169,9 @@ describe("SqliteSessionRepo with custom search", () => {
|
|||
expect(upserts).toContainEqual(
|
||||
expect.objectContaining({ metadata, entry: expect.objectContaining({ type: "leaf", targetId: null }) }),
|
||||
);
|
||||
await expect(repo.search({ text: "indexed remotely" })).resolves.toEqual([]);
|
||||
await expect(repo.search?.search({ text: "indexed remotely" })).resolves.toEqual([]);
|
||||
expect(searches).toEqual([{ text: "indexed remotely" }]);
|
||||
await repo.delete(metadata);
|
||||
await repo.storage.delete(metadata);
|
||||
expect(removals).toEqual([metadata]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,4 +2,4 @@
|
|||
|
||||
Node sqlite storage backend for `@earendil-works/pi-agent-core` sessions. Provides the
|
||||
`node:sqlite` adapter (`SqliteDatabase` implementation) and the SQLite session
|
||||
repo/storage implementation (`SqliteSessionRepo`, migrations, materialized views).
|
||||
store/storage implementation (`SqliteSessionStore`, migrations, materialized views).
|
||||
|
|
|
|||
|
|
@ -1,16 +1,9 @@
|
|||
import type {
|
||||
Session,
|
||||
SessionSearch,
|
||||
SessionSearchOptions,
|
||||
SessionStorage,
|
||||
SessionTreeEntry,
|
||||
} from "@earendil-works/pi-agent-core";
|
||||
import type { SessionSearch, SessionStorage, SessionTreeEntry } from "@earendil-works/pi-agent-core";
|
||||
import {
|
||||
createSessionId,
|
||||
getEntriesToFork,
|
||||
getFileSystemResultOrThrow,
|
||||
SessionError,
|
||||
toRepoSession,
|
||||
} from "@earendil-works/pi-agent-core";
|
||||
import { applyMigrations } from "./migrations.ts";
|
||||
import { SqliteSessionSearch } from "./search-backend.ts";
|
||||
|
|
@ -22,8 +15,8 @@ import type {
|
|||
SqliteSessionCreateOptions,
|
||||
SqliteSessionListOptions,
|
||||
SqliteSessionMetadata,
|
||||
SqliteSessionRepoApi,
|
||||
SqliteSessionRepoEnv,
|
||||
SqliteSessionStoreApi,
|
||||
SqliteSessionStoreEnv,
|
||||
} from "./types.ts";
|
||||
|
||||
function getParentPath(path: string): string {
|
||||
|
|
@ -45,29 +38,26 @@ async function cleanupSessionStorage(storage: SessionStorage): Promise<void> {
|
|||
if (typeof maybeClosable.cleanup === "function") await maybeClosable.cleanup();
|
||||
}
|
||||
|
||||
export class SqliteSessionRepo implements SqliteSessionRepoApi {
|
||||
private readonly env: SqliteSessionRepoEnv;
|
||||
export class SqliteSessionStore implements SqliteSessionStoreApi {
|
||||
private readonly env: SqliteSessionStoreEnv;
|
||||
private readonly sqlite: SqliteDatabaseFactory;
|
||||
private readonly databasePathInput: string;
|
||||
private readonly sessionSearch: SessionSearch<SqliteSessionMetadata>;
|
||||
readonly defaultSearch: SessionSearch<SqliteSessionMetadata>;
|
||||
private databasePath: string | undefined;
|
||||
|
||||
constructor(options: {
|
||||
env: SqliteSessionRepoEnv;
|
||||
env: SqliteSessionStoreEnv;
|
||||
sqlite: SqliteDatabaseFactory;
|
||||
databasePath: string;
|
||||
search?: SessionSearch<SqliteSessionMetadata>;
|
||||
}) {
|
||||
this.env = options.env;
|
||||
this.sqlite = options.sqlite;
|
||||
this.databasePathInput = options.databasePath;
|
||||
this.sessionSearch =
|
||||
options.search ??
|
||||
new SqliteSessionSearch({
|
||||
env: this.env,
|
||||
sqlite: this.sqlite,
|
||||
databasePath: this.databasePathInput,
|
||||
});
|
||||
this.defaultSearch = new SqliteSessionSearch({
|
||||
env: this.env,
|
||||
sqlite: this.sqlite,
|
||||
databasePath: this.databasePathInput,
|
||||
});
|
||||
}
|
||||
|
||||
private async getDatabasePath(): Promise<string> {
|
||||
|
|
@ -102,24 +92,23 @@ export class SqliteSessionRepo implements SqliteSessionRepoApi {
|
|||
}
|
||||
}
|
||||
|
||||
async create(options: SqliteSessionCreateOptions): Promise<Session<SqliteSessionMetadata>> {
|
||||
async create(options: SqliteSessionCreateOptions): Promise<SessionStorage<SqliteSessionMetadata>> {
|
||||
const db = await this.openDatabase();
|
||||
try {
|
||||
const id = options.id ?? createSessionId();
|
||||
const storage = await SqliteSessionStorage.create(db, await this.getDatabasePath(), {
|
||||
return await SqliteSessionStorage.create(db, await this.getDatabasePath(), {
|
||||
cwd: options.cwd,
|
||||
sessionId: id,
|
||||
parentSessionId: options.parentSessionId,
|
||||
metadata: options.metadata,
|
||||
});
|
||||
return await toRepoSession(storage, this.sessionSearch);
|
||||
} catch (error) {
|
||||
await db.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async open(metadata: SqliteSessionMetadata): Promise<Session<SqliteSessionMetadata>> {
|
||||
async open(metadata: SqliteSessionMetadata): Promise<SessionStorage<SqliteSessionMetadata>> {
|
||||
if (
|
||||
!getFileSystemResultOrThrow(await this.env.exists(metadata.path), `Failed to check database ${metadata.path}`)
|
||||
) {
|
||||
|
|
@ -127,7 +116,7 @@ export class SqliteSessionRepo implements SqliteSessionRepoApi {
|
|||
}
|
||||
const db = await this.openDatabase();
|
||||
try {
|
||||
return await toRepoSession(await SqliteSessionStorage.open(db, metadata), this.sessionSearch);
|
||||
return await SqliteSessionStorage.open(db, metadata);
|
||||
} catch (error) {
|
||||
await db.close();
|
||||
throw error;
|
||||
|
|
@ -156,12 +145,7 @@ export class SqliteSessionRepo implements SqliteSessionRepoApi {
|
|||
}
|
||||
}
|
||||
|
||||
async search(options: SessionSearchOptions) {
|
||||
return await this.sessionSearch.search(options);
|
||||
}
|
||||
|
||||
async delete(metadata: SqliteSessionMetadata): Promise<void> {
|
||||
await this.sessionSearch.index?.removeSession(metadata);
|
||||
const db = await this.openDatabase();
|
||||
try {
|
||||
await db.transaction(async () => {
|
||||
|
|
@ -181,13 +165,13 @@ export class SqliteSessionRepo implements SqliteSessionRepoApi {
|
|||
async fork(
|
||||
sourceMetadata: SqliteSessionMetadata,
|
||||
options: SqliteSessionCreateOptions & { entryId?: string; position?: "before" | "at"; id?: string },
|
||||
): Promise<Session<SqliteSessionMetadata>> {
|
||||
): Promise<SessionStorage<SqliteSessionMetadata>> {
|
||||
const source = await this.open(sourceMetadata);
|
||||
let forkedEntries: SessionTreeEntry[];
|
||||
try {
|
||||
forkedEntries = await getEntriesToFork(source.getStorage(), options);
|
||||
forkedEntries = await getEntriesToFork(source, options);
|
||||
} finally {
|
||||
await cleanupSessionStorage(source.getStorage());
|
||||
await cleanupSessionStorage(source);
|
||||
}
|
||||
const db = await this.openDatabase();
|
||||
try {
|
||||
|
|
@ -199,8 +183,7 @@ export class SqliteSessionRepo implements SqliteSessionRepoApi {
|
|||
metadata: options.metadata ?? sourceMetadata.metadata,
|
||||
});
|
||||
for (const entry of forkedEntries) await storage.appendEntry(entry);
|
||||
await this.sessionSearch.index?.replaceSession(await storage.getMetadata(), forkedEntries);
|
||||
return await toRepoSession(storage, this.sessionSearch);
|
||||
return storage;
|
||||
} catch (error) {
|
||||
await db.close();
|
||||
throw error;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import type {
|
|||
SessionTreeEntry,
|
||||
} from "@earendil-works/pi-agent-core";
|
||||
import { getFileSystemResultOrThrow } from "@earendil-works/pi-agent-core";
|
||||
import type { SqliteDatabase, SqliteDatabaseFactory, SqliteSessionRepoEnv } from "./types.ts";
|
||||
import type { SqliteDatabase, SqliteDatabaseFactory, SqliteSessionStoreEnv } from "./types.ts";
|
||||
|
||||
function getParentPath(path: string): string {
|
||||
const normalized = path.replace(/[\\/]+$/, "");
|
||||
|
|
@ -47,14 +47,14 @@ export class SqliteSessionSearch<TMetadata extends SessionMetadata = SessionMeta
|
|||
{
|
||||
readonly index: SessionSearchIndex<TMetadata> = this;
|
||||
private readonly options: {
|
||||
env: Pick<SqliteSessionRepoEnv, "absolutePath" | "createDir">;
|
||||
env: Pick<SqliteSessionStoreEnv, "absolutePath" | "createDir">;
|
||||
sqlite: SqliteDatabaseFactory;
|
||||
databasePath: string;
|
||||
};
|
||||
private databasePath: string | undefined;
|
||||
|
||||
constructor(options: {
|
||||
env: Pick<SqliteSessionRepoEnv, "absolutePath" | "createDir">;
|
||||
env: Pick<SqliteSessionStoreEnv, "absolutePath" | "createDir">;
|
||||
sqlite: SqliteDatabaseFactory;
|
||||
databasePath: string;
|
||||
}) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { FileSystem, SessionCreateOptions, SessionMetadata, SessionRepo } from "@earendil-works/pi-agent-core";
|
||||
import type { FileSystem, SessionCreateOptions, SessionMetadata, SessionStore } from "@earendil-works/pi-agent-core";
|
||||
|
||||
/** Result of a prepared SQLite statement execution. */
|
||||
export interface SqliteRunResult {
|
||||
|
|
@ -44,7 +44,7 @@ export interface SqliteSessionListOptions {
|
|||
cwd?: string;
|
||||
}
|
||||
|
||||
export interface SqliteSessionRepoApi
|
||||
extends SessionRepo<SqliteSessionMetadata, SqliteSessionCreateOptions, SqliteSessionListOptions> {}
|
||||
export interface SqliteSessionStoreApi
|
||||
extends SessionStore<SqliteSessionMetadata, SqliteSessionCreateOptions, SqliteSessionListOptions> {}
|
||||
|
||||
export type SqliteSessionRepoEnv = Pick<FileSystem, "absolutePath" | "createDir" | "exists">;
|
||||
export type SqliteSessionStoreEnv = Pick<FileSystem, "absolutePath" | "createDir" | "exists">;
|
||||
|
|
|
|||
|
|
@ -10,9 +10,10 @@ import {
|
|||
formatSkillInvocation,
|
||||
formatSkillsForSystemPrompt,
|
||||
getOrThrow,
|
||||
InMemorySessionRepo,
|
||||
MemorySessionStore,
|
||||
ok,
|
||||
parseCommandArgs,
|
||||
SessionRepo,
|
||||
streamProxy,
|
||||
toError,
|
||||
truncateHead,
|
||||
|
|
@ -26,7 +27,7 @@ const stream = createAssistantMessageEventStream();
|
|||
|
||||
const agent = new Agent({ initialState: { model }, streamFn: streamSimple });
|
||||
agent.steer({ role: "user", content: [{ type: "text", text: "queued" }], timestamp: 0 });
|
||||
const repo = new InMemorySessionRepo();
|
||||
const repo = new SessionRepo({ storage: new MemorySessionStore() });
|
||||
const result = getOrThrow(ok({ value: 1 }));
|
||||
const customMessage = createCustomMessage("note", "hello", true, undefined, "2026-01-01T00:00:00.000Z");
|
||||
const llmMessages = convertToLlm([customMessage]);
|
||||
|
|
@ -39,7 +40,7 @@ console.log(
|
|||
schema.type,
|
||||
typeof stream.push,
|
||||
agent.hasQueuedMessages(),
|
||||
typeof repo.create,
|
||||
typeof repo.storage.create,
|
||||
result.value,
|
||||
llmMessages.length,
|
||||
bashExecutionToText({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue