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,509 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/ingest/ingestor.ts — the WP1 ingestion orchestrator.
|
|
3
|
+
*
|
|
4
|
+
* Wires discovery → tail → parse → price → persist → reconcile. Holds the small
|
|
5
|
+
* in-process correlation state (tool_use → owning turn) that lets tool_result
|
|
6
|
+
* sizes and commit SHAs attach to the right rows. SEC-101: only tokens, ids,
|
|
7
|
+
* sizes, model names, and structural markers are ever written.
|
|
8
|
+
*
|
|
9
|
+
* Idempotency (NFR-107): turns upsert-ignore on message_id; session aggregates
|
|
10
|
+
* advance only when a turn is genuinely new (changes === 1). A dropped DB
|
|
11
|
+
* re-scanned from scratch reproduces identical RECONCILED aggregates.
|
|
12
|
+
*/
|
|
13
|
+
import * as crypto from "node:crypto";
|
|
14
|
+
import * as fs from "node:fs";
|
|
15
|
+
import { runPostIngestHook } from "./detector-hook.js";
|
|
16
|
+
import { createDiscoveryCache, refreshDiscoveryCache, registerWorkspace, sessionStemFor, } from "./discovery.js";
|
|
17
|
+
import { Health } from "./health.js";
|
|
18
|
+
import { PARSER_VERSION, projectLine } from "./parser.js";
|
|
19
|
+
import { PricingSnapshotStore, seedListPrices } from "./pricing.js";
|
|
20
|
+
import { DEFAULT_RECONCILE_OPTIONS, reconcileSessions, } from "./reconcile.js";
|
|
21
|
+
import { loadOffset, saveOffset, tailFile } from "./tail.js";
|
|
22
|
+
import { LONG_GAP_THRESHOLD_S } from "./types.js";
|
|
23
|
+
import { backfillDiscoveredCwd, defaultReadCwd, defaultReadRemote, resolveWorkspaceMappings, } from "./workspace-mapping.js";
|
|
24
|
+
export const DEFAULT_INGESTOR_OPTIONS = {
|
|
25
|
+
activityWindowSecs: 5 * 60,
|
|
26
|
+
reconcile: DEFAULT_RECONCILE_OPTIONS,
|
|
27
|
+
tailIntervalMs: 2_000,
|
|
28
|
+
discoveryIntervalMs: 30_000,
|
|
29
|
+
now: () => new Date(),
|
|
30
|
+
};
|
|
31
|
+
const INITIAL_SCAN_BATCH_SIZE = 100;
|
|
32
|
+
export class Ingestor {
|
|
33
|
+
db;
|
|
34
|
+
roots;
|
|
35
|
+
opts;
|
|
36
|
+
health = new Health();
|
|
37
|
+
pricing;
|
|
38
|
+
// Correlation state (process lifetime; rebuilt fresh on a cold re-scan).
|
|
39
|
+
toolUseOwner = new Map(); // toolUseId → messageId
|
|
40
|
+
gitUseIds = new Set(); // toolUseIds that looked like git commit/push
|
|
41
|
+
countedResults = new Set(); // toolUseIds already summed
|
|
42
|
+
resultBytesByMsg = new Map(); // messageId → running byte sum
|
|
43
|
+
lineCursor = new Map(); // filePath → complete lines consumed
|
|
44
|
+
lastSize = new Map(); // filePath → last-seen size (skip unchanged files)
|
|
45
|
+
discoveryCache = createDiscoveryCache();
|
|
46
|
+
unresolvedRemotes = new Set();
|
|
47
|
+
userTurnTsBySession = new Map(); // sessionId → epoch-ms of user turns
|
|
48
|
+
// Prepared statements.
|
|
49
|
+
stInsertTurn;
|
|
50
|
+
stInsertSession;
|
|
51
|
+
stBumpSession;
|
|
52
|
+
stBumpUserTurnCount;
|
|
53
|
+
stBumpFrictionCounts;
|
|
54
|
+
stSetGapAggregates;
|
|
55
|
+
stGetGapN;
|
|
56
|
+
stInsertToolEvent;
|
|
57
|
+
stUpsertToolEventMetadata;
|
|
58
|
+
stSetToolResult;
|
|
59
|
+
stRefreshOwnerResultBytes;
|
|
60
|
+
stSetResultBytes;
|
|
61
|
+
stSetCommitSha;
|
|
62
|
+
stInsertQuarantine;
|
|
63
|
+
stSetDiscoveredCwd;
|
|
64
|
+
constructor(db, roots, options = {}) {
|
|
65
|
+
this.db = db;
|
|
66
|
+
this.roots = roots;
|
|
67
|
+
this.opts = { ...DEFAULT_INGESTOR_OPTIONS, ...options };
|
|
68
|
+
// Seed the canonical list prices, then load the pricing store.
|
|
69
|
+
seedListPrices(db, this.opts.now().toISOString());
|
|
70
|
+
this.pricing = new PricingSnapshotStore(db, this.opts.now().toISOString());
|
|
71
|
+
this.stInsertTurn = db.prepare(`INSERT OR IGNORE INTO turns
|
|
72
|
+
(message_id, session_id, workspace_id, ts, model, is_sidechain,
|
|
73
|
+
input_tokens, output_tokens, thinking_tokens, cache_read_tokens,
|
|
74
|
+
cache_write_5m, cache_write_1h, cache_write_other,
|
|
75
|
+
tool_result_bytes, pricing_snapshot_id, cost_equiv_u, cost_claim,
|
|
76
|
+
provisional, effort, parser_version)
|
|
77
|
+
VALUES (?,?,?,?,?,?, ?,?,?,?, ?,?,?, ?,?,?,?,?,?,?)`);
|
|
78
|
+
this.stInsertSession = db.prepare(`INSERT OR IGNORE INTO sessions
|
|
79
|
+
(session_id, workspace_id, file_path, first_turn_at, last_turn_at,
|
|
80
|
+
state, turn_count, cost_equiv_u, hygiene_flags)
|
|
81
|
+
VALUES (?,?,?,?,?,'LIVE',0,0,'[]')`);
|
|
82
|
+
this.stBumpSession = db.prepare(`UPDATE sessions
|
|
83
|
+
SET turn_count = turn_count + 1,
|
|
84
|
+
cost_equiv_u = cost_equiv_u + ?,
|
|
85
|
+
first_turn_at = MIN(COALESCE(first_turn_at, ?), ?),
|
|
86
|
+
last_turn_at = MAX(COALESCE(last_turn_at, ?), ?),
|
|
87
|
+
state = CASE WHEN state = 'RECONCILED' THEN 'LIVE' ELSE state END
|
|
88
|
+
WHERE session_id = ?`);
|
|
89
|
+
this.stBumpUserTurnCount = db.prepare("UPDATE sessions SET user_turn_count = user_turn_count + 1 WHERE session_id = ?");
|
|
90
|
+
this.stBumpFrictionCounts = db.prepare(`UPDATE sessions SET
|
|
91
|
+
compaction_count = compaction_count + ?,
|
|
92
|
+
api_error_count = api_error_count + ?,
|
|
93
|
+
interrupt_count = interrupt_count + ?
|
|
94
|
+
WHERE session_id = ?`);
|
|
95
|
+
this.stSetGapAggregates = db.prepare("UPDATE sessions SET gap_median_s=?, gap_p90_s=?, long_gap_count=?, gap_n=? WHERE session_id=?");
|
|
96
|
+
this.stGetGapN = db.prepare("SELECT gap_n FROM sessions WHERE session_id=?");
|
|
97
|
+
this.stInsertToolEvent = db.prepare(`INSERT INTO tool_events
|
|
98
|
+
(event_id, session_id, ts, tool_name, input_bytes, result_bytes, input_hash, exit_class, commit_sha)
|
|
99
|
+
VALUES (?,?,?,?,?,?,?,?,?)
|
|
100
|
+
ON CONFLICT(event_id) DO UPDATE SET
|
|
101
|
+
input_bytes = COALESCE(tool_events.input_bytes, excluded.input_bytes),
|
|
102
|
+
input_hash = COALESCE(tool_events.input_hash, excluded.input_hash)`);
|
|
103
|
+
this.stUpsertToolEventMetadata = db.prepare(`INSERT INTO tool_event_metadata
|
|
104
|
+
(event_id, file_path_hash, owner_message_id, block_index, is_test_command)
|
|
105
|
+
VALUES (?,?,?,?,?)
|
|
106
|
+
ON CONFLICT(event_id) DO UPDATE SET
|
|
107
|
+
file_path_hash = COALESCE(tool_event_metadata.file_path_hash, excluded.file_path_hash),
|
|
108
|
+
owner_message_id = COALESCE(tool_event_metadata.owner_message_id, excluded.owner_message_id),
|
|
109
|
+
block_index = excluded.block_index,
|
|
110
|
+
is_test_command = excluded.is_test_command`);
|
|
111
|
+
this.stSetToolResult = db.prepare(`UPDATE tool_events
|
|
112
|
+
SET result_bytes = ?,
|
|
113
|
+
exit_class = CASE
|
|
114
|
+
WHEN ? = 0 THEN 'OK'
|
|
115
|
+
WHEN EXISTS (
|
|
116
|
+
SELECT 1 FROM tool_event_metadata AS metadata
|
|
117
|
+
WHERE metadata.event_id = tool_events.event_id
|
|
118
|
+
AND metadata.is_test_command = 1
|
|
119
|
+
) THEN 'TEST_FAIL'
|
|
120
|
+
ELSE 'ERROR'
|
|
121
|
+
END
|
|
122
|
+
WHERE event_id = ?`);
|
|
123
|
+
this.stRefreshOwnerResultBytes = db.prepare(`UPDATE turns
|
|
124
|
+
SET tool_result_bytes = (
|
|
125
|
+
SELECT SUM(events.result_bytes)
|
|
126
|
+
FROM tool_event_metadata AS owned
|
|
127
|
+
JOIN tool_events AS events ON events.event_id = owned.event_id
|
|
128
|
+
WHERE owned.owner_message_id = turns.message_id
|
|
129
|
+
AND events.result_bytes IS NOT NULL
|
|
130
|
+
)
|
|
131
|
+
WHERE message_id = (
|
|
132
|
+
SELECT owner_message_id FROM tool_event_metadata WHERE event_id = ?
|
|
133
|
+
)`);
|
|
134
|
+
this.stSetResultBytes = db.prepare("UPDATE turns SET tool_result_bytes = ? WHERE message_id = ?");
|
|
135
|
+
this.stSetCommitSha = db.prepare("UPDATE tool_events SET commit_sha = ? WHERE event_id = ?");
|
|
136
|
+
this.stInsertQuarantine = db.prepare(`INSERT OR IGNORE INTO ingest_quarantine
|
|
137
|
+
(q_id, file_path, line_no, error_class, parser_version, seen_at)
|
|
138
|
+
VALUES (?,?,?,?,?,?)`);
|
|
139
|
+
this.stSetDiscoveredCwd = db.prepare("UPDATE workspaces SET discovered_cwd=? WHERE workspace_id=? AND discovered_cwd IS NULL");
|
|
140
|
+
}
|
|
141
|
+
/** Full back-scan: ingest every discovered file once, then reconcile. */
|
|
142
|
+
runBackscan() {
|
|
143
|
+
this.ingestAllKnown();
|
|
144
|
+
this.reconcileNow();
|
|
145
|
+
this.runPostIngest();
|
|
146
|
+
return this.health.snapshot();
|
|
147
|
+
}
|
|
148
|
+
/** Async full back-scan that yields between bounded batches, then reconciles. */
|
|
149
|
+
async runBackscanBatched(batchSize = INITIAL_SCAN_BATCH_SIZE) {
|
|
150
|
+
await this.ingestAllKnownBatched(batchSize);
|
|
151
|
+
this.reconcileNow();
|
|
152
|
+
this.runPostIngest();
|
|
153
|
+
return this.health.snapshot();
|
|
154
|
+
}
|
|
155
|
+
/** Start incremental tailing: initial pass, then discovery + tail intervals. */
|
|
156
|
+
startTail() {
|
|
157
|
+
this.ingestAllKnown();
|
|
158
|
+
this.reconcileNow();
|
|
159
|
+
this.runPostIngest();
|
|
160
|
+
return this.startTailTimers();
|
|
161
|
+
}
|
|
162
|
+
/** Start tailing after an async, yielding initial scan. */
|
|
163
|
+
async startTailBatched(batchSize = INITIAL_SCAN_BATCH_SIZE) {
|
|
164
|
+
await this.ingestAllKnownBatched(batchSize);
|
|
165
|
+
this.reconcileNow();
|
|
166
|
+
this.runPostIngest();
|
|
167
|
+
return this.startTailTimers();
|
|
168
|
+
}
|
|
169
|
+
startTailTimers() {
|
|
170
|
+
let busy = false;
|
|
171
|
+
// Fast tail cadence: advance byte offsets on every known file, then reconcile.
|
|
172
|
+
const tailTimer = setInterval(() => {
|
|
173
|
+
if (busy)
|
|
174
|
+
return;
|
|
175
|
+
busy = true;
|
|
176
|
+
try {
|
|
177
|
+
for (const f of refreshDiscoveryCache(this.roots, this.discoveryCache)) {
|
|
178
|
+
this.ingestFile(f.filePath, f.projectSlug);
|
|
179
|
+
}
|
|
180
|
+
this.reconcileNow();
|
|
181
|
+
}
|
|
182
|
+
catch (e) {
|
|
183
|
+
console.warn(`tail tick failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
184
|
+
}
|
|
185
|
+
finally {
|
|
186
|
+
busy = false;
|
|
187
|
+
}
|
|
188
|
+
}, this.opts.tailIntervalMs);
|
|
189
|
+
// Slower discovery cadence: register any newly-appeared workspace slugs, then
|
|
190
|
+
// run a full detector pass (30s freshness matches the recommendations surface;
|
|
191
|
+
// a pass on every 2s tail tick would be wasteful).
|
|
192
|
+
const discoveryTimer = setInterval(() => {
|
|
193
|
+
try {
|
|
194
|
+
const discovered = refreshDiscoveryCache(this.roots, this.discoveryCache, true);
|
|
195
|
+
for (const f of discovered)
|
|
196
|
+
registerWorkspace(this.db, f.projectSlug);
|
|
197
|
+
backfillDiscoveredCwd(this.db, discovered, defaultReadCwd);
|
|
198
|
+
const newlyMapped = resolveWorkspaceMappings(this.db, {
|
|
199
|
+
readRemote: this.opts.readRemote ?? defaultReadRemote,
|
|
200
|
+
unresolved: this.unresolvedRemotes,
|
|
201
|
+
});
|
|
202
|
+
this.runPostIngest();
|
|
203
|
+
if (newlyMapped > 0 && this.opts.onNewMappings !== undefined) {
|
|
204
|
+
try {
|
|
205
|
+
this.opts.onNewMappings(newlyMapped);
|
|
206
|
+
}
|
|
207
|
+
catch (e) {
|
|
208
|
+
console.warn(`new mappings callback failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
catch (e) {
|
|
213
|
+
console.warn(`discovery tick failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
214
|
+
}
|
|
215
|
+
}, this.opts.discoveryIntervalMs);
|
|
216
|
+
return {
|
|
217
|
+
stop: () => {
|
|
218
|
+
clearInterval(tailTimer);
|
|
219
|
+
clearInterval(discoveryTimer);
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
/** Current parser-health counters (surfaced in Settings by WP4). */
|
|
224
|
+
healthSnapshot() {
|
|
225
|
+
return this.health.snapshot();
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Clear in-memory correlation/offset caches. Called after a DB reset so the
|
|
229
|
+
* running tailer re-ingests the still-present transcript files faithfully
|
|
230
|
+
* (identical to a cold rescan) instead of re-inserting rows against stale
|
|
231
|
+
* correlation state (which would leave tool_events bytes/SHAs NULL).
|
|
232
|
+
*/
|
|
233
|
+
clearRuntimeState() {
|
|
234
|
+
this.toolUseOwner.clear();
|
|
235
|
+
this.gitUseIds.clear();
|
|
236
|
+
this.countedResults.clear();
|
|
237
|
+
this.resultBytesByMsg.clear();
|
|
238
|
+
this.lineCursor.clear();
|
|
239
|
+
this.lastSize.clear();
|
|
240
|
+
this.unresolvedRemotes.clear();
|
|
241
|
+
this.userTurnTsBySession.clear();
|
|
242
|
+
}
|
|
243
|
+
// ── internals ──────────────────────────────────────────────────────────────
|
|
244
|
+
ingestAllKnown() {
|
|
245
|
+
for (const f of refreshDiscoveryCache(this.roots, this.discoveryCache, true)) {
|
|
246
|
+
this.ingestFile(f.filePath, f.projectSlug);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
async ingestAllKnownBatched(batchSize) {
|
|
250
|
+
const files = refreshDiscoveryCache(this.roots, this.discoveryCache, true);
|
|
251
|
+
const normalizedBatchSize = Number.isFinite(batchSize) && batchSize >= 1
|
|
252
|
+
? Math.max(1, Math.floor(batchSize))
|
|
253
|
+
: INITIAL_SCAN_BATCH_SIZE;
|
|
254
|
+
for (let start = 0; start < files.length; start += normalizedBatchSize) {
|
|
255
|
+
const end = Math.min(start + normalizedBatchSize, files.length);
|
|
256
|
+
for (let i = start; i < end; i++) {
|
|
257
|
+
const f = files[i];
|
|
258
|
+
if (f === undefined)
|
|
259
|
+
continue;
|
|
260
|
+
this.ingestFile(f.filePath, f.projectSlug);
|
|
261
|
+
}
|
|
262
|
+
if (end < files.length) {
|
|
263
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
reconcileNow() {
|
|
268
|
+
const cutoff = new Date(this.opts.now().getTime() - this.opts.activityWindowSecs * 1000);
|
|
269
|
+
reconcileSessions(this.db, cutoff.toISOString(), this.opts.reconcile);
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Post-ingest detector pass. Passes the ingestor's INJECTED clock (never
|
|
273
|
+
* new Date()) so recommendation windows/ids are deterministic (NFR-107 /
|
|
274
|
+
* Review F4). No-op unless the daemon wired the post-ingest hook.
|
|
275
|
+
*/
|
|
276
|
+
runPostIngest() {
|
|
277
|
+
runPostIngestHook(this.db, this.opts.now());
|
|
278
|
+
}
|
|
279
|
+
/** Tail one file from its stored offset and ingest the complete new lines. */
|
|
280
|
+
ingestFile(filePath, projectSlug) {
|
|
281
|
+
// Cheap size guard: transcripts are append-only, so an unchanged file size
|
|
282
|
+
// means zero new complete lines. Skip it before touching the DB — this keeps
|
|
283
|
+
// the 2s tail tick from doing ~4,700 sqlite writes across ~2,352 idle files
|
|
284
|
+
// every tick, which was starving the daemon's event loop (outcomes pass).
|
|
285
|
+
// Keep the per-file stat: directory mtimes only decide when to refresh paths;
|
|
286
|
+
// they cannot safely replace append/rotation change detection for each file.
|
|
287
|
+
let curSize;
|
|
288
|
+
try {
|
|
289
|
+
curSize = fs.statSync(filePath).size;
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
return; // file vanished between discovery and tail; nothing to ingest
|
|
293
|
+
}
|
|
294
|
+
if (this.lastSize.get(filePath) === curSize)
|
|
295
|
+
return; // unchanged since last tick — no new bytes
|
|
296
|
+
this.lastSize.set(filePath, curSize);
|
|
297
|
+
this.health.fileSeen();
|
|
298
|
+
registerWorkspace(this.db, projectSlug);
|
|
299
|
+
const stored = loadOffset(this.db, filePath);
|
|
300
|
+
const result = tailFile(filePath, stored);
|
|
301
|
+
if (result.wasReset)
|
|
302
|
+
this.lineCursor.set(filePath, 0);
|
|
303
|
+
if (result.lines.length === 0) {
|
|
304
|
+
// Still persist head-hash/offset so first-touch rotation detection is armed.
|
|
305
|
+
saveOffset(this.db, filePath, result.newOffset, result.newHeadHash);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const defaultSessionId = sessionStemFor(filePath);
|
|
309
|
+
const base = this.lineCursor.get(filePath) ?? 0;
|
|
310
|
+
const tx = this.db.transaction(() => {
|
|
311
|
+
for (let i = 0; i < result.lines.length; i++) {
|
|
312
|
+
const raw = result.lines[i];
|
|
313
|
+
if (raw === undefined)
|
|
314
|
+
continue;
|
|
315
|
+
this.applyLine(raw, { defaultSessionId }, projectSlug, filePath, base + i + 1);
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
tx();
|
|
319
|
+
this.lineCursor.set(filePath, base + result.lines.length);
|
|
320
|
+
saveOffset(this.db, filePath, result.newOffset, result.newHeadHash);
|
|
321
|
+
this.health.fileParsed();
|
|
322
|
+
}
|
|
323
|
+
applyLine(raw, ctx, workspaceId, filePath, lineNo) {
|
|
324
|
+
const proj = projectLine(raw, ctx);
|
|
325
|
+
if (proj.kind === "quarantine") {
|
|
326
|
+
this.quarantine(filePath, lineNo, proj.errorClass);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
this.health.unknownFields(proj.unknownFields);
|
|
330
|
+
if (proj.cwd !== null) {
|
|
331
|
+
this.stSetDiscoveredCwd.run(proj.cwd, workspaceId);
|
|
332
|
+
}
|
|
333
|
+
// A session row must exist before any session-scoped FK insert (tool_events,
|
|
334
|
+
// commands, turns). Resolve the line's session + a timestamp and ensure it.
|
|
335
|
+
const lineSessionId = proj.isUserTurn
|
|
336
|
+
? proj.sessionId
|
|
337
|
+
: (proj.turn?.sessionId ?? proj.command?.sessionId ?? proj.toolEvents[0]?.sessionId ?? null);
|
|
338
|
+
const lineTs = proj.turn?.ts ?? proj.command?.ts ?? proj.toolEvents[0]?.ts ?? "";
|
|
339
|
+
if (lineSessionId !== null) {
|
|
340
|
+
this.ensureSession(lineSessionId, workspaceId, filePath, lineTs === "" ? null : lineTs);
|
|
341
|
+
}
|
|
342
|
+
if (proj.isUserTurn === true) {
|
|
343
|
+
this.stBumpUserTurnCount.run(proj.sessionId);
|
|
344
|
+
if (typeof proj.ts === "string" && proj.ts.length > 0) {
|
|
345
|
+
const epochMs = Date.parse(proj.ts);
|
|
346
|
+
if (Number.isFinite(epochMs)) {
|
|
347
|
+
const list = this.userTurnTsBySession.get(proj.sessionId);
|
|
348
|
+
if (list !== undefined) {
|
|
349
|
+
list.push(epochMs);
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
this.userTurnTsBySession.set(proj.sessionId, [epochMs]);
|
|
353
|
+
}
|
|
354
|
+
this.applyGapAggregates(proj.sessionId);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
// Friction counters (RV2a): compact summary, API error, interrupt.
|
|
359
|
+
if (proj.isCompactSummary || proj.isApiErrorMessage || proj.isInterrupt) {
|
|
360
|
+
// Ensure session row exists for lines that carry only top-level flags
|
|
361
|
+
// (no turn, command, or tool events — so lineSessionId would be null).
|
|
362
|
+
if (lineSessionId === null) {
|
|
363
|
+
this.ensureSession(proj.sessionId, workspaceId, filePath, null);
|
|
364
|
+
}
|
|
365
|
+
this.stBumpFrictionCounts.run(proj.isCompactSummary ? 1 : 0, proj.isApiErrorMessage ? 1 : 0, proj.isInterrupt ? 1 : 0, proj.sessionId);
|
|
366
|
+
}
|
|
367
|
+
// Command markers → tool_events(local_command) for hygiene evaluation.
|
|
368
|
+
if (proj.command !== null) {
|
|
369
|
+
this.recordCommand(proj.command.sessionId, proj.command.ts, proj.command.command);
|
|
370
|
+
}
|
|
371
|
+
// Tool-use blocks → tool_events; remember owner + git hint for correlation.
|
|
372
|
+
for (const te of proj.toolEvents) {
|
|
373
|
+
this.insertToolEvent(te);
|
|
374
|
+
if (te.toolUseId !== null) {
|
|
375
|
+
if (te.ownerMessageId !== null)
|
|
376
|
+
this.toolUseOwner.set(te.toolUseId, te.ownerMessageId);
|
|
377
|
+
if (te.gitCommandHint)
|
|
378
|
+
this.gitUseIds.add(te.toolUseId);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
// Tool-result blocks → attach bytes to owning turn; harvest git commit SHAs.
|
|
382
|
+
for (const tr of proj.toolResults) {
|
|
383
|
+
this.applyToolResult(tr.toolUseId, tr.resultBytes, tr.commitSha, tr.isError);
|
|
384
|
+
}
|
|
385
|
+
if (proj.synthetic) {
|
|
386
|
+
this.health.synthetic();
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
if (proj.turn === null)
|
|
390
|
+
return;
|
|
391
|
+
this.writeTurn(proj.turn, workspaceId);
|
|
392
|
+
}
|
|
393
|
+
/** INSERT OR IGNORE a session row so FK-bearing child rows can be written. */
|
|
394
|
+
ensureSession(sessionId, workspaceId, filePath, ts) {
|
|
395
|
+
this.stInsertSession.run(sessionId, workspaceId, filePath, ts, ts);
|
|
396
|
+
}
|
|
397
|
+
writeTurn(turn, workspaceId) {
|
|
398
|
+
const priced = this.pricing.price(turn.model, {
|
|
399
|
+
inputTokens: turn.inputTokens,
|
|
400
|
+
outputTokens: turn.outputTokens,
|
|
401
|
+
cacheReadTokens: turn.cacheReadTokens,
|
|
402
|
+
cacheWrite5m: turn.cacheWrite5m,
|
|
403
|
+
cacheWrite1h: turn.cacheWrite1h,
|
|
404
|
+
cacheWriteOther: turn.cacheWriteOther,
|
|
405
|
+
});
|
|
406
|
+
const res = this.stInsertTurn.run(turn.messageId, turn.sessionId, workspaceId, turn.ts, turn.model, turn.isSidechain ? 1 : 0, turn.inputTokens, turn.outputTokens, turn.thinkingTokens, turn.cacheReadTokens, turn.cacheWrite5m, turn.cacheWrite1h, turn.cacheWriteOther, null, // tool_result_bytes filled by correlation
|
|
407
|
+
priced.snapshotId, priced.costU, priced.claim, 1, // provisional until reconciled
|
|
408
|
+
turn.effort, PARSER_VERSION);
|
|
409
|
+
if (res.changes === 0) {
|
|
410
|
+
// Duplicate message_id — a true no-op for aggregates.
|
|
411
|
+
this.health.duplicateDrop();
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
// New turn: the session is already ensured; advance its aggregates.
|
|
415
|
+
this.stBumpSession.run(priced.costU ?? 0, turn.ts, turn.ts, turn.ts, turn.ts, turn.sessionId);
|
|
416
|
+
this.health.turnIngested(PARSER_VERSION);
|
|
417
|
+
// If tool_result bytes already accumulated for this turn (results seen first
|
|
418
|
+
// is impossible within a file, but the owner map may hold late updates).
|
|
419
|
+
const bytes = this.resultBytesByMsg.get(turn.messageId);
|
|
420
|
+
if (bytes !== undefined)
|
|
421
|
+
this.stSetResultBytes.run(bytes, turn.messageId);
|
|
422
|
+
}
|
|
423
|
+
insertToolEvent(te) {
|
|
424
|
+
this.stInsertToolEvent.run(te.eventId, te.sessionId, te.ts, te.toolName, te.inputBytes, null, // result_bytes filled on correlation
|
|
425
|
+
te.inputHash, null, // exit_class
|
|
426
|
+
null);
|
|
427
|
+
this.stUpsertToolEventMetadata.run(te.eventId, te.filePathHash, te.ownerMessageId, te.blockIndex, te.testCommandHint ? 1 : 0);
|
|
428
|
+
}
|
|
429
|
+
applyToolResult(toolUseId, resultBytes, commitSha, isError) {
|
|
430
|
+
if (toolUseId === null)
|
|
431
|
+
return;
|
|
432
|
+
// Always enrich the event row, including after a restart or deliberate operator
|
|
433
|
+
// re-scan. Persisted structural metadata is authoritative for TEST_FAIL.
|
|
434
|
+
this.stSetToolResult.run(resultBytes, isError ? 1 : 0, toolUseId);
|
|
435
|
+
// Persisted ownership is also authoritative: a result can arrive after a
|
|
436
|
+
// daemon restart, when the process-local toolUseOwner map is empty.
|
|
437
|
+
this.stRefreshOwnerResultBytes.run(toolUseId);
|
|
438
|
+
if (this.countedResults.has(toolUseId))
|
|
439
|
+
return; // turn aggregate stays idempotent
|
|
440
|
+
this.countedResults.add(toolUseId);
|
|
441
|
+
const owner = this.toolUseOwner.get(toolUseId);
|
|
442
|
+
if (owner !== undefined) {
|
|
443
|
+
const next = (this.resultBytesByMsg.get(owner) ?? 0) + resultBytes;
|
|
444
|
+
this.resultBytesByMsg.set(owner, next);
|
|
445
|
+
this.stSetResultBytes.run(next, owner);
|
|
446
|
+
}
|
|
447
|
+
// Persist a commit SHA only for tool_uses that looked like git commit/push.
|
|
448
|
+
if (commitSha !== null && this.gitUseIds.has(toolUseId)) {
|
|
449
|
+
this.stSetCommitSha.run(commitSha, toolUseId);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
recordCommand(sessionId, ts, command) {
|
|
453
|
+
const eventId = `cmd-${crypto
|
|
454
|
+
.createHash("sha1")
|
|
455
|
+
.update(`${sessionId}|${ts}|${command}`)
|
|
456
|
+
.digest("hex")
|
|
457
|
+
.slice(0, 20)}`;
|
|
458
|
+
this.stInsertToolEvent.run(eventId, sessionId, ts, "local_command", null, null, command, null, null);
|
|
459
|
+
}
|
|
460
|
+
applyGapAggregates(sessionId) {
|
|
461
|
+
const tsList = this.userTurnTsBySession.get(sessionId);
|
|
462
|
+
if (tsList === undefined || tsList.length === 0) {
|
|
463
|
+
this.clearGapAggregatesUnlessRicher(sessionId);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
const sorted = [...tsList].sort((a, b) => a - b);
|
|
467
|
+
const gaps = [];
|
|
468
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
469
|
+
gaps.push((sorted[i] - sorted[i - 1]) / 1000);
|
|
470
|
+
}
|
|
471
|
+
const gapN = gaps.length;
|
|
472
|
+
if (gapN === 0) {
|
|
473
|
+
this.clearGapAggregatesUnlessRicher(sessionId);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
// gapN >= 1: a real gap was observed in-process. Always write it — even a partial,
|
|
477
|
+
// post-restart sample reflects genuine current friction (e.g. a new long gap) and
|
|
478
|
+
// must not be suppressed just because it's smaller than a pre-restart gap_n.
|
|
479
|
+
const g = [...gaps].sort((a, b) => a - b);
|
|
480
|
+
const mid = g.length;
|
|
481
|
+
const gapMedianS = mid % 2 === 1
|
|
482
|
+
? g[Math.floor(mid / 2)]
|
|
483
|
+
: (g[mid / 2 - 1] + g[mid / 2]) / 2;
|
|
484
|
+
const p90Idx = Math.min(Math.ceil(0.9 * g.length) - 1, g.length - 1);
|
|
485
|
+
const gapP90S = g[Math.max(0, p90Idx)];
|
|
486
|
+
const longGapCount = g.filter((x) => x > LONG_GAP_THRESHOLD_S).length;
|
|
487
|
+
this.stSetGapAggregates.run(gapMedianS, gapP90S, longGapCount, gapN, sessionId);
|
|
488
|
+
}
|
|
489
|
+
// Write-guard for the "no computable gap" collapse (0 or 1 in-process user turns):
|
|
490
|
+
// the in-memory turn-timestamp map resets on every daemon restart, so this state is
|
|
491
|
+
// reached on the first post-restart user turn even when a prior cold runBackscan()
|
|
492
|
+
// already persisted a richer gap_n. Skip the null/zero write in that case so it
|
|
493
|
+
// doesn't clobber the richer aggregate.
|
|
494
|
+
clearGapAggregatesUnlessRicher(sessionId) {
|
|
495
|
+
const storedRow = this.stGetGapN.get(sessionId);
|
|
496
|
+
if ((storedRow?.gap_n ?? 0) > 0)
|
|
497
|
+
return;
|
|
498
|
+
this.stSetGapAggregates.run(null, null, 0, 0, sessionId);
|
|
499
|
+
}
|
|
500
|
+
quarantine(filePath, lineNo, errorClass) {
|
|
501
|
+
const qId = crypto
|
|
502
|
+
.createHash("sha1")
|
|
503
|
+
.update(`${filePath}|${lineNo}|${errorClass}`)
|
|
504
|
+
.digest("hex")
|
|
505
|
+
.slice(0, 24);
|
|
506
|
+
this.stInsertQuarantine.run(qId, filePath, lineNo, errorClass, PARSER_VERSION, this.opts.now().toISOString());
|
|
507
|
+
this.health.quarantined();
|
|
508
|
+
}
|
|
509
|
+
}
|