flowviant 0.97.0 → 0.99.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 (45) hide show
  1. package/bin/cli.mjs +13 -3
  2. package/bin/lib/agentPlan.mjs +159 -37
  3. package/bin/lib/artifacts.mjs +136 -26
  4. package/bin/lib/authReject.mjs +32 -0
  5. package/bin/lib/authproxy.mjs +52 -16
  6. package/bin/lib/childEnv.mjs +33 -1
  7. package/bin/lib/claude.mjs +166 -71
  8. package/bin/lib/config.mjs +24 -1
  9. package/bin/lib/credentials.mjs +27 -4
  10. package/bin/lib/deploy.mjs +364 -30
  11. package/bin/lib/env.mjs +130 -13
  12. package/bin/lib/fleet.mjs +99 -28
  13. package/bin/lib/git.mjs +131 -3
  14. package/bin/lib/hooks/readGuard.mjs +24 -0
  15. package/bin/lib/instance.mjs +115 -14
  16. package/bin/lib/knowledge.mjs +180 -60
  17. package/bin/lib/landed.mjs +38 -30
  18. package/bin/lib/listeners.mjs +211 -0
  19. package/bin/lib/localSessions.mjs +29 -4
  20. package/bin/lib/login.mjs +2 -2
  21. package/bin/lib/machines.mjs +16 -7
  22. package/bin/lib/preview.mjs +95 -6
  23. package/bin/lib/printable.mjs +42 -0
  24. package/bin/lib/procRegistry.mjs +65 -0
  25. package/bin/lib/processes.mjs +20 -7
  26. package/bin/lib/projectTools.mjs +248 -0
  27. package/bin/lib/prompts.mjs +16 -11
  28. package/bin/lib/resources.mjs +46 -4
  29. package/bin/lib/runtimeOfferings.mjs +75 -0
  30. package/bin/lib/runtimes.mjs +71 -9
  31. package/bin/lib/shipMerge.mjs +8 -4
  32. package/bin/lib/trace.mjs +16 -6
  33. package/bin/lib/update.mjs +5 -0
  34. package/bin/lib/work.mjs +1192 -4584
  35. package/bin/lib/workAgentMerges.mjs +504 -0
  36. package/bin/lib/workAgentPlans.mjs +447 -0
  37. package/bin/lib/workAgentReview.mjs +702 -0
  38. package/bin/lib/workAgentTurns.mjs +1148 -0
  39. package/bin/lib/workDiffs.mjs +107 -0
  40. package/bin/lib/workPreviews.mjs +351 -0
  41. package/bin/lib/workProcesses.mjs +236 -0
  42. package/bin/lib/workPullRequests.mjs +313 -0
  43. package/bin/lib/workShip.mjs +385 -0
  44. package/bin/lib/worktreeDiff.mjs +99 -43
  45. package/package.json +2 -2
@@ -0,0 +1,107 @@
1
+ /** Commit diff requests and their bounded reports. */
2
+ import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
3
+ import { git } from './git.mjs';
4
+
5
+ export function createWorkDiffs({ repoRoot }) {
6
+ const DIFF_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/diff-done');
7
+
8
+ /**
9
+ * COMMIT DIFFS, on request — the one PULL-shaped thing this daemon does.
10
+ *
11
+ * Everything else here is a push: the machine knows something and says it.
12
+ * A patch cannot work that way, because most are never opened and pushing
13
+ * every one would be storage and bandwidth for nothing. So the server leaves
14
+ * a job on the roster and this drains it.
15
+ *
16
+ * RUN FROM THE REPO ROOT, never a session worktree. A tab can be closed and
17
+ * its directory gone, but `session/<id>` outlives the tab and after a ship
18
+ * the commit is on main — the root checkout can see all of it, and a
19
+ * worktree can see only its own branch.
20
+ *
21
+ * Every failure is REPORTED rather than swallowed: "no such commit on this
22
+ * machine" is a real answer, and a viewer spinning forever is the worst thing
23
+ * this can do. The one thing never sent is a guess — an empty patch would
24
+ * tell a reader the commit changed nothing.
25
+ */
26
+ const MAX_PATCH_BYTES = 256 * 1024;
27
+ const servedDiffs = new Set();
28
+ const postDiff = async (body) => {
29
+ try {
30
+ await fetch(DIFF_DONE_URL, {
31
+ method: 'POST',
32
+ headers: {
33
+ Authorization: `Bearer ${FLEET_TOKEN}`,
34
+ 'User-Agent': USER_AGENT,
35
+ 'Content-Type': 'application/json',
36
+ },
37
+ signal: AbortSignal.timeout(30_000),
38
+ body: JSON.stringify(body),
39
+ });
40
+ } catch {
41
+ /* the row stays pending and expires; the next click re-requests */
42
+ }
43
+ };
44
+ const processDiffJobs = (jobs) => {
45
+ if (!Array.isArray(jobs) || jobs.length === 0) return;
46
+ for (const job of jobs.slice(0, 5)) {
47
+ const sha = String(job?.sha || '').toLowerCase();
48
+ // A sha becomes a `git` argument. The server validates too; one place
49
+ // doing this check is one deploy away from being zero places.
50
+ if (!/^[0-9a-f]{7,64}$/.test(sha)) continue;
51
+ // In-flight guard, not a cache: the server stops offering a sha the
52
+ // moment it settles, so this only stops the SAME poll's job being
53
+ // started twice while its `git show` is still running.
54
+ if (servedDiffs.has(sha)) continue;
55
+ servedDiffs.add(sha);
56
+ void (async () => {
57
+ try {
58
+ let patch = '';
59
+ const files = [];
60
+ try {
61
+ // `--format=` so the body is pure diff: the subject, author and
62
+ // date already reached the card on the worktree sweep, and
63
+ // repeating them inside the patch would put a second copy above
64
+ // every hunk.
65
+ patch = git(['show', '--patch', '--format=', sha], repoRoot);
66
+ } catch (e) {
67
+ await postDiff({ sha, error: String(e?.message || e).slice(0, 500) });
68
+ return;
69
+ }
70
+ try {
71
+ const raw = git(['show', '--numstat', '--format=', sha], repoRoot);
72
+ for (const line of raw.split('\n')) {
73
+ if (!line.trim()) continue;
74
+ const [a, d, ...rest] = line.split('\t');
75
+ const path = rest.join('\t');
76
+ if (!path) continue;
77
+ const binary = a === '-' || d === '-';
78
+ files.push({
79
+ path: path.slice(0, 300),
80
+ added: binary ? 0 : Number(a) || 0,
81
+ deleted: binary ? 0 : Number(d) || 0,
82
+ ...(binary ? { binary: true } : {}),
83
+ });
84
+ }
85
+ } catch {
86
+ /* a header without counts still beats no diff */
87
+ }
88
+ const truncated = Buffer.byteLength(patch, 'utf8') > MAX_PATCH_BYTES;
89
+ await postDiff({
90
+ sha,
91
+ // Cut on a LINE boundary: half a hunk header renders as garbage,
92
+ // and the viewer says the patch was truncated either way.
93
+ patch: truncated
94
+ ? patch.slice(0, MAX_PATCH_BYTES).replace(/\n[^\n]*$/, '\n')
95
+ : patch,
96
+ files: files.slice(0, 200),
97
+ truncated,
98
+ });
99
+ } finally {
100
+ servedDiffs.delete(sha);
101
+ }
102
+ })();
103
+ }
104
+ };
105
+
106
+ return { processDiffJobs };
107
+ }
@@ -0,0 +1,351 @@
1
+ /** Session preview claims, tunnels, and retirement. */
2
+ import { FLEET_URL, FLEET_TOKEN, USER_AGENT, DAEMON_INSTANCE } from './config.mjs';
3
+ import { isSafePathSegment } from './git.mjs';
4
+ import { originFor } from './listeners.mjs';
5
+ import { openTunnel } from './preview.mjs';
6
+ import { note } from './ui.mjs';
7
+
8
+ export function createWorkPreviews({ placeDir }) {
9
+ const PREVIEW_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/preview-done');
10
+ const PREVIEW_CLAIM_URL = FLEET_URL.replace(/\/agents\/?$/, '/preview-claim');
11
+
12
+ // ── SESSION PREVIEWS ──────────────────────────────────────────────────────
13
+ //
14
+ // Share the dev server the DRIVER is already running in their tab, behind a
15
+ // generated password, on a quick tunnel. This daemon never starts an app: the
16
+ // deleted live-preview feature ran a repo-declared command through a shell,
17
+ // and that is the reason it is deleted. Here the human runs their own server,
18
+ // `listenersIn` notices it, and this only ever wraps a port that measurement
19
+ // already named for that session.
20
+ //
21
+ // CLAIM BEFORE ACTING. Two daemons legitimately share one fleet credential —
22
+ // the case `machineDaemonsDisagree` exists because it happens, and the 0.51.2
23
+ // instance lock is blind to an OLDER peer — so both are handed the same job
24
+ // array. Both opening a tunnel leaves a public hostname alive that nobody
25
+ // owns and nobody can tear down, because only the lease holder can settle the
26
+ // row. `processDiffJobs` gets away without this because running `git show`
27
+ // twice costs nothing.
28
+ const livePreviews = new Map(); // sessionId -> { port, shareId, url, stop }
29
+ const previewClaiming = new Set(); // sessionIds mid-claim on this tick
30
+
31
+ // Returns the parsed JSON (or null on any failure) rather than discarding
32
+ // it — an OPEN settle's caller needs to see `data.settled === false`, the
33
+ // sharper share-id check on the server's side of a re-share racing the
34
+ // open's own round trip (see the openTunnel call site below).
35
+ const postPreview = async (body) => {
36
+ try {
37
+ const res = await fetch(PREVIEW_DONE_URL, {
38
+ method: 'POST',
39
+ headers: {
40
+ Authorization: `Bearer ${FLEET_TOKEN}`,
41
+ 'User-Agent': USER_AGENT,
42
+ 'Content-Type': 'application/json',
43
+ },
44
+ signal: AbortSignal.timeout(30_000),
45
+ body: JSON.stringify({ ...body, instance: DAEMON_INSTANCE }),
46
+ });
47
+ return await res.json().catch(() => null);
48
+ } catch {
49
+ /* the row stops being confirmed and reads as ended — which is true */
50
+ return null;
51
+ }
52
+ };
53
+
54
+ // `shareId` is the share the daemon was OFFERED, echoed off the job. A
55
+ // re-share inside the claim's round trip rotates the id server-side, so a
56
+ // claim naming the old one matches nothing and this daemon opens no tunnel
57
+ // for a request that no longer exists. Absent-safe: an older server that
58
+ // does not read the field just claims on the place alone, the old rule.
59
+ const claimPreview = async (sessionId, shareId) => {
60
+ try {
61
+ const res = await fetch(PREVIEW_CLAIM_URL, {
62
+ method: 'POST',
63
+ headers: {
64
+ Authorization: `Bearer ${FLEET_TOKEN}`,
65
+ 'User-Agent': USER_AGENT,
66
+ 'Content-Type': 'application/json',
67
+ },
68
+ signal: AbortSignal.timeout(15_000),
69
+ body: JSON.stringify({ sessionId, instance: DAEMON_INSTANCE, ...(shareId ? { shareId } : {}) }),
70
+ });
71
+ const j = await res.json().catch(() => null);
72
+ return j?.data?.claimed === true;
73
+ } catch {
74
+ return false; // could not claim → do nothing at all. The other daemon may have.
75
+ }
76
+ };
77
+
78
+ /** Tear one down here, and say so. `reason` is why, stored server-side rather
79
+ * than inferred: "the origin stopped listening" and "the owner pressed Stop"
80
+ * are different sentences to a teammate holding a phone. */
81
+ const stopPreview = async (sessionId, reason) => {
82
+ const live = livePreviews.get(sessionId);
83
+ livePreviews.delete(sessionId);
84
+ if (live) {
85
+ try {
86
+ live.stop();
87
+ } catch {
88
+ /* best-effort */
89
+ }
90
+ }
91
+ // Confirm only a teardown we actually PERFORMED. The stop job is a
92
+ // broadcast — every daemon on the credential gets it — and the one holding
93
+ // nothing used to answer instantly, flipping the row to 'ended' so the
94
+ // real holder was never told to stop and its tunnel outlived every
95
+ // surface. (The server drops mismatched confirms too; this is the copy on
96
+ // the component that can be published ahead of a deploy.) A stop for a
97
+ // tunnel whose daemon crashed resolves server-side: an unanswered 'ending'
98
+ // row reads as over once it goes stale.
99
+ if (live) await postPreview({ sessionId, ended: true, endedReason: reason });
100
+ };
101
+
102
+ const processPreviewJobs = (jobs) => {
103
+ if (!Array.isArray(jobs) || jobs.length === 0) return;
104
+ for (const job of jobs.slice(0, 5)) {
105
+ const sessionId = String(job?.sessionId || '');
106
+ const port = Number(job?.port);
107
+ if (!isSafePathSegment(sessionId)) continue;
108
+
109
+ if (job?.action === 'stop') {
110
+ if (previewClaiming.has(sessionId)) continue;
111
+ previewClaiming.add(sessionId);
112
+ void stopPreview(sessionId, 'stopped').finally(() => previewClaiming.delete(sessionId));
113
+ continue;
114
+ }
115
+
116
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) continue;
117
+
118
+ // VALIDATE THE MEMBERS-GATE TRIPLE AT THE BOUNDARY, the way sessionId and
119
+ // port already are — one place doing the check is one deploy away from
120
+ // being zero places. A malformed value is DROPPED rather than errored and
121
+ // the share opens password-only: a gate is never degraded to open, but it
122
+ // is also never left un-opened over a field we could not read.
123
+ const secret = /^[A-Za-z0-9_-]{32,128}$/.test(String(job?.secret ?? ''))
124
+ ? String(job.secret)
125
+ : null;
126
+ const shareId = isSafePathSegment(String(job?.shareId ?? '')) ? String(job.shareId) : null;
127
+ let authorizeUrl = null;
128
+ try {
129
+ const u = new URL(String(job?.authorizeUrl ?? ''));
130
+ if (u.protocol === 'https:') authorizeUrl = u.toString();
131
+ } catch {
132
+ /* not a URL — password-only, which is honest */
133
+ }
134
+ // All three or none: two of the three is a gate that cannot bounce.
135
+ const gateOk = Boolean(secret && shareId && authorizeUrl);
136
+
137
+ // Already serving exactly this. Re-opening would replace a working URL
138
+ // somebody may be looking at right now.
139
+ //
140
+ // NOTE this key is (session, port) and NOT the secret. Rotating a secret
141
+ // under a LIVE share is deliberately unsupported: `requestPreview`
142
+ // early-returns on a live row of the same port, so a new secret only ever
143
+ // arrives with a genuinely new row, by which time this map has been
144
+ // cleared. Anyone adding rotation must widen the key first.
145
+ //
146
+ // WIDENED for shareId: a live entry that has no shareId of its own (an
147
+ // older server, or one opened before shareId rode the wire) or this
148
+ // job's own shareId that is blank still matches on port alone, the old
149
+ // rule — but a live entry whose shareId is SET and DIFFERS from this
150
+ // job's is a re-share of the same port under a NEW request, and must
151
+ // not be skipped as "already serving exactly this".
152
+ const curLive = livePreviews.get(sessionId);
153
+ if (curLive?.port === port && (!shareId || !curLive.shareId || curLive.shareId === shareId))
154
+ continue;
155
+ if (previewClaiming.has(sessionId)) continue;
156
+ previewClaiming.add(sessionId);
157
+
158
+ void (async () => {
159
+ try {
160
+ if (!(await claimPreview(sessionId, shareId))) return; // somebody else has it
161
+ const wt = placeDir(sessionId);
162
+ // RE-VALIDATE the attribution here, not just the liveness. The server
163
+ // checked this port against a report up to a minute old; more
164
+ // importantly, checking `originFor` again is what keeps the answer
165
+ // to "whose port is this" on the machine that can actually see it —
166
+ // the same rule the gate itself dials by (below).
167
+ const measured = !originFor(wt, port).error;
168
+ if (!measured) {
169
+ await postPreview({
170
+ sessionId,
171
+ error: `nothing is listening on port ${port} in this worktree.`,
172
+ ...(shareId ? { shareId } : {}),
173
+ });
174
+ return;
175
+ }
176
+ // Replace anything this session already had — one tab, one door.
177
+ const prev = livePreviews.get(sessionId);
178
+ if (prev) {
179
+ try {
180
+ prev.stop();
181
+ } catch {
182
+ /* best-effort */
183
+ }
184
+ livePreviews.delete(sessionId);
185
+ }
186
+ const t = await openTunnel({
187
+ port,
188
+ log: (m) => note(`preview ${sessionId.slice(0, 8)}: ${m}`),
189
+ // The worktree the port was attributed to — the gate then dials
190
+ // the address the ATTRIBUTED socket holds (`originFor`, ::1 for a
191
+ // v6-loopback dev server) and refuses when an outside process
192
+ // holds the same address, rather than assuming 127.0.0.1.
193
+ worktree: wt,
194
+ // The origin died under a live tunnel. cloudflared happily outlives
195
+ // a dead dev server and the gate answers a dead origin with 502, so
196
+ // without this the app would print "live" over a 502.
197
+ onDead: () => {
198
+ livePreviews.delete(sessionId);
199
+ void postPreview({
200
+ sessionId,
201
+ ended: true,
202
+ endedReason: 'origin_gone',
203
+ ...(shareId ? { shareId } : {}),
204
+ });
205
+ },
206
+ // ATTRIBUTION rides the probe, not just the open: a freed default
207
+ // port (5173…) rebound by any other process on the box would keep
208
+ // a bare TCP probe green, and the share's URL+password would serve
209
+ // a worktree nobody consented to publish.
210
+ stillServing: async () => !originFor(wt, port).error,
211
+ ...(gateOk ? { grantSecret: secret, shareId, authorizeUrl } : {}),
212
+ // The gate closed itself after repeated failed passwords. Stored,
213
+ // so the incident is visible — and the entry is dropped so the
214
+ // owner can re-share the port without restarting the daemon.
215
+ onAbuse: () => {
216
+ livePreviews.delete(sessionId);
217
+ void postPreview({
218
+ sessionId,
219
+ ended: true,
220
+ endedReason: 'abuse',
221
+ ...(shareId ? { shareId } : {}),
222
+ });
223
+ },
224
+ // cloudflared died AFTER publishing (quick tunnels get dropped).
225
+ // Without this the daemon kept heartbeating a hostname that 530s.
226
+ onTunnelGone: () => {
227
+ livePreviews.delete(sessionId);
228
+ void postPreview({
229
+ sessionId,
230
+ error: 'the tunnel dropped — share it again to reopen.',
231
+ ...(shareId ? { shareId } : {}),
232
+ });
233
+ },
234
+ });
235
+ if (t.error) {
236
+ await postPreview({ sessionId, error: t.error, ...(shareId ? { shareId } : {}) });
237
+ return;
238
+ }
239
+ livePreviews.set(sessionId, { port, shareId, url: t.url, stop: t.stop });
240
+ // The gate we ACTUALLY installed, so the app never asserts a door
241
+ // nobody observed. An older server ignores the field.
242
+ const settled = await postPreview({
243
+ sessionId,
244
+ url: t.url,
245
+ user: t.user,
246
+ password: t.password,
247
+ gate: t.gateMode,
248
+ ...(shareId ? { shareId } : {}),
249
+ });
250
+ // A re-share (or a Stop) can land inside this open's own round
251
+ // trip and rotate the share id server-side — the settle is then
252
+ // refused for naming a request that no longer exists, and nobody
253
+ // holds a live row for the tunnel we just opened. Tear it down
254
+ // rather than leave a public hostname serving with no door back to
255
+ // it, and only if this call is still the entry's own (a newer open
256
+ // for the same session must not be undone by a late-arriving
257
+ // settle for an older one).
258
+ if (settled?.data?.settled === false) {
259
+ if (livePreviews.get(sessionId)?.stop === t.stop) livePreviews.delete(sessionId);
260
+ try {
261
+ t.stop();
262
+ } catch {
263
+ /* best-effort */
264
+ }
265
+ }
266
+ } finally {
267
+ previewClaiming.delete(sessionId);
268
+ }
269
+ })();
270
+ }
271
+ };
272
+
273
+ /** The sessionIds this machine is still serving — sent on the poll so the
274
+ * server can tell a live share from one whose machine went away. Silence
275
+ * must never read as "live". */
276
+ /**
277
+ * The daemon's own shape check on an argv the server parsed.
278
+ *
279
+ * Deliberately a SHAPE check and not a re-parse: the server owns the policy
280
+ * (which argv[0] are allowed, the install refusal, the length caps) and the
281
+ * machine owns the refusal to EXECUTE something malformed. It is duplicated
282
+ * rather than imported because this package ships standalone and cannot
283
+ * depend on the monorepo — the mirror is small, and `devCommand.ts` is where
284
+ * the real rules live.
285
+ */
286
+ const isPlausibleDevArgv = (argv) =>
287
+ Array.isArray(argv) &&
288
+ argv.length > 0 &&
289
+ argv.length <= 8 &&
290
+ argv.every((a) => typeof a === 'string' && a.length > 0 && a.length <= 200) &&
291
+ !argv.some((a) => /[&|;<>`$(){}*?~\\]/.test(a));
292
+
293
+ // ── DEV RUNS ARE DELETED (2026-08-26) ─────────────────────────────────
294
+ //
295
+ // The machine no longer starts application processes. It never learned to
296
+ // decide what "run dev" means for a stack nobody enumerated — `rojo serve`
297
+ // and `rbxtsc -w` are both correct for one Roblox repo and neither could
298
+ // clear the server's argv0 allowlist, and widening that list is a code change
299
+ // per ecosystem forever.
300
+ //
301
+ // Nothing downstream is lost, because SHARING NEVER DEPENDED ON US STARTING
302
+ // IT. `listenersIn` attributes a listening socket to a place by the cwd of
303
+ // the process holding it, so a server the driver's own agent started in the
304
+ // tab is measured exactly like one this file used to spawn. The web renders
305
+ // that measured list and a person picks which port to share.
306
+ //
307
+ // Gone with it: `devServer.mjs`, `devResolve.mjs`, the four `/fleet/dev-run-*`
308
+ // endpoints, the claim lease, the orphan registry at ~/.flowviant/devruns.json
309
+ // and its adopt-across-re-exec dance. If supervision is ever wanted back it
310
+ // returns as "supervise this process", never as "run dev".
311
+
312
+ const livePreviewIds = () => [...livePreviews.keys()];
313
+
314
+ /**
315
+ * The tab closed (or the server stopped listing it). Ordered BEFORE
316
+ * `retireWorkSessions`, and that ordering is load-bearing: `git worktree
317
+ * remove` under a running dev server reintroduces the stale-server bug — on
318
+ * Linux the process keeps serving bytes from open file handles in a directory
319
+ * that no longer exists, which shows a human the wrong thing without erroring
320
+ * anywhere.
321
+ */
322
+ const retirePreviews = (activeIds) => {
323
+ // Same guard `retireWorkSessions` keeps: a roster response missing the
324
+ // field is an older server, not a close, and must not tear down every live
325
+ // share at once.
326
+ if (!Array.isArray(activeIds)) return;
327
+ const live = new Set(activeIds);
328
+ for (const sessionId of [...livePreviews.keys()]) {
329
+ if (live.has(sessionId)) continue;
330
+ if (previewClaiming.has(sessionId)) continue;
331
+ previewClaiming.add(sessionId);
332
+ void stopPreview(sessionId, 'tab_closed').finally(() => previewClaiming.delete(sessionId));
333
+ }
334
+ };
335
+
336
+ /** Daemon shutdown. Detached tunnels survive our exit by design, so leaving
337
+ * them would strand a public hostname until the box rebooted — the exact
338
+ * case `reapOrphanPreviews` exists to clean up after an UNgraceful death. */
339
+ const shutdownPreviews = () => {
340
+ for (const [, live] of livePreviews) {
341
+ try {
342
+ live.stop();
343
+ } catch {
344
+ /* best-effort */
345
+ }
346
+ }
347
+ livePreviews.clear();
348
+ };
349
+
350
+ return { processPreviewJobs, livePreviewIds, retirePreviews, shutdownPreviews };
351
+ }