feat(mcp): add guided construction workflows
This commit is contained in:
@@ -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);
|
||||
`)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user