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,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RUNNING ONE DEPLOY JOB — cut the base checkout, read the target off that
|
|
3
|
+
* same commit, run build → deploy (or the rollback) with the operator's infra
|
|
4
|
+
* credentials, verify health, and hand back an outcome. No claim, no
|
|
5
|
+
* heartbeat, no report: deploy.mjs owns the lease this runs under.
|
|
6
|
+
*
|
|
7
|
+
* Split out of deploy.mjs (SOLID F060): this half changes with process
|
|
8
|
+
* execution, the command environment, log scrubbing and the health check.
|
|
9
|
+
* `runDeploy` is one job's run in order (checkout → config at that sha →
|
|
10
|
+
* commands → cleanup); it composes deployCheckout.mjs and deployConfig.mjs
|
|
11
|
+
* and owns neither rule.
|
|
12
|
+
*
|
|
13
|
+
* ── THE MACHINE'S OWN ENVIRONMENT IS THE SOURCE OF A DEPLOY'S SECRETS
|
|
14
|
+
* (2026-09-21), BECAUSE FLOWVIANT NO LONGER HOLDS ANY ──
|
|
15
|
+
*
|
|
16
|
+
* This lane used to get both halves of its secrets from the end-to-end
|
|
17
|
+
* encrypted vault: `deployCreds()` put the deploy-scope credentials in the
|
|
18
|
+
* command's environment, and `appSecretsFor('prod')` fed `wrangler secret put`
|
|
19
|
+
* on stdin so a prod deploy pushed the project's app secrets to the provider's
|
|
20
|
+
* own store. The vault is DELETED — the owner: "no i dont want it. unless its
|
|
21
|
+
* needed where i want to show the env of each of the machines (for
|
|
22
|
+
* comparison)", and on the recovery passphrase that protected it, "no, its fine
|
|
23
|
+
* to repaste from providers".
|
|
24
|
+
*
|
|
25
|
+
* So both halves are gone, and what replaces them is the operator:
|
|
26
|
+
*
|
|
27
|
+
* - the deploy COMMAND runs with the infra credentials that are in this
|
|
28
|
+
* daemon's own environment, passed through `childEnv`'s opt-in `deploy: true`
|
|
29
|
+
* allowlist. If `wrangler` authenticates when the operator runs it in that
|
|
30
|
+
* shell, it authenticates here.
|
|
31
|
+
* - the `pushSecrets` step is DELETED outright rather than reimplemented
|
|
32
|
+
* against `process.env`. It existed to move values Flowviant was custodian
|
|
33
|
+
* of; with no custody there is nothing here that the operator does not
|
|
34
|
+
* already have in front of them, and `wrangler secret put` is a command they
|
|
35
|
+
* can run. Flowviant automating a push of secrets it does not hold, from an
|
|
36
|
+
* environment it does not own, into a provider store it cannot read back, is
|
|
37
|
+
* the shape of a feature that fails silently and invisibly. `target
|
|
38
|
+
* .pushSecrets` is still PARSED and reported (a dormant key is the standing
|
|
39
|
+
* call) — it simply drives nothing.
|
|
40
|
+
*
|
|
41
|
+
* ── WHAT A DEPLOY LOG IS ACTUALLY SCRUBBED AGAINST, corrected 2026-09-21 by
|
|
42
|
+
* the review that caught this paragraph asserting a guarantee it had lost ──
|
|
43
|
+
*
|
|
44
|
+
* Every log line that leaves the machine still passes through `scrub`. What
|
|
45
|
+
* this paragraph claimed for a few hours was that being fed from the checkout's
|
|
46
|
+
* `.env*` files was "strictly more of what a deploy log can contain than the
|
|
47
|
+
* vault ever delivered", and for THIS lane that was exactly backwards. The
|
|
48
|
+
* vault's deploy-scope half WAS `CLOUDFLARE_API_TOKEN` and friends, so those
|
|
49
|
+
* were redacted; an operator's own token lives in the shell they started the
|
|
50
|
+
* daemon in — which is the entire reason `childEnv`'s `deploy: true` widening
|
|
51
|
+
* exists — and almost never in a checkout file. So the one lane that hands a
|
|
52
|
+
* credential to a command and then streams that command's stdout to the server
|
|
53
|
+
* was the one lane no longer redacting it.
|
|
54
|
+
*
|
|
55
|
+
* It is fed from BOTH now, and the join is made in `learnSecrets`
|
|
56
|
+
* (`uplinkScrub.mjs`) rather than here so no call site has to remember: the
|
|
57
|
+
* checkout's `.env*` files, plus the PRESENT values of `childEnv`'s own `DEPLOY_KEEP` names out of
|
|
58
|
+
* `process.env` (see `processEnvSecrets`, which also explains why `CF_API_TOKEN`
|
|
59
|
+
* is redacted although it is never passed, and why none of this reaches the
|
|
60
|
+
* `/fleet/env-report` wire — that report is a statement about the checkout's
|
|
61
|
+
* files, and this box's shell is not one). Deriving the redaction list from the
|
|
62
|
+
* admission list is what stops the two drifting the next time a name is added.
|
|
63
|
+
*
|
|
64
|
+
* The honest residue, stated: a credential that is neither in `DEPLOY_KEEP` nor
|
|
65
|
+
* in a checkout `.env*` — one a `build` script fetches for itself, say — is not
|
|
66
|
+
* redacted, because this daemon has never seen it.
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
import { spawn } from 'node:child_process';
|
|
70
|
+
import { scrub } from './uplinkScrub.mjs';
|
|
71
|
+
import { childEnv } from './childEnv.mjs';
|
|
72
|
+
import { readDeployConfig } from './deployConfig.mjs';
|
|
73
|
+
import { openDeployCheckout } from './deployCheckout.mjs';
|
|
74
|
+
|
|
75
|
+
/** How long one deploy command may run before it is stopped. */
|
|
76
|
+
const DEPLOY_COMMAND_TIMEOUT_MS = 30 * 60_000;
|
|
77
|
+
|
|
78
|
+
/** Run a shell command ASYNC (never blocks the daemon's event loop — the
|
|
79
|
+
* reconcile poll + the deploy heartbeat must keep firing during a long
|
|
80
|
+
* deploy). Captures combined + scrubbed output; resolves {ok,out,code}.
|
|
81
|
+
*
|
|
82
|
+
* A TIMEOUT SETTLES IT, AND TAKES THE WHOLE GROUP (2026-09-26, SOLID F060's
|
|
83
|
+
* test). With `shell: true` the child is `/bin/sh`, and the timer used to
|
|
84
|
+
* SIGKILL only that: anything the shell had started — a backgrounded server,
|
|
85
|
+
* a bundler's worker, `wrangler`'s own children — kept running AND kept the
|
|
86
|
+
* stdout pipe open, so 'close' never fired, the promise never settled, and
|
|
87
|
+
* the lease heartbeated a dead deploy for ever (no report, no requeue, and
|
|
88
|
+
* `deploysInFlight` holding off every self-update). Measured: `sleep 30 &
|
|
89
|
+
* wait` held the job past its timeout. So the command runs as its own process
|
|
90
|
+
* group (the shape `gitNetAsync` in git.mjs and the project check in
|
|
91
|
+
* workAgentCheck.mjs already use), the timer kills the group, and the timer
|
|
92
|
+
* itself resolves rather than waiting on a pipe a survivor could still hold.
|
|
93
|
+
* The output gains one line saying so, which reaches the server in the
|
|
94
|
+
* report's `message` — a relayed fact, not a new wire field.
|
|
95
|
+
*
|
|
96
|
+
* A STAND-DOWN LETS THE COMMAND FINISH AND REPORT (owner ruling 2026-09-26).
|
|
97
|
+
* Nothing but the timer above ever kills the group: there is no exit hook
|
|
98
|
+
* here, and none of the stand-downs (Ctrl+C, SIGTERM, a same-repo takeover,
|
|
99
|
+
* a commanded stop, a displaced or removed box, a revoked credential) stops
|
|
100
|
+
* a deploy. They leave through standDownExit.mjs's `leave`, which keeps the
|
|
101
|
+
* process up — lock held and marked draining, `busy` still written for the
|
|
102
|
+
* tray — until deploy.mjs has posted the outcome, a refused credential
|
|
103
|
+
* included (said in words, never thrown). deployRunner.test.mjs pins that the
|
|
104
|
+
* command outlives even a process that exits under it. `detached` is
|
|
105
|
+
* setsid, so the command is not in the terminal's foreground group: a Ctrl-C
|
|
106
|
+
* reaches only the daemon, which then waits for it. A person who presses
|
|
107
|
+
* Ctrl-C a second time leaves the command running, but the daemon first
|
|
108
|
+
* posts a best-effort outcome `unknown` (deploy.mjs
|
|
109
|
+
* `reportDeploysAbandoned`), which the server makes terminal and never hands
|
|
110
|
+
* out again. Only a SIGKILL, or an `unknown` post that does not land, leaves
|
|
111
|
+
* the job to the server's three-minute requeue. A service manager that kills
|
|
112
|
+
* the whole control group after its own stop timeout is outside what this
|
|
113
|
+
* process can hold (standDownExit.mjs states the unit settings).
|
|
114
|
+
* `windowsHide` keeps a native Windows daemon from opening a console window
|
|
115
|
+
* per command. */
|
|
116
|
+
export function run(command, { cwd, env, timeoutMs = DEPLOY_COMMAND_TIMEOUT_MS }) {
|
|
117
|
+
return new Promise((resolve) => {
|
|
118
|
+
const child = spawn(command, {
|
|
119
|
+
cwd,
|
|
120
|
+
env,
|
|
121
|
+
shell: true,
|
|
122
|
+
detached: true,
|
|
123
|
+
windowsHide: true,
|
|
124
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
125
|
+
});
|
|
126
|
+
let buf = '';
|
|
127
|
+
let settled = false;
|
|
128
|
+
const settle = (v) => {
|
|
129
|
+
if (settled) return;
|
|
130
|
+
settled = true;
|
|
131
|
+
clearTimeout(timer);
|
|
132
|
+
resolve(v);
|
|
133
|
+
};
|
|
134
|
+
const cap = (d) => {
|
|
135
|
+
buf += d.toString();
|
|
136
|
+
if (buf.length > 512 * 1024) buf = buf.slice(-512 * 1024); // bound memory
|
|
137
|
+
};
|
|
138
|
+
child.stdout.on('data', cap);
|
|
139
|
+
child.stderr.on('data', cap);
|
|
140
|
+
const timer = setTimeout(() => {
|
|
141
|
+
try {
|
|
142
|
+
process.kill(-child.pid, 'SIGKILL'); // the whole group
|
|
143
|
+
} catch {
|
|
144
|
+
try {
|
|
145
|
+
child.kill('SIGKILL');
|
|
146
|
+
} catch {
|
|
147
|
+
/* already gone */
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const limit = timeoutMs >= 60_000 ? `${Math.round(timeoutMs / 60_000)} minutes` : `${timeoutMs}ms`;
|
|
151
|
+
settle({
|
|
152
|
+
ok: false,
|
|
153
|
+
code: -1,
|
|
154
|
+
out: scrub(`${buf}\n[flowviant] the command ran past ${limit} and was stopped`),
|
|
155
|
+
});
|
|
156
|
+
}, timeoutMs);
|
|
157
|
+
// A broken pipe (a child that exits before its stdin is closed) surfaces as
|
|
158
|
+
// an ASYNC 'error' on the stdin stream, which the try/catch around this
|
|
159
|
+
// cannot catch. Without a listener it is an uncaught exception that kills
|
|
160
|
+
// the whole daemon. Swallow it.
|
|
161
|
+
//
|
|
162
|
+
// The `input` parameter this used to take went with the prod-secret push —
|
|
163
|
+
// it existed so a value could reach `wrangler secret put` on stdin rather
|
|
164
|
+
// than argv, and there is no value left to send. Closing stdin immediately
|
|
165
|
+
// is now the only case.
|
|
166
|
+
child.stdin.on('error', () => {});
|
|
167
|
+
child.stdin.end();
|
|
168
|
+
child.on('close', (code) => {
|
|
169
|
+
settle({ ok: code === 0, code: code ?? -1, out: scrub(buf) });
|
|
170
|
+
});
|
|
171
|
+
child.on('error', (e) => {
|
|
172
|
+
settle({ ok: false, code: -1, out: scrub(`${buf}\n${e.message}`) });
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const tailLines = (s, n = 40) => s.split('\n').filter(Boolean).slice(-n);
|
|
178
|
+
|
|
179
|
+
/** Health-check a deployed target: GET the URL, expect `status`. Retries a few
|
|
180
|
+
* times for propagation lag. */
|
|
181
|
+
async function verifyHealth(url, status) {
|
|
182
|
+
for (let i = 0; i < 4; i++) {
|
|
183
|
+
try {
|
|
184
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(10_000), redirect: 'manual' });
|
|
185
|
+
if (res.status === Number(status)) return true; // coerce — a string "200" in deploy.json must still match
|
|
186
|
+
} catch {
|
|
187
|
+
/* not up yet */
|
|
188
|
+
}
|
|
189
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
190
|
+
}
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export async function runDeploy(job, ctx) {
|
|
195
|
+
let checkout;
|
|
196
|
+
try {
|
|
197
|
+
checkout = await openDeployCheckout({
|
|
198
|
+
repoRoot: ctx.repoRoot,
|
|
199
|
+
baseRef: ctx.baseRef,
|
|
200
|
+
jobId: job.id,
|
|
201
|
+
worktreeDir: ctx.worktreeDir,
|
|
202
|
+
});
|
|
203
|
+
} catch (e) {
|
|
204
|
+
return { ok: false, message: `deploy not run — ${e.message}`, logs: [] };
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
const targets = readDeployConfig(ctx.repoRoot, checkout.sha);
|
|
208
|
+
const target = targets.find((t) => t.id === job.targetId);
|
|
209
|
+
if (!target) {
|
|
210
|
+
return { ok: false, message: `target "${job.targetId}" not in .flowviant/deploy.json`, logs: [] };
|
|
211
|
+
}
|
|
212
|
+
const outcome = await runDeployIn(job, target, checkout.dir);
|
|
213
|
+
const short = checkout.sha.slice(0, 12);
|
|
214
|
+
return { ...outcome, sha: checkout.sha, message: `${outcome.message} (${short})` };
|
|
215
|
+
} finally {
|
|
216
|
+
checkout.cleanup();
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function runDeployIn(job, target, cwd) {
|
|
221
|
+
// AN ALLOWLIST, not `{...process.env}` minus names. What stood here was
|
|
222
|
+
//
|
|
223
|
+
// const env = { ...process.env, ...deployCreds() };
|
|
224
|
+
// delete env.FLEET_TOKEN;
|
|
225
|
+
//
|
|
226
|
+
// and that delete was a NO-OP: `FLEET_TOKEN` is a module constant in
|
|
227
|
+
// config.mjs, while the environment variable is `FLOWVIANT_FLEET`. The
|
|
228
|
+
// machine credential was therefore in the environment of every deploy
|
|
229
|
+
// command — and of `target.build`, which is a string the REPO controls —
|
|
230
|
+
// under a comment asserting the opposite. A denylist is a claim about a set
|
|
231
|
+
// you cannot see; this is built from {} instead.
|
|
232
|
+
//
|
|
233
|
+
// `deploy: true` is the OPT-IN SECOND GROUP (childEnv.mjs): a named set of
|
|
234
|
+
// infra credential variables kept out of this daemon's own environment, and
|
|
235
|
+
// nothing else. It replaced `extra: deployCreds()` when the vault was deleted
|
|
236
|
+
// — the credentials are the OPERATOR's now, in the shell they started the
|
|
237
|
+
// daemon in, rather than values Flowviant decrypted onto the box.
|
|
238
|
+
const env = childEnv({ cwd, deploy: true }); // infra creds; never a file
|
|
239
|
+
const logs = [];
|
|
240
|
+
/**
|
|
241
|
+
* THE ENV-ISOLATION GUARD, RE-APPLIED HERE (2026-09-24, the audit).
|
|
242
|
+
*
|
|
243
|
+
* The server already refuses a non-prod trigger whose target declares no
|
|
244
|
+
* per-env override — `createDeployJob`'s own comment names the same
|
|
245
|
+
* fallback this file resolves, `commands?.[env] || command`, as the reason:
|
|
246
|
+
* the base `command` (and the bare `npx wrangler rollback` default) is the
|
|
247
|
+
* PROD command, so a dev/preview trigger with no override would silently
|
|
248
|
+
* run production. But that check reads the SERVER'S MIRROR
|
|
249
|
+
* (`reportDeployConfig`'s last post), and this function reads the file OFF
|
|
250
|
+
* THE COMMIT it is about to build and run — a window the trigger-time check
|
|
251
|
+
* cannot close: the file on base changed after the last report, or this
|
|
252
|
+
* daemon has not reported yet. So the same rule is asked again here,
|
|
253
|
+
* against the only copy that is about to matter, and prod is the one env
|
|
254
|
+
* this never touches.
|
|
255
|
+
*/
|
|
256
|
+
if (job.env !== 'prod') {
|
|
257
|
+
const key = job.kind === 'rollback' ? `rollback:${job.env}` : job.env;
|
|
258
|
+
if (!target.commands?.[key]) {
|
|
259
|
+
return {
|
|
260
|
+
ok: false,
|
|
261
|
+
message: `target declares no ${job.env} command — refusing to fall back to the prod command`,
|
|
262
|
+
logs,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
// Rollback is a single wrangler command; deploy is build → secrets → deploy.
|
|
267
|
+
if (job.kind === 'rollback') {
|
|
268
|
+
const cmd = target.commands?.[`rollback:${job.env}`] || `npx wrangler rollback`;
|
|
269
|
+
const r = await run(cmd, { cwd, env });
|
|
270
|
+
logs.push(...tailLines(r.out));
|
|
271
|
+
return { ok: r.ok, message: r.ok ? 'rolled back' : logs.slice(-6).join('\n'), logs };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (target.build) {
|
|
275
|
+
const b = await run(target.build, { cwd, env });
|
|
276
|
+
logs.push(...tailLines(b.out));
|
|
277
|
+
if (!b.ok) return { ok: false, message: `build failed:\n${logs.slice(-6).join('\n')}`, logs };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// THE PROD-SECRET PUSH IS DELETED (2026-09-21). What stood here read
|
|
281
|
+
// `appSecretsFor('prod')` out of the vault and piped each value into
|
|
282
|
+
// `wrangler secret put` on stdin — never argv, so no prod plaintext in `ps`.
|
|
283
|
+
// That care was right and it is moot: the vault is gone, Flowviant holds no
|
|
284
|
+
// app secret for anybody, and there is nothing left to push. It is not
|
|
285
|
+
// reimplemented against `process.env`, because a deploy that quietly pushes
|
|
286
|
+
// whatever happens to be exported in the daemon's shell into a provider's
|
|
287
|
+
// secret store is a worse feature than no feature — the operator can see and
|
|
288
|
+
// run `wrangler secret put`, and they are the only one who can tell which
|
|
289
|
+
// values belong there. `target.pushSecrets` stays parsed and reported, which
|
|
290
|
+
// is what every retired key in this product does.
|
|
291
|
+
const cmd = target.commands?.[job.env] || target.command;
|
|
292
|
+
const d = await run(cmd, { cwd, env });
|
|
293
|
+
logs.push(...tailLines(d.out));
|
|
294
|
+
if (!d.ok) return { ok: false, message: `deploy failed:\n${logs.slice(-8).join('\n')}`, logs };
|
|
295
|
+
|
|
296
|
+
// Extract the wrangler version/deployment id if present.
|
|
297
|
+
const idMatch = d.out.match(/Current Version ID:\s*([0-9a-f-]+)/i);
|
|
298
|
+
const deploymentId = idMatch ? idMatch[1] : null;
|
|
299
|
+
|
|
300
|
+
let healthOk = null;
|
|
301
|
+
if (target.healthcheck) {
|
|
302
|
+
healthOk = await verifyHealth(target.healthcheck, target.healthStatus ?? 200);
|
|
303
|
+
}
|
|
304
|
+
return {
|
|
305
|
+
ok: true,
|
|
306
|
+
healthOk,
|
|
307
|
+
deploymentId,
|
|
308
|
+
message: healthOk === false ? 'deployed, but health check failed' : 'deployed',
|
|
309
|
+
logs,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE DEPLOY LANE'S ONE POST — `/fleet/deploy-*`, with the machine credential.
|
|
3
|
+
*
|
|
4
|
+
* Split out of deploy.mjs (SOLID F060) because two halves of the lane speak
|
|
5
|
+
* it: the config report (deployConfig.mjs) and the job lease (deploy.mjs —
|
|
6
|
+
* claim, heartbeat, outcome). A leaf, so neither has to import the other to
|
|
7
|
+
* reach the wire, and a change to how the lane authenticates or times out is
|
|
8
|
+
* one edit.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
|
|
12
|
+
import { fleetEndpoint } from './fleetWire.mjs';
|
|
13
|
+
import { isCredentialRejection } from './authReject.mjs';
|
|
14
|
+
|
|
15
|
+
const deployUrl = (tail) => fleetEndpoint(tail, FLEET_URL);
|
|
16
|
+
|
|
17
|
+
export async function post(tail, body) {
|
|
18
|
+
const res = await fetch(deployUrl(tail), {
|
|
19
|
+
method: 'POST',
|
|
20
|
+
headers: {
|
|
21
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
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) {
|
|
30
|
+
// The sentence is unchanged. What rides beside it is whether this was the
|
|
31
|
+
// API's OWN refusal of the credential (a Disconnect rotates it, even
|
|
32
|
+
// mid-deploy) — authReject.mjs's one rule, never the bare status: an edge
|
|
33
|
+
// 401/403 (Cloudflare's bot checks, which this client class trips) is a
|
|
34
|
+
// blip and is retried like any other failed post.
|
|
35
|
+
const e = new Error(`${tail} failed (${res.status}${json?.error ? `: ${json.error}` : ''})`);
|
|
36
|
+
e.status = res.status;
|
|
37
|
+
e.credentialRejected = isCredentialRejection(res.status, res.headers.get('content-type'), json);
|
|
38
|
+
throw e;
|
|
39
|
+
}
|
|
40
|
+
return json?.data;
|
|
41
|
+
}
|
|
@@ -1,65 +1,49 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The local, read-only desktop contract: `flowviant status --json` and its
|
|
3
|
+
* `--remote` form. It READS the per-project state (daemonState.mjs); the
|
|
4
|
+
* daemon's output and events live in daemonLogging.mjs (split 2026-09-26,
|
|
5
|
+
* SOLID F061).
|
|
6
|
+
*/
|
|
7
7
|
import { FLEET_URL, MACHINE_HOST, USER_AGENT, VERSION } from './config.mjs';
|
|
8
8
|
import { listStoredProjects } from './credentials.mjs';
|
|
9
|
-
import { readStoredPubB64 } from './
|
|
10
|
-
import { daemonRunningFor } from './instance.mjs';
|
|
9
|
+
import { readStoredPubB64 } from './boxIdentity.mjs';
|
|
10
|
+
import { daemonRunningFor, drainingFor } from './instance.mjs';
|
|
11
11
|
import { runningViaNpx } from './launchCommand.mjs';
|
|
12
|
-
import { boxesUrlFrom, fetchBoxesFor } from './machines.mjs';
|
|
13
|
-
import {
|
|
12
|
+
import { boxesUrlFrom, classifyMachineResponse, fetchBoxesFor } from './machines.mjs';
|
|
13
|
+
import { daemonLockPid, daemonLogPath, readDaemonState } from './daemonState.mjs';
|
|
14
|
+
import { detectRuntimes } from './runtimeDetection.mjs';
|
|
14
15
|
import { runningCompiledBinary } from './update.mjs';
|
|
16
|
+
import { fleetEndpoint } from './fleetWire.mjs';
|
|
15
17
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
/**
|
|
19
|
+
* HOW OLD A BUSY MEASUREMENT MAY BE and still be one (2026-09-26, SOLID F034).
|
|
20
|
+
* The daemon writes it every reconcile tick (seconds apart); a reading older
|
|
21
|
+
* than this is from a daemon that stopped ticking, and is unknown.
|
|
22
|
+
*/
|
|
23
|
+
export const BUSY_FRESH_MS = 2 * 60_000;
|
|
21
24
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
writeFileSync(tmp, JSON.stringify(next), { mode: 0o600 });
|
|
38
|
-
renameSync(tmp, path);
|
|
39
|
-
} catch { /* a local readout must never interrupt a daemon */ }
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export function setRuntimeLimit(projectId, runtime, words) {
|
|
43
|
-
if (!projectId || !runtime) return false;
|
|
44
|
-
const state = readState(projectId);
|
|
45
|
-
const limits = { ...(state.limits ?? {}) };
|
|
46
|
-
if (words == null) {
|
|
47
|
-
if (!Object.hasOwn(limits, runtime)) return false;
|
|
48
|
-
delete limits[runtime];
|
|
49
|
-
} else {
|
|
50
|
-
if (limits[runtime] === words) return false;
|
|
51
|
-
limits[runtime] = words;
|
|
52
|
-
}
|
|
53
|
-
writeDaemonState(projectId, { limits });
|
|
54
|
-
emitMachineEvent({ event: words == null ? 'limit-cleared' : 'limit-hit', runtime, ...(words == null ? {} : { message: words }) });
|
|
55
|
-
return true;
|
|
25
|
+
/**
|
|
26
|
+
* IS THE DAEMON RUNNING HERE WORKING — `true`, `false`, or `null` for unknown.
|
|
27
|
+
*
|
|
28
|
+
* Measured by the daemon itself (`machineBusy` in fleet.mjs: turns, plans,
|
|
29
|
+
* merges, ships, deploys, the wiki lane, undelivered settles) and read from
|
|
30
|
+
* its own state file, so it covers work the server's agent list cannot see.
|
|
31
|
+
* Unknown — no live daemon state, an older daemon that never wrote the field,
|
|
32
|
+
* or a stale reading — is NEVER idle: the desktop app installs an update on
|
|
33
|
+
* its own only over a measured `false`.
|
|
34
|
+
*/
|
|
35
|
+
export function busyOf(liveState, now = Date.now()) {
|
|
36
|
+
if (!liveState || typeof liveState.busy !== 'boolean') return null;
|
|
37
|
+
const at = Date.parse(liveState.busyAt ?? '');
|
|
38
|
+
if (!Number.isFinite(at) || now - at > BUSY_FRESH_MS || at - now > BUSY_FRESH_MS) return null;
|
|
39
|
+
return liveState.busy;
|
|
56
40
|
}
|
|
57
41
|
|
|
58
42
|
export function installChannel() {
|
|
59
43
|
return runningCompiledBinary() ? 'binary' : runningViaNpx() ? 'npx' : 'npm-g';
|
|
60
44
|
}
|
|
61
45
|
|
|
62
|
-
export function desktopStatus({ entries = listStoredProjects(), runtimes = detectRuntimes(), runningFor = daemonRunningFor, stateFor =
|
|
46
|
+
export function desktopStatus({ entries = listStoredProjects(), runtimes = detectRuntimes(), runningFor = daemonRunningFor, stateFor = readDaemonState, pidFor = daemonLockPid, drainFor = drainingFor, nowMs = Date.now() } = {}) {
|
|
63
47
|
return {
|
|
64
48
|
schema: 1,
|
|
65
49
|
version: VERSION,
|
|
@@ -74,6 +58,12 @@ export function desktopStatus({ entries = listStoredProjects(), runtimes = detec
|
|
|
74
58
|
dir: entry.repoRoot ?? null,
|
|
75
59
|
running,
|
|
76
60
|
holder: liveState?.holder ?? null,
|
|
61
|
+
// Measured by the daemon; null = unknown, never idle (see `busyOf`).
|
|
62
|
+
busy: busyOf(liveState, nowMs),
|
|
63
|
+
// What a standing-down daemon is finishing (`web → prod`), from its
|
|
64
|
+
// lock's draining mark; null when it is not draining or unknown. A
|
|
65
|
+
// daemon→desktop report field: an older tray ignores it.
|
|
66
|
+
draining: running === true ? drainFor(entry.fleetToken) : null,
|
|
77
67
|
lastPoll: state.lastPoll ?? null,
|
|
78
68
|
logFile: daemonLogPath(entry.projectId),
|
|
79
69
|
runtimes: runtimes.map((rt) => ({
|
|
@@ -124,12 +114,19 @@ function liveAgent(row) {
|
|
|
124
114
|
return { id, name, status, runtime, delivered, total, asks, parked, since };
|
|
125
115
|
}
|
|
126
116
|
|
|
117
|
+
/**
|
|
118
|
+
* The live-agents read, refused in the SAME classes as the boxes read
|
|
119
|
+
* (`classifyMachineResponse`, review 2026-09-26): an app rejection, an edge
|
|
120
|
+
* 401/403 and an older server's 404 are three different facts about a
|
|
121
|
+
* credential, and the tray's log said `HTTP n` for all of them.
|
|
122
|
+
*/
|
|
127
123
|
async function fetchLiveAgentsFor(entry, { url, fetchImpl, signal }) {
|
|
128
124
|
const res = await fetchImpl(url, {
|
|
129
125
|
headers: { Authorization: `Bearer ${entry.fleetToken}`, 'User-Agent': USER_AGENT },
|
|
130
126
|
signal,
|
|
131
127
|
});
|
|
132
|
-
|
|
128
|
+
const refused = await classifyMachineResponse(res);
|
|
129
|
+
if (refused) return refused;
|
|
133
130
|
const body = await res.json();
|
|
134
131
|
const data = body?.data ?? body;
|
|
135
132
|
if (!data || !Array.isArray(data.agents)) return { error: 'unexpected answer shape' };
|
|
@@ -141,13 +138,17 @@ async function fetchLiveAgentsFor(entry, { url, fetchImpl, signal }) {
|
|
|
141
138
|
};
|
|
142
139
|
}
|
|
143
140
|
|
|
141
|
+
/** One log phrase for a classified refusal, for both remote reads. */
|
|
142
|
+
const refusalWords = (r) =>
|
|
143
|
+
r.error ?? (r.unsupported ? 'HTTP 404' : r.rejected ? 'credential rejected' : 'unexpected answer shape');
|
|
144
|
+
|
|
144
145
|
export async function desktopStatusRemote({ entries = listStoredProjects(), runtimes = detectRuntimes(),
|
|
145
|
-
runningFor = daemonRunningFor, stateFor =
|
|
146
|
+
runningFor = daemonRunningFor, stateFor = readDaemonState, pidFor = daemonLockPid,
|
|
146
147
|
fetchImpl = fetch, envpub = readStoredPubB64(), fleetUrl = FLEET_URL, host = MACHINE_HOST,
|
|
147
148
|
now = () => new Date().toISOString(), timeoutMs = REMOTE_TIMEOUT_MS, log = console.error } = {}) {
|
|
148
149
|
const status = desktopStatus({ entries, runtimes, runningFor, stateFor, pidFor });
|
|
149
150
|
const boxesUrl = boxesUrlFrom(fleetUrl);
|
|
150
|
-
const liveUrl =
|
|
151
|
+
const liveUrl = fleetEndpoint('live-agents', fleetUrl);
|
|
151
152
|
const remotes = await Promise.all(entries.map(async (entry) => {
|
|
152
153
|
if (!entry.fleetToken) return null;
|
|
153
154
|
const [boxes, live] = await Promise.all([
|
|
@@ -156,11 +157,8 @@ export async function desktopStatusRemote({ entries = listStoredProjects(), runt
|
|
|
156
157
|
]);
|
|
157
158
|
const boxesOk = Array.isArray(boxes.boxes);
|
|
158
159
|
const liveOk = Array.isArray(live.agents);
|
|
159
|
-
if (!boxesOk) {
|
|
160
|
-
|
|
161
|
-
log(`status --remote ${entry.projectId} boxes: ${reason}`);
|
|
162
|
-
}
|
|
163
|
-
if (!liveOk) log(`status --remote ${entry.projectId} live-agents: ${live.error ?? 'unexpected answer shape'}`);
|
|
160
|
+
if (!boxesOk) log(`status --remote ${entry.projectId} boxes: ${refusalWords(boxes)}`);
|
|
161
|
+
if (!liveOk) log(`status --remote ${entry.projectId} live-agents: ${refusalWords(live)}`);
|
|
164
162
|
if (!boxesOk && !liveOk) return null;
|
|
165
163
|
const mine = boxesOk ? boxes.boxes.find((box) => box?.boxId === boxes.me) : null;
|
|
166
164
|
const serving = boxesOk ? boxes.boxes.find((box) => box?.role === 'serving') : null;
|
|
@@ -178,50 +176,3 @@ export async function desktopStatusRemote({ entries = listStoredProjects(), runt
|
|
|
178
176
|
status.projects.forEach((project, index) => { project.remote = remotes[index]; });
|
|
179
177
|
return status;
|
|
180
178
|
}
|
|
181
|
-
|
|
182
|
-
function lockPid(token) {
|
|
183
|
-
try {
|
|
184
|
-
const hash = createHash('sha256').update(String(token || 'anon')).digest('hex').slice(0, 12);
|
|
185
|
-
return JSON.parse(readFileSync(join(store(), `daemon-${hash}.lock`), 'utf8')).pid;
|
|
186
|
-
} catch { return null; }
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
let events = false;
|
|
190
|
-
let eventWriter = process.stdout.write.bind(process.stdout);
|
|
191
|
-
export function enableMachineEvents(write = eventWriter) { events = true; eventWriter = write; }
|
|
192
|
-
export function emitMachineEvent(value) {
|
|
193
|
-
if (events) eventWriter(`${JSON.stringify(value)}\n`);
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/** All human output stays on its existing stream in terminal mode. In desktop
|
|
197
|
-
* mode stdout is reserved for JSONL and those same lines go to stderr. */
|
|
198
|
-
export function installDaemonLogging(projectId, { jsonEvents = false } = {}) {
|
|
199
|
-
if (!projectId) return;
|
|
200
|
-
if (jsonEvents) {
|
|
201
|
-
enableMachineEvents();
|
|
202
|
-
process.stdout.write = (...args) => process.stderr.write(...args);
|
|
203
|
-
}
|
|
204
|
-
const original = { log: console.log, info: console.info, warn: console.warn, error: console.error };
|
|
205
|
-
const logPath = daemonLogPath(projectId);
|
|
206
|
-
for (const method of Object.keys(original)) {
|
|
207
|
-
console[method] = (...args) => {
|
|
208
|
-
const line = format(...args);
|
|
209
|
-
try {
|
|
210
|
-
mkdirSync(store(), { recursive: true });
|
|
211
|
-
const encoded = Buffer.from(`${line}\n`);
|
|
212
|
-
const tail = Buffer.from('\n[truncated]\n');
|
|
213
|
-
const chunk = encoded.length > MAX_DAEMON_LOG_BYTES
|
|
214
|
-
? Buffer.concat([encoded.subarray(0, MAX_DAEMON_LOG_BYTES - tail.length), tail])
|
|
215
|
-
: encoded;
|
|
216
|
-
let size = 0;
|
|
217
|
-
try { size = statSync(logPath).size; } catch { /* new file */ }
|
|
218
|
-
if (size + chunk.length > MAX_DAEMON_LOG_BYTES) {
|
|
219
|
-
try { renameSync(logPath, `${logPath}.1`); } catch { /* no old log */ }
|
|
220
|
-
}
|
|
221
|
-
appendFileSync(logPath, chunk, { mode: 0o600 });
|
|
222
|
-
} catch { /* logging must never stop service */ }
|
|
223
|
-
if (jsonEvents) process.stderr.write(`${line}\n`);
|
|
224
|
-
else original[method](...args);
|
|
225
|
-
};
|
|
226
|
-
}
|
|
227
|
-
}
|