switchroom 0.5.0 → 0.7.8
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 +142 -121
- package/bin/autoaccept.exp +29 -6
- package/dist/agent-scheduler/index.js +12261 -0
- package/dist/cli/autoaccept-poll.js +10 -0
- package/dist/cli/switchroom.js +27250 -25324
- package/dist/vault/approvals/kernel-server.js +12709 -0
- package/dist/vault/broker/server.js +15724 -0
- package/package.json +4 -3
- package/profiles/_base/start.sh.hbs +133 -0
- package/profiles/_shared/telegram-style.md.hbs +3 -3
- package/profiles/default/CLAUDE.md +3 -3
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/default/workspace/CLAUDE.md.hbs +9 -0
- package/skills/docx/VENDORED.md +1 -1
- package/skills/mcp-builder/VENDORED.md +1 -1
- package/skills/pdf/VENDORED.md +1 -1
- package/skills/pptx/VENDORED.md +1 -1
- package/skills/skill-creator/VENDORED.md +1 -1
- package/skills/switchroom-architecture/SKILL.md +8 -7
- package/skills/switchroom-cli/SKILL.md +23 -15
- package/skills/switchroom-health/SKILL.md +7 -7
- package/skills/switchroom-install/SKILL.md +36 -39
- package/skills/switchroom-manage/SKILL.md +4 -4
- package/skills/switchroom-status/SKILL.md +1 -1
- package/skills/webapp-testing/VENDORED.md +1 -1
- package/skills/xlsx/VENDORED.md +1 -1
- package/telegram-plugin/admin-commands/dispatch.test.ts +119 -1
- package/telegram-plugin/admin-commands/index.ts +71 -0
- package/telegram-plugin/ask-user.ts +1 -0
- package/telegram-plugin/card-event-log.ts +138 -0
- package/telegram-plugin/dist/bridge/bridge.js +178 -31
- package/telegram-plugin/dist/foreman/foreman.js +6875 -6526
- package/telegram-plugin/dist/gateway/gateway.js +13862 -11834
- package/telegram-plugin/dist/server.js +202 -40
- package/telegram-plugin/fleet-state.ts +25 -10
- package/telegram-plugin/foreman/foreman.ts +38 -3
- package/telegram-plugin/gateway/approval-callback.ts +126 -0
- package/telegram-plugin/gateway/approval-card.test.ts +90 -0
- package/telegram-plugin/gateway/approval-card.ts +127 -0
- package/telegram-plugin/gateway/approvals-commands.ts +126 -0
- package/telegram-plugin/gateway/boot-card.ts +31 -6
- package/telegram-plugin/gateway/boot-probes.ts +503 -72
- package/telegram-plugin/gateway/gateway.ts +822 -94
- package/telegram-plugin/gateway/ipc-protocol.ts +34 -1
- package/telegram-plugin/gateway/ipc-server.ts +35 -0
- package/telegram-plugin/gateway/startup-mutex.ts +110 -2
- package/telegram-plugin/hooks/hooks.json +19 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +216 -0
- package/telegram-plugin/hooks/tool-label-stop.mjs +63 -0
- package/telegram-plugin/package.json +4 -1
- package/telegram-plugin/plugin-logger.ts +20 -1
- package/telegram-plugin/progress-card-driver.ts +202 -13
- package/telegram-plugin/progress-card.ts +2 -2
- package/telegram-plugin/quota-check.ts +1 -0
- package/telegram-plugin/registry/subagents-schema.ts +37 -0
- package/telegram-plugin/registry/subagents.test.ts +64 -0
- package/telegram-plugin/session-tail.ts +58 -5
- package/telegram-plugin/shared/bot-runtime.ts +48 -2
- package/telegram-plugin/subagent-watcher.ts +139 -7
- package/telegram-plugin/tests/_progress-card-harness.ts +4 -0
- package/telegram-plugin/tests/bg-agent-progress-card-757.test.ts +201 -0
- package/telegram-plugin/tests/boot-card-probe-target.test.ts +10 -34
- package/telegram-plugin/tests/boot-card-render.test.ts +6 -5
- package/telegram-plugin/tests/boot-probes.test.ts +558 -0
- package/telegram-plugin/tests/card-event-log.test.ts +145 -0
- package/telegram-plugin/tests/gateway-startup-mutex.test.ts +102 -0
- package/telegram-plugin/tests/ipc-server-validate-inject-inbound.test.ts +134 -0
- package/telegram-plugin/tests/progress-card-delay-842.test.ts +160 -0
- package/telegram-plugin/tests/quota-check.test.ts +37 -1
- package/telegram-plugin/tests/subagent-registry-bugs.test.ts +5 -0
- package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +104 -1
- package/telegram-plugin/tests/subagent-watcher.test.ts +5 -0
- package/telegram-plugin/tests/tool-label-sidecar.test.ts +114 -0
- package/telegram-plugin/tests/two-zone-bg-done-when-all-terminal.test.ts +5 -3
- package/telegram-plugin/tests/two-zone-card-header-phases.test.ts +10 -0
- package/telegram-plugin/tests/two-zone-snapshot-extras.test.ts +58 -14
- package/telegram-plugin/tests/welcome-text.test.ts +57 -0
- package/telegram-plugin/tool-label-sidecar.ts +140 -0
- package/telegram-plugin/tool-labels.ts +55 -0
- package/telegram-plugin/two-zone-card.ts +27 -7
- package/telegram-plugin/uat/SETUP.md +160 -0
- package/telegram-plugin/uat/assertions.ts +140 -0
- package/telegram-plugin/uat/driver.ts +174 -0
- package/telegram-plugin/uat/harness.ts +161 -0
- package/telegram-plugin/uat/login.ts +134 -0
- package/telegram-plugin/uat/port-allocator.ts +71 -0
- package/telegram-plugin/uat/scenarios/smoke-clerk-reply.test.ts +61 -0
- package/telegram-plugin/welcome-text.ts +44 -2
- package/bin/bridge-watchdog.sh +0 -967
|
@@ -13,8 +13,17 @@ import { join } from 'path'
|
|
|
13
13
|
|
|
14
14
|
import {
|
|
15
15
|
probeAgentProcess,
|
|
16
|
+
probeScheduler,
|
|
17
|
+
probeBroker,
|
|
18
|
+
probeKernel,
|
|
19
|
+
probeSkills,
|
|
16
20
|
probeQuota,
|
|
17
21
|
watchAgentProcess,
|
|
22
|
+
findAgentProcessInContainer,
|
|
23
|
+
uptimeMsForStarttime,
|
|
24
|
+
type ProcFsImpl,
|
|
25
|
+
type SchedulerFsImpl,
|
|
26
|
+
type SkillsFsImpl,
|
|
18
27
|
} from '../gateway/boot-probes.js'
|
|
19
28
|
import { readQuotaCache, RATE_LIMIT_TTL_MS } from '../gateway/quota-cache.js'
|
|
20
29
|
|
|
@@ -448,4 +457,553 @@ describe('watchAgentProcess — #296: re-poll after window expiry', () => {
|
|
|
448
457
|
expect(yields[0].status).toBe('ok')
|
|
449
458
|
expect(extraCalls).toBe(1) // only the initial probe; no follow-up
|
|
450
459
|
})
|
|
460
|
+
|
|
461
|
+
// ── docker mode: skip systemctl, use /proc walk ───────────────────────────
|
|
462
|
+
|
|
463
|
+
describe('probeAgentProcess — docker mode skips systemctl', () => {
|
|
464
|
+
it('probeAgentProcess(dockerMode) returns the injected /proc result without execing', async () => {
|
|
465
|
+
let execFileCalls = 0
|
|
466
|
+
const execFileImpl: ExecFileFn = async () => {
|
|
467
|
+
execFileCalls++
|
|
468
|
+
throw new Error('systemctl should never be called under dockerMode')
|
|
469
|
+
}
|
|
470
|
+
const dockerProbeImpl = () => ({
|
|
471
|
+
status: 'ok' as const,
|
|
472
|
+
label: 'Agent',
|
|
473
|
+
detail: 'PID 42 · up 3.0s · 128 MB',
|
|
474
|
+
})
|
|
475
|
+
const result = await probeAgentProcess('clerk', {
|
|
476
|
+
dockerMode: true,
|
|
477
|
+
dockerProbeImpl,
|
|
478
|
+
execFileImpl: execFileImpl as never,
|
|
479
|
+
sleepImpl: noopSleep,
|
|
480
|
+
retryIntervalMs: 1,
|
|
481
|
+
retryMaxMs: 5,
|
|
482
|
+
})
|
|
483
|
+
expect(result.status).toBe('ok')
|
|
484
|
+
expect(result.label).toBe('Agent')
|
|
485
|
+
expect(result.detail).toBe('PID 42 · up 3.0s · 128 MB')
|
|
486
|
+
expect(execFileCalls).toBe(0)
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
it('probeAgentProcess(dockerMode) surfaces fail when no claude process found', async () => {
|
|
490
|
+
const dockerProbeImpl = () => ({
|
|
491
|
+
status: 'fail' as const,
|
|
492
|
+
label: 'Agent',
|
|
493
|
+
detail: 'claude process not found',
|
|
494
|
+
})
|
|
495
|
+
const result = await probeAgentProcess('clerk', {
|
|
496
|
+
dockerMode: true,
|
|
497
|
+
dockerProbeImpl,
|
|
498
|
+
})
|
|
499
|
+
expect(result.status).toBe('fail')
|
|
500
|
+
expect(result.detail).toBe('claude process not found')
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
it('watchAgentProcess(dockerMode) yields the /proc result once and exits', async () => {
|
|
504
|
+
let execFileCalls = 0
|
|
505
|
+
const execFileImpl: ExecFileFn = async () => {
|
|
506
|
+
execFileCalls++
|
|
507
|
+
return { stdout: '', stderr: '' }
|
|
508
|
+
}
|
|
509
|
+
const dockerProbeImpl = () => ({
|
|
510
|
+
status: 'ok' as const,
|
|
511
|
+
label: 'Agent',
|
|
512
|
+
detail: 'PID 42 · up 5.0s · 200 MB',
|
|
513
|
+
})
|
|
514
|
+
const yields: Array<{ status: string; detail: string }> = []
|
|
515
|
+
const gen = watchAgentProcess('clerk', {
|
|
516
|
+
dockerMode: true,
|
|
517
|
+
dockerProbeImpl,
|
|
518
|
+
execFileImpl: execFileImpl as never,
|
|
519
|
+
sleepImpl: noopSleep,
|
|
520
|
+
liveWindowMs: 1000,
|
|
521
|
+
pollIntervalMs: 10,
|
|
522
|
+
followupRepollMs: 0,
|
|
523
|
+
})
|
|
524
|
+
for await (const r of gen) yields.push({ status: r.status, detail: r.detail })
|
|
525
|
+
expect(yields).toHaveLength(1)
|
|
526
|
+
expect(yields[0].status).toBe('ok')
|
|
527
|
+
expect(execFileCalls).toBe(0)
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
// ── probeScheduler — in-container agent-scheduler (Phase 4 cron-fold-in) ──
|
|
533
|
+
|
|
534
|
+
function makeSchedulerFs(files: Record<string, { content?: string; mtimeMs?: number }>): SchedulerFsImpl {
|
|
535
|
+
return {
|
|
536
|
+
readFile: (p) => {
|
|
537
|
+
const f = files[p]
|
|
538
|
+
if (!f || f.content === undefined) throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
|
539
|
+
return f.content
|
|
540
|
+
},
|
|
541
|
+
mtimeMs: (p) => {
|
|
542
|
+
const f = files[p]
|
|
543
|
+
if (!f || f.mtimeMs === undefined) throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
|
544
|
+
return f.mtimeMs
|
|
545
|
+
},
|
|
546
|
+
exists: (p) => p in files,
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
describe('probeScheduler', () => {
|
|
551
|
+
it('returns ok n/a when not in dockerMode (Phase 4 deleted host-side scheduler)', async () => {
|
|
552
|
+
const result = await probeScheduler('clerk', { dockerMode: false })
|
|
553
|
+
expect(result.status).toBe('ok')
|
|
554
|
+
expect(result.label).toBe('Scheduler')
|
|
555
|
+
expect(result.detail).toContain('non-docker')
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
it('fails when lockfile is missing (sidecar never started or supervisor gave up)', async () => {
|
|
559
|
+
const fs = makeSchedulerFs({})
|
|
560
|
+
const result = await probeScheduler('clerk', {
|
|
561
|
+
dockerMode: true,
|
|
562
|
+
fs,
|
|
563
|
+
isAlive: () => true,
|
|
564
|
+
// Disable settle softening so the verdict is the hard fail —
|
|
565
|
+
// the "boot still settling" case is covered by the dedicated
|
|
566
|
+
// test below.
|
|
567
|
+
containerBootTimeMs: null,
|
|
568
|
+
})
|
|
569
|
+
expect(result.status).toBe('fail')
|
|
570
|
+
expect(result.detail).toContain('no lockfile')
|
|
571
|
+
})
|
|
572
|
+
|
|
573
|
+
it('softens fail to degraded when container booted recently (still-settling window)', async () => {
|
|
574
|
+
// No lockfile, but container PID 1 started 5 s ago — the supervisor
|
|
575
|
+
// is still bringing the scheduler up. /status hit at this instant
|
|
576
|
+
// should NOT show 🔴 for a non-issue.
|
|
577
|
+
const now = 1_700_000_000_000
|
|
578
|
+
const fs = makeSchedulerFs({})
|
|
579
|
+
const result = await probeScheduler('clerk', {
|
|
580
|
+
dockerMode: true,
|
|
581
|
+
fs,
|
|
582
|
+
isAlive: () => true,
|
|
583
|
+
now: () => now,
|
|
584
|
+
containerBootTimeMs: now - 5_000,
|
|
585
|
+
})
|
|
586
|
+
expect(result.status).toBe('degraded')
|
|
587
|
+
expect(result.detail).toContain('still settling')
|
|
588
|
+
})
|
|
589
|
+
|
|
590
|
+
it('does NOT soften when container booted long ago (real wedge, not a flap)', async () => {
|
|
591
|
+
// Container has been up for an hour; missing lockfile is a real
|
|
592
|
+
// problem, not a settle window. Hard fail expected.
|
|
593
|
+
const now = 1_700_000_000_000
|
|
594
|
+
const fs = makeSchedulerFs({})
|
|
595
|
+
const result = await probeScheduler('clerk', {
|
|
596
|
+
dockerMode: true,
|
|
597
|
+
fs,
|
|
598
|
+
isAlive: () => true,
|
|
599
|
+
now: () => now,
|
|
600
|
+
containerBootTimeMs: now - 60 * 60_000,
|
|
601
|
+
})
|
|
602
|
+
expect(result.status).toBe('fail')
|
|
603
|
+
expect(result.detail).not.toContain('still settling')
|
|
604
|
+
})
|
|
605
|
+
|
|
606
|
+
it('honors SWITCHROOM_AGENT_SCHEDULER_LOCK env override', async () => {
|
|
607
|
+
const customLock = '/custom/sched.lock'
|
|
608
|
+
const fs = makeSchedulerFs({
|
|
609
|
+
[customLock]: { content: '1234' },
|
|
610
|
+
})
|
|
611
|
+
const oldEnv = process.env.SWITCHROOM_AGENT_SCHEDULER_LOCK
|
|
612
|
+
process.env.SWITCHROOM_AGENT_SCHEDULER_LOCK = customLock
|
|
613
|
+
try {
|
|
614
|
+
const result = await probeScheduler('clerk', {
|
|
615
|
+
dockerMode: true,
|
|
616
|
+
fs,
|
|
617
|
+
isAlive: (pid) => pid === 1234,
|
|
618
|
+
containerBootTimeMs: null,
|
|
619
|
+
})
|
|
620
|
+
expect(result.status).toBe('ok')
|
|
621
|
+
expect(result.detail).toContain('pid 1234')
|
|
622
|
+
} finally {
|
|
623
|
+
if (oldEnv === undefined) delete process.env.SWITCHROOM_AGENT_SCHEDULER_LOCK
|
|
624
|
+
else process.env.SWITCHROOM_AGENT_SCHEDULER_LOCK = oldEnv
|
|
625
|
+
}
|
|
626
|
+
})
|
|
627
|
+
|
|
628
|
+
it('honors SWITCHROOM_AGENT_SCHEDULER_JSONL env override for freshness hint', async () => {
|
|
629
|
+
const lockPath = '/state/agent/scheduler.lock'
|
|
630
|
+
const customJsonl = '/custom/audit.jsonl'
|
|
631
|
+
const now = 1_700_000_000_000
|
|
632
|
+
const fs = makeSchedulerFs({
|
|
633
|
+
[lockPath]: { content: '5555' },
|
|
634
|
+
[customJsonl]: { content: '{}\n', mtimeMs: now - 90_000 },
|
|
635
|
+
})
|
|
636
|
+
const oldEnv = process.env.SWITCHROOM_AGENT_SCHEDULER_JSONL
|
|
637
|
+
process.env.SWITCHROOM_AGENT_SCHEDULER_JSONL = customJsonl
|
|
638
|
+
try {
|
|
639
|
+
const result = await probeScheduler('clerk', {
|
|
640
|
+
dockerMode: true,
|
|
641
|
+
fs,
|
|
642
|
+
isAlive: () => true,
|
|
643
|
+
now: () => now,
|
|
644
|
+
containerBootTimeMs: null,
|
|
645
|
+
})
|
|
646
|
+
expect(result.status).toBe('ok')
|
|
647
|
+
expect(result.detail).toContain('last fire')
|
|
648
|
+
} finally {
|
|
649
|
+
if (oldEnv === undefined) delete process.env.SWITCHROOM_AGENT_SCHEDULER_JSONL
|
|
650
|
+
else process.env.SWITCHROOM_AGENT_SCHEDULER_JSONL = oldEnv
|
|
651
|
+
}
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
it('returns ok with last-fire age when lock is held by a live PID', async () => {
|
|
655
|
+
const lockPath = '/state/agent/scheduler.lock'
|
|
656
|
+
const jsonlPath = '/state/agent/scheduler.jsonl'
|
|
657
|
+
const now = 1_700_000_000_000
|
|
658
|
+
const fs = makeSchedulerFs({
|
|
659
|
+
[lockPath]: { content: '4242\n' },
|
|
660
|
+
[jsonlPath]: { content: '{}\n', mtimeMs: now - 5 * 60_000 },
|
|
661
|
+
})
|
|
662
|
+
const result = await probeScheduler('clerk', {
|
|
663
|
+
dockerMode: true,
|
|
664
|
+
fs,
|
|
665
|
+
isAlive: (pid) => pid === 4242,
|
|
666
|
+
now: () => now,
|
|
667
|
+
})
|
|
668
|
+
expect(result.status).toBe('ok')
|
|
669
|
+
expect(result.detail).toContain('pid 4242')
|
|
670
|
+
expect(result.detail).toContain('last fire')
|
|
671
|
+
})
|
|
672
|
+
|
|
673
|
+
it('degraded when lockfile holder PID is not alive (mid-restart)', async () => {
|
|
674
|
+
const lockPath = '/state/agent/scheduler.lock'
|
|
675
|
+
const fs = makeSchedulerFs({
|
|
676
|
+
[lockPath]: { content: '99\n' },
|
|
677
|
+
})
|
|
678
|
+
const result = await probeScheduler('clerk', {
|
|
679
|
+
dockerMode: true,
|
|
680
|
+
fs,
|
|
681
|
+
isAlive: () => false,
|
|
682
|
+
})
|
|
683
|
+
expect(result.status).toBe('degraded')
|
|
684
|
+
expect(result.detail).toContain('pid 99 not alive')
|
|
685
|
+
})
|
|
686
|
+
|
|
687
|
+
it('degraded when lockfile contents are not a valid PID', async () => {
|
|
688
|
+
const lockPath = '/state/agent/scheduler.lock'
|
|
689
|
+
const fs = makeSchedulerFs({
|
|
690
|
+
[lockPath]: { content: 'garbage' },
|
|
691
|
+
})
|
|
692
|
+
const result = await probeScheduler('clerk', {
|
|
693
|
+
dockerMode: true,
|
|
694
|
+
fs,
|
|
695
|
+
isAlive: () => true,
|
|
696
|
+
})
|
|
697
|
+
expect(result.status).toBe('degraded')
|
|
698
|
+
expect(result.detail).toContain('invalid')
|
|
699
|
+
})
|
|
700
|
+
|
|
701
|
+
it('returns ok without freshness hint when scheduler.jsonl has never been written', async () => {
|
|
702
|
+
const lockPath = '/state/agent/scheduler.lock'
|
|
703
|
+
const fs = makeSchedulerFs({
|
|
704
|
+
[lockPath]: { content: '5555' },
|
|
705
|
+
})
|
|
706
|
+
const result = await probeScheduler('clerk', {
|
|
707
|
+
dockerMode: true,
|
|
708
|
+
fs,
|
|
709
|
+
isAlive: () => true,
|
|
710
|
+
})
|
|
711
|
+
expect(result.status).toBe('ok')
|
|
712
|
+
expect(result.detail).toContain('pid 5555')
|
|
713
|
+
expect(result.detail).not.toContain('last fire')
|
|
714
|
+
})
|
|
715
|
+
})
|
|
716
|
+
|
|
717
|
+
// ── probeBroker / probeKernel — UDS reachability ─────────────────────────
|
|
718
|
+
|
|
719
|
+
describe('probeBroker / probeKernel', () => {
|
|
720
|
+
it('probeBroker returns ok n/a when not in dockerMode', async () => {
|
|
721
|
+
const result = await probeBroker('/some/path', { dockerMode: false })
|
|
722
|
+
expect(result.status).toBe('ok')
|
|
723
|
+
expect(result.detail).toContain('non-docker')
|
|
724
|
+
})
|
|
725
|
+
|
|
726
|
+
it('probeBroker fails when no socket path is configured', async () => {
|
|
727
|
+
const oldEnv = process.env.SWITCHROOM_BROKER_SOCKET
|
|
728
|
+
delete process.env.SWITCHROOM_BROKER_SOCKET
|
|
729
|
+
try {
|
|
730
|
+
const result = await probeBroker(undefined, { dockerMode: true })
|
|
731
|
+
expect(result.status).toBe('fail')
|
|
732
|
+
expect(result.detail).toContain('not configured')
|
|
733
|
+
} finally {
|
|
734
|
+
if (oldEnv !== undefined) process.env.SWITCHROOM_BROKER_SOCKET = oldEnv
|
|
735
|
+
}
|
|
736
|
+
})
|
|
737
|
+
|
|
738
|
+
it('probeBroker reports ok when connect resolves', async () => {
|
|
739
|
+
const result = await probeBroker('/run/switchroom/broker/clerk/sock', {
|
|
740
|
+
dockerMode: true,
|
|
741
|
+
connectImpl: async () => { /* connect ok */ },
|
|
742
|
+
})
|
|
743
|
+
expect(result.status).toBe('ok')
|
|
744
|
+
expect(result.label).toBe('Broker')
|
|
745
|
+
expect(result.detail).toBe('reachable')
|
|
746
|
+
})
|
|
747
|
+
|
|
748
|
+
it('probeBroker reports fail with ENOENT detail when socket missing', async () => {
|
|
749
|
+
const result = await probeBroker('/run/switchroom/broker/clerk/sock', {
|
|
750
|
+
dockerMode: true,
|
|
751
|
+
connectImpl: async () => {
|
|
752
|
+
const err = new Error('ENOENT') as NodeJS.ErrnoException
|
|
753
|
+
err.code = 'ENOENT'
|
|
754
|
+
throw err
|
|
755
|
+
},
|
|
756
|
+
})
|
|
757
|
+
expect(result.status).toBe('fail')
|
|
758
|
+
expect(result.detail).toContain('socket missing')
|
|
759
|
+
})
|
|
760
|
+
|
|
761
|
+
it('probeBroker reports fail with ECONNREFUSED detail when daemon down', async () => {
|
|
762
|
+
const result = await probeBroker('/run/switchroom/broker/clerk/sock', {
|
|
763
|
+
dockerMode: true,
|
|
764
|
+
connectImpl: async () => {
|
|
765
|
+
const err = new Error('ECONNREFUSED') as NodeJS.ErrnoException
|
|
766
|
+
err.code = 'ECONNREFUSED'
|
|
767
|
+
throw err
|
|
768
|
+
},
|
|
769
|
+
})
|
|
770
|
+
expect(result.status).toBe('fail')
|
|
771
|
+
expect(result.detail).toContain('connection refused')
|
|
772
|
+
})
|
|
773
|
+
|
|
774
|
+
it('probeKernel mirrors probeBroker shape with Kernel label', async () => {
|
|
775
|
+
const result = await probeKernel('/run/switchroom/kernel/clerk/sock', {
|
|
776
|
+
dockerMode: true,
|
|
777
|
+
connectImpl: async () => { /* connect ok */ },
|
|
778
|
+
})
|
|
779
|
+
expect(result.status).toBe('ok')
|
|
780
|
+
expect(result.label).toBe('Kernel')
|
|
781
|
+
expect(result.detail).toBe('reachable')
|
|
782
|
+
})
|
|
783
|
+
})
|
|
784
|
+
|
|
785
|
+
// ── probeSkills — symlink validity ───────────────────────────────────────
|
|
786
|
+
|
|
787
|
+
function makeSkillsFs(entries: Record<string, string[]>, files: Set<string>): SkillsFsImpl {
|
|
788
|
+
return {
|
|
789
|
+
readdir: (p) => {
|
|
790
|
+
if (!(p in entries)) throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
|
|
791
|
+
return entries[p]
|
|
792
|
+
},
|
|
793
|
+
exists: (p) => files.has(p) || p in entries,
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
describe('probeSkills', () => {
|
|
798
|
+
const agentDir = '/state/agent'
|
|
799
|
+
const skillsDir = '/state/agent/.claude/skills'
|
|
800
|
+
|
|
801
|
+
it('returns ok when no skills dir exists (no skills configured is normal)', async () => {
|
|
802
|
+
const fs = makeSkillsFs({}, new Set())
|
|
803
|
+
const result = await probeSkills(agentDir, { fs })
|
|
804
|
+
expect(result.status).toBe('ok')
|
|
805
|
+
expect(result.detail).toContain('no skills dir')
|
|
806
|
+
})
|
|
807
|
+
|
|
808
|
+
it('returns ok with count when every skill resolves', async () => {
|
|
809
|
+
const fs = makeSkillsFs(
|
|
810
|
+
{ [skillsDir]: ['simplify', 'review'] },
|
|
811
|
+
new Set([
|
|
812
|
+
`${skillsDir}/simplify`, `${skillsDir}/simplify/SKILL.md`,
|
|
813
|
+
`${skillsDir}/review`, `${skillsDir}/review/SKILL.md`,
|
|
814
|
+
]),
|
|
815
|
+
)
|
|
816
|
+
const result = await probeSkills(agentDir, { fs })
|
|
817
|
+
expect(result.status).toBe('ok')
|
|
818
|
+
expect(result.detail).toContain('2 resolved')
|
|
819
|
+
})
|
|
820
|
+
|
|
821
|
+
it('degraded when at least one symlink dangles, names them up to cap', async () => {
|
|
822
|
+
const fs = makeSkillsFs(
|
|
823
|
+
{ [skillsDir]: ['simplify', 'gone-skill', 'also-gone', 'and-this', 'review'] },
|
|
824
|
+
new Set([
|
|
825
|
+
`${skillsDir}/simplify`, `${skillsDir}/simplify/SKILL.md`,
|
|
826
|
+
`${skillsDir}/review`, `${skillsDir}/review/SKILL.md`,
|
|
827
|
+
// gone-skill, also-gone, and-this NOT in files set → dangling
|
|
828
|
+
]),
|
|
829
|
+
)
|
|
830
|
+
const result = await probeSkills(agentDir, { fs, maxNamesShown: 2 })
|
|
831
|
+
expect(result.status).toBe('degraded')
|
|
832
|
+
expect(result.detail).toContain('3/5 dangling')
|
|
833
|
+
expect(result.detail).toContain('gone-skill')
|
|
834
|
+
expect(result.detail).toContain('also-gone')
|
|
835
|
+
expect(result.detail).toContain('+1 more')
|
|
836
|
+
expect(result.detail).not.toContain('and-this')
|
|
837
|
+
})
|
|
838
|
+
|
|
839
|
+
it('returns ok when entries dir is empty', async () => {
|
|
840
|
+
const fs = makeSkillsFs({ [skillsDir]: [] }, new Set([skillsDir]))
|
|
841
|
+
// Make exists() report true for the dir itself
|
|
842
|
+
const wrappedFs: SkillsFsImpl = {
|
|
843
|
+
readdir: fs.readdir,
|
|
844
|
+
exists: (p) => p === skillsDir || fs.exists(p),
|
|
845
|
+
}
|
|
846
|
+
const result = await probeSkills(agentDir, { fs: wrappedFs })
|
|
847
|
+
expect(result.status).toBe('ok')
|
|
848
|
+
expect(result.detail).toBe('0 skills')
|
|
849
|
+
})
|
|
850
|
+
})
|
|
851
|
+
|
|
852
|
+
// ── /proc parser unit tests (synthetic fs) ────────────────────────────────
|
|
853
|
+
|
|
854
|
+
/** Build a /proc/<pid>/stat string for tests. */
|
|
855
|
+
function makeStat(pid: number, comm: string, starttime: number): string {
|
|
856
|
+
// Layout: pid (comm) state ppid pgrp session tty_nr tpgid flags
|
|
857
|
+
// minflt cminflt majflt cmajflt utime stime cutime cstime
|
|
858
|
+
// priority nice num_threads itrealvalue starttime ...
|
|
859
|
+
// We need starttime at field 22 (1-indexed). Pad fields 4..21 with zeros.
|
|
860
|
+
const middle = new Array(18).fill('0').join(' ')
|
|
861
|
+
return `${pid} (${comm}) S ${middle} ${starttime} 0 0 0 0\n`
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function makeProcFs(
|
|
865
|
+
procs: Array<{ pid: number; comm: string; rssKb: number; starttime: number }>,
|
|
866
|
+
extraFiles: Record<string, string> = {},
|
|
867
|
+
): ProcFsImpl {
|
|
868
|
+
const files: Record<string, string> = { ...extraFiles }
|
|
869
|
+
const dirEntries = ['1', '2', 'self', 'uptime', 'meminfo']
|
|
870
|
+
for (const p of procs) {
|
|
871
|
+
files[`/proc/${p.pid}/comm`] = `${p.comm}\n`
|
|
872
|
+
files[`/proc/${p.pid}/status`] = `Name:\t${p.comm}\nVmRSS:\t ${p.rssKb} kB\n`
|
|
873
|
+
files[`/proc/${p.pid}/stat`] = makeStat(p.pid, p.comm, p.starttime)
|
|
874
|
+
if (!dirEntries.includes(String(p.pid))) dirEntries.push(String(p.pid))
|
|
875
|
+
}
|
|
876
|
+
return {
|
|
877
|
+
readdir: (path: string) => {
|
|
878
|
+
if (path === '/proc') return dirEntries
|
|
879
|
+
throw new Error(`unexpected readdir: ${path}`)
|
|
880
|
+
},
|
|
881
|
+
readFile: (path: string) => {
|
|
882
|
+
if (path in files) return files[path]
|
|
883
|
+
throw new Error(`ENOENT: ${path}`)
|
|
884
|
+
},
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
describe('findAgentProcessInContainer — /proc parser', () => {
|
|
889
|
+
it('picks heaviest claude process across multiple candidates', () => {
|
|
890
|
+
const fs = makeProcFs([
|
|
891
|
+
{ pid: 100, comm: 'claude', rssKb: 50_000, starttime: 1000 },
|
|
892
|
+
{ pid: 101, comm: 'claude', rssKb: 200_000, starttime: 1100 },
|
|
893
|
+
{ pid: 102, comm: 'bash', rssKb: 5_000, starttime: 900 },
|
|
894
|
+
{ pid: 103, comm: 'tmux', rssKb: 2_000, starttime: 800 },
|
|
895
|
+
])
|
|
896
|
+
const found = findAgentProcessInContainer(fs)
|
|
897
|
+
expect(found).not.toBeNull()
|
|
898
|
+
expect(found!.pid).toBe(101)
|
|
899
|
+
expect(found!.rssKb).toBe(200_000)
|
|
900
|
+
expect(found!.starttime).toBe(1100)
|
|
901
|
+
})
|
|
902
|
+
|
|
903
|
+
it('falls back to heaviest non-wrapper node when no claude exists', () => {
|
|
904
|
+
const fs = makeProcFs([
|
|
905
|
+
{ pid: 200, comm: 'node', rssKb: 80_000, starttime: 500 },
|
|
906
|
+
{ pid: 201, comm: 'node', rssKb: 30_000, starttime: 600 },
|
|
907
|
+
{ pid: 202, comm: 'bash', rssKb: 5_000, starttime: 400 },
|
|
908
|
+
])
|
|
909
|
+
const found = findAgentProcessInContainer(fs)
|
|
910
|
+
expect(found!.pid).toBe(200)
|
|
911
|
+
})
|
|
912
|
+
|
|
913
|
+
it('returns null when only wrappers exist', () => {
|
|
914
|
+
const fs = makeProcFs([
|
|
915
|
+
{ pid: 1, comm: 'tini', rssKb: 500, starttime: 100 },
|
|
916
|
+
{ pid: 50, comm: 'bash', rssKb: 1000, starttime: 200 },
|
|
917
|
+
{ pid: 51, comm: 'tmux', rssKb: 800, starttime: 250 },
|
|
918
|
+
])
|
|
919
|
+
const found = findAgentProcessInContainer(fs)
|
|
920
|
+
expect(found).toBeNull()
|
|
921
|
+
})
|
|
922
|
+
|
|
923
|
+
it('handles a comm containing parens via lastIndexOf(\")\")', () => {
|
|
924
|
+
// Tests are the only protection against off-by-one when comm is funky.
|
|
925
|
+
const procs = [{ pid: 300, comm: '(weird)comm', rssKb: 90_000, starttime: 1234 }]
|
|
926
|
+
const fs = makeProcFs(procs)
|
|
927
|
+
// Override stat with a path comm has parens — comm content goes inside (...)
|
|
928
|
+
// Note: the kernel actually wraps comm in single parens in /proc/<pid>/stat,
|
|
929
|
+
// so a comm of `(weird)comm` lands as `300 ((weird)comm) S ...` — making
|
|
930
|
+
// lastIndexOf the only safe parse anchor.
|
|
931
|
+
const fsWithFunky: ProcFsImpl = {
|
|
932
|
+
readdir: () => ['300', 'uptime'],
|
|
933
|
+
readFile: (path: string) => {
|
|
934
|
+
if (path === '/proc/300/comm') return '(weird)comm\n'
|
|
935
|
+
if (path === '/proc/300/status') return 'VmRSS:\t90000 kB\n'
|
|
936
|
+
if (path === '/proc/300/stat') return `300 ((weird)comm) S ${new Array(18).fill('0').join(' ')} 1234 0 0 0\n`
|
|
937
|
+
throw new Error(`ENOENT: ${path}`)
|
|
938
|
+
},
|
|
939
|
+
}
|
|
940
|
+
// Funky comm is neither 'claude' nor 'node', so it's filtered out.
|
|
941
|
+
expect(findAgentProcessInContainer(fsWithFunky)).toBeNull()
|
|
942
|
+
void fs; void procs
|
|
943
|
+
})
|
|
944
|
+
|
|
945
|
+
it('handles a claude comm with trailing parens correctly', () => {
|
|
946
|
+
const fs: ProcFsImpl = {
|
|
947
|
+
readdir: () => ['400', 'uptime'],
|
|
948
|
+
readFile: (path: string) => {
|
|
949
|
+
if (path === '/proc/400/comm') return 'claude\n'
|
|
950
|
+
if (path === '/proc/400/status') return 'VmRSS:\t 150000 kB\n'
|
|
951
|
+
// Use a `claude` comm followed by 18 zero pad fields then starttime=9999.
|
|
952
|
+
if (path === '/proc/400/stat') return `400 (claude) S ${new Array(18).fill('0').join(' ')} 9999 0 0 0\n`
|
|
953
|
+
throw new Error(`ENOENT: ${path}`)
|
|
954
|
+
},
|
|
955
|
+
}
|
|
956
|
+
const found = findAgentProcessInContainer(fs)
|
|
957
|
+
expect(found).not.toBeNull()
|
|
958
|
+
expect(found!.pid).toBe(400)
|
|
959
|
+
expect(found!.starttime).toBe(9999)
|
|
960
|
+
expect(found!.rssKb).toBe(150_000)
|
|
961
|
+
})
|
|
962
|
+
|
|
963
|
+
it('skips entries that fail to read', () => {
|
|
964
|
+
const fs: ProcFsImpl = {
|
|
965
|
+
readdir: () => ['1', '500'],
|
|
966
|
+
readFile: (path: string) => {
|
|
967
|
+
if (path === '/proc/500/comm') return 'claude\n'
|
|
968
|
+
if (path === '/proc/500/status') return 'VmRSS:\t10000 kB\n'
|
|
969
|
+
if (path === '/proc/500/stat') return `500 (claude) S ${new Array(18).fill('0').join(' ')} 7000 0 0 0\n`
|
|
970
|
+
throw new Error(`ENOENT: ${path}`) // PID 1 reads fail → skipped
|
|
971
|
+
},
|
|
972
|
+
}
|
|
973
|
+
const found = findAgentProcessInContainer(fs)
|
|
974
|
+
expect(found!.pid).toBe(500)
|
|
975
|
+
})
|
|
976
|
+
})
|
|
977
|
+
|
|
978
|
+
describe('uptimeMsForStarttime', () => {
|
|
979
|
+
it('computes uptime in ms from starttime ticks and /proc/uptime', () => {
|
|
980
|
+
const fs: ProcFsImpl = {
|
|
981
|
+
readdir: () => [],
|
|
982
|
+
readFile: (path: string) => {
|
|
983
|
+
if (path === '/proc/uptime') return '1234.56 1000.00\n'
|
|
984
|
+
throw new Error(`unexpected: ${path}`)
|
|
985
|
+
},
|
|
986
|
+
}
|
|
987
|
+
// boot uptime = 1234.56 s, starttime = 1000 ticks → 10 s in.
|
|
988
|
+
// Process uptime = 1234.56 - 10 = 1224.56 s = 1_224_560 ms.
|
|
989
|
+
expect(uptimeMsForStarttime(1000, fs)).toBe(1_224_560)
|
|
990
|
+
})
|
|
991
|
+
|
|
992
|
+
it('returns null if /proc/uptime is unreadable', () => {
|
|
993
|
+
const fs: ProcFsImpl = {
|
|
994
|
+
readdir: () => [],
|
|
995
|
+
readFile: () => { throw new Error('ENOENT') },
|
|
996
|
+
}
|
|
997
|
+
expect(uptimeMsForStarttime(1000, fs)).toBeNull()
|
|
998
|
+
})
|
|
999
|
+
|
|
1000
|
+
it('returns null when computed uptime is negative', () => {
|
|
1001
|
+
// starttime in the future relative to boot uptime → invalid.
|
|
1002
|
+
const fs: ProcFsImpl = {
|
|
1003
|
+
readdir: () => [],
|
|
1004
|
+
readFile: () => '5.0 0.0\n',
|
|
1005
|
+
}
|
|
1006
|
+
expect(uptimeMsForStarttime(99999999, fs)).toBeNull()
|
|
1007
|
+
})
|
|
1008
|
+
})
|
|
451
1009
|
})
|