switchroom 0.19.26 → 0.19.27
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/bin/git-agent-attribution-hook.sh +144 -0
- package/dist/agent-scheduler/index.js +55 -0
- package/dist/auth-broker/index.js +116 -6
- package/dist/cli/notion-write-pretool.mjs +55 -0
- package/dist/cli/switchroom.js +2055 -1193
- package/dist/host-control/main.js +117 -7
- package/dist/vault/approvals/kernel-server.js +114 -6
- package/dist/vault/broker/server.js +114 -6
- package/package.json +6 -2
- package/profiles/_base/cron-session.sh.hbs +8 -0
- package/profiles/_base/start.sh.hbs +105 -0
- package/telegram-plugin/card-layout.ts +328 -0
- package/telegram-plugin/dist/bridge/bridge.js +93 -1
- package/telegram-plugin/dist/gateway/gateway.js +2009 -1166
- package/telegram-plugin/dist/server.js +96 -1
- package/telegram-plugin/edit-flood-fuse.ts +637 -56
- package/telegram-plugin/flood-429-ledger.ts +526 -0
- package/telegram-plugin/flood-circuit-breaker.ts +18 -0
- package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
- package/telegram-plugin/gateway/gateway.ts +58 -68
- package/telegram-plugin/gateway/narrative-lane.ts +14 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +36 -0
- package/telegram-plugin/gateway/outbox-sweep.ts +183 -6
- package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
- package/telegram-plugin/gateway/status-pin-retarget.ts +72 -36
- package/telegram-plugin/gateway/status-pin-store.ts +58 -9
- package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
- package/telegram-plugin/llm-error-present.ts +61 -2
- package/telegram-plugin/model-unavailable.ts +8 -0
- package/telegram-plugin/operator-events.ts +72 -5
- package/telegram-plugin/outbound-class.ts +81 -0
- package/telegram-plugin/provider-credit.ts +237 -0
- package/telegram-plugin/scripts/bun-test-ci.sh +36 -6
- package/telegram-plugin/send-gate.ts +24 -2
- package/telegram-plugin/status-no-truncate.ts +10 -48
- package/telegram-plugin/status-pin-driver.ts +33 -45
- package/telegram-plugin/status-pin.ts +18 -1
- package/telegram-plugin/tests/card-golden.test.ts +69 -0
- package/telegram-plugin/tests/card-lifecycle-render.test.ts +362 -0
- package/telegram-plugin/tests/card-type-distinguishability.test.ts +187 -164
- package/telegram-plugin/tests/card-variants.golden.txt +211 -0
- package/telegram-plugin/tests/card-variants.ts +366 -0
- package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +316 -0
- package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
- package/telegram-plugin/tests/edit-flood-fuse.test.ts +11 -2
- package/telegram-plugin/tests/feed-edit-rate-ceiling.test.ts +462 -0
- package/telegram-plugin/tests/fixtures/real-429-stream.ts +220 -0
- package/telegram-plugin/tests/flood-429-ledger.test.ts +278 -0
- package/telegram-plugin/tests/flood-429-recorder-wiring.test.ts +128 -0
- package/telegram-plugin/tests/flood-reply-queue.test.ts +418 -0
- package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +221 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +19 -24
- package/telegram-plugin/tests/pinned-message-handler.test.ts +15 -15
- package/telegram-plugin/tests/provider-credit-402.test.ts +243 -0
- package/telegram-plugin/tests/status-pin-api.test.ts +11 -11
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +36 -37
- package/telegram-plugin/tests/status-pin-lifecycle.test.ts +602 -0
- package/telegram-plugin/tests/status-pin-retarget.test.ts +90 -62
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
- package/telegram-plugin/tests/status-pin-store.test.ts +109 -60
- package/telegram-plugin/tests/status-pin.test.ts +56 -5
- package/telegram-plugin/tests/test-runner-coverage.test.ts +133 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +12 -10
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +23 -29
- package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +56 -59
- package/telegram-plugin/tests/worker-feed-terminal-edit-class.test.ts +335 -0
- package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
- package/telegram-plugin/tool-activity-summary.ts +239 -365
- package/telegram-plugin/uat/assertions.ts +22 -11
- package/telegram-plugin/uat/feed-matcher.test.ts +24 -17
- package/telegram-plugin/worker-activity-feed.ts +105 -47
- package/vendor/hindsight-memory/CLAUDE.md +45 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
- package/vendor/hindsight-memory/scripts/recall.py +176 -7
- package/vendor/hindsight-memory/scripts/tests/test_config_recall_passthrough_env.py +170 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
- package/vendor/hindsight-memory/settings.json +1 -1
|
@@ -13,8 +13,11 @@ import {
|
|
|
13
13
|
type TrackedStatusPin,
|
|
14
14
|
} from "../gateway/status-pin-store.js";
|
|
15
15
|
import { decidePinAction, type PinState, type DesiredPin } from "../status-pin.js";
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
16
|
+
import { executePinLeg, type PinBotApi } from "../status-pin-driver.js";
|
|
17
|
+
import {
|
|
18
|
+
runStatusPinReconcile,
|
|
19
|
+
type StatusPinClaim,
|
|
20
|
+
} from "../gateway/status-pin-retarget.js";
|
|
18
21
|
import { makeFloodWaitActiveError } from "../retry-api-call.js";
|
|
19
22
|
|
|
20
23
|
/** In-memory fs seam with an atomic rename, so the store's tmp→rename
|
|
@@ -48,6 +51,26 @@ function pin(over: Partial<PersistedStatusPin> = {}): PersistedStatusPin {
|
|
|
48
51
|
return { pinKey: "fg:c:3", chatId: "-100123", messageId: 715, ...over };
|
|
49
52
|
}
|
|
50
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Strip the #3810 claim-age stamp from persisted rows.
|
|
56
|
+
*
|
|
57
|
+
* Every confirmed/pending row now carries `pinnedAt` (the durable claim age the
|
|
58
|
+
* mid-session store-orphan reaper needs). Row assertions that are about pin
|
|
59
|
+
* IDENTITY — which key, which chat, which message, pending or not — stay about
|
|
60
|
+
* identity by normalising the age away. The age has its own dedicated teeth in
|
|
61
|
+
* the `claim age` describe below; it is not silently untested.
|
|
62
|
+
*/
|
|
63
|
+
function idOnly(rows: PersistedStatusPin[]): Omit<PersistedStatusPin, "pinnedAt">[] {
|
|
64
|
+
return rows.map(({ pinnedAt: _age, ...rest }) => rest);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The `prev` snapshot a reconcile reads from the single claim registry (#3809),
|
|
68
|
+
* exactly as gateway.ts's `reconcileStatusPinInner` does. */
|
|
69
|
+
function prevOf(claims: Map<string, StatusPinClaim>, pinKey: string): PinState | null {
|
|
70
|
+
const c = claims.get(pinKey);
|
|
71
|
+
return c == null ? null : { messageId: c.messageId };
|
|
72
|
+
}
|
|
73
|
+
|
|
51
74
|
describe("status-pin-store", () => {
|
|
52
75
|
it("round-trips the pin claim set", () => {
|
|
53
76
|
const { fs } = memFs();
|
|
@@ -56,7 +79,7 @@ describe("status-pin-store", () => {
|
|
|
56
79
|
pin({ pinKey: "wk:agent-x", chatId: "-100999", messageId: 42 }),
|
|
57
80
|
];
|
|
58
81
|
persistStatusPins(PATH, fs, snap);
|
|
59
|
-
expect(loadStatusPins(PATH, fs)).toEqual(snap);
|
|
82
|
+
expect(idOnly(loadStatusPins(PATH, fs))).toEqual(snap);
|
|
60
83
|
});
|
|
61
84
|
|
|
62
85
|
it("writes via tmp + atomic rename (crash-safe)", () => {
|
|
@@ -80,22 +103,22 @@ describe("status-pin-store", () => {
|
|
|
80
103
|
expect(loadStatusPins(PATH, fs)).toHaveLength(1);
|
|
81
104
|
// Reconcile deleted the key → snapshot of the now-empty Map.
|
|
82
105
|
persistStatusPins(PATH, fs, []);
|
|
83
|
-
expect(loadStatusPins(PATH, fs)).toEqual([]);
|
|
106
|
+
expect(idOnly(loadStatusPins(PATH, fs))).toEqual([]);
|
|
84
107
|
});
|
|
85
108
|
|
|
86
109
|
it("loads [] when no file exists (fresh boot)", () => {
|
|
87
110
|
const { fs } = memFs();
|
|
88
|
-
expect(loadStatusPins(PATH, fs)).toEqual([]);
|
|
111
|
+
expect(idOnly(loadStatusPins(PATH, fs))).toEqual([]);
|
|
89
112
|
});
|
|
90
113
|
|
|
91
114
|
it("fail-open: corrupt JSON loads as [] (never crashes boot)", () => {
|
|
92
115
|
const { fs } = memFs({ [PATH]: "{not json" });
|
|
93
|
-
expect(loadStatusPins(PATH, fs)).toEqual([]);
|
|
116
|
+
expect(idOnly(loadStatusPins(PATH, fs))).toEqual([]);
|
|
94
117
|
});
|
|
95
118
|
|
|
96
119
|
it("fail-open: wrong envelope version / shape loads as []", () => {
|
|
97
|
-
//
|
|
98
|
-
const { fs: f1 } = memFs({ [PATH]: JSON.stringify({ v:
|
|
120
|
+
// v4 is an unknown FUTURE version (v1–v3 are the supported set).
|
|
121
|
+
const { fs: f1 } = memFs({ [PATH]: JSON.stringify({ v: 4, pins: [pin()] }) });
|
|
99
122
|
expect(loadStatusPins(PATH, f1)).toEqual([]);
|
|
100
123
|
const { fs: f2 } = memFs({ [PATH]: JSON.stringify({ v: 1, pins: "nope" }) });
|
|
101
124
|
expect(loadStatusPins(PATH, f2)).toEqual([]);
|
|
@@ -203,7 +226,7 @@ describe("runStatusPinBootCleanup", () => {
|
|
|
203
226
|
]);
|
|
204
227
|
expect(res).toEqual({ cleared: 2, retained: 0, kept: 0, total: 2 });
|
|
205
228
|
// Store empty afterwards → no re-attempt next boot.
|
|
206
|
-
expect(loadStatusPins(PATH, fs)).toEqual([]);
|
|
229
|
+
expect(idOnly(loadStatusPins(PATH, fs))).toEqual([]);
|
|
207
230
|
});
|
|
208
231
|
|
|
209
232
|
it("retry-safe (#3001): a failing unpin is non-fatal, RETAINS the row with an attempt counter, and drops only the succeeded one", async () => {
|
|
@@ -225,7 +248,7 @@ describe("runStatusPinBootCleanup", () => {
|
|
|
225
248
|
// One failed, one succeeded — the failure is retained for a next-boot
|
|
226
249
|
// retry instead of forfeiting the orphan (the pre-#3001 behaviour).
|
|
227
250
|
expect(res).toEqual({ cleared: 1, retained: 1, kept: 0, total: 2 });
|
|
228
|
-
expect(loadStatusPins(PATH, fs)).toEqual([
|
|
251
|
+
expect(idOnly(loadStatusPins(PATH, fs))).toEqual([
|
|
229
252
|
{ pinKey: "fg:c:1", chatId: "-100123", messageId: 5, attempts: 1 },
|
|
230
253
|
]);
|
|
231
254
|
});
|
|
@@ -251,7 +274,7 @@ describe("runStatusPinBootCleanup", () => {
|
|
|
251
274
|
// Final attempt failed too — forfeited, not retained: a permanently-
|
|
252
275
|
// undeliverable unpin must not re-fail on every future boot.
|
|
253
276
|
expect(res).toEqual({ cleared: 0, retained: 0, kept: 0, total: 1 });
|
|
254
|
-
expect(loadStatusPins(PATH, fs)).toEqual([]);
|
|
277
|
+
expect(idOnly(loadStatusPins(PATH, fs))).toEqual([]);
|
|
255
278
|
});
|
|
256
279
|
|
|
257
280
|
it("tool pins (#3001): an UNEXPIRED `tool:` row survives the boot untouched; an EXPIRED one is unpinned and dropped", async () => {
|
|
@@ -277,7 +300,7 @@ describe("runStatusPinBootCleanup", () => {
|
|
|
277
300
|
// deliberate agent pin with no "work finished" event).
|
|
278
301
|
expect(unpinned).toEqual([71, 72]);
|
|
279
302
|
expect(res).toEqual({ cleared: 2, retained: 0, kept: 1, total: 3 });
|
|
280
|
-
expect(loadStatusPins(PATH, fs)).toEqual([
|
|
303
|
+
expect(idOnly(loadStatusPins(PATH, fs))).toEqual([
|
|
281
304
|
{ pinKey: "tool:-100123:70", chatId: "-100123", messageId: 70, expiresAt: now + 1 },
|
|
282
305
|
]);
|
|
283
306
|
});
|
|
@@ -316,7 +339,7 @@ describe("runStatusPinBootCleanup", () => {
|
|
|
316
339
|
});
|
|
317
340
|
expect(unpinned).toEqual([["-100777", 314]]);
|
|
318
341
|
expect(res).toEqual({ cleared: 1, retained: 0, kept: 0, total: 1 });
|
|
319
|
-
expect(loadStatusPins(PATH, fs)).toEqual([]);
|
|
342
|
+
expect(idOnly(loadStatusPins(PATH, fs))).toEqual([]);
|
|
320
343
|
});
|
|
321
344
|
});
|
|
322
345
|
|
|
@@ -360,7 +383,7 @@ describe("status-pin-store — concurrent-writer race", () => {
|
|
|
360
383
|
// Let the reconcile reach its applyPin await (pending row now on disk).
|
|
361
384
|
await Promise.resolve();
|
|
362
385
|
await Promise.resolve();
|
|
363
|
-
expect(onDiskAtPin).toEqual([
|
|
386
|
+
expect(idOnly(onDiskAtPin)).toEqual([
|
|
364
387
|
{ pinKey: "fg:c:3", chatId: "-100123", messageId: 715, pending: true },
|
|
365
388
|
]);
|
|
366
389
|
|
|
@@ -383,12 +406,12 @@ describe("status-pin-store — concurrent-writer race", () => {
|
|
|
383
406
|
// BOTH rows survive: the status pin was NOT dropped by the concurrent
|
|
384
407
|
// banner write (the pre-fix bug), and the banner row is present.
|
|
385
408
|
const loaded = loadStatusPins(PATH, fs);
|
|
386
|
-
expect(loaded).toContainEqual({
|
|
409
|
+
expect(idOnly(loaded)).toContainEqual({
|
|
387
410
|
pinKey: "fg:c:3",
|
|
388
411
|
chatId: "-100123",
|
|
389
412
|
messageId: 715,
|
|
390
413
|
});
|
|
391
|
-
expect(loaded).toContainEqual({
|
|
414
|
+
expect(idOnly(loaded)).toContainEqual({
|
|
392
415
|
pinKey: "banner:owner",
|
|
393
416
|
chatId: "-100999",
|
|
394
417
|
messageId: 42,
|
|
@@ -436,12 +459,12 @@ describe("status-pin-store — concurrent-writer race", () => {
|
|
|
436
459
|
await reconcileB;
|
|
437
460
|
|
|
438
461
|
const loaded = loadStatusPins(PATH, fs);
|
|
439
|
-
expect(loaded).toContainEqual({
|
|
462
|
+
expect(idOnly(loaded)).toContainEqual({
|
|
440
463
|
pinKey: "fg:c:3",
|
|
441
464
|
chatId: "-100123",
|
|
442
465
|
messageId: 715,
|
|
443
466
|
});
|
|
444
|
-
expect(loaded).toContainEqual({
|
|
467
|
+
expect(idOnly(loaded)).toContainEqual({
|
|
445
468
|
pinKey: "wk:agent-x",
|
|
446
469
|
chatId: "-100999",
|
|
447
470
|
messageId: 42,
|
|
@@ -525,16 +548,44 @@ describe("status-pin-store — envelope version compat", () => {
|
|
|
525
548
|
expect(loaded[0].messageId).toBe(715);
|
|
526
549
|
});
|
|
527
550
|
|
|
528
|
-
it("round-trips a v2 pending flag and writes
|
|
551
|
+
it("round-trips a v2 pending flag and writes v3 envelopes", () => {
|
|
529
552
|
const { fs, files } = memFs();
|
|
530
553
|
persistStatusPins(PATH, fs, [pin({ pending: true })]);
|
|
531
|
-
expect(JSON.parse(files.get(PATH)!).v).toBe(
|
|
554
|
+
expect(JSON.parse(files.get(PATH)!).v).toBe(3);
|
|
532
555
|
expect(loadStatusPins(PATH, fs)[0].pending).toBe(true);
|
|
533
556
|
});
|
|
534
557
|
|
|
558
|
+
it("#3810: a v3 row round-trips its pinnedAt claim age; a v1/v2 row loads without one", () => {
|
|
559
|
+
const { fs } = memFs({
|
|
560
|
+
[PATH]: JSON.stringify({
|
|
561
|
+
v: 3,
|
|
562
|
+
pins: [
|
|
563
|
+
{ pinKey: "wk:group:g1", chatId: "-100123", messageId: 715, pinnedAt: 1234 },
|
|
564
|
+
{ pinKey: "wk:group:g2", chatId: "-100123", messageId: 716 },
|
|
565
|
+
],
|
|
566
|
+
}),
|
|
567
|
+
});
|
|
568
|
+
const loaded = loadStatusPins(PATH, fs);
|
|
569
|
+
expect(loaded[0].pinnedAt).toBe(1234);
|
|
570
|
+
expect(loaded[1].pinnedAt).toBeUndefined();
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
it("drops a row with a non-numeric pinnedAt", () => {
|
|
574
|
+
const { fs } = memFs({
|
|
575
|
+
[PATH]: JSON.stringify({
|
|
576
|
+
v: 3,
|
|
577
|
+
pins: [
|
|
578
|
+
pin({ messageId: 715 }),
|
|
579
|
+
{ pinKey: "k", chatId: "c", messageId: 1, pinnedAt: "soon" },
|
|
580
|
+
],
|
|
581
|
+
}),
|
|
582
|
+
});
|
|
583
|
+
expect(loadStatusPins(PATH, fs)).toHaveLength(1);
|
|
584
|
+
});
|
|
585
|
+
|
|
535
586
|
it("rejects an unknown future envelope version (fail-open [])", () => {
|
|
536
|
-
const { fs } = memFs({ [PATH]: JSON.stringify({ v:
|
|
537
|
-
expect(loadStatusPins(PATH, fs)).toEqual([]);
|
|
587
|
+
const { fs } = memFs({ [PATH]: JSON.stringify({ v: 4, pins: [pin()] }) });
|
|
588
|
+
expect(idOnly(loadStatusPins(PATH, fs))).toEqual([]);
|
|
538
589
|
});
|
|
539
590
|
|
|
540
591
|
it("drops a row with a non-boolean pending field", () => {
|
|
@@ -579,12 +630,12 @@ describe("reconcileAndPersistStatusPin — persist-before-pin ordering", () => {
|
|
|
579
630
|
log: () => {},
|
|
580
631
|
});
|
|
581
632
|
// At the moment the pin API ran, a pending record was already on disk.
|
|
582
|
-
expect(onDiskAtPin).toEqual([
|
|
633
|
+
expect(idOnly(onDiskAtPin)).toEqual([
|
|
583
634
|
{ pinKey: "fg:c:3", chatId: "-100123", messageId: 715, pending: true },
|
|
584
635
|
]);
|
|
585
636
|
// After success it's confirmed (pending cleared).
|
|
586
637
|
expect(next).toEqual({ messageId: 715 });
|
|
587
|
-
expect(loadStatusPins(PATH, fs)).toEqual([
|
|
638
|
+
expect(idOnly(loadStatusPins(PATH, fs))).toEqual([
|
|
588
639
|
{ pinKey: "fg:c:3", chatId: "-100123", messageId: 715 },
|
|
589
640
|
]);
|
|
590
641
|
});
|
|
@@ -616,7 +667,7 @@ describe("reconcileAndPersistStatusPin — persist-before-pin ordering", () => {
|
|
|
616
667
|
// persist-AFTER-pin ordering FAILED to do — the store would have been empty
|
|
617
668
|
// and boot cleanup blind to the orphan).
|
|
618
669
|
const persisted = loadStatusPins(PATH, fs);
|
|
619
|
-
expect(persisted).toEqual([
|
|
670
|
+
expect(idOnly(persisted)).toEqual([
|
|
620
671
|
{ pinKey: "fg:c:3", chatId: "-100123", messageId: 715, pending: true },
|
|
621
672
|
]);
|
|
622
673
|
|
|
@@ -631,7 +682,7 @@ describe("reconcileAndPersistStatusPin — persist-before-pin ordering", () => {
|
|
|
631
682
|
log: () => {},
|
|
632
683
|
});
|
|
633
684
|
expect(unpinned).toEqual([["-100123", 715]]);
|
|
634
|
-
expect(loadStatusPins(PATH, fs)).toEqual([]);
|
|
685
|
+
expect(idOnly(loadStatusPins(PATH, fs))).toEqual([]);
|
|
635
686
|
});
|
|
636
687
|
|
|
637
688
|
it("clears the pending record when the pin API fails (no phantom claim)", async () => {
|
|
@@ -642,13 +693,13 @@ describe("reconcileAndPersistStatusPin — persist-before-pin ordering", () => {
|
|
|
642
693
|
pinKey: "fg:c:3",
|
|
643
694
|
chatId: "-100123",
|
|
644
695
|
op: { kind: "pin", messageId: 715 },
|
|
645
|
-
//
|
|
696
|
+
// executePinLeg returns null (prevState) when the pin API throws.
|
|
646
697
|
applyPin: async () => null,
|
|
647
698
|
log: () => {},
|
|
648
699
|
});
|
|
649
700
|
expect(next).toBeNull();
|
|
650
701
|
// Pending record dropped — nothing to leak, nothing was pinned.
|
|
651
|
-
expect(loadStatusPins(PATH, fs)).toEqual([]);
|
|
702
|
+
expect(idOnly(loadStatusPins(PATH, fs))).toEqual([]);
|
|
652
703
|
});
|
|
653
704
|
|
|
654
705
|
it("preserves OTHER live claims while flipping one key pending→confirmed", async () => {
|
|
@@ -671,8 +722,8 @@ describe("reconcileAndPersistStatusPin — persist-before-pin ordering", () => {
|
|
|
671
722
|
log: () => {},
|
|
672
723
|
});
|
|
673
724
|
const loaded = loadStatusPins(PATH, fs);
|
|
674
|
-
expect(loaded).toContainEqual(other);
|
|
675
|
-
expect(loaded).toContainEqual({
|
|
725
|
+
expect(idOnly(loaded)).toContainEqual(other);
|
|
726
|
+
expect(idOnly(loaded)).toContainEqual({
|
|
676
727
|
pinKey: "fg:c:3",
|
|
677
728
|
chatId: "-100123",
|
|
678
729
|
messageId: 715,
|
|
@@ -696,12 +747,12 @@ describe("reconcileAndPersistStatusPin — persist-before-pin ordering", () => {
|
|
|
696
747
|
log: () => {},
|
|
697
748
|
});
|
|
698
749
|
const loaded = loadStatusPins(PATH, fs);
|
|
699
|
-
expect(loaded).toContainEqual({
|
|
750
|
+
expect(idOnly(loaded)).toContainEqual({
|
|
700
751
|
pinKey: "banner:owner",
|
|
701
752
|
chatId: "-100999",
|
|
702
753
|
messageId: 42,
|
|
703
754
|
});
|
|
704
|
-
expect(loaded).toContainEqual({
|
|
755
|
+
expect(idOnly(loaded)).toContainEqual({
|
|
705
756
|
pinKey: "fg:c:3",
|
|
706
757
|
chatId: "-100123",
|
|
707
758
|
messageId: 715,
|
|
@@ -721,11 +772,11 @@ describe("reconcileAndPersistStatusPin — persist-before-pin ordering", () => {
|
|
|
721
772
|
log: () => {},
|
|
722
773
|
});
|
|
723
774
|
expect(next).toBeNull();
|
|
724
|
-
expect(loadStatusPins(PATH, fs)).toEqual([]);
|
|
775
|
+
expect(idOnly(loadStatusPins(PATH, fs))).toEqual([]);
|
|
725
776
|
});
|
|
726
777
|
|
|
727
778
|
// F1 (invisible-worker-cards review): a `clear` op is emitted for BOTH a real
|
|
728
|
-
// unpin AND a `noop: already pinned`. For a noop,
|
|
779
|
+
// unpin AND a `noop: already pinned`. For a noop, executePinLeg issues NO
|
|
729
780
|
// Telegram call and returns the LIVE claim (non-null). The pre-fix clear
|
|
730
781
|
// branch dropped the row unconditionally, so the worker feed's per-edit
|
|
731
782
|
// syncPin (every steady-state edit maps to a noop-clear) erased the durable
|
|
@@ -736,7 +787,7 @@ describe("reconcileAndPersistStatusPin — persist-before-pin ordering", () => {
|
|
|
736
787
|
const { fs, calls } = memFs();
|
|
737
788
|
persistStatusPins(PATH, fs, [pin({ pinKey: "wk:group:g1", messageId: 715 })]);
|
|
738
789
|
const before = calls.length;
|
|
739
|
-
// applyPin returns the live claim unchanged (what
|
|
790
|
+
// applyPin returns the live claim unchanged (what executePinLeg does on noop).
|
|
740
791
|
const next = await reconcileAndPersistStatusPin({
|
|
741
792
|
path: PATH,
|
|
742
793
|
fs,
|
|
@@ -748,7 +799,7 @@ describe("reconcileAndPersistStatusPin — persist-before-pin ordering", () => {
|
|
|
748
799
|
});
|
|
749
800
|
expect(next).toEqual({ messageId: 715 });
|
|
750
801
|
// Row survives (rewritten confirmed, no `pending`).
|
|
751
|
-
expect(loadStatusPins(PATH, fs)).toEqual([
|
|
802
|
+
expect(idOnly(loadStatusPins(PATH, fs))).toEqual([
|
|
752
803
|
{ pinKey: "wk:group:g1", chatId: "-100123", messageId: 715 },
|
|
753
804
|
]);
|
|
754
805
|
// Sanity: at least one disk write happened (the confirm rewrite) — the fix
|
|
@@ -770,7 +821,7 @@ describe("reconcileAndPersistStatusPin — persist-before-pin ordering", () => {
|
|
|
770
821
|
log: () => {},
|
|
771
822
|
});
|
|
772
823
|
}
|
|
773
|
-
expect(loadStatusPins(PATH, fs)).toEqual([
|
|
824
|
+
expect(idOnly(loadStatusPins(PATH, fs))).toEqual([
|
|
774
825
|
{ pinKey: "wk:group:g1", chatId: "-100123", messageId: 715 },
|
|
775
826
|
]);
|
|
776
827
|
});
|
|
@@ -784,7 +835,7 @@ describe("reconcileAndPersistStatusPin — persist-before-pin ordering", () => {
|
|
|
784
835
|
* row while a flood-delayed open-pin lands, leaving a stuck pin with no row.
|
|
785
836
|
*
|
|
786
837
|
* These tests exercise the REAL fix primitives — `withPinReconcileLock` +
|
|
787
|
-
* `reconcileAndPersistStatusPin` + `
|
|
838
|
+
* `reconcileAndPersistStatusPin` + `executePinLeg` + `decidePinAction` — composed
|
|
788
839
|
* exactly as the gateway wires them (read prev INSIDE the per-key lock), and
|
|
789
840
|
* prove the row survives. The `noLock` control models the pre-fix ordering
|
|
790
841
|
* (prev read OUTSIDE the lock) and shows it regresses — so the lock, not luck,
|
|
@@ -793,11 +844,11 @@ describe("reconcileAndPersistStatusPin — persist-before-pin ordering", () => {
|
|
|
793
844
|
describe("status-pin-store — F2 per-key reconcile serialization", () => {
|
|
794
845
|
// A gateway-faithful reconcile: an in-memory claim map (the `prev` source read
|
|
795
846
|
// at the TOP of the reconcile), the real decide→op mapping, and
|
|
796
|
-
// reconcileAndPersistStatusPin driving
|
|
847
|
+
// reconcileAndPersistStatusPin driving executePinLeg against a call-counting
|
|
797
848
|
// pin API. An optional gate lets a test hold ONE reconcile's applyPin open to
|
|
798
849
|
// force the exact interleave. `serialize` toggles the F2 fix.
|
|
799
850
|
function makeReconciler(fs: StatusPinStoreFsSeam, opts: { serialize: boolean }) {
|
|
800
|
-
const claims = new Map<string,
|
|
851
|
+
const claims = new Map<string, StatusPinClaim>();
|
|
801
852
|
const pinCalls: number[] = [];
|
|
802
853
|
const unpinCalls: number[] = [];
|
|
803
854
|
const api: PinBotApi = {
|
|
@@ -808,8 +859,6 @@ describe("status-pin-store — F2 per-key reconcile serialization", () => {
|
|
|
808
859
|
unpinCalls.push(id);
|
|
809
860
|
},
|
|
810
861
|
};
|
|
811
|
-
const chatIds = new Map<string, string>();
|
|
812
|
-
const pinnedAt = new Map<string, number>();
|
|
813
862
|
// Drives the REAL production orchestrator the gateway calls, so the persist
|
|
814
863
|
// ordering and the RETARGET two-leg expansion under test are the shipped
|
|
815
864
|
// ones — not a copy that could drift out from under this suite.
|
|
@@ -817,14 +866,14 @@ describe("status-pin-store — F2 per-key reconcile serialization", () => {
|
|
|
817
866
|
return runStatusPinReconcile({
|
|
818
867
|
pinKey,
|
|
819
868
|
chatId,
|
|
820
|
-
prev: claims
|
|
869
|
+
prev: prevOf(claims, pinKey), // read BEFORE the store op (the F2 window)
|
|
821
870
|
desired,
|
|
822
871
|
persist: { path: PATH, fs },
|
|
823
|
-
runPin: async (
|
|
872
|
+
runPin: async (action, from) => {
|
|
824
873
|
if (gate) await gate; // hold the pin open inside the store lock
|
|
825
|
-
return
|
|
874
|
+
return executePinLeg({ api, chatId, prevState: from, action });
|
|
826
875
|
},
|
|
827
|
-
|
|
876
|
+
claims,
|
|
828
877
|
});
|
|
829
878
|
}
|
|
830
879
|
function reconcile(
|
|
@@ -872,7 +921,7 @@ describe("status-pin-store — F2 per-key reconcile serialization", () => {
|
|
|
872
921
|
// and claim together. The Telegram pin is cleaned up — nothing stuck.
|
|
873
922
|
expect(pinCalls).toEqual([900]); // A pinned it
|
|
874
923
|
expect(unpinCalls).toEqual([900]); // B unpinned it (the fix)
|
|
875
|
-
expect(rows).toEqual([]);
|
|
924
|
+
expect(idOnly(rows)).toEqual([]);
|
|
876
925
|
expect(claims.get("fg:c:3") ?? null).toBeNull();
|
|
877
926
|
});
|
|
878
927
|
|
|
@@ -886,7 +935,7 @@ describe("status-pin-store — F2 per-key reconcile serialization", () => {
|
|
|
886
935
|
// issued → stuck until a boot cleanup that can no longer see it.
|
|
887
936
|
expect(pinCalls).toEqual([900]); // message 900 WAS pinned
|
|
888
937
|
expect(unpinCalls).toEqual([]); // …but never unpinned (no-op clear on prev=null)
|
|
889
|
-
expect(rows).toEqual([]); // …and the durable row is gone → unreapable
|
|
938
|
+
expect(idOnly(rows)).toEqual([]); // …and the durable row is gone → unreapable
|
|
890
939
|
});
|
|
891
940
|
|
|
892
941
|
it("serialized steady-state noop reconciles add ZERO pin/unpin API calls (finn-flood guard)", async () => {
|
|
@@ -898,7 +947,7 @@ describe("status-pin-store — F2 per-key reconcile serialization", () => {
|
|
|
898
947
|
expect(r.unpinCalls).toEqual([]);
|
|
899
948
|
|
|
900
949
|
// 20 steady-state edits — each a re-pin of the SAME id → decidePinAction
|
|
901
|
-
// noop →
|
|
950
|
+
// noop → executePinLeg issues NO Telegram call. The serialization + F1
|
|
902
951
|
// row-preservation must not add a single pin/unpin API call.
|
|
903
952
|
for (let i = 0; i < 20; i++) {
|
|
904
953
|
await r.reconcile("wk:group:g1", "-100123", { pinned: true, messageId: 900 });
|
|
@@ -906,7 +955,7 @@ describe("status-pin-store — F2 per-key reconcile serialization", () => {
|
|
|
906
955
|
expect(r.pinCalls).toEqual([900]); // still exactly one pin, ever
|
|
907
956
|
expect(r.unpinCalls).toEqual([]); // never unpinned
|
|
908
957
|
// The durable row is intact throughout (F1).
|
|
909
|
-
expect(loadStatusPins(PATH, fs)).toEqual([
|
|
958
|
+
expect(idOnly(loadStatusPins(PATH, fs))).toEqual([
|
|
910
959
|
{ pinKey: "wk:group:g1", chatId: "-100123", messageId: 900 },
|
|
911
960
|
]);
|
|
912
961
|
});
|
|
@@ -924,7 +973,7 @@ describe("status-pin-store — F2 per-key reconcile serialization", () => {
|
|
|
924
973
|
|
|
925
974
|
await r.reconcile("fg:c:7", "-100123", { pinned: true, messageId: 900 });
|
|
926
975
|
expect(r.pinCalls).toEqual([900]);
|
|
927
|
-
expect(loadStatusPins(PATH, fs)).toEqual([
|
|
976
|
+
expect(idOnly(loadStatusPins(PATH, fs))).toEqual([
|
|
928
977
|
{ pinKey: "fg:c:7", chatId: "-100123", messageId: 900 },
|
|
929
978
|
]);
|
|
930
979
|
|
|
@@ -933,10 +982,10 @@ describe("status-pin-store — F2 per-key reconcile serialization", () => {
|
|
|
933
982
|
|
|
934
983
|
expect(r.unpinCalls).toEqual([900]); // stale card unpinned
|
|
935
984
|
expect(r.pinCalls).toEqual([900, 901]); // …AND the new card pinned
|
|
936
|
-
expect(r.claims.get("fg:c:7")).
|
|
985
|
+
expect(r.claims.get("fg:c:7")?.messageId).toBe(901);
|
|
937
986
|
// The durable row tracks the message that is genuinely pinned, so the
|
|
938
987
|
// mid-session reaper and the boot sweep can both still reach it.
|
|
939
|
-
expect(loadStatusPins(PATH, fs)).toEqual([
|
|
988
|
+
expect(idOnly(loadStatusPins(PATH, fs))).toEqual([
|
|
940
989
|
{ pinKey: "fg:c:7", chatId: "-100123", messageId: 901 },
|
|
941
990
|
]);
|
|
942
991
|
});
|
|
@@ -946,7 +995,7 @@ describe("status-pin-store — F2 per-key reconcile serialization", () => {
|
|
|
946
995
|
// still pinned, so we must NOT pin the new one and must NOT lose the row —
|
|
947
996
|
// otherwise the chat holds a pin nothing on disk names.
|
|
948
997
|
const { fs } = memFs();
|
|
949
|
-
const claims = new Map<string,
|
|
998
|
+
const claims = new Map<string, StatusPinClaim>();
|
|
950
999
|
const pinCalls: number[] = [];
|
|
951
1000
|
const unpinCalls: number[] = [];
|
|
952
1001
|
let floodTheUnpin = false;
|
|
@@ -963,12 +1012,12 @@ describe("status-pin-store — F2 per-key reconcile serialization", () => {
|
|
|
963
1012
|
runStatusPinReconcile({
|
|
964
1013
|
pinKey: "fg:c:8",
|
|
965
1014
|
chatId: "-100123",
|
|
966
|
-
prev: claims
|
|
1015
|
+
prev: prevOf(claims, "fg:c:8"),
|
|
967
1016
|
desired,
|
|
968
1017
|
persist: { path: PATH, fs },
|
|
969
|
-
runPin: (
|
|
970
|
-
|
|
971
|
-
|
|
1018
|
+
runPin: (action, from) =>
|
|
1019
|
+
executePinLeg({ api, chatId: "-100123", prevState: from, action }),
|
|
1020
|
+
claims,
|
|
972
1021
|
});
|
|
973
1022
|
|
|
974
1023
|
await reconcile({ pinned: true, messageId: 900 });
|
|
@@ -977,8 +1026,8 @@ describe("status-pin-store — F2 per-key reconcile serialization", () => {
|
|
|
977
1026
|
|
|
978
1027
|
expect(unpinCalls).toEqual([900]); // attempted…
|
|
979
1028
|
expect(pinCalls).toEqual([900]); // …and the pin leg was correctly SKIPPED
|
|
980
|
-
expect(claims.get("fg:c:8")).
|
|
981
|
-
expect(loadStatusPins(PATH, fs)).toEqual([
|
|
1029
|
+
expect(claims.get("fg:c:8")?.messageId).toBe(900); // claim retained
|
|
1030
|
+
expect(idOnly(loadStatusPins(PATH, fs))).toEqual([
|
|
982
1031
|
{ pinKey: "fg:c:8", chatId: "-100123", messageId: 900 },
|
|
983
1032
|
]);
|
|
984
1033
|
});
|
|
@@ -2,8 +2,59 @@ import { describe, it, expect } from 'vitest'
|
|
|
2
2
|
import { GrammyError } from 'grammy'
|
|
3
3
|
import { decidePinAction, isPinRightsError, isUnpinTerminalError, PinRightsCache } from '../status-pin.js'
|
|
4
4
|
import { makeFloodWaitActiveError, GIVE_UP_MESSAGE } from '../retry-api-call.js'
|
|
5
|
-
import {
|
|
6
|
-
import type { PinState } from '../status-pin.js'
|
|
5
|
+
import { executePinLeg, type PinBotApi } from '../status-pin-driver.js'
|
|
6
|
+
import type { DesiredPin, PinState, PinRightsCache as _RightsCache } from '../status-pin.js'
|
|
7
|
+
import {
|
|
8
|
+
runStatusPinReconcile,
|
|
9
|
+
type StatusPinClaim,
|
|
10
|
+
} from '../gateway/status-pin-retarget.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Drive a whole desired-state reconcile through the REAL unified path — the
|
|
14
|
+
* orchestrator (`runStatusPinReconcile`, which owns the single retarget
|
|
15
|
+
* expansion) composed with the driver (`executePinLeg`), exactly as gateway.ts
|
|
16
|
+
* wires them. `persist: null` mirrors the STATIC / feature-off binding.
|
|
17
|
+
*
|
|
18
|
+
* These cases used to call `reconcilePin(prev, desired)` directly, back when the
|
|
19
|
+
* driver re-decided the action itself and carried its OWN copy of the retarget
|
|
20
|
+
* expansion (#3831). The driver can no longer express a retarget, so exercising
|
|
21
|
+
* the same behaviours now means exercising the composition — which is what
|
|
22
|
+
* production actually runs.
|
|
23
|
+
*/
|
|
24
|
+
async function reconcilePin(args: {
|
|
25
|
+
api: PinBotApi
|
|
26
|
+
chatId: string
|
|
27
|
+
prevState: PinState | null
|
|
28
|
+
desired: DesiredPin
|
|
29
|
+
rightsCache?: _RightsCache
|
|
30
|
+
onError?: (phase: 'pin' | 'unpin', err: unknown) => void
|
|
31
|
+
onPinRightsDisabled?: (chatId: string) => void
|
|
32
|
+
}): Promise<PinState | null> {
|
|
33
|
+
const claims = new Map<string, StatusPinClaim>()
|
|
34
|
+
if (args.prevState != null) {
|
|
35
|
+
claims.set('k', { messageId: args.prevState.messageId, chatId: args.chatId, pinnedAt: 1 })
|
|
36
|
+
}
|
|
37
|
+
await runStatusPinReconcile({
|
|
38
|
+
pinKey: 'k',
|
|
39
|
+
chatId: args.chatId,
|
|
40
|
+
prev: args.prevState,
|
|
41
|
+
desired: args.desired,
|
|
42
|
+
persist: null,
|
|
43
|
+
runPin: (action, from) =>
|
|
44
|
+
executePinLeg({
|
|
45
|
+
api: args.api,
|
|
46
|
+
chatId: args.chatId,
|
|
47
|
+
prevState: from,
|
|
48
|
+
action,
|
|
49
|
+
rightsCache: args.rightsCache,
|
|
50
|
+
onError: args.onError,
|
|
51
|
+
onPinRightsDisabled: args.onPinRightsDisabled,
|
|
52
|
+
}),
|
|
53
|
+
claims,
|
|
54
|
+
})
|
|
55
|
+
const claim = claims.get('k')
|
|
56
|
+
return claim == null ? null : { messageId: claim.messageId }
|
|
57
|
+
}
|
|
7
58
|
|
|
8
59
|
/** A real GrammyError with the given code + description, mirroring the wire
|
|
9
60
|
* shape Telegram returns for the pin-rights failure that crashed marko. */
|
|
@@ -64,7 +115,7 @@ function fakeApi(opts: { pinThrows?: boolean; unpinThrows?: boolean } = {}) {
|
|
|
64
115
|
return { api, calls }
|
|
65
116
|
}
|
|
66
117
|
|
|
67
|
-
describe('
|
|
118
|
+
describe('unified reconcile path (orchestrator + driver)', () => {
|
|
68
119
|
it('pin on in-progress: pins the EXISTING message SILENTLY and claims it', async () => {
|
|
69
120
|
const { api, calls } = fakeApi()
|
|
70
121
|
const next = await reconcilePin({
|
|
@@ -333,7 +384,7 @@ describe('reconcilePin (driver)', () => {
|
|
|
333
384
|
// the whole gateway down. Auto status-pin is best-effort/cosmetic — a
|
|
334
385
|
// pin-rights failure must be absorbed, never fatal, and never reach the
|
|
335
386
|
// process-level unhandledRejection handler.
|
|
336
|
-
describe('
|
|
387
|
+
describe('unified reconcile path — pin-rights 400 must never crash (marko 2026-07-01)', () => {
|
|
337
388
|
it('absorbs the "not enough rights" 400 via onError and never rejects', async () => {
|
|
338
389
|
const rightsErr = grammyError(
|
|
339
390
|
400,
|
|
@@ -468,7 +519,7 @@ function countingApi(pinError?: unknown) {
|
|
|
468
519
|
}
|
|
469
520
|
}
|
|
470
521
|
|
|
471
|
-
describe('
|
|
522
|
+
describe('unified reconcile path — rights-aware negative cache (#3024)', () => {
|
|
472
523
|
const rightsErr = () =>
|
|
473
524
|
grammyError(400, 'Bad Request: not enough rights to manage pinned messages in the chat')
|
|
474
525
|
|