20 KiB
workshop
The project notebook behind the workshop/office assistant — step 1 of
docs/workshop-assistant.md, and deliberately only
step 1.
That note ranks four capabilities (conversational planning, spec research, guide lookup, camera identification) and says to build this one first: it is the only one with no perception problem in it, and it is where all the others land. An identified mainboard is worth nothing until there is a project to attach it to.
Two databases and a share
| Holds | Lifetime | |
|---|---|---|
workshop.db |
projects, notes, decisions — a record of work | scoped to a project; over when it's over |
workshop-knowledge.db |
workflow instructions, facts, project learnings, hardware inventory — a record of what is true | forever. Nothing here is ever pruned |
/workspace (SMB) |
datasheets, generated diagrams, photos, long-form notes | whatever you leave there |
Two files rather than four more tables because they have different lifetimes and different blast radius: rebuilding the project store must not take the standing instruction about how you solder with it.
Nothing in the knowledge store expires. Every other store in this stack has a
retention window — doorway.py keeps sightings 30 days, digest-engine's archive 400
— because stale observations are worse than none. This one is the opposite kind of
data: the entire point is that you tell it once. A retention policy would be a policy
of forgetting the thing the feature exists to remember, so there is no cutoff and no
cleanup pass anywhere in knowledge.py. Corrections are per-row DELETEs — no
expiry does not mean nothing is ever wrong.
The knowledge tables
workflow_notes— retrieved by activity. "When I'm soldering, flux first." Activity*means always. Re-adding the same instruction updates rather than duplicating, since telling it twice is the exact problem the table solves.facts— retrieved by keyword. Device specs, URLs, part numbers. Re-adding the same subject overwrites: a spec you looked up again is usually one you had wrong.project_knowledge— retrieved by project, and deliberately not a foreign key intoworkshop.db. What you learned building the NAS stays true after the NAS is gone.hardware— what you own, where it is, and what it's promised to.
GET /context?activity=&q=&project= is what makes these a memory rather than four
lists: one call returns the standing instructions, the relevant facts, the project's
learnings and its hardware. An unknown activity still returns the always-notes —
"I don't know what you're doing" is no reason to forget how you like to work.
There is exactly one inventory
An earlier cut had a parts table in the project store. It's gone. Two inventories is
the failure this repo's own docs keep warning about: the one you didn't update becomes
a lie, and you find out by buying a part you already own. What you own is knowledge;
what a project needs is a claim on it, which is hardware.project_slug.
Four statuses, and the middle two are the point:
| Means | Can I use it right now? | |
|---|---|---|
available |
on the shelf, unpromised | yes |
assigned |
reserved for a project, still on the shelf | yes, if you're willing to change your mind |
in_use |
installed and working somewhere | not without taking something apart |
retired |
dead, sold, given away | no — kept so "didn't I have one?" has an answer |
Naming a project is the assignment: {"project_slug": "nas-build"} moves an item to
assigned by itself, and clearing it releases to available — an item assigned to a
project while still reading available is how an inventory starts lying. The one
exception: in_use is never silently downgraded by a re-assignment. Saying which
project something is in must not turn a thing you'd have to unscrew into a thing the
list says you can just take.
Why a share and not blobs in the database
Mixing them gets the worst of both: a database you can't browse and files nothing can query. Keeping artefacts as files means every one of them outlives this service — a datasheet in the share is a PDF you can open from a laptop with no API, no export step, and no dependence on this project still existing in two years.
Consequently this service never serves file contents. GET /projects/<slug>/files
is a directory listing; opening the file is the share's job, and a second copy of the
bytes over HTTP would just be a second place for them to go stale.
The workspace is the one writable share in this stack
The photo gallery share (gallery-smb) is read only = yes on purpose. This one
can't be — the assistant writes into it — and that difference is the whole security
story:
- Its own volume, not a subdirectory of anything else, so the blast radius of a bad path is "the workshop workspace" and is recoverable.
- Its own SMB account. Guest off, same as the gallery.
- Nothing on the host ever executes anything found in it. The moment it becomes a place for scripts, it is a different security question than this component answers.
- Slugs are validated, never sanitised (
SLUG_REinserver.py). A project slug is a directory name on that share, which makes it the one input here with a filesystem consequence. Silently rewriting../../etctoetcwould let two different requests write to one directory with nobody finding out, so a bad slug is a 400.
_ensure_project_dir() is best-effort: an unmounted or read-only share does not
stop a project being created, because the notebook is in SQLite and that is the part
that matters. Every response that touches the workspace carries available, so the
API says plainly whether the share is working instead of implying it by silence.
Specs carry their source, or they are marked as not having one
specs always sits next to source_url, in both hardware and facts. This is the schema half of the
rule in the feasibility note:
Every spec must be quoted from a fetched document, with its URL, and never generated from the model's memory.
A hallucinated TDP wastes an afternoon; a hallucinated pinout destroys hardware. A
spec you read off the part in your hand is a perfectly good source and is accepted
without a URL — it is just recorded as unsourced, so the UI can mark it and a later
research pass knows which specs still need a document behind them. Never populate
specs from an LLM's memory. That is not a style preference here, it is the reason
the column pair exists.
Why decisions are their own table
"What did I decide last Tuesday, and why" is the question a project notebook exists to answer, and a pile of notes answers it worst. A decision is a question, an answer, and a because — the last one being the column the table is for, because an answer without its reasoning is something you will re-litigate in three weeks.
API
All endpoints are bearer-token gated; an unset WORKSHOP_TOKEN rejects everything.
| Endpoint | What it does |
|---|---|
GET /projects[?room=<area_id>] |
every project, or the ones belonging to a room |
POST /projects |
{"name", "slug"?, "room"?, "summary"?} → creates it and its workspace directories |
GET /projects/<slug> |
the project with its notes, decisions, durable knowledge and claimed hardware |
POST /projects/<slug> |
{"name"?, "status"?, "summary"?, "room"?} — status is active/parked/done |
GET /projects/<slug>/files |
directory listing of the workspace, never contents |
POST /projects/<slug>/notes |
{"body", "author"?} — author distinguishes what you wrote from what the assistant wrote |
POST /projects/<slug>/decisions |
{"question", "answer", "because"?} |
POST /projects/<slug>/knowledge |
{"body", "keywords"?} — a durable learning, not a log entry |
POST /projects/<slug>/repo |
{"name"?, "description"?, "private"?} → creates a Gitea repo, applies branch protection, sets up code/ and records the URL |
GET /projects/<slug>/git |
branch, dirty files, last five commits |
POST /projects/<slug>/commit |
{"message", "push"?} — commit and push. Reported separately: a failed push is not a failed commit |
POST /projects/<slug>/branch |
{"name"} — create and switch. There is no delete counterpart, on purpose |
POST /projects/<slug>/scrub-request |
{"path"?, "note"?} → the manual commands to remove something from history. Executes nothing |
GET /hardware[?q=&status=&project=] |
the inventory |
POST /hardware |
{"designation", "kind"?, "quantity"?, "storage_location"?, "status"?, "project_slug"?, "specs"?, "source_url"?, "notes"?} |
POST /hardware/<id> |
edit anything, including assignment |
GET /knowledge/workflow[?activity=] · POST /knowledge/workflow |
standing instructions |
GET /knowledge/facts[?q=] · POST /knowledge/facts |
keyword-retrieved specifics |
GET /knowledge/projects[?project=&q=] |
durable project learnings |
GET /context[?activity=&q=&project=] |
everything that applies right now, in one call |
GET /health |
CheckMK + every firewall: current state, how long it's held, the problems |
GET /cameras |
network camera names + go2rtc stream ids |
GET /fleet |
every slot, its versions, and what each endpoint reported |
GET /fleet/script/<platform> |
the published script for a platform (what endpoints fetch) |
POST /fleet/upload |
{"platform", "body", "note"?} → a new draft version |
POST /fleet/publish |
{"platform", "version"} → make it the one endpoints run |
POST /fleet/report |
an endpoint saying what it ran and whether it worked |
DELETE /hardware/<id>, DELETE /knowledge/{workflow,facts,projects}/<id> |
corrections |
parked is not done: a shelved project whose parts are still allocated to it is
exactly what you want to find before buying those parts again.
room is an HA area_id, the same vocabulary as everywhere else in this project —
see docs/rooms-and-endpoints.md. It is what lets a
room-scoped assistant answer "what am I working on" with the projects of the room it
was asked in.
Configure
cp workshop/workshop.env.example /opt/smart-home/workshop/workshop.env
openssl rand -hex 32 # WORKSHOP_TOKEN
chmod 600 /opt/smart-home/workshop/workshop.env
The web interface
frontend/ — a static page for editing the hardware inventory by hand: search,
filter by status or project, inline edit, add, delete. Same shape as
pantry-vision/frontend (vanilla JS, no build step, ?api=&token= from the URL).
It edits the inventory and nothing else, on purpose. Every other table records something that was said or decided, and those are only wrong if someone recorded them wrong. The inventory describes physical reality, which drifts without telling anybody — so it's the one table that needs somewhere a person can sit down and fix it.
Deliberately not the purple/magenta holo theme from the feasibility note: that's for the canvas surfaces the assistant draws on, where the look is the point. This is a form you correct data in, and a glow behind a text field is a cost with no benefit.
Gitea
POST /projects/<slug>/repo creates a repository and records its URL on the project.
Needs GITEA_URL + GITEA_TOKEN (Settings → Applications → Generate New Token, scope
write:repository); refused with a clear message while unset.
The assistant writes history. It never rewrites it.
Allowed, unattended: commit, push, branch, tag, merge — everything that adds. The worst case is a bad commit you revert.
Never, by any path: force-push, rebase, amend, reset --hard, filter-branch,
filter-repo, branch or tag deletion, reflog expiry, gc --prune. Rewriting has no
undo, and the whole point is that a rollback is always feasible.
That refusal lives in git_ops.py as a deny-list checked on every git invocation —
but client-side refusal is a policy, not a control. The enforcement is Gitea
branch protection, applied automatically at repo creation (enable_force_push: false, enable_delete: false), which rejects the push regardless of what asked for
it, including a human who typed --force out of habit. Check
repo.protection.applied in the create response; never assume it's on because the
repo exists.
When history genuinely has to be scrubbed — a committed API token —
POST /projects/<slug>/scrub-request returns the commands for you to run by hand
from the repo on the SMB share. It executes nothing. The manual step is the safety
mechanism: the person running git filter-repo can read it first and take a copy;
a service doing it on a timer cannot. The instructions lead with rotate the secret
first, because the commit was already pushed and a scrub that makes you feel finished
without rotating is worse than no scrub.
The working tree is code/ inside the project workspace, never the workspace root
— so no git operation of any kind has a path to the datasheets and photos beside it.
Repository deletion isn't implemented at all, even though the token permits it.
Use a dedicated Gitea user ("workshop-bot") scoped to one organisation via
GITEA_OWNER, not a token on your own account. The blast radius of a leaked env file
is then one org of generated repos.
An existing repo of the same name is a success, not a conflict — asking twice means you wanted it to exist, and erroring there would make every retry look like a failure while leaving the caller with no URL to record.
Infrastructure health
health.py polls CheckMK and every OPNsense firewall every few minutes and
keeps ~30 days of samples in infra_status.
Why here and not in digest-engine, which already reads one firewall: the digest
runs four times a day, and "is the NAS disk failing right now" is not a question with a
six-hour answer. This service is always on, so it polls; digest-engine's
ingest/infra_health.py reads this over HTTP. One poller, two consumers — and the
digest gets "critical since Tuesday" instead of a snapshot it can't compare to
anything.
Three states, not two: ok, problem, and unreachable. "I could not ask" is a
different fact from "I asked and it is broken", and collapsing them is how a display
shows green for a machine that has been off all day. Every failure is written as a row
rather than raised — silence has to be visible.
Multiple firewalls are the point. OPNSENSE_JSON is a list and every row carries
its firewall's name, all the way to the display and the digest prompt. A household
with two firewalls must never be told "the IDS is running".
Read-only, and not by promise: the CheckMK user should hold the Guest role, which
cannot acknowledge, downtime or reschedule. From OPNsense this reads exactly one
endpoint (GET /api/ids/service/status); the alert query stays in digest-engine, which
already does the paging properly — two implementations of one read would eventually
give two different answers about one log file.
Unlike the rest of the knowledge store, these samples do expire (30 days). A service state from three weeks ago is an observation, not a fact.
Cameras
GET /cameras returns names and go2rtc stream ids; the frontend's Cameras tab embeds
go2rtc's own player per stream. This service never proxies video — a Python HTTP
server in the path of an H.264 stream is how a working camera starts stuttering.
Network cameras only. Every USB camera in this project is aimed at one fixed thing at an angle useless for anything else.
Fleet scripts — the monitoring-agent admin surface
The Fleet scripts tab holds one script per kind of machine, uploaded through the
browser and fetched by each endpoint's fleet-bootstrap timer
(tools/fleet-bootstrap.sh). It is how every machine in the house gets a CheckMK
agent without SSHing into eleven hosts and pasting the same installer.
| Slot | Covers | Fetched by a device? |
|---|---|---|
debian-x86 |
thin clients, touch panel, kitchen display, door panel, workshop | yes |
raspbian-arm |
arm64 audio endpoints, any future Pi | yes |
container-host |
the Docker host — agent plus container and disk checks | yes |
llm-host |
the GPU machine — different packages, and the only host where VRAM is worth checking | yes |
esphome |
voice satellites, BLE proxies, RuView nodes | no |
network-appliance |
OPNsense, switches, cameras | no |
The last two cannot run a script — a microcontroller has no shell. Their slots hold the CheckMK-server side (SNMP config, a special agent that polls them), and the UI says so. They exist because "are the ESPs monitored?" deserves a visible answer rather than being quietly out of scope.
Slots are a fixed set, not a free-form file list: each endpoint knows which slot is its own without being told, and empty slots are listed — the thing someone setting this up needs to see is which platforms are still uncovered.
The rules that make this safe enough to have
This is remote code execution by design; there is no version of "distribute scripts to the endpoints" that isn't. So the honesty is in the constraints:
- Upload is not deploy. An upload is a draft; endpoints only ever fetch the published version, and publishing is a second, deliberate click. Same propose-then-confirm rule as pantry-vision's stock writes — and it matters more here, because what you are confirming runs as root on every machine in the house.
- This service never executes anything. It stores text and serves text. There is no "deploy now" button, because a button that runs a script on eleven machines at once is a button that breaks eleven machines at once.
- Scripts live in SQLite, not on the SMB share — the one place the "artefacts go on the share" rule is wrong. The share is writable by anyone with the SMB password, and a file executed as root on every endpoint must not be writable by that path: it would make a share credential a whole-fleet code-execution credential with no history.
- Every version is kept, so rolling back is picking an older row — the same
append-only reasoning
git_ops.pyuses. - The endpoint verifies the sha256 before running, runs a version once, and reports back pass or fail. A script that was served is not a script that succeeded, and only the endpoint knows which. The admin page flags a host running an older version than the one now published.
- Opt-in per host: no
/etc/fleet-bootstrap.conf, no execution.
Worth saying in your own words before enabling it: anyone who can publish here can
run code as root on every machine in the house. WORKSHOP_TOKEN is the most powerful
credential in this project.
Not built yet
Everything past step 1 of the feasibility note, and it is worth being explicit because this component is the foundation the rest attaches to:
- The canvas
svgwindow kind and its server-side renderers (Graphviz first) — the diagram half ofdocs/workshop-assistant.md. The frontend here is an inventory editor plus cameras plus health; it is not yet the assistant's display surface. - Spec research, guide lookup, camera identification. Steps 2–6 of that doc. All write into this schema; none exist.
- Nothing has run against a real deployment. Covered by smoke tests against
temporary databases (projects, knowledge, hardware assignment including the
in_useexception, git commit/branch/deny-list, an unreachable health target), and that is the whole of the testing. In particular: the CheckMK API shape and the Gitea branch-protection payload are written from documentation, and both have version-sensitive field names.