Compare commits

...
2 Commits
Author SHA1 Message Date
legop3 6bfbb8ad80 replay beeps and boops and flashings
Container image / image (push) Failing after 1m3s
2026-10-07 14:31:54 -04:00
legop3 ba2f66ebc1 add react wrapper thingy to find crashings 2026-10-07 13:38:56 -04:00
11 changed files with 154 additions and 110 deletions
@@ -1,6 +1,6 @@
// Discord Replay Command
// Purpose: Handles replay capture requests from Discord through the shared replay job/status workflow.
// Scope: Resolves sources, enforces cooldowns, reports job progress, uploads video, and broadcasts media URLs.
// Scope: Resolves sources, enforces replay availability, reports job progress, uploads video, and broadcasts media URLs.
const { AttachmentBuilder } = require('discord.js');
const io = require('../../../globals/io');
const { hostReplay } = require('../../replayMediaService');
@@ -60,12 +60,6 @@ function createReplayCommand({
return;
}
const attempt = tryTriggerReplay({ by: message.author?.id || null, source: 'discord' });
if (!attempt.ok) {
await replyDenied(message, `Replay denied: cooldown active. Try again in ${Math.ceil(attempt.remainingMs / 1000)}s.`);
return;
}
const requester = message.member?.nickname || message.author?.globalName || message.author?.username || 'Discord';
const job = createReplayJob({
id: buildReplayJobId('discord'),
@@ -74,17 +68,23 @@ function createReplayCommand({
sources: resolved.sources || [],
includeSidebar: true,
});
jobStatus.emit(job, 'accepted', { message: buildAcceptedMessage(job) });
const progressMessage = await message.reply({
content: sanitizeMentions(buildAcceptedMessage(job)),
allowedMentions: DEFAULT_ALLOWED_MENTIONS,
});
const stopTyping = startDiscordTypingLoop(message.channel, logger, 'discord replay command');
const attempt = tryTriggerReplay({ jobId: job.id, by: { source: 'discord', requester } });
if (!attempt.ok) {
await replyDenied(message, `Replay denied: ${attempt.error}`);
return;
}
let progressMessage = null;
let stopTyping = () => {};
let builtReplay = null;
let deliveredMedia = null;
try {
jobStatus.emit(job, 'accepted', { message: buildAcceptedMessage(job) });
progressMessage = await message.reply({
content: sanitizeMentions(buildAcceptedMessage(job)),
allowedMentions: DEFAULT_ALLOWED_MENTIONS,
});
stopTyping = startDiscordTypingLoop(message.channel, logger, 'discord replay command');
jobStatus.emit(job, 'building', { message: buildStatusMessage(job, 'building') });
if (progressMessage?.edit) {
await progressMessage.edit({ content: sanitizeMentions(buildStatusMessage(job, 'building')), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
@@ -35,9 +35,9 @@ async function deliverReplay(payload = {}) {
requestedBy: payload.requestedBy,
});
jobStatus.emit(job, 'accepted', { message: buildAcceptedMessage(job) });
let providerContext = null;
try {
jobStatus.emit(job, 'accepted', { message: buildAcceptedMessage(job) });
let providerError = null;
if (preferredDeliveryProvider?.begin) {
try {
@@ -2,6 +2,7 @@
// Purpose: Provides transport-neutral replay jobs, source lookup, status events, and user-facing progress text.
// Scope: Keeps Discord-command and web-triggered replay delivery on the same core status pipeline.
const Fuse = require('fuse.js');
const { updateReplayStatus } = require('../replayEngineV2/cooldown');
const DEFAULT_ALLOWED_MENTIONS = { parse: [], repliedUser: false };
const FUZZY_THRESHOLD = 0.42;
@@ -180,6 +181,22 @@ function createJobStatusEmitter({ io, logger, sanitizeMentions }) {
function emit(job, status, extra = {}) {
const payload = buildPayload(job, status, extra);
updateReplayStatus(payload);
if (status === 'accepted') {
// Sound once per participating rover; a disconnected rover must not abort video creation.
const { issueCommand } = require('../commandService');
const roverIds = new Set(job.sources.filter((entry) => entry.type === 'rover').map((entry) => entry.id));
for (const roverId of roverIds) {
try {
issueCommand(roverId, {
type: 'song',
song: { notes: [{ note: 72, duration: 8 }, { note: 76, duration: 8 }, { note: 79, duration: 16 }] },
});
} catch (err) {
logger?.warn?.('Unable to play replay tune', { roverId, error: err.message });
}
}
}
io.emit('replay:status', payload);
if (status === 'ready' && extra.media) io.emit('replay:ready', extra.media);
if (status === 'failed') io.emit('replay:failed', payload);
+43 -22
View File
@@ -4,40 +4,61 @@
const EventEmitter = require('events');
const COOLDOWN_MS = 10 * 1000;
const MAX_CONCURRENT_JOBS = 3;
const replayEvents = new EventEmitter();
const activeJobs = new Map();
let status = null;
let lastTriggeredAt = null;
let lastTriggeredBy = null;
function getRemainingMs(now = Date.now()) {
if (!lastTriggeredAt) return 0;
return Math.max(0, COOLDOWN_MS - (now - lastTriggeredAt));
}
function getReplayState() {
const remainingMs = getRemainingMs();
const remainingMs = lastTriggeredAt === null ? 0 : Math.max(0, COOLDOWN_MS - (Date.now() - lastTriggeredAt));
return {
cooldownMs: COOLDOWN_MS,
lastTriggeredAt,
lastTriggeredBy,
remainingMs,
available: remainingMs === 0,
atJobLimit: activeJobs.size >= MAX_CONCURRENT_JOBS,
activeSourceKeys: [...new Set(Array.from(activeJobs.values()).flatMap((sources) =>
sources.map((source) => `${source.type}:${source.id}`),
))],
status,
available: remainingMs === 0 && activeJobs.size < MAX_CONCURRENT_JOBS,
};
}
function tryTriggerReplay(by = null) {
const remainingMs = getRemainingMs();
if (remainingMs > 0) {
return { ok: false, remainingMs, state: getReplayState() };
}
lastTriggeredAt = Date.now();
lastTriggeredBy = by || null;
const state = getReplayState();
replayEvents.emit('update', { state, by: lastTriggeredBy });
return { ok: true, state };
function emitUpdate() {
replayEvents.emit('update', { state: getReplayState(), by: lastTriggeredBy });
}
module.exports = {
tryTriggerReplay,
getReplayState,
replayEvents,
};
function tryTriggerReplay({ jobId, by = null } = {}) {
const state = getReplayState();
if (state.atJobLimit) {
return { ok: false, error: 'Replay limit reached: 3 jobs are already being made', state };
}
if (state.remainingMs > 0) {
return { ok: false, error: `Replay cooldown active. Try again in ${Math.ceil(state.remainingMs / 1000)}s.`, remainingMs: state.remainingMs, state };
}
if (!jobId) throw new Error('Replay job id required');
activeJobs.set(jobId, []);
status = { jobId, status: 'accepted' };
lastTriggeredAt = Date.now();
lastTriggeredBy = by;
emitUpdate();
return { ok: true, state: getReplayState() };
}
function updateReplayStatus(payload) {
if (!activeJobs.has(payload.jobId)) return;
if (payload.jobId === status?.jobId) {
status = { jobId: payload.jobId, status: payload.status, title: payload.title, message: payload.message };
}
if (payload.status === 'ready' || payload.status === 'failed') {
activeJobs.delete(payload.jobId);
} else {
activeJobs.set(payload.jobId, payload.sources || []);
}
emitUpdate();
}
module.exports = { tryTriggerReplay, getReplayState, replayEvents, updateReplayStatus };
@@ -1,6 +1,6 @@
// Replay Socket Hooks
// Purpose: Registers web socket replay-trigger handler that publishes replay requests.
// Scope: Applies replay mode/cooldown/source validation for socket-triggered replay requests.
// Scope: Applies replay mode/availability/source validation for socket-triggered replay requests.
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('replaySocket');
const { getMode, MODES } = require('../modeManager');
@@ -45,9 +45,9 @@ function registerReplaySocketHooks({ tryTriggerReplay, validateSources, getDefau
const title = normalizeReplayTitle(payload?.title);
const includeSidebar = normalizeIncludeSidebar(payload?.includeSidebar);
const jobId = buildReplayJobId('web');
const attempt = tryTriggerReplay({ by: { source: 'web', requester } });
const attempt = tryTriggerReplay({ jobId, by: { source: 'web', requester } });
if (!attempt.ok) {
cb({ error: 'Replay cooldown active', remainingMs: attempt.remainingMs, state: attempt.state });
cb({ error: attempt.error, remainingMs: attempt.remainingMs, state: attempt.state });
return;
}
+1 -1
View File
@@ -459,7 +459,7 @@ balanceBoardEvents.on('change', () => {
});
replayEvents.on('update', () => {
logger.info('Replay cooldown updated; syncing all clients');
logger.info('Replay state updated; syncing all clients');
syncAll();
});
+1 -1
View File
@@ -141,7 +141,7 @@ export default function AdminOverview({ snapshot, socket, runSensitive, onSnapsh
<label className="flex max-w-md flex-col gap-0.5">
<span>Container tag</span>
<input
className="rounded border border-slate-600 bg-slate-900 px-2 py-1 text-slate-100"
className="field-input w-full"
value={containerTag ?? lifecycle?.selectedTag ?? ''}
placeholder="Enter a tag"
maxLength={128}
+51
View File
@@ -0,0 +1,51 @@
import { Component } from 'react';
export default class AppErrorBoundary extends Component {
state = { crashed: false, error: null, componentStack: '', copyStatus: '' };
static getDerivedStateFromError(error) {
return { crashed: true, error };
}
componentDidCatch(error, info) {
this.setState({ componentStack: info.componentStack || '' });
}
getDetails() {
const { error, componentStack } = this.state;
return [
String(error?.message ?? error ?? 'Unknown error'),
'JavaScript stack:',
error?.stack || 'Unavailable',
'React component stack:',
componentStack || 'Unavailable',
].join('\n\n');
}
copyDetails = async () => {
try {
await navigator.clipboard.writeText(this.getDetails());
this.setState({ copyStatus: 'Copied.' });
} catch {
// Clipboard access may be unavailable on the display device; the text
// remains selectable so the error can still be copied manually.
this.setState({ copyStatus: 'Copy unavailable. Select the details below to copy manually.' });
}
};
render() {
if (!this.state.crashed) return this.props.children;
// Keep recovery independent of providers and shared UI components: one of
// those dependencies may be the reason the application failed.
return (
<main style={{ backgroundColor: '#b2339f', minHeight: '100vh' }}>
<h1>Application crashed</h1>
<button type="button" onClick={this.copyDetails}>Copy details</button>
<button type="button" onClick={() => window.location.reload()}>Reload</button>
{this.state.copyStatus ? <p role="status">{this.state.copyStatus}</p> : null}
<pre>{this.getDetails()}</pre>
</main>
);
}
}
@@ -46,7 +46,7 @@ export default function ReplaySourcesPanel({
const assignmentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const roster = useSessionSelector((state) => state.session?.roster ?? []);
const replayState = useSessionSelector((state) => state.session?.replay || null);
const replayStatus = useSessionSelector((state) => state.replayStatus);
const replayStatus = replayState?.status;
const socket = useSocket();
const { triggerReplay } = useSessionActions();
const sources = useMemo(() => normalizeSources(replaySources || []), [replaySources]);
@@ -161,48 +161,25 @@ export default function ReplaySourcesPanel({
};
}, [sources]);
const hasReplayCooldown = Boolean(replayState?.lastTriggeredAt && replayState?.cooldownMs);
const replayCooldownEndsAt = hasReplayCooldown
? replayState.lastTriggeredAt + replayState.cooldownMs
const cooldownNow = useSharedClock(1000, Boolean(replayState?.lastTriggeredAt));
const remainingMs = replayState?.lastTriggeredAt
? Math.max(0, replayState.lastTriggeredAt + replayState.cooldownMs - cooldownNow)
: 0;
const cooldownNow = useSharedClock(1000, hasReplayCooldown);
const remainingMs = useMemo(() => {
if (!hasReplayCooldown) return 0;
/*
The button only shows whole seconds, so a shared one-second clock gives the
same useful information without each mounted replay panel owning a 250ms
interval. The exact server cooldown still decides whether the action is
accepted; this value is only the local disabled-state/display estimate.
*/
const next = replayCooldownEndsAt - cooldownNow;
return Math.max(0, next);
}, [cooldownNow, hasReplayCooldown, replayCooldownEndsAt]);
const replayDisabled = busy || mode === 'lockdown' || remainingMs > 0 || !selected.length;
const atJobLimit = replayState?.atJobLimit === true;
const replaySourceKeys = replayState?.activeSourceKeys;
const activeSourceKeys = useMemo(() => new Set(replaySourceKeys || []), [replaySourceKeys]);
const replayDisabled = busy || mode === 'lockdown' || remainingMs > 0 || atJobLimit || !selected.length;
const selectedSet = useMemo(() => {
// Checkbox rendering asks the same membership question for every source.
// A Set avoids repeated linear scans and, more importantly, gives memoized
// child lists a stable value while the selected keys have not changed.
return new Set(selected);
}, [selected]);
const replayStatusText = useMemo(() => {
if (!replayStatus?.status) return null;
const titleText = replayStatus.title ? `: ${replayStatus.title}` : '';
switch (replayStatus.status) {
case 'accepted':
return `Replay accepted${titleText}`;
case 'building':
return `Replay building${titleText}`;
case 'uploading':
return `Replay uploading${titleText}`;
case 'ready':
return `Replay ready${titleText}`;
case 'failed':
return replayStatus.message || `Replay failed${titleText}`;
default:
return replayStatus.message || `Replay ${replayStatus.status}${titleText}`;
}
}, [replayStatus]);
const replayStatusText = replayStatus?.status
? replayStatus.status === 'failed' && replayStatus.message
? replayStatus.message
: `Replay ${replayStatus.status}${replayStatus.title ? `: ${replayStatus.title}` : ''}`
: null;
const toggleKey = useCallback((key) => {
setSelected((prev) => {
return prev.includes(key) ? prev.filter((value) => value !== key) : [...prev, key];
@@ -250,13 +227,13 @@ export default function ReplaySourcesPanel({
current desktop sidebar. They only collapse when the card becomes
narrower than the supported new-drive desktop column. */}
<div className={`grid grid-cols-2 gap-0.5 @max-[15rem]:grid-cols-1 ${listWrapClass}`}>
<GroupList title="Rovers" items={grouped.rovers} selectedSet={selectedSet} onToggle={toggleKey} />
<GroupList title="Room Cams" items={grouped.rooms} selectedSet={selectedSet} onToggle={toggleKey} />
<GroupList title="Rovers" items={grouped.rovers} selectedSet={selectedSet} activeSourceKeys={activeSourceKeys} onToggle={toggleKey} />
<GroupList title="Room Cams" items={grouped.rooms} selectedSet={selectedSet} activeSourceKeys={activeSourceKeys} onToggle={toggleKey} />
</div>
<div className="space-y-0.5">
{error ? <div className="text-xs text-amber-400">{error}</div> : null}
{replayStatusText ? (
<div className={`text-xs ${replayStatus?.status === 'failed' ? 'text-amber-400' : 'text-emerald-300'}`}>
<div className={`text-xs ${replayStatus.status === 'failed' ? 'text-amber-400' : 'text-emerald-300'}`}>
{replayStatusText}
</div>
) : null}
@@ -279,11 +256,6 @@ export default function ReplaySourcesPanel({
saveSettings((current) => ({ ...(current || {}), [titleSettingKey]: next }));
}}
onKeyDown={(event) => {
// Enter is the keyboard equivalent of clicking Replay. Ignore
// composition events so confirming an IME candidate cannot
// accidentally submit a replay before the title is complete.
// handleReplay remains the single authority for cooldown,
// lockdown, busy, and empty-source checks.
if (event.key !== 'Enter' || event.nativeEvent?.isComposing) return;
event.preventDefault();
handleReplay();
@@ -314,7 +286,7 @@ export default function ReplaySourcesPanel({
onClick={handleReplay}
disabled={replayDisabled}
>
{remainingMs > 0 ? `Replay (${Math.ceil(remainingMs / 1000)}s)` : busy ? 'Replay…' : 'Replay'}
{atJobLimit ? 'Replay (limit reached)' : remainingMs > 0 ? `Replay (${Math.ceil(remainingMs / 1000)}s)` : busy ? 'Replay…' : 'Replay'}
</button>
</div>
</div>
@@ -323,14 +295,14 @@ export default function ReplaySourcesPanel({
);
}
const GroupList = React.memo(function GroupList({ title, items, selectedSet, onToggle }) {
const GroupList = React.memo(function GroupList({ title, items, selectedSet, activeSourceKeys, onToggle }) {
if (!items.length) return null;
return (
<div className="space-y-0.5">
<div className="panel-muted text-xs">{title}</div>
<div className="space-y-0.5">
{items.map((item) => (
<label key={item.key} className="surface flex items-center gap-0.5 text-xs">
<label key={item.key} className={`surface flex items-center gap-0.5 text-xs ${activeSourceKeys.has(item.key) ? 'animate-pulse' : ''}`}>
<input
type="checkbox"
checked={selectedSet.has(item.key)}
-20
View File
@@ -16,7 +16,6 @@ const INITIAL_STATE = {
overseerMemory: null,
alerts: [],
latestReplay: null,
replayStatus: null,
duplicateIdentityBlock: null,
roverRemovalNotice: null,
};
@@ -199,23 +198,6 @@ export function SessionProvider({ children }) {
],
}));
}
function handleReplayStatus(payload = {}) {
if (!payload?.status) return;
setState((prev) => ({
...prev,
/*
Replay creation is serialized by the server's cooldown and workflow,
so the browser needs one current progress value rather than a map of
jobs. The server remains authoritative for job identity and execution;
this scalar exists only to present accepted/building/uploading/ready/
failed progress in the web UI.
*/
replayStatus: {
...payload,
receivedAt: Date.now(),
},
}));
}
function handleReplayReady(payload = {}) {
if (!payload?.jobId || !payload?.url) return;
setState((prev) => ({
@@ -316,7 +298,6 @@ export function SessionProvider({ children }) {
socket.on('overseer:state', handleOverseerState);
socket.on('overseer:memory', handleOverseerMemory);
socket.on('alert:new', handleAlertNew);
socket.on('replay:status', handleReplayStatus);
socket.on('replay:ready', handleReplayReady);
socket.on('replay:failed', handleReplayFailed);
socket.on('session:duplicateIdentity', handleDuplicateIdentity);
@@ -331,7 +312,6 @@ export function SessionProvider({ children }) {
socket.off('overseer:state', handleOverseerState);
socket.off('overseer:memory', handleOverseerMemory);
socket.off('alert:new', handleAlertNew);
socket.off('replay:status', handleReplayStatus);
socket.off('replay:ready', handleReplayReady);
socket.off('replay:failed', handleReplayFailed);
socket.off('session:duplicateIdentity', handleDuplicateIdentity);
+4 -1
View File
@@ -22,6 +22,7 @@ import AnalyticsReporter from './analytics/AnalyticsReporter.jsx'
import PtzAppRoot from './ptz/PtzAppRoot.jsx'
import OperatingModeRouteSync from './components/OperatingModeRouteSync/index.jsx'
import InitialSessionOverlay from './components/InitialSessionOverlay/index.jsx'
import AppErrorBoundary from './components/AppErrorBoundary.jsx'
// Reporting and administration carry substantial route-specific libraries.
// Loading each only on its own route keeps charting and JSON Schema tooling out
@@ -40,6 +41,7 @@ function lazyRoute(element, label) {
}
createRoot(document.getElementById('root')).render(
<AppErrorBoundary>
<StrictMode>
<SocketProvider>
<SessionProvider>
@@ -84,5 +86,6 @@ createRoot(document.getElementById('root')).render(
</TelemetryProvider>
</SessionProvider>
</SocketProvider>
</StrictMode>,
</StrictMode>
</AppErrorBoundary>,
)