switchroom 0.19.26 → 0.19.28
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 +60 -2
- package/dist/auth-broker/index.js +244 -13
- package/dist/cli/autoaccept-poll.js +225 -17
- package/dist/cli/notion-write-pretool.mjs +60 -2
- package/dist/cli/switchroom.js +2843 -1220
- package/dist/host-control/main.js +245 -14
- package/dist/vault/approvals/kernel-server.js +242 -13
- package/dist/vault/broker/server.js +242 -13
- package/package.json +7 -2
- package/profiles/_base/cron-session.sh.hbs +8 -0
- package/profiles/_base/start.sh.hbs +175 -15
- package/telegram-plugin/card-layout.ts +328 -0
- package/telegram-plugin/dist/bridge/bridge.js +94 -1
- package/telegram-plugin/dist/gateway/gateway.js +2544 -1182
- package/telegram-plugin/dist/server.js +97 -1
- package/telegram-plugin/edit-flood-fuse.ts +841 -57
- package/telegram-plugin/flood-429-ledger.ts +526 -0
- package/telegram-plugin/flood-circuit-breaker.ts +18 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +6 -0
- package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
- package/telegram-plugin/gateway/gateway.ts +67 -70
- package/telegram-plugin/gateway/mcp-failure-hook.ts +74 -0
- 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/inline-keyboard-callbacks.ts +202 -21
- package/telegram-plugin/llm-error-present.ts +61 -2
- package/telegram-plugin/mcp-credential-failure.ts +459 -0
- package/telegram-plugin/model-unavailable.ts +8 -0
- package/telegram-plugin/operator-events.ts +110 -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 +373 -0
- package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
- package/telegram-plugin/tests/edit-flood-fuse-reply-reserve.test.ts +340 -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/finalize-callback-flood-policy.test.ts +298 -0
- package/telegram-plugin/tests/finalize-callback.test.ts +41 -8
- 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/mcp-credential-failure.test.ts +310 -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/drain_pending.py +433 -11
- package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
- package/vendor/hindsight-memory/scripts/lib/pending.py +193 -28
- 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_drain_circuit_breaker.py +401 -0
- package/vendor/hindsight-memory/scripts/tests/test_drain_serialisation.py +286 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +817 -8
- package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
- package/vendor/hindsight-memory/settings.json +1 -1
- package/vendor/hindsight-memory/tests/test_hooks.py +11 -2
|
@@ -70,6 +70,27 @@ export interface PersistedStatusPin {
|
|
|
70
70
|
* unpin fails (flood-wait exhausted / transient 5xx); the row is retained
|
|
71
71
|
* for retry until BOOT_UNPIN_MAX_ATTEMPTS, then forfeited. Absent = 0. */
|
|
72
72
|
attempts?: number
|
|
73
|
+
/**
|
|
74
|
+
* Wall-clock ms the pin was FIRST claimed for this key+message (#3810).
|
|
75
|
+
*
|
|
76
|
+
* Why the store needs it: the mid-session `wk:` reaper folds in rows that
|
|
77
|
+
* exist on disk but have NO in-memory claim (`storeOnlyWorkerPinCandidates`).
|
|
78
|
+
* The store used to persist no timestamp, so those candidates were stamped
|
|
79
|
+
* `pinnedAt = now` on every pass — deliberately, to avoid a spurious unpin of
|
|
80
|
+
* a pin whose real age was unknown, but with the side effect that the TTL
|
|
81
|
+
* gate could NEVER fire for a store orphan. Combined with a `wk:<agentId>`
|
|
82
|
+
* whose turnsDb row is gone (verdict `'unknown'`, never `'terminal'`), such a
|
|
83
|
+
* row was never mid-session reaped AT ALL — it waited for the next boot, the
|
|
84
|
+
* exact "stale pin glued to the top of the chat" failure the reaper exists to
|
|
85
|
+
* prevent.
|
|
86
|
+
*
|
|
87
|
+
* Recording the real claim time removes the guess: a store orphan now ages
|
|
88
|
+
* honestly and the ordinary TTL applies. Optional so a v1/v2 snapshot still
|
|
89
|
+
* loads; a row without it keeps the old conservative `now` stamp (terminal-
|
|
90
|
+
* only reaping), which self-clears within one restart since every write from
|
|
91
|
+
* this version onward carries the field.
|
|
92
|
+
*/
|
|
93
|
+
pinnedAt?: number
|
|
73
94
|
}
|
|
74
95
|
|
|
75
96
|
/** How many boots may retry a failing boot-cleanup unpin before the row is
|
|
@@ -80,13 +101,18 @@ export interface PersistedStatusPin {
|
|
|
80
101
|
export const BOOT_UNPIN_MAX_ATTEMPTS = 5
|
|
81
102
|
|
|
82
103
|
/** Envelope version. v1 had no `pending` field; a v1 row loads as a confirmed
|
|
83
|
-
* pin (pending undefined). v2 adds the optional `pending` flag.
|
|
84
|
-
* fail-open — an
|
|
104
|
+
* pin (pending undefined). v2 adds the optional `pending` flag. v3 adds the
|
|
105
|
+
* optional `pinnedAt` claim timestamp (#3810). All load fail-open — an
|
|
106
|
+
* unknown/newer version yields []. Every field added since v1 is optional, so
|
|
107
|
+
* the versions are mutually readable and a downgrade degrades rather than
|
|
108
|
+
* breaks. */
|
|
85
109
|
interface SnapshotEnvelope {
|
|
86
|
-
v: 1 | 2
|
|
110
|
+
v: 1 | 2 | 3
|
|
87
111
|
pins: PersistedStatusPin[]
|
|
88
112
|
}
|
|
89
113
|
|
|
114
|
+
const SNAPSHOT_VERSIONS = new Set([1, 2, 3])
|
|
115
|
+
|
|
90
116
|
function isPinRow(x: unknown): x is PersistedStatusPin {
|
|
91
117
|
if (x == null || typeof x !== 'object') return false
|
|
92
118
|
const o = x as Record<string, unknown>
|
|
@@ -98,7 +124,8 @@ function isPinRow(x: unknown): x is PersistedStatusPin {
|
|
|
98
124
|
typeof o.messageId === 'number' &&
|
|
99
125
|
(o.pending === undefined || typeof o.pending === 'boolean') &&
|
|
100
126
|
(o.expiresAt === undefined || typeof o.expiresAt === 'number') &&
|
|
101
|
-
(o.attempts === undefined || typeof o.attempts === 'number')
|
|
127
|
+
(o.attempts === undefined || typeof o.attempts === 'number') &&
|
|
128
|
+
(o.pinnedAt === undefined || typeof o.pinnedAt === 'number')
|
|
102
129
|
)
|
|
103
130
|
}
|
|
104
131
|
|
|
@@ -127,7 +154,7 @@ export function loadStatusPins(
|
|
|
127
154
|
}
|
|
128
155
|
if (parsed == null || typeof parsed !== 'object') return []
|
|
129
156
|
const env = parsed as Record<string, unknown>
|
|
130
|
-
if (
|
|
157
|
+
if (typeof env.v !== 'number' || !SNAPSHOT_VERSIONS.has(env.v) || !Array.isArray(env.pins)) return []
|
|
131
158
|
return env.pins.filter(isPinRow)
|
|
132
159
|
}
|
|
133
160
|
|
|
@@ -143,7 +170,7 @@ export function persistStatusPins(
|
|
|
143
170
|
snapshot: readonly PersistedStatusPin[],
|
|
144
171
|
log: (line: string) => void = (l) => process.stderr.write(l),
|
|
145
172
|
): void {
|
|
146
|
-
const env: SnapshotEnvelope = { v:
|
|
173
|
+
const env: SnapshotEnvelope = { v: 3, pins: [...snapshot] }
|
|
147
174
|
const tmp = path + '.tmp'
|
|
148
175
|
try {
|
|
149
176
|
fs.writeFileSync(tmp, JSON.stringify(env))
|
|
@@ -427,16 +454,34 @@ export function reconcileAndPersistStatusPin(args: {
|
|
|
427
454
|
/** Execute the real pin/unpin; returns the confirmed message id (pin) or
|
|
428
455
|
* null (cleared). Must never throw — API errors are swallowed inside. */
|
|
429
456
|
applyPin: () => Promise<{ messageId: number } | null>
|
|
457
|
+
/** Clock for the `pinnedAt` stamp (#3810). Defaults to wall clock. */
|
|
458
|
+
now?: number
|
|
430
459
|
log?: (line: string) => void
|
|
431
460
|
}): Promise<{ messageId: number } | null> {
|
|
432
461
|
const { path, fs, pinKey, chatId, op } = args
|
|
433
462
|
const log = args.log ?? ((l: string) => process.stderr.write(l))
|
|
463
|
+
const now = args.now ?? Date.now()
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* The claim age to persist for `messageId`. Carried forward from the row
|
|
467
|
+
* already on disk when it names the SAME message (a steady-state re-write
|
|
468
|
+
* must not reset the age and hand a stale pin a fresh TTL lease); stamped
|
|
469
|
+
* `now` for a genuinely new pin. Mirrors the in-memory `pinnedAt` rule in
|
|
470
|
+
* `status-pin-retarget.ts` so disk and memory can't disagree about age.
|
|
471
|
+
*/
|
|
472
|
+
const claimAge = (messageId: number): number => {
|
|
473
|
+
const existing = loadStatusPins(path, fs).find((p) => p.pinKey === pinKey)
|
|
474
|
+
return existing?.messageId === messageId && existing.pinnedAt != null
|
|
475
|
+
? existing.pinnedAt
|
|
476
|
+
: now
|
|
477
|
+
}
|
|
434
478
|
|
|
435
479
|
// Hold the per-path lock across the WHOLE op so no other writer (a banner
|
|
436
480
|
// persist, or another key's reconcile) can rebuild/overwrite the file during
|
|
437
481
|
// the applyPin await window and drop this key's pending/confirmed row.
|
|
438
482
|
return withStoreLock(path, async () => {
|
|
439
483
|
if (op.kind === 'pin') {
|
|
484
|
+
const pinnedAt = claimAge(op.messageId)
|
|
440
485
|
// Persist INTENT first, marked pending — BEFORE the pin API call. If we
|
|
441
486
|
// crash after the pin lands but before the confirm rewrite, this pending
|
|
442
487
|
// record is what boot cleanup uses to unpin the orphan.
|
|
@@ -444,7 +489,7 @@ export function reconcileAndPersistStatusPin(args: {
|
|
|
444
489
|
path,
|
|
445
490
|
fs,
|
|
446
491
|
pinKey,
|
|
447
|
-
{ pinKey, chatId, messageId: op.messageId, pending: true },
|
|
492
|
+
{ pinKey, chatId, messageId: op.messageId, pending: true, pinnedAt },
|
|
448
493
|
log,
|
|
449
494
|
)
|
|
450
495
|
const next = await args.applyPin()
|
|
@@ -459,7 +504,7 @@ export function reconcileAndPersistStatusPin(args: {
|
|
|
459
504
|
path,
|
|
460
505
|
fs,
|
|
461
506
|
pinKey,
|
|
462
|
-
{ pinKey, chatId, messageId: next.messageId },
|
|
507
|
+
{ pinKey, chatId, messageId: next.messageId, pinnedAt: claimAge(next.messageId) },
|
|
463
508
|
log,
|
|
464
509
|
)
|
|
465
510
|
return next
|
|
@@ -488,11 +533,15 @@ export function reconcileAndPersistStatusPin(args: {
|
|
|
488
533
|
if (next == null) {
|
|
489
534
|
applyStatusPinRow(path, fs, pinKey, null, log)
|
|
490
535
|
} else {
|
|
536
|
+
// Steady-state noop-clear (the worker feed calls syncPin on every edit):
|
|
537
|
+
// the pin is still up, so the row is preserved AND so is its original
|
|
538
|
+
// claim age — re-stamping it here would hand a genuinely old pin a fresh
|
|
539
|
+
// TTL lease on every feed edit and make it immortal (#3810).
|
|
491
540
|
applyStatusPinRow(
|
|
492
541
|
path,
|
|
493
542
|
fs,
|
|
494
543
|
pinKey,
|
|
495
|
-
{ pinKey, chatId, messageId: next.messageId },
|
|
544
|
+
{ pinKey, chatId, messageId: next.messageId, pinnedAt: claimAge(next.messageId) },
|
|
496
545
|
log,
|
|
497
546
|
)
|
|
498
547
|
}
|
|
@@ -99,6 +99,47 @@ export interface StoreOrphanRow {
|
|
|
99
99
|
pending?: boolean
|
|
100
100
|
/** Time-scoped `tool:` pin. Never a worker orphan; excluded. */
|
|
101
101
|
expiresAt?: number
|
|
102
|
+
/** Wall-clock ms the claim was first taken (#3810). Absent on a pre-v3 row. */
|
|
103
|
+
pinnedAt?: number
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The live worker-activity feed's liveness probe, as the reaper needs it.
|
|
108
|
+
* Nullable ON PURPOSE — see `groupPinStatus`.
|
|
109
|
+
*/
|
|
110
|
+
export interface WorkerFeedLivenessProbe {
|
|
111
|
+
hasRunningInFeed(feedKey: string): boolean
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Verdict for a GROUP-level pin key (`wk:group:<feedKey>`, the shape the
|
|
116
|
+
* coalesced feed produces since #3207). Vouched off the live feed rather than
|
|
117
|
+
* the sub-agent registry: `group:<feedKey>` is not a jsonl agent id.
|
|
118
|
+
*
|
|
119
|
+
* #3811 — the gateway used to write this as
|
|
120
|
+
* `feed?.hasRunningInFeed(key) ? 'running' : 'terminal'`, which collapses two
|
|
121
|
+
* very different facts into one verdict:
|
|
122
|
+
*
|
|
123
|
+
* - "a live feed exists and affirmatively says this group is done" → terminal
|
|
124
|
+
* - "there is NO feed object to ask" → nothing known
|
|
125
|
+
*
|
|
126
|
+
* A `'terminal'` verdict reaps at ANY age — it bypasses the TTL exemption
|
|
127
|
+
* entirely — so the second case could unpin a GENUINELY LIVE group pin out from
|
|
128
|
+
* under running workers during a window where `workerActivityFeed` is null
|
|
129
|
+
* (teardown/rebuild on a bridge flap, and every tick before the first bridge
|
|
130
|
+
* connect). That is the inverse of the stale-pin bug and much harder to notice:
|
|
131
|
+
* the user loses the pin for work that is still in flight.
|
|
132
|
+
*
|
|
133
|
+
* "No feed to ask" is `'unknown'`: the TTL still applies, so a truly stale pin
|
|
134
|
+
* is still bounded, but nothing is reaped on the strength of a question we
|
|
135
|
+
* never got to ask.
|
|
136
|
+
*/
|
|
137
|
+
export function groupPinStatus(
|
|
138
|
+
feed: WorkerFeedLivenessProbe | null | undefined,
|
|
139
|
+
feedKey: string,
|
|
140
|
+
): WorkerRegistryStatus {
|
|
141
|
+
if (feed == null) return 'unknown'
|
|
142
|
+
return feed.hasRunningInFeed(feedKey) ? 'running' : 'terminal'
|
|
102
143
|
}
|
|
103
144
|
|
|
104
145
|
/**
|
|
@@ -110,13 +151,21 @@ export interface StoreOrphanRow {
|
|
|
110
151
|
* it too, group-safely (per-message unpin of a bot-tracked row — never an
|
|
111
152
|
* unpin-all, never a human pin).
|
|
112
153
|
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
154
|
+
* AGE (#3810). A v3+ row carries the real `pinnedAt` the claim was taken at, so
|
|
155
|
+
* a store orphan ages honestly and the ordinary TTL gate applies to it exactly
|
|
156
|
+
* as it does to an in-memory claim. A pre-v3 row has no timestamp and keeps the
|
|
157
|
+
* old conservative stamp (`pinnedAt = now`, i.e. TERMINAL-verdict reaping only)
|
|
158
|
+
* rather than guessing an age — but that case now self-clears within one
|
|
159
|
+
* restart, because every write from v3 onward records the field.
|
|
160
|
+
*
|
|
161
|
+
* Why the honest age matters: `storeOnlyWorkerPinCandidates` was the ONLY net
|
|
162
|
+
* for a `wk:` row with no in-memory claim, and with the TTL permanently
|
|
163
|
+
* disabled a `wk:<agentId>` row whose turnsDb row had been pruned (verdict
|
|
164
|
+
* `'unknown'`, never `'terminal'`) was never mid-session reaped at all — its
|
|
165
|
+
* stale `🛠 Worker` pin sat at the top of the chat until the next gateway boot.
|
|
166
|
+
*
|
|
117
167
|
* `pending` rows (pin API in-flight) and time-scoped `tool:` rows are excluded,
|
|
118
|
-
* as are rows already tracked in memory (the in-memory reaper owns those
|
|
119
|
-
* a real `pinnedAt` that can drive the TTL).
|
|
168
|
+
* as are rows already tracked in memory (the in-memory reaper owns those).
|
|
120
169
|
*/
|
|
121
170
|
export function storeOnlyWorkerPinCandidates(args: {
|
|
122
171
|
rows: Iterable<StoreOrphanRow>
|
|
@@ -133,7 +182,7 @@ export function storeOnlyWorkerPinCandidates(args: {
|
|
|
133
182
|
out.push({
|
|
134
183
|
pinKey: r.pinKey,
|
|
135
184
|
chatId: r.chatId,
|
|
136
|
-
pinnedAt: args.now,
|
|
185
|
+
pinnedAt: r.pinnedAt ?? args.now,
|
|
137
186
|
messageId: r.messageId,
|
|
138
187
|
})
|
|
139
188
|
}
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
type AnyButton,
|
|
35
35
|
type ButtonValidationError,
|
|
36
36
|
} from './telegram-button-constraints.js'
|
|
37
|
+
import type { RetryCallOpts } from './retry-api-call.js'
|
|
37
38
|
|
|
38
39
|
/** Prefix used to namespace agent-emitted callback_data on the wire. */
|
|
39
40
|
export const AGENT_CALLBACK_PREFIX = 'agent:'
|
|
@@ -496,6 +497,11 @@ export function validateAndWrapAgentKeyboard(
|
|
|
496
497
|
* Minimal callback-context shape the helper needs. Real grammy
|
|
497
498
|
* `Context` satisfies this; tests can implement a lightweight fake
|
|
498
499
|
* without dragging the grammy types in.
|
|
500
|
+
*
|
|
501
|
+
* `editMessageReplyMarkup` / `reply` are REQUIRED (not optional) because
|
|
502
|
+
* they are the two rungs of the repaint-failure ladder (#3891). A context
|
|
503
|
+
* that cannot disarm its own keyboard is exactly the context that leaves
|
|
504
|
+
* the operator tapping a corpse, so the type refuses to construct one.
|
|
499
505
|
*/
|
|
500
506
|
export interface FinalizeCallbackContext {
|
|
501
507
|
answerCallbackQuery: (
|
|
@@ -505,8 +511,68 @@ export interface FinalizeCallbackContext {
|
|
|
505
511
|
text: string | { markdown: string },
|
|
506
512
|
opts?: Record<string, unknown>,
|
|
507
513
|
) => Promise<unknown>
|
|
514
|
+
/** Rung 2 of the ladder — strip `reply_markup` without touching the body. */
|
|
515
|
+
editMessageReplyMarkup: (
|
|
516
|
+
opts?: Record<string, unknown>,
|
|
517
|
+
) => Promise<unknown>
|
|
518
|
+
/** Rung 3 of the ladder — a fresh in-channel message when the card is unfixable. */
|
|
519
|
+
reply: (
|
|
520
|
+
text: string,
|
|
521
|
+
opts?: Record<string, unknown>,
|
|
522
|
+
) => Promise<unknown>
|
|
523
|
+
/**
|
|
524
|
+
* The raw `callback_query` update, typed `unknown` on purpose: this
|
|
525
|
+
* helper only reads `message.chat.id` off it (defensively, at runtime)
|
|
526
|
+
* to scope the retry policy's flood window, and a structural type here
|
|
527
|
+
* would have to track grammy's `MaybeInaccessibleMessage` union for no
|
|
528
|
+
* benefit. See {@link extractCallbackChatId}.
|
|
529
|
+
*/
|
|
530
|
+
callbackQuery?: unknown
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Best-effort `chat_id` for the retry policy's scope-precise flood window.
|
|
535
|
+
*
|
|
536
|
+
* Returns `undefined` rather than throwing on ANY unexpected shape — a
|
|
537
|
+
* missing scope degrades the 429 to a `global` window (still recorded, still
|
|
538
|
+
* respected), whereas a throw here would break the tap path outright.
|
|
539
|
+
*/
|
|
540
|
+
export function extractCallbackChatId(callbackQuery: unknown): string | undefined {
|
|
541
|
+
const msg = (callbackQuery as { message?: unknown } | null | undefined)?.message
|
|
542
|
+
const chat = (msg as { chat?: unknown } | null | undefined)?.chat
|
|
543
|
+
const id = (chat as { id?: unknown } | null | undefined)?.id
|
|
544
|
+
if (typeof id === 'number' && Number.isFinite(id)) return String(id)
|
|
545
|
+
if (typeof id === 'string' && id !== '') return id
|
|
546
|
+
return undefined
|
|
508
547
|
}
|
|
509
548
|
|
|
549
|
+
/**
|
|
550
|
+
* The retry/flood seam every leg of {@link finalizeCallback} transits.
|
|
551
|
+
*
|
|
552
|
+
* Structurally the gateway's `robustApiCall` (chat-lock → send gate →
|
|
553
|
+
* `createRetryApiCall`). Declared here rather than imported as
|
|
554
|
+
* `typeof robustApiCall` so this module keeps its zero-dependency-on-gateway
|
|
555
|
+
* shape and tests can hand in a counting fake.
|
|
556
|
+
*/
|
|
557
|
+
export type FinalizeApiCall = <T>(
|
|
558
|
+
fn: () => Promise<T>,
|
|
559
|
+
opts?: RetryCallOpts,
|
|
560
|
+
) => Promise<T>
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Default notice posted when BOTH the body repaint and the keyboard strip
|
|
564
|
+
* fail — the card is still standing with live-looking buttons and the only
|
|
565
|
+
* honest thing left is to say so out loud, in the same chat.
|
|
566
|
+
*
|
|
567
|
+
* Deliberately a LITERAL plain string (no markdown, no entities): the most
|
|
568
|
+
* common cause of a repaint failure is Telegram rejecting the body's
|
|
569
|
+
* entities, so a rich fallback would be likely to fail the same way.
|
|
570
|
+
*/
|
|
571
|
+
export const DEAD_CARD_NOTICE =
|
|
572
|
+
'⚠️ Your tap was applied, but this card could not be updated. ' +
|
|
573
|
+
'The buttons on it are STALE — tapping them again will not change anything. ' +
|
|
574
|
+
'Scroll down for the outcome, or ask the agent to re-send the card.'
|
|
575
|
+
|
|
510
576
|
export interface FinalizeCallbackOptions {
|
|
511
577
|
/**
|
|
512
578
|
* Toast text shown to the operator via `answerCallbackQuery`. Telegram
|
|
@@ -549,52 +615,167 @@ export interface FinalizeCallbackOptions {
|
|
|
549
615
|
* actions that shell to the host CLI, operator-event dismiss, etc).
|
|
550
616
|
*/
|
|
551
617
|
synthInbound?: () => void | Promise<void>
|
|
618
|
+
/**
|
|
619
|
+
* The retry/flood policy every Telegram call in this helper transits
|
|
620
|
+
* (#3891). REQUIRED — not optional-with-a-passthrough-default, because a
|
|
621
|
+
* passthrough default is exactly the bug: the tap path silently opted out
|
|
622
|
+
* of the one policy that records 429s, and nothing failed. Making it a
|
|
623
|
+
* mandatory field turns "did this call site wire the policy?" into a
|
|
624
|
+
* `tsc` error at all 8 call sites instead of a review checklist item.
|
|
625
|
+
*
|
|
626
|
+
* Pass the gateway's `robustApiCall`.
|
|
627
|
+
*/
|
|
628
|
+
apiCall: FinalizeApiCall
|
|
552
629
|
/** Logger seam for tests. Defaults to stderr. */
|
|
553
630
|
log?: (line: string) => void
|
|
554
631
|
}
|
|
555
632
|
|
|
633
|
+
/**
|
|
634
|
+
* Rungs 2 and 3 of the repaint-failure ladder (#3891).
|
|
635
|
+
*
|
|
636
|
+
* Precondition: the body repaint already failed AFTER the retry policy had
|
|
637
|
+
* its go, so the card is standing with a live `reply_markup` over a decision
|
|
638
|
+
* that is already resolved. Getting the keyboard OFF is what matters here —
|
|
639
|
+
* the stale body text is cosmetic by comparison, an un-disarmed keyboard is
|
|
640
|
+
* an invitation to re-tap.
|
|
641
|
+
*
|
|
642
|
+
* Never throws. A throw would land back in `finalizeCallback` and could skip
|
|
643
|
+
* invariant 3 (the model wake-up), trading a confusing card for a wedged turn.
|
|
644
|
+
*/
|
|
645
|
+
async function disarmDeadCard(
|
|
646
|
+
ctx: FinalizeCallbackContext,
|
|
647
|
+
apiCall: FinalizeApiCall,
|
|
648
|
+
scope: RetryCallOpts,
|
|
649
|
+
log: (line: string) => void,
|
|
650
|
+
): Promise<void> {
|
|
651
|
+
try {
|
|
652
|
+
await apiCall(
|
|
653
|
+
() => ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } }),
|
|
654
|
+
{ ...scope, verb: 'editMessageReplyMarkup' },
|
|
655
|
+
)
|
|
656
|
+
log(
|
|
657
|
+
'finalizeCallback: repaint failed but the keyboard was stripped — ' +
|
|
658
|
+
'the card shows stale text and is no longer tappable\n',
|
|
659
|
+
)
|
|
660
|
+
return
|
|
661
|
+
} catch (err) {
|
|
662
|
+
log(`finalizeCallback: editMessageReplyMarkup fallback failed: ${(err as Error).message}\n`)
|
|
663
|
+
}
|
|
664
|
+
// Rung 3 — the card cannot be changed at all. Say so out loud rather than
|
|
665
|
+
// leave a live-looking keyboard with no explanation behind it.
|
|
666
|
+
try {
|
|
667
|
+
await apiCall(
|
|
668
|
+
() => ctx.reply(DEAD_CARD_NOTICE, { link_preview_options: { is_disabled: true } }),
|
|
669
|
+
{ ...scope, verb: 'sendMessage' },
|
|
670
|
+
)
|
|
671
|
+
} catch (err) {
|
|
672
|
+
log(`finalizeCallback: dead-card notice failed: ${(err as Error).message}\n`)
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
556
676
|
/**
|
|
557
677
|
* Apply the three-invariant finalize pattern. See module docstring
|
|
558
678
|
* above for design rationale.
|
|
559
679
|
*
|
|
560
|
-
* Order: ack → edit → synth. The ack is
|
|
561
|
-
* Telegram API doesn't delay the visible
|
|
562
|
-
* is awaited so `synthInbound` doesn't race
|
|
563
|
-
* visual confirmation. Each step's error is
|
|
564
|
-
* partial success is preferred to "tap looked dead
|
|
565
|
-
* stayed stuck" full failure.
|
|
680
|
+
* Order: ack → edit (→ disarm ladder on failure) → synth. The ack is
|
|
681
|
+
* fired-and-forgotten (so a slow Telegram API doesn't delay the visible
|
|
682
|
+
* state change), but the edit is awaited so `synthInbound` doesn't race
|
|
683
|
+
* ahead of the operator's visual confirmation. Each step's error is
|
|
684
|
+
* logged + swallowed — partial success is preferred to "tap looked dead
|
|
685
|
+
* AND the model stayed stuck" full failure.
|
|
686
|
+
*
|
|
687
|
+
* ── #3891: every leg goes through `opts.apiCall` ──────────────────────
|
|
688
|
+
* Both legs used to call grammy's CONTEXT methods raw. That bypassed
|
|
689
|
+
* `robustApiCall` / `createRetryApiCall` entirely, with two consequences
|
|
690
|
+
* observed together in one live incident:
|
|
691
|
+
*
|
|
692
|
+
* 1. A 429 earned on the tap path never reached `onFloodWait`, so it was
|
|
693
|
+
* never folded into `429-ledger.json` and never opened a window in
|
|
694
|
+
* `flood-windows.json`. Every consumer of that state — the send gate,
|
|
695
|
+
* `switchroom doctor`'s flood-pressure classifier, the wedge-watchdog's
|
|
696
|
+
* Esc suppression — was reasoning about the bot's 429 pressure from a
|
|
697
|
+
* picture with the operator's own taps cut out of it.
|
|
698
|
+
* 2. With no retry, ONE transient failure at the edit left the card
|
|
699
|
+
* holding its `reply_markup`: still live, still tappable, decision
|
|
700
|
+
* already resolved. The operator taps a corpse and gets a bare
|
|
701
|
+
* "doesn't want to proceed" with no explanation.
|
|
702
|
+
*
|
|
703
|
+
* Both legs are tagged `priorityClass: 'critical'` and carry no
|
|
704
|
+
* `messageId`/`editPayload`. That is deliberate: `critical` is the one
|
|
705
|
+
* class the send gate never SHEDS, and omitting the edit-coalescing keys
|
|
706
|
+
* keeps the gate from treating a finalize repaint as a droppable
|
|
707
|
+
* last-write-wins card edit. A finalize repaint is the operator's only
|
|
708
|
+
* visual confirmation that a human-in-the-loop decision resolved — shedding
|
|
709
|
+
* or coalescing it away IS the incident this fix exists to stop.
|
|
710
|
+
*
|
|
711
|
+
* ── The repaint-failure ladder ────────────────────────────────────────
|
|
712
|
+
* `robustApiCall` already swallows the two BENIGN edit failures
|
|
713
|
+
* (MESSAGE_NOT_MODIFIED, MESSAGE_TO_EDIT_NOT_FOUND → `undefined`) and
|
|
714
|
+
* retries transient ones. So anything reaching the catch below is a real,
|
|
715
|
+
* post-retry failure — and the card is still standing with live buttons.
|
|
716
|
+
* Three rungs, cheapest and most-likely-to-work first:
|
|
717
|
+
*
|
|
718
|
+
* 1. body repaint (`editMessageText`) — the good outcome.
|
|
719
|
+
* 2. keyboard strip (`editMessageReplyMarkup`) — the body is what usually
|
|
720
|
+
* breaks (entity/markdown parse, length); an empty-keyboard edit sends
|
|
721
|
+
* no body at all, so it survives the failure mode that killed rung 1.
|
|
722
|
+
* Card keeps stale TEXT but is no longer tappable.
|
|
723
|
+
* 3. plain-text notice (`reply`) — the card is unfixable, so
|
|
724
|
+
* say so in-channel rather than let it keep presenting as live.
|
|
725
|
+
*
|
|
726
|
+
* Every rung is best-effort and logged; none of them may block invariant 3.
|
|
727
|
+
* NOTE the ladder changes no approval semantics — it only repaints and
|
|
728
|
+
* narrates. The decision was resolved by the caller before we were called.
|
|
566
729
|
*/
|
|
567
730
|
export async function finalizeCallback(
|
|
568
731
|
ctx: FinalizeCallbackContext,
|
|
569
732
|
opts: FinalizeCallbackOptions,
|
|
570
733
|
): Promise<void> {
|
|
571
734
|
const log = opts.log ?? ((line: string) => process.stderr.write(line))
|
|
735
|
+
const apiCall = opts.apiCall
|
|
736
|
+
const chatId = extractCallbackChatId(ctx.callbackQuery)
|
|
737
|
+
const scope: RetryCallOpts = {
|
|
738
|
+
...(chatId != null ? { chat_id: chatId } : {}),
|
|
739
|
+
priorityClass: 'critical',
|
|
740
|
+
}
|
|
572
741
|
// Invariant 1 — toast. Fire-and-forget; we don't want a slow
|
|
573
742
|
// answerCallbackQuery round-trip to delay the message edit.
|
|
574
|
-
void
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
743
|
+
void apiCall(
|
|
744
|
+
() =>
|
|
745
|
+
ctx.answerCallbackQuery({
|
|
746
|
+
text: opts.ackText,
|
|
747
|
+
...(opts.alert ? { show_alert: true } : {}),
|
|
748
|
+
}),
|
|
749
|
+
{ ...scope, verb: 'answerCallbackQuery' },
|
|
750
|
+
).catch((err: unknown) => {
|
|
578
751
|
log(`finalizeCallback: answerCallbackQuery failed: ${(err as Error).message}\n`)
|
|
579
752
|
})
|
|
580
753
|
// Invariant 2 — strip keyboard + append status line, atomic edit.
|
|
754
|
+
let repainted = true
|
|
581
755
|
try {
|
|
582
|
-
await
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
756
|
+
await apiCall(
|
|
757
|
+
() =>
|
|
758
|
+
ctx.editMessageText(
|
|
759
|
+
opts.literalText ? opts.newText : { markdown: opts.newText },
|
|
760
|
+
{
|
|
761
|
+
reply_markup: { inline_keyboard: [] },
|
|
762
|
+
// Default link_preview_options off — most finalized cards don't
|
|
763
|
+
// benefit from preview cards, and a stale preview survives the
|
|
764
|
+
// edit otherwise.
|
|
765
|
+
link_preview_options: { is_disabled: true },
|
|
766
|
+
},
|
|
767
|
+
),
|
|
768
|
+
{ ...scope, verb: 'editMessageText' },
|
|
591
769
|
)
|
|
592
770
|
} catch (err) {
|
|
593
771
|
// MESSAGE_NOT_MODIFIED (text didn't change) and MESSAGE_TO_EDIT_NOT_FOUND
|
|
594
|
-
// (operator already deleted the card) are both benign
|
|
595
|
-
//
|
|
772
|
+
// (operator already deleted the card) are both benign AND are already
|
|
773
|
+
// swallowed inside the retry policy, so they never land here. Anything
|
|
774
|
+
// that does is a real failure that left the keyboard standing.
|
|
775
|
+
repainted = false
|
|
596
776
|
log(`finalizeCallback: editMessageText failed: ${(err as Error).message}\n`)
|
|
597
777
|
}
|
|
778
|
+
if (!repainted) await disarmDeadCard(ctx, apiCall, scope, log)
|
|
598
779
|
// Invariant 3 — model wake-up (when applicable).
|
|
599
780
|
if (opts.synthInbound != null) {
|
|
600
781
|
try {
|
|
@@ -34,6 +34,12 @@ import {
|
|
|
34
34
|
parseResetTime,
|
|
35
35
|
} from './model-unavailable.js'
|
|
36
36
|
import { classify429Detail } from './throttle-tier.js'
|
|
37
|
+
import {
|
|
38
|
+
PROVIDER_CREDIT_REGISTRY,
|
|
39
|
+
attributeProvider,
|
|
40
|
+
describeProviderCreditRemedy,
|
|
41
|
+
type ProviderCreditEntry,
|
|
42
|
+
} from './provider-credit.js'
|
|
37
43
|
import { classifyClaudeError } from './operator-events.js'
|
|
38
44
|
import { stripRawErrorBytes, extractRequestId } from './raw-error-scrub.js'
|
|
39
45
|
import { fmtLocalClock, tzAbbrev } from './shared/local-time.js'
|
|
@@ -48,10 +54,24 @@ export type LlmErrorKind =
|
|
|
48
54
|
| 'quota_wall'
|
|
49
55
|
| 'auth'
|
|
50
56
|
| 'infra_misconfig'
|
|
57
|
+
/**
|
|
58
|
+
* A THIRD-PARTY model provider (OpenRouter / OpenAI / Perplexity) is out of
|
|
59
|
+
* credit. OPERATOR-actionable, never user-actionable — its `coreText` is a
|
|
60
|
+
* deliberately diagnosis-free one-liner and its recommendation names the
|
|
61
|
+
* vendor console, never `/auth`. Kept distinct from `quota_wall` (the
|
|
62
|
+
* Anthropic subscription window, which resets on a clock and is addressed by
|
|
63
|
+
* switching account slots); a vendor balance resets only when someone pays.
|
|
64
|
+
*/
|
|
65
|
+
| 'provider_credit'
|
|
51
66
|
| 'transient'
|
|
52
67
|
| 'unknown'
|
|
53
68
|
|
|
54
|
-
export type LlmErrorSource =
|
|
69
|
+
export type LlmErrorSource =
|
|
70
|
+
| 'anthropic'
|
|
71
|
+
| 'litellm-local'
|
|
72
|
+
/** A paid third-party model provider reached THROUGH the proxy (OpenRouter, OpenAI). */
|
|
73
|
+
| 'external-provider'
|
|
74
|
+
| 'network'
|
|
55
75
|
|
|
56
76
|
export interface ParsedLlmError {
|
|
57
77
|
kind: LlmErrorKind
|
|
@@ -65,6 +85,14 @@ export interface ParsedLlmError {
|
|
|
65
85
|
model?: string
|
|
66
86
|
/** Anthropic `request_id`, when present — the strongest dedup key. */
|
|
67
87
|
requestId?: string
|
|
88
|
+
/**
|
|
89
|
+
* Registry id of the third-party provider the error was attributed to
|
|
90
|
+
* (`openrouter` / `openai` / `perplexity`), when `kind === 'provider_credit'`
|
|
91
|
+
* and the raw text named one. An ID only — never raw error text, never a
|
|
92
|
+
* credential — so the recommendation line can point at the right console
|
|
93
|
+
* without this struct ever carrying the raw string it was parsed from.
|
|
94
|
+
*/
|
|
95
|
+
providerId?: string
|
|
68
96
|
source: LlmErrorSource
|
|
69
97
|
/** True when the harness is still retrying this error internally (mid-retry). */
|
|
70
98
|
autoRetrying: boolean
|
|
@@ -96,7 +124,10 @@ const TRANSIENT_KINDS: ReadonlySet<LlmErrorKind> = new Set<LlmErrorKind>([
|
|
|
96
124
|
|
|
97
125
|
/** The always-actionable kinds — NEVER silenced, even inside a collapse window. */
|
|
98
126
|
export function isActionableKind(kind: LlmErrorKind): boolean {
|
|
99
|
-
|
|
127
|
+
// `provider_credit` joins the never-silenced set: an unpaid vendor balance
|
|
128
|
+
// does not clear on its own, so collapsing it inside a dedup window would
|
|
129
|
+
// hide the one signal that needs a human.
|
|
130
|
+
return kind === 'auth' || kind === 'quota_wall' || kind === 'provider_credit'
|
|
100
131
|
}
|
|
101
132
|
|
|
102
133
|
/**
|
|
@@ -118,6 +149,8 @@ export function parseLlmError(
|
|
|
118
149
|
resetAt != null ? Math.max(0, resetAt.getTime() - Date.now()) : undefined
|
|
119
150
|
|
|
120
151
|
const { kind, source } = classifyKindAndSource(text)
|
|
152
|
+
const providerId =
|
|
153
|
+
kind === 'provider_credit' ? (attributeProvider(text)?.id ?? undefined) : undefined
|
|
121
154
|
|
|
122
155
|
// Retry / terminal semantics. Only the transient family can be mid-retry;
|
|
123
156
|
// auth / quota_wall / model_unavailable are terminal by construction.
|
|
@@ -143,6 +176,7 @@ export function parseLlmError(
|
|
|
143
176
|
...(retryAfterMs != null ? { retryAfterMs } : {}),
|
|
144
177
|
...(model != null ? { model } : {}),
|
|
145
178
|
...(requestId != null ? { requestId } : {}),
|
|
179
|
+
...(providerId != null ? { providerId } : {}),
|
|
146
180
|
source,
|
|
147
181
|
autoRetrying,
|
|
148
182
|
terminal,
|
|
@@ -165,6 +199,15 @@ function classifyKindAndSource(text: string): { kind: LlmErrorKind; source: LlmE
|
|
|
165
199
|
|
|
166
200
|
// 1. Auth — always terminal, always actionable.
|
|
167
201
|
const claudeKind = classifyClaudeError({ message: text, type: text })
|
|
202
|
+
// 1b. THIRD-PARTY provider credit wall. Placed immediately after the auth
|
|
203
|
+
// resolution and BEFORE isLitellmProxyLocal429 / detectModelUnavailable:
|
|
204
|
+
// OpenAI reports an exhausted balance as HTTP 429 `insufficient_quota`,
|
|
205
|
+
// and the 429 matchers below would otherwise swallow it as a transient
|
|
206
|
+
// "retrying automatically" — the single most misleading thing to tell
|
|
207
|
+
// someone whose provider has no money left.
|
|
208
|
+
if (claudeKind === 'provider-credit-exhausted') {
|
|
209
|
+
return { kind: 'provider_credit', source: 'external-provider' }
|
|
210
|
+
}
|
|
168
211
|
if (claudeKind === 'proxy-misconfig') {
|
|
169
212
|
return { kind: 'infra_misconfig', source: 'litellm-local' }
|
|
170
213
|
}
|
|
@@ -216,6 +259,12 @@ function classifyKindAndSource(text: string): { kind: LlmErrorKind; source: LlmE
|
|
|
216
259
|
return { kind: 'unknown', source: 'anthropic' }
|
|
217
260
|
}
|
|
218
261
|
|
|
262
|
+
/** Resolve a registry entry from a persisted `providerId`, or null. */
|
|
263
|
+
function providerById(id: string | undefined): ProviderCreditEntry | null {
|
|
264
|
+
if (id == null) return null
|
|
265
|
+
return PROVIDER_CREDIT_REGISTRY.find(p => p.id === id) ?? null
|
|
266
|
+
}
|
|
267
|
+
|
|
219
268
|
function buildCoreText(kind: LlmErrorKind, source: LlmErrorSource): string {
|
|
220
269
|
switch (kind) {
|
|
221
270
|
case 'rate_limit':
|
|
@@ -230,6 +279,10 @@ function buildCoreText(kind: LlmErrorKind, source: LlmErrorSource): string {
|
|
|
230
279
|
return 'Claude login needs re-authentication.'
|
|
231
280
|
case 'infra_misconfig':
|
|
232
281
|
return 'Local model-gateway auth misconfig (proxy fallback dropped the OAuth header).'
|
|
282
|
+
case 'provider_credit':
|
|
283
|
+
// Diagnosis-free by design — this string is the ceiling on what a
|
|
284
|
+
// non-operator may ever be told about a vendor billing wall.
|
|
285
|
+
return 'An upstream model provider is out of credit — the operator has been notified.'
|
|
233
286
|
case 'transient':
|
|
234
287
|
return source === 'network'
|
|
235
288
|
? "Couldn't reach Anthropic (network) — retrying automatically."
|
|
@@ -302,6 +355,10 @@ function buildRecommendation(parsed: ParsedLlmError, tz: string): string | undef
|
|
|
302
355
|
// Operator-facing — NO re-auth wording (the login is fine). Points at the
|
|
303
356
|
// real remedy: the local LiteLLM proxy fallback config.
|
|
304
357
|
return '→ Fix the LiteLLM proxy fallback config (deployment missing OAuth passthrough).'
|
|
358
|
+
case 'provider_credit':
|
|
359
|
+
// Operator-facing. NO `/auth` wording: the credential is valid, the
|
|
360
|
+
// balance is empty, and rotating an Anthropic slot changes nothing.
|
|
361
|
+
return `→ ${describeProviderCreditRemedy(providerById(parsed.providerId))}`
|
|
305
362
|
case 'quota_wall': {
|
|
306
363
|
const reset = formatResetClock(parsed.resetAt, tz)
|
|
307
364
|
return reset
|
|
@@ -377,6 +434,8 @@ function kindEmoji(kind: LlmErrorKind): string {
|
|
|
377
434
|
return '🔑'
|
|
378
435
|
case 'infra_misconfig':
|
|
379
436
|
return '🛠️'
|
|
437
|
+
case 'provider_credit':
|
|
438
|
+
return '💳'
|
|
380
439
|
case 'transient':
|
|
381
440
|
return '🌐'
|
|
382
441
|
case 'unknown':
|