agentwrangler 0.1.1-next.826ece4 → 0.1.1-next.adabfa3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/daemon/http.js +8 -0
- package/dist/daemon/router.js +233 -8
- package/dist/db/migrations/019_work_records.sql +291 -0
- package/dist/query/api/esf-observations.js +33 -2
- package/dist/query/api/reported-work.js +46 -0
- package/dist/query/api/work-records.js +85 -0
- package/dist/query/settings-store.js +9 -0
- package/dist/ui/assets/{BarChart-CQbdKRIK.js → BarChart-BOecTQgC.js} +1 -1
- package/dist/ui/assets/{BriefsPage-CEpYiWdj.js → BriefsPage-CjvXspiw.js} +1 -1
- package/dist/ui/assets/{CacheWriteSpikesChart-BsSR8umm.js → CacheWriteSpikesChart-5Pn1sUZC.js} +1 -1
- package/dist/ui/assets/{CartesianChart-B8S3scl8.js → CartesianChart-56rkZQt4.js} +1 -1
- package/dist/ui/assets/{Chip-CA4wIi6T.js → Chip-BZTcuGOm.js} +1 -1
- package/dist/ui/assets/{ComposedChart-v1IkqzcG.js → ComposedChart-C8PPdkFl.js} +1 -1
- package/dist/ui/assets/{EmptyState-QhnMG1cV.js → EmptyState-BfD86Pv3.js} +1 -1
- package/dist/ui/assets/{FlavorDecomposition-DPnknTC4.js → FlavorDecomposition-BsOuw4CF.js} +1 -1
- package/dist/ui/assets/{FrictionCell-DaE9QneM.js → FrictionCell-DQBJsot-.js} +1 -1
- package/dist/ui/assets/{GlossaryPage-BL2Dak3C.js → GlossaryPage-DutLIAlv.js} +1 -1
- package/dist/ui/assets/{HotSessionsPage-B-njRmVy.js → HotSessionsPage-DLWlyBfb.js} +1 -1
- package/dist/ui/assets/{InfoTip-C4ZL8HcU.js → InfoTip-BrYyvCNl.js} +1 -1
- package/dist/ui/assets/{Legend-B02ulZSz.js → Legend-DzSAmt8-.js} +1 -1
- package/dist/ui/assets/{Line-3W0HD2qo.js → Line-1eksYo3m.js} +1 -1
- package/dist/ui/assets/Modal-DX-JN5Ul.js +1 -0
- package/dist/ui/assets/ObservationEvidence-Bbsc-DCW.js +1 -0
- package/dist/ui/assets/ObservationEvidence-Cy92xibu.css +1 -0
- package/dist/ui/assets/OverviewPage-CWgzRIqk.js +2 -0
- package/dist/ui/assets/RecommendationsPage-sRHEku70.js +2 -0
- package/dist/ui/assets/{Scatter-B2WQrP2P.js → Scatter-Ys7Lbhjf.js} +1 -1
- package/dist/ui/assets/SessionDetailPage-L02ituTQ.js +1 -0
- package/dist/ui/assets/{SettingsPage-CisYPwFk.js → SettingsPage-DJjWicvx.js} +4 -4
- package/dist/ui/assets/{Skeleton-CXHTXg1O.js → Skeleton-ab1u7ntu.js} +1 -1
- package/dist/ui/assets/{SpendPercentileChip-CmT4Dh81.js → SpendPercentileChip-DnRJWBUP.js} +1 -1
- package/dist/ui/assets/{TrendChart-DhE3th2k.js → TrendChart-_nohASRq.js} +1 -1
- package/dist/ui/assets/WorkspaceDetailPage-BQ3XA3Hf.js +1 -0
- package/dist/ui/assets/{WorkspacesPage-CiPasCJW.js → WorkspacesPage-DKdpBgC9.js} +1 -1
- package/dist/ui/assets/{chart-theme-BKzBXNjC.js → chart-theme-0POFlal8.js} +1 -1
- package/dist/ui/assets/{graphicalItemSelectors-DFL6tG4x.js → graphicalItemSelectors-B97PsJDp.js} +1 -1
- package/dist/ui/assets/{index-anuDukMM.js → index-DAAmee2m.js} +2 -2
- package/dist/ui/assets/{useExperimentalActions-CPl-lsm9.js → useExperimentalActions-avkWaDmQ.js} +1 -1
- package/dist/ui/index.html +1 -1
- package/dist/work-records/allocation.js +242 -0
- package/dist/work-records/ids.js +80 -0
- package/dist/work-records/index.js +4 -0
- package/dist/work-records/store.js +376 -0
- package/dist/work-records/types.js +29 -0
- package/dist/work-records/validation.js +71 -0
- package/package.json +1 -1
- package/dist/ui/assets/OverviewPage-Dn68beIQ.js +0 -2
- package/dist/ui/assets/RecommendationsPage-DXo_pldt.js +0 -2
- package/dist/ui/assets/SessionDetailPage-tXXISzbh.js +0 -1
- package/dist/ui/assets/WorkspaceDetailPage-BiCqKdSw.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/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)));
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
-- ESF3: privacy-preserving work records, immutable membership history, and
|
|
2
|
+
-- frozen allocation reports. All human-provided values are bounded enums;
|
|
3
|
+
-- opaque identifiers and timestamps are issued by the daemon.
|
|
4
|
+
|
|
5
|
+
CREATE TABLE work_records (
|
|
6
|
+
work_record_id TEXT PRIMARY KEY,
|
|
7
|
+
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id),
|
|
8
|
+
created_at TEXT NOT NULL,
|
|
9
|
+
updated_at TEXT NOT NULL,
|
|
10
|
+
current_revision_no INTEGER NOT NULL CHECK (current_revision_no >= 0),
|
|
11
|
+
archived_at TEXT,
|
|
12
|
+
external_ref_id TEXT,
|
|
13
|
+
external_ref_kind TEXT CHECK (external_ref_kind IN ('ISSUE', 'PULL_REQUEST', 'TASK', 'OTHER')),
|
|
14
|
+
CHECK ((external_ref_id IS NULL) = (external_ref_kind IS NULL))
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
CREATE TABLE work_record_revisions (
|
|
18
|
+
work_record_id TEXT NOT NULL REFERENCES work_records(work_record_id) ON DELETE CASCADE,
|
|
19
|
+
revision_no INTEGER NOT NULL CHECK (revision_no >= 0),
|
|
20
|
+
task_intent TEXT NOT NULL CHECK (task_intent IN
|
|
21
|
+
('IMPLEMENT', 'DEBUG', 'REVIEW', 'RESEARCH_PLAN', 'OTHER', 'UNKNOWN')),
|
|
22
|
+
outcome_state TEXT NOT NULL CHECK (outcome_state IN
|
|
23
|
+
('ACTIVE', 'USEFUL', 'PARTIAL', 'UNSUCCESSFUL', 'ABANDONED', 'UNKNOWN')),
|
|
24
|
+
repair_band TEXT NOT NULL CHECK (repair_band IN
|
|
25
|
+
('NONE', 'MINOR', 'MAJOR', 'UNREPORTED', 'UNKNOWN')),
|
|
26
|
+
effort_band TEXT NOT NULL CHECK (effort_band IN
|
|
27
|
+
('LOW', 'MEDIUM', 'HIGH', 'UNREPORTED', 'UNKNOWN')),
|
|
28
|
+
feedback_source TEXT NOT NULL CHECK (feedback_source IN ('USER_CLOSEOUT', 'USER_EDIT', 'NONE')),
|
|
29
|
+
reported_at TEXT,
|
|
30
|
+
recorded_at TEXT NOT NULL,
|
|
31
|
+
mutation_id TEXT NOT NULL UNIQUE,
|
|
32
|
+
PRIMARY KEY (work_record_id, revision_no),
|
|
33
|
+
CHECK ((feedback_source = 'NONE' AND reported_at IS NULL)
|
|
34
|
+
OR (feedback_source <> 'NONE' AND reported_at IS NOT NULL))
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
CREATE TRIGGER trg_work_record_revision_immutable
|
|
38
|
+
BEFORE UPDATE ON work_record_revisions
|
|
39
|
+
BEGIN
|
|
40
|
+
SELECT RAISE(ABORT, 'work_record_revision_immutable');
|
|
41
|
+
END;
|
|
42
|
+
|
|
43
|
+
CREATE TRIGGER trg_work_record_identity_immutable
|
|
44
|
+
BEFORE UPDATE ON work_records
|
|
45
|
+
BEGIN
|
|
46
|
+
SELECT CASE WHEN NEW.work_record_id <> OLD.work_record_id
|
|
47
|
+
OR NEW.workspace_id <> OLD.workspace_id
|
|
48
|
+
OR NEW.created_at <> OLD.created_at
|
|
49
|
+
OR COALESCE(NEW.external_ref_id, '') <> COALESCE(OLD.external_ref_id, '')
|
|
50
|
+
OR COALESCE(NEW.external_ref_kind, '') <> COALESCE(OLD.external_ref_kind, '')
|
|
51
|
+
THEN RAISE(ABORT, 'work_record_identity_immutable') END;
|
|
52
|
+
SELECT CASE WHEN NEW.current_revision_no <> OLD.current_revision_no
|
|
53
|
+
AND NEW.current_revision_no <> OLD.current_revision_no + 1
|
|
54
|
+
THEN RAISE(ABORT, 'work_record_revision_pointer_invalid') END;
|
|
55
|
+
END;
|
|
56
|
+
|
|
57
|
+
CREATE TABLE work_record_session_links (
|
|
58
|
+
work_record_id TEXT NOT NULL REFERENCES work_records(work_record_id) ON DELETE CASCADE,
|
|
59
|
+
session_id TEXT NOT NULL REFERENCES sessions(session_id),
|
|
60
|
+
linked_revision_no INTEGER NOT NULL,
|
|
61
|
+
linked_at TEXT NOT NULL,
|
|
62
|
+
unlinked_revision_no INTEGER,
|
|
63
|
+
unlinked_at TEXT,
|
|
64
|
+
link_source TEXT NOT NULL CHECK (link_source = 'USER'),
|
|
65
|
+
link_mutation_id TEXT NOT NULL UNIQUE,
|
|
66
|
+
unlink_mutation_id TEXT UNIQUE,
|
|
67
|
+
PRIMARY KEY (work_record_id, session_id, linked_revision_no),
|
|
68
|
+
FOREIGN KEY (work_record_id, linked_revision_no)
|
|
69
|
+
REFERENCES work_record_revisions(work_record_id, revision_no),
|
|
70
|
+
FOREIGN KEY (work_record_id, unlinked_revision_no)
|
|
71
|
+
REFERENCES work_record_revisions(work_record_id, revision_no),
|
|
72
|
+
CHECK ((unlinked_at IS NULL AND unlinked_revision_no IS NULL AND unlink_mutation_id IS NULL)
|
|
73
|
+
OR (unlinked_at IS NOT NULL AND unlinked_revision_no IS NOT NULL AND unlink_mutation_id IS NOT NULL)),
|
|
74
|
+
CHECK (unlinked_at IS NULL OR unlinked_at > linked_at)
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
CREATE UNIQUE INDEX idx_work_record_session_open
|
|
78
|
+
ON work_record_session_links(work_record_id, session_id) WHERE unlinked_at IS NULL;
|
|
79
|
+
CREATE INDEX idx_work_record_session_links_session_open
|
|
80
|
+
ON work_record_session_links(session_id, linked_at, unlinked_at);
|
|
81
|
+
|
|
82
|
+
CREATE TRIGGER trg_work_record_session_workspace_insert
|
|
83
|
+
BEFORE INSERT ON work_record_session_links
|
|
84
|
+
BEGIN
|
|
85
|
+
SELECT CASE WHEN NOT EXISTS (
|
|
86
|
+
SELECT 1 FROM work_records wr
|
|
87
|
+
JOIN sessions s ON s.session_id = NEW.session_id
|
|
88
|
+
WHERE wr.work_record_id = NEW.work_record_id AND wr.workspace_id = s.workspace_id
|
|
89
|
+
) THEN RAISE(ABORT, 'work_record_session_workspace_mismatch') END;
|
|
90
|
+
END;
|
|
91
|
+
|
|
92
|
+
CREATE TRIGGER trg_work_record_session_workspace_update
|
|
93
|
+
BEFORE UPDATE ON work_record_session_links
|
|
94
|
+
BEGIN
|
|
95
|
+
SELECT CASE WHEN NOT EXISTS (
|
|
96
|
+
SELECT 1 FROM work_records wr
|
|
97
|
+
JOIN sessions s ON s.session_id = NEW.session_id
|
|
98
|
+
WHERE wr.work_record_id = NEW.work_record_id AND wr.workspace_id = s.workspace_id
|
|
99
|
+
) THEN RAISE(ABORT, 'work_record_session_workspace_mismatch') END;
|
|
100
|
+
END;
|
|
101
|
+
|
|
102
|
+
CREATE TRIGGER trg_work_record_session_no_overlap
|
|
103
|
+
BEFORE INSERT ON work_record_session_links
|
|
104
|
+
BEGIN
|
|
105
|
+
SELECT CASE WHEN EXISTS (
|
|
106
|
+
SELECT 1 FROM work_record_session_links old
|
|
107
|
+
WHERE old.work_record_id = NEW.work_record_id
|
|
108
|
+
AND old.session_id = NEW.session_id
|
|
109
|
+
AND NEW.linked_at < COALESCE(old.unlinked_at, '9999-12-31T23:59:59.999Z')
|
|
110
|
+
AND old.linked_at < COALESCE(NEW.unlinked_at, '9999-12-31T23:59:59.999Z')
|
|
111
|
+
) THEN RAISE(ABORT, 'work_record_session_interval_overlap') END;
|
|
112
|
+
END;
|
|
113
|
+
|
|
114
|
+
CREATE TRIGGER trg_work_record_session_closed_immutable
|
|
115
|
+
BEFORE UPDATE ON work_record_session_links
|
|
116
|
+
BEGIN
|
|
117
|
+
SELECT CASE WHEN OLD.unlinked_at IS NOT NULL
|
|
118
|
+
THEN RAISE(ABORT, 'work_record_session_closed_immutable') END;
|
|
119
|
+
SELECT CASE WHEN NEW.work_record_id <> OLD.work_record_id
|
|
120
|
+
OR NEW.session_id <> OLD.session_id
|
|
121
|
+
OR NEW.linked_revision_no <> OLD.linked_revision_no
|
|
122
|
+
OR NEW.linked_at <> OLD.linked_at
|
|
123
|
+
OR NEW.link_source <> OLD.link_source
|
|
124
|
+
OR NEW.link_mutation_id <> OLD.link_mutation_id
|
|
125
|
+
THEN RAISE(ABORT, 'work_record_session_link_immutable') END;
|
|
126
|
+
END;
|
|
127
|
+
|
|
128
|
+
CREATE TABLE work_record_context_refs (
|
|
129
|
+
work_record_id TEXT NOT NULL REFERENCES work_records(work_record_id) ON DELETE CASCADE,
|
|
130
|
+
context_kind TEXT NOT NULL CHECK (context_kind = 'WORKTREE'),
|
|
131
|
+
context_ref_id TEXT NOT NULL,
|
|
132
|
+
linked_revision_no INTEGER NOT NULL,
|
|
133
|
+
linked_at TEXT NOT NULL,
|
|
134
|
+
unlinked_revision_no INTEGER,
|
|
135
|
+
unlinked_at TEXT,
|
|
136
|
+
link_mutation_id TEXT NOT NULL UNIQUE,
|
|
137
|
+
unlink_mutation_id TEXT UNIQUE,
|
|
138
|
+
PRIMARY KEY (work_record_id, context_kind, context_ref_id, linked_revision_no),
|
|
139
|
+
FOREIGN KEY (work_record_id, linked_revision_no)
|
|
140
|
+
REFERENCES work_record_revisions(work_record_id, revision_no),
|
|
141
|
+
FOREIGN KEY (work_record_id, unlinked_revision_no)
|
|
142
|
+
REFERENCES work_record_revisions(work_record_id, revision_no),
|
|
143
|
+
CHECK ((unlinked_at IS NULL AND unlinked_revision_no IS NULL AND unlink_mutation_id IS NULL)
|
|
144
|
+
OR (unlinked_at IS NOT NULL AND unlinked_revision_no IS NOT NULL AND unlink_mutation_id IS NOT NULL)),
|
|
145
|
+
CHECK (unlinked_at IS NULL OR unlinked_at > linked_at)
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
CREATE UNIQUE INDEX idx_work_record_context_open
|
|
149
|
+
ON work_record_context_refs(work_record_id, context_kind, context_ref_id)
|
|
150
|
+
WHERE unlinked_at IS NULL;
|
|
151
|
+
|
|
152
|
+
CREATE TRIGGER trg_work_record_context_no_overlap
|
|
153
|
+
BEFORE INSERT ON work_record_context_refs
|
|
154
|
+
BEGIN
|
|
155
|
+
SELECT CASE WHEN EXISTS (
|
|
156
|
+
SELECT 1 FROM work_record_context_refs old
|
|
157
|
+
WHERE old.work_record_id = NEW.work_record_id
|
|
158
|
+
AND old.context_kind = NEW.context_kind
|
|
159
|
+
AND old.context_ref_id = NEW.context_ref_id
|
|
160
|
+
AND NEW.linked_at < COALESCE(old.unlinked_at, '9999-12-31T23:59:59.999Z')
|
|
161
|
+
AND old.linked_at < COALESCE(NEW.unlinked_at, '9999-12-31T23:59:59.999Z')
|
|
162
|
+
) THEN RAISE(ABORT, 'work_record_context_interval_overlap') END;
|
|
163
|
+
END;
|
|
164
|
+
|
|
165
|
+
CREATE TRIGGER trg_work_record_context_closed_immutable
|
|
166
|
+
BEFORE UPDATE ON work_record_context_refs
|
|
167
|
+
BEGIN
|
|
168
|
+
SELECT CASE WHEN OLD.unlinked_at IS NOT NULL
|
|
169
|
+
THEN RAISE(ABORT, 'work_record_context_closed_immutable') END;
|
|
170
|
+
SELECT CASE WHEN NEW.work_record_id <> OLD.work_record_id
|
|
171
|
+
OR NEW.context_kind <> OLD.context_kind
|
|
172
|
+
OR NEW.context_ref_id <> OLD.context_ref_id
|
|
173
|
+
OR NEW.linked_revision_no <> OLD.linked_revision_no
|
|
174
|
+
OR NEW.linked_at <> OLD.linked_at
|
|
175
|
+
OR NEW.link_mutation_id <> OLD.link_mutation_id
|
|
176
|
+
THEN RAISE(ABORT, 'work_record_context_link_immutable') END;
|
|
177
|
+
END;
|
|
178
|
+
|
|
179
|
+
CREATE TABLE work_record_mutations (
|
|
180
|
+
mutation_id TEXT PRIMARY KEY,
|
|
181
|
+
operation TEXT NOT NULL CHECK (operation IN
|
|
182
|
+
('CREATE', 'EDIT', 'ARCHIVE', 'REOPEN', 'ATTACH_SESSION', 'DETACH_SESSION',
|
|
183
|
+
'ATTACH_CONTEXT', 'DETACH_CONTEXT', 'CLOSEOUT', 'RECOMPUTE_ALLOCATION')),
|
|
184
|
+
request_sha256 TEXT NOT NULL CHECK (length(request_sha256) = 64 AND request_sha256 = lower(request_sha256)),
|
|
185
|
+
work_record_id TEXT REFERENCES work_records(work_record_id) ON DELETE CASCADE,
|
|
186
|
+
expected_revision_no INTEGER CHECK (expected_revision_no IS NULL OR expected_revision_no >= 0),
|
|
187
|
+
resulting_revision_no INTEGER CHECK (resulting_revision_no IS NULL OR resulting_revision_no >= 0),
|
|
188
|
+
allocation_revision_id TEXT,
|
|
189
|
+
applied_at TEXT NOT NULL,
|
|
190
|
+
result TEXT NOT NULL CHECK (result IN ('APPLIED', 'NOOP', 'PARTIAL', 'UNSUPPORTED'))
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
CREATE TABLE work_record_tombstones (
|
|
194
|
+
work_record_id TEXT PRIMARY KEY,
|
|
195
|
+
deleted_at TEXT NOT NULL,
|
|
196
|
+
delete_mutation_id TEXT NOT NULL UNIQUE
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
CREATE TRIGGER trg_work_mutation_id_not_tombstoned
|
|
200
|
+
BEFORE INSERT ON work_record_mutations
|
|
201
|
+
BEGIN
|
|
202
|
+
SELECT CASE WHEN EXISTS (
|
|
203
|
+
SELECT 1 FROM work_record_tombstones WHERE delete_mutation_id = NEW.mutation_id
|
|
204
|
+
) THEN RAISE(ABORT, 'work_mutation_id_conflict') END;
|
|
205
|
+
END;
|
|
206
|
+
|
|
207
|
+
CREATE TRIGGER trg_work_tombstone_id_not_live
|
|
208
|
+
BEFORE INSERT ON work_record_tombstones
|
|
209
|
+
BEGIN
|
|
210
|
+
SELECT CASE WHEN EXISTS (
|
|
211
|
+
SELECT 1 FROM work_record_mutations WHERE mutation_id = NEW.delete_mutation_id
|
|
212
|
+
) THEN RAISE(ABORT, 'work_mutation_id_conflict') END;
|
|
213
|
+
END;
|
|
214
|
+
|
|
215
|
+
CREATE TABLE work_allocation_revisions (
|
|
216
|
+
allocation_revision_id TEXT PRIMARY KEY,
|
|
217
|
+
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id),
|
|
218
|
+
cohort_from TEXT NOT NULL,
|
|
219
|
+
cohort_to TEXT NOT NULL,
|
|
220
|
+
created_at TEXT NOT NULL,
|
|
221
|
+
evidence_as_of TEXT NOT NULL,
|
|
222
|
+
contract_version TEXT NOT NULL CHECK (contract_version = 'esf-work-allocation-1'),
|
|
223
|
+
metric_definition_version TEXT NOT NULL,
|
|
224
|
+
source_ingestion_watermark TEXT NOT NULL,
|
|
225
|
+
frozen_record_count INTEGER NOT NULL CHECK (frozen_record_count >= 0),
|
|
226
|
+
report_status TEXT NOT NULL CHECK (report_status IN
|
|
227
|
+
('COMPLETE', 'PARTIAL', 'UNSUPPORTED_VERSION', 'STALE_SOURCE', 'INSUFFICIENT_EVIDENCE')),
|
|
228
|
+
CHECK (cohort_to > cohort_from),
|
|
229
|
+
CHECK (evidence_as_of >= cohort_to)
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
CREATE TABLE work_session_allocations (
|
|
233
|
+
allocation_revision_id TEXT NOT NULL REFERENCES work_allocation_revisions(allocation_revision_id) ON DELETE CASCADE,
|
|
234
|
+
session_id TEXT NOT NULL REFERENCES sessions(session_id),
|
|
235
|
+
owner_snapshot_id TEXT,
|
|
236
|
+
disposition TEXT NOT NULL CHECK (disposition IN
|
|
237
|
+
('OWNED', 'UNALLOCATED_UNGROUPED', 'UNALLOCATED_SHARED',
|
|
238
|
+
'UNALLOCATED_AMBIGUOUS', 'UNALLOCATED_UNSUPPORTED')),
|
|
239
|
+
priced_reconciled_cost_u INTEGER NOT NULL CHECK (priced_reconciled_cost_u >= 0),
|
|
240
|
+
priced_turn_count INTEGER NOT NULL CHECK (priced_turn_count >= 0),
|
|
241
|
+
unpriced_turn_count INTEGER NOT NULL CHECK (unpriced_turn_count >= 0),
|
|
242
|
+
parser_claim_summary TEXT NOT NULL,
|
|
243
|
+
PRIMARY KEY (allocation_revision_id, session_id),
|
|
244
|
+
CHECK ((disposition = 'OWNED' AND owner_snapshot_id IS NOT NULL)
|
|
245
|
+
OR (disposition <> 'OWNED' AND owner_snapshot_id IS NULL))
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
-- Feedback snapshots remain reproducible across later edits/reopens. They are
|
|
249
|
+
-- deliberately record-linked so privacy deletion cascades the structured
|
|
250
|
+
-- assessment; the parent revision retains only aggregate record counts.
|
|
251
|
+
CREATE TABLE work_allocation_record_snapshots (
|
|
252
|
+
allocation_revision_id TEXT NOT NULL REFERENCES work_allocation_revisions(allocation_revision_id) ON DELETE CASCADE,
|
|
253
|
+
work_record_id TEXT NOT NULL REFERENCES work_records(work_record_id) ON DELETE CASCADE,
|
|
254
|
+
revision_no INTEGER NOT NULL,
|
|
255
|
+
outcome_state TEXT NOT NULL CHECK (outcome_state IN
|
|
256
|
+
('ACTIVE', 'USEFUL', 'PARTIAL', 'UNSUCCESSFUL', 'ABANDONED', 'UNKNOWN')),
|
|
257
|
+
feedback_source TEXT NOT NULL CHECK (feedback_source IN ('USER_CLOSEOUT', 'USER_EDIT', 'NONE')),
|
|
258
|
+
archived INTEGER NOT NULL CHECK (archived IN (0, 1)),
|
|
259
|
+
PRIMARY KEY (allocation_revision_id, work_record_id)
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
CREATE TRIGGER trg_work_allocation_revision_immutable
|
|
263
|
+
BEFORE UPDATE ON work_allocation_revisions
|
|
264
|
+
BEGIN
|
|
265
|
+
SELECT RAISE(ABORT, 'work_allocation_revision_immutable');
|
|
266
|
+
END;
|
|
267
|
+
|
|
268
|
+
CREATE TRIGGER trg_work_session_allocation_immutable
|
|
269
|
+
BEFORE UPDATE ON work_session_allocations
|
|
270
|
+
BEGIN
|
|
271
|
+
SELECT RAISE(ABORT, 'work_session_allocation_immutable');
|
|
272
|
+
END;
|
|
273
|
+
|
|
274
|
+
CREATE TRIGGER trg_work_record_allocation_snapshot_immutable
|
|
275
|
+
BEFORE UPDATE ON work_allocation_record_snapshots
|
|
276
|
+
BEGIN
|
|
277
|
+
SELECT RAISE(ABORT, 'work_record_allocation_snapshot_immutable');
|
|
278
|
+
END;
|
|
279
|
+
|
|
280
|
+
CREATE INDEX idx_work_records_workspace_current
|
|
281
|
+
ON work_records(workspace_id, archived_at, updated_at DESC);
|
|
282
|
+
CREATE INDEX idx_work_record_revisions_latest
|
|
283
|
+
ON work_record_revisions(work_record_id, revision_no DESC);
|
|
284
|
+
CREATE INDEX idx_work_allocations_owner
|
|
285
|
+
ON work_session_allocations(allocation_revision_id, owner_snapshot_id);
|
|
286
|
+
CREATE INDEX idx_work_allocations_disposition
|
|
287
|
+
ON work_session_allocations(allocation_revision_id, disposition);
|
|
288
|
+
CREATE INDEX idx_work_allocation_record_outcomes
|
|
289
|
+
ON work_allocation_record_snapshots(allocation_revision_id, feedback_source, outcome_state);
|
|
290
|
+
CREATE INDEX idx_work_allocation_revisions_scope
|
|
291
|
+
ON work_allocation_revisions(workspace_id, cohort_from, cohort_to, created_at DESC);
|
|
@@ -15,6 +15,7 @@ function sourceFingerprint(opts, rows) {
|
|
|
15
15
|
version: "esf-cohort-source-fingerprint-1",
|
|
16
16
|
window: { from: opts.from, to: opts.to },
|
|
17
17
|
workspace_id: opts.workspaceId,
|
|
18
|
+
...(opts.sessionId === undefined ? {} : { session_id: opts.sessionId }),
|
|
18
19
|
turns: rows,
|
|
19
20
|
}), "utf8")
|
|
20
21
|
.digest("hex");
|
|
@@ -30,11 +31,14 @@ function sourceFingerprint(opts, rows) {
|
|
|
30
31
|
*/
|
|
31
32
|
export function getEsfObservations(db, opts) {
|
|
32
33
|
const workspaceFilter = opts.workspaceId === null ? "" : " AND t.workspace_id = ?";
|
|
34
|
+
const sessionFilter = opts.sessionId === undefined ? "" : " AND t.session_id = ?";
|
|
33
35
|
const params = opts.workspaceId === null ? [opts.from, opts.to] : [opts.from, opts.to, opts.workspaceId];
|
|
36
|
+
if (opts.sessionId !== undefined)
|
|
37
|
+
params.push(opts.sessionId);
|
|
34
38
|
const cohort = `WITH selected_turns AS (
|
|
35
39
|
SELECT t.message_id, t.session_id, t.ts, t.cost_equiv_u, t.cost_claim, t.parser_version, t.provisional
|
|
36
40
|
FROM turns t
|
|
37
|
-
WHERE t.ts >= ? AND t.ts < ?${workspaceFilter}
|
|
41
|
+
WHERE t.ts >= ? AND t.ts < ?${workspaceFilter}${sessionFilter}
|
|
38
42
|
),
|
|
39
43
|
selected_sessions AS (
|
|
40
44
|
SELECT DISTINCT st.session_id, s.state
|
|
@@ -249,6 +253,33 @@ export function getEsfObservations(db, opts) {
|
|
|
249
253
|
claim_kinds_count: data.watermark.cost_claim_counts.length,
|
|
250
254
|
note: "Selected-turn cohort is [from,to). Unpriced turns are unknown, not zero-cost. Test recovery is a versioned observation of completed test events, not D7 detector matching or a task outcome.",
|
|
251
255
|
},
|
|
252
|
-
|
|
256
|
+
drilldown_ids: {
|
|
257
|
+
...(opts.workspaceId === null ? {} : { workspace_id: opts.workspaceId }),
|
|
258
|
+
...(opts.sessionId === undefined ? {} : { session_id: opts.sessionId }),
|
|
259
|
+
},
|
|
253
260
|
});
|
|
254
261
|
}
|
|
262
|
+
/** Full session evidence; the exclusive end includes the final turn/tool event. */
|
|
263
|
+
export function getSessionEsfObservations(db, sessionId) {
|
|
264
|
+
const session = db
|
|
265
|
+
.prepare("SELECT workspace_id FROM sessions WHERE session_id = ?")
|
|
266
|
+
.get(sessionId);
|
|
267
|
+
if (session === undefined) {
|
|
268
|
+
const missing = buildResponse(null, {
|
|
269
|
+
claim_kind: "N_A",
|
|
270
|
+
metric_definition_version: "esf-1",
|
|
271
|
+
n: 0,
|
|
272
|
+
drilldown_ids: { session_id: sessionId },
|
|
273
|
+
});
|
|
274
|
+
return { data: null, meta: missing.meta };
|
|
275
|
+
}
|
|
276
|
+
const span = db
|
|
277
|
+
.prepare(`SELECT MIN(ts) AS first_at, MAX(ts) AS last_at FROM (
|
|
278
|
+
SELECT ts FROM turns WHERE session_id = ?
|
|
279
|
+
UNION ALL SELECT ts FROM tool_events WHERE session_id = ?
|
|
280
|
+
)`)
|
|
281
|
+
.get(sessionId, sessionId);
|
|
282
|
+
const from = span.first_at ?? "1970-01-01T00:00:00.000Z";
|
|
283
|
+
const to = new Date(Date.parse(span.last_at ?? from) + 1).toISOString();
|
|
284
|
+
return getEsfObservations(db, { workspaceId: session.workspace_id, sessionId, from, to });
|
|
285
|
+
}
|