flowviant 0.115.0 → 0.117.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/README.md +31 -12
- package/bin/cli.mjs +13 -17
- package/bin/lib/admission.mjs +7 -14
- package/bin/lib/agentInternalWait.mjs +83 -0
- package/bin/lib/agentPlan.mjs +56 -24
- package/bin/lib/agentPublish.mjs +12 -13
- package/bin/lib/agentTaskKinds.mjs +4 -7
- package/bin/lib/agySessions.mjs +3 -3
- package/bin/lib/artifactPreview.mjs +92 -18
- package/bin/lib/artifactReporter.mjs +16 -8
- package/bin/lib/artifacts.mjs +64 -60
- package/bin/lib/authproxy.mjs +2 -1
- package/bin/lib/childEnv.mjs +9 -4
- package/bin/lib/claude.mjs +10 -20
- package/bin/lib/claudePersonal.mjs +71 -6
- package/bin/lib/claudePosture.mjs +119 -42
- package/bin/lib/claudeSessions.mjs +7 -7
- package/bin/lib/claudeStream.mjs +7 -11
- package/bin/lib/cliLimit.mjs +2 -2
- package/bin/lib/codexHome.mjs +17 -0
- package/bin/lib/codexPersonal.mjs +44 -3
- package/bin/lib/codexRelay.mjs +21 -5
- package/bin/lib/codexResearch.mjs +180 -0
- package/bin/lib/config.mjs +37 -24
- package/bin/lib/conversationIds.mjs +51 -0
- package/bin/lib/credentials.mjs +2 -7
- package/bin/lib/daemonLocks.mjs +88 -0
- package/bin/lib/daemonState.mjs +5 -5
- package/bin/lib/deploy.mjs +6 -9
- package/bin/lib/deployCheckout.mjs +26 -22
- package/bin/lib/deployRunner.mjs +54 -18
- package/bin/lib/deployWire.mjs +32 -23
- package/bin/lib/desktopContract.mjs +3 -5
- package/bin/lib/env.mjs +104 -35
- package/bin/lib/fleet.mjs +7 -9
- package/bin/lib/fleetPost.mjs +172 -25
- package/bin/lib/fleetReports.mjs +130 -170
- package/bin/lib/fleetStartup.mjs +6 -6
- package/bin/lib/fleetWire.mjs +42 -0
- package/bin/lib/git.mjs +111 -61
- package/bin/lib/gitMergeIdentity.mjs +2 -8
- package/bin/lib/grant.mjs +23 -7
- package/bin/lib/holder.mjs +108 -81
- package/bin/lib/hooks/readGuard.mjs +41 -7
- package/bin/lib/install.mjs +80 -33
- package/bin/lib/instance.mjs +36 -83
- package/bin/lib/intakeDraft.mjs +4 -1
- package/bin/lib/knowledgeFileSync.mjs +31 -0
- package/bin/lib/knowledgeFiles.mjs +28 -83
- package/bin/lib/landed.mjs +22 -29
- package/bin/lib/launchCommand.mjs +91 -0
- package/bin/lib/listeners.mjs +0 -5
- package/bin/lib/localSessions.mjs +52 -39
- package/bin/lib/login.mjs +22 -12
- package/bin/lib/loginPath.mjs +22 -17
- package/bin/lib/machineListing.mjs +6 -3
- package/bin/lib/mcp-cli.mjs +9 -10
- package/bin/lib/noWindowEnv.mjs +23 -3
- package/bin/lib/ownedDirs.mjs +99 -0
- package/bin/lib/pinnedCapture.mjs +383 -0
- package/bin/lib/prWorkflow.mjs +94 -17
- package/bin/lib/preflight.mjs +8 -25
- package/bin/lib/preview.mjs +19 -25
- package/bin/lib/previewGatePolicy.mjs +34 -9
- package/bin/lib/previewRegistry.mjs +4 -11
- package/bin/lib/printable.mjs +70 -7
- package/bin/lib/procRegistry.mjs +33 -13
- package/bin/lib/procScan.mjs +2 -3
- package/bin/lib/procStat.mjs +76 -0
- package/bin/lib/processGroup.mjs +48 -0
- package/bin/lib/processes.mjs +10 -24
- package/bin/lib/projectToolRuntimes.mjs +101 -16
- package/bin/lib/projectTools.mjs +50 -32
- package/bin/lib/prompts.mjs +41 -19
- package/bin/lib/repoPath.mjs +19 -0
- package/bin/lib/repoState.mjs +17 -19
- package/bin/lib/runTurn.mjs +268 -210
- package/bin/lib/runtimeAntigravity.mjs +16 -5
- package/bin/lib/runtimeCapabilities.mjs +140 -20
- package/bin/lib/runtimeClaude.mjs +55 -5
- package/bin/lib/runtimeCodex.mjs +245 -205
- package/bin/lib/runtimeDetection.mjs +2 -2
- package/bin/lib/runtimeEvents.mjs +16 -10
- package/bin/lib/runtimeLimits.mjs +5 -6
- package/bin/lib/runtimeOfferings.mjs +24 -11
- package/bin/lib/runtimes.mjs +10 -0
- package/bin/lib/shipSweep.mjs +5 -3
- package/bin/lib/shot.mjs +91 -17
- package/bin/lib/sleep.mjs +11 -0
- package/bin/lib/startProject.mjs +5 -5
- package/bin/lib/statePaths.mjs +40 -0
- package/bin/lib/tty.mjs +25 -15
- package/bin/lib/turnLock.mjs +7 -14
- package/bin/lib/turnProfile.mjs +1 -1
- package/bin/lib/ui.mjs +0 -3
- package/bin/lib/uninstall.mjs +30 -26
- package/bin/lib/update.mjs +79 -27
- package/bin/lib/uplinkScrub.mjs +296 -31
- package/bin/lib/vault.mjs +78 -10
- package/bin/lib/vaultDiff.mjs +5 -2
- package/bin/lib/views.mjs +37 -12
- package/bin/lib/wikiRunner.mjs +40 -61
- package/bin/lib/work.mjs +17 -7
- package/bin/lib/workAdoptCarry.mjs +2 -4
- package/bin/lib/workAgentCheck.mjs +104 -57
- package/bin/lib/workAgentMerges.mjs +62 -57
- package/bin/lib/workAgentPlans.mjs +99 -145
- package/bin/lib/workAgentPrecheck.mjs +71 -138
- package/bin/lib/workAgentPublish.mjs +6 -8
- package/bin/lib/workAgentTurnBegun.mjs +15 -11
- package/bin/lib/workAgentTurnExecution.mjs +46 -53
- package/bin/lib/workAgentTurnReports.mjs +9 -37
- package/bin/lib/workAgentTurns.mjs +4 -3
- package/bin/lib/workAttachments.mjs +66 -11
- package/bin/lib/workBrain.mjs +9 -4
- package/bin/lib/workCappedTurn.mjs +109 -0
- package/bin/lib/workChildren.mjs +5 -5
- package/bin/lib/workCommandAudit.mjs +11 -2
- package/bin/lib/workDiffs.mjs +52 -6
- package/bin/lib/workIntake.mjs +35 -60
- package/bin/lib/workPlaceReports.mjs +7 -4
- package/bin/lib/workPlaces.mjs +96 -30
- package/bin/lib/workPreviews.mjs +47 -112
- package/bin/lib/workProcesses.mjs +8 -35
- package/bin/lib/workPullRequests.mjs +37 -56
- package/bin/lib/workReportQueue.mjs +100 -148
- package/bin/lib/workRetire.mjs +2 -1
- package/bin/lib/workSessionRuntime.mjs +38 -37
- package/bin/lib/workSessionTurns.mjs +111 -189
- package/bin/lib/workShip.mjs +43 -23
- package/bin/lib/workTurnShape.mjs +163 -0
- package/npm-shrinkwrap.json +59 -0
- package/package.json +3 -2
- package/bin/lib/fleetJobs.mjs +0 -120
- package/bin/lib/patchRevert.mjs +0 -60
|
@@ -8,12 +8,11 @@ import { FLEET_URL, MACHINE_HOST, USER_AGENT, VERSION } from './config.mjs';
|
|
|
8
8
|
import { listStoredProjects } from './credentials.mjs';
|
|
9
9
|
import { readStoredPubB64 } from './boxIdentity.mjs';
|
|
10
10
|
import { daemonRunningFor, drainingFor } from './instance.mjs';
|
|
11
|
-
import {
|
|
11
|
+
import { installChannel } from './launchCommand.mjs';
|
|
12
12
|
import { boxesUrlFrom, classifyMachineResponse, fetchBoxesFor } from './machines.mjs';
|
|
13
13
|
import { daemonLockPid, daemonLogPath, readDaemonState } from './daemonState.mjs';
|
|
14
14
|
import { detectRuntimes } from './runtimeDetection.mjs';
|
|
15
15
|
import { readClaudeAuthStatus } from './runtimeAuth.mjs';
|
|
16
|
-
import { runningCompiledBinary } from './update.mjs';
|
|
17
16
|
import { fleetEndpoint } from './fleetWire.mjs';
|
|
18
17
|
|
|
19
18
|
/**
|
|
@@ -40,9 +39,8 @@ export function busyOf(liveState, now = Date.now()) {
|
|
|
40
39
|
return liveState.busy;
|
|
41
40
|
}
|
|
42
41
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
42
|
+
// One home for the channel (launchCommand.mjs); re-exported for views.mjs.
|
|
43
|
+
export { installChannel };
|
|
46
44
|
|
|
47
45
|
export function desktopStatus({ entries = listStoredProjects(), runtimes = detectRuntimes(), runningFor = daemonRunningFor, stateFor = readDaemonState, pidFor = daemonLockPid, drainFor = drainingFor, nowMs = Date.now(), authFor = readClaudeAuthStatus, refreshAuth = false } = {}) {
|
|
48
46
|
const runtimeRows = runtimes.map((rt) => ({
|
package/bin/lib/env.mjs
CHANGED
|
@@ -92,7 +92,18 @@ import { learnSecrets } from './uplinkScrub.mjs';
|
|
|
92
92
|
* monorepo's shared types. The daemon holds its own line for the same reason
|
|
93
93
|
* every other boundary in this file does — a report that the server will
|
|
94
94
|
* reject is a report nobody sees, and clamping here is what makes the
|
|
95
|
-
* difference visible as a smaller list
|
|
95
|
+
* difference visible as a smaller list.
|
|
96
|
+
*
|
|
97
|
+
* ── THE RELEASE GATE HOLDS THEM (2026-10-10, audit F093) ──
|
|
98
|
+
*
|
|
99
|
+
* `ENV_REPORT_BOUNDS` and `ENV_NAME_RE` are exported for
|
|
100
|
+
* `scripts/check-app-parity.mjs`, which compares them with the shared
|
|
101
|
+
* `MACHINE_ENV_MAX_FILES` / `_MAX_VARS` / `_MAX_NAME` / `_FP_LENGTH` and
|
|
102
|
+
* `machineEnvVarSchema`'s name pattern. Drift would be silent: POST
|
|
103
|
+
* /fleet/env-report answers a body its schema refuses with 200 `stored:false`
|
|
104
|
+
* (and its reader drops a bad file alone), so this lane would see `ok`, dedup,
|
|
105
|
+
* and the comparison sheet would simply stop hearing from this box. `vars` is
|
|
106
|
+
* a TOTAL on both ends (the server's reader spends one budget across files).
|
|
96
107
|
*
|
|
97
108
|
* ── THE REPORT'S CAPS ARE NOT THE SCRUBBER'S, AND THAT ASYMMETRY IS
|
|
98
109
|
* DELIBERATE (2026-09-21, the review) ──
|
|
@@ -112,23 +123,41 @@ import { learnSecrets } from './uplinkScrub.mjs';
|
|
|
112
123
|
* actually read, so the totals are honest about a directory somebody has let
|
|
113
124
|
* grow without this scan walking an unbounded list of files on the poll's beat.
|
|
114
125
|
*/
|
|
115
|
-
const
|
|
116
|
-
const
|
|
117
|
-
const
|
|
126
|
+
export const ENV_REPORT_BOUNDS = Object.freeze({ files: 8, vars: 200, name: 64, fp: 8 });
|
|
127
|
+
const MAX_ENV_FILES = ENV_REPORT_BOUNDS.files;
|
|
128
|
+
const MAX_ENV_VARS = ENV_REPORT_BOUNDS.vars; // TOTAL across every file, not per file — REPORT only
|
|
129
|
+
const MAX_ENV_NAME_CHARS = ENV_REPORT_BOUNDS.name;
|
|
118
130
|
const MAX_ENV_FILE_BYTES = 256 * 1024;
|
|
119
131
|
const MAX_SCAN_FILES = 64;
|
|
120
132
|
const MAX_SCRUB_VALUES = 2000;
|
|
121
|
-
const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
133
|
+
export const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
122
134
|
|
|
123
135
|
/** `.env`, `.env.local`, `.env.production` … and never a template. A file whose
|
|
124
136
|
* name ends `.example` / `.sample` / `.template` is somebody's DOCUMENTATION
|
|
125
137
|
* of the shape, committed on purpose, and its "values" are placeholders — so
|
|
126
138
|
* it would fingerprint `your-key-here` on every box and report them as
|
|
127
139
|
* agreeing, which is the exact opposite of what a comparison is for. */
|
|
128
|
-
const isEnvFileName = (name) =>
|
|
140
|
+
export const isEnvFileName = (name) =>
|
|
129
141
|
(name === '.env' || name.startsWith('.env.')) &&
|
|
130
142
|
!/\.(example|sample|template)$/i.test(name);
|
|
131
143
|
|
|
144
|
+
/**
|
|
145
|
+
* THE OTHER RULE FOR "AN ENV FILE", AND WHY THE TWO DIFFER (2026-10-10, the
|
|
146
|
+
* audit's F085; it moved here from deployCheckout.mjs so both live in one
|
|
147
|
+
* place). `isEnvFileName` decides what the COMPARISON REPORT reads at the
|
|
148
|
+
* checkout root: `.env*` minus templates, because a template's placeholders
|
|
149
|
+
* would fingerprint as agreement. This one decides what a DEPLOY COPIES into
|
|
150
|
+
* its throwaway worktree, at every tracked directory three levels deep: every
|
|
151
|
+
* `.env*` and `.dev.vars` a build might load, templates included, because a
|
|
152
|
+
* build that reads one must find it. Copying is about making the build work;
|
|
153
|
+
* reporting is about a screen a person reads. The deploy lane teaches the
|
|
154
|
+
* scrubber the files it actually copied (deployRunner.mjs), so the wider rule
|
|
155
|
+
* never widens what goes unredacted. (vaultArtefacts.mjs keeps a third, wider
|
|
156
|
+
* net on purpose: it looks only for files the deleted vault wrote, which it
|
|
157
|
+
* recognises by their header, never by their name alone.)
|
|
158
|
+
*/
|
|
159
|
+
export const isCheckoutEnvFile = (name) => /^(?:\.env(?:\..+)?|\.dev\.vars)$/.test(name);
|
|
160
|
+
|
|
132
161
|
/**
|
|
133
162
|
* ONE LINE OF DOTENV. Blank lines and `#` comments are skipped, an optional
|
|
134
163
|
* `export ` prefix is allowed, the line is split on the FIRST `=` (a value may
|
|
@@ -230,6 +259,55 @@ function closingQuoteLine(lines, start, quote) {
|
|
|
230
259
|
return lines.length;
|
|
231
260
|
}
|
|
232
261
|
|
|
262
|
+
/**
|
|
263
|
+
* ONE DOTENV FILE'S TEXT → `[{ name, value }]`, the variables in force — pure
|
|
264
|
+
* (2026-10-10, extracted from `scanEnvFiles` so the deploy lane parses the
|
|
265
|
+
* nested files it copies with the SAME rules the report uses).
|
|
266
|
+
*
|
|
267
|
+
* A MAP, because LAST WRITE WINS inside one file — which is how a dotenv
|
|
268
|
+
* loader resolves a repeated key, so the fingerprint reported is the one
|
|
269
|
+
* actually in force. It also makes the totals honest: a file that sets the
|
|
270
|
+
* same key forty times counts once, not forty times.
|
|
271
|
+
*/
|
|
272
|
+
export function parseEnvText(text) {
|
|
273
|
+
const byName = new Map();
|
|
274
|
+
const lines = String(text ?? '').split('\n');
|
|
275
|
+
for (let i = 0; i < lines.length; i++) {
|
|
276
|
+
const parsed = parseEnvLine(lines[i]);
|
|
277
|
+
if (!parsed) continue;
|
|
278
|
+
// A VALUE WE CANNOT READ IS REPORTED AS NOTHING. The variable is
|
|
279
|
+
// dropped from BOTH halves and the scan steps over its continuation
|
|
280
|
+
// lines, so a PEM body can never be read as variables of its own.
|
|
281
|
+
if (parsed.unterminated) {
|
|
282
|
+
i = closingQuoteLine(lines, i, parsed.unterminated);
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
// A NAME THAT FAILS EITHER TEST IS DROPPED, NEVER TRUNCATED: a cut name
|
|
286
|
+
// is a DIFFERENT VARIABLE, and a comparison screen rendering two boxes'
|
|
287
|
+
// truncated names as the same row is worse than rendering neither.
|
|
288
|
+
if (parsed.name.length > MAX_ENV_NAME_CHARS) continue;
|
|
289
|
+
if (!ENV_NAME_RE.test(parsed.name)) continue;
|
|
290
|
+
if (!byName.has(parsed.name) && byName.size >= MAX_SCRUB_VALUES) continue;
|
|
291
|
+
byName.set(parsed.name, parsed.value);
|
|
292
|
+
}
|
|
293
|
+
return [...byName].map(([name, value]) => ({ name, value }));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* The values in ONE env file, for the scrubber — `[]` for anything that is not
|
|
298
|
+
* a regular file under the scan's size cap (the same `lstat` refusal of a
|
|
299
|
+
* symlink, for the same reason; see `scanEnvFiles`). Never throws.
|
|
300
|
+
*/
|
|
301
|
+
export function readEnvFileValues(path) {
|
|
302
|
+
try {
|
|
303
|
+
const st = lstatSync(path);
|
|
304
|
+
if (!st.isFile() || st.size > MAX_ENV_FILE_BYTES) return [];
|
|
305
|
+
return parseEnvText(readFileSync(path, 'utf8'));
|
|
306
|
+
} catch {
|
|
307
|
+
return [];
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
233
311
|
/**
|
|
234
312
|
* A VALUE FINGERPRINT — sha256 over `<projectId>\n<value>`, first 8 hex
|
|
235
313
|
* characters.
|
|
@@ -249,8 +327,22 @@ function closingQuoteLine(lines, start, quote) {
|
|
|
249
327
|
* `development`, `production`, `postgres`, `localhost`, `info` — so anybody who
|
|
250
328
|
* could read a stored report could recover those values with one precomputed
|
|
251
329
|
* table, and the eight characters that were chosen to be uninformative became
|
|
252
|
-
* a lookup key instead. Salting with the project id
|
|
253
|
-
*
|
|
330
|
+
* a lookup key instead. Salting with the project id stops ONE table serving
|
|
331
|
+
* every project, and that is all it stops.
|
|
332
|
+
*
|
|
333
|
+
* ── WHAT THE SALT DOES NOT STOP (2026-10-10, audit F094) ──
|
|
334
|
+
*
|
|
335
|
+
* The project id is no secret: every member of the project has it in the URL,
|
|
336
|
+
* and it is stored beside the report. To anyone who knows it, this digest
|
|
337
|
+
* still CONFIRMS A GUESS — hash `postgres`, `changeme` or every six-digit PIN
|
|
338
|
+
* with the id and compare eight hex — so for a weak value it is a per-project
|
|
339
|
+
* dictionary oracle, not "useless for recovering it". The server therefore
|
|
340
|
+
* never serves it back: the app's comparison receives an opaque equality group
|
|
341
|
+
* per (file, name) instead (`envComparisonView` in the app's
|
|
342
|
+
* `machineEnvReports.ts`). Keying this digest with a secret no member holds —
|
|
343
|
+
* an HMAC under the machine credential, with a scheme marker so mixed daemons
|
|
344
|
+
* read as "not comparable" rather than `differs` — is the at-rest half, and it
|
|
345
|
+
* is still an open item, not done here.
|
|
254
346
|
*
|
|
255
347
|
* IT COSTS THE FEATURE NOTHING, and that is what makes the trade obvious: the
|
|
256
348
|
* screen compares the BOXES OF ONE PROJECT. Two projects agreeing on a value is
|
|
@@ -263,7 +355,7 @@ function closingQuoteLine(lines, start, quote) {
|
|
|
263
355
|
* deliberately SHORT so nobody mistakes it for a commitment to the value.
|
|
264
356
|
*/
|
|
265
357
|
const fingerprint = (salt, value) =>
|
|
266
|
-
createHash('sha256').update(`${salt}\n${value}`, 'utf8').digest('hex').slice(0,
|
|
358
|
+
createHash('sha256').update(`${salt}\n${value}`, 'utf8').digest('hex').slice(0, ENV_REPORT_BOUNDS.fp);
|
|
267
359
|
|
|
268
360
|
/**
|
|
269
361
|
* READ THE CHECKOUT'S `.env*` FILES AND REPORT WHAT IS IN THEM BY NAME.
|
|
@@ -353,36 +445,13 @@ export function scanEnvFiles(repoRoot, salt) {
|
|
|
353
445
|
// file and contributes nothing, and neither is a SYMLINK: see above.
|
|
354
446
|
const st = lstatSync(abs);
|
|
355
447
|
if (!st.isFile() || st.size > MAX_ENV_FILE_BYTES) continue;
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
// actually in force. It also makes the totals honest: a file that sets
|
|
359
|
-
// the same key forty times counts once, not forty times.
|
|
360
|
-
const byName = new Map();
|
|
361
|
-
const lines = readFileSync(abs, 'utf8').split('\n');
|
|
362
|
-
for (let i = 0; i < lines.length; i++) {
|
|
363
|
-
const parsed = parseEnvLine(lines[i]);
|
|
364
|
-
if (!parsed) continue;
|
|
365
|
-
// A VALUE WE CANNOT READ IS REPORTED AS NOTHING. The variable is
|
|
366
|
-
// dropped from BOTH halves and the scan steps over its continuation
|
|
367
|
-
// lines, so a PEM body can never be read as variables of its own.
|
|
368
|
-
if (parsed.unterminated) {
|
|
369
|
-
i = closingQuoteLine(lines, i, parsed.unterminated);
|
|
370
|
-
continue;
|
|
371
|
-
}
|
|
372
|
-
// A NAME THAT FAILS EITHER TEST IS DROPPED, NEVER TRUNCATED: a cut name
|
|
373
|
-
// is a DIFFERENT VARIABLE, and a comparison screen rendering two boxes'
|
|
374
|
-
// truncated names as the same row is worse than rendering neither.
|
|
375
|
-
if (parsed.name.length > MAX_ENV_NAME_CHARS) continue;
|
|
376
|
-
if (!ENV_NAME_RE.test(parsed.name)) continue;
|
|
377
|
-
if (!byName.has(parsed.name) && byName.size >= MAX_SCRUB_VALUES) continue;
|
|
378
|
-
byName.set(parsed.name, parsed.value);
|
|
379
|
-
}
|
|
380
|
-
out.varsTotal += byName.size;
|
|
448
|
+
const parsed = parseEnvText(readFileSync(abs, 'utf8'));
|
|
449
|
+
out.varsTotal += parsed.length;
|
|
381
450
|
// Decided BEFORE this file spends any budget, so `budget` reaching zero
|
|
382
451
|
// inside a file still lets that file's own entry carry what it got.
|
|
383
452
|
const reporting = canReport && budget > 0 && out.files.length < MAX_ENV_FILES;
|
|
384
453
|
const vars = [];
|
|
385
|
-
for (const
|
|
454
|
+
for (const { name: n, value } of parsed) {
|
|
386
455
|
// THE SCRUB LIST IS NOT BOUNDED BY THE REPORT'S CAPS — see the bounds
|
|
387
456
|
// block above. A secret dropped for being the 201st is a secret in the
|
|
388
457
|
// server's database.
|
package/bin/lib/fleet.mjs
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
* what order. What a lane DOES lives beside it — the poll's query string
|
|
11
11
|
* (fleetRoster.mjs), the machine's own reports (fleetReports.mjs), holdership
|
|
12
12
|
* and the terminal signals (holder.mjs), the wiki runner (wikiRunner.mjs), the
|
|
13
|
-
*
|
|
14
|
-
*
|
|
13
|
+
* start steps (fleetStartup.mjs), the reconcile wait (fleetWake.mjs), and
|
|
14
|
+
* session/agent work (work.mjs).
|
|
15
15
|
*
|
|
16
16
|
* Over the 500-line mark as ONE LIFECYCLE, not as debt: what is left is the
|
|
17
17
|
* intake order over roster keys, and most of its length is the argument for
|
|
@@ -38,7 +38,7 @@ import { emitMachineEvent } from './daemonLogging.mjs';
|
|
|
38
38
|
import { writeDaemonState } from './daemonState.mjs';
|
|
39
39
|
import { repoRootOrDie, detectBaseRef, baseBranchName, excludeInWorktree, usableBaseRef } from './git.mjs';
|
|
40
40
|
import { c, info, note, ok, warn, fail } from './ui.mjs';
|
|
41
|
-
import { sleep } from './
|
|
41
|
+
import { sleep } from './sleep.mjs';
|
|
42
42
|
import { reapOrphanPreviews } from './previewRegistry.mjs';
|
|
43
43
|
import { preflight } from './preflight.mjs';
|
|
44
44
|
import { connectStream } from './stream.mjs';
|
|
@@ -75,7 +75,6 @@ import {
|
|
|
75
75
|
} from './fleetReports.mjs';
|
|
76
76
|
import { createHolderWatch, obeyRosterCommands } from './holder.mjs';
|
|
77
77
|
import { createWikiRunner } from './wikiRunner.mjs';
|
|
78
|
-
import { createCleanupLane, createPatchRevertLane } from './fleetJobs.mjs';
|
|
79
78
|
import { installSignalHandlers, takeInstanceLockOrExit, tidyWorktreeHome, worktreeHome } from './fleetStartup.mjs';
|
|
80
79
|
import { createReconcileWait } from './fleetWake.mjs';
|
|
81
80
|
|
|
@@ -235,7 +234,10 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
|
|
|
235
234
|
// never-squash law — and every server since the pivot sends `mergeJobs: []`
|
|
236
235
|
// (a wire fossil, `rosterResponse.ts`). Merges run through `prWorkflow.mjs`
|
|
237
236
|
// (session PR job, agent approve) and `shipMerge.mjs`, and nowhere else.
|
|
238
|
-
|
|
237
|
+
// Its two checkout siblings went the same way (audit 2026-10-10, F083): no
|
|
238
|
+
// server sends `patchRevertJobs` (a `git revert` in this checkout) or
|
|
239
|
+
// `cleanupJobs` (`gh pr close --delete-branch`, a remote branch delete), and
|
|
240
|
+
// their settle routes died with dispatch, so nothing here reads either key.
|
|
239
241
|
|
|
240
242
|
// ── Work sessions — the Workbench tabs ─────────────────────────────────────
|
|
241
243
|
//
|
|
@@ -314,8 +316,6 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
|
|
|
314
316
|
});
|
|
315
317
|
workShutdown = shutdownWork; // teardown can now reach the live session CLIs
|
|
316
318
|
|
|
317
|
-
const processCleanupJobs = createCleanupLane({ repoRoot, getBaseRef });
|
|
318
|
-
|
|
319
319
|
// Living-wiki work: one cartographer turn at a time, off the agents'
|
|
320
320
|
// checkouts (wikiRunner.mjs). Wiki work needs no agent online.
|
|
321
321
|
wiki = createWikiRunner({ repoRoot, baseDir, repoKey, getBaseRef, admit });
|
|
@@ -505,7 +505,6 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
|
|
|
505
505
|
// taking new work — the skip-if-pending guards make the ordering safe, but
|
|
506
506
|
// delivering first keeps the tab honest a poll sooner.
|
|
507
507
|
void flushWorkReports();
|
|
508
|
-
processPatchRevertJobs(roster.patchRevertJobs);
|
|
509
508
|
// WHERE each live tab works, BEFORE anything measures one: the sweep below
|
|
510
509
|
// and every preview check ask `placeOf`, and a tab nobody has typed into
|
|
511
510
|
// yet has taught it nothing.
|
|
@@ -617,7 +616,6 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
|
|
|
617
616
|
// startup on purpose: a daemon that has been up since before this release
|
|
618
617
|
// gets measured too, without needing a restart to earn its own menu.
|
|
619
618
|
probeSkillsOnce(repoRoot);
|
|
620
|
-
processCleanupJobs(roster.cleanupJobs);
|
|
621
619
|
// Announce roster membership only when it changes (not every poll).
|
|
622
620
|
const sig = [...new Set(roster.agents.map((a) => a.agentId))].sort().join(',');
|
|
623
621
|
if (sig !== rosterSig) {
|
package/bin/lib/fleetPost.mjs
CHANGED
|
@@ -1,40 +1,187 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* `
|
|
2
|
+
* THE DAEMON'S MACHINE-CREDENTIAL REQUEST — the headers, the timeout and the
|
|
3
|
+
* body every `/fleet/*` call shares, and the few answers built on it: a settle
|
|
4
|
+
* that says only "was it accepted" (`postToFleet`), the lease claim that stops
|
|
5
|
+
* two daemons acting on one job (`claimFleetLease`), the lease settle that
|
|
6
|
+
* follows it (`settleLease`), and the bounded best-effort settle
|
|
7
|
+
* (`postBestEffort`).
|
|
5
8
|
*
|
|
6
|
-
* Split out of fleet.mjs (SOLID audit 2026-09-26, F038) because
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* Split out of fleet.mjs (SOLID audit 2026-09-26, F038) because lanes in
|
|
10
|
+
* other modules share `postToFleet` — today the wiki runner (wikiRunner.mjs)
|
|
11
|
+
* and the machine report (fleetReports.mjs) — and it was a closure inside
|
|
12
|
+
* `runFleetDaemon` that only one of them could reach. It was called
|
|
13
|
+
* `reportMergeOutcome` there, a name left over from the deleted dispatch-era
|
|
14
|
+
* merge lane. The patch-revert and cleanup lanes that also settled through it
|
|
15
|
+
* were deleted with their dead roster keys (audit 2026-10-10, F083).
|
|
12
16
|
*
|
|
13
|
-
*
|
|
17
|
+
* ONE REQUEST SKELETON (audit 2026-10-10, F071/F082/F102/F136/F138/F154/F744).
|
|
18
|
+
* The `Authorization: Bearer …` / `User-Agent` / `Content-Type` block and its
|
|
19
|
+
* `AbortSignal.timeout` were hand-built at ~37 sites in 23 files, and the
|
|
20
|
+
* copies had drifted (mcp-cli's had no timeout at all; wikiRunner's abandon
|
|
21
|
+
* post was a near copy of `postToFleet`). The lease claim — the double-run
|
|
22
|
+
* guard — was five byte-identical copies. Each caller still passes its OWN
|
|
23
|
+
* timeout and keeps its OWN status policy; only the request and the claim's
|
|
24
|
+
* winner test moved here.
|
|
25
|
+
*
|
|
26
|
+
* `fetch` is the global, resolved at CALL time inside each function — tests
|
|
27
|
+
* swap `globalThis.fetch` after import, and that must keep working.
|
|
28
|
+
*
|
|
29
|
+
* A DAEMON→SERVER REPORT needs no floor: the report's presence is the
|
|
14
30
|
* capability, and a lane that gets `false` back retries on its own beat.
|
|
15
31
|
*/
|
|
16
32
|
|
|
17
|
-
import { FLEET_TOKEN, USER_AGENT } from './config.mjs';
|
|
33
|
+
import { FLEET_TOKEN, USER_AGENT, DAEMON_INSTANCE } from './config.mjs';
|
|
34
|
+
import { isCredentialRejection } from './authReject.mjs';
|
|
35
|
+
import { isConsideredRefusal, isNotNow, retryAfterMs } from './fleetWire.mjs';
|
|
36
|
+
|
|
37
|
+
/** The machine credential's headers. `token` defaults to the LIVE binding
|
|
38
|
+
* (config.mjs's `let FLEET_TOKEN`, which a project switch re-points), read
|
|
39
|
+
* at call time. `json: false` for a GET or a multipart body. */
|
|
40
|
+
export function fleetHeaders({ json = true, token = FLEET_TOKEN } = {}) {
|
|
41
|
+
return {
|
|
42
|
+
Authorization: `Bearer ${token}`,
|
|
43
|
+
'User-Agent': USER_AGENT,
|
|
44
|
+
...(json ? { 'Content-Type': 'application/json' } : {}),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A body as it goes on the wire: a string is already serialised (the
|
|
49
|
+
* readout lanes dedup on the exact string they post), anything else is
|
|
50
|
+
* JSON-encoded, and `undefined` sends no body. */
|
|
51
|
+
const wireBody = (body) => (body === undefined || typeof body === 'string' ? body : JSON.stringify(body));
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A JSON POST with the machine credential, returning the RESPONSE — for a
|
|
55
|
+
* caller that reads it itself. Rejects on a network error or a timeout
|
|
56
|
+
* exactly as `fetch` does.
|
|
57
|
+
*/
|
|
58
|
+
export async function postFleetJson(url, body, { timeoutMs = 30_000, token, signal } = {}) {
|
|
59
|
+
return fetch(url, {
|
|
60
|
+
method: 'POST',
|
|
61
|
+
headers: fleetHeaders({ token }),
|
|
62
|
+
signal: signal ?? AbortSignal.timeout(timeoutMs),
|
|
63
|
+
body: wireBody(body),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* ONE REQUEST, ANSWERED AS A SHAPE — never a throw.
|
|
69
|
+
*
|
|
70
|
+
* { ok, status, json, credentialRejected, retryAfterMs, error }
|
|
71
|
+
*
|
|
72
|
+
* `status` is null (and `error` set) when nothing answered: a network error
|
|
73
|
+
* or the timeout. `json` is the parsed body, or null when it was not JSON.
|
|
74
|
+
* `credentialRejected` is authReject.mjs's one rule — a 401/403 wearing the
|
|
75
|
+
* API's own `{ success: false }` envelope, never the bare status (an edge
|
|
76
|
+
* 403 is somebody else's answer). `retryAfterMs` is the server's Retry-After,
|
|
77
|
+
* capped, or null when it sent none.
|
|
78
|
+
*/
|
|
79
|
+
export async function fleetRequest(url, { method = 'POST', body, timeoutMs = 30_000, token, signal } = {}) {
|
|
80
|
+
let res;
|
|
81
|
+
try {
|
|
82
|
+
const sent = wireBody(body);
|
|
83
|
+
res = await fetch(url, {
|
|
84
|
+
method,
|
|
85
|
+
headers: fleetHeaders({ json: sent !== undefined, token }),
|
|
86
|
+
signal: signal ?? AbortSignal.timeout(timeoutMs),
|
|
87
|
+
...(sent !== undefined ? { body: sent } : {}),
|
|
88
|
+
});
|
|
89
|
+
} catch (error) {
|
|
90
|
+
return { ok: false, status: null, json: null, credentialRejected: false, retryAfterMs: null, error };
|
|
91
|
+
}
|
|
92
|
+
let json = null;
|
|
93
|
+
try {
|
|
94
|
+
json = typeof res.json === 'function' ? await res.json() : null;
|
|
95
|
+
} catch {
|
|
96
|
+
json = null; // not JSON — an edge page, an empty body
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
ok: Boolean(res.ok),
|
|
100
|
+
status: res.status,
|
|
101
|
+
json,
|
|
102
|
+
credentialRejected: isCredentialRejection(res.status, res.headers?.get?.('content-type'), json),
|
|
103
|
+
retryAfterMs: retryAfterMs(res, null),
|
|
104
|
+
error: null,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
18
107
|
|
|
19
108
|
/** Returns whether the server actually accepted it. Callers that spend a
|
|
20
109
|
* Claude turn per attempt need to know: swallowing the failure silently made
|
|
21
110
|
* an unreachable endpoint look identical to a settled job, so the turn
|
|
22
|
-
* re-ran on every poll.
|
|
23
|
-
|
|
111
|
+
* re-ran on every poll. Never throws — the job reappears next poll if this
|
|
112
|
+
* failed. */
|
|
113
|
+
export async function postToFleet(url, body, { timeoutMs = 30_000 } = {}) {
|
|
114
|
+
return (await fleetRequest(url, { body, timeoutMs })).ok;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* CLAIM A LEASE BEFORE ACTING — the double-run guard (audit 2026-10-10,
|
|
119
|
+
* F138). Two daemons legitimately share one credential and are handed the
|
|
120
|
+
* same job array, so a lane that opens a tunnel, signals a pid, pushes a
|
|
121
|
+
* branch or runs a deploy claims the job first and does NOTHING unless it
|
|
122
|
+
* won. The rules, which were five hand copies:
|
|
123
|
+
* - the body names this PROCESS (`instance: DAEMON_INSTANCE`), never only
|
|
124
|
+
* the credential — a token cannot tell two holders apart;
|
|
125
|
+
* - the winner is read from the BODY (`data.claimed === true`), never
|
|
126
|
+
* inferred from the status;
|
|
127
|
+
* - any failure — a network error, a timeout, a non-JSON answer — is "not
|
|
128
|
+
* claimed": the peer may hold it, and doing nothing is the safe answer.
|
|
129
|
+
* `won` is for a route that answers in another shape (intake's `{ ok: true }`
|
|
130
|
+
* on a 200); it is handed `fleetRequest`'s result.
|
|
131
|
+
*/
|
|
132
|
+
export async function claimFleetLease(
|
|
133
|
+
url,
|
|
134
|
+
fields,
|
|
135
|
+
{ timeoutMs = 15_000, won = (r) => r.json?.data?.claimed === true } = {}
|
|
136
|
+
) {
|
|
24
137
|
try {
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
29
|
-
'User-Agent': USER_AGENT,
|
|
30
|
-
'Content-Type': 'application/json',
|
|
31
|
-
},
|
|
32
|
-
signal: AbortSignal.timeout(30_000),
|
|
33
|
-
body: JSON.stringify(body),
|
|
34
|
-
});
|
|
35
|
-
return res.ok;
|
|
138
|
+
const r = await fleetRequest(url, { body: { ...fields, instance: DAEMON_INSTANCE }, timeoutMs });
|
|
139
|
+
if (r.error) return false;
|
|
140
|
+
return won(r) === true;
|
|
36
141
|
} catch {
|
|
37
|
-
/* best-effort — the job reappears next poll if this failed */
|
|
38
142
|
return false;
|
|
39
143
|
}
|
|
40
144
|
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* SETTLE A HELD LEASE — the outcome POST that follows a claim, stamped with
|
|
148
|
+
* the same `instance` (only the holder may settle). Returns the parsed reply
|
|
149
|
+
* (a preview open reads `data.settled`), or null when nothing usable came
|
|
150
|
+
* back. Never throws: an unsettled lease expires server-side and the asker
|
|
151
|
+
* is told, never spun.
|
|
152
|
+
*/
|
|
153
|
+
export async function settleLease(url, body, { timeoutMs = 30_000 } = {}) {
|
|
154
|
+
const r = await fleetRequest(url, { body: { ...body, instance: DAEMON_INSTANCE }, timeoutMs });
|
|
155
|
+
return r.json ?? null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* A best-effort settle POST, AWAITED INLINE by a caller with no per-poll
|
|
160
|
+
* queue of its own to retry from — unlike workReportQueue's `postSettle`,
|
|
161
|
+
* whose callers requeue a 'retry'/'reject' outcome themselves. Bounded
|
|
162
|
+
* attempts: 408/429 retry honouring Retry-After, 5xx retries on a short fixed
|
|
163
|
+
* backoff, and any OTHER 4xx is the server's considered answer — retrying it
|
|
164
|
+
* would just spend the same refusal again, so it counts as delivered, the
|
|
165
|
+
* existing trace convention. A network error retries the same way and is
|
|
166
|
+
* then swallowed: unsettled, and the server expires the job so the asker is
|
|
167
|
+
* told, never spun.
|
|
168
|
+
*
|
|
169
|
+
* Moved here from inside `createWorkReportQueue` (audit 2026-10-10, F136): it
|
|
170
|
+
* closed over nothing of the queue's. work.mjs still INJECTS it into the
|
|
171
|
+
* plan, check and intake lanes, so their tests hand in fakes.
|
|
172
|
+
*/
|
|
173
|
+
export async function postBestEffort(url, body, { attempts = 4, timeoutMs = 30_000 } = {}) {
|
|
174
|
+
for (let i = 0; i < attempts; i += 1) {
|
|
175
|
+
const r = await fleetRequest(url, { body, timeoutMs });
|
|
176
|
+
if (r.ok) return true;
|
|
177
|
+
if (isConsideredRefusal(r.status)) return true; // the server's considered answer
|
|
178
|
+
const notNow = isNotNow(r.status);
|
|
179
|
+
// Only a network error, 408/429 or a 5xx retries; anything else (a stray
|
|
180
|
+
// 1xx/3xx) was never retried and still is not.
|
|
181
|
+
if (!r.error && !notNow && r.status < 500) return true;
|
|
182
|
+
if (i === attempts - 1) return false;
|
|
183
|
+
const backoff = 2_000 * (i + 1);
|
|
184
|
+
await new Promise((done) => setTimeout(done, notNow ? (r.retryAfterMs ?? backoff) : backoff));
|
|
185
|
+
}
|
|
186
|
+
return false;
|
|
187
|
+
}
|