@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.
@@ -35,6 +35,7 @@
35
35
  import { appendFileSync, mkdirSync, readdirSync, readFileSync, unlinkSync } from 'node:fs';
36
36
  import { join } from 'node:path';
37
37
  import type { TripwireTier } from './build-discipline';
38
+ import type { RecordedTabCensus } from './tab-census';
38
39
 
39
40
  /** `editor-` + an ISO instant with `:`/`.` flattened + `.jsonl` — the same
40
41
  * lexicographic-order-is-chronological-order shape `play-*.jsonl` uses. */
@@ -64,6 +65,37 @@ export type SessionJournalEvent =
64
65
  }
65
66
  /** The session is going down (best-effort: a SIGKILL logs nothing). */
66
67
  | { readonly kind: 'session-shutdown' }
68
+ /**
69
+ * WHERE EDITOR BOOT TIME WENT — written ONCE, when the first page proves it
70
+ * is RUNNING the editor document (its command listener attaches), which is
71
+ * the honest end of the wait `vgai edit` narrates as "Vite cold start".
72
+ *
73
+ * The gap between "Editor ready at …" and that line is the largest stage of
74
+ * opening a project and nothing used to record it: on an imported Unity port
75
+ * it measured 83–139s against a template's 5s, and finding out why took a
76
+ * hand-rolled request trace plus a V8 CPU profile of the dev server. Every
77
+ * field here is a fact the host already had.
78
+ *
79
+ * READ `loopDelayP99Ms` FIRST. It is what separates "the server was busy" —
80
+ * where every slow response in the window is queueing, not work — from "the
81
+ * server was idle and the browser was the wait". See
82
+ * `packages/editor/server/boot-timings.ts`.
83
+ */
84
+ | {
85
+ readonly kind: 'boot';
86
+ /** Process start → Vite's `createServer` resolved. */
87
+ readonly viteMs: number;
88
+ /** …→ the dependency scan settled. */
89
+ readonly depScanMs: number;
90
+ /** …→ HTTP listening (what "Editor ready at" announces). */
91
+ readonly listenMs: number;
92
+ /** Listening → the first page running the editor document. */
93
+ readonly boundMs: number;
94
+ /** Process start → the declared world warmup finished; `null` if none ran. */
95
+ readonly worldWarmupDoneMs: number | null;
96
+ readonly loopDelayP99Ms: number;
97
+ readonly loopDelayMaxMs: number;
98
+ }
67
99
  /** A save-validation verdict — the same facts `runFileValidation` reports. */
68
100
  | {
69
101
  readonly kind: 'validation';
@@ -102,6 +134,26 @@ export type SessionJournalEvent =
102
134
  readonly name: string | null;
103
135
  readonly logFile: string | null;
104
136
  }
137
+ /**
138
+ * A play run's VIDEO evidence opened or landed.
139
+ *
140
+ * Every `vgai play` records — there is no flag — so this row is the answer
141
+ * to "where is the footage of that run", and it has to be in the journal
142
+ * rather than only in a terminal ack: the runs whose footage matters most
143
+ * are the unattended ones, whose ack nobody read.
144
+ *
145
+ * `reason` is what ENDED the recording (`'idle-autostop'`, `'stop'`), null
146
+ * on the `started` row. `rotates` says whether the next play overwrites this
147
+ * file — the unnamed clip does, a `--record <name>` keepsake does not.
148
+ */
149
+ | {
150
+ readonly kind: 'play-recording';
151
+ readonly action: 'started' | 'finalized';
152
+ /** Basename under the project's `.vgai/recordings/`. */
153
+ readonly file: string;
154
+ readonly rotates: boolean;
155
+ readonly reason: string | null;
156
+ }
105
157
  /**
106
158
  * A relayed command ran out of budget, and WHICH PLAY-BOOT STEP the page was
107
159
  * inside when it did.
@@ -243,17 +295,10 @@ export type SessionJournalEvent =
243
295
  /** The abnormal close code that triggered the line. */
244
296
  readonly code: number;
245
297
  readonly censusAgeMs: number | null;
