switchroom 0.7.15 → 0.10.0
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 +51 -59
- package/bin/run-hook.sh +27 -11
- package/bin/timezone-hook.sh +9 -7
- package/dist/agent-scheduler/index.js +410 -133
- package/dist/auth-broker/index.js +13932 -0
- package/dist/cli/switchroom.js +26937 -5601
- package/dist/host-control/main.js +12702 -0
- package/dist/vault/approvals/kernel-server.js +467 -184
- package/dist/vault/broker/server.js +1430 -724
- package/examples/minimal.yaml +63 -0
- package/examples/personal-google-workspace-mcp/.env.example +34 -0
- package/examples/personal-google-workspace-mcp/README.md +194 -0
- package/examples/personal-google-workspace-mcp/compose.yaml +66 -0
- package/examples/switchroom.yaml +220 -0
- package/package.json +7 -4
- package/profiles/_base/settings.json.hbs +20 -5
- package/profiles/_base/start.sh.hbs +16 -3
- package/profiles/_shared/agent-self-service.md.hbs +126 -0
- package/profiles/_shared/telegram-style.md.hbs +20 -90
- package/profiles/_shared/vault-protocol.md.hbs +68 -0
- package/profiles/default/CLAUDE.md +50 -96
- package/profiles/default/CLAUDE.md.hbs +36 -6
- package/profiles/default/workspace/SOUL.md.hbs +12 -5
- package/skills/buildkite-agent-infrastructure/SKILL.md +30 -11
- package/skills/buildkite-agent-runtime/SKILL.md +44 -11
- package/skills/buildkite-api/SKILL.md +31 -8
- package/skills/buildkite-cli/SKILL.md +27 -9
- package/skills/buildkite-migration/SKILL.md +22 -9
- package/skills/buildkite-pipelines/SKILL.md +26 -9
- package/skills/buildkite-secure-delivery/SKILL.md +23 -9
- package/skills/buildkite-test-engine/SKILL.md +25 -8
- package/skills/docx/SKILL.md +1 -1
- 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/file-bug/SKILL.md +34 -6
- package/skills/humanizer/SKILL.md +15 -0
- package/skills/humanizer-calibrate/SKILL.md +7 -1
- package/skills/mcp-builder/SKILL.md +1 -1
- package/skills/pdf/SKILL.md +1 -1
- package/skills/pptx/SKILL.md +1 -1
- package/skills/skill-creator/SKILL.md +21 -1
- 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/skills/switchroom-cli/SKILL.md +63 -64
- package/skills/switchroom-health/SKILL.md +23 -10
- package/skills/switchroom-install/SKILL.md +3 -3
- package/skills/switchroom-manage/SKILL.md +26 -19
- package/skills/switchroom-runtime/SKILL.md +191 -0
- package/skills/switchroom-status/SKILL.md +27 -2
- package/skills/telegram-test-harness/SKILL.md +3 -0
- package/skills/token-helpers/SKILL.md +24 -1
- package/skills/webapp-testing/SKILL.md +31 -1
- package/skills/xlsx/SKILL.md +1 -1
- package/telegram-plugin/admin-commands/index.ts +7 -5
- package/telegram-plugin/analytics-posthog.ts +191 -0
- package/telegram-plugin/bridge/bridge.ts +69 -0
- package/telegram-plugin/bridge/ipc-client.ts +4 -1
- package/telegram-plugin/dist/bridge/bridge.js +194 -119
- package/telegram-plugin/dist/gateway/gateway.js +23611 -19671
- package/telegram-plugin/dist/server.js +245 -189
- package/telegram-plugin/first-paint.ts +3 -24
- package/telegram-plugin/gateway/auth-add-flow.ts +326 -0
- package/telegram-plugin/gateway/auth-broker-client.ts +75 -0
- package/telegram-plugin/gateway/auth-command.ts +794 -0
- package/telegram-plugin/gateway/auth-line.ts +123 -0
- package/telegram-plugin/gateway/boot-card.ts +169 -40
- package/telegram-plugin/gateway/boot-issue-cache.ts +308 -0
- package/telegram-plugin/gateway/boot-probes.ts +166 -123
- package/telegram-plugin/gateway/boot-reason.ts +41 -7
- package/telegram-plugin/gateway/boot-version.ts +66 -0
- package/telegram-plugin/gateway/gateway.ts +3499 -1885
- package/telegram-plugin/gateway/hostd-dispatch.ts +117 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +18 -0
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +106 -0
- package/telegram-plugin/gateway/quarantine.ts +69 -0
- package/telegram-plugin/gateway/quota-cache.ts +9 -4
- package/telegram-plugin/gateway/reaction-trigger.ts +401 -0
- package/telegram-plugin/gateway/recent-denials.test.ts +103 -0
- package/telegram-plugin/gateway/recent-denials.ts +77 -0
- package/telegram-plugin/gateway/startup-network-retry.ts +109 -31
- package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +125 -0
- package/telegram-plugin/history.ts +91 -0
- package/telegram-plugin/hooks/hooks.json +10 -0
- package/telegram-plugin/hooks/sandbox-hint-posttool.mjs +130 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +19 -2
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +22 -2
- package/telegram-plugin/hooks/tool-label-pretool.mjs +11 -0
- package/telegram-plugin/hooks/wedge-detect-posttool.mjs +303 -0
- package/telegram-plugin/inbound-classifier.ts +50 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +136 -0
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/telegram-plugin/package.json +4 -2
- package/telegram-plugin/permission-rule.ts +51 -0
- package/telegram-plugin/permission-title.ts +56 -0
- package/telegram-plugin/quota-check.ts +19 -41
- package/telegram-plugin/registry/reaper.ts +223 -0
- package/telegram-plugin/retry-api-call.ts +80 -0
- package/telegram-plugin/runtime-metrics.ts +177 -0
- package/telegram-plugin/scripts/build.mjs +0 -1
- package/telegram-plugin/secret-detect/index.ts +24 -0
- package/telegram-plugin/secret-detect/vault-error.test.ts +64 -12
- package/telegram-plugin/secret-detect/vault-error.ts +78 -11
- package/telegram-plugin/secret-detect/vault-write.ts +14 -2
- package/telegram-plugin/server.js +41795 -0
- package/telegram-plugin/session-tail.ts +6 -1
- package/telegram-plugin/shared/bot-runtime.ts +5 -4
- package/telegram-plugin/silence-poke.ts +420 -0
- package/telegram-plugin/silent-end.ts +174 -0
- package/telegram-plugin/stream-controller.ts +13 -0
- package/telegram-plugin/stream-reply-handler.ts +7 -0
- package/telegram-plugin/subagent-watcher.ts +213 -4
- package/telegram-plugin/tests/auth-add-flow.test.ts +559 -0
- package/telegram-plugin/tests/auth-code-redact.test.ts +8 -4
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +531 -0
- package/telegram-plugin/tests/boot-card-issue-dedup.test.ts +247 -0
- package/telegram-plugin/tests/boot-card-reason-to-render.test.ts +182 -0
- package/telegram-plugin/tests/boot-card-reason.test.ts +65 -2
- package/telegram-plugin/tests/boot-card-render.test.ts +146 -0
- package/telegram-plugin/tests/boot-card-silent-on-operator.test.ts +103 -0
- package/telegram-plugin/tests/boot-probes.test.ts +216 -10
- package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
- package/telegram-plugin/tests/finalize-callback.test.ts +190 -0
- package/telegram-plugin/tests/gateway-message-validator.test.ts +26 -0
- package/telegram-plugin/tests/gateway-secret-detect.test.ts +12 -3
- package/telegram-plugin/tests/gateway-startup-network-retry.test.ts +104 -0
- package/telegram-plugin/tests/history-reaper.test.ts +378 -0
- package/telegram-plugin/tests/hostd-dispatch.test.ts +129 -0
- package/telegram-plugin/tests/inbound-classifier.test.ts +76 -0
- package/telegram-plugin/tests/inbound-message-types.test.ts +267 -0
- package/telegram-plugin/tests/issues-card.test.ts +49 -0
- package/telegram-plugin/tests/pending-inbound-buffer.test.ts +132 -0
- package/telegram-plugin/tests/permission-rule.test.ts +80 -1
- package/telegram-plugin/tests/permission-title.test.ts +31 -0
- package/telegram-plugin/tests/quota-check.test.ts +5 -35
- package/telegram-plugin/tests/races.test.ts +179 -0
- package/telegram-plugin/tests/reaction-trigger-flow.test.ts +353 -0
- package/telegram-plugin/tests/reaction-trigger.test.ts +397 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +152 -1
- package/telegram-plugin/tests/runtime-metrics.test.ts +145 -0
- package/telegram-plugin/tests/sandbox-hint-posttool.test.ts +155 -0
- package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +133 -0
- package/telegram-plugin/tests/secret-detect-false-positives.test.ts +137 -0
- package/telegram-plugin/tests/silence-poke.test.ts +493 -0
- package/telegram-plugin/tests/silent-end.test.ts +206 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +107 -0
- package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +224 -0
- package/telegram-plugin/tests/subagent-watcher-stall-terminal.test.ts +316 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +263 -0
- package/telegram-plugin/tests/turn-signal-tracker.test.ts +81 -0
- package/telegram-plugin/tests/vault-approval-posture.test.ts +256 -0
- package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +73 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +226 -0
- package/telegram-plugin/tests/vault-grant-union.test.ts +130 -0
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +140 -0
- package/telegram-plugin/tests/vault-posture-quarantine.test.ts +104 -0
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +114 -0
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +106 -0
- package/telegram-plugin/turn-signal-tracker.ts +100 -24
- package/telegram-plugin/uat/SETUP.md +210 -35
- package/telegram-plugin/uat/assertions.ts +264 -37
- package/telegram-plugin/uat/driver-info.ts +57 -0
- package/telegram-plugin/uat/driver.ts +590 -51
- package/telegram-plugin/uat/harness.ts +140 -94
- package/telegram-plugin/uat/load-env.test.ts +72 -0
- package/telegram-plugin/uat/load-env.ts +48 -0
- package/telegram-plugin/uat/login.ts +96 -53
- package/telegram-plugin/uat/runners/agent-self-sufficiency.ts +457 -0
- package/telegram-plugin/uat/runners/paraphrases.ts +231 -0
- package/telegram-plugin/uat/runners/report.ts +150 -0
- package/telegram-plugin/uat/runners/run-agent-self-sufficiency.sh +50 -0
- package/telegram-plugin/uat/runners/scorer.test.ts +196 -0
- package/telegram-plugin/uat/runners/scorer.ts +106 -0
- package/telegram-plugin/uat/runners/skill-coverage.test.ts +100 -0
- package/telegram-plugin/uat/runners/skill-coverage.ts +620 -0
- package/telegram-plugin/uat/scenarios/ask-user-button-tap-dm.test.ts +141 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +191 -0
- package/telegram-plugin/uat/scenarios/fuzz-extended-dm.test.ts +255 -0
- package/telegram-plugin/uat/scenarios/fuzz-human-style-dm.test.ts +275 -0
- package/telegram-plugin/uat/scenarios/fuzz-random-prompts-dm.test.ts +146 -0
- package/telegram-plugin/uat/scenarios/fuzz-status-ask-dm.test.ts +486 -0
- package/telegram-plugin/uat/scenarios/jtbd-interrupt-marker-dm.test.ts +67 -0
- package/telegram-plugin/uat/scenarios/jtbd-rapid-followup-dm.test.ts +100 -0
- package/telegram-plugin/uat/scenarios/jtbd-soft-commit-dm.test.ts +67 -0
- package/telegram-plugin/uat/scenarios/jtbd-status-query-dm.test.ts +49 -0
- package/telegram-plugin/uat/scenarios/location-inbound-dm.test.ts +65 -0
- package/telegram-plugin/uat/scenarios/midturn-silent-dm.test.ts +175 -0
- package/telegram-plugin/uat/scenarios/reactions-dm.test.ts +142 -0
- package/telegram-plugin/uat/scenarios/reactions-trigger-turn-dm.test.ts +96 -0
- package/telegram-plugin/uat/scenarios/secret-redaction-deletes-original-dm.test.ts +123 -0
- package/telegram-plugin/uat/scenarios/secret-redaction-no-false-positive-dm.test.ts +87 -0
- package/telegram-plugin/uat/scenarios/silence-poke-soft-dm.test.ts +155 -0
- package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +95 -0
- package/telegram-plugin/uat/scenarios/smoke-dm-reply.test.ts +57 -0
- package/telegram-plugin/uat/scenarios/subagent-watcher-no-rerun-dm.test.ts +135 -0
- package/telegram-plugin/uat/scenarios/vault-approval-posture-telegram-id-dm.test.ts +191 -0
- package/telegram-plugin/uat/scenarios/vault-audit-allow-dm.test.ts +108 -0
- package/telegram-plugin/uat/scenarios/vault-grant-auto-resume-dm.test.ts +121 -0
- package/telegram-plugin/uat/scenarios/vault-request-access-concurrent-dm.test.ts +161 -0
- package/telegram-plugin/uat/scenarios/vault-request-access-end-to-end-dm.test.ts +158 -0
- package/telegram-plugin/uat/scenarios/voice-inbound-dm.test.ts +65 -0
- package/telegram-plugin/vault-approval-posture.ts +42 -0
- package/telegram-plugin/welcome-text.ts +1 -0
- package/telegram-plugin/active-pins-sweep.ts +0 -204
- package/telegram-plugin/active-pins.ts +0 -146
- package/telegram-plugin/auth-dashboard.ts +0 -1104
- package/telegram-plugin/auth-slot-parser.ts +0 -497
- package/telegram-plugin/card-event-log.ts +0 -138
- package/telegram-plugin/dist/foreman/foreman.js +0 -31106
- package/telegram-plugin/docs/multi-agent-card-design.md +0 -847
- package/telegram-plugin/docs/pinned-progress-card-reliability.md +0 -144
- package/telegram-plugin/foreman/foreman-create-flow.ts +0 -202
- package/telegram-plugin/foreman/foreman-handlers.ts +0 -493
- package/telegram-plugin/foreman/foreman.ts +0 -1165
- package/telegram-plugin/foreman/setup-flow.ts +0 -345
- package/telegram-plugin/foreman/setup-state.ts +0 -239
- package/telegram-plugin/foreman/state.ts +0 -203
- package/telegram-plugin/pin-event-log.ts +0 -76
- package/telegram-plugin/progress-card-driver.ts +0 -2886
- package/telegram-plugin/progress-card-pin-manager.ts +0 -589
- package/telegram-plugin/progress-card-pin-watchdog.ts +0 -98
- package/telegram-plugin/progress-card.ts +0 -1409
- package/telegram-plugin/tests/HARNESS.md +0 -340
- package/telegram-plugin/tests/_progress-card-harness.ts +0 -109
- package/telegram-plugin/tests/active-pins-boot-reaper.test.ts +0 -211
- package/telegram-plugin/tests/active-pins-sweep.test.ts +0 -309
- package/telegram-plugin/tests/active-pins.test.ts +0 -187
- package/telegram-plugin/tests/auth-account-identity-surface.test.ts +0 -118
- package/telegram-plugin/tests/auth-dashboard-edge-cases.test.ts +0 -260
- package/telegram-plugin/tests/auth-dashboard-restart-flow.test.ts +0 -140
- package/telegram-plugin/tests/auth-dashboard-v3b.test.ts +0 -559
- package/telegram-plugin/tests/auth-dashboard.test.ts +0 -1045
- package/telegram-plugin/tests/auth-slot-commands.test.ts +0 -640
- package/telegram-plugin/tests/bg-agent-progress-card-757.test.ts +0 -201
- package/telegram-plugin/tests/boot-card-account-quota.test.ts +0 -137
- package/telegram-plugin/tests/card-event-log.test.ts +0 -145
- package/telegram-plugin/tests/first-paint.test.ts +0 -257
- package/telegram-plugin/tests/foreman-create-flow.test.ts +0 -359
- package/telegram-plugin/tests/foreman-handlers.test.ts +0 -347
- package/telegram-plugin/tests/foreman-state.test.ts +0 -164
- package/telegram-plugin/tests/foreman-write-ops.test.ts +0 -214
- package/telegram-plugin/tests/harness-ordering-invariants.test.ts +0 -243
- package/telegram-plugin/tests/pin-event-log.test.ts +0 -124
- package/telegram-plugin/tests/progress-card-api-failure-during-deferred.test.ts +0 -73
- package/telegram-plugin/tests/progress-card-close-paths-converge.test.ts +0 -272
- package/telegram-plugin/tests/progress-card-cross-turn.test.ts +0 -258
- package/telegram-plugin/tests/progress-card-delay-842.test.ts +0 -160
- package/telegram-plugin/tests/progress-card-dispose-preservepending.test.ts +0 -81
- package/telegram-plugin/tests/progress-card-draft-flag.test.ts +0 -80
- package/telegram-plugin/tests/progress-card-driver-eviction.test.ts +0 -215
- package/telegram-plugin/tests/progress-card-driver-fleet-shadow.test.ts +0 -123
- package/telegram-plugin/tests/progress-card-driver-force-complete-parent-done.test.ts +0 -76
- package/telegram-plugin/tests/progress-card-edit-timestamps-budget.test.ts +0 -62
- package/telegram-plugin/tests/progress-card-memory-bounds.test.ts +0 -84
- package/telegram-plugin/tests/progress-card-pin-failure-paths.test.ts +0 -139
- package/telegram-plugin/tests/progress-card-pin-manager.test.ts +0 -773
- package/telegram-plugin/tests/progress-card-pin-race-fast-turn.test.ts +0 -66
- package/telegram-plugin/tests/progress-card-pin-sidecar-partial-write.test.ts +0 -64
- package/telegram-plugin/tests/progress-card-pin-watchdog.test.ts +0 -190
- package/telegram-plugin/tests/progress-card-sigterm-pin-flush.test.ts +0 -146
- package/telegram-plugin/tests/real-gateway-f1-ladder-integrity.test.ts +0 -123
- package/telegram-plugin/tests/real-gateway-f2-instant-draft.test.ts +0 -82
- package/telegram-plugin/tests/real-gateway-f3-late-card.test.ts +0 -114
- package/telegram-plugin/tests/real-gateway-harness.ts +0 -699
- package/telegram-plugin/tests/real-gateway-i6-turn-flush-replay-dedup.test.ts +0 -313
- package/telegram-plugin/tests/real-gateway-ipc-lifecycle.test.ts +0 -299
- package/telegram-plugin/tests/real-gateway-spec.test.ts +0 -487
- package/telegram-plugin/tests/real-gateway.smoke.test.ts +0 -101
- package/telegram-plugin/tests/setup-flow.test.ts +0 -510
- package/telegram-plugin/tests/setup-state.test.ts +0 -146
- package/telegram-plugin/tests/sync-chat-running-subagents.test.ts +0 -116
- package/telegram-plugin/tests/turn-end-regressions.test.ts +0 -489
- package/telegram-plugin/tests/turn-flush-card-takeover.test.ts +0 -218
- package/telegram-plugin/tests/turn-flush-prose-recovery.test.ts +0 -78
- package/telegram-plugin/tests/two-zone-bg-carry-full-lifecycle.test.ts +0 -131
- package/telegram-plugin/tests/two-zone-bg-detection.test.ts +0 -120
- package/telegram-plugin/tests/two-zone-bg-done-when-all-terminal.test.ts +0 -116
- package/telegram-plugin/tests/two-zone-bg-early-turn-end.test.ts +0 -87
- package/telegram-plugin/tests/two-zone-bg-survives-next-turn.test.ts +0 -211
- package/telegram-plugin/tests/two-zone-card-cap.test.ts +0 -62
- package/telegram-plugin/tests/two-zone-card-fleet-row.test.ts +0 -101
- package/telegram-plugin/tests/two-zone-card-header-phases.test.ts +0 -78
- package/telegram-plugin/tests/two-zone-card-html-balance.test.ts +0 -110
- package/telegram-plugin/tests/two-zone-card-lifecycle.test.ts +0 -128
- package/telegram-plugin/tests/two-zone-card-sanitise.test.ts +0 -58
- package/telegram-plugin/tests/two-zone-card-snapshot.test.ts +0 -133
- package/telegram-plugin/tests/two-zone-concurrent-turns-isolation.test.ts +0 -155
- package/telegram-plugin/tests/two-zone-phasefor-precedence.test.ts +0 -117
- package/telegram-plugin/tests/two-zone-snapshot-extras.test.ts +0 -187
- package/telegram-plugin/tests/two-zone-stuck-edit-throttle.test.ts +0 -149
- package/telegram-plugin/tests/two-zone-stuck-header-escalation.test.ts +0 -101
- package/telegram-plugin/tests/two-zone-stuck-per-member.test.ts +0 -114
- package/telegram-plugin/tests/two-zone-stuck-recovery.test.ts +0 -105
- package/telegram-plugin/tests/waiting-ux-harness.ts +0 -381
- package/telegram-plugin/tests/waiting-ux.e2e.test.ts +0 -233
- package/telegram-plugin/turn-flush-prose-recovery.ts +0 -40
- package/telegram-plugin/two-zone-card.ts +0 -269
- package/telegram-plugin/uat/scenarios/smoke-clerk-reply.test.ts +0 -61
|
@@ -18,6 +18,7 @@ import { describe, it, expect, vi } from 'vitest'
|
|
|
18
18
|
import {
|
|
19
19
|
isBootNetworkError,
|
|
20
20
|
gatewayStartupRetry,
|
|
21
|
+
classifyStartupError,
|
|
21
22
|
STARTUP_RETRY_DELAYS_MS,
|
|
22
23
|
} from '../gateway/startup-network-retry'
|
|
23
24
|
|
|
@@ -76,6 +77,57 @@ describe('isBootNetworkError', () => {
|
|
|
76
77
|
})
|
|
77
78
|
})
|
|
78
79
|
|
|
80
|
+
// ── classifyStartupError ──────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
describe('classifyStartupError', () => {
|
|
83
|
+
it('classifies grammy 401 (GrammyError, error_code=401) as unauthorized', () => {
|
|
84
|
+
const err = Object.assign(new Error('Unauthorized'), {
|
|
85
|
+
name: 'GrammyError',
|
|
86
|
+
error_code: 401,
|
|
87
|
+
})
|
|
88
|
+
expect(classifyStartupError(err)).toBe('unauthorized')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('classifies an Unauthorized-message error as unauthorized — defence in depth', () => {
|
|
92
|
+
expect(classifyStartupError(new Error('Unauthorized'))).toBe('unauthorized')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('does NOT mis-classify a network error mentioning "401" port as unauthorized', () => {
|
|
96
|
+
// Hypothetical message that happens to contain "401" but isn't a
|
|
97
|
+
// 401 status. classifyStartupError matches on the literal token
|
|
98
|
+
// "Unauthorized" rather than the substring "401" to avoid this.
|
|
99
|
+
expect(classifyStartupError(new Error('connect ECONNREFUSED 10.0.0.1:401'))).toBe('network')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('classifies HttpError as network', () => {
|
|
103
|
+
const err = Object.assign(new Error('Network request failed'), {
|
|
104
|
+
name: 'HttpError',
|
|
105
|
+
})
|
|
106
|
+
expect(classifyStartupError(err)).toBe('network')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('classifies ETIMEDOUT as network', () => {
|
|
110
|
+
expect(classifyStartupError(new Error('connect ETIMEDOUT 1.2.3.4:443'))).toBe('network')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('classifies a bare app error as other', () => {
|
|
114
|
+
expect(classifyStartupError(new Error('something else'))).toBe('other')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('classifies non-Error values as other', () => {
|
|
118
|
+
expect(classifyStartupError('string')).toBe('other')
|
|
119
|
+
expect(classifyStartupError(null)).toBe('other')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('classifies a GrammyError 403 (kicked) as other — surfaces as a fatal rethrow', () => {
|
|
123
|
+
const err = Object.assign(new Error('Forbidden: bot was kicked'), {
|
|
124
|
+
name: 'GrammyError',
|
|
125
|
+
error_code: 403,
|
|
126
|
+
})
|
|
127
|
+
expect(classifyStartupError(err)).toBe('other')
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
79
131
|
// ── gatewayStartupRetry ───────────────────────────────────────────────────────
|
|
80
132
|
|
|
81
133
|
describe('gatewayStartupRetry', () => {
|
|
@@ -162,6 +214,58 @@ describe('gatewayStartupRetry', () => {
|
|
|
162
214
|
expect(STARTUP_RETRY_DELAYS_MS[STARTUP_RETRY_DELAYS_MS.length - 1]).toBe(64_000)
|
|
163
215
|
})
|
|
164
216
|
|
|
217
|
+
it('calls onUnauthorized (not onExhausted, not rethrow) on a 401 — #1076', async () => {
|
|
218
|
+
// Grammy surfaces 401 via GrammyError with error_code=401.
|
|
219
|
+
const authErr = Object.assign(new Error('Unauthorized'), {
|
|
220
|
+
name: 'GrammyError',
|
|
221
|
+
error_code: 401,
|
|
222
|
+
})
|
|
223
|
+
const fn = vi.fn().mockRejectedValue(authErr)
|
|
224
|
+
const onUnauthorized = vi.fn(() => {
|
|
225
|
+
throw new Error('__quarantined__')
|
|
226
|
+
}) as unknown as (err: unknown) => never
|
|
227
|
+
const onExhausted = vi.fn(() => {
|
|
228
|
+
throw new Error('__exhausted__')
|
|
229
|
+
}) as unknown as (err: unknown) => never
|
|
230
|
+
const sleep = vi.fn().mockResolvedValue(undefined)
|
|
231
|
+
|
|
232
|
+
await expect(
|
|
233
|
+
gatewayStartupRetry(fn, {
|
|
234
|
+
delaysMs: [100, 200, 400],
|
|
235
|
+
sleep,
|
|
236
|
+
onUnauthorized,
|
|
237
|
+
onExhausted,
|
|
238
|
+
log: noopLog,
|
|
239
|
+
}),
|
|
240
|
+
).rejects.toThrow('__quarantined__')
|
|
241
|
+
|
|
242
|
+
// 401 short-circuits — only one fn() call, no retries, no exhaustion path.
|
|
243
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
244
|
+
expect(sleep).not.toHaveBeenCalled()
|
|
245
|
+
expect(onExhausted).not.toHaveBeenCalled()
|
|
246
|
+
expect(onUnauthorized).toHaveBeenCalledTimes(1)
|
|
247
|
+
expect(onUnauthorized).toHaveBeenCalledWith(authErr)
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('classifies a 401-message-only error (no error_code) as unauthorized — defence in depth', async () => {
|
|
251
|
+
// Some fetch wrappers / test fixtures surface 401 only in the message.
|
|
252
|
+
const authErr = new Error('Unauthorized')
|
|
253
|
+
const fn = vi.fn().mockRejectedValue(authErr)
|
|
254
|
+
const onUnauthorized = vi.fn(() => {
|
|
255
|
+
throw new Error('__quarantined__')
|
|
256
|
+
}) as unknown as (err: unknown) => never
|
|
257
|
+
|
|
258
|
+
await expect(
|
|
259
|
+
gatewayStartupRetry(fn, {
|
|
260
|
+
delaysMs: [1, 2],
|
|
261
|
+
sleep: vi.fn(),
|
|
262
|
+
onUnauthorized,
|
|
263
|
+
log: noopLog,
|
|
264
|
+
}),
|
|
265
|
+
).rejects.toThrow('__quarantined__')
|
|
266
|
+
expect(onUnauthorized).toHaveBeenCalledTimes(1)
|
|
267
|
+
})
|
|
268
|
+
|
|
165
269
|
it('logs retry progress before each sleep', async () => {
|
|
166
270
|
const networkErr = Object.assign(new Error('Network request failed'), {
|
|
167
271
|
name: 'HttpError',
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the history reaper (#1073).
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* - `pruneSubagentsOlderThan` / `pruneTurnsOlderThan` correctness:
|
|
6
|
+
* rows below the cutoff are deleted, rows above are preserved,
|
|
7
|
+
* coalesce semantics (ended_at | last_activity_at | started_at)
|
|
8
|
+
* are honored.
|
|
9
|
+
* - Batch-loop bounded scan: 6000-row backlog drains across multiple
|
|
10
|
+
* batches when batchLimit=2000.
|
|
11
|
+
* - WAL checkpoint runs (file-backed DB; assert .db-wal exists and
|
|
12
|
+
* shrinks, or at least that checkpoint reports success).
|
|
13
|
+
* - `resolveRetentionDays`: env > access > default precedence, plus
|
|
14
|
+
* guards against invalid env values.
|
|
15
|
+
* - `pruneMessagesOlderThanDays` on the history DB respects the
|
|
16
|
+
* batch cap and preserves recent rows.
|
|
17
|
+
*
|
|
18
|
+
* Runs under bun (uses bun:sqlite via the schema modules' lazy loader).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
|
|
22
|
+
import { mkdtempSync, rmSync, existsSync, statSync } from 'fs'
|
|
23
|
+
import { tmpdir } from 'os'
|
|
24
|
+
import { join } from 'path'
|
|
25
|
+
import {
|
|
26
|
+
pruneSubagentsOlderThan,
|
|
27
|
+
pruneTurnsOlderThan,
|
|
28
|
+
runRegistryReaper,
|
|
29
|
+
resolveRetentionDays,
|
|
30
|
+
DEFAULT_RETENTION_DAYS,
|
|
31
|
+
} from '../registry/reaper.js'
|
|
32
|
+
import {
|
|
33
|
+
openSubagentsDbInMemory,
|
|
34
|
+
recordSubagentStart,
|
|
35
|
+
} from '../registry/subagents-schema.js'
|
|
36
|
+
import {
|
|
37
|
+
initHistory,
|
|
38
|
+
recordInbound,
|
|
39
|
+
pruneMessagesOlderThanDays,
|
|
40
|
+
query as queryHistory,
|
|
41
|
+
_resetForTests as resetHistory,
|
|
42
|
+
} from '../history.js'
|
|
43
|
+
|
|
44
|
+
// `bun:sqlite` for direct file-backed DB tests (WAL inspection).
|
|
45
|
+
// Same lazy-load pattern as the schemas use, but inline here.
|
|
46
|
+
type SqliteDatabase = {
|
|
47
|
+
exec(sql: string): void
|
|
48
|
+
prepare(sql: string): {
|
|
49
|
+
run(...params: unknown[]): unknown
|
|
50
|
+
all(...params: unknown[]): unknown[]
|
|
51
|
+
get(...params: unknown[]): unknown
|
|
52
|
+
}
|
|
53
|
+
close(): void
|
|
54
|
+
}
|
|
55
|
+
type SqliteDatabaseCtor = new (path: string, opts?: { create?: boolean }) => SqliteDatabase
|
|
56
|
+
function bunSqlite(): SqliteDatabaseCtor {
|
|
57
|
+
const metaRequire = (import.meta as { require?: (id: string) => unknown }).require
|
|
58
|
+
if (typeof metaRequire !== 'function') throw new Error('bun runtime required')
|
|
59
|
+
const mod = metaRequire('bun:sqlite') as { Database: SqliteDatabaseCtor }
|
|
60
|
+
return mod.Database
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const DAY_MS = 86_400_000
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// pruneSubagentsOlderThan
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
describe('pruneSubagentsOlderThan', () => {
|
|
70
|
+
it('deletes rows older than the cutoff and preserves recent ones', () => {
|
|
71
|
+
const db = openSubagentsDbInMemory()
|
|
72
|
+
const now = 1_000_000_000_000
|
|
73
|
+
// Three rows: 30 days ago, 5 days ago, 1 day ago.
|
|
74
|
+
recordSubagentStart(db, { id: 'old', background: true, startedAt: now - 30 * DAY_MS })
|
|
75
|
+
recordSubagentStart(db, { id: 'mid', background: true, startedAt: now - 5 * DAY_MS })
|
|
76
|
+
recordSubagentStart(db, { id: 'new', background: true, startedAt: now - 1 * DAY_MS })
|
|
77
|
+
|
|
78
|
+
// Cutoff: 14 days ago. Only `old` should go.
|
|
79
|
+
const cutoff = now - 14 * DAY_MS
|
|
80
|
+
const result = pruneSubagentsOlderThan(db, cutoff)
|
|
81
|
+
expect(result.deleted).toBe(1)
|
|
82
|
+
|
|
83
|
+
const remaining = db.prepare('SELECT id FROM subagents ORDER BY started_at').all() as Array<{ id: string }>
|
|
84
|
+
expect(remaining.map((r) => r.id)).toEqual(['mid', 'new'])
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('uses COALESCE(ended_at, last_activity_at, started_at)', () => {
|
|
88
|
+
const db = openSubagentsDbInMemory()
|
|
89
|
+
const now = 1_000_000_000_000
|
|
90
|
+
// started_at is ancient but last_activity_at is recent — should NOT prune.
|
|
91
|
+
recordSubagentStart(db, { id: 'still-active', background: true, startedAt: now - 60 * DAY_MS })
|
|
92
|
+
db.prepare('UPDATE subagents SET last_activity_at = ? WHERE id = ?').run(now - 2 * DAY_MS, 'still-active')
|
|
93
|
+
|
|
94
|
+
// started_at + last_activity_at ancient but ended_at recent — should NOT prune.
|
|
95
|
+
recordSubagentStart(db, { id: 'recent-end', background: true, startedAt: now - 60 * DAY_MS })
|
|
96
|
+
db.prepare('UPDATE subagents SET last_activity_at = ?, ended_at = ?, status = ? WHERE id = ?')
|
|
97
|
+
.run(now - 50 * DAY_MS, now - 2 * DAY_MS, 'completed', 'recent-end')
|
|
98
|
+
|
|
99
|
+
// Truly old — all three timestamps are ancient.
|
|
100
|
+
recordSubagentStart(db, { id: 'truly-old', background: true, startedAt: now - 60 * DAY_MS })
|
|
101
|
+
db.prepare('UPDATE subagents SET last_activity_at = ?, ended_at = ?, status = ? WHERE id = ?')
|
|
102
|
+
.run(now - 59 * DAY_MS, now - 58 * DAY_MS, 'completed', 'truly-old')
|
|
103
|
+
|
|
104
|
+
const cutoff = now - 14 * DAY_MS
|
|
105
|
+
const result = pruneSubagentsOlderThan(db, cutoff)
|
|
106
|
+
expect(result.deleted).toBe(1)
|
|
107
|
+
|
|
108
|
+
const ids = (db.prepare('SELECT id FROM subagents ORDER BY id').all() as Array<{ id: string }>).map((r) => r.id)
|
|
109
|
+
expect(ids).toEqual(['recent-end', 'still-active'])
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('batches a large backlog and drains it across iterations', () => {
|
|
113
|
+
const db = openSubagentsDbInMemory()
|
|
114
|
+
const now = 1_000_000_000_000
|
|
115
|
+
const insert = db.prepare(`
|
|
116
|
+
INSERT INTO subagents
|
|
117
|
+
(id, background, started_at, last_activity_at, status)
|
|
118
|
+
VALUES (?, 1, ?, ?, 'running')
|
|
119
|
+
`)
|
|
120
|
+
const tx = (db as unknown as { transaction: (fn: (n: number) => void) => (n: number) => void })
|
|
121
|
+
.transaction((n: number) => {
|
|
122
|
+
for (let i = 0; i < n; i++) {
|
|
123
|
+
insert.run(`old-${i}`, now - 30 * DAY_MS, now - 30 * DAY_MS)
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
tx(6000)
|
|
127
|
+
// Plus 50 recent rows.
|
|
128
|
+
for (let i = 0; i < 50; i++) {
|
|
129
|
+
insert.run(`new-${i}`, now - 1 * DAY_MS, now - 1 * DAY_MS)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const cutoff = now - 14 * DAY_MS
|
|
133
|
+
const result = pruneSubagentsOlderThan(db, cutoff, 2000)
|
|
134
|
+
expect(result.deleted).toBe(6000)
|
|
135
|
+
// 6000 / 2000 = 3 full batches + a final 0-row sentinel batch.
|
|
136
|
+
expect(result.batches).toBeGreaterThanOrEqual(3)
|
|
137
|
+
|
|
138
|
+
const remaining = db.prepare('SELECT COUNT(*) as c FROM subagents').get() as { c: number }
|
|
139
|
+
expect(remaining.c).toBe(50)
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// pruneTurnsOlderThan
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
describe('pruneTurnsOlderThan', () => {
|
|
148
|
+
it('deletes turns older than the cutoff, preserves recent and open turns', () => {
|
|
149
|
+
const db = openSubagentsDbInMemory()
|
|
150
|
+
const now = 1_000_000_000_000
|
|
151
|
+
|
|
152
|
+
const insert = db.prepare(`
|
|
153
|
+
INSERT INTO turns
|
|
154
|
+
(turn_key, chat_id, started_at, ended_at, created_at, updated_at)
|
|
155
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
156
|
+
`)
|
|
157
|
+
// ancient + ended → prune
|
|
158
|
+
insert.run('old-ended', '-100', now - 60 * DAY_MS, now - 59 * DAY_MS, now - 60 * DAY_MS, now - 59 * DAY_MS)
|
|
159
|
+
// ancient + open → COALESCE falls through to started_at, also prunes
|
|
160
|
+
insert.run('old-open', '-100', now - 60 * DAY_MS, null, now - 60 * DAY_MS, now - 60 * DAY_MS)
|
|
161
|
+
// recent + ended → preserve
|
|
162
|
+
insert.run('recent', '-100', now - 1 * DAY_MS, now - 1 * DAY_MS, now - 1 * DAY_MS, now - 1 * DAY_MS)
|
|
163
|
+
// ancient started but ended recently → COALESCE picks ended_at, preserves
|
|
164
|
+
insert.run('long-running', '-100', now - 60 * DAY_MS, now - 1 * DAY_MS, now - 60 * DAY_MS, now - 1 * DAY_MS)
|
|
165
|
+
|
|
166
|
+
const cutoff = now - 14 * DAY_MS
|
|
167
|
+
const result = pruneTurnsOlderThan(db, cutoff)
|
|
168
|
+
expect(result.deleted).toBe(2)
|
|
169
|
+
|
|
170
|
+
const keys = (db.prepare('SELECT turn_key FROM turns ORDER BY turn_key').all() as Array<{ turn_key: string }>).map((r) => r.turn_key)
|
|
171
|
+
expect(keys).toEqual(['long-running', 'recent'])
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// runRegistryReaper + WAL checkpoint
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
describe('runRegistryReaper', () => {
|
|
180
|
+
let tmpDir: string
|
|
181
|
+
|
|
182
|
+
beforeEach(() => {
|
|
183
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'reaper-test-'))
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
afterEach(() => {
|
|
187
|
+
if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true })
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('prunes both tables and reports a successful WAL checkpoint', () => {
|
|
191
|
+
const Database = bunSqlite()
|
|
192
|
+
const dbPath = join(tmpDir, 'registry.db')
|
|
193
|
+
const db = new Database(dbPath, { create: true })
|
|
194
|
+
db.exec('PRAGMA journal_mode = WAL')
|
|
195
|
+
// Apply both schemas inline (mirrors openTurnsDb + applySubagentsSchema).
|
|
196
|
+
db.exec(`
|
|
197
|
+
CREATE TABLE turns (
|
|
198
|
+
turn_key TEXT PRIMARY KEY,
|
|
199
|
+
chat_id TEXT NOT NULL,
|
|
200
|
+
thread_id TEXT,
|
|
201
|
+
started_at INTEGER NOT NULL,
|
|
202
|
+
ended_at INTEGER,
|
|
203
|
+
ended_via TEXT,
|
|
204
|
+
last_assistant_msg_id TEXT,
|
|
205
|
+
last_assistant_done INTEGER,
|
|
206
|
+
last_user_msg_id TEXT,
|
|
207
|
+
user_prompt_preview TEXT,
|
|
208
|
+
assistant_reply_preview TEXT,
|
|
209
|
+
tool_call_count INTEGER,
|
|
210
|
+
created_at INTEGER NOT NULL,
|
|
211
|
+
updated_at INTEGER NOT NULL
|
|
212
|
+
);
|
|
213
|
+
CREATE TABLE subagents (
|
|
214
|
+
id TEXT PRIMARY KEY,
|
|
215
|
+
parent_session_id TEXT,
|
|
216
|
+
parent_turn_key TEXT,
|
|
217
|
+
agent_type TEXT,
|
|
218
|
+
description TEXT,
|
|
219
|
+
background INTEGER NOT NULL,
|
|
220
|
+
started_at INTEGER NOT NULL,
|
|
221
|
+
last_activity_at INTEGER,
|
|
222
|
+
ended_at INTEGER,
|
|
223
|
+
status TEXT NOT NULL,
|
|
224
|
+
result_summary TEXT,
|
|
225
|
+
jsonl_agent_id TEXT
|
|
226
|
+
);
|
|
227
|
+
`)
|
|
228
|
+
|
|
229
|
+
const now = Date.now()
|
|
230
|
+
db.prepare(`
|
|
231
|
+
INSERT INTO subagents (id, background, started_at, last_activity_at, status)
|
|
232
|
+
VALUES ('old', 1, ?, ?, 'running')
|
|
233
|
+
`).run(now - 30 * DAY_MS, now - 30 * DAY_MS)
|
|
234
|
+
db.prepare(`
|
|
235
|
+
INSERT INTO turns (turn_key, chat_id, started_at, ended_at, created_at, updated_at)
|
|
236
|
+
VALUES ('old-turn', '-1', ?, ?, ?, ?)
|
|
237
|
+
`).run(now - 30 * DAY_MS, now - 30 * DAY_MS, now - 30 * DAY_MS, now - 30 * DAY_MS)
|
|
238
|
+
|
|
239
|
+
// Force some WAL activity before the checkpoint so there's something to flush.
|
|
240
|
+
db.prepare(`INSERT INTO turns (turn_key, chat_id, started_at, created_at, updated_at) VALUES ('recent', '-1', ?, ?, ?)`)
|
|
241
|
+
.run(now, now, now)
|
|
242
|
+
|
|
243
|
+
const walPath = `${dbPath}-wal`
|
|
244
|
+
expect(existsSync(walPath)).toBe(true)
|
|
245
|
+
const walSizeBefore = statSync(walPath).size
|
|
246
|
+
expect(walSizeBefore).toBeGreaterThan(0)
|
|
247
|
+
|
|
248
|
+
const result = runRegistryReaper(db, { retentionDays: 14, now })
|
|
249
|
+
expect(result.subagents.deleted).toBe(1)
|
|
250
|
+
expect(result.turns.deleted).toBe(1)
|
|
251
|
+
expect(result.walCheckpointed).toBe(true)
|
|
252
|
+
|
|
253
|
+
// After TRUNCATE checkpoint, the WAL file is truncated to zero bytes.
|
|
254
|
+
// (Strict equality may vary across SQLite builds, but the post-truncate
|
|
255
|
+
// size must be strictly less than the pre-truncate size.)
|
|
256
|
+
const walSizeAfter = statSync(walPath).size
|
|
257
|
+
expect(walSizeAfter).toBeLessThan(walSizeBefore)
|
|
258
|
+
|
|
259
|
+
db.close()
|
|
260
|
+
})
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
// resolveRetentionDays
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
describe('resolveRetentionDays', () => {
|
|
268
|
+
const savedEnv = process.env.HISTORY_RETENTION_DAYS
|
|
269
|
+
|
|
270
|
+
afterEach(() => {
|
|
271
|
+
if (savedEnv === undefined) delete process.env.HISTORY_RETENTION_DAYS
|
|
272
|
+
else process.env.HISTORY_RETENTION_DAYS = savedEnv
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('returns DEFAULT_RETENTION_DAYS when nothing is set', () => {
|
|
276
|
+
delete process.env.HISTORY_RETENTION_DAYS
|
|
277
|
+
expect(resolveRetentionDays()).toBe(DEFAULT_RETENTION_DAYS)
|
|
278
|
+
expect(DEFAULT_RETENTION_DAYS).toBe(14)
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it('prefers env over access', () => {
|
|
282
|
+
process.env.HISTORY_RETENTION_DAYS = '1'
|
|
283
|
+
expect(resolveRetentionDays(30)).toBe(1)
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('falls back to access when env is missing', () => {
|
|
287
|
+
delete process.env.HISTORY_RETENTION_DAYS
|
|
288
|
+
expect(resolveRetentionDays(7)).toBe(7)
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
it('rejects invalid env values', () => {
|
|
292
|
+
process.env.HISTORY_RETENTION_DAYS = 'abc'
|
|
293
|
+
expect(resolveRetentionDays(7)).toBe(7)
|
|
294
|
+
process.env.HISTORY_RETENTION_DAYS = '0'
|
|
295
|
+
expect(resolveRetentionDays(7)).toBe(7)
|
|
296
|
+
process.env.HISTORY_RETENTION_DAYS = '-5'
|
|
297
|
+
expect(resolveRetentionDays(7)).toBe(7)
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('rejects invalid access values', () => {
|
|
301
|
+
delete process.env.HISTORY_RETENTION_DAYS
|
|
302
|
+
expect(resolveRetentionDays(0)).toBe(DEFAULT_RETENTION_DAYS)
|
|
303
|
+
expect(resolveRetentionDays(-1)).toBe(DEFAULT_RETENTION_DAYS)
|
|
304
|
+
expect(resolveRetentionDays(NaN)).toBe(DEFAULT_RETENTION_DAYS)
|
|
305
|
+
})
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
// history.ts: pruneMessagesOlderThanDays
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
describe('pruneMessagesOlderThanDays', () => {
|
|
313
|
+
let stateDir: string
|
|
314
|
+
|
|
315
|
+
beforeEach(() => {
|
|
316
|
+
stateDir = mkdtempSync(join(tmpdir(), 'history-reaper-test-'))
|
|
317
|
+
initHistory(stateDir, 0) // 0 disables the init-time prune so we can seed cleanly
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
afterEach(() => {
|
|
321
|
+
resetHistory()
|
|
322
|
+
if (existsSync(stateDir)) rmSync(stateDir, { recursive: true, force: true })
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
it('deletes only messages older than retentionDays', () => {
|
|
326
|
+
const nowSec = 2_000_000_000
|
|
327
|
+
// Old (60d ago), borderline (15d ago), recent (1d ago).
|
|
328
|
+
recordInbound({
|
|
329
|
+
chat_id: '-100', thread_id: null, message_id: 1, user: 'u', user_id: '1',
|
|
330
|
+
ts: nowSec - 60 * 86400, text: 'old',
|
|
331
|
+
})
|
|
332
|
+
recordInbound({
|
|
333
|
+
chat_id: '-100', thread_id: null, message_id: 2, user: 'u', user_id: '1',
|
|
334
|
+
ts: nowSec - 15 * 86400, text: 'mid',
|
|
335
|
+
})
|
|
336
|
+
recordInbound({
|
|
337
|
+
chat_id: '-100', thread_id: null, message_id: 3, user: 'u', user_id: '1',
|
|
338
|
+
ts: nowSec - 1 * 86400, text: 'new',
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
const deleted = pruneMessagesOlderThanDays(14, nowSec)
|
|
342
|
+
expect(deleted).toBe(2)
|
|
343
|
+
|
|
344
|
+
const remaining = queryHistory({ chat_id: '-100' })
|
|
345
|
+
expect(remaining.map((r) => r.text)).toEqual(['new'])
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
it('batches a >5k backlog and drains it', () => {
|
|
349
|
+
const nowSec = 2_000_000_000
|
|
350
|
+
for (let i = 0; i < 6000; i++) {
|
|
351
|
+
recordInbound({
|
|
352
|
+
chat_id: '-100', thread_id: null, message_id: i + 1, user: 'u', user_id: '1',
|
|
353
|
+
ts: nowSec - 60 * 86400, text: `old-${i}`,
|
|
354
|
+
})
|
|
355
|
+
}
|
|
356
|
+
for (let i = 0; i < 30; i++) {
|
|
357
|
+
recordInbound({
|
|
358
|
+
chat_id: '-100', thread_id: null, message_id: 100_000 + i, user: 'u', user_id: '1',
|
|
359
|
+
ts: nowSec - 1 * 86400, text: `new-${i}`,
|
|
360
|
+
})
|
|
361
|
+
}
|
|
362
|
+
const deleted = pruneMessagesOlderThanDays(14, nowSec, 2000)
|
|
363
|
+
expect(deleted).toBe(6000)
|
|
364
|
+
const remaining = queryHistory({ chat_id: '-100', limit: 50 })
|
|
365
|
+
expect(remaining.length).toBe(30)
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
it('respects retentionDays <= 0 as disabled', () => {
|
|
369
|
+
const nowSec = 2_000_000_000
|
|
370
|
+
recordInbound({
|
|
371
|
+
chat_id: '-100', thread_id: null, message_id: 1, user: 'u', user_id: '1',
|
|
372
|
+
ts: nowSec - 60 * 86400, text: 'old',
|
|
373
|
+
})
|
|
374
|
+
expect(pruneMessagesOlderThanDays(0, nowSec)).toBe(0)
|
|
375
|
+
expect(pruneMessagesOlderThanDays(-1, nowSec)).toBe(0)
|
|
376
|
+
expect(queryHistory({ chat_id: '-100' })).toHaveLength(1)
|
|
377
|
+
})
|
|
378
|
+
})
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `hostd-dispatch.ts` — the gateway's helper that routes
|
|
3
|
+
* self-restart slash-commands through the hostd UDS when enabled.
|
|
4
|
+
*
|
|
5
|
+
* The config-loading branches are validated by mocking
|
|
6
|
+
* `loadSwitchroomConfig` (the schema's complexity isn't this test's
|
|
7
|
+
* concern — we just need to feed it a known value). The wire-error
|
|
8
|
+
* branch is validated by pointing the helper at a nonexistent socket.
|
|
9
|
+
*
|
|
10
|
+
* The "actually hits a real hostd" path is covered in
|
|
11
|
+
* `tests/host-control/server.test.ts` end-to-end — we don't re-test
|
|
12
|
+
* the server here.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
describe,
|
|
17
|
+
it,
|
|
18
|
+
expect,
|
|
19
|
+
beforeEach,
|
|
20
|
+
afterEach,
|
|
21
|
+
vi,
|
|
22
|
+
} from "vitest";
|
|
23
|
+
|
|
24
|
+
const loadConfigMock = vi.fn();
|
|
25
|
+
vi.mock("../../src/config/loader.js", () => ({
|
|
26
|
+
loadConfig: loadConfigMock,
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
// Import AFTER the mock so the module captures the mocked function.
|
|
30
|
+
const {
|
|
31
|
+
tryHostdDispatch,
|
|
32
|
+
hostdWillBeUsed,
|
|
33
|
+
isHostdEnabled,
|
|
34
|
+
hostdSocketPath,
|
|
35
|
+
_resetHostdEnabledCache,
|
|
36
|
+
} = await import("../gateway/hostd-dispatch.js");
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
_resetHostdEnabledCache();
|
|
40
|
+
loadConfigMock.mockReset();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
_resetHostdEnabledCache();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("isHostdEnabled() — config gate", () => {
|
|
48
|
+
it("returns false when host_control absent", () => {
|
|
49
|
+
loadConfigMock.mockReturnValue({});
|
|
50
|
+
expect(isHostdEnabled()).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("returns false when host_control.enabled is false", () => {
|
|
54
|
+
loadConfigMock.mockReturnValue({ host_control: { enabled: false } });
|
|
55
|
+
expect(isHostdEnabled()).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("returns true when host_control.enabled is true", () => {
|
|
59
|
+
loadConfigMock.mockReturnValue({ host_control: { enabled: true } });
|
|
60
|
+
expect(isHostdEnabled()).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("returns false on config-load throw (best-effort fallback)", () => {
|
|
64
|
+
// Gateway runs in environments where the config may not be
|
|
65
|
+
// readable yet (very-early-boot, broken symlink). The helper must
|
|
66
|
+
// not propagate — it just disables the hostd path.
|
|
67
|
+
loadConfigMock.mockImplementation(() => {
|
|
68
|
+
throw new Error("config: file not found");
|
|
69
|
+
});
|
|
70
|
+
expect(isHostdEnabled()).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("caches the result across calls (no re-read)", () => {
|
|
74
|
+
loadConfigMock.mockReturnValue({ host_control: { enabled: true } });
|
|
75
|
+
expect(isHostdEnabled()).toBe(true);
|
|
76
|
+
expect(isHostdEnabled()).toBe(true);
|
|
77
|
+
expect(isHostdEnabled()).toBe(true);
|
|
78
|
+
expect(loadConfigMock).toHaveBeenCalledTimes(1);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("hostdWillBeUsed() — config + socket existence", () => {
|
|
83
|
+
it("false when hostd disabled even if socket would be present", () => {
|
|
84
|
+
loadConfigMock.mockReturnValue({});
|
|
85
|
+
expect(hostdWillBeUsed("klanker")).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("false when hostd enabled but per-agent socket isn't bound", () => {
|
|
89
|
+
loadConfigMock.mockReturnValue({ host_control: { enabled: true } });
|
|
90
|
+
// hostdSocketPath() is hard-coded to /run/switchroom/hostd/<name>/sock
|
|
91
|
+
// — that path doesn't exist in the test env, so existsSync returns
|
|
92
|
+
// false and hostdWillBeUsed is false.
|
|
93
|
+
expect(hostdWillBeUsed("klanker-no-such-agent")).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("tryHostdDispatch()", () => {
|
|
98
|
+
it("returns 'not-configured' when hostd disabled", async () => {
|
|
99
|
+
loadConfigMock.mockReturnValue({});
|
|
100
|
+
const result = await tryHostdDispatch("klanker", {
|
|
101
|
+
v: 1,
|
|
102
|
+
op: "agent_restart",
|
|
103
|
+
request_id: "test-1",
|
|
104
|
+
args: { name: "klanker", force: true },
|
|
105
|
+
});
|
|
106
|
+
expect(result).toBe("not-configured");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("returns 'not-configured' when socket absent", async () => {
|
|
110
|
+
loadConfigMock.mockReturnValue({ host_control: { enabled: true } });
|
|
111
|
+
const result = await tryHostdDispatch("nonexistent-agent", {
|
|
112
|
+
v: 1,
|
|
113
|
+
op: "agent_restart",
|
|
114
|
+
request_id: "test-2",
|
|
115
|
+
args: { name: "nonexistent-agent", force: true },
|
|
116
|
+
});
|
|
117
|
+
expect(result).toBe("not-configured");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("locks the socket-path contract", () => {
|
|
121
|
+
// RFC C pins this path. If the gateway and the compose generator
|
|
122
|
+
// drift apart on the bind path, the mount silently goes nowhere
|
|
123
|
+
// and every dispatch returns "not-configured". Catch any rename
|
|
124
|
+
// in lockstep with the compose-generator test.
|
|
125
|
+
expect(hostdSocketPath("klanker")).toBe(
|
|
126
|
+
"/run/switchroom/hostd/klanker/sock",
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
});
|