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,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE KNOWLEDGE LIBRARY'S RULES, pure (2026-09-26, split out of knowledge.mjs —
|
|
3
|
+
* SOLID SRP, F049): where every manifest entry may land, under what name, at
|
|
4
|
+
* what cap, and what the generated catalog says. No disk, no network — the
|
|
5
|
+
* materialisation (knowledgeFiles.mjs), the download (knowledgeFetch.mjs) and
|
|
6
|
+
* the roster driver (knowledge.mjs) all ask these, so a change to the bundle
|
|
7
|
+
* format or the naming rule is a change here and nowhere else.
|
|
8
|
+
*
|
|
9
|
+
* ── THE KEPT LIBRARY (2026-09-23, 0.97.0) ──
|
|
10
|
+
*
|
|
11
|
+
* The manifest may carry `library: { items: [...] }` — the designs and
|
|
12
|
+
* write-ups the project KEPT when a person accepted them — and each item lands
|
|
13
|
+
* at its own relative path, `designs/<slug>-v<N>.html` or
|
|
14
|
+
* `research/<slug>-v<N>.md`, with a generated `LIBRARY.md` at the root naming
|
|
15
|
+
* every one (path, title, kind, the card that asked for it, the date, what it
|
|
16
|
+
* supersedes). So "implement design A" is an agent opening LIBRARY.md, finding
|
|
17
|
+
* design A, and reading the page. At 0.99.0 a kept design is one folder,
|
|
18
|
+
* including its original relative sidecar paths and measured preview.
|
|
19
|
+
*
|
|
20
|
+
* SUBDIRECTORIES, AND EXACTLY TWO. The safe-name rule for a knowledge file
|
|
21
|
+
* forbids every separator; a library item is the one thing allowed ONE, and
|
|
22
|
+
* only after one of the two literal prefixes, with a stem that is already
|
|
23
|
+
* safe and the extension its kind hands back. `..`, a third segment, another
|
|
24
|
+
* prefix, a leading dot — refused, never rewritten, because a rewritten path
|
|
25
|
+
* could collide with another item's real one. The two directories are OURS the
|
|
26
|
+
* way the library directory is: a symlink or a file planted there is removed
|
|
27
|
+
* (the link, never its target) and a real directory made.
|
|
28
|
+
*
|
|
29
|
+
* ABSENT MEANS "LEAVE IT ALONE", the same rule the whole key keeps: a manifest
|
|
30
|
+
* with no `library` key is an older server, and `designs/`, `research/` and
|
|
31
|
+
* `LIBRARY.md` are neither synced nor swept. `library: { items: [] }` is the
|
|
32
|
+
* emptied catalog, SAID — both directories and the catalog go.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { safeFileName } from './safeFileName.mjs';
|
|
36
|
+
|
|
37
|
+
/** Relative to the checkout, under `.flowviant/` beside the turn uploads and
|
|
38
|
+
* the artifacts — see `FLOWVIANT_OWN_PATHS` for why that is NOT one exclude
|
|
39
|
+
* line covering all three. */
|
|
40
|
+
export const KNOWLEDGE_DIR = '.flowviant/knowledge';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* THE PATHS UNDER `.flowviant/` THAT ARE OURS, and the exclude file names these
|
|
44
|
+
* and nothing wider (2026-09-23).
|
|
45
|
+
*
|
|
46
|
+
* The first cut excluded `/.flowviant/` whole, from every place a turn spawns
|
|
47
|
+
* in — the attachment fetch's line, which until this release was written only
|
|
48
|
+
* in repos somebody had attached a file in. But `.flowviant/` is not only ours:
|
|
49
|
+
* the repo DECLARES `.flowviant/check.json` and `.flowviant/deploy.json` there,
|
|
50
|
+
* committed like any other file. An exclude line never hides a TRACKED file,
|
|
51
|
+
* so an existing one was safe — and a NEW one was not: an agent asked to "add a
|
|
52
|
+
* check command" writes `.flowviant/check.json`, its `git add -A` skips it as
|
|
53
|
+
* ignored, the commit lands without it, and `git status` shows nothing to
|
|
54
|
+
* explain why. Writing that line into every worktree on every turn would have
|
|
55
|
+
* made the product's own configuration uncommittable by the product's own
|
|
56
|
+
* agents. So the exclude names the four things this daemon writes there, and a
|
|
57
|
+
* repo's own `.flowviant/*.json` stays ordinary, committable work.
|
|
58
|
+
*
|
|
59
|
+
* (A repo that already carries the old `/.flowviant/` line keeps it — it is in
|
|
60
|
+
* the user's own exclude file, and removing a line we cannot prove we alone
|
|
61
|
+
* wrote is not ours to do.)
|
|
62
|
+
*/
|
|
63
|
+
export const FLOWVIANT_OWN_PATHS = [
|
|
64
|
+
'.flowviant/knowledge/',
|
|
65
|
+
'.flowviant/knowledge.rev*',
|
|
66
|
+
'.flowviant/artifacts/',
|
|
67
|
+
'.flowviant/uploads/',
|
|
68
|
+
];
|
|
69
|
+
/** The generated catalog of the kept library, at the knowledge root. Reserved
|
|
70
|
+
* like INSTRUCTIONS.md: a person's file of that name is suffixed, because the
|
|
71
|
+
* prompt tells the CLI this one is the catalog. */
|
|
72
|
+
export const LIBRARY_FILE = 'LIBRARY.md';
|
|
73
|
+
/** The two subdirectories a kept item may live in, by kind. */
|
|
74
|
+
export const LIBRARY_DIRS = { design: 'designs', research: 'research' };
|
|
75
|
+
const LIBRARY_EXT = { design: ['.html', '.htm', '.gltf', '.obj', '.glb', '.bin'], research: ['.md'] };
|
|
76
|
+
/** The server caps a library at 200 items; a longer manifest is cut. */
|
|
77
|
+
const MAX_LIBRARY_ITEMS = 256;
|
|
78
|
+
|
|
79
|
+
/** The person's standing brief, written from the manifest's `instructions`.
|
|
80
|
+
* A RESERVED name: a library FILE called this gets a suffix, because the
|
|
81
|
+
* prompt tells the CLI to read this one first and a stranger's file must
|
|
82
|
+
* never be able to stand in for the person's own words. */
|
|
83
|
+
export const INSTRUCTIONS_FILE = 'INSTRUCTIONS.md';
|
|
84
|
+
/** The server's per-file ceiling (`KNOWLEDGE_FILE_MAX_BYTES`), re-checked here:
|
|
85
|
+
* this is somebody's disk, and one place checking is one deploy away from
|
|
86
|
+
* zero places — the attachment fetch's own rule. */
|
|
87
|
+
export const KNOWLEDGE_FILE_MAX_BYTES = 10 * 1024 * 1024;
|
|
88
|
+
export const KNOWLEDGE_BINARY_MODEL_MAX_BYTES = 20 * 1024 * 1024;
|
|
89
|
+
export const knowledgeMaxFor = (name, base) => /\.(glb|bin)$/i.test(name)
|
|
90
|
+
? Math.max(base, KNOWLEDGE_BINARY_MODEL_MAX_BYTES) : base;
|
|
91
|
+
/** The manifest is capped at the server (25 files); a manifest longer than
|
|
92
|
+
* this is not one the product produced, and is cut rather than trusted. */
|
|
93
|
+
export const MAX_FILES = 64;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* `safeFileName` (bin/lib/safeFileName.mjs — the server's rule, one daemon
|
|
97
|
+
* home): drops every path separator, never starts with a dot or a dash, and
|
|
98
|
+
* THE EXTENSION SURVIVES A CUT — byte-identical to what the server computes,
|
|
99
|
+
* so a knowledge file synced under this name and one requested by name later
|
|
100
|
+
* never disagree over the cut. An empty name is `file`.
|
|
101
|
+
*/
|
|
102
|
+
export const safeKnowledgeName = (raw) => safeFileName(raw, 'file');
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The LOCAL name each manifest entry is written under, in manifest order.
|
|
106
|
+
* Collisions get a numeric suffix (`spec.md`, `spec-2.md`) — two files a
|
|
107
|
+
* person uploaded with one name are two files, and silently writing one over
|
|
108
|
+
* the other loses one. Deterministic in the manifest's order, so the same
|
|
109
|
+
* manifest always yields the same names and a re-sync never renames a file.
|
|
110
|
+
* `INSTRUCTIONS.md` is reserved (compared case-insensitively — macOS and
|
|
111
|
+
* Windows disks are).
|
|
112
|
+
*/
|
|
113
|
+
export function planKnowledgeNames(files) {
|
|
114
|
+
// The catalog's name and the two library directories are reserved beside
|
|
115
|
+
// the brief (0.97.0): a person's `LIBRARY.md` or a file called `designs`
|
|
116
|
+
// must never stand where the catalog or a directory has to.
|
|
117
|
+
const taken = new Set([
|
|
118
|
+
INSTRUCTIONS_FILE.toLowerCase(),
|
|
119
|
+
LIBRARY_FILE.toLowerCase(),
|
|
120
|
+
...Object.values(LIBRARY_DIRS),
|
|
121
|
+
]);
|
|
122
|
+
const out = [];
|
|
123
|
+
for (const f of files) {
|
|
124
|
+
const name = safeKnowledgeName(f.name);
|
|
125
|
+
const dot = name.lastIndexOf('.');
|
|
126
|
+
const stem = dot > 0 ? name.slice(0, dot) : name;
|
|
127
|
+
const ext = dot > 0 ? name.slice(dot) : '';
|
|
128
|
+
let candidate = name;
|
|
129
|
+
for (let n = 2; taken.has(candidate.toLowerCase()); n++) candidate = `${stem}-${n}${ext}`;
|
|
130
|
+
taken.add(candidate.toLowerCase());
|
|
131
|
+
out.push({ ...f, local: candidate });
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* A kept item's relative path, or null when it is not EXACTLY
|
|
138
|
+
* `<designs|research>/<safe stem>.<its kind's extension>` — see the header.
|
|
139
|
+
* Refused rather than rewritten: a rewritten path is one the server never
|
|
140
|
+
* named, and it could land on another item's real one.
|
|
141
|
+
*/
|
|
142
|
+
export function safeLibraryPath(name, kind) {
|
|
143
|
+
if (typeof name !== 'string' || !(kind in LIBRARY_DIRS)) return null;
|
|
144
|
+
const parts = name.split('/');
|
|
145
|
+
if (parts.length !== 2 && !(kind === 'design' && parts.length >= 3 && parts.length <= 7)) return null;
|
|
146
|
+
const [dir, ...rest] = parts;
|
|
147
|
+
const file = rest.at(-1);
|
|
148
|
+
if (dir !== LIBRARY_DIRS[kind]) return null;
|
|
149
|
+
if (rest.length >= 2 && !/^[a-z0-9-]+-v[1-9][0-9]*$/.test(rest[0])) return null;
|
|
150
|
+
if (rest.slice(1, -1).some((p) => !safeBundleSegment(p))) return null;
|
|
151
|
+
if (!file || (rest.length === 1 ? file !== safeKnowledgeName(file) : !safeBundleSegment(file))) return null;
|
|
152
|
+
if (!LIBRARY_EXT[kind].some((ext) => file.toLowerCase().endsWith(ext) && file.length > ext.length)) return null;
|
|
153
|
+
return name;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function safeBundleSegment(part) {
|
|
157
|
+
return typeof part === 'string' && part.length > 0 && part.length <= 80 &&
|
|
158
|
+
/^[A-Za-z0-9][A-Za-z0-9._ -]*$/.test(part) && !part.endsWith('.');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function safeBundleFile(path, rootDir) {
|
|
162
|
+
if (typeof path !== 'string' || !path.startsWith(`${rootDir}/`)) return null;
|
|
163
|
+
const relative = path.slice(rootDir.length + 1);
|
|
164
|
+
if (relative === 'preview.png') return null;
|
|
165
|
+
const parts = relative.split('/');
|
|
166
|
+
if (!parts.length || parts.some((p) => !safeBundleSegment(p))) return null;
|
|
167
|
+
if (!/\.(gltf|obj|glb|bin|png|jpg|jpeg|gif|webp|svg|json)$/i.test(relative)) return null;
|
|
168
|
+
return path;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The manifest's library items that are safe to write, in manifest order, or
|
|
172
|
+
* null when the key is ABSENT (an older server — leave everything alone). */
|
|
173
|
+
export function libraryItemsOf(manifest) {
|
|
174
|
+
const lib = manifest?.library;
|
|
175
|
+
if (lib === undefined || lib === null) return null;
|
|
176
|
+
if (typeof lib !== 'object' || !Array.isArray(lib.items)) return null;
|
|
177
|
+
const out = [];
|
|
178
|
+
const seen = new Set();
|
|
179
|
+
for (const it of lib.items.slice(0, MAX_LIBRARY_ITEMS)) {
|
|
180
|
+
if (!it || typeof it.id !== 'string' || !/^[0-9a-f-]{8,64}$/i.test(it.id)) continue;
|
|
181
|
+
const path = safeLibraryPath(it.name, it.kind);
|
|
182
|
+
if (!path || seen.has(path.toLowerCase())) continue;
|
|
183
|
+
seen.add(path.toLowerCase());
|
|
184
|
+
const bundleDir = path.split('/').length >= 3 ? path.split('/').slice(0, 2).join('/') : null;
|
|
185
|
+
const expectedPreview = bundleDir ? `${bundleDir}/preview.png` : it.name.replace(/\.html$/, '.png');
|
|
186
|
+
const previewPath = it.kind === 'design' && it.preview && it.preview.name === expectedPreview ? expectedPreview : null;
|
|
187
|
+
const files = bundleDir && Array.isArray(it.files) ? it.files.map((f, index) => ({ ...f,
|
|
188
|
+
index, path: safeBundleFile(f.name, bundleDir),
|
|
189
|
+
})).filter((f) => f.path && !seen.has(f.path.toLowerCase())) : [];
|
|
190
|
+
for (const f of files) seen.add(f.path.toLowerCase());
|
|
191
|
+
out.push({ ...it, path, files, previewPath, previewUnknown: it.kind === 'design' && path.endsWith('.html') && it.preview === undefined });
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** One line of the catalog: server strings are made single-line and capped —
|
|
197
|
+
* they are a person's card title and an artifact's name, headed into a file
|
|
198
|
+
* an agent reads, so they may not carry a line break that forges a second
|
|
199
|
+
* entry. */
|
|
200
|
+
const catalogText = (v, max) =>
|
|
201
|
+
String(v ?? '')
|
|
202
|
+
.replace(/[\r\n\t]+/g, ' ')
|
|
203
|
+
.replace(/\s+/g, ' ')
|
|
204
|
+
.trim()
|
|
205
|
+
.slice(0, max);
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* THE CATALOG, generated whole from the manifest on every sync:
|
|
209
|
+
*
|
|
210
|
+
* - designs/landing-v2.html — Landing mockup (design, from card "Redesign
|
|
211
|
+
* landing page", 2026-09-23, supersedes designs/landing-v1.html)
|
|
212
|
+
*
|
|
213
|
+
* (one line per item, oldest first — the server's order — so a sync that adds
|
|
214
|
+
* one item adds one line and moves nothing). Null when there are no items, in
|
|
215
|
+
* which case the file is removed rather than written empty.
|
|
216
|
+
*/
|
|
217
|
+
export function renderLibraryCatalog(items) {
|
|
218
|
+
if (!items || items.length === 0) return null;
|
|
219
|
+
const lines = items.map((it) => {
|
|
220
|
+
const facts = [it.kind === 'research' ? 'research' : 'design'];
|
|
221
|
+
const card = catalogText(it.taskTitle, 200);
|
|
222
|
+
if (card) facts.push(`from card "${card.replace(/"/g, "'")}"`);
|
|
223
|
+
const day = typeof it.createdAt === 'string' ? it.createdAt.slice(0, 10) : '';
|
|
224
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(day)) facts.push(day);
|
|
225
|
+
const sup = typeof it.supersedes === 'string' ? catalogText(it.supersedes, 200) : '';
|
|
226
|
+
if (sup) facts.push(`supersedes ${sup}`);
|
|
227
|
+
const title = catalogText(it.title, 200) || it.path;
|
|
228
|
+
return `- ${it.path} — ${title} (${facts.join(', ')})`;
|
|
229
|
+
});
|
|
230
|
+
return (
|
|
231
|
+
'# Library\n\n' +
|
|
232
|
+
'The designs and research this project kept. Paths are relative to this directory;\n' +
|
|
233
|
+
'a newer version supersedes an older one of the same name.\n\n' +
|
|
234
|
+
`${lines.join('\n')}\n`
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Does this manifest look like one the server produced? Anything else is
|
|
239
|
+
* ignored whole rather than half-applied. */
|
|
240
|
+
export function isKnowledgeManifest(k) {
|
|
241
|
+
return (
|
|
242
|
+
!!k &&
|
|
243
|
+
typeof k === 'object' &&
|
|
244
|
+
Number.isInteger(k.rev) &&
|
|
245
|
+
k.rev >= 0 &&
|
|
246
|
+
(k.instructions === null || k.instructions === undefined || typeof k.instructions === 'string') &&
|
|
247
|
+
Array.isArray(k.files)
|
|
248
|
+
);
|
|
249
|
+
}
|
package/bin/lib/landed.mjs
CHANGED
|
@@ -32,8 +32,9 @@ import { git, baseBranchName } from './git.mjs';
|
|
|
32
32
|
import { commitRecord, stripDelims, taskIdsFromMessage } from './worktreeDiff.mjs';
|
|
33
33
|
import { warn } from './ui.mjs';
|
|
34
34
|
import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
|
|
35
|
+
import { fleetEndpoint } from './fleetWire.mjs';
|
|
35
36
|
|
|
36
|
-
const LANDED_URL =
|
|
37
|
+
const LANDED_URL = fleetEndpoint('base-landed', FLEET_URL);
|
|
37
38
|
/** The server accepts 50 per report. A bigger range walks OLDEST-FIRST in
|
|
38
39
|
* batches: the persisted tip advances to the last commit actually walked,
|
|
39
40
|
* so the remainder is picked up on the next beat rather than skipped forever
|
package/bin/lib/listeners.mjs
CHANGED
|
@@ -53,10 +53,10 @@
|
|
|
53
53
|
|
|
54
54
|
import { execFileSync } from 'node:child_process';
|
|
55
55
|
import { createConnection } from 'node:net';
|
|
56
|
-
import { readFileSync
|
|
56
|
+
import { readFileSync } from 'node:fs';
|
|
57
57
|
import { platform } from 'node:os';
|
|
58
58
|
import { rssBytes } from './processes.mjs';
|
|
59
|
-
import {
|
|
59
|
+
import { insideDir, socketHoldersIn } from './procScan.mjs';
|
|
60
60
|
|
|
61
61
|
/**
|
|
62
62
|
* INFRASTRUCTURE CHILDREN ARE NOT LISTENERS. cloudflared opens its own local
|
|
@@ -78,10 +78,9 @@ export const forgetInfraPid = (pid) => {
|
|
|
78
78
|
infraPids.delete(pid);
|
|
79
79
|
};
|
|
80
80
|
|
|
81
|
-
/** A
|
|
82
|
-
*
|
|
83
|
-
|
|
84
|
-
const MAX_PIDS = 4000;
|
|
81
|
+
/** A process that IS Flowviant is not a process Flowviant reports — see
|
|
82
|
+
* `infraPids` above and the daemon's own gate in `scanLinux`. */
|
|
83
|
+
const isFlowviant = (pid) => pid === process.pid || infraPids.has(pid);
|
|
85
84
|
/**
|
|
86
85
|
* Rows reported per session.
|
|
87
86
|
*
|
|
@@ -249,64 +248,35 @@ function labelFor(pid) {
|
|
|
249
248
|
}
|
|
250
249
|
}
|
|
251
250
|
|
|
252
|
-
|
|
251
|
+
/**
|
|
252
|
+
* The worktree's listening sockets, from the kernel's table and the processes
|
|
253
|
+
* standing in the worktree — `{ rows, complete }`. The walk of /proc is
|
|
254
|
+
* `procScan.mjs`'s (bounded on the processes it reads in the worktree, never
|
|
255
|
+
* on which pids are looked at); `complete: false` means it stopped with pids
|
|
256
|
+
* unread, and a listener among them is UNMEASURED, not absent.
|
|
257
|
+
*
|
|
258
|
+
* Exported with `io` ONLY so the scan-order property can be pinned without a
|
|
259
|
+
* box that runs four thousand processes. Nothing in the daemon passes it.
|
|
260
|
+
*/
|
|
261
|
+
export function scanLinux(worktree, io) {
|
|
253
262
|
const inodes = listeningByInode();
|
|
254
|
-
if (inodes.size === 0) return [];
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
try {
|
|
267
|
-
pids = readdirSync('/proc').filter((d) => /^\d+$/.test(d));
|
|
268
|
-
} catch {
|
|
269
|
-
return [];
|
|
270
|
-
}
|
|
271
|
-
if (pids.length > MAX_PIDS) pids = pids.slice(0, MAX_PIDS);
|
|
263
|
+
if (inodes.size === 0) return { rows: [], complete: true };
|
|
264
|
+
|
|
265
|
+
// NEVER THE DAEMON ITSELF. `startAuthProxy` binds a loopback port IN THIS
|
|
266
|
+
// PROCESS to gate a preview, and the daemon's own cwd is inside the
|
|
267
|
+
// checkout — so the gate was attributed to the repo place and surfaced as
|
|
268
|
+
// a listener the operator could click Stop on. Stopping it would take down
|
|
269
|
+
// the door in front of a live share, and it is not a thing anybody
|
|
270
|
+
// started: it is us. Same rule `processes.mjs` keeps by never reporting the
|
|
271
|
+
// group leader, for the same reason — a process that IS Flowviant is not a
|
|
272
|
+
// process Flowviant reports.
|
|
273
|
+
const scan = socketHoldersIn(worktree, { skip: isFlowviant, io });
|
|
274
|
+
if (scan === null) return { rows: [], complete: true };
|
|
272
275
|
|
|
273
276
|
const found = new Map(); // port -> row
|
|
274
|
-
for (const pid of
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
let cwd;
|
|
278
|
-
try {
|
|
279
|
-
cwd = readlinkSync(`/proc/${pid}/cwd`);
|
|
280
|
-
} catch {
|
|
281
|
-
continue; // not ours, or gone
|
|
282
|
-
}
|
|
283
|
-
if (!inside(cwd)) continue;
|
|
284
|
-
// NEVER THE DAEMON ITSELF. `startAuthProxy` binds a loopback port IN THIS
|
|
285
|
-
// PROCESS to gate a preview, and the daemon's own cwd is inside the
|
|
286
|
-
// checkout — so the gate was attributed to the repo place and surfaced as
|
|
287
|
-
// a listener the operator could click Stop on. Stopping it would take down
|
|
288
|
-
// the door in front of a live share, and it is not a thing anybody
|
|
289
|
-
// started: it is us. Same rule `processes.mjs` keeps by never reporting the
|
|
290
|
-
// group leader, for the same reason — a process that IS Flowviant is not a
|
|
291
|
-
// process Flowviant reports.
|
|
292
|
-
if (Number(pid) === process.pid || infraPids.has(Number(pid))) continue;
|
|
293
|
-
|
|
294
|
-
let fds;
|
|
295
|
-
try {
|
|
296
|
-
fds = readdirSync(`/proc/${pid}/fd`);
|
|
297
|
-
} catch {
|
|
298
|
-
continue;
|
|
299
|
-
}
|
|
300
|
-
for (const fd of fds) {
|
|
301
|
-
let link;
|
|
302
|
-
try {
|
|
303
|
-
link = readlinkSync(`/proc/${pid}/fd/${fd}`);
|
|
304
|
-
} catch {
|
|
305
|
-
continue;
|
|
306
|
-
}
|
|
307
|
-
const m = /^socket:\[(\d+)\]$/.exec(link);
|
|
308
|
-
if (!m) continue;
|
|
309
|
-
const hit = inodes.get(m[1]);
|
|
277
|
+
for (const { pid, inodes: held } of scan.holders) {
|
|
278
|
+
for (const inode of held) {
|
|
279
|
+
const hit = inodes.get(inode);
|
|
310
280
|
if (!hit) continue;
|
|
311
281
|
if (found.has(hit.port)) continue;
|
|
312
282
|
const chosen = chosenPort(hit.port);
|
|
@@ -319,13 +289,13 @@ function scanLinux(worktree) {
|
|
|
319
289
|
// was thrown away the moment the row was built. Keeping it is what lets
|
|
320
290
|
// a surface group nine sockets under the ONE program that opened them,
|
|
321
291
|
// and it is the only thing a kill could ever be aimed at.
|
|
322
|
-
pid
|
|
292
|
+
pid,
|
|
323
293
|
...(rss != null ? { rss } : {}),
|
|
324
294
|
...(chosen === undefined ? {} : { chosen }),
|
|
325
295
|
});
|
|
326
296
|
}
|
|
327
297
|
}
|
|
328
|
-
return [...found.values()];
|
|
298
|
+
return { rows: [...found.values()], complete: scan.complete };
|
|
329
299
|
}
|
|
330
300
|
|
|
331
301
|
// ── macOS ──────────────────────────────────────────────────────────────────
|
|
@@ -363,12 +333,10 @@ function scanDarwin(worktree) {
|
|
|
363
333
|
} catch {
|
|
364
334
|
/* no memory on this box — rows simply carry no rss */
|
|
365
335
|
}
|
|
366
|
-
const self = String(process.pid);
|
|
367
336
|
for (const line of lsof(['-nP', '-iTCP', '-sTCP:LISTEN', '-F', 'pn']).split('\n')) {
|
|
368
337
|
// The daemon's own preview gate is not something the driver started — see
|
|
369
338
|
// the linux branch for the whole argument. Nor its cloudflared children.
|
|
370
|
-
if (line.startsWith('p'))
|
|
371
|
-
pid = line.slice(1) === self || infraPids.has(Number(line.slice(1))) ? null : line.slice(1);
|
|
339
|
+
if (line.startsWith('p')) pid = isFlowviant(Number(line.slice(1))) ? null : line.slice(1);
|
|
372
340
|
else if (line.startsWith('n') && pid) {
|
|
373
341
|
const m = /:(\d+)$/.exec(line.slice(1));
|
|
374
342
|
if (!m) continue;
|
|
@@ -380,13 +348,8 @@ function scanDarwin(worktree) {
|
|
|
380
348
|
}
|
|
381
349
|
if (byPid.size === 0) return [];
|
|
382
350
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
root = realpathSync(worktree);
|
|
386
|
-
} catch {
|
|
387
|
-
return [];
|
|
388
|
-
}
|
|
389
|
-
const prefix = root.endsWith(sep) ? root : root + sep;
|
|
351
|
+
const inside = insideDir(worktree);
|
|
352
|
+
if (!inside) return [];
|
|
390
353
|
|
|
391
354
|
// Pass 2: the cwd of exactly those pids, in ONE batched call.
|
|
392
355
|
const out = new Map();
|
|
@@ -394,8 +357,7 @@ function scanDarwin(worktree) {
|
|
|
394
357
|
for (const line of lsof(['-a', '-d', 'cwd', '-F', 'pn', '-p', [...byPid.keys()].join(',')]).split('\n')) {
|
|
395
358
|
if (line.startsWith('p')) cur = line.slice(1);
|
|
396
359
|
else if (line.startsWith('n') && cur) {
|
|
397
|
-
|
|
398
|
-
if (cwd !== root && !cwd.startsWith(prefix)) continue;
|
|
360
|
+
if (!inside(line.slice(1))) continue;
|
|
399
361
|
for (const row of byPid.get(cur) || []) {
|
|
400
362
|
if (out.has(row.port)) continue;
|
|
401
363
|
const chosen = chosenPort(row.port);
|
|
@@ -452,17 +414,39 @@ export function byChosenThenPort(a, b) {
|
|
|
452
414
|
* Repository block already settled that trade for branches and worktrees: the
|
|
453
415
|
* capped list rides beside the count it was cut from. An empty array on an
|
|
454
416
|
* unsupported platform means "we did not look" — callers must not turn it into
|
|
455
|
-
* "nothing is running".
|
|
417
|
+
* "nothing is running". `incomplete: true` rides along when the /proc walk hit
|
|
418
|
+
* its work bound (`procScan.mjs`) with pids still unread.
|
|
419
|
+
*
|
|
420
|
+
* `incomplete` IS NOT RELAYED, deliberately, for now. The listener reports
|
|
421
|
+
* (`workPlaceReports.mjs`, `repoState.mjs`) send `rows`/`total` only: a new report field
|
|
422
|
+
* needs no floor, but it needs a reader on the server and a sentence on the
|
|
423
|
+
* web, and neither exists — sending it would be a field nobody reads. The cut
|
|
424
|
+
* needs thousands of processes standing in ONE worktree; the share path, where
|
|
425
|
+
* the cut would change an answer, says it in words (`originFor`). Relaying it
|
|
426
|
+
* to the Workbench is a product call, not this module's.
|
|
427
|
+
*
|
|
428
|
+
* `io` is the test seam `scanLinux` documents; nothing in the daemon passes it.
|
|
456
429
|
*/
|
|
457
|
-
export function measureListeners(worktree) {
|
|
430
|
+
export function measureListeners(worktree, io) {
|
|
458
431
|
if (!worktree) return { rows: [], total: 0 };
|
|
459
|
-
let
|
|
432
|
+
let scan;
|
|
460
433
|
try {
|
|
461
|
-
|
|
434
|
+
scan =
|
|
435
|
+
platform() === 'linux'
|
|
436
|
+
? scanLinux(worktree, io)
|
|
437
|
+
: { rows: platform() === 'darwin' ? scanDarwin(worktree) : [], complete: true };
|
|
462
438
|
} catch {
|
|
463
439
|
return { rows: [], total: 0 };
|
|
464
440
|
}
|
|
465
|
-
|
|
441
|
+
const { rows, complete } = scan;
|
|
442
|
+
return {
|
|
443
|
+
rows: rows.sort(byChosenThenPort).slice(0, MAX_ROWS),
|
|
444
|
+
total: rows.length,
|
|
445
|
+
// SAID ONLY WHEN IT HAPPENED: the walk stopped with pids unread, so the
|
|
446
|
+
// rows are real and the total is a floor — a listener missing from them
|
|
447
|
+
// was not looked for, which is not the same fact as "not running".
|
|
448
|
+
...(complete ? {} : { incomplete: true }),
|
|
449
|
+
};
|
|
466
450
|
}
|
|
467
451
|
|
|
468
452
|
/** The capped rows alone, for callers with nowhere to put a total. */
|
|
@@ -537,23 +521,36 @@ export function isListening(port, timeoutMs = 1500) {
|
|
|
537
521
|
* file cannot measure answers the loopback it always dialled — the same
|
|
538
522
|
* behaviour as before, never a refusal invented from ignorance.
|
|
539
523
|
*/
|
|
540
|
-
export function originFor(worktree, port) {
|
|
524
|
+
export function originFor(worktree, port, io) {
|
|
541
525
|
if (!worktree || !Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
542
526
|
return { error: `nothing is listening on port ${port} in this worktree.` };
|
|
543
527
|
}
|
|
544
|
-
let
|
|
528
|
+
let scan;
|
|
545
529
|
try {
|
|
546
|
-
|
|
530
|
+
scan =
|
|
547
531
|
platform() === 'linux'
|
|
548
|
-
? socketsOnPortLinux(worktree, port)
|
|
532
|
+
? socketsOnPortLinux(worktree, port, io)
|
|
549
533
|
: platform() === 'darwin'
|
|
550
|
-
? socketsOnPortDarwin(worktree, port)
|
|
534
|
+
? { sockets: socketsOnPortDarwin(worktree, port), complete: true }
|
|
551
535
|
: null;
|
|
552
536
|
} catch {
|
|
553
|
-
|
|
537
|
+
scan = null;
|
|
538
|
+
}
|
|
539
|
+
if (scan == null) return { host: '127.0.0.1' };
|
|
540
|
+
const picked = pickOrigin(scan.sockets, port);
|
|
541
|
+
// A socket on this port whose holder the walk never reached is read as
|
|
542
|
+
// FOREIGN by the rule — the safe reading for a shadow check — so an
|
|
543
|
+
// unshadowed socket of ours is shared exactly as a complete walk would share
|
|
544
|
+
// it. Only when the rule would REFUSE does the cut matter: then "nothing is
|
|
545
|
+
// listening" (or "another process") may be about a socket the kernel holds
|
|
546
|
+
// for a process the walk never reached, and that is said instead (F007;
|
|
547
|
+
// review 2026-09-26: never a refusal built on the cut alone).
|
|
548
|
+
if (picked.error && !scan.complete && scan.sockets.some((s) => !s.ours)) {
|
|
549
|
+
return {
|
|
550
|
+
error: `this machine runs more processes in this worktree than one look reads, so which one holds port ${port} could not be told — it was not shared.`,
|
|
551
|
+
};
|
|
554
552
|
}
|
|
555
|
-
|
|
556
|
-
return pickOrigin(sockets, port);
|
|
553
|
+
return picked;
|
|
557
554
|
}
|
|
558
555
|
|
|
559
556
|
/**
|
|
@@ -617,7 +614,9 @@ function hexAddr(hex, family) {
|
|
|
617
614
|
return `${head}::${tail}`;
|
|
618
615
|
}
|
|
619
616
|
|
|
620
|
-
|
|
617
|
+
/** `{ sockets, complete }` on success, null when /proc cannot be listed.
|
|
618
|
+
* `io` is the test seam `scanLinux` documents. */
|
|
619
|
+
export function socketsOnPortLinux(worktree, port, io) {
|
|
621
620
|
const byInode = new Map(); // inode -> { family, addr }
|
|
622
621
|
for (const [f, family] of [
|
|
623
622
|
['/proc/net/tcp', 4],
|
|
@@ -637,51 +636,17 @@ function socketsOnPortLinux(worktree, port) {
|
|
|
637
636
|
if (c[9] && c[9] !== '0') byInode.set(c[9], { family, addr: hexAddr(a, family) });
|
|
638
637
|
}
|
|
639
638
|
}
|
|
640
|
-
if (byInode.size === 0) return [];
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
root = realpathSync(worktree);
|
|
644
|
-
} catch {
|
|
645
|
-
return [];
|
|
646
|
-
}
|
|
647
|
-
const prefix = root.endsWith(sep) ? root : root + sep;
|
|
639
|
+
if (byInode.size === 0) return { sockets: [], complete: true };
|
|
640
|
+
const scan = socketHoldersIn(worktree, { skip: isFlowviant, io });
|
|
641
|
+
if (scan === null) return null;
|
|
648
642
|
const ours = new Set();
|
|
649
|
-
|
|
650
|
-
try {
|
|
651
|
-
pids = readdirSync('/proc').filter((d) => /^\d+$/.test(d));
|
|
652
|
-
} catch {
|
|
653
|
-
return null;
|
|
654
|
-
}
|
|
655
|
-
if (pids.length > MAX_PIDS) pids = pids.slice(0, MAX_PIDS);
|
|
656
|
-
for (const pid of pids) {
|
|
657
|
-
let cwd;
|
|
658
|
-
try {
|
|
659
|
-
cwd = readlinkSync(`/proc/${pid}/cwd`);
|
|
660
|
-
} catch {
|
|
661
|
-
continue;
|
|
662
|
-
}
|
|
663
|
-
if (cwd !== root && !cwd.startsWith(prefix)) continue;
|
|
664
|
-
if (Number(pid) === process.pid || infraPids.has(Number(pid))) continue;
|
|
665
|
-
let fds;
|
|
666
|
-
try {
|
|
667
|
-
fds = readdirSync(`/proc/${pid}/fd`);
|
|
668
|
-
} catch {
|
|
669
|
-
continue;
|
|
670
|
-
}
|
|
671
|
-
for (const fd of fds) {
|
|
672
|
-
let link;
|
|
673
|
-
try {
|
|
674
|
-
link = readlinkSync(`/proc/${pid}/fd/${fd}`);
|
|
675
|
-
} catch {
|
|
676
|
-
continue;
|
|
677
|
-
}
|
|
678
|
-
const m = /^socket:\[(\d+)\]$/.exec(link);
|
|
679
|
-
if (m && byInode.has(m[1])) ours.add(m[1]);
|
|
680
|
-
}
|
|
681
|
-
}
|
|
643
|
+
for (const { inodes } of scan.holders) for (const inode of inodes) if (byInode.has(inode)) ours.add(inode);
|
|
682
644
|
// A socket whose holder we could not read (another user's process) is
|
|
683
645
|
// FOREIGN — the safe reading for a refusal check.
|
|
684
|
-
return
|
|
646
|
+
return {
|
|
647
|
+
sockets: [...byInode.entries()].map(([inode, s]) => ({ ...s, ours: ours.has(inode) })),
|
|
648
|
+
complete: scan.complete,
|
|
649
|
+
};
|
|
685
650
|
}
|
|
686
651
|
|
|
687
652
|
function socketsOnPortDarwin(worktree, port) {
|
|
@@ -700,22 +665,15 @@ function socketsOnPortDarwin(worktree, port) {
|
|
|
700
665
|
}
|
|
701
666
|
}
|
|
702
667
|
if (!rows.length) return [];
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
root = realpathSync(worktree);
|
|
706
|
-
} catch {
|
|
707
|
-
return [];
|
|
708
|
-
}
|
|
709
|
-
const prefix = root.endsWith(sep) ? root : root + sep;
|
|
668
|
+
const within = insideDir(worktree);
|
|
669
|
+
if (!within) return [];
|
|
710
670
|
const inside = new Set();
|
|
711
|
-
const self = String(process.pid);
|
|
712
671
|
let cur = null;
|
|
713
672
|
const pids = [...new Set(rows.map((r) => r.pid))];
|
|
714
673
|
for (const line of lsof(['-a', '-d', 'cwd', '-F', 'pn', '-p', pids.join(',')]).split('\n')) {
|
|
715
674
|
if (line.startsWith('p')) cur = line.slice(1);
|
|
716
675
|
else if (line.startsWith('n') && cur) {
|
|
717
|
-
|
|
718
|
-
if ((cwd === root || cwd.startsWith(prefix)) && cur !== self && !infraPids.has(Number(cur))) inside.add(cur);
|
|
676
|
+
if (within(line.slice(1)) && !isFlowviant(Number(cur))) inside.add(cur);
|
|
719
677
|
}
|
|
720
678
|
}
|
|
721
679
|
return rows.map((r) => ({ family: r.family, addr: r.addr, ours: inside.has(r.pid) }));
|