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,185 @@
|
|
|
1
|
+
/** One model-independent review tool over reusable execution/surface adapters. */
|
|
2
|
+
import { createServer } from 'node:net';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { writeFileSync } from 'node:fs';
|
|
5
|
+
import { basename, join } from 'node:path';
|
|
6
|
+
import { listenHeldTool, heldToolArgv } from './heldTool.mjs';
|
|
7
|
+
import { serveMcpStdio } from './mcpStdio.mjs';
|
|
8
|
+
import { readReviewTargets, reviewCwd, commandOnPath } from './reviewTargets.mjs';
|
|
9
|
+
import { startReviewProcess } from './reviewProcess.mjs';
|
|
10
|
+
import { desktopSupport } from './reviewDesktop.mjs';
|
|
11
|
+
import { openLinuxDesktop } from './linuxDesktopBridge.mjs';
|
|
12
|
+
import { openBridgePage } from './reviewBridge.mjs';
|
|
13
|
+
import { openRobloxStudio } from './robloxStudioBridge.mjs';
|
|
14
|
+
import { reviewedProviderCommand } from './reviewEnvironmentConfig.mjs';
|
|
15
|
+
import { environmentOverlay, readWorktreeEnvironment } from './reviewEnvironment.mjs';
|
|
16
|
+
import { childEnv } from './childEnv.mjs';
|
|
17
|
+
import { measureListeners } from './listeners.mjs';
|
|
18
|
+
import { resolveBrowser } from './shot.mjs';
|
|
19
|
+
import { reviewSourceState, sameReviewSource } from './uiReview.mjs';
|
|
20
|
+
import { reviewDiagnostic } from './uiReviewBrowser.mjs';
|
|
21
|
+
import { ensureArtifactDir, ARTIFACT_DIR } from './artifacts.mjs';
|
|
22
|
+
import { learnExtraSecrets, forgetExtraSecrets, scrub } from './uplinkScrub.mjs';
|
|
23
|
+
|
|
24
|
+
export const RUNTIME_REVIEW_SERVER = 'card_runtime';
|
|
25
|
+
const TOOLS = [
|
|
26
|
+
{ name: 'discover', description: 'List reviewed command, browser, desktop, Roblox Studio and engine review targets and measured prerequisites. Discovery starts no application.', inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
|
|
27
|
+
{ name: 'run', description: 'Run a named reviewed target. Command checks return logs and source-bound evidence; interactive targets open the SAME Live app frame and human control handoff. Never treats a build or sync server as gameplay.', inputSchema: { type: 'object', properties: { target: { type: 'string' }, repository: { type: 'string' } }, required: ['target'], additionalProperties: false } },
|
|
28
|
+
{ name: 'observe', description: 'Capture and retain the current shared runtime surface. Screenshot capture is an observation, not a passing test.', inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
|
|
29
|
+
{ name: 'act', description: 'Interact with the current surface using bounded click, type, key, scroll or browser navigation verbs. Human control pauses model interactions.', inputSchema: { type: 'object', properties: { type: { type: 'string', enum: ['click', 'type', 'key', 'scroll', 'navigate', 'snapshot'] }, x: { type: 'number' }, y: { type: 'number' }, text: { type: 'string' }, key: { type: 'string' }, path: { type: 'string' }, selector: { type: 'string' }, deltaY: { type: 'number' } }, required: ['type'], additionalProperties: false } },
|
|
30
|
+
{ name: 'handoff', description: 'Hold this turn for human private sign-in or native app testing. Waits without model polling and resumes the same conversation.', inputSchema: { type: 'object', properties: { kind: { type: 'string', enum: ['signin', 'control'] }, reason: { type: 'string', maxLength: 300 } }, required: ['kind', 'reason'], additionalProperties: false } },
|
|
31
|
+
{ name: 'stop', description: 'Stop the runtime processes owned by this tool. A person’s Stop prevents reopening for the turn.', inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
|
|
32
|
+
];
|
|
33
|
+
const text = value => ({ content: [{ type: 'text', text: JSON.stringify(value) }] });
|
|
34
|
+
const availablePort = () => new Promise((resolve, reject) => {
|
|
35
|
+
const server = createServer(); server.once('error', reject);
|
|
36
|
+
server.listen(0, '127.0.0.1', () => { const port = server.address().port; server.close(() => resolve(port)); });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export async function createRuntimeReview({ wt, repoRoot, baseRef, sources = [], browser, signal, onChild,
|
|
40
|
+
openDesktop = openLinuxDesktop, openBridge = openBridgePage, openRoblox = openRobloxStudio, desktopProbe = desktopSupport, browserProbe = resolveBrowser }) {
|
|
41
|
+
const catalog = (sources.length ? sources : [{ wt, repoRoot, baseRef }]).map(source => ({ ...readReviewTargets(source.repoRoot, source.baseRef),
|
|
42
|
+
wt: source.wt, name: source.name ?? basename(source.repoRoot), id: source.id }));
|
|
43
|
+
if (catalog.every(source => Object.keys(source.targets).length === 0)) return null;
|
|
44
|
+
const controller = new AbortController(), reports = [], commands = new Set();
|
|
45
|
+
let closed = false, busy = false, current = null, app = null;
|
|
46
|
+
const abort = () => { void close(); };
|
|
47
|
+
const environmentFor = source => readWorktreeEnvironment(source.wt)?.env ?? process.env;
|
|
48
|
+
function support(target, env) {
|
|
49
|
+
if (target.platforms && !target.platforms.includes(process.platform)) return { available: false, reason: `This target requires ${target.platforms.join(' or ')}; this machine is ${process.platform}.` };
|
|
50
|
+
if (target.adapter === 'browser') {
|
|
51
|
+
const found = browserProbe();
|
|
52
|
+
return browser && found && !found.viaWindows ? { available: true } : { available: false, reason: 'No native managed Chromium browser is available on this machine.' };
|
|
53
|
+
}
|
|
54
|
+
if (target.adapter === 'desktop') return browser ? desktopProbe(env) : { available: false, reason: 'The interactive surface transport is unavailable on this turn.' };
|
|
55
|
+
if (target.adapter === 'roblox') {
|
|
56
|
+
const found = browserProbe();
|
|
57
|
+
return browser && process.platform === 'linux' && commandOnPath('flock', env) && found && !found.viaWindows ? { available: true } : { available: false, reason: 'Studio viewport review needs a Linux/WSL daemon, flock and native headless Chromium. Studio/MCP runs on the Windows host.' };
|
|
58
|
+
}
|
|
59
|
+
if (target.adapter === 'bridge' && !browser) return { available: false, reason: 'The interactive surface transport is unavailable on this turn.' };
|
|
60
|
+
return { available: true };
|
|
61
|
+
}
|
|
62
|
+
const stopApp = async () => { const previous = app; app = null; await previous?.close(); };
|
|
63
|
+
async function close() {
|
|
64
|
+
if (closed) return;
|
|
65
|
+
closed = true; signal?.removeEventListener('abort', abort); controller.abort();
|
|
66
|
+
if (current) await browser?.close();
|
|
67
|
+
await Promise.all([stopApp(), ...[...commands].map(command => command.close())]);
|
|
68
|
+
held.server.close(); held.server.closeAllConnections(); forgetExtraSecrets(secretKey);
|
|
69
|
+
}
|
|
70
|
+
const record = (source, target, name, before, result) => {
|
|
71
|
+
const after = reviewSourceState(source.wt);
|
|
72
|
+
const report = { target: name, repository: source.name, adapter: target.adapter, ...result,
|
|
73
|
+
before, after, sourceStable: sameReviewSource(before, after) };
|
|
74
|
+
reports.push({ source, report });
|
|
75
|
+
if (ensureArtifactDir(wt)) {
|
|
76
|
+
const path = `runtime-review-${randomUUID()}.md`;
|
|
77
|
+
const body = `# Runtime review: ${source.name}/${name}\n\n${scrub(JSON.stringify(report, null, 2))}\n`;
|
|
78
|
+
writeFileSync(join(wt, ARTIFACT_DIR, path), body, { flag: 'wx', mode: 0o600 });
|
|
79
|
+
report.artifact = `${ARTIFACT_DIR}/${path}`;
|
|
80
|
+
}
|
|
81
|
+
return report;
|
|
82
|
+
};
|
|
83
|
+
const call = async (name, input = {}) => {
|
|
84
|
+
if (closed || controller.signal.aborted) throw new Error('This review turn has ended.');
|
|
85
|
+
if (name === 'discover') return text({ repositories: catalog.map(source => ({ repository: source.name, targets: Object.entries(source.targets).map(([target, config]) => ({ target, adapter: config.adapter,
|
|
86
|
+
...support(config, environmentFor(source)), command: config.command })) })),
|
|
87
|
+
instruction: 'Use the repository’s existing workflows when no recipe is configured. A missing runtime leaves those checks unverified; code editing can continue. A Roblox/Rojo sync server is not gameplay.' });
|
|
88
|
+
if (busy) throw new Error('Another runtime review operation is finishing.');
|
|
89
|
+
busy = true;
|
|
90
|
+
try {
|
|
91
|
+
if (name === 'observe') { if (!browser) throw new Error('No interactive runtime is available.'); return await browser.act({ type: 'snapshot' }); }
|
|
92
|
+
if (name === 'act') { if (!browser) throw new Error('No interactive runtime is available.'); return await browser.act(input); }
|
|
93
|
+
if (name === 'handoff') { if (!browser) throw new Error('No interactive runtime is available.'); return await browser.handoff(input); }
|
|
94
|
+
if (name === 'stop') { browser?.stopSurface(); await stopApp(); current = null; return text({ stopped: true }); }
|
|
95
|
+
if (name !== 'run') throw new Error('Unknown runtime tool.');
|
|
96
|
+
const source = input.repository == null ? catalog.find(source => source.wt === wt) : catalog.find(source => source.name === input.repository || source.id === input.repository);
|
|
97
|
+
const target = source && Object.hasOwn(source.targets, input.target) ? source.targets[input.target] : null;
|
|
98
|
+
if (!target) throw new Error('That reviewed target does not exist. Call discover and use a listed repository/target.');
|
|
99
|
+
const cwd = reviewCwd(source.wt, target.cwd), env = environmentFor(source), measured = support(target, env);
|
|
100
|
+
if (!measured.available) throw new Error(measured.reason);
|
|
101
|
+
if (!commandOnPath(target.command[0], env) && !target.command[0].startsWith('./')) throw new Error(`Review command ${target.command[0]} is not installed on this machine.`);
|
|
102
|
+
const scoped = readWorktreeEnvironment(source.wt);
|
|
103
|
+
const interactiveEnv = { ...childEnv({ cwd }), ...Object.fromEntries((target.env ?? []).filter(key => env[key] != null).map(key => [key, env[key]])), ...(scoped ? environmentOverlay(scoped) : {}) };
|
|
104
|
+
const before = reviewSourceState(source.wt), options = { cwd, env: target.adapter === 'command' ? env : interactiveEnv, onChild, signal: controller.signal, timeoutMs: target.timeoutSeconds * 1000 };
|
|
105
|
+
if (target.adapter === 'command') {
|
|
106
|
+
const running = startReviewProcess(target.command, options);
|
|
107
|
+
commands.add(running);
|
|
108
|
+
let result;
|
|
109
|
+
try { result = await running.done; } finally { commands.delete(running); }
|
|
110
|
+
const report = record(source, target, input.target, before, { status: !result.error && result.code === 0 ? 'passed' : 'failed', exitCode: result.code, output: result.output, ...(result.error ? { error: result.error } : {}) });
|
|
111
|
+
return text({ ...report, output: result.output.slice(-4000), ...(result.output.length > 4000 ? { outputTruncated: true } : {}) });
|
|
112
|
+
}
|
|
113
|
+
browser.assertControl();
|
|
114
|
+
const key = `${source.wt}:${input.target}`;
|
|
115
|
+
if (target.adapter === 'browser') {
|
|
116
|
+
try {
|
|
117
|
+
if (current?.key !== key || !app?.running()) {
|
|
118
|
+
await stopApp();
|
|
119
|
+
const port = target.port || await availablePort();
|
|
120
|
+
if (measureListeners(source.wt).rows.some(row => row.port === port)) throw new Error('That worktree port is already running. Use card_browser for the existing server, or choose a different review port.');
|
|
121
|
+
app = startReviewProcess(target.command.map(arg => arg.replaceAll('${PORT}', String(port))), options);
|
|
122
|
+
current = { key, port };
|
|
123
|
+
for (let n = 0; n < 100; n++) {
|
|
124
|
+
if (controller.signal.aborted) throw new Error('The review turn ended during startup.');
|
|
125
|
+
if (!app.running()) throw new Error(app.result()?.error ?? `The review app exited. ${app.output()}`);
|
|
126
|
+
if (measureListeners(source.wt).rows.some(row => row.port === port)) break;
|
|
127
|
+
if (n === 99) throw new Error('No listener was measured for this review target.');
|
|
128
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return await browser.open({ port: current.port, hostname: target.hostname, scheme: target.scheme, path: target.path ?? '/', fixture: target.fixture }, source.wt);
|
|
132
|
+
}
|
|
133
|
+
catch (error) { await stopApp(); current = null; throw error; }
|
|
134
|
+
}
|
|
135
|
+
const result = await browser.openSurface({ key, target: input.target, surface: target.adapter === 'desktop' ? 'desktop' : 'engine',
|
|
136
|
+
open: async surface => {
|
|
137
|
+
await stopApp(); current = { key };
|
|
138
|
+
if (target.adapter === 'desktop') return openDesktop({ ...options, ...surface, command: target.command, support: measured });
|
|
139
|
+
const command = reviewedProviderCommand({ root: source.root, commit: source.commit, recipe: { database: { command: target.command } } });
|
|
140
|
+
if (target.adapter === 'roblox') return openRoblox({ ...options, ...surface, command, cwd: source.root, worktree: source.wt, configuration: target.configuration });
|
|
141
|
+
return openBridge({ ...options, ...surface, command, cwd: source.root, worktree: source.wt, configuration: target.configuration });
|
|
142
|
+
} });
|
|
143
|
+
record(source, target, input.target, before, { status: 'observed', note: 'Interactive runtime opened. Captures and input do not establish passing gameplay tests.' });
|
|
144
|
+
return result;
|
|
145
|
+
} catch (error) { throw new Error(reviewDiagnostic(error.message)); }
|
|
146
|
+
finally { busy = false; }
|
|
147
|
+
};
|
|
148
|
+
const held = await listenHeldTool({ envPrefix: 'FLOWVIANT_RUNTIME_REVIEW', verb: '__runtime-review-mcp', requestTimeoutMs: 615_000, isClosed: () => closed,
|
|
149
|
+
respond: async (req, res) => {
|
|
150
|
+
try {
|
|
151
|
+
const chunks = []; let size = 0;
|
|
152
|
+
for await (const bytes of req) { size += bytes.length; if (size > 16_000) throw new Error('Runtime request exceeds its byte bound.'); chunks.push(bytes); }
|
|
153
|
+
const input = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
154
|
+
if (!TOOLS.some(tool => tool.name === input.name)) throw new Error('Unknown runtime tool.');
|
|
155
|
+
const result = await call(input.name, input.input);
|
|
156
|
+
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify(result));
|
|
157
|
+
} catch (error) { res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ isError: true, content: [{ type: 'text', text: reviewDiagnostic(error.message) }] })); }
|
|
158
|
+
} });
|
|
159
|
+
const secretKey = `runtime-review:${held.capability.slice(0, 12)}`;
|
|
160
|
+
learnExtraSecrets(secretKey, [{ name: 'FLOWVIANT_RUNTIME_REVIEW_CAPABILITY', value: held.capability }]);
|
|
161
|
+
signal?.addEventListener('abort', abort, { once: true }); if (signal?.aborted) await close();
|
|
162
|
+
return { env: held.env, config: { command: process.execPath, args: held.args, env: held.env, toolTimeoutSec: 650 }, close,
|
|
163
|
+
instructions: `Use card_runtime.discover to find reviewed command, browser, desktop or engine targets. Run a named target with card_runtime.run; never treat code compilation or a sync server as a visual/gameplay test. Desktop targets use a private virtual display, never the personal desktop. Native/engine screenshots use the SAME Live app frame and handoff controls as browser review. Human control pauses model observations/input; call handoff before asking for sign-in or manual testing and finish this invocation without polling. Missing capabilities are skipped checks, not passing tests or reasons to stop code work. Keep external services on scoped test bindings. Observe and retain actual captures before delivery. For a runner without MCP, this turn also provides the private shell helper argv ${JSON.stringify([process.execPath, ...heldToolArgv('__runtime-review')])}; pass a JSON request file {name,input}.`,
|
|
164
|
+
finalize() {
|
|
165
|
+
return reports.map(({ source, report }) => `Runtime review ${report.repository}/${report.target}: ${report.status}${report.status === 'observed' ? ' (not a passing gameplay test)' : ''}. ${report.sourceStable && sameReviewSource(report.after, reviewSourceState(source.wt)) ? 'Source matches final tracked/nonignored state.' : 'STALE: source changed or could not be measured; this does not verify final source.'}${report.artifact ? ` Evidence: ${report.artifact}.` : ''}`).join('\n');
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function requestRuntime(input) {
|
|
171
|
+
const response = await fetch(process.env.FLOWVIANT_RUNTIME_REVIEW_URL, { method: 'POST', headers: { Authorization: `Bearer ${process.env.FLOWVIANT_RUNTIME_REVIEW_CAPABILITY}`, 'Content-Type': 'application/json' }, body: JSON.stringify(input), signal: AbortSignal.timeout(615_000) });
|
|
172
|
+
if (!response.ok) throw new Error('The held runtime turn has ended.');
|
|
173
|
+
return response.json();
|
|
174
|
+
}
|
|
175
|
+
export async function runtimeReviewMain(mcp = false) {
|
|
176
|
+
if (mcp) return serveMcpStdio({ serverInfo: { name: RUNTIME_REVIEW_SERVER, version: '1.0.0' }, tools: TOOLS, rawResult: true, call: (name, input) => requestRuntime({ name, input }) });
|
|
177
|
+
try {
|
|
178
|
+
const { readFileSync } = await import('node:fs');
|
|
179
|
+
const result = await requestRuntime(JSON.parse(readFileSync(process.argv[3], 'utf8')));
|
|
180
|
+
// Shell tools are text transports. Never flood a model context with image
|
|
181
|
+
// base64; observe retains a local artifact that its image tool can inspect.
|
|
182
|
+
const content = result.content?.map(block => block.type === 'image' ? { type: 'text', text: 'Image shown in Live app. Use observe to retain it, then open the reported artifact with an available image tool. Capture alone does not establish visual inspection.' } : block);
|
|
183
|
+
process.stdout.write(JSON.stringify({ ...result, ...(content ? { content } : {}) }) + '\n'); process.exitCode = result.isError ? 1 : 0;
|
|
184
|
+
} catch (error) { process.stderr.write(error.message + '\n'); process.exitCode = 1; }
|
|
185
|
+
}
|
package/bin/lib/uiReview.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import { procStat } from './procStat.mjs';
|
|
|
12
12
|
import { killGroup } from './processGroup.mjs';
|
|
13
13
|
import { unattendedEnv } from './noWindowEnv.mjs';
|
|
14
14
|
import { childEnv } from './childEnv.mjs';
|
|
15
|
+
import { environmentOverlay, readWorktreeEnvironment } from './reviewEnvironment.mjs';
|
|
15
16
|
import { gitRaw } from './git.mjs';
|
|
16
17
|
import { serveMcpStdio } from './mcpStdio.mjs';
|
|
17
18
|
import { scrub, learnExtraSecrets, forgetExtraSecrets } from './uplinkScrub.mjs';
|
|
@@ -172,8 +173,10 @@ export async function createUiReview({ wt, signal, capture = captureLocalReview,
|
|
|
172
173
|
record.folder = `${ARTIFACT_DIR}/${name}`;
|
|
173
174
|
record.artifact = `${record.folder}/report.json`;
|
|
174
175
|
scratch = mkdtempSync(join(tmpdir(), 'flowviant-ui-preview-'));
|
|
175
|
-
const
|
|
176
|
-
|
|
176
|
+
const environment = readWorktreeEnvironment(wt);
|
|
177
|
+
const env = unattendedEnv({ ...childEnv({ cwd }), ...(environment ? environmentOverlay(environment) : {}) }, { browserHome: scratch });
|
|
178
|
+
// Only explicit scoped bindings join the minimal preview environment;
|
|
179
|
+
// no inherited personal home, SDK credentials or browser sessions.
|
|
177
180
|
env.HOME = scratch; env.USERPROFILE = scratch; env.XDG_CONFIG_HOME = scratch;
|
|
178
181
|
env.XDG_DATA_HOME = scratch; env.XDG_CACHE_HOME = scratch;
|
|
179
182
|
let output = '';
|
package/bin/lib/work.mjs
CHANGED
|
@@ -67,6 +67,8 @@ import { placeTurnLockedByLivePid } from './turnLock.mjs';
|
|
|
67
67
|
import { createWorkLocalCleanup } from './workLocalCleanup.mjs';
|
|
68
68
|
import { createManagedLocalCleanup } from './managedLocalCleanup.mjs';
|
|
69
69
|
import { trackedRepositorySetup } from './repositorySetup.mjs';
|
|
70
|
+
import { sweepWorktreeEnvironments } from './reviewEnvironment.mjs';
|
|
71
|
+
import { workLanesStopped } from './standDownGate.mjs';
|
|
70
72
|
import { createRepositoryPlaces } from './repositoryPlaces.mjs';
|
|
71
73
|
import { createAgentRepositories } from './agentRepositories.mjs';
|
|
72
74
|
import { createWorkProcesses } from './workProcesses.mjs';
|
|
@@ -395,6 +397,7 @@ export function createWorkManager({
|
|
|
395
397
|
// The check and the pre-review are built with only what each one uses; the
|
|
396
398
|
// review entry beat is handed the two and owns only their order.
|
|
397
399
|
const { runCheck } = createWorkAgentCheck({
|
|
400
|
+
baseRef,
|
|
398
401
|
repoRoot,
|
|
399
402
|
postBestEffort,
|
|
400
403
|
workChildren,
|
|
@@ -461,6 +464,23 @@ export function createWorkManager({
|
|
|
461
464
|
sweepMergedSessionBranch,
|
|
462
465
|
});
|
|
463
466
|
|
|
467
|
+
let environmentSweep = null, lastEnvironmentSweep = 0;
|
|
468
|
+
const reconcileEnvironments = () => {
|
|
469
|
+
if (environmentSweep || workLanesStopped() || Date.now() - lastEnvironmentSweep < 60_000) return;
|
|
470
|
+
lastEnvironmentSweep = Date.now();
|
|
471
|
+
environmentSweep = (async () => {
|
|
472
|
+
const roots = [...new Set(repositories?.list().map(r => r.repoRoot).filter(Boolean) ?? [repoRoot])];
|
|
473
|
+
for (const root of roots) {
|
|
474
|
+
if (workLanesStopped()) break;
|
|
475
|
+
const result = await sweepWorktreeEnvironments(root, {
|
|
476
|
+
onSpawn: ch => { workChildren.set(ch, null); groupKillChildren.add(ch); },
|
|
477
|
+
onClose: ch => { workChildren.delete(ch); groupKillChildren.delete(ch); },
|
|
478
|
+
});
|
|
479
|
+
for (const failure of result.errors) warn(envScrub(`Environment ${failure.id}: ${failure.error}`));
|
|
480
|
+
}
|
|
481
|
+
})().catch(error => warn(envScrub(error.message))).finally(() => { environmentSweep = null; });
|
|
482
|
+
};
|
|
483
|
+
|
|
464
484
|
const { processKillJobs } = createWorkProcesses({
|
|
465
485
|
placeDir,
|
|
466
486
|
reportPlaceWorktrees,
|
|
@@ -498,7 +518,7 @@ export function createWorkManager({
|
|
|
498
518
|
workChildren.size > 0 ||
|
|
499
519
|
workAnswering.size > 0 ||
|
|
500
520
|
pendingWorkReports.size > 0 ||
|
|
501
|
-
pendingShipReports.size > 0 || cleanupBusy();
|
|
521
|
+
pendingShipReports.size > 0 || cleanupBusy() || environmentSweep !== null;
|
|
502
522
|
|
|
503
523
|
return {
|
|
504
524
|
flushWorkReports,
|
|
@@ -514,6 +534,7 @@ export function createWorkManager({
|
|
|
514
534
|
retirePreviews,
|
|
515
535
|
shutdownPreviews,
|
|
516
536
|
retireWorkSessions,
|
|
537
|
+
reconcileEnvironments,
|
|
517
538
|
reportWorktrees,
|
|
518
539
|
reconcileIdleArtifacts,
|
|
519
540
|
reconcileLocalCleanup,
|
|
@@ -21,6 +21,9 @@ import { scrub as envScrub } from './uplinkScrub.mjs';
|
|
|
21
21
|
import { agentBrowserHome, openBrowserHome, unattendedEnv } from './noWindowEnv.mjs';
|
|
22
22
|
import { fleetEndpoint } from './fleetWire.mjs';
|
|
23
23
|
import { workLanesStopped } from './standDownGate.mjs';
|
|
24
|
+
import { setupRepository } from './repositorySetup.mjs';
|
|
25
|
+
import { readEnvironmentRecipe } from './reviewEnvironmentConfig.mjs';
|
|
26
|
+
import { holdWorktreeEnvironment } from './reviewEnvironment.mjs';
|
|
24
27
|
|
|
25
28
|
/**
|
|
26
29
|
* CLEAR THE ARTIFACTS DIRECTORY OF ANYTHING THAT IS NOT AN ARTIFACT, BEFORE
|
|
@@ -135,6 +138,7 @@ export function readConfiguredCheck(root) {
|
|
|
135
138
|
|
|
136
139
|
export function createWorkAgentCheck({
|
|
137
140
|
repoRoot,
|
|
141
|
+
baseRef = () => 'HEAD',
|
|
138
142
|
postBestEffort,
|
|
139
143
|
workChildren,
|
|
140
144
|
groupKillChildren,
|
|
@@ -184,6 +188,24 @@ export function createWorkAgentCheck({
|
|
|
184
188
|
await postCheck({ agentId, status: 'none', ...(headSha ? { headSha } : {}) });
|
|
185
189
|
return { status: 'none' };
|
|
186
190
|
}
|
|
191
|
+
let environment = null, releaseEnvironment = () => {};
|
|
192
|
+
try {
|
|
193
|
+
const root = repository?.repoRoot ?? repoRoot;
|
|
194
|
+
const snapshot = readEnvironmentRecipe(root, repository?.baseRef ?? baseRef());
|
|
195
|
+
if (snapshot) {
|
|
196
|
+
// A combined merge has a different worktree/database from its input
|
|
197
|
+
// agents. Migrate its CURRENT integrated source before its checks.
|
|
198
|
+
environment = await setupRepository(root, wt, { snapshot,
|
|
199
|
+
onSpawn: ch => { workChildren.set(ch, agentId); groupKillChildren.add(ch); },
|
|
200
|
+
onClose: ch => { workChildren.delete(ch); groupKillChildren.delete(ch); } });
|
|
201
|
+
releaseEnvironment = holdWorktreeEnvironment(environment);
|
|
202
|
+
}
|
|
203
|
+
} catch (error) {
|
|
204
|
+
const text = envScrub(error.message);
|
|
205
|
+
await postCheck({ agentId, status: 'failed', output: text, ...(headSha ? { headSha } : {}) });
|
|
206
|
+
return { status: 'failed', text };
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
187
209
|
const cleared = clearNonArtifacts(wt);
|
|
188
210
|
if (cleared.length)
|
|
189
211
|
warn(
|
|
@@ -241,7 +263,7 @@ export function createWorkAgentCheck({
|
|
|
241
263
|
shell: true,
|
|
242
264
|
detached: true,
|
|
243
265
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
244
|
-
env: checkEnv(process.env, browser.dir),
|
|
266
|
+
env: checkEnv(environment?.env ?? process.env, browser.dir),
|
|
245
267
|
});
|
|
246
268
|
/**
|
|
247
269
|
* …AND IT IS TRACKED, so a stop or a takeover takes it with them.
|
|
@@ -330,6 +352,7 @@ export function createWorkAgentCheck({
|
|
|
330
352
|
...(headSha ? { headSha } : {}),
|
|
331
353
|
});
|
|
332
354
|
return out;
|
|
355
|
+
} finally { releaseEnvironment(); }
|
|
333
356
|
};
|
|
334
357
|
|
|
335
358
|
return { runCheck };
|
|
@@ -28,6 +28,7 @@ import { machineMcpServers } from './claudeMachineMcp.mjs';
|
|
|
28
28
|
import { ORCHESTRATOR_SERVER } from './orchestratorProtocol.mjs';
|
|
29
29
|
import { VIDEO_SERVER } from './videoRender.mjs';
|
|
30
30
|
import { BROWSER_SERVER } from './managedBrowser.mjs';
|
|
31
|
+
import { RUNTIME_REVIEW_SERVER } from './runtimeReview.mjs';
|
|
31
32
|
|
|
32
33
|
/** The servers the daemon itself mounts on a card turn, by the names the
|
|
33
34
|
* agent calls them: a multi-repository card's add_repository, an
|
|
@@ -35,7 +36,7 @@ import { BROWSER_SERVER } from './managedBrowser.mjs';
|
|
|
35
36
|
* render_video (2026-10-26), and a code turn's managed browser (2026-10-30).
|
|
36
37
|
* Never a project server's, whatever the base
|
|
37
38
|
* `.mcp.json` names. */
|
|
38
|
-
export const CARD_TURN_SERVERS = Object.freeze({ repositories: 'card_repositories', orchestrator: ORCHESTRATOR_SERVER, uiReview: 'card_ui_review', video: VIDEO_SERVER, browser: BROWSER_SERVER });
|
|
39
|
+
export const CARD_TURN_SERVERS = Object.freeze({ repositories: 'card_repositories', orchestrator: ORCHESTRATOR_SERVER, uiReview: 'card_ui_review', video: VIDEO_SERVER, browser: BROWSER_SERVER, runtime: RUNTIME_REVIEW_SERVER });
|
|
39
40
|
|
|
40
41
|
/**
|
|
41
42
|
* The card turn's tools, prepared, and what it is told of them.
|
|
@@ -48,13 +49,13 @@ export const CARD_TURN_SERVERS = Object.freeze({ repositories: 'card_repositorie
|
|
|
48
49
|
* from here), the note, the mount's part of the CLI's `mcpEnv` and whether it
|
|
49
50
|
* needs one, and the keys the runner takes the mount as.
|
|
50
51
|
*/
|
|
51
|
-
export function prepareCardTools({ rt, posture, wt, repoRoot, baseRef, sources = [], repositoryTool = null, orchestratorConfig = null, uiReviewConfig = null, videoConfig = null, browserConfig = null, orchestrated = false, codexHome = null, env = process.env, machineServers = machineMcpServers }) {
|
|
52
|
+
export function prepareCardTools({ rt, posture, wt, repoRoot, baseRef, sources = [], repositoryTool = null, orchestratorConfig = null, uiReviewConfig = null, videoConfig = null, browserConfig = null, runtimeConfig = null, orchestrated = false, codexHome = null, env = process.env, machineServers = machineMcpServers }) {
|
|
52
53
|
// A turn reads the base ref afresh. Changes the agent has made to its
|
|
53
54
|
// own worktree cannot silently change the next turn's tools.
|
|
54
55
|
const source = sources.find((r) => r.wt === wt);
|
|
55
|
-
const snapshot = readBaseTools(source?.repoRoot ?? repoRoot, source?.baseRef ?? baseRef());
|
|
56
|
+
const snapshot = readBaseTools(source?.repoRoot ?? repoRoot, source?.baseRef ?? baseRef(), env);
|
|
56
57
|
for (const other of sources.filter((r) => r !== source)) {
|
|
57
|
-
const base = readBaseTools(other.repoRoot, other.baseRef);
|
|
58
|
+
const base = readBaseTools(other.repoRoot, other.baseRef, env);
|
|
58
59
|
snapshot.instructions.push(...[...base.instructions, ...base.skillFiles].map((file) => ({ ...file, path: `repositories/${other.folder}/${file.path}` })));
|
|
59
60
|
}
|
|
60
61
|
const projectTools = prepareAgentTools(snapshot, rt, env, wt, { codexHome, fenced: posture !== 'build', turnServers: {
|
|
@@ -62,6 +63,7 @@ export function prepareCardTools({ rt, posture, wt, repoRoot, baseRef, sources =
|
|
|
62
63
|
...(orchestratorConfig ? { [CARD_TURN_SERVERS.orchestrator]: orchestratorConfig } : {}),
|
|
63
64
|
...(posture === 'build' && uiReviewConfig ? { [CARD_TURN_SERVERS.uiReview]: uiReviewConfig } : {}),
|
|
64
65
|
...(posture === 'build' && browserConfig ? { [CARD_TURN_SERVERS.browser]: browserConfig } : {}),
|
|
66
|
+
...(posture === 'build' && runtimeConfig ? { [CARD_TURN_SERVERS.runtime]: runtimeConfig } : {}),
|
|
65
67
|
// A video card's render tool rides its fenced posture (videoRender.mjs).
|
|
66
68
|
...(videoConfig ? { [CARD_TURN_SERVERS.video]: videoConfig } : {}),
|
|
67
69
|
} });
|
|
@@ -73,6 +73,11 @@ import { createVideoRender } from './videoRender.mjs';
|
|
|
73
73
|
import { createManagedBrowser } from './managedBrowser.mjs';
|
|
74
74
|
import { completeBrowserReview, browserHandoffPrompt } from './browserHandoff.mjs';
|
|
75
75
|
import { withUiReview, appendUiReviewEvidence } from './uiReviewTurn.mjs';
|
|
76
|
+
import { setupRepository } from './repositorySetup.mjs';
|
|
77
|
+
import { readEnvironmentRecipe } from './reviewEnvironmentConfig.mjs';
|
|
78
|
+
import { environmentInstructions, environmentOverlay, holdWorktreeEnvironment } from './reviewEnvironment.mjs';
|
|
79
|
+
import { environmentCredentialNames } from './reviewDatabase.mjs';
|
|
80
|
+
import { createRuntimeReview } from './runtimeReview.mjs';
|
|
76
81
|
|
|
77
82
|
export function createAgentTurnExecution({
|
|
78
83
|
admit,
|
|
@@ -105,6 +110,7 @@ export function createAgentTurnExecution({
|
|
|
105
110
|
makeUiReview = createUiReview,
|
|
106
111
|
makeVideoRender = createVideoRender,
|
|
107
112
|
makeManagedBrowser = createManagedBrowser,
|
|
113
|
+
makeRuntimeReview = createRuntimeReview,
|
|
108
114
|
}) {
|
|
109
115
|
const reportDelivery = createDeliveryReporter();
|
|
110
116
|
/** How long the final flush may hold the settle. Bounded because the settle
|
|
@@ -126,6 +132,7 @@ export function createAgentTurnExecution({
|
|
|
126
132
|
* `interruptAgentTurns`); absent, nothing can interrupt it. */
|
|
127
133
|
const runAgentTurnInPlace = async (job, { turnId, agentId, place, releaseSlot, postAgentTurn, measured, run = { interrupted: false } }) => {
|
|
128
134
|
let repositoryRun = null;
|
|
135
|
+
const environmentReleases = [];
|
|
129
136
|
const setupController = new AbortController();
|
|
130
137
|
run.abortSetup = () => setupController.abort();
|
|
131
138
|
if (run.interrupted) setupController.abort();
|
|
@@ -417,6 +424,8 @@ export function createAgentTurnExecution({
|
|
|
417
424
|
let browserNote = '';
|
|
418
425
|
let uiReviewNote = '';
|
|
419
426
|
let uiReviewEvidence = '';
|
|
427
|
+
let environmentNote = '', reviewEnv = null;
|
|
428
|
+
let runtimeReview = null, runtimeNote = '', runtimeEvidence = '';
|
|
420
429
|
// A video card's render tool (videoRender.mjs), and the words a turn
|
|
421
430
|
// gets when it could not be held, so it stops rather than guesses.
|
|
422
431
|
let videoRender = null;
|
|
@@ -430,6 +439,8 @@ export function createAgentTurnExecution({
|
|
|
430
439
|
args = withUiReview(args, uiReviewNote, uiReview?.env);
|
|
431
440
|
args = withUiReview(args, videoNote);
|
|
432
441
|
args = withUiReview(args, browserNote, managedBrowser?.env);
|
|
442
|
+
args = withUiReview(args, environmentNote, reviewEnv);
|
|
443
|
+
args = withUiReview(args, runtimeNote, runtimeReview?.env);
|
|
433
444
|
args.managedBrowser = Boolean(managedBrowser);
|
|
434
445
|
return runTurnImpl(args);
|
|
435
446
|
};
|
|
@@ -480,6 +491,22 @@ export function createAgentTurnExecution({
|
|
|
480
491
|
let orchestrator = null;
|
|
481
492
|
let intermediateDelivery = null;
|
|
482
493
|
try {
|
|
494
|
+
if (posture === 'build') {
|
|
495
|
+
const sources = repositoryRun?.sources ?? [{ wt, repoRoot: worktreeRepo, baseRef: baseRef() }];
|
|
496
|
+
const administrativeNames = new Set();
|
|
497
|
+
for (const source of sources) {
|
|
498
|
+
const snapshot = readEnvironmentRecipe(source.repoRoot, source.baseRef);
|
|
499
|
+
if (!snapshot) continue;
|
|
500
|
+
const environment = await setupRepository(source.repoRoot, source.wt, { snapshot, signal: setupController.signal,
|
|
501
|
+
onSpawn: ch => { workChildren.set(ch, agentId); groupKillChildren?.add(ch); },
|
|
502
|
+
onClose: ch => { workChildren.delete(ch); groupKillChildren?.delete(ch); } });
|
|
503
|
+
environmentReleases.push(holdWorktreeEnvironment(environment));
|
|
504
|
+
environmentNote += '\n' + environmentInstructions(environment);
|
|
505
|
+
for (const name of environmentCredentialNames(snapshot.recipe)) administrativeNames.add(name);
|
|
506
|
+
if (source.wt === wt) reviewEnv = environmentOverlay(environment);
|
|
507
|
+
}
|
|
508
|
+
if (administrativeNames.size) reviewEnv = { ...reviewEnv, ...Object.fromEntries([...administrativeNames].map(name => [name, undefined])) };
|
|
509
|
+
}
|
|
483
510
|
if (posture === 'build' && job.kind !== 'merge_resolve' && ['claude', 'codex'].includes(rt)) {
|
|
484
511
|
try {
|
|
485
512
|
managedBrowser = await makeManagedBrowser({ wt, turnId, signal: setupController.signal, onChild: ch => {
|
|
@@ -490,6 +517,14 @@ export function createAgentTurnExecution({
|
|
|
490
517
|
} catch (error) { browserNote = `The managed browser could not start: ${envScrub(error.message)}. Report interactive browser checks unavailable.`; }
|
|
491
518
|
}
|
|
492
519
|
if (posture === 'build' && job.kind !== 'merge_resolve') {
|
|
520
|
+
try {
|
|
521
|
+
runtimeReview = await makeRuntimeReview({ wt, repoRoot: worktreeRepo, baseRef: baseRef(), sources: repositoryRun?.sources ?? [],
|
|
522
|
+
browser: managedBrowser, signal: setupController.signal, onChild: ch => {
|
|
523
|
+
workChildren.set(ch, agentId); groupKillChildren?.add(ch);
|
|
524
|
+
return () => { workChildren.delete(ch); groupKillChildren?.delete(ch); };
|
|
525
|
+
} });
|
|
526
|
+
runtimeNote = runtimeReview?.instructions ?? '';
|
|
527
|
+
} catch (error) { runtimeNote = `Configured runtime review is unavailable: ${envScrub(error.message)}. Use the repository’s existing checks and report unavailable runtime verification.`; }
|
|
493
528
|
try {
|
|
494
529
|
uiReview = await makeUiReview({ wt, signal: setupController.signal, onChild: ch => {
|
|
495
530
|
workChildren.set(ch, agentId); groupKillChildren?.add(ch);
|
|
@@ -515,7 +550,9 @@ export function createAgentTurnExecution({
|
|
|
515
550
|
notify: message => liveInput ? run.live?.inject(message) === true : false,
|
|
516
551
|
taken: id => run.live?.onTaken(id) });
|
|
517
552
|
}
|
|
518
|
-
const tools = prepareCardTools({ rt, posture, wt, repoRoot, baseRef, sources: repositoryRun?.sources ?? [], repositoryTool: repositoryRun?.tool ?? null, orchestratorConfig: orchestrator?.config ?? null, uiReviewConfig: uiReview?.config, videoConfig: videoRender?.config ?? null, browserConfig: managedBrowser?.config, orchestrated: Boolean(orchestrator), codexHome: agentResume.codexHome
|
|
553
|
+
const tools = prepareCardTools({ rt, posture, wt, repoRoot, baseRef, sources: repositoryRun?.sources ?? [], repositoryTool: repositoryRun?.tool ?? null, orchestratorConfig: orchestrator?.config ?? null, uiReviewConfig: uiReview?.config, videoConfig: videoRender?.config ?? null, browserConfig: managedBrowser?.config, orchestrated: Boolean(orchestrator), codexHome: agentResume.codexHome,
|
|
554
|
+
runtimeConfig: runtimeReview?.config,
|
|
555
|
+
...(reviewEnv ? { env: { ...process.env, ...reviewEnv } } : {}) });
|
|
519
556
|
projectTools = tools.projectTools;
|
|
520
557
|
localCleanup?.begin(turnId);
|
|
521
558
|
const { toolsNote } = tools;
|
|
@@ -794,12 +831,15 @@ export function createAgentTurnExecution({
|
|
|
794
831
|
// owned by its review tool, then bind evidence to the final source
|
|
795
832
|
// before the existing artifact upload and delivery settlement.
|
|
796
833
|
try { await videoRender?.close(); } catch { /* a closed listener */ }
|
|
834
|
+
try { await runtimeReview?.close(); } catch { /* turn teardown continues */ }
|
|
797
835
|
try { await managedBrowser?.close(); } catch { /* turn teardown continues */ }
|
|
798
836
|
try { await uiReview?.close(); }
|
|
799
837
|
finally {
|
|
800
838
|
try { uiReviewEvidence = uiReview?.finalize() ?? uiReviewEvidence; }
|
|
801
839
|
catch (error) { uiReviewEvidence = `Local UI review final source/evidence measurement unavailable: ${envScrub(error.message)}. Earlier checks do not establish the final source.`; }
|
|
802
840
|
}
|
|
841
|
+
try { runtimeEvidence = runtimeReview?.finalize() ?? ''; }
|
|
842
|
+
catch { runtimeEvidence = 'Runtime review final source could not be measured; earlier results do not verify final source.'; }
|
|
803
843
|
await run.live?.stop();
|
|
804
844
|
await orchestrator?.close(run.interrupted);
|
|
805
845
|
await brief?.stop();
|
|
@@ -910,6 +950,7 @@ export function createAgentTurnExecution({
|
|
|
910
950
|
modelExports: intermediateDelivery?.modelExports,
|
|
911
951
|
});
|
|
912
952
|
appendUiReviewEvidence(body, uiReviewEvidence);
|
|
953
|
+
appendUiReviewEvidence(body, runtimeEvidence);
|
|
913
954
|
// The agent's own account of what it did not deliver, for the pre-review.
|
|
914
955
|
if (job.kind === 'task' && job.task && body.outcome === 'delivered' && res?.gaps)
|
|
915
956
|
stashGaps(sessionMetaPath(wt, 'flowviant-agent-cards', agentId), job.task.id, res.gaps);
|
|
@@ -965,7 +1006,10 @@ export function createAgentTurnExecution({
|
|
|
965
1006
|
// hold the lock on — see `runReviewEntry`. Only after the settle that may
|
|
966
1007
|
// say delivered: the three `nothing` settles never opened it.
|
|
967
1008
|
if (final && reply?.review === true) await runReviewEntry(agentId, wt, job.agentName, undefined, job);
|
|
968
|
-
} finally {
|
|
1009
|
+
} finally {
|
|
1010
|
+
for (const release of environmentReleases) release();
|
|
1011
|
+
await repositoryRun?.tool?.close();
|
|
1012
|
+
}
|
|
969
1013
|
};
|
|
970
1014
|
|
|
971
1015
|
return { runAgentTurnInPlace };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.125.
|
|
3
|
+
"version": "0.125.30",
|
|
4
4
|
"description": "Run your own coding CLIs as build agents for Flowviant \u2014 Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|