flowviant 0.97.0 → 0.100.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 (50) hide show
  1. package/README.md +15 -13
  2. package/bin/cli.mjs +108 -28
  3. package/bin/lib/agentPlan.mjs +159 -37
  4. package/bin/lib/artifacts.mjs +136 -26
  5. package/bin/lib/authReject.mjs +32 -0
  6. package/bin/lib/authproxy.mjs +52 -16
  7. package/bin/lib/childEnv.mjs +33 -1
  8. package/bin/lib/claude.mjs +166 -71
  9. package/bin/lib/config.mjs +36 -9
  10. package/bin/lib/credentials.mjs +29 -5
  11. package/bin/lib/deploy.mjs +364 -30
  12. package/bin/lib/desktopContract.mjs +227 -0
  13. package/bin/lib/env.mjs +130 -13
  14. package/bin/lib/fleet.mjs +121 -30
  15. package/bin/lib/git.mjs +131 -3
  16. package/bin/lib/hooks/readGuard.mjs +24 -0
  17. package/bin/lib/instance.mjs +129 -14
  18. package/bin/lib/knowledge.mjs +180 -60
  19. package/bin/lib/landed.mjs +38 -30
  20. package/bin/lib/launchCommand.mjs +23 -0
  21. package/bin/lib/listeners.mjs +211 -0
  22. package/bin/lib/localSessions.mjs +29 -4
  23. package/bin/lib/login.mjs +36 -12
  24. package/bin/lib/machines.mjs +24 -14
  25. package/bin/lib/mcp-cli.mjs +4 -3
  26. package/bin/lib/preflight.mjs +2 -1
  27. package/bin/lib/preview.mjs +95 -6
  28. package/bin/lib/printable.mjs +42 -0
  29. package/bin/lib/procRegistry.mjs +65 -0
  30. package/bin/lib/processes.mjs +20 -7
  31. package/bin/lib/projectTools.mjs +248 -0
  32. package/bin/lib/prompts.mjs +16 -11
  33. package/bin/lib/resources.mjs +46 -4
  34. package/bin/lib/runtimeOfferings.mjs +75 -0
  35. package/bin/lib/runtimes.mjs +71 -9
  36. package/bin/lib/shipMerge.mjs +8 -4
  37. package/bin/lib/trace.mjs +16 -6
  38. package/bin/lib/update.mjs +114 -25
  39. package/bin/lib/work.mjs +1192 -4584
  40. package/bin/lib/workAgentMerges.mjs +504 -0
  41. package/bin/lib/workAgentPlans.mjs +447 -0
  42. package/bin/lib/workAgentReview.mjs +702 -0
  43. package/bin/lib/workAgentTurns.mjs +1151 -0
  44. package/bin/lib/workDiffs.mjs +107 -0
  45. package/bin/lib/workPreviews.mjs +351 -0
  46. package/bin/lib/workProcesses.mjs +236 -0
  47. package/bin/lib/workPullRequests.mjs +313 -0
  48. package/bin/lib/workShip.mjs +385 -0
  49. package/bin/lib/worktreeDiff.mjs +99 -43
  50. package/package.json +2 -3
@@ -64,12 +64,26 @@
64
64
 
65
65
 
66
66
  import { spawn } from 'node:child_process';
67
- import { join } from 'node:path';
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';
68
82
  import { FLEET_URL, FLEET_TOKEN, USER_AGENT, DAEMON_INSTANCE } from './config.mjs';
69
83
  import { c, note, ok, warn } from './ui.mjs';
70
84
  import { scrub, myPubB64 } from './env.mjs';
71
85
  import { childEnv } from './childEnv.mjs';
72
- import { git } from './git.mjs';
86
+ import { git, gitNetAsync } from './git.mjs';
73
87
 
74
88
  const deployUrl = (tail) => FLEET_URL.replace(/\/agents\/?$/, `/${tail}`);
75
89
 
