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

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 (63) hide show
  1. package/dist/apply/jobs.js +7 -1
  2. package/dist/daemon/effect-pass.js +24 -0
  3. package/dist/daemon/http.js +2 -0
  4. package/dist/daemon/index.js +8 -6
  5. package/dist/daemon/router.js +63 -6
  6. package/dist/db/migrations/018_effect_cycles.sql +109 -0
  7. package/dist/detector/context-history-retention.js +49 -0
  8. package/dist/detector/measurement.js +11 -1
  9. package/dist/effects/api-contract.js +1 -0
  10. package/dist/effects/engine.js +574 -0
  11. package/dist/effects/gate.js +2 -0
  12. package/dist/effects/index.js +5 -0
  13. package/dist/effects/legacy.js +28 -0
  14. package/dist/effects/registry.js +152 -0
  15. package/dist/effects/sql-observer.js +238 -0
  16. package/dist/effects/store.js +181 -0
  17. package/dist/effects/types.js +2 -0
  18. package/dist/query/api/cost-per-success.js +13 -1
  19. package/dist/query/api/delivery.js +25 -3
  20. package/dist/query/api/effect-service.js +308 -0
  21. package/dist/query/api/effectiveness.js +30 -5
  22. package/dist/query/api/efficiency-headroom.js +12 -14
  23. package/dist/query/api/esf-observations.js +254 -0
  24. package/dist/query/api/overview.js +77 -3
  25. package/dist/query/api/recommendations-ledger.js +9 -14
  26. package/dist/query/api/recommendations.js +21 -1
  27. package/dist/query/settings-store.js +4 -0
  28. package/dist/query/spend.js +64 -2
  29. package/dist/ui/assets/{BarChart-VQGcJCLY.js → BarChart-CIjCNfbI.js} +1 -1
  30. package/dist/ui/assets/{BriefsPage-CmhxCHLf.js → BriefsPage-DIU8iFjV.js} +1 -1
  31. package/dist/ui/assets/{CacheWriteSpikesChart-C641X3kK.js → CacheWriteSpikesChart-1Nfnf7OX.js} +1 -1
  32. package/dist/ui/assets/{CartesianChart-BTva-_b9.js → CartesianChart-DEnDOGKj.js} +1 -1
  33. package/dist/ui/assets/{Chip-Gem94sHD.js → Chip-s-32Dh3o.js} +1 -1
  34. package/dist/ui/assets/{ComposedChart-CS5SgLoo.js → ComposedChart-DUw8Y79v.js} +1 -1
  35. package/dist/ui/assets/{EmptyState-Dhyw1Dvj.js → EmptyState-B8vpug3_.js} +1 -1
  36. package/dist/ui/assets/{FlavorDecomposition-Dle5qGWA.js → FlavorDecomposition-CkHX3M-U.js} +1 -1
  37. package/dist/ui/assets/FrictionCell-X75fKRBK.js +2 -0
  38. package/dist/ui/assets/{GlossaryPage-DkZXoJTT.js → GlossaryPage-D1tYumqL.js} +1 -1
  39. package/dist/ui/assets/HotSessionsPage-8HlRe0VB.js +1 -0
  40. package/dist/ui/assets/{InfoTip-DCQtERnJ.js → InfoTip-CL8RC4vz.js} +1 -1
  41. package/dist/ui/assets/{Legend-SbbUv9Nm.js → Legend-CB7sqqJH.js} +1 -1
  42. package/dist/ui/assets/{Line-Dvv4aBXw.js → Line-B9XY30uT.js} +1 -1
  43. package/dist/ui/assets/{OverviewPage-Blab_aYw.js → OverviewPage-DL4joBBE.js} +2 -2
  44. package/dist/ui/assets/RecommendationsPage-NkfH17eB.js +2 -0
  45. package/dist/ui/assets/{Scatter-xyJ9miNg.js → Scatter-BSWm_yIZ.js} +1 -1
  46. package/dist/ui/assets/SessionDetailPage-Ceqt7poO.js +1 -0
  47. package/dist/ui/assets/{SettingsPage-DWfbUCe8.js → SettingsPage-ZK4ii5h_.js} +1 -1
  48. package/dist/ui/assets/{Skeleton-D5lrQpCm.js → Skeleton-Dp_m79B8.js} +1 -1
  49. package/dist/ui/assets/{SpendPercentileChip-C3hfiYOG.js → SpendPercentileChip-CzY6h1gP.js} +1 -1
  50. package/dist/ui/assets/{TrendChart-B8RRiXUW.js → TrendChart-BSLwlBiq.js} +1 -1
  51. package/dist/ui/assets/WorkspaceDetailPage-DFOdEB39.js +1 -0
  52. package/dist/ui/assets/{WorkspacesPage-BI0AvjU4.js → WorkspacesPage-Doct6meK.js} +1 -1
  53. package/dist/ui/assets/{chart-theme-F_Y5-T0C.js → chart-theme-DYgw_Tac.js} +1 -1
  54. package/dist/ui/assets/{graphicalItemSelectors-XzBS96LG.js → graphicalItemSelectors-0-Ue_y1f.js} +1 -1
  55. package/dist/ui/assets/{index-DWsa7LPr.js → index-DhFiA8Ch.js} +10 -10
  56. package/dist/ui/assets/{useExperimentalActions-4CyCEvGU.js → useExperimentalActions-aI00AFZv.js} +1 -1
  57. package/dist/ui/index.html +1 -1
  58. package/package.json +1 -1
  59. package/dist/ui/assets/FrictionCell-BN-7hRQU.js +0 -2
  60. package/dist/ui/assets/HotSessionsPage-C_hD8WUr.js +0 -1
  61. package/dist/ui/assets/RecommendationsPage-DmguRb6u.js +0 -2
  62. package/dist/ui/assets/SessionDetailPage-kcGm5Cph.js +0 -1
  63. package/dist/ui/assets/WorkspaceDetailPage-DDb6M8cK.js +0 -1
