Archive/device plane - #10
decoded-cipher wants to merge 45 commits into
Conversation
Statements ran one at a time, so a failure part-way left the schema half-applied and the retry restarted from the first statement. That is only safe while every statement is idempotent — the first ALTER TABLE would turn a partial failure into a permanent failure loop on an instance nobody can see. db.batch() is a real transaction. The applied_at write rides inside it, so d1_migrations can never record a migration that did not commit.
Durable Objects get no migration runner, so each class now carries an ordered list of schema changes and walks itself forward inside transactionSync() the first time it wakes after a deploy. One that throws rolls back rather than stranding storage between versions, and the constructor runs before any method, so no caller sees a half-migrated object. The baseline is the existing schema unchanged, so an object that predates versioning replays it as a no-op and converges on the version a fresh object reaches. SchedulerDO is key-value only and needs none of this.
The build preserved each deployment's wrangler.toml verbatim, which froze its topology at whatever the Deploy button wrote on day one: a binding, cron or compatibility flag added upstream never reached anyone who had already deployed. Bindings, flags and build config now come from upstream's carrier template, while the Worker name, account, routes, resource ids and vars come from the deployment. Taking upstream's name instead would create a second Worker and orphan the live one, so identity is merged in by binding rather than by position. The build script is fetched from master while the source clone is the latest release tag, so a release predating the merge script falls back to the previous behaviour instead of failing.
The monitor holds an exclusive reader on the port and the flasher needs it closed, so both go through claim(), which tears down the read loop, closes the port, hands it over, and restores the monitor at its previous baud. Two owners racing for the same lock is what produces "port already open" errors that survive a reload. Console lines are source-tagged, because flash progress never arrives over the port — esptool-js speaks binary to the ROM loader and reports separately. Web Serial is absent from TypeScript's DOM lib, hence the types dependency.
Splits the stream into levels the console can colour: SDK lines by their message, ESP-IDF lines by their severity letter, boot ROM output by its prefixes, and anything else as plain sketch output. Entries commit once per animation frame rather than per line — a board at 115200 baud can outrun per-line reactivity. Pausing holds new lines aside instead of dropping them. Tracks whether recent output is mostly replacement and control characters, which is what a wrong baud rate looks like.
A Device section under each project, with a console that connects a board over Web Serial and shows what it prints. Output follows the tail only while the view is at the bottom — a chatty board otherwise makes it impossible to read back through a fault. Browsers without Web Serial get a page naming the ones that do and saying this is the only part of nodrix that needs USB, rather than a dead button. The hub takes the tabbed shape Variables and Automations already use, with one tab for now.
Turns SDK debug output into a banner above the console — "Token rejected", "The server refused the connection" — instead of leaving a red line for the reader to interpret. The newest significant line wins, so recovering from a fault clears the stale error. Wi-Fi state is tracked separately from cloud state, which is what makes a compound reading possible: "Wi-Fi is up" in front of a server failure says the radio is fine and the problem is further along. diagnose() takes an entry array and holds no reactive state, so the rules are tested directly rather than through a mounted component.
Adds a devices table and a default device per project, then rebuilds project_variables with device_id in its unique key. Telemetry that names no device lands on the default, so a plain curl with only a token keeps working. project_variables is rebuilt rather than altered because device_id is NOT NULL with a foreign key and the unique key changes shape. Nothing references that table by foreign key, so the rebuild needs no deferral. Default device ids derive from the project id, which keeps the migration deterministic without generating ids in SQL, and one default per project is enforced by a partial unique index rather than left to convention. Tested against SQLite: an instance seeded before the upgrade and a fresh install come out with byte-identical schemas, and replaying the baseline over a migrated database does not reinstate the old two-column unique index.
Boards identify themselves with an X-Nodrix-Device header; the value maps to a device row, created on first sight. Anything that names no device lands on the project's default, so an existing board or a plain curl keeps working untouched. The reported key is untrusted input — normalised, length-capped, and bounded at 100 devices per project so a board that reports a fresh key every boot can't grow the table without limit. It is kept apart from the device id so renaming stays free and a MAC never surfaces in anything a user reads. Device creation reads back after inserting rather than trusting the id it generated: two isolates racing on the same first boot both insert, one loses the conflict, and returning its own id would attribute telemetry to a row that does not exist. createProject now writes the project and its default device in one batch. The migration only covers projects that already existed, so without this a project created after upgrading would have no default device at all.
latest_state is rebuilt on (device_id, variable), ring_buffer and pending_control gain the column, and the ring index follows. '' marks the project's default device and every pre-devices row backfills to it. NULL cannot serve there: SQLite treats NULLs as distinct in a unique key, so the upsert would never match and each write would append a duplicate row instead of updating one. pending_control does use NULL, where it means a control write with no target that still broadcasts. R2 keeps its shape — the device goes in the NDJSON row, not the key path, so an hour isn't fragmented into one small object per device. Rows written before this read back as the default device. Series reads take an optional device filter; omitting it queries the whole project, which is what dashboards still do.
Ingest now carries the device through to Durable Object storage, and /state groups by device rather than returning a flat key map. Those had to change together: two boards reporting the same key into a flat map means the second silently overwrites the first, which is the whole point of having devices. resolveDevice returns both ids it is asked for — the D1 id that owns the variable rows, and the storage id the DO keys by, which is '' for the default device. Callers get what they need without either of them knowing the rule. Adds list, rename and forget. The default device can't be forgotten; without one, telemetry that names no device has nowhere to land. Forgetting drops D1 rows and DO state but leaves R2 history, which is only safe because the device is named in each row rather than in the key path.
Adds a hello frame carrying the device key and optional chip and firmware. An all-WS board never sends an HTTP header, so without this it had no way to say which board it was and everything landed on the default device. The socket's device is held in serializeAttachment rather than memory: a hibernated Durable Object keeps the attachment but loses anything in an isolate, so an idle board that wakes hours later stays attributed. Connect and hello split the pending-control flush. A socket has to catch up the moment it connects, but its identity isn't known until hello arrives, so it starts as the default device and takes broadcasts and default-device writes, then re-scopes and drains its own queue on hello. The two queries can't overlap, and a hello that fails to resolve returns rather than falling through to the default. Control writes can now target one device; a null target still broadcasts.
Devices becomes the first tab under Device and the console moves alongside it. Renaming matters more than it looks: a board identifies itself by something MAC-derived, and without a name that string would surface in every variable a user reads. The default device is badged and has no Forget control, mirroring the server rule rather than trusting the client to know it. The forget confirmation spells out what happens, because none of it is guessable: variables and recent history go, archived telemetry stays, and the board reappears if it ever reports again. Focus on the rename input is set from a function ref. A template ref declared inside v-for collects into an array — the compiler decides that by lexical position, so the v-if narrowing it to one row makes no difference — and calling focus() on the array would have thrown.
Variable triggers take an optional device, defaulting to any. Both readings are real — one rule per place, or one rule for every place — and neither was expressible before. An automation now stays on the device that fired it: set_variable targets it and condition reads come from its state, so a rule about one greenhouse can't switch the fan in another. Pending control was returned in full to any polling board, so a second device executed writes meant for the first. Listing and acking are both scoped now — a device sees broadcasts and its own, and can't consume another's queue. Triggers name devices by their D1 id while storage calls the default device '', so the id is mapped before comparing. Without that a trigger pinned to the default device matches nothing and the automation silently never fires.
Drives esptool-js through the port handoff the console already owns, so the monitor tears down cleanly and comes back when the write finishes. Flasher progress is pushed into the same console buffer. It arrives out of band — the port is speaking binary to the ROM loader at the time — and without it the console simply goes dead for twenty seconds mid-session. after() resets the board once writing completes; skipping it leaves the chip in the ROM bootloader until it is physically unplugged, which reads as a bricked board. The transport is disconnected in a finally so a failed flash doesn't leave the port held for the rest of the session. esptool-js is 106 kB, so it stays in the lazily loaded Flash chunk rather than the main bundle.
GitHub's API sends CORS headers but release assets do not — they redirect to a host that sends none — so the browser can read the catalogue and cannot read the binary. The worker proxies both, which also keeps every browser off the unauthenticated GitHub rate limit. The download endpoint takes a tag and a filename, never a URL, and builds the target from a fixed repo. Both parts are charset-checked and '..' is rejected on its own, since the charset allows dots and v1..2 would otherwise climb out of the release path. The catalogue caches in KV behind an ETag. No published release reads as an empty catalogue rather than an error, and the panel falls back to picking a local .bin, which is what it did before.
Assigning firmware to a device sets desired state and returns. The board compares its reported version against that and pulls when they differ — nothing is pushed at it, no job is tracked, and the slow part runs on the ESP32 where a Worker CPU limit can't reach it. Devices get two endpoints, both on the token and device header they already use for telemetry: one asks whether to update, one streams the image from R2. Success is the board reporting the desired version on its next hello. That's the only honest signal available — the cloud cannot know a device booted. A failed insert deletes the image it already wrote, so losing the unique version race can't strand an object in R2 that nothing points at. The nudge crosses into Durable Object storage, where the default device is '' rather than its D1 id, so it goes through storageIdOf. That mapping has now caught three call sites and lives in one place.
The devices table reads "Running" and "Should run" rather than offering a start button, because that is what the system does — you state desired state and the board reconciles when it next checks. There is no job to watch. The upload form warns that the version must match what the sketch reports. A mismatch updates the board and never marks it done, which is the easiest way to be confused by any of this. Images post as a raw body rather than through the JSON helper; base64 in JSON would inflate a megabyte image by a third for nothing.
A CodeMirror editor with C++ highlighting, seeded from the SDK examples and kept per project in localStorage. Example sources are read at the same release tag the binaries were built from, not the default branch, so what's on screen is what a published image was compiled from. raw.githubusercontent sends CORS headers, so unlike release assets this needs no proxy. The compile box explains why a browser can't run a C++ toolchain and what to do instead, rather than offering a button that does nothing. CodeMirror and esptool-js are both excluded from the service worker precache. Workbox globs every chunk, so ~630 kB of toolchain was being downloaded on install by people who may never open either tab.
Deleting a variable cleared its hot state for every device in the project, not the one that owned it. Five sensors reporting temp from five places meant removing one wiped all five — the exact case device scoping exists for. Deleting a project wiped telemetry/ but not firmware/, leaving images in R2 that nothing could reach and nothing would ever remove.
The migration to 2.0 runs once, unattended, on instances nobody can observe. Every other safeguard — batched statements, transaction-wrapped Durable Object steps — lowers the chance of failure without putting a floor under it. This is the floor: a copy taken before upgrading, and something to compare against afterwards. NDJSON, one typed record per line, streamed from an async generator so a project carrying a year of telemetry never sits in memory. No secrets leave: token hashes, sealed integration config and dashboard share tokens are all omitted. That makes the file safe to hand to someone, and means it restores data rather than credentials.
The image endpoint serves about a megabyte a call and had no throttle. A boot-looping board — likelier than an attacker — would pull it in a loop against R2 egress. The counter lives in Durable Object SQLite rather than KV. The existing auth throttle is deliberately soft and fails open, which is right when the cost of over-blocking is locking someone out of their own instance. Here the cost is money, and a device hammering in a loop can outrun an eventually consistent counter, so this one is strongly consistent and fails closed.
Durable Objects have no migration runner, so an object created today and one that predates devices arrive by different routes. The divergence that matters is silent — a column NOT NULL on one and nullable on the other — so the two are now compared directly rather than assumed equal. Also asserts what the '' sentinel exists for: two writes to the default device produce one row. With NULL they would produce two, because SQLite treats NULLs as distinct in a unique key. The schema ladder moves to its own module so a test can drive it without pulling in the Durable Object runtime.
The ring buffer capped 1,000 rows for the whole project. That is generous with one board and quietly wrong with five — 200 points each, charts thinning as hardware is added, and nothing anywhere explaining why.
Derived from last_seen at read time rather than stored. A device that goes quiet never writes anything, so there is nothing to flip a stored flag.
A key stopped being unique once variables became device-scoped. set_variable checked existence with project and key alone, so an LLM could target one device using a variable that only exists on another. Reading with no device still spans the project, which is what a single-device instance always returned. Writing with no device targets the default rather than broadcasting — a model steering hardware should reach one board unless it says otherwise.
Every upload was an artifact nothing would ever remove — around 1.5 MB each against a 10 GB free tier, unbounded. Keeps the ten most recent, plus anything a device runs or is waiting to run. Those exclusions are the point: dropping a desired image strands a pending update behind a dangling reference, and dropping a running version loses the image a board in the field is actually on. Pruning can't fail an upload that already succeeded, so a failure leaves one extra image behind until the next one.
Dashboard widgets bind to a bare variable key, but storage is per device now. A second board reporting the same key made getLatestState return both rows — the widget showing whichever came last — and merged both into one chart series. Silently, with no error and no way to tell. The automation engine had the same shape for schedule, sunset and manual runs, which carry no device. That one was accidentally right, since '' sorts before any generated id; it no longer depends on collation. /state still returns every device grouped — showing all of them is its job.
Widgets bind to a bare variable key, so a dashboard needs to say which board those keys belong to. Layout gains an optional device; absent means the project's default, which is what every dashboard did before devices existed — so no stored layout changes and nothing needs migrating. Dashboard-level rather than per-widget. Per-widget would touch every widget's props, the extractor, the builder and the snapshot API to fetch several devices at once; this composes with that later rather than blocking it. The cost today is that five sensors in five places means five dashboards.
The selector only appears once a project has more than one device, so a single-board instance gains no control it has no use for. normalizeLayout rebuilds the layout field by field and was dropping device when rescaling an older grid, which would have quietly reset a dashboard to the default device on the next save.
The agent dials into ProjectDO and its socket is tagged in the attachment, so agent frames and device frames route apart on the same object — no new Durable Object class. Nothing is queued for an absent agent. Compiling is work on someone's physical machine, and a queue would fire builds hours later when a laptop reopens; no agent connected is an immediate 409. The browser's request is held open until the agent answers, which works because a Worker has no wall-clock limit while a client is connected. Five minutes, since a first build installs a toolchain. Owner and admin at both ends, on the socket and on the build request. A member triggering a build would be arbitrary toolchain execution on someone else's laptop.
recordDeviceSeen had one caller — the WebSocket hello handler — so HTTP-mode boards never updated last_seen and the online indicator read them as never seen. On an upgraded instance every device is the default device, so that is every device. touchDevice writes at most once a minute per device and is called from HTTP ingest, the control poll, the OTA check and image download, and WS telemetry (hello alone left a long-lived socket looking stale). The WS path also attributed variables to the default device regardless of which device the socket belonged to, so a named board's telemetry landed under its own id while its variable rows were created elsewhere.
requestBuild carried the whole binary back as base64 through an object that is also running ingest, the ring buffer, R2 flushes and automation evaluation for every board in the project. The DO now carries job control only. The agent PUTs to /v1/agent/artifact which streams straight into R2, then reports the result, so ok always means the object is collectable; the browser fetches it once and the route deletes it. builds/ joins the project-delete prefix loop, and a sweep on each new build drops anything a timed-out request left behind.
… shipped An HTTP-mode board also never reported the version it runs, so offerFor compared against NULL, offered the update, and would have gone on offering it after it succeeded — boot, flash, restart, repeat, bounded only by the hourly download quota. The check route now reads X-Nodrix-Firmware and X-Nodrix-Chip and feeds them to the same recordDeviceSeen and reconcile pair the hello frame uses. offerFor takes the reported version and prefers it over the stored one, so the answer is right in the same request instead of one round trip later.
The device plane: author, build, flash, provision, observe, update
requestBuild buffered every log line and handed them over at the end, so a cold toolchain install was minutes of a spinner and then a wall of text — which reads as a hang, and is exactly what makes people retry. It now returns NDJSON as the agent emits it: log frames, then one result frame carrying the outcome. RPC only carries byte-oriented streams, so the readable is built with type: 'bytes'; a plain TransformStream typechecks and fails at runtime. The route is a 200 either way now, since the outcome is in the body rather than the status.
It offered three things. Flashing a prebuilt SDK example is now "load the example into the editor and press Flash". Flashing a .bin you already have is not something the platform needs to own. A hand-entered flash offset is guesswork the build itself knows. That takes the release-asset proxy with it — binaryUrl, fetchBinary, the two regexes standing between it and an open redirect, and the test guarding them. The catalogue stays: the editor's example picker reads it. The build artifact also stops being deleted on read, so one build can be flashed over USB and kept for OTA rather than one or the other. The hourly sweep already collects whatever nobody promotes.
The upload form asked for a .bin and a version string, and warned in its own help text that the version "must match what the sketch reports, or the device will never be marked updated". That mismatch was possible only because uploading was divorced from building. A build now becomes a firmware version by being kept: publishBuild copies the artifact out of builds/ and gives it a row. The version is the build id, which the agent stamps into the sketch at compile time, so what the board reports and what the row says are the same string by construction. Code offers both destinations for one compile — flash over USB, or save for OTA — and reuses the artifact rather than rebuilding. Assignment moves onto the Devices table, so the firmware page and its endpoint both go.
The offset was hardcoded to 0x10000, which is where an ESP32 app sits above its bootloader and partition table. An ESP8266 sketch is the whole image and starts at zero, so picking NodeMCU wrote 64 KB past the start and left a board that could not boot. The offset now comes from the selected board.
Writing a sketch, watching it build, and watching the board boot were three tabs, so the two halves of one loop never shared a screen. Editor on top, a dock underneath with Console and Serial monitor. The dock follows the build, then switches to the monitor once a flash lands, which is the moment the board starts printing. Flash progress rides in the tab strip rather than taking a panel of its own. CodeMirror grows with its content by default, which would push the dock off screen on a long sketch, so the editor now fills the pane and scrolls itself. SerialConsole moves to components/SerialMonitor and fills the height it is given instead of naming its own. Device tabs are down to Devices and Code. The FlashPanel precache ignore goes with them — it stopped matching anything when that page was deleted.
One page to write, build, flash and watch a board
… picker Five native selects were left behind while the rest of the app moved to Dropdown: board and example on the code page, baud on the serial monitor, which build a device should run, and which device a dashboard reads. Empty options become the placeholder, which is what Dropdown already models, and the two that used :value with a change handler keep that shape. The example picker was worse than redundant, it was inert. It listed assets named <Example>-<target>.bin on the SDK's latest release, and the only published release carries no assets at all, so the list was always empty and Load was always disabled. Removing it takes the whole catalogue: the GitHub releases fetch, its ETag handling and KV cache, the /v1/admin/firmware mount, and both catalogue types. The code page now talks only to its own instance. The starter sketch still seeds an empty editor.
Custom dropdowns everywhere, and one dead feature removed
Pressing Flash without one running printed "no agent is connected" into the console and stopped there. Nothing said an agent is a thing you download and run on your own machine, and the only instructions were in another repo's README, which nobody had a reason to find. The failure now carries a code rather than a sentence to match on, and the console answers it: what the agent is for, the download named for the platform the browser is on, the instance URL already filled in, and the arduino-cli core the build will need next. The main README gained the same path, since deploying an instance was documented but flashing a board from it was not.
Tell people what an agent is when a build needs one
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved device-scoping, migration, firmware, API/export, and browser workflow issues remain.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds device-aware telemetry, firmware/OTA workflows, browser-based code editing and flashing, and deployment configuration improvements.
Changes:
- Adds device, firmware, agent-build, telemetry, dashboard, and automation support.
- Adds CodeMirror editing, serial monitoring, Web Serial flashing, and OTA workflows.
- Updates schemas, migrations, deployment scripts, documentation, shared types, and tests.
File summaries
| File | Reviewed change |
|---|---|
wrangler.toml |
Updates deployment configuration. |
worker/test/ws-protocol.test.ts |
Tests device WebSocket messages. |
worker/test/ota-offer.test.ts |
Tests OTA offer selection. |
worker/test/migrations.test.ts |
Tests database migrations and retention. |
worker/test/do-schema.test.ts |
Tests Durable Object schema migration. |
worker/test/device-seen.test.ts |
Tests device last-seen handling. |
worker/src/routes.ts |
Registers device, firmware, OTA, and agent routes. |
worker/src/platform/lib/layout.ts |
Adds dashboard device targeting. |
worker/src/platform/lib/ids.ts |
Adds device, firmware, and build identifiers. |
worker/src/platform/lib/audit.ts |
Adds device and firmware audit targets. |
worker/src/platform/engine/types.ts |
Adds device-aware automation types. |
worker/src/platform/engine/run.ts |
Executes device-scoped automation. |
worker/src/platform/durable-objects/schema.ts |
Updates Durable Object schema handling. |
worker/src/platform/durable-objects/project-schema.ts |
Adds device-scoped project storage. |
worker/src/platform/durable-objects/project-do.ts |
Handles device state, controls, OTA, and builds. |
worker/src/platform/durable-objects/dashboard-do.ts |
Adds device-targeted dashboard snapshots and updates. |
worker/src/platform/db/migrations/0002_devices.sql |
Adds device and firmware tables. |
worker/src/platform/db/migrations.gen.ts |
Bundles the device migration. |
worker/src/platform/db/auto-migrate.ts |
Applies database migrations. |
worker/src/mcp/tools-write.ts |
Adds device-aware write tools. |
worker/src/mcp/tools-read.ts |
Adds device-aware read tools. |
worker/src/domains/variables/service.ts |
Adds device-scoped variable access. |
worker/src/domains/variables/routes.ts |
Exposes variable operations. |
worker/src/domains/telemetry/ws-protocol.ts |
Extends the telemetry protocol with device metadata. |
worker/src/domains/telemetry/variables.ts |
Processes device telemetry variables. |
worker/src/domains/telemetry/telemetry.ts |
Routes telemetry by device. |
worker/src/domains/telemetry/control.ts |
Adds device-aware controls. |
worker/src/domains/projects/service.ts |
Supports project device lifecycle. |
worker/src/domains/projects/routes.ts |
Exposes project operations. |
worker/src/domains/projects/export.ts |
Exports device and firmware data. |
worker/src/domains/firmware/ota.ts |
Publishes, assigns, and retains firmware. |
worker/src/domains/firmware/device.ts |
Provides device OTA endpoints. |
worker/src/domains/firmware/agent.ts |
Implements agent builds and artifact handling. |
worker/src/domains/firmware/admin.ts |
Provides firmware administration endpoints. |
worker/src/domains/devices/service.ts |
Implements device identity and lifecycle services. |
worker/src/domains/devices/routes.ts |
Adds device administration routes. |
web/vite.config.ts |
Configures application and editor chunks. |
web/tsconfig.json |
Adds Web Serial typing support. |
web/test/serial-diagnosis.test.ts |
Tests serial diagnostics. |
web/src/types.ts |
Extends application types for devices and layouts. |
web/src/stores/project.ts |
Manages project device and firmware state. |
web/src/router.ts |
Adds device and code routes. |
web/src/pages/Projects.vue |
Updates project management UI. |
web/src/pages/project/device/DevicesList.vue |
Manages devices and firmware. |
web/src/pages/project/device/DeviceHub.vue |
Adds device navigation. |
web/src/pages/project/device/CodePanel.vue |
Provides browser code, build, and flashing workflows. |
web/src/pages/project/DashboardEdit.vue |
Adds dashboard device selection. |
web/src/pages/project/automations/NodeInspector.vue |
Supports device trigger configuration. |
web/src/pages/project/automations/AutomationEditor.vue |
Updates automation device handling. |
web/src/layouts/Sidebar.vue |
Adds device and code navigation. |
web/src/composables/useSerialPort.ts |
Manages Web Serial connections. |
web/src/composables/useSerialLog.ts |
Handles serial output. |
web/src/composables/useSerialDiagnosis.ts |
Diagnoses serial connection issues. |
web/src/composables/useEspFlasher.ts |
Implements browser firmware flashing. |
web/src/composables/useAgentBuild.ts |
Streams agent build results. |
web/src/components/SerialMonitor.vue |
Adds the serial monitor UI. |
web/src/components/CodeEditor.vue |
Adds the CodeMirror editor. |
web/src/builder/grid.ts |
Preserves device-targeted layouts. |
web/src/api.ts |
Adds binary download support. |
web/package.json |
Adds editor and flashing dependencies. |
shared/blocks/triggers.ts |
Adds device trigger fields. |
shared/blocks/index.ts |
Adds shared device field types. |
scripts/merge-wrangler.ts |
Merges deployment Wrangler configuration. |
scripts/merge-wrangler.test.ts |
Tests Wrangler configuration merging. |
scripts/build-from-upstream.sh |
Rebuilds deployments from upstream sources. |
README.md |
Documents browser development and agent setup. |
deploy/wrangler.toml |
Defines deployment configuration. |
Review details
Suppressed comments (20)
web/src/composables/useAgentBuild.ts:56
- The NDJSON parser only processes complete lines inside the read loop. If the final network chunk ends after the JSON but before its newline,
carryremains unparsed when the stream closes and the successful result is returned as "ended without a result", so flashing/saving cannot proceed; flushcarryafter the loop.
} finally {
reader.releaseLock();
}
return result;
web/src/composables/useSerialDiagnosis.ts:86
wifiUpreturns true as soon as it finds any earlierwifi connectedentry, even if a later Wi-Fi disconnect exists. After a drop, a server error is therefore prefixed "Wi-Fi is up." Track the latest Wi-Fi state instead of scanning for any historical connection.
function wifiUp(entries: LogEntry[]): boolean {
for (let i = entries.length - 1; i >= 0; i--) {
const e = entries[i]!;
if (e.tag === 'nodrix' && /^wifi connected/.test(e.text)) return true;
}
web/src/pages/project/device/CodePanel.vue:67
- The agent endpoint requires
NODRIX_PROJECTwhen an admin token has all-project scope, but this copied setup only exportsNODRIX_TOKEN. Such a token is valid for Account → Tokens yet the agent immediately gets400 project required; include the current project ID in the generated command.
`NODRIX_INSTANCE=${window.location.origin} \\`,
'NODRIX_TOKEN=<admin token from Account -> Tokens> \\',
'./nodrix-agent',
web/src/pages/project/device/CodePanel.vue:148
lastBuildis reused bysaveForOta, but changing the sketch or the FQBN never invalidates it. A user can build, edit the code or switch boards, then click Save for OTA and publish the previous binary instead of the current editor contents; clear the cached build whenever either input changes.
watch(code, (v) => localStorage.setItem(storageKey.value, v));
web/src/pages/project/device/CodePanel.vue:264
SerialMonitoris hidden withv-show, and neither it norCodePanelstops the global serial monitor when the route unmounts. Navigating away while connected leaves the reader and USB port open, so another page/session cannot claim the board and the old listener continues processing output; tear down the monitor on page unmount.
<div v-show="tab === 'serial'" class="min-h-0 flex-1 p-3">
<SerialMonitor />
web/src/pages/project/device/CodePanel.vue:68
- The platform selector emits the same Unix commands for Windows:
chmodis not a PowerShell/CMD command, and the downloaded.exeis saved and invoked without a Windows executable name. The copied setup therefore fails on the platform this branch selects. Generate platform-specific PowerShell commands or link to platform instructions.
const agentSetup = computed(() => [
`curl -fsSL -o nodrix-agent ${AGENT_RELEASES}/download/${agentBinary.value}`,
'chmod +x nodrix-agent',
'',
`NODRIX_INSTANCE=${window.location.origin} \\`,
'NODRIX_TOKEN=<admin token from Account -> Tokens> \\',
'./nodrix-agent',
].join('\n'));
web/src/pages/project/device/CodePanel.vue:148
- The code editor initializes only once from
project.currentProjectId. When the route changes to another project, the router reuses this component and the project store switches after mount, but the sketch is not reloaded; editing then writes the previous project's sketch under the new project's storage key. Reload the sketch wheneverstorageKeychanges.
onMounted(() => {
code.value = localStorage.getItem(storageKey.value) ?? STARTER;
});
watch(code, (v) => localStorage.setItem(storageKey.value, v));
web/src/pages/project/device/CodePanel.vue:257
- The build UI supports
esp8266:esp8266:nodemcuv2, but the setup command installs onlyesp32:esp32. A fresh agent following this page will fail every ESP8266 build because Arduino CLI does not have that core; install the ESP8266 core too or remove that board option.
Also needs arduino-cli, with <code>arduino-cli core install esp32:esp32</code>.
worker/src/domains/devices/service.ts:180
- The D1 batch commits before the ProjectDO cleanup. If the RPC immediately after it fails, the device is already gone from D1, the method returns an error, and a retry cannot find the device—leaving its latest state, ring-buffer rows, and controls in the DO. Make this cleanup retryable/idempotent rather than relying on the only attempt after an irreversible delete.
worker/src/domains/firmware/agent.ts:27 - R2 listing is paginated, but this cleanup examines only the first page and ignores
list.truncated. Once a project has more than one page of build artifacts, stale objects beyond that page are never collected and can grow without bound; follow the cursor until the listing is complete.
worker/src/domains/projects/export.ts:24 - The device record omits
desired_firmware_id(and its status/timestamp), so an export loses which firmware each device is assigned to. That deployment state cannot be reconstructed from the separate firmware rows; include the assignment fields in the export.
worker/src/domains/variables/service.ts:10 0002_devicesnow allows the same key once per device, but this response type still omitsdevice_id. Two rows can therefore be indistinguishable to API consumers (and the UI renders duplicate variable names with no way to tell which device they belong to); expose the owning device in the type and returned rows.
worker/src/domains/variables/service.ts:72- With no device query parameter, this passes
null, sogetSeriesnow returns same-key points from every device, whileSeriesRowomitsdevice_idand the HTTP route offers no device selector. Consumers cannot distinguish the merged histories; preserve default-only behavior or add a device parameter and identity to the response.
worker/src/platform/db/auto-migrate.ts:55 - The zero-valued claim is committed before the migration batch; another isolate that sees this row gets
changes === 0, skips the migration, andensureMigratedresolves before the DDL commits. Requests can then run against a partially migrated schema. Wait forapplied_at > 0(and retry after failure) instead of treating an in-progress claim as complete.
worker/src/platform/durable-objects/dashboard-do.ts:202 - The bootstrap is scoped to
snapshotDevice, but liveupdatesare still emitted bynotifyBatchwithout any device identity and broadcast to every dashboard socket. After this change, a dashboard selected to device A will apply a same-key telemetry point from device B until the next snapshot; include the storage device in notifications and filter each socket to its selected device.
worker/src/platform/durable-objects/dashboard-do.ts:202 - The new device-scoped dashboard still enqueues controls through
addControlwith no device target, while the validation join also matches any project variable with this key. A dashboard for one device can therefore broadcast a control to every device (and accept a key that only exists on another device); resolve the socket's selected device and pass its storage ID through the lookup and enqueue.
worker/src/platform/durable-objects/dashboard-do.ts:202 - Once
snapshotDeviceis supplied,oldestTsis still returned by the unscopedringOldestTs(). If another device retains older rows while this dashboard's device has already evicted points aftersince, the delta check can incorrectly accept an incomplete delta and leave chart gaps; compute the retention frontier for the same storage device.
worker/src/platform/durable-objects/project-do.ts:308 oldestTsis calculated over the entire ring even whenlatestandseriesare filtered todeviceId. Per-device eviction can remove the selected device's old points while another device retains an older row, so reconnect can incorrectly return a delta with a hole. Compute the retention frontier for the requested device.
worker/src/platform/durable-objects/project-do.ts:693- A project that only uses the new build/firmware flow may never call
setProjectId()(the build and publish paths use the project ID directly and do not initializeflush_meta). On deletion,projectId()therefore falls back to the DO ID string, which does not match thebuilds/${projectId}/firmware/${projectId}prefixes created by these routes, so these R2 objects are leaked. Pass the known project ID intodestroy()or initialize it before cleanup.
worker/src/platform/durable-objects/project-do.ts:643 - After a non-default device sends
hello, this query excludesdevice_id IS NULLrows even thoughNULLcontrols are documented as broadcasts andlistPendingControlincludes them. Such a device will never receive pending broadcast controls over WebSocket; include the broadcast predicate here as well.
- Files reviewed: 67/67 changed files
- Comments generated: 14
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| await env.DB.batch([ | ||
| env.DB.prepare(`DELETE FROM project_variables WHERE device_id = ?`).bind(id), | ||
| env.DB.prepare(`DELETE FROM devices WHERE id = ? AND project_id = ?`).bind(id, projectId), | ||
| ]); | ||
| forgetCachedDevice(projectId, row.device_key); | ||
| await projectStub(env, projectId).deleteDevice(id); |
| await env.DB.batch( | ||
| stale.results.map((r) => env.DB.prepare(`DELETE FROM firmware WHERE id = ?`).bind(r.id)) | ||
| ); | ||
| await env.R2.delete(stale.results.map((r) => r.r2_key)).catch(() => {}); |
| ['device', `SELECT id, name, device_key, chip, firmware_version, is_default, first_seen, last_seen, created_at | ||
| FROM devices WHERE project_id = ?`], |
| export type StateEntry = { value: unknown; received_at: number }; | ||
| export type DeviceState = { id: string; name: string; variables: Record<string, StateEntry> }; | ||
|
|
||
| // Latest value of every variable. Mirrors GET /v1/projects/:proj/state. | ||
| export async function getState(env: Env, projectId: string): Promise<Record<string, StateEntry>> { | ||
| const latest = await projectStub(env, projectId).getLatestState(); | ||
| const out: Record<string, StateEntry> = {}; | ||
| for (const r of latest) out[r.variable] = { value: r.value, received_at: r.received_at }; | ||
| return out; | ||
| // Mirrors GET /v1/projects/:proj/state. Grouped by device because two of them | ||
| // may report the same key, and a flat map would drop one. | ||
| export async function getState(env: Env, projectId: string): Promise<DeviceState[]> { |
| const snapshotDevice = layout.device | ||
| ? await storageIdOf(this.env, row.project_id, layout.device) | ||
| : ''; |
| // Apple silicon and Intel are indistinguishable from the user agent. | ||
| const agentBinary = computed(() => { | ||
| const ua = navigator.userAgent; | ||
| if (ua.includes('Win')) return 'nodrix-agent-windows-x64.exe'; | ||
| if (ua.includes('Mac')) return 'nodrix-agent-macos-arm64'; | ||
| return 'nodrix-agent-linux-x64'; |
| onMounted(async () => { | ||
| try { | ||
| await Promise.all([project.loadDevices(), project.loadFirmware()]); | ||
| } catch (e) { | ||
| toast.error((e as Error).message); | ||
| } finally { | ||
| loading.value = false; | ||
| } |
| `INSERT INTO firmware (id, project_id, version, target, size, sha256, r2_key, notes, created_by, created_at) | ||
| VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` | ||
| ) | ||
| .bind(id, projectId, buildId, null, body.byteLength, sha256, key, notes, userId, now) |
| `UPDATE pending_control SET delivered_at = ? | ||
| WHERE id IN (${placeholders}) AND delivered_at IS NULL`, | ||
| WHERE id IN (${placeholders}) AND delivered_at IS NULL | ||
| AND (device_id IS NULL OR device_id = ?)`, | ||
| now, | ||
| ...ids | ||
| ...ids, | ||
| deviceId |
| chmod +x nodrix-agent | ||
| arduino-cli core install esp32:esp32 | ||
|
|
||
| NODRIX_INSTANCE=https://<your-worker> NODRIX_TOKEN=<admin token> ./nodrix-agent |
This pull request introduces browser-based code editing, building, and flashing for hardware devices, significantly enhancing the developer workflow. It adds a new Code page that integrates a code editor, serial monitor, and Web Serial flasher, supported by a new agent for local builds. The deployment process is also improved with a robust mechanism for merging configuration files, ensuring deployments stay up-to-date with upstream changes while preserving local identities and resources. Several supporting changes update documentation, dependencies, and shared block types.
Browser-based hardware development and flashing:
codemirror,@codemirror/lang-cpp, etc.) and flashing (esptool-js). [1]], [2]], [3]])requestBytesto support binary downloads needed for firmware flashing. [1]], [2]])Deployment and configuration improvements:
wrangler.tomlfrom an upstream template, merging only the deployment's unique identity and resource IDs. This ensures new upstream bindings and settings reach all deployments. [1]], [2]], [3]], [4]], [5]])merge-wrangler.tsutility with comprehensive tests to safely merge deployment and template configuration files, preserving critical fields and supporting idempotency. [1]], [2]])Documentation updates:
README.mdwith detailed instructions and explanations for browser-based flashing, the role of the nodrix agent, and updated architecture diagrams. [1]], [2]])Shared block and trigger enhancements:
devicefield type to shared blocks and integrated it into trigger definitions, allowing triggers to be scoped to specific devices. [1]], [2]])Layout and device targeting:
deviceproperty, supporting device-specific layouts and widgets. ([web/src/builder/grid.tsR23])These changes collectively enable a seamless browser-to-device workflow, improve deployment flexibility, and enhance the platform's extensibility for device-specific features.