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
@@ -51,7 +51,7 @@
51
51
  * any of them. What they cannot be is REWRITTEN: `envScrub` swapping bytes
52
52
  * inside one corrupts it, and a corrupted binary served as the thing the agent
53
53
  * made is worse than none. So each binary is checked against the same scrub
54
- * list as exact byte substrings (`secretIn`, env.mjs) before it is uploaded,
54
+ * list as exact byte substrings (`secretIn`, uplinkScrub.mjs) before it is uploaded,
55
55
  * and a hit SKIPS the file with a warn line naming it and the variable — never
56
56
  * a redacted copy, never a by-name row the page would draw as a file it merely
57
57
  * could not show. STATED: a value inside a deflated stream is not a substring
@@ -60,73 +60,49 @@
60
60
  * The SERVER computes the sha256 of what it stored; the one sent here is for
61
61
  * the log.
62
62
  *
63
- * ── DELIVERY ──
63
+ * ── DELIVERY LIVES NEXT DOOR (2026-09-26) ──
64
64
  *
65
- * A 2xx is delivered. A 4xx (other than 408/429) is TREATED AS DELIVERED — an
66
- * older SERVER 404s the whole route, and holding bodies it will refuse forever
67
- * would be the trace relay's wedge wearing a retry's clothes; a refusal of one
68
- * file (ended session, wrong owner) is equally permanent. A 5xx, 408, 429 or a
69
- * network error keeps the STORED BODY — the fields and the bytes as they were
70
- * scrubbed — and retries it on the next report beat, the settle's shape, at
71
- * most `MAX_TRIES` times; a newer copy of the same file replaces a held one.
65
+ * This module is the DISK half: scan, diff against the snapshot, and prepare
66
+ * one upload body (scrubbed text, withheld binaries). The screenshot of an
67
+ * HTML page is `artifactPreview.mjs` (a browser); the queued POST with its
68
+ * bounded retries is `artifactReporter.mjs` (the network). Three reasons to
69
+ * change, three files — split from one 439-line module (SOLID F051).
72
70
  *
73
71
  * NO VERSION FLOOR: a daemon→server report on a new endpoint. The prompt
74
72
  * paragraph is gated instead, on the roster's `artifactsAccepted` — a server
75
73
  * that cannot show an artifact must not have the CLI told it will.
76
74
  */
77
75
 
