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/cli.mjs +9 -0
- package/bin/lib/claude.mjs +159 -73
- package/bin/lib/config.mjs +1 -1
- package/bin/lib/env-cli.mjs +212 -0
- package/bin/lib/env.mjs +504 -0
- package/bin/lib/fleet.mjs +167 -50
- package/bin/lib/live.mjs +14 -3
- package/bin/lib/vault.mjs +258 -0
- package/package.json +2 -1
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
|
|
402
|
-
// agents' checkouts)
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
//
|
|
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
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
566
|
-
|
|
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('
|
|
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
|
|
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
|
-
|
|
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(`—
|
|
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 —
|
|
590
|
-
//
|
|
591
|
-
// a failing re-ground can't loop-burn quota. Only a
|
|
592
|
-
// this line leaves the job listed for a retry
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|