agentwrangler 0.1.1-next.bca85ea → 0.1.1-next.f6137bd
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/daemon/http.js +8 -0
- package/dist/daemon/index.js +40 -81
- package/dist/daemon/lifecycle.js +122 -0
- package/dist/daemon/router.js +233 -8
- package/dist/db/migrations/019_work_records.sql +291 -0
- package/dist/detector/d7-loop-analysis.js +142 -0
- package/dist/detector/detectors/d7_loop_retry_waste.js +14 -127
- package/dist/effects/engine.js +27 -10
- package/dist/effects/index.js +1 -0
- package/dist/effects/registry.js +87 -6
- package/dist/effects/scope-identity.js +19 -0
- package/dist/effects/sql-observer.js +322 -8
- package/dist/ingest/ingestor.js +322 -73
- package/dist/ingest/tail.js +130 -20
- package/dist/query/api/effect-service.js +2 -0
- package/dist/query/api/esf-observations.js +117 -62
- package/dist/query/api/overview.js +15 -0
- package/dist/query/api/reported-work.js +46 -0
- package/dist/query/api/session-drivers.js +1 -1
- 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 +9 -0
- package/dist/query/spend.js +27 -13
- package/dist/query/trends.js +27 -0
- package/dist/ui/assets/{BarChart-CIjCNfbI.js → BarChart-DTP3Ex0q.js} +1 -1
- package/dist/ui/assets/{BriefsPage-DIU8iFjV.js → BriefsPage-CZtJKPO8.js} +2 -2
- package/dist/ui/assets/{CacheWriteSpikesChart-1Nfnf7OX.js → CacheWriteSpikesChart-tMCYDMsa.js} +1 -1
- package/dist/ui/assets/{CartesianChart-DEnDOGKj.js → CartesianChart-Bfn4Ugr1.js} +19 -19
- package/dist/ui/assets/{Chip-s-32Dh3o.js → Chip-D4-LOYVP.js} +1 -1
- package/dist/ui/assets/{ComposedChart-DUw8Y79v.js → ComposedChart-BKSiNYFC.js} +1 -1
- package/dist/ui/assets/{EmptyState-B8vpug3_.js → EmptyState-Bh0TMCwJ.js} +1 -1
- package/dist/ui/assets/FlavorDecomposition-6ulqBWet.js +1 -0
- package/dist/ui/assets/{FrictionCell-X75fKRBK.js → FrictionCell-0pqTXWN2.js} +1 -1
- package/dist/ui/assets/{GlossaryPage-D1tYumqL.js → GlossaryPage-qEF7_PEx.js} +1 -1
- package/dist/ui/assets/HotSessionsPage-DiiDTU6t.js +1 -0
- package/dist/ui/assets/InfoTip-wfbpV1kQ.js +1 -0
- package/dist/ui/assets/{Legend-CB7sqqJH.js → Legend-UquETeYh.js} +1 -1
- package/dist/ui/assets/{Line-B9XY30uT.js → Line-CABWN3JI.js} +1 -1
- package/dist/ui/assets/Modal-M7LV48K2.js +1 -0
- package/dist/ui/assets/ObservationEvidence-Cy92xibu.css +1 -0
- package/dist/ui/assets/ObservationEvidence-DSMYiBI8.js +1 -0
- package/dist/ui/assets/OverviewPage-CepxUEaO.js +2 -0
- package/dist/ui/assets/RecommendationsPage-62P1LejX.js +2 -0
- package/dist/ui/assets/{Scatter-BSWm_yIZ.js → Scatter-BTzqaZ8e.js} +1 -1
- package/dist/ui/assets/SectionHeader-BRxLcoVN.js +1 -0
- package/dist/ui/assets/SessionDetailPage-CM4sApx5.js +1 -0
- package/dist/ui/assets/SettingsPage-Bg_6_EZT.js +25 -0
- package/dist/ui/assets/{Skeleton-Dp_m79B8.js → Skeleton-CW4pKc3A.js} +1 -1
- package/dist/ui/assets/{SpendPercentileChip-CzY6h1gP.js → SpendPercentileChip--8Y96rJx.js} +1 -1
- package/dist/ui/assets/{TrendChart-BSLwlBiq.js → TrendChart-DJ0P1Eeq.js} +1 -1
- package/dist/ui/assets/WorkspaceDetailPage-D5160c4E.js +1 -0
- package/dist/ui/assets/WorkspacesPage-43y8mGnZ.js +1 -0
- package/dist/ui/assets/{chart-theme-DYgw_Tac.js → chart-theme-wsS-NR-n.js} +1 -1
- package/dist/ui/assets/{graphicalItemSelectors-0-Ue_y1f.js → graphicalItemSelectors-DsI4Dlrx.js} +1 -1
- package/dist/ui/assets/{index-DhFiA8Ch.js → index-B55KCjaw.js} +9 -9
- package/dist/ui/assets/index-CkFwfN3_.css +1 -0
- package/dist/ui/assets/{prompt-templates-CVzMex9L.js → prompt-templates-B5iN-dJC.js} +1 -1
- package/dist/ui/assets/{useExperimentalActions-aI00AFZv.js → useExperimentalActions-B8VQPGsV.js} +1 -1
- package/dist/ui/assets/workspace-names-C9E5QdU5.js +1 -0
- package/dist/ui/index.html +2 -2
- 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/FlavorDecomposition-CkHX3M-U.js +0 -1
- package/dist/ui/assets/HotSessionsPage-8HlRe0VB.js +0 -1
- package/dist/ui/assets/InfoTip-CL8RC4vz.js +0 -1
- package/dist/ui/assets/OverviewPage-DL4joBBE.js +0 -2
- package/dist/ui/assets/RecommendationsPage-NkfH17eB.js +0 -2
- package/dist/ui/assets/SessionDetailPage-Ceqt7poO.js +0 -1
- package/dist/ui/assets/SettingsPage-ZK4ii5h_.js +0 -25
- package/dist/ui/assets/WorkspaceDetailPage-DFOdEB39.js +0 -1
- package/dist/ui/assets/WorkspacesPage-Doct6meK.js +0 -1
- package/dist/ui/assets/index-D_IJsvkJ.css +0 -1
- package/dist/ui/assets/workspace-label-CysPJNc0.js +0 -1
package/dist/daemon/http.js
CHANGED
|
@@ -140,6 +140,14 @@ function requiresSessionToken(pathname) {
|
|
|
140
140
|
return true;
|
|
141
141
|
if (/^\/api\/esf\/effects\/(track|stop|close|rollback|attest-rollback)$/.test(pathname))
|
|
142
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;
|
|
143
151
|
return false;
|
|
144
152
|
}
|
|
145
153
|
/**
|
package/dist/daemon/index.js
CHANGED
|
@@ -38,6 +38,7 @@ import { getSettingsData, setHealthInstance, setRuntimeResetHook, } from "../que
|
|
|
38
38
|
import { loadConfig } from "./config.js";
|
|
39
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";
|
|
@@ -171,10 +172,6 @@ runProbePass("boot");
|
|
|
171
172
|
// The runner (src/daemon/outcomes-pass.ts) adds a hard 15-min per-pass deadline:
|
|
172
173
|
// a hung pass can no longer wedge the poll cadence forever — the running flag
|
|
173
174
|
// always clears and the next poll resumes.
|
|
174
|
-
let outcomesTimer = null;
|
|
175
|
-
let reportsTimer = null;
|
|
176
|
-
let reportsRunning = false;
|
|
177
|
-
let bptCalibrationTimer = null;
|
|
178
175
|
const outcomesRunner = createOutcomesPassRunner({
|
|
179
176
|
db,
|
|
180
177
|
probe: () => runProbePass("poll"),
|
|
@@ -188,29 +185,31 @@ const outcomesRunner = createOutcomesPassRunner({
|
|
|
188
185
|
function runOutcomesPass() {
|
|
189
186
|
return outcomesRunner.run();
|
|
190
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
|
+
}
|
|
191
203
|
// ── Deferred boot scan (runs AFTER the HTTP port is bound) ───────────────────
|
|
192
204
|
// startTailBatched() performs a yielding initial back-scan (ingestAllKnown +
|
|
193
205
|
// reconcile), then arms the tail/discovery timers. We do NOT
|
|
194
206
|
// also call runBackscan — that would spin up a second Ingestor whose separate
|
|
195
207
|
// Health instance is the one surfaced in Settings. One Ingestor, one Health.
|
|
196
208
|
// Bounded: if ingestion fails to start, still serve the dashboard (degraded).
|
|
197
|
-
|
|
198
|
-
//
|
|
199
|
-
// requests or timers fire kickBootScan concurrently.
|
|
200
|
-
let scanKicked = false;
|
|
201
|
-
function kickBootScan() {
|
|
202
|
-
if (scanKicked)
|
|
203
|
-
return;
|
|
204
|
-
scanKicked = true;
|
|
205
|
-
// Use setImmediate so the triggering HTTP response flushes before boot work
|
|
206
|
-
// starts. The scan itself also yields between bounded batches.
|
|
207
|
-
setImmediate(() => {
|
|
208
|
-
runBootScan().catch((e) => {
|
|
209
|
-
console.error(`Boot scan failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
210
|
-
});
|
|
211
|
-
});
|
|
212
|
-
}
|
|
209
|
+
// The one-shot kick guard, the daemon-owned intervals, and shutdown ordering
|
|
210
|
+
// live in the lifecycle harness (src/daemon/lifecycle.ts).
|
|
213
211
|
async function runBootScan() {
|
|
212
|
+
let handle = null;
|
|
214
213
|
try {
|
|
215
214
|
const scanRoots = getSettingsData(db).scan_roots;
|
|
216
215
|
setScanRoots(scanRoots);
|
|
@@ -263,21 +262,8 @@ async function runBootScan() {
|
|
|
263
262
|
console.error(`Self-churn collection failed (non-fatal): ${e instanceof Error ? e.message : String(e)}`);
|
|
264
263
|
}
|
|
265
264
|
});
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
// or older than 30 days. Mirrors the outcomes bootstrap pattern.
|
|
269
|
-
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
|
270
|
-
function runBptCalibrationIfStale() {
|
|
271
|
-
const enabled = bptConfigGet(db, "bytes_per_token_calibration_enabled");
|
|
272
|
-
if (enabled !== "true")
|
|
273
|
-
return;
|
|
274
|
-
const measuredAt = bptConfigGet(db, "bytes_per_token_measured_at");
|
|
275
|
-
if (measuredAt !== null && Date.now() - new Date(measuredAt).getTime() < THIRTY_DAYS_MS)
|
|
276
|
-
return;
|
|
277
|
-
calibrateBytesPerToken(db).catch((e) => {
|
|
278
|
-
console.error(`Bytes-per-token calibration failed (non-fatal): ${e instanceof Error ? e.message : String(e)}`);
|
|
279
|
-
});
|
|
280
|
-
}
|
|
265
|
+
// Deferred boot calibration check (the weekly interval is armed by the
|
|
266
|
+
// lifecycle harness after this scan resolves).
|
|
281
267
|
setImmediate(() => {
|
|
282
268
|
try {
|
|
283
269
|
runBptCalibrationIfStale();
|
|
@@ -286,42 +272,31 @@ async function runBootScan() {
|
|
|
286
272
|
console.error(`Bytes-per-token calibration boot failed (non-fatal): ${e instanceof Error ? e.message : String(e)}`);
|
|
287
273
|
}
|
|
288
274
|
});
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
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: () => {
|
|
299
284
|
runOutcomesPass().catch((e) => {
|
|
300
285
|
console.error(`Outcomes poll failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
301
286
|
});
|
|
302
|
-
},
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
await Promise.resolve(generateWeeklyReport(db, new Date()));
|
|
309
|
-
}
|
|
310
|
-
catch (e) {
|
|
311
|
-
console.error(`Weekly report poll failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
312
|
-
}
|
|
313
|
-
finally {
|
|
314
|
-
reportsRunning = false;
|
|
315
|
-
}
|
|
316
|
-
}, 7 * 24 * 60 * 60 * 1000);
|
|
317
|
-
}
|
|
287
|
+
},
|
|
288
|
+
reportsTick: () => generateWeeklyReport(db, new Date()),
|
|
289
|
+
closeServer: (onClosed) => server.close(onClosed),
|
|
290
|
+
closeDb: () => db.close(),
|
|
291
|
+
onClosed: () => process.exit(0),
|
|
292
|
+
});
|
|
318
293
|
// ── 4. Start HTTP server ──────────────────────────────────────────────────────
|
|
319
294
|
// Bind the port BEFORE the back-scan so the loading page is delivered
|
|
320
295
|
// immediately while the initial scan yields between file batches.
|
|
321
296
|
// Generate a random in-memory session token for the CSRF token gate.
|
|
322
297
|
// NEVER log it, NEVER persist it — it lives only in process memory.
|
|
323
298
|
const sessionToken = randomUUID();
|
|
324
|
-
const server = createServer(db, config.port, config.uiRoot, sessionToken, kickBootScan);
|
|
299
|
+
const server = createServer(db, config.port, config.uiRoot, sessionToken, lifecycle.kickBootScan);
|
|
325
300
|
server.listen(config.port, "127.0.0.1", () => {
|
|
326
301
|
const url = `http://127.0.0.1:${config.port}`;
|
|
327
302
|
console.log(`Listening on ${url}`);
|
|
@@ -334,7 +309,7 @@ server.listen(config.port, "127.0.0.1", () => {
|
|
|
334
309
|
// Fallback: kick the back-scan after 3 s even if no browser request arrives
|
|
335
310
|
// (headless mode, --no-open, CI). The one-shot guard in kickBootScan() makes
|
|
336
311
|
// whichever trigger fires first the only one that runs the scan.
|
|
337
|
-
|
|
312
|
+
lifecycle.armBootFallback();
|
|
338
313
|
});
|
|
339
314
|
/** Open `url` in the system default browser (best-effort; never crashes the daemon). */
|
|
340
315
|
function openBrowser(url) {
|
|
@@ -357,24 +332,8 @@ function openBrowser(url) {
|
|
|
357
332
|
// ── Graceful shutdown ─────────────────────────────────────────────────────────
|
|
358
333
|
process.on("SIGINT", () => {
|
|
359
334
|
console.log("Shutting down…");
|
|
360
|
-
|
|
361
|
-
if (outcomesTimer !== null)
|
|
362
|
-
clearInterval(outcomesTimer);
|
|
363
|
-
if (reportsTimer !== null)
|
|
364
|
-
clearInterval(reportsTimer);
|
|
365
|
-
server.close(() => {
|
|
366
|
-
db.close();
|
|
367
|
-
process.exit(0);
|
|
368
|
-
});
|
|
335
|
+
lifecycle.shutdown();
|
|
369
336
|
});
|
|
370
337
|
process.on("SIGTERM", () => {
|
|
371
|
-
|
|
372
|
-
if (outcomesTimer !== null)
|
|
373
|
-
clearInterval(outcomesTimer);
|
|
374
|
-
if (reportsTimer !== null)
|
|
375
|
-
clearInterval(reportsTimer);
|
|
376
|
-
server.close(() => {
|
|
377
|
-
db.close();
|
|
378
|
-
process.exit(0);
|
|
379
|
-
});
|
|
338
|
+
lifecycle.shutdown();
|
|
380
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
|
+
}
|
package/dist/daemon/router.js
CHANGED
|
@@ -1,11 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* src/daemon/router.ts — minimal API route dispatcher.
|
|
3
|
-
*
|
|
4
|
-
* Routes /api/* requests to the LocalQueryAPI stubs.
|
|
5
|
-
* WP2 and WP4 replace the stub calls with real implementations.
|
|
6
|
-
*
|
|
7
|
-
* All responses are JSON with no permissive CORS headers.
|
|
8
|
-
*/
|
|
1
|
+
import { getReportedWork } from "../query/api/reported-work.js";
|
|
9
2
|
import { getPractices } from "../detector/practice-registry.js";
|
|
10
3
|
import { getOAuthStatus } from "../oauth/credentials.js";
|
|
11
4
|
import { getGithubTokenStatus } from "../outcomes/github/credential.js";
|
|
@@ -17,6 +10,7 @@ import { getDeliveryMetrics } from "../query/api/delivery.js";
|
|
|
17
10
|
import { EffectRequestError, listEffectEvidence, mutateEffectCycle, trackCompletedChange, } from "../query/api/effect-service.js";
|
|
18
11
|
import { getClosureProxy } from "../query/api/effectiveness.js";
|
|
19
12
|
import { getEfficiencyHeadroom } from "../query/api/efficiency-headroom.js";
|
|
13
|
+
import { getEsfObservations, getSessionEsfObservations } from "../query/api/esf-observations.js";
|
|
20
14
|
import { getHeadroomTrend } from "../query/api/headroom-trend.js";
|
|
21
15
|
import { getHotSessions } from "../query/api/hot-sessions.js";
|
|
22
16
|
import { adoptRecommendation, calibrateBytesPerToken, calibrateLimit, confirmApplyJob, dismissRecommendation, getApplyJob, getContextBudget, getContextComposition, getGlobalOverview, getHookConfig, getIdleSessions, getLinkageRate, getLoopGuard, getSession, getSessionDrivers, getSettings, getSuccessRate, getTurnTimeline, getWorkspaceOutcomeDetail, installHookRoute, listLedger, listLiveSessions, listRecommendations, listSessions, listWorkspaceOutcomes, listWorkspaces, openTerminalForRec, resetDatabase, rollbackApplyJob, startApplyJob, uninstallHookRoute, updateHookConfig, updateSettings, } from "../query/api/index.js";
|
|
@@ -26,6 +20,7 @@ import { getSelfChurn } from "../query/api/self-churn.js";
|
|
|
26
20
|
import { getSessionSpendPercentile, getWeeklySelfPercentile, } from "../query/api/self-percentiles.js";
|
|
27
21
|
import { getFlavorDecomposition } from "../query/api/spend-flavor.js";
|
|
28
22
|
import { getCacheWriteTrend, getTrends } from "../query/api/trends.js";
|
|
23
|
+
import { WorkRecordError, attachWorkRecordContextRoute, attachWorkRecordSessionRoute, closeoutWorkRecordRoute, createWorkRecordRoute, deleteWorkRecordRoute, detachWorkRecordContextRoute, detachWorkRecordSessionRoute, editWorkRecordRoute, getWorkAllocation, getWorkAllocationClaimKinds, getWorkRecord, issueWorkRecordId, listWorkRecords, recomputeWorkAllocationRoute, setWorkRecordArchivedRoute, workRecordResponse, } from "../query/api/work-records.js";
|
|
29
24
|
import { getSettingsData } from "../query/settings-store.js";
|
|
30
25
|
import { getScanStatus, isReady } from "./readiness.js";
|
|
31
26
|
function sendJson(res, status, body) {
|
|
@@ -134,10 +129,84 @@ function jobRoute(pathname) {
|
|
|
134
129
|
const action = m[2] === "confirm" || m[2] === "rollback" ? m[2] : "get";
|
|
135
130
|
return { jobId: decodeURIComponent(m[1]), action };
|
|
136
131
|
}
|
|
132
|
+
function workRecordId(pathname) {
|
|
133
|
+
const match = pathname.match(/^\/api\/work-records\/([^/]+)$/);
|
|
134
|
+
return match?.[1] === undefined ? null : decodeURIComponent(match[1]);
|
|
135
|
+
}
|
|
136
|
+
function workRecordAction(pathname) {
|
|
137
|
+
const match = pathname.match(/^\/api\/work-records\/([^/]+)\/(edit|closeout|archive|reopen|sessions|contexts|delete)(?:\/([^/]+)\/detach)?$/);
|
|
138
|
+
if (match?.[1] === undefined || match[2] === undefined)
|
|
139
|
+
return null;
|
|
140
|
+
const result = { recordId: decodeURIComponent(match[1]), action: match[2] };
|
|
141
|
+
return match[3] === undefined ? result : { ...result, relatedId: decodeURIComponent(match[3]) };
|
|
142
|
+
}
|
|
143
|
+
function workRecordBody(raw, recordId, related) {
|
|
144
|
+
let body;
|
|
145
|
+
try {
|
|
146
|
+
body = JSON.parse(raw);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
throw new WorkRecordError(400, "INVALID_REQUEST", "Invalid JSON body");
|
|
150
|
+
}
|
|
151
|
+
if (body === null || typeof body !== "object" || Array.isArray(body)) {
|
|
152
|
+
throw new WorkRecordError(400, "INVALID_REQUEST", "request body must be an object");
|
|
153
|
+
}
|
|
154
|
+
const result = { ...body };
|
|
155
|
+
if (recordId !== undefined) {
|
|
156
|
+
if (result.work_record_id !== undefined && result.work_record_id !== recordId) {
|
|
157
|
+
throw new WorkRecordError(400, "INVALID_REQUEST", "work_record_id must match the route");
|
|
158
|
+
}
|
|
159
|
+
result.work_record_id = recordId;
|
|
160
|
+
}
|
|
161
|
+
if (related !== undefined) {
|
|
162
|
+
if (result[related.key] !== undefined && result[related.key] !== related.value) {
|
|
163
|
+
throw new WorkRecordError(400, "INVALID_REQUEST", `${related.key} must match the route`);
|
|
164
|
+
}
|
|
165
|
+
result[related.key] = related.value;
|
|
166
|
+
}
|
|
167
|
+
return result;
|
|
168
|
+
}
|
|
169
|
+
function sendWorkRecordError(res, error) {
|
|
170
|
+
if (error instanceof EffectRequestError) {
|
|
171
|
+
sendJson(res, error.status, { error: error.message, code: "INVALID_REQUEST" });
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (error instanceof WorkRecordError) {
|
|
175
|
+
sendJson(res, error.status, { error: error.message, code: error.code });
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
sendJson(res, 500, { error: "Internal error" });
|
|
179
|
+
}
|
|
137
180
|
export function handleApiRequest(_db, req, res, method, url) {
|
|
138
181
|
// Strip query string for routing.
|
|
139
182
|
const pathname = url.split("?")[0] ?? url;
|
|
140
183
|
try {
|
|
184
|
+
if (method === "GET" && pathname === "/api/work-records/summary") {
|
|
185
|
+
const params = new URLSearchParams(url.split("?")[1] ?? "");
|
|
186
|
+
sendJson(res, 200, getReportedWork(_db, params.get("workspace_id") || null));
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
// GET /api/esf/observations — complete selected-turn accounting cohort.
|
|
190
|
+
if (method === "GET" && pathname === "/api/esf/observations") {
|
|
191
|
+
const params = new URLSearchParams(url.split("?")[1] ?? "");
|
|
192
|
+
const window = resolveWindow(parseWindowFilter(url));
|
|
193
|
+
if (Date.parse(window.from) >= Date.parse(window.to)) {
|
|
194
|
+
sendJson(res, 400, { error: "from must precede to" });
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
sendJson(res, 200, getEsfObservations(_db, {
|
|
198
|
+
workspaceId: params.get("workspace_id") || null,
|
|
199
|
+
...window,
|
|
200
|
+
}));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
// Full-session aggregate: never derive this from a paginated timeline.
|
|
204
|
+
const sessionObservationId = pathname.match(/^\/api\/esf\/session-observations\/([^/]+)$/)?.[1];
|
|
205
|
+
if (method === "GET" && sessionObservationId !== undefined) {
|
|
206
|
+
const evidence = getSessionEsfObservations(_db, decodeURIComponent(sessionObservationId));
|
|
207
|
+
sendJson(res, evidence.data === null ? 404 : 200, evidence);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
141
210
|
// GET /api/esf/effects — independently keyset-paged versioned and legacy evidence.
|
|
142
211
|
if (method === "GET" && pathname === "/api/esf/effects") {
|
|
143
212
|
const params = new URLSearchParams(url.split("?")[1] ?? "");
|
|
@@ -179,6 +248,162 @@ export function handleApiRequest(_db, req, res, method, url) {
|
|
|
179
248
|
}));
|
|
180
249
|
return;
|
|
181
250
|
}
|
|
251
|
+
// ESF3 work records are local-only, structured records. The HTTP token gate
|
|
252
|
+
// in http.ts covers every mutation and ID issuance route below.
|
|
253
|
+
if (method === "GET" && pathname === "/api/work-records") {
|
|
254
|
+
const params = new URLSearchParams(url.split("?")[1] ?? "");
|
|
255
|
+
const workspaceId = params.get("workspace_id");
|
|
256
|
+
const includeArchived = params.get("include_archived");
|
|
257
|
+
if (workspaceId === null ||
|
|
258
|
+
workspaceId.length === 0 ||
|
|
259
|
+
(includeArchived !== null && includeArchived !== "true" && includeArchived !== "false")) {
|
|
260
|
+
sendJson(res, 400, { error: "workspace_id and include_archived are invalid" });
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const records = listWorkRecords(_db, workspaceId, includeArchived === "true");
|
|
264
|
+
sendJson(res, 200, workRecordResponse(records, { n: records.length, workspaceId }));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (method === "GET" && pathname.startsWith("/api/work-records/allocations/")) {
|
|
268
|
+
const allocationId = pathname.slice("/api/work-records/allocations/".length);
|
|
269
|
+
if (allocationId.length === 0 || allocationId.includes("/")) {
|
|
270
|
+
sendJson(res, 404, { error: "Not found" });
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
try {
|
|
274
|
+
const allocation = getWorkAllocation(_db, decodeURIComponent(allocationId));
|
|
275
|
+
sendJson(res, 200, workRecordResponse(allocation, {
|
|
276
|
+
n: allocation.eligible_session_count,
|
|
277
|
+
workspaceId: allocation.workspace_id,
|
|
278
|
+
claimKind: "EXPERIMENTAL",
|
|
279
|
+
meta: {
|
|
280
|
+
window: { from: allocation.cohort_from, to: allocation.cohort_to },
|
|
281
|
+
qualification: {
|
|
282
|
+
provisional_excluded: true,
|
|
283
|
+
unpriced_turns: allocation.unpriced_turn_count,
|
|
284
|
+
claim_kinds_count: getWorkAllocationClaimKinds(_db, allocation.allocation_revision_id),
|
|
285
|
+
note: "Frozen ESF3 allocation; unpriced turns are not zero cost.",
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
}));
|
|
289
|
+
}
|
|
290
|
+
catch (error) {
|
|
291
|
+
sendWorkRecordError(res, error);
|
|
292
|
+
}
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const recordId = workRecordId(pathname);
|
|
296
|
+
if (method === "GET" && recordId !== null) {
|
|
297
|
+
try {
|
|
298
|
+
const record = getWorkRecord(_db, recordId);
|
|
299
|
+
sendJson(res, 200, workRecordResponse(record, { workspaceId: record.workspace_id }));
|
|
300
|
+
}
|
|
301
|
+
catch (error) {
|
|
302
|
+
sendWorkRecordError(res, error);
|
|
303
|
+
}
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (method === "POST" && pathname === "/api/work-records/ids") {
|
|
307
|
+
readBody(req, 1024)
|
|
308
|
+
.then((raw) => {
|
|
309
|
+
try {
|
|
310
|
+
sendJson(res, 200, workRecordResponse(issueWorkRecordId(_db, workRecordBody(raw))));
|
|
311
|
+
}
|
|
312
|
+
catch (error) {
|
|
313
|
+
sendWorkRecordError(res, error);
|
|
314
|
+
}
|
|
315
|
+
})
|
|
316
|
+
.catch((error) => sendWorkRecordError(res, error));
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (method === "POST" && pathname === "/api/work-records") {
|
|
320
|
+
readBody(req, 4096)
|
|
321
|
+
.then((raw) => {
|
|
322
|
+
try {
|
|
323
|
+
const result = createWorkRecordRoute(_db, workRecordBody(raw));
|
|
324
|
+
sendJson(res, 200, workRecordResponse(result, { workspaceId: result.record.workspace_id }));
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
sendWorkRecordError(res, error);
|
|
328
|
+
}
|
|
329
|
+
})
|
|
330
|
+
.catch((error) => sendWorkRecordError(res, error));
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (method === "POST" && pathname === "/api/work-records/allocations/recompute") {
|
|
334
|
+
readBody(req, 4096)
|
|
335
|
+
.then((raw) => {
|
|
336
|
+
try {
|
|
337
|
+
const result = recomputeWorkAllocationRoute(_db, workRecordBody(raw));
|
|
338
|
+
sendJson(res, 200, workRecordResponse(result, {
|
|
339
|
+
n: result.allocation.eligible_session_count,
|
|
340
|
+
workspaceId: result.allocation.workspace_id,
|
|
341
|
+
claimKind: "EXPERIMENTAL",
|
|
342
|
+
meta: {
|
|
343
|
+
window: {
|
|
344
|
+
from: result.allocation.cohort_from,
|
|
345
|
+
to: result.allocation.cohort_to,
|
|
346
|
+
},
|
|
347
|
+
qualification: {
|
|
348
|
+
provisional_excluded: true,
|
|
349
|
+
unpriced_turns: result.allocation.unpriced_turn_count,
|
|
350
|
+
claim_kinds_count: getWorkAllocationClaimKinds(_db, result.allocation.allocation_revision_id),
|
|
351
|
+
note: "Frozen ESF3 allocation; unpriced turns are not zero cost.",
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
}));
|
|
355
|
+
}
|
|
356
|
+
catch (error) {
|
|
357
|
+
sendWorkRecordError(res, error);
|
|
358
|
+
}
|
|
359
|
+
})
|
|
360
|
+
.catch((error) => sendWorkRecordError(res, error));
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
const recordAction = workRecordAction(pathname);
|
|
364
|
+
if (recordAction !== null &&
|
|
365
|
+
((method === "POST" && recordAction.action !== "delete") ||
|
|
366
|
+
(method === "DELETE" && recordAction.action === "delete"))) {
|
|
367
|
+
readBody(req, 4096)
|
|
368
|
+
.then((raw) => {
|
|
369
|
+
try {
|
|
370
|
+
const related = recordAction.relatedId === undefined
|
|
371
|
+
? undefined
|
|
372
|
+
: {
|
|
373
|
+
key: recordAction.action === "contexts" ? "context_ref_id" : "session_id",
|
|
374
|
+
value: recordAction.relatedId,
|
|
375
|
+
};
|
|
376
|
+
const body = workRecordBody(raw, recordAction.recordId, related);
|
|
377
|
+
const result = recordAction.action === "edit"
|
|
378
|
+
? editWorkRecordRoute(_db, body)
|
|
379
|
+
: recordAction.action === "closeout"
|
|
380
|
+
? closeoutWorkRecordRoute(_db, body)
|
|
381
|
+
: recordAction.action === "archive"
|
|
382
|
+
? setWorkRecordArchivedRoute(_db, body, true)
|
|
383
|
+
: recordAction.action === "reopen"
|
|
384
|
+
? setWorkRecordArchivedRoute(_db, body, false)
|
|
385
|
+
: recordAction.action === "sessions" && recordAction.relatedId === undefined
|
|
386
|
+
? attachWorkRecordSessionRoute(_db, body)
|
|
387
|
+
: recordAction.action === "sessions"
|
|
388
|
+
? detachWorkRecordSessionRoute(_db, body)
|
|
389
|
+
: recordAction.action === "contexts" &&
|
|
390
|
+
recordAction.relatedId === undefined
|
|
391
|
+
? attachWorkRecordContextRoute(_db, body)
|
|
392
|
+
: recordAction.action === "contexts"
|
|
393
|
+
? detachWorkRecordContextRoute(_db, body)
|
|
394
|
+
: deleteWorkRecordRoute(_db, body);
|
|
395
|
+
const workspaceId = "record" in result ? result.record.workspace_id : undefined;
|
|
396
|
+
sendJson(res, 200, workspaceId === undefined
|
|
397
|
+
? workRecordResponse(result)
|
|
398
|
+
: workRecordResponse(result, { workspaceId }));
|
|
399
|
+
}
|
|
400
|
+
catch (error) {
|
|
401
|
+
sendWorkRecordError(res, error);
|
|
402
|
+
}
|
|
403
|
+
})
|
|
404
|
+
.catch((error) => sendWorkRecordError(res, error));
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
182
407
|
// GET /api/overview
|
|
183
408
|
if (method === "GET" && pathname === "/api/overview") {
|
|
184
409
|
sendJson(res, 200, getGlobalOverview(parseWindowFilter(url)));
|