live: AMI-fed live state, /api/live WebSocket, dashboard and status dots
ci / check (push) Canceled after 0s
ci / images (push) Canceled after 0s

LiveState loads extension states and registered devices from Asterisk on
every AMI connect and follows ExtensionStatus/ContactStatus; the hub sends
a snapshot then deltas batched every 250 ms (devices for admins only),
pings every 30 s and closes unauthenticated sockets with 4401. The web UI
keeps one reconnecting socket in a zustand store, shows a Reconnecting
banner, a Dashboard (online, on a call, telephony engine) and status dots.

Fix: Asterisk 24 has no 'pjsip reload'; reload with
'module reload res_pjsip.so'. AMI errors now carry the CLI output.

Milestone check, automated: call-test.ts registers two baresip phones and
holds a 30 s call with RTP both ways (pnpm test:asterisk); the recorded
session drives the live-state tests. scripts/e2e/m2-live.mjs runs api +
Asterisk + phones + browser on a private Docker network: a new extension
is live in Asterisk in ~1.3 s, the dashboard shows 2/2 online and red dots
during the call.

Task: M2-T6
This commit is contained in:
iamdoubz committed 2026-10-17 16:49:25 -05:00
1 parent 1cfc13ead3
commit c0c4036887
31 files changed
+4810 -28

No files matched your search

+19 -3
View File
@@ -8,6 +8,7 @@ import { setSecretKey } from './crypto/secrets.ts';
import { createDb, runMigrations } from './db/client.ts';
import { createGenerator } from './generator/index.ts';
import { ensureInitialAdmin } from './services/bootstrap.ts';
import { LiveState } from './live/state.ts';
let config;
try {
@@ -28,13 +29,22 @@ const ami = new AmiClient({
secret: config.AMI_SECRET,
logger: app.log,
});
const live = new LiveState(db, ami);
const generator = createGenerator({
db,
ami,
dir: resolve(config.ASTERISK_CONFIG_DIR),
log: app.log,
onApplied: (r) => {
live.emit('message', {
type: 'system.configApplied',
data: { ok: r.ok, error: r.error, restartRequired: r.restartRequired },
});
void live.loadExtensions().catch((err) => app.log.error({ err }, 'live reload failed'));
},
});
app.decorate('generator', generator);
app.decorate('live', live);
await runMigrations(db);
app.log.info('database migrated');
@@ -47,9 +57,15 @@ if (admin === 'created')
app.log.info({ email: config.INITIAL_ADMIN_EMAIL }, 'initial admin created');
await app.listen({ host: '127.0.0.1', port: config.API_PORT });
// Every (re)connect: Asterisk may have restarted, so push the full config again.
// TODO(M2-T6): load live state; TODO(M6): sync AstDB first (04 §5.5).
ami.on('connected', () => void generator.apply({ trigger: 'startup', force: true }));
await live.loadExtensions();
// Every (re)connect: Asterisk may have restarted, so push the full config again, then read its
// state (after the apply, so hints for new extensions exist). TODO(M6): sync AstDB first (04 §5.5).
ami.on('connected', () => {
void (async () => {
await generator.apply({ trigger: 'startup', force: true });
await live.loadFromAsterisk();
})().catch((err) => app.log.error({ err }, 'startup sync failed'));
});
ami.start();
for (const sig of ['SIGTERM', 'SIGINT'] as const) {