feat(mcp): add guided construction workflows

This commit is contained in:
Aymeric Rabot
2026-04-27 14:31:04 -04:00
parent b3d1f663f6
commit 3d5c87a651
48 changed files with 3877 additions and 115 deletions
@@ -226,6 +226,53 @@ describe('SqliteSceneStore', () => {
}
})
test('appends and lists scene events in order', async () => {
const graph = makeGraph()
const meta = await store.save({ id: 'live', name: 'Live', graph })
const first = await store.appendSceneEvent({
sceneId: meta.id,
version: meta.version,
kind: 'save_scene',
graph,
})
const updatedGraph = makeGraph({
nodes: {
...graph.nodes,
wall_new: {
object: 'node',
id: 'wall_new',
type: 'wall',
parentId: 'building_def',
visible: true,
metadata: {},
children: [],
start: [0, 0],
end: [1, 0],
thickness: 0.1,
height: 2.5,
frontSide: 'unknown',
backSide: 'unknown',
},
} as SceneGraph['nodes'],
})
const second = await store.appendSceneEvent({
sceneId: meta.id,
version: meta.version,
kind: 'create_wall',
graph: updatedGraph,
})
expect(second.eventId).toBeGreaterThan(first.eventId)
expect((await store.listSceneEvents('live')).map((event) => event.kind)).toEqual([
'save_scene',
'create_wall',
])
const afterFirst = await store.listSceneEvents('live', { afterEventId: first.eventId })
expect(afterFirst).toHaveLength(1)
expect(afterFirst[0]!.eventId).toBe(second.eventId)
expect(afterFirst[0]!.graph.nodes.wall_new).toBeDefined()
})
test('validates name and scene size', async () => {
await expect(store.save({ name: '', graph: makeGraph() })).rejects.toThrow(SceneInvalidError)
await expect(store.save({ name: 'x'.repeat(201), graph: makeGraph() })).rejects.toThrow(
@@ -6,6 +6,9 @@ import { z } from 'zod'
import { generateSlug, isValidSlug, sanitizeSlug } from './slug'
import { openSqliteDatabase, type SqliteDatabase } from './sqlite-driver'
import {
type SceneEvent,
type SceneEventAppendOptions,
type SceneEventListOptions,
SceneInvalidError,
type SceneListOptions,
type SceneMeta,
@@ -46,6 +49,15 @@ interface SceneRow {
graph_json: string
}
interface SceneEventRow {
event_id: number
scene_id: string
version: number
kind: string
created_at: string
graph_json: string
}
const GraphSchema = z.object({
nodes: z.record(z.string(), z.unknown()),
rootNodeIds: z.array(z.string()),
@@ -170,6 +182,17 @@ function asSceneRow(value: unknown): SceneRow | null {
return value as SceneRow
}
function rowToSceneEvent(row: SceneEventRow): SceneEvent {
return {
eventId: Number(row.event_id),
sceneId: row.scene_id,
version: Number(row.version),
kind: row.kind,
createdAt: row.created_at,
graph: parseGraph(row.graph_json, `${row.scene_id}@${row.version}`),
}
}
/**
* SQLite-backed implementation of `SceneStore`.
*
@@ -397,6 +420,54 @@ export class SqliteSceneStore implements SceneStore {
})
}
async appendSceneEvent(opts: SceneEventAppendOptions): Promise<SceneEvent> {
return this.withWriteTransaction((db) => {
const safeId = sanitizeSlug(opts.sceneId)
const existing = this.getRow(db, safeId)
if (!existing) {
throw new SceneNotFoundError(`Scene "${safeId}" not found`)
}
const graphJson = serializeGraph(opts.graph)
const now = new Date().toISOString()
const result = db
.query(
`INSERT INTO scene_events (
scene_id, version, kind, created_at, graph_json
) VALUES (?, ?, ?, ?, ?)`,
)
.run(safeId, opts.version, opts.kind, now, graphJson)
return {
eventId: Number(result.lastInsertRowid),
sceneId: safeId,
version: opts.version,
kind: opts.kind,
createdAt: now,
graph: opts.graph,
}
})
}
async listSceneEvents(sceneId: string, opts: SceneEventListOptions = {}): Promise<SceneEvent[]> {
const afterEventId = Math.max(0, opts.afterEventId ?? 0)
const requestedLimit = opts.limit ?? 100
const limit = Number.isInteger(requestedLimit) && requestedLimit > 0 ? requestedLimit : 100
const db = await this.database()
const rows = db
.query(
`SELECT event_id, scene_id, version, kind, created_at, graph_json
FROM scene_events
WHERE scene_id = ?
AND event_id > ?
ORDER BY event_id ASC
LIMIT ?`,
)
.all(sanitizeSlug(sceneId), afterEventId, limit)
return rows.map((row) => rowToSceneEvent(row as SceneEventRow))
}
close(): void {
this.db?.close()
this.db = null
@@ -452,6 +523,19 @@ export class SqliteSceneStore implements SceneStore {
PRIMARY KEY (scene_id, version),
FOREIGN KEY (scene_id) REFERENCES scenes(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS scene_events (
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
scene_id TEXT NOT NULL,
version INTEGER NOT NULL CHECK (version >= 1),
kind TEXT NOT NULL,
created_at TEXT NOT NULL,
graph_json TEXT NOT NULL,
FOREIGN KEY (scene_id) REFERENCES scenes(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS scene_events_scene_event_idx
ON scene_events(scene_id, event_id);
`)
}
+23
View File
@@ -25,6 +25,15 @@ export interface SceneWithGraph extends SceneMeta {
graph: SceneGraph
}
export interface SceneEvent {
eventId: number
sceneId: SceneId
version: number
kind: string
createdAt: string
graph: SceneGraph
}
export interface SceneSaveOptions {
id?: SceneId
name: string
@@ -46,6 +55,18 @@ export interface SceneMutateOptions {
expectedVersion?: number
}
export interface SceneEventAppendOptions {
sceneId: SceneId
version: number
kind: string
graph: SceneGraph
}
export interface SceneEventListOptions {
afterEventId?: number
limit?: number
}
export interface SceneStore {
readonly backend: 'sqlite'
save(opts: SceneSaveOptions): Promise<SceneMeta>
@@ -53,6 +74,8 @@ export interface SceneStore {
list(opts?: SceneListOptions): Promise<SceneMeta[]>
delete(id: SceneId, opts?: SceneMutateOptions): Promise<boolean>
rename(id: SceneId, newName: string, opts?: SceneMutateOptions): Promise<SceneMeta>
appendSceneEvent?(opts: SceneEventAppendOptions): Promise<SceneEvent>
listSceneEvents?(sceneId: SceneId, opts?: SceneEventListOptions): Promise<SceneEvent[]>
}
export class SceneNotFoundError extends Error {