@vgai/sdk 0.5.21 → 0.5.23

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 CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@vgai/sdk",
3
3
  "author": "Volter AI, Inc.",
4
4
  "license": "Apache-2.0",
5
- "version": "0.5.21",
5
+ "version": "0.5.23",
6
6
  "type": "module",
7
7
  "repository": {
8
8
  "type": "git",
@@ -26,11 +26,12 @@
26
26
  "./registry": "./src/registry.ts",
27
27
  "./run-name": "./src/project/run-name.ts",
28
28
  "./session-journal": "./src/project/session-journal.ts",
29
+ "./tab-census": "./src/project/tab-census.ts",
29
30
  "./tools": "./src/tools.ts"
30
31
  },
31
32
  "dependencies": {
32
33
  "@modelcontextprotocol/sdk": "^1.30.0",
33
- "@vgai/engine": "0.5.21",
34
+ "@vgai/engine": "0.5.23",
34
35
  "playwright": "^1.58.2",
35
36
  "zod": "^4.3.6"
36
37
  }
@@ -5,7 +5,7 @@
5
5
  * request/response SDK operation cannot itself BE a stream. This describes
6
6
  * the real, existing subscription mechanics: the general SSE endpoint
7
7
  * (`GET /__editor/events`, broadcasting `editor-command`/`project-changed`/
8
- * `asset-moved`/`scene-changed`) and the play-mode log persistence pair
8
+ * `asset-moved`) and the play-mode log persistence pair
9
9
  * (`POST /__editor/log-session`, `GET/POST /__editor/log-entries`), plus a
10
10
  * live count of currently-buffered log entries as a liveness signal. Editor
11
11
  * console messages themselves (`packages/editor/src/editor-console.ts`) are
