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.
Files changed (50) hide show
  1. package/dist/daemon/http.js +8 -0
  2. package/dist/daemon/router.js +233 -8
  3. package/dist/db/migrations/019_work_records.sql +291 -0
  4. package/dist/query/api/esf-observations.js +33 -2
  5. package/dist/query/api/reported-work.js +46 -0
  6. package/dist/query/api/work-records.js +85 -0
  7. package/dist/query/settings-store.js +9 -0
  8. package/dist/ui/assets/{BarChart-CQbdKRIK.js → BarChart-BOecTQgC.js} +1 -1
  9. package/dist/ui/assets/{BriefsPage-CEpYiWdj.js → BriefsPage-CjvXspiw.js} +1 -1
  10. package/dist/ui/assets/{CacheWriteSpikesChart-BsSR8umm.js → CacheWriteSpikesChart-5Pn1sUZC.js} +1 -1
  11. package/dist/ui/assets/{CartesianChart-B8S3scl8.js → CartesianChart-56rkZQt4.js} +1 -1
  12. package/dist/ui/assets/{Chip-CA4wIi6T.js → Chip-BZTcuGOm.js} +1 -1
  13. package/dist/ui/assets/{ComposedChart-v1IkqzcG.js → ComposedChart-C8PPdkFl.js} +1 -1
  14. package/dist/ui/assets/{EmptyState-QhnMG1cV.js → EmptyState-BfD86Pv3.js} +1 -1
  15. package/dist/ui/assets/{FlavorDecomposition-DPnknTC4.js → FlavorDecomposition-BsOuw4CF.js} +1 -1
  16. package/dist/ui/assets/{FrictionCell-DaE9QneM.js → FrictionCell-DQBJsot-.js} +1 -1
  17. package/dist/ui/assets/{GlossaryPage-BL2Dak3C.js → GlossaryPage-DutLIAlv.js} +1 -1
  18. package/dist/ui/assets/{HotSessionsPage-B-njRmVy.js → HotSessionsPage-DLWlyBfb.js} +1 -1
  19. package/dist/ui/assets/{InfoTip-C4ZL8HcU.js → InfoTip-BrYyvCNl.js} +1 -1
  20. package/dist/ui/assets/{Legend-B02ulZSz.js → Legend-DzSAmt8-.js} +1 -1
  21. package/dist/ui/assets/{Line-3W0HD2qo.js → Line-1eksYo3m.js} +1 -1
  22. package/dist/ui/assets/Modal-DX-JN5Ul.js +1 -0
  23. package/dist/ui/assets/ObservationEvidence-Bbsc-DCW.js +1 -0
  24. package/dist/ui/assets/ObservationEvidence-Cy92xibu.css +1 -0
  25. package/dist/ui/assets/OverviewPage-CWgzRIqk.js +2 -0
  26. package/dist/ui/assets/RecommendationsPage-sRHEku70.js +2 -0
  27. package/dist/ui/assets/{Scatter-B2WQrP2P.js → Scatter-Ys7Lbhjf.js} +1 -1
  28. package/dist/ui/assets/SessionDetailPage-L02ituTQ.js +1 -0
  29. package/dist/ui/assets/{SettingsPage-CisYPwFk.js → SettingsPage-DJjWicvx.js} +4 -4
  30. package/dist/ui/assets/{Skeleton-CXHTXg1O.js → Skeleton-ab1u7ntu.js} +1 -1
  31. package/dist/ui/assets/{SpendPercentileChip-CmT4Dh81.js → SpendPercentileChip-DnRJWBUP.js} +1 -1
  32. package/dist/ui/assets/{TrendChart-DhE3th2k.js → TrendChart-_nohASRq.js} +1 -1
  33. package/dist/ui/assets/WorkspaceDetailPage-BQ3XA3Hf.js +1 -0
  34. package/dist/ui/assets/{WorkspacesPage-CiPasCJW.js → WorkspacesPage-DKdpBgC9.js} +1 -1
  35. package/dist/ui/assets/{chart-theme-BKzBXNjC.js → chart-theme-0POFlal8.js} +1 -1
  36. package/dist/ui/assets/{graphicalItemSelectors-DFL6tG4x.js → graphicalItemSelectors-B97PsJDp.js} +1 -1
  37. package/dist/ui/assets/{index-anuDukMM.js → index-DAAmee2m.js} +2 -2
  38. package/dist/ui/assets/{useExperimentalActions-CPl-lsm9.js → useExperimentalActions-avkWaDmQ.js} +1 -1
  39. package/dist/ui/index.html +1 -1
  40. package/dist/work-records/allocation.js +242 -0
  41. package/dist/work-records/ids.js +80 -0
  42. package/dist/work-records/index.js +4 -0
  43. package/dist/work-records/store.js +376 -0
  44. package/dist/work-records/types.js +29 -0
  45. package/dist/work-records/validation.js +71 -0
  46. package/package.json +1 -1
  47. package/dist/ui/assets/OverviewPage-Dn68beIQ.js +0 -2
  48. package/dist/ui/assets/RecommendationsPage-DXo_pldt.js +0 -2
  49. package/dist/ui/assets/SessionDetailPage-tXXISzbh.js +0 -1
  50. package/dist/ui/assets/WorkspaceDetailPage-BiCqKdSw.js +0 -1
@@ -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
  /**
@@ -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
- ...(opts.workspaceId === null ? {} : { drilldown_ids: { workspace_id: opts.workspaceId } }),
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
+ }