flowviant 0.125.18 → 0.125.21

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.
Files changed (48) hide show
  1. package/README.md +11 -1
  2. package/bin/cli.mjs +13 -0
  3. package/bin/lib/artifactCache.mjs +336 -0
  4. package/bin/lib/artifactContracts.mjs +87 -2
  5. package/bin/lib/artifactIteration.mjs +3 -2
  6. package/bin/lib/artifactPolicy.mjs +5 -1
  7. package/bin/lib/artifactReporter.mjs +91 -9
  8. package/bin/lib/artifacts.mjs +12 -6
  9. package/bin/lib/claudeStream.mjs +7 -0
  10. package/bin/lib/codexFencedMount.mjs +2 -2
  11. package/bin/lib/fleet.mjs +9 -0
  12. package/bin/lib/intermediateArtifacts.mjs +40 -8
  13. package/bin/lib/knowledgeLibrary.mjs +2 -1
  14. package/bin/lib/localCleanupReport.mjs +34 -0
  15. package/bin/lib/localCleanupState.mjs +68 -0
  16. package/bin/lib/logReplay.mjs +6 -0
  17. package/bin/lib/managedLocalCleanup.mjs +158 -0
  18. package/bin/lib/pinnedCapture.mjs +19 -8
  19. package/bin/lib/postureTools.mjs +2 -2
  20. package/bin/lib/publicReferenceBrowser.mjs +180 -0
  21. package/bin/lib/publicReferenceNetwork.mjs +131 -0
  22. package/bin/lib/publicReferenceProtocol.mjs +55 -0
  23. package/bin/lib/publicReferenceRuntime.mjs +65 -0
  24. package/bin/lib/publicReferenceTool.mjs +89 -0
  25. package/bin/lib/referenceCaptureProtocol.mjs +12 -0
  26. package/bin/lib/referenceCaptures.mjs +140 -0
  27. package/bin/lib/resultInputs.mjs +4 -2
  28. package/bin/lib/runTurn.mjs +8 -0
  29. package/bin/lib/runtimeCodex.mjs +1 -1
  30. package/bin/lib/runtimeEvents.mjs +24 -2
  31. package/bin/lib/uiReview.mjs +305 -0
  32. package/bin/lib/uiReviewBrowser.mjs +212 -0
  33. package/bin/lib/uiReviewContract.mjs +51 -0
  34. package/bin/lib/uiReviewTurn.mjs +15 -0
  35. package/bin/lib/websiteAccess.mjs +68 -0
  36. package/bin/lib/websiteTrace.mjs +36 -0
  37. package/bin/lib/work.mjs +46 -3
  38. package/bin/lib/workAgentPlans.mjs +3 -0
  39. package/bin/lib/workAgentPrecheck.mjs +16 -15
  40. package/bin/lib/workAgentReview.mjs +2 -2
  41. package/bin/lib/workAgentTools.mjs +3 -2
  42. package/bin/lib/workAgentTurnExecution.mjs +69 -4
  43. package/bin/lib/workAgentTurns.mjs +3 -1
  44. package/bin/lib/workLocalCleanup.mjs +125 -0
  45. package/bin/lib/workMergeBatch.mjs +19 -4
  46. package/bin/lib/workSessionGroups.mjs +20 -1
  47. package/bin/lib/workToolLog.mjs +24 -3
  48. package/package.json +1 -1
@@ -37,10 +37,12 @@
37
37
  * with `includeNames`, so it never attributes a sibling card's files.
38
38
  */
39
39
 
40
+ import { createArtifactCache } from './artifactCache.mjs';
40
41
  import { artifactStoredName, artifactTypeFor, scanArtifacts } from './artifacts.mjs';
42
+ import { ARTIFACT_MULTIPART_MAX_BYTES } from './artifactPolicy.mjs';
41
43
  import { fleetHeaders } from './fleetPost.mjs';
42
44
  import {
43
- imageRevisionProtection, iterationSignature, iterationChanges, deletedArtifacts, localArtifact, discardLocalArtifact, iterationInstructions,
45
+ declaredDiscards, imageRevisionProtection, iterationSignature, iterationChanges, deletedArtifacts, localArtifact, discardLocalArtifact, iterationInstructions,
44
46
  presentArtifacts, readSeenArtifacts, writeSeenArtifacts, seenArtifacts, serverBornAt,
45
47
  } from './artifactIteration.mjs';
46
48
  import { fleetEndpoint, isConsideredRefusal } from './fleetWire.mjs';
