@bevel-software/platform-core-backend 0.3.1 → 0.3.2

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.
@@ -1,67 +1,89 @@
1
- /**
2
- * Short-lived memo of manual-registration FAILURES, keyed per (user, manual).
3
- *
4
- * Registering a `.tool` manual with a present-but-broken credential (an
5
- * expired OAuth session, a revoked key) fails with a live network round-trip —
6
- * and sessions are rebuilt constantly (every external MCP connection runs a
7
- * full discovery), so one stale credential otherwise re-dials its provider on
8
- * every build, forever. Besides the log noise, each failed attempt on the
9
- * MCP transport layer's long-lived shared session leaks an abort listener
10
- * upstream — the memo starves that loop.
11
- *
12
- * Failures are remembered for a few minutes only: a repaired credential is
13
- * picked up at the next build after expiry (or immediately after a restart).
14
- * Successes are never cached here — this is a circuit breaker, not a catalog
15
- * cache. Expired entries are pruned opportunistically so the map stays
16
- * bounded by the number of DISTINCT recently-failing (user, manual) pairs.
17
- */
18
- export class ManualFailureMemo {
19
- private readonly failures = new Map<string, { message: string; expiresAt: number }>();
20
-
21
- constructor(
22
- private readonly ttlMs: number = 5 * 60_000,
23
- private readonly now: () => number = Date.now,
24
- ) {}
25
-
26
- private key(userId: string, manualName: string): string {
27
- return `${userId} ${manualName}`;
28
- }
29
-
30
- /** The remembered failure message when (user, manual) failed recently; undefined otherwise. */
31
- recentFailure(userId: string, manualName: string): string | undefined {
32
- const now = this.now();
33
- for (const [k, entry] of this.failures) {
34
- if (entry.expiresAt <= now) this.failures.delete(k);
35
- }
36
- return this.failures.get(this.key(userId, manualName))?.message;
37
- }
38
-
39
- recordFailure(userId: string, manualName: string, message: string): void {
40
- this.failures.set(this.key(userId, manualName), {
41
- message,
42
- expiresAt: this.now() + this.ttlMs,
43
- });
44
- }
45
-
46
- /** Forget a pair (e.g. after a successful registration proves the credential works again). */
47
- clear(userId: string, manualName: string): void {
48
- this.failures.delete(this.key(userId, manualName));
49
- }
50
-
51
- /**
52
- * Forget every failure for one user — called when their secrets change, so a
53
- * just-repaired credential is retried on the VERY NEXT build instead of
54
- * waiting out the TTL.
55
- */
56
- clearUser(userId: string): void {
57
- const prefix = `${userId} `;
58
- for (const k of this.failures.keys()) {
59
- if (k.startsWith(prefix)) this.failures.delete(k);
60
- }
61
- }
62
-
63
- /** Forget everything — a SHARED secret changed, which can affect any user. */
64
- clearAll(): void {
65
- this.failures.clear();
66
- }
67
- }
1
+ /**
2
+ * Short-lived memo of manual-registration FAILURES, keyed per (user, manual).
3
+ *
4
+ * Registering a `.tool` manual with a present-but-broken credential (an
5
+ * expired OAuth session, a revoked key) fails with a live network round-trip —
6
+ * and sessions are rebuilt constantly (every external MCP connection runs a
7
+ * full discovery), so one stale credential otherwise re-dials its provider on
8
+ * every build, forever. Besides the log noise, each failed attempt on the
9
+ * MCP transport layer's long-lived shared session leaks an abort listener
10
+ * upstream — the memo starves that loop.
11
+ *
12
+ * Failures are remembered for a few minutes only: a repaired credential is
13
+ * picked up at the next build after expiry (or immediately after a restart).
14
+ * Successes are never cached here — this is a circuit breaker, not a catalog
15
+ * cache. Expired entries are pruned opportunistically so the map stays
16
+ * bounded by the number of DISTINCT recently-failing (user, manual) pairs.
17
+ */
18
+ export class ManualFailureMemo {
19
+ private readonly failures = new Map<string, { message: string; expiresAt: number }>();
20
+ /**
21
+ * Advanced by every clear operation. A registration attempt captures the
22
+ * generation BEFORE its (slow, awaited) network call and hands it back to
23
+ * {@link recordFailure} — if a clear happened in between (the user just
24
+ * repaired the credential), the stale in-flight failure is discarded instead
25
+ * of resurrecting a memo entry the clear was meant to remove.
26
+ */
27
+ private generation = 0;
28
+
29
+ constructor(
30
+ private readonly ttlMs: number = 5 * 60_000,
31
+ private readonly now: () => number = Date.now,
32
+ ) {}
33
+
34
+ /** Capture before an awaited registration attempt; pass to {@link recordFailure}. */
35
+ get currentGeneration(): number {
36
+ return this.generation;
37
+ }
38
+
39
+ private key(userId: string, manualName: string): string {
40
+ return `${userId} ${manualName}`;
41
+ }
42
+
43
+ /** The remembered failure message when (user, manual) failed recently; undefined otherwise. */
44
+ recentFailure(userId: string, manualName: string): string | undefined {
45
+ const now = this.now();
46
+ for (const [k, entry] of this.failures) {
47
+ if (entry.expiresAt <= now) this.failures.delete(k);
48
+ }
49
+ return this.failures.get(this.key(userId, manualName))?.message;
50
+ }
51
+
52
+ /**
53
+ * Record a failure — unless `generation` (captured before the attempt) is
54
+ * stale, meaning a clear ran while the attempt was in flight. Dropping the
55
+ * record is the conservative direction: the worst case is one extra retry.
56
+ */
57
+ recordFailure(userId: string, manualName: string, message: string, generation?: number): void {
58
+ if (generation !== undefined && generation !== this.generation) return;
59
+ this.failures.set(this.key(userId, manualName), {
60
+ message,
61
+ expiresAt: this.now() + this.ttlMs,
62
+ });
63
+ }
64
+
65
+ /** Forget a pair (e.g. after a successful registration proves the credential works again). */
66
+ clear(userId: string, manualName: string): void {
67
+ this.generation += 1;
68
+ this.failures.delete(this.key(userId, manualName));
69
+ }
70
+
71
+ /**
72
+ * Forget every failure for one user — called when their secrets change, so a
73
+ * just-repaired credential is retried on the VERY NEXT build instead of
74
+ * waiting out the TTL.
75
+ */
76
+ clearUser(userId: string): void {
77
+ this.generation += 1;
78
+ const prefix = `${userId} `;
79
+ for (const k of this.failures.keys()) {
80
+ if (k.startsWith(prefix)) this.failures.delete(k);
81
+ }
82
+ }
83
+
84
+ /** Forget everything — a SHARED secret changed, which can affect any user. */
85
+ clearAll(): void {
86
+ this.generation += 1;
87
+ this.failures.clear();
88
+ }
89
+ }