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