@@ -157,24 +171,60 @@ export async function reportDeployConfig(repoRoot, baseRef) {
157
171
  o && typeof o === 'object'
158
172
  ? Object.fromEntries(Object.entries(o).map(([k, v]) => [k, scrub(String(v ?? ''))]))
159
173
  : o;
160
- const meta = targets.map((t) => ({
161
- id: t.id,
162
- label: t.label,
163
- provider: t.provider || 'cloudflare',
164
- command: scrub(String(t.command ?? '')),
165
- build: t.build ? scrub(String(t.build)) : t.build,
166
- commands: scrubCmds(t.commands),
167
- healthcheck: t.healthcheck,
168
- healthStatus: t.healthStatus,
169
- pushSecrets: t.pushSecrets,
170
- // Deploy-on-merge: the env this target auto-deploys to when commits land
171
- // on base. MUST ride this map — a field forgotten here never reaches the
172
- // server, and the server is what turns a landed report into the job.
173
- ...(typeof t.onMerge === 'string' ? { onMerge: t.onMerge } : {}),
174
- }));
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
+ });
175
214
  try {
176
- await post('deploy-config', { pubkey: myPubB64(), targets: meta });
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.
177
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
+ }
178
228
  } catch (e) {
179
229
  warn(`deploy: could not report config — ${e.message}`);
180
230
  }
@@ -323,14 +373,8 @@ export function processDeployJobs(jobs, ctx) {
323
373
  }
324
374
  });
325
375
  }, 60_000);
326
- const targets = readDeployConfig(ctx.repoRoot, ctx.baseRef);
327
- const target = targets.find((t) => t.id === job.targetId);
328
- if (!target) {
329
- await report(job, ctx, { ok: false, message: `target "${job.targetId}" not in .flowviant/deploy.json` });
330
- return;
331
- }
332
376
  note(`${c.cyan('deploy')} ${c.dim(`— ${job.kind} ${job.targetId} → ${job.env}…`)}`);
333
- const outcome = await runDeploy(job, target, ctx);
377
+ const outcome = await runDeploy(job, ctx);
334
378
  // From here the work is DONE. Whatever the report does, this job must
335
379
  // never run again in this process.
336
380
  ran.add(job.id);
@@ -351,7 +395,271 @@ export function processDeployJobs(jobs, ctx) {
351
395
  }
352
396
  }
353
397
 
