flowviant 0.25.0 → 0.27.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/lib/fleet.mjs CHANGED
@@ -54,10 +54,25 @@ import { runLiveWorker } from './live.mjs';
54
54
  import { reapOrphanPreviews } from './preview.mjs';
55
55
  import { preflight } from './preflight.mjs';
56
56
  import { connectStream } from './stream.mjs';
57
+ import { ensureVault, syncVault } from './vault.mjs';
58
+ import {
59
+ envQueryParams,
60
+ handleRosterEnv,
61
+ materializeInto,
62
+ scrub as envScrub,
63
+ } from './env.mjs';
57
64
 
58
65
  async function fetchRoster(haveIds) {
59
66
  const url = new URL(FLEET_URL);
60
67
  if (haveIds.length) url.searchParams.set('have', haveIds.join(','));
68
+ // Env-sync identity + materialized version (the Settings "env vN" chip).
69
+ try {
70
+ for (const [k, v] of Object.entries(await envQueryParams())) {
71
+ if (v) url.searchParams.set(k, v);
72
+ }
73
+ } catch {
74
+ /* env identity is best-effort — the poll must never fail on it */
75
+ }
61
76
  // An explicit User-Agent is required: Node's default ("node"/empty) trips
62
77
  // Cloudflare Bot Fight Mode (403). A descriptive product UA passes.
63
78
  const res = await fetch(url, {
@@ -116,6 +131,7 @@ async function runFleetWorker({ agentId, label, cwd, baseRef, getToken, getHasWo
116
131
  }
117
132
  if (!resuming && needsReset) {
118
133
  resetWorktree(cwd, baseRef); // clean slate for a new task
134
+ materializeInto(cwd); // reset wiped the env files (git clean -fd) — rewrite
119
135
  needsReset = false;
120
136
  }
121
137
  const { dir, path: mcpConfig } = mcpConfigFor(token, getMcpUrl());
@@ -221,6 +237,14 @@ export async function runFleetDaemon() {
221
237
  } catch {
222
238
  /* best-effort */
223
239
  }
240
+ // A mid-sweep wiki Claude must die with the daemon — orphaning it leaves it
241
+ // burning quota, and a restarted daemon would start a SECOND sweep racing
242
+ // it on the same vault dir + sync state.
243
+ try {
244
+ wikiChild?.kill('SIGKILL');
245
+ } catch {
246
+ /* best-effort */
247
+ }
224
248
  for (const [, w] of workers) {
225
249
  w.state.alive = false;
226
250
  try {
@@ -398,36 +422,31 @@ export async function runFleetDaemon() {
398
422
  }
399
423
  };
400
424
 
401
- // Living-wiki work runs ONE turn at a time in a dedicated worktree (off the
402
- // agents' checkouts), via the emit_wiki_node MCP tool never code edits. Two
403
- // triggers enqueue: a Regenerate click (full SWEEP) and a successful merge
404
- // (incremental RE-GROUND feature-history). One queue + runner serializes
405
- // them so they never collide on the worktree. Each turn runs under its own
406
- // wiki-scoped credential (minted per task below) the server allows ONLY the
407
- // wiki tools on it, and build agents' worker tokens can't touch the map. Also
408
- // means wiki work needs no agent online.
425
+ // Living-wiki work runs ONE turn at a time in a dedicated repo worktree (off
426
+ // the agents' checkouts). Claude READS the repo there and writes the markdown
427
+ // VAULT (~/.flowviant/vaults/<projectId>) plain files, no MCP tools; the
428
+ // daemon hash-diff syncs the vault to the server after each turn. Two
429
+ // triggers enqueue: a Regenerate click (full SWEEP, finalize-prunes) and a
430
+ // successful merge (incremental RE-GROUND). One queue + runner serializes
431
+ // them so they never collide on the worktree or the vault. Wiki work needs no
432
+ // agent online.
409
433
  const wikiWt = join(baseDir, 'wiki');
410
434
  const REGROUND_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/reground-done');
411
- const WIKI_TOKEN_URL = FLEET_URL.replace(/\/agents\/?$/, '/wiki-token');
435
+ const WIKI_VAULT_URL = FLEET_URL.replace(/\/agents\/?$/, '/wiki-vault');
412
436
  const WIKI_PROGRESS_URL = FLEET_URL.replace(/\/agents\/?$/, '/wiki-progress');
413
437
  const wikiQueue = [];
414
438
  let wikiBusy = false;
439
+ let wikiChild = null; // the wiki turn's Claude process — tracked so teardown can kill it
415
440
  let lastSweepAt = null; // dedup: run each Regenerate request once
416
441
  const groundedIntents = new Set(); // dedup: re-ground each delivery once
417
-
418
- const mintWikiToken = async () => {
419
- try {
420
- const res = await fetch(WIKI_TOKEN_URL, {
421
- method: 'POST',
422
- headers: { Authorization: `Bearer ${FLEET_TOKEN}`, 'User-Agent': USER_AGENT },
423
- signal: AbortSignal.timeout(30_000),
424
- });
425
- if (!res.ok) return null;
426
- return (await res.json())?.data?.token ?? null;
427
- } catch {
428
- return null;
429
- }
430
- };
442
+ // The vault is keyed by the server project this fleet credential serves
443
+ // (learned from the roster); until the first poll names it, fall back to a
444
+ // repo-keyed dir so a stale-server daemon still works.
445
+ let wikiProjectId = null;
446
+ const vaultDirFor = () =>
447
+ wikiProjectId && isSafePathSegment(wikiProjectId)
448
+ ? join(homedir(), '.flowviant', 'vaults', wikiProjectId)
449
+ : join(homedir(), '.flowviant', 'vaults', repoKey);
431
450
 
432
451
  // Stream what the wiki turn is doing to the app (the canvas renders the read
433
452
  // phase). Throttled to ~1/s — the FIRST activity of a run and the terminal
@@ -437,6 +456,14 @@ export async function runFleetDaemon() {
437
456
  const now = Date.now();
438
457
  if (!force && now - lastProgressAt < 600) return;
439
458
  lastProgressAt = now;
459
+ // Uplink scrub: narration/labels can quote repo content, and repo content
460
+ // can contain a synced secret — redact known values before anything leaves
461
+ // this machine.
462
+ const safe = {
463
+ ...body,
464
+ ...(typeof body.activity === 'string' ? { activity: envScrub(body.activity) } : {}),
465
+ ...(Array.isArray(body.recent) ? { recent: body.recent.map((s) => envScrub(s)) } : {}),
466
+ };
440
467
  try {
441
468
  await fetch(WIKI_PROGRESS_URL, {
442
469
  method: 'POST',
@@ -446,7 +473,7 @@ export async function runFleetDaemon() {
446
473
  'Content-Type': 'application/json',
447
474
  },
448
475
  signal: AbortSignal.timeout(15_000),
449
- body: JSON.stringify(body),
476
+ body: JSON.stringify(safe),
450
477
  });
451
478
  } catch {
452
479
  /* best-effort — a dropped frame is harmless, the next one supersedes it */
@@ -456,6 +483,11 @@ export async function runFleetDaemon() {
456
483
  const enqueueSweep = (job) => {
457
484
  if (!job || job.requestedAt === lastSweepAt) return;
458
485
  lastSweepAt = job.requestedAt;
486
+ // A full sweep is expensive — never stack two. One queued sweep already
487
+ // covers any newer Regenerate click (it reads the repo fresh when it runs).
488
+ // A failed/partial sweep stays recoverable: re-clicking Regenerate always
489
+ // refreshes requestedAt server-side, beating this dedup.
490
+ if (wikiQueue.some((t) => t.type === 'sweep')) return;
459
491
  wikiQueue.push({ type: 'sweep' });
460
492
  void drainWiki();
461
493
  };
@@ -468,6 +500,9 @@ export async function runFleetDaemon() {
468
500
 
469
501
  // Changed files of a (merged) PR, for the re-ground prompt. Capped so a huge
470
502
  // PR can't blow up the prompt. prUrl was already validated before the merge.
503
+ // Returns null on a gh FAILURE (network/auth) — distinct from a PR that
504
+ // genuinely changed nothing — so the caller can retry instead of silently
505
+ // consuming the durable job with no re-ground run.
471
506
  const changedFilesForPr = (prUrl) => {
472
507
  try {
473
508
  const out = execFileSync('gh', ['pr', 'view', prUrl, '--json', 'files'], {
@@ -477,24 +512,22 @@ export async function runFleetDaemon() {
477
512
  });
478
513
  return (JSON.parse(out).files ?? []).map((f) => f.path).filter(Boolean).slice(0, 60);
479
514
  } catch {
480
- return [];
515
+ return null;
481
516
  }
482
517
  };
518
+ const regroundAttempts = new Map(); // intentId -> gh-failure count
483
519
 
484
520
  async function drainWiki() {
485
521
  if (wikiBusy || wikiQueue.length === 0) return;
486
522
  wikiBusy = true;
487
523
  try {
488
524
  while (wikiQueue.length) {
489
- // Fresh wiki-scoped credential per task — dedicated, so no roster
490
- // re-mint can rotate it out from under a long sweep.
491
- const token = await mintWikiToken();
492
- if (!token) {
493
- warn('wiki: could not mint the cartographer token — retrying on a later poll');
494
- break; // queue intact — the reconcile loop re-drains
495
- }
496
525
  const task = wikiQueue.shift();
497
- const { dir, path: mcpConfig } = mcpConfigFor(token, mcpUrl);
526
+ // The vault is plain files the turn needs no MCP server and no
527
+ // cartographer token; the daemon itself syncs afterwards on the fleet
528
+ // credential.
529
+ const vaultDir = vaultDirFor();
530
+ ensureVault(vaultDir);
498
531
  // Live progress for this turn: a rolling FEED of everything Claude does
499
532
  // (thinking, narration, reads, node writes), the file count, and the
500
533
  // phase — streamed to the app (throttled; each frame carries the whole
@@ -550,56 +583,115 @@ export async function runFleetDaemon() {
550
583
  } catch {
551
584
  /* detached/no HEAD — still writes the map, just ungrounded */
552
585
  }
586
+ // Sync the vault after the turn regardless of the sentinel: a died
587
+ // sweep's partial pages still persist (merge, no prune) — only a
588
+ // COMPLETED sweep finalizes, so an interrupted one can't erase pages.
589
+ const runSync = async (finalize) => {
590
+ try {
591
+ const r = await syncVault({
592
+ dir: vaultDir,
593
+ url: WIKI_VAULT_URL,
594
+ token: FLEET_TOKEN,
595
+ userAgent: USER_AGENT,
596
+ finalize,
597
+ groundedAtSha: sha || undefined,
598
+ // Powers the GitHub blob links behind every cited file path.
599
+ repoFullName: originSlug(repoRoot) || undefined,
600
+ warn,
601
+ // Redact synced secrets a page may have quoted from the repo.
602
+ scrub: envScrub,
603
+ });
604
+ if (r.skipped) note(`${c.cyan('wiki')} ${c.dim('— vault unchanged, nothing to sync')}`);
605
+ else
606
+ ok(
607
+ `${c.cyan('wiki')} ${c.dim(
608
+ `— synced ${r.uploaded} page${r.uploaded === 1 ? '' : 's'} (${r.pages} total${r.deleted ? `, ${r.deleted} removed` : ''})`
609
+ )}`
610
+ );
611
+ } catch (e) {
612
+ warn(`wiki vault sync failed: ${e.message} — pages stay local; next turn retries`);
613
+ }
614
+ };
553
615
  if (task.type === 'sweep') {
554
616
  note(`${c.cyan('wiki')} ${c.dim('— regenerating: your Claude is reading the repo…')}`);
555
617
  const out = await runTurn({
556
- prompt: WIKI_KICKOFF(sha),
618
+ prompt: WIKI_KICKOFF(sha, vaultDir),
557
619
  resume: false,
558
- system: SYSTEM_WIKI,
620
+ system: SYSTEM_WIKI(vaultDir),
559
621
  cwd: wikiWt,
560
- mcpConfig,
622
+ wikiPerm: true,
561
623
  label: c.cyan('[wiki]'),
562
624
  streamJson: true,
563
625
  onActivity,
626
+ onSpawn: (ch) => {
627
+ wikiChild = ch;
628
+ },
564
629
  });
565
- if (sawSentinel(out, 'WIKI_DONE'))
566
- ok(`${c.cyan('wiki')} ${c.dim('— regenerated from your code.')}`);
630
+ const complete = sawSentinel(out, 'WIKI_DONE');
631
+ if (complete) ok(`${c.cyan('wiki')} ${c.dim('— vault regenerated from your code.')}`);
567
632
  else
568
- warn('code-wiki regeneration ended without WIKI_DONE — retry from the app if incomplete.');
633
+ warn('wiki sweep ended without WIKI_DONE — partial pages synced; retry from the app.');
634
+ await runSync(complete);
569
635
  } else {
570
636
  const files = changedFilesForPr(task.prUrl);
571
- if (files.length === 0) {
637
+ if (files === null) {
638
+ // gh failed (network/auth) — retry via the durable job a couple
639
+ // of times before consuming it, so a transient outage doesn't
640
+ // silently drop the re-ground.
641
+ const n = (regroundAttempts.get(task.intentId) ?? 0) + 1;
642
+ regroundAttempts.set(task.intentId, n);
643
+ if (n < 3) {
644
+ warn(`wiki re-ground for "${task.title}": gh failed — will retry (${n}/3)`);
645
+ groundedIntents.delete(task.intentId); // let the roster re-offer it
646
+ continue;
647
+ }
648
+ warn(`wiki re-ground for "${task.title}": gh failed ${n} times — giving up (heals on the next full sweep)`);
649
+ } else if (files.length === 0) {
572
650
  note(`${c.cyan('wiki')} ${c.dim(`— "${task.title}": no changed files to re-ground`)}`);
573
651
  } else {
574
652
  note(`${c.cyan('wiki')} ${c.dim(`— re-grounding after "${task.title}"…`)}`);
575
653
  const out = await runTurn({
576
- prompt: REGROUND_KICKOFF({ sha, title: task.title, files }),
654
+ prompt: REGROUND_KICKOFF({ sha, title: task.title, files, vaultDir }),
577
655
  resume: false,
578
- system: SYSTEM_REGROUND,
656
+ system: SYSTEM_REGROUND(vaultDir),
579
657
  cwd: wikiWt,
580
- mcpConfig,
658
+ wikiPerm: true,
581
659
  label: c.cyan('[wiki]'),
582
660
  streamJson: true,
583
661
  onActivity,
662
+ onSpawn: (ch) => {
663
+ wikiChild = ch;
664
+ },
584
665
  });
585
666
  if (sawSentinel(out, 'REGROUND_DONE'))
586
- ok(`${c.cyan('wiki')} ${c.dim(`— wiki updated for "${task.title}".`)}`);
667
+ ok(`${c.cyan('wiki')} ${c.dim(`— vault updated for "${task.title}".`)}`);
587
668
  else warn(`wiki re-ground for "${task.title}" ended without REGROUND_DONE.`);
669
+ await runSync(false);
588
670
  }
589
- // Consume the durable job: attempted = done (success or not — emits
590
- // are idempotent and a failed turn heals on the next full sweep), so
591
- // a failing re-ground can't loop-burn quota. Only a crash BEFORE
592
- // this line leaves the job listed for a retry after restart.
671
+ // Consume the durable job: attempted = done (success or not — the
672
+ // sync is idempotent and a failed turn heals on the next full
673
+ // sweep), so a failing re-ground can't loop-burn quota. Only a
674
+ // crash BEFORE this line leaves the job listed for a retry.
675
+ regroundAttempts.delete(task.intentId);
593
676
  await reportMergeOutcome(REGROUND_DONE_URL, { intentId: task.intentId });
594
677
  }
595
678
  } catch (e) {
596
679
  warn(`wiki ${task.type} failed: ${e.message}`);
597
680
  } finally {
681
+ wikiChild = null;
598
682
  if (heartbeat) clearInterval(heartbeat);
599
683
  // Terminal frame so the app cover clears promptly (don't wait for the
600
684
  // freshness window to lapse). force-sent past the throttle.
601
685
  await postWikiProgress(frame({ done: true }), true);
602
- rmSync(dir, { recursive: true, force: true });
686
+ // Safety net: the wiki turn is read-only on the repo by CONTRACT, but
687
+ // permission enforcement is a curated tool list, not a path jail —
688
+ // discard anything a confused turn wrote to the worktree so it can
689
+ // never leak into a later turn or a push.
690
+ try {
691
+ resetWorktree(wikiWt, baseRef);
692
+ } catch {
693
+ /* best-effort */
694
+ }
603
695
  }
604
696
  }
605
697
  } finally {
@@ -684,12 +776,17 @@ export async function runFleetDaemon() {
684
776
  }
685
777
  }
686
778
  if (roster.mcpUrl) mcpUrl = roster.mcpUrl;
779
+ if (roster.project?.id) wikiProjectId = roster.project.id; // keys the vault dir
687
780
  if (roster.leaseTtlSeconds) leaseTtlSeconds = roster.leaseTtlSeconds;
688
781
  // Keep the daemon current. Safe = no worker mid-task (true at startup, since
689
782
  // no workers are spawned yet). If it self-updates it re-execs into the new
690
783
  // version and this process becomes a proxy — stop the loop.
691
784
  if (roster.daemon) {
692
- const safeToUpdate = [...workers.values()].every((w) => w.state.child == null);
785
+ // "No worker mid-task" must include the wiki runner: updating mid-sweep
786
+ // re-execs the daemon, orphans the wiki Claude, and the fresh process
787
+ // starts a second sweep racing it on the same vault.
788
+ const safeToUpdate =
789
+ !wikiBusy && [...workers.values()].every((w) => w.state.child == null);
693
790
  const updating = handleVersionSignal({
694
791
  latest: roster.daemon.latest,
695
792
  min: roster.daemon.min,
@@ -743,6 +840,11 @@ export async function runFleetDaemon() {
743
840
  fail(`could not create worktree for "${a.name}": ${e.message}`);
744
841
  continue;
745
842
  }
843
+ try {
844
+ materializeInto(wt); // synced env into the fresh worktree
845
+ } catch {
846
+ /* best-effort */
847
+ }
746
848
  const colorFn = LABEL_COLORS[joinCount++ % LABEL_COLORS.length];
747
849
  const label = colorFn(`[${a.name}]`);
748
850
  const state = { alive: true, child: null };
@@ -788,6 +890,21 @@ export async function runFleetDaemon() {
788
890
  for (const j of roster.regroundJobs ?? []) enqueueReground(j.intentId, j.prUrl, j.title);
789
891
  void drainWiki();
790
892
 
893
+ // Env sync tick: register/bootstrap/wrap/rotate/sync as the roster block
894
+ // dictates (self-guarded — one operation at a time, errors retry next
895
+ // poll). A fresh bundle rematerializes every AGENT worktree; the wiki
896
+ // worktree NEVER gets env (the cartographer doesn't need secrets).
897
+ void handleRosterEnv(roster.env, { projectId: roster.project?.id }).then(({ changed }) => {
898
+ if (!changed) return;
899
+ for (const [, w] of workers) {
900
+ try {
901
+ materializeInto(w.wt);
902
+ } catch {
903
+ /* best-effort */
904
+ }
905
+ }
906
+ });
907
+
791
908
  // Stop workers whose agent left the roster (removed in the app).
792
909
  for (const [id, w] of [...workers]) {
793
910
  if (!rosterIds.has(id)) {
package/bin/lib/live.mjs CHANGED
@@ -34,6 +34,7 @@ import { c, info, ok, warn } from './ui.mjs';
34
34
  import { sleep } from './claude.mjs';
35
35
  import { git, resetWorktree, isValidBranch } from './git.mjs';
36
36
  import { loadPreviewConfig, startPreview } from './preview.mjs';
37
+ import { materializeInto, scrub as envScrub } from './env.mjs';
37
38
 
38
39
  // Register a branch preview's tunnel URL with Flowviant (fleet-authed). The
39
40
  // reviewer then drives it via "Open live preview" in the node.
@@ -92,7 +93,9 @@ function postPreviewNote(intentId, text) {
92
93
  'Content-Type': 'application/json',
93
94
  },
94
95
  signal: AbortSignal.timeout(10_000),
95
- body: JSON.stringify({ intentId, text }),
96
+ // Scrub: preview failure reasons can quote dev-server output, which can
97
+ // echo env values.
98
+ body: JSON.stringify({ intentId, text: envScrub(text) }),
96
99
  }).catch(() => {});
97
100
  }
98
101
 
@@ -143,7 +146,12 @@ self-report becomes your DELIVERY CARD in the task thread — it's what the team
143
146
  reads to confirm done, so write it for them, not for a log. A live preview of
144
147
  your branch is started for you automatically — you do NOT need to open a tunnel
145
148
  or register a live target. NEVER merge — a human confirms done in the thread
146
- (the merge card) and the merge runs separately.`;
149
+ (the merge card) and the merge runs separately.
150
+ SECRETS: env files (.env, .dev.vars, …) in your worktree hold the team's synced
151
+ secrets. Their VALUES must NEVER appear in evidence, progress reports, blocker
152
+ questions, delivery summaries, commits, or PRs — reference keys by NAME only
153
+ (e.g. "set STRIPE_KEY"). Never screenshot a terminal or page that displays a
154
+ credential, and never commit an env file.`;
147
155
 
148
156
  function seedPrompt(runId, brief, transcript, resumedInPlace) {
149
157
  return [
@@ -383,6 +391,7 @@ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive, resume
383
391
  } else if (!resuming && !resumedInPlace) {
384
392
  resetWorktree(cwd, baseRef);
385
393
  }
394
+ materializeInto(cwd); // resets wipe the synced env files — rewrite them
386
395
  writeTaskMarker(cwd, intentId);
387
396
 
388
397
  if (resumedInPlace) {
@@ -480,7 +489,9 @@ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive, resume
480
489
  await mcpCall(mcpUrl, token, 'stream_turn', {
481
490
  runId,
482
491
  turnId,
483
- text: turnText.trim(),
492
+ // Uplink scrub: the model's narration can quote file contents, and a
493
+ // file can contain a synced secret — redact before it leaves the box.
494
+ text: envScrub(turnText.trim()),
484
495
  createdAt: turnAt,
485
496
  }).catch(() => {});
486
497
  }
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Knowledge-vault plumbing: the local Obsidian-style wiki directory Claude
3
+ * writes (plain markdown + [[wikilinks]]) and the hash-diff sync that ships it
4
+ * to Flowviant. The vault lives OUTSIDE the repo and its worktrees
5
+ * (~/.flowviant/vaults/<projectId>) so it persists across sweeps, and gets a
6
+ * private `git init` so every pass is versioned locally for free — the user's
7
+ * repository is never touched.
8
+ *
9
+ * Sync protocol (POST /api/v2/fleet/wiki-vault, fleet-token auth): only files
10
+ * whose sha256 changed since the last successful sync are uploaded, chunked;
11
+ * the LAST request carries the finalize.manifest of a completed full sweep so
12
+ * the server prunes pages the sweep no longer has. The last-synced hashes live
13
+ * in `.flowviant-sync.json` inside the vault (dotfile — never walked, never
14
+ * uploaded).
15
+ *
16
+ * HARD RULE — deletion is opt-in, never inferred: a page we can't read, can't
17
+ * upload (oversized / invalid path), or truncated past the cap is CARRIED
18
+ * FORWARD at its last-synced state, not turned into a deletion. Only a page
19
+ * that verifiably vanished from a readable vault becomes a delete. Otherwise
20
+ * an append-only log.md crossing the size cap would silently erase itself
21
+ * server-side.
22
+ */
23
+
24
+ import {
25
+ readdirSync,
26
+ readFileSync,
27
+ writeFileSync,
28
+ mkdirSync,
29
+ existsSync,
30
+ } from 'node:fs';
31
+ import { createHash } from 'node:crypto';
32
+ import { execFileSync } from 'node:child_process';
33
+ import { join, relative, sep } from 'node:path';
34
+
35
+ const SYNC_STATE = '.flowviant-sync.json';
36
+ // Mirror the server contract (shared schema) — a page that violates it is
37
+ // skipped with a warning (and carried forward if previously synced), never
38
+ // allowed to 400 the whole request and wedge the sync.
39
+ const MAX_FILE_BYTES = 262_144;
40
+ const MAX_PATH_CHARS = 300;
41
+ const MAX_FILES = 400;
42
+ const CHUNK_FILES = 30;
43
+ const CHUNK_BYTES = 700_000;
44
+ const MAX_DELETIONS_PER_REQ = 200;
45
+
46
+ /** Daemon-side mirror of the server's isSafeVaultPath. */
47
+ const isSafePath = (p) =>
48
+ p.length > 0 &&
49
+ p.length <= MAX_PATH_CHARS &&
50
+ p.endsWith('.md') &&
51
+ !p.includes('\\') &&
52
+ !p.includes('\0') &&
53
+ !p.startsWith('/') &&
54
+ p.split('/').every((seg) => seg.length > 0 && seg !== '.' && seg !== '..' && !seg.startsWith('.'));
55
+
56
+ /** Create the vault dir + its private git history (best-effort). */
57
+ export function ensureVault(dir) {
58
+ mkdirSync(dir, { recursive: true });
59
+ if (!existsSync(join(dir, '.git'))) {
60
+ try {
61
+ execFileSync('git', ['init', '-q'], { cwd: dir, stdio: 'ignore' });
62
+ } catch {
63
+ /* git unavailable — the vault still works, just unversioned */
64
+ }
65
+ }
66
+ }
67
+
68
+ /** All vault-relative .md paths (forward slashes), dotfiles/dirs skipped.
69
+ * Splits on the PLATFORM separator only — a literal backslash in a Linux
70
+ * filename must not be mangled into a bogus subpath. A failed directory read
71
+ * bumps `errors.count` — the caller MUST treat the walk as partial then
72
+ * (pages under an unreadable subtree are absent, not deleted). */
73
+ function walkMd(dir, base = dir, out = [], errors = { count: 0 }) {
74
+ let entries;
75
+ try {
76
+ entries = readdirSync(dir, { withFileTypes: true });
77
+ } catch {
78
+ errors.count++;
79
+ return out;
80
+ }
81
+ for (const e of entries) {
82
+ if (e.name.startsWith('.')) continue;
83
+ const p = join(dir, e.name);
84
+ if (e.isDirectory()) walkMd(p, base, out, errors);
85
+ else if (e.isFile() && e.name.endsWith('.md'))
86
+ out.push(relative(base, p).split(sep).join('/'));
87
+ }
88
+ return out;
89
+ }
90
+
91
+ /** Best-effort local history commit — identity pinned so it works on machines
92
+ * with no global git config, and never touches the user's identity. */
93
+ function commitVault(dir, message) {
94
+ try {
95
+ execFileSync('git', ['add', '-A'], { cwd: dir, stdio: 'ignore' });
96
+ execFileSync(
97
+ 'git',
98
+ ['-c', 'user.name=flowviant', '-c', 'user.email=wiki@flowviant.local', 'commit', '-q', '-m', message],
99
+ { cwd: dir, stdio: 'ignore' }
100
+ );
101
+ } catch {
102
+ /* nothing to commit / git unavailable — fine */
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Hash-diff sync the vault to the server. Returns counts; throws on a failed
108
+ * upload (the sync state is only advanced after EVERY request lands, so a
109
+ * partial failure re-uploads next time — server upserts are idempotent).
110
+ */
111
+ export async function syncVault({ dir, url, token, userAgent, finalize, groundedAtSha, repoFullName, warn = () => {}, scrub = (t) => t }) {
112
+ const walkErrors = { count: 0 };
113
+ const found = walkMd(dir, dir, [], walkErrors).sort();
114
+ if (walkErrors.count > 0 && found.length === 0) {
115
+ // Vault root (or everything under it) unreadable — nothing to diff against.
116
+ warn(`vault at ${dir} is unreadable — skipping sync; check the vault dir`);
117
+ return { pages: 0, uploaded: 0, deleted: 0, skipped: true };
118
+ }
119
+
120
+ // Partition into uploadable pages and carried-forward ones. Carried = we
121
+ // know the page exists (or existed) but can't ship this state — keep the
122
+ // server's last-good copy: tracked in `current` (prev hash) + manifest,
123
+ // never a deletion.
124
+ const current = {}; // path -> sha256 tracked as the post-sync state
125
+ const contents = {}; // path -> markdown to upload (subset of current)
126
+ let prev = {};
127
+ try {
128
+ prev = JSON.parse(readFileSync(join(dir, SYNC_STATE), 'utf8'));
129
+ } catch {
130
+ /* first sync */
131
+ }
132
+ const carry = (p, why) => {
133
+ if (prev[p]) {
134
+ current[p] = prev[p];
135
+ warn(`vault page ${p}: ${why} — keeping the last synced copy`);
136
+ } else {
137
+ warn(`vault page ${p}: ${why} — not synced`);
138
+ }
139
+ };
140
+
141
+ let kept = 0;
142
+ for (const p of found) {
143
+ if (!isSafePath(p)) {
144
+ carry(p, 'name violates the sync contract (length/characters)');
145
+ continue;
146
+ }
147
+ if (kept >= MAX_FILES) {
148
+ carry(p, `vault exceeds ${MAX_FILES} pages`);
149
+ continue;
150
+ }
151
+ let text;
152
+ try {
153
+ text = readFileSync(join(dir, p), 'utf8');
154
+ } catch {
155
+ carry(p, 'unreadable');
156
+ continue;
157
+ }
158
+ // Uplink scrub: the cartographer quotes real repo files, and a repo file
159
+ // can contain a synced secret — redact known values before upload. The
160
+ // hash is computed on the SCRUBBED text so the diff state stays coherent.
161
+ text = scrub(text);
162
+ if (Buffer.byteLength(text) > MAX_FILE_BYTES) {
163
+ carry(p, 'exceeds 256KB');
164
+ continue;
165
+ }
166
+ kept++;
167
+ contents[p] = text;
168
+ current[p] = createHash('sha256').update(text).digest('hex');
169
+ }
170
+
171
+ // Partial walk (an unreadable SUBdirectory): every previously-synced page the
172
+ // walk failed to reach must be carried forward, not inferred deleted — a
173
+ // transient EMFILE/EACCES on e.g. docs/ must never erase those pages
174
+ // server-side. The hard rule: deletion is opt-in, never inferred.
175
+ if (walkErrors.count > 0) {
176
+ warn(
177
+ `vault walk hit ${walkErrors.count} unreadable director${walkErrors.count === 1 ? 'y' : 'ies'} — carrying missing pages forward, no deletions this pass`
178
+ );
179
+ for (const p of Object.keys(prev)) {
180
+ if (!(p in current)) current[p] = prev[p];
181
+ }
182
+ }
183
+
184
+ // A readable vault that suddenly presents ZERO pages while the server holds
185
+ // many is almost always a broken/moved dir, not an intentional wipe — refuse
186
+ // to mass-delete. (An intentional reset is a fresh Regenerate: the sweep
187
+ // rewrites pages, then finalize prunes precisely.)
188
+ const prevCount = Object.keys(prev).length;
189
+ if (Object.keys(current).length === 0 && prevCount > 0) {
190
+ warn(`vault at ${dir} presents 0 pages but ${prevCount} were synced — refusing to delete; check the vault dir`);
191
+ return { pages: 0, uploaded: 0, deleted: 0, skipped: true };
192
+ }
193
+
194
+ const changed = Object.keys(current).filter((p) => p in contents && prev[p] !== current[p]);
195
+ const deletions = Object.keys(prev).filter((p) => !(p in current));
196
+ const pages = Object.keys(current).length;
197
+ if (changed.length === 0 && deletions.length === 0 && !finalize) {
198
+ return { pages, uploaded: 0, deleted: 0, skipped: true };
199
+ }
200
+
201
+ // Finalize manifests are schema-capped server-side at MAX_FILES; carried
202
+ // pages can push the tracked set past it. Downgrade to a plain merge (no
203
+ // prune) rather than wedge the whole sync on a 400 — nothing is lost, the
204
+ // regen request stays pending, and the warning names the cause.
205
+ let doFinalize = !!finalize;
206
+ if (doFinalize && pages > MAX_FILES) {
207
+ warn(`vault tracks ${pages} pages (> ${MAX_FILES}) — skipping the finalize prune this pass`);
208
+ doFinalize = false;
209
+ }
210
+
211
+ // Build the request series: file chunks (count+byte capped), then however
212
+ // many deletion batches the 200-cap needs. finalize/sha ride the LAST
213
+ // request only, so the server prunes exactly once, after every upsert landed.
214
+ const fileChunks = [];
215
+ let cur = [];
216
+ let bytes = 0;
217
+ for (const p of changed) {
218
+ const size = Buffer.byteLength(contents[p]);
219
+ if (cur.length && (cur.length >= CHUNK_FILES || bytes + size > CHUNK_BYTES)) {
220
+ fileChunks.push(cur);
221
+ cur = [];
222
+ bytes = 0;
223
+ }
224
+ cur.push(p);
225
+ bytes += size;
226
+ }
227
+ if (cur.length) fileChunks.push(cur);
228
+
229
+ const requests = fileChunks.map((paths) => ({ files: paths.map((p) => ({ path: p, content: contents[p] })), deletions: [] }));
230
+ for (let i = 0; i < deletions.length; i += MAX_DELETIONS_PER_REQ) {
231
+ requests.push({ files: [], deletions: deletions.slice(i, i + MAX_DELETIONS_PER_REQ) });
232
+ }
233
+ if (requests.length === 0) requests.push({ files: [], deletions: [] }); // finalize-only
234
+
235
+ for (let i = 0; i < requests.length; i++) {
236
+ const last = i === requests.length - 1;
237
+ const res = await fetch(url, {
238
+ method: 'POST',
239
+ headers: {
240
+ Authorization: `Bearer ${token}`,
241
+ 'User-Agent': userAgent,
242
+ 'Content-Type': 'application/json',
243
+ },
244
+ signal: AbortSignal.timeout(60_000),
245
+ body: JSON.stringify({
246
+ ...requests[i],
247
+ ...(last && doFinalize ? { finalize: { manifest: Object.keys(current) } } : {}),
248
+ ...(last && groundedAtSha ? { groundedAtSha } : {}),
249
+ ...(last && repoFullName ? { repoFullName } : {}),
250
+ }),
251
+ });
252
+ if (!res.ok) throw new Error(`wiki-vault sync failed (${res.status})`);
253
+ }
254
+
255
+ writeFileSync(join(dir, SYNC_STATE), JSON.stringify(current));
256
+ commitVault(dir, doFinalize ? `sweep${groundedAtSha ? ` @ ${groundedAtSha.slice(0, 7)}` : ''}` : `update${groundedAtSha ? ` @ ${groundedAtSha.slice(0, 7)}` : ''}`);
257
+ return { pages, uploaded: changed.length, deleted: deletions.length };
258
+ }