tokentrace 0.18.0 → 0.18.1
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/CHANGELOG.md +35 -0
- package/dist/runtime/anomalies.mjs +3 -2
- package/dist/runtime/db-seed.mjs +1 -1
- package/dist/runtime/pricing-refresh.mjs +1 -1
- package/dist/runtime/query.mjs +13 -2
- package/dist/runtime/repair.mjs +31 -9
- package/dist/runtime/reset.mjs +1 -1
- package/dist/runtime/scan.mjs +1 -1
- package/package.json +1 -1
- package/scripts/query.ts +12 -1
- package/scripts/repair.ts +19 -6
- package/server.json +2 -2
- package/src/lib/anomaly-detection.ts +7 -1
- package/src/lib/cost-recalculation.ts +5 -1
- package/src/lib/model-aliases/backfill.ts +9 -3
- package/src/lib/scheduled-scan.ts +18 -1
- package/src/lib/structured-query-cli.ts +5 -1
- package/tsconfig.json +1 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,41 @@ All notable changes to TokenTrace are documented here.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## [0.18.1] - 2026-05-28
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **Scheduled scans now coalesce concurrent runs.** `runDueScheduledScan`
|
|
12
|
+
fires on every overview page load; two overlapping loads (a second tab, a
|
|
13
|
+
quick refresh, a prefetch) previously both passed the due-check and both
|
|
14
|
+
called `runScan`, producing duplicate scan runs. A module-scoped in-flight
|
|
15
|
+
promise guard now coalesces concurrent callers into a single scan within
|
|
16
|
+
the server process.
|
|
17
|
+
- **`tokentrace repair auto-classify --apply` is now atomic.** The alias
|
|
18
|
+
writes and cost backfills for a batch run inside a single SQLite
|
|
19
|
+
transaction, and `backfillAlias` wraps its per-row updates in a transaction
|
|
20
|
+
too, so a failure partway can no longer leave the local database
|
|
21
|
+
half-applied with inaccurate reported counts.
|
|
22
|
+
- **`tokentrace query` surfaces range errors cleanly.** Range validation
|
|
23
|
+
(e.g. `--from` after `--to`, or a preset combined with explicit dates) now
|
|
24
|
+
prints the validation message plus usage and exits non-zero, instead of
|
|
25
|
+
letting a raw stack trace escape.
|
|
26
|
+
- **Anomaly z-scores survive JSON.** The flat-window (`mad === 0`) severe
|
|
27
|
+
branch reported `Infinity`, which `JSON.stringify` turned into `null` and
|
|
28
|
+
silently dropped the signal. It now reports a finite sentinel z-score.
|
|
29
|
+
- **Stricter query CLI parsing.** Empty `--flag=` values are rejected with a
|
|
30
|
+
clear "Missing value" message rather than flowing through as empty strings.
|
|
31
|
+
|
|
32
|
+
### Internal
|
|
33
|
+
|
|
34
|
+
- **Test suite no longer flakes under parallel load.** CLI/MCP integration
|
|
35
|
+
tests spawn `bin/tokentrace.js`, which itself spawns `db-migrate` and
|
|
36
|
+
`db-seed`, fanning out to ~3x the core count in node processes. The Vitest
|
|
37
|
+
fork pool is now capped at roughly half the available cores with one
|
|
38
|
+
generous global timeout, removing the scattered per-test timeout overrides.
|
|
39
|
+
- Removed the dead `tailwind.config.ts` left over from the Tailwind 4
|
|
40
|
+
(CSS-first) migration.
|
|
41
|
+
|
|
7
42
|
## [0.18.0] - 2026-05-28
|
|
8
43
|
|
|
9
44
|
### Local intelligence
|
|
@@ -846,7 +846,7 @@ function scoreValue(value, window, thresholds) {
|
|
|
846
846
|
}
|
|
847
847
|
const flatRunLength = window.filter((entry) => entry === baseline).length;
|
|
848
848
|
if (value > 2 * baseline && flatRunLength >= 3) {
|
|
849
|
-
return { severity: "severe", baseline, zScore:
|
|
849
|
+
return { severity: "severe", baseline, zScore: FLAT_RUN_Z };
|
|
850
850
|
}
|
|
851
851
|
return { severity: null, baseline, zScore: 0 };
|
|
852
852
|
}
|
|
@@ -907,7 +907,7 @@ function detectAnomalies(points, options2 = {}) {
|
|
|
907
907
|
summary
|
|
908
908
|
};
|
|
909
909
|
}
|
|
910
|
-
var DEFAULT_WINDOW, DEFAULT_MIN_WINDOW, DEFAULT_THRESHOLDS, MAD_TO_STDDEV, METRICS;
|
|
910
|
+
var DEFAULT_WINDOW, DEFAULT_MIN_WINDOW, DEFAULT_THRESHOLDS, MAD_TO_STDDEV, FLAT_RUN_Z, METRICS;
|
|
911
911
|
var init_anomaly_detection = __esm({
|
|
912
912
|
"src/lib/anomaly-detection.ts"() {
|
|
913
913
|
"use strict";
|
|
@@ -915,6 +915,7 @@ var init_anomaly_detection = __esm({
|
|
|
915
915
|
DEFAULT_MIN_WINDOW = 5;
|
|
916
916
|
DEFAULT_THRESHOLDS = { notable: 3, high: 4.5, severe: 6 };
|
|
917
917
|
MAD_TO_STDDEV = 0.6745;
|
|
918
|
+
FLAT_RUN_Z = 999;
|
|
918
919
|
METRICS = [
|
|
919
920
|
{ metric: "tokens", valueOf: (point) => point.totalTokens },
|
|
920
921
|
{ metric: "cost", valueOf: (point) => point.cost }
|
package/dist/runtime/db-seed.mjs
CHANGED
|
@@ -1659,7 +1659,6 @@ function recalculateInteractionCosts() {
|
|
|
1659
1659
|
currency: interaction.alias_currency ?? "USD"
|
|
1660
1660
|
} : null;
|
|
1661
1661
|
const resolvedPrice = aliasPrice ?? directPrice;
|
|
1662
|
-
const resolvedViaAlias = aliasPrice != null;
|
|
1663
1662
|
const cost = calculateInteractionCost(
|
|
1664
1663
|
{
|
|
1665
1664
|
inputTokens: interaction.input_tokens,
|
|
@@ -1671,6 +1670,7 @@ function recalculateInteractionCosts() {
|
|
|
1671
1670
|
},
|
|
1672
1671
|
resolvedPrice
|
|
1673
1672
|
);
|
|
1673
|
+
const resolvedViaAlias = aliasPrice != null && cost.amount != null;
|
|
1674
1674
|
if (cost.status === "unknown") unknownCostInteractions += 1;
|
|
1675
1675
|
const metadata = {
|
|
1676
1676
|
...existingMetadata,
|
|
@@ -940,7 +940,6 @@ function recalculateInteractionCosts() {
|
|
|
940
940
|
currency: interaction.alias_currency ?? "USD"
|
|
941
941
|
} : null;
|
|
942
942
|
const resolvedPrice = aliasPrice ?? directPrice;
|
|
943
|
-
const resolvedViaAlias = aliasPrice != null;
|
|
944
943
|
const cost = calculateInteractionCost(
|
|
945
944
|
{
|
|
946
945
|
inputTokens: interaction.input_tokens,
|
|
@@ -952,6 +951,7 @@ function recalculateInteractionCosts() {
|
|
|
952
951
|
},
|
|
953
952
|
resolvedPrice
|
|
954
953
|
);
|
|
954
|
+
const resolvedViaAlias = aliasPrice != null && cost.amount != null;
|
|
955
955
|
if (cost.status === "unknown") unknownCostInteractions += 1;
|
|
956
956
|
const metadata = {
|
|
957
957
|
...existingMetadata,
|
package/dist/runtime/query.mjs
CHANGED
|
@@ -942,7 +942,11 @@ var SORT = ["asc", "desc"];
|
|
|
942
942
|
var PRESET = ["today", "7d", "30d", "60d", "90d", "all"];
|
|
943
943
|
function valueOf(arg, next) {
|
|
944
944
|
const eq = arg.indexOf("=");
|
|
945
|
-
if (eq >= 0)
|
|
945
|
+
if (eq >= 0) {
|
|
946
|
+
const value = arg.slice(eq + 1);
|
|
947
|
+
if (!value) throw new Error(`Missing value for ${arg.slice(0, eq)}`);
|
|
948
|
+
return { value, consumedNext: false };
|
|
949
|
+
}
|
|
946
950
|
if (next == null || next.startsWith("-")) {
|
|
947
951
|
throw new Error(`Missing value for ${arg}`);
|
|
948
952
|
}
|
|
@@ -1075,7 +1079,14 @@ if (parsed.help) {
|
|
|
1075
1079
|
process.exit(0);
|
|
1076
1080
|
}
|
|
1077
1081
|
var { runStructuredQuery: runStructuredQuery2 } = await Promise.resolve().then(() => (init_structured_query(), structured_query_exports));
|
|
1078
|
-
var result
|
|
1082
|
+
var result;
|
|
1083
|
+
try {
|
|
1084
|
+
result = runStructuredQuery2(parsed.args);
|
|
1085
|
+
} catch (error) {
|
|
1086
|
+
console.error(error instanceof Error ? error.message : "Invalid query.");
|
|
1087
|
+
console.error(structuredQueryUsage());
|
|
1088
|
+
process.exit(1);
|
|
1089
|
+
}
|
|
1079
1090
|
if (parsed.json) {
|
|
1080
1091
|
console.log(JSON.stringify(result, null, 2));
|
|
1081
1092
|
} else {
|
package/dist/runtime/repair.mjs
CHANGED
|
@@ -897,6 +897,12 @@ var init_schema = __esm({
|
|
|
897
897
|
});
|
|
898
898
|
|
|
899
899
|
// src/db/client.ts
|
|
900
|
+
var client_exports = {};
|
|
901
|
+
__export(client_exports, {
|
|
902
|
+
db: () => db,
|
|
903
|
+
getDatabasePath: () => getDatabasePath,
|
|
904
|
+
sqlite: () => sqlite
|
|
905
|
+
});
|
|
900
906
|
import fs from "node:fs";
|
|
901
907
|
import path from "node:path";
|
|
902
908
|
import { fileURLToPath } from "node:url";
|
|
@@ -910,6 +916,9 @@ function databaseUrlPath(value) {
|
|
|
910
916
|
return value.slice("file:".length);
|
|
911
917
|
}
|
|
912
918
|
}
|
|
919
|
+
function getDatabasePath() {
|
|
920
|
+
return dbPath;
|
|
921
|
+
}
|
|
913
922
|
var defaultDbPath, dbPath, sqlite, db;
|
|
914
923
|
var init_client = __esm({
|
|
915
924
|
"src/db/client.ts"() {
|
|
@@ -2001,9 +2010,12 @@ function backfillAlias(alias, options2 = {}) {
|
|
|
2001
2010
|
const stmt = prepareCached(
|
|
2002
2011
|
"UPDATE interactions SET cost = ?, cost_estimated = 1 WHERE id = ?"
|
|
2003
2012
|
);
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2013
|
+
const writeAll = sqlite.transaction((rows4) => {
|
|
2014
|
+
for (const update of rows4) {
|
|
2015
|
+
stmt.run(update.cost, update.id);
|
|
2016
|
+
}
|
|
2017
|
+
});
|
|
2018
|
+
writeAll(updates);
|
|
2007
2019
|
}
|
|
2008
2020
|
return {
|
|
2009
2021
|
alias,
|
|
@@ -2016,6 +2028,7 @@ function backfillAlias(alias, options2 = {}) {
|
|
|
2016
2028
|
var init_backfill = __esm({
|
|
2017
2029
|
"src/lib/model-aliases/backfill.ts"() {
|
|
2018
2030
|
"use strict";
|
|
2031
|
+
init_client();
|
|
2019
2032
|
init_prepared();
|
|
2020
2033
|
init_cost();
|
|
2021
2034
|
}
|
|
@@ -2248,10 +2261,11 @@ if (args[0] === "auto-classify") {
|
|
|
2248
2261
|
const workbench2 = buildUnknownCostRepairWorkbench3();
|
|
2249
2262
|
const result = buildAutoClassifyResult2(workbench2, { minConfidence: options2.minConfidence });
|
|
2250
2263
|
if (options2.apply) {
|
|
2251
|
-
const [{ upsertAlias: upsertAlias2 }, { backfillAlias: backfillAlias2 }, { prepareCached: prepareCached2 }] = await Promise.all([
|
|
2264
|
+
const [{ upsertAlias: upsertAlias2 }, { backfillAlias: backfillAlias2 }, { prepareCached: prepareCached2 }, { sqlite: sqlite2 }] = await Promise.all([
|
|
2252
2265
|
Promise.resolve().then(() => (init_store(), store_exports)),
|
|
2253
2266
|
Promise.resolve().then(() => (init_backfill(), backfill_exports)),
|
|
2254
|
-
Promise.resolve().then(() => (init_prepared(), prepared_exports))
|
|
2267
|
+
Promise.resolve().then(() => (init_prepared(), prepared_exports)),
|
|
2268
|
+
Promise.resolve().then(() => (init_client(), client_exports))
|
|
2255
2269
|
]);
|
|
2256
2270
|
const outcome = {
|
|
2257
2271
|
dryRun: options2.dryRun,
|
|
@@ -2271,18 +2285,18 @@ if (args[0] === "auto-classify") {
|
|
|
2271
2285
|
).get(modelName, providerName, providerName);
|
|
2272
2286
|
return row2 ?? null;
|
|
2273
2287
|
};
|
|
2274
|
-
|
|
2288
|
+
const processSuggestion = (suggestion) => {
|
|
2275
2289
|
const c = suggestion.classification;
|
|
2276
2290
|
if (c.rule === "none" || !c.suggestedModel) {
|
|
2277
2291
|
outcome.skipped.push({ key: suggestion.key, reason: "No suggested model." });
|
|
2278
|
-
|
|
2292
|
+
return;
|
|
2279
2293
|
}
|
|
2280
2294
|
if (c.rule === "parser-source") {
|
|
2281
2295
|
outcome.skipped.push({
|
|
2282
2296
|
key: suggestion.key,
|
|
2283
2297
|
reason: "parser-source suggestions have no (provider, observed-model) pair; fix the parser instead."
|
|
2284
2298
|
});
|
|
2285
|
-
|
|
2299
|
+
return;
|
|
2286
2300
|
}
|
|
2287
2301
|
const resolved = resolvePricedModelId(
|
|
2288
2302
|
c.suggestedProvider ?? suggestion.provider,
|
|
@@ -2293,7 +2307,7 @@ if (args[0] === "auto-classify") {
|
|
|
2293
2307
|
key: suggestion.key,
|
|
2294
2308
|
reason: `Could not resolve priced model "${c.suggestedModel}" under provider "${suggestion.provider}".`
|
|
2295
2309
|
});
|
|
2296
|
-
|
|
2310
|
+
return;
|
|
2297
2311
|
}
|
|
2298
2312
|
if (!options2.dryRun) {
|
|
2299
2313
|
upsertAlias2({
|
|
@@ -2325,6 +2339,14 @@ if (args[0] === "auto-classify") {
|
|
|
2325
2339
|
affectedInteractions: backfill.affectedInteractions,
|
|
2326
2340
|
addedCost: backfill.totalCost
|
|
2327
2341
|
});
|
|
2342
|
+
};
|
|
2343
|
+
if (options2.dryRun) {
|
|
2344
|
+
for (const suggestion of result.suggestions) processSuggestion(suggestion);
|
|
2345
|
+
} else {
|
|
2346
|
+
const applyBatch = sqlite2.transaction(() => {
|
|
2347
|
+
for (const suggestion of result.suggestions) processSuggestion(suggestion);
|
|
2348
|
+
});
|
|
2349
|
+
applyBatch();
|
|
2328
2350
|
}
|
|
2329
2351
|
result.applied = outcome;
|
|
2330
2352
|
}
|
package/dist/runtime/reset.mjs
CHANGED
|
@@ -1659,7 +1659,6 @@ function recalculateInteractionCosts() {
|
|
|
1659
1659
|
currency: interaction.alias_currency ?? "USD"
|
|
1660
1660
|
} : null;
|
|
1661
1661
|
const resolvedPrice = aliasPrice ?? directPrice;
|
|
1662
|
-
const resolvedViaAlias = aliasPrice != null;
|
|
1663
1662
|
const cost = calculateInteractionCost(
|
|
1664
1663
|
{
|
|
1665
1664
|
inputTokens: interaction.input_tokens,
|
|
@@ -1671,6 +1670,7 @@ function recalculateInteractionCosts() {
|
|
|
1671
1670
|
},
|
|
1672
1671
|
resolvedPrice
|
|
1673
1672
|
);
|
|
1673
|
+
const resolvedViaAlias = aliasPrice != null && cost.amount != null;
|
|
1674
1674
|
if (cost.status === "unknown") unknownCostInteractions += 1;
|
|
1675
1675
|
const metadata = {
|
|
1676
1676
|
...existingMetadata,
|
package/dist/runtime/scan.mjs
CHANGED
|
@@ -4337,7 +4337,6 @@ function recalculateInteractionCosts() {
|
|
|
4337
4337
|
currency: interaction.alias_currency ?? "USD"
|
|
4338
4338
|
} : null;
|
|
4339
4339
|
const resolvedPrice = aliasPrice ?? directPrice;
|
|
4340
|
-
const resolvedViaAlias = aliasPrice != null;
|
|
4341
4340
|
const cost = calculateInteractionCost(
|
|
4342
4341
|
{
|
|
4343
4342
|
inputTokens: interaction.input_tokens,
|
|
@@ -4349,6 +4348,7 @@ function recalculateInteractionCosts() {
|
|
|
4349
4348
|
},
|
|
4350
4349
|
resolvedPrice
|
|
4351
4350
|
);
|
|
4351
|
+
const resolvedViaAlias = aliasPrice != null && cost.amount != null;
|
|
4352
4352
|
if (cost.status === "unknown") unknownCostInteractions += 1;
|
|
4353
4353
|
const metadata = {
|
|
4354
4354
|
...existingMetadata,
|
package/package.json
CHANGED
package/scripts/query.ts
CHANGED
|
@@ -17,7 +17,18 @@ if (parsed.help) {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
const { runStructuredQuery } = await import("@/src/lib/structured-query");
|
|
20
|
-
|
|
20
|
+
|
|
21
|
+
let result: ReturnType<typeof runStructuredQuery>;
|
|
22
|
+
try {
|
|
23
|
+
// Range/argument validation (e.g. from >= to, preset + from/to together)
|
|
24
|
+
// lives in runStructuredQuery, so surface those the same clean way as parse
|
|
25
|
+
// errors instead of letting a raw stack trace escape.
|
|
26
|
+
result = runStructuredQuery(parsed.args);
|
|
27
|
+
} catch (error) {
|
|
28
|
+
console.error(error instanceof Error ? error.message : "Invalid query.");
|
|
29
|
+
console.error(structuredQueryUsage());
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
21
32
|
|
|
22
33
|
if (parsed.json) {
|
|
23
34
|
console.log(JSON.stringify(result, null, 2));
|
package/scripts/repair.ts
CHANGED
|
@@ -49,10 +49,11 @@ if (args[0] === "auto-classify") {
|
|
|
49
49
|
const result = buildAutoClassifyResult(workbench, { minConfidence: options.minConfidence });
|
|
50
50
|
|
|
51
51
|
if (options.apply) {
|
|
52
|
-
const [{ upsertAlias }, { backfillAlias }, { prepareCached }] = await Promise.all([
|
|
52
|
+
const [{ upsertAlias }, { backfillAlias }, { prepareCached }, { sqlite }] = await Promise.all([
|
|
53
53
|
import("@/src/lib/model-aliases/store"),
|
|
54
54
|
import("@/src/lib/model-aliases/backfill"),
|
|
55
|
-
import("@/src/db/prepared")
|
|
55
|
+
import("@/src/db/prepared"),
|
|
56
|
+
import("@/src/db/client")
|
|
56
57
|
]);
|
|
57
58
|
|
|
58
59
|
const outcome: NonNullable<typeof result.applied> = {
|
|
@@ -79,18 +80,18 @@ if (args[0] === "auto-classify") {
|
|
|
79
80
|
return row ?? null;
|
|
80
81
|
};
|
|
81
82
|
|
|
82
|
-
|
|
83
|
+
const processSuggestion = (suggestion: (typeof result.suggestions)[number]) => {
|
|
83
84
|
const c = suggestion.classification;
|
|
84
85
|
if (c.rule === "none" || !c.suggestedModel) {
|
|
85
86
|
outcome.skipped.push({ key: suggestion.key, reason: "No suggested model." });
|
|
86
|
-
|
|
87
|
+
return;
|
|
87
88
|
}
|
|
88
89
|
if (c.rule === "parser-source") {
|
|
89
90
|
outcome.skipped.push({
|
|
90
91
|
key: suggestion.key,
|
|
91
92
|
reason: "parser-source suggestions have no (provider, observed-model) pair; fix the parser instead."
|
|
92
93
|
});
|
|
93
|
-
|
|
94
|
+
return;
|
|
94
95
|
}
|
|
95
96
|
const resolved = resolvePricedModelId(
|
|
96
97
|
c.suggestedProvider ?? suggestion.provider,
|
|
@@ -101,7 +102,7 @@ if (args[0] === "auto-classify") {
|
|
|
101
102
|
key: suggestion.key,
|
|
102
103
|
reason: `Could not resolve priced model "${c.suggestedModel}" under provider "${suggestion.provider}".`
|
|
103
104
|
});
|
|
104
|
-
|
|
105
|
+
return;
|
|
105
106
|
}
|
|
106
107
|
|
|
107
108
|
if (!options.dryRun) {
|
|
@@ -136,6 +137,18 @@ if (args[0] === "auto-classify") {
|
|
|
136
137
|
affectedInteractions: backfill.affectedInteractions,
|
|
137
138
|
addedCost: backfill.totalCost
|
|
138
139
|
});
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
if (options.dryRun) {
|
|
143
|
+
for (const suggestion of result.suggestions) processSuggestion(suggestion);
|
|
144
|
+
} else {
|
|
145
|
+
// Apply the whole batch atomically: every alias + cost backfill commits
|
|
146
|
+
// together, or none does, so a failure partway can't leave the local
|
|
147
|
+
// database half-applied with inaccurate reported counts.
|
|
148
|
+
const applyBatch = sqlite.transaction(() => {
|
|
149
|
+
for (const suggestion of result.suggestions) processSuggestion(suggestion);
|
|
150
|
+
});
|
|
151
|
+
applyBatch();
|
|
139
152
|
}
|
|
140
153
|
|
|
141
154
|
result.applied = outcome;
|
package/server.json
CHANGED
|
@@ -8,12 +8,12 @@
|
|
|
8
8
|
"url": "https://github.com/abhiyoheswaran1/tokentrace",
|
|
9
9
|
"source": "github"
|
|
10
10
|
},
|
|
11
|
-
"version": "0.18.
|
|
11
|
+
"version": "0.18.1",
|
|
12
12
|
"packages": [
|
|
13
13
|
{
|
|
14
14
|
"registryType": "npm",
|
|
15
15
|
"identifier": "tokentrace",
|
|
16
|
-
"version": "0.18.
|
|
16
|
+
"version": "0.18.1",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"packageArguments": [
|
|
19
19
|
{
|
|
@@ -42,6 +42,12 @@ const DEFAULT_THRESHOLDS: AnomalyThresholds = { notable: 3, high: 4.5, severe: 6
|
|
|
42
42
|
// Makes MAD a consistent estimator of stddev under normality.
|
|
43
43
|
const MAD_TO_STDDEV = 0.6745;
|
|
44
44
|
|
|
45
|
+
// When the trailing window has zero spread (mad === 0) a true modified z-score
|
|
46
|
+
// is undefined/infinite. We still flag a large jump as severe, but report a
|
|
47
|
+
// finite sentinel z-score so the value round-trips through JSON (JSON.stringify
|
|
48
|
+
// serializes Infinity as null, which would silently drop the signal).
|
|
49
|
+
const FLAT_RUN_Z = 999;
|
|
50
|
+
|
|
45
51
|
function median(values: number[]): number {
|
|
46
52
|
if (values.length === 0) return 0;
|
|
47
53
|
const sorted = values.slice().sort((a, b) => a - b);
|
|
@@ -81,7 +87,7 @@ function scoreValue(value: number, window: number[], thresholds: AnomalyThreshol
|
|
|
81
87
|
}
|
|
82
88
|
const flatRunLength = window.filter((entry) => entry === baseline).length;
|
|
83
89
|
if (value > 2 * baseline && flatRunLength >= 3) {
|
|
84
|
-
return { severity: "severe", baseline, zScore:
|
|
90
|
+
return { severity: "severe", baseline, zScore: FLAT_RUN_Z };
|
|
85
91
|
}
|
|
86
92
|
return { severity: null, baseline, zScore: 0 };
|
|
87
93
|
}
|
|
@@ -238,7 +238,6 @@ export function recalculateInteractionCosts(): CostRecalculationResult {
|
|
|
238
238
|
}
|
|
239
239
|
: null;
|
|
240
240
|
const resolvedPrice = aliasPrice ?? directPrice;
|
|
241
|
-
const resolvedViaAlias = aliasPrice != null;
|
|
242
241
|
|
|
243
242
|
const cost = calculateInteractionCost(
|
|
244
243
|
{
|
|
@@ -252,6 +251,11 @@ export function recalculateInteractionCosts(): CostRecalculationResult {
|
|
|
252
251
|
resolvedPrice
|
|
253
252
|
);
|
|
254
253
|
|
|
254
|
+
// Only treat the row as alias-resolved if the alias actually produced a
|
|
255
|
+
// cost. An alias pointing at a model that itself lacks complete pricing
|
|
256
|
+
// must not be labelled as estimated/alias-sourced with a null amount.
|
|
257
|
+
const resolvedViaAlias = aliasPrice != null && cost.amount != null;
|
|
258
|
+
|
|
255
259
|
if (cost.status === "unknown") unknownCostInteractions += 1;
|
|
256
260
|
const metadata = {
|
|
257
261
|
...existingMetadata,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sqlite } from "@/src/db/client";
|
|
1
2
|
import { prepareCached } from "@/src/db/prepared";
|
|
2
3
|
import { calculateInteractionCost, type PriceConfig } from "@/src/lib/cost";
|
|
3
4
|
|
|
@@ -115,9 +116,14 @@ export function backfillAlias(
|
|
|
115
116
|
const stmt = prepareCached(
|
|
116
117
|
"UPDATE interactions SET cost = ?, cost_estimated = 1 WHERE id = ?"
|
|
117
118
|
);
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}
|
|
119
|
+
// Atomic: either every matched interaction is backfilled or none are, so a
|
|
120
|
+
// mid-loop failure can't leave the cost column half-updated.
|
|
121
|
+
const writeAll = sqlite.transaction((rows: Array<{ id: string; cost: number }>) => {
|
|
122
|
+
for (const update of rows) {
|
|
123
|
+
stmt.run(update.cost, update.id);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
writeAll(updates);
|
|
121
127
|
}
|
|
122
128
|
|
|
123
129
|
return {
|
|
@@ -10,7 +10,24 @@ function latestScanDate() {
|
|
|
10
10
|
return row?.scanAt ? new Date(row.scanAt) : null;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
type ScheduledScanOutcome = Awaited<ReturnType<typeof runScheduledScan>>;
|
|
14
|
+
|
|
15
|
+
// The dashboard fires runDueScheduledScan() on every overview page load. Two
|
|
16
|
+
// overlapping loads (a second tab, a quick refresh, prefetch) would otherwise
|
|
17
|
+
// both pass the due-check and both call runScan(), producing duplicate scan
|
|
18
|
+
// runs and wasted work. The Next.js server is a single process, so a
|
|
19
|
+
// module-scoped promise guard coalesces concurrent callers into one scan.
|
|
20
|
+
let inFlightScheduledScan: Promise<ScheduledScanOutcome> | null = null;
|
|
21
|
+
|
|
22
|
+
export function runDueScheduledScan(now = new Date()): Promise<ScheduledScanOutcome> {
|
|
23
|
+
if (inFlightScheduledScan) return inFlightScheduledScan;
|
|
24
|
+
inFlightScheduledScan = runScheduledScan(now).finally(() => {
|
|
25
|
+
inFlightScheduledScan = null;
|
|
26
|
+
});
|
|
27
|
+
return inFlightScheduledScan;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function runScheduledScan(now: Date) {
|
|
14
31
|
const settings = getAppSettings();
|
|
15
32
|
if (!isScanDue(settings.scanSchedule, latestScanDate(), now)) {
|
|
16
33
|
return {
|
|
@@ -47,7 +47,11 @@ const PRESET: readonly StructuredQueryRangePreset[] = ["today", "7d", "30d", "60
|
|
|
47
47
|
|
|
48
48
|
function valueOf(arg: string, next: string | undefined): { value: string; consumedNext: boolean } {
|
|
49
49
|
const eq = arg.indexOf("=");
|
|
50
|
-
if (eq >= 0)
|
|
50
|
+
if (eq >= 0) {
|
|
51
|
+
const value = arg.slice(eq + 1);
|
|
52
|
+
if (!value) throw new Error(`Missing value for ${arg.slice(0, eq)}`);
|
|
53
|
+
return { value, consumedNext: false };
|
|
54
|
+
}
|
|
51
55
|
if (next == null || next.startsWith("-")) {
|
|
52
56
|
throw new Error(`Missing value for ${arg}`);
|
|
53
57
|
}
|