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
@@ -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
- * ── THE MACHINE'S OWN ENVIRONMENT IS THE SOURCE OF A DEPLOY'S SECRETS
10
- * (2026-09-21), BECAUSE FLOWVIANT NO LONGER HOLDS ANY ──
11
- *
12
- * This lane used to get both halves of its secrets from the end-to-end
13
- * encrypted vault: `deployCreds()` put the deploy-scope credentials in the
14
- * command's environment, and `appSecretsFor('prod')` fed `wrangler secret put`
15
- * on stdin so a prod deploy pushed the project's app secrets to the provider's
16
- * own store. The vault is DELETED — the owner: "no i dont want it. unless its
17
- * needed where i want to show the env of each of the machines (for
18
- * comparison)", and on the recovery passphrase that protected it, "no, its fine
19
- * to repaste from providers".
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, myPubB64 } from './env.mjs';
85
- import { childEnv } from './childEnv.mjs';
86
- import { git, gitNetAsync } from './git.mjs';
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
- const deployUrl = (tail) => FLEET_URL.replace(/\/agents\/?$/, `/${tail}`);
89
-
90
- async function post(tail, body) {
91
- const res = await fetch(deployUrl(tail), {
92
- method: 'POST',
93
- headers: {
94
- Authorization: `Bearer ${FLEET_TOKEN}`,
95
- 'User-Agent': USER_AGENT,
96
- 'Content-Type': 'application/json',
97
- },
98
- signal: AbortSignal.timeout(30_000),
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
- * Read + parse `.flowviant/deploy.json` AS IT IS ON THE BASE BRANCH. Returns []
110
- * if the branch has none.
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
- * A base ref that does not resolve yields NO TARGETS, and says so once. That is
130
- * the withholding direction and it is the right one here — a deploy is
131
- * irreversible and running the wrong file is worse than running nothing.
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
- export function readDeployConfig(repoRoot, baseRef) {
134
- let raw;
135
- if (baseRef) {
136
- try {
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
- /** Report the parsed config to the server (only when it changed). */
163
- let lastConfigJson = null;
164
- export async function reportDeployConfig(repoRoot, baseRef) {
165
- const targets = readDeployConfig(repoRoot, baseRef);
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
- /** Run a shell command ASYNC (never blocks the daemon's event loop — the
234
- * reconcile poll + the deploy heartbeat must keep firing during a long
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.add(job.id);
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() below keeps the event loop free so this fires.
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
- claiming.delete(job.id);
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
+ }