failproofai 1.0.0-beta.12 → 1.0.0-beta.13

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 (173) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +3 -3
  3. package/.next/standalone/.next/prerender-manifest.json +3 -3
  4. package/.next/standalone/.next/required-server-files.json +1 -1
  5. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  6. package/.next/standalone/.next/server/app/_global-error/page.js +2 -3
  7. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  8. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  11. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
  12. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  13. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
  14. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
  15. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  16. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  17. package/.next/standalone/.next/server/app/_not-found/page.js +2 -3
  18. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  19. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  20. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  21. package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
  22. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
  23. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  24. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +10 -10
  25. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  26. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  27. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  28. package/.next/standalone/.next/server/app/api/audit/invite/route.js +2 -2
  29. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/audit/run/route.js +2 -2
  31. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/api/auth/login-request/route.js +3 -2
  34. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js +2 -2
  36. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  37. package/.next/standalone/.next/server/app/api/auth/logout/route.js +2 -2
  38. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  39. package/.next/standalone/.next/server/app/api/auth/reminder/route.js +2 -2
  40. package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +1 -1
  41. package/.next/standalone/.next/server/app/api/auth/status/route.js +2 -2
  42. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  43. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  44. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  45. package/.next/standalone/.next/server/app/audit/page.js +2 -3
  46. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  47. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  48. package/.next/standalone/.next/server/app/index.html +1 -1
  49. package/.next/standalone/.next/server/app/index.rsc +15 -15
  50. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  51. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  52. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  53. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +10 -10
  54. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  55. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  56. package/.next/standalone/.next/server/app/page.js +2 -3
  57. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  58. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  59. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +10 -10
  60. package/.next/standalone/.next/server/app/policies/page.js +2 -3
  61. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  62. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  63. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  64. package/.next/standalone/.next/server/app/project/[name]/page.js +3 -4
  65. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  66. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  67. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  68. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  69. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +3 -4
  70. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  71. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  72. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  73. package/.next/standalone/.next/server/app/projects/page.js +3 -4
  74. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  75. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  76. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
  77. package/.next/standalone/.next/server/app/settings/page.js +2 -3
  78. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  79. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  80. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__1a7i2q4._.js → [root-of-the-server]__00ni9za._.js} +1 -1
  81. package/.next/standalone/.next/server/chunks/[root-of-the-server]__08y94xv._.js +3 -0
  82. package/.next/standalone/.next/server/chunks/[root-of-the-server]__094p1f1._.js +1 -1
  83. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_0xu5z._.js +1 -1
  84. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0ed01_d._.js +1 -1
  85. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__1_ps0jw._.js → [root-of-the-server]__0effm9m._.js} +1 -1
  86. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0lk_935._.js → [root-of-the-server]__0ix8gm7._.js} +1 -1
  87. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0ky_n0r._.js → [root-of-the-server]__0ji11a-._.js} +1 -1
  88. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0v2fpxe._.js → [root-of-the-server]__0ojwjxl._.js} +1 -1
  89. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0vb5fl2._.js +3 -0
  90. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1ibjy5x._.js +1 -1
  91. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1tvn4wf._.js +1 -1
  92. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1uyc-z4._.js +3 -0
  93. package/.next/standalone/.next/server/chunks/[root-of-the-server]__20d06rh._.js +3 -0
  94. package/.next/standalone/.next/server/chunks/_0lxbzdq._.js +1 -3
  95. package/.next/standalone/.next/server/chunks/_1zuiiy3._.js +1 -3
  96. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  97. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1rdruw4._.js → [root-of-the-server]__0_1tiya._.js} +1 -1
  98. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0ha3gul._.js → [root-of-the-server]__0eeipso._.js} +2 -2
  99. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0spkm68._.js +3 -3
  100. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1tyf0tc._.js → [root-of-the-server]__17itmx-._.js} +2 -2
  101. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1io8n-9._.js → [root-of-the-server]__1dx-a-c._.js} +2 -2
  102. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1mmefsr._.js +5 -0
  103. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1yw368c._.js → [root-of-the-server]__1n2vcv4._.js} +1 -1
  104. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1rv-sc9._.js +1 -1
  105. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1a2hcb_._.js → [root-of-the-server]__1tlvv9a._.js} +1 -1
  106. package/.next/standalone/.next/server/chunks/ssr/_0rjgfln._.js +1 -1
  107. package/.next/standalone/.next/server/chunks/ssr/{_1fahg9e._.js → _0vo2qyt._.js} +2 -2
  108. package/.next/standalone/.next/server/chunks/ssr/{_0uz7o9y._.js → _0xej-0e._.js} +2 -2
  109. package/.next/standalone/.next/server/chunks/ssr/{_04mekdy._.js → _10399ge._.js} +2 -2
  110. package/.next/standalone/.next/server/chunks/ssr/{_0se484m._.js → _149k9oy._.js} +2 -2
  111. package/.next/standalone/.next/server/chunks/ssr/{_1vsgpqt._.js → _166jvu8._.js} +1 -1
  112. package/.next/standalone/.next/server/chunks/ssr/{_0r-dsre._.js → _1_hwp06._.js} +2 -2
  113. package/.next/standalone/.next/server/chunks/ssr/{_1w8sola._.js → _1mnhi_r._.js} +2 -2
  114. package/.next/standalone/.next/server/chunks/ssr/{_1w_6p9g._.js → _1ror7k9._.js} +2 -2
  115. package/.next/standalone/.next/server/chunks/ssr/{_1gmzkwj._.js → _1sdgprl._.js} +1 -1
  116. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  117. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  118. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
  119. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  120. package/.next/standalone/.next/server/pages/404.html +1 -1
  121. package/.next/standalone/.next/server/pages/500.html +1 -1
  122. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  123. package/.next/standalone/.next/server/server-reference-manifest.json +15 -15
  124. package/.next/standalone/.next/static/chunks/{2_h--2ame3r83.js → 01s3vpzky_qew.js} +1 -1
  125. package/.next/standalone/.next/static/chunks/0km-jn9o5xyqs.css +2 -0
  126. package/.next/standalone/.next/static/chunks/{23hp7hekegpe5.js → 0zbcd_n8ku2ov.js} +1 -1
  127. package/.next/standalone/.next/static/chunks/{0cakk77ppmlo7.js → 1erji0ratofla.js} +1 -1
  128. package/.next/standalone/.next/static/chunks/{40-zk44qsg5y-.js → 23hnlzqx44vbe.js} +1 -1
  129. package/.next/standalone/.next/static/chunks/{1wb8cnmzx69rz.js → 2cwsiyhc7d2cw.js} +1 -1
  130. package/.next/standalone/.next/static/chunks/{0_rdbv_k5j51p.js → 2y42ebzynyxdv.js} +1 -1
  131. package/.next/standalone/.next/static/chunks/{1spk1bub0vd-h.js → 315lncj7dsp9c.js} +1 -1
  132. package/.next/standalone/.next/static/chunks/{0-zh1umoq6ppc.js → 31_px0dfgdg9j.js} +2 -2
  133. package/.next/standalone/.next/static/chunks/{2bie7ajv70p9b.js → 3l4-fu3xt75j8.js} +1 -1
  134. package/.next/standalone/.next/static/chunks/{298v78ypf8edl.js → 3poscoxn69x9g.js} +1 -1
  135. package/.next/standalone/app/policies/hooks-client.tsx +4 -4
  136. package/.next/standalone/ino.ujh3/.failproofai/VERSION +4 -0
  137. package/.next/standalone/ino.ujh3/.failproofai/state/last-version +1 -0
  138. package/.next/standalone/ino.ujh3/.failproofai/state/telemetry-id +1 -0
  139. package/.next/standalone/package.json +10 -10
  140. package/.next/standalone/server.js +1 -1
  141. package/bin/failproofai.mjs +186 -5
  142. package/dist/cli.mjs +1130 -1352
  143. package/dist/worker.mjs +107 -1000
  144. package/package.json +10 -10
  145. package/src/hooks/backfill-cli.ts +1 -1
  146. package/src/hooks/cloud-connection.ts +4 -4
  147. package/src/hooks/cloud-enrollment-cli.ts +1 -1
  148. package/src/hooks/cloud-enrollment.ts +3 -3
  149. package/src/hooks/cloud-managed-policies.ts +60 -22
  150. package/src/hooks/configure-wizard.ts +124 -53
  151. package/src/hooks/custom-hooks-loader.ts +57 -8
  152. package/src/hooks/daemon-service.ts +66 -11
  153. package/src/hooks/flush-cli.ts +180 -0
  154. package/src/hooks/fp-config.ts +242 -118
  155. package/src/hooks/fp-home.ts +138 -22
  156. package/src/hooks/fp-reset.ts +357 -43
  157. package/src/hooks/handler.ts +11 -11
  158. package/src/hooks/harness-cli.ts +309 -0
  159. package/src/hooks/hook-activity-store.ts +5 -5
  160. package/src/hooks/manager.ts +35 -0
  161. package/src/hooks/onboarding-attempt.ts +22 -6
  162. package/src/hooks/tui.ts +64 -7
  163. package/src/hooks/uninstall-cli.ts +1 -1
  164. package/.next/standalone/.next/server/chunks/[root-of-the-server]__06hexd0._.js +0 -3
  165. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1iny24d._.js +0 -3
  166. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1tyedjt._.js +0 -3
  167. package/.next/standalone/.next/server/chunks/lib_telemetry_ts_0dctyyw._.js +0 -5
  168. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1u-wx42._.js +0 -4
  169. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-config_ts_04t589g._.js +0 -5
  170. package/.next/standalone/.next/static/chunks/13vrxdsdihqd_.css +0 -2
  171. /package/.next/standalone/.next/static/{-3gDrXYxNRTVwrFnqXAWd → ziAsXfNE0cH_S7LHK-Py3}/_buildManifest.js +0 -0
  172. /package/.next/standalone/.next/static/{-3gDrXYxNRTVwrFnqXAWd → ziAsXfNE0cH_S7LHK-Py3}/_clientMiddlewareManifest.js +0 -0
  173. /package/.next/standalone/.next/static/{-3gDrXYxNRTVwrFnqXAWd → ziAsXfNE0cH_S7LHK-Py3}/_ssgManifest.js +0 -0
