Write down the backend <-> ai-worker contract
`stream:camera_frames` and `channel:print_alerts` are the only interface between `continuum-backend` and `continuum-ai-worker`, and there was nothing specifying either. They had drifted completely: the backend wrote `farmId`, `printerId`, `r2ObjectKey` and `capturedAt`; the worker read a single field called `printer_id` and ignored the rest. Every task was delivered, acknowledged, and attributed to a printer called "unknown" - and nothing errored, because a stream entry is a flat string map and asking it for a field that is not there is not a failure. Same shape as the /ws/edge/v1 drift, found the same way. `schemas/continuum/v1/ai/` is now canonical, with a TypeBox mirror in ts-types and a Rust mirror in the worker. Two things the schemas have to say that JSON Schema does not naturally express, both of which caused real bugs: - A Valkey stream entry is a flat field/value map of *strings*. No nesting, no numbers, no null. So FrameTask has no numeric or nested properties, and a field with no value is omitted from the XADD rather than written as `""` - an empty string is a value, and the producer was writing one for `print_job_id`, which a consumer would carry into a lookup or a uuid column. - PrintAlert, by contrast, *is* a JSON document, because PUBLISH carries one opaque string. It is also fire-and-forget with no persistence, so it can only ever be a live-console feed and never the record of a detection. The README's layout section was stale (it still listed four DTOs that were removed in 2026-09) and did not mention `edge/` or `ai/` at all. It now names both, says why the inter-service protocols live here, and notes that their Rust mirrors sit in the single repo that consumes each rather than in `packages/rust-types`.
This commit is contained in:
@@ -13,19 +13,39 @@ JSON Schema, compiled into two consumer packages:
|
|||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
schemas/continuum/v1/ Continuum's own domain model (PrintJob, Template, ...)
|
schemas/continuum/v1/ Continuum's own model
|
||||||
|
ai/ continuum-backend <-> continuum-ai-worker: the
|
||||||
|
stream:camera_frames work queue and the
|
||||||
|
channel:print_alerts notification channel
|
||||||
|
edge/ continuum-backend <-> continuum-proxy: the
|
||||||
|
/ws/edge/v1 gateway uplink protocol
|
||||||
|
material/ printer/ domain objects
|
||||||
|
vendor.schema.json
|
||||||
schemas/bambulab/ Bambu Lab MQTT wire schemas (v1/v2, report + request)
|
schemas/bambulab/ Bambu Lab MQTT wire schemas (v1/v2, report + request)
|
||||||
packages/ts-types/ TypeBox schemas (hand-written, mirrors schemas/)
|
packages/ts-types/ TypeBox schemas (hand-written, mirrors schemas/)
|
||||||
packages/rust-types/ serde structs (hand-written, mirrors schemas/)
|
packages/rust-types/ serde structs (hand-written, mirrors schemas/)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The two inter-service protocols - `edge/` and `ai/` - are here for a
|
||||||
|
specific reason: each has two independent implementations, one per repo, and
|
||||||
|
when there was nothing written down between them they drifted completely
|
||||||
|
while every test passed. `/ws/edge/v1` had an `auth` handshake the gateway
|
||||||
|
never sent; `stream:camera_frames` had a producer writing `printerId` and a
|
||||||
|
consumer reading `printer_id`, so every frame was attributed to a printer
|
||||||
|
called "unknown". Neither failure was visible from either side alone, which
|
||||||
|
is what a canonical schema plus a contract test on each end is for.
|
||||||
|
|
||||||
`schemas/continuum/v1/*.schema.json` is the canonical shape for each
|
`schemas/continuum/v1/*.schema.json` is the canonical shape for each
|
||||||
domain object; `packages/ts-types/src/schemas/` and
|
domain object; `packages/ts-types/src/schemas/` and
|
||||||
`packages/rust-types/src/lib.rs` both mirror it by hand. There are only
|
`packages/rust-types/src/lib.rs` both mirror it by hand. A mirror is added
|
||||||
four small, stable DTOs here (`PrintJob`, `MaterialProfile`,
|
per schema only once something actually consumes it, so not every file here
|
||||||
`PrintParameters`, `Template`), so keeping two hand-synced copies in sync
|
has one, and the two inter-service protocols keep their Rust mirror in the
|
||||||
is less overhead than running a codegen pipeline (`protoc`/`ts-proto`/
|
single repo that consumes it rather than in `packages/rust-types`:
|
||||||
`prost-build`) nothing actually consumed - see git history for the
|
`edge/` in `continuum-proxy/src/uplink/protocol.rs`, `ai/` in
|
||||||
|
`continuum-ai-worker/src/task.rs`. The set is small and changes slowly,
|
||||||
|
which is why keeping hand-synced copies in sync is less overhead than a
|
||||||
|
codegen pipeline (`protoc`/`ts-proto`/`prost-build`) nothing actually
|
||||||
|
consumed - see git history for the
|
||||||
protobuf version this replaced, and why it was dropped: every service in
|
protobuf version this replaced, and why it was dropped: every service in
|
||||||
this system talks JSON (JSON Schema for the wire, TypeBox/serde for
|
this system talks JSON (JSON Schema for the wire, TypeBox/serde for
|
||||||
runtime validation), so protobuf's actual value - a single schema compiled
|
runtime validation), so protobuf's actual value - a single schema compiled
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { Type, type Static } from "@sinclair/typebox";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `stream:camera_frames` work queue and the `channel:print_alerts`
|
||||||
|
* notification channel, mirroring `schemas/continuum/v1/ai/*.schema.json`
|
||||||
|
* by hand - the JSON Schema files are canonical, and the Rust mirror lives
|
||||||
|
* in `continuum-ai-worker/src/task.rs`.
|
||||||
|
*
|
||||||
|
* These two are the only interface between `continuum-backend` and
|
||||||
|
* `continuum-ai-worker`. They are written down here because the previous
|
||||||
|
* arrangement - a producer and a consumer each naming their own fields,
|
||||||
|
* with nothing in between - produced exactly the drift it sounds like: the
|
||||||
|
* backend wrote `printerId` and the worker read `printer_id`, so every
|
||||||
|
* frame the worker ever handled was attributed to a printer called
|
||||||
|
* "unknown", and nothing failed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One entry on `stream:camera_frames`.
|
||||||
|
*
|
||||||
|
* ## Every value is a string
|
||||||
|
*
|
||||||
|
* A Valkey stream entry is a flat field/value map of strings. There is no
|
||||||
|
* nesting and no numeric type, which is why `FrameTaskSchema` has no
|
||||||
|
* numbers and no nested objects - a producer that needs one has to encode
|
||||||
|
* it, and then both sides have to agree on the encoding, which is the thing
|
||||||
|
* this file exists to avoid.
|
||||||
|
*
|
||||||
|
* ## An absent field is absent
|
||||||
|
*
|
||||||
|
* `print_job_id` is omitted from the XADD when the frame belongs to no
|
||||||
|
* tracked job - not written as `""`. An empty string is a value: a consumer
|
||||||
|
* that passes it along ends up looking up job id `""`, and one that writes
|
||||||
|
* it to Postgres violates a uuid column. So the producer omits the field and
|
||||||
|
* the consumer treats absent and empty alike.
|
||||||
|
*/
|
||||||
|
export const FrameTaskSchema = Type.Object({
|
||||||
|
/** `farms.id`. */
|
||||||
|
farm_id: Type.String({ format: "uuid" }),
|
||||||
|
/**
|
||||||
|
* `printers.id` - the backend's UUID, already resolved from the gateway's
|
||||||
|
* own printer id, which means nothing outside its farm.
|
||||||
|
*/
|
||||||
|
printer_id: Type.String({ format: "uuid" }),
|
||||||
|
/** `print_jobs.id`, or omitted. Never `""`. */
|
||||||
|
print_job_id: Type.Optional(Type.String({ format: "uuid" })),
|
||||||
|
/** Where the frame is in R2. The bytes are never in the stream. */
|
||||||
|
r2_object_key: Type.String({ minLength: 1 }),
|
||||||
|
/**
|
||||||
|
* The gateway's clock, at capture - not enqueue time. A gateway that
|
||||||
|
* buffered frames through an uplink outage produces tasks whose
|
||||||
|
* `captured_at` is minutes old.
|
||||||
|
*/
|
||||||
|
captured_at: Type.String({ format: "date-time" }),
|
||||||
|
});
|
||||||
|
export type FrameTask = Static<typeof FrameTaskSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One message on `channel:print_alerts`, as JSON.
|
||||||
|
*
|
||||||
|
* Unlike a stream entry this one is a document, because PUBLISH carries a
|
||||||
|
* single opaque string. It is also fire-and-forget: an alert published with
|
||||||
|
* no subscriber is gone, so this channel is a live-console feed and not the
|
||||||
|
* record of a detection.
|
||||||
|
*/
|
||||||
|
export const PrintAlertSchema = Type.Object({
|
||||||
|
printer_id: Type.String({ format: "uuid" }),
|
||||||
|
print_job_id: Type.Optional(Type.String({ format: "uuid" })),
|
||||||
|
/**
|
||||||
|
* Free string, not an enum: the label set belongs to the model file and
|
||||||
|
* changes without this schema changing.
|
||||||
|
*/
|
||||||
|
label: Type.String(),
|
||||||
|
/**
|
||||||
|
* The model's own score. Thresholding is the consumer's policy - an
|
||||||
|
* operator may well want to see near-misses.
|
||||||
|
*/
|
||||||
|
confidence: Type.Number({ minimum: 0, maximum: 1 }),
|
||||||
|
/** From the frame, so a console shows when the printer looked like this. */
|
||||||
|
captured_at: Type.String({ format: "date-time" }),
|
||||||
|
/** So a console can show the picture, not just the label. */
|
||||||
|
r2_object_key: Type.String(),
|
||||||
|
});
|
||||||
|
export type PrintAlert = Static<typeof PrintAlertSchema>;
|
||||||
|
|
||||||
|
/** The stream the tasks go on, and the group the worker reads as. */
|
||||||
|
export const CAMERA_FRAMES_STREAM = "stream:camera_frames";
|
||||||
|
export const AI_WORKER_CONSUMER_GROUP = "ai-worker";
|
||||||
|
/**
|
||||||
|
* Where entries go that cannot be processed and must not be retried - a
|
||||||
|
* malformed task, or one that has been redelivered too many times. Separate
|
||||||
|
* from the main stream so a poison message cannot block the queue and
|
||||||
|
* cannot be lost silently either.
|
||||||
|
*/
|
||||||
|
export const CAMERA_FRAMES_DEAD_LETTER_STREAM = "stream:camera_frames:dead";
|
||||||
|
export const PRINT_ALERTS_CHANNEL = "channel:print_alerts";
|
||||||
@@ -9,3 +9,8 @@
|
|||||||
// a TypeBox mirror here, consumed by continuum-backend's edge WebSocket
|
// a TypeBox mirror here, consumed by continuum-backend's edge WebSocket
|
||||||
// route. Mirrors schemas/continuum/v1/edge/.
|
// route. Mirrors schemas/continuum/v1/edge/.
|
||||||
export * from "./edge";
|
export * from "./edge";
|
||||||
|
|
||||||
|
// The continuum-backend -> continuum-ai-worker interface: the
|
||||||
|
// stream:camera_frames work queue and the channel:print_alerts notification
|
||||||
|
// channel. Mirrors schemas/continuum/v1/ai/.
|
||||||
|
export * from "./ai";
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://git.octoturge.com/Continuum/continuum-schemas/raw/branch/main/schemas/continuum/v1/ai/frame_task.schema.json",
|
||||||
|
"title": "FrameTask",
|
||||||
|
"description": "One camera still, handed from continuum-backend to continuum-ai-worker for print-failure scoring. Produced by XADD onto the stream named below; consumed by XREADGROUP.\n\nThis is a Valkey stream entry, not a JSON document: the wire form is a flat field/value map and EVERY value is a string. That is why there are no numeric or boolean properties here and why a field with no value is omitted from the XADD rather than written as an empty string - an empty string is a value, and a consumer that treats it as one ends up looking up the object key \"\".\n\nField names are snake_case, matching every other schema in this repository. The backend's HTTP routes are camelCase; this wire is not an HTTP route, and the consumer on the other end is serde-derived Rust.",
|
||||||
|
"x-stream": "stream:camera_frames",
|
||||||
|
"x-consumer-group": "ai-worker",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"farm_id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"description": "`farms.id`. Carried so a consumer can attribute load and alerts per farm without a database round trip."
|
||||||
|
},
|
||||||
|
"printer_id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"description": "`printers.id` - the backend's own UUID, already resolved from the gateway's printer id. NOT the gateway's `printers.toml` id, which means nothing outside its own farm."
|
||||||
|
},
|
||||||
|
"print_job_id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"description": "`print_jobs.id`, when the frame belongs to a tracked job. Omitted entirely when it does not - a printer may be streaming while idle, or running something queued outside Continuum. Consumers must treat an absent key and an empty value alike, and must not fabricate a job id."
|
||||||
|
},
|
||||||
|
"r2_object_key": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "Where the frame actually is, in the R2 bucket. The frame bytes are deliberately NOT in the stream: a stream entry is held in memory by the server and replayed to every consumer group, and a JPEG per printer per second would make the stream the largest thing in the deployment."
|
||||||
|
},
|
||||||
|
"captured_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"description": "When the camera took the frame, from the gateway's clock - not when the backend enqueued it. These can differ by a lot: a gateway buffers frames while its uplink is down, so a task may arrive minutes after the moment it describes."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"farm_id",
|
||||||
|
"printer_id",
|
||||||
|
"r2_object_key",
|
||||||
|
"captured_at"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://git.octoturge.com/Continuum/continuum-schemas/raw/branch/main/schemas/continuum/v1/ai/print_alert.schema.json",
|
||||||
|
"title": "PrintAlert",
|
||||||
|
"description": "A print-failure detection, published by continuum-ai-worker for live operator consoles.\n\nUnlike FrameTask this one IS a JSON document: it travels as the payload of a Valkey PUBLISH, which carries a single opaque string, so the structure has to be inside it.\n\nPUBLISH is fire-and-forget with no persistence and no delivery guarantee: an alert published while nothing is subscribed is gone. That is acceptable for the live-console path it feeds and is NOT acceptable as the record of a detection, which belongs in Postgres. As of 2026-10-11 nothing subscribes to this channel and nothing persists it - see the README section on the AI path.",
|
||||||
|
"x-channel": "channel:print_alerts",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"printer_id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"description": "`printers.id`, taken from the frame task that produced the detection."
|
||||||
|
},
|
||||||
|
"print_job_id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"description": "Omitted when the frame was not attributed to a tracked job."
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "What the model thinks it saw, e.g. \"spaghetti\", \"layer_shift\", \"detachment\". A free string rather than an enum on purpose: the label set is a property of the model file, which changes without this schema changing."
|
||||||
|
},
|
||||||
|
"confidence": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 1,
|
||||||
|
"description": "The model's own score. Whether it is high enough to act on is the consumer's policy decision, not the worker's - the worker publishes what it found and does not threshold on behalf of an operator who may want to see near-misses."
|
||||||
|
},
|
||||||
|
"captured_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"description": "From the frame, not from the detection. An operator needs to know when the printer looked like this, not when a GPU got round to looking."
|
||||||
|
},
|
||||||
|
"r2_object_key": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The frame the detection came from, so a console can show the operator the picture rather than only a label."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"printer_id",
|
||||||
|
"label",
|
||||||
|
"confidence",
|
||||||
|
"captured_at",
|
||||||
|
"r2_object_key"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user