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/machines.mjs
CHANGED
|
@@ -1,336 +1,81 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* enter for a project's two verbs, esc to leave. Piped, or on a terminal with
|
|
23
|
-
* no raw mode, it is exactly the listing it was, and `--remove <id>` /
|
|
24
|
-
* `--forget <id>` are the same two verbs for a script.
|
|
25
|
-
*
|
|
26
|
-
* THE VERBS ACT ON THIS BOX'S OWN CONNECTIONS AND NOTHING ELSE. "Disconnect"
|
|
27
|
-
* stops the daemon serving that project HERE (one credential's lock, never a
|
|
28
|
-
* sweep), tells the app this box has left that project's machines list, and
|
|
29
|
-
* forgets the credential here — three things this box is entitled to do about
|
|
30
|
-
* itself. It does not stop, remove or move a daemon on another computer: those
|
|
31
|
-
* rows are printed under each project so the duplicate is visible, and the
|
|
32
|
-
* footer still says where THOSE verbs live. Decisions about other machines
|
|
33
|
-
* stay in the app, which is the ruling this amends and not the one it breaks.
|
|
34
|
-
*
|
|
35
|
-
* The question it answers is the owner's, verbatim: "is there a way to view ALL
|
|
36
|
-
* the connected flowviants? because im not sure if i have any duplicate or
|
|
37
|
-
* redundant daemons running". Measured the day it was asked: four live daemons
|
|
38
|
-
* across two boxes, two credentials last heard three days ago, and two projects
|
|
39
|
-
* both named "BRIF AI" bound to the same checkout — which is why `npx
|
|
40
|
-
* flowviant` there offered two identical picker rows. THE PROJECT ID IS
|
|
41
|
-
* THEREFORE ALWAYS PRINTED: it is the only thing that tells those two apart,
|
|
42
|
-
* and the whole listing is worth less without it. **And since 0.95.0 the
|
|
43
|
-
* collision is SAID, not left to be noticed**: the id told him the rows were
|
|
44
|
-
* different projects, and he still read them as one project connected twice
|
|
45
|
-
* ("BRIF AI appears twice meaning I likely have 2 daemon or 'machine
|
|
46
|
-
* profiles' on my machine for the same BRIF AI project"). `renderCollisions`
|
|
47
|
-
* prints one line per repo that two projects are bound to, naming both.
|
|
48
|
-
*
|
|
49
|
-
* ── IT IS A RELAY ──
|
|
50
|
-
*
|
|
51
|
-
* The role words are the SERVER'S — `serving` and `inactive` today — computed in
|
|
52
|
-
* one place so this command, project settings and the account's Home block
|
|
53
|
-
* cannot disagree about what a box is doing. They are PRINTED AS SENT: there is
|
|
54
|
-
* no mapping table here and no default word, because either is this command
|
|
55
|
-
* having an opinion about a state it did not measure. Nothing here re-derives a
|
|
56
|
-
* state, and a project the server could not answer for says so rather than
|
|
57
|
-
* rendering as empty.
|
|
58
|
-
*
|
|
59
|
-
* ── RENDERING IS PURE, FETCHING IS NOT ──
|
|
60
|
-
*
|
|
61
|
-
* `renderMachines` takes the entries and the results and returns lines, so
|
|
62
|
-
* every case that matters — rejected, unreachable, an older server, no boxes,
|
|
63
|
-
* behind, this box — is provable without a network, a credential or a daemon.
|
|
64
|
-
* The wiring above it is the thin part on purpose.
|
|
2
|
+
* THE MACHINE'S TWO REQUESTS about its own boxes — `GET /fleet/boxes` (0.91.0)
|
|
3
|
+
* and `POST /fleet/boxes/leave` (0.95.0) — and the one rule that classifies
|
|
4
|
+
* what the server answered.
|
|
5
|
+
*
|
|
6
|
+
* Split out of the `machines` command's single module (SOLID audit 2026-09-26,
|
|
7
|
+
* F056): the listing and its copy live in `machineListing.mjs`, the
|
|
8
|
+
* stop → leave → forget orchestration in `machineDisconnect.mjs`, and this
|
|
9
|
+
* file is only the wire. The daemon's startup line and the tray's
|
|
10
|
+
* `status --remote` read the same boxes call from here.
|
|
11
|
+
*
|
|
12
|
+
* ONE CLASSIFICATION FOR BOTH (F170). The read and the leave each spelled the
|
|
13
|
+
* same five decisions — the app rejected the credential, something in front of
|
|
14
|
+
* the app answered 401/403, a 404 is an older SERVER, any other non-OK is an
|
|
15
|
+
* HTTP error, a thrown fetch is a transport failure — and only their success
|
|
16
|
+
* bodies differ. `machineRequest` states them once; each caller validates its
|
|
17
|
+
* own body. Keeping 404 apart from 401 is the part that matters: one means
|
|
18
|
+
* "older server", the other "a credential this box should probably forget",
|
|
19
|
+
* and collapsing them is how somebody deletes a working credential. The tray's
|
|
20
|
+
* third machine-credential read, `GET /fleet/live-agents` (desktopContract.mjs),
|
|
21
|
+
* classifies through the same exported rule.
|
|
65
22
|
*
|
|
66
23
|
* It deliberately imports NOTHING from fleet.mjs: that module pulls the whole
|
|
67
|
-
* daemon
|
|
68
|
-
* command runs before the auth gate, like `stop` and `projects`. The small
|
|
69
|
-
* duration formatter below is a second copy of a ten-line function, knowingly,
|
|
70
|
-
* and it says different words anyway — "heard 12s ago" against "last heard 3d
|
|
71
|
-
* ago", where the word LAST is the staleness mark.
|
|
24
|
+
* daemon, and the `machines` command runs before the auth gate.
|
|
72
25
|
*/
|
|
73
26
|
|
|
74
27
|
import { USER_AGENT } from './config.mjs';
|
|
75
|
-
import { projectLabel, repoCollisions, safeName } from './credentials.mjs';
|
|
76
28
|
import { credentialRejected } from './authReject.mjs';
|
|
77
|
-
import {
|
|
29
|
+
import { fleetEndpoint } from './fleetWire.mjs';
|
|
78
30
|
|
|
79
31
|
/** The boxes read, derived from the roster URL the way the diffstat post is —
|
|
80
32
|
* one place configures the API base and everything else is a suffix swap. */
|
|
81
33
|
export function boxesUrlFrom(fleetUrl) {
|
|
82
|
-
return
|
|
34
|
+
return fleetEndpoint('boxes', fleetUrl);
|
|
83
35
|
}
|
|
84
36
|
|
|
85
37
|
/** Where this box tells the server it has left a project — the boxes read's
|
|
86
38
|
* own path with a verb on the end, so a server that has the read and not the
|
|
87
39
|
* verb answers 404 and the caller says "older server" rather than guessing. */
|
|
88
40
|
export function leaveUrlFrom(fleetUrl) {
|
|
89
|
-
return
|
|
41
|
+
return fleetEndpoint('boxes/leave', fleetUrl);
|
|
90
42
|
}
|
|
91
43
|
|
|
92
|
-
/** The one answer `agoFrom` gives that is a PHRASE rather than a duration, so
|
|
93
|
-
* it is the one answer that must not take an " ago" after it. Named because
|
|
94
|
-
* two callers have to agree about it and a bare string literal is how they
|
|
95
|
-
* stopped agreeing. */
|
|
96
|
-
const JUST_NOW = 'just now';
|
|
97
|
-
|
|
98
44
|
/**
|
|
99
|
-
* A
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
45
|
+
* WHAT A NON-SUCCESS ANSWER MEANS, as a shape both callers and the renderer
|
|
46
|
+
* know — or null when the response is an OK the caller should read.
|
|
47
|
+
*
|
|
48
|
+
* { rejected: true } the APP refused the credential (its JSON envelope)
|
|
49
|
+
* { error: 'HTTP 403 from something in front of the app' }
|
|
50
|
+
* an edge 401/403 (a challenge page) — not the app
|
|
51
|
+
* { unsupported: true } 404: an older server without this route
|
|
52
|
+
* { error: 'HTTP <n>' } any other non-OK
|
|
106
53
|
*/
|
|
107
|
-
export function
|
|
108
|
-
|
|
109
|
-
if (
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if (s < 90) return `${s}s`;
|
|
114
|
-
const m = Math.round(s / 60);
|
|
115
|
-
if (m < 90) return `${m}m`;
|
|
116
|
-
const h = Math.round(m / 60);
|
|
117
|
-
if (h < 36) return `${h}h`;
|
|
118
|
-
return `${Math.round(h / 24)}d`;
|
|
54
|
+
export async function classifyMachineResponse(res) {
|
|
55
|
+
if (await credentialRejected(res)) return { rejected: true };
|
|
56
|
+
if (res.status === 401 || res.status === 403) return { error: `HTTP ${res.status} from something in front of the app` };
|
|
57
|
+
if (res.status === 404) return { unsupported: true };
|
|
58
|
+
if (!res.ok) return { error: `HTTP ${res.status}` };
|
|
59
|
+
return null;
|
|
119
60
|
}
|
|
120
61
|
|
|
121
62
|
/**
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
* `agoFrom` answers with a DURATION for everything except the newest case,
|
|
127
|
-
* where it answers with the words "just now" — and both callers appended
|
|
128
|
-
* " ago", so the most common row in the listing, a daemon that polled four
|
|
129
|
-
* hundred milliseconds ago, read "heard just now ago". On the serving box. On
|
|
130
|
-
* the first line anybody looks at.
|
|
131
|
-
*
|
|
132
|
-
* Stated once here rather than fixed twice: a duration takes the suffix, a
|
|
133
|
-
* phrase does not, and "LAST heard" is the staleness mark the whole listing
|
|
134
|
-
* turns on. An unmeasurable stamp says "never heard" rather than printing a
|
|
135
|
-
* duration nobody took.
|
|
63
|
+
* Send one request and answer in a shape, never a throw: the classification
|
|
64
|
+
* above for a refusal, `accept(data)` for an OK body's `data`, and the
|
|
65
|
+
* transport's own words for a fetch that threw.
|
|
136
66
|
*/
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
* has no date, and inventing one would be the listing asserting a day. */
|
|
147
|
-
export function connectedOn(iso) {
|
|
148
|
-
const t = Date.parse(iso ?? '');
|
|
149
|
-
if (Number.isNaN(t)) return null;
|
|
150
|
-
return new Date(t).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* THE SERVER'S OWN ROLE WORD, PRINTED. A pure relay and nothing else.
|
|
155
|
-
*
|
|
156
|
-
* It carried one special case until 2026-09-21: `'standing-by'` was respelled
|
|
157
|
-
* as `standing by`, because the server sent a hyphenated enum value and a
|
|
158
|
-
* terminal should not print an identifier. The owner replaced that word —
|
|
159
|
-
* asked whether a box should keep saying "standing by", he answered *"no, it
|
|
160
|
-
* can [be] inactive instead"* — so the server sends `serving` and `inactive`,
|
|
161
|
-
* both of which are already words, and the special case had nothing left to
|
|
162
|
-
* translate.
|
|
163
|
-
*
|
|
164
|
-
* IT IS NOT KEPT "IN CASE": a mapping table in a relay is a place for the two
|
|
165
|
-
* ends to disagree, and a stale entry would print one thing while the app
|
|
166
|
-
* printed another about the same box — which is precisely the confusion this
|
|
167
|
-
* command exists to end. A role this build has never heard of passes through
|
|
168
|
-
* untouched for the same reason.
|
|
169
|
-
*
|
|
170
|
-
* An ABSENT role prints nothing rather than a guess. It used to default to
|
|
171
|
-
* `idle`, which is a CLAIM — "the server told us this box is doing nothing" —
|
|
172
|
-
* about an answer the server did not give.
|
|
173
|
-
*/
|
|
174
|
-
function roleWord(role) {
|
|
175
|
-
return typeof role === 'string' && role.trim() ? role.trim() : '';
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/** One box, as a line under its project. The MARK is the only derived thing on
|
|
179
|
-
* it — a filled dot for a box the server still calls fresh, hollow for one it
|
|
180
|
-
* does not — and the words beside it are all the server's or the box's own. */
|
|
181
|
-
export function renderBox(box, { me = null, latest = null } = {}, now = Date.now()) {
|
|
182
|
-
const mark = box.fresh ? '●' : '○';
|
|
183
|
-
// SCRUBBED HERE TOO (2026-09-24, the audit) — a belt against the server's
|
|
184
|
-
// own display scrub, in defence of a stale stored row or a server this
|
|
185
|
-
// fix has not reached: the terminal is where an escape sequence in a
|
|
186
|
-
// box's name or its checkout path actually does harm, so this is the one
|
|
187
|
-
// place it must never be trusted to have already been cleaned. See
|
|
188
|
-
// printable.mjs.
|
|
189
|
-
const name = safeName(box.boxName) ?? 'an unnamed machine';
|
|
190
|
-
// "LAST heard" is the staleness mark, and the row is never hidden for it —
|
|
191
|
-
// the owner's answer to "what about a box that stopped" was exactly this.
|
|
192
|
-
const heard = heardPhrase(box, now);
|
|
193
|
-
// An unreported role DROPS ITS CELL. Every other cell on this row states its
|
|
194
|
-
// own absence ("(checkout not reported)") because a reader who cannot see a
|
|
195
|
-
// path needs to know nobody measured one; a role is different — the row is
|
|
196
|
-
// already carrying the mark, the version and the last-heard, and a
|
|
197
|
-
// placeholder there would be four words of nothing in the widest column.
|
|
198
|
-
const cells = [
|
|
199
|
-
`${mark} ${name}`,
|
|
200
|
-
roleWord(box.role),
|
|
201
|
-
safeName(box.checkoutPath) ?? '(checkout not reported)',
|
|
202
|
-
box.daemonVersion ? `v${box.daemonVersion}` : 'version not reported',
|
|
203
|
-
heard,
|
|
204
|
-
].filter(Boolean);
|
|
205
|
-
let line = ` ${cells.join(' ')}`;
|
|
206
|
-
if (me && box.boxId === me) line += ' ← this box';
|
|
207
|
-
// Said only when the server said it, and it names the version that is
|
|
208
|
-
// actually published rather than a feature floor — a floor may sit above
|
|
209
|
-
// LATEST on purpose, and an instruction to install something npm does not
|
|
210
|
-
// serve is worse than silence.
|
|
211
|
-
if (box.behind && latest) line += ` · behind, ${latest} is latest`;
|
|
212
|
-
return line;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* THE WHOLE LISTING, as lines. Pure over (entries, results, now).
|
|
217
|
-
*
|
|
218
|
-
* `results` is keyed by projectId and each value is one of:
|
|
219
|
-
* { boxes, latest, me } the server answered
|
|
220
|
-
* { rejected: true } 401/403 — the credential is dead here
|
|
221
|
-
* { unsupported: true } 404 — an older server with no boxes route
|
|
222
|
-
* { error: '<words>' } anything else, relayed rather than summarised
|
|
223
|
-
* A project with no entry in `results` is treated as unreachable, because that
|
|
224
|
-
* is the only thing an absent answer can honestly mean.
|
|
225
|
-
*/
|
|
226
|
-
export function renderMachines(entries, results, { now = Date.now() } = {}) {
|
|
227
|
-
const lines = [];
|
|
228
|
-
// WHICH LABELS COLLIDE. Two projects genuinely called the same thing is the
|
|
229
|
-
// case that produced this command, so the id carries it — and it is printed
|
|
230
|
-
// on EVERY row rather than only the colliding ones, because a reader who has
|
|
231
|
-
// to notice a suffix to know whether it matters has already been asked to do
|
|
232
|
-
// the work this listing is for.
|
|
233
|
-
for (const e of entries) {
|
|
234
|
-
const label = projectLabel(e);
|
|
235
|
-
const connected = connectedOn(e.savedAt);
|
|
236
|
-
const id = `(id ${e.projectId.slice(0, 8)}…${connected ? `, connected ${connected}` : ''})`;
|
|
237
|
-
lines.push(
|
|
238
|
-
` ${label} · ${e.repoRoot || 'not bound to a repo yet'} ${id}`
|
|
239
|
-
);
|
|
240
|
-
const r = results?.[e.projectId];
|
|
241
|
-
if (!r || r.error) {
|
|
242
|
-
// The failure is RELAYED, not summarised, and the loop carries on: one
|
|
243
|
-
// unreachable project must not cost you the listing for the others.
|
|
244
|
-
lines.push(` could not ask the server — ${r?.error ?? 'no answer'}`);
|
|
245
|
-
continue;
|
|
246
|
-
}
|
|
247
|
-
if (r.rejected) {
|
|
248
|
-
lines.push(
|
|
249
|
-
' credential rejected — this project was disconnected or deleted. ' +
|
|
250
|
-
`\`${terminalCommand(`machines --forget ${e.projectId.slice(0, 8)}`)}\` removes it here.`
|
|
251
|
-
);
|
|
252
|
-
continue;
|
|
253
|
-
}
|
|
254
|
-
if (r.unsupported) {
|
|
255
|
-
lines.push(' server does not report machines yet');
|
|
256
|
-
continue;
|
|
257
|
-
}
|
|
258
|
-
const boxes = Array.isArray(r.boxes) ? r.boxes : [];
|
|
259
|
-
if (boxes.length === 0) {
|
|
260
|
-
// NOT "no machines". Nothing has polled this credential, which is a
|
|
261
|
-
// different fact from a project whose machines have all gone quiet — and
|
|
262
|
-
// a quiet one still has rows, wearing its last-heard.
|
|
263
|
-
lines.push(' (no machine has polled this project)');
|
|
264
|
-
continue;
|
|
265
|
-
}
|
|
266
|
-
for (const b of boxes) lines.push(renderBox(b, { me: r.me, latest: r.latest }, now));
|
|
67
|
+
async function machineRequest(send, accept) {
|
|
68
|
+
try {
|
|
69
|
+
const res = await send();
|
|
70
|
+
const refused = await classifyMachineResponse(res);
|
|
71
|
+
if (refused) return refused;
|
|
72
|
+
const body = await res.json();
|
|
73
|
+
return accept(body?.data);
|
|
74
|
+
} catch (e) {
|
|
75
|
+
return { error: String(e?.message ?? e) };
|
|
267
76
|
}
|
|
268
|
-
const collisions = renderCollisions(entries);
|
|
269
|
-
if (collisions.length) lines.push('', ...collisions);
|
|
270
|
-
return lines;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
/** A project as ONE phrase for a sentence that names two of them — the name,
|
|
274
|
-
* the id, and the connected date, which is the fact a person can match
|
|
275
|
-
* against their own memory ("I set that one up last month"). */
|
|
276
|
-
export function projectPhrase(e) {
|
|
277
|
-
const connected = connectedOn(e?.savedAt);
|
|
278
|
-
return `${projectLabel(e)} (${String(e?.projectId ?? '').slice(0, 8)}…${connected ? `, connected ${connected}` : ''})`;
|
|
279
77
|
}
|
|
280
78
|
|
|
281
|
-
/**
|
|
282
|
-
* TWO PROJECTS ON ONE REPO, SAID OUT LOUD (2026-09-23).
|
|
283
|
-
*
|
|
284
|
-
* The owner ran this command, saw "BRIF AI" twice, and concluded he had two
|
|
285
|
-
* daemons for one project. The id on each row said they were different
|
|
286
|
-
* projects; nothing said they were bound to the SAME checkout, which is the
|
|
287
|
-
* fact that makes it a duplicate rather than two rows. So the listing ends
|
|
288
|
-
* with one line per such repo, naming every project on it in full, the rule
|
|
289
|
-
* (a directory serves one project — the owner's 2026-09-23 ruling, which made
|
|
290
|
-
* the start there a refusal), and the remedy in his order: delete the project
|
|
291
|
-
* in Flowviant, or disconnect this box from it (the menu below, or the flags).
|
|
292
|
-
*
|
|
293
|
-
* Pure over the entries. An unbound project collides with nothing.
|
|
294
|
-
*/
|
|
295
|
-
export function renderCollisions(entries) {
|
|
296
|
-
return repoCollisions(entries).map((g) => {
|
|
297
|
-
const names = g.entries.map(projectPhrase);
|
|
298
|
-
const list = `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
|
|
299
|
-
return (
|
|
300
|
-
` ! ${g.entries.length} projects are connected for ${g.repoRoot}: ${list} — ` +
|
|
301
|
-
`a directory serves one project, and \`${launchCommand()}\` there refuses to start until it does. ` +
|
|
302
|
-
'Delete the one you do not mean in Flowviant (project settings → General → Delete project), or disconnect this box from it.'
|
|
303
|
-
);
|
|
304
|
-
});
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
/**
|
|
308
|
-
* THE FOOTER. Two sentences, both of which exist because this listing is the
|
|
309
|
-
* moment somebody is confused — the same reason `flowviant projects` names its
|
|
310
|
-
* remedies inline rather than in a manual.
|
|
311
|
-
*
|
|
312
|
-
* The first says where the verbs are, because there are none here. The second
|
|
313
|
-
* states this command's own SCOPE, which is the one thing a reader cannot see
|
|
314
|
-
* from the output: it lists what is connected on THIS box, and a daemon running
|
|
315
|
-
* on a computer that has never met this one is invisible to it. The app's Home
|
|
316
|
-
* is where "all of them" lives, and pretending otherwise would be this listing
|
|
317
|
-
* quietly answering a narrower question than the one asked.
|
|
318
|
-
*/
|
|
319
|
-
export const MACHINES_FOOTER = [
|
|
320
|
-
' stop a machine from the app: project settings → Machine → Disconnect;',
|
|
321
|
-
` a new \`${launchCommand()}\` on another box takes the project over.`,
|
|
322
|
-
' this lists the projects connected on THIS box; the app’s Home lists every machine on your account.',
|
|
323
|
-
];
|
|
324
|
-
|
|
325
|
-
/** THE SCRIPT'S VERBS, said only when there was no menu to offer them in — a
|
|
326
|
-
* terminal that could draw the menu was offered the same two things by name,
|
|
327
|
-
* and repeating the flags under it would be a manual under a control. */
|
|
328
|
-
export const MACHINES_FLAGS_FOOTER = [
|
|
329
|
-
` \`${terminalCommand('machines --remove <id>')}\` disconnects this box from a project: stops its daemon here,`,
|
|
330
|
-
' removes this box from that project’s machines list in the app, and forgets its credential here.',
|
|
331
|
-
' `--forget <id>` forgets the credential here only.',
|
|
332
|
-
];
|
|
333
|
-
|
|
334
79
|
/**
|
|
335
80
|
* Ask one credential for its boxes. Never throws: every outcome is a shape the
|
|
336
81
|
* renderer knows, because one dead project must not end the listing.
|
|
@@ -343,43 +88,17 @@ export const MACHINES_FLAGS_FOOTER = [
|
|
|
343
88
|
export async function fetchBoxesFor(entry, { url, envpub, fetchImpl = fetch, signal = AbortSignal.timeout(15_000) } = {}) {
|
|
344
89
|
const target = new URL(url);
|
|
345
90
|
if (envpub) target.searchParams.set('envpub', envpub);
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
if (!data || !Array.isArray(data.boxes)) return { error: 'unexpected answer shape' };
|
|
358
|
-
return { boxes: data.boxes, latest: data.latest ?? null, me: data.me ?? null };
|
|
359
|
-
} catch (e) {
|
|
360
|
-
return { error: String(e?.message ?? e) };
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
/**
|
|
365
|
-
* THE OTHER BOXES ON THIS PROJECT, as ONE line for the daemon's startup — or
|
|
366
|
-
* null when there are none, which is the common case and must print nothing.
|
|
367
|
-
*
|
|
368
|
-
* Said ONCE per process, after the first successful poll, and never per poll:
|
|
369
|
-
* the fact only changes when somebody starts a daemon somewhere else, and a
|
|
370
|
-
* true sentence restated every ten seconds is a scrolling console nobody reads.
|
|
371
|
-
* THE CALLING BOX IS EXCLUDED — it is the one box the person at that terminal
|
|
372
|
-
* does not need to be told about.
|
|
373
|
-
*/
|
|
374
|
-
export function otherBoxesLine(boxes, me, now = Date.now()) {
|
|
375
|
-
const others = (Array.isArray(boxes) ? boxes : []).filter((b) => b && b.boxId !== me);
|
|
376
|
-
if (others.length === 0) return null;
|
|
377
|
-
const parts = others.slice(0, 6).map((b) => {
|
|
378
|
-
const role = roleWord(b.role);
|
|
379
|
-
return `${safeName(b.boxName) ?? 'an unnamed machine'} (${role ? `${role} · ` : ''}${heardPhrase(b, now)})`;
|
|
380
|
-
});
|
|
381
|
-
const more = others.length - parts.length;
|
|
382
|
-
return `other machines on this project: ${parts.join(', ')}${more > 0 ? `, and ${more} more` : ''}`;
|
|
91
|
+
return machineRequest(
|
|
92
|
+
() =>
|
|
93
|
+
fetchImpl(target, {
|
|
94
|
+
headers: { Authorization: `Bearer ${entry.fleetToken}`, 'User-Agent': USER_AGENT },
|
|
95
|
+
signal,
|
|
96
|
+
}),
|
|
97
|
+
(data) => {
|
|
98
|
+
if (!data || !Array.isArray(data.boxes)) return { error: 'unexpected answer shape' };
|
|
99
|
+
return { boxes: data.boxes, latest: data.latest ?? null, me: data.me ?? null };
|
|
100
|
+
}
|
|
101
|
+
);
|
|
383
102
|
}
|
|
384
103
|
|
|
385
104
|
/**
|
|
@@ -401,117 +120,21 @@ export function otherBoxesLine(boxes, me, now = Date.now()) {
|
|
|
401
120
|
*/
|
|
402
121
|
export async function leaveBoxFor(entry, { url, envpub, fetchImpl = fetch } = {}) {
|
|
403
122
|
if (!envpub) return { skipped: true };
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
if (!data || typeof data.removed !== 'boolean') return { error: 'unexpected answer shape' };
|
|
422
|
-
return { removed: data.removed, wasHolder: Boolean(data.wasHolder) };
|
|
423
|
-
} catch (e) {
|
|
424
|
-
return { error: String(e?.message ?? e) };
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
/**
|
|
429
|
-
* DISCONNECT THIS BOX FROM ONE PROJECT — the three steps, in the only order
|
|
430
|
-
* that is safe, with every outcome said as it happens (0.95.0).
|
|
431
|
-
*
|
|
432
|
-
* 1. STOP the daemon serving that credential HERE. One lock, identified
|
|
433
|
-
* before it is signalled, through the same stand-down `flowviant stop`
|
|
434
|
-
* uses — never a sweep, because the person named ONE project. A daemon
|
|
435
|
-
* that is alive and could not be stopped ABORTS the disconnect: forgetting
|
|
436
|
-
* a credential under a running daemon changes nothing about the daemon
|
|
437
|
-
* (it holds the token in memory) and telling the server we have left while
|
|
438
|
-
* it keeps polling is a row that comes back in ten seconds wearing the
|
|
439
|
-
* name we just said was gone.
|
|
440
|
-
* 2. LEAVE in the app — see `leaveBoxFor`. Skipped, and said, when this box
|
|
441
|
-
* has no identity file: a box that never ran a daemon never polled, so
|
|
442
|
-
* the app has no row to remove.
|
|
443
|
-
* 3. FORGET the credential here. Last, so a failure in 1 or 2 leaves the
|
|
444
|
-
* store able to try again.
|
|
445
|
-
*
|
|
446
|
-
* `deps` is injected so the ORDER and the SENTENCES are provable without a
|
|
447
|
-
* daemon, a socket or a credential file; `realDisconnectDeps` builds the live
|
|
448
|
-
* set. Returns `{ ok }`, false only when step 1 refused.
|
|
449
|
-
*/
|
|
450
|
-
export async function disconnectHere(entry, deps, { log = (m) => console.log(m) } = {}) {
|
|
451
|
-
const who = projectPhrase(entry);
|
|
452
|
-
const tally = deps.stopDaemon(entry.fleetToken);
|
|
453
|
-
if (tally.failed > 0) {
|
|
454
|
-
log(
|
|
455
|
-
`not disconnected from ${who}: a daemon for it is still running here and was not stopped. ` +
|
|
456
|
-
'Stop it first (the line above says how), then run this again.'
|
|
457
|
-
);
|
|
458
|
-
return { ok: false };
|
|
459
|
-
}
|
|
460
|
-
if (tally.running === 0) log(`no daemon for ${who} was running here.`);
|
|
461
|
-
|
|
462
|
-
const left = await deps.leave(entry);
|
|
463
|
-
if (left.skipped) {
|
|
464
|
-
log('this box has never run a daemon, so the app has no row of it to remove.');
|
|
465
|
-
} else if (left.rejected) {
|
|
466
|
-
log(`the app had already disconnected or deleted ${who} — nothing to leave.`);
|
|
467
|
-
} else if (left.unsupported) {
|
|
468
|
-
log(
|
|
469
|
-
'the app does not take a leave from a machine yet (older server) — its row goes quiet on its own, ' +
|
|
470
|
-
'or remove it in project settings → Machines.'
|
|
471
|
-
);
|
|
472
|
-
} else if (left.error) {
|
|
473
|
-
log(
|
|
474
|
-
`could not tell the app this box has left ${who} (${left.error}) — its row goes quiet on its own, ` +
|
|
475
|
-
'or remove it in project settings → Machines.'
|
|
476
|
-
);
|
|
477
|
-
} else if (!left.removed) {
|
|
478
|
-
log(`the app was not listing this box on ${who}.`);
|
|
479
|
-
} else {
|
|
480
|
-
log(
|
|
481
|
-
`removed this box from ${who}’s machines list in the app.` +
|
|
482
|
-
(left.wasHolder ? ' It was the machine serving that project, which has none until another polls.' : '')
|
|
483
|
-
);
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
const gone = deps.forget(entry.projectId);
|
|
487
|
-
if (gone.error) {
|
|
488
|
-
log(`could not forget the credential here: ${gone.error}`);
|
|
489
|
-
return { ok: false };
|
|
490
|
-
}
|
|
491
|
-
log(`forgot ${who}’s credential on this box. \`${terminalCommand('login')}\` in its repo connects it again.`);
|
|
492
|
-
return { ok: true };
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
/**
|
|
496
|
-
* THE LIVE DEPENDENCIES for `disconnectHere`, built lazily: `instance.mjs`
|
|
497
|
-
* and `env.mjs` are imported here and not at the top so that rendering the
|
|
498
|
-
* listing — the piped, scripted, most common use — loads nothing that reads a
|
|
499
|
-
* lock directory or a keypair. `readStoredPubB64` READS the key and never
|
|
500
|
-
* mints one, the rule the `machines` branch in cli.mjs is pinned to.
|
|
501
|
-
*/
|
|
502
|
-
export async function realDisconnectDeps({ url, log = (m) => console.log(m) } = {}) {
|
|
503
|
-
const { stopDaemonFor } = await import('./instance.mjs');
|
|
504
|
-
const { forgetStoredProject } = await import('./credentials.mjs');
|
|
505
|
-
let envpub = null;
|
|
506
|
-
try {
|
|
507
|
-
const env = await import('./env.mjs');
|
|
508
|
-
envpub = env.readStoredPubB64();
|
|
509
|
-
} catch {
|
|
510
|
-
/* unreadable keypair — the leave is skipped and said */
|
|
511
|
-
}
|
|
512
|
-
return {
|
|
513
|
-
stopDaemon: (fleetToken) => stopDaemonFor(fleetToken, { log: (m) => log(` ${m}`) }),
|
|
514
|
-
leave: (entry) => leaveBoxFor(entry, { url, envpub }),
|
|
515
|
-
forget: (projectId) => forgetStoredProject(projectId),
|
|
516
|
-
};
|
|
123
|
+
return machineRequest(
|
|
124
|
+
() =>
|
|
125
|
+
fetchImpl(url, {
|
|
126
|
+
method: 'POST',
|
|
127
|
+
headers: {
|
|
128
|
+
Authorization: `Bearer ${entry.fleetToken}`,
|
|
129
|
+
'User-Agent': USER_AGENT,
|
|
130
|
+
'Content-Type': 'application/json',
|
|
131
|
+
},
|
|
132
|
+
body: JSON.stringify({ envpub }),
|
|
133
|
+
signal: AbortSignal.timeout(15_000),
|
|
134
|
+
}),
|
|
135
|
+
(data) => {
|
|
136
|
+
if (!data || typeof data.removed !== 'boolean') return { error: 'unexpected answer shape' };
|
|
137
|
+
return { removed: data.removed, wasHolder: Boolean(data.wasHolder) };
|
|
138
|
+
}
|
|
139
|
+
);
|
|
517
140
|
}
|