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,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TWO ROSTER JOB LANES THAT ACT ON THIS CHECKOUT AND SETTLE BACK: patch
|
|
3
|
+
* reverts and task-restart cleanups.
|
|
4
|
+
*
|
|
5
|
+
* Split out of fleet.mjs (SOLID audit 2026-09-26, F038). Each lane dedups its
|
|
6
|
+
* in-flight job ids, does its work fire-and-forget, and ALWAYS reports the job
|
|
7
|
+
* done — the roster re-serves a job until it is reported, so a lane that could
|
|
8
|
+
* wedge on a failure would redo it every poll. The loop in fleet.mjs only
|
|
9
|
+
* hands each lane its roster array.
|
|
10
|
+
*
|
|
11
|
+
* Factories rather than module state because each lane is bound to the
|
|
12
|
+
* checkout this daemon serves (and cleanup to the live base ref, read at call
|
|
13
|
+
* time — the roster can move it mid-run).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { execFileSync } from 'node:child_process';
|
|
17
|
+
import { FLEET_URL } from './config.mjs';
|
|
18
|
+
import { gitNetAsync, originSlug, isValidPrUrl, isValidBranch } from './git.mjs';
|
|
19
|
+
import { c, note, ok, warn } from './ui.mjs';
|
|
20
|
+
import { revertPatch, withPatchLock } from './patchRevert.mjs';
|
|
21
|
+
import { fleetEndpoint } from './fleetWire.mjs';
|
|
22
|
+
import { postToFleet } from './fleetPost.mjs';
|
|
23
|
+
|
|
24
|
+
export function createPatchRevertLane({ repoRoot }) {
|
|
25
|
+
// Patch reverts: a patch landed straight in this checkout, and a human took it
|
|
26
|
+
// back. The commits are HERE, not on the server, so the reverse-apply happens
|
|
27
|
+
// here too — a revert, never a reset, because the owner has almost certainly
|
|
28
|
+
// worked on top by now. Serialised through the same lock as applies.
|
|
29
|
+
const PATCH_REVERT_DONE_URL = fleetEndpoint('patch-revert-done', FLEET_URL);
|
|
30
|
+
const reverting = new Set();
|
|
31
|
+
const processPatchRevertJobs = (jobs) => {
|
|
32
|
+
for (const job of jobs ?? []) {
|
|
33
|
+
if (!job || typeof job.id !== 'string' || !Array.isArray(job.shas)) continue;
|
|
34
|
+
if (reverting.has(job.id)) continue;
|
|
35
|
+
reverting.add(job.id);
|
|
36
|
+
(async () => {
|
|
37
|
+
try {
|
|
38
|
+
note(`${c.cyan('revert')} ${c.dim(`— ${job.title}`)}`);
|
|
39
|
+
const res = await withPatchLock(() =>
|
|
40
|
+
Promise.resolve(revertPatch({ repoRoot, shas: job.shas }))
|
|
41
|
+
);
|
|
42
|
+
if (res.ok) ok(`${c.dim('reverted')} ${job.title}`);
|
|
43
|
+
else warn(`revert failed for "${job.title}": ${res.error}`);
|
|
44
|
+
// ALWAYS report, success or not. Without this the flag stays set, the
|
|
45
|
+
// roster re-serves the job every poll, and each pass reverts the
|
|
46
|
+
// revert — the change flapping in and out of the owner's tree forever.
|
|
47
|
+
await postToFleet(PATCH_REVERT_DONE_URL, {
|
|
48
|
+
taskId: job.id,
|
|
49
|
+
ok: res.ok,
|
|
50
|
+
error: res.ok ? undefined : String(res.error ?? 'revert failed'),
|
|
51
|
+
});
|
|
52
|
+
} finally {
|
|
53
|
+
reverting.delete(job.id);
|
|
54
|
+
}
|
|
55
|
+
})();
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
return processPatchRevertJobs;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function createCleanupLane({ repoRoot, getBaseRef }) {
|
|
62
|
+
// Cleanup jobs (task restarts): close the abandoned PR + delete its remote
|
|
63
|
+
// branch on the user's own gh, so a restart doesn't litter the repo.
|
|
64
|
+
const CLEANUP_DONE_URL = fleetEndpoint('cleanup-done', FLEET_URL);
|
|
65
|
+
const cleaning = new Set();
|
|
66
|
+
const processCleanupJobs = (jobs) => {
|
|
67
|
+
for (const job of jobs ?? []) {
|
|
68
|
+
if (!job || typeof job.id !== 'string') continue; // a null element would wedge the loop
|
|
69
|
+
if (cleaning.has(job.id)) continue;
|
|
70
|
+
cleaning.add(job.id);
|
|
71
|
+
(async () => {
|
|
72
|
+
try {
|
|
73
|
+
note(`${c.cyan('cleanup')} ${c.dim(`— ${job.title} (restarted)`)}`);
|
|
74
|
+
// Same guards as merge: only close a PR in THIS repo, only delete a
|
|
75
|
+
// well-formed non-base branch. A bad server must not close a stranger's
|
|
76
|
+
// PR or delete `main` (`--delete` with `main`) via a cleanup job.
|
|
77
|
+
if (job.prUrl && isValidPrUrl(job.prUrl, originSlug(repoRoot))) {
|
|
78
|
+
try {
|
|
79
|
+
execFileSync(
|
|
80
|
+
'gh',
|
|
81
|
+
[
|
|
82
|
+
'pr',
|
|
83
|
+
'close',
|
|
84
|
+
job.prUrl,
|
|
85
|
+
'--comment',
|
|
86
|
+
'Task restarted in Flowviant — this attempt was discarded.',
|
|
87
|
+
'--delete-branch',
|
|
88
|
+
],
|
|
89
|
+
{ cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000 }
|
|
90
|
+
);
|
|
91
|
+
} catch (e) {
|
|
92
|
+
// Already closed/merged/missing = fine; anything else we still
|
|
93
|
+
// report done — a restart must never wedge on stale remotes.
|
|
94
|
+
const err = e.stderr?.toString?.() || e.message || '';
|
|
95
|
+
warn(`cleanup for "${job.title}": ${err.split('\n')[0] || 'gh pr close failed'}`);
|
|
96
|
+
}
|
|
97
|
+
} else if (job.branch && isValidBranch(job.branch, repoRoot, getBaseRef())) {
|
|
98
|
+
try {
|
|
99
|
+
// Explicit refspec form so a leading '-' can't be a git flag.
|
|
100
|
+
// NETWORK call, so timed and non-interactive (2026-09-24, the
|
|
101
|
+
// audit): a bare execFileSync here had no timeout and could
|
|
102
|
+
// prompt on /dev/tty, freezing every poll and lease on the
|
|
103
|
+
// machine behind this cleanup's own async loop.
|
|
104
|
+
await gitNetAsync(['push', 'origin', `:refs/heads/${job.branch}`], repoRoot);
|
|
105
|
+
} catch {
|
|
106
|
+
/* branch already gone — fine */
|
|
107
|
+
}
|
|
108
|
+
} else if (job.prUrl || job.branch) {
|
|
109
|
+
warn(`cleanup REFUSED for "${job.title}": untrusted PR/branch value`);
|
|
110
|
+
}
|
|
111
|
+
await postToFleet(CLEANUP_DONE_URL, { taskId: job.id });
|
|
112
|
+
ok(`${c.cyan('cleaned')} ${c.dim(`— ${job.title}`)}`);
|
|
113
|
+
} finally {
|
|
114
|
+
cleaning.delete(job.id);
|
|
115
|
+
}
|
|
116
|
+
})();
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
return processCleanupJobs;
|
|
120
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ONE DAEMON→SERVER POST THAT ANSWERS ONLY "WAS IT ACCEPTED" — the settle a
|
|
3
|
+
* job lane sends when it is done (`patch-revert-done`, `cleanup-done`,
|
|
4
|
+
* `reground-done`) and the machine telemetry beat (`/fleet/machine`).
|
|
5
|
+
*
|
|
6
|
+
* Split out of fleet.mjs (SOLID audit 2026-09-26, F038) because four lanes in
|
|
7
|
+
* three modules share it — the job lanes (fleetJobs.mjs), the wiki runner
|
|
8
|
+
* (wikiRunner.mjs) and the machine report (fleetReports.mjs) — and it was a
|
|
9
|
+
* closure inside `runFleetDaemon` that only one of them could reach. It was
|
|
10
|
+
* called `reportMergeOutcome` there, a name left over from the deleted
|
|
11
|
+
* dispatch-era merge lane; the body is unchanged.
|
|
12
|
+
*
|
|
13
|
+
* A DAEMON→SERVER REPORT, so no floor: the report's presence is the
|
|
14
|
+
* capability, and a lane that gets `false` back retries on its own beat.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { FLEET_TOKEN, USER_AGENT } from './config.mjs';
|
|
18
|
+
|
|
19
|
+
/** Returns whether the server actually accepted it. Callers that spend a
|
|
20
|
+
* Claude turn per attempt need to know: swallowing the failure silently made
|
|
21
|
+
* an unreachable endpoint look identical to a settled job, so the turn
|
|
22
|
+
* re-ran on every poll. */
|
|
23
|
+
export async function postToFleet(url, body) {
|
|
24
|
+
try {
|
|
25
|
+
const res = await fetch(url, {
|
|
26
|
+
method: 'POST',
|
|
27
|
+
headers: {
|
|
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;
|
|
36
|
+
} catch {
|
|
37
|
+
/* best-effort — the job reappears next poll if this failed */
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHAT THIS MACHINE REPORTS ABOUT ITSELF, off the roster's beat — terminal
|
|
3
|
+
* sessions, the repo's branches and worktrees (with the CLI's credential
|
|
4
|
+
* context), the checkout's env by name, repo tool readiness, and the machine
|
|
5
|
+
* telemetry snapshot.
|
|
6
|
+
*
|
|
7
|
+
* Split out of fleet.mjs (SOLID audit 2026-09-26, F038). Each lane is its own
|
|
8
|
+
* throttle, its own dedup against the last ACCEPTED payload, and its own
|
|
9
|
+
* "the server refused for good — quiet until restart" flag; all of that is
|
|
10
|
+
* module state, and none of it is the reconcile loop's business. The loop
|
|
11
|
+
* fires each one and forgets it (`void`), which is the one contract they all
|
|
12
|
+
* keep: a readout must never throw into, or delay, the poll.
|
|
13
|
+
*
|
|
14
|
+
* Every lane is a DAEMON→SERVER report, so none needs a floor: an older server
|
|
15
|
+
* 404s once and the lane goes quiet; an older daemon simply never posts.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
|
|
19
|
+
import { fleetEndpoint } from './fleetWire.mjs';
|
|
20
|
+
import { postToFleet } from './fleetPost.mjs';
|
|
21
|
+
import { myPubB64 } from './boxIdentity.mjs';
|
|
22
|
+
import { scanEnvForScrub } from './env.mjs';
|
|
23
|
+
import { machineSnapshot } from './resources.mjs';
|
|
24
|
+
import { detectRuntimes } from './runtimeDetection.mjs';
|
|
25
|
+
import { scanLocalSessions } from './localSessions.mjs';
|
|
26
|
+
import { repoState } from './repoState.mjs';
|
|
27
|
+
import { claudeAuthContext } from './claudeAuth.mjs';
|
|
28
|
+
import { readBaseTools, toolReadout } from './projectTools.mjs';
|
|
29
|
+
import { runnerToolCapabilities } from './projectToolRuntimes.mjs';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Terminal-session presence: tell the server which Claude sessions exist in
|
|
33
|
+
* this repo (localSessions.mjs reads them off Claude's own on-disk state), so
|
|
34
|
+
* the Workbench can offer "adopt this terminal session as a tab". Best-effort
|
|
35
|
+
* in exactly the way the env/runtimes blocks are — a presence report that can
|
|
36
|
+
* fail a poll is worse than no presence at all — with three quiet economies:
|
|
37
|
+
* the scan runs at most once a minute (the reconcile loop ticks far faster), a
|
|
38
|
+
* report identical to the last DELIVERED one is not re-sent, and a 404 means
|
|
39
|
+
* an older server that has never heard of the endpoint, after which this
|
|
40
|
+
* process stops asking (a deploy that adds it also restarts nothing on this
|
|
41
|
+
* machine, so silence-until-restart costs one daemon restart, not a feature).
|
|
42
|
+
*/
|
|
43
|
+
const LOCAL_SESSIONS_URL = fleetEndpoint('local-sessions', FLEET_URL);
|
|
44
|
+
const LOCAL_SESSIONS_SCAN_MS = 60_000;
|
|
45
|
+
// The web hides a report older than 10 minutes (presence must not linger as
|
|
46
|
+
// fact after the machine dies), so an UNCHANGED report is re-sent inside that
|
|
47
|
+
// window anyway — the re-send is the machine's heartbeat on this fact, and
|
|
48
|
+
// suppressing it entirely would blank the strip while everything still holds.
|
|
49
|
+
const LOCAL_SESSIONS_RESEND_MS = 5 * 60_000;
|
|
50
|
+
let localSessionsUnsupported = false; // the server 404'd — quiet until restart
|
|
51
|
+
let localSessionsScanAt = 0;
|
|
52
|
+
let localSessionsSent = null; // last payload the server ACCEPTED, stringified
|
|
53
|
+
let localSessionsSentAt = 0;
|
|
54
|
+
export async function maybeReportLocalSessions({ repoRoot, excludeDirs, excludeIds }) {
|
|
55
|
+
if (localSessionsUnsupported) return;
|
|
56
|
+
if (Date.now() - localSessionsScanAt < LOCAL_SESSIONS_SCAN_MS) return;
|
|
57
|
+
localSessionsScanAt = Date.now();
|
|
58
|
+
let payload;
|
|
59
|
+
try {
|
|
60
|
+
// scanLocalSessions orders deterministically, so this string only changes
|
|
61
|
+
// when the facts on disk do — the dedup below compares whole payloads.
|
|
62
|
+
payload = JSON.stringify({
|
|
63
|
+
sessions: scanLocalSessions({ repoRoot, excludeDirs, excludeIds }),
|
|
64
|
+
});
|
|
65
|
+
} catch {
|
|
66
|
+
return; // presence must never throw into the poll loop
|
|
67
|
+
}
|
|
68
|
+
if (payload === localSessionsSent && Date.now() - localSessionsSentAt < LOCAL_SESSIONS_RESEND_MS)
|
|
69
|
+
return;
|
|
70
|
+
try {
|
|
71
|
+
const res = await fetch(LOCAL_SESSIONS_URL, {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: {
|
|
74
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
75
|
+
'User-Agent': USER_AGENT,
|
|
76
|
+
'Content-Type': 'application/json',
|
|
77
|
+
},
|
|
78
|
+
signal: AbortSignal.timeout(15_000),
|
|
79
|
+
body: payload,
|
|
80
|
+
});
|
|
81
|
+
if (res.status === 404) {
|
|
82
|
+
localSessionsUnsupported = true; // older server — it REPLACED nothing here
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
// Only an accepted report counts as sent; anything else forgets the
|
|
86
|
+
// last-sent payload so the next pass retries instead of dedup-suppressing
|
|
87
|
+
// a report the server never received.
|
|
88
|
+
localSessionsSent = res.ok ? payload : null;
|
|
89
|
+
localSessionsSentAt = res.ok ? Date.now() : 0;
|
|
90
|
+
} catch {
|
|
91
|
+
localSessionsSent = null;
|
|
92
|
+
localSessionsSentAt = 0;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* EVERY BRANCH AND WORKTREE ON THIS MACHINE, pushed on the same beat as
|
|
98
|
+
* presence — the answer to "is my Claude leaving a mess in here?".
|
|
99
|
+
*
|
|
100
|
+
* Same three economies as the presence report above and for the same reasons:
|
|
101
|
+
* scanned at most once a minute, not re-sent while identical (repoState orders
|
|
102
|
+
* deterministically so the string only moves when the repo does), and silent
|
|
103
|
+
* for the rest of the process once an older server 404s.
|
|
104
|
+
*
|
|
105
|
+
* ONE DIFFERENCE, deliberate: there is no re-send heartbeat window. Presence
|
|
106
|
+
* expires in the UI because a session that ENDED must stop reading as live;
|
|
107
|
+
* a branch list is not presence — a branch that existed a minute ago still
|
|
108
|
+
* exists — so re-posting an unchanged list would be a write per machine per
|
|
109
|
+
* five minutes to say nothing at all.
|
|
110
|
+
*/
|
|
111
|
+
const REPO_STATE_URL = fleetEndpoint('repo-state', FLEET_URL);
|
|
112
|
+
const REPO_STATE_SCAN_MS = 60_000;
|
|
113
|
+
let repoStateUnsupported = false; // the server 404'd — quiet until restart
|
|
114
|
+
let repoStateScanAt = 0;
|
|
115
|
+
let repoStateSent = null; // last payload the server ACCEPTED, stringified
|
|
116
|
+
export async function maybeReportRepoState({ repoRoot, baseRef }) {
|
|
117
|
+
if (repoStateUnsupported) return;
|
|
118
|
+
if (Date.now() - repoStateScanAt < REPO_STATE_SCAN_MS) return;
|
|
119
|
+
repoStateScanAt = Date.now();
|
|
120
|
+
let payload;
|
|
121
|
+
try {
|
|
122
|
+
// The PARAM, and nothing else: this function sits at MODULE scope, where
|
|
123
|
+
// runFleetDaemon's `getBaseRef` closure does not exist. An earlier version
|
|
124
|
+
// reached for it anyway, the ReferenceError landed in the catch below —
|
|
125
|
+
// written for an unreadable repo, silent by design — and this endpoint was
|
|
126
|
+
// never once posted to. The caller reads the loop's live `baseRef` at call
|
|
127
|
+
// time, so the value here is always current.
|
|
128
|
+
const state = repoState(repoRoot, baseRef);
|
|
129
|
+
if (!state) return; // not readable — say nothing rather than say "none"
|
|
130
|
+
/**
|
|
131
|
+
* WHICH CREDENTIAL THIS MACHINE'S CLI RESOLVES, on the same beat.
|
|
132
|
+
*
|
|
133
|
+
* It rides this report rather than getting an endpoint of its own because
|
|
134
|
+
* it is the same KIND of fact — something only the machine can see, pushed
|
|
135
|
+
* because a pull client can never be asked — and it inherits this
|
|
136
|
+
* function's three economies for free: scanned once a minute, deduped
|
|
137
|
+
* against the last ACCEPTED payload, and silent forever once an older
|
|
138
|
+
* server 404s.
|
|
139
|
+
*
|
|
140
|
+
* The dedup keeps it cheap: `claudeAuthContext` is presence plus two dates,
|
|
141
|
+
* so the string is stable across scans and only moves when something about
|
|
142
|
+
* the credential actually moves. It DOES move when the CLI refreshes its
|
|
143
|
+
* access token — that stamp is carried for diagnostics — which costs a
|
|
144
|
+
* write roughly twice a day and is the whole of the extra traffic. The
|
|
145
|
+
* WARNING is built on the 22-day refresh clock instead, so a note never
|
|
146
|
+
* fires on the ~12h cycle.
|
|
147
|
+
*
|
|
148
|
+
* NO VERSION FLOOR, and none is possible to need: this is a daemon→server
|
|
149
|
+
* report, and an older SERVER strips the unknown key in its zod parse and
|
|
150
|
+
* stores the rest exactly as before.
|
|
151
|
+
*/
|
|
152
|
+
payload = JSON.stringify({ ...state, auth: claudeAuthContext() });
|
|
153
|
+
} catch {
|
|
154
|
+
return; // a readout must never throw into the poll loop
|
|
155
|
+
}
|
|
156
|
+
if (payload === repoStateSent) return;
|
|
157
|
+
try {
|
|
158
|
+
const res = await fetch(REPO_STATE_URL, {
|
|
159
|
+
method: 'POST',
|
|
160
|
+
headers: {
|
|
161
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
162
|
+
'User-Agent': USER_AGENT,
|
|
163
|
+
'Content-Type': 'application/json',
|
|
164
|
+
},
|
|
165
|
+
signal: AbortSignal.timeout(15_000),
|
|
166
|
+
body: payload,
|
|
167
|
+
});
|
|
168
|
+
if (res.status === 404) {
|
|
169
|
+
repoStateUnsupported = true; // older server — nothing was replaced here
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
// Only an ACCEPTED report counts: anything else forgets it so the next
|
|
173
|
+
// pass retries rather than dedup-suppressing a report nobody received.
|
|
174
|
+
repoStateSent = res.ok ? payload : null;
|
|
175
|
+
} catch {
|
|
176
|
+
repoStateSent = null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* WHAT IS IN THIS BOX'S ENVIRONMENT, BY NAME (2026-09-21) — the one readout the
|
|
182
|
+
* deleted secrets vault was genuinely wanted for.
|
|
183
|
+
*
|
|
184
|
+
* The owner, on the vault: *"no i dont want it. unless its needed where i want
|
|
185
|
+
* to show the env of each of the machines (for comparison)."* So the custody
|
|
186
|
+
* went and the comparison stayed, rebuilt as a plain report: for each `.env*`
|
|
187
|
+
* file in the CHECKOUT ROOT, the variable NAMES and an 8-hex VALUE FINGERPRINT.
|
|
188
|
+
* **The value never leaves the box.** Two machines holding the same fingerprint
|
|
189
|
+
* for `DATABASE_URL` agree; two different fingerprints is the whole answer to
|
|
190
|
+
* "why does it work over there", and neither requires Flowviant to hold a
|
|
191
|
+
* secret, seal a key, or own a recovery code.
|
|
192
|
+
*
|
|
193
|
+
* ONE POST PER BOX PER CHANGE. The report is hashed and compared against the
|
|
194
|
+
* last one the server ACCEPTED, so a box whose env is stable posts exactly once
|
|
195
|
+
* per daemon and then never again — the same economy `maybeReportRepoState`
|
|
196
|
+
* keeps next door, and for the same reason: an env file is not PRESENCE, so
|
|
197
|
+
* re-posting an unchanged list would be a write per machine per minute to say
|
|
198
|
+
* nothing.
|
|
199
|
+
*
|
|
200
|
+
* A PERMANENT 4xx IS TREATED AS DELIVERED, which is the `/fleet/agent-trace`
|
|
201
|
+
* precedent stated there: an older SERVER 404s every batch, and a daemon that
|
|
202
|
+
* held them would keep re-posting a body nobody will ever read. Which statuses
|
|
203
|
+
* count as permanent is `envReportIsPermanent`, and it is a SHORT list for the
|
|
204
|
+
* reason stated there; everything else forgets the dedup so the next beat tries
|
|
205
|
+
* again.
|
|
206
|
+
*
|
|
207
|
+
* THE TOTALS RIDE BESIDE THE CAPPED LIST (2026-09-21, the review). The report
|
|
208
|
+
* is bounded at eight files and two hundred variables, and a list silently cut
|
|
209
|
+
* at a cap reads as the whole directory — so `filesTotal` and `varsTotal` (the
|
|
210
|
+
* numbers BEFORE the caps) go on the wire beside it and the app can say "N more
|
|
211
|
+
* not shown". Same rule `repoState` already keeps for branches and worktrees.
|
|
212
|
+
* Both are optional on the server, so an older server parsing only `files` is
|
|
213
|
+
* unaffected and needs no floor.
|
|
214
|
+
*
|
|
215
|
+
* NO VERSION FLOOR, and none is possible to need: it is a daemon→server report
|
|
216
|
+
* on a NEW endpoint, so an older server answers 404 once and is never asked
|
|
217
|
+
* again, and an older daemon simply never posts. The report's presence IS the
|
|
218
|
+
* capability.
|
|
219
|
+
*
|
|
220
|
+
* IT ALSO FEEDS THE SCRUBBER, on every scan and whether or not anything is
|
|
221
|
+
* posted (`scanEnvForScrub`). That is the half that must not be skipped: with
|
|
222
|
+
* the vault gone these files are what `scrub()` redacts out of turn streams,
|
|
223
|
+
* tool events, traces and deploy logs, and the dedup above is about the WIRE,
|
|
224
|
+
* never about what this box knows to hide.
|
|
225
|
+
*/
|
|
226
|
+
/**
|
|
227
|
+
* WHICH REFUSAL IS FINAL — pure, exported, and deliberately a SHORT list
|
|
228
|
+
* (2026-09-21, the review).
|
|
229
|
+
*
|
|
230
|
+
* The first cut treated EVERY 4xx as permanent, which quietly turned one bad
|
|
231
|
+
* minute into a dead lane for the life of the process. A 429 is a rate limit
|
|
232
|
+
* and says "later", not "never". A 401 or 403 during a credential blip — a
|
|
233
|
+
* rotation, a clock skew, a roster the box is momentarily not the holder of —
|
|
234
|
+
* is transient by construction. A 408 is a timeout wearing a 4xx. Every one of
|
|
235
|
+
* those stopped env reporting until somebody restarted the daemon, with NO
|
|
236
|
+
* sign anywhere that it had stopped: the report is deduped and silent by
|
|
237
|
+
* design, so "posted once and never again" is indistinguishable from "working
|
|
238
|
+
* normally on a box whose env has not changed". A lane that can die invisibly
|
|
239
|
+
* must not die for a reason that will pass.
|
|
240
|
+
*
|
|
241
|
+
* So permanent means exactly three things, and each of them is a fact about
|
|
242
|
+
* the REQUEST rather than about the moment: 404, the route does not exist (an
|
|
243
|
+
* older server — the `/fleet/agent-trace` precedent this lane is built on);
|
|
244
|
+
* 400 and 422, the server will never accept this shape. Retrying any of those
|
|
245
|
+
* forever is the daemon arguing with a decision already made.
|
|
246
|
+
*
|
|
247
|
+
* Pure and exported so the decision can be proved without a credential, a
|
|
248
|
+
* server or a poll — the same shape `shouldStop` and `createHolderWatch` keep
|
|
249
|
+
* in holder.mjs.
|
|
250
|
+
*/
|
|
251
|
+
export function envReportIsPermanent(status) {
|
|
252
|
+
return status === 400 || status === 404 || status === 422;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const ENV_REPORT_URL = fleetEndpoint('env-report', FLEET_URL);
|
|
256
|
+
const ENV_REPORT_SCAN_MS = 60_000;
|
|
257
|
+
let envReportUnsupported = false; // the server refused permanently — quiet until restart
|
|
258
|
+
let envReportScanAt = 0;
|
|
259
|
+
let envReportSent = null; // the last report the server ACCEPTED, stringified
|
|
260
|
+
/**
|
|
261
|
+
* Returns a one-word VERDICT — 'throttled' | 'scan-failed' | 'quiet' |
|
|
262
|
+
* 'deduped' | 'accepted' | 'retry' | 'stopped'. The daemon ignores it (the
|
|
263
|
+
* caller is a bare `void`); it exists so the lane's decisions can be driven
|
|
264
|
+
* against a real HTTP server in a test rather than pinned as source text. A
|
|
265
|
+
* readout whose only proof is that its source LOOKS right is the inert-pin
|
|
266
|
+
* class this repo has caught five times.
|
|
267
|
+
*/
|
|
268
|
+
export async function maybeReportEnv(repoRoot) {
|
|
269
|
+
if (Date.now() - envReportScanAt < ENV_REPORT_SCAN_MS) return 'throttled';
|
|
270
|
+
envReportScanAt = Date.now();
|
|
271
|
+
let report;
|
|
272
|
+
try {
|
|
273
|
+
// The SCAN happens even when the POST cannot — see the docblock: the
|
|
274
|
+
// scrubber has no dedup and must reflect the newest read every time.
|
|
275
|
+
report = scanEnvForScrub(repoRoot);
|
|
276
|
+
} catch {
|
|
277
|
+
return 'scan-failed'; // a readout must never throw into the poll loop
|
|
278
|
+
}
|
|
279
|
+
if (envReportUnsupported) return 'quiet';
|
|
280
|
+
const payload = JSON.stringify({
|
|
281
|
+
pubkey: myPubB64(),
|
|
282
|
+
files: report.files,
|
|
283
|
+
filesTotal: report.filesTotal,
|
|
284
|
+
varsTotal: report.varsTotal,
|
|
285
|
+
});
|
|
286
|
+
if (payload === envReportSent) return 'deduped';
|
|
287
|
+
try {
|
|
288
|
+
const res = await fetch(ENV_REPORT_URL, {
|
|
289
|
+
method: 'POST',
|
|
290
|
+
headers: {
|
|
291
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
292
|
+
'User-Agent': USER_AGENT,
|
|
293
|
+
'Content-Type': 'application/json',
|
|
294
|
+
},
|
|
295
|
+
signal: AbortSignal.timeout(15_000),
|
|
296
|
+
body: payload,
|
|
297
|
+
});
|
|
298
|
+
// Only a refusal that cannot change stops the lane — see
|
|
299
|
+
// `envReportIsPermanent`. A 429 or a 401 during a credential blip is a
|
|
300
|
+
// "later", and treating it as a "never" killed env reporting for the life
|
|
301
|
+
// of the process with nothing anywhere saying so.
|
|
302
|
+
if (envReportIsPermanent(res.status)) {
|
|
303
|
+
envReportUnsupported = true;
|
|
304
|
+
return 'stopped';
|
|
305
|
+
}
|
|
306
|
+
// Only an ACCEPTED report counts, the rule `maybeReportRepoState` keeps:
|
|
307
|
+
// anything else forgets it so the next pass retries rather than
|
|
308
|
+
// dedup-suppressing a report nobody received.
|
|
309
|
+
envReportSent = res.ok ? payload : null;
|
|
310
|
+
return res.ok ? 'accepted' : 'retry';
|
|
311
|
+
} catch {
|
|
312
|
+
envReportSent = null;
|
|
313
|
+
return 'retry';
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const TOOLS_REPORT_URL = fleetEndpoint('tools-report', FLEET_URL);
|
|
318
|
+
let toolsReportSent = null;
|
|
319
|
+
let toolsReportUnsupported = false;
|
|
320
|
+
let toolsReportAt = 0;
|
|
321
|
+
/** Repo tool readiness is a per-box daemon report. Never include env values. */
|
|
322
|
+
export async function maybeReportTools(repoRoot, baseRef) {
|
|
323
|
+
if (Date.now() - toolsReportAt < 60_000) return;
|
|
324
|
+
toolsReportAt = Date.now();
|
|
325
|
+
if (toolsReportUnsupported) return;
|
|
326
|
+
const pubkey = myPubB64();
|
|
327
|
+
if (!pubkey) return;
|
|
328
|
+
let payload;
|
|
329
|
+
try {
|
|
330
|
+
const snapshot = readBaseTools(repoRoot, baseRef);
|
|
331
|
+
const installed = detectRuntimes().filter((r) => r.installed);
|
|
332
|
+
const runner = installed.find((r) => runnerToolCapabilities(r.id).mcp)?.id ?? installed[0]?.id ?? 'none';
|
|
333
|
+
payload = JSON.stringify({ pubkey, tools: toolReadout(snapshot, runner) });
|
|
334
|
+
} catch { return; }
|
|
335
|
+
if (payload === toolsReportSent) return;
|
|
336
|
+
try {
|
|
337
|
+
const res = await fetch(TOOLS_REPORT_URL, {
|
|
338
|
+
method: 'POST',
|
|
339
|
+
headers: { Authorization: `Bearer ${FLEET_TOKEN}`, 'User-Agent': USER_AGENT, 'Content-Type': 'application/json' },
|
|
340
|
+
signal: AbortSignal.timeout(15_000), body: payload,
|
|
341
|
+
});
|
|
342
|
+
if (envReportIsPermanent(res.status)) toolsReportUnsupported = true;
|
|
343
|
+
toolsReportSent = res.ok ? payload : null;
|
|
344
|
+
} catch { toolsReportSent = null; }
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* "THE REPO JUST CHANGED — look again." Clears the repo-state throttle so the
|
|
349
|
+
* next reconcile rescans on the beat it already runs; the work manager calls
|
|
350
|
+
* it (through fleet.mjs's `onRepoChanged`) after a ship, a retirement or a cut.
|
|
351
|
+
*/
|
|
352
|
+
export function repoStateChanged() {
|
|
353
|
+
repoStateScanAt = 0;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const MACHINE_URL = fleetEndpoint('machine', FLEET_URL);
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Machine telemetry — what the box is doing with itself, for the admin view.
|
|
360
|
+
* `liveTurns` is the work manager's live registry, passed in by the loop.
|
|
361
|
+
*/
|
|
362
|
+
export function reportMachine({ worktreeDir, liveTurns }) {
|
|
363
|
+
// Tell the app what this machine is doing with itself. Every reconcile,
|
|
364
|
+
// best-effort, and never awaited — telemetry that can delay a dispatch is
|
|
365
|
+
// worse than no telemetry.
|
|
366
|
+
//
|
|
367
|
+
// The one thing Flowviant could never answer about a task was "why is it
|
|
368
|
+
// slow", because the box was somebody's laptop and only they could look at
|
|
369
|
+
// it. Centralising is supposed to make one machine easier to manage than N
|
|
370
|
+
// laptops; that is only true if the machine is visible. Per-task RSS is the
|
|
371
|
+
// load-bearing part — "the box is full" is not actionable, "this task is
|
|
372
|
+
// holding 9GB" is.
|
|
373
|
+
//
|
|
374
|
+
// …AND IT HAD NEVER BEEN POPULATED ONCE (fixed 2026-09-14). This read the
|
|
375
|
+
// dispatch-era `workers` map, which nothing has `.set()` since the lane was
|
|
376
|
+
// deleted on 2026-08-19 — so the list was permanently empty, the server
|
|
377
|
+
// stored an empty array every poll, and the column its own handler calls
|
|
378
|
+
// the load-bearing half of this report was blank on every machine that has
|
|
379
|
+
// ever run. The same shape as the env rotation two blocks up, which
|
|
380
|
+
// iterated the same dead map and reached no worktree at all.
|
|
381
|
+
//
|
|
382
|
+
// `liveTurns()` is the live registry: every CLI child this daemon is
|
|
383
|
+
// holding, with the session or agent id it serves. Bounded inside the
|
|
384
|
+
// snapshot, because each row costs a /proc tree walk.
|
|
385
|
+
void postToFleet(
|
|
386
|
+
MACHINE_URL,
|
|
387
|
+
machineSnapshot({
|
|
388
|
+
worktreeDir,
|
|
389
|
+
tasks: liveTurns().map((t) => ({ intentId: t.id, pid: t.pid })),
|
|
390
|
+
})
|
|
391
|
+
);
|
|
392
|
+
}
|