agentwrangler 0.1.1-next.fbbbe7f → 0.1.1-next.fbffef0

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 (108) hide show
  1. package/README.md +82 -162
  2. package/dist/apply/jobs.js +13 -3
  3. package/dist/daemon/effect-pass.js +24 -0
  4. package/dist/daemon/http.js +10 -0
  5. package/dist/daemon/index.js +48 -87
  6. package/dist/daemon/lifecycle.js +122 -0
  7. package/dist/daemon/router.js +296 -14
  8. package/dist/db/migrations/018_effect_cycles.sql +109 -0
  9. package/dist/db/migrations/019_work_records.sql +291 -0
  10. package/dist/detector/context-history-retention.js +49 -0
  11. package/dist/detector/detectors/d10_catalog_footprint.js +2 -2
  12. package/dist/detector/detectors/d1_ctx_always_loaded.js +9 -9
  13. package/dist/detector/detectors/d2_session_long_full_context.js +2 -2
  14. package/dist/detector/detectors/d4_model_mismatch.js +3 -3
  15. package/dist/detector/detectors/d6_tool_result_bloat.js +1 -1
  16. package/dist/detector/detectors/d7_loop_retry_waste.js +1 -1
  17. package/dist/detector/detectors/d8_cache_write_churn.js +2 -2
  18. package/dist/detector/detectors/d9_idle_background_session.js +1 -1
  19. package/dist/detector/measurement.js +14 -3
  20. package/dist/effects/api-contract.js +1 -0
  21. package/dist/effects/engine.js +574 -0
  22. package/dist/effects/gate.js +2 -0
  23. package/dist/effects/index.js +5 -0
  24. package/dist/effects/legacy.js +28 -0
  25. package/dist/effects/registry.js +152 -0
  26. package/dist/effects/sql-observer.js +238 -0
  27. package/dist/effects/store.js +181 -0
  28. package/dist/effects/types.js +2 -0
  29. package/dist/hook/precompact-checkpoint-hook.mjs +26 -13
  30. package/dist/ingest/ingestor.js +322 -73
  31. package/dist/ingest/pricing.js +15 -0
  32. package/dist/ingest/tail.js +130 -20
  33. package/dist/query/api/cost-per-success.js +13 -1
  34. package/dist/query/api/delivery.js +25 -3
  35. package/dist/query/api/effect-service.js +308 -0
  36. package/dist/query/api/effectiveness.js +30 -5
  37. package/dist/query/api/efficiency-headroom.js +12 -14
  38. package/dist/query/api/esf-observations.js +285 -0
  39. package/dist/query/api/outcomes.js +2 -1
  40. package/dist/query/api/overview.js +105 -6
  41. package/dist/query/api/recommendations-ledger.js +9 -14
  42. package/dist/query/api/recommendations.js +21 -1
  43. package/dist/query/api/reported-work.js +46 -0
  44. package/dist/query/api/spend-flavor.js +22 -15
  45. package/dist/query/api/trends.js +86 -6
  46. package/dist/query/api/work-records.js +85 -0
  47. package/dist/query/settings-store.js +13 -0
  48. package/dist/query/spend.js +91 -15
  49. package/dist/query/trends.js +27 -0
  50. package/dist/ui/assets/{BarChart-p1usctxJ.js → BarChart-BOecTQgC.js} +3 -3
  51. package/dist/ui/assets/BriefsPage-CjvXspiw.js +2 -0
  52. package/dist/ui/assets/CacheWriteSpikesChart-5Pn1sUZC.js +1 -0
  53. package/dist/ui/assets/{chart-theme-JH5aQymO.js → CartesianChart-56rkZQt4.js} +36 -36
  54. package/dist/ui/assets/Chip-BZTcuGOm.js +1 -0
  55. package/dist/ui/assets/ComposedChart-C8PPdkFl.js +1 -0
  56. package/dist/ui/assets/{EmptyState-CvQeJjjG.js → EmptyState-BfD86Pv3.js} +1 -1
  57. package/dist/ui/assets/FlavorDecomposition-BsOuw4CF.js +1 -0
  58. package/dist/ui/assets/FrictionCell-DQBJsot-.js +2 -0
  59. package/dist/ui/assets/GlossaryPage-DutLIAlv.js +1 -0
  60. package/dist/ui/assets/HotSessionsPage-DLWlyBfb.js +1 -0
  61. package/dist/ui/assets/{InfoTip-Dx7BHpBu.js → InfoTip-BrYyvCNl.js} +1 -1
  62. package/dist/ui/assets/{Legend--q6-rQGk.js → Legend-DzSAmt8-.js} +3 -3
  63. package/dist/ui/assets/{Line-DzVtUwF4.js → Line-1eksYo3m.js} +1 -1
  64. package/dist/ui/assets/Modal-DX-JN5Ul.js +1 -0
  65. package/dist/ui/assets/ObservationEvidence-Bbsc-DCW.js +1 -0
  66. package/dist/ui/assets/ObservationEvidence-Cy92xibu.css +1 -0
  67. package/dist/ui/assets/OverviewPage-CWgzRIqk.js +2 -0
  68. package/dist/ui/assets/RecommendationsPage-sRHEku70.js +2 -0
  69. package/dist/ui/assets/{Scatter-Cr_hCkjG.js → Scatter-Ys7Lbhjf.js} +1 -1
  70. package/dist/ui/assets/SessionDetailPage-L02ituTQ.js +1 -0
  71. package/dist/ui/assets/SettingsPage-DJjWicvx.js +25 -0
  72. package/dist/ui/assets/{Skeleton-CcD61x7q.js → Skeleton-ab1u7ntu.js} +1 -1
  73. package/dist/ui/assets/SpendPercentileChip-DnRJWBUP.js +1 -0
  74. package/dist/ui/assets/TrendChart-_nohASRq.js +1 -0
  75. package/dist/ui/assets/WorkspaceDetailPage-BQ3XA3Hf.js +1 -0
  76. package/dist/ui/assets/WorkspacesPage-DKdpBgC9.js +1 -0
  77. package/dist/ui/assets/chart-theme-0POFlal8.js +1 -0
  78. package/dist/ui/assets/graphicalItemSelectors-B97PsJDp.js +1 -0
  79. package/dist/ui/assets/{index-DRhoA51c.js → index-DAAmee2m.js} +10 -10
  80. package/dist/ui/assets/{index-SrfNUeBZ.css → index-D_IJsvkJ.css} +1 -1
  81. package/dist/ui/assets/{prompt-templates-DhR4Qoy9.js → prompt-templates-CVzMex9L.js} +4 -4
  82. package/dist/ui/assets/rec-sessions-ggz9MYgP.js +1 -0
  83. package/dist/ui/assets/{useExperimentalActions-BPDdswCN.js → useExperimentalActions-avkWaDmQ.js} +1 -1
  84. package/dist/ui/index.html +2 -2
  85. package/dist/work-records/allocation.js +242 -0
  86. package/dist/work-records/ids.js +80 -0
  87. package/dist/work-records/index.js +4 -0
  88. package/dist/work-records/store.js +376 -0
  89. package/dist/work-records/types.js +29 -0
  90. package/dist/work-records/validation.js +71 -0
  91. package/package.json +2 -1
  92. package/dist/ui/assets/BriefsPage-CneqLVGC.js +0 -2
  93. package/dist/ui/assets/CacheWriteSpikesChart-zNRZbzy9.js +0 -1
  94. package/dist/ui/assets/Chip-Bu6udQGq.js +0 -1
  95. package/dist/ui/assets/ComposedChart-8D5E-Po_.js +0 -1
  96. package/dist/ui/assets/FlavorDecomposition-xcMLA50f.js +0 -1
  97. package/dist/ui/assets/FrictionCell-OafGLGxT.js +0 -2
  98. package/dist/ui/assets/GlossaryPage-BmOP3Syj.js +0 -1
  99. package/dist/ui/assets/HotSessionsPage-FoxuuDLF.js +0 -1
  100. package/dist/ui/assets/OverviewPage-BjSQYNvS.js +0 -2
  101. package/dist/ui/assets/RecommendationsPage-CRTpzcHs.js +0 -2
  102. package/dist/ui/assets/SessionDetailPage-Dd3u8kVI.js +0 -1
  103. package/dist/ui/assets/SettingsPage-BPVQyjlc.js +0 -25
  104. package/dist/ui/assets/SpendPercentileChip-BaQhJQAn.js +0 -1
  105. package/dist/ui/assets/TrendChart-Baz3GGgE.js +0 -1
  106. package/dist/ui/assets/WorkspaceDetailPage-yk4z_EtQ.js +0 -1
  107. package/dist/ui/assets/WorkspacesPage-BDtclXJP.js +0 -1
  108. package/dist/ui/assets/graphicalItemSelectors-CCbl8AKC.js +0 -1
