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,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `flowviant machines --remove` and the menu's first verb: DISCONNECT THIS BOX
|
|
3
|
+
* FROM ONE PROJECT — stop the daemon here, leave the app's machines list,
|
|
4
|
+
* forget the credential here.
|
|
5
|
+
*
|
|
6
|
+
* THE VERBS ACT ON THIS BOX'S OWN CONNECTIONS AND NOTHING ELSE. It does not
|
|
7
|
+
* stop, remove or move a daemon on another computer; decisions about other
|
|
8
|
+
* machines stay in the app (the ruling the `machines` command amends, argued
|
|
9
|
+
* in `machineListing.mjs`'s header).
|
|
10
|
+
*
|
|
11
|
+
* Split out of the `machines` command's single module (SOLID audit 2026-09-26,
|
|
12
|
+
* F056) because it is the one DESTRUCTIVE workflow there, with its own callers
|
|
13
|
+
* (machinesCommand.mjs's `--remove` and menu, `uninstall`) and its own test needs (the
|
|
14
|
+
* order and the sentences, under injected deps). It reads the listing's
|
|
15
|
+
* `projectPhrase` so a project is named the same way in both, and the leave
|
|
16
|
+
* request from `machines.mjs`.
|
|
17
|
+
*
|
|
18
|
+
* IT ALSO OWNS WHAT A DISCONNECT ACHIEVED — the outcome contract
|
|
19
|
+
* `disconnectHere` returns beside the sentences it prints, and the one reading
|
|
20
|
+
* of it every caller that must ACT on the result uses (SOLID F004), because
|
|
21
|
+
* `flowviant uninstall --purge` decided whether the app had been told by
|
|
22
|
+
* matching four phrases in the log lines, and then deleted `~/.flowviant`
|
|
23
|
+
* even after a failure it had just recorded. The sentences are for a person
|
|
24
|
+
* at a terminal; a decision reads this.
|
|
25
|
+
*
|
|
26
|
+
* The shape: `{ ok, stop, leave, forget }` —
|
|
27
|
+
* stop: 'stopped' | 'none' | 'failed' | 'draining'
|
|
28
|
+
* leave: 'left' | 'not-listed' | 'skipped' | 'rejected' | 'unsupported' | 'failed' | 'not-run'
|
|
29
|
+
* (+ `leaveError` when 'failed')
|
|
30
|
+
* forget: 'forgotten' | 'kept' | 'failed' | 'not-run'
|
|
31
|
+
* (+ `forgetError` when 'failed')
|
|
32
|
+
* `ok` keeps its meaning for the `machines` verbs (false when the stop
|
|
33
|
+
* refused, the forget failed, or the caller asked to keep the credential
|
|
34
|
+
* after a failed leave and it was kept).
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { projectPhrase } from './machineListing.mjs';
|
|
38
|
+
import { leaveBoxFor } from './machines.mjs';
|
|
39
|
+
import { terminalCommand } from './launchCommand.mjs';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Where a person removes a box's row by hand when this box could not: the
|
|
43
|
+
* project's Machines page (it took over Settings › Machines, 2026-09-25).
|
|
44
|
+
* One spelling for every terminal sentence that points there.
|
|
45
|
+
*/
|
|
46
|
+
export const removeOnMachinesPage = (what = 'it') => `remove ${what} on the project's Machines page`;
|
|
47
|
+
|
|
48
|
+
/** The leave's answer (see `leaveBoxFor`) as one word. */
|
|
49
|
+
export function leaveOutcomeOf(left) {
|
|
50
|
+
if (left?.skipped) return 'skipped';
|
|
51
|
+
if (left?.rejected) return 'rejected';
|
|
52
|
+
if (left?.unsupported) return 'unsupported';
|
|
53
|
+
if (left?.error) return 'failed';
|
|
54
|
+
if (!left?.removed) return 'not-listed';
|
|
55
|
+
return 'left';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* What the disconnect did NOT achieve, or null when it achieved everything it
|
|
60
|
+
* can. `retryable` says whether running it again could change the answer:
|
|
61
|
+
* a daemon that would not stop, a leave the network or the app refused, a
|
|
62
|
+
* credential that could not be forgotten. An older server with no leave
|
|
63
|
+
* route is reported but not retryable — asking it again changes nothing.
|
|
64
|
+
*
|
|
65
|
+
* An outcome with none of the fields (a caller's own stub, an older shape) is
|
|
66
|
+
* read by `ok` alone.
|
|
67
|
+
*/
|
|
68
|
+
export function disconnectShortfall(outcome) {
|
|
69
|
+
if (!outcome || typeof outcome !== 'object') return { reason: 'the disconnect reported nothing', retryable: true };
|
|
70
|
+
if (outcome.stop === 'failed')
|
|
71
|
+
return { reason: 'a daemon for it is still running here and was not stopped', retryable: true };
|
|
72
|
+
if (outcome.stop === 'draining')
|
|
73
|
+
return { reason: 'a daemon for it is finishing a deploy here; wait for it to finish', retryable: true };
|
|
74
|
+
if (outcome.leave === 'failed')
|
|
75
|
+
return { reason: `could not tell the app this box has left (${outcome.leaveError ?? 'failed'})`, retryable: true };
|
|
76
|
+
if (outcome.forget === 'failed')
|
|
77
|
+
return { reason: `could not forget the credential here: ${outcome.forgetError ?? 'failed'}`, retryable: true };
|
|
78
|
+
if (outcome.leave === 'unsupported')
|
|
79
|
+
return { reason: 'the app does not take a leave from a machine yet (older server)', retryable: false };
|
|
80
|
+
if (outcome.ok === false) return { reason: 'failed', retryable: true };
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* DISCONNECT THIS BOX FROM ONE PROJECT — the three steps, in the only order
|
|
86
|
+
* that is safe, with every outcome said as it happens (0.95.0).
|
|
87
|
+
*
|
|
88
|
+
* 1. STOP the daemon serving that credential HERE. One lock, identified
|
|
89
|
+
* before it is signalled, through the same stand-down `flowviant stop`
|
|
90
|
+
* uses — never a sweep, because the person named ONE project. A daemon
|
|
91
|
+
* that is alive and could not be stopped ABORTS the disconnect: forgetting
|
|
92
|
+
* a credential under a running daemon changes nothing about the daemon
|
|
93
|
+
* (it holds the token in memory) and telling the server we have left while
|
|
94
|
+
* it keeps polling is a row that comes back in ten seconds wearing the
|
|
95
|
+
* name we just said was gone.
|
|
96
|
+
* A daemon FINISHING A DEPLOY aborts it too (ruling 2026-09-26: a
|
|
97
|
+
* stand-down lets a deploy finish and report). The stop was obeyed and is
|
|
98
|
+
* never forced, but the daemon is still alive: its report needs the
|
|
99
|
+
* credential and the app's row of this box, and the leave would delete
|
|
100
|
+
* that row, which is how the app knows to refuse its own Disconnect while
|
|
101
|
+
* the deploy is in flight. So nothing is left or forgotten, and it says to
|
|
102
|
+
* wait, in the app's own words.
|
|
103
|
+
* 2. LEAVE in the app — see `leaveBoxFor`. Skipped, and said, when this box
|
|
104
|
+
* has no identity file: a box that never ran a daemon never polled, so
|
|
105
|
+
* the app has no row to remove.
|
|
106
|
+
* 3. FORGET the credential here. Last, so a failure in 1 or 2 leaves the
|
|
107
|
+
* store able to try again.
|
|
108
|
+
*
|
|
109
|
+
* `deps` is injected so the ORDER and the SENTENCES are provable without a
|
|
110
|
+
* daemon, a socket or a credential file; `realDisconnectDeps` builds the live
|
|
111
|
+
* set. Returns the OUTCOME (above) — `{ ok, stop, leave, forget }` — so a
|
|
112
|
+
* caller that must act on what happened reads fields, never the sentences.
|
|
113
|
+
* `ok` is false only when step 1 refused, step 3 failed, or the credential
|
|
114
|
+
* was kept (below).
|
|
115
|
+
*
|
|
116
|
+
* `forgetAfterFailedLeave` (default true, the `machines` verbs' rule): after
|
|
117
|
+
* a leave the app could not be told, the stopped box's store is still the
|
|
118
|
+
* last thing to clean. `uninstall --purge` passes false — it is about to
|
|
119
|
+
* delete the store whole, and a credential forgotten before a failed leave is
|
|
120
|
+
* one it can never use to try again.
|
|
121
|
+
*/
|
|
122
|
+
export async function disconnectHere(entry, deps, { log = (m) => console.log(m), forgetAfterFailedLeave = true } = {}) {
|
|
123
|
+
const who = projectPhrase(entry);
|
|
124
|
+
const tally = deps.stopDaemon(entry.fleetToken);
|
|
125
|
+
if (tally.failed > 0) {
|
|
126
|
+
log(
|
|
127
|
+
`not disconnected from ${who}: a daemon for it is still running here and was not stopped. ` +
|
|
128
|
+
'Stop it first (the line above says how), then run this again.'
|
|
129
|
+
);
|
|
130
|
+
return { ok: false, stop: 'failed', leave: 'not-run', forget: 'not-run' };
|
|
131
|
+
}
|
|
132
|
+
if (tally.draining > 0) {
|
|
133
|
+
log(
|
|
134
|
+
`not disconnected from ${who}: a deploy is in flight on this box; wait for it to finish, ` +
|
|
135
|
+
'then run this again. Its daemon stops by itself once the outcome is reported.'
|
|
136
|
+
);
|
|
137
|
+
return { ok: false, stop: 'draining', leave: 'not-run', forget: 'not-run' };
|
|
138
|
+
}
|
|
139
|
+
const stop = tally.running === 0 ? 'none' : 'stopped';
|
|
140
|
+
if (tally.running === 0) log(`no daemon for ${who} was running here.`);
|
|
141
|
+
|
|
142
|
+
const left = await deps.leave(entry);
|
|
143
|
+
const leave = leaveOutcomeOf(left);
|
|
144
|
+
const leaveFacts = leave === 'failed' ? { leave, leaveError: String(left.error) } : { leave };
|
|
145
|
+
if (left.skipped) {
|
|
146
|
+
log('this box has never run a daemon, so the app has no row of it to remove.');
|
|
147
|
+
} else if (left.rejected) {
|
|
148
|
+
log(`the app had already disconnected or deleted ${who} — nothing to leave.`);
|
|
149
|
+
} else if (left.unsupported) {
|
|
150
|
+
log(
|
|
151
|
+
'the app does not take a leave from a machine yet (older server) — its row goes quiet on its own, ' +
|
|
152
|
+
`or ${removeOnMachinesPage()}.`
|
|
153
|
+
);
|
|
154
|
+
} else if (left.error) {
|
|
155
|
+
log(
|
|
156
|
+
`could not tell the app this box has left ${who} (${left.error}) — its row goes quiet on its own, ` +
|
|
157
|
+
`or ${removeOnMachinesPage()}.`
|
|
158
|
+
);
|
|
159
|
+
} else if (!left.removed) {
|
|
160
|
+
log(`the app was not listing this box on ${who}.`);
|
|
161
|
+
} else {
|
|
162
|
+
log(
|
|
163
|
+
`removed this box from ${who}’s machines list in the app.` +
|
|
164
|
+
(left.wasHolder ? ' It was the machine serving that project, which has none until another polls.' : '')
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (leave === 'failed' && !forgetAfterFailedLeave) {
|
|
169
|
+
log(`kept ${who}’s credential on this box, so the leave can be tried again.`);
|
|
170
|
+
return { ok: false, stop, ...leaveFacts, forget: 'kept' };
|
|
171
|
+
}
|
|
172
|
+
const gone = deps.forget(entry.projectId);
|
|
173
|
+
if (gone.error) {
|
|
174
|
+
log(`could not forget the credential here: ${gone.error}`);
|
|
175
|
+
return { ok: false, stop, ...leaveFacts, forget: 'failed', forgetError: String(gone.error) };
|
|
176
|
+
}
|
|
177
|
+
log(`forgot ${who}’s credential on this box. \`${terminalCommand('login')}\` in its repo connects it again.`);
|
|
178
|
+
return { ok: true, stop, ...leaveFacts, forget: 'forgotten' };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* THE LIVE DEPENDENCIES for `disconnectHere`, built lazily: `instance.mjs`
|
|
183
|
+
* and `boxIdentity.mjs` are imported here and not at the top so that rendering the
|
|
184
|
+
* listing — the piped, scripted, most common use — loads nothing that reads a
|
|
185
|
+
* lock directory or a keypair. `readStoredPubB64` READS the key and never
|
|
186
|
+
* mints one, the rule machinesCommand.mjs is pinned to.
|
|
187
|
+
*/
|
|
188
|
+
export async function realDisconnectDeps({ url, log = (m) => console.log(m) } = {}) {
|
|
189
|
+
const { stopDaemonFor } = await import('./instance.mjs');
|
|
190
|
+
const { forgetStoredProject } = await import('./credentials.mjs');
|
|
191
|
+
let envpub = null;
|
|
192
|
+
try {
|
|
193
|
+
const env = await import('./boxIdentity.mjs');
|
|
194
|
+
envpub = env.readStoredPubB64();
|
|
195
|
+
} catch {
|
|
196
|
+
/* unreadable keypair — the leave is skipped and said */
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
stopDaemon: (fleetToken) => stopDaemonFor(fleetToken, { log: (m) => log(` ${m}`) }),
|
|
200
|
+
leave: (entry) => leaveBoxFor(entry, { url, envpub }),
|
|
201
|
+
forget: (projectId) => forgetStoredProject(projectId),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE MACHINE CREDENTIAL NEVER RIDES AN INHERITED ENVIRONMENT — one home for
|
|
3
|
+
* the removal rule every child built from the daemon's own environment obeys
|
|
4
|
+
* (a CLI turn's `cliEnv` in runTurn.mjs, the project check's `checkEnv` in
|
|
5
|
+
* workAgentCheck.mjs). Split out (SOLID F042) because the two lists were
|
|
6
|
+
* hand-copied: a third credential name added to one would have left the other
|
|
7
|
+
* lane handing it to a repo-controlled command.
|
|
8
|
+
*
|
|
9
|
+
* A DENYLIST ON PURPOSE, unlike `childEnv`'s allowlist: these children need
|
|
10
|
+
* the operator's shell whole (the CLI's own sign-in, the toolchain a test run
|
|
11
|
+
* needs), so stripping everything else would manufacture failures. Nothing
|
|
12
|
+
* they run needs the credential the daemon authenticates with, so exactly
|
|
13
|
+
* these names go. Imports nothing.
|
|
14
|
+
*/
|
|
15
|
+
export const MACHINE_CREDENTIAL_ENV = Object.freeze(['FLOWVIANT_MACHINE_TOKEN', 'FLOWVIANT_FLEET']);
|
|
16
|
+
|
|
17
|
+
/** A copy of `env` without the machine credential. Never mutates its input. */
|
|
18
|
+
export function withoutMachineCredentials(env) {
|
|
19
|
+
const out = { ...(env ?? {}) };
|
|
20
|
+
for (const k of MACHINE_CREDENTIAL_ENV) delete out[k];
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHAT THIS MACHINE CAN ACTUALLY BE GIVEN, read from the machine (2026-09-26,
|
|
3
|
+
* SOLID F059).
|
|
4
|
+
*
|
|
5
|
+
* Split out of config.mjs, which computed this inline at import from the real
|
|
6
|
+
* `/sys/fs/cgroup` files and the os module — so the rule could not be driven
|
|
7
|
+
* under any cgroup but the one the suite happened to run in. The RULE is now
|
|
8
|
+
* `deriveMachineLimits`, pure over supplied cgroup contents and host totals;
|
|
9
|
+
* `measureMachineLimits` is the thin reader that hands it this box's.
|
|
10
|
+
* config.mjs still exports the measured `MACHINE`, and `deriveMaxConcurrent`
|
|
11
|
+
* keeps its (memBytes, cores) contract.
|
|
12
|
+
*
|
|
13
|
+
* `os.totalmem()` and `cpus().length` report the HOST inside a container: a
|
|
14
|
+
* 4GB container on a 256GB box reads 256GB and cheerfully oversubscribes until
|
|
15
|
+
* the OOM killer picks a victim — which, because it picks by resident size, is
|
|
16
|
+
* frequently not the task that caused it. cgroup v2 publishes the real limits,
|
|
17
|
+
* so read those first and treat the os module as the fallback it is.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { readFileSync } from 'node:fs';
|
|
21
|
+
import { cpus, totalmem } from 'node:os';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The limits from what was read. `memMax` / `cpuMax` are the raw cgroup v2
|
|
25
|
+
* file contents (trimmed), or null when there is no such file; anything
|
|
26
|
+
* unparseable is ignored and the host figure stands.
|
|
27
|
+
*/
|
|
28
|
+
export function deriveMachineLimits({ memMax = null, cpuMax = null, hostMemBytes, hostCores } = {}) {
|
|
29
|
+
let memBytes = hostMemBytes;
|
|
30
|
+
if (memMax && memMax !== 'max') {
|
|
31
|
+
const n = Number(memMax);
|
|
32
|
+
if (Number.isFinite(n) && n > 0) memBytes = Math.min(memBytes, n);
|
|
33
|
+
}
|
|
34
|
+
let cores = hostCores || 2;
|
|
35
|
+
// "<quota> <period>" in microseconds, or "max <period>" for unlimited.
|
|
36
|
+
if (cpuMax && !cpuMax.startsWith('max')) {
|
|
37
|
+
const [q, p] = cpuMax.split(/\s+/).map(Number);
|
|
38
|
+
if (Number.isFinite(q) && Number.isFinite(p) && p > 0) {
|
|
39
|
+
cores = Math.max(1, Math.min(cores, Math.floor(q / p)));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return { memBytes, cores };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const readCgroup = (f) => {
|
|
46
|
+
try {
|
|
47
|
+
return readFileSync(`/sys/fs/cgroup/${f}`, 'utf8').trim();
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** This box's limits: its cgroup files and its os module, handed to the rule. */
|
|
54
|
+
export function measureMachineLimits(read = readCgroup) {
|
|
55
|
+
return deriveMachineLimits({
|
|
56
|
+
memMax: read('memory.max'),
|
|
57
|
+
cpuMax: read('cpu.max'),
|
|
58
|
+
hostMemBytes: totalmem(),
|
|
59
|
+
hostCores: cpus().length,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `npx flowviant machines` — every project connected on THIS box, and every box
|
|
3
|
+
* that has polled each of them (0.91.0).
|
|
4
|
+
*
|
|
5
|
+
* ── WHY A THIRD COMMAND EXISTS AT ALL ──
|
|
6
|
+
*
|
|
7
|
+
* The terminal surface is `npx flowviant` and `npx flowviant login`, full stop.
|
|
8
|
+
* That is the owner's own ruling and it is what deleted the terminal flag that
|
|
9
|
+
* used to move a project's machine. This amends it BY HIS OWN REQUEST — "the CLI gets a command to list connections" —
|
|
10
|
+
* and it shipped in the narrowest way the ruling could survive: VIEW-ONLY,
|
|
11
|
+
* starting nothing, stopping nothing, moving nothing, the one write behind an
|
|
12
|
+
* explicit `--forget`.
|
|
13
|
+
*
|
|
14
|
+
* ── AND IT GREW VERBS, ON HIS SECOND REQUEST (2026-09-23, 0.95.0) ──
|
|
15
|
+
*
|
|
16
|
+
* Verbatim: *"when i run npx flowviant machines, it just exits out of cli, it
|
|
17
|
+
* seems to be only a read cmd but I want to be able to interact with it and
|
|
18
|
+
* use arrow keys to select the machines or to remove them"* — and, of the
|
|
19
|
+
* duplicate it showed him, *"there should be a cli to remove it as well as a
|
|
20
|
+
* ui on flowviant to remove it."* So on a terminal that can draw a menu the
|
|
21
|
+
* listing is followed by one: ↑/↓ over the projects connected on THIS box,
|
|
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, AND IT IS THIS FILE ──
|
|
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
|
+
*
|
|
65
|
+
* THREE FILES, ONE COMMAND (SOLID audit 2026-09-26, F056). `machines.mjs` held
|
|
66
|
+
* the listing, the two fleet requests and the destructive disconnect in one
|
|
67
|
+
* 517-line module with three sets of callers and three kinds of test. The
|
|
68
|
+
* split follows those callers: this file is the PURE listing and its copy
|
|
69
|
+
* (the daemon's startup line and `views.mjs` import from here too);
|
|
70
|
+
* `machines.mjs` is the two requests to `/fleet/boxes` and the one rule that
|
|
71
|
+
* classifies their answers; `machineDisconnect.mjs` is the stop → leave →
|
|
72
|
+
* forget orchestration. Each importer names the file it means.
|
|
73
|
+
*
|
|
74
|
+
* It deliberately imports NOTHING from fleet.mjs: that module pulls the whole
|
|
75
|
+
* daemon (the CLIs, the worktree machinery, the preview tunnels) and this
|
|
76
|
+
* command runs before the auth gate, like `stop` and `projects`. The small
|
|
77
|
+
* duration formatter below is a second copy of a ten-line function, knowingly,
|
|
78
|
+
* and it says different words anyway — "heard 12s ago" against "last heard 3d
|
|
79
|
+
* ago", where the word LAST is the staleness mark.
|
|
80
|
+
*/
|
|
81
|
+
|
|
82
|
+
import { projectLabel, repoCollisions, safeName } from './credentials.mjs';
|
|
83
|
+
import { connectedOn } from './credentialDate.mjs';
|
|
84
|
+
import { launchCommand, terminalCommand } from './launchCommand.mjs';
|
|
85
|
+
|
|
86
|
+
/** The one answer `agoFrom` gives that is a PHRASE rather than a duration, so
|
|
87
|
+
* it is the one answer that must not take an " ago" after it. Named because
|
|
88
|
+
* two callers have to agree about it and a bare string literal is how they
|
|
89
|
+
* stopped agreeing. */
|
|
90
|
+
const JUST_NOW = 'just now';
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A coarse age. Never "0s": a box heard a moment ago reads as "just now",
|
|
94
|
+
* because a daemon that polled four hundred milliseconds ago has not been
|
|
95
|
+
* silent for no time at all — the same rule `agoLabel` keeps in holder.mjs and
|
|
96
|
+
* `elapsed` keeps in the web.
|
|
97
|
+
*
|
|
98
|
+
* NULL for anything unmeasurable, so the caller DROPS the clause rather than
|
|
99
|
+
* printing a duration nobody took.
|
|
100
|
+
*/
|
|
101
|
+
export function agoFrom(iso, now = Date.now()) {
|
|
102
|
+
const t = Date.parse(iso ?? '');
|
|
103
|
+
if (Number.isNaN(t)) return null;
|
|
104
|
+
const ms = Math.max(0, now - t);
|
|
105
|
+
const s = Math.round(ms / 1000);
|
|
106
|
+
if (s < 5) return JUST_NOW;
|
|
107
|
+
if (s < 90) return `${s}s`;
|
|
108
|
+
const m = Math.round(s / 60);
|
|
109
|
+
if (m < 90) return `${m}m`;
|
|
110
|
+
const h = Math.round(m / 60);
|
|
111
|
+
if (h < 36) return `${h}h`;
|
|
112
|
+
return `${Math.round(h / 24)}d`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* WHEN THIS BOX WAS LAST HEARD FROM, as a whole phrase — and the phrase is one
|
|
117
|
+
* function because the two callers got it WRONG THE SAME WAY (2026-09-19, the
|
|
118
|
+
* review).
|
|
119
|
+
*
|
|
120
|
+
* `agoFrom` answers with a DURATION for everything except the newest case,
|
|
121
|
+
* where it answers with the words "just now" — and both callers appended
|
|
122
|
+
* " ago", so the most common row in the listing, a daemon that polled four
|
|
123
|
+
* hundred milliseconds ago, read "heard just now ago". On the serving box. On
|
|
124
|
+
* the first line anybody looks at.
|
|
125
|
+
*
|
|
126
|
+
* Stated once here rather than fixed twice: a duration takes the suffix, a
|
|
127
|
+
* phrase does not, and "LAST heard" is the staleness mark the whole listing
|
|
128
|
+
* turns on. An unmeasurable stamp says "never heard" rather than printing a
|
|
129
|
+
* duration nobody took.
|
|
130
|
+
*/
|
|
131
|
+
export function heardPhrase(box, now = Date.now()) {
|
|
132
|
+
const ago = agoFrom(box?.lastHeardAt, now);
|
|
133
|
+
if (!ago) return 'never heard';
|
|
134
|
+
const lead = box?.fresh ? 'heard' : 'last heard';
|
|
135
|
+
return ago === JUST_NOW ? `${lead} ${ago}` : `${lead} ${ago} ago`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* THE SERVER'S OWN ROLE WORD, PRINTED. A pure relay and nothing else.
|
|
140
|
+
*
|
|
141
|
+
* It carried one special case until 2026-09-21: `'standing-by'` was respelled
|
|
142
|
+
* as `standing by`, because the server sent a hyphenated enum value and a
|
|
143
|
+
* terminal should not print an identifier. The owner replaced that word —
|
|
144
|
+
* asked whether a box should keep saying "standing by", he answered *"no, it
|
|
145
|
+
* can [be] inactive instead"* — so the server sends `serving` and `inactive`,
|
|
146
|
+
* both of which are already words, and the special case had nothing left to
|
|
147
|
+
* translate.
|
|
148
|
+
*
|
|
149
|
+
* IT IS NOT KEPT "IN CASE": a mapping table in a relay is a place for the two
|
|
150
|
+
* ends to disagree, and a stale entry would print one thing while the app
|
|
151
|
+
* printed another about the same box — which is precisely the confusion this
|
|
152
|
+
* command exists to end. A role this build has never heard of passes through
|
|
153
|
+
* untouched for the same reason.
|
|
154
|
+
*
|
|
155
|
+
* An ABSENT role prints nothing rather than a guess. It used to default to
|
|
156
|
+
* `idle`, which is a CLAIM — "the server told us this box is doing nothing" —
|
|
157
|
+
* about an answer the server did not give.
|
|
158
|
+
*/
|
|
159
|
+
function roleWord(role) {
|
|
160
|
+
return typeof role === 'string' && role.trim() ? role.trim() : '';
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** One box, as a line under its project. The MARK is the only derived thing on
|
|
164
|
+
* it — a filled dot for a box the server still calls fresh, hollow for one it
|
|
165
|
+
* does not — and the words beside it are all the server's or the box's own. */
|
|
166
|
+
export function renderBox(box, { me = null, latest = null } = {}, now = Date.now()) {
|
|
167
|
+
const mark = box.fresh ? '●' : '○';
|
|
168
|
+
// SCRUBBED HERE TOO (2026-09-24, the audit) — a belt against the server's
|
|
169
|
+
// own display scrub, in defence of a stale stored row or a server this
|
|
170
|
+
// fix has not reached: the terminal is where an escape sequence in a
|
|
171
|
+
// box's name or its checkout path actually does harm, so this is the one
|
|
172
|
+
// place it must never be trusted to have already been cleaned. See
|
|
173
|
+
// printable.mjs.
|
|
174
|
+
const name = safeName(box.boxName) ?? 'an unnamed machine';
|
|
175
|
+
// "LAST heard" is the staleness mark, and the row is never hidden for it —
|
|
176
|
+
// the owner's answer to "what about a box that stopped" was exactly this.
|
|
177
|
+
const heard = heardPhrase(box, now);
|
|
178
|
+
// An unreported role DROPS ITS CELL. Every other cell on this row states its
|
|
179
|
+
// own absence ("(checkout not reported)") because a reader who cannot see a
|
|
180
|
+
// path needs to know nobody measured one; a role is different — the row is
|
|
181
|
+
// already carrying the mark, the version and the last-heard, and a
|
|
182
|
+
// placeholder there would be four words of nothing in the widest column.
|
|
183
|
+
const cells = [
|
|
184
|
+
`${mark} ${name}`,
|
|
185
|
+
roleWord(box.role),
|
|
186
|
+
safeName(box.checkoutPath) ?? '(checkout not reported)',
|
|
187
|
+
box.daemonVersion ? `v${box.daemonVersion}` : 'version not reported',
|
|
188
|
+
heard,
|
|
189
|
+
].filter(Boolean);
|
|
190
|
+
let line = ` ${cells.join(' ')}`;
|
|
191
|
+
if (me && box.boxId === me) line += ' ← this box';
|
|
192
|
+
// Said only when the server said it, and it names the version that is
|
|
193
|
+
// actually published rather than a feature floor — a floor may sit above
|
|
194
|
+
// LATEST on purpose, and an instruction to install something npm does not
|
|
195
|
+
// serve is worse than silence.
|
|
196
|
+
if (box.behind && latest) line += ` · behind, ${latest} is latest`;
|
|
197
|
+
return line;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* THE WHOLE LISTING, as lines. Pure over (entries, results, now).
|
|
202
|
+
*
|
|
203
|
+
* `results` is keyed by projectId and each value is one of:
|
|
204
|
+
* { boxes, latest, me } the server answered
|
|
205
|
+
* { rejected: true } 401/403 — the credential is dead here
|
|
206
|
+
* { unsupported: true } 404 — an older server with no boxes route
|
|
207
|
+
* { error: '<words>' } anything else, relayed rather than summarised
|
|
208
|
+
* A project with no entry in `results` is treated as unreachable, because that
|
|
209
|
+
* is the only thing an absent answer can honestly mean.
|
|
210
|
+
*/
|
|
211
|
+
export function renderMachines(entries, results, { now = Date.now() } = {}) {
|
|
212
|
+
const lines = [];
|
|
213
|
+
// WHICH LABELS COLLIDE. Two projects genuinely called the same thing is the
|
|
214
|
+
// case that produced this command, so the id carries it — and it is printed
|
|
215
|
+
// on EVERY row rather than only the colliding ones, because a reader who has
|
|
216
|
+
// to notice a suffix to know whether it matters has already been asked to do
|
|
217
|
+
// the work this listing is for.
|
|
218
|
+
for (const e of entries) {
|
|
219
|
+
const label = projectLabel(e);
|
|
220
|
+
const connected = connectedOn(e.savedAt);
|
|
221
|
+
const id = `(id ${e.projectId.slice(0, 8)}…${connected ? `, connected ${connected}` : ''})`;
|
|
222
|
+
lines.push(
|
|
223
|
+
` ${label} · ${e.repoRoot || 'not bound to a repo yet'} ${id}`
|
|
224
|
+
);
|
|
225
|
+
const r = results?.[e.projectId];
|
|
226
|
+
if (!r || r.error) {
|
|
227
|
+
// The failure is RELAYED, not summarised, and the loop carries on: one
|
|
228
|
+
// unreachable project must not cost you the listing for the others.
|
|
229
|
+
lines.push(` could not ask the server — ${r?.error ?? 'no answer'}`);
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (r.rejected) {
|
|
233
|
+
lines.push(
|
|
234
|
+
' credential rejected — this project was disconnected or deleted. ' +
|
|
235
|
+
`\`${terminalCommand(`machines --forget ${e.projectId.slice(0, 8)}`)}\` removes it here.`
|
|
236
|
+
);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (r.unsupported) {
|
|
240
|
+
lines.push(' server does not report machines yet');
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
const boxes = Array.isArray(r.boxes) ? r.boxes : [];
|
|
244
|
+
if (boxes.length === 0) {
|
|
245
|
+
// NOT "no machines". Nothing has polled this credential, which is a
|
|
246
|
+
// different fact from a project whose machines have all gone quiet — and
|
|
247
|
+
// a quiet one still has rows, wearing its last-heard.
|
|
248
|
+
lines.push(' (no machine has polled this project)');
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
for (const b of boxes) lines.push(renderBox(b, { me: r.me, latest: r.latest }, now));
|
|
252
|
+
}
|
|
253
|
+
const collisions = renderCollisions(entries);
|
|
254
|
+
if (collisions.length) lines.push('', ...collisions);
|
|
255
|
+
return lines;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** A project as ONE phrase for a sentence that names two of them — the name,
|
|
259
|
+
* the id, and the connected date, which is the fact a person can match
|
|
260
|
+
* against their own memory ("I set that one up last month"). */
|
|
261
|
+
export function projectPhrase(e) {
|
|
262
|
+
const connected = connectedOn(e?.savedAt);
|
|
263
|
+
return `${projectLabel(e)} (${String(e?.projectId ?? '').slice(0, 8)}…${connected ? `, connected ${connected}` : ''})`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* TWO PROJECTS ON ONE REPO, SAID OUT LOUD (2026-09-23).
|
|
268
|
+
*
|
|
269
|
+
* The owner ran this command, saw "BRIF AI" twice, and concluded he had two
|
|
270
|
+
* daemons for one project. The id on each row said they were different
|
|
271
|
+
* projects; nothing said they were bound to the SAME checkout, which is the
|
|
272
|
+
* fact that makes it a duplicate rather than two rows. So the listing ends
|
|
273
|
+
* with one line per such repo, naming every project on it in full, the rule
|
|
274
|
+
* (a directory serves one project — the owner's 2026-09-23 ruling, which made
|
|
275
|
+
* the start there a refusal), and the remedy in his order: delete the project
|
|
276
|
+
* in Flowviant, or disconnect this box from it (the menu below, or the flags).
|
|
277
|
+
*
|
|
278
|
+
* Pure over the entries. An unbound project collides with nothing.
|
|
279
|
+
*/
|
|
280
|
+
export function renderCollisions(entries) {
|
|
281
|
+
return repoCollisions(entries).map((g) => {
|
|
282
|
+
const names = g.entries.map(projectPhrase);
|
|
283
|
+
const list = `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
|
|
284
|
+
return (
|
|
285
|
+
` ! ${g.entries.length} projects are connected for ${g.repoRoot}: ${list} — ` +
|
|
286
|
+
`a directory serves one project, and \`${launchCommand()}\` there refuses to start until it does. ` +
|
|
287
|
+
'Delete the one you do not mean in Flowviant (project settings → General → Delete project), or disconnect this box from it.'
|
|
288
|
+
);
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* THE FOOTER. Two sentences, both of which exist because this listing is the
|
|
294
|
+
* moment somebody is confused — the same reason `flowviant projects` names its
|
|
295
|
+
* remedies inline rather than in a manual.
|
|
296
|
+
*
|
|
297
|
+
* The first says where the verbs are, because there are none here. The second
|
|
298
|
+
* states this command's own SCOPE, which is the one thing a reader cannot see
|
|
299
|
+
* from the output: it lists what is connected on THIS box, and a daemon running
|
|
300
|
+
* on a computer that has never met this one is invisible to it. The app's Home
|
|
301
|
+
* is where "all of them" lives, and pretending otherwise would be this listing
|
|
302
|
+
* quietly answering a narrower question than the one asked.
|
|
303
|
+
*/
|
|
304
|
+
export const MACHINES_FOOTER = [
|
|
305
|
+
' stop a machine from the app: project settings → Machine → Disconnect;',
|
|
306
|
+
` a new \`${launchCommand()}\` on another box takes the project over.`,
|
|
307
|
+
' this lists the projects connected on THIS box; the app’s Home lists every machine on your account.',
|
|
308
|
+
];
|
|
309
|
+
|
|
310
|
+
/** THE SCRIPT'S VERBS, said only when there was no menu to offer them in — a
|
|
311
|
+
* terminal that could draw the menu was offered the same two things by name,
|
|
312
|
+
* and repeating the flags under it would be a manual under a control. */
|
|
313
|
+
export const MACHINES_FLAGS_FOOTER = [
|
|
314
|
+
` \`${terminalCommand('machines --remove <id>')}\` disconnects this box from a project: stops its daemon here,`,
|
|
315
|
+
' removes this box from that project’s machines list in the app, and forgets its credential here.',
|
|
316
|
+
' `--forget <id>` forgets the credential here only.',
|
|
317
|
+
];
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* THE OTHER BOXES ON THIS PROJECT, as ONE line for the daemon's startup — or
|
|
321
|
+
* null when there are none, which is the common case and must print nothing.
|
|
322
|
+
*
|
|
323
|
+
* Said ONCE per process, after the first successful poll, and never per poll:
|
|
324
|
+
* the fact only changes when somebody starts a daemon somewhere else, and a
|
|
325
|
+
* true sentence restated every ten seconds is a scrolling console nobody reads.
|
|
326
|
+
* THE CALLING BOX IS EXCLUDED — it is the one box the person at that terminal
|
|
327
|
+
* does not need to be told about.
|
|
328
|
+
*/
|
|
329
|
+
export function otherBoxesLine(boxes, me, now = Date.now()) {
|
|
330
|
+
const others = (Array.isArray(boxes) ? boxes : []).filter((b) => b && b.boxId !== me);
|
|
331
|
+
if (others.length === 0) return null;
|
|
332
|
+
const parts = others.slice(0, 6).map((b) => {
|
|
333
|
+
const role = roleWord(b.role);
|
|
334
|
+
return `${safeName(b.boxName) ?? 'an unnamed machine'} (${role ? `${role} · ` : ''}${heardPhrase(b, now)})`;
|
|
335
|
+
});
|
|
336
|
+
const more = others.length - parts.length;
|
|
337
|
+
return `other machines on this project: ${parts.join(', ')}${more > 0 ? `, and ${more} more` : ''}`;
|
|
338
|
+
}
|