354
- async function runDeploy(job, target, ctx) {
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) {
355
663
  // AN ALLOWLIST, not `{...process.env}` minus names. What stood here was
356
664
  //
357
665
  // const env = { ...process.env, ...deployCreds() };
@@ -369,18 +677,44 @@ async function runDeploy(job, target, ctx) {
369
677
  // nothing else. It replaced `extra: deployCreds()` when the vault was deleted
370
678
  // — the credentials are the OPERATOR's now, in the shell they started the
371
679
  // daemon in, rather than values Flowviant decrypted onto the box.
372
- const env = childEnv({ cwd: ctx.repoRoot, deploy: true }); // infra creds; never a file
680
+ const env = childEnv({ cwd, deploy: true }); // infra creds; never a file
373
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
+ }
374
708
  // Rollback is a single wrangler command; deploy is build → secrets → deploy.
375
709
  if (job.kind === 'rollback') {
376
710
  const cmd = target.commands?.[`rollback:${job.env}`] || `npx wrangler rollback`;
377
- const r = await run(cmd, { cwd: ctx.repoRoot, env });
711
+ const r = await run(cmd, { cwd, env });
378
712
  logs.push(...tailLines(r.out));
379
713
  return { ok: r.ok, message: r.ok ? 'rolled back' : logs.slice(-6).join('\n'), logs };
380
714
  }
381
715
 
382
716
  if (target.build) {
383
- const b = await run(target.build, { cwd: ctx.repoRoot, env });
717
+ const b = await run(target.build, { cwd, env });
384
718
  logs.push(...tailLines(b.out));
385
719
  if (!b.ok) return { ok: false, message: `build failed:\n${logs.slice(-6).join('\n')}`, logs };
386
720
  }
@@ -397,7 +731,7 @@ async function runDeploy(job, target, ctx) {
397
731
  // values belong there. `target.pushSecrets` stays parsed and reported, which
398
732
  // is what every retired key in this product does.
399
733
  const cmd = target.commands?.[job.env] || target.command;
400
- const d = await run(cmd, { cwd: ctx.repoRoot, env });
734
+ const d = await run(cmd, { cwd, env });
401
735
  logs.push(...tailLines(d.out));
402
736
  if (!d.ok) return { ok: false, message: `deploy failed:\n${logs.slice(-8).join('\n')}`, logs };
403
737
 
@@ -0,0 +1,227 @@
1
+ /** Local, read-only desktop contract and bounded daemon logs. */
2
+ import { createHash } from 'node:crypto';
3
+ import { appendFileSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { format } from 'node:util';
7
+ import { FLEET_URL, MACHINE_HOST, USER_AGENT, VERSION } from './config.mjs';
8
+ import { listStoredProjects } from './credentials.mjs';
9
+ import { readStoredPubB64 } from './env.mjs';
10
+ import { daemonRunningFor } from './instance.mjs';
11
+ import { runningViaNpx } from './launchCommand.mjs';
12
+ import { boxesUrlFrom, fetchBoxesFor } from './machines.mjs';
13
+ import { detectRuntimes } from './runtimes.mjs';
14
+ import { runningCompiledBinary } from './update.mjs';
15
+
16
+ export const MAX_DAEMON_LOG_BYTES = 2 * 1024 * 1024;
17
+ const store = () => join(homedir(), '.flowviant');
18
+ const key = (projectId) => createHash('sha256').update(String(projectId)).digest('hex').slice(0, 16);
19
+ export const daemonLogPath = (projectId) => join(store(), `daemon-${key(projectId)}.log`);
20
+ export const daemonStatePath = (projectId) => join(store(), `daemon-${key(projectId)}.state.json`);
21
+
22
+ function readState(projectId) {
23
+ try {
24
+ const value = JSON.parse(readFileSync(daemonStatePath(projectId), 'utf8'));
25
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
26
+ } catch { return {}; }
27
+ }
28
+
29
+ export function writeDaemonState(projectId, change) {
30
+ if (!projectId) return;
31
+ try {
32
+ mkdirSync(store(), { recursive: true });
33
+ const path = daemonStatePath(projectId);
34
+ const current = readState(projectId);
35
+ const next = { ...current, ...change, pid: process.pid };
36
+ const tmp = `${path}.${process.pid}.tmp`;
37
+ writeFileSync(tmp, JSON.stringify(next), { mode: 0o600 });
38
+ renameSync(tmp, path);
39
+ } catch { /* a local readout must never interrupt a daemon */ }
40
+ }
41
+
42
+ export function setRuntimeLimit(projectId, runtime, words) {
43
+ if (!projectId || !runtime) return false;
44
+ const state = readState(projectId);
45
+ const limits = { ...(state.limits ?? {}) };
46
+ if (words == null) {
47
+ if (!Object.hasOwn(limits, runtime)) return false;
48
+ delete limits[runtime];
49
+ } else {
50
+ if (limits[runtime] === words) return false;
51
+ limits[runtime] = words;
52
+ }
53
+ writeDaemonState(projectId, { limits });
54
+ emitMachineEvent({ event: words == null ? 'limit-cleared' : 'limit-hit', runtime, ...(words == null ? {} : { message: words }) });
55
+ return true;
56
+ }
57
+
58
+ export function installChannel() {
59
+ return runningCompiledBinary() ? 'binary' : runningViaNpx() ? 'npx' : 'npm-g';
60
+ }
61
+
62
+ export function desktopStatus({ entries = listStoredProjects(), runtimes = detectRuntimes(), runningFor = daemonRunningFor, stateFor = readState, pidFor = lockPid } = {}) {
63
+ return {
64
+ schema: 1,
65
+ version: VERSION,
66
+ installChannel: installChannel(),
67
+ projects: entries.map((entry) => {
68
+ const running = runningFor(entry.fleetToken);
69
+ const state = stateFor(entry.projectId);
70
+ const liveState = running === true && state.pid != null && state.pid === pidFor(entry.fleetToken) ? state : null;
71
+ return {
72
+ id: entry.projectId,
73
+ name: entry.name ?? null,
74
+ dir: entry.repoRoot ?? null,
75
+ running,
76
+ holder: liveState?.holder ?? null,
77
+ lastPoll: state.lastPoll ?? null,
78
+ logFile: daemonLogPath(entry.projectId),
79
+ runtimes: runtimes.map((rt) => ({
80
+ id: rt.id,
81
+ installed: rt.installed,
82
+ version: rt.version ?? null,
83
+ dispatchable: rt.dispatchable,
84
+ ...(liveState?.limits?.[rt.id] ? { parkedByLimit: true, message: liveState.limits[rt.id] } : {}),
85
+ })),
86
+ };
87
+ }),
88
+ // A box with no projects still has installed CLIs.
89
+ runtimes: runtimes.map((rt) => ({ id: rt.id, installed: rt.installed, version: rt.version ?? null, dispatchable: rt.dispatchable })),
90
+ };
91
+ }
92
+
93
+ const REMOTE_TIMEOUT_MS = 4_000;
94
+
95
+ async function remoteRead(read, timeoutMs) {
96
+ const controller = new AbortController();
97
+ let timer;
98
+ try {
99
+ // Abort the request and bound body parsing even if a fetch ignores its signal.
100
+ return await Promise.race([
101
+ read(controller.signal),
102
+ new Promise((resolve) => {
103
+ timer = setTimeout(() => {
104
+ controller.abort();
105
+ resolve({ error: `timed out after ${timeoutMs}ms` });
106
+ }, timeoutMs);
107
+ }),
108
+ ]);
109
+ } catch (error) {
110
+ return { error: String(error?.message ?? error) };
111
+ } finally {
112
+ clearTimeout(timer);
113
+ }
114
+ }
115
+
116
+ function liveAgent(row) {
117
+ if (!row || typeof row !== 'object' || typeof row.id !== 'string' || !row.id ||
118
+ typeof row.name !== 'string' || !row.name || typeof row.status !== 'string' ||
119
+ typeof row.runtime !== 'string' || !row.runtime ||
120
+ !['delivered', 'total'].every((field) => Number.isInteger(row[field]) && row[field] >= 0) ||
121
+ !['asks', 'parked'].every((field) => typeof row[field] === 'boolean') ||
122
+ !(row.since === null || typeof row.since === 'string')) return null;
123
+ const { id, name, status, runtime, delivered, total, asks, parked, since } = row;
124
+ return { id, name, status, runtime, delivered, total, asks, parked, since };
125
+ }
126
+
127
+ async function fetchLiveAgentsFor(entry, { url, fetchImpl, signal }) {
128
+ const res = await fetchImpl(url, {
129
+ headers: { Authorization: `Bearer ${entry.fleetToken}`, 'User-Agent': USER_AGENT },
130
+ signal,
131
+ });
132
+ if (!res.ok) return { error: `HTTP ${res.status}` };
133
+ const body = await res.json();
134
+ const data = body?.data ?? body;
135
+ if (!data || !Array.isArray(data.agents)) return { error: 'unexpected answer shape' };
136
+ const pressure = data.pressure;
137
+ return {
138
+ agents: data.agents.map(liveAgent).filter(Boolean),
139
+ pressure: pressure && typeof pressure.reason === 'string' && typeof pressure.at === 'string'
140
+ ? { reason: pressure.reason, at: pressure.at } : null,
141
+ };
142
+ }
143
+
144
+ export async function desktopStatusRemote({ entries = listStoredProjects(), runtimes = detectRuntimes(),
145
+ runningFor = daemonRunningFor, stateFor = readState, pidFor = lockPid,
146
+ fetchImpl = fetch, envpub = readStoredPubB64(), fleetUrl = FLEET_URL, host = MACHINE_HOST,
147
+ now = () => new Date().toISOString(), timeoutMs = REMOTE_TIMEOUT_MS, log = console.error } = {}) {
148
+ const status = desktopStatus({ entries, runtimes, runningFor, stateFor, pidFor });
149
+ const boxesUrl = boxesUrlFrom(fleetUrl);
150
+ const liveUrl = String(fleetUrl).replace(/\/agents\/?$/, '/live-agents');
151
+ const remotes = await Promise.all(entries.map(async (entry) => {
152
+ if (!entry.fleetToken) return null;
153
+ const [boxes, live] = await Promise.all([
154
+ remoteRead((signal) => fetchBoxesFor(entry, { url: boxesUrl, envpub, fetchImpl, signal }), timeoutMs),
155
+ remoteRead((signal) => fetchLiveAgentsFor(entry, { url: liveUrl, fetchImpl, signal }), timeoutMs),
156
+ ]);
157
+ const boxesOk = Array.isArray(boxes.boxes);
158
+ const liveOk = Array.isArray(live.agents);
159
+ if (!boxesOk) {
160
+ const reason = boxes.error ?? (boxes.unsupported ? 'HTTP 404' : boxes.rejected ? 'credential rejected' : 'unexpected answer shape');
161
+ log(`status --remote ${entry.projectId} boxes: ${reason}`);
162
+ }
163
+ if (!liveOk) log(`status --remote ${entry.projectId} live-agents: ${live.error ?? 'unexpected answer shape'}`);
164
+ if (!boxesOk && !liveOk) return null;
165
+ const mine = boxesOk ? boxes.boxes.find((box) => box?.boxId === boxes.me) : null;
166
+ const serving = boxesOk ? boxes.boxes.find((box) => box?.role === 'serving') : null;
167
+ return {
168
+ at: now(),
169
+ boxName: boxesOk ? (typeof mine?.boxName === 'string' && mine.boxName ? mine.boxName : host || null) : null,
170
+ role: mine?.role === 'serving' || mine?.role === 'inactive' ? mine.role : null,
171
+ servingBoxName: typeof serving?.boxName === 'string' ? serving.boxName : null,
172
+ daemonVersion: typeof mine?.daemonVersion === 'string' ? mine.daemonVersion : null,
173
+ latest: boxesOk ? boxes.latest : null,
174
+ agents: liveOk ? live.agents : null,
175
+ pressure: liveOk ? live.pressure : null,
176
+ };
177
+ }));
178
+ status.projects.forEach((project, index) => { project.remote = remotes[index]; });
179
+ return status;
180
+ }
181
+
182
+ function lockPid(token) {
183
+ try {
184
+ const hash = createHash('sha256').update(String(token || 'anon')).digest('hex').slice(0, 12);
185
+ return JSON.parse(readFileSync(join(store(), `daemon-${hash}.lock`), 'utf8')).pid;
186
+ } catch { return null; }
187
+ }
188
+
189
+ let events = false;
190
+ let eventWriter = process.stdout.write.bind(process.stdout);
191
+ export function enableMachineEvents(write = eventWriter) { events = true; eventWriter = write; }
192
+ export function emitMachineEvent(value) {
193
+ if (events) eventWriter(`${JSON.stringify(value)}\n`);
194
+ }
195
+
196
+ /** All human output stays on its existing stream in terminal mode. In desktop
197
+ * mode stdout is reserved for JSONL and those same lines go to stderr. */
198
+ export function installDaemonLogging(projectId, { jsonEvents = false } = {}) {
199
+ if (!projectId) return;
200
+ if (jsonEvents) {
201
+ enableMachineEvents();
202
+ process.stdout.write = (...args) => process.stderr.write(...args);
203
+ }
204
+ const original = { log: console.log, info: console.info, warn: console.warn, error: console.error };
205
+ const logPath = daemonLogPath(projectId);
206
+ for (const method of Object.keys(original)) {
207
+ console[method] = (...args) => {
208
+ const line = format(...args);
209
+ try {
210
+ mkdirSync(store(), { recursive: true });
211
+ const encoded = Buffer.from(`${line}\n`);
212
+ const tail = Buffer.from('\n[truncated]\n');
213
+ const chunk = encoded.length > MAX_DAEMON_LOG_BYTES
214
+ ? Buffer.concat([encoded.subarray(0, MAX_DAEMON_LOG_BYTES - tail.length), tail])
215
+ : encoded;
216
+ let size = 0;
217
+ try { size = statSync(logPath).size; } catch { /* new file */ }
218
+ if (size + chunk.length > MAX_DAEMON_LOG_BYTES) {
219
+ try { renameSync(logPath, `${logPath}.1`); } catch { /* no old log */ }
220
+ }
221
+ appendFileSync(logPath, chunk, { mode: 0o600 });
222
+ } catch { /* logging must never stop service */ }
223
+ if (jsonEvents) process.stderr.write(`${line}\n`);
224
+ else original[method](...args);
225
+ };
226
+ }
227
+ }