Skip to content

feat: asset publish/unpublish surface, change feed and the IFC sweep - #380

Merged
Krande merged 4 commits into
mainfrom
feat/asset-publish-and-change-feed
Sep 23, 2026
Merged

Krande merged 4 commits into
mainfrom
feat/asset-publish-and-change-feed

Conversation

@Krande

@Krande Krande commented Sep 22, 2026

Copy link
Copy Markdown
Owner

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:

  • The owner gate. A provider that sets change.published_by or published_via is 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.
  • 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 instead 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 (a staging_id or explicit staged keys; dry_run echoed back), GET /assets/staging (recovery is a listing — the store is the memory, so an upload whose publish was never started is findable), 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 (Phase 3's 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 (additive, nullable), and the plant-a_v2 / v2-leaf corpus. The four feed states stay honest: current is one administrative row re-affirming the published instant and never advancing it, which is what distinguishes it from not-recorded without this table ever holding a per-node NOCHANGE value.

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.ts with the four states as a closed union — current and not-recorded must never collapse (a ref absent from an answer that was given means nobody looked), and a 503 is no-feed, never a map of current. Plus 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, two inputs, never one badge.

Verification

  • Python: 1108 pass / 103 skipped across tests/core/assets + tests/comms/rest (35 publish/unpublish tests, 34 IFC publisher/sweep tests). Migration 030 and the source_nodes round trip were also exercised against a real throwaway Postgres.
  • Frontend: 1025 tests (1024 pass, 1 pre-existing skip); tsc clean.
  • pixi run lint-check clean; 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.ifc is 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

Krande and others added 3 commits September 22, 2026 20:48
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
@github-actions

Copy link
Copy Markdown

PR Review

👋 I checked your PR and found no issues. Thanks!

  • ✅ PR title is ok
  • ✅ Exactly one release label
  • ✅ SOURCE_KEY secret is set
  • ✅ Calculated next version: "0.84.0"

@github-actions

github-actions Bot commented Sep 22, 2026

Copy link
Copy Markdown

🚀 Profiling Results (Top 20 most expensive calls)

Function Calls Duration (s)
<built-in method builtins.exec> (~:0) 2016 21.8440
<module> (<string>:1) 1 21.8439
test_bench_batch_tessellate (tests/profiling/test_cad_backend_bench.py:63) 1 6.6818
run (tests/profiling/test_cad_backend_bench.py:73) 6 6.4436
batch_tessellate (src/ada/visit/tessellate.py:1102) 1446 6.4425
test_build_big_ifc_pipe (tests/profiling/test_ifc_creation.py:48) 1 6.1006
tessellate_geom (src/ada/visit/tessellate.py:830) 1440 5.8853
__truediv__ (src/ada/api/spatial/part.py:2042) 8 5.7058
tessellate_occ_geom (src/ada/visit/tessellate.py:684) 1440 5.3588
tessellate_shape (src/ada/visit/tessellate.py:554) 1440 5.3446
to_ifc (src/ada/api/spatial/assembly.py:292) 5 4.6953
tessellate (src/ada/cad/__init__.py:1163) 1440 4.3603
sync (src/ada/cadit/ifc/store.py:200) 5 4.3173
sync_added_physical_objects (src/ada/cadit/ifc/write/write_ifc.py:92) 5 4.2600
add (src/ada/cadit/ifc/write/write_ifc.py:582) 2200 4.1801
add_object (src/ada/api/spatial/part.py:309) 1201 4.1360
add_pipe (src/ada/api/spatial/part.py:164) 200 4.1173
add_section (src/ada/api/spatial/part.py:294) 801 3.4693
add (src/ada/api/containers/sections.py:139) 805 3.4680
equal_props (src/ada/sections/concept.py:100) 241399 3.1579

📈 Performance Impact per Commit

Commit Total Top-20 Duration (s) Change
87f19cd 128.8119 -
9a24846 131.8126 ⚪ +3.0007 (+2.33%)

`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
@Krande
Krande merged commit 19ffc9a into main Sep 23, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant