@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 +3 -2
- package/src/editor/console-operations.ts +1 -1
- package/src/editor/open-operations.ts +4 -13
- package/src/editor/session-registry-format.ts +218 -0
- package/src/editor/transport.ts +20 -140
- package/src/generations.ts +100 -51
- package/src/play/control-operations.ts +1 -1
- package/src/play/debug-command-operations.ts +1 -1
- package/src/play/input-operations.ts +1 -1
- package/src/play/log-format.ts +1 -1
- package/src/play/state-operations.ts +1 -1
- package/src/play/status-operations.ts +1 -2
- package/src/play/transport.ts +29 -143
- package/src/project/build-discipline.ts +18 -27
- package/src/project/index.ts +4 -7
- package/src/project/provenance.ts +61 -47
- package/src/project/run-name.ts +5 -40
- package/src/project/session-journal.ts +85 -14
- package/src/project/shared.ts +2 -5
- package/src/project/tab-census.ts +83 -0
- package/src/project-tool-catalog.ts +25 -0
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.
|
|
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.
|
|
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
|
|
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.
|
|
3
|
-
* (B3, §8 B3 "open scene/asset/story").
|
|
2
|
+
* `editor.asset.open` / `editor.story.open`.
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
+
}
|
package/src/editor/transport.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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'
|
|
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
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
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
|
}
|
package/src/generations.ts
CHANGED
|
@@ -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
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
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
|