684 lines
30 KiB
Python
684 lines
30 KiB
Python
"""workshop — the project notebook behind the workshop/office assistant, from
|
|
docs/workshop-assistant.md.
|
|
|
|
STEP 1 OF THAT DOC, AND DELIBERATELY ONLY STEP 1. The feasibility note ranks four
|
|
capabilities and says to build this one first, because it is the only one with no
|
|
perception problem in it and because it is where everything else lands: an identified
|
|
mainboard is worth nothing until there is a project to attach it to. Camera
|
|
identification, spec research and guide lookup all arrive later as *writers into this
|
|
database*, not as separate stores.
|
|
|
|
THE SPLIT THAT SHAPES THIS FILE
|
|
--------------------------------
|
|
**SQLite is the index; the SMB share is the filing cabinet.** State that has to be
|
|
queried — projects, decisions, what step you are on — lives in the database.
|
|
(What you *own* is knowledge, not work product: it lives in knowledge.py's separate,
|
|
never-pruned database, because there must be exactly one inventory.)
|
|
Artefacts — datasheets, generated diagrams, photos of the board, notes you want to
|
|
open from a laptop — live as ordinary files under WORKSPACE_DIR, which is exported
|
|
read-write over SMB. Mixing the two gets you the worst of both: a database you cannot
|
|
browse and files nothing can query.
|
|
|
|
That is also why this service never returns file *contents* for anything in the
|
|
workspace. It lists what is there and it hands back paths; opening the file is the
|
|
share's job, and a second copy of the bytes over HTTP would be a second place for them
|
|
to be stale. `GET /projects/<slug>/files` is a directory listing, not a file server.
|
|
|
|
WHY DECISIONS ARE THEIR OWN TABLE
|
|
----------------------------------
|
|
"What did I decide last Tuesday, and why" is the single question a project notebook
|
|
exists to answer, and it is the one a pile of notes answers worst. A decision has a
|
|
question, an answer, and a reason, and it is worth the four columns to keep those
|
|
apart — the same argument this project already makes for every "flag the reasoning,
|
|
not just the outcome" README in the repo.
|
|
|
|
SECURITY BOUNDARY, same as every other service here: one bearer token, checked on
|
|
every request including the GETs, failing closed when unset. This one holds no
|
|
credentials and no personal data, but it *writes files into a share*, so see
|
|
_safe_slug() for the one rule that keeps that from becoming a path-traversal
|
|
question.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import sqlite3
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
from http import HTTPStatus
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from pathlib import Path
|
|
from urllib.parse import parse_qs, urlsplit
|
|
|
|
import fleet
|
|
import git_ops
|
|
import gitea
|
|
import health
|
|
import knowledge
|
|
|
|
LOG = logging.getLogger("workshop")
|
|
|
|
TOKEN = os.environ.get("WORKSHOP_TOKEN", "")
|
|
DB_PATH = Path(os.environ.get("WORKSHOP_DB_PATH", "/data/workshop.db"))
|
|
# The SMB-exported workspace. Every project gets a directory under here; see
|
|
# docs/workshop-assistant.md for the layout and for why it is a share at all.
|
|
WORKSPACE_DIR = Path(os.environ.get("WORKSHOP_WORKSPACE_DIR", "/workspace"))
|
|
PROJECT_SUBDIRS = ("notes", "datasheets", "diagrams", "photos", "scratch")
|
|
|
|
MAX_JSON_BYTES = 256 * 1024
|
|
|
|
# A slug is a directory name on a writable share, which makes it the one piece of
|
|
# user input in this service with a filesystem consequence. Anchored, no dots, no
|
|
# separators — "../../etc" cannot survive this, and neither can a name that only
|
|
# differs from another by case on a case-insensitive mount.
|
|
SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$")
|
|
|
|
|
|
def _now() -> str:
|
|
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
|
|
|
|
def _safe_slug(value: str) -> str | None:
|
|
"""A slug, or None. Never raises, never sanitises-and-continues.
|
|
|
|
Sanitising is the tempting option and the wrong one: silently turning `../x` into
|
|
`x` means two different requests write to one directory, and the person who typed
|
|
the first one never finds out. A bad slug is a 400.
|
|
"""
|
|
slug = str(value or "").strip().lower()
|
|
return slug if SLUG_RE.match(slug) else None
|
|
|
|
|
|
def _slugify(name: str) -> str:
|
|
"""A first-guess slug from a project name, for the common case where nobody
|
|
supplied one. Not authoritative — the result still goes through _safe_slug()."""
|
|
slug = re.sub(r"[^a-z0-9]+", "-", str(name or "").strip().lower()).strip("-")
|
|
return slug[:63] or "project"
|
|
|
|
|
|
# --- storage ----------------------------------------------------------------------
|
|
def _db() -> sqlite3.Connection:
|
|
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
conn = sqlite3.connect(DB_PATH, timeout=10)
|
|
conn.row_factory = sqlite3.Row
|
|
conn.execute("PRAGMA foreign_keys = ON")
|
|
return conn
|
|
|
|
|
|
def init_db() -> None:
|
|
with _db() as conn:
|
|
conn.executescript(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS projects (
|
|
id INTEGER PRIMARY KEY,
|
|
slug TEXT NOT NULL UNIQUE,
|
|
name TEXT NOT NULL,
|
|
-- The HA area this project belongs to, so a room-scoped assistant can
|
|
-- answer "what am I working on" with the projects of the room it was
|
|
-- asked in. An area_id, the same vocabulary as everything else in this
|
|
-- project — see docs/rooms-and-endpoints.md.
|
|
room TEXT,
|
|
-- active | parked | done. 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.
|
|
status TEXT NOT NULL DEFAULT 'active',
|
|
summary TEXT,
|
|
-- The project's Gitea repository, once one exists. A URL, not a flag:
|
|
-- what anybody actually wants from this column is something to click
|
|
-- or clone, and "true" would send every reader to Gitea to search.
|
|
repo_url TEXT,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS notes (
|
|
id INTEGER PRIMARY KEY,
|
|
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
body TEXT NOT NULL,
|
|
-- Who or what wrote this. 'human' or an assistant identifier — kept so
|
|
-- a note the model wrote is never mistaken for something you said, the
|
|
-- same distinction digest-engine draws between a source's claim and a
|
|
-- fact.
|
|
author TEXT NOT NULL DEFAULT 'human',
|
|
created_at TEXT NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS decisions (
|
|
id INTEGER PRIMARY KEY,
|
|
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
question TEXT NOT NULL,
|
|
answer TEXT NOT NULL,
|
|
-- The column this table exists for. An answer without its reasoning is
|
|
-- a thing you will re-litigate in three weeks.
|
|
because TEXT,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS notes_by_project ON notes (project_id, created_at);
|
|
CREATE INDEX IF NOT EXISTS decisions_by_project ON decisions (project_id, created_at);
|
|
"""
|
|
)
|
|
|
|
|
|
def _project_dir(slug: str) -> Path:
|
|
return WORKSPACE_DIR / "projects" / slug
|
|
|
|
|
|
def _ensure_project_dir(slug: str) -> list[str]:
|
|
"""Create the project's workspace directories. Best-effort by design.
|
|
|
|
An unmounted or read-only share must not stop a project from being created — the
|
|
notebook is in SQLite and is the part that matters, and a share that comes back
|
|
later can have its directories made then (every write path calls this). Returns
|
|
the subdirectories that exist, so the API can say plainly whether the share is
|
|
working rather than implying it is.
|
|
"""
|
|
made = []
|
|
for sub in PROJECT_SUBDIRS:
|
|
try:
|
|
(_project_dir(slug) / sub).mkdir(parents=True, exist_ok=True)
|
|
made.append(sub)
|
|
except OSError:
|
|
LOG.warning("workshop: could not create %s/%s in the workspace", slug, sub, exc_info=True)
|
|
return made
|
|
|
|
|
|
def _row(row) -> dict:
|
|
return {k: row[k] for k in row.keys()}
|
|
|
|
|
|
def list_projects(room: str = "") -> dict:
|
|
with _db() as conn:
|
|
if room:
|
|
rows = conn.execute(
|
|
"SELECT * FROM projects WHERE room = ? ORDER BY updated_at DESC", (room,)
|
|
).fetchall()
|
|
else:
|
|
rows = conn.execute("SELECT * FROM projects ORDER BY updated_at DESC").fetchall()
|
|
return {"projects": [_row(r) for r in rows]}
|
|
|
|
|
|
def create_project(payload: dict) -> dict:
|
|
name = str(payload.get("name") or "").strip()
|
|
if not name:
|
|
return {"ok": False, "reason": "bad_field", "message": "'name' is required."}
|
|
slug = _safe_slug(payload.get("slug") or _slugify(name))
|
|
if slug is None:
|
|
return {
|
|
"ok": False,
|
|
"reason": "bad_slug",
|
|
"message": "A slug is lowercase letters, digits and hyphens — it is also a "
|
|
"directory name on the share, so it can't contain dots or slashes.",
|
|
}
|
|
room = str(payload.get("room") or "").strip()
|
|
with _db() as conn:
|
|
if conn.execute("SELECT 1 FROM projects WHERE slug = ?", (slug,)).fetchone():
|
|
return {"ok": False, "reason": "duplicate", "message": f"A project called {slug!r} already exists."}
|
|
conn.execute(
|
|
"INSERT INTO projects (slug, name, room, status, summary, created_at, updated_at) "
|
|
"VALUES (?, ?, ?, 'active', ?, ?, ?)",
|
|
(slug, name, room or None, str(payload.get("summary") or "").strip() or None, _now(), _now()),
|
|
)
|
|
row = conn.execute("SELECT * FROM projects WHERE slug = ?", (slug,)).fetchone()
|
|
made = _ensure_project_dir(slug)
|
|
LOG.info("workshop: created project %s (workspace dirs: %s)", slug, ", ".join(made) or "none")
|
|
return {"ok": True, "project": _row(row), "workspace": _workspace_status(slug, made)}
|
|
|
|
|
|
def _workspace_status(slug: str, made: list[str]) -> dict:
|
|
"""Says plainly whether the share is usable, rather than implying it is by silence."""
|
|
return {
|
|
"path": str(_project_dir(slug)),
|
|
"subdirs": made,
|
|
"available": len(made) == len(PROJECT_SUBDIRS),
|
|
}
|
|
|
|
|
|
def get_project(slug: str) -> dict | None:
|
|
with _db() as conn:
|
|
row = conn.execute("SELECT * FROM projects WHERE slug = ?", (slug,)).fetchone()
|
|
if row is None:
|
|
return None
|
|
project = _row(row)
|
|
pid = row["id"]
|
|
project["notes"] = [_row(r) for r in conn.execute(
|
|
"SELECT * FROM notes WHERE project_id = ? ORDER BY created_at DESC", (pid,))]
|
|
|
|
project["decisions"] = [_row(r) for r in conn.execute(
|
|
"SELECT * FROM decisions WHERE project_id = ? ORDER BY created_at DESC", (pid,))]
|
|
# Hardware lives in the knowledge database, not here — see knowledge.py's docstring
|
|
# on why there is exactly one inventory. A project reads its claims from it.
|
|
project["hardware"] = knowledge.list_hardware(project_slug=slug)["hardware"]
|
|
project["knowledge"] = knowledge.list_project_knowledge(slug)["project_knowledge"]
|
|
project["workspace"] = _workspace_status(slug, [
|
|
s for s in PROJECT_SUBDIRS if (_project_dir(slug) / s).is_dir()
|
|
])
|
|
return project
|
|
|
|
|
|
def update_project(slug: str, payload: dict) -> dict:
|
|
updates = []
|
|
if "name" in payload:
|
|
name = str(payload["name"] or "").strip()
|
|
if not name:
|
|
return {"ok": False, "reason": "bad_field", "message": "'name' cannot be empty."}
|
|
updates.append(("name", name))
|
|
if "status" in payload:
|
|
status = str(payload["status"] or "").strip().lower()
|
|
if status not in ("active", "parked", "done"):
|
|
return {"ok": False, "reason": "bad_field", "message": "status is active, parked or done."}
|
|
updates.append(("status", status))
|
|
for field in ("summary", "room"):
|
|
if field in payload:
|
|
updates.append((field, str(payload[field] or "").strip() or None))
|
|
if not updates:
|
|
return {"ok": False, "reason": "bad_field", "message": "Nothing to update."}
|
|
|
|
with _db() as conn:
|
|
if conn.execute("SELECT 1 FROM projects WHERE slug = ?", (slug,)).fetchone() is None:
|
|
return {"ok": False, "reason": "no_such_project", "message": "No project with that slug."}
|
|
for column, value in updates:
|
|
conn.execute(f"UPDATE projects SET {column} = ? WHERE slug = ?", (value, slug))
|
|
conn.execute("UPDATE projects SET updated_at = ? WHERE slug = ?", (_now(), slug))
|
|
row = conn.execute("SELECT * FROM projects WHERE slug = ?", (slug,)).fetchone()
|
|
return {"ok": True, "project": _row(row)}
|
|
|
|
|
|
def _project_id(conn, slug: str) -> int | None:
|
|
row = conn.execute("SELECT id FROM projects WHERE slug = ?", (slug,)).fetchone()
|
|
return int(row["id"]) if row else None
|
|
|
|
|
|
def add_note(slug: str, payload: dict) -> dict:
|
|
body = str(payload.get("body") or "").strip()
|
|
if not body:
|
|
return {"ok": False, "reason": "bad_field", "message": "'body' is required."}
|
|
author = str(payload.get("author") or "human").strip() or "human"
|
|
with _db() as conn:
|
|
pid = _project_id(conn, slug)
|
|
if pid is None:
|
|
return {"ok": False, "reason": "no_such_project", "message": "No project with that slug."}
|
|
conn.execute(
|
|
"INSERT INTO notes (project_id, body, author, created_at) VALUES (?, ?, ?, ?)",
|
|
(pid, body, author, _now()),
|
|
)
|
|
conn.execute("UPDATE projects SET updated_at = ? WHERE id = ?", (_now(), pid))
|
|
return {"ok": True}
|
|
|
|
|
|
def add_decision(slug: str, payload: dict) -> dict:
|
|
question = str(payload.get("question") or "").strip()
|
|
answer = str(payload.get("answer") or "").strip()
|
|
if not question or not answer:
|
|
return {"ok": False, "reason": "bad_field", "message": "'question' and 'answer' are both required."}
|
|
with _db() as conn:
|
|
pid = _project_id(conn, slug)
|
|
if pid is None:
|
|
return {"ok": False, "reason": "no_such_project", "message": "No project with that slug."}
|
|
conn.execute(
|
|
"INSERT INTO decisions (project_id, question, answer, because, created_at) VALUES (?, ?, ?, ?, ?)",
|
|
(pid, question, answer, str(payload.get("because") or "").strip() or None, _now()),
|
|
)
|
|
conn.execute("UPDATE projects SET updated_at = ? WHERE id = ?", (_now(), pid))
|
|
return {"ok": True}
|
|
|
|
|
|
def create_project_repo(slug: str, payload: dict) -> dict:
|
|
"""Give a project a Gitea repository and remember where it is.
|
|
|
|
The repo name defaults to the project slug — the slug is already the project's
|
|
stable, filesystem-safe identity, and having the directory on the share, the row in
|
|
the database and the repository all answer to one name is worth more than letting
|
|
each pick its own.
|
|
"""
|
|
with _db() as conn:
|
|
row = conn.execute("SELECT * FROM projects WHERE slug = ?", (slug,)).fetchone()
|
|
if row is None:
|
|
return {"ok": False, "reason": "no_such_project", "message": "No project with that slug."}
|
|
name = str(payload.get("name") or slug).strip()
|
|
description = str(payload.get("description") or row["summary"] or f"Workshop project: {row['name']}")
|
|
|
|
result = gitea.create_repo(name, description, payload.get("private"))
|
|
if not result.get("ok"):
|
|
return result
|
|
|
|
url = result["repo"].get("html_url")
|
|
# Set up code/ as a working tree pointed at the new remote, so the first commit
|
|
# does not need a second call. Best-effort: a repo with no local tree yet is fine.
|
|
result["local"] = git_ops.ensure_repo(_project_dir(slug), result["repo"].get("clone_url") or "")
|
|
with _db() as conn:
|
|
conn.execute("UPDATE projects SET repo_url = ?, updated_at = ? WHERE slug = ?", (url, _now(), slug))
|
|
LOG.info("workshop: project %s -> %s", slug, url)
|
|
return result
|
|
|
|
|
|
def cameras() -> dict:
|
|
"""The network cameras the workshop display may show.
|
|
|
|
Network, not USB: every USB camera in this project is aimed at one fixed thing (an
|
|
item held up to the kitchen display, an appliance door) at an angle that is useless
|
|
for anything else. The cameras worth putting on a workshop screen are the ones
|
|
already on the network and already ingested by go2rtc/Frigate.
|
|
"""
|
|
raw = os.environ.get("WORKSHOP_CAMERAS", "").strip()
|
|
entries = []
|
|
for item in raw.split(","):
|
|
item = item.strip()
|
|
if not item or ":" not in item:
|
|
continue
|
|
name, _, stream = item.partition(":")
|
|
if name.strip() and stream.strip():
|
|
entries.append({"name": name.strip(), "stream": stream.strip()})
|
|
return {"cameras": entries, "go2rtc_url": os.environ.get("GO2RTC_URL", "").rstrip("/")}
|
|
|
|
|
|
def list_files(slug: str) -> dict:
|
|
"""A directory listing of the project's workspace — names and sizes, never bytes.
|
|
|
|
The share serves the files; this says what is there. Symlinks are reported but
|
|
never followed, and nothing outside the project directory is ever listed, because
|
|
the whole point of the slug rule is that this path is not user-controlled beyond
|
|
one directory name.
|
|
"""
|
|
base = _project_dir(slug)
|
|
result: dict[str, list] = {}
|
|
for sub in PROJECT_SUBDIRS:
|
|
entries = []
|
|
directory = base / sub
|
|
try:
|
|
for entry in sorted(directory.iterdir(), key=lambda p: p.name):
|
|
try:
|
|
stat = entry.stat()
|
|
entries.append({
|
|
"name": entry.name,
|
|
"bytes": stat.st_size,
|
|
"modified_at": datetime.fromtimestamp(stat.st_mtime, timezone.utc)
|
|
.isoformat().replace("+00:00", "Z"),
|
|
"is_dir": entry.is_dir(),
|
|
})
|
|
except OSError:
|
|
continue
|
|
except OSError:
|
|
# Missing or unreadable: an empty list plus `available: false` below, not
|
|
# an error — a share that is temporarily not mounted must not look like a
|
|
# project that has no files.
|
|
pass
|
|
result[sub] = entries
|
|
return {
|
|
"path": str(base),
|
|
"available": base.is_dir(),
|
|
"files": result,
|
|
}
|
|
|
|
|
|
# --- HTTP -------------------------------------------------------------------------
|
|
class Handler(BaseHTTPRequestHandler):
|
|
server_version = "workshop/1"
|
|
|
|
def log_message(self, format, *args): # noqa: A002
|
|
LOG.info("%s - %s", self.address_string(), format % args)
|
|
|
|
def _authorized(self) -> bool:
|
|
return bool(TOKEN) and self.headers.get("Authorization", "") == f"Bearer {TOKEN}"
|
|
|
|
def _respond(self, status: HTTPStatus, payload) -> None:
|
|
body = json.dumps(payload).encode("utf-8")
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.send_header("Access-Control-Allow-Origin", "*")
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def _json_body(self) -> dict | None:
|
|
try:
|
|
length = int(self.headers.get("Content-Length") or 0)
|
|
if length > MAX_JSON_BYTES:
|
|
raise ValueError(f"body too large ({length} bytes)")
|
|
payload = json.loads(self.rfile.read(length) or b"{}")
|
|
except (ValueError, json.JSONDecodeError) as exc:
|
|
self._respond(HTTPStatus.BAD_REQUEST, {"error": f"bad request body: {exc}"})
|
|
return None
|
|
if not isinstance(payload, dict):
|
|
self._respond(HTTPStatus.BAD_REQUEST, {"error": "body must be a JSON object"})
|
|
return None
|
|
return payload
|
|
|
|
def _slug_or_400(self, raw: str) -> str | None:
|
|
slug = _safe_slug(raw)
|
|
if slug is None:
|
|
self._respond(HTTPStatus.BAD_REQUEST, {"error": f"{raw!r} is not a valid project slug"})
|
|
return slug
|
|
|
|
def _result(self, result: dict) -> None:
|
|
if result.get("ok"):
|
|
self._respond(HTTPStatus.OK, result)
|
|
return
|
|
status = {
|
|
"no_such_project": HTTPStatus.NOT_FOUND,
|
|
"no_such_item": HTTPStatus.NOT_FOUND,
|
|
"no_such_version": HTTPStatus.NOT_FOUND,
|
|
"duplicate": HTTPStatus.CONFLICT,
|
|
}.get(str(result.get("reason")), HTTPStatus.BAD_REQUEST)
|
|
self._respond(status, result)
|
|
|
|
def do_OPTIONS(self): # noqa: N802
|
|
self.send_response(HTTPStatus.NO_CONTENT)
|
|
self.send_header("Access-Control-Allow-Origin", "*")
|
|
self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
|
self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
|
|
self.end_headers()
|
|
|
|
def do_GET(self): # noqa: N802
|
|
if not self._authorized():
|
|
self._respond(HTTPStatus.UNAUTHORIZED, {"error": "missing or invalid bearer token"})
|
|
return
|
|
split = urlsplit(self.path)
|
|
path, query = split.path, parse_qs(split.query)
|
|
|
|
project_match = re.match(r"^/projects/([^/]+)$", path)
|
|
files_match = re.match(r"^/projects/([^/]+)/files$", path)
|
|
git_match = re.match(r"^/projects/([^/]+)/git$", path)
|
|
fleet_script_match = re.match(r"^/fleet/script/([a-z0-9-]+)$", path)
|
|
|
|
if path == "/projects":
|
|
self._respond(HTTPStatus.OK, list_projects((query.get("room") or [""])[0].strip()))
|
|
elif path == "/hardware":
|
|
self._respond(HTTPStatus.OK, knowledge.list_hardware(
|
|
(query.get("q") or [""])[0].strip(),
|
|
(query.get("status") or [""])[0].strip(),
|
|
(query.get("project") or [""])[0].strip(),
|
|
))
|
|
elif path == "/knowledge/workflow":
|
|
self._respond(HTTPStatus.OK, knowledge.list_workflow_notes(
|
|
(query.get("activity") or [""])[0].strip()))
|
|
elif path == "/knowledge/facts":
|
|
self._respond(HTTPStatus.OK, knowledge.list_facts((query.get("q") or [""])[0].strip()))
|
|
elif path == "/knowledge/projects":
|
|
self._respond(HTTPStatus.OK, knowledge.list_project_knowledge(
|
|
(query.get("project") or [""])[0].strip(), (query.get("q") or [""])[0].strip()))
|
|
elif path == "/fleet":
|
|
self._respond(HTTPStatus.OK, fleet.overview())
|
|
elif fleet_script_match:
|
|
# What an endpoint of this platform should run. Serves the PUBLISHED version
|
|
# only — a draft is invisible here by design.
|
|
entry = fleet.published(fleet_script_match.group(1))
|
|
if entry is None:
|
|
self._respond(HTTPStatus.NOT_FOUND,
|
|
{"error": "no published script for that platform"})
|
|
else:
|
|
self._respond(HTTPStatus.OK, entry)
|
|
elif path == "/cameras":
|
|
# Names and stream ids only — this service never proxies video. Putting a
|
|
# Python HTTP server in the path of an H.264 stream is how a working camera
|
|
# becomes a stuttering one; the kiosk talks to go2rtc directly.
|
|
self._respond(HTTPStatus.OK, cameras())
|
|
elif path == "/health":
|
|
# The workshop display's overlay reads this. `overall` is one word so the
|
|
# display never re-derives "is anything wrong" from a list.
|
|
self._respond(HTTPStatus.OK, health.latest())
|
|
elif path == "/context":
|
|
# The payoff: everything the assistant should already know, in one call.
|
|
self._respond(HTTPStatus.OK, knowledge.context(
|
|
(query.get("activity") or [""])[0].strip(),
|
|
(query.get("q") or [""])[0].strip(),
|
|
(query.get("project") or [""])[0].strip(),
|
|
))
|
|
elif project_match:
|
|
slug = self._slug_or_400(project_match.group(1))
|
|
if slug is None:
|
|
return
|
|
project = get_project(slug)
|
|
if project is None:
|
|
self._respond(HTTPStatus.NOT_FOUND, {"error": "no such project"})
|
|
else:
|
|
self._respond(HTTPStatus.OK, project)
|
|
elif git_match:
|
|
slug = self._slug_or_400(git_match.group(1))
|
|
if slug is not None:
|
|
self._respond(HTTPStatus.OK, git_ops.status(_project_dir(slug)))
|
|
elif files_match:
|
|
slug = self._slug_or_400(files_match.group(1))
|
|
if slug is not None:
|
|
self._respond(HTTPStatus.OK, list_files(slug))
|
|
else:
|
|
self._respond(HTTPStatus.NOT_FOUND, {"error": "no such endpoint"})
|
|
|
|
def do_DELETE(self): # noqa: N802
|
|
"""Corrections. "No retention period" means nothing expires by itself — it does
|
|
not mean nothing is ever wrong, and a knowledge base you cannot correct is one
|
|
people route around."""
|
|
if not self._authorized():
|
|
self._respond(HTTPStatus.UNAUTHORIZED, {"error": "missing or invalid bearer token"})
|
|
return
|
|
path = urlsplit(self.path).path
|
|
match = re.match(r"^/(hardware|knowledge/workflow|knowledge/facts|knowledge/projects)/(\d+)$", path)
|
|
if not match:
|
|
self._respond(HTTPStatus.NOT_FOUND, {"error": "no such endpoint"})
|
|
return
|
|
table = {
|
|
"hardware": "hardware",
|
|
"knowledge/workflow": "workflow_notes",
|
|
"knowledge/facts": "facts",
|
|
"knowledge/projects": "project_knowledge",
|
|
}[match.group(1)]
|
|
self._result(knowledge.delete_row(table, int(match.group(2))))
|
|
|
|
def do_POST(self): # noqa: N802
|
|
if not self._authorized():
|
|
self._respond(HTTPStatus.UNAUTHORIZED, {"error": "missing or invalid bearer token"})
|
|
return
|
|
path = urlsplit(self.path).path
|
|
|
|
note_match = re.match(r"^/projects/([^/]+)/notes$", path)
|
|
knowledge_match = re.match(r"^/projects/([^/]+)/knowledge$", path)
|
|
repo_match = re.match(r"^/projects/([^/]+)/repo$", path)
|
|
commit_match = re.match(r"^/projects/([^/]+)/commit$", path)
|
|
branch_match = re.match(r"^/projects/([^/]+)/branch$", path)
|
|
scrub_match = re.match(r"^/projects/([^/]+)/scrub-request$", path)
|
|
hardware_match = re.match(r"^/hardware/(\d+)$", path)
|
|
decision_match = re.match(r"^/projects/([^/]+)/decisions$", path)
|
|
project_match = re.match(r"^/projects/([^/]+)$", path)
|
|
|
|
payload = self._json_body()
|
|
if payload is None:
|
|
return
|
|
|
|
if path == "/projects":
|
|
self._result(create_project(payload))
|
|
elif path == "/hardware":
|
|
self._result(knowledge.add_hardware(payload))
|
|
elif hardware_match:
|
|
self._result(knowledge.update_hardware(int(hardware_match.group(1)), payload))
|
|
elif path == "/fleet/upload":
|
|
self._result(fleet.upload(payload.get("platform", ""), payload.get("body", ""),
|
|
payload.get("note", "")))
|
|
elif path == "/fleet/publish":
|
|
# The second, deliberate action. Upload is not deploy — see fleet.py.
|
|
try:
|
|
version = int(payload.get("version"))
|
|
except (TypeError, ValueError):
|
|
self._respond(HTTPStatus.BAD_REQUEST, {"error": "'version' is required"})
|
|
return
|
|
self._result(fleet.publish(payload.get("platform", ""), version))
|
|
elif path == "/fleet/report":
|
|
self._result(fleet.report(payload))
|
|
elif path == "/knowledge/workflow":
|
|
self._result(knowledge.add_workflow_note(payload))
|
|
elif path == "/knowledge/facts":
|
|
self._result(knowledge.add_fact(payload))
|
|
elif note_match:
|
|
slug = self._slug_or_400(note_match.group(1))
|
|
if slug is not None:
|
|
self._result(add_note(slug, payload))
|
|
elif repo_match:
|
|
slug = self._slug_or_400(repo_match.group(1))
|
|
if slug is not None:
|
|
self._result(create_project_repo(slug, payload))
|
|
elif commit_match:
|
|
slug = self._slug_or_400(commit_match.group(1))
|
|
if slug is not None:
|
|
self._result(git_ops.commit(
|
|
_project_dir(slug), payload.get("message", ""), bool(payload.get("push", True))))
|
|
elif branch_match:
|
|
slug = self._slug_or_400(branch_match.group(1))
|
|
if slug is not None:
|
|
self._result(git_ops.branch(_project_dir(slug), payload.get("name", "")))
|
|
elif scrub_match:
|
|
# Never executes anything. Returns the commands for a human to run from the
|
|
# repository on the share — see git_ops.scrub_instructions().
|
|
slug = self._slug_or_400(scrub_match.group(1))
|
|
if slug is not None:
|
|
self._result(git_ops.scrub_instructions(
|
|
_project_dir(slug), payload.get("path", ""), payload.get("note", "")))
|
|
elif knowledge_match:
|
|
slug = self._slug_or_400(knowledge_match.group(1))
|
|
if slug is not None:
|
|
self._result(knowledge.add_project_knowledge(slug, payload))
|
|
elif decision_match:
|
|
slug = self._slug_or_400(decision_match.group(1))
|
|
if slug is not None:
|
|
self._result(add_decision(slug, payload))
|
|
elif project_match:
|
|
slug = self._slug_or_400(project_match.group(1))
|
|
if slug is not None:
|
|
self._result(update_project(slug, payload))
|
|
else:
|
|
self._respond(HTTPStatus.NOT_FOUND, {"error": "no such endpoint"})
|
|
|
|
|
|
def main() -> int:
|
|
logging.basicConfig(
|
|
level=os.environ.get("LOG_LEVEL", "INFO").upper(),
|
|
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
|
|
)
|
|
if not TOKEN:
|
|
LOG.error("WORKSHOP_TOKEN is not set — every request will be rejected until it is.")
|
|
|
|
init_db()
|
|
knowledge.init_db()
|
|
health.init_db()
|
|
fleet.init_db()
|
|
health.start_poller()
|
|
try:
|
|
(WORKSPACE_DIR / "projects").mkdir(parents=True, exist_ok=True)
|
|
except OSError:
|
|
LOG.warning(
|
|
"workshop: %s is not writable — the notebook still works, but nothing can be "
|
|
"filed on the share until it is",
|
|
WORKSPACE_DIR, exc_info=True,
|
|
)
|
|
|
|
port = int(os.environ.get("WORKSHOP_PORT", "8102"))
|
|
server = ThreadingHTTPServer(("0.0.0.0", port), Handler)
|
|
LOG.info("workshop listening on :%d (db: %s, workspace: %s)", port, DB_PATH, WORKSPACE_DIR)
|
|
try:
|
|
server.serve_forever()
|
|
except KeyboardInterrupt:
|
|
pass
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|