345 lines
20 KiB
Markdown
345 lines
20 KiB
Markdown
# 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/<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_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=<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`](../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/<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.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.
|