- compile portable manifests through cached evidence and semantic assembly - replace legacy workbench operations with project plan, build, and native handoff - support feature-only coordinate spaces, native file-open events, and semantic presentation
- commit
- 91c37e3e5625f656f077686570dbc3c13c6b0f56
- parent
- d8898999a400cde4bb8797999c065a1612ab952f
- author
- Dami <dami.etoile@gmail.com>
@@ -3,7 +3,7 @@
33 # These targets are the enforcement surface; the existing build recipes are44 # untouched and still live where they always did.55 6-6+77 88 # The one entrypoint: every required gate, and nothing that can silently99 # decline to judge. Go tests run through tools/testgate, which fails the run@@ -89,18 +89,6 @@ static: seam
8989 serve-static: static9090 go run ./cmd/atlas serve -static dist/static9191 92-# The included Earth volume: the pinned Blue Marble base map captured, derived93-# and composed into the committed bundle the desktop shell embeds94-# (included/README.md is the recipe). The crawl step is the one networked part95-# and fetches nothing when the pinned source is already in the staged archive;96-# everything after it is offline. The registry index compose derives beside the97-# bundle is deleted because included/ is an embedded asset, not a library.98-included-earth:99- go run ./cmd/atlas crawl -archive crawl/blue-marble/fmg-archive -source nasa-blue-marble earth100- go run ./cmd/atlas tiles -archive crawl/blue-marble/fmg-archive -output tiles/blue-marble101- go run ./cmd/atlas compose -archive crawl/blue-marble/fmg-archive -tiles tiles/blue-marble/index.json -bundles included earth102- rm -f included/index.json103-10492 # The desktop shell (issue #5 §3.4): the application in a window, one file,10593 # seam embedded. This is the macOS recipe -- the same `go build` .github/10694 # workflows/release.yml runs, plus the bundle directory macOS wants before it@@ -1,19 +1,17 @@
11 # Atlas22 3-Atlas is an offline map explorer built around a file format. A world — a game's4-map, a city's open data, Mars — travels as one self-contained `.atlas` bundle:5-its manifest, its features, its raster tile pyramids and its icons in a single6-zip archive. Drop a bundle into the library and the volume appears; drop in a7-newer build of the same volume and it takes over. No sidecars, no CDN, no8-runtime network of any kind, ever.3+Atlas is an offline map explorer built around a file format. A world travels as4+one self-contained `.atlas` volume: a schema-described semantic graph, packed5+typed features, raster pyramids, presentation and assets. Drop a volume into6+the library and it appears; drop in a newer build and it takes over. No7+sidecars, CDN, or runtime network is required.98 109 Two things ship from this repository:1110 12-- **`atlas`** — the pipeline and the server. `crawl` captures what a publisher13- serves, `tiles` derives pyramids, `compose` writes bundles, `enrich` folds14- several readings of a volume together, `measure` scores them, `workbench`15- serves the operator's pages, `serve` serves the application headlessly, and16- `dev` is the loop for working on it.11+- **`atlas`** — the authoring tool and server. `build` turns one portable12+ `.atlas-project` manifest directly into native vNext, `workbench` provides the13+ visual authoring/build surface, `measure` scores libraries, `serve` hosts the14+ application headlessly, and `dev` is the development loop.1715 - **Atlas** — the desktop application: the same application in a window.1816 1917 ```sh@@ -32,8 +30,8 @@ The library lives under the application's own data directory —
3230 `~/Library/Application Support/dev.felinestatemachine.atlas/bundles` on macOS,3331 `%AppData%\dev.felinestatemachine.atlas\bundles` on Windows,3432 `~/.config/dev.felinestatemachine.atlas/bundles` on Linux. `ATLAS_BUNDLES_DIR`35-points either the CLI or the desktop app somewhere else; `atlas compose36--bundles DIR` writes a registry elsewhere without touching the library at all.33+points either the CLI or desktop app somewhere else; `atlas build -bundles DIR`34+writes a library elsewhere without touching the application library.3735 3836 ## Released builds3937 @@ -54,10 +52,11 @@ format/ THE CENTRE. The .atlas schema, packed typed tables,
5452 content-addressed blobs, registry and semantic conventions.5553 Pure Go, standard library only, importable by anyone.5654 internal/57- generate/ Capture and composition: crawl, five sources, tiles, compose.58- enrich/ Merge, national layers, standard icons, lenses, maturity.55+ authoring/ Manifest, adapters, evidence cache, semantic assembly, publish.56+ generate/ Retired pipeline retained for fixture migration and tests.57+ enrich/ Legacy enrichment plus the current maturity measurement.5958 app/ The hypermedia application: one pure http.Handler, HTMX 4.60- workbench/ Scores, build diffs, source cards, pipeline operations.59+ workbench/ Scores, diffs, manifest authoring, plans, builds and handoff.6160 analysis/ TypeScript: the cell systems (geohash, S2) behind one contract.6261 render/ TypeScript: the rendering seam. Deletable, and deleted in the6362 sense that matters — nothing imports it, and the application@@ -75,8 +74,8 @@ main.go The desktop shell: ~300 lines of host wiring around the
7574 7675 [`docs/`](docs/) is the system, written down. Read in this order:7776 [`format.md`](docs/format.md) is the centre;78-[`generate.md`](docs/generate.md) and [`enrich.md`](docs/enrich.md) are how a79-bundle comes to be; [`app.md`](docs/app.md) is what serves it;77+[`authoring.md`](docs/authoring.md) is how a volume is built;78+[`app.md`](docs/app.md) is what serves it;8079 [`render-seam.md`](docs/render-seam.md) and8180 [`analysis.md`](docs/analysis.md) are what pictures it;8281 [`workbench.md`](docs/workbench.md) is the operator's view;@@ -0,0 +1,102 @@
1+package main2+3+import (4+ "context"5+ "encoding/json"6+ "errors"7+ "fmt"8+ "log/slog"9+ "os"10+ "path/filepath"11+12+ "github.com/FelineStateMachine/atlas/internal/authoring"13+ "github.com/FelineStateMachine/atlas/internal/logging"14+)15+16+func buildCommand() command {17+ return command{18+ name: "build",19+ summary: "plan and build one .atlas-project into a native Atlas volume",20+ run: runBuild,21+ }22+}23+24+func runBuild(args []string) error {25+ fs := flags("build", "[-cache DIR] [-bundles DIR] [-offline] [-plan-json] FILE.atlas-project")26+ cacheDir := fs.String("cache", "", "shared content-addressed source cache")27+ bundleDir := fs.String("bundles", "", "Atlas library to install the immutable build into")28+ offline := fs.Bool("offline", false, "use captured evidence only; perform no source requests")29+ planJSON := fs.Bool("plan-json", false, "print the resolved plan without fetching or writing")30+ var logOptions logging.Options31+ logOptions.Bind(fs)32+ if err := fs.Parse(args); err != nil {33+ return err34+ }35+ if fs.NArg() != 1 {36+ fs.Usage()37+ return errors.New("name exactly one .atlas-project manifest")38+ }39+ project := fs.Arg(0)40+ if *cacheDir == "" {41+ var err error42+ if *cacheDir, err = defaultAuthoringCacheDir(); err != nil {43+ return err44+ }45+ }46+ if *bundleDir == "" {47+ var err error48+ if *bundleDir, err = defaultRegistryDir(); err != nil {49+ return err50+ }51+ }52+ logger, err := logging.Setup(logOptions)53+ if err != nil {54+ return err55+ }56+ result, err := authoring.Build(context.Background(), authoring.BuildOptions{57+ ProjectPath: project, CacheDir: *cacheDir, LibraryDir: *bundleDir,58+ Offline: *offline, PlanOnly: *planJSON,59+ Event: func(event authoring.Event) {60+ attrs := []any{"stage", event.Stage}61+ if event.Current > 0 {62+ attrs = append(attrs, "current", event.Current, "total", event.Total)63+ }64+ if event.Bytes > 0 {65+ attrs = append(attrs, "bytes", event.Bytes)66+ }67+ if event.Cached {68+ attrs = append(attrs, "cached", true)69+ }70+ if event.Artifact != "" {71+ attrs = append(attrs, "artifact", event.Artifact)72+ }73+ logger.Info(event.Message, attrs...)74+ },75+ })76+ if err != nil {77+ return err78+ }79+ if *planJSON {80+ data, err := json.MarshalIndent(result.Plan, "", " ")81+ if err != nil {82+ return err83+ }84+ fmt.Println(string(data))85+ return nil86+ }87+ state := "installed"88+ if result.Present {89+ state = "already installed"90+ }91+ fmt.Printf("%s\t%s\t%s\n", result.Descriptor.Slug, filepath.Base(result.Path), state)92+ logger.Info("build finished", slog.String("volume", result.Descriptor.Slug), logging.Path(result.Path))93+ return nil94+}95+96+func defaultAuthoringCacheDir() (string, error) {97+ base, err := os.UserCacheDir()98+ if err != nil {99+ return "", fmt.Errorf("find the authoring cache: %w", err)100+ }101+ return filepath.Join(base, applicationID, "authoring"), nil102+}@@ -1,13 +1,9 @@
11 // Command atlas is the one command-line binary: every lane's operations under22 // one name, each as a subcommand.33 //4-// atlas compose build a volume from archived captures and derived tiles5-// atlas crawl fetch what a publisher serves into the capture archive6-// atlas tiles derive raster pyramids from archived captures7-// atlas enrich fold every reading of a volume together and build it4+// atlas build FILE.atlas-project plan, capture and compile one native volume85 // atlas measure score every build in a registry9-// atlas translate read archived captures and print the interchange document10-// atlas workbench serve the workbench: scores, diffs, sources, operations6+// atlas workbench FILE.atlas-project serve the library and authoring project117 // atlas serve -bundles DIR # the headless application host128 // atlas dev -bundles DIR # the same, reading its chrome from the working copy139 //@@ -45,14 +41,10 @@ type command struct {
4541 // before main.4642 func commands() []command {4743 return []command{48- composeCommand(),49- crawlCommand(),44+ buildCommand(),5045 devCommand(),51- enrichCommand(),5246 measureCommand(),5347 serveCommand(),54- tilesCommand(),55- translateCommand(),5648 workbenchCommand(),5749 }5850 }@@ -29,7 +29,7 @@ func TestCommandTable(t *testing.T) {
2929 if len(seen) == 0 {3030 t.Fatal("the binary does nothing")3131 }32- for _, name := range []string{"compose", "serve", "translate"} {32+ for _, name := range []string{"build", "serve", "workbench"} {3333 if !seen[name] {3434 t.Errorf("the table does not carry %s", name)3535 }@@ -64,8 +64,7 @@ func TestSubcommandsRefuseMissingInputs(t *testing.T) {
6464 run func([]string) error6565 want string6666 }{67- {"compose", runCompose, "-archive and -tiles"},68- {"translate", runTranslate, "-archive is required"},67+ {"build", runBuild, "one .atlas-project"},6968 }7069 for _, tt := range tests {7170 t.Run(tt.name, func(t *testing.T) {@@ -8,16 +8,13 @@ import (
88 "net"99 "net/http"1010 "os"11+ "os/exec"1112 "os/signal"1213 "path/filepath"13- "strings"1414 "syscall"1515 "time"1616 1717 "github.com/FelineStateMachine/atlas/internal/app/assets"18- "github.com/FelineStateMachine/atlas/internal/generate/crawl"19- "github.com/FelineStateMachine/atlas/internal/generate/sources"20- "github.com/FelineStateMachine/atlas/internal/generate/tiles"2118 "github.com/FelineStateMachine/atlas/internal/logging"2219 "github.com/FelineStateMachine/atlas/internal/workbench"2320 )@@ -45,20 +42,17 @@ import (
4542 func workbenchCommand() command {4643 return command{4744 name: "workbench",48- summary: "serve the workbench: scores, build diffs, sources and pipeline operations",45+ summary: "author, plan and build one .atlas-project beside the Atlas library",4946 run: runWorkbench,5047 }5148 }5249 5350 func runWorkbench(args []string) error {54- fs := flags("workbench", "[-addr HOST:PORT] [-bundles DIR] [-archive DIR] [-tiles DIR]")51+ fs := flags("workbench", "[-addr HOST:PORT] [-bundles DIR] [-cache DIR] FILE.atlas-project")5552 addr := fs.String("addr", "127.0.0.1:6180", "address the workbench listens on")5653 bundleDir := fs.String("bundles", "",5754 "registry of .atlas files to measure; default is the application's own library")58- archiveDir := fs.String("archive", "",59- "capture archive root operations read and write (operations that need one are refused without it)")60- tileSet := fs.String("tiles", "",61- "derived tile set directory operations write and read")55+ cacheDir := fs.String("cache", "", "shared content-addressed source cache")6256 var log logging.Options6357 log.Bind(fs)6458 if err := fs.Parse(args); err != nil {@@ -67,14 +61,27 @@ func runWorkbench(args []string) error {
6761 if _, err := logging.Setup(log); err != nil {6862 return err6963 }64+ if fs.NArg() != 1 {65+ fs.Usage()66+ return errors.New("name exactly one .atlas-project manifest")67+ }68+ project, err := filepath.Abs(fs.Arg(0))69+ if err != nil {70+ return fmt.Errorf("resolve project: %w", err)71+ }7072 7173 registry := *bundleDir72- var err error7374 if registry == "" {7475 if registry, err = defaultRegistryDir(); err != nil {7576 return err7677 }7778 }79+ cache := *cacheDir80+ if cache == "" {81+ if cache, err = defaultAuthoringCacheDir(); err != nil {82+ return err83+ }84+ }7885 binary, err := os.Executable()7986 if err != nil {8087 // A workbench that cannot name its own binary still measures; it just@@ -86,21 +93,25 @@ func runWorkbench(args []string) error {
8693 targets := workbench.Targets{8794 Atlas: binary,8895 Registry: registry,89- Archive: *archiveDir,90- TileSet: *tileSet,91- }92- if *tileSet != "" {93- targets.TileIndex = filepath.Join(*tileSet, tiles.IndexName)96+ Cache: cache,97+ Project: project,98+ Dir: filepath.Dir(project),9499 }95100 96101 handler, err := workbench.New(workbench.Options{97102 Targets: targets,98- Sources: sourceCards(),99103 Runtime: assets.Runtime(),104+ OpenArtifact: func(path string) error {105+ if err := exec.Command("open", "-b", applicationID, path).Run(); err != nil {106+ return fmt.Errorf("open in Atlas: %w", err)107+ }108+ return nil109+ },100110 })101111 if err != nil {102112 return err103113 }114+ defer handler.Close()104115 105116 listener, err := net.Listen("tcp", *addr)106117 if err != nil {@@ -135,34 +146,3 @@ func runWorkbench(args []string) error {
135146 slog.Info("workbench stopped", logging.Op("workbench"))136147 return nil137148 }138-139-// sourceCards is the generate lane's source registry, as the workbench's cards.140-//141-// Every fact is the lane's own: the identity and the terms come from the142-// source's Describe(), and whether a fetch may be offered at all comes from143-// whether a crawler is registered under the same name. Nothing is restated144-// here, so a card cannot disagree with the documents its source emits.145-func sourceCards() []workbench.Source {146- cards := make([]workbench.Source, 0, len(sources.All()))147- for _, source := range sources.All() {148- about := source.Describe()149- card := workbench.Source{150- Name: about.Name,151- Label: about.Label,152- License: about.License,153- Attribution: about.Attribution,154- IDSpace: about.IDSpace,155- }156- if crawler, err := crawl.For(about.Name); err == nil {157- card.Crawlable = true158- card.TargetHint = crawler.Usage()159- // A crawler's usage line shows the shape of its target, and a160- // target addressed as two slugs shows it as a slash. That is the161- // one thing target validation has to be told, and the crawler is162- // the only thing that knows it.163- card.Pair = strings.Contains(crawler.Usage(), "/")164- }165- cards = append(cards, card)166- }167- return cards168-}@@ -8,8 +8,8 @@ the behavioral differences accepted against the archived reference are
88 [decision 18](decisions/0018-divergences-from-the-reference.md).99 1010 Read in this order to learn the system: [`format.md`](format.md) is the centre,11-[`generate.md`](generate.md) and [`enrich.md`](enrich.md) are how a bundle12-comes to be, [`app.md`](app.md) is what serves it,11+[`authoring.md`](authoring.md) is how a volume comes to be,12+[`app.md`](app.md) is what serves it,1313 [`render-seam.md`](render-seam.md) and [`analysis.md`](analysis.md) are what1414 pictures it, and [`decisions/`](decisions/) is why any of it is shaped this way.1515 @@ -19,9 +19,10 @@ pictures it, and [`decisions/`](decisions/) is why any of it is shaped this way.
1919 |---|---|2020 | [`format.md`](format.md) | Normative native schema-bundle spec: semantic graph, typed blocks, compatibility, release ordering, validation, and install. |2121 | [`format-v3.md`](format-v3.md) | Historical specification for the retired document-shaped format; current readers do not install it. |22+| [`authoring.md`](authoring.md) | The single-manifest authoring contract: plan, capture, observe, assemble, compile, publish, and workbench handoff. |2223 | [`semconv/REGISTRY.md`](semconv/REGISTRY.md) | The attribute vocabulary, generated from `spec/registry.yaml`; `spec/gen`'s own test holds the committed copy up to date. |23-| [`generate.md`](generate.md) | The generate lane: the interchange document, the sources and their gates, the capture archive, tile derivation, composition, curation. §2.8 is the "add a source" walkthrough. |24-| [`enrich.md`](enrich.md) | The enrich lane: the enricher interface, the contribution format, the ledger vocabulary, the curated queue, the maturity score. |24+| [`generate.md`](generate.md) | Historical generate pipeline retained for fixture migration and corpus tests. |25+| [`enrich.md`](enrich.md) | Historical enrichment pipeline plus the current maturity score. |2526 | [`analysis.md`](analysis.md) | The cell-system contract: the eighteen methods, coordinates and continuity, the `Ground` descriptor, the plan order, the style tokens. §9 is the "add a third system" checklist. |2627 | [`app.md`](app.md) | The hypermedia application: routes, the session record, regions and partials, the state island, the hostenv contract and the three-host story. |2728 | [`render-seam.md`](render-seam.md) | The brief for rewriting `render/` blind: the scene description, the standing set, the chart, the globe, the diagnostics duty. §10.1 names what the document does not carry. |@@ -0,0 +1,77 @@
1+# Atlas authoring2+3+Atlas creation has one public input and one public operation:4+5+```text6+sample-region.atlas-project7+ │8+ ▼9+atlas build ── plan ── capture ── observe ── assemble ── compile ── install10+ │ shared content cache │11+ └────────────────────────────────────────────────────────┘12+ immutable native .atlas13+```14+15+The manifest is configuration, not a project directory. Source bytes are held16+once in a shared content-addressed cache; successful outputs are held once in17+the Atlas library. Moving the manifest preserves request identity because18+remote locators remain authored and local locators resolve relative to the19+manifest.20+21+## Commands22+23+```sh24+# Inspect the exact source requests and output without fetching or writing.25+go run ./cmd/atlas build -plan-json examples/sample-region.atlas-project26+27+# Build and atomically install a native vNext volume.28+go run ./cmd/atlas build examples/sample-region.atlas-project29+30+# Rebuild only from already captured evidence.31+go run ./cmd/atlas build -offline examples/sample-region.atlas-project32+33+# Edit, inspect, build, and hand the result to Atlas from the workbench.34+go run ./cmd/atlas workbench examples/sample-region.atlas-project35+```36+37+`-cache` selects the evidence cache and `-bundles` selects the Atlas library.38+Their defaults are the application-owned cache and library.39+40+## Contract41+42+- The YAML decoder is strict. Unknown keys, duplicate identities, incomplete43+ mappings, broken presentation references, and invalid raster plans fail.44+- Adapters describe acquisition; declarative mappings describe meaning.45+ Built-ins are `geojson`, `arcgis-feature-service`, `ogc-api-features`,46+ `raster-file`, and explicit-window `xyz`.47+- Planning performs no network or filesystem writes. Every request has a stable48+ identity and reports cache presence, estimated bytes, and obligations.49+- Capture envelopes retain request identity, body hash, media type, timestamp,50+ licence, and attribution. Changed bodies append history instead of replacing51+ evidence.52+- Observations retain source-native identity and evidence. Assembly merges only53+ canonical identities declared by the manifest; earlier sources win conflicts54+ and every contributor remains in provenance.55+- Presentation is compiled directly into native styles and layers. There is no56+ legacy JSON document or enrichment projection between authored intent and the57+ packed schema.58+- Publishing stages, syncs, reopens, validates, and atomically renames the59+ content-versioned `.atlas`. Rebuilding the same project from the same evidence60+ produces the same bytes and returns the existing file.61+62+## Workbench63+64+The workbench receives one manifest path at startup. Browser requests cannot65+choose another file, cache, command, or output directory.66+67+The Project page shows the resolved build graph, cache readiness, estimated68+input, licence/attribution obligations, manifest editor, and the build log. A69+build belongs to the workbench process rather than the POST request, so reloads70+do not cancel it. When it completes, **Open in Atlas** performs a native file71+handoff; the artifact card also supports dragging the `.atlas` onto an Atlas72+window.73+74+The former `crawl`, `tiles`, `compose`, `enrich`, and `translate` commands and75+the Sources/Operations workbench pages are no longer public interfaces. Their76+code remains only as migration and corpus-test material while existing fixtures77+are retired.@@ -1,5 +1,10 @@
11 # The enrich lane22 3+> **Historical creation path.** Native authoring now observes and assembles4+> configured sources directly; see [authoring.md](authoring.md). The maturity5+> measurement remains in use, while legacy enrichment is retained for fixture6+> migration and regression coverage.7+38 Enrich makes volumes richer. It takes a volume the [generate lane](generate.md)49 composed — or the several readings of one volume that several sources produced —510 and writes a **new build of the same volume**: same slug, new stamp, more in it.@@ -1,5 +1,10 @@
11 # The generate lane22 3+> **Historical creation path.** The public authoring system is now the4+> single-manifest native vNext compiler in [authoring.md](authoring.md). These5+> packages remain for fixture migration and regression coverage; their CLI6+> commands are not registered.7+38 Generate makes volumes. It takes a capture archive — bytes somebody else49 published, fetched once and kept — and writes a `.atlas` file: one volume,510 complete, offline, deterministically named.@@ -1,234 +1,83 @@
11 # The workbench22 3-**Status: built.** This document specifies the workbench: the pages it4-serves, where their facts come from, the operation-runner library, and the5-safety properties operations are held to.3+The workbench is the visual surface beside the Atlas reader. It measures the4+installed library and authors exactly one `.atlas-project` selected when the5+server starts.66 7-The implementation is `internal/workbench`, the runner is8-`internal/workbench/oprunner`, and the host is `atlas workbench`9-(`cmd/atlas/workbench.go`). Where this document and the code disagree, take it10-as a defect in one of them and say so.7+```sh8+atlas workbench [-addr 127.0.0.1:6180] [-bundles DIR] [-cache DIR] \9+ examples/sample-region.atlas-project10+```1111 12-The [application](app.md) serves the build of every volume a reader should13-have and asks no questions. The workbench answers the questions: what each build14-is worth, what moved between two of them, what the collection owes the people15-whose work it carries, and what the pipeline should do next.12+The URL is printed on stdout. `-bundles` defaults to the Atlas library and13+`-cache` defaults to the shared authoring cache.1614 17----15+## Surface1816 19-## 1. Running it17+| Route | Purpose |18+|---|---|19+| `GET /` | Installed volumes and serving maturity. |20+| `GET /volume/{slug}` | One volume's builds, score, semantic contents and provenance. |21+| `GET /volume/{slug}/diff` | Build comparison. |22+| `GET /project` | The fixed manifest, resolved plan, build controls, run history and artifact handoff. |23+| `POST /project/save` | Strictly validate a candidate, then atomically replace the fixed manifest. |24+| `POST /project/build` | Start the one native build operation. |25+| `GET /project/run` | Replay the latest build rows for live polling or a page reload. |26+| `POST /project/open` | Hand the completed file to the operating system to open in Atlas. |27+| `GET /project/artifact` | Drag-out/download body for the completed file. |2028 21-```22-atlas workbench [-addr 127.0.0.1:6180] [-bundles DIR] [-archive DIR] [-tiles DIR]29+The old `/sources`, `/operations`, and `/operations/run` routes are gone. Source30+selection, protocol settings, semantic mapping and presentation all live in the31+manifest; five partially overlapping commands no longer define creation.32+33+## Authority and safety34+35+The command host supplies the absolute manifest, cache, binary and library36+paths. No browser field can change them. All state-changing requests are37+same-origin checked. Manifest candidates are size-limited, staged beside the38+real file, decoded with unknown-field rejection, fully validated, synced and39+renamed only after success.40+41+One build runs at a time. Its argv is always:42+43+```text44+atlas build --log-json -cache CACHE -bundles LIBRARY [-offline] MANIFEST2345 ```2446 25-| Flag | What it is | Without it |26-|---|---|---|27-| `-addr` | Where the workbench listens. | `127.0.0.1:6180`. |28-| `-bundles` | The registry of `.atlas` files every measurement page is about. | The application's own library. |29-| `-archive` | The capture archive root operations read and write. | Crawl, tiles, compose and enrich say they cannot run. |30-| `-tiles` | The derived tile set directory. | Tiles, compose and enrich say they cannot run. |31-32-The listening URL is printed on stdout as product output; the event stream goes33-to stderr like every other subcommand ([logging](logging.md)).34-35-Reading the library is free and happens on every page load. **Nothing runs on a36-schedule or at startup.** An operation exists only between a submitted form and37-its subprocess's exit.38-39-## 2. The pages40-41-| Route | Page | What it says |42-|---|---|---|43-| `GET /` | Library | Every volume, headlined by its serving build's score, with the movement against the build before it. Bundles that will not measure are listed as warnings rather than failing the page. |44-| `GET /volume/{slug}` | Measurement | The serving score and how it moved; then every build in full — per-world score breakdown, the five absolute axes as diagnostics, and the whole of every ledger. |45-| `GET /volume/{slug}/diff?a=&b=` | Diff | Two builds side by side, headlined by the score delta and its verdict; per-world deltas, axis deltas, the features added and removed, and matched-pair stability. |46-| `GET /sources` | Sources | One card per capture source: licence, attribution, id space, and whether it is crawled from here. |47-| `GET /operations` | Operations | What may be run, what each operation needs, and what this workbench was pointed at. |48-| `POST /operations/run` | — | One operation, streamed back as HTML rows. |49-| `GET /assets/{path...}` | — | `workbench.css`, and `htmx.js` when a host handed a runtime over. |50-51-**Measurement first.** Every page leads with the score, because the score is the52-only number anything gates on ([enrich](enrich.md), the monotonicity gate). The53-five axes — annotation, cartography, structure,54-icons, conventions — are printed under it as diagnostics and are labelled as55-such on the page. Percentages survive there and nowhere else.56-57-### The score and its movement58-59-A volume's movement is `maturity.Compare(previous, serving)`: the same60-comparison the build gate reads, printed rather than enforced. Three things61-come with it and are all rendered:62-63-- **Comparability.** Two builds scored under different point-table versions64- conclude nothing, and the page says so instead of showing a delta.65-- **The allowance.** A decline is permitted up to what the later build's ledger66- accounts for in corrections; the reasons are printed.67-- **The verdict** on a diff — *richer*, *corrected*, *poorer*, *unmoved*, or68- *not comparable* — is that arithmetic in one word.69-70-### Where the facts come from71-72-Two sources, and neither is a copy.73-74-**Scores** are `internal/enrich/maturity` reading the registry directory75-directly. The directory is re-read on every request — a build installed by an76-operation is on the page at the next load, with no watcher and no cache to77-invalidate by hand — and scoring is memoised per file by size and modification78-time, the same test the format's registry uses to tell an untouched file from79-one rewritten under its name.80-81-**Source registry entries** arrive as **data, handed to the handler at82-construction** by `atlas workbench`. The workbench may not import the generate83-lane (issue #5 §3.2), and `cmd/` is the one place allowed to wire every lane, so84-the wiring reads each source's own `Describe()` and the crawl registry's own85-`Usage()` and passes the result to `workbench.New`. Two alternatives were86-weighed and refused:87-88-- an `atlas sources -json` subcommand the workbench shells out for — it puts a89- second copy of every licence on a wire, and adds a subprocess between a page90- and a fact that is already in the binary;91-- a curation data file restating the entries — a licence that exists twice can92- be wrong in one of them.93-94-Wiring passes the registry entry itself, once. A source added to the generate95-lane appears on the card wall with no second edit anywhere.96-97-## 3. Operations98-99-An operation is the `atlas` binary invoked exactly as a person at a terminal100-would invoke it. The workbench does not link a pipeline lane: it **shells out to101-the lane CLIs** (issue #5 §3.1), which is what keeps a lane's work inside its102-lane while the page that starts it imports nothing but the format and the score.103-104-The binary is this process's own executable (`os.Executable`), so an operation105-runs the same build of the pipeline that is serving the page. No repository106-checkout is involved.107-108-| Operation | Needs | argv |109-|---|---|---|110-| `crawl` | archive | `atlas crawl --log-json -archive A -source NAME TARGET` |111-| `tiles` | archive, tile set | `atlas tiles --log-json -archive A -output T` |112-| `compose` | archive, tile index, registry | `atlas compose --log-json -archive A -tiles T/index.json -bundles R [volume]` |113-| `enrich` | archive, tile index, registry | `atlas enrich --log-json -archive A -tiles T/index.json -bundles R [volume]` |114-| `measure` | registry | `atlas measure --log-json -bundles R [volume]` |115-116-An operation whose targets are not all configured is not offered: the card says117-what is missing instead of letting a person find out by pressing a button.118-119-Three request-borne values exist, and each is validated before an argv does:120-the operation name (a table lookup), the source name (a registry lookup, plus a121-crawler must exist for it), and the target and volume slug (`ValidTarget`,122-below). Nothing composes a shell command; argv is a slice and the operating123-system gets it as one.124-125-## 4. The operation runner126-127-`internal/workbench/oprunner` is a small library the workbench consumes. It is128-not workbench-internal code (issue #5 §5.6): the safety properties are the129-interesting part, they are testable without a page around them, and a second130-consumer of the pipeline gets them by importing rather than by copying.131-132-```go133-type Operation struct { Name, Dir string; Argv []string }134-func (o Operation) Validate() error135-func (o Operation) Command() string136-137-type Row struct {138- Seq int // 1, 2, 3… in arrival order across both streams139- Kind Kind // command | event | output | result140- Stream string // stdout | stderr, for the rows the subprocess spoke141- Level string // INFO, WARN… on a parsed record142- Time string // the record's own timestamp, verbatim143- Message string144- Attrs []Attr // documented vocabulary first, then the rest, alphabetically145- Failed bool // an ERROR record, or a result that did not succeed146-}147-148-type Runner struct{ /* the one operation slot; zero value ready */ }149-func (r *Runner) Acquire(name string) (release func(), ok bool)150-func (r *Runner) Busy() string151-func (r *Runner) Run(ctx context.Context, op Operation, emit func(Row) error) error152-func (r *Runner) Serve(w http.ResponseWriter, req *http.Request, op Operation, render RowWriter)153-func Stream(ctx context.Context, op Operation, emit func(Row) error) error154-155-func CheckOrigin(r *http.Request) error156-func ValidTarget(target string, pair bool) error47+Arguments are passed directly to the operating system without a shell. The48+subprocess belongs to an in-memory supervisor, not the initiating HTTP request.49+Closing or reloading the page therefore does not cancel it; the next page load50+replays every retained row. A failed or successful result remains visible until51+the next build.52+53+The native-open action accepts only a `.atlas` path reported by the build and54+contained by the configured library. The drag card publishes the same artifact55+through browser drag data (`DownloadURL`, URI and path); clicking the card never56+navigates the workbench webview to the file. **Open in Atlas** is an explicit57+native handoff.58+59+## Page model60+61+The Project page is a compact projection of the compiler:62+63+```text64+feature adapters ─┐65+ ├─ exact requests ─ evidence cache ─ observations ─┐66+raster adapters ──┘ │67+ ├─ native volume68+typed mappings ───────── semantic feature sets + relationships ──────┤69+presentation ─────────── styles + ordered layers + labels ────────────┘15770 ```15871 159-**Rows are data, not markup.** `atlas` writes slog records to stderr and JSON160-when asked, so the runner parses each line into a `Row` carrying level, message161-and the documented attribute vocabulary of [logging](logging.md); a line that is162-not a record is carried verbatim, because a tool's own words are worth more than163-a runner's opinion of them. Rendering a row as HTML is the consumer's business,164-which is what keeps the framework vocabulary in templates (issue #5 §4.3). The165-workbench's `RowWriter` is one template, `op-row.tmpl`.166-167-Every run reads the same way: one **command** row, then the subprocess's own168-**event** and **output** rows in arrival order, then exactly one **result** row.169-A program that will not start, a program that exits non-zero, and a program that170-succeeds all end in a result row — the difference is `Failed`.171-172-### The safety properties173-174-Named because they are the contract:175-176-1. **Origin-checked POSTs.** A browser sends `Origin` on any cross-site POST and177- omits it on an ordinary same-origin form submission. A present one must agree178- with the host the request arrived at, scheme included; a foreign one is179- **403** and nothing is planned, let alone run.180-2. **One operation at a time.** The slot is a mutex; a second submission is181- **409** with the name of what is running, never a queue. Both refusals are182- decided before the first row goes out, because once a body is streaming the183- status line is spent.184-3. **Target validation.** `ValidTarget` admits lowercase letters, digits, `-`,185- `_`, `.`, and — for a source addressed as two slugs — exactly one interior186- slash. A leading dash is refused so a target can never be read as a flag. A187- bad target is **400**.188-4. **An operation dies with its request.** The subprocess runs under the189- request's context: a page abandoned mid-operation stops its operation, so190- nothing crawls on with nobody watching. An emit that fails — the page went191- away — ends the run the same way.192-5. **A strict content security policy** on every page:193- `default-src 'none'; style-src 'self'; script-src 'self'; img-src 'self';194- form-action 'self'; base-uri 'none'; frame-ancestors 'none'`. Nothing loads195- from anywhere but this server, nothing but this server's forms may be196- submitted to, and no page may be framed — a page that operates a pipeline is197- exactly the page that must not be reachable through somebody else's document.198-6. **Pages are rendered into a buffer first**, so a template error is a clean199- 500 rather than half a page.200-201-## 5. Hypermedia202-203-The workbench is pure HTMX and has no seam. Real URLs for every view, ordinary204-links and forms, and **one** swap: the operations console, which grows a row at205-a time (`hx-post` + `hx-target="#op-log"` + `hx-swap="beforeend"`) — the206-streaming swap of issue #5 §4.3, the same shape the application's import stream207-takes. No `hx-on`; the `hx-*` vocabulary lives in `templates/` and nowhere else.208-209-The runtime is **not** vendored here. htmx is vendored once, with the210-application's assets, and reaches this handler as bytes through211-`Options.Runtime`: one vendored copy, one licence file, and no import edge212-between two lanes that must not depend on each other. `atlas workbench` — which213-may import both — hands it over.214-215-A workbench mounted without a runtime is a working workbench. Every page is216-plain HTML, every form is an ordinary POST, and an operation still streams: the217-browser renders the rows as they arrive instead of a swap appending them. The218-stylesheet is the identity's token system (carried verbatim from the219-application's assets) plus one file of this surface's own.220-221----222-223-## 6. Scope224-225-The workbench is the pipeline dashboard's whole surface: measurement-first226-pages over a registry, source cards fed by the lanes' own registry entries,227-and pipeline operations behind the safety properties of §4. Its archived228-predecessor is `cmd/cartograph` on the `golden-reference` tag.229-230-What carries the surface: the score and its movement on every page, the231-build gate's own comparison rendered rather than reimplemented, the whole232-ledger instead of its counts, the enrich lane's operations beside the generate233-lane's, structured rows instead of a text pane, and a runner whose safety234-properties are a tested library rather than four paragraphs of handler.72+It shows request cache state, estimated bytes, the project digest, source73+adapters, obligations, semantic-set/layer counts, and output library before any74+build begins. The manifest editor is deliberately the durable configuration75+rather than a second form model that could drift from it.76+77+## Boundaries78+79+`internal/workbench` imports the native authoring planner for read-only plan80+projection and shells out to the same `atlas` executable for builds. The81+library/diff pages continue to read installed native volumes. The host injects82+the hypermedia runtime and native-open function. OS process execution stays in83+the operation runner; native application choice stays in the command host.@@ -0,0 +1,56 @@
1+schema: atlas-project/v12+schema-namespace: example.invalid/atlas/sample-region/v13+id: sample-region4+title: Sample Region5+6+target:7+ world: sample-region8+ title: Sample Region9+ coordinate-space:10+ id: sample-grid11+ kind: projected12+ unit: metre13+ definition: local sample grid14+ extent: [0, 0, 100, 100]15+16+sources:17+ - id: sample-features18+ adapter: geojson19+ locator: sample-region.geojson20+ media-type: application/geo+json21+ license: Example data22+ attribution: Sample Region contributors23+ estimate-bytes: 102424+ mapping:25+ feature-set: places26+ title: Sample Places27+ semantic-type: place28+ identity: id29+ feature-title: properties.name30+ fields:31+ - id: kind32+ name: Kind33+ source: properties.kind34+ type: string35+36+presentation:37+ id: default38+ title: Default39+ styles:40+ - id: place41+ symbol: circle42+ render-as: geometry43+ stroke: "#d9e7f5"44+ fill: "#5ba4d7"45+ layers:46+ - id: places47+ feature-set: places48+ style: place49+ label: Sample Places50+ group: Sample Region51+ label-policy: automatic52+ visible: true53+ order: 154+55+release:56+ revision: 1@@ -0,0 +1,31 @@
1+{2+ "type": "FeatureCollection",3+ "features": [4+ {5+ "type": "Feature",6+ "id": "marker-1",7+ "properties": {8+ "id": "marker-1",9+ "name": "Sample Marker",10+ "kind": "landmark"11+ },12+ "geometry": {13+ "type": "Point",14+ "coordinates": [40, 60]15+ }16+ },17+ {18+ "type": "Feature",19+ "id": "marker-2",20+ "properties": {21+ "id": "marker-2",22+ "name": "Sample Lookout",23+ "kind": "landmark"24+ },25+ "geometry": {26+ "type": "Point",27+ "coordinates": [75, 35]28+ }29+ }30+ ]31+}@@ -4,7 +4,6 @@ import (
44 "html/template"55 "regexp"66 "sort"7- "strconv"87 "strings"98 109 "github.com/FelineStateMachine/atlas/format/semconv"@@ -460,8 +459,7 @@ func (a *App) detail(_ hostenv.Volume, model *worldModel, session Session, id st
460459 out.Color = collectionColor(pin.Collection)461460 out.Glyph = initials(pin.Collection.Title)462461 out.Icon = pin.Collection.Icon463- out.Coordinates = strconv.FormatFloat(pin.Lat, 'f', 6, 64) + ", " +464- strconv.FormatFloat(pin.Lng, 'f', 6, 64)462+ out.Coordinates = pin.Coordinates465463 // Which is what earns this card the empty cell row: a place a point466464 // stands is a place the cell systems can name.467465 out.Point = true@@ -40,6 +40,21 @@ func TestVNextEarthBuildsTheRendererDirectly(t *testing.T) {
4040 }4141 }4242 43+func TestNativeCoordinateLabelsRespectTheDeclaredSpace(t *testing.T) {44+ t.Parallel()45+46+ space := vnext.CoordinateSpace{Kind: "projected", Unit: "metre", Extent: [4]float64{0, 0, 100, 100}}47+ if got := coordinateLabel(space, tileGrid{}, vnext.Position{40, 60}); got != "40.000000, 60.000000 metre" {48+ t.Fatalf("projected coordinate label = %q", got)49+ }50+51+ legacy := tileGrid{SourceZoom: 5, FirstTile: 10, TileSize: 256, Size: 8192}52+ got := coordinateLabel(vnext.CoordinateSpace{Kind: "projected", Unit: "world-pixel"}, legacy, vnext.Position{4096, 4096})53+ if strings.Contains(got, "world-pixel") || strings.Contains(got, "Inf") {54+ t.Fatalf("legacy tile-plane label = %q", got)55+ }56+}57+4358 func nativeCollection(model *worldModel, title string) *collectionModel {4459 for _, collection := range model.Members {4560 if collection.Title == title {@@ -68,7 +68,7 @@ func buildVNextWorld(world vnext.World, assets []vnext.Asset) (*worldModel, erro
6868 space := world.CoordinateSpace6969 grid := tileGrid{SourceZoom: int(space.SourceZoom), FirstTile: int(space.FirstTile), TileSize: int(space.TileSize), Size: int(space.Size)}7070 model := &worldModel{71- Slug: world.ID, Lenses: nativeRasters(world.RasterPyramids), Attrs: propertiesToAttrs(world.Claims),71+ Slug: world.ID, Space: space, Lenses: nativeRasters(world.RasterPyramids), Attrs: propertiesToAttrs(world.Claims),7272 Origin: worldOrigin(world), Grid: grid,7373 ByID: map[string]*collectionModel{}, PointByID: map[string]*pointModel{}, ShapeByID: map[string]*shapeModel{},7474 }@@ -124,15 +124,20 @@ func buildVNextWorld(world vnext.World, assets []vnext.Asset) (*worldModel, erro
124124 return model, nil125125 }126126 127-func addNativeFeature(model *worldModel, collection *collectionModel, feature *vnext.Feature, grid tileGrid) error {127+func addNativeFeature(128+ model *worldModel,129+ collection *collectionModel,130+ feature *vnext.Feature,131+ grid tileGrid,132+) error {128133 if feature.Geometry.Kind == vnext.GeometryPoint {129134 if len(feature.Geometry.Parts) != 1 || len(feature.Geometry.Parts[0].Rings) != 1 || len(feature.Geometry.Parts[0].Rings[0]) != 1 {130135 return fmt.Errorf("point %s has an invalid geometry", feature.ID)131136 }132137 position := feature.Geometry.Parts[0].Rings[0][0]133- lat, lng := grid.unproject(position[0], position[1])134138 pin := &pointModel{135- ID: feature.ID, Title: feature.Title, Lat: lat, Lng: lng, X: position[0], Y: -position[1],139+ ID: feature.ID, Title: feature.Title, Coordinates: coordinateLabel(model.Space, grid, position),140+ X: position[0], Y: -position[1],136141 Shard: feature.Shard, Feature: feature, Collection: collection,137142 }138143 model.Points = append(model.Points, pin)@@ -151,6 +156,24 @@ func addNativeFeature(model *worldModel, collection *collectionModel, feature *v
151156 return nil152157 }153158 159+// coordinateLabel keeps the detail card in the coordinate space the volume160+// declares. Raster-era tile planes retain their familiar latitude/longitude161+// reading; a native projected or synthetic space shows its own x/y values and162+// unit instead of pretending those values are Web Mercator.163+func coordinateLabel(space vnext.CoordinateSpace, grid tileGrid, position vnext.Position) string {164+ if grid.SourceZoom > 0 && grid.TileSize > 0 && grid.Size > 0 {165+ lat, lng := grid.unproject(position[0], position[1])166+ return strconv.FormatFloat(lat, 'f', 6, 64) + ", " +167+ strconv.FormatFloat(lng, 'f', 6, 64)168+ }169+ label := strconv.FormatFloat(position[0], 'f', 6, 64) + ", " +170+ strconv.FormatFloat(position[1], 'f', 6, 64)171+ if space.Unit != "" {172+ label += " " + space.Unit173+ }174+ return label175+}176+154177 func buildNativeShape(feature *vnext.Feature, collection *collectionModel) (*shapeModel, error) {155178 shape := &shapeModel{156179 ID: feature.ID, Title: feature.Title, Subtitle: feature.Subtitle, Shard: feature.Shard,@@ -50,6 +50,7 @@ type payloadCoverage struct {
5050 // that world.5151 type worldModel struct {5252 Slug string53+ Space vnext.CoordinateSpace5354 Lenses []payloadLens5455 Attrs map[string]string5556 Origin string@@ -105,13 +106,13 @@ func (c *collectionModel) Domain() string {
105106 106107 // pointModel is one packed location, already on the world square.107108 type pointModel struct {108- ID string109- Title string110- Lat, Lng float64111- X, Y float64112- Shard int64113- Feature *vnext.Feature114- Collection *collectionModel109+ ID string110+ Title string111+ Coordinates string112+ X, Y float64113+ Shard int64114+ Feature *vnext.Feature115+ Collection *collectionModel115116 }116117 117118 // shapeModel is one path or area feature: its rings in world pixels, its@@ -0,0 +1,195 @@
1+package authoring2+3+import (4+ "crypto/sha256"5+ "encoding/hex"6+ "fmt"7+ "sort"8+9+ "github.com/FelineStateMachine/atlas/format/vnext"10+)11+12+type assembledFeature struct {13+ feature vnext.Feature14+ values map[string]vnext.Property15+}16+17+func assemble(project Project, observations []observation) (vnext.Volume, error) {18+ space := project.Target.CoordinateSpace19+ world := vnext.World{20+ ID: project.Target.World, Title: project.Target.Title,21+ CoordinateSpace: vnext.CoordinateSpace{22+ ID: space.ID, Kind: space.Kind, Unit: space.Unit, Definition: space.Definition,23+ Extent: space.Extent, SourceZoom: space.SourceZoom, FirstTile: space.FirstTile,24+ TileSize: space.TileSize, Size: space.Size,25+ },26+ Presentation: vnext.Presentation{ID: project.Presentation.ID, Title: project.Presentation.Title},27+ }28+ sort.SliceStable(observations, func(i, j int) bool {29+ if observations[i].FeatureSet != observations[j].FeatureSet {30+ return observations[i].FeatureSet < observations[j].FeatureSet31+ }32+ if observations[i].NativeID != observations[j].NativeID {33+ return observations[i].NativeID < observations[j].NativeID34+ }35+ return observations[i].SourceOrder < observations[j].SourceOrder36+ })37+ sets := make(map[string]*vnext.FeatureSet)38+ features := make(map[string]map[string]*assembledFeature)39+ for _, item := range observations {40+ set := sets[item.FeatureSet]41+ if set == nil {42+ world.FeatureSets = append(world.FeatureSets, vnext.FeatureSet{ID: setID(world.ID, item.FeatureSet), Title: item.SetTitle, SemanticType: item.Semantic})43+ set = &world.FeatureSets[len(world.FeatureSets)-1]44+ sets[item.FeatureSet] = set45+ features[item.FeatureSet] = make(map[string]*assembledFeature)46+ }47+ if set.Title != item.SetTitle || set.SemanticType != item.Semantic {48+ return vnext.Volume{}, fmt.Errorf("feature set %s has incompatible observations", item.FeatureSet)49+ }50+ for key, field := range item.Fields {51+ if current, held := fieldByID(set.Properties, field.ID); held && current.Kind != field.Kind {52+ return vnext.Volume{}, fmt.Errorf("feature set %s field %s changes type", item.FeatureSet, key)53+ } else if !held {54+ set.Properties = append(set.Properties, field)55+ }56+ }57+ held := features[item.FeatureSet][item.NativeID]58+ if held == nil {59+ held = &assembledFeature{60+ feature: vnext.Feature{61+ ID: featureID(world.ID, item.FeatureSet, item.NativeID), Title: item.Title,62+ Geometry: item.Geometry,63+ },64+ values: make(map[string]vnext.Property),65+ }66+ features[item.FeatureSet][item.NativeID] = held67+ }68+ held.feature.Provenance = append(held.feature.Provenance, vnext.Provenance{69+ Source: item.Source, NativeID: item.NativeID, CapturedAt: item.Evidence.CapturedAt,70+ })71+ for key, value := range item.Values {72+ if _, exists := held.values[key]; !exists {73+ field := item.Fields[key]74+ held.values[key] = vnext.Property{FieldID: field.ID, Field: field, Value: value}75+ }76+ }77+ for _, relation := range item.Relations {78+ held.feature.Relationships = append(held.feature.Relationships, vnext.Relationship{79+ Predicate: relation.Predicate,80+ Target: featureID(world.ID, relation.FeatureSet, relation.NativeID),81+ })82+ }83+ }84+ for _, setName := range sortedSetNames(sets) {85+ set := sets[setName]86+ sort.Slice(set.Properties, func(i, j int) bool { return set.Properties[i].ID.String() < set.Properties[j].ID.String() })87+ for _, nativeID := range sortedFeatureNames(features[setName]) {88+ held := features[setName][nativeID]89+ for _, key := range sortedPropertyNames(held.values) {90+ held.feature.Properties = append(held.feature.Properties, held.values[key])91+ }92+ sort.Slice(held.feature.Provenance, func(i, j int) bool {93+ if held.feature.Provenance[i].Source != held.feature.Provenance[j].Source {94+ return held.feature.Provenance[i].Source < held.feature.Provenance[j].Source95+ }96+ return held.feature.Provenance[i].CapturedAt < held.feature.Provenance[j].CapturedAt97+ })98+ sort.Slice(held.feature.Relationships, func(i, j int) bool {99+ if held.feature.Relationships[i].Predicate != held.feature.Relationships[j].Predicate {100+ return held.feature.Relationships[i].Predicate < held.feature.Relationships[j].Predicate101+ }102+ return held.feature.Relationships[i].Target < held.feature.Relationships[j].Target103+ })104+ set.Features = append(set.Features, held.feature)105+ }106+ }107+ sort.Slice(world.FeatureSets, func(i, j int) bool { return world.FeatureSets[i].ID < world.FeatureSets[j].ID })108+ for _, style := range project.Presentation.Styles {109+ world.Presentation.Styles = append(world.Presentation.Styles, vnext.Style{110+ ID: style.ID, Symbol: style.Symbol, Icon: style.Icon, RenderAs: style.RenderAs,111+ Stroke: style.Stroke, Fill: style.Fill,112+ })113+ }114+ for _, layer := range project.Presentation.Layers {115+ maxZoom := layer.MaxZoom116+ if maxZoom == 0 {117+ maxZoom = 32118+ }119+ world.Presentation.Layers = append(world.Presentation.Layers, vnext.Layer{120+ ID: layer.ID, FeatureSet: setID(world.ID, layer.FeatureSet), Style: layer.Style,121+ Group: layer.Group, LabelPolicy: layer.LabelPolicy, Visible: layer.Visible,122+ Order: layer.Order, MinZoom: layer.MinZoom, MaxZoom: maxZoom,123+ })124+ world.Presentation.Legend = append(world.Presentation.Legend, vnext.LegendEntry{Layer: layer.ID, Label: layer.Label, Order: layer.Order})125+ }126+ sort.Slice(world.Presentation.Styles, func(i, j int) bool { return world.Presentation.Styles[i].ID < world.Presentation.Styles[j].ID })127+ sort.Slice(world.Presentation.Layers, func(i, j int) bool { return world.Presentation.Layers[i].Order < world.Presentation.Layers[j].Order })128+ sort.Slice(world.Presentation.Legend, func(i, j int) bool { return world.Presentation.Legend[i].Order < world.Presentation.Legend[j].Order })129+ if err := relationshipClosure(world); err != nil {130+ return vnext.Volume{}, err131+ }132+ return vnext.Volume{ID: project.ID, Title: project.Title, Worlds: []vnext.World{world}}, nil133+}134+135+func fieldByID(fields []vnext.Field, id vnext.ID) (vnext.Field, bool) {136+ for _, field := range fields {137+ if field.ID == id {138+ return field, true139+ }140+ }141+ return vnext.Field{}, false142+}143+144+func relationshipClosure(world vnext.World) error {145+ ids := make(map[string]bool)146+ for _, set := range world.FeatureSets {147+ for _, feature := range set.Features {148+ ids[feature.ID] = true149+ }150+ }151+ for _, set := range world.FeatureSets {152+ for _, feature := range set.Features {153+ for _, relation := range feature.Relationships {154+ if !ids[relation.Target] {155+ return fmt.Errorf("feature %s relationship %s targets missing %s", feature.ID, relation.Predicate, relation.Target)156+ }157+ }158+ }159+ }160+ return nil161+}162+163+func setID(world, set string) string { return world + "/set/" + set }164+165+func featureID(world, set, native string) string {166+ digest := sha256.Sum256([]byte(native))167+ return setID(world, set) + "/feature/" + hex.EncodeToString(digest[:12])168+}169+170+func sortedSetNames(values map[string]*vnext.FeatureSet) []string {171+ out := make([]string, 0, len(values))172+ for key := range values {173+ out = append(out, key)174+ }175+ sort.Strings(out)176+ return out177+}178+179+func sortedFeatureNames(values map[string]*assembledFeature) []string {180+ out := make([]string, 0, len(values))181+ for key := range values {182+ out = append(out, key)183+ }184+ sort.Strings(out)185+ return out186+}187+188+func sortedPropertyNames(values map[string]vnext.Property) []string {189+ out := make([]string, 0, len(values))190+ for key := range values {191+ out = append(out, key)192+ }193+ sort.Strings(out)194+ return out195+}@@ -0,0 +1,288 @@
1+package authoring2+3+import (4+ "context"5+ "encoding/json"6+ "fmt"7+ "mime"8+ "os"9+ "path/filepath"10+ "sort"11+ "strings"12+13+ "github.com/FelineStateMachine/atlas/format/vnext"14+)15+16+type Event struct {17+ Stage string `json:"stage"`18+ Message string `json:"message"`19+ Current int `json:"current,omitempty"`20+ Total int `json:"total,omitempty"`21+ Bytes int64 `json:"bytes,omitempty"`22+ Cached bool `json:"cached,omitempty"`23+ Artifact string `json:"artifact,omitempty"`24+}25+26+type BuildOptions struct {27+ ProjectPath string28+ CacheDir string29+ LibraryDir string30+ Offline bool31+ PlanOnly bool32+ Event func(Event)33+}34+35+type BuildResult struct {36+ Plan Plan `json:"plan"`37+ Descriptor vnext.Descriptor `json:"descriptor,omitempty"`38+ Path string `json:"path,omitempty"`39+ Present bool `json:"present,omitempty"`40+}41+42+func Build(ctx context.Context, options BuildOptions) (BuildResult, error) {43+ project, err := LoadProject(options.ProjectPath)44+ if err != nil {45+ return BuildResult{}, err46+ }47+ cache := OpenCache(options.CacheDir)48+ plan, err := PlanProject(project, cache, options.LibraryDir)49+ if err != nil {50+ return BuildResult{}, err51+ }52+ emit(options, Event{Stage: "plan", Message: "build planned", Total: len(plan.Requests), Bytes: plan.Estimated})53+ result := BuildResult{Plan: plan}54+ if options.PlanOnly {55+ return result, nil56+ }57+ captures := make(map[string]Capture, len(plan.Requests))58+ for index, planned := range plan.Requests {59+ capture, cached, err := cache.Acquire(ctx, planned.Request, options.Offline)60+ if err != nil {61+ return result, err62+ }63+ captures[planned.ID] = capture64+ emit(options, Event{65+ Stage: "capture", Message: planned.Source, Current: index + 1,66+ Total: len(plan.Requests), Bytes: capture.Body.Length, Cached: cached,67+ })68+ }69+ var observations []observation70+ for sourceIndex, source := range project.Sources {71+ planned, ok := featurePlan(plan, source.ID)72+ if !ok {73+ return result, fmt.Errorf("source %s has no planned request", source.ID)74+ }75+ capture := captures[planned.ID]76+ body, err := os.ReadFile(cache.BlobPath(capture.Body))77+ if err != nil {78+ return result, fmt.Errorf("read captured source %s: %w", source.ID, err)79+ }80+ items, err := observe(project, sourceIndex, source, capture, body)81+ if err != nil {82+ return result, err83+ }84+ observations = append(observations, items...)85+ emit(options, Event{Stage: "observe", Message: source.ID, Current: sourceIndex + 1, Total: len(project.Sources)})86+ }87+ volume, err := assemble(project, observations)88+ if err != nil {89+ return result, err90+ }91+ rasterBlobs, err := attachRasters(project, plan, captures, cache, &volume.Worlds[0])92+ if err != nil {93+ return result, err94+ }95+ receipt, createdAt, err := buildReceipt(plan, captures)96+ if err != nil {97+ return result, err98+ }99+ volume.Assets = append(volume.Assets, vnext.Asset{100+ ID: "build-receipt", MediaType: "application/json", Data: receipt,101+ Provenance: project.ID,102+ })103+ emit(options, Event{Stage: "assemble", Message: "native semantic volume assembled"})104+ bundle, err := vnext.Compile(volume)105+ if err != nil {106+ return result, fmt.Errorf("compile native volume: %w", err)107+ }108+ bundle.Blobs = append(bundle.Blobs, rasterBlobs...)109+ bundle.Release.CreatedAt = createdAt110+ bundle.Release.Revision = project.Release.Revision111+ if bundle.Release.Revision == 0 {112+ bundle.Release.Revision = 1113+ }114+ descriptor, path, present, err := writeBuild(options.LibraryDir, bundle)115+ if err != nil {116+ return result, err117+ }118+ result.Descriptor, result.Path, result.Present = descriptor, path, present119+ emit(options, Event{Stage: "publish", Message: "native build installed", Artifact: path})120+ return result, nil121+}122+123+func emit(options BuildOptions, event Event) {124+ if options.Event != nil {125+ options.Event(event)126+ }127+}128+129+func featurePlan(plan Plan, source string) (PlannedRequest, bool) {130+ for _, request := range plan.Requests {131+ if request.Kind == RequestFeatures && request.Source == source {132+ return request, true133+ }134+ }135+ return PlannedRequest{}, false136+}137+138+func attachRasters(project Project, plan Plan, captures map[string]Capture, cache *Cache, world *vnext.World) ([]vnext.Blob, error) {139+ var blobs []vnext.Blob140+ for _, source := range project.Rasters {141+ var requests []PlannedRequest142+ for _, request := range plan.Requests {143+ if request.Kind == RequestRaster && request.Raster == source.ID {144+ requests = append(requests, request)145+ }146+ }147+ if len(requests) == 0 {148+ return nil, fmt.Errorf("raster %s has no planned requests", source.ID)149+ }150+ sort.Slice(requests, func(i, j int) bool {151+ if requests[i].Zoom != requests[j].Zoom {152+ return requests[i].Zoom < requests[j].Zoom153+ }154+ if requests[i].Y != requests[j].Y {155+ return requests[i].Y < requests[j].Y156+ }157+ return requests[i].X < requests[j].X158+ })159+ minZoom, maxZoom := requests[0].Zoom, requests[len(requests)-1].Zoom160+ formats := make([]string, maxZoom-minZoom+1)161+ codec := "application/octet-stream"162+ for _, request := range requests {163+ capture := captures[request.ID]164+ format := formatOf(capture.Body.MediaType, request.IdentityLocator)165+ formats[request.Zoom-minZoom] = format166+ codec = capture.Body.MediaType167+ blobs = append(blobs, vnext.Blob{168+ Name: fmt.Sprintf("tiles/%s/%d/%d/%d.%s", source.ID, request.Zoom, request.X, request.Y, format),169+ Path: cache.BlobPath(capture.Body),170+ })171+ }172+ for index := range formats {173+ if formats[index] == "" {174+ return nil, fmt.Errorf("raster %s omits zoom %d", source.ID, int64(index)+minZoom)175+ }176+ }177+ world.RasterPyramids = append(world.RasterPyramids, vnext.RasterPyramid{178+ ID: world.ID + "/raster/" + source.ID, Name: source.Name, Codec: codec,179+ TileSize: source.TileSize, MinZoom: minZoom, MaxZoom: maxZoom, FullZoom: maxZoom,180+ SourceZoom: source.SourceZoom, Template: "tiles/" + source.ID + "/{z}/{x}/{y}.{format}",181+ Formats: formats, Interpolate: source.Interpolate, Background: source.Background,182+ })183+ }184+ sort.Slice(world.RasterPyramids, func(i, j int) bool { return world.RasterPyramids[i].ID < world.RasterPyramids[j].ID })185+ sort.Slice(blobs, func(i, j int) bool { return blobs[i].Name < blobs[j].Name })186+ return blobs, nil187+}188+189+func formatOf(mediaType, locator string) string {190+ extension := strings.TrimPrefix(filepath.Ext(locator), ".")191+ if extension == "" {192+ extensions, _ := mime.ExtensionsByType(mediaType)193+ if len(extensions) > 0 {194+ extension = strings.TrimPrefix(extensions[0], ".")195+ }196+ }197+ if extension == "jpeg" {198+ extension = "jpg"199+ }200+ if extension == "" {201+ extension = "bin"202+ }203+ return strings.ToLower(extension)204+}205+206+func buildReceipt(plan Plan, captures map[string]Capture) ([]byte, string, error) {207+ type selected struct {208+ Request string `json:"request"`209+ Source string `json:"source"`210+ SHA256 string `json:"sha256"`211+ }212+ receipt := struct {213+ Format string `json:"format"`214+ Project string `json:"project"`215+ ProjectDigest string `json:"projectDigest"`216+ Captures []selected `json:"captures"`217+ }{Format: "atlas-build-receipt/v1", Project: plan.Project, ProjectDigest: plan.ProjectDigest}218+ createdAt := ""219+ for _, request := range plan.Requests {220+ capture := captures[request.ID]221+ receipt.Captures = append(receipt.Captures, selected{Request: request.ID, Source: request.Source, SHA256: capture.Body.SHA256})222+ if capture.CapturedAt > createdAt {223+ createdAt = capture.CapturedAt224+ }225+ }226+ if createdAt == "" {227+ return nil, "", fmt.Errorf("build selects no captured evidence")228+ }229+ data, err := json.Marshal(receipt)230+ if err != nil {231+ return nil, "", err232+ }233+ return append(data, '\n'), createdAt, nil234+}235+236+func writeBuild(library string, bundle vnext.Bundle) (vnext.Descriptor, string, bool, error) {237+ if library == "" {238+ return vnext.Descriptor{}, "", false, fmt.Errorf("build has no Atlas library")239+ }240+ if err := os.MkdirAll(library, 0o755); err != nil {241+ return vnext.Descriptor{}, "", false, err242+ }243+ file, err := os.CreateTemp(library, ".building-*.atlas")244+ if err != nil {245+ return vnext.Descriptor{}, "", false, err246+ }247+ stage := file.Name()248+ defer os.Remove(stage)249+ if err := vnext.Write(file, bundle); err != nil {250+ file.Close()251+ return vnext.Descriptor{}, "", false, err252+ }253+ if err := file.Sync(); err != nil {254+ file.Close()255+ return vnext.Descriptor{}, "", false, err256+ }257+ if err := file.Close(); err != nil {258+ return vnext.Descriptor{}, "", false, err259+ }260+ opened, err := vnext.OpenFile(stage, vnext.StandardSchema())261+ if err != nil {262+ return vnext.Descriptor{}, "", false, fmt.Errorf("reopen native build: %w", err)263+ }264+ if err := opened.Validate(); err != nil {265+ opened.Close()266+ return vnext.Descriptor{}, "", false, fmt.Errorf("validate native build: %w", err)267+ }268+ descriptor := opened.Descriptor()269+ if err := opened.Close(); err != nil {270+ return vnext.Descriptor{}, "", false, err271+ }272+ target := filepath.Join(library, vnext.VersionedFileName(descriptor.Slug, vnext.Release{273+ Title: descriptor.Title, CreatedAt: descriptor.CreatedAt, Revision: descriptor.Revision,274+ Stamp: descriptor.Stamp, Worlds: descriptor.Worlds,275+ }))276+ if _, err := os.Stat(target); err == nil {277+ held, err := vnext.Describe(target, vnext.StandardSchema())278+ return held, target, true, err279+ }280+ if err := os.Chmod(stage, 0o644); err != nil {281+ return vnext.Descriptor{}, "", false, err282+ }283+ if err := os.Rename(stage, target); err != nil {284+ return vnext.Descriptor{}, "", false, err285+ }286+ installed, err := vnext.Describe(target, vnext.StandardSchema())287+ return installed, target, false, err288+}@@ -0,0 +1,104 @@
1+package authoring2+3+import (4+ "bytes"5+ "context"6+ "os"7+ "path/filepath"8+ "strings"9+ "testing"10+11+ "github.com/FelineStateMachine/atlas/format/vnext"12+)13+14+func TestBuildSampleRegionIsNativeDeterministicAndFused(t *testing.T) {15+ root := t.TempDir()16+ projectPath := filepath.Join(root, "sample-region"+ProjectExtension)17+ projectBody := strings.Replace(sampleProject, "sources:\n", `sources:18+ - id: sample-features-secondary19+ adapter: geojson20+ locator: sample-region-secondary.geojson21+ license: CC0-1.022+ attribution: Secondary sample publisher23+ mapping:24+ feature-set: places25+ title: Places26+ semantic-type: sample.place27+ identity: properties.id28+ feature-title: properties.name29+ fields:30+ - id: sample.kind31+ source: properties.kind32+ type: string33+`, 1)34+ if err := os.WriteFile(projectPath, []byte(projectBody), 0o644); err != nil {35+ t.Fatal(err)36+ }37+ primary := `{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"id":"one","name":"Sample One","kind":"primary"},"geometry":{"type":"Point","coordinates":[32,48]}}]}`38+ secondary := `{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"id":"one","name":"Sample One","kind":"secondary"},"geometry":{"type":"Point","coordinates":[33,49]}}]}`39+ if err := os.WriteFile(filepath.Join(root, "sample-region.geojson"), []byte(primary), 0o644); err != nil {40+ t.Fatal(err)41+ }42+ if err := os.WriteFile(filepath.Join(root, "sample-region-secondary.geojson"), []byte(secondary), 0o644); err != nil {43+ t.Fatal(err)44+ }45+ cache, library := filepath.Join(root, "cache"), filepath.Join(root, "library")46+ first, err := Build(context.Background(), BuildOptions{ProjectPath: projectPath, CacheDir: cache, LibraryDir: library})47+ if err != nil {48+ t.Fatal(err)49+ }50+ firstBytes, err := os.ReadFile(first.Path)51+ if err != nil {52+ t.Fatal(err)53+ }54+ second, err := Build(context.Background(), BuildOptions{ProjectPath: projectPath, CacheDir: cache, LibraryDir: library, Offline: true})55+ if err != nil {56+ t.Fatal(err)57+ }58+ secondBytes, err := os.ReadFile(second.Path)59+ if err != nil {60+ t.Fatal(err)61+ }62+ if !second.Present || first.Path != second.Path || !bytes.Equal(firstBytes, secondBytes) {63+ t.Fatalf("rebuild differs: first=%+v second=%+v", first, second)64+ }65+ file, err := vnext.OpenFile(first.Path, vnext.StandardSchema())66+ if err != nil {67+ t.Fatal(err)68+ }69+ defer file.Close()70+ volume, err := file.Volume()71+ if err != nil {72+ t.Fatal(err)73+ }74+ if len(volume.Worlds) != 1 || len(volume.Worlds[0].FeatureSets) != 1 || len(volume.Worlds[0].FeatureSets[0].Features) != 1 {75+ t.Fatalf("unexpected semantic volume: %+v", volume)76+ }77+ feature := volume.Worlds[0].FeatureSets[0].Features[0]78+ if len(feature.Provenance) != 2 {79+ t.Fatalf("fused feature has provenance %+v", feature.Provenance)80+ }81+ if got := feature.Properties[0].Value.String; got != "secondary" {82+ // The manifest puts the secondary source first; source order is the83+ // explicit deterministic conflict policy.84+ t.Fatalf("fused property = %q, want secondary", got)85+ }86+}87+88+func TestPlanOnlyDoesNotCreateCacheOrLibrary(t *testing.T) {89+ root := t.TempDir()90+ project := writeSampleProject(t, strings.Replace(sampleProject, "sample-region.geojson", filepath.Join(root, "missing.geojson"), 1))91+ cache, library := filepath.Join(root, "cache"), filepath.Join(root, "library")92+ result, err := Build(context.Background(), BuildOptions{ProjectPath: project, CacheDir: cache, LibraryDir: library, PlanOnly: true})93+ if err != nil {94+ t.Fatal(err)95+ }96+ if len(result.Plan.Requests) != 1 {97+ t.Fatalf("plan has %d requests", len(result.Plan.Requests))98+ }99+ for _, path := range []string{cache, library} {100+ if _, err := os.Stat(path); !os.IsNotExist(err) {101+ t.Fatalf("plan created %s", path)102+ }103+ }104+}@@ -0,0 +1,215 @@
1+package authoring2+3+import (4+ "context"5+ "crypto/sha256"6+ "encoding/hex"7+ "encoding/json"8+ "errors"9+ "fmt"10+ "io"11+ "net/http"12+ "os"13+ "path/filepath"14+ "sort"15+ "strings"16+ "time"17+)18+19+const CaptureFormat = "atlas-capture/v1"20+21+type BlobRef struct {22+ SHA256 string `json:"sha256"`23+ Length int64 `json:"length"`24+ MediaType string `json:"mediaType"`25+}26+27+// Capture is an immutable source-neutral envelope around exact evidence bytes.28+type Capture struct {29+ Format string `json:"format"`30+ RequestHash string `json:"requestHash"`31+ Source string `json:"source"`32+ Adapter string `json:"adapter"`33+ Kind string `json:"kind"`34+ Locator string `json:"locator"`35+ CapturedAt string `json:"capturedAt"`36+ License string `json:"license,omitempty"`37+ Attribution string `json:"attribution,omitempty"`38+ Body BlobRef `json:"body"`39+}40+41+// Cache is shared by projects. The clock and client are replaceable for a42+// deterministic test; production uses UTC now and a bounded HTTP client.43+type Cache struct {44+ root string45+ client *http.Client46+ now func() time.Time47+}48+49+func OpenCache(root string) *Cache {50+ return &Cache{51+ root: root, client: &http.Client{Timeout: 2 * time.Minute},52+ now: func() time.Time { return time.Now().UTC() },53+ }54+}55+56+func (cache *Cache) Root() string { return cache.root }57+58+func (cache *Cache) Has(requestID string) bool {59+ _, err := cache.Latest(requestID)60+ return err == nil61+}62+63+func (cache *Cache) Latest(requestID string) (Capture, error) {64+ dir := filepath.Join(cache.root, "captures", requestID[:2], requestID)65+ entries, err := os.ReadDir(dir)66+ if err != nil {67+ return Capture{}, err68+ }69+ sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })70+ var captures []Capture71+ for _, entry := range entries {72+ if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {73+ continue74+ }75+ data, err := os.ReadFile(filepath.Join(dir, entry.Name()))76+ if err != nil {77+ return Capture{}, err78+ }79+ var capture Capture80+ if err := json.Unmarshal(data, &capture); err != nil {81+ return Capture{}, fmt.Errorf("decode capture %s: %w", entry.Name(), err)82+ }83+ captures = append(captures, capture)84+ }85+ if len(captures) == 0 {86+ return Capture{}, os.ErrNotExist87+ }88+ sort.Slice(captures, func(i, j int) bool {89+ if captures[i].CapturedAt != captures[j].CapturedAt {90+ return captures[i].CapturedAt < captures[j].CapturedAt91+ }92+ return captures[i].Body.SHA256 < captures[j].Body.SHA25693+ })94+ return captures[len(captures)-1], nil95+}96+97+func (cache *Cache) BlobPath(ref BlobRef) string {98+ return filepath.Join(cache.root, "blobs", "sha256", ref.SHA256[:2], ref.SHA256)99+}100+101+// Acquire fetches or reads one planned request, records only changed bytes and102+// returns the immutable capture selected for this build.103+func (cache *Cache) Acquire(ctx context.Context, request Request, offline bool) (Capture, bool, error) {104+ if offline {105+ capture, err := cache.Latest(request.ID)106+ if err != nil {107+ return Capture{}, false, fmt.Errorf("request %s is not cached: %w", request.Source, err)108+ }109+ return capture, true, nil110+ }111+ body, mediaType, err := cache.read(ctx, request)112+ if err != nil {113+ return Capture{}, false, err114+ }115+ digest := sha256.Sum256(body)116+ hash := hex.EncodeToString(digest[:])117+ if current, err := cache.Latest(request.ID); err == nil && current.Body.SHA256 == hash {118+ return current, true, nil119+ }120+ if mediaType == "" {121+ mediaType = request.MediaType122+ }123+ if mediaType == "" {124+ mediaType = "application/octet-stream"125+ }126+ ref := BlobRef{SHA256: hash, Length: int64(len(body)), MediaType: mediaType}127+ if err := writeOnce(cache.BlobPath(ref), body); err != nil {128+ return Capture{}, false, fmt.Errorf("cache evidence: %w", err)129+ }130+ capture := Capture{131+ Format: CaptureFormat, RequestHash: request.ID, Source: request.Source,132+ Adapter: request.Adapter, Kind: string(request.Kind), Locator: request.IdentityLocator,133+ CapturedAt: cache.now().UTC().Format(time.RFC3339Nano),134+ License: request.License, Attribution: request.Attribution, Body: ref,135+ }136+ data, err := json.Marshal(capture)137+ if err != nil {138+ return Capture{}, false, err139+ }140+ data = append(data, '\n')141+ path := filepath.Join(cache.root, "captures", request.ID[:2], request.ID, hash+".json")142+ if err := writeOnce(path, data); err != nil {143+ return Capture{}, false, fmt.Errorf("cache capture: %w", err)144+ }145+ return capture, false, nil146+}147+148+func (cache *Cache) read(ctx context.Context, request Request) ([]byte, string, error) {149+ if !isRemote(request.Locator) {150+ data, err := os.ReadFile(request.Locator)151+ if err != nil {152+ return nil, "", fmt.Errorf("read %s source %s: %w", request.Adapter, request.Source, err)153+ }154+ return data, request.MediaType, nil155+ }156+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, request.Locator, nil)157+ if err != nil {158+ return nil, "", err159+ }160+ req.Header.Set("Accept", request.MediaType)161+ req.Header.Set("User-Agent", "Atlas authoring/1")162+ response, err := cache.client.Do(req)163+ if err != nil {164+ return nil, "", fmt.Errorf("fetch %s source %s: %w", request.Adapter, request.Source, err)165+ }166+ defer response.Body.Close()167+ if response.StatusCode != http.StatusOK {168+ _, _ = io.Copy(io.Discard, response.Body)169+ return nil, "", fmt.Errorf("fetch %s source %s: %s", request.Adapter, request.Source, response.Status)170+ }171+ body, err := io.ReadAll(response.Body)172+ if err != nil {173+ return nil, "", fmt.Errorf("read %s source %s: %w", request.Adapter, request.Source, err)174+ }175+ mediaType := strings.TrimSpace(strings.Split(response.Header.Get("Content-Type"), ";")[0])176+ return body, mediaType, nil177+}178+179+func writeOnce(path string, data []byte) error {180+ if _, err := os.Stat(path); err == nil {181+ return nil182+ } else if !errors.Is(err, os.ErrNotExist) {183+ return err184+ }185+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {186+ return err187+ }188+ stage, err := os.CreateTemp(filepath.Dir(path), ".writing-*")189+ if err != nil {190+ return err191+ }192+ name := stage.Name()193+ defer os.Remove(name)194+ if _, err := stage.Write(data); err != nil {195+ stage.Close()196+ return err197+ }198+ if err := stage.Sync(); err != nil {199+ stage.Close()200+ return err201+ }202+ if err := stage.Close(); err != nil {203+ return err204+ }205+ if err := os.Chmod(name, 0o644); err != nil {206+ return err207+ }208+ if err := os.Rename(name, path); err != nil {209+ if _, statErr := os.Stat(path); statErr == nil {210+ return nil211+ }212+ return err213+ }214+ return nil215+}@@ -0,0 +1,61 @@
1+package authoring2+3+import (4+ "context"5+ "os"6+ "path/filepath"7+ "testing"8+ "time"9+)10+11+func TestCaptureReusesUnchangedEvidence(t *testing.T) {12+ root := t.TempDir()13+ source := filepath.Join(root, "sample-region.geojson")14+ if err := os.WriteFile(source, []byte(`{"type":"FeatureCollection","features":[]}`), 0o644); err != nil {15+ t.Fatal(err)16+ }17+ cache := OpenCache(filepath.Join(root, "cache"))18+ clock := time.Date(2026, 8, 8, 12, 0, 0, 0, time.UTC)19+ cache.now = func() time.Time { return clock }20+ request := Request{ID: requestID(Request{Kind: RequestFeatures, Source: "sample-features", Adapter: "geojson", IdentityLocator: "sample-region.geojson"}), Kind: RequestFeatures, Source: "sample-features", Adapter: "geojson", Locator: source, IdentityLocator: "sample-region.geojson", MediaType: "application/geo+json"}21+ first, cached, err := cache.Acquire(context.Background(), request, false)22+ if err != nil || cached {23+ t.Fatalf("first acquire = cached %v, err %v", cached, err)24+ }25+ clock = clock.Add(time.Hour)26+ second, cached, err := cache.Acquire(context.Background(), request, false)27+ if err != nil || !cached {28+ t.Fatalf("second acquire = cached %v, err %v", cached, err)29+ }30+ if second != first {31+ t.Fatalf("unchanged capture moved: first %+v second %+v", first, second)32+ }33+}34+35+func TestCaptureRecordsChangedEvidence(t *testing.T) {36+ root := t.TempDir()37+ source := filepath.Join(root, "sample-region.geojson")38+ if err := os.WriteFile(source, []byte(`{"v":1}`), 0o644); err != nil {39+ t.Fatal(err)40+ }41+ cache := OpenCache(filepath.Join(root, "cache"))42+ clock := time.Date(2026, 8, 8, 12, 0, 0, 0, time.UTC)43+ cache.now = func() time.Time { return clock }44+ request := Request{Kind: RequestFeatures, Source: "sample-features", Adapter: "geojson", Locator: source, IdentityLocator: "sample-region.geojson"}45+ request.ID = requestID(request)46+ first, _, err := cache.Acquire(context.Background(), request, false)47+ if err != nil {48+ t.Fatal(err)49+ }50+ if err := os.WriteFile(source, []byte(`{"v":2}`), 0o644); err != nil {51+ t.Fatal(err)52+ }53+ clock = clock.Add(time.Hour)54+ second, cached, err := cache.Acquire(context.Background(), request, false)55+ if err != nil || cached {56+ t.Fatalf("changed acquire = cached %v, err %v", cached, err)57+ }58+ if first.Body.SHA256 == second.Body.SHA256 || first.CapturedAt == second.CapturedAt {59+ t.Fatalf("change was not recorded: first %+v second %+v", first, second)60+ }61+}@@ -0,0 +1,383 @@
1+// Package authoring builds native Atlas volumes from portable project manifests.2+//3+// A project is configuration only. Captured evidence lives in a shared cache and4+// completed .atlas files live in the library; neither is copied beside the5+// manifest. This keeps one small file as the reproducible statement of intent.6+package authoring7+8+import (9+ "errors"10+ "fmt"11+ "io"12+ "os"13+ "path/filepath"14+ "strings"15+16+ "github.com/FelineStateMachine/atlas/format/vnext"17+ "gopkg.in/yaml.v3"18+)19+20+const (21+ ProjectSchema = "atlas-project/v1"22+ ProjectExtension = ".atlas-project"23+)24+25+// Project is the complete durable input to one Atlas build.26+type Project struct {27+ Schema string `yaml:"schema"`28+ SchemaNamespace string `yaml:"schema-namespace"`29+ ID string `yaml:"id"`30+ Title string `yaml:"title"`31+ Target Target `yaml:"target"`32+ Sources []Source `yaml:"sources,omitempty"`33+ Rasters []Raster `yaml:"rasters,omitempty"`34+ Presentation Presentation `yaml:"presentation"`35+ Release Release `yaml:"release,omitempty"`36+37+ baseDir string38+}39+40+// Target states the one world and coordinate space every configured source41+// must speak before semantic assembly begins.42+type Target struct {43+ World string `yaml:"world"`44+ Title string `yaml:"title"`45+ CoordinateSpace CoordinateSpace `yaml:"coordinate-space"`46+}47+48+type CoordinateSpace struct {49+ ID string `yaml:"id"`50+ Kind string `yaml:"kind"`51+ Unit string `yaml:"unit"`52+ Definition string `yaml:"definition"`53+ Extent [4]float64 `yaml:"extent"`54+ SourceZoom int64 `yaml:"source-zoom,omitempty"`55+ FirstTile int64 `yaml:"first-tile,omitempty"`56+ TileSize int64 `yaml:"tile-size,omitempty"`57+ Size int64 `yaml:"size,omitempty"`58+}59+60+// Source is one configured instance of a reusable feature adapter.61+type Source struct {62+ ID string `yaml:"id"`63+ Adapter string `yaml:"adapter"`64+ Locator string `yaml:"locator"`65+ MediaType string `yaml:"media-type,omitempty"`66+ License string `yaml:"license,omitempty"`67+ Attribution string `yaml:"attribution,omitempty"`68+ EstimateBytes int64 `yaml:"estimate-bytes,omitempty"`69+ Query Query `yaml:"query,omitempty"`70+ Mapping Mapping `yaml:"mapping"`71+}72+73+// Query carries protocol-neutral selection knobs interpreted only by the74+// selected adapter.75+type Query struct {76+ Layer int `yaml:"layer,omitempty"`77+ Collection string `yaml:"collection,omitempty"`78+ Where string `yaml:"where,omitempty"`79+ Limit int `yaml:"limit,omitempty"`80+}81+82+// Mapping turns source observations into one semantic feature set. Sources83+// that name the same feature-set and canonical identity meet during assembly.84+type Mapping struct {85+ FeatureSet string `yaml:"feature-set"`86+ Title string `yaml:"title"`87+ SemanticType string `yaml:"semantic-type"`88+ Identity string `yaml:"identity"`89+ FeatureTitle string `yaml:"feature-title"`90+ Fields []Field `yaml:"fields,omitempty"`91+ Relations []RelationMap `yaml:"relationships,omitempty"`92+}93+94+type Field struct {95+ ID string `yaml:"id"`96+ Name string `yaml:"name,omitempty"`97+ Source string `yaml:"source"`98+ Type string `yaml:"type"`99+ Optional bool `yaml:"optional,omitempty"`100+}101+102+type RelationMap struct {103+ Predicate string `yaml:"predicate"`104+ FeatureSet string `yaml:"feature-set,omitempty"`105+ Target string `yaml:"target"`106+}107+108+// Raster is a configured raster adapter. A file is one tile at zoom zero; an109+// xyz source names explicit tile windows so planning never probes the network.110+type Raster struct {111+ ID string `yaml:"id"`112+ Name string `yaml:"name"`113+ Adapter string `yaml:"adapter"`114+ Locator string `yaml:"locator"`115+ MediaType string `yaml:"media-type,omitempty"`116+ License string `yaml:"license,omitempty"`117+ Attribution string `yaml:"attribution,omitempty"`118+ EstimateBytes int64 `yaml:"estimate-bytes,omitempty"`119+ TileSize int64 `yaml:"tile-size"`120+ SourceZoom int64 `yaml:"source-zoom,omitempty"`121+ Interpolate bool `yaml:"interpolate,omitempty"`122+ Background string `yaml:"background,omitempty"`123+ Levels []RasterLevel `yaml:"levels,omitempty"`124+}125+126+type RasterLevel struct {127+ Zoom int64 `yaml:"zoom"`128+ MinX int64 `yaml:"min-x"`129+ MinY int64 `yaml:"min-y"`130+ MaxX int64 `yaml:"max-x"`131+ MaxY int64 `yaml:"max-y"`132+}133+134+type Presentation struct {135+ ID string `yaml:"id"`136+ Title string `yaml:"title"`137+ Styles []Style `yaml:"styles,omitempty"`138+ Layers []Layer `yaml:"layers,omitempty"`139+}140+141+type Style struct {142+ ID string `yaml:"id"`143+ Symbol string `yaml:"symbol,omitempty"`144+ Icon string `yaml:"icon,omitempty"`145+ RenderAs string `yaml:"render-as,omitempty"`146+ Stroke string `yaml:"stroke,omitempty"`147+ Fill string `yaml:"fill,omitempty"`148+}149+150+type Layer struct {151+ ID string `yaml:"id"`152+ FeatureSet string `yaml:"feature-set"`153+ Style string `yaml:"style"`154+ Label string `yaml:"label"`155+ Group string `yaml:"group,omitempty"`156+ LabelPolicy string `yaml:"label-policy,omitempty"`157+ Visible bool `yaml:"visible"`158+ Order int64 `yaml:"order"`159+ MinZoom int64 `yaml:"min-zoom,omitempty"`160+ MaxZoom int64 `yaml:"max-zoom,omitempty"`161+}162+163+type Release struct {164+ Revision int `yaml:"revision,omitempty"`165+}166+167+// LoadProject strictly decodes one manifest. Unknown fields fail so a typo168+// cannot silently remove a source, mapping, or layer from a build.169+func LoadProject(path string) (Project, error) {170+ if filepath.Ext(path) != ProjectExtension {171+ return Project{}, fmt.Errorf("project %s must use %s", filepath.Base(path), ProjectExtension)172+ }173+ file, err := os.Open(path)174+ if err != nil {175+ return Project{}, fmt.Errorf("open project: %w", err)176+ }177+ defer file.Close()178+ decoder := yaml.NewDecoder(file)179+ decoder.KnownFields(true)180+ var project Project181+ if err := decoder.Decode(&project); err != nil {182+ return Project{}, fmt.Errorf("decode project: %w", err)183+ }184+ var extra any185+ if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {186+ if err == nil {187+ return Project{}, fmt.Errorf("project carries more than one YAML document")188+ }189+ return Project{}, fmt.Errorf("decode project trailer: %w", err)190+ }191+ project.baseDir = filepath.Dir(path)192+ if err := project.Validate(); err != nil {193+ return Project{}, err194+ }195+ return project, nil196+}197+198+// ResolveLocator resolves only local locators. Remote locators remain exactly199+// as authored so their request identity is portable across machines.200+func (project Project) ResolveLocator(locator string) string {201+ if isRemote(locator) || filepath.IsAbs(locator) {202+ return locator203+ }204+ return filepath.Clean(filepath.Join(project.baseDir, filepath.FromSlash(locator)))205+}206+207+func (project Project) Validate() error {208+ if project.Schema != ProjectSchema {209+ return fmt.Errorf("project schema %q, want %q", project.Schema, ProjectSchema)210+ }211+ if err := vnext.ValidSlug(project.ID); err != nil {212+ return fmt.Errorf("project identity: %w", err)213+ }214+ if project.Title == "" {215+ return fmt.Errorf("project %s has no title", project.ID)216+ }217+ if strings.TrimSpace(project.SchemaNamespace) == "" {218+ return fmt.Errorf("project %s has no schema namespace", project.ID)219+ }220+ if err := validateTarget(project.Target); err != nil {221+ return err222+ }223+ if len(project.Sources) == 0 && len(project.Rasters) == 0 {224+ return fmt.Errorf("project %s configures no sources or rasters", project.ID)225+ }226+ sets := make(map[string]Mapping)227+ sourceIDs := make(map[string]bool)228+ for _, source := range project.Sources {229+ if err := validateSource(source); err != nil {230+ return err231+ }232+ if sourceIDs[source.ID] {233+ return fmt.Errorf("source %s is configured twice", source.ID)234+ }235+ sourceIDs[source.ID] = true236+ if held, ok := sets[source.Mapping.FeatureSet]; ok {237+ if held.Title != source.Mapping.Title || held.SemanticType != source.Mapping.SemanticType {238+ return fmt.Errorf("feature set %s has conflicting declarations", source.Mapping.FeatureSet)239+ }240+ } else {241+ sets[source.Mapping.FeatureSet] = source.Mapping242+ }243+ }244+ rasterIDs := make(map[string]bool)245+ for _, raster := range project.Rasters {246+ if err := validateRaster(raster); err != nil {247+ return err248+ }249+ if rasterIDs[raster.ID] {250+ return fmt.Errorf("raster %s is configured twice", raster.ID)251+ }252+ rasterIDs[raster.ID] = true253+ }254+ return validatePresentation(project.Presentation, sets)255+}256+257+func validateTarget(target Target) error {258+ if err := vnext.ValidSlug(target.World); err != nil {259+ return fmt.Errorf("target world: %w", err)260+ }261+ space := target.CoordinateSpace262+ if target.Title == "" || space.ID == "" || space.Kind == "" || space.Unit == "" || space.Definition == "" {263+ return fmt.Errorf("target requires a world title and complete coordinate space")264+ }265+ if space.Extent[2] <= space.Extent[0] || space.Extent[3] <= space.Extent[1] {266+ return fmt.Errorf("target coordinate extent is empty")267+ }268+ if space.SourceZoom < 0 || space.FirstTile < 0 || space.TileSize < 0 || space.Size < 0 {269+ return fmt.Errorf("target coordinate grid metadata is invalid")270+ }271+ return nil272+}273+274+func validateSource(source Source) error {275+ if err := vnext.ValidSlug(source.ID); err != nil {276+ return fmt.Errorf("source identity: %w", err)277+ }278+ if source.Adapter == "" || source.Locator == "" {279+ return fmt.Errorf("source %s requires an adapter and locator", source.ID)280+ }281+ if source.EstimateBytes < 0 {282+ return fmt.Errorf("source %s has a negative size estimate", source.ID)283+ }284+ mapping := source.Mapping285+ if err := vnext.ValidSlug(mapping.FeatureSet); err != nil {286+ return fmt.Errorf("source %s feature set: %w", source.ID, err)287+ }288+ if mapping.Title == "" || mapping.SemanticType == "" || mapping.Identity == "" || mapping.FeatureTitle == "" {289+ return fmt.Errorf("source %s has an incomplete mapping", source.ID)290+ }291+ fields := make(map[string]string)292+ for _, field := range mapping.Fields {293+ if field.ID == "" || field.Source == "" || field.Type == "" {294+ return fmt.Errorf("source %s has an incomplete field mapping", source.ID)295+ }296+ if _, err := kindOf(field.Type); err != nil {297+ return fmt.Errorf("source %s field %s: %w", source.ID, field.ID, err)298+ }299+ if held := fields[field.ID]; held != "" && held != field.Type {300+ return fmt.Errorf("source %s field %s has conflicting types", source.ID, field.ID)301+ }302+ fields[field.ID] = field.Type303+ }304+ for _, relation := range mapping.Relations {305+ if relation.Predicate == "" || relation.Target == "" {306+ return fmt.Errorf("source %s has an incomplete relationship mapping", source.ID)307+ }308+ }309+ return nil310+}311+312+func validateRaster(raster Raster) error {313+ if err := vnext.ValidSlug(raster.ID); err != nil {314+ return fmt.Errorf("raster identity: %w", err)315+ }316+ if raster.Name == "" || raster.Adapter == "" || raster.Locator == "" || raster.TileSize <= 0 || raster.EstimateBytes < 0 {317+ return fmt.Errorf("raster %s is incomplete", raster.ID)318+ }319+ switch raster.Adapter {320+ case "raster-file":321+ if len(raster.Levels) != 0 {322+ return fmt.Errorf("raster-file %s must not declare xyz levels", raster.ID)323+ }324+ case "xyz":325+ if len(raster.Levels) == 0 || !strings.Contains(raster.Locator, "{z}") || !strings.Contains(raster.Locator, "{x}") || !strings.Contains(raster.Locator, "{y}") {326+ return fmt.Errorf("xyz raster %s requires a template and explicit levels", raster.ID)327+ }328+ previous := int64(-1)329+ for _, level := range raster.Levels {330+ if level.Zoom <= previous || level.MinX < 0 || level.MinY < 0 || level.MaxX < level.MinX || level.MaxY < level.MinY {331+ return fmt.Errorf("xyz raster %s has an invalid or unordered level", raster.ID)332+ }333+ previous = level.Zoom334+ }335+ default:336+ return fmt.Errorf("raster %s uses unknown adapter %q", raster.ID, raster.Adapter)337+ }338+ return nil339+}340+341+func validatePresentation(presentation Presentation, sets map[string]Mapping) error {342+ if presentation.ID == "" || presentation.Title == "" {343+ return fmt.Errorf("presentation requires an ID and title")344+ }345+ styles := make(map[string]bool)346+ for _, style := range presentation.Styles {347+ if style.ID == "" || styles[style.ID] {348+ return fmt.Errorf("presentation has an invalid or duplicate style")349+ }350+ styles[style.ID] = true351+ }352+ layers := make(map[string]bool)353+ for _, layer := range presentation.Layers {354+ if layer.ID == "" || layers[layer.ID] || sets[layer.FeatureSet].FeatureSet == "" || !styles[layer.Style] || layer.Label == "" || layer.MaxZoom < layer.MinZoom {355+ return fmt.Errorf("presentation layer %s is invalid", layer.ID)356+ }357+ layers[layer.ID] = true358+ }359+ return nil360+}361+362+func kindOf(name string) (vnext.Kind, error) {363+ switch name {364+ case "bool":365+ return vnext.KindBool, nil366+ case "int64":367+ return vnext.KindInt64, nil368+ case "float64":369+ return vnext.KindFloat64, nil370+ case "string":371+ return vnext.KindString, nil372+ case "bytes":373+ return vnext.KindBytes, nil374+ case "id":375+ return vnext.KindID, nil376+ default:377+ return vnext.KindInvalid, fmt.Errorf("unknown type %q", name)378+ }379+}380+381+func isRemote(locator string) bool {382+ return strings.HasPrefix(locator, "https://") || strings.HasPrefix(locator, "http://")383+}@@ -0,0 +1,111 @@
1+package authoring2+3+import (4+ "os"5+ "path/filepath"6+ "strings"7+ "testing"8+)9+10+const sampleProject = `schema: atlas-project/v111+schema-namespace: example.invalid/atlas/sample-region12+id: sample-region13+title: Sample Region14+target:15+ world: sample-region16+ title: Sample Region17+ coordinate-space:18+ id: sample-region/space19+ kind: projected20+ unit: pixel21+ definition: atlas:tile-plane22+ extent: [0, 0, 256, 256]23+ tile-size: 25624+ size: 25625+sources:26+ - id: sample-features27+ adapter: geojson28+ locator: sample-region.geojson29+ license: CC0-1.030+ attribution: Sample publisher31+ mapping:32+ feature-set: places33+ title: Places34+ semantic-type: sample.place35+ identity: properties.id36+ feature-title: properties.name37+ fields:38+ - id: sample.kind39+ source: properties.kind40+ type: string41+presentation:42+ id: sample-region/presentation43+ title: Sample Region44+ styles:45+ - id: place-style46+ symbol: circle47+ fill: '#5588AA'48+ layers:49+ - id: places-layer50+ feature-set: places51+ style: place-style52+ label: Places53+ visible: true54+ order: 055+ max-zoom: 3256+`57+58+func writeSampleProject(t *testing.T, body string) string {59+ t.Helper()60+ path := filepath.Join(t.TempDir(), "sample-region"+ProjectExtension)61+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {62+ t.Fatal(err)63+ }64+ return path65+}66+67+func TestLoadProject(t *testing.T) {68+ path := writeSampleProject(t, sampleProject)69+ project, err := LoadProject(path)70+ if err != nil {71+ t.Fatal(err)72+ }73+ if project.ID != "sample-region" || project.Target.World != "sample-region" || len(project.Sources) != 1 {74+ t.Fatalf("unexpected project: %+v", project)75+ }76+ want := filepath.Join(filepath.Dir(path), "sample-region.geojson")77+ if got := project.ResolveLocator(project.Sources[0].Locator); got != want {78+ t.Fatalf("resolved locator %q, want %q", got, want)79+ }80+}81+82+func TestLoadProjectRefusesUnknownFields(t *testing.T) {83+ path := writeSampleProject(t, strings.Replace(sampleProject, "title: Sample Region\n", "title: Sample Region\nunknown: true\n", 1))84+ if _, err := LoadProject(path); err == nil || !strings.Contains(err.Error(), "field unknown not found") {85+ t.Fatalf("LoadProject = %v, want unknown-field refusal", err)86+ }87+}88+89+func TestProjectRequiresManifestExtension(t *testing.T) {90+ path := filepath.Join(t.TempDir(), "sample-region.yaml")91+ if err := os.WriteFile(path, []byte(sampleProject), 0o644); err != nil {92+ t.Fatal(err)93+ }94+ if _, err := LoadProject(path); err == nil || !strings.Contains(err.Error(), ProjectExtension) {95+ t.Fatalf("LoadProject = %v, want extension refusal", err)96+ }97+}98+99+func TestProjectRejectsConflictingFeatureSets(t *testing.T) {100+ project, err := LoadProject(writeSampleProject(t, sampleProject))101+ if err != nil {102+ t.Fatal(err)103+ }104+ second := project.Sources[0]105+ second.ID = "sample-features-two"106+ second.Mapping.Title = "Different title"107+ project.Sources = append(project.Sources, second)108+ if err := project.Validate(); err == nil || !strings.Contains(err.Error(), "conflicting declarations") {109+ t.Fatalf("Validate = %v, want feature-set conflict", err)110+ }111+}@@ -0,0 +1,249 @@
1+package authoring2+3+import (4+ "bytes"5+ "encoding/base64"6+ "encoding/json"7+ "fmt"8+ "math"9+ "sort"10+ "strconv"11+ "strings"12+13+ "github.com/FelineStateMachine/atlas/format/vnext"14+)15+16+type observation struct {17+ SourceOrder int18+ Source string19+ NativeID string20+ FeatureSet string21+ SetTitle string22+ Semantic string23+ Title string24+ Geometry vnext.Geometry25+ Fields map[string]vnext.Field26+ Values map[string]vnext.Value27+ Relations []observedRelation28+ Evidence Capture29+}30+31+type observedRelation struct {32+ Predicate string33+ FeatureSet string34+ NativeID string35+}36+37+type geoJSON struct {38+ Type string `json:"type"`39+ Features []geoJSONFeature `json:"features"`40+}41+42+type geoJSONFeature struct {43+ Type string `json:"type"`44+ ID any `json:"id"`45+ Properties map[string]any `json:"properties"`46+ Geometry geoJSONGeometry `json:"geometry"`47+}48+49+type geoJSONGeometry struct {50+ Type string `json:"type"`51+ Coordinates json.RawMessage `json:"coordinates"`52+}53+54+func observe(project Project, sourceOrder int, source Source, capture Capture, body []byte) ([]observation, error) {55+ decoder := json.NewDecoder(bytes.NewReader(body))56+ decoder.UseNumber()57+ var collection geoJSON58+ if err := decoder.Decode(&collection); err != nil {59+ return nil, fmt.Errorf("source %s decode GeoJSON: %w", source.ID, err)60+ }61+ if collection.Type != "FeatureCollection" {62+ return nil, fmt.Errorf("source %s is %q, want FeatureCollection", source.ID, collection.Type)63+ }64+ out := make([]observation, 0, len(collection.Features))65+ for index, feature := range collection.Features {66+ if feature.Type != "" && feature.Type != "Feature" {67+ return nil, fmt.Errorf("source %s feature %d has type %q", source.ID, index, feature.Type)68+ }69+ nativeValue, held := featureValue(feature, source.Mapping.Identity)70+ if !held {71+ return nil, fmt.Errorf("source %s feature %d has no identity at %s", source.ID, index, source.Mapping.Identity)72+ }73+ nativeID := scalarString(nativeValue)74+ if nativeID == "" {75+ return nil, fmt.Errorf("source %s feature %d has an empty identity", source.ID, index)76+ }77+ titleValue, held := featureValue(feature, source.Mapping.FeatureTitle)78+ if !held || scalarString(titleValue) == "" {79+ return nil, fmt.Errorf("source %s feature %s has no title at %s", source.ID, nativeID, source.Mapping.FeatureTitle)80+ }81+ geometry, err := geometryOf(feature.Geometry)82+ if err != nil {83+ return nil, fmt.Errorf("source %s feature %s: %w", source.ID, nativeID, err)84+ }85+ item := observation{86+ SourceOrder: sourceOrder, Source: source.ID, NativeID: nativeID,87+ FeatureSet: source.Mapping.FeatureSet, SetTitle: source.Mapping.Title,88+ Semantic: source.Mapping.SemanticType, Title: scalarString(titleValue),89+ Geometry: geometry, Fields: make(map[string]vnext.Field), Values: make(map[string]vnext.Value),90+ Evidence: capture,91+ }92+ for _, mapping := range source.Mapping.Fields {93+ raw, held := featureValue(feature, mapping.Source)94+ if !held || raw == nil {95+ if mapping.Optional {96+ continue97+ }98+ return nil, fmt.Errorf("source %s feature %s omits required field %s", source.ID, nativeID, mapping.ID)99+ }100+ kind, _ := kindOf(mapping.Type)101+ value, err := valueOf(project.SchemaNamespace, kind, raw)102+ if err != nil {103+ return nil, fmt.Errorf("source %s feature %s field %s: %w", source.ID, nativeID, mapping.ID, err)104+ }105+ name := mapping.Name106+ if name == "" {107+ name = mapping.ID108+ }109+ item.Fields[mapping.ID] = vnext.Field{110+ ID: vnext.IDFromName(project.SchemaNamespace, "feature."+mapping.ID),111+ Name: name, Kind: kind, Optional: mapping.Optional,112+ }113+ item.Values[mapping.ID] = value114+ }115+ for _, mapping := range source.Mapping.Relations {116+ raw, held := featureValue(feature, mapping.Target)117+ if !held || scalarString(raw) == "" {118+ continue119+ }120+ set := mapping.FeatureSet121+ if set == "" {122+ set = source.Mapping.FeatureSet123+ }124+ item.Relations = append(item.Relations, observedRelation{Predicate: mapping.Predicate, FeatureSet: set, NativeID: scalarString(raw)})125+ }126+ out = append(out, item)127+ }128+ sort.Slice(out, func(i, j int) bool { return out[i].NativeID < out[j].NativeID })129+ return out, nil130+}131+132+func featureValue(feature geoJSONFeature, path string) (any, bool) {133+ switch path {134+ case "id":135+ return feature.ID, feature.ID != nil136+ }137+ const prefix = "properties."138+ if !strings.HasPrefix(path, prefix) {139+ return nil, false140+ }141+ value, held := feature.Properties[strings.TrimPrefix(path, prefix)]142+ return value, held143+}144+145+func scalarString(value any) string {146+ switch held := value.(type) {147+ case string:148+ return strings.TrimSpace(held)149+ case json.Number:150+ return held.String()151+ case float64:152+ return strconv.FormatFloat(held, 'g', -1, 64)153+ case bool:154+ return strconv.FormatBool(held)155+ default:156+ return ""157+ }158+}159+160+func valueOf(namespace string, kind vnext.Kind, raw any) (vnext.Value, error) {161+ switch kind {162+ case vnext.KindString:163+ value := scalarString(raw)164+ if value == "" {165+ return vnext.Value{}, fmt.Errorf("value is not a scalar string")166+ }167+ return vnext.StringValue(value), nil168+ case vnext.KindBool:169+ if value, ok := raw.(bool); ok {170+ return vnext.BoolValue(value), nil171+ }172+ value, err := strconv.ParseBool(scalarString(raw))173+ if err != nil {174+ return vnext.Value{}, err175+ }176+ return vnext.BoolValue(value), nil177+ case vnext.KindInt64:178+ value, err := strconv.ParseInt(scalarString(raw), 10, 64)179+ if err != nil {180+ return vnext.Value{}, err181+ }182+ return vnext.Int64Value(value), nil183+ case vnext.KindFloat64:184+ value, err := strconv.ParseFloat(scalarString(raw), 64)185+ if err != nil || math.IsNaN(value) || math.IsInf(value, 0) {186+ return vnext.Value{}, fmt.Errorf("value is not a finite float64")187+ }188+ return vnext.Float64Value(value), nil189+ case vnext.KindBytes:190+ value, err := base64.StdEncoding.DecodeString(scalarString(raw))191+ if err != nil {192+ return vnext.Value{}, err193+ }194+ return vnext.BytesValue(value), nil195+ case vnext.KindID:196+ value := scalarString(raw)197+ if value == "" {198+ return vnext.Value{}, fmt.Errorf("value is not an identity")199+ }200+ return vnext.IDValue(vnext.IDFromName(namespace, value)), nil201+ default:202+ return vnext.Value{}, fmt.Errorf("unsupported value type")203+ }204+}205+206+func geometryOf(source geoJSONGeometry) (vnext.Geometry, error) {207+ switch source.Type {208+ case "Point":209+ var point vnext.Position210+ if err := json.Unmarshal(source.Coordinates, &point); err != nil {211+ return vnext.Geometry{}, err212+ }213+ return vnext.Geometry{Kind: vnext.GeometryPoint, Parts: []vnext.GeometryPart{{Rings: [][]vnext.Position{{point}}}}}, nil214+ case "LineString":215+ var line []vnext.Position216+ if err := json.Unmarshal(source.Coordinates, &line); err != nil {217+ return vnext.Geometry{}, err218+ }219+ return vnext.Geometry{Kind: vnext.GeometryLineString, Parts: []vnext.GeometryPart{{Rings: [][]vnext.Position{line}}}}, nil220+ case "MultiLineString":221+ var lines [][]vnext.Position222+ if err := json.Unmarshal(source.Coordinates, &lines); err != nil {223+ return vnext.Geometry{}, err224+ }225+ geometry := vnext.Geometry{Kind: vnext.GeometryLineString}226+ for _, line := range lines {227+ geometry.Parts = append(geometry.Parts, vnext.GeometryPart{Rings: [][]vnext.Position{line}})228+ }229+ return geometry, nil230+ case "Polygon":231+ var rings [][]vnext.Position232+ if err := json.Unmarshal(source.Coordinates, &rings); err != nil {233+ return vnext.Geometry{}, err234+ }235+ return vnext.Geometry{Kind: vnext.GeometryPolygon, Parts: []vnext.GeometryPart{{Rings: rings}}}, nil236+ case "MultiPolygon":237+ var polygons [][][]vnext.Position238+ if err := json.Unmarshal(source.Coordinates, &polygons); err != nil {239+ return vnext.Geometry{}, err240+ }241+ geometry := vnext.Geometry{Kind: vnext.GeometryPolygon}242+ for _, rings := range polygons {243+ geometry.Parts = append(geometry.Parts, vnext.GeometryPart{Rings: rings})244+ }245+ return geometry, nil246+ default:247+ return vnext.Geometry{}, fmt.Errorf("unsupported GeoJSON geometry %q", source.Type)248+ }249+}@@ -0,0 +1,224 @@
1+package authoring2+3+import (4+ "crypto/sha256"5+ "encoding/hex"6+ "encoding/json"7+ "fmt"8+ "net/url"9+ "path/filepath"10+ "sort"11+ "strconv"12+ "strings"13+)14+15+type RequestKind string16+17+const (18+ RequestFeatures RequestKind = "features"19+ RequestRaster RequestKind = "raster"20+)21+22+// Request is one exact acquisition planned without touching the network.23+type Request struct {24+ ID string `json:"id"`25+ Kind RequestKind `json:"kind"`26+ Source string `json:"source"`27+ Adapter string `json:"adapter"`28+ Locator string `json:"-"`29+ IdentityLocator string `json:"locator"`30+ MediaType string `json:"mediaType,omitempty"`31+ License string `json:"license,omitempty"`32+ Attribution string `json:"attribution,omitempty"`33+ Raster string `json:"raster,omitempty"`34+ Zoom int64 `json:"zoom,omitempty"`35+ X int64 `json:"x,omitempty"`36+ Y int64 `json:"y,omitempty"`37+}38+39+type PlannedRequest struct {40+ Request41+ Cached bool `json:"cached"`42+ EstimateBytes int64 `json:"estimateBytes,omitempty"`43+}44+45+// Plan is the complete inspectable work graph for one build.46+type Plan struct {47+ Project string `json:"project"`48+ Title string `json:"title"`49+ ProjectDigest string `json:"projectDigest"`50+ World string `json:"world"`51+ Requests []PlannedRequest `json:"requests"`52+ Estimated int64 `json:"estimatedBytes"`53+ Cached int `json:"cachedRequests"`54+ Licenses []Obligation `json:"licenses,omitempty"`55+ Output string `json:"output"`56+}57+58+type Obligation struct {59+ Source string `json:"source"`60+ License string `json:"license,omitempty"`61+ Attribution string `json:"attribution,omitempty"`62+}63+64+func PlanProject(project Project, cache *Cache, output string) (Plan, error) {65+ digest, err := projectDigest(project)66+ if err != nil {67+ return Plan{}, err68+ }69+ plan := Plan{70+ Project: project.ID, Title: project.Title, ProjectDigest: digest,71+ World: project.Target.World, Output: output,72+ }73+ for _, source := range project.Sources {74+ request, err := featureRequest(project, source)75+ if err != nil {76+ return Plan{}, err77+ }78+ planned := PlannedRequest{Request: request, EstimateBytes: source.EstimateBytes}79+ planned.Cached = cache != nil && cache.Has(request.ID)80+ plan.Requests = append(plan.Requests, planned)81+ plan.Estimated += source.EstimateBytes82+ plan.Licenses = append(plan.Licenses, Obligation{Source: source.ID, License: source.License, Attribution: source.Attribution})83+ }84+ for _, raster := range project.Rasters {85+ requests, err := rasterRequests(project, raster)86+ if err != nil {87+ return Plan{}, err88+ }89+ each := int64(0)90+ if len(requests) > 0 {91+ each = raster.EstimateBytes / int64(len(requests))92+ }93+ for index, request := range requests {94+ estimate := each95+ if index == len(requests)-1 {96+ estimate += raster.EstimateBytes - each*int64(len(requests))97+ }98+ planned := PlannedRequest{Request: request, EstimateBytes: estimate}99+ planned.Cached = cache != nil && cache.Has(request.ID)100+ plan.Requests = append(plan.Requests, planned)101+ }102+ plan.Estimated += raster.EstimateBytes103+ plan.Licenses = append(plan.Licenses, Obligation{Source: raster.ID, License: raster.License, Attribution: raster.Attribution})104+ }105+ sort.Slice(plan.Requests, func(i, j int) bool { return plan.Requests[i].ID < plan.Requests[j].ID })106+ for _, request := range plan.Requests {107+ if request.Cached {108+ plan.Cached++109+ }110+ }111+ sort.Slice(plan.Licenses, func(i, j int) bool { return plan.Licenses[i].Source < plan.Licenses[j].Source })112+ return plan, nil113+}114+115+func featureRequest(project Project, source Source) (Request, error) {116+ identity := source.Locator117+ locator := project.ResolveLocator(identity)118+ switch source.Adapter {119+ case "geojson":120+ case "arcgis-feature-service":121+ parsed, err := url.Parse(locator)122+ if err != nil {123+ return Request{}, fmt.Errorf("source %s locator: %w", source.ID, err)124+ }125+ path := strings.TrimSuffix(parsed.Path, "/")126+ if source.Query.Layer > 0 && filepath.Base(path) != strconv.Itoa(source.Query.Layer) {127+ path += "/" + strconv.Itoa(source.Query.Layer)128+ }129+ if !strings.HasSuffix(path, "/query") {130+ path += "/query"131+ }132+ parsed.Path = path133+ query := parsed.Query()134+ query.Set("f", "geojson")135+ query.Set("outFields", "*")136+ query.Set("returnGeometry", "true")137+ where := source.Query.Where138+ if where == "" {139+ where = "1=1"140+ }141+ query.Set("where", where)142+ parsed.RawQuery = query.Encode()143+ locator, identity = parsed.String(), parsed.String()144+ case "ogc-api-features":145+ if source.Query.Collection == "" {146+ return Request{}, fmt.Errorf("source %s requires query.collection", source.ID)147+ }148+ parsed, err := url.Parse(locator)149+ if err != nil {150+ return Request{}, fmt.Errorf("source %s locator: %w", source.ID, err)151+ }152+ parsed.Path = strings.TrimSuffix(parsed.Path, "/") + "/collections/" + url.PathEscape(source.Query.Collection) + "/items"153+ query := parsed.Query()154+ query.Set("bbox", extentString(project.Target.CoordinateSpace.Extent))155+ query.Set("f", "json")156+ if source.Query.Limit > 0 {157+ query.Set("limit", strconv.Itoa(source.Query.Limit))158+ }159+ parsed.RawQuery = query.Encode()160+ locator, identity = parsed.String(), parsed.String()161+ default:162+ return Request{}, fmt.Errorf("source %s uses unknown adapter %q", source.ID, source.Adapter)163+ }164+ request := Request{165+ Kind: RequestFeatures, Source: source.ID, Adapter: source.Adapter,166+ Locator: locator, IdentityLocator: identity, MediaType: source.MediaType,167+ License: source.License, Attribution: source.Attribution,168+ }169+ request.ID = requestID(request)170+ return request, nil171+}172+173+func rasterRequests(project Project, raster Raster) ([]Request, error) {174+ makeRequest := func(locator, identity string, zoom, x, y int64) Request {175+ request := Request{176+ Kind: RequestRaster, Source: raster.ID, Adapter: raster.Adapter,177+ Locator: locator, IdentityLocator: identity, MediaType: raster.MediaType,178+ License: raster.License, Attribution: raster.Attribution,179+ Raster: raster.ID, Zoom: zoom, X: x, Y: y,180+ }181+ request.ID = requestID(request)182+ return request183+ }184+ if raster.Adapter == "raster-file" {185+ return []Request{makeRequest(project.ResolveLocator(raster.Locator), raster.Locator, 0, 0, 0)}, nil186+ }187+ var requests []Request188+ for _, level := range raster.Levels {189+ for y := level.MinY; y <= level.MaxY; y++ {190+ for x := level.MinX; x <= level.MaxX; x++ {191+ identity := strings.NewReplacer("{z}", strconv.FormatInt(level.Zoom, 10), "{x}", strconv.FormatInt(x, 10), "{y}", strconv.FormatInt(y, 10)).Replace(raster.Locator)192+ requests = append(requests, makeRequest(project.ResolveLocator(identity), identity, level.Zoom, x, y))193+ }194+ }195+ }196+ return requests, nil197+}198+199+func requestID(request Request) string {200+ canonical := struct {201+ Kind, Source, Adapter, Locator, Raster string202+ Zoom, X, Y int64203+ }{string(request.Kind), request.Source, request.Adapter, request.IdentityLocator, request.Raster, request.Zoom, request.X, request.Y}204+ data, _ := json.Marshal(canonical)205+ digest := sha256.Sum256(data)206+ return hex.EncodeToString(digest[:])207+}208+209+func projectDigest(project Project) (string, error) {210+ data, err := json.Marshal(project)211+ if err != nil {212+ return "", fmt.Errorf("canonicalize project: %w", err)213+ }214+ digest := sha256.Sum256(data)215+ return hex.EncodeToString(digest[:]), nil216+}217+218+func extentString(extent [4]float64) string {219+ values := make([]string, len(extent))220+ for index, value := range extent {221+ values[index] = strconv.FormatFloat(value, 'g', -1, 64)222+ }223+ return strings.Join(values, ",")224+}@@ -23,7 +23,7 @@ import (
2323 // between two lanes that must not depend on each other. `atlas workbench` --2424 // which may import both -- is what hands it over.2525 26-//go:embed templates/*.tmpl assets/*.css26+//go:embed templates/*.tmpl assets/*.css assets/*.js2727 var files embed.FS2828 2929 // stylesheets is the cascade, in order: the tokens first, because everything@@ -56,3 +56,8 @@ func stylesheet() []byte {
5656 })5757 return sheet5858 }59+60+func behavior() []byte {61+ body, _ := fs.ReadFile(files, "assets/workbench.js")62+ return body63+}@@ -222,6 +222,42 @@ input:focus-visible, select:focus-visible, button:focus-visible {
222222 outline-offset: 1px;223223 }224224 225+textarea {226+ width: 100%;227+ min-height: 32rem;228+ resize: vertical;229+ font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;230+ color: var(--text);231+ background: var(--bg);232+ border: 1px solid var(--line-strong);233+ padding: 14px;234+}235+236+.manifest-editor form { display: block; }237+.manifest-editor button { margin-top: 10px; }238+.eyebrow { color: var(--accent); text-transform: uppercase; letter-spacing: .12em; font-size: 11px; margin: 0 0 3px; }239+.notice { color: var(--accent); border-left: 2px solid var(--accent); padding: 8px 12px; }240+.project-flow { display: flex; align-items: stretch; gap: 8px; margin: 0 0 16px; overflow-x: auto; }241+.project-flow div { min-width: 130px; flex: 1; border: 1px solid var(--line); background: var(--panel); padding: 12px 14px; }242+.project-flow div.accent { border-color: var(--accent); }243+.project-flow b, .project-flow span { display: block; }244+.project-flow b { color: var(--data); font-size: 18px; }245+.project-flow span { color: var(--muted); font-size: 11px; }246+.project-flow i { align-self: center; color: var(--muted); font-style: normal; }247+.project-columns { grid-template-columns: minmax(360px, 1fr) minmax(420px, 1.2fr); }248+.build-control form label { display: flex; gap: 5px; align-items: center; }249+.build-control input[type="checkbox"] { min-width: 0; }250+.run-state { border-left: 2px solid var(--accent); padding-left: 8px; }251+.run-state.failed { border-color: #d98a72; color: #d98a72; }252+.artifact-handoff { margin-top: 14px; padding: 12px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border: 1px dashed var(--accent); background: var(--accent-wash); cursor: grab; }253+.artifact-handoff:active { cursor: grabbing; }254+.artifact-handoff form { margin: 0; }255+.file-icon { display: inline-grid; place-items: center; width: 30px; height: 36px; margin-right: 10px; color: var(--accent-ink); background: var(--accent); font-weight: 700; float: left; }256+257+@media (max-width: 850px) {258+ .project-columns { grid-template-columns: 1fr; }259+}260+225261 /* the console: one row per line the operation said ----------------------- */226262 227263 .console {@@ -0,0 +1,11 @@
1+document.addEventListener("dragstart", (event) => {2+ const handoff = event.target.closest("[data-artifact-handoff]");3+ if (!handoff || !event.dataTransfer) return;4+5+ const endpoint = new URL("/project/artifact", window.location.href).href;6+ const name = handoff.dataset.artifactName || "volume.atlas";7+ event.dataTransfer.effectAllowed = "copy";8+ event.dataTransfer.setData("DownloadURL", `application/octet-stream:${name}:${endpoint}`);9+ event.dataTransfer.setData("text/uri-list", endpoint);10+ event.dataTransfer.setData("text/plain", handoff.dataset.artifactPath || name);11+});@@ -3,6 +3,7 @@ package workbench
33 import (44 "errors"55 "fmt"6+ "sort"67 "strings"78 89 "github.com/FelineStateMachine/atlas/internal/workbench/oprunner"@@ -36,6 +37,11 @@ type Targets struct {
3637 // Registry is the library of .atlas files: what the measurement pages read3738 // and what compose, enrich and measure are pointed at.3839 Registry string40+ // Cache is the shared content-addressed evidence cache used by every build.41+ Cache string42+ // Project is the one portable manifest this workbench is allowed to edit,43+ // plan, and build. It is fixed at startup and never accepted from a request.44+ Project string3945 // Archive is the capture archive root -- the directory holding archive.json.4046 Archive string4147 // TileSet is the derived tile set directory, which `atlas tiles` writes.@@ -46,6 +52,32 @@ type Targets struct {
4652 TileIndex string4753 }4854 55+// buildOperation is the only authoring command the vNext workbench exposes.56+// The manifest, cache and library are startup authority, not form values.57+func buildOperation(targets Targets, offline bool) (oprunner.Operation, error) {58+ var missing []string59+ for name, value := range map[string]string{60+ "atlas binary": targets.Atlas,61+ "project": targets.Project,62+ "cache": targets.Cache,63+ "library": targets.Registry,64+ } {65+ if value == "" {66+ missing = append(missing, name)67+ }68+ }69+ if len(missing) > 0 {70+ sort.Strings(missing)71+ return oprunner.Operation{}, fmt.Errorf("build cannot run here: no %s configured", strings.Join(missing, ", no "))72+ }73+ argv := []string{targets.Atlas, "build", "--log-json", "-cache", targets.Cache, "-bundles", targets.Registry}74+ if offline {75+ argv = append(argv, "-offline")76+ }77+ argv = append(argv, targets.Project)78+ return oprunner.Operation{Name: "build", Dir: targets.Dir, Argv: argv}, nil79+}80+4981 // An operation is one pipeline subcommand the page may run.5082 type operation struct {5183 // Name is the operation as the form submits it and the CLI spells it.@@ -4,275 +4,237 @@ import (
44 "io"55 "net/http"66 "net/url"7+ "os"78 "path/filepath"89 "reflect"910 "strings"1011 "testing"12+ "time"1113 )1214 13-func testTargets() Targets {14- return Targets{15- Atlas: "/opt/atlas",16- Dir: "/depot",17- Registry: "/library/bundles",18- Archive: "/depot/archive",19- TileSet: "/depot/tiles",20- TileIndex: "/depot/tiles/index.json",15+func writeSampleProject(t *testing.T, dir string) string {16+ t.Helper()17+ source := filepath.Join(dir, "sample-region.geojson")18+ if err := os.WriteFile(source, []byte(`{"type":"FeatureCollection","features":[{"type":"Feature","id":"marker-1","properties":{"id":"marker-1","name":"Sample marker"},"geometry":{"type":"Point","coordinates":[1,1]}}]}`), 0o644); err != nil {19+ t.Fatal(err)2120 }22-}23-24-func TestPlanIsTheCommandAPersonWouldHaveTyped(t *testing.T) {25- targets := testTargets()26- sources := testSources()27- cases := []struct {28- what string29- ask request30- want []string31- }{32- {33- what: "a crawl of one source and target",34- ask: request{Operation: "crawl", Source: "ign", Target: "cyberpunk-2077/night-city"},35- want: []string{"/opt/atlas", "crawl", "--log-json", "-archive", "/depot/archive",36- "-source", "ign", "cyberpunk-2077/night-city"},37- },38- {39- what: "every pyramid the archive holds",40- ask: request{Operation: "tiles"},41- want: []string{"/opt/atlas", "tiles", "--log-json",42- "-archive", "/depot/archive", "-output", "/depot/tiles"},43- },44- {45- what: "composition into the registry the pages read",46- ask: request{Operation: "compose"},47- want: []string{"/opt/atlas", "compose", "--log-json", "-archive", "/depot/archive",48- "-tiles", "/depot/tiles/index.json", "-bundles", "/library/bundles"},49- },50- {51- what: "enrichment of one volume",52- ask: request{Operation: "enrich", Volume: "cyberpunk-2077"},53- want: []string{"/opt/atlas", "enrich", "--log-json", "-archive", "/depot/archive",54- "-tiles", "/depot/tiles/index.json", "-bundles", "/library/bundles", "cyberpunk-2077"},55- },56- {57- what: "a measurement of the registry",58- ask: request{Operation: "measure"},59- want: []string{"/opt/atlas", "measure", "--log-json", "-bundles", "/library/bundles"},60- },61- }62- for _, c := range cases {63- t.Run(c.what, func(t *testing.T) {64- op, err := plan(targets, sources, c.ask)65- if err != nil {66- t.Fatal(err)67- }68- if !reflect.DeepEqual(op.Argv, c.want) {69- t.Errorf("argv %v, want %v", op.Argv, c.want)70- }71- if op.Name != c.ask.Operation {72- t.Errorf("the operation is called %q", op.Name)73- }74- if op.Dir != targets.Dir {75- t.Errorf("the operation runs in %q", op.Dir)76- }77- })21+ project := filepath.Join(dir, "sample-region.atlas-project")22+ manifest := `schema: atlas-project/v123+schema-namespace: example.invalid/atlas/sample-region/v124+id: sample-region25+title: Sample Region26+target:27+ world: sample-region28+ title: Sample Region29+ coordinate-space:30+ id: local31+ kind: projected32+ unit: metre33+ definition: local sample grid34+ extent: [0, 0, 100, 100]35+sources:36+ - id: sample-features37+ adapter: geojson38+ locator: sample-region.geojson39+ media-type: application/geo+json40+ license: Example data41+ attribution: Sample Region contributors42+ mapping:43+ feature-set: markers44+ title: Markers45+ semantic-type: place46+ identity: id47+ feature-title: properties.name48+presentation:49+ id: default50+ title: Default51+ styles:52+ - id: marker53+ symbol: circle54+ layers:55+ - id: markers56+ feature-set: markers57+ style: marker58+ label: Markers59+ visible: true60+ order: 161+release:62+ revision: 163+`64+ if err := os.WriteFile(project, []byte(manifest), 0o644); err != nil {65+ t.Fatal(err)7866 }67+ return project7968 }8069 81-func TestPlanRefusesWhatItCannotRun(t *testing.T) {82- targets := testTargets()83- sources := testSources()84- cases := []struct {85- what string86- targets Targets87- ask request88- says string89- }{90- {"an operation nobody wrote", targets, request{Operation: "levitate"}, "unknown operation"},91- {"a source nobody registered", targets, request{Operation: "crawl", Source: "nowhere", Target: "x"}, "unknown source"},92- {93- "a source with no crawler", targets,94- request{Operation: "crawl", Source: "mapgenie", Target: "cyberpunk-2077"},95- "no crawler registered",96- },97- {98- "a target of the wrong shape", targets,99- request{Operation: "crawl", Source: "ign", Target: "night-city"},100- "needs exactly one slash",101- },102- {103- "a target that would read as a flag", targets,104- request{Operation: "crawl", Source: "nasa-trek", Target: "-rf"},105- "starts with a dash",106- },107- {108- "a volume slug that is not one", targets,109- request{Operation: "measure", Volume: "../etc"}, "volume",110- },111- {112- "a volume on an operation that takes none", targets,113- request{Operation: "tiles", Volume: "tunic"}, "does not take a volume",114- },115- {116- "a crawl with no archive configured",117- Targets{Atlas: "/opt/atlas", Registry: "/library/bundles"},118- request{Operation: "crawl", Source: "ign", Target: "a/b"},119- "no archive configured",120- },121- {122- "anything at all with no binary",123- Targets{Registry: "/library/bundles"},124- request{Operation: "measure"},125- "atlas binary",126- },127- }128- for _, c := range cases {129- t.Run(c.what, func(t *testing.T) {130- op, err := plan(c.targets, sources, c.ask)131- if err == nil {132- t.Fatalf("the plan was accepted: %v", op.Argv)133- }134- if !strings.Contains(err.Error(), c.says) {135- t.Errorf("the refusal is %q, and it should name %q", err, c.says)136- }137- })70+func projectTargets(t *testing.T) Targets {71+ t.Helper()72+ root := t.TempDir()73+ return Targets{74+ Atlas: "/opt/atlas", Dir: root, Project: writeSampleProject(t, root),75+ Cache: filepath.Join(root, "cache"), Registry: filepath.Join(root, "library"),13876 }13977 }14078 141-func TestTheOperationsPageSaysWhatItCanAndCannotRun(t *testing.T) {142- // A workbench pointed at a registry alone measures and nothing else, and143- // the page says which operations are unavailable rather than letting a144- // person find out by pressing a button.145- partial, err := New(Options{146- Targets: Targets{Atlas: "/opt/atlas", Registry: t.TempDir()},147- Sources: testSources(),148- })79+func TestBuildOperationUsesOnlyStartupAuthority(t *testing.T) {80+ targets := projectTargets(t)81+ op, err := buildOperation(targets, false)14982 if err != nil {15083 t.Fatal(err)15184 }152- _, body := get(t, site(t, partial), "/operations")153- wants(t, "operations", body,154- "crawl", "tiles", "compose", "enrich", "measure",155- "no archive configured", "/operations/run", "op-log")156-157- whole, err := New(Options{Targets: testTargets(), Sources: testSources()})85+ want := []string{"/opt/atlas", "build", "--log-json", "-cache", targets.Cache, "-bundles", targets.Registry, targets.Project}86+ if !reflect.DeepEqual(op.Argv, want) {87+ t.Fatalf("argv %v, want %v", op.Argv, want)88+ }89+ offline, err := buildOperation(targets, true)15890 if err != nil {15991 t.Fatal(err)16092 }161- _, ready := get(t, site(t, whole), "/operations")162- wants(t, "operations", ready,163- "/depot/archive", "/depot/tiles", "/library/bundles",164- // one form per crawlable source, each with its own target hint165- "IGN Wiki", "NASA Trek", "cyberpunk-2077/night-city", "a body, e.g. mars")166- if strings.Contains(ready, "not configured") {167- t.Error("a fully configured workbench reports something missing")93+ if !strings.Contains(strings.Join(offline.Argv, " "), "-offline") {94+ t.Fatalf("offline argv = %v", offline.Argv)16895 }169- // A source with no crawler is not offered a fetch form.170- if strings.Contains(ready, `value="mapgenie"`) {171- t.Error("a source with no crawler was offered a fetch")96+ missing := targets97+ missing.Project = ""98+ if _, err := buildOperation(missing, false); err == nil || !strings.Contains(err.Error(), "project") {99+ t.Fatalf("missing project refusal = %v", err)172100 }173101 }174102 175-// post submits an operation form the way the page does.176-func post(t *testing.T, server *http.Client, at string, form url.Values, header ...string) (*http.Response, string) {177- t.Helper()178- request, err := http.NewRequest(http.MethodPost, at, strings.NewReader(form.Encode()))103+func TestProjectPageMakesTheNativeBuildGraphVisible(t *testing.T) {104+ targets := projectTargets(t)105+ held, err := New(Options{Targets: targets, Runtime: []byte("/* runtime */")})179106 if err != nil {180107 t.Fatal(err)181108 }182- request.Header.Set("Content-Type", "application/x-www-form-urlencoded")183- for pair := 0; pair+1 < len(header); pair += 2 {184- request.Header.Set(header[pair], header[pair+1])109+ server := site(t, held)110+ response, body := get(t, server, "/project")111+ if response.StatusCode != http.StatusOK {112+ t.Fatalf("project answered %d: %s", response.StatusCode, body)185113 }186- response, err := server.Do(request)114+ wants(t, "project", body,115+ "Sample Region", "One manifest → one native volume", "sample-features", "geojson",116+ "0 / 1 requests", "feature sources", "semantic sets", "presentation layers",117+ "Build native Atlas", "Portable manifest", "Sample Region contributors")118+ if response, _ := get(t, server, "/operations"); response.StatusCode != http.StatusNotFound {119+ t.Errorf("retired operations route answered %d", response.StatusCode)120+ }121+ if response, _ := get(t, server, "/sources"); response.StatusCode != http.StatusNotFound {122+ t.Errorf("retired sources route answered %d", response.StatusCode)123+ }124+}125+126+func TestManifestSaveValidatesBeforeAtomicReplacement(t *testing.T) {127+ targets := projectTargets(t)128+ held, err := New(Options{Targets: targets})187129 if err != nil {188130 t.Fatal(err)189131 }190- t.Cleanup(func() { response.Body.Close() })191- body, err := io.ReadAll(response.Body)132+ server := site(t, held)133+ original, err := os.ReadFile(targets.Project)192134 if err != nil {193135 t.Fatal(err)194136 }195- return response, string(body)137+ response, _ := postForm(t, server.Client(), server.URL+"/project/save", url.Values{"manifest": {"schema: wrong\n"}})138+ if response.StatusCode != http.StatusBadRequest {139+ t.Fatalf("invalid save answered %d", response.StatusCode)140+ }141+ after, _ := os.ReadFile(targets.Project)142+ if string(after) != string(original) {143+ t.Fatal("invalid candidate replaced the manifest")144+ }145+ updated := strings.Replace(string(original), "title: Sample Region", "title: Sample Region Updated", 1)146+ response, _ = postForm(t, noRedirectClient(server.Client()), server.URL+"/project/save", url.Values{"manifest": {updated}})147+ if response.StatusCode != http.StatusSeeOther {148+ t.Fatalf("valid save answered %d", response.StatusCode)149+ }150+ installed, _ := os.ReadFile(targets.Project)151+ if !strings.Contains(string(installed), "Sample Region Updated") {152+ t.Fatal("valid candidate was not installed")153+ }196154 }197155 198-func TestAnOperationIsRefusedBeforeItIsStarted(t *testing.T) {199- held, err := New(Options{Targets: testTargets(), Sources: testSources()})156+func TestBuildSurvivesNavigationAndHandsOffTheArtifact(t *testing.T) {157+ targets := projectTargets(t)158+ if err := os.MkdirAll(targets.Registry, 0o755); err != nil {159+ t.Fatal(err)160+ }161+ artifact := filepath.Join(targets.Registry, "sample-region-abc.atlas")162+ binary := filepath.Join(targets.Dir, "fake-atlas")163+ script := "#!/bin/sh\nsleep 0.05\ntouch '" + artifact + "'\nprintf '%s\\n' '{\"time\":\"2026-01-01T00:00:00Z\",\"level\":\"INFO\",\"msg\":\"native build installed\",\"artifact\":\"" + artifact + "\"}' >&2\n"164+ if err := os.WriteFile(binary, []byte(script), 0o755); err != nil {165+ t.Fatal(err)166+ }167+ targets.Atlas = binary168+ var opened string169+ held, err := New(Options{Targets: targets, OpenArtifact: func(path string) error { opened = path; return nil }})200170 if err != nil {201171 t.Fatal(err)202172 }203173 server := site(t, held)204- at := server.URL + "/operations/run"205-206- // A cross-site POST is turned away before anything is even planned.207- response, _ := post(t, server.Client(), at, url.Values{"op": {"measure"}},208- "Origin", "http://evil.example")209- if response.StatusCode != http.StatusForbidden {210- t.Errorf("a cross-site submission answered %d", response.StatusCode)211- }212- // A same-origin one is not.213- for _, c := range []struct {214- what string215- form url.Values216- want int217- }{218- {"nothing at all", url.Values{}, http.StatusBadRequest},219- {"an operation nobody wrote", url.Values{"op": {"levitate"}}, http.StatusBadRequest},220- {"a bad target", url.Values{"op": {"crawl"}, "source": {"ign"}, "target": {"-rf"}}, http.StatusBadRequest},221- } {222- response, _ := post(t, server.Client(), at, c.form)223- if response.StatusCode != c.want {224- t.Errorf("%s answered %d, want %d", c.what, response.StatusCode, c.want)225- }174+ response, _ := postForm(t, noRedirectClient(server.Client()), server.URL+"/project/build", url.Values{})175+ if response.StatusCode != http.StatusSeeOther {176+ t.Fatalf("build answered %d", response.StatusCode)226177 }227-228- // One operation at a time: while the slot is held, a submission is229- // refused outright rather than queued behind it.230- release, ok := held.runner.Acquire("compose")231- if !ok {232- t.Fatal("the slot was already held")178+ if !held.supervisor.Snapshot().Running {179+ t.Fatal("build was tied to the completed POST instead of continuing")233180 }234- response, body := post(t, server.Client(), at, url.Values{"op": {"measure"}})235- if response.StatusCode != http.StatusConflict {236- t.Fatalf("a second operation answered %d, want 409", response.StatusCode)181+ deadline := time.Now().Add(2 * time.Second)182+ for held.supervisor.Snapshot().Running && time.Now().Before(deadline) {183+ time.Sleep(10 * time.Millisecond)237184 }238- if !strings.Contains(body, "compose") {239- t.Errorf("the refusal does not say what is running: %q", body)185+ run := held.supervisor.Snapshot()186+ if run.Running || run.Failed || run.Artifact != artifact {187+ t.Fatalf("run = %+v", run)188+ }189+ _, body := get(t, server, "/project")190+ wants(t, "completed project", body, "Build complete", "Open in Atlas", "Drag this file onto an Atlas window", filepath.Base(artifact))191+ response, _ = postForm(t, noRedirectClient(server.Client()), server.URL+"/project/open", url.Values{})192+ if response.StatusCode != http.StatusSeeOther || opened != artifact {193+ t.Fatalf("open answered %d and handed off %q", response.StatusCode, opened)194+ }195+ response, _ = get(t, server, "/project/artifact")196+ if response.StatusCode != http.StatusOK {197+ t.Fatalf("drag artifact answered %d", response.StatusCode)240198 }241- release()242199 }243200 244-func TestAnOperationStreamsItsVoiceBackAsRows(t *testing.T) {245- // The binary is a path with nothing at the end of it, so the operation246- // fails at once -- which is the point: its refusal has to arrive through247- // the page as rows rather than as a status code, and that wiring is what248- // is under test here. What a working subprocess sounds like, and that its249- // lines are flushed as they arrive, is oprunner's own suite.250- targets := testTargets()251- targets.Atlas = filepath.Join(t.TempDir(), "atlas")252- targets.Dir = t.TempDir()253-254- held, err := New(Options{Targets: targets, Sources: testSources()})201+func TestProjectMutationsRefuseForeignOrigins(t *testing.T) {202+ held, err := New(Options{Targets: projectTargets(t)})255203 if err != nil {256204 t.Fatal(err)257205 }258206 server := site(t, held)207+ request, _ := http.NewRequest(http.MethodPost, server.URL+"/project/build", nil)208+ request.Header.Set("Origin", "https://foreign.invalid")209+ response, err := server.Client().Do(request)210+ if err != nil {211+ t.Fatal(err)212+ }213+ response.Body.Close()214+ if response.StatusCode != http.StatusForbidden {215+ t.Fatalf("foreign build answered %d", response.StatusCode)216+ }217+}259218 260- response, body := post(t, server.Client(), server.URL+"/operations/run", url.Values{"op": {"measure"}})261- if response.StatusCode != http.StatusOK {262- t.Fatalf("the operation answered %d: %s", response.StatusCode, body)219+func postForm(t *testing.T, client *http.Client, at string, form url.Values) (*http.Response, string) {220+ t.Helper()221+ request, err := http.NewRequest(http.MethodPost, at, strings.NewReader(form.Encode()))222+ if err != nil {223+ t.Fatal(err)263224 }264- if got := response.Header.Get("Content-Type"); !strings.HasPrefix(got, "text/html") {265- t.Errorf("the rows arrived as %q", got)225+ request.Header.Set("Content-Type", "application/x-www-form-urlencoded")226+ response, err := client.Do(request)227+ if err != nil {228+ t.Fatal(err)266229 }267- if got := response.Header.Get("X-Content-Type-Options"); got != "nosniff" {268- t.Errorf("the stream is sniffable: %q", got)230+ body, err := io.ReadAll(response.Body)231+ response.Body.Close()232+ if err != nil {233+ t.Fatal(err)269234 }270- wants(t, "operation stream", body,271- `class="op-row"`, `data-kind="command"`, "measure", "--log-json",272- "/library/bundles", `data-kind="result"`, `data-failed="true"`)235+ return response, string(body)236+}273237 274- // And the slot is free again the moment the operation ends.275- if busy := held.runner.Busy(); busy != "" {276- t.Errorf("the runner still holds %q", busy)277- }238+func noRedirectClient(base *http.Client) *http.Client {239+ return &http.Client{Transport: base.Transport, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }}278240 }@@ -0,0 +1,221 @@
1+package workbench2+3+import (4+ "fmt"5+ "net/http"6+ "os"7+ "path/filepath"8+ "strings"9+10+ "github.com/FelineStateMachine/atlas/internal/authoring"11+ "github.com/FelineStateMachine/atlas/internal/workbench/oprunner"12+)13+14+const maxManifestBytes = 2 << 2015+16+type projectPage struct {17+ Targets Targets18+ Manifest string19+ Project authoring.Project20+ Plan authoring.Plan21+ Error string22+ Notice string23+ Run supervisedRun24+ FeatureSets int25+ SourceCount int26+ RasterCount int27+ LayerCount int28+ CachedCount int29+ RequestCount int30+ ArtifactName string31+}32+33+func (w *Workbench) projectPage(notice string) projectPage {34+ page := projectPage{Targets: w.targets, Notice: notice, Run: w.supervisor.Snapshot()}35+ page.ArtifactName = filepath.Base(page.Run.Artifact)36+ data, err := os.ReadFile(w.targets.Project)37+ if err != nil {38+ page.Error = err.Error()39+ return page40+ }41+ if len(data) > maxManifestBytes {42+ page.Error = "manifest is larger than the editor limit"43+ return page44+ }45+ page.Manifest = string(data)46+ project, err := authoring.LoadProject(w.targets.Project)47+ if err != nil {48+ page.Error = err.Error()49+ return page50+ }51+ page.Project = project52+ plan, err := authoring.PlanProject(project, authoring.OpenCache(w.targets.Cache), w.targets.Registry)53+ if err != nil {54+ page.Error = err.Error()55+ return page56+ }57+ page.Plan = plan58+ page.SourceCount = len(project.Sources)59+ page.RasterCount = len(project.Rasters)60+ page.LayerCount = len(project.Presentation.Layers)61+ page.CachedCount = plan.Cached62+ page.RequestCount = len(plan.Requests)63+ sets := make(map[string]bool)64+ for _, source := range project.Sources {65+ sets[source.Mapping.FeatureSet] = true66+ }67+ page.FeatureSets = len(sets)68+ return page69+}70+71+func (w *Workbench) handleProject(rw http.ResponseWriter, r *http.Request) {72+ w.render(rw, "project", w.projectPage(r.URL.Query().Get("notice")))73+}74+75+func (w *Workbench) handleProjectSave(rw http.ResponseWriter, r *http.Request) {76+ if err := oprunner.CheckOrigin(r); err != nil {77+ http.Error(rw, err.Error(), http.StatusForbidden)78+ return79+ }80+ r.Body = http.MaxBytesReader(rw, r.Body, maxManifestBytes)81+ if err := r.ParseForm(); err != nil {82+ http.Error(rw, err.Error(), http.StatusBadRequest)83+ return84+ }85+ manifest := r.FormValue("manifest")86+ if strings.TrimSpace(manifest) == "" {87+ http.Error(rw, "manifest is empty", http.StatusBadRequest)88+ return89+ }90+ if err := validateAndInstallManifest(w.targets.Project, []byte(manifest)); err != nil {91+ http.Error(rw, err.Error(), http.StatusBadRequest)92+ return93+ }94+ http.Redirect(rw, r, "/project?notice=Manifest+saved", http.StatusSeeOther)95+}96+97+func validateAndInstallManifest(path string, data []byte) error {98+ dir := filepath.Dir(path)99+ stage, err := os.CreateTemp(dir, ".atlas-project-*.atlas-project")100+ if err != nil {101+ return fmt.Errorf("stage manifest: %w", err)102+ }103+ stagePath := stage.Name()104+ defer os.Remove(stagePath)105+ if _, err := stage.Write(data); err != nil {106+ stage.Close()107+ return fmt.Errorf("write staged manifest: %w", err)108+ }109+ if err := stage.Sync(); err != nil {110+ stage.Close()111+ return fmt.Errorf("sync staged manifest: %w", err)112+ }113+ if err := stage.Close(); err != nil {114+ return fmt.Errorf("close staged manifest: %w", err)115+ }116+ if _, err := authoring.LoadProject(stagePath); err != nil {117+ return err118+ }119+ if err := os.Chmod(stagePath, 0o644); err != nil {120+ return fmt.Errorf("prepare staged manifest: %w", err)121+ }122+ if err := os.Rename(stagePath, path); err != nil {123+ return fmt.Errorf("install manifest: %w", err)124+ }125+ return nil126+}127+128+func (w *Workbench) handleProjectBuild(rw http.ResponseWriter, r *http.Request) {129+ if err := oprunner.CheckOrigin(r); err != nil {130+ http.Error(rw, err.Error(), http.StatusForbidden)131+ return132+ }133+ if _, err := authoring.LoadProject(w.targets.Project); err != nil {134+ http.Error(rw, err.Error(), http.StatusBadRequest)135+ return136+ }137+ if err := r.ParseForm(); err != nil {138+ http.Error(rw, err.Error(), http.StatusBadRequest)139+ return140+ }141+ op, err := buildOperation(w.targets, r.FormValue("offline") == "true")142+ if err != nil {143+ http.Error(rw, err.Error(), http.StatusBadRequest)144+ return145+ }146+ if err := w.supervisor.Start(op); err != nil {147+ status := http.StatusInternalServerError148+ if err == oprunner.ErrBusy {149+ status = http.StatusConflict150+ }151+ http.Error(rw, err.Error(), status)152+ return153+ }154+ http.Redirect(rw, r, "/project?notice=Build+started", http.StatusSeeOther)155+}156+157+func (w *Workbench) handleProjectRun(rw http.ResponseWriter, _ *http.Request) {158+ rw.Header().Set("Content-Type", "text/html; charset=utf-8")159+ rw.Header().Set("Cache-Control", "no-store")160+ for _, row := range w.supervisor.Snapshot().Rows {161+ if err := w.writeRow(rw, row); err != nil {162+ return163+ }164+ }165+}166+167+func (w *Workbench) handleProjectOpen(rw http.ResponseWriter, r *http.Request) {168+ if err := oprunner.CheckOrigin(r); err != nil {169+ http.Error(rw, err.Error(), http.StatusForbidden)170+ return171+ }172+ artifact, err := w.currentArtifact()173+ if err != nil {174+ http.Error(rw, err.Error(), http.StatusBadRequest)175+ return176+ }177+ if w.openArtifact == nil {178+ http.Error(rw, "native Atlas handoff is unavailable", http.StatusNotImplemented)179+ return180+ }181+ if err := w.openArtifact(artifact); err != nil {182+ http.Error(rw, err.Error(), http.StatusInternalServerError)183+ return184+ }185+ http.Redirect(rw, r, "/project?notice=Opened+in+Atlas", http.StatusSeeOther)186+}187+188+func (w *Workbench) handleProjectArtifact(rw http.ResponseWriter, r *http.Request) {189+ artifact, err := w.currentArtifact()190+ if err != nil {191+ http.NotFound(rw, r)192+ return193+ }194+ rw.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filepath.Base(artifact)))195+ http.ServeFile(rw, r, artifact)196+}197+198+func (w *Workbench) currentArtifact() (string, error) {199+ artifact := w.supervisor.Snapshot().Artifact200+ if artifact == "" {201+ return "", fmt.Errorf("no completed Atlas artifact is available")202+ }203+ absArtifact, err := filepath.Abs(artifact)204+ if err != nil {205+ return "", err206+ }207+ absLibrary, err := filepath.Abs(w.targets.Registry)208+ if err != nil {209+ return "", err210+ }211+ relative, err := filepath.Rel(absLibrary, absArtifact)212+ if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.Ext(absArtifact) != ".atlas" {213+ return "", fmt.Errorf("build artifact is outside the Atlas library")214+ }215+ file, err := os.Open(absArtifact)216+ if err != nil {217+ return "", err218+ }219+ file.Close()220+ return absArtifact, nil221+}@@ -0,0 +1,97 @@
1+package workbench2+3+import (4+ "context"5+ "sync"6+ "time"7+8+ "github.com/FelineStateMachine/atlas/internal/workbench/oprunner"9+)10+11+// supervisedRun is the durable in-memory view of the latest build. The12+// subprocess belongs to the workbench, not to the HTTP request that started it,13+// so navigating away and returning never cancels or loses its progress.14+type supervisedRun struct {15+ Name string16+ Command string17+ StartedAt time.Time18+ FinishedAt time.Time19+ Running bool20+ Failed bool21+ Rows []oprunner.Row22+ Artifact string23+}24+25+type runSupervisor struct {26+ runner *oprunner.Runner27+ ctx context.Context28+ cancel context.CancelFunc29+ wg sync.WaitGroup30+31+ mu sync.Mutex32+ last supervisedRun33+}34+35+func newRunSupervisor(runner *oprunner.Runner) *runSupervisor {36+ ctx, cancel := context.WithCancel(context.Background())37+ return &runSupervisor{runner: runner, ctx: ctx, cancel: cancel}38+}39+40+func (s *runSupervisor) Start(op oprunner.Operation) error {41+ if err := op.Validate(); err != nil {42+ return err43+ }44+ release, ok := s.runner.Acquire(op.Name)45+ if !ok {46+ return oprunner.ErrBusy47+ }48+ s.mu.Lock()49+ s.last = supervisedRun{50+ Name: op.Name, Command: op.Command(), StartedAt: time.Now().UTC(), Running: true,51+ }52+ s.mu.Unlock()53+ s.wg.Add(1)54+ go func() {55+ defer s.wg.Done()56+ defer release()57+ err := oprunner.Stream(s.ctx, op, func(row oprunner.Row) error {58+ s.mu.Lock()59+ defer s.mu.Unlock()60+ s.last.Rows = append(s.last.Rows, row)61+ if row.Failed {62+ s.last.Failed = true63+ }64+ for _, attr := range row.Attrs {65+ if attr.Key == "artifact" {66+ s.last.Artifact = attr.Value67+ }68+ }69+ return nil70+ })71+ s.mu.Lock()72+ s.last.Running = false73+ s.last.FinishedAt = time.Now().UTC()74+ if err != nil {75+ s.last.Failed = true76+ s.last.Rows = append(s.last.Rows, oprunner.Row{77+ Seq: len(s.last.Rows) + 1, Kind: oprunner.KindResult,78+ Message: err.Error(), Failed: true,79+ })80+ }81+ s.mu.Unlock()82+ }()83+ return nil84+}85+86+func (s *runSupervisor) Close() {87+ s.cancel()88+ s.wg.Wait()89+}90+91+func (s *runSupervisor) Snapshot() supervisedRun {92+ s.mu.Lock()93+ defer s.mu.Unlock()94+ held := s.last95+ held.Rows = append([]oprunner.Row(nil), held.Rows...)96+ return held97+}@@ -13,18 +13,17 @@
1313 <meta charset="utf-8">1414 <meta name="viewport" content="width=device-width, initial-scale=1">1515 <meta name="color-scheme" content="dark">16-<link rel="icon" href="data:,">1716 <title>{{block "title" .}}Workbench{{end}} — Atlas</title>1817 <link rel="stylesheet" href="/assets/workbench.css">1918 <script src="/assets/htmx.js" defer></script>19+<script src="/assets/workbench.js" defer></script>2020 </head>2121 <body class="workbench">2222 <header class="topbar">2323 <a class="brand" href="/">Workbench</a>2424 <nav>2525 <a href="/">Library</a>26- <a href="/sources">Sources</a>27- <a href="/operations">Operations</a>26+ <a href="/project">Project</a>2827 </nav>2928 </header>3029 <main>@@ -0,0 +1,78 @@
1+{{define "title"}}Project{{end}}2+{{define "content"}}3+<section class="page-head">4+ <p class="eyebrow">One manifest → one native volume</p>5+ <h1>{{if .Project.Title}}{{.Project.Title}}{{else}}Atlas project{{end}}</h1>6+ <p class="dim"><b>{{.Targets.Project}}</b></p>7+ {{if .Notice}}<p class="notice">{{.Notice}}</p>{{end}}8+ {{if .Error}}<p class="warn">{{.Error}}</p>{{end}}9+</section>10+11+{{if .Project.ID}}12+<section class="project-flow" aria-label="Build flow">13+ <div><b>{{.SourceCount}}</b><span>feature sources</span></div>14+ <i>→</i>15+ <div><b>{{.FeatureSets}}</b><span>semantic sets</span></div>16+ <i>→</i>17+ <div><b>{{.RasterCount}}</b><span>raster pyramids</span></div>18+ <i>→</i>19+ <div><b>{{.LayerCount}}</b><span>presentation layers</span></div>20+ <i>→</i>21+ <div class="accent"><b>.atlas</b><span>native vNext</span></div>22+</section>23+24+<div class="columns project-columns">25+<section class="card">26+ <h2>Resolved plan</h2>27+ <p class="score">{{.CachedCount}} / {{.RequestCount}} requests cached</p>28+ <dl class="terms">29+ <div><dt>project digest</dt><dd><code>{{short .Plan.ProjectDigest}}</code></dd></div>30+ <div><dt>world</dt><dd>{{.Plan.World}}</dd></div>31+ <div><dt>estimated input</dt><dd>{{.Plan.Estimated}} bytes</dd></div>32+ <div><dt>output library</dt><dd>{{.Plan.Output}}</dd></div>33+ </dl>34+ <h3>Evidence requests</h3>35+ <table>36+ <thead><tr><th>source</th><th>adapter</th><th>kind</th><th>cache</th></tr></thead>37+ <tbody>{{range .Plan.Requests}}<tr><td>{{.Source}}</td><td>{{.Adapter}}</td><td>{{.Kind}}</td><td>{{if .Cached}}ready{{else}}fetch{{end}}</td></tr>{{end}}</tbody>38+ </table>39+ <h3>Obligations carried into provenance</h3>40+ {{range .Plan.Licenses}}<p><b>{{.Source}}</b>{{if .License}} · {{.License}}{{end}}{{if .Attribution}}<br><span class="dim">{{.Attribution}}</span>{{end}}</p>{{end}}41+</section>42+43+<section class="card build-control">44+ <h2>Build</h2>45+ <p class="dim">Capture immutable evidence, observe, assemble semantics and presentation, compile, validate, then atomically install.</p>46+ <form method="post" action="/project/build">47+ <button>Build native Atlas</button>48+ <label><input type="checkbox" name="offline" value="true"> offline / cached only</label>49+ </form>50+ {{if .Run.Name}}51+ <p class="run-state {{if .Run.Failed}}failed{{else if .Run.Running}}running{{else}}done{{end}}">52+ {{if .Run.Running}}Building… safe to leave this page{{else if .Run.Failed}}Build failed{{else}}Build complete{{end}}53+ </p>54+ <div id="op-log" class="console" aria-live="polite"55+ hx-get="/project/run" hx-trigger="load, every 1s" hx-swap="innerHTML">56+ {{range .Run.Rows}}{{template "op-row" .}}{{end}}57+ </div>58+ {{end}}59+ {{if .Run.Artifact}}60+ <div class="artifact-handoff" draggable="true" data-artifact-handoff61+ data-artifact-name="{{.ArtifactName}}" data-artifact-path="{{.Run.Artifact}}">62+ <div><span class="file-icon">A</span><b>{{.ArtifactName}}</b><br><span class="dim">Drag this file onto an Atlas window</span></div>63+ <form method="post" action="/project/open"><button>Open in Atlas</button></form>64+ </div>65+ {{end}}66+</section>67+</div>68+{{end}}69+70+<section class="card manifest-editor">71+ <h2>Portable manifest</h2>72+ <p class="dim">Configuration lives here. Captured evidence stays in the shared cache; completed volumes stay in the Atlas library.</p>73+ <form method="post" action="/project/save">74+ <textarea name="manifest" spellcheck="false" aria-label="Atlas project manifest">{{.Manifest}}</textarea>75+ <button>Validate and save</button>76+ </form>77+</section>78+{{end}}@@ -1,31 +1,27 @@
1-// Package workbench is the workbench beside the reader: one http.Handler that2-// answers for the collection itself.1+// Package workbench is a local authoring surface for one portable Atlas2+// project manifest and the native volume it produces.33 //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).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.97 //108 // # What it is made of119 //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.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.2013 //2114 // # The pages2215 //2316 // GET / the library: every volume, headlined by its serving score2417 // GET /volume/{slug} measurement: the score, its breakdown, the axes, the ledger2518 // 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 rows19+// 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 handoff2925 // GET /assets/{path...} the stylesheet, and the hypermedia runtime3026 //3127 // Every page is measurement first: a score is the headline and everything else@@ -72,16 +68,21 @@ type Options struct {
7268 // Table is the point table scores are read under. The zero value means7369 // the embedded one, which is what everything but a test wants.7470 Table maturity.Table71+ // OpenArtifact performs the native handoff for a completed .atlas build.72+ // The command host wires this to the operating system; tests may replace it.73+ OpenArtifact func(string) error7574 }7675 7776 // Workbench is the handler. It holds the registry it reads, the registry of7877 // sources it was told about, and the one operation slot.7978 type Workbench struct {80- library *library81- sources []Source82- targets Targets83- runtime []byte84- runner *oprunner.Runner79+ library *library80+ sources []Source81+ targets Targets82+ runtime []byte83+ runner *oprunner.Runner84+ supervisor *runSupervisor85+ openArtifact func(string) error8586 8687 pages map[string]*template.Template8788 rows *template.Template@@ -102,32 +103,44 @@ func New(opts Options) (*Workbench, error) {
102103 if err != nil {103104 return nil, err104105 }106+ runner := &oprunner.Runner{}105107 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(),108+ library: &library{dir: opts.Targets.Registry, table: table},109+ sources: opts.Sources,110+ targets: opts.Targets,111+ runtime: opts.Runtime,112+ runner: runner,113+ supervisor: newRunSupervisor(runner),114+ openArtifact: opts.OpenArtifact,115+ pages: pages,116+ rows: rows,117+ mux: http.NewServeMux(),114118 }115119 w.routes()116120 return w, nil117121 }118122 123+// Close stops and waits for any build subprocess owned by the workbench.124+func (w *Workbench) Close() {125+ w.supervisor.Close()126+}127+119128 // ServeHTTP answers one request.120129 func (w *Workbench) ServeHTTP(rw http.ResponseWriter, r *http.Request) { w.mux.ServeHTTP(rw, r) }121130 122131 // routes is the whole URL surface, spelled in one place; nothing registers123132 // itself.124133 func (w *Workbench) routes() {134+ w.mux.HandleFunc("GET /favicon.ico", func(rw http.ResponseWriter, _ *http.Request) { rw.WriteHeader(http.StatusNoContent) })125135 w.mux.HandleFunc("GET /{$}", w.handleLibrary)126136 w.mux.HandleFunc("GET /volume/{slug}", w.handleVolume)127137 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)138+ w.mux.HandleFunc("GET /project", w.handleProject)139+ w.mux.HandleFunc("POST /project/save", w.handleProjectSave)140+ w.mux.HandleFunc("POST /project/build", w.handleProjectBuild)141+ w.mux.HandleFunc("GET /project/run", w.handleProjectRun)142+ w.mux.HandleFunc("POST /project/open", w.handleProjectOpen)143+ w.mux.HandleFunc("GET /project/artifact", w.handleProjectArtifact)131144 w.mux.HandleFunc("GET /assets/{path...}", w.handleAsset)132145 }133146 @@ -137,7 +150,7 @@ func (w *Workbench) routes() {
137150 // pipeline operations is exactly the page that must not be reachable through138151 // somebody else's document.139152 const contentSecurityPolicy = "default-src 'none'; style-src 'self'; script-src 'self'; " +140- "img-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'"153+ "connect-src 'self'; img-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'"141154 142155 // render executes a page into a buffer first, so a template error becomes a143156 // clean 500 rather than half a page.@@ -360,6 +373,8 @@ func (w *Workbench) handleAsset(rw http.ResponseWriter, r *http.Request) {
360373 return361374 }362375 body, kind = w.runtime, "text/javascript; charset=utf-8"376+ case "workbench.js":377+ body, kind = behavior(), "text/javascript; charset=utf-8"363378 default:364379 http.NotFound(rw, r)365380 return@@ -381,7 +396,7 @@ func parseTemplates() (map[string]*template.Template, *template.Template, error)
381396 pages := make(map[string]*template.Template, len(pageNames))382397 for _, name := range pageNames {383398 held, err := template.New("layout").Funcs(funcs).ParseFS(384- files, "templates/layout.tmpl", "templates/"+name+".tmpl")399+ files, "templates/layout.tmpl", "templates/op-row.tmpl", "templates/"+name+".tmpl")385400 if err != nil {386401 return nil, nil, fmt.Errorf("workbench templates: %w", err)387402 }@@ -395,4 +410,4 @@ func parseTemplates() (map[string]*template.Template, *template.Template, error)
395410 }396411 397412 // pageNames are the pages, one template file each, named for the page.398-var pageNames = []string{"library", "volume", "diff", "sources", "operations"}413+var pageNames = []string{"library", "volume", "diff", "project"}@@ -258,19 +258,18 @@ func TestTheDiffPageIsHeadlinedByTheScoreDelta(t *testing.T) {
258258 }259259 }260260 261-func TestTheSourcePagePrintsWhatEachVolumeOwes(t *testing.T) {261+func TestTheNavigationPointsAtTheSingleProjectInsteadOfLegacySources(t *testing.T) {262262 server := site(t, testWorkbench(t, testLibrary(t)))263263 264- response, body := get(t, server, "/sources")265- if response.StatusCode != http.StatusOK {266- t.Fatalf("the sources page answered %d", response.StatusCode)267- }268- wants(t, "sources", body,269- "MapGenie", "IGN Wiki", "NASA Trek",270- "Maps and pin data by MapGenie and its contributors.",271- "Public domain.", "Reproduced under IGN's terms.",272- "native ids", "derived ids",273- "crawlable", "archived captures only")264+ response, _ := get(t, server, "/sources")265+ if response.StatusCode != http.StatusNotFound {266+ t.Fatalf("the retired sources page answered %d", response.StatusCode)267+ }268+ _, body := get(t, server, "/")269+ wants(t, "navigation", body, `href="/project"`, "Project")270+ if strings.Contains(body, `href="/sources"`) || strings.Contains(body, `href="/operations"`) {271+ t.Fatal("library navigation still exposes a legacy creation surface")272+ }274273 }275274 276275 func TestTheAssetsAreTheWorkbenchsOwnAndTheRuntimeIsOptional(t *testing.T) {@@ -36,6 +36,7 @@ import (
3636 "github.com/wailsapp/wails/v2"3737 "github.com/wailsapp/wails/v2/pkg/options"3838 "github.com/wailsapp/wails/v2/pkg/options/assetserver"39+ "github.com/wailsapp/wails/v2/pkg/options/mac"3940 4041 "github.com/FelineStateMachine/atlas/format/vnext"4142 "github.com/FelineStateMachine/atlas/internal/app"@@ -154,6 +155,12 @@ func run() error {
154155 155156 return wails.Run(&options.App{156157 Title: "Atlas — World Explorer",158+ Mac: &mac.Options{OnFileOpen: func(path string) {159+ // Finder, `open`, and another application handing Atlas a file all160+ // arrive here. They join launch arguments and window drops at the161+ // same validated, atomic intake queue.162+ window.QueuePaths([]string{path})163+ }},157164 // The application *is* the asset server. Every request the page makes158165 // -- pages, partials, the /data plane, the events stream -- is served159166 // by the same handler the headless host serves, over the webview's own@@ -888,16 +888,19 @@ export class AtlasChart extends HTMLElement {
888888 }889889 890890 private openLens(context: WorldContext, fresh: boolean): void {891- if (!this.map || !this.view || !this.projection || !this.plane || !context.lens) return;891+ if (!this.map || !this.view || !this.projection || !this.plane) return;892892 if (this.raster) {893893 this.map.removeLayer(this.raster.base);894894 this.map.removeLayer(this.raster.detail);895+ this.raster = null;895896 }896897 this.counter.reset();897- this.raster = buildRaster(898- this.plane, context.base, context.lens, context.grid, this.projection, this.counter);899- this.map.addLayer(this.raster.base);900- this.map.addLayer(this.raster.detail);898+ if (context.lens) {899+ this.raster = buildRaster(900+ this.plane, context.base, context.lens, context.grid, this.projection, this.counter);901+ this.map.addLayer(this.raster.base);902+ this.map.addLayer(this.raster.detail);903+ }901904 // A lens is a different picture of the ground, and three of the camera's902905 // options cannot be changed after it is built, so it is rebuilt. The903906 // camera itself is carried across by hand below.@@ -32,6 +32,7 @@ export const RASTER_CACHE_SIZE = 64;
3232 3333 /** The world square as an OL extent. */3434 export function worldExtent(grid: GridSpec): Extent {35+ if (grid.extent) return [...grid.extent];3536 return [0, -grid.size, grid.size, 0];3637 }3738 @@ -125,6 +125,8 @@ export interface TileGrid {
125125 readonly firstTile: number;126126 readonly tileSize: number;127127 readonly size: number;128+ /** The authoritative coordinate-space extent, converted to y-up display coordinates. */129+ readonly extent?: readonly [number, number, number, number];128130 }129131 130132 /** One world as the manifest lists it. */@@ -16,6 +16,7 @@ import test from "node:test";
1616 import { strict as assert } from "node:assert";1717 import {1818 COORDINATE_SYSTEM, OVERZOOM_LEVELS, fitResolution, lensExtent, levelResolution, viewMaxZoom,19+ worldExtent,1920 } from "../chart/projection.ts";2021 import { payloads, tileGrid } from "./fixtures.ts";2122 import { SQUARE, gamePlane, splitSheet } from "./models.ts";@@ -37,6 +38,13 @@ test("a level's resolution is the world square over its tiles", () => {
3738 assert.equal(levelResolution(grid, 6), grid.size / 2 ** 6 / grid.tileSize);3839 });3940 41+test("an authoritative coordinate extent replaces the raster-era square", () => {42+ assert.deepEqual(worldExtent({43+ sourceZoom: 0, firstTile: 0, tileSize: 256, size: 200,44+ extent: [100, -400, 300, -200],45+ }), [100, -400, 300, -200]);46+});47+4048 test("Mars opens at the fit its own bounds imply", () => {4149 const grid = tileGrid("mars");4250 const payload = payloads("mars").get("global");@@ -5,6 +5,7 @@ import {
55 type FieldSchema, type Schema,66 } from "../data/vnext.ts";77 import { coreID, decodeGeometry } from "../data/semantic.ts";8+import { presentedGrid, presentedKind } from "../world/model.ts";89 910 const ROOT = "11111111-1111-5111-8111-111111111111";1011 const TITLE = "22222222-2222-5222-8222-222222222222";@@ -78,6 +79,26 @@ test("native geometry preserves parts, rings, and string identities without GeoJ
7879 });7980 });8081 82+test("semantic meaning does not have to masquerade as geometry", () => {83+ assert.equal(presentedKind([{ geometry: { kind: 1 } }], "place"), "point");84+ assert.equal(presentedKind([{ geometry: { kind: 2 } }], "route"), "path");85+ assert.equal(presentedKind([], "geometry.area"), "area");86+ assert.throws(87+ () => presentedKind([{ geometry: { kind: 1 } }, { geometry: { kind: 2 } }], "place"),88+ /mixes geometry kinds/,89+ );90+});91+92+test("feature-only coordinate spaces derive a finite presentation grid", () => {93+ assert.deepEqual(presentedGrid({94+ id: "sample-grid", kind: "projected", unit: "metre", definition: "local sample grid",95+ extent: [10, 20, 110, 70], sourceZoom: 0, firstTile: 0, tileSize: 0, size: 0,96+ }), {97+ sourceZoom: 0, firstTile: 0, tileSize: 256, size: 100,98+ extent: [10, -70, 110, -20],99+ });100+});101+81102 function fixtureBlock(): ArrayBuffer {82103 const title = variableColumn([new TextEncoder().encode("Clinic"), null]);83104 const emergency = fixedInt64Column([4n, 5n]);@@ -8,7 +8,7 @@ import type { Ground } from "@atlas/analysis";
88 import type { Collection, Lens, TileGrid, WorldPayload } from "../data/payload.ts";99 import type { OpenWorld } from "../data/plane.ts";1010 import type {11- Feature, Geometry, Property, RasterPyramid, Style,11+ CoordinateSpace, Feature, Geometry, Property, RasterPyramid, Style,1212 } from "../data/semantic.ts";1313 1414 /** `[x, y]` in OL world coordinates: x east, y negative-down. */@@ -35,6 +35,28 @@ export function worldGrid(volume: TileGrid, payload: WorldPayload): TileGrid {
3535 return payload.grid ? { ...volume, ...payload.grid } : volume;3636 }3737 38+/**39+ * The presentation grid for an authoritative vNext coordinate space.40+ *41+ * Raster-era volumes already declare their square and tile size, and retain42+ * those values exactly. A feature-only world has no reason to invent raster43+ * metadata, so its finite display scale comes from the coordinate extent and44+ * a conventional 256-pixel resolution step. The extent is converted from the45+ * semantic space's y-down coordinates to OpenLayers' y-up display space once.46+ */47+export function presentedGrid(space: CoordinateSpace): TileGrid {48+ const [minX, minY, maxX, maxY] = space.extent;49+ const width = maxX - minX;50+ const height = maxY - minY;51+ return {52+ sourceZoom: space.sourceZoom,53+ firstTile: space.firstTile,54+ tileSize: space.tileSize > 0 ? space.tileSize : 256,55+ size: space.size > 0 ? space.size : Math.max(width, height),56+ extent: [minX, -maxY, maxX, -minY],57+ };58+}59+3860 /** One point feature, standing where it stands. */3961 export interface PointRecord {4062 readonly id: string;@@ -79,12 +101,7 @@ export class WorldModel {
79101 const { volume, world } = open;80102 this.slug = world.id;81103 this.title = world.title;82- this.grid = {83- sourceZoom: world.coordinateSpace.sourceZoom,84- firstTile: world.coordinateSpace.firstTile,85- tileSize: world.coordinateSpace.tileSize,86- size: world.coordinateSpace.size,87- };104+ this.grid = presentedGrid(world.coordinateSpace);88105 this.attrs = propertiesToAttrs(world.claims);89106 this.lenses = world.rasterPyramids.map(presentRaster);90107 @@ -99,7 +116,10 @@ export class WorldModel {
99116 const set = sets.get(layer.featureSet);100117 const style = styles.get(layer.style);101118 if (!set || !style) throw new Error(`layer ${layer.id} has an unresolved feature set or style`);102- const kind = semanticKind(set.semanticType);119+ // Meaning and shape are separate. A feature set may be semantically a120+ // place, trailhead, quest, or anything a schema declares; geometry is121+ // read from its native features rather than smuggled through that name.122+ const kind = presentedKind(set.features, set.semanticType);103123 const asset = style.iconAsset ? volume.assets.get(style.iconAsset) : undefined;104124 const collection: Collection = {105125 id: layer.id, featureSet: set.id, style: style.id,@@ -202,10 +222,29 @@ function displayGeometry(geometry: Geometry): { lines: Line[]; holes: Line[][] }
202222 return { lines, holes };203223 }204224 205-function semanticKind(value: string): "point" | "path" | "area" {206- const kind = value.startsWith("geometry.") ? value.slice("geometry.".length) : value;207- if (kind !== "point" && kind !== "path" && kind !== "area") throw new Error(`semantic geometry kind ${value} is unsupported`);208- return kind;225+type GeometryBearing = { readonly geometry: { readonly kind: number } };226+227+/** Geometry determines drawing; semantic type remains free to mean something. */228+export function presentedKind(229+ features: readonly GeometryBearing[],230+ semanticType: string,231+): "point" | "path" | "area" {232+ const kinds = new Set(features.map((feature) => feature.geometry.kind));233+ if (kinds.size > 1) {234+ throw new Error(`semantic feature set ${semanticType} mixes geometry kinds`);235+ }236+ const native = kinds.values().next().value as number | undefined;237+ if (native === 1) return "point";238+ if (native === 2) return "path";239+ if (native === 3) return "area";240+241+ // An empty feature set has no feature from which to infer its shape. Keep242+ // the older geometry.* spelling as a useful declaration for that case.243+ const declared = semanticType.startsWith("geometry.")244+ ? semanticType.slice("geometry.".length)245+ : semanticType;246+ if (declared === "path" || declared === "area") return declared;247+ return "point";209248 }210249 211250 function presentRaster(raster: RasterPyramid): Lens {