flowviant 0.91.2 → 0.92.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
@@ -66,14 +66,8 @@ import { acquireInstanceLock } from './instance.mjs';
66
66
  import { preflight } from './preflight.mjs';
67
67
  import { connectStream } from './stream.mjs';
68
68
  import { ensureVault, syncVault } from './vault.mjs';
69
- import {
70
- envQueryParams,
71
- handleRosterEnv,
72
- loadCachedEnv,
73
- materializeInto,
74
- myPubB64,
75
- scrub as envScrub,
76
- } from './env.mjs';
69
+ import { envQueryParams, myPubB64, scanEnvForScrub, scrub as envScrub } from './env.mjs';
70
+ import { sweepVaultArtefactsOnce } from './vaultArtefacts.mjs';
77
71
  import { processDeployJobs, reportDeployConfig } from './deploy.mjs';
78
72
  import { machineSnapshot } from './resources.mjs';
79
73
  import {
@@ -287,15 +281,20 @@ async function fetchRoster(
287
281
  } catch {
288
282
  /* best-effort — the poll must never fail on a readout */
289
283
  }
290
- // Env-sync identity + materialized version (the Settings "env vN" chip).
284
+ // THIS BOX'S IDENTITY — `envpub`, and since 2026-09-21 nothing else.
285
+ //
286
+ // It carried two more params while the secrets vault existed: `envv` (the
287
+ // materialized bundle version) and `envskip` (the target files the
288
+ // materializer refused to write, where the EMPTY STRING was a real report and
289
+ // a truthiness filter here silently dropped it). Both died with the vault and
290
+ // the server no longer reads either, so the loop's `!= null` has nothing left
291
+ // to defend — it stays anyway, because it is the correct general shape for
292
+ // forwarding a param map and re-deriving that lesson is how it was lost the
293
+ // first time.
291
294
  //
292
- // `!= null`, NOT truthiness. `envskip` uses the EMPTY STRING as a real
293
- // report — "measured, refused nothing" — and it is the only value that can
294
- // clear the surface's warning. A `if (v)` here silently dropped it, so
295
- // fixing the filter in envQueryParams alone would have changed nothing.
296
- // This is the same trap the skills relay eight lines up documents and
297
- // sidesteps by calling `url.searchParams.set` directly; the general fix is
298
- // better than a second special case.
295
+ // `envpub` ITSELF IS UNCHANGED: same key, same base64, same keypair file. A
296
+ // box upgrading into this release must stay the SAME box to holdership
297
+ // arbitration and to the machine registry.
299
298
  try {
300
299
  for (const [k, v] of Object.entries(await envQueryParams())) {
301
300
  if (v != null) url.searchParams.set(k, v);
@@ -605,6 +604,143 @@ async function maybeReportRepoState({ repoRoot, baseRef }) {
605
604
  }
606
605
  }
607
606
 
607
+ /**
608
+ * WHAT IS IN THIS BOX'S ENVIRONMENT, BY NAME (2026-09-21) — the one readout the
609
+ * deleted secrets vault was genuinely wanted for.
610
+ *
611
+ * The owner, on the vault: *"no i dont want it. unless its needed where i want
612
+ * to show the env of each of the machines (for comparison)."* So the custody
613
+ * went and the comparison stayed, rebuilt as a plain report: for each `.env*`
614
+ * file in the CHECKOUT ROOT, the variable NAMES and an 8-hex VALUE FINGERPRINT.
615
+ * **The value never leaves the box.** Two machines holding the same fingerprint
616
+ * for `DATABASE_URL` agree; two different fingerprints is the whole answer to
617
+ * "why does it work over there", and neither requires Flowviant to hold a
618
+ * secret, seal a key, or own a recovery code.
619
+ *
620
+ * ONE POST PER BOX PER CHANGE. The report is hashed and compared against the
621
+ * last one the server ACCEPTED, so a box whose env is stable posts exactly once
622
+ * per daemon and then never again — the same economy `maybeReportRepoState`
623
+ * keeps next door, and for the same reason: an env file is not PRESENCE, so
624
+ * re-posting an unchanged list would be a write per machine per minute to say
625
+ * nothing.
626
+ *
627
+ * A PERMANENT 4xx IS TREATED AS DELIVERED, which is the `/fleet/agent-trace`
628
+ * precedent stated there: an older SERVER 404s every batch, and a daemon that
629
+ * held them would keep re-posting a body nobody will ever read. Which statuses
630
+ * count as permanent is `envReportIsPermanent`, and it is a SHORT list for the
631
+ * reason stated there; everything else forgets the dedup so the next beat tries
632
+ * again.
633
+ *
634
+ * THE TOTALS RIDE BESIDE THE CAPPED LIST (2026-09-21, the review). The report
635
+ * is bounded at eight files and two hundred variables, and a list silently cut
636
+ * at a cap reads as the whole directory — so `filesTotal` and `varsTotal` (the
637
+ * numbers BEFORE the caps) go on the wire beside it and the app can say "N more
638
+ * not shown". Same rule `repoState` already keeps for branches and worktrees.
639
+ * Both are optional on the server, so an older server parsing only `files` is
640
+ * unaffected and needs no floor.
641
+ *
642
+ * NO VERSION FLOOR, and none is possible to need: it is a daemon→server report
643
+ * on a NEW endpoint, so an older server answers 404 once and is never asked
644
+ * again, and an older daemon simply never posts. The report's presence IS the
645
+ * capability.
646
+ *
647
+ * IT ALSO FEEDS THE SCRUBBER, on every scan and whether or not anything is
648
+ * posted (`scanEnvForScrub`). That is the half that must not be skipped: with
649
+ * the vault gone these files are what `scrub()` redacts out of turn streams,
650
+ * tool events, traces and deploy logs, and the dedup above is about the WIRE,
651
+ * never about what this box knows to hide.
652
+ */
653
+ /**
654
+ * WHICH REFUSAL IS FINAL — pure, exported, and deliberately a SHORT list
655
+ * (2026-09-21, the review).
656
+ *
657
+ * The first cut treated EVERY 4xx as permanent, which quietly turned one bad
658
+ * minute into a dead lane for the life of the process. A 429 is a rate limit
659
+ * and says "later", not "never". A 401 or 403 during a credential blip — a
660
+ * rotation, a clock skew, a roster the box is momentarily not the holder of —
661
+ * is transient by construction. A 408 is a timeout wearing a 4xx. Every one of
662
+ * those stopped env reporting until somebody restarted the daemon, with NO
663
+ * sign anywhere that it had stopped: the report is deduped and silent by
664
+ * design, so "posted once and never again" is indistinguishable from "working
665
+ * normally on a box whose env has not changed". A lane that can die invisibly
666
+ * must not die for a reason that will pass.
667
+ *
668
+ * So permanent means exactly three things, and each of them is a fact about
669
+ * the REQUEST rather than about the moment: 404, the route does not exist (an
670
+ * older server — the `/fleet/agent-trace` precedent this lane is built on);
671
+ * 400 and 422, the server will never accept this shape. Retrying any of those
672
+ * forever is the daemon arguing with a decision already made.
673
+ *
674
+ * Pure and exported so the decision can be proved without a credential, a
675
+ * server or a poll — the same shape `shouldStop` and `createHolderWatch` keep
676
+ * below.
677
+ */
678
+ export function envReportIsPermanent(status) {
679
+ return status === 400 || status === 404 || status === 422;
680
+ }
681
+
682
+ const ENV_REPORT_URL = FLEET_URL.replace(/\/agents\/?$/, '/env-report');
683
+ const ENV_REPORT_SCAN_MS = 60_000;
684
+ let envReportUnsupported = false; // the server refused permanently — quiet until restart
685
+ let envReportScanAt = 0;
686
+ let envReportSent = null; // the last report the server ACCEPTED, stringified
687
+ /**
688
+ * Returns a one-word VERDICT — 'throttled' | 'scan-failed' | 'quiet' |
689
+ * 'deduped' | 'accepted' | 'retry' | 'stopped'. The daemon ignores it (the
690
+ * caller is a bare `void`); it exists so the lane's decisions can be driven
691
+ * against a real HTTP server in a test rather than pinned as source text. A
692
+ * readout whose only proof is that its source LOOKS right is the inert-pin
693
+ * class this repo has caught five times.
694
+ */
695
+ export async function maybeReportEnv(repoRoot) {
696
+ if (Date.now() - envReportScanAt < ENV_REPORT_SCAN_MS) return 'throttled';
697
+ envReportScanAt = Date.now();
698
+ let report;
699
+ try {
700
+ // The SCAN happens even when the POST cannot — see the docblock: the
701
+ // scrubber has no dedup and must reflect the newest read every time.
702
+ report = scanEnvForScrub(repoRoot);
703
+ } catch {
704
+ return 'scan-failed'; // a readout must never throw into the poll loop
705
+ }
706
+ if (envReportUnsupported) return 'quiet';
707
+ const payload = JSON.stringify({
708
+ pubkey: myPubB64(),
709
+ files: report.files,
710
+ filesTotal: report.filesTotal,
711
+ varsTotal: report.varsTotal,
712
+ });
713
+ if (payload === envReportSent) return 'deduped';
714
+ try {
715
+ const res = await fetch(ENV_REPORT_URL, {
716
+ method: 'POST',
717
+ headers: {
718
+ Authorization: `Bearer ${FLEET_TOKEN}`,
719
+ 'User-Agent': USER_AGENT,
720
+ 'Content-Type': 'application/json',
721
+ },
722
+ signal: AbortSignal.timeout(15_000),
723
+ body: payload,
724
+ });
725
+ // Only a refusal that cannot change stops the lane — see
726
+ // `envReportIsPermanent`. A 429 or a 401 during a credential blip is a
727
+ // "later", and treating it as a "never" killed env reporting for the life
728
+ // of the process with nothing anywhere saying so.
729
+ if (envReportIsPermanent(res.status)) {
730
+ envReportUnsupported = true;
731
+ return 'stopped';
732
+ }
733
+ // Only an ACCEPTED report counts, the rule `maybeReportRepoState` keeps:
734
+ // anything else forgets it so the next pass retries rather than
735
+ // dedup-suppressing a report nobody received.
736
+ envReportSent = res.ok ? payload : null;
737
+ return res.ok ? 'accepted' : 'retry';
738
+ } catch {
739
+ envReportSent = null;
740
+ return 'retry';
741
+ }
742
+ }
743
+
608
744
  /**
609
745
  * A STOP COMMANDED BY FLOWVIANT, read off the roster poll.
610
746
  *
@@ -686,12 +822,20 @@ export function agoLabel(ms) {
686
822
  * · ABSENT -> this server does not arbitrate machines. Behave exactly
687
823
  * as every daemon before 0.84.0 did — zero new paths.
688
824
  * · mine: true -> we are the machine. Announce it only if we have been
689
- * standing by, so an ordinary daemon prints nothing new.
690
- * · mine: false -> STAND BY. Keep polling quietly; the restricted roster
825
+ * inactive, so an ordinary daemon prints nothing new.
826
+ * · mine: false -> GO INACTIVE. Keep polling quietly; the restricted roster
691
827
  * serves us nothing, the auto-handover makes this box the
692
828
  * machine when the holder dies, and the app is where a
693
- * person moves it sooner. Never exit: a standby that quits
694
- * is a box somebody has to go and restart by hand.
829
+ * person moves it sooner. Never exit: an inactive box that
830
+ * quits is one somebody has to go and restart by hand.
831
+ *
832
+ * THE WORD A PERSON READS IS `inactive` (2026-09-21) — the owner replaced
833
+ * "standing by" outright: *"no, it can [be] inactive instead"*. The server's
834
+ * role enum says `inactive`, `flowviant machines` prints it, and so do the two
835
+ * sentences below. The STATE NAME returned here is still `'standby'`, and that
836
+ * is deliberate: it names the server's arbitration arm, it is what every gate
837
+ * in this file branches on, and renaming an identifier to match a copy change
838
+ * is how a rename becomes a behaviour change nobody reviewed.
695
839
  *
696
840
  * Printed ONCE PER DISTINCT HOLDER rather than per poll — a true sentence
697
841
  * restated every ten seconds is a scrolling console nobody reads, and the fact
@@ -790,9 +934,23 @@ export function createHolderWatch({ say = () => {} } = {}) {
790
934
  // would promise a handover on the one path where it is least likely to
791
935
  // be what happens. So the line relays the fact and points at the door,
792
936
  // which is the same door it always pointed at.
937
+ //
938
+ // THE WORD IS `inactive` (2026-09-21). It was "standing by" until the
939
+ // owner replaced it — asked whether a box should keep saying that, he
940
+ // answered *"no, it can [be] inactive instead"* — and the server's role
941
+ // enum moved in the same pass (`serving` / `inactive`, which is why
942
+ // `machines.mjs` no longer respells anything). A terminal saying one
943
+ // word about this box while the app says another about the same box is
944
+ // the confusion these readouts exist to end, so the two ends say the
945
+ // same thing.
946
+ //
947
+ // THE POINTER SURVIVES THE REWORD. The sentence still ends at the app's
948
+ // project settings rather than at "another machine is serving this
949
+ // project", which the line's first half has already said — restating it
950
+ // would spend the only clause the person can act on repeating a fact.
793
951
  say(
794
952
  `This project's machine is ${name ?? 'another machine'}${ago ? ` (heard ${ago} ago)` : ''}. ` +
795
- "This one is standing by — move it here from the app's project settings."
953
+ "This one is inactive — move it here from the app's project settings."
796
954
  );
797
955
  }
798
956
  return 'standby';
@@ -808,6 +966,25 @@ export function displacedTurnSentence(by) {
808
966
  return `The project's machine moved to ${name} while this turn was running.`;
809
967
  }
810
968
 
969
+ /**
970
+ * …AND THE SENTENCE FOR THE OTHER WAY A BOX STOPS BEING THIS PROJECT'S
971
+ * MACHINE (2026-09-21): it was REMOVED in the app.
972
+ *
973
+ * Kept apart from the displaced one rather than generalised into "the machine
974
+ * is no longer this box", because the two are different facts and the person
975
+ * reading a stuck turn needs the one that happened. A move names where the work
976
+ * went and implies somebody will pick it up there; a removal names nothing,
977
+ * because there is nowhere for it to have gone. Guessing between them — or
978
+ * blurring them into one sentence that is true of both — is the product
979
+ * inventing a state.
980
+ *
981
+ * It takes NO name: there is no box that took over. The PROJECT is named on the
982
+ * console line instead, where it answers "removed from what".
983
+ */
984
+ export function removedTurnSentence() {
985
+ return 'This machine was removed from the project in the app while this turn was running.';
986
+ }
987
+
811
988
  /**
812
989
  * THE MACHINE MOVED. STAND DOWN.
813
990
  *
@@ -833,6 +1010,8 @@ export function displacedTurnSentence(by) {
833
1010
  */
834
1011
  export async function standDownDisplaced({
835
1012
  by,
1013
+ kind = 'moved',
1014
+ project,
836
1015
  settleAgentTurns,
837
1016
  flushReports,
838
1017
  teardown,
@@ -850,16 +1029,38 @@ export async function standDownDisplaced({
850
1029
  }),
851
1030
  ]);
852
1031
  const name = typeof by === 'string' && by.trim() ? by.trim().slice(0, 64) : null;
1032
+ /**
1033
+ * TWO KINDS, ONE CHOREOGRAPHY (2026-09-21).
1034
+ *
1035
+ * `moved` is the original: another box took the machine, and it is the
1036
+ * DEFAULT so the displaced call site reads exactly as it always did.
1037
+ * `removed` is the project saying this box is not its machine at all any
1038
+ * more — somebody pressed a button in the app — and the only thing that
1039
+ * differs is the WORDS. Everything below this point is the same, and it must
1040
+ * be: settle the turns first, flush the reports, tear down the detached
1041
+ * children, keep the worktrees, exit 0.
1042
+ *
1043
+ * The sentences are picked HERE rather than passed in, so the two cannot
1044
+ * drift and neither call site can invent a third.
1045
+ */
1046
+ const removed = kind === 'removed';
1047
+ const projectName =
1048
+ typeof project === 'string' && project.trim() ? project.trim().slice(0, 64) : 'this project';
853
1049
  log.warn(
854
- name
855
- ? `this project's machine moved to ${name} — standing down.`
856
- : "this project's machine moved to another box — standing down."
1050
+ removed
1051
+ ? `removed from ${projectName} in the app — stopping.`
1052
+ : name
1053
+ ? `this project's machine moved to ${name} — standing down.`
1054
+ : "this project's machine moved to another box — standing down."
857
1055
  );
858
1056
  // FIRST, and awaited: an unsettled turn is the one thing here that no later
859
1057
  // poll from anybody can fix — this process holds the only copy of the fact
860
1058
  // that it was running.
861
1059
  try {
862
- await bounded(settleAgentTurns(displacedTurnSentence(by)), 10);
1060
+ await bounded(
1061
+ settleAgentTurns(removed ? removedTurnSentence() : displacedTurnSentence(by)),
1062
+ 10
1063
+ );
863
1064
  } catch {
864
1065
  /* an unsettled turn expires server-side with words of its own */
865
1066
  }
@@ -979,36 +1180,26 @@ export async function runFleetDaemon() {
979
1180
 
980
1181
  await preflight({ needGit: true });
981
1182
 
982
- // WARM THE ENV CACHE BEFORE THE FIRST POLL, not on the first roster tick.
983
- // `handleRosterEnv` loads it, and `handleRosterEnv` runs AFTER
984
- // `processWorkTurns` in the reconcile below — so on the first poll after a
985
- // restart a brand-new session worktree was materialized against an EMPTY
986
- // bundle, and then never revisited (creation-only, and `needSync` is false
987
- // when the cache holds the version the server is already on). The turn ran
988
- // with no secrets and nothing said so.
1183
+ // FEED THE SCRUBBER BEFORE ANYTHING CAN POST, not on the first roster tick.
989
1184
  //
990
- // This is only possible since 0.55.0: the credential store knows which
991
- // project this checkout is, so the cache — which is keyed by projectId — can
992
- // be found before the server has named anything. A `--fleet`/env token names
993
- // no project until the roster does, so it keeps the old lazy path.
994
- // Best-effort throughout: a cache miss is the ordinary first-run state.
1185
+ // What stood here warmed the VAULT's encrypted cache off the stored
1186
+ // credential's projectId, so a worktree created on the first poll after a
1187
+ // restart was not materialized against an empty bundle. The vault is deleted;
1188
+ // the ordering lesson survives and applies to the one thing that replaced it.
1189
+ // `scrub()` redacts the checkout's own `.env*` values out of everything this
1190
+ // daemon posts, and a redactor that has not read yet redacts nothing — so the
1191
+ // first read happens here, before the first poll, rather than on the 60s beat
1192
+ // that keeps it current. A daemon that starts, immediately answers a turn and
1193
+ // posts its stream must already know what to hide.
995
1194
  //
996
- // GATED ON THE STORE ACTUALLY BEING THE SOURCE. `--fleet` / `FLOWVIANT_FLEET`
997
- // OVERRIDE the stored credential (config.mjs), but `CREDENTIAL` is resolved
998
- // from the store regardless — so reading its projectId here would decrypt and
999
- // materialize project A's cached secrets while this daemon is serving project
1000
- // B's token. That is the wrong project's plaintext in a worktree, which is
1001
- // the exact failure the repo binding exists to prevent, arriving by a
1002
- // different door. An external token names no project until the roster does,
1003
- // so it keeps the lazy path and loses nothing but one poll.
1004
- const externalToken =
1005
- process.argv.includes('--fleet') || Boolean(process.env.FLOWVIANT_FLEET);
1006
- if (!externalToken && CREDENTIAL?.entry?.projectId) {
1007
- try {
1008
- await loadCachedEnv(CREDENTIAL.entry.projectId);
1009
- } catch {
1010
- /* no cache, no keypair yet, unreadable home — the roster tick retries */
1011
- }
1195
+ // Best-effort and unconditional: it reads files in a directory this process
1196
+ // is already standing in, needs no credential and names no project, so the
1197
+ // whole `--fleet`-overrides-the-store hazard the old warm had to reason
1198
+ // about does not exist here.
1199
+ try {
1200
+ scanEnvForScrub(repoRoot);
1201
+ } catch {
1202
+ /* an unreadable checkout redacts nothing — the 60s beat retries */
1012
1203
  }
1013
1204
 
1014
1205
  // Kill any preview dev-server/tunnel groups a previously-crashed daemon left
@@ -1021,6 +1212,17 @@ export async function runFleetDaemon() {
1021
1212
  const baseDir = join(homedir(), '.flowviant', 'worktrees', repoKey);
1022
1213
  mkdirSync(baseDir, { recursive: true });
1023
1214
 
1215
+ // WHAT THE DELETED VAULT LEFT ON THIS DISK — once, here, because this is the
1216
+ // first point at which both directories it wrote into are known. Deleting the
1217
+ // code that writes a file does not delete the file: the encrypted
1218
+ // `~/.flowviant/env-cache` and the PLAINTEXT `.env` files `materializeInto`
1219
+ // put in every worktree survive the upgrade on every box that ever ran a
1220
+ // daemon before this release. Only files carrying the vault's own header are
1221
+ // removed — see vaultArtefacts.mjs for why "no marker, no delete" is
1222
+ // absolute, and for the stated bound on the walk. Never throws, and silent
1223
+ // unless it actually removed something.
1224
+ sweepVaultArtefactsOnce({ roots: [repoRoot, baseDir], log: (m) => info(m) });
1225
+
1024
1226
  // ONE CHECKOUT PER TASK, named after the task. Worktrees used to be
1025
1227
  // `agent-<agentId>` — a long-lived tree per lane, reset to base between
1026
1228
  // tasks — and that was the last thing a lane owned. Now a lane is a
@@ -2229,6 +2431,45 @@ export async function runFleetDaemon() {
2229
2431
  });
2230
2432
  return;
2231
2433
  }
2434
+ /**
2435
+ * REMOVED FROM THE PROJECT IN THE APP — the second way this box stops being
2436
+ * this project's machine, and it goes through the same door for the same
2437
+ * reason (2026-09-21).
2438
+ *
2439
+ * IMMEDIATELY AFTER THE DISPLACEMENT, AND BEFORE THE VERSION SIGNAL, which
2440
+ * is the ordering the stop and the displacement above both document:
2441
+ * `handleVersionSignal` can RE-EXEC this process, and a box that was just
2442
+ * removed coming back up wearing a newer version is the one outcome nobody
2443
+ * asked for. The two removal-shaped signals sit together so a reader cannot
2444
+ * find one without the other.
2445
+ *
2446
+ * THE KEY'S PRESENCE IS THE COMMAND, exactly as it is for a stop and a
2447
+ * displacement: the server sends it only when it names THIS box and only
2448
+ * inside its own window, so there is no TTL to re-evaluate here and no way
2449
+ * for a relaunch to obey a removal aimed at somebody else.
2450
+ *
2451
+ * The choreography is identical — settle every in-flight turn, flush the
2452
+ * queued reports, tear down the detached children, keep the worktrees,
2453
+ * exit 0 — and only the sentences differ. Exit 0 for the reason every
2454
+ * terminal path here states: this was ASKED FOR, so under
2455
+ * `Restart=on-failure` a nonzero code would relaunch the daemon straight
2456
+ * into being removed again.
2457
+ */
2458
+ if (roster.standDown && typeof roster.standDown === 'object' && !Array.isArray(roster.standDown)) {
2459
+ await standDownDisplaced({
2460
+ kind: 'removed',
2461
+ // The project's NAME rather than the box that took over, because no box
2462
+ // took over. Falls back to "this project" inside — an unnamed project
2463
+ // says so rather than being guessed at.
2464
+ project: roster.standDown.project ?? roster.project?.name,
2465
+ settleAgentTurns,
2466
+ flushReports: flushWorkReports,
2467
+ teardown,
2468
+ exit: (code) => process.exit(code),
2469
+ log: { warn, note },
2470
+ });
2471
+ return;
2472
+ }
2232
2473
  /**
2233
2474
  * WHOSE MACHINE THIS IS. Absent = a server that does not arbitrate, and then
2234
2475
  * this is a no-op and the daemon behaves exactly as 0.83.0 did.
@@ -2394,9 +2635,14 @@ export async function runFleetDaemon() {
2394
2635
  // there session turns, ships or unsettled reports in flight?
2395
2636
  if (!workBusy() && Date.now() - idleBeatAt > 60_000) {
2396
2637
  idleBeatAt = Date.now();
2638
+ // `inactive`, the owner's own word for this state since 2026-09-21 — the
2639
+ // same word the app's machines list and `flowviant machines` print for
2640
+ // this box. The internal state is still called `standby` because it names
2641
+ // the SERVER'S arbitration arm rather than anything a person reads; what
2642
+ // a person reads is this sentence.
2397
2643
  info(
2398
2644
  holderState === 'standby'
2399
- ? 'standing by — another machine holds this project.'
2645
+ ? 'inactive — another machine is serving this project.'
2400
2646
  : 'machine online — nothing running right now.'
2401
2647
  );
2402
2648
  }
@@ -2414,25 +2660,10 @@ export async function runFleetDaemon() {
2414
2660
  }
2415
2661
  void drainWiki();
2416
2662
 
2417
- // Env sync tick: register/bootstrap/wrap/rotate/sync as the roster block
2418
- // dictates (self-guarded — one operation at a time, errors retry next
2419
- // poll). A fresh bundle rematerializes every SESSION worktree this daemon
2420
- // holds — read off the sessions directory, the same fact retirement acts
2421
- // on; the wiki worktree NEVER gets env (the cartographer doesn't need
2422
- // secrets). This used to iterate the dispatch-era `workers` map, which
2423
- // nothing has ever `.set()`, so a rotation reached no worktree at all.
2424
- // Safe mid-turn by construction: materializeInto refuses to write anything
2425
- // git does not ignore, so it cannot dirty a tree and block a ship.
2426
- void handleRosterEnv(roster.env, { projectId: roster.project?.id }).then(({ changed }) => {
2427
- if (!changed) return;
2428
- for (const id of heldSessionIds()) {
2429
- try {
2430
- materializeInto(join(baseDir, 'sessions', id));
2431
- } catch {
2432
- /* best-effort */
2433
- }
2434
- }
2435
- });
2663
+ // WHAT IS IN THIS BOX'S ENV, by name, on its own 60s beat. Never awaited,
2664
+ // throttled and deduped inside, and silent forever on an older server.
2665
+ // This is what replaced the vault's sync tick — see `maybeReportEnv`.
2666
+ void maybeReportEnv(repoRoot);
2436
2667
 