78
- import { constants, closeSync, fstatSync, lstatSync, openSync, readdirSync, readSync, mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
76
+ import { constants, closeSync, fstatSync, lstatSync, openSync, readdirSync, readSync } from 'node:fs';
79
77
  import { createHash } from 'node:crypto';
80
78
  import { join } from 'node:path';
81
- import { tmpdir } from 'node:os';
82
- import { pathToFileURL } from 'node:url';
83
- import { captureScreenshot } from './shot.mjs';
79
+ import { cutKeepingExtension } from './safeFileName.mjs';
80
+ import {
81
+ ARTIFACT_BINARY_MODEL_EXTS,
82
+ ARTIFACT_BINARY_MODEL_MAX_BYTES,
83
+ ARTIFACT_MAX_BYTES,
84
+ ARTIFACT_MAX_PER_OWNER,
85
+ ARTIFACT_TYPES,
86
+ } from './artifactPolicy.mjs';
84
87
 
85
88
  /** Relative to the directory the turn stands in. Under `.flowviant/`, which
86
89
  * the exclude file already hides from git (`excludeInWorktree`). */
87
90
  export const ARTIFACT_DIR = '.flowviant/artifacts';
88
- /** The server's per-file ceiling, re-checked here. */
89
- export const ARTIFACT_MAX_BYTES = 2 * 1024 * 1024;
90
- export const ARTIFACT_BINARY_MODEL_MAX_BYTES = 20 * 1024 * 1024;
91
- export const DESIGN_PREVIEW_MAX_BYTES = 2 * 1024 * 1024;
92
- const PREVIEW_CSP = "default-src 'none'; script-src 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net/npm/ https://unpkg.com; style-src 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net/npm/ https://unpkg.com https://fonts.googleapis.com; img-src data: blob:; font-src data: https://fonts.gstatic.com; connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'";
93
- export const artifactMaxBytesFor = (name) => /\.(?:glb|bin)$/i.test(name)
94
- ? ARTIFACT_BINARY_MODEL_MAX_BYTES : ARTIFACT_MAX_BYTES;
95
- /** Listed per scan, newest first — the server keeps as many per owner. */
96
- export const ARTIFACT_MAX_FILES = 40;
97
- /** A held upload is tried this many times in all, then dropped with a line. */
98
- const MAX_TRIES = 4;
99
- /** Held bodies across every owner — each can be two megabytes. */
100
- const MAX_PENDING = 40;
101
-
102
- /** The allowlist, by extension. The server's `ARTIFACT_TYPES`, repeated. */
103
- export const ARTIFACT_TYPES = {
104
- html: { mime: 'text/html', text: true },
105
- htm: { mime: 'text/html', text: true },
106
- md: { mime: 'text/markdown', text: true },
107
- svg: { mime: 'image/svg+xml', text: true },
108
- png: { mime: 'image/png', text: false },
109
- jpg: { mime: 'image/jpeg', text: false },
110
- jpeg: { mime: 'image/jpeg', text: false },
111
- gif: { mime: 'image/gif', text: false },
112
- webp: { mime: 'image/webp', text: false },
113
- json: { mime: 'application/json', text: true },
114
- csv: { mime: 'text/csv', text: true },
115
- txt: { mime: 'text/plain', text: true },
116
- // DOCUMENTS (0.97.0): a Word file, a deck, a sheet or a PDF — what the
117
- // machine's docx/pptx/xlsx/pdf skills write. Binary, so never REWRITTEN (a
118
- // scrub inside a zip or a PDF corrupts it) but checked for a known secret's
119
- // bytes and withheld on a hit — see the header. Under the same cap. The app
120
- // offers the first three as a download and frames the PDF.
121
- docx: { mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', text: false },
122
- pptx: { mime: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', text: false },
123
- xlsx: { mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', text: false },
124
- pdf: { mime: 'application/pdf', text: false },
125
- gltf: { mime: 'model/gltf+json', text: true },
126
- obj: { mime: 'model/obj', text: true },
127
- glb: { mime: 'model/gltf-binary', text: false },
128
- bin: { mime: 'application/octet-stream', text: false },
91
+ /**
92
+ * THE ALLOWLIST AND THE CEILINGS are the server's, from its GENERATED snapshot
93
+ * (`artifactPolicy.mjs`, rendered from packages/shared/src/schemas/
94
+ * artifact.schema.ts — 2026-09-26, SOLID F044). They were hand-repeated here,
95
+ * and a type added on one side only was either reported by name while the
96
+ * server could serve it, or sent as a type the server refuses. Re-checked here
97
+ * all the same: this is somebody's disk, and one place checking is one deploy
98
+ * from zero.
99
+ */
100
+ export const artifactMaxBytesFor = (name) => {
101
+ const ext = String(name).slice(String(name).lastIndexOf('.') + 1).toLowerCase();
102
+ return ARTIFACT_BINARY_MODEL_EXTS.includes(ext) ? ARTIFACT_BINARY_MODEL_MAX_BYTES : ARTIFACT_MAX_BYTES;
129
103
  };
104
+ /** Listed per scan, newest first — the server keeps as many per owner. */
105
+ export const ARTIFACT_MAX_FILES = ARTIFACT_MAX_PER_OWNER;
130
106
 
131
107
  export function artifactTypeFor(name) {
132
108
  const dot = String(name).lastIndexOf('.');
@@ -193,6 +169,12 @@ export function changedArtifacts(before, entries) {
193
169
  return entries.filter((e) => before?.get(e.name) !== sigOf(e));
194
170
  }
195
171
 
172
+ /** What the turn wrote in `placeDir` since `before` — the scanner the reporter
173
+ * is handed (`artifactReporter.mjs`), as one function. */
174
+ export function changedSince(placeDir, before) {
175
+ return changedArtifacts(before, scanArtifacts(placeDir));
176
+ }
177
+
196
178
  /** Read a regular file WITHOUT following a symlink planted after the lstat. */
197
179
  function readNoFollow(path, limit) {
198
180
  const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
@@ -212,38 +194,23 @@ function readNoFollow(path, limit) {
212
194
  }
213
195
  }
214
196
 
215
- /** FNV-1a, 32-bit, as 8 hex — verbatim against the server's `fnv1a8`
216
- * (apps/api/src/routes/sessionsAttachments.routes.ts). */
217
- function artifactFnv1a8(s) {
218
- let h = 0x811c9dc5;
219
- for (let i = 0; i < s.length; i++) {
220
- h ^= s.charCodeAt(i);
221
- h = Math.imul(h, 0x01000193);
222
- }
223
- return (h >>> 0).toString(16).padStart(8, '0');
224
- }
225
- const ARTIFACT_SAFE_EXT_RE = /^[A-Za-z0-9]{1,10}$/;
226
197
  /**
227
- * `safeFileName`, verbatim in effect (apps/api/src/routes/sessionsAttachments.
228
- * routes.ts): keeps the extension through a cut. Applied to each relative
198
+ * `safeFileName`'s CUT (bin/lib/safeFileName.mjs — `cutKeepingExtension`, the
199
+ * server's rule): keeps the extension through a cut. Applied to each relative
229
200
  * path component, so a model under `scene/` keeps that relation to its page.
230
- * This only differs from a component on an over-long name — but that is exactly the case the
231
- * server's own `safeFileName(rawName)` would otherwise re-cut DIFFERENTLY
232
- * from whatever this reported, since a bare truncation and an
233
- * extension-preserving one disagree past 80 characters. Applying the same
234
- * rule here means the server's re-application is a no-op and the name a
235
- * design or research delivery is judged by is the one this machine reported.
201
+ * The context rule around the cut is this lane's own: a component that is
202
+ * already safe — spaces included — passes through untouched, because a bundle
203
+ * path names files the page references by exactly that spelling. The cut only
204
+ * differs from the component on an over-long name — but that is exactly the
205
+ * case the server's own `safeFileName(rawName)` would otherwise re-cut
206
+ * DIFFERENTLY from whatever this reported, so applying the same cut here means
207
+ * the server's re-application is a no-op and the name a design or research
208
+ * delivery is judged by is the one this machine reported.
236
209
  */
237
210
  function safeArtifactName(raw) {
238
211
  if (typeof raw === 'string' && raw.length <= 80 && /^[A-Za-z0-9][A-Za-z0-9._ -]*$/.test(raw) && !raw.endsWith('.')) return raw;
239
212
  const clean = String(raw ?? '').replace(/[^A-Za-z0-9._-]/g, '_');
240
- if (clean.length <= 80) return clean || 'artifact';
241
- const dot = clean.lastIndexOf('.');
242
- const ext = dot > 0 && ARTIFACT_SAFE_EXT_RE.test(clean.slice(dot + 1)) ? clean.slice(dot + 1) : '';
243
- const stem = ext ? clean.slice(0, dot) : clean;
244
- const tag = `-${artifactFnv1a8(clean)}`;
245
- const room = 80 - tag.length - (ext ? ext.length + 1 : 0);
246
- return `${stem.slice(0, room)}${tag}${ext ? `.${ext}` : ''}`;
213
+ return clean ? cutKeepingExtension(clean) : 'artifact';
247
214
  }
248
215
 
249
216
  /**
@@ -290,150 +257,3 @@ export function buildArtifactUpload(entry, { sessionId, agentId, turnId }, scrub
290
257
  bytes,
291
258
  };
292
259
  }
293
-
294
- /** Render the scrubbed HTML, never the checkout's unsanitized source. A
295
- * failed browser measurement is explicit; older daemon reports are absent. */
296
- export async function renderDesignPreview(bytes, capture = captureScreenshot) {
297
- const dir = mkdtempSync(join(tmpdir(), 'flowviant-design-'));
298
- try {
299
- const html = join(dir, 'design.html');
300
- const png = join(dir, 'design.png');
301
- // A fresh Chrome profile has no Flowviant credentials. Put the artifact
302
- // box's network fence before any model-written markup executes.
303
- writeFileSync(html, Buffer.concat([
304
- Buffer.from(`<meta http-equiv="Content-Security-Policy" content="${PREVIEW_CSP}">`),
305
- bytes,
306
- ]));
307
- const result = await capture({ url: pathToFileURL(html).href, out: png, width: 720, height: 450, timeoutMs: 15_000 });
308
- if (!result.ok) return { renderState: 'unavailable' };
309
- const image = readFileSync(png);
310
- if (image.byteLength > DESIGN_PREVIEW_MAX_BYTES || !image.subarray(0, 8).equals(Buffer.from([137,80,78,71,13,10,26,10]))) {
311
- return { renderState: 'unavailable' };
312
- }
313
- return { renderState: 'rendered', preview: image };
314
- } catch {
315
- return { renderState: 'unavailable' };
316
- } finally {
317
- rmSync(dir, { recursive: true, force: true });
318
- }
319
- }
320
-
321
- /**
322
- * The reporter a work manager holds for its life. `report` is called once per
323
- * finished turn and never awaited by the turn (a slow uplink must not hold the
324
- * next turn behind a readout); `retryPending` rides the settle retry beat.
325
- */
326
- export function createArtifactReporter({ fleetUrl, token, userAgent, scrub, secretIn, fetchImpl, log = () => {} }) {
327
- const url = String(fleetUrl).replace(/\/agents\/?$/, '/artifact');
328
- const doFetch = fetchImpl ?? ((...a) => fetch(...a));
329
- const pending = new Map(); // `${owner}:${name}` -> { body, tries }
330
-
331
- /** true = settled (delivered or permanently refused); false = hold it. */
332
- const post = async (body) => {
333
- const form = new FormData();
334
- for (const [k, v] of Object.entries(body.fields)) form.set(k, v);
335
- if (body.bytes) form.set('file', new Blob([body.bytes]), body.fields.name);
336
- if (body.preview) form.set('preview', new Blob([body.preview], { type: 'image/png' }), 'preview.png');
337
- try {
338
- const res = await doFetch(url, {
339
- method: 'POST',
340
- headers: { Authorization: `Bearer ${token}`, 'User-Agent': userAgent },
341
- signal: AbortSignal.timeout(30_000),
342
- body: form,
343
- });
344
- return (
345
- res.ok || (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429)
346
- );
347
- } catch {
348
- return false;
349
- }
350
- };
351
-
352
- const hold = (key, body, tries) => {
353
- if (tries >= MAX_TRIES) {
354
- pending.delete(key);
355
- log(`artifact ${body.fields.name}: not delivered after ${tries} tries — dropped`);
356
- return;
357
- }
358
- pending.delete(key); // re-insert at the tail, so the cap sheds the oldest
359
- pending.set(key, { body, tries });
360
- while (pending.size > MAX_PENDING) {
361
- const shed = pending.keys().next().value;
362
- pending.delete(shed);
363
- if (!chains.has(shed)) genOf.delete(shed);
364
- }
365
- };
366
-
367
- // ONE COPY OF A FILE IS IN FLIGHT AT A TIME, AND ONLY THE NEWEST MAY
368
- // DECIDE. Each report of a (owner, name) takes a fresh generation, and every
369
- // post for that key is chained behind the previous one — so a slow retry of
370
- // an OLD body can neither land on the server after the newer copy (the
371
- // server upserts by name, so the last write wins) nor re-hold itself over
372
- // the newer one when it fails. A body superseded before it went out is not
373
- // sent at all; one superseded while in flight has its answer discarded.
374
- let generation = 0;
375
- const genOf = new Map(); // key -> the generation allowed to decide
376
- const chains = new Map(); // key -> the tail of that key's post chain
377
-
378
- const send = (key, body, tries, gen) => {
379
- const run = (chains.get(key) ?? Promise.resolve()).then(async () => {
380
- if (genOf.get(key) !== gen) return; // superseded before it went out
381
- const ok = await post(body);
382
- if (genOf.get(key) !== gen) return; // a newer copy decides for this key
383
- if (ok) pending.delete(key);
384
- else hold(key, body, tries + 1);
385
- });
386
- const tail = run.catch(() => {});
387
- chains.set(key, tail);
388
- tail.then(() => {
389
- if (chains.get(key) !== tail) return;
390
- chains.delete(key);
391
- if (!pending.has(key)) genOf.delete(key); // settled: nothing left to order
392
- });
393
- return run;
394
- };
395
-
396
- let retrying = false;
397
- const retryPending = async () => {
398
- if (retrying || pending.size === 0) return;
399
- retrying = true;
400
- try {
401
- for (const [key, held] of [...pending]) {
402
- if (pending.get(key) !== held) continue; // superseded meanwhile
403
- await send(key, held.body, held.tries, genOf.get(key));
404
- }
405
- } finally {
406
- retrying = false;
407
- }
408
- };
409
-
410
- /** Upload what this turn changed in `placeDir`. Resolves to the number of
411
- * bodies SENT this call (delivered or held), for the tests and the log. */
412
- const report = async ({ placeDir, before, sessionId, agentId, turnId }) => {
413
- if (!placeDir || (!sessionId && !agentId)) return 0;
414
- const changed = changedArtifacts(before, scanArtifacts(placeDir));
415
- let n = 0;
416
- for (const entry of changed) {
417
- const body = buildArtifactUpload(entry, { sessionId, agentId, turnId }, scrub, secretIn);
418
- if (!body) continue;
419
- if (body.withheld) {
420
- log(`artifact ${entry.name}: not uploaded — it contains the value of ${body.withheld} from this machine's environment`);
421
- continue;
422
- }
423
- if (body.bytes && /\.html?$/i.test(entry.name)) {
424
- const measured = await renderDesignPreview(body.bytes);
425
- body.fields.renderState = measured.renderState;
426
- if (measured.preview) body.preview = measured.preview;
427
- }
428
- const key = `${sessionId ?? agentId}:${entry.name}`;
429
- pending.delete(key); // this copy supersedes a held older one
430
- const gen = ++generation;
431
- genOf.set(key, gen); // …and one still in flight
432
- await send(key, body, 0, gen);
433
- n++;
434
- }
435
- return n;
436
- };
437
-
438
- return { report, retryPending, pendingCount: () => pending.size };
439
- }
@@ -21,12 +21,24 @@
21
21
  */
22
22
  export async function credentialRejected(res) {
23
23
  if (res?.status !== 401 && res?.status !== 403) return false;
24
- const type = String(res.headers?.get?.('content-type') ?? '');
25
- if (!/\bapplication\/json\b/i.test(type)) return false;
24
+ let body;
26
25
  try {
27
- const body = await res.json();
28
- return Boolean(body) && body.success === false;
26
+ body = await res.json();
29
27
  } catch {
30
28
  return false; // not the API's envelope — an edge page mislabelled as JSON
31
29
  }
30
+ return isCredentialRejection(res.status, res.headers?.get?.('content-type'), body);
31
+ }
32
+
33
+ /**
34
+ * THE SAME TEST, ON A BODY ALREADY READ — for a caller that parsed the answer
35
+ * before it knew it was a refusal (deployWire.mjs reads every `/fleet/deploy-*`
36
+ * answer once). The rule is this one: a 401/403, labelled JSON, wearing the
37
+ * API's `{ success: false }` envelope. A body that did not parse is passed as
38
+ * anything but that envelope and answers false.
39
+ */
40
+ export function isCredentialRejection(status, contentType, body) {
41
+ if (status !== 401 && status !== 403) return false;
42
+ if (!/\bapplication\/json\b/i.test(String(contentType ?? ''))) return false;
43
+ return Boolean(body) && typeof body === 'object' && body.success === false;
32
44
  }