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.
- package/dist/apply/jobs.js +7 -1
- package/dist/daemon/effect-pass.js +24 -0
- package/dist/daemon/http.js +2 -0
- package/dist/daemon/index.js +8 -6
- package/dist/daemon/router.js +63 -6
- package/dist/db/migrations/018_effect_cycles.sql +109 -0
- package/dist/detector/context-history-retention.js +49 -0
- package/dist/detector/measurement.js +11 -1
- package/dist/effects/api-contract.js +1 -0
- package/dist/effects/engine.js +574 -0
- package/dist/effects/gate.js +2 -0
- package/dist/effects/index.js +5 -0
- package/dist/effects/legacy.js +28 -0
- package/dist/effects/registry.js +152 -0
- package/dist/effects/sql-observer.js +238 -0
- package/dist/effects/store.js +181 -0
- package/dist/effects/types.js +2 -0
- package/dist/query/api/cost-per-success.js +13 -1
- package/dist/query/api/delivery.js +25 -3
- package/dist/query/api/effect-service.js +308 -0
- package/dist/query/api/effectiveness.js +30 -5
- package/dist/query/api/efficiency-headroom.js +12 -14
- package/dist/query/api/esf-observations.js +254 -0
- package/dist/query/api/overview.js +77 -3
- package/dist/query/api/recommendations-ledger.js +9 -14
- package/dist/query/api/recommendations.js +21 -1
- package/dist/query/settings-store.js +4 -0
- package/dist/query/spend.js +64 -2
- package/dist/ui/assets/{BarChart-VQGcJCLY.js → BarChart-CIjCNfbI.js} +1 -1
- package/dist/ui/assets/{BriefsPage-CmhxCHLf.js → BriefsPage-DIU8iFjV.js} +1 -1
- package/dist/ui/assets/{CacheWriteSpikesChart-C641X3kK.js → CacheWriteSpikesChart-1Nfnf7OX.js} +1 -1
- package/dist/ui/assets/{CartesianChart-BTva-_b9.js → CartesianChart-DEnDOGKj.js} +1 -1
- package/dist/ui/assets/{Chip-Gem94sHD.js → Chip-s-32Dh3o.js} +1 -1
- package/dist/ui/assets/{ComposedChart-CS5SgLoo.js → ComposedChart-DUw8Y79v.js} +1 -1
- package/dist/ui/assets/{EmptyState-Dhyw1Dvj.js → EmptyState-B8vpug3_.js} +1 -1
- package/dist/ui/assets/{FlavorDecomposition-Dle5qGWA.js → FlavorDecomposition-CkHX3M-U.js} +1 -1
- package/dist/ui/assets/FrictionCell-X75fKRBK.js +2 -0
- package/dist/ui/assets/{GlossaryPage-DkZXoJTT.js → GlossaryPage-D1tYumqL.js} +1 -1
- package/dist/ui/assets/HotSessionsPage-8HlRe0VB.js +1 -0
- package/dist/ui/assets/{InfoTip-DCQtERnJ.js → InfoTip-CL8RC4vz.js} +1 -1
- package/dist/ui/assets/{Legend-SbbUv9Nm.js → Legend-CB7sqqJH.js} +1 -1
- package/dist/ui/assets/{Line-Dvv4aBXw.js → Line-B9XY30uT.js} +1 -1
- package/dist/ui/assets/{OverviewPage-Blab_aYw.js → OverviewPage-DL4joBBE.js} +2 -2
- package/dist/ui/assets/RecommendationsPage-NkfH17eB.js +2 -0
- package/dist/ui/assets/{Scatter-xyJ9miNg.js → Scatter-BSWm_yIZ.js} +1 -1
- package/dist/ui/assets/SessionDetailPage-Ceqt7poO.js +1 -0
- package/dist/ui/assets/{SettingsPage-DWfbUCe8.js → SettingsPage-ZK4ii5h_.js} +1 -1
- package/dist/ui/assets/{Skeleton-D5lrQpCm.js → Skeleton-Dp_m79B8.js} +1 -1
- package/dist/ui/assets/{SpendPercentileChip-C3hfiYOG.js → SpendPercentileChip-CzY6h1gP.js} +1 -1
- package/dist/ui/assets/{TrendChart-B8RRiXUW.js → TrendChart-BSLwlBiq.js} +1 -1
- package/dist/ui/assets/WorkspaceDetailPage-DFOdEB39.js +1 -0
- package/dist/ui/assets/{WorkspacesPage-BI0AvjU4.js → WorkspacesPage-Doct6meK.js} +1 -1
- package/dist/ui/assets/{chart-theme-F_Y5-T0C.js → chart-theme-DYgw_Tac.js} +1 -1
- package/dist/ui/assets/{graphicalItemSelectors-XzBS96LG.js → graphicalItemSelectors-0-Ue_y1f.js} +1 -1
- package/dist/ui/assets/{index-DWsa7LPr.js → index-DhFiA8Ch.js} +10 -10
- package/dist/ui/assets/{useExperimentalActions-4CyCEvGU.js → useExperimentalActions-aI00AFZv.js} +1 -1
- package/dist/ui/index.html +1 -1
- package/package.json +1 -1
- package/dist/ui/assets/FrictionCell-BN-7hRQU.js +0 -2
- package/dist/ui/assets/HotSessionsPage-C_hD8WUr.js +0 -1
- package/dist/ui/assets/RecommendationsPage-DmguRb6u.js +0 -2
- package/dist/ui/assets/SessionDetailPage-kcGm5Cph.js +0 -1
- package/dist/ui/assets/WorkspaceDetailPage-DDb6M8cK.js +0 -1
package/dist/apply/jobs.js
CHANGED
|
@@ -302,7 +302,10 @@ function completeApplyPhase(db, job, rec, result, rt) {
|
|
|
302
302
|
let runId = null;
|
|
303
303
|
const completedAt = rt.now();
|
|
304
304
|
try {
|
|
305
|
-
adoptRecommendation(job.rec_id, completedAt.getTime()
|
|
305
|
+
adoptRecommendation(job.rec_id, completedAt.getTime(), {
|
|
306
|
+
machineConfirmed: true,
|
|
307
|
+
actionRevision: job.job_id,
|
|
308
|
+
});
|
|
306
309
|
runId = randomUUID();
|
|
307
310
|
const evidencePackHash = createHash("sha256")
|
|
308
311
|
.update(JSON.stringify(rec.evidence))
|
|
@@ -417,6 +420,9 @@ export function rollbackApplyJob(jobId) {
|
|
|
417
420
|
if (row.status !== "APPLIED" && row.status !== "CONFIRMING") {
|
|
418
421
|
throw new Error("job cannot be rolled back from this state");
|
|
419
422
|
}
|
|
423
|
+
if (db.prepare("SELECT 1 FROM effect_cycles WHERE rec_id=? LIMIT 1").get(row.rec_id)) {
|
|
424
|
+
throw new Error("Whole-file backup rollback is not a supported effect inverse. Revert manually and attest the rollback in the effect ledger.");
|
|
425
|
+
}
|
|
420
426
|
if (row.backup_path === null)
|
|
421
427
|
throw new Error("no backup available");
|
|
422
428
|
fs.copyFileSync(row.backup_path, row.file_ref);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { runMeasurementPass } from "../detector/measurement.js";
|
|
2
|
+
import { ESF_EFFECT_WRITER_ENABLED } from "../effects/gate.js";
|
|
3
|
+
import { effectEngineForDb } from "../query/api/effect-service.js";
|
|
4
|
+
const INTERVAL_MS = 60 * 60 * 1000;
|
|
5
|
+
const lastPass = new WeakMap();
|
|
6
|
+
/** Probe-clock scheduling; restarting can safely replay the bounded, idempotent engine pass. */
|
|
7
|
+
export function runEffectMeasurementPass(db, now) {
|
|
8
|
+
if (!ESF_EFFECT_WRITER_ENABLED) {
|
|
9
|
+
runMeasurementPass(db, now);
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
const previous = lastPass.get(db);
|
|
13
|
+
const current = now.getTime();
|
|
14
|
+
if (previous !== undefined && current >= previous && current - previous < INTERVAL_MS)
|
|
15
|
+
return;
|
|
16
|
+
lastPass.set(db, current);
|
|
17
|
+
try {
|
|
18
|
+
effectEngineForDb(db).runPass(now);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
// Never expose source identities or DB contents in a background error.
|
|
22
|
+
console.warn("Effect measurement pass failed; retrying on the next scheduled pass.");
|
|
23
|
+
}
|
|
24
|
+
}
|
package/dist/daemon/http.js
CHANGED
|
@@ -138,6 +138,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
|
/**
|
package/dist/daemon/index.js
CHANGED
|
@@ -22,7 +22,6 @@ import { openDb } from "../db/open.js";
|
|
|
22
22
|
import { configGet as bptConfigGet, calibrateBytesPerToken } from "../detector/calibration.js";
|
|
23
23
|
import { runContextProbe } from "../detector/context-probe.js";
|
|
24
24
|
import { runDetectors } from "../detector/index.js";
|
|
25
|
-
import { runMeasurementPass } from "../detector/measurement.js";
|
|
26
25
|
import { installHook, uninstallHook } from "../hook/install.js";
|
|
27
26
|
import { collectSessionChurn } from "../ingest/churn-collector.js";
|
|
28
27
|
import { runPostProbeHook, setPostIngestHook, setPostProbeHook } from "../ingest/detector-hook.js";
|
|
@@ -37,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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
166
|
+
runEffectMeasurementPass(probeDb, now);
|
|
165
167
|
});
|
|
166
168
|
runProbePass("boot");
|
|
167
169
|
// ── Outcomes bootstrap (WP5 — best-effort, never crash daemon) ────────────────
|
package/dist/daemon/router.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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.
|
|
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 {};
|