@vgai/live 0.5.22 → 0.5.24
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/dist/.tsbuildinfo +1 -1
- package/dist/{editor.d.ts → vgai-live/src/editor.d.ts} +5 -8
- package/dist/{editor.js → vgai-live/src/editor.js} +5 -15
- package/dist/{game-client → vgai-live/src/game-client}/bridge-transport.d.ts +10 -1
- package/dist/{game-client → vgai-live/src/game-client}/client.d.ts +36 -6
- package/dist/{game-client → vgai-live/src/game-client}/client.js +80 -9
- package/dist/{game-client → vgai-live/src/game-client}/relay-transport.d.ts +15 -2
- package/dist/{game-client → vgai-live/src/game-client}/relay-transport.js +38 -3
- package/dist/{game-client → vgai-live/src/game-client}/types.d.ts +5 -5
- package/dist/{game-client → vgai-live/src/game-client}/wait-for.js +2 -2
- package/dist/{game.d.ts → vgai-live/src/game.d.ts} +1 -1
- package/dist/{index.d.ts → vgai-live/src/index.d.ts} +2 -2
- package/dist/{index.js → vgai-live/src/index.js} +2 -2
- package/dist/{recording.js → vgai-live/src/recording.js} +3 -10
- package/dist/{session.d.ts → vgai-live/src/session.d.ts} +0 -7
- package/dist/{session.js → vgai-live/src/session.js} +56 -19
- package/dist/{tools.d.ts → vgai-live/src/tools.d.ts} +1 -1
- package/dist/{tools.js → vgai-live/src/tools.js} +1 -1
- package/dist/vgai-sdk/src/editor/session-registry-format.d.ts +81 -0
- package/dist/vgai-sdk/src/editor/session-registry-format.js +158 -0
- package/package.json +3 -3
- package/src/editor.ts +5 -16
- package/src/game-client/bridge-transport.ts +14 -1
- package/src/game-client/client.ts +98 -9
- package/src/game-client/relay-transport.ts +46 -3
- package/src/game-client/types.ts +5 -5
- package/src/game-client/wait-for.ts +2 -2
- package/src/game.ts +1 -1
- package/src/index.ts +2 -2
- package/src/recording.ts +3 -10
- package/src/session.ts +67 -24
- package/src/tools.ts +1 -1
- /package/dist/{editor-document.d.ts → vgai-live/src/editor-document.d.ts} +0 -0
- /package/dist/{editor-document.js → vgai-live/src/editor-document.js} +0 -0
- /package/dist/{game-client → vgai-live/src/game-client}/bridge-heartbeat.d.ts +0 -0
- /package/dist/{game-client → vgai-live/src/game-client}/bridge-heartbeat.js +0 -0
- /package/dist/{game-client → vgai-live/src/game-client}/bridge-transport.js +0 -0
- /package/dist/{game-client → vgai-live/src/game-client}/capture-notes.d.ts +0 -0
- /package/dist/{game-client → vgai-live/src/game-client}/capture-notes.js +0 -0
- /package/dist/{game-client → vgai-live/src/game-client}/errors.d.ts +0 -0
- /package/dist/{game-client → vgai-live/src/game-client}/errors.js +0 -0
- /package/dist/{game-client → vgai-live/src/game-client}/events-matcher.d.ts +0 -0
- /package/dist/{game-client → vgai-live/src/game-client}/events-matcher.js +0 -0
- /package/dist/{game-client → vgai-live/src/game-client}/failure-block.d.ts +0 -0
- /package/dist/{game-client → vgai-live/src/game-client}/failure-block.js +0 -0
- /package/dist/{game-client → vgai-live/src/game-client}/fast-forward.d.ts +0 -0
- /package/dist/{game-client → vgai-live/src/game-client}/fast-forward.js +0 -0
- /package/dist/{game-client → vgai-live/src/game-client}/hidden-recovery.d.ts +0 -0
- /package/dist/{game-client → vgai-live/src/game-client}/hidden-recovery.js +0 -0
- /package/dist/{game-client → vgai-live/src/game-client}/index.d.ts +0 -0
- /package/dist/{game-client → vgai-live/src/game-client}/index.js +0 -0
- /package/dist/{game-client → vgai-live/src/game-client}/perf-sampling.d.ts +0 -0
- /package/dist/{game-client → vgai-live/src/game-client}/perf-sampling.js +0 -0
- /package/dist/{game-client → vgai-live/src/game-client}/screenshot-target.d.ts +0 -0
- /package/dist/{game-client → vgai-live/src/game-client}/screenshot-target.js +0 -0
- /package/dist/{game-client → vgai-live/src/game-client}/state-cap.d.ts +0 -0
- /package/dist/{game-client → vgai-live/src/game-client}/state-cap.js +0 -0
- /package/dist/{game-client → vgai-live/src/game-client}/types.js +0 -0
- /package/dist/{game-client → vgai-live/src/game-client}/wait-for.d.ts +0 -0
- /package/dist/{game.js → vgai-live/src/game.js} +0 -0
- /package/dist/{lazy-proxy.d.ts → vgai-live/src/lazy-proxy.d.ts} +0 -0
- /package/dist/{lazy-proxy.js → vgai-live/src/lazy-proxy.js} +0 -0
- /package/dist/{recording.d.ts → vgai-live/src/recording.d.ts} +0 -0
- /package/dist/{singleton.d.ts → vgai-live/src/singleton.d.ts} +0 -0
- /package/dist/{singleton.js → vgai-live/src/singleton.js} +0 -0
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
48
48
|
import { dirname, join, resolve } from 'node:path';
|
|
49
49
|
import { EDITOR_SESSION_DISCOVERY_TIMEOUT_MS, HttpEditorTransport, withTimeout, } from '@vgai/sdk';
|
|
50
|
+
import { servedProjectAnswer } from '../../vgai-sdk/src/editor/session-registry-format';
|
|
50
51
|
/**
|
|
51
52
|
* Lifted verbatim from `packages/vgai-cli/src/project-root.ts` (see this
|
|
52
53
|
* module's doc comment above for why it's duplicated rather than imported).
|
|
@@ -106,13 +107,6 @@ function readProjectSession(projectRoot) {
|
|
|
106
107
|
* but none open this project)" — sending the operator to start an editor that
|
|
107
108
|
* was already running, with the real defect (their own manifest) never named.
|
|
108
109
|
*/
|
|
109
|
-
function servedProject(body) {
|
|
110
|
-
const b = body;
|
|
111
|
-
return {
|
|
112
|
-
path: b.project?.path ?? b.serving?.path ?? null,
|
|
113
|
-
manifestError: b.project ? null : (b.serving?.error ?? null),
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
110
|
/**
|
|
117
111
|
* What the server on `port` says it serves — `undefined` when it did not
|
|
118
112
|
* answer at all. One probe: the manifest failure arrives with the path, so
|
|
@@ -125,7 +119,7 @@ async function probeServedProject(port) {
|
|
|
125
119
|
});
|
|
126
120
|
if (!response.ok)
|
|
127
121
|
return undefined;
|
|
128
|
-
return
|
|
122
|
+
return servedProjectAnswer(await response.json());
|
|
129
123
|
}
|
|
130
124
|
catch {
|
|
131
125
|
return undefined;
|
|
@@ -154,6 +148,50 @@ function manifestRefusal(projectRoot, manifestError) {
|
|
|
154
148
|
* silently attaches to an unrelated project's session — see the module doc
|
|
155
149
|
* above) when no vgai.project.json is found, or no live session covers it.
|
|
156
150
|
*/
|
|
151
|
+
/**
|
|
152
|
+
* THE REFUSAL WHEN NOTHING MATCHED — and it says WHICH nothing.
|
|
153
|
+
*
|
|
154
|
+
* "No live editor session found … run `vgai edit`" used to be the answer to
|
|
155
|
+
* four different states, only one of which it described. The other three sent
|
|
156
|
+
* the operator to start an editor that was already running:
|
|
157
|
+
*
|
|
158
|
+
* - discovery FAILED (a probe timeout under load) — nothing was learned, so
|
|
159
|
+
* "no session is running" is not a fact anyone established;
|
|
160
|
+
* - the registry was read and is genuinely empty — the one case the old text
|
|
161
|
+
* was right about;
|
|
162
|
+
* - sessions exist, but every one resolves to a different canonical path. In
|
|
163
|
+
* a repo worked through git worktrees this is the ORDINARY miss: two
|
|
164
|
+
* checkouts of the same project differ only in a path prefix, and a
|
|
165
|
+
* symlinked worktree's `realpath` diverges from the path the caller typed.
|
|
166
|
+
* Naming both sides is what makes it a two-second diagnosis instead of a
|
|
167
|
+
* hunt.
|
|
168
|
+
*
|
|
169
|
+
* Each branch prescribes only what its own state supports.
|
|
170
|
+
*/
|
|
171
|
+
function noMatchingSessionRefusal(projectRoot, canon, sessions, discoveryFailure) {
|
|
172
|
+
if (discoveryFailure !== null) {
|
|
173
|
+
return new Error(`@vgai/live: could not READ the editor session registry while looking for ${projectRoot} — ` +
|
|
174
|
+
`${discoveryFailure}. This is not the answer "no editor is running": the question went ` +
|
|
175
|
+
'unanswered, so nothing is known about what is live. Retry (a probe can time out while ' +
|
|
176
|
+
'the box is loaded); if it keeps failing, `vgai sessions` asks the same question directly.');
|
|
177
|
+
}
|
|
178
|
+
if (sessions.length === 0) {
|
|
179
|
+
return new Error(`@vgai/live: the editor session registry is readable and lists NO live sessions, so none ` +
|
|
180
|
+
`covers ${projectRoot}. @vgai/live only attaches to an already-running session — it ` +
|
|
181
|
+
'never starts one — so run `vgai edit` in that project first, then retry.');
|
|
182
|
+
}
|
|
183
|
+
const listed = sessions
|
|
184
|
+
.map((s) => ` port ${s.port} → ${s.project === null ? '(no project)' : s.project}`)
|
|
185
|
+
.join('\n');
|
|
186
|
+
return new Error(`@vgai/live: ${sessions.length} live editor session(s) are running, but none of them opens ` +
|
|
187
|
+
`${projectRoot}. @vgai/live never silently attaches to a different project.\n` +
|
|
188
|
+
` looking for (resolved): ${canon}\n` +
|
|
189
|
+
` live sessions:\n${listed}\n` +
|
|
190
|
+
' If one of those is meant to be this project, the two paths differ after resolution — ' +
|
|
191
|
+
'the usual cause is a git worktree or a symlink, where the session was opened through a ' +
|
|
192
|
+
'different path to the same files. Run `vgai edit` from THIS path, or use the path the ' +
|
|
193
|
+
'session lists.');
|
|
194
|
+
}
|
|
157
195
|
export async function resolveSession(projectDir = process.cwd(), deps = {}) {
|
|
158
196
|
const findRoot = deps.findProjectRootFrom ?? findProjectRootFrom;
|
|
159
197
|
const transport = deps.transport ?? new HttpEditorTransport();
|
|
@@ -187,24 +225,23 @@ export async function resolveSession(projectDir = process.cwd(), deps = {}) {
|
|
|
187
225
|
}
|
|
188
226
|
}
|
|
189
227
|
let sessions;
|
|
228
|
+
// A FAILED discovery is not an empty one. Collapsing the two into `[]` is
|
|
229
|
+
// what made this door answer "no live editor session found" — and prescribe
|
|
230
|
+
// `vgai edit` — for a probe that merely timed out under load, sending the
|
|
231
|
+
// operator to start an editor that was already running while the real defect
|
|
232
|
+
// went unnamed. The same collapse the manifest refusal above was added for.
|
|
233
|
+
let discoveryFailure = null;
|
|
190
234
|
try {
|
|
191
235
|
sessions = await withTimeout(transport.listSessions(EDITOR_SESSION_DISCOVERY_TIMEOUT_MS), EDITOR_SESSION_DISCOVERY_TIMEOUT_MS, 'editor session discovery');
|
|
192
236
|
}
|
|
193
|
-
catch {
|
|
237
|
+
catch (err) {
|
|
194
238
|
sessions = [];
|
|
239
|
+
discoveryFailure = err instanceof Error ? err.message : String(err);
|
|
195
240
|
}
|
|
196
241
|
const canon = canonicalPath(projectRoot);
|
|
197
242
|
const match = sessions.find((s) => s.project !== null && canonicalPath(s.project) === canon);
|
|
198
|
-
if (!match)
|
|
199
|
-
|
|
200
|
-
throw new Error(`@vgai/live: no live editor session found for ${projectRoot}. @vgai/live only attaches to ` +
|
|
201
|
-
'an already-running session — it never starts one — so run `vgai edit` in that project ' +
|
|
202
|
-
'first, then retry.' +
|
|
203
|
-
(otherCount > 0
|
|
204
|
-
? ` (${otherCount} other live session(s) found, but none open this project — @vgai/live ` +
|
|
205
|
-
'never silently attaches to a different project.)'
|
|
206
|
-
: ''));
|
|
207
|
-
}
|
|
243
|
+
if (!match)
|
|
244
|
+
throw noMatchingSessionRefusal(projectRoot, canon, sessions, discoveryFailure);
|
|
208
245
|
if (match.manifestError != null)
|
|
209
246
|
throw manifestRefusal(projectRoot, match.manifestError);
|
|
210
247
|
return { port: match.port, projectRoot };
|
|
@@ -12,7 +12,7 @@ export declare class LiveTools {
|
|
|
12
12
|
*
|
|
13
13
|
* `instance` names WHICH mounted instance the tool should drive when several
|
|
14
14
|
* are live (multiplayer authoring) — it reaches the tool as `ctx.instance`,
|
|
15
|
-
* and a tool that drives the game
|
|
15
|
+
* and a tool that drives the game binds
|
|
16
16
|
* `game.instance(ctx.instance)` from it. Omitted is the single-instance case;
|
|
17
17
|
* the tool then targets the sole live instance, exactly as before.
|
|
18
18
|
*/
|
|
@@ -19,7 +19,7 @@ export class LiveTools {
|
|
|
19
19
|
*
|
|
20
20
|
* `instance` names WHICH mounted instance the tool should drive when several
|
|
21
21
|
* are live (multiplayer authoring) — it reaches the tool as `ctx.instance`,
|
|
22
|
-
* and a tool that drives the game
|
|
22
|
+
* and a tool that drives the game binds
|
|
23
23
|
* `game.instance(ctx.instance)` from it. Omitted is the single-instance case;
|
|
24
24
|
* the tool then targets the sole live instance, exactly as before.
|
|
25
25
|
*/
|
|
@@ -0,0 +1,81 @@
|
|
|
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
|
+
/** One registry entry, exactly as the server's write half records it. */
|
|
17
|
+
export interface EditorSessionEntry {
|
|
18
|
+
/** Canonical project root currently open, or null when none. */
|
|
19
|
+
project: string | null;
|
|
20
|
+
port: number;
|
|
21
|
+
/** PID of the dev-server process. */
|
|
22
|
+
pid: number;
|
|
23
|
+
/** ISO timestamp of server start. */
|
|
24
|
+
startedAt: string;
|
|
25
|
+
sessionId: string | null;
|
|
26
|
+
/** Private loopback control credential — never returned by an editor HTTP
|
|
27
|
+
* route; exists only in this user-private registry. */
|
|
28
|
+
controlSecret?: string | null;
|
|
29
|
+
repositoryId: string | null;
|
|
30
|
+
worktreeId: string | null;
|
|
31
|
+
worktreeRoot: string | null;
|
|
32
|
+
projectRelativePath: string | null;
|
|
33
|
+
branch: string | null;
|
|
34
|
+
headCommit: string | null;
|
|
35
|
+
baseCommit: string | null;
|
|
36
|
+
}
|
|
37
|
+
export declare const EDITOR_SESSIONS_REGISTRY_FILE: string;
|
|
38
|
+
export declare function isEditorSessionEntry(v: unknown): v is EditorSessionEntry;
|
|
39
|
+
export declare function normalizeEditorSessionEntry(session: EditorSessionEntry): EditorSessionEntry;
|
|
40
|
+
export declare function pidAlive(pid: number): boolean;
|
|
41
|
+
/** The registry's live entries — shape-validated and PID-liveness-filtered. */
|
|
42
|
+
export declare function readLiveRegisteredSessions(): EditorSessionEntry[];
|
|
43
|
+
/**
|
|
44
|
+
* WHICH PROJECT a `/__editor/project` body says its server is serving.
|
|
45
|
+
*
|
|
46
|
+
* `serving` is that server's own statement of "I AM serving this project, I
|
|
47
|
+
* just cannot describe it" (its manifest is unparseable or fails strict
|
|
48
|
+
* validation) — added to the route precisely because a bare
|
|
49
|
+
* `{ project: null }` is indistinguishable from "no project open". Reading
|
|
50
|
+
* only `project.path` collapses the two, and the cost is that every
|
|
51
|
+
* project-matched command loses a live session the moment a save breaks its
|
|
52
|
+
* manifest, reporting it as belonging to no project rather than as this
|
|
53
|
+
* project's degraded session.
|
|
54
|
+
*/
|
|
55
|
+
export declare function servedProjectAnswer(body: unknown): {
|
|
56
|
+
path: string | null;
|
|
57
|
+
manifestError: string | null;
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Deterministic session selection — the ONE precedence order every surface
|
|
61
|
+
* follows: explicit `ctx.editorUrl` probe > port match against the listing >
|
|
62
|
+
* `ctx.projectRoot` match > lowest live port > not-running. Generic over the
|
|
63
|
+
* session shape so the editor and play transports (whose probe results
|
|
64
|
+
* differ) share the algorithm rather than a copy of it.
|
|
65
|
+
*/
|
|
66
|
+
export declare function resolveRegisteredSession<T extends {
|
|
67
|
+
port: number;
|
|
68
|
+
project: string | null;
|
|
69
|
+
url?: string;
|
|
70
|
+
}>(ctx: {
|
|
71
|
+
editorUrl?: string | undefined;
|
|
72
|
+
projectRoot?: string | undefined;
|
|
73
|
+
}, transport: {
|
|
74
|
+
listSessions(timeoutMs: number): Promise<T[]>;
|
|
75
|
+
probeSessionUrl?: ((url: string, timeoutMs: number) => Promise<T | null | undefined>) | undefined;
|
|
76
|
+
}, options: {
|
|
77
|
+
probeTimeoutMs: number;
|
|
78
|
+
discoveryTimeoutMs: number;
|
|
79
|
+
withTimeout: <V>(work: Promise<V>, timeoutMs: number, what: string) => Promise<V>;
|
|
80
|
+
notRunning: () => never;
|
|
81
|
+
}): Promise<T>;
|
|
@@ -0,0 +1,158 @@
|
|
|
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
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
17
|
+
import { homedir } from 'node:os';
|
|
18
|
+
import { join, resolve } from 'node:path';
|
|
19
|
+
export const EDITOR_SESSIONS_REGISTRY_FILE = join(homedir(), '.vgai', 'editor-sessions.json');
|
|
20
|
+
export function isEditorSessionEntry(v) {
|
|
21
|
+
if (typeof v !== 'object' || v === null)
|
|
22
|
+
return false;
|
|
23
|
+
const s = v;
|
|
24
|
+
const optionalIdentity = (value) => value === undefined || value === null || typeof value === 'string';
|
|
25
|
+
return ((typeof s['project'] === 'string' || s['project'] === null) &&
|
|
26
|
+
typeof s['port'] === 'number' &&
|
|
27
|
+
typeof s['pid'] === 'number' &&
|
|
28
|
+
typeof s['startedAt'] === 'string' &&
|
|
29
|
+
optionalIdentity(s['sessionId']) &&
|
|
30
|
+
optionalIdentity(s['controlSecret']) &&
|
|
31
|
+
optionalIdentity(s['repositoryId']) &&
|
|
32
|
+
optionalIdentity(s['worktreeId']) &&
|
|
33
|
+
optionalIdentity(s['worktreeRoot']) &&
|
|
34
|
+
optionalIdentity(s['projectRelativePath']) &&
|
|
35
|
+
optionalIdentity(s['branch']) &&
|
|
36
|
+
optionalIdentity(s['headCommit']) &&
|
|
37
|
+
optionalIdentity(s['baseCommit']));
|
|
38
|
+
}
|
|
39
|
+
export function normalizeEditorSessionEntry(session) {
|
|
40
|
+
return {
|
|
41
|
+
...session,
|
|
42
|
+
sessionId: session.sessionId ?? null,
|
|
43
|
+
controlSecret: session.controlSecret ?? null,
|
|
44
|
+
repositoryId: session.repositoryId ?? null,
|
|
45
|
+
worktreeId: session.worktreeId ?? null,
|
|
46
|
+
worktreeRoot: session.worktreeRoot ?? null,
|
|
47
|
+
projectRelativePath: session.projectRelativePath ?? null,
|
|
48
|
+
branch: session.branch ?? null,
|
|
49
|
+
headCommit: session.headCommit ?? null,
|
|
50
|
+
baseCommit: session.baseCommit ?? null,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export function pidAlive(pid) {
|
|
54
|
+
try {
|
|
55
|
+
process.kill(pid, 0);
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** The registry's live entries — shape-validated and PID-liveness-filtered. */
|
|
63
|
+
export function readLiveRegisteredSessions() {
|
|
64
|
+
try {
|
|
65
|
+
const raw = JSON.parse(readFileSync(EDITOR_SESSIONS_REGISTRY_FILE, 'utf8'));
|
|
66
|
+
return Array.isArray(raw)
|
|
67
|
+
? raw
|
|
68
|
+
.filter(isEditorSessionEntry)
|
|
69
|
+
.map(normalizeEditorSessionEntry)
|
|
70
|
+
.filter((s) => pidAlive(s.pid))
|
|
71
|
+
: [];
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* WHICH PROJECT a `/__editor/project` body says its server is serving.
|
|
79
|
+
*
|
|
80
|
+
* `serving` is that server's own statement of "I AM serving this project, I
|
|
81
|
+
* just cannot describe it" (its manifest is unparseable or fails strict
|
|
82
|
+
* validation) — added to the route precisely because a bare
|
|
83
|
+
* `{ project: null }` is indistinguishable from "no project open". Reading
|
|
84
|
+
* only `project.path` collapses the two, and the cost is that every
|
|
85
|
+
* project-matched command loses a live session the moment a save breaks its
|
|
86
|
+
* manifest, reporting it as belonging to no project rather than as this
|
|
87
|
+
* project's degraded session.
|
|
88
|
+
*/
|
|
89
|
+
export function servedProjectAnswer(body) {
|
|
90
|
+
const b = body;
|
|
91
|
+
return {
|
|
92
|
+
path: b.project?.path ?? b.serving?.path ?? null,
|
|
93
|
+
manifestError: b.project ? null : (b.serving?.error ?? null),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/** Canonical (realpathed when possible) absolute form of a project path. */
|
|
97
|
+
function canonicalizeProjectPath(p) {
|
|
98
|
+
const absolute = resolve(p);
|
|
99
|
+
try {
|
|
100
|
+
return realpathSync(absolute);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return absolute;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** Extract the port from an `http(s)://host:port` URL, or undefined if unparseable. */
|
|
107
|
+
function portOfUrl(url) {
|
|
108
|
+
try {
|
|
109
|
+
const port = new URL(url).port;
|
|
110
|
+
return port ? Number(port) : undefined;
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Deterministic session selection — the ONE precedence order every surface
|
|
118
|
+
* follows: explicit `ctx.editorUrl` probe > port match against the listing >
|
|
119
|
+
* `ctx.projectRoot` match > lowest live port > not-running. Generic over the
|
|
120
|
+
* session shape so the editor and play transports (whose probe results
|
|
121
|
+
* differ) share the algorithm rather than a copy of it.
|
|
122
|
+
*/
|
|
123
|
+
export async function resolveRegisteredSession(ctx, transport, options) {
|
|
124
|
+
const editorUrl = ctx.editorUrl;
|
|
125
|
+
if (editorUrl !== undefined && transport.probeSessionUrl) {
|
|
126
|
+
try {
|
|
127
|
+
const session = await options.withTimeout(transport.probeSessionUrl(editorUrl, options.probeTimeoutMs), options.probeTimeoutMs, 'explicit editor URL probe');
|
|
128
|
+
if (session)
|
|
129
|
+
return session;
|
|
130
|
+
}
|
|
131
|
+
catch { }
|
|
132
|
+
return options.notRunning();
|
|
133
|
+
}
|
|
134
|
+
let sessions;
|
|
135
|
+
try {
|
|
136
|
+
sessions = await options.withTimeout(transport.listSessions(options.discoveryTimeoutMs), options.discoveryTimeoutMs, 'editor session discovery');
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return options.notRunning();
|
|
140
|
+
}
|
|
141
|
+
if (sessions.length === 0)
|
|
142
|
+
return options.notRunning();
|
|
143
|
+
if (editorUrl !== undefined) {
|
|
144
|
+
const port = portOfUrl(editorUrl);
|
|
145
|
+
const match = sessions.find((s) => s.port === port);
|
|
146
|
+
if (!match)
|
|
147
|
+
return options.notRunning();
|
|
148
|
+
return { ...match, url: editorUrl.replace(/\/+$/, '') };
|
|
149
|
+
}
|
|
150
|
+
if (ctx.projectRoot !== undefined) {
|
|
151
|
+
const canon = canonicalizeProjectPath(ctx.projectRoot);
|
|
152
|
+
const match = sessions.find((s) => s.project !== null && canonicalizeProjectPath(s.project) === canon);
|
|
153
|
+
if (!match)
|
|
154
|
+
return options.notRunning();
|
|
155
|
+
return match;
|
|
156
|
+
}
|
|
157
|
+
return [...sessions].sort((a, b) => a.port - b.port)[0];
|
|
158
|
+
}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@vgai/live",
|
|
3
3
|
"author": "Volter AI, Inc.",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
|
-
"version": "0.5.
|
|
5
|
+
"version": "0.5.24",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
"prepack": "npm run build"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@vgai/editor-sdk": "0.5.
|
|
36
|
-
"@vgai/sdk": "0.5.
|
|
35
|
+
"@vgai/editor-sdk": "0.5.24",
|
|
36
|
+
"@vgai/sdk": "0.5.24"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
39
|
"@playwright/test": ">=1.58.2 <2"
|
package/src/editor.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `LiveEditor` — the editor-control half of `@vgai/live`'s `{ editor, game,
|
|
3
|
-
* page }
|
|
3
|
+
* page }`. Methods are named after the
|
|
4
4
|
* ACTION, not the CLI flag spelling (e.g. `select('all')` rather than a
|
|
5
5
|
* separate `selectAll`, `showPanel('viewport-play')` rather than
|
|
6
6
|
* `vgai show viewport play`'s two-token shape) — see each method's own doc
|
|
7
7
|
* comment for the exact CLI verb / `EditorClient` call it mirrors.
|
|
8
8
|
*
|
|
9
9
|
* Every method reuses `@vgai/editor-sdk`'s `EditorClient` — this module never
|
|
10
|
-
* hand-rolls a `fetch` to `/__editor/command` itself
|
|
11
|
-
*
|
|
10
|
+
* hand-rolls a `fetch` to `/__editor/command` itself, and has no FILE-mode
|
|
11
|
+
* escape hatch: every method goes over the wire.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import type {
|
|
@@ -103,11 +103,8 @@ export class LiveEditor {
|
|
|
103
103
|
|
|
104
104
|
/**
|
|
105
105
|
* The active authoring adapter's persistence destination — where a save would
|
|
106
|
-
* land (`status().savePath`).
|
|
107
|
-
*
|
|
108
|
-
* WO-8: this also took a `path` and OPENED that scene through
|
|
109
|
-
* `EditorClient.openScene` -> the `open-scene` relay verb. That verb now rejects
|
|
110
|
-
* (the `.vscn.json` format is deleted), so only the read remains.
|
|
106
|
+
* land (`status().savePath`). A read only: a three root has no scene document
|
|
107
|
+
* to open, and its root is activated instead.
|
|
111
108
|
*/
|
|
112
109
|
async scene(): Promise<string | null> {
|
|
113
110
|
const state = await this.#client.getState();
|
|
@@ -133,14 +130,6 @@ export class LiveEditor {
|
|
|
133
130
|
return this.#client.captureActiveDocument(size);
|
|
134
131
|
}
|
|
135
132
|
|
|
136
|
-
/*
|
|
137
|
-
* `applyDiff` lived here. It dispatched `project.scene.apply`, one of the
|
|
138
|
-
* twelve `.vscn` document tools deleted with the format, so there is nothing
|
|
139
|
-
* left for it to call. `ApplyDiffInput` / `ApplyDiffResult` went with it, and
|
|
140
|
-
* so did `projectRoot` — this was the ONE method that used it (every other
|
|
141
|
-
* method goes over the wire).
|
|
142
|
-
*/
|
|
143
|
-
|
|
144
133
|
async play(opts?: { seed?: number }): Promise<void> {
|
|
145
134
|
await this.#client.play(opts);
|
|
146
135
|
}
|
|
@@ -51,7 +51,7 @@ export interface BridgeTransport {
|
|
|
51
51
|
* blank/corrupt file. */
|
|
52
52
|
screenshot(path: string): Promise<CaptureNotes>;
|
|
53
53
|
/**
|
|
54
|
-
*
|
|
54
|
+
* One dialect, full capability — runs a UI-automation step
|
|
55
55
|
* written as a literal `async (page) => {...}` (`GameClient.page()`,
|
|
56
56
|
* `client.ts`). `src` is `step.toString()`; `step` is the ORIGINAL
|
|
57
57
|
* function, wrapped so its own parameter type is erased to `unknown` (only
|
|
@@ -75,6 +75,19 @@ export interface BridgeTransport {
|
|
|
75
75
|
* serialized — inline every value the step needs.
|
|
76
76
|
*/
|
|
77
77
|
runPageScript(src: string, step: (page: unknown) => unknown): Promise<BridgeCallOutcome>;
|
|
78
|
+
/**
|
|
79
|
+
* THE MODULE LANE — run a step INSIDE the editor page against
|
|
80
|
+
* `{ page, modules, instanceId }`, where `modules(path)` imports the
|
|
81
|
+
* RUNNING mount's own instance of a project module (never a phantom
|
|
82
|
+
* second copy). Same serialization contract as `runPageScript`: the
|
|
83
|
+
* step's source travels as text, closures do not survive, and the return
|
|
84
|
+
* value must be plain data. `instance` scopes multi-instance sessions.
|
|
85
|
+
*/
|
|
86
|
+
runGameScript(
|
|
87
|
+
src: string,
|
|
88
|
+
step: (scope: unknown) => unknown,
|
|
89
|
+
instance?: string,
|
|
90
|
+
): Promise<BridgeCallOutcome>;
|
|
78
91
|
/**
|
|
79
92
|
* P20 — reload the document showing the game, resolving only once the page
|
|
80
93
|
* is BACK and taking commands again.
|
|
@@ -141,7 +141,7 @@ export class PageTransport implements BridgeTransport {
|
|
|
141
141
|
return {};
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
/**
|
|
144
|
+
/** The ONE transport that runs a `game.page()` step against a REAL
|
|
145
145
|
* Playwright `Page` — no serialization, so `step`'s own closures work
|
|
146
146
|
* here (see `bridge-transport.ts`'s `runPageScript` doc comment for the
|
|
147
147
|
* full honesty-boundary contract; `src` is unused on this leg, kept only
|
|
@@ -158,6 +158,41 @@ export class PageTransport implements BridgeTransport {
|
|
|
158
158
|
}
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
/** THE MODULE LANE under a real Playwright host: the step's source is
|
|
162
|
+
* evaluated INSIDE the editor page via the same in-page handler the relay
|
|
163
|
+
* op uses (`window.__vgaiGameEval`), because `modules()` only means
|
|
164
|
+
* anything in the page's own module space — a Node-side call could never
|
|
165
|
+
* hand back the running mount's instances. Same serialization contract
|
|
166
|
+
* as the relay leg. */
|
|
167
|
+
async runGameScript(
|
|
168
|
+
src: string,
|
|
169
|
+
_step: (scope: unknown) => unknown,
|
|
170
|
+
instance?: string,
|
|
171
|
+
): Promise<BridgeCallOutcome> {
|
|
172
|
+
try {
|
|
173
|
+
const result = await this.page.evaluate(
|
|
174
|
+
async (args: { src: string; instance?: string }) => {
|
|
175
|
+
const hook = (window as unknown as Record<string, unknown>)['__vgaiGameEval'] as
|
|
176
|
+
| ((src: string, instance?: string) => Promise<unknown>)
|
|
177
|
+
| undefined;
|
|
178
|
+
if (typeof hook !== 'function') {
|
|
179
|
+
throw new Error(
|
|
180
|
+
'game-eval: this page has no __vgaiGameEval hook — is the editor page loaded?',
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
return hook(args.src, args.instance);
|
|
184
|
+
},
|
|
185
|
+
{ src, ...(instance === undefined ? {} : { instance }) },
|
|
186
|
+
);
|
|
187
|
+
return { ok: true, result };
|
|
188
|
+
} catch (err) {
|
|
189
|
+
return {
|
|
190
|
+
ok: false,
|
|
191
|
+
error: { code: undefined, message: err instanceof Error ? err.message : String(err) },
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
161
196
|
/** Playwright's own reload already waits for the new document's `load`
|
|
162
197
|
* event, which is exactly the completion signal this method's contract
|
|
163
198
|
* asks for — nothing to reconstruct on this leg. */
|
|
@@ -528,6 +563,9 @@ export class GameClient {
|
|
|
528
563
|
/** Per-test tick-rate samples, fed by every `snapshot()` read (a poll the
|
|
529
564
|
* client was making anyway — zero extra page.evaluate round trips). */
|
|
530
565
|
readonly #tps = new TpsAccumulator();
|
|
566
|
+
/** True after the settled run-ticks door proved absent on this page (an older exported
|
|
567
|
+
* game's engine) — see `fastForward`'s `runTicksBatch`. */
|
|
568
|
+
#legacyRunTicksDoor = false;
|
|
531
569
|
/** Hidden-tab recovery (hollowstone field lesson: the engine hard-stops
|
|
532
570
|
* while `document.hidden`). Client-lifetime state so `bringToFront()`
|
|
533
571
|
* fires at most once per test, across ALL waitFor/waitSimTime loops. */
|
|
@@ -619,7 +657,29 @@ export class GameClient {
|
|
|
619
657
|
// names neither the method nor the shape).
|
|
620
658
|
assertValidWaitForBudget(budget, 'fastForward');
|
|
621
659
|
const clock: FastForwardClock = {
|
|
622
|
-
|
|
660
|
+
// The SETTLED door (`runTicksSettled`, an async bridge method): ticks never race a scene
|
|
661
|
+
// remount's async commit, so which tick first runs a freshly reloaded world is
|
|
662
|
+
// deterministic (see engine/runtime/run-ticks-settled.ts — measured: without it, one
|
|
663
|
+
// drive script produced 7 or 8 post-respawn walked ticks depending on wall timing).
|
|
664
|
+
// Falls back ONCE to the plain sync door for a page whose engine predates the method
|
|
665
|
+
// (an older exported game), and remembers the verdict for the rest of the burst.
|
|
666
|
+
runTicksBatch: async (n, render) => {
|
|
667
|
+
if (this.#legacyRunTicksDoor) {
|
|
668
|
+
await this.callBridgeVoid('runTicks', n, { render });
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
try {
|
|
672
|
+
await this.callBridgeAsync<void>('runTicksSettled', n, { render });
|
|
673
|
+
} catch (error) {
|
|
674
|
+
const code = (error as { code?: string }).code;
|
|
675
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
676
|
+
const doorAbsent =
|
|
677
|
+
code === 'UNKNOWN_BRIDGE_METHOD' || /is not a function|undefined/i.test(message);
|
|
678
|
+
if (!doorAbsent) throw error;
|
|
679
|
+
this.#legacyRunTicksDoor = true;
|
|
680
|
+
await this.callBridgeVoid('runTicks', n, { render });
|
|
681
|
+
}
|
|
682
|
+
},
|
|
623
683
|
readTime: async () => {
|
|
624
684
|
// Raw bridge read — deliberately NOT `this.snapshot()`, which would
|
|
625
685
|
// feed the TpsAccumulator (see fast-forward.ts's module doc, point 2).
|
|
@@ -651,7 +711,7 @@ export class GameClient {
|
|
|
651
711
|
snapshot: () => this.snapshot(),
|
|
652
712
|
now: () => Date.now(),
|
|
653
713
|
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
654
|
-
// Fixture heartbeat
|
|
714
|
+
// Fixture heartbeat — real stdout, the
|
|
655
715
|
// same channel a caller watching stdout already treats as
|
|
656
716
|
// liveness (see wait-for.ts's module doc for the emission
|
|
657
717
|
// invariant `maybeHeartbeat` enforces).
|
|
@@ -688,7 +748,7 @@ export class GameClient {
|
|
|
688
748
|
const start = await this.snapshot();
|
|
689
749
|
let lastTick: number | null = null;
|
|
690
750
|
let stalledPolls = 0;
|
|
691
|
-
// Fixture heartbeat
|
|
751
|
+
// Fixture heartbeat — same invariant as
|
|
692
752
|
// `wait-for.ts`'s `runWaitFor`: a heartbeat requires BOTH 60s of wall
|
|
693
753
|
// silence AND the tick having advanced since the last one emitted, so a
|
|
694
754
|
// genuinely stalled sim clock (caught by `stalledPolls` above, ~30s)
|
|
@@ -770,10 +830,8 @@ export class GameClient {
|
|
|
770
830
|
* `events.expect`'s failure path each shoot a frame on their own
|
|
771
831
|
* (`toSessionFailure`, `GameEvents.expect`), and a route's `whileHeld`
|
|
772
832
|
* observer may shoot one from inside a callback the driver never sees. The
|
|
773
|
-
* only place that sees ALL of them is here, which is why
|
|
774
|
-
* stamps its captures by listening rather than by intercepting
|
|
775
|
-
* (`src/tools/route-context.ts` in a scaffolded project is the shipped
|
|
776
|
-
* listener).
|
|
833
|
+
* only place that sees ALL of them is here, which is why a driving tool
|
|
834
|
+
* stamps its captures by listening rather than by intercepting.
|
|
777
835
|
*/
|
|
778
836
|
onCapture(listener: CaptureListener): () => void {
|
|
779
837
|
this.#captureListeners.add(listener);
|
|
@@ -783,7 +841,7 @@ export class GameClient {
|
|
|
783
841
|
}
|
|
784
842
|
|
|
785
843
|
/**
|
|
786
|
-
*
|
|
844
|
+
* One dialect, full capability — runs a UI-automation step
|
|
787
845
|
* written as a literal Playwright `async (page) => {...}` (interface
|
|
788
846
|
* doctrine §3.2/§4 rung 4: "the AI should think it is basically just
|
|
789
847
|
* executing Playwright"). Under `PageTransport` this drives the REAL
|
|
@@ -804,6 +862,37 @@ export class GameClient {
|
|
|
804
862
|
return this.unwrap<T>(outcome);
|
|
805
863
|
}
|
|
806
864
|
|
|
865
|
+
/**
|
|
866
|
+
* THE MODULE LANE — run literal JS INSIDE the game's page, with the
|
|
867
|
+
* running mount's modules in reach:
|
|
868
|
+
*
|
|
869
|
+
* ```js
|
|
870
|
+
* await game.run(async ({ modules }) => {
|
|
871
|
+
* const { simHost } = await modules('src/sim/host.ts');
|
|
872
|
+
* return simHost().state.day;
|
|
873
|
+
* })
|
|
874
|
+
* ```
|
|
875
|
+
*
|
|
876
|
+
* `scope` is `{ page, modules, instanceId }`. Serialization contract as
|
|
877
|
+
* `game.page()`: the step travels as source (no closures), and the return
|
|
878
|
+
* value must be plain data. `modules(path)` resolves through the ACTIVE
|
|
879
|
+
* mount's own url space, so what you touch IS the running game — never a
|
|
880
|
+
* phantom second copy. Dev-server sessions only; a shipped build's curated
|
|
881
|
+
* surface is its adapter exports.
|
|
882
|
+
*/
|
|
883
|
+
async run<T = unknown>(
|
|
884
|
+
step: (scope: {
|
|
885
|
+
page: Page;
|
|
886
|
+
modules: (path: string) => Promise<Record<string, unknown>>;
|
|
887
|
+
instanceId: string;
|
|
888
|
+
}) => T | Promise<T>,
|
|
889
|
+
opts?: { instance?: string },
|
|
890
|
+
): Promise<T> {
|
|
891
|
+
const erased = (arg: unknown) => step(arg as Parameters<typeof step>[0]);
|
|
892
|
+
const outcome = await this.#transport.runGameScript(step.toString(), erased, opts?.instance);
|
|
893
|
+
return this.unwrap<T>(outcome);
|
|
894
|
+
}
|
|
895
|
+
|
|
807
896
|
/**
|
|
808
897
|
* Reload the document showing the game, resolving only once it is back and
|
|
809
898
|
* answering commands (see `bridge-transport.ts`'s `reloadPage`).
|