Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions apps/game-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
137 changes: 137 additions & 0 deletions apps/game-api/src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -24,6 +29,7 @@ import {
updateAgentPersonalityResponseSchema,
updateExperimentModelsResponseSchema,
verifyModelResponseSchema,
type ExperimentExportDocument,
} from '@hexzero/shared';
import {
createApp,
Expand Down Expand Up @@ -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'],
[
Expand Down
92 changes: 92 additions & 0 deletions apps/game-api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
type AgentProvider,
} from '@hexzero/agent-runtime';
import {
archiveExperimentExportRequestSchema,
archiveExperimentExportResponseSchema,
apiErrorSchema,
AGENT_DECISION_CONTRACT_VERSION,
cancelSimulationResponseSchema,
Expand Down Expand Up @@ -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,
Expand All @@ -65,6 +75,29 @@ export interface AppOptions {
provider?: AgentProvider;
catalog?: Pick<OpenRouterModelCatalog, 'getCatalog'>;
geocoder?: Geocoder;
archiveExperimentExport?: (
document: ExperimentExportDocument,
) =>
ArchiveExperimentExportResponse | Promise<ArchiveExperimentExportResponse>;
}

async function archiveExperimentExportDefault(
document: ExperimentExportDocument,
): Promise<ArchiveExperimentExportResponse> {
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(
Expand Down Expand Up @@ -106,6 +139,8 @@ export function createApp(options: AppOptions = {}) {
new OpenRouterModelCatalog({ apiKey: process.env.OPENROUTER_API_KEY });
const modelVerifications = new Map<string, ModelVerification>();
const geocoder = options.geocoder ?? new NominatimGeocoder();
const archiveExperimentExport =
options.archiveExperimentExport ?? archiveExperimentExportDefault;
const turnMutations = new Map<string, Promise<unknown>>();
const mutationPromise = <T>(
context: Context,
Expand Down Expand Up @@ -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),
Expand Down
Loading
Loading