mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-17 10:02:26 +00:00
chore: fix bad merge
This commit is contained in:
parent
1dd45908a3
commit
94353f7696
205 changed files with 19244 additions and 14866 deletions
45
sdks/persist-rivet/package.json
Normal file
45
sdks/persist-rivet/package.json
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
{
|
||||
"name": "@sandbox-agent/persist-rivet",
|
||||
"version": "0.1.0",
|
||||
"description": "Rivet Actor persistence driver for the Sandbox Agent TypeScript SDK",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/rivet-dev/sandbox-agent"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"sandbox-agent": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"rivetkit": ">=0.5.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"rivetkit": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
180
sdks/persist-rivet/src/index.ts
Normal file
180
sdks/persist-rivet/src/index.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import type {
|
||||
ListEventsRequest,
|
||||
ListPage,
|
||||
ListPageRequest,
|
||||
SessionEvent,
|
||||
SessionPersistDriver,
|
||||
SessionRecord,
|
||||
} from "sandbox-agent";
|
||||
|
||||
/** Structural type compatible with rivetkit's ActorContext without importing it. */
|
||||
export interface ActorContextLike {
|
||||
state: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface RivetPersistData {
|
||||
sessions: Record<string, SessionRecord>;
|
||||
events: Record<string, SessionEvent[]>;
|
||||
}
|
||||
|
||||
export type RivetPersistState = {
|
||||
_sandboxAgentPersist: RivetPersistData;
|
||||
};
|
||||
|
||||
export interface RivetSessionPersistDriverOptions {
|
||||
/** Maximum number of sessions to retain. Oldest are evicted first. Default: 1024. */
|
||||
maxSessions?: number;
|
||||
/** Maximum events per session. Oldest are trimmed first. Default: 500. */
|
||||
maxEventsPerSession?: number;
|
||||
/** Key on `c.state` where persist data is stored. Default: `"_sandboxAgentPersist"`. */
|
||||
stateKey?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_SESSIONS = 1024;
|
||||
const DEFAULT_MAX_EVENTS_PER_SESSION = 500;
|
||||
const DEFAULT_LIST_LIMIT = 100;
|
||||
const DEFAULT_STATE_KEY = "_sandboxAgentPersist";
|
||||
|
||||
export class RivetSessionPersistDriver implements SessionPersistDriver {
|
||||
private readonly maxSessions: number;
|
||||
private readonly maxEventsPerSession: number;
|
||||
private readonly stateKey: string;
|
||||
private readonly ctx: ActorContextLike;
|
||||
|
||||
constructor(ctx: ActorContextLike, options: RivetSessionPersistDriverOptions = {}) {
|
||||
this.ctx = ctx;
|
||||
this.maxSessions = normalizeCap(options.maxSessions, DEFAULT_MAX_SESSIONS);
|
||||
this.maxEventsPerSession = normalizeCap(
|
||||
options.maxEventsPerSession,
|
||||
DEFAULT_MAX_EVENTS_PER_SESSION,
|
||||
);
|
||||
this.stateKey = options.stateKey ?? DEFAULT_STATE_KEY;
|
||||
|
||||
// Auto-initialize if absent; preserve existing data on actor wake.
|
||||
if (!this.ctx.state[this.stateKey]) {
|
||||
this.ctx.state[this.stateKey] = { sessions: {}, events: {} } satisfies RivetPersistData;
|
||||
}
|
||||
}
|
||||
|
||||
private get data(): RivetPersistData {
|
||||
return this.ctx.state[this.stateKey] as RivetPersistData;
|
||||
}
|
||||
|
||||
async getSession(id: string): Promise<SessionRecord | null> {
|
||||
const session = this.data.sessions[id];
|
||||
return session ? cloneSessionRecord(session) : null;
|
||||
}
|
||||
|
||||
async listSessions(request: ListPageRequest = {}): Promise<ListPage<SessionRecord>> {
|
||||
const sorted = Object.values(this.data.sessions).sort((a, b) => {
|
||||
if (a.createdAt !== b.createdAt) {
|
||||
return a.createdAt - b.createdAt;
|
||||
}
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
const page = paginate(sorted, request);
|
||||
return {
|
||||
items: page.items.map(cloneSessionRecord),
|
||||
nextCursor: page.nextCursor,
|
||||
};
|
||||
}
|
||||
|
||||
async updateSession(session: SessionRecord): Promise<void> {
|
||||
this.data.sessions[session.id] = { ...session };
|
||||
|
||||
if (!this.data.events[session.id]) {
|
||||
this.data.events[session.id] = [];
|
||||
}
|
||||
|
||||
const ids = Object.keys(this.data.sessions);
|
||||
if (ids.length <= this.maxSessions) {
|
||||
return;
|
||||
}
|
||||
|
||||
const overflow = ids.length - this.maxSessions;
|
||||
const removable = Object.values(this.data.sessions)
|
||||
.sort((a, b) => {
|
||||
if (a.createdAt !== b.createdAt) {
|
||||
return a.createdAt - b.createdAt;
|
||||
}
|
||||
return a.id.localeCompare(b.id);
|
||||
})
|
||||
.slice(0, overflow)
|
||||
.map((s) => s.id);
|
||||
|
||||
for (const sessionId of removable) {
|
||||
delete this.data.sessions[sessionId];
|
||||
delete this.data.events[sessionId];
|
||||
}
|
||||
}
|
||||
|
||||
async listEvents(request: ListEventsRequest): Promise<ListPage<SessionEvent>> {
|
||||
const all = [...(this.data.events[request.sessionId] ?? [])].sort((a, b) => {
|
||||
if (a.eventIndex !== b.eventIndex) {
|
||||
return a.eventIndex - b.eventIndex;
|
||||
}
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
const page = paginate(all, request);
|
||||
return {
|
||||
items: page.items.map(cloneSessionEvent),
|
||||
nextCursor: page.nextCursor,
|
||||
};
|
||||
}
|
||||
|
||||
async insertEvent(event: SessionEvent): Promise<void> {
|
||||
const events = this.data.events[event.sessionId] ?? [];
|
||||
events.push(cloneSessionEvent(event));
|
||||
|
||||
if (events.length > this.maxEventsPerSession) {
|
||||
events.splice(0, events.length - this.maxEventsPerSession);
|
||||
}
|
||||
|
||||
this.data.events[event.sessionId] = events;
|
||||
}
|
||||
}
|
||||
|
||||
function cloneSessionRecord(session: SessionRecord): SessionRecord {
|
||||
return {
|
||||
...session,
|
||||
sessionInit: session.sessionInit
|
||||
? (JSON.parse(JSON.stringify(session.sessionInit)) as SessionRecord["sessionInit"])
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function cloneSessionEvent(event: SessionEvent): SessionEvent {
|
||||
return {
|
||||
...event,
|
||||
payload: JSON.parse(JSON.stringify(event.payload)) as SessionEvent["payload"],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCap(value: number | undefined, fallback: number): number {
|
||||
if (!Number.isFinite(value) || (value ?? 0) < 1) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.floor(value as number);
|
||||
}
|
||||
|
||||
function paginate<T>(items: T[], request: ListPageRequest): ListPage<T> {
|
||||
const offset = parseCursor(request.cursor);
|
||||
const limit = normalizeCap(request.limit, DEFAULT_LIST_LIMIT);
|
||||
const slice = items.slice(offset, offset + limit);
|
||||
const nextOffset = offset + slice.length;
|
||||
return {
|
||||
items: slice,
|
||||
nextCursor: nextOffset < items.length ? String(nextOffset) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function parseCursor(cursor: string | undefined): number {
|
||||
if (!cursor) {
|
||||
return 0;
|
||||
}
|
||||
const parsed = Number.parseInt(cursor, 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return 0;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
236
sdks/persist-rivet/tests/driver.test.ts
Normal file
236
sdks/persist-rivet/tests/driver.test.ts
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { RivetSessionPersistDriver } from "../src/index.ts";
|
||||
import type { RivetPersistData } from "../src/index.ts";
|
||||
|
||||
function makeCtx() {
|
||||
return { state: {} as Record<string, unknown> };
|
||||
}
|
||||
|
||||
describe("RivetSessionPersistDriver", () => {
|
||||
it("auto-initializes state on construction", () => {
|
||||
const ctx = makeCtx();
|
||||
new RivetSessionPersistDriver(ctx);
|
||||
const data = ctx.state._sandboxAgentPersist as RivetPersistData;
|
||||
expect(data).toBeDefined();
|
||||
expect(data.sessions).toEqual({});
|
||||
expect(data.events).toEqual({});
|
||||
});
|
||||
|
||||
it("preserves existing state on construction (actor wake)", async () => {
|
||||
const ctx = makeCtx();
|
||||
const driver1 = new RivetSessionPersistDriver(ctx);
|
||||
|
||||
await driver1.updateSession({
|
||||
id: "s-1",
|
||||
agent: "mock",
|
||||
agentSessionId: "a-1",
|
||||
lastConnectionId: "c-1",
|
||||
createdAt: 100,
|
||||
});
|
||||
|
||||
// Simulate actor wake: new driver instance, same state object
|
||||
const driver2 = new RivetSessionPersistDriver(ctx);
|
||||
const session = await driver2.getSession("s-1");
|
||||
expect(session?.id).toBe("s-1");
|
||||
expect(session?.createdAt).toBe(100);
|
||||
});
|
||||
|
||||
it("stores and retrieves sessions", async () => {
|
||||
const driver = new RivetSessionPersistDriver(makeCtx());
|
||||
|
||||
await driver.updateSession({
|
||||
id: "s-1",
|
||||
agent: "mock",
|
||||
agentSessionId: "a-1",
|
||||
lastConnectionId: "c-1",
|
||||
createdAt: 100,
|
||||
});
|
||||
|
||||
await driver.updateSession({
|
||||
id: "s-2",
|
||||
agent: "mock",
|
||||
agentSessionId: "a-2",
|
||||
lastConnectionId: "c-2",
|
||||
createdAt: 200,
|
||||
destroyedAt: 300,
|
||||
});
|
||||
|
||||
const loaded = await driver.getSession("s-2");
|
||||
expect(loaded?.destroyedAt).toBe(300);
|
||||
|
||||
const missing = await driver.getSession("s-nonexistent");
|
||||
expect(missing).toBeNull();
|
||||
});
|
||||
|
||||
it("pages sessions sorted by createdAt", async () => {
|
||||
const driver = new RivetSessionPersistDriver(makeCtx());
|
||||
|
||||
await driver.updateSession({
|
||||
id: "s-1",
|
||||
agent: "mock",
|
||||
agentSessionId: "a-1",
|
||||
lastConnectionId: "c-1",
|
||||
createdAt: 100,
|
||||
});
|
||||
|
||||
await driver.updateSession({
|
||||
id: "s-2",
|
||||
agent: "mock",
|
||||
agentSessionId: "a-2",
|
||||
lastConnectionId: "c-2",
|
||||
createdAt: 200,
|
||||
});
|
||||
|
||||
const page1 = await driver.listSessions({ limit: 1 });
|
||||
expect(page1.items).toHaveLength(1);
|
||||
expect(page1.items[0]?.id).toBe("s-1");
|
||||
expect(page1.nextCursor).toBeTruthy();
|
||||
|
||||
const page2 = await driver.listSessions({ cursor: page1.nextCursor, limit: 1 });
|
||||
expect(page2.items).toHaveLength(1);
|
||||
expect(page2.items[0]?.id).toBe("s-2");
|
||||
expect(page2.nextCursor).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stores and pages events", async () => {
|
||||
const driver = new RivetSessionPersistDriver(makeCtx());
|
||||
|
||||
await driver.updateSession({
|
||||
id: "s-1",
|
||||
agent: "mock",
|
||||
agentSessionId: "a-1",
|
||||
lastConnectionId: "c-1",
|
||||
createdAt: 1,
|
||||
});
|
||||
|
||||
await driver.insertEvent({
|
||||
id: "evt-1",
|
||||
eventIndex: 1,
|
||||
sessionId: "s-1",
|
||||
createdAt: 1,
|
||||
connectionId: "c-1",
|
||||
sender: "client",
|
||||
payload: { jsonrpc: "2.0", method: "session/prompt", params: { sessionId: "a-1" } },
|
||||
});
|
||||
|
||||
await driver.insertEvent({
|
||||
id: "evt-2",
|
||||
eventIndex: 2,
|
||||
sessionId: "s-1",
|
||||
createdAt: 2,
|
||||
connectionId: "c-1",
|
||||
sender: "agent",
|
||||
payload: { jsonrpc: "2.0", method: "session/update", params: { sessionId: "a-1" } },
|
||||
});
|
||||
|
||||
const eventsPage = await driver.listEvents({ sessionId: "s-1", limit: 10 });
|
||||
expect(eventsPage.items).toHaveLength(2);
|
||||
expect(eventsPage.items[0]?.id).toBe("evt-1");
|
||||
expect(eventsPage.items[0]?.eventIndex).toBe(1);
|
||||
expect(eventsPage.items[1]?.id).toBe("evt-2");
|
||||
expect(eventsPage.items[1]?.eventIndex).toBe(2);
|
||||
});
|
||||
|
||||
it("evicts oldest sessions when maxSessions exceeded", async () => {
|
||||
const driver = new RivetSessionPersistDriver(makeCtx(), { maxSessions: 2 });
|
||||
|
||||
await driver.updateSession({
|
||||
id: "s-1",
|
||||
agent: "mock",
|
||||
agentSessionId: "a-1",
|
||||
lastConnectionId: "c-1",
|
||||
createdAt: 100,
|
||||
});
|
||||
|
||||
await driver.updateSession({
|
||||
id: "s-2",
|
||||
agent: "mock",
|
||||
agentSessionId: "a-2",
|
||||
lastConnectionId: "c-2",
|
||||
createdAt: 200,
|
||||
});
|
||||
|
||||
// Adding a third session should evict the oldest (s-1)
|
||||
await driver.updateSession({
|
||||
id: "s-3",
|
||||
agent: "mock",
|
||||
agentSessionId: "a-3",
|
||||
lastConnectionId: "c-3",
|
||||
createdAt: 300,
|
||||
});
|
||||
|
||||
expect(await driver.getSession("s-1")).toBeNull();
|
||||
expect(await driver.getSession("s-2")).not.toBeNull();
|
||||
expect(await driver.getSession("s-3")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("trims oldest events when maxEventsPerSession exceeded", async () => {
|
||||
const driver = new RivetSessionPersistDriver(makeCtx(), { maxEventsPerSession: 2 });
|
||||
|
||||
await driver.updateSession({
|
||||
id: "s-1",
|
||||
agent: "mock",
|
||||
agentSessionId: "a-1",
|
||||
lastConnectionId: "c-1",
|
||||
createdAt: 1,
|
||||
});
|
||||
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
await driver.insertEvent({
|
||||
id: `evt-${i}`,
|
||||
eventIndex: i,
|
||||
sessionId: "s-1",
|
||||
createdAt: i,
|
||||
connectionId: "c-1",
|
||||
sender: "client",
|
||||
payload: { jsonrpc: "2.0", method: "session/prompt", params: { sessionId: "a-1" } },
|
||||
});
|
||||
}
|
||||
|
||||
const page = await driver.listEvents({ sessionId: "s-1" });
|
||||
expect(page.items).toHaveLength(2);
|
||||
// Oldest event (evt-1) should be trimmed
|
||||
expect(page.items[0]?.id).toBe("evt-2");
|
||||
expect(page.items[1]?.id).toBe("evt-3");
|
||||
});
|
||||
|
||||
it("clones data to prevent external mutation", async () => {
|
||||
const driver = new RivetSessionPersistDriver(makeCtx());
|
||||
|
||||
await driver.updateSession({
|
||||
id: "s-1",
|
||||
agent: "mock",
|
||||
agentSessionId: "a-1",
|
||||
lastConnectionId: "c-1",
|
||||
createdAt: 1,
|
||||
});
|
||||
|
||||
const s1 = await driver.getSession("s-1");
|
||||
const s2 = await driver.getSession("s-1");
|
||||
expect(s1).toEqual(s2);
|
||||
expect(s1).not.toBe(s2); // Different object references
|
||||
});
|
||||
|
||||
it("supports custom stateKey", async () => {
|
||||
const ctx = makeCtx();
|
||||
const driver = new RivetSessionPersistDriver(ctx, { stateKey: "myPersist" });
|
||||
|
||||
await driver.updateSession({
|
||||
id: "s-1",
|
||||
agent: "mock",
|
||||
agentSessionId: "a-1",
|
||||
lastConnectionId: "c-1",
|
||||
createdAt: 1,
|
||||
});
|
||||
|
||||
expect((ctx.state.myPersist as RivetPersistData).sessions["s-1"]).toBeDefined();
|
||||
expect(ctx.state._sandboxAgentPersist).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns empty results for unknown session events", async () => {
|
||||
const driver = new RivetSessionPersistDriver(makeCtx());
|
||||
const page = await driver.listEvents({ sessionId: "nonexistent" });
|
||||
expect(page.items).toHaveLength(0);
|
||||
expect(page.nextCursor).toBeUndefined();
|
||||
});
|
||||
});
|
||||
16
sdks/persist-rivet/tsconfig.json
Normal file
16
sdks/persist-rivet/tsconfig.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*", "tests/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
10
sdks/persist-rivet/tsup.config.ts
Normal file
10
sdks/persist-rivet/tsup.config.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["esm"],
|
||||
dts: true,
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
target: "es2022",
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue