flowviant 0.103.0 → 0.105.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 +130 -0
  5. package/bin/lib/agySessions.mjs +158 -0
  6. package/bin/lib/artifactContracts.mjs +153 -0
  7. package/bin/lib/artifactPolicy.mjs +45 -0
  8. package/bin/lib/artifactPreview.mjs +52 -0
  9. package/bin/lib/artifactReporter.mjs +163 -0
  10. package/bin/lib/artifacts.mjs +49 -228
  11. package/bin/lib/authReject.mjs +16 -4
  12. package/bin/lib/authproxy.mjs +47 -304
  13. package/bin/lib/boxIdentity.mjs +245 -0
  14. package/bin/lib/childEnv.mjs +11 -9
  15. package/bin/lib/claude.mjs +14 -851
  16. package/bin/lib/claudeAuth.mjs +3 -3
  17. package/bin/lib/claudePosture.mjs +370 -0
  18. package/bin/lib/claudeSessions.mjs +411 -0
  19. package/bin/lib/claudeStream.mjs +207 -0
  20. package/bin/lib/cliLimit.mjs +34 -0
  21. package/bin/lib/commandSpecs.mjs +173 -0
  22. package/bin/lib/config.mjs +11 -42
  23. package/bin/lib/credentialDate.mjs +22 -0
  24. package/bin/lib/credentials.mjs +21 -26
  25. package/bin/lib/daemonLogging.mjs +55 -0
  26. package/bin/lib/daemonState.mjs +64 -0
  27. package/bin/lib/deploy.mjs +148 -647
  28. package/bin/lib/deployCheckout.mjs +265 -0
  29. package/bin/lib/deployConfig.mjs +142 -0
  30. package/bin/lib/deployRunner.mjs +311 -0
  31. package/bin/lib/deployWire.mjs +41 -0
  32. package/bin/lib/desktopContract.mjs +56 -105
  33. package/bin/lib/env.mjs +49 -484
  34. package/bin/lib/fleet.mjs +183 -2383
  35. package/bin/lib/fleetJobs.mjs +120 -0
  36. package/bin/lib/fleetPost.mjs +40 -0
  37. package/bin/lib/fleetReports.mjs +392 -0
  38. package/bin/lib/fleetRoster.mjs +470 -0
  39. package/bin/lib/fleetStartup.mjs +196 -0
  40. package/bin/lib/fleetWake.mjs +45 -0
  41. package/bin/lib/fleetWire.mjs +46 -0
  42. package/bin/lib/git.mjs +2 -2
  43. package/bin/lib/gitMergeIdentity.mjs +51 -0
  44. package/bin/lib/grant.mjs +5 -3
  45. package/bin/lib/help.mjs +3 -125
  46. package/bin/lib/holder.mjs +482 -0
  47. package/bin/lib/instance.mjs +124 -46
  48. package/bin/lib/knowledge.mjs +16 -799
  49. package/bin/lib/knowledgeFetch.mjs +38 -0
  50. package/bin/lib/knowledgeFileSync.mjs +112 -0
  51. package/bin/lib/knowledgeFiles.mjs +489 -0
  52. package/bin/lib/knowledgeLibrary.mjs +299 -0
  53. package/bin/lib/landed.mjs +2 -1
  54. package/bin/lib/listeners.mjs +99 -141
  55. package/bin/lib/localSessions.mjs +30 -555
  56. package/bin/lib/login.mjs +3 -2
  57. package/bin/lib/loginPath.mjs +3 -5
  58. package/bin/lib/machineDisconnect.mjs +203 -0
  59. package/bin/lib/machineEnv.mjs +22 -0
  60. package/bin/lib/machineLimits.mjs +61 -0
  61. package/bin/lib/machineListing.mjs +338 -0
  62. package/bin/lib/machines.mjs +78 -455
  63. package/bin/lib/machinesCommand.mjs +214 -0
  64. package/bin/lib/mcp-cli.mjs +2 -1
  65. package/bin/lib/patchRevert.mjs +60 -0
  66. package/bin/lib/prWorkflow.mjs +157 -0
  67. package/bin/lib/preflight.mjs +2 -1
  68. package/bin/lib/pressurePolicy.mjs +104 -0
  69. package/bin/lib/preview.mjs +2 -272
  70. package/bin/lib/previewFramePolicy.mjs +112 -0
  71. package/bin/lib/previewGatePolicy.mjs +236 -0
  72. package/bin/lib/previewRegistry.mjs +235 -0
  73. package/bin/lib/printable.mjs +2 -2
  74. package/bin/lib/procRegistry.mjs +28 -18
  75. package/bin/lib/procScan.mjs +146 -0
  76. package/bin/lib/processes.mjs +29 -37
  77. package/bin/lib/projectToolRuntimes.mjs +138 -0
  78. package/bin/lib/projectTools.mjs +13 -88
  79. package/bin/lib/prompts.mjs +105 -139
  80. package/bin/lib/repoPath.mjs +26 -0
  81. package/bin/lib/resources.mjs +7 -69
  82. package/bin/lib/resumeLost.mjs +107 -0
  83. package/bin/lib/runTurn.mjs +324 -0
  84. package/bin/lib/runtimeAntigravity.mjs +230 -0
  85. package/bin/lib/runtimeCapabilities.mjs +334 -0
  86. package/bin/lib/runtimeClaude.mjs +126 -0
  87. package/bin/lib/runtimeCodex.mjs +217 -0
  88. package/bin/lib/runtimeDetection.mjs +121 -0
  89. package/bin/lib/runtimeEvents.mjs +482 -0
  90. package/bin/lib/runtimeOfferings.mjs +4 -1
  91. package/bin/lib/runtimes.mjs +42 -1402
  92. package/bin/lib/safeFileName.mjs +74 -0
  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 +17 -17
  107. package/bin/lib/wikiRunner.mjs +630 -0
  108. package/bin/lib/work.mjs +188 -3034
  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 +219 -0
  119. package/bin/lib/workAgentTurnReports.mjs +189 -0
  120. package/bin/lib/workAgentTurns.mjs +91 -898
  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,163 @@
1
+ /**
2
+ * THE ARTIFACT REPORTER — the queued POST to `/fleet/artifact` and its bounded
3
+ * retries (2026-09-26, split out of artifacts.mjs — SOLID F051).
4
+ *
5
+ * Network delivery is its own reason to change, apart from what is on the disk
6
+ * (artifacts.mjs) and how a page is screenshotted (artifactPreview.mjs). The
7
+ * reporter imports neither: it is HANDED the three functions it runs —
8
+ * `listChanged(placeDir, before)` (what the turn wrote), `buildUpload(entry,
9
+ * owner, scrub, secretIn)` (one scrubbed body) and `renderPreview(bytes)` (the
10
+ * screenshot) — so the delivery rules below are tested against a fake server
11
+ * and a fake renderer, and a new source of bodies would not edit this file.
12
+ *
13
+ * ── DELIVERY ──
14
+ *
15
+ * A 2xx is delivered. A 4xx (other than 408/429) is TREATED AS DELIVERED — an
16
+ * older SERVER 404s the whole route, and holding bodies it will refuse forever
17
+ * would be the trace relay's wedge wearing a retry's clothes; a refusal of one
18
+ * file (ended session, wrong owner) is equally permanent. A 5xx, 408, 429 or a
19
+ * network error keeps the STORED BODY — the fields and the bytes as they were
20
+ * scrubbed — and retries it on the next report beat, the settle's shape, at
21
+ * most `MAX_TRIES` times; a newer copy of the same file replaces a held one.
22
+ *
23
+ * A PREVIEW THAT FAILS NEVER STOPS THE ARTIFACT: an HTML body goes out with
24
+ * `renderState: 'unavailable'` and no preview, the renderer's explicit answer.
25
+ */
26
+
27
+ import { fleetEndpoint } from './fleetWire.mjs';
28
+
29
+ /** A held upload is tried this many times in all, then dropped with a line. */
30
+ const MAX_TRIES = 4;
31
+ /** Held bodies across every owner — each can be two megabytes. */
32
+ const MAX_PENDING = 40;
33
+
34
+ /**
35
+ * The reporter a work manager holds for its life. `report` is called once per
36
+ * finished turn and never awaited by the turn (a slow uplink must not hold the
37
+ * next turn behind a readout); `retryPending` rides the settle retry beat.
38
+ */
39
+ export function createArtifactReporter({
40
+ fleetUrl,
41
+ token,
42
+ userAgent,
43
+ scrub,
44
+ secretIn,
45
+ fetchImpl,
46
+ log = () => {},
47
+ listChanged,
48
+ buildUpload,
49
+ renderPreview,
50
+ }) {
51
+ const url = fleetEndpoint('artifact', fleetUrl);
52
+ const doFetch = fetchImpl ?? ((...a) => fetch(...a));
53
+ const pending = new Map(); // `${owner}:${name}` -> { body, tries }
54
+
55
+ /** true = settled (delivered or permanently refused); false = hold it. */
56
+ const post = async (body) => {
57
+ const form = new FormData();
58
+ for (const [k, v] of Object.entries(body.fields)) form.set(k, v);
59
+ if (body.bytes) form.set('file', new Blob([body.bytes]), body.fields.name);
60
+ if (body.preview) form.set('preview', new Blob([body.preview], { type: 'image/png' }), 'preview.png');
61
+ try {
62
+ const res = await doFetch(url, {
63
+ method: 'POST',
64
+ headers: { Authorization: `Bearer ${token}`, 'User-Agent': userAgent },
65
+ signal: AbortSignal.timeout(30_000),
66
+ body: form,
67
+ });
68
+ return (
69
+ res.ok || (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429)
70
+ );
71
+ } catch {
72
+ return false;
73
+ }
74
+ };
75
+
76
+ const hold = (key, body, tries) => {
77
+ if (tries >= MAX_TRIES) {
78
+ pending.delete(key);
79
+ log(`artifact ${body.fields.name}: not delivered after ${tries} tries — dropped`);
80
+ return;
81
+ }
82
+ pending.delete(key); // re-insert at the tail, so the cap sheds the oldest
83
+ pending.set(key, { body, tries });
84
+ while (pending.size > MAX_PENDING) {
85
+ const shed = pending.keys().next().value;
86
+ pending.delete(shed);
87
+ if (!chains.has(shed)) genOf.delete(shed);
88
+ }
89
+ };
90
+
91
+ // ONE COPY OF A FILE IS IN FLIGHT AT A TIME, AND ONLY THE NEWEST MAY
92
+ // DECIDE. Each report of a (owner, name) takes a fresh generation, and every
93
+ // post for that key is chained behind the previous one — so a slow retry of
94
+ // an OLD body can neither land on the server after the newer copy (the
95
+ // server upserts by name, so the last write wins) nor re-hold itself over
96
+ // the newer one when it fails. A body superseded before it went out is not
97
+ // sent at all; one superseded while in flight has its answer discarded.
98
+ let generation = 0;
99
+ const genOf = new Map(); // key -> the generation allowed to decide
100
+ const chains = new Map(); // key -> the tail of that key's post chain
101
+
102
+ const send = (key, body, tries, gen) => {
103
+ const run = (chains.get(key) ?? Promise.resolve()).then(async () => {
104
+ if (genOf.get(key) !== gen) return; // superseded before it went out
105
+ const ok = await post(body);
106
+ if (genOf.get(key) !== gen) return; // a newer copy decides for this key
107
+ if (ok) pending.delete(key);
108
+ else hold(key, body, tries + 1);
109
+ });
110
+ const tail = run.catch(() => {});
111
+ chains.set(key, tail);
112
+ tail.then(() => {
113
+ if (chains.get(key) !== tail) return;
114
+ chains.delete(key);
115
+ if (!pending.has(key)) genOf.delete(key); // settled: nothing left to order
116
+ });
117
+ return run;
118
+ };
119
+
120
+ let retrying = false;
121
+ const retryPending = async () => {
122
+ if (retrying || pending.size === 0) return;
123
+ retrying = true;
124
+ try {
125
+ for (const [key, held] of [...pending]) {
126
+ if (pending.get(key) !== held) continue; // superseded meanwhile
127
+ await send(key, held.body, held.tries, genOf.get(key));
128
+ }
129
+ } finally {
130
+ retrying = false;
131
+ }
132
+ };
133
+
134
+ /** Upload what this turn changed in `placeDir`. Resolves to the number of
135
+ * bodies SENT this call (delivered or held), for the tests and the log. */
136
+ const report = async ({ placeDir, before, sessionId, agentId, turnId }) => {
137
+ if (!placeDir || (!sessionId && !agentId)) return 0;
138
+ const changed = listChanged(placeDir, before);
139
+ let n = 0;
140
+ for (const entry of changed) {
141
+ const body = buildUpload(entry, { sessionId, agentId, turnId }, scrub, secretIn);
142
+ if (!body) continue;
143
+ if (body.withheld) {
144
+ log(`artifact ${entry.name}: not uploaded — it contains the value of ${body.withheld} from this machine's environment`);
145
+ continue;
146
+ }
147
+ if (body.bytes && /\.html?$/i.test(entry.name)) {
148
+ const measured = await renderPreview(body.bytes);
149
+ body.fields.renderState = measured.renderState;
150
+ if (measured.preview) body.preview = measured.preview;
151
+ }
152
+ const key = `${sessionId ?? agentId}:${entry.name}`;
153
+ pending.delete(key); // this copy supersedes a held older one
154
+ const gen = ++generation;
155
+ genOf.set(key, gen); // …and one still in flight
156
+ await send(key, body, 0, gen);
157
+ n++;
158
+ }
159
+ return n;
160
+ };
161
+
162
+ return { report, retryPending, pendingCount: () => pending.size };
163
+ }
@@ -28,7 +28,8 @@
28
28
  *
