flowviant 0.125.29 → 0.125.30
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/README.md +364 -0
- package/bin/cli.mjs +23 -0
- package/bin/lib/environmentProcess.mjs +44 -0
- package/bin/lib/fleet.mjs +2 -0
- package/bin/lib/linuxDesktopBridge.mjs +62 -0
- package/bin/lib/managedBrowser.mjs +50 -10
- package/bin/lib/mergeBatch.mjs +1 -1
- package/bin/lib/noWindowEnv.mjs +4 -2
- package/bin/lib/projectToolRuntimes.mjs +4 -2
- package/bin/lib/projectTools.mjs +2 -2
- package/bin/lib/repositoryPlaces.mjs +1 -1
- package/bin/lib/repositorySetup.mjs +15 -24
- package/bin/lib/reviewBridge.mjs +110 -0
- package/bin/lib/reviewDatabase.mjs +112 -0
- package/bin/lib/reviewDesktop.mjs +153 -0
- package/bin/lib/reviewEnvironment.mjs +286 -0
- package/bin/lib/reviewEnvironmentConfig.mjs +104 -0
- package/bin/lib/reviewEnvironmentMain.mjs +38 -0
- package/bin/lib/reviewImage.mjs +58 -0
- package/bin/lib/reviewMcpClient.mjs +69 -0
- package/bin/lib/reviewProcess.mjs +51 -0
- package/bin/lib/reviewTargets.mjs +50 -0
- package/bin/lib/robloxReview.mjs +234 -0
- package/bin/lib/robloxStudioBridge.mjs +68 -0
- package/bin/lib/runtimeReview.mjs +185 -0
- package/bin/lib/uiReview.mjs +5 -2
- package/bin/lib/work.mjs +22 -1
- package/bin/lib/workAgentCheck.mjs +24 -1
- package/bin/lib/workAgentTools.mjs +6 -4
- package/bin/lib/workAgentTurnExecution.mjs +46 -2
- package/package.json +1 -1
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** One network-disabled headless canvas normalizes native engine images. */
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { startReviewProcess } from './reviewProcess.mjs';
|
|
6
|
+
import { chromeFlags, resolveBrowser, sandboxOff } from './shot.mjs';
|
|
7
|
+
import { createCdpClient } from './pinnedCapture.mjs';
|
|
8
|
+
import { childEnv } from './childEnv.mjs';
|
|
9
|
+
|
|
10
|
+
export async function createReviewImage({ signal, onChild, timeoutMs = 620_000, detached = true, browser = resolveBrowser() } = {}) {
|
|
11
|
+
if (!browser || browser.viaWindows) throw new Error('Studio viewport review needs native headless Chromium on the daemon host to normalize captured images.');
|
|
12
|
+
const home = mkdtempSync(join(tmpdir(), 'flowviant-engine-image-'));
|
|
13
|
+
let process, cdp, closed = false, sessionId;
|
|
14
|
+
const close = async () => {
|
|
15
|
+
if (closed) return;
|
|
16
|
+
closed = true; cdp?.close('Engine image encoder closed.');
|
|
17
|
+
await process?.close();
|
|
18
|
+
try { rmSync(home, { recursive: true, force: true }); } catch { /* local cleanup */ }
|
|
19
|
+
};
|
|
20
|
+
const send = async (method, params = {}, session) => {
|
|
21
|
+
let timer;
|
|
22
|
+
try { return await Promise.race([cdp.send(method, params, session), new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('Engine image encoding timed out.')), 5000); })]); }
|
|
23
|
+
catch (error) { await close(); throw error; }
|
|
24
|
+
finally { clearTimeout(timer); }
|
|
25
|
+
};
|
|
26
|
+
try {
|
|
27
|
+
process = startReviewProcess([browser.bin, ...chromeFlags({ headlessFlag: '--headless=new', noSandbox: sandboxOff(), userDataDir: home, width: 1440, height: 900 }),
|
|
28
|
+
'--remote-debugging-pipe', '--disable-background-networking', '--disable-sync', '--disable-default-apps', '--proxy-server=http://127.0.0.1:9', '--proxy-bypass-list=<-loopback>', 'about:blank'],
|
|
29
|
+
{ env: { ...childEnv(), HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: home, XDG_DATA_HOME: home, XDG_CACHE_HOME: home }, signal, onChild, timeoutMs, detached, captureStdout: false, stdio: ['ignore', 'ignore', 'pipe', 'pipe', 'pipe'] });
|
|
30
|
+
cdp = createCdpClient(process.child.stdio[3], process.child.stdio[4], { maxMessageBytes: 10 * 1024 * 1024 });
|
|
31
|
+
process.done.then(() => { cdp.close('Engine image encoder exited.'); void close(); });
|
|
32
|
+
await send('Browser.getVersion');
|
|
33
|
+
await send('Browser.setDownloadBehavior', { behavior: 'deny' });
|
|
34
|
+
const { targetInfos } = await send('Target.getTargets');
|
|
35
|
+
const target = targetInfos.find(t => t.type === 'page');
|
|
36
|
+
if (!target) throw new Error('No isolated engine image canvas is available.');
|
|
37
|
+
({ sessionId } = await send('Target.attachToTarget', { targetId: target.targetId, flatten: true }));
|
|
38
|
+
await send('Network.enable', {}, sessionId);
|
|
39
|
+
await send('Network.setBlockedURLs', { urls: ['http://*', 'https://*', 'file://*', 'ws://*', 'wss://*', 'ftp://*'] }, sessionId);
|
|
40
|
+
return { close, async encode(image) {
|
|
41
|
+
if (closed) throw new Error('Engine image encoder closed.');
|
|
42
|
+
if (!image || !['image/png', 'image/jpeg'].includes(image.mimeType) || typeof image.data !== 'string' || image.data.length > 8 * 1024 * 1024 || !/^[A-Za-z0-9+/]+={0,2}$/.test(image.data)) throw new Error('Studio MCP must return one bounded PNG/JPEG image content block.');
|
|
43
|
+
const encoded = await send('Runtime.evaluate', { awaitPromise: true, returnByValue: true, expression: `new Promise((resolve,reject)=>{
|
|
44
|
+
const image=new Image();image.onerror=()=>reject(new Error('Invalid engine image'));
|
|
45
|
+
image.onload=()=>{const width=image.naturalWidth,height=image.naturalHeight;
|
|
46
|
+
if(!width||!height||width>4096||height>4096){reject(new Error('Engine image exceeds pixel bound'));return;}
|
|
47
|
+
const scale=Math.min(1440/width,900/height),w=width*scale,h=height*scale,left=(1440-w)/2,top=(900-h)/2;
|
|
48
|
+
const canvas=document.createElement('canvas');canvas.width=1440;canvas.height=900;
|
|
49
|
+
const context=canvas.getContext('2d');context.fillStyle='#000';context.fillRect(0,0,1440,900);context.drawImage(image,left,top,w,h);
|
|
50
|
+
resolve({data:canvas.toDataURL('image/jpeg',0.65).split(',')[1],width,height,left,top,w,h});
|
|
51
|
+
};image.src=${JSON.stringify(`data:${image.mimeType};base64,${image.data}`)};
|
|
52
|
+
})` }, sessionId);
|
|
53
|
+
const result = encoded.result?.value;
|
|
54
|
+
if (encoded.exceptionDetails || !result || result.data.length > 1_000_000) throw new Error('Studio image could not be normalized within the frame bound.');
|
|
55
|
+
return result;
|
|
56
|
+
} };
|
|
57
|
+
} catch (error) { await close(); throw error; }
|
|
58
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/** Bounded stdio MCP client for an engine bridge, never a model service. */
|
|
2
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
3
|
+
import { startReviewProcess } from './reviewProcess.mjs';
|
|
4
|
+
import { reviewDiagnostic } from './uiReviewBrowser.mjs';
|
|
5
|
+
|
|
6
|
+
export async function connectReviewMcp({ command, cwd, env, onChild, signal, requestTimeoutMs = 15_000, timeoutMs = 620_000, detached = true }) {
|
|
7
|
+
const process = startReviewProcess(command, { cwd, env, onChild, signal, timeoutMs, detached, captureStdout: false, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
8
|
+
const pending = new Map(), decoder = new StringDecoder('utf8');
|
|
9
|
+
let serial = 0, buffer = '', closed = false;
|
|
10
|
+
const close = async () => {
|
|
11
|
+
if (closed) return;
|
|
12
|
+
closed = true;
|
|
13
|
+
for (const entry of pending.values()) { clearTimeout(entry.timer); entry.reject(new Error('Studio MCP disconnected.')); }
|
|
14
|
+
pending.clear(); process.child.stdin.end(); await process.close();
|
|
15
|
+
};
|
|
16
|
+
const fail = () => { void close(); };
|
|
17
|
+
process.done.then(fail);
|
|
18
|
+
process.child.stdout.on('data', bytes => {
|
|
19
|
+
if (closed) return;
|
|
20
|
+
buffer += decoder.write(bytes);
|
|
21
|
+
if (Buffer.byteLength(buffer) > 10 * 1024 * 1024) { fail(); return; }
|
|
22
|
+
let newline;
|
|
23
|
+
while (!closed && (newline = buffer.indexOf('\n')) >= 0) {
|
|
24
|
+
const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1);
|
|
25
|
+
try {
|
|
26
|
+
const message = JSON.parse(line);
|
|
27
|
+
if (message.jsonrpc !== '2.0') throw new Error('Invalid MCP protocol.');
|
|
28
|
+
if (message.id != null && message.method) {
|
|
29
|
+
// This adapter supplies no sampling, elicitation or filesystem roots.
|
|
30
|
+
process.child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: message.id, error: { code: -32601, message: 'This review client does not support server requests.' } }) + '\n');
|
|
31
|
+
} else {
|
|
32
|
+
const entry = pending.get(message.id); if (!entry) continue;
|
|
33
|
+
pending.delete(message.id); clearTimeout(entry.timer);
|
|
34
|
+
message.error ? entry.reject(new Error(reviewDiagnostic(message.error.message))) : entry.resolve(message.result);
|
|
35
|
+
}
|
|
36
|
+
} catch { fail(); }
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
const request = (method, params, limit = requestTimeoutMs) => new Promise((resolve, reject) => {
|
|
40
|
+
if (closed) { reject(new Error('Studio MCP is disconnected.')); return; }
|
|
41
|
+
const id = ++serial;
|
|
42
|
+
const timer = setTimeout(() => { pending.delete(id); reject(new Error(`Studio MCP ${method} timed out.`)); }, limit);
|
|
43
|
+
pending.set(id, { resolve, reject, timer });
|
|
44
|
+
process.child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n', error => { if (error) fail(); });
|
|
45
|
+
});
|
|
46
|
+
try {
|
|
47
|
+
const hello = await request('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'flowviant-runtime-review', version: '1' } });
|
|
48
|
+
if (!hello?.capabilities?.tools) throw new Error('The configured server exposes no MCP tools.');
|
|
49
|
+
process.child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n');
|
|
50
|
+
const tools = []; let cursor;
|
|
51
|
+
for (let page = 0; page < 8; page++) {
|
|
52
|
+
const result = await request('tools/list', cursor ? { cursor } : {});
|
|
53
|
+
if (!Array.isArray(result?.tools) || tools.length + result.tools.length > 500) throw new Error('Studio MCP returned an invalid tool catalog.');
|
|
54
|
+
tools.push(...result.tools); cursor = result.nextCursor;
|
|
55
|
+
if (!cursor) break;
|
|
56
|
+
if (page === 7) throw new Error('Studio MCP tool catalog exceeded its page bound.');
|
|
57
|
+
}
|
|
58
|
+
return { tools, close, async call(name, args, limit) {
|
|
59
|
+
if (!tools.some(tool => tool.name === name)) throw new Error(`Studio MCP does not expose ${name}. Update Studio and enable its built-in MCP server.`);
|
|
60
|
+
const result = await request('tools/call', { name, arguments: args }, limit);
|
|
61
|
+
if (result?.isError) throw new Error(reviewDiagnostic((result.content ?? []).filter(c => c.type === 'text').map(c => c.text).join('\n')).slice(0, 1000) || `Studio MCP ${name} failed.`);
|
|
62
|
+
return result;
|
|
63
|
+
} };
|
|
64
|
+
} catch (error) { await close(); throw error; }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function mcpText(result) {
|
|
68
|
+
return (result?.content ?? []).filter(c => c.type === 'text' && typeof c.text === 'string').map(c => c.text).join('\n');
|
|
69
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** Own every runtime child and bound its output, cancellation and lifetime. */
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { killGroup } from './processGroup.mjs';
|
|
4
|
+
import { unattendedEnv } from './noWindowEnv.mjs';
|
|
5
|
+
import { scrub } from './uplinkScrub.mjs';
|
|
6
|
+
import { workLanesStopped } from './standDownGate.mjs';
|
|
7
|
+
|
|
8
|
+
export function startReviewProcess(argv, { cwd, env = process.env, display = false, input, signal, onChild = () => () => {}, timeoutMs = 600_000, binary = false, maxBytes = 64 * 1024, stdio, captureStdout = true, detached = true } = {}) {
|
|
9
|
+
signal?.throwIfAborted();
|
|
10
|
+
if (workLanesStopped()) throw new Error('The daemon is stopping. Review has not started.');
|
|
11
|
+
const child = spawn(argv[0], argv.slice(1), { cwd, env: unattendedEnv(env, { display }), detached, stdio: stdio ?? ['pipe', 'pipe', 'pipe'] });
|
|
12
|
+
let timer, untrack = () => {}, settled = false, total = 0, stdout = [], tail = '', exit;
|
|
13
|
+
let resolveExit;
|
|
14
|
+
const done = new Promise(resolve => { resolveExit = resolve; });
|
|
15
|
+
const finish = result => {
|
|
16
|
+
if (settled) return;
|
|
17
|
+
settled = true; exit = result; clearTimeout(timer); signal?.removeEventListener('abort', abort); untrack();
|
|
18
|
+
killGroup(child);
|
|
19
|
+
resolveExit({ ...result, output: tail, data: binary ? Buffer.concat(stdout) : undefined });
|
|
20
|
+
};
|
|
21
|
+
const terminate = reason => { killGroup(child); child.stdout?.destroy(); child.stderr?.destroy(); finish({ code: null, error: reason }); };
|
|
22
|
+
const abort = () => terminate('Review was interrupted.');
|
|
23
|
+
const diagnostics = bytes => { tail = scrub(tail + bytes.toString()).slice(-maxBytes); };
|
|
24
|
+
child.stdout?.on('data', bytes => {
|
|
25
|
+
if (!captureStdout) return;
|
|
26
|
+
if (binary) {
|
|
27
|
+
total += bytes.length;
|
|
28
|
+
if (total > maxBytes) { terminate('Review capture exceeded its byte bound.'); return; }
|
|
29
|
+
stdout.push(bytes);
|
|
30
|
+
} else diagnostics(bytes);
|
|
31
|
+
});
|
|
32
|
+
child.stderr?.on('data', diagnostics);
|
|
33
|
+
child.once('error', () => finish({ code: null, error: `Could not start ${argv[0]}. Check that it is installed.` }));
|
|
34
|
+
child.once('close', code => finish({ code }));
|
|
35
|
+
child.stdin?.on('error', () => {});
|
|
36
|
+
if (input !== undefined) child.stdin?.end(input);
|
|
37
|
+
else if (stdio == null) child.stdin?.end();
|
|
38
|
+
try { untrack = onChild(child) ?? (() => {}); }
|
|
39
|
+
catch (error) { terminate('The review process could not be tracked.'); throw error; }
|
|
40
|
+
timer = setTimeout(() => terminate('Review reached its time limit.'), timeoutMs); timer.unref?.();
|
|
41
|
+
signal?.addEventListener('abort', abort, { once: true }); if (signal?.aborted) abort();
|
|
42
|
+
return { child, done, running: () => !settled, result: () => exit, output: () => tail,
|
|
43
|
+
async close() { terminate('Review stopped.'); await done; } };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function reviewExec(argv, options) {
|
|
47
|
+
const process = startReviewProcess(argv, options);
|
|
48
|
+
const result = await process.done;
|
|
49
|
+
if (result.error || result.code !== 0) throw new Error(result.error ?? `Review command exited ${result.code}. ${result.output}`);
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** A reviewed recipe selects workflows; languages do not select viewers. */
|
|
2
|
+
import { accessSync, constants, realpathSync } from 'node:fs';
|
|
3
|
+
import { delimiter, isAbsolute, relative, resolve } from 'node:path';
|
|
4
|
+
import { git } from './git.mjs';
|
|
5
|
+
import { environmentKey } from './reviewEnvironmentConfig.mjs';
|
|
6
|
+
import { robloxConfiguration } from './robloxReview.mjs';
|
|
7
|
+
|
|
8
|
+
export const REVIEW_RECIPE = '.flowviant/review.json';
|
|
9
|
+
const quiet = { stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 128 * 1024 };
|
|
10
|
+
const nameOk = value => typeof value === 'string' && /^[A-Za-z0-9_-]{1,60}$/.test(value);
|
|
11
|
+
const commandOk = value => Array.isArray(value) && value.length > 0 && value.length <= 40 && value.every(v => typeof v === 'string' && v.length > 0 && v.length <= 2000 && !/[\0\r\n]/.test(v));
|
|
12
|
+
|
|
13
|
+
export function reviewCwd(wt, cwd = '.') {
|
|
14
|
+
if (typeof cwd !== 'string' || isAbsolute(cwd) || /[\0\r\n\\]/.test(cwd)) throw new Error('Review cwd must be inside this worktree.');
|
|
15
|
+
const path = realpathSync(resolve(wt, cwd)), rel = relative(realpathSync(wt), path);
|
|
16
|
+
if (isAbsolute(rel) || rel === '..' || rel.startsWith('../')) throw new Error('Review cwd must be inside this worktree.');
|
|
17
|
+
return path;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function commandOnPath(command, env = process.env) {
|
|
21
|
+
const candidates = isAbsolute(command) ? [command] : command.includes('/') || command.includes('\\') ? [] : (env.PATH ?? '').split(delimiter).map(dir => resolve(dir, command));
|
|
22
|
+
return candidates.find(path => { try { accessSync(path, constants.X_OK); return true; } catch { return false; } }) ?? null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function readReviewTargets(root, ref = 'HEAD') {
|
|
26
|
+
const commit = git(['rev-parse', '--verify', ref], root, quiet);
|
|
27
|
+
const entry = git(['ls-tree', commit, '--', REVIEW_RECIPE], root, quiet);
|
|
28
|
+
if (!entry) return { root, commit, targets: {} };
|
|
29
|
+
if (!/^100(?:644|755) blob /.test(entry)) throw new Error('The review recipe must be a regular tracked file.');
|
|
30
|
+
let recipe;
|
|
31
|
+
try { recipe = JSON.parse(git(['show', `${commit}:${REVIEW_RECIPE}`], root, quiet)); }
|
|
32
|
+
catch { throw new Error('The reviewed review.json is not valid JSON.'); }
|
|
33
|
+
if (recipe?.version !== 1 || !recipe.targets || typeof recipe.targets !== 'object' || Array.isArray(recipe.targets) || Object.keys(recipe.targets).length > 24) throw new Error('Review recipes need version 1 and at most 24 named targets.');
|
|
34
|
+
const targets = Object.create(null);
|
|
35
|
+
for (const [name, target] of Object.entries(recipe.targets)) {
|
|
36
|
+
if (!nameOk(name) || !target || !['command', 'browser', 'desktop', 'bridge', 'roblox'].includes(target.adapter) || !commandOk(target.command)) throw new Error('Each review target needs a name, supported adapter and command argv.');
|
|
37
|
+
if (target.adapter === 'roblox') robloxConfiguration(target.configuration);
|
|
38
|
+
if (target.cwd != null && (typeof target.cwd !== 'string' || target.cwd.length > 500 || isAbsolute(target.cwd) || /[\0\r\n\\]/.test(target.cwd))) throw new Error(`Invalid review cwd for ${name}.`);
|
|
39
|
+
const timeoutSeconds = target.timeoutSeconds ?? (target.adapter === 'command' ? 120 : 600);
|
|
40
|
+
if (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 1 || timeoutSeconds > 600) throw new Error(`Review target ${name} timeoutSeconds must be 1–600.`);
|
|
41
|
+
if (target.adapter !== 'command' && (typeof target.fixture !== 'string' || target.fixture.trim().length < 12 || target.fixture.length > 2000)) throw new Error(`Review target ${name} needs a disposable fixture description.`);
|
|
42
|
+
if (target.adapter === 'browser' && (!Number.isInteger(target.port) || target.port < 0 || target.port > 65535 || (target.port === 0 && !target.command.some(arg => arg.includes('${PORT}'))) || (target.hostname != null && target.hostname !== 'localhost') || (target.scheme != null && !['http', 'https'].includes(target.scheme)))) throw new Error(`Review target ${name} needs a valid local port/protocol (port 0 requires a PORT placeholder in command argv).`);
|
|
43
|
+
if (target.adapter === 'browser' && target.path != null && (typeof target.path !== 'string' || !target.path.startsWith('/') || target.path.startsWith('//') || /[\x00-\x20\\]/.test(target.path) || target.path.length > 2000 || new URL(target.path, 'http://preview.invalid').pathname.startsWith('//'))) throw new Error(`Review target ${name} needs a local path.`);
|
|
44
|
+
if (target.platforms != null && (!Array.isArray(target.platforms) || target.platforms.length > 3 || target.platforms.some(p => !['linux', 'darwin', 'win32'].includes(p)))) throw new Error(`Review target ${name} platforms must name operating systems.`);
|
|
45
|
+
if (target.configuration != null && (typeof target.configuration !== 'object' || Array.isArray(target.configuration) || Buffer.byteLength(JSON.stringify(target.configuration)) > 16_000)) throw new Error(`Review target ${name} bridge configuration exceeds its bound.`);
|
|
46
|
+
if (target.env != null && (!Array.isArray(target.env) || target.env.length > 40 || target.env.some(key => typeof key !== 'string' || !environmentKey(key)))) throw new Error(`Review target ${name} env must name explicitly selected runtime variables.`);
|
|
47
|
+
targets[name] = { ...target, cwd: target.cwd ?? '.', timeoutSeconds };
|
|
48
|
+
}
|
|
49
|
+
return { root: realpathSync(root), commit, targets };
|
|
50
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/** Studio viewport adapter. All engine operations name one verified fixture. */
|
|
2
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { basename, isAbsolute, relative, resolve } from 'node:path';
|
|
5
|
+
import { connectReviewMcp, mcpText } from './reviewMcpClient.mjs';
|
|
6
|
+
import { createReviewImage } from './reviewImage.mjs';
|
|
7
|
+
import { reviewDiagnostic } from './uiReviewBrowser.mjs';
|
|
8
|
+
|
|
9
|
+
const MARKER = 'FLOWVIANT_REVIEW_PROBE:';
|
|
10
|
+
const CLIENT_PROBE = `local camera=workspace.CurrentCamera
|
|
11
|
+
return '${MARKER}'..game:GetService('HttpService'):JSONEncode({player=game:GetService('Players').LocalPlayer ~= nil,width=camera and camera.ViewportSize.X or 0,height=camera and camera.ViewportSize.Y or 0,session=game:GetAttribute('FlowviantReviewSession')})`;
|
|
12
|
+
const sha = value => createHash('sha256').update(value).digest('hex');
|
|
13
|
+
const normalize = value => value.replace(/\r\n/g, '\n');
|
|
14
|
+
// Decimal byte escapes preserve Unicode without creating hundreds of call
|
|
15
|
+
// arguments (long UTF-8 names can exceed Luau's register limit).
|
|
16
|
+
const luauString = value => '"' + [...Buffer.from(value)].map(byte => '\\' + String(byte).padStart(3, '0')).join('') + '"';
|
|
17
|
+
const pathOk = value => Array.isArray(value) && value.length > 0 && value.length <= 20 && value.every(part => typeof part === 'string' && part.length > 0 && part.length <= 150 && !/[\x00-\x1f]/.test(part));
|
|
18
|
+
|
|
19
|
+
export function robloxConfiguration(config) {
|
|
20
|
+
if (!config || typeof config.studioName !== 'string' || config.studioName.length < 1 || config.studioName.length > 250 || /[\x00-\x1f]/.test(config.studioName)) throw new Error('Roblox review needs the exact Studio fixture name (optionally containing ${WORKTREE}).');
|
|
21
|
+
if (config.startPlay != null && typeof config.startPlay !== 'boolean') throw new Error('Roblox startPlay must be a boolean.');
|
|
22
|
+
if (!Array.isArray(config.scripts) || config.scripts.length < 1 || config.scripts.length > 40 || config.scripts.some(script => !script || typeof script.file !== 'string' || !script.file || script.file.length > 500 || isAbsolute(script.file) || script.file.split('/').includes('..') || /[\x00-\x1f\\]/.test(script.file) || !/\.(?:lua|luau)$/.test(script.file) || !pathOk(script.path))) throw new Error('Roblox review needs 1–40 Lua/Luau files mapped to explicit Studio instance path segments.');
|
|
23
|
+
if (new Set(config.scripts.map(s => s.file)).size !== config.scripts.length || new Set(config.scripts.map(s => JSON.stringify(s.path))).size !== config.scripts.length) throw new Error('Roblox source mappings must be unique.');
|
|
24
|
+
return { studioName: config.studioName, startPlay: config.startPlay ?? true, scripts: config.scripts };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function values(result) {
|
|
28
|
+
const out = [];
|
|
29
|
+
const visit = (value, depth = 0) => {
|
|
30
|
+
if (depth > 5) return;
|
|
31
|
+
out.push(value);
|
|
32
|
+
if (typeof value === 'string') { try { visit(JSON.parse(value), depth + 1); } catch { /* ordinary text */ } }
|
|
33
|
+
else if (value && typeof value === 'object') for (const entry of Object.values(value)) visit(entry, depth + 1);
|
|
34
|
+
};
|
|
35
|
+
visit(result.structuredContent); for (const item of result.content ?? []) if (item.type === 'text') visit(item.text);
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function studioPlayState(result) {
|
|
40
|
+
const states = new Set();
|
|
41
|
+
const mode = value => {
|
|
42
|
+
if (typeof value !== 'string') return;
|
|
43
|
+
if (/^(edit|stopped|stop)$/i.test(value)) states.add('edit');
|
|
44
|
+
if (/^(play|playing|run|running)$/i.test(value)) states.add('play');
|
|
45
|
+
};
|
|
46
|
+
for (const value of values(result)) {
|
|
47
|
+
if (typeof value === 'string') {
|
|
48
|
+
const match = /(?:^|\n)\s*(?:current\s+)?(?:studio\s+)?(?:play\s*(?:state|mode)|state|mode)\s*:\s*(Edit|Stopped|Playing|Play|Running|Run)\s*(?:$|\n)/i.exec(value);
|
|
49
|
+
if (match) mode(match[1]);
|
|
50
|
+
} else if (value && !Array.isArray(value) && typeof value === 'object') {
|
|
51
|
+
for (const key of ['playState', 'play_state', 'playMode', 'play_mode', 'state']) mode(value[key]);
|
|
52
|
+
for (const key of ['isPlaying', 'is_playing', 'isRunning', 'is_running']) if (typeof value[key] === 'boolean') states.add(value[key] ? 'play' : 'edit');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (states.size !== 1) throw new Error('Studio did not report an unambiguous supported play state. No play session was changed.');
|
|
56
|
+
return [...states][0];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function probeValue(result) {
|
|
60
|
+
for (const value of values(result)) if (typeof value === 'string') {
|
|
61
|
+
const index = value.indexOf(MARKER);
|
|
62
|
+
if (index >= 0) { try { return JSON.parse(value.slice(index + MARKER.length)); } catch { /* require a complete probe result */ } }
|
|
63
|
+
}
|
|
64
|
+
throw new Error('Studio did not return the complete source-binding probe.');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function studioProbe(config) {
|
|
68
|
+
return `local paths={${config.scripts.map(s => `{${s.path.map(luauString).join(',')}}`).join(',')}}
|
|
69
|
+
local sources={}
|
|
70
|
+
local checked=0
|
|
71
|
+
for _,path in ipairs(paths) do
|
|
72
|
+
local node=game
|
|
73
|
+
for _,name in ipairs(path) do
|
|
74
|
+
local found=nil
|
|
75
|
+
for _,child in ipairs(node:GetChildren()) do
|
|
76
|
+
checked+=1
|
|
77
|
+
if checked%500==0 then task.wait() end
|
|
78
|
+
if child.Name==name then if found then error('Ambiguous review source path') end found=child end
|
|
79
|
+
end
|
|
80
|
+
if not found then error('Missing review source path') end
|
|
81
|
+
node=found
|
|
82
|
+
end
|
|
83
|
+
if not node:IsA('LuaSourceContainer') then error('Review source is not a script') end
|
|
84
|
+
local source=node.Source
|
|
85
|
+
if #source>131072 then error('Review source exceeds byte bound') end
|
|
86
|
+
table.insert(sources,source)
|
|
87
|
+
end
|
|
88
|
+
return '${MARKER}'..game:GetService('HttpService'):JSONEncode({fixture=game:GetService('ReplicatedStorage'):GetAttribute('FlowviantReviewWorktree'),sources=sources})`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function openRobloxPage({ command, cwd, worktree, configuration, env, signal, onChild, onFrame = () => {}, onChange = () => {}, timeoutMs = 600_000,
|
|
92
|
+
connect = connectReviewMcp, imageFactory = createReviewImage, frameIntervalMs = 2000, startupWaitMs = 10_000 }) {
|
|
93
|
+
const config = robloxConfiguration(configuration), wt = realpathSync(worktree), fixture = basename(wt);
|
|
94
|
+
const expectedName = config.studioName.replaceAll('${WORKTREE}', fixture);
|
|
95
|
+
let mcp, encoder, studioId, ownedPlay = false, marked = false, closed = false, closePromise, timer, epoch = 0, captureSerial = 0, privateUsed = false, lastImage, queue = Promise.resolve();
|
|
96
|
+
const session = randomUUID();
|
|
97
|
+
const sourceHashes = [];
|
|
98
|
+
const tool = (name, args = {}, limit) => mcp.call(name, { ...args, studio_id: studioId }, limit);
|
|
99
|
+
const serial = fn => { const result = queue.catch(() => {}).then(fn); queue = result; return result; };
|
|
100
|
+
const close = () => {
|
|
101
|
+
if (closePromise) return closePromise;
|
|
102
|
+
closed = true; epoch++; clearTimeout(timer);
|
|
103
|
+
closePromise = (async () => {
|
|
104
|
+
// Only stop a play session this adapter started; never close Studio.
|
|
105
|
+
try {
|
|
106
|
+
await queue.catch(() => {});
|
|
107
|
+
if (ownedPlay && marked) {
|
|
108
|
+
const current = probeValue(await tool('execute_luau', { datamodel_type: 'Client', code: CLIENT_PROBE }, 4000));
|
|
109
|
+
if (current.session !== session) ownedPlay = false;
|
|
110
|
+
}
|
|
111
|
+
if (ownedPlay) await tool('start_stop_play', { is_start: false }, 8000);
|
|
112
|
+
}
|
|
113
|
+
finally { await Promise.allSettled([mcp?.close(), encoder?.close()]); }
|
|
114
|
+
})();
|
|
115
|
+
return closePromise;
|
|
116
|
+
};
|
|
117
|
+
const capture = async () => {
|
|
118
|
+
const viewport = probeValue(await tool('execute_luau', { datamodel_type: 'Client', code: CLIENT_PROBE }));
|
|
119
|
+
if (ownedPlay && marked && viewport.session !== session) { ownedPlay = false; throw new Error('Studio play was replaced outside this review; the replacement session was left alone.'); }
|
|
120
|
+
if (viewport.player !== true || !(viewport.width > 0 && viewport.height > 0)) throw new Error('No player camera viewport is available in Studio.');
|
|
121
|
+
const result = await tool('screen_capture', { capture_id: `Flowviant_${epoch}_${++captureSerial}` });
|
|
122
|
+
const images = (result.content ?? []).filter(c => c.type === 'image');
|
|
123
|
+
if (images.length !== 1) throw new Error('Studio screen_capture must return one native image content block.');
|
|
124
|
+
const image = await encoder.encode(images[0]);
|
|
125
|
+
if (Math.abs((image.width / image.height) / (viewport.width / viewport.height) - 1) > 0.01) throw new Error('Studio capture and player viewport aspect ratios differ; coordinate input cannot be mapped reliably.');
|
|
126
|
+
return { ...image, inputWidth: viewport.width, inputHeight: viewport.height };
|
|
127
|
+
};
|
|
128
|
+
const stream = async () => {
|
|
129
|
+
const current = epoch;
|
|
130
|
+
try { const image = await serial(capture); if (!closed && current === epoch) { lastImage = image; onFrame({ type: 'frame', data: image.data, width: 1440, height: 900 }); } }
|
|
131
|
+
catch (error) { if (!closed && current === epoch) { onChange({ error: reviewDiagnostic(error.message) }); void close().catch(() => {}); } }
|
|
132
|
+
finally { if (!closed && current === epoch) timer = setTimeout(stream, frameIntervalMs); }
|
|
133
|
+
};
|
|
134
|
+
try {
|
|
135
|
+
signal?.throwIfAborted();
|
|
136
|
+
// Keep dependencies alive through the outer bridge's 30-second cleanup
|
|
137
|
+
// grace, including a queued capture plus session-check/stop round trips.
|
|
138
|
+
mcp = await connect({ command, cwd, env, onChild, timeoutMs: timeoutMs + 35_000, detached: false });
|
|
139
|
+
for (const required of ['list_roblox_studios', 'get_studio_state', 'execute_luau', 'screen_capture', 'user_mouse_input', 'user_keyboard_input', ...(config.startPlay ? ['start_stop_play'] : [])]) {
|
|
140
|
+
if (!mcp.tools.some(t => t.name === required)) throw new Error(`Studio MCP is missing ${required}. Update Studio and enable its built-in MCP server.`);
|
|
141
|
+
}
|
|
142
|
+
const listed = await mcp.call('list_roblox_studios', {});
|
|
143
|
+
const studios = values(listed).find(value => value && !Array.isArray(value) && Array.isArray(value.studios))?.studios;
|
|
144
|
+
if (!studios) throw new Error('Studio MCP did not return a supported Studio instance list.');
|
|
145
|
+
const selected = studios.filter(studio => studio.name === expectedName && typeof studio.id === 'string');
|
|
146
|
+
if (selected.length !== 1) throw new Error(`Open exactly one disposable Studio fixture named ${expectedName}. ${selected.length ? 'Several matching instances are connected.' : 'No matching instance is connected.'}`);
|
|
147
|
+
studioId = selected[0].id;
|
|
148
|
+
const initial = studioPlayState(await tool('get_studio_state'));
|
|
149
|
+
if (config.startPlay && initial !== 'edit') throw new Error('This Studio fixture already has a play session. Return it to Edit mode, or configure startPlay:false to watch the existing session.');
|
|
150
|
+
const loaded = probeValue(await tool('execute_luau', { datamodel_type: 'Edit', code: studioProbe(config) }));
|
|
151
|
+
if (loaded.fixture !== fixture) throw new Error('Studio fixture is not bound to this worktree. Set the FlowviantReviewWorktree attribute on ReplicatedStorage to the worktree folder name in the disposable fixture.');
|
|
152
|
+
if (!Array.isArray(loaded.sources) || loaded.sources.length !== config.scripts.length) throw new Error('Studio did not return every mapped source.');
|
|
153
|
+
for (let index = 0; index < config.scripts.length; index++) {
|
|
154
|
+
const source = config.scripts[index], file = realpathSync(resolve(wt, source.file)), rel = relative(wt, file);
|
|
155
|
+
if (isAbsolute(rel) || rel === '..' || rel.startsWith('../')) throw new Error('A mapped Studio source escapes the worktree.');
|
|
156
|
+
const bytes = readFileSync(file); if (bytes.length > 128 * 1024 || typeof loaded.sources[index] !== 'string' || loaded.sources[index].length > 128 * 1024) throw new Error('Mapped Studio sources exceed the per-script bound.');
|
|
157
|
+
if (normalize(bytes.toString('utf8')) !== normalize(loaded.sources[index])) throw new Error(`Studio has stale/different code for ${source.file}. Sync this worktree into its fixture before review.`);
|
|
158
|
+
sourceHashes.push({ file: source.file, sha256: sha(normalize(bytes.toString('utf8'))) });
|
|
159
|
+
}
|
|
160
|
+
encoder = await imageFactory({ onChild, timeoutMs: timeoutMs + 35_000, detached: false });
|
|
161
|
+
signal?.throwIfAborted();
|
|
162
|
+
if (config.startPlay) {
|
|
163
|
+
if (studioPlayState(await tool('get_studio_state')) !== 'edit') throw new Error('Studio started playing during review setup; that existing session was left alone.');
|
|
164
|
+
signal?.throwIfAborted(); ownedPlay = true; await tool('start_stop_play', { is_start: true });
|
|
165
|
+
}
|
|
166
|
+
signal?.throwIfAborted();
|
|
167
|
+
if (studioPlayState(await tool('get_studio_state')) !== 'play') throw new Error('Studio did not enter play mode; no gameplay input was enabled.');
|
|
168
|
+
// Verify Client context rather than treating server-only Run as a player.
|
|
169
|
+
let player = false;
|
|
170
|
+
const until = Date.now() + startupWaitMs;
|
|
171
|
+
do {
|
|
172
|
+
signal?.throwIfAborted();
|
|
173
|
+
try {
|
|
174
|
+
const client = probeValue(await tool('execute_luau', { datamodel_type: 'Client', code: CLIENT_PROBE }, Math.max(1, Math.min(2000, until - Date.now()))));
|
|
175
|
+
player = client.player === true && client.width > 0 && client.height > 0;
|
|
176
|
+
} catch { /* Client context can arrive after the play-state transition. */ }
|
|
177
|
+
if (player || Date.now() >= until) break;
|
|
178
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(250, until - Date.now())));
|
|
179
|
+
} while (Date.now() < until);
|
|
180
|
+
if (!player) throw new Error('No local player was measured in the Studio Client context.');
|
|
181
|
+
if (ownedPlay) {
|
|
182
|
+
const receipt = probeValue(await tool('execute_luau', { datamodel_type: 'Client', code: `game:SetAttribute('FlowviantReviewSession', '${session}')
|
|
183
|
+
return '${MARKER}'..game:GetService('HttpService'):JSONEncode({session=game:GetAttribute('FlowviantReviewSession')})` }));
|
|
184
|
+
if (receipt.session !== session) throw new Error('Studio did not acknowledge the owned play-session marker.');
|
|
185
|
+
marked = true;
|
|
186
|
+
}
|
|
187
|
+
timer = setTimeout(stream, frameIntervalMs);
|
|
188
|
+
return { close,
|
|
189
|
+
async setPrivate(privateInput) { epoch++; clearTimeout(timer); lastImage = null; if (privateInput) privateUsed = true; await queue.catch(() => {}); if (!closed) timer = setTimeout(stream, frameIntervalMs); },
|
|
190
|
+
async snapshot() {
|
|
191
|
+
const current = epoch;
|
|
192
|
+
return serial(async () => {
|
|
193
|
+
if (closed) throw new Error('Studio review ended.');
|
|
194
|
+
if (studioPlayState(await tool('get_studio_state')) !== 'play') throw new Error('The Studio play session ended.');
|
|
195
|
+
const image = await capture();
|
|
196
|
+
let logs = '';
|
|
197
|
+
if (!privateUsed && mcp.tools.some(t => t.name === 'get_console_output')) {
|
|
198
|
+
try { logs = reviewDiagnostic(mcpText(await tool('get_console_output'))).slice(-4000); }
|
|
199
|
+
catch (error) { logs = `Console unavailable: ${reviewDiagnostic(error.message)}`; }
|
|
200
|
+
}
|
|
201
|
+
if (closed || current !== epoch) throw new Error('Studio control changed during capture.');
|
|
202
|
+
lastImage = image;
|
|
203
|
+
return { data: image.data, observation: JSON.stringify({ studio: expectedName, sourceHashes, console: privateUsed ? 'Console omitted after private control.' : logs,
|
|
204
|
+
note: 'Studio viewport observation. Mapped script sources matched at startup; assets and unmapped scripts are unverified. Captures/input do not establish a passing gameplay test. Studio sign-in dialogs require the Windows host.' }), diagnostics: [] };
|
|
205
|
+
});
|
|
206
|
+
},
|
|
207
|
+
async act(input) {
|
|
208
|
+
return serial(async () => {
|
|
209
|
+
if (closed) throw new Error('Studio review ended.');
|
|
210
|
+
if (input.selector || input.type === 'navigate') throw new Error('Studio supports viewport input; browser navigation and DOM selectors do not apply.');
|
|
211
|
+
if (studioPlayState(await tool('get_studio_state')) !== 'play') throw new Error('The Studio play session ended.');
|
|
212
|
+
if (ownedPlay && marked) {
|
|
213
|
+
const current = probeValue(await tool('execute_luau', { datamodel_type: 'Client', code: CLIENT_PROBE }));
|
|
214
|
+
if (current.session !== session) { ownedPlay = false; throw new Error('Studio play was replaced outside this review; the replacement session was left alone.'); }
|
|
215
|
+
}
|
|
216
|
+
const base = { datamodel_type: 'Client' };
|
|
217
|
+
if (input.type === 'click') {
|
|
218
|
+
const image = lastImage;
|
|
219
|
+
if (!image || input.x < image.left || input.y < image.top || input.x >= image.left + image.w || input.y >= image.top + image.h) throw new Error('Click inside the captured Studio viewport.');
|
|
220
|
+
await tool('user_mouse_input', { ...base, actions: [{ action: 'mouseButtonClick', mouse_button: 'left', x: Math.floor((input.x - image.left) * image.inputWidth / image.w), y: Math.floor((input.y - image.top) * image.inputHeight / image.h) }] });
|
|
221
|
+
} else if (input.type === 'type') await tool('user_keyboard_input', { ...base, actions: [{ action: 'textInput', text_inputs: input.text }] });
|
|
222
|
+
else if (input.type === 'key') {
|
|
223
|
+
const key = ({ Enter: 'Return', ArrowUp: 'Up', ArrowDown: 'Down', ArrowLeft: 'Left', ArrowRight: 'Right' })[input.key] ?? input.key;
|
|
224
|
+
await tool('user_keyboard_input', { ...base, actions: [{ action: 'keyPress', key_code: key }] });
|
|
225
|
+
} else if (input.type === 'scroll' && input.deltaY !== 0) {
|
|
226
|
+
if (!lastImage) throw new Error('Observe the Studio viewport before scrolling.');
|
|
227
|
+
await tool('user_mouse_input', { ...base, actions: Array.from({ length: Math.min(10, Math.ceil(Math.abs(input.deltaY) / 100)) }, () => ({ action: input.deltaY < 0 ? 'scrollUp' : 'scrollDown', x: Math.floor(lastImage.inputWidth / 2), y: Math.floor(lastImage.inputHeight / 2) })) });
|
|
228
|
+
}
|
|
229
|
+
else if (!['snapshot', 'scroll'].includes(input.type)) throw new Error('Studio supports viewport input; browser navigation and DOM selectors do not apply.');
|
|
230
|
+
});
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
} catch (error) { await close().catch(() => {}); throw error; }
|
|
234
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** Windows Studio through WSL stdio; no Windows daemon or desktop capture. */
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { mkdirSync } from 'node:fs';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { basename, join } from 'node:path';
|
|
7
|
+
import { openBridgePage } from './reviewBridge.mjs';
|
|
8
|
+
import { openRobloxPage, robloxConfiguration } from './robloxReview.mjs';
|
|
9
|
+
import { heldToolArgv } from './heldTool.mjs';
|
|
10
|
+
import { browserAction } from './managedBrowser.mjs';
|
|
11
|
+
import { reviewDiagnostic } from './uiReviewBrowser.mjs';
|
|
12
|
+
import { commandOnPath } from './reviewTargets.mjs';
|
|
13
|
+
|
|
14
|
+
export function openRobloxStudio({ command, cwd, worktree, configuration, ...options }) {
|
|
15
|
+
const config = robloxConfiguration(configuration);
|
|
16
|
+
const name = config.studioName.replaceAll('${WORKTREE}', basename(worktree));
|
|
17
|
+
const flock = commandOnPath('flock', options.env);
|
|
18
|
+
if (process.platform !== 'linux' || !flock) throw new Error('The first Studio adapter requires a Linux/WSL daemon with flock and a configured Windows Studio MCP launcher.');
|
|
19
|
+
const directory = join(homedir(), '.flowviant', 'review-studios'); mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
20
|
+
const lock = join(directory, createHash('sha256').update(name).digest('hex') + '.lock');
|
|
21
|
+
return openBridgePage({ ...options, command: [flock, '-n', '-E', '75', lock, process.execPath, ...heldToolArgv('__roblox-studio-bridge')], cwd, worktree,
|
|
22
|
+
configuration: { ...config, command, cwd, timeoutMs: options.timeoutMs }, requestTimeoutMs: 60_000, closeGraceMs: 30_000,
|
|
23
|
+
}).catch(error => {
|
|
24
|
+
if (/exited \(75\)/.test(error.message)) throw new Error('This Studio fixture is already being reviewed by another local turn. Return or stop that review first.');
|
|
25
|
+
throw error;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function robloxStudioBridgeMain() {
|
|
30
|
+
const controller = new AbortController();
|
|
31
|
+
let page, configuration, worktree, epoch = 0, closing = false, shuttingDown;
|
|
32
|
+
const send = value => { if (!closing) process.stdout.write(JSON.stringify({ jsonrpc: '2.0', ...value }) + '\n'); };
|
|
33
|
+
const shutdown = () => {
|
|
34
|
+
if (shuttingDown) return shuttingDown;
|
|
35
|
+
closing = true; controller.abort();
|
|
36
|
+
shuttingDown = Promise.resolve().then(() => page?.close()).catch(() => { process.exitCode = 1; });
|
|
37
|
+
return shuttingDown;
|
|
38
|
+
};
|
|
39
|
+
process.stdin.once('end', () => { void shutdown(); }); process.stdin.once('error', () => { void shutdown(); }); process.stdout.once('error', () => { void shutdown(); });
|
|
40
|
+
process.once('SIGTERM', () => { void shutdown(); process.stdin.destroy(); }); process.once('SIGINT', () => { void shutdown(); process.stdin.destroy(); });
|
|
41
|
+
try {
|
|
42
|
+
for await (const line of createInterface({ input: process.stdin })) {
|
|
43
|
+
if (closing) break;
|
|
44
|
+
let request;
|
|
45
|
+
try {
|
|
46
|
+
if (Buffer.byteLength(line) > 128 * 1024) throw new Error('Studio bridge request exceeds its byte bound.');
|
|
47
|
+
request = JSON.parse(line);
|
|
48
|
+
const { id, method, params } = request; let result = {};
|
|
49
|
+
if (method === 'initialize' && !configuration) {
|
|
50
|
+
configuration = params.configuration; worktree = params.worktree;
|
|
51
|
+
if (params.protocol !== 1 || !Array.isArray(configuration?.command) || typeof worktree !== 'string') throw new Error('Invalid Studio bridge initialization.');
|
|
52
|
+
robloxConfiguration(configuration);
|
|
53
|
+
result = { protocol: 1, isolation: 'consented-window', privateHandoff: true };
|
|
54
|
+
} else if (method === 'open' && configuration && !page) {
|
|
55
|
+
const opened = await openRobloxPage({ command: configuration.command, cwd: configuration.cwd, worktree, configuration, env: process.env, signal: controller.signal, timeoutMs: configuration.timeoutMs,
|
|
56
|
+
onFrame: frame => send({ method: 'frame', params: { ...frame, epoch } }), onChange: patch => send({ method: 'failure', params: { epoch, message: patch.error } }) });
|
|
57
|
+
page = opened;
|
|
58
|
+
// Parent EOF may have arrived while Studio was starting play.
|
|
59
|
+
if (closing) await opened.close();
|
|
60
|
+
} else if (method === 'snapshot' && page) result = { ...await page.snapshot(), width: 1440, height: 900, epoch };
|
|
61
|
+
else if (method === 'act' && page && params.epoch === epoch) await page.act(browserAction(params.input));
|
|
62
|
+
else if (method === 'privacy' && page) { await page.setPrivate(params.private); epoch = params.epoch; result = { epoch, drained: true }; }
|
|
63
|
+
else throw new Error('Unsupported or stale Studio bridge operation.');
|
|
64
|
+
send({ id, result });
|
|
65
|
+
} catch (error) { send({ id: request?.id, error: { message: reviewDiagnostic(error.message) } }); }
|
|
66
|
+
}
|
|
67
|
+
} finally { await shutdown(); }
|
|
68
|
+
}
|