@@ -358,9 +358,43 @@ export async function loadAllCustomHooks(
358
358
  // twice per event. Seeded by the explicit path below and consulted by both
359
359
  // convention passes.
360
360
  const loadedPaths = new Set<string>();
361
- const cloudManagedByPath = new Map(
362
- (opts?.cloudManagedPolicies ?? []).map((policy) => [resolve(policy.path), policy]),
363
- );
361
+
362
+ // Cloud policies keyed by the artifact they resolve to — and artifacts are
363
+ // CONTENT-ADDRESSED, so two policies whose source is byte-identical share one
364
+ // path. Building this map with a plain `new Map(entries)` let the last one win
365
+ // silently, and since `loadedPaths` then loads that file exactly once, the
366
+ // other policy vanished: its id never appeared in `failproofai policies` or in
367
+ // the decision log, and — the part that matters — ITS EFFECT DECIDED NOTHING.
368
+ // An `enforce` policy sharing bytes with an `observe` one was downgraded to
369
+ // observation, which is a silent enforcement gap, and the reverse turned a
370
+ // measurement-only rollout into live denials.
371
+ //
372
+ // It could not happen before the flattening: `deployments/<n>/<id>.mjs` gave
373
+ // every policy its own path even when the bytes matched.
374
+ //
375
+ // One module instance is right — `customPolicies.add` is an unconditional
376
+ // push, so importing the file twice would register every hook twice and run
377
+ // the policy twice per event. So the collision is resolved rather than
378
+ // avoided, and it resolves toward ENFORCEMENT: over-enforcing is visible to
379
+ // whoever hits it, while under-enforcing is exactly the silent failure this
380
+ // codebase exists to remove. It is also announced, so an operator can give the
381
+ // two policies distinguishable source instead of living with the merge.
382
+ const cloudManagedByPath = new Map<string, CloudManagedPolicyArtifact>();
383
+ for (const policy of opts?.cloudManagedPolicies ?? []) {
384
+ const key = resolve(policy.path);
385
+ const existing = cloudManagedByPath.get(key);
386
+ if (!existing) {
387
+ cloudManagedByPath.set(key, policy);
388
+ continue;
389
+ }
390
+ hookLogWarn(
391
+ `cloud-managed policies ${existing.id} and ${policy.id} have identical source, so they share ` +
392
+ `one artifact and load as one policy; enforcing it if either asks to enforce`,
393
+ );
394
+ if (existing.effect !== "enforce" && policy.effect === "enforce") {
395
+ cloudManagedByPath.set(key, policy);
396
+ }
397
+ }
364
398
 
365
399
  // 1. Explicit custom policy paths. Accept a string for callers/configs using
366
400
  // the legacy singular form.
@@ -387,7 +421,7 @@ export async function loadAllCustomHooks(
387
421
  };
388
422
  if (cloudManaged) {
389
423
  tagged.__cloudManaged = cloudManaged;
390
- tagged.__policyId = `cloud:${cloudManaged.id}@${cloudManaged.revision}:${hook.name}`;
424
+ tagged.__policyId = `cloud:${cloudManaged.id}@${cloudManaged.version}:${hook.name}`;
391
425
  } else {
392
426
  tagged.__policyId = customPolicyId(absPath, hook.name);
393
427
  }
@@ -415,9 +449,24 @@ export async function loadAllCustomHooks(
415
449
  // leaks one file permanently rather than leaving one that the next load
416
450
  // overwrites. Best-effort and age-gated, so it can never remove a tree
417
451
  // another process is still importing.
452
+ // WHICH SCOPE this directory is depends on whether it IS the user directory.
453
+ // Run an agent CLI with the cwd set to $HOME and `<cwd>/.failproofai/policies`
454
+ // resolves to `~/.failproofai/policies` — the user scope itself. Layout 3 made
455
+ // that reachable by collapsing `customPoliciesDir()` onto `policiesDir()`;
456
+ // layout 2 nested the user directory one level deeper, so the two could never
457
+ // be the same path.
458
+ //
459
+ // Tagging it `project` there would make a policy's id depend on where the
460
+ // agent happened to be launched from — `convention:project:x-policies.mjs:foo`
461
+ // from $HOME and `convention:user:x-policies.mjs:foo` from anywhere else — and
462
+ // a disable is RECORDED AGAINST THAT ID. So a user who switched a policy off
463
+ // would find it firing again whenever they worked out of their home
464
+ // directory, with nothing to explain it. The files are the user's global
465
+ // policies; that the cwd also points at them is incidental.
466
+ const projectScope: "project" | "user" = projectDir === customPoliciesDir() ? "user" : "project";
418
467
  if (conventionEnabled) {
419
468
  void sweepStaleTmpArtifacts(projectDir);
420
- warnSkippedPolicyFiles(projectDir, "project");
469
+ warnSkippedPolicyFiles(projectDir, projectScope);
421
470
  }
422
471
  // The shim directory leaks the same way and is swept on the same terms. It is
423
472
  // ours alone and outside the policy tree, so nothing else would ever reap it;
@@ -430,14 +479,14 @@ export async function loadAllCustomHooks(
430
479
  for (const file of projectFiles) {
431
480
  loadedPaths.add(file);
432
481
  const hooksBefore = getCustomHooks().length;
433
- await loadSingleFile(file, { conventionScope: "project" });
482
+ await loadSingleFile(file, { conventionScope: projectScope });
434
483
  const newHooks = getCustomHooks().slice(hooksBefore);
435
484
  for (const hook of newHooks) {
436
- (hook as CustomHook & { __policyId?: string }).__policyId = conventionPolicyId("project", basename(file), hook.name);
485
+ (hook as CustomHook & { __policyId?: string }).__policyId = conventionPolicyId(projectScope, basename(file), hook.name);
437
486
  }
438
487
  if (newHooks.length > 0) {
439
488
  conventionSources.push({
440
- scope: "project",
489
+ scope: projectScope,
441
490
  file: basename(file),
442
491
  hookNames: newHooks.map((h) => h.name),
443
492
  });
@@ -1036,25 +1036,80 @@ export async function probeDaemon(): Promise<DaemonProbe> {
1036
1036
  }
1037
1037
  }
1038
1038
 
1039
+ /**
1040
+ * "Is anything listening on the daemon socket?" — never throws.
1041
+ *
1042
+ * Split out because `waitForDaemonRunning` needs the connect check WITHOUT the
1043
+ * hook evaluation: it runs inside a settle loop, and a failed import or a
1044
+ * transient refusal there must read as "not yet", never as an error.
1045
+ */
1046
+ async function daemonAcceptsConnectionsQuietly(): Promise<boolean> {
1047
+ try {
1048
+ const { daemonAcceptsConnections } = await import("./daemon-client");
1049
+ return await daemonAcceptsConnections();
1050
+ } catch {
1051
+ return false;
1052
+ }
1053
+ }
1054
+
1039
1055
  /** Boolean form, for callers that only branch on healthy/not. */
1040
1056
  export async function probeDaemonEndToEnd(): Promise<boolean> {
1041
1057
  return (await probeDaemon()).ok;
1042
1058
  }
1043
1059
 
1044
1060
  /**
1045
- * Waits for the service to report running, then re-checks after a settle
1046
- * window (see `SERVICE_SETTLE_MS`) so a daemon that dies at startup doesn't
1047
- * pass on the strength of one optimistic reading.
1061
+ * Waits for the service to report running and to HOLD it — a `Type=simple` unit
1062
+ * is active the moment it forks, so one optimistic reading passes a daemon that
1063
+ * died at startup.
1064
+ *
1065
+ * WATCHED, not slept through. This used to sleep `SERVICE_SETTLE_MS` blind and
1066
+ * read the status once at the end, which was wrong in both directions on a
1067
+ * healthy machine and a broken one:
1068
+ *
1069
+ * • Healthy: the socket is up in ~13ms and answers a real hook in ~125ms, and
1070
+ * setup still sat there for the remaining ~600ms with the answer already in
1071
+ * hand. Setup runs this twice on the repair path (uninstall, reinstall), so
1072
+ * it was over a second of dead wait every time.
1073
+ * • Broken: a daemon that died at 100ms was not noticed until 750ms, because
1074
+ * nothing looked until the sleep was over.
1075
+ *
1076
+ * Now the window is polled. Leaving `running` at any point fails immediately,
1077
+ * and the wait ends early once the daemon has answered a real hook — a reply is
1078
+ * strictly stronger evidence of "did not die at startup" than "still active
1079
+ * after an arbitrary sleep", which is all the settle ever established.
1048
1080
  */
1049
- async function waitForDaemonRunning(): Promise<boolean> {
1050
- const deadline = Date.now() + SERVICE_START_TIMEOUT_MS;
1081
+ export interface WaitForDaemonDeps {
1082
+ status?: () => DaemonServiceStatus;
1083
+ accepts?: () => Promise<boolean>;
1084
+ sleep?: (ms: number) => Promise<void>;
1085
+ now?: () => number;
1086
+ }
1087
+
1088
+ export async function waitForDaemonRunning(deps: WaitForDaemonDeps = {}): Promise<boolean> {
1089
+ const status = deps.status ?? daemonServiceStatus;
1090
+ const accepts = deps.accepts ?? daemonAcceptsConnectionsQuietly;
1091
+ const sleep = deps.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
1092
+ const now = deps.now ?? Date.now;
1093
+
1094
+ const deadline = now() + SERVICE_START_TIMEOUT_MS;
1095
+ for (;;) {
1096
+ if (status() === "running") break;
1097
+ if (now() >= deadline) return false;
1098
+ await sleep(SERVICE_START_POLL_MS);
1099
+ }
1100
+
1101
+ const settleUntil = now() + SERVICE_SETTLE_MS;
1051
1102
  for (;;) {
1052
- if (daemonServiceStatus() === "running") break;
1053
- if (Date.now() >= deadline) return false;
1054
- await new Promise((r) => setTimeout(r, SERVICE_START_POLL_MS));
1103
+ // A unit that has left `running` is dead now; there is nothing to wait out.
1104
+ if (status() !== "running") return false;
1105
+ // A daemon that accepted a connection is a daemon that got past startup and
1106
+ // bound its socket. Deliberately the CHEAP check, not a full hook
1107
+ // evaluation: `probeDaemon` runs the end-to-end one moments later, and
1108
+ // paying for it twice is what this rewrite exists to stop.
1109
+ if (await accepts()) return true;
1110
+ if (now() >= settleUntil) return true;
1111
+ await sleep(SERVICE_START_POLL_MS);
1055
1112
  }
1056
- await new Promise((r) => setTimeout(r, SERVICE_SETTLE_MS));
1057
- return daemonServiceStatus() === "running";
1058
1113
  }
1059
1114
 
1060
1115
  // ── Upgrading a service definition that predates a variable ──────────────────
@@ -1125,7 +1180,7 @@ function installedExecStart(definition: string): string | null {
1125
1180
  * True when a service IS installed but its definition predates
1126
1181
  * `FAILPROOFAI_CLI_CMD`.
1127
1182
  *
1128
- * Deliberately a content check against the definition on disk, not a revision
1183
+ * Deliberately a content check against the definition on disk, not a version
1129
1184
  * number mirrored into `config.toml`. The mirror is the tempting shape — it is
1130
1185
  * how `daemon.installed_version` is modelled — but that field is declared,
1131
1186
  * read and cleared and has never once been WRITTEN (both `setDaemonConfigured`
@@ -0,0 +1,180 @@
1
+ /**
2
+ * `failproofai flush` — deliver what is already spooled, now.
3
+ *
4
+ * The collector is built to be unhurried on purpose. A batch is swept only once
5
+ * it is older than two minutes, at most 64 per pass, on a 60-second cadence —
6
+ * pacing that keeps a backlog from stampeding a server and is exactly wrong for
7
+ * one case: somebody standing at a dashboard waiting to see their own events.
8
+ * They have no way to tell "not delivered yet" from "not working", and the
9
+ * honest answer is usually the first one.
10
+ *
11
+ * WHY A REQUEST FILE, not the work itself. The uploader's concurrency limiter
12
+ * and in-flight set live in the running daemon. A second uploader started by
13
+ * the CLI would share neither, so both would POST the same batch — the exact
14
+ * double-delivery `delivery.rs` keeps one shared `Delivery` to prevent. So this
15
+ * writes a request the daemon drains on its next tick.
16
+ *
17
+ * What it does NOT hand off is the checking — same rule as `backfill-cli.ts`.
18
+ * Every precondition a person can get wrong is verified here, synchronously,
19
+ * before returning success. A CLI that prints "requested" while the daemon is
20
+ * stopped has told the user the opposite of what happened.
21
+ */
22
+ import { existsSync, mkdirSync, writeFileSync, readdirSync } from "node:fs";
23
+ import { dirname, join } from "node:path";
24
+ import { failproofaiHome, spoolDir } from "./fp-home";
25
+ import { readConfig } from "./fp-config";
26
+ import { readIngestCredential } from "./collector-config";
27
+ import { daemonServiceStatus, isDaemonSupportedPlatform } from "./daemon-service";
28
+
29
+ /** How long `--wait` blocks by default. */
30
+ export const DEFAULT_FLUSH_TIMEOUT_SECS = 60;
31
+
32
+ export interface FlushOptions {
33
+ wait?: boolean;
34
+ timeoutSecs?: number;
35
+ home?: string;
36
+ /** Injected in tests so the wait loop does not really sleep. */
37
+ sleep?: (ms: number) => Promise<void>;
38
+ }
39
+
40
+ export interface FlushResult {
41
+ exitCode: number;
42
+ lines: string[];
43
+ /** Batches still spooled when we stopped looking. */
44
+ pending: number;
45
+ }
46
+
47
+ /** Mirrored from the daemon's `paths::flush_request_path()`. */
48
+ export function flushRequestPath(home?: string): string {
49
+ return join(failproofaiHome(home), "state", "flush-request.json");
50
+ }
51
+
52
+ /** Every directory the collector spools batches into. */
53
+ function spoolDirs(home?: string): string[] {
54
+ const root = spoolDir(home);
55
+ if (!existsSync(root)) return [];
56
+ try {
57
+ return readdirSync(root, { withFileTypes: true })
58
+ .filter((e) => e.isDirectory() && e.name !== "failed")
59
+ .map((e) => join(root, e.name));
60
+ } catch {
61
+ return [];
62
+ }
63
+ }
64
+
65
+ /** Batches awaiting delivery. `.tmp` files are half-written and not counted. */
66
+ export function pendingBatches(home?: string): number {
67
+ let n = 0;
68
+ for (const dir of spoolDirs(home)) {
69
+ try {
70
+ n += readdirSync(dir).filter((f) => f.endsWith(".jsonl")).length;
71
+ } catch {
72
+ // A directory that vanished mid-scan is one the collector just drained.
73
+ }
74
+ }
75
+ return n;
76
+ }
77
+
78
+ export async function runFlushCommand(opts: FlushOptions = {}): Promise<FlushResult> {
79
+ const { home, wait = false } = opts;
80
+ const timeoutSecs = opts.timeoutSecs ?? DEFAULT_FLUSH_TIMEOUT_SECS;
81
+ const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
82
+ const lines: string[] = [];
83
+
84
+ // Preconditions, in the order that produces the most useful message. Each of
85
+ // these makes a flush a no-op, and each has a different remedy.
86
+ const cfg = readConfig();
87
+ if (!cfg.collector?.hooks && !cfg.collector?.sessions) {
88
+ return {
89
+ exitCode: 1,
90
+ pending: 0,
91
+ lines: [
92
+ "Collection is off, so nothing is spooled to flush.",
93
+ "Turn it on with `failproofai config`.",
94
+ ],
95
+ };
96
+ }
97
+
98
+ if (!readIngestCredential()) {
99
+ return {
100
+ exitCode: 1,
101
+ pending: 0,
102
+ lines: [
103
+ "This machine is not connected, so there is nowhere to flush to.",
104
+ "Connect it with `failproofai config`.",
105
+ ],
106
+ };
107
+ }
108
+
109
+ // Nothing on an unsupported platform will ever read the request file, so
110
+ // writing one and reporting success is a lie the user cannot detect: the
111
+ // command exits 0, the batches stay exactly where they were, and the only
112
+ // symptom is data that never arrives.
113
+ //
114
+ // Refusing here matches what `failproofai config` now does on the same
115
+ // platforms rather than completing unenforced — the two commands should not
116
+ // disagree about whether this machine has a daemon.
117
+ if (!isDaemonSupportedPlatform()) {
118
+ return {
119
+ exitCode: 1,
120
+ pending: pendingBatches(home),
121
+ lines: [
122
+ `failproofaid does not run on ${process.platform}, and it is what delivers batches.`,
123
+ "Nothing would pick this request up, so nothing was written.",
124
+ ],
125
+ };
126
+ }
127
+
128
+ const status = daemonServiceStatus();
129
+ if (status !== "running") {
130
+ return {
131
+ exitCode: 1,
132
+ pending: pendingBatches(home),
133
+ lines: [
134
+ `failproofaid is ${status}, and it is what delivers batches.`,
135
+ "Start it with `failproofai config`.",
136
+ ],
137
+ };
138
+ }
139
+
140
+ const before = pendingBatches(home);
141
+ if (before === 0) {
142
+ return { exitCode: 0, pending: 0, lines: ["Nothing spooled — everything already delivered."] };
143
+ }
144
+
145
+ const path = flushRequestPath(home);
146
+ mkdirSync(dirname(path), { recursive: true });
147
+ writeFileSync(path, `${JSON.stringify({ requestedAtMs: Date.now() }, null, 2)}\n`, {
148
+ mode: 0o600,
149
+ });
150
+
151
+ lines.push(`${before} batch${before === 1 ? "" : "es"} spooled. Requested delivery.`);
152
+
153
+ if (!wait) {
154
+ lines.push("The daemon picks this up within a few seconds.");
155
+ return { exitCode: 0, pending: before, lines };
156
+ }
157
+
158
+ // `--wait` exists so a script can flush and then assert. Poll the spool
159
+ // rather than the server: the question is whether THIS machine still holds
160
+ // events, and the spool is the only place that can answer it locally.
161
+ const deadline = Date.now() + timeoutSecs * 1000;
162
+ let pending = before;
163
+ while (Date.now() < deadline) {
164
+ await sleep(1000);
165
+ pending = pendingBatches(home);
166
+ if (pending === 0) {
167
+ lines.push("Spool drained.");
168
+ return { exitCode: 0, pending: 0, lines };
169
+ }
170
+ }
171
+
172
+ // A timeout is not necessarily a failure — a large backlog legitimately takes
173
+ // longer than the budget — so say what is still outstanding rather than
174
+ // calling it broken.
175
+ lines.push(
176
+ `Still ${pending} batch${pending === 1 ? "" : "es"} spooled after ${timeoutSecs}s.`,
177
+ "Delivery continues in the background; re-run with a longer --timeout to keep watching.",
178
+ );
179
+ return { exitCode: 1, pending, lines };
180
+ }