@@ -1,10 +1,9 @@
1
1
  /**
2
- * `editor.scene.open` / `editor.asset.open` / `editor.story.open`
3
- * (B3, §8 B3 "open scene/asset/story").
2
+ * `editor.asset.open` / `editor.story.open`.
4
3
  *
5
- * scene/asset open are genuinely wired end to end: `{type:'open-scene'}` /
6
- * `{type:'open-asset-tab'}` are existing, already-handled cases in the
7
- * browser's `handleCommand` switch (`command-listener.ts`).
4
+ * asset open is genuinely wired end to end: `{type:'open-asset-tab'}` is an
5
+ * existing, already-handled case in the browser's `handleCommand` switch
6
+ * (`command-listener.ts`).
8
7
  *
9
8
  * story open remains an HONEST GAP (see `./transport.ts` module jsdoc), and
10
9
  * still does after the B3-followup false-ack fix: the editor has no
@@ -48,14 +47,6 @@ const STORY_OPEN_UNSUPPORTED_ERROR = {
48
47
 
49
48
  const OkResult = z.object({ ok: z.literal(true) });
50
49
 
51
- // ---------------------------------------------------------------------------
52
- // WO-8: `editor.scene.open` lived here. It relayed `{type:'open-scene', path}` to
53
- // a connected editor, and that verb now REJECTS — there is no scene document to
54
- // open, because the `.vscn.json` format is deleted. A tool whose only relay is a
55
- // guaranteed rejection is worse than no tool.
56
- // ---------------------------------------------------------------------------
57
-
58
-
59
50
  // ---------------------------------------------------------------------------
60
51
  // editor.asset.open
61
52
  // ---------------------------------------------------------------------------
@@ -0,0 +1,218 @@
1
+ /**
2
+ * `~/.vgai/editor-sessions.json` — the ONE spelling of the editor-session
3
+ * registry contract, and the shared machinery every reader was hand-copying.
4
+ *
5
+ * The registry's WRITE half stays with its owner
6
+ * (`packages/editor/server/session-registry.ts`); this module owns the
7
+ * FORMAT: the entry shape, the shape guards, the file path, the
8
+ * liveness-filtered read, the `/__editor/project` answer parser, and the
9
+ * deterministic session-selection core. It existed as FOUR drifting copies
10
+ * (server registry, the CLI, and both vgai-sdk transports) held together by
11
+ * a "the CLI has no editor dependency" premise that had stopped being true —
12
+ * and the drift was already real: one copy's `/__editor/project` parser
13
+ * dropped `manifestError`, reporting a degraded session as belonging to no
14
+ * project.
15
+ */
16
+
17
+ import { readFileSync, realpathSync } from 'node:fs';
18
+ import { homedir } from 'node:os';
19
+ import { join, resolve } from 'node:path';
20
+
21
+ /** One registry entry, exactly as the server's write half records it. */
22
+ export interface EditorSessionEntry {
23
+ /** Canonical project root currently open, or null when none. */
24
+ project: string | null;
25
+ port: number;
26
+ /** PID of the dev-server process. */
27
+ pid: number;
28
+ /** ISO timestamp of server start. */
29
+ startedAt: string;
30
+ sessionId: string | null;
31
+ /** Private loopback control credential — never returned by an editor HTTP
32
+ * route; exists only in this user-private registry. */
33
+ controlSecret?: string | null;
34
+ repositoryId: string | null;
35
+ worktreeId: string | null;
36
+ worktreeRoot: string | null;
37
+ projectRelativePath: string | null;
38
+ branch: string | null;
39
+ headCommit: string | null;
40
+ baseCommit: string | null;
41
+ }
42
+
43
+ export const EDITOR_SESSIONS_REGISTRY_FILE = join(homedir(), '.vgai', 'editor-sessions.json');
44
+
45
+ export function isEditorSessionEntry(v: unknown): v is EditorSessionEntry {
46
+ if (typeof v !== 'object' || v === null) return false;
47
+ const s = v as Record<string, unknown>;
48
+ const optionalIdentity = (value: unknown) =>
49
+ value === undefined || value === null || typeof value === 'string';
50
+ return (
51
+ (typeof s['project'] === 'string' || s['project'] === null) &&
52
+ typeof s['port'] === 'number' &&
53
+ typeof s['pid'] === 'number' &&
54
+ typeof s['startedAt'] === 'string' &&
55
+ optionalIdentity(s['sessionId']) &&
56
+ optionalIdentity(s['controlSecret']) &&
57
+ optionalIdentity(s['repositoryId']) &&
58
+ optionalIdentity(s['worktreeId']) &&
59
+ optionalIdentity(s['worktreeRoot']) &&
60
+ optionalIdentity(s['projectRelativePath']) &&
61
+ optionalIdentity(s['branch']) &&
62
+ optionalIdentity(s['headCommit']) &&
63
+ optionalIdentity(s['baseCommit'])
64
+ );
65
+ }
66
+
67
+ export function normalizeEditorSessionEntry(session: EditorSessionEntry): EditorSessionEntry {
68
+ return {
69
+ ...session,
70
+ sessionId: session.sessionId ?? null,
71
+ controlSecret: session.controlSecret ?? null,
72
+ repositoryId: session.repositoryId ?? null,
73
+ worktreeId: session.worktreeId ?? null,
74
+ worktreeRoot: session.worktreeRoot ?? null,
75
+ projectRelativePath: session.projectRelativePath ?? null,
76
+ branch: session.branch ?? null,
77
+ headCommit: session.headCommit ?? null,
78
+ baseCommit: session.baseCommit ?? null,
79
+ };
80
+ }
81
+
82
+ export function pidAlive(pid: number): boolean {
83
+ try {
84
+ process.kill(pid, 0);
85
+ return true;
86
+ } catch {
87
+ return false;
88
+ }
89
+ }
90
+
91
+ /** The registry's live entries — shape-validated and PID-liveness-filtered. */
92
+ export function readLiveRegisteredSessions(): EditorSessionEntry[] {
93
+ try {
94
+ const raw: unknown = JSON.parse(readFileSync(EDITOR_SESSIONS_REGISTRY_FILE, 'utf8'));
95
+ return Array.isArray(raw)
96
+ ? raw
97
+ .filter(isEditorSessionEntry)
98
+ .map(normalizeEditorSessionEntry)
99
+ .filter((s) => pidAlive(s.pid))
100
+ : [];
101
+ } catch {
102
+ return [];
103
+ }
104
+ }
105
+
106
+ /**
107
+ * WHICH PROJECT a `/__editor/project` body says its server is serving.
108
+ *
109
+ * `serving` is that server's own statement of "I AM serving this project, I
110
+ * just cannot describe it" (its manifest is unparseable or fails strict
111
+ * validation) — added to the route precisely because a bare
112
+ * `{ project: null }` is indistinguishable from "no project open". Reading
113
+ * only `project.path` collapses the two, and the cost is that every
114
+ * project-matched command loses a live session the moment a save breaks its
115
+ * manifest, reporting it as belonging to no project rather than as this
116
+ * project's degraded session.
117
+ */
118
+ export function servedProjectAnswer(body: unknown): {
119
+ path: string | null;
120
+ manifestError: string | null;
121
+ } {
122
+ const b = body as {
123
+ project?: { path?: string } | null;
124
+ serving?: { path?: string; error?: string } | null;
125
+ };
126
+ return {
127
+ path: b.project?.path ?? b.serving?.path ?? null,
128
+ manifestError: b.project ? null : (b.serving?.error ?? null),
129
+ };
130
+ }
131
+
132
+ /** Canonical (realpathed when possible) absolute form of a project path. */
133
+ function canonicalizeProjectPath(p: string): string {
134
+ const absolute = resolve(p);
135
+ try {
136
+ return realpathSync(absolute);
137
+ } catch {
138
+ return absolute;
139
+ }
140
+ }
141
+
142
+ /** Extract the port from an `http(s)://host:port` URL, or undefined if unparseable. */
143
+ function portOfUrl(url: string): number | undefined {
144
+ try {
145
+ const port = new URL(url).port;
146
+ return port ? Number(port) : undefined;
147
+ } catch {
148
+ return undefined;
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Deterministic session selection — the ONE precedence order every surface
154
+ * follows: explicit `ctx.editorUrl` probe > port match against the listing >
155
+ * `ctx.projectRoot` match > lowest live port > not-running. Generic over the
156
+ * session shape so the editor and play transports (whose probe results
157
+ * differ) share the algorithm rather than a copy of it.
158
+ */
159
+ export async function resolveRegisteredSession<
160
+ T extends { port: number; project: string | null; url?: string },
161
+ >(
162
+ ctx: { editorUrl?: string | undefined; projectRoot?: string | undefined },
163
+ transport: {
164
+ listSessions(timeoutMs: number): Promise<T[]>;
165
+ probeSessionUrl?:
166
+ | ((url: string, timeoutMs: number) => Promise<T | null | undefined>)
167
+ | undefined;
168
+ },
169
+ options: {
170
+ probeTimeoutMs: number;
171
+ discoveryTimeoutMs: number;
172
+ withTimeout: <V>(work: Promise<V>, timeoutMs: number, what: string) => Promise<V>;
173
+ notRunning: () => never;
174
+ },
175
+ ): Promise<T> {
176
+ const editorUrl = ctx.editorUrl;
177
+ if (editorUrl !== undefined && transport.probeSessionUrl) {
178
+ try {
179
+ const session = await options.withTimeout(
180
+ transport.probeSessionUrl(editorUrl, options.probeTimeoutMs),
181
+ options.probeTimeoutMs,
182
+ 'explicit editor URL probe',
183
+ );
184
+ if (session) return session;
185
+ } catch {}
186
+ return options.notRunning();
187
+ }
188
+
189
+ let sessions: T[];
190
+ try {
191
+ sessions = await options.withTimeout(
192
+ transport.listSessions(options.discoveryTimeoutMs),
193
+ options.discoveryTimeoutMs,
194
+ 'editor session discovery',
195
+ );
196
+ } catch {
197
+ return options.notRunning();
198
+ }
199
+ if (sessions.length === 0) return options.notRunning();
200
+
201
+ if (editorUrl !== undefined) {
202
+ const port = portOfUrl(editorUrl);
203
+ const match = sessions.find((s) => s.port === port);
204
+ if (!match) return options.notRunning();
205
+ return { ...match, url: editorUrl.replace(/\/+$/, '') };
206
+ }
207
+
208
+ if (ctx.projectRoot !== undefined) {
209
+ const canon = canonicalizeProjectPath(ctx.projectRoot);
210
+ const match = sessions.find(
211
+ (s) => s.project !== null && canonicalizeProjectPath(s.project) === canon,
212
+ );
213
+ if (!match) return options.notRunning();
214
+ return match;
215
+ }
216
+
217
+ return [...sessions].sort((a, b) => a.port - b.port)[0]!;
218
+ }
@@ -110,13 +110,15 @@
110
110
  * deliberately never exercised in the first place.)
111
111
  */
112
112
 
113
- import { existsSync, readFileSync, realpathSync } from 'node:fs';
114
- import { homedir } from 'node:os';
115
- import { join, resolve } from 'node:path';
116
113
  import { z } from 'zod';
117
114
  import { ToolError } from '../errors.js';
118
115
  import type { ToolErrorDefinition } from '../registry.js';
119
116
  import type { ToolContext } from '../types.js';
117
+ import {
118
+ readLiveRegisteredSessions,
119
+ resolveRegisteredSession,
120
+ servedProjectAnswer,
121
+ } from './session-registry-format';
120
122
 
121
123
  // ---------------------------------------------------------------------------
122
124
  // Named timeouts (§8 B3 AC: "enforce a named timeout, don't block forever")
@@ -377,74 +379,6 @@ export interface EditorTransport {
377
379
  // Real transport
378
380
  // ---------------------------------------------------------------------------
379
381
 
380
- interface RegistrySessionEntry {
381
- project: string | null;
382
- port: number;
383
- pid: number;
384
- startedAt: string;
385
- kind?: string;
386
- }
387
-
388
- function isRegistrySessionEntry(v: unknown): v is RegistrySessionEntry {
389
- if (typeof v !== 'object' || v === null) return false;
390
- const s = v as Record<string, unknown>;
391
- return (
392
- (typeof s['project'] === 'string' || s['project'] === null) &&
393
- typeof s['port'] === 'number' &&
394
- typeof s['pid'] === 'number' &&
395
- typeof s['startedAt'] === 'string'
396
- );
397
- }
398
-
399
- function pidAlive(pid: number): boolean {
400
- try {
401
- process.kill(pid, 0);
402
- return true;
403
- } catch {
404
- return false;
405
- }
406
- }
407
-
408
- /**
409
- * Read `~/.vgai/editor-sessions.json` (the format contract owned by
410
- * `packages/editor/server/session-registry.ts`), PID-liveness-filtered.
411
- * Deliberate light duplicate of the read half — see module jsdoc.
412
- */
413
- function readRegisteredSessions(): RegistrySessionEntry[] {
414
- const registryFile = join(homedir(), '.vgai', 'editor-sessions.json');
415
- if (!existsSync(registryFile)) return [];
416
- try {
417
- const raw: unknown = JSON.parse(readFileSync(registryFile, 'utf8'));
418
- return Array.isArray(raw)
419
- ? raw.filter(isRegistrySessionEntry).filter((s) => pidAlive(s.pid))
420
- : [];
421
- } catch {
422
- return [];
423
- }
424
- }
425
-
426
- /**
427
- * WHICH PROJECT a `/__editor/project` body says its server is serving.
428
- *
429
- * `serving` is that server's own statement of "I AM serving this project, I
430
- * just cannot describe it" (its manifest is unparseable or fails strict
431
- * validation) — added to the route precisely because a bare `{ project: null }`
432
- * is indistinguishable from "no project open". Reading only `project.path`
433
- * collapses the two, and the cost is that every project-matched command loses
434
- * a live session the moment a save breaks its manifest, reporting it as
435
- * belonging to no project rather than as this project's degraded session.
436
- */
437
- function servedProjectPath(body: unknown): { path: string | null; manifestError: string | null } {
438
- const b = body as {
439
- project?: { path?: string } | null;
440
- serving?: { path?: string; error?: string } | null;
441
- };
442
- return {
443
- path: b.project?.path ?? b.serving?.path ?? null,
444
- manifestError: b.project ? null : (b.serving?.error ?? null),
445
- };
446
- }
447
-
448
382
  async function fetchJson(url: string, timeoutMs: number): Promise<unknown | undefined> {
449
383
  try {
450
384
  const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
@@ -494,7 +428,7 @@ export class HttpEditorTransport implements EditorTransport {
494
428
  const base = editorUrl.replace(/\/+$/, '');
495
429
  const body = await fetchJson(`${base}/__editor/project`, timeoutMs);
496
430
  if (body === undefined) return undefined;
497
- const served = servedProjectPath(body);
431
+ const served = servedProjectAnswer(body);
498
432
  const port = url.port ? Number(url.port) : url.protocol === 'https:' ? 443 : 80;
499
433
  return {
500
434
  port,
@@ -506,7 +440,7 @@ export class HttpEditorTransport implements EditorTransport {
506
440
  }
507
441
 
508
442
  async listSessions(timeoutMs: number): Promise<EditorSessionInfo[]> {
509
- const registered = readRegisteredSessions();
443
+ const registered = readLiveRegisteredSessions();
510
444
  const perProbeTimeout = Math.min(EDITOR_PROBE_TIMEOUT_MS, Math.max(200, timeoutMs));
511
445
  const probes = await Promise.all(
512
446
  registered.map(async (s) => {
@@ -515,7 +449,7 @@ export class HttpEditorTransport implements EditorTransport {
515
449
  perProbeTimeout,
516
450
  );
517
451
  if (body === undefined) return undefined;
518
- const served = servedProjectPath(body);
452
+ const served = servedProjectAnswer(body);
519
453
  const info: EditorSessionInfo = {
520
454
  port: s.port,
521
455
  project: served.path,
@@ -671,7 +605,7 @@ export class HttpEditorTransport implements EditorTransport {
671
605
  if (!body) return undefined;
672
606
  return {
673
607
  sseUrl: `${baseUrl(session)}/__editor/events`,
674
- events: ['editor-command', 'project-changed', 'asset-moved', 'scene-changed'],
608
+ events: ['editor-command', 'project-changed', 'asset-moved'],
675
609
  logEntriesUrl: `${baseUrl(session)}/__editor/log-entries`,
676
610
  logSessionUrl: `${baseUrl(session)}/__editor/log-session`,
677
611
  bufferedLogCount: Array.isArray(body.entries) ? body.entries.length : 0,
@@ -731,25 +665,6 @@ export const EDITOR_NOT_RUNNING_ERROR: ToolErrorDefinition = {
731
665
  data: z.object({ editorUrl: z.string().optional() }),
732
666
  };
733
667
 
734
- function canonicalize(p: string): string {
735
- const absolute = resolve(p);
736
- try {
737
- return realpathSync(absolute);
738
- } catch {
739
- return absolute;
740
- }
741
- }
742
-
743
- /** Extract the port from an `http(s)://host:port` URL, or undefined if unparseable. */
744
- function portOf(url: string): number | undefined {
745
- try {
746
- const port = new URL(url).port;
747
- return port ? Number(port) : undefined;
748
- } catch {
749
- return undefined;
750
- }
751
- }
752
-
753
668
  /**
754
669
  * Deterministic session selection (§8 B3 AC: "Session selection is
755
670
  * deterministic when multiple editors are running"). Precedence, in order:
@@ -775,52 +690,17 @@ function portOf(url: string): number | undefined {
775
690
  */
776
691
  export async function resolveEditorSession(
777
692
  ctx: ToolContext,
778
- transport: EditorTransport,
693
+ transport: Pick<EditorTransport, 'listSessions' | 'probeSessionUrl'>,
779
694
  ): Promise<EditorSessionInfo> {
780
695
  const editorUrl = ctx.editorUrl;
781
- const notRunning = (): never => {
782
- throw new ToolError('EDITOR_NOT_RUNNING', 'No editor connected.', {
783
- ...(editorUrl !== undefined ? { editorUrl } : {}),
784
- });
785
- };
786
-
787
- if (editorUrl !== undefined && transport.probeSessionUrl) {
788
- try {
789
- const session = await withTimeout(
790
- transport.probeSessionUrl(editorUrl, EDITOR_PROBE_TIMEOUT_MS),
791
- EDITOR_PROBE_TIMEOUT_MS,
792
- 'explicit editor URL probe',
793
- );
794
- if (session) return session;
795
- } catch {}
796
- return notRunning();
797
- }
798
-
799
- let sessions: EditorSessionInfo[];
800
- try {
801
- sessions = await withTimeout(
802
- transport.listSessions(EDITOR_SESSION_DISCOVERY_TIMEOUT_MS),
803
- EDITOR_SESSION_DISCOVERY_TIMEOUT_MS,
804
- 'editor session discovery',
805
- );
806
- } catch {
807
- return notRunning();
808
- }
809
- if (sessions.length === 0) return notRunning();
810
-
811
- if (editorUrl !== undefined) {
812
- const port = portOf(editorUrl);
813
- const match = sessions.find((s) => s.port === port);
814
- if (!match) return notRunning();
815
- return { ...match, url: editorUrl.replace(/\/+$/, '') };
816
- }
817
-
818
- if (ctx.projectRoot !== undefined) {
819
- const canon = canonicalize(ctx.projectRoot);
820
- const match = sessions.find((s) => s.project !== null && canonicalize(s.project) === canon);
821
- if (!match) return notRunning();
822
- return match;
823
- }
824
-
825
- return [...sessions].sort((a, b) => a.port - b.port)[0]!;
696
+ return resolveRegisteredSession<EditorSessionInfo>(ctx, transport, {
697
+ probeTimeoutMs: EDITOR_PROBE_TIMEOUT_MS,
698
+ discoveryTimeoutMs: EDITOR_SESSION_DISCOVERY_TIMEOUT_MS,
699
+ withTimeout,
700
+ notRunning: (): never => {
701
+ throw new ToolError('EDITOR_NOT_RUNNING', 'No editor connected.', {
702
+ ...(editorUrl !== undefined ? { editorUrl } : {}),
703
+ });
704
+ },
705
+ });
826
706
  }
@@ -18,67 +18,116 @@ export const GenerationJobStatusSchema = z.enum([
18
18
  ]);
19
19
 
20
20
  export const GenerationBillingSchema = z.discriminatedUnion('route', [
21
- z.object({ route: z.literal('mock') }),
22
- z.object({
23
- route: z.literal('managed'),
24
- estimatedCredits: z.number().nonnegative().optional(),
25
- settledCredits: z.number().nonnegative().optional(),
26
- }),
27
- z.object({
28
- route: z.literal('byok'),
29
- currency: z.literal('USD'),
30
- estimatedAmount: z.number().nonnegative().optional(),
31
- settledAmount: z.number().nonnegative().optional(),
32
- }),
21
+ z.object({ route: z.literal('mock') }).strict(),
22
+ z
23
+ .object({
24
+ route: z.literal('managed'),
25
+ estimatedCredits: z.number().nonnegative().optional(),
26
+ settledCredits: z.number().nonnegative().optional(),
27
+ })
28
+ .strict(),
29
+ z
30
+ .object({
31
+ route: z.literal('byok'),
32
+ currency: z.literal('USD'),
33
+ estimatedAmount: z.number().nonnegative().optional(),
34
+ settledAmount: z.number().nonnegative().optional(),
35
+ })
36
+ .strict(),
33
37
  ]);
34
38
 
35
- const GenerationOperationReferenceSchema = z.object({
36
- tool: z.string().min(1),
37
- input: z.unknown(),
38
- });
39
+ const GenerationOperationReferenceSchema = z
40
+ .object({
41
+ tool: z.string().min(1),
42
+ input: z.unknown(),
43
+ })
44
+ .strict();
39
45
 
40
- export const GenerationJobSchema = z.object({
41
- id: z.string().min(1),
42
- provider: z.string().min(1),
43
- externalId: z.string().min(1),
44
- label: z.string().min(1),
45
- operation: z.string().min(1),
46
- mode: z.enum(['mock', 'direct', 'managed']),
47
- status: GenerationJobStatusSchema,
48
- createdAt: z.string().datetime(),
49
- updatedAt: z.string().datetime(),
50
- progress: z.number().min(0).max(1).optional(),
51
- providerStatus: z.string().optional(),
52
- queuePosition: z.number().int().nonnegative().optional(),
53
- message: z.string().optional(),
54
- lastPolledAt: z.string().datetime().optional(),
55
- lastProviderChangeAt: z.string().datetime().optional(),
56
- pollError: z.string().optional(),
57
- pollErrorAt: z.string().datetime().optional(),
58
- pollFailureCount: z.number().int().nonnegative().optional(),
59
- nextPollAt: z.string().datetime().optional(),
60
- billing: GenerationBillingSchema,
61
- poll: GenerationOperationReferenceSchema,
62
- cancel: GenerationOperationReferenceSchema.optional(),
63
- accept: GenerationOperationReferenceSchema.optional(),
64
- acceptedAt: z.string().datetime().optional(),
65
- /** Last time a human/agent opened the native result. A later provider
66
- * update makes the terminal result unread again without changing status. */
67
- readAt: z.string().datetime().optional(),
68
- provenanceOperationId: z.string().optional(),
69
- outputPaths: z.array(z.string()).optional(),
70
- });
46
+ export const GenerationJobSchema = z
47
+ .object({
48
+ id: z.string().min(1),
49
+ provider: z.string().min(1),
50
+ externalId: z.string().min(1),
51
+ label: z.string().min(1),
52
+ operation: z.string().min(1),
53
+ mode: z.enum(['mock', 'direct', 'managed']),
54
+ status: GenerationJobStatusSchema,
55
+ createdAt: z.string().datetime(),
56
+ updatedAt: z.string().datetime(),
57
+ progress: z.number().min(0).max(1).optional(),
58
+ providerStatus: z.string().optional(),
59
+ queuePosition: z.number().int().nonnegative().optional(),
60
+ message: z.string().optional(),
61
+ lastPolledAt: z.string().datetime().optional(),
62
+ lastProviderChangeAt: z.string().datetime().optional(),
63
+ pollError: z.string().optional(),
64
+ pollErrorAt: z.string().datetime().optional(),
65
+ pollFailureCount: z.number().int().nonnegative().optional(),
66
+ nextPollAt: z.string().datetime().optional(),
67
+ billing: GenerationBillingSchema,
68
+ poll: GenerationOperationReferenceSchema,
69
+ cancel: GenerationOperationReferenceSchema.optional(),
70
+ accept: GenerationOperationReferenceSchema.optional(),
71
+ acceptedAt: z.string().datetime().optional(),
72
+ /** Last time a human/agent opened the native result. A later provider
73
+ * update makes the terminal result unread again without changing status. */
74
+ readAt: z.string().datetime().optional(),
75
+ provenanceOperationId: z.string().optional(),
76
+ outputPaths: z.array(z.string()).optional(),
77
+ })
78
+ .strict();
71
79
 
72
- export const GenerationJobsDocumentSchema = z.object({
73
- version: z.literal(1),
74
- jobs: z.array(GenerationJobSchema),
75
- });
80
+ export const GenerationJobsDocumentSchema = z
81
+ .object({
82
+ version: z.literal(1),
83
+ jobs: z.array(GenerationJobSchema),
84
+ })
85
+ .strict();
76
86
 
77
87
  export type GenerationJobStatus = z.infer<typeof GenerationJobStatusSchema>;
78
88
  export type GenerationBilling = z.infer<typeof GenerationBillingSchema>;
79
89
  export type GenerationJob = z.infer<typeof GenerationJobSchema>;
80
90
  export type GenerationJobsDocument = z.infer<typeof GenerationJobsDocumentSchema>;
81
91
 
92
+ /** The MANAGED arm of {@link GenerationBilling} — vgai credits, not currency. */
93
+ export type ManagedGenerationBilling = Extract<GenerationBilling, { route: 'managed' }>;
94
+
95
+ /**
96
+ * The managed-route billing leg of a provider tool's `toUpdate`, spread-ready.
97
+ *
98
+ * Every provider poll tool needs the same three-part decision — is this run on
99
+ * the managed route at all, did the provider report either credit figure, and
100
+ * which of the two are present — and Fal, Tripo, World Labs and OpenRouter had
101
+ * each spelled it out identically, fourteen lines apiece. The conditions are
102
+ * not obvious enough to retype safely: reporting `route: 'managed'` with both
103
+ * figures absent claims a charge nobody measured, and writing
104
+ * `estimatedCredits: undefined` is NOT the same as omitting it under
105
+ * `exactOptionalPropertyTypes` — it fails the schema rather than leaving the
106
+ * field unset.
107
+ *
108
+ * Call it with the parsed result itself; only the two credit fields are read.
109
+ *
110
+ * ...managedBilling(input.mode, result),
111
+ */
112
+ export function managedBilling(
113
+ mode: string,
114
+ credits: {
115
+ readonly estimatedCredits?: number | undefined;
116
+ readonly settledCredits?: number | undefined;
117
+ },
118
+ ): { billing?: ManagedGenerationBilling } {
119
+ if (mode !== 'managed') return {};
120
+ const { estimatedCredits, settledCredits } = credits;
121
+ if (estimatedCredits === undefined && settledCredits === undefined) return {};
122
+ return {
123
+ billing: {
124
+ route: 'managed',
125
+ ...(estimatedCredits === undefined ? {} : { estimatedCredits }),
126
+ ...(settledCredits === undefined ? {} : { settledCredits }),
127
+ },
128
+ };
129
+ }
130
+
82
131
  export interface GenerationJobDraft {
83
132
  provider: string;
84
133
  externalId: string;
@@ -2,7 +2,7 @@
2
2
  * `play.seed.set` / `play.timeScale.set`
3
3
  * (B4, §8 B4 "deterministic seed and time-scale control").
4
4
  *
5
- * TIME-SCALE is REAL since Wave 5: the `set-time-scale` relay case
5
+ * TIME-SCALE is REAL: the `set-time-scale` relay case
6
6
  * (`command-listener.ts`) reaches the live session's `GameLoop.timeScale`
7
7
  * (`packages/engine/src/core/game-loop.ts`, a live get/set property clamped
8
8
  * to `[0, 8]`) through `play-mode.ts`'s `getPlayRuntimeAccess()`, and