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.
Files changed (141) hide show
  1. package/bin/cli.mjs +87 -474
  2. package/bin/lib/admission.mjs +19 -3
  3. package/bin/lib/agentPlan.mjs +2 -2
  4. package/bin/lib/agentTaskKinds.mjs +93 -0
  5. package/bin/lib/agySessions.mjs +158 -0
  6. package/bin/lib/artifactPolicy.mjs +44 -0
  7. package/bin/lib/artifactPreview.mjs +52 -0
  8. package/bin/lib/artifactReporter.mjs +163 -0
  9. package/bin/lib/artifacts.mjs +47 -227
  10. package/bin/lib/authReject.mjs +16 -4
  11. package/bin/lib/authproxy.mjs +47 -304
  12. package/bin/lib/boxIdentity.mjs +245 -0
  13. package/bin/lib/childEnv.mjs +11 -9
  14. package/bin/lib/claude.mjs +14 -851
  15. package/bin/lib/claudeAuth.mjs +3 -3
  16. package/bin/lib/claudePosture.mjs +367 -0
  17. package/bin/lib/claudeSessions.mjs +411 -0
  18. package/bin/lib/claudeStream.mjs +207 -0
  19. package/bin/lib/cliLimit.mjs +34 -0
  20. package/bin/lib/commandSpecs.mjs +173 -0
  21. package/bin/lib/config.mjs +11 -42
  22. package/bin/lib/credentialDate.mjs +22 -0
  23. package/bin/lib/credentials.mjs +21 -26
  24. package/bin/lib/daemonLogging.mjs +55 -0
  25. package/bin/lib/daemonState.mjs +64 -0
  26. package/bin/lib/deploy.mjs +148 -647
  27. package/bin/lib/deployCheckout.mjs +265 -0
  28. package/bin/lib/deployConfig.mjs +142 -0
  29. package/bin/lib/deployRunner.mjs +311 -0
  30. package/bin/lib/deployWire.mjs +41 -0
  31. package/bin/lib/desktopContract.mjs +56 -105
  32. package/bin/lib/env.mjs +49 -484
  33. package/bin/lib/fleet.mjs +184 -2396
  34. package/bin/lib/fleetJobs.mjs +120 -0
  35. package/bin/lib/fleetPost.mjs +40 -0
  36. package/bin/lib/fleetReports.mjs +392 -0
  37. package/bin/lib/fleetRoster.mjs +470 -0
  38. package/bin/lib/fleetStartup.mjs +196 -0
  39. package/bin/lib/fleetWake.mjs +45 -0
  40. package/bin/lib/fleetWire.mjs +46 -0
  41. package/bin/lib/git.mjs +34 -3
  42. package/bin/lib/gitMergeIdentity.mjs +51 -0
  43. package/bin/lib/grant.mjs +5 -3
  44. package/bin/lib/help.mjs +3 -125
  45. package/bin/lib/holder.mjs +482 -0
  46. package/bin/lib/instance.mjs +124 -46
  47. package/bin/lib/knowledge.mjs +16 -799
  48. package/bin/lib/knowledgeFetch.mjs +38 -0
  49. package/bin/lib/knowledgeFileSync.mjs +112 -0
  50. package/bin/lib/knowledgeFiles.mjs +487 -0
  51. package/bin/lib/knowledgeLibrary.mjs +249 -0
  52. package/bin/lib/landed.mjs +2 -1
  53. package/bin/lib/listeners.mjs +99 -141
  54. package/bin/lib/localSessions.mjs +30 -555
  55. package/bin/lib/login.mjs +3 -2
  56. package/bin/lib/loginPath.mjs +3 -5
  57. package/bin/lib/machineDisconnect.mjs +203 -0
  58. package/bin/lib/machineEnv.mjs +22 -0
  59. package/bin/lib/machineLimits.mjs +61 -0
  60. package/bin/lib/machineListing.mjs +338 -0
  61. package/bin/lib/machines.mjs +78 -455
  62. package/bin/lib/machinesCommand.mjs +214 -0
  63. package/bin/lib/mcp-cli.mjs +2 -1
  64. package/bin/lib/patchRevert.mjs +60 -0
  65. package/bin/lib/prWorkflow.mjs +157 -0
  66. package/bin/lib/preflight.mjs +2 -1
  67. package/bin/lib/pressurePolicy.mjs +104 -0
  68. package/bin/lib/preview.mjs +2 -272
  69. package/bin/lib/previewFramePolicy.mjs +112 -0
  70. package/bin/lib/previewGatePolicy.mjs +236 -0
  71. package/bin/lib/previewRegistry.mjs +235 -0
  72. package/bin/lib/printable.mjs +2 -2
  73. package/bin/lib/procRegistry.mjs +28 -18
  74. package/bin/lib/procScan.mjs +146 -0
  75. package/bin/lib/processes.mjs +29 -37
  76. package/bin/lib/projectToolRuntimes.mjs +138 -0
  77. package/bin/lib/projectTools.mjs +13 -88
  78. package/bin/lib/prompts.mjs +28 -42
  79. package/bin/lib/repoPath.mjs +26 -0
  80. package/bin/lib/resources.mjs +7 -69
  81. package/bin/lib/resumeLost.mjs +107 -0
  82. package/bin/lib/runTurn.mjs +324 -0
  83. package/bin/lib/runtimeAntigravity.mjs +230 -0
  84. package/bin/lib/runtimeCapabilities.mjs +334 -0
  85. package/bin/lib/runtimeClaude.mjs +126 -0
  86. package/bin/lib/runtimeCodex.mjs +217 -0
  87. package/bin/lib/runtimeDetection.mjs +121 -0
  88. package/bin/lib/runtimeEvents.mjs +482 -0
  89. package/bin/lib/runtimeOfferings.mjs +4 -1
  90. package/bin/lib/runtimes.mjs +42 -1402
  91. package/bin/lib/safeFileName.mjs +74 -0
  92. package/bin/lib/shipMerge.mjs +33 -2
  93. package/bin/lib/standDownExit.mjs +181 -0
  94. package/bin/lib/standDownGate.mjs +48 -0
  95. package/bin/lib/startProject.mjs +279 -0
  96. package/bin/lib/trace.mjs +3 -3
  97. package/bin/lib/turnLock.mjs +68 -0
  98. package/bin/lib/turnProfile.mjs +77 -0
  99. package/bin/lib/uninstall.mjs +55 -45
  100. package/bin/lib/uninstallCopies.mjs +112 -0
  101. package/bin/lib/update.mjs +30 -17
  102. package/bin/lib/uplinkScrub.mjs +265 -0
  103. package/bin/lib/vault.mjs +22 -151
  104. package/bin/lib/vaultDiff.mjs +183 -0
  105. package/bin/lib/versionOrder.mjs +42 -0
  106. package/bin/lib/views.mjs +35 -12
  107. package/bin/lib/wikiRunner.mjs +630 -0
  108. package/bin/lib/work.mjs +188 -3037
  109. package/bin/lib/workAdoptCarry.mjs +94 -0
  110. package/bin/lib/workAgentCheck.mjs +278 -0
  111. package/bin/lib/workAgentMerges.mjs +54 -142
  112. package/bin/lib/workAgentPlans.mjs +14 -12
  113. package/bin/lib/workAgentPrecheck.mjs +393 -0
  114. package/bin/lib/workAgentPublish.mjs +177 -0
  115. package/bin/lib/workAgentReview.mjs +8 -666
  116. package/bin/lib/workAgentTurnBegun.mjs +109 -0
  117. package/bin/lib/workAgentTurnExecution.mjs +604 -0
  118. package/bin/lib/workAgentTurnOutcome.mjs +207 -0
  119. package/bin/lib/workAgentTurnReports.mjs +189 -0
  120. package/bin/lib/workAgentTurns.mjs +92 -897
  121. package/bin/lib/workAttachments.mjs +93 -0
  122. package/bin/lib/workBrain.mjs +53 -0
  123. package/bin/lib/workChildren.mjs +110 -0
  124. package/bin/lib/workCommandAudit.mjs +62 -0
  125. package/bin/lib/workDiffs.mjs +2 -1
  126. package/bin/lib/workNarration.mjs +152 -0
  127. package/bin/lib/workPlaceReports.mjs +426 -0
  128. package/bin/lib/workPlaces.mjs +228 -0
  129. package/bin/lib/workPreviews.mjs +31 -9
  130. package/bin/lib/workProcesses.mjs +4 -5
  131. package/bin/lib/workPullRequests.mjs +52 -126
  132. package/bin/lib/workReportQueue.mjs +254 -0
  133. package/bin/lib/workRetire.mjs +185 -0
  134. package/bin/lib/workSessionGroups.mjs +166 -0
  135. package/bin/lib/workSessionRuntime.mjs +164 -0
  136. package/bin/lib/workSessionTokens.mjs +68 -0
  137. package/bin/lib/workSessionTurns.mjs +892 -0
  138. package/bin/lib/workShip.mjs +9 -29
  139. package/bin/lib/workToolLog.mjs +76 -0
  140. package/package.json +3 -2
  141. 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
