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.
- package/README.md +11 -1
- package/bin/cli.mjs +13 -0
- package/bin/lib/artifactCache.mjs +336 -0
- package/bin/lib/artifactContracts.mjs +87 -2
- package/bin/lib/artifactIteration.mjs +3 -2
- package/bin/lib/artifactPolicy.mjs +5 -1
- package/bin/lib/artifactReporter.mjs +91 -9
- package/bin/lib/artifacts.mjs +12 -6
- package/bin/lib/claudeStream.mjs +7 -0
- package/bin/lib/codexFencedMount.mjs +2 -2
- package/bin/lib/fleet.mjs +9 -0
- package/bin/lib/intermediateArtifacts.mjs +40 -8
- package/bin/lib/knowledgeLibrary.mjs +2 -1
- package/bin/lib/localCleanupReport.mjs +34 -0
- package/bin/lib/localCleanupState.mjs +68 -0
- package/bin/lib/logReplay.mjs +6 -0
- package/bin/lib/managedLocalCleanup.mjs +158 -0
- package/bin/lib/pinnedCapture.mjs +19 -8
- package/bin/lib/postureTools.mjs +2 -2
- package/bin/lib/publicReferenceBrowser.mjs +180 -0
- package/bin/lib/publicReferenceNetwork.mjs +131 -0
- package/bin/lib/publicReferenceProtocol.mjs +55 -0
- package/bin/lib/publicReferenceRuntime.mjs +65 -0
- package/bin/lib/publicReferenceTool.mjs +89 -0
- package/bin/lib/referenceCaptureProtocol.mjs +12 -0
- package/bin/lib/referenceCaptures.mjs +140 -0
- package/bin/lib/resultInputs.mjs +4 -2
- package/bin/lib/runTurn.mjs +8 -0
- package/bin/lib/runtimeCodex.mjs +1 -1
- package/bin/lib/runtimeEvents.mjs +24 -2
- package/bin/lib/uiReview.mjs +305 -0
- package/bin/lib/uiReviewBrowser.mjs +212 -0
- package/bin/lib/uiReviewContract.mjs +51 -0
- package/bin/lib/uiReviewTurn.mjs +15 -0
- package/bin/lib/websiteAccess.mjs +68 -0
- package/bin/lib/websiteTrace.mjs +36 -0
- package/bin/lib/work.mjs +46 -3
- package/bin/lib/workAgentPlans.mjs +3 -0
- package/bin/lib/workAgentPrecheck.mjs +16 -15
- package/bin/lib/workAgentReview.mjs +2 -2
- package/bin/lib/workAgentTools.mjs +3 -2
- package/bin/lib/workAgentTurnExecution.mjs +69 -4
- package/bin/lib/workAgentTurns.mjs +3 -1
- package/bin/lib/workLocalCleanup.mjs +125 -0
- package/bin/lib/workMergeBatch.mjs +19 -4
- package/bin/lib/workSessionGroups.mjs +20 -1
- package/bin/lib/workToolLog.mjs +24 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -188,7 +188,17 @@ From **0.86.0**, a project whose owner turns on **Publish agent branches** has e
|
|
|
188
188
|
|
|
189
189
|
You run your dev server yourself, in the session's own worktree, exactly as you would in any terminal. The daemon NOTICES the listening port; ask for a share in the app and it puts a [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) quick tunnel in front of it (auto-fetched if missing, pinned and checksummed), behind the daemon's own gate. Viewers reach the share through Flowviant's relay at `<name>.flowviant.dev`, never the tunnel's own address, and the gate admits only the share's password or a short-lived grant signed with the share's key. Flowviant stores that key and the password with the share, and its relay mints grants for signed-in project members — or for anyone with the link, if the share's owner opens it to everyone. Since 0.72.0 the gate also lets the Workbench embed the share in a frame: it rewrites the response's frame policy to permit exactly the app's origin, and a framed sign-in gets a partitioned cookie so it works where third-party cookies are blocked.
|
|
190
190
|
|
|
191
|
-
|
|
191
|
+
Sharing never starts a repository command. An earlier version read a `.flowviant/preview.json` from the branch and spawned the command it named — that sharing start path was removed in 0.53.0; see the header of `bin/lib/preview.mjs`.
|
|
192
|
+
|
|
193
|
+
## Local UI self-review in code agents
|
|
194
|
+
|
|
195
|
+
During a code build, the same agent can run a bounded local Chromium review before delivery. Its turn supplies the exact helper command and JSON-plan format. The agent chooses the repository-supported preview argv and dedicated port, names its script/configuration basis, and describes disposable fixtures. The runner starts that command inside the worktree with an isolated home and no inherited SDK credentials, measures its listener and process group, then checks HTTP readiness. It does not install a browser or dependencies, share a preview, or open a public tunnel.
|
|
196
|
+
|
|
197
|
+
Each plan contains local routes and ordered CSS-selector actions (`click`, `fill`, `submit`, `waitFor`, `assertText`, `assertUrl`). Chromium uses fresh contexts for 1440×900 desktop and 390×844 mobile captures. Click/submit mean DOM activation; assertions establish the resulting state. A preview-only proxy and request fence block other destinations, popups, downloads, frames, workers and sockets. Apps requiring those capabilities must report the affected checks skipped or use a disposable fixture that works within this boundary. Repository code still executes as the user's UID; the runner is not a sandbox for a malicious backend or a substitute for choosing safe fixtures.
|
|
198
|
+
|
|
199
|
+
On Claude Code and Codex build turns, `card_ui_review.inspect_screenshot` can supply an exact captured PNG as native image input. The agent must actually inspect the images and report what it saw, or report the runtime's image-input failure. A screenshot or image-input receipt alone never counts as a visual verdict. Other build CLIs can use their existing image tools if available and must name unavailable capabilities honestly.
|
|
200
|
+
|
|
201
|
+
The agent can fix in-scope findings and rerun up to three attempts per turn, each capped at two minutes including startup. Every attempt stops its owned browser and preview, including on cancellation; teardown also tracks their process groups. Reports and screenshots use `.flowviant/artifacts/`, and the existing delivery Verification retains routes, viewports, actions/outcomes, scrubbed diagnostics and source identity. Tracked/nonignored file bytes and HEAD are remeasured at delivery; later changes mark evidence stale. Ignored build/runtime files are outside that source measurement. The project check, fresh read-only pre-review, human approval and merge remain separate.
|
|
192
202
|
|
|
193
203
|
## Modes
|
|
194
204
|
|
package/bin/cli.mjs
CHANGED
|
@@ -85,6 +85,19 @@ if (process.argv[2] === '__capture-mcp') {
|
|
|
85
85
|
process.exit(process.exitCode ?? 0);
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
if (process.argv[2] === '__reference-mcp') {
|
|
89
|
+
await (await import('./lib/publicReferenceTool.mjs')).referenceToolMain();
|
|
90
|
+
// A screenshot can exceed the pipe buffer. EOF on the client's input must
|
|
91
|
+
// not truncate a completed result while stdout is still draining.
|
|
92
|
+
await new Promise(resolve => process.stdout.write('', resolve));
|
|
93
|
+
process.exit(process.exitCode ?? 0);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (['__ui-review', '__ui-review-mcp'].includes(process.argv[2])) {
|
|
97
|
+
await (await import('./lib/uiReview.mjs')).uiReviewMain(process.argv[2] === '__ui-review-mcp');
|
|
98
|
+
process.exit(process.exitCode ?? 0);
|
|
99
|
+
}
|
|
100
|
+
|
|
88
101
|
if (process.argv[2] === '__orchestrator-mcp') {
|
|
89
102
|
await (await import('./lib/orchestratorClient.mjs')).orchestratorMcpMain();
|
|
90
103
|
process.exit(process.exitCode ?? 0);
|
|
@@ -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
|
+
}
|
|
@@ -158,7 +158,54 @@ export const LOOK_HEADLESS = `${NO_WINDOW} To look at your page, screenshot it
|
|
|
158
158
|
* so it posts to `parent` with `'*'`; the app accepts only its own frame's
|
|
159
159
|
* messages. A page with no handshake (every 0.105.0 bundle) is still viewed;
|
|
160
160
|
* it is just never offered a Download .glb.
|
|
161
|
+
*
|
|
162
|
+
* THE MANIFEST AND THE POSTER (2026-10-25, the owner's `Output Viewer Frame`;
|
|
163
|
+
* decision record `2026-10-25-flowviant-draws-standard-outputs`). Flowviant
|
|
164
|
+
* draws the exported GLB in its own viewer, and every control over it, so the
|
|
165
|
+
* page becomes a full-bleed canvas and its folder gains `view.json`, the
|
|
166
|
+
* manifest the web reads when present (packages/shared/src/modelViewer.ts —
|
|
167
|
+
* its literal here is `MODEL_VIEW_MANIFEST`, held to that schema by
|
|
168
|
+
* agentTaskKinds.test.mjs). It uploads on the general rule — an allowlisted
|
|
169
|
+
* type the turn changed is prepared and sent (intermediateArtifacts.mjs) — so
|
|
170
|
+
* only these words had been keeping it out. It proves no delivery: the page
|
|
171
|
+
* and its measured export still do.
|
|
172
|
+
*
|
|
173
|
+
* THE POSTER IS THE DAEMON'S RENDER OF THE PAGE (0.125.21). These words first
|
|
174
|
+
* asked the agent to screenshot its own `poster.png`, the still a list or a
|
|
175
|
+
* nested kit shows without loading a GLB; but a Claude model card cannot run
|
|
176
|
+
* Chrome under its fenced shell, and the daemon already renders every HTML
|
|
177
|
+
* artifact it uploads (artifactPreview.mjs `renderDesignPreview`), which the
|
|
178
|
+
* server stores as that artifact's preview. So the page's default view IS the
|
|
179
|
+
* poster, and the contract asks for that view instead: the whole model,
|
|
180
|
+
* full-bleed, nothing over it, standing within the render's few seconds.
|
|
181
|
+
* "poster" stays optional in the shape: a poster.png an agent already has
|
|
182
|
+
* still ships on the same general rule.
|
|
161
183
|
*/
|
|
184
|
+
export const MODEL_VIEW_MANIFEST = `{
|
|
185
|
+
"kind": "map",
|
|
186
|
+
"title": "Street and metro map",
|
|
187
|
+
"entry": "street-metro-map/index.glb",
|
|
188
|
+
"poster": "street-metro-map/poster.png",
|
|
189
|
+
"view": {
|
|
190
|
+
"units": { "metre": 1, "unit": "m" },
|
|
191
|
+
"upAxis": "Y",
|
|
192
|
+
"front": "+Z",
|
|
193
|
+
"defaultView": "overview",
|
|
194
|
+
"views": [
|
|
195
|
+
{ "id": "overview", "label": "Overview", "group": "overview", "direction": "iso" },
|
|
196
|
+
{ "id": "plan", "label": "Plan", "group": "overview", "direction": "top" },
|
|
197
|
+
{ "id": "konbini", "label": "Konbini", "group": "spot", "area": "Shops", "direction": "front", "part": "konbini" },
|
|
198
|
+
{ "id": "north-platform", "label": "North platform", "group": "spot", "area": "Station",
|
|
199
|
+
"camera": { "position": [42, 6, -18], "target": [42, 1, -4] } }
|
|
200
|
+
],
|
|
201
|
+
"layers": [
|
|
202
|
+
{ "id": "konbini", "label": "Konbini", "part": "konbini" },
|
|
203
|
+
{ "id": "station-platforms", "label": "Station platforms", "part": "station-platforms" }
|
|
204
|
+
]
|
|
205
|
+
},
|
|
206
|
+
"parts": ["konbini", "station-platforms"]
|
|
207
|
+
}`;
|
|
208
|
+
|
|
162
209
|
export const SYSTEM_AGENT_MODEL = `You are the human's own Claude, working one 3D MODEL card in a git worktree of
|
|
163
210
|
their repository. Nobody is watching this run. You have the repo and the tools
|
|
164
211
|
TOOLS ON THIS CARD names below — no board, no chat.
|
|
@@ -177,8 +224,9 @@ WHAT TO DO:
|
|
|
177
224
|
|
|
178
225
|
2. Write ONE viewer, .flowviant/artifacts/<kebab-name>/index.html — a short
|
|
179
226
|
kebab-case name for what it is (for example
|
|
180
|
-
.flowviant/artifacts/reading-chair/index.html). Keep the viewer self-contained
|
|
181
|
-
|
|
227
|
+
.flowviant/artifacts/reading-chair/index.html). Keep the viewer self-contained:
|
|
228
|
+
beside it, its folder holds only the view.json below (and a poster.png, if
|
|
229
|
+
you already have one), never scripts or notes. Flowviant materializes index.glb
|
|
182
230
|
beside this exact page before completing the card, through the handler below.
|
|
183
231
|
Keep original generated texture PNGs elsewhere under .flowviant/artifacts/;
|
|
184
232
|
preserve their bytes and material mapping. The page BUILDS
|
|
@@ -186,6 +234,9 @@ WHAT TO DO:
|
|
|
186
234
|
- three.js as ES modules from cdn.jsdelivr.net/npm, through an import map
|
|
187
235
|
for "three" and "three/addons/"; orbit controls, lights and a neutral
|
|
188
236
|
ground. Nothing else loads from the network, and nothing can be sent.
|
|
237
|
+
- the page is the scene and nothing else: a full-bleed canvas, with no
|
|
238
|
+
title, panel, legend, button or text over the model. Flowviant draws
|
|
239
|
+
every control.
|
|
189
240
|
- the model is ONE THREE.Group named for what it is, built from three.js
|
|
190
241
|
geometry (lathe, extrude, tube, box and the rest, one mesh per part),
|
|
191
242
|
with MeshStandardMaterial only. Colour, roughness and metalness are
|
|
@@ -228,6 +279,40 @@ WHAT TO DO:
|
|
|
228
279
|
|
|
229
280
|
${LOOK_HEADLESS}
|
|
230
281
|
|
|
282
|
+
THE PAGE'S DEFAULT VIEW IS THE POSTER FLOWVIANT SHOWS. Flowviant renders
|
|
283
|
+
this page itself when it uploads it, and that still is the model's picture
|
|
284
|
+
wherever the model is listed. So the page opens on the default view below:
|
|
285
|
+
the whole model, fitted full-bleed to the frame, with nothing over it — no
|
|
286
|
+
loading text, label or overlay — standing within a few seconds of load.
|
|
287
|
+
Take no poster yourself; a poster.png you already have may sit in the
|
|
288
|
+
folder, named in "poster" below.
|
|
289
|
+
|
|
290
|
+
Then write <kebab-name>/view.json — how Flowviant's viewer frames the
|
|
291
|
+
model, exactly in this shape (a map's, with every field):
|
|
292
|
+
|
|
293
|
+
${MODEL_VIEW_MANIFEST.replace(/^/gm, ' ')}
|
|
294
|
+
|
|
295
|
+
"kind" is "model" for one object, "map" for a scene that places several
|
|
296
|
+
kits. "entry" is a path under .flowviant/artifacts/: this folder's
|
|
297
|
+
index.glb. "poster" is optional: name this folder's poster.png only when
|
|
298
|
+
the folder holds one; otherwise leave the field out. Declare "units"
|
|
299
|
+
once, as the size of the scene's unit in metres —
|
|
300
|
+
{ "stud": 0.28, "unit": "m" } for a scene built in studs — and never
|
|
301
|
+
write units or any other text into the scene; Flowviant draws the scale
|
|
302
|
+
bar. "upAxis" is "Y" (three.js is Y-up); "front" is the side the model
|
|
303
|
+
faces: "+X", "-X", "+Z" or "-Z". "views": up to three "overview" views,
|
|
304
|
+
"defaultView" naming one; a map adds named "spot" views, each grouped
|
|
305
|
+
under its "area". A view looks from a "direction" ("iso", "top",
|
|
306
|
+
"front", "side" or "back") fitted to the whole scene or to one "part", or
|
|
307
|
+
from a "camera" position and target in scene units. "layers", for a map:
|
|
308
|
+
one per kit, its "part" the kit's scene node name exactly. "parts": every
|
|
309
|
+
node you name in code (.name, which GLTFExporter keeps); each "part"
|
|
310
|
+
above is one of them, spelled the same. A single model has no spots and
|
|
311
|
+
no layers: Flowviant gives it Display and Turntable. A label or area is a
|
|
312
|
+
few words, 40 characters at most; at most 40 views, 60 layers and 200
|
|
313
|
+
parts — a manifest past any limit is not read. The page's opening view
|
|
314
|
+
is the one "defaultView" names, so the poster and the viewer agree.
|
|
315
|
+
|
|
231
316
|
${ARTIFACT_CONTRACT_TAIL({
|
|
232
317
|
ask: `If you cannot model it without a DECISION only a person can make — which of
|
|
233
318
|
two directions, what size or style, which asset it stands beside — STOP AND
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** Explicit iteration relationships, plus measured local deletions. No model runs here. */
|
|
2
|
+
import { REFERENCE_CAPTURE_FILE } from './referenceCaptureProtocol.mjs';
|
|
2
3
|
import { constants, openSync, closeSync, fstatSync, readFileSync, lstatSync, unlinkSync, realpathSync, writeFileSync, renameSync, rmSync } from 'node:fs';
|
|
3
4
|
import { join, dirname, resolve, sep } from 'node:path';
|
|
4
5
|
import { createHash } from 'node:crypto';
|
|
@@ -117,7 +118,7 @@ export function seenArtifacts(record, stored, present, bornAt = null) {
|
|
|
117
118
|
let seen = record;
|
|
118
119
|
if (!seen) {
|
|
119
120
|
const since = Number.isFinite(bornAt)
|
|
120
|
-
? stored.filter((a) => storedAt(a) >= bornAt).map((a) => a.name) : [];
|
|
121
|
+
? stored.filter((a) => storedAt(a) >= bornAt && !REFERENCE_CAPTURE_FILE.test(a.name)).map((a) => a.name) : [];
|
|
121
122
|
seen = since.some((n) => present.has(n)) ? new Set(since) : new Set();
|
|
122
123
|
}
|
|
123
124
|
return new Set([...seen, ...present].filter((n) => names.has(n) || present.has(n)));
|
|
@@ -144,7 +145,7 @@ export function deletedArtifacts(placeDir, before) {
|
|
|
144
145
|
// Only names this place held are candidates: this turn's snapshot and the
|
|
145
146
|
// place's record (`before.seen`). Normalize live names before probing: a
|
|
146
147
|
// 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 ?? [])]);
|
|
148
|
+
const candidates = new Set([...(before?.keys() ?? []), ...(before?.seen ?? [])].filter(name => !before?.evicted?.has(artifactStoredName(name))));
|
|
148
149
|
const live = presentArtifacts(placeDir);
|
|
149
150
|
if (!live) {
|
|
150
151
|
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 =
|
|
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 = {
|