@@ -74,17 +76,64 @@ export function createArtifactReporter({
74
76
  * checkout, anything unreadable) gives a place without a record no
75
77
  * legacy evidence at all. */
76
78
  placeBornAt = () => null,
79
+ projectIdentity = fleetUrl,
77
80
  }) {
78
81
  const url = fleetEndpoint('artifact', fleetUrl);
79
82
  const doFetch = fetchImpl ?? ((...a) => fetch(...a));
80
83
  const pending = new Map(); // `${owner}:${name}` -> { body, tries }
81
84
 
82
- const prepare = async ({ placeDir, sessionId, agentId, before }) => {
85
+ const cacheFor = ({ placeDir, sessionId, agentId, cleanupGuard = null, recoveryMemo = null, preparedOnly = () => false }) => {
86
+ const privatePath = seenPath(placeDir, sessionId ?? agentId);
87
+ const project = typeof projectIdentity === 'function' ? projectIdentity() : projectIdentity;
88
+ if (!privatePath || !project) return null;
89
+ return createArtifactCache({ placeDir, path: `${privatePath}.cache`,
90
+ getDiscards: () => declaredDiscards(placeDir),
91
+ getUses: name => (cleanupGuard && cleanupGuard() !== true) || pending.has(`${sessionId ?? agentId}:${name}`) || chains.has(`${sessionId ?? agentId}:${name}`) ? ['pending-upload-or-removal'] : [],
92
+ scope: { projectId: project, ...(sessionId ? { sessionId } : { agentId }) },
93
+ fetchVersion: async ref => {
94
+ const key = `${ref.remote.id}:${ref.sha256}`, cached = recoveryMemo?.get(key);
95
+ if (cached && Date.now() >= cached.at && Date.now() - cached.at < 5_000) return cached.bytes;
96
+ if (preparedOnly()) throw new Error('recovery-verification-expired');
97
+ const query = new URLSearchParams({ ...(sessionId ? { sessionId } : { agentId }), sha256: ref.sha256 });
98
+ const response = await doFetch(`${url}/${encodeURIComponent(ref.remote.id)}/recover?${query}`, {
99
+ method: 'GET', headers: fleetHeaders({ token, json: false }), signal: AbortSignal.timeout(cleanupGuard ? 5_000 : 30_000),
100
+ });
101
+ if (!response.ok) throw new Error(`recovery-unavailable-http-${response.status}`);
102
+ // Bound even an incompatible server's response before buffering it.
103
+ const reader = response.body?.getReader();
104
+ if (!reader) throw new Error('recovery-unreadable');
105
+ const chunks = []; let size = 0;
106
+ try {
107
+ for (;;) {
108
+ const { done, value } = await reader.read(); if (done) break;
109
+ size += value.length;
110
+ if (size > 32 * 1024 * 1024) throw new Error('recovery-too-large');
111
+ chunks.push(Buffer.from(value));
112
+ }
113
+ } finally { await reader.cancel().catch(() => {}); }
114
+ const bytes = Buffer.concat(chunks);
115
+ recoveryMemo?.set(key, { bytes, at: Date.now() });
116
+ return bytes;
117
+ } });
118
+ };
119
+
120
+ const prepare = async ({ placeDir, sessionId, agentId, before, idle = false }) => {
83
121
  if (!before) return '';
84
122
  before.iterationSignature = iterationSignature(placeDir);
85
123
  delete before.stored;
86
124
  delete before.seen;
87
125
  before.inventoryFailed = false;
126
+ const cache = cacheFor({ placeDir, sessionId, agentId });
127
+ if (cache && !idle) {
128
+ // Consumer preparation is outside inventory's best-effort catch: exact
129
+ // recovery failure must stop this turn from consuming missing inputs.
130
+ const restored = await cache.restore(declaredDiscards(placeDir));
131
+ const snapshot = scanArtifacts(placeDir, { all: true });
132
+ for (const name of restored) {
133
+ const entry = snapshot.find(e => e.name === name);
134
+ if (entry) before.set(name, `${entry.size}:${entry.mtimeMs}`);
135
+ }
136
+ }
88
137
  const query = new URLSearchParams(sessionId ? { sessionId } : { agentId });
89
138
  try {
90
139
  const sentAt = Date.now();
@@ -100,6 +149,11 @@ export function createArtifactReporter({
100
149
  const result = await response.json();
101
150
  if (!Array.isArray(result.data)) { before.inventoryFailed = true; return ''; }
102
151
  before.stored = result.data.filter((a) => a && typeof a.id === 'string' && typeof a.name === 'string');
152
+ if (cache) for (const remote of before.stored) {
153
+ if (!/^[a-f0-9]{64}$/.test(remote.sha256 ?? '')) continue;
154
+ try { cache.record({ name: remote.name, remote }); }
155
+ catch { /* Unknown, missing or scrubbed local bytes are never owned. */ }
156
+ }
103
157
  const path = seenPath(placeDir, sessionId ?? agentId);
104
158
  before.seen = seenArtifacts(readSeenArtifacts(path), before.stored, presentArtifacts(placeDir) ?? new Set(),
105
159
  serverBornAt(placeBornAt(placeDir), response.headers?.get?.('date'), sentAt));
@@ -130,14 +184,22 @@ export function createArtifactReporter({
130
184
  }
131
185
  const form = new FormData();
132
186
  for (const [k, v] of Object.entries(body.fields)) form.set(k, v);
133
- if (body.bytes) form.set('file', new Blob([body.bytes]), body.fields.name);
187
+ const streamed = body.bytes?.length > ARTIFACT_MULTIPART_MAX_BYTES;
188
+ if (body.bytes && !streamed) form.set('file', new Blob([body.bytes]), body.fields.name);
134
189
  if (body.preview) form.set('preview', new Blob([body.preview], { type: 'image/png' }), 'preview.png');
135
190
  try {
136
191
  const res = await doFetch(url, {
137
192
  method: 'POST',
138
- headers: { Authorization: `Bearer ${token}`, 'User-Agent': userAgent },
193
+ headers: { Authorization: `Bearer ${token}`, 'User-Agent': userAgent,
194
+ ...(streamed ? {
195
+ 'Content-Type': 'application/octet-stream',
196
+ 'Content-Length': String(body.bytes.length),
197
+ 'X-Flowviant-Artifact': JSON.stringify(body.fields),
198
+ 'X-Content-Sha256': body.fields.sha256,
199
+ } : {}),
200
+ },
139
201
  signal: AbortSignal.timeout(30_000),
140
- body: form,
202
+ body: streamed ? body.bytes : form,
141
203
  });
142
204
  if (res.ok) {
143
205
  const result = typeof res.json === 'function' ? await res.json().catch(() => null) : null;
@@ -186,6 +248,15 @@ export function createArtifactReporter({
186
248
  if (genOf.get(key) !== gen) return; // a newer copy decides for this key
187
249
  if (result.settled) pending.delete(key);
188
250
  else hold(key, body, tries + 1);
251
+ if (result.success && body.cacheInfo && result.id && body.bytes) {
252
+ try { cacheFor(body.cacheInfo)?.record({ name: body.fields.name,
253
+ remote: { id: result.id, sha256: body.fields.sha256 } }); }
254
+ catch (error) { log(`artifact ${body.fields.name}: local cache retained — ${error.message}`); }
255
+ }
256
+ if (result.success && body.removal) {
257
+ try { cacheFor(body.cacheInfo ?? { placeDir: body.placeDir, ...body.removal })?.discard(body.fields.name, body.removal.id); }
258
+ catch (error) { log(`artifact ${body.fields.name}: cache discard bookkeeping incomplete — ${error.message}`); }
259
+ }
189
260
  if (result.success && body.replacements?.length) {
190
261
  result.cleanupFailed = [];
191
262
  for (const removal of body.replacements) {
@@ -226,7 +297,7 @@ export function createArtifactReporter({
226
297
 
227
298
  /** Upload what this turn changed in `placeDir`. Resolves to the number of
228
299
  * bodies SENT this call (delivered or held), for the tests and the log. */
229
- const report = async ({ placeDir, before, sessionId, agentId, turnId, idle = false, requireStored = false, includeNames = null, requiredOutputs = null }) => {
300
+ const report = async ({ placeDir, before, sessionId, agentId, turnId, idle = false, requireStored = false, includeNames = null, requiredOutputs = null, referenceCapture = false }) => {
230
301
  if (!placeDir || (!sessionId && !agentId)) return 0;
231
302
  const changes = iterationChanges(placeDir, before);
232
303
  const protectedNames = imageRevisionProtection(before?.stored, before?.imageRevision);
@@ -239,6 +310,14 @@ export function createArtifactReporter({
239
310
  for (const name of [...changes.remove, ...Object.keys(changes.replace)]) if (protectedNames.has(name)) throw new Error(`Image revision retained ${name}; its discard or replacement was not authorized.`);
240
311
  }
241
312
  if (!before?.stored && (changes.remove.length || Object.keys(changes.replace).length)) throw new Error('Stored artifact inventory could not be measured; removal was not attempted.');
313
+ // An unreadable eviction ledger is unknown evidence, never a discard.
314
+ const cache = cacheFor({ placeDir, sessionId, agentId });
315
+ // A standing replacement is not an unconditional remove: after restart
316
+ // its predecessor stays cached until this report has delivery evidence.
317
+ const standing = iterationChanges(placeDir, null);
318
+ const explicitAbsences = new Set([...standing.remove, ...Object.keys(changes.replace)]);
319
+ try { if (before) before.evicted = cache?.absences(before?.stored ?? [], explicitAbsences) ?? new Set(); }
320
+ catch { if (before) before.evicted = new Set((before?.stored ?? []).map(a => a.name)); }
242
321
  const removing = new Set([...changes.remove, ...Object.keys(changes.replace), ...(before?.stored ? deletedArtifacts(placeDir, before).filter(name => !protectedNames.has(name)) : [])]);
243
322
  const stored = new Map((before?.stored ?? []).map((a) => [a.name, a]));
244
323
  for (const name of [...changes.remove, ...Object.keys(changes.replace)]) {
@@ -268,7 +347,7 @@ export function createArtifactReporter({
268
347
  failures.push(`Required intermediate output ${name} is missing, unsafe or excluded from reporting; its handoff is unfinished.`);
269
348
  let n = 0;
270
349
  for (const entry of changed) {
271
- const body = buildUpload(entry, { sessionId, agentId, turnId }, scrub, secretIn);
350
+ const body = buildUpload(entry, { sessionId, agentId, turnId, ...(referenceCapture ? { referenceCapture: true } : {}) }, scrub, secretIn);
272
351
  if (required.has(entry.name) && (!body?.bytes || body.withheld || body.fields.sha256 !== required.get(entry.name))) {
273
352
  failures.push(`Required intermediate output ${entry.name} is unreadable, withheld or changed since preparation. Restore its exact producer bytes and retry.`);
274
353
  continue;
@@ -292,6 +371,9 @@ export function createArtifactReporter({
292
371
  body.fields.renderState = measured.renderState;
293
372
  if (measured.preview) body.preview = measured.preview;
294
373
  }
374
+ body.cacheInfo = { placeDir, sessionId, agentId };
375
+ try { cache?.trackUpload(body.fields.name); }
376
+ catch (error) { log(`artifact ${body.fields.name}: ownership unavailable — ${error.message}`); }
295
377
  body.replacements = Object.entries(changes.replace).filter(([, to]) => to === body.fields.name).map(([name]) => {
296
378
  const key = `${sessionId ?? agentId}:${name}`;
297
379
  return { fields: { name }, removal: { ...(sessionId ? { sessionId } : { agentId }), id: stored.get(name).id, name },
@@ -377,9 +459,9 @@ export function createArtifactReporter({
377
459
  */
378
460
  const reconcile = async ({ placeDir, before, sessionId, agentId }) => {
379
461
  if (!placeDir || !before || (!sessionId && !agentId)) return 0;
380
- await prepare({ placeDir, before, sessionId, agentId });
462
+ await prepare({ placeDir, before, sessionId, agentId, idle: true });
381
463
  return report({ placeDir, before, sessionId, agentId, idle: true });
382
464
  };
383
465
 
384
- return { prepare, report, reconcile, retryPending, pendingCount: () => pending.size };
466
+ return { prepare, report, reconcile, cacheFor, retryPending, pendingCount: () => pending.size };
385
467
  }
@@ -74,7 +74,8 @@
74
74
  * that cannot show an artifact must not have the CLI told it will.
75
75
  */
76
76
 
77
- import { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readSync, rmSync } from 'node:fs';
77
+ import { REFERENCE_CAPTURE_MANIFEST, REFERENCE_CAPTURE_FILE } from './referenceCaptureProtocol.mjs';
78
+ import { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readSync } from 'node:fs';
78
79
  import { createHash } from 'node:crypto';
79
80
  import { join } from 'node:path';
80
81
  import { cutKeepingExtension } from './safeFileName.mjs';
@@ -138,7 +139,7 @@ export function ensureArtifactDir(placeDir) {
138
139
  * points both variables at it (runtimeCodex.mjs `codexDesignFence`), so the
139
140
  * shell works like a shell; nothing here is uploaded, committed or read back.
140
141
  *
141
- * Made EMPTY before each turn (a previous turn's scratch is nobody's), and
142
+ * Created before each turn without erasing unrecorded or still-required files, and
142
143
  * only as a real directory under a real `.flowviant` — the artifacts
143
144
  * directory's rule, for its reason: a fence grant on a path through a
144
145
  * committed symlink is a write grant on its target. False creates nothing
@@ -149,9 +150,8 @@ export function ensureFenceScratch(placeDir) {
149
150
  const dir = join(placeDir, FENCE_TMP_DIR);
150
151
  if (flowviantParentState(placeDir) !== 'dir' || realDirState(dir) === 'other') return false;
151
152
  try {
152
- // A real directory (checked above): its contents go, never through a link.
153
- if (realDirState(dir) === 'dir') rmSync(dir, { recursive: true, force: true });
154
- mkdirSync(dir);
153
+ // Reclamation requires a versioned ownership record and released uses.
154
+ if (realDirState(dir) === 'none') mkdirSync(dir);
155
155
  } catch {
156
156
  return false;
157
157
  }
@@ -306,7 +306,7 @@ export const artifactStoredName = (name) => name.split('/').map(safeArtifactName
306
306
  * re-sent exactly as it was first built (scrubbed once, never re-read from a
307
307
  * disk the agent may have changed since).
308
308
  */
309
- export function buildArtifactUpload(entry, { sessionId, agentId, turnId }, scrub = (s) => s, secretIn = () => null) {
309
+ export function buildArtifactUpload(entry, { sessionId, agentId, turnId, referenceCapture = false }, scrub = (s) => s, secretIn = () => null) {
310
310
  const type = artifactTypeFor(entry.name);
311
311
  const fields = {
312
312
  ...(sessionId ? { sessionId } : { agentId }),
@@ -314,6 +314,9 @@ export function buildArtifactUpload(entry, { sessionId, agentId, turnId }, scrub
314
314
  name: artifactStoredName(entry.name),
315
315
  bytes: String(entry.size),
316
316
  };
317
+ // Reserved evidence names are writable only by the measured browser
318
+ // consumer. Model-authored JSON cannot become a page-access measurement.
319
+ if (REFERENCE_CAPTURE_FILE.test(entry.name) && !referenceCapture) return { withheld: 'measured-reference-capture-only', fields };
317
320
  if (!type) return { fields, bytes: null }; // reported by name only
318
321
  const maxBytes = artifactMaxBytesFor(entry.name);
319
322
  if (entry.size > maxBytes) return { fields: { ...fields, tooLarge: '1' }, bytes: null };
@@ -329,7 +332,10 @@ export function buildArtifactUpload(entry, { sessionId, agentId, turnId }, scrub
329
332
  const secret = secretIn(bytes);
330
333
  if (secret) return { withheld: secret, fields };
331
334
  } else {
335
+ const original = bytes;
332
336
  bytes = Buffer.from(String(scrub(bytes.toString('utf8'))), 'utf8');
337
+ if (REFERENCE_CAPTURE_MANIFEST.test(entry.name) && !bytes.equals(original))
338
+ return { withheld: 'reference-capture-redaction', fields };
333
339
  // A redaction marker can be longer than the value it replaced.
334
340
  if (bytes.byteLength > maxBytes) {
335
341
  return { fields: { ...fields, bytes: String(bytes.byteLength), tooLarge: '1' }, bytes: null };
@@ -198,6 +198,7 @@ export function handleStreamLine(line, options) {
198
198
  for (const b of ev.message.content) {
199
199
  if (b.type === 'tool_result') onToolEvent?.(null, {}, {
200
200
  id: b.tool_use_id, result: true, isError: b.is_error === true,
201
+ toolSuccess: b.is_error !== true,
201
202
  ...(agentId ? { agentId } : {}),
202
203
  output: typeof b.content === 'string' ? b.content : Array.isArray(b.content) ? b.content.slice(0, 8).filter(x => x?.type === 'text' && typeof x.text === 'string').map(x => x.text).join('\n') : '',
203
204
  detail: typeof b.content === 'string' ? b.content : Array.isArray(b.content) ? b.content.filter(x => x?.type === 'text' && typeof x.text === 'string').map(x => x.text).join('\n') : '',
@@ -232,6 +233,12 @@ export function handleStreamLine(line, options) {
232
233
  // alias, and this is the CLI naming what it chose. See turnModel.mjs.
233
234
  const resolved = turnModelOf(ev.model);
234
235
  if (resolved) onModel?.(resolved);
236
+ if (Array.isArray(ev.tools)) for (const tool of ['WebSearch', 'WebFetch']) {
237
+ onToolEvent?.('WebsiteAccess', { method: 'capability', tool }, {
238
+ id: `website-capability:${tool}`, result: true,
239
+ website: { access: 'unknown', availability: ev.tools.includes(tool) ? 'advertised' : 'unknown' },
240
+ });
241
+ }
235
242
  if (Array.isArray(ev.skills) || typeof ev.session_id === 'string' || Array.isArray(ev.mcp_servers)) {
236
243
  onInit?.({
237
244
  skills: Array.isArray(ev.skills) ? ev.skills.map(String) : undefined,
@@ -109,7 +109,7 @@ const table = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});
109
109
  * config.toml could not be read), and
110
110
  * `unmounted` `[{ name, reason, source: 'project' | 'machine', partial? }]`.
111
111
  */
112
- export function codexFencedMount(usable, expand, machineEnv, turnServers = {}) {
112
+ export function codexFencedMount(usable, expand, machineEnv, turnServers = {}, { includeMachine = true } = {}) {
113
113
  const args = [];
114
114
  const childEnv = {};
115
115
  const mounted = [];
@@ -192,7 +192,7 @@ export function codexFencedMount(usable, expand, machineEnv, turnServers = {}) {
192
192
  }
193
193
 
194
194
  const projectNames = new Set([...usable.map((t) => t.name), ...ownNames]);
195
- const personal = machineCodexServers(machineEnv);
195
+ const personal = includeMachine ? machineCodexServers(machineEnv) : [];
196
196
  for (const [name, server] of personal ?? []) {
197
197
  if (projectNames.has(name) || server.enabled === false) continue;
198
198
  if (typeof server.command !== 'string' && typeof server.url !== 'string') continue;
package/bin/lib/fleet.mjs CHANGED
@@ -278,6 +278,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
278
278
  retireWorkSessions,
279
279
  reportWorktrees,
280
280
  reconcileIdleArtifacts,
281
+ reconcileLocalCleanup,
281
282
  shutdownWork,
282
283
  settleAgentTurns,
283
284
  workBusy,
@@ -291,6 +292,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
291
292
  getBaseRef,
292
293
  getMcpUrl: () => mcpUrl,
293
294
  getLeaseTtl: () => leaseTtlSeconds,
295
+ getCleanupPermission: () => holderState === 'mine' && !standingDown() && !readMaintenance(),
294
296
  /**
295
297
  * The cartographer is a CLI turn too, and it is the one this manager cannot
296
298
  * see — it lives in the wiki runner (wikiRunner.mjs), not in
@@ -649,6 +651,13 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
649
651
  // once per process, beside no turn (workArtifactReconcile.mjs). After the
650
652
  // turn intake above, so a card about to run in a place is not raced.
651
653
  void reconcileIdleArtifacts(roster.activeWorkSessions)?.catch(() => {});
654
+ // First authoritative serving roster triggers startup recovery; later
655
+ // idle beats are bounded internally. Refresh evidence on every roster so
656
+ // an in-flight remote check cannot outlive ownership or a peer lease.
657
+ void reconcileLocalCleanup({ activeIds: roster.activeWorkSessions,
658
+ heldElsewhere: roster.sessionsHeldElsewhere, serving: holderState === 'mine',
659
+ draining: standingDown() || Boolean(readMaintenance()),
660
+ });
652
661
  // Terminal-session presence, throttled + dedup'd inside; never awaited —
653
662
  // the daemon's own worktrees are carved out (a session the daemon spawned
654
663
  // is already a tab, not something to offer adopting).
@@ -33,7 +33,7 @@ function readRegular(path, max = ARTIFACT_BINARY_MODEL_MAX_BYTES) {
33
33
  try {
34
34
  const stat = fstatSync(fd);
35
35
  if (!stat.isFile()) throw new Error('Intermediate artifact is not a regular file.');
36
- if (stat.size > max) throw new Error(`Intermediate artifact is ${stat.size} bytes; the per-file limit is ${max} bytes.`);
36
+ if (stat.size > max) throw new Error(`Intermediate artifact is ${stat.size} bytes; the per-file limit is ${max} bytes. Preserve these exact producer bytes and retry after a compatible update, or revise this producer explicitly.`);
37
37
  const bytes = Buffer.alloc(stat.size);
38
38
  let offset = 0;
39
39
  while (offset < bytes.length) {
@@ -116,7 +116,7 @@ export function createIntermediateArtifacts({ wt, agentId, path, exportPage = ex
116
116
  try {
117
117
  if (!safeName(ref.name) || !/^[a-f0-9]{64}$/.test(ref.sha256) || ref.path !== copyPath(ref.sha256, ref.name))
118
118
  throw new Error('Invalid intermediate artifact version identity.');
119
- if (!historical && state.pending?.taskId && state.pending.taskId === ref.taskId && state.pending.sources.some(source =>
119
+ if (!historical && state.pending && (state.pending.recovery === true || state.pending.taskId === ref.taskId) && state.pending.sources.some(source =>
120
120
  source.name === ref.name || source.name === ref.model?.page)) throw new Error('Producer handoff remains unfinished');
121
121
  let bytes;
122
122
  try { bytes = readIntermediateFile(wt, ref.path); }
@@ -145,15 +145,21 @@ export function createIntermediateArtifacts({ wt, agentId, path, exportPage = ex
145
145
  const state = load(), taskId = taskOf(job);
146
146
  const identity = { agentId, taskId, turnId, taskKind };
147
147
  const standing = scanArtifacts(wt, { all: true });
148
- const changed = changedArtifacts(before, standing);
148
+ let changed = changedArtifacts(before, standing);
149
149
  // Failed export/storage retries are tied to a card and exact source hash.
150
150
  // A changed page is a new version; it never reuses a previous GLB receipt.
151
151
  // A source the agent's iteration declaration discards or replaces is the
152
152
  // explicit revision and leaves the retry set.
153
- const pending = state.pending?.taskId === taskId && taskId ? state.pending : null;
153
+ const pending = state.pending?.taskId === taskId && (taskId || (state.pending.recovery === true && job.kind === 'human')) ? state.pending : null;
154
+ if (state.pending?.recovery && !pending)
155
+ throw new Error('An exact producer recovery is pending. Retry that producer before starting different work.');
156
+ if (pending?.recovery) {
157
+ identity.recoveredFromTurnId = pending.recoverySourceTurnId ?? pending.turnId;
158
+ changed = changed.filter(e => pending.sources.some(s => s.name === e.name));
159
+ }
154
160
  const discarded = pending ? declaredDiscards(wt) : new Set();
155
161
  if (pending) for (const ref of pending.sources) {
156
- if (changed.some(e => e.name === ref.name) || discarded.has(artifactStoredName(ref.name))) continue;
162
+ if (!pending.recovery && (changed.some(e => e.name === ref.name) || discarded.has(artifactStoredName(ref.name)))) continue;
157
163
  const entry = standing.find(e => e.name === ref.name);
158
164
  let bytes;
159
165
  try { bytes = readIntermediateFile(wt, `.flowviant/artifacts/${ref.name}`, artifactMaxBytesFor(ref.name)); }
@@ -161,7 +167,7 @@ export function createIntermediateArtifacts({ wt, agentId, path, exportPage = ex
161
167
  if (hash(bytes) !== ref.sha256)
162
168
  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.`);
163
169
  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.`);
164
- changed.push(entry);
170
+ if (!changed.some(e => e.name === entry.name)) changed.push(entry);
165
171
  }
166
172
  if (taskKind === 'model' && !changed.some(e => /\.html?$/i.test(e.name)) && (job.kind !== 'task' || job.redo === true) && taskId) {
167
173
  for (const ref of state.files.filter(f => f.taskId === taskId && f.taskKind === 'model' && /\.html?$/i.test(f.name))) {
@@ -171,7 +177,7 @@ export function createIntermediateArtifacts({ wt, agentId, path, exportPage = ex
171
177
  }
172
178
  const pages = taskKind === 'model' ? changed.filter(e => /\.html?$/i.test(e.name)) : [];
173
179
  const outputs = changed.filter(e => artifactTypeFor(e.name));
174
- state.pending = { taskId, turnId, sources: outputs.map(e => ({ name: e.name, sha256: hash(readIntermediateFile(wt, `.flowviant/artifacts/${e.name}`, artifactMaxBytesFor(e.name))) })), exports: pending?.exports ?? [] };
180
+ 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 ?? [] };
175
181
  save(state);
176
182
  const exports = [];
177
183
  for (const page of pages) {
@@ -226,7 +232,33 @@ export function createIntermediateArtifacts({ wt, agentId, path, exportPage = ex
226
232
  save({ ...state, files: [...byName.values()], pending: null });
227
233
  } };
228
234
  };
229
- return { inputs, prepare };
235
+ /** Offline recovery of an interrupted producer with no saved pending set.
236
+ * The operator supplies the original log/export binding, never a new model
237
+ * or a same-name Library lookup. This publishes no consumer receipt. */
238
+ const restorePending = ({ taskId, turnId, sources, exports }) => {
239
+ const id = /^[A-Za-z0-9_-]{1,80}$/;
240
+ if (!(taskId === null || id.test(taskId ?? '')) || !id.test(turnId ?? '') ||
241
+ !Array.isArray(sources) || !sources.length || sources.length > ARTIFACT_MAX_PER_AGENT ||
242
+ !Array.isArray(exports) || !exports.length ||
243
+ new Set(sources.map(s => s.name)).size !== sources.length)
244
+ throw new Error('Recovery needs the exact producer task, turn, source hashes and page-export bindings.');
245
+ const state = load();
246
+ if (state.pending && (state.pending.taskId !== taskId || state.pending.turnId !== turnId))
247
+ throw new Error('Another producer handoff is pending. Preserve its receipt before recovery.');
248
+ for (const ref of sources) {
249
+ if (!safeName(ref.name) || !artifactTypeFor(ref.name) || !/^[a-f0-9]{64}$/.test(ref.sha256 ?? '') ||
250
+ hash(readIntermediateFile(wt, `.flowviant/artifacts/${ref.name}`, artifactMaxBytesFor(ref.name))) !== ref.sha256)
251
+ throw new Error(`Recovery source ${ref.name} does not match its original producer hash. Restore the exact bytes before retrying.`);
252
+ }
253
+ if (!exports.every(ref => ref.name === ref.page?.replace(/\.html?$/i, '.glb') &&
254
+ /\.html?$/i.test(ref.page) && sources.some(s => s.name === ref.page && s.sha256 === ref.pageSha256) &&
255
+ sources.some(s => s.name === ref.name && s.sha256 === ref.sha256)) || !measuredModelExports(wt, exports))
256
+ throw new Error('Recovery page/export binding is missing, changed or unusable. Restore the original producer export evidence.');
257
+ save({ ...state, pending: { taskId, turnId, recovery: true, recoverySourceTurnId: turnId,
258
+ sources: sources.map(({ name, sha256 }) => ({ name, sha256 })),
259
+ exports: exports.map(({ page, pageSha256, name, sha256 }) => ({ page, pageSha256, name, sha256 })) } });
260
+ };
261
+ return { inputs, prepare, restorePending };
230
262
  }
231
263
 
232
264
  export function intermediateArtifactContext(files = []) {
@@ -41,6 +41,7 @@
41
41
  */
42
42
 
43
43
  import { safeFileName } from './safeFileName.mjs';
44
+ import { ARTIFACT_BINARY_MODEL_MAX_BYTES } from './artifactPolicy.mjs';
44
45
 
45
46
  /** Relative to the checkout, under `.flowviant/` beside the turn uploads and
46
47
  * the artifacts — see `FLOWVIANT_OWN_PATHS` for why that is NOT one exclude
@@ -140,7 +141,7 @@ export const INSTRUCTIONS_FILE = 'INSTRUCTIONS.md';
140
141
  export const KNOWLEDGE_FILE_MAX_BYTES = 10 * 1024 * 1024;
141
142
  export const KNOWLEDGE_BINARY_MODEL_MAX_BYTES = 20 * 1024 * 1024;
142
143
  export const knowledgeMaxFor = (name, base) => /\.(glb|bin)$/i.test(name)
143
- ? Math.max(base, KNOWLEDGE_BINARY_MODEL_MAX_BYTES) : base;
144
+ ? Math.max(base, /^(models|designs)\//.test(name) ? ARTIFACT_BINARY_MODEL_MAX_BYTES : KNOWLEDGE_BINARY_MODEL_MAX_BYTES) : base;
144
145
  /** The manifest is capped at the server (25 files); a manifest longer than
145
146
  * this is not one the product produced, and is cut rather than trusted. */
146
147
  export const MAX_FILES = 64;
@@ -0,0 +1,34 @@
1
+ /** Local diagnostics only: consume measured outcomes, never estimate disk use. */
2
+ import { randomUUID } from 'node:crypto';
3
+ import { retainTurnRecord } from './daemonLogging.mjs';
4
+ import { encodeDetailRecord, decodeDetailRecord } from './daemonJournal.mjs';
5
+ import { formatTurnRecord } from './turnRecord.mjs';
6
+
7
+ export function reportLocalCleanup(entry, { retain = retainTurnRecord,
8
+ write = text => process.stderr.write(text) } = {}) {
9
+ if (!entry || entry.reason === 'already-evicted') return true;
10
+ const id = entry.id || randomUUID();
11
+ const bytes = entry.status === 'reclaimed' && Number.isFinite(entry.bytes) && entry.bytes >= 0 ? entry.bytes : null;
12
+ const text = JSON.stringify({ operation: entry.kind === 'artifact' ? 'local-cache-eviction' : 'managed-local-cleanup',
13
+ attemptId: id, status: entry.status, kind: entry.kind,
14
+ name: entry.name, path: entry.path, projectId: entry.projectId,
15
+ place: entry.place, placeDir: entry.placeDir, agentId: entry.agentId,
16
+ sessionId: entry.sessionId, localVersion: entry.localVersion,
17
+ reclaimedFiles: entry.status === 'reclaimed' ? 1 : 0,
18
+ reclaimedBytes: bytes, reason: entry.reason, error: entry.error,
19
+ incomplete: entry.incomplete === true,
20
+ retryAt: entry.status === 'failed' || entry.incomplete || entry.status === 'retained' ? entry.nextAt : undefined,
21
+ tries: entry.tries, resumedAttempt: entry.resumedAttempt,
22
+ previousCompletion: entry.previousCompletion,
23
+ cloudCopies: 'unchanged', repositoryFiles: 'unchanged' });
24
+ // Use the journal's own scrub/clip policy even when retention fails and the
25
+ // complete diagnostic must be shown live. No raw paths/errors reach output.
26
+ const record = decodeDetailRecord(encodeDetailRecord({ kind: 'cleanup',
27
+ name: 'Local cleanup', workId: entry.agentId || entry.sessionId || entry.place || '',
28
+ turnId: entry.turnId || `cleanup:${id}`, source: `cleanup:${id}`,
29
+ observedAt: entry.at ?? Date.now(), text }));
30
+ const retained = retain(record);
31
+ if (retained !== true || entry.status === 'failed' || entry.incomplete)
32
+ write(formatTurnRecord(record, { color: false }));
33
+ return retained === true;
34
+ }
@@ -0,0 +1,68 @@
1
+ /** Private operational attempts, not a diagnostic log. Atomic and bounded;
2
+ * losing/corrupting state never grants permission to delete. */
3
+ import { constants, openSync, closeSync, fstatSync, readFileSync, writeFileSync, fsyncSync, renameSync, rmSync, lstatSync, realpathSync, linkSync } from 'node:fs';
4
+ import { dirname, resolve } from 'node:path';
5
+ import { randomUUID } from 'node:crypto';
6
+ import { processAlive, processStartTime, bootMark, sameBoot } from './procRegistry.mjs';
7
+
8
+ export function cleanupState(path, identity) {
9
+ if (realpathSync(dirname(path)) !== resolve(dirname(path))) throw new Error('cleanup-state-parent-unsafe');
10
+ const read = file => {
11
+ const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
12
+ try {
13
+ const st = fstatSync(fd);
14
+ if (!st.isFile() || st.size > 2 * 1024 * 1024) throw new Error('cleanup-state-unreadable');
15
+ return JSON.parse(readFileSync(fd, 'utf8'));
16
+ } finally { closeSync(fd); }
17
+ };
18
+ const load = () => {
19
+ let value;
20
+ try { value = read(path); }
21
+ catch (e) { if (e.code === 'ENOENT') return { version: 1, identity, cursor: 0, attempts: {}, results: [] }; throw e; }
22
+ if (value.version !== 1 || value.identity !== identity || !Number.isSafeInteger(value.cursor) || value.cursor < 0 ||
23
+ !value.attempts || typeof value.attempts !== 'object' || Array.isArray(value.attempts) || Object.keys(value.attempts).length > 2000 ||
24
+ !Array.isArray(value.results) || value.results.length > 128 || Object.values(value.attempts).some(a =>
25
+ !Number.isFinite(a.nextAt) || !Number.isSafeInteger(a.tries) || a.tries < 0)) throw new Error('cleanup-state-unreadable');
26
+ return value;
27
+ };
28
+ const save = value => {
29
+ const tmp = `${path}.${randomUUID()}.tmp`, bytes = JSON.stringify(value);
30
+ if (Buffer.byteLength(bytes) > 2 * 1024 * 1024) throw new Error('cleanup-state-budget-exhausted');
31
+ try {
32
+ const fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
33
+ try { writeFileSync(fd, bytes); fsyncSync(fd); } finally { closeSync(fd); }
34
+ renameSync(tmp, path);
35
+ const parent = openSync(dirname(path), constants.O_RDONLY | constants.O_NOFOLLOW);
36
+ try { fsyncSync(parent); } finally { closeSync(parent); }
37
+ } finally { rmSync(tmp, { force: true }); }
38
+ };
39
+ /** No age-only lock stealing: a live or unreadable holder keeps its claim. */
40
+ const claim = () => {
41
+ const lock = `${path}.lock`, token = randomUUID();
42
+ for (let i = 0; i < 2; i++) {
43
+ try {
44
+ const tmp = `${lock}.${token}.tmp`;
45
+ try {
46
+ const fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
47
+ try { writeFileSync(fd, JSON.stringify({ identity, token, pid: process.pid, start: processStartTime(process.pid), boot: bootMark() })); fsyncSync(fd); }
48
+ finally { closeSync(fd); }
49
+ linkSync(tmp, lock); // publish only complete ownership evidence
50
+ } finally { rmSync(tmp, { force: true }); }
51
+ return () => { try { if (read(lock).token === token) rmSync(lock); } catch { /* retain an unknown replacement */ } };
52
+ } catch (error) {
53
+ if (error.code !== 'EEXIST') return null;
54
+ try {
55
+ const stat = lstatSync(lock), holder = read(lock);
56
+ if (holder.identity !== identity || !Number.isInteger(holder.pid) || holder.pid <= 1) return null;
57
+ const alive = processAlive(holder.pid), start = processStartTime(holder.pid);
58
+ if (alive && (!holder.boot || sameBoot(holder.boot)) && (!holder.start || !start || start === holder.start)) return null;
59
+ const current = lstatSync(lock);
60
+ if (current.ino !== stat.ino || current.mtimeMs !== stat.mtimeMs) return null;
61
+ rmSync(lock);
62
+ } catch { return null; }
63
+ }
64
+ }
65
+ return null;
66
+ };
67
+ return { load, save, claim };
68
+ }
@@ -29,10 +29,16 @@ function openCursor(path) {
29
29
 
30
30
  export function createLogReader(path, { turnId = null } = {}) {
31
31
  let current = null, previousSeen = null, initialized = false, closed = false;
32
+ const cleanupIds = new Set();
32
33
  const line = (raw, batch) => {
33
34
  const row = decodeDetailRecord(raw);
34
35
  if (row === false) { batch.lines.push('[detail gap: an incomplete or unreadable retained record was skipped]'); return; }
35
36
  if (row) {
37
+ if (row.kind === 'cleanup' && row.source.startsWith('cleanup:')) {
38
+ if (cleanupIds.has(row.source)) return;
39
+ if (cleanupIds.size >= 8192) cleanupIds.delete(cleanupIds.values().next().value);
40
+ cleanupIds.add(row.source);
41
+ }
36
42
  if (turnId !== null && !matchesTurn(row.turnId, turnId)) return;
37
43
  batch.matched++;
38
44
  for (const text of formatTurnRecord(row, { color: false }).slice(0, -1).split('\n')) batch.lines.push(text);