- /** Local, read-only desktop contract and bounded daemon logs. */
2
- import { createHash } from 'node:crypto';
3
- import { appendFileSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
4
- import { homedir } from 'node:os';
5
- import { join } from 'node:path';
6
- import { format } from 'node:util';
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 './env.mjs';
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 { detectRuntimes } from './runtimes.mjs';
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
- export const MAX_DAEMON_LOG_BYTES = 2 * 1024 * 1024;
17
- const store = () => join(homedir(), '.flowviant');
18
- const key = (projectId) => createHash('sha256').update(String(projectId)).digest('hex').slice(0, 16);
19
- export const daemonLogPath = (projectId) => join(store(), `daemon-${key(projectId)}.log`);
20
- export const daemonStatePath = (projectId) => join(store(), `daemon-${key(projectId)}.state.json`);
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
- function readState(projectId) {
23
- try {
24
- const value = JSON.parse(readFileSync(daemonStatePath(projectId), 'utf8'));
25
- return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
26
- } catch { return {}; }
27
- }
28
-
29
- export function writeDaemonState(projectId, change) {
30
- if (!projectId) return;
31
- try {
32
- mkdirSync(store(), { recursive: true });
33
- const path = daemonStatePath(projectId);
34
- const current = readState(projectId);
35
- const next = { ...current, ...change, pid: process.pid };
36
- const tmp = `${path}.${process.pid}.tmp`;
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 = readState, pidFor = lockPid } = {}) {
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
- if (!res.ok) return { error: `HTTP ${res.status}` };
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 = readState, pidFor = lockPid,
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 = String(fleetUrl).replace(/\/agents\/?$/, '/live-agents');
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
- const reason = boxes.error ?? (boxes.unsupported ? 'HTTP 404' : boxes.rejected ? 'credential rejected' : 'unexpected answer shape');
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
- }