mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-11-14 10:42:45 -05:00
slop
This commit is contained in:
+10
@@ -97,6 +97,15 @@ RUN curl --fail --location --retry 3 \
|
||||
&& find /output/opt/roverd/googletts -type d -exec chmod 0755 {} + \
|
||||
&& find /output/opt/roverd/googletts -type f -exec chmod 0644 {} +
|
||||
|
||||
FROM architecture-check AS learning-dependencies
|
||||
# CPU-only learning dependencies are built once. Disabled installations never
|
||||
# launch Python or install packages as a side effect of starting Node.
|
||||
RUN dnf install -y --setopt=install_weak_deps=False python3 python3-pip \
|
||||
&& dnf clean all
|
||||
COPY server/src/services/roverLearningService/workers/requirements.txt /tmp/learning-requirements.txt
|
||||
RUN python3 -m venv /opt/rover-learning \
|
||||
&& /opt/rover-learning/bin/pip install --no-cache-dir -r /tmp/learning-requirements.txt
|
||||
|
||||
FROM fedora:${FEDORA_VERSION} AS runtime
|
||||
ARG FEDORA_VERSION
|
||||
ARG TARGETARCH
|
||||
@@ -148,6 +157,7 @@ COPY --from=webui-build /build/server/public ./public
|
||||
COPY --from=native-workers /build/kinect/kinect_worker ./src/services/kinectService/native/kinect_worker
|
||||
COPY --from=native-workers /build/balance-board/balance_board_worker ./src/services/balanceBoardService/native/balance_board_worker
|
||||
COPY --from=packaged-tools /output/ /
|
||||
COPY --from=learning-dependencies /opt/rover-learning /opt/rover-learning
|
||||
COPY --chmod=0755 server/bin/chromegtts-wav.py /usr/local/bin/chromegtts-wav
|
||||
COPY --chmod=0755 server/mediamtx/rover-snapshot-writer.sh /usr/local/bin/rover-snapshot-writer.sh
|
||||
|
||||
|
||||
+173
-30
@@ -2,11 +2,12 @@
|
||||
|
||||
## Resume here
|
||||
|
||||
Status: implementation authorized; first recording/lifecycle increment is coded.
|
||||
Training, model catalog, autonomous control, and the VIP card are not implemented.
|
||||
The standalone
|
||||
CPU benchmark exists at `perf/rover-model-benchmark.py` and was run successfully
|
||||
on the user's actual server. No autonomous-driving capability is implemented.
|
||||
Status: recording, bounded dataset/training, permanent named models, live inference,
|
||||
checked autonomous command submission, and the verified VIP card are coded.
|
||||
Human commands do not stop or pause autonomy (explicit latest user requirement).
|
||||
Focused local checks passed; actual recording/training, live autonomous behavior,
|
||||
concurrent server performance, and browser appearance remain unverified.
|
||||
The standalone CPU benchmark was run successfully on the actual server.
|
||||
|
||||
This document preserves the conversation's decisions across compactions and
|
||||
usage cutoffs. Keep the checklist and handoff section current during authorized
|
||||
@@ -19,12 +20,14 @@ AGENTS.md instructions: targeted investigation, minimal edits, no new tests unle
|
||||
requested, proportional direct verification, and no development processes left
|
||||
running. This development machine is not the actual rover server.
|
||||
|
||||
Next work: verify real recording output on the server, then define the actual
|
||||
feature/action schema and implement training/checkpoint publication. The VIP entry
|
||||
point is `webui/src/components/VipPanel/index.jsx`; verified cards are in its
|
||||
`isVerified` branch, and client settings use `useSettingsNamespace`. The common
|
||||
browser rover command sender is `webui/src/controls/commandPipeline.js`. Finish
|
||||
the control-path inventory before declaring every capability learnable.
|
||||
The user declined the manual server checklist and explicitly asked to continue.
|
||||
Do not block further work or repeatedly ask them to do it. Preserve the unverified
|
||||
status and have the reader reject unusable recordings automatically.
|
||||
|
||||
Next work: verify the integrated feature against actual recordings and the real
|
||||
camera/rover pipeline when that environment is available; inspect the VIP card in
|
||||
the running browser. Do not invent successful driving results or block further
|
||||
implementation on the previously declined manual checklist.
|
||||
|
||||
## Intended product
|
||||
|
||||
@@ -101,13 +104,13 @@ the default development path. Intended layout:
|
||||
```text
|
||||
rover-learning/
|
||||
recordings/ Rolling video/events/session metadata (implemented)
|
||||
models/ Permanent published models (pending)
|
||||
training/ Resumable training/working state (pending)
|
||||
catalog.sqlite Session/model/progress index (proposed; not created yet)
|
||||
models/ Permanent UUID directories: weights.pt and model.json
|
||||
training/ resume.pt, schedule.json, and current job.json
|
||||
```
|
||||
|
||||
Only `recordings/` currently exists when enabled. Its session manifests provide
|
||||
the initial retention index; no placeholder model/training database is created.
|
||||
Training/model directories are created only when service and training are enabled.
|
||||
Session/model manifests and a small scheduling ledger are sufficient at present;
|
||||
the previously proposed catalog.sqlite is not needed or created.
|
||||
Python compute and video processing must not block the Node event loop. Worker
|
||||
dependencies, deployment packaging, and process shutdown must be included in the
|
||||
implementation plan; installing Python packages at server startup is not assumed.
|
||||
@@ -166,8 +169,11 @@ an admin identity or send directly to the rover WebSocket.
|
||||
Recheck permissions server-side for actions and continued control. Losing the
|
||||
turn/assignment/connection or otherwise losing eligibility stops the autonomous
|
||||
session. A safe stop must still be possible after ordinary permission is revoked.
|
||||
The exact policy for an incoming manual control command during autonomy is open;
|
||||
immediate takeover is the proposed behavior. Prevent two controllers fighting.
|
||||
Explicit user decision: incoming human commands do not stop or pause autonomy.
|
||||
Both pass through the existing checked command path; subsequent model commands
|
||||
can replace human commands. Stop remains explicit, with permission/disconnect,
|
||||
stale input, and worker failure stops. Model-influenced sessions are excluded
|
||||
from human demonstration recording, including manual inputs during autonomy.
|
||||
|
||||
Model actions describe operating controls, not arbitrary server maintenance.
|
||||
Inventory reboot/update/raw commands and capability restrictions explicitly rather
|
||||
@@ -329,16 +335,21 @@ Published inference artifacts and resumable training state need not be identical
|
||||
- [x] Obtain IPI for feature implementation.
|
||||
- [ ] Inspect remaining UI/control paths and settle concrete contracts.
|
||||
- [x] Add service-owned config and dormant lifecycle integration.
|
||||
- [ ] Add optional detailed command observer and shared checked submission path.
|
||||
- [x] Add optional detailed command observer and shared checked submission path.
|
||||
- [ ] Implement timestamped recording, video worker ownership, and bounded dataset.
|
||||
Code is implemented for common commands, raw/decoded rover sensor frames,
|
||||
host stats, and existing rover state events. Live video/timing/retention
|
||||
verification and full control-path inventory remain; do not mark complete yet.
|
||||
- [ ] Implement feature/action encoding and training worker with resource limits.
|
||||
- [ ] Implement atomic checkpoint publication and permanent model catalog.
|
||||
- [ ] Implement model inference, ownership, takeover, stops, and worker cleanup.
|
||||
- [ ] Implement service socket gateway with complete per-user card state.
|
||||
- [ ] Implement verified VIP card at top, model selection, local favorites, controls.
|
||||
- [x] Implement feature/action encoding and training worker with resource limits.
|
||||
Runtime import/dependency checks passed; training on actual recordings is still
|
||||
unverified. The current schema limits and next inference work are described below.
|
||||
- [x] Implement atomic checkpoint publication and permanent model catalog.
|
||||
Code complete; no actual-data published checkpoint has been produced locally.
|
||||
- [x] Implement model inference, ownership, explicit stops, and worker cleanup.
|
||||
Human input does not stop autonomy; live execution remains unverified.
|
||||
- [x] Implement service socket gateway with complete per-user card state.
|
||||
- [x] Implement verified VIP card at top, model selection, local favorites, controls,
|
||||
and expandable diagnostics. Browser appearance remains unverified.
|
||||
- [ ] Verify disabled startup and enable/disable cleanup directly.
|
||||
- [ ] Verify manual-control behavior remains intact and autonomous permission loss
|
||||
stops motion using an appropriate authorized runtime.
|
||||
@@ -420,8 +431,8 @@ Limits and follow-up:
|
||||
surviving children. Existing MediaMTX shutdown can exit the whole Node process
|
||||
before async metadata flush finishes; next enable marks those sessions interrupted.
|
||||
- Storage leases for concurrent training reads are pending with dataset sampling.
|
||||
- Safe command submission extraction, autonomous provenance, training, permanent
|
||||
catalog, model selection, client favorites, and UI remain pending.
|
||||
- Safe command submission extraction, autonomous provenance, model selection,
|
||||
client favorites, and UI remain pending. Training/catalog code follows below.
|
||||
|
||||
Verification completed:
|
||||
|
||||
@@ -438,7 +449,8 @@ Verification completed:
|
||||
- Local FFmpeg help confirms the RTSP transport and timeout options used.
|
||||
- No full build, full lint, new tests, live server startup, or rover hardware run.
|
||||
|
||||
Server verification needed for this increment:
|
||||
Optional server verification reference (user declined doing this checklist;
|
||||
do not require it before continuing implementation):
|
||||
|
||||
1. Deploy normally, initially disabled; confirm no `rover-learning` directory or
|
||||
learning FFmpeg workers are created by a fresh disabled startup.
|
||||
@@ -458,11 +470,142 @@ Server verification needed for this increment:
|
||||
6. Verify rotation and retention with an intentionally chosen small recording
|
||||
budget, and confirm manual driving remains responsive during recording.
|
||||
|
||||
Return the session metadata, a small representative event sample (excluding any
|
||||
unwanted personal text), FFprobe timestamps, and any `roverLearning` errors. Actual
|
||||
server capture/timing and live cleanup remain unverified until these checks run.
|
||||
Actual server capture/timing and live cleanup remain unverified. These steps are
|
||||
a reference for future runtime investigation, not a pending request to the user.
|
||||
|
||||
### Dataset/training/checkpoint increment
|
||||
|
||||
The user supplied IPI, then asked to continue after a rate-limit interruption.
|
||||
Authorization remains in effect. Implemented:
|
||||
|
||||
- `workers/policy.py`: fixed-size spatial CNN/GRU with 64 action-shape slots and
|
||||
up to 8 numeric output fields per shape. Four 160x120 RGB frames, spaced roughly
|
||||
300 ms apart, with 32 selected sensors plus missing-value masks and previous
|
||||
command values/ages/known flags. About 805k parameters (larger than the benchmark).
|
||||
- Numeric controls are normalized with `2/pi * atan(value/scale)`; the model
|
||||
produces per-shape event logits and numeric values for the next 100 ms.
|
||||
Command types supported: drive, motors, servo, headlight, laser, horn, peripheral,
|
||||
song, raw. Strings/bools/arrays remain exact template constants; variable-size
|
||||
songs/raw OI payloads are discrete demonstrations, not generated arbitrary bytes.
|
||||
Maintenance commands, audio/text and sensor-stream setup are not action targets.
|
||||
All recordings remain intact until normal dataset retention, regardless of
|
||||
whether the trainer can use their commands.
|
||||
- The schema grows only into unused fixed slots and is saved with each model.
|
||||
Overflow (64 shapes/8 numeric fields/4096-byte commands) skips the affected
|
||||
recording with a reason instead of silently truncating controls. This is a
|
||||
concrete initial encoding limit, not a promise to represent unlimited hardware
|
||||
or arbitrary song libraries. Revisit it if real recordings exhaust capacity.
|
||||
- `workers/dataset.py`: bounded event loading (64 MiB/session), PyAV decoding,
|
||||
absolute timestamp checks, causal sensor/action history, accepted-client-command
|
||||
targets, and masks for observed action slots. Actual measured wheel speeds are
|
||||
among the selected sensor inputs. Missing/stale sensors, video gaps, clock shifts,
|
||||
incomplete recordings, data loss, and ambiguous conflicting actions are excluded.
|
||||
Server-generated control overrides are history/boundaries, not demonstrations.
|
||||
- Validation is automatic and cannot establish what frame a browser displayed.
|
||||
A session is only accepted after its video finishes decoding; samples from a
|
||||
subsequently corrupt session are discarded. Commands/sensors at future times
|
||||
do not enter observation history.
|
||||
- `workers/train.py`: finite CPU jobs, lower OS priority, shuffled bounded reservoir
|
||||
sampling, sparse-event classification plus masked numeric regression, gradient
|
||||
clipping, finite-loss checks, and AdamW resume state. Never sends rover commands.
|
||||
- `training.js`: one owned worker, recording leases acquired within serialized
|
||||
maintenance, bounded stdout/stderr, interval and wall-clock limits, shutdown,
|
||||
persistent per-recording visit/rejection ledger, and progress state for the
|
||||
future card. Retention protects leased recordings. Disk pressure cancels the
|
||||
training job before files become eligible for pruning again.
|
||||
- Atomic `training/resume.pt` includes model and optimizer state, schema, cumulative
|
||||
steps/examples, and publication progress. Permanent model directories are
|
||||
atomically renamed into visibility with weights and metadata together.
|
||||
- `models.js`: reads the permanent metadata catalog. Each model has a UUID, two-word
|
||||
generated name plus UUID suffix, creation time, steps/examples, schema, source
|
||||
session IDs, trained rover IDs, training loss, parameter count, weight hash/size,
|
||||
and `evaluation: unevaluated`. No model pruning, favorites, or server pin state.
|
||||
- Dockerfile packages a CPU-only Python venv at `/opt/rover-learning` during image
|
||||
build. Pinned requirements: torch 2.14.0+cpu, NumPy 2.5.3, PyAV 18.1.0. No runtime
|
||||
downloads/installers. A disabled service does not load or run these dependencies.
|
||||
|
||||
Training defaults beneath `roverLearning.training`:
|
||||
|
||||
| Setting | Default |
|
||||
|---|---:|
|
||||
| enabled (still gated by outer service enabled=false) | true |
|
||||
| threads | 4 |
|
||||
| batchSize | 16 |
|
||||
| maxSamples / minimumSamples | 512 / 128 |
|
||||
| passesPerJob | 2 |
|
||||
| roundsPerSession | 3 |
|
||||
| maxStepsPerJob | 200 |
|
||||
| checkpointEverySteps | 100 |
|
||||
| intervalSeconds | 300 |
|
||||
| maxJobSeconds | 600 |
|
||||
|
||||
At most four completed retained sessions are selected per job. Samples are spread
|
||||
over each full video via reservoir sampling, not always taken from its beginning.
|
||||
The rounds limit is sampled training visits, not full passes over all recorded
|
||||
frames. Once retained data reaches that limit, training waits for new sessions.
|
||||
Candidate publication happens at job boundaries after enough accumulated steps;
|
||||
it does not automatically deploy a model. No all-no-action sample pool is trained.
|
||||
|
||||
Container rebuild is needed to include Python packages. For a non-container
|
||||
deployment, install `workers/requirements.txt` into a dedicated venv and set
|
||||
`training.python` to its Python path. The default path is container-specific.
|
||||
|
||||
Verification performed in this increment:
|
||||
|
||||
- Installed the actual pinned requirements in `/tmp/rover-bench-check-env`.
|
||||
- Ran the production worker's `--check-dependencies`: PyTorch/PyAV/NumPy imports,
|
||||
dataset imports, policy construction, and specification output succeeded.
|
||||
- Four existing focused configuration tests passed (defaults, descriptions,
|
||||
generated feature paths, normalization). The unrelated prior Discord default
|
||||
assertion was not rerun or changed.
|
||||
- Node syntax checks on scheduler/runtime/catalog passed.
|
||||
- No new tests, fake recording fixtures, or throwaway test programs were created.
|
||||
- No real recordings are available locally. Actual decoding/alignment, optimizer
|
||||
execution against recordings, checkpoint publication/resume, and concurrent
|
||||
server performance are not claimed verified. No full container build was run.
|
||||
|
||||
The following controller/UI increment implements those remaining code paths.
|
||||
Real-data and live-hardware verification still remain before claiming usable autonomy. The initial model is an experimental imitation policy, not a demonstrated
|
||||
competent driver or a final architecture choice.
|
||||
|
||||
When resuming after implementation begins, append concrete changed files, commands
|
||||
run/results, unresolved failures, decisions approved by the user, and the next
|
||||
bounded task here. Do not mark a checkbox complete merely because code exists;
|
||||
record verification limits and outstanding server/hardware work explicitly.
|
||||
|
||||
|
||||
### Controller and VIP card increment
|
||||
|
||||
- `commandService/index.js` exports the existing checked handler as `submitCommand`;
|
||||
browser behavior remains in the same handler. Recording distinguishes model
|
||||
requests from human requests without changing command payloads.
|
||||
- `driving.js` owns per-rover CPU workers under the initiating live socket's
|
||||
permissions. Explicit Stop, permission/connection loss, stale predictions, or
|
||||
worker failure stop the session and send zero drive/motors and horn stop.
|
||||
Human commands deliberately neither stop nor pause the model.
|
||||
- `workers/infer.py` consumes live RTSP/TCP video plus sensor/command observations,
|
||||
uses the training history format, validates weights/specification, resolves
|
||||
competing command families, and latches discrete actions. It never writes to
|
||||
the rover directly. Driving defaults: four CPU threads, 0.7 action threshold,
|
||||
2000ms stale limit, with a 30-second worker startup allowance.
|
||||
- `capabilities.js` matches recorded hardware control profiles against the target
|
||||
rover. Recordings, resumable training state, and published manifests preserve
|
||||
those profiles; models without a matching profile are not selectable for Start.
|
||||
- `runtime.js` excludes autonomous sessions from human demonstrations, manages
|
||||
controller shutdown, and supplies bounded recent activity and storage state.
|
||||
- `socketGateway.js` emits one complete authorized card object every second to
|
||||
subscribers, refreshes the permanent catalog every 15 seconds, and accepts
|
||||
checked Start/Stop actions. No server-side favorites are stored.
|
||||
- `VipRoverLearningCard.jsx` is at the top of the verified VIP tab, gated by the
|
||||
service feature flag. It uses existing CardFrame, field/button/surface styles;
|
||||
favorites use browser settings. Its expandable section includes storage,
|
||||
recording sessions, dataset rejection reasons, training progress/resource
|
||||
limits, model size/loss/control count, inference timing, last action, and events.
|
||||
- Verification: focused ESLint on the new card and VipPanel passed; four existing
|
||||
configuration tests passed. The live worker's actual `--help` entry point loaded
|
||||
PyTorch/PyAV/NumPy successfully. Shared-handler diff reviewed with whitespace
|
||||
ignored: extraction preserves the previous command handler logic.
|
||||
- No real dataset, checkpoint, camera stream, or rover was available locally;
|
||||
end-to-end inference/training and browser rendering remain unverified. No new
|
||||
tests or background processes were added for verification, and no full build
|
||||
or full test suite was run.
|
||||
|
||||
@@ -259,144 +259,146 @@ module.exports = {
|
||||
setDriveCooldown,
|
||||
commandEvents,
|
||||
subscribeCommandRecording,
|
||||
submitCommand,
|
||||
};
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
function handleCommand({ roverId, type, data } = {}, cb) {
|
||||
const reply = typeof cb === 'function' ? cb : () => {};
|
||||
try {
|
||||
if (!roverId) {
|
||||
throw new Error('roverId required');
|
||||
// Shared authorization/safety boundary for browser and service-owned control.
|
||||
function submitCommand(socket, { roverId, type, data } = {}, cb) {
|
||||
const reply = typeof cb === 'function' ? cb : () => {};
|
||||
try {
|
||||
if (!roverId) {
|
||||
throw new Error('roverId required');
|
||||
}
|
||||
if (!type) {
|
||||
throw new Error('type required');
|
||||
}
|
||||
if (type === 'audioLevels') {
|
||||
throw new Error('audioLevels command is service-managed');
|
||||
}
|
||||
let payload = data ? { ...data } : {};
|
||||
if (type === 'headlight' && isHeadlightBlocked()) {
|
||||
logger.info('Ignoring headlight command while darkness lock is active', { socketId: socket.id, roverId });
|
||||
reply({ ignored: true, reason: 'darknessActive' });
|
||||
return;
|
||||
}
|
||||
if (type === 'laser' && isLaserCommandBlockedByRoomLightLock(payload)) {
|
||||
logger.info('Ignoring laser command while room lights are locked on', {
|
||||
socketId: socket.id,
|
||||
roverId,
|
||||
action: getLaserAction(payload),
|
||||
});
|
||||
reply({ ignored: true, reason: 'roomLightsLockedOn' });
|
||||
return;
|
||||
}
|
||||
const isRebootCommand = type === 'reboot';
|
||||
const isUpdateCommand = type === 'update';
|
||||
const isSongCommand = type === 'song' || (type === 'raw' && isSongRawPayload(payload));
|
||||
const isAdminSocket = isAdmin(socket);
|
||||
if (!isAdminSocket && isDeterred(socket)) {
|
||||
throw new Error('Not authorized');
|
||||
}
|
||||
/*
|
||||
Both structured song commands and raw Open Interface song payloads
|
||||
reach this shared flag. Enforcing mute here covers the VIP MIDI beeper
|
||||
and any future browser beeper without affecting unrelated driving.
|
||||
*/
|
||||
if (!isAdminSocket && isSongCommand && isMuted(socket)) {
|
||||
throw new Error('Muted');
|
||||
}
|
||||
// Rover updates run a privileged, root-owned helper on the Pi. Keep this
|
||||
// in the same explicit admin-only branch as reboot instead of relying on
|
||||
// drive ownership checks, because having a turn should not grant system
|
||||
// maintenance privileges.
|
||||
if ((isRebootCommand || isUpdateCommand) && !isAdminSocket) {
|
||||
throw new Error('Not authorized');
|
||||
}
|
||||
if (!isSongCommand && !isRebootCommand && !isUpdateCommand && !roverManager.canDrive(roverId, socket)) {
|
||||
throw new Error('Not your turn or no control');
|
||||
}
|
||||
const driveDirect = payload?.driveDirect;
|
||||
if (type === 'drive' && driveDirect) {
|
||||
if (!isAdminSocket) {
|
||||
const safeDrive = roverManager.applyPrivateDriveSafety(roverId, socket, driveDirect);
|
||||
if (safeDrive) {
|
||||
payload.driveDirect = safeDrive;
|
||||
}
|
||||
}
|
||||
if (!type) {
|
||||
throw new Error('type required');
|
||||
}
|
||||
if (type === 'audioLevels') {
|
||||
throw new Error('audioLevels command is service-managed');
|
||||
}
|
||||
let payload = data ? { ...data } : {};
|
||||
if (type === 'headlight' && isHeadlightBlocked()) {
|
||||
logger.info('Ignoring headlight command while darkness lock is active', { socketId: socket.id, roverId });
|
||||
reply({ ignored: true, reason: 'darknessActive' });
|
||||
return;
|
||||
const left = Number(payload?.driveDirect?.left);
|
||||
const right = Number(payload?.driveDirect?.right);
|
||||
const speed = Math.max(Math.abs(left), Math.abs(right));
|
||||
if (!isAdminSocket) {
|
||||
const blockedUntil = driveCooldowns.get(roverId);
|
||||
if (blockedUntil && Date.now() < blockedUntil && speed > 0) {
|
||||
const reason = isLockdownAdmin(socket)
|
||||
? 'Drive blocked: cooldown'
|
||||
: 'Drive blocked: safety cooldown';
|
||||
throw new Error(reason);
|
||||
}
|
||||
}
|
||||
if (type === 'laser' && isLaserCommandBlockedByRoomLightLock(payload)) {
|
||||
logger.info('Ignoring laser command while room lights are locked on', {
|
||||
if (speed > 0) {
|
||||
let direction = 'turn';
|
||||
if (left > 0 && right > 0) direction = 'forward';
|
||||
if (left < 0 && right < 0) direction = 'backward';
|
||||
lastDriveActivity.set(roverId, {
|
||||
ts: Date.now(),
|
||||
socketId: socket.id,
|
||||
roverId,
|
||||
action: getLaserAction(payload),
|
||||
});
|
||||
reply({ ignored: true, reason: 'roomLightsLockedOn' });
|
||||
return;
|
||||
}
|
||||
const isRebootCommand = type === 'reboot';
|
||||
const isUpdateCommand = type === 'update';
|
||||
const isSongCommand = type === 'song' || (type === 'raw' && isSongRawPayload(payload));
|
||||
const isAdminSocket = isAdmin(socket);
|
||||
if (!isAdminSocket && isDeterred(socket)) {
|
||||
throw new Error('Not authorized');
|
||||
}
|
||||
/*
|
||||
Both structured song commands and raw Open Interface song payloads
|
||||
reach this shared flag. Enforcing mute here covers the VIP MIDI beeper
|
||||
and any future browser beeper without affecting unrelated driving.
|
||||
*/
|
||||
if (!isAdminSocket && isSongCommand && isMuted(socket)) {
|
||||
throw new Error('Muted');
|
||||
}
|
||||
// Rover updates run a privileged, root-owned helper on the Pi. Keep this
|
||||
// in the same explicit admin-only branch as reboot instead of relying on
|
||||
// drive ownership checks, because having a turn should not grant system
|
||||
// maintenance privileges.
|
||||
if ((isRebootCommand || isUpdateCommand) && !isAdminSocket) {
|
||||
throw new Error('Not authorized');
|
||||
}
|
||||
if (!isSongCommand && !isRebootCommand && !isUpdateCommand && !roverManager.canDrive(roverId, socket)) {
|
||||
throw new Error('Not your turn or no control');
|
||||
}
|
||||
const driveDirect = payload?.driveDirect;
|
||||
if (type === 'drive' && driveDirect) {
|
||||
if (!isAdminSocket) {
|
||||
const safeDrive = roverManager.applyPrivateDriveSafety(roverId, socket, driveDirect);
|
||||
if (safeDrive) {
|
||||
payload.driveDirect = safeDrive;
|
||||
}
|
||||
}
|
||||
const left = Number(payload?.driveDirect?.left);
|
||||
const right = Number(payload?.driveDirect?.right);
|
||||
const speed = Math.max(Math.abs(left), Math.abs(right));
|
||||
if (!isAdminSocket) {
|
||||
const blockedUntil = driveCooldowns.get(roverId);
|
||||
if (blockedUntil && Date.now() < blockedUntil && speed > 0) {
|
||||
const reason = isLockdownAdmin(socket)
|
||||
? 'Drive blocked: cooldown'
|
||||
: 'Drive blocked: safety cooldown';
|
||||
throw new Error(reason);
|
||||
}
|
||||
}
|
||||
if (speed > 0) {
|
||||
let direction = 'turn';
|
||||
if (left > 0 && right > 0) direction = 'forward';
|
||||
if (left < 0 && right < 0) direction = 'backward';
|
||||
lastDriveActivity.set(roverId, {
|
||||
ts: Date.now(),
|
||||
socketId: socket.id,
|
||||
direction,
|
||||
speed,
|
||||
isAdmin: isAdminSocket,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'drive' || type === 'motors') {
|
||||
/*
|
||||
Role is supplied at the command boundary because telemetry does not
|
||||
identify the operator who produced the active motor intent. Admin and
|
||||
lockdown commands therefore enter the service explicitly bypassed;
|
||||
they are recorded for status visibility but are never scaled, blocked,
|
||||
or countermanded by a later sensor frame.
|
||||
*/
|
||||
payload = overcurrentProtectionService.protectCommand(roverId, type, payload, {
|
||||
bypassed: isAdminSocket,
|
||||
direction,
|
||||
speed,
|
||||
isAdmin: isAdminSocket,
|
||||
});
|
||||
}
|
||||
const id = issueCommand(roverId, { type, ...payload });
|
||||
commandEvents.emit('observation', {
|
||||
ts: Date.now(),
|
||||
roverId: String(roverId),
|
||||
type,
|
||||
commandId: id,
|
||||
outcome: 'issued',
|
||||
socketId: socket.id,
|
||||
// Payloads are omitted deliberately: raw OI, TTS, and maintenance
|
||||
// commands can carry arbitrary content. Their structured type/outcome
|
||||
// supplies analytics without accidentally persisting secret material.
|
||||
});
|
||||
logger.info('Queued command', socket.id, roverId, type);
|
||||
if (shouldRecordTurnActivity(type, payload)) {
|
||||
try {
|
||||
const { recordActivity } = require('../turnService');
|
||||
recordActivity(roverId, socket.id);
|
||||
} catch (err) {
|
||||
// Activity recording is best-effort because command delivery should not fail if turn bookkeeping has a transient issue.
|
||||
}
|
||||
}
|
||||
reply({ id });
|
||||
} catch (err) {
|
||||
logger.warn('Command rejected', socket.id, err.message);
|
||||
commandEvents.emit('observation', {
|
||||
ts: Date.now(),
|
||||
roverId: roverId ? String(roverId) : null,
|
||||
type: type || 'unknown',
|
||||
outcome: 'rejected',
|
||||
socketId: socket.id,
|
||||
error: err.message,
|
||||
});
|
||||
reply({ error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
const recordedCommand = (request, reply) => recordCommandRequest(socket, request, reply, handleCommand);
|
||||
if (type === 'drive' || type === 'motors') {
|
||||
/*
|
||||
Role is supplied at the command boundary because telemetry does not
|
||||
identify the operator who produced the active motor intent. Admin and
|
||||
lockdown commands therefore enter the service explicitly bypassed;
|
||||
they are recorded for status visibility but are never scaled, blocked,
|
||||
or countermanded by a later sensor frame.
|
||||
*/
|
||||
payload = overcurrentProtectionService.protectCommand(roverId, type, payload, {
|
||||
bypassed: isAdminSocket,
|
||||
});
|
||||
}
|
||||
const id = issueCommand(roverId, { type, ...payload });
|
||||
commandEvents.emit('observation', {
|
||||
ts: Date.now(),
|
||||
roverId: String(roverId),
|
||||
type,
|
||||
commandId: id,
|
||||
outcome: 'issued',
|
||||
socketId: socket.id,
|
||||
// Payloads are omitted deliberately: raw OI, TTS, and maintenance
|
||||
// commands can carry arbitrary content. Their structured type/outcome
|
||||
// supplies analytics without accidentally persisting secret material.
|
||||
});
|
||||
logger.info('Queued command', socket.id, roverId, type);
|
||||
if (shouldRecordTurnActivity(type, payload)) {
|
||||
try {
|
||||
const { recordActivity } = require('../turnService');
|
||||
recordActivity(roverId, socket.id);
|
||||
} catch (err) {
|
||||
// Activity recording is best-effort because command delivery should not fail if turn bookkeeping has a transient issue.
|
||||
}
|
||||
}
|
||||
reply({ id });
|
||||
} catch (err) {
|
||||
logger.warn('Command rejected', socket.id, err.message);
|
||||
commandEvents.emit('observation', {
|
||||
ts: Date.now(),
|
||||
roverId: roverId ? String(roverId) : null,
|
||||
type: type || 'unknown',
|
||||
outcome: 'rejected',
|
||||
socketId: socket.id,
|
||||
error: err.message,
|
||||
});
|
||||
reply({ error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
const recordedCommand = (request, reply) => recordCommandRequest(socket, request, reply, (message, cb) => submitCommand(socket, message, cb));
|
||||
socket.on('command', recordedCommand);
|
||||
socket.on('command:issue', recordedCommand);
|
||||
|
||||
|
||||
@@ -33,9 +33,9 @@ function observeCommand(event) {
|
||||
}
|
||||
}
|
||||
|
||||
function recordCommandRequest(socket, request, reply, execute) {
|
||||
function recordCommandRequest(socket, request, reply, execute, source = 'client') {
|
||||
if (!observers.size) return execute(request, reply);
|
||||
const context = { requestId: randomUUID(), socketId: socket.id, userId: socket.data?.userId || null };
|
||||
const context = { requestId: randomUUID(), socketId: socket.id, userId: socket.data?.userId || null, source };
|
||||
const scope = { active: true, context };
|
||||
return requests.run(scope, () => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// Match actuator configuration, not a rover's display name or changing telemetry.
|
||||
const { isDeepStrictEqual } = require('node:util');
|
||||
|
||||
function controlProfile(meta = {}) {
|
||||
return Object.fromEntries(['cameraServo', 'peripherals', 'headlight', 'laser', 'roomba', 'platform']
|
||||
.map((key) => [key, meta?.[key] ?? null]));
|
||||
}
|
||||
|
||||
function compatible(model, record) {
|
||||
if (model?.specification?.version !== 1 || !record) return false;
|
||||
const profile = controlProfile(record.meta);
|
||||
return Array.isArray(model.controlProfiles) && model.controlProfiles.some((saved) => isDeepStrictEqual(saved, profile));
|
||||
}
|
||||
|
||||
module.exports = { controlProfile, compatible };
|
||||
@@ -1,5 +1,5 @@
|
||||
// Metadata only: loading the configuration must never start the learning runtime.
|
||||
const { strictObject, boolean, integer, number } = require('../../configuration/schemaHelpers');
|
||||
const { strictObject, boolean, integer, number, string } = require('../../configuration/schemaHelpers');
|
||||
|
||||
module.exports = {
|
||||
key: 'roverLearning',
|
||||
@@ -7,13 +7,39 @@ module.exports = {
|
||||
defaultValue: {
|
||||
enabled: false,
|
||||
recording: { maxGiB: 20, minimumFreeGiB: 2, sessionSeconds: 300 },
|
||||
driving: { threads: 4, actionThreshold: 0.7, staleMs: 2000 },
|
||||
training: {
|
||||
enabled: true, python: '/opt/rover-learning/bin/python', threads: 4,
|
||||
batchSize: 16, maxSamples: 512, minimumSamples: 128, passesPerJob: 2,
|
||||
roundsPerSession: 3, maxStepsPerJob: 200, checkpointEverySteps: 100,
|
||||
intervalSeconds: 300, maxJobSeconds: 600,
|
||||
},
|
||||
},
|
||||
schema: strictObject({
|
||||
enabled: boolean({ description: 'Enable rover learning recording. Disabled starts no recording workers or storage. Training and autonomous control are not implemented yet.' }),
|
||||
enabled: boolean({ description: 'Enable human-session recording, background learning, and verified-user model control. Disabled starts no workers or storage.' }),
|
||||
driving: strictObject({
|
||||
threads: integer({ minimum: 1, maximum: 16, description: 'CPU threads per active model controller.' }),
|
||||
actionThreshold: number({ minimum: 0.5, maximum: 0.99, description: 'Minimum predicted action probability for issuing a new control event.' }),
|
||||
staleMs: integer({ minimum: 1000, maximum: 5000, description: 'Stop when video, sensors, or model predictions are older than this many milliseconds.' }),
|
||||
}, { description: 'Live model execution. Manual inputs do not stop or pause the model.', required: ['threads', 'actionThreshold', 'staleMs'] }),
|
||||
recording: strictObject({
|
||||
maxGiB: number({ minimum: 0.1, maximum: 100000, description: 'Rolling recording budget in GiB. Checked every 15 seconds; active files can temporarily exceed it. Published models are never pruned.' }),
|
||||
minimumFreeGiB: number({ minimum: 0.1, maximum: 100000, description: 'Pause recording below this amount of available filesystem space, in GiB.' }),
|
||||
sessionSeconds: integer({ minimum: 30, maximum: 3600, description: 'Rotate video and telemetry sessions after this many seconds so completed sessions can be pruned.' }),
|
||||
}, { description: 'Bounded recording storage. Limits never delete published models.', required: ['maxGiB', 'minimumFreeGiB', 'sessionSeconds'] }),
|
||||
}, { description: 'Optional rover learning service. Currently provides passive recording only.', required: ['enabled', 'recording'] }),
|
||||
training: strictObject({
|
||||
enabled: boolean({ description: 'Train periodically from completed usable human recordings while the service is enabled.' }),
|
||||
python: string({ minLength: 1, description: 'Python executable with the service requirements installed. The container supplies /opt/rover-learning/bin/python.' }),
|
||||
threads: integer({ minimum: 1, maximum: 32, description: 'CPU threads for the lower-priority training process.' }),
|
||||
batchSize: integer({ minimum: 1, maximum: 64, description: 'Training windows per optimizer step.' }),
|
||||
maxSamples: integer({ minimum: 128, maximum: 2048, description: 'Maximum decoded windows retained in RAM per job. Each contains four 160x120 RGB frames.' }),
|
||||
minimumSamples: integer({ minimum: 1, maximum: 128, description: 'Usable sampled windows required before training.' }),
|
||||
passesPerJob: integer({ minimum: 1, maximum: 10, description: 'Shuffled passes over the sampled windows per job.' }),
|
||||
roundsPerSession: integer({ minimum: 1, maximum: 20, description: 'Maximum successful sampled training visits per retained recording; pauses repeated training on unchanged data.' }),
|
||||
maxStepsPerJob: integer({ minimum: 1, maximum: 10000, description: 'Optimizer step cap per finite training job.' }),
|
||||
checkpointEverySteps: integer({ minimum: 1, maximum: 100000, description: 'Accumulated training steps between permanent named model publications.' }),
|
||||
intervalSeconds: integer({ minimum: 30, maximum: 86400, description: 'Minimum seconds between completed training jobs.' }),
|
||||
maxJobSeconds: integer({ minimum: 30, maximum: 3600, description: 'Wall-clock budget including video decoding; slow/stuck workers are stopped.' }),
|
||||
}, { description: 'Bounded CPU training, sampling, and permanent checkpoint publication.', required: ['enabled', 'python', 'threads', 'batchSize', 'maxSamples', 'minimumSamples', 'passesPerJob', 'roundsPerSession', 'maxStepsPerJob', 'checkpointEverySteps', 'intervalSeconds', 'maxJobSeconds'] }),
|
||||
}, { description: 'Optional rover recording and learning service.', required: ['enabled', 'recording', 'training', 'driving'] }),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
// Autonomy owns a live user's control context, never an administrative identity.
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const { spawn } = require('node:child_process');
|
||||
const roverManager = require('../roverManager');
|
||||
const { isVerified, isDeterred } = require('../verificationService');
|
||||
const { isAdmin } = require('../roleService');
|
||||
const { submitCommand, issueCommand, subscribeCommandRecording } = require('../commandService');
|
||||
const { recordCommandRequest } = require('../commandService/recording');
|
||||
const { compatible } = require('./capabilities');
|
||||
|
||||
const OPERATING = new Set(['drive', 'motors', 'servo', 'headlight', 'laser', 'horn', 'peripheral', 'song', 'raw']);
|
||||
const clamp = (value, min, max) => Math.max(min, Math.min(max, Number(value) || 0));
|
||||
|
||||
function canControl(socket, roverId) {
|
||||
return Boolean(socket?.connected && isVerified(socket) && (isAdmin(socket) || !isDeterred(socket))
|
||||
&& roverManager.rovers.get(roverId)?.ws && roverManager.canDrive(roverId, socket));
|
||||
}
|
||||
|
||||
function createDriving({ root, config, onChange, activity }) {
|
||||
const sessions = new Map();
|
||||
let stopping = false;
|
||||
let watchdog;
|
||||
let unsubscribe;
|
||||
|
||||
function sendState(session, event) {
|
||||
const input = session.worker.stdin;
|
||||
if (input.destroyed || !input.writable || input.writableLength > 256 * 1024) return;
|
||||
input.write(JSON.stringify(event) + '\n');
|
||||
}
|
||||
|
||||
function transmit(session, command) {
|
||||
if (session.status === 'stopping') return;
|
||||
if (!canControl(session.socket, session.roverId)) { stop(session.roverId, 'Control permission lost'); return; }
|
||||
if (!OPERATING.has(command?.type)) throw new Error('Unsupported model command');
|
||||
const record = roverManager.rovers.get(session.roverId);
|
||||
if (command.type === 'drive') {
|
||||
command.driveDirect = { left: clamp(command.driveDirect?.left, -500, 500), right: clamp(command.driveDirect?.right, -500, 500) };
|
||||
}
|
||||
if (command.type === 'motors') {
|
||||
command.motorPwm = { main: clamp(command.motorPwm?.main, -127, 127), side: clamp(command.motorPwm?.side, -127, 127), vacuum: clamp(command.motorPwm?.vacuum, 0, 127) };
|
||||
}
|
||||
if (command.type === 'servo') {
|
||||
const servo = record.meta?.cameraServo;
|
||||
if (!servo?.enabled) return;
|
||||
command.servo.angle = clamp(command.servo?.angle, servo.minAngle ?? 0, servo.maxAngle ?? 180);
|
||||
}
|
||||
const { type, ...data } = command;
|
||||
recordCommandRequest(session.socket, { roverId: session.roverId, type, data }, (result) => {
|
||||
session.lastResult = result.error || result.reason || 'issued';
|
||||
if (result.id) session.lastAction = { at: Date.now(), command };
|
||||
}, (request, reply) => submitCommand(session.socket, request, reply), 'model');
|
||||
}
|
||||
|
||||
function stop(roverId, reason = 'Stopped by user') {
|
||||
const session = sessions.get(roverId);
|
||||
if (!session) return Promise.resolve();
|
||||
if (session.done) return session.done;
|
||||
session.status = 'stopping';
|
||||
session.stopReason = reason;
|
||||
// A stop must still reach the rover after turn permission is revoked.
|
||||
for (const command of [
|
||||
{ type: 'drive', driveDirect: { left: 0, right: 0 } },
|
||||
{ type: 'motors', motorPwm: { main: 0, side: 0, vacuum: 0 } },
|
||||
{ type: 'horn', horn: { action: 'stop' } },
|
||||
]) { try { issueCommand(roverId, command); } catch { /* Rover may already be offline. */ } }
|
||||
session.worker.kill('SIGTERM');
|
||||
const timer = setTimeout(() => session.worker.kill('SIGKILL'), 2000);
|
||||
session.done = session.closed.then(() => {
|
||||
clearTimeout(timer);
|
||||
sessions.delete(roverId);
|
||||
activity('Controller stopped', { roverId, reason });
|
||||
onChange();
|
||||
});
|
||||
onChange();
|
||||
return session.done;
|
||||
}
|
||||
|
||||
async function start(socket, roverId, modelId) {
|
||||
if (stopping || !canControl(socket, roverId)) throw new Error('You cannot control this rover');
|
||||
if (!/^[0-9a-f-]{36}$/.test(modelId)) throw new Error('Invalid model');
|
||||
const directory = path.join(root, 'models', modelId);
|
||||
const metadata = JSON.parse(await fs.readFile(path.join(directory, 'model.json'), 'utf8'));
|
||||
if (metadata.id !== modelId || !compatible(metadata, roverManager.rovers.get(roverId))) {
|
||||
throw new Error('Model is not compatible with this rover');
|
||||
}
|
||||
if (stopping || !canControl(socket, roverId)) throw new Error('Control permission changed');
|
||||
if (sessions.has(roverId)) throw new Error('Stop the current controller before starting another model');
|
||||
const worker = spawn(config.training.python, [path.join(__dirname, 'workers/infer.py'), '--model', directory,
|
||||
'--url', `rtsp://127.0.0.1:8554/${encodeURIComponent(roverId)}`, '--threads', String(config.driving.threads),
|
||||
'--threshold', String(config.driving.actionThreshold)], { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1' } });
|
||||
const session = { worker, socket, roverId, modelId, modelName: metadata.name, status: 'starting', startedAt: Date.now(), lastPredictionAt: null };
|
||||
session.closed = new Promise((resolve) => worker.once('close', resolve));
|
||||
sessions.set(roverId, session);
|
||||
let output = '';
|
||||
let stderr = '';
|
||||
worker.stdin.on('error', () => { stop(roverId, 'Worker input closed'); });
|
||||
worker.stderr.on('data', (chunk) => { stderr = (stderr + chunk).slice(-1000); });
|
||||
worker.on('error', (error) => { stop(roverId, error.message); });
|
||||
worker.once('close', () => { stop(roverId, stderr || 'Worker exited'); });
|
||||
worker.stdout.on('data', (chunk) => {
|
||||
if (session.status === 'stopping') return;
|
||||
output += chunk;
|
||||
if (output.length > 1024 * 1024) { stop(roverId, 'Worker output exceeded limit'); return; }
|
||||
let newline;
|
||||
while ((newline = output.indexOf('\n')) >= 0) {
|
||||
const line = output.slice(0, newline);
|
||||
output = output.slice(newline + 1);
|
||||
try {
|
||||
const message = JSON.parse(line);
|
||||
if (message.kind === 'error') throw new Error(message.message);
|
||||
if (message.kind !== 'prediction') continue;
|
||||
if (!Number.isFinite(message.frameAt) || Math.abs(Date.now() - message.frameAt) > config.driving.staleMs) throw new Error('Video is stale');
|
||||
if (!Number.isFinite(message.sensorAt) || Math.abs(Date.now() - message.sensorAt) > config.driving.staleMs) throw new Error('Sensors are stale');
|
||||
if (!canControl(socket, roverId)) throw new Error('Control permission lost');
|
||||
session.status = 'running';
|
||||
session.lastPredictionAt = Date.now();
|
||||
session.frameAt = message.frameAt;
|
||||
session.sensorAt = message.sensorAt;
|
||||
session.latencyMs = message.latencyMs;
|
||||
if (!Array.isArray(message.commands) || message.commands.length > 64) throw new Error('Invalid action batch');
|
||||
// Manual commands intentionally do not stop or pause this controller.
|
||||
for (const command of message.commands) transmit(session, command);
|
||||
} catch (error) { stop(roverId, error.message); break; }
|
||||
}
|
||||
});
|
||||
const sensors = roverManager.rovers.get(roverId)?.lastSensor?.decoded;
|
||||
if (sensors) sendState(session, { kind: 'sensor', ts: Date.now(), data: sensors });
|
||||
activity('Controller started', { roverId, model: metadata.name, socketId: socket.id });
|
||||
onChange();
|
||||
}
|
||||
|
||||
function sensor(event) {
|
||||
const session = sessions.get(String(event.roverId));
|
||||
if (session) sendState(session, { kind: 'sensor', ts: Date.now(), data: event.sensors });
|
||||
}
|
||||
|
||||
function attach() {
|
||||
roverManager.managerEvents.on('sensor', sensor);
|
||||
unsubscribe = subscribeCommandRecording((event) => {
|
||||
const session = sessions.get(String(event.roverId));
|
||||
if (session && event.phase === 'dispatched') sendState(session, { kind: 'command', ts: event.ts, data: event.command });
|
||||
});
|
||||
watchdog = setInterval(() => {
|
||||
for (const session of sessions.values()) {
|
||||
if (!canControl(session.socket, session.roverId)) stop(session.roverId, 'Control permission lost');
|
||||
else if (Date.now() - (session.lastPredictionAt || session.startedAt) > (session.lastPredictionAt ? config.driving.staleMs : 30000)) {
|
||||
stop(session.roverId, 'No fresh model predictions');
|
||||
}
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
|
||||
return {
|
||||
attach, start, stop, canControl,
|
||||
isActive: (id) => sessions.has(id),
|
||||
getState: (id) => {
|
||||
const session = sessions.get(id);
|
||||
if (!session) return null;
|
||||
const { worker, socket, closed, done, ...state } = session;
|
||||
return { ...state, startedBy: socket.data?.nickname || socket.id, socketId: socket.id };
|
||||
},
|
||||
shutdown: async () => {
|
||||
stopping = true;
|
||||
clearInterval(watchdog);
|
||||
unsubscribe?.();
|
||||
roverManager.managerEvents.off('sensor', sensor);
|
||||
await Promise.all([...sessions.keys()].map((id) => stop(id, 'Service disabled')));
|
||||
},
|
||||
kill: () => { for (const session of sessions.values()) session.worker.kill('SIGKILL'); },
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createDriving, canControl };
|
||||
@@ -0,0 +1,20 @@
|
||||
// Published directories are immutable and become visible via atomic rename.
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
|
||||
async function listModels(root) {
|
||||
const directory = path.join(root, 'models');
|
||||
let entries;
|
||||
try { entries = await fs.readdir(directory, { withFileTypes: true }); }
|
||||
catch (error) { if (error.code === 'ENOENT') return []; throw error; }
|
||||
const models = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || !/^[0-9a-f-]{36}$/.test(entry.name)) continue;
|
||||
const metadata = JSON.parse(await fs.readFile(path.join(directory, entry.name, 'model.json'), 'utf8'));
|
||||
if (metadata.id !== entry.name) throw new Error(`Model identity mismatch: ${entry.name}`);
|
||||
models.push(metadata);
|
||||
}
|
||||
return models.sort((a, b) => b.createdAt - a.createdAt);
|
||||
}
|
||||
|
||||
module.exports = { listModels };
|
||||
@@ -5,6 +5,7 @@ const fsp = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const { randomUUID } = require('node:crypto');
|
||||
const { spawn } = require('node:child_process');
|
||||
const { controlProfile } = require('./capabilities');
|
||||
|
||||
const MAX_BUFFER_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
@@ -20,6 +21,7 @@ async function createRecording({ root, roverId, snapshot, logger }) {
|
||||
await fsp.mkdir(directory);
|
||||
const metadata = {
|
||||
version: 1, id, roverId, startedAt: Date.now(), endedAt: null,
|
||||
controlProfile: controlProfile(snapshot.meta),
|
||||
timing: 'Video PTS uses server receiver Unix seconds. Events use Unix milliseconds and process monotonic nanoseconds. Browser display delay is not measured.',
|
||||
video: { file: 'video.mkv', codec: 'source-copy', timestampUnit: 'seconds', origin: 'unix' },
|
||||
events: 'events.ndjson', droppedEvents: 0,
|
||||
@@ -111,6 +113,8 @@ async function createRecording({ root, roverId, snapshot, logger }) {
|
||||
id, directory, startedAt: metadata.startedAt, append, stop,
|
||||
get failed() { return Boolean(failure); },
|
||||
get stopping() { return stopping; },
|
||||
getStatus: () => ({ id, roverId, startedAt: metadata.startedAt, droppedEvents: metadata.droppedEvents,
|
||||
video: childClosed ? 'stopped' : 'recorder running', error: failure, stopping }),
|
||||
// Process exit is synchronous; normal disable uses stop() and awaits close.
|
||||
kill: () => { if (!childClosed) child.kill('SIGKILL'); },
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Enabled-only recording runtime. No training or driving worker is started here.
|
||||
// Enabled-only recording, training, controller, and card lifecycle.
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const { resolveDataPath } = require('../../helpers/dataPaths');
|
||||
@@ -11,10 +11,27 @@ const { isDeterred, verificationEvents } = require('../verificationService');
|
||||
const { subscribeCommandRecording, commandEvents } = require('../commandService');
|
||||
const { createRecording } = require('./recording');
|
||||
const { recoverRecordings, enforceRetention } = require('./storage');
|
||||
const { createTrainer } = require('./training');
|
||||
const { createDriving } = require('./driving');
|
||||
const { createGateway } = require('./socketGateway');
|
||||
|
||||
function createRuntime({ config, logger }) {
|
||||
const root = resolveDataPath('rover-learning');
|
||||
const recordingsRoot = path.join(root, 'recordings');
|
||||
const recentActivity = [];
|
||||
function activity(message, details = {}) {
|
||||
recentActivity.push({ at: Date.now(), message, roverId: details.roverId || null,
|
||||
detail: details.reason || details.error || details.model || null });
|
||||
if (recentActivity.length > 40) recentActivity.shift();
|
||||
}
|
||||
const baseLogger = logger;
|
||||
logger = Object.fromEntries(['info', 'warn', 'error'].map((level) => [level, (message, details) => {
|
||||
baseLogger[level](message, details); activity(message, details);
|
||||
}]));
|
||||
const trainer = createTrainer({ root, config: config.training, logger });
|
||||
const driving = createDriving({ root, config, onChange: refreshEligibility, activity });
|
||||
let gateway;
|
||||
let diskState = { bytes: null, free: null };
|
||||
const sessions = new Map();
|
||||
const unsubscribers = [];
|
||||
let running = false;
|
||||
@@ -30,6 +47,9 @@ function createRuntime({ config, logger }) {
|
||||
let eligibleRovers = new Set();
|
||||
|
||||
function hasHumanController(roverId) {
|
||||
// Even manual inputs during autonomy remain excluded: model-influenced
|
||||
// video must not silently become human demonstration training data.
|
||||
if (driving.isActive(roverId)) return false;
|
||||
const record = roverManager.rovers.get(roverId);
|
||||
if (!record?.ws) return false;
|
||||
// Admin permission alone must not record the whole fleet. Require actual
|
||||
@@ -120,14 +140,16 @@ function createRuntime({ config, logger }) {
|
||||
}
|
||||
if (!running) return;
|
||||
const retention = await enforceRetention(
|
||||
recordingsRoot, new Set([...sessions.values()].map((session) => session.id)),
|
||||
recordingsRoot, new Set([...sessions.values()].map((session) => session.id).concat([...trainer.leases])),
|
||||
config.recording.maxGiB * 1024 ** 3, config.recording.minimumFreeGiB * 1024 ** 3,
|
||||
);
|
||||
diskState = retention;
|
||||
if (retention.paused) {
|
||||
if (!diskPaused) logger.warn('Recording paused for disk budget', retention);
|
||||
diskPaused = true;
|
||||
pending = [];
|
||||
pendingBytes = 0;
|
||||
await trainer.cancel();
|
||||
// Close active files to make them eligible for pruning on the next tick.
|
||||
await Promise.all([...sessions.keys()].map((id) => closeSession(id, 'disk-budget')));
|
||||
return;
|
||||
@@ -156,6 +178,14 @@ function createRuntime({ config, logger }) {
|
||||
return false;
|
||||
});
|
||||
}
|
||||
if (running) {
|
||||
try {
|
||||
await trainer.tick(new Set([...sessions.values()].map((session) => session.id)));
|
||||
} catch (error) {
|
||||
// Training availability must not tear down otherwise healthy capture.
|
||||
logger.error('Unable to schedule training', { error: error.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
@@ -175,7 +205,11 @@ function createRuntime({ config, logger }) {
|
||||
});
|
||||
}
|
||||
|
||||
function killChildren() { for (const session of sessions.values()) session.kill(); }
|
||||
function killChildren() {
|
||||
driving.kill();
|
||||
trainer.kill();
|
||||
for (const session of sessions.values()) session.kill();
|
||||
}
|
||||
function onSignal() {
|
||||
// Other server services can exit the process before asynchronous cleanup
|
||||
// finishes. Signal children immediately, and keep an exit fallback too.
|
||||
@@ -186,7 +220,16 @@ function createRuntime({ config, logger }) {
|
||||
async function start() {
|
||||
await fs.mkdir(recordingsRoot, { recursive: true });
|
||||
await recoverRecordings(recordingsRoot);
|
||||
await trainer.start();
|
||||
running = true;
|
||||
driving.attach();
|
||||
gateway = createGateway({ root, driving, activity, getSystemState: () => ({
|
||||
recording: { paused: diskPaused, bytes: diskState.bytes, freeBytes: diskState.free,
|
||||
pendingBytes, droppedPending, sessions: [...sessions.values()].map((session) => session.getStatus()) },
|
||||
training: trainer.getState(),
|
||||
limits: { recording: config.recording, training: { ...config.training, python: undefined }, driving: config.driving },
|
||||
activity: [...recentActivity].reverse(),
|
||||
}) });
|
||||
eligibleRovers = currentEligibleRovers();
|
||||
unsubscribers.push(subscribeCommandRecording((event) => append('command', event)));
|
||||
// ACK analytics also provide latency/type context; detailed acknowledgements
|
||||
@@ -226,17 +269,22 @@ function createRuntime({ config, logger }) {
|
||||
}
|
||||
if (running) timer = setTimeout(schedule, wakeRequested ? 0 : 15000);
|
||||
wakeRequested = false;
|
||||
logger.info('Rover learning recording enabled', { root });
|
||||
logger.info('Rover learning enabled', { root, training: config.training.enabled });
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
if (stopPromise) return stopPromise;
|
||||
running = false;
|
||||
gateway?.close();
|
||||
clearTimeout(timer);
|
||||
for (const unsubscribe of unsubscribers.splice(0)) unsubscribe();
|
||||
stopPromise = (async () => {
|
||||
const trainingStop = trainer.stop();
|
||||
const drivingStop = driving.shutdown();
|
||||
await maintenance.catch(() => undefined);
|
||||
const results = await Promise.allSettled([...sessions.keys()].map((id) => closeSession(id, 'disabled')));
|
||||
await trainingStop;
|
||||
await drivingStop;
|
||||
process.off('SIGTERM', onSignal);
|
||||
process.off('SIGINT', onSignal);
|
||||
process.off('exit', killChildren);
|
||||
@@ -248,7 +296,7 @@ function createRuntime({ config, logger }) {
|
||||
return stopPromise;
|
||||
}
|
||||
|
||||
return { start, stop };
|
||||
return { start, stop, getTrainingState: trainer.getState };
|
||||
}
|
||||
|
||||
module.exports = { createRuntime };
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// One complete card snapshot per subscriber; no telemetry-frequency UI events.
|
||||
const io = require('../../globals/io');
|
||||
const roverManager = require('../roverManager');
|
||||
const { isVerified } = require('../verificationService');
|
||||
const { listModels } = require('./models');
|
||||
const { compatible } = require('./capabilities');
|
||||
|
||||
function createGateway({ root, driving, getSystemState, activity }) {
|
||||
const bindings = new Map();
|
||||
const viewers = new Map();
|
||||
let models = [];
|
||||
let modelError = null;
|
||||
let lastCatalog = 0;
|
||||
let closed = false;
|
||||
let refreshing = false;
|
||||
|
||||
function stateFor(socket, roverId) {
|
||||
if (!isVerified(socket)) return { available: false };
|
||||
const visible = roverId && roverManager.canSeeRover(roverId, socket);
|
||||
const id = visible ? roverId : null;
|
||||
const session = id ? driving.getState(id) : null;
|
||||
const canControl = Boolean(id && driving.canControl(socket, id));
|
||||
const system = getSystemState();
|
||||
return {
|
||||
available: true, roverId: id, updatedAt: Date.now(),
|
||||
control: { canStart: canControl && !session, canStop: Boolean(session && (canControl || session.socketId === socket.id)),
|
||||
reason: !id ? 'Select a rover first' : !canControl ? 'You do not currently have control of this rover' : null },
|
||||
session,
|
||||
models: models.filter((model) => model.trainedRovers?.some((rover) => roverManager.canSeeRover(rover, socket)))
|
||||
.map((model) => ({ id: model.id, name: model.name, createdAt: model.createdAt,
|
||||
trainingSteps: model.trainingSteps, examplesProcessed: model.examplesProcessed,
|
||||
weightBytes: model.weightBytes, parameters: model.parameters, trainingLoss: model.trainingLoss,
|
||||
evaluation: model.evaluation, controls: model.specification?.commands?.length || 0,
|
||||
compatible: Boolean(id && compatible(model, roverManager.rovers.get(id))) })),
|
||||
catalogError: modelError,
|
||||
recording: { ...system.recording, sessions: system.recording.sessions.filter((s) => roverManager.canSeeRover(s.roverId, socket)) },
|
||||
training: { ...system.training, sessions: undefined },
|
||||
limits: system.limits,
|
||||
activity: system.activity.filter((entry) => !entry.roverId || roverManager.canSeeRover(entry.roverId, socket)),
|
||||
};
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (closed || refreshing) return;
|
||||
refreshing = true;
|
||||
try {
|
||||
if (Date.now() - lastCatalog >= 15000) {
|
||||
lastCatalog = Date.now();
|
||||
try { models = await listModels(root); modelError = null; }
|
||||
catch (error) { modelError = error.message; }
|
||||
}
|
||||
if (closed) return;
|
||||
for (const [socket, roverId] of viewers) socket.emit('roverLearning:state', stateFor(socket, roverId));
|
||||
} finally { refreshing = false; }
|
||||
}
|
||||
|
||||
function connect(socket) {
|
||||
const subscribe = (payload = {}, reply) => {
|
||||
const respond = typeof reply === 'function' ? reply : () => {};
|
||||
if (!isVerified(socket)) { respond({ error: 'Verification required' }); return; }
|
||||
const roverId = typeof payload?.roverId === 'string' ? payload.roverId : null;
|
||||
viewers.set(socket, roverId);
|
||||
respond({ state: stateFor(socket, roverId) });
|
||||
refresh().catch((error) => activity('Card update failed', { error: error.message }));
|
||||
};
|
||||
const action = async (payload = {}, reply) => {
|
||||
const respond = typeof reply === 'function' ? reply : () => {};
|
||||
try {
|
||||
if (!isVerified(socket)) throw new Error('Verification required');
|
||||
const roverId = String(payload.roverId || '');
|
||||
if (payload.action === 'start') await driving.start(socket, roverId, String(payload.modelId || ''));
|
||||
else if (payload.action === 'stop') {
|
||||
const session = driving.getState(roverId);
|
||||
if (!session || !(driving.canControl(socket, roverId) || session.socketId === socket.id)) throw new Error('You cannot stop this controller');
|
||||
await driving.stop(roverId);
|
||||
} else throw new Error('Unknown action');
|
||||
viewers.set(socket, roverId);
|
||||
respond({ state: stateFor(socket, roverId) });
|
||||
} catch (error) { respond({ error: error.message }); }
|
||||
refresh().catch((error) => activity('Card update failed', { error: error.message }));
|
||||
};
|
||||
const unsubscribe = () => viewers.delete(socket);
|
||||
const disconnect = () => { unsubscribe(); detach(socket); };
|
||||
const handlers = { 'roverLearning:subscribe': subscribe, 'roverLearning:action': action,
|
||||
'roverLearning:unsubscribe': unsubscribe, disconnect };
|
||||
for (const [name, handler] of Object.entries(handlers)) socket.on(name, handler);
|
||||
bindings.set(socket, handlers);
|
||||
}
|
||||
|
||||
function detach(socket) {
|
||||
for (const [name, handler] of Object.entries(bindings.get(socket) || {})) socket.off(name, handler);
|
||||
bindings.delete(socket);
|
||||
}
|
||||
|
||||
io.on('connection', connect);
|
||||
for (const socket of io.sockets.sockets.values()) connect(socket);
|
||||
const timer = setInterval(() => { refresh().catch((error) => activity('Card update failed', { error: error.message })); }, 1000);
|
||||
refresh().catch((error) => activity('Catalog unavailable', { error: error.message }));
|
||||
return {
|
||||
close() {
|
||||
closed = true;
|
||||
clearInterval(timer);
|
||||
io.off('connection', connect);
|
||||
for (const socket of viewers.keys()) socket.emit('roverLearning:state', { available: false });
|
||||
viewers.clear();
|
||||
for (const socket of bindings.keys()) detach(socket);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createGateway };
|
||||
@@ -56,4 +56,4 @@ async function enforceRetention(root, activeIds, maxBytes, minimumFreeBytes) {
|
||||
return { bytes, free, paused: bytes >= maxBytes || free < minimumFreeBytes };
|
||||
}
|
||||
|
||||
module.exports = { recoverRecordings, enforceRetention };
|
||||
module.exports = { recoverRecordings, enforceRetention, listRecordings };
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
// One owned finite worker at a time. Leases are acquired in the runtime's
|
||||
// serialized maintenance pass, before retention can next inspect recordings.
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const { spawn } = require('node:child_process');
|
||||
const { randomInt } = require('node:crypto');
|
||||
const { listRecordings } = require('./storage');
|
||||
|
||||
function createTrainer({ root, config, logger }) {
|
||||
const leases = new Set();
|
||||
const trainingRoot = path.join(root, 'training');
|
||||
let ledger = {};
|
||||
let child = null;
|
||||
let completion = Promise.resolve();
|
||||
let stopped = false;
|
||||
let nextRun = 0;
|
||||
let startupError = false;
|
||||
let state = { status: config.enabled ? 'waiting' : 'disabled', steps: 0, examples: 0 };
|
||||
|
||||
async function start() {
|
||||
if (!config.enabled) return;
|
||||
try {
|
||||
await fs.mkdir(trainingRoot, { recursive: true });
|
||||
await fs.mkdir(path.join(root, 'models'), { recursive: true });
|
||||
// Only unpublished staging directories from an interrupted old worker
|
||||
// are disposable. Permanent UUID model directories are never removed.
|
||||
for (const entry of await fs.readdir(path.join(root, 'models'), { withFileTypes: true })) {
|
||||
if (entry.isDirectory() && /^\.[0-9a-f-]{36}\.pending$/.test(entry.name)) {
|
||||
await fs.rm(path.join(root, 'models', entry.name), { recursive: true });
|
||||
}
|
||||
}
|
||||
try {
|
||||
const saved = JSON.parse(await fs.readFile(path.join(trainingRoot, 'schedule.json'), 'utf8'));
|
||||
ledger = saved.sessions;
|
||||
state = { ...saved.progress, status: 'waiting' };
|
||||
if (!ledger || typeof ledger !== 'object') throw new Error('Invalid training schedule');
|
||||
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
||||
} catch (error) {
|
||||
startupError = true;
|
||||
state = { ...state, status: 'error', error: error.message };
|
||||
logger.error('Training initialization failed; recording remains available', { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async function saveLedger() {
|
||||
const filename = path.join(trainingRoot, 'schedule.json');
|
||||
await fs.writeFile(`${filename}.tmp`, JSON.stringify({ sessions: ledger, progress: state }));
|
||||
await fs.rename(`${filename}.tmp`, filename);
|
||||
}
|
||||
|
||||
async function tick(activeIds) {
|
||||
if (!config.enabled || startupError || stopped || child || Date.now() < nextRun) return;
|
||||
nextRun = Date.now() + config.intervalSeconds * 1000;
|
||||
const recordings = await listRecordings(path.join(root, 'recordings'));
|
||||
const present = new Set(recordings.map((recording) => recording.id));
|
||||
// Scheduling history is bounded by recording retention, unlike models.
|
||||
for (const id of Object.keys(ledger)) if (!present.has(id)) delete ledger[id];
|
||||
const eligible = [];
|
||||
for (const recording of recordings) {
|
||||
if (activeIds.has(recording.id) || ledger[recording.id]?.rejected
|
||||
|| (ledger[recording.id]?.rounds || 0) >= config.roundsPerSession) continue;
|
||||
try {
|
||||
const metadata = JSON.parse(await fs.readFile(path.join(recording.directory, 'session.json'), 'utf8'));
|
||||
if (!metadata.endedAt || metadata.error || metadata.droppedEvents || metadata.reason === 'interrupted') continue;
|
||||
eligible.push(recording.id);
|
||||
} catch (error) {
|
||||
ledger[recording.id] = { rejected: `Unreadable session metadata: ${error.message}` };
|
||||
}
|
||||
}
|
||||
// Random cohorts mix old and new retained sessions without repeatedly
|
||||
// favoring the first files in directory order.
|
||||
for (let index = eligible.length - 1; index > 0; index -= 1) {
|
||||
const other = randomInt(index + 1);
|
||||
[eligible[index], eligible[other]] = [eligible[other], eligible[index]];
|
||||
}
|
||||
const selected = eligible.slice(0, 4);
|
||||
nextRun = Date.now() + config.intervalSeconds * 1000;
|
||||
if (!selected.length) {
|
||||
state = { ...state, status: 'waiting' };
|
||||
await saveLedger();
|
||||
return;
|
||||
}
|
||||
const jobPath = path.join(trainingRoot, 'job.json');
|
||||
await fs.writeFile(jobPath, JSON.stringify({ root, config, sessions: selected }));
|
||||
if (stopped) return;
|
||||
selected.forEach((id) => leases.add(id));
|
||||
state = { ...state, status: 'training', sessions: selected };
|
||||
state.startedAt = Date.now();
|
||||
child = spawn(config.python, [path.join(__dirname, 'workers/train.py'), '--job', jobPath], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1', OMP_NUM_THREADS: String(config.threads), MKL_NUM_THREADS: String(config.threads) },
|
||||
});
|
||||
let output = '';
|
||||
let stderr = '';
|
||||
let result = null;
|
||||
let errorMessage = null;
|
||||
let forcedTimer;
|
||||
const worker = child;
|
||||
const deadline = setTimeout(() => {
|
||||
worker.kill('SIGTERM');
|
||||
forcedTimer = setTimeout(() => worker.kill('SIGKILL'), 5000);
|
||||
}, (config.maxJobSeconds + 15) * 1000);
|
||||
child.on('error', (error) => { errorMessage = error.message; });
|
||||
child.stderr.on('data', (chunk) => { stderr = (stderr + chunk).slice(-8192); });
|
||||
child.stdout.on('data', (chunk) => {
|
||||
output += chunk;
|
||||
if (output.length > 1024 * 1024) {
|
||||
errorMessage = 'Training worker output exceeded protocol bound';
|
||||
worker.kill('SIGKILL');
|
||||
return;
|
||||
}
|
||||
let newline;
|
||||
while ((newline = output.indexOf('\n')) >= 0) {
|
||||
const line = output.slice(0, newline);
|
||||
output = output.slice(newline + 1);
|
||||
try {
|
||||
const message = JSON.parse(line);
|
||||
if (message.kind === 'complete') result = message;
|
||||
if (message.kind === 'error') errorMessage = message.message;
|
||||
if (message.kind === 'skipped') logger.warn('Training skipped recording', message);
|
||||
if (message.kind === 'dataset') state = { ...state, lastSampledWindows: message.sampled, lastAvailableWindows: message.windows };
|
||||
if (message.kind === 'progress') state = { ...state, steps: message.steps, examples: message.examples, loss: message.loss };
|
||||
} catch { errorMessage = 'Invalid training worker message'; }
|
||||
}
|
||||
});
|
||||
completion = new Promise((resolve) => {
|
||||
child.once('close', async (code, signal) => {
|
||||
clearTimeout(deadline);
|
||||
clearTimeout(forcedTimer);
|
||||
try {
|
||||
if (code !== 0 || errorMessage || !result) {
|
||||
state = { ...state, status: stopped ? 'stopped' : 'error', error: errorMessage || `Worker exited ${code ?? signal}` };
|
||||
if (!stopped) logger.error('Training job failed', { error: state.error, stderr });
|
||||
} else {
|
||||
for (const [id, reason] of Object.entries(result.rejected || {})) ledger[id] = { rejected: reason };
|
||||
if (result.trained) {
|
||||
for (const id of result.accepted) ledger[id] = { rounds: (ledger[id]?.rounds || 0) + 1 };
|
||||
}
|
||||
state = { ...state, status: stopped ? 'stopped' : 'waiting', steps: result.steps ?? state.steps,
|
||||
completedAt: Date.now(), elapsedSeconds: result.elapsedSeconds ?? null, error: null,
|
||||
examples: result.examples ?? state.examples, loss: result.loss ?? null, reason: result.reason ?? null };
|
||||
await saveLedger();
|
||||
logger.info('Training job completed', { ...state, model: result.published?.name || null });
|
||||
}
|
||||
} catch (error) {
|
||||
state = { ...state, status: 'error', error: error.message };
|
||||
logger.error('Training result persistence failed', { error: error.message });
|
||||
} finally {
|
||||
leases.clear();
|
||||
child = null;
|
||||
nextRun = Date.now() + config.intervalSeconds * 1000;
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function cancel() {
|
||||
if (!child) return;
|
||||
const worker = child;
|
||||
worker.kill('SIGTERM');
|
||||
const timer = setTimeout(() => worker.kill('SIGKILL'), 5000);
|
||||
try { await completion; } finally { clearTimeout(timer); }
|
||||
}
|
||||
|
||||
return {
|
||||
leases, start, tick, cancel,
|
||||
getState: () => ({ ...state, nextAttemptAt: nextRun || null, leasedSessions: leases.size,
|
||||
visitedSessions: Object.values(ledger).filter((entry) => entry.rounds).length,
|
||||
skippedSessions: Object.values(ledger).filter((entry) => entry.rejected).length,
|
||||
rejectionReasons: [...new Set(Object.values(ledger).filter((entry) => entry.rejected).map((entry) => entry.rejected))].slice(0, 10) }),
|
||||
stop: async () => { stopped = true; await cancel(); },
|
||||
kill: () => child?.kill('SIGKILL'),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createTrainer };
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Read closed recordings with causal state/action alignment and bounded memory."""
|
||||
import bisect
|
||||
import collections
|
||||
import copy
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import av
|
||||
import numpy as np
|
||||
|
||||
from policy import SLOTS, VALUES, HEIGHT, WIDTH, encode_command, sensor_vector
|
||||
|
||||
|
||||
def load_events(directory):
|
||||
metadata = json.loads((directory / 'session.json').read_text())
|
||||
if (metadata.get('version') != 1 or not metadata.get('endedAt') or metadata.get('error')
|
||||
or metadata.get('droppedEvents') or metadata.get('reason') == 'interrupted'):
|
||||
raise ValueError('Incomplete/interrupted recording or lost events')
|
||||
if metadata.get('video', {}).get('origin') != 'unix':
|
||||
raise ValueError('Unsupported video time origin')
|
||||
video_result = metadata.get('video', {}).get('result') or {}
|
||||
if video_result.get('signal') == 'SIGKILL' or video_result.get('code') not in (0, 255, None):
|
||||
raise ValueError('Video recorder failed or was forcibly killed')
|
||||
event_file = directory / 'events.ndjson'
|
||||
if event_file.stat().st_size > 64 * 1024 * 1024:
|
||||
raise ValueError('Event file exceeds 64 MiB read bound')
|
||||
events = []
|
||||
with event_file.open() as stream:
|
||||
for line in stream:
|
||||
event = json.loads(line)
|
||||
if event['kind'] == 'captureGap':
|
||||
raise ValueError('Recording contains a capture gap')
|
||||
if not isinstance(event.get('ts'), (int, float)) or not math.isfinite(event['ts']):
|
||||
raise ValueError('Invalid event timestamp')
|
||||
events.append(event)
|
||||
if not events or not any(e['kind'] == 'sessionEnd' for e in events):
|
||||
raise ValueError('Missing completed event stream')
|
||||
# File order can differ across rotation buffering. Monotonic time detects
|
||||
# wall-clock steps; sorting Unix timestamps alone would hide those steps.
|
||||
timed = sorted(events, key=lambda e: int(e['monotonicNs']))
|
||||
origin = timed[0]
|
||||
for event in timed:
|
||||
drift = (event['ts'] - origin['ts']) - (int(event['monotonicNs']) - int(origin['monotonicNs'])) / 1e6
|
||||
if abs(drift) > 250:
|
||||
raise ValueError('Server wall clock shifted during recording')
|
||||
return metadata, sorted(events, key=lambda e: e['ts'])
|
||||
|
||||
|
||||
def prepare(directory, schema):
|
||||
metadata, events = load_events(directory)
|
||||
requests = {}
|
||||
accepted = set()
|
||||
failed = set()
|
||||
sensors, dispatches, boundaries = [], [], []
|
||||
for event in events:
|
||||
data = event['data']
|
||||
if event['kind'] == 'sensor':
|
||||
sensors.append((event['ts'], sensor_vector(data.get('sensors') or {})))
|
||||
if event['kind'] in ('driver', 'turn:activeDriver', 'lock', 'private'):
|
||||
boundaries.append(event['ts'])
|
||||
if event['kind'] != 'command':
|
||||
continue
|
||||
if data.get('phase') == 'requested' and data.get('source') == 'client':
|
||||
requests[data.get('requestId')] = data
|
||||
if data.get('phase') == 'result' and data.get('result', {}).get('id'):
|
||||
accepted.add(data['result']['id'])
|
||||
if data.get('phase') == 'acknowledged' and data.get('acknowledgement', {}).get('error'):
|
||||
failed.add(data.get('commandId'))
|
||||
if data.get('phase') == 'dispatched':
|
||||
dispatches.append(data)
|
||||
if not sensors:
|
||||
raise ValueError('No sensor observations')
|
||||
candidate = copy.deepcopy(schema)
|
||||
history, targets = [], []
|
||||
for data in dispatches:
|
||||
encoded = encode_command(data.get('command') or {}, candidate, extend=True)
|
||||
if encoded is None:
|
||||
continue
|
||||
stamp = data['ts']
|
||||
history.append((stamp, *encoded))
|
||||
request = requests.get(data.get('requestId'))
|
||||
if (data.get('source') == 'client' and request
|
||||
and data.get('commandId') in accepted and data.get('commandId') not in failed):
|
||||
target_time = request['ts']
|
||||
if not 0 <= stamp - target_time <= 1000:
|
||||
raise ValueError('Invalid request/dispatch timing')
|
||||
targets.append((target_time, *encoded))
|
||||
else:
|
||||
# Server safety overrides belong in history, never demonstration
|
||||
# targets. Exclude windows around them rather than teaching inaction.
|
||||
boundaries.append(stamp)
|
||||
if not targets:
|
||||
raise ValueError('No accepted client operating commands')
|
||||
schema[:] = candidate
|
||||
return metadata, sensors, sorted(history, key=lambda x: x[0]), sorted(targets, key=lambda x: x[0]), sorted(boundaries)
|
||||
|
||||
|
||||
def windows(directory, schema, cancelled=lambda: False):
|
||||
directory = Path(directory)
|
||||
metadata, sensors, history, targets, boundaries = prepare(directory, schema)
|
||||
sensor_times = [row[0] for row in sensors]
|
||||
target_times = [row[0] for row in targets]
|
||||
available = np.zeros(SLOTS, dtype=np.float32)
|
||||
for _, slot, _ in history:
|
||||
available[slot] = 1
|
||||
command_values = np.zeros((SLOTS, VALUES), dtype=np.float32)
|
||||
command_times = np.full(SLOTS, -np.inf)
|
||||
cursor = 0
|
||||
recent = collections.deque(maxlen=32)
|
||||
last_sample, last_pts = -math.inf, -math.inf
|
||||
with av.open(str(directory / 'video.mkv')) as container:
|
||||
stream = container.streams.video[0]
|
||||
stream.codec_context.thread_count = 1
|
||||
for frame in container.decode(stream):
|
||||
if cancelled():
|
||||
return
|
||||
if frame.pts is None:
|
||||
raise ValueError('Video frame has no timestamp')
|
||||
stamp = float(frame.pts * frame.time_base) * 1000
|
||||
if not math.isfinite(stamp) or stamp < last_pts:
|
||||
raise ValueError('Invalid/nonmonotonic video timestamps')
|
||||
last_pts = stamp
|
||||
if not metadata['startedAt'] - 2000 <= stamp <= metadata['endedAt'] + 2000:
|
||||
raise ValueError('Video PTS is not aligned to session Unix time')
|
||||
if stamp - last_sample < 99:
|
||||
continue
|
||||
last_sample = stamp
|
||||
while cursor < len(history) and history[cursor][0] < stamp:
|
||||
when, slot, values = history[cursor]
|
||||
command_values[slot] = values
|
||||
command_times[slot] = when
|
||||
cursor += 1
|
||||
sensor_index = bisect.bisect_right(sensor_times, stamp) - 1
|
||||
if sensor_index < 0 or stamp - sensor_times[sensor_index] > 1000:
|
||||
recent.clear()
|
||||
continue
|
||||
seen = np.isfinite(command_times).astype(np.float32)
|
||||
ages = np.minimum((stamp - command_times) / 10000, 1).astype(np.float32)
|
||||
state = np.concatenate((sensors[sensor_index][1], command_values.flatten(), ages, seen))
|
||||
pixels = frame.to_ndarray(width=WIDTH, height=HEIGHT, format='rgb24').transpose(2, 0, 1).copy()
|
||||
recent.append((stamp, pixels, state))
|
||||
chosen = []
|
||||
for offset in (900, 600, 300, 0):
|
||||
options = [row for row in recent if row[0] <= stamp - offset + 0.01]
|
||||
if not options or stamp - offset - options[-1][0] > 200:
|
||||
break
|
||||
chosen.append(options[-1])
|
||||
if len(chosen) != 4 or stamp + 100 > metadata['endedAt']:
|
||||
continue
|
||||
if bisect.bisect_right(boundaries, stamp + 100) > bisect.bisect_left(boundaries, chosen[0][0]):
|
||||
continue
|
||||
start = bisect.bisect_left(target_times, stamp)
|
||||
end = bisect.bisect_left(target_times, stamp + 100)
|
||||
actions = np.zeros(SLOTS, dtype=np.float32)
|
||||
values = np.zeros((SLOTS, VALUES), dtype=np.float32)
|
||||
ambiguous = False
|
||||
families = set()
|
||||
for _, slot, vector in targets[start:end]:
|
||||
template = schema[slot]['template']
|
||||
family = template['type']
|
||||
if family == 'peripheral':
|
||||
peripheral = template.get('peripheral') or {}
|
||||
family += ':' + str(peripheral.get('id')) + ':' + str(peripheral.get('control'))
|
||||
if ((actions[slot] and (not schema[slot]['fields'] or not np.array_equal(values[slot], vector)))
|
||||
or (not actions[slot] and family in families)):
|
||||
ambiguous = True
|
||||
break
|
||||
families.add(family)
|
||||
actions[slot], values[slot] = 1, vector
|
||||
if ambiguous:
|
||||
continue
|
||||
yield np.stack([row[1] for row in chosen]), np.stack([row[2] for row in chosen]), actions, values, available
|
||||
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Live policy worker. JSON state on stdin, proposals on stdout; no rover writes."""
|
||||
import argparse
|
||||
import collections
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
import av
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
from policy import Policy, VERSION, SLOTS, VALUES, HEIGHT, WIDTH, encode_command, sensor_vector
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--model', required=True)
|
||||
parser.add_argument('--url', required=True)
|
||||
parser.add_argument('--threads', type=int, default=4)
|
||||
parser.add_argument('--threshold', type=float, default=0.7)
|
||||
args = parser.parse_args()
|
||||
torch.set_num_threads(args.threads)
|
||||
torch.set_num_interop_threads(1)
|
||||
directory = Path(args.model)
|
||||
metadata = json.loads((directory / 'model.json').read_text())
|
||||
if metadata['specification']['version'] != VERSION:
|
||||
raise ValueError('Model specification is incompatible')
|
||||
if hashlib.sha256((directory / 'weights.pt').read_bytes()).hexdigest() != metadata['sha256']:
|
||||
raise ValueError('Model weights checksum mismatch')
|
||||
schema = metadata['specification']['commands']
|
||||
model = Policy().eval()
|
||||
model.load_state_dict(torch.load(directory / 'weights.pt', map_location='cpu', weights_only=True))
|
||||
observations = collections.deque(maxlen=2048)
|
||||
mutex = threading.Lock()
|
||||
closed = threading.Event()
|
||||
|
||||
def read_input():
|
||||
try:
|
||||
for line in sys.stdin:
|
||||
event = json.loads(line)
|
||||
with mutex:
|
||||
observations.append(event)
|
||||
finally:
|
||||
closed.set()
|
||||
|
||||
threading.Thread(target=read_input, daemon=True).start()
|
||||
values = np.zeros((SLOTS, VALUES), dtype=np.float32)
|
||||
last_commands = np.full(SLOTS, -np.inf)
|
||||
sensors, sensor_time = {}, 0
|
||||
history = collections.deque(maxlen=32)
|
||||
latched = set()
|
||||
sampled = 0
|
||||
with av.open(args.url, options={'rtsp_transport': 'tcp', 'timeout': '3000000',
|
||||
'use_wallclock_as_timestamps': '1'}) as video:
|
||||
stream = video.streams.video[0]
|
||||
stream.codec_context.thread_count = 1
|
||||
for frame in video.decode(stream):
|
||||
if closed.is_set():
|
||||
return
|
||||
if frame.pts is None:
|
||||
continue
|
||||
stamp = float(frame.pts * frame.time_base) * 1000
|
||||
now = time.time() * 1000
|
||||
if not math.isfinite(stamp) or abs(now - stamp) > 2000:
|
||||
raise ValueError('Video time is stale or not aligned to server time')
|
||||
if stamp - sampled < 99:
|
||||
continue
|
||||
sampled = stamp
|
||||
with mutex:
|
||||
while observations and observations[0]['ts'] < stamp:
|
||||
event = observations.popleft()
|
||||
if event['kind'] == 'sensor':
|
||||
sensors, sensor_time = event['data'], event['ts']
|
||||
elif event['kind'] == 'command':
|
||||
try:
|
||||
encoded = encode_command(event['data'], schema)
|
||||
if encoded:
|
||||
slot, vector = encoded
|
||||
values[slot], last_commands[slot] = vector, event['ts']
|
||||
except ValueError:
|
||||
pass # Unseen manual controls do not stop autonomy.
|
||||
if stamp - sensor_time > 1000:
|
||||
history.clear()
|
||||
continue
|
||||
state = np.concatenate((sensor_vector(sensors), values.flatten(),
|
||||
np.minimum((stamp - last_commands) / 10000, 1).astype(np.float32),
|
||||
np.isfinite(last_commands).astype(np.float32)))
|
||||
pixels = frame.to_ndarray(width=WIDTH, height=HEIGHT, format='rgb24').transpose(2, 0, 1).copy()
|
||||
history.append((stamp, pixels, state))
|
||||
selected = []
|
||||
for offset in (900, 600, 300, 0):
|
||||
candidates = [entry for entry in history if entry[0] <= stamp - offset + .01]
|
||||
if not candidates or stamp - offset - candidates[-1][0] > 200:
|
||||
break
|
||||
selected.append(candidates[-1])
|
||||
if len(selected) != 4:
|
||||
continue
|
||||
before = time.perf_counter()
|
||||
with torch.inference_mode():
|
||||
logits, numeric = model(torch.from_numpy(np.stack([row[1] for row in selected])).unsqueeze(0).float() / 255,
|
||||
torch.from_numpy(np.stack([row[2] for row in selected])).unsqueeze(0))
|
||||
probabilities = logits[0].sigmoid().tolist()
|
||||
predictions = numeric[0].tolist()
|
||||
if not all(math.isfinite(value) for value in probabilities):
|
||||
raise ValueError('Nonfinite model output')
|
||||
families = {}
|
||||
active = set()
|
||||
for slot, descriptor in enumerate(schema):
|
||||
if probabilities[slot] < args.threshold:
|
||||
continue
|
||||
active.add(slot)
|
||||
command = json.loads(json.dumps(descriptor['template']))
|
||||
family = command['type']
|
||||
if family == 'peripheral':
|
||||
family += ':' + str(command['peripheral']['id']) + ':' + str(command['peripheral']['control'])
|
||||
for index, field in enumerate(descriptor['fields']):
|
||||
number = predictions[slot][index]
|
||||
if not math.isfinite(number):
|
||||
raise ValueError('Nonfinite control value')
|
||||
number = math.tan(max(-.98, min(.98, number)) * math.pi / 2) * field['scale']
|
||||
target = command
|
||||
for key in field['path'][:-1]:
|
||||
target = target[key]
|
||||
target[field['path'][-1]] = round(number) if field['integer'] else number
|
||||
discrete = command['type'] in ('headlight', 'laser', 'horn', 'song', 'raw')
|
||||
if family not in families or probabilities[slot] > families[family][0]:
|
||||
families[family] = (probabilities[slot], command, slot, discrete)
|
||||
latched.intersection_update(active)
|
||||
# Choose the winning action before latching, so a held toggle cannot
|
||||
# expose a competing lower-confidence toggle on the following tick.
|
||||
commands = [command for _, command, slot, discrete in families.values()
|
||||
if not discrete or slot not in latched]
|
||||
for _, _, slot, discrete in families.values():
|
||||
if discrete:
|
||||
latched.add(slot)
|
||||
print(json.dumps({'kind': 'prediction', 'frameAt': stamp, 'sensorAt': sensor_time,
|
||||
'latencyMs': (time.perf_counter() - before) * 1000,
|
||||
'commands': commands}, allow_nan=False), flush=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except Exception as error:
|
||||
print(json.dumps({'kind': 'error', 'message': str(error)}), flush=True)
|
||||
raise SystemExit(1)
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Versioned, fixed-size vision/control policy and command encoding.
|
||||
|
||||
Slots describe command shapes, not arbitrary executable instructions. Numeric
|
||||
leaves are regressed; string/bool/list values remain exact template constants.
|
||||
No command is sent by this module. Execution must use server authorization.
|
||||
"""
|
||||
import copy
|
||||
import json
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from torch import nn
|
||||
|
||||
VERSION = 1
|
||||
SLOTS = 64
|
||||
VALUES = 8
|
||||
FRAMES = 4
|
||||
HEIGHT, WIDTH = 120, 160
|
||||
OPERATING_TYPES = {"drive", "motors", "servo", "headlight", "laser", "horn", "peripheral", "song", "raw"}
|
||||
SENSORS = [
|
||||
("bumpsAndWheelDrops.bumpLeft", 1), ("bumpsAndWheelDrops.bumpRight", 1),
|
||||
("bumpsAndWheelDrops.wheelDropLeft", 1), ("bumpsAndWheelDrops.wheelDropRight", 1),
|
||||
("cliffLeft", 1), ("cliffFrontLeft", 1), ("cliffFrontRight", 1), ("cliffRight", 1),
|
||||
("wall", 1), ("virtualWall", 1), ("wallSignal", 4096),
|
||||
("voltageMv", 16000), ("currentMa", 3000), ("batteryChargeMah", 4000),
|
||||
("batteryCapacityMah", 4000), ("batteryTemperatureC", 50),
|
||||
("chargingState.code", 5), ("chargingSources.homeBase", 1), ("oiMode.code", 4),
|
||||
("wheelSpeedsMmPerSecond.left", 500), ("wheelSpeedsMmPerSecond.right", 500),
|
||||
("wheelLeftCurrentMa", 2000), ("wheelRightCurrentMa", 2000),
|
||||
("mainBrushCurrentMa", 2000), ("sideBrushCurrentMa", 2000),
|
||||
("lightBumpLeftSignal", 4096), ("lightBumpRightSignal", 4096),
|
||||
("cliffLeftSignal", 4096), ("cliffRightSignal", 4096),
|
||||
("distanceMm", 500), ("angleDeg", 180), ("stasis", 1),
|
||||
]
|
||||
STATE_SIZE = len(SENSORS) * 2 + SLOTS * (VALUES + 2)
|
||||
|
||||
|
||||
def normalize(value, scale):
|
||||
return math.atan(float(value) / scale) * 2 / math.pi
|
||||
|
||||
|
||||
def sensor_vector(sensors):
|
||||
values, present = [], []
|
||||
for name, scale in SENSORS:
|
||||
value = sensors
|
||||
for key in name.split('.'):
|
||||
value = value.get(key) if isinstance(value, dict) else None
|
||||
known = isinstance(value, (int, float)) and math.isfinite(value)
|
||||
values.append(normalize(value, scale) if known else 0)
|
||||
present.append(float(known))
|
||||
return np.array(values + present, dtype=np.float32)
|
||||
|
||||
|
||||
def describe_command(command):
|
||||
if command.get('type') not in OPERATING_TYPES:
|
||||
return None
|
||||
if len(json.dumps(command)) > 4096:
|
||||
raise ValueError('Operating command exceeds 4096-byte schema limit')
|
||||
values, fields = [], []
|
||||
|
||||
def visit(value, path=()):
|
||||
if isinstance(value, dict):
|
||||
return {key: visit(item, (*path, key)) for key, item in sorted(value.items())}
|
||||
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
||||
if not math.isfinite(value) or len(values) >= VALUES:
|
||||
raise ValueError('Nonfinite command or too many numeric control fields')
|
||||
scale = {'drive': 500, 'motors': 255, 'servo': 180, 'peripheral': 255,
|
||||
'horn': 1000, 'song': 127}.get(command['type'], 1)
|
||||
fields.append({'path': list(path), 'scale': scale, 'integer': isinstance(value, int)})
|
||||
values.append(normalize(value, scale))
|
||||
return {'$number': len(values) - 1}
|
||||
# Arrays (e.g. songs) are discrete exact demonstrations, not variable
|
||||
# length regression outputs. Never reinterpret raw OI bytes as numbers.
|
||||
return copy.deepcopy(value)
|
||||
|
||||
template = visit(command)
|
||||
return {'template': template, 'fields': fields}, values
|
||||
|
||||
|
||||
def encode_command(command, schema, extend=False):
|
||||
described = describe_command(command)
|
||||
if described is None:
|
||||
return None
|
||||
descriptor, values = described
|
||||
try:
|
||||
slot = schema.index(descriptor)
|
||||
except ValueError:
|
||||
if not extend:
|
||||
raise ValueError('Command shape missing from model schema')
|
||||
if len(schema) >= SLOTS:
|
||||
raise ValueError('64 command shapes exhausted; recording skipped, not silently truncated')
|
||||
slot = len(schema)
|
||||
schema.append(descriptor)
|
||||
vector = np.zeros(VALUES, dtype=np.float32)
|
||||
vector[:len(values)] = values
|
||||
return slot, vector
|
||||
|
||||
|
||||
class Policy(nn.Module):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.vision = nn.Sequential(
|
||||
nn.Conv2d(3, 24, 5, 2, 2), nn.ReLU(),
|
||||
nn.Conv2d(24, 48, 3, 2, 1), nn.ReLU(),
|
||||
nn.Conv2d(48, 96, 3, 2, 1), nn.ReLU(),
|
||||
nn.Conv2d(96, 128, 3, 2, 1), nn.ReLU(),
|
||||
nn.AdaptiveAvgPool2d((3, 4)), nn.Flatten(),
|
||||
nn.Linear(128 * 3 * 4, 128), nn.ReLU(),
|
||||
)
|
||||
self.history = nn.GRU(128 + STATE_SIZE, 128, batch_first=True)
|
||||
self.actions = nn.Linear(128, SLOTS)
|
||||
self.values = nn.Linear(128, SLOTS * VALUES)
|
||||
|
||||
def forward(self, images, state):
|
||||
batch, frames = images.shape[:2]
|
||||
encoded = self.vision(images.reshape(-1, 3, HEIGHT, WIDTH)).reshape(batch, frames, -1)
|
||||
history, _ = self.history(torch.cat((encoded, state), dim=-1))
|
||||
last = history[:, -1]
|
||||
return self.actions(last), self.values(last).reshape(batch, SLOTS, VALUES).tanh()
|
||||
|
||||
|
||||
def specification(schema):
|
||||
return {'version': VERSION, 'architecture': 'spatial-cnn-gru-v1',
|
||||
'frames': FRAMES, 'height': HEIGHT, 'width': WIDTH,
|
||||
'historyStrideMs': 300, 'decisionIntervalMs': 100,
|
||||
'stateSize': STATE_SIZE, 'sensorFields': SENSORS,
|
||||
'normalization': '2/pi * atan(value/scale); missing sensors have mask=0',
|
||||
'maxCommandShapes': SLOTS, 'numericFieldsPerShape': VALUES,
|
||||
'commands': schema, 'actionSemantics': 'Events in the next 100 ms; zero means no new command',
|
||||
'timing': 'Server-receipt alignment; browser display latency unmeasured'}
|
||||
@@ -0,0 +1,4 @@
|
||||
--extra-index-url https://download.pytorch.org/whl/cpu
|
||||
torch==2.14.0+cpu
|
||||
numpy==2.5.3
|
||||
av==18.1.0
|
||||
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env python3
|
||||
"""One finite CPU training job; Node owns scheduling, leases, and shutdown.
|
||||
|
||||
Usage: python train.py --job /path/to/job.json
|
||||
Dependencies are packaged at image build time, never installed at runtime.
|
||||
"""
|
||||
import argparse
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import random
|
||||
import signal
|
||||
import time
|
||||
import uuid
|
||||
|
||||
|
||||
def emit(kind, **data):
|
||||
print(json.dumps({'kind': kind, **data}, allow_nan=False), flush=True)
|
||||
|
||||
|
||||
def atomic_torch_save(torch, value, path):
|
||||
temporary = path.with_suffix('.tmp')
|
||||
with temporary.open('wb') as stream:
|
||||
torch.save(value, stream)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--job')
|
||||
parser.add_argument('--check-dependencies', action='store_true', help='Describe the installed production policy and exit without creating storage')
|
||||
args = parser.parse_args()
|
||||
# --help works even before Python dependencies are installed.
|
||||
import numpy as np
|
||||
import av
|
||||
import torch
|
||||
from torch.nn import functional as functional
|
||||
from dataset import windows
|
||||
from policy import Policy, VERSION, SLOTS, VALUES, specification
|
||||
|
||||
if args.check_dependencies:
|
||||
emit('dependencies', torch=str(torch.__version__), numpy=str(np.__version__), av=str(av.__version__),
|
||||
parameters=sum(p.numel() for p in Policy().parameters()), specification=specification([]))
|
||||
return
|
||||
if not args.job:
|
||||
parser.error('--job is required unless --check-dependencies is used')
|
||||
|
||||
job = json.loads(Path(args.job).read_text())
|
||||
root = Path(job['root'])
|
||||
config = job['config']
|
||||
torch.set_num_threads(config['threads'])
|
||||
torch.set_num_interop_threads(1)
|
||||
# Training yields CPU priority to the Node/media processes. Thread count is
|
||||
# an additional bound, not a claim to reserve specific physical cores.
|
||||
os.nice(10)
|
||||
stopped = False
|
||||
|
||||
def stop(_signal, _frame):
|
||||
nonlocal stopped
|
||||
stopped = True
|
||||
|
||||
signal.signal(signal.SIGTERM, stop)
|
||||
signal.signal(signal.SIGINT, stop)
|
||||
started = time.monotonic()
|
||||
cancelled = lambda: stopped or time.monotonic() - started >= config['maxJobSeconds']
|
||||
model = Policy().cpu()
|
||||
optimizer = torch.optim.AdamW(model.parameters(), lr=0.0001)
|
||||
resume_path = root / 'training' / 'resume.pt'
|
||||
schema, steps, examples, last_published = [], 0, 0, 0
|
||||
trained_rovers = set()
|
||||
control_profiles = []
|
||||
if resume_path.exists():
|
||||
saved = torch.load(resume_path, map_location='cpu', weights_only=True)
|
||||
if saved['version'] != VERSION:
|
||||
raise ValueError('Resume schema incompatible; refusing to reinterpret existing weights')
|
||||
model.load_state_dict(saved['weights'])
|
||||
optimizer.load_state_dict(saved['optimizer'])
|
||||
schema = saved['schema']
|
||||
steps, examples = saved['steps'], saved['examples']
|
||||
last_published = saved['lastPublished']
|
||||
trained_rovers = set(saved['trainedRovers'])
|
||||
control_profiles = saved.get('controlProfiles', [])
|
||||
initial_steps = steps
|
||||
pool, accepted, rejected = [], [], {}
|
||||
sessions = list(job['sessions'])
|
||||
random.shuffle(sessions)
|
||||
per_session = max(1, config['maxSamples'] // max(1, len(sessions)))
|
||||
for session_id in sessions:
|
||||
if cancelled():
|
||||
break
|
||||
candidate_schema = copy.deepcopy(schema)
|
||||
samples, seen = [], 0
|
||||
try:
|
||||
directory = root / 'recordings' / session_id
|
||||
for sample in windows(directory, candidate_schema, cancelled):
|
||||
seen += 1
|
||||
# Uniform reservoir across the whole recording, not just the
|
||||
# opening seconds. Frames remain uint8 until a batch is used.
|
||||
if len(samples) < per_session:
|
||||
samples.append(sample)
|
||||
else:
|
||||
index = random.randrange(seen)
|
||||
if index < per_session:
|
||||
samples[index] = sample
|
||||
if cancelled():
|
||||
break # Do not call a partially decoded recording validated.
|
||||
if not samples:
|
||||
raise ValueError('No complete, aligned observation/action windows')
|
||||
schema = candidate_schema
|
||||
pool.extend(samples)
|
||||
accepted.append(session_id)
|
||||
metadata = json.loads((directory / 'session.json').read_text())
|
||||
trained_rovers.add(str(metadata['roverId']))
|
||||
profile = metadata.get('controlProfile')
|
||||
if profile is not None and profile not in control_profiles:
|
||||
control_profiles.append(profile)
|
||||
emit('dataset', sessionId=session_id, windows=seen, sampled=len(samples))
|
||||
except (ValueError, KeyError, TypeError, OSError, EOFError, IndexError, OverflowError, av.error.FFmpegError) as error:
|
||||
rejected[session_id] = str(error)[:500]
|
||||
emit('skipped', sessionId=session_id, reason=rejected[session_id])
|
||||
if len(pool) < config['minimumSamples'] or not any(sample[2].any() for sample in pool) or cancelled():
|
||||
emit('complete', trained=False, accepted=[], rejected=rejected, samples=len(pool),
|
||||
reason='Stopped/time limit' if cancelled() else 'Waiting for more usable samples')
|
||||
return
|
||||
|
||||
numeric_mask = torch.zeros(SLOTS, VALUES)
|
||||
for slot, descriptor in enumerate(schema):
|
||||
numeric_mask[slot, :len(descriptor['fields'])] = 1
|
||||
# Balance sparse events without assuming that every time step should fire
|
||||
# every control. This is training loss, not a driving-quality evaluation.
|
||||
positives = np.sum([sample[2] for sample in pool], axis=0)
|
||||
opportunities = np.sum([sample[4] for sample in pool], axis=0)
|
||||
positive_weight = torch.from_numpy(np.clip((opportunities - positives) / np.maximum(positives, 1), 1, 20)).float()
|
||||
losses = []
|
||||
model.train()
|
||||
for _ in range(config['passesPerJob']):
|
||||
random.shuffle(pool)
|
||||
for offset in range(0, len(pool), config['batchSize']):
|
||||
if cancelled() or steps - initial_steps >= config['maxStepsPerJob']:
|
||||
break
|
||||
batch = pool[offset:offset + config['batchSize']]
|
||||
images, states, targets, values, available = [torch.from_numpy(np.stack(items)) for items in zip(*batch)]
|
||||
images = images.float().div_(255)
|
||||
optimizer.zero_grad(set_to_none=True)
|
||||
action_logits, predictions = model(images, states)
|
||||
event_loss = functional.binary_cross_entropy_with_logits(action_logits, targets,
|
||||
pos_weight=positive_weight, reduction='none')
|
||||
event_loss = (event_loss * available).sum() / available.sum().clamp_min(1)
|
||||
mask = numeric_mask.unsqueeze(0) * targets.unsqueeze(-1)
|
||||
value_loss = ((predictions - values).square() * mask).sum() / mask.sum().clamp_min(1)
|
||||
loss = event_loss + value_loss
|
||||
if not torch.isfinite(loss):
|
||||
raise ValueError('Nonfinite training loss; checkpoint not saved')
|
||||
loss.backward()
|
||||
torch.nn.utils.clip_grad_norm_(model.parameters(), 1)
|
||||
optimizer.step()
|
||||
steps += 1
|
||||
examples += len(batch)
|
||||
losses.append(loss.item())
|
||||
if steps % 10 == 0:
|
||||
emit('progress', steps=steps, examples=examples, loss=loss.item())
|
||||
if cancelled() or steps - initial_steps >= config['maxStepsPerJob']:
|
||||
break
|
||||
if steps == initial_steps:
|
||||
emit('complete', trained=False, accepted=[], rejected=rejected, reason='No training steps completed')
|
||||
return
|
||||
|
||||
published = None
|
||||
if steps - last_published >= config['checkpointEverySteps']:
|
||||
adjectives = ['amber', 'curious', 'gentle', 'quiet', 'silver', 'bright', 'merry', 'sleepy']
|
||||
animals = ['otter', 'finch', 'badger', 'robin', 'fox', 'heron', 'marten', 'wren']
|
||||
model_id = str(uuid.uuid4())
|
||||
name = f'{random.choice(adjectives)}-{random.choice(animals)}-{model_id[:8]}'
|
||||
staging = root / 'models' / f'.{model_id}.pending'
|
||||
staging.mkdir()
|
||||
weights_path = staging / 'weights.pt'
|
||||
atomic_torch_save(torch, model.state_dict(), weights_path)
|
||||
published = {
|
||||
'id': model_id, 'name': name, 'createdAt': int(time.time() * 1000),
|
||||
'trainingSteps': steps, 'examplesProcessed': examples,
|
||||
'parameters': sum(p.numel() for p in model.parameters()),
|
||||
'weightBytes': weights_path.stat().st_size,
|
||||
'sha256': hashlib.sha256(weights_path.read_bytes()).hexdigest(),
|
||||
'specification': specification(schema), 'trainedRovers': sorted(trained_rovers),
|
||||
'controlProfiles': control_profiles,
|
||||
'sourceSessions': accepted, 'trainingLoss': sum(losses) / len(losses),
|
||||
'evaluation': 'unevaluated', 'dtype': 'float32', 'torchVersion': str(torch.__version__),
|
||||
}
|
||||
(staging / 'model.json').write_text(json.dumps(published, indent=2, allow_nan=False))
|
||||
# A catalog reader sees both weights and metadata, or neither. Existing
|
||||
# versions are never overwritten or deleted by the trainer.
|
||||
staging.rename(root / 'models' / model_id)
|
||||
last_published = steps
|
||||
atomic_torch_save(torch, {
|
||||
'version': VERSION, 'weights': model.state_dict(), 'optimizer': optimizer.state_dict(),
|
||||
'schema': schema, 'steps': steps, 'examples': examples,
|
||||
'lastPublished': last_published, 'trainedRovers': sorted(trained_rovers),
|
||||
'controlProfiles': control_profiles,
|
||||
}, resume_path)
|
||||
emit('complete', trained=True, accepted=accepted, rejected=rejected,
|
||||
steps=steps, examples=examples, loss=sum(losses) / len(losses),
|
||||
published=published, elapsedSeconds=time.monotonic() - started)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except Exception as error:
|
||||
emit('error', message=str(error))
|
||||
raise SystemExit(1)
|
||||
@@ -11,6 +11,7 @@ import VipVerificationCard from '../vip/VipVerificationCard.jsx';
|
||||
import VipIdentityCard from '../vip/VipIdentityCard.jsx';
|
||||
import VipPrivateRoverAccessCard from '../vip/VipPrivateRoverAccessCard.jsx';
|
||||
import VipProfileImageCard from '../vip/VipProfileImageCard.jsx';
|
||||
import VipRoverLearningCard from '../vip/VipRoverLearningCard.jsx';
|
||||
|
||||
export default function VipPanel() {
|
||||
const session = useSessionSelector((state) => state.session);
|
||||
@@ -54,6 +55,9 @@ export default function VipPanel() {
|
||||
return (
|
||||
<section className="@container space-y-2 text-base">
|
||||
<div className="grid grid-cols-1 gap-2 @[32rem]:grid-cols-[minmax(0,1.25fr)_minmax(0,0.9fr)]">
|
||||
{isVerified && session?.features?.roverLearning ? (
|
||||
<div className="@[32rem]:col-span-2"><VipRoverLearningCard roverId={ownRoverId || null} /></div>
|
||||
) : null}
|
||||
{!isVerified ? (
|
||||
<div className="@[32rem]:col-span-2">
|
||||
<VipVerificationCard
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
// One service snapshot supplies this entire card. Favorites never leave settings.
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSocket } from '../../context/SocketContext.jsx';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import { fieldClass } from './constants.js';
|
||||
|
||||
const display = (value) => value == null ? 'Unavailable' : String(value);
|
||||
const bytes = (value) => value == null ? 'Unavailable' : `${(value / 1024 ** 2).toFixed(1)} MiB`;
|
||||
const time = (value) => value ? new Date(value).toLocaleString() : 'Unavailable';
|
||||
const age = (now, value) => value ? `${Math.max(0, (now - value) / 1000).toFixed(1)} s` : 'Unavailable';
|
||||
|
||||
function Row({ label, children }) {
|
||||
return <div className="flex flex-wrap items-baseline justify-between gap-x-2 gap-y-0.5 py-0.5">
|
||||
<dt className="text-slate-400">{label}</dt>
|
||||
<dd className="min-w-0 break-words text-right text-slate-200">{children}</dd>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function Group({ title, children }) {
|
||||
return <section className="surface-muted min-w-0 space-y-1">
|
||||
<h4 className="font-semibold text-slate-200">{title}</h4>{children}
|
||||
</section>;
|
||||
}
|
||||
|
||||
export default function VipRoverLearningCard({ roverId }) {
|
||||
const socket = useSocket();
|
||||
const [snapshot, setSnapshot] = useState(null);
|
||||
const [selectedId, setSelectedId] = useState('');
|
||||
const [working, setWorking] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const { value: settings, save } = useSettingsNamespace('roverLearning', { favorites: [] });
|
||||
const favorites = Array.isArray(settings?.favorites) ? settings.favorites : [];
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const receive = (state) => { if (active) setSnapshot(state); };
|
||||
const subscribe = () => {
|
||||
if (!socket.connected) return;
|
||||
socket.timeout(5000).emit('roverLearning:subscribe', { roverId }, (failure, response) => {
|
||||
if (!failure && response?.state) receive(response.state);
|
||||
});
|
||||
};
|
||||
const disconnected = () => receive(null);
|
||||
socket.on('roverLearning:state', receive);
|
||||
socket.on('connect', subscribe);
|
||||
socket.on('disconnect', disconnected);
|
||||
subscribe();
|
||||
// A config enable can mount this card before async service startup has
|
||||
// attached handlers. Resubscribe periodically without another data API.
|
||||
const timer = setInterval(subscribe, 10000);
|
||||
return () => {
|
||||
active = false;
|
||||
clearInterval(timer);
|
||||
socket.off('roverLearning:state', receive);
|
||||
socket.off('connect', subscribe);
|
||||
socket.off('disconnect', disconnected);
|
||||
socket.emit('roverLearning:unsubscribe');
|
||||
};
|
||||
}, [socket, roverId]);
|
||||
|
||||
const state = snapshot?.roverId === roverId ? snapshot : null;
|
||||
const models = [...(state?.models || [])].sort((a, b) => Number(favorites.includes(b.id)) - Number(favorites.includes(a.id)) || b.createdAt - a.createdAt);
|
||||
const chosen = models.find((model) => model.id === selectedId) || models.find((model) => model.compatible) || models[0];
|
||||
const session = state?.session;
|
||||
const training = state?.training;
|
||||
const recording = state?.recording;
|
||||
const limits = state?.limits;
|
||||
const now = state?.updatedAt || 0;
|
||||
|
||||
const act = (action) => {
|
||||
setWorking(true);
|
||||
setError('');
|
||||
socket.timeout(10000).emit('roverLearning:action', { action, roverId, modelId: chosen?.id }, (failure, response) => {
|
||||
setWorking(false);
|
||||
if (failure || response?.error) setError(response?.error || 'The server did not respond. Check the controller status before trying again.');
|
||||
else if (response?.state) setSnapshot(response.state);
|
||||
});
|
||||
};
|
||||
const favorite = () => {
|
||||
if (!chosen) return;
|
||||
save((current) => {
|
||||
const ids = Array.isArray(current?.favorites) ? current.favorites : [];
|
||||
return { ...current, favorites: ids.includes(chosen.id) ? ids.filter((id) => id !== chosen.id) : [...ids, chosen.id] };
|
||||
});
|
||||
};
|
||||
|
||||
return <CardFrame title="Rover learning" className="w-full" bodyClassName="text-sm text-slate-300">
|
||||
<div className="space-y-2">
|
||||
<p className="text-center text-xs text-slate-400">
|
||||
{session ? `${session.modelName} · ${session.status}` : state ? 'Choose a model to control your rover.' : 'Waiting for rover learning status…'}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-1">
|
||||
<select aria-label="Rover model" className={fieldClass} value={chosen?.id || ''} onChange={(event) => setSelectedId(event.target.value)} disabled={working || !models.length}>
|
||||
{!models.length && <option value="">No published models yet</option>}
|
||||
{models.map((model) => <option key={model.id} value={model.id}>{favorites.includes(model.id) ? '★ ' : ''}{model.name}{model.compatible ? '' : ' (incompatible)'}</option>)}
|
||||
</select>
|
||||
<button type="button" className="button-dark text-sm disabled:opacity-50" aria-pressed={Boolean(chosen && favorites.includes(chosen.id))} disabled={!chosen} onClick={favorite}>
|
||||
{chosen && favorites.includes(chosen.id) ? '★ Favorite' : '☆ Favorite'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex justify-center gap-1">
|
||||
<button type="button" className="button-dark text-sm disabled:opacity-50" disabled={working || !state?.control?.canStart || !chosen?.compatible} onClick={() => act('start')}>Start</button>
|
||||
<button type="button" className="button-dark text-sm disabled:opacity-50" disabled={working || !state?.control?.canStop} onClick={() => act('stop')}>Stop</button>
|
||||
</div>
|
||||
{state?.control?.reason && <p className="text-center text-xs text-slate-400">{state.control.reason}</p>}
|
||||
<p className="text-center text-xs text-slate-500">Manual controls stay available and do not stop the model.</p>
|
||||
<p className="text-center text-xs text-slate-400">Recording: {recording ? recording.paused ? 'paused for disk space' : `${recording.sessions.length} human session(s)` : 'unavailable'} · Training: {training?.status || 'unavailable'}</p>
|
||||
{(error || state?.catalogError) && <p role="alert" className="break-words text-xs text-amber-300">{error || state.catalogError}</p>}
|
||||
<details className="surface-muted text-xs">
|
||||
<summary className="cursor-pointer py-1 font-semibold text-slate-200">System details</summary>
|
||||
<div className="mt-2 grid min-w-0 grid-cols-1 gap-2 @[36rem]:grid-cols-2">
|
||||
<Group title="Recording and storage"><dl>
|
||||
<Row label="Recording size">{bytes(recording?.bytes)}</Row>
|
||||
<Row label="Disk budget">{limits ? `${limits.recording.maxGiB} GiB` : 'Unavailable'}</Row>
|
||||
<Row label="Free disk space">{bytes(recording?.freeBytes)}</Row>
|
||||
<Row label="Minimum free space">{limits ? `${limits.recording.minimumFreeGiB} GiB` : 'Unavailable'}</Row>
|
||||
<Row label="Session rotation">{limits ? `${limits.recording.sessionSeconds} s` : 'Unavailable'}</Row>
|
||||
<Row label="Buffered events">{bytes(recording?.pendingBytes)}</Row>
|
||||
<Row label="Dropped buffered events">{display(recording?.droppedPending)}</Row>
|
||||
</dl>{recording?.sessions.map((item) => <div key={item.id} className="mt-1 border-t border-slate-700 pt-1">
|
||||
<p>{item.roverId} · {item.video} · {age(now, item.startedAt)}</p>
|
||||
<p>Dropped events: {item.droppedEvents}</p>{item.error && <p className="text-amber-300">{item.error}</p>}
|
||||
</div>)}</Group>
|
||||
<Group title="Dataset and training"><dl>
|
||||
<Row label="Status">{training?.status || 'Unavailable'}</Row>
|
||||
<Row label="Steps">{display(training?.steps)}</Row>
|
||||
<Row label="Examples processed">{display(training?.examples)}</Row>
|
||||
<Row label="Training loss">{training?.loss == null ? 'Unavailable' : training.loss.toFixed(4)}</Row>
|
||||
<Row label="Current job age">{training?.status === 'training' ? age(now, training.startedAt) : 'Not running'}</Row>
|
||||
<Row label="Next attempt">{time(training?.nextAttemptAt)}</Row>
|
||||
<Row label="Visited / skipped sessions">{training ? `${training.visitedSessions} / ${training.skippedSessions}` : 'Unavailable'}</Row>
|
||||
<Row label="Sessions protected from retention">{display(training?.leasedSessions)}</Row>
|
||||
<Row label="Last windows available / sampled">{training?.lastAvailableWindows == null ? 'Unavailable' : `${training.lastAvailableWindows} / ${training.lastSampledWindows}`}</Row>
|
||||
<Row label="CPU threads / batch size">{limits ? `${limits.training.threads} / ${limits.training.batchSize}` : 'Unavailable'}</Row>
|
||||
<Row label="Samples / passes per job">{limits ? `${limits.training.maxSamples} / ${limits.training.passesPerJob}` : 'Unavailable'}</Row>
|
||||
<Row label="Steps between publications">{display(limits?.training.checkpointEverySteps)}</Row>
|
||||
</dl>{training?.error && <p className="break-words text-amber-300">{training.error}</p>}
|
||||
{training?.reason && <p>{training.reason}</p>}
|
||||
{training?.rejectionReasons?.map((reason) => <p className="break-words text-amber-300" key={reason}>{reason}</p>)}
|
||||
</Group>
|
||||
<Group title="Selected model"><dl>
|
||||
<Row label="Name">{chosen?.name || 'None'}</Row>
|
||||
<Row label="Created">{time(chosen?.createdAt)}</Row>
|
||||
<Row label="Size">{bytes(chosen?.weightBytes)}</Row>
|
||||
<Row label="Parameters">{chosen?.parameters?.toLocaleString() || 'Unavailable'}</Row>
|
||||
<Row label="Steps / examples">{chosen ? `${chosen.trainingSteps} / ${chosen.examplesProcessed}` : 'Unavailable'}</Row>
|
||||
<Row label="Control shapes">{display(chosen?.controls)}</Row>
|
||||
<Row label="Evaluation">{chosen?.evaluation || 'Unavailable'}</Row>
|
||||
<Row label="Published models">{models.length}</Row>
|
||||
</dl><p className="mt-1 text-slate-500">Models are kept permanently. Favorites are saved in this browser.</p></Group>
|
||||
<Group title="Live controller"><dl>
|
||||
<Row label="State">{session?.status || 'Stopped'}</Row>
|
||||
<Row label="Started by">{session?.startedBy || 'Unavailable'}</Row>
|
||||
<Row label="Duration">{age(now, session?.startedAt)}</Row>
|
||||
<Row label="Inference time">{session?.latencyMs == null ? 'Unavailable' : `${session.latencyMs.toFixed(1)} ms`}</Row>
|
||||
<Row label="Video age">{age(now, session?.frameAt)}</Row>
|
||||
<Row label="Sensor age">{age(now, session?.sensorAt)}</Row>
|
||||
<Row label="Last command result">{session?.lastResult || 'Unavailable'}</Row>
|
||||
<Row label="CPU threads">{display(limits?.driving.threads)}</Row>
|
||||
</dl>{session?.lastAction && <pre className="mt-1 whitespace-pre-wrap break-all text-slate-400">{JSON.stringify(session.lastAction.command, null, 2)}</pre>}
|
||||
{session?.stopReason && <p>{session.stopReason}</p>}
|
||||
</Group>
|
||||
<div className="@[36rem]:col-span-2"><Group title="Recent activity">
|
||||
<ul className="max-h-56 space-y-1 overflow-y-auto">
|
||||
{state?.activity?.map((entry, index) => <li key={`${entry.at}-${index}`} className="break-words">
|
||||
<span className="text-slate-500">{time(entry.at)}</span> · {entry.message}{entry.roverId ? ` · ${entry.roverId}` : ''}{entry.detail ? ` · ${entry.detail}` : ''}
|
||||
</li>)}
|
||||
</ul>
|
||||
</Group></div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</CardFrame>;
|
||||
}
|
||||
Reference in New Issue
Block a user