flowviant 0.102.2 → 0.104.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.mjs +87 -474
- package/bin/lib/admission.mjs +19 -3
- package/bin/lib/agentPlan.mjs +2 -2
- package/bin/lib/agentTaskKinds.mjs +93 -0
- package/bin/lib/agySessions.mjs +158 -0
- package/bin/lib/artifactPolicy.mjs +44 -0
- package/bin/lib/artifactPreview.mjs +52 -0
- package/bin/lib/artifactReporter.mjs +163 -0
- package/bin/lib/artifacts.mjs +47 -227
- package/bin/lib/authReject.mjs +16 -4
- package/bin/lib/authproxy.mjs +47 -304
- package/bin/lib/boxIdentity.mjs +245 -0
- package/bin/lib/childEnv.mjs +11 -9
- package/bin/lib/claude.mjs +14 -851
- package/bin/lib/claudeAuth.mjs +3 -3
- package/bin/lib/claudePosture.mjs +367 -0
- package/bin/lib/claudeSessions.mjs +411 -0
- package/bin/lib/claudeStream.mjs +207 -0
- package/bin/lib/cliLimit.mjs +34 -0
- package/bin/lib/commandSpecs.mjs +173 -0
- package/bin/lib/config.mjs +11 -42
- package/bin/lib/credentialDate.mjs +22 -0
- package/bin/lib/credentials.mjs +21 -26
- package/bin/lib/daemonLogging.mjs +55 -0
- package/bin/lib/daemonState.mjs +64 -0
- package/bin/lib/deploy.mjs +148 -647
- package/bin/lib/deployCheckout.mjs +265 -0
- package/bin/lib/deployConfig.mjs +142 -0
- package/bin/lib/deployRunner.mjs +311 -0
- package/bin/lib/deployWire.mjs +41 -0
- package/bin/lib/desktopContract.mjs +56 -105
- package/bin/lib/env.mjs +49 -484
- package/bin/lib/fleet.mjs +184 -2396
- package/bin/lib/fleetJobs.mjs +120 -0
- package/bin/lib/fleetPost.mjs +40 -0
- package/bin/lib/fleetReports.mjs +392 -0
- package/bin/lib/fleetRoster.mjs +470 -0
- package/bin/lib/fleetStartup.mjs +196 -0
- package/bin/lib/fleetWake.mjs +45 -0
- package/bin/lib/fleetWire.mjs +46 -0
- package/bin/lib/git.mjs +34 -3
- package/bin/lib/gitMergeIdentity.mjs +51 -0
- package/bin/lib/grant.mjs +5 -3
- package/bin/lib/help.mjs +3 -125
- package/bin/lib/holder.mjs +482 -0
- package/bin/lib/instance.mjs +124 -46
- package/bin/lib/knowledge.mjs +16 -799
- package/bin/lib/knowledgeFetch.mjs +38 -0
- package/bin/lib/knowledgeFileSync.mjs +112 -0
- package/bin/lib/knowledgeFiles.mjs +487 -0
- package/bin/lib/knowledgeLibrary.mjs +249 -0
- package/bin/lib/landed.mjs +2 -1
- package/bin/lib/listeners.mjs +99 -141
- package/bin/lib/localSessions.mjs +30 -555
- package/bin/lib/login.mjs +3 -2
- package/bin/lib/loginPath.mjs +3 -5
- package/bin/lib/machineDisconnect.mjs +203 -0
- package/bin/lib/machineEnv.mjs +22 -0
- package/bin/lib/machineLimits.mjs +61 -0
- package/bin/lib/machineListing.mjs +338 -0
- package/bin/lib/machines.mjs +78 -455
- package/bin/lib/machinesCommand.mjs +214 -0
- package/bin/lib/mcp-cli.mjs +2 -1
- package/bin/lib/patchRevert.mjs +60 -0
- package/bin/lib/prWorkflow.mjs +157 -0
- package/bin/lib/preflight.mjs +2 -1
- package/bin/lib/pressurePolicy.mjs +104 -0
- package/bin/lib/preview.mjs +2 -272
- package/bin/lib/previewFramePolicy.mjs +112 -0
- package/bin/lib/previewGatePolicy.mjs +236 -0
- package/bin/lib/previewRegistry.mjs +235 -0
- package/bin/lib/printable.mjs +2 -2
- package/bin/lib/procRegistry.mjs +28 -18
- package/bin/lib/procScan.mjs +146 -0
- package/bin/lib/processes.mjs +29 -37
- package/bin/lib/projectToolRuntimes.mjs +138 -0
- package/bin/lib/projectTools.mjs +13 -88
- package/bin/lib/prompts.mjs +28 -42
- package/bin/lib/repoPath.mjs +26 -0
- package/bin/lib/resources.mjs +7 -69
- package/bin/lib/resumeLost.mjs +107 -0
- package/bin/lib/runTurn.mjs +324 -0
- package/bin/lib/runtimeAntigravity.mjs +230 -0
- package/bin/lib/runtimeCapabilities.mjs +334 -0
- package/bin/lib/runtimeClaude.mjs +126 -0
- package/bin/lib/runtimeCodex.mjs +217 -0
- package/bin/lib/runtimeDetection.mjs +121 -0
- package/bin/lib/runtimeEvents.mjs +482 -0
- package/bin/lib/runtimeOfferings.mjs +4 -1
- package/bin/lib/runtimes.mjs +42 -1402
- package/bin/lib/safeFileName.mjs +74 -0
- package/bin/lib/shipMerge.mjs +33 -2
- package/bin/lib/standDownExit.mjs +181 -0
- package/bin/lib/standDownGate.mjs +48 -0
- package/bin/lib/startProject.mjs +279 -0
- package/bin/lib/trace.mjs +3 -3
- package/bin/lib/turnLock.mjs +68 -0
- package/bin/lib/turnProfile.mjs +77 -0
- package/bin/lib/uninstall.mjs +55 -45
- package/bin/lib/uninstallCopies.mjs +112 -0
- package/bin/lib/update.mjs +30 -17
- package/bin/lib/uplinkScrub.mjs +265 -0
- package/bin/lib/vault.mjs +22 -151
- package/bin/lib/vaultDiff.mjs +183 -0
- package/bin/lib/versionOrder.mjs +42 -0
- package/bin/lib/views.mjs +35 -12
- package/bin/lib/wikiRunner.mjs +630 -0
- package/bin/lib/work.mjs +188 -3037
- package/bin/lib/workAdoptCarry.mjs +94 -0
- package/bin/lib/workAgentCheck.mjs +278 -0
- package/bin/lib/workAgentMerges.mjs +54 -142
- package/bin/lib/workAgentPlans.mjs +14 -12
- package/bin/lib/workAgentPrecheck.mjs +393 -0
- package/bin/lib/workAgentPublish.mjs +177 -0
- package/bin/lib/workAgentReview.mjs +8 -666
- package/bin/lib/workAgentTurnBegun.mjs +109 -0
- package/bin/lib/workAgentTurnExecution.mjs +604 -0
- package/bin/lib/workAgentTurnOutcome.mjs +207 -0
- package/bin/lib/workAgentTurnReports.mjs +189 -0
- package/bin/lib/workAgentTurns.mjs +92 -897
- package/bin/lib/workAttachments.mjs +93 -0
- package/bin/lib/workBrain.mjs +53 -0
- package/bin/lib/workChildren.mjs +110 -0
- package/bin/lib/workCommandAudit.mjs +62 -0
- package/bin/lib/workDiffs.mjs +2 -1
- package/bin/lib/workNarration.mjs +152 -0
- package/bin/lib/workPlaceReports.mjs +426 -0
- package/bin/lib/workPlaces.mjs +228 -0
- package/bin/lib/workPreviews.mjs +31 -9
- package/bin/lib/workProcesses.mjs +4 -5
- package/bin/lib/workPullRequests.mjs +52 -126
- package/bin/lib/workReportQueue.mjs +254 -0
- package/bin/lib/workRetire.mjs +185 -0
- package/bin/lib/workSessionGroups.mjs +166 -0
- package/bin/lib/workSessionRuntime.mjs +164 -0
- package/bin/lib/workSessionTokens.mjs +68 -0
- package/bin/lib/workSessionTurns.mjs +892 -0
- package/bin/lib/workShip.mjs +9 -29
- package/bin/lib/workToolLog.mjs +76 -0
- package/package.json +3 -2
- package/bin/lib/patch.mjs +0 -363
package/bin/lib/deploy.mjs
CHANGED
|
@@ -6,290 +6,63 @@
|
|
|
6
6
|
* .flowviant/deploy.json so the app can list targets, and (basic) observes
|
|
7
7
|
* out-of-band deployments.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* So both halves are gone, and what replaces them is the operator:
|
|
22
|
-
*
|
|
23
|
-
* - the deploy COMMAND runs with the infra credentials that are in this
|
|
24
|
-
* daemon's own environment, passed through `childEnv`'s opt-in `deploy: true`
|
|
25
|
-
* allowlist. If `wrangler` authenticates when the operator runs it in that
|
|
26
|
-
* shell, it authenticates here.
|
|
27
|
-
* - the `pushSecrets` step is DELETED outright rather than reimplemented
|
|
28
|
-
* against `process.env`. It existed to move values Flowviant was custodian
|
|
29
|
-
* of; with no custody there is nothing here that the operator does not
|
|
30
|
-
* already have in front of them, and `wrangler secret put` is a command they
|
|
31
|
-
* can run. Flowviant automating a push of secrets it does not hold, from an
|
|
32
|
-
* environment it does not own, into a provider store it cannot read back, is
|
|
33
|
-
* the shape of a feature that fails silently and invisibly. `target
|
|
34
|
-
* .pushSecrets` is still PARSED and reported (a dormant key is the standing
|
|
35
|
-
* call) — it simply drives nothing.
|
|
36
|
-
*
|
|
37
|
-
* ── WHAT A DEPLOY LOG IS ACTUALLY SCRUBBED AGAINST, corrected 2026-09-21 by
|
|
38
|
-
* the review that caught this paragraph asserting a guarantee it had lost ──
|
|
39
|
-
*
|
|
40
|
-
* Every log line that leaves the machine still passes through `scrub`. What
|
|
41
|
-
* this paragraph claimed for a few hours was that being fed from the checkout's
|
|
42
|
-
* `.env*` files was "strictly more of what a deploy log can contain than the
|
|
43
|
-
* vault ever delivered", and for THIS lane that was exactly backwards. The
|
|
44
|
-
* vault's deploy-scope half WAS `CLOUDFLARE_API_TOKEN` and friends, so those
|
|
45
|
-
* were redacted; an operator's own token lives in the shell they started the
|
|
46
|
-
* daemon in — which is the entire reason `childEnv`'s `deploy: true` widening
|
|
47
|
-
* exists — and almost never in a checkout file. So the one lane that hands a
|
|
48
|
-
* credential to a command and then streams that command's stdout to the server
|
|
49
|
-
* was the one lane no longer redacting it.
|
|
50
|
-
*
|
|
51
|
-
* It is fed from BOTH now, and the join is made in `scanEnvForScrub` rather
|
|
52
|
-
* than here so no call site has to remember: the checkout's `.env*` files, plus
|
|
53
|
-
* the PRESENT values of `childEnv`'s own `DEPLOY_KEEP` names out of
|
|
54
|
-
* `process.env` (see `processEnvSecrets`, which also explains why `CF_API_TOKEN`
|
|
55
|
-
* is redacted although it is never passed, and why none of this reaches the
|
|
56
|
-
* `/fleet/env-report` wire — that report is a statement about the checkout's
|
|
57
|
-
* files, and this box's shell is not one). Deriving the redaction list from the
|
|
58
|
-
* admission list is what stops the two drifting the next time a name is added.
|
|
59
|
-
*
|
|
60
|
-
* The honest residue, stated: a credential that is neither in `DEPLOY_KEEP` nor
|
|
61
|
-
* in a checkout `.env*` — one a `build` script fetches for itself, say — is not
|
|
62
|
-
* redacted, because this daemon has never seen it.
|
|
9
|
+
* THIS FILE IS THE JOB LEASE (SOLID F060, split from one 811-line module):
|
|
10
|
+
* claim → heartbeat → run → report, single-flight per job id and never twice
|
|
11
|
+
* in one process. The rest of the lane sits beside it, one reason to change
|
|
12
|
+
* each:
|
|
13
|
+
* - deployConfig.mjs what `.flowviant/deploy.json` on base declares, and
|
|
14
|
+
* the report of it to the server
|
|
15
|
+
* - deployCheckout.mjs the throwaway detached worktree at the base tip
|
|
16
|
+
* - deployRunner.mjs the commands, their environment and log scrubbing,
|
|
17
|
+
* the health check (and why a deploy's secrets are
|
|
18
|
+
* the machine's own environment)
|
|
19
|
+
* - deployWire.mjs the one `/fleet/deploy-*` POST
|
|
63
20
|
*/
|
|
64
21
|
|
|
65
|
-
|
|
66
|
-
import { spawn } from 'node:child_process';
|
|
67
|
-
import { createHash } from 'node:crypto';
|
|
68
|
-
import {
|
|
69
|
-
copyFileSync,
|
|
70
|
-
existsSync,
|
|
71
|
-
lstatSync,
|
|
72
|
-
mkdirSync,
|
|
73
|
-
readdirSync,
|
|
74
|
-
realpathSync,
|
|
75
|
-
rmdirSync,
|
|
76
|
-
rmSync,
|
|
77
|
-
symlinkSync,
|
|
78
|
-
unlinkSync,
|
|
79
|
-
} from 'node:fs';
|
|
80
|
-
import { homedir } from 'node:os';
|
|
81
|
-
import { basename, isAbsolute, join, relative, sep } from 'node:path';
|
|
82
|
-
import { FLEET_URL, FLEET_TOKEN, USER_AGENT, DAEMON_INSTANCE } from './config.mjs';
|
|
22
|
+
import { DAEMON_INSTANCE } from './config.mjs';
|
|
83
23
|
import { c, note, ok, warn } from './ui.mjs';
|
|
84
|
-
import { scrub
|
|
85
|
-
import {
|
|
86
|
-
import {
|
|
24
|
+
import { scrub } from './uplinkScrub.mjs';
|
|
25
|
+
import { post } from './deployWire.mjs';
|
|
26
|
+
import { runDeploy } from './deployRunner.mjs';
|
|
27
|
+
import { workLanesStopped } from './standDownGate.mjs';
|
|
87
28
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
body: JSON.stringify(body),
|
|
100
|
-
});
|
|
101
|
-
const json = await res.json().catch(() => ({}));
|
|
102
|
-
if (!res.ok || json?.success === false) {
|
|
103
|
-
throw new Error(`${tail} failed (${res.status}${json?.error ? `: ${json.error}` : ''})`);
|
|
104
|
-
}
|
|
105
|
-
return json?.data;
|
|
106
|
-
}
|
|
29
|
+
/** In-flight guard (single-flight per daemon process): job id → what it is
|
|
30
|
+
* deploying, in words (`web → prod`, for the stand-down's sentence), and —
|
|
31
|
+
* once the server has granted the claim — the job and its context, which is
|
|
32
|
+
* what `reportDeploysAbandoned` needs to settle it. */
|
|
33
|
+
const claiming = new Map();
|
|
34
|
+
/** How many deploys this process is running (claim through report) — a
|
|
35
|
+
* deploy is work the machine must not be restarted under (fleet.mjs,
|
|
36
|
+
* `machineBusy`). */
|
|
37
|
+
export const deploysInFlight = () => claiming.size;
|
|
38
|
+
/** What the in-flight deploys are deploying, `target → env` each. */
|
|
39
|
+
export const deploysInFlightLabels = () => [...claiming.values()].map((c) => c.label);
|
|
107
40
|
|
|
108
41
|
/**
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
* IT USED TO READ THE WORKING TREE, which made the feature's one stated bound
|
|
113
|
-
* false. `deploy_target`'s own description says "only ids already declared in
|
|
114
|
-
* `.flowviant/deploy.json` on MAIN can be named (the daemon reads and runs from
|
|
115
|
-
* the repo ROOT, never a session worktree, so an agent cannot author the command
|
|
116
|
-
* it triggers without shipping it first)" — and the repo root's WORKING TREE is
|
|
117
|
-
* exactly where the machine operator's tabs stand (their place is the checkout).
|
|
118
|
-
* So an agent that had read an injected instruction could write an uncommitted
|
|
119
|
-
* `.flowviant/deploy.json` naming any shell command, call `deploy_target`, and
|
|
120
|
-
* have the daemon run it from the repo root with `CLOUDFLARE_API_TOKEN` and
|
|
121
|
-
* every other deploy-scope credential in its environment. Nothing about that
|
|
122
|
-
* needed a commit, a review, or an owner.
|
|
123
|
-
*
|
|
124
|
-
* Reading the COMMITTED tree is what makes the sentence true: authoring the
|
|
125
|
-
* command now requires landing it on base, which is a reviewed act. The file is
|
|
126
|
-
* read through git rather than the filesystem, so an uncommitted edit is simply
|
|
127
|
-
* not there.
|
|
42
|
+
* RESOLVES ONCE EVERY DEPLOY THIS PROCESS HAS CLAIMED HAS REPORTED (or given
|
|
43
|
+
* up reporting) — immediately when none is in flight.
|
|
128
44
|
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
45
|
+
* The stand-down waits on this before it exits (standDownExit.mjs), because a
|
|
46
|
+
* STAND-DOWN LETS A DEPLOY FINISH AND REPORT (ruling 2026-09-26): the process
|
|
47
|
+
* that holds the claim is the only thing that can post its outcome, and an
|
|
48
|
+
* outcome that never lands has the server hand the same irreversible job out
|
|
49
|
+
* again three minutes later.
|
|
132
50
|
*/
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
if (
|
|
136
|
-
|
|
137
|
-
raw = git(['show', `${baseRef}:.flowviant/deploy.json`], repoRoot);
|
|
138
|
-
} catch {
|
|
139
|
-
// No such file on base, or a base ref that does not resolve. Both mean
|
|
140
|
-
// "this branch declares no targets", which is a real answer.
|
|
141
|
-
return [];
|
|
142
|
-
}
|
|
143
|
-
} else {
|
|
144
|
-
// No base ref in hand (a caller that has not been updated). Refuse rather
|
|
145
|
-
// than silently falling back to the working tree — that fallback IS the bug.
|
|
146
|
-
warn('deploy: no base branch resolved, so no deploy targets were read.');
|
|
147
|
-
return [];
|
|
148
|
-
}
|
|
149
|
-
try {
|
|
150
|
-
const parsed = JSON.parse(raw);
|
|
151
|
-
const targets = Array.isArray(parsed?.targets) ? parsed.targets : [];
|
|
152
|
-
// Keep only fields the server + runner need; the daemon holds the commands.
|
|
153
|
-
return targets
|
|
154
|
-
.filter((t) => t && typeof t.id === 'string' && typeof t.command === 'string')
|
|
155
|
-
.slice(0, 20);
|
|
156
|
-
} catch (e) {
|
|
157
|
-
warn(`deploy: .flowviant/deploy.json on the base branch is not valid JSON — ${e.message}`);
|
|
158
|
-
return [];
|
|
159
|
-
}
|
|
51
|
+
const settleWaiters = [];
|
|
52
|
+
export function whenDeploysSettle() {
|
|
53
|
+
if (claiming.size === 0) return Promise.resolve();
|
|
54
|
+
return new Promise((resolve) => settleWaiters.push(resolve));
|
|
160
55
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
const json = JSON.stringify(targets);
|
|
167
|
-
if (json === lastConfigJson) return;
|
|
168
|
-
// Scrub command strings before the server sees them — a command line can embed
|
|
169
|
-
// an internal host or a synced secret. Only redacted metadata leaves the box.
|
|
170
|
-
const scrubCmds = (o) =>
|
|
171
|
-
o && typeof o === 'object'
|
|
172
|
-
? Object.fromEntries(Object.entries(o).map(([k, v]) => [k, scrub(String(v ?? ''))]))
|
|
173
|
-
: o;
|
|
174
|
-
/**
|
|
175
|
-
* NORMALIZE BEFORE POSTING (2026-09-24, the audit). `.flowviant/deploy.json`
|
|
176
|
-
* is a hand-written file; the shared schema treats `healthStatus` /
|
|
177
|
-
* `healthcheck` / `build` / `label` as display metadata and drops what it
|
|
178
|
-
* cannot read rather than refusing the whole target — but this file used to
|
|
179
|
-
* send them through unmodified, so a `"healthStatus": "200"` (which
|
|
180
|
-
* `verifyHealth` below already coerces with `Number()`) or a `null` label
|
|
181
|
-
* used to reach the wire looking present-but-unreadable. Send exactly what
|
|
182
|
-
* the mirror is going to keep instead of what the file happened to spell:
|
|
183
|
-
* a numeric `healthStatus` when the value is numeric or a numeric string,
|
|
184
|
-
* and no key at all for `label` / `healthcheck` / `build` when the file's
|
|
185
|
-
* value is not a non-empty string.
|
|
186
|
-
*/
|
|
187
|
-
const numOrOmit = (v) => {
|
|
188
|
-
if (typeof v === 'number' && Number.isFinite(v)) return v;
|
|
189
|
-
if (typeof v === 'string' && /^\s*\d+\s*$/.test(v)) return Number(v);
|
|
190
|
-
return undefined;
|
|
191
|
-
};
|
|
192
|
-
const strOrOmit = (v) => (typeof v === 'string' && v.trim() ? v : undefined);
|
|
193
|
-
const meta = targets.map((t) => {
|
|
194
|
-
const label = strOrOmit(t.label);
|
|
195
|
-
const build = strOrOmit(t.build);
|
|
196
|
-
const healthcheck = strOrOmit(t.healthcheck);
|
|
197
|
-
const healthStatus = numOrOmit(t.healthStatus);
|
|
198
|
-
return {
|
|
199
|
-
id: t.id,
|
|
200
|
-
...(label !== undefined ? { label } : {}),
|
|
201
|
-
provider: t.provider || 'cloudflare',
|
|
202
|
-
command: scrub(String(t.command ?? '')),
|
|
203
|
-
...(build !== undefined ? { build: scrub(build) } : {}),
|
|
204
|
-
commands: scrubCmds(t.commands),
|
|
205
|
-
...(healthcheck !== undefined ? { healthcheck } : {}),
|
|
206
|
-
...(healthStatus !== undefined ? { healthStatus } : {}),
|
|
207
|
-
pushSecrets: t.pushSecrets,
|
|
208
|
-
// Deploy-on-merge: the env this target auto-deploys to when commits land
|
|
209
|
-
// on base. MUST ride this map — a field forgotten here never reaches the
|
|
210
|
-
// server, and the server is what turns a landed report into the job.
|
|
211
|
-
...(typeof t.onMerge === 'string' ? { onMerge: t.onMerge } : {}),
|
|
212
|
-
};
|
|
213
|
-
});
|
|
214
|
-
try {
|
|
215
|
-
const data = await post('deploy-config', { pubkey: myPubB64(), targets: meta });
|
|
216
|
-
// Set on any 2xx, rejected targets included — the route now accepts a
|
|
217
|
-
// report per-target rather than failing the whole thing, so a target it
|
|
218
|
-
// could not read is not a reason to re-post this same file forever.
|
|
219
|
-
lastConfigJson = json;
|
|
220
|
-
// THE SERVER NAMES WHAT IT COULD NOT READ. Warned once per CHANGED config —
|
|
221
|
-
// this function only reaches here when the file did — never once per poll.
|
|
222
|
-
const rejected = Array.isArray(data?.rejected) ? data.rejected : [];
|
|
223
|
-
for (const r of rejected) {
|
|
224
|
-
warn(
|
|
225
|
-
`deploy: .flowviant/deploy.json target "${r?.id ?? '?'}" was not accepted — ${r?.reason ?? 'invalid'}`
|
|
226
|
-
);
|
|
227
|
-
}
|
|
228
|
-
} catch (e) {
|
|
229
|
-
warn(`deploy: could not report config — ${e.message}`);
|
|
230
|
-
}
|
|
56
|
+
/** The job's real outcome is on its way (`report` has started): a second
|
|
57
|
+
* Ctrl+C must not race it with `unknown` (`reportDeploysAbandoned`). */
|
|
58
|
+
function markReporting(jobId) {
|
|
59
|
+
const entry = claiming.get(jobId);
|
|
60
|
+
if (entry) entry.reporting = true;
|
|
231
61
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
* deploy). Captures combined + scrubbed output; resolves {ok,out,code}. */
|
|
236
|
-
function run(command, { cwd, env }) {
|
|
237
|
-
return new Promise((resolve) => {
|
|
238
|
-
const child = spawn(command, { cwd, env, shell: true, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
239
|
-
let buf = '';
|
|
240
|
-
const cap = (d) => {
|
|
241
|
-
buf += d.toString();
|
|
242
|
-
if (buf.length > 512 * 1024) buf = buf.slice(-512 * 1024); // bound memory
|
|
243
|
-
};
|
|
244
|
-
child.stdout.on('data', cap);
|
|
245
|
-
child.stderr.on('data', cap);
|
|
246
|
-
const timer = setTimeout(() => {
|
|
247
|
-
try {
|
|
248
|
-
child.kill('SIGKILL');
|
|
249
|
-
} catch {
|
|
250
|
-
/* already gone */
|
|
251
|
-
}
|
|
252
|
-
}, 30 * 60_000);
|
|
253
|
-
// A broken pipe (a child that exits before its stdin is closed) surfaces as
|
|
254
|
-
// an ASYNC 'error' on the stdin stream, which the try/catch around this
|
|
255
|
-
// cannot catch. Without a listener it is an uncaught exception that kills
|
|
256
|
-
// the whole daemon. Swallow it.
|
|
257
|
-
//
|
|
258
|
-
// The `input` parameter this used to take went with the prod-secret push —
|
|
259
|
-
// it existed so a value could reach `wrangler secret put` on stdin rather
|
|
260
|
-
// than argv, and there is no value left to send. Closing stdin immediately
|
|
261
|
-
// is now the only case.
|
|
262
|
-
child.stdin.on('error', () => {});
|
|
263
|
-
child.stdin.end();
|
|
264
|
-
child.on('close', (code) => {
|
|
265
|
-
clearTimeout(timer);
|
|
266
|
-
resolve({ ok: code === 0, code: code ?? -1, out: scrub(buf) });
|
|
267
|
-
});
|
|
268
|
-
child.on('error', (e) => {
|
|
269
|
-
clearTimeout(timer);
|
|
270
|
-
resolve({ ok: false, code: -1, out: scrub(`${buf}\n${e.message}`) });
|
|
271
|
-
});
|
|
272
|
-
});
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
const tailLines = (s, n = 40) => s.split('\n').filter(Boolean).slice(-n);
|
|
276
|
-
|
|
277
|
-
/** Health-check a deployed target: GET the URL, expect `status`. Retries a few
|
|
278
|
-
* times for propagation lag. */
|
|
279
|
-
async function verifyHealth(url, status) {
|
|
280
|
-
for (let i = 0; i < 4; i++) {
|
|
281
|
-
try {
|
|
282
|
-
const res = await fetch(url, { signal: AbortSignal.timeout(10_000), redirect: 'manual' });
|
|
283
|
-
if (res.status === Number(status)) return true; // coerce — a string "200" in deploy.json must still match
|
|
284
|
-
} catch {
|
|
285
|
-
/* not up yet */
|
|
286
|
-
}
|
|
287
|
-
await new Promise((r) => setTimeout(r, 3000));
|
|
288
|
-
}
|
|
289
|
-
return false;
|
|
62
|
+
function releaseClaim(jobId) {
|
|
63
|
+
claiming.delete(jobId);
|
|
64
|
+
if (claiming.size === 0) for (const resolve of settleWaiters.splice(0)) resolve();
|
|
290
65
|
}
|
|
291
|
-
|
|
292
|
-
const claiming = new Set(); // in-flight guard (single-flight per daemon process)
|
|
293
66
|
/**
|
|
294
67
|
* JOBS THIS PROCESS HAS ALREADY RUN, whatever the server thinks.
|
|
295
68
|
*
|
|
@@ -322,7 +95,7 @@ export function processDeployJobs(jobs, ctx) {
|
|
|
322
95
|
if (!job || typeof job.id !== 'string') continue;
|
|
323
96
|
if (claiming.has(job.id)) continue;
|
|
324
97
|
if (ran.has(job.id)) continue; // already executed here — never twice
|
|
325
|
-
claiming.
|
|
98
|
+
claiming.set(job.id, { label: `${job.targetId} → ${job.env}`, held: null, reporting: false });
|
|
326
99
|
void (async () => {
|
|
327
100
|
let beat = null;
|
|
328
101
|
try {
|
|
@@ -336,9 +109,22 @@ export function processDeployJobs(jobs, ctx) {
|
|
|
336
109
|
instance: DAEMON_INSTANCE,
|
|
337
110
|
}).catch(() => null);
|
|
338
111
|
if (!claimed?.claimed) return; // another daemon won the claim
|
|
112
|
+
/**
|
|
113
|
+
* A CLAIM THAT COMES BACK AFTER THE STAND-DOWN BEGAN IS NOT RUN. The
|
|
114
|
+
* loop gate cannot see it (the claim was already on the wire), and the
|
|
115
|
+
* command is irreversible: starting it now would start work under a
|
|
116
|
+
* daemon that is leaving. Nothing has run, so the server's stale sweep
|
|
117
|
+
* handing the job out again in three minutes is the right outcome.
|
|
118
|
+
*/
|
|
119
|
+
if (workLanesStopped()) {
|
|
120
|
+
note(`${c.cyan('deploy')} ${c.dim(`— ${job.targetId} → ${job.env} was claimed as this daemon began stopping; not run, the app hands it out again.`)}`);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const entry = claiming.get(job.id);
|
|
124
|
+
if (entry) entry.held = { job, ctx };
|
|
339
125
|
// Keep the claim fresh while we run — a long deploy must never be
|
|
340
126
|
// re-queued out from under us (that would double-deploy). The async
|
|
341
|
-
// run()
|
|
127
|
+
// run() in deployRunner.mjs keeps the event loop free so this fires.
|
|
342
128
|
/**
|
|
343
129
|
* …AND IT CAN DIE, which is what makes the `stillBeating` predicate
|
|
344
130
|
* below mean anything.
|
|
@@ -378,380 +164,27 @@ export function processDeployJobs(jobs, ctx) {
|
|
|
378
164
|
// From here the work is DONE. Whatever the report does, this job must
|
|
379
165
|
// never run again in this process.
|
|
380
166
|
ran.add(job.id);
|
|
167
|
+
if (abandoned.has(job.id)) return; // its outcome was already said: `unknown`
|
|
168
|
+
markReporting(job.id);
|
|
381
169
|
await report(job, ctx, outcome, () => beat != null);
|
|
382
170
|
if (outcome.ok) ok(`${c.cyan('deploy')} ${c.dim(`— ${job.targetId} → ${job.env} done${outcome.healthOk === false ? ' (health failed)' : ''}`)}`);
|
|
383
171
|
else warn(`deploy: ${job.targetId} → ${job.env} failed — ${outcome.message}`);
|
|
384
172
|
} catch (e) {
|
|
385
173
|
warn(`deploy job ${job.id} errored: ${e.message}`);
|
|
174
|
+
if (abandoned.has(job.id)) return;
|
|
175
|
+
markReporting(job.id);
|
|
386
176
|
await report(job, ctx, { ok: false, message: e.message }).catch(() => {});
|
|
387
177
|
} finally {
|
|
388
178
|
// Stopped only AFTER the report has landed or given up — the requeue is
|
|
389
179
|
// gated on heartbeat staleness, so beating through the retries is what
|
|
390
180
|
// stops the server handing this job out again mid-retry.
|
|
391
181
|
if (beat) clearInterval(beat);
|
|
392
|
-
|
|
182
|
+
releaseClaim(job.id);
|
|
393
183
|
}
|
|
394
184
|
})();
|
|
395
185
|
}
|
|
396
186
|
}
|
|
397
187
|
|
|
398
|
-
/**
|
|
399
|
-
* A DEPLOY BUILDS THE BASE COMMIT, NEVER THE OPERATOR'S WORKING TREE
|
|
400
|
-
* (audit 2026-09-24).
|
|
401
|
-
*
|
|
402
|
-
* `readDeployConfig` reads the TARGET off base so that authoring a command
|
|
403
|
-
* requires landing it — and then every command ran with `cwd: repoRoot`, the
|
|
404
|
-
* operator's own checkout, on whatever branch they left it with whatever they
|
|
405
|
-
* had not committed. So `npm run build` and `wrangler deploy` read the
|
|
406
|
-
* package.json scripts, wrangler.toml and source of that working tree:
|
|
407
|
-
* deploy-on-merge (queued by a teammate's ship, a PR merge or an agent merge,
|
|
408
|
-
* none of which moves the checkout) shipped code WITHOUT the merge that
|
|
409
|
-
* triggered it, and a co-owner's "deploy it to prod" shipped the operator's
|
|
410
|
-
* half-finished branch. Reproduced: a checkout on `feature` with an
|
|
411
|
-
* uncommitted edit deployed the edit.
|
|
412
|
-
*
|
|
413
|
-
* So a deploy runs in a THROWAWAY DETACHED WORKTREE at the base tip — the
|
|
414
|
-
* shipMerge shape — and the config is read from THAT SHA, so the command and
|
|
415
|
-
* the code it builds are one commit. The directory dies in a `finally`.
|
|
416
|
-
*
|
|
417
|
-
* What a fresh worktree does not have is what git does not track: installed
|
|
418
|
-
* dependencies and the operator's env files. Those are the checkout's
|
|
419
|
-
* ENVIRONMENT, not its code, so for every directory base tracks (three levels
|
|
420
|
-
* deep) a `node_modules` in the checkout is re-created as a directory of links
|
|
421
|
-
* to its entries — a WORKSPACE package's link re-pointed at base's own copy of
|
|
422
|
-
* that package, never the checkout's working tree — and a `.env` / `.env.*` /
|
|
423
|
-
* `.dev.vars` the base commit does not carry is COPIED in. Stated residual:
|
|
424
|
-
* installed third-party packages and those env files are still the checkout's,
|
|
425
|
-
* so a dependency installed for a feature branch, or an untracked env file an
|
|
426
|
-
* agent edited, reaches the build; so does a workspace package's untracked
|
|
427
|
-
* build output (`dist/`), which is simply absent here and fails the build
|
|
428
|
-
* loudly rather than shipping the checkout's. A `.bin` shim is linked whole, so
|
|
429
|
-
* a workspace package's own CLI still runs from the checkout. Submodules are
|
|
430
|
-
* not initialised. The code and the commands no longer come from the checkout.
|
|
431
|
-
*/
|
|
432
|
-
const ENV_FILE_RE = /^(?:\.env(?:\..+)?|\.dev\.vars)$/;
|
|
433
|
-
const lexists = (p) => {
|
|
434
|
-
try {
|
|
435
|
-
lstatSync(p);
|
|
436
|
-
return true;
|
|
437
|
-
} catch {
|
|
438
|
-
return false;
|
|
439
|
-
}
|
|
440
|
-
};
|
|
441
|
-
|
|
442
|
-
/** Bring the checkout's untracked environment into a deploy worktree. Returns
|
|
443
|
-
* every path it created, so the cleanup can unlink them BEFORE the worktree
|
|
444
|
-
* is removed (a recursive delete must never be handed a symlink into the
|
|
445
|
-
* operator's own node_modules). */
|
|
446
|
-
export function linkCheckoutEnvironment(repoRoot, dir) {
|
|
447
|
-
const made = [];
|
|
448
|
-
let realRoot = repoRoot;
|
|
449
|
-
try {
|
|
450
|
-
realRoot = realpathSync(repoRoot);
|
|
451
|
-
} catch {
|
|
452
|
-
/* compared as given */
|
|
453
|
-
}
|
|
454
|
-
/**
|
|
455
|
-
* A node_modules is a REAL directory of per-entry links, not one link to the
|
|
456
|
-
* checkout's (review, audit 2026-09-24). A workspace package — npm, yarn and
|
|
457
|
-
* pnpm all install one as a link like `node_modules/@x/shared ->
|
|
458
|
-
* ../../packages/shared` — resolves THROUGH the checkout's node_modules into
|
|
459
|
-
* the checkout's WORKING TREE, so a whole-directory link had every bundler
|
|
460
|
-
* import the operator's uncommitted `packages/shared/src` into a deploy of
|
|
461
|
-
* base. An entry whose real path is inside the checkout but outside any
|
|
462
|
-
* node_modules is re-pointed at the SAME path in this worktree (base's copy);
|
|
463
|
-
* one base does not carry is left out, since base cannot import what it does
|
|
464
|
-
* not have. Every other entry links to the checkout's own, as before.
|
|
465
|
-
*/
|
|
466
|
-
const linkNodeModules = (src, dst, depth) => {
|
|
467
|
-
let entries;
|
|
468
|
-
try {
|
|
469
|
-
entries = readdirSync(src, { withFileTypes: true });
|
|
470
|
-
mkdirSync(dst);
|
|
471
|
-
made.push(dst);
|
|
472
|
-
} catch {
|
|
473
|
-
return;
|
|
474
|
-
}
|
|
475
|
-
for (const e of entries) {
|
|
476
|
-
const s = join(src, e.name);
|
|
477
|
-
const d = join(dst, e.name);
|
|
478
|
-
// A scope directory holds packages, not a package: one level down.
|
|
479
|
-
if (depth === 0 && e.name.startsWith('@') && e.isDirectory()) {
|
|
480
|
-
linkNodeModules(s, d, 1);
|
|
481
|
-
continue;
|
|
482
|
-
}
|
|
483
|
-
let target = s;
|
|
484
|
-
if (e.isSymbolicLink()) {
|
|
485
|
-
let real;
|
|
486
|
-
try {
|
|
487
|
-
real = realpathSync(s);
|
|
488
|
-
} catch {
|
|
489
|
-
continue; // a dangling link — nothing to bring
|
|
490
|
-
}
|
|
491
|
-
const rel = relative(realRoot, real);
|
|
492
|
-
const inCheckout = rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
|
|
493
|
-
if (inCheckout && !rel.split(sep).includes('node_modules')) {
|
|
494
|
-
if (!existsSync(join(dir, rel))) continue;
|
|
495
|
-
target = join(dir, rel);
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
try {
|
|
499
|
-
symlinkSync(target, d);
|
|
500
|
-
made.push(d);
|
|
501
|
-
} catch {
|
|
502
|
-
/* the build says so if it needed it */
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
};
|
|
506
|
-
const walk = (rel, depth) => {
|
|
507
|
-
let entries;
|
|
508
|
-
try {
|
|
509
|
-
entries = readdirSync(join(repoRoot, rel), { withFileTypes: true });
|
|
510
|
-
} catch {
|
|
511
|
-
return;
|
|
512
|
-
}
|
|
513
|
-
for (const e of entries) {
|
|
514
|
-
const src = join(repoRoot, rel, e.name);
|
|
515
|
-
const dst = join(dir, rel, e.name);
|
|
516
|
-
if (e.name === '.git') continue;
|
|
517
|
-
if (e.name === 'node_modules') {
|
|
518
|
-
if (e.isDirectory() && !lexists(dst)) linkNodeModules(src, dst, 0);
|
|
519
|
-
continue;
|
|
520
|
-
}
|
|
521
|
-
if (e.isFile() && ENV_FILE_RE.test(e.name)) {
|
|
522
|
-
if (!lexists(dst)) {
|
|
523
|
-
try {
|
|
524
|
-
copyFileSync(src, dst);
|
|
525
|
-
made.push(dst);
|
|
526
|
-
} catch {
|
|
527
|
-
/* unreadable — the build says so if it needed it */
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
continue;
|
|
531
|
-
}
|
|
532
|
-
// Recurse only into directories BASE tracks — an untracked `dist/` or a
|
|
533
|
-
// cache is neither code nor environment, and walking it costs a scan.
|
|
534
|
-
if (e.isDirectory() && depth < 3 && existsSync(dst) && !lexists(join(dst, '.git'))) {
|
|
535
|
-
walk(join(rel, e.name), depth + 1);
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
};
|
|
539
|
-
walk('', 0);
|
|
540
|
-
return made;
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
/**
|
|
544
|
-
* Cut the deploy worktree at the base tip. Returns `{ dir, sha, cleanup }`, or
|
|
545
|
-
* throws with a sentence when base does not resolve. `worktreeDir` defaults to
|
|
546
|
-
* the daemon's own worktree home for this repo (fleet.mjs keys it the same
|
|
547
|
-
* way); the job id is hashed into the path, never joined raw — it is a
|
|
548
|
-
* server-named string.
|
|
549
|
-
*/
|
|
550
|
-
export async function openDeployCheckout({ repoRoot, baseRef, jobId, worktreeDir }) {
|
|
551
|
-
if (!baseRef) throw new Error('no base branch resolved, so there is nothing to deploy from');
|
|
552
|
-
try {
|
|
553
|
-
// A NETWORK call, so timed, non-interactive and off the event loop — a
|
|
554
|
-
// bare `git fetch` here is `execFileSync` with no timeout, and a remote
|
|
555
|
-
// that prompts or a half-open connection froze every poll and lease on
|
|
556
|
-
// the machine until it returned.
|
|
557
|
-
await gitNetAsync(['fetch', 'origin', '--quiet'], repoRoot);
|
|
558
|
-
} catch {
|
|
559
|
-
/* offline — deploy the base this box last saw, and say which */
|
|
560
|
-
}
|
|
561
|
-
let sha;
|
|
562
|
-
try {
|
|
563
|
-
sha = git(['rev-parse', '--verify', `${baseRef}^{commit}`], repoRoot);
|
|
564
|
-
} catch {
|
|
565
|
-
throw new Error(`the base branch ${baseRef} does not resolve on this machine`);
|
|
566
|
-
}
|
|
567
|
-
const home =
|
|
568
|
-
worktreeDir ??
|
|
569
|
-
join(
|
|
570
|
-
homedir(),
|
|
571
|
-
'.flowviant',
|
|
572
|
-
'worktrees',
|
|
573
|
-
`${basename(repoRoot)}-${createHash('sha256').update(repoRoot).digest('hex').slice(0, 8)}`
|
|
574
|
-
);
|
|
575
|
-
const dir = join(home, 'deploy', createHash('sha256').update(String(jobId)).digest('hex').slice(0, 16));
|
|
576
|
-
mkdirSync(join(home, 'deploy'), { recursive: true });
|
|
577
|
-
const drop = (made = [], target = dir) => {
|
|
578
|
-
// Newest first, so a node_modules directory we made is empty of our links
|
|
579
|
-
// by the time it is removed.
|
|
580
|
-
for (const p of [...made].reverse()) {
|
|
581
|
-
try {
|
|
582
|
-
unlinkSync(p);
|
|
583
|
-
} catch {
|
|
584
|
-
try {
|
|
585
|
-
rmdirSync(p);
|
|
586
|
-
} catch {
|
|
587
|
-
/* already gone */
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
|
-
try {
|
|
592
|
-
git(['worktree', 'remove', '--force', target], repoRoot);
|
|
593
|
-
} catch {
|
|
594
|
-
/* not registered */
|
|
595
|
-
}
|
|
596
|
-
// Every symlink we made is already unlinked, and `rmSync` unlinks a
|
|
597
|
-
// symlink rather than descending it, so this can only delete our own copy.
|
|
598
|
-
try {
|
|
599
|
-
rmSync(target, { recursive: true, force: true });
|
|
600
|
-
} catch {
|
|
601
|
-
/* best-effort */
|
|
602
|
-
}
|
|
603
|
-
try {
|
|
604
|
-
git(['worktree', 'prune'], repoRoot);
|
|
605
|
-
} catch {
|
|
606
|
-
/* best-effort */
|
|
607
|
-
}
|
|
608
|
-
};
|
|
609
|
-
drop(); // a corpse from a crashed deploy of the same job
|
|
610
|
-
// …and any other job's corpse old enough that no deploy can still be running
|
|
611
|
-
// in it (two commands at most, 30 minutes each), so a daemon killed
|
|
612
|
-
// mid-deploy does not leave a checkout on disk for ever.
|
|
613
|
-
try {
|
|
614
|
-
const cutoff = Date.now() - 3 * 60 * 60 * 1000;
|
|
615
|
-
for (const name of readdirSync(join(home, 'deploy'))) {
|
|
616
|
-
const p = join(home, 'deploy', name);
|
|
617
|
-
try {
|
|
618
|
-
if (p !== dir && lstatSync(p).mtimeMs < cutoff) drop([], p);
|
|
619
|
-
} catch {
|
|
620
|
-
/* gone meanwhile */
|
|
621
|
-
}
|
|
622
|
-
}
|
|
623
|
-
} catch {
|
|
624
|
-
/* nothing to sweep */
|
|
625
|
-
}
|
|
626
|
-
git(['worktree', 'add', '--detach', dir, sha], repoRoot);
|
|
627
|
-
let made = [];
|
|
628
|
-
try {
|
|
629
|
-
made = linkCheckoutEnvironment(repoRoot, dir);
|
|
630
|
-
} catch {
|
|
631
|
-
/* the build says so if it needed any of it */
|
|
632
|
-
}
|
|
633
|
-
return { dir, sha, cleanup: () => drop(made) };
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
export async function runDeploy(job, ctx) {
|
|
637
|
-
let checkout;
|
|
638
|
-
try {
|
|
639
|
-
checkout = await openDeployCheckout({
|
|
640
|
-
repoRoot: ctx.repoRoot,
|
|
641
|
-
baseRef: ctx.baseRef,
|
|
642
|
-
jobId: job.id,
|
|
643
|
-
worktreeDir: ctx.worktreeDir,
|
|
644
|
-
});
|
|
645
|
-
} catch (e) {
|
|
646
|
-
return { ok: false, message: `deploy not run — ${e.message}`, logs: [] };
|
|
647
|
-
}
|
|
648
|
-
try {
|
|
649
|
-
const targets = readDeployConfig(ctx.repoRoot, checkout.sha);
|
|
650
|
-
const target = targets.find((t) => t.id === job.targetId);
|
|
651
|
-
if (!target) {
|
|
652
|
-
return { ok: false, message: `target "${job.targetId}" not in .flowviant/deploy.json`, logs: [] };
|
|
653
|
-
}
|
|
654
|
-
const outcome = await runDeployIn(job, target, checkout.dir);
|
|
655
|
-
const short = checkout.sha.slice(0, 12);
|
|
656
|
-
return { ...outcome, sha: checkout.sha, message: `${outcome.message} (${short})` };
|
|
657
|
-
} finally {
|
|
658
|
-
checkout.cleanup();
|
|
659
|
-
}
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
async function runDeployIn(job, target, cwd) {
|
|
663
|
-
// AN ALLOWLIST, not `{...process.env}` minus names. What stood here was
|
|
664
|
-
//
|
|
665
|
-
// const env = { ...process.env, ...deployCreds() };
|
|
666
|
-
// delete env.FLEET_TOKEN;
|
|
667
|
-
//
|
|
668
|
-
// and that delete was a NO-OP: `FLEET_TOKEN` is a module constant in
|
|
669
|
-
// config.mjs, while the environment variable is `FLOWVIANT_FLEET`. The
|
|
670
|
-
// machine credential was therefore in the environment of every deploy
|
|
671
|
-
// command — and of `target.build`, which is a string the REPO controls —
|
|
672
|
-
// under a comment asserting the opposite. A denylist is a claim about a set
|
|
673
|
-
// you cannot see; this is built from {} instead.
|
|
674
|
-
//
|
|
675
|
-
// `deploy: true` is the OPT-IN SECOND GROUP (childEnv.mjs): a named set of
|
|
676
|
-
// infra credential variables kept out of this daemon's own environment, and
|
|
677
|
-
// nothing else. It replaced `extra: deployCreds()` when the vault was deleted
|
|
678
|
-
// — the credentials are the OPERATOR's now, in the shell they started the
|
|
679
|
-
// daemon in, rather than values Flowviant decrypted onto the box.
|
|
680
|
-
const env = childEnv({ cwd, deploy: true }); // infra creds; never a file
|
|
681
|
-
const logs = [];
|
|
682
|
-
/**
|
|
683
|
-
* THE ENV-ISOLATION GUARD, RE-APPLIED HERE (2026-09-24, the audit).
|
|
684
|
-
*
|
|
685
|
-
* The server already refuses a non-prod trigger whose target declares no
|
|
686
|
-
* per-env override — `createDeployJob`'s own comment names the same
|
|
687
|
-
* fallback this file resolves, `commands?.[env] || command`, as the reason:
|
|
688
|
-
* the base `command` (and the bare `npx wrangler rollback` default) is the
|
|
689
|
-
* PROD command, so a dev/preview trigger with no override would silently
|
|
690
|
-
* run production. But that check reads the SERVER'S MIRROR
|
|
691
|
-
* (`reportDeployConfig`'s last post), and this function reads the file OFF
|
|
692
|
-
* THE COMMIT it is about to build and run — a window the trigger-time check
|
|
693
|
-
* cannot close: the file on base changed after the last report, or this
|
|
694
|
-
* daemon has not reported yet. So the same rule is asked again here,
|
|
695
|
-
* against the only copy that is about to matter, and prod is the one env
|
|
696
|
-
* this never touches.
|
|
697
|
-
*/
|
|
698
|
-
if (job.env !== 'prod') {
|
|
699
|
-
const key = job.kind === 'rollback' ? `rollback:${job.env}` : job.env;
|
|
700
|
-
if (!target.commands?.[key]) {
|
|
701
|
-
return {
|
|
702
|
-
ok: false,
|
|
703
|
-
message: `target declares no ${job.env} command — refusing to fall back to the prod command`,
|
|
704
|
-
logs,
|
|
705
|
-
};
|
|
706
|
-
}
|
|
707
|
-
}
|
|
708
|
-
// Rollback is a single wrangler command; deploy is build → secrets → deploy.
|
|
709
|
-
if (job.kind === 'rollback') {
|
|
710
|
-
const cmd = target.commands?.[`rollback:${job.env}`] || `npx wrangler rollback`;
|
|
711
|
-
const r = await run(cmd, { cwd, env });
|
|
712
|
-
logs.push(...tailLines(r.out));
|
|
713
|
-
return { ok: r.ok, message: r.ok ? 'rolled back' : logs.slice(-6).join('\n'), logs };
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
if (target.build) {
|
|
717
|
-
const b = await run(target.build, { cwd, env });
|
|
718
|
-
logs.push(...tailLines(b.out));
|
|
719
|
-
if (!b.ok) return { ok: false, message: `build failed:\n${logs.slice(-6).join('\n')}`, logs };
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
// THE PROD-SECRET PUSH IS DELETED (2026-09-21). What stood here read
|
|
723
|
-
// `appSecretsFor('prod')` out of the vault and piped each value into
|
|
724
|
-
// `wrangler secret put` on stdin — never argv, so no prod plaintext in `ps`.
|
|
725
|
-
// That care was right and it is moot: the vault is gone, Flowviant holds no
|
|
726
|
-
// app secret for anybody, and there is nothing left to push. It is not
|
|
727
|
-
// reimplemented against `process.env`, because a deploy that quietly pushes
|
|
728
|
-
// whatever happens to be exported in the daemon's shell into a provider's
|
|
729
|
-
// secret store is a worse feature than no feature — the operator can see and
|
|
730
|
-
// run `wrangler secret put`, and they are the only one who can tell which
|
|
731
|
-
// values belong there. `target.pushSecrets` stays parsed and reported, which
|
|
732
|
-
// is what every retired key in this product does.
|
|
733
|
-
const cmd = target.commands?.[job.env] || target.command;
|
|
734
|
-
const d = await run(cmd, { cwd, env });
|
|
735
|
-
logs.push(...tailLines(d.out));
|
|
736
|
-
if (!d.ok) return { ok: false, message: `deploy failed:\n${logs.slice(-8).join('\n')}`, logs };
|
|
737
|
-
|
|
738
|
-
// Extract the wrangler version/deployment id if present.
|
|
739
|
-
const idMatch = d.out.match(/Current Version ID:\s*([0-9a-f-]+)/i);
|
|
740
|
-
const deploymentId = idMatch ? idMatch[1] : null;
|
|
741
|
-
|
|
742
|
-
let healthOk = null;
|
|
743
|
-
if (target.healthcheck) {
|
|
744
|
-
healthOk = await verifyHealth(target.healthcheck, target.healthStatus ?? 200);
|
|
745
|
-
}
|
|
746
|
-
return {
|
|
747
|
-
ok: true,
|
|
748
|
-
healthOk,
|
|
749
|
-
deploymentId,
|
|
750
|
-
message: healthOk === false ? 'deployed, but health check failed' : 'deployed',
|
|
751
|
-
logs,
|
|
752
|
-
};
|
|
753
|
-
}
|
|
754
|
-
|
|
755
188
|
/**
|
|
756
189
|
* THE OUTCOME IS RETRIED, because losing it re-runs the deploy.
|
|
757
190
|
*
|
|
@@ -769,25 +202,34 @@ async function runDeployIn(job, target, cwd) {
|
|
|
769
202
|
* `ran` guard as the floor.
|
|
770
203
|
*/
|
|
771
204
|
async function report(job, ctx, outcome, stillBeating = () => true) {
|
|
772
|
-
const body =
|
|
773
|
-
jobId: job.id,
|
|
774
|
-
pubkey: ctx.myPubB64(),
|
|
775
|
-
// The same term the claim carries, for the same reason: the pubkey is one
|
|
776
|
-
// keypair per home directory, so two daemons on one box share it, and a
|
|
777
|
-
// stale holder's late report would otherwise settle the RECLAIMER's
|
|
778
|
-
// running job. The server matches it when present; an older server
|
|
779
|
-
// ignores the extra key.
|
|
780
|
-
instance: DAEMON_INSTANCE,
|
|
781
|
-
ok: !!outcome.ok,
|
|
782
|
-
deploymentId: outcome.deploymentId ?? null,
|
|
783
|
-
healthOk: outcome.healthOk ?? null,
|
|
784
|
-
message: scrub(outcome.message || ''),
|
|
785
|
-
};
|
|
205
|
+
const body = reportBody(job, ctx, outcome);
|
|
786
206
|
for (let attempt = 0; attempt < 6; attempt++) {
|
|
787
207
|
try {
|
|
788
208
|
await post('deploy-report', body);
|
|
789
209
|
return;
|
|
790
210
|
} catch (e) {
|
|
211
|
+
/**
|
|
212
|
+
* A REFUSED CREDENTIAL IS SAID, NEVER THROWN (ruling 2026-09-26). A
|
|
213
|
+
* stand-down lets the deploy finish and report — and the stand-down may
|
|
214
|
+
* be a Disconnect, which rotates the credential while the command is
|
|
215
|
+
* still running. Retrying the API's own refusal changes nothing, and
|
|
216
|
+
* throwing would leave the outcome in nobody's hands; one line names
|
|
217
|
+
* what happened and where the result is.
|
|
218
|
+
*
|
|
219
|
+
* ONLY THE API'S OWN REFUSAL (`credentialRejected`, authReject.mjs's
|
|
220
|
+
* rule, set by deployWire.mjs). An edge 401/403 — Cloudflare's bot
|
|
221
|
+
* checks answer this client class with their own — is a blip like any
|
|
222
|
+
* other and keeps the retries below: dropping the outcome over one
|
|
223
|
+
* would have the server hand the irreversible job out again.
|
|
224
|
+
*/
|
|
225
|
+
if (e?.credentialRejected) {
|
|
226
|
+
warn(
|
|
227
|
+
`deploy: ${job.targetId} → ${job.env} ${outcome.ok ? 'finished' : 'failed'}, but its outcome ` +
|
|
228
|
+
`could not be reported — the app refused this machine's credential (HTTP ${e.status}). ` +
|
|
229
|
+
'The result is only in this log.'
|
|
230
|
+
);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
791
233
|
// The last attempt says so; the ones before it are noise on a path that
|
|
792
234
|
// usually recovers.
|
|
793
235
|
if (attempt === 5) {
|
|
@@ -804,3 +246,62 @@ async function report(job, ctx, outcome, stillBeating = () => true) {
|
|
|
804
246
|
}
|
|
805
247
|
}
|
|
806
248
|
}
|
|
249
|
+
|
|
250
|
+
/** The one shape of `/fleet/deploy-report`, for a real outcome and for an
|
|
251
|
+
* abandoned one alike. */
|
|
252
|
+
function reportBody(job, ctx, outcome) {
|
|
253
|
+
return {
|
|
254
|
+
jobId: job.id,
|
|
255
|
+
pubkey: ctx.myPubB64(),
|
|
256
|
+
// The same term the claim carries, for the same reason: the pubkey is one
|
|
257
|
+
// keypair per home directory, so two daemons on one box share it, and a
|
|
258
|
+
// stale holder's late report would otherwise settle the RECLAIMER's
|
|
259
|
+
// running job. The server matches it when present; an older server
|
|
260
|
+
// ignores the extra key.
|
|
261
|
+
instance: DAEMON_INSTANCE,
|
|
262
|
+
ok: !!outcome.ok,
|
|
263
|
+
deploymentId: outcome.deploymentId ?? null,
|
|
264
|
+
healthOk: outcome.healthOk ?? null,
|
|
265
|
+
message: scrub(outcome.message || ''),
|
|
266
|
+
// `unknown` only when this process leaves without seeing the end of the
|
|
267
|
+
// command (`reportDeploysAbandoned`). A daemon→server report field, so no
|
|
268
|
+
// floor: an older server ignores the key and records `ok: false` — a
|
|
269
|
+
// terminal failure, which is still never handed out again.
|
|
270
|
+
...(outcome.unknown ? { outcome: 'unknown' } : {}),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* THE WORDS A DEPLOY'S OUTCOME CARRIES WHEN NOBODY SAW IT END — the second
|
|
276
|
+
* Ctrl+C during a stand-down's drain (standDownExit.mjs, ruling 2026-09-26).
|
|
277
|
+
*/
|
|
278
|
+
export const ABANDONED_DEPLOY_WORDS = 'outcome unknown: operator left mid-deploy';
|
|
279
|
+
|
|
280
|
+
/** Jobs whose outcome was posted as `unknown`: their own report never follows. */
|
|
281
|
+
const abandoned = new Set();
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* A PERSON IS LEAVING BEFORE THE DEPLOYS END — settle each claimed one as
|
|
285
|
+
* `unknown`, ONCE, so the server marks it terminal and never hands the same
|
|
286
|
+
* irreversible job out again (ruling 2026-09-26: a second Ctrl+C during the
|
|
287
|
+
* drain leaves, but says so first). Best-effort and bounded by the caller: one
|
|
288
|
+
* post per job, no retries, never throws. The commands themselves are not
|
|
289
|
+
* touched — they run in their own process groups and only their timeout ends
|
|
290
|
+
* them.
|
|
291
|
+
*/
|
|
292
|
+
export async function reportDeploysAbandoned() {
|
|
293
|
+
// Only a deploy whose command is still running: one already reporting has
|
|
294
|
+
// its real outcome on the wire (and keeps retrying it), and whichever post
|
|
295
|
+
// landed first would win.
|
|
296
|
+
const held = [...claiming.entries()].filter(([id, c]) => c.held && !c.reporting && !abandoned.has(id));
|
|
297
|
+
await Promise.all(
|
|
298
|
+
held.map(async ([id, { held: h }]) => {
|
|
299
|
+
abandoned.add(id);
|
|
300
|
+
try {
|
|
301
|
+
await post('deploy-report', reportBody(h.job, h.ctx, { ok: false, unknown: true, message: ABANDONED_DEPLOY_WORDS }));
|
|
302
|
+
} catch (e) {
|
|
303
|
+
warn(`deploy: ${h.job.targetId} → ${h.job.env} — could not say its outcome is unknown: ${e.message}`);
|
|
304
|
+
}
|
|
305
|
+
})
|
|
306
|
+
);
|
|
307
|
+
}
|