flowviant 0.125.23 → 0.125.25
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 +14 -0
- package/bin/lib/artifactReporter.mjs +8 -4
- package/bin/lib/intermediateArtifacts.mjs +37 -12
- package/bin/lib/runTurn.mjs +1 -0
- package/bin/lib/runtimeCodex.mjs +8 -6
- package/bin/lib/turnLock.mjs +14 -1
- package/bin/lib/uiReview.mjs +1 -1
- package/bin/lib/uiReviewArtifacts.mjs +30 -0
- package/bin/lib/uiReviewBrowser.mjs +15 -3
- package/bin/lib/work.mjs +1 -0
- package/bin/lib/workAgentTurnBegun.mjs +6 -2
- package/bin/lib/workAgentTurnExecution.mjs +19 -2
- package/bin/lib/workPlaces.mjs +13 -2
- package/bin/lib/workRetire.mjs +5 -0
- package/bin/lib/worktreeHealth.mjs +23 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -328,6 +328,20 @@ pinned because [npm 12 removed shrinkwrap support](https://github.com/npm/cli/bl
|
|
|
328
328
|
and omits it from tarballs; npm 12 consumers also ignore that lockfile.
|
|
329
329
|
No sibling repository or separate app deploy key is required.
|
|
330
330
|
|
|
331
|
+
### Agent worktree startup and restart protection (0.125.24)
|
|
332
|
+
|
|
333
|
+
Card launches verify their exact Git worktree and repository before starting,
|
|
334
|
+
including after tool preparation. Fresh empty leftover directories are
|
|
335
|
+
initialized; nonempty invalid directories are preserved. Verified Codex
|
|
336
|
+
launches use an explicit cwd and the daemon's repository checks. Agent turns,
|
|
337
|
+
retirement and manual fast-forwarding honor live CLI PID/start-time receipts
|
|
338
|
+
across daemon restarts. Existing begun work still needs its worktree, surviving
|
|
339
|
+
branch or published recovery copy; restarting the daemon alone does not reset
|
|
340
|
+
a stuck board agent. No new wire floor or database migration is required.
|
|
341
|
+
Published on npm and the standalone download channel on 2026-10-26. Running
|
|
342
|
+
daemons receive the version signal from the app and update when idle, unless
|
|
343
|
+
automatic updates are disabled.
|
|
344
|
+
|
|
331
345
|
### Staged artifact handoff revisions (0.125.18)
|
|
332
346
|
|
|
333
347
|
A failed handoff's retry drops every pending source the agent's
|
|
@@ -297,7 +297,7 @@ export function createArtifactReporter({
|
|
|
297
297
|
|
|
298
298
|
/** Upload what this turn changed in `placeDir`. Resolves to the number of
|
|
299
299
|
* bodies SENT this call (delivered or held), for the tests and the log. */
|
|
300
|
-
const report = async ({ placeDir, before, sessionId, agentId, turnId, idle = false, requireStored = false, includeNames = null, requiredOutputs = null, referenceCapture = false }) => {
|
|
300
|
+
const report = async ({ placeDir, before, sessionId, agentId, turnId, idle = false, requireStored = false, includeNames = null, requiredOutputs = null, evidenceNames = [], referenceCapture = false }) => {
|
|
301
301
|
if (!placeDir || (!sessionId && !agentId)) return 0;
|
|
302
302
|
const changes = iterationChanges(placeDir, before);
|
|
303
303
|
const protectedNames = imageRevisionProtection(before?.stored, before?.imageRevision);
|
|
@@ -334,9 +334,13 @@ export function createArtifactReporter({
|
|
|
334
334
|
// discovery window. Re-walk safely, then verify the built bytes against
|
|
335
335
|
// the prepared version before sending anything to storage.
|
|
336
336
|
const discovered = idle ? [] : listChanged(placeDir, before);
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
337
|
+
// Verification evidence is explicitly selected too, but never becomes a
|
|
338
|
+
// required producer input. Multiple review attempts can exceed the normal
|
|
339
|
+
// discovery window; retain their reports and captures on the same turn.
|
|
340
|
+
const selected = new Set([...required.keys(), ...evidenceNames]);
|
|
341
|
+
const entries = selected.size
|
|
342
|
+
? [...scanArtifacts(placeDir, { all: true, includeNames: selected }),
|
|
343
|
+
...discovered.filter(e => !selected.has(e.name) && (!required.size || !artifactTypeFor(e.name)))]
|
|
340
344
|
: discovered;
|
|
341
345
|
const changed = idle ? [] : entries.filter((e) =>
|
|
342
346
|
!removing.has(artifactStoredName(e.name)) && (!includeNames || includeNames.has(e.name)));
|
|
@@ -11,6 +11,7 @@ import { ensureRealSubdir, realDirState } from './ownedDirs.mjs';
|
|
|
11
11
|
import { scrub, secretIn } from './uplinkScrub.mjs';
|
|
12
12
|
import { exportModelPage } from './modelExport.mjs';
|
|
13
13
|
import { usableModelGlb } from './modelExportProtocol.mjs';
|
|
14
|
+
import { uiReviewEvidenceNames } from './uiReviewArtifacts.mjs';
|
|
14
15
|
|
|
15
16
|
const hash = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
16
17
|
const safeName = name => typeof name === 'string' && name.length <= 400 &&
|
|
@@ -18,6 +19,8 @@ const safeName = name => typeof name === 'string' && name.length <= 400 &&
|
|
|
18
19
|
const copyPath = (sha256, name) => `.flowviant/artifacts/.handoff/${sha256}/${name}`;
|
|
19
20
|
const taskOf = job => job.task?.id ?? job.repositoryTaskId ?? null;
|
|
20
21
|
|
|
22
|
+
export class IntermediateArtifactCapacityError extends Error {}
|
|
23
|
+
|
|
21
24
|
export function readIntermediateFile(wt, relative, max = ARTIFACT_BINARY_MODEL_MAX_BYTES) {
|
|
22
25
|
if (!safeName(relative) || !relative.startsWith('.flowviant/artifacts/')) throw new Error('Unsafe intermediate artifact path.');
|
|
23
26
|
const parts = dirname(relative).split('/');
|
|
@@ -99,9 +102,22 @@ export function createIntermediateArtifacts({ wt, agentId, path, exportPage = ex
|
|
|
99
102
|
let state;
|
|
100
103
|
try { state = JSON.parse(readFileSync(path, 'utf8')); }
|
|
101
104
|
catch (error) { if (error.code === 'ENOENT') return { version: 1, agentId, files: [], pending: null }; throw new Error('The intermediate artifact receipt could not be read. Restore it before retrying.'); }
|
|
102
|
-
if (state.version !== 1 || state.agentId !== agentId || !Array.isArray(state.files) || state.files.length >
|
|
105
|
+
if (state.version !== 1 || state.agentId !== agentId || !Array.isArray(state.files) || state.files.length > ARTIFACT_MAX_PER_AGENT ||
|
|
103
106
|
state.files.some(f => !safeName(f.name) || f.path !== copyPath(f.sha256, f.name) || !/^[a-f0-9]{64}$/.test(f.sha256)))
|
|
104
107
|
throw new Error('The intermediate artifact receipt has invalid identity or paths.');
|
|
108
|
+
// Older daemons put verification reports and captures in this producer
|
|
109
|
+
// receipt. Migrate the references, never delete the original evidence.
|
|
110
|
+
const evidence = uiReviewEvidenceNames(state.files, ref => {
|
|
111
|
+
let bytes;
|
|
112
|
+
try { bytes = readIntermediateFile(wt, ref.path, artifactMaxBytesFor(ref.name)); }
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (error.code !== 'ENOENT' || realDirState(privateFiles) !== 'dir') throw error;
|
|
115
|
+
bytes = readRegular(join(privateFiles, ref.sha256), artifactMaxBytesFor(ref.name));
|
|
116
|
+
}
|
|
117
|
+
if (hash(bytes) !== ref.sha256) throw new Error('Review receipt hash mismatch');
|
|
118
|
+
return bytes;
|
|
119
|
+
});
|
|
120
|
+
state.files = state.files.filter(ref => !evidence.has(ref.name));
|
|
105
121
|
return state;
|
|
106
122
|
};
|
|
107
123
|
const save = state => {
|
|
@@ -145,7 +161,12 @@ export function createIntermediateArtifacts({ wt, agentId, path, exportPage = ex
|
|
|
145
161
|
const state = load(), taskId = taskOf(job);
|
|
146
162
|
const identity = { agentId, taskId, turnId, taskKind };
|
|
147
163
|
const standing = scanArtifacts(wt, { all: true });
|
|
148
|
-
|
|
164
|
+
const evidence = uiReviewEvidenceNames(standing, entry => readIntermediateFile(wt, `.flowviant/artifacts/${entry.name}`, artifactMaxBytesFor(entry.name)));
|
|
165
|
+
const changed = changedArtifacts(before, standing);
|
|
166
|
+
const evidenceNames = [...new Set([...changed.map(entry => entry.name), ...(state.pending?.sources ?? []).map(ref => ref.name)].filter(name => evidence.has(name)))];
|
|
167
|
+
let producerChanges = changed.filter(entry => !evidence.has(entry.name));
|
|
168
|
+
if (state.pending && !state.pending.recovery)
|
|
169
|
+
state.pending.sources = state.pending.sources.filter(ref => !evidence.has(ref.name));
|
|
149
170
|
// Failed export/storage retries are tied to a card and exact source hash.
|
|
150
171
|
// A changed page is a new version; it never reuses a previous GLB receipt.
|
|
151
172
|
// A source the agent's iteration declaration discards or replaces is the
|
|
@@ -155,11 +176,11 @@ export function createIntermediateArtifacts({ wt, agentId, path, exportPage = ex
|
|
|
155
176
|
throw new Error('An exact producer recovery is pending. Retry that producer before starting different work.');
|
|
156
177
|
if (pending?.recovery) {
|
|
157
178
|
identity.recoveredFromTurnId = pending.recoverySourceTurnId ?? pending.turnId;
|
|
158
|
-
|
|
179
|
+
producerChanges = producerChanges.filter(e => pending.sources.some(s => s.name === e.name));
|
|
159
180
|
}
|
|
160
181
|
const discarded = pending ? declaredDiscards(wt) : new Set();
|
|
161
182
|
if (pending) for (const ref of pending.sources) {
|
|
162
|
-
if (!pending.recovery && (
|
|
183
|
+
if (!pending.recovery && (producerChanges.some(e => e.name === ref.name) || discarded.has(artifactStoredName(ref.name)))) continue;
|
|
163
184
|
const entry = standing.find(e => e.name === ref.name);
|
|
164
185
|
let bytes;
|
|
165
186
|
try { bytes = readIntermediateFile(wt, `.flowviant/artifacts/${ref.name}`, artifactMaxBytesFor(ref.name)); }
|
|
@@ -167,16 +188,16 @@ export function createIntermediateArtifacts({ wt, agentId, path, exportPage = ex
|
|
|
167
188
|
if (hash(bytes) !== ref.sha256)
|
|
168
189
|
throw new Error(`Required intermediate output ${ref.name} is missing or changed since the failed handoff. Restore it or revise the producer explicitly, then retry.`);
|
|
169
190
|
if (!entry) throw new Error(`Required intermediate output ${ref.name} is outside the safe artifact walk (non-hidden paths with at most ${ARTIFACT_MAX_DEPTH} directories). Move it to a supported artifact path and retry this producer.`);
|
|
170
|
-
if (!
|
|
191
|
+
if (!producerChanges.some(e => e.name === entry.name)) producerChanges.push(entry);
|
|
171
192
|
}
|
|
172
|
-
if (taskKind === 'model' && !
|
|
193
|
+
if (taskKind === 'model' && !producerChanges.some(e => /\.html?$/i.test(e.name)) && (job.kind !== 'task' || job.redo === true) && taskId) {
|
|
173
194
|
for (const ref of state.files.filter(f => f.taskId === taskId && f.taskKind === 'model' && /\.html?$/i.test(f.name))) {
|
|
174
195
|
const entry = standing.find(e => e.name === ref.name);
|
|
175
|
-
if (entry)
|
|
196
|
+
if (entry) producerChanges.push(entry);
|
|
176
197
|
}
|
|
177
198
|
}
|
|
178
|
-
const pages = taskKind === 'model' ?
|
|
179
|
-
const outputs =
|
|
199
|
+
const pages = taskKind === 'model' ? producerChanges.filter(e => /\.html?$/i.test(e.name)) : [];
|
|
200
|
+
const outputs = producerChanges.filter(e => artifactTypeFor(e.name));
|
|
180
201
|
state.pending = { taskId, turnId, ...(pending?.recovery ? { recovery: true, recoverySourceTurnId: identity.recoveredFromTurnId } : {}), sources: outputs.map(e => ({ name: e.name, sha256: hash(readIntermediateFile(wt, `.flowviant/artifacts/${e.name}`, artifactMaxBytesFor(e.name))) })), exports: pending?.exports ?? [] };
|
|
181
202
|
save(state);
|
|
182
203
|
const exports = [];
|
|
@@ -207,7 +228,12 @@ export function createIntermediateArtifacts({ wt, agentId, path, exportPage = ex
|
|
|
207
228
|
// would prune one of this very handoff's files and then falsely claim
|
|
208
229
|
// complete delivery.
|
|
209
230
|
if (outputs.length > ARTIFACT_MAX_PER_AGENT)
|
|
210
|
-
throw new
|
|
231
|
+
throw new IntermediateArtifactCapacityError(`This producer requires ${outputs.length} output files including model exports; artifact storage retains at most ${ARTIFACT_MAX_PER_AGENT} files per agent. Narrow this producer's output set before retrying; its handoff is unfinished.`);
|
|
232
|
+
const retainedNames = new Set(state.files.map(ref => ref.name));
|
|
233
|
+
const newNames = outputs.filter(entry => !retainedNames.has(entry.name));
|
|
234
|
+
const total = retainedNames.size + newNames.length;
|
|
235
|
+
if (total > ARTIFACT_MAX_PER_AGENT)
|
|
236
|
+
throw new IntermediateArtifactCapacityError(`The intermediate artifact receipt has ${retainedNames.size} retained producer files plus ${newNames.length} new files (${total} total); artifact storage retains at most ${ARTIFACT_MAX_PER_AGENT} files per agent. Narrow the producer outputs before retrying; its handoff is unfinished.`);
|
|
211
237
|
const textures = [...state.files.flatMap(f => [...(/\.png$/i.test(f.name) ? [f] : []), ...(f.model?.textureInputs ?? [])]),
|
|
212
238
|
...outputs.filter(e => /\.png$/i.test(e.name)).map(e => snapshot(wt, e, identity, retain))]
|
|
213
239
|
.map(({ name, path, sha256, taskId, turnId, agentId, taskKind }) => ({ name, path, sha256, taskId, turnId, agentId, taskKind }));
|
|
@@ -225,10 +251,9 @@ export function createIntermediateArtifacts({ wt, agentId, path, exportPage = ex
|
|
|
225
251
|
// Retry all prepared files even if their mtime did not change this turn.
|
|
226
252
|
const uploadBefore = Object.assign(new Map(before), before);
|
|
227
253
|
for (const ref of files) uploadBefore.delete(ref.name);
|
|
228
|
-
return { modelExports: exports, requiredOutputs: files.map(({ name, sha256 }) => ({ name, sha256 })), uploadBefore, commit: () => {
|
|
254
|
+
return { modelExports: exports, requiredOutputs: files.map(({ name, sha256 }) => ({ name, sha256 })), evidenceNames, uploadBefore, commit: () => {
|
|
229
255
|
const byName = new Map(state.files.map(f => [f.name, f]));
|
|
230
256
|
for (const ref of files) byName.set(ref.name, ref);
|
|
231
|
-
if (byName.size > 160) throw new Error('The intermediate artifact receipt exceeds 160 files. Narrow the outputs before retrying.');
|
|
232
257
|
save({ ...state, files: [...byName.values()], pending: null });
|
|
233
258
|
} };
|
|
234
259
|
};
|
package/bin/lib/runTurn.mjs
CHANGED
|
@@ -204,6 +204,7 @@ function runPlainTurn(opts) {
|
|
|
204
204
|
const stdinText = rt.stdin?.({ prompt, system, profile: turnProfile.adapterProfile }) ?? null;
|
|
205
205
|
const args = rt.args({
|
|
206
206
|
cardTurn: opts.cardTurn === true,
|
|
207
|
+
verifiedWorktree: opts.verifiedWorktree === true,
|
|
207
208
|
orchestration: opts.orchestration === true,
|
|
208
209
|
readOnlyCoordination: opts.readOnlyCoordination === true,
|
|
209
210
|
// The lane's own measurement when it decided to orchestrate, so the
|
package/bin/lib/runtimeCodex.mjs
CHANGED
|
@@ -270,18 +270,17 @@ export const CODEX_RUNTIME = {
|
|
|
270
270
|
* the prompt always carries them too. Workbench tabs keep their normal
|
|
271
271
|
* checkout discovery.
|
|
272
272
|
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
* (codexResearch.mjs), so its fence passes it.
|
|
273
|
+
* Agent launches verify their exact worktree root and repository ownership
|
|
274
|
+
* through git before every spawn. Only those launches may replace Codex's
|
|
275
|
+
* own repository discovery with `--skip-git-repo-check`; this grants no
|
|
276
|
+
* project-config trust. Research's isolated copy has its own exception.
|
|
278
277
|
*
|
|
279
278
|
* The prompt positional stays last; runTurn uses `-` and sends the complete
|
|
280
279
|
* framed text through stdin. A whole batch can exceed Linux's single-argument
|
|
281
280
|
* limit even when its argv plus environment is far below ARG_MAX. Both exec
|
|
282
281
|
* and exec resume support `-` (verified on Codex 0.160.0, 2026-10-16).
|
|
283
282
|
*/
|
|
284
|
-
args({ streamInput = false, writableDirs = [], prompt, system, model, effort, resume, resumeThreadId, profile = 'build', vaultDir, cwd, mcp = [], resultSchemaArgs = [], adoptResumeId, agentTools, personal = null, researchIsolation, knowledgeDir, projectReads, cardTurn = false, codexVersion, orchestration = false, readOnlyCoordination = false }) {
|
|
283
|
+
args({ streamInput = false, writableDirs = [], prompt, system, model, effort, resume, resumeThreadId, profile = 'build', vaultDir, cwd, mcp = [], resultSchemaArgs = [], adoptResumeId, agentTools, personal = null, researchIsolation, knowledgeDir, projectReads, cardTurn = false, verifiedWorktree = false, codexVersion, orchestration = false, readOnlyCoordination = false }) {
|
|
285
284
|
// Adoption resumes a conversation in ITS OWN CLI's store (claude forks,
|
|
286
285
|
// agy moves) — codex has no adoptable store wired yet. Reaching here
|
|
287
286
|
// with an adopt id is a wiring mistake upstream, and it fails loudly on
|
|
@@ -289,6 +288,8 @@ export const CODEX_RUNTIME = {
|
|
|
289
288
|
// context with a different brain.
|
|
290
289
|
if (adoptResumeId) throw new Error("codex has no adoptable terminal store — an adopt id can't reach this builder");
|
|
291
290
|
const a = ['exec'];
|
|
291
|
+
// --cd belongs to exec, before resume (resume has no --cd option).
|
|
292
|
+
if (verifiedWorktree) a.push('--cd', cwd);
|
|
292
293
|
// BY ID when the caller knows WHICH conversation this is — a Workbench
|
|
293
294
|
// tab's held context, captured off thread.started and stored with its
|
|
294
295
|
// worktree. `--last` resumes the machine's most recent codex conversation,
|
|
@@ -298,6 +299,7 @@ export const CODEX_RUNTIME = {
|
|
|
298
299
|
if (resumeThreadId) a.push('resume', resumeThreadId);
|
|
299
300
|
else if (resume) a.push('resume', '--last');
|
|
300
301
|
a.push('--json');
|
|
302
|
+
if (verifiedWorktree) a.push('--skip-git-repo-check');
|
|
301
303
|
if (agentTools) {
|
|
302
304
|
a.push('-c', 'project_doc_max_bytes=0');
|
|
303
305
|
// A person's existing developer instructions keep their authority.
|
package/bin/lib/turnLock.mjs
CHANGED
|
@@ -18,10 +18,23 @@
|
|
|
18
18
|
* context), so it is scoped by the tab; the unscoped file is what a daemon
|
|
19
19
|
* from before this change wrote, and is still honored while its CLI lives.
|
|
20
20
|
*/
|
|
21
|
-
import { existsSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
|
21
|
+
import { existsSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
22
22
|
import { basename, dirname, join } from 'node:path';
|
|
23
23
|
import { aliveAndOurs, processStartTime } from './procRegistry.mjs';
|
|
24
24
|
|
|
25
|
+
/** The child owns the receipt, so a daemon restart still sees its live PID. */
|
|
26
|
+
export function recordTurnLock(path, pid) {
|
|
27
|
+
if (!path || !pid) return () => {};
|
|
28
|
+
const start = processStartTime(pid);
|
|
29
|
+
const value = start ? `${pid}:${start}` : String(pid);
|
|
30
|
+
writeFileSync(path, value);
|
|
31
|
+
return () => {
|
|
32
|
+
try {
|
|
33
|
+
if (readFileSync(path, 'utf8') === value) rmSync(path);
|
|
34
|
+
} catch { /* already removed, or replaced by another holder */ }
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
25
38
|
/**
|
|
26
39
|
* The spawn lock: the pid of the CLI currently live in this tab. A restarted
|
|
27
40
|
* daemon must not resume the conversation its previous life's orphan still
|
package/bin/lib/uiReview.mjs
CHANGED
|
@@ -157,7 +157,7 @@ export async function createUiReview({ wt, signal, capture = captureLocalReview,
|
|
|
157
157
|
const stop = () => runController.abort();
|
|
158
158
|
controller.signal.addEventListener('abort', stop, { once: true });
|
|
159
159
|
const timer = setTimeout(stop, timeoutMs);
|
|
160
|
-
const record = { status: 'unavailable', before: reviewSourceState(wt), basis: reviewDiagnostic(input.basis), fixture: reviewDiagnostic(input.fixture.description),
|
|
160
|
+
const record = { purpose: 'ui-review', status: 'unavailable', before: reviewSourceState(wt), basis: reviewDiagnostic(input.basis), fixture: reviewDiagnostic(input.fixture.description),
|
|
161
161
|
requestedScenarios: input.scenarios.map(s => ({ name: reviewDiagnostic(s.name), path: reviewDiagnostic(s.path), actions: s.actions.map(a => ({ type: a.type, ...(a.selector ? { selector: reviewDiagnostic(a.selector) } : {}) })) })),
|
|
162
162
|
scenarios: [], consoleErrors: [], failedRequests: [] };
|
|
163
163
|
let child, proxy, scratch, artifactDir, untrack;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Identify only files explicitly owned by a measured UI-review report.
|
|
2
|
+
* Legacy reports predate the purpose marker. A folder prefix alone never
|
|
3
|
+
* exempts producer data from handoff; unknown/unreadable reports exempt none.
|
|
4
|
+
* read receives the entry so callers can use immutable receipt bytes. */
|
|
5
|
+
export function uiReviewEvidenceNames(entries, read) {
|
|
6
|
+
const evidence = new Set();
|
|
7
|
+
const byName = new Map(entries.map(entry => [entry.name, entry]));
|
|
8
|
+
for (const entry of entries) {
|
|
9
|
+
if (!/^ui-review-[a-f0-9]{8}-[a-f0-9]{3}\/report\.json$/.test(entry.name)) continue;
|
|
10
|
+
try {
|
|
11
|
+
const report = JSON.parse(read(entry));
|
|
12
|
+
const folder = entry.name.slice(0, -'/report.json'.length);
|
|
13
|
+
if (report.folder !== `.flowviant/artifacts/${folder}` ||
|
|
14
|
+
report.artifact !== `${report.folder}/report.json` ||
|
|
15
|
+
!report.before || !report.after || !Array.isArray(report.requestedScenarios) || !Array.isArray(report.scenarios) ||
|
|
16
|
+
(report.purpose !== undefined && report.purpose !== 'ui-review') ||
|
|
17
|
+
(report.screenshots !== undefined && !Array.isArray(report.screenshots))) continue;
|
|
18
|
+
const screenshots = report.screenshots ?? [];
|
|
19
|
+
if (screenshots.some(name => typeof name !== 'string' || !/^(desktop|mobile)-\d+-(initial|final|failure)\.png$/.test(name))) continue;
|
|
20
|
+
evidence.add(entry.name);
|
|
21
|
+
for (const name of screenshots) evidence.add(`${folder}/${name}`);
|
|
22
|
+
const delivery = byName.get(`${folder}/delivery-state.json`);
|
|
23
|
+
if (delivery) {
|
|
24
|
+
const state = JSON.parse(read(delivery));
|
|
25
|
+
if (state.finalState && typeof state.latestMatches === 'boolean' && Array.isArray(state.imageInputs)) evidence.add(delivery.name);
|
|
26
|
+
}
|
|
27
|
+
} catch { /* Unknown evidence remains an ordinary producer output. */ }
|
|
28
|
+
}
|
|
29
|
+
return evidence;
|
|
30
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
2
3
|
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
4
|
import { join } from 'node:path';
|
|
4
5
|
import { tmpdir, platform } from 'node:os';
|
|
@@ -39,6 +40,7 @@ export const LOCAL_REVIEW_BOOTSTRAP = `for (const name of ['RTCPeerConnection',
|
|
|
39
40
|
export async function captureLocalReview({ origin, scenarios, artifactDir, signal,
|
|
40
41
|
browser = resolveBrowser(), spawnImpl = spawn, timeoutMs = UI_REVIEW_TIMEOUT_MS, onChild = () => () => {} }) {
|
|
41
42
|
const result = { status: 'unavailable', scenarios: [], consoleErrors: [], failedRequests: [], screenshots: [] };
|
|
43
|
+
const capturesByHash = new Map();
|
|
42
44
|
if (!browser) return { ...result, reason: 'No Chrome/Chromium/Edge found; no browser checks ran.' };
|
|
43
45
|
if (browser.viaWindows) return { ...result, reason: 'WSL Windows interop cannot carry the scoped DevTools pipe.' };
|
|
44
46
|
if (signal?.aborted) return { ...result, reason: 'Review was cancelled before launch.' };
|
|
@@ -158,9 +160,19 @@ export async function captureLocalReview({ origin, scenarios, artifactDir, signa
|
|
|
158
160
|
const shot = await send('Page.captureScreenshot', { format: 'png' });
|
|
159
161
|
const bytes = Buffer.from(shot.data ?? '', 'base64');
|
|
160
162
|
if (!bytes.length || bytes.length > 2 * 1024 * 1024) throw new Error('Screenshot was empty or exceeded the retained artifact size.');
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
163
|
+
const sha256 = createHash('sha256').update(bytes).digest('hex');
|
|
164
|
+
let name = capturesByHash.get(sha256);
|
|
165
|
+
if (!name) {
|
|
166
|
+
name = `${viewport.name}-${index + 1}-${phase}.png`;
|
|
167
|
+
writeFileSync(join(artifactDir, name), bytes, { flag: 'wx' });
|
|
168
|
+
capturesByHash.set(sha256, name);
|
|
169
|
+
result.screenshots.push(name);
|
|
170
|
+
}
|
|
171
|
+
// Each capture keeps its viewport/scenario/phase even when its
|
|
172
|
+
// exact pixels already have a retained file in this review run.
|
|
173
|
+
row.captures ??= [];
|
|
174
|
+
row.captures.push({ phase, name, sha256 });
|
|
175
|
+
if (!row.screenshots.includes(name)) row.screenshots.push(name);
|
|
164
176
|
};
|
|
165
177
|
captureFailure = () => screenshot('failure');
|
|
166
178
|
await screenshot('initial');
|
package/bin/lib/work.mjs
CHANGED
|
@@ -450,6 +450,7 @@ export function createWorkManager({
|
|
|
450
450
|
// Retirement asks the agent lane which CLI to stop.
|
|
451
451
|
const { heldSessionIds, retireWorkSessions, freshenManualPlaces } = createWorkRetire({
|
|
452
452
|
repositoryPlaces, repoRoot,
|
|
453
|
+
sessionMetaPath,
|
|
453
454
|
baseDir,
|
|
454
455
|
baseRef,
|
|
455
456
|
placeOf,
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* through the one turn-done wire (workAgentTurnReports.mjs). Null means go on.
|
|
14
14
|
*/
|
|
15
15
|
import { existsSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
16
17
|
import { git, sessionBranchRef } from './git.mjs';
|
|
17
18
|
import { worktreePath } from './workPlaces.mjs';
|
|
18
19
|
|
|
@@ -49,6 +50,9 @@ export function begunTurnRefusal(job, place, { baseDir, repoRoot, fetchPublished
|
|
|
49
50
|
// null directory AND a null ref below, and the null ref stands the guard
|
|
50
51
|
// down — the same fail-open direction as an unreadable repo.
|
|
51
52
|
const wtDir = worktreePath(baseDir, place);
|
|
53
|
+
// A shell left by an interrupted setup is not the agent's git worktree.
|
|
54
|
+
// The launch path validates ownership and readability before using it.
|
|
55
|
+
const hasWorktree = Boolean(wtDir && existsSync(join(wtDir, '.git')));
|
|
52
56
|
const ref = sessionBranchRef(place);
|
|
53
57
|
let hasBranch = false;
|
|
54
58
|
/**
|
|
@@ -76,7 +80,7 @@ export function begunTurnRefusal(job, place, { baseDir, repoRoot, fetchPublished
|
|
|
76
80
|
else branchMeasured = false;
|
|
77
81
|
}
|
|
78
82
|
}
|
|
79
|
-
if (branchMeasured && !
|
|
83
|
+
if (branchMeasured && !hasWorktree && !hasBranch) {
|
|
80
84
|
/**
|
|
81
85
|
* …UNLESS THE WORK IS ON THE REMOTE (0.86.0).
|
|
82
86
|
*
|
|
@@ -110,6 +114,6 @@ export function begunTurnRefusal(job, place, { baseDir, repoRoot, fetchPublished
|
|
|
110
114
|
// opens a worktree on it below — the same path a directory somebody
|
|
111
115
|
// cleaned up already takes, on commits this box now genuinely holds.
|
|
112
116
|
}
|
|
113
|
-
if (hasBranch ||
|
|
117
|
+
if (hasBranch || hasWorktree) onAvailable();
|
|
114
118
|
return null;
|
|
115
119
|
}
|
|
@@ -31,6 +31,8 @@ import { finishRepositoryDelivery, allResolutionsCommitted } from './repositoryD
|
|
|
31
31
|
import { MODEL, PROJECT_ID } from './config.mjs';
|
|
32
32
|
import { setRuntimeLimit } from './daemonState.mjs';
|
|
33
33
|
import { gitOrNull } from './git.mjs';
|
|
34
|
+
import { assertWorktree } from './worktreeHealth.mjs';
|
|
35
|
+
import { placeTurnLockedByLivePid, recordTurnLock } from './turnLock.mjs';
|
|
34
36
|
import { turnCommits } from './turnReceipts.mjs';
|
|
35
37
|
import { addUsage } from './turnUsage.mjs';
|
|
36
38
|
import { stashCard, stashGaps } from './agentCards.mjs';
|
|
@@ -65,7 +67,7 @@ import { ORCHESTRATED_POSTURES, orchestratesPosture } from './orchestratorPrompt
|
|
|
65
67
|
import { knownCodexModels } from './runtimeOfferings.mjs';
|
|
66
68
|
import { codexSubagentsSafe } from './runtimeCodex.mjs';
|
|
67
69
|
import { measuredRuntimeVersion } from './runtimeDetection.mjs';
|
|
68
|
-
import { createIntermediateArtifacts } from './intermediateArtifacts.mjs';
|
|
70
|
+
import { createIntermediateArtifacts, IntermediateArtifactCapacityError } from './intermediateArtifacts.mjs';
|
|
69
71
|
import { createUiReview } from './uiReview.mjs';
|
|
70
72
|
import { createVideoRender } from './videoRender.mjs';
|
|
71
73
|
import { withUiReview, appendUiReviewEvidence } from './uiReviewTurn.mjs';
|
|
@@ -148,6 +150,10 @@ export function createAgentTurnExecution({
|
|
|
148
150
|
return;
|
|
149
151
|
}
|
|
150
152
|
const wt = dir.wt;
|
|
153
|
+
const worktreeRepo = repositoryRun?.sources.find(r => r.wt === wt)?.repoRoot ?? repoRoot;
|
|
154
|
+
assertWorktree(wt, worktreeRepo);
|
|
155
|
+
const turnLock = sessionMetaPath(wt, 'flowviant-turn.lock', place);
|
|
156
|
+
if (placeTurnLockedByLivePid(sessionMetaPath(wt, 'flowviant-turn.lock'))) return;
|
|
151
157
|
/**
|
|
152
158
|
* NEITHER OF THESE MAY THROW. `git()` throws on a non-zero exit, and both
|
|
153
159
|
* of these exit non-zero in states a real worktree reaches: `symbolic-ref`
|
|
@@ -412,6 +418,10 @@ export function createAgentTurnExecution({
|
|
|
412
418
|
let videoNote = '';
|
|
413
419
|
const runCli = (args) => {
|
|
414
420
|
if (run.interrupted) return Promise.resolve('');
|
|
421
|
+
// Preparation awaits external work; remeasure before each fresh or
|
|
422
|
+
// resumed spawn, before replacing Codex's own repository discovery.
|
|
423
|
+
assertWorktree(wt, worktreeRepo);
|
|
424
|
+
args.verifiedWorktree = true;
|
|
415
425
|
args = withUiReview(args, uiReviewNote, uiReview?.env);
|
|
416
426
|
args = withUiReview(args, videoNote);
|
|
417
427
|
return runTurnImpl(args);
|
|
@@ -667,6 +677,10 @@ export function createAgentTurnExecution({
|
|
|
667
677
|
// (runTurn.mjs, `initHarvestFor`). An agent-only box learns from the
|
|
668
678
|
// startup probe and its Terminal tabs.
|
|
669
679
|
onSpawn: (ch) => {
|
|
680
|
+
let releaseTurnLock;
|
|
681
|
+
try { releaseTurnLock = recordTurnLock(turnLock, ch.pid); }
|
|
682
|
+
catch (error) { killGroup(ch); throw error; }
|
|
683
|
+
ch.once('close', releaseTurnLock);
|
|
670
684
|
if (orchestrator && rt === 'codex') run.live?.connect((id, body) => orchestrator.input(id, body));
|
|
671
685
|
if (rt !== 'claude') reportDelivery('agent', job, 'taken');
|
|
672
686
|
brief?.running(true);
|
|
@@ -813,6 +827,9 @@ export function createAgentTurnExecution({
|
|
|
813
827
|
try { intermediateDelivery = await intermediate.prepare({ job, turnId, taskKind, before: artifactsBefore, signal: setupController.signal }); }
|
|
814
828
|
catch (error) {
|
|
815
829
|
if (run.interrupted) throw error;
|
|
830
|
+
// A measured storage overflow must stop before sending a subset
|
|
831
|
+
// that could evict this producer's already retained inputs.
|
|
832
|
+
if (error instanceof IntermediateArtifactCapacityError) throw error;
|
|
816
833
|
// The failed page still has its preview/files for repair. Reporting
|
|
817
834
|
// those bytes cannot turn a failed export into a delivered card.
|
|
818
835
|
try { await artifacts.report({ placeDir: wt, before: artifactsBefore, agentId, turnId, requireStored: kind.artifact != null }); }
|
|
@@ -821,7 +838,7 @@ export function createAgentTurnExecution({
|
|
|
821
838
|
}
|
|
822
839
|
}
|
|
823
840
|
await artifacts.report({ placeDir: wt, before: intermediateDelivery?.uploadBefore ?? artifactsBefore, agentId, turnId,
|
|
824
|
-
requireStored: kind.artifact != null, requiredOutputs: intermediateDelivery?.requiredOutputs });
|
|
841
|
+
requireStored: kind.artifact != null, requiredOutputs: intermediateDelivery?.requiredOutputs, evidenceNames: intermediateDelivery?.evidenceNames });
|
|
825
842
|
}
|
|
826
843
|
|
|
827
844
|
|
package/bin/lib/workPlaces.mjs
CHANGED
|
@@ -13,9 +13,10 @@
|
|
|
13
13
|
* so a lane that learns a place cannot store one unvalidated.
|
|
14
14
|
*/
|
|
15
15
|
import { readRepositoryPlace } from './repositoryPlaces.mjs';
|
|
16
|
-
import { existsSync, statSync } from 'node:fs';
|
|
16
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
17
17
|
import { join } from 'node:path';
|
|
18
18
|
import { git, isSafePathSegment, hasCommits, gitFailure, sessionBranch } from './git.mjs';
|
|
19
|
+
import { assertWorktree } from './worktreeHealth.mjs';
|
|
19
20
|
|
|
20
21
|
/** The place id meaning "the checkout", not a worktree. Must match the
|
|
21
22
|
* server's REPO_PLACE — it is a wire value, not a local convention. The
|
|
@@ -213,6 +214,7 @@ export function createWorkPlaces({ repoRoot, baseDir, baseRef, onRepoChanged })
|
|
|
213
214
|
* same way adoption resolves a cwd, and for the same reason.
|
|
214
215
|
*/
|
|
215
216
|
const placeWtFor = (placeId, baseAt) => {
|
|
217
|
+
placeWtFor.lastError = null;
|
|
216
218
|
if (placeId === REPO_PLACE) {
|
|
217
219
|
// The checkout. `fresh: false` on purpose — nothing was opened, so no
|
|
218
220
|
// caller may treat this as a newly-cut branch.
|
|
@@ -220,7 +222,14 @@ export function createWorkPlaces({ repoRoot, baseDir, baseRef, onRepoChanged })
|
|
|
220
222
|
}
|
|
221
223
|
const wt = worktreePath(baseDir, placeId);
|
|
222
224
|
if (!wt) return null;
|
|
223
|
-
|
|
225
|
+
let fresh;
|
|
226
|
+
try {
|
|
227
|
+
fresh = !existsSync(wt) || readdirSync(wt).length === 0;
|
|
228
|
+
if (!fresh) assertWorktree(wt, repoRoot);
|
|
229
|
+
} catch (error) {
|
|
230
|
+
placeWtFor.lastError = error.message;
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
224
233
|
if (fresh) {
|
|
225
234
|
const branch = sessionBranch(placeId);
|
|
226
235
|
// `baseAt` is the adoption override: a tab born from a terminal session
|
|
@@ -276,6 +285,8 @@ export function createWorkPlaces({ repoRoot, baseDir, baseRef, onRepoChanged })
|
|
|
276
285
|
// always did (runTurn.mjs spawns with `{...process.env}`), so anything
|
|
277
286
|
// exported in the shell the daemon was started from is present here.
|
|
278
287
|
}
|
|
288
|
+
try { assertWorktree(wt, repoRoot); }
|
|
289
|
+
catch (error) { placeWtFor.lastError = error.message; return null; }
|
|
279
290
|
return { wt, fresh };
|
|
280
291
|
};
|
|
281
292
|
|
package/bin/lib/workRetire.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import { existsSync, readdirSync } from 'node:fs';
|
|
|
12
12
|
import { join } from 'node:path';
|
|
13
13
|
import { git, isSafePathSegment } from './git.mjs';
|
|
14
14
|
import { PERSON_PLACE_PREFIX } from './workPlaces.mjs';
|
|
15
|
+
import { placeTurnLockedByLivePid } from './turnLock.mjs';
|
|
15
16
|
|
|
16
17
|
export function createWorkRetire({
|
|
17
18
|
repositoryPlaces,
|
|
@@ -24,6 +25,7 @@ export function createWorkRetire({
|
|
|
24
25
|
workTokens,
|
|
25
26
|
agentChildren,
|
|
26
27
|
sweepMergedSessionBranch,
|
|
28
|
+
sessionMetaPath,
|
|
27
29
|
}) {
|
|
28
30
|
/**
|
|
29
31
|
* Retire the worktrees of sessions the server says are CLOSED.
|
|
@@ -111,6 +113,8 @@ export function createWorkRetire({
|
|
|
111
113
|
if (placeLocks.has(placeOf(id)) || shipping.has(id)) continue; // still draining here
|
|
112
114
|
const wt = join(dir, id);
|
|
113
115
|
try {
|
|
116
|
+
const places = repositoryPlaces?.stateFor(id) ? repositoryPlaces.entries(id).filter(r => r.writable).map(r => r.wt) : [wt];
|
|
117
|
+
if (places.some(path => placeTurnLockedByLivePid(sessionMetaPath?.(path, 'flowviant-turn.lock')))) continue;
|
|
114
118
|
// Uncommitted work is the human's — a resource sweep does not outrank
|
|
115
119
|
// it, closed tab or not. (The non-force remove would refuse anyway;
|
|
116
120
|
// the explicit check keeps the intent legible.)
|
|
@@ -179,6 +183,7 @@ export function createWorkRetire({
|
|
|
179
183
|
if (placeLocks.has(place)) continue;
|
|
180
184
|
const wt = join(baseDir, 'sessions', place);
|
|
181
185
|
try {
|
|
186
|
+
if (placeTurnLockedByLivePid(sessionMetaPath?.(wt, 'flowviant-turn.lock'))) continue;
|
|
182
187
|
if (git(['status', '--porcelain'], wt).trim() !== '') continue;
|
|
183
188
|
git(['merge', '--ff-only', baseRef()], wt);
|
|
184
189
|
} catch {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Verify the directory itself, rather than a parent git happened to discover. */
|
|
2
|
+
import { realpathSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { git } from './git.mjs';
|
|
5
|
+
|
|
6
|
+
export function assertWorktree(wt, repoRoot) {
|
|
7
|
+
try {
|
|
8
|
+
const top = realpathSync(git(['rev-parse', '--show-toplevel'], wt));
|
|
9
|
+
if (top !== realpathSync(wt)) throw new Error('the directory is not a git worktree root');
|
|
10
|
+
const common = (dir) => realpathSync(resolve(dir, git(['rev-parse', '--git-common-dir'], dir)));
|
|
11
|
+
if (common(wt) !== common(repoRoot)) throw new Error('the worktree belongs to another repository');
|
|
12
|
+
const registered = git(['worktree', 'list', '--porcelain', '-z'], repoRoot).split('\0')
|
|
13
|
+
.some(line => {
|
|
14
|
+
if (!line.startsWith('worktree ')) return false;
|
|
15
|
+
try { return realpathSync(line.slice(9)) === top; } catch { return false; }
|
|
16
|
+
});
|
|
17
|
+
if (!registered) throw new Error('the worktree is not registered with this repository');
|
|
18
|
+
git(['rev-parse', '--verify', 'HEAD'], wt);
|
|
19
|
+
} catch (error) {
|
|
20
|
+
const why = error.stderr?.toString().trim() || error.message;
|
|
21
|
+
throw new Error(`The worktree at ${wt} is unavailable: ${why}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.125.
|
|
3
|
+
"version": "0.125.25",
|
|
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": {
|