flowviant 0.125.24 → 0.125.26

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 CHANGED
@@ -328,7 +328,19 @@ 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
- ### Staged agent worktree startup and restart protection (0.125.24)
331
+ ### UI review evidence retention (0.125.26)
332
+
333
+ Review reports and screenshots stay attached to their run without filling the
334
+ same-agent producer receipt. Legacy receipts migrate on retry without deleting
335
+ evidence. Identical captures within one review share a file and retain their
336
+ scenario, viewport, phase and hash references. Producer receipts use the shared
337
+ agent storage limit and report cumulative overflow before upload.
338
+
339
+ 0.125.26 includes the npm dependency shrinkwrap with the pinned npm 11
340
+ publisher. The earlier 0.125.25 npm package contains the same runtime fix but
341
+ was published by npm 12 without that lockfile.
342
+
343
+ ### Agent worktree startup and restart protection (0.125.24)
332
344
 
333
345
  Card launches verify their exact Git worktree and repository before starting,
334
346
  including after tool preparation. Fresh empty leftover directories are
@@ -338,7 +350,9 @@ retirement and manual fast-forwarding honor live CLI PID/start-time receipts
338
350
  across daemon restarts. Existing begun work still needs its worktree, surviving
339
351
  branch or published recovery copy; restarting the daemon alone does not reset
340
352
  a stuck board agent. No new wire floor or database migration is required.
341
- This version is staged, not published or installed.
353
+ Published on npm and the standalone download channel on 2026-10-26. Running
354
+ daemons receive the version signal from the app and update when idle, unless
355
+ automatic updates are disabled.
342
356
 
343
357
  ### Staged artifact handoff revisions (0.125.18)
344
358
 
@@ -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
- const entries = required.size
338
- ? [...scanArtifacts(placeDir, { all: true, includeNames: new Set(required.keys()) }),
339
- ...discovered.filter(e => !artifactTypeFor(e.name))] // unsupported files still report by name
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 > 160 ||
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
- let changed = changedArtifacts(before, standing);
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
- changed = changed.filter(e => pending.sources.some(s => s.name === e.name));
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 && (changed.some(e => e.name === ref.name) || discarded.has(artifactStoredName(ref.name)))) continue;
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 (!changed.some(e => e.name === entry.name)) changed.push(entry);
191
+ if (!producerChanges.some(e => e.name === entry.name)) producerChanges.push(entry);
171
192
  }
172
- if (taskKind === 'model' && !changed.some(e => /\.html?$/i.test(e.name)) && (job.kind !== 'task' || job.redo === true) && taskId) {
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) changed.push(entry);
196
+ if (entry) producerChanges.push(entry);
176
197
  }
177
198
  }
178
- const pages = taskKind === 'model' ? changed.filter(e => /\.html?$/i.test(e.name)) : [];
179
- const outputs = changed.filter(e => artifactTypeFor(e.name));
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 Error(`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.`);
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
  };
@@ -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 name = `${viewport.name}-${index + 1}-${phase}.png`;
162
- writeFileSync(join(artifactDir, name), bytes, { flag: 'wx' });
163
- row.screenshots.push(name); result.screenshots.push(name);
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');
@@ -67,7 +67,7 @@ import { ORCHESTRATED_POSTURES, orchestratesPosture } from './orchestratorPrompt
67
67
  import { knownCodexModels } from './runtimeOfferings.mjs';
68
68
  import { codexSubagentsSafe } from './runtimeCodex.mjs';
69
69
  import { measuredRuntimeVersion } from './runtimeDetection.mjs';
70
- import { createIntermediateArtifacts } from './intermediateArtifacts.mjs';
70
+ import { createIntermediateArtifacts, IntermediateArtifactCapacityError } from './intermediateArtifacts.mjs';
71
71
  import { createUiReview } from './uiReview.mjs';
72
72
  import { createVideoRender } from './videoRender.mjs';
73
73
  import { withUiReview, appendUiReviewEvidence } from './uiReviewTurn.mjs';
@@ -827,6 +827,9 @@ export function createAgentTurnExecution({
827
827
  try { intermediateDelivery = await intermediate.prepare({ job, turnId, taskKind, before: artifactsBefore, signal: setupController.signal }); }
828
828
  catch (error) {
829
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;
830
833
  // The failed page still has its preview/files for repair. Reporting
831
834
  // those bytes cannot turn a failed export into a delivered card.
832
835
  try { await artifacts.report({ placeDir: wt, before: artifactsBefore, agentId, turnId, requireStored: kind.artifact != null }); }
@@ -835,7 +838,7 @@ export function createAgentTurnExecution({
835
838
  }
836
839
  }
837
840
  await artifacts.report({ placeDir: wt, before: intermediateDelivery?.uploadBefore ?? artifactsBefore, agentId, turnId,
838
- requireStored: kind.artifact != null, requiredOutputs: intermediateDelivery?.requiredOutputs });
841
+ requireStored: kind.artifact != null, requiredOutputs: intermediateDelivery?.requiredOutputs, evidenceNames: intermediateDelivery?.evidenceNames });
839
842
  }
840
843
 
841
844
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.125.24",
3
+ "version": "0.125.26",
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": {