2437
2668
  // Tell the app what this machine is doing with itself. Every reconcile,
2438
2669
  // best-effort, and never awaited — telemetry that can delay a dispatch is
@@ -2464,10 +2695,21 @@ export async function runFleetDaemon() {
2464
2695
  })
2465
2696
  );
2466
2697
 
2467
- // Deploy: a deploy-authorized daemon reports its .flowviant/deploy.json and
2468
- // runs queued deploy jobs (the server only sends deployJobs to authorized
2469
- // machines). Config report is cheap + dedup'd; jobs are single-flight.
2470
- if (roster.env?.deployAuthorized) {
2698
+ // Deploy: a daemon on a project with deploy ALLOWED reports its
2699
+ // .flowviant/deploy.json and runs queued deploy jobs (the server only sends
2700
+ // deployJobs to such projects). Config report is cheap + dedup'd; jobs are
2701
+ // single-flight.
2702
+ //
2703
+ // `roster.deployAllowed`, a top-level boolean the server sends only when
2704
+ // TRUE — it used to be `roster.env.deployAuthorized`, read off the vault's
2705
+ // own roster block. The fact never belonged there: since migration 0096 the
2706
+ // answer comes from the PROJECT row (`projects.deploy_allowed`, owner-only,
2707
+ // off by default), not from a per-device column on an enrolled daemon —
2708
+ // "every device on a project shares one credential, so a boundary between
2709
+ // them is not a boundary". The vault's block is gone; the switch is not, and
2710
+ // this is where it now arrives. Absence reads as NOT allowed, which is the
2711
+ // withholding direction and the right one for an irreversible act.
2712
+ if (roster.deployAllowed) {
2471
2713
  // The BASE branch's copy, not the working tree's — see readDeployConfig.
2472
2714
  // Reporting the working tree would advertise targets the runner will not
2473
2715
  // find, which is the same lie in the other direction.
package/bin/lib/git.mjs CHANGED
@@ -1,6 +1,8 @@
1
1
  /** Git worktree helpers (fleet & static-fleet modes). */
2
2
 
3
3
  import { execFileSync } from 'node:child_process';
4
+ import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
5
+ import { dirname, join, resolve } from 'node:path';
4
6
 
5
7
  export function git(args, cwd) {
6
8
  return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
@@ -154,3 +156,64 @@ export function resetWorktree(wt, baseRef) {
154
156
  }
155
157
  }
156
158
 
159
+
160
+ /**
161
+ * Add paths to the exclude file git ACTUALLY READS, for one worktree.
162
+ *
163
+ * MOVED HERE 2026-09-21, from env.mjs. It lived in the secrets vault because
164
+ * the vault is what first needed it — materialized `.env` files had to be
165
+ * untracked AND unstageable — but it is a plain `git info/exclude` helper with
166
+ * no crypto, no bundle and no opinion about secrets, and the vault is deleted.
167
+ * Its one surviving caller is `work.mjs`, which hides a tab's `.flowviant/`
168
+ * upload directory with it.
169
+ *
170
+ * WHY IT IS NOT `.git/worktrees/<name>/info/exclude`: it used to resolve the
171
+ * worktree's own gitdir and write there, on the belief that the file "applies
172
+ * to that worktree only and never touches the user's repo". Git does not read
173
+ * that file — it resolves `info/exclude` against $GIT_COMMON_DIR, the main
174
+ * `.git` — so in every linked worktree the daemon creates, the exclusion did
175
+ * nothing at all, and the paths it was meant to hide stayed visible to
176
+ * `git add -A`.
177
+ *
178
+ * `--git-common-dir` is ASKED OF GIT rather than derived, because that is the
179
+ * one answer that cannot drift from what git itself will consult. The file is
180
+ * local to the clone and never committed. Idempotent: a path already listed is
181
+ * not appended again, so calling this per fetch costs one read.
182
+ *
183
+ * Best-effort throughout. It is a CONVENIENCE and never a guarantee — nothing
184
+ * downstream may treat "we called this" as proof git cannot see a path.
185
+ */
186
+ export function excludeInWorktree(wt, relPaths) {
187
+ try {
188
+ let gitdir;
189
+ try {
190
+ gitdir = resolve(
191
+ wt,
192
+ execFileSync('git', ['rev-parse', '--git-common-dir'], {
193
+ cwd: wt,
194
+ encoding: 'utf8',
195
+ stdio: ['ignore', 'pipe', 'ignore'],
196
+ }).trim()
197
+ );
198
+ } catch {
199
+ return; // not a repo — there is no exclude file to write
200
+ }
201
+ const excludePath = join(gitdir, 'info', 'exclude');
202
+ mkdirSync(dirname(excludePath), { recursive: true });
203
+ let existing = '';
204
+ try {
205
+ existing = readFileSync(excludePath, 'utf8');
206
+ } catch {
207
+ /* fresh */
208
+ }
209
+ const missing = relPaths.filter((p) => !existing.split('\n').includes(`/${p}`));
210
+ if (missing.length) {
211
+ appendFileSync(
212
+ excludePath,
213
+ `${existing.endsWith('\n') || !existing ? '' : '\n'}${missing.map((p) => `/${p}`).join('\n')}\n`
214
+ );
215
+ }
216
+ } catch {
217
+ /* best-effort */
218
+ }
219
+ }