29
29
  * ── THE BOUNDS ──
30
30
  *
31
- * Regular files only, DEPTH ONE, `lstat` and never followed: a symlink an
31
+ * Regular files only, to DEPTH FOUR (relative paths, 0.99.0 — a bundle's
32
+ * sidecars), `lstat` and never followed: a symlink an
32
33
  * agent planted at `.flowviant/artifacts/x.txt -> ~/.ssh/id_ed25519` is
33
34
  * reported as nothing, and the read itself opens with `O_NOFOLLOW` so a swap
34
35
  * between the lstat and the open cannot turn one into a follow. The directory
@@ -51,7 +52,7 @@
51
52
  * any of them. What they cannot be is REWRITTEN: `envScrub` swapping bytes
52
53
  * inside one corrupts it, and a corrupted binary served as the thing the agent
53
54
  * 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,
55
+ * list as exact byte substrings (`secretIn`, uplinkScrub.mjs) before it is uploaded,
55
56
  * and a hit SKIPS the file with a warn line naming it and the variable — never
56
57
  * a redacted copy, never a by-name row the page would draw as a file it merely
57
58
  * could not show. STATED: a value inside a deflated stream is not a substring
@@ -60,73 +61,49 @@
60
61
  * The SERVER computes the sha256 of what it stored; the one sent here is for
