flowviant 0.125.18 → 0.125.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,336 @@
1
+ /** Versioned local cache ownership. Call mutations under the place writer lock.
2
+ * No age, Library membership, or successful upload proves a file disposable.
3
+ * The lifecycle owner must supply a complete, version-bound use/bundle release. */
4
+ import { createHash, randomUUID } from 'node:crypto';
5
+ import { constants, openSync, closeSync, fsyncSync, fstatSync, readFileSync, writeFileSync, renameSync, unlinkSync, linkSync, rmSync, lstatSync, realpathSync } from 'node:fs';
6
+ import { dirname, join, resolve } from 'node:path';
7
+ import { ensureRealSubdir, realDirState } from './ownedDirs.mjs';
8
+
9
+ const hash = b => createHash('sha256').update(b).digest('hex');
10
+ const safe = n => typeof n === 'string' && n.length <= 500 && !/[\\\x00-\x1f\x7f]/.test(n) && n.split('/').every(p => p && p !== '.' && p !== '..' && !p.startsWith('.'));
11
+ const sha = s => /^[a-f0-9]{64}$/.test(s ?? '');
12
+ const stamp = s => `${s.dev}:${s.ino}:${s.size}:${s.mtimeMs}:${s.ctimeMs}`;
13
+ const MAX = 32 * 1024 * 1024;
14
+
15
+ export function createArtifactCache({ placeDir, path, scope, fetchVersion, getDiscards = () => new Set(), getUses = () => [], managedKind = null, managedRoot = null, unlink = unlinkSync }) {
16
+ if (managedKind && !['scratch', 'native-image'].includes(managedKind)) throw new Error('unknown-managed-kind');
17
+ const root = managedKind ? resolve(managedRoot) : resolve(placeDir, '.flowviant/artifacts');
18
+ const relative = name => managedKind ? name : `.flowviant/artifacts/${name}`;
19
+ const pathFor = name => join(root, name);
20
+ const identity = JSON.stringify({ ...scope, placeDir: resolve(placeDir), ...(managedKind ? { managedKind, root } : {}) });
21
+ const load = () => {
22
+ if (!path) throw new Error('ownership-unavailable');
23
+ let fd;
24
+ try {
25
+ fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
26
+ const st = fstatSync(fd);
27
+ if (!st.isFile() || st.size > 2 * 1024 * 1024) throw new Error('ownership-unreadable');
28
+ const state = JSON.parse(readFileSync(fd, 'utf8'));
29
+ if (state.version !== 1 || state.identity !== identity || !Array.isArray(state.files) || state.files.length > 2000 || state.files.some(f =>
30
+ !safe(f.name) || f.relativePath !== relative(f.name) || !sha(f.sha256) || typeof f.localVersion !== 'string' || !['local', 'evicting', 'evicted', 'restoring', 'discarded'].includes(f.state))) throw new Error('ownership-unreadable');
31
+ return state;
32
+ } catch (e) {
33
+ if (e.code === 'ENOENT') {
34
+ try { lstatSync(`${path}.guard`); }
35
+ catch (guardError) {
36
+ if (guardError.code === 'ENOENT') return { version: 1, identity, files: [] };
37
+ throw guardError;
38
+ }
39
+ throw new Error('ownership-ledger-missing');
40
+ }
41
+ throw e;
42
+ }
43
+ finally { if (fd !== undefined) closeSync(fd); }
44
+ };
45
+ const save = state => {
46
+ if (!path || realDirState(dirname(path)) !== 'dir') throw new Error('ownership-unavailable');
47
+ const tmp = `${path}.${randomUUID()}.tmp`;
48
+ try {
49
+ // The independent guard distinguishes a legacy unmeasured place from
50
+ // loss of a ledger that may contain eviction receipts. Persist it first.
51
+ let guard;
52
+ try {
53
+ guard = openSync(`${path}.guard`, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
54
+ writeFileSync(guard, identity); fsyncSync(guard);
55
+ } catch (e) { if (e.code !== 'EEXIST') throw e; }
56
+ finally { if (guard !== undefined) closeSync(guard); }
57
+ const fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
58
+ try { writeFileSync(fd, JSON.stringify(state)); fsyncSync(fd); } finally { closeSync(fd); }
59
+ renameSync(tmp, path);
60
+ const parent = openSync(dirname(path), constants.O_RDONLY | constants.O_NOFOLLOW);
61
+ try { fsyncSync(parent); } finally { closeSync(parent); }
62
+ }
63
+ finally { rmSync(tmp, { force: true }); }
64
+ };
65
+ const measure = name => {
66
+ if (!safe(name)) throw new Error('unsafe-path');
67
+ // A registered managed root must be physical, not an alias into a shared
68
+ // home or source tree. Every descendant parent is checked separately.
69
+ if (realpathSync(root) !== root) throw new Error('unsafe-parent');
70
+ let parent = root;
71
+ for (const part of (dirname(name) === '.' ? [] : dirname(name).split('/'))) {
72
+ parent = join(parent, part);
73
+ const kind = realDirState(parent);
74
+ if (kind !== 'dir') throw Object.assign(new Error('unsafe-parent'), { code: kind === 'none' ? 'ENOENT' : 'UNSAFE' });
75
+ }
76
+ let fd;
77
+ try {
78
+ fd = openSync(pathFor(name), constants.O_RDONLY | constants.O_NOFOLLOW);
79
+ const st = fstatSync(fd);
80
+ if (!st.isFile() || st.size > MAX) throw new Error('unsafe-file');
81
+ const bytes = readFileSync(fd);
82
+ if (stamp(fstatSync(fd)) !== stamp(st) || bytes.length !== st.size) throw new Error('local-version-changed');
83
+ return { bytes, sha256: hash(bytes), signature: stamp(st), size: st.size };
84
+ } finally { if (fd !== undefined) closeSync(fd); }
85
+ };
86
+ /** Only a measured local version paired with the exact successful upload. */
87
+ const record = ({ name, remote, uses = null }) => {
88
+ const local = measure(name);
89
+ if (!remote?.id || remote.sha256 !== local.sha256) throw new Error('remote-version-unverified');
90
+ const state = load(), old = state.files.find(f => f.name === name);
91
+ if (old?.state !== 'discarded' && old?.signature === local.signature && old.sha256 === local.sha256 && old.remote?.id === remote.id && !old.uses?.includes('pending-upload')) return old;
92
+ const ref = { name, relativePath: relative(name), sha256: local.sha256, bytes: local.size,
93
+ signature: local.signature, localVersion: randomUUID(), remote: { id: remote.id, sha256: remote.sha256 },
94
+ uses, bundle: null, state: 'local' };
95
+ state.files = [...state.files.filter(f => f.name !== name), ref]; save(state); return ref;
96
+ };
97
+ /** A queued upload is owned but unrecoverable until its exact remote id
98
+ * arrives. A crash loses no use pin and grants no eviction permission. */
99
+ const trackUpload = name => {
100
+ const local = measure(name), state = load(), old = state.files.find(f => f.name === name);
101
+ const same = old && old.state !== 'discarded' && old.signature === local.signature && old.sha256 === local.sha256;
102
+ const ref = { name, relativePath: relative(name), sha256: local.sha256, bytes: local.size,
103
+ signature: local.signature, localVersion: same ? old.localVersion : randomUUID(), remote: same ? old.remote : null,
104
+ uses: [...new Set([...(same && Array.isArray(old.uses) ? old.uses : ['requirements-unknown']), 'pending-upload'])],
105
+ bundle: null, state: 'local' };
106
+ state.files = [...state.files.filter(f => f.name !== name), ref]; save(state); return ref;
107
+ };
108
+ const recordManaged = ({ name, turnId, preserved = null }) => {
109
+ if (!managedKind || !turnId) throw new Error('ownership-unknown');
110
+ const local = measure(name), state = load();
111
+ const old = state.files.find(f => f.name === name);
112
+ if (old && old.state !== 'discarded' && old.signature === local.signature && old.sha256 === local.sha256) {
113
+ if (old.turnId !== turnId) {
114
+ old.uses = [...new Set([...(old.uses ?? ['requirements-unknown']), `turn:${turnId}`,
115
+ ...(managedKind === 'native-image' ? ['resume-inputs-unknown'] : [])])];
116
+ old.turnId = turnId; old.state = 'local';
117
+ }
118
+ if (preserved && preserved.sha256 === local.sha256) { old.preserved = preserved; old.remote = preserved.remote; }
119
+ save(state); return old;
120
+ }
121
+ if (preserved && preserved.sha256 !== local.sha256) throw new Error('preserved-copy-unknown');
122
+ const ref = { name, relativePath: relative(name), kind: managedKind, turnId,
123
+ sha256: local.sha256, bytes: local.size, signature: local.signature,
124
+ localVersion: randomUUID(), remote: preserved?.remote ?? null, preserved,
125
+ uses: [`turn:${turnId}`, ...(managedKind === 'native-image' ? ['resume-inputs-unknown'] : /\.(png|webp|glb|gltf|bin|obj|mtl|html?|json)$/i.test(name) ? ['required-inputs-unknown'] : [])],
126
+ bundle: null, state: 'local' };
127
+ state.files = [...state.files.filter(f => f.name !== name), ref]; save(state); return ref;
128
+ };
129
+ /** Evidence is version-bound. Null uses/bundle means unknown, never released.
130
+ * Include review/revision, pending uploads, producer/consumer and resume pins.
131
+ * Bundle lists exact versions of every viewer, model and original resource. */
132
+ const requirements = ({ name, localVersion, uses, bundle }) => {
133
+ const state = load(), ref = state.files.find(f => f.name === name && f.localVersion === localVersion);
134
+ if (!ref || ref.state !== 'local') throw new Error('local-version-changed');
135
+ if (managedKind === 'scratch') {
136
+ if (!Array.isArray(uses) || !uses.every(u => typeof u === 'string')) throw new Error('requirements-unknown');
137
+ ref.uses = uses; save(state); return;
138
+ }
139
+ if (!Array.isArray(uses) || !uses.every(u => typeof u === 'string') || !bundle?.complete || !Array.isArray(bundle.files) || !bundle.files.length || bundle.files.length > 256 || new Set(bundle.files.map(f => f.name)).size !== bundle.files.length ||
140
+ !bundle.files.every(f => safe(f.name) && sha(f.sha256) && typeof f.id === 'string') ||
141
+ !bundle.files.some(f => f.name === name && f.id === ref.remote?.id && f.sha256 === ref.sha256)) throw new Error('requirements-unknown');
142
+ ref.uses = uses; ref.bundle = bundle; save(state);
143
+ };
144
+ const verified = async ref => {
145
+ if (!fetchVersion) throw new Error('recovery-unavailable');
146
+ const bytes = Buffer.from(await fetchVersion(ref));
147
+ if (bytes.length > MAX || hash(bytes) !== ref.sha256) throw new Error('remote-version-mismatch');
148
+ return bytes;
149
+ };
150
+ const eligibility = ref => {
151
+ if (!ref || ref.state === 'discarded') return 'ownership-unknown';
152
+ if (!Array.isArray(ref.uses) || (managedKind !== 'scratch' && !ref.bundle?.complete)) return 'requirements-unknown';
153
+ if (ref.uses.length) return 'active-use';
154
+ return null;
155
+ };
156
+ const bundleEligibility = (state, ref) => {
157
+ if (managedKind === 'scratch') return null;
158
+ for (const file of ref.bundle?.files ?? []) {
159
+ const member = state.files.find(f => f.name === file.name && f.sha256 === file.sha256 && f.remote?.id === file.id);
160
+ const reason = eligibility(member);
161
+ if (reason || getUses(file.name).length) return reason ?? 'active-use';
162
+ }
163
+ return null;
164
+ };
165
+ /** Non-mutating remote phase, safe outside the place writer lock. The
166
+ * caller may memoize these exact bytes for a brief locked eviction phase. */
167
+ const prepareEviction = async ({ name, localVersion }) => {
168
+ try {
169
+ const state = load(), ref = state.files.find(f => f.name === name && f.localVersion === localVersion);
170
+ if (ref?.state === 'evicted') return { status: 'ready', name }; // bookkeeping only; no new deletion
171
+ const reason = eligibility(ref);
172
+ if (reason || getUses(name).length || getDiscards().has(name)) return { status: 'retained', name, reason: reason ?? 'active-use' };
173
+ const bundleReason = bundleEligibility(state, ref);
174
+ if (bundleReason) return { status: 'retained', name, reason: bundleReason };
175
+ let bytes = 0;
176
+ for (const file of ref.bundle?.files ?? []) {
177
+ bytes += (await verified({ ...file, remote: { id: file.id, sha256: file.sha256 } })).length;
178
+ if (bytes > 80 * 1024 * 1024) throw new Error('bundle-too-large');
179
+ }
180
+ return { status: 'ready', name };
181
+ } catch (error) { return { status: 'retained', name, reason: error.message }; }
182
+ };
183
+ const evict = async ({ name, localVersion }) => {
184
+ let reclaimed = null;
185
+ try {
186
+ let state = load(), ref = state.files.find(f => f.name === name && f.localVersion === localVersion);
187
+ if (getDiscards().has(name)) return { status: 'retained', name, reason: 'explicit-discard' };
188
+ if (getUses(name).length) return { status: 'retained', name, reason: 'active-use' };
189
+ const reason = eligibility(ref); if (reason) return { status: 'retained', name, reason };
190
+ const bundleReason = bundleEligibility(state, ref);
191
+ if (bundleReason) return { status: 'retained', name, reason: bundleReason };
192
+ if (!ref.remote && managedKind !== 'scratch') return { status: 'retained', name, reason: 'pending-upload' };
193
+ if (ref.state === 'restoring') return { status: 'retained', name, reason: 'restoration-pending' };
194
+ if (ref.state === 'evicted') {
195
+ try {
196
+ measure(name);
197
+ // A newly present file is a new local use/version, even at the same
198
+ // bytes. Retire the old eviction meaning under the writer lock.
199
+ ref.state = 'discarded'; save(state);
200
+ return { status: 'retained', name, reason: 'local-version-changed', bytes: 0 };
201
+ } catch (error) {
202
+ if (error.code !== 'ENOENT') throw error;
203
+ return { status: 'retained', name, reason: 'already-evicted', bytes: 0 };
204
+ }
205
+ }
206
+ // Verify the entire exact bundle before deleting even one member.
207
+ let bundleBytes = 0;
208
+ for (const file of ref.bundle?.files ?? []) {
209
+ if (getUses(file.name).length) return { status: 'retained', name, reason: 'active-use' };
210
+ bundleBytes += (await verified({ ...file, remote: { id: file.id, sha256: file.sha256 } })).length;
211
+ if (bundleBytes > 80 * 1024 * 1024) throw new Error('bundle-too-large');
212
+ }
213
+ // Network waits may have admitted a rewrite, pin or explicit discard.
214
+ state = load(); const current = state.files.find(f => f.name === name);
215
+ if (getDiscards().has(name)) return { status: 'retained', name, reason: 'explicit-discard' };
216
+ if (!current || current.localVersion !== localVersion || eligibility(current) || bundleEligibility(state, current) || JSON.stringify(current.bundle) !== JSON.stringify(ref.bundle))
217
+ return { status: 'retained', name, reason: 'requirements-changed' };
218
+ let local;
219
+ try { local = measure(name); }
220
+ catch (error) {
221
+ if (error.code !== 'ENOENT' || current.state !== 'evicting') throw error;
222
+ // A crash may have happened after unlink but before receipt/result.
223
+ // Never infer reclaimed bytes from absence, and never replay discard.
224
+ current.state = 'evicted'; save(state);
225
+ return { status: 'retained', name, bytes: 0, reason: 'interrupted-unlink-unmeasured', localVersion };
226
+ }
227
+ if (local.signature !== current.signature || local.sha256 !== current.sha256) return { status: 'retained', name, reason: 'local-version-changed' };
228
+ // Persist BEFORE unlink: a restart at either boundary must not report a
229
+ // cloud discard. Identity checks still distinguish a later rewrite.
230
+ current.state = 'evicting'; save(state);
231
+ const check = measure(name);
232
+ if (check.signature !== local.signature || check.sha256 !== local.sha256) return { status: 'retained', name, reason: 'local-version-changed' };
233
+ try { unlink(pathFor(name)); }
234
+ catch (error) { return { status: 'failed', name, localVersion, bytes: 0, reason: 'unlink-failed', error: error.message }; }
235
+ reclaimed = { status: 'reclaimed', name, bytes: local.size, localVersion };
236
+ current.state = 'evicted'; save(state);
237
+ return { status: 'reclaimed', name, bytes: local.size, localVersion };
238
+ } catch (error) { return reclaimed ? { ...reclaimed, incomplete: true, reason: error.message } : { status: 'retained', name, reason: error.message }; }
239
+ };
240
+ const discard = (name, remoteId = null) => {
241
+ const state = load(), ref = state.files.find(f => f.name === name);
242
+ if (ref && (!remoteId || ref.remote?.id === remoteId)) { ref.state = 'discarded'; save(state); }
243
+ };
244
+ /** Missing previously evicted files are not discards, even if the current
245
+ * inventory changed: this place never measured holding that newer remote.
246
+ * Existing changed files invalidate the receipt before a later deletion. */
247
+ const absences = (stored, discards = new Set()) => {
248
+ const state = load(), names = new Set(); let changed = false;
249
+ for (const ref of state.files) {
250
+ if (discards.has(ref.name) || ref.state === 'discarded') continue;
251
+ try {
252
+ const local = measure(ref.name);
253
+ if (local.sha256 !== ref.sha256 || (ref.state !== 'restoring' && local.signature !== ref.signature)) { ref.state = 'discarded'; changed = true; }
254
+ } catch (e) {
255
+ if (e.code === 'ENOENT' && ['evicting', 'evicted', 'restoring'].includes(ref.state)) names.add(ref.name);
256
+ }
257
+ }
258
+ if (changed) save(state);
259
+ return names;
260
+ };
261
+ /** Restore full bundles before the next consumer. Never overwrite a rewrite.
262
+ * All remote bytes are verified first; partial writes remain retryable. */
263
+ const restore = async (discards = new Set()) => {
264
+ if (managedKind) throw new Error('managed-source-is-not-an-output-cache');
265
+ const state = load(), restored = [];
266
+ for (const original of state.files) {
267
+ const ref = load().files.find(f => f.name === original.name);
268
+ if (!ref || !['evicting', 'evicted', 'restoring'].includes(ref.state) || discards.has(ref.name)) continue;
269
+ try {
270
+ const local = measure(ref.name);
271
+ // A later rewrite belongs to the writer; invalidate the old receipt.
272
+ if (local.sha256 !== ref.sha256 || (ref.state !== 'restoring' && local.signature !== ref.signature)) {
273
+ const latest = load(), current = latest.files.find(f => f.name === ref.name);
274
+ if (current?.localVersion === ref.localVersion) { current.state = 'discarded'; save(latest); }
275
+ continue;
276
+ }
277
+ } catch (e) { if (e.code !== 'ENOENT') throw e; }
278
+ const bundle = ref.bundle?.files;
279
+ if (!bundle?.length) throw new Error('bundle-unknown');
280
+ if (bundle.some(f => discards.has(f.name))) throw new Error('bundle-explicitly-discarded');
281
+ const files = []; let total = 0;
282
+ for (const file of bundle) {
283
+ const bytes = await verified({ ...file, remote: { id: file.id, sha256: file.sha256 } });
284
+ total += bytes.length;
285
+ if (total > 80 * 1024 * 1024) throw new Error('bundle-too-large');
286
+ files.push({ ...file, bytes });
287
+ }
288
+ const writeState = load(), writing = writeState.files.find(f => f.name === ref.name);
289
+ if (files.some(f => getDiscards().has(f.name)) || writing?.state === 'discarded') throw new Error('bundle-explicitly-discarded');
290
+ if (writing?.localVersion !== ref.localVersion) throw new Error('local-version-changed');
291
+ for (const file of files) {
292
+ const member = writeState.files.find(f => f.name === file.name);
293
+ try {
294
+ const local = measure(file.name);
295
+ if (local.sha256 !== file.sha256 || (member && member.state !== 'restoring' && member.signature !== local.signature)) throw new Error('local-version-changed');
296
+ } catch (e) { if (e.code !== 'ENOENT') throw e; }
297
+ }
298
+ // A separate intent distinguishes our partially restored bytes from a
299
+ // later same-content rewrite of an ordinary evicted version.
300
+ for (const file of files) {
301
+ const member = writeState.files.find(f => f.name === file.name);
302
+ if (member && ['evicting', 'evicted'].includes(member.state) && member.sha256 === file.sha256 && member.remote?.id === file.id) member.state = 'restoring';
303
+ }
304
+ save(writeState);
305
+ for (const file of files) {
306
+ const recorded = load().files.find(f => f.name === file.name);
307
+ if (recorded && (recorded.state === 'discarded' || recorded.sha256 !== file.sha256 || recorded.remote?.id !== file.id)) throw new Error('local-version-changed');
308
+ // A newer local version is never replaced with the old producer.
309
+ try { if (measure(file.name).sha256 !== file.sha256) throw new Error('local-version-changed'); continue; }
310
+ catch (e) { if (e.code !== 'ENOENT') throw e; }
311
+ const relative = `.flowviant/artifacts/${file.name}`;
312
+ if (!ensureRealSubdir(placeDir, dirname(relative))) throw new Error('unsafe-parent');
313
+ const target = join(placeDir, relative), tmp = `${target}.${randomUUID()}.tmp`;
314
+ try {
315
+ const fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
316
+ try { writeFileSync(fd, file.bytes); fsyncSync(fd); } finally { closeSync(fd); }
317
+ // link is exclusive: a racing writer wins; rename would overwrite it.
318
+ linkSync(tmp, target);
319
+ const parent = openSync(dirname(target), constants.O_RDONLY | constants.O_NOFOLLOW);
320
+ try { fsyncSync(parent); } finally { closeSync(parent); }
321
+ } finally { rmSync(tmp, { force: true }); }
322
+ restored.push(file.name);
323
+ }
324
+ // Keep the durable eviction meaning until the full bundle is restored.
325
+ const latest = load(), current = latest.files.find(f => f.name === ref.name);
326
+ if (current?.localVersion !== ref.localVersion || current.state === 'discarded') throw new Error('requirements-changed');
327
+ for (const file of files) {
328
+ const owned = latest.files.find(f => f.name === file.name && f.remote?.id === file.id && f.sha256 === file.sha256);
329
+ if (owned && owned.state !== 'discarded') { owned.state = 'local'; owned.signature = measure(file.name).signature; owned.uses = null; }
330
+ }
331
+ save(latest);
332
+ }
333
+ return restored;
334
+ };
335
+ return { record, recordManaged, verifyRemote: verified, prepareEviction, trackUpload, requirements, evict, restore, discard, absences, records: () => load().files, measure };
336
+ }
@@ -144,7 +144,7 @@ export function deletedArtifacts(placeDir, before) {
144
144
  // Only names this place held are candidates: this turn's snapshot and the
145
145
  // place's record (`before.seen`). Normalize live names before probing: a
146
146
  // draft+1.md is stored as draft_1.md and is not an absent retained file.
147
- const candidates = new Set([...(before?.keys() ?? []), ...(before?.seen ?? [])]);
147
+ const candidates = new Set([...(before?.keys() ?? []), ...(before?.seen ?? [])].filter(name => !before?.evicted?.has(artifactStoredName(name))));
148
148
  const live = presentArtifacts(placeDir);
149
149
  if (!live) {
150
150
  return flowviantParentState(placeDir) === 'dir' && realDirState(join(placeDir, ARTIFACT_DIR)) === 'none'
@@ -13,13 +13,17 @@ export const ARTIFACT_POLICY_SOURCE = 'packages/shared/src/schemas/artifact.sche
13
13
 
14
14
  /** Per text file and ordinary binary artifact. */
15
15
  export const ARTIFACT_MAX_BYTES = 2097152;
16
+ /** Above this size, upload binary bytes as a stream rather than multipart. */
17
+ export const ARTIFACT_MULTIPART_MAX_BYTES = 20971520;
16
18
  /** Binary model buffers (the extensions below). */
17
- export const ARTIFACT_BINARY_MODEL_MAX_BYTES = 20971520;
19
+ export const ARTIFACT_BINARY_MODEL_MAX_BYTES = 33554432;
18
20
  export const ARTIFACT_BINARY_MODEL_EXTS = ['glb', 'bin', 'png', 'webp'];
19
21
  /** Per Terminal session, and the per-turn discovery window. */
20
22
  export const ARTIFACT_MAX_PER_OWNER = 40;
21
23
  /** Per agent — the server keeps as many for one agent. */
22
24
  export const ARTIFACT_MAX_PER_AGENT = 400;
25
+ /** Selected result inputs, including exact embedded model textures. */
26
+ export const RESULT_INPUT_MAX_FILES = 256;
23
27
 
24
28
  /** Extension → the one mime the server serves it under; `text` is scrubbed. */
25
29
  export const ARTIFACT_TYPES = {
@@ -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) {
@@ -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)]) {
@@ -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,7 @@
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 { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readSync } from 'node:fs';
78
78
  import { createHash } from 'node:crypto';
79
79
  import { join } from 'node:path';
80
80
  import { cutKeepingExtension } from './safeFileName.mjs';
@@ -138,7 +138,7 @@ export function ensureArtifactDir(placeDir) {
138
138
  * points both variables at it (runtimeCodex.mjs `codexDesignFence`), so the
139
139
  * shell works like a shell; nothing here is uploaded, committed or read back.
140
140
  *
141
- * Made EMPTY before each turn (a previous turn's scratch is nobody's), and
141
+ * Created before each turn without erasing unrecorded or still-required files, and
142
142
  * only as a real directory under a real `.flowviant` — the artifacts
143
143
  * directory's rule, for its reason: a fence grant on a path through a
144
144
  * committed symlink is a write grant on its target. False creates nothing
@@ -149,9 +149,8 @@ export function ensureFenceScratch(placeDir) {
149
149
  const dir = join(placeDir, FENCE_TMP_DIR);
150
150
  if (flowviantParentState(placeDir) !== 'dir' || realDirState(dir) === 'other') return false;
151
151
  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);
152
+ // Reclamation requires a versioned ownership record and released uses.
153
+ if (realDirState(dir) === 'none') mkdirSync(dir);
155
154
  } catch {
156
155
  return false;
157
156
  }
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).