@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/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 { basename } from 'node:path';
20
- import { render } from "./render.js";
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
- * Recursion is not possible: this card is not linted.
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 reportBrokenCard = async (e, faults) => {
298
- log(`card does not match the standard: ${faults.join('; ')}`);
299
- const broken = {
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(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/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
- await deliver(targets(broken), render(broken)).catch(() => undefined);
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) → '210 / 207 ▲3'
14
- * trend(202, 207) → '202 / 207 ▼5'
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, '%') → '4.4% / 3.6% ▲0.8'
17
+ * trend(4.4, 3.6, '%') → '3.6% / 4.4% ▲0.8'
18
18
  *
19
- * Both numbers are printed, now first and before first. The owner read
20
- * `51 ▲5` and asked what the 5 was — the new value or the old one. Neither: it
21
- * was the distance between two numbers, one of which the card never showed.
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) → '210 / 207 ▲3'
14
- * trend(202, 207) → '202 / 207 ▼5'
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, '%') → '4.4% / 3.6% ▲0.8'
17
+ * trend(4.4, 3.6, '%') → '3.6% / 4.4% ▲0.8'
18
18
  *
19
- * Both numbers are printed, now first and before first. The owner read
20
- * `51 ▲5` and asked what the 5 was — the new value or the old one. Neither: it
21
- * was the distance between two numbers, one of which the card never showed.
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 = `${head} / ${fmt(was)}${unit}`;
40
- if (same(now, was)) {
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
- return now > was ? `${pair} ▲${fmt(now - was)}` : `${pair} ▼${fmt(was - now)}`;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikitasazan/notify",
3
- "version": "1.11.0",
3
+ "version": "1.12.0",
4
4
  "description": "Единая типизированная отправка Telegram-уведомлений (форум-темы, маршрутизация, ретраи) для всех проектов",
5
5
  "type": "module",
6
6
  "license": "MIT",