Commit Graph
427 Commits
Author SHA1 Message Date
Logan CusanoandClaude Opus 5.5 09746c467b Discord: channel permission math for radio bots (#225 S1)
New pure module app/internal/discord_perms.py:
- effective_permissions(guild, member, channel) -> (bits, trace), in
  Discord's order: owner, base (@everyone | member roles), ADMINISTRATOR,
  @everyone overwrite, role overwrites (all denies then all allows),
  member overwrite, no View Channel = 0, no Connect drops voice perms
  (as discord.py does, so labels match the edge), timeout keeps only
  View + Read History.
- trace names what removed each radio permission, e.g. "denied by
  #dispatch's override for @Muted".
- check_voice(bits, channel, trace=None) -> {ok, is_voice, missing,
  reasons, user_limit, limit_bypass}; only type-2 channels count; labels
  match node-26#38's _join_refusal ("View Channel", "Connect", "Speak").
- Permission strings parsed with int (values above 2**53 tested);
  malformed values raise ValueError so callers treat them as unknown.

No caller yet; S3 wires it into pick_token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:53:44 -04:00
logan c4e1f9aa61 Merge pull request 'Discord: several radio streams per voice channel; adding never overwrites (#213)' (#227) from fix/213-multi-stream-per-channel into main
Build & Deploy / Build & push images (push) Successful in 4m13s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 28s
Build & Deploy / Deploy to VM (push) Successful in 1m49s
Build & Deploy / Report a failed deploy (push) Skipped
2026-10-05 01:44:35 -04:00
Logan CusanoandClaude Opus 5.5 5c53850cbf Discord admin Add form: say so when the system already plays in that channel (#213)
Re-adding the same system to a voice channel returns the running stream
untouched (already_on). The form closed as if a stream had been added and
the new label/bot was silently dropped; it now stays open and explains.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:38:58 -04:00
Logan CusanoandClaude Opus 5.5 8af410aede Discord: several radio streams per voice channel; adding never overwrites (#213)
Owner-reported on live: adding a radio channel for a server + voice channel
that already had one (different label, system, even bot) overwrote the
existing stream and made its bot leave, because stream_id was guild:channel.

- New streams get guild:channel:<12 hex> ids (leases.new_stream_id); old
  guild:channel docs keep working for read/stop/remove/node reports. Route
  validators accept both (leases.STREAM_ID_PATTERN).
- start_stream: no stream_id = ADD. Same system already on in that channel
  is idempotent (already_on, label/mode untouched); a stopped one of that
  system is restarted; anything else is a new doc. stream_id = explicit
  restart/edit (admin Start button, reconciler), incl. moving to another bot.
- preferred_token_id is strict: bot already in this server -> 409
  preferred_busy naming the stream; on another node -> preferred_other_node;
  unusable -> preferred_unavailable. No silent swap. pool_exhausted now says
  one-bot-per-server and to add + invite another bot.
- One-token-per-guild was already enforced inside the lease transaction;
  now covered by race tests. Per-guild asyncio lock serialises the
  already-on / server-cap check with the create (guild half of #223).
- Stop by stream_id (POST /discord/guilds/{id}/stop {stream_id}); admin UI
  Stop/Start/Remove act on the row's stream; node picker lists every stream
  in a channel and adds beside other systems.
- Bot: /leave stream: (autocomplete) stops one; /leave channel: with several
  streams asks which unless all:True; bare /leave still stops the server.
  /status and /join name each stream's bot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:30:40 -04:00
logan 2d8525678e Merge pull request 'Nodes: Update All Nodes button (fleet-wide update request)' (#226) from feat/update-all-nodes into main
Build & Deploy / Build & push images (push) Successful in 4m15s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 28s
Build & Deploy / Deploy to VM (push) Successful in 1m52s
Build & Deploy / Report a failed deploy (push) Skipped
2026-10-05 00:29:57 -04:00
Logan CusanoandClaude Opus 5.5 f18f7a49f6 Nodes: Update All Nodes — fleet-wide update request for every approved node
POST /nodes/request-update-all (admin) does what Update Node does for each
approved node: flags update_requested_at for the HTTPS updater and nudges it
over MQTT. Pending and rejected nodes are skipped. Admin-only button on the
Nodes page reports which nodes weren't reachable over MQTT.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:29:30 -04:00
logan 16a609e35b Merge pull request 'Discord: log node-reported stream state; remove a radio channel or a server (#213, #221)' (#224) from feat/discord-stream-logging-remove into main
Build & Deploy / Build & push images (push) Successful in 4m45s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 33s
Build & Deploy / Deploy to VM (push) Successful in 1m39s
Build & Deploy / Report a failed deploy (push) Skipped
Reviewed-on: #224
2026-10-04 22:55:29 -04:00
Logan CusanoandClaude Opus 5.5 25553fbac6 Discord remove server: audit a removal a restart interrupted
DELETE /discord/guilds/{id} deletes channels one by one. If a channel was
restarted mid-removal, delete_stream raised "restarted" and the route
returned 409 with no audit entry, though the channels before it were
already deleted for good. The error now carries the channels already
removed and the route writes discord.guild.remove with outcome
"incomplete" before returning the 409. The server stays linked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 22:48:30 -04:00
Logan CusanoandClaude Opus 5.5 c92bdfe841 Discord: log node-reported stream state; remove a radio channel or a server
- handle_actual logs every node-reported state TRANSITION (not heartbeats)
  with stream/node/guild/channel/token ids and the node's error text: info
  for connected/connecting/stopped, warning for error, and a dedicated
  "radio bot X failed to log in on node N: <reason>" line for login_failed.
  The node's reason code is now stored in actual.code and shown next to the
  error on Settings > Discord and the node page.
- Remove a radio channel permanently: streams.delete_stream stops it through
  desired state first (node republished without it, token -> releasing per
  #216, freed on the node's ack), then deletes the doc in a transaction that
  refuses if the channel was restarted in between. DELETE
  /discord/streams/{id} (platform admin) and DELETE
  /discord/nodes/{node}/streams/{id} (admin or org owner, same org + node
  only; operators can still stop, not remove). Audited.
- Remove a server: DELETE /discord/guilds/{id} (admin) deletes every radio
  channel in it the same way, then tombstones discord_guilds (org_id None +
  unlinked_at, #218 semantics, plus removed_at) so it never falls back to
  DISCORD_UNLINKED_GUILD_ORG_ID; the admin page hides it, and /drb link or the
  admin link brings it back. Optional leave_radio_bots makes pool bots and the
  server's org's own BYO bots leave via Discord REST; the DRB server bot stays
  until kicked, and the UI says so. Audited.
- UI: Remove (with confirm) on Radio channels, Servers and the node page list.

Refs #213, #221

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 22:40:43 -04:00
logan fabdf5839e Merge pull request 'Node page Discord picker: org's servers + voice channels, not raw IDs (#213 item 3)' (#220) from feat/213-node-join-picker into main
Build & Deploy / Build & push images (push) Successful in 4m58s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 29s
Build & Deploy / Deploy to VM (push) Successful in 2m37s
Build & Deploy / Report a failed deploy (push) Skipped
Reviewed-on: #220
2026-10-04 22:05:49 -04:00
logan ebd7e893ce Merge pull request 'Discord admin page + hide admin-only bot pool from operators (#213 items 2, 4)' (#219) from feat/213-discord-admin into main
Build & Deploy / Build & push images (push) Successful in 5m1s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 5s
Build & Deploy / Deploy to VM (push) Successful in 7s
Build & Deploy / Report a failed deploy (push) Skipped
Reviewed-on: #219
2026-10-04 22:05:36 -04:00
logan dbb7d7f944 Merge pull request 'Discord: self-serve server→org linking with /drb link (#213 item 1)' (#218) from feat/213-guild-link into main
Build & Deploy / Build & push images (push) Successful in 5m0s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 5s
Build & Deploy / Deploy to VM (push) Successful in 12s
Build & Deploy / Report a failed deploy (push) Skipped
Reviewed-on: #218
2026-10-04 22:04:31 -04:00
Logan CusanoandClaude Opus 5.5 d2437e3a0b Picker review fixes: validate stream_id on stop; accurate offline + no-system copy
- POST /discord/nodes/{id}/streams/stop: stream_id must be guild:channel
  snowflakes, so a malformed id is a 422 instead of a Firestore path error.
- Offline footer said channels resume on reconnect; ad-hoc ones stop once
  the node is gone past the lease grace (reconcile_once). Say so.
- Picker: say why Start is disabled when the node has no system.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:36:30 -04:00
Logan CusanoandClaude Opus 5.5 31ca63b2a0 Discord admin overview: no fallback org for explicitly unlinked servers
PR #218 (server-26#213 item 1) unlinks a server by clearing org_id and
stamping unlinked_at, and resolve_guild_org then refuses the fallback org.
The overview showed such a server as "Unlinked · fallback org" and offered it
in Add radio channel, where start_stream would 403. Mirror the rule here
(forward-compatible: unlinked_at does not exist on main yet). Also key each
stream row by the resolved guild id, and drop panel copy that pointed at a
"Needed" figure the page doesn't show / misstated the invite permissions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:35:48 -04:00
Logan CusanoandClaude Opus 5.5 e86507cd2d fix(discord): non-owners no longer see a stuck "Loading…" on the servers card (#213)
The card only loads for owners/admins (the list endpoint is owner-only), so a
member saw "Loading…" forever. Show an owner-only note instead. Also assert the
one-server/two-org race really retried, as the PR body already claimed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 20:11:27 -04:00
Logan CusanoandClaude Opus 5.5 2457d2d734 Move picker router/client additions off PR #218's lines (no merge conflict)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 20:05:56 -04:00
Logan CusanoandClaude Opus 5.5 d3277c13e1 Discord admin page: servers, radio channels, radio bots (server-26#213 item 2)
Frontend, built from design-canvas/discord-system-mockup.html ("DRB admin"):
- Settings > Discord (/settings/discord, platform admin only; the tab is
  hidden from org owners because every route behind it is admin-only).
  KPIs + tabs:
  * Radio channels: every discord_streams doc, desired -> actual, the node's
    real error, red after 2 min desired-on without connecting. Stop
    (POST /discord/guilds/{id}/stop), Start for a stopped channel and
    "+ Add radio channel" (POST /discord/streams), with the invite links a
    "no bot in this server" refusal carries.
  * Servers: linked servers plus servers seen running radio while unlinked
    (acting for DISCORD_UNLINKED_GUILD_ORG_ID); Link / Change org
    (PUT /discord/guilds/{id}/link).
  * Radio bots: valid / check_error, guild membership with a per-server
    invite link for each server the bot is missing from, lease (node,
    since, releasing), fan-out, Refresh (POST /tokens/{id}/refresh),
    Remove, Release all leases (POST /tokens/flush), add with optional BYO org.
  * How it works.
- /tokens now renders the same radio-bot table (new token fields + refresh).
- Left out, no backend yet: alert routes, incident forum/roles/plan columns,
  per-channel talkgroup chips, "Generate link code" (item 1).

Backend (routers/discord.py):
- GET /discord/admin/overview (platform admin): guilds, streams with node
  and bot names, orgs, systems, limits. No token strings, no Discord user
  ids, no linking-admin uid.
- PUT /discord/guilds/{id}/link: digits-only id, optional guild_name label,
  and re-linking a server to a different org stops its live channels
  (the reconciler would otherwise keep re-placing them on the old org's
  nodes, using its stored org_id).
- POST /discord/streams: guild_id/channel_id must be digits (the web form
  types them in; the bot always sent snowflakes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 20:05:35 -04:00
Logan CusanoandClaude Opus 5.5 a2c840f186 Picker: share PR #218's guild_name field and its no-fallback-after-unlink rule
A server an org explicitly unlinked (unlinked_at) must not fall back to the
default org in the picker either, or the founding org could stream into it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 20:03:44 -04:00
Logan CusanoandClaude Opus 5.5 62e6d69bfa Node page Discord picker: org's linked servers + voice channels, not raw IDs (#213 item 3, #109 item 13)
- c2-core routers/discord_web.py:
  PUT  /discord/guilds/{id}/directory (service key): the server bot pushes each
       server's name + voice channels; merged onto discord_guilds, never touches org_id.
  GET  /discord/nodes/{id}/join-options: the node org's servers (same
       resolve rule as the bot), their voice channels with any live stream,
       radio-bot readiness + invite links, and this node's streams. No tokens.
  POST /discord/nodes/{id}/streams: stream pinned to the node; server must be
       the node org's, channel must be in the bot's directory, guild + node caps.
  POST /discord/nodes/{id}/streams/stop: one stream on this node, or all.
  Control = platform admin, org owner, or operator owning the node.
- nodes.py: legacy raw-ID discord_join now refuses a server outside the node's org.
- discord bot: DirectorySync cog pushes on ready (15-min loop), join/leave,
  rename, voice/category channel changes (debounced).
- frontend: NodeDiscordRadio panel + picker replaces DiscordJoinModal and the
  admin-only Join/Leave buttons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 20:02:16 -04:00
Logan CusanoandClaude Opus 5.5 455c55f5c2 feat(discord): self-serve server->org linking with /drb link (#213 item 1)
- c2-core: POST /discord/link-codes (org owner/admin) mints a single-use,
  10-minute code; minting revokes the org's older codes. The bot redeems it
  via POST /discord/guilds/{id}/link/redeem (service key) inside one
  Firestore transaction, so two servers racing for one code cannot both win
  and a server linked to org A refuses org B's code. GET/DELETE
  /discord/linked-guilds lists/unlinks the caller's org's servers; unlink
  stops that server's streams. Rate-limited (mint per user, redeem per
  server and per Discord user); every mint/link/refusal/unlink audited.
- resolve_guild_org: an explicitly unlinked server no longer falls back to
  DISCORD_UNLINKED_GUILD_ORG_ID. Linking away from the fallback org stops
  the streams it had running for that org.
- Discord bot: /drb link code: (Manage Server, enforced at runtime too);
  GatedTree answers every other command in an unlinked server with how to
  link (autocomplete returns nothing). With the default fallback org set,
  nothing is gated — unchanged behaviour.
- Frontend: "Linked Discord servers" card on Settings -> Organization —
  generate code with countdown + copy, polls for the new link, unlink.
- 16 tests: single-use, two-server race, one-server/two-org race, expiry,
  revoked/malformed codes, wrong-org takeover, idempotent relink, rate
  limits, fallback stop, scoped list, unlink without fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 20:01:25 -04:00
Logan CusanoandClaude Opus 5.5 e574bd924a frontend: hide radio-bot pool controls from non-admins (server-26#213 item 4)
GET /tokens and PUT /tokens/{id}/prefer became platform-admin only in #216
(server-26#204 item 6). Three frontend surfaces still offered them to
operators:

- systems page "Preferred Bot Token" panel: now rendered for admins only.
  No non-admin listing endpoint was added: the write behind the picker is
  admin-only too, so a list would only feed a control that 403s on save.
  The panel also no longer turns a fetch error into "No tokens in pool."
- /tokens page: admin-only (operators are sent to /network).
- /network hub: the Bot Tokens card is shown to admins only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:57:14 -04:00
logan dc1df72c86 Merge pull request 'deploy.yml: a superseded run is a skip, not a failure (#167)' (#217) from fix/167-superseded-exit into main
Build & Deploy / Build & push images (push) Successful in 4m13s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 29s
Build & Deploy / Deploy to VM (push) Successful in 1m49s
Build & Deploy / Report a failed deploy (push) Skipped
Reviewed-on: #217
2026-10-04 18:41:31 -04:00
Logan CusanoandClaude Opus 5.5 73d692109c deploy.yml: a superseded run is a skip, not a failure (server-26#167)
The remote 'exit 0' came back as ssh exit 1 on the VM, and under bash -e
the step died before the SUPERSEDED check ran (runs 1139, 1144). Capture
the ssh status, check SUPERSEDED first, then fail on a non-zero status.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:40:27 -04:00
logan 99407c1df0 Merge pull request 'Discord radio channels: transactional token leases + desired-state streams (#203, #204)' (#216) from feat/discord-system into main
Build & Deploy / Build & push images (push) Successful in 4m26s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 27s
Build & Deploy / Deploy to VM (push) Successful in 4m7s
Build & Deploy / Report a failed deploy (push) Skipped
Reviewed-on: #216
2026-10-04 18:34:09 -04:00
Logan CusanoandClaude Opus 5.5 e3c68738e8 Merge origin/main into feat/discord-system (keep cameras + discord routers)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:32:26 -04:00
logan 8869d51557 Merge pull request 'deploy.yml: serialise prod deploys and skip superseded runs (#167)' (#210) from fix/167-deploy-concurrency into main
Build & Deploy / Build & push images (push) Successful in 5m14s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 4s
Build & Deploy / Deploy to VM (push) Failing after 21s
Build & Deploy / Report a failed deploy (push) Successful in 2s
Reviewed-on: #210
2026-10-04 18:29:31 -04:00
logan e9246510ea Merge branch 'main' into fix/167-deploy-concurrency 2026-10-04 18:29:21 -04:00
logan a49b814646 Merge pull request 'Explicit timeouts on every OpenAI and Gemini request (#153)' (#209) from fix/153-ai-request-timeouts into main
Build & Deploy / Build & push images (push) Successful in 5m12s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 37s
Build & Deploy / Deploy to VM (push) Successful in 3m25s
Build & Deploy / Report a failed deploy (push) Skipped
Reviewed-on: #209
2026-10-04 18:29:09 -04:00
logan 8d9fe01828 Merge branch 'main' into fix/153-ai-request-timeouts 2026-10-04 18:29:00 -04:00
logan 30bca25ecc Merge pull request 'Live ops wall + pluggable camera providers with licence gating (server-26#198)' (#208) from feat/live-ops-wall into main
Build & Deploy / Build & push images (push) Successful in 5m7s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 39s
Build & Deploy / Deploy to VM (push) Failing after 1m7s
Build & Deploy / Report a failed deploy (push) Successful in 2s
Reviewed-on: #208
2026-10-04 18:28:49 -04:00
logan 1d7f4af42f Merge branch 'main' into feat/live-ops-wall 2026-10-04 18:28:36 -04:00
logan a03ec63846 Merge pull request 'Reject revokes the node's MQTT login and API key (#195)' (#206) from fix/195-reject-revokes-credentials into main
Build & Deploy / Build & push images (push) Failing after 1m7s
Build & Deploy / Deploy to VM (push) Skipped
Build & Deploy / Deploy Firestore rules & indexes (push) Skipped
Build & Deploy / Report a failed deploy (push) Successful in 1s
Reviewed-on: #206
2026-10-04 18:28:09 -04:00
Logan CusanoandClaude Opus 5.5 03ba7eacd9 Discord leases: free releasing tokens on gone nodes; stamp before building desired list
- reconcile_once only walked desired=on streams, so a token left "releasing"
  by /leave while its node was offline (or a deleted node) never returned to
  the pool until that node came back (server-26#204 leak shape). Sweep leased
  tokens whose node is offline/deleted past the lease grace.
- publish_desired stamped release_seq AFTER building the list, so a stop
  landing between the two got a seq whose list still held the stream; an ack
  for it could free a token the node was still told to use. Stamp first.

Three regression tests; each fails on the previous commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:16:31 -04:00
Logan CusanoandClaude Opus 5.5 06f01ebbe0 deploy.yml: run the superseded check after the #129 pre-deploy prune (server-26#167)
The tip check's git fetch ran before the disk-relief prune that #129 says
must be the first thing a deploy does; on a full disk the fetch could fail
and skip the prune. Prune only removes unreferenced images, so running it
on a run that then turns out superseded is harmless.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:07:08 -04:00
Logan CusanoandClaude Opus 5.5 2f21728bce Cameras: redact API keys from feed errors; gate TfL/Calgary; clamp map bbox
- Feed/catalog error text (shown to every signed-in user via GET /cameras and
  /traffic/511 status fields) carried httpx's full request URL, including
  ?key= for Ontario 511 and the 511NY DAA key. Query strings are now dropped.
- TfL and Calgary licences are per GEV only (TfL's terms page 403'd), the same
  footing as Ontario 511, so they are UNVERIFIED (acknowledgement required)
  instead of ungated attribution_required. Attribution still renders.
- The map camera layer clamps its bbox to +/-180/+/-90; zoomed-out bounds
  produced a 422 ("could not reach server").

Refs server-26#198

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:06:47 -04:00
Logan CusanoandClaude Opus 5.5 a31a8df17c Discord radio channels: wire leases + desired-state streams (server-26#203, #204)
c2-core
- discord_streams/discord_leases wired in: POST /discord/streams,
  POST /discord/guilds/{id}/stop, GET /discord/guilds/{id}/overview,
  GET /discord/streams/{id}, PUT /discord/guilds/{id}/link (admin),
  GET /discord/node/{node}/tokens/{id} (node key, lease holder only).
- Retained nodes/{id}/discord/desired (token ids, never token strings),
  nodes/+/discord/actual subscribed and handled; per-node publish lock keeps
  the retained list the newest; failed publishes retried by the reconciler.
- Tokens freed only after the node acks logout (or offline past
  discord_lease_offline_release_seconds) - sweeper and C2 startup no longer
  release leases.
- /nodes/{id}/command discord_join/leave map onto streams; CommandPayload
  keeps preferred_token_id. /tokens: admin-only list, validate on add against
  Discord, refresh, flush via transactional force release.
- Guild -> org via discord_guilds, unlinked guilds fall back to
  DISCORD_UNLINKED_GUILD_ORG_ID (default "founding").
- tests/fake_firestore.py: optimistic-transaction fake; 27 lease/race tests.

discord bot
- /join, /leave, /status, autocomplete scoped to the calling guild; /leave
  never touches another server; Manage Channels default permission;
  /join edits its reply with the node's real result; /help copy fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:05:26 -04:00
Logan CusanoandClaude Opus 5.5 71ee6101a1 deploy.yml: serialise prod deploys and skip superseded runs (server-26#167)
Two merges minutes apart ran two "Deploy to VM" jobs on the VM at once
(616/617, 652/656): racing compose pull/up, one run's prune breaking the
other's retry, health checks judging the wrong containers.

- deploy job: concurrency group drb-prod-deploy, cancel-in-progress false
  (Gitea >= 1.26 honours it, go-gitea/gitea#32751; server is 1.27.3).
- Deploy step exits clean, before touching the VM, when its SHA is no
  longer origin/main's tip; Health check is skipped for a superseded run.
  Stops an older run landing after a newer one and rolling prod back.
- deploy-firestore-rules: its own group plus the same tip check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:01:53 -04:00
Logan CusanoandClaude Opus 5.5 278edf48dc Explicit timeouts on every OpenAI and Gemini request (#153)
No AI client set a timeout: OpenAI's SDK waits 600s per attempt and retries
twice (timeouts included); google-genai waits forever. A hung provider call
had no upper bound, so recorrelation_sweep's 15-minute pipeline hold-back was
a guess against an unbounded tail.

- config: stt_timeout_seconds (120), openai_timeout_seconds (60),
  openai_max_retries (1), gemini_timeout_seconds (60).
- app/internal/ai_clients.py builds every OpenAI/AsyncOpenAI client with them
  (transcription, extraction, embeddings, summarizer, vocabulary learner,
  trip chat). gemini.py's shared client gets HttpOptions(timeout=ms).
- A timeout raises like any other provider error, so existing fallbacks are
  unchanged: rules-only correlation, rules decision on tiebreak, raw
  transcript on correction, ai_health "transient".
- MIN_MINUTES_SINCE_PIPELINE_START is now derived from those timeouts
  (pipeline_worst_case_seconds, 3-scene budget + 2 min non-AI slack), floor
  15: 21 min with the defaults. Loop warns if it reaches the scan window.
- tests/test_ai_timeouts.py: client limits, a real hung-socket request that
  gives up in <5s, timeout fallback per tier, the derived bound, and a guard
  against any new OpenAI( outside ai_clients.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:00:25 -04:00
Logan CusanoandClaude Opus 5.5 73147803ea Reject revokes the node's MQTT login and API key (server-26#195)
reject_node only set approval_status, so an approved-then-rejected node kept
its dynsec login, its /upload key and /systems read access.

- reject_node: status -> rejected first (fail-closed), then
  dynsec.delete_node_client, then delete node_keys. dynsec failure -> 502,
  key kept, retry is idempotent. Re-approve mints a fresh key.
- upload.py: valid key alone is not enough; node must be approved (403),
  uncached read so revocation bites immediately.
- dynsec.reconcile_all: deletes rejected nodes' logins on startup, healing
  nodes rejected before this change or whose revoke failed.
- Reject confirm text says it revokes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:58:51 -04:00
Logan CusanoandClaude Opus 5.5 e6b1581139 Live wall: pinned/docked cameras stop when their provider is switched off; admin-only empty-state CTA
- LiveView: localStorage pins carry image URLs, so they rendered (without
  credit) after an admin disabled the provider. Pins now show only while
  their provider is serving (server-26#198 licence gating).
- MapView: pinned/docked map players drop out once their provider leaves
  the serving list; credits are kept from the last answer so docked players
  keep attribution while the overlay is hidden.
- WallPanels: the "no camera provider enabled" link to /admin was a dead CTA
  for non-admins (bounced to /). Link shown to admins only, deep-linked to
  /admin#cameras; the admin page now honours a tab hash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:58:49 -04:00
Logan CusanoandClaude Opus 5.5 af00139079 WIP: list credits for all serving providers; pin 511NY imagery to https
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:56:09 -04:00
Logan CusanoandClaude Opus 5.5 aa5b71d853 Live ops wall + pluggable camera providers with licence gating (server-26#198)
Backend (c2-core):
- app/internal/cameras/: provider interface, licence model, per-provider
  catalog cache; one adapter per source in design-canvas/camera-sources.md.
  Implemented: 511NY (legacy shape + tolerant XML inventory parser, 511x
  endpoints UNVERIFIED), Caltrans, DelDOT, Ontario 511, DriveBC, Calgary,
  TfL, NSW, Fintraffic. Stubs: NYC DOT, Westchester DPW, TRANSCOM, CTroads,
  511NJ, Austin, TxDOT, MD CHART, WSDOT, Tallinn, Tarktee, Warendorf,
  TrafficLand, Windy, EarthCam.
- Every provider defaults OFF (config/camera_providers). Gated licences
  (agreement required / unverified / non-commercial / paid) need an explicit
  acknowledgement stored with who/when and written to audit_log; the audit
  write is a precondition. Stubs and prohibited sources cannot be enabled.
  Ack is bound to the licence status it was given for.
- GET /cameras (signed-in), GET/PUT /admin/camera-providers (admin only).
- /traffic/511 follows the ny511 provider switch and returns the DAA credit
  + NYSDOT as-is disclaimer; 511NY event descriptions no longer truncated
  ("may not alter the substance of a report").

Frontend:
- LiveView becomes the ops wall: Region > Incident > Camera drill-down,
  worst-first rail (MapView), KPIs, transcript ticker, scrubber incident
  bands, auto-tour, deep links (#inc=&cam=&tour), Esc steps up.
- Incident panel: nearest cameras (path-weighted), fused situational context
  (ADS-B, AIS, 511NY events, NEXRAD tile sample), units, call spine.
- MapView keeps every layer; adds Camera Viewsheds (approx.) overlay and a
  nearest-camera layer for the selected incident.
- CameraFeed hygiene: hidden-tab/off-screen gating, 2 concurrent HLS cap,
  last good frame + stale badge, frozen-frame signature, HLS stall watchdog,
  provider attribution rendered on the imagery.
- Camera/event popups show source credit, update time and the disclaimer.
- Admin > Camera providers tab with licence status, health, gated confirm.
- Camera detection is not built (blocked on #199/#200); seam only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:56:06 -04:00
Logan CusanoandClaude Opus 5.5 0a724a064f WIP: discord system — transactional token leases + desired-state streams (c2-core, unwired)
Paused by owner instruction; not wired into routers/mqtt yet, untested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:55:09 -04:00
logan d57738a8f6 Merge pull request 'Systems: P25 trunked / conventional / analog mode + channel editor (node-26#24)' (#205) from feat/24-p25-conventional into main
Build & Deploy / Build & push images (push) Successful in 4m16s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 29s
Build & Deploy / Deploy to VM (push) Successful in 1m53s
Build & Deploy / Report a failed deploy (push) Skipped
Reviewed-on: #205
2026-10-04 17:24:29 -04:00
Logan CusanoandClaude Opus 5.5 23860eb31b Systems: P25 trunked / P25 conventional / analog as one choice (node-26#24)
- system_config.normalize(): a P25 system with config.mode "conventional"
  carries config.channels [{freq MHz, name, nac?}] (1-2, within 1.5 MHz) and
  can no longer carry control channels (422). A missing mode is trunked and
  passes through untouched, so there is no migration. An analog channel is
  mirrored into control_channels for older nodes.
- talkgroups.name_from_system resolves the edge node's synthetic per-channel
  id (freq in 100 Hz units) to the channel name.
- The checkin persists radio_channels (conventional channels + encryption).
- Systems editor: Type = P25 trunked / P25 conventional / Analog (NBFM) /
  DMR, plus a channel-row editor for conventional and a one-frequency form for
  analog. The node page shows conventional channels and an encryption notice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:55:34 -04:00
logan ae0ece9d2c Merge pull request 'approve_node never rotates an existing key; node update-check endpoint; real Update Node button (node-26#14, #15)' (#194) from fix/node-14-15-update-endpoint into main
Build & Deploy / Build & push images (push) Successful in 4m25s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 30s
Build & Deploy / Deploy to VM (push) Successful in 4m11s
Build & Deploy / Report a failed deploy (push) Skipped
Merge #194: approve reuses existing key; node update-check + request-update endpoints (node-26#14, #15)
2026-10-04 01:17:32 -04:00
Logan CusanoandClaude Opus 5.5 7fa50f24d2 approve_node never rotates an existing key; node update-check endpoint; real Update Node button (node-26#14, #15)
- approve_node: reuses node_keys/{id}.api_key when one exists (still
  re-syncs dynsec with it, still never overwrites node_keys). Re-clicking
  Approve can no longer lock a live node out. Reissue stays explicit.
- POST /nodes/{id}/request-update (admin): sets update_requested_at on the
  node doc AND sends MQTT node_update best-effort, so offline/locked-out
  nodes still get it.
- POST /nodes/{id}/update-check (node, inline auth: Bearer api_key for THAT
  node or X-Pickup-Secret): host updater's HTTPS check-in. Returns only
  update_requested_at; records updater_last_check_at / sha / applied result.
  Unknown node -> 401 (no node_id oracle). C2 cannot name a ref, image or
  command.
- Frontend: "Update Node Software" (admin only) calls request-update, honest
  confirm text; node page shows updater check-in and last update state.

Tests: tests/test_node_provisioning.py (12).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:09:52 -04:00
Logan CusanoandClaude Opus 5.5 fb299008f8 CameraFeed: recover from HLS blips, retry video instead of sticking on the still
Build & Deploy / Build & push images (push) Successful in 4m12s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 27s
Build & Deploy / Deploy to VM (push) Successful in 1m35s
Build & Deploy / Report a failed deploy (push) Skipped
Any fatal hls.js error set videoFailed permanently, so a brief 511NY feed
drop left the tile on a 60s snapshot until it was reopened. Now fatal
media/network errors get up to 3 in-place recoveries, and a tile that did
fall back retries video every 30s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:06:58 -04:00
logan e2364096f1 Merge pull request 'Correlator prompt: incident age/size + new-event rule (#189)' (#191) from fix/189-tiebreak-sink into main
Build & Deploy / Build & push images (push) Successful in 5m2s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 29s
Build & Deploy / Deploy to VM (push) Successful in 2m13s
Build & Deploy / Report a failed deploy (push) Skipped
Reviewed-on: #191
2026-09-27 22:56:17 -04:00
Logan CusanoandClaude Opus 5.5 e009090418 Correlator prompt: show incident age/size, stop unit overlap chaining new jobs (#189)
The tiebreaker forced ~all of the 09-27 rollover sink's bad links: idle resets
on every link so a 2.5h, 40-call incident read as fresh, and the rules treated
unit overlap as enough on a county channel where units work jobs back to back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 19:43:21 -04:00