@@ -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
+ }
@@ -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";
@@ -14,8 +7,10 @@ import { endSession, getAgentsLiveness } from "../query/api/agents-liveness.js";
14
7
  import { getBurnStatus } from "../query/api/burn-status.js";
15
8
  import { getCostPerSuccess } from "../query/api/cost-per-success.js";
16
9
  import { getDeliveryMetrics } from "../query/api/delivery.js";
10
+ import { EffectRequestError, listEffectEvidence, mutateEffectCycle, trackCompletedChange, } from "../query/api/effect-service.js";
17
11
  import { getClosureProxy } from "../query/api/effectiveness.js";
18
12
  import { getEfficiencyHeadroom } from "../query/api/efficiency-headroom.js";
13
+ import { getEsfObservations, getSessionEsfObservations } from "../query/api/esf-observations.js";
19
14
  import { getHeadroomTrend } from "../query/api/headroom-trend.js";
20
15
  import { getHotSessions } from "../query/api/hot-sessions.js";
21
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";
@@ -25,6 +20,7 @@ import { getSelfChurn } from "../query/api/self-churn.js";
25
20
  import { getSessionSpendPercentile, getWeeklySelfPercentile, } from "../query/api/self-percentiles.js";
26
21
  import { getFlavorDecomposition } from "../query/api/spend-flavor.js";
