switchroom 0.11.1 → 0.12.1
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/README.md +32 -16
- package/dist/agent-scheduler/index.js +216 -97
- package/dist/auth-broker/index.js +176 -97
- package/dist/cli/drive-write-pretool.mjs +26 -11
- package/dist/cli/skill-validate-pretool.mjs +7209 -0
- package/dist/cli/switchroom.js +45571 -42642
- package/dist/cli/ui/index.html +1281 -0
- package/dist/host-control/main.js +3628 -309
- package/dist/vault/approvals/kernel-server.js +207 -98
- package/dist/vault/broker/server.js +249 -119
- package/examples/personal-google-workspace-mcp/README.md +8 -3
- package/examples/switchroom.yaml +91 -42
- package/package.json +4 -3
- package/profiles/_base/start.sh.hbs +76 -36
- package/profiles/_shared/agent-self-service.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +4 -2
- package/skills/file-bug/SKILL.md +6 -4
- package/skills/skill-creator/SKILL.md +52 -0
- package/skills/switchroom-cli/SKILL.md +20 -4
- package/skills/switchroom-install/SKILL.md +3 -3
- package/telegram-plugin/auth-snapshot-format.ts +9 -9
- package/telegram-plugin/card-format.ts +3 -3
- package/telegram-plugin/dist/bridge/bridge.js +112 -112
- package/telegram-plugin/dist/gateway/gateway.js +853 -414
- package/telegram-plugin/dist/server.js +162 -161
- package/telegram-plugin/format.ts +71 -0
- package/telegram-plugin/gateway/access-validator.test.ts +8 -8
- package/telegram-plugin/gateway/access-validator.ts +1 -1
- package/telegram-plugin/gateway/approval-card.test.ts +18 -18
- package/telegram-plugin/gateway/approval-card.ts +1 -1
- package/telegram-plugin/gateway/auth-command.ts +2 -2
- package/telegram-plugin/gateway/boot-card.ts +40 -3
- package/telegram-plugin/gateway/boot-probes.ts +114 -30
- package/telegram-plugin/gateway/diff-preview-card.test.ts +15 -15
- package/telegram-plugin/gateway/diff-preview-card.ts +1 -1
- package/telegram-plugin/gateway/drive-write-approval.test.ts +2 -2
- package/telegram-plugin/gateway/gateway.ts +265 -22
- package/telegram-plugin/gateway/update-announce.ts +167 -0
- package/telegram-plugin/quota-check.ts +0 -195
- package/telegram-plugin/recent-outbound-dedup.ts +1 -1
- package/telegram-plugin/registry/turns-schema.ts +1 -1
- package/telegram-plugin/retry-api-call.ts +24 -0
- package/telegram-plugin/server.ts +8 -5
- package/telegram-plugin/tests/auth-add-flow.test.ts +32 -3
- package/telegram-plugin/tests/auth-command-format2.test.ts +4 -4
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +17 -17
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +10 -10
- package/telegram-plugin/tests/boot-probes.test.ts +90 -2
- package/telegram-plugin/tests/bot-runtime.test.ts +23 -1
- package/telegram-plugin/tests/fixtures/service-log-current-claude-code.bin +1 -1
- package/telegram-plugin/tests/fleet-state.test.ts +3 -2
- package/telegram-plugin/tests/quota-check.test.ts +0 -409
- package/telegram-plugin/tests/retry-api-call.test.ts +76 -0
- package/telegram-plugin/tests/secret-detect-audit.test.ts +1 -1
- package/telegram-plugin/tests/secret-detect-pipeline.test.ts +7 -6
- package/telegram-plugin/tests/secret-detect-suppressor-no-silent-allow.test.ts +6 -5
- package/telegram-plugin/tests/secret-detect.test.ts +8 -8
- package/telegram-plugin/tests/telegram-format.test.ts +84 -1
- package/telegram-plugin/tests/update-announce.test.ts +154 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +8 -8
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +51 -0
- package/telegram-plugin/welcome-text.ts +1 -8
- package/profiles/default/CLAUDE.md +0 -192
- package/skills/docx/scripts/office/validators/__pycache__/__init__.cpython-313.pyc +0 -0
- package/skills/docx/scripts/office/validators/__pycache__/base.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/utils.cpython-313.pyc +0 -0
- package/telegram-plugin/first-paint.ts +0 -225
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
- package/telegram-plugin/server.js +0 -41795
- package/telegram-plugin/tests/html-balanced.ts +0 -63
- package/telegram-plugin/tests/snapshot-serializer.ts +0 -79
- package/telegram-plugin/tool-error-filter.ts +0 -89
|
@@ -44,7 +44,7 @@ describe('runFleetAutoFallback', () => {
|
|
|
44
44
|
fanned: ['alice', 'bob'],
|
|
45
45
|
}));
|
|
46
46
|
const out = await runFleetAutoFallback({
|
|
47
|
-
state: state('ken@x', ['ken@x', 'me@x', '
|
|
47
|
+
state: state('ken@x', ['ken@x', 'me@x', 'you@x']),
|
|
48
48
|
quotas: [
|
|
49
49
|
// ken: just blew 5h
|
|
50
50
|
qOk({
|
|
@@ -58,7 +58,7 @@ describe('runFleetAutoFallback', () => {
|
|
|
58
58
|
sevenDayResetAt: new Date('2026-05-17T10:00:00Z'),
|
|
59
59
|
representativeClaim: 'seven_day',
|
|
60
60
|
}),
|
|
61
|
-
//
|
|
61
|
+
// you: healthy 5h/7d
|
|
62
62
|
qOk({ fiveHourUtilizationPct: 8, sevenDayUtilizationPct: 20 }),
|
|
63
63
|
],
|
|
64
64
|
setActive,
|
|
@@ -69,10 +69,10 @@ describe('runFleetAutoFallback', () => {
|
|
|
69
69
|
|
|
70
70
|
expect(out.kind).toBe('switched');
|
|
71
71
|
expect(setActive).toHaveBeenCalledTimes(1);
|
|
72
|
-
expect(setActive).toHaveBeenCalledWith('
|
|
72
|
+
expect(setActive).toHaveBeenCalledWith('you@x');
|
|
73
73
|
if (out.kind === 'switched') {
|
|
74
74
|
expect(out.oldLabel).toBe('ken@x');
|
|
75
|
-
expect(out.newLabel).toBe('
|
|
75
|
+
expect(out.newLabel).toBe('you@x');
|
|
76
76
|
expect(out.announcement).toContain('5-hour limit on ken@x');
|
|
77
77
|
expect(out.announcement).toContain('Triggered by: agent <b>carrie</b>');
|
|
78
78
|
expect(out.announcement).toContain('plenty of headroom');
|
|
@@ -112,7 +112,7 @@ describe('runFleetAutoFallback', () => {
|
|
|
112
112
|
it('idempotency: skips swap when active probes healthy (stale event)', async () => {
|
|
113
113
|
const setActive = vi.fn();
|
|
114
114
|
const out = await runFleetAutoFallback({
|
|
115
|
-
state: state('ken@x', ['ken@x', '
|
|
115
|
+
state: state('ken@x', ['ken@x', 'you@x']),
|
|
116
116
|
quotas: [
|
|
117
117
|
qOk({ fiveHourUtilizationPct: 5, sevenDayUtilizationPct: 10 }),
|
|
118
118
|
qOk({ fiveHourUtilizationPct: 5, sevenDayUtilizationPct: 10 }),
|
|
@@ -147,14 +147,14 @@ describe('runFleetAutoFallback', () => {
|
|
|
147
147
|
it('falls back to a throttling alternative when no healthy one exists', async () => {
|
|
148
148
|
const setActive = vi.fn(async (label: string) => ({ active: label, fanned: [] }));
|
|
149
149
|
const out = await runFleetAutoFallback({
|
|
150
|
-
state: state('ken@x', ['ken@x', '
|
|
150
|
+
state: state('ken@x', ['ken@x', 'you@x']),
|
|
151
151
|
quotas: [
|
|
152
152
|
qOk({
|
|
153
153
|
fiveHourUtilizationPct: 100,
|
|
154
154
|
fiveHourResetAt: new Date('2026-05-15T05:50:00Z'),
|
|
155
155
|
representativeClaim: 'five_hour',
|
|
156
156
|
}),
|
|
157
|
-
//
|
|
157
|
+
// you throttling at 85% but not blocked
|
|
158
158
|
qOk({ fiveHourUtilizationPct: 85, sevenDayUtilizationPct: 20 }),
|
|
159
159
|
],
|
|
160
160
|
setActive,
|
|
@@ -164,7 +164,7 @@ describe('runFleetAutoFallback', () => {
|
|
|
164
164
|
});
|
|
165
165
|
|
|
166
166
|
expect(out.kind).toBe('switched');
|
|
167
|
-
expect(setActive).toHaveBeenCalledWith('
|
|
167
|
+
expect(setActive).toHaveBeenCalledWith('you@x');
|
|
168
168
|
if (out.kind === 'switched') {
|
|
169
169
|
expect(out.announcement).toContain('near limit — watch this');
|
|
170
170
|
}
|
|
@@ -173,7 +173,7 @@ describe('runFleetAutoFallback', () => {
|
|
|
173
173
|
it('skips unknown-health (probe failed) when picking a target', async () => {
|
|
174
174
|
const setActive = vi.fn(async (label: string) => ({ active: label, fanned: [] }));
|
|
175
175
|
const out = await runFleetAutoFallback({
|
|
176
|
-
state: state('ken@x', ['ken@x', 'broken@x', '
|
|
176
|
+
state: state('ken@x', ['ken@x', 'broken@x', 'you@x']),
|
|
177
177
|
quotas: [
|
|
178
178
|
qOk({ fiveHourUtilizationPct: 100, fiveHourResetAt: new Date('2026-05-15T05:50:00Z') }),
|
|
179
179
|
{ ok: false, reason: 'HTTP 401' },
|
|
@@ -186,7 +186,7 @@ describe('runFleetAutoFallback', () => {
|
|
|
186
186
|
});
|
|
187
187
|
|
|
188
188
|
expect(out.kind).toBe('switched');
|
|
189
|
-
expect(setActive).toHaveBeenCalledWith('
|
|
189
|
+
expect(setActive).toHaveBeenCalledWith('you@x');
|
|
190
190
|
});
|
|
191
191
|
});
|
|
192
192
|
|
|
@@ -292,6 +292,59 @@ describe('probeQuota — #1163: /v1/messages headers path', () => {
|
|
|
292
292
|
expect(result.detail).toContain('18% / 7d')
|
|
293
293
|
})
|
|
294
294
|
|
|
295
|
+
it('prefers the broker probe and does NOT do a direct fetch when it succeeds (Option A / #1336)', async () => {
|
|
296
|
+
const directFetch: typeof fetch = async () => {
|
|
297
|
+
throw new Error('direct fetch must not run when the broker probe returns a result')
|
|
298
|
+
}
|
|
299
|
+
const brokerProbe = async () => ({
|
|
300
|
+
ok: true as const,
|
|
301
|
+
data: {
|
|
302
|
+
fiveHourUtilizationPct: 30,
|
|
303
|
+
sevenDayUtilizationPct: 12,
|
|
304
|
+
fiveHourResetAt: null,
|
|
305
|
+
sevenDayResetAt: null,
|
|
306
|
+
representativeClaim: null,
|
|
307
|
+
overageStatus: null,
|
|
308
|
+
overageDisabledReason: null,
|
|
309
|
+
},
|
|
310
|
+
})
|
|
311
|
+
const result = await probeQuota(claudeDir, agentDir, directFetch, { brokerProbe })
|
|
312
|
+
expect(result.status).toBe('ok')
|
|
313
|
+
expect(result.detail).toContain('30% / 5h')
|
|
314
|
+
expect(result.detail).toContain('12% / 7d')
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it('falls back to a direct probe when the broker probe returns null (broker unreachable)', async () => {
|
|
318
|
+
const headers = new Headers({
|
|
319
|
+
'anthropic-ratelimit-unified-5h-utilization': '0.55',
|
|
320
|
+
'anthropic-ratelimit-unified-7d-utilization': '0.22',
|
|
321
|
+
})
|
|
322
|
+
const directFetch: typeof fetch = async () =>
|
|
323
|
+
new Response('{}', { status: 200, headers }) as Response
|
|
324
|
+
const brokerProbe = async () => null
|
|
325
|
+
|
|
326
|
+
const result = await probeQuota(claudeDir, agentDir, directFetch, { brokerProbe })
|
|
327
|
+
expect(result.status).toBe('ok')
|
|
328
|
+
expect(result.detail).toContain('55% / 5h')
|
|
329
|
+
expect(result.detail).toContain('22% / 7d')
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
it('falls back to a direct probe when the broker probe throws', async () => {
|
|
333
|
+
const headers = new Headers({
|
|
334
|
+
'anthropic-ratelimit-unified-5h-utilization': '0.10',
|
|
335
|
+
'anthropic-ratelimit-unified-7d-utilization': '0.05',
|
|
336
|
+
})
|
|
337
|
+
const directFetch: typeof fetch = async () =>
|
|
338
|
+
new Response('{}', { status: 200, headers }) as Response
|
|
339
|
+
const brokerProbe = async () => {
|
|
340
|
+
throw new Error('broker UDS connect failed')
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const result = await probeQuota(claudeDir, agentDir, directFetch, { brokerProbe })
|
|
344
|
+
expect(result.status).toBe('ok')
|
|
345
|
+
expect(result.detail).toContain('10% / 5h')
|
|
346
|
+
})
|
|
347
|
+
|
|
295
348
|
it('surfaces auth rejection with the RFC-H replace-account hint on 403', async () => {
|
|
296
349
|
const fakeFetch: typeof fetch = async () =>
|
|
297
350
|
new Response(null, { status: 403 }) as Response
|
|
@@ -830,7 +883,7 @@ describe('probeSkills', () => {
|
|
|
830
883
|
expect(result.detail).toContain('no skills dir')
|
|
831
884
|
})
|
|
832
885
|
|
|
833
|
-
it('returns ok with
|
|
886
|
+
it('returns ok with every skill listed under Switchroom bucket when no overlay', async () => {
|
|
834
887
|
const fs = makeSkillsFs(
|
|
835
888
|
{ [skillsDir]: ['simplify', 'review'] },
|
|
836
889
|
new Set([
|
|
@@ -840,7 +893,40 @@ describe('probeSkills', () => {
|
|
|
840
893
|
)
|
|
841
894
|
const result = await probeSkills(agentDir, { fs })
|
|
842
895
|
expect(result.status).toBe('ok')
|
|
843
|
-
expect(result.detail).
|
|
896
|
+
expect(result.detail).toBe('Switchroom: review, simplify')
|
|
897
|
+
})
|
|
898
|
+
|
|
899
|
+
it('buckets overlay-installed skills under Agent', async () => {
|
|
900
|
+
const overlayDir = '/state/agent/skills.d'
|
|
901
|
+
const fs = makeSkillsFs(
|
|
902
|
+
{
|
|
903
|
+
[skillsDir]: ['simplify', 'review', 'webapp-testing'],
|
|
904
|
+
[overlayDir]: ['webapp-testing.yaml'],
|
|
905
|
+
},
|
|
906
|
+
new Set([
|
|
907
|
+
`${skillsDir}/simplify`, `${skillsDir}/simplify/SKILL.md`,
|
|
908
|
+
`${skillsDir}/review`, `${skillsDir}/review/SKILL.md`,
|
|
909
|
+
`${skillsDir}/webapp-testing`, `${skillsDir}/webapp-testing/SKILL.md`,
|
|
910
|
+
overlayDir,
|
|
911
|
+
]),
|
|
912
|
+
)
|
|
913
|
+
const result = await probeSkills(agentDir, { fs })
|
|
914
|
+
expect(result.status).toBe('ok')
|
|
915
|
+
expect(result.detail).toBe('Switchroom: review, simplify · Agent: webapp-testing')
|
|
916
|
+
})
|
|
917
|
+
|
|
918
|
+
it('lists every skill without a +N more truncation in the healthy case', async () => {
|
|
919
|
+
const names = Array.from({ length: 12 }, (_, i) => `skill-${i.toString().padStart(2, '0')}`)
|
|
920
|
+
const fileSet = new Set<string>()
|
|
921
|
+
for (const n of names) {
|
|
922
|
+
fileSet.add(`${skillsDir}/${n}`)
|
|
923
|
+
fileSet.add(`${skillsDir}/${n}/SKILL.md`)
|
|
924
|
+
}
|
|
925
|
+
const fs = makeSkillsFs({ [skillsDir]: names }, fileSet)
|
|
926
|
+
const result = await probeSkills(agentDir, { fs })
|
|
927
|
+
expect(result.status).toBe('ok')
|
|
928
|
+
expect(result.detail).not.toContain('+')
|
|
929
|
+
for (const n of names) expect(result.detail).toContain(n)
|
|
844
930
|
})
|
|
845
931
|
|
|
846
932
|
it('degraded when at least one symlink dangles, names them up to cap', async () => {
|
|
@@ -859,6 +945,8 @@ describe('probeSkills', () => {
|
|
|
859
945
|
expect(result.detail).toContain('also-gone')
|
|
860
946
|
expect(result.detail).toContain('+1 more')
|
|
861
947
|
expect(result.detail).not.toContain('and-this')
|
|
948
|
+
// resolved skills still surface alongside the dangling summary
|
|
949
|
+
expect(result.detail).toContain('Switchroom: review, simplify')
|
|
862
950
|
})
|
|
863
951
|
|
|
864
952
|
it('returns ok when entries dir is empty', async () => {
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* via integration tests; here we keep it to pure unit coverage.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { describe, it, expect, vi } from 'vitest'
|
|
10
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
11
11
|
import {
|
|
12
12
|
escapeHtmlForTg,
|
|
13
13
|
preBlock,
|
|
@@ -20,6 +20,28 @@ import {
|
|
|
20
20
|
} from '../shared/bot-runtime.js'
|
|
21
21
|
import type { Context } from 'grammy'
|
|
22
22
|
|
|
23
|
+
// ─── env hygiene ─────────────────────────────────────────────────────────
|
|
24
|
+
// The exec factories read SWITCHROOM_CONFIG and prepend `--config <path>` to
|
|
25
|
+
// argv. When the test runner inherits SWITCHROOM_CONFIG from the environment
|
|
26
|
+
// (e.g. switchroom-managed shells), this leaks into tests that use `echo`
|
|
27
|
+
// as the cliPath and breaks stdout assertions. Clear before each test and
|
|
28
|
+
// restore after.
|
|
29
|
+
|
|
30
|
+
let savedSwitchroomConfig: string | undefined
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
savedSwitchroomConfig = process.env.SWITCHROOM_CONFIG
|
|
34
|
+
delete process.env.SWITCHROOM_CONFIG
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
if (savedSwitchroomConfig !== undefined) {
|
|
39
|
+
process.env.SWITCHROOM_CONFIG = savedSwitchroomConfig
|
|
40
|
+
} else {
|
|
41
|
+
delete process.env.SWITCHROOM_CONFIG
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
|
|
23
45
|
// ─── escapeHtmlForTg ─────────────────────────────────────────────────────
|
|
24
46
|
|
|
25
47
|
describe('escapeHtmlForTg', () => {
|
|
@@ -155,8 +155,9 @@ describe('sanitiseToolArg', () => {
|
|
|
155
155
|
expect(sanitiseToolArg('Write', { file_path: '/tmp/out.json' })).toBe('out.json')
|
|
156
156
|
})
|
|
157
157
|
it('redacts bearer-token-like strings in Bash commands', () => {
|
|
158
|
-
const
|
|
159
|
-
|
|
158
|
+
const tok = ['sk-ant-', '1234567890abcdef'].join('')
|
|
159
|
+
const out = sanitiseToolArg('Bash', { command: `curl -H "Authorization: Bearer ${tok}" https://x` })
|
|
160
|
+
expect(out).not.toContain(tok)
|
|
160
161
|
expect(out.toLowerCase()).toContain('redacted')
|
|
161
162
|
})
|
|
162
163
|
it('returns empty string when no recognisable arg', () => {
|
|
@@ -7,12 +7,6 @@ import {
|
|
|
7
7
|
formatQuotaBlock,
|
|
8
8
|
formatQuotaLine,
|
|
9
9
|
parseQuotaHeaders,
|
|
10
|
-
readAccountAccessToken,
|
|
11
|
-
fetchAccountQuota,
|
|
12
|
-
getCachedAccountQuota,
|
|
13
|
-
prefetchAccountQuotaIfStale,
|
|
14
|
-
clearAccountQuotaCache,
|
|
15
|
-
ACCOUNT_QUOTA_CACHE_TTL_MS,
|
|
16
10
|
} from '../quota-check.js'
|
|
17
11
|
|
|
18
12
|
function makeTempClaudeDir(token: string | null): string {
|
|
@@ -189,409 +183,6 @@ describe('fetchQuota', () => {
|
|
|
189
183
|
})
|
|
190
184
|
})
|
|
191
185
|
|
|
192
|
-
// ─── Account-level helpers ────────────────────────────────────────────
|
|
193
|
-
|
|
194
|
-
/** Build a fake $HOME with `~/.switchroom/accounts/<label>/credentials.json`. */
|
|
195
|
-
function makeAccountHome(
|
|
196
|
-
accounts: Record<string, { accessToken?: string }>,
|
|
197
|
-
): string {
|
|
198
|
-
const home = mkdtempSync(join(tmpdir(), 'quota-acct-test-'))
|
|
199
|
-
for (const [label, creds] of Object.entries(accounts)) {
|
|
200
|
-
const dir = join(home, '.switchroom', 'accounts', label)
|
|
201
|
-
mkdirSync(dir, { recursive: true })
|
|
202
|
-
if (creds.accessToken !== undefined) {
|
|
203
|
-
writeFileSync(
|
|
204
|
-
join(dir, 'credentials.json'),
|
|
205
|
-
JSON.stringify({ claudeAiOauth: { accessToken: creds.accessToken } }),
|
|
206
|
-
)
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
return home
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
describe('readAccountAccessToken', () => {
|
|
213
|
-
it('returns the access token from credentials.json', () => {
|
|
214
|
-
const home = makeAccountHome({
|
|
215
|
-
'pixsoul@gmail.com': { accessToken: 'sk-ant-oat01-fake' },
|
|
216
|
-
})
|
|
217
|
-
try {
|
|
218
|
-
expect(readAccountAccessToken('pixsoul@gmail.com', home)).toBe(
|
|
219
|
-
'sk-ant-oat01-fake',
|
|
220
|
-
)
|
|
221
|
-
} finally {
|
|
222
|
-
rmSync(home, { recursive: true, force: true })
|
|
223
|
-
}
|
|
224
|
-
})
|
|
225
|
-
|
|
226
|
-
it('returns null when the account dir is missing', () => {
|
|
227
|
-
const home = makeAccountHome({})
|
|
228
|
-
try {
|
|
229
|
-
expect(readAccountAccessToken('absent', home)).toBeNull()
|
|
230
|
-
} finally {
|
|
231
|
-
rmSync(home, { recursive: true, force: true })
|
|
232
|
-
}
|
|
233
|
-
})
|
|
234
|
-
|
|
235
|
-
it('returns null when accessToken is empty', () => {
|
|
236
|
-
const home = makeAccountHome({
|
|
237
|
-
'empty@example.com': { accessToken: '' },
|
|
238
|
-
})
|
|
239
|
-
try {
|
|
240
|
-
expect(readAccountAccessToken('empty@example.com', home)).toBeNull()
|
|
241
|
-
} finally {
|
|
242
|
-
rmSync(home, { recursive: true, force: true })
|
|
243
|
-
}
|
|
244
|
-
})
|
|
245
|
-
|
|
246
|
-
it('returns null when credentials.json is malformed', () => {
|
|
247
|
-
const home = mkdtempSync(join(tmpdir(), 'quota-acct-bad-'))
|
|
248
|
-
const dir = join(home, '.switchroom', 'accounts', 'broken')
|
|
249
|
-
mkdirSync(dir, { recursive: true })
|
|
250
|
-
writeFileSync(join(dir, 'credentials.json'), '{not json')
|
|
251
|
-
try {
|
|
252
|
-
expect(readAccountAccessToken('broken', home)).toBeNull()
|
|
253
|
-
} finally {
|
|
254
|
-
rmSync(home, { recursive: true, force: true })
|
|
255
|
-
}
|
|
256
|
-
})
|
|
257
|
-
})
|
|
258
|
-
|
|
259
|
-
describe('fetchAccountQuota — cache + token resolution', () => {
|
|
260
|
-
beforeEach(() => {
|
|
261
|
-
clearAccountQuotaCache()
|
|
262
|
-
})
|
|
263
|
-
|
|
264
|
-
it('fetches once, returns cached on subsequent calls within TTL', async () => {
|
|
265
|
-
const home = makeAccountHome({
|
|
266
|
-
'work@example.com': { accessToken: 'tok' },
|
|
267
|
-
})
|
|
268
|
-
let callCount = 0
|
|
269
|
-
const fakeFetch = async () => {
|
|
270
|
-
callCount++
|
|
271
|
-
return new Response('{}', {
|
|
272
|
-
status: 200,
|
|
273
|
-
headers: {
|
|
274
|
-
'anthropic-ratelimit-unified-5h-utilization': '0.42',
|
|
275
|
-
'anthropic-ratelimit-unified-7d-utilization': '0.17',
|
|
276
|
-
},
|
|
277
|
-
})
|
|
278
|
-
}
|
|
279
|
-
try {
|
|
280
|
-
const r1 = await fetchAccountQuota('work@example.com', {
|
|
281
|
-
home,
|
|
282
|
-
fetchImpl: fakeFetch as typeof fetch,
|
|
283
|
-
})
|
|
284
|
-
const r2 = await fetchAccountQuota('work@example.com', {
|
|
285
|
-
home,
|
|
286
|
-
fetchImpl: fakeFetch as typeof fetch,
|
|
287
|
-
})
|
|
288
|
-
expect(r1.ok).toBe(true)
|
|
289
|
-
expect(r2.ok).toBe(true)
|
|
290
|
-
expect(callCount).toBe(1) // cache hit on the second call
|
|
291
|
-
} finally {
|
|
292
|
-
rmSync(home, { recursive: true, force: true })
|
|
293
|
-
}
|
|
294
|
-
})
|
|
295
|
-
|
|
296
|
-
it('force=true bypasses the cache', async () => {
|
|
297
|
-
const home = makeAccountHome({
|
|
298
|
-
'work@example.com': { accessToken: 'tok' },
|
|
299
|
-
})
|
|
300
|
-
let callCount = 0
|
|
301
|
-
const fakeFetch = async () => {
|
|
302
|
-
callCount++
|
|
303
|
-
return new Response('{}', {
|
|
304
|
-
status: 200,
|
|
305
|
-
headers: {
|
|
306
|
-
'anthropic-ratelimit-unified-5h-utilization': '0.5',
|
|
307
|
-
'anthropic-ratelimit-unified-7d-utilization': '0.5',
|
|
308
|
-
},
|
|
309
|
-
})
|
|
310
|
-
}
|
|
311
|
-
try {
|
|
312
|
-
await fetchAccountQuota('work@example.com', {
|
|
313
|
-
home,
|
|
314
|
-
fetchImpl: fakeFetch as typeof fetch,
|
|
315
|
-
})
|
|
316
|
-
await fetchAccountQuota('work@example.com', {
|
|
317
|
-
home,
|
|
318
|
-
fetchImpl: fakeFetch as typeof fetch,
|
|
319
|
-
force: true,
|
|
320
|
-
})
|
|
321
|
-
expect(callCount).toBe(2)
|
|
322
|
-
} finally {
|
|
323
|
-
rmSync(home, { recursive: true, force: true })
|
|
324
|
-
}
|
|
325
|
-
})
|
|
326
|
-
|
|
327
|
-
it('caches missing-credentials failures so the API is not pinged', async () => {
|
|
328
|
-
const home = makeAccountHome({})
|
|
329
|
-
let callCount = 0
|
|
330
|
-
const fakeFetch = async () => {
|
|
331
|
-
callCount++
|
|
332
|
-
return new Response('{}')
|
|
333
|
-
}
|
|
334
|
-
const r1 = await fetchAccountQuota('absent', {
|
|
335
|
-
home,
|
|
336
|
-
fetchImpl: fakeFetch as typeof fetch,
|
|
337
|
-
})
|
|
338
|
-
const r2 = await fetchAccountQuota('absent', {
|
|
339
|
-
home,
|
|
340
|
-
fetchImpl: fakeFetch as typeof fetch,
|
|
341
|
-
})
|
|
342
|
-
expect(r1.ok).toBe(false)
|
|
343
|
-
expect(r2.ok).toBe(false)
|
|
344
|
-
expect(callCount).toBe(0) // never reached fetch — token resolution failed first
|
|
345
|
-
})
|
|
346
|
-
|
|
347
|
-
it('cache miss after TTL triggers a fresh fetch', async () => {
|
|
348
|
-
const home = makeAccountHome({
|
|
349
|
-
'work@example.com': { accessToken: 'tok' },
|
|
350
|
-
})
|
|
351
|
-
let callCount = 0
|
|
352
|
-
const fakeFetch = async () => {
|
|
353
|
-
callCount++
|
|
354
|
-
return new Response('{}', {
|
|
355
|
-
status: 200,
|
|
356
|
-
headers: {
|
|
357
|
-
'anthropic-ratelimit-unified-5h-utilization': '0.3',
|
|
358
|
-
'anthropic-ratelimit-unified-7d-utilization': '0.3',
|
|
359
|
-
},
|
|
360
|
-
})
|
|
361
|
-
}
|
|
362
|
-
let nowVal = 1_000_000
|
|
363
|
-
const now = () => nowVal
|
|
364
|
-
try {
|
|
365
|
-
await fetchAccountQuota('work@example.com', {
|
|
366
|
-
home,
|
|
367
|
-
fetchImpl: fakeFetch as typeof fetch,
|
|
368
|
-
now,
|
|
369
|
-
})
|
|
370
|
-
// Step time past the TTL.
|
|
371
|
-
nowVal += ACCOUNT_QUOTA_CACHE_TTL_MS + 1
|
|
372
|
-
await fetchAccountQuota('work@example.com', {
|
|
373
|
-
home,
|
|
374
|
-
fetchImpl: fakeFetch as typeof fetch,
|
|
375
|
-
now,
|
|
376
|
-
})
|
|
377
|
-
expect(callCount).toBe(2)
|
|
378
|
-
} finally {
|
|
379
|
-
rmSync(home, { recursive: true, force: true })
|
|
380
|
-
}
|
|
381
|
-
})
|
|
382
|
-
|
|
383
|
-
// Removed in RFC H: per-account quota.json disk persistence is gone.
|
|
384
|
-
// switchroom-auth-broker holds canonical quota state and exposes it
|
|
385
|
-
// via list-state; the gateway's in-process cache is enough between
|
|
386
|
-
// restarts (and the broker survives gateway restarts, so the state
|
|
387
|
-
// is preserved at the broker side anyway).
|
|
388
|
-
})
|
|
389
|
-
|
|
390
|
-
describe('getCachedAccountQuota + prefetchAccountQuotaIfStale', () => {
|
|
391
|
-
beforeEach(() => {
|
|
392
|
-
clearAccountQuotaCache()
|
|
393
|
-
})
|
|
394
|
-
|
|
395
|
-
it('returns null on a cold cache, populates after a fetch', async () => {
|
|
396
|
-
const home = makeAccountHome({
|
|
397
|
-
'work@example.com': { accessToken: 'tok' },
|
|
398
|
-
})
|
|
399
|
-
try {
|
|
400
|
-
expect(getCachedAccountQuota('work@example.com')).toBeNull()
|
|
401
|
-
await fetchAccountQuota('work@example.com', {
|
|
402
|
-
home,
|
|
403
|
-
fetchImpl: (async () =>
|
|
404
|
-
new Response('{}', {
|
|
405
|
-
status: 200,
|
|
406
|
-
headers: {
|
|
407
|
-
'anthropic-ratelimit-unified-5h-utilization': '0.42',
|
|
408
|
-
'anthropic-ratelimit-unified-7d-utilization': '0.17',
|
|
409
|
-
},
|
|
410
|
-
})) as typeof fetch,
|
|
411
|
-
})
|
|
412
|
-
const cached = getCachedAccountQuota('work@example.com')
|
|
413
|
-
expect(cached?.ok).toBe(true)
|
|
414
|
-
if (cached?.ok) {
|
|
415
|
-
expect(Math.round(cached.data.fiveHourUtilizationPct)).toBe(42)
|
|
416
|
-
}
|
|
417
|
-
} finally {
|
|
418
|
-
rmSync(home, { recursive: true, force: true })
|
|
419
|
-
}
|
|
420
|
-
})
|
|
421
|
-
|
|
422
|
-
it("returns stale entries verbatim — staleness is the prefetch path's concern, not the read path's (v0.6.11)", async () => {
|
|
423
|
-
// The dashboard renders sync. Pre-v0.6.11 this function treated
|
|
424
|
-
// stale cache as a miss → the boot-warmed cache vanished after
|
|
425
|
-
// 30s and the operator saw empty quota rows on the first /auth
|
|
426
|
-
// tap of any session past that window. Now stale-but-present
|
|
427
|
-
// entries are returned; the background prefetch keeps the cache
|
|
428
|
-
// fresh across renders.
|
|
429
|
-
const home = makeAccountHome({
|
|
430
|
-
'work@example.com': { accessToken: 'tok' },
|
|
431
|
-
})
|
|
432
|
-
try {
|
|
433
|
-
const nowVal = 1_000_000
|
|
434
|
-
await fetchAccountQuota('work@example.com', {
|
|
435
|
-
home,
|
|
436
|
-
fetchImpl: (async () =>
|
|
437
|
-
new Response('{}', {
|
|
438
|
-
status: 200,
|
|
439
|
-
headers: {
|
|
440
|
-
'anthropic-ratelimit-unified-5h-utilization': '0.42',
|
|
441
|
-
'anthropic-ratelimit-unified-7d-utilization': '0.17',
|
|
442
|
-
},
|
|
443
|
-
})) as typeof fetch,
|
|
444
|
-
now: () => nowVal,
|
|
445
|
-
})
|
|
446
|
-
// Within TTL — cached.
|
|
447
|
-
const fresh = getCachedAccountQuota('work@example.com', nowVal)
|
|
448
|
-
expect(fresh).not.toBeNull()
|
|
449
|
-
// Past TTL — STILL returned, identical to the within-TTL read.
|
|
450
|
-
const after = nowVal + ACCOUNT_QUOTA_CACHE_TTL_MS + 1
|
|
451
|
-
const stale = getCachedAccountQuota('work@example.com', after)
|
|
452
|
-
expect(stale).not.toBeNull()
|
|
453
|
-
expect(stale).toEqual(fresh)
|
|
454
|
-
} finally {
|
|
455
|
-
rmSync(home, { recursive: true, force: true })
|
|
456
|
-
}
|
|
457
|
-
})
|
|
458
|
-
|
|
459
|
-
it('returns null when the label has never been probed', async () => {
|
|
460
|
-
// The only "no data" path: the cache map has no entry. After
|
|
461
|
-
// the first probe the entry persists for the lifetime of the
|
|
462
|
-
// gateway process, regardless of staleness.
|
|
463
|
-
expect(getCachedAccountQuota('never-probed@example.com')).toBeNull()
|
|
464
|
-
})
|
|
465
|
-
|
|
466
|
-
it('prefetchAccountQuotaIfStale is a noop when cache is fresh', async () => {
|
|
467
|
-
const home = makeAccountHome({
|
|
468
|
-
'work@example.com': { accessToken: 'tok' },
|
|
469
|
-
})
|
|
470
|
-
let callCount = 0
|
|
471
|
-
const fakeFetch = (async () => {
|
|
472
|
-
callCount++
|
|
473
|
-
return new Response('{}', {
|
|
474
|
-
status: 200,
|
|
475
|
-
headers: {
|
|
476
|
-
'anthropic-ratelimit-unified-5h-utilization': '0.42',
|
|
477
|
-
'anthropic-ratelimit-unified-7d-utilization': '0.17',
|
|
478
|
-
},
|
|
479
|
-
})
|
|
480
|
-
}) as typeof fetch
|
|
481
|
-
try {
|
|
482
|
-
await fetchAccountQuota('work@example.com', { home, fetchImpl: fakeFetch })
|
|
483
|
-
expect(callCount).toBe(1)
|
|
484
|
-
// Fresh cache — prefetch should not fire.
|
|
485
|
-
prefetchAccountQuotaIfStale('work@example.com', { home, fetchImpl: fakeFetch })
|
|
486
|
-
// Yield once to let any spurious microtasks settle.
|
|
487
|
-
await Promise.resolve()
|
|
488
|
-
expect(callCount).toBe(1)
|
|
489
|
-
} finally {
|
|
490
|
-
rmSync(home, { recursive: true, force: true })
|
|
491
|
-
}
|
|
492
|
-
})
|
|
493
|
-
})
|
|
494
|
-
|
|
495
|
-
describe('regression: boot-warm + delayed sync-read (v0.6.11)', () => {
|
|
496
|
-
// The bug: gateway boot-warm fills the cache; cache TTL elapses;
|
|
497
|
-
// dashboard's sync read returns null; operator sees empty quota
|
|
498
|
-
// rows on first /auth tap of the session past TTL. Fix: sync read
|
|
499
|
-
// returns last-known data regardless of staleness; prefetch path
|
|
500
|
-
// owns the freshness contract. Pin both legs so a future TTL
|
|
501
|
-
// tweak can't silently re-introduce the bug.
|
|
502
|
-
it('returns last-known data even after multiple TTL windows have elapsed', async () => {
|
|
503
|
-
clearAccountQuotaCache()
|
|
504
|
-
const home = makeAccountHome({
|
|
505
|
-
'pixsoul@gmail.com': { accessToken: 'tok' },
|
|
506
|
-
})
|
|
507
|
-
try {
|
|
508
|
-
const t0 = 1_000_000
|
|
509
|
-
// Boot-warm: probe completes at t0.
|
|
510
|
-
await fetchAccountQuota('pixsoul@gmail.com', {
|
|
511
|
-
home,
|
|
512
|
-
fetchImpl: (async () =>
|
|
513
|
-
new Response('{}', {
|
|
514
|
-
status: 200,
|
|
515
|
-
headers: {
|
|
516
|
-
'anthropic-ratelimit-unified-5h-utilization': '0.04',
|
|
517
|
-
'anthropic-ratelimit-unified-7d-utilization': '0.78',
|
|
518
|
-
},
|
|
519
|
-
})) as typeof fetch,
|
|
520
|
-
now: () => t0,
|
|
521
|
-
})
|
|
522
|
-
// 8.5 minutes later (the screenshot-reproduction window): the
|
|
523
|
-
// dashboard fetches state. Sync read returns the boot-warmed
|
|
524
|
-
// values rather than null.
|
|
525
|
-
const tDashboard = t0 + 8.5 * 60_000
|
|
526
|
-
const cached = getCachedAccountQuota('pixsoul@gmail.com', tDashboard)
|
|
527
|
-
expect(cached).not.toBeNull()
|
|
528
|
-
if (cached?.ok) {
|
|
529
|
-
expect(cached.data.fiveHourUtilizationPct).toBe(4)
|
|
530
|
-
expect(cached.data.sevenDayUtilizationPct).toBe(78)
|
|
531
|
-
} else {
|
|
532
|
-
throw new Error('expected ok=true cached entry')
|
|
533
|
-
}
|
|
534
|
-
} finally {
|
|
535
|
-
rmSync(home, { recursive: true, force: true })
|
|
536
|
-
}
|
|
537
|
-
})
|
|
538
|
-
|
|
539
|
-
it('prefetchAccountQuotaIfStale re-probes once the TTL has elapsed', async () => {
|
|
540
|
-
clearAccountQuotaCache()
|
|
541
|
-
const home = makeAccountHome({
|
|
542
|
-
'work@example.com': { accessToken: 'tok' },
|
|
543
|
-
})
|
|
544
|
-
try {
|
|
545
|
-
const t0 = 1_000_000
|
|
546
|
-
let fetchCount = 0
|
|
547
|
-
const counterFetch: typeof fetch = async () => {
|
|
548
|
-
fetchCount++
|
|
549
|
-
return new Response('{}', {
|
|
550
|
-
status: 200,
|
|
551
|
-
headers: {
|
|
552
|
-
'anthropic-ratelimit-unified-5h-utilization': '0.10',
|
|
553
|
-
'anthropic-ratelimit-unified-7d-utilization': '0.20',
|
|
554
|
-
},
|
|
555
|
-
})
|
|
556
|
-
}
|
|
557
|
-
// First probe seeds the cache.
|
|
558
|
-
await fetchAccountQuota('work@example.com', {
|
|
559
|
-
home,
|
|
560
|
-
fetchImpl: counterFetch,
|
|
561
|
-
now: () => t0,
|
|
562
|
-
})
|
|
563
|
-
expect(fetchCount).toBe(1)
|
|
564
|
-
// Within TTL: prefetch is a no-op.
|
|
565
|
-
prefetchAccountQuotaIfStale('work@example.com', {
|
|
566
|
-
home,
|
|
567
|
-
fetchImpl: counterFetch,
|
|
568
|
-
now: () => t0 + 60_000,
|
|
569
|
-
})
|
|
570
|
-
// Give microtask queue a chance — should still be 1.
|
|
571
|
-
await new Promise((r) => setTimeout(r, 5))
|
|
572
|
-
expect(fetchCount).toBe(1)
|
|
573
|
-
// Past TTL: prefetch fires a fresh probe.
|
|
574
|
-
prefetchAccountQuotaIfStale('work@example.com', {
|
|
575
|
-
home,
|
|
576
|
-
fetchImpl: counterFetch,
|
|
577
|
-
now: () => t0 + ACCOUNT_QUOTA_CACHE_TTL_MS + 1,
|
|
578
|
-
})
|
|
579
|
-
// Wait for the fire-and-forget probe to complete.
|
|
580
|
-
await new Promise((r) => setTimeout(r, 20))
|
|
581
|
-
expect(fetchCount).toBe(2)
|
|
582
|
-
} finally {
|
|
583
|
-
rmSync(home, { recursive: true, force: true })
|
|
584
|
-
}
|
|
585
|
-
})
|
|
586
|
-
|
|
587
|
-
it('cache TTL is at least 1 minute — short TTLs cause empty-row regressions', () => {
|
|
588
|
-
// Pre-v0.6.11 was 30s, which made the boot-warm useless. If a
|
|
589
|
-
// future PR drops it below 60s, this test catches it before the
|
|
590
|
-
// empty-row regression hits production.
|
|
591
|
-
expect(ACCOUNT_QUOTA_CACHE_TTL_MS).toBeGreaterThanOrEqual(60_000)
|
|
592
|
-
})
|
|
593
|
-
})
|
|
594
|
-
|
|
595
186
|
describe('fetchQuota — accessToken parameter', () => {
|
|
596
187
|
it('accepts a direct accessToken instead of a config dir', async () => {
|
|
597
188
|
const fakeFetch = async (_url: unknown, init?: RequestInit) => {
|