61
62
  * the log.
62
63
  *
63
- * ── DELIVERY ──
64
+ * ── DELIVERY LIVES NEXT DOOR (2026-09-26) ──
64
65
  *
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.
66
+ * This module is the DISK half: scan, diff against the snapshot, and prepare
67
+ * one upload body (scrubbed text, withheld binaries). The screenshot of an
68
+ * HTML page is `artifactPreview.mjs` (a browser); the queued POST with its
69
+ * bounded retries is `artifactReporter.mjs` (the network). Three reasons to
70
+ * change, three files — split from one 439-line module (SOLID F051).
72
71
  *
73
72
  * NO VERSION FLOOR: a daemon→server report on a new endpoint. The prompt
74
73
  * paragraph is gated instead, on the roster's `artifactsAccepted` — a server
75
74
  * that cannot show an artifact must not have the CLI told it will.
76
75
  */
77
76
 
78
- import { constants, closeSync, fstatSync, lstatSync, openSync, readdirSync, readSync, mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
77
+ import { constants, closeSync, fstatSync, lstatSync, openSync, readdirSync, readSync } from 'node:fs';
79
78
  import { createHash } from 'node:crypto';
80
79
  import { join } from 'node:path';
81
- import { tmpdir } from 'node:os';
82
- import { pathToFileURL } from 'node:url';
83
- import { captureScreenshot } from './shot.mjs';
80
+ import { cutKeepingExtension } from './safeFileName.mjs';
81
+ import {
82
+ ARTIFACT_BINARY_MODEL_EXTS,
83
+ ARTIFACT_BINARY_MODEL_MAX_BYTES,
84
+ ARTIFACT_MAX_BYTES,
85
+ ARTIFACT_MAX_PER_OWNER,
86
+ ARTIFACT_TYPES,
87
+ } from './artifactPolicy.mjs';
84
88
 
85
89
  /** Relative to the directory the turn stands in. Under `.flowviant/`, which
86
90
  * the exclude file already hides from git (`excludeInWorktree`). */
87
91
  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 },
