switchroom 0.19.42 → 0.19.44
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/agent-scheduler/index.js +4 -1
- package/dist/auth-broker/index.js +284 -124
- package/dist/cli/notion-write-pretool.mjs +4 -1
- package/dist/cli/switchroom.js +4094 -2711
- package/dist/host-control/main.js +472 -155
- package/dist/vault/approvals/kernel-server.js +231 -71
- package/dist/vault/broker/server.js +293 -133
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +93 -8
- package/telegram-plugin/bridge/bridge.ts +1 -1
- package/telegram-plugin/card-layout.ts +63 -1
- package/telegram-plugin/dist/bridge/bridge.js +4 -1
- package/telegram-plugin/dist/gateway/gateway.js +1682 -936
- package/telegram-plugin/dist/server.js +5 -2
- package/telegram-plugin/flood-429-ledger.ts +5 -3
- package/telegram-plugin/flood-circuit-breaker.ts +11 -3
- package/telegram-plugin/format.ts +223 -13
- package/telegram-plugin/gateway/boot-card.ts +7 -2
- package/telegram-plugin/gateway/flood-reply-queue.ts +5 -0
- package/telegram-plugin/gateway/gateway.ts +49 -51
- package/telegram-plugin/gateway/handback-orphan-recovery.ts +69 -0
- package/telegram-plugin/gateway/handback-preturn-signal.ts +170 -32
- package/telegram-plugin/gateway/ipc-protocol.ts +31 -3
- package/telegram-plugin/gateway/ipc-server.ts +13 -4
- package/telegram-plugin/gateway/obligation-store.ts +55 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +127 -11
- package/telegram-plugin/gateway/outbox-sweep.ts +371 -61
- package/telegram-plugin/gateway/rollout-status-edit.ts +175 -0
- package/telegram-plugin/gateway/stream-render.ts +5 -0
- package/telegram-plugin/gateway/update-announce.ts +11 -4
- package/telegram-plugin/hooks/audience-classify.d.mts +50 -0
- package/telegram-plugin/hooks/audience-classify.mjs +364 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +86 -3
- package/telegram-plugin/hooks/silent-end-scan.mjs +80 -4
- package/telegram-plugin/outbox.ts +103 -2
- package/telegram-plugin/render/rich-render.ts +10 -2
- package/telegram-plugin/reply-quote.ts +150 -0
- package/telegram-plugin/retry-api-call.ts +254 -11
- package/telegram-plugin/shared/bot-runtime.ts +69 -34
- package/telegram-plugin/shared/gw-trace-gate.ts +14 -2
- package/telegram-plugin/silent-end.ts +34 -2
- package/telegram-plugin/status-no-truncate.ts +31 -16
- package/telegram-plugin/stream-controller.ts +44 -9
- package/telegram-plugin/stream-reply-handler.ts +5 -3
- package/telegram-plugin/tests/boot-card-render.test.ts +2 -1
- package/telegram-plugin/tests/card-budget-never-overflows.test.ts +165 -0
- package/telegram-plugin/tests/card-hard-truncate-entity-safe.test.ts +153 -0
- package/telegram-plugin/tests/card-variants.golden.txt +9 -9
- package/telegram-plugin/tests/flood-reply-queue.test.ts +89 -4
- package/telegram-plugin/tests/format-consistency.test.ts +160 -14
- package/telegram-plugin/tests/handback-orphan-recovery.test.ts +131 -0
- package/telegram-plugin/tests/handback-preturn-signal.test.ts +212 -17
- package/telegram-plugin/tests/nested-indent-idiom.test.ts +162 -0
- package/telegram-plugin/tests/nested-prefix-indent.test.ts +115 -0
- package/telegram-plugin/tests/no-robust-api-call-factory.test.ts +62 -0
- package/telegram-plugin/tests/obligation-store.test.ts +51 -0
- package/telegram-plugin/tests/outbox-audience-3865.test.ts +579 -0
- package/telegram-plugin/tests/outbox-provenance-4141.test.ts +1126 -0
- package/telegram-plugin/tests/outbox-sweep-single-flight.test.ts +138 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +12 -10
- package/telegram-plugin/tests/plain-fallback-4096-cap.test.ts +509 -0
- package/telegram-plugin/tests/reply-quote-wire.test.ts +433 -0
- package/telegram-plugin/tests/retry-grammy-http-error.test.ts +404 -0
- package/telegram-plugin/tests/rollout-narration-edit-socket.test.ts +170 -0
- package/telegram-plugin/tests/rollout-status-edit-retry-policy.test.ts +366 -0
- package/telegram-plugin/tests/rollout-status-edit.test.ts +140 -0
- package/telegram-plugin/tests/rollout-status-wiring.test.ts +40 -7
- package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +4 -0
- package/telegram-plugin/tests/tg-post-retry-attribution.test.ts +196 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +23 -17
- package/telegram-plugin/tool-activity-summary.ts +58 -30
- package/telegram-plugin/uat/scenarios/jtbd-foreground-subagent-activity-dm.test.ts +3 -2
- package/vendor/hindsight-memory/scripts/lib/config.py +15 -0
- package/vendor/hindsight-memory/scripts/tests/test_retain_provenance_tag.py +135 -0
- package/vendor/hindsight-memory/settings.json +1 -1
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
|
3
|
+
import { join, relative } from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { renderBootCard } from '../gateway/boot-card.js'
|
|
6
|
+
import { renderUpdateOutcomeLine } from '../gateway/update-announce.js'
|
|
7
|
+
import { NESTED_PREFIX } from '../status-no-truncate.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* #4115 — ONE indent idiom in the repo, and it is the one that actually
|
|
11
|
+
* renders.
|
|
12
|
+
*
|
|
13
|
+
* #3668 fixed the status card's nested lines by moving `NESTED_PREFIX` off
|
|
14
|
+
* ASCII spaces onto U+2800 BRAILLE PATTERN BLANK: card bodies reach Telegram
|
|
15
|
+
* as GFM markdown parsed SERVER-SIDE, and that parser left-trims a leading
|
|
16
|
+
* whitespace run off a content line, so an ASCII (or U+00A0) indent is dropped
|
|
17
|
+
* on the wire and the line renders FLAT. Live-verified on a phone 2026-07-26 —
|
|
18
|
+
* the record is on `WORKER_STEP_INDENT` in `status-no-truncate.ts`.
|
|
19
|
+
*
|
|
20
|
+
* The boot card and the update-announce line were NOT migrated by that PR, so
|
|
21
|
+
* they kept shipping the collapsed non-indent to the user. These tests assert
|
|
22
|
+
* what RENDERS (the leading run survives a server-side left-trim), not merely
|
|
23
|
+
* that a constant was referenced — a byte-equality assertion against the old
|
|
24
|
+
* literal is exactly what let the pre-#3668 indent ship green and inert.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** Every rule a server-side trimmer could plausibly use. */
|
|
28
|
+
const isTrimmableWhitespace = (ch: string): boolean =>
|
|
29
|
+
/\s/u.test(ch) || /\p{White_Space}/u.test(ch) || /\p{Zs}/u.test(ch)
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The assertion that ties to rendering: after a leading-whitespace left-trim
|
|
33
|
+
* of the kind Telegram applies, the line STILL carries its full indent, and it
|
|
34
|
+
* does not lead with either character already proven flat on a phone.
|
|
35
|
+
*/
|
|
36
|
+
function expectSurvivesLeftTrim(line: string): void {
|
|
37
|
+
expect(line.startsWith(NESTED_PREFIX)).toBe(true)
|
|
38
|
+
expect(
|
|
39
|
+
line.replace(/^[\s\p{White_Space}]+/u, '').startsWith(NESTED_PREFIX),
|
|
40
|
+
`nested line "${line.slice(0, 40)}…" loses its indent to a leading-whitespace trim — ` +
|
|
41
|
+
`it renders FLAT on a phone (#3668/#4115). Use NESTED_PREFIX.`,
|
|
42
|
+
).toBe(true)
|
|
43
|
+
expect(line.startsWith(' ')).toBe(false)
|
|
44
|
+
expect(line.startsWith(' ')).toBe(false)
|
|
45
|
+
const indent = [...line.slice(0, line.indexOf('↳'))]
|
|
46
|
+
expect(indent.length).toBeGreaterThan(0)
|
|
47
|
+
for (const ch of indent) expect(isTrimmableWhitespace(ch)).toBe(false)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Card lines with the stack's own break chrome removed. */
|
|
51
|
+
function linesOf(card: string): string[] {
|
|
52
|
+
return card.split('\n').map((l) => l.replace(/[ \t]+$/, ''))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('boot card nested lines render as a real indent (#4115)', () => {
|
|
56
|
+
it("a probe's next-step continuation line survives a server-side left-trim", () => {
|
|
57
|
+
// Fail-before: this row was `' ↳ ' + nextStep` — four ASCII spaces, all
|
|
58
|
+
// of them trimmed, so the line rendered flush against its probe row.
|
|
59
|
+
const out = renderBootCard({
|
|
60
|
+
agentName: 'lawgpt',
|
|
61
|
+
version: 'v0.7.16',
|
|
62
|
+
probes: {
|
|
63
|
+
skills: {
|
|
64
|
+
status: 'degraded',
|
|
65
|
+
label: 'Skills',
|
|
66
|
+
detail: '10/10 dangling: a, b, c +7 more',
|
|
67
|
+
nextStep: 'Run `switchroom agent reconcile lawgpt` to rebuild symlinks',
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
})
|
|
71
|
+
const nested = linesOf(out).filter((l) => l.includes('↳'))
|
|
72
|
+
expect(nested.length).toBe(1)
|
|
73
|
+
expectSurvivesLeftTrim(nested[0])
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('the crash row tail-logs continuation line survives a server-side left-trim', () => {
|
|
77
|
+
const out = renderBootCard({
|
|
78
|
+
agentName: 'lawgpt',
|
|
79
|
+
version: 'v0.7.16',
|
|
80
|
+
restartReason: 'crash',
|
|
81
|
+
restartAgeMs: 6_100,
|
|
82
|
+
})
|
|
83
|
+
const nested = linesOf(out).filter((l) => l.includes('↳'))
|
|
84
|
+
expect(nested.length).toBe(1)
|
|
85
|
+
expect(nested[0]).toContain('Tail logs:')
|
|
86
|
+
expectSurvivesLeftTrim(nested[0])
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe('update-announce recovery line renders as a real indent (#4115)', () => {
|
|
91
|
+
it('the failure card\'s recovery continuation survives a server-side left-trim', () => {
|
|
92
|
+
// Fail-before: the recovery row was `' ↳ Recovery: …'`.
|
|
93
|
+
const line = renderUpdateOutcomeLine({
|
|
94
|
+
ts: '2026-05-17T11:59:00.000Z',
|
|
95
|
+
op: 'update_apply',
|
|
96
|
+
caller: { kind: 'operator' },
|
|
97
|
+
request_id: 'req-2',
|
|
98
|
+
result: 'error',
|
|
99
|
+
exit_code: 1,
|
|
100
|
+
duration_ms: 100,
|
|
101
|
+
phase: 'terminal',
|
|
102
|
+
stderr_tail: 'compose pull failed: registry timeout',
|
|
103
|
+
install_context: { install_type: 'binary', detected_at: '2026-05-17T11:00:00Z' },
|
|
104
|
+
})
|
|
105
|
+
const nested = linesOf(line).filter((l) => l.includes('↳'))
|
|
106
|
+
expect(nested.length).toBe(1)
|
|
107
|
+
expect(nested[0]).toContain('Recovery:')
|
|
108
|
+
expectSurvivesLeftTrim(nested[0])
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe('GUARD: no ASCII-space nested indent may reappear in the source (#4115)', () => {
|
|
113
|
+
// A run of ASCII spaces/tabs immediately before the `↳` nesting glyph — the
|
|
114
|
+
// exact shape that renders flat. The one exported constant is the only legal
|
|
115
|
+
// way to indent a nested card line.
|
|
116
|
+
const ASCII_INDENT_BEFORE_ARROW = /[ \t]{2,}↳/
|
|
117
|
+
|
|
118
|
+
const here = fileURLToPath(import.meta.url)
|
|
119
|
+
const repoRoot = join(here, '..', '..', '..')
|
|
120
|
+
const ROOTS = ['telegram-plugin', 'src']
|
|
121
|
+
const SKIP_DIRS = new Set(['node_modules', 'dist', '.git', 'coverage'])
|
|
122
|
+
|
|
123
|
+
function walk(dir: string, out: string[]): string[] {
|
|
124
|
+
for (const name of readdirSync(dir)) {
|
|
125
|
+
if (SKIP_DIRS.has(name)) continue
|
|
126
|
+
const p = join(dir, name)
|
|
127
|
+
if (statSync(p).isDirectory()) walk(p, out)
|
|
128
|
+
else if (p.endsWith('.ts') && p !== here) out.push(p)
|
|
129
|
+
}
|
|
130
|
+
return out
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
it('no source file hardcodes an ASCII-indent-plus-↳ nested line', () => {
|
|
134
|
+
const offenders: string[] = []
|
|
135
|
+
for (const root of ROOTS) {
|
|
136
|
+
for (const file of walk(join(repoRoot, root), [])) {
|
|
137
|
+
const text = readFileSync(file, 'utf8')
|
|
138
|
+
text.split('\n').forEach((line, i) => {
|
|
139
|
+
if (ASCII_INDENT_BEFORE_ARROW.test(line)) {
|
|
140
|
+
offenders.push(`${relative(repoRoot, file)}:${i + 1}: ${line.trim().slice(0, 100)}`)
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
expect(
|
|
146
|
+
offenders,
|
|
147
|
+
`Hardcoded ASCII indent before a '↳' nesting glyph. Telegram left-trims a leading ` +
|
|
148
|
+
`ASCII-whitespace run off a content line, so this renders FLAT on a phone (#3668/#4115). ` +
|
|
149
|
+
`Import NESTED_PREFIX from status-no-truncate.js instead:\n ${offenders.join('\n ')}`,
|
|
150
|
+
).toEqual([])
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('the guard actually fires on the shape it is guarding against', () => {
|
|
154
|
+
// A guard that cannot fail is not a guard: pin the detector on the exact
|
|
155
|
+
// literal the two migrated call sites used to carry.
|
|
156
|
+
expect(ASCII_INDENT_BEFORE_ARROW.test("degradedRows.push(` ↳ ${next}`)")).toBe(true)
|
|
157
|
+
expect(ASCII_INDENT_BEFORE_ARROW.test(' ↳ Recovery: reinstall')).toBe(true)
|
|
158
|
+
// …and stays quiet on the legal idiom and on prose that merely mentions ↳.
|
|
159
|
+
expect(ASCII_INDENT_BEFORE_ARROW.test(`${NESTED_PREFIX}Recovery: reinstall`)).toBe(false)
|
|
160
|
+
expect(ASCII_INDENT_BEFORE_ARROW.test('the nested ↳ block is windowed')).toBe(false)
|
|
161
|
+
})
|
|
162
|
+
})
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { renderStatusCard } from '../tool-activity-summary.js'
|
|
3
|
+
import { NESTED_PREFIX } from '../status-no-truncate.js'
|
|
4
|
+
import { COLLAPSE_SAFE_SEPARATOR } from '../card-format.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* #3668 — the nested (foreground sub-agent) child block must actually LOOK
|
|
8
|
+
* nested on a phone.
|
|
9
|
+
*
|
|
10
|
+
* `NESTED_PREFIX` used to be three ASCII spaces plus `↳ `. Card bodies reach
|
|
11
|
+
* Telegram as GFM markdown parsed SERVER-SIDE, and that parser left-trims a
|
|
12
|
+
* leading whitespace run off a content line, so the indent was dropped on the
|
|
13
|
+
* wire and every child step rendered FLAT against its parent's steps — the
|
|
14
|
+
* whole nesting cue reduced to one `↳` glyph.
|
|
15
|
+
*
|
|
16
|
+
* The replacement is the repo's already-live-verified indent idiom, U+2800
|
|
17
|
+
* BRAILLE PATTERN BLANK: general category So (Symbol, other), not Zs, so no
|
|
18
|
+
* whitespace-trimming rule can classify it as whitespace, yet it renders as
|
|
19
|
+
* blank width. Evidence, in order of strength:
|
|
20
|
+
*
|
|
21
|
+
* 1. LIVE: on 2026-07-26 four candidate indents were sent to a real phone in
|
|
22
|
+
* one message (recorded at `status-no-truncate.ts`, WORKER_STEP_INDENT) —
|
|
23
|
+
* three U+00A0 rendered FLAT, three U+2800 INDENTED CORRECTLY, a leading
|
|
24
|
+
* `↳ ` rendered as visible ink, a leading `· ` was promoted by Telegram
|
|
25
|
+
* into a real list bullet.
|
|
26
|
+
* 2. SHIPPED: `WORKER_STEP_INDENT` (the combined worker card) has used U+2800
|
|
27
|
+
* since that check and renders indented in production.
|
|
28
|
+
* 3. CATEGORY: the property below — U+2800 is not `\s`, not `White_Space`,
|
|
29
|
+
* not `Zs`.
|
|
30
|
+
*
|
|
31
|
+
* Note what is NOT claimed: no character was chosen on inference. #3662 shipped
|
|
32
|
+
* U+00A0 on exactly that kind of inference, was merged green, and was inert on
|
|
33
|
+
* a phone — which is why these tests assert the PROPERTY, not just the bytes.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/** Every rule a server-side trimmer could plausibly use. */
|
|
37
|
+
const isTrimmableWhitespace = (ch: string): boolean =>
|
|
38
|
+
/\s/u.test(ch) || /\p{White_Space}/u.test(ch) || /\p{Zs}/u.test(ch)
|
|
39
|
+
|
|
40
|
+
function nestedCard(children: string[]): string {
|
|
41
|
+
return renderStatusCard({
|
|
42
|
+
header: { emoji: '🤖', label: 'Agent', elapsedMs: 95_000, toolCount: 12, state: 'running' },
|
|
43
|
+
steps: ['Reading gateway.ts'],
|
|
44
|
+
childSteps: children,
|
|
45
|
+
final: false,
|
|
46
|
+
})!
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The card's pre-join lines, hard-break chrome removed. */
|
|
50
|
+
function linesOf(card: string): string[] {
|
|
51
|
+
return card
|
|
52
|
+
.split('\n')
|
|
53
|
+
.map((l) => l.replace(/[ \t]+$/, '').replace(new RegExp(`${COLLAPSE_SAFE_SEPARATOR}$`), ''))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('NESTED_PREFIX renders as a real indent (#3668)', () => {
|
|
57
|
+
it('contains no character any whitespace rule would trim', () => {
|
|
58
|
+
// The `↳ ` glyph is visible ink and is kept; the run BEFORE it is the
|
|
59
|
+
// indent, and every char of THAT must survive a left-trim. Pin the width
|
|
60
|
+
// too — a single blank char reads as near-flat on a phone and would undo
|
|
61
|
+
// the fix while keeping the character-class assertion green.
|
|
62
|
+
const indent = [...NESTED_PREFIX.slice(0, NESTED_PREFIX.indexOf('↳'))]
|
|
63
|
+
expect(indent.length).toBe(3)
|
|
64
|
+
for (const ch of indent) {
|
|
65
|
+
const cp = `U+${ch.codePointAt(0)!.toString(16).toUpperCase().padStart(4, '0')}`
|
|
66
|
+
expect(
|
|
67
|
+
isTrimmableWhitespace(ch),
|
|
68
|
+
`NESTED_PREFIX's indent contains ${cp}, which is Unicode whitespace ` +
|
|
69
|
+
`(\\s / White_Space / Zs). Telegram left-trims a leading whitespace run off a ` +
|
|
70
|
+
`content line, so this indent renders FLAT on a phone — the #3662/#3668 failure. ` +
|
|
71
|
+
`Use U+2800 BRAILLE PATTERN BLANK (category So), live-verified 2026-07-26.`,
|
|
72
|
+
).toBe(false)
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('leads with exactly three U+2800 and keeps the ↳ glyph', () => {
|
|
77
|
+
// Byte pin, companion to the property above: that one says "not
|
|
78
|
+
// whitespace", this one says "the specific glyph we live-tested", in the
|
|
79
|
+
// order that matters (blanks LEAD, so the glyph is never what gets trimmed).
|
|
80
|
+
expect(NESTED_PREFIX).toBe('\u2800\u2800\u2800↳ ')
|
|
81
|
+
// Not a GFM block-structure lead-in: `stackCardLines` promotes every
|
|
82
|
+
// inter-line break to a hard break *because* card lines are never block
|
|
83
|
+
// lines, and a live check showed Telegram promotes a leading `·` to a real
|
|
84
|
+
// list bullet.
|
|
85
|
+
expect(/^[-*+>|#·]/.test(NESTED_PREFIX)).toBe(false)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('every rendered child line survives a server-side leading-whitespace trim with its indent intact', () => {
|
|
89
|
+
const card = nestedCard(['Searching memory', 'Running tests'])
|
|
90
|
+
const childLines = linesOf(card).filter((l) => l.includes('↳'))
|
|
91
|
+
expect(childLines.length).toBe(2)
|
|
92
|
+
for (const line of childLines) {
|
|
93
|
+
// Fail-before assertion: with the old ASCII prefix, `line` started with
|
|
94
|
+
// `' '` and this strip removed the whole indent.
|
|
95
|
+
expect(line.startsWith(NESTED_PREFIX)).toBe(true)
|
|
96
|
+
expect(line.replace(/^[\s\p{White_Space}]+/u, '').startsWith(NESTED_PREFIX)).toBe(true)
|
|
97
|
+
// And it leads with neither of the two characters already proven flat.
|
|
98
|
+
expect(line.startsWith(' ')).toBe(false)
|
|
99
|
+
expect(line.startsWith('\u00A0')).toBe(false)
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('golden line ordering — parent steps stay flush, child steps sit one level in', () => {
|
|
104
|
+
// Set-membership assertions alone would pass a render that emitted all the
|
|
105
|
+
// parents and then all the children. This pins the ORDER, which is the
|
|
106
|
+
// thing "nested" actually means.
|
|
107
|
+
const card = nestedCard(['Searching memory', 'Running tests'])
|
|
108
|
+
const body = linesOf(card).slice(2)
|
|
109
|
+
expect(body).toEqual([
|
|
110
|
+
'~~_✓ Reading gateway.ts_~~',
|
|
111
|
+
`${NESTED_PREFIX}~~_Searching memory_~~`,
|
|
112
|
+
`${NESTED_PREFIX}**→ Running tests**`,
|
|
113
|
+
])
|
|
114
|
+
})
|
|
115
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createRobustApiCall` must stay deleted (#3863).
|
|
3
|
+
*
|
|
4
|
+
* It was a second `createRetryApiCall` wiring in `shared/bot-runtime.ts`,
|
|
5
|
+
* described as "exactly how gateway.ts constructs its robustApiCall". It had
|
|
6
|
+
* ZERO production callers — and, worse, its flood-breaker hooks were OPTIONAL
|
|
7
|
+
* (`floodStatePath` defaulted to absent), so the default construction was a
|
|
8
|
+
* send path blind to the shared flood window: the exact failure
|
|
9
|
+
* `scripts/check-retry-flood-hooks.mjs` exists to prevent, sitting in the file
|
|
10
|
+
* a future caller would naturally reach for.
|
|
11
|
+
*
|
|
12
|
+
* It was also load-bearing DOCUMENTATION: `flood-circuit-breaker.ts` and
|
|
13
|
+
* `flood-429-ledger.ts` both cited it as a live wiring, so the breaker's
|
|
14
|
+
* "every onFloodWait goes through here" completeness argument was partly
|
|
15
|
+
* anchored to dead code.
|
|
16
|
+
*
|
|
17
|
+
* This is a source-level guard on purpose: the defect is the existence of the
|
|
18
|
+
* identifier, and there is no runtime behaviour to assert.
|
|
19
|
+
*
|
|
20
|
+
* The rule is "the name may appear only inside backticks" — i.e. as PROSE in a
|
|
21
|
+
* docblock explaining why it is gone. Any bare occurrence is code (a
|
|
22
|
+
* declaration, an import, a call) and fails.
|
|
23
|
+
*/
|
|
24
|
+
import { describe, it, expect } from 'vitest'
|
|
25
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs'
|
|
26
|
+
import { join, extname } from 'node:path'
|
|
27
|
+
import { fileURLToPath } from 'node:url'
|
|
28
|
+
|
|
29
|
+
const REPO_ROOT = fileURLToPath(new URL('../../', import.meta.url))
|
|
30
|
+
const SEARCH_ROOTS = ['telegram-plugin', 'src', 'bin', 'scripts', 'docs']
|
|
31
|
+
const SKIP_DIRS = new Set(['node_modules', 'dist', '.git', 'vendor', 'coverage'])
|
|
32
|
+
const SEARCH_EXTS = new Set(['.ts', '.tsx', '.js', '.mjs', '.cjs', '.md'])
|
|
33
|
+
|
|
34
|
+
function* walk(dir: string): Generator<string> {
|
|
35
|
+
for (const entry of readdirSync(dir)) {
|
|
36
|
+
if (SKIP_DIRS.has(entry)) continue
|
|
37
|
+
const full = join(dir, entry)
|
|
38
|
+
if (statSync(full).isDirectory()) yield* walk(full)
|
|
39
|
+
else if (SEARCH_EXTS.has(extname(entry))) yield full
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('#3863 — the dead createRobustApiCall factory', () => {
|
|
44
|
+
it('appears in no source, script or doc file except as backticked prose', () => {
|
|
45
|
+
const selfPath = fileURLToPath(import.meta.url)
|
|
46
|
+
const offenders: string[] = []
|
|
47
|
+
for (const root of SEARCH_ROOTS) {
|
|
48
|
+
for (const file of walk(join(REPO_ROOT, root))) {
|
|
49
|
+
if (file === selfPath) continue
|
|
50
|
+
// Drop the backticked (prose) occurrences; anything left is code.
|
|
51
|
+
const bare = readFileSync(file, 'utf8').replaceAll('`createRobustApiCall`', '')
|
|
52
|
+
if (bare.includes('createRobustApiCall')) offenders.push(file.slice(REPO_ROOT.length))
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
expect(offenders).toEqual([])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('is not exported by shared/bot-runtime.ts', async () => {
|
|
59
|
+
const mod = (await import('../shared/bot-runtime.js')) as Record<string, unknown>
|
|
60
|
+
expect(Object.keys(mod)).not.toContain('createRobustApiCall')
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
|
|
|
2
2
|
import {
|
|
3
3
|
loadObligations,
|
|
4
4
|
persistObligations,
|
|
5
|
+
removeUnmaintainedSnapshot,
|
|
5
6
|
type ObligationStoreFsSeam,
|
|
6
7
|
} from "../gateway/obligation-store.js";
|
|
7
8
|
import type { Obligation } from "../gateway/obligation-ledger.js";
|
|
@@ -33,6 +34,10 @@ function memFs(seed: Record<string, string> = {}) {
|
|
|
33
34
|
fsyncDirSync: (p) => {
|
|
34
35
|
calls.push(`fsyncDir:${p}`);
|
|
35
36
|
},
|
|
37
|
+
unlinkSync: (p) => {
|
|
38
|
+
calls.push(`unlink:${p}`);
|
|
39
|
+
files.delete(p);
|
|
40
|
+
},
|
|
36
41
|
};
|
|
37
42
|
return { fs, files, calls };
|
|
38
43
|
}
|
|
@@ -240,4 +245,50 @@ describe("obligation-store — power-cut durability (fsync ordering)", () => {
|
|
|
240
245
|
expect(files.has(PATH)).toBe(true);
|
|
241
246
|
expect(loadObligations(PATH, fs)).toHaveLength(1);
|
|
242
247
|
});
|
|
248
|
+
|
|
249
|
+
// ── #4146: a snapshot nobody maintains must not read as "nobody waiting" ──
|
|
250
|
+
|
|
251
|
+
it("removeUnmaintainedSnapshot deletes a leftover snapshot and says why", () => {
|
|
252
|
+
// The exact hazard: the agent once ran with the ledger on, so an EMPTY
|
|
253
|
+
// open set is on disk. Persistence is now off, so that emptiness is a
|
|
254
|
+
// leftover — and the W1-d audience classifier would read it as positive
|
|
255
|
+
// proof that nobody is waiting, and suppress a message owed to a human.
|
|
256
|
+
const { fs, files } = memFs({ [PATH]: JSON.stringify({ v: 1, obligations: [] }) });
|
|
257
|
+
const logs: string[] = [];
|
|
258
|
+
expect(removeUnmaintainedSnapshot(PATH, fs, "static=true enabled=false", (l) => logs.push(l))).toBe(true);
|
|
259
|
+
expect(files.has(PATH)).toBe(false);
|
|
260
|
+
// ...and the classifier's precondition now genuinely holds: absent file.
|
|
261
|
+
expect(fs.existsSync(PATH)).toBe(false);
|
|
262
|
+
expect(logs.join("")).toContain("static=true enabled=false");
|
|
263
|
+
expect(logs.join("")).toContain("#4146");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("removeUnmaintainedSnapshot is a no-op when there is nothing to remove", () => {
|
|
267
|
+
const { fs } = memFs();
|
|
268
|
+
const logs: string[] = [];
|
|
269
|
+
expect(removeUnmaintainedSnapshot(PATH, fs, "reason", (l) => logs.push(l))).toBe(false);
|
|
270
|
+
expect(logs).toEqual([]);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("removeUnmaintainedSnapshot never throws, and reports failure rather than claiming success", () => {
|
|
274
|
+
// A failure here must degrade to the reader-side ledger-off guard, not
|
|
275
|
+
// crash gateway boot and not be mistaken for a completed cleanup.
|
|
276
|
+
const { fs } = memFs({ [PATH]: "{}" });
|
|
277
|
+
const logs: string[] = [];
|
|
278
|
+
const failing: ObligationStoreFsSeam = {
|
|
279
|
+
...fs,
|
|
280
|
+
unlinkSync: () => {
|
|
281
|
+
throw new Error("EACCES: permission denied, unlink");
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
expect(removeUnmaintainedSnapshot(PATH, failing, "reason", (l) => logs.push(l))).toBe(false);
|
|
285
|
+
expect(logs.join("")).toContain("could not remove unmaintained snapshot");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("a seam with no unlinkSync degrades to no cleanup rather than throwing", () => {
|
|
289
|
+
const { fs, files } = memFs({ [PATH]: "{}" });
|
|
290
|
+
const { unlinkSync: _dropped, ...legacySeam } = fs;
|
|
291
|
+
expect(removeUnmaintainedSnapshot(PATH, legacySeam, "reason", () => {})).toBe(false);
|
|
292
|
+
expect(files.has(PATH)).toBe(true);
|
|
293
|
+
});
|
|
243
294
|
});
|