-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathapi-server.ts
More file actions
4133 lines (3947 loc) · 214 KB
/
Copy pathapi-server.ts
File metadata and controls
4133 lines (3947 loc) · 214 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* SecureContext API Server
*
* Exposes the Store interface as an HTTP REST API.
* Agents on any machine can connect to this server and use the full
* SecureContext feature set without needing local SQLite access.
*
* AUTHENTICATION:
* Every request (except /health) must include:
* Authorization: Bearer <ZC_API_KEY>
* ZC_API_KEY is a shared secret set at server startup.
* This is the server-level auth — separate from per-project RBAC session tokens.
*
* For production: set ZC_API_KEY to a random 32+ char string.
* For local dev: set ZC_API_KEY=dev (or any value) — the key is still checked.
*
* TRANSPORT:
* HTTP (Fastify). For production, run behind nginx with SSL termination.
* The Docker Compose stack handles this automatically.
*
* PORT:
* Default 3099. Override with ZC_API_PORT.
*
* RATE LIMITING:
* In-process per-IP rate limiting (500 req/min per IP).
* Redis-backed rate limiting can be added by extending the rateLimit map
* to use Redis INCR + EXPIRE (same pattern as the SQLite rate_limits table).
*
* SECURITY:
* - All inputs validated and sanitized before passing to Store
* - projectPath is validated to be an absolute path (no traversal)
* - Timing-safe key comparison for API key check
* - Error responses never expose internal details (stack traces, DB paths)
* - Request size limit: 1MB (prevents body stuffing)
*/
import Fastify from "fastify";
import cors from "@fastify/cors";
import { timingSafeEqual, createHash } from "node:crypto";
import { isAbsolute as posixIsAbsolute } from "node:path/posix";
import { isAbsolute as win32IsAbsolute } from "node:path/win32";
import { createStore } from "./store.js";
import type { Store, RetentionTier } from "./store.js";
import { Config } from "./config.js";
import type { EpistemicOpts } from "./memory.js";
import { checkOllamaAvailable } from "./embedder.js";
// ─────────────────────────────────────────────────────────────────────────────
// Config from environment
// ─────────────────────────────────────────────────────────────────────────────
const API_PORT = parseInt(process.env["ZC_API_PORT"] ?? "3099", 10);
const API_HOST = process.env["ZC_API_HOST"] ?? "0.0.0.0";
const API_KEY = process.env["ZC_API_KEY"];
const ALLOWED_ORIGINS = (process.env["ZC_API_CORS_ORIGINS"] ?? "*").split(",").map(s => s.trim());
// Per-IP in-process rate limit: 500 requests per 60 seconds.
// Raised from 100 to support multi-agent sessions where 5+ agents + dispatcher
// each make multiple API calls per minute (zc_broadcast, zc_recall_context, polling).
// 100 req/min was too low: agents hitting 429 would silently skip zc_broadcast.
const RATE_LIMIT_WINDOW_MS = 60_000;
const RATE_LIMIT_MAX = 500;
const ipRateMap = new Map<string, { count: number; resetAt: number }>();
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
function timingSafeKeyCheck(supplied: string | undefined): boolean {
// v0.28.0 fix: read ZC_API_KEY dynamically per call rather than module-load capture.
// The module-load capture broke under test interleaving: when test file A set
// ZC_API_KEY before test file B's `beforeAll`, file B's `import { createApiServer }`
// ran with the stale key cached in the module-scope `API_KEY` constant, and
// subsequent requests using B's freshly-minted testApiKey returned 401.
// The original comment near the global gate said:
// "API_KEY is captured at module-load. The test process imports api-server.ts
// BEFORE beforeAll() sets ZC_API_KEY, so API_KEY is undefined and
// timingSafeKeyCheck returns true regardless."
// …which was only true when api-server.ts was the FIRST module to import in
// the test worker. Once vitest started sharing workers across files (or once
// any other test file imports api-server.ts while having ZC_API_KEY set), the
// assumption broke. Reading per-call is the simple fix; perf cost is one
// env lookup, negligible vs. the sha256 + timingSafeEqual.
const apiKey = process.env["ZC_API_KEY"];
if (!apiKey) return true; // No key configured — open (dev mode warning logged at startup)
if (!supplied) return false;
try {
const a = Buffer.from(createHash("sha256").update(supplied).digest("hex"), "hex");
const b = Buffer.from(createHash("sha256").update(apiKey).digest("hex"), "hex");
return timingSafeEqual(a, b);
} catch {
return false;
}
}
function validateProjectPath(projectPath: unknown): string {
if (typeof projectPath !== "string" || !projectPath.trim()) {
throw new ApiError(400, "projectPath is required and must be a non-empty string");
}
// Accept POSIX absolute paths (/home/...) AND Windows absolute paths (C:\... or C:/...)
// The API server runs in Docker (Linux) but clients are often Windows-native — both must work.
// node:path/posix and node:path/win32 each implement isAbsolute correctly for their platform
// regardless of the host OS, so this check is always cross-platform.
if (!posixIsAbsolute(projectPath) && !win32IsAbsolute(projectPath)) {
throw new ApiError(400, "projectPath must be an absolute filesystem path");
}
// Normalize Windows path separators: C:/Users/... → C:\Users\...
// This ensures C:/foo and C:\foo hash to the same project DB.
// Windows clients may send forward-slash paths (e.g. from URL encoding), but
// register.mjs and claude sessions use native backslash — they must collide.
//
// v0.28.0 fix: ONLY apply this normalization to Windows-style paths
// (drive letter prefix like "C:/" or "C:\"). POSIX paths start with "/"
// and MUST keep their forward slashes — replacing `/` → `\` mangles
// "/tmp/foo" into "\tmp\foo" which:
// (a) is not a valid Linux path,
// (b) hashes to a different DB than the client computed it under,
// (c) caused every session-token-bearing telemetry request on Linux/CI
// to 401 since `requireSessionToken` would look up the token under
// the mangled hash and find nothing. Local Windows always passed
// because the normalization round-trips on Windows paths; CI Linux
// always failed because POSIX paths got butchered.
// The regex matches drive letter + separator only at string start.
if (/^[a-zA-Z]:[\/\\]/.test(projectPath)) {
return projectPath.replace(/\//g, "\\");
}
return projectPath;
}
function checkIpRate(ip: string): void {
const now = Date.now();
let slot = ipRateMap.get(ip);
if (!slot || now > slot.resetAt) {
slot = { count: 0, resetAt: now + RATE_LIMIT_WINDOW_MS };
ipRateMap.set(ip, slot);
}
slot.count++;
if (slot.count > RATE_LIMIT_MAX) {
throw new ApiError(429, "Rate limit exceeded -- max 500 requests per minute per IP");
}
// Prune stale IPs periodically (every 1000 requests)
if (ipRateMap.size > 10_000) {
for (const [k, v] of ipRateMap) {
if (now > v.resetAt) ipRateMap.delete(k);
}
}
}
class ApiError extends Error {
constructor(public statusCode: number, message: string) {
super(message);
}
}
// (sendError is inlined at each call site — Fastify reply types are complex to annotate generically)
// ─────────────────────────────────────────────────────────────────────────────
// Server factory (exported for testing)
// ─────────────────────────────────────────────────────────────────────────────
function createFastifyInstance() {
return Fastify({
logger: { level: process.env["ZC_API_LOG_LEVEL"] ?? "warn" },
bodyLimit: 1 * 1024 * 1024, // 1 MB
trustProxy: true,
});
}
export async function createApiServer(storeOverride?: Store) {
const store = storeOverride ?? await createStore();
const app = createFastifyInstance();
await app.register(cors, {
origin: ALLOWED_ORIGINS.includes("*") ? true : ALLOWED_ORIGINS,
methods: ["GET", "POST", "DELETE", "OPTIONS"],
});
if (!API_KEY) {
app.log.warn("⚠️ ZC_API_KEY not set — API is OPEN (no authentication). Set ZC_API_KEY for production.");
}
// ── Auth + rate-limit hook (runs before every route handler) ────────────────
// v0.18.2 Sprint 2.6 — register a urlencoded body parser inline (avoids a
// new dependency on @fastify/formbody). HTMX forms POST as
// application/x-www-form-urlencoded; without this Fastify rejects them with
// 415 Unsupported Media Type.
app.addContentTypeParser("application/x-www-form-urlencoded", { parseAs: "string" },
(_req, body, done) => {
try {
// v0.25.0: handle duplicate keys (e.g. multiple checkbox<input> with
// the same name="intended_roles" — HTMX submits each checked value
// as a separate key-value pair). Without this fix the iteration
// overwrites: only the LAST value survives. Caught when operator
// checked developer + qa but only qa landed in skills_pg.
const params = new URLSearchParams(body as string);
const obj: Record<string, string | string[]> = {};
for (const [k, v] of params.entries()) {
if (k in obj) {
const prev = obj[k];
obj[k] = Array.isArray(prev) ? [...prev, v] : [prev as string, v];
} else {
obj[k] = v;
}
}
done(null, obj);
} catch (e) { done(e as Error, undefined); }
},
);
app.addHook("preHandler", async (request, reply) => {
// Health check is always open
if (request.url === "/health") return;
// v0.18.2 Sprint 2.6 — operator dashboard. Local-only by design (HOST default
// 0.0.0.0 in dev, but operators are expected to firewall :3099 to localhost
// for now). Routes under /dashboard render HTML for browser viewing without
// an Authorization header. When a multi-tenant story lands (Sprint 3.x),
// these routes will gate via the existing per-project RBAC token system.
if (request.url === "/dashboard" || request.url.startsWith("/dashboard/")) return;
// v0.38.0 — the A2A agent card is public by protocol design (discovery document,
// no secrets). Task submission/query under /a2a/* stays behind the bearer gate.
if (request.url === "/.well-known/agent.json") return;
// v0.26.0 Step 4 — PreToolUse hook (~/.claude/hooks/skill-script-hmac-verify.mjs)
// calls /api/v1/skills/<name>/verify-script to ask "is this script's HMAC
// intact?" The hook is spawned per Bash invocation by Claude Code; it doesn't
// know the API key (the key lives in MCP env, not host env). Exempt this
// route from the global API key gate. The endpoint is READ-ONLY (verify by
// hash, no DB writes, no secret leakage) so the exemption is safe.
if (request.url.startsWith("/api/v1/skills/") && request.url.includes("/verify-script")) return;
// Per-IP rate limiting
const ip = request.ip;
try {
checkIpRate(ip);
} catch (e) {
if (e instanceof ApiError) {
reply.status(e.statusCode).send({ error: e.message });
return;
}
}
// v0.18.9 fix — telemetry endpoints have their own per-agent session_token
// auth (the Reference Monitor pattern from v0.12.1, see RT-S2-02..RT-S2-06).
// The Authorization header here carries the session_token, NOT the global
// API key. The route handler validates it via requireSessionToken() and
// enforces the agent_id binding. Skip the global API key gate here so
// legitimate session-token requests aren't 401'd.
//
// Why this didn't fail in tests: API_KEY is captured at module-load. The
// test process imports api-server.ts BEFORE beforeAll() sets ZC_API_KEY,
// so API_KEY is undefined and timingSafeKeyCheck returns true regardless.
// Production (real settings.json env at MCP-spawn time) had API_KEY set,
// and every tool_call write was rejected with 401. Telemetry has been
// silently dropped on the floor for everyone running with API mode +
// ZC_API_KEY since v0.12.1.
if (request.url.startsWith("/api/v1/telemetry/")) return;
// API key check
const authHeader = request.headers.authorization;
const supplied = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : undefined;
if (!timingSafeKeyCheck(supplied)) {
reply.status(401).send({ error: "Unauthorized — invalid or missing API key" });
}
});
// ── Health ─────────────────────────────────────────────────────────────────
app.get("/health", async () => {
const [config, ollama] = await Promise.all([
import("./config.js"),
checkOllamaAvailable(),
]);
return {
status: "ok",
version: config.Config.VERSION,
store: process.env["ZC_STORE"] ?? "sqlite",
ollamaAvailable: ollama.available,
ollamaUrl: ollama.available ? ollama.url.replace("/api/embeddings", "") : null,
searchMode: ollama.available ? "hybrid (BM25 + vector)" : "BM25-only (Ollama unavailable)",
ts: new Date().toISOString(),
};
});
// ─────────────────────────────────────────────────────────────────────────
// v0.18.2 Sprint 2.6 — Operator Dashboard
// ─────────────────────────────────────────────────────────────────────────
// Local-only HTMX dashboard for reviewing pending skill-mutation results
// and approving / rejecting them. Bypasses the API key auth (see preHandler
// exemption above) so it can be opened in a browser without setting headers.
// For a multi-tenant story (Sprint 3.x), replace this exemption with
// session-token gating via the existing RBAC system.
app.get("/dashboard", async (_request, reply) => {
const { renderDashboardHtml } = await import("./dashboard/render.js");
reply.type("text/html").send(renderDashboardHtml());
});
// v0.30.6 — Personal-wiki KB graph panel. Reads the wiki/graph.json
// produced by personal-wiki/viz/build_graph.py and renders an inline
// d3.js force-directed view scoped to the operator's curated content.
// Path is configurable via PERSONAL_WIKI_ROOT env var. When the file
// doesn't exist (no wiki configured, or wiki is empty), the fragment
// shows an actionable "how to populate" message — never an error.
app.get("/dashboard/wiki-graph", async (_request, reply) => {
const { renderWikiGraphFragment } = await import("./dashboard/render.js");
reply.type("text/html").send(await renderWikiGraphFragment());
});
// v0.31.0 — Code/Memory knowledge graph (Tier-1 A). SecureContext's OWN reference
// graph (kb_edges/kb_backlinks), PER PROJECT — distinct from the wiki graph above.
// Data fetched server-side via the store so the browser needs no API key.
app.get("/dashboard/kb-graph", async (request, reply) => {
const { renderKbGraphFragment, loadProjectNameMap } = await import("./dashboard/render.js");
const { projectPath } = request.query as Record<string, unknown>;
let pp = "";
let data: { nodes: Array<{ id: string; inDegree: number; weightedIn: number }>; edges: Array<{ from: string; to: string; relation: string; weight: number }> } = { nodes: [], edges: [] };
if (typeof projectPath === "string" && projectPath.trim()) {
try {
pp = validateProjectPath(projectPath); // normalizes C:/ → C:\ so the hash matches how content was indexed
data = await store.graphData(pp);
} catch { pp = projectPath.trim(); /* invalid path → empty state showing what they typed */ }
}
// v0.35.0 — project picker: every project with KB content whose path is known
// (project_paths_pg is written on every telemetry call, so it covers all active
// projects). Empty on a SQLite-only install → the fragment falls back to the
// free-text path input, so nothing regresses without PG.
let projects: Array<{ path: string; label: string }> = [];
try {
const { withClient } = await import("./pg_pool.js");
const rows = await withClient(async (c) => (await c.query<{ project_hash: string; n: string; project_path: string | null }>(
`SELECT ke.project_hash, COUNT(*)::text AS n, MAX(pp2.project_path) AS project_path
FROM knowledge_entries ke
LEFT JOIN project_paths_pg pp2 ON pp2.project_hash = ke.project_hash
GROUP BY ke.project_hash
ORDER BY COUNT(*) DESC LIMIT 40`)).rows);
const nameMap = await loadProjectNameMap();
projects = rows.filter((r) => r.project_path).map((r) => ({
path: String(r.project_path),
label: `${nameMap.get(r.project_hash) ?? `project:${r.project_hash.slice(0, 8)}…`} (${r.n} sources)`,
}));
} catch { /* no PG (SQLite-only) → free-text input fallback */ }
reply.type("text/html").send(renderKbGraphFragment(data, pp, projects));
});
app.get("/dashboard/health", async (_request, _reply) => {
const { withClient } = await import("./pg_pool.js");
try {
const n = await withClient(async (c) => {
const res = await c.query<{ n: string }>(
`SELECT COUNT(*)::text AS n FROM mutation_results_pg WHERE consumed_at IS NULL`,
);
return Number(res.rows[0]?.n ?? 0);
});
return { pending_count: n, ts: new Date().toISOString() };
} catch (e) {
return { pending_count: 0, error: (e as Error).message, ts: new Date().toISOString() };
}
});
// v0.40.0 — Overview status strip: the console landing view. One system line
// (version · store · ollama · cron) + count cards for everything the operator
// actually triages. Each card jumps to its tab. HTML fragment for HTMX.
app.get("/dashboard/overview-strip", async (_request, reply) => {
const { withClient } = await import("./pg_pool.js");
const esc = (s: string) => s.replace(/&/g, "&").replace(/</g, "<");
let counts = { contradictions: 0, pending: 0, quarantined: 0, agents24h: 0, autoExtract24h: 0, facts: 0 };
try {
counts = await withClient(async (c) => {
const one = async (sql: string) => Number((await c.query<{ n: string }>(sql)).rows[0]?.n ?? 0);
return {
contradictions: await one(`SELECT COUNT(*)::text AS n FROM memory_contradictions_pg WHERE status = 'open'`),
pending: await one(`SELECT COUNT(*)::text AS n FROM mutation_results_pg WHERE consumed_at IS NULL`),
quarantined: await one(`SELECT COUNT(*)::text AS n FROM skills_pg WHERE quarantined = TRUE`),
agents24h: await one(`SELECT COUNT(DISTINCT agent_id)::text AS n FROM tool_calls_pg WHERE ts > NOW() - INTERVAL '24 hours'`),
autoExtract24h: await one(`SELECT COUNT(*)::text AS n FROM working_memory WHERE origin LIKE 'auto-extract%' AND created_at > NOW() - INTERVAL '24 hours' AND valid_to IS NULL`),
facts: await one(`SELECT COUNT(*)::text AS n FROM working_memory WHERE valid_to IS NULL`),
};
});
} catch { /* PG down — render zeros; the sys line still shows status */ }
let ollamaUp = false;
try {
const ctl = new AbortController(); const t = setTimeout(() => ctl.abort(), 1200);
const r = await fetch(`${Config.OLLAMA_URL.replace(/\/api\/.*$/, "")}/api/tags`, { signal: ctl.signal });
clearTimeout(t); ollamaUp = r.ok;
} catch { ollamaUp = false; }
const cronOn = process.env.ZC_ENRICHMENT_CRON !== "0";
const card = (n: number, label: string, tab: string, warnWhenPositive: boolean) =>
`<div class="stat-card" onclick="document.querySelector('.tab-button[data-tab=${tab}]')?.click()" role="button" tabindex="0">` +
`<div class="stat-n ${n > 0 ? (warnWhenPositive ? "warn" : "ok") : ""}">${n}</div>` +
`<div class="stat-l">${esc(label)}</div></div>`;
const html =
`<div class="stat-sys">` +
`<span><span class="dot up"></span><b>SecureContext</b> v${esc(Config.VERSION)}</span>` +
`<span>store <b>postgres</b></span>` +
`<span><span class="dot ${ollamaUp ? "up" : "down"}"></span>ollama <b>${ollamaUp ? "up" : "down"}</b></span>` +
`<span><span class="dot ${cronOn ? "up" : "down"}"></span>enrichment cron <b>${cronOn ? "on" : "off"}</b></span>` +
`</div>` +
`<div class="stat-strip">` +
card(counts.contradictions, "open contradictions", "memory", true) +
card(counts.pending, "pending mutation reviews", "skills", true) +
card(counts.quarantined, "quarantined skills", "security", true) +
card(counts.agents24h, "agents active · 24h", "overview", false) +
card(counts.autoExtract24h, "auto-extracted facts · 24h", "memory", false) +
card(counts.facts, "live memory facts", "memory", false) +
`</div>`;
reply.type("text/html; charset=utf-8").send(html);
});
// v0.22.9 — Generic pretool-event telemetry. Records EVERY PreRead hook
// invocation regardless of outcome (redirect / block_unindexed /
// bypass_force_read / bypass_partial_read / pass_through / error).
// Diagnoses the "read_redirects=0 forever" silent-failure mode found
// in the v0.22.x audit: read_redirects_pg only logs the success path,
// so the operator couldn't tell if the hook was firing at all when
// count==0. With this table, the dashboard can show "hook fires N
// times/day, 0 of them produce redirects because all reads are of
// unindexed project-root files" — actionable signal vs invisible gap.
app.post("/api/v1/telemetry/pretool-event", async (request, reply) => {
try {
const b = request.body as Record<string, unknown>;
const pp = validateProjectPath(b["projectPath"]);
const agentId = typeof b["agentId"] === "string" ? b["agentId"].slice(0, 64) : "default";
const toolName = typeof b["toolName"] === "string" ? b["toolName"].slice(0, 64) : "Read";
const filePath = typeof b["filePath"] === "string" ? b["filePath"].slice(0, 1024) : null;
const outcome = String(b["outcome"] ?? "error").slice(0, 32);
const detail = typeof b["detail"] === "string" ? b["detail"].slice(0, 2048) : null;
const allowed = ["redirect", "block_unindexed", "block_dedup",
"bypass_force_read", "bypass_partial_read", "pass_through", "error"];
if (!allowed.includes(outcome)) {
return reply.status(400).send({ error: `outcome must be one of ${allowed.join(", ")}` });
}
const { createHash } = await import("node:crypto");
const { realpathSync } = await import("node:fs");
let normalized = pp;
try { normalized = realpathSync(pp); } catch { /* use raw */ }
const projectHash = createHash("sha256").update(normalized).digest("hex").slice(0, 16);
const { withClient } = await import("./pg_pool.js");
await withClient(async (c) => {
await c.query(
`INSERT INTO pretool_events_pg
(project_hash, agent_id, tool_name, file_path, outcome, detail)
VALUES ($1, $2, $3, $4, $5, $6)`,
[projectHash, agentId, toolName, filePath, outcome, detail],
);
});
return { ok: true };
} catch (e) {
if (e instanceof ApiError) return reply.status(e.statusCode).send({ error: e.message });
// Hook is fire-and-forget — never break agent flow on telemetry failures
return { ok: false, error: (e as Error).message };
}
});
// v0.22.7 — Summarizer-event telemetry receiver. Mirrors the v0.22.5
// read-redirect pattern: harness.ts fires POSTs after every L0/L1
// generation (success, fallback, error). Stores rows in
// summarizer_events_pg so the dashboard "Summarizer activity" panel
// can surface real-time indexing health — currently the operator was
// completely blind to whether file summaries were being created or
// failing silently.
app.post("/api/v1/telemetry/summarizer-event", async (request, reply) => {
try {
const b = request.body as Record<string, unknown>;
const pp = validateProjectPath(b["projectPath"]);
const agentId = typeof b["agentId"] === "string" ? b["agentId"].slice(0, 64) : "default";
const source = typeof b["source"] === "string" ? b["source"].slice(0, 1024) : "";
const size = Number(b["sourceSizeBytes"] ?? 0);
const l0Len = Number(b["l0Length"] ?? 0);
const l1Len = Number(b["l1Length"] ?? 0);
const durMs = Number(b["durationMs"] ?? 0);
const model = typeof b["model"] === "string" ? b["model"].slice(0, 128) : null;
const summarySource = String(b["summarySource"] ?? "unknown").slice(0, 32);
const status = String(b["status"] ?? "error").slice(0, 32);
const errorMsg = typeof b["errorMessage"] === "string" ? b["errorMessage"].slice(0, 2048) : null;
if (!source) {
return reply.status(400).send({ error: "source is required" });
}
const allowedSrc = ["ast", "semantic", "truncation", "unknown"];
const allowedStat = ["ok", "fallback_truncation", "error", "skipped"];
if (!allowedSrc.includes(summarySource)) {
return reply.status(400).send({ error: `summarySource must be one of ${allowedSrc.join(", ")}` });
}
if (!allowedStat.includes(status)) {
return reply.status(400).send({ error: `status must be one of ${allowedStat.join(", ")}` });
}
const { createHash } = await import("node:crypto");
const { realpathSync } = await import("node:fs");
let normalized = pp;
try { normalized = realpathSync(pp); } catch { /* use raw */ }
const projectHash = createHash("sha256").update(normalized).digest("hex").slice(0, 16);
const { withClient } = await import("./pg_pool.js");
await withClient(async (c) => {
await c.query(
`INSERT INTO summarizer_events_pg
(project_hash, agent_id, source, source_size_bytes, l0_length, l1_length,
duration_ms, model, summary_source, status, error_message)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
[projectHash, agentId, source,
Math.max(0, Math.floor(size)),
Math.max(0, Math.floor(l0Len)),
Math.max(0, Math.floor(l1Len)),
Math.max(0, Math.floor(durMs)),
model, summarySource, status, errorMsg],
);
});
return { ok: true };
} catch (e) {
if (e instanceof ApiError) return reply.status(e.statusCode).send({ error: e.message });
return { ok: false, error: (e as Error).message };
}
});
// v0.22.7 — Summarizer-activity dashboard panel. Returns rendered HTML
// with: (a) total file summaries indexed for the project (from
// source_meta), (b) recent summarizer events grouped by status, (c) the
// last 10 successful summarizations, (d) the last 5 failures with full
// error messages so the operator can debug. Polls every 60s from the UI.
app.get("/dashboard/summarizer-health", async (request, reply) => {
const { renderSummarizerHealthFragment, loadProjectNameMap } = await import(
"./dashboard/render.js"
);
const { withClient } = await import("./pg_pool.js");
const q = request.query as Record<string, unknown>;
const projectFilter = typeof q.project === "string" && /^[0-9a-f]{16}$/.test(q.project)
? q.project
: null;
try {
const result = await withClient(async (c) => {
// v0.22.8 — total_file_summaries is now the AUTHORITATIVE count
// from source_meta (the STATE table). After v0.22.8, the agent's
// every L0/L1 write dual-mirrors here, and the v0.22.8 backfill
// copied existing SQLite-only summaries. So this count = "files
// the system actually has L0/L1 summaries for." Operator policy:
// PG and SQLite must have feature parity; PG is preferred when
// available. See feedback_pg_first_storage.md.
//
// distinct_summarized_v0227 is the secondary "telemetry-tracked"
// count — distinct sources seen in summarizer_events_pg since
// v0.22.7. Useful for "how much has been summarized in the last
// <window>" but not for "total available." Surfaced below.
const totalQ = projectFilter
? `SELECT COUNT(*)::text AS n FROM source_meta WHERE project_hash = $1 AND source LIKE 'file:%'`
: `SELECT COUNT(*)::text AS n FROM source_meta WHERE source LIKE 'file:%'`;
const totalR = await c.query<{ n: string }>(totalQ, projectFilter ? [projectFilter] : []);
const total_file_summaries = Number(totalR.rows[0]?.n ?? 0);
const distinctQ = projectFilter
? `SELECT COUNT(DISTINCT source)::text AS n FROM summarizer_events_pg WHERE project_hash = $1 AND status IN ('ok', 'fallback_truncation')`
: `SELECT COUNT(DISTINCT source)::text AS n FROM summarizer_events_pg WHERE status IN ('ok', 'fallback_truncation')`;
const distinctR = await c.query<{ n: string }>(distinctQ, projectFilter ? [projectFilter] : []);
const distinct_summarized_v0227 = Number(distinctR.rows[0]?.n ?? 0);
// 2) breakdown of last 24h events by status × source
const eventQ = projectFilter
? `SELECT status, summary_source, COUNT(*)::text AS n,
AVG(duration_ms)::int AS avg_ms
FROM summarizer_events_pg
WHERE ts > NOW() - INTERVAL '24 hours' AND project_hash = $1
GROUP BY status, summary_source
ORDER BY 3 DESC`
: `SELECT status, summary_source, COUNT(*)::text AS n,
AVG(duration_ms)::int AS avg_ms
FROM summarizer_events_pg
WHERE ts > NOW() - INTERVAL '24 hours'
GROUP BY status, summary_source
ORDER BY 3 DESC`;
const eventP = projectFilter ? [projectFilter] : [];
const eventR = await c.query<{ status: string; summary_source: string; n: string; avg_ms: number }>(
eventQ, eventP,
);
const events_24h = eventR.rows.map((r) => ({
status: r.status, summary_source: r.summary_source,
count: Number(r.n), avg_duration_ms: Number(r.avg_ms ?? 0),
}));
// 3) recent successful summaries (last 10)
const recentQ = projectFilter
? `SELECT source, summary_source, model, duration_ms, ts, agent_id, l0_length, l1_length
FROM summarizer_events_pg
WHERE project_hash = $1 AND status IN ('ok', 'fallback_truncation')
ORDER BY ts DESC LIMIT 10`
: `SELECT source, summary_source, model, duration_ms, ts, agent_id, l0_length, l1_length, project_hash
FROM summarizer_events_pg
WHERE status IN ('ok', 'fallback_truncation')
ORDER BY ts DESC LIMIT 10`;
const recentR = await c.query<Record<string, unknown>>(
recentQ, projectFilter ? [projectFilter] : [],
);
// 4) recent failures (last 5)
const failQ = projectFilter
? `SELECT source, status, summary_source, error_message, ts, agent_id, model
FROM summarizer_events_pg
WHERE project_hash = $1 AND status IN ('error', 'skipped')
ORDER BY ts DESC LIMIT 5`
: `SELECT source, status, summary_source, error_message, ts, agent_id, model, project_hash
FROM summarizer_events_pg
WHERE status IN ('error', 'skipped')
ORDER BY ts DESC LIMIT 5`;
const failR = await c.query<Record<string, unknown>>(
failQ, projectFilter ? [projectFilter] : [],
);
return {
total_file_summaries,
distinct_summarized_v0227,
events_24h,
recent_success: recentR.rows,
recent_failures: failR.rows,
};
});
const nameMap = await loadProjectNameMap();
reply.type("text/html").send(renderSummarizerHealthFragment(result, nameMap, projectFilter));
} catch (e) {
reply
.type("text/html")
.send(
`<div class="skill-health-empty">Failed to load summarizer health: ${(e as Error).message}</div>`,
);
}
});
// v0.22.6 — Skill-activity health: surface projects that are active
// (broadcasting) but recording zero skill outcomes. Catches the failure
// mode where the v0.21.0 enforcement levers got dropped from agent
// system prompts (e.g. spawn-agent.ps1 not patched, settings.json
// fallback missing on a worker). Run-once every 60s by HTMX from the
// top of the dashboard.
app.get("/dashboard/skill-health", async (_request, reply) => {
const { renderSkillHealthFragment, loadProjectNameMap } = await import(
"./dashboard/render.js"
);
const { withClient } = await import("./pg_pool.js");
try {
type Row = {
project_hash: string;
broadcasts_24h: string;
skill_runs_24h: string;
skill_show_calls_24h: string;
outcome_calls_24h: string;
unique_agents: string;
last_broadcast_at: string;
};
const rows = await withClient(async (c) => {
const res = await c.query<Row>(
`WITH broadcast_activity AS (
-- v0.25.2: dropped HAVING COUNT(*) >= 3 filter. The threshold
-- hid genuinely-active projects in their first hour: real-life
-- A2A_communication had 2 broadcasts (1 LAUNCH_ROLE + 1 ASSIGN)
-- in the first 2 minutes of a session, was filtered out, and
-- the operator saw "All 1 active project is healthy" instead
-- of seeing their newly-started project. Now any project with
-- ≥1 broadcast in the last 24h shows up — a much truer
-- definition of "active project right now."
SELECT project_hash,
COUNT(*) AS broadcasts_24h,
COUNT(DISTINCT agent_id) AS unique_agents,
MAX(created_at) AS last_broadcast_at
FROM broadcasts
WHERE created_at::timestamptz > NOW() - INTERVAL '24 hours'
GROUP BY project_hash
),
skill_run_counts AS (
SELECT project_hash, COUNT(*) AS skill_runs_24h
FROM skill_runs_pg
WHERE ts > NOW() - INTERVAL '24 hours'
GROUP BY project_hash
),
skill_show_counts AS (
-- v0.23.3: zc_skill_show window widened to 7 days. Window-boundary
-- artifact: a single agent session that loads a skill at hour 0
-- and records 5 outcomes over the next 30h would, with a 24h
-- window, show "0 skill_show, N outcomes" once the show drops
-- out of the 24h count. zc_skill_show is one-per-skill-load
-- (not one-per-task) so the load signal is multi-day. Outcomes
-- still measure recent (24h) activity.
SELECT project_hash,
COUNT(*) FILTER (WHERE tool_name = 'zc_skill_show'
AND ts > NOW() - INTERVAL '7 days') AS skill_show_calls_24h,
COUNT(*) FILTER (WHERE tool_name = 'zc_record_skill_outcome'
AND ts > NOW() - INTERVAL '24 hours') AS outcome_calls_24h
FROM tool_calls_pg
WHERE ts > NOW() - INTERVAL '7 days'
AND tool_name IN ('zc_skill_show', 'zc_record_skill_outcome')
GROUP BY project_hash
)
SELECT b.project_hash,
b.broadcasts_24h::text,
COALESCE(s.skill_runs_24h, 0)::text AS skill_runs_24h,
COALESCE(c.skill_show_calls_24h, 0)::text AS skill_show_calls_24h,
COALESCE(c.outcome_calls_24h, 0)::text AS outcome_calls_24h,
b.unique_agents::text,
b.last_broadcast_at
FROM broadcast_activity b
LEFT JOIN skill_run_counts s ON s.project_hash = b.project_hash
LEFT JOIN skill_show_counts c ON c.project_hash = b.project_hash
ORDER BY b.last_broadcast_at DESC
LIMIT 20`,
);
return res.rows;
});
const nameMap = await loadProjectNameMap();
const fragRows = rows.map((r) => ({
project_hash: r.project_hash,
project_name: nameMap.get(r.project_hash) ?? null,
broadcasts_24h: Number(r.broadcasts_24h),
skill_runs_24h: Number(r.skill_runs_24h),
skill_show_calls_24h: Number(r.skill_show_calls_24h),
outcome_calls_24h: Number(r.outcome_calls_24h),
unique_agents: Number(r.unique_agents),
last_broadcast_at: String(r.last_broadcast_at ?? ""),
}));
reply.type("text/html").send(renderSkillHealthFragment(fragRows));
} catch (e) {
reply
.type("text/html")
.send(
`<div class="skill-health-empty">Failed to load skill health: ${(e as Error).message}</div>`,
);
}
});
app.get("/dashboard/pending", async (_request, reply) => {
const { renderPendingFragment, loadProjectNameMap } = await import("./dashboard/render.js");
const { withClient } = await import("./pg_pool.js");
try {
const rows = await withClient(async (c) => {
// v0.18.4: LEFT JOIN skills_pg to fetch the parent body for the
// diff view in the dashboard (so each candidate can be shown
// side-by-side against what it's replacing).
const res = await c.query<Record<string, unknown>>(
`SELECT mr.result_id, mr.mutation_id, mr.skill_id, mr.project_hash, mr.proposer_model,
mr.proposer_role, mr.candidate_count, mr.best_score, mr.bodies, mr.bodies_hash,
mr.headline, mr.created_at, mr.original_task_id, mr.original_role,
mr.mutator_pool, sp.body AS parent_body
FROM mutation_results_pg mr
LEFT JOIN skills_pg sp ON sp.skill_id = mr.skill_id
WHERE mr.consumed_at IS NULL
ORDER BY mr.created_at DESC LIMIT 50`,
);
return res.rows;
});
// v0.18.3: resolve project_hash → name once per request (sync read of
// agents.json; cheap enough for a 10s poll, no caching needed yet).
const nameMap = await loadProjectNameMap();
reply.type("text/html").send(renderPendingFragment(rows, nameMap));
} catch (e) {
reply.type("text/html").send(`<div class="error">Failed to load pending: ${(e as Error).message}</div>`);
}
});
app.post("/dashboard/approve", async (request, reply) => {
const body = request.body as Record<string, unknown>;
const result_id = String(body.result_id ?? "").trim();
const confirm_id = String(body.confirm_id ?? "").trim();
const picked_candidate_idx = Number(body.picked_candidate_index);
const rationale = String(body.rationale ?? "").trim();
const auto_reassign = body.auto_reassign === "on" || body.auto_reassign === "true" || body.auto_reassign === true;
if (!result_id || result_id !== confirm_id) {
reply.type("text/html").send(`<div class="error">❌ Confirmation failed: typed ID does not match result_id.</div>`);
return;
}
if (!Number.isFinite(picked_candidate_idx) || picked_candidate_idx < 0) {
reply.type("text/html").send(`<div class="error">❌ picked_candidate_index missing or invalid.</div>`);
return;
}
if (!rationale) {
reply.type("text/html").send(`<div class="error">❌ Rationale required.</div>`);
return;
}
try {
const { handleApproveFromDashboard } = await import("./dashboard/operator_review.js");
const result = await handleApproveFromDashboard({ result_id, picked_candidate_index: picked_candidate_idx, rationale, auto_reassign });
reply.type("text/html").send(
`<div class="ok">✓ Approved <code>${escapeHtml(result_id)}</code><br>` +
`→ promoted to <code>${escapeHtml(result.new_skill_id)}</code> (candidate #${picked_candidate_idx})<br>` +
(result.retry_task_id ? `→ auto-reassigned retry task <code>${escapeHtml(result.retry_task_id)}</code> to role <code>${escapeHtml(result.original_role ?? "?")}</code><br>` : `→ no auto-reassign (operator unchecked OR no original_role)<br>`) +
`</div>`,
);
} catch (e) {
reply.type("text/html").send(`<div class="error">❌ ${escapeHtml((e as Error).message)}</div>`);
}
});
app.post("/dashboard/reject", async (request, reply) => {
const body = request.body as Record<string, unknown>;
const result_id = String(body.result_id ?? "").trim();
const confirm_id = String(body.confirm_id ?? "").trim();
const rationale = String(body.rationale ?? "").trim();
if (!result_id || result_id !== confirm_id) {
reply.type("text/html").send(`<div class="error">❌ Confirmation failed: typed ID does not match.</div>`);
return;
}
if (!rationale) {
reply.type("text/html").send(`<div class="error">❌ Rationale required.</div>`);
return;
}
try {
const { handleRejectFromDashboard } = await import("./dashboard/operator_review.js");
await handleRejectFromDashboard({ result_id, rationale });
reply.type("text/html").send(`<div class="ok">✗ Rejected <code>${escapeHtml(result_id)}</code></div>`);
} catch (e) {
reply.type("text/html").send(`<div class="error">❌ ${escapeHtml((e as Error).message)}</div>`);
}
});
function escapeHtml(s: string): string {
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
}
// ─── v0.38.0 — A2A protocol compatibility (Tier-2 #8, experimental) ─────────
// Minimal Google-A2A-shaped surface so EXTERNAL agents (LangGraph/CrewAI/etc.)
// can hand tasks to an SC-coordinated team: the agent card advertises the
// capability; tasks/send maps to a typed ASSIGN broadcast; tasks/get resolves
// from the (HMAC-chained) broadcast stream — a MERGE on the task completes it.
// SC's evidence gate + identity tokens still govern the agents doing the work.
app.get("/.well-known/agent.json", async (_request, reply) => {
reply.type("application/json").send({
name: "SecureContext Coordination Gateway",
description: "Hands tasks to a SecureContext-coordinated agent team (typed ASSIGN/MERGE broadcasts, evidence-gated outcomes, HMAC-chained audit).",
url: `http://${API_HOST === "0.0.0.0" ? "localhost" : API_HOST}:${API_PORT}`,
version: Config.VERSION,
capabilities: { streaming: false, pushNotifications: false },
defaultInputModes: ["text"],
defaultOutputModes: ["text"],
skills: [{
id: "assign_task",
name: "Assign a task to the project's agent team",
description: "Submits the message as an ASSIGN broadcast on the target project; the team's MERGE completes the task. Pass metadata.projectPath (or set ZC_A2A_DEFAULT_PROJECT_PATH).",
inputModes: ["text"], outputModes: ["text"],
}],
});
});
app.post("/a2a/tasks/send", async (request, reply) => {
try {
const body = request.body as Record<string, unknown>;
const meta = (body.metadata ?? {}) as Record<string, unknown>;
const taskId = String(body.id ?? `a2a-${Date.now().toString(36)}`).slice(0, 100);
const msg = body.message as { parts?: Array<{ text?: string }> } | undefined;
const text = (msg?.parts ?? []).map((p) => p.text ?? "").join("\n").trim();
if (!text) { reply.status(400).send({ error: "message.parts[].text is required" }); return; }
const rawPath = typeof meta.projectPath === "string" ? meta.projectPath : process.env.ZC_A2A_DEFAULT_PROJECT_PATH;
if (!rawPath) { reply.status(400).send({ error: "metadata.projectPath is required (or set ZC_A2A_DEFAULT_PROJECT_PATH)" }); return; }
const pp = validateProjectPath(rawPath);
await store.broadcast(pp, "ASSIGN", "a2a-gateway", {
task: taskId, summary: text.slice(0, 950), state: "a2a-submitted", importance: 4,
});
reply.send({ id: taskId, status: { state: "submitted", timestamp: new Date().toISOString() } });
} catch (e) {
reply.status(500).send({ error: (e as Error).message });
}
});
app.get("/a2a/tasks/:id", async (request, reply) => {
try {
const taskId = String((request.params as Record<string, unknown>).id ?? "").slice(0, 100);
const { withClient } = await import("./pg_pool.js");
const rows = await withClient(async (c) => (await c.query<{ type: string; summary: string; agent_id: string; created_at: string }>(
`SELECT type, summary, agent_id, created_at FROM broadcasts WHERE task = $1 ORDER BY id DESC LIMIT 20`, [taskId])).rows);
if (rows.length === 0) { reply.status(404).send({ error: `unknown task ${taskId}` }); return; }
const merge = rows.find((r) => r.type === "MERGE");
const state = merge ? "completed" : rows.some((r) => r.type === "STATUS" || r.type === "REVISE") ? "working" : "submitted";
reply.send({
id: taskId,
status: { state, timestamp: rows[0]!.created_at },
...(merge ? { artifacts: [{ parts: [{ type: "text", text: merge.summary }], metadata: { agent: merge.agent_id } }] } : {}),
});
} catch (e) {
reply.status(500).send({ error: (e as Error).message });
}
});
// ─── v0.39.0 — Trajectory export (Tier-3 #13) ────────────────────────────────
// Streams recent agent sessions as JSONL trajectories for eval/analysis pipelines:
// per session, the ordered tool-call sequence (with chain hashes — each line is
// independently verifiable) + the evidence-rich outcomes. Metadata-level by design:
// SC deliberately never stores raw tool payloads, so trajectories carry the
// structure + evidence signal, not conversation content.
app.get("/api/v1/trajectories/export", async (request, reply) => {
try {
const q = request.query as Record<string, unknown>;
const sinceDays = Math.max(1, Math.min(90, parseInt(String(q.sinceDays ?? "7"), 10) || 7));
const limitSessions = Math.max(1, Math.min(500, parseInt(String(q.limitSessions ?? "50"), 10) || 50));
// Optional project filter: a 16-hex project hash or a filesystem path.
let projectHash: string | null = null;
const rawProject = String(q.project ?? "").trim();
if (rawProject) {
if (/^[0-9a-f]{16}$/.test(rawProject)) {
projectHash = rawProject;
} else {
const pp = validateProjectPath(rawProject);
const { createHash } = await import("node:crypto");
const { realpathSync } = await import("node:fs");
let normalized = pp;
try { normalized = realpathSync(pp); } catch { /* use raw */ }
projectHash = createHash("sha256").update(normalized).digest("hex").slice(0, 16);
}
}
const { withClient } = await import("./pg_pool.js");
const lines: string[] = [];
await withClient(async (c) => {
const params: unknown[] = [sinceDays, limitSessions];
let projClause = "";
if (projectHash) { params.push(projectHash); projClause = ` AND project_hash = $${params.length}`; }
const sessions = (await c.query<{ session_id: string; agent_id: string; project_hash: string; started: Date; ended: Date; calls: string }>(
`SELECT session_id, agent_id, project_hash, MIN(ts) AS started, MAX(ts) AS ended, COUNT(*)::text AS calls
FROM tool_calls_pg WHERE ts > NOW() - ($1::int * INTERVAL '1 day')${projClause}
GROUP BY session_id, agent_id, project_hash ORDER BY MAX(ts) DESC LIMIT $2`,
params)).rows;
for (const s of sessions) {
const events = (await c.query<Record<string, unknown>>(
`SELECT tool_name, status, latency_ms, input_tokens, output_tokens, skill_id, task_id, row_hash
FROM tool_calls_pg WHERE session_id = $1 ORDER BY id ASC LIMIT 500`, [s.session_id])).rows;
let outcomes: Record<string, unknown>[] = [];
try {
outcomes = (await c.query<Record<string, unknown>>(
`SELECT * FROM outcomes_pg WHERE session_id = $1 ORDER BY 1 ASC LIMIT 50`, [s.session_id])).rows
.map((o) => ({ task_id: o.task_id ?? null, status: o.status ?? o.outcome ?? null, evidence: o.evidence ?? null }));
} catch { /* outcomes table variant — omit */ }
lines.push(JSON.stringify({
session_id: s.session_id, agent: s.agent_id, project_hash: s.project_hash,
started: s.started, ended: s.ended, tool_calls: Number(s.calls),
events, outcomes,
}));
}
});
reply.type("application/x-ndjson").send(lines.join("\n") + (lines.length ? "\n" : ""));
} catch (e) {
reply.status(500).send({ error: (e as Error).message });
}
});
// ─── v0.33.0 — Suspected-contradictions review (dashboard) ──────────────────
app.get("/dashboard/contradictions", async (_request, reply) => {
const { renderContradictionsFragment, loadProjectNameMap } = await import("./dashboard/render.js");
const { withClient } = await import("./pg_pool.js");
try {
const rows = await withClient(async (c) => {
// Join each flagged key to its fact value for context (prefer the agent's own
// value, else the shared 'default' pool). Correlated subqueries avoid row fan-out.
const res = await c.query<Record<string, unknown>>(
`SELECT mc.project_hash, mc.agent_id, mc.key_a, mc.key_b, mc.reason, mc.similarity, mc.surfaced_at,
(SELECT value FROM working_memory w WHERE w.project_hash = mc.project_hash AND w.key = mc.key_a
AND (w.agent_id = mc.agent_id OR w.agent_id = 'default')
ORDER BY (w.agent_id = mc.agent_id) DESC LIMIT 1) AS value_a,
(SELECT value FROM working_memory w WHERE w.project_hash = mc.project_hash AND w.key = mc.key_b
AND (w.agent_id = mc.agent_id OR w.agent_id = 'default')
ORDER BY (w.agent_id = mc.agent_id) DESC LIMIT 1) AS value_b
FROM memory_contradictions_pg mc
WHERE mc.status = 'open'
ORDER BY mc.surfaced_at DESC LIMIT 100`,
);
return res.rows;
});
// v0.37.0 — recently auto-resolved conflicts (retired stale side), each with an Undo.
const autoRows = await withClient(async (c) => {
const res = await c.query<Record<string, unknown>>(
`SELECT project_hash, agent_id, key_a, key_b, reason, similarity, detail, reviewed_at
FROM memory_contradictions_pg
WHERE status = 'resolved' AND resolution_mode = 'auto' AND reviewed_at > NOW() - INTERVAL '7 days'
ORDER BY reviewed_at DESC LIMIT 50`,
);
return res.rows;
}).catch(() => [] as Record<string, unknown>[]);
const nameMap = await loadProjectNameMap();
reply.type("text/html").send(renderContradictionsFragment(rows, nameMap, autoRows));
} catch (e) {
reply.type("text/html").send(`<p class="empty">Suspected contradictions unavailable: ${escapeHtml((e as Error).message)}</p>`);
}
});
app.post("/dashboard/contradictions/review", async (request, reply) => {
const body = request.body as Record<string, unknown>;
const project_hash = String(body.project_hash ?? "").trim();
const agent_id = String(body.agent_id ?? "default").trim();
const key_a = String(body.key_a ?? "").trim();
const key_b = String(body.key_b ?? "").trim();
const action = String(body.action ?? "").trim();
// v0.37.0 — actions now ACT ON MEMORY, not just the flag:
// keep_a / keep_b → retire the losing fact (valid_to + KB archival), flag → resolved
// not_conflict → both facts stay, flag → dismissed (operator override; never re-auto-resolved)
// undo → revive the retired side of an auto/operator resolution, flag → open
const VALID = new Set(["keep_a", "keep_b", "not_conflict", "undo"]);
if (!project_hash || !key_a || !key_b || !VALID.has(action)) {
reply.type("text/html").send(`<div class="contra-resolved" style="border-left-color:#ff5d6c;color:#ffb3bb">❌ Invalid review request.</div>`);
return;
}
try {
const { withClient } = await import("./pg_pool.js");
const err = (m: string) => reply.type("text/html").send(`<div class="contra-resolved" style="border-left-color:#ff5d6c;color:#ffb3bb">❌ ${escapeHtml(m)}</div>`);
if (action === "not_conflict") {
const changed = await withClient(async (c) => (await c.query(
`UPDATE memory_contradictions_pg SET status = 'dismissed', reviewed_at = NOW(), resolution_mode = 'not_conflict'
WHERE project_hash = $1 AND agent_id = $2 AND key_a = $3 AND key_b = $4 AND status = 'open'`,