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.
@@ -0,0 +1,112 @@
1
+ /** Database allocation is deterministic from a durable ownership receipt. */
2
+ import { randomBytes } from 'node:crypto';
3
+ import { environmentCommand } from './environmentProcess.mjs';
4
+ import { reviewedProviderCommand } from './reviewEnvironmentConfig.mjs';
5
+
6
+ const quote = value => `'${String(value).replaceAll("'", "''")}'`;
7
+ const credentialNames = recipe => recipe.database.provider === 'postgres' ? [recipe.database.adminUrlEnv] : recipe.database.credentials ?? [];
8
+ export { credentialNames as environmentCredentialNames };
9
+
10
+ export function newDatabaseReceipt(id, provider) {
11
+ return provider === 'postgres' ? { provider, name: `fv_${id}`, role: `fv_${id}`, password: randomBytes(32).toString('hex') } : { provider, owner: id };
12
+ }
13
+
14
+ function postgresEnv(value) {
15
+ let url;
16
+ try { url = new URL(value); } catch { throw new Error('The review PostgreSQL admin URL is invalid.'); }
17
+ if (!['postgres:', 'postgresql:'].includes(url.protocol) || !url.hostname || !url.username || !url.pathname.slice(1) || url.hash) throw new Error('The review PostgreSQL admin URL must name a host, user and database.');
18
+ const env = { PGHOST: url.hostname.replace(/^\[|\]$/g, ''), PGPORT: url.port || '5432', PGUSER: decodeURIComponent(url.username), PGPASSWORD: decodeURIComponent(url.password), PGDATABASE: decodeURIComponent(url.pathname.slice(1)), PGCONNECT_TIMEOUT: '15' };
19
+ const options = { sslmode: 'PGSSLMODE', sslrootcert: 'PGSSLROOTCERT', sslcert: 'PGSSLCERT', sslkey: 'PGSSLKEY', channel_binding: 'PGCHANNELBINDING', connect_timeout: 'PGCONNECT_TIMEOUT', application_name: 'PGAPPNAME' };
20
+ for (const [key, v] of url.searchParams) {
21
+ if (!Object.hasOwn(options, key)) throw new Error(`Unsupported review PostgreSQL connection option: ${key}.`);
22
+ env[options[key]] = v;
23
+ }
24
+ return env;
25
+ }
26
+
27
+ const providerEnv = local => Object.fromEntries(['PATH', 'TMPDIR', 'LANG', 'LC_ALL', 'LD_LIBRARY_PATH', 'SSL_CERT_FILE', 'SSL_CERT_DIR', 'PGSYSCONFDIR'].filter(key => local[key] != null).map(key => [key, local[key]]));
28
+ const pgCommand = async (sql, local, snapshot, options) => {
29
+ const admin = local[snapshot.recipe.database.adminUrlEnv];
30
+ if (!admin) throw new Error(`Set ${snapshot.recipe.database.adminUrlEnv} on this machine to provision review databases.`);
31
+ return environmentCommand(['psql', '-X', '-q', '-A', '-t', '-w', '-v', 'ON_ERROR_STOP=1'], { ...options, cwd: snapshot.root, timeoutMs: 30_000, label: 'Review PostgreSQL operation', env: { ...providerEnv(local), ...postgresEnv(admin) }, input: sql });
32
+ };
33
+
34
+ async function postgresState(record, local, snapshot, options) {
35
+ const db = record.database;
36
+ const answer = await pgCommand(`SELECT json_build_object(
37
+ 'role', (SELECT json_build_object('marker', shobj_description(oid, 'pg_authid'), 'superuser', rolsuper, 'createdb', rolcreatedb, 'createrole', rolcreaterole, 'replication', rolreplication, 'bypassrls', rolbypassrls, 'login', rolcanlogin, 'memberships', EXISTS(SELECT 1 FROM pg_auth_members WHERE member = pg_roles.oid)) FROM pg_roles WHERE rolname = ${quote(db.role)}),
38
+ 'database', (SELECT json_build_object('marker', shobj_description(oid, 'pg_database'), 'owner', pg_get_userbyid(datdba)) FROM pg_database WHERE datname = ${quote(db.name)}));`, local, snapshot, options);
39
+ try { return JSON.parse(answer); } catch { throw new Error('PostgreSQL did not return the review database ownership measurement.'); }
40
+ }
41
+
42
+ function ownedPostgres(state, record) {
43
+ const marker = `flowviant:${record.id}`;
44
+ const role = state.role;
45
+ if (role && (role.marker !== marker || role.superuser || role.createdb || role.createrole || role.replication || role.bypassrls || role.memberships || !role.login)) throw new Error('The review database role has unexpected ownership or privileges. It was left alone.');
46
+ if (state.database && (!role || state.database.owner !== record.database.role || (state.database.marker != null && state.database.marker !== marker))) throw new Error('The review database has unexpected ownership. It was left alone.');
47
+ }
48
+
49
+ async function allocatePostgres(record, local, snapshot, options) {
50
+ const db = record.database, marker = `flowviant:${record.id}`;
51
+ let state = await postgresState(record, local, snapshot, options);
52
+ ownedPostgres(state, record);
53
+ if (!state.role) {
54
+ await pgCommand(`BEGIN;
55
+ CREATE ROLE ${db.role} LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS PASSWORD ${quote(db.password)};
56
+ COMMENT ON ROLE ${db.role} IS ${quote(marker)};
57
+ COMMIT;`, local, snapshot, options);
58
+ }
59
+ if (!state.database) await pgCommand(`CREATE DATABASE ${db.name} WITH OWNER ${db.role} TEMPLATE template0;`, local, snapshot, options);
60
+ // Re-read after creation, also recovering a crash between CREATE DATABASE
61
+ // and COMMENT. Only a database owned by our marked, restricted role qualifies.
62
+ state = await postgresState(record, local, snapshot, options);
63
+ ownedPostgres(state, record);
64
+ if (!state.role || !state.database) throw new Error('The review database could not be measured after creation.');
65
+ await pgCommand(`COMMENT ON DATABASE ${db.name} IS ${quote(marker)};
66
+ REVOKE CONNECT ON DATABASE ${db.name} FROM PUBLIC;
67
+ GRANT CONNECT ON DATABASE ${db.name} TO ${db.role};`, local, snapshot, options);
68
+ const url = new URL(local[snapshot.recipe.database.adminUrlEnv]);
69
+ url.username = db.role; url.password = db.password; url.pathname = `/${db.name}`;
70
+ return { ...db, connectionString: url.toString() };
71
+ }
72
+
73
+ async function commandOperation(operation, record, local, snapshot, options) {
74
+ const credentials = {};
75
+ for (const name of credentialNames(snapshot.recipe)) {
76
+ if (!local[name]) throw new Error(`Set ${name} on this machine for the review database adapter.`);
77
+ credentials[name] = local[name];
78
+ }
79
+ const output = await environmentCommand([...reviewedProviderCommand(snapshot), operation], {
80
+ ...options, cwd: snapshot.root, timeoutMs: 120_000, label: `Review database ${operation}`,
81
+ env: { ...providerEnv(local), ...credentials,
82
+ FLOWVIANT_ENVIRONMENT_ID: record.id, FLOWVIANT_ENVIRONMENT_SOURCE: snapshot.recipe.database.source,
83
+ FLOWVIANT_ENVIRONMENT_RESOURCE_ID: record.database.resourceId ?? '',
84
+ FLOWVIANT_ENVIRONMENT_EXPIRES_AT: new Date(record.expiresAt).toISOString() },
85
+ input: JSON.stringify({ id: record.id, resourceId: record.database.resourceId ?? null, source: snapshot.recipe.database.source }),
86
+ });
87
+ let answer;
88
+ try { answer = JSON.parse(output); } catch { throw new Error(`The review database adapter must return JSON for ${operation}.`); }
89
+ if (answer?.owner !== record.id) throw new Error('The database adapter returned a different environment owner.');
90
+ return answer;
91
+ }
92
+
93
+ export async function allocateReviewDatabase(record, local, snapshot, options) {
94
+ if (record.database.provider === 'postgres') return allocatePostgres(record, local, snapshot, options);
95
+ const answer = await commandOperation('allocate', record, local, snapshot, options);
96
+ if (typeof answer.resourceId !== 'string' || !answer.resourceId || answer.resourceId.length > 200 || typeof answer.connectionString !== 'string' || !answer.connectionString || answer.connectionString.length > 16_384 || /[\0\r\n]/.test(answer.connectionString)) throw new Error('The database adapter must return a resourceId and connectionString.');
97
+ if (answer.bindings != null && (typeof answer.bindings !== 'object' || Array.isArray(answer.bindings) || Object.entries(answer.bindings).some(([key, value]) => !snapshot.recipe.database.bindings.includes(key) || typeof value !== 'string' || !value || value.length > 16_384 || /[\0\r\n]/.test(value)))) throw new Error('Database adapter bindings must name declared database variables with bounded connection strings.');
98
+ return { provider: 'command', owner: record.id, resourceId: answer.resourceId, connectionString: answer.connectionString, ...(answer.bindings ? { bindings: answer.bindings } : {}) };
99
+ }
100
+
101
+ export async function releaseReviewDatabase(record, local, snapshot, options) {
102
+ if (record.database.provider === 'command') {
103
+ // An interrupted allocation has no resource id. The adapter must reconcile
104
+ // by the original id, including create-success/response-lost crashes.
105
+ await commandOperation('release', record, local, snapshot, options);
106
+ return;
107
+ }
108
+ const state = await postgresState(record, local, snapshot, options);
109
+ ownedPostgres(state, record);
110
+ if (state.database) await pgCommand(`DROP DATABASE ${record.database.name} WITH (FORCE);`, local, snapshot, options);
111
+ if (state.role) await pgCommand(`DROP ROLE ${record.database.role};`, local, snapshot, options);
112
+ }
@@ -0,0 +1,153 @@
1
+ /** Linux native apps on an owned X server. Never attach to DISPLAY/WSLg. */
2
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir, platform } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { randomBytes } from 'node:crypto';
6
+ import { commandOnPath } from './reviewTargets.mjs';
7
+ import { startReviewProcess } from './reviewProcess.mjs';
8
+ import { learnExtraSecrets, forgetExtraSecrets } from './uplinkScrub.mjs';
9
+
10
+ export function desktopSupport(env = process.env) {
11
+ if (platform() !== 'linux') return { available: false, reason: 'The built-in isolated desktop needs Linux. Configure a native host bridge on this OS.' };
12
+ const tools = Object.fromEntries(['Xvfb', 'xauth', 'openbox', 'xdotool', 'import'].map(name => [name, commandOnPath(name, env)]));
13
+ const missing = Object.keys(tools).filter(name => !tools[name]);
14
+ return missing.length ? { available: false, reason: `Isolated desktop tools missing: ${missing.join(', ')}.` } : { available: true, tools };
15
+ }
16
+
17
+ export async function openDesktopPage({ command, cwd, env, timeoutMs = 600_000, signal, onChild, onFrame = () => {}, onChange = () => {}, support = desktopSupport(env), frameIntervalMs = 750, detachedChildren = true }) {
18
+ if (!support.available) throw new Error(support.reason);
19
+ signal?.throwIfAborted();
20
+ const scratch = mkdtempSync(join(tmpdir(), 'flowviant-desktop-'));
21
+ const authority = join(scratch, 'Xauthority');
22
+ const cookie = randomBytes(16).toString('hex'), secretKey = `desktop:${cookie}`;
23
+ learnExtraSecrets(secretKey, [{ name: 'XAUTHORITY_COOKIE', value: cookie }]);
24
+ writeFileSync(authority, '', { mode: 0o600 });
25
+ const runtime = join(scratch, 'run'); mkdirSync(runtime, { mode: 0o700 });
26
+ const tools = support.tools;
27
+ const privateEnv = { ...env, HOME: scratch, USERPROFILE: scratch, XDG_CONFIG_HOME: scratch, XDG_DATA_HOME: scratch,
28
+ XDG_CACHE_HOME: scratch, XDG_RUNTIME_DIR: runtime, XAUTHORITY: authority,
29
+ WAYLAND_DISPLAY: '', WAYLAND_SOCKET: undefined, DBUS_SESSION_BUS_ADDRESS: 'disabled:', BROWSER: 'none',
30
+ XDG_SESSION_TYPE: 'x11', GDK_BACKEND: 'x11', QT_QPA_PLATFORM: 'xcb', SDL_VIDEODRIVER: 'x11' };
31
+ let xserver, manager, app, display, timer, failure, closed = false, seenImage = false, privateUsed = false, epoch = 0, queue = Promise.resolve();
32
+ const controller = new AbortController();
33
+ const abort = () => close();
34
+ const processes = new Set();
35
+ const options = { cwd, env: privateEnv, signal: controller.signal, onChild, detached: detachedChildren };
36
+ const exec = async (argv, extra = {}) => {
37
+ const process = startReviewProcess(argv, { ...options, timeoutMs: 5000, ...extra });
38
+ processes.add(process);
39
+ try {
40
+ const result = await process.done;
41
+ if (result.error || result.code !== 0) throw failure ?? new Error(result.error ?? `The isolated desktop operation failed (${result.code}). ${privateUsed ? '' : result.output.slice(-800)}`);
42
+ return result;
43
+ } finally { processes.delete(process); }
44
+ };
45
+ function close() {
46
+ if (closed) return;
47
+ closed = true; epoch++; clearTimeout(timer); signal?.removeEventListener('abort', abort); controller.abort();
48
+ const children = [app, manager, xserver, ...processes].filter(Boolean);
49
+ void Promise.all(children.map(child => child.close())).finally(() => {
50
+ // A process may still have Xauthority or its runtime files open until exit.
51
+ try { rmSync(scratch, { recursive: true, force: true }); } catch { /* a failed local unlink must not crash the daemon */ }
52
+ forgetExtraSecrets(secretKey);
53
+ });
54
+ }
55
+ const fail = error => { if (!closed) { failure = error; onChange({ error: error.message }); close(); } };
56
+ const captured = () => {
57
+ const run = queue.catch(() => {}).then(async () => {
58
+ if (closed) throw new Error('The isolated desktop has ended.');
59
+ const { data } = await exec([tools.import, '-display', display, '-window', 'root', '-quality', '65', 'jpeg:-'], { display: true, binary: true, maxBytes: 750_000 });
60
+ if (data[0] !== 0xff || data[1] !== 0xd8) throw new Error('The desktop capture did not return a JPEG.');
61
+ return data.toString('base64');
62
+ });
63
+ queue = run; return run;
64
+ };
65
+ const stream = async () => {
66
+ const current = epoch;
67
+ try { const data = await captured(); if (!closed && current === epoch) onFrame({ type: 'frame', data, width: 1440, height: 900 }); }
68
+ catch (error) { if (!closed && current === epoch) fail(error); }
69
+ finally { if (!closed && current === epoch) timer = setTimeout(stream, frameIntervalMs); }
70
+ };
71
+ signal?.addEventListener('abort', abort, { once: true });
72
+ try {
73
+ // The X server reads this cookie independent of the display number. Once
74
+ // -displayfd allocates a free display atomically, add the client's entry.
75
+ await exec([tools.xauth, '-f', authority, 'source', '-'], { input: `add :0 MIT-MAGIC-COOKIE-1 ${cookie}\n` });
76
+ xserver = startReviewProcess([tools.Xvfb, '-displayfd', '3', '-screen', '0', '1440x900x24', '-nolisten', 'tcp', '-auth', authority], {
77
+ ...options, timeoutMs, stdio: ['ignore', 'pipe', 'pipe', 'pipe'],
78
+ });
79
+ display = await new Promise((resolve, reject) => {
80
+ let text = '';
81
+ const deadline = setTimeout(() => reject(new Error('The private X server did not start.')), 5000);
82
+ const finish = (error, value) => { clearTimeout(deadline); error ? reject(error) : resolve(value); };
83
+ xserver.child.stdio[3].on('data', bytes => {
84
+ text += bytes.toString();
85
+ if (text.length > 20) finish(new Error('The X server returned an invalid display.'));
86
+ else if (/^\d+\n$/.test(text)) finish(null, `:${Number(text.trim())}`);
87
+ });
88
+ xserver.done.then(result => finish(new Error(result.error ?? `The private X server exited. ${result.output.slice(-1500)}`)));
89
+ });
90
+ privateEnv.DISPLAY = display;
91
+ await exec([tools.xauth, '-f', authority, 'source', '-'], { input: `add ${display} MIT-MAGIC-COOKIE-1 ${cookie}\n` });
92
+ if (closed || signal?.aborted) throw new Error('The desktop turn has ended.');
93
+ // Start only the window manager, never a desktop session's autostart apps.
94
+ // It provides normal click-to-focus and window behavior on the private X server.
95
+ manager = startReviewProcess([tools.openbox, '--sm-disable'], { ...options, env: privateEnv, display: true, timeoutMs });
96
+ manager.done.then(result => { if (!closed) fail(new Error(result.error ?? `The private window manager exited (${result.code}). ${!seenImage && !privateUsed ? result.output.slice(-1000) : ''}`)); });
97
+ app = startReviewProcess(command, { ...options, env: privateEnv, display: true, timeoutMs });
98
+ app.done.then(result => {
99
+ if (closed) return;
100
+ if (result.code === 0 && !result.error && seenImage) { onChange({ ended: true }); close(); }
101
+ else fail(new Error(result.error ?? `The desktop app exited (${result.code}). ${!seenImage && !privateUsed ? result.output.slice(-1000) : ''}`));
102
+ });
103
+ xserver.done.then(result => { if (!closed) fail(new Error(result.error ?? 'The private X server exited.')); });
104
+ // A live X server alone is not an application preview. Wait for a mapped
105
+ // client window in this owned display and give it ordinary keyboard focus.
106
+ let window, startupError;
107
+ const startupUntil = Date.now() + 10_000;
108
+ while (Date.now() < startupUntil && !closed) {
109
+ try {
110
+ const result = await exec([tools.xdotool, 'search', '--onlyvisible', '--class', '.'], { display: true });
111
+ window = result.output.split(/\s+/).find(value => /^\d+$/.test(value));
112
+ if (window) {
113
+ await exec([tools.xdotool, 'windowactivate', '--sync', window], { display: true, timeoutMs: 1000 });
114
+ // _NET_ACTIVE_WINDOW can arrive before the window manager sets X
115
+ // input focus. Do not accept the first key into that startup gap.
116
+ await exec([tools.xdotool, 'windowfocus', '--sync', window], { display: true, timeoutMs: 1000 });
117
+ break;
118
+ }
119
+ } catch (error) { startupError = error; window = null; if (closed) break; }
120
+ await new Promise(resolve => setTimeout(resolve, 100));
121
+ }
122
+ if (!window || closed) throw failure ?? new Error(`No visible application window opened in the private desktop.${startupError ? ` ${startupError.message}` : ''}`);
123
+ timer = setTimeout(stream, frameIntervalMs);
124
+ return { kind: 'desktop', display, pid: app.child.pid, displayPid: xserver.child.pid, managerPid: manager.child.pid, close,
125
+ async setPrivate(privateInput) {
126
+ if (privateInput) privateUsed = true;
127
+ epoch++; clearTimeout(timer);
128
+ // Discard captures already queued before the handoff. A fresh capture
129
+ // begins after the drain and cannot carry a prior private frame.
130
+ await queue.catch(() => {});
131
+ if (!closed) timer = setTimeout(stream, frameIntervalMs);
132
+ },
133
+ async snapshot() {
134
+ const current = epoch;
135
+ const data = await captured();
136
+ if (current !== epoch || closed) throw new Error('Desktop control changed during capture.');
137
+ seenImage = true;
138
+ return { data, observation: 'Isolated native application. Use screen coordinates; DOM selectors are unavailable. A screenshot is an observation, not a passing gameplay or integration test.', diagnostics: [] };
139
+ },
140
+ async act(input) {
141
+ if (closed) throw new Error('The isolated desktop has ended.');
142
+ if (input.type === 'snapshot') return;
143
+ if (input.selector || ['navigate', 'retry'].includes(input.type)) throw new Error('This native desktop supports coordinate input; browser navigation and DOM selectors do not apply.');
144
+ if (input.type === 'click') await exec([tools.xdotool, 'mousemove', String(input.x), String(input.y), 'click', '1'], { display: true });
145
+ else if (input.type === 'type') await exec([tools.xdotool, 'type', '--clearmodifiers', '--delay', '0', '--file', '-'], { display: true, input: input.text });
146
+ else if (input.type === 'key') await exec([tools.xdotool, 'key', '--clearmodifiers', ({ Enter: 'Return', Backspace: 'BackSpace', ArrowUp: 'Up', ArrowDown: 'Down', ArrowLeft: 'Left', ArrowRight: 'Right' })[input.key] ?? input.key], { display: true });
147
+ else if (input.type === 'scroll' && input.deltaY !== 0) await exec([tools.xdotool, 'click', '--repeat', String(Math.max(1, Math.min(10, Math.ceil(Math.abs(input.deltaY) / 100)))), '--delay', '20', input.deltaY < 0 ? '4' : '5'], { display: true });
148
+ else if (input.type === 'scroll') return;
149
+ else throw new Error('Unsupported native input.');
150
+ },
151
+ };
152
+ } catch (error) { close(); throw error; }
153
+ }
@@ -0,0 +1,286 @@
1
+ /** Durable per-worktree test environments, independent of the model/runner. */
2
+ import { appendFileSync, chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { randomBytes } from 'node:crypto';
5
+ import { git } from './git.mjs';
6
+ import { environmentCommand } from './environmentProcess.mjs';
7
+ import { environmentHash, readEnvironmentLocal, readEnvironmentRecipe, repositoryPath, validateEnvironmentConnectors } from './reviewEnvironmentConfig.mjs';
8
+ import { allocateReviewDatabase, environmentCredentialNames, newDatabaseReceipt, releaseReviewDatabase } from './reviewDatabase.mjs';
9
+ import { learnExtraSecrets, forgetExtraSecrets } from './uplinkScrub.mjs';
10
+ import { heldToolArgv } from './heldTool.mjs';
11
+ import { workLanesStopped } from './standDownGate.mjs';
12
+
13
+ const quiet = { stdio: ['ignore', 'pipe', 'ignore'] };
14
+ const ID = /^[0-9a-f]{32}$/;
15
+ function ownDirectory(path) {
16
+ mkdirSync(path, { recursive: true, mode: 0o700 });
17
+ if (!lstatSync(path).isDirectory() || lstatSync(path).isSymbolicLink()) throw new Error('The local environment store must be a real directory.');
18
+ chmodSync(path, 0o700);
19
+ return path;
20
+ }
21
+ function storeFor(root) {
22
+ const common = realpathSync(resolve(root, git(['rev-parse', '--git-common-dir'], root, quiet)));
23
+ return ownDirectory(join(common, 'flowviant-environments'));
24
+ }
25
+ function pathsFor(root, wt) {
26
+ const store = storeFor(root);
27
+ if (storeFor(wt) !== store || realpathSync(root) === realpathSync(wt)) throw new Error('Review environments need a separate worktree of this repository.');
28
+ const marker = resolve(wt, git(['rev-parse', '--git-path', 'flowviant-environment'], wt, quiet));
29
+ const key = environmentHash(dirname(marker));
30
+ return { store, marker, lock: join(store, `${key}.lock`) };
31
+ }
32
+ const save = (path, value) => {
33
+ const temp = `${path}.${randomBytes(8).toString('hex')}.tmp`;
34
+ writeFileSync(temp, JSON.stringify(value), { mode: 0o600, flag: 'wx' });
35
+ renameSync(temp, path);
36
+ };
37
+ const alive = pid => { try { process.kill(pid, 0); return true; } catch (e) { return e.code !== 'ESRCH'; } };
38
+ async function locked(path, action) {
39
+ try { mkdirSync(path, { mode: 0o700 }); }
40
+ catch (error) {
41
+ if (error.code !== 'EEXIST') throw error;
42
+ let owner;
43
+ try { owner = JSON.parse(readFileSync(join(path, 'owner'), 'utf8')); } catch { /* a creator may still be writing */ }
44
+ if ((owner && Number.isInteger(owner.pid) && owner.pid > 1 && !alive(owner.pid)) || (!owner && Date.now() - statSync(path).mtimeMs > 60_000)) rmSync(path, { recursive: true });
45
+ else throw new Error('This worktree environment is being prepared or cleaned up. Retry after that operation finishes.');
46
+ mkdirSync(path, { mode: 0o700 });
47
+ }
48
+ try { save(join(path, 'owner'), { pid: process.pid }); return await action(); }
49
+ finally { rmSync(path, { recursive: true, force: true }); }
50
+ }
51
+ function loadRecord(path, root) {
52
+ const record = JSON.parse(readFileSync(path, 'utf8'));
53
+ if (record.version !== 1 || !ID.test(record.id) || record.snapshot.root !== realpathSync(root) ||
54
+ !['postgres', 'command'].includes(record.database.provider) ||
55
+ (record.database.provider === 'postgres' && (record.database.name !== `fv_${record.id}` || record.database.role !== `fv_${record.id}`))) throw new Error('The local environment ownership receipt is invalid.');
56
+ return record;
57
+ }
58
+ function teach(record, local) {
59
+ const values = { ...record.env, password: record.database.password, connectionString: record.database.connectionString };
60
+ learnExtraSecrets(`environment:${record.id}:runtime`, Object.entries(values).filter(([, value]) => typeof value === 'string').map(([name, value]) => ({ name, value })));
61
+ const provider = environmentCredentialNames(record.snapshot.recipe).filter(name => typeof local[name] === 'string').map(name => ({ name, value: local[name] }));
62
+ // A later runtime-only read must not forget the administrator credential
63
+ // learned while provisioning (or a still-running provider's diagnostics).
64
+ if (provider.length) learnExtraSecrets(`environment:${record.id}:provider`, provider);
65
+ }
66
+
67
+ function runtimeBindings(record, local) {
68
+ const recipe = record.snapshot.recipe, credentials = new Set(environmentCredentialNames(recipe));
69
+ // Only explicitly selected local runtime values go into files/receipts.
70
+ let selected = {};
71
+ const file = repositoryPath(record.snapshot.root, '.flowviant/environment.local.json');
72
+ if (existsSync(file)) selected = JSON.parse(readFileSync(file, 'utf8')).env;
73
+ const env = { ...Object.fromEntries(Object.entries(recipe.env ?? {}).map(([key, value]) => [key, value.replaceAll('${ENVIRONMENT_ID}', record.id)])), ...selected };
74
+ for (const key of credentials) delete env[key];
75
+ for (const key of recipe.database.bindings) env[key] = record.database.bindings?.[key] ?? record.database.connectionString;
76
+ // Mask the administrative credential in inherited CLI/check environments.
77
+ // undefined removes it at spawn; no provider credential is given to hooks.
78
+ const childEnv = { ...local, ...env };
79
+ for (const key of credentials) childEnv[key] = undefined;
80
+ return { env, childEnv };
81
+ }
82
+
83
+ function envText(env) {
84
+ return Object.entries(env).map(([key, value]) => {
85
+ // Real quoted multiline values work in dotenv and Node's env-file reader.
86
+ // Complex values can always use the command wrapper with envFiles: [].
87
+ const quote = !value.includes("'") ? "'" : !value.includes('"') && !value.includes('\\') ? '"' : null;
88
+ if (!quote) throw new Error(`Cannot represent ${key} in a portable .env file. Use envFiles: [] and the environment command wrapper.`);
89
+ return `${key}=${quote}${value}${quote}`;
90
+ }).join('\n') + '\n';
91
+ }
92
+ function writeBindings(record, persist) {
93
+ if (!record.snapshot.recipe.envFiles.length) return;
94
+ const text = envText(record.env), hash = environmentHash(text);
95
+ record.files ??= {};
96
+ record.previousFiles ??= {}; record.pendingFiles ??= {};
97
+ // Reconcile a crash during a previous atomic replacement before starting
98
+ // another. Only receipt-owned bytes may be removed.
99
+ for (const [rel, expected] of Object.entries(record.pendingFiles)) {
100
+ const file = repositoryPath(record.wt, rel);
101
+ if (existsSync(file)) {
102
+ if (!lstatSync(file).isFile() || environmentHash(readFileSync(file)) !== expected) throw new Error('A pending generated environment file was edited. It was left alone.');
103
+ rmSync(file);
104
+ }
105
+ delete record.pendingFiles[rel];
106
+ }
107
+ persist();
108
+ for (const rel of record.snapshot.recipe.envFiles) {
109
+ const path = repositoryPath(record.wt, rel);
110
+ const tracked = git(['ls-files', '--', rel], record.wt, quiet);
111
+ if (tracked) throw new Error(`Generated environment file ${rel} is tracked. Choose an ignored path.`);
112
+ let current;
113
+ if (existsSync(path)) {
114
+ if (!lstatSync(path).isFile()) throw new Error(`Environment file ${rel} is not a regular file.`);
115
+ current = environmentHash(readFileSync(path));
116
+ if (current !== record.files[rel] && current !== record.previousFiles[rel]) throw new Error(`Environment file ${rel} already exists or was edited. Choose a different envFiles path; Flowviant will not overwrite it.`);
117
+ if (current === hash) { chmodSync(path, 0o600); delete record.previousFiles[rel]; persist(); continue; }
118
+ }
119
+ const tempRel = `${rel}.${randomBytes(8).toString('hex')}.flowviant-tmp`;
120
+ record.previousFiles[rel] = current;
121
+ record.files[rel] = hash; record.pendingFiles[tempRel] = hash; persist();
122
+ const exclude = resolve(record.wt, git(['rev-parse', '--git-path', 'info/exclude'], record.wt, quiet));
123
+ mkdirSync(dirname(exclude), { recursive: true });
124
+ const escaped = value => value.replace(/[\\ !#*?\[\]]/g, char => '\\' + char);
125
+ appendFileSync(exclude, `\n/${escaped(rel)}\n/${escaped(tempRel)}\n`);
126
+ mkdirSync(dirname(path), { recursive: true });
127
+ const temp = repositoryPath(record.wt, tempRel);
128
+ writeFileSync(temp, text, { flag: 'wx', mode: 0o600 }); renameSync(temp, path);
129
+ delete record.pendingFiles[tempRel]; delete record.previousFiles[rel]; persist();
130
+ }
131
+ }
132
+
133
+ export function readWorktreeEnvironment(wt, callerEnv = process.env, { allowPending = false } = {}) {
134
+ const marker = resolve(wt, git(['rev-parse', '--git-path', 'flowviant-environment'], wt, quiet));
135
+ if (!existsSync(marker)) return null;
136
+ const id = readFileSync(marker, 'utf8').trim();
137
+ if (!ID.test(id)) throw new Error('The worktree environment marker is invalid.');
138
+ const store = storeFor(wt), path = join(store, `${id}.json`);
139
+ const raw = JSON.parse(readFileSync(path, 'utf8'));
140
+ const record = loadRecord(path, raw.snapshot.root);
141
+ if (record.wt !== realpathSync(wt) || record.marker !== marker || (!allowPending && record.phase !== 'ready')) throw new Error('This worktree environment is not ready.');
142
+ const env = { ...callerEnv, ...record.env };
143
+ for (const key of environmentCredentialNames(record.snapshot.recipe)) env[key] = undefined;
144
+ teach(record, record.env);
145
+ return { record, env, path, snapshot: record.snapshot };
146
+ }
147
+
148
+ /** The recipe and credentials come from the checkout; migrations/fixtures run
149
+ * against current worktree source. Hooks must be idempotent on partial failure. */
150
+ export async function prepareWorktreeEnvironment(root, wt, { snapshot = readEnvironmentRecipe(root), install = null, env = process.env, now = Date.now, reseed = false, ...options } = {}) {
151
+ if (!snapshot) return null;
152
+ validateEnvironmentConnectors(snapshot);
153
+ wt = realpathSync(wt);
154
+ const paths = pathsFor(root, wt);
155
+ if (existsSync(paths.marker)) {
156
+ const previousId = readFileSync(paths.marker, 'utf8').trim();
157
+ if (!ID.test(previousId)) throw new Error('The worktree environment marker is invalid.');
158
+ const previous = loadRecord(join(paths.store, `${previousId}.json`), root);
159
+ // A reviewed recipe change deliberately rebuilds disposable data. Current
160
+ // turns retain their lease and cannot have their database replaced.
161
+ if (previous.snapshot.hash !== snapshot.hash) await releaseWorktreeEnvironment(root, previousId, { ...options, env, now });
162
+ }
163
+ return locked(paths.lock, async () => {
164
+ const local = readEnvironmentLocal(root, env);
165
+ let record;
166
+ if (existsSync(paths.marker)) {
167
+ const id = readFileSync(paths.marker, 'utf8').trim();
168
+ if (!ID.test(id)) throw new Error('The worktree environment marker is invalid.');
169
+ record = loadRecord(join(paths.store, `${id}.json`), root);
170
+ if (record.marker !== paths.marker) throw new Error('The environment belongs to another worktree.');
171
+ // git worktree move preserves its private git dir and marker. Its data
172
+ // lease follows that identity, rather than allocating another database.
173
+ record.wt = wt;
174
+ if (record.snapshot.hash !== snapshot.hash) throw new Error('The reviewed environment recipe changed during preparation. Retry.');
175
+ } else {
176
+ const id = randomBytes(16).toString('hex');
177
+ record = { version: 1, id, wt, marker: paths.marker, lock: paths.lock, snapshot, phase: 'allocating', createdAt: now(), expiresAt: now() + snapshot.recipe.ttlHours * 3_600_000, database: newDatabaseReceipt(id, snapshot.recipe.database.provider), files: {} };
178
+ // Intent precedes every provider call; its id survives timeouts/restarts.
179
+ save(join(paths.store, `${id}.json`), record);
180
+ writeFileSync(paths.marker, id, { flag: 'wx', mode: 0o600 });
181
+ }
182
+ const path = join(paths.store, `${record.id}.json`);
183
+ const persist = () => save(path, record);
184
+ record.expiresAt = now() + snapshot.recipe.ttlHours * 3_600_000;
185
+ record.phase = 'allocating'; persist(); teach(record, local);
186
+ record.database = await allocateReviewDatabase(record, local, snapshot, options);
187
+ const bindings = runtimeBindings(record, local);
188
+ record.env = bindings.env;
189
+ persist(); teach(record, local);
190
+ writeBindings(record, persist);
191
+ if (!record.installed) {
192
+ record.phase = 'installing'; persist();
193
+ if (install) await install(bindings.childEnv);
194
+ record.installed = true; persist();
195
+ }
196
+ record.phase = 'migrating'; persist();
197
+ await environmentCommand(snapshot.recipe.migrate, { ...options, cwd: wt, env: bindings.childEnv, label: 'Review database migration', captureOutput: false, includeDiagnostics: true });
198
+ record.migrationHead = git(['rev-parse', 'HEAD'], wt, quiet);
199
+ if (!record.seeded || reseed) {
200
+ record.phase = 'seeding'; record.seeded = false; persist();
201
+ await environmentCommand(snapshot.recipe.seed, { ...options, cwd: wt, env: bindings.childEnv, label: 'Review database fixtures', captureOutput: false, includeDiagnostics: true });
202
+ record.seeded = true;
203
+ }
204
+ record.phase = 'ready'; persist();
205
+ return readWorktreeEnvironment(wt, env);
206
+ });
207
+ }
208
+
209
+ /** Held through CLI continuations and final checks. The heartbeat expires
210
+ * after a crashed daemon, rather than preserving a resource forever by pid. */
211
+ export function holdWorktreeEnvironment(environment) {
212
+ const { record, path } = environment;
213
+ const lease = `${path}.${randomBytes(8).toString('hex')}.active`;
214
+ const beat = () => save(lease, { pid: process.pid, at: Date.now() });
215
+ beat(); const timer = setInterval(() => { try { beat(); } catch { /* a removed store must not crash the daemon */ } }, 30_000); timer.unref?.();
216
+ return () => { clearInterval(timer); rmSync(lease, { force: true }); };
217
+ }
218
+ function activeEnvironment(store, id, now) {
219
+ return readdirSync(store).some(file => {
220
+ if (!file.startsWith(`${id}.json.`) || !file.endsWith('.active')) return false;
221
+ try { const lease = JSON.parse(readFileSync(join(store, file), 'utf8')); return now - lease.at < 90_000 && Number.isInteger(lease.pid) && lease.pid > 1 && alive(lease.pid); }
222
+ catch { return true; } // unknown ownership cannot justify deletion
223
+ });
224
+ }
225
+
226
+ function registeredEnvironmentWorktree(record) {
227
+ try {
228
+ // git updates this backlink on worktree move. Reading the two tiny files
229
+ // avoids a git process for every retained environment on every sweep.
230
+ const gitfile = readFileSync(join(dirname(record.marker), 'gitdir'), 'utf8').trim();
231
+ if (!lstatSync(gitfile).isFile() || lstatSync(gitfile).isSymbolicLink()) return null;
232
+ const wt = dirname(gitfile), link = readFileSync(gitfile, 'utf8').trim();
233
+ if (!link.startsWith('gitdir: ')) return null;
234
+ return join(resolve(wt, link.slice(8)), 'flowviant-environment') === record.marker ? wt : null;
235
+ } catch { return null; }
236
+ }
237
+
238
+ export async function releaseWorktreeEnvironment(root, id, { now = Date.now, ...options } = {}) {
239
+ if (!ID.test(id)) throw new Error('Invalid environment id.');
240
+ const store = storeFor(root), path = join(store, `${id}.json`);
241
+ const record = loadRecord(path, root);
242
+ return locked(record.lock, async () => {
243
+ if (activeEnvironment(store, id, now())) throw new Error('This environment is in use by a live turn.');
244
+ record.wt = registeredEnvironmentWorktree(record) ?? record.wt;
245
+ const local = readEnvironmentLocal(root, options.env ?? process.env);
246
+ teach(record, local);
247
+ await releaseReviewDatabase(record, local, record.snapshot, options);
248
+ for (const [rel, hash] of Object.entries({ ...record.files, ...record.pendingFiles })) {
249
+ const file = repositoryPath(record.wt, rel);
250
+ if (existsSync(file) && lstatSync(file).isFile() && [hash, record.previousFiles?.[rel]].includes(environmentHash(readFileSync(file)))) rmSync(file);
251
+ }
252
+ if (existsSync(record.marker) && readFileSync(record.marker, 'utf8').trim() === id) rmSync(record.marker);
253
+ for (const file of readdirSync(store)) if (file.startsWith(`${id}.json.`) && file.endsWith('.active')) rmSync(join(store, file));
254
+ rmSync(path); forgetExtraSecrets(`environment:${id}:runtime`); forgetExtraSecrets(`environment:${id}:provider`);
255
+ });
256
+ }
257
+
258
+ /** Bounded reconciliation retains receipts after provider/ownership failures.
259
+ * No database is selected for deletion by its name alone. */
260
+ export async function sweepWorktreeEnvironments(root, { now = Date.now, limit = 2, ...options } = {}) {
261
+ const store = storeFor(root), result = { released: [], errors: [] };
262
+ for (const file of readdirSync(store).filter(file => /^[0-9a-f]{32}\.json$/.test(file))) {
263
+ if (workLanesStopped() || result.released.length + result.errors.length >= limit) break;
264
+ try {
265
+ const record = loadRecord(join(store, file), root);
266
+ if (activeEnvironment(store, record.id, now())) continue;
267
+ const present = existsSync(record.marker) && readFileSync(record.marker, 'utf8').trim() === record.id && registeredEnvironmentWorktree(record) !== null;
268
+ if (present && record.expiresAt > now()) continue;
269
+ await releaseWorktreeEnvironment(root, record.id, { ...options, now });
270
+ result.released.push(record.id);
271
+ } catch (error) { result.errors.push({ id: file.slice(0, 32), error: error.message }); }
272
+ }
273
+ return result;
274
+ }
275
+
276
+ export function environmentInstructions(environment) {
277
+ const { record } = environment;
278
+ // Exact argv is represented as JSON for the agent to shell-quote, with no
279
+ // connection string, credential, or model-invented setup step in the prompt.
280
+ const argv = [process.execPath, ...heldToolArgv('__environment'), record.wt];
281
+ return `Flowviant prepared disposable environment ${record.id} (${record.database.provider}) for ${record.wt}. Its migrations completed and fixtures were seeded; these are setup facts, not passing application tests. Run app/test commands through this argv prefix, appending ["run", "--", ...commandArgv]: ${JSON.stringify(argv)}. This selects this repository's scoped values even in a multi-repository place. After editing migrations, use the same prefix with ["refresh"] before testing. After editing fixtures, use ["refresh", "--seed"] to rerun the retry-safe seeder. Ordinary refresh retries incomplete seeding, but never resets successfully seeded data. Generated files: ${record.snapshot.recipe.envFiles.join(', ') || 'none'}. The configured project MCP connectors expand the same database bindings. Personal/unlisted connectors and external services are not isolated by this database. Use test identities and sandbox or namespaced services. Never copy shared dev/prod database credentials into this worktree.`;
282
+ }
283
+
284
+ export function environmentOverlay(environment) {
285
+ return { ...environment.record.env, ...Object.fromEntries(environmentCredentialNames(environment.snapshot.recipe).map(key => [key, undefined])) };
286
+ }