Files
OpenSquawk/server/utils/simControlQueue.ts
itsrubberduck 3fe0ea5f8a feat(sim-control): wire frequency-sim-control command channel end-to-end
Implements the open items from docs/plans/2026-07-14-frequency-sim-control-design.md
§4/§"Offen für die Implementierungsphase": a per-bridge-token in-memory command
queue piggybacked on the existing telemetry channel, plus the client-side gate
and TTS confirmations.

- server/utils/simControlQueue.ts: TTL-based queue keyed by bridge token
  (enqueue → drainPending → resolve → drainResultsForClient).
- server/api/bridge/data.post.ts: response gains a `commands` field the
  bridge drains on its next telemetry POST.
- server/api/bridge/command.post.ts (new): client enqueues a parsed command,
  re-validated server-side via isValidSimControlCommand.
- server/api/bridge/command-result.post.ts (new): bridge reports ok/failed.
- server/api/bridge/live.get.ts: response gains `commandResults` so the
  client can announce outcomes.
- shared/utils/simControl.ts: wire types, isValidSimControlCommand, and
  simControlRejectionSpeech/simControlResultSpeech TTS phrasing.
- useLiveAtcSession.ts: parseSimControl() gated on bridgeConnected, wired in
  right after the local special cases and before the frequency check —
  matched commands never reach radioBackend.transmit().
- useSimBridgeSync.ts / live-atc.vue: bridgeToken threaded through, command
  results forwarded from the telemetry poll to TTS.

43 new tests (shared parser/validation/speech + server queue lifecycle/TTL).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 09:31:17 +02:00

123 lines
4.6 KiB
TypeScript

/**
* In-memory command queue for the frequency-sim-control channel (design doc
* §4, `docs/plans/2026-07-14-frequency-sim-control-design.md`).
*
* Flow: /live-atc client → POST /api/bridge/command (enqueue) → this queue
* → POST /api/bridge/data response `commands` field (bridge picks up) →
* bridge executes → POST /api/bridge/command-result (resolve) → this queue
* → GET /api/bridge/live response `commandResults` field (client announces).
*
* Keyed by bridge token, not userId: both ends of this channel authenticate
* with the same x-bridge-token the telemetry channel already uses.
*/
import { randomUUID } from 'node:crypto'
import type { PendingCommand, SimControlCommand, SimControlCommandResult, SimControlCommandStatus } from '../../shared/utils/simControl'
type RecordStatus = 'pending' | 'delivered' | SimControlCommandStatus
interface CommandRecord {
id: string
token: string
command: SimControlCommand
issuedAt: number
status: RecordStatus
reason: string | null
consumedByClient: boolean
}
export interface SimControlQueueOptions {
/** Commands not delivered/resolved within this window are treated as expired. */
ttlMs?: number
/** Clock override for deterministic tests. */
now?: () => number
}
export class SimControlQueue {
private records = new Map<string, CommandRecord>()
private pendingByToken = new Map<string, string[]>()
private ttlMs: number
private now: () => number
constructor(opts: SimControlQueueOptions = {}) {
this.ttlMs = opts.ttlMs ?? 30_000
this.now = opts.now ?? Date.now
}
/** Enqueue a parsed command for the bridge behind this token to pick up. */
enqueue(token: string, command: SimControlCommand): PendingCommand {
const id = randomUUID()
const issuedAt = this.now()
const record: CommandRecord = { id, token, command, issuedAt, status: 'pending', reason: null, consumedByClient: false }
this.records.set(id, record)
const queue = this.pendingByToken.get(token) ?? []
queue.push(id)
this.pendingByToken.set(token, queue)
return { id, issued_at: new Date(issuedAt).toISOString(), command }
}
/**
* Called from POST /api/bridge/data: hand every still-pending command for
* this token to the bridge and mark it delivered. Each command is handed
* out exactly once — the caller does not re-poll for it.
*/
drainPending(token: string): PendingCommand[] {
this.sweepExpired()
const ids = this.pendingByToken.get(token) ?? []
this.pendingByToken.set(token, [])
const out: PendingCommand[] = []
for (const id of ids) {
const record = this.records.get(id)
if (!record || record.status !== 'pending') continue // expired while queued
record.status = 'delivered'
out.push({ id: record.id, issued_at: new Date(record.issuedAt).toISOString(), command: record.command })
}
return out
}
/**
* Called from POST /api/bridge/command-result: the bridge reports the
* outcome of a delivered command. Returns false if the id is unknown or
* belongs to a different token (never trust the caller's own claim).
*/
resolve(token: string, id: string, status: 'ok' | 'failed', reason?: string | null): boolean {
const record = this.records.get(id)
if (!record || record.token !== token) return false
if (record.status !== 'pending' && record.status !== 'delivered') return false
record.status = status
record.reason = reason ?? null
return true
}
/**
* Called from GET /api/bridge/live: hand the client every terminal result
* for this token it hasn't seen yet, then forget them (one-shot delivery,
* same as drainPending on the bridge side).
*/
drainResultsForClient(token: string): SimControlCommandResult[] {
this.sweepExpired()
const out: SimControlCommandResult[] = []
for (const record of this.records.values()) {
if (record.token !== token || record.consumedByClient) continue
if (record.status === 'ok' || record.status === 'failed' || record.status === 'expired') {
record.consumedByClient = true
out.push({ id: record.id, command: record.command, status: record.status, reason: record.reason })
}
}
for (const record of out) this.records.delete(record.id)
return out
}
private sweepExpired() {
const cutoff = this.now() - this.ttlMs
for (const record of this.records.values()) {
if ((record.status === 'pending' || record.status === 'delivered') && record.issuedAt < cutoff) {
record.status = 'expired'
}
}
}
}
// Singleton — shared across all server handlers, same pattern as flightlabTelemetryStore.
export const simControlQueue = new SimControlQueue()