# workshop The project notebook behind the workshop/office assistant — **step 1 of [`docs/workshop-assistant.md`](../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 `DELETE`s — 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 into `workshop.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//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_RE` in `server.py`). A project slug is a directory name on that share, which makes it the one input here with a filesystem consequence. Silently rewriting `../../etc` to `etc` would 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=]` | every project, or the ones belonging to a room | | `POST /projects` | `{"name", "slug"?, "room"?, "summary"?}` → creates it and its workspace directories | | `GET /projects/` | the project with its notes, decisions, durable knowledge and claimed hardware | | `POST /projects/` | `{"name"?, "status"?, "summary"?, "room"?}` — status is `active`/`parked`/`done` | | `GET /projects//files` | directory listing of the workspace, never contents | | `POST /projects//notes` | `{"body", "author"?}` — `author` distinguishes what you wrote from what the assistant wrote | | `POST /projects//decisions` | `{"question", "answer", "because"?}` | | `POST /projects//knowledge` | `{"body", "keywords"?}` — a durable learning, not a log entry | | `POST /projects//repo` | `{"name"?, "description"?, "private"?}` → creates a Gitea repo, applies branch protection, sets up `code/` and records the URL | | `GET /projects//git` | branch, dirty files, last five commits | | `POST /projects//commit` | `{"message", "push"?}` — commit and push. Reported separately: a failed push is not a failed commit | | `POST /projects//branch` | `{"name"}` — create and switch. There is no delete counterpart, on purpose | | `POST /projects//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/` | 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/` | 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/`, `DELETE /knowledge/{workflow,facts,projects}/` | 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`](../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 ```sh 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//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//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.py` uses. - **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: 1. **The canvas `svg` window kind and its server-side renderers** (Graphviz first) — the diagram half of `docs/workshop-assistant.md`. The frontend here is an inventory editor plus cameras plus health; it is not yet the assistant's display surface. 2. **Spec research, guide lookup, camera identification.** Steps 2–6 of that doc. All write into this schema; none exist. 3. **Nothing has run against a real deployment.** Covered by smoke tests against temporary databases (projects, knowledge, hardware assignment including the `in_use` exception, 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.