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
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE KNOWLEDGE DOWNLOAD (2026-09-26, split out of knowledge.mjs — SOLID SRP,
|
|
3
|
+
* F049): the one place the daemon spells the `/fleet/knowledge/:id` and
|
|
4
|
+
* `/fleet/library/:id` requests. The sync takes the bytes through an injected
|
|
5
|
+
* `fetchFile`, so the transport changes here without touching the disk rules
|
|
6
|
+
* (knowledgeFiles.mjs) or the roster driver (knowledge.mjs).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { fleetEndpoint } from './fleetWire.mjs';
|
|
10
|
+
import { KNOWLEDGE_BINARY_MODEL_MAX_BYTES } from './knowledgeLibrary.mjs';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The `/fleet/knowledge/:id` download, in the attachment fetch's own shape:
|
|
14
|
+
* the machine credential as a bearer, a 60s ceiling, and the size checked on
|
|
15
|
+
* the header AND on the bytes (a lying header must not decide the cap). The
|
|
16
|
+
* URL is derived from the roster URL the way every `/fleet/*` path is, so a
|
|
17
|
+
* self-hosted `FLOWVIANT_FLEET_URL` is honoured.
|
|
18
|
+
*/
|
|
19
|
+
export function knowledgeFetcher({ fleetUrl, token, userAgent, maxBytes = KNOWLEDGE_BINARY_MODEL_MAX_BYTES }) {
|
|
20
|
+
const base = fleetEndpoint('knowledge', fleetUrl);
|
|
21
|
+
// A kept library item (0.97.0) is its sibling, `GET /fleet/library/:id` —
|
|
22
|
+
// the same credential, the same shape, the same caps.
|
|
23
|
+
const libraryBase = fleetEndpoint('library', fleetUrl);
|
|
24
|
+
return async (id, { library = false, preview = false, fileIndex = null } = {}) => {
|
|
25
|
+
const suffix = preview ? '/preview' : Number.isSafeInteger(fileIndex) && fileIndex >= 0 ? `/file/${fileIndex}` : '';
|
|
26
|
+
const res = await fetch(`${library ? libraryBase : base}/${encodeURIComponent(id)}${suffix}`, {
|
|
27
|
+
headers: { Authorization: `Bearer ${token}`, 'User-Agent': userAgent },
|
|
28
|
+
signal: AbortSignal.timeout(60_000),
|
|
29
|
+
});
|
|
30
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
31
|
+
if (Number(res.headers.get('content-length') ?? '0') > maxBytes) {
|
|
32
|
+
// Returned rather than thrown: over the cap is a REFUSAL, permanent for
|
|
33
|
+
// this rev, not a failure the backoff should keep retrying.
|
|
34
|
+
return Buffer.alloc(maxBytes + 1);
|
|
35
|
+
}
|
|
36
|
+
return Buffer.from(await res.arrayBuffer());
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ONE VERIFIED FILE, FETCHED ONLY WHEN IT DIFFERS (2026-09-26, split out of
|
|
3
|
+
* knowledge.mjs — SOLID F003).
|
|
4
|
+
*
|
|
5
|
+
* The knowledge sync downloads four kinds of file — shelf entries, kept
|
|
6
|
+
* library entries, their previews and a design bundle's sidecar files — and it
|
|
7
|
+
* had four hand-written download-and-verify loops that disagreed about the one
|
|
8
|
+
* question the catalog asks: IS THIS FILE THE ONE THE MANIFEST NAMES? The kept
|
|
9
|
+
* entry's loop, on a failed download, catalogued any file that happened to be
|
|
10
|
+
* at the path (`localSha(path) !== null`) without matching it to the manifest
|
|
11
|
+
* hash — so `LIBRARY.md` could advertise a stale entry, or a design whose
|
|
12
|
+
* bundle files never landed, as current.
|
|
13
|
+
*
|
|
14
|
+
* `syncVerifiedFile` is the one loop, and its answer is a STATE rather than a
|
|
15
|
+
* boolean, so the caller cannot collapse the difference:
|
|
16
|
+
*
|
|
17
|
+
* current — the file on disk already matches the manifest hash; not fetched
|
|
18
|
+
* wrote — fetched, verified against the hash, written atomically
|
|
19
|
+
* refused — over its cap (declared or delivered): permanent for this rev
|
|
20
|
+
* stale — the fetch failed and an OLDER copy stays on disk (a stale file
|
|
21
|
+
* is better than a missing one until the retry lands — but it is
|
|
22
|
+
* not the manifest's file, and is never catalogued as one)
|
|
23
|
+
* failed — the fetch failed and nothing is on disk
|
|
24
|
+
*
|
|
25
|
+
* The retry semantics are the callers' as before: `stale`/`failed` clear the
|
|
26
|
+
* sync's `ok` and the driver backs off and tries again. A missing manifest
|
|
27
|
+
* hash cannot be `current` (nothing to match), so such a file is always
|
|
28
|
+
* fetched, exactly as the shelf always did.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { lstatSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
32
|
+
import { createHash } from 'node:crypto';
|
|
33
|
+
|
|
34
|
+
export const sha256Of = (buf) => createHash('sha256').update(buf).digest('hex');
|
|
35
|
+
|
|
36
|
+
/** A regular file's sha256, or null for anything else (missing, a directory, a
|
|
37
|
+
* symlink — a symlink is never followed, so it is never "the same file"). */
|
|
38
|
+
export function localSha(path) {
|
|
39
|
+
try {
|
|
40
|
+
const st = lstatSync(path);
|
|
41
|
+
if (!st.isFile()) return null;
|
|
42
|
+
return sha256Of(readFileSync(path));
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Write through a temp name and rename, so a CLI reading the library mid-sync
|
|
49
|
+
* sees the old file or the new one, never half of one. Anything already at
|
|
50
|
+
* the path that is NOT a regular file (a symlink planted there) is removed
|
|
51
|
+
* first — `writeFileSync` would otherwise follow it and write wherever it
|
|
52
|
+
* pointed. */
|
|
53
|
+
export function writeAtomic(path, data) {
|
|
54
|
+
try {
|
|
55
|
+
const st = lstatSync(path);
|
|
56
|
+
if (!st.isFile()) rmSync(path, { recursive: true, force: true });
|
|
57
|
+
} catch {
|
|
58
|
+
/* absent — the ordinary case */
|
|
59
|
+
}
|
|
60
|
+
const tmp = `${path}.fvtmp`;
|
|
61
|
+
// THE TEMP NAME IS CHECKED TOO (2026-09-23). The first cut guarded the
|
|
62
|
+
// destination and then wrote straight through `<name>.fvtmp` — a link planted
|
|
63
|
+
// THERE would have been followed, the library's bytes written wherever it
|
|
64
|
+
// pointed, and the link renamed into place. Unlinked first, then created
|
|
65
|
+
// EXCLUSIVELY (`wx`): if anything reappears at the name between the two, the
|
|
66
|
+
// write throws into the caller's catch rather than following it.
|
|
67
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
68
|
+
writeFileSync(tmp, data, { flag: 'wx' });
|
|
69
|
+
renameSync(tmp, path);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A state the manifest's file is on disk in — the only states a catalog may
|
|
73
|
+
* list. */
|
|
74
|
+
export const isCurrent = (state) => state === 'current' || state === 'wrote';
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Make `path` hold the manifest's file.
|
|
78
|
+
*
|
|
79
|
+
* @param {object} o
|
|
80
|
+
* @param {string} o.path where it lives
|
|
81
|
+
* @param {string|undefined} o.sha256 the manifest hash; absent = always fetch
|
|
82
|
+
* @param {number|undefined} o.bytes the manifest's declared size; over `maxBytes` refuses unfetched
|
|
83
|
+
* @param {number} o.maxBytes the ceiling for this file
|
|
84
|
+
* @param {() => Promise<Buffer>} o.fetch the bytes, or a throw
|
|
85
|
+
* @param {boolean} [o.oversizeFails] an over-cap DELIVERY is a failure to retry
|
|
86
|
+
* rather than a refusal (a preview: its cap is ours, not the manifest's)
|
|
87
|
+
* @param {() => void} [o.beforeWrite] makes the parent directories real, right before the write
|
|
88
|
+
* @returns {Promise<'current'|'wrote'|'refused'|'stale'|'failed'>}
|
|
89
|
+
*/
|
|
90
|
+
export async function syncVerifiedFile({ path, sha256, bytes, maxBytes, fetch, oversizeFails = false, beforeWrite = () => {} }) {
|
|
91
|
+
if (Number(bytes) > maxBytes) return 'refused';
|
|
92
|
+
const want = typeof sha256 === 'string' ? sha256.toLowerCase() : null;
|
|
93
|
+
if (want !== null && localSha(path) === want) return 'current';
|
|
94
|
+
try {
|
|
95
|
+
const buf = await fetch();
|
|
96
|
+
if (!Buffer.isBuffer(buf) || buf.byteLength > maxBytes) {
|
|
97
|
+
if (oversizeFails) throw new Error('over the cap');
|
|
98
|
+
return 'refused';
|
|
99
|
+
}
|
|
100
|
+
// The bytes must BE the file the manifest names. A mismatch is a failure
|
|
101
|
+
// to retry, never a file to trust. Keyed on the TYPE, not truthiness: an
|
|
102
|
+
// empty-string hash is a hash no bytes match, so it fails as every copy
|
|
103
|
+
// this replaced did — only an ABSENT hash skips the check.
|
|
104
|
+
if (want !== null && sha256Of(buf) !== want) throw new Error('sha256 mismatch');
|
|
105
|
+
beforeWrite();
|
|
106
|
+
writeAtomic(path, buf);
|
|
107
|
+
return 'wrote';
|
|
108
|
+
} catch {
|
|
109
|
+
// Keep whatever copy is there — but say it is not the manifest's.
|
|
110
|
+
return localSha(path) !== null ? 'stale' : 'failed';
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE KNOWLEDGE LIBRARY ON DISK (2026-09-26, split out of knowledge.mjs —
|
|
3
|
+
* SOLID SRP, F049): making `<checkout>/.flowviant/knowledge/` match a manifest,
|
|
4
|
+
* the rev marker beside it, and the two questions the driver asks the disk
|
|
5
|
+
* before it skips a same-rev roster (`libraryMissing`, `libraryStale`). The
|
|
6
|
+
* bytes arrive through an injected `fetchFile`, so nothing here knows the
|
|
7
|
+
* transport; the names and paths are knowledgeLibrary.mjs's, and each file is
|
|
8
|
+
* fetched-and-verified by knowledgeFileSync.mjs's one loop. Why a sync does
|
|
9
|
+
* what it does — the three states of the key, the failed download that does
|
|
10
|
+
* not advance the rev — is argued in knowledge.mjs's header.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
existsSync,
|
|
15
|
+
lstatSync,
|
|
16
|
+
mkdirSync,
|
|
17
|
+
readdirSync,
|
|
18
|
+
readFileSync,
|
|
19
|
+
rmSync,
|
|
20
|
+
unlinkSync,
|
|
21
|
+
} from 'node:fs';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { isCurrent, localSha, syncVerifiedFile, writeAtomic } from './knowledgeFileSync.mjs';
|
|
24
|
+
import {
|
|
25
|
+
INSTRUCTIONS_FILE,
|
|
26
|
+
KNOWLEDGE_DIR,
|
|
27
|
+
KNOWLEDGE_FILE_MAX_BYTES,
|
|
28
|
+
LIBRARY_DIRS,
|
|
29
|
+
LIBRARY_FILE,
|
|
30
|
+
MAX_FILES,
|
|
31
|
+
knowledgeMaxFor,
|
|
32
|
+
libraryItemsOf,
|
|
33
|
+
planKnowledgeNames,
|
|
34
|
+
renderLibraryCatalog,
|
|
35
|
+
} from './knowledgeLibrary.mjs';
|
|
36
|
+
|
|
37
|
+
/** The rev last materialised — OUTSIDE the directory the prompt hands the CLI,
|
|
38
|
+
* so the agent listing its library sees only the library. */
|
|
39
|
+
const MARKER = '.flowviant/knowledge.rev';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Is `<checkout>/.flowviant` a real directory, or absent? Anything else — a
|
|
43
|
+
* symlink, a regular file — REFUSES the sync rather than being touched.
|
|
44
|
+
*
|
|
45
|
+
* `.flowviant/` is not only ours: a repo declares `.flowviant/check.json` and
|
|
46
|
+
* `.flowviant/deploy.json` there, and an operator may have arranged it however
|
|
47
|
+
* they like. Deleting a file or a link at that path to make room for a library
|
|
48
|
+
* would be Flowviant destroying something it did not create; following a link
|
|
49
|
+
* would land downloads, and the emptied library's `rm -r`, wherever it points.
|
|
50
|
+
* So the parent is checked, never repaired.
|
|
51
|
+
*/
|
|
52
|
+
function flowviantDirOk(checkoutDir) {
|
|
53
|
+
try {
|
|
54
|
+
const st = lstatSync(join(checkoutDir, '.flowviant'));
|
|
55
|
+
return st.isDirectory() && !st.isSymbolicLink();
|
|
56
|
+
} catch {
|
|
57
|
+
return true; // absent — ours to create
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The library directory, as a real directory. The PARENT is checked by the
|
|
62
|
+
* caller; the library path itself is OURS, so a symlink or a file planted
|
|
63
|
+
* there is removed (unlinked — the link, never its target) rather than
|
|
64
|
+
* followed. */
|
|
65
|
+
function ensureDir(checkoutDir) {
|
|
66
|
+
const dir = join(checkoutDir, KNOWLEDGE_DIR);
|
|
67
|
+
try {
|
|
68
|
+
const st = lstatSync(dir);
|
|
69
|
+
if (st.isSymbolicLink() || !st.isDirectory()) unlinkSync(dir);
|
|
70
|
+
} catch {
|
|
71
|
+
/* absent */
|
|
72
|
+
}
|
|
73
|
+
mkdirSync(dir, { recursive: true });
|
|
74
|
+
return dir;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* THE MARKER SAYS WHICH REV WAS WRITTEN, AND WHETHER THE LIBRARY WAS PART OF
|
|
79
|
+
* IT (2026-09-23): `<rev>` or `<rev>:lib`.
|
|
80
|
+
*
|
|
81
|
+
* The rev alone could not tell an upgrade from a sync. A 0.96.0 box wrote the
|
|
82
|
+
* marker for rev N while ignoring the manifest's `library` key (it did not
|
|
83
|
+
* know one); updated to 0.97.0 with `knowledge_rev` still N, it read "N",
|
|
84
|
+
* matched the roster, and never synced the library — then the server handed
|
|
85
|
+
* `task.references` naming `designs/x-v1.html` and the agent was told to read
|
|
86
|
+
* a file that was not there. `:lib` records that THIS rev's sync carried a
|
|
87
|
+
* library key, so the upgrade case is one string compare and re-syncs at
|
|
88
|
+
* once. An older daemon reading `N:lib` sees NaN and re-syncs once, which is
|
|
89
|
+
* the harmless direction.
|
|
90
|
+
*/
|
|
91
|
+
function parseMarker(checkoutDir) {
|
|
92
|
+
try {
|
|
93
|
+
const m = /^(\d+)(:lib)?$/.exec(readFileSync(join(checkoutDir, MARKER), 'utf8').trim());
|
|
94
|
+
if (!m) return null;
|
|
95
|
+
const n = Number(m[1]);
|
|
96
|
+
return Number.isSafeInteger(n) ? { rev: n, lib: Boolean(m[2]) } : null;
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function readKnowledgeMarker(checkoutDir) {
|
|
103
|
+
return parseMarker(checkoutDir)?.rev ?? null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Did the sync that wrote the marker carry a `library` key? */
|
|
107
|
+
export function knowledgeMarkerHasLibrary(checkoutDir) {
|
|
108
|
+
return parseMarker(checkoutDir)?.lib ?? false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function writeKnowledgeMarker(checkoutDir, rev, lib = false) {
|
|
112
|
+
try {
|
|
113
|
+
mkdirSync(join(checkoutDir, '.flowviant'), { recursive: true });
|
|
114
|
+
writeAtomic(join(checkoutDir, MARKER), `${rev}${lib ? ':lib' : ''}\n`);
|
|
115
|
+
} catch {
|
|
116
|
+
/* a lost marker costs one re-sync after a restart — every file already on
|
|
117
|
+
disk matches by sha and is not downloaded again */
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** A library subdirectory as a real directory — ours, like the library's own
|
|
122
|
+
* path: a symlink or a file planted there is removed, never followed. */
|
|
123
|
+
function ensureSubdir(dir, name) {
|
|
124
|
+
const path = join(dir, name);
|
|
125
|
+
try {
|
|
126
|
+
const st = lstatSync(path);
|
|
127
|
+
if (st.isSymbolicLink() || !st.isDirectory()) unlinkSync(path);
|
|
128
|
+
} catch {
|
|
129
|
+
/* absent */
|
|
130
|
+
}
|
|
131
|
+
mkdirSync(path, { recursive: true });
|
|
132
|
+
return path;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function ensureLibraryParents(dir, relativePath) {
|
|
136
|
+
const parts = relativePath.split('/');
|
|
137
|
+
let at = dir;
|
|
138
|
+
for (const part of parts.slice(0, -1)) at = ensureSubdir(at, part);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function sweepLibraryTree(dir, sub, wanted, result) {
|
|
142
|
+
const walk = (at, prefix = '') => {
|
|
143
|
+
let entries;
|
|
144
|
+
try { entries = readdirSync(at); } catch { return; }
|
|
145
|
+
for (const name of entries) {
|
|
146
|
+
const rel = prefix ? `${prefix}/${name}` : name;
|
|
147
|
+
const path = join(at, name);
|
|
148
|
+
let st;
|
|
149
|
+
try { st = lstatSync(path); } catch { continue; }
|
|
150
|
+
if (st.isDirectory() && !st.isSymbolicLink()) {
|
|
151
|
+
walk(path, rel);
|
|
152
|
+
try { if (readdirSync(path).length === 0) rmSync(path, { recursive: true, force: true }); } catch { /* next sync */ }
|
|
153
|
+
} else if (!wanted.has(rel)) {
|
|
154
|
+
try { rmSync(path, { recursive: true, force: true }); result.removed.push(`${sub}/${rel}`); } catch { /* next sync */ }
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
walk(join(dir, sub));
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Make `<checkoutDir>/.flowviant/knowledge/` match `manifest`.
|
|
163
|
+
*
|
|
164
|
+
* `fetchFile(id)` returns the file's bytes as a Buffer, or throws. Injected so
|
|
165
|
+
* the sync is testable against a temp directory and a fake server; the daemon
|
|
166
|
+
* hands the `/fleet/knowledge/:id` fetch.
|
|
167
|
+
*
|
|
168
|
+
* @returns {{ ok: boolean, wrote: string[], removed: string[], refused: string[], failed: string[] }}
|
|
169
|
+
* `ok` is false when any download FAILED (to be retried); a file REFUSED for
|
|
170
|
+
* size is permanent for this rev and does not clear `ok`.
|
|
171
|
+
*/
|
|
172
|
+
export async function syncKnowledge({ checkoutDir, manifest, fetchFile, maxBytes = KNOWLEDGE_FILE_MAX_BYTES }) {
|
|
173
|
+
const result = { ok: true, wrote: [], removed: [], refused: [], failed: [] };
|
|
174
|
+
const dirPath = join(checkoutDir, KNOWLEDGE_DIR);
|
|
175
|
+
const files = (manifest.files ?? [])
|
|
176
|
+
.filter((f) => f && typeof f.id === 'string' && /^[0-9a-f-]{8,64}$/i.test(f.id))
|
|
177
|
+
.slice(0, MAX_FILES);
|
|
178
|
+
const instructions =
|
|
179
|
+
typeof manifest.instructions === 'string' && manifest.instructions.trim()
|
|
180
|
+
? manifest.instructions
|
|
181
|
+
: null;
|
|
182
|
+
// null = the key is ABSENT (leave the library's paths alone); [] = emptied.
|
|
183
|
+
const library = libraryItemsOf(manifest);
|
|
184
|
+
|
|
185
|
+
if (!flowviantDirOk(checkoutDir)) {
|
|
186
|
+
// Not a failure to retry every poll — nothing changes until a person
|
|
187
|
+
// changes it — but not a success either: the rev must not advance over a
|
|
188
|
+
// library that was never written.
|
|
189
|
+
result.ok = false;
|
|
190
|
+
result.failed.push('.flowviant (not a directory — left untouched)');
|
|
191
|
+
return result;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// EMPTIED: the directory goes, and the prompt paragraph with it — unless the
|
|
195
|
+
// library key is ABSENT and a synced library is on disk, which an older
|
|
196
|
+
// server's emptied shelf has no say over.
|
|
197
|
+
const libraryOnDisk =
|
|
198
|
+
library === null &&
|
|
199
|
+
[LIBRARY_FILE, ...Object.values(LIBRARY_DIRS)].some((n) => existsSync(join(dirPath, n)));
|
|
200
|
+
if (files.length === 0 && !instructions && (library === null ? !libraryOnDisk : library.length === 0)) {
|
|
201
|
+
if (existsSync(dirPath)) {
|
|
202
|
+
try {
|
|
203
|
+
const st = lstatSync(dirPath);
|
|
204
|
+
if (st.isSymbolicLink()) unlinkSync(dirPath);
|
|
205
|
+
else rmSync(dirPath, { recursive: true, force: true });
|
|
206
|
+
result.removed.push(KNOWLEDGE_DIR);
|
|
207
|
+
} catch {
|
|
208
|
+
result.ok = false;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return result;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const dir = ensureDir(checkoutDir);
|
|
215
|
+
const planned = planKnowledgeNames(files);
|
|
216
|
+
const keep = new Set(planned.map((p) => p.local));
|
|
217
|
+
if (instructions) keep.add(INSTRUCTIONS_FILE);
|
|
218
|
+
// ABSENT library key: its paths are neither synced nor swept.
|
|
219
|
+
if (library === null) {
|
|
220
|
+
keep.add(LIBRARY_FILE);
|
|
221
|
+
for (const d of Object.values(LIBRARY_DIRS)) keep.add(d);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
for (const f of planned) {
|
|
225
|
+
// One verified-file rule for every kind the sync fetches — see
|
|
226
|
+
// knowledgeFileSync.mjs for the five states.
|
|
227
|
+
const state = await syncVerifiedFile({
|
|
228
|
+
path: join(dir, f.local),
|
|
229
|
+
sha256: f.sha256,
|
|
230
|
+
bytes: f.bytes,
|
|
231
|
+
maxBytes: knowledgeMaxFor(f.name, maxBytes),
|
|
232
|
+
fetch: () => fetchFile(f.id),
|
|
233
|
+
});
|
|
234
|
+
if (state === 'refused') {
|
|
235
|
+
// Permanent for this rev, and NOT a reason to delete the rest. Any stale
|
|
236
|
+
// local copy under this name goes, because it is not the file the
|
|
237
|
+
// manifest now names.
|
|
238
|
+
result.refused.push(f.local);
|
|
239
|
+
keep.delete(f.local);
|
|
240
|
+
} else if (state === 'wrote') {
|
|
241
|
+
result.wrote.push(f.local);
|
|
242
|
+
} else if (!isCurrent(state)) {
|
|
243
|
+
// A stale file is better than a missing one until the retry lands.
|
|
244
|
+
result.ok = false;
|
|
245
|
+
result.failed.push(f.local);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (instructions) {
|
|
250
|
+
const path = join(dir, INSTRUCTIONS_FILE);
|
|
251
|
+
const body = instructions.endsWith('\n') ? instructions : `${instructions}\n`;
|
|
252
|
+
let same = false;
|
|
253
|
+
try {
|
|
254
|
+
same = lstatSync(path).isFile() && readFileSync(path, 'utf8') === body;
|
|
255
|
+
} catch {
|
|
256
|
+
/* absent */
|
|
257
|
+
}
|
|
258
|
+
if (!same) {
|
|
259
|
+
writeAtomic(path, body);
|
|
260
|
+
result.wrote.push(INSTRUCTIONS_FILE);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (library !== null) {
|
|
265
|
+
await syncLibrary({ dir, items: library, fetchFile, maxBytes, keep, result });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Everything the manifest no longer names — files, stray temp names, and
|
|
269
|
+
// anything that is not a regular file — leaves.
|
|
270
|
+
let entries = [];
|
|
271
|
+
try {
|
|
272
|
+
entries = readdirSync(dir);
|
|
273
|
+
} catch {
|
|
274
|
+
entries = [];
|
|
275
|
+
}
|
|
276
|
+
for (const name of entries) {
|
|
277
|
+
if (keep.has(name)) continue;
|
|
278
|
+
try {
|
|
279
|
+
rmSync(join(dir, name), { recursive: true, force: true });
|
|
280
|
+
result.removed.push(name);
|
|
281
|
+
} catch {
|
|
282
|
+
/* best-effort; the next sync tries again */
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return result;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* THE KEPT LIBRARY'S HALF OF A SYNC: each item into its subdirectory (fetched
|
|
290
|
+
* only when its sha differs, written atomically, the bytes checked against the
|
|
291
|
+
* manifest), every other entry in the two subdirectories removed, and
|
|
292
|
+
* `LIBRARY.md` regenerated from the items that are on disk AS THE MANIFEST
|
|
293
|
+
* NAMES THEM — the entry and every bundle file current (`isCurrent`,
|
|
294
|
+
* knowledgeFileSync.mjs). A download that fails keeps whatever copy was there
|
|
295
|
+
* and clears `ok`, the file rule, but that stale copy is not catalogued; an
|
|
296
|
+
* item over the cap is refused for this rev and left out of the catalog.
|
|
297
|
+
*/
|
|
298
|
+
async function syncLibrary({ dir, items, fetchFile, maxBytes, keep, result }) {
|
|
299
|
+
const wanted = { designs: new Set(), research: new Set() };
|
|
300
|
+
const onDisk = [];
|
|
301
|
+
for (const kind of Object.keys(LIBRARY_DIRS)) {
|
|
302
|
+
if (items.some((it) => it.kind === kind)) {
|
|
303
|
+
ensureSubdir(dir, LIBRARY_DIRS[kind]);
|
|
304
|
+
keep.add(LIBRARY_DIRS[kind]);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
for (const it of items) {
|
|
308
|
+
const [sub, ...tail] = it.path.split('/');
|
|
309
|
+
const file = tail.join('/');
|
|
310
|
+
const path = join(dir, ...it.path.split('/'));
|
|
311
|
+
ensureLibraryParents(dir, it.path);
|
|
312
|
+
const itemMax = knowledgeMaxFor(it.path, maxBytes);
|
|
313
|
+
wanted[sub].add(file);
|
|
314
|
+
if (it.previewPath) wanted[sub].add(it.previewPath.split('/').slice(1).join('/'));
|
|
315
|
+
if (it.previewUnknown) {
|
|
316
|
+
const oldPng = file.includes('/') ? `${file.slice(0, file.lastIndexOf('/'))}/preview.png` : file.replace(/\.html$/, '.png');
|
|
317
|
+
if (localSha(join(dir, sub, ...oldPng.split('/'))) !== null) wanted[sub].add(oldPng);
|
|
318
|
+
}
|
|
319
|
+
// THE ENTRY. Catalogued only when it IS the manifest's file — a stale
|
|
320
|
+
// copy kept through a failed fetch stays on disk, but is not listed as
|
|
321
|
+
// current (the defect this split fixed: any file at the path was).
|
|
322
|
+
const entryState = await syncVerifiedFile({
|
|
323
|
+
path,
|
|
324
|
+
sha256: it.sha256,
|
|
325
|
+
bytes: it.bytes,
|
|
326
|
+
maxBytes: itemMax,
|
|
327
|
+
fetch: () => fetchFile(it.id, { library: true }),
|
|
328
|
+
beforeWrite: () => ensureLibraryParents(dir, it.path),
|
|
329
|
+
});
|
|
330
|
+
if (entryState === 'refused') {
|
|
331
|
+
result.refused.push(it.path);
|
|
332
|
+
wanted[sub].delete(file);
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
if (entryState === 'wrote') result.wrote.push(it.path);
|
|
336
|
+
else if (!isCurrent(entryState)) {
|
|
337
|
+
result.ok = false;
|
|
338
|
+
result.failed.push(it.path);
|
|
339
|
+
}
|
|
340
|
+
let complete = isCurrent(entryState);
|
|
341
|
+
// THE PREVIEW: measured by the server, ours to cap — an oversized one is
|
|
342
|
+
// a failure to retry, never a refusal. Not required for the catalog: the
|
|
343
|
+
// entry reads the same without its picture.
|
|
344
|
+
if (it.previewPath && it.preview?.sha256) {
|
|
345
|
+
ensureLibraryParents(dir, it.previewPath);
|
|
346
|
+
const previewState = await syncVerifiedFile({
|
|
347
|
+
path: join(dir, ...it.previewPath.split('/')),
|
|
348
|
+
sha256: it.preview.sha256,
|
|
349
|
+
maxBytes: 2 * 1024 * 1024,
|
|
350
|
+
oversizeFails: true,
|
|
351
|
+
fetch: () => fetchFile(it.id, { library: true, preview: true }),
|
|
352
|
+
beforeWrite: () => ensureLibraryParents(dir, it.previewPath),
|
|
353
|
+
});
|
|
354
|
+
if (previewState === 'wrote') result.wrote.push(it.previewPath);
|
|
355
|
+
else if (!isCurrent(previewState)) {
|
|
356
|
+
result.ok = false;
|
|
357
|
+
result.failed.push(it.previewPath);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
// THE BUNDLE'S SIDECARS are REQUIRED: a design whose model failed to land
|
|
361
|
+
// is not a complete current design, so it is not catalogued as one. The hash
|
|
362
|
+
// is always compared (`String(...)`), so a manifest file without one fails
|
|
363
|
+
// verification, as it always did.
|
|
364
|
+
for (const f of it.files ?? []) {
|
|
365
|
+
ensureLibraryParents(dir, f.path);
|
|
366
|
+
const rel = f.path.split('/').slice(1).join('/');
|
|
367
|
+
wanted[sub].add(rel);
|
|
368
|
+
const fileState = await syncVerifiedFile({
|
|
369
|
+
path: join(dir, ...f.path.split('/')),
|
|
370
|
+
sha256: String(f.sha256),
|
|
371
|
+
bytes: f.bytes,
|
|
372
|
+
maxBytes: knowledgeMaxFor(f.path, maxBytes),
|
|
373
|
+
fetch: () => fetchFile(it.id, { library: true, fileIndex: f.index }),
|
|
374
|
+
beforeWrite: () => ensureLibraryParents(dir, f.path),
|
|
375
|
+
});
|
|
376
|
+
if (fileState === 'refused') {
|
|
377
|
+
result.refused.push(f.path);
|
|
378
|
+
wanted[sub].delete(rel);
|
|
379
|
+
} else if (fileState === 'wrote') result.wrote.push(f.path);
|
|
380
|
+
else if (!isCurrent(fileState)) {
|
|
381
|
+
result.ok = false;
|
|
382
|
+
result.failed.push(f.path);
|
|
383
|
+
}
|
|
384
|
+
// A REFUSED sidecar is permanent for this rev and said in `refused`, the
|
|
385
|
+
// way `libraryStale` already stops expecting it — blocking the catalog on
|
|
386
|
+
// it would leave LIBRARY.md missing and re-sync the rev every poll.
|
|
387
|
+
if (fileState === 'stale' || fileState === 'failed') complete = false;
|
|
388
|
+
}
|
|
389
|
+
if (complete) onDisk.push(it);
|
|
390
|
+
}
|
|
391
|
+
// Each subdirectory holds exactly what the manifest names.
|
|
392
|
+
for (const sub of Object.values(LIBRARY_DIRS)) {
|
|
393
|
+
const subPath = join(dir, sub);
|
|
394
|
+
try { if (!lstatSync(subPath).isDirectory()) continue; } catch { continue; }
|
|
395
|
+
sweepLibraryTree(dir, sub, wanted[sub], result);
|
|
396
|
+
// An emptied subdirectory leaves with its last item.
|
|
397
|
+
if (wanted[sub].size === 0) keep.delete(sub);
|
|
398
|
+
}
|
|
399
|
+
const catalog = renderLibraryCatalog(onDisk);
|
|
400
|
+
if (catalog) {
|
|
401
|
+
keep.add(LIBRARY_FILE);
|
|
402
|
+
const path = join(dir, LIBRARY_FILE);
|
|
403
|
+
let same = false;
|
|
404
|
+
try {
|
|
405
|
+
same = lstatSync(path).isFile() && readFileSync(path, 'utf8') === catalog;
|
|
406
|
+
} catch {
|
|
407
|
+
/* absent */
|
|
408
|
+
}
|
|
409
|
+
if (!same) {
|
|
410
|
+
writeAtomic(path, catalog);
|
|
411
|
+
result.wrote.push(LIBRARY_FILE);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* The absolute library path, or null when there is nothing there for a turn
|
|
418
|
+
* to read. The prompt paragraph is rendered only when this is non-null, so a
|
|
419
|
+
* project with no library costs the prompt nothing.
|
|
420
|
+
*/
|
|
421
|
+
export function knowledgeDirFor(checkoutDir) {
|
|
422
|
+
if (!checkoutDir || !flowviantDirOk(checkoutDir)) return null;
|
|
423
|
+
const dir = join(checkoutDir, KNOWLEDGE_DIR);
|
|
424
|
+
try {
|
|
425
|
+
if (!lstatSync(dir).isDirectory()) return null;
|
|
426
|
+
return readdirSync(dir).length > 0 ? dir : null;
|
|
427
|
+
} catch {
|
|
428
|
+
return null;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Does the manifest name something the disk no longer has a directory for?
|
|
433
|
+
* An EMPTIED manifest over no directory is the synced state, not a loss. */
|
|
434
|
+
export function libraryMissing(checkoutDir, manifest) {
|
|
435
|
+
const named =
|
|
436
|
+
(Array.isArray(manifest.files) && manifest.files.length > 0) ||
|
|
437
|
+
(typeof manifest.instructions === 'string' && manifest.instructions.trim() !== '') ||
|
|
438
|
+
(Array.isArray(manifest.library?.items) && manifest.library.items.length > 0);
|
|
439
|
+
if (!named) return false;
|
|
440
|
+
try {
|
|
441
|
+
lstatSync(join(checkoutDir, KNOWLEDGE_DIR));
|
|
442
|
+
return false;
|
|
443
|
+
} catch {
|
|
444
|
+
return true;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Does the manifest name LIBRARY items the disk no longer holds — or a library
|
|
450
|
+
* this box never synced at all? The same-rev skip's second question, beside
|
|
451
|
+
* `libraryMissing`'s "is the directory there".
|
|
452
|
+
*
|
|
453
|
+
* `synced` is the marker's `:lib` (or the driver's memory of it): false means
|
|
454
|
+
* the rev on the marker was written by a sync that ignored the library, the
|
|
455
|
+
* 0.96.0-to-0.97.0 upgrade. Otherwise every named item's path AND `LIBRARY.md`
|
|
456
|
+
* must be regular files — a hand-deleted `designs/`, or one item removed, is a
|
|
457
|
+
* re-sync, and the files still on disk match by hash and are not fetched
|
|
458
|
+
* again. Items the manifest itself puts over the cap, and items this driver
|
|
459
|
+
* already saw refused for this rev, are not expected: a refusal is permanent
|
|
460
|
+
* for the rev, and expecting it would re-fetch ten megabytes every poll.
|
|
461
|
+
* An ABSENT `library` key expects nothing (an older server has no say).
|
|
462
|
+
*/
|
|
463
|
+
export function libraryStale(checkoutDir, manifest, { synced, refused }) {
|
|
464
|
+
const items = libraryItemsOf(manifest);
|
|
465
|
+
if (items === null) return false;
|
|
466
|
+
if (!synced) return true;
|
|
467
|
+
const expected = items.filter((it) => !(Number(it.bytes) > knowledgeMaxFor(it.path, KNOWLEDGE_FILE_MAX_BYTES)) && !refused.has(it.path));
|
|
468
|
+
if (expected.length === 0) return false;
|
|
469
|
+
const dir = join(checkoutDir, KNOWLEDGE_DIR);
|
|
470
|
+
const isFile = (p) => {
|
|
471
|
+
try {
|
|
472
|
+
let parent = dir;
|
|
473
|
+
for (const part of p.slice(dir.length + 1).split('/').slice(0, -1)) {
|
|
474
|
+
parent = join(parent, part);
|
|
475
|
+
const st = lstatSync(parent);
|
|
476
|
+
if (st.isSymbolicLink() || !st.isDirectory()) return false;
|
|
477
|
+
}
|
|
478
|
+
return lstatSync(p).isFile();
|
|
479
|
+
} catch {
|
|
480
|
+
return false;
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
if (!isFile(join(dir, LIBRARY_FILE))) return true;
|
|
484
|
+
return expected.some((it) => !isFile(join(dir, ...it.path.split('/'))) ||
|
|
485
|
+
(it.previewPath && !refused.has(it.previewPath) && !isFile(join(dir, ...it.previewPath.split('/')))) ||
|
|
486
|
+
(it.files ?? []).some((f) => !refused.has(f.path) && !isFile(join(dir, ...f.path.split('/')))));
|
|
487
|
+
}
|