switchroom 0.18.28 → 0.18.30
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/handoff-briefing.sh +15 -2
- package/dist/agent-scheduler/index.js +111 -7
- package/dist/auth-broker/index.js +154 -73
- package/dist/cli/autoaccept-poll.js +8 -3
- package/dist/cli/drive-write-pretool.mjs +8 -3
- package/dist/cli/ms-365-write-pretool.mjs +158 -11
- package/dist/cli/notion-write-pretool.mjs +103 -4
- package/dist/cli/switchroom.js +2712 -2219
- package/dist/host-control/main.js +110 -70
- package/dist/vault/approvals/kernel-server.js +116 -70
- package/dist/vault/broker/server.js +314 -202
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +105 -34
- package/telegram-plugin/dist/bridge/bridge.js +71 -47
- package/telegram-plugin/dist/gateway/gateway.js +1128 -666
- package/telegram-plugin/dist/server.js +89 -64
- package/telegram-plugin/gateway/backstop-delivery.ts +272 -0
- package/telegram-plugin/gateway/forward-origin.ts +9 -1
- package/telegram-plugin/gateway/gateway.ts +656 -388
- package/telegram-plugin/gateway/model-command.ts +331 -602
- package/telegram-plugin/gateway/session-model-file.ts +40 -0
- package/telegram-plugin/gateway/turn-record-status.ts +45 -0
- package/telegram-plugin/gateway/unhandled-message.ts +177 -0
- package/telegram-plugin/history.ts +153 -23
- package/telegram-plugin/llm-error-present.ts +24 -0
- package/telegram-plugin/model-unavailable.ts +55 -0
- package/telegram-plugin/operator-events.ts +113 -0
- package/telegram-plugin/pending-user-notice.ts +88 -0
- package/telegram-plugin/shared/local-time.ts +99 -0
- package/telegram-plugin/tests/backstop-delivery.test.ts +250 -0
- package/telegram-plugin/tests/catch-all-forwarded-history.test.ts +103 -0
- package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +264 -0
- package/telegram-plugin/tests/forward-origin.test.ts +30 -3
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +111 -60
- package/telegram-plugin/tests/history.test.ts +88 -0
- package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +278 -0
- package/telegram-plugin/tests/local-time.test.ts +135 -0
- package/telegram-plugin/tests/model-command.test.ts +427 -1512
- package/telegram-plugin/tests/session-model-file.test.ts +23 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +34 -0
- package/telegram-plugin/tier-downgrade.ts +4 -3
- package/telegram-plugin/turn-flush-safety.ts +25 -1
- package/vendor/hindsight-memory/scripts/backfill_transcripts.py +399 -2
- package/vendor/hindsight-memory/scripts/lib/client.py +47 -0
- package/vendor/hindsight-memory/scripts/lib/content.py +93 -7
- package/vendor/hindsight-memory/scripts/lib/turnlog.py +450 -0
- package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +467 -0
- package/vendor/hindsight-memory/tests/test_content.py +63 -7
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* litellm-proxy-auth-misconfig.test.ts — outcome-asserting tests for Ken's
|
|
3
|
+
* deterministic error-surfacing policy applied to the LiteLLM proxy-fallback
|
|
4
|
+
* 401 incident (2026-07-16).
|
|
5
|
+
*
|
|
6
|
+
* Root cause: LiteLLM's internal fallback chain re-dispatched WITHOUT the
|
|
7
|
+
* client's OAuth Authorization header onto a keyless `claude-sonnet-5`
|
|
8
|
+
* deployment, so Anthropic returned 401 authentication_error "x-api-key header
|
|
9
|
+
* is required". The gateway classifier mapped ANY authentication_error →
|
|
10
|
+
* credentials-invalid → an always-rendered "🔑 re-authenticate" card that was
|
|
11
|
+
* broadcast to EVERY allowlist chat — wrong audience (a non-operator user like
|
|
12
|
+
* Lisa cannot re-auth) and wrong diagnosis (the login is fine).
|
|
13
|
+
*
|
|
14
|
+
* These assert OUTCOMES:
|
|
15
|
+
* 1. the proxy 401 classifies as an operator-only infra fault, NEVER auth /
|
|
16
|
+
* credentials-invalid, and NEVER renders a re-auth card;
|
|
17
|
+
* 2. a genuine credential expiry still classifies as credentials-expired and
|
|
18
|
+
* still reaches the operator;
|
|
19
|
+
* 3. the audience router sends operator-actionable faults to the operator
|
|
20
|
+
* ONLY, and hands non-operator users a diagnosis-free plain-language
|
|
21
|
+
* notice — while the operator (allowlist head, incl. their own DM) always
|
|
22
|
+
* keeps the full card.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { describe, it, expect } from 'vitest'
|
|
26
|
+
import {
|
|
27
|
+
classifyClaudeError,
|
|
28
|
+
renderOperatorEvent,
|
|
29
|
+
decideOperatorEventAudience,
|
|
30
|
+
isOperatorActionableKind,
|
|
31
|
+
renderUserFacingFailureNotice,
|
|
32
|
+
type OperatorEvent,
|
|
33
|
+
type OperatorEventKind,
|
|
34
|
+
} from '../operator-events.js'
|
|
35
|
+
import { isLitellmProxyAuthMisconfig } from '../model-unavailable.js'
|
|
36
|
+
import { parseLlmError, renderLlmError } from '../llm-error-present.js'
|
|
37
|
+
import {
|
|
38
|
+
PendingUserNoticeGate,
|
|
39
|
+
PENDING_USER_NOTICE_TTL_MS,
|
|
40
|
+
type PendingUserNotice,
|
|
41
|
+
} from '../pending-user-notice.js'
|
|
42
|
+
|
|
43
|
+
// The verbatim shape Anthropic returns to the header-less proxy fallback.
|
|
44
|
+
const PROXY_401_MESSAGE =
|
|
45
|
+
'litellm.AuthenticationError: AnthropicException - {"type":"error","error":{"type":"authentication_error","message":"x-api-key header is required"}} (fallback deployment claude-sonnet-5)'
|
|
46
|
+
|
|
47
|
+
function makeEvent(kind: OperatorEventKind, overrides?: Partial<OperatorEvent>): OperatorEvent {
|
|
48
|
+
return {
|
|
49
|
+
kind,
|
|
50
|
+
agent: 'gymbro',
|
|
51
|
+
detail: '',
|
|
52
|
+
suggestedActions: [],
|
|
53
|
+
firstSeenAt: new Date('2026-07-16T20:00:00Z'),
|
|
54
|
+
...overrides,
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('isLitellmProxyAuthMisconfig', () => {
|
|
59
|
+
it('detects the definitive keyless-401 marker', () => {
|
|
60
|
+
expect(isLitellmProxyAuthMisconfig(PROXY_401_MESSAGE)).toBe(true)
|
|
61
|
+
expect(isLitellmProxyAuthMisconfig('x-api-key header is required')).toBe(true)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('detects an authentication_error carrying the fallback + x-api-key structural pair', () => {
|
|
65
|
+
expect(
|
|
66
|
+
isLitellmProxyAuthMisconfig(
|
|
67
|
+
'litellm fallback re-dispatch: authentication_error — upstream demanded x-api-key on backup deployment',
|
|
68
|
+
),
|
|
69
|
+
).toBe(true)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('does NOT down-classify a genuine Anthropic OAuth expiry', () => {
|
|
73
|
+
expect(
|
|
74
|
+
isLitellmProxyAuthMisconfig(
|
|
75
|
+
'authentication_error: OAuth token has expired. Please run /login to refresh.',
|
|
76
|
+
),
|
|
77
|
+
).toBe(false)
|
|
78
|
+
expect(isLitellmProxyAuthMisconfig('authentication_error: invalid bearer token')).toBe(false)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
// #3293 review finding 2 — precision over recall: an AMBIGUOUS proxy
|
|
82
|
+
// envelope (mentions litellm/proxy/api_key but lacks the keyless-fallback
|
|
83
|
+
// signature) must KEEP the credentials diagnosis, not become misconfig.
|
|
84
|
+
it('does NOT claim a genuine expiry wrapped in a proxy envelope mentioning api_key', () => {
|
|
85
|
+
const wrappedExpiry =
|
|
86
|
+
'litellm.AuthenticationError: AnthropicException - authentication_error: OAuth token has expired (api_key slot proxy-default). Please refresh.'
|
|
87
|
+
expect(isLitellmProxyAuthMisconfig(wrappedExpiry)).toBe(false)
|
|
88
|
+
expect(
|
|
89
|
+
classifyClaudeError({ type: 'authentication_error', status: 401, message: wrappedExpiry }),
|
|
90
|
+
).toBe('credentials-expired')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('does NOT claim proxy+fallback wording without an explicit x-api-key signal', () => {
|
|
94
|
+
expect(
|
|
95
|
+
isLitellmProxyAuthMisconfig(
|
|
96
|
+
'litellm proxy fallback: authentication_error while routing to backup deployment',
|
|
97
|
+
),
|
|
98
|
+
).toBe(false)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('never throws on junk input', () => {
|
|
102
|
+
expect(isLitellmProxyAuthMisconfig('')).toBe(false)
|
|
103
|
+
// @ts-expect-error — deliberate wrong type
|
|
104
|
+
expect(isLitellmProxyAuthMisconfig(null)).toBe(false)
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
describe('classifyClaudeError — proxy 401 vs genuine credential faults', () => {
|
|
109
|
+
it('classifies the proxy fallback 401 as proxy-misconfig, NOT credentials-invalid', () => {
|
|
110
|
+
const kind = classifyClaudeError({
|
|
111
|
+
type: 'authentication_error',
|
|
112
|
+
status: 401,
|
|
113
|
+
message: PROXY_401_MESSAGE,
|
|
114
|
+
})
|
|
115
|
+
expect(kind).toBe('proxy-misconfig')
|
|
116
|
+
expect(kind).not.toBe('credentials-invalid')
|
|
117
|
+
expect(kind).not.toBe('credentials-expired')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('still classifies a genuine expired credential as credentials-expired', () => {
|
|
121
|
+
const kind = classifyClaudeError({
|
|
122
|
+
type: 'authentication_error',
|
|
123
|
+
message: 'authentication_error: your OAuth token has expired, please refresh',
|
|
124
|
+
})
|
|
125
|
+
expect(kind).toBe('credentials-expired')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('still classifies a bare invalid credential as credentials-invalid', () => {
|
|
129
|
+
const kind = classifyClaudeError({
|
|
130
|
+
type: 'authentication_error',
|
|
131
|
+
message: 'authentication_error: credentials are not valid',
|
|
132
|
+
})
|
|
133
|
+
expect(kind).toBe('credentials-invalid')
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
describe('renderOperatorEvent — proxy-misconfig card', () => {
|
|
138
|
+
it('renders an operator infra card with NO re-auth button and NO login language', () => {
|
|
139
|
+
const { text, keyboard } = renderOperatorEvent(makeEvent('proxy-misconfig', { detail: 'x-api-key header is required' }))
|
|
140
|
+
// No re-auth instruction reaches the surface.
|
|
141
|
+
expect(text.toLowerCase()).not.toContain('re-authenticate')
|
|
142
|
+
expect(text.toLowerCase()).not.toContain('reauth')
|
|
143
|
+
expect(text).toContain('misconfig')
|
|
144
|
+
// Only a Dismiss button — never a Reauth callback.
|
|
145
|
+
const callbacks = keyboard.inline_keyboard.flat().map((b) => b.callback_data ?? '')
|
|
146
|
+
expect(callbacks.some((c) => c.startsWith('op:reauth:'))).toBe(false)
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
describe('parseLlmError — proxy 401 never becomes the user-facing auth kind', () => {
|
|
151
|
+
it('parses the proxy 401 as infra_misconfig (operator-only), source litellm-local', () => {
|
|
152
|
+
const parsed = parseLlmError(PROXY_401_MESSAGE)
|
|
153
|
+
expect(parsed.kind).toBe('infra_misconfig')
|
|
154
|
+
expect(parsed.kind).not.toBe('auth')
|
|
155
|
+
expect(parsed.source).toBe('litellm-local')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('the rendered infra_misconfig line carries NO re-auth recommendation', () => {
|
|
159
|
+
const parsed = parseLlmError(PROXY_401_MESSAGE)
|
|
160
|
+
const { text } = renderLlmError(parsed, 'gymbro', 'UTC')
|
|
161
|
+
expect(text.toLowerCase()).not.toContain('re-authenticate this account')
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('a genuine OAuth expiry still parses as the actionable auth kind', () => {
|
|
165
|
+
const parsed = parseLlmError('authentication_error: OAuth token has expired, please refresh')
|
|
166
|
+
expect(parsed.kind).toBe('auth')
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
describe('decideOperatorEventAudience — Ken routing policy', () => {
|
|
171
|
+
const allow = ['5000000001' /* operator */, '5000000002' /* Lisa */, '5000000003']
|
|
172
|
+
|
|
173
|
+
it('routes the proxy misconfig to the operator ONLY; users get a plain notice', () => {
|
|
174
|
+
const { operatorChats, userNoticeChats } = decideOperatorEventAudience(
|
|
175
|
+
'proxy-misconfig',
|
|
176
|
+
allow,
|
|
177
|
+
allow[0],
|
|
178
|
+
)
|
|
179
|
+
expect(operatorChats).toEqual(['5000000001'])
|
|
180
|
+
expect(userNoticeChats).toEqual(['5000000002', '5000000003'])
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('routes credentials-invalid / -expired / credit-exhausted to the operator ONLY', () => {
|
|
184
|
+
for (const kind of ['credentials-invalid', 'credentials-expired', 'credit-exhausted'] as const) {
|
|
185
|
+
const a = decideOperatorEventAudience(kind, allow, allow[0])
|
|
186
|
+
expect(a.operatorChats).toEqual(['5000000001'])
|
|
187
|
+
expect(a.userNoticeChats).toEqual(['5000000002', '5000000003'])
|
|
188
|
+
}
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('preserves the operator DM: a lone-operator allowlist still gets the full card, no user notice', () => {
|
|
192
|
+
const a = decideOperatorEventAudience('proxy-misconfig', ['5000000001'], '5000000001')
|
|
193
|
+
expect(a.operatorChats).toEqual(['5000000001'])
|
|
194
|
+
expect(a.userNoticeChats).toEqual([])
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('leaves non-operator-actionable kinds broadcasting to every allowlist chat', () => {
|
|
198
|
+
for (const kind of ['rate-limited', 'unknown-5xx', 'agent-crashed', 'quota-exhausted'] as const) {
|
|
199
|
+
const a = decideOperatorEventAudience(kind, allow, allow[0])
|
|
200
|
+
expect(a.operatorChats).toEqual(allow)
|
|
201
|
+
expect(a.userNoticeChats).toEqual([])
|
|
202
|
+
}
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('isOperatorActionableKind gates exactly the credential/infra fault classes', () => {
|
|
206
|
+
expect(isOperatorActionableKind('proxy-misconfig')).toBe(true)
|
|
207
|
+
expect(isOperatorActionableKind('credentials-invalid')).toBe(true)
|
|
208
|
+
expect(isOperatorActionableKind('credentials-expired')).toBe(true)
|
|
209
|
+
expect(isOperatorActionableKind('credit-exhausted')).toBe(true)
|
|
210
|
+
expect(isOperatorActionableKind('rate-limited')).toBe(false)
|
|
211
|
+
expect(isOperatorActionableKind('quota-exhausted')).toBe(false)
|
|
212
|
+
})
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
// #3293 review finding 1 — the user notice is gated on the TURN OUTCOME, not
|
|
216
|
+
// the error line: a turn that 401s on the proxy fallback but still delivers a
|
|
217
|
+
// reply must produce NO user notice; only a reply-less turn end flushes it.
|
|
218
|
+
describe('PendingUserNoticeGate — notice fires IFF the turn dies reply-less', () => {
|
|
219
|
+
function makeNotice(overrides?: Partial<PendingUserNotice>): PendingUserNotice {
|
|
220
|
+
return {
|
|
221
|
+
chatIds: ['5000000002', '5000000003'],
|
|
222
|
+
text: renderUserFacingFailureNotice(),
|
|
223
|
+
agent: 'gymbro',
|
|
224
|
+
kind: 'proxy-misconfig',
|
|
225
|
+
atMs: 1_000_000,
|
|
226
|
+
...overrides,
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
it('DROPS the notice when the turn recovered (delivered a reply)', () => {
|
|
231
|
+
const gate = new PendingUserNoticeGate()
|
|
232
|
+
gate.schedule(makeNotice())
|
|
233
|
+
expect(gate.resolveTurnEnd(true, 1_001_000)).toEqual([])
|
|
234
|
+
// And it does not resurrect on a later failed turn end.
|
|
235
|
+
expect(gate.resolveTurnEnd(false, 1_002_000)).toEqual([])
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('SENDS the notice exactly once when the turn ends without a reply', () => {
|
|
239
|
+
const gate = new PendingUserNoticeGate()
|
|
240
|
+
const notice = makeNotice()
|
|
241
|
+
gate.schedule(notice)
|
|
242
|
+
const flushed = gate.resolveTurnEnd(false, 1_001_000)
|
|
243
|
+
expect(flushed).toHaveLength(1)
|
|
244
|
+
expect(flushed[0].chatIds).toEqual(['5000000002', '5000000003'])
|
|
245
|
+
// Exactly once — a second turn end flushes nothing.
|
|
246
|
+
expect(gate.resolveTurnEnd(false, 1_002_000)).toEqual([])
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('collapses an error burst within one turn to ONE pending notice per agent', () => {
|
|
250
|
+
const gate = new PendingUserNoticeGate()
|
|
251
|
+
gate.schedule(makeNotice({ atMs: 1_000_000 }))
|
|
252
|
+
gate.schedule(makeNotice({ atMs: 1_000_500 }))
|
|
253
|
+
gate.schedule(makeNotice({ atMs: 1_001_000 }))
|
|
254
|
+
expect(gate.resolveTurnEnd(false, 1_002_000)).toHaveLength(1)
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('expires an unresolved notice after the TTL (bias to silence over false failure)', () => {
|
|
258
|
+
const gate = new PendingUserNoticeGate()
|
|
259
|
+
gate.schedule(makeNotice({ atMs: 1_000_000 }))
|
|
260
|
+
const after = 1_000_000 + PENDING_USER_NOTICE_TTL_MS + 1
|
|
261
|
+
expect(gate.hasPending(after)).toBe(false)
|
|
262
|
+
expect(gate.resolveTurnEnd(false, after)).toEqual([])
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
describe('renderUserFacingFailureNotice', () => {
|
|
267
|
+
it('is a diagnosis-free, re-auth-free, byte-free plain-language notice', () => {
|
|
268
|
+
const notice = renderUserFacingFailureNotice()
|
|
269
|
+
const lower = notice.toLowerCase()
|
|
270
|
+
expect(lower).not.toContain('re-auth')
|
|
271
|
+
expect(lower).not.toContain('reauth')
|
|
272
|
+
expect(lower).not.toContain('x-api-key')
|
|
273
|
+
expect(lower).not.toContain('litellm')
|
|
274
|
+
expect(lower).not.toContain('authentication_error')
|
|
275
|
+
// Communicates "our side" honestly.
|
|
276
|
+
expect(lower).toContain('our side')
|
|
277
|
+
})
|
|
278
|
+
})
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the model-facing local-time primitives in
|
|
3
|
+
* telegram-plugin/shared/local-time.ts.
|
|
4
|
+
*
|
|
5
|
+
* switchroom #tz-fix: `fmtLocalStamp` + `resolveEnvTimezone` are the single
|
|
6
|
+
* source of truth for the LOCAL am/pm timestamps the model now sees on inbound
|
|
7
|
+
* `<channel ts="…">` tags, `forwarded_date`, and the get_recent_messages
|
|
8
|
+
* buffer — replacing the UTC ISO strings that competed with the local-time
|
|
9
|
+
* hint. These pin the deterministic outcome: local am/pm, NEVER a UTC string.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, it } from 'vitest'
|
|
13
|
+
import { fmtLocalStamp, resolveEnvTimezone, renderLogTimestampsLocal } from '../shared/local-time.js'
|
|
14
|
+
|
|
15
|
+
// 2025-06-15T14:26:40Z — a fixed instant so the local rendering is deterministic.
|
|
16
|
+
const MS = 1750000000 * 1000
|
|
17
|
+
|
|
18
|
+
describe('resolveEnvTimezone', () => {
|
|
19
|
+
it('follows the SWITCHROOM_TIMEZONE → TZ → UTC cascade', () => {
|
|
20
|
+
expect(resolveEnvTimezone({ SWITCHROOM_TIMEZONE: 'Australia/Melbourne', TZ: 'America/New_York' })).toBe(
|
|
21
|
+
'Australia/Melbourne',
|
|
22
|
+
)
|
|
23
|
+
expect(resolveEnvTimezone({ TZ: 'America/New_York' })).toBe('America/New_York')
|
|
24
|
+
expect(resolveEnvTimezone({})).toBe('UTC')
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('fmtLocalStamp', () => {
|
|
29
|
+
it('renders LOCAL am/pm with weekday, ISO date, and zone abbrev — no UTC', () => {
|
|
30
|
+
const out = fmtLocalStamp(MS, 'Australia/Melbourne')
|
|
31
|
+
// e.g. "Sunday 2025-06-16 12:26 AM AEST"
|
|
32
|
+
expect(out).toMatch(/^[A-Za-z]+ \d{4}-\d{2}-\d{2} \d{2}:\d{2} (?:AM|PM) [A-Za-z]{2,5}$/)
|
|
33
|
+
expect(out).not.toContain('UTC')
|
|
34
|
+
expect(out.endsWith('Z')).toBe(false)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('reflects the requested zone (different wall clock for a different tz)', () => {
|
|
38
|
+
const melbourne = fmtLocalStamp(MS, 'Australia/Melbourne')
|
|
39
|
+
const newYork = fmtLocalStamp(MS, 'America/New_York')
|
|
40
|
+
expect(melbourne).not.toBe(newYork)
|
|
41
|
+
expect(newYork).toMatch(/ (?:AM|PM) [A-Za-z]{2,5}$/)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('is total — an invalid IANA zone degrades to am/pm, never throws', () => {
|
|
45
|
+
// Must not throw out of the inbound path on a misconfigured agent.
|
|
46
|
+
const out = fmtLocalStamp(MS, 'Not/ARealZone')
|
|
47
|
+
expect(typeof out).toBe('string')
|
|
48
|
+
expect(out.length).toBeGreaterThan(0)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('UTC zone still renders am/pm (24h/"UTC-suffix" form is gone)', () => {
|
|
52
|
+
const out = fmtLocalStamp(MS, 'UTC')
|
|
53
|
+
expect(out).toMatch(/ (?:AM|PM) /)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('tracks DST — Australia/Melbourne is AEDT in Jan (summer) and AEST in Jul (winter)', () => {
|
|
57
|
+
// Southern-hemisphere DST: daylight time is the Dec–Mar summer.
|
|
58
|
+
const summer = fmtLocalStamp(Date.UTC(2026, 0, 15, 3, 0, 0), 'Australia/Melbourne') // 15 Jan
|
|
59
|
+
const winter = fmtLocalStamp(Date.UTC(2026, 6, 15, 3, 0, 0), 'Australia/Melbourne') // 15 Jul
|
|
60
|
+
expect(summer).toContain('AEDT')
|
|
61
|
+
expect(winter).toContain('AEST')
|
|
62
|
+
// Both stay am/pm and UTC-free across the transition.
|
|
63
|
+
for (const s of [summer, winter]) {
|
|
64
|
+
expect(s).toMatch(/ (?:AM|PM) /)
|
|
65
|
+
expect(s).not.toContain('UTC')
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('renderLogTimestampsLocal', () => {
|
|
71
|
+
// #tz-fix audit (MEDIUM gap 2): /logs docker lines carry a leading UTC
|
|
72
|
+
// ISO-Z stamp; render it in local am/pm at display time.
|
|
73
|
+
it('rewrites a leading UTC ISO-Z timestamp into local am/pm', () => {
|
|
74
|
+
// 04:09Z in July → Melbourne AEST (UTC+10) → 02:09 PM.
|
|
75
|
+
const out = renderLogTimestampsLocal(
|
|
76
|
+
'2026-07-16T04:09:00.123456789Z gateway boot ok',
|
|
77
|
+
'Australia/Melbourne',
|
|
78
|
+
)
|
|
79
|
+
expect(out).toContain('2026-07-16 02:09 PM AEST')
|
|
80
|
+
expect(out).toContain('gateway boot ok')
|
|
81
|
+
expect(out).not.toContain('T04:09')
|
|
82
|
+
expect(out).not.toContain('Z gateway')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('handles a fractionless ISO-Z timestamp', () => {
|
|
86
|
+
const out = renderLogTimestampsLocal('2026-07-16T04:09:00Z hello', 'Australia/Melbourne')
|
|
87
|
+
expect(out).toContain('2026-07-16 02:09 PM AEST hello')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('converts every line and preserves non-timestamped lines verbatim', () => {
|
|
91
|
+
const input = [
|
|
92
|
+
'2026-07-16T04:09:00Z first',
|
|
93
|
+
' continuation line with no timestamp',
|
|
94
|
+
'2026-07-16T05:10:00Z second',
|
|
95
|
+
].join('\n')
|
|
96
|
+
const out = renderLogTimestampsLocal(input, 'Australia/Melbourne').split('\n')
|
|
97
|
+
expect(out[0]).toContain('02:09 PM AEST first')
|
|
98
|
+
expect(out[1]).toBe(' continuation line with no timestamp')
|
|
99
|
+
expect(out[2]).toContain('03:10 PM AEST second')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('preserves a line whose leading token is not a valid ISO-Z stamp', () => {
|
|
103
|
+
const raw = 'not-a-timestamp still here'
|
|
104
|
+
expect(renderLogTimestampsLocal(raw, 'Australia/Melbourne')).toBe(raw)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('does not touch a timestamp that is not at line start', () => {
|
|
108
|
+
const raw = 'prefix 2026-07-16T04:09:00Z trailing'
|
|
109
|
+
expect(renderLogTimestampsLocal(raw, 'Australia/Melbourne')).toBe(raw)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('returns empty input unchanged', () => {
|
|
113
|
+
expect(renderLogTimestampsLocal('', 'Australia/Melbourne')).toBe('')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
// End-to-end shape check against REAL `docker logs --timestamps` output —
|
|
117
|
+
// the /logs path requests --timestamps precisely so this prefix exists.
|
|
118
|
+
// (Captured verbatim from a live container; nanosecond precision.)
|
|
119
|
+
it('converts a verbatim docker --timestamps line (the real /logs input shape)', () => {
|
|
120
|
+
const real = '2026-07-16T20:17:58.433481596Z gateway-supervisor: heartbeat ok'
|
|
121
|
+
const out = renderLogTimestampsLocal(real, 'Australia/Melbourne')
|
|
122
|
+
// 20:17:58Z on 16 Jul → AEST (UTC+10) → 06:17 AM on 17 Jul local.
|
|
123
|
+
expect(out).toBe('Friday 2026-07-17 06:17 AM AEST gateway-supervisor: heartbeat ok')
|
|
124
|
+
expect(out).not.toContain('Z ')
|
|
125
|
+
expect(out).not.toContain('UTC')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('does NOT touch an app-emitted local-zone stamp (no double shift)', () => {
|
|
129
|
+
// Post-#3275 containers run with local TZ baked, so Python-style
|
|
130
|
+
// `%H:%M:%S,mmm` stamps are already local wall clock — converting them
|
|
131
|
+
// as UTC would be wrong. They must pass through verbatim.
|
|
132
|
+
const appLine = '2026-07-16 20:14:14,725 - INFO - scheduler tick'
|
|
133
|
+
expect(renderLogTimestampsLocal(appLine, 'Australia/Melbourne')).toBe(appLine)
|
|
134
|
+
})
|
|
135
|
+
})
|