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,372 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/daemon/index.ts — AgentWrangler daemon entry point.
|
|
3
|
+
*
|
|
4
|
+
* Boot sequence:
|
|
5
|
+
* 1. Load config (env vars + defaults).
|
|
6
|
+
* 2. Open on-disk SQLite (WAL + FK).
|
|
7
|
+
* 3. Run migrations (idempotent).
|
|
8
|
+
* 4. In --smoke mode: assert all v2 tables exist, exit 0.
|
|
9
|
+
* 5. Start HTTP server on 127.0.0.1.
|
|
10
|
+
* 6. Auto-open browser (unless --no-open or AW_NO_OPEN=1).
|
|
11
|
+
*
|
|
12
|
+
* Flags:
|
|
13
|
+
* --smoke Boot, assert DB tables, exit 0. For CI.
|
|
14
|
+
* --no-open Suppress browser auto-open. Implied by --smoke.
|
|
15
|
+
* --db-path Override the DB file path (also: AW_DB_PATH env var).
|
|
16
|
+
* --port Override port (also: AW_PORT env var).
|
|
17
|
+
*/
|
|
18
|
+
import { randomUUID } from "node:crypto";
|
|
19
|
+
import process from "node:process";
|
|
20
|
+
import { runMigrations } from "../db/migrate.js";
|
|
21
|
+
import { openDb } from "../db/open.js";
|
|
22
|
+
import { configGet as bptConfigGet, calibrateBytesPerToken } from "../detector/calibration.js";
|
|
23
|
+
import { runContextProbe } from "../detector/context-probe.js";
|
|
24
|
+
import { runDetectors } from "../detector/index.js";
|
|
25
|
+
import { runMeasurementPass } from "../detector/measurement.js";
|
|
26
|
+
import { installHook, uninstallHook } from "../hook/install.js";
|
|
27
|
+
import { collectSessionChurn } from "../ingest/churn-collector.js";
|
|
28
|
+
import { runPostProbeHook, setPostIngestHook, setPostProbeHook } from "../ingest/detector-hook.js";
|
|
29
|
+
import { Ingestor } from "../ingest/index.js";
|
|
30
|
+
import { writeObservedOutcomes } from "../outcomes/derive.js";
|
|
31
|
+
import { extractAllFindings } from "../outcomes/findings.js";
|
|
32
|
+
import { readGithubToken } from "../outcomes/github/credential.js";
|
|
33
|
+
import { GhCliClient } from "../outcomes/github/gh-cli-client.js";
|
|
34
|
+
import { linkSessions } from "../outcomes/linker.js";
|
|
35
|
+
import { syncAllWorkspaces } from "../outcomes/sync.js";
|
|
36
|
+
import { generateWeeklyReport } from "../query/api/reports.js";
|
|
37
|
+
import { setQueryDb } from "../query/db-context.js";
|
|
38
|
+
import { setHealthInstance, setRuntimeResetHook } from "../query/settings-store.js";
|
|
39
|
+
import { loadConfig } from "./config.js";
|
|
40
|
+
import { createServer } from "./http.js";
|
|
41
|
+
import { createOutcomesPassRunner } from "./outcomes-pass.js";
|
|
42
|
+
import { setReady } from "./readiness.js";
|
|
43
|
+
const VERSION = "0.1.0";
|
|
44
|
+
/** Parse a named CLI argument: --name value or --name=value. */
|
|
45
|
+
function getArg(name) {
|
|
46
|
+
const prefix = `--${name}=`;
|
|
47
|
+
for (let i = 0; i < process.argv.length; i++) {
|
|
48
|
+
const a = process.argv[i];
|
|
49
|
+
if (a === undefined)
|
|
50
|
+
continue;
|
|
51
|
+
if (a.startsWith(prefix))
|
|
52
|
+
return a.slice(prefix.length);
|
|
53
|
+
if (a === `--${name}` && i + 1 < process.argv.length)
|
|
54
|
+
return process.argv[i + 1];
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
const isSmoke = process.argv.includes("--smoke");
|
|
59
|
+
const noOpen = isSmoke || process.argv.includes("--no-open") || process.env.AW_NO_OPEN === "1";
|
|
60
|
+
// These commands only update the user's Claude Code settings; they do not need
|
|
61
|
+
// a database or a running daemon.
|
|
62
|
+
if (process.argv.includes("--install-hook")) {
|
|
63
|
+
const result = installHook();
|
|
64
|
+
console.log(`${result.changed ? "Installed" : "Already installed"} context-budget hook: ${result.settingsPath}`);
|
|
65
|
+
process.exit(0);
|
|
66
|
+
}
|
|
67
|
+
if (process.argv.includes("--uninstall-hook")) {
|
|
68
|
+
const result = uninstallHook();
|
|
69
|
+
console.log(`${result.changed ? "Uninstalled" : "Not installed"} context-budget hook: ${result.settingsPath}`);
|
|
70
|
+
process.exit(0);
|
|
71
|
+
}
|
|
72
|
+
// CLI overrides.
|
|
73
|
+
const cliDbPath = getArg("db-path");
|
|
74
|
+
const cliPort = getArg("port");
|
|
75
|
+
const config = loadConfig({
|
|
76
|
+
...(cliDbPath !== undefined ? { dbPath: cliDbPath } : {}),
|
|
77
|
+
...(cliPort !== undefined ? { port: Number.parseInt(cliPort, 10) } : {}),
|
|
78
|
+
});
|
|
79
|
+
console.log(`AgentWrangler daemon v${VERSION}`);
|
|
80
|
+
console.log(`Node ${process.version}`);
|
|
81
|
+
console.log(`DB : ${config.dbPath}`);
|
|
82
|
+
console.log(`Port: ${config.port}`);
|
|
83
|
+
// ── 1. Open DB ────────────────────────────────────────────────────────────────
|
|
84
|
+
const db = openDb(config.dbPath);
|
|
85
|
+
console.log("DB : opened (WAL + FK)");
|
|
86
|
+
// ── 2. Run migrations ─────────────────────────────────────────────────────────
|
|
87
|
+
const applied = runMigrations(db);
|
|
88
|
+
if (applied.length > 0) {
|
|
89
|
+
console.log(`Migrations applied: ${applied.join(", ")}`);
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
console.log("Migrations: up to date");
|
|
93
|
+
}
|
|
94
|
+
// ── 3. Smoke mode ─────────────────────────────────────────────────────────────
|
|
95
|
+
if (isSmoke) {
|
|
96
|
+
// Assert all v2 tables are present.
|
|
97
|
+
const V2_TABLES = [
|
|
98
|
+
"workspaces",
|
|
99
|
+
"sessions",
|
|
100
|
+
"turns",
|
|
101
|
+
"tool_events",
|
|
102
|
+
"pricing_snapshots",
|
|
103
|
+
"context_inventory",
|
|
104
|
+
"work_items",
|
|
105
|
+
"session_work_links",
|
|
106
|
+
"observed_outcomes",
|
|
107
|
+
"review_findings",
|
|
108
|
+
"recommendations",
|
|
109
|
+
"recommendation_effects",
|
|
110
|
+
"apply_jobs",
|
|
111
|
+
"analysis_runs",
|
|
112
|
+
"ingest_quarantine",
|
|
113
|
+
"ingest_offsets",
|
|
114
|
+
"schema_migrations",
|
|
115
|
+
"user_config",
|
|
116
|
+
"reports",
|
|
117
|
+
];
|
|
118
|
+
const present = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((r) => r.name));
|
|
119
|
+
const missing = V2_TABLES.filter((t) => !present.has(t));
|
|
120
|
+
if (missing.length > 0) {
|
|
121
|
+
console.error(`smoke: FAIL — missing tables: ${missing.join(", ")}`);
|
|
122
|
+
db.close();
|
|
123
|
+
process.exit(1);
|
|
124
|
+
}
|
|
125
|
+
console.log(`smoke: ${V2_TABLES.length}/${V2_TABLES.length} v2 tables present — OK`);
|
|
126
|
+
db.close();
|
|
127
|
+
process.exit(0);
|
|
128
|
+
}
|
|
129
|
+
// ── Bind the query layer to the daemon's DB, then start ingestion ────────────
|
|
130
|
+
setQueryDb(db);
|
|
131
|
+
// Populate the current ISO week's deterministic report at startup. This is a
|
|
132
|
+
// best-effort local aggregate snapshot and never blocks daemon availability.
|
|
133
|
+
try {
|
|
134
|
+
generateWeeklyReport(db, new Date());
|
|
135
|
+
}
|
|
136
|
+
catch (e) {
|
|
137
|
+
console.error(`Weekly report bootstrap failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
138
|
+
}
|
|
139
|
+
// Wire the post-ingest detector pass BEFORE startTail so the initial back-scan
|
|
140
|
+
// already produces recommendations. runDetectors receives the ingestor's
|
|
141
|
+
// injected clock (deterministic; NFR-107 / Review F4), never new Date().
|
|
142
|
+
setPostIngestHook((detectorDb, now) => runDetectors(detectorDb, { now }));
|
|
143
|
+
// ── Context probe (best-effort, never crash daemon) ──────────────────────────
|
|
144
|
+
// Run before startTail so the first detector pass sees inventory.
|
|
145
|
+
// Re-run inside the outcomes poll cadence so inventory stays fresh.
|
|
146
|
+
function runProbePass(label) {
|
|
147
|
+
try {
|
|
148
|
+
const now = new Date();
|
|
149
|
+
const { rows } = runContextProbe(db, now);
|
|
150
|
+
console.log(`ContextProbe[${label}]: ${rows} row(s) upserted`);
|
|
151
|
+
// W4: measurement pass after each probe (best-effort; the seam never throws
|
|
152
|
+
// through, and runMeasurementPass itself is guarded/log-not-throw).
|
|
153
|
+
runPostProbeHook(db, now);
|
|
154
|
+
}
|
|
155
|
+
catch (e) {
|
|
156
|
+
console.log(`ContextProbe[${label}]: skipped — ${e instanceof Error ? e.message : String(e)}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// Wire the W4 Impact Ledger measurement pass onto the post-probe seam. The pass
|
|
160
|
+
// receives the probe's clock (deterministic; NFR-107), is throttled internally.
|
|
161
|
+
setPostProbeHook((probeDb, now) => {
|
|
162
|
+
runMeasurementPass(probeDb, now);
|
|
163
|
+
});
|
|
164
|
+
runProbePass("boot");
|
|
165
|
+
// ── Outcomes bootstrap (WP5 — best-effort, never crash daemon) ────────────────
|
|
166
|
+
// Pattern mirrors ingestion boot: try/catch in pass body, degraded mode on failure.
|
|
167
|
+
// The runner (src/daemon/outcomes-pass.ts) adds a hard 15-min per-pass deadline:
|
|
168
|
+
// a hung pass can no longer wedge the poll cadence forever — the running flag
|
|
169
|
+
// always clears and the next poll resumes.
|
|
170
|
+
let outcomesTimer = null;
|
|
171
|
+
let reportsTimer = null;
|
|
172
|
+
let reportsRunning = false;
|
|
173
|
+
let bptCalibrationTimer = null;
|
|
174
|
+
const outcomesRunner = createOutcomesPassRunner({
|
|
175
|
+
db,
|
|
176
|
+
probe: () => runProbePass("poll"),
|
|
177
|
+
readToken: readGithubToken,
|
|
178
|
+
createClient: (tokenResult) => new GhCliClient(tokenResult),
|
|
179
|
+
sync: syncAllWorkspaces,
|
|
180
|
+
link: linkSessions,
|
|
181
|
+
derive: writeObservedOutcomes,
|
|
182
|
+
findings: extractAllFindings,
|
|
183
|
+
});
|
|
184
|
+
function runOutcomesPass() {
|
|
185
|
+
return outcomesRunner.run();
|
|
186
|
+
}
|
|
187
|
+
// ── Deferred boot scan (runs AFTER the HTTP port is bound) ───────────────────
|
|
188
|
+
// startTailBatched() performs a yielding initial back-scan (ingestAllKnown +
|
|
189
|
+
// reconcile), then arms the tail/discovery timers. We do NOT
|
|
190
|
+
// also call runBackscan — that would spin up a second Ingestor whose separate
|
|
191
|
+
// Health instance is the one surfaced in Settings. One Ingestor, one Health.
|
|
192
|
+
// Bounded: if ingestion fails to start, still serve the dashboard (degraded).
|
|
193
|
+
let handle = null;
|
|
194
|
+
// One-shot guard: runBootScan executes exactly once regardless of how many
|
|
195
|
+
// requests or timers fire kickBootScan concurrently.
|
|
196
|
+
let scanKicked = false;
|
|
197
|
+
function kickBootScan() {
|
|
198
|
+
if (scanKicked)
|
|
199
|
+
return;
|
|
200
|
+
scanKicked = true;
|
|
201
|
+
// Use setImmediate so the triggering HTTP response flushes before boot work
|
|
202
|
+
// starts. The scan itself also yields between bounded batches.
|
|
203
|
+
setImmediate(() => {
|
|
204
|
+
runBootScan().catch((e) => {
|
|
205
|
+
console.error(`Boot scan failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
async function runBootScan() {
|
|
210
|
+
try {
|
|
211
|
+
const ingestor = new Ingestor(db, config.scanRoots, {
|
|
212
|
+
onNewMappings: (count) => {
|
|
213
|
+
console.log(`Discovery mapped ${count} new repo(s) — scheduling outcomes pass`);
|
|
214
|
+
runOutcomesPass().catch((e) => {
|
|
215
|
+
console.error(`Outcomes pass (new mappings) failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
216
|
+
});
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
setHealthInstance(ingestor.health);
|
|
220
|
+
handle = await ingestor.startTailBatched();
|
|
221
|
+
setRuntimeResetHook(() => ingestor.clearRuntimeState());
|
|
222
|
+
console.log(`Ingestion: initial scan complete — health ${JSON.stringify(ingestor.healthSnapshot())}`);
|
|
223
|
+
}
|
|
224
|
+
catch (e) {
|
|
225
|
+
console.error(`Ingestion failed to start — serving dashboard in degraded mode: ${e instanceof Error ? e.message : String(e)}`);
|
|
226
|
+
}
|
|
227
|
+
// After the initial back-scan, workspaces are discovered — re-probe so per-workspace
|
|
228
|
+
// sources (project memory, project CLAUDE.md) are captured even on a cold DB, where the
|
|
229
|
+
// boot probe ran before any workspace existed and could only size the global sources.
|
|
230
|
+
// Then re-evaluate detectors so D1 surfaces its per-source context recs on the first run
|
|
231
|
+
// instead of waiting for the next 10-minute poll.
|
|
232
|
+
runProbePass("post-scan");
|
|
233
|
+
try {
|
|
234
|
+
runDetectors(db, { now: new Date() });
|
|
235
|
+
}
|
|
236
|
+
catch (e) {
|
|
237
|
+
console.error(`Post-scan detector pass failed (non-fatal): ${e instanceof Error ? e.message : String(e)}`);
|
|
238
|
+
}
|
|
239
|
+
// Signal readiness BEFORE the outcomes pass — outcomes is a slow ~97s I/O
|
|
240
|
+
// pass that yields on `gh` subprocess I/O; the loading page must not wait on it.
|
|
241
|
+
setReady();
|
|
242
|
+
// Outcomes bootstrap — deferred until after the back-scan so the event loop
|
|
243
|
+
// is free and readGithubToken() execFile (10 s timeout) cannot be starved.
|
|
244
|
+
runOutcomesPass().catch((e) => {
|
|
245
|
+
console.error(`Outcomes bootstrap failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
246
|
+
});
|
|
247
|
+
// Self-churn collector (RV9b — L2b) — best-effort, never crash the daemon.
|
|
248
|
+
// Synchronous git reader; deferred via setImmediate so it never blocks
|
|
249
|
+
// readiness or the outcomes pass.
|
|
250
|
+
setImmediate(() => {
|
|
251
|
+
try {
|
|
252
|
+
collectSessionChurn(db);
|
|
253
|
+
}
|
|
254
|
+
catch (e) {
|
|
255
|
+
console.error(`Self-churn collection failed (non-fatal): ${e instanceof Error ? e.message : String(e)}`);
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
// R12 bytes→token calibration — best-effort, never crash the daemon.
|
|
259
|
+
// Runs on boot (deferred) and weekly when: opt-in enabled AND ratio is absent
|
|
260
|
+
// or older than 30 days. Mirrors the outcomes bootstrap pattern.
|
|
261
|
+
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
|
262
|
+
function runBptCalibrationIfStale() {
|
|
263
|
+
const enabled = bptConfigGet(db, "bytes_per_token_calibration_enabled");
|
|
264
|
+
if (enabled !== "true")
|
|
265
|
+
return;
|
|
266
|
+
const measuredAt = bptConfigGet(db, "bytes_per_token_measured_at");
|
|
267
|
+
if (measuredAt !== null && Date.now() - new Date(measuredAt).getTime() < THIRTY_DAYS_MS)
|
|
268
|
+
return;
|
|
269
|
+
calibrateBytesPerToken(db).catch((e) => {
|
|
270
|
+
console.error(`Bytes-per-token calibration failed (non-fatal): ${e instanceof Error ? e.message : String(e)}`);
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
setImmediate(() => {
|
|
274
|
+
try {
|
|
275
|
+
runBptCalibrationIfStale();
|
|
276
|
+
}
|
|
277
|
+
catch (e) {
|
|
278
|
+
console.error(`Bytes-per-token calibration boot failed (non-fatal): ${e instanceof Error ? e.message : String(e)}`);
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
bptCalibrationTimer = setInterval(() => {
|
|
282
|
+
try {
|
|
283
|
+
runBptCalibrationIfStale();
|
|
284
|
+
}
|
|
285
|
+
catch (e) {
|
|
286
|
+
console.error(`Bytes-per-token calibration poll failed (non-fatal): ${e instanceof Error ? e.message : String(e)}`);
|
|
287
|
+
}
|
|
288
|
+
}, 7 * 24 * 60 * 60 * 1000);
|
|
289
|
+
// 10-minute poll — guarded by outcomesRunning to prevent overlap
|
|
290
|
+
outcomesTimer = setInterval(() => {
|
|
291
|
+
runOutcomesPass().catch((e) => {
|
|
292
|
+
console.error(`Outcomes poll failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
293
|
+
});
|
|
294
|
+
}, 10 * 60 * 1000);
|
|
295
|
+
reportsTimer = setInterval(async () => {
|
|
296
|
+
if (reportsRunning)
|
|
297
|
+
return;
|
|
298
|
+
reportsRunning = true;
|
|
299
|
+
try {
|
|
300
|
+
await Promise.resolve(generateWeeklyReport(db, new Date()));
|
|
301
|
+
}
|
|
302
|
+
catch (e) {
|
|
303
|
+
console.error(`Weekly report poll failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
304
|
+
}
|
|
305
|
+
finally {
|
|
306
|
+
reportsRunning = false;
|
|
307
|
+
}
|
|
308
|
+
}, 7 * 24 * 60 * 60 * 1000);
|
|
309
|
+
}
|
|
310
|
+
// ── 4. Start HTTP server ──────────────────────────────────────────────────────
|
|
311
|
+
// Bind the port BEFORE the back-scan so the loading page is delivered
|
|
312
|
+
// immediately while the initial scan yields between file batches.
|
|
313
|
+
// Generate a random in-memory session token for the CSRF token gate.
|
|
314
|
+
// NEVER log it, NEVER persist it — it lives only in process memory.
|
|
315
|
+
const sessionToken = randomUUID();
|
|
316
|
+
const server = createServer(db, config.port, config.uiRoot, sessionToken, kickBootScan);
|
|
317
|
+
server.listen(config.port, "127.0.0.1", () => {
|
|
318
|
+
const url = `http://127.0.0.1:${config.port}`;
|
|
319
|
+
console.log(`Listening on ${url}`);
|
|
320
|
+
// ── 5. Auto-open browser ──────────────────────────────────────────────────
|
|
321
|
+
// Opens immediately onto the loading page; the page's poll loop will reload
|
|
322
|
+
// once /api/ready returns { ready: true }.
|
|
323
|
+
if (!noOpen) {
|
|
324
|
+
openBrowser(url);
|
|
325
|
+
}
|
|
326
|
+
// Fallback: kick the back-scan after 3 s even if no browser request arrives
|
|
327
|
+
// (headless mode, --no-open, CI). The one-shot guard in kickBootScan() makes
|
|
328
|
+
// whichever trigger fires first the only one that runs the scan.
|
|
329
|
+
setTimeout(kickBootScan, 3000);
|
|
330
|
+
});
|
|
331
|
+
/** Open `url` in the system default browser (best-effort; never crashes the daemon). */
|
|
332
|
+
function openBrowser(url) {
|
|
333
|
+
import("node:child_process")
|
|
334
|
+
.then(({ exec }) => {
|
|
335
|
+
const cmd = process.platform === "win32"
|
|
336
|
+
? `start "" "${url}"`
|
|
337
|
+
: process.platform === "darwin"
|
|
338
|
+
? `open "${url}"`
|
|
339
|
+
: `xdg-open "${url}"`;
|
|
340
|
+
exec(cmd, (err) => {
|
|
341
|
+
if (err)
|
|
342
|
+
console.warn(`auto-open: ${err.message}`);
|
|
343
|
+
});
|
|
344
|
+
})
|
|
345
|
+
.catch(() => {
|
|
346
|
+
// child_process unavailable — skip silently.
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
// ── Graceful shutdown ─────────────────────────────────────────────────────────
|
|
350
|
+
process.on("SIGINT", () => {
|
|
351
|
+
console.log("Shutting down…");
|
|
352
|
+
handle?.stop();
|
|
353
|
+
if (outcomesTimer !== null)
|
|
354
|
+
clearInterval(outcomesTimer);
|
|
355
|
+
if (reportsTimer !== null)
|
|
356
|
+
clearInterval(reportsTimer);
|
|
357
|
+
server.close(() => {
|
|
358
|
+
db.close();
|
|
359
|
+
process.exit(0);
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
process.on("SIGTERM", () => {
|
|
363
|
+
handle?.stop();
|
|
364
|
+
if (outcomesTimer !== null)
|
|
365
|
+
clearInterval(outcomesTimer);
|
|
366
|
+
if (reportsTimer !== null)
|
|
367
|
+
clearInterval(reportsTimer);
|
|
368
|
+
server.close(() => {
|
|
369
|
+
db.close();
|
|
370
|
+
process.exit(0);
|
|
371
|
+
});
|
|
372
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/daemon/outcomes-pass.ts — the outcomes poll pass, with a hard per-pass deadline.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from daemon/index.ts (M-batch task 4) so the skip-guard + deadline
|
|
5
|
+
* logic is deterministically unit-testable without booting the daemon.
|
|
6
|
+
*
|
|
7
|
+
* Why: a bare `outcomesRunning` boolean prevents poll overlap but a HUNG pass
|
|
8
|
+
* (e.g. a wedged `gh` subprocess pipe) would block ALL future polls forever.
|
|
9
|
+
* The runner races the pass body against a hard total deadline (default
|
|
10
|
+
* 15 minutes): on breach it logs clearly and — crucially — always resets the
|
|
11
|
+
* running flag in a `finally` so subsequent polls resume. The abandoned body's
|
|
12
|
+
* remaining I/O may still settle in the background; that is safe because every
|
|
13
|
+
* DB write in the pass is transactional/upsert-based (idempotent), so a
|
|
14
|
+
* late-finishing body can only re-apply the same rows.
|
|
15
|
+
*
|
|
16
|
+
* JS cannot preempt a hung await; "abort" here means: stop WAITING for the
|
|
17
|
+
* pass and un-block the poll cadence. This is the honest best-effort bound.
|
|
18
|
+
*/
|
|
19
|
+
/** Hard total wall-clock budget for ONE outcomes pass. */
|
|
20
|
+
export const OUTCOMES_PASS_DEADLINE_MS = 15 * 60 * 1000;
|
|
21
|
+
const IDLE_RESULT = { skipped: false, disabled: false, aborted: false };
|
|
22
|
+
class DeadlineError extends Error {
|
|
23
|
+
}
|
|
24
|
+
export function createOutcomesPassRunner(deps) {
|
|
25
|
+
let running = false;
|
|
26
|
+
const log = deps.log ?? ((msg) => console.log(msg));
|
|
27
|
+
const error = deps.error ?? ((msg) => console.error(msg));
|
|
28
|
+
const deadlineMs = deps.deadlineMs ?? OUTCOMES_PASS_DEADLINE_MS;
|
|
29
|
+
return {
|
|
30
|
+
get isRunning() {
|
|
31
|
+
return running;
|
|
32
|
+
},
|
|
33
|
+
async run() {
|
|
34
|
+
if (running) {
|
|
35
|
+
log("Outcomes: skipping poll — previous pass still running");
|
|
36
|
+
return { ...IDLE_RESULT, skipped: true };
|
|
37
|
+
}
|
|
38
|
+
running = true;
|
|
39
|
+
let timer = null;
|
|
40
|
+
try {
|
|
41
|
+
const body = (async () => {
|
|
42
|
+
// Re-probe context inventory on the same cadence as outcomes.
|
|
43
|
+
deps.probe();
|
|
44
|
+
const tokenResult = await deps.readToken();
|
|
45
|
+
const client = deps.createClient(tokenResult);
|
|
46
|
+
if (!client.enabled) {
|
|
47
|
+
const reason = tokenResult.ok
|
|
48
|
+
? "ok"
|
|
49
|
+
: tokenResult.reason;
|
|
50
|
+
log(`Outcomes: GitHub token not configured (${reason}) — outcomes pass skipped`);
|
|
51
|
+
return { ...IDLE_RESULT, disabled: true };
|
|
52
|
+
}
|
|
53
|
+
await deps.sync(deps.db, client);
|
|
54
|
+
await deps.link(deps.db, client);
|
|
55
|
+
deps.derive(deps.db);
|
|
56
|
+
await deps.findings(deps.db, client);
|
|
57
|
+
log("Outcomes: sync+link+derive+findings pass complete");
|
|
58
|
+
return IDLE_RESULT;
|
|
59
|
+
})();
|
|
60
|
+
const deadline = new Promise((_, reject) => {
|
|
61
|
+
timer = setTimeout(() => reject(new DeadlineError("outcomes pass deadline")), deadlineMs);
|
|
62
|
+
});
|
|
63
|
+
return await Promise.race([body, deadline]);
|
|
64
|
+
}
|
|
65
|
+
catch (e) {
|
|
66
|
+
if (e instanceof DeadlineError) {
|
|
67
|
+
error(`Outcomes pass exceeded its ${Math.round(deadlineMs / 60_000)}-min hard deadline — abandoning the pass; the running flag is cleared so the next poll resumes. In-flight reads may still settle in the background; DB writes are transactional/upsert-based and stay consistent.`);
|
|
68
|
+
return { ...IDLE_RESULT, aborted: true };
|
|
69
|
+
}
|
|
70
|
+
error(`Outcomes pass failed (non-fatal): ${e instanceof Error ? e.message : String(e)}`);
|
|
71
|
+
return IDLE_RESULT;
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
if (timer !== null)
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
// ALWAYS reset — this is the whole point of the deadline: a hung pass
|
|
77
|
+
// must never wedge the poll cadence forever.
|
|
78
|
+
running = false;
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/daemon/readiness.ts — Shared readiness flag for the daemon boot sequence.
|
|
3
|
+
*
|
|
4
|
+
* Set to true by index.ts after the initial back-scan completes.
|
|
5
|
+
* Read by http.ts to decide whether to serve the loading page or the real SPA.
|
|
6
|
+
*/
|
|
7
|
+
const state = { ready: false };
|
|
8
|
+
/** Returns true after the initial back-scan + detector pass have finished. */
|
|
9
|
+
export function isReady() {
|
|
10
|
+
return state.ready;
|
|
11
|
+
}
|
|
12
|
+
/** Called by index.ts once the back-scan is done. Idempotent. */
|
|
13
|
+
export function setReady() {
|
|
14
|
+
state.ready = true;
|
|
15
|
+
}
|