@trazum/cli 1.42.0 → 1.43.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/README.md +1 -0
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +63 -0
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +65 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/types.d.ts +26 -1
- package/dist/i18n/types.d.ts.map +1 -1
- package/dist/index.js +162 -1
- package/dist/index.js.map +1 -1
- package/dist/watch-run.d.ts +69 -0
- package/dist/watch-run.d.ts.map +1 -0
- package/dist/watch-run.js +79 -0
- package/dist/watch-run.js.map +1 -0
- package/package.json +2 -2
- package/src/i18n/en.ts +73 -0
- package/src/i18n/es.ts +75 -0
- package/src/i18n/types.ts +27 -1
- package/src/index.ts +222 -0
- package/src/watch-run.ts +126 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One cycle of watching, and the state that survives a restart.
|
|
3
|
+
*
|
|
4
|
+
* `--once` is the primitive: pull the window, keep it, evaluate the gates,
|
|
5
|
+
* emit what crossed, save state. A cron entry runs exactly that, and so does
|
|
6
|
+
* every test. The foreground loop is this function in a timer, so there is one
|
|
7
|
+
* code path and no daemon-only behaviour that nobody exercises.
|
|
8
|
+
*
|
|
9
|
+
* **The state file is what makes a restart honest.** Without it a resumed
|
|
10
|
+
* watcher re-alerts on yesterday's crossing (noise nobody reads) and implies
|
|
11
|
+
* it was watching the whole time (a claim it cannot make). With it, the
|
|
12
|
+
* crossing stays quiet and the unwatched stretch gets named once.
|
|
13
|
+
*/
|
|
14
|
+
import type { WatchCrossing } from '@trazum/core';
|
|
15
|
+
export declare const WATCH_STATE_FILE = ".trazum/watch.json";
|
|
16
|
+
export declare const WATCH_STATE_VERSION = 1;
|
|
17
|
+
export interface WatchState {
|
|
18
|
+
v: number;
|
|
19
|
+
/** When the last cycle ran, so a long silence can be told from a first run. */
|
|
20
|
+
lastCycleMs: number;
|
|
21
|
+
/** How far the measurements reached, for the coverage gap. */
|
|
22
|
+
lastCoveredToMs: number | null;
|
|
23
|
+
/** Gate keys already alerted on, so a restart is not amnesia. */
|
|
24
|
+
fired: Record<string, number>;
|
|
25
|
+
}
|
|
26
|
+
export declare function readWatchState(root: string): Promise<WatchState | null>;
|
|
27
|
+
export declare function writeWatchState(root: string, state: WatchState): Promise<void>;
|
|
28
|
+
/**
|
|
29
|
+
* Whether a webhook URL is one this tool will post to.
|
|
30
|
+
*
|
|
31
|
+
* **This is not the SSRF case and the difference matters.** `checkedEndpoint`
|
|
32
|
+
* exists because a *request body* must never name a host: an anonymous caller
|
|
33
|
+
* pointing a shared server at an internal address is somebody else's machine
|
|
34
|
+
* reaching somewhere it was never meant to. Here the URL is in the operator's
|
|
35
|
+
* own config, on their own machine, and pointing it at their own alerting
|
|
36
|
+
* daemon on loopback is the ordinary case rather than the attack.
|
|
37
|
+
*
|
|
38
|
+
* So loopback is allowed and plain http is allowed *only* there, while two
|
|
39
|
+
* rules stay absolute: no credentials embedded in the URL, because a URL ends
|
|
40
|
+
* up in logs and shell history; and https everywhere else, because an alert
|
|
41
|
+
* carries spend figures across a network.
|
|
42
|
+
*/
|
|
43
|
+
export type WebhookRejection = 'invalid-url' | 'credentials-in-url' | 'insecure-scheme';
|
|
44
|
+
export declare function checkWebhook(raw: string): {
|
|
45
|
+
ok: true;
|
|
46
|
+
url: URL;
|
|
47
|
+
} | {
|
|
48
|
+
ok: false;
|
|
49
|
+
reason: WebhookRejection;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* The alert payload.
|
|
53
|
+
*
|
|
54
|
+
* Figures and gate names, never prompt text — the store has never held any and
|
|
55
|
+
* neither does this. Every crossing carries its own provenance, so a receiver
|
|
56
|
+
* that fans these into a dashboard cannot lose track of what kind of number it
|
|
57
|
+
* is holding.
|
|
58
|
+
*/
|
|
59
|
+
export interface WatchAlert {
|
|
60
|
+
schemaVersion: 1;
|
|
61
|
+
firedAtMs: number;
|
|
62
|
+
crossings: WatchCrossing[];
|
|
63
|
+
}
|
|
64
|
+
export declare function postWebhook(url: URL, alert: WatchAlert, fetchImpl?: typeof fetch): Promise<{
|
|
65
|
+
ok: boolean;
|
|
66
|
+
status: number | null;
|
|
67
|
+
error: string | null;
|
|
68
|
+
}>;
|
|
69
|
+
//# sourceMappingURL=watch-run.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"watch-run.d.ts","sourceRoot":"","sources":["../src/watch-run.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAKH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAElD,eAAO,MAAM,gBAAgB,uBAAuB,CAAC;AAErD,eAAO,MAAM,mBAAmB,IAAI,CAAC;AAErC,MAAM,WAAW,UAAU;IACzB,CAAC,EAAE,MAAM,CAAC;IACV,+EAA+E;IAC/E,WAAW,EAAE,MAAM,CAAC;IACpB,8DAA8D;IAC9D,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,iEAAiE;IACjE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC/B;AAED,wBAAsB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,CAU7E;AAED,wBAAsB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAIpF;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,MAAM,gBAAgB,GAAG,aAAa,GAAG,oBAAoB,GAAG,iBAAiB,CAAC;AAExF,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,GAAG,EAAE,GAAG,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,gBAAgB,CAAA;CAAE,CAkB1G;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,UAAU;IACzB,aAAa,EAAE,CAAC,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,aAAa,EAAE,CAAC;CAC5B;AAED,wBAAsB,WAAW,CAC/B,GAAG,EAAE,GAAG,EACR,KAAK,EAAE,UAAU,EACjB,SAAS,GAAE,OAAO,KAAa,GAC9B,OAAO,CAAC;IAAE,EAAE,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAAC,CAmBvE"}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One cycle of watching, and the state that survives a restart.
|
|
3
|
+
*
|
|
4
|
+
* `--once` is the primitive: pull the window, keep it, evaluate the gates,
|
|
5
|
+
* emit what crossed, save state. A cron entry runs exactly that, and so does
|
|
6
|
+
* every test. The foreground loop is this function in a timer, so there is one
|
|
7
|
+
* code path and no daemon-only behaviour that nobody exercises.
|
|
8
|
+
*
|
|
9
|
+
* **The state file is what makes a restart honest.** Without it a resumed
|
|
10
|
+
* watcher re-alerts on yesterday's crossing (noise nobody reads) and implies
|
|
11
|
+
* it was watching the whole time (a claim it cannot make). With it, the
|
|
12
|
+
* crossing stays quiet and the unwatched stretch gets named once.
|
|
13
|
+
*/
|
|
14
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
15
|
+
import { dirname, join } from 'node:path';
|
|
16
|
+
import { SAFE_FETCH_INIT } from '@trazum/core/node';
|
|
17
|
+
export const WATCH_STATE_FILE = '.trazum/watch.json';
|
|
18
|
+
export const WATCH_STATE_VERSION = 1;
|
|
19
|
+
export async function readWatchState(root) {
|
|
20
|
+
try {
|
|
21
|
+
const parsed = JSON.parse(await readFile(join(root, WATCH_STATE_FILE), 'utf8'));
|
|
22
|
+
if (parsed?.v !== WATCH_STATE_VERSION)
|
|
23
|
+
return null;
|
|
24
|
+
return parsed;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// No state, or state this version cannot read: a first cycle either way,
|
|
28
|
+
// which is a state the caller reports rather than an error.
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export async function writeWatchState(root, state) {
|
|
33
|
+
const path = join(root, WATCH_STATE_FILE);
|
|
34
|
+
await mkdir(dirname(path), { recursive: true });
|
|
35
|
+
await writeFile(path, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
|
36
|
+
}
|
|
37
|
+
export function checkWebhook(raw) {
|
|
38
|
+
let url;
|
|
39
|
+
try {
|
|
40
|
+
url = new URL(raw);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return { ok: false, reason: 'invalid-url' };
|
|
44
|
+
}
|
|
45
|
+
if (url.username !== '' || url.password !== '') {
|
|
46
|
+
return { ok: false, reason: 'credentials-in-url' };
|
|
47
|
+
}
|
|
48
|
+
const loopback = url.hostname === 'localhost' ||
|
|
49
|
+
url.hostname === '127.0.0.1' ||
|
|
50
|
+
url.hostname === '[::1]' ||
|
|
51
|
+
url.hostname === '::1';
|
|
52
|
+
if (url.protocol === 'https:')
|
|
53
|
+
return { ok: true, url };
|
|
54
|
+
if (url.protocol === 'http:' && loopback)
|
|
55
|
+
return { ok: true, url };
|
|
56
|
+
return { ok: false, reason: 'insecure-scheme' };
|
|
57
|
+
}
|
|
58
|
+
export async function postWebhook(url, alert, fetchImpl = fetch) {
|
|
59
|
+
try {
|
|
60
|
+
const response = await fetchImpl(url.toString(), {
|
|
61
|
+
...SAFE_FETCH_INIT,
|
|
62
|
+
method: 'POST',
|
|
63
|
+
headers: { 'content-type': 'application/json' },
|
|
64
|
+
body: JSON.stringify(alert),
|
|
65
|
+
signal: AbortSignal.timeout(10_000),
|
|
66
|
+
});
|
|
67
|
+
return { ok: response.ok, status: response.status, error: null };
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
/**
|
|
71
|
+
* A webhook that will not deliver must not take the alert down with it.
|
|
72
|
+
* The exit code and the stdout event have already carried the crossing;
|
|
73
|
+
* losing those because a receiver is down would make the quietest failure
|
|
74
|
+
* the loudest one.
|
|
75
|
+
*/
|
|
76
|
+
return { ok: false, status: null, error: error instanceof Error ? error.message : String(error) };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
//# sourceMappingURL=watch-run.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"watch-run.js","sourceRoot":"","sources":["../src/watch-run.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAE,KAAK,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAC9D,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAC1C,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAGpD,MAAM,CAAC,MAAM,gBAAgB,GAAG,oBAAoB,CAAC;AAErD,MAAM,CAAC,MAAM,mBAAmB,GAAG,CAAC,CAAC;AAYrC,MAAM,CAAC,KAAK,UAAU,cAAc,CAAC,IAAY;IAC/C,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,QAAQ,CAAC,IAAI,CAAC,IAAI,EAAE,gBAAgB,CAAC,EAAE,MAAM,CAAC,CAAe,CAAC;QAC9F,IAAI,MAAM,EAAE,CAAC,KAAK,mBAAmB;YAAE,OAAO,IAAI,CAAC;QACnD,OAAO,MAAM,CAAC;IAChB,CAAC;IAAC,MAAM,CAAC;QACP,yEAAyE;QACzE,4DAA4D;QAC5D,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,eAAe,CAAC,IAAY,EAAE,KAAiB;IACnE,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,EAAE,gBAAgB,CAAC,CAAC;IAC1C,MAAM,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAChD,MAAM,SAAS,CAAC,IAAI,EAAE,GAAG,IAAI,CAAC,SAAS,CAAC,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;AAChF,CAAC;AAmBD,MAAM,UAAU,YAAY,CAAC,GAAW;IACtC,IAAI,GAAQ,CAAC;IACb,IAAI,CAAC;QACH,GAAG,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC;IACrB,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,aAAa,EAAE,CAAC;IAC9C,CAAC;IACD,IAAI,GAAG,CAAC,QAAQ,KAAK,EAAE,IAAI,GAAG,CAAC,QAAQ,KAAK,EAAE,EAAE,CAAC;QAC/C,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,oBAAoB,EAAE,CAAC;IACrD,CAAC;IACD,MAAM,QAAQ,GACZ,GAAG,CAAC,QAAQ,KAAK,WAAW;QAC5B,GAAG,CAAC,QAAQ,KAAK,WAAW;QAC5B,GAAG,CAAC,QAAQ,KAAK,OAAO;QACxB,GAAG,CAAC,QAAQ,KAAK,KAAK,CAAC;IACzB,IAAI,GAAG,CAAC,QAAQ,KAAK,QAAQ;QAAE,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,GAAG,EAAE,CAAC;IACxD,IAAI,GAAG,CAAC,QAAQ,KAAK,OAAO,IAAI,QAAQ;QAAE,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,GAAG,EAAE,CAAC;IACnE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,iBAAiB,EAAE,CAAC;AAClD,CAAC;AAgBD,MAAM,CAAC,KAAK,UAAU,WAAW,CAC/B,GAAQ,EACR,KAAiB,EACjB,SAAS,GAAiB,KAAK;IAE/B,IAAI,CAAC;QACH,MAAM,QAAQ,GAAG,MAAM,SAAS,CAAC,GAAG,CAAC,QAAQ,EAAE,EAAE;YAC/C,GAAG,eAAe;YAClB,MAAM,EAAE,MAAM;YACd,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;YAC/C,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC;YAC3B,MAAM,EAAE,WAAW,CAAC,OAAO,CAAC,MAAM,CAAC;SACpC,CAAC,CAAC;QACH,OAAO,EAAE,EAAE,EAAE,QAAQ,CAAC,EAAE,EAAE,MAAM,EAAE,QAAQ,CAAC,MAAM,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC;IACnE,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf;;;;;WAKG;QACH,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC;IACpG,CAAC;AACH,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trazum/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.43.0",
|
|
4
4
|
"description": "Trazum CLI: find where your LLM bill goes, price every finding per month, and enforce token budgets in CI.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "David Mu\u00f1oz Rey",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"prepublishOnly": "npm run build && npm test"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@trazum/core": "1.
|
|
40
|
+
"@trazum/core": "1.43.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@types/node": "^26.2.0",
|
package/src/i18n/en.ts
CHANGED
|
@@ -44,6 +44,7 @@ ${bold('USAGE')}
|
|
|
44
44
|
trazum history <dir-of-stored-reports> [options]
|
|
45
45
|
trazum connect <anthropic|openai> [options]
|
|
46
46
|
trazum store [--prune] [options]
|
|
47
|
+
trazum watch [--once | --interval 15m] [options]
|
|
47
48
|
trazum diff <before> <after> [options]
|
|
48
49
|
trazum diff --all <dir> <dir> [options]
|
|
49
50
|
trazum rank <dir> [options]
|
|
@@ -321,6 +322,35 @@ ${bold('OPTIONS FOR plan')}
|
|
|
321
322
|
a plan that hides its assumptions is advice pretending to be arithmetic.
|
|
322
323
|
Projected savings and money already spent are separate totals throughout.
|
|
323
324
|
|
|
325
|
+
${bold('OPTIONS FOR watch')}
|
|
326
|
+
--once One cycle: measure, keep, evaluate, emit,
|
|
327
|
+
remember. What a cron entry runs. The default.
|
|
328
|
+
--interval <n>m|h Stay in the foreground and repeat. Minimum five
|
|
329
|
+
minutes: usage APIs are rate limited, and a tight
|
|
330
|
+
loop is a way to get your own key throttled.
|
|
331
|
+
--webhook <url> POST the crossings somewhere. https only, except
|
|
332
|
+
loopback; a URL carrying credentials is refused,
|
|
333
|
+
because URLs end up in logs and shell history.
|
|
334
|
+
--payload <file> Evaluate a usage payload you already have,
|
|
335
|
+
instead of the store.
|
|
336
|
+
--json The cycle as data: crossings, abstentions, gap.
|
|
337
|
+
|
|
338
|
+
Evaluates the spend gates from your config — maxUsd, maxDayUsd,
|
|
339
|
+
maxCacheLossUsd — against what has been measured, and tells you the
|
|
340
|
+
afternoon it happens rather than three weeks later. Exits 1 when something
|
|
341
|
+
crossed, so cron mails it and CI fails.
|
|
342
|
+
|
|
343
|
+
An alert fires on a measured crossing and never on a projection: "you have
|
|
344
|
+
spent $412 of a $400 budget" is a fact and "you will exceed" is a forecast,
|
|
345
|
+
which this tool does not make at any window length. A day still being
|
|
346
|
+
measured is reported as not yet judgeable rather than passed — but a day
|
|
347
|
+
already over budget fires whatever the hour, because it does not become less
|
|
348
|
+
over budget at midnight.
|
|
349
|
+
|
|
350
|
+
A restart does not re-alert on a crossing already reported, and names the
|
|
351
|
+
stretch it was not watching, because a watcher that resumes in silence
|
|
352
|
+
implies coverage it did not have.
|
|
353
|
+
|
|
324
354
|
${bold('OPTIONS FOR store')}
|
|
325
355
|
--prune Drop measurements older than the retention
|
|
326
356
|
policy, and compact the append log to what the
|
|
@@ -1506,6 +1536,49 @@ ${bold('EXAMPLES')}
|
|
|
1506
1536
|
`Plan written to ${path}, dated. Keep it: a prediction nobody wrote down is a prediction nobody can be held to.`,
|
|
1507
1537
|
},
|
|
1508
1538
|
|
|
1539
|
+
watch: {
|
|
1540
|
+
noThresholds: () =>
|
|
1541
|
+
'Watching needs something to watch for. Set spend.maxUsd, spend.maxDayUsd or spend.maxCacheLossUsd in trazum.config.json — a watcher with no threshold is a green light nobody earned.',
|
|
1542
|
+
nothingToWatch: (dir) =>
|
|
1543
|
+
`Nothing has been measured yet: the store at ${dir} is empty. Fill it with "trazum connect <provider> --store" first — watching nothing would report that everything is fine.`,
|
|
1544
|
+
intervalTooTight: () =>
|
|
1545
|
+
'--interval must be at least 5m. Usage APIs are rate limited, and a tight loop is a way to get your own key throttled by a tool that exists to save you money.',
|
|
1546
|
+
badWebhook: (reason) =>
|
|
1547
|
+
reason === 'credentials-in-url'
|
|
1548
|
+
? 'That webhook URL carries credentials. URLs end up in logs, shell history and error messages, so this one is refused — put the secret in a header your receiver checks, or in the receiver itself.'
|
|
1549
|
+
: reason === 'insecure-scheme'
|
|
1550
|
+
? 'A webhook must be https, except on loopback. An alert carries your spend figures, and sending them in the clear across a network is a leak you did not ask for.'
|
|
1551
|
+
: 'That webhook is not a URL this tool can parse.',
|
|
1552
|
+
crossed: (gate, measured, limit, day) => {
|
|
1553
|
+
const what =
|
|
1554
|
+
gate === 'maxUsd'
|
|
1555
|
+
? 'Total spend'
|
|
1556
|
+
: gate === 'maxDayUsd'
|
|
1557
|
+
? `Spend on ${day}`
|
|
1558
|
+
: 'Money lost to caching';
|
|
1559
|
+
return `CROSSED — ${what} is ${measured} against a limit of ${limit}. Measured, not projected.`;
|
|
1560
|
+
},
|
|
1561
|
+
stillOver: (gate, measured, limit, day) => {
|
|
1562
|
+
const what =
|
|
1563
|
+
gate === 'maxUsd'
|
|
1564
|
+
? 'Total spend'
|
|
1565
|
+
: gate === 'maxDayUsd'
|
|
1566
|
+
? `Spend on ${day}`
|
|
1567
|
+
: 'Money lost to caching';
|
|
1568
|
+
return `STILL OVER — ${what} is ${measured} against a limit of ${limit}, and was already reported. Quiet is not clean.`;
|
|
1569
|
+
},
|
|
1570
|
+
notJudgeable: (gate, reason, covered) =>
|
|
1571
|
+
reason === 'window-too-short'
|
|
1572
|
+
? `${gate} cannot be judged yet: this period is ${covered ?? 'partly'} measured, and a threshold over part of a day is a threshold over something else. Not a pass — it will be judged when the day is in.`
|
|
1573
|
+
: `${gate} cannot be judged on this source, which does not serve what the gate is written against. Not a pass: a gate silently skipped reads exactly like a gate that keeps passing.`,
|
|
1574
|
+
gap: (from, to) =>
|
|
1575
|
+
`Nothing was watching between ${from} and ${to}. Whatever crossed in that stretch was not seen, and this line exists so a resumed watcher does not imply coverage it did not have.`,
|
|
1576
|
+
allWithin: (gates) => `Within every threshold: ${gates} gates evaluated against measured spend.`,
|
|
1577
|
+
webhookFailed: (status) =>
|
|
1578
|
+
`The webhook did not deliver (${status}). The crossing is still in the exit code and in the output above — a receiver being down must not be the quietest failure in the room.`,
|
|
1579
|
+
watching: (minutes) => `Watching every ${minutes} minutes. Ctrl-C stops it.`,
|
|
1580
|
+
},
|
|
1581
|
+
|
|
1509
1582
|
store: {
|
|
1510
1583
|
appended: (count, dir) => `Kept ${count} measurements in ${dir}.`,
|
|
1511
1584
|
empty: (dir) =>
|
package/src/i18n/es.ts
CHANGED
|
@@ -31,6 +31,7 @@ ${bold('USO')}
|
|
|
31
31
|
trazum history <dir-de-informes-guardados> [opciones]
|
|
32
32
|
trazum connect <anthropic|openai> [opciones]
|
|
33
33
|
trazum store [--prune] [opciones]
|
|
34
|
+
trazum watch [--once | --interval 15m] [opciones]
|
|
34
35
|
trazum diff <antes> <después> [opciones]
|
|
35
36
|
trazum diff --all <dir> <dir> [opciones]
|
|
36
37
|
trazum rank <dir> [opciones]
|
|
@@ -328,6 +329,37 @@ ${bold('OPCIONES DE plan')}
|
|
|
328
329
|
consejo haciéndose pasar por aritmética. El ahorro proyectado y el dinero ya
|
|
329
330
|
gastado son totales separados en todas partes.
|
|
330
331
|
|
|
332
|
+
${bold('OPCIONES DE watch')}
|
|
333
|
+
--once Una vuelta: medir, guardar, evaluar, emitir,
|
|
334
|
+
recordar. Lo que ejecuta una entrada de cron. Es
|
|
335
|
+
lo que se hace por defecto.
|
|
336
|
+
--interval <n>m|h Se queda en primer plano y repite. Mínimo cinco
|
|
337
|
+
minutos: las APIs de uso están limitadas por
|
|
338
|
+
tasa, y un bucle apretado es una forma de que te
|
|
339
|
+
estrangulen tu propia clave.
|
|
340
|
+
--webhook <url> Envía los cruces por POST. Solo https, salvo en
|
|
341
|
+
loopback; una URL con credenciales se rechaza,
|
|
342
|
+
porque las URLs acaban en logs e historiales.
|
|
343
|
+
--payload <fichero> Evalúa un payload de uso que ya tengas, en vez
|
|
344
|
+
del almacén.
|
|
345
|
+
--json La vuelta como datos: cruces, abstenciones, hueco.
|
|
346
|
+
|
|
347
|
+
Evalúa los gates de gasto de tu configuración — maxUsd, maxDayUsd,
|
|
348
|
+
maxCacheLossUsd — contra lo que se ha medido, y te lo dice la tarde en que
|
|
349
|
+
pasa en vez de tres semanas después. Sale con 1 cuando algo cruzó, así que
|
|
350
|
+
cron te lo manda y CI falla.
|
|
351
|
+
|
|
352
|
+
Una alerta salta por un cruce medido y nunca por una proyección: "has
|
|
353
|
+
gastado $412 de un presupuesto de $400" es un hecho y "vas a excederte" es un
|
|
354
|
+
pronóstico, que esta herramienta no hace a ninguna escala de ventana. Un día
|
|
355
|
+
que todavía se está midiendo se reporta como aún no juzgable en vez de
|
|
356
|
+
aprobarse — pero un día que ya se pasó del presupuesto salta a cualquier
|
|
357
|
+
hora, porque no se pasa menos a medianoche.
|
|
358
|
+
|
|
359
|
+
Un reinicio no vuelve a avisar de un cruce ya reportado, y nombra el tramo
|
|
360
|
+
que no estuvo vigilando, porque un vigilante que se reanuda en silencio
|
|
361
|
+
insinúa una cobertura que no tuvo.
|
|
362
|
+
|
|
331
363
|
${bold('OPCIONES DE store')}
|
|
332
364
|
--prune Borra las mediciones más antiguas que la política
|
|
333
365
|
de retención y compacta el log a lo que el
|
|
@@ -1531,6 +1563,49 @@ ${bold('EJEMPLOS')}
|
|
|
1531
1563
|
`Plan escrito en ${path}, con fecha. Guárdalo: una predicción que nadie apuntó es una predicción que no se le puede exigir a nadie.`,
|
|
1532
1564
|
},
|
|
1533
1565
|
|
|
1566
|
+
watch: {
|
|
1567
|
+
noThresholds: () =>
|
|
1568
|
+
'Vigilar necesita algo que vigilar. Define spend.maxUsd, spend.maxDayUsd o spend.maxCacheLossUsd en trazum.config.json — un vigilante sin umbral es una luz verde que nadie se ha ganado.',
|
|
1569
|
+
nothingToWatch: (dir) =>
|
|
1570
|
+
`Todavía no se ha medido nada: el almacén de ${dir} está vacío. Llénalo primero con "trazum connect <proveedor> --store" — vigilar la nada reportaría que todo está bien.`,
|
|
1571
|
+
intervalTooTight: () =>
|
|
1572
|
+
'--interval tiene que ser de al menos 5m. Las APIs de uso están limitadas por tasa, y un bucle apretado es una forma de que una herramienta que existe para ahorrarte dinero acabe estrangulando tu propia clave.',
|
|
1573
|
+
badWebhook: (reason) =>
|
|
1574
|
+
reason === 'credentials-in-url'
|
|
1575
|
+
? 'Esa URL de webhook lleva credenciales. Las URLs acaban en logs, historiales de shell y mensajes de error, así que se rechaza — pon el secreto en una cabecera que tu receptor compruebe, o en el propio receptor.'
|
|
1576
|
+
: reason === 'insecure-scheme'
|
|
1577
|
+
? 'Un webhook tiene que ser https, salvo en loopback. Una alerta lleva tus cifras de gasto, y mandarlas en claro por una red es una fuga que no pediste.'
|
|
1578
|
+
: 'Ese webhook no es una URL que esta herramienta pueda parsear.',
|
|
1579
|
+
crossed: (gate, measured, limit, day) => {
|
|
1580
|
+
const what =
|
|
1581
|
+
gate === 'maxUsd'
|
|
1582
|
+
? 'El gasto total'
|
|
1583
|
+
: gate === 'maxDayUsd'
|
|
1584
|
+
? `El gasto del ${day}`
|
|
1585
|
+
: 'El dinero perdido con la caché';
|
|
1586
|
+
return `CRUZADO — ${what} es ${measured} contra un límite de ${limit}. Medido, no proyectado.`;
|
|
1587
|
+
},
|
|
1588
|
+
stillOver: (gate, measured, limit, day) => {
|
|
1589
|
+
const what =
|
|
1590
|
+
gate === 'maxUsd'
|
|
1591
|
+
? 'El gasto total'
|
|
1592
|
+
: gate === 'maxDayUsd'
|
|
1593
|
+
? `El gasto del ${day}`
|
|
1594
|
+
: 'El dinero perdido con la caché';
|
|
1595
|
+
return `SIGUE POR ENCIMA — ${what} es ${measured} contra un límite de ${limit}, y ya se avisó. Callado no es limpio.`;
|
|
1596
|
+
},
|
|
1597
|
+
notJudgeable: (gate, reason, covered) =>
|
|
1598
|
+
reason === 'window-too-short'
|
|
1599
|
+
? `${gate} todavía no se puede juzgar: este período está medido al ${covered ?? 'parcialmente'}, y un umbral sobre parte de un día es un umbral sobre otra cosa. No es un aprobado — se juzgará cuando el día esté completo.`
|
|
1600
|
+
: `${gate} no se puede juzgar en esta fuente, que no sirve aquello sobre lo que está escrito el gate. No es un aprobado: un gate saltado en silencio se lee exactamente igual que un gate que lleva tiempo pasando.`,
|
|
1601
|
+
gap: (from, to) =>
|
|
1602
|
+
`Nada estuvo vigilando entre ${from} y ${to}. Lo que cruzara en ese tramo no se vio, y esta línea existe para que un vigilante reanudado no insinúe una cobertura que no tuvo.`,
|
|
1603
|
+
allWithin: (gates) => `Dentro de todos los umbrales: ${gates} gates evaluados contra gasto medido.`,
|
|
1604
|
+
webhookFailed: (status) =>
|
|
1605
|
+
`El webhook no se entregó (${status}). El cruce sigue en el código de salida y en la salida de arriba — que un receptor esté caído no puede ser el fallo más silencioso de la sala.`,
|
|
1606
|
+
watching: (minutes) => `Vigilando cada ${minutes} minutos. Ctrl-C lo para.`,
|
|
1607
|
+
},
|
|
1608
|
+
|
|
1534
1609
|
store: {
|
|
1535
1610
|
appended: (count, dir) => `Guardadas ${count} mediciones en ${dir}.`,
|
|
1536
1611
|
empty: (dir) =>
|
package/src/i18n/types.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CannotTellReason, EvalVerdict, Locale, PlanActionKind, PlanAssumption, RuleLevel, VerifyOutcome } from '@trazum/core';
|
|
1
|
+
import type { CannotTellReason, EvalVerdict, Locale, NotJudgeable, PlanActionKind, PlanAssumption, RuleLevel, VerifyOutcome, WatchGate } from '@trazum/core';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* The CLI's own message catalogue.
|
|
@@ -1072,6 +1072,32 @@ export interface CliMessages {
|
|
|
1072
1072
|
wrote(path: string): string;
|
|
1073
1073
|
};
|
|
1074
1074
|
|
|
1075
|
+
/**
|
|
1076
|
+
* `trazum watch` — the gates, evaluated as the money moves.
|
|
1077
|
+
*
|
|
1078
|
+
* The copy here carries the rule that makes an alert at 3am trustworthy: a
|
|
1079
|
+
* crossing is measured, never projected, and a period too short to judge is
|
|
1080
|
+
* said to be rather than passed.
|
|
1081
|
+
*/
|
|
1082
|
+
watch: {
|
|
1083
|
+
/** Watching with no threshold configured is a green light nobody earned. */
|
|
1084
|
+
noThresholds(): string;
|
|
1085
|
+
nothingToWatch(dir: string): string;
|
|
1086
|
+
intervalTooTight(): string;
|
|
1087
|
+
badWebhook(reason: 'invalid-url' | 'credentials-in-url' | 'insecure-scheme'): string;
|
|
1088
|
+
/** A measured crossing. `day` names the afternoon when the gate is a day gate. */
|
|
1089
|
+
crossed(gate: WatchGate, measured: string, limit: string, day: string | null): string;
|
|
1090
|
+
/** Still over the limit, and already reported — quiet, but not clean. */
|
|
1091
|
+
stillOver(gate: WatchGate, measured: string, limit: string, day: string | null): string;
|
|
1092
|
+
/** Neither a pass nor a failure: this cannot be judged yet, and why. */
|
|
1093
|
+
notJudgeable(gate: WatchGate, reason: NotJudgeable, covered: string | null): string;
|
|
1094
|
+
/** The stretch nobody was watching, named rather than implied away. */
|
|
1095
|
+
gap(from: string, to: string): string;
|
|
1096
|
+
allWithin(gates: string): string;
|
|
1097
|
+
webhookFailed(status: string): string;
|
|
1098
|
+
watching(minutes: string): string;
|
|
1099
|
+
};
|
|
1100
|
+
|
|
1075
1101
|
/**
|
|
1076
1102
|
* `trazum store` — the measurements kept on disk.
|
|
1077
1103
|
*
|
package/src/index.ts
CHANGED
|
@@ -20,6 +20,8 @@ import {
|
|
|
20
20
|
normalizeAnthropicUsage,
|
|
21
21
|
normalizeOpenAIUsage,
|
|
22
22
|
bucketsFromRecords,
|
|
23
|
+
evaluateWatch,
|
|
24
|
+
firedKey,
|
|
23
25
|
pruneRecords,
|
|
24
26
|
recordsFromBuckets,
|
|
25
27
|
storeInventory,
|
|
@@ -146,6 +148,13 @@ import {
|
|
|
146
148
|
import type { Revision } from './git.js';
|
|
147
149
|
import { fetchProviderUsage } from './connect.js';
|
|
148
150
|
import { STORE_DIR, appendRecords, readStore, rewriteStore } from './store-fs.js';
|
|
151
|
+
import {
|
|
152
|
+
WATCH_STATE_VERSION,
|
|
153
|
+
checkWebhook,
|
|
154
|
+
postWebhook,
|
|
155
|
+
readWatchState,
|
|
156
|
+
writeWatchState,
|
|
157
|
+
} from './watch-run.js';
|
|
149
158
|
import { detectLocale, getCliMessages } from './i18n/index.js';
|
|
150
159
|
import {
|
|
151
160
|
MAX_SUMMARY_CHARS,
|
|
@@ -196,6 +205,8 @@ const VALUE_FLAGS = new Set([
|
|
|
196
205
|
'min-usd',
|
|
197
206
|
'payload',
|
|
198
207
|
'keep',
|
|
208
|
+
'interval',
|
|
209
|
+
'webhook',
|
|
199
210
|
// `route` takes a path here, and the flag is deliberately not `--prompt`:
|
|
200
211
|
// everywhere else in this tool `--prompt` names a marked prompt *inside* a
|
|
201
212
|
// source file, and reusing it for a path would be a trap laid for the reader.
|
|
@@ -501,6 +512,7 @@ const COMMAND_FLAGS: Record<string, string[]> = {
|
|
|
501
512
|
history: ['store', 'json', 'markdown-out'],
|
|
502
513
|
connect: ['since', 'until', 'payload', 'store', 'json', 'out', 'markdown-out', 'pricing', 'pricing-live', 'dry-run'],
|
|
503
514
|
store: ['prune', 'keep', 'json', 'pricing', 'pricing-live', 'dry-run'],
|
|
515
|
+
watch: ['once', 'interval', 'since', 'payload', 'webhook', 'json', 'pricing', 'pricing-live'],
|
|
504
516
|
route: ['prompt-file', 'cases', 'label', 'concurrency', 'json', 'yes', 'pricing', 'pricing-live'],
|
|
505
517
|
eval: ['cases', 'level', 'concurrency', 'export', 'out', 'o', 'model'],
|
|
506
518
|
prune: ['cases', 'concurrency', 'json', 'yes'],
|
|
@@ -2282,6 +2294,213 @@ function parseWhen(
|
|
|
2282
2294
|
throw new Error(t.profile.badWhen(flag, value));
|
|
2283
2295
|
}
|
|
2284
2296
|
|
|
2297
|
+
/**
|
|
2298
|
+
* `trazum watch` — the afternoon it happened, said that afternoon.
|
|
2299
|
+
*
|
|
2300
|
+
* One cycle is the primitive: measure, keep, evaluate, emit, remember. The
|
|
2301
|
+
* loop is that cycle in a timer, so a cron entry and a foreground watcher run
|
|
2302
|
+
* exactly the same code and the tests exercise the thing that ships.
|
|
2303
|
+
*
|
|
2304
|
+
* Three transports, all boring on purpose: a non-zero exit code so cron mails
|
|
2305
|
+
* it, a JSON event on stdout so any pipeline can read it, and a webhook for
|
|
2306
|
+
* the operator who already has somewhere for alerts to go. No hosted service
|
|
2307
|
+
* and no account.
|
|
2308
|
+
*/
|
|
2309
|
+
async function commandWatch(
|
|
2310
|
+
args: Args,
|
|
2311
|
+
config: TrazumConfig,
|
|
2312
|
+
pricing: PricingCatalogue,
|
|
2313
|
+
t: CliMessages,
|
|
2314
|
+
): Promise<void> {
|
|
2315
|
+
const root = process.cwd();
|
|
2316
|
+
const asJson = boolFlag(args, 'json');
|
|
2317
|
+
const n = (value: number): string => value.toLocaleString(t.numberLocale);
|
|
2318
|
+
const day = (msValue: number): string => new Date(msValue).toISOString().slice(0, 10);
|
|
2319
|
+
|
|
2320
|
+
const thresholds = {
|
|
2321
|
+
maxUsd: config.spend?.maxUsd,
|
|
2322
|
+
maxDayUsd: config.spend?.maxDayUsd,
|
|
2323
|
+
maxCacheLossUsd: config.spend?.maxCacheLossUsd,
|
|
2324
|
+
};
|
|
2325
|
+
if (
|
|
2326
|
+
thresholds.maxUsd === undefined &&
|
|
2327
|
+
thresholds.maxDayUsd === undefined &&
|
|
2328
|
+
thresholds.maxCacheLossUsd === undefined
|
|
2329
|
+
) {
|
|
2330
|
+
throw new Error(t.watch.noThresholds());
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
/**
|
|
2334
|
+
* A webhook is a new outbound surface, so it is checked before anything is
|
|
2335
|
+
* sent: credentials in a URL end up in logs and shell history, and an alert
|
|
2336
|
+
* carrying spend figures over plain http across a network is a leak the
|
|
2337
|
+
* operator did not ask for. Loopback is the exception, because pointing a
|
|
2338
|
+
* watcher at your own alerting daemon is the ordinary case.
|
|
2339
|
+
*/
|
|
2340
|
+
const webhookRaw = stringFlag(args, 'webhook');
|
|
2341
|
+
let webhook: URL | null = null;
|
|
2342
|
+
if (webhookRaw !== undefined) {
|
|
2343
|
+
const checked = checkWebhook(webhookRaw);
|
|
2344
|
+
if (!checked.ok) throw new Error(t.watch.badWebhook(checked.reason));
|
|
2345
|
+
webhook = checked.url;
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
const intervalRaw = stringFlag(args, 'interval');
|
|
2349
|
+
const once = boolFlag(args, 'once') || intervalRaw === undefined;
|
|
2350
|
+
let intervalMs = 0;
|
|
2351
|
+
if (!once) {
|
|
2352
|
+
const match = /^(\d+)(m|h)$/.exec(intervalRaw!);
|
|
2353
|
+
const amount = match === null ? NaN : Number(match[1]);
|
|
2354
|
+
intervalMs = match?.[2] === 'h' ? amount * 3_600_000 : amount * 60_000;
|
|
2355
|
+
// Usage APIs are rate limited, and a tight loop is a way to get somebody's
|
|
2356
|
+
// key throttled by a tool that was supposed to save them money.
|
|
2357
|
+
if (!Number.isFinite(intervalMs) || intervalMs < 5 * 60_000) {
|
|
2358
|
+
throw new Error(t.watch.intervalTooTight());
|
|
2359
|
+
}
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
const cycle = async (): Promise<number> => {
|
|
2363
|
+
const state = await readWatchState(root);
|
|
2364
|
+
const nowMs = Date.now();
|
|
2365
|
+
|
|
2366
|
+
/**
|
|
2367
|
+
* Where the measurements come from: a saved payload when one is named
|
|
2368
|
+
* (which is how this is tested and how an air-gapped run works), and the
|
|
2369
|
+
* store otherwise. A cycle that found nothing to measure says so — a
|
|
2370
|
+
* watcher over nothing is a green light nobody earned.
|
|
2371
|
+
*/
|
|
2372
|
+
const payloadPath = stringFlag(args, 'payload');
|
|
2373
|
+
let pull;
|
|
2374
|
+
if (payloadPath !== undefined) {
|
|
2375
|
+
pull = normalizeAnthropicUsage(JSON.parse(await readFile(payloadPath, 'utf8')));
|
|
2376
|
+
} else {
|
|
2377
|
+
const { resolved } = await readStore(root);
|
|
2378
|
+
if (resolved.records.length === 0) throw new Error(t.watch.nothingToWatch(STORE_DIR));
|
|
2379
|
+
pull = {
|
|
2380
|
+
provider: 'store',
|
|
2381
|
+
granularity: 'bucketed' as const,
|
|
2382
|
+
buckets: bucketsFromRecords(resolved.records),
|
|
2383
|
+
window: null,
|
|
2384
|
+
gaps: [],
|
|
2385
|
+
unavailable: [],
|
|
2386
|
+
};
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
const report = bucketedProfile(pull, { catalogue: pricing });
|
|
2390
|
+
const cache = bucketedCacheEconomics(report);
|
|
2391
|
+
const result = evaluateWatch({
|
|
2392
|
+
report,
|
|
2393
|
+
thresholds,
|
|
2394
|
+
cacheDeltaUsd: cache.verdict === 'no-cache' ? undefined : cache.deltaUsd,
|
|
2395
|
+
nowMs,
|
|
2396
|
+
lastCoveredToMs: state?.lastCoveredToMs ?? undefined,
|
|
2397
|
+
alreadyFired: new Set(Object.keys(state?.fired ?? {})),
|
|
2398
|
+
});
|
|
2399
|
+
|
|
2400
|
+
if (asJson) {
|
|
2401
|
+
console.log(JSON.stringify({ schemaVersion: 1, firedAtMs: nowMs, ...result }, null, 2));
|
|
2402
|
+
} else {
|
|
2403
|
+
if (result.gap !== null) {
|
|
2404
|
+
console.log(c.yellow(wrap(t.watch.gap(day(result.gap.fromMs), day(result.gap.toMs)), 76, ' ')));
|
|
2405
|
+
}
|
|
2406
|
+
for (const crossing of result.crossings) {
|
|
2407
|
+
console.log(
|
|
2408
|
+
c.red(
|
|
2409
|
+
wrap(
|
|
2410
|
+
t.watch.crossed(
|
|
2411
|
+
crossing.gate,
|
|
2412
|
+
formatUsd(crossing.measuredUsd),
|
|
2413
|
+
formatUsd(crossing.limitUsd),
|
|
2414
|
+
crossing.day,
|
|
2415
|
+
),
|
|
2416
|
+
76,
|
|
2417
|
+
' ',
|
|
2418
|
+
),
|
|
2419
|
+
),
|
|
2420
|
+
);
|
|
2421
|
+
}
|
|
2422
|
+
for (const abstention of result.abstentions) {
|
|
2423
|
+
console.log(
|
|
2424
|
+
c.dim(
|
|
2425
|
+
wrap(
|
|
2426
|
+
t.watch.notJudgeable(
|
|
2427
|
+
abstention.gate,
|
|
2428
|
+
abstention.reason,
|
|
2429
|
+
abstention.detail === null
|
|
2430
|
+
? null
|
|
2431
|
+
: `${Math.round((abstention.detail.coveredMs / abstention.detail.neededMs) * 100)}%`,
|
|
2432
|
+
),
|
|
2433
|
+
76,
|
|
2434
|
+
' ',
|
|
2435
|
+
),
|
|
2436
|
+
),
|
|
2437
|
+
);
|
|
2438
|
+
}
|
|
2439
|
+
for (const still of result.suppressed) {
|
|
2440
|
+
console.log(
|
|
2441
|
+
c.yellow(
|
|
2442
|
+
wrap(
|
|
2443
|
+
t.watch.stillOver(
|
|
2444
|
+
still.gate,
|
|
2445
|
+
formatUsd(still.measuredUsd),
|
|
2446
|
+
formatUsd(still.limitUsd),
|
|
2447
|
+
still.day,
|
|
2448
|
+
),
|
|
2449
|
+
76,
|
|
2450
|
+
' ',
|
|
2451
|
+
),
|
|
2452
|
+
),
|
|
2453
|
+
);
|
|
2454
|
+
}
|
|
2455
|
+
if (
|
|
2456
|
+
result.crossings.length === 0 &&
|
|
2457
|
+
result.suppressed.length === 0 &&
|
|
2458
|
+
result.abstentions.length === 0
|
|
2459
|
+
) {
|
|
2460
|
+
console.log(c.green(wrap(t.watch.allWithin(n(Object.keys(thresholds).filter((k) => thresholds[k as keyof typeof thresholds] !== undefined).length)), 76, ' ')));
|
|
2461
|
+
}
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2464
|
+
if (webhook !== null && result.crossings.length > 0) {
|
|
2465
|
+
const sent = await postWebhook(webhook, {
|
|
2466
|
+
schemaVersion: 1,
|
|
2467
|
+
firedAtMs: nowMs,
|
|
2468
|
+
crossings: result.crossings,
|
|
2469
|
+
});
|
|
2470
|
+
if (!sent.ok) {
|
|
2471
|
+
// Reported and swallowed: the exit code and the event already carried
|
|
2472
|
+
// the crossing, and losing those because a receiver is down would make
|
|
2473
|
+
// the quietest failure the loudest one.
|
|
2474
|
+
console.error(c.yellow(t.watch.webhookFailed(sent.status === null ? sent.error ?? '' : String(sent.status))));
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
const fired = { ...(state?.fired ?? {}) };
|
|
2479
|
+
for (const crossing of result.crossings) fired[firedKey(crossing.gate, crossing.day)] = nowMs;
|
|
2480
|
+
await writeWatchState(root, {
|
|
2481
|
+
v: WATCH_STATE_VERSION,
|
|
2482
|
+
lastCycleMs: nowMs,
|
|
2483
|
+
lastCoveredToMs: report.span?.toMs ?? state?.lastCoveredToMs ?? null,
|
|
2484
|
+
fired,
|
|
2485
|
+
});
|
|
2486
|
+
|
|
2487
|
+
return result.crossings.length + result.suppressed.length;
|
|
2488
|
+
};
|
|
2489
|
+
|
|
2490
|
+
const crossed = await cycle();
|
|
2491
|
+
// Still over is still a failure: only the alert was already sent.
|
|
2492
|
+
if (crossed > 0) process.exitCode = 1;
|
|
2493
|
+
if (once) return;
|
|
2494
|
+
|
|
2495
|
+
console.log(c.dim(t.watch.watching(String(Math.round(intervalMs / 60_000)))));
|
|
2496
|
+
// The loop is the cycle in a timer and nothing more, so the primitive above
|
|
2497
|
+
// is the only thing that ever needs testing.
|
|
2498
|
+
for (;;) {
|
|
2499
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
2500
|
+
await cycle();
|
|
2501
|
+
}
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2285
2504
|
/**
|
|
2286
2505
|
* `trazum store` — what is kept, and what a prune would take.
|
|
2287
2506
|
*
|
|
@@ -6866,6 +7085,9 @@ async function main(): Promise<void> {
|
|
|
6866
7085
|
case 'store':
|
|
6867
7086
|
await commandStore(args, config, pricing, t);
|
|
6868
7087
|
break;
|
|
7088
|
+
case 'watch':
|
|
7089
|
+
await commandWatch(args, config, pricing, t);
|
|
7090
|
+
break;
|
|
6869
7091
|
case 'route':
|
|
6870
7092
|
await commandRoute(args, pricing, t);
|
|
6871
7093
|
break;
|