@mikitasazan/notify 1.11.0 → 1.12.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 +17 -0
- package/dist/cli-flags.js +2 -1
- package/dist/cli.js +44 -6
- package/dist/events.d.ts +74 -17
- package/dist/lint.js +56 -15
- package/dist/render.d.ts +8 -5
- package/dist/render.js +249 -90
- package/dist/send.d.ts +7 -0
- package/dist/send.js +165 -10
- package/dist/trend.d.ts +9 -6
- package/dist/trend.js +21 -14
- package/package.json +1 -1
package/dist/send.js
CHANGED
|
@@ -15,9 +15,10 @@
|
|
|
15
15
|
* allowed to bring down the deploy or the scheduled task that called it.
|
|
16
16
|
*/
|
|
17
17
|
import { execFileSync } from 'node:child_process';
|
|
18
|
-
import { readFileSync } from 'node:fs';
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
18
|
+
import { appendFileSync, mkdirSync, readFileSync, renameSync, rmdirSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { homedir } from 'node:os';
|
|
20
|
+
import { basename, dirname, join } from 'node:path';
|
|
21
|
+
import { eventKey, outcomeTag, render } from "./render.js";
|
|
21
22
|
import { lintCard } from "./lint.js";
|
|
22
23
|
import { ROUTES, targets } from "./routes.js";
|
|
23
24
|
const log = (msg) => {
|
|
@@ -257,6 +258,99 @@ const sendFile = async (e) => {
|
|
|
257
258
|
}
|
|
258
259
|
return results.includes('sent') ? 'sent' : 'failed';
|
|
259
260
|
};
|
|
261
|
+
/**
|
|
262
|
+
* Repeat suppression (v2.1). A failure that is still the same failure does
|
|
263
|
+
* not resend every run: the first card goes out, repeats inside the window
|
|
264
|
+
* are swallowed, and past the window ONE card a day goes out carrying
|
|
265
|
+
* `Still red: day N`. A green outcome clears the record — without that, a
|
|
266
|
+
* new failure would inherit the old one's day counter and the recovery
|
|
267
|
+
* itself would never be told.
|
|
268
|
+
*
|
|
269
|
+
* The key is project + type + instance — DELIBERATELY no free text: both
|
|
270
|
+
* external reviews independently showed that normalizing prose (stripping
|
|
271
|
+
* digits, hexes, paths) merges different failures — "connect to host A" and
|
|
272
|
+
* "connect to host B" become one key and the second failure goes silent. A
|
|
273
|
+
* job that covers several targets owes each target its own `--key`.
|
|
274
|
+
*
|
|
275
|
+
* Every failure of the mechanism itself fails OPEN: a broken state file, a
|
|
276
|
+
* held lock, an unwritable directory all mean "send". A duplicate card is a
|
|
277
|
+
* small cost; a swallowed alarm is not.
|
|
278
|
+
*/
|
|
279
|
+
const WINDOW_MS = 20 * 3600_000;
|
|
280
|
+
const DAY_MS = 24 * 3600_000;
|
|
281
|
+
const statePath = () => process.env.NOTIFY_STATE?.trim() || join(homedir(), '.claude', '.runs', 'notify-sent.json');
|
|
282
|
+
/** Exported for tests only — the time is injectable so day counting is provable. */
|
|
283
|
+
export const dedupe = (e, now = Date.now()) => {
|
|
284
|
+
const file = statePath();
|
|
285
|
+
const lock = `${file}.lock`;
|
|
286
|
+
try {
|
|
287
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
288
|
+
// The lock is a directory: mkdir is atomic on every filesystem this runs
|
|
289
|
+
// on. A held lock means another sender is mid-write — fail open.
|
|
290
|
+
mkdirSync(lock);
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
return { action: 'send' };
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
let state = {};
|
|
297
|
+
try {
|
|
298
|
+
state = JSON.parse(readFileSync(file, 'utf-8'));
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
state = {}; // missing or broken JSON — start clean, never swallow
|
|
302
|
+
}
|
|
303
|
+
const key = `${String(e.project)}:${String(e.type)}:${eventKey(e)}`;
|
|
304
|
+
const rec = state[key];
|
|
305
|
+
const write = () => {
|
|
306
|
+
const tmp = `${file}.tmp`;
|
|
307
|
+
writeFileSync(tmp, JSON.stringify(state));
|
|
308
|
+
renameSync(tmp, file);
|
|
309
|
+
};
|
|
310
|
+
if (outcomeTag(e) !== 'fail') {
|
|
311
|
+
if (rec) {
|
|
312
|
+
delete state[key];
|
|
313
|
+
write();
|
|
314
|
+
}
|
|
315
|
+
return { action: 'send' };
|
|
316
|
+
}
|
|
317
|
+
if (!rec) {
|
|
318
|
+
state[key] = { first: new Date(now).toISOString(), last: new Date(now).toISOString(), count: 0 };
|
|
319
|
+
write();
|
|
320
|
+
return { action: 'send' };
|
|
321
|
+
}
|
|
322
|
+
const last = Date.parse(rec.last);
|
|
323
|
+
const first = Date.parse(rec.first);
|
|
324
|
+
if (Number.isNaN(last) || Number.isNaN(first)) {
|
|
325
|
+
delete state[key];
|
|
326
|
+
write();
|
|
327
|
+
return { action: 'send' };
|
|
328
|
+
}
|
|
329
|
+
if (now - last < WINDOW_MS) {
|
|
330
|
+
rec.count += 1;
|
|
331
|
+
write();
|
|
332
|
+
log(`suppressed: same failure "${key}" already reported ${rec.count} time(s) in the window`);
|
|
333
|
+
return { action: 'suppress' };
|
|
334
|
+
}
|
|
335
|
+
rec.last = new Date(now).toISOString();
|
|
336
|
+
write();
|
|
337
|
+
// Day 1 is the day the first card went out; the counter only appears
|
|
338
|
+
// from day 2 on — "Still red: day 1" would restate the card itself.
|
|
339
|
+
const day = Math.floor((now - first) / DAY_MS) + 1;
|
|
340
|
+
return { action: 'send', ...(day >= 2 ? { stillRed: day } : {}) };
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
return { action: 'send' };
|
|
344
|
+
}
|
|
345
|
+
finally {
|
|
346
|
+
try {
|
|
347
|
+
rmdirSync(lock);
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
// the lock directory is gone or never ours — nothing to release
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
};
|
|
260
354
|
/**
|
|
261
355
|
* Sends the event to all of its targets (the project topic, plus
|
|
262
356
|
* `incidents` if needed, plus the team chat). Targets are handled one after
|
|
@@ -281,6 +375,7 @@ const reportLostProject = async (project, kind) => {
|
|
|
281
375
|
job: 'notify: an event was lost',
|
|
282
376
|
status: 'fail',
|
|
283
377
|
note: `project "${String(project)}" is not in ROUTES — event "${kind}" went nowhere`,
|
|
378
|
+
check: 'npx --yes @mikitasazan/notify routes --json',
|
|
284
379
|
key: 'notify-unknown-project'
|
|
285
380
|
};
|
|
286
381
|
await deliver(targets(lost), render(lost)).catch(() => undefined);
|
|
@@ -290,21 +385,70 @@ const reportLostProject = async (project, kind) => {
|
|
|
290
385
|
* worth losing over its own formatting — and the breach is raised as its own
|
|
291
386
|
* red card, the way a lost project is.
|
|
292
387
|
*
|
|
388
|
+
* The watchdog card names the offender: the first two content lines of the
|
|
389
|
+
* card it complains about, quoted verbatim under `Offender:` so the owner
|
|
390
|
+
* can find it in the feed. Its `Check:` is a real command (the mockups'
|
|
391
|
+
* `lint-text < card.txt` pointed at a file the owner does not have — a fake
|
|
392
|
+
* pointer is worse than none), and the offender's full text goes to the
|
|
393
|
+
* failure log the session start already reads.
|
|
394
|
+
*
|
|
293
395
|
* `key` carries the type, so a renderer that starts producing broken deploy
|
|
294
|
-
* cards raises one running complaint rather than a new one every hour
|
|
295
|
-
*
|
|
396
|
+
* cards raises one running complaint rather than a new one every hour — and
|
|
397
|
+
* the card passes through the same `dedupe` as any failure, so it cannot
|
|
398
|
+
* loop daily on one unfixed offender.
|
|
399
|
+
*
|
|
400
|
+
* The watchdog lints ITSELF (v2.1 — the v1 card failed its own lint and
|
|
401
|
+
* nothing noticed). If its own card is at fault it still goes out, and the
|
|
402
|
+
* breach lands in the failure log: silence is the one thing it may not do.
|
|
296
403
|
*/
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
|
|
404
|
+
const failuresLog = () => process.env.NOTIFY_FAILLOG?.trim() || join(homedir(), '.claude', '.runs', 'notify-fail.failures.log');
|
|
405
|
+
const journal = (line) => {
|
|
406
|
+
try {
|
|
407
|
+
const file = failuresLog();
|
|
408
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
409
|
+
appendFileSync(file, `${new Date().toISOString()} ${line}\n`);
|
|
410
|
+
}
|
|
411
|
+
catch {
|
|
412
|
+
// the journal is best-effort: a card must never be lost to bookkeeping
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
/** Exported for tests: the watchdog's own card must provably pass the lint. */
|
|
416
|
+
export const brokenCardEvent = (e, faults, offenderHtml) => {
|
|
417
|
+
const offender = offenderHtml
|
|
418
|
+
.split('\n')
|
|
419
|
+
.slice(1) // the tag line names nothing a human reads
|
|
420
|
+
.map((r) => r.replace(/<[^>]+>/g, '').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&').trim())
|
|
421
|
+
.filter((r) => r !== '')
|
|
422
|
+
.slice(0, 2)
|
|
423
|
+
.join('\n');
|
|
424
|
+
return {
|
|
300
425
|
type: 'job',
|
|
301
426
|
project: 'mac-config',
|
|
302
427
|
job: 'notify: a card broke the standard',
|
|
303
428
|
status: 'fail',
|
|
304
429
|
note: `${String(e.type)} card for ${String(e.project)}: ${faults.join('; ')}`,
|
|
430
|
+
detail: offender || undefined,
|
|
431
|
+
detailLabel: 'Offender',
|
|
432
|
+
check: 'config jobs --log notify-broken',
|
|
305
433
|
key: `notify-broken-${String(e.type)}`
|
|
306
434
|
};
|
|
307
|
-
|
|
435
|
+
};
|
|
436
|
+
const reportBrokenCard = async (e, faults, offenderHtml) => {
|
|
437
|
+
log(`card does not match the standard: ${faults.join('; ')}`);
|
|
438
|
+
const broken = brokenCardEvent(e, faults, offenderHtml);
|
|
439
|
+
const dup = dedupe(broken);
|
|
440
|
+
if (dup.action === 'suppress') {
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
if (dup.stillRed) {
|
|
444
|
+
broken.stillRed = dup.stillRed;
|
|
445
|
+
}
|
|
446
|
+
const html = render(broken);
|
|
447
|
+
const ownFaults = lintCard(html);
|
|
448
|
+
if (ownFaults.length > 0) {
|
|
449
|
+
journal(`notify-watchdog: own card failed lint: ${ownFaults.join('; ')}`);
|
|
450
|
+
}
|
|
451
|
+
await deliver(targets(broken), html).catch(() => undefined);
|
|
308
452
|
};
|
|
309
453
|
export const notify = async (e) => {
|
|
310
454
|
// Object.hasOwn, not `in`: `in` walks the prototype chain, and
|
|
@@ -313,6 +457,17 @@ export const notify = async (e) => {
|
|
|
313
457
|
await reportLostProject(e.project, String(e.type));
|
|
314
458
|
return 'skipped';
|
|
315
459
|
}
|
|
460
|
+
// The same unresolved failure does not resend: the window swallows it, one
|
|
461
|
+
// card a day carries `Still red: day N`. Suppression reports 'sent' to the
|
|
462
|
+
// caller — the words sent/failed/skipped are a watchdog contract about the
|
|
463
|
+
// delivery PIPELINE, and a deliberate swallow is the pipeline working.
|
|
464
|
+
const dup = dedupe(e);
|
|
465
|
+
if (dup.action === 'suppress') {
|
|
466
|
+
return 'sent';
|
|
467
|
+
}
|
|
468
|
+
if (dup.stillRed) {
|
|
469
|
+
e.stillRed = dup.stillRed;
|
|
470
|
+
}
|
|
316
471
|
// A card with a file becomes the caption of that file — one card, not two.
|
|
317
472
|
if (e.path) {
|
|
318
473
|
return sendFile(e);
|
|
@@ -323,7 +478,7 @@ export const notify = async (e) => {
|
|
|
323
478
|
const result = await deliver(targets(e), html);
|
|
324
479
|
const faults = lintCard(html);
|
|
325
480
|
if (faults.length > 0) {
|
|
326
|
-
await reportBrokenCard(e, faults);
|
|
481
|
+
await reportBrokenCard(e, faults, html);
|
|
327
482
|
}
|
|
328
483
|
return result;
|
|
329
484
|
};
|
package/dist/trend.d.ts
CHANGED
|
@@ -10,15 +10,18 @@
|
|
|
10
10
|
* So the shape is not a sender's business any more. It lives here, one
|
|
11
11
|
* implementation, and every report calls it:
|
|
12
12
|
*
|
|
13
|
-
* trend(210, 207) → '
|
|
14
|
-
* trend(202, 207) → '
|
|
13
|
+
* trend(210, 207) → '207 / 210 ▲3'
|
|
14
|
+
* trend(202, 207) → '207 / 202 ▼5'
|
|
15
15
|
* trend(0, 0) → '0 / 0 ='
|
|
16
16
|
* trend(37) → '37' nothing to compare to, so no mark
|
|
17
|
-
* trend(4.4, 3.6, '%') → '
|
|
17
|
+
* trend(4.4, 3.6, '%') → '3.6% / 4.4% ▲0.8'
|
|
18
18
|
*
|
|
19
|
-
* Both numbers are printed,
|
|
20
|
-
* `51 ▲5` and asked what the 5
|
|
21
|
-
* was the
|
|
19
|
+
* Both numbers are printed, old first, new second — left is what it was,
|
|
20
|
+
* right is what it became. The owner read `51 ▲5` first and asked what the 5
|
|
21
|
+
* was — the new value or the old one. Neither: it was the distance between
|
|
22
|
+
* two numbers, one of which the card never showed. Once both were on the
|
|
23
|
+
* card the owner asked for THIS order specifically, so a reader can read the
|
|
24
|
+
* row left to right as a sentence: was, became, and by how much.
|
|
22
25
|
*
|
|
23
26
|
* The rule the owner asked for, in one line: where there is data to compare
|
|
24
27
|
* against, the arrow is printed; where there is none, nothing is printed —
|
package/dist/trend.js
CHANGED
|
@@ -10,15 +10,18 @@
|
|
|
10
10
|
* So the shape is not a sender's business any more. It lives here, one
|
|
11
11
|
* implementation, and every report calls it:
|
|
12
12
|
*
|
|
13
|
-
* trend(210, 207) → '
|
|
14
|
-
* trend(202, 207) → '
|
|
13
|
+
* trend(210, 207) → '207 / 210 ▲3'
|
|
14
|
+
* trend(202, 207) → '207 / 202 ▼5'
|
|
15
15
|
* trend(0, 0) → '0 / 0 ='
|
|
16
16
|
* trend(37) → '37' nothing to compare to, so no mark
|
|
17
|
-
* trend(4.4, 3.6, '%') → '
|
|
17
|
+
* trend(4.4, 3.6, '%') → '3.6% / 4.4% ▲0.8'
|
|
18
18
|
*
|
|
19
|
-
* Both numbers are printed,
|
|
20
|
-
* `51 ▲5` and asked what the 5
|
|
21
|
-
* was the
|
|
19
|
+
* Both numbers are printed, old first, new second — left is what it was,
|
|
20
|
+
* right is what it became. The owner read `51 ▲5` first and asked what the 5
|
|
21
|
+
* was — the new value or the old one. Neither: it was the distance between
|
|
22
|
+
* two numbers, one of which the card never showed. Once both were on the
|
|
23
|
+
* card the owner asked for THIS order specifically, so a reader can read the
|
|
24
|
+
* row left to right as a sentence: was, became, and by how much.
|
|
22
25
|
*
|
|
23
26
|
* The rule the owner asked for, in one line: where there is data to compare
|
|
24
27
|
* against, the arrow is printed; where there is none, nothing is printed —
|
|
@@ -26,19 +29,23 @@
|
|
|
26
29
|
*/
|
|
27
30
|
/** Integers stay integers; anything else keeps one decimal. */
|
|
28
31
|
const fmt = (n) => (Number.isInteger(n) ? String(n) : n.toFixed(1));
|
|
29
|
-
/**
|
|
30
|
-
* Two values that round to the same first decimal are equal: `4.42%` against
|
|
31
|
-
* `4.44%` is not movement, it is noise, and `▲0.0` reads as a lie.
|
|
32
|
-
*/
|
|
33
|
-
const same = (a, b) => Math.abs(a - b) < 0.05;
|
|
34
32
|
export const trend = (now, was, unit = '') => {
|
|
35
33
|
const head = `${fmt(now)}${unit}`;
|
|
36
34
|
if (was === undefined || !Number.isFinite(was)) {
|
|
37
35
|
return head;
|
|
38
36
|
}
|
|
39
|
-
const pair = `${
|
|
40
|
-
|
|
37
|
+
const pair = `${fmt(was)}${unit} / ${head}`;
|
|
38
|
+
// Equality is judged on the printed digits, not the raw numbers: `4.44` and
|
|
39
|
+
// `4.46` round to different labels (`4.4%` / `4.5%`), and printing `=` next
|
|
40
|
+
// to two different numbers reads as a lie no threshold on the raw values
|
|
41
|
+
// can prevent.
|
|
42
|
+
if (fmt(now) === fmt(was)) {
|
|
41
43
|
return `${pair} =`;
|
|
42
44
|
}
|
|
43
|
-
|
|
45
|
+
// The diff is computed from the two PRINTED numbers, not the raw ones: a
|
|
46
|
+
// diff of the raw values can round to `0.0` even when the printed pair
|
|
47
|
+
// reads `4.4% / 4.5%` — the arrow would show movement of nothing next to
|
|
48
|
+
// two numbers that are visibly different.
|
|
49
|
+
const diff = Number(fmt(now)) - Number(fmt(was));
|
|
50
|
+
return diff > 0 ? `${pair} ▲${fmt(Math.abs(diff))}` : `${pair} ▼${fmt(Math.abs(diff))}`;
|
|
44
51
|
};
|
package/package.json
CHANGED