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.
- package/dist/apply/jobs.js +7 -1
- package/dist/daemon/effect-pass.js +24 -0
- package/dist/daemon/http.js +10 -0
- package/dist/daemon/index.js +48 -87
- package/dist/daemon/lifecycle.js +122 -0
- package/dist/daemon/router.js +296 -14
- package/dist/db/migrations/018_effect_cycles.sql +109 -0
- package/dist/db/migrations/019_work_records.sql +291 -0
- package/dist/detector/context-history-retention.js +49 -0
- package/dist/detector/measurement.js +11 -1
- package/dist/effects/api-contract.js +1 -0
- package/dist/effects/engine.js +574 -0
- package/dist/effects/gate.js +2 -0
- package/dist/effects/index.js +5 -0
- package/dist/effects/legacy.js +28 -0
- package/dist/effects/registry.js +152 -0
- package/dist/effects/sql-observer.js +238 -0
- package/dist/effects/store.js +181 -0
- package/dist/effects/types.js +2 -0
- package/dist/ingest/ingestor.js +322 -73
- package/dist/ingest/tail.js +130 -20
- package/dist/query/api/cost-per-success.js +13 -1
- package/dist/query/api/delivery.js +25 -3
- package/dist/query/api/effect-service.js +308 -0
- package/dist/query/api/effectiveness.js +30 -5
- package/dist/query/api/efficiency-headroom.js +12 -14
- package/dist/query/api/esf-observations.js +285 -0
- package/dist/query/api/overview.js +92 -3
- package/dist/query/api/recommendations-ledger.js +9 -14
- package/dist/query/api/recommendations.js +21 -1
- package/dist/query/api/reported-work.js +46 -0
- package/dist/query/api/spend-flavor.js +22 -15
- package/dist/query/api/trends.js +86 -6
- package/dist/query/api/work-records.js +85 -0
- package/dist/query/settings-store.js +13 -0
- package/dist/query/spend.js +91 -15
- package/dist/query/trends.js +27 -0
- package/dist/ui/assets/{BarChart-VQGcJCLY.js → BarChart-BOecTQgC.js} +1 -1
- package/dist/ui/assets/{BriefsPage-CmhxCHLf.js → BriefsPage-CjvXspiw.js} +1 -1
- package/dist/ui/assets/{CacheWriteSpikesChart-C641X3kK.js → CacheWriteSpikesChart-5Pn1sUZC.js} +1 -1
- package/dist/ui/assets/{CartesianChart-BTva-_b9.js → CartesianChart-56rkZQt4.js} +1 -1
- package/dist/ui/assets/{Chip-Gem94sHD.js → Chip-BZTcuGOm.js} +1 -1
- package/dist/ui/assets/{ComposedChart-CS5SgLoo.js → ComposedChart-C8PPdkFl.js} +1 -1
- package/dist/ui/assets/{EmptyState-Dhyw1Dvj.js → EmptyState-BfD86Pv3.js} +1 -1
- package/dist/ui/assets/{FlavorDecomposition-Dle5qGWA.js → FlavorDecomposition-BsOuw4CF.js} +1 -1
- package/dist/ui/assets/FrictionCell-DQBJsot-.js +2 -0
- package/dist/ui/assets/{GlossaryPage-DkZXoJTT.js → GlossaryPage-DutLIAlv.js} +1 -1
- package/dist/ui/assets/HotSessionsPage-DLWlyBfb.js +1 -0
- package/dist/ui/assets/{InfoTip-DCQtERnJ.js → InfoTip-BrYyvCNl.js} +1 -1
- package/dist/ui/assets/{Legend-SbbUv9Nm.js → Legend-DzSAmt8-.js} +1 -1
- package/dist/ui/assets/{Line-Dvv4aBXw.js → Line-1eksYo3m.js} +1 -1
- package/dist/ui/assets/Modal-DX-JN5Ul.js +1 -0
- package/dist/ui/assets/ObservationEvidence-Bbsc-DCW.js +1 -0
- package/dist/ui/assets/ObservationEvidence-Cy92xibu.css +1 -0
- package/dist/ui/assets/OverviewPage-CWgzRIqk.js +2 -0
- package/dist/ui/assets/RecommendationsPage-sRHEku70.js +2 -0
- package/dist/ui/assets/{Scatter-xyJ9miNg.js → Scatter-Ys7Lbhjf.js} +1 -1
- package/dist/ui/assets/SessionDetailPage-L02ituTQ.js +1 -0
- package/dist/ui/assets/{SettingsPage-DWfbUCe8.js → SettingsPage-DJjWicvx.js} +4 -4
- package/dist/ui/assets/{Skeleton-D5lrQpCm.js → Skeleton-ab1u7ntu.js} +1 -1
- package/dist/ui/assets/{SpendPercentileChip-C3hfiYOG.js → SpendPercentileChip-DnRJWBUP.js} +1 -1
- package/dist/ui/assets/{TrendChart-B8RRiXUW.js → TrendChart-_nohASRq.js} +1 -1
- package/dist/ui/assets/WorkspaceDetailPage-BQ3XA3Hf.js +1 -0
- package/dist/ui/assets/{WorkspacesPage-BI0AvjU4.js → WorkspacesPage-DKdpBgC9.js} +1 -1
- package/dist/ui/assets/{chart-theme-F_Y5-T0C.js → chart-theme-0POFlal8.js} +1 -1
- package/dist/ui/assets/{graphicalItemSelectors-XzBS96LG.js → graphicalItemSelectors-B97PsJDp.js} +1 -1
- package/dist/ui/assets/{index-DWsa7LPr.js → index-DAAmee2m.js} +10 -10
- package/dist/ui/assets/{useExperimentalActions-4CyCEvGU.js → useExperimentalActions-avkWaDmQ.js} +1 -1
- package/dist/ui/index.html +1 -1
- package/dist/work-records/allocation.js +242 -0
- package/dist/work-records/ids.js +80 -0
- package/dist/work-records/index.js +4 -0
- package/dist/work-records/store.js +376 -0
- package/dist/work-records/types.js +29 -0
- package/dist/work-records/validation.js +71 -0
- package/package.json +2 -1
- package/dist/ui/assets/FrictionCell-BN-7hRQU.js +0 -2
- package/dist/ui/assets/HotSessionsPage-C_hD8WUr.js +0 -1
- package/dist/ui/assets/OverviewPage-Blab_aYw.js +0 -2
- package/dist/ui/assets/RecommendationsPage-DmguRb6u.js +0 -2
- package/dist/ui/assets/SessionDetailPage-kcGm5Cph.js +0 -1
- package/dist/ui/assets/WorkspaceDetailPage-DDb6M8cK.js +0 -1
package/dist/apply/jobs.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/daemon/http.js
CHANGED
|
@@ -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
|
/**
|
package/dist/daemon/index.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
196
|
-
//
|
|
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
|
-
//
|
|
265
|
-
//
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
},
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|