92
+ /**
93
+ * THE ALLOWLIST AND THE CEILINGS are the server's, from its GENERATED snapshot
94
+ * (`artifactPolicy.mjs`, rendered from packages/shared/src/schemas/
95
+ * artifact.schema.ts — 2026-09-26, SOLID F044). They were hand-repeated here,
96
+ * and a type added on one side only was either reported by name while the
97
+ * server could serve it, or sent as a type the server refuses. Re-checked here
98
+ * all the same: this is somebody's disk, and one place checking is one deploy
99
+ * from zero.
100
+ */
101
+ export const artifactMaxBytesFor = (name) => {
102
+ const ext = String(name).slice(String(name).lastIndexOf('.') + 1).toLowerCase();
103
+ return ARTIFACT_BINARY_MODEL_EXTS.includes(ext) ? ARTIFACT_BINARY_MODEL_MAX_BYTES : ARTIFACT_MAX_BYTES;
129
104
  };
105
+ /** Listed per scan, newest first — the server keeps as many per owner. */
106
+ export const ARTIFACT_MAX_FILES = ARTIFACT_MAX_PER_OWNER;
130
107
 
131
108
  export function artifactTypeFor(name) {
132
109
  const dot = String(name).lastIndexOf('.');
@@ -193,6 +170,12 @@ export function changedArtifacts(before, entries) {
193
170
  return entries.filter((e) => before?.get(e.name) !== sigOf(e));
194
171
  }
195
172
 
173
+ /** What the turn wrote in `placeDir` since `before` — the scanner the reporter
174
+ * is handed (`artifactReporter.mjs`), as one function. */
175
+ export function changedSince(placeDir, before) {
176
+ return changedArtifacts(before, scanArtifacts(placeDir));
177
+ }
178
+
196
179
  /** Read a regular file WITHOUT following a symlink planted after the lstat. */
197
180
  function readNoFollow(path, limit) {
198
181
  const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
@@ -212,38 +195,23 @@ function readNoFollow(path, limit) {
212
195
  }
213
196
  }
214
197
 
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
198
  /**
227
- * `safeFileName`, verbatim in effect (apps/api/src/routes/sessionsAttachments.
228
- * routes.ts): keeps the extension through a cut. Applied to each relative
199
+ * `safeFileName`'s CUT (bin/lib/safeFileName.mjs — `cutKeepingExtension`, the
200
+ * server's rule): keeps the extension through a cut. Applied to each relative
229
201
  * 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.
202
+ * The context rule around the cut is this lane's own: a component that is
203
+ * already safe — spaces included — passes through untouched, because a bundle
204
+ * path names files the page references by exactly that spelling. The cut only
205
+ * differs from the component on an over-long name — but that is exactly the
206
+ * case the server's own `safeFileName(rawName)` would otherwise re-cut
207
+ * DIFFERENTLY from whatever this reported, so applying the same cut here means
208
+ * the server's re-application is a no-op and the name a design or research
209
+ * delivery is judged by is the one this machine reported.
236
210
  */
237
211
  function safeArtifactName(raw) {
238
212
  if (typeof raw === 'string' && raw.length <= 80 && /^[A-Za-z0-9][A-Za-z0-9._ -]*$/.test(raw) && !raw.endsWith('.')) return raw;
239
213
  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}` : ''}`;
214
+ return clean ? cutKeepingExtension(clean) : 'artifact';
247
215
  }
248
216
 
249
217
  /**
@@ -290,150 +258,3 @@ export function buildArtifactUpload(entry, { sessionId, agentId, turnId }, scrub
290
258
  bytes,
291
259
  };
292
260
  }
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
  }