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.
Files changed (45) hide show
  1. package/bin/cli.mjs +13 -3
  2. package/bin/lib/agentPlan.mjs +159 -37
  3. package/bin/lib/artifacts.mjs +136 -26
  4. package/bin/lib/authReject.mjs +32 -0
  5. package/bin/lib/authproxy.mjs +52 -16
  6. package/bin/lib/childEnv.mjs +33 -1
  7. package/bin/lib/claude.mjs +166 -71
  8. package/bin/lib/config.mjs +24 -1
  9. package/bin/lib/credentials.mjs +27 -4
  10. package/bin/lib/deploy.mjs +364 -30
  11. package/bin/lib/env.mjs +130 -13
  12. package/bin/lib/fleet.mjs +99 -28
  13. package/bin/lib/git.mjs +131 -3
  14. package/bin/lib/hooks/readGuard.mjs +24 -0
  15. package/bin/lib/instance.mjs +115 -14
  16. package/bin/lib/knowledge.mjs +180 -60
  17. package/bin/lib/landed.mjs +38 -30
  18. package/bin/lib/listeners.mjs +211 -0
  19. package/bin/lib/localSessions.mjs +29 -4
  20. package/bin/lib/login.mjs +2 -2
  21. package/bin/lib/machines.mjs +16 -7
  22. package/bin/lib/preview.mjs +95 -6
  23. package/bin/lib/printable.mjs +42 -0
  24. package/bin/lib/procRegistry.mjs +65 -0
  25. package/bin/lib/processes.mjs +20 -7
  26. package/bin/lib/projectTools.mjs +248 -0
  27. package/bin/lib/prompts.mjs +16 -11
  28. package/bin/lib/resources.mjs +46 -4
  29. package/bin/lib/runtimeOfferings.mjs +75 -0
  30. package/bin/lib/runtimes.mjs +71 -9
  31. package/bin/lib/shipMerge.mjs +8 -4
  32. package/bin/lib/trace.mjs +16 -6
  33. package/bin/lib/update.mjs +5 -0
  34. package/bin/lib/work.mjs +1192 -4584
  35. package/bin/lib/workAgentMerges.mjs +504 -0
  36. package/bin/lib/workAgentPlans.mjs +447 -0
  37. package/bin/lib/workAgentReview.mjs +702 -0
  38. package/bin/lib/workAgentTurns.mjs +1148 -0
  39. package/bin/lib/workDiffs.mjs +107 -0
  40. package/bin/lib/workPreviews.mjs +351 -0
  41. package/bin/lib/workProcesses.mjs +236 -0
  42. package/bin/lib/workPullRequests.mjs +313 -0
  43. package/bin/lib/workShip.mjs +385 -0
  44. package/bin/lib/worktreeDiff.mjs +99 -43
  45. package/package.json +2 -2
@@ -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
- * `safeUploadName`, verbatim in effect (work.mjs): keeps the extension, drops
157
- * every path separator, never starts with a dot or a dash. Re-applied here
158
- * although the server did it, because this string becomes a path on this box.
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 base = String(raw ?? '')
188
+ const clean = String(raw ?? '')
162
189
  .split(/[\\/]/)
163
190
  .pop()
164
191
  .replace(/[^A-Za-z0-9._-]/g, '_')
165
- .replace(/^[.-]+/, '')
166
- .slice(0, 80);
167
- return base || 'file';
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, file] = parts;
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 (!file || file !== safeKnowledgeName(file)) return null;
333
- if (!file.toLowerCase().endsWith(LIBRARY_EXT[kind]) || file.length <= LIBRARY_EXT[kind].length) return null;
334
- return `${dir}/${file}`;
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
- out.push({ ...it, path });
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
- if (Number(f.bytes) > maxBytes) {
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 > maxBytes) {
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, file] = it.path.split('/');
578
- const path = join(dir, sub, file);
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 (Number(it.bytes) > maxBytes) {
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
- continue;
588
- }
589
- try {
590
- const buf = await fetchFile(it.id, { library: true });
591
- if (!Buffer.isBuffer(buf) || buf.byteLength > maxBytes) {
592
- result.refused.push(it.path);
593
- wanted[sub].delete(file);
594
- continue;
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
- if (typeof it.sha256 === 'string' && sha256Of(buf) !== it.sha256.toLowerCase()) {
597
- throw new Error('sha256 mismatch');
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
- writeAtomic(path, buf);
600
- result.wrote.push(it.path);
601
- onDisk.push(it);
602
- } catch {
603
- result.ok = false;
604
- result.failed.push(it.path);
605
- // A stale copy is better than none until the retry lands — and it stays
606
- // in the catalog only if it is actually there.
607
- if (localSha(path) !== null) onDisk.push(it);
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
- let entries = [];
614
- try {
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 = KNOWLEDGE_FILE_MAX_BYTES }) {
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 res = await fetch(`${library ? libraryBase : base}/${encodeURIComponent(id)}`, {
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
  });
@@ -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. Git
99
- * preserves the 0x1e/0x1f delimiter bytes inside a commit BODY (verified
100
- * empirically), so a crafted message can fabricate whole records — an
101
- * arbitrary sha plus Flowviant-Task ids that /fleet/base-landed would close
102
- * cards on. rev-list prints nothing an author controls, so its output is
103
- * the set of commits that exist: a parsed record whose sha is not in the
104
- * batch is a forgery and is dropped, a repeated sha is the same forgery
105
- * wearing a real commit's name, and the delimiter bytes are stripped from
106
- * every surviving field.
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
- * BOUNDING THE BODY FETCH TO THE BATCH is what makes the header's batching
109
- * contract true at any range size: `%B` over the whole range grows without
110
- * bound, so the formatted log runs over exactly the shas being reported
111
- * this beat (`--no-walk=unsorted` shows precisely the commits named, in
112
- * argv order — measured). */
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 rec of raw.split('\x1e')) {
124
- const line = rec.replace(/^\n+/, '');
125
- if (!line.trim()) continue;
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(bodyParts.join('\n'))).slice(0, 8),
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 the range — the closes it carried re-run at the next REAL tip
209
- // move only if their cards are still open, which is the idempotent
210
- // half; the honest cost is stated out loud.
211
- writeState({ ref, tip });
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 {