clone

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

feat/atlas-next-horizon
atlas / internal / workbench / workbench.go
workbench.go 14.6K
1// Package workbench is a local authoring surface for one portable Atlas2// project manifest and the native volume it produces.3//4// The startup manifest is the authority. The workbench resolves its build5// plan, edits it atomically, runs one supervised native build, and hands the6// resulting .atlas file to the desktop application by click or drag.7//8// # What it is made of9//10// Existing library measurement remains available at the root. Authoring work11// is delegated to the atlas CLI through internal/workbench/oprunner, keeping12// the HTTP surface out of the compiler and capture implementations.13//14// # The pages15//16// GET / the library: every volume, headlined by its serving score17// GET /volume/{slug} measurement: the score, its breakdown, the axes, the ledger18// GET /volume/{slug}/diff two builds side by side, headlined by the score delta19// GET /project the resolved plan, latest run, artifact, and manifest20// POST /project/save validate and atomically replace the manifest21// POST /project/build begin the one native build operation22// GET /project/run replay the supervised run23// POST /project/open ask the desktop application to open the artifact24// GET /project/artifact stream the artifact for a native drag handoff25// GET /project/inspect inspect the validated native artifact26// GET /assets/{path...} the stylesheet, and the hypermedia runtime27//28// Every page is measurement first: a score is the headline and everything else29// is diagnostic, because the score is the only number anything gates on30// (issue #5 §5.3, docs/enrich.md).31//32// # Where the operating system is33//34// The handler is not held to the application's hostenv rule -- it is a35// developer's tool on a developer's machine, and it exists to run subprocesses36// -- but the OS still lives at the edges: library.go stats and opens files,37// oprunner starts processes, and everything between them works on values.38package workbench3940import (41 "bytes"42 "fmt"43 "html/template"44 "io"45 "log/slog"46 "net/http"47 "time"4849 "github.com/FelineStateMachine/atlas/format/vnext"50 "github.com/FelineStateMachine/atlas/internal/enrich/maturity"51 "github.com/FelineStateMachine/atlas/internal/logging"52 "github.com/FelineStateMachine/atlas/internal/workbench/oprunner"53)5455// Options is what a host tells the workbench about itself.56type Options struct {57 // Targets is where the workbench reads and where operations run. Its58 // Registry is the library every measurement page is about.59 Targets Targets60 // Sources is the capture source registry, as data (see sources.go).61 Sources []Source62 // Runtime is the vendored hypermedia runtime served at /assets/htmx.js.63 // It arrives as bytes from the wiring rather than by importing the64 // application, so one vendored copy serves both surfaces and neither65 // lane depends on the other. A nil runtime is a supported configuration:66 // every page here works as plain HTML, and operations still stream --67 // the browser renders the rows itself instead of a swap appending them.68 Runtime []byte69 // Table is the point table scores are read under. The zero value means70 // the embedded one, which is what everything but a test wants.71 Table maturity.Table72 // OpenArtifact performs the native handoff for a completed .atlas build.73 // The command host wires this to the operating system; tests may replace it.74 OpenArtifact func(string) error75}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.Runner85 supervisor *runSupervisor86 openArtifact func(string) error8788 pages map[string]*template.Template89 rows *template.Template90 mux *http.ServeMux91}9293// New wires a workbench.94func New(opts Options) (*Workbench, error) {95 table := opts.Table96 if table.Version == 0 {97 held, err := maturity.Points()98 if err != nil {99 return nil, err100 }101 table = held102 }103 pages, rows, err := parseTemplates()104 if err != nil {105 return nil, err106 }107 runner := &oprunner.Runner{}108 w := &Workbench{109 library: &library{dir: opts.Targets.Registry, table: table},110 sources: opts.Sources,111 targets: opts.Targets,112 runtime: opts.Runtime,113 runner: runner,114 supervisor: newRunSupervisor(runner),115 openArtifact: opts.OpenArtifact,116 pages: pages,117 rows: rows,118 mux: http.NewServeMux(),119 }120 w.routes()121 return w, nil122}123124// Close stops and waits for any build subprocess owned by the workbench.125func (w *Workbench) Close() {126 w.supervisor.Close()127}128129// ServeHTTP answers one request.130func (w *Workbench) ServeHTTP(rw http.ResponseWriter, r *http.Request) { w.mux.ServeHTTP(rw, r) }131132// routes is the whole URL surface, spelled in one place; nothing registers133// itself.134func (w *Workbench) routes() {135 w.mux.HandleFunc("GET /favicon.ico", func(rw http.ResponseWriter, _ *http.Request) { rw.WriteHeader(http.StatusNoContent) })136 w.mux.HandleFunc("GET /{$}", w.handleLibrary)137 w.mux.HandleFunc("GET /volume/{slug}", w.handleVolume)138 w.mux.HandleFunc("GET /volume/{slug}/diff", w.handleDiff)139 w.mux.HandleFunc("GET /project", w.handleProject)140 w.mux.HandleFunc("POST /project/us-area", w.handleProjectUSArea)141 w.mux.HandleFunc("POST /project/save", w.handleProjectSave)142 w.mux.HandleFunc("POST /project/build", w.handleProjectBuild)143 w.mux.HandleFunc("GET /project/run", w.handleProjectRun)144 w.mux.HandleFunc("POST /project/open", w.handleProjectOpen)145 w.mux.HandleFunc("GET /project/artifact", w.handleProjectArtifact)146 w.mux.HandleFunc("GET /project/inspect", w.handleProjectInspect)147 w.mux.HandleFunc("GET /assets/{path...}", w.handleAsset)148}149150// The content security policy every page carries, and it is the strict one:151// nothing loads from anywhere but this server, nothing but this server's own152// forms may be submitted to, and no page may be framed. A workbench that runs153// pipeline operations is exactly the page that must not be reachable through154// somebody else's document.155const contentSecurityPolicy = "default-src 'none'; style-src 'self'; script-src 'self'; " +156 "connect-src 'self'; img-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'"157158// render executes a page into a buffer first, so a template error becomes a159// clean 500 rather than half a page.160func (w *Workbench) render(rw http.ResponseWriter, page string, data any) {161 held, known := w.pages[page]162 if !known {163 http.Error(rw, "no such page: "+page, http.StatusInternalServerError)164 return165 }166 var body bytes.Buffer167 if err := held.ExecuteTemplate(&body, "layout", data); err != nil {168 slog.Error("rendering a workbench page", logging.Op("workbench"),169 slog.String("page", page), slog.Any("error", err))170 http.Error(rw, "rendering failed: "+err.Error(), http.StatusInternalServerError)171 return172 }173 rw.Header().Set("Content-Type", "text/html; charset=utf-8")174 rw.Header().Set("Content-Security-Policy", contentSecurityPolicy)175 // The library is read whole on every ask, so a page is only ever true of176 // the moment it was asked for.177 rw.Header().Set("Cache-Control", "no-store")178 rw.Write(body.Bytes())179}180181// libraryPage is the collection, every volume headlined by what its serving182// build is worth.183type libraryPage struct {184 Dir string185 Table int186 Volumes []*volume187 Skipped []string188}189190func (w *Workbench) handleLibrary(rw http.ResponseWriter, r *http.Request) {191 volumes, skipped, err := w.library.volumes()192 if err != nil {193 http.Error(rw, err.Error(), http.StatusInternalServerError)194 return195 }196 w.render(rw, "library", libraryPage{197 Dir: w.library.dir,198 Table: w.library.table.Version,199 Volumes: volumes,200 Skipped: skipped,201 })202}203204// volumePage is one volume measured: the serving build's score and how it moved,205// then every build in full -- its worlds, the five axes as diagnostics, and the206// whole of every ledger its payloads carry.207type volumePage struct {208 Volume *volume209 Table int210 Movement *maturity.Comparison211}212213func (w *Workbench) handleVolume(rw http.ResponseWriter, r *http.Request) {214 held, _, err := w.library.volumeBySlug(r.PathValue("slug"))215 if err != nil {216 http.Error(rw, err.Error(), http.StatusInternalServerError)217 return218 }219 if held == nil {220 http.NotFound(rw, r)221 return222 }223 w.render(rw, "volume", volumePage{224 Volume: held,225 Table: w.library.table.Version,226 Movement: held.Movement(),227 })228}229230// diffPage is two builds of one volume, headlined by the score delta.231type diffPage struct {232 Volume *volume233 Diff *buildDiff234}235236// handleDiff compares two builds of one volume, named by file. The packed237// features are read here, on demand, because only a comparison ever needs them238// whole.239func (w *Workbench) handleDiff(rw http.ResponseWriter, r *http.Request) {240 held, _, err := w.library.volumeBySlug(r.PathValue("slug"))241 if err != nil {242 http.Error(rw, err.Error(), http.StatusInternalServerError)243 return244 }245 if held == nil {246 http.NotFound(rw, r)247 return248 }249 from, to := held.Build(r.FormValue("a")), held.Build(r.FormValue("b"))250 if from == nil || to == nil {251 http.Error(rw, "name two builds of this volume", http.StatusBadRequest)252 return253 }254 featuresA, err := features(from)255 if err != nil {256 http.Error(rw, err.Error(), http.StatusInternalServerError)257 return258 }259 featuresB, err := features(to)260 if err != nil {261 http.Error(rw, err.Error(), http.StatusInternalServerError)262 return263 }264 w.render(rw, "diff", diffPage{Volume: held, Diff: diffBuilds(from, to, featuresA, featuresB)})265}266267// sourcesPage is the card wall.268type sourcesPage struct {269 Sources []Source270}271272func (w *Workbench) handleSources(rw http.ResponseWriter, r *http.Request) {273 w.render(rw, "sources", sourcesPage{Sources: w.sources})274}275276// operationsPage is the pipeline: what may be run, what each one needs, and277// what the workbench was pointed at.278type operationsPage struct {279 Targets Targets280 Sources []Source281 Operations []operationCard282 Busy string283}284285// operationCard is one operation with its readiness resolved, so the template286// renders a decision rather than making one.287type operationCard struct {288 operation289 Ready bool290 Missing string291}292293func (w *Workbench) handleOperations(rw http.ResponseWriter, r *http.Request) {294 cards := make([]operationCard, 0, len(operations))295 for _, held := range operations {296 ready, missing := held.Ready(w.targets)297 cards = append(cards, operationCard{operation: held, Ready: ready, Missing: missing})298 }299 crawlable := make([]Source, 0, len(w.sources))300 for _, source := range w.sources {301 if source.Crawlable {302 crawlable = append(crawlable, source)303 }304 }305 w.render(rw, "operations", operationsPage{306 Targets: w.targets,307 Sources: crawlable,308 Operations: cards,309 Busy: w.runner.Busy(),310 })311}312313// handleRun runs one operation and streams its voice back as rows.314//315// The safety properties are the runner's: a foreign Origin is refused, a second316// operation is refused, and the subprocess dies with the request. What is317// decided here is only what to run -- and every refusal of that is a 400 that318// starts nothing.319func (w *Workbench) handleRun(rw http.ResponseWriter, r *http.Request) {320 if err := r.ParseForm(); err != nil {321 http.Error(rw, err.Error(), http.StatusBadRequest)322 return323 }324 asked := request{325 Operation: r.FormValue("op"),326 Source: r.FormValue("source"),327 Target: r.FormValue("target"),328 Volume: r.FormValue("volume"),329 }330 if asked.Operation == "" {331 http.Error(rw, errNoOperation.Error(), http.StatusBadRequest)332 return333 }334 // The origin check happens before anything is planned as well as inside335 // the runner: a cross-site POST should not even be told which operations336 // exist.337 if err := oprunner.CheckOrigin(r); err != nil {338 http.Error(rw, err.Error(), http.StatusForbidden)339 return340 }341 op, err := plan(w.targets, w.sources, asked)342 if err != nil {343 http.Error(rw, err.Error(), http.StatusBadRequest)344 return345 }346 slog.Info("running an operation", logging.Op("workbench"),347 slog.String("operation", op.Name), slog.String("command", op.Command()))348 w.runner.Serve(rw, r, op, w.writeRow)349}350351// writeRow renders one row of an operation's output. The rows region swaps352// beforeend, so one row is one fragment and the page grows by a line at a time353// -- the same shape the application's import stream takes (issue #5 §4.3).354func (w *Workbench) writeRow(to io.Writer, row oprunner.Row) error {355 return w.rows.ExecuteTemplate(to, "op-row", row)356}357358// assetEpoch is the modification time every asset reports: the bytes are359// compiled in (or handed over at startup), so they change exactly when the360// binary does and never while it is running.361var assetEpoch = time.Unix(0, 0).UTC()362363// handleAsset serves the workbench's own chrome: the stylesheet, and the364// hypermedia runtime the wiring handed over.365func (w *Workbench) handleAsset(rw http.ResponseWriter, r *http.Request) {366 var body []byte367 var kind string368 switch r.PathValue("path") {369 case "workbench.css":370 body, kind = stylesheet(), "text/css; charset=utf-8"371 case "htmx.js":372 if len(w.runtime) == 0 {373 // A workbench mounted without a runtime is a working workbench;374 // saying so plainly is better than serving an empty script.375 http.NotFound(rw, r)376 return377 }378 body, kind = w.runtime, "text/javascript; charset=utf-8"379 case "workbench.js":380 body, kind = behavior(), "text/javascript; charset=utf-8"381 default:382 http.NotFound(rw, r)383 return384 }385 rw.Header().Set("Content-Type", kind)386 rw.Header().Set("Cache-Control", "no-cache")387 http.ServeContent(rw, r, r.PathValue("path"), assetEpoch, bytes.NewReader(body))388}389390// parseTemplates builds the page set. Each page is the layout plus its own391// file, in its own template namespace, so two pages may both define "content"392// and each stays one file long.393func parseTemplates() (map[string]*template.Template, *template.Template, error) {394 funcs := template.FuncMap{395 // The one display function templates get: a stamp is 64 characters396 // and a page wants the twelve that identify it (format/vnext).397 "short": vnext.ShortStamp,398 }399 pages := make(map[string]*template.Template, len(pageNames))400 for _, name := range pageNames {401 held, err := template.New("layout").Funcs(funcs).ParseFS(402 files, "templates/layout.tmpl", "templates/op-row.tmpl", "templates/"+name+".tmpl")403 if err != nil {404 return nil, nil, fmt.Errorf("workbench templates: %w", err)405 }406 pages[name] = held407 }408 rows, err := template.New("op-row").Funcs(funcs).ParseFS(files, "templates/op-row.tmpl")409 if err != nil {410 return nil, nil, fmt.Errorf("workbench templates: %w", err)411 }412 return pages, rows, nil413}414415// pageNames are the pages, one template file each, named for the page.416var pageNames = []string{"library", "volume", "diff", "project", "inspector"}