flowviant 0.125.28 → 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/browserHandoff.mjs +25 -0
- package/bin/lib/desktopContract.mjs +8 -6
- 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 +132 -30
- package/bin/lib/managedBrowserPage.mjs +12 -7
- 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/runtimeAuth.mjs +56 -16
- 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 +75 -11
- package/package.json +1 -1
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/** Reviewed recipes plus machine-local bindings; never a cloud secret vault. */
|
|
2
|
+
import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { git } from './git.mjs';
|
|
6
|
+
|
|
7
|
+
export const ENVIRONMENT_RECIPE = '.flowviant/environment.json';
|
|
8
|
+
export const ENVIRONMENT_LOCAL = '.flowviant/environment.local.json';
|
|
9
|
+
export const environmentHash = value => createHash('sha256').update(value).digest('hex');
|
|
10
|
+
const KEY = /^[A-Za-z_][A-Za-z0-9_]{0,99}$/;
|
|
11
|
+
export const environmentKey = key => KEY.test(key) && !key.startsWith('FLOWVIANT_') && !['HOME', 'PATH', 'CODEX_HOME', 'NODE_OPTIONS', 'LD_PRELOAD'].includes(key);
|
|
12
|
+
const command = value => Array.isArray(value) && value.length > 0 && value.length <= 32 && value.every(v => typeof v === 'string' && v.length > 0 && v.length <= 2000 && !/[\0\r\n]/.test(v));
|
|
13
|
+
const strings = (value, max = 40) => Array.isArray(value) && value.length <= max && value.every(v => typeof v === 'string');
|
|
14
|
+
|
|
15
|
+
export function repositoryPath(root, path) {
|
|
16
|
+
if (typeof path !== 'string' || !path || isAbsolute(path) || path.includes('\\') || path.split('/').some(p => !p || p === '.' || p === '..') || /[\0\r\n]/.test(path)) throw new Error('Environment paths must be relative repository paths.');
|
|
17
|
+
const dest = resolve(root, path);
|
|
18
|
+
// Refuse links in any existing parent, including a linked .flowviant.
|
|
19
|
+
let current = resolve(root);
|
|
20
|
+
for (const part of path.split('/')) {
|
|
21
|
+
current = resolve(current, part);
|
|
22
|
+
try { if (lstatSync(current).isSymbolicLink()) throw new Error('Environment paths cannot contain symbolic links.'); }
|
|
23
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
24
|
+
}
|
|
25
|
+
return dest;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function readEnvironmentRecipe(root, ref = 'HEAD') {
|
|
29
|
+
const options = { stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 };
|
|
30
|
+
let commit;
|
|
31
|
+
try { commit = git(['rev-parse', '--verify', ref], root, options); }
|
|
32
|
+
catch (error) {
|
|
33
|
+
// Existing checks can lack a resolvable optional base ref. Without a
|
|
34
|
+
// declared recipe there is no environment feature to fail their check.
|
|
35
|
+
if (!git(['ls-tree', 'HEAD', '--', ENVIRONMENT_RECIPE], root, options)) return null;
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
// Absence and a failed read are different. A malformed/unreadable declared
|
|
39
|
+
// recipe must never fall through to copying the checkout's shared env.
|
|
40
|
+
const entry = git(['ls-tree', commit, '--', ENVIRONMENT_RECIPE], root, options);
|
|
41
|
+
if (!entry) return null;
|
|
42
|
+
if (!entry.startsWith('100644 blob ') && !entry.startsWith('100755 blob ')) throw new Error('The environment recipe must be a regular tracked file.');
|
|
43
|
+
let recipe;
|
|
44
|
+
try { recipe = JSON.parse(git(['show', `${commit}:${ENVIRONMENT_RECIPE}`], root, options)); }
|
|
45
|
+
catch { throw new Error('The reviewed environment recipe is not valid JSON.'); }
|
|
46
|
+
const db = recipe?.database;
|
|
47
|
+
if (recipe.version !== 1 || !db || !['postgres', 'command'].includes(db.provider) || !strings(db.bindings) || db.bindings.length === 0 || db.bindings.some(k => !environmentKey(k)) || new Set(db.bindings).size !== db.bindings.length) throw new Error('The environment recipe needs version 1, a database provider and unique environment variable bindings.');
|
|
48
|
+
if (!command(recipe.migrate) || !command(recipe.seed)) throw new Error('The environment recipe needs migrate and seed argv arrays. Both commands must be retry-safe.');
|
|
49
|
+
if (db.provider === 'postgres' && (!environmentKey(db.adminUrlEnv ?? '') || db.bindings.includes(db.adminUrlEnv))) throw new Error('PostgreSQL needs a separate adminUrlEnv credential name.');
|
|
50
|
+
if (db.provider === 'command' && (!command(db.command) || !strings(db.credentials ?? []) || (db.credentials ?? []).some(k => !environmentKey(k) || db.bindings.includes(k)) || typeof db.source !== 'string' || !db.source.trim())) throw new Error('A database adapter needs command argv, credential names and an explicit test-data source.');
|
|
51
|
+
const files = recipe.envFiles ?? ['.env.local'];
|
|
52
|
+
if (!strings(files, 12) || new Set(files).size !== files.length) throw new Error('Environment envFiles must be unique repository paths.');
|
|
53
|
+
for (const file of files) {
|
|
54
|
+
repositoryPath(root, file);
|
|
55
|
+
if (!/(^|\/)(?:\.env(?:\.[A-Za-z0-9_-]+)*|\.dev\.vars(?:\.[A-Za-z0-9_-]+)*)$/.test(file)) throw new Error('Generated environment files must use an .env or .dev.vars filename.');
|
|
56
|
+
}
|
|
57
|
+
if (recipe.env != null && (!recipe.env || typeof recipe.env !== 'object' || Array.isArray(recipe.env) || Object.entries(recipe.env).some(([k, v]) => !environmentKey(k) || typeof v !== 'string' || v.length > 16_384 || v.includes('\0')))) throw new Error('Recipe env must contain environment variable names and string values.');
|
|
58
|
+
const ttlHours = recipe.ttlHours ?? 24;
|
|
59
|
+
if (!Number.isFinite(ttlHours) || ttlHours < 1 || ttlHours > 168) throw new Error('Environment ttlHours must be between 1 and 168.');
|
|
60
|
+
const connectors = recipe.connectors ?? [];
|
|
61
|
+
if (!strings(connectors) || connectors.some(n => !/^[A-Za-z0-9_-]{1,80}$/.test(n))) throw new Error('Environment connectors must name reviewed project MCP servers.');
|
|
62
|
+
return { recipe: { ...recipe, envFiles: files, ttlHours, connectors }, commit, root: realpathSync(root), hash: environmentHash(JSON.stringify(recipe)) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function readEnvironmentLocal(root, callerEnv = process.env) {
|
|
66
|
+
const path = repositoryPath(root, ENVIRONMENT_LOCAL);
|
|
67
|
+
if (!existsSync(path)) return { ...callerEnv };
|
|
68
|
+
try { git(['check-ignore', '--quiet', '--', ENVIRONMENT_LOCAL], root); }
|
|
69
|
+
catch { throw new Error(`${ENVIRONMENT_LOCAL} must be gitignored. Credentials stay on this machine.`); }
|
|
70
|
+
if (!lstatSync(path).isFile() || lstatSync(path).size > 128 * 1024 || (lstatSync(path).mode & 0o077)) throw new Error(`${ENVIRONMENT_LOCAL} must be a regular owner-only file (chmod 600).`);
|
|
71
|
+
let local;
|
|
72
|
+
try { local = JSON.parse(readFileSync(path, 'utf8')); } catch { throw new Error(`${ENVIRONMENT_LOCAL} is not valid JSON.`); }
|
|
73
|
+
if (!local?.env || typeof local.env !== 'object' || Array.isArray(local.env) || Object.entries(local.env).some(([key, value]) => !environmentKey(key) || typeof value !== 'string' || value.length > 16_384 || value.includes('\0'))) throw new Error(`${ENVIRONMENT_LOCAL} needs an env object with string values.`);
|
|
74
|
+
return { ...callerEnv, ...local.env };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function reviewedProviderCommand(snapshot) {
|
|
78
|
+
const argv = snapshot.recipe.database.command;
|
|
79
|
+
// Like reviewed MCP launchers, referenced repo files must still be the
|
|
80
|
+
// reviewed blobs. Run providers from the checkout, never an edited worktree.
|
|
81
|
+
for (const arg of argv) {
|
|
82
|
+
if (!arg.startsWith('./') && !arg.startsWith('.flowviant/')) continue;
|
|
83
|
+
const path = arg.replace(/^\.\//, '');
|
|
84
|
+
const absolute = repositoryPath(snapshot.root, path);
|
|
85
|
+
const expected = git(['rev-parse', `${snapshot.commit}:${path}`], snapshot.root, { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
86
|
+
let actual;
|
|
87
|
+
try { actual = git(['hash-object', '--no-filters', '--', absolute], snapshot.root, { stdio: ['ignore', 'pipe', 'ignore'] }); }
|
|
88
|
+
catch { throw new Error('A reviewed database adapter is missing from the checkout.'); }
|
|
89
|
+
if (actual !== expected) throw new Error('The database adapter differs from its reviewed base copy.');
|
|
90
|
+
}
|
|
91
|
+
return argv;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function validateEnvironmentConnectors(snapshot) {
|
|
95
|
+
if (!snapshot.recipe.connectors.length) return;
|
|
96
|
+
let servers;
|
|
97
|
+
try { servers = JSON.parse(git(['show', `${snapshot.commit}:.mcp.json`], snapshot.root, { stdio: ['ignore', 'pipe', 'ignore'] })).mcpServers; }
|
|
98
|
+
catch { throw new Error('Environment connectors need a reviewed .mcp.json.'); }
|
|
99
|
+
for (const name of snapshot.recipe.connectors) {
|
|
100
|
+
const config = servers?.[name];
|
|
101
|
+
const values = config && [config.command, config.url, ...(config.args ?? []), ...Object.values(config.env ?? {}), ...Object.values(config.headers ?? {})];
|
|
102
|
+
if (!values?.some(value => snapshot.recipe.database.bindings.some(key => String(value).includes('${' + key + '}')))) throw new Error(`The ${name} connector must reference an isolated database binding in .mcp.json.`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Private daemon verbs, also usable by runners without MCP. */
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { killGroup, STOP_GRACE_MS } from './processGroup.mjs';
|
|
4
|
+
import { unattendedEnv } from './noWindowEnv.mjs';
|
|
5
|
+
import { prepareWorktreeEnvironment, readWorktreeEnvironment, releaseWorktreeEnvironment } from './reviewEnvironment.mjs';
|
|
6
|
+
|
|
7
|
+
export async function reviewEnvironmentMain(argv = process.argv.slice(3)) {
|
|
8
|
+
const controller = new AbortController();
|
|
9
|
+
const abort = () => controller.abort();
|
|
10
|
+
process.on('SIGINT', abort); process.on('SIGTERM', abort);
|
|
11
|
+
try {
|
|
12
|
+
const [wt, verb, separator, ...command] = argv;
|
|
13
|
+
const current = readWorktreeEnvironment(wt, process.env, { allowPending: verb === 'refresh' || verb === 'release' });
|
|
14
|
+
if (!current) throw new Error('No Flowviant environment has been prepared in this worktree.');
|
|
15
|
+
if (verb === 'refresh' && (argv.length === 2 || (argv.length === 3 && separator === '--seed'))) {
|
|
16
|
+
const next = await prepareWorktreeEnvironment(current.snapshot.root, wt, { snapshot: current.snapshot, reseed: separator === '--seed', signal: controller.signal });
|
|
17
|
+
process.stdout.write(JSON.stringify({ environment: next.record.id, state: next.record.phase, fixturesSeeded: next.record.seeded }) + '\n');
|
|
18
|
+
} else if (verb === 'release' && argv.length === 2) {
|
|
19
|
+
await releaseWorktreeEnvironment(current.snapshot.root, current.record.id, { signal: controller.signal });
|
|
20
|
+
process.stdout.write('Disposable environment released.\n');
|
|
21
|
+
} else if (verb === 'run' && separator === '--' && command.length) {
|
|
22
|
+
const child = spawn(command[0], command.slice(1), { cwd: wt, env: unattendedEnv(current.env), stdio: 'inherit', detached: true });
|
|
23
|
+
let stopTimer;
|
|
24
|
+
const stop = () => {
|
|
25
|
+
killGroup(child, 'SIGTERM');
|
|
26
|
+
stopTimer ??= setTimeout(() => killGroup(child), STOP_GRACE_MS);
|
|
27
|
+
stopTimer.unref?.();
|
|
28
|
+
};
|
|
29
|
+
controller.signal.addEventListener('abort', stop, { once: true });
|
|
30
|
+
if (controller.signal.aborted) stop();
|
|
31
|
+
await new Promise((resolve, reject) => {
|
|
32
|
+
child.once('error', () => reject(new Error('The environment command could not start.')));
|
|
33
|
+
child.once('close', code => { process.exitCode = code ?? 1; resolve(); });
|
|
34
|
+
}).finally(() => { clearTimeout(stopTimer); controller.signal.removeEventListener('abort', stop); killGroup(child); });
|
|
35
|
+
} else throw new Error('Use this worktree environment with refresh, release, or run -- <command argv>.');
|
|
36
|
+
} catch (error) { process.stderr.write(error.message + '\n'); process.exitCode = 1; }
|
|
37
|
+
finally { process.off('SIGINT', abort); process.off('SIGTERM', abort); }
|
|
38
|
+
}
|
|
@@ -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
|
+
}
|