flowviant 0.97.0 → 0.99.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/bin/cli.mjs +13 -3
- package/bin/lib/agentPlan.mjs +159 -37
- package/bin/lib/artifacts.mjs +136 -26
- package/bin/lib/authReject.mjs +32 -0
- package/bin/lib/authproxy.mjs +52 -16
- package/bin/lib/childEnv.mjs +33 -1
- package/bin/lib/claude.mjs +166 -71
- package/bin/lib/config.mjs +24 -1
- package/bin/lib/credentials.mjs +27 -4
- package/bin/lib/deploy.mjs +364 -30
- package/bin/lib/env.mjs +130 -13
- package/bin/lib/fleet.mjs +99 -28
- package/bin/lib/git.mjs +131 -3
- package/bin/lib/hooks/readGuard.mjs +24 -0
- package/bin/lib/instance.mjs +115 -14
- package/bin/lib/knowledge.mjs +180 -60
- package/bin/lib/landed.mjs +38 -30
- package/bin/lib/listeners.mjs +211 -0
- package/bin/lib/localSessions.mjs +29 -4
- package/bin/lib/login.mjs +2 -2
- package/bin/lib/machines.mjs +16 -7
- package/bin/lib/preview.mjs +95 -6
- package/bin/lib/printable.mjs +42 -0
- package/bin/lib/procRegistry.mjs +65 -0
- package/bin/lib/processes.mjs +20 -7
- package/bin/lib/projectTools.mjs +248 -0
- package/bin/lib/prompts.mjs +16 -11
- package/bin/lib/resources.mjs +46 -4
- package/bin/lib/runtimeOfferings.mjs +75 -0
- package/bin/lib/runtimes.mjs +71 -9
- package/bin/lib/shipMerge.mjs +8 -4
- package/bin/lib/trace.mjs +16 -6
- package/bin/lib/update.mjs +5 -0
- package/bin/lib/work.mjs +1192 -4584
- package/bin/lib/workAgentMerges.mjs +504 -0
- package/bin/lib/workAgentPlans.mjs +447 -0
- package/bin/lib/workAgentReview.mjs +702 -0
- package/bin/lib/workAgentTurns.mjs +1148 -0
- package/bin/lib/workDiffs.mjs +107 -0
- package/bin/lib/workPreviews.mjs +351 -0
- package/bin/lib/workProcesses.mjs +236 -0
- package/bin/lib/workPullRequests.mjs +313 -0
- package/bin/lib/workShip.mjs +385 -0
- package/bin/lib/worktreeDiff.mjs +99 -43
- package/package.json +2 -2
package/bin/lib/knowledge.mjs
CHANGED
|
@@ -63,7 +63,8 @@
|
|
|
63
63
|
* `research/<slug>-v<N>.md`, with a generated `LIBRARY.md` at the root naming
|
|
64
64
|
* every one (path, title, kind, the card that asked for it, the date, what it
|
|
65
65
|
* supersedes). So "implement design A" is an agent opening LIBRARY.md, finding
|
|
66
|
-
* design A, and reading the page.
|
|
66
|
+
* design A, and reading the page. At 0.99.0 a kept design is one folder,
|
|
67
|
+
* including its original relative sidecar paths and measured preview.
|
|
67
68
|
*
|
|
68
69
|
* SUBDIRECTORIES, AND EXACTLY TWO. The safe-name rule for a knowledge file
|
|
69
70
|
* forbids every separator; a library item is the one thing allowed ONE, and
|
|
@@ -132,7 +133,7 @@ export const FLOWVIANT_OWN_PATHS = [
|
|
|
132
133
|
export const LIBRARY_FILE = 'LIBRARY.md';
|
|
133
134
|
/** The two subdirectories a kept item may live in, by kind. */
|
|
134
135
|
export const LIBRARY_DIRS = { design: 'designs', research: 'research' };
|
|
135
|
-
const LIBRARY_EXT = { design: '.html', research: '.md' };
|
|
136
|
+
const LIBRARY_EXT = { design: ['.html', '.htm', '.gltf', '.obj', '.glb', '.bin'], research: ['.md'] };
|
|
136
137
|
/** The server caps a library at 200 items; a longer manifest is cut. */
|
|
137
138
|
const MAX_LIBRARY_ITEMS = 256;
|
|
138
139
|
|
|
@@ -148,23 +149,55 @@ const MARKER = '.flowviant/knowledge.rev';
|
|
|
148
149
|
* this is somebody's disk, and one place checking is one deploy away from
|
|
149
150
|
* zero places — the attachment fetch's own rule. */
|
|
150
151
|
export const KNOWLEDGE_FILE_MAX_BYTES = 10 * 1024 * 1024;
|
|
152
|
+
export const KNOWLEDGE_BINARY_MODEL_MAX_BYTES = 20 * 1024 * 1024;
|
|
153
|
+
const knowledgeMaxFor = (name, base) => /\.(glb|bin)$/i.test(name)
|
|
154
|
+
? Math.max(base, KNOWLEDGE_BINARY_MODEL_MAX_BYTES) : base;
|
|
151
155
|
/** The manifest is capped at the server (25 files); a manifest longer than
|
|
152
156
|
* this is not one the product produced, and is cut rather than trusted. */
|
|
153
157
|
const MAX_FILES = 64;
|
|
154
158
|
|
|
159
|
+
/** FNV-1a, 32-bit, as 8 hex — deterministic, so the same long name always maps
|
|
160
|
+
* to the same stored name. Verbatim against the server's `fnv1a8`
|
|
161
|
+
* (apps/api/src/routes/sessionsAttachments.routes.ts). */
|
|
162
|
+
function fnv1a8(s) {
|
|
163
|
+
let h = 0x811c9dc5;
|
|
164
|
+
for (let i = 0; i < s.length; i++) {
|
|
165
|
+
h ^= s.charCodeAt(i);
|
|
166
|
+
h = Math.imul(h, 0x01000193);
|
|
167
|
+
}
|
|
168
|
+
return (h >>> 0).toString(16).padStart(8, '0');
|
|
169
|
+
}
|
|
170
|
+
/** What counts as an extension worth keeping through a cut. */
|
|
171
|
+
const SAFE_EXT_RE = /^[A-Za-z0-9]{1,10}$/;
|
|
172
|
+
const SAFE_NAME_MAX = 80;
|
|
173
|
+
|
|
155
174
|
/**
|
|
156
|
-
* `
|
|
157
|
-
* every path separator, never starts with a dot or a dash
|
|
158
|
-
*
|
|
175
|
+
* `safeFileName`, verbatim (apps/api/src/routes/sessionsAttachments.routes.ts):
|
|
176
|
+
* drops every path separator, never starts with a dot or a dash, and — since
|
|
177
|
+
* 2026-09-24 — THE EXTENSION SURVIVES A CUT. A bare `slice(0, 80)` truncated
|
|
178
|
+
* an over-long name mid-extension (an 84-character `….html` mockup stored as
|
|
179
|
+
* `….` with an unrecognised type and its bytes discarded), so a long name's
|
|
180
|
+
* stem is now cut and an 8-hex FNV-1a hash of the WHOLE sanitised name is
|
|
181
|
+
* appended before the extension: stable across calls, at most 80 characters,
|
|
182
|
+
* idempotent over its own output, and — the reason it is re-applied here
|
|
183
|
+
* rather than trusted to the server's own pass — byte-identical to what
|
|
184
|
+
* `safeFileName` computes there, so a knowledge file synced under this name
|
|
185
|
+
* and one requested by name later never disagree over the cut.
|
|
159
186
|
*/
|
|
160
187
|
export function safeKnowledgeName(raw) {
|
|
161
|
-
const
|
|
188
|
+
const clean = String(raw ?? '')
|
|
162
189
|
.split(/[\\/]/)
|
|
163
190
|
.pop()
|
|
164
191
|
.replace(/[^A-Za-z0-9._-]/g, '_')
|
|
165
|
-
.replace(/^[.-]+/, '')
|
|
166
|
-
|
|
167
|
-
|
|
192
|
+
.replace(/^[.-]+/, '');
|
|
193
|
+
if (!clean) return 'file';
|
|
194
|
+
if (clean.length <= SAFE_NAME_MAX) return clean;
|
|
195
|
+
const dot = clean.lastIndexOf('.');
|
|
196
|
+
const ext = dot > 0 && SAFE_EXT_RE.test(clean.slice(dot + 1)) ? clean.slice(dot + 1) : '';
|
|
197
|
+
const stem = ext ? clean.slice(0, dot) : clean;
|
|
198
|
+
const tag = `-${fnv1a8(clean)}`;
|
|
199
|
+
const room = SAFE_NAME_MAX - tag.length - (ext ? ext.length + 1 : 0);
|
|
200
|
+
return `${stem.slice(0, room)}${tag}${ext ? `.${ext}` : ''}`;
|
|
168
201
|
}
|
|
169
202
|
|
|
170
203
|
/**
|
|
@@ -326,12 +359,30 @@ function writeKnowledgeMarker(checkoutDir, rev, lib = false) {
|
|
|
326
359
|
export function safeLibraryPath(name, kind) {
|
|
327
360
|
if (typeof name !== 'string' || !(kind in LIBRARY_DIRS)) return null;
|
|
328
361
|
const parts = name.split('/');
|
|
329
|
-
if (parts.length !== 2) return null;
|
|
330
|
-
const [dir,
|
|
362
|
+
if (parts.length !== 2 && !(kind === 'design' && parts.length >= 3 && parts.length <= 7)) return null;
|
|
363
|
+
const [dir, ...rest] = parts;
|
|
364
|
+
const file = rest.at(-1);
|
|
331
365
|
if (dir !== LIBRARY_DIRS[kind]) return null;
|
|
332
|
-
if (
|
|
333
|
-
if (
|
|
334
|
-
return
|
|
366
|
+
if (rest.length >= 2 && !/^[a-z0-9-]+-v[1-9][0-9]*$/.test(rest[0])) return null;
|
|
367
|
+
if (rest.slice(1, -1).some((p) => !safeBundleSegment(p))) return null;
|
|
368
|
+
if (!file || (rest.length === 1 ? file !== safeKnowledgeName(file) : !safeBundleSegment(file))) return null;
|
|
369
|
+
if (!LIBRARY_EXT[kind].some((ext) => file.toLowerCase().endsWith(ext) && file.length > ext.length)) return null;
|
|
370
|
+
return name;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function safeBundleSegment(part) {
|
|
374
|
+
return typeof part === 'string' && part.length > 0 && part.length <= 80 &&
|
|
375
|
+
/^[A-Za-z0-9][A-Za-z0-9._ -]*$/.test(part) && !part.endsWith('.');
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function safeBundleFile(path, rootDir) {
|
|
379
|
+
if (typeof path !== 'string' || !path.startsWith(`${rootDir}/`)) return null;
|
|
380
|
+
const relative = path.slice(rootDir.length + 1);
|
|
381
|
+
if (relative === 'preview.png') return null;
|
|
382
|
+
const parts = relative.split('/');
|
|
383
|
+
if (!parts.length || parts.some((p) => !safeBundleSegment(p))) return null;
|
|
384
|
+
if (!/\.(gltf|obj|glb|bin|png|jpg|jpeg|gif|webp|svg|json)$/i.test(relative)) return null;
|
|
385
|
+
return path;
|
|
335
386
|
}
|
|
336
387
|
|
|
337
388
|
/** The manifest's library items that are safe to write, in manifest order, or
|
|
@@ -347,7 +398,14 @@ function libraryItemsOf(manifest) {
|
|
|
347
398
|
const path = safeLibraryPath(it.name, it.kind);
|
|
348
399
|
if (!path || seen.has(path.toLowerCase())) continue;
|
|
349
400
|
seen.add(path.toLowerCase());
|
|
350
|
-
|
|
401
|
+
const bundleDir = path.split('/').length >= 3 ? path.split('/').slice(0, 2).join('/') : null;
|
|
402
|
+
const expectedPreview = bundleDir ? `${bundleDir}/preview.png` : it.name.replace(/\.html$/, '.png');
|
|
403
|
+
const previewPath = it.kind === 'design' && it.preview && it.preview.name === expectedPreview ? expectedPreview : null;
|
|
404
|
+
const files = bundleDir && Array.isArray(it.files) ? it.files.map((f, index) => ({ ...f,
|
|
405
|
+
index, path: safeBundleFile(f.name, bundleDir),
|
|
406
|
+
})).filter((f) => f.path && !seen.has(f.path.toLowerCase())) : [];
|
|
407
|
+
for (const f of files) seen.add(f.path.toLowerCase());
|
|
408
|
+
out.push({ ...it, path, files, previewPath, previewUnknown: it.kind === 'design' && path.endsWith('.html') && it.preview === undefined });
|
|
351
409
|
}
|
|
352
410
|
return out;
|
|
353
411
|
}
|
|
@@ -408,6 +466,32 @@ function ensureSubdir(dir, name) {
|
|
|
408
466
|
return path;
|
|
409
467
|
}
|
|
410
468
|
|
|
469
|
+
function ensureLibraryParents(dir, relativePath) {
|
|
470
|
+
const parts = relativePath.split('/');
|
|
471
|
+
let at = dir;
|
|
472
|
+
for (const part of parts.slice(0, -1)) at = ensureSubdir(at, part);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function sweepLibraryTree(dir, sub, wanted, result) {
|
|
476
|
+
const walk = (at, prefix = '') => {
|
|
477
|
+
let entries;
|
|
478
|
+
try { entries = readdirSync(at); } catch { return; }
|
|
479
|
+
for (const name of entries) {
|
|
480
|
+
const rel = prefix ? `${prefix}/${name}` : name;
|
|
481
|
+
const path = join(at, name);
|
|
482
|
+
let st;
|
|
483
|
+
try { st = lstatSync(path); } catch { continue; }
|
|
484
|
+
if (st.isDirectory() && !st.isSymbolicLink()) {
|
|
485
|
+
walk(path, rel);
|
|
486
|
+
try { if (readdirSync(path).length === 0) rmSync(path, { recursive: true, force: true }); } catch { /* next sync */ }
|
|
487
|
+
} else if (!wanted.has(rel)) {
|
|
488
|
+
try { rmSync(path, { recursive: true, force: true }); result.removed.push(`${sub}/${rel}`); } catch { /* next sync */ }
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
walk(join(dir, sub));
|
|
493
|
+
};
|
|
494
|
+
|
|
411
495
|
/** Does this manifest look like one the server produced? Anything else is
|
|
412
496
|
* ignored whole rather than half-applied. */
|
|
413
497
|
export function isKnowledgeManifest(k) {
|
|
@@ -486,7 +570,8 @@ export async function syncKnowledge({ checkoutDir, manifest, fetchFile, maxBytes
|
|
|
486
570
|
|
|
487
571
|
for (const f of planned) {
|
|
488
572
|
const path = join(dir, f.local);
|
|
489
|
-
|
|
573
|
+
const fileMax = knowledgeMaxFor(f.name, maxBytes);
|
|
574
|
+
if (Number(f.bytes) > fileMax) {
|
|
490
575
|
// Permanent for this rev, and NOT a reason to delete the rest. Any stale
|
|
491
576
|
// local copy under this name goes, because it is not the file the
|
|
492
577
|
// manifest now names.
|
|
@@ -497,7 +582,7 @@ export async function syncKnowledge({ checkoutDir, manifest, fetchFile, maxBytes
|
|
|
497
582
|
if (typeof f.sha256 === 'string' && localSha(path) === f.sha256.toLowerCase()) continue;
|
|
498
583
|
try {
|
|
499
584
|
const buf = await fetchFile(f.id);
|
|
500
|
-
if (!Buffer.isBuffer(buf) || buf.byteLength >
|
|
585
|
+
if (!Buffer.isBuffer(buf) || buf.byteLength > fileMax) {
|
|
501
586
|
result.refused.push(f.local);
|
|
502
587
|
keep.delete(f.local);
|
|
503
588
|
continue;
|
|
@@ -574,58 +659,84 @@ async function syncLibrary({ dir, items, fetchFile, maxBytes, keep, result }) {
|
|
|
574
659
|
}
|
|
575
660
|
}
|
|
576
661
|
for (const it of items) {
|
|
577
|
-
const [sub,
|
|
578
|
-
const
|
|
662
|
+
const [sub, ...tail] = it.path.split('/');
|
|
663
|
+
const file = tail.join('/');
|
|
664
|
+
const path = join(dir, ...it.path.split('/'));
|
|
665
|
+
ensureLibraryParents(dir, it.path);
|
|
666
|
+
const itemMax = knowledgeMaxFor(it.path, maxBytes);
|
|
579
667
|
wanted[sub].add(file);
|
|
580
|
-
if (
|
|
668
|
+
if (it.previewPath) wanted[sub].add(it.previewPath.split('/').slice(1).join('/'));
|
|
669
|
+
if (it.previewUnknown) {
|
|
670
|
+
const oldPng = file.includes('/') ? `${file.slice(0, file.lastIndexOf('/'))}/preview.png` : file.replace(/\.html$/, '.png');
|
|
671
|
+
if (localSha(join(dir, sub, ...oldPng.split('/'))) !== null) wanted[sub].add(oldPng);
|
|
672
|
+
}
|
|
673
|
+
if (Number(it.bytes) > itemMax) {
|
|
581
674
|
result.refused.push(it.path);
|
|
582
675
|
wanted[sub].delete(file);
|
|
583
676
|
continue;
|
|
584
677
|
}
|
|
585
678
|
if (typeof it.sha256 === 'string' && localSha(path) === it.sha256.toLowerCase()) {
|
|
586
679
|
onDisk.push(it);
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
680
|
+
} else {
|
|
681
|
+
try {
|
|
682
|
+
const buf = await fetchFile(it.id, { library: true });
|
|
683
|
+
if (!Buffer.isBuffer(buf) || buf.byteLength > itemMax) {
|
|
684
|
+
result.refused.push(it.path);
|
|
685
|
+
wanted[sub].delete(file);
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
if (typeof it.sha256 === 'string' && sha256Of(buf) !== it.sha256.toLowerCase()) {
|
|
689
|
+
throw new Error('sha256 mismatch');
|
|
690
|
+
}
|
|
691
|
+
ensureLibraryParents(dir, it.path);
|
|
692
|
+
writeAtomic(path, buf);
|
|
693
|
+
result.wrote.push(it.path);
|
|
694
|
+
onDisk.push(it);
|
|
695
|
+
} catch {
|
|
696
|
+
result.ok = false;
|
|
697
|
+
result.failed.push(it.path);
|
|
698
|
+
if (localSha(path) !== null) onDisk.push(it);
|
|
595
699
|
}
|
|
596
|
-
|
|
597
|
-
|
|
700
|
+
}
|
|
701
|
+
if (it.previewPath && it.preview?.sha256) {
|
|
702
|
+
ensureLibraryParents(dir, it.previewPath);
|
|
703
|
+
const previewFile = join(dir, ...it.previewPath.split('/'));
|
|
704
|
+
if (localSha(previewFile) !== it.preview.sha256.toLowerCase()) {
|
|
705
|
+
try {
|
|
706
|
+
const buf = await fetchFile(it.id, { library: true, preview: true });
|
|
707
|
+
if (!Buffer.isBuffer(buf) || buf.byteLength > 2 * 1024 * 1024 || sha256Of(buf) !== it.preview.sha256.toLowerCase()) throw new Error('preview mismatch');
|
|
708
|
+
ensureLibraryParents(dir, it.previewPath);
|
|
709
|
+
writeAtomic(previewFile, buf);
|
|
710
|
+
result.wrote.push(it.previewPath);
|
|
711
|
+
} catch {
|
|
712
|
+
result.ok = false;
|
|
713
|
+
result.failed.push(it.previewPath);
|
|
714
|
+
}
|
|
598
715
|
}
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
if (localSha(
|
|
716
|
+
}
|
|
717
|
+
for (const f of it.files ?? []) {
|
|
718
|
+
ensureLibraryParents(dir, f.path);
|
|
719
|
+
const rel = f.path.split('/').slice(1).join('/');
|
|
720
|
+
wanted[sub].add(rel);
|
|
721
|
+
const filePath = join(dir, ...f.path.split('/'));
|
|
722
|
+
const fileMax = knowledgeMaxFor(f.path, maxBytes);
|
|
723
|
+
if (Number(f.bytes) > fileMax) { result.refused.push(f.path); wanted[sub].delete(rel); continue; }
|
|
724
|
+
if (localSha(filePath) === String(f.sha256).toLowerCase()) continue;
|
|
725
|
+
try {
|
|
726
|
+
const buf = await fetchFile(it.id, { library: true, fileIndex: f.index });
|
|
727
|
+
if (!Buffer.isBuffer(buf) || buf.byteLength > fileMax) { result.refused.push(f.path); wanted[sub].delete(rel); continue; }
|
|
728
|
+
if (sha256Of(buf) !== String(f.sha256).toLowerCase()) throw new Error('sha256 mismatch');
|
|
729
|
+
ensureLibraryParents(dir, f.path);
|
|
730
|
+
writeAtomic(filePath, buf);
|
|
731
|
+
result.wrote.push(f.path);
|
|
732
|
+
} catch { result.ok = false; result.failed.push(f.path); }
|
|
608
733
|
}
|
|
609
734
|
}
|
|
610
735
|
// Each subdirectory holds exactly what the manifest names.
|
|
611
736
|
for (const sub of Object.values(LIBRARY_DIRS)) {
|
|
612
737
|
const subPath = join(dir, sub);
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
if (!lstatSync(subPath).isDirectory()) continue;
|
|
616
|
-
entries = readdirSync(subPath);
|
|
617
|
-
} catch {
|
|
618
|
-
continue;
|
|
619
|
-
}
|
|
620
|
-
for (const name of entries) {
|
|
621
|
-
if (wanted[sub].has(name)) continue;
|
|
622
|
-
try {
|
|
623
|
-
rmSync(join(subPath, name), { recursive: true, force: true });
|
|
624
|
-
result.removed.push(`${sub}/${name}`);
|
|
625
|
-
} catch {
|
|
626
|
-
/* best-effort */
|
|
627
|
-
}
|
|
628
|
-
}
|
|
738
|
+
try { if (!lstatSync(subPath).isDirectory()) continue; } catch { continue; }
|
|
739
|
+
sweepLibraryTree(dir, sub, wanted[sub], result);
|
|
629
740
|
// An emptied subdirectory leaves with its last item.
|
|
630
741
|
if (wanted[sub].size === 0) keep.delete(sub);
|
|
631
742
|
}
|
|
@@ -697,18 +808,26 @@ function libraryStale(checkoutDir, manifest, { synced, refused }) {
|
|
|
697
808
|
const items = libraryItemsOf(manifest);
|
|
698
809
|
if (items === null) return false;
|
|
699
810
|
if (!synced) return true;
|
|
700
|
-
const expected = items.filter((it) => !(Number(it.bytes) > KNOWLEDGE_FILE_MAX_BYTES) && !refused.has(it.path));
|
|
811
|
+
const expected = items.filter((it) => !(Number(it.bytes) > knowledgeMaxFor(it.path, KNOWLEDGE_FILE_MAX_BYTES)) && !refused.has(it.path));
|
|
701
812
|
if (expected.length === 0) return false;
|
|
702
813
|
const dir = join(checkoutDir, KNOWLEDGE_DIR);
|
|
703
814
|
const isFile = (p) => {
|
|
704
815
|
try {
|
|
816
|
+
let parent = dir;
|
|
817
|
+
for (const part of p.slice(dir.length + 1).split('/').slice(0, -1)) {
|
|
818
|
+
parent = join(parent, part);
|
|
819
|
+
const st = lstatSync(parent);
|
|
820
|
+
if (st.isSymbolicLink() || !st.isDirectory()) return false;
|
|
821
|
+
}
|
|
705
822
|
return lstatSync(p).isFile();
|
|
706
823
|
} catch {
|
|
707
824
|
return false;
|
|
708
825
|
}
|
|
709
826
|
};
|
|
710
827
|
if (!isFile(join(dir, LIBRARY_FILE))) return true;
|
|
711
|
-
return expected.some((it) => !isFile(join(dir, ...it.path.split('/')))
|
|
828
|
+
return expected.some((it) => !isFile(join(dir, ...it.path.split('/'))) ||
|
|
829
|
+
(it.previewPath && !refused.has(it.previewPath) && !isFile(join(dir, ...it.previewPath.split('/')))) ||
|
|
830
|
+
(it.files ?? []).some((f) => !refused.has(f.path) && !isFile(join(dir, ...f.path.split('/')))));
|
|
712
831
|
}
|
|
713
832
|
|
|
714
833
|
/**
|
|
@@ -827,13 +946,14 @@ export function createKnowledgeSync({
|
|
|
827
946
|
* URL is derived from the roster URL the way every `/fleet/*` path is, so a
|
|
828
947
|
* self-hosted `FLOWVIANT_FLEET_URL` is honoured.
|
|
829
948
|
*/
|
|
830
|
-
export function knowledgeFetcher({ fleetUrl, token, userAgent, maxBytes =
|
|
949
|
+
export function knowledgeFetcher({ fleetUrl, token, userAgent, maxBytes = KNOWLEDGE_BINARY_MODEL_MAX_BYTES }) {
|
|
831
950
|
const base = String(fleetUrl).replace(/\/agents\/?$/, '/knowledge');
|
|
832
951
|
// A kept library item (0.97.0) is its sibling, `GET /fleet/library/:id` —
|
|
833
952
|
// the same credential, the same shape, the same caps.
|
|
834
953
|
const libraryBase = String(fleetUrl).replace(/\/agents\/?$/, '/library');
|
|
835
|
-
return async (id, { library = false } = {}) => {
|
|
836
|
-
const
|
|
954
|
+
return async (id, { library = false, preview = false, fileIndex = null } = {}) => {
|
|
955
|
+
const suffix = preview ? '/preview' : Number.isSafeInteger(fileIndex) && fileIndex >= 0 ? `/file/${fileIndex}` : '';
|
|
956
|
+
const res = await fetch(`${library ? libraryBase : base}/${encodeURIComponent(id)}${suffix}`, {
|
|
837
957
|
headers: { Authorization: `Bearer ${token}`, 'User-Agent': userAgent },
|
|
838
958
|
signal: AbortSignal.timeout(60_000),
|
|
839
959
|
});
|
package/bin/lib/landed.mjs
CHANGED
|
@@ -29,7 +29,7 @@ import { join } from 'node:path';
|
|
|
29
29
|
import { homedir } from 'node:os';
|
|
30
30
|
import { createHash } from 'node:crypto';
|
|
31
31
|
import { git, baseBranchName } from './git.mjs';
|
|
32
|
-
import { stripDelims, taskIdsFromMessage } from './worktreeDiff.mjs';
|
|
32
|
+
import { commitRecord, stripDelims, taskIdsFromMessage } from './worktreeDiff.mjs';
|
|
33
33
|
import { warn } from './ui.mjs';
|
|
34
34
|
import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
|
|
35
35
|
|
|
@@ -95,41 +95,39 @@ export function createLandedObserver({ repoRoot, baseRef }) {
|
|
|
95
95
|
* merge commit describes a range rather than doing work, and its
|
|
96
96
|
* constituents are walked as themselves.
|
|
97
97
|
*
|
|
98
|
-
* THE SHA LIST COMES FROM REV-LIST, NEVER FROM THE FORMATTED LOG
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
98
|
+
* THE SHA LIST COMES FROM REV-LIST, NEVER FROM THE FORMATTED LOG, and each
|
|
99
|
+
* commit is then read BY ITS OWN SHA (`commitRecord`, one `git show` per
|
|
100
|
+
* commit) rather than as one delimited multi-record log. A batched log
|
|
101
|
+
* used to print every commit's `%H%x1f%s%x1f%B%x1e` back to back and split
|
|
102
|
+
* the blob on those same delimiter bytes — bytes git preserves verbatim
|
|
103
|
+
* inside a commit BODY (verified empirically), so a crafted message could
|
|
104
|
+
* embed its own fake `%x1e`/`%x1f%1f` run and fabricate an extra "record"
|
|
105
|
+
* naming an arbitrary sha and Flowviant-Task ids that /fleet/base-landed
|
|
106
|
+
* would close cards on. Reading one sha at a time removes the multi-record
|
|
107
|
+
* parse entirely — there is no blob to split, so there is nothing for an
|
|
108
|
+
* embedded delimiter to forge a SECOND record out of. `stripDelims` still
|
|
109
|
+
* scrubs whatever lands in the subject/body of the commit it actually
|
|
110
|
+
* belongs to, the same defence-in-depth `branchCommits` above keeps.
|
|
107
111
|
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
112
|
+
* ONE UNREADABLE COMMIT IS SKIPPED, NOT AN ABORT OF THE WHOLE BATCH:
|
|
113
|
+
* `commitRecord` runs through `gitRaw`, whose `maxBuffer` is git.mjs's
|
|
114
|
+
* plain default (1MiB) rather than this module's widened `gitWide` — a
|
|
115
|
+
* single commit whose message alone exceeds that returns null and is
|
|
116
|
+
* dropped, while every other commit in the batch still reports. */
|
|
113
117
|
const walk = (from, to) => {
|
|
114
118
|
const shas = gitWide(['rev-list', '--reverse', '--no-merges', `${from}..${to}`])
|
|
115
119
|
.split('\n')
|
|
116
120
|
.filter((s) => SHA_RE.test(s));
|
|
117
121
|
const batch = shas.slice(0, MAX_COMMITS);
|
|
118
122
|
const tipAfter = shas.length > MAX_COMMITS ? batch[batch.length - 1] : null;
|
|
119
|
-
if (batch.length === 0) return { commits: [], tipAfter };
|
|
120
|
-
const real = new Set(batch);
|
|
121
|
-
const raw = gitWide(['log', '--no-walk=unsorted', '--format=%H%x1f%s%x1f%B%x1e', ...batch]);
|
|
122
123
|
const out = [];
|
|
123
|
-
for (const
|
|
124
|
-
const
|
|
125
|
-
if (!
|
|
126
|
-
const [sha, subject, ...bodyParts] = line.split('\x1f');
|
|
127
|
-
if (!real.has(sha)) continue;
|
|
128
|
-
real.delete(sha);
|
|
124
|
+
for (const sha of batch) {
|
|
125
|
+
const rec = commitRecord(sha, repoRoot);
|
|
126
|
+
if (!rec) continue;
|
|
129
127
|
out.push({
|
|
130
128
|
sha,
|
|
131
|
-
subject: stripDelims(subject).slice(0, 200),
|
|
132
|
-
taskIds: taskIdsFromMessage(stripDelims(
|
|
129
|
+
subject: stripDelims(rec.subject).slice(0, 200),
|
|
130
|
+
taskIds: taskIdsFromMessage(stripDelims(rec.body)).slice(0, 8),
|
|
133
131
|
});
|
|
134
132
|
}
|
|
135
133
|
return { commits: out, tipAfter };
|
|
@@ -202,13 +200,23 @@ export function createLandedObserver({ repoRoot, baseRef }) {
|
|
|
202
200
|
for (const r of j?.data?.deployRefused ?? []) {
|
|
203
201
|
warn(`deploy-on-merge refused for target "${r?.targetId}": ${r?.reason}`);
|
|
204
202
|
}
|
|
203
|
+
} else if (res.status === 408 || res.status === 429) {
|
|
204
|
+
// A TIMEOUT OR A RATE LIMIT IS "NOT NOW", NOT "NEVER". Both used to
|
|
205
|
+
// land in the drop below, so one 429 — a busy operator IP past the
|
|
206
|
+
// limiter — skipped the range for good: its trailers and receipt shas
|
|
207
|
+
// never closed their cards (in PR mode the observer is the ONLY road
|
|
208
|
+
// to Done) and deploy-on-merge for that tip was never queued. Keep the
|
|
209
|
+
// stored tip, exactly as a 5xx does; the next beat re-walks it.
|
|
205
210
|
} else if (res.status >= 400 && res.status < 500) {
|
|
206
211
|
// A persistent 4xx (deploy skew, a payload this server refuses) would
|
|
207
212
|
// otherwise re-send the same poison range on every beat forever.
|
|
208
|
-
// Drop
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
|
|
213
|
+
// Drop THIS BATCH — to `reportedTip`, the last commit it carried, never
|
|
214
|
+
// the full tip: a range past the cap has a remainder this batch never
|
|
215
|
+
// sent, and advancing to the tip discarded that too. The closes it
|
|
216
|
+
// carried re-run at the next REAL tip move only if their cards are
|
|
217
|
+
// still open, which is the idempotent half; the honest cost is stated
|
|
218
|
+
// out loud.
|
|
219
|
+
writeState({ ref, tip: reportedTip });
|
|
212
220
|
warn(`base-landed report refused (${res.status}) — skipped ${commits.length} commit(s)`);
|
|
213
221
|
}
|
|
214
222
|
} catch {
|