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/cli.mjs +10 -10
- package/bin/lib/childEnv.mjs +128 -9
- package/bin/lib/config.mjs +28 -0
- package/bin/lib/deploy.mjs +92 -45
- package/bin/lib/env.mjs +595 -852
- package/bin/lib/fleet.mjs +319 -77
- package/bin/lib/git.mjs +63 -0
- package/bin/lib/machines.mjs +39 -13
- package/bin/lib/mcp-cli.mjs +8 -7
- package/bin/lib/vaultArtefacts.mjs +170 -0
- package/bin/lib/work.mjs +34 -43
- package/package.json +1 -1
- package/bin/lib/env-cli.mjs +0 -216
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
293
|
-
//
|
|
294
|
-
//
|
|
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
|
-
*
|
|
690
|
-
* · mine: false ->
|
|
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:
|
|
694
|
-
* is
|
|
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
|
|
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
|
-
|
|
855
|
-
? `
|
|
856
|
-
:
|
|
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(
|
|
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
|
-
//
|
|
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
|
-
//
|
|
991
|
-
//
|
|
992
|
-
//
|
|
993
|
-
//
|
|
994
|
-
//
|
|
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
|
-
//
|
|
997
|
-
//
|
|
998
|
-
//
|
|
999
|
-
//
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
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
|
-
? '
|
|
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
|
-
//
|
|
2418
|
-
//
|
|
2419
|
-
//
|
|
2420
|
-
|
|
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
|
|
2468
|
-
// runs queued deploy jobs (the server only sends
|
|
2469
|
-
//
|
|
2470
|
-
|
|
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
|
+
}
|