flowviant 0.103.0 → 0.104.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 +87 -474
- package/bin/lib/admission.mjs +19 -3
- package/bin/lib/agentPlan.mjs +2 -2
- package/bin/lib/agentTaskKinds.mjs +93 -0
- package/bin/lib/agySessions.mjs +158 -0
- package/bin/lib/artifactPolicy.mjs +44 -0
- package/bin/lib/artifactPreview.mjs +52 -0
- package/bin/lib/artifactReporter.mjs +163 -0
- package/bin/lib/artifacts.mjs +47 -227
- package/bin/lib/authReject.mjs +16 -4
- package/bin/lib/authproxy.mjs +47 -304
- package/bin/lib/boxIdentity.mjs +245 -0
- package/bin/lib/childEnv.mjs +11 -9
- package/bin/lib/claude.mjs +14 -851
- package/bin/lib/claudeAuth.mjs +3 -3
- package/bin/lib/claudePosture.mjs +367 -0
- package/bin/lib/claudeSessions.mjs +411 -0
- package/bin/lib/claudeStream.mjs +207 -0
- package/bin/lib/cliLimit.mjs +34 -0
- package/bin/lib/commandSpecs.mjs +173 -0
- package/bin/lib/config.mjs +11 -42
- package/bin/lib/credentialDate.mjs +22 -0
- package/bin/lib/credentials.mjs +21 -26
- package/bin/lib/daemonLogging.mjs +55 -0
- package/bin/lib/daemonState.mjs +64 -0
- package/bin/lib/deploy.mjs +148 -647
- package/bin/lib/deployCheckout.mjs +265 -0
- package/bin/lib/deployConfig.mjs +142 -0
- package/bin/lib/deployRunner.mjs +311 -0
- package/bin/lib/deployWire.mjs +41 -0
- package/bin/lib/desktopContract.mjs +56 -105
- package/bin/lib/env.mjs +49 -484
- package/bin/lib/fleet.mjs +183 -2383
- package/bin/lib/fleetJobs.mjs +120 -0
- package/bin/lib/fleetPost.mjs +40 -0
- package/bin/lib/fleetReports.mjs +392 -0
- package/bin/lib/fleetRoster.mjs +470 -0
- package/bin/lib/fleetStartup.mjs +196 -0
- package/bin/lib/fleetWake.mjs +45 -0
- package/bin/lib/fleetWire.mjs +46 -0
- package/bin/lib/git.mjs +2 -2
- package/bin/lib/gitMergeIdentity.mjs +51 -0
- package/bin/lib/grant.mjs +5 -3
- package/bin/lib/help.mjs +3 -125
- package/bin/lib/holder.mjs +482 -0
- package/bin/lib/instance.mjs +124 -46
- package/bin/lib/knowledge.mjs +16 -799
- package/bin/lib/knowledgeFetch.mjs +38 -0
- package/bin/lib/knowledgeFileSync.mjs +112 -0
- package/bin/lib/knowledgeFiles.mjs +487 -0
- package/bin/lib/knowledgeLibrary.mjs +249 -0
- package/bin/lib/landed.mjs +2 -1
- package/bin/lib/listeners.mjs +99 -141
- package/bin/lib/localSessions.mjs +30 -555
- package/bin/lib/login.mjs +3 -2
- package/bin/lib/loginPath.mjs +3 -5
- package/bin/lib/machineDisconnect.mjs +203 -0
- package/bin/lib/machineEnv.mjs +22 -0
- package/bin/lib/machineLimits.mjs +61 -0
- package/bin/lib/machineListing.mjs +338 -0
- package/bin/lib/machines.mjs +78 -455
- package/bin/lib/machinesCommand.mjs +214 -0
- package/bin/lib/mcp-cli.mjs +2 -1
- package/bin/lib/patchRevert.mjs +60 -0
- package/bin/lib/prWorkflow.mjs +157 -0
- package/bin/lib/preflight.mjs +2 -1
- package/bin/lib/pressurePolicy.mjs +104 -0
- package/bin/lib/preview.mjs +2 -272
- package/bin/lib/previewFramePolicy.mjs +112 -0
- package/bin/lib/previewGatePolicy.mjs +236 -0
- package/bin/lib/previewRegistry.mjs +235 -0
- package/bin/lib/printable.mjs +2 -2
- package/bin/lib/procRegistry.mjs +28 -18
- package/bin/lib/procScan.mjs +146 -0
- package/bin/lib/processes.mjs +29 -37
- package/bin/lib/projectToolRuntimes.mjs +138 -0
- package/bin/lib/projectTools.mjs +13 -88
- package/bin/lib/prompts.mjs +28 -42
- package/bin/lib/repoPath.mjs +26 -0
- package/bin/lib/resources.mjs +7 -69
- package/bin/lib/resumeLost.mjs +107 -0
- package/bin/lib/runTurn.mjs +324 -0
- package/bin/lib/runtimeAntigravity.mjs +230 -0
- package/bin/lib/runtimeCapabilities.mjs +334 -0
- package/bin/lib/runtimeClaude.mjs +126 -0
- package/bin/lib/runtimeCodex.mjs +217 -0
- package/bin/lib/runtimeDetection.mjs +121 -0
- package/bin/lib/runtimeEvents.mjs +482 -0
- package/bin/lib/runtimeOfferings.mjs +4 -1
- package/bin/lib/runtimes.mjs +42 -1402
- package/bin/lib/safeFileName.mjs +74 -0
- package/bin/lib/standDownExit.mjs +181 -0
- package/bin/lib/standDownGate.mjs +48 -0
- package/bin/lib/startProject.mjs +279 -0
- package/bin/lib/trace.mjs +3 -3
- package/bin/lib/turnLock.mjs +68 -0
- package/bin/lib/turnProfile.mjs +77 -0
- package/bin/lib/uninstall.mjs +55 -45
- package/bin/lib/uninstallCopies.mjs +112 -0
- package/bin/lib/update.mjs +30 -17
- package/bin/lib/uplinkScrub.mjs +265 -0
- package/bin/lib/vault.mjs +22 -151
- package/bin/lib/vaultDiff.mjs +183 -0
- package/bin/lib/versionOrder.mjs +42 -0
- package/bin/lib/views.mjs +17 -17
- package/bin/lib/wikiRunner.mjs +630 -0
- package/bin/lib/work.mjs +188 -3034
- package/bin/lib/workAdoptCarry.mjs +94 -0
- package/bin/lib/workAgentCheck.mjs +278 -0
- package/bin/lib/workAgentMerges.mjs +54 -142
- package/bin/lib/workAgentPlans.mjs +14 -12
- package/bin/lib/workAgentPrecheck.mjs +393 -0
- package/bin/lib/workAgentPublish.mjs +177 -0
- package/bin/lib/workAgentReview.mjs +8 -666
- package/bin/lib/workAgentTurnBegun.mjs +109 -0
- package/bin/lib/workAgentTurnExecution.mjs +604 -0
- package/bin/lib/workAgentTurnOutcome.mjs +207 -0
- package/bin/lib/workAgentTurnReports.mjs +189 -0
- package/bin/lib/workAgentTurns.mjs +91 -898
- package/bin/lib/workAttachments.mjs +93 -0
- package/bin/lib/workBrain.mjs +53 -0
- package/bin/lib/workChildren.mjs +110 -0
- package/bin/lib/workCommandAudit.mjs +62 -0
- package/bin/lib/workDiffs.mjs +2 -1
- package/bin/lib/workNarration.mjs +152 -0
- package/bin/lib/workPlaceReports.mjs +426 -0
- package/bin/lib/workPlaces.mjs +228 -0
- package/bin/lib/workPreviews.mjs +31 -9
- package/bin/lib/workProcesses.mjs +4 -5
- package/bin/lib/workPullRequests.mjs +52 -126
- package/bin/lib/workReportQueue.mjs +254 -0
- package/bin/lib/workRetire.mjs +185 -0
- package/bin/lib/workSessionGroups.mjs +166 -0
- package/bin/lib/workSessionRuntime.mjs +164 -0
- package/bin/lib/workSessionTokens.mjs +68 -0
- package/bin/lib/workSessionTurns.mjs +892 -0
- package/bin/lib/workShip.mjs +9 -29
- package/bin/lib/workToolLog.mjs +76 -0
- package/package.json +3 -2
- package/bin/lib/patch.mjs +0 -363
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE UPLINK SCRUBBER — what this daemon hides from every piece of text it
|
|
3
|
+
* posts, and the one thing in this file.
|
|
4
|
+
*
|
|
5
|
+
* `scrub()` redacts secret values out of every piece of text this daemon posts
|
|
6
|
+
* — turn streams, tool events, the trace, process command lines, wiki
|
|
7
|
+
* progress, deploy logs — and `secretIn()` finds them inside the bytes it
|
|
8
|
+
* cannot rewrite. It is fed from TWO places (2026-09-21, the review): the
|
|
9
|
+
* checkout's `.env*` files (`env.mjs`'s scan hands them to `learnSecrets`), and
|
|
10
|
+
* the PRESENT values of `childEnv`'s `DEPLOY_KEEP` names out of this process's
|
|
11
|
+
* own environment, which is where an operator's `CLOUDFLARE_API_TOKEN`
|
|
12
|
+
* actually lives and which the deploy lane hands to a command whose stdout it
|
|
13
|
+
* then streams to the server. It redacts on SHAPE, never on meaning: a value
|
|
14
|
+
* that looks like an ordinary word is left alone, because a relay that swaps
|
|
15
|
+
* the CLI's own words for `[REDACTED:NODE_ENV]` has stopped being a relay.
|
|
16
|
+
* NOTHING HERE EVER REACHES THE WIRE — it is a list of what to hide, not a
|
|
17
|
+
* report.
|
|
18
|
+
*
|
|
19
|
+
* SPLIT OUT OF `env.mjs` (2026-09-26, the SOLID pass). That module held three
|
|
20
|
+
* things with three unrelated reasons to change — the box keypair
|
|
21
|
+
* (`boxIdentity.mjs`), the env comparison scan (still `env.mjs`), and this
|
|
22
|
+
* redaction policy — and a change to what counts as a secret had to be made in
|
|
23
|
+
* the same file as the dotenv parser and the identity mint. The scan now owns
|
|
24
|
+
* READING the checkout; this module owns DECIDING what to hide and hiding it,
|
|
25
|
+
* and the two meet at exactly one call, `learnSecrets`.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
// THE DEPLOY LANE'S OWN ALLOWLIST, borrowed for REDACTION ONLY. Deriving the
|
|
29
|
+
// scrub list from the set a deploy command is actually handed is what stops the
|
|
30
|
+
// two drifting; `processEnvSecrets`'s docblock carries the whole argument,
|
|
31
|
+
// including why none of it ever reaches the wire.
|
|
32
|
+
import { processEnvSecrets } from './childEnv.mjs';
|
|
33
|
+
|
|
34
|
+
// ── What `scrub` will redact ───────────────────────────────────────────────
|
|
35
|
+
//
|
|
36
|
+
// THE PREDICATE LIVES ABOVE THE LIST ON PURPOSE (2026-09-21): the list is
|
|
37
|
+
// FILTERED THROUGH IT ONCE, when a scan rebuilds it, rather than tested per
|
|
38
|
+
// call. `scrub` runs on every posted line of every turn stream, tool event and
|
|
39
|
+
// deploy log, and it does a whole-string `split`/`join` per value — so a value
|
|
40
|
+
// that will never match must not survive into the loop at all. Pre-filtering
|
|
41
|
+
// also makes the list smaller than it was before this pass on a typical
|
|
42
|
+
// checkout, because half of a real `.env` is ordinary configuration.
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* THE LENGTH FLOOR. A value of one or two characters — `1`, `ab`, `on` — occurs
|
|
46
|
+
* in ordinary prose constantly, so redacting one replaces half of every stream
|
|
47
|
+
* this daemon posts with `[REDACTED:NAME]` and the trace stops being readable
|
|
48
|
+
* at all. Six is the point below which a value is far likelier to be a flag
|
|
49
|
+
* than a secret, and nothing this short is worth making the product illegible
|
|
50
|
+
* for.
|
|
51
|
+
*
|
|
52
|
+
* (It used to carry the comment "mirrors shared ENV_SCRUB_MIN_LENGTH". That
|
|
53
|
+
* constant does not exist anywhere in the monorepo any more — it went with the
|
|
54
|
+
* vault's server half on 2026-09-21 — and a cross-reference to a deleted
|
|
55
|
+
* identifier reads as "do not change this, the other end depends on it", which
|
|
56
|
+
* is a claim about a coupling nobody can check. The floor's own argument is
|
|
57
|
+
* above and it stands on its own.)
|
|
58
|
+
*/
|
|
59
|
+
const SCRUB_MIN_LENGTH = 6;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A VALUE THAT LOOKS LIKE AN ORDINARY WORD IS NOT REDACTED (2026-09-21, the
|
|
63
|
+
* review) — and this is a correctness fix, not a tuning knob.
|
|
64
|
+
*
|
|
65
|
+
* `scrub` now reads the CHECKOUT'S `.env*` files, and a real `.env` is full of
|
|
66
|
+
* configuration that is not secret at all: `NODE_ENV=development`,
|
|
67
|
+
* `HOST=localhost`, `APP_NAME=flowviant`, `LOG_LEVEL=verbose`,
|
|
68
|
+
* `AWS_REGION=us-east-1`. With those in the list, every occurrence of the word
|
|
69
|
+
* "development" in the CLI's own narration came back as
|
|
70
|
+
* `[REDACTED:NODE_ENV]` — so a turn trace, which exists to relay what the CLI
|
|
71
|
+
* said, was relaying something else. A relay does not swap the words it
|
|
72
|
+
* carries. That is a worse failure than under-redaction, because it is silent,
|
|
73
|
+
* universal, and destroys the readout the trace was built for.
|
|
74
|
+
*
|
|
75
|
+
* TWO SHAPE TESTS, applied per value, both cheap and both about SHAPE rather
|
|
76
|
+
* than meaning (nothing here classifies, guesses, or asks a model):
|
|
77
|
+
*
|
|
78
|
+
* · A PLAIN IDENTIFIER — starts with a letter, runs at most sixteen
|
|
79
|
+
* characters, and is made only of letters, digits and `.` `/` `-`. That is
|
|
80
|
+
* what a word, a hostname, a short version string and an enum value all look
|
|
81
|
+
* like. `_` is deliberately NOT in the set: an underscore is rare in prose
|
|
82
|
+
* and common in key material (`sk-live_…`), so its presence is the cheapest
|
|
83
|
+
* honest signal that a string was minted rather than written.
|
|
84
|
+
* · A SHORT VALUE WITH LITTLE VARIETY — under twelve characters and drawing on
|
|
85
|
+
* fewer than three of {lower, upper, digit, symbol}. A real credential short
|
|
86
|
+
* enough to be under twelve characters is mixed; `us-east-1` and `verbose`
|
|
87
|
+
* are not.
|
|
88
|
+
*
|
|
89
|
+
* THE ACCEPTED COST, STATED RATHER THAN HIDDEN: a genuinely secret value that
|
|
90
|
+
* is short and lowercase — `hunter2secret`, a thirteen-character all-letters
|
|
91
|
+
* password — is NOT redacted. Under-redaction is chosen here over making the
|
|
92
|
+
* product illegible, because the alternative is a trace nobody can read and a
|
|
93
|
+
* product that silently rewrites its own CLI's output. An operator whose secret
|
|
94
|
+
* is a dictionary word has a problem this function cannot fix. Anything with a
|
|
95
|
+
* symbol, a case mix, twelve-plus characters, or an underscore — which is every
|
|
96
|
+
* token, key, URL, JWT and hex digest anybody actually pastes into a `.env` —
|
|
97
|
+
* is redacted.
|
|
98
|
+
*/
|
|
99
|
+
const SCRUB_PLAIN_RE = /^[A-Za-z][A-Za-z0-9._/-]{0,15}$/;
|
|
100
|
+
function characterClasses(value) {
|
|
101
|
+
let n = 0;
|
|
102
|
+
if (/[a-z]/.test(value)) n += 1;
|
|
103
|
+
if (/[A-Z]/.test(value)) n += 1;
|
|
104
|
+
if (/[0-9]/.test(value)) n += 1;
|
|
105
|
+
if (/[^A-Za-z0-9]/.test(value)) n += 1;
|
|
106
|
+
return n;
|
|
107
|
+
}
|
|
108
|
+
function worthRedacting(value) {
|
|
109
|
+
if (typeof value !== 'string' || value.length < SCRUB_MIN_LENGTH) return false;
|
|
110
|
+
// THE PLAIN-IDENTIFIER EXEMPTION DOES NOT COVER A CASE-AND-DIGIT MIX
|
|
111
|
+
// (2026-09-24, the audit). It was checked first and alone, so a generated
|
|
112
|
+
// password — `Tq8vZ2mKp4Lx9RbN`, the `pwgen -s 16` shape, or a twelve-
|
|
113
|
+
// character `Hx93kPq2Lm7w` — starts with a letter, fits sixteen characters of
|
|
114
|
+
// [A-Za-z0-9] and was left in plain text everywhere this daemon posts, against
|
|
115
|
+
// this block's own promise that "a case mix" is redacted. A word, a hostname,
|
|
116
|
+
// a region or a version string does not draw on upper, lower AND digits at
|
|
117
|
+
// once; a minted secret almost always does.
|
|
118
|
+
if (SCRUB_PLAIN_RE.test(value) && !(/[a-z]/.test(value) && /[A-Z]/.test(value) && /[0-9]/.test(value))) {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
if (value.length < 12 && characterClasses(value) < 3) return false;
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── Module state ───────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* WHAT `scrub` REDACTS — `[{ name, value }]`, refilled by every env scan.
|
|
129
|
+
*
|
|
130
|
+
* THIS ARRAY CHANGED HANDS (2026-09-21), and the change is an IMPROVEMENT
|
|
131
|
+
* rather than a salvage. It used to hold the vault's decrypted bundle: only
|
|
132
|
+
* the values Flowviant itself had delivered to this box. With the vault gone
|
|
133
|
+
* that array would be permanently empty and `scrub` would become a silent
|
|
134
|
+
* no-op in roughly ten call sites — the worst possible way for a redactor to
|
|
135
|
+
* stop working, because every one of those sites keeps calling it and nothing
|
|
136
|
+
* looks different until a secret is already in the server's database.
|
|
137
|
+
*
|
|
138
|
+
* So it is filled from the CHECKOUT'S OWN `.env*` FILES, which are the box's
|
|
139
|
+
* REAL secrets: the ones the operator pasted in from their providers, the ones
|
|
140
|
+
* a dev server actually loads, and the ones a CLI turn is overwhelmingly
|
|
141
|
+
* likeliest to echo into a log. The vault could only ever redact what it had
|
|
142
|
+
* delivered; this redacts what is there.
|
|
143
|
+
*
|
|
144
|
+
* ── TWO SOURCES, KEPT IN TWO VARIABLES (2026-09-21, the review) ──
|
|
145
|
+
*
|
|
146
|
+
* `values` is what `scrub` reads and is rebuilt on every scan as
|
|
147
|
+
* `processEnvSecrets()` ++ `fileValues`. The split exists because the two halves
|
|
148
|
+
* have opposite failure modes. The CHECKOUT half can come back empty for two
|
|
149
|
+
* different reasons — the operator deleted `.env`, or this pass could not read
|
|
150
|
+
* the directory — so an empty read must NOT replace it (see `learnSecrets`).
|
|
151
|
+
* The PROCESS half cannot blink: `process.env` is in memory and reading it is
|
|
152
|
+
* infallible, so it is replaced unconditionally, and folding it into one array
|
|
153
|
+
* with the file half would make a single unreadable-directory blip drop the
|
|
154
|
+
* operator's deploy credential out of the redactor too.
|
|
155
|
+
*
|
|
156
|
+
* SEEDED AT IMPORT, deliberately. `scrub` is reachable from the deploy lane and
|
|
157
|
+
* from a turn before the first scan has run, and the process half needs no
|
|
158
|
+
* checkout, no credential and no I/O to be correct — so there is no moment at
|
|
159
|
+
* which this module knows a deploy token and is not hiding it.
|
|
160
|
+
*
|
|
161
|
+
* AND IT IS ALREADY FILTERED. Every entry in `values` has passed
|
|
162
|
+
* `worthRedacting` — see that predicate's block above — so `scrub` itself makes
|
|
163
|
+
* no decisions and simply replaces. `fileValues` keeps the UNFILTERED read,
|
|
164
|
+
* because the filter is about redaction and the read is about what is on disk.
|
|
165
|
+
*/
|
|
166
|
+
let fileValues = [];
|
|
167
|
+
let values = processEnvSecrets().filter((v) => worthRedacting(v.value));
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* HAND THE SCRUBBER A FRESH READ OF THE CHECKOUT'S `.env*` VALUES —
|
|
171
|
+
* `[{ name, value }]`, unfiltered, exactly as `scanEnvFiles` returns them — and
|
|
172
|
+
* rebuild the list `scrub` reads.
|
|
173
|
+
*
|
|
174
|
+
* THE PROCESS ENVIRONMENT'S INFRA CREDENTIALS ARE MERGED IN HERE, and here is
|
|
175
|
+
* the point: this is the one function every scan already goes through, so the
|
|
176
|
+
* two lists cannot drift and nobody has to remember. They are derived from
|
|
177
|
+
* `childEnv`'s own `DEPLOY_KEEP`, which is the set a deploy command is actually
|
|
178
|
+
* handed — see `processEnvSecrets` for why they are redacted and never
|
|
179
|
+
* reported. They are re-read on every scan rather than frozen at import,
|
|
180
|
+
* because that costs nothing and an environment read once is a list that can
|
|
181
|
+
* only be wrong.
|
|
182
|
+
*
|
|
183
|
+
* AN EMPTY READ DOES NOT CLEAR WHAT WE ALREADY KNOW, and this is the one place
|
|
184
|
+
* the two halves deliberately disagree. A scan that comes back with nothing is
|
|
185
|
+
* two different facts wearing one shape — the operator deleted `.env`, or this
|
|
186
|
+
* pass could not read the directory (EACCES, a mid-sweep rename, a network
|
|
187
|
+
* mount that blinked) — and `scanEnvFiles` swallows per-file errors by design
|
|
188
|
+
* so the poll cannot throw. Replacing the list on an empty read means a blip
|
|
189
|
+
* silently turns the redactor OFF, which is the exact failure mode the whole
|
|
190
|
+
* repoint above exists to avoid, and it is invisible until a secret is already
|
|
191
|
+
* in the server's database. Hence the separate `fileValues`: the process half
|
|
192
|
+
* is refreshed unconditionally (process.env does not blink) while the file half
|
|
193
|
+
* is only ever replaced by a read that found something.
|
|
194
|
+
*
|
|
195
|
+
* So an empty read leaves the previous list standing. The cost is
|
|
196
|
+
* OVER-redaction — a value the operator has since removed or rotated keeps
|
|
197
|
+
* being replaced with `[REDACTED:NAME]` for the life of this process — and
|
|
198
|
+
* over-redacting is the safe direction, the same one every three-state readout
|
|
199
|
+
* in this daemon takes. The REPORT half is the scan's, and is unaffected: it
|
|
200
|
+
* returns the real (empty) read, so the app is told what was actually measured.
|
|
201
|
+
*/
|
|
202
|
+
export function learnSecrets(scannedValues) {
|
|
203
|
+
if (scannedValues.length) fileValues = scannedValues;
|
|
204
|
+
// FILTERED ONCE, HERE — see the predicate's own block above: `scrub` is a hot
|
|
205
|
+
// path and a value that can never match must not survive into its loop.
|
|
206
|
+
values = [...processEnvSecrets(), ...fileValues].filter((v) => worthRedacting(v.value));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── Uplink scrubbing ───────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
/** Redact every known secret value from daemon-posted text. The list has
|
|
212
|
+
* already been through `worthRedacting`, so this makes no decisions: an
|
|
213
|
+
* ordinary word, a short flag and anything under the length floor are simply
|
|
214
|
+
* not in it. */
|
|
215
|
+
export function scrub(text) {
|
|
216
|
+
if (typeof text !== 'string' || !text) return text;
|
|
217
|
+
let out = text;
|
|
218
|
+
for (const v of values) out = out.split(v.value).join(`[REDACTED:${v.name}]`);
|
|
219
|
+
return out.replace(FLOWVIANT_TOKEN_RE, `[REDACTED:${FLOWVIANT_TOKEN_NAME}]`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* EVERY FLOWVIANT CREDENTIAL, CAUGHT BY SHAPE (2026-09-24, the audit).
|
|
224
|
+
*
|
|
225
|
+
* The value lists above are the CHECKOUT'S secrets and the process
|
|
226
|
+
* environment's, and neither holds the one secret that costs the project
|
|
227
|
+
* itself: the MACHINE CREDENTIAL. It sits in `~/.flowviant/credentials.json` —
|
|
228
|
+
* for EVERY project connected on this box, 0600 and readable by every turn,
|
|
229
|
+
* since a turn runs as this uid — and the server mints more of the same shape
|
|
230
|
+
* per turn (the work and capture tokens a tab's MCP server carries). A turn
|
|
231
|
+
* that read the store and wrote it into an artifact, a trace or an answer
|
|
232
|
+
* shipped it verbatim, because `scrub` had no value to match; and a design
|
|
233
|
+
* artifact runs scripts in a frame that may navigate itself, so the page that
|
|
234
|
+
* held it could carry it anywhere.
|
|
235
|
+
*
|
|
236
|
+
* Every one of them is `fva_` + 40 characters of nanoid (the server's
|
|
237
|
+
* `AGENT_TOKEN_PREFIX`), so a SHAPE catches them all, including ones this
|
|
238
|
+
* process never saw, with no store read and nothing to keep in sync. Twenty is
|
|
239
|
+
* the floor so the 12-character prefix the app shows for identification
|
|
240
|
+
* (`fva_` + 8) still reads as itself.
|
|
241
|
+
*/
|
|
242
|
+
const FLOWVIANT_TOKEN_RE = /fva_[A-Za-z0-9_-]{20,}/g;
|
|
243
|
+
const FLOWVIANT_TOKEN_NAME = 'FLOWVIANT_TOKEN';
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The NAME of the first known secret whose value appears in `bytes` as an
|
|
247
|
+
* exact byte substring, or null. For the payloads `scrub` cannot touch: a
|
|
248
|
+
* binary artifact (a PNG's text chunk, a PDF's uncompressed stream, a zip's
|
|
249
|
+
* stored entry) is not text to rewrite — replacing bytes inside one corrupts
|
|
250
|
+
* it — so the caller WITHHOLDS a hit rather than redacting it. The same list,
|
|
251
|
+
* already filtered by `worthRedacting`, so the two can never disagree about
|
|
252
|
+
* what counts as a secret. It sees only what is stored plainly: a value inside
|
|
253
|
+
* a deflated stream is not a substring of the file, and that is stated where
|
|
254
|
+
* it is used (artifacts.mjs).
|
|
255
|
+
*/
|
|
256
|
+
export function secretIn(bytes) {
|
|
257
|
+
if (!Buffer.isBuffer(bytes) || !bytes.length) return null;
|
|
258
|
+
for (const v of values) if (bytes.includes(v.value, 0, 'utf8')) return v.name;
|
|
259
|
+
// The token SHAPE too (see `scrub`). `latin1` maps every byte to one
|
|
260
|
+
// character, so the ASCII pattern matches exactly where the bytes do.
|
|
261
|
+
if (bytes.includes('fva_') && new RegExp(FLOWVIANT_TOKEN_RE.source).test(bytes.toString('latin1'))) {
|
|
262
|
+
return FLOWVIANT_TOKEN_NAME;
|
|
263
|
+
}
|
|
264
|
+
return null;
|
|
265
|
+
}
|
package/bin/lib/vault.mjs
CHANGED
|
@@ -13,12 +13,9 @@
|
|
|
13
13
|
* in `.flowviant-sync.json` inside the vault (dotfile — never walked, never
|
|
14
14
|
* uploaded).
|
|
15
15
|
*
|
|
16
|
-
* HARD RULE — deletion is opt-in, never inferred
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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.
|
|
16
|
+
* HARD RULE — deletion is opt-in, never inferred. That rule, the carry-forward
|
|
17
|
+
* policy and the request plan live in vaultDiff.mjs (planVaultSync); this
|
|
18
|
+
* module is the adapter: walk, read, POST in order, advance the state.
|
|
22
19
|
*/
|
|
23
20
|
|
|
24
21
|
import {
|
|
@@ -28,30 +25,11 @@ import {
|
|
|
28
25
|
mkdirSync,
|
|
29
26
|
existsSync,
|
|
30
27
|
} from 'node:fs';
|
|
31
|
-
import { createHash } from 'node:crypto';
|
|
32
28
|
import { execFileSync } from 'node:child_process';
|
|
33
29
|
import { join, relative, sep } from 'node:path';
|
|
30
|
+
import { planVaultSync } from './vaultDiff.mjs';
|
|
34
31
|
|
|
35
32
|
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
33
|
|
|
56
34
|
/** Create the vault dir + its private git history (best-effort). */
|
|
57
35
|
export function ensureVault(dir) {
|
|
@@ -111,129 +89,27 @@ function commitVault(dir, message) {
|
|
|
111
89
|
export async function syncVault({ dir, url, token, userAgent, finalize, groundedAtSha, repoFullName, warn = () => {}, scrub = (t) => t }) {
|
|
112
90
|
const walkErrors = { count: 0 };
|
|
113
91
|
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
92
|
let prev = {};
|
|
127
|
-
|
|
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;
|
|
93
|
+
if (!(walkErrors.count > 0 && found.length === 0)) {
|
|
152
94
|
try {
|
|
153
|
-
|
|
95
|
+
prev = JSON.parse(readFileSync(join(dir, SYNC_STATE), 'utf8'));
|
|
154
96
|
} catch {
|
|
155
|
-
|
|
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;
|
|
97
|
+
/* first sync */
|
|
165
98
|
}
|
|
166
|
-
kept++;
|
|
167
|
-
contents[p] = text;
|
|
168
|
-
current[p] = createHash('sha256').update(text).digest('hex');
|
|
169
99
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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;
|
|
100
|
+
const read = (p) => {
|
|
101
|
+
try {
|
|
102
|
+
return readFileSync(join(dir, p), 'utf8');
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
223
105
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
}
|
|
227
|
-
|
|
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
|
|
106
|
+
};
|
|
107
|
+
// What to send is vaultDiff.mjs's decision; this adapter only moves bytes.
|
|
108
|
+
const plan = planVaultSync({ found, walkErrors: walkErrors.count, prev, read, scrub, finalize, groundedAtSha, repoFullName, dir });
|
|
109
|
+
for (const w of plan.warnings) warn(w);
|
|
110
|
+
if (plan.result) return plan.result;
|
|
234
111
|
|
|
235
|
-
for (
|
|
236
|
-
const last = i === requests.length - 1;
|
|
112
|
+
for (const body of plan.requests) {
|
|
237
113
|
const res = await fetch(url, {
|
|
238
114
|
method: 'POST',
|
|
239
115
|
headers: {
|
|
@@ -242,17 +118,12 @@ export async function syncVault({ dir, url, token, userAgent, finalize, grounded
|
|
|
242
118
|
'Content-Type': 'application/json',
|
|
243
119
|
},
|
|
244
120
|
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
|
-
}),
|
|
121
|
+
body: JSON.stringify(body),
|
|
251
122
|
});
|
|
252
123
|
if (!res.ok) throw new Error(`wiki-vault sync failed (${res.status})`);
|
|
253
124
|
}
|
|
254
125
|
|
|
255
|
-
writeFileSync(join(dir, SYNC_STATE), JSON.stringify(
|
|
256
|
-
commitVault(dir,
|
|
257
|
-
return { pages, uploaded:
|
|
126
|
+
writeFileSync(join(dir, SYNC_STATE), JSON.stringify(plan.state));
|
|
127
|
+
commitVault(dir, plan.finalized ? `sweep${groundedAtSha ? ` @ ${groundedAtSha.slice(0, 7)}` : ''}` : `update${groundedAtSha ? ` @ ${groundedAtSha.slice(0, 7)}` : ''}`);
|
|
128
|
+
return { pages: plan.pages, uploaded: plan.uploaded, deleted: plan.deleted };
|
|
258
129
|
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHAT A VAULT SYNC SENDS — the pure planner behind syncVault (2026-09-26,
|
|
3
|
+
* SOLID F063).
|
|
4
|
+
*
|
|
5
|
+
* Split out of vault.mjs, where the deletion and carry-forward policy sat
|
|
6
|
+
* inside the walk, the POSTs and the state write, so its central rule could
|
|
7
|
+
* only be driven by building files and intercepting the network. This module
|
|
8
|
+
* decides; vault.mjs walks the directory, reads the prior state, POSTs the
|
|
9
|
+
* planned requests in order, and advances the state only after every one
|
|
10
|
+
* lands.
|
|
11
|
+
*
|
|
12
|
+
* HARD RULE — deletion is opt-in, never inferred: a page we can't read, can't
|
|
13
|
+
* upload (oversized / invalid path), or truncated past the cap is CARRIED
|
|
14
|
+
* FORWARD at its last-synced state, not turned into a deletion. Only a page
|
|
15
|
+
* that verifiably vanished from a readable vault becomes a delete. Otherwise
|
|
16
|
+
* an append-only log.md crossing the size cap would silently erase itself
|
|
17
|
+
* server-side.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { createHash } from 'node:crypto';
|
|
21
|
+
|
|
22
|
+
// Mirror the server contract (shared schema) — a page that violates it is
|
|
23
|
+
// skipped with a warning (and carried forward if previously synced), never
|
|
24
|
+
// allowed to 400 the whole request and wedge the sync.
|
|
25
|
+
export const MAX_FILE_BYTES = 262_144;
|
|
26
|
+
const MAX_PATH_CHARS = 300;
|
|
27
|
+
export const MAX_FILES = 400;
|
|
28
|
+
export const CHUNK_FILES = 30;
|
|
29
|
+
export const CHUNK_BYTES = 700_000;
|
|
30
|
+
export const MAX_DELETIONS_PER_REQ = 200;
|
|
31
|
+
|
|
32
|
+
/** Daemon-side mirror of the server's isSafeVaultPath. */
|
|
33
|
+
export const isSafePath = (p) =>
|
|
34
|
+
p.length > 0 &&
|
|
35
|
+
p.length <= MAX_PATH_CHARS &&
|
|
36
|
+
p.endsWith('.md') &&
|
|
37
|
+
!p.includes('\\') &&
|
|
38
|
+
!p.includes('\0') &&
|
|
39
|
+
!p.startsWith('/') &&
|
|
40
|
+
p.split('/').every((seg) => seg.length > 0 && seg !== '.' && seg !== '..' && !seg.startsWith('.'));
|
|
41
|
+
|
|
42
|
+
const SKIPPED = Object.freeze({ pages: 0, uploaded: 0, deleted: 0, skipped: true });
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Plan one sync.
|
|
46
|
+
*
|
|
47
|
+
* found vault-relative .md paths the walk discovered, sorted
|
|
48
|
+
* walkErrors how many directories the walk could not read
|
|
49
|
+
* prev path -> sha256 of the last successful sync ({} on the first)
|
|
50
|
+
* read(p) the page's markdown, or null when it cannot be read
|
|
51
|
+
* scrub uplink redaction, applied before hashing
|
|
52
|
+
* finalize, groundedAtSha, repoFullName ride the LAST request only
|
|
53
|
+
*
|
|
54
|
+
* Returns `{ warnings, result }` when nothing is sent (result is syncVault's
|
|
55
|
+
* return), else `{ warnings, requests, state, finalized, pages, uploaded,
|
|
56
|
+
* deleted }` — `requests` are the POST bodies in order, `state` the hashes to
|
|
57
|
+
* write once every one has landed.
|
|
58
|
+
*/
|
|
59
|
+
export function planVaultSync({ found, walkErrors = 0, prev = {}, read, scrub = (t) => t, finalize = false, groundedAtSha, repoFullName, dir = 'the vault' }) {
|
|
60
|
+
const warnings = [];
|
|
61
|
+
if (walkErrors > 0 && found.length === 0) {
|
|
62
|
+
// Vault root (or everything under it) unreadable — nothing to diff against.
|
|
63
|
+
warnings.push(`vault at ${dir} is unreadable — skipping sync; check the vault dir`);
|
|
64
|
+
return { warnings, result: { ...SKIPPED } };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Partition into uploadable pages and carried-forward ones. Carried = we
|
|
68
|
+
// know the page exists (or existed) but can't ship this state — keep the
|
|
69
|
+
// server's last-good copy: tracked in `current` (prev hash) + manifest,
|
|
70
|
+
// never a deletion.
|
|
71
|
+
const current = {}; // path -> sha256 tracked as the post-sync state
|
|
72
|
+
const contents = {}; // path -> markdown to upload (subset of current)
|
|
73
|
+
const carry = (p, why) => {
|
|
74
|
+
if (prev[p]) {
|
|
75
|
+
current[p] = prev[p];
|
|
76
|
+
warnings.push(`vault page ${p}: ${why} — keeping the last synced copy`);
|
|
77
|
+
} else {
|
|
78
|
+
warnings.push(`vault page ${p}: ${why} — not synced`);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
let kept = 0;
|
|
83
|
+
for (const p of found) {
|
|
84
|
+
if (!isSafePath(p)) {
|
|
85
|
+
carry(p, 'name violates the sync contract (length/characters)');
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (kept >= MAX_FILES) {
|
|
89
|
+
carry(p, `vault exceeds ${MAX_FILES} pages`);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
let text = read(p);
|
|
93
|
+
if (text == null) {
|
|
94
|
+
carry(p, 'unreadable');
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
// Uplink scrub: the cartographer quotes real repo files, and a repo file
|
|
98
|
+
// can contain a synced secret — redact known values before upload. The
|
|
99
|
+
// hash is computed on the SCRUBBED text so the diff state stays coherent.
|
|
100
|
+
text = scrub(text);
|
|
101
|
+
if (Buffer.byteLength(text) > MAX_FILE_BYTES) {
|
|
102
|
+
carry(p, 'exceeds 256KB');
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
kept++;
|
|
106
|
+
contents[p] = text;
|
|
107
|
+
current[p] = createHash('sha256').update(text).digest('hex');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Partial walk (an unreadable SUBdirectory): every previously-synced page the
|
|
111
|
+
// walk failed to reach must be carried forward, not inferred deleted — a
|
|
112
|
+
// transient EMFILE/EACCES on e.g. docs/ must never erase those pages
|
|
113
|
+
// server-side. The hard rule: deletion is opt-in, never inferred.
|
|
114
|
+
if (walkErrors > 0) {
|
|
115
|
+
warnings.push(
|
|
116
|
+
`vault walk hit ${walkErrors} unreadable director${walkErrors === 1 ? 'y' : 'ies'} — carrying missing pages forward, no deletions this pass`
|
|
117
|
+
);
|
|
118
|
+
for (const p of Object.keys(prev)) {
|
|
119
|
+
if (!(p in current)) current[p] = prev[p];
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// A readable vault that suddenly presents ZERO pages while the server holds
|
|
124
|
+
// many is almost always a broken/moved dir, not an intentional wipe — refuse
|
|
125
|
+
// to mass-delete. (An intentional reset is a fresh Regenerate: the sweep
|
|
126
|
+
// rewrites pages, then finalize prunes precisely.)
|
|
127
|
+
const prevCount = Object.keys(prev).length;
|
|
128
|
+
if (Object.keys(current).length === 0 && prevCount > 0) {
|
|
129
|
+
warnings.push(`vault at ${dir} presents 0 pages but ${prevCount} were synced — refusing to delete; check the vault dir`);
|
|
130
|
+
return { warnings, result: { ...SKIPPED } };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const changed = Object.keys(current).filter((p) => p in contents && prev[p] !== current[p]);
|
|
134
|
+
const deletions = Object.keys(prev).filter((p) => !(p in current));
|
|
135
|
+
const pages = Object.keys(current).length;
|
|
136
|
+
if (changed.length === 0 && deletions.length === 0 && !finalize) {
|
|
137
|
+
return { warnings, result: { pages, uploaded: 0, deleted: 0, skipped: true } };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Finalize manifests are schema-capped server-side at MAX_FILES; carried
|
|
141
|
+
// pages can push the tracked set past it. Downgrade to a plain merge (no
|
|
142
|
+
// prune) rather than wedge the whole sync on a 400 — nothing is lost, the
|
|
143
|
+
// regen request stays pending, and the warning names the cause.
|
|
144
|
+
let doFinalize = !!finalize;
|
|
145
|
+
if (doFinalize && pages > MAX_FILES) {
|
|
146
|
+
warnings.push(`vault tracks ${pages} pages (> ${MAX_FILES}) — skipping the finalize prune this pass`);
|
|
147
|
+
doFinalize = false;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Build the request series: file chunks (count+byte capped), then however
|
|
151
|
+
// many deletion batches the 200-cap needs. finalize/sha ride the LAST
|
|
152
|
+
// request only, so the server prunes exactly once, after every upsert landed.
|
|
153
|
+
const fileChunks = [];
|
|
154
|
+
let cur = [];
|
|
155
|
+
let bytes = 0;
|
|
156
|
+
for (const p of changed) {
|
|
157
|
+
const size = Buffer.byteLength(contents[p]);
|
|
158
|
+
if (cur.length && (cur.length >= CHUNK_FILES || bytes + size > CHUNK_BYTES)) {
|
|
159
|
+
fileChunks.push(cur);
|
|
160
|
+
cur = [];
|
|
161
|
+
bytes = 0;
|
|
162
|
+
}
|
|
163
|
+
cur.push(p);
|
|
164
|
+
bytes += size;
|
|
165
|
+
}
|
|
166
|
+
if (cur.length) fileChunks.push(cur);
|
|
167
|
+
|
|
168
|
+
const requests = fileChunks.map((paths) => ({ files: paths.map((p) => ({ path: p, content: contents[p] })), deletions: [] }));
|
|
169
|
+
for (let i = 0; i < deletions.length; i += MAX_DELETIONS_PER_REQ) {
|
|
170
|
+
requests.push({ files: [], deletions: deletions.slice(i, i + MAX_DELETIONS_PER_REQ) });
|
|
171
|
+
}
|
|
172
|
+
if (requests.length === 0) requests.push({ files: [], deletions: [] }); // finalize-only
|
|
173
|
+
|
|
174
|
+
const last = requests.length - 1;
|
|
175
|
+
requests[last] = {
|
|
176
|
+
...requests[last],
|
|
177
|
+
...(doFinalize ? { finalize: { manifest: Object.keys(current) } } : {}),
|
|
178
|
+
...(groundedAtSha ? { groundedAtSha } : {}),
|
|
179
|
+
...(repoFullName ? { repoFullName } : {}),
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
return { warnings, requests, state: current, finalized: doFinalize, pages, uploaded: changed.length, deleted: deletions.length };
|
|
183
|
+
}
|