27
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";
28
24
  import { getSettingsData } from "../query/settings-store.js";
29
25
  import { getScanStatus, isReady } from "./readiness.js";
30
26
  function sendJson(res, status, body) {
@@ -35,10 +31,19 @@ function sendJson(res, status, body) {
35
31
  });
36
32
  res.end(payload);
37
33
  }
38
- function readBody(req) {
34
+ function readBody(req, maxBytes) {
39
35
  return new Promise((resolve, reject) => {
40
36
  const chunks = [];
41
- req.on("data", (chunk) => chunks.push(chunk));
37
+ let size = 0;
38
+ req.on("data", (chunk) => {
39
+ size += chunk.length;
40
+ if (maxBytes !== undefined && size > maxBytes) {
41
+ reject(new EffectRequestError(400, "Request body is too large."));
42
+ req.resume();
43
+ return;
44
+ }
45
+ chunks.push(chunk);
46
+ });
42
47
  req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
43
48
  req.on("error", reject);
44
49
  });
@@ -124,10 +129,281 @@ function jobRoute(pathname) {
124
129
  const action = m[2] === "confirm" || m[2] === "rollback" ? m[2] : "get";
125
130
  return { jobId: decodeURIComponent(m[1]), action };
126
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
+ }
127
180
  export function handleApiRequest(_db, req, res, method, url) {
128
181
  // Strip query string for routing.
129
182
  const pathname = url.split("?")[0] ?? url;
130
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
+ }
210
+ // GET /api/esf/effects — independently keyset-paged versioned and legacy evidence.
211
+ if (method === "GET" && pathname === "/api/esf/effects") {
212
+ const params = new URLSearchParams(url.split("?")[1] ?? "");
213
+ const workspaceId = params.get("workspace_id");
214
+ sendJson(res, 200, listEffectEvidence(_db, workspaceId, params.get("rec_id") ?? undefined, params.get("limit") ?? undefined, params.get("cycle_cursor") ?? undefined, params.get("legacy_cursor") ?? undefined));
215
+ return;
216
+ }
217
+ const effectAction = pathname.match(/^\/api\/esf\/effects\/(track|stop|close|rollback|attest-rollback)$/)?.[1];
218
+ if (method === "POST" && effectAction !== undefined) {
219
+ readBody(req, 4096)
220
+ .then((raw) => {
221
+ let body;
222
+ try {
223
+ body = JSON.parse(raw);
224
+ }
225
+ catch {
226
+ sendJson(res, 400, { error: "Invalid JSON body." });
227
+ return;
228
+ }
229
+ try {
230
+ const result = effectAction === "track"
231
+ ? trackCompletedChange(_db, body)
232
+ : mutateEffectCycle(_db, effectAction, body);
233
+ sendJson(res, 200, result);
234
+ }
235
+ catch (error) {
236
+ const status = error instanceof EffectRequestError ? error.status : 409;
237
+ sendJson(res, status, {
238
+ error: error instanceof EffectRequestError
239
+ ? error.message
240
+ : "Effect request conflicts with the current cycle.",
241
+ });
242
+ }
243
+ })
244
+ .catch((error) => sendJson(res, error instanceof EffectRequestError ? error.status : 500, {
245
+ error: error instanceof EffectRequestError
246
+ ? error.message
247
+ : "Internal error reading request body",
248
+ }));
249
+ return;
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
+ }
131
407
  // GET /api/overview
132
408
  if (method === "GET" && pathname === "/api/overview") {
133
409
  sendJson(res, 200, getGlobalOverview(parseWindowFilter(url)));
@@ -264,7 +540,7 @@ export function handleApiRequest(_db, req, res, method, url) {
264
540
  sendJson(res, 200, getPractices(_db, { from, to }));
265
541
  return;
266
542
  }
267
- // GET /api/efficiency-headroom (BM2 modeled savings vs trailing-window spend)
543
+ // GET /api/efficiency-headroom (individual weekly estimates; separate selected-window spend)
268
544
  if (method === "GET" && pathname === "/api/efficiency-headroom") {
269
545
  const { from, to } = resolveWindow(parseWindowFilter(url));
270
546
  sendJson(res, 200, getEfficiencyHeadroom(_db, { from, to }));
@@ -506,13 +782,15 @@ export function handleApiRequest(_db, req, res, method, url) {
506
782
  sendJson(res, 400, { error: "Invalid JSON body" });
507
783
  return;
508
784
  }
509
- const { rec_id } = body;
785
+ const { rec_id, completed_change } = body;
510
786
  if (typeof rec_id !== "string" || rec_id.length === 0) {
511
787
  sendJson(res, 400, { error: "rec_id is required" });
512
788
  return;
513
789
  }
514
790
  try {
515
- sendJson(res, 200, adoptRecommendation(rec_id));
791
+ sendJson(res, 200, adoptRecommendation(rec_id, Date.now(), {
792
+ completedChange: completed_change === true,
793
+ }));
516
794
  }
517
795
  catch (e) {
518
796
  sendJson(res, 400, { error: e instanceof Error ? e.message : "Adopt failed" });
@@ -749,7 +1027,11 @@ export function handleApiRequest(_db, req, res, method, url) {
749
1027
  // 404 for everything else.
750
1028
  sendJson(res, 404, { error: "Not found", path: pathname });
751
1029
  }
752
- catch {
1030
+ catch (error) {
1031
+ if (error instanceof EffectRequestError) {
1032
+ sendJson(res, error.status, { error: error.message });
1033
+ return;
1034
+ }
753
1035
  // Do not leak internal exception text into a cross-origin-readable 500 body
754
1036
  // (GET routes are not CSRF-gated). Validation errors on POST routes still
755
1037
  // return their message via the 400 paths above.
@@ -0,0 +1,109 @@
1
+ -- ESF2 versioned recommendation-effect cycles. Additive: legacy W4 rows remain untouched.
2
+
3
+ CREATE TABLE effect_cycles (
4
+ cycle_id TEXT PRIMARY KEY,
5
+ rec_id TEXT NOT NULL REFERENCES recommendations(rec_id),
6
+ cycle_no INTEGER NOT NULL CHECK (cycle_no > 0),
7
+ contract_version TEXT NOT NULL,
8
+ query_definition_version TEXT NOT NULL,
9
+ detector_id TEXT NOT NULL,
10
+ metric_id TEXT NOT NULL,
11
+ method_version TEXT NOT NULL,
12
+ state TEXT NOT NULL CHECK (state IN
13
+ ('OPEN_SETTLING','OPEN_MEASURING','STOPPED','FINALIZED','UNSUPPORTED')),
14
+ created_at TEXT NOT NULL,
15
+ tracking_requested_at TEXT NOT NULL,
16
+ applied_at TEXT NOT NULL,
17
+ application_source TEXT NOT NULL CHECK (application_source IN
18
+ ('MACHINE_CONFIRMED','USER_ATTESTED')),
19
+ action_revision TEXT,
20
+ track_idempotency_key TEXT NOT NULL,
21
+ scope_json TEXT NOT NULL CHECK (json_valid(scope_json)),
22
+ cohort_json TEXT NOT NULL CHECK (json_valid(cohort_json)),
23
+ target_definition_json TEXT NOT NULL CHECK (json_valid(target_definition_json)),
24
+ guardrail_definitions_json TEXT NOT NULL CHECK (json_valid(guardrail_definitions_json)),
25
+ baseline_from TEXT NOT NULL,
26
+ baseline_to TEXT NOT NULL,
27
+ observation_from TEXT NOT NULL,
28
+ scheduled_observation_to TEXT NOT NULL,
29
+ observation_to TEXT,
30
+ baseline_evidence_json TEXT NOT NULL CHECK (json_valid(baseline_evidence_json)),
31
+ provisional_evidence_json TEXT CHECK (provisional_evidence_json IS NULL OR json_valid(provisional_evidence_json)),
32
+ final_evidence_json TEXT CHECK (final_evidence_json IS NULL OR json_valid(final_evidence_json)),
33
+ target_direction TEXT CHECK (target_direction IS NULL OR target_direction IN
34
+ ('IMPROVED','UNCHANGED','WORSENED','INSUFFICIENT_DATA')),
35
+ comparison_status TEXT CHECK (comparison_status IS NULL OR comparison_status IN
36
+ ('DESCRIPTIVE','COMPARABLE','CONFOUNDED')),
37
+ comparison_reasons_json TEXT CHECK (comparison_reasons_json IS NULL OR json_valid(comparison_reasons_json)),
38
+ terminal_at TEXT,
39
+ terminal_reason TEXT,
40
+ stop_idempotency_key TEXT,
41
+ stop_reason TEXT,
42
+ attribution_closed_at TEXT,
43
+ attribution_close_reason TEXT,
44
+ closure_idempotency_key TEXT,
45
+ finalizer_revision TEXT,
46
+ concurrent_change_json TEXT CHECK (concurrent_change_json IS NULL OR json_valid(concurrent_change_json)),
47
+ rollback_status TEXT CHECK (rollback_status IS NULL OR rollback_status IN
48
+ ('PENDING','SUCCEEDED','CONFLICT','FAILED','USER_ATTESTED','MANUAL','UNSUPPORTED')),
49
+ rollback_at TEXT,
50
+ rollback_attestation_source TEXT,
51
+ UNIQUE (rec_id, cycle_no),
52
+ UNIQUE (rec_id, track_idempotency_key)
53
+ );
54
+
55
+ CREATE UNIQUE INDEX idx_effect_cycles_one_open_rec
56
+ ON effect_cycles(rec_id)
57
+ WHERE state IN ('OPEN_SETTLING','OPEN_MEASURING');
58
+ CREATE INDEX idx_effect_cycles_pass
59
+ ON effect_cycles(state, scheduled_observation_to, applied_at);
60
+ CREATE INDEX idx_effect_cycles_rec
61
+ ON effect_cycles(rec_id, cycle_no DESC);
62
+ CREATE INDEX idx_effect_cycles_overlap
63
+ ON effect_cycles(contract_version, query_definition_version, observation_from);
64
+
65
+ CREATE TABLE effect_guardrail_results (
66
+ cycle_id TEXT NOT NULL REFERENCES effect_cycles(cycle_id) ON DELETE CASCADE,
67
+ guardrail_id TEXT NOT NULL,
68
+ method_version TEXT NOT NULL,
69
+ availability TEXT NOT NULL CHECK (availability IN ('SUPPORTED','UNSUPPORTED')),
70
+ unit TEXT NOT NULL,
71
+ before_value REAL,
72
+ after_value REAL,
73
+ before_denominator REAL,
74
+ after_denominator REAL,
75
+ before_eligible_n INTEGER,
76
+ after_eligible_n INTEGER,
77
+ direction TEXT NOT NULL CHECK (direction IN
78
+ ('IMPROVED','STABLE','ADVERSE','INSUFFICIENT_DATA')),
79
+ reason_codes_json TEXT NOT NULL CHECK (json_valid(reason_codes_json)),
80
+ evidence_json TEXT NOT NULL CHECK (json_valid(evidence_json)),
81
+ PRIMARY KEY (cycle_id, guardrail_id)
82
+ );
83
+
84
+ CREATE TABLE effect_rollback_operations (
85
+ operation_id TEXT PRIMARY KEY,
86
+ cycle_id TEXT NOT NULL REFERENCES effect_cycles(cycle_id) ON DELETE CASCADE,
87
+ idempotency_key TEXT NOT NULL,
88
+ status TEXT NOT NULL CHECK (status IN
89
+ ('PENDING','SUCCEEDED','CONFLICT','FAILED','USER_ATTESTED','MANUAL','UNSUPPORTED')),
90
+ requested_at TEXT NOT NULL,
91
+ completed_at TEXT,
92
+ inverse_kind TEXT NOT NULL,
93
+ expected_revision TEXT,
94
+ restoration_revision TEXT,
95
+ failure_class TEXT,
96
+ attestation_source TEXT,
97
+ actual_rollback_at TEXT,
98
+ UNIQUE (cycle_id, idempotency_key)
99
+ );
100
+ CREATE UNIQUE INDEX idx_effect_rollbacks_one_pending
101
+ ON effect_rollback_operations(cycle_id) WHERE status = 'PENDING';
102
+
103
+ CREATE TABLE effect_mutation_keys (
104
+ cycle_id TEXT NOT NULL REFERENCES effect_cycles(cycle_id) ON DELETE CASCADE,
105
+ mutation_kind TEXT NOT NULL,
106
+ idempotency_key TEXT NOT NULL,
107
+ recorded_at TEXT NOT NULL,
108
+ PRIMARY KEY (cycle_id, mutation_kind, idempotency_key)
109
+ );