@@ -302,7 +302,10 @@ function completeApplyPhase(db, job, rec, result, rt) {
302
302
  let runId = null;
303
303
  const completedAt = rt.now();
304
304
  try {
305
- adoptRecommendation(job.rec_id, completedAt.getTime());
305
+ adoptRecommendation(job.rec_id, completedAt.getTime(), {
306
+ machineConfirmed: true,
307
+ actionRevision: job.job_id,
308
+ });
306
309
  runId = randomUUID();
307
310
  const evidencePackHash = createHash("sha256")
308
311
  .update(JSON.stringify(rec.evidence))
@@ -417,6 +420,9 @@ export function rollbackApplyJob(jobId) {
417
420
  if (row.status !== "APPLIED" && row.status !== "CONFIRMING") {
418
421
  throw new Error("job cannot be rolled back from this state");
419
422
  }
423
+ if (db.prepare("SELECT 1 FROM effect_cycles WHERE rec_id=? LIMIT 1").get(row.rec_id)) {
424
+ throw new Error("Whole-file backup rollback is not a supported effect inverse. Revert manually and attest the rollback in the effect ledger.");
425
+ }
420
426
  if (row.backup_path === null)
421
427
  throw new Error("no backup available");
422
428
  fs.copyFileSync(row.backup_path, row.file_ref);
@@ -0,0 +1,24 @@
1
+ import { runMeasurementPass } from "../detector/measurement.js";
2
+ import { ESF_EFFECT_WRITER_ENABLED } from "../effects/gate.js";
3
+ import { effectEngineForDb } from "../query/api/effect-service.js";
4
+ const INTERVAL_MS = 60 * 60 * 1000;
5
+ const lastPass = new WeakMap();
6
+ /** Probe-clock scheduling; restarting can safely replay the bounded, idempotent engine pass. */
7
+ export function runEffectMeasurementPass(db, now) {
8
+ if (!ESF_EFFECT_WRITER_ENABLED) {
9
+ runMeasurementPass(db, now);
10
+ return;
11
+ }
12
+ const previous = lastPass.get(db);
13
+ const current = now.getTime();
14
+ if (previous !== undefined && current >= previous && current - previous < INTERVAL_MS)
15
+ return;
16
+ lastPass.set(db, current);
17
+ try {
18
+ effectEngineForDb(db).runPass(now);
19
+ }
20
+ catch {
21
+ // Never expose source identities or DB contents in a background error.
22
+ console.warn("Effect measurement pass failed; retrying on the next scheduled pass.");
23
+ }
24
+ }
@@ -138,6 +138,8 @@ 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;
141
143
  return false;
142
144
  }
143
145
  /**
@@ -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,6 +36,7 @@ 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
41
  import { createOutcomesPassRunner } from "./outcomes-pass.js";
42
42
  import { setReady, setScanRoots, setScanState } from "./readiness.js";
@@ -107,6 +107,10 @@ if (isSmoke) {
107
107
  "review_findings",
108
108
  "recommendations",
109
109
  "recommendation_effects",
110
+ "effect_cycles",
111
+ "effect_guardrail_results",
112
+ "effect_rollback_operations",
113
+ "effect_mutation_keys",
110
114
  "apply_jobs",
111
115
  "analysis_runs",
112
116
  "ingest_quarantine",
@@ -150,18 +154,16 @@ function runProbePass(label) {
150
154
  const now = new Date();
151
155
  const { rows } = runContextProbe(db, now);
152
156
  console.log(`ContextProbe[${label}]: ${rows} row(s) upserted`);
153
- // W4: measurement pass after each probe (best-effort; the seam never throws
154
- // through, and runMeasurementPass itself is guarded/log-not-throw).
157
+ // Best-effort measurement after each probe, using the same observation clock.
155
158
  runPostProbeHook(db, now);
156
159
  }
157
160
  catch (e) {
158
161
  console.log(`ContextProbe[${label}]: skipped — ${e instanceof Error ? e.message : String(e)}`);
159
162
  }
160
163
  }
161
- // Wire the W4 Impact Ledger measurement pass onto the post-probe seam. The pass
162
- // receives the probe's clock (deterministic; NFR-107), is throttled internally.
164
+ // Versioned measurement owns new cycles; W4 history stays read-only under the code gate.
163
165
  setPostProbeHook((probeDb, now) => {
164
- runMeasurementPass(probeDb, now);
166
+ runEffectMeasurementPass(probeDb, now);
165
167
  });
166
168
  runProbePass("boot");
167
169
  // ── Outcomes bootstrap (WP5 — best-effort, never crash daemon) ────────────────
@@ -14,6 +14,7 @@ import { endSession, getAgentsLiveness } from "../query/api/agents-liveness.js";
14
14
  import { getBurnStatus } from "../query/api/burn-status.js";
15
15
  import { getCostPerSuccess } from "../query/api/cost-per-success.js";
16
16
  import { getDeliveryMetrics } from "../query/api/delivery.js";
17
+ import { EffectRequestError, listEffectEvidence, mutateEffectCycle, trackCompletedChange, } from "../query/api/effect-service.js";
17
18
  import { getClosureProxy } from "../query/api/effectiveness.js";
18
19
  import { getEfficiencyHeadroom } from "../query/api/efficiency-headroom.js";
19
20
  import { getHeadroomTrend } from "../query/api/headroom-trend.js";
@@ -35,10 +36,19 @@ function sendJson(res, status, body) {
35
36
  });
36
37
  res.end(payload);
37
38
  }
38
- function readBody(req) {
39
+ function readBody(req, maxBytes) {
39
40
  return new Promise((resolve, reject) => {
40
41
  const chunks = [];
41
- req.on("data", (chunk) => chunks.push(chunk));
42
+ let size = 0;
43
+ req.on("data", (chunk) => {
44
+ size += chunk.length;
45
+ if (maxBytes !== undefined && size > maxBytes) {
46
+ reject(new EffectRequestError(400, "Request body is too large."));
47
+ req.resume();
48
+ return;
49
+ }
50
+ chunks.push(chunk);
51
+ });
42
52
  req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
43
53
  req.on("error", reject);
44
54
  });
@@ -128,6 +138,47 @@ export function handleApiRequest(_db, req, res, method, url) {
128
138
  // Strip query string for routing.
129
139
  const pathname = url.split("?")[0] ?? url;
130
140
  try {
141
+ // GET /api/esf/effects — independently keyset-paged versioned and legacy evidence.
142
+ if (method === "GET" && pathname === "/api/esf/effects") {
143
+ const params = new URLSearchParams(url.split("?")[1] ?? "");
144
+ const workspaceId = params.get("workspace_id");
145
+ 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));
146
+ return;
147
+ }
148
+ const effectAction = pathname.match(/^\/api\/esf\/effects\/(track|stop|close|rollback|attest-rollback)$/)?.[1];
149
+ if (method === "POST" && effectAction !== undefined) {
150
+ readBody(req, 4096)
151
+ .then((raw) => {
152
+ let body;
153
+ try {
154
+ body = JSON.parse(raw);
155
+ }
156
+ catch {
157
+ sendJson(res, 400, { error: "Invalid JSON body." });
158
+ return;
159
+ }
160
+ try {
161
+ const result = effectAction === "track"
162
+ ? trackCompletedChange(_db, body)
163
+ : mutateEffectCycle(_db, effectAction, body);
164
+ sendJson(res, 200, result);
165
+ }
166
+ catch (error) {
167
+ const status = error instanceof EffectRequestError ? error.status : 409;
168
+ sendJson(res, status, {
169
+ error: error instanceof EffectRequestError
170
+ ? error.message
171
+ : "Effect request conflicts with the current cycle.",
172
+ });
173
+ }
174
+ })
175
+ .catch((error) => sendJson(res, error instanceof EffectRequestError ? error.status : 500, {
176
+ error: error instanceof EffectRequestError
177
+ ? error.message
178
+ : "Internal error reading request body",
179
+ }));
180
+ return;
181
+ }
131
182
  // GET /api/overview
132
183
  if (method === "GET" && pathname === "/api/overview") {
133
184
  sendJson(res, 200, getGlobalOverview(parseWindowFilter(url)));
@@ -264,7 +315,7 @@ export function handleApiRequest(_db, req, res, method, url) {
264
315
  sendJson(res, 200, getPractices(_db, { from, to }));
265
316
  return;
266
317
  }
267
- // GET /api/efficiency-headroom (BM2 modeled savings vs trailing-window spend)
318
+ // GET /api/efficiency-headroom (individual weekly estimates; separate selected-window spend)
268
319
  if (method === "GET" && pathname === "/api/efficiency-headroom") {
269
320
  const { from, to } = resolveWindow(parseWindowFilter(url));
270
321
  sendJson(res, 200, getEfficiencyHeadroom(_db, { from, to }));
@@ -506,13 +557,15 @@ export function handleApiRequest(_db, req, res, method, url) {
506
557
  sendJson(res, 400, { error: "Invalid JSON body" });
507
558
  return;
508
559
  }
509
- const { rec_id } = body;
560
+ const { rec_id, completed_change } = body;
510
561
  if (typeof rec_id !== "string" || rec_id.length === 0) {
511
562
  sendJson(res, 400, { error: "rec_id is required" });
512
563
  return;
513
564
  }
514
565
  try {
515
- sendJson(res, 200, adoptRecommendation(rec_id));
566
+ sendJson(res, 200, adoptRecommendation(rec_id, Date.now(), {
567
+ completedChange: completed_change === true,
568
+ }));
516
569
  }
517
570
  catch (e) {
518
571
  sendJson(res, 400, { error: e instanceof Error ? e.message : "Adopt failed" });
@@ -749,7 +802,11 @@ export function handleApiRequest(_db, req, res, method, url) {
749
802
  // 404 for everything else.
750
803
  sendJson(res, 404, { error: "Not found", path: pathname });
751
804
  }
752
- catch {
805
+ catch (error) {
806
+ if (error instanceof EffectRequestError) {
807
+ sendJson(res, error.status, { error: error.message });
808
+ return;
809
+ }
753
810
  // Do not leak internal exception text into a cross-origin-readable 500 body
754
811
  // (GET routes are not CSRF-gated). Validation errors on POST routes still
755
812
  // 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
+ );
@@ -1,4 +1,6 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { performance } from "node:perf_hooks";
3
+ import { cycleFromRow } from "../effects/store.js";
2
4
  import { GLOBAL_WORKSPACE_ID } from "./context-probe.js";
3
5
  import { isD1SourceBackedRecommendation, parseD1SourceIdentity } from "./d1-source-identity.js";
4
6
  import { AFTER_WINDOW_DAYS } from "./measurement.js";
@@ -78,10 +80,15 @@ function validateHistoryRows(db) {
78
80
  });
79
81
  }
80
82
  function readOpenEffects(db) {
83
+ const hasVersioned = db.prepare("SELECT 1 FROM sqlite_master WHERE name='effect_cycles'").get();
84
+ const versionedExclusion = hasVersioned
85
+ ? "AND NOT EXISTS (SELECT 1 FROM effect_cycles c WHERE c.rec_id=recommendations.rec_id)"
86
+ : "";
81
87
  const recs = db
82
88
  .prepare(`SELECT rec_id, detector_id, scope_workspace_id, evidence_json, target_metric, adopted_at
83
89
  FROM recommendations
84
90
  WHERE state IN ('ADOPTED', 'MEASURING')
91
+ ${versionedExclusion}
85
92
  ORDER BY rec_id ASC`)
86
93
  .all();
87
94
  const sourceBacked = recs.filter(isD1SourceBackedRecommendation);
@@ -140,6 +147,48 @@ function readOpenEffects(db) {
140
147
  }
141
148
  if (malformedN > 0)
142
149
  throw new RetentionPlanError("invalid_open_effect", malformedN);
150
+ if (hasVersioned) {
151
+ const rows = db
152
+ .prepare(`SELECT c.*,r.evidence_json,r.scope_workspace_id
153
+ FROM effect_cycles c JOIN recommendations r ON r.rec_id=c.rec_id
154
+ WHERE c.detector_id='D1' AND c.state IN ('OPEN_SETTLING','OPEN_MEASURING')`)
155
+ .all();
156
+ for (const row of rows) {
157
+ const cycle = cycleFromRow(row);
158
+ const identity = parseD1SourceIdentity(String(row.evidence_json));
159
+ const workspaceId = row.scope_workspace_id === null ? GLOBAL_WORKSPACE_ID : String(row.scope_workspace_id);
160
+ const beforeToMs = canonicalTimestamp(cycle.baselineTo);
161
+ const afterFromMs = canonicalTimestamp(cycle.observationFrom);
162
+ const afterToMs = canonicalTimestamp(cycle.scheduledObservationTo);
163
+ if (!identity ||
164
+ !COMPONENTS.has(identity.component) ||
165
+ !identity.fileRef ||
166
+ cycle.versionStatus !== "SUPPORTED" ||
167
+ beforeToMs === null ||
168
+ afterFromMs === null ||
169
+ afterToMs === null ||
170
+ beforeToMs !== afterFromMs ||
171
+ afterToMs < afterFromMs) {
172
+ throw new RetentionPlanError("invalid_open_effect", 1);
173
+ }
174
+ // Same opaque tuple encoding as the service; a moved/reassigned source is not reinterpreted.
175
+ const digest = createHash("sha256")
176
+ .update(JSON.stringify({
177
+ workspaceId,
178
+ component: identity.component,
179
+ fileRef: identity.fileRef,
180
+ }))
181
+ .digest("hex");
182
+ if (digest !== cycle.scope.sourceIdentity)
183
+ throw new RetentionPlanError("invalid_open_effect", 1);
184
+ effects.push({
185
+ sourceKey: sourceKey(workspaceId, identity.component, identity.fileRef),
186
+ beforeToMs,
187
+ afterFromMs,
188
+ afterToMs,
189
+ });
190
+ }
191
+ }
143
192
  return effects;
144
193
  }
145
194
  function emptySummary(mode, startedAt, failureClass, malformedOpenEffectN = 0) {
@@ -20,6 +20,7 @@
20
20
  *
21
21
  * Injected `now: Date` everywhere — never call new Date() in this module.
22
22
  */
23
+ import { ESF_EFFECT_WRITER_ENABLED } from "../effects/gate.js";
23
24
  import { PREMIUM_MODEL_SQL } from "../ingest/pricing.js";
24
25
  import { GLOBAL_WORKSPACE_ID } from "./context-probe.js";
25
26
  import { isD1SourceBackedRecommendation, parseD1SourceIdentity } from "./d1-source-identity.js";
@@ -135,7 +136,8 @@ function handlerFor(rec) {
135
136
  }
136
137
  /** Warning-class recs (D5 / LIMIT / explicit NONE) skip the lifecycle entirely (design §2d). */
137
138
  export function isWarningClass(rec) {
138
- return (rec.category === "LIMIT" ||
139
+ return (rec.detector_id === "D5" ||
140
+ rec.category === "LIMIT" ||
139
141
  rec.target_metric === "NONE" ||
140
142
  rec.target_metric === "forecast_margin");
141
143
  }
@@ -364,6 +366,9 @@ function closeMeasurement(db, rec, now, result) {
364
366
  */
365
367
  export function runMeasurementPass(db, now, opts) {
366
368
  const result = { to_measuring: 0, verdicts: 0, skipped: 0 };
369
+ // The versioned writer leaves every historical W4 row unchanged, including open rows.
370
+ if (ESF_EFFECT_WRITER_ENABLED)
371
+ return result;
367
372
  if (!tableExists(db, "context_inventory_history")) {
368
373
  console.warn("W4: context_inventory_history not available; measurement pass skipped");
369
374
  return result;
@@ -381,11 +386,16 @@ export function runMeasurementPass(db, now, opts) {
381
386
  }
382
387
  }
383
388
  try {
389
+ // A code-gate rollback must not let W4 take over an already-versioned recommendation.
390
+ const versionedExclusion = tableExists(db, "effect_cycles")
391
+ ? "AND NOT EXISTS (SELECT 1 FROM effect_cycles c WHERE c.rec_id=recommendations.rec_id)"
392
+ : "";
384
393
  const rows = db
385
394
  .prepare(`SELECT rec_id, detector_id, category, scope_workspace_id, evidence_json,
386
395
  target_metric, state, adopted_at
387
396
  FROM recommendations
388
397
  WHERE state IN ('ADOPTED', 'MEASURING') AND adopted_at IS NOT NULL
398
+ ${versionedExclusion}
389
399
  ORDER BY adopted_at ASC, rec_id ASC`)
390
400
  .all();
391
401
  for (const rec of rows) {
@@ -0,0 +1 @@
1
+ export {};