246
- readonly census: {
247
- readonly heapUsedMB: number | null;
248
- readonly heapLimitMB: number | null;
249
- /** Absent on journal lines written before the mount census existed. */
250
- readonly mountEpochs?: number;
251
- readonly canvases: number;
252
- readonly canvasMB: number;
253
- readonly textures?: number;
254
- readonly geometries?: number;
255
- readonly programs?: number;
256
- } | null;
298
+ /** {@link RecordedTabCensus} rather than `TabCensus`: the writer files a
299
+ * full census, but a line READ back may have been written before the
300
+ * mount census existed. */
301
+ readonly census: RecordedTabCensus | null;
257
302
  }
258
303
  /** Two epochs beating under one tabId — "Duplicate Tab" copied sessionStorage. */
259
304
  | { readonly kind: 'tab-duplicated'; readonly tabId8: string }
@@ -481,6 +526,30 @@ export function readRecentJournalEvents(
481
526
  * events already have exactly one wording as data, and a second prose form per
482
527
  * caller is how a reader ends up believing there are two records.
483
528
  */
529
+ /** Split out of {@link formatJournalLine}'s switch for the same reason as
530
+ * {@link playRecordingLine} below. Seconds, because that is the unit the
531
+ * reader's complaint is in ("opening this project takes two minutes"), except
532
+ * the loop-delay pair — a p99 is a millisecond fact and rounding it to
533
+ * seconds would erase the only number that says WHO was waiting. */
534
+ function bootLine(line: Extract<SessionJournalEvent, { kind: 'boot' }>): string {
535
+ const s = (ms: number): string => `${Math.round(ms / 100) / 10}s`;
536
+ return (
537
+ `boot ready ${s(line.listenMs)} (vite ${s(line.viteMs)}, dep-scan ${s(line.depScanMs)}) ` +
538
+ `→ page bound +${s(line.boundMs)}` +
539
+ (line.worldWarmupDoneMs === null ? '' : `, world warmup done ${s(line.worldWarmupDoneMs)}`) +
540
+ ` — loop delay p99 ${line.loopDelayP99Ms}ms / max ${line.loopDelayMaxMs}ms`
541
+ );
542
+ }
543
+
544
+ /** Split out of {@link formatJournalLine}'s switch so this row's three
545
+ * conditionals do not push that already-long function further over the
546
+ * complexity bound. */
547
+ function playRecordingLine(line: Extract<SessionJournalEvent, { kind: 'play-recording' }>): string {
548
+ const why = line.reason === null ? '' : ` (${line.reason})`;
549
+ const life = line.rotates ? ' — replaced by the next play' : ' — kept';
550
+ return `play-recording ${line.action} .vgai/recordings/${line.file}${why}${life}`;
551
+ }
552
+
484
553
  export function formatJournalLine(line: SessionJournalLine): string {
485
554
  const at = line.at.slice(11, 19);
486
555
  switch (line.kind) {
@@ -492,6 +561,8 @@ export function formatJournalLine(line: SessionJournalLine): string {
492
561
  return `journal: ${at} validation ${line.ok ? 'ok' : 'FAILED'} ${line.path}`;
493
562
  case 'play':
494
563
  return `journal: ${at} play ${line.action}${line.name === null ? '' : ` [${line.name}]`}`;
564
+ case 'play-recording':
565
+ return `journal: ${at} ${playRecordingLine(line)}`;
495
566
  case 'play-stall':
496
567
  return (
497
568
  `journal: ${at} play-stall ${line.command} ${line.requestId8} after ` +
@@ -506,6 +577,8 @@ export function formatJournalLine(line: SessionJournalLine): string {
506
577
  return `journal: ${at} project-opened ${line.project}`;
507
578
  case 'session-shutdown':
508
579
  return `journal: ${at} session-shutdown`;
580
+ case 'boot':
581
+ return `journal: ${at} ${bootLine(line)}`;
509
582
  // The TRANSPORT arm. One short line each, and every one of them names the
510
583
  // TAB or the request it is about — a column of ids is how these lines get
511
584
  // correlated, and a paragraph per line is how a reader stops reading.
@@ -574,9 +647,7 @@ function tabDeathProfileBody(line: SessionJournalEvent & { kind: 'tab-death-prof
574
647
  * phrasings of the same numbers is how a reader ends up believing there are
575
648
  * two measurements. Renderer counts appear only when the page could read them
576
649
  * (no mounted render-debug adapter = the words are absent, never a zero). */
577
- export function formatTabCensus(
578
- census: (SessionJournalEvent & { kind: 'tab-death-profile' })['census'],
579
- ): string {
650
+ export function formatTabCensus(census: RecordedTabCensus | null): string {
580
651
  if (census === null) return 'no resource census';
581
652
  const parts = [
582
653
  census.heapUsedMB === null
@@ -3,12 +3,9 @@
3
3
  *
4
4
  * Every `project.*` op is file-native (host: 'node', requires: { project: true })
5
5
  * and runs with NO editor process — it resolves `ctx.projectRoot`, reads/writes
6
- * plain files on disk, and reuses the engine's existing Zod schemas +
7
- * schemas. (WO-8: this also said it reused the engine's `applyDiff` for anything
8
- * scene-shaped — `engine/src/scene/scene-apply.ts` and the twelve
9
- * `project.scene.*` ops that used it are deleted with the `.vscn.json` format.)
6
+ * plain files on disk, and reuses the engine's existing Zod schemas.
10
7
  *
11
- * Two cross-cutting decisions (pinned by the B2 task brief) live here so every
8
+ * Two cross-cutting decisions live here so every
12
9
  * operation module applies them identically:
13
10
  *
14
11
  * - DRY-RUN: every mutation's input carries an optional `dryRun` field
@@ -0,0 +1,83 @@
1
+ /**
2
+ * THE TAB RESOURCE CENSUS — one declaration of the profile a browser tab
3
+ * reports about itself, for every compilation unit that speaks it.
4
+ *
5
+ * WHAT IT IS. What a game tab is holding: JS heap, canvas backing stores,
6
+ * renderer resource counts, and how many project mount generations the
7
+ * document has accumulated. Sampled by the page every five seconds and carried
8
+ * on the heartbeat, so that a browser-level renderer death — which the presence
9
+ * system recovers from perfectly and would otherwise leave unexplained — has a
10
+ * number beside it. MEASUREMENT ONLY: nothing anywhere enforces a budget from
11
+ * these. The page-side sampler and its `why` are
12
+ * `packages/editor/src/tab-census.ts`.
13
+ *
14
+ * WHY IT LIVES HERE. This one shape crosses four compilation units — the page
15
+ * that samples it (`@vgai/editor`'s browser bundle), the editor server that
16
+ * files it (`@vgai/editor`'s node half), the editor-extension surface that
17
+ * describes the status response (`@vgai/editor-sdk`), and the session journal
18
+ * plus the CLI row that print it (`@vgai/sdk`, `@vgai/cli`). Each of them used
19
+ * to spell the seven fields out again. `@vgai/sdk` is the only package all four
20
+ * already depend on: `@vgai/editor-sdk` depends on `@vgai/sdk` and `@vgai/editor`
21
+ * depends on both, so this cannot live in `@vgai/editor-sdk` without a cycle —
22
+ * and it does not belong in `@vgai/engine`, whose subject is a running game,
23
+ * not an editor session's tabs.
24
+ *
25
+ * Deliberately import-free so a browser bundle can take it: the census's other
26
+ * `@vgai/sdk` home, `project/session-journal.ts`, reads `node:fs`.
27
+ *
28
+ * ABSENT IS NOT ZERO. `heapUsedMB`/`heapLimitMB` are null off Chromium
29
+ * (`performance.memory` is non-standard). The renderer counts are ABSENT rather
30
+ * than zero when no game has registered a render-debug adapter, and `programs`
31
+ * is absent until a first render: a zero would read as "no textures", which is
32
+ * a different claim from "nobody measured".
33
+ */
34
+
35
+ /**
36
+ * A census as a CURRENT page produces it and the server files it.
37
+ *
38
+ * Every field here is guaranteed by a producer that always writes it, and
39
+ * `packages/editor/server/tab-heartbeat.ts`'s `parseCensus` is where that is
40
+ * checked: a beat frame missing `canvases`, `canvasMB` or `mountEpochs` is
41
+ * rejected outright rather than filed as a partial profile.
42
+ */
43
+ export interface TabCensus {
44
+ /** `performance.memory.usedJSHeapSize` in MB; null off Chromium. */
45
+ readonly heapUsedMB: number | null;
46
+ /** `performance.memory.jsHeapSizeLimit` in MB; null off Chromium. */
47
+ readonly heapLimitMB: number | null;
48
+ /** Distinct project mount generations fetched by this document. */
49
+ readonly mountEpochs: number;
50
+ /** How many `<canvas>` elements the document holds. */
51
+ readonly canvases: number;
52
+ /** Their total pixel-buffer footprint: Σ width×height×4 bytes, in MB. */
53
+ readonly canvasMB: number;
54
+ /** `renderer.info.memory.textures` — absent with no mounted adapter. */
55
+ readonly textures?: number;
56
+ /** `renderer.info.memory.geometries` — absent with no mounted adapter. */
57
+ readonly geometries?: number;
58
+ /** Compiled programs — absent with no adapter, or before the first render. */
59
+ readonly programs?: number;
60
+ }
61
+
62
+ /**
63
+ * A census read back out of a RECORD, where the writer may predate a field.
64
+ *
65
+ * Exactly one field varies, and this is the only place that is said: a session
66
+ * journal line written before the mount census existed has no `mountEpochs`,
67
+ * and neither does a status response from an older editor server. The
68
+ * difference is a property of the READER's input, so it is named on the
69
+ * reader's type rather than by softening {@link TabCensus} — which is what the
70
+ * four hand-copied declarations did, and how the producers' own guarantee got
71
+ * lost on the way to the two SDKs.
72
+ *
73
+ * There is no runtime narrowing to point at because neither reader has a parse
74
+ * step to put one in: the journal is `JSON.parse` per line by recorded design
75
+ * (`session-journal.ts`: "nothing reads a journal back through a validator"),
76
+ * and the CLI reads the status body as JSON. So the absence is carried in the
77
+ * type and handled where it is printed — `formatTabCensus` omits the words
78
+ * rather than printing a fabricated count. Every {@link TabCensus} is a valid
79
+ * value of this type; the reverse is not.
80
+ */
81
+ export type RecordedTabCensus = Omit<TabCensus, 'mountEpochs'> & {
82
+ readonly mountEpochs?: number;
83
+ };
@@ -49,6 +49,31 @@ export interface ProjectToolLoadError {
49
49
  message: string;
50
50
  }
51
51
 
52
+ /**
53
+ * What a host says when it cannot load the project's TypeScript at all.
54
+ *
55
+ * ONE owner for the sentence, because two realms reach this state through
56
+ * different doors and both must say the same true thing rather than fall back
57
+ * to a fabricated empty catalog:
58
+ *
59
+ * - the NODE side (`packages/editor/server/project-tools.ts`) when a host
60
+ * builds the catalog with no `loadModule`; and
61
+ * - the BROWSER side (`packages/editor/src/project-tools.ts`) on the hosted
62
+ * `--mode web` build, which has no editor server on its origin at all. A
63
+ * static host answers `/__editor/project-tools` with the SPA fallback —
64
+ * `200` and HTML — so an unguarded fetch that merely checks `response.ok`
65
+ * reads a successful page load as a successful catalog, then dies in
66
+ * `res.json()`. The panel's empty state ("No project tools registered")
67
+ * is a FACT about the project; a host that cannot ask has not learned it.
68
+ */
69
+ export const NO_PROJECT_MODULE_HOST_MESSAGE =
70
+ 'This editor host cannot load project TypeScript modules. Use the Vite-backed dev or packaged editor.';
71
+
72
+ /** The catalog row that states {@link NO_PROJECT_MODULE_HOST_MESSAGE}. */
73
+ export function noProjectModuleHostError(): ProjectToolLoadError {
74
+ return { sourcePath: 'package.json#vgai.tools', message: NO_PROJECT_MODULE_HOST_MESSAGE };
75
+ }
76
+
52
77
  /**
53
78
  * Callables and contribution modules are SIBLINGS, not parent and child.
54
79
  *