flowviant 0.116.0 → 0.124.0
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 +66 -12
- package/bin/cli.mjs +50 -17
- package/bin/lib/admission.mjs +7 -14
- package/bin/lib/agentAsk.mjs +123 -0
- package/bin/lib/agentBrief.mjs +36 -0
- package/bin/lib/agentFootprint.mjs +76 -0
- package/bin/lib/agentInternalWait.mjs +83 -0
- package/bin/lib/agentPlan.mjs +134 -31
- package/bin/lib/agentPrerequisiteContract.mjs +43 -0
- package/bin/lib/agentPublish.mjs +12 -13
- package/bin/lib/agentTaskKinds.mjs +5 -8
- package/bin/lib/agySessions.mjs +3 -3
- package/bin/lib/artifactContracts.mjs +91 -20
- package/bin/lib/artifactIteration.mjs +182 -0
- package/bin/lib/artifactPreview.mjs +92 -18
- package/bin/lib/artifactRecovery.mjs +36 -0
- package/bin/lib/artifactReporter.mjs +194 -22
- package/bin/lib/artifacts.mjs +70 -63
- package/bin/lib/authproxy.mjs +2 -1
- package/bin/lib/childEnv.mjs +9 -4
- package/bin/lib/claude.mjs +10 -20
- package/bin/lib/claudeAuth.mjs +15 -5
- package/bin/lib/claudePersonal.mjs +71 -6
- package/bin/lib/claudePosture.mjs +227 -54
- package/bin/lib/claudeSessions.mjs +7 -7
- package/bin/lib/claudeStream.mjs +27 -13
- package/bin/lib/cliLimit.mjs +2 -2
- package/bin/lib/codexHome.mjs +17 -0
- package/bin/lib/codexPersonal.mjs +44 -3
- package/bin/lib/codexProjectRead.mjs +63 -0
- package/bin/lib/codexRelay.mjs +21 -5
- package/bin/lib/codexResearch.mjs +193 -0
- package/bin/lib/commandSpecs.mjs +6 -0
- package/bin/lib/config.mjs +37 -24
- package/bin/lib/conversationIds.mjs +51 -0
- package/bin/lib/credentials.mjs +2 -7
- package/bin/lib/daemonLocks.mjs +90 -0
- package/bin/lib/daemonState.mjs +5 -5
- package/bin/lib/deploy.mjs +6 -9
- package/bin/lib/deployCheckout.mjs +26 -22
- package/bin/lib/deployRunner.mjs +54 -18
- package/bin/lib/deployWire.mjs +32 -23
- package/bin/lib/desktopContract.mjs +6 -5
- package/bin/lib/discoveryContract.mjs +19 -0
- package/bin/lib/env.mjs +104 -35
- package/bin/lib/fleet.mjs +53 -13
- package/bin/lib/fleetPost.mjs +172 -25
- package/bin/lib/fleetReports.mjs +172 -171
- package/bin/lib/fleetStartup.mjs +6 -6
- package/bin/lib/fleetWire.mjs +42 -0
- package/bin/lib/git.mjs +111 -61
- package/bin/lib/gitMergeIdentity.mjs +2 -8
- package/bin/lib/grant.mjs +23 -7
- package/bin/lib/holder.mjs +108 -81
- package/bin/lib/hooks/mcpAllow.mjs +60 -0
- package/bin/lib/hooks/readGuard.mjs +54 -11
- package/bin/lib/imageArtifact.mjs +74 -0
- package/bin/lib/install.mjs +80 -33
- package/bin/lib/instance.mjs +36 -83
- package/bin/lib/intakeDraft.mjs +4 -1
- package/bin/lib/knowledgeFileSync.mjs +31 -0
- package/bin/lib/knowledgeFiles.mjs +28 -83
- package/bin/lib/landed.mjs +22 -29
- package/bin/lib/launchCommand.mjs +91 -0
- package/bin/lib/listeners.mjs +0 -5
- package/bin/lib/localSessions.mjs +52 -39
- package/bin/lib/login.mjs +22 -12
- package/bin/lib/loginPath.mjs +22 -17
- package/bin/lib/machineListing.mjs +6 -3
- package/bin/lib/maintenance.mjs +47 -0
- package/bin/lib/maintenanceInventory.mjs +39 -0
- package/bin/lib/mcp-cli.mjs +9 -10
- package/bin/lib/mcpInstall.mjs +163 -0
- package/bin/lib/mcpInventory.mjs +119 -0
- package/bin/lib/noWindowEnv.mjs +23 -3
- package/bin/lib/ownedDirs.mjs +99 -0
- package/bin/lib/pinnedCapture.mjs +383 -0
- package/bin/lib/planDependencies.mjs +70 -0
- package/bin/lib/postureTools.mjs +85 -0
- package/bin/lib/prWorkflow.mjs +94 -17
- package/bin/lib/preflight.mjs +8 -25
- package/bin/lib/preview.mjs +19 -25
- package/bin/lib/previewGatePolicy.mjs +34 -9
- package/bin/lib/previewRegistry.mjs +4 -11
- package/bin/lib/printable.mjs +70 -7
- package/bin/lib/procRegistry.mjs +33 -13
- package/bin/lib/procScan.mjs +2 -3
- package/bin/lib/procStat.mjs +76 -0
- package/bin/lib/processGroup.mjs +48 -0
- package/bin/lib/processes.mjs +10 -24
- package/bin/lib/projectToolRuntimes.mjs +328 -19
- package/bin/lib/projectTools.mjs +109 -34
- package/bin/lib/prompts.mjs +205 -60
- package/bin/lib/repoPath.mjs +19 -0
- package/bin/lib/repoState.mjs +17 -19
- package/bin/lib/repositoryBaseline.mjs +68 -0
- package/bin/lib/runTurn.mjs +286 -209
- package/bin/lib/runtimeAntigravity.mjs +16 -5
- package/bin/lib/runtimeCapabilities.mjs +215 -21
- package/bin/lib/runtimeClaude.mjs +78 -10
- package/bin/lib/runtimeCodex.mjs +217 -209
- package/bin/lib/runtimeDetection.mjs +8 -2
- package/bin/lib/runtimeEvents.mjs +28 -11
- package/bin/lib/runtimeLimits.mjs +5 -6
- package/bin/lib/runtimeOfferings.mjs +99 -14
- package/bin/lib/runtimes.mjs +10 -0
- package/bin/lib/shipSweep.mjs +5 -3
- package/bin/lib/shot.mjs +91 -17
- package/bin/lib/sleep.mjs +11 -0
- package/bin/lib/startProject.mjs +5 -5
- package/bin/lib/statePaths.mjs +40 -0
- package/bin/lib/tty.mjs +25 -15
- package/bin/lib/turnDiagnostics.mjs +93 -0
- package/bin/lib/turnLock.mjs +7 -14
- package/bin/lib/turnProfile.mjs +16 -4
- package/bin/lib/ui.mjs +0 -3
- package/bin/lib/uninstall.mjs +30 -26
- package/bin/lib/update.mjs +79 -27
- package/bin/lib/uplinkScrub.mjs +296 -31
- package/bin/lib/vault.mjs +78 -10
- package/bin/lib/vaultDiff.mjs +5 -2
- package/bin/lib/views.mjs +37 -12
- package/bin/lib/wikiRunner.mjs +40 -61
- package/bin/lib/work.mjs +44 -13
- package/bin/lib/workAdoptCarry.mjs +2 -4
- package/bin/lib/workAgentBrief.mjs +92 -0
- package/bin/lib/workAgentBriefRunner.mjs +59 -0
- package/bin/lib/workAgentCheck.mjs +104 -57
- package/bin/lib/workAgentMerges.mjs +62 -57
- package/bin/lib/workAgentPlans.mjs +82 -199
- package/bin/lib/workAgentPrecheck.mjs +76 -138
- package/bin/lib/workAgentPublish.mjs +6 -8
- package/bin/lib/workAgentTurnBegun.mjs +15 -11
- package/bin/lib/workAgentTurnExecution.mjs +131 -67
- package/bin/lib/workAgentTurnOutcome.mjs +11 -2
- package/bin/lib/workAgentTurnReports.mjs +9 -37
- package/bin/lib/workAgentTurns.mjs +64 -7
- package/bin/lib/workArtifactReconcile.mjs +79 -0
- package/bin/lib/workAttachments.mjs +66 -11
- package/bin/lib/workBrain.mjs +9 -4
- package/bin/lib/workCappedTurn.mjs +119 -0
- package/bin/lib/workChildren.mjs +5 -5
- package/bin/lib/workCommandAudit.mjs +18 -2
- package/bin/lib/workDiffs.mjs +52 -6
- package/bin/lib/workIntake.mjs +35 -60
- package/bin/lib/workPlaceReports.mjs +7 -4
- package/bin/lib/workPlaces.mjs +113 -31
- package/bin/lib/workPreviews.mjs +47 -112
- package/bin/lib/workProcesses.mjs +8 -35
- package/bin/lib/workPullRequests.mjs +37 -56
- package/bin/lib/workReportQueue.mjs +100 -148
- package/bin/lib/workRetire.mjs +2 -1
- package/bin/lib/workSessionRuntime.mjs +38 -37
- package/bin/lib/workSessionTurns.mjs +256 -199
- package/bin/lib/workShip.mjs +43 -23
- package/bin/lib/workToolLog.mjs +70 -3
- package/bin/lib/workTurnShape.mjs +165 -0
- package/npm-shrinkwrap.json +69 -0
- package/package.json +9 -4
- package/bin/lib/fleetJobs.mjs +0 -120
- package/bin/lib/patchRevert.mjs +0 -60
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/** Explicit iteration relationships, plus measured local deletions. No model runs here. */
|
|
2
|
+
import { constants, openSync, closeSync, fstatSync, readFileSync, lstatSync, unlinkSync, realpathSync, writeFileSync, renameSync, rmSync } from 'node:fs';
|
|
3
|
+
import { join, dirname, resolve, sep } from 'node:path';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { flowviantParentState, realDirState } from './ownedDirs.mjs';
|
|
6
|
+
import { ARTIFACT_DIR, artifactStoredName, walkArtifactDir } from './artifacts.mjs';
|
|
7
|
+
|
|
8
|
+
const MANIFEST = '.iteration.json';
|
|
9
|
+
const safeName = (name) => typeof name === 'string' && name.length <= 500 &&
|
|
10
|
+
name.split('/').every((p) => p && p !== '.' && p !== '..' && !p.startsWith('.') && !p.includes('\\'));
|
|
11
|
+
|
|
12
|
+
const manifestText = (placeDir) => {
|
|
13
|
+
if (!walkArtifactDir(placeDir, () => {})) return null;
|
|
14
|
+
let fd;
|
|
15
|
+
try {
|
|
16
|
+
fd = openSync(join(placeDir, ARTIFACT_DIR, MANIFEST), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
17
|
+
const st = fstatSync(fd);
|
|
18
|
+
if (!st.isFile() || st.size > 32 * 1024) return null;
|
|
19
|
+
return { text: readFileSync(fd, 'utf8'), stamp: `${st.size}:${st.mtimeMs}` };
|
|
20
|
+
} catch { return null; }
|
|
21
|
+
finally { if (fd !== undefined) closeSync(fd); }
|
|
22
|
+
};
|
|
23
|
+
const signature = (value) => value === null ? null : `${value.stamp}:${createHash('sha256').update(value.text).digest('hex')}`;
|
|
24
|
+
export const iterationSignature = (placeDir) => signature(manifestText(placeDir));
|
|
25
|
+
|
|
26
|
+
export function iterationChanges(placeDir, before) {
|
|
27
|
+
const text = manifestText(placeDir);
|
|
28
|
+
if (text === null || signature(text) === before?.iterationSignature) return { remove: [], replace: {} };
|
|
29
|
+
let value;
|
|
30
|
+
try { value = JSON.parse(text.text); } catch { throw new Error('Artifact iteration declaration is not valid JSON.'); }
|
|
31
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Artifact iteration declaration must be an object.');
|
|
32
|
+
const remove = value.remove ?? [];
|
|
33
|
+
const replace = value.replace ?? {};
|
|
34
|
+
if (!Array.isArray(remove) || remove.length > 40 || !remove.every(safeName) ||
|
|
35
|
+
!replace || typeof replace !== 'object' || Array.isArray(replace) || Object.keys(replace).length > 40 ||
|
|
36
|
+
!Object.entries(replace).every(([from, to]) => safeName(from) && safeName(to) && from !== to)) {
|
|
37
|
+
throw new Error('Artifact iteration needs remove names and explicit old-to-new replacement names.');
|
|
38
|
+
}
|
|
39
|
+
const removed = new Set([...remove, ...Object.keys(replace)]);
|
|
40
|
+
if (Object.values(replace).some((to) => removed.has(to))) throw new Error('A replacement cannot also be discarded.');
|
|
41
|
+
return { remove, replace };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Stored spellings of the regular files a place holds; null when its directory cannot be walked. */
|
|
45
|
+
export function presentArtifacts(placeDir) {
|
|
46
|
+
const live = new Set();
|
|
47
|
+
return walkArtifactDir(placeDir, (e) => { if (e.kind === 'file') live.add(artifactStoredName(e.name)); }) ? live : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* WHAT THIS PLACE HAS HELD, per owner (2026-10-11 follow-up). Local absence
|
|
52
|
+
* proves a discard only for a name this directory held: a handed-over or
|
|
53
|
+
* recreated worktree starts without its owner's delivery, which was stored
|
|
54
|
+
* from somewhere else. The record lives in the worktree's private git dir
|
|
55
|
+
* (the work manager's `seenPath`), so it dies with the worktree. Missing is
|
|
56
|
+
* null — a place never measured — and an unreadable record proves nothing.
|
|
57
|
+
*/
|
|
58
|
+
const SEEN_MAX_BYTES = 256 * 1024;
|
|
59
|
+
const SEEN_MAX_NAMES = 2000;
|
|
60
|
+
export function readSeenArtifacts(path) {
|
|
61
|
+
if (!path) return new Set();
|
|
62
|
+
let fd;
|
|
63
|
+
try {
|
|
64
|
+
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
65
|
+
const st = fstatSync(fd);
|
|
66
|
+
if (!st.isFile() || st.size > SEEN_MAX_BYTES) return new Set();
|
|
67
|
+
const value = JSON.parse(readFileSync(fd, 'utf8'));
|
|
68
|
+
return new Set(Array.isArray(value) ? value.filter(safeName) : []);
|
|
69
|
+
} catch (e) {
|
|
70
|
+
return e?.code === 'ENOENT' ? null : new Set();
|
|
71
|
+
} finally { if (fd !== undefined) closeSync(fd); }
|
|
72
|
+
}
|
|
73
|
+
export function writeSeenArtifacts(path, names) {
|
|
74
|
+
if (!path) return;
|
|
75
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
76
|
+
try {
|
|
77
|
+
writeFileSync(tmp, JSON.stringify([...new Set(names)].filter(safeName).slice(0, SEEN_MAX_NAMES)));
|
|
78
|
+
renameSync(tmp, path);
|
|
79
|
+
} catch {
|
|
80
|
+
// An unwritten record leaves the place unmeasured, never a false discard.
|
|
81
|
+
try { rmSync(tmp, { force: true }); } catch { /* best-effort */ }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The owner's stored names this place counts as its own. A place measured
|
|
87
|
+
* before the record existed counts only rows stored AFTER it was cut
|
|
88
|
+
* (`bornAt`, the worktree's creation on the server's clock — `serverBornAt`):
|
|
89
|
+
* a moved agent's new worktree, or one
|
|
90
|
+
* recreated after retirement, never held what was stored before it existed —
|
|
91
|
+
* even when it has since regenerated one of those names. Among the rows it
|
|
92
|
+
* could have written, it held them all only while it still holds part of them:
|
|
93
|
+
* the delivery whose discards an older daemon never relayed. An unknown birth
|
|
94
|
+
* or an undated row proves nothing; an empty place proves nothing; and the
|
|
95
|
+
* record written now keeps a later same-name upload from proving it either.
|
|
96
|
+
*/
|
|
97
|
+
export function seenArtifacts(record, stored, present, bornAt = null) {
|
|
98
|
+
const names = new Set(stored.map((a) => a.name));
|
|
99
|
+
let seen = record;
|
|
100
|
+
if (!seen) {
|
|
101
|
+
const since = Number.isFinite(bornAt)
|
|
102
|
+
? stored.filter((a) => storedAt(a) >= bornAt).map((a) => a.name) : [];
|
|
103
|
+
seen = since.some((n) => present.has(n)) ? new Set(since) : new Set();
|
|
104
|
+
}
|
|
105
|
+
return new Set([...seen, ...present].filter((n) => names.has(n) || present.has(n)));
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* THE PLACE'S BIRTH ON THE SERVER'S CLOCK, never earlier than it was. Rows are
|
|
109
|
+
* dated by the server and the birth by this machine, whose clock can run hours
|
|
110
|
+
* behind (a WSL clock after the host slept), which would make an older
|
|
111
|
+
* delivery read as stored after the cut. The inventory answer's `Date` was
|
|
112
|
+
* stamped after `sentAt` and floors to the second, so the true offset is below
|
|
113
|
+
* `Date + 1s - sentAt`: that bound can only exclude a row. No header, no bound,
|
|
114
|
+
* and so no legacy evidence.
|
|
115
|
+
*/
|
|
116
|
+
export function serverBornAt(bornAt, serverDate, sentAt) {
|
|
117
|
+
const server = Date.parse(serverDate ?? '');
|
|
118
|
+
return Number.isFinite(bornAt) && Number.isFinite(server) && Number.isFinite(sentAt)
|
|
119
|
+
? bornAt + (server + 1000 - sentAt) : null;
|
|
120
|
+
}
|
|
121
|
+
/** A row's stored time in ms, or NaN — an older server sends none. */
|
|
122
|
+
const storedAt = (a) => typeof a.createdAt === 'string' || typeof a.createdAt === 'number' ? new Date(a.createdAt).getTime() : NaN;
|
|
123
|
+
|
|
124
|
+
/** A missing leaf is measured only through real, readable parent directories. */
|
|
125
|
+
export function deletedArtifacts(placeDir, before) {
|
|
126
|
+
// Only names this place held are candidates: this turn's snapshot and the
|
|
127
|
+
// place's record (`before.seen`). Normalize live names before probing: a
|
|
128
|
+
// draft+1.md is stored as draft_1.md and is not an absent retained file.
|
|
129
|
+
const candidates = new Set([...(before?.keys() ?? []), ...(before?.seen ?? [])]);
|
|
130
|
+
const live = presentArtifacts(placeDir);
|
|
131
|
+
if (!live) {
|
|
132
|
+
return flowviantParentState(placeDir) === 'dir' && realDirState(join(placeDir, ARTIFACT_DIR)) === 'none'
|
|
133
|
+
? [...candidates].filter(safeName).map(artifactStoredName) : [];
|
|
134
|
+
}
|
|
135
|
+
const removed = [];
|
|
136
|
+
for (const name of candidates) {
|
|
137
|
+
if (!safeName(name) || live.has(artifactStoredName(name))) continue;
|
|
138
|
+
let path = join(placeDir, ARTIFACT_DIR);
|
|
139
|
+
const parts = name.split('/');
|
|
140
|
+
for (let i = 0; i < parts.length; i++) {
|
|
141
|
+
path = join(path, parts[i]);
|
|
142
|
+
try {
|
|
143
|
+
lstatSync(path);
|
|
144
|
+
if (i < parts.length - 1 && realDirState(path) !== 'dir') break;
|
|
145
|
+
} catch (e) {
|
|
146
|
+
if (e.code === 'ENOENT') removed.push(artifactStoredName(name));
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return [...new Set(removed)];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** A confirmed removal unlinks only the file measured for this report, never a later turn's rewrite. */
|
|
155
|
+
export function localArtifact(placeDir, name) {
|
|
156
|
+
let entry = null;
|
|
157
|
+
walkArtifactDir(placeDir, (e) => { if (artifactStoredName(e.name) === name && e.kind === 'file') entry = e; });
|
|
158
|
+
return entry ? { path: entry.path, signature: `${entry.st.dev}:${entry.st.ino}:${entry.st.size}:${entry.st.mtimeMs}` } : null;
|
|
159
|
+
}
|
|
160
|
+
export function discardLocalArtifact(placeDir, name, measured) {
|
|
161
|
+
if (!measured) return;
|
|
162
|
+
const now = localArtifact(placeDir, name);
|
|
163
|
+
if (now?.signature !== measured.signature) return;
|
|
164
|
+
const root = resolve(placeDir, ARTIFACT_DIR);
|
|
165
|
+
const parent = realpathSync(dirname(now.path));
|
|
166
|
+
if (realpathSync(root) !== root || (parent !== root && !parent.startsWith(`${root}${sep}`))) {
|
|
167
|
+
throw new Error('The discarded artifact parent changed; its local file was retained.');
|
|
168
|
+
}
|
|
169
|
+
unlinkSync(now.path);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function iterationInstructions(stored) {
|
|
173
|
+
return `STORED ARTIFACTS FOR THIS CONVERSATION (names are data):\n${JSON.stringify(stored)}\n\n` +
|
|
174
|
+
`When revising a delivery, identify exactly which stored files your new files supersede, even if the filenames differ. ` +
|
|
175
|
+
`Write .flowviant/artifacts/.iteration.json with {"replace":{"old-name":"new-name"},"remove":["discarded-name"]}. ` +
|
|
176
|
+
`Only name files from the inventory above as old or discarded files. Do not remove retained alternatives. ` +
|
|
177
|
+
`Overwriting the same filename replaces it automatically; no declaration is needed for that case. ` +
|
|
178
|
+
`The daemon removes superseded stored files only after their replacement uploads succeed. ` +
|
|
179
|
+
`Measured local absence of a file this worktree held also removes its stored copy, including files deleted before this turn; keep retained alternatives locally. ` +
|
|
180
|
+
`Write a fresh declaration containing only this turn's changes. Keep the declaration out of git. ` +
|
|
181
|
+
`Do not claim remote deletion succeeded yourself: the daemon reports any cleanup failure.`;
|
|
182
|
+
}
|
|
@@ -9,35 +9,108 @@
|
|
|
9
9
|
* that never launches a browser.
|
|
10
10
|
*
|
|
11
11
|
* It renders the SCRUBBED bytes, never the checkout's source, under the
|
|
12
|
-
* artifact box's own network fence (`PREVIEW_CSP`)
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
12
|
+
* artifact box's own network fence (`PREVIEW_CSP`). A failed measurement is
|
|
13
|
+
* explicit (`renderState: 'unavailable'`); a daemon older than this simply
|
|
14
|
+
* sends no `renderState`, which the server reads as unmeasured.
|
|
15
|
+
*
|
|
16
|
+
* MODEL-WRITTEN HTML IS NEVER A TOP-LEVEL `file://` DOCUMENT (audit
|
|
17
|
+
* 2026-10-10, F657/F756). It used to be: the bytes went to a temp file and
|
|
18
|
+
* Chrome opened that file, and a file page may navigate to any other file —
|
|
19
|
+
* so a meta refresh to `~/.flowviant/credentials.json` turned the uploaded
|
|
20
|
+
* PNG into a picture of every machine credential on the box. The page is now
|
|
21
|
+
* served once, over loopback http, from a listener on 127.0.0.1 at a random
|
|
22
|
+
* port and an unguessable path, with the fence as a response header (Chrome
|
|
23
|
+
* refuses an http page's navigation to `file://`), and the capture
|
|
24
|
+
* (`pinnedCapture.mjs`) refuses every navigation away from that URL. A render
|
|
25
|
+
* whose final document is not the served page is 'unavailable', and no
|
|
26
|
+
* preview is uploaded. The listener closes when the capture is done.
|
|
27
|
+
*
|
|
28
|
+
* `PREVIEW_CSP` is the served `ARTIFACT_CSP_BASE` (apps/api
|
|
29
|
+
* artifactDelivery.ts) minus `navigate-to` and `webrtc`, which Chrome does
|
|
30
|
+
* not enforce, so the thumbnail renders what the app renders:
|
|
31
|
+
* the same sandbox, and blob workers (`worker-src blob:`, added 2026-10-10 —
|
|
32
|
+
* a three.js/DRACO page rendered blank here and whole in the app). Exported
|
|
33
|
+
* for the release parity gate.
|
|
16
34
|
*/
|
|
17
35
|
|
|
18
|
-
import {
|
|
36
|
+
import { createServer } from 'node:http';
|
|
37
|
+
import { randomBytes } from 'node:crypto';
|
|
38
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
19
39
|
import { join } from 'node:path';
|
|
20
40
|
import { tmpdir } from 'node:os';
|
|
21
|
-
import {
|
|
22
|
-
import { captureScreenshot } from './shot.mjs';
|
|
41
|
+
import { capturePinnedPage } from './pinnedCapture.mjs';
|
|
23
42
|
|
|
24
43
|
export const DESIGN_PREVIEW_MAX_BYTES = 2 * 1024 * 1024;
|
|
25
|
-
const PREVIEW_CSP = "default-src 'none'; script-src 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net/npm/ https://unpkg.com; style-src 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net/npm/ https://unpkg.com https://fonts.googleapis.com; img-src data: blob:; font-src data: https://fonts.gstatic.com; connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'";
|
|
44
|
+
export const PREVIEW_CSP = "sandbox allow-scripts; default-src 'none'; script-src 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net/npm/ https://unpkg.com; style-src 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net/npm/ https://unpkg.com https://fonts.googleapis.com; img-src data: blob:; font-src data: https://fonts.gstatic.com; connect-src 'none'; worker-src blob:; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'";
|
|
45
|
+
|
|
46
|
+
/** The https sources PREVIEW_CSP names — the only hosts a subresource of the
|
|
47
|
+
* page may come from, read from the policy so the two cannot drift. */
|
|
48
|
+
const CSP_SOURCES = [...new Set(PREVIEW_CSP.match(/https:\/\/[^\s;]+/g) ?? [])].map((s) => new URL(s));
|
|
49
|
+
|
|
50
|
+
/** CSP host-source matching for the sources above: the same host over https,
|
|
51
|
+
* and a path ending in `/` is a prefix while any other path is exact. */
|
|
52
|
+
export function previewSubresourceAllowed(raw) {
|
|
53
|
+
let u;
|
|
54
|
+
try {
|
|
55
|
+
u = new URL(raw);
|
|
56
|
+
} catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
if (u.protocol !== 'https:') return false;
|
|
60
|
+
return CSP_SOURCES.some((s) =>
|
|
61
|
+
s.host === u.host &&
|
|
62
|
+
(s.pathname === '/' || (s.pathname.endsWith('/') ? u.pathname.startsWith(s.pathname) : u.pathname === s.pathname)));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Serve `bytes` at one random path on 127.0.0.1, with the fence as a
|
|
66
|
+
* header. Resolves `{ url, close }`. */
|
|
67
|
+
function servePage(bytes) {
|
|
68
|
+
const path = `/${randomBytes(16).toString('hex')}.html`;
|
|
69
|
+
const server = createServer((req, res) => {
|
|
70
|
+
if ((req.method === 'GET' || req.method === 'HEAD') && req.url === path) {
|
|
71
|
+
res.writeHead(200, {
|
|
72
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
73
|
+
'Content-Security-Policy': PREVIEW_CSP,
|
|
74
|
+
'Cache-Control': 'no-store',
|
|
75
|
+
'X-Content-Type-Options': 'nosniff',
|
|
76
|
+
});
|
|
77
|
+
res.end(req.method === 'HEAD' ? undefined : bytes);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
81
|
+
res.end();
|
|
82
|
+
});
|
|
83
|
+
return new Promise((resolve, reject) => {
|
|
84
|
+
server.once('error', reject);
|
|
85
|
+
server.listen(0, '127.0.0.1', () => {
|
|
86
|
+
const { port } = server.address();
|
|
87
|
+
resolve({
|
|
88
|
+
url: `http://127.0.0.1:${port}${path}`,
|
|
89
|
+
close: () => {
|
|
90
|
+
server.closeAllConnections?.();
|
|
91
|
+
server.close();
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
}
|
|
26
97
|
|
|
27
98
|
/** Render the scrubbed HTML, never the checkout's unsanitized source. A
|
|
28
|
-
*
|
|
29
|
-
export async function renderDesignPreview(bytes, capture =
|
|
99
|
+
* failed browser measurement is explicit; older daemon reports are absent. */
|
|
100
|
+
export async function renderDesignPreview(bytes, capture = capturePinnedPage) {
|
|
30
101
|
const dir = mkdtempSync(join(tmpdir(), 'flowviant-design-'));
|
|
102
|
+
let page = null;
|
|
31
103
|
try {
|
|
32
|
-
const html = join(dir, 'design.html');
|
|
33
104
|
const png = join(dir, 'design.png');
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
105
|
+
page = await servePage(bytes);
|
|
106
|
+
const result = await capture({
|
|
107
|
+
url: page.url,
|
|
108
|
+
out: png,
|
|
109
|
+
width: 720,
|
|
110
|
+
height: 450,
|
|
111
|
+
timeoutMs: 15_000,
|
|
112
|
+
allowSubresource: previewSubresourceAllowed,
|
|
113
|
+
});
|
|
41
114
|
if (!result.ok) return { renderState: 'unavailable' };
|
|
42
115
|
const image = readFileSync(png);
|
|
43
116
|
if (image.byteLength > DESIGN_PREVIEW_MAX_BYTES || !image.subarray(0, 8).equals(Buffer.from([137,80,78,71,13,10,26,10]))) {
|
|
@@ -47,6 +120,7 @@ export async function renderDesignPreview(bytes, capture = captureScreenshot) {
|
|
|
47
120
|
} catch {
|
|
48
121
|
return { renderState: 'unavailable' };
|
|
49
122
|
} finally {
|
|
123
|
+
page?.close();
|
|
50
124
|
rmSync(dir, { recursive: true, force: true });
|
|
51
125
|
}
|
|
52
126
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { ARTIFACT_MAX_FILES, scanArtifacts, snapshotArtifacts } from './artifacts.mjs';
|
|
2
|
+
import { AGENT_TASK_KINDS } from './agentTaskKinds.mjs';
|
|
3
|
+
import { iterationSignature } from './artifactIteration.mjs';
|
|
4
|
+
import { usableImageArtifact } from './imageArtifact.mjs';
|
|
5
|
+
|
|
6
|
+
/** Recover explicit surviving images to their original run. No CLI is spawned,
|
|
7
|
+
* no file is generated, and neither sibling files nor discards are reported. */
|
|
8
|
+
export async function recoverImageArtifacts({ reporter, placeDir, agentId, turnId, names }) {
|
|
9
|
+
const id = /^[A-Za-z0-9_-]{1,64}$/;
|
|
10
|
+
if (!id.test(agentId ?? '') || !id.test(turnId ?? '')) {
|
|
11
|
+
throw new Error('Recovery needs the original agent and image turn ids.');
|
|
12
|
+
}
|
|
13
|
+
if (!Array.isArray(names) || !names.length || names.length > ARTIFACT_MAX_FILES ||
|
|
14
|
+
!names.every((name) => typeof name === 'string' && AGENT_TASK_KINDS.image.artifact.match.test(name)) ||
|
|
15
|
+
new Set(names).size !== names.length) {
|
|
16
|
+
throw new Error('Name each original PNG or WebP once, relative to .flowviant/artifacts/.');
|
|
17
|
+
}
|
|
18
|
+
const entries = new Map(scanArtifacts(placeDir).map((entry) => [entry.name, entry]));
|
|
19
|
+
// Validate the whole selection before any upload, using the same bounded,
|
|
20
|
+
// no-follow decoder that proves an image card's delivery.
|
|
21
|
+
for (const name of names) {
|
|
22
|
+
const entry = entries.get(name);
|
|
23
|
+
if (!entry || !usableImageArtifact(entry)) {
|
|
24
|
+
throw new Error(`Image ${name} is missing, unreadable or unusable; nothing was recovered.`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const before = snapshotArtifacts(placeDir);
|
|
28
|
+
for (const name of names) before.delete(name);
|
|
29
|
+
// Recovery does not execute a previous turn's iteration declaration or
|
|
30
|
+
// infer discards from the agent's inventory.
|
|
31
|
+
before.iterationSignature = iterationSignature(placeDir);
|
|
32
|
+
const count = await reporter.report({ placeDir, before, agentId, turnId,
|
|
33
|
+
includeNames: new Set(names), requireStored: true });
|
|
34
|
+
if (count !== names.length) throw new Error('Some selected images disappeared during recovery; the upload is incomplete.');
|
|
35
|
+
return count;
|
|
36
|
+
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Network delivery is its own reason to change, apart from what is on the disk
|
|
6
6
|
* (artifacts.mjs) and how a page is screenshotted (artifactPreview.mjs). The
|
|
7
|
-
*
|
|
7
|
+
* uploader is HANDED the three functions it runs —
|
|
8
8
|
* `listChanged(placeDir, before)` (what the turn wrote), `buildUpload(entry,
|
|
9
9
|
* owner, scrub, secretIn)` (one scrubbed body) and `renderPreview(bytes)` (the
|
|
10
10
|
* screenshot) — so the delivery rules below are tested against a fake server
|
|
@@ -12,19 +12,38 @@
|
|
|
12
12
|
*
|
|
13
13
|
* ── DELIVERY ──
|
|
14
14
|
*
|
|
15
|
-
* A 2xx is
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
15
|
+
* A 2xx is stored. A considered 4xx (fleetWire.mjs's `isConsideredRefusal`:
|
|
16
|
+
* other than 408/429) ENDS THE RETRY — an older SERVER 404s the whole
|
|
17
|
+
* route, and holding bodies it will refuse forever would be the trace relay's
|
|
18
|
+
* wedge wearing a retry's clothes; a refusal of one file (ended session, wrong
|
|
19
|
+
* owner) is equally permanent. EXCEPT 401 AND 403, which are a "later"
|
|
20
|
+
* (2026-10-10 ruling, audit F071): a credential blip — a rotation, a box that
|
|
21
|
+
* is momentarily not the holder — or an edge 403 (Cloudflare's WAF on a large
|
|
22
|
+
* HTML mockup, authReject.mjs's whole subject) says nothing about this BODY,
|
|
23
|
+
* and settling on one lost the artifact for good. The same argument as
|
|
24
|
+
* fleetReports.mjs's `envReportIsPermanent`. A 5xx, 408, 429, 401, 403 or a
|
|
19
25
|
* network error keeps the STORED BODY — the fields and the bytes as they were
|
|
20
26
|
* scrubbed — and retries it on the next report beat, the settle's shape, at
|
|
21
27
|
* most `MAX_TRIES` times; a newer copy of the same file replaces a held one.
|
|
22
28
|
*
|
|
23
29
|
* A PREVIEW THAT FAILS NEVER STOPS THE ARTIFACT: an HTML body goes out with
|
|
24
30
|
* `renderState: 'unavailable'` and no preview, the renderer's explicit answer.
|
|
31
|
+
*
|
|
32
|
+
* FILE-CARD DELIVERY REQUIRES STORAGE (2026-10-14). `requireStored` makes
|
|
33
|
+
* unsuccessful first uploads fail the report, as replacements already did.
|
|
34
|
+
* A held body is still retried, but cannot advance an image/model queue into
|
|
35
|
+
* a delivery with no uploaded files. Code and Terminal artifacts keep their
|
|
36
|
+
* existing best-effort upload policy. Explicit recovery may narrow the scan
|
|
37
|
+
* with `includeNames`, so it never attributes a sibling card's files.
|
|
25
38
|
*/
|
|
26
39
|
|
|
27
|
-
import {
|
|
40
|
+
import { artifactStoredName } from './artifacts.mjs';
|
|
41
|
+
import { fleetHeaders } from './fleetPost.mjs';
|
|
42
|
+
import {
|
|
43
|
+
iterationSignature, iterationChanges, deletedArtifacts, localArtifact, discardLocalArtifact, iterationInstructions,
|
|
44
|
+
presentArtifacts, readSeenArtifacts, writeSeenArtifacts, seenArtifacts, serverBornAt,
|
|
45
|
+
} from './artifactIteration.mjs';
|
|
46
|
+
import { fleetEndpoint, isConsideredRefusal } from './fleetWire.mjs';
|
|
28
47
|
|
|
29
48
|
/** A held upload is tried this many times in all, then dropped with a line. */
|
|
30
49
|
const MAX_TRIES = 4;
|
|
@@ -33,8 +52,8 @@ const MAX_PENDING = 40;
|
|
|
33
52
|
|
|
34
53
|
/**
|
|
35
54
|
* The reporter a work manager holds for its life. `report` is called once per
|
|
36
|
-
* finished turn and
|
|
37
|
-
*
|
|
55
|
+
* finished turn and awaited before settlement: a failed iteration cleanup
|
|
56
|
+
* must not become an optimistic delivery. `retryPending` rides the settle beat.
|
|
38
57
|
*/
|
|
39
58
|
export function createArtifactReporter({
|
|
40
59
|
fleetUrl,
|
|
@@ -47,13 +66,68 @@ export function createArtifactReporter({
|
|
|
47
66
|
listChanged,
|
|
48
67
|
buildUpload,
|
|
49
68
|
renderPreview,
|
|
69
|
+
/** `(placeDir, owner) => path | null` — where a place records what it held
|
|
70
|
+
* (artifactIteration.mjs). Null keeps no record: only a turn's own
|
|
71
|
+
* snapshot then measures a deletion. */
|
|
72
|
+
seenPath = () => null,
|
|
73
|
+
/** `(placeDir) => ms | null` — when that worktree was cut. Null (the
|
|
74
|
+
* checkout, anything unreadable) gives a place without a record no
|
|
75
|
+
* legacy evidence at all. */
|
|
76
|
+
placeBornAt = () => null,
|
|
50
77
|
}) {
|
|
51
78
|
const url = fleetEndpoint('artifact', fleetUrl);
|
|
52
79
|
const doFetch = fetchImpl ?? ((...a) => fetch(...a));
|
|
53
80
|
const pending = new Map(); // `${owner}:${name}` -> { body, tries }
|
|
54
81
|
|
|
55
|
-
|
|
82
|
+
const prepare = async ({ placeDir, sessionId, agentId, before }) => {
|
|
83
|
+
if (!before) return '';
|
|
84
|
+
before.iterationSignature = iterationSignature(placeDir);
|
|
85
|
+
delete before.stored;
|
|
86
|
+
delete before.seen;
|
|
87
|
+
before.inventoryFailed = false;
|
|
88
|
+
const query = new URLSearchParams(sessionId ? { sessionId } : { agentId });
|
|
89
|
+
try {
|
|
90
|
+
const sentAt = Date.now();
|
|
91
|
+
const response = await doFetch(`${fleetEndpoint('artifacts', fleetUrl)}?${query}`, {
|
|
92
|
+
method: 'GET', headers: fleetHeaders({ token, json: false }), signal: AbortSignal.timeout(15_000),
|
|
93
|
+
});
|
|
94
|
+
if (!response.ok) {
|
|
95
|
+
// Older servers have no iteration door. Other failures are unknown,
|
|
96
|
+
// not an empty inventory and not a successful cleanup.
|
|
97
|
+
before.inventoryFailed = response.status !== 404;
|
|
98
|
+
return '';
|
|
99
|
+
}
|
|
100
|
+
const result = await response.json();
|
|
101
|
+
if (!Array.isArray(result.data)) { before.inventoryFailed = true; return ''; }
|
|
102
|
+
before.stored = result.data.filter((a) => a && typeof a.id === 'string' && typeof a.name === 'string');
|
|
103
|
+
const path = seenPath(placeDir, sessionId ?? agentId);
|
|
104
|
+
before.seen = seenArtifacts(readSeenArtifacts(path), before.stored, presentArtifacts(placeDir) ?? new Set(),
|
|
105
|
+
serverBornAt(placeBornAt(placeDir), response.headers?.get?.('date'), sentAt));
|
|
106
|
+
writeSeenArtifacts(path, before.seen);
|
|
107
|
+
return iterationInstructions(before.stored);
|
|
108
|
+
} catch { before.inventoryFailed = true; return ''; }
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/** A response distinguishes delivery from a permanent refusal and a retry. */
|
|
56
112
|
const post = async (body) => {
|
|
113
|
+
if (body.removal) {
|
|
114
|
+
try {
|
|
115
|
+
const res = await doFetch(`${url}/remove`, {
|
|
116
|
+
method: 'POST', headers: fleetHeaders({ token }), signal: AbortSignal.timeout(30_000),
|
|
117
|
+
body: JSON.stringify(body.removal),
|
|
118
|
+
});
|
|
119
|
+
const result = typeof res.json === 'function' ? await res.json().catch(() => null) : null;
|
|
120
|
+
if (res.ok && result?.data?.complete === true) {
|
|
121
|
+
discardLocalArtifact(body.placeDir, body.fields.name, body.local);
|
|
122
|
+
return { settled: true, success: true };
|
|
123
|
+
}
|
|
124
|
+
log(`artifact ${body.fields.name}: ${result?.error ?? `removal failed (HTTP ${res.status})`}`);
|
|
125
|
+
return { settled: res.status !== 401 && res.status !== 403 && isConsideredRefusal(res.status), success: false };
|
|
126
|
+
} catch (error) {
|
|
127
|
+
log(`artifact ${body.fields.name}: removal incomplete — ${error.message}`);
|
|
128
|
+
return { settled: false, success: false };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
57
131
|
const form = new FormData();
|
|
58
132
|
for (const [k, v] of Object.entries(body.fields)) form.set(k, v);
|
|
59
133
|
if (body.bytes) form.set('file', new Blob([body.bytes]), body.fields.name);
|
|
@@ -65,11 +139,17 @@ export function createArtifactReporter({
|
|
|
65
139
|
signal: AbortSignal.timeout(30_000),
|
|
66
140
|
body: form,
|
|
67
141
|
});
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
142
|
+
if (res.ok) {
|
|
143
|
+
const result = typeof res.json === 'function' ? await res.json().catch(() => null) : null;
|
|
144
|
+
return { settled: true, success: true, id: result?.data?.id };
|
|
145
|
+
}
|
|
146
|
+
// A credential blip or an edge page is a "later", bounded by MAX_TRIES
|
|
147
|
+
// (2026-10-10 ruling — see the header).
|
|
148
|
+
if (res.status === 401 || res.status === 403) return { settled: false, success: false };
|
|
149
|
+
const failure = typeof res.json === 'function' ? await res.json().catch(() => null) : null;
|
|
150
|
+
return { settled: isConsideredRefusal(res.status), success: false, error: typeof failure?.error === 'string' ? failure.error : failure?.error?.message };
|
|
71
151
|
} catch {
|
|
72
|
-
return false;
|
|
152
|
+
return { settled: false, success: false };
|
|
73
153
|
}
|
|
74
154
|
};
|
|
75
155
|
|
|
@@ -102,17 +182,30 @@ export function createArtifactReporter({
|
|
|
102
182
|
const send = (key, body, tries, gen) => {
|
|
103
183
|
const run = (chains.get(key) ?? Promise.resolve()).then(async () => {
|
|
104
184
|
if (genOf.get(key) !== gen) return; // superseded before it went out
|
|
105
|
-
const
|
|
185
|
+
const result = await post(body);
|
|
106
186
|
if (genOf.get(key) !== gen) return; // a newer copy decides for this key
|
|
107
|
-
if (
|
|
187
|
+
if (result.settled) pending.delete(key);
|
|
108
188
|
else hold(key, body, tries + 1);
|
|
189
|
+
if (result.success && body.replacements?.length) {
|
|
190
|
+
result.cleanupFailed = [];
|
|
191
|
+
for (const removal of body.replacements) {
|
|
192
|
+
if (!result.id) { result.cleanupFailed.push(removal.fields.name); continue; }
|
|
193
|
+
removal.removal.replacementId = result.id;
|
|
194
|
+
const removalKey = `${removal.removal.sessionId ?? removal.removal.agentId}:${removal.fields.name}`;
|
|
195
|
+
// Do not let an earlier replacement retire a later turn's retained file.
|
|
196
|
+
if (genOf.get(removalKey) !== removal.generation) continue;
|
|
197
|
+
const removed = await send(removalKey, removal, 0, removal.generation);
|
|
198
|
+
if (!removed?.success) result.cleanupFailed.push(removal.fields.name);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return result;
|
|
109
202
|
});
|
|
110
203
|
const tail = run.catch(() => {});
|
|
111
204
|
chains.set(key, tail);
|
|
112
205
|
tail.then(() => {
|
|
113
206
|
if (chains.get(key) !== tail) return;
|
|
114
207
|
chains.delete(key);
|
|
115
|
-
if (!pending.has(key)) genOf.delete(key); // settled: nothing left to order
|
|
208
|
+
if (!pending.has(key) && genOf.get(key) === gen) genOf.delete(key); // settled: nothing left to order
|
|
116
209
|
});
|
|
117
210
|
return run;
|
|
118
211
|
};
|
|
@@ -133,15 +226,37 @@ export function createArtifactReporter({
|
|
|
133
226
|
|
|
134
227
|
/** Upload what this turn changed in `placeDir`. Resolves to the number of
|
|
135
228
|
* bodies SENT this call (delivered or held), for the tests and the log. */
|
|
136
|
-
const report = async ({ placeDir, before, sessionId, agentId, turnId }) => {
|
|
229
|
+
const report = async ({ placeDir, before, sessionId, agentId, turnId, idle = false, requireStored = false, includeNames = null }) => {
|
|
137
230
|
if (!placeDir || (!sessionId && !agentId)) return 0;
|
|
138
|
-
const
|
|
231
|
+
const changes = iterationChanges(placeDir, before);
|
|
232
|
+
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.');
|
|
233
|
+
const removing = new Set([...changes.remove, ...Object.keys(changes.replace), ...(before?.stored ? deletedArtifacts(placeDir, before) : [])]);
|
|
234
|
+
const stored = new Map((before?.stored ?? []).map((a) => [a.name, a]));
|
|
235
|
+
for (const name of [...changes.remove, ...Object.keys(changes.replace)]) {
|
|
236
|
+
if (!stored.has(name)) throw new Error(`No stored artifact named ${name} belongs to this conversation.`);
|
|
237
|
+
}
|
|
238
|
+
// Cancel held uploads immediately, including a copy currently in flight.
|
|
239
|
+
for (const name of removing) {
|
|
240
|
+
const key = `${sessionId ?? agentId}:${name}`;
|
|
241
|
+
pending.delete(key);
|
|
242
|
+
genOf.set(key, ++generation);
|
|
243
|
+
}
|
|
244
|
+
const changed = idle ? [] : listChanged(placeDir, before).filter((e) =>
|
|
245
|
+
!removing.has(artifactStoredName(e.name)) && (!includeNames || includeNames.has(e.name)));
|
|
246
|
+
const delivered = new Map();
|
|
247
|
+
const failures = before?.inventoryFailed
|
|
248
|
+
? ['Stored artifact inventory could not be measured; cleanup is incomplete. Retry.'] : [];
|
|
139
249
|
let n = 0;
|
|
140
250
|
for (const entry of changed) {
|
|
141
251
|
const body = buildUpload(entry, { sessionId, agentId, turnId }, scrub, secretIn);
|
|
142
|
-
if (!body)
|
|
252
|
+
if (!body) {
|
|
253
|
+
if (requireStored) failures.push(`artifact ${entry.name} could not be read; it was not delivered`);
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
143
256
|
if (body.withheld) {
|
|
144
257
|
log(`artifact ${entry.name}: not uploaded — it contains the value of ${body.withheld} from this machine's environment`);
|
|
258
|
+
if (stored.has(body.fields.name)) failures.push(`artifact ${body.fields.name} replacement was withheld; its predecessor was retained`);
|
|
259
|
+
else if (requireStored) failures.push(`artifact ${body.fields.name} was withheld; it was not delivered`);
|
|
145
260
|
continue;
|
|
146
261
|
}
|
|
147
262
|
if (body.bytes && /\.html?$/i.test(entry.name)) {
|
|
@@ -149,15 +264,72 @@ export function createArtifactReporter({
|
|
|
149
264
|
body.fields.renderState = measured.renderState;
|
|
150
265
|
if (measured.preview) body.preview = measured.preview;
|
|
151
266
|
}
|
|
152
|
-
|
|
267
|
+
body.replacements = Object.entries(changes.replace).filter(([, to]) => to === body.fields.name).map(([name]) => {
|
|
268
|
+
const key = `${sessionId ?? agentId}:${name}`;
|
|
269
|
+
return { fields: { name }, removal: { ...(sessionId ? { sessionId } : { agentId }), id: stored.get(name).id, name },
|
|
270
|
+
placeDir, local: localArtifact(placeDir, name), generation: genOf.get(key) };
|
|
271
|
+
});
|
|
272
|
+
const key = `${sessionId ?? agentId}:${body.fields.name}`;
|
|
153
273
|
pending.delete(key); // this copy supersedes a held older one
|
|
154
274
|
const gen = ++generation;
|
|
155
275
|
genOf.set(key, gen); // …and one still in flight
|
|
156
|
-
await send(key, body, 0, gen);
|
|
276
|
+
const result = await send(key, body, 0, gen);
|
|
277
|
+
if (result?.success && result.id) delivered.set(body.fields.name, result.id);
|
|
278
|
+
if (result?.cleanupFailed?.length) failures.push(`artifact cleanup incomplete: ${result.cleanupFailed.join(', ')}`);
|
|
279
|
+
if (!result?.success && (requireStored || stored.has(body.fields.name) || result?.error)) {
|
|
280
|
+
failures.push(result?.error ?? `artifact ${body.fields.name}${stored.has(body.fields.name) ? ' replacement' : ''} was not delivered`);
|
|
281
|
+
}
|
|
157
282
|
n++;
|
|
158
283
|
}
|
|
284
|
+
const removed = new Set();
|
|
285
|
+
for (const name of removing) {
|
|
286
|
+
const old = stored.get(name);
|
|
287
|
+
if (!old) continue; // A local file may belong to a sibling Terminal tab.
|
|
288
|
+
const replacementName = changes.replace[name];
|
|
289
|
+
const replacementId = replacementName ? delivered.get(replacementName) : null;
|
|
290
|
+
if (replacementName && !replacementId) {
|
|
291
|
+
failures.push(`replacement ${replacementName} was not delivered; ${name} was retained`);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
if (replacementName) continue; // Its upload carries the removal through retries.
|
|
295
|
+
const body = {
|
|
296
|
+
fields: { name },
|
|
297
|
+
removal: { ...(sessionId ? { sessionId } : { agentId }), id: old.id, name,
|
|
298
|
+
...(replacementId ? { replacementId } : {}) },
|
|
299
|
+
placeDir, local: localArtifact(placeDir, name),
|
|
300
|
+
};
|
|
301
|
+
const key = `${sessionId ?? agentId}:${name}`;
|
|
302
|
+
const gen = ++generation;
|
|
303
|
+
genOf.set(key, gen);
|
|
304
|
+
const result = await send(key, body, 0, gen);
|
|
305
|
+
if (result?.success) removed.add(name);
|
|
306
|
+
else failures.push(`artifact ${name} could not be removed; cleanup is incomplete`);
|
|
307
|
+
}
|
|
308
|
+
if (before?.stored) {
|
|
309
|
+
// What this place holds now joins its record; a failed removal stays
|
|
310
|
+
// in it, so the next measurement retries rather than forgets.
|
|
311
|
+
const present = presentArtifacts(placeDir) ?? new Set();
|
|
312
|
+
const owned = new Set([...before.stored.map((a) => a.name), ...present, ...delivered.keys()]);
|
|
313
|
+
writeSeenArtifacts(seenPath(placeDir, sessionId ?? agentId),
|
|
314
|
+
[...(before.seen ?? []), ...present, ...delivered.keys()]
|
|
315
|
+
.filter((name) => owned.has(name) && !removed.has(name)));
|
|
316
|
+
}
|
|
317
|
+
if (failures.length) throw new Error(failures.join('; '));
|
|
159
318
|
return n;
|
|
160
319
|
};
|
|
161
320
|
|
|
162
|
-
|
|
321
|
+
/**
|
|
322
|
+
* AN IDLE DELIVERY, RECONCILED WITHOUT A TURN (2026-10-11 follow-up). A
|
|
323
|
+
* discard was relayed only when a turn settled, so an agent waiting in
|
|
324
|
+
* Review kept every stored file an earlier daemon never removed. Nothing
|
|
325
|
+
* is uploaded here; the place's measured discards are retired and a
|
|
326
|
+
* failure throws, exactly as a turn's report would.
|
|
327
|
+
*/
|
|
328
|
+
const reconcile = async ({ placeDir, before, sessionId, agentId }) => {
|
|
329
|
+
if (!placeDir || !before || (!sessionId && !agentId)) return 0;
|
|
330
|
+
await prepare({ placeDir, before, sessionId, agentId });
|
|
331
|
+
return report({ placeDir, before, sessionId, agentId, idle: true });
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
return { prepare, report, reconcile, retryPending, pendingCount: () => pending.size };
|
|
163
335
|
}
|