Commit Graph
10 Commits
Author SHA1 Message Date
octoturge d827b465cf Commit the first migration, and apply schema the same way everywhere
`drizzle/` was gitignored, so every environment re-derived the schema from
src/db/schema and nothing recorded what a given release expected. That is
survivable while nothing is deployed and a problem the moment something is.
The generated migration is now committed: four tables, three enums, five
foreign keys, and the per-farm unique index on gateway_printer_id.

It needed one hand-added line to be usable on its own. drizzle-kit does not
emit extension statements, but frame_embeddings.embedding is vector(512) with
an ivfflat index, so as generated the migration only applied to a database
somebody had already run `CREATE EXTENSION vector` on - which was true locally
precisely because dev-env.sh does that, and would not have been true on a
fresh deploy. `CREATE EXTENSION IF NOT EXISTS vector` now leads the file, and
it survives regeneration: drizzle only appends new migrations, and the diffing
snapshot lives in meta/0000_snapshot.json rather than in the SQL.

Verified by dropping the database, recreating it with no extensions at all
(`pg_extension` count 0), and running db:migrate: the extension and all four
tables were created, `db:generate` then reported no pending changes, and the
e2e script still passed against both live printers on the result.

`dev-env.sh schema` now runs `migrate` instead of `push`, so a local database
and a deployed one get the schema by the same route - testing `push` locally
while production runs `migrate` tests two different things. `push` is still
available as its own command for iterating on a table that is still moving,
with a comment about what it does not record.
2026-10-03 15:17:42 +00:00
octoturge 42d4b3820a Add a runnable dev environment, and fix the pins that stopped it booting
The edge route had never been run. Adding a way to run it turned up that the
server could not start at all, for two independent dependency reasons that
typechecking could not see:

- `elysia: ^1.1.26` had resolved to 1.4.30, which calls `t.Module` - a TypeBox
  0.34 API - while this repo pinned TypeBox 0.33. `t.Module is not a function`.
- `drizzle-typebox: ^0.2.1` resolved to a version whose peers demand
  `drizzle-orm >= 0.36`, against the `^0.33.0` pinned here. Importing it threw
  `Export named 'getViewSelectedFields' not found`.

Both are caret ranges that drifted into combinations nobody chose. elysia,
@sinclair/typebox and the drizzle packages are now pinned tightly, TypeBox
forward to 0.34.52 (which satisfies Elysia and drizzle-typebox 0.1.1 alike),
and tests/dependency_identity.test.ts fails if the TypeBox version ever differs
between here and @continuum/ts-types - the skew that made every shared schema
invalid, with an error message pointing at Standard Schema instead.

Dropping to drizzle-typebox 0.1.1 widens `jsonb` in the generated validators,
so `metadata` needs an explicit object shape in validators.ts; the column is
declared `$type<Record<string, unknown>>()` and a validated body otherwise is
not assignable to `.values()`.

scripts/:

- `install-docker.sh` - one-time host setup, kept separate because it touches
  the machine rather than containers.
- `dev-env.sh up|down|reset|status|psql|schema|env` - Valkey + Postgres/pgvector.
  Uses docker-compose.yml when a daemon answers, and otherwise runs the same two
  services from apt. That fallback is not hypothetical: Docker cannot run in this
  sandbox at all. dockerd needs CAP_NET_ADMIN to build its bridge; with
  `--bridge=none --iptables=false` it starts but cannot unpack an image without
  CAP_SYS_ADMIN; with `--storage-driver=vfs` it still fails because registering a
  layer needs user namespaces, which are blocked outright. All three are
  host-level grants, so no script can work around them - hence a second path
  rather than a broken first one.
- `e2e-edge.sh` - the test the unit tests cannot be. Seeds a farm, claims its
  printers, mints an edge token, runs the real continuum-proxy, and asserts live
  telemetry reached Postgres and a command reached a printer. It only sends a fan
  speed, only to a printer reporting IDLE, and puts it back afterwards.
- `capture-gateway-frames.js` - records what a gateway really sends, for
  refreshing tests/fixtures/. Read-only unless FAN_TEST_PRINTER is set. The test
  comment referred to this before it existed.

Verified end to end, from a dropped and freshly migrated database: `h2c`
persisted as printing/RUNNING 22% layer 2/25 nozzle 230 bed 35 with 3 HMS
alerts, `p1p` as idle/IDLE, and `POST /printers/:id/commands` reached the P1P's
fan and came back ok:true. An unclaimed printer is refused 409
PRINTER_UNREACHABLE rather than guessed at.

Two things the e2e script got wrong first, both now fixed in it: it waited for
the first printer row instead of all of them, which silently tested only the v2
printer (a v1 Bambu reports one tick later); and `$!` after `( cd ... ) &` is
the subshell, so cleanup left an orphaned gateway reconnecting with a token for
a deleted farm and polluting the next run.

Note: `drizzle/` is gitignored, so the generated migration is not committed and
there is no migration history in the repo. Left as-is deliberately - that is a
workflow call, flagged in PLAN.md.
2026-10-03 12:17:10 +00:00
octoturge e2c1e0abe2 Speak the gateway's actual protocol on /ws/edge/v1, and persist telemetry
This route and continuum-proxy had drifted into entirely different
protocols. It expected an `auth` handshake the gateway never sends, one
printer per telemetry message where the gateway sends a batch, UUID printer
ids a gateway has no way to know, and camelCase field names against the
gateway's snake_case. It had no way to send a command to a printer at all.
Telemetry was acknowledged and thrown away - the handler said as much.

It now implements schemas/continuum/v1/edge/ via its TypeBox mirror in
@continuum/ts-types, so both ends mirror one written contract instead of
each other's assumptions.

The missing link was identity: a gateway knows its printers as "p1p", this
database knows them as UUIDs, and nothing mapped between them.
printers.gateway_printer_id is that mapping, unique per farm since two
farms may each have a printer called "p1p". An unrecognised id is logged
and skipped rather than inserted - a gateway should not be able to create
printer records by mentioning them, or a typo in someone's printers.toml
becomes a permanent phantom printer.

Telemetry now lands in `printers` as a latest-value snapshot, extending the
pattern status/nozzle_temp_c/bed_temp_c already used: target temperatures,
progress, layers, remaining minutes, job name, active alert count. Also
vendor_state alongside the narrowed status enum, because a state we do not
recognise yet would otherwise be flattened and lost - and an unrecognised
state deliberately leaves status untouched rather than falling back to
"idle", since idle is what the scheduler reads as safe to send work to.

Commands can now go down: sendPrinterCommand() takes our own printer id,
translates it on the way out, and waits for the gateway's result, with a
10s timeout and a registry keyed by farm. Pending commands are failed
immediately when a gateway disconnects rather than making every caller wait
out the timeout for a socket that is already gone. POST /printers/:id/commands
exposes it, validated against the same shared schema the gateway
accepts, so this route cannot offer something no gateway can execute - which
notably means there is still no way to start a print.

tests/edge_protocol.test.ts is the contract test, run against real frames
captured from a live gateway talking to a real P1P and H2C rather than
hand-written examples - hand-written ones agree with whoever wrote them,
which is how this drifted in the first place. The second fixture is the
same capture from before the gateway was taught to omit empty fields, kept
deliberately: the validator must keep accepting explicit nulls.

Not yet applied to a database: this adds columns but no migration, and the
repo has none committed. Run db:generate (or push) before the next deploy.
2026-10-03 10:37:41 +00:00
octoturge ab093f3136 Use Valkey instead of Redis
Redis 7.4+ is RSALv2/SSPL and 8.0+ adds AGPLv3. None of the three works
for a closed-source commercial cloud service, and SSPL section 13
specifically targets offering the software's functionality as a service,
which is what a control plane does. Valkey is BSD-3-Clause, under the
Linux Foundation, forked from Redis 7.2.4.

Deciding this now rather than after step 3 of PLAN.md stands up real
infrastructure makes it free: Valkey speaks the same RESP protocol, so
`ioredis` is untouched and only names change. `REDIS_URL` becomes
`VALKEY_URL`, the exported client becomes `valkey`, and `/health`
reports a `valkey` field.

Two things deliberately do not change, documented in place so they are
not "fixed" later: the `redis://` URL scheme, which names the wire
protocol and is what every client actually parses (none understands
`valkey://`), and `ioredis` itself, which is a RESP client whose name
refers to the protocol's origin and not to the server we run.

docker-compose.yml is new, pinning valkey/valkey:8-alpine alongside
pgvector/pgvector:pg17 - the plain postgres image lacks the extension
`frame_embeddings` needs. It makes the Valkey choice concrete rather
than a note someone has to remember.
2026-10-03 10:10:28 +00:00
octoturge 349c4073cd Declare proprietary licensing
Continuum is intended to be sold, so every dependency has to permit
distribution in closed-source commercial software. This package carried
no licence field; `"UNLICENSED"` with `"private": true` is npm's
convention for that. The policy and the dependency audit are in
continuum-common's docs/LICENSES.md.

No `deny.toml` here - there are no cargo dependencies in this repo.
2026-10-03 10:10:28 +00:00
octoturge 6d9db5adc1 Replace em dashes with hyphens across docs and comments 2026-08-29 08:13:54 +00:00
octoturge 978846d304 Ignore bun.lock alongside bun.lockb 2026-08-28 18:28:27 +00:00
octoturge c8167804c2 Fix bun-types resolution; fix possibly-undefined health-check row
Same bun-types-vs-@types/bun fix as continuum-common. Also fixes a real
noUncheckedIndexedAccess violation in the /health route: db.execute()'s
result is T[], so rows[0] is 'T | undefined', not T — destructuring assumed
it always existed. Verified with a clean 'bunx tsc --noEmit'.
2026-08-28 18:28:06 +00:00
octoturge 3f0a51194b Derive Elysia request validators from Drizzle tables via drizzle-typebox 2026-08-28 17:02:10 +00:00
octoturge fad0a52690 Initial boilerplate scaffold for continuum-backend
Bun + ElysiaJS control plane: Drizzle/Postgres schema (printers, farms,
print_jobs, pgvector frame_embeddings), R2 presigned URLs, Redis Streams
frame ingestion queue, REST routes, and the /ws/edge/v1 edge-gateway
WebSocket uplink.
2026-08-28 16:13:57 +00:00