agentwrangler 0.1.1-next.8be2eb0 → 0.1.1-next.adabfa3

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 (82) hide show
  1. package/dist/apply/jobs.js +7 -1
  2. package/dist/daemon/effect-pass.js +24 -0
  3. package/dist/daemon/http.js +10 -0
  4. package/dist/daemon/index.js +48 -87
  5. package/dist/daemon/lifecycle.js +122 -0
  6. package/dist/daemon/router.js +296 -14
  7. package/dist/db/migrations/018_effect_cycles.sql +109 -0
  8. package/dist/db/migrations/019_work_records.sql +291 -0
  9. package/dist/detector/context-history-retention.js +49 -0
  10. package/dist/detector/measurement.js +11 -1
  11. package/dist/effects/api-contract.js +1 -0
  12. package/dist/effects/engine.js +574 -0
  13. package/dist/effects/gate.js +2 -0
  14. package/dist/effects/index.js +5 -0
  15. package/dist/effects/legacy.js +28 -0
  16. package/dist/effects/registry.js +152 -0
  17. package/dist/effects/sql-observer.js +238 -0
  18. package/dist/effects/store.js +181 -0
  19. package/dist/effects/types.js +2 -0
  20. package/dist/ingest/ingestor.js +322 -73
  21. package/dist/ingest/tail.js +130 -20
  22. package/dist/query/api/cost-per-success.js +13 -1
  23. package/dist/query/api/delivery.js +25 -3
  24. package/dist/query/api/effect-service.js +308 -0
  25. package/dist/query/api/effectiveness.js +30 -5
  26. package/dist/query/api/efficiency-headroom.js +12 -14
  27. package/dist/query/api/esf-observations.js +285 -0
  28. package/dist/query/api/overview.js +92 -3
  29. package/dist/query/api/recommendations-ledger.js +9 -14
  30. package/dist/query/api/recommendations.js +21 -1
  31. package/dist/query/api/reported-work.js +46 -0
  32. package/dist/query/api/spend-flavor.js +22 -15
  33. package/dist/query/api/trends.js +86 -6
  34. package/dist/query/api/work-records.js +85 -0
  35. package/dist/query/settings-store.js +13 -0
  36. package/dist/query/spend.js +91 -15
  37. package/dist/query/trends.js +27 -0
  38. package/dist/ui/assets/{BarChart-VQGcJCLY.js → BarChart-BOecTQgC.js} +1 -1
  39. package/dist/ui/assets/{BriefsPage-CmhxCHLf.js → BriefsPage-CjvXspiw.js} +1 -1
  40. package/dist/ui/assets/{CacheWriteSpikesChart-C641X3kK.js → CacheWriteSpikesChart-5Pn1sUZC.js} +1 -1
  41. package/dist/ui/assets/{CartesianChart-BTva-_b9.js → CartesianChart-56rkZQt4.js} +1 -1
  42. package/dist/ui/assets/{Chip-Gem94sHD.js → Chip-BZTcuGOm.js} +1 -1
  43. package/dist/ui/assets/{ComposedChart-CS5SgLoo.js → ComposedChart-C8PPdkFl.js} +1 -1
  44. package/dist/ui/assets/{EmptyState-Dhyw1Dvj.js → EmptyState-BfD86Pv3.js} +1 -1
  45. package/dist/ui/assets/{FlavorDecomposition-Dle5qGWA.js → FlavorDecomposition-BsOuw4CF.js} +1 -1
  46. package/dist/ui/assets/FrictionCell-DQBJsot-.js +2 -0
  47. package/dist/ui/assets/{GlossaryPage-DkZXoJTT.js → GlossaryPage-DutLIAlv.js} +1 -1
  48. package/dist/ui/assets/HotSessionsPage-DLWlyBfb.js +1 -0
  49. package/dist/ui/assets/{InfoTip-DCQtERnJ.js → InfoTip-BrYyvCNl.js} +1 -1
  50. package/dist/ui/assets/{Legend-SbbUv9Nm.js → Legend-DzSAmt8-.js} +1 -1
  51. package/dist/ui/assets/{Line-Dvv4aBXw.js → Line-1eksYo3m.js} +1 -1
  52. package/dist/ui/assets/Modal-DX-JN5Ul.js +1 -0
  53. package/dist/ui/assets/ObservationEvidence-Bbsc-DCW.js +1 -0
  54. package/dist/ui/assets/ObservationEvidence-Cy92xibu.css +1 -0
  55. package/dist/ui/assets/OverviewPage-CWgzRIqk.js +2 -0
  56. package/dist/ui/assets/RecommendationsPage-sRHEku70.js +2 -0
  57. package/dist/ui/assets/{Scatter-xyJ9miNg.js → Scatter-Ys7Lbhjf.js} +1 -1
  58. package/dist/ui/assets/SessionDetailPage-L02ituTQ.js +1 -0
  59. package/dist/ui/assets/{SettingsPage-DWfbUCe8.js → SettingsPage-DJjWicvx.js} +4 -4
  60. package/dist/ui/assets/{Skeleton-D5lrQpCm.js → Skeleton-ab1u7ntu.js} +1 -1
  61. package/dist/ui/assets/{SpendPercentileChip-C3hfiYOG.js → SpendPercentileChip-DnRJWBUP.js} +1 -1
  62. package/dist/ui/assets/{TrendChart-B8RRiXUW.js → TrendChart-_nohASRq.js} +1 -1
  63. package/dist/ui/assets/WorkspaceDetailPage-BQ3XA3Hf.js +1 -0
  64. package/dist/ui/assets/{WorkspacesPage-BI0AvjU4.js → WorkspacesPage-DKdpBgC9.js} +1 -1
  65. package/dist/ui/assets/{chart-theme-F_Y5-T0C.js → chart-theme-0POFlal8.js} +1 -1
  66. package/dist/ui/assets/{graphicalItemSelectors-XzBS96LG.js → graphicalItemSelectors-B97PsJDp.js} +1 -1
  67. package/dist/ui/assets/{index-DWsa7LPr.js → index-DAAmee2m.js} +10 -10
  68. package/dist/ui/assets/{useExperimentalActions-4CyCEvGU.js → useExperimentalActions-avkWaDmQ.js} +1 -1
  69. package/dist/ui/index.html +1 -1
  70. package/dist/work-records/allocation.js +242 -0
  71. package/dist/work-records/ids.js +80 -0
  72. package/dist/work-records/index.js +4 -0
  73. package/dist/work-records/store.js +376 -0
  74. package/dist/work-records/types.js +29 -0
  75. package/dist/work-records/validation.js +71 -0
  76. package/package.json +2 -1
  77. package/dist/ui/assets/FrictionCell-BN-7hRQU.js +0 -2
  78. package/dist/ui/assets/HotSessionsPage-C_hD8WUr.js +0 -1
  79. package/dist/ui/assets/OverviewPage-Blab_aYw.js +0 -2
  80. package/dist/ui/assets/RecommendationsPage-DmguRb6u.js +0 -2
  81. package/dist/ui/assets/SessionDetailPage-kcGm5Cph.js +0 -1
  82. package/dist/ui/assets/WorkspaceDetailPage-DDb6M8cK.js +0 -1
@@ -302,7 +302,10 @@ function completeApplyPhase(db, job, rec, result, rt) {
302
302
  let runId = null;
303
303
  const completedAt = rt.now();
304
304
  try {
305
- adoptRecommendation(job.rec_id, completedAt.getTime());
305
+ adoptRecommendation(job.rec_id, completedAt.getTime(), {
306
+ machineConfirmed: true,
307
+ actionRevision: job.job_id,
308
+ });
306
309
  runId = randomUUID();
307
310
  const evidencePackHash = createHash("sha256")
308
311
  .update(JSON.stringify(rec.evidence))
@@ -417,6 +420,9 @@ export function rollbackApplyJob(jobId) {
417
420
  if (row.status !== "APPLIED" && row.status !== "CONFIRMING") {
418
421
  throw new Error("job cannot be rolled back from this state");
419
422
  }
423
+ if (db.prepare("SELECT 1 FROM effect_cycles WHERE rec_id=? LIMIT 1").get(row.rec_id)) {
424
+ throw new Error("Whole-file backup rollback is not a supported effect inverse. Revert manually and attest the rollback in the effect ledger.");
425
+ }
420
426
  if (row.backup_path === null)
421
427
  throw new Error("no backup available");
422
428
  fs.copyFileSync(row.backup_path, row.file_ref);
@@ -0,0 +1,24 @@
1
+ import { runMeasurementPass } from "../detector/measurement.js";
2
+ import { ESF_EFFECT_WRITER_ENABLED } from "../effects/gate.js";
3
+ import { effectEngineForDb } from "../query/api/effect-service.js";
4
+ const INTERVAL_MS = 60 * 60 * 1000;
5
+ const lastPass = new WeakMap();
6
+ /** Probe-clock scheduling; restarting can safely replay the bounded, idempotent engine pass. */
7
+ export function runEffectMeasurementPass(db, now) {
8
+ if (!ESF_EFFECT_WRITER_ENABLED) {
9
+ runMeasurementPass(db, now);
10
+ return;
11
+ }
12
+ const previous = lastPass.get(db);
13
+ const current = now.getTime();
14
+ if (previous !== undefined && current >= previous && current - previous < INTERVAL_MS)
15
+ return;
16
+ lastPass.set(db, current);
17
+ try {
18
+ effectEngineForDb(db).runPass(now);
19
+ }
20
+ catch {
21
+ // Never expose source identities or DB contents in a background error.
22
+ console.warn("Effect measurement pass failed; retrying on the next scheduled pass.");
23
+ }
24
+ }
@@ -138,6 +138,16 @@ function requiresSessionToken(pathname) {
138
138
  return true;
139
139
  if (/^\/api\/recommendations\/jobs\/[^/]+\/(confirm|rollback)$/.test(pathname))
140
140
  return true;
141
+ if (/^\/api\/esf\/effects\/(track|stop|close|rollback|attest-rollback)$/.test(pathname))
142
+ return true;
143
+ if (pathname === "/api/work-records" || pathname === "/api/work-records/ids")
144
+ return true;
145
+ if (/^\/api\/work-records\/[^/]+\/(edit|closeout|archive|reopen|sessions|contexts|delete)$/.test(pathname))
146
+ return true;
147
+ if (/^\/api\/work-records\/[^/]+\/(sessions|contexts)\/[^/]+\/detach$/.test(pathname))
148
+ return true;
149
+ if (pathname === "/api/work-records/allocations/recompute")
150
+ return true;
141
151
  return false;
142
152
  }
143
153
  /**
@@ -22,7 +22,6 @@ import { openDb } from "../db/open.js";
22
22
  import { configGet as bptConfigGet, calibrateBytesPerToken } from "../detector/calibration.js";
23
23
  import { runContextProbe } from "../detector/context-probe.js";
24
24
  import { runDetectors } from "../detector/index.js";
25
- import { runMeasurementPass } from "../detector/measurement.js";
26
25
  import { installHook, uninstallHook } from "../hook/install.js";
27
26
  import { collectSessionChurn } from "../ingest/churn-collector.js";
28
27
  import { runPostProbeHook, setPostIngestHook, setPostProbeHook } from "../ingest/detector-hook.js";
@@ -37,7 +36,9 @@ import { generateWeeklyReport } from "../query/api/reports.js";
37
36
  import { setQueryDb } from "../query/db-context.js";
38
37
  import { getSettingsData, setHealthInstance, setRuntimeResetHook, } from "../query/settings-store.js";
39
38
  import { loadConfig } from "./config.js";
39
+ import { runEffectMeasurementPass } from "./effect-pass.js";
40
40
  import { createServer } from "./http.js";
41
+ import { createDaemonLifecycle } from "./lifecycle.js";
41
42
  import { createOutcomesPassRunner } from "./outcomes-pass.js";
42
43
  import { setReady, setScanRoots, setScanState } from "./readiness.js";
43
44
  const VERSION = "0.1.0";
@@ -107,6 +108,10 @@ if (isSmoke) {
107
108
  "review_findings",
108
109
  "recommendations",
109
110
  "recommendation_effects",
111
+ "effect_cycles",
112
+ "effect_guardrail_results",
113
+ "effect_rollback_operations",
114
+ "effect_mutation_keys",
110
115
  "apply_jobs",
111
116
  "analysis_runs",
112
117
  "ingest_quarantine",
@@ -150,18 +155,16 @@ function runProbePass(label) {
150
155
  const now = new Date();
151
156
  const { rows } = runContextProbe(db, now);
152
157
  console.log(`ContextProbe[${label}]: ${rows} row(s) upserted`);
153
- // W4: measurement pass after each probe (best-effort; the seam never throws
154
- // through, and runMeasurementPass itself is guarded/log-not-throw).
158
+ // Best-effort measurement after each probe, using the same observation clock.
155
159
  runPostProbeHook(db, now);
156
160
  }
157
161
  catch (e) {
158
162
  console.log(`ContextProbe[${label}]: skipped — ${e instanceof Error ? e.message : String(e)}`);
159
163
  }
160
164
  }
161
- // Wire the W4 Impact Ledger measurement pass onto the post-probe seam. The pass
162
- // receives the probe's clock (deterministic; NFR-107), is throttled internally.
165
+ // Versioned measurement owns new cycles; W4 history stays read-only under the code gate.
163
166
  setPostProbeHook((probeDb, now) => {
164
- runMeasurementPass(probeDb, now);
167
+ runEffectMeasurementPass(probeDb, now);
165
168
  });
166
169
  runProbePass("boot");
167
170
  // ── Outcomes bootstrap (WP5 — best-effort, never crash daemon) ────────────────
@@ -169,10 +172,6 @@ runProbePass("boot");
169
172
  // The runner (src/daemon/outcomes-pass.ts) adds a hard 15-min per-pass deadline:
170
173
  // a hung pass can no longer wedge the poll cadence forever — the running flag
171
174
  // always clears and the next poll resumes.
172
- let outcomesTimer = null;
173
- let reportsTimer = null;
174
- let reportsRunning = false;
175
- let bptCalibrationTimer = null;
176
175
  const outcomesRunner = createOutcomesPassRunner({
177
176
  db,
178
177
  probe: () => runProbePass("poll"),
@@ -186,29 +185,31 @@ const outcomesRunner = createOutcomesPassRunner({
186
185
  function runOutcomesPass() {
187
186
  return outcomesRunner.run();
188
187
  }
188
+ // R12 bytes→token calibration — best-effort, never crash the daemon.
189
+ // Runs on boot (deferred) and weekly when: opt-in enabled AND ratio is absent
190
+ // or older than 30 days. Mirrors the outcomes bootstrap pattern.
191
+ const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
192
+ function runBptCalibrationIfStale() {
193
+ const enabled = bptConfigGet(db, "bytes_per_token_calibration_enabled");
194
+ if (enabled !== "true")
195
+ return;
196
+ const measuredAt = bptConfigGet(db, "bytes_per_token_measured_at");
197
+ if (measuredAt !== null && Date.now() - new Date(measuredAt).getTime() < THIRTY_DAYS_MS)
198
+ return;
199
+ calibrateBytesPerToken(db).catch((e) => {
200
+ console.error(`Bytes-per-token calibration failed (non-fatal): ${e instanceof Error ? e.message : String(e)}`);
201
+ });
202
+ }
189
203
  // ── Deferred boot scan (runs AFTER the HTTP port is bound) ───────────────────
190
204
  // startTailBatched() performs a yielding initial back-scan (ingestAllKnown +
191
205
  // reconcile), then arms the tail/discovery timers. We do NOT
192
206
  // also call runBackscan — that would spin up a second Ingestor whose separate
193
207
  // Health instance is the one surfaced in Settings. One Ingestor, one Health.
194
208
  // Bounded: if ingestion fails to start, still serve the dashboard (degraded).
195
- let handle = null;
196
- // One-shot guard: runBootScan executes exactly once regardless of how many
197
- // requests or timers fire kickBootScan concurrently.
198
- let scanKicked = false;
199
- function kickBootScan() {
200
- if (scanKicked)
201
- return;
202
- scanKicked = true;
203
- // Use setImmediate so the triggering HTTP response flushes before boot work
204
- // starts. The scan itself also yields between bounded batches.
205
- setImmediate(() => {
206
- runBootScan().catch((e) => {
207
- console.error(`Boot scan failed: ${e instanceof Error ? e.message : String(e)}`);
208
- });
209
- });
210
- }
209
+ // The one-shot kick guard, the daemon-owned intervals, and shutdown ordering
210
+ // live in the lifecycle harness (src/daemon/lifecycle.ts).
211
211
  async function runBootScan() {
212
+ let handle = null;
212
213
  try {
213
214
  const scanRoots = getSettingsData(db).scan_roots;
214
215
  setScanRoots(scanRoots);
@@ -261,21 +262,8 @@ async function runBootScan() {
261
262
  console.error(`Self-churn collection failed (non-fatal): ${e instanceof Error ? e.message : String(e)}`);
262
263
  }
263
264
  });
264
- // R12 bytes→token calibration — best-effort, never crash the daemon.
265
- // Runs on boot (deferred) and weekly when: opt-in enabled AND ratio is absent
266
- // or older than 30 days. Mirrors the outcomes bootstrap pattern.
267
- const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
268
- function runBptCalibrationIfStale() {
269
- const enabled = bptConfigGet(db, "bytes_per_token_calibration_enabled");
270
- if (enabled !== "true")
271
- return;
272
- const measuredAt = bptConfigGet(db, "bytes_per_token_measured_at");
273
- if (measuredAt !== null && Date.now() - new Date(measuredAt).getTime() < THIRTY_DAYS_MS)
274
- return;
275
- calibrateBytesPerToken(db).catch((e) => {
276
- console.error(`Bytes-per-token calibration failed (non-fatal): ${e instanceof Error ? e.message : String(e)}`);
277
- });
278
- }
265
+ // Deferred boot calibration check (the weekly interval is armed by the
266
+ // lifecycle harness after this scan resolves).
279
267
  setImmediate(() => {
280
268
  try {
281
269
  runBptCalibrationIfStale();
@@ -284,42 +272,31 @@ async function runBootScan() {
284
272
  console.error(`Bytes-per-token calibration boot failed (non-fatal): ${e instanceof Error ? e.message : String(e)}`);
285
273
  }
286
274
  });
287
- bptCalibrationTimer = setInterval(() => {
288
- try {
289
- runBptCalibrationIfStale();
290
- }
291
- catch (e) {
292
- console.error(`Bytes-per-token calibration poll failed (non-fatal): ${e instanceof Error ? e.message : String(e)}`);
293
- }
294
- }, 7 * 24 * 60 * 60 * 1000);
295
- // 10-minute poll — guarded by outcomesRunning to prevent overlap
296
- outcomesTimer = setInterval(() => {
275
+ return handle;
276
+ }
277
+ // ── Lifecycle harness: boot-kick guard, daemon intervals, shutdown ordering ──
278
+ // closeServer closes over `server` (declared below); shutdown only ever runs
279
+ // after listen, so the reference is initialized by then.
280
+ const lifecycle = createDaemonLifecycle({
281
+ bootScan: runBootScan,
282
+ calibrationTick: runBptCalibrationIfStale,
283
+ outcomesTick: () => {
297
284
  runOutcomesPass().catch((e) => {
298
285
  console.error(`Outcomes poll failed: ${e instanceof Error ? e.message : String(e)}`);
299
286
  });
300
- }, 10 * 60 * 1000);
301
- reportsTimer = setInterval(async () => {
302
- if (reportsRunning)
303
- return;
304
- reportsRunning = true;
305
- try {
306
- await Promise.resolve(generateWeeklyReport(db, new Date()));
307
- }
308
- catch (e) {
309
- console.error(`Weekly report poll failed: ${e instanceof Error ? e.message : String(e)}`);
310
- }
311
- finally {
312
- reportsRunning = false;
313
- }
314
- }, 7 * 24 * 60 * 60 * 1000);
315
- }
287
+ },
288
+ reportsTick: () => generateWeeklyReport(db, new Date()),
289
+ closeServer: (onClosed) => server.close(onClosed),
290
+ closeDb: () => db.close(),
291
+ onClosed: () => process.exit(0),
292
+ });
316
293
  // ── 4. Start HTTP server ──────────────────────────────────────────────────────
317
294
  // Bind the port BEFORE the back-scan so the loading page is delivered
318
295
  // immediately while the initial scan yields between file batches.
319
296
  // Generate a random in-memory session token for the CSRF token gate.
320
297
  // NEVER log it, NEVER persist it — it lives only in process memory.
321
298
  const sessionToken = randomUUID();
322
- const server = createServer(db, config.port, config.uiRoot, sessionToken, kickBootScan);
299
+ const server = createServer(db, config.port, config.uiRoot, sessionToken, lifecycle.kickBootScan);
323
300
  server.listen(config.port, "127.0.0.1", () => {
324
301
  const url = `http://127.0.0.1:${config.port}`;
325
302
  console.log(`Listening on ${url}`);
@@ -332,7 +309,7 @@ server.listen(config.port, "127.0.0.1", () => {
332
309
  // Fallback: kick the back-scan after 3 s even if no browser request arrives
333
310
  // (headless mode, --no-open, CI). The one-shot guard in kickBootScan() makes
334
311
  // whichever trigger fires first the only one that runs the scan.
335
- setTimeout(kickBootScan, 3000);
312
+ lifecycle.armBootFallback();
336
313
  });
337
314
  /** Open `url` in the system default browser (best-effort; never crashes the daemon). */
338
315
  function openBrowser(url) {
@@ -355,24 +332,8 @@ function openBrowser(url) {
355
332
  // ── Graceful shutdown ─────────────────────────────────────────────────────────
356
333
  process.on("SIGINT", () => {
357
334
  console.log("Shutting down…");
358
- handle?.stop();
359
- if (outcomesTimer !== null)
360
- clearInterval(outcomesTimer);
361
- if (reportsTimer !== null)
362
- clearInterval(reportsTimer);
363
- server.close(() => {
364
- db.close();
365
- process.exit(0);
366
- });
335
+ lifecycle.shutdown();
367
336
  });
368
337
  process.on("SIGTERM", () => {
369
- handle?.stop();
370
- if (outcomesTimer !== null)
371
- clearInterval(outcomesTimer);
372
- if (reportsTimer !== null)
373
- clearInterval(reportsTimer);
374
- server.close(() => {
375
- db.close();
376
- process.exit(0);
377
- });
338
+ lifecycle.shutdown();
378
339
  });
@@ -0,0 +1,122 @@
1
+ /**
2
+ * src/daemon/lifecycle.ts — daemon timer registration and graceful shutdown.
3
+ *
4
+ * Extracted from daemon/index.ts (PERF5) so the boot-kick guard, the three
5
+ * daemon-owned intervals (calibration / outcomes / weekly reports), and the
6
+ * shutdown ordering are deterministically unit-testable without booting the
7
+ * daemon. The signal handlers in index.ts stay thin: both call shutdown().
8
+ *
9
+ * Shutdown contract:
10
+ * - idempotent — a second signal is a no-op
11
+ * - clears the boot-fallback timeout so a shutdown during the 3 s window
12
+ * cannot start a scan afterwards
13
+ * - a pending boot-kick setImmediate checks the flag and never starts the
14
+ * scan after shutdown
15
+ * - when shutdown lands while the boot scan is still in flight, the timers
16
+ * are never armed and the tail handle is stopped as soon as the scan
17
+ * resolves (previously both leaked past db.close())
18
+ * - stopping the tail does NOT drain a suspended tail pass; the Ingestor's
19
+ * stopped flag makes it quit at the next file boundary (its contract)
20
+ */
21
+ const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
22
+ export function createDaemonLifecycle(deps) {
23
+ const error = deps.error ?? ((msg) => console.error(msg));
24
+ const calibrationIntervalMs = deps.calibrationIntervalMs ?? WEEK_MS;
25
+ const outcomesIntervalMs = deps.outcomesIntervalMs ?? 10 * 60 * 1000;
26
+ const reportsIntervalMs = deps.reportsIntervalMs ?? WEEK_MS;
27
+ const bootFallbackMs = deps.bootFallbackMs ?? 3000;
28
+ let scanKicked = false;
29
+ let shutDown = false;
30
+ let handle = null;
31
+ let calibrationTimer = null;
32
+ let outcomesTimer = null;
33
+ let reportsTimer = null;
34
+ let fallbackTimer = null;
35
+ let reportsRunning = false;
36
+ function armTimers() {
37
+ calibrationTimer = setInterval(() => {
38
+ try {
39
+ deps.calibrationTick();
40
+ }
41
+ catch (e) {
42
+ error(`Bytes-per-token calibration poll failed (non-fatal): ${e instanceof Error ? e.message : String(e)}`);
43
+ }
44
+ }, calibrationIntervalMs);
45
+ outcomesTimer = setInterval(() => {
46
+ try {
47
+ deps.outcomesTick();
48
+ }
49
+ catch (e) {
50
+ error(`Outcomes poll failed: ${e instanceof Error ? e.message : String(e)}`);
51
+ }
52
+ }, outcomesIntervalMs);
53
+ reportsTimer = setInterval(() => {
54
+ if (reportsRunning)
55
+ return;
56
+ reportsRunning = true;
57
+ Promise.resolve()
58
+ .then(() => deps.reportsTick())
59
+ .catch((e) => {
60
+ error(`Weekly report poll failed: ${e instanceof Error ? e.message : String(e)}`);
61
+ })
62
+ .finally(() => {
63
+ reportsRunning = false;
64
+ });
65
+ }, reportsIntervalMs);
66
+ }
67
+ function kickBootScan() {
68
+ if (scanKicked || shutDown)
69
+ return;
70
+ scanKicked = true;
71
+ // setImmediate so the triggering HTTP response flushes before boot work
72
+ // starts. The scan itself also yields between bounded batches.
73
+ setImmediate(() => {
74
+ if (shutDown)
75
+ return;
76
+ deps
77
+ .bootScan()
78
+ .then((h) => {
79
+ handle = h;
80
+ if (shutDown) {
81
+ // Shutdown landed mid-scan: never arm timers; stop the tail now.
82
+ handle?.stop();
83
+ handle = null;
84
+ return;
85
+ }
86
+ armTimers();
87
+ })
88
+ .catch((e) => {
89
+ error(`Boot scan failed: ${e instanceof Error ? e.message : String(e)}`);
90
+ });
91
+ });
92
+ }
93
+ function shutdown() {
94
+ if (shutDown)
95
+ return;
96
+ shutDown = true;
97
+ if (fallbackTimer !== null)
98
+ clearTimeout(fallbackTimer);
99
+ handle?.stop();
100
+ handle = null;
101
+ if (calibrationTimer !== null)
102
+ clearInterval(calibrationTimer);
103
+ if (outcomesTimer !== null)
104
+ clearInterval(outcomesTimer);
105
+ if (reportsTimer !== null)
106
+ clearInterval(reportsTimer);
107
+ deps.closeServer(() => {
108
+ deps.closeDb();
109
+ deps.onClosed?.();
110
+ });
111
+ }
112
+ return {
113
+ kickBootScan,
114
+ armBootFallback() {
115
+ fallbackTimer = setTimeout(kickBootScan, bootFallbackMs);
116
+ },
117
+ shutdown,
118
+ get isShutDown() {
119
+ return shutDown;
120
+ },
121
+ };
122
+ }