@vgai/sdk 0.4.0-canary.20260715.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +27 -0
- package/src/cinematic/capabilities-operations.ts +128 -0
- package/src/cinematic/cue-operations.ts +198 -0
- package/src/cinematic/gsap-operations.ts +126 -0
- package/src/cinematic/index.ts +59 -0
- package/src/cinematic/preview-operations.ts +279 -0
- package/src/cinematic/preview-transport.ts +244 -0
- package/src/cinematic/render-operations.ts +409 -0
- package/src/cinematic/render-transport.ts +238 -0
- package/src/cinematic/theatre-operations.ts +306 -0
- package/src/editor/camera-operations.ts +169 -0
- package/src/editor/console-operations.ts +87 -0
- package/src/editor/hierarchy-operations.ts +95 -0
- package/src/editor/index.ts +62 -0
- package/src/editor/open-operations.ts +209 -0
- package/src/editor/screenshot-operations.ts +99 -0
- package/src/editor/selection-operations.ts +144 -0
- package/src/editor/session-operations.ts +73 -0
- package/src/editor/source-location-operations.ts +106 -0
- package/src/editor/transport.ts +647 -0
- package/src/errors.ts +72 -0
- package/src/http/http-projection.ts +349 -0
- package/src/http/index.ts +11 -0
- package/src/index.ts +67 -0
- package/src/mcp/index.ts +16 -0
- package/src/mcp/mcp-projection.ts +288 -0
- package/src/operations.ts +83 -0
- package/src/play/control-operations.ts +205 -0
- package/src/play/debug-command-operations.ts +245 -0
- package/src/play/index.ts +66 -0
- package/src/play/input-operations.ts +316 -0
- package/src/play/lifecycle-operations.ts +271 -0
- package/src/play/log-operations.ts +279 -0
- package/src/play/run-ticks-operations.ts +141 -0
- package/src/play/state-operations.ts +210 -0
- package/src/play/status-operations.ts +160 -0
- package/src/play/transport.ts +728 -0
- package/src/project/asset-operations.ts +243 -0
- package/src/project/component-operations.ts +337 -0
- package/src/project/discovery-operations.ts +269 -0
- package/src/project/entity-operations.ts +366 -0
- package/src/project/index.ts +55 -0
- package/src/project/input-map-operations.ts +233 -0
- package/src/project/manifest-operations.ts +355 -0
- package/src/project/scene-operations.ts +426 -0
- package/src/project/shared.ts +299 -0
- package/src/registry.ts +285 -0
- package/src/render/capabilities/ffmpeg.ts +141 -0
- package/src/render/index.ts +15 -0
- package/src/render/render-cinematic.ts +1847 -0
- package/src/types.ts +101 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `project.story.discover`, `project.theatre.*`, `project.xstate.*` (B2,
|
|
3
|
+
* §8 B2 "story discovery", "Theatre project-state discovery/validation",
|
|
4
|
+
* "XState machine discovery/validation where statically possible").
|
|
5
|
+
*
|
|
6
|
+
* These three are DISCOVERY/VALIDATION-ONLY by design (the B2 task brief
|
|
7
|
+
* explicitly allows this "where the spec says 'where statically possible'"):
|
|
8
|
+
*
|
|
9
|
+
* - Stories (CSF) are ordinary `.tsx`/`.ts` modules (§5.2/C3) — VGAI stores
|
|
10
|
+
* no duplicate story registry, so there is nothing to mutate here; listing
|
|
11
|
+
* `*.stories.tsx`/`*.stories.ts` is the whole B2 surface.
|
|
12
|
+
* - Theatre project state (`*.theatre-project.json`) is a real, versioned,
|
|
13
|
+
* proprietary Theatre.js format this repo does not own (§3.2/§5.3 — "the
|
|
14
|
+
* committed Theatre project state is authoritative authored data"). B2
|
|
15
|
+
* validates only the OUTER shape (`sheetsById`/`definitionVersion`/
|
|
16
|
+
* `revisionHistory` — confirmed against the real committed fixtures,
|
|
17
|
+
* e.g. `packages/engine/test/fixtures/theatre-director-proof.theatre-project.json`)
|
|
18
|
+
* — never the internal per-track keyframe format, which is Theatre
|
|
19
|
+
* Studio's own authoring concern, not VGAI's.
|
|
20
|
+
* - XState machines are ordinary TypeScript modules (§3.3/§5.6) with no
|
|
21
|
+
* VGAI-owned serialization. Statically discovering/validating one without
|
|
22
|
+
* executing/transpiling the module is necessarily a heuristic (regex-based
|
|
23
|
+
* "does this file import `xstate` and call `createMachine`") — this is
|
|
24
|
+
* named as a LIMIT, not silently passed off as full validation.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { readFileSync } from 'node:fs';
|
|
28
|
+
import { z } from 'zod';
|
|
29
|
+
import { OperationError } from '../errors.js';
|
|
30
|
+
import { defineOperation, type OperationRegistry } from '../registry.js';
|
|
31
|
+
import {
|
|
32
|
+
listProjectFiles,
|
|
33
|
+
NO_PROJECT_ROOT_ERROR,
|
|
34
|
+
PATH_OUTSIDE_PROJECT_ERROR,
|
|
35
|
+
requireProjectRoot,
|
|
36
|
+
resolveProjectPath,
|
|
37
|
+
} from './shared.js';
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// project.story.discover
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
export const projectStoryDiscover = defineOperation({
|
|
44
|
+
name: 'project.story.discover',
|
|
45
|
+
summary: 'List every Storybook CSF story module (*.stories.tsx / *.stories.ts) in the project.',
|
|
46
|
+
description:
|
|
47
|
+
'List-only (§5.2/C3: CSF is the canonical story format; VGAI keeps no duplicate registry). ' +
|
|
48
|
+
'Does not parse story exports/args/decorators — an agent opens the module directly for that.',
|
|
49
|
+
input: z.object({}),
|
|
50
|
+
result: z.object({ storyFiles: z.array(z.string()).describe('Project-relative paths, sorted.') }),
|
|
51
|
+
errors: [NO_PROJECT_ROOT_ERROR],
|
|
52
|
+
requires: { project: true },
|
|
53
|
+
host: 'node',
|
|
54
|
+
mutates: false,
|
|
55
|
+
supportsDryRun: false,
|
|
56
|
+
permission: { risk: 'read', summary: 'Walks the project file tree; no writes.' },
|
|
57
|
+
async impl(_input, ctx) {
|
|
58
|
+
const projectRoot = requireProjectRoot(ctx);
|
|
59
|
+
const storyFiles = listProjectFiles(
|
|
60
|
+
projectRoot,
|
|
61
|
+
(p) => p.endsWith('.stories.tsx') || p.endsWith('.stories.ts'),
|
|
62
|
+
);
|
|
63
|
+
return { storyFiles };
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// project.theatre.discover
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
export const projectTheatreDiscover = defineOperation({
|
|
72
|
+
name: 'project.theatre.discover',
|
|
73
|
+
summary:
|
|
74
|
+
'List every committed Theatre project-state file (*.theatre-project.json) in the project.',
|
|
75
|
+
description:
|
|
76
|
+
'List-only (§3.2/§5.3: the committed Theatre project state is authoritative authored data VGAI does not own the internal format of).',
|
|
77
|
+
input: z.object({}),
|
|
78
|
+
result: z.object({
|
|
79
|
+
theatreProjectFiles: z.array(z.string()).describe('Project-relative paths, sorted.'),
|
|
80
|
+
}),
|
|
81
|
+
errors: [NO_PROJECT_ROOT_ERROR],
|
|
82
|
+
requires: { project: true },
|
|
83
|
+
host: 'node',
|
|
84
|
+
mutates: false,
|
|
85
|
+
supportsDryRun: false,
|
|
86
|
+
permission: { risk: 'read', summary: 'Walks the project file tree; no writes.' },
|
|
87
|
+
async impl(_input, ctx) {
|
|
88
|
+
const projectRoot = requireProjectRoot(ctx);
|
|
89
|
+
const theatreProjectFiles = listProjectFiles(projectRoot, (p) =>
|
|
90
|
+
p.endsWith('.theatre-project.json'),
|
|
91
|
+
);
|
|
92
|
+
return { theatreProjectFiles };
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Best-effort OUTER shape only — see module jsdoc for why this deliberately
|
|
97
|
+
// does not validate `sheetsById`'s internal track/keyframe format.
|
|
98
|
+
const TheatreProjectStateShape = z.object({
|
|
99
|
+
sheetsById: z.record(z.string(), z.unknown()),
|
|
100
|
+
definitionVersion: z.string(),
|
|
101
|
+
revisionHistory: z.array(z.unknown()),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const ProjectTheatreValidateInput = z.object({
|
|
105
|
+
theatreProjectPath: z
|
|
106
|
+
.string()
|
|
107
|
+
.optional()
|
|
108
|
+
.describe('Validate this project-relative file instead of `document`.'),
|
|
109
|
+
document: z
|
|
110
|
+
.unknown()
|
|
111
|
+
.optional()
|
|
112
|
+
.describe('Validate this raw document instead of reading `theatreProjectPath`.'),
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
export const projectTheatreValidate = defineOperation({
|
|
116
|
+
name: 'project.theatre.validate',
|
|
117
|
+
summary:
|
|
118
|
+
'Validate a Theatre project-state file’s OUTER shape (sheetsById/definitionVersion/revisionHistory).',
|
|
119
|
+
description:
|
|
120
|
+
'Shape-only — Theatre’s internal per-track keyframe format is a real, versioned, ' +
|
|
121
|
+
'proprietary Theatre.js format this repo does not own (§3.2/§5.3), so this does not attempt ' +
|
|
122
|
+
'deep validation of track/keyframe contents.',
|
|
123
|
+
input: ProjectTheatreValidateInput,
|
|
124
|
+
result: z.object({ valid: z.boolean(), issues: z.array(z.string()) }),
|
|
125
|
+
errors: [
|
|
126
|
+
NO_PROJECT_ROOT_ERROR,
|
|
127
|
+
PATH_OUTSIDE_PROJECT_ERROR,
|
|
128
|
+
{
|
|
129
|
+
code: 'BAD_INPUT',
|
|
130
|
+
summary: 'Neither theatreProjectPath nor document was given.',
|
|
131
|
+
data: z.object({}),
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
requires: { project: true },
|
|
135
|
+
host: 'node',
|
|
136
|
+
mutates: false,
|
|
137
|
+
supportsDryRun: false,
|
|
138
|
+
permission: {
|
|
139
|
+
risk: 'read',
|
|
140
|
+
summary: 'Reads a Theatre project-state file (or validates a given document); no writes.',
|
|
141
|
+
},
|
|
142
|
+
async impl(input, ctx) {
|
|
143
|
+
let json: unknown;
|
|
144
|
+
if (input.document !== undefined) {
|
|
145
|
+
json = input.document;
|
|
146
|
+
} else if (input.theatreProjectPath !== undefined) {
|
|
147
|
+
const projectRoot = requireProjectRoot(ctx);
|
|
148
|
+
const absPath = resolveProjectPath(projectRoot, input.theatreProjectPath);
|
|
149
|
+
json = JSON.parse(readFileSync(absPath, 'utf-8'));
|
|
150
|
+
} else {
|
|
151
|
+
throw new OperationError(
|
|
152
|
+
'BAD_INPUT',
|
|
153
|
+
'One of theatreProjectPath or document must be given.',
|
|
154
|
+
{},
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
const parsed = TheatreProjectStateShape.safeParse(json);
|
|
158
|
+
if (!parsed.success) {
|
|
159
|
+
return {
|
|
160
|
+
valid: false,
|
|
161
|
+
issues: parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
return { valid: true, issues: [] };
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// project.xstate.discover / .validate — best-effort STATIC discovery only.
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
/** True when the file text looks like it defines an XState machine — a heuristic, not a parse. */
|
|
173
|
+
function looksLikeXStateMachine(text: string): { importsXstate: boolean; definesMachine: boolean } {
|
|
174
|
+
const importsXstate = /from\s+['"]xstate['"]/.test(text);
|
|
175
|
+
const definesMachine = /\.createMachine\s*\(|(?<!\.)createMachine\s*\(/.test(text);
|
|
176
|
+
return { importsXstate, definesMachine };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export const projectXstateDiscover = defineOperation({
|
|
180
|
+
name: 'project.xstate.discover',
|
|
181
|
+
summary:
|
|
182
|
+
'Best-effort static discovery of TypeScript modules that appear to define an XState machine.',
|
|
183
|
+
description:
|
|
184
|
+
'STATIC HEURISTIC, not a parse/execute: a file is reported when its text imports from ' +
|
|
185
|
+
"'xstate' AND contains a `createMachine(...)`/`setup(...).createMachine(...)` call. This can " +
|
|
186
|
+
'both miss machines built through indirection and never proves the machine config is valid ' +
|
|
187
|
+
'— see project.xstate.validate for the same limit named on the validation side.',
|
|
188
|
+
input: z.object({}),
|
|
189
|
+
result: z.object({
|
|
190
|
+
machineFiles: z
|
|
191
|
+
.array(
|
|
192
|
+
z.object({ path: z.string(), importsXstate: z.boolean(), definesMachine: z.boolean() }),
|
|
193
|
+
)
|
|
194
|
+
.describe(
|
|
195
|
+
'Every *.ts/*.tsx file that imports xstate, with whether it also defines a machine.',
|
|
196
|
+
),
|
|
197
|
+
}),
|
|
198
|
+
errors: [NO_PROJECT_ROOT_ERROR],
|
|
199
|
+
requires: { project: true },
|
|
200
|
+
host: 'node',
|
|
201
|
+
mutates: false,
|
|
202
|
+
supportsDryRun: false,
|
|
203
|
+
permission: {
|
|
204
|
+
risk: 'read',
|
|
205
|
+
summary: 'Reads *.ts/*.tsx file contents under the project; no writes.',
|
|
206
|
+
},
|
|
207
|
+
async impl(_input, ctx) {
|
|
208
|
+
const projectRoot = requireProjectRoot(ctx);
|
|
209
|
+
const candidates = listProjectFiles(
|
|
210
|
+
projectRoot,
|
|
211
|
+
(p) => (p.endsWith('.ts') || p.endsWith('.tsx')) && !p.endsWith('.d.ts'),
|
|
212
|
+
);
|
|
213
|
+
const machineFiles: { path: string; importsXstate: boolean; definesMachine: boolean }[] = [];
|
|
214
|
+
for (const rel of candidates) {
|
|
215
|
+
const abs = resolveProjectPath(projectRoot, rel);
|
|
216
|
+
let text: string;
|
|
217
|
+
try {
|
|
218
|
+
text = readFileSync(abs, 'utf-8');
|
|
219
|
+
} catch {
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const { importsXstate, definesMachine } = looksLikeXStateMachine(text);
|
|
223
|
+
if (importsXstate) machineFiles.push({ path: rel, importsXstate, definesMachine });
|
|
224
|
+
}
|
|
225
|
+
return { machineFiles };
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const ProjectXstateValidateInput = z.object({
|
|
230
|
+
machinePath: z.string().describe('Project-relative path to a TypeScript module to check.'),
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
export const projectXstateValidate = defineOperation({
|
|
234
|
+
name: 'project.xstate.validate',
|
|
235
|
+
summary:
|
|
236
|
+
'Best-effort static check that a TypeScript module imports xstate and defines a machine.',
|
|
237
|
+
description:
|
|
238
|
+
'STATIC HEURISTIC, not a parse/execute (see project.xstate.discover’s jsdoc for the same ' +
|
|
239
|
+
'limit): this cannot validate states/transitions/guards/events — only that the file imports ' +
|
|
240
|
+
"'xstate' and contains a machine-creation call. A file that fails this check may still be a " +
|
|
241
|
+
'perfectly valid hand-authored machine using an unusual construction pattern.',
|
|
242
|
+
input: ProjectXstateValidateInput,
|
|
243
|
+
result: z.object({ valid: z.boolean(), issues: z.array(z.string()) }),
|
|
244
|
+
errors: [NO_PROJECT_ROOT_ERROR, PATH_OUTSIDE_PROJECT_ERROR],
|
|
245
|
+
requires: { project: true },
|
|
246
|
+
host: 'node',
|
|
247
|
+
mutates: false,
|
|
248
|
+
supportsDryRun: false,
|
|
249
|
+
permission: { risk: 'read', summary: 'Reads one file’s text; no writes.' },
|
|
250
|
+
async impl(input, ctx) {
|
|
251
|
+
const projectRoot = requireProjectRoot(ctx);
|
|
252
|
+
const absPath = resolveProjectPath(projectRoot, input.machinePath);
|
|
253
|
+
const text = readFileSync(absPath, 'utf-8');
|
|
254
|
+
const { importsXstate, definesMachine } = looksLikeXStateMachine(text);
|
|
255
|
+
const issues: string[] = [];
|
|
256
|
+
if (!importsXstate) issues.push(`${input.machinePath} does not import from 'xstate'.`);
|
|
257
|
+
if (!definesMachine) issues.push(`${input.machinePath} has no createMachine(...) call.`);
|
|
258
|
+
return { valid: issues.length === 0, issues };
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
/** Register every B2 story/Theatre/XState discovery operation onto `registry`. */
|
|
263
|
+
export function registerDiscoveryOperations(registry: OperationRegistry): void {
|
|
264
|
+
registry.register(projectStoryDiscover);
|
|
265
|
+
registry.register(projectTheatreDiscover);
|
|
266
|
+
registry.register(projectTheatreValidate);
|
|
267
|
+
registry.register(projectXstateDiscover);
|
|
268
|
+
registry.register(projectXstateValidate);
|
|
269
|
+
}
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `project.scene.entity.*` (B2, §8 B2 "entity add/update/move/remove").
|
|
3
|
+
*
|
|
4
|
+
* ID-AWARE BY CONSTRUCTION: every op here targets an entity by its stable
|
|
5
|
+
* `id` (never an array index) and builds exactly one `DiffOp`
|
|
6
|
+
* (`@vgai/engine/scene/scene-diff-types`) for `applyDiff`
|
|
7
|
+
* (`@vgai/engine/scene/scene-apply`) to execute — the SAME op-ordering/
|
|
8
|
+
* placement machinery `project.scene.apply` and `vgai apply-diff` use. This
|
|
9
|
+
* module only assembles the single-op `SceneDiff`; it never walks/splices the
|
|
10
|
+
* entity tree itself.
|
|
11
|
+
*
|
|
12
|
+
* `entity.update` is a convenience MERGE over applyDiff's `update` op (which
|
|
13
|
+
* is itself a full field replace — see scene-apply.ts's jsdoc): the impl
|
|
14
|
+
* reads the entity's current fields, overlays the caller's `fields` on top,
|
|
15
|
+
* and hands the FULL merged object to applyDiff as the update payload — so a
|
|
16
|
+
* caller can patch just `{ transform: { position: [...] } }` without
|
|
17
|
+
* re-stating every other field, while applyDiff still only ever sees (and
|
|
18
|
+
* only ever needs to implement) a full-replace update.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { parseSceneFile, SceneParseError } from '@vgai/engine/scene/parse';
|
|
22
|
+
import type { applyDiff } from '@vgai/engine/scene/scene-apply';
|
|
23
|
+
import type { SceneEntity } from '@vgai/engine/scene/scene-types';
|
|
24
|
+
import { SceneEntitySchema } from '@vgai/engine/scene/schema/entity';
|
|
25
|
+
import { z } from 'zod';
|
|
26
|
+
import { OperationError } from '../errors.js';
|
|
27
|
+
import { defineOperation, type OperationRegistry } from '../registry.js';
|
|
28
|
+
import {
|
|
29
|
+
APPLY_DIFF_FAILED_ERROR,
|
|
30
|
+
applyDiffOrThrow,
|
|
31
|
+
readAndParseScene,
|
|
32
|
+
SCENE_INVALID_ERROR,
|
|
33
|
+
} from './scene-operations.js';
|
|
34
|
+
import {
|
|
35
|
+
BaseHashField,
|
|
36
|
+
CONFLICT_ERROR,
|
|
37
|
+
checkNotStale,
|
|
38
|
+
DryRunField,
|
|
39
|
+
ENTITY_NOT_FOUND_ERROR,
|
|
40
|
+
FILE_NOT_FOUND_ERROR,
|
|
41
|
+
findEntityById,
|
|
42
|
+
mutationResultSchema,
|
|
43
|
+
NO_PROJECT_ROOT_ERROR,
|
|
44
|
+
PATH_OUTSIDE_PROJECT_ERROR,
|
|
45
|
+
requireEntityById,
|
|
46
|
+
requireProjectRoot,
|
|
47
|
+
writeFileAtomic,
|
|
48
|
+
} from './shared.js';
|
|
49
|
+
|
|
50
|
+
const EntityAfterSchema = z.object({
|
|
51
|
+
entity: z
|
|
52
|
+
.unknown()
|
|
53
|
+
.describe('The affected entity after the change (or that WOULD result), by id.'),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Shared write tail: re-validate the whole document, honor dryRun, write via
|
|
58
|
+
* checkNotStale-guarded atomic write, and shape the mutation envelope.
|
|
59
|
+
* `before`/`after` are passed through AS GIVEN (not wrapped) — callers shape
|
|
60
|
+
* them to match their own op's declared `after` result schema (entity ops
|
|
61
|
+
* wrap as `{ entity }`; component-operations.ts's callers pass their own
|
|
62
|
+
* `{ entityId, componentName, data }` shape directly).
|
|
63
|
+
*/
|
|
64
|
+
function finalizeSceneMutation<TBefore, TAfter>(
|
|
65
|
+
absPath: string,
|
|
66
|
+
scenePath: string,
|
|
67
|
+
result: SceneFileLike,
|
|
68
|
+
dryRun: boolean,
|
|
69
|
+
before: TBefore,
|
|
70
|
+
after: TAfter,
|
|
71
|
+
) {
|
|
72
|
+
try {
|
|
73
|
+
parseSceneFile(result, scenePath);
|
|
74
|
+
} catch (err) {
|
|
75
|
+
if (err instanceof SceneParseError) {
|
|
76
|
+
throw new OperationError('SCENE_INVALID', `Result fails validation: ${err.message}`, {
|
|
77
|
+
issues: err.issues.map((i) => `${i.path.join('.')}: ${i.message}`),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
throw err;
|
|
81
|
+
}
|
|
82
|
+
if (!dryRun) {
|
|
83
|
+
writeFileAtomic(absPath, `${JSON.stringify(result, null, 2)}\n`);
|
|
84
|
+
}
|
|
85
|
+
return { dryRun, written: !dryRun, filesChanged: [absPath], before, after };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// SceneFile is only used structurally here (applyDiff's return type) — avoid
|
|
89
|
+
// a second import cycle by aliasing via the already-imported applyDiff's own
|
|
90
|
+
// parameter type through a small local type alias.
|
|
91
|
+
type SceneFileLike = ReturnType<typeof applyDiff>;
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// project.scene.entity.add
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
const ProjectSceneEntityAddInput = z.object({
|
|
98
|
+
scenePath: z.string().describe('Project-relative path to the .vscn.json scene file.'),
|
|
99
|
+
parentId: z.string().nullable().describe('Parent entity id, or null for a top-level entity.'),
|
|
100
|
+
index: z.number().int().min(0).describe('Target position among the (new) parent’s children.'),
|
|
101
|
+
entity: SceneEntitySchema.describe('The entity to insert (id auto-generated if omitted).'),
|
|
102
|
+
dryRun: DryRunField,
|
|
103
|
+
baseHash: BaseHashField,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
export const projectSceneEntityAdd = defineOperation({
|
|
107
|
+
name: 'project.scene.entity.add',
|
|
108
|
+
summary: 'Insert a new entity into a scene, by parent id + index.',
|
|
109
|
+
description:
|
|
110
|
+
'Builds a single `add` DiffOp and delegates to applyDiff — the same insertion/placement logic project.scene.apply uses.',
|
|
111
|
+
input: ProjectSceneEntityAddInput,
|
|
112
|
+
result: mutationResultSchema(EntityAfterSchema),
|
|
113
|
+
errors: [
|
|
114
|
+
NO_PROJECT_ROOT_ERROR,
|
|
115
|
+
PATH_OUTSIDE_PROJECT_ERROR,
|
|
116
|
+
FILE_NOT_FOUND_ERROR,
|
|
117
|
+
SCENE_INVALID_ERROR,
|
|
118
|
+
APPLY_DIFF_FAILED_ERROR,
|
|
119
|
+
CONFLICT_ERROR,
|
|
120
|
+
],
|
|
121
|
+
requires: { project: true },
|
|
122
|
+
host: 'node',
|
|
123
|
+
mutates: true,
|
|
124
|
+
supportsDryRun: true,
|
|
125
|
+
permission: { risk: 'write', summary: 'Writes the target scene file.' },
|
|
126
|
+
async impl(input, ctx) {
|
|
127
|
+
const projectRoot = requireProjectRoot(ctx);
|
|
128
|
+
const { absPath, hash, scene } = readAndParseScene(projectRoot, input.scenePath);
|
|
129
|
+
checkNotStale(absPath, hash, input.baseHash);
|
|
130
|
+
|
|
131
|
+
const result = applyDiffOrThrow(scene, {
|
|
132
|
+
ops: [
|
|
133
|
+
{
|
|
134
|
+
type: 'add',
|
|
135
|
+
entity: input.entity as SceneEntity,
|
|
136
|
+
parentId: input.parentId,
|
|
137
|
+
index: input.index,
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
environmentChanged: false,
|
|
141
|
+
metaChanged: false,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Report the entity as it landed (id may have been auto-generated).
|
|
145
|
+
const after = input.entity.id
|
|
146
|
+
? findEntityById(result.entities, input.entity.id)
|
|
147
|
+
: findEntityById(result.entities, findNewestSiblingId(result, input.parentId, input.index));
|
|
148
|
+
|
|
149
|
+
return finalizeSceneMutation(
|
|
150
|
+
absPath,
|
|
151
|
+
input.scenePath,
|
|
152
|
+
result,
|
|
153
|
+
input.dryRun ?? false,
|
|
154
|
+
{ entity: null },
|
|
155
|
+
{ entity: after ?? null },
|
|
156
|
+
);
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
/** Best-effort: when the caller omitted `id`, look up whatever landed at the target position after the add. */
|
|
161
|
+
function findNewestSiblingId(scene: SceneFileLike, parentId: string | null, index: number): string {
|
|
162
|
+
const siblings =
|
|
163
|
+
parentId === null ? scene.entities : (findEntityById(scene.entities, parentId)?.children ?? []);
|
|
164
|
+
const clamped = Math.min(index, siblings.length - 1);
|
|
165
|
+
return siblings[clamped]?.id ?? '';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// project.scene.entity.update — merge-onto-full-replace convenience.
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
const ProjectSceneEntityUpdateInput = z.object({
|
|
173
|
+
scenePath: z.string().describe('Project-relative path to the .vscn.json scene file.'),
|
|
174
|
+
id: z.string().describe('Id of the entity to update (never an array index).'),
|
|
175
|
+
fields: z
|
|
176
|
+
.record(z.string(), z.unknown())
|
|
177
|
+
.describe(
|
|
178
|
+
'Fields to overlay onto the entity’s CURRENT fields (a merge, not a full replace — ' +
|
|
179
|
+
'unlike the underlying applyDiff `update` op, which this is built on top of). `id` and ' +
|
|
180
|
+
'`children` are ignored if present.',
|
|
181
|
+
),
|
|
182
|
+
dryRun: DryRunField,
|
|
183
|
+
baseHash: BaseHashField,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
export const projectSceneEntityUpdate = defineOperation({
|
|
187
|
+
name: 'project.scene.entity.update',
|
|
188
|
+
summary: 'Merge field changes onto an existing entity, addressed by stable id.',
|
|
189
|
+
description:
|
|
190
|
+
'ID-aware — resolves the target by `id` regardless of its position/reordering in the ' +
|
|
191
|
+
'entities array. Reads the entity’s current fields, overlays `fields`, and hands the merged ' +
|
|
192
|
+
'whole object to applyDiff’s `update` op (a full replace under the hood) so nothing the ' +
|
|
193
|
+
'caller did not mention is lost.',
|
|
194
|
+
input: ProjectSceneEntityUpdateInput,
|
|
195
|
+
result: mutationResultSchema(EntityAfterSchema),
|
|
196
|
+
errors: [
|
|
197
|
+
NO_PROJECT_ROOT_ERROR,
|
|
198
|
+
PATH_OUTSIDE_PROJECT_ERROR,
|
|
199
|
+
FILE_NOT_FOUND_ERROR,
|
|
200
|
+
SCENE_INVALID_ERROR,
|
|
201
|
+
APPLY_DIFF_FAILED_ERROR,
|
|
202
|
+
CONFLICT_ERROR,
|
|
203
|
+
ENTITY_NOT_FOUND_ERROR,
|
|
204
|
+
],
|
|
205
|
+
requires: { project: true },
|
|
206
|
+
host: 'node',
|
|
207
|
+
mutates: true,
|
|
208
|
+
supportsDryRun: true,
|
|
209
|
+
permission: { risk: 'write', summary: 'Writes the target scene file.' },
|
|
210
|
+
async impl(input, ctx) {
|
|
211
|
+
const projectRoot = requireProjectRoot(ctx);
|
|
212
|
+
const { absPath, hash, scene } = readAndParseScene(projectRoot, input.scenePath);
|
|
213
|
+
checkNotStale(absPath, hash, input.baseHash);
|
|
214
|
+
|
|
215
|
+
const current = requireEntityById(scene.entities, input.id);
|
|
216
|
+
const { id: _id, children: _children, ...currentFields } = current;
|
|
217
|
+
const {
|
|
218
|
+
id: _fid,
|
|
219
|
+
children: _fchildren,
|
|
220
|
+
...overlayFields
|
|
221
|
+
} = input.fields as Record<string, unknown>;
|
|
222
|
+
const merged = { ...currentFields, ...overlayFields };
|
|
223
|
+
|
|
224
|
+
const result = applyDiffOrThrow(scene, {
|
|
225
|
+
ops: [{ type: 'update', id: input.id, entity: merged as SceneEntity }],
|
|
226
|
+
environmentChanged: false,
|
|
227
|
+
metaChanged: false,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const after = findEntityById(result.entities, input.id) ?? null;
|
|
231
|
+
return finalizeSceneMutation(
|
|
232
|
+
absPath,
|
|
233
|
+
input.scenePath,
|
|
234
|
+
result,
|
|
235
|
+
input.dryRun ?? false,
|
|
236
|
+
{ entity: current },
|
|
237
|
+
{ entity: after },
|
|
238
|
+
);
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
// project.scene.entity.move
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
const ProjectSceneEntityMoveInput = z.object({
|
|
247
|
+
scenePath: z.string().describe('Project-relative path to the .vscn.json scene file.'),
|
|
248
|
+
id: z.string().describe('Id of the entity to move.'),
|
|
249
|
+
parentId: z.string().nullable().describe('New parent entity id, or null for top-level.'),
|
|
250
|
+
index: z.number().int().min(0).describe('Target position among the new parent’s children.'),
|
|
251
|
+
dryRun: DryRunField,
|
|
252
|
+
baseHash: BaseHashField,
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
export const projectSceneEntityMove = defineOperation({
|
|
256
|
+
name: 'project.scene.entity.move',
|
|
257
|
+
summary: 'Reparent and/or reorder an existing entity, addressed by stable id.',
|
|
258
|
+
description: 'Builds a single `move` DiffOp and delegates to applyDiff.',
|
|
259
|
+
input: ProjectSceneEntityMoveInput,
|
|
260
|
+
result: mutationResultSchema(EntityAfterSchema),
|
|
261
|
+
errors: [
|
|
262
|
+
NO_PROJECT_ROOT_ERROR,
|
|
263
|
+
PATH_OUTSIDE_PROJECT_ERROR,
|
|
264
|
+
FILE_NOT_FOUND_ERROR,
|
|
265
|
+
SCENE_INVALID_ERROR,
|
|
266
|
+
APPLY_DIFF_FAILED_ERROR,
|
|
267
|
+
CONFLICT_ERROR,
|
|
268
|
+
ENTITY_NOT_FOUND_ERROR,
|
|
269
|
+
],
|
|
270
|
+
requires: { project: true },
|
|
271
|
+
host: 'node',
|
|
272
|
+
mutates: true,
|
|
273
|
+
supportsDryRun: true,
|
|
274
|
+
permission: { risk: 'write', summary: 'Writes the target scene file.' },
|
|
275
|
+
async impl(input, ctx) {
|
|
276
|
+
const projectRoot = requireProjectRoot(ctx);
|
|
277
|
+
const { absPath, hash, scene } = readAndParseScene(projectRoot, input.scenePath);
|
|
278
|
+
checkNotStale(absPath, hash, input.baseHash);
|
|
279
|
+
|
|
280
|
+
const before = requireEntityById(scene.entities, input.id);
|
|
281
|
+
const result = applyDiffOrThrow(scene, {
|
|
282
|
+
ops: [{ type: 'move', id: input.id, parentId: input.parentId, index: input.index }],
|
|
283
|
+
environmentChanged: false,
|
|
284
|
+
metaChanged: false,
|
|
285
|
+
});
|
|
286
|
+
const after = findEntityById(result.entities, input.id) ?? null;
|
|
287
|
+
|
|
288
|
+
return finalizeSceneMutation(
|
|
289
|
+
absPath,
|
|
290
|
+
input.scenePath,
|
|
291
|
+
result,
|
|
292
|
+
input.dryRun ?? false,
|
|
293
|
+
{ entity: before },
|
|
294
|
+
{ entity: after },
|
|
295
|
+
);
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
// project.scene.entity.remove
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
const ProjectSceneEntityRemoveInput = z.object({
|
|
304
|
+
scenePath: z.string().describe('Project-relative path to the .vscn.json scene file.'),
|
|
305
|
+
id: z.string().describe('Id of the entity (and its subtree) to remove.'),
|
|
306
|
+
dryRun: DryRunField,
|
|
307
|
+
baseHash: BaseHashField,
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
export const projectSceneEntityRemove = defineOperation({
|
|
311
|
+
name: 'project.scene.entity.remove',
|
|
312
|
+
summary: 'Delete an entity (and its subtree), addressed by stable id.',
|
|
313
|
+
description: 'Builds a single `remove` DiffOp and delegates to applyDiff.',
|
|
314
|
+
input: ProjectSceneEntityRemoveInput,
|
|
315
|
+
result: mutationResultSchema(EntityAfterSchema),
|
|
316
|
+
errors: [
|
|
317
|
+
NO_PROJECT_ROOT_ERROR,
|
|
318
|
+
PATH_OUTSIDE_PROJECT_ERROR,
|
|
319
|
+
FILE_NOT_FOUND_ERROR,
|
|
320
|
+
SCENE_INVALID_ERROR,
|
|
321
|
+
APPLY_DIFF_FAILED_ERROR,
|
|
322
|
+
CONFLICT_ERROR,
|
|
323
|
+
ENTITY_NOT_FOUND_ERROR,
|
|
324
|
+
],
|
|
325
|
+
requires: { project: true },
|
|
326
|
+
host: 'node',
|
|
327
|
+
mutates: true,
|
|
328
|
+
supportsDryRun: true,
|
|
329
|
+
permission: {
|
|
330
|
+
risk: 'destructive',
|
|
331
|
+
summary: 'Deletes an entity subtree from the target scene file.',
|
|
332
|
+
},
|
|
333
|
+
async impl(input, ctx) {
|
|
334
|
+
const projectRoot = requireProjectRoot(ctx);
|
|
335
|
+
const { absPath, hash, scene } = readAndParseScene(projectRoot, input.scenePath);
|
|
336
|
+
checkNotStale(absPath, hash, input.baseHash);
|
|
337
|
+
|
|
338
|
+
const before = requireEntityById(scene.entities, input.id);
|
|
339
|
+
const result = applyDiffOrThrow(scene, {
|
|
340
|
+
ops: [{ type: 'remove', id: input.id }],
|
|
341
|
+
environmentChanged: false,
|
|
342
|
+
metaChanged: false,
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
return finalizeSceneMutation(
|
|
346
|
+
absPath,
|
|
347
|
+
input.scenePath,
|
|
348
|
+
result,
|
|
349
|
+
input.dryRun ?? false,
|
|
350
|
+
{ entity: before },
|
|
351
|
+
{ entity: null },
|
|
352
|
+
);
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
/** Register every B2 `project.scene.entity.*` operation onto `registry`. */
|
|
357
|
+
export function registerEntityOperations(registry: OperationRegistry): void {
|
|
358
|
+
registry.register(projectSceneEntityAdd);
|
|
359
|
+
registry.register(projectSceneEntityUpdate);
|
|
360
|
+
registry.register(projectSceneEntityMove);
|
|
361
|
+
registry.register(projectSceneEntityRemove);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Re-exported for component-operations.ts (avoids a second copy of the
|
|
365
|
+
// "merge fields -> applyDiff update -> finalize" plumbing).
|
|
366
|
+
export { finalizeSceneMutation };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* B2 — `project.*` operations
|
|
3
|
+
* (docs/AI-NATIVE-AUTHORING-IMPLEMENTATION-SPEC.md §8 B2). Registered
|
|
4
|
+
* SEPARATELY from B1's `registerBuiltinOperations` (see `../operations.ts`)
|
|
5
|
+
* so B1's own registry tests — which assert `registerBuiltinOperations`
|
|
6
|
+
* registers "exactly the three B1 sample operations" — are unaffected; the
|
|
7
|
+
* default `operations` singleton (`../index.ts`) calls both.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export * from './asset-operations.js';
|
|
11
|
+
export * from './component-operations.js';
|
|
12
|
+
export * from './discovery-operations.js';
|
|
13
|
+
export * from './entity-operations.js';
|
|
14
|
+
export * from './input-map-operations.js';
|
|
15
|
+
export * from './manifest-operations.js';
|
|
16
|
+
export * from './scene-operations.js';
|
|
17
|
+
export {
|
|
18
|
+
BaseHashField,
|
|
19
|
+
CONFLICT_ERROR,
|
|
20
|
+
checkNotStale,
|
|
21
|
+
DryRunField,
|
|
22
|
+
ENTITY_NOT_FOUND_ERROR,
|
|
23
|
+
FILE_NOT_FOUND_ERROR,
|
|
24
|
+
findEntityById,
|
|
25
|
+
listProjectFiles,
|
|
26
|
+
mutationResultSchema,
|
|
27
|
+
NO_PROJECT_ROOT_ERROR,
|
|
28
|
+
PATH_OUTSIDE_PROJECT_ERROR,
|
|
29
|
+
readFileWithHash,
|
|
30
|
+
requireEntityById,
|
|
31
|
+
requireProjectRoot,
|
|
32
|
+
resolveProjectPath,
|
|
33
|
+
sha256Hex,
|
|
34
|
+
writeFileAtomic,
|
|
35
|
+
} from './shared.js';
|
|
36
|
+
|
|
37
|
+
import type { OperationRegistry } from '../registry.js';
|
|
38
|
+
import { registerAssetOperations } from './asset-operations.js';
|
|
39
|
+
import { registerComponentOperations } from './component-operations.js';
|
|
40
|
+
import { registerDiscoveryOperations } from './discovery-operations.js';
|
|
41
|
+
import { registerEntityOperations } from './entity-operations.js';
|
|
42
|
+
import { registerInputMapOperations } from './input-map-operations.js';
|
|
43
|
+
import { registerManifestOperations } from './manifest-operations.js';
|
|
44
|
+
import { registerSceneOperations } from './scene-operations.js';
|
|
45
|
+
|
|
46
|
+
/** Register every B2 `project.*` operation onto `registry`. */
|
|
47
|
+
export function registerProjectOperations(registry: OperationRegistry): void {
|
|
48
|
+
registerManifestOperations(registry);
|
|
49
|
+
registerSceneOperations(registry);
|
|
50
|
+
registerEntityOperations(registry);
|
|
51
|
+
registerComponentOperations(registry);
|
|
52
|
+
registerAssetOperations(registry);
|
|
53
|
+
registerInputMapOperations(registry);
|
|
54
|
+
registerDiscoveryOperations(registry);
|
|
55
|
+
}
|