flowviant 0.102.2 → 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 +184 -2396
- 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 +34 -3
- 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/shipMerge.mjs +33 -2
- 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 +35 -12
- package/bin/lib/wikiRunner.mjs +630 -0
- package/bin/lib/work.mjs +188 -3037
- 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 +92 -897
- 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
package/bin/lib/knowledge.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* files uploaded once and a standing Instructions text, read by every turn on
|
|
7
7
|
* the project. The server holds the bytes; the roster carries a MANIFEST
|
|
8
8
|
* (`knowledge: { rev, instructions, files: [{ id, name, bytes, sha256 }] }`);
|
|
9
|
-
*
|
|
9
|
+
* the daemon makes `<checkout>/.flowviant/knowledge/` match it, and the turn
|
|
10
10
|
* prompts name that directory. Nothing here reads a file's CONTENTS for any
|
|
11
11
|
* purpose but writing it — the only brain is the CLI, reading off a disk.
|
|
12
12
|
*
|
|
@@ -55,780 +55,25 @@
|
|
|
55
55
|
* permanent for that rev, skipped with a warning, and the rest of the library
|
|
56
56
|
* is written.
|
|
57
57
|
*
|
|
58
|
-
* ──
|
|
58
|
+
* ── WHERE EACH PART LIVES (2026-09-26, SOLID SRP, F049) ──
|
|
59
59
|
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
* design A, and reading the page. At 0.99.0 a kept design is one folder,
|
|
67
|
-
* including its original relative sidecar paths and measured preview.
|
|
68
|
-
*
|
|
69
|
-
* SUBDIRECTORIES, AND EXACTLY TWO. The safe-name rule for a knowledge file
|
|
70
|
-
* forbids every separator; a library item is the one thing allowed ONE, and
|
|
71
|
-
* only after one of the two literal prefixes, with a stem that is already
|
|
72
|
-
* safe and the extension its kind hands back. `..`, a third segment, another
|
|
73
|
-
* prefix, a leading dot — refused, never rewritten, because a rewritten path
|
|
74
|
-
* could collide with another item's real one. The two directories are OURS the
|
|
75
|
-
* way the library directory is: a symlink or a file planted there is removed
|
|
76
|
-
* (the link, never its target) and a real directory made.
|
|
77
|
-
*
|
|
78
|
-
* ABSENT MEANS "LEAVE IT ALONE", the same rule the whole key keeps: a manifest
|
|
79
|
-
* with no `library` key is an older server, and `designs/`, `research/` and
|
|
80
|
-
* `LIBRARY.md` are neither synced nor swept. `library: { items: [] }` is the
|
|
81
|
-
* emptied catalog, SAID — both directories and the catalog go.
|
|
60
|
+
* This module is the ROSTER DRIVER alone: the rev held in memory, the
|
|
61
|
+
* serialised sync, the backoff. The pure naming, path and catalog rules —
|
|
62
|
+
* including the kept library's two subdirectories — are knowledgeLibrary.mjs;
|
|
63
|
+
* the disk (the sync itself, the marker, `knowledgeDirFor`) is
|
|
64
|
+
* knowledgeFiles.mjs; the `/fleet/knowledge` and `/fleet/library` download is
|
|
65
|
+
* knowledgeFetch.mjs. Callers import the owner they need.
|
|
82
66
|
*/
|
|
83
67
|
|
|
68
|
+
import { isKnowledgeManifest, libraryItemsOf } from './knowledgeLibrary.mjs';
|
|
84
69
|
import {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
unlinkSync,
|
|
93
|
-
writeFileSync,
|
|
94
|
-
} from 'node:fs';
|
|
95
|
-
import { createHash } from 'node:crypto';
|
|
96
|
-
import { join } from 'node:path';
|
|
97
|
-
|
|
98
|
-
/** Relative to the checkout, under `.flowviant/` beside the turn uploads and
|
|
99
|
-
* the artifacts — see `FLOWVIANT_OWN_PATHS` for why that is NOT one exclude
|
|
100
|
-
* line covering all three. */
|
|
101
|
-
export const KNOWLEDGE_DIR = '.flowviant/knowledge';
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* THE PATHS UNDER `.flowviant/` THAT ARE OURS, and the exclude file names these
|
|
105
|
-
* and nothing wider (2026-09-23).
|
|
106
|
-
*
|
|
107
|
-
* The first cut excluded `/.flowviant/` whole, from every place a turn spawns
|
|
108
|
-
* in — the attachment fetch's line, which until this release was written only
|
|
109
|
-
* in repos somebody had attached a file in. But `.flowviant/` is not only ours:
|
|
110
|
-
* the repo DECLARES `.flowviant/check.json` and `.flowviant/deploy.json` there,
|
|
111
|
-
* committed like any other file. An exclude line never hides a TRACKED file,
|
|
112
|
-
* so an existing one was safe — and a NEW one was not: an agent asked to "add a
|
|
113
|
-
* check command" writes `.flowviant/check.json`, its `git add -A` skips it as
|
|
114
|
-
* ignored, the commit lands without it, and `git status` shows nothing to
|
|
115
|
-
* explain why. Writing that line into every worktree on every turn would have
|
|
116
|
-
* made the product's own configuration uncommittable by the product's own
|
|
117
|
-
* agents. So the exclude names the four things this daemon writes there, and a
|
|
118
|
-
* repo's own `.flowviant/*.json` stays ordinary, committable work.
|
|
119
|
-
*
|
|
120
|
-
* (A repo that already carries the old `/.flowviant/` line keeps it — it is in
|
|
121
|
-
* the user's own exclude file, and removing a line we cannot prove we alone
|
|
122
|
-
* wrote is not ours to do.)
|
|
123
|
-
*/
|
|
124
|
-
export const FLOWVIANT_OWN_PATHS = [
|
|
125
|
-
'.flowviant/knowledge/',
|
|
126
|
-
'.flowviant/knowledge.rev*',
|
|
127
|
-
'.flowviant/artifacts/',
|
|
128
|
-
'.flowviant/uploads/',
|
|
129
|
-
];
|
|
130
|
-
/** The generated catalog of the kept library, at the knowledge root. Reserved
|
|
131
|
-
* like INSTRUCTIONS.md: a person's file of that name is suffixed, because the
|
|
132
|
-
* prompt tells the CLI this one is the catalog. */
|
|
133
|
-
export const LIBRARY_FILE = 'LIBRARY.md';
|
|
134
|
-
/** The two subdirectories a kept item may live in, by kind. */
|
|
135
|
-
export const LIBRARY_DIRS = { design: 'designs', research: 'research' };
|
|
136
|
-
const LIBRARY_EXT = { design: ['.html', '.htm', '.gltf', '.obj', '.glb', '.bin'], research: ['.md'] };
|
|
137
|
-
/** The server caps a library at 200 items; a longer manifest is cut. */
|
|
138
|
-
const MAX_LIBRARY_ITEMS = 256;
|
|
139
|
-
|
|
140
|
-
/** The person's standing brief, written from the manifest's `instructions`.
|
|
141
|
-
* A RESERVED name: a library FILE called this gets a suffix, because the
|
|
142
|
-
* prompt tells the CLI to read this one first and a stranger's file must
|
|
143
|
-
* never be able to stand in for the person's own words. */
|
|
144
|
-
export const INSTRUCTIONS_FILE = 'INSTRUCTIONS.md';
|
|
145
|
-
/** The rev last materialised — OUTSIDE the directory the prompt hands the CLI,
|
|
146
|
-
* so the agent listing its library sees only the library. */
|
|
147
|
-
const MARKER = '.flowviant/knowledge.rev';
|
|
148
|
-
/** The server's per-file ceiling (`KNOWLEDGE_FILE_MAX_BYTES`), re-checked here:
|
|
149
|
-
* this is somebody's disk, and one place checking is one deploy away from
|
|
150
|
-
* zero places — the attachment fetch's own rule. */
|
|
151
|
-
export const KNOWLEDGE_FILE_MAX_BYTES = 10 * 1024 * 1024;
|
|
152
|
-
export const KNOWLEDGE_BINARY_MODEL_MAX_BYTES = 20 * 1024 * 1024;
|
|
153
|
-
const knowledgeMaxFor = (name, base) => /\.(glb|bin)$/i.test(name)
|
|
154
|
-
? Math.max(base, KNOWLEDGE_BINARY_MODEL_MAX_BYTES) : base;
|
|
155
|
-
/** The manifest is capped at the server (25 files); a manifest longer than
|
|
156
|
-
* this is not one the product produced, and is cut rather than trusted. */
|
|
157
|
-
const MAX_FILES = 64;
|
|
158
|
-
|
|
159
|
-
/** FNV-1a, 32-bit, as 8 hex — deterministic, so the same long name always maps
|
|
160
|
-
* to the same stored name. Verbatim against the server's `fnv1a8`
|
|
161
|
-
* (apps/api/src/routes/sessionsAttachments.routes.ts). */
|
|
162
|
-
function fnv1a8(s) {
|
|
163
|
-
let h = 0x811c9dc5;
|
|
164
|
-
for (let i = 0; i < s.length; i++) {
|
|
165
|
-
h ^= s.charCodeAt(i);
|
|
166
|
-
h = Math.imul(h, 0x01000193);
|
|
167
|
-
}
|
|
168
|
-
return (h >>> 0).toString(16).padStart(8, '0');
|
|
169
|
-
}
|
|
170
|
-
/** What counts as an extension worth keeping through a cut. */
|
|
171
|
-
const SAFE_EXT_RE = /^[A-Za-z0-9]{1,10}$/;
|
|
172
|
-
const SAFE_NAME_MAX = 80;
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* `safeFileName`, verbatim (apps/api/src/routes/sessionsAttachments.routes.ts):
|
|
176
|
-
* drops every path separator, never starts with a dot or a dash, and — since
|
|
177
|
-
* 2026-09-24 — THE EXTENSION SURVIVES A CUT. A bare `slice(0, 80)` truncated
|
|
178
|
-
* an over-long name mid-extension (an 84-character `….html` mockup stored as
|
|
179
|
-
* `….` with an unrecognised type and its bytes discarded), so a long name's
|
|
180
|
-
* stem is now cut and an 8-hex FNV-1a hash of the WHOLE sanitised name is
|
|
181
|
-
* appended before the extension: stable across calls, at most 80 characters,
|
|
182
|
-
* idempotent over its own output, and — the reason it is re-applied here
|
|
183
|
-
* rather than trusted to the server's own pass — byte-identical to what
|
|
184
|
-
* `safeFileName` computes there, so a knowledge file synced under this name
|
|
185
|
-
* and one requested by name later never disagree over the cut.
|
|
186
|
-
*/
|
|
187
|
-
export function safeKnowledgeName(raw) {
|
|
188
|
-
const clean = String(raw ?? '')
|
|
189
|
-
.split(/[\\/]/)
|
|
190
|
-
.pop()
|
|
191
|
-
.replace(/[^A-Za-z0-9._-]/g, '_')
|
|
192
|
-
.replace(/^[.-]+/, '');
|
|
193
|
-
if (!clean) return 'file';
|
|
194
|
-
if (clean.length <= SAFE_NAME_MAX) return clean;
|
|
195
|
-
const dot = clean.lastIndexOf('.');
|
|
196
|
-
const ext = dot > 0 && SAFE_EXT_RE.test(clean.slice(dot + 1)) ? clean.slice(dot + 1) : '';
|
|
197
|
-
const stem = ext ? clean.slice(0, dot) : clean;
|
|
198
|
-
const tag = `-${fnv1a8(clean)}`;
|
|
199
|
-
const room = SAFE_NAME_MAX - tag.length - (ext ? ext.length + 1 : 0);
|
|
200
|
-
return `${stem.slice(0, room)}${tag}${ext ? `.${ext}` : ''}`;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* The LOCAL name each manifest entry is written under, in manifest order.
|
|
205
|
-
* Collisions get a numeric suffix (`spec.md`, `spec-2.md`) — two files a
|
|
206
|
-
* person uploaded with one name are two files, and silently writing one over
|
|
207
|
-
* the other loses one. Deterministic in the manifest's order, so the same
|
|
208
|
-
* manifest always yields the same names and a re-sync never renames a file.
|
|
209
|
-
* `INSTRUCTIONS.md` is reserved (compared case-insensitively — macOS and
|
|
210
|
-
* Windows disks are).
|
|
211
|
-
*/
|
|
212
|
-
export function planKnowledgeNames(files) {
|
|
213
|
-
// The catalog's name and the two library directories are reserved beside
|
|
214
|
-
// the brief (0.97.0): a person's `LIBRARY.md` or a file called `designs`
|
|
215
|
-
// must never stand where the catalog or a directory has to.
|
|
216
|
-
const taken = new Set([
|
|
217
|
-
INSTRUCTIONS_FILE.toLowerCase(),
|
|
218
|
-
LIBRARY_FILE.toLowerCase(),
|
|
219
|
-
...Object.values(LIBRARY_DIRS),
|
|
220
|
-
]);
|
|
221
|
-
const out = [];
|
|
222
|
-
for (const f of files) {
|
|
223
|
-
const name = safeKnowledgeName(f.name);
|
|
224
|
-
const dot = name.lastIndexOf('.');
|
|
225
|
-
const stem = dot > 0 ? name.slice(0, dot) : name;
|
|
226
|
-
const ext = dot > 0 ? name.slice(dot) : '';
|
|
227
|
-
let candidate = name;
|
|
228
|
-
for (let n = 2; taken.has(candidate.toLowerCase()); n++) candidate = `${stem}-${n}${ext}`;
|
|
229
|
-
taken.add(candidate.toLowerCase());
|
|
230
|
-
out.push({ ...f, local: candidate });
|
|
231
|
-
}
|
|
232
|
-
return out;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
const sha256Of = (buf) => createHash('sha256').update(buf).digest('hex');
|
|
236
|
-
|
|
237
|
-
/** A regular file's sha256, or null for anything else (missing, a directory, a
|
|
238
|
-
* symlink — a symlink is never followed, so it is never "the same file"). */
|
|
239
|
-
function localSha(path) {
|
|
240
|
-
try {
|
|
241
|
-
const st = lstatSync(path);
|
|
242
|
-
if (!st.isFile()) return null;
|
|
243
|
-
return sha256Of(readFileSync(path));
|
|
244
|
-
} catch {
|
|
245
|
-
return null;
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/** Write through a temp name and rename, so a CLI reading the library mid-sync
|
|
250
|
-
* sees the old file or the new one, never half of one. Anything already at
|
|
251
|
-
* the path that is NOT a regular file (a symlink planted there) is removed
|
|
252
|
-
* first — `writeFileSync` would otherwise follow it and write wherever it
|
|
253
|
-
* pointed. */
|
|
254
|
-
function writeAtomic(path, data) {
|
|
255
|
-
try {
|
|
256
|
-
const st = lstatSync(path);
|
|
257
|
-
if (!st.isFile()) rmSync(path, { recursive: true, force: true });
|
|
258
|
-
} catch {
|
|
259
|
-
/* absent — the ordinary case */
|
|
260
|
-
}
|
|
261
|
-
const tmp = `${path}.fvtmp`;
|
|
262
|
-
// THE TEMP NAME IS CHECKED TOO (2026-09-23). The first cut guarded the
|
|
263
|
-
// destination and then wrote straight through `<name>.fvtmp` — a link planted
|
|
264
|
-
// THERE would have been followed, the library's bytes written wherever it
|
|
265
|
-
// pointed, and the link renamed into place. Unlinked first, then created
|
|
266
|
-
// EXCLUSIVELY (`wx`): if anything reappears at the name between the two, the
|
|
267
|
-
// write throws into the caller's catch rather than following it.
|
|
268
|
-
rmSync(tmp, { recursive: true, force: true });
|
|
269
|
-
writeFileSync(tmp, data, { flag: 'wx' });
|
|
270
|
-
renameSync(tmp, path);
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
/**
|
|
274
|
-
* Is `<checkout>/.flowviant` a real directory, or absent? Anything else — a
|
|
275
|
-
* symlink, a regular file — REFUSES the sync rather than being touched.
|
|
276
|
-
*
|
|
277
|
-
* `.flowviant/` is not only ours: a repo declares `.flowviant/check.json` and
|
|
278
|
-
* `.flowviant/deploy.json` there, and an operator may have arranged it however
|
|
279
|
-
* they like. Deleting a file or a link at that path to make room for a library
|
|
280
|
-
* would be Flowviant destroying something it did not create; following a link
|
|
281
|
-
* would land downloads, and the emptied library's `rm -r`, wherever it points.
|
|
282
|
-
* So the parent is checked, never repaired.
|
|
283
|
-
*/
|
|
284
|
-
function flowviantDirOk(checkoutDir) {
|
|
285
|
-
try {
|
|
286
|
-
const st = lstatSync(join(checkoutDir, '.flowviant'));
|
|
287
|
-
return st.isDirectory() && !st.isSymbolicLink();
|
|
288
|
-
} catch {
|
|
289
|
-
return true; // absent — ours to create
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/** The library directory, as a real directory. The PARENT is checked by the
|
|
294
|
-
* caller; the library path itself is OURS, so a symlink or a file planted
|
|
295
|
-
* there is removed (unlinked — the link, never its target) rather than
|
|
296
|
-
* followed. */
|
|
297
|
-
function ensureDir(checkoutDir) {
|
|
298
|
-
const dir = join(checkoutDir, KNOWLEDGE_DIR);
|
|
299
|
-
try {
|
|
300
|
-
const st = lstatSync(dir);
|
|
301
|
-
if (st.isSymbolicLink() || !st.isDirectory()) unlinkSync(dir);
|
|
302
|
-
} catch {
|
|
303
|
-
/* absent */
|
|
304
|
-
}
|
|
305
|
-
mkdirSync(dir, { recursive: true });
|
|
306
|
-
return dir;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
/**
|
|
310
|
-
* THE MARKER SAYS WHICH REV WAS WRITTEN, AND WHETHER THE LIBRARY WAS PART OF
|
|
311
|
-
* IT (2026-09-23): `<rev>` or `<rev>:lib`.
|
|
312
|
-
*
|
|
313
|
-
* The rev alone could not tell an upgrade from a sync. A 0.96.0 box wrote the
|
|
314
|
-
* marker for rev N while ignoring the manifest's `library` key (it did not
|
|
315
|
-
* know one); updated to 0.97.0 with `knowledge_rev` still N, it read "N",
|
|
316
|
-
* matched the roster, and never synced the library — then the server handed
|
|
317
|
-
* `task.references` naming `designs/x-v1.html` and the agent was told to read
|
|
318
|
-
* a file that was not there. `:lib` records that THIS rev's sync carried a
|
|
319
|
-
* library key, so the upgrade case is one string compare and re-syncs at
|
|
320
|
-
* once. An older daemon reading `N:lib` sees NaN and re-syncs once, which is
|
|
321
|
-
* the harmless direction.
|
|
322
|
-
*/
|
|
323
|
-
function parseMarker(checkoutDir) {
|
|
324
|
-
try {
|
|
325
|
-
const m = /^(\d+)(:lib)?$/.exec(readFileSync(join(checkoutDir, MARKER), 'utf8').trim());
|
|
326
|
-
if (!m) return null;
|
|
327
|
-
const n = Number(m[1]);
|
|
328
|
-
return Number.isSafeInteger(n) ? { rev: n, lib: Boolean(m[2]) } : null;
|
|
329
|
-
} catch {
|
|
330
|
-
return null;
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
export function readKnowledgeMarker(checkoutDir) {
|
|
335
|
-
return parseMarker(checkoutDir)?.rev ?? null;
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
/** Did the sync that wrote the marker carry a `library` key? */
|
|
339
|
-
export function knowledgeMarkerHasLibrary(checkoutDir) {
|
|
340
|
-
return parseMarker(checkoutDir)?.lib ?? false;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
function writeKnowledgeMarker(checkoutDir, rev, lib = false) {
|
|
344
|
-
try {
|
|
345
|
-
mkdirSync(join(checkoutDir, '.flowviant'), { recursive: true });
|
|
346
|
-
writeAtomic(join(checkoutDir, MARKER), `${rev}${lib ? ':lib' : ''}\n`);
|
|
347
|
-
} catch {
|
|
348
|
-
/* a lost marker costs one re-sync after a restart — every file already on
|
|
349
|
-
disk matches by sha and is not downloaded again */
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
/**
|
|
354
|
-
* A kept item's relative path, or null when it is not EXACTLY
|
|
355
|
-
* `<designs|research>/<safe stem>.<its kind's extension>` — see the header.
|
|
356
|
-
* Refused rather than rewritten: a rewritten path is one the server never
|
|
357
|
-
* named, and it could land on another item's real one.
|
|
358
|
-
*/
|
|
359
|
-
export function safeLibraryPath(name, kind) {
|
|
360
|
-
if (typeof name !== 'string' || !(kind in LIBRARY_DIRS)) return null;
|
|
361
|
-
const parts = name.split('/');
|
|
362
|
-
if (parts.length !== 2 && !(kind === 'design' && parts.length >= 3 && parts.length <= 7)) return null;
|
|
363
|
-
const [dir, ...rest] = parts;
|
|
364
|
-
const file = rest.at(-1);
|
|
365
|
-
if (dir !== LIBRARY_DIRS[kind]) return null;
|
|
366
|
-
if (rest.length >= 2 && !/^[a-z0-9-]+-v[1-9][0-9]*$/.test(rest[0])) return null;
|
|
367
|
-
if (rest.slice(1, -1).some((p) => !safeBundleSegment(p))) return null;
|
|
368
|
-
if (!file || (rest.length === 1 ? file !== safeKnowledgeName(file) : !safeBundleSegment(file))) return null;
|
|
369
|
-
if (!LIBRARY_EXT[kind].some((ext) => file.toLowerCase().endsWith(ext) && file.length > ext.length)) return null;
|
|
370
|
-
return name;
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
function safeBundleSegment(part) {
|
|
374
|
-
return typeof part === 'string' && part.length > 0 && part.length <= 80 &&
|
|
375
|
-
/^[A-Za-z0-9][A-Za-z0-9._ -]*$/.test(part) && !part.endsWith('.');
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
function safeBundleFile(path, rootDir) {
|
|
379
|
-
if (typeof path !== 'string' || !path.startsWith(`${rootDir}/`)) return null;
|
|
380
|
-
const relative = path.slice(rootDir.length + 1);
|
|
381
|
-
if (relative === 'preview.png') return null;
|
|
382
|
-
const parts = relative.split('/');
|
|
383
|
-
if (!parts.length || parts.some((p) => !safeBundleSegment(p))) return null;
|
|
384
|
-
if (!/\.(gltf|obj|glb|bin|png|jpg|jpeg|gif|webp|svg|json)$/i.test(relative)) return null;
|
|
385
|
-
return path;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
/** The manifest's library items that are safe to write, in manifest order, or
|
|
389
|
-
* null when the key is ABSENT (an older server — leave everything alone). */
|
|
390
|
-
function libraryItemsOf(manifest) {
|
|
391
|
-
const lib = manifest?.library;
|
|
392
|
-
if (lib === undefined || lib === null) return null;
|
|
393
|
-
if (typeof lib !== 'object' || !Array.isArray(lib.items)) return null;
|
|
394
|
-
const out = [];
|
|
395
|
-
const seen = new Set();
|
|
396
|
-
for (const it of lib.items.slice(0, MAX_LIBRARY_ITEMS)) {
|
|
397
|
-
if (!it || typeof it.id !== 'string' || !/^[0-9a-f-]{8,64}$/i.test(it.id)) continue;
|
|
398
|
-
const path = safeLibraryPath(it.name, it.kind);
|
|
399
|
-
if (!path || seen.has(path.toLowerCase())) continue;
|
|
400
|
-
seen.add(path.toLowerCase());
|
|
401
|
-
const bundleDir = path.split('/').length >= 3 ? path.split('/').slice(0, 2).join('/') : null;
|
|
402
|
-
const expectedPreview = bundleDir ? `${bundleDir}/preview.png` : it.name.replace(/\.html$/, '.png');
|
|
403
|
-
const previewPath = it.kind === 'design' && it.preview && it.preview.name === expectedPreview ? expectedPreview : null;
|
|
404
|
-
const files = bundleDir && Array.isArray(it.files) ? it.files.map((f, index) => ({ ...f,
|
|
405
|
-
index, path: safeBundleFile(f.name, bundleDir),
|
|
406
|
-
})).filter((f) => f.path && !seen.has(f.path.toLowerCase())) : [];
|
|
407
|
-
for (const f of files) seen.add(f.path.toLowerCase());
|
|
408
|
-
out.push({ ...it, path, files, previewPath, previewUnknown: it.kind === 'design' && path.endsWith('.html') && it.preview === undefined });
|
|
409
|
-
}
|
|
410
|
-
return out;
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
/** One line of the catalog: server strings are made single-line and capped —
|
|
414
|
-
* they are a person's card title and an artifact's name, headed into a file
|
|
415
|
-
* an agent reads, so they may not carry a line break that forges a second
|
|
416
|
-
* entry. */
|
|
417
|
-
const catalogText = (v, max) =>
|
|
418
|
-
String(v ?? '')
|
|
419
|
-
.replace(/[\r\n\t]+/g, ' ')
|
|
420
|
-
.replace(/\s+/g, ' ')
|
|
421
|
-
.trim()
|
|
422
|
-
.slice(0, max);
|
|
423
|
-
|
|
424
|
-
/**
|
|
425
|
-
* THE CATALOG, generated whole from the manifest on every sync:
|
|
426
|
-
*
|
|
427
|
-
* - designs/landing-v2.html — Landing mockup (design, from card "Redesign
|
|
428
|
-
* landing page", 2026-09-23, supersedes designs/landing-v1.html)
|
|
429
|
-
*
|
|
430
|
-
* (one line per item, oldest first — the server's order — so a sync that adds
|
|
431
|
-
* one item adds one line and moves nothing). Null when there are no items, in
|
|
432
|
-
* which case the file is removed rather than written empty.
|
|
433
|
-
*/
|
|
434
|
-
export function renderLibraryCatalog(items) {
|
|
435
|
-
if (!items || items.length === 0) return null;
|
|
436
|
-
const lines = items.map((it) => {
|
|
437
|
-
const facts = [it.kind === 'research' ? 'research' : 'design'];
|
|
438
|
-
const card = catalogText(it.taskTitle, 200);
|
|
439
|
-
if (card) facts.push(`from card "${card.replace(/"/g, "'")}"`);
|
|
440
|
-
const day = typeof it.createdAt === 'string' ? it.createdAt.slice(0, 10) : '';
|
|
441
|
-
if (/^\d{4}-\d{2}-\d{2}$/.test(day)) facts.push(day);
|
|
442
|
-
const sup = typeof it.supersedes === 'string' ? catalogText(it.supersedes, 200) : '';
|
|
443
|
-
if (sup) facts.push(`supersedes ${sup}`);
|
|
444
|
-
const title = catalogText(it.title, 200) || it.path;
|
|
445
|
-
return `- ${it.path} — ${title} (${facts.join(', ')})`;
|
|
446
|
-
});
|
|
447
|
-
return (
|
|
448
|
-
'# Library\n\n' +
|
|
449
|
-
'The designs and research this project kept. Paths are relative to this directory;\n' +
|
|
450
|
-
'a newer version supersedes an older one of the same name.\n\n' +
|
|
451
|
-
`${lines.join('\n')}\n`
|
|
452
|
-
);
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
/** A library subdirectory as a real directory — ours, like the library's own
|
|
456
|
-
* path: a symlink or a file planted there is removed, never followed. */
|
|
457
|
-
function ensureSubdir(dir, name) {
|
|
458
|
-
const path = join(dir, name);
|
|
459
|
-
try {
|
|
460
|
-
const st = lstatSync(path);
|
|
461
|
-
if (st.isSymbolicLink() || !st.isDirectory()) unlinkSync(path);
|
|
462
|
-
} catch {
|
|
463
|
-
/* absent */
|
|
464
|
-
}
|
|
465
|
-
mkdirSync(path, { recursive: true });
|
|
466
|
-
return path;
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
function ensureLibraryParents(dir, relativePath) {
|
|
470
|
-
const parts = relativePath.split('/');
|
|
471
|
-
let at = dir;
|
|
472
|
-
for (const part of parts.slice(0, -1)) at = ensureSubdir(at, part);
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
function sweepLibraryTree(dir, sub, wanted, result) {
|
|
476
|
-
const walk = (at, prefix = '') => {
|
|
477
|
-
let entries;
|
|
478
|
-
try { entries = readdirSync(at); } catch { return; }
|
|
479
|
-
for (const name of entries) {
|
|
480
|
-
const rel = prefix ? `${prefix}/${name}` : name;
|
|
481
|
-
const path = join(at, name);
|
|
482
|
-
let st;
|
|
483
|
-
try { st = lstatSync(path); } catch { continue; }
|
|
484
|
-
if (st.isDirectory() && !st.isSymbolicLink()) {
|
|
485
|
-
walk(path, rel);
|
|
486
|
-
try { if (readdirSync(path).length === 0) rmSync(path, { recursive: true, force: true }); } catch { /* next sync */ }
|
|
487
|
-
} else if (!wanted.has(rel)) {
|
|
488
|
-
try { rmSync(path, { recursive: true, force: true }); result.removed.push(`${sub}/${rel}`); } catch { /* next sync */ }
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
};
|
|
492
|
-
walk(join(dir, sub));
|
|
493
|
-
};
|
|
494
|
-
|
|
495
|
-
/** Does this manifest look like one the server produced? Anything else is
|
|
496
|
-
* ignored whole rather than half-applied. */
|
|
497
|
-
export function isKnowledgeManifest(k) {
|
|
498
|
-
return (
|
|
499
|
-
!!k &&
|
|
500
|
-
typeof k === 'object' &&
|
|
501
|
-
Number.isInteger(k.rev) &&
|
|
502
|
-
k.rev >= 0 &&
|
|
503
|
-
(k.instructions === null || k.instructions === undefined || typeof k.instructions === 'string') &&
|
|
504
|
-
Array.isArray(k.files)
|
|
505
|
-
);
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
/**
|
|
509
|
-
* Make `<checkoutDir>/.flowviant/knowledge/` match `manifest`.
|
|
510
|
-
*
|
|
511
|
-
* `fetchFile(id)` returns the file's bytes as a Buffer, or throws. Injected so
|
|
512
|
-
* the sync is testable against a temp directory and a fake server; the daemon
|
|
513
|
-
* hands the `/fleet/knowledge/:id` fetch.
|
|
514
|
-
*
|
|
515
|
-
* @returns {{ ok: boolean, wrote: string[], removed: string[], refused: string[], failed: string[] }}
|
|
516
|
-
* `ok` is false when any download FAILED (to be retried); a file REFUSED for
|
|
517
|
-
* size is permanent for this rev and does not clear `ok`.
|
|
518
|
-
*/
|
|
519
|
-
export async function syncKnowledge({ checkoutDir, manifest, fetchFile, maxBytes = KNOWLEDGE_FILE_MAX_BYTES }) {
|
|
520
|
-
const result = { ok: true, wrote: [], removed: [], refused: [], failed: [] };
|
|
521
|
-
const dirPath = join(checkoutDir, KNOWLEDGE_DIR);
|
|
522
|
-
const files = (manifest.files ?? [])
|
|
523
|
-
.filter((f) => f && typeof f.id === 'string' && /^[0-9a-f-]{8,64}$/i.test(f.id))
|
|
524
|
-
.slice(0, MAX_FILES);
|
|
525
|
-
const instructions =
|
|
526
|
-
typeof manifest.instructions === 'string' && manifest.instructions.trim()
|
|
527
|
-
? manifest.instructions
|
|
528
|
-
: null;
|
|
529
|
-
// null = the key is ABSENT (leave the library's paths alone); [] = emptied.
|
|
530
|
-
const library = libraryItemsOf(manifest);
|
|
531
|
-
|
|
532
|
-
if (!flowviantDirOk(checkoutDir)) {
|
|
533
|
-
// Not a failure to retry every poll — nothing changes until a person
|
|
534
|
-
// changes it — but not a success either: the rev must not advance over a
|
|
535
|
-
// library that was never written.
|
|
536
|
-
result.ok = false;
|
|
537
|
-
result.failed.push('.flowviant (not a directory — left untouched)');
|
|
538
|
-
return result;
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
// EMPTIED: the directory goes, and the prompt paragraph with it — unless the
|
|
542
|
-
// library key is ABSENT and a synced library is on disk, which an older
|
|
543
|
-
// server's emptied shelf has no say over.
|
|
544
|
-
const libraryOnDisk =
|
|
545
|
-
library === null &&
|
|
546
|
-
[LIBRARY_FILE, ...Object.values(LIBRARY_DIRS)].some((n) => existsSync(join(dirPath, n)));
|
|
547
|
-
if (files.length === 0 && !instructions && (library === null ? !libraryOnDisk : library.length === 0)) {
|
|
548
|
-
if (existsSync(dirPath)) {
|
|
549
|
-
try {
|
|
550
|
-
const st = lstatSync(dirPath);
|
|
551
|
-
if (st.isSymbolicLink()) unlinkSync(dirPath);
|
|
552
|
-
else rmSync(dirPath, { recursive: true, force: true });
|
|
553
|
-
result.removed.push(KNOWLEDGE_DIR);
|
|
554
|
-
} catch {
|
|
555
|
-
result.ok = false;
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
return result;
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
const dir = ensureDir(checkoutDir);
|
|
562
|
-
const planned = planKnowledgeNames(files);
|
|
563
|
-
const keep = new Set(planned.map((p) => p.local));
|
|
564
|
-
if (instructions) keep.add(INSTRUCTIONS_FILE);
|
|
565
|
-
// ABSENT library key: its paths are neither synced nor swept.
|
|
566
|
-
if (library === null) {
|
|
567
|
-
keep.add(LIBRARY_FILE);
|
|
568
|
-
for (const d of Object.values(LIBRARY_DIRS)) keep.add(d);
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
for (const f of planned) {
|
|
572
|
-
const path = join(dir, f.local);
|
|
573
|
-
const fileMax = knowledgeMaxFor(f.name, maxBytes);
|
|
574
|
-
if (Number(f.bytes) > fileMax) {
|
|
575
|
-
// Permanent for this rev, and NOT a reason to delete the rest. Any stale
|
|
576
|
-
// local copy under this name goes, because it is not the file the
|
|
577
|
-
// manifest now names.
|
|
578
|
-
result.refused.push(f.local);
|
|
579
|
-
keep.delete(f.local);
|
|
580
|
-
continue;
|
|
581
|
-
}
|
|
582
|
-
if (typeof f.sha256 === 'string' && localSha(path) === f.sha256.toLowerCase()) continue;
|
|
583
|
-
try {
|
|
584
|
-
const buf = await fetchFile(f.id);
|
|
585
|
-
if (!Buffer.isBuffer(buf) || buf.byteLength > fileMax) {
|
|
586
|
-
result.refused.push(f.local);
|
|
587
|
-
keep.delete(f.local);
|
|
588
|
-
continue;
|
|
589
|
-
}
|
|
590
|
-
// The bytes must BE the file the manifest names. A mismatch is a
|
|
591
|
-
// failure to retry, never a file to trust.
|
|
592
|
-
if (typeof f.sha256 === 'string' && sha256Of(buf) !== f.sha256.toLowerCase()) {
|
|
593
|
-
throw new Error('sha256 mismatch');
|
|
594
|
-
}
|
|
595
|
-
writeAtomic(path, buf);
|
|
596
|
-
result.wrote.push(f.local);
|
|
597
|
-
} catch {
|
|
598
|
-
result.ok = false;
|
|
599
|
-
result.failed.push(f.local);
|
|
600
|
-
// Keep whatever copy is there: a stale file is better than a missing one
|
|
601
|
-
// until the retry lands.
|
|
602
|
-
}
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
if (instructions) {
|
|
606
|
-
const path = join(dir, INSTRUCTIONS_FILE);
|
|
607
|
-
const body = instructions.endsWith('\n') ? instructions : `${instructions}\n`;
|
|
608
|
-
let same = false;
|
|
609
|
-
try {
|
|
610
|
-
same = lstatSync(path).isFile() && readFileSync(path, 'utf8') === body;
|
|
611
|
-
} catch {
|
|
612
|
-
/* absent */
|
|
613
|
-
}
|
|
614
|
-
if (!same) {
|
|
615
|
-
writeAtomic(path, body);
|
|
616
|
-
result.wrote.push(INSTRUCTIONS_FILE);
|
|
617
|
-
}
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
if (library !== null) {
|
|
621
|
-
await syncLibrary({ dir, items: library, fetchFile, maxBytes, keep, result });
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
// Everything the manifest no longer names — files, stray temp names, and
|
|
625
|
-
// anything that is not a regular file — leaves.
|
|
626
|
-
let entries = [];
|
|
627
|
-
try {
|
|
628
|
-
entries = readdirSync(dir);
|
|
629
|
-
} catch {
|
|
630
|
-
entries = [];
|
|
631
|
-
}
|
|
632
|
-
for (const name of entries) {
|
|
633
|
-
if (keep.has(name)) continue;
|
|
634
|
-
try {
|
|
635
|
-
rmSync(join(dir, name), { recursive: true, force: true });
|
|
636
|
-
result.removed.push(name);
|
|
637
|
-
} catch {
|
|
638
|
-
/* best-effort; the next sync tries again */
|
|
639
|
-
}
|
|
640
|
-
}
|
|
641
|
-
return result;
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
/**
|
|
645
|
-
* THE KEPT LIBRARY'S HALF OF A SYNC: each item into its subdirectory (fetched
|
|
646
|
-
* only when its sha differs, written atomically, the bytes checked against the
|
|
647
|
-
* manifest), every other entry in the two subdirectories removed, and
|
|
648
|
-
* `LIBRARY.md` regenerated from the items that are on disk. A download that
|
|
649
|
-
* fails keeps whatever copy was there and clears `ok`, the file rule; an item
|
|
650
|
-
* over the cap is refused for this rev and left out of the catalog.
|
|
651
|
-
*/
|
|
652
|
-
async function syncLibrary({ dir, items, fetchFile, maxBytes, keep, result }) {
|
|
653
|
-
const wanted = { designs: new Set(), research: new Set() };
|
|
654
|
-
const onDisk = [];
|
|
655
|
-
for (const kind of Object.keys(LIBRARY_DIRS)) {
|
|
656
|
-
if (items.some((it) => it.kind === kind)) {
|
|
657
|
-
ensureSubdir(dir, LIBRARY_DIRS[kind]);
|
|
658
|
-
keep.add(LIBRARY_DIRS[kind]);
|
|
659
|
-
}
|
|
660
|
-
}
|
|
661
|
-
for (const it of items) {
|
|
662
|
-
const [sub, ...tail] = it.path.split('/');
|
|
663
|
-
const file = tail.join('/');
|
|
664
|
-
const path = join(dir, ...it.path.split('/'));
|
|
665
|
-
ensureLibraryParents(dir, it.path);
|
|
666
|
-
const itemMax = knowledgeMaxFor(it.path, maxBytes);
|
|
667
|
-
wanted[sub].add(file);
|
|
668
|
-
if (it.previewPath) wanted[sub].add(it.previewPath.split('/').slice(1).join('/'));
|
|
669
|
-
if (it.previewUnknown) {
|
|
670
|
-
const oldPng = file.includes('/') ? `${file.slice(0, file.lastIndexOf('/'))}/preview.png` : file.replace(/\.html$/, '.png');
|
|
671
|
-
if (localSha(join(dir, sub, ...oldPng.split('/'))) !== null) wanted[sub].add(oldPng);
|
|
672
|
-
}
|
|
673
|
-
if (Number(it.bytes) > itemMax) {
|
|
674
|
-
result.refused.push(it.path);
|
|
675
|
-
wanted[sub].delete(file);
|
|
676
|
-
continue;
|
|
677
|
-
}
|
|
678
|
-
if (typeof it.sha256 === 'string' && localSha(path) === it.sha256.toLowerCase()) {
|
|
679
|
-
onDisk.push(it);
|
|
680
|
-
} else {
|
|
681
|
-
try {
|
|
682
|
-
const buf = await fetchFile(it.id, { library: true });
|
|
683
|
-
if (!Buffer.isBuffer(buf) || buf.byteLength > itemMax) {
|
|
684
|
-
result.refused.push(it.path);
|
|
685
|
-
wanted[sub].delete(file);
|
|
686
|
-
continue;
|
|
687
|
-
}
|
|
688
|
-
if (typeof it.sha256 === 'string' && sha256Of(buf) !== it.sha256.toLowerCase()) {
|
|
689
|
-
throw new Error('sha256 mismatch');
|
|
690
|
-
}
|
|
691
|
-
ensureLibraryParents(dir, it.path);
|
|
692
|
-
writeAtomic(path, buf);
|
|
693
|
-
result.wrote.push(it.path);
|
|
694
|
-
onDisk.push(it);
|
|
695
|
-
} catch {
|
|
696
|
-
result.ok = false;
|
|
697
|
-
result.failed.push(it.path);
|
|
698
|
-
if (localSha(path) !== null) onDisk.push(it);
|
|
699
|
-
}
|
|
700
|
-
}
|
|
701
|
-
if (it.previewPath && it.preview?.sha256) {
|
|
702
|
-
ensureLibraryParents(dir, it.previewPath);
|
|
703
|
-
const previewFile = join(dir, ...it.previewPath.split('/'));
|
|
704
|
-
if (localSha(previewFile) !== it.preview.sha256.toLowerCase()) {
|
|
705
|
-
try {
|
|
706
|
-
const buf = await fetchFile(it.id, { library: true, preview: true });
|
|
707
|
-
if (!Buffer.isBuffer(buf) || buf.byteLength > 2 * 1024 * 1024 || sha256Of(buf) !== it.preview.sha256.toLowerCase()) throw new Error('preview mismatch');
|
|
708
|
-
ensureLibraryParents(dir, it.previewPath);
|
|
709
|
-
writeAtomic(previewFile, buf);
|
|
710
|
-
result.wrote.push(it.previewPath);
|
|
711
|
-
} catch {
|
|
712
|
-
result.ok = false;
|
|
713
|
-
result.failed.push(it.previewPath);
|
|
714
|
-
}
|
|
715
|
-
}
|
|
716
|
-
}
|
|
717
|
-
for (const f of it.files ?? []) {
|
|
718
|
-
ensureLibraryParents(dir, f.path);
|
|
719
|
-
const rel = f.path.split('/').slice(1).join('/');
|
|
720
|
-
wanted[sub].add(rel);
|
|
721
|
-
const filePath = join(dir, ...f.path.split('/'));
|
|
722
|
-
const fileMax = knowledgeMaxFor(f.path, maxBytes);
|
|
723
|
-
if (Number(f.bytes) > fileMax) { result.refused.push(f.path); wanted[sub].delete(rel); continue; }
|
|
724
|
-
if (localSha(filePath) === String(f.sha256).toLowerCase()) continue;
|
|
725
|
-
try {
|
|
726
|
-
const buf = await fetchFile(it.id, { library: true, fileIndex: f.index });
|
|
727
|
-
if (!Buffer.isBuffer(buf) || buf.byteLength > fileMax) { result.refused.push(f.path); wanted[sub].delete(rel); continue; }
|
|
728
|
-
if (sha256Of(buf) !== String(f.sha256).toLowerCase()) throw new Error('sha256 mismatch');
|
|
729
|
-
ensureLibraryParents(dir, f.path);
|
|
730
|
-
writeAtomic(filePath, buf);
|
|
731
|
-
result.wrote.push(f.path);
|
|
732
|
-
} catch { result.ok = false; result.failed.push(f.path); }
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
// Each subdirectory holds exactly what the manifest names.
|
|
736
|
-
for (const sub of Object.values(LIBRARY_DIRS)) {
|
|
737
|
-
const subPath = join(dir, sub);
|
|
738
|
-
try { if (!lstatSync(subPath).isDirectory()) continue; } catch { continue; }
|
|
739
|
-
sweepLibraryTree(dir, sub, wanted[sub], result);
|
|
740
|
-
// An emptied subdirectory leaves with its last item.
|
|
741
|
-
if (wanted[sub].size === 0) keep.delete(sub);
|
|
742
|
-
}
|
|
743
|
-
const catalog = renderLibraryCatalog(onDisk);
|
|
744
|
-
if (catalog) {
|
|
745
|
-
keep.add(LIBRARY_FILE);
|
|
746
|
-
const path = join(dir, LIBRARY_FILE);
|
|
747
|
-
let same = false;
|
|
748
|
-
try {
|
|
749
|
-
same = lstatSync(path).isFile() && readFileSync(path, 'utf8') === catalog;
|
|
750
|
-
} catch {
|
|
751
|
-
/* absent */
|
|
752
|
-
}
|
|
753
|
-
if (!same) {
|
|
754
|
-
writeAtomic(path, catalog);
|
|
755
|
-
result.wrote.push(LIBRARY_FILE);
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
/**
|
|
761
|
-
* The absolute library path, or null when there is nothing there for a turn
|
|
762
|
-
* to read. The prompt paragraph is rendered only when this is non-null, so a
|
|
763
|
-
* project with no library costs the prompt nothing.
|
|
764
|
-
*/
|
|
765
|
-
export function knowledgeDirFor(checkoutDir) {
|
|
766
|
-
if (!checkoutDir || !flowviantDirOk(checkoutDir)) return null;
|
|
767
|
-
const dir = join(checkoutDir, KNOWLEDGE_DIR);
|
|
768
|
-
try {
|
|
769
|
-
if (!lstatSync(dir).isDirectory()) return null;
|
|
770
|
-
return readdirSync(dir).length > 0 ? dir : null;
|
|
771
|
-
} catch {
|
|
772
|
-
return null;
|
|
773
|
-
}
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
/** Does the manifest name something the disk no longer has a directory for?
|
|
777
|
-
* An EMPTIED manifest over no directory is the synced state, not a loss. */
|
|
778
|
-
function libraryMissing(checkoutDir, manifest) {
|
|
779
|
-
const named =
|
|
780
|
-
(Array.isArray(manifest.files) && manifest.files.length > 0) ||
|
|
781
|
-
(typeof manifest.instructions === 'string' && manifest.instructions.trim() !== '') ||
|
|
782
|
-
(Array.isArray(manifest.library?.items) && manifest.library.items.length > 0);
|
|
783
|
-
if (!named) return false;
|
|
784
|
-
try {
|
|
785
|
-
lstatSync(join(checkoutDir, KNOWLEDGE_DIR));
|
|
786
|
-
return false;
|
|
787
|
-
} catch {
|
|
788
|
-
return true;
|
|
789
|
-
}
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
/**
|
|
793
|
-
* Does the manifest name LIBRARY items the disk no longer holds — or a library
|
|
794
|
-
* this box never synced at all? The same-rev skip's second question, beside
|
|
795
|
-
* `libraryMissing`'s "is the directory there".
|
|
796
|
-
*
|
|
797
|
-
* `synced` is the marker's `:lib` (or the driver's memory of it): false means
|
|
798
|
-
* the rev on the marker was written by a sync that ignored the library, the
|
|
799
|
-
* 0.96.0-to-0.97.0 upgrade. Otherwise every named item's path AND `LIBRARY.md`
|
|
800
|
-
* must be regular files — a hand-deleted `designs/`, or one item removed, is a
|
|
801
|
-
* re-sync, and the files still on disk match by hash and are not fetched
|
|
802
|
-
* again. Items the manifest itself puts over the cap, and items this driver
|
|
803
|
-
* already saw refused for this rev, are not expected: a refusal is permanent
|
|
804
|
-
* for the rev, and expecting it would re-fetch ten megabytes every poll.
|
|
805
|
-
* An ABSENT `library` key expects nothing (an older server has no say).
|
|
806
|
-
*/
|
|
807
|
-
function libraryStale(checkoutDir, manifest, { synced, refused }) {
|
|
808
|
-
const items = libraryItemsOf(manifest);
|
|
809
|
-
if (items === null) return false;
|
|
810
|
-
if (!synced) return true;
|
|
811
|
-
const expected = items.filter((it) => !(Number(it.bytes) > knowledgeMaxFor(it.path, KNOWLEDGE_FILE_MAX_BYTES)) && !refused.has(it.path));
|
|
812
|
-
if (expected.length === 0) return false;
|
|
813
|
-
const dir = join(checkoutDir, KNOWLEDGE_DIR);
|
|
814
|
-
const isFile = (p) => {
|
|
815
|
-
try {
|
|
816
|
-
let parent = dir;
|
|
817
|
-
for (const part of p.slice(dir.length + 1).split('/').slice(0, -1)) {
|
|
818
|
-
parent = join(parent, part);
|
|
819
|
-
const st = lstatSync(parent);
|
|
820
|
-
if (st.isSymbolicLink() || !st.isDirectory()) return false;
|
|
821
|
-
}
|
|
822
|
-
return lstatSync(p).isFile();
|
|
823
|
-
} catch {
|
|
824
|
-
return false;
|
|
825
|
-
}
|
|
826
|
-
};
|
|
827
|
-
if (!isFile(join(dir, LIBRARY_FILE))) return true;
|
|
828
|
-
return expected.some((it) => !isFile(join(dir, ...it.path.split('/'))) ||
|
|
829
|
-
(it.previewPath && !refused.has(it.previewPath) && !isFile(join(dir, ...it.previewPath.split('/')))) ||
|
|
830
|
-
(it.files ?? []).some((f) => !refused.has(f.path) && !isFile(join(dir, ...f.path.split('/')))));
|
|
831
|
-
}
|
|
70
|
+
knowledgeMarkerHasLibrary,
|
|
71
|
+
libraryMissing,
|
|
72
|
+
libraryStale,
|
|
73
|
+
readKnowledgeMarker,
|
|
74
|
+
syncKnowledge,
|
|
75
|
+
writeKnowledgeMarker,
|
|
76
|
+
} from './knowledgeFiles.mjs';
|
|
832
77
|
|
|
833
78
|
/**
|
|
834
79
|
* The per-process driver: called with every roster's `knowledge` key.
|
|
@@ -938,31 +183,3 @@ export function createKnowledgeSync({
|
|
|
938
183
|
},
|
|
939
184
|
};
|
|
940
185
|
}
|
|
941
|
-
|
|
942
|
-
/**
|
|
943
|
-
* The `/fleet/knowledge/:id` download, in the attachment fetch's own shape:
|
|
944
|
-
* the machine credential as a bearer, a 60s ceiling, and the size checked on
|
|
945
|
-
* the header AND on the bytes (a lying header must not decide the cap). The
|
|
946
|
-
* URL is derived from the roster URL the way every `/fleet/*` path is, so a
|
|
947
|
-
* self-hosted `FLOWVIANT_FLEET_URL` is honoured.
|
|
948
|
-
*/
|
|
949
|
-
export function knowledgeFetcher({ fleetUrl, token, userAgent, maxBytes = KNOWLEDGE_BINARY_MODEL_MAX_BYTES }) {
|
|
950
|
-
const base = String(fleetUrl).replace(/\/agents\/?$/, '/knowledge');
|
|
951
|
-
// A kept library item (0.97.0) is its sibling, `GET /fleet/library/:id` —
|
|
952
|
-
// the same credential, the same shape, the same caps.
|
|
953
|
-
const libraryBase = String(fleetUrl).replace(/\/agents\/?$/, '/library');
|
|
954
|
-
return async (id, { library = false, preview = false, fileIndex = null } = {}) => {
|
|
955
|
-
const suffix = preview ? '/preview' : Number.isSafeInteger(fileIndex) && fileIndex >= 0 ? `/file/${fileIndex}` : '';
|
|
956
|
-
const res = await fetch(`${library ? libraryBase : base}/${encodeURIComponent(id)}${suffix}`, {
|
|
957
|
-
headers: { Authorization: `Bearer ${token}`, 'User-Agent': userAgent },
|
|
958
|
-
signal: AbortSignal.timeout(60_000),
|
|
959
|
-
});
|
|
960
|
-
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
961
|
-
if (Number(res.headers.get('content-length') ?? '0') > maxBytes) {
|
|
962
|
-
// Returned rather than thrown: over the cap is a REFUSAL, permanent for
|
|
963
|
-
// this rev, not a failure the backoff should keep retrying.
|
|
964
|
-
return Buffer.alloc(maxBytes + 1);
|
|
965
|
-
}
|
|
966
|
-
return Buffer.from(await res.arrayBuffer());
|
|
967
|
-
};
|
|
968
|
-
}
|