clone

an offline map explorer where each world, from a game map to a city's open data, ships as one .atlas file

main
atlas / internal / workbench / workbench.go
workbench.go 13.7K
1// Package workbench is the workbench beside the reader: one http.Handler that2// answers for the collection itself.3//4// Where the application serves the build of every volume a reader should have5// and asks no questions, the workbench answers the questions -- what each build6// is worth, what moved between two of them, what the collection owes the people7// whose work it carries, and what the pipeline should do next. It is pure8// HTMX: server-rendered pages, no seam, no client-side state (issue #5 §5.6).9//10// # What it is made of11//12// It reads two things and links three packages. It reads the registry13// directory, scoring every build it finds with internal/enrich/maturity, and it14// reads nothing else: the source registry arrives as data from whoever mounted15// the handler (sources.go), and pipeline work is done by shelling out to the16// `atlas` CLI through internal/workbench/oprunner, never by linking a lane. The17// import matrix of issue #5 §3.2 -- format plus enrich/maturity, and nothing18// else -- is therefore a property of the design rather than a rule the code has19// to be reminded of.20//21// # The pages22//23// GET / the library: every volume, headlined by its serving score24// GET /volume/{slug} measurement: the score, its breakdown, the axes, the ledger25// GET /volume/{slug}/diff two builds side by side, headlined by the score delta26// GET /sources the source registry: licence, attribution, id space27// GET /operations the pipeline, and what it may be pointed at28// POST /operations/run one operation, streamed back as rows29// GET /assets/{path...} the stylesheet, and the hypermedia runtime30//31// Every page is measurement first: a score is the headline and everything else32// is diagnostic, because the score is the only number anything gates on33// (issue #5 §5.3, docs/enrich.md).34//35// # Where the operating system is36//37// The handler is not held to the application's hostenv rule -- it is a38// developer's tool on a developer's machine, and it exists to run subprocesses39// -- but the OS still lives at the edges: library.go stats and opens files,40// oprunner starts processes, and everything between them works on values.41package workbench4243import (44 "bytes"45 "fmt"46 "html/template"47 "io"48 "log/slog"49 "net/http"50 "time"5152 "github.com/FelineStateMachine/atlas/format/bundle"53 "github.com/FelineStateMachine/atlas/internal/enrich/maturity"54 "github.com/FelineStateMachine/atlas/internal/logging"55 "github.com/FelineStateMachine/atlas/internal/workbench/oprunner"56)5758// Options is what a host tells the workbench about itself.59type Options struct {60 // Targets is where the workbench reads and where operations run. Its61 // Registry is the library every measurement page is about.62 Targets Targets63 // Sources is the capture source registry, as data (see sources.go).64 Sources []Source65 // Runtime is the vendored hypermedia runtime served at /assets/htmx.js.66 // It arrives as bytes from the wiring rather than by importing the67 // application, so one vendored copy serves both surfaces and neither68 // lane depends on the other. A nil runtime is a supported configuration:69 // every page here works as plain HTML, and operations still stream --70 // the browser renders the rows itself instead of a swap appending them.71 Runtime []byte72 // Table is the point table scores are read under. The zero value means73 // the embedded one, which is what everything but a test wants.74 Table maturity.Table75}7677// Workbench is the handler. It holds the registry it reads, the registry of78// sources it was told about, and the one operation slot.79type Workbench struct {80 library *library81 sources []Source82 targets Targets83 runtime []byte84 runner *oprunner.Runner8586 pages map[string]*template.Template87 rows *template.Template88 mux *http.ServeMux89}9091// New wires a workbench.92func New(opts Options) (*Workbench, error) {93 table := opts.Table94 if table.Version == 0 {95 held, err := maturity.Points()96 if err != nil {97 return nil, err98 }99 table = held100 }101 pages, rows, err := parseTemplates()102 if err != nil {103 return nil, err104 }105 w := &Workbench{106 library: &library{dir: opts.Targets.Registry, table: table},107 sources: opts.Sources,108 targets: opts.Targets,109 runtime: opts.Runtime,110 runner: &oprunner.Runner{},111 pages: pages,112 rows: rows,113 mux: http.NewServeMux(),114 }115 w.routes()116 return w, nil117}118119// ServeHTTP answers one request.120func (w *Workbench) ServeHTTP(rw http.ResponseWriter, r *http.Request) { w.mux.ServeHTTP(rw, r) }121122// routes is the whole URL surface, spelled in one place; nothing registers123// itself.124func (w *Workbench) routes() {125 w.mux.HandleFunc("GET /{$}", w.handleLibrary)126 w.mux.HandleFunc("GET /volume/{slug}", w.handleVolume)127 w.mux.HandleFunc("GET /volume/{slug}/diff", w.handleDiff)128 w.mux.HandleFunc("GET /sources", w.handleSources)129 w.mux.HandleFunc("GET /operations", w.handleOperations)130 w.mux.HandleFunc("POST /operations/run", w.handleRun)131 w.mux.HandleFunc("GET /assets/{path...}", w.handleAsset)132}133134// The content security policy every page carries, and it is the strict one:135// nothing loads from anywhere but this server, nothing but this server's own136// forms may be submitted to, and no page may be framed. A workbench that runs137// pipeline operations is exactly the page that must not be reachable through138// somebody else's document.139const contentSecurityPolicy = "default-src 'none'; style-src 'self'; script-src 'self'; " +140 "img-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'"141142// render executes a page into a buffer first, so a template error becomes a143// clean 500 rather than half a page.144func (w *Workbench) render(rw http.ResponseWriter, page string, data any) {145 held, known := w.pages[page]146 if !known {147 http.Error(rw, "no such page: "+page, http.StatusInternalServerError)148 return149 }150 var body bytes.Buffer151 if err := held.ExecuteTemplate(&body, "layout", data); err != nil {152 slog.Error("rendering a workbench page", logging.Op("workbench"),153 slog.String("page", page), slog.Any("error", err))154 http.Error(rw, "rendering failed: "+err.Error(), http.StatusInternalServerError)155 return156 }157 rw.Header().Set("Content-Type", "text/html; charset=utf-8")158 rw.Header().Set("Content-Security-Policy", contentSecurityPolicy)159 // The library is read whole on every ask, so a page is only ever true of160 // the moment it was asked for.161 rw.Header().Set("Cache-Control", "no-store")162 rw.Write(body.Bytes())163}164165// libraryPage is the collection, every volume headlined by what its serving166// build is worth.167type libraryPage struct {168 Dir string169 Table int170 Volumes []*volume171 Skipped []string172}173174func (w *Workbench) handleLibrary(rw http.ResponseWriter, r *http.Request) {175 volumes, skipped, err := w.library.volumes()176 if err != nil {177 http.Error(rw, err.Error(), http.StatusInternalServerError)178 return179 }180 w.render(rw, "library", libraryPage{181 Dir: w.library.dir,182 Table: w.library.table.Version,183 Volumes: volumes,184 Skipped: skipped,185 })186}187188// volumePage is one volume measured: the serving build's score and how it moved,189// then every build in full -- its worlds, the five axes as diagnostics, and the190// whole of every ledger its payloads carry.191type volumePage struct {192 Volume *volume193 Table int194 Movement *maturity.Comparison195}196197func (w *Workbench) handleVolume(rw http.ResponseWriter, r *http.Request) {198 held, _, err := w.library.volumeBySlug(r.PathValue("slug"))199 if err != nil {200 http.Error(rw, err.Error(), http.StatusInternalServerError)201 return202 }203 if held == nil {204 http.NotFound(rw, r)205 return206 }207 w.render(rw, "volume", volumePage{208 Volume: held,209 Table: w.library.table.Version,210 Movement: held.Movement(),211 })212}213214// diffPage is two builds of one volume, headlined by the score delta.215type diffPage struct {216 Volume *volume217 Diff *buildDiff218}219220// handleDiff compares two builds of one volume, named by file. The packed221// features are read here, on demand, because only a comparison ever needs them222// whole.223func (w *Workbench) handleDiff(rw http.ResponseWriter, r *http.Request) {224 held, _, err := w.library.volumeBySlug(r.PathValue("slug"))225 if err != nil {226 http.Error(rw, err.Error(), http.StatusInternalServerError)227 return228 }229 if held == nil {230 http.NotFound(rw, r)231 return232 }233 from, to := held.Build(r.FormValue("a")), held.Build(r.FormValue("b"))234 if from == nil || to == nil {235 http.Error(rw, "name two builds of this volume", http.StatusBadRequest)236 return237 }238 featuresA, err := features(from)239 if err != nil {240 http.Error(rw, err.Error(), http.StatusInternalServerError)241 return242 }243 featuresB, err := features(to)244 if err != nil {245 http.Error(rw, err.Error(), http.StatusInternalServerError)246 return247 }248 w.render(rw, "diff", diffPage{Volume: held, Diff: diffBuilds(from, to, featuresA, featuresB)})249}250251// sourcesPage is the card wall.252type sourcesPage struct {253 Sources []Source254}255256func (w *Workbench) handleSources(rw http.ResponseWriter, r *http.Request) {257 w.render(rw, "sources", sourcesPage{Sources: w.sources})258}259260// operationsPage is the pipeline: what may be run, what each one needs, and261// what the workbench was pointed at.262type operationsPage struct {263 Targets Targets264 Sources []Source265 Operations []operationCard266 Busy string267}268269// operationCard is one operation with its readiness resolved, so the template270// renders a decision rather than making one.271type operationCard struct {272 operation273 Ready bool274 Missing string275}276277func (w *Workbench) handleOperations(rw http.ResponseWriter, r *http.Request) {278 cards := make([]operationCard, 0, len(operations))279 for _, held := range operations {280 ready, missing := held.Ready(w.targets)281 cards = append(cards, operationCard{operation: held, Ready: ready, Missing: missing})282 }283 crawlable := make([]Source, 0, len(w.sources))284 for _, source := range w.sources {285 if source.Crawlable {286 crawlable = append(crawlable, source)287 }288 }289 w.render(rw, "operations", operationsPage{290 Targets: w.targets,291 Sources: crawlable,292 Operations: cards,293 Busy: w.runner.Busy(),294 })295}296297// handleRun runs one operation and streams its voice back as rows.298//299// The safety properties are the runner's: a foreign Origin is refused, a second300// operation is refused, and the subprocess dies with the request. What is301// decided here is only what to run -- and every refusal of that is a 400 that302// starts nothing.303func (w *Workbench) handleRun(rw http.ResponseWriter, r *http.Request) {304 if err := r.ParseForm(); err != nil {305 http.Error(rw, err.Error(), http.StatusBadRequest)306 return307 }308 asked := request{309 Operation: r.FormValue("op"),310 Source: r.FormValue("source"),311 Target: r.FormValue("target"),312 Volume: r.FormValue("volume"),313 }314 if asked.Operation == "" {315 http.Error(rw, errNoOperation.Error(), http.StatusBadRequest)316 return317 }318 // The origin check happens before anything is planned as well as inside319 // the runner: a cross-site POST should not even be told which operations320 // exist.321 if err := oprunner.CheckOrigin(r); err != nil {322 http.Error(rw, err.Error(), http.StatusForbidden)323 return324 }325 op, err := plan(w.targets, w.sources, asked)326 if err != nil {327 http.Error(rw, err.Error(), http.StatusBadRequest)328 return329 }330 slog.Info("running an operation", logging.Op("workbench"),331 slog.String("operation", op.Name), slog.String("command", op.Command()))332 w.runner.Serve(rw, r, op, w.writeRow)333}334335// writeRow renders one row of an operation's output. The rows region swaps336// beforeend, so one row is one fragment and the page grows by a line at a time337// -- the same shape the application's import stream takes (issue #5 §4.3).338func (w *Workbench) writeRow(to io.Writer, row oprunner.Row) error {339 return w.rows.ExecuteTemplate(to, "op-row", row)340}341342// assetEpoch is the modification time every asset reports: the bytes are343// compiled in (or handed over at startup), so they change exactly when the344// binary does and never while it is running.345var assetEpoch = time.Unix(0, 0).UTC()346347// handleAsset serves the workbench's own chrome: the stylesheet, and the348// hypermedia runtime the wiring handed over.349func (w *Workbench) handleAsset(rw http.ResponseWriter, r *http.Request) {350 var body []byte351 var kind string352 switch r.PathValue("path") {353 case "workbench.css":354 body, kind = stylesheet(), "text/css; charset=utf-8"355 case "htmx.js":356 if len(w.runtime) == 0 {357 // A workbench mounted without a runtime is a working workbench;358 // saying so plainly is better than serving an empty script.359 http.NotFound(rw, r)360 return361 }362 body, kind = w.runtime, "text/javascript; charset=utf-8"363 default:364 http.NotFound(rw, r)365 return366 }367 rw.Header().Set("Content-Type", kind)368 rw.Header().Set("Cache-Control", "no-cache")369 http.ServeContent(rw, r, r.PathValue("path"), assetEpoch, bytes.NewReader(body))370}371372// parseTemplates builds the page set. Each page is the layout plus its own373// file, in its own template namespace, so two pages may both define "content"374// and each stays one file long.375func parseTemplates() (map[string]*template.Template, *template.Template, error) {376 funcs := template.FuncMap{377 // The one display function templates get: a stamp is 64 characters378 // and a page wants the twelve that identify it (format/bundle).379 "short": bundle.ShortStamp,380 }381 pages := make(map[string]*template.Template, len(pageNames))382 for _, name := range pageNames {383 held, err := template.New("layout").Funcs(funcs).ParseFS(384 files, "templates/layout.tmpl", "templates/"+name+".tmpl")385 if err != nil {386 return nil, nil, fmt.Errorf("workbench templates: %w", err)387 }388 pages[name] = held389 }390 rows, err := template.New("op-row").Funcs(funcs).ParseFS(files, "templates/op-row.tmpl")391 if err != nil {392 return nil, nil, fmt.Errorf("workbench templates: %w", err)393 }394 return pages, rows, nil395}396397// pageNames are the pages, one template file each, named for the page.398var pageNames = []string{"library", "volume", "diff", "sources", "operations"}