agentwrangler 0.1.0
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/LICENSE +191 -0
- package/README.md +116 -0
- package/dist/apply/jobs.js +429 -0
- package/dist/apply/open-terminal-child.mjs +98 -0
- package/dist/apply/open-terminal.js +221 -0
- package/dist/apply/settings-gen.js +35 -0
- package/dist/cli/agentwrangler.js +18 -0
- package/dist/daemon/config.js +51 -0
- package/dist/daemon/http.js +258 -0
- package/dist/daemon/index.js +372 -0
- package/dist/daemon/outcomes-pass.js +82 -0
- package/dist/daemon/readiness.js +15 -0
- package/dist/daemon/router.js +756 -0
- package/dist/daemon/static.js +146 -0
- package/dist/db/migrate.js +72 -0
- package/dist/db/migrations/001_observe.sql +196 -0
- package/dist/db/migrations/002_indexes.sql +6 -0
- package/dist/db/migrations/003_context_inventory_history.sql +20 -0
- package/dist/db/migrations/004_apply_jobs.sql +17 -0
- package/dist/db/migrations/005_tool_event_metadata.sql +17 -0
- package/dist/db/migrations/006_d7_query_indexes.sql +9 -0
- package/dist/db/migrations/007_work_item_branch_keys.sql +11 -0
- package/dist/db/migrations/008_thinking_tokens.sql +1 -0
- package/dist/db/migrations/009_user_turn_count.sql +1 -0
- package/dist/db/migrations/010_workspace_cwd.sql +1 -0
- package/dist/db/migrations/011_reports.sql +1 -0
- package/dist/db/migrations/012_reconcile_indexes.sql +2 -0
- package/dist/db/migrations/013_friction_fields.sql +5 -0
- package/dist/db/migrations/014_session_churn.sql +11 -0
- package/dist/db/migrations/015_gap_aggregates.sql +6 -0
- package/dist/db/open.js +30 -0
- package/dist/detector/benchmark-anchors.js +36 -0
- package/dist/detector/calibration.js +302 -0
- package/dist/detector/context-history-retention.js +312 -0
- package/dist/detector/context-probe.js +574 -0
- package/dist/detector/d1-source-identity.js +25 -0
- package/dist/detector/detectors/d10_catalog_footprint.js +146 -0
- package/dist/detector/detectors/d1_ctx_always_loaded.js +203 -0
- package/dist/detector/detectors/d2_session_long_full_context.js +119 -0
- package/dist/detector/detectors/d4_model_mismatch.js +258 -0
- package/dist/detector/detectors/d5_limit_burn_forecast.js +138 -0
- package/dist/detector/detectors/d6_tool_result_bloat.js +301 -0
- package/dist/detector/detectors/d7_loop_retry_waste.js +345 -0
- package/dist/detector/detectors/d8_cache_write_churn.js +201 -0
- package/dist/detector/detectors/d9_idle_background_session.js +101 -0
- package/dist/detector/engine.js +88 -0
- package/dist/detector/index.js +17 -0
- package/dist/detector/measurement.js +426 -0
- package/dist/detector/practice-registry.js +259 -0
- package/dist/detector/registry.js +32 -0
- package/dist/detector/savings.js +249 -0
- package/dist/detector/types.js +14 -0
- package/dist/evidence/common/approved-input.js +632 -0
- package/dist/evidence/common/boundary.js +84 -0
- package/dist/evidence/common/canonical.js +55 -0
- package/dist/evidence/common/redaction.js +321 -0
- package/dist/evidence/common/sqlite.js +25 -0
- package/dist/evidence/common/state.js +29 -0
- package/dist/evidence/cond1/cli.js +289 -0
- package/dist/evidence/cond1/packet.js +407 -0
- package/dist/evidence/cond1/prepare.js +295 -0
- package/dist/evidence/cond1/score.js +349 -0
- package/dist/evidence/cond1/types.js +1 -0
- package/dist/evidence/create-approval.js +365 -0
- package/dist/evidence/create-scratch.js +542 -0
- package/dist/evidence/d7/cli.js +113 -0
- package/dist/evidence/d7/measure.js +193 -0
- package/dist/evidence/d7/types.js +1 -0
- package/dist/evidence/discover-approval.js +492 -0
- package/dist/evidence/g2/adjudicate.js +20 -0
- package/dist/evidence/g2/cli.js +207 -0
- package/dist/evidence/g2/kappa.js +39 -0
- package/dist/evidence/g2/pipeline.js +92 -0
- package/dist/evidence/g2/store.js +14 -0
- package/dist/evidence/github/client.js +1 -0
- package/dist/evidence/github/gh-cli-client.js +301 -0
- package/dist/evidence/r3/cli.js +209 -0
- package/dist/evidence/r3/evaluate.js +417 -0
- package/dist/evidence/r3/packet.js +162 -0
- package/dist/evidence/r3/prepare.js +405 -0
- package/dist/evidence/r3/score.js +341 -0
- package/dist/evidence/r3/transcript.js +155 -0
- package/dist/evidence/r3/types.js +4 -0
- package/dist/hook/context-budget-hook.mjs +138 -0
- package/dist/hook/danger-guard-denylist.json +27 -0
- package/dist/hook/danger-guard-hook.mjs +167 -0
- package/dist/hook/install.js +0 -0
- package/dist/hook/limit-burn-hook.mjs +127 -0
- package/dist/hook/loop-guard-hook.mjs +104 -0
- package/dist/hook/precompact-checkpoint-hook.mjs +123 -0
- package/dist/ingest/churn-collector.js +122 -0
- package/dist/ingest/detector-hook.js +52 -0
- package/dist/ingest/discovery.js +207 -0
- package/dist/ingest/health.js +43 -0
- package/dist/ingest/index.js +28 -0
- package/dist/ingest/ingestor.js +509 -0
- package/dist/ingest/parser.js +344 -0
- package/dist/ingest/pricing.js +153 -0
- package/dist/ingest/reconcile.js +52 -0
- package/dist/ingest/tail.js +152 -0
- package/dist/ingest/types.js +24 -0
- package/dist/ingest/workspace-mapping.js +114 -0
- package/dist/oauth/anthropic-api-key.js +88 -0
- package/dist/oauth/count-tokens.js +86 -0
- package/dist/oauth/credentials.js +171 -0
- package/dist/oauth/judge-g2-client.js +154 -0
- package/dist/oauth/usage.js +167 -0
- package/dist/outcomes/branch-key.js +49 -0
- package/dist/outcomes/conclusions.js +45 -0
- package/dist/outcomes/derive.js +94 -0
- package/dist/outcomes/finding-extractors.js +131 -0
- package/dist/outcomes/findings.js +237 -0
- package/dist/outcomes/github/client.js +367 -0
- package/dist/outcomes/github/credential.js +195 -0
- package/dist/outcomes/github/gh-cli-client.js +340 -0
- package/dist/outcomes/linker.js +486 -0
- package/dist/outcomes/pool.js +24 -0
- package/dist/outcomes/sync.js +276 -0
- package/dist/query/api/agents-liveness.js +182 -0
- package/dist/query/api/burn-status.js +50 -0
- package/dist/query/api/context-budget.js +114 -0
- package/dist/query/api/context-composition.js +67 -0
- package/dist/query/api/cost-per-success.js +104 -0
- package/dist/query/api/delivery.js +92 -0
- package/dist/query/api/effectiveness.js +254 -0
- package/dist/query/api/efficiency-headroom.js +74 -0
- package/dist/query/api/headroom-trend.js +105 -0
- package/dist/query/api/hook-config.js +75 -0
- package/dist/query/api/hook-install.js +8 -0
- package/dist/query/api/hot-sessions.js +17 -0
- package/dist/query/api/idle-sessions.js +52 -0
- package/dist/query/api/index.js +40 -0
- package/dist/query/api/loop-guard.js +90 -0
- package/dist/query/api/offload-share.js +41 -0
- package/dist/query/api/outcomes.js +218 -0
- package/dist/query/api/overview.js +535 -0
- package/dist/query/api/rec-prompt.js +138 -0
- package/dist/query/api/recommendations-ledger.js +111 -0
- package/dist/query/api/recommendations.js +514 -0
- package/dist/query/api/reports.js +78 -0
- package/dist/query/api/self-churn.js +77 -0
- package/dist/query/api/self-percentiles.js +109 -0
- package/dist/query/api/session-drivers.js +153 -0
- package/dist/query/api/settings.js +85 -0
- package/dist/query/api/spend-flavor.js +234 -0
- package/dist/query/api/trends.js +155 -0
- package/dist/query/cap-weighted.js +119 -0
- package/dist/query/db-context.js +42 -0
- package/dist/query/envelope.js +71 -0
- package/dist/query/forecast.js +191 -0
- package/dist/query/settings-store.js +441 -0
- package/dist/query/spend.js +171 -0
- package/dist/query/trends.js +194 -0
- package/dist/ui/assets/index-DnRKgc21.css +1 -0
- package/dist/ui/assets/index-h1Q1wWq5.js +168 -0
- package/dist/ui/index.html +39 -0
- package/package.json +59 -0
|
@@ -0,0 +1,756 @@
|
|
|
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
|
+
*/
|
|
9
|
+
import { getPractices } from "../detector/practice-registry.js";
|
|
10
|
+
import { getOAuthStatus } from "../oauth/credentials.js";
|
|
11
|
+
import { getGithubTokenStatus } from "../outcomes/github/credential.js";
|
|
12
|
+
import { manualLink, manualUnlink } from "../outcomes/linker.js";
|
|
13
|
+
import { endSession, getAgentsLiveness } from "../query/api/agents-liveness.js";
|
|
14
|
+
import { getBurnStatus } from "../query/api/burn-status.js";
|
|
15
|
+
import { getCostPerSuccess } from "../query/api/cost-per-success.js";
|
|
16
|
+
import { getDeliveryMetrics } from "../query/api/delivery.js";
|
|
17
|
+
import { getClosureProxy } from "../query/api/effectiveness.js";
|
|
18
|
+
import { getEfficiencyHeadroom } from "../query/api/efficiency-headroom.js";
|
|
19
|
+
import { getHeadroomTrend } from "../query/api/headroom-trend.js";
|
|
20
|
+
import { getHotSessions } from "../query/api/hot-sessions.js";
|
|
21
|
+
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";
|
|
22
|
+
import { getOffloadShare } from "../query/api/offload-share.js";
|
|
23
|
+
import { getReport, listReports } from "../query/api/reports.js";
|
|
24
|
+
import { getSelfChurn } from "../query/api/self-churn.js";
|
|
25
|
+
import { getSessionSpendPercentile, getWeeklySelfPercentile, } from "../query/api/self-percentiles.js";
|
|
26
|
+
import { getFlavorDecomposition } from "../query/api/spend-flavor.js";
|
|
27
|
+
import { getCacheWriteTrend, getTrends } from "../query/api/trends.js";
|
|
28
|
+
import { getSettingsData } from "../query/settings-store.js";
|
|
29
|
+
import { isReady } from "./readiness.js";
|
|
30
|
+
function sendJson(res, status, body) {
|
|
31
|
+
const payload = JSON.stringify(body);
|
|
32
|
+
res.writeHead(status, {
|
|
33
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
34
|
+
"Content-Length": Buffer.byteLength(payload),
|
|
35
|
+
});
|
|
36
|
+
res.end(payload);
|
|
37
|
+
}
|
|
38
|
+
function readBody(req) {
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
const chunks = [];
|
|
41
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
42
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
|
43
|
+
req.on("error", reject);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
// JS Date is representable within ±8.64e15 ms of the epoch. resolveWindow can
|
|
47
|
+
// derive a bound up to 7 days beyond an explicit from/to; reject any date so
|
|
48
|
+
// close to that limit that the derived window would overflow (a RangeError from
|
|
49
|
+
// toISOString). Parsable-but-unbounded dates are dropped, not crashed on.
|
|
50
|
+
const MAX_WINDOW_TS = 8.64e15 - 7 * 24 * 60 * 60 * 1000;
|
|
51
|
+
function parseDateParam(v) {
|
|
52
|
+
if (v === null)
|
|
53
|
+
return undefined;
|
|
54
|
+
const t = Date.parse(v);
|
|
55
|
+
if (Number.isNaN(t) || Math.abs(t) > MAX_WINDOW_TS)
|
|
56
|
+
return undefined;
|
|
57
|
+
return v;
|
|
58
|
+
}
|
|
59
|
+
function parseWindowFilter(url) {
|
|
60
|
+
const qs = url.split("?")[1] ?? "";
|
|
61
|
+
const params = new URLSearchParams(qs);
|
|
62
|
+
const filter = {};
|
|
63
|
+
const preset = params.get("preset");
|
|
64
|
+
if (preset === "24h" || preset === "7d" || preset === "30d")
|
|
65
|
+
filter.preset = preset;
|
|
66
|
+
const from = parseDateParam(params.get("from"));
|
|
67
|
+
if (from !== undefined)
|
|
68
|
+
filter.from = from;
|
|
69
|
+
const to = parseDateParam(params.get("to"));
|
|
70
|
+
if (to !== undefined)
|
|
71
|
+
filter.to = to;
|
|
72
|
+
return filter;
|
|
73
|
+
}
|
|
74
|
+
const PRESET_DAYS = {
|
|
75
|
+
"24h": 1,
|
|
76
|
+
"7d": 7,
|
|
77
|
+
"30d": 30,
|
|
78
|
+
};
|
|
79
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
80
|
+
function resolveWindow(filters, now = new Date()) {
|
|
81
|
+
const nowIso = now.toISOString();
|
|
82
|
+
if (filters.preset !== undefined) {
|
|
83
|
+
return {
|
|
84
|
+
from: new Date(now.getTime() - PRESET_DAYS[filters.preset] * MS_PER_DAY).toISOString(),
|
|
85
|
+
to: nowIso,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
if (filters.from !== undefined && filters.to !== undefined)
|
|
89
|
+
return { from: filters.from, to: filters.to };
|
|
90
|
+
if (filters.from !== undefined)
|
|
91
|
+
return { from: filters.from, to: nowIso };
|
|
92
|
+
if (filters.to !== undefined) {
|
|
93
|
+
return {
|
|
94
|
+
from: new Date(new Date(filters.to).getTime() - 7 * MS_PER_DAY).toISOString(),
|
|
95
|
+
to: filters.to,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
return { from: new Date(now.getTime() - 7 * MS_PER_DAY).toISOString(), to: nowIso };
|
|
99
|
+
}
|
|
100
|
+
function parseCursor(url) {
|
|
101
|
+
const params = new URLSearchParams(url.split("?")[1] ?? "");
|
|
102
|
+
const after = params.get("after") ?? undefined;
|
|
103
|
+
const rawLimit = params.get("limit");
|
|
104
|
+
const limit = rawLimit === null ? undefined : Number(rawLimit);
|
|
105
|
+
const cursor = {};
|
|
106
|
+
if (after !== undefined)
|
|
107
|
+
cursor.after = after;
|
|
108
|
+
if (limit !== undefined && Number.isInteger(limit) && limit > 0)
|
|
109
|
+
cursor.limit = limit;
|
|
110
|
+
return cursor;
|
|
111
|
+
}
|
|
112
|
+
function recApplyId(pathname) {
|
|
113
|
+
const m = pathname.match(/^\/api\/recommendations\/([^/]+)\/apply$/);
|
|
114
|
+
return m?.[1] ? decodeURIComponent(m[1]) : null;
|
|
115
|
+
}
|
|
116
|
+
function recOpenTerminalId(pathname) {
|
|
117
|
+
const m = pathname.match(/^\/api\/recommendations\/([^/]+)\/open-terminal$/);
|
|
118
|
+
return m?.[1] ? decodeURIComponent(m[1]) : null;
|
|
119
|
+
}
|
|
120
|
+
function jobRoute(pathname) {
|
|
121
|
+
const m = pathname.match(/^\/api\/recommendations\/jobs\/([^/]+)(?:\/(confirm|rollback))?$/);
|
|
122
|
+
if (!m?.[1])
|
|
123
|
+
return null;
|
|
124
|
+
const action = m[2] === "confirm" || m[2] === "rollback" ? m[2] : "get";
|
|
125
|
+
return { jobId: decodeURIComponent(m[1]), action };
|
|
126
|
+
}
|
|
127
|
+
export function handleApiRequest(_db, req, res, method, url) {
|
|
128
|
+
// Strip query string for routing.
|
|
129
|
+
const pathname = url.split("?")[0] ?? url;
|
|
130
|
+
try {
|
|
131
|
+
// GET /api/overview
|
|
132
|
+
if (method === "GET" && pathname === "/api/overview") {
|
|
133
|
+
sendJson(res, 200, getGlobalOverview(parseWindowFilter(url)));
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
// GET /api/context-budget?session_id=... (strictly fail-open budget hook)
|
|
137
|
+
if (method === "GET" && pathname === "/api/context-budget") {
|
|
138
|
+
const sessionId = new URLSearchParams(url.split("?")[1] ?? "").get("session_id") ?? "";
|
|
139
|
+
sendJson(res, 200, getContextBudget(sessionId));
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
// GET /api/loop-guard?session_id=... (strictly fail-open D7 hook)
|
|
143
|
+
if (method === "GET" && pathname === "/api/loop-guard") {
|
|
144
|
+
const sessionId = new URLSearchParams(url.split("?")[1] ?? "").get("session_id") ?? "";
|
|
145
|
+
sendJson(res, 200, getLoopGuard(sessionId));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
// GET /api/idle-sessions (strictly read-only D9 idle-session measurements)
|
|
149
|
+
if (method === "GET" && pathname === "/api/idle-sessions") {
|
|
150
|
+
sendJson(res, 200, getIdleSessions());
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (method === "GET" && pathname === "/api/agents-liveness") {
|
|
154
|
+
getAgentsLiveness()
|
|
155
|
+
.then((r) => sendJson(res, 200, r))
|
|
156
|
+
.catch(() => sendJson(res, 500, { error: "liveness failed" }));
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (method === "POST" && pathname === "/api/idle-sessions/end") {
|
|
160
|
+
readBody(req)
|
|
161
|
+
.then((raw) => {
|
|
162
|
+
let body;
|
|
163
|
+
try {
|
|
164
|
+
body = JSON.parse(raw);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
sendJson(res, 400, { error: "Invalid JSON body" });
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const result = endSession(body.pid, body.confirm);
|
|
171
|
+
sendJson(res, result.status, result);
|
|
172
|
+
})
|
|
173
|
+
.catch(() => sendJson(res, 500, { error: "Internal error reading request body" }));
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
// GET/POST /api/hook-config (dedicated user_config keys; CSRF gated in http.ts)
|
|
177
|
+
if (method === "GET" && pathname === "/api/hook-config") {
|
|
178
|
+
sendJson(res, 200, getHookConfig());
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (method === "POST" && pathname === "/api/hook-config") {
|
|
182
|
+
readBody(req)
|
|
183
|
+
.then((raw) => {
|
|
184
|
+
let body;
|
|
185
|
+
try {
|
|
186
|
+
body = JSON.parse(raw);
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
sendJson(res, 400, { error: "Invalid JSON body" });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
sendJson(res, 200, updateHookConfig(body));
|
|
194
|
+
}
|
|
195
|
+
catch (e) {
|
|
196
|
+
sendJson(res, 400, { error: e instanceof Error ? e.message : "Invalid hook config" });
|
|
197
|
+
}
|
|
198
|
+
})
|
|
199
|
+
.catch(() => sendJson(res, 500, { error: "Internal error reading request body" }));
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
// POST /api/hook/install and /api/hook/uninstall (local settings write; token gated in http.ts)
|
|
203
|
+
if (method === "POST" && pathname === "/api/hook/install") {
|
|
204
|
+
sendJson(res, 200, installHookRoute());
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (method === "POST" && pathname === "/api/hook/uninstall") {
|
|
208
|
+
sendJson(res, 200, uninstallHookRoute());
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
// GET /api/reports (stored deterministic weekly report artifacts)
|
|
212
|
+
if (method === "GET" && pathname === "/api/reports") {
|
|
213
|
+
sendJson(res, 200, listReports(_db));
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const reportMatch = pathname.match(/^\/api\/reports\/([^/]+)$/);
|
|
217
|
+
if (method === "GET" && reportMatch?.[1]) {
|
|
218
|
+
const report = getReport(_db, decodeURIComponent(reportMatch[1]));
|
|
219
|
+
sendJson(res, report === undefined ? 404 : 200, report === undefined ? null : report);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
// GET /api/workspaces (per-workspace comparison table)
|
|
223
|
+
if (method === "GET" && pathname === "/api/workspaces") {
|
|
224
|
+
sendJson(res, 200, listWorkspaces(parseWindowFilter(url)));
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
// GET /api/delivery (delivery proxy metrics across all workspaces)
|
|
228
|
+
if (method === "GET" && pathname === "/api/delivery") {
|
|
229
|
+
const { from, to } = resolveWindow(parseWindowFilter(url));
|
|
230
|
+
sendJson(res, 200, getDeliveryMetrics(_db, { workspaceId: null, from, to }));
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const workspaceDeliveryMatch = pathname.match(/^\/api\/workspaces\/([^/]+)\/delivery$/);
|
|
234
|
+
if (method === "GET" && workspaceDeliveryMatch?.[1]) {
|
|
235
|
+
const { from, to } = resolveWindow(parseWindowFilter(url));
|
|
236
|
+
sendJson(res, 200, getDeliveryMetrics(_db, {
|
|
237
|
+
workspaceId: decodeURIComponent(workspaceDeliveryMatch[1]),
|
|
238
|
+
from,
|
|
239
|
+
to,
|
|
240
|
+
}));
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
// GET /api/cost-per-success (lifecycle cost-per-success across all workspaces)
|
|
244
|
+
if (method === "GET" && pathname === "/api/cost-per-success") {
|
|
245
|
+
const { from, to } = resolveWindow(parseWindowFilter(url));
|
|
246
|
+
sendJson(res, 200, getCostPerSuccess(_db, null, from, to));
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const workspaceCostPerSuccessMatch = pathname.match(/^\/api\/workspaces\/([^/]+)\/cost-per-success$/);
|
|
250
|
+
if (method === "GET" && workspaceCostPerSuccessMatch?.[1]) {
|
|
251
|
+
const { from, to } = resolveWindow(parseWindowFilter(url));
|
|
252
|
+
sendJson(res, 200, getCostPerSuccess(_db, decodeURIComponent(workspaceCostPerSuccessMatch[1]), from, to));
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
// GET /api/self-churn (14-day self-churn structural proxy across all workspaces)
|
|
256
|
+
if (method === "GET" && pathname === "/api/self-churn") {
|
|
257
|
+
const { from, to } = resolveWindow(parseWindowFilter(url));
|
|
258
|
+
sendJson(res, 200, getSelfChurn(_db, { workspaceId: null, from, to }));
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
// GET /api/practices (BM1 published-best-practice scorecard, computed on request)
|
|
262
|
+
if (method === "GET" && pathname === "/api/practices") {
|
|
263
|
+
const { from, to } = resolveWindow(parseWindowFilter(url));
|
|
264
|
+
sendJson(res, 200, getPractices(_db, { from, to }));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
// GET /api/efficiency-headroom (BM2 modeled savings vs trailing-window spend)
|
|
268
|
+
if (method === "GET" && pathname === "/api/efficiency-headroom") {
|
|
269
|
+
const { from, to } = resolveWindow(parseWindowFilter(url));
|
|
270
|
+
sendJson(res, 200, getEfficiencyHeadroom(_db, { from, to }));
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
const workspaceSelfChurnMatch = pathname.match(/^\/api\/workspaces\/([^/]+)\/self-churn$/);
|
|
274
|
+
if (method === "GET" && workspaceSelfChurnMatch?.[1]) {
|
|
275
|
+
const { from, to } = resolveWindow(parseWindowFilter(url));
|
|
276
|
+
sendJson(res, 200, getSelfChurn(_db, {
|
|
277
|
+
workspaceId: decodeURIComponent(workspaceSelfChurnMatch[1]),
|
|
278
|
+
from,
|
|
279
|
+
to,
|
|
280
|
+
}));
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
// GET /api/offload-share (within-session subagent offload share across all workspaces)
|
|
284
|
+
if (method === "GET" && pathname === "/api/offload-share") {
|
|
285
|
+
const { from, to } = resolveWindow(parseWindowFilter(url));
|
|
286
|
+
sendJson(res, 200, getOffloadShare(_db, { workspaceId: null, from, to }));
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const workspaceOffloadShareMatch = pathname.match(/^\/api\/workspaces\/([^/]+)\/offload-share$/);
|
|
290
|
+
if (method === "GET" && workspaceOffloadShareMatch?.[1]) {
|
|
291
|
+
const { from, to } = resolveWindow(parseWindowFilter(url));
|
|
292
|
+
sendJson(res, 200, getOffloadShare(_db, {
|
|
293
|
+
workspaceId: decodeURIComponent(workspaceOffloadShareMatch[1]),
|
|
294
|
+
from,
|
|
295
|
+
to,
|
|
296
|
+
}));
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const workspaceClosureProxyMatch = pathname.match(/^\/api\/workspaces\/([^/]+)\/closure-proxy$/);
|
|
300
|
+
if (method === "GET" && workspaceClosureProxyMatch?.[1]) {
|
|
301
|
+
sendJson(res, 200, getClosureProxy(_db, {
|
|
302
|
+
workspaceId: decodeURIComponent(workspaceClosureProxyMatch[1]),
|
|
303
|
+
now: new Date().toISOString(),
|
|
304
|
+
}));
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const workspaceSessionsMatch = pathname.match(/^\/api\/workspaces\/([^/]+)\/sessions$/);
|
|
308
|
+
if (method === "GET" && workspaceSessionsMatch?.[1]) {
|
|
309
|
+
sendJson(res, 200, listSessions(decodeURIComponent(workspaceSessionsMatch[1]), parseWindowFilter(url), parseCursor(url)));
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const contextMatch = pathname.match(/^\/api\/workspaces\/([^/]+)\/context$/);
|
|
313
|
+
if (method === "GET" && contextMatch?.[1]) {
|
|
314
|
+
sendJson(res, 200, getContextComposition(decodeURIComponent(contextMatch[1])));
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
// GET /api/live (live strip — LIVE sessions, <=30s freshness)
|
|
318
|
+
if (method === "GET" && pathname === "/api/live") {
|
|
319
|
+
sendJson(res, 200, listLiveSessions());
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
// GET /api/hot-sessions (sessions ranked by list-equivalent cost)
|
|
323
|
+
// Optional preset/from/to windows the ranking (used by Briefs prior-week deltas);
|
|
324
|
+
// with no window param it stays all-time (unchanged default).
|
|
325
|
+
if (method === "GET" && pathname === "/api/hot-sessions") {
|
|
326
|
+
const wf = parseWindowFilter(url);
|
|
327
|
+
const hasWindow = wf.preset !== undefined || wf.from !== undefined || wf.to !== undefined;
|
|
328
|
+
sendJson(res, 200, getHotSessions(hasWindow ? resolveWindow(wf) : undefined));
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
// GET /api/self-percentiles/weekly (BM3 — this ISO week vs the trailing 8
|
|
332
|
+
// full weeks for spend + cache-write share; plain observed numbers, no envelope)
|
|
333
|
+
if (method === "GET" && pathname === "/api/self-percentiles/weekly") {
|
|
334
|
+
sendJson(res, 200, getWeeklySelfPercentile(_db, new Date()));
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const sessionTurnsMatch = pathname.match(/^\/api\/sessions\/([^/]+)\/turns$/);
|
|
338
|
+
if (method === "GET" && sessionTurnsMatch?.[1]) {
|
|
339
|
+
sendJson(res, 200, getTurnTimeline(decodeURIComponent(sessionTurnsMatch[1]), parseCursor(url)));
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const sessionDriversMatch = pathname.match(/^\/api\/sessions\/([^/]+)\/drivers$/);
|
|
343
|
+
if (method === "GET" && sessionDriversMatch?.[1]) {
|
|
344
|
+
sendJson(res, 200, getSessionDrivers(_db, decodeURIComponent(sessionDriversMatch[1])));
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
const sessionSpendPercentileMatch = pathname.match(/^\/api\/sessions\/([^/]+)\/spend-percentile$/);
|
|
348
|
+
if (method === "GET" && sessionSpendPercentileMatch?.[1]) {
|
|
349
|
+
sendJson(res, 200, getSessionSpendPercentile(_db, decodeURIComponent(sessionSpendPercentileMatch[1])));
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
const sessionMatch = pathname.match(/^\/api\/sessions\/([^/]+)$/);
|
|
353
|
+
if (method === "GET" && sessionMatch?.[1]) {
|
|
354
|
+
sendJson(res, 200, getSession(decodeURIComponent(sessionMatch[1])));
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
// GET /api/recommendations (DetectorEngine — Tier-1 recs, read path)
|
|
358
|
+
if (method === "GET" && pathname === "/api/recommendations") {
|
|
359
|
+
const wid = new URLSearchParams(url.split("?")[1] ?? "").get("workspace_id") ?? undefined;
|
|
360
|
+
sendJson(res, 200, listRecommendations(wid));
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
// GET /api/recommendations/ledger (W4 Impact Ledger — realized vs modeled)
|
|
364
|
+
if (method === "GET" && pathname === "/api/recommendations/ledger") {
|
|
365
|
+
const wid = new URLSearchParams(url.split("?")[1] ?? "").get("workspace_id") ?? undefined;
|
|
366
|
+
sendJson(res, 200, listLedger(wid));
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
const applyRecId = recApplyId(pathname);
|
|
370
|
+
if (method === "POST" && applyRecId !== null) {
|
|
371
|
+
readBody(req)
|
|
372
|
+
.then((raw) => {
|
|
373
|
+
let body;
|
|
374
|
+
try {
|
|
375
|
+
body = JSON.parse(raw);
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
sendJson(res, 400, { error: "Invalid JSON body" });
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
const { workspace_cwd } = body;
|
|
382
|
+
if (typeof workspace_cwd !== "string" || workspace_cwd.length === 0) {
|
|
383
|
+
sendJson(res, 400, { error: "workspace_cwd is required" });
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
try {
|
|
387
|
+
sendJson(res, 202, startApplyJob(applyRecId, workspace_cwd));
|
|
388
|
+
}
|
|
389
|
+
catch (e) {
|
|
390
|
+
sendJson(res, 400, { error: e instanceof Error ? e.message : "Apply failed" });
|
|
391
|
+
}
|
|
392
|
+
})
|
|
393
|
+
.catch(() => sendJson(res, 500, { error: "Internal error reading request body" }));
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
// POST /api/recommendations/:id/open-terminal (O11 Option B — write path,
|
|
397
|
+
// CSRF/token gate enforced in http.ts). Launches the user's real terminal
|
|
398
|
+
// in the rec's workspace; the daemon never runs the edit.
|
|
399
|
+
const openTerminalRecId = recOpenTerminalId(pathname);
|
|
400
|
+
if (method === "POST" && openTerminalRecId !== null) {
|
|
401
|
+
readBody(req)
|
|
402
|
+
.then((raw) => {
|
|
403
|
+
let body;
|
|
404
|
+
try {
|
|
405
|
+
body = JSON.parse(raw);
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
sendJson(res, 400, { error: "Invalid JSON body" });
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
const { prompt } = body;
|
|
412
|
+
if (typeof prompt !== "string" || prompt.length === 0) {
|
|
413
|
+
sendJson(res, 400, { error: "prompt is required" });
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
try {
|
|
417
|
+
const result = openTerminalForRec(openTerminalRecId, prompt);
|
|
418
|
+
sendJson(res, result.launched ? 200 : 409, result);
|
|
419
|
+
}
|
|
420
|
+
catch (e) {
|
|
421
|
+
sendJson(res, 500, { error: e instanceof Error ? e.message : "Open terminal failed" });
|
|
422
|
+
}
|
|
423
|
+
})
|
|
424
|
+
.catch(() => sendJson(res, 500, { error: "Internal error reading request body" }));
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
const job = jobRoute(pathname);
|
|
428
|
+
if (job !== null) {
|
|
429
|
+
if (method === "GET" && job.action === "get") {
|
|
430
|
+
try {
|
|
431
|
+
sendJson(res, 200, getApplyJob(job.jobId));
|
|
432
|
+
}
|
|
433
|
+
catch (e) {
|
|
434
|
+
sendJson(res, 404, { error: e instanceof Error ? e.message : "Job not found" });
|
|
435
|
+
}
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
if (method === "POST" && job.action === "confirm") {
|
|
439
|
+
readBody(req)
|
|
440
|
+
.then(() => {
|
|
441
|
+
try {
|
|
442
|
+
sendJson(res, 202, confirmApplyJob(job.jobId));
|
|
443
|
+
}
|
|
444
|
+
catch (e) {
|
|
445
|
+
sendJson(res, 400, { error: e instanceof Error ? e.message : "Confirm failed" });
|
|
446
|
+
}
|
|
447
|
+
})
|
|
448
|
+
.catch(() => sendJson(res, 500, { error: "Internal error reading request body" }));
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
if (method === "POST" && job.action === "rollback") {
|
|
452
|
+
readBody(req)
|
|
453
|
+
.then(() => {
|
|
454
|
+
try {
|
|
455
|
+
sendJson(res, 200, rollbackApplyJob(job.jobId));
|
|
456
|
+
}
|
|
457
|
+
catch (e) {
|
|
458
|
+
sendJson(res, 400, { error: e instanceof Error ? e.message : "Rollback failed" });
|
|
459
|
+
}
|
|
460
|
+
})
|
|
461
|
+
.catch(() => sendJson(res, 500, { error: "Internal error reading request body" }));
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
// POST /api/recommendations/dismiss (write path — CSRF gate in http.ts already enforced)
|
|
466
|
+
if (method === "POST" && pathname === "/api/recommendations/dismiss") {
|
|
467
|
+
readBody(req)
|
|
468
|
+
.then((raw) => {
|
|
469
|
+
let body;
|
|
470
|
+
try {
|
|
471
|
+
body = JSON.parse(raw);
|
|
472
|
+
}
|
|
473
|
+
catch {
|
|
474
|
+
sendJson(res, 400, { error: "Invalid JSON body" });
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
const { rec_id, dismissed_until } = body;
|
|
478
|
+
if (typeof rec_id !== "string" || rec_id.length === 0) {
|
|
479
|
+
sendJson(res, 400, { error: "rec_id is required" });
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
// Optional dismissed_until override (ISO string) for explicit snooze duration.
|
|
483
|
+
// When absent, dismissRecommendation applies the default 30-day cool-down.
|
|
484
|
+
const dismissedUntilOverride = typeof dismissed_until === "string" && dismissed_until.length > 0
|
|
485
|
+
? dismissed_until
|
|
486
|
+
: undefined;
|
|
487
|
+
try {
|
|
488
|
+
sendJson(res, 200, dismissRecommendation(rec_id, Date.now(), dismissedUntilOverride));
|
|
489
|
+
}
|
|
490
|
+
catch (e) {
|
|
491
|
+
sendJson(res, 400, { error: e instanceof Error ? e.message : "Dismiss failed" });
|
|
492
|
+
}
|
|
493
|
+
})
|
|
494
|
+
.catch(() => sendJson(res, 500, { error: "Internal error reading request body" }));
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
// POST /api/recommendations/adopt (write path — CSRF gate in http.ts already enforced)
|
|
498
|
+
if (method === "POST" && pathname === "/api/recommendations/adopt") {
|
|
499
|
+
readBody(req)
|
|
500
|
+
.then((raw) => {
|
|
501
|
+
let body;
|
|
502
|
+
try {
|
|
503
|
+
body = JSON.parse(raw);
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
sendJson(res, 400, { error: "Invalid JSON body" });
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
const { rec_id } = body;
|
|
510
|
+
if (typeof rec_id !== "string" || rec_id.length === 0) {
|
|
511
|
+
sendJson(res, 400, { error: "rec_id is required" });
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
try {
|
|
515
|
+
sendJson(res, 200, adoptRecommendation(rec_id));
|
|
516
|
+
}
|
|
517
|
+
catch (e) {
|
|
518
|
+
sendJson(res, 400, { error: e instanceof Error ? e.message : "Adopt failed" });
|
|
519
|
+
}
|
|
520
|
+
})
|
|
521
|
+
.catch(() => sendJson(res, 500, { error: "Internal error reading request body" }));
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
// GET /api/settings
|
|
525
|
+
if (method === "GET" && pathname === "/api/settings") {
|
|
526
|
+
sendJson(res, 200, getSettings());
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
// POST /api/reset (write path — CSRF gate in http.ts already enforced)
|
|
530
|
+
if (method === "POST" && pathname === "/api/reset") {
|
|
531
|
+
try {
|
|
532
|
+
sendJson(res, 200, resetDatabase());
|
|
533
|
+
}
|
|
534
|
+
catch (e) {
|
|
535
|
+
sendJson(res, 400, { error: e instanceof Error ? e.message : "Reset failed" });
|
|
536
|
+
}
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
// POST /api/settings (write path — CSRF gate in http.ts already enforced)
|
|
540
|
+
if (method === "POST" && pathname === "/api/settings") {
|
|
541
|
+
readBody(req)
|
|
542
|
+
.then((raw) => {
|
|
543
|
+
let body;
|
|
544
|
+
try {
|
|
545
|
+
body = JSON.parse(raw);
|
|
546
|
+
}
|
|
547
|
+
catch {
|
|
548
|
+
sendJson(res, 400, { error: "Invalid JSON body" });
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
try {
|
|
552
|
+
sendJson(res, 200, updateSettings(body));
|
|
553
|
+
}
|
|
554
|
+
catch (e) {
|
|
555
|
+
sendJson(res, 400, { error: e instanceof Error ? e.message : "Invalid settings" });
|
|
556
|
+
}
|
|
557
|
+
})
|
|
558
|
+
.catch(() => {
|
|
559
|
+
sendJson(res, 500, { error: "Internal error reading request body" });
|
|
560
|
+
});
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
// POST /api/calibrate (write path — CSRF gate in http.ts already enforced)
|
|
564
|
+
if (method === "POST" && pathname === "/api/calibrate") {
|
|
565
|
+
calibrateLimit()
|
|
566
|
+
.then((result) => {
|
|
567
|
+
sendJson(res, 200, result);
|
|
568
|
+
})
|
|
569
|
+
.catch(() => {
|
|
570
|
+
sendJson(res, 500, { error: "Internal error" });
|
|
571
|
+
});
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
// POST /api/calibrate-bytes-per-token (R12 — write path, CSRF gate enforced)
|
|
575
|
+
if (method === "POST" && pathname === "/api/calibrate-bytes-per-token") {
|
|
576
|
+
calibrateBytesPerToken()
|
|
577
|
+
.then((result) => {
|
|
578
|
+
sendJson(res, 200, result);
|
|
579
|
+
})
|
|
580
|
+
.catch(() => {
|
|
581
|
+
sendJson(res, 500, { error: "Internal error" });
|
|
582
|
+
});
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
// GET /api/overview/flavor (four-flavor decomposition + cache efficiency)
|
|
586
|
+
if (method === "GET" && pathname === "/api/overview/flavor") {
|
|
587
|
+
sendJson(res, 200, getFlavorDecomposition(parseWindowFilter(url)));
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
// GET /api/trends/cache-write (cache-write spike timeline)
|
|
591
|
+
if (method === "GET" && pathname === "/api/trends/cache-write") {
|
|
592
|
+
const params = new URLSearchParams(url.split("?")[1] ?? "");
|
|
593
|
+
const rawBucket = params.get("bucket") ?? "day";
|
|
594
|
+
const bucket = rawBucket === "week" || rawBucket === "month" ? rawBucket : "day";
|
|
595
|
+
const wid = params.get("workspace_id") ?? undefined;
|
|
596
|
+
sendJson(res, 200, getCacheWriteTrend(parseWindowFilter(url), bucket, wid));
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
// GET /api/trends
|
|
600
|
+
if (method === "GET" && pathname === "/api/trends") {
|
|
601
|
+
const params = new URLSearchParams(url.split("?")[1] ?? "");
|
|
602
|
+
const rawBucket = params.get("bucket") ?? "day";
|
|
603
|
+
const bucket = rawBucket === "week" || rawBucket === "month" ? rawBucket : "day";
|
|
604
|
+
const wid = params.get("workspace_id") ?? undefined;
|
|
605
|
+
sendJson(res, 200, getTrends(parseWindowFilter(url), bucket, wid));
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
// GET /api/trends/headroom
|
|
609
|
+
if (method === "GET" && pathname === "/api/trends/headroom") {
|
|
610
|
+
const params = new URLSearchParams(url.split("?")[1] ?? "");
|
|
611
|
+
const rawBucket = params.get("bucket") ?? "day";
|
|
612
|
+
const bucket = rawBucket === "week" || rawBucket === "month" ? rawBucket : "day";
|
|
613
|
+
const wid = params.get("workspace_id") ?? undefined;
|
|
614
|
+
sendJson(res, 200, getHeadroomTrend(parseWindowFilter(url), bucket, wid));
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
// GET /api/outcomes/success-rate
|
|
618
|
+
if (method === "GET" && pathname === "/api/outcomes/success-rate") {
|
|
619
|
+
sendJson(res, 200, getSuccessRate());
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
// GET /api/outcomes/workspaces
|
|
623
|
+
if (method === "GET" && pathname === "/api/outcomes/workspaces") {
|
|
624
|
+
sendJson(res, 200, listWorkspaceOutcomes());
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
// GET /api/outcomes/linkage
|
|
628
|
+
if (method === "GET" && pathname === "/api/outcomes/linkage") {
|
|
629
|
+
const wid = new URLSearchParams(url.split("?")[1] ?? "").get("workspace_id") ?? undefined;
|
|
630
|
+
sendJson(res, 200, getLinkageRate(wid));
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
// GET /api/outcomes/detail
|
|
634
|
+
if (method === "GET" && pathname === "/api/outcomes/detail") {
|
|
635
|
+
const workItemId = new URLSearchParams(url.split("?")[1] ?? "").get("work_item_id") ?? "";
|
|
636
|
+
if (workItemId.length === 0) {
|
|
637
|
+
sendJson(res, 400, { error: "work_item_id is required" });
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
sendJson(res, 200, getWorkspaceOutcomeDetail(workItemId));
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
// POST /api/outcomes/link (write path — CSRF gate in http.ts already enforced)
|
|
644
|
+
if (method === "POST" && pathname === "/api/outcomes/link") {
|
|
645
|
+
readBody(req)
|
|
646
|
+
.then((raw) => {
|
|
647
|
+
let body;
|
|
648
|
+
try {
|
|
649
|
+
body = JSON.parse(raw);
|
|
650
|
+
}
|
|
651
|
+
catch {
|
|
652
|
+
sendJson(res, 400, { error: "Invalid JSON body" });
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
const { session_id, work_item_id } = body;
|
|
656
|
+
if (typeof session_id !== "string" || typeof work_item_id !== "string") {
|
|
657
|
+
sendJson(res, 400, { error: "session_id and work_item_id are required strings" });
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
try {
|
|
661
|
+
manualLink(_db, session_id, work_item_id);
|
|
662
|
+
sendJson(res, 200, { ok: true });
|
|
663
|
+
}
|
|
664
|
+
catch (e) {
|
|
665
|
+
sendJson(res, 400, { error: e instanceof Error ? e.message : "Link failed" });
|
|
666
|
+
}
|
|
667
|
+
})
|
|
668
|
+
.catch(() => sendJson(res, 500, { error: "Internal error reading request body" }));
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
// POST /api/outcomes/unlink (write path — CSRF gate in http.ts already enforced)
|
|
672
|
+
if (method === "POST" && pathname === "/api/outcomes/unlink") {
|
|
673
|
+
readBody(req)
|
|
674
|
+
.then((raw) => {
|
|
675
|
+
let body;
|
|
676
|
+
try {
|
|
677
|
+
body = JSON.parse(raw);
|
|
678
|
+
}
|
|
679
|
+
catch {
|
|
680
|
+
sendJson(res, 400, { error: "Invalid JSON body" });
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
const { session_id, work_item_id } = body;
|
|
684
|
+
if (typeof session_id !== "string" || typeof work_item_id !== "string") {
|
|
685
|
+
sendJson(res, 400, { error: "session_id and work_item_id are required strings" });
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
try {
|
|
689
|
+
const changes = manualUnlink(_db, session_id, work_item_id);
|
|
690
|
+
if (changes === 0) {
|
|
691
|
+
sendJson(res, 404, { error: "No MANUAL link found for this session and work item" });
|
|
692
|
+
}
|
|
693
|
+
else {
|
|
694
|
+
sendJson(res, 200, { ok: true });
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
catch (e) {
|
|
698
|
+
sendJson(res, 400, { error: e instanceof Error ? e.message : "Unlink failed" });
|
|
699
|
+
}
|
|
700
|
+
})
|
|
701
|
+
.catch(() => sendJson(res, 500, { error: "Internal error reading request body" }));
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
// GET /api/burn-status — OAuth-backed rate-limit burn status (no params)
|
|
705
|
+
if (method === "GET" && pathname === "/api/burn-status") {
|
|
706
|
+
getBurnStatus()
|
|
707
|
+
.then((result) => {
|
|
708
|
+
sendJson(res, 200, result);
|
|
709
|
+
})
|
|
710
|
+
.catch(() => {
|
|
711
|
+
sendJson(res, 500, { error: "Internal error" });
|
|
712
|
+
});
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
// GET /api/oauth/status — OAuth usage-reader auth state (no token; read-only)
|
|
716
|
+
if (method === "GET" && pathname === "/api/oauth/status") {
|
|
717
|
+
sendJson(res, 200, getOAuthStatus());
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
// GET /api/outcomes/token-status — GitHub token availability (no token value)
|
|
721
|
+
if (method === "GET" && pathname === "/api/outcomes/token-status") {
|
|
722
|
+
getGithubTokenStatus()
|
|
723
|
+
.then((status) => sendJson(res, 200, status))
|
|
724
|
+
.catch(() => sendJson(res, 200, {
|
|
725
|
+
configured: false,
|
|
726
|
+
source: null,
|
|
727
|
+
reason: "outcomes sync: token status unavailable",
|
|
728
|
+
}));
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
// GET /api/ready — readiness probe (GET; exempt from CSRF/write gate)
|
|
732
|
+
if (method === "GET" && pathname === "/api/ready") {
|
|
733
|
+
sendJson(res, 200, { ready: isReady() });
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
// GET /api/status — first-run onboarding counters (GET; no CSRF gate)
|
|
737
|
+
if (method === "GET" && pathname === "/api/status") {
|
|
738
|
+
const sessions = _db.prepare("SELECT COUNT(*) AS count FROM sessions").get().count;
|
|
739
|
+
const { parser_health } = getSettingsData(_db);
|
|
740
|
+
sendJson(res, 200, {
|
|
741
|
+
sessions,
|
|
742
|
+
files_seen: parser_health.files_seen,
|
|
743
|
+
files_parsed: parser_health.files_parsed,
|
|
744
|
+
});
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
// 404 for everything else.
|
|
748
|
+
sendJson(res, 404, { error: "Not found", path: pathname });
|
|
749
|
+
}
|
|
750
|
+
catch {
|
|
751
|
+
// Do not leak internal exception text into a cross-origin-readable 500 body
|
|
752
|
+
// (GET routes are not CSRF-gated). Validation errors on POST routes still
|
|
753
|
+
// return their message via the 400 paths above.
|
|
754
|
+
sendJson(res, 500, { error: "Internal error" });
|
|
755
|
+
}
|
|
756
|
+
}
|