feat: asset publish/unpublish surface, change feed and the IFC sweep - #380
Merged
Merged
Conversation
Phase 3 of the core asset browser: a delivery claim becomes scene content, and core ships a provider that exercises every mechanic in CI. Core (`ada.assets`): - build.py — core composes the derived key from identity (provider/collection/subject/revision/node/fingerprint) and never from a value inside the provider's options; the fingerprint hashes only the provider-NAMED inputs plus the node and the hierarchy that placed it, and never emits defaults, so adding an option cannot re-key existing builds. `ada.assets/build@1` summaries, validation that names the disagreeing field, and a GLB `asset.extras` patcher that rewrites only the JSON chunk. - builders.py — capability -> builder, idempotent by origin, with an availability probe so a pool never claims a builder it cannot import. - catalogue_adapter.py — any ExternalModelCatalog as a live provider whose model rows claim `mesh`, minted per call. - ifc/ — the shipped provider: a geometry-free spatial walk (GlobalId as the node id, verbatim), publish in three scopes (whole file, --root, --leaf, incl. leaf-without-stem) with manifests written last, the private `ifc-index` artefact, and a builder that slices the node's subtree through adacpp `stream_ifc_to_glb(include_guids=...)`. REST/worker: `POST /assets/build` reads the claim from the node's own manifest (a caller names a node, never a capability), answers a repeat from the store without enqueueing, and takes an optional `subject` so a node covered by an ancestor's publish still builds scoped to itself. An `asset_build` job kind, a local engine so a queue-less viewer can build, and pools that derive their build capabilities from the registry. Frontend: `@/assets/delivery.ts` turns a claim into scene content — mesh loads directly, build posts/polls then validates the summary against the request before loading — with a bounded wait so a capability no pool serves fails with a sentence instead of a spinner. Load / reveal in Files / unload in the Assets tab. Also: the loader refuses a scene-less glTF with a message naming the file, rather than a TypeError two lines later — core now loads GLBs it did not produce. Verified in a browser against a local scope: an IFC site builds on demand (~2s cold, cached repeat enqueues nothing) and a fixture mesh leaf loads; both appear in the Files tab as ordinary sources. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 4 of the core asset browser: the leaf/branch update surface, driven end to end by the in-tree IFC provider. Publishing — a provider PLANS, core WRITES (`ada.assets.publish`). That inversion makes two rules structural instead of things every provider must remember: the owner gate (a provider that sets `change.published_by` or `published_via` is refused BY NAME — those record who called core, which no provider can observe) and manifests-last (the plan is an ordered list, checked before a byte is written, so a half-written publish is invisible rather than discoverable-and-broken). `published_via` is derived from the authenticated principal, so a scheduled run records `service` rather than putting a person's name on a revision nobody pushed. Unpublishing — the refcount check the prior art left as "the publisher's obligation" (`ada.assets.unpublish`). An obligation every publisher must remember is one that will eventually be forgotten, and what it costs is a manifest pointing at a deleted blob: an asset that lists, resolves and badges like a working one and fails only at load. The route refuses, names the holder, and deletes the manifest first so a partial delete reads as unpublished. `_derived/` builds are not cascaded — they become unreachable, not wrong. Routes: `POST /assets/publish` (staging_id or explicit staged keys, dry_run echoed), `GET /assets/staging` (recovery is a listing, because the store is the memory — an upload whose publish was never started leaves bytes nobody remembers), refcount-checked `DELETE`, an `asset_publish` job kind and a local engine so publishing is not a verb that only exists in a cluster. IFC: `IfcAssetPublisher` (the Phase-3 publish split into plan + apply), `asset-sweep-ifc` comparing a staged newer file against the published spine and recording `source_nodes` rows with `action`, migration 030 (nullable), and the v2 / v2-leaf corpus. The four feed states stay honest: `current` is one administrative row re-affirming the published instant (never advancing it), which is what tells it from `not-recorded` without this table ever holding a per-node NOCHANGE value. Fixed while wiring the sweep: `ifc/index.py`'s per-product hash embedded a STEP entity id at every nesting depth, so two independent exports disagreed on EVERY product — the sweep would have reported the whole file as changed. Frontend: `@/assets/changes.ts` with the four states as a closed union (`current` and `not-recorded` must never collapse; a 503 is `no-feed`, never a map of `current`), a typed source-nodes client, per-node evidence marks, owner lines shown only when a manifest carries them, and a `behind` banner kept visually and textually distinct from `stale` — two facts from two inputs, never one badge. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KXGBkUbGK6oYWxm5R2WYVB
…d-change-feed # Conflicts: # src/ada/assets/ifc/__init__.py # src/ada/assets/ifc/index.py # src/ada/assets/ifc/publish.py # src/ada/comms/rest/formats/__init__.py # src/ada/comms/rest/job_transport.py # src/ada/comms/rest/local_jobs.py # src/ada/comms/rest/routes/assets.py # src/frontend/src/__tests__/state/assetBrowserStore.test.ts # src/frontend/src/assets/types.ts # src/frontend/src/components/asset_browser/AssetsTab.tsx # src/frontend/src/state/assetBrowserStore.ts # tests/comms/rest/test_format_registry.py # tests/comms/rest/test_job_transport.py # tests/core/assets/corpus/make_plant_a.py # tests/core/assets/corpus/plant-a_v1.ifc # tests/core/assets/ifc/fake_store.py
PR Review👋 I checked your PR and found no issues. Thanks!
|
🚀 Profiling Results (Top 20 most expensive calls)
📈 Performance Impact per Commit
|
`test_ifc_sweep.py` imported fastapi at module scope for one assertion about a ROUTE helper's 503, which collected — and so failed — in every environment without the REST extras (`Test ada-py-core`, `test (ubuntu-latest)`, `Test ada-cpp integration`, the feedstock recipe job). The assertion itself is worth keeping: `no-feed` must never collapse into `current`. It just belongs with the REST tests, where the stack it asserts against exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KXGBkUbGK6oYWxm5R2WYVB
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Phase 4 of the core asset browser (Phases 1-3 = #371, #377, #378): the leaf/branch update surface, driven end to end by the in-tree IFC provider.
Publishing — a provider PLANS, core WRITES
ada.assets.publish. That inversion turns two rules from things every provider must remember into properties of the store:change.published_byorpublished_viais refused by name — those fields record who called core, which no provider can observe. Relaying what the source says (source_actor,action,source_instant) stays the provider's to do, and is labelled as relayed.published_viais derived from the authenticated principal, so a scheduled run recordsserviceinstead of putting a person's name on a revision nobody pushed.Unpublishing — the refcount check
ada.assets.unpublish. The prior art left this as "the publisher's obligation"; an obligation everyone must remember eventually gets forgotten, and the cost is a manifest pointing at a deleted blob — an asset that lists, resolves and badges like a working one and fails only at load. The route refuses with the holder named, and deletes the manifest first so a partial delete reads as unpublished._derived/builds are not cascaded: they become unreachable, not wrong.Routes
POST /assets/publish(astaging_idor explicit staged keys;dry_runechoed back),GET /assets/staging(recovery is a listing — the store is the memory, so an upload whose publish was never started is findable), refcount-checkedDELETE, anasset_publishjob kind, and a local engine so publishing is not a verb that only exists in a cluster.IFC
IfcAssetPublisher(Phase 3's publish split into plan + apply),asset-sweep-ifccomparing a staged newer file against the published spine and recordingsource_nodesrows withaction, migration030(additive, nullable), and theplant-a_v2/v2-leafcorpus. The four feed states stay honest:currentis one administrative row re-affirming the published instant and never advancing it, which is what distinguishes it fromnot-recordedwithout this table ever holding a per-nodeNOCHANGEvalue.Bug found while wiring the sweep:
ifc/index.py's per-product hash embedded a STEP entity id at every nesting depth, so two independent exports of the same model disagreed on every product — the sweep would have reported an untouched file as entirely changed. Now only the deliberately-modified member's hash differs between v1 and v2.Frontend
@/assets/changes.tswith the four states as a closed union —currentandnot-recordedmust never collapse (a ref absent from an answer that was given means nobody looked), and a 503 isno-feed, never a map ofcurrent. Plus a typed source-nodes client, per-node evidence marks, owner lines shown only when a manifest carries them, and abehindbanner kept visually and textually distinct fromstale: two facts, two inputs, never one badge.Verification
tests/core/assets+tests/comms/rest(35 publish/unpublish tests, 34 IFC publisher/sweep tests). Migration 030 and thesource_nodesround trip were also exercised against a real throwaway Postgres.tscclean.pixi run lint-checkclean; bundle provenance OK; the layering and no-vendor-names gates re-checked (two violations in new files fixed — core must never name a vendor system or work repo).Note:
plant-a_v1.ifcis regenerated (name-keyed guids, so identity is stable across two independent exports) and is therefore not byte-identical to the file #378 committed. All Phase-3 IFC tests pass unchanged against it — they look nodes up by label, never by hardcoded guid.🤖 Generated with Claude Code