flowviant 0.116.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 +71 -139
- 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
package/bin/lib/config.mjs
CHANGED
|
@@ -69,6 +69,10 @@ function argFlag(name) {
|
|
|
69
69
|
*/
|
|
70
70
|
const API_BASE = process.env.FLOWVIANT_API_URL || 'https://api.flowviant.com/api';
|
|
71
71
|
export const MCP_URL = process.env.FLOWVIANT_MCP_URL || `${API_BASE}/mcp`;
|
|
72
|
+
/** The web app a terminal view or a device login sends a person to. One
|
|
73
|
+
* declaration (audit 2026-10-10, F753): login.mjs and views.mjs each held a
|
|
74
|
+
* copy of this line. */
|
|
75
|
+
export const APP_URL = process.env.FLOWVIANT_APP_URL || 'https://app.flowviant.com';
|
|
72
76
|
export const FLEET_URL = process.env.FLOWVIANT_FLEET_URL || `${API_BASE}/fleet/agents`;
|
|
73
77
|
// Push channel: the daemon holds this WebSocket open and the server nudges it
|
|
74
78
|
// the instant a job lands, so dispatch is ~a round-trip instead of a full poll.
|
|
@@ -76,7 +80,6 @@ export const FLEET_URL = process.env.FLOWVIANT_FLEET_URL || `${API_BASE}/fleet/a
|
|
|
76
80
|
export const STREAM_URL =
|
|
77
81
|
process.env.FLOWVIANT_STREAM_URL ||
|
|
78
82
|
FLEET_URL.replace(/\/agents(\/?)$/, '/stream$1').replace(/^http/, 'ws');
|
|
79
|
-
export const POLL_SECONDS = Number(process.env.POLL_SECONDS || 20);
|
|
80
83
|
|
|
81
84
|
// What this machine can actually be given — the cgroup's limits before the
|
|
82
85
|
// host's. The rule and its reader live in machineLimits.mjs; this is the
|
|
@@ -147,16 +150,42 @@ export const MACHINE = measureMachineLimits();
|
|
|
147
150
|
* every agent. `cores * 2` lets the scheduler do what it is for, and memory
|
|
148
151
|
* stays the bound that actually refuses.
|
|
149
152
|
*
|
|
150
|
-
* `min(
|
|
151
|
-
* bound on a box, not a product decision, and an operator who typed
|
|
152
|
-
* the machine gets exactly that number.
|
|
153
|
+
* `min(TURN_CEILING_MAX, …)` and the env override are untouched: the hard 32 is
|
|
154
|
+
* a runaway bound on a box, not a product decision, and an operator who typed
|
|
155
|
+
* a number at the machine gets exactly that number.
|
|
153
156
|
*/
|
|
154
157
|
export function deriveMaxConcurrent(memBytes, cores) {
|
|
155
158
|
const byMem = Math.floor(memBytes / 2 ** 30 - 2);
|
|
156
159
|
const byCpu = Number(cores) * 2;
|
|
157
|
-
return Math.max(1, Math.min(
|
|
160
|
+
return Math.max(1, Math.min(TURN_CEILING_MAX, byMem, byCpu));
|
|
158
161
|
}
|
|
159
162
|
|
|
163
|
+
/**
|
|
164
|
+
* THE RUNAWAY BOUND ON TURNS AT ONCE, AND THE ONE READING OF A CEILING
|
|
165
|
+
* (2026-10-10, audit F070).
|
|
166
|
+
*
|
|
167
|
+
* Every author of the ceiling — the env var, the server's dial, the
|
|
168
|
+
* derivation and an admission's own read — goes through `clampTurnCeiling`,
|
|
169
|
+
* which was five hand copies of one rule across this file and admission.mjs:
|
|
170
|
+
* a usable ceiling is a finite number ≥ 1, floored and capped at
|
|
171
|
+
* `TURN_CEILING_MAX`; anything else is ABSENT (null), never 0 or NaN — a 0
|
|
172
|
+
* refuses every spawn forever, and `live >= NaN` is no ceiling at all.
|
|
173
|
+
*
|
|
174
|
+
* 32 is the server's own bound on the dial (`machineTurnsSchema.maxTurns`,
|
|
175
|
+
* `max(32)` in the shared agent-runner schema): the release parity gate
|
|
176
|
+
* (`scripts/check-app-parity.mjs`) holds this copy to it, so the app never
|
|
177
|
+
* stores a dial no machine honours, and no machine caps one it would.
|
|
178
|
+
*/
|
|
179
|
+
export const TURN_CEILING_MAX = 32;
|
|
180
|
+
export function clampTurnCeiling(raw) {
|
|
181
|
+
const v = Number(raw);
|
|
182
|
+
return Number.isFinite(v) && v >= 1 ? Math.min(Math.floor(v), TURN_CEILING_MAX) : null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The operator's `FLOWVIANT_MAX_CONCURRENT`, read once through the one clamp:
|
|
186
|
+
* null when unset or unusable (the derivation stands). */
|
|
187
|
+
const MAX_CONCURRENT_ASKED = clampTurnCeiling(process.env.FLOWVIANT_MAX_CONCURRENT);
|
|
188
|
+
|
|
160
189
|
/**
|
|
161
190
|
* WAS THE ENV VAR THE SOURCE? — the one fact the precedence rule cannot
|
|
162
191
|
* reconstruct afterwards.
|
|
@@ -166,20 +195,10 @@ export function deriveMaxConcurrent(memBytes, cores) {
|
|
|
166
195
|
* last word and must outrank the app's dial, the other is a default the app may
|
|
167
196
|
* override. See `pickMaxTurns` in admission.mjs for what is done with it.
|
|
168
197
|
*/
|
|
169
|
-
export const MAX_CONCURRENT_FROM_ENV =
|
|
170
|
-
const asked = Number(process.env.FLOWVIANT_MAX_CONCURRENT);
|
|
171
|
-
return Number.isFinite(asked) && asked >= 1;
|
|
172
|
-
})();
|
|
198
|
+
export const MAX_CONCURRENT_FROM_ENV = MAX_CONCURRENT_ASKED !== null;
|
|
173
199
|
|
|
174
|
-
export const MAX_CONCURRENT =
|
|
175
|
-
|
|
176
|
-
if (Number.isFinite(asked) && asked >= 1) return Math.min(Math.floor(asked), 32);
|
|
177
|
-
return deriveMaxConcurrent(MACHINE.memBytes, MACHINE.cores);
|
|
178
|
-
})();
|
|
179
|
-
export const IDLE_SECONDS = Number(process.env.IDLE_SECONDS || 30);
|
|
180
|
-
// Live mode: after this long idle-parked on a blocker, tear the session down to
|
|
181
|
-
// free the Claude process (the intent stays claimed; it resumes when answered).
|
|
182
|
-
export const PARK_TIMEOUT_SECONDS = Number(process.env.PARK_TIMEOUT_SECONDS || 900);
|
|
200
|
+
export const MAX_CONCURRENT =
|
|
201
|
+
MAX_CONCURRENT_ASKED ?? deriveMaxConcurrent(MACHINE.memBytes, MACHINE.cores);
|
|
183
202
|
export const RECONCILE_SECONDS = Number(process.env.RECONCILE_SECONDS || 10);
|
|
184
203
|
// Proactively refresh a worker token this many seconds before its lease lapses,
|
|
185
204
|
// so a long-lived daemon never silently 401s on an expired token.
|
|
@@ -190,12 +209,6 @@ export const SAFE = process.env.FLOWVIANT_SAFE === '1';
|
|
|
190
209
|
// keeps it nag-only (it still tells you to update, never installs). Below the
|
|
191
210
|
// server's MIN version it updates regardless, since live mode won't work.
|
|
192
211
|
export const AUTO_UPDATE = process.env.FLOWVIANT_NO_UPDATE !== '1';
|
|
193
|
-
// Live mode (DEFAULT since 0.8.0): persistent Agent-SDK session per task —
|
|
194
|
-
// streams into the task channel, injectable mid-task, blocker-parks in place,
|
|
195
|
-
// delivery card on complete, branch preview tunnels. The legacy poll/sentinel
|
|
196
|
-
// path (one-shot `claude -p` turns) survives behind FLOWVIANT_POLL=1 as the
|
|
197
|
-
// escape hatch; FLOWVIANT_LIVE=1 is still honored for old scripts.
|
|
198
|
-
export const LIVE = process.env.FLOWVIANT_POLL !== '1';
|
|
199
212
|
// Sent on the daemon's own HTTP calls so Cloudflare Bot Fight Mode doesn't 403
|
|
200
213
|
// them (Node's default UA is treated as a bot). Claude Code sends its own UA.
|
|
201
214
|
export const USER_AGENT = `flowviant/${VERSION}`;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHICH CONVERSATION A TAB RESUMES — the shape each CLI's conversation id must
|
|
3
|
+
* have, and the marker each tab pins it under (audit 2026-10-10, F157).
|
|
4
|
+
*
|
|
5
|
+
* A LEAF ON PURPOSE: no imports. These regexes are argv-injection and
|
|
6
|
+
* path-traversal guards — an id rides argv as `--resume <id>`, `resume <id>`
|
|
7
|
+
* or `--conversation <id>`, and Claude's is joined into a transcript path the
|
|
8
|
+
* probe cleanup then REMOVES — and they were written out by hand: Claude's
|
|
9
|
+
* four times, Antigravity's twice. A CLI that changes its id format would
|
|
10
|
+
* have had someone loosen one copy and miss the one guarding `rmSync`. The
|
|
11
|
+
* readers (claudeSessions, agySessions, runtimeCapabilities, runtimeLimits)
|
|
12
|
+
* sit below the work lanes, so the shapes cannot live in a lane module.
|
|
13
|
+
*
|
|
14
|
+
* THE MARKER NAMES ARE HERE FOR THE SAME REASON. `workSessionTurns.mjs`
|
|
15
|
+
* WRITES them (`sessionMetaPath(wt, <name>, sessionId)`), workPlaceReports
|
|
16
|
+
* reads Claude's for the tab's title, and localSessions READS all of them to
|
|
17
|
+
* know which conversations are the daemon's own — a drift between the writer
|
|
18
|
+
* and that fence is silent, and offers somebody their own tab back as an
|
|
19
|
+
* adoptable terminal session.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Claude Code's session id, as `system.init` reports it. Conservative: word
|
|
24
|
+
* characters and dash, 8–64 of them, so it can neither climb a path nor
|
|
25
|
+
* carry whitespace into argv.
|
|
26
|
+
*/
|
|
27
|
+
export const CLAUDE_SESSION_RE = /^[A-Za-z0-9_-]{8,64}$/;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The shape a codex thread id must have before it is written to disk or —
|
|
31
|
+
* decisive — pushed into argv as `resume <id>`. Conservative on purpose:
|
|
32
|
+
* alphanumeric plus dash/underscore, never a leading dash (an argv that
|
|
33
|
+
* parses as a flag), never whitespace. Anything else is dropped and the
|
|
34
|
+
* session simply runs fresh in its own worktree.
|
|
35
|
+
*/
|
|
36
|
+
export const CODEX_THREAD_RE = /^[0-9a-zA-Z][0-9a-zA-Z_-]{7,63}$/;
|
|
37
|
+
|
|
38
|
+
/** agy conversation ids are plain UUIDs (the db filename IS the identity —
|
|
39
|
+
* measured: a renamed copy fails "trajectory not found"). Guarded the same
|
|
40
|
+
* way as the codex id: it rides in argv as `--conversation <id>`. */
|
|
41
|
+
export const AGY_CONV_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
42
|
+
|
|
43
|
+
/** Each runtime's per-tab conversation marker (scoped by the session id). */
|
|
44
|
+
export const RESUME_MARKERS = Object.freeze({
|
|
45
|
+
claude: 'flowviant-claude-session',
|
|
46
|
+
codex: 'flowviant-codex-thread',
|
|
47
|
+
antigravity: 'flowviant-agy-conversation',
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/** Every tab's conversation marker — what the adopt fence reads back. */
|
|
51
|
+
export const SESSION_MARKERS = Object.freeze(Object.values(RESUME_MARKERS));
|
package/bin/lib/credentials.mjs
CHANGED
|
@@ -44,7 +44,6 @@
|
|
|
44
44
|
* mistake permanent.
|
|
45
45
|
*/
|
|
46
46
|
|
|
47
|
-
import { execFileSync } from 'node:child_process';
|
|
48
47
|
import { mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
|
|
49
48
|
import { homedir } from 'node:os';
|
|
50
49
|
import { join } from 'node:path';
|
|
@@ -52,6 +51,7 @@ import { printable } from './printable.mjs';
|
|
|
52
51
|
import { connectedOn } from './credentialDate.mjs';
|
|
53
52
|
import { terminalCommand } from './launchCommand.mjs';
|
|
54
53
|
import { samePath } from './repoPath.mjs';
|
|
54
|
+
import { git } from './git.mjs';
|
|
55
55
|
|
|
56
56
|
const CRED_DIR = join(homedir(), '.flowviant');
|
|
57
57
|
const CRED_FILE = join(CRED_DIR, 'credentials.json');
|
|
@@ -76,12 +76,7 @@ function writeFile(v) {
|
|
|
76
76
|
/** The repo root of `cwd`, realpath'd, or null when not inside a git repo. */
|
|
77
77
|
export function detectRepoRoot(cwd = process.cwd()) {
|
|
78
78
|
try {
|
|
79
|
-
const root =
|
|
80
|
-
cwd,
|
|
81
|
-
encoding: 'utf8',
|
|
82
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
83
|
-
timeout: 5000,
|
|
84
|
-
}).trim();
|
|
79
|
+
const root = git(['rev-parse', '--show-toplevel'], cwd, { stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 });
|
|
85
80
|
return root ? realpathSync(root) : null;
|
|
86
81
|
} catch {
|
|
87
82
|
return null;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE DAEMON LOCK FILE — its name, its record, and the one scan of them
|
|
3
|
+
* (audit 2026-10-10, F090/F123).
|
|
4
|
+
*
|
|
5
|
+
* instance.mjs arbitrates with these files; the status readout
|
|
6
|
+
* (daemonState.mjs, desktopContract.mjs) and `flowviant uninstall` only READ
|
|
7
|
+
* them. Each of those readers used to keep its own copy of the filename
|
|
8
|
+
* pattern or the record parse, and they had drifted: uninstall carried its own
|
|
9
|
+
* regex and pid read, the readout returned a pid that was not a positive
|
|
10
|
+
* integer, and `daemonRunningFor` re-implemented `readHolder` line for line. The
|
|
11
|
+
* FILENAME PATTERN is the thing that must not drift between them: it is what
|
|
12
|
+
* separates our locks from anything else living in ~/.flowviant, and a scan
|
|
13
|
+
* that stops matching sees an empty machine — so `uninstall --others` would
|
|
14
|
+
* remove the copy a live daemon runs from.
|
|
15
|
+
*
|
|
16
|
+
* A leaf (node builtins only), so a reader never has to load instance.mjs's
|
|
17
|
+
* takeover machinery to look at a file; instance.mjs re-exports all of it.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import * as nodeFs from 'node:fs';
|
|
21
|
+
import { createHash } from 'node:crypto';
|
|
22
|
+
import { homedir } from 'node:os';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
|
|
25
|
+
/** Every daemon lock's file name: `daemon-` + 12 hex of the credential's hash. */
|
|
26
|
+
export const DAEMON_LOCK_RE = /^daemon-[0-9a-f]{12}\.lock$/;
|
|
27
|
+
|
|
28
|
+
/** Deliberately a HASH: a credential must never become a filename. */
|
|
29
|
+
export function instanceLockPath(fleetToken, home = homedir()) {
|
|
30
|
+
const key = createHash('sha256').update(String(fleetToken || 'anon')).digest('hex').slice(0, 12);
|
|
31
|
+
return join(home, '.flowviant', `daemon-${key}.lock`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A lock record, or null: absent, truncated, half-written, or naming no
|
|
36
|
+
* positive integer pid (a pid of '12' is not a pid). Everything else the
|
|
37
|
+
* record carries — repoRoot, startedAt, entry, version, draining — is the
|
|
38
|
+
* caller's to read; none of it is required.
|
|
39
|
+
*/
|
|
40
|
+
export function parseLockRecord(text) {
|
|
41
|
+
try {
|
|
42
|
+
const v = JSON.parse(text);
|
|
43
|
+
return v && Number.isInteger(v.pid) && v.pid > 0 ? v : null;
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The record in the lock file at `path`, or null (see parseLockRecord). */
|
|
50
|
+
export function readLockFile(path, { fs = nodeFs } = {}) {
|
|
51
|
+
try {
|
|
52
|
+
return parseLockRecord(fs.readFileSync(path, 'utf8'));
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The record in this credential's lock, or null. */
|
|
59
|
+
export function readLockHolder(fleetToken) {
|
|
60
|
+
return readLockFile(instanceLockPath(fleetToken));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Every daemon lock on this machine, sorted by name for a stable read:
|
|
65
|
+
* `[{ path, holder }]`, where `holder` is null for a file with no readable
|
|
66
|
+
* record (no caller reads that as a daemon). ONE scan, because three callers
|
|
67
|
+
* walk this directory for different reasons — instance.mjs looking for a
|
|
68
|
+
* neighbour in the same repo, `flowviant stop` sweeping the lot, uninstall
|
|
69
|
+
* protecting the copy a live daemon runs from. An unreadable (or absent)
|
|
70
|
+
* directory is not an error here, it is an empty machine. `fs` and `home` are
|
|
71
|
+
* injectable for uninstall's test seams.
|
|
72
|
+
*/
|
|
73
|
+
export function listDaemonLocks({ fs = nodeFs, home = homedir() } = {}) {
|
|
74
|
+
const dir = join(home, '.flowviant');
|
|
75
|
+
let names;
|
|
76
|
+
try {
|
|
77
|
+
names = fs.readdirSync(dir);
|
|
78
|
+
} catch {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
return names
|
|
82
|
+
.filter((name) => DAEMON_LOCK_RE.test(name))
|
|
83
|
+
.sort()
|
|
84
|
+
.map((name) => {
|
|
85
|
+
const path = join(dir, name);
|
|
86
|
+
return { path, holder: readLockFile(path, { fs }) };
|
|
87
|
+
});
|
|
88
|
+
}
|
package/bin/lib/daemonState.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
|
13
13
|
import { homedir } from 'node:os';
|
|
14
14
|
import { join } from 'node:path';
|
|
15
15
|
import { emitMachineEvent } from './daemonLogging.mjs';
|
|
16
|
-
import {
|
|
16
|
+
import { readLockHolder } from './daemonLocks.mjs';
|
|
17
17
|
|
|
18
18
|
const store = () => join(homedir(), '.flowviant');
|
|
19
19
|
const key = (projectId) => createHash('sha256').update(String(projectId)).digest('hex').slice(0, 16);
|
|
@@ -56,9 +56,9 @@ export function setRuntimeLimit(projectId, runtime, words) {
|
|
|
56
56
|
return true;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
/** The pid in the instance lock for this credential, or null
|
|
59
|
+
/** The pid in the instance lock for this credential, or null — read through
|
|
60
|
+
* the lock's one record parse, so a pid that is not a positive integer is no
|
|
61
|
+
* pid (2026-10-10, F090). */
|
|
60
62
|
export function daemonLockPid(token) {
|
|
61
|
-
|
|
62
|
-
return JSON.parse(readFileSync(instanceLockPath(token), 'utf8')).pid;
|
|
63
|
-
} catch { return null; }
|
|
63
|
+
return readLockHolder(token)?.pid ?? null;
|
|
64
64
|
}
|
package/bin/lib/deploy.mjs
CHANGED
|
@@ -16,13 +16,13 @@
|
|
|
16
16
|
* - deployRunner.mjs the commands, their environment and log scrubbing,
|
|
17
17
|
* the health check (and why a deploy's secrets are
|
|
18
18
|
* the machine's own environment)
|
|
19
|
-
* - deployWire.mjs the
|
|
19
|
+
* - deployWire.mjs the lane's `/fleet/deploy-*` wire (post, claim)
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { DAEMON_INSTANCE } from './config.mjs';
|
|
23
23
|
import { c, note, ok, warn } from './ui.mjs';
|
|
24
24
|
import { scrub } from './uplinkScrub.mjs';
|
|
25
|
-
import { post } from './deployWire.mjs';
|
|
25
|
+
import { claim, post } from './deployWire.mjs';
|
|
26
26
|
import { runDeploy } from './deployRunner.mjs';
|
|
27
27
|
import { workLanesStopped } from './standDownGate.mjs';
|
|
28
28
|
|
|
@@ -99,16 +99,13 @@ export function processDeployJobs(jobs, ctx) {
|
|
|
99
99
|
void (async () => {
|
|
100
100
|
let beat = null;
|
|
101
101
|
try {
|
|
102
|
-
// `instance` names THIS PROCESS
|
|
102
|
+
// `instance` names THIS PROCESS — the shared lease claim stamps it
|
|
103
|
+
// (fleetPost.mjs's `claimFleetLease`). The pubkey cannot: it is the env
|
|
103
104
|
// keypair read from one file per home directory, so two daemons on one
|
|
104
105
|
// box share it and a pubkey-only read-back let both "win" the claim
|
|
105
106
|
// and run the same deploy twice concurrently.
|
|
106
|
-
const claimed = await
|
|
107
|
-
|
|
108
|
-
pubkey: ctx.myPubB64(),
|
|
109
|
-
instance: DAEMON_INSTANCE,
|
|
110
|
-
}).catch(() => null);
|
|
111
|
-
if (!claimed?.claimed) return; // another daemon won the claim
|
|
107
|
+
const claimed = await claim('deploy-claim', { jobId: job.id, pubkey: ctx.myPubB64() });
|
|
108
|
+
if (!claimed) return; // another daemon won the claim, or none was made
|
|
112
109
|
/**
|
|
113
110
|
* A CLAIM THAT COMES BACK AFTER THE STAND-DOWN BEGAN IS NOT RUN. The
|
|
114
111
|
* loop gate cannot see it (the claim was already on the wire), and the
|
|
@@ -22,9 +22,12 @@ import {
|
|
|
22
22
|
symlinkSync,
|
|
23
23
|
unlinkSync,
|
|
24
24
|
} from 'node:fs';
|
|
25
|
-
import {
|
|
26
|
-
import { basename, isAbsolute, join, relative, sep } from 'node:path';
|
|
25
|
+
import { isAbsolute, join, relative, sep } from 'node:path';
|
|
27
26
|
import { git, gitNetAsync } from './git.mjs';
|
|
27
|
+
import { worktreeHomeDir } from './statePaths.mjs';
|
|
28
|
+
// WHICH FILES ARE ENV FILES has one home, beside the report's own narrower
|
|
29
|
+
// rule; that block says why the two differ (2026-10-10, the audit's F085).
|
|
30
|
+
import { isCheckoutEnvFile } from './env.mjs';
|
|
28
31
|
|
|
29
32
|
/**
|
|
30
33
|
* A DEPLOY BUILDS THE BASE COMMIT, NEVER THE OPERATOR'S WORKING TREE
|
|
@@ -60,7 +63,6 @@ import { git, gitNetAsync } from './git.mjs';
|
|
|
60
63
|
* a workspace package's own CLI still runs from the checkout. Submodules are
|
|
61
64
|
* not initialised. The code and the commands no longer come from the checkout.
|
|
62
65
|
*/
|
|
63
|
-
const ENV_FILE_RE = /^(?:\.env(?:\..+)?|\.dev\.vars)$/;
|
|
64
66
|
const lexists = (p) => {
|
|
65
67
|
try {
|
|
66
68
|
lstatSync(p);
|
|
@@ -71,11 +73,15 @@ const lexists = (p) => {
|
|
|
71
73
|
};
|
|
72
74
|
|
|
73
75
|
/** Bring the checkout's untracked environment into a deploy worktree. Returns
|
|
74
|
-
* every path it created, so the cleanup can
|
|
75
|
-
* is removed (a recursive delete must never
|
|
76
|
-
* operator's own node_modules)
|
|
76
|
+
* `{ made, envFiles }`: `made` is every path it created, so the cleanup can
|
|
77
|
+
* unlink them BEFORE the worktree is removed (a recursive delete must never
|
|
78
|
+
* be handed a symlink into the operator's own node_modules); `envFiles` is
|
|
79
|
+
* every env file it COPIED (2026-10-10, the audit's F085), so the deploy lane
|
|
80
|
+
* can teach the scrubber the values its own build is about to be able to
|
|
81
|
+
* print — nested files the root-only env scan never reads. */
|
|
77
82
|
export function linkCheckoutEnvironment(repoRoot, dir) {
|
|
78
83
|
const made = [];
|
|
84
|
+
const envFiles = [];
|
|
79
85
|
let realRoot = repoRoot;
|
|
80
86
|
try {
|
|
81
87
|
realRoot = realpathSync(repoRoot);
|
|
@@ -149,11 +155,12 @@ export function linkCheckoutEnvironment(repoRoot, dir) {
|
|
|
149
155
|
if (e.isDirectory() && !lexists(dst)) linkNodeModules(src, dst, 0);
|
|
150
156
|
continue;
|
|
151
157
|
}
|
|
152
|
-
if (e.isFile() &&
|
|
158
|
+
if (e.isFile() && isCheckoutEnvFile(e.name)) {
|
|
153
159
|
if (!lexists(dst)) {
|
|
154
160
|
try {
|
|
155
161
|
copyFileSync(src, dst);
|
|
156
162
|
made.push(dst);
|
|
163
|
+
envFiles.push(dst);
|
|
157
164
|
} catch {
|
|
158
165
|
/* unreadable — the build says so if it needed it */
|
|
159
166
|
}
|
|
@@ -168,15 +175,18 @@ export function linkCheckoutEnvironment(repoRoot, dir) {
|
|
|
168
175
|
}
|
|
169
176
|
};
|
|
170
177
|
walk('', 0);
|
|
171
|
-
return made;
|
|
178
|
+
return { made, envFiles };
|
|
172
179
|
}
|
|
173
180
|
|
|
174
181
|
/**
|
|
175
|
-
* Cut the deploy worktree at the base tip. Returns `{ dir, sha, cleanup
|
|
182
|
+
* Cut the deploy worktree at the base tip. Returns `{ dir, sha, cleanup,
|
|
183
|
+
* envFiles }` (`envFiles`: the env files copied in — see
|
|
184
|
+
* `linkCheckoutEnvironment`), or
|
|
176
185
|
* throws with a sentence when base does not resolve. `worktreeDir` defaults to
|
|
177
|
-
* the daemon's own worktree home for this repo
|
|
178
|
-
*
|
|
179
|
-
*
|
|
186
|
+
* the daemon's own worktree home for this repo — statePaths.mjs's one
|
|
187
|
+
* derivation, the same one fleetStartup.worktreeHome makes and reaps — and the
|
|
188
|
+
* deploy lane relies on that default rather than passing it; the job id is
|
|
189
|
+
* hashed into the path, never joined raw — it is a server-named string.
|
|
180
190
|
*/
|
|
181
191
|
export async function openDeployCheckout({ repoRoot, baseRef, jobId, worktreeDir }) {
|
|
182
192
|
if (!baseRef) throw new Error('no base branch resolved, so there is nothing to deploy from');
|
|
@@ -195,14 +205,7 @@ export async function openDeployCheckout({ repoRoot, baseRef, jobId, worktreeDir
|
|
|
195
205
|
} catch {
|
|
196
206
|
throw new Error(`the base branch ${baseRef} does not resolve on this machine`);
|
|
197
207
|
}
|
|
198
|
-
const home =
|
|
199
|
-
worktreeDir ??
|
|
200
|
-
join(
|
|
201
|
-
homedir(),
|
|
202
|
-
'.flowviant',
|
|
203
|
-
'worktrees',
|
|
204
|
-
`${basename(repoRoot)}-${createHash('sha256').update(repoRoot).digest('hex').slice(0, 8)}`
|
|
205
|
-
);
|
|
208
|
+
const home = worktreeDir ?? worktreeHomeDir(repoRoot);
|
|
206
209
|
const dir = join(home, 'deploy', createHash('sha256').update(String(jobId)).digest('hex').slice(0, 16));
|
|
207
210
|
mkdirSync(join(home, 'deploy'), { recursive: true });
|
|
208
211
|
const drop = (made = [], target = dir) => {
|
|
@@ -256,10 +259,11 @@ export async function openDeployCheckout({ repoRoot, baseRef, jobId, worktreeDir
|
|
|
256
259
|
}
|
|
257
260
|
git(['worktree', 'add', '--detach', dir, sha], repoRoot);
|
|
258
261
|
let made = [];
|
|
262
|
+
let envFiles = [];
|
|
259
263
|
try {
|
|
260
|
-
made = linkCheckoutEnvironment(repoRoot, dir);
|
|
264
|
+
({ made, envFiles } = linkCheckoutEnvironment(repoRoot, dir));
|
|
261
265
|
} catch {
|
|
262
266
|
/* the build says so if it needed any of it */
|
|
263
267
|
}
|
|
264
|
-
return { dir, sha, cleanup: () => drop(made) };
|
|
268
|
+
return { dir, sha, envFiles, cleanup: () => drop(made) };
|
|
265
269
|
}
|
package/bin/lib/deployRunner.mjs
CHANGED
|
@@ -61,19 +61,35 @@
|
|
|
61
61
|
* files, and this box's shell is not one). Deriving the redaction list from the
|
|
62
62
|
* admission list is what stops the two drifting the next time a name is added.
|
|
63
63
|
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
64
|
+
* AND THE NESTED ENV FILES THIS DAEMON COPIES IN ITSELF (2026-10-10, the
|
|
65
|
+
* audit's F085). The root scan reads only the checkout root's `.env*`
|
|
66
|
+
* (env.mjs states that limit), while `openDeployCheckout` copies every
|
|
67
|
+
* `.env*` and `.dev.vars` from tracked directories three levels deep into the
|
|
68
|
+
* worktree the build runs in — `apps/api/.dev.vars` among them — so a build
|
|
69
|
+
* that printed its config relayed values the scrubber had never been told.
|
|
70
|
+
* The checkout now reports which files it copied, and `runDeploy` teaches the
|
|
71
|
+
* scrubber their values for that job's life only (`learnExtraSecrets`), then
|
|
72
|
+
* forgets them with the worktree. The root-only report scan is unchanged.
|
|
73
|
+
* Well-known vendor token SHAPES are redacted too (uplinkScrub.mjs).
|
|
74
|
+
*
|
|
75
|
+
* The honest residue, stated: a credential that is in none of those — one a
|
|
76
|
+
* `build` script fetches for itself, say, in a shape nobody listed — is not
|
|
66
77
|
* redacted, because this daemon has never seen it.
|
|
67
78
|
*/
|
|
68
79
|
|
|
69
80
|
import { spawn } from 'node:child_process';
|
|
70
|
-
import {
|
|
81
|
+
import { forgetExtraSecrets, learnExtraSecrets, scrubTail } from './uplinkScrub.mjs';
|
|
71
82
|
import { childEnv } from './childEnv.mjs';
|
|
72
83
|
import { readDeployConfig } from './deployConfig.mjs';
|
|
73
84
|
import { openDeployCheckout } from './deployCheckout.mjs';
|
|
85
|
+
import { readEnvFileValues } from './env.mjs';
|
|
86
|
+
import { killGroup } from './processGroup.mjs';
|
|
74
87
|
|
|
75
88
|
/** How long one deploy command may run before it is stopped. */
|
|
76
89
|
const DEPLOY_COMMAND_TIMEOUT_MS = 30 * 60_000;
|
|
90
|
+
/** How much of a command's output is kept — a memory bound; only the tail is
|
|
91
|
+
* ever read (`tailLines`, the version id). */
|
|
92
|
+
const DEPLOY_OUTPUT_KEEP = 512 * 1024;
|
|
77
93
|
|
|
78
94
|
/** Run a shell command ASYNC (never blocks the daemon's event loop — the
|
|
79
95
|
* reconcile poll + the deploy heartbeat must keep firing during a long
|
|
@@ -88,7 +104,8 @@ const DEPLOY_COMMAND_TIMEOUT_MS = 30 * 60_000;
|
|
|
88
104
|
* `deploysInFlight` holding off every self-update). Measured: `sleep 30 &
|
|
89
105
|
* wait` held the job past its timeout. So the command runs as its own process
|
|
90
106
|
* group (the shape `gitNetAsync` in git.mjs and the project check in
|
|
91
|
-
* workAgentCheck.mjs already use), the timer kills the group
|
|
107
|
+
* workAgentCheck.mjs already use), the timer kills the group (`killGroup`,
|
|
108
|
+
* processGroup.mjs — one home since 2026-10-10), and the timer
|
|
92
109
|
* itself resolves rather than waiting on a pipe a survivor could still hold.
|
|
93
110
|
* The output gains one line saying so, which reaches the server in the
|
|
94
111
|
* report's `message` — a relayed fact, not a new wire field.
|
|
@@ -131,27 +148,40 @@ export function run(command, { cwd, env, timeoutMs = DEPLOY_COMMAND_TIMEOUT_MS }
|
|
|
131
148
|
clearTimeout(timer);
|
|
132
149
|
resolve(v);
|
|
133
150
|
};
|
|
151
|
+
/**
|
|
152
|
+
* SCRUBBED BEFORE IT IS CUT (2026-10-10, the audit's F143) — workAgentCheck's
|
|
153
|
+
* order. This used to keep the last 512 KB per chunk and scrub only at
|
|
154
|
+
* settle, so a secret straddling the window's edge was cut first and its
|
|
155
|
+
* surviving half matched nothing. Now the window is cut only from text
|
|
156
|
+
* that has been scrubbed whole (`scrubTail`); a secret still arriving at
|
|
157
|
+
* the END of the buffer is not cut, and the next scrub sees it whole.
|
|
158
|
+
*
|
|
159
|
+
* Amortised rather than per chunk, unlike the check's 4000-character tail:
|
|
160
|
+
* re-scrubbing a 512 KB buffer on every small write of a chatty build is
|
|
161
|
+
* quadratic, so the buffer may grow to twice the window before it is
|
|
162
|
+
* scrubbed and cut. The settle-time scrub stays as the second pass over
|
|
163
|
+
* what ships.
|
|
164
|
+
*
|
|
165
|
+
* A DECODER PER STREAM: `setEncoding` hands whole characters, where
|
|
166
|
+
* `d.toString()` split a multi-byte character across two chunks into
|
|
167
|
+
* replacement characters — and a secret holding one could then never
|
|
168
|
+
* match.
|
|
169
|
+
*/
|
|
134
170
|
const cap = (d) => {
|
|
135
|
-
buf += d
|
|
136
|
-
if (buf.length >
|
|
171
|
+
buf += d;
|
|
172
|
+
if (buf.length > 2 * DEPLOY_OUTPUT_KEEP) buf = scrubTail(buf, DEPLOY_OUTPUT_KEEP);
|
|
137
173
|
};
|
|
174
|
+
child.stdout.setEncoding('utf8');
|
|
175
|
+
child.stderr.setEncoding('utf8');
|
|
138
176
|
child.stdout.on('data', cap);
|
|
139
177
|
child.stderr.on('data', cap);
|
|
140
178
|
const timer = setTimeout(() => {
|
|
141
|
-
|
|
142
|
-
process.kill(-child.pid, 'SIGKILL'); // the whole group
|
|
143
|
-
} catch {
|
|
144
|
-
try {
|
|
145
|
-
child.kill('SIGKILL');
|
|
146
|
-
} catch {
|
|
147
|
-
/* already gone */
|
|
148
|
-
}
|
|
149
|
-
}
|
|
179
|
+
killGroup(child, 'SIGKILL'); // the whole group
|
|
150
180
|
const limit = timeoutMs >= 60_000 ? `${Math.round(timeoutMs / 60_000)} minutes` : `${timeoutMs}ms`;
|
|
151
181
|
settle({
|
|
152
182
|
ok: false,
|
|
153
183
|
code: -1,
|
|
154
|
-
out:
|
|
184
|
+
out: scrubTail(`${buf}\n[flowviant] the command ran past ${limit} and was stopped`, DEPLOY_OUTPUT_KEEP),
|
|
155
185
|
});
|
|
156
186
|
}, timeoutMs);
|
|
157
187
|
// A broken pipe (a child that exits before its stdin is closed) surfaces as
|
|
@@ -166,10 +196,10 @@ export function run(command, { cwd, env, timeoutMs = DEPLOY_COMMAND_TIMEOUT_MS }
|
|
|
166
196
|
child.stdin.on('error', () => {});
|
|
167
197
|
child.stdin.end();
|
|
168
198
|
child.on('close', (code) => {
|
|
169
|
-
settle({ ok: code === 0, code: code ?? -1, out:
|
|
199
|
+
settle({ ok: code === 0, code: code ?? -1, out: scrubTail(buf, DEPLOY_OUTPUT_KEEP) });
|
|
170
200
|
});
|
|
171
201
|
child.on('error', (e) => {
|
|
172
|
-
settle({ ok: false, code: -1, out:
|
|
202
|
+
settle({ ok: false, code: -1, out: scrubTail(`${buf}\n${e.message}`, DEPLOY_OUTPUT_KEEP) });
|
|
173
203
|
});
|
|
174
204
|
});
|
|
175
205
|
}
|
|
@@ -203,7 +233,12 @@ export async function runDeploy(job, ctx) {
|
|
|
203
233
|
} catch (e) {
|
|
204
234
|
return { ok: false, message: `deploy not run — ${e.message}`, logs: [] };
|
|
205
235
|
}
|
|
236
|
+
// THE FILES THIS JOB COPIED IN, TAUGHT TO THE SCRUBBER FOR ITS LIFE ONLY
|
|
237
|
+
// (2026-10-10, the audit's F085) — see the header. Keyed per job, so two
|
|
238
|
+
// deploys never forget each other's.
|
|
239
|
+
const secretsKey = `deploy:${job.id}`;
|
|
206
240
|
try {
|
|
241
|
+
learnExtraSecrets(secretsKey, (checkout.envFiles ?? []).flatMap((f) => readEnvFileValues(f)));
|
|
207
242
|
const targets = readDeployConfig(ctx.repoRoot, checkout.sha);
|
|
208
243
|
const target = targets.find((t) => t.id === job.targetId);
|
|
209
244
|
if (!target) {
|
|
@@ -213,6 +248,7 @@ export async function runDeploy(job, ctx) {
|
|
|
213
248
|
const short = checkout.sha.slice(0, 12);
|
|
214
249
|
return { ...outcome, sha: checkout.sha, message: `${outcome.message} (${short})` };
|
|
215
250
|
} finally {
|
|
251
|
+
forgetExtraSecrets(secretsKey); // first: it cannot throw, and the cleanup can
|
|
216
252
|
checkout.cleanup();
|
|
217
253
|
}
|
|
218
254
|
}
|
package/bin/lib/deployWire.mjs
CHANGED
|
@@ -1,41 +1,50 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* THE DEPLOY LANE'S
|
|
2
|
+
* THE DEPLOY LANE'S WIRE — `/fleet/deploy-*`, with the machine credential.
|
|
3
3
|
*
|
|
4
4
|
* Split out of deploy.mjs (SOLID F060) because two halves of the lane speak
|
|
5
5
|
* it: the config report (deployConfig.mjs) and the job lease (deploy.mjs —
|
|
6
6
|
* claim, heartbeat, outcome). A leaf, so neither has to import the other to
|
|
7
|
-
* reach the wire
|
|
8
|
-
*
|
|
7
|
+
* reach the wire.
|
|
8
|
+
*
|
|
9
|
+
* The request itself is fleetPost.mjs's (audit 2026-10-10, F082): this was a
|
|
10
|
+
* byte-identical copy of `postToFleet`'s skeleton that differed only in what it
|
|
11
|
+
* returned. What stays here is the lane's own CONTRACT over that request — a
|
|
12
|
+
* throw on anything but an accepted answer, carrying whether it was the API's
|
|
13
|
+
* own refusal of the credential — and the lane's 30-second ceiling.
|
|
9
14
|
*/
|
|
10
15
|
|
|
11
|
-
import { FLEET_URL
|
|
16
|
+
import { FLEET_URL } from './config.mjs';
|
|
12
17
|
import { fleetEndpoint } from './fleetWire.mjs';
|
|
13
|
-
import {
|
|
18
|
+
import { claimFleetLease, fleetRequest } from './fleetPost.mjs';
|
|
14
19
|
|
|
15
20
|
const deployUrl = (tail) => fleetEndpoint(tail, FLEET_URL);
|
|
21
|
+
const DEPLOY_TIMEOUT_MS = 30_000;
|
|
16
22
|
|
|
23
|
+
/** POST to `/fleet/<tail>` and return the reply's `data`. A network error or
|
|
24
|
+
* timeout is rethrown as `fetch` threw it; a refusal (a non-2xx, or a 2xx
|
|
25
|
+
* saying `success: false`) throws an Error carrying `status` and
|
|
26
|
+
* `credentialRejected`. */
|
|
17
27
|
export async function post(tail, body) {
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
'User-Agent': USER_AGENT,
|
|
23
|
-
'Content-Type': 'application/json',
|
|
24
|
-
},
|
|
25
|
-
signal: AbortSignal.timeout(30_000),
|
|
26
|
-
body: JSON.stringify(body),
|
|
27
|
-
});
|
|
28
|
-
const json = await res.json().catch(() => ({}));
|
|
29
|
-
if (!res.ok || json?.success === false) {
|
|
28
|
+
const r = await fleetRequest(deployUrl(tail), { body, timeoutMs: DEPLOY_TIMEOUT_MS });
|
|
29
|
+
if (r.error) throw r.error;
|
|
30
|
+
const json = r.json;
|
|
31
|
+
if (!r.ok || json?.success === false) {
|
|
30
32
|
// The sentence is unchanged. What rides beside it is whether this was the
|
|
31
33
|
// API's OWN refusal of the credential (a Disconnect rotates it, even
|
|
32
|
-
// mid-deploy) — authReject.mjs's one rule,
|
|
33
|
-
// 401/403 (Cloudflare's bot checks, which this
|
|
34
|
-
// blip and is retried like any other failed post.
|
|
35
|
-
const e = new Error(`${tail} failed (${
|
|
36
|
-
e.status =
|
|
37
|
-
e.credentialRejected =
|
|
34
|
+
// mid-deploy) — authReject.mjs's one rule, asked by fleetRequest, never
|
|
35
|
+
// the bare status: an edge 401/403 (Cloudflare's bot checks, which this
|
|
36
|
+
// client class trips) is a blip and is retried like any other failed post.
|
|
37
|
+
const e = new Error(`${tail} failed (${r.status}${json?.error ? `: ${json.error}` : ''})`);
|
|
38
|
+
e.status = r.status;
|
|
39
|
+
e.credentialRejected = r.credentialRejected;
|
|
38
40
|
throw e;
|
|
39
41
|
}
|
|
40
42
|
return json?.data;
|
|
41
43
|
}
|
|
44
|
+
|
|
45
|
+
/** Claim a deploy job — the shared lease rule (fleetPost.mjs's
|
|
46
|
+
* `claimFleetLease`: this process's `instance` stamped, the winner read from
|
|
47
|
+
* `data.claimed`, any failure "not claimed"), on this lane's ceiling. */
|
|
48
|
+
export function claim(tail, fields) {
|
|
49
|
+
return claimFleetLease(deployUrl(tail), fields, { timeoutMs: DEPLOY_TIMEOUT_MS });
|
|
50
|
+
}
|