From fd2e6f3fe2a1d06581b363cc3e106ea89bb149ff Mon Sep 17 00:00:00 2001 From: Christopher Nelson Date: Sun, 23 Aug 2026 12:15:56 -0400 Subject: [PATCH] feat(world-lab): save exports to sqlite --- README.md | 4 + ROADMAP.md | 5 + apps/game-api/package.json | 1 + apps/game-api/src/app.test.ts | 137 ++++++++++++++++++ apps/game-api/src/app.ts | 92 ++++++++++++ .../src/components/world-lab.test.tsx | 61 +++++++- apps/world-lab/src/components/world-lab.tsx | 48 +++++- docs/ARCHITECTURE.md | 10 ++ docs/EXPERIMENT_ARCHIVE.md | 7 + docs/SECURITY.md | 6 + docs/TESTING.md | 7 + docs/adr/0021-manual-direct-sqlite-export.md | 27 ++++ packages/shared/src/index.test.ts | 27 ++++ packages/shared/src/index.ts | 22 +++ pnpm-lock.yaml | 3 + 15 files changed, 450 insertions(+), 7 deletions(-) create mode 100644 docs/adr/0021-manual-direct-sqlite-export.md diff --git a/README.md b/README.md index 739bfc4..2e16717 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,10 @@ See [Testing](docs/TESTING.md), [Architecture](docs/ARCHITECTURE.md), [Security] Completed schema-v10 exports and legacy schema-v9 exports can be imported into an ignored local SQLite archive and queried without repeatedly loading full JSON artifacts. See [Local experiment archive](docs/EXPERIMENT_ARCHIVE.md). +After Generate export, World Lab can also save that exact current validated +artifact to the configured local archive with **Save to SQLite**. Preview +remains an optional estimate and does not gate generation or saving. +The action is manual and idempotent; changed options require regeneration. ## Rename compatibility diff --git a/ROADMAP.md b/ROADMAP.md index 487128f..2d19af0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -98,6 +98,11 @@ Slice A delivers bounded per-agent strategic goals with deterministic revision s Pre-PR-5 observability slice: add a local append-only SQLite experiment archive, transactional schema-v9 export import, bounded human/Codex queries, normalized comparisons, and FTS-searchable curated notes. The in-memory engine remains authoritative. Crash recovery, restartable simulation state, MCP, embeddings, vector search, and a database browser remain deferred. +Follow-up observability slice: after explicit generation, World Lab can manually +save the exact current safe artifact through the Game API to the configured +SQLite archive. Preview remains optional. Automatic persistence, arbitrary +paths or SQL, recovery, scheduling, MCP, and archive authority remain deferred. + Persistent short- and long-term objectives, compact memories, plan revision, summaries, and longer simulation runs. ## PR 6 — Persistent autonomous world diff --git a/apps/game-api/package.json b/apps/game-api/package.json index b2b7298..206a044 100644 --- a/apps/game-api/package.json +++ b/apps/game-api/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@hexzero/agent-runtime": "workspace:*", + "@hexzero/experiment-archive": "workspace:*", "@hexzero/shared": "workspace:*", "@hexzero/world-engine": "workspace:*", "@hono/node-server": "2.1.0", diff --git a/apps/game-api/src/app.test.ts b/apps/game-api/src/app.test.ts index 2c11c46..9e55b90 100644 --- a/apps/game-api/src/app.test.ts +++ b/apps/game-api/src/app.test.ts @@ -7,8 +7,13 @@ import { type AgentProvider, type ProviderDecision, } from '@hexzero/agent-runtime'; +import { + ArchivePersistenceError, + ExperimentImportError, +} from '@hexzero/experiment-archive'; import { cancelledTurnResponseSchema, + archiveExperimentExportResponseSchema, apiErrorSchema, defaultWorldSetupResponseSchema, h3CellSchema, @@ -24,6 +29,7 @@ import { updateAgentPersonalityResponseSchema, updateExperimentModelsResponseSchema, verifyModelResponseSchema, + type ExperimentExportDocument, } from '@hexzero/shared'; import { createApp, @@ -1025,6 +1031,137 @@ describe('game API simulation boundary', () => { ); }); + it('archives the exact supplied generated artifact through an injected writer', async () => { + const archiveExperimentExport = vi.fn( + (document: ExperimentExportDocument) => ({ + experimentId: document.experiment.id, + inserted: 3, + existing: 0, + skipped: 1, + rejected: 0, + idempotent: false, + }), + ); + const app = createApp({ + provider: new ScriptedAgentProvider([ + { worldAction: { type: 'wait' }, summary: 'Wait.' }, + ]), + archiveExperimentExport, + }); + const request = { + agents: { mode: 'all' as const }, + turns: { mode: 'entire-retained' as const }, + outcomes: ['accepted' as const], + actions: ['wait' as const], + level: 'minimal' as const, + }; + const generatedResponse = await app.request( + '/api/simulation/experiment/export', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }, + ); + const generated = experimentExportResponseSchema.parse( + await generatedResponse.json(), + ); + const response = await app.request( + '/api/simulation/experiment/export/archive', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(generated), + }, + ); + expect(response.status).toBe(200); + expect(archiveExperimentExport).toHaveBeenCalledWith(generated.document); + expect( + archiveExperimentExportResponseSchema.parse(await response.json()), + ).toMatchObject({ inserted: 3, idempotent: false }); + }); + + it('rejects invalid archive artifacts before invoking persistence', async () => { + const archiveExperimentExport = vi.fn(); + const app = createApp({ archiveExperimentExport }); + const response = await app.request( + '/api/simulation/experiment/export/archive', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ document: { schemaVersion: 10 } }), + }, + ); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + error: { code: 'invalid_artifact' }, + }); + expect(archiveExperimentExport).not.toHaveBeenCalled(); + }); + + it.each([ + { + error: new ExperimentImportError('unsafe internal rejection detail'), + status: 422, + code: 'archive_rejected', + message: 'The experiment archive rejected the export safely.', + }, + { + error: new ArchivePersistenceError('private filesystem detail'), + status: 500, + code: 'archive_persistence_failed', + message: 'The local experiment archive could not be updated.', + }, + { + error: new ExperimentImportError( + 'wrapped private persistence detail', + new ArchivePersistenceError('private database detail'), + ), + status: 500, + code: 'archive_persistence_failed', + message: 'The local experiment archive could not be updated.', + }, + ])( + 'maps archive failures to safe API errors', + async ({ error, status, code, message }) => { + const sourceApp = createApp(); + const generatedResponse = await sourceApp.request( + '/api/simulation/experiment/export', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + agents: { mode: 'all' }, + turns: { mode: 'entire-retained' }, + outcomes: ['accepted'], + actions: ['wait'], + level: 'minimal', + }), + }, + ); + const generated = experimentExportResponseSchema.parse( + await generatedResponse.json(), + ); + const app = createApp({ + archiveExperimentExport: () => { + throw error; + }, + }); + const response = await app.request( + '/api/simulation/experiment/export/archive', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(generated), + }, + ); + expect(response.status).toBe(status); + const body = await response.json(); + expect(body).toEqual({ error: { code, message } }); + expect(JSON.stringify(body)).not.toMatch(/private|filesystem|database/); + }, + ); + it.each([ [{}, 400, 'invalid_export'], [ diff --git a/apps/game-api/src/app.ts b/apps/game-api/src/app.ts index fccfb79..8e947d0 100644 --- a/apps/game-api/src/app.ts +++ b/apps/game-api/src/app.ts @@ -8,6 +8,8 @@ import { type AgentProvider, } from '@hexzero/agent-runtime'; import { + archiveExperimentExportRequestSchema, + archiveExperimentExportResponseSchema, apiErrorSchema, AGENT_DECISION_CONTRACT_VERSION, cancelSimulationResponseSchema, @@ -44,7 +46,15 @@ import { locationSearchResponseSchema, defaultWorldSetupResponseSchema, type ModelVerification, + type ArchiveExperimentExportResponse, + type ExperimentExportDocument, } from '@hexzero/shared'; +import { + ArchiveDatabase, + ArchivePersistenceError, + ExperimentImportError, + importExperimentExport, +} from '@hexzero/experiment-archive'; import { createDevelopmentWorld, generateDeterministicRoster, @@ -65,6 +75,29 @@ export interface AppOptions { provider?: AgentProvider; catalog?: Pick; geocoder?: Geocoder; + archiveExperimentExport?: ( + document: ExperimentExportDocument, + ) => + ArchiveExperimentExportResponse | Promise; +} + +async function archiveExperimentExportDefault( + document: ExperimentExportDocument, +): Promise { + const archive = new ArchiveDatabase(); + try { + const report = importExperimentExport(archive, document); + return archiveExperimentExportResponseSchema.parse({ + experimentId: report.experimentId, + inserted: report.inserted, + existing: report.existing, + skipped: report.skipped, + rejected: report.rejected, + idempotent: report.inserted === 0 && report.rejected === 0, + }); + } finally { + archive.close(); + } } export function resolveProviderModeFromEnvironment( @@ -106,6 +139,8 @@ export function createApp(options: AppOptions = {}) { new OpenRouterModelCatalog({ apiKey: process.env.OPENROUTER_API_KEY }); const modelVerifications = new Map(); const geocoder = options.geocoder ?? new NominatimGeocoder(); + const archiveExperimentExport = + options.archiveExperimentExport ?? archiveExperimentExportDefault; const turnMutations = new Map>(); const mutationPromise = ( context: Context, @@ -815,6 +850,63 @@ export function createApp(options: AppOptions = {}) { } }); + app.post('/api/simulation/experiment/export/archive', async (context) => { + const request = archiveExperimentExportRequestSchema.safeParse( + await context.req.json().catch(() => undefined), + ); + if (!request.success) + return context.json( + apiErrorSchema.parse({ + error: { + code: 'invalid_artifact', + message: 'The generated experiment export artifact is invalid.', + }, + }), + 400, + ); + try { + return context.json( + archiveExperimentExportResponseSchema.parse( + await archiveExperimentExport(request.data.document), + ), + ); + } catch (error) { + const persistenceFailure = + error instanceof ArchivePersistenceError || + (error instanceof ExperimentImportError && + error.cause instanceof ArchivePersistenceError); + if (persistenceFailure) + return context.json( + apiErrorSchema.parse({ + error: { + code: 'archive_persistence_failed', + message: 'The local experiment archive could not be updated.', + }, + }), + 500, + ); + if (error instanceof ExperimentImportError) + return context.json( + apiErrorSchema.parse({ + error: { + code: 'archive_rejected', + message: 'The experiment archive rejected the export safely.', + }, + }), + 422, + ); + return context.json( + apiErrorSchema.parse({ + error: { + code: 'archive_persistence_failed', + message: 'The local experiment archive could not be updated.', + }, + }), + 500, + ); + } + }); + app.post('/api/simulation/experiment/import', async (context) => { const request = experimentImportRequestSchema.safeParse( await context.req.json().catch(() => undefined), diff --git a/apps/world-lab/src/components/world-lab.test.tsx b/apps/world-lab/src/components/world-lab.test.tsx index cc6ea53..a73acd5 100644 --- a/apps/world-lab/src/components/world-lab.test.tsx +++ b/apps/world-lab/src/components/world-lab.test.tsx @@ -3137,10 +3137,8 @@ describe('WorldLab', () => { it('copies and downloads the exact same validated generated JSON and revokes its URL', async () => { const user = userEvent.setup(); const progressed = afterInfection(); - vi.stubGlobal( - 'fetch', - vi.fn(() => jsonResponse(progressed)), - ); + const fetchMock = vi.fn(async () => jsonResponse(progressed)); + vi.stubGlobal('fetch', fetchMock); const clipboardWrite = vi.fn(async () => undefined); Object.defineProperty(navigator, 'clipboard', { configurable: true, @@ -3162,12 +3160,16 @@ describe('WorldLab', () => { expect( screen.getByRole('button', { name: 'Download JSON' }), ).toBeDisabled(); + expect( + screen.getByRole('button', { name: 'Save to SQLite' }), + ).toBeDisabled(); const document = minimalExportDocument(progressed); - vi.mocked(fetch).mockImplementationOnce(() => jsonResponse({ document })); + const validatedDocument = experimentExportDocumentSchema.parse(document); + fetchMock.mockImplementationOnce(() => jsonResponse({ document })); await user.click(screen.getByRole('button', { name: 'Generate export' })); await user.click(await screen.findByRole('button', { name: 'Copy JSON' })); expect(clipboardWrite).toHaveBeenCalledWith( - JSON.stringify(experimentExportDocumentSchema.parse(document)), + JSON.stringify(validatedDocument), ); clipboardWrite.mockRejectedValueOnce(new Error('denied')); await user.click(screen.getByRole('button', { name: 'Copy JSON' })); @@ -3179,6 +3181,50 @@ describe('WorldLab', () => { expect(downloadedFilename).toMatch( /^hexzero-experiment-.+-one-agent-entire-retained\.json$/, ); + let resolveArchive!: (response: Response) => void; + fetchMock.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveArchive = resolve; + }), + ); + const archiveButton = screen.getByRole('button', { + name: 'Save to SQLite', + }); + const requestsBeforeArchive = fetchMock.mock.calls.length; + fireEvent.click(archiveButton); + fireEvent.click(archiveButton); + expect(fetchMock.mock.calls).toHaveLength(requestsBeforeArchive + 1); + expect(screen.getByRole('button', { name: 'Saving…' })).toBeDisabled(); + resolveArchive( + await jsonResponse({ + experimentId: document.experiment.id, + inserted: 4, + existing: 0, + skipped: 0, + rejected: 0, + idempotent: false, + }), + ); + expect(fetchMock.mock.calls.at(-1)).toEqual([ + expect.stringMatching(/\/experiment\/export\/archive$/), + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ document: validatedDocument }), + }), + ]); + expect(await screen.findByText(/saved to SQLite/)).toBeInTheDocument(); + fetchMock.mockRejectedValueOnce(new Error('archive unavailable')); + await user.click(screen.getByRole('button', { name: 'Save to SQLite' })); + expect( + await screen.findByText(/Could not confirm the SQLite save/), + ).toBeInTheDocument(); + const exportDialog = screen.getByRole('dialog', { + name: 'Experiment export', + }); + expect(within(exportDialog).getByRole('status')).toHaveTextContent( + 'Retry safely with the same generated export.', + ); await user.selectOptions( screen.getByLabelText('JSON serialization'), 'pretty', @@ -3187,6 +3233,9 @@ describe('WorldLab', () => { expect( screen.getByRole('button', { name: 'Download JSON' }), ).toBeDisabled(); + expect( + screen.getByRole('button', { name: 'Save to SQLite' }), + ).toBeDisabled(); expect( screen.getByText('Options changed — regenerate export.'), ).toBeInTheDocument(); diff --git a/apps/world-lab/src/components/world-lab.tsx b/apps/world-lab/src/components/world-lab.tsx index c5c9ebc..87e2315 100644 --- a/apps/world-lab/src/components/world-lab.tsx +++ b/apps/world-lab/src/components/world-lab.tsx @@ -15,6 +15,7 @@ import { PERSONALITY_PROFILES, STRATEGY_PROFILES, assignBehavior, + archiveExperimentExportResponseSchema, cancelSimulationResponseSchema, cancelledTurnResponseSchema, experimentExportPreviewSchema, @@ -4700,10 +4701,11 @@ function ExperimentExportPanel({ string | null >(null); const [operation, setOperation] = useState< - 'preview' | 'generate' | 'copy' | 'download' | null + 'preview' | 'generate' | 'copy' | 'download' | 'sqlite' | null >(null); const [notice, setNotice] = useState(null); const downloadPendingRef = useRef(false); + const sqlitePendingRef = useRef(false); const close = useCallback(() => onOpenChange(false), [onOpenChange]); const requestInput = { @@ -4838,6 +4840,42 @@ function ExperimentExportPanel({ } }; + const saveToSqlite = async () => { + if ( + !document || + !documentIsCurrent || + operation !== null || + sqlitePendingRef.current + ) + return; + sqlitePendingRef.current = true; + setOperation('sqlite'); + setNotice(null); + try { + const response = await fetch(`${apiBase}/experiment/export/archive`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ document }), + }); + if (!response.ok) throw new Error('archive request failed'); + const result = archiveExperimentExportResponseSchema.parse( + await response.json(), + ); + setNotice( + result.idempotent + ? `Experiment ${result.experimentId} was already saved to SQLite.` + : `Experiment ${result.experimentId} saved to SQLite · ${result.inserted} imported, ${result.existing} existing, ${result.skipped} skipped.`, + ); + } catch { + setNotice( + 'Could not confirm the SQLite save. Retry safely with the same generated export.', + ); + } finally { + sqlitePendingRef.current = false; + setOperation(null); + } + }; + const toggle = (values: T[], value: T): T[] => values.includes(value) ? values.filter((candidate) => candidate !== value) @@ -4891,6 +4929,14 @@ function ExperimentExportPanel({ > {operation === 'download' ? 'Downloading…' : 'Download JSON'} + } > diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8c5a846..f37e318 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -70,6 +70,8 @@ Communication resolves against the authoritative pre-action snapshot. Public cha - `POST /api/simulation/personalities/restore-defaults` — restore all eight milestone personality directives without resetting progress - `POST /api/simulation/experiment/export/preview` — validate filters and report subset size, retention, cost, and approximate sharing tokens - `POST /api/simulation/experiment/export` — construct one schema-versioned safe JSON document +- `POST /api/simulation/experiment/export/archive` — manually import the exact + generated safe document into the configured local SQLite archive - `GET /api/simulation/models` — return the cached, sanitized compatible model catalog - `POST /api/simulation/models/refresh` — explicitly refresh that catalog - `POST /api/simulation/models/verify` — make one explicit, non-mutating compatibility probe @@ -191,6 +193,12 @@ Snapshots keep the newest 120 turn records and 120 world events. Observations ex ## Experiment telemetry and export +World Lab archive writes remain downstream and manual. The browser submits the +exact current schema-validated generated document; the Game API lazily opens the +configured archive only for that request, delegates the transactional, +idempotent import to `packages/experiment-archive`, and closes the handle. The +archive never becomes simulation authority. + The active experiment has a runtime-validated UUID, start time, versioned authoritative scenario and ordered initial roster, immutable configuration events, initial world, and up to 5,000 complete safe turns. The browser snapshot and world-event list remain capped at 120. Reset creates a new experiment from the current scenario and clears telemetry/cost; no previous experiments survive reset or process restart. Metrics and filtering are deterministic Game API responsibilities. Schema v10 adds mandatory tick attribution, first-class lost ticks, and per-tick summaries. `modelAttempts` is canonical for provider-call, latency, token, and cost totals so repairs and transient retries are not undercounted. Tick-native and unstarted tick-default experiments export v10; retained sequential experiments remain v9 and cannot mix execution modes. The Game API retains its documented older safe-import support for model configuration. @@ -231,6 +239,8 @@ the wait. The rationale and deferrals are recorded in [ADR 0002](adr/0002-first-visible-llm-invasion.md). Personality ownership and reset semantics are recorded in [ADR 0003](adr/0003-session-personality-configuration.md). Experiment capture and export semantics are recorded in [ADR 0004](adr/0004-server-owned-experiment-telemetry.md). +Manual direct SQLite archival of a generated artifact is recorded in +[ADR 0021](adr/0021-manual-direct-sqlite-export.md). Nearby-message authority, observation bounds, and export selection semantics are recorded in [ADR 0005](adr/0005-nearby-agent-messaging.md). Contested control, capture, territory authority, and schema-v3 selection semantics are recorded in [ADR 0006](adr/0006-contested-hex-control.md). Decoupled communication and schema-v4 selection semantics are recorded in [ADR 0007](adr/0007-decoupled-world-communication.md). diff --git a/docs/EXPERIMENT_ARCHIVE.md b/docs/EXPERIMENT_ARCHIVE.md index 23c16a7..78ee1fc 100644 --- a/docs/EXPERIMENT_ARCHIVE.md +++ b/docs/EXPERIMENT_ARCHIVE.md @@ -17,6 +17,13 @@ The experiment archive is a durable, local research surface for completed or par ## Storage and configuration +World Lab provides a manual import path after an operator explicitly generates +an export. Preview is optional. **Save to SQLite** sends that exact current +artifact; stale artifacts are disabled. The API reuses the transactional, +safe-field-scanned, idempotent importer, opens the configured archive lazily, +and closes the handle. It accepts no browser-selected path and never writes +automatically. + `@hexzero/experiment-archive` uses SQLite built into the pinned Node 24 runtime. Versioned migrations create strict tables with foreign keys and indexes. File-backed databases enable WAL and a five-second busy timeout; imports use prepared statements inside one transaction. Tests use in-memory or temporary databases. The new default database is `.hexzero/experiments.sqlite`, an ignored development path. Resolution order is explicit `--db`, `HEXZERO_EXPERIMENT_DB`, legacy `AGENTBORNE_EXPERIMENT_DB`, an existing `.hexzero/experiments.sqlite`, an existing `.agentborne/experiments.sqlite`, then a new `.hexzero/experiments.sqlite`. Legacy selections emit a concise notice and open normally; no database is moved, overwritten, or recreated for branding. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 979f80a..f6dc1c9 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -87,6 +87,12 @@ Actual cost is accepted only from OpenRouter's safe `usage.cost`. Missing cost i The offline experiment archive adds local persistence only for complete schema-validated safe exports and explicitly curated Markdown notes. Imports scan for prohibited credential/private-reasoning fields and recognizable credential values before a transaction begins; failures roll back. Both the canonical `.hexzero/` and compatible legacy `.agentborne/` database locations are ignored. The CLI exposes bounded typed queries, not arbitrary SQL, and adds no MCP, embedding, vector-store, or network-listener surface. +World Lab may manually submit only the exact current generated export artifact +to a narrow archive endpoint. The browser cannot supply a database path or SQL. +The endpoint returns bounded counts and an experiment ID, never the resolved +filesystem path, and uses safe invalid-artifact, rejection, and persistence +errors without underlying diagnostics. + ## Reporting This is a private repository. Report suspected vulnerabilities privately to the repository owners rather than opening a public issue. diff --git a/docs/TESTING.md b/docs/TESTING.md index e98a659..9d0f68c 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -90,6 +90,13 @@ and approximately 768×900 with deterministic scripted data only. Command-navbar coverage verifies one persistent row, always-visible known cost, stable async control slots, accessible icon controls, exact absolute tick targets 5/10/25/50/100, current/past target advancement, hover/focus experiment details, responsive overflow, and removal of the provider badge and inspector export action. Map interaction coverage keeps agent-marker inspection independent from explicit hex selection and verifies that the map-local hex card dismisses on a background click. Export coverage separates preview from generation, includes lost ticks by default, invalidates artifacts after relevant option changes, prevents stale or duplicate copy/download, and checks accessible pending/ready/error states. Shared model-option tests require identical global/per-agent ordering and formatting. Effective-color tests cover current-alliance, retained, base, and neutral fallback precedence. Dark-map tests assert tokenless CARTO URLs and complete attribution without network access. +Direct archive-write coverage validates the narrow request/response schemas, +exact generated-document submission, pre-generation and stale-artifact +disabling, synchronous duplicate-activation prevention, bounded notices, +injected persistence without startup file creation, and idempotency through the +existing importer. Tests use injected writers, in-memory SQLite, or temporary +paths only. + Behavior coverage verifies registry uniqueness/versioning, deterministic balanced and fully random assignment, independent profile dimensions, turn-one locking, reset semantics, exact diplomacy affordances, layered prompt trust language, bounded structural detail codes, turn attribution, and export preservation. Agent Controller coverage verifies accessible Overview/Models/Behavior tabs, default readiness, manual pre-turn selection, post-start locking, responsive dialog layout, and compact roster summaries. Schema-v8 reconciliation tests require personality and strategy subtotals to match global logical-turn totals and verify that repaired validation attempts retain both `invalid-action-fields` and their stable structural detail code. The conversational-invitation regression expects contradictory diplomacy fields when a model supplies a chat participant as the recipient while omitting the required formal proposal ID. A well-formed but unavailable proposal UUID remains an engine-authoritative, non-retried rejection. diff --git a/docs/adr/0021-manual-direct-sqlite-export.md b/docs/adr/0021-manual-direct-sqlite-export.md new file mode 100644 index 0000000..9295a87 --- /dev/null +++ b/docs/adr/0021-manual-direct-sqlite-export.md @@ -0,0 +1,27 @@ +# ADR 0021: Manual direct SQLite export + +- Status: Accepted +- Date: 2026-08-23 + +## Decision + +World Lab offers **Save to SQLite** only after the operator explicitly generates +an export. Preview remains optional. Saving submits that exact schema-validated +document, and changed options make the artifact stale until regenerated. + +The Game API runtime-validates one narrow request, lazily opens the configured +archive, delegates to `ArchiveDatabase` and `importExperimentExport`, and closes +the handle. Its bounded response contains the experiment ID, import counts, and +idempotency indicator. The browser cannot choose a path or provide SQL. + +## Consequences + +Direct saves inherit safe-field scanning, transactions, rollback, and stable +idempotency. Ordinary startup does not create an archive. The archive remains +downstream observability and cannot restore or mutate the active simulation. + +## Boundaries + +This adds no automatic persistence, scheduler, schema migration, export-version +change, MCP, arbitrary SQL/path, provider or prompt change, engine change, or +simulation recovery. diff --git a/packages/shared/src/index.test.ts b/packages/shared/src/index.test.ts index 95d9c31..cb2dcb6 100644 --- a/packages/shared/src/index.test.ts +++ b/packages/shared/src/index.test.ts @@ -55,6 +55,7 @@ import { requestedMemoryOperationSchema, memoryOperationResultSchema, createMemoryId, + archiveExperimentExportResponseSchema, } from '.'; const agentId = '128f3f38-6b7d-4db7-9e95-751b4ce2681e'; @@ -1310,4 +1311,30 @@ describe('personality mutation contracts', () => { }).success, ).toBe(false); }); + + it('bounds archive-write confirmations and rejects extra fields', () => { + const confirmation = { + experimentId: '018f3f38-6b7d-7db7-8e95-751b4ce2681e', + inserted: 4, + existing: 1, + skipped: 0, + rejected: 0, + idempotent: false, + }; + expect( + archiveExperimentExportResponseSchema.safeParse(confirmation).success, + ).toBe(true); + expect( + archiveExperimentExportResponseSchema.safeParse({ + ...confirmation, + inserted: -1, + }).success, + ).toBe(false); + expect( + archiveExperimentExportResponseSchema.safeParse({ + ...confirmation, + archivePath: '/private/archive.sqlite', + }).success, + ).toBe(false); + }); }); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 090a380..09e567d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2759,6 +2759,9 @@ export const apiErrorCodeSchema = z.enum([ 'invalid_personality', 'invalid_request', 'invalid_export', + 'invalid_artifact', + 'archive_rejected', + 'archive_persistence_failed', 'export_conflict', 'records_unavailable', 'model_configuration_conflict', @@ -3747,6 +3750,25 @@ export type ExperimentExportDocument = z.infer< export const experimentExportResponseSchema = z.object({ document: experimentExportDocumentSchema, }); +export const archiveExperimentExportRequestSchema = z + .object({ document: experimentExportDocumentSchema }) + .strict(); +export const archiveExperimentExportResponseSchema = z + .object({ + experimentId: experimentIdSchema, + inserted: z.number().int().min(0).max(1_000_000), + existing: z.number().int().min(0).max(1_000_000), + skipped: z.number().int().min(0).max(1_000_000), + rejected: z.number().int().min(0).max(1_000_000), + idempotent: z.boolean(), + }) + .strict(); +export type ArchiveExperimentExportRequest = z.infer< + typeof archiveExperimentExportRequestSchema +>; +export type ArchiveExperimentExportResponse = z.infer< + typeof archiveExperimentExportResponseSchema +>; export const experimentImportRequestSchema = z .object({ document: z.unknown() }) .strict(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0a2a02..ab457c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,6 +31,9 @@ importers: '@hexzero/agent-runtime': specifier: workspace:* version: link:../../packages/agent-runtime + '@hexzero/experiment-archive': + specifier: workspace:* + version: link:../../packages/experiment-archive '@hexzero/shared': specifier: workspace:* version: link:../../packages/shared