feat(FN-4218): complete Steps 2-5 — schema, store, tests, and docs

Fusion-Task-Id: FN-4218
Fusion-Task-Lineage: 0b50d7f4-5001-4eb6-9633-7b24b00097fd
This commit is contained in:
Fusion
2026-05-13 23:49:20 -07:00
committed by gsxdsm
parent 1d369a268f
commit 773f493419
12 changed files with 751 additions and 26 deletions

View File

@@ -290,7 +290,7 @@ describe("Database", () => {
});
it("seeds schema version", () => {
expect(db.getSchemaVersion()).toBe(74);
expect(db.getSchemaVersion()).toBe(75);
});
it("includes tokenUsageCacheWriteTokens on freshly initialized tasks table", () => {
@@ -318,7 +318,7 @@ describe("Database", () => {
it("is idempotent - calling init() twice does not fail", () => {
expect(() => db.init()).not.toThrow();
expect(db.getSchemaVersion()).toBe(74);
expect(db.getSchemaVersion()).toBe(75);
});
it("does not overwrite existing config on re-init", () => {
// Update the config
@@ -1383,7 +1383,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
expect(db.getSchemaVersion()).toBe(74);
expect(db.getSchemaVersion()).toBe(75);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -1408,11 +1408,11 @@ describe("schema migrations", () => {
const db = new Database(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(74);
expect(db.getSchemaVersion()).toBe(75);
// Re-init should not fail
db.init();
expect(db.getSchemaVersion()).toBe(74);
expect(db.getSchemaVersion()).toBe(75);
db.close();
});
@@ -1447,7 +1447,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(74);
expect(db.getSchemaVersion()).toBe(75);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("priority");
@@ -1488,7 +1488,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(74);
expect(db.getSchemaVersion()).toBe(75);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name);
@@ -1560,7 +1560,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(74);
expect(db.getSchemaVersion()).toBe(75);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name);
@@ -1800,7 +1800,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(74);
expect(db.getSchemaVersion()).toBe(75);
const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("attachments");
@@ -1874,7 +1874,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(74);
expect(db.getSchemaVersion()).toBe(75);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "agentRatings" }]);
@@ -1898,7 +1898,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(74);
expect(db.getSchemaVersion()).toBe(75);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "mission_events" }]);
@@ -2002,7 +2002,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29
expect(db.getSchemaVersion()).toBe(74);
expect(db.getSchemaVersion()).toBe(75);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -2221,7 +2221,7 @@ describe("schema migrations", () => {
localDb.init();
expect(localDb.getSchemaVersion()).toBe(74);
expect(localDb.getSchemaVersion()).toBe(75);
const columns = localDb.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
expect(columns.map((column) => column.name)).toContain("tokenUsageCacheWriteTokens");
@@ -2532,7 +2532,7 @@ describe("createDatabase factory", () => {
const db = createDatabase(fusionDir);
db.init();
expect(db.getSchemaVersion()).toBe(74);
expect(db.getSchemaVersion()).toBe(75);
expect(db.getLastModified()).toBeGreaterThan(0);
db.close();
@@ -2668,7 +2668,7 @@ describe("migration v67 drops orphan project auth tables", () => {
migrated = new Database(fusion);
migrated.init();
expect(migrated.getSchemaVersion()).toBe(74);
expect(migrated.getSchemaVersion()).toBe(75);
const tables = migrated
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
.all() as Array<{ name: string }>;
@@ -2695,7 +2695,7 @@ describe("migration v67 drops orphan project auth tables", () => {
try {
fresh.init();
expect(fresh.getSchemaVersion()).toBe(74);
expect(fresh.getSchemaVersion()).toBe(75);
const tables = fresh
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
.all() as Array<{ name: string }>;

View File

@@ -0,0 +1,171 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { createDatabase, type Database } from "../db.js";
import { ExperimentSessionStore } from "../experiment-session-store.js";
describe("ExperimentSessionStore", () => {
let db: Database;
let store: ExperimentSessionStore;
beforeEach(() => {
const fusionDir = mkdtempSync(join(tmpdir(), "fn-experiment-test-"));
db = createDatabase(fusionDir, { inMemory: true });
db.init();
store = new ExperimentSessionStore(db);
});
it("creates schema tables and indexes and cascades session deletes", () => {
const tables = db
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('experiment_sessions', 'experiment_session_records')")
.all() as Array<{ name: string }>;
expect(tables.map((row) => row.name).sort()).toEqual(["experiment_session_records", "experiment_sessions"]);
const sessionIndexes = db.prepare("PRAGMA index_list(experiment_sessions)").all() as Array<{ name: string }>;
expect(sessionIndexes.map((row) => row.name)).toEqual(
expect.arrayContaining([
"idxExperimentSessionsStatus",
"idxExperimentSessionsProject",
"idxExperimentSessionsCreatedAt",
]),
);
const recordIndexes = db.prepare("PRAGMA index_list(experiment_session_records)").all() as Array<{ name: string }>;
expect(recordIndexes.map((row) => row.name)).toEqual(
expect.arrayContaining(["idxExperimentRecordsSessionSegment", "idxExperimentRecordsType"]),
);
const session = store.createSession({ name: "S1", metric: { name: "latency", direction: "minimize" } });
store.appendRecord(session.id, {
type: "run",
payload: { primaryMetric: 100, secondaryMetrics: [], status: "pending" },
});
expect(store.deleteSession(session.id)).toBe(true);
const count = db.prepare("SELECT COUNT(*) as c FROM experiment_session_records").get() as { c: number };
expect(count.c).toBe(0);
});
it("supports session CRUD, status/finalized events, and list filters", () => {
const onStatus = vi.fn();
const onFinalized = vi.fn();
store.on("session:status_changed", onStatus);
store.on("session:finalized", onFinalized);
const s1 = store.createSession({
name: "alpha bench",
projectId: "proj-a",
metric: { name: "throughput", direction: "maximize" },
tags: ["perf", "ci"],
});
const s2 = store.createSession({
name: "beta stability",
projectId: "proj-b",
status: "finalizing",
metric: { name: "latency", direction: "minimize" },
tags: ["stability"],
workingDir: "apps/api",
});
expect(store.getSession(s1.id)?.name).toBe("alpha bench");
expect(store.listSessions({ projectId: "proj-a" }).map((s) => s.id)).toEqual([s1.id]);
expect(store.listSessions({ status: "finalizing" }).map((s) => s.id)).toEqual([s2.id]);
expect(store.listSessions({ tag: "perf" }).map((s) => s.id)).toEqual([s1.id]);
expect(store.listSessions({ search: "api" }).map((s) => s.id)).toEqual([s2.id]);
const finalized = store.updateSession(s1.id, { status: "finalized" });
expect(finalized.finalizedAt).toBeTruthy();
expect(onStatus).toHaveBeenCalledTimes(1);
expect(onFinalized).toHaveBeenCalledTimes(1);
expect(store.deleteSession(s2.id)).toBe(true);
expect(store.getSession(s2.id)).toBeUndefined();
});
it("maintains contiguous seq per session under interleaved appends", () => {
const a = store.createSession({ name: "A", metric: { name: "m", direction: "maximize" } });
const b = store.createSession({ name: "B", metric: { name: "m", direction: "maximize" } });
store.appendRecord(a.id, { type: "run", payload: { primaryMetric: 1, secondaryMetrics: [], status: "pending" } });
store.appendRecord(b.id, { type: "run", payload: { primaryMetric: 2, secondaryMetrics: [], status: "pending" } });
store.appendRecord(a.id, { type: "run", payload: { primaryMetric: 3, secondaryMetrics: [], status: "keep" } });
store.appendRecord(b.id, { type: "run", payload: { primaryMetric: 4, secondaryMetrics: [], status: "discard" } });
expect(store.listRecords(a.id).map((r) => r.seq)).toEqual([1, 2]);
expect(store.listRecords(b.id).map((r) => r.seq)).toEqual([1, 2]);
});
it("starts new segments and appends config record in new segment", () => {
const session = store.createSession({ name: "seg", metric: { name: "x", direction: "maximize" } });
const { session: updated, record } = store.startNewSegment(session.id, {
metric: { name: "x", direction: "maximize" },
maxIterations: 20,
});
expect(updated.currentSegment).toBe(2);
expect(record.type).toBe("config");
expect(record.segment).toBe(2);
const run = store.appendRecord(session.id, {
type: "run",
payload: { primaryMetric: 5, secondaryMetrics: [], status: "pending" },
});
expect(run.segment).toBe(2);
});
it.each([
["config", { metric: { name: "t", direction: "maximize" } }],
["run", { primaryMetric: 1, secondaryMetrics: [{ name: "cpu", value: 2 }], status: "keep", durationMs: 12 }],
["hook", { hook: "after", exitCode: 0, stdout: "ok" }],
["finalize", { keptRunIds: ["r1"], discardedRunIds: ["r2"], summary: "done" }],
] as const)("round-trips %s payloads", (type, payload) => {
const session = store.createSession({ name: "rt", metric: { name: "m", direction: "maximize" } });
const appended = store.appendRecord(session.id, { type, payload });
const listed = store.listRecords(session.id, { type });
expect(listed).toHaveLength(1);
expect(listed[0]).toEqual(appended);
expect(store.getRecord(appended.id)?.payload).toEqual(payload);
});
it("validates baseline/best run pointers and updates pointers", () => {
const a = store.createSession({ name: "A", metric: { name: "x", direction: "maximize" } });
const b = store.createSession({ name: "B", metric: { name: "x", direction: "maximize" } });
const runA = store.appendRecord(a.id, { type: "run", payload: { primaryMetric: 1, secondaryMetrics: [], status: "keep" } });
const configA = store.appendRecord(a.id, { type: "config", payload: { metric: { name: "x", direction: "maximize" } } });
const runB = store.appendRecord(b.id, { type: "run", payload: { primaryMetric: 2, secondaryMetrics: [], status: "keep" } });
expect(() => store.setBaselineRun(a.id, "missing")).toThrow(/not found/i);
expect(() => store.setBaselineRun(a.id, configA.id)).toThrow(/not a run/i);
expect(() => store.setBestRun(a.id, runB.id)).toThrow(/does not belong/i);
store.setBaselineRun(a.id, runA.id);
const updated = store.setBestRun(a.id, runA.id);
expect(updated.baselineRunId).toBe(runA.id);
expect(updated.bestRunId).toBe(runA.id);
});
it("rejects appends for finalized sessions", () => {
const session = store.createSession({ name: "done", metric: { name: "x", direction: "maximize" } });
store.updateSession(session.id, { status: "finalized" });
const onRecord = vi.fn();
store.on("record:appended", onRecord);
expect(() =>
store.appendRecord(session.id, {
type: "run",
payload: { primaryMetric: 1, secondaryMetrics: [], status: "pending" },
}),
).toThrow(/Cannot append record/i);
expect(onRecord).not.toHaveBeenCalled();
});
it("recordKept is idempotent", () => {
const session = store.createSession({ name: "k", metric: { name: "x", direction: "maximize" } });
const run = store.appendRecord(session.id, {
type: "run",
payload: { primaryMetric: 9, secondaryMetrics: [], status: "keep" },
});
store.recordKept(session.id, run.id);
const updated = store.recordKept(session.id, run.id);
expect(updated.keptRunIds).toEqual([run.id]);
});
});

View File

@@ -886,7 +886,7 @@ describe("Migration: pre-33 DB upgrade", () => {
// Step 1: Create a fresh database at v33 (runs all migrations up to 33)
const db1 = createDatabase(legacyDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(74);
expect(db1.getSchemaVersion()).toBe(75);
db1.close();
// Step 2: Manually downgrade to version 32 and drop insight tables
@@ -921,7 +921,7 @@ describe("Migration: pre-33 DB upgrade", () => {
expect(tableNamesBefore).not.toContain("project_insight_runs");
// Now run init — this triggers the v32→v33 migration
db3.init();
expect(db3.getSchemaVersion()).toBe(74);
expect(db3.getSchemaVersion()).toBe(75);
// Step 4: Verify insight tables exist after migration
const tablesAfter = db3.prepare(
@@ -952,12 +952,12 @@ describe("Migration: pre-33 DB upgrade", () => {
try {
const db1 = createDatabase(testDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(74);
expect(db1.getSchemaVersion()).toBe(75);
db1.close();
const db2 = createDatabase(testDir);
expect(() => db2.init()).not.toThrow();
expect(db2.getSchemaVersion()).toBe(74);
expect(db2.getSchemaVersion()).toBe(75);
db2.close();
} finally {
rmSync(testDir, { recursive: true, force: true });
@@ -971,7 +971,7 @@ describe("Migration: pre-33 DB upgrade", () => {
// Step 1: Create a fresh DB and run migrations
const db1 = createDatabase(compatDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(74);
expect(db1.getSchemaVersion()).toBe(75);
// Step 2: Strip lifecycle and cancelledAt columns by recreating the
// table without them. This simulates a DB that was created before the

View File

@@ -2639,7 +2639,7 @@ describe("MissionStore", () => {
describe("Loop State & Validator Run Schema (v31)", () => {
it("schema version is 40 after migration", () => {
expect(db.getSchemaVersion()).toBe(74);
expect(db.getSchemaVersion()).toBe(75);
});
it("mission_features table has loop state columns", () => {

View File

@@ -584,7 +584,7 @@ describe("Run Audit", () => {
});
it("schema version is bumped to 40", () => {
expect(db.getSchemaVersion()).toBe(74);
expect(db.getSchemaVersion()).toBe(75);
});
});
});

View File

@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
expect(tableNames.has("task_documents")).toBe(true);
expect(tableNames.has("task_document_revisions")).toBe(true);
expect(db.getSchemaVersion()).toBe(74);
expect(db.getSchemaVersion()).toBe(75);
const index = db
.prepare(

View File

@@ -119,7 +119,7 @@ export function probeFts5(db: DatabaseSync): boolean {
// ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 74;
const SCHEMA_VERSION = 75;
function normalizeTaskComments(
steeringComments: SteeringComment[] | undefined,
@@ -562,6 +562,42 @@ CREATE TABLE IF NOT EXISTS research_run_events (
);
CREATE INDEX IF NOT EXISTS idxResearchRunEventsRunIdSeq ON research_run_events(runId, seq);
CREATE TABLE IF NOT EXISTS experiment_sessions (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
projectId TEXT,
status TEXT NOT NULL,
metric TEXT NOT NULL,
currentSegment INTEGER NOT NULL DEFAULT 1,
maxIterations INTEGER,
workingDir TEXT,
baselineRunId TEXT,
bestRunId TEXT,
keptRunIds TEXT NOT NULL DEFAULT '[]',
tags TEXT NOT NULL DEFAULT '[]',
metadata TEXT,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
finalizedAt TEXT
);
CREATE INDEX IF NOT EXISTS idxExperimentSessionsStatus ON experiment_sessions(status);
CREATE INDEX IF NOT EXISTS idxExperimentSessionsProject ON experiment_sessions(projectId);
CREATE INDEX IF NOT EXISTS idxExperimentSessionsCreatedAt ON experiment_sessions(createdAt);
CREATE TABLE IF NOT EXISTS experiment_session_records (
id TEXT PRIMARY KEY,
sessionId TEXT NOT NULL,
segment INTEGER NOT NULL,
seq INTEGER NOT NULL,
type TEXT NOT NULL,
payload TEXT NOT NULL,
createdAt TEXT NOT NULL,
FOREIGN KEY (sessionId) REFERENCES experiment_sessions(id) ON DELETE CASCADE,
UNIQUE(sessionId, seq)
);
CREATE INDEX IF NOT EXISTS idxExperimentRecordsSessionSegment ON experiment_session_records(sessionId, segment, seq);
CREATE INDEX IF NOT EXISTS idxExperimentRecordsType ON experiment_session_records(sessionId, type);
-- Eval run persistence (FN-3387)
CREATE TABLE IF NOT EXISTS eval_runs (
id TEXT PRIMARY KEY,
@@ -3164,6 +3200,50 @@ export class Database {
});
}
if (version < 75) {
this.applyMigration(75, () => {
this.db.exec(`
CREATE TABLE IF NOT EXISTS experiment_sessions (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
projectId TEXT,
status TEXT NOT NULL,
metric TEXT NOT NULL,
currentSegment INTEGER NOT NULL DEFAULT 1,
maxIterations INTEGER,
workingDir TEXT,
baselineRunId TEXT,
bestRunId TEXT,
keptRunIds TEXT NOT NULL DEFAULT '[]',
tags TEXT NOT NULL DEFAULT '[]',
metadata TEXT,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
finalizedAt TEXT
)
`);
this.db.exec(`CREATE INDEX IF NOT EXISTS idxExperimentSessionsStatus ON experiment_sessions(status)`);
this.db.exec(`CREATE INDEX IF NOT EXISTS idxExperimentSessionsProject ON experiment_sessions(projectId)`);
this.db.exec(`CREATE INDEX IF NOT EXISTS idxExperimentSessionsCreatedAt ON experiment_sessions(createdAt)`);
this.db.exec(`
CREATE TABLE IF NOT EXISTS experiment_session_records (
id TEXT PRIMARY KEY,
sessionId TEXT NOT NULL,
segment INTEGER NOT NULL,
seq INTEGER NOT NULL,
type TEXT NOT NULL,
payload TEXT NOT NULL,
createdAt TEXT NOT NULL,
FOREIGN KEY (sessionId) REFERENCES experiment_sessions(id) ON DELETE CASCADE,
UNIQUE(sessionId, seq)
)
`);
this.db.exec(`CREATE INDEX IF NOT EXISTS idxExperimentRecordsSessionSegment ON experiment_session_records(sessionId, segment, seq)`);
this.db.exec(`CREATE INDEX IF NOT EXISTS idxExperimentRecordsType ON experiment_session_records(sessionId, type)`);
});
}
}
/**

View File

@@ -0,0 +1,349 @@
import { randomUUID } from "node:crypto";
import { EventEmitter } from "node:events";
import type { Database } from "./db.js";
import { fromJson, toJson, toJsonNullable } from "./db.js";
import type {
ExperimentConfigRecordPayload,
ExperimentRecordType,
ExperimentSession,
ExperimentSessionCreateInput,
ExperimentSessionListOptions,
ExperimentSessionRecord,
ExperimentSessionRecordAppendInput,
ExperimentSessionStatus,
ExperimentSessionStoreEvents,
ExperimentSessionUpdateInput,
} from "./experiment-session-types.js";
function generateId(prefix: string): string {
return `${prefix}-${randomUUID()}`;
}
export class ExperimentSessionStore extends EventEmitter<ExperimentSessionStoreEvents> {
private readonly insertSessionStmt;
constructor(private readonly db: Database) {
super();
this.setMaxListeners(50);
this.insertSessionStmt = this.db.prepare(`
INSERT INTO experiment_sessions (
id, name, projectId, status, metric, currentSegment, maxIterations, workingDir,
baselineRunId, bestRunId, keptRunIds, tags, metadata, createdAt, updatedAt, finalizedAt
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
}
createSession(input: ExperimentSessionCreateInput): ExperimentSession {
const now = new Date().toISOString();
const session: ExperimentSession = {
id: generateId("EXP"),
name: input.name,
projectId: input.projectId,
status: input.status ?? "active",
metric: input.metric,
currentSegment: input.currentSegment ?? 1,
maxIterations: input.maxIterations,
workingDir: input.workingDir,
baselineRunId: input.baselineRunId,
bestRunId: input.bestRunId,
keptRunIds: input.keptRunIds ?? [],
tags: input.tags ?? [],
metadata: input.metadata,
createdAt: now,
updatedAt: now,
finalizedAt: input.finalizedAt,
};
this.insertSessionStmt.run(
session.id,
session.name,
session.projectId ?? null,
session.status,
toJson(session.metric),
session.currentSegment,
session.maxIterations ?? null,
session.workingDir ?? null,
session.baselineRunId ?? null,
session.bestRunId ?? null,
toJson(session.keptRunIds),
toJson(session.tags),
toJsonNullable(session.metadata),
session.createdAt,
session.updatedAt,
session.finalizedAt ?? null,
);
this.db.bumpLastModified();
this.emit("session:created", session);
return session;
}
getSession(id: string): ExperimentSession | undefined {
const row = this.db.prepare("SELECT * FROM experiment_sessions WHERE id = ?").get(id) as Record<string, unknown> | undefined;
return row ? this.rowToSession(row) : undefined;
}
listSessions(options: ExperimentSessionListOptions = {}): ExperimentSession[] {
const where: string[] = [];
const params: Array<string | number> = [];
if (options.status) {
where.push("status = ?");
params.push(options.status);
}
if (options.projectId) {
where.push("projectId = ?");
params.push(options.projectId);
}
if (options.tag) {
where.push("tags LIKE ?");
params.push(`%\"${options.tag}\"%`);
}
if (options.search) {
where.push("(name LIKE ? OR COALESCE(workingDir, '') LIKE ?)");
params.push(`%${options.search}%`, `%${options.search}%`);
}
const whereClause = where.length ? `WHERE ${where.join(" AND ")}` : "";
const limitClause = options.limit !== undefined ? `LIMIT ${options.limit}` : "";
const offsetClause = options.offset !== undefined ? `OFFSET ${options.offset}` : "";
const rows = this.db.prepare(`
SELECT * FROM experiment_sessions
${whereClause}
ORDER BY createdAt DESC
${limitClause}
${offsetClause}
`).all(...params) as Record<string, unknown>[];
return rows.map((row) => this.rowToSession(row));
}
updateSession(id: string, patch: ExperimentSessionUpdateInput): ExperimentSession {
const existing = this.getSession(id);
if (!existing) throw new Error(`Experiment session not found: ${id}`);
const now = new Date().toISOString();
const status = patch.status ?? existing.status;
const finalizedAt = status === "finalized" ? (patch.finalizedAt ?? existing.finalizedAt ?? now) : (patch.finalizedAt ?? existing.finalizedAt);
const updated: ExperimentSession = {
...existing,
...patch,
status,
finalizedAt,
updatedAt: now,
};
this.persistSession(updated);
this.db.bumpLastModified();
this.emit("session:updated", updated);
if (updated.status !== existing.status) {
this.emit("session:status_changed", updated);
if (updated.status === "finalized") {
this.emit("session:finalized", updated);
}
}
return updated;
}
deleteSession(id: string): boolean {
const result = this.db.prepare("DELETE FROM experiment_sessions WHERE id = ?").run(id) as { changes?: number };
const deleted = (result.changes ?? 0) > 0;
if (deleted) {
this.db.bumpLastModified();
this.emit("session:deleted", id);
}
return deleted;
}
appendRecord(sessionId: string, input: ExperimentSessionRecordAppendInput): ExperimentSessionRecord {
const session = this.getSession(sessionId);
if (!session) throw new Error(`Experiment session not found: ${sessionId}`);
if (session.status === "finalized" || session.status === "archived") {
throw new Error(`Cannot append record to ${session.status} session: ${sessionId}`);
}
const now = new Date().toISOString();
const record = this.db.transaction(() => {
const seqRow = this.db
.prepare("SELECT COALESCE(MAX(seq), 0) + 1 as nextSeq FROM experiment_session_records WHERE sessionId = ?")
.get(sessionId) as { nextSeq: number };
const nextSeq = seqRow.nextSeq;
const created: ExperimentSessionRecord = {
id: generateId("EXPR"),
sessionId,
segment: input.segment ?? session.currentSegment,
seq: nextSeq,
type: input.type,
payload: input.payload,
createdAt: now,
} as ExperimentSessionRecord;
this.db.prepare(`
INSERT INTO experiment_session_records (id, sessionId, segment, seq, type, payload, createdAt)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(created.id, created.sessionId, created.segment, created.seq, created.type, toJson(created.payload), created.createdAt);
return created;
});
this.db.bumpLastModified();
this.emit("record:appended", record);
return record;
}
listRecords(sessionId: string, opts: { segment?: number; type?: ExperimentRecordType; limit?: number; offset?: number } = {}): ExperimentSessionRecord[] {
const where = ["sessionId = ?"];
const params: Array<string | number> = [sessionId];
if (opts.segment !== undefined) {
where.push("segment = ?");
params.push(opts.segment);
}
if (opts.type) {
where.push("type = ?");
params.push(opts.type);
}
const limitClause = opts.limit !== undefined ? `LIMIT ${opts.limit}` : "";
const offsetClause = opts.offset !== undefined ? `OFFSET ${opts.offset}` : "";
const rows = this.db.prepare(`
SELECT * FROM experiment_session_records
WHERE ${where.join(" AND ")}
ORDER BY seq ASC
${limitClause}
${offsetClause}
`).all(...params) as Record<string, unknown>[];
return rows.map((row) => this.rowToRecord(row));
}
getRecord(id: string): ExperimentSessionRecord | undefined {
const row = this.db.prepare("SELECT * FROM experiment_session_records WHERE id = ?").get(id) as Record<string, unknown> | undefined;
return row ? this.rowToRecord(row) : undefined;
}
startNewSegment(sessionId: string, configPayload: ExperimentConfigRecordPayload): { session: ExperimentSession; record: ExperimentSessionRecord } {
const result = this.db.transaction(() => {
const session = this.getSession(sessionId);
if (!session) throw new Error(`Experiment session not found: ${sessionId}`);
const nextSegment = session.currentSegment + 1;
const updated: ExperimentSession = { ...session, currentSegment: nextSegment, updatedAt: new Date().toISOString() };
this.persistSession(updated);
const seqRow = this.db
.prepare("SELECT COALESCE(MAX(seq), 0) + 1 as nextSeq FROM experiment_session_records WHERE sessionId = ?")
.get(sessionId) as { nextSeq: number };
const record: ExperimentSessionRecord = {
id: generateId("EXPR"),
sessionId,
segment: nextSegment,
seq: seqRow.nextSeq,
type: "config",
payload: configPayload,
createdAt: new Date().toISOString(),
};
this.db.prepare(`
INSERT INTO experiment_session_records (id, sessionId, segment, seq, type, payload, createdAt)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(record.id, record.sessionId, record.segment, record.seq, record.type, toJson(record.payload), record.createdAt);
return { session: updated, record };
});
this.db.bumpLastModified();
this.emit("segment:reset", { sessionId, segment: result.session.currentSegment });
this.emit("record:appended", result.record);
return result;
}
setBaselineRun(sessionId: string, runRecordId: string): ExperimentSession {
const session = this.assertRunRecordOwnership(sessionId, runRecordId);
return this.updateSession(session.id, { baselineRunId: runRecordId });
}
setBestRun(sessionId: string, runRecordId: string): ExperimentSession {
const session = this.assertRunRecordOwnership(sessionId, runRecordId);
return this.updateSession(session.id, { bestRunId: runRecordId });
}
recordKept(sessionId: string, runRecordId: string): ExperimentSession {
const session = this.assertRunRecordOwnership(sessionId, runRecordId);
const keptRunIds = session.keptRunIds.includes(runRecordId)
? session.keptRunIds
: [...session.keptRunIds, runRecordId];
return this.updateSession(sessionId, { keptRunIds });
}
private assertRunRecordOwnership(sessionId: string, runRecordId: string): ExperimentSession {
const session = this.getSession(sessionId);
if (!session) throw new Error(`Experiment session not found: ${sessionId}`);
const record = this.getRecord(runRecordId);
if (!record) throw new Error(`Experiment record not found: ${runRecordId}`);
if (record.type !== "run") throw new Error(`Experiment record is not a run: ${runRecordId}`);
if (record.sessionId !== sessionId) throw new Error(`Experiment record ${runRecordId} does not belong to session ${sessionId}`);
return session;
}
private persistSession(session: ExperimentSession): void {
this.db.prepare(`
UPDATE experiment_sessions
SET name = ?, projectId = ?, status = ?, metric = ?, currentSegment = ?, maxIterations = ?,
workingDir = ?, baselineRunId = ?, bestRunId = ?, keptRunIds = ?, tags = ?, metadata = ?,
updatedAt = ?, finalizedAt = ?
WHERE id = ?
`).run(
session.name,
session.projectId ?? null,
session.status,
toJson(session.metric),
session.currentSegment,
session.maxIterations ?? null,
session.workingDir ?? null,
session.baselineRunId ?? null,
session.bestRunId ?? null,
toJson(session.keptRunIds),
toJson(session.tags),
toJsonNullable(session.metadata),
session.updatedAt,
session.finalizedAt ?? null,
session.id,
);
}
private rowToSession(row: Record<string, unknown>): ExperimentSession {
return {
id: row.id as string,
name: row.name as string,
projectId: (row.projectId as string | null) ?? undefined,
status: row.status as ExperimentSessionStatus,
metric: fromJson<ExperimentSession["metric"]>(row.metric as string | null) ?? { name: "unknown", direction: "maximize" },
currentSegment: Number(row.currentSegment ?? 1),
maxIterations: (row.maxIterations as number | null) ?? undefined,
workingDir: (row.workingDir as string | null) ?? undefined,
baselineRunId: (row.baselineRunId as string | null) ?? undefined,
bestRunId: (row.bestRunId as string | null) ?? undefined,
keptRunIds: fromJson<string[]>(row.keptRunIds as string | null) ?? [],
tags: fromJson<string[]>(row.tags as string | null) ?? [],
metadata: fromJson<Record<string, unknown>>(row.metadata as string | null),
createdAt: row.createdAt as string,
updatedAt: row.updatedAt as string,
finalizedAt: (row.finalizedAt as string | null) ?? undefined,
};
}
private rowToRecord(row: Record<string, unknown>): ExperimentSessionRecord {
return {
id: row.id as string,
sessionId: row.sessionId as string,
segment: Number(row.segment),
seq: Number(row.seq),
type: row.type as ExperimentSessionRecord["type"],
payload: fromJson<ExperimentSessionRecord["payload"]>(row.payload as string | null) ?? {},
createdAt: row.createdAt as string,
} as ExperimentSessionRecord;
}
}

View File

@@ -184,6 +184,36 @@ export { AutomationStore } from "./automation-store.js";
export type { AutomationStoreEvents } from "./automation-store.js";
export { runCommandAsync } from "./run-command.js";
export type { RunCommandOptions, RunCommandResult } from "./run-command.js";
export {
EXPERIMENT_SESSION_STATUSES,
EXPERIMENT_METRIC_DIRECTIONS,
EXPERIMENT_RECORD_TYPES,
EXPERIMENT_RUN_OUTCOMES,
isRunRecord,
isConfigRecord,
isHookRecord,
isFinalizeRecord,
} from "./experiment-session-types.js";
export type {
ExperimentSessionStatus,
ExperimentMetricDirection,
ExperimentMetricDefinition,
ExperimentRecordType,
ExperimentRunOutcome,
ExperimentSecondaryMetric,
ExperimentRunRecordPayload,
ExperimentConfigRecordPayload,
ExperimentHookRecordPayload,
ExperimentFinalizeRecordPayload,
ExperimentSessionRecord,
ExperimentSession,
ExperimentSessionCreateInput,
ExperimentSessionUpdateInput,
ExperimentSessionRecordAppendInput,
ExperimentSessionListOptions,
ExperimentSessionStoreEvents,
} from "./experiment-session-types.js";
export { ExperimentSessionStore } from "./experiment-session-store.js";
export {
detectFnBinary,
FN_NPM_PACKAGE,