Add a validated artifact-first workbench view for Worlds, coordinate spaces, FeatureSets, typed feature facts, rasters, presentation, assets, carried schema, and replay evidence. Harden completed artifact download and native handoff around a held regular file, successful build state, library confinement, and full native validation. Cover representative semantics, pagination, malformed receipts, symlink/path boundaries, and pathname replacement.
- commit
- d0a1b390b78aa588f91745918c4bc584d7f1989c
- parent
- 1b8abff7b19393b01d0aa20cbd6e018f97f3083b
- author
- Dami <dami.etoile@gmail.com>
| A | internal/ | 80 | +++++ |
| M | internal/ | 101 | +++++++ |
| A | internal/ | 466 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 246 | ++++++++++++++++ |
| M | internal/ | 6 | +- |
| M | internal/ | 45 | +-- |
| A | internal/ | 134 | +++++++++ |
| M | internal/ | 3 | +- |
| M | internal/ | 4 | +- |
@@ -0,0 +1,80 @@
1+package workbench2+3+import (4+ "fmt"5+ "os"6+ "path/filepath"7+ "strings"8+9+ "github.com/FelineStateMachine/atlas/format/vnext"10+)11+12+// completedArtifact keeps the exact regular file that passed the native13+// integrity gate open. Inspector and download consumers therefore read the14+// validated inode rather than reopening a replaceable pathname.15+type completedArtifact struct {16+ path string17+ name string18+ source *os.File19+ reader *vnext.Reader20+ descriptor vnext.Descriptor21+}22+23+func (artifact *completedArtifact) Close() error { return artifact.source.Close() }24+25+func (w *Workbench) openCompletedArtifact() (*completedArtifact, error) {26+ run := w.supervisor.Snapshot()27+ if run.Name != "build" || run.Running || run.Failed || run.FinishedAt.IsZero() || run.Artifact == "" {28+ return nil, fmt.Errorf("no successful completed Atlas build is available")29+ }30+ absLibrary, err := filepath.Abs(w.targets.Registry)31+ if err != nil {32+ return nil, err33+ }34+ absArtifact, err := filepath.Abs(run.Artifact)35+ if err != nil {36+ return nil, err37+ }38+ relative, err := filepath.Rel(absLibrary, absArtifact)39+ if err != nil || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.Ext(relative) != ".atlas" {40+ return nil, fmt.Errorf("build artifact is outside the Atlas library")41+ }42+ root, err := os.OpenRoot(absLibrary)43+ if err != nil {44+ return nil, fmt.Errorf("open Atlas library: %w", err)45+ }46+ defer root.Close()47+ linkInfo, err := root.Lstat(relative)48+ if err != nil {49+ return nil, fmt.Errorf("stat completed Atlas artifact: %w", err)50+ }51+ if linkInfo.Mode()&os.ModeSymlink != 0 {52+ return nil, fmt.Errorf("completed Atlas artifact is a symbolic link")53+ }54+ source, err := root.Open(relative)55+ if err != nil {56+ return nil, fmt.Errorf("open completed Atlas artifact: %w", err)57+ }58+ info, err := source.Stat()59+ if err != nil {60+ source.Close()61+ return nil, fmt.Errorf("stat opened Atlas artifact: %w", err)62+ }63+ if !info.Mode().IsRegular() {64+ source.Close()65+ return nil, fmt.Errorf("completed Atlas artifact is not a regular file")66+ }67+ reader, err := vnext.Open(source, info.Size(), vnext.StandardSchema())68+ if err != nil {69+ source.Close()70+ return nil, fmt.Errorf("open completed Atlas artifact: %w", err)71+ }72+ if err := reader.Validate(); err != nil {73+ source.Close()74+ return nil, fmt.Errorf("validate completed Atlas artifact: %w", err)75+ }76+ return &completedArtifact{77+ path: absArtifact, name: filepath.Base(absArtifact), source: source, reader: reader,78+ descriptor: vnext.DescriptorOf(absArtifact, reader.VolumeID, reader.Release, info.Size()),79+ }, nil80+}@@ -421,3 +421,104 @@ textarea {
421421 .op-row[data-kind="result"] .msg { font-weight: 600; }422422 .op-row[data-level="WARN"] .msg { color: var(--warm-strong); }423423 .op-row[data-failed] .msg { color: #d98a72; }424+425+/* compiled artifact inspector -------------------------------------------- */426+427+.inspector-head { max-width: 1180px; }428+.artifact-facts {429+ display: grid;430+ grid-template-columns: repeat(5, minmax(110px, 1fr));431+ gap: 8px;432+ margin-top: 18px;433+}434+.artifact-facts span, .native-rail span {435+ display: flex;436+ flex-direction: column;437+ gap: 2px;438+ padding: 10px 12px;439+ border: 1px solid var(--line);440+ border-radius: 4px;441+ background: var(--panel);442+}443+.artifact-facts b, .native-rail b { color: var(--text-strong); }444+.artifact-facts small, .native-rail small { color: var(--muted); }445+.native-rail {446+ display: grid;447+ grid-template-columns: 1fr auto 1fr auto 1fr auto 1fr auto 1fr;448+ align-items: center;449+ gap: 8px;450+ max-width: 1180px;451+ margin: 0 auto 18px;452+ padding: 0 20px;453+}454+.native-rail i { color: var(--muted); font-style: normal; text-align: center; }455+.inspected-world { max-width: 1180px; margin-inline: auto; }456+.inspector-section-head {457+ display: flex;458+ justify-content: space-between;459+ align-items: end;460+ gap: 20px;461+ border-bottom: 1px solid var(--line);462+ padding-bottom: 14px;463+}464+.world-foundation, .raster-strip, .inspected-sets, .inspector-lower-grid {465+ display: grid;466+ grid-template-columns: repeat(2, minmax(0, 1fr));467+ gap: 12px;468+ margin-top: 14px;469+}470+.inspect-node {471+ min-width: 0;472+ padding: 14px;473+ border: 1px solid var(--line-strong);474+ border-radius: 5px;475+ background: var(--bg);476+}477+.inspect-node header h3, .inspect-node header p { margin: 2px 0; }478+.inspect-node code { overflow-wrap: anywhere; }479+.node-kind {480+ display: block;481+ color: var(--muted);482+ font-size: 10px;483+ letter-spacing: .08em;484+ text-transform: uppercase;485+}486+.mini-terms { margin: 10px 0 0; }487+.mini-terms div { display: grid; grid-template-columns: 90px minmax(0, 1fr); gap: 8px; padding: 4px 0; }488+.mini-terms dt, .mini-terms dd { margin: 0; }489+.mini-terms dt { color: var(--muted); }490+.raster-strip { grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); }491+.inspected-sets { grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); }492+.feature-summaries { display: grid; gap: 5px; margin: 8px 0; }493+.feature-summaries a {494+ display: flex;495+ justify-content: space-between;496+ gap: 12px;497+ padding: 7px 9px;498+ border: 1px solid var(--line);499+ border-radius: 3px;500+ text-decoration: none;501+}502+.feature-summaries a:hover { border-color: var(--accent); }503+.feature-summaries small { color: var(--muted); }504+.feature-pages { display: flex; justify-content: space-between; margin-top: 8px; }505+.feature-facts {506+ margin-top: 12px;507+ padding: 12px;508+ border: 1px solid var(--accent);509+ border-radius: 4px;510+ background: var(--accent-wash);511+}512+.geometry-fact { display: flex; flex-wrap: wrap; gap: 8px 14px; margin: 10px 0; }513+.geometry-fact b { color: var(--data); }514+.warn-inline { color: #d98a72; }515+.inspector-lower-grid { max-width: 1180px; margin: 14px auto; padding: 0 20px; }516+.schema-type { padding: 8px 0; border-bottom: 1px solid var(--line); }517+518+@media (max-width: 850px) {519+ .artifact-facts { grid-template-columns: repeat(2, minmax(0, 1fr)); }520+ .native-rail { grid-template-columns: 1fr; }521+ .native-rail i { transform: rotate(90deg); }522+ .inspector-section-head { align-items: start; flex-direction: column; }523+ .world-foundation, .inspected-sets, .inspector-lower-grid { grid-template-columns: 1fr; }524+}@@ -0,0 +1,466 @@
1+package workbench2+3+import (4+ "bytes"5+ "crypto/sha256"6+ "encoding/base64"7+ "encoding/hex"8+ "encoding/json"9+ "fmt"10+ "io"11+ "net/http"12+ "sort"13+ "strconv"14+ "strings"15+ "time"16+17+ "github.com/FelineStateMachine/atlas/format/semconv"18+ "github.com/FelineStateMachine/atlas/format/vnext"19+ "github.com/FelineStateMachine/atlas/internal/authoring"20+)21+22+// artifactPage is a semantic projection of a validated native Atlas file. It23+// is intentionally built from vnext.Reader.Volume rather than the authoring24+// manifest, so this page describes what was actually packed and reopened.25+type artifactPage struct {26+ File string27+ Descriptor vnext.Descriptor28+ TableCount int29+ BlobCount int30+ RuntimeTypes int31+ Tables []artifactTable32+ BlobNames []string33+ Schema []artifactSchemaType34+ Worlds []artifactWorld35+ Assets []artifactAsset36+ Receipt *artifactReceipt37+ ReceiptErr string38+}39+40+type artifactTable struct {41+ Name string42+ Rows int43+ Columns int44+}45+46+type artifactSchemaType struct {47+ ID string48+ Name string49+ Fields []artifactField50+}51+52+type artifactWorld struct {53+ ID string54+ Title string55+ CoordinateSpace vnext.CoordinateSpace56+ Claims []artifactProperty57+ FeatureSets []artifactFeatureSet58+ Rasters []artifactRaster59+ Presentation artifactPresentation60+ Features int61+}62+63+type artifactFeatureSet struct {64+ ID string65+ Title string66+ SemanticType string67+ Geometry string68+ Properties []artifactField69+ Claims []artifactProperty70+ Features []artifactFeature71+ FeatureCount int72+ Offset int73+ Previous int74+ Next int75+ HasPrevious bool76+ HasNext bool77+ Selected *artifactFeature78+}79+80+type artifactField struct {81+ ID string82+ Name string83+ Kind string84+ Optional bool85+}86+87+type artifactFeature struct {88+ ID string89+ Title string90+ Subtitle string91+ Description string92+ Geometry artifactGeometry93+ Properties []artifactProperty94+ Relationships []artifactRelationship95+ Provenance []vnext.Provenance96+}97+98+type artifactGeometry struct {99+ Kind string100+ Parts int101+ Rings int102+ Vertices int103+ Bounds [4]float64104+ HasData bool105+}106+107+type artifactProperty struct {108+ ID string109+ Name string110+ Kind string111+ Value string112+}113+114+type artifactRelationship struct {115+ Predicate string116+ Target string117+ Title string118+}119+120+type artifactRaster struct {121+ ID string122+ Name string123+ Codec string124+ TileSize int64125+ MinZoom int64126+ MaxZoom int64127+ FullZoom int64128+ Formats []string129+ Coverage int130+ TileBlobs int131+ Template string132+ Interpolate bool133+ Background string134+}135+136+type artifactPresentation struct {137+ ID string138+ Title string139+ Layers []vnext.Layer140+ Styles []vnext.Style141+ Legend []vnext.LegendEntry142+}143+144+type artifactAsset struct {145+ ID string146+ MediaType string147+ Bytes int148+ Provenance string149+}150+151+type artifactReceipt struct {152+ Project string153+ ProjectDigest string154+ Captures []authoring.SelectedCapture155+}156+157+func (w *Workbench) handleProjectInspect(rw http.ResponseWriter, request *http.Request) {158+ artifact, err := w.openCompletedArtifact()159+ if err != nil {160+ http.Error(rw, err.Error(), http.StatusBadRequest)161+ return162+ }163+ defer artifact.Close()164+ offset, _ := strconv.Atoi(request.URL.Query().Get("offset"))165+ if offset < 0 {166+ offset = 0167+ }168+ page, err := inspectArtifact(artifact, request.URL.Query().Get("set"), request.URL.Query().Get("feature"), offset)169+ if err != nil {170+ http.Error(rw, err.Error(), http.StatusInternalServerError)171+ return172+ }173+ w.render(rw, "inspector", page)174+}175+176+func inspectArtifact(artifact *completedArtifact, selectedSet, selectedFeature string, selectedOffset int) (artifactPage, error) {177+ volume, err := artifact.reader.Volume()178+ if err != nil {179+ return artifactPage{}, fmt.Errorf("restore native artifact: %w", err)180+ }181+ page := artifactPage{182+ File: artifact.name, Descriptor: artifact.descriptor,183+ TableCount: len(artifact.reader.TableNames()), BlobCount: len(artifact.reader.BlobNames()),184+ RuntimeTypes: len(artifact.reader.RuntimeSchema.Types),185+ }186+ for _, name := range artifact.reader.TableNames() {187+ table, err := artifact.reader.Table(name)188+ if err != nil {189+ return artifactPage{}, fmt.Errorf("inspect table %s: %w", name, err)190+ }191+ page.Tables = append(page.Tables, artifactTable{Name: name, Rows: table.Rows, Columns: len(table.Columns)})192+ }193+ for index, name := range artifact.reader.BlobNames() {194+ if index == 50 {195+ break196+ }197+ page.BlobNames = append(page.BlobNames, name)198+ }199+ for _, typ := range artifact.reader.FileSchema.Types {200+ held := artifactSchemaType{ID: typ.ID.String(), Name: typ.Name}201+ for _, field := range typ.Fields {202+ held.Fields = append(held.Fields, artifactField{203+ ID: field.ID.String(), Name: field.Name, Kind: kindName(field.Kind), Optional: field.Optional,204+ })205+ }206+ page.Schema = append(page.Schema, held)207+ }208+ titles := featureTitles(volume)209+ for _, world := range volume.Worlds {210+ view := artifactWorld{211+ ID: world.ID, Title: world.Title, CoordinateSpace: world.CoordinateSpace,212+ Claims: inspectProperties(world.Claims),213+ Presentation: artifactPresentation{214+ ID: world.Presentation.ID, Title: world.Presentation.Title,215+ Layers: world.Presentation.Layers, Styles: world.Presentation.Styles, Legend: world.Presentation.Legend,216+ },217+ }218+ for _, set := range world.FeatureSets {219+ contract := artifactFeatureSet{220+ ID: set.ID, Title: set.Title, SemanticType: set.SemanticType,221+ Geometry: geometryContract(set), Claims: inspectProperties(set.Claims), FeatureCount: len(set.Features),222+ }223+ for _, field := range set.Properties {224+ contract.Properties = append(contract.Properties, artifactField{225+ ID: field.ID.String(), Name: field.Name, Kind: kindName(field.Kind), Optional: field.Optional,226+ })227+ }228+ offset := 0229+ if set.ID == selectedSet {230+ offset = selectedOffset231+ }232+ if offset >= len(set.Features) && len(set.Features) > 0 {233+ offset = (len(set.Features) - 1) / 25 * 25234+ }235+ contract.Offset = offset236+ contract.HasPrevious = offset > 0237+ contract.Previous = max(0, offset-25)238+ contract.HasNext = offset+25 < len(set.Features)239+ contract.Next = offset + 25240+ for index, feature := range set.Features {241+ item := inspectFeature(feature, titles)242+ if set.ID == selectedSet && feature.ID == selectedFeature {243+ selected := item244+ contract.Selected = &selected245+ }246+ if index >= offset && index < offset+25 {247+ contract.Features = append(contract.Features, item)248+ }249+ }250+ view.Features += len(set.Features)251+ view.FeatureSets = append(view.FeatureSets, contract)252+ }253+ for _, raster := range world.RasterPyramids {254+ view.Rasters = append(view.Rasters, artifactRaster{255+ ID: raster.ID, Name: raster.Name, Codec: raster.Codec, TileSize: raster.TileSize,256+ MinZoom: raster.MinZoom, MaxZoom: raster.MaxZoom, FullZoom: raster.FullZoom,257+ Formats: raster.Formats, Coverage: len(raster.Coverage), TileBlobs: countBlobPrefix(artifact.reader.BlobNames(), raster.Template),258+ Template: raster.Template, Interpolate: raster.Interpolate, Background: raster.Background,259+ })260+ }261+ page.Worlds = append(page.Worlds, view)262+ }263+ for _, asset := range volume.Assets {264+ page.Assets = append(page.Assets, artifactAsset{265+ ID: asset.ID, MediaType: asset.MediaType, Bytes: len(asset.Data), Provenance: asset.Provenance,266+ })267+ if asset.ID == "build-receipt" {268+ selection, err := inspectEvidenceSelection(asset.Data)269+ if err != nil {270+ page.ReceiptErr = err.Error()271+ } else {272+ page.Receipt = &artifactReceipt{273+ Project: selection.Project, ProjectDigest: selection.ProjectDigest, Captures: selection.Captures,274+ }275+ }276+ }277+ }278+ return page, nil279+}280+281+func countBlobPrefix(names []string, template string) int {282+ prefix := template283+ if marker := strings.IndexByte(prefix, '{'); marker >= 0 {284+ prefix = prefix[:marker]285+ }286+ if prefix == "" {287+ return 0288+ }289+ count := 0290+ for _, name := range names {291+ if strings.HasPrefix(name, prefix) {292+ count++293+ }294+ }295+ return count296+}297+298+func inspectEvidenceSelection(data []byte) (authoring.EvidenceSelection, error) {299+ var selection authoring.EvidenceSelection300+ decoder := json.NewDecoder(bytes.NewReader(data))301+ decoder.DisallowUnknownFields()302+ if err := decoder.Decode(&selection); err != nil {303+ return selection, fmt.Errorf("receipt metadata is invalid: %w", err)304+ }305+ if err := decoder.Decode(&struct{}{}); err != io.EOF {306+ return selection, fmt.Errorf("receipt metadata has trailing data")307+ }308+ if selection.Format != authoring.BuildReceiptFormat || selection.Project == "" || !isLowerSHA256(selection.ProjectDigest) || len(selection.Captures) == 0 {309+ return selection, fmt.Errorf("receipt metadata does not satisfy %s", authoring.BuildReceiptFormat)310+ }311+ seen := make(map[string]bool, len(selection.Captures))312+ previousRequest, previousSource := "", ""313+ for _, capture := range selection.Captures {314+ if !isLowerSHA256(capture.Request) || !isLowerSHA256(capture.Acquisition) || !isLowerSHA256(capture.SHA256) ||315+ capture.Source == "" || capture.Length < 0 || capture.MediaType == "" || seen[capture.Request] {316+ return selection, fmt.Errorf("receipt capture metadata is invalid")317+ }318+ if _, err := time.Parse(time.RFC3339Nano, capture.CapturedAt); err != nil {319+ return selection, fmt.Errorf("receipt capture time is invalid")320+ }321+ if previousRequest > capture.Request || (previousRequest == capture.Request && previousSource > capture.Source) {322+ return selection, fmt.Errorf("receipt captures are not canonically ordered")323+ }324+ seen[capture.Request] = true325+ previousRequest, previousSource = capture.Request, capture.Source326+ }327+ return selection, nil328+}329+330+func isLowerSHA256(value string) bool {331+ if len(value) != sha256.Size*2 || value != strings.ToLower(value) {332+ return false333+ }334+ _, err := hex.DecodeString(value)335+ return err == nil336+}337+338+func inspectFeature(feature vnext.Feature, titles map[string]string) artifactFeature {339+ item := artifactFeature{340+ ID: feature.ID, Title: feature.Title, Subtitle: feature.Subtitle, Description: feature.Description,341+ Geometry: inspectGeometry(feature.Geometry), Properties: inspectProperties(feature.Properties),342+ Provenance: feature.Provenance,343+ }344+ for _, relationship := range feature.Relationships {345+ item.Relationships = append(item.Relationships, artifactRelationship{346+ Predicate: relationship.Predicate, Target: relationship.Target, Title: titles[relationship.Target],347+ })348+ }349+ return item350+}351+352+func featureTitles(volume vnext.Volume) map[string]string {353+ titles := make(map[string]string)354+ for _, world := range volume.Worlds {355+ for _, set := range world.FeatureSets {356+ for _, feature := range set.Features {357+ titles[feature.ID] = feature.Title358+ }359+ }360+ }361+ return titles362+}363+364+func geometryContract(set vnext.FeatureSet) string {365+ for _, claim := range set.Claims {366+ if claim.Field.Name == semconv.KeyGeometryKind && claim.Value.Kind == vnext.KindString {367+ return claim.Value.String368+ }369+ }370+ if len(set.Features) > 0 {371+ return geometryKindName(set.Features[0].Geometry.Kind)372+ }373+ return "undeclared"374+}375+376+func inspectGeometry(geometry vnext.Geometry) artifactGeometry {377+ view := artifactGeometry{Kind: geometryKindName(geometry.Kind), Parts: len(geometry.Parts)}378+ for _, part := range geometry.Parts {379+ view.Rings += len(part.Rings)380+ for _, ring := range part.Rings {381+ for _, position := range ring {382+ view.Vertices++383+ if !view.HasData {384+ view.Bounds = [4]float64{position[0], position[1], position[0], position[1]}385+ view.HasData = true386+ continue387+ }388+ view.Bounds[0] = min(view.Bounds[0], position[0])389+ view.Bounds[1] = min(view.Bounds[1], position[1])390+ view.Bounds[2] = max(view.Bounds[2], position[0])391+ view.Bounds[3] = max(view.Bounds[3], position[1])392+ }393+ }394+ }395+ return view396+}397+398+func inspectProperties(values []vnext.Property) []artifactProperty {399+ out := make([]artifactProperty, 0, len(values))400+ for _, property := range values {401+ out = append(out, artifactProperty{402+ ID: property.FieldID.String(), Name: property.Field.Name,403+ Kind: kindName(property.Value.Kind), Value: valueString(property.Value),404+ })405+ }406+ sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })407+ return out408+}409+410+func geometryKindName(kind vnext.GeometryKind) string {411+ switch kind {412+ case vnext.GeometryPoint:413+ return "point"414+ case vnext.GeometryLineString:415+ return "path"416+ case vnext.GeometryPolygon:417+ return "area"418+ default:419+ return "unknown"420+ }421+}422+423+func kindName(kind vnext.Kind) string {424+ switch kind {425+ case vnext.KindBool:426+ return "bool"427+ case vnext.KindInt64:428+ return "int64"429+ case vnext.KindFloat64:430+ return "float64"431+ case vnext.KindString:432+ return "string"433+ case vnext.KindBytes:434+ return "bytes"435+ case vnext.KindID:436+ return "id"437+ default:438+ return "null"439+ }440+}441+442+func valueString(value vnext.Value) string {443+ switch value.Kind {444+ case vnext.KindBool:445+ return fmt.Sprintf("%t", value.Bool)446+ case vnext.KindInt64:447+ return fmt.Sprintf("%d", value.Int64)448+ case vnext.KindFloat64:449+ return fmt.Sprintf("%g", value.Float64)450+ case vnext.KindString:451+ return value.String452+ case vnext.KindBytes:453+ digest := sha256.Sum256(value.Bytes)454+ prefix := value.Bytes455+ truncated := ""456+ if len(prefix) > 16 {457+ prefix = prefix[:16]458+ truncated = "…"459+ }460+ return fmt.Sprintf("%d bytes · sha256 %x · %s%s", len(value.Bytes), digest[:6], base64.StdEncoding.EncodeToString(prefix), truncated)461+ case vnext.KindID:462+ return value.ID.String()463+ default:464+ return ""465+ }466+}@@ -0,0 +1,246 @@
1+package workbench2+3+import (4+ "context"5+ "encoding/json"6+ "fmt"7+ "io"8+ "net/http"9+ "net/url"10+ "os"11+ "path/filepath"12+ "strings"13+ "testing"14+ "time"15+16+ "github.com/FelineStateMachine/atlas/internal/authoring"17+)18+19+func representativeInspector(t *testing.T) (*Workbench, authoring.BuildResult) {20+ t.Helper()21+ root := t.TempDir()22+ project, err := filepath.Abs(filepath.Join("..", "..", "examples", "sample-region"+authoring.ProjectExtension))23+ if err != nil {24+ t.Fatal(err)25+ }26+ targets := Targets{27+ Atlas: "/opt/atlas", Dir: root, Project: project,28+ Cache: filepath.Join(root, "cache"), Registry: filepath.Join(root, "library"),29+ }30+ result, err := authoring.Build(context.Background(), authoring.BuildOptions{31+ ProjectPath: project, CacheDir: targets.Cache, LibraryDir: targets.Registry,32+ })33+ if err != nil {34+ t.Fatal(err)35+ }36+ held, err := New(Options{Targets: targets})37+ if err != nil {38+ t.Fatal(err)39+ }40+ held.supervisor.last = supervisedRun{Name: "build", Artifact: result.Path, FinishedAt: time.Now()}41+ return held, result42+}43+44+func TestInspectorDescribesTheCompiledRepresentativeArtifact(t *testing.T) {45+ held, _ := representativeInspector(t)46+ artifact, err := held.openCompletedArtifact()47+ if err != nil {48+ t.Fatal(err)49+ }50+ page, err := inspectArtifact(artifact, "", "", 0)51+ artifact.Close()52+ if err != nil {53+ t.Fatal(err)54+ }55+ if len(page.Worlds) != 1 || len(page.Worlds[0].FeatureSets) != 3 || page.Worlds[0].Features != 4 {56+ t.Fatalf("compiled world summary = %+v", page.Worlds)57+ }58+ var places artifactFeatureSet59+ for _, set := range page.Worlds[0].FeatureSets {60+ if strings.HasSuffix(set.ID, "/set/places") {61+ places = set62+ }63+ }64+ if places.Geometry != "point" || places.FeatureCount != 2 || len(places.Properties) != 6 {65+ t.Fatalf("compiled places contract = %+v", places)66+ }67+ wantKinds := map[string]bool{"bool": false, "int64": false, "float64": false, "string": false, "bytes": false, "id": false}68+ for _, field := range places.Properties {69+ wantKinds[field.Kind] = true70+ }71+ for kind, found := range wantKinds {72+ if !found {73+ t.Errorf("compiled places contract omits %s", kind)74+ }75+ }76+ if page.Receipt == nil || len(page.Receipt.Captures) != 5 || page.ReceiptErr != "" {77+ t.Fatalf("compiled receipt = %+v, error %q", page.Receipt, page.ReceiptErr)78+ }79+80+ server := site(t, held)81+ response, body := get(t, server, "/project/inspect")82+ if response.StatusCode != http.StatusOK {83+ t.Fatalf("inspector answered %d: %s", response.StatusCode, body)84+ }85+ wants(t, "compiled inspector", body,86+ "Compiled artifact", "native semantic truth", "validated", "runtime union",87+ "Sample Region", "CoordinateSpace", "Sample Places", "point", "Sample Routes", "path",88+ "Sample Areas", "area", "Sample Background", "Presentation", "Executable replay receipt")89+90+ selected := places.Features[0]91+ query := url.Values{"set": {places.ID}, "feature": {selected.ID}}92+ response, body = get(t, server, "/project/inspect?"+query.Encode())93+ if response.StatusCode != http.StatusOK {94+ t.Fatalf("selected feature answered %d: %s", response.StatusCode, body)95+ }96+ wants(t, "compiled feature facts", body, "Feature facts", selected.Title, "typed values", "observed relationships", "within", "provenance records")97+}98+99+func TestInspectorPaginatesFeatureSummaries(t *testing.T) {100+ targets := projectTargets(t)101+ if err := os.MkdirAll(targets.Registry, 0o755); err != nil {102+ t.Fatal(err)103+ }104+ features := make([]featureSpec, 31)105+ for index := range features {106+ features[index] = featureSpec{id: int64(index + 1), title: fmt.Sprintf("Sample feature %02d", index+1)}107+ }108+ path := bundleSpec{109+ slug: "sample-region", title: "Sample Region",110+ worlds: []worldSpec{{slug: "sample-region", title: "Sample Region", features: features}},111+ }.write(t, targets.Registry)112+ held, err := New(Options{Targets: targets})113+ if err != nil {114+ t.Fatal(err)115+ }116+ held.supervisor.last = supervisedRun{Name: "build", Artifact: path, FinishedAt: time.Now()}117+ server := site(t, held)118+ response, body := get(t, server, "/project/inspect?set=sample-region%2Fset%2Fmarkers")119+ if response.StatusCode != http.StatusOK {120+ t.Fatalf("first page answered %d: %s", response.StatusCode, body)121+ }122+ wants(t, "first feature page", body, "Features (0–25 of 31)", "Sample feature 01", "Sample feature 25", "Next")123+ if strings.Contains(body, "Sample feature 26") {124+ t.Fatal("first feature page rendered beyond its 25-item budget")125+ }126+ response, body = get(t, server, "/project/inspect?set=sample-region%2Fset%2Fmarkers&offset=25")127+ if response.StatusCode != http.StatusOK {128+ t.Fatalf("second page answered %d: %s", response.StatusCode, body)129+ }130+ wants(t, "second feature page", body, "Features (25–31 of 31)", "Sample feature 26", "Sample feature 31", "Previous")131+}132+133+func TestCompletedArtifactBoundaryRequiresSuccessAndRejectsLinks(t *testing.T) {134+ targets := projectTargets(t)135+ if err := os.MkdirAll(targets.Registry, 0o755); err != nil {136+ t.Fatal(err)137+ }138+ held, err := New(Options{Targets: targets})139+ if err != nil {140+ t.Fatal(err)141+ }142+ for name, run := range map[string]supervisedRun{143+ "running": {Name: "build", Running: true, Artifact: filepath.Join(targets.Registry, "running.atlas")},144+ "failed": {Name: "build", Failed: true, FinishedAt: time.Now(), Artifact: filepath.Join(targets.Registry, "failed.atlas")},145+ "unfinished": {Name: "build", Artifact: filepath.Join(targets.Registry, "unfinished.atlas")},146+ "other run": {Name: "diff", FinishedAt: time.Now(), Artifact: filepath.Join(targets.Registry, "other.atlas")},147+ } {148+ t.Run(name, func(t *testing.T) {149+ held.supervisor.last = run150+ if _, err := held.openCompletedArtifact(); err == nil || !strings.Contains(err.Error(), "no successful completed") {151+ t.Fatalf("boundary error = %v", err)152+ }153+ })154+ }155+156+ outside := filepath.Join(targets.Dir, "outside.atlas")157+ if err := os.WriteFile(outside, []byte("outside"), 0o644); err != nil {158+ t.Fatal(err)159+ }160+ link := filepath.Join(targets.Registry, "link.atlas")161+ if err := os.Symlink(outside, link); err != nil {162+ t.Fatal(err)163+ }164+ held.supervisor.last = supervisedRun{Name: "build", FinishedAt: time.Now(), Artifact: link}165+ if _, err := held.openCompletedArtifact(); err == nil || !strings.Contains(err.Error(), "symbolic link") {166+ t.Fatalf("symlink error = %v", err)167+ }168+169+ directory := filepath.Join(targets.Registry, "directory.atlas")170+ if err := os.Mkdir(directory, 0o755); err != nil {171+ t.Fatal(err)172+ }173+ held.supervisor.last = supervisedRun{Name: "build", FinishedAt: time.Now(), Artifact: directory}174+ if _, err := held.openCompletedArtifact(); err == nil || !strings.Contains(err.Error(), "regular file") {175+ t.Fatalf("directory error = %v", err)176+ }177+178+ held.supervisor.last = supervisedRun{Name: "build", FinishedAt: time.Now(), Artifact: outside}179+ if _, err := held.openCompletedArtifact(); err == nil || !strings.Contains(err.Error(), "outside") {180+ t.Fatalf("outside-library error = %v", err)181+ }182+}183+184+func TestCompletedArtifactKeepsTheValidatedFileOpen(t *testing.T) {185+ held, result := representativeInspector(t)186+ want, err := os.ReadFile(result.Path)187+ if err != nil {188+ t.Fatal(err)189+ }190+ artifact, err := held.openCompletedArtifact()191+ if err != nil {192+ t.Fatal(err)193+ }194+ defer artifact.Close()195+ if err := os.Rename(result.Path, result.Path+".moved"); err != nil {196+ t.Fatal(err)197+ }198+ if err := os.WriteFile(result.Path, []byte("replacement"), 0o644); err != nil {199+ t.Fatal(err)200+ }201+ if _, err := artifact.source.Seek(0, io.SeekStart); err != nil {202+ t.Fatal(err)203+ }204+ got, err := io.ReadAll(artifact.source)205+ if err != nil {206+ t.Fatal(err)207+ }208+ if string(got) != string(want) {209+ t.Fatal("validated handle followed the replaced pathname")210+ }211+}212+213+func TestInspectorReceiptDecoderIsStrictAndContained(t *testing.T) {214+ hashA := strings.Repeat("a", 64)215+ hashB := strings.Repeat("b", 64)216+ selection := authoring.EvidenceSelection{217+ Format: authoring.BuildReceiptFormat, Project: "sample-region", ProjectDigest: hashA,218+ Captures: []authoring.SelectedCapture{{219+ Request: hashA, Acquisition: hashB, Source: "sample-source", SHA256: hashA,220+ Length: 4, MediaType: "application/geo+json", CapturedAt: "2026-08-08T12:00:00Z",221+ }},222+ }223+ data, err := json.Marshal(selection)224+ if err != nil {225+ t.Fatal(err)226+ }227+ if _, err := inspectEvidenceSelection(data); err != nil {228+ t.Fatalf("valid receipt refused: %v", err)229+ }230+ for name, mutate := range map[string]func([]byte) []byte{231+ "unknown field": func(data []byte) []byte { return append(data[:len(data)-1], []byte(`,"surprise":true}`)...) },232+ "trailing data": func(data []byte) []byte { return append(data, []byte(` {}`)...) },233+ "uppercase hash": func(data []byte) []byte {234+ return []byte(strings.Replace(string(data), hashA, strings.ToUpper(hashA), 1))235+ },236+ "bad time": func(data []byte) []byte {237+ return []byte(strings.Replace(string(data), "2026-08-08T12:00:00Z", "sometime", 1))238+ },239+ } {240+ t.Run(name, func(t *testing.T) {241+ if _, err := inspectEvidenceSelection(mutate(append([]byte(nil), data...))); err == nil {242+ t.Fatal("invalid receipt was accepted")243+ }244+ })245+ }246+}@@ -280,12 +280,12 @@ func TestBuildSurvivesNavigationAndHandsOffTheArtifact(t *testing.T) {
280280 t.Fatalf("run = %+v", run)281281 }282282 _, runBody := get(t, server, "/project/run")283- wants(t, "completed run fragment", runBody, "Build complete", "Open in Atlas", "Drag this file onto an Atlas window", filepath.Base(artifact))283+ wants(t, "completed run fragment", runBody, "Build complete", "Inspect packed data", "Open in Atlas", "Drag this file onto an Atlas window", filepath.Base(artifact))284284 if strings.Contains(runBody, `hx-get="/project/run"`) {285285 t.Fatal("completed run fragment kept polling")286286 }287287 _, body := get(t, server, "/project")288- wants(t, "completed project", body, "Build complete", "Open in Atlas", "Drag this file onto an Atlas window", filepath.Base(artifact))288+ wants(t, "completed project", body, "Build complete", "Inspect packed data", "Open in Atlas", "Drag this file onto an Atlas window", filepath.Base(artifact))289289 response, _ = postForm(t, noRedirectClient(server.Client()), server.URL+"/project/open", url.Values{})290290 if response.StatusCode != http.StatusSeeOther || opened != artifact {291291 t.Fatalf("open answered %d and handed off %q", response.StatusCode, opened)@@ -309,7 +309,7 @@ func TestArtifactHandoffRefusesAnInvalidAtlasFile(t *testing.T) {
309309 if err != nil {310310 t.Fatal(err)311311 }312- held.supervisor.last = supervisedRun{Name: "build", Artifact: artifact}312+ held.supervisor.last = supervisedRun{Name: "build", Artifact: artifact, FinishedAt: time.Now()}313313 if _, err := held.currentArtifact(); err == nil || !strings.Contains(err.Error(), "open completed Atlas artifact") {314314 t.Fatalf("currentArtifact = %v, want native validation failure", err)315315 }@@ -3,13 +3,14 @@ package workbench
33 import (44 "bytes"55 "fmt"6+ "io"7+ "mime"68 "net/http"79 "os"810 "path/filepath"911 "sort"1012 "strings"1113 12- "github.com/FelineStateMachine/atlas/format/vnext"1314 "github.com/FelineStateMachine/atlas/internal/authoring"1415 "github.com/FelineStateMachine/atlas/internal/workbench/oprunner"1516 )@@ -372,39 +373,31 @@ func (w *Workbench) handleProjectOpen(rw http.ResponseWriter, r *http.Request) {
372373 }373374 374375 func (w *Workbench) handleProjectArtifact(rw http.ResponseWriter, r *http.Request) {375- artifact, err := w.currentArtifact()376+ artifact, err := w.openCompletedArtifact()376377 if err != nil {377378 http.NotFound(rw, r)378379 return379380 }380- rw.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filepath.Base(artifact)))381- http.ServeFile(rw, r, artifact)382-}383-384-func (w *Workbench) currentArtifact() (string, error) {385- artifact := w.supervisor.Snapshot().Artifact386- if artifact == "" {387- return "", fmt.Errorf("no completed Atlas artifact is available")381+ defer artifact.Close()382+ if _, err := artifact.source.Seek(0, io.SeekStart); err != nil {383+ http.Error(rw, err.Error(), http.StatusInternalServerError)384+ return388385 }389- absArtifact, err := filepath.Abs(artifact)386+ disposition := mime.FormatMediaType("attachment", map[string]string{"filename": artifact.name})387+ rw.Header().Set("Content-Disposition", disposition)388+ info, err := artifact.source.Stat()390389 if err != nil {391- return "", err390+ http.Error(rw, err.Error(), http.StatusInternalServerError)391+ return392392 }393- absLibrary, err := filepath.Abs(w.targets.Registry)393+ http.ServeContent(rw, r, artifact.name, info.ModTime(), artifact.source)394+}395+396+func (w *Workbench) currentArtifact() (string, error) {397+ artifact, err := w.openCompletedArtifact()394398 if err != nil {395399 return "", err396400 }397- relative, err := filepath.Rel(absLibrary, absArtifact)398- if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.Ext(absArtifact) != ".atlas" {399- return "", fmt.Errorf("build artifact is outside the Atlas library")400- }401- file, err := vnext.OpenFile(absArtifact, vnext.StandardSchema())402- if err != nil {403- return "", fmt.Errorf("open completed Atlas artifact: %w", err)404- }405- defer file.Close()406- if err := file.Validate(); err != nil {407- return "", fmt.Errorf("validate completed Atlas artifact: %w", err)408- }409- return absArtifact, nil401+ defer artifact.Close()402+ return artifact.path, nil410403 }@@ -0,0 +1,134 @@
1+{{define "title"}}Packed data{{end}}2+{{define "content"}}3+<section class="page-head inspector-head">4+ <p class="eyebrow">Compiled artifact · native semantic truth</p>5+ <h1>{{.Descriptor.Title}} <span class="dim">{{.Descriptor.Slug}}</span></h1>6+ <p><code>{{.File}}</code></p>7+ <div class="artifact-facts">8+ <span><b>validated</b><small>all native entries</small></span>9+ <span><b>{{short .Descriptor.Stamp}}</b><small>content stamp</small></span>10+ <span><b>{{.Descriptor.Size}}</b><small>bytes</small></span>11+ <span><b>{{.Descriptor.Revision}}</b><small>revision</small></span>12+ <span><b>{{.Descriptor.CreatedAt}}</b><small>newest capture</small></span>13+ </div>14+</section>15+16+<section class="native-rail" aria-label="Native file decoding">17+ <span><b>bootstrap</b><small>framing</small></span><i>→</i>18+ <span><b>embedded schema</b><small>{{len .Schema}} types</small></span><i>→</i>19+ <span><b>runtime union</b><small>{{.RuntimeTypes}} known types</small></span><i>→</i>20+ <span><b>typed tables</b><small>{{.TableCount}} tables</small></span><i>+</i>21+ <span><b>opaque blobs</b><small>{{.BlobCount}} blobs</small></span>22+</section>23+24+{{range .Worlds}}25+{{$world := .}}26+<section class="card inspected-world">27+ <header class="inspector-section-head">28+ <div><p class="eyebrow">World</p><h2>{{.Title}}</h2><code>{{.ID}}</code></div>29+ <p><b>{{len .FeatureSets}}</b> FeatureSets · <b>{{.Features}}</b> features · <b>{{len .Rasters}}</b> rasters · <b>{{len .Presentation.Layers}}</b> layers</p>30+ </header>31+32+ <div class="world-foundation">33+ <article class="inspect-node coordinate-node">34+ <span class="node-kind">CoordinateSpace</span>35+ <b>{{.CoordinateSpace.ID}}</b>36+ <dl class="mini-terms">37+ <div><dt>frame</dt><dd>{{.CoordinateSpace.Kind}} · {{.CoordinateSpace.Unit}}</dd></div>38+ <div><dt>definition</dt><dd>{{.CoordinateSpace.Definition}}</dd></div>39+ <div><dt>extent</dt><dd><code>{{.CoordinateSpace.Extent}}</code></dd></div>40+ <div><dt>grid</dt><dd>z{{.CoordinateSpace.SourceZoom}} · first {{.CoordinateSpace.FirstTile}} · tile {{.CoordinateSpace.TileSize}} · size {{.CoordinateSpace.Size}}</dd></div>41+ </dl>42+ </article>43+ <article class="inspect-node presentation-node">44+ <span class="node-kind">Presentation</span>45+ <b>{{.Presentation.Title}}</b>46+ <p>{{len .Presentation.Layers}} layers · {{len .Presentation.Styles}} styles · {{len .Presentation.Legend}} legend entries</p>47+ <details><summary>Layers, styles and legend</summary>48+ <table><thead><tr><th>layer</th><th>FeatureSet</th><th>style</th><th>visibility</th><th>zoom</th></tr></thead><tbody>49+ {{range .Presentation.Layers}}<tr><td>{{.ID}}</td><td><code>{{.FeatureSet}}</code></td><td>{{.Style}}</td><td>{{if .Visible}}visible{{else}}hidden{{end}} · {{.LabelPolicy}}</td><td>{{.MinZoom}}–{{.MaxZoom}}</td></tr>{{end}}50+ </tbody></table>51+ <h3>Styles</h3>52+ {{range .Presentation.Styles}}<p><b>{{.ID}}</b> · {{.RenderAs}}{{if .Symbol}} · {{.Symbol}}{{end}}{{if .Stroke}} · stroke <code>{{.Stroke}}</code>{{end}}{{if .Fill}} · fill <code>{{.Fill}}</code>{{end}}{{if .IconAsset}} · asset {{.IconAsset}}{{end}}</p>{{end}}53+ <h3>Legend</h3>54+ {{range .Presentation.Legend}}<p><b>{{.Label}}</b> → {{.Layer}} · order {{.Order}}</p>{{end}}55+ </details>56+ </article>57+ </div>58+59+ {{if .Rasters}}<div class="raster-strip">60+ {{range .Rasters}}<article class="inspect-node raster-node">61+ <span class="node-kind">RasterPyramid</span><b>{{.Name}}</b><br><code>{{.ID}}</code>62+ <p>z{{.MinZoom}}–{{.MaxZoom}} · full z{{.FullZoom}} · {{.TileSize}}px · {{.Codec}}</p>63+ <p class="dim">{{.TileBlobs}} packed tiles · formats {{.Formats}} · {{.Coverage}} coverage windows · {{if .Interpolate}}interpolated{{else}}nearest{{end}}{{if .Background}} · {{.Background}}{{end}}</p>64+ <code>{{.Template}}</code>65+ </article>{{end}}66+ </div>{{end}}67+68+ <div class="inspected-sets">69+ {{range .FeatureSets}}70+ {{$set := .}}71+ <article class="inspect-node inspected-set">72+ <header><span class="node-kind">FeatureSet</span><h3>{{.Title}}</h3><code>{{.ID}}</code></header>73+ <p><span class="status-chip semantic">{{.SemanticType}}</span><span class="status-chip geometry">{{.Geometry}}</span><b>{{.FeatureCount}}</b> features</p>74+ <details><summary>{{len .Properties}} typed contract fields</summary>75+ <table><thead><tr><th>name</th><th>kind</th><th>identity</th></tr></thead><tbody>76+ {{range .Properties}}<tr><td>{{.Name}}{{if .Optional}} <span class="dim">optional</span>{{end}}</td><td><code>{{.Kind}}</code></td><td><code>{{.ID}}</code></td></tr>{{end}}77+ </tbody></table>78+ </details>79+ <details open><summary>Features {{if .FeatureCount}}({{.Offset}}–{{if .HasNext}}{{.Next}}{{else}}{{.FeatureCount}}{{end}} of {{.FeatureCount}}){{end}}</summary>80+ <div class="feature-summaries">81+ {{range .Features}}<a href="/project/inspect?set={{$set.ID}}&feature={{.ID}}&offset={{$set.Offset}}"><b>{{.Title}}</b><small>{{.Geometry.Kind}} · {{.Geometry.Vertices}} positions</small></a>{{else}}<span class="dim">No compiled features.</span>{{end}}82+ </div>83+ {{if or .HasPrevious .HasNext}}<nav class="feature-pages">{{if .HasPrevious}}<a href="/project/inspect?set={{.ID}}&offset={{.Previous}}">Previous</a>{{end}}{{if .HasNext}}<a href="/project/inspect?set={{.ID}}&offset={{.Next}}">Next</a>{{end}}</nav>{{end}}84+ </details>85+ {{with .Selected}}86+ <section class="feature-facts">87+ <header><span class="node-kind">Feature facts</span><h3>{{.Title}}</h3><code>{{.ID}}</code></header>88+ {{if .Subtitle}}<p>{{.Subtitle}}</p>{{end}}{{if .Description}}<p>{{.Description}}</p>{{end}}89+ <div class="geometry-fact" data-geometry="{{.Geometry.Kind}}"><b>{{.Geometry.Kind}}</b><span>{{.Geometry.Parts}} parts · {{.Geometry.Rings}} rings · {{.Geometry.Vertices}} positions</span>{{if .Geometry.HasData}}<code>bounds {{.Geometry.Bounds}}</code>{{end}}</div>90+ <details open><summary>{{len .Properties}} typed values</summary>91+ <table><thead><tr><th>field</th><th>kind</th><th>value</th></tr></thead><tbody>92+ {{range .Properties}}<tr><td>{{.Name}}<br><code>{{.ID}}</code></td><td>{{.Kind}}</td><td><code>{{.Value}}</code></td></tr>{{end}}93+ </tbody></table>94+ </details>95+ <details open><summary>{{len .Relationships}} observed relationships</summary>96+ {{range .Relationships}}<p><b>{{.Predicate}}</b> → {{if .Title}}{{.Title}}{{else}}<span class="warn-inline">unresolved</span>{{end}} <code>{{.Target}}</code></p>{{else}}<p class="dim">No relationship edges.</p>{{end}}97+ </details>98+ <details open><summary>{{len .Provenance}} provenance records</summary>99+ {{range .Provenance}}<p><b>{{.Source}}</b> · native {{.NativeID}} · {{.CapturedAt}}</p>{{else}}<p class="dim">No provenance.</p>{{end}}100+ </details>101+ </section>102+ {{end}}103+ </article>104+ {{end}}105+ </div>106+</section>107+{{end}}108+109+<div class="inspector-lower-grid">110+<section class="card">111+ <h2>Assets and evidence receipt</h2>112+ {{range .Assets}}<p><b>{{.ID}}</b> · {{.MediaType}} · {{.Bytes}} bytes{{if .Provenance}}<br><span class="dim">{{.Provenance}}</span>{{end}}</p>{{end}}113+ {{with .Receipt}}<details open><summary>Executable replay receipt · {{len .Captures}} captures</summary>114+ <p><b>{{.Project}}</b> · project digest <code>{{short .ProjectDigest}}</code></p>115+ <table><thead><tr><th>source</th><th>capture</th><th>media</th><th>bytes</th><th>captured</th></tr></thead><tbody>116+ {{range .Captures}}<tr><td>{{.Source}}</td><td><code>{{short .SHA256}}</code><br><span class="dim">acq {{short .Acquisition}}</span></td><td>{{.MediaType}}</td><td>{{.Length}}</td><td>{{.CapturedAt}}</td></tr>{{end}}117+ </tbody></table>118+ </details>{{else}}{{if $.ReceiptErr}}<p class="warn-inline">The embedded receipt is integrity-protected, but its metadata is not valid: {{$.ReceiptErr}}</p>{{else}}<p class="dim">No Atlas authoring receipt is embedded.</p>{{end}}{{end}}119+</section>120+121+<section class="card">122+ <h2>Physical native layout</h2>123+ <details><summary>{{.TableCount}} typed tables</summary>124+ {{range .Tables}}<p><code>{{.Name}}</code> · {{.Rows}} rows · {{.Columns}} columns</p>{{end}}125+ </details>126+ <details><summary>{{.BlobCount}} opaque blobs{{if gt .BlobCount (len .BlobNames)}} · first {{len .BlobNames}} shown{{end}}</summary>127+ {{range .BlobNames}}<p><code>{{.}}</code></p>{{end}}128+ </details>129+ <details><summary>{{len .Schema}} carried schema types</summary>130+ {{range .Schema}}<div class="schema-type"><b>{{.Name}}</b> <code>{{.ID}}</code>{{range .Fields}}<p>{{.Name}} · {{.Kind}}{{if .Optional}} · optional{{end}}<br><code>{{.ID}}</code></p>{{end}}</div>{{end}}131+ </details>132+</section>133+</div>134+{{end}}@@ -146,10 +146,11 @@
146146 </div>147147 </details>148148 {{end}}149- {{if .Run.Artifact}}149+ {{if and .Run.Artifact (not .Run.Running) (not .Run.Failed)}}150150 <div class="artifact-handoff" draggable="true" data-artifact-handoff151151 data-artifact-name="{{.ArtifactName}}" data-artifact-path="{{.Run.Artifact}}">152152 <div><span class="file-icon">A</span><b>{{.ArtifactName}}</b><br><span class="dim">Drag this file onto an Atlas window</span></div>153+ <a href="/project/inspect">Inspect packed data</a>153154 <form method="post" action="/project/open"><button>Open in Atlas</button></form>154155 </div>155156 {{end}}@@ -22,6 +22,7 @@
2222 // GET /project/run replay the supervised run2323 // POST /project/open ask the desktop application to open the artifact2424 // GET /project/artifact stream the artifact for a native drag handoff25+// GET /project/inspect inspect the validated native artifact2526 // GET /assets/{path...} the stylesheet, and the hypermedia runtime2627 //2728 // Every page is measurement first: a score is the headline and everything else@@ -141,6 +142,7 @@ func (w *Workbench) routes() {
141142 w.mux.HandleFunc("GET /project/run", w.handleProjectRun)142143 w.mux.HandleFunc("POST /project/open", w.handleProjectOpen)143144 w.mux.HandleFunc("GET /project/artifact", w.handleProjectArtifact)145+ w.mux.HandleFunc("GET /project/inspect", w.handleProjectInspect)144146 w.mux.HandleFunc("GET /assets/{path...}", w.handleAsset)145147 }146148 @@ -410,4 +412,4 @@ func parseTemplates() (map[string]*template.Template, *template.Template, error)
410412 }411413 412414 // pageNames are the pages, one template file each, named for the page.413-var pageNames = []string{"library", "volume", "diff", "project"}415+var pageNames = []string{"library", "volume", "diff", "project", "inspector"}