@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.
Files changed (51) hide show
  1. package/package.json +27 -0
  2. package/src/cinematic/capabilities-operations.ts +128 -0
  3. package/src/cinematic/cue-operations.ts +198 -0
  4. package/src/cinematic/gsap-operations.ts +126 -0
  5. package/src/cinematic/index.ts +59 -0
  6. package/src/cinematic/preview-operations.ts +279 -0
  7. package/src/cinematic/preview-transport.ts +244 -0
  8. package/src/cinematic/render-operations.ts +409 -0
  9. package/src/cinematic/render-transport.ts +238 -0
  10. package/src/cinematic/theatre-operations.ts +306 -0
  11. package/src/editor/camera-operations.ts +169 -0
  12. package/src/editor/console-operations.ts +87 -0
  13. package/src/editor/hierarchy-operations.ts +95 -0
  14. package/src/editor/index.ts +62 -0
  15. package/src/editor/open-operations.ts +209 -0
  16. package/src/editor/screenshot-operations.ts +99 -0
  17. package/src/editor/selection-operations.ts +144 -0
  18. package/src/editor/session-operations.ts +73 -0
  19. package/src/editor/source-location-operations.ts +106 -0
  20. package/src/editor/transport.ts +647 -0
  21. package/src/errors.ts +72 -0
  22. package/src/http/http-projection.ts +349 -0
  23. package/src/http/index.ts +11 -0
  24. package/src/index.ts +67 -0
  25. package/src/mcp/index.ts +16 -0
  26. package/src/mcp/mcp-projection.ts +288 -0
  27. package/src/operations.ts +83 -0
  28. package/src/play/control-operations.ts +205 -0
  29. package/src/play/debug-command-operations.ts +245 -0
  30. package/src/play/index.ts +66 -0
  31. package/src/play/input-operations.ts +316 -0
  32. package/src/play/lifecycle-operations.ts +271 -0
  33. package/src/play/log-operations.ts +279 -0
  34. package/src/play/run-ticks-operations.ts +141 -0
  35. package/src/play/state-operations.ts +210 -0
  36. package/src/play/status-operations.ts +160 -0
  37. package/src/play/transport.ts +728 -0
  38. package/src/project/asset-operations.ts +243 -0
  39. package/src/project/component-operations.ts +337 -0
  40. package/src/project/discovery-operations.ts +269 -0
  41. package/src/project/entity-operations.ts +366 -0
  42. package/src/project/index.ts +55 -0
  43. package/src/project/input-map-operations.ts +233 -0
  44. package/src/project/manifest-operations.ts +355 -0
  45. package/src/project/scene-operations.ts +426 -0
  46. package/src/project/shared.ts +299 -0
  47. package/src/registry.ts +285 -0
  48. package/src/render/capabilities/ffmpeg.ts +141 -0
  49. package/src/render/index.ts +15 -0
  50. package/src/render/render-cinematic.ts +1847 -0
  51. package/src/types.ts +101 -0
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Shared plumbing for B2's `project.*` operations
3
+ * (docs/AI-NATIVE-AUTHORING-IMPLEMENTATION-SPEC.md §8 B2).
4
+ *
5
+ * Every `project.*` op is file-native (host: 'node', requires: { project: true })
6
+ * and runs with NO editor process — it resolves `ctx.projectRoot`, reads/writes
7
+ * plain files on disk, and reuses the engine's existing Zod schemas +
8
+ * `applyDiff` for anything scene-shaped (see `../../../engine/src/scene/scene-apply.ts`).
9
+ *
10
+ * Two cross-cutting decisions (pinned by the B2 task brief) live here so every
11
+ * operation module applies them identically:
12
+ *
13
+ * - DRY-RUN: every mutation's input carries an optional `dryRun` field
14
+ * (`DryRunInputShape`). When true, the impl computes the same
15
+ * before/after/filesChanged summary as a real write but returns before
16
+ * ever calling `writeFileAtomic` — `MutationEnvelope.written` is `false`
17
+ * and the file(s) on disk are provably unchanged (round-tripped by tests).
18
+ * - EXTERNAL-CHANGE DETECTION: every mutation's input carries an optional
19
+ * `baseHash` field (`BaseHashInputShape`) — the `contentHash` a prior
20
+ * `read`-shaped op returned. `checkNotStale` compares it against the hash
21
+ * of the file's CURRENT on-disk content (read fresh, inside the mutating
22
+ * op, right before the write decision) and throws the declared `CONFLICT`
23
+ * error on mismatch — the file changed between the caller's read and this
24
+ * write. Omitting `baseHash` skips the check (an agent that never read the
25
+ * file first has nothing to compare against; this mirrors optimistic-
26
+ * concurrency/ETag conventions, not a hidden requirement).
27
+ */
28
+
29
+ import { createHash } from 'node:crypto';
30
+ import {
31
+ existsSync,
32
+ mkdirSync,
33
+ readdirSync,
34
+ readFileSync,
35
+ renameSync,
36
+ statSync,
37
+ writeFileSync,
38
+ } from 'node:fs';
39
+ import { dirname, isAbsolute, join, normalize, relative } from 'node:path';
40
+ import type { SceneEntity } from '@vgai/engine/scene/scene-types';
41
+ import { z } from 'zod';
42
+ import { OperationError } from '../errors.js';
43
+ import type { ErrorDefinition } from '../registry.js';
44
+ import type { OperationContext } from '../types.js';
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Project-root resolution
48
+ // ---------------------------------------------------------------------------
49
+
50
+ export const NO_PROJECT_ROOT_ERROR: ErrorDefinition = {
51
+ code: 'NO_PROJECT_ROOT',
52
+ summary: 'dispatch() was called without ctx.projectRoot.',
53
+ data: z.object({}).describe('No additional data.'),
54
+ };
55
+
56
+ /** Every `project.*` op needs `ctx.projectRoot` — this is the one place that enforces it. */
57
+ export function requireProjectRoot(ctx: OperationContext): string {
58
+ if (!ctx.projectRoot) {
59
+ throw new OperationError(
60
+ 'NO_PROJECT_ROOT',
61
+ 'dispatch() was called without ctx.projectRoot.',
62
+ {},
63
+ );
64
+ }
65
+ return ctx.projectRoot;
66
+ }
67
+
68
+ export const PATH_OUTSIDE_PROJECT_ERROR: ErrorDefinition = {
69
+ code: 'PATH_OUTSIDE_PROJECT',
70
+ summary: 'A given relative path escapes ctx.projectRoot (e.g. via "..").',
71
+ data: z.object({ projectRoot: z.string(), path: z.string() }),
72
+ };
73
+
74
+ /**
75
+ * Resolve a project-relative path against `projectRoot`, rejecting absolute
76
+ * paths and any `..` escape — every `project.*` op that takes a path input
77
+ * (scene/manifest/input-map/asset) goes through this so an op can never be
78
+ * pointed outside the project directory.
79
+ */
80
+ export function resolveProjectPath(projectRoot: string, relativePath: string): string {
81
+ if (isAbsolute(relativePath)) {
82
+ throw new OperationError(
83
+ 'PATH_OUTSIDE_PROJECT',
84
+ `"${relativePath}" must be project-relative, not absolute.`,
85
+ {
86
+ projectRoot,
87
+ path: relativePath,
88
+ },
89
+ );
90
+ }
91
+ const resolved = normalize(join(projectRoot, relativePath));
92
+ const rel = relative(projectRoot, resolved);
93
+ if (rel === '..' || rel.startsWith(`..${'/'}`) || isAbsolute(rel)) {
94
+ throw new OperationError(
95
+ 'PATH_OUTSIDE_PROJECT',
96
+ `"${relativePath}" escapes the project root.`,
97
+ {
98
+ projectRoot,
99
+ path: relativePath,
100
+ },
101
+ );
102
+ }
103
+ return resolved;
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Content hashing / atomic write / conflict detection
108
+ // ---------------------------------------------------------------------------
109
+
110
+ export function sha256Hex(content: string): string {
111
+ return createHash('sha256').update(content, 'utf-8').digest('hex');
112
+ }
113
+
114
+ export interface FileRead {
115
+ raw: string;
116
+ hash: string;
117
+ }
118
+
119
+ export const FILE_NOT_FOUND_ERROR: ErrorDefinition = {
120
+ code: 'FILE_NOT_FOUND',
121
+ summary: 'The referenced project file does not exist on disk.',
122
+ data: z.object({ path: z.string() }),
123
+ };
124
+
125
+ /** Read a file's raw text + its content hash. Throws the declared `FILE_NOT_FOUND` shape when absent. */
126
+ export function readFileWithHash(absPath: string): FileRead {
127
+ if (!existsSync(absPath)) {
128
+ throw new OperationError('FILE_NOT_FOUND', `File not found: ${absPath}`, { path: absPath });
129
+ }
130
+ const raw = readFileSync(absPath, 'utf-8');
131
+ return { raw, hash: sha256Hex(raw) };
132
+ }
133
+
134
+ /** Write `content` to `absPath`, creating parent directories, via a temp-file + rename so a reader never observes a partial write. */
135
+ export function writeFileAtomic(absPath: string, content: string): void {
136
+ const dir = dirname(absPath);
137
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
138
+ const tmp = join(dir, `.${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2)}.tmp`);
139
+ writeFileSync(tmp, content, 'utf-8');
140
+ renameSync(tmp, absPath);
141
+ }
142
+
143
+ export const CONFLICT_ERROR: ErrorDefinition = {
144
+ code: 'CONFLICT',
145
+ summary:
146
+ 'The target file changed on disk since it was last read (baseHash mismatch) — the write ' +
147
+ 'was refused rather than silently clobbering the external change.',
148
+ data: z.object({ path: z.string(), expectedHash: z.string(), actualHash: z.string() }),
149
+ };
150
+
151
+ /**
152
+ * External-change detection (see module jsdoc). `actualHash` is the hash of
153
+ * what THIS op just read from disk; `baseHash` is the caller-supplied hash
154
+ * from a prior read. A mismatch means something wrote to the file in
155
+ * between — refuse rather than clobber.
156
+ */
157
+ export function checkNotStale(
158
+ path: string,
159
+ actualHash: string,
160
+ baseHash: string | undefined,
161
+ ): void {
162
+ if (baseHash !== undefined && actualHash !== baseHash) {
163
+ throw new OperationError(
164
+ 'CONFLICT',
165
+ `"${path}" changed on disk since it was last read (baseHash mismatch) — refusing to overwrite an external change.`,
166
+ { path, expectedHash: baseHash, actualHash },
167
+ );
168
+ }
169
+ }
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // Shared Zod input fragments
173
+ // ---------------------------------------------------------------------------
174
+
175
+ export const DryRunField = z
176
+ .boolean()
177
+ .optional()
178
+ .describe(
179
+ 'When true, compute the before/after summary and the files that WOULD change, but write ' +
180
+ 'nothing to disk. Defaults to false (a real write).',
181
+ );
182
+
183
+ export const BaseHashField = z
184
+ .string()
185
+ .optional()
186
+ .describe(
187
+ 'The `contentHash` a prior read-shaped op returned for this file. When given, the write is ' +
188
+ "refused with a CONFLICT error if the file's current on-disk content hash no longer " +
189
+ 'matches (external-change detection). Omit to skip the check.',
190
+ );
191
+
192
+ /** The envelope every mutation op's result carries, parameterized by the op's own `after` shape. */
193
+ export function mutationResultSchema<T extends z.ZodType>(afterSchema: T) {
194
+ return z.object({
195
+ dryRun: z.boolean().describe('True when this call computed the change without writing it.'),
196
+ written: z.boolean().describe('True iff bytes were actually written to disk this call.'),
197
+ filesChanged: z
198
+ .array(z.string())
199
+ .describe('Absolute path(s) written (dryRun:false) or that WOULD be written (dryRun:true).'),
200
+ before: z.unknown().describe('Summary of the relevant state before this change.'),
201
+ after: afterSchema.describe(
202
+ 'Summary of the relevant state after this change (or that WOULD result).',
203
+ ),
204
+ });
205
+ }
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // Scene entity tree walk (read-side helper shared by entity/component ops)
209
+ // ---------------------------------------------------------------------------
210
+
211
+ export const ENTITY_NOT_FOUND_ERROR: ErrorDefinition = {
212
+ code: 'ENTITY_NOT_FOUND',
213
+ summary: 'No entity with the given id exists in the scene.',
214
+ data: z.object({ id: z.string() }),
215
+ };
216
+
217
+ /** Depth-first search for an entity by its stable `id` — the SAME identity `applyDiff` addresses by (never an array index). */
218
+ export function findEntityById(entities: SceneEntity[], id: string): SceneEntity | undefined {
219
+ for (const e of entities) {
220
+ if (e.id === id) return e;
221
+ if (e.children) {
222
+ const found = findEntityById(e.children, id);
223
+ if (found) return found;
224
+ }
225
+ }
226
+ return undefined;
227
+ }
228
+
229
+ /** Require an entity by id, throwing the declared `ENTITY_NOT_FOUND` shape otherwise. */
230
+ export function requireEntityById(entities: SceneEntity[], id: string): SceneEntity {
231
+ const found = findEntityById(entities, id);
232
+ if (!found) {
233
+ throw new OperationError(
234
+ 'ENTITY_NOT_FOUND',
235
+ `No entity with id "${id}" exists in this scene.`,
236
+ { id },
237
+ );
238
+ }
239
+ return found;
240
+ }
241
+
242
+ // ---------------------------------------------------------------------------
243
+ // Project-wide file discovery (shared by project.discover / story / theatre /
244
+ // xstate discovery ops — all "find files matching X under the project" ops).
245
+ // ---------------------------------------------------------------------------
246
+
247
+ /** Directories never worth descending into for project-content discovery. */
248
+ const SKIP_DIR_NAMES = new Set([
249
+ 'node_modules',
250
+ '.git',
251
+ 'dist',
252
+ 'dist-server',
253
+ '.vgai',
254
+ '.ci-scaffold',
255
+ ]);
256
+
257
+ /**
258
+ * Recursively list every file under `projectRoot` for which `matches(relPath)`
259
+ * is true, returning POSIX-style project-relative paths, sorted. Used for
260
+ * best-effort discovery ops (stories/Theatre project state/XState machines) —
261
+ * a plain filename-pattern walk, not a build-tool glob dependency.
262
+ */
263
+ /** `readdirSync`, swallowing a race/permission error into `[]` (a directory that vanished mid-walk is not this function's problem). */
264
+ function safeReaddir(dir: string): string[] {
265
+ try {
266
+ return readdirSync(dir);
267
+ } catch {
268
+ return [];
269
+ }
270
+ }
271
+
272
+ /** `statSync(...).isDirectory()`, swallowing a race/permission error into `false` (skip, don't crash the whole walk). */
273
+ function safeIsDirectory(abs: string): boolean {
274
+ try {
275
+ return statSync(abs).isDirectory();
276
+ } catch {
277
+ return false;
278
+ }
279
+ }
280
+
281
+ export function listProjectFiles(
282
+ projectRoot: string,
283
+ matches: (relPath: string) => boolean,
284
+ ): string[] {
285
+ const out: string[] = [];
286
+ function walk(dir: string): void {
287
+ for (const name of safeReaddir(dir)) {
288
+ const abs = join(dir, name);
289
+ if (safeIsDirectory(abs)) {
290
+ if (!SKIP_DIR_NAMES.has(name)) walk(abs);
291
+ continue;
292
+ }
293
+ const rel = relative(projectRoot, abs).split('\\').join('/');
294
+ if (matches(rel)) out.push(rel);
295
+ }
296
+ }
297
+ walk(projectRoot);
298
+ return out.sort();
299
+ }
@@ -0,0 +1,285 @@
1
+ import type { z } from 'zod';
2
+ import {
3
+ CORE_ERROR_CODES,
4
+ OperationError,
5
+ type StructuredOperationError,
6
+ toStructuredIssues,
7
+ } from './errors.js';
8
+ import {
9
+ type ExecutionHost,
10
+ type ExecutionRequirements,
11
+ OPERATION_NAMESPACES,
12
+ type OperationContext,
13
+ type OperationNamespace,
14
+ type PermissionMetadata,
15
+ } from './types.js';
16
+
17
+ /** One declared, machine-readable failure mode of an operation (§8 B1: "structured error codes and data schemas"). */
18
+ export interface ErrorDefinition<TCode extends string = string> {
19
+ code: TCode;
20
+ /** One-line human summary of when this code fires — for docs/help text, never parsed by callers. */
21
+ summary: string;
22
+ /** Optional schema for this code's `data` payload. When present, `dispatch()` validates a thrown OperationError's data against it. */
23
+ data?: z.ZodType;
24
+ }
25
+
26
+ /**
27
+ * One operation, fully self-describing: identity, both schemas, every
28
+ * declared failure mode, where/how it runs, and its own implementation.
29
+ * Built via `defineOperation` (below), which validates the name shape and
30
+ * error-code uniqueness at definition time.
31
+ */
32
+ export interface OperationDefinition<
33
+ TInput extends z.ZodType = z.ZodType,
34
+ TResult extends z.ZodType = z.ZodType,
35
+ TErrorCode extends string = string,
36
+ > {
37
+ /** Fully qualified dotted name, e.g. "project.scene.read" (§5.7 namespaces). */
38
+ name: string;
39
+ /** Short, one-line summary (for CLI help / listOperations tables). */
40
+ summary: string;
41
+ /** Longer prose description of behavior, side effects, and caveats. */
42
+ description: string;
43
+ /** Zod schema every `dispatch()` input is validated against before `impl` runs. */
44
+ input: TInput;
45
+ /** Zod schema every `impl` return value is validated against before `dispatch()` succeeds. */
46
+ result: TResult;
47
+ /** Every structured failure mode this operation may raise via `OperationError`. */
48
+ errors: ReadonlyArray<ErrorDefinition<TErrorCode>>;
49
+ /** Which live contexts this operation needs (project/editor/play/render). */
50
+ requires: ExecutionRequirements;
51
+ /** Which of the three hosts this operation executes on (node / editor-browser / runtime-page). */
52
+ host: ExecutionHost;
53
+ /** True if this operation writes/changes state (files, editor, runtime). */
54
+ mutates: boolean;
55
+ /** True if this operation supports a dry-run mode (mutations only, meaningful subset). */
56
+ supportsDryRun: boolean;
57
+ /** True for jobs that must not ride a short request/response timeout (e.g. `cinematic.render` on the editor relay). */
58
+ longRunning?: boolean;
59
+ /** Coarse permission/risk metadata for gated callers (agents, HTTP/MCP auth). */
60
+ permission: PermissionMetadata;
61
+ /** The actual implementation. Receives already-schema-validated input. */
62
+ impl: (input: z.infer<TInput>, ctx: OperationContext) => Promise<z.infer<TResult>>;
63
+ }
64
+
65
+ /** `listOperations()`'s element shape — every field of `OperationDefinition` except `impl`, so enumerating never risks invoking anything. */
66
+ export type OperationSummary<
67
+ TInput extends z.ZodType = z.ZodType,
68
+ TResult extends z.ZodType = z.ZodType,
69
+ TErrorCode extends string = string,
70
+ > = Omit<OperationDefinition<TInput, TResult, TErrorCode>, 'impl'>;
71
+
72
+ const NAME_PATTERN = /^[a-z][a-zA-Z0-9]*(\.[a-z][a-zA-Z0-9]*)+$/;
73
+
74
+ function namespaceOf(name: string): string {
75
+ const dot = name.indexOf('.');
76
+ return dot === -1 ? name : name.slice(0, dot);
77
+ }
78
+
79
+ /**
80
+ * Typed helper that builds an `OperationDefinition`. Pure and synchronous —
81
+ * it does not touch a registry (so it can never itself throw "duplicate
82
+ * name"; that check happens at `OperationRegistry.register`, which has the
83
+ * cross-operation state to detect it) — but it DOES validate the two things
84
+ * that are decidable from the definition alone: the name is a valid dotted
85
+ * `namespace.rest` string under one of the four operation namespaces
86
+ * (§5.7), and no two declared error codes on the same operation collide.
87
+ *
88
+ * Exists mainly for type inference: it pins `impl`'s parameter/return types
89
+ * to `z.infer<TInput>` / `z.infer<TResult>` so a mismatched implementation
90
+ * fails to compile rather than failing at runtime.
91
+ */
92
+ export function defineOperation<
93
+ TInput extends z.ZodType,
94
+ TResult extends z.ZodType,
95
+ TErrorCode extends string = string,
96
+ >(
97
+ def: OperationDefinition<TInput, TResult, TErrorCode>,
98
+ ): OperationDefinition<TInput, TResult, TErrorCode> {
99
+ if (!NAME_PATTERN.test(def.name)) {
100
+ throw new Error(
101
+ `defineOperation: "${def.name}" is not a valid dotted operation name ` +
102
+ '(expected e.g. "project.scene.read" — lowercase-leading segments joined by dots).',
103
+ );
104
+ }
105
+ const ns = namespaceOf(def.name);
106
+ if (!(OPERATION_NAMESPACES as readonly string[]).includes(ns)) {
107
+ throw new Error(
108
+ `defineOperation: "${def.name}" has unknown namespace "${ns}" — expected one of ` +
109
+ `${OPERATION_NAMESPACES.join(', ')} (§5.7).`,
110
+ );
111
+ }
112
+ const seen = new Set<string>();
113
+ for (const err of def.errors) {
114
+ if (seen.has(err.code)) {
115
+ throw new Error(
116
+ `defineOperation: "${def.name}" declares duplicate error code "${err.code}".`,
117
+ );
118
+ }
119
+ seen.add(err.code);
120
+ }
121
+ return def;
122
+ }
123
+
124
+ /** `dispatch()`'s result — a discriminated union, never a thrown exception, so every projection (CLI/HTTP/MCP) gets one uniform JSON-able shape for both success and failure. */
125
+ export type OperationOutcome<TResult = unknown> =
126
+ | { ok: true; data: TResult }
127
+ | { ok: false; error: StructuredOperationError };
128
+
129
+ /**
130
+ * Normalize whatever an `impl` threw into a `StructuredOperationError`.
131
+ * Three cases:
132
+ * 1. A declared `OperationError` whose code IS in `def.errors` and whose
133
+ * `data` (if the code declares a schema) validates — forwarded as-is,
134
+ * `data` replaced by its *parsed* form.
135
+ * 2. A declared code whose `data` fails its own schema — that is itself an
136
+ * implementation bug, surfaced as INVALID_OUTPUT (never silently
137
+ * forwarding unvalidated data).
138
+ * 3. Anything else — an `OperationError` with an undeclared code, a plain
139
+ * `Error`, or a non-Error throw — normalized into INTERNAL_ERROR. The
140
+ * raw exception/message is tucked into `data.message`, never used as
141
+ * the identifying `code`.
142
+ */
143
+ function normalizeThrown(def: OperationDefinition, err: unknown): StructuredOperationError {
144
+ if (err instanceof OperationError) {
145
+ const declared = def.errors.find((e) => e.code === err.code);
146
+ if (!declared) {
147
+ return {
148
+ code: CORE_ERROR_CODES.INTERNAL_ERROR,
149
+ message: `"${def.name}" threw undeclared error code "${err.code}".`,
150
+ data: { undeclaredCode: err.code, message: err.message },
151
+ };
152
+ }
153
+ if (declared.data) {
154
+ const parsed = declared.data.safeParse(err.data);
155
+ if (!parsed.success) {
156
+ return {
157
+ code: CORE_ERROR_CODES.INVALID_OUTPUT,
158
+ message:
159
+ `"${def.name}" threw declared code "${err.code}" but its data failed that ` +
160
+ "code's own schema.",
161
+ issues: toStructuredIssues(parsed.error.issues),
162
+ };
163
+ }
164
+ return { code: err.code, message: err.message, data: parsed.data };
165
+ }
166
+ return {
167
+ code: err.code,
168
+ message: err.message,
169
+ ...(err.data !== undefined ? { data: err.data } : {}),
170
+ };
171
+ }
172
+
173
+ const message = err instanceof Error ? err.message : String(err);
174
+ return {
175
+ code: CORE_ERROR_CODES.INTERNAL_ERROR,
176
+ message: `"${def.name}" threw an unstructured exception.`,
177
+ data: { message },
178
+ };
179
+ }
180
+
181
+ function toSummary(def: OperationDefinition): OperationSummary {
182
+ const { impl: _impl, ...summary } = def;
183
+ return summary;
184
+ }
185
+
186
+ /**
187
+ * The one registry of operation definitions (§8 B1). Holds definitions
188
+ * keyed by their fully-qualified name; `register` rejects a duplicate name
189
+ * outright (names are unique and stable per the AC), `listOperations`
190
+ * enumerates metadata without ever touching `impl`, and `dispatch` is the
191
+ * single validated call path: input schema -> impl -> result schema, with
192
+ * every failure normalized into `StructuredOperationError`.
193
+ */
194
+ export class OperationRegistry {
195
+ private readonly definitions = new Map<string, OperationDefinition>();
196
+
197
+ /** Register a definition. Throws synchronously on a duplicate name — names are unique and stable by construction, not by convention. */
198
+ register<TInput extends z.ZodType, TResult extends z.ZodType, TErrorCode extends string>(
199
+ def: OperationDefinition<TInput, TResult, TErrorCode>,
200
+ ): void {
201
+ if (this.definitions.has(def.name)) {
202
+ throw new Error(
203
+ `OperationRegistry.register: "${def.name}" is already registered — operation names ` +
204
+ 'must be unique and stable.',
205
+ );
206
+ }
207
+ this.definitions.set(def.name, def as unknown as OperationDefinition);
208
+ }
209
+
210
+ /** Enumerate every registered operation's metadata. Never invokes `impl`. */
211
+ listOperations(): OperationSummary[] {
212
+ return [...this.definitions.values()].map(toSummary);
213
+ }
214
+
215
+ /** Look up one operation's full definition (including `impl`) by name, or `undefined`. */
216
+ getOperation(name: string): OperationDefinition | undefined {
217
+ return this.definitions.get(name);
218
+ }
219
+
220
+ has(name: string): boolean {
221
+ return this.definitions.has(name);
222
+ }
223
+
224
+ /**
225
+ * Validate `input` against the named operation's input schema, run its
226
+ * `impl`, validate the return value against its result schema, and return
227
+ * a uniform `OperationOutcome` — success or a `StructuredOperationError`.
228
+ * Never throws for an expected failure (unknown name, bad input, impl
229
+ * throw, bad output); those are exactly what this method exists to turn
230
+ * into a machine-readable result instead of an exception a caller has to
231
+ * parse prose out of.
232
+ */
233
+ async dispatch<TResult = unknown>(
234
+ name: string,
235
+ input: unknown,
236
+ ctx: OperationContext = {},
237
+ ): Promise<OperationOutcome<TResult>> {
238
+ const def = this.definitions.get(name);
239
+ if (!def) {
240
+ return {
241
+ ok: false,
242
+ error: {
243
+ code: CORE_ERROR_CODES.OPERATION_NOT_FOUND,
244
+ message: `No operation is registered as "${name}".`,
245
+ data: { name },
246
+ },
247
+ };
248
+ }
249
+
250
+ const parsedInput = def.input.safeParse(input);
251
+ if (!parsedInput.success) {
252
+ return {
253
+ ok: false,
254
+ error: {
255
+ code: CORE_ERROR_CODES.INVALID_INPUT,
256
+ message: `Input for "${name}" failed schema validation.`,
257
+ issues: toStructuredIssues(parsedInput.error.issues),
258
+ },
259
+ };
260
+ }
261
+
262
+ let rawResult: unknown;
263
+ try {
264
+ rawResult = await def.impl(parsedInput.data, ctx);
265
+ } catch (err) {
266
+ return { ok: false, error: normalizeThrown(def, err) };
267
+ }
268
+
269
+ const parsedResult = def.result.safeParse(rawResult);
270
+ if (!parsedResult.success) {
271
+ return {
272
+ ok: false,
273
+ error: {
274
+ code: CORE_ERROR_CODES.INVALID_OUTPUT,
275
+ message: `Result of "${name}" failed schema validation (implementation bug).`,
276
+ issues: toStructuredIssues(parsedResult.error.issues),
277
+ },
278
+ };
279
+ }
280
+
281
+ return { ok: true, data: parsedResult.data as TResult };
282
+ }
283
+ }
284
+
285
+ export type { ExecutionHost, ExecutionRequirements, OperationContext, OperationNamespace };