Skip to content

feat(runtime): import a Session bundle into a workspace - #5139

Merged
likun666661 merged 5 commits into
apache:mainfrom
Joob1n:feat/session-import-bundle-0b
Sep 11, 2026
Merged

feat(runtime): import a Session bundle into a workspace#5139
likun666661 merged 5 commits into
apache:mainfrom
Joob1n:feat/session-import-bundle-0b

Conversation

@Joob1n

@Joob1n Joob1n commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Import a .maka-session bundle into a workspace — the other half of #5113 — plus the one non-blocking follow-up from that PR's approving review.

Why this is not simply the export run backwards

The export owns a private copy and can delete what is not the subtree. The import writes into a live workspace holding other people's Sessions, and can only add. That single difference drives the design.

What survives the asymmetry is the property that mattered most in #5113: no table list. The bundle's database has already been filtered down to its own Sessions, so the merge copies every table it has. A table added to the schema later travels in both directions without anyone maintaining a list — the same reason the export filters by deleting rather than selecting.

Three kinds of table are not copied:

  • Workspace-level (operational_schema_migrations, session_catalog_state, …) belong to the target, which has its own.
  • session_catalog_projection is derived, and session_metadata carries triggers that maintain it. Inserting the bundle's copy and then the Session row makes the trigger collide with what was just inserted — found by the round-trip test, and letting the target derive it is the only way it stays correct when the derivation changes.

Write order

Artifact bytes land first; the database transaction commits last. A failure between them leaves files nothing points at, which is reclaimable, rather than rows pointing at files that are not there, which is not.

The context-offload closure is merged too. Without it an imported Session arrives with its read-image references intact and none of the bytes behind them — the hole @likun666661 caught on the export side, in the other direction. Blobs are content-addressed, so an id already present is the same bytes and the insert is skipped rather than treated as a conflict.

Conflicts

A Session id already present refuses the whole bundle, before anything is written. Ids are generated rather than chosen, so one already here means this Session is already here — not that two of them collided. Importing over it would merge two histories that share ids and agree about nothing else.

Copying a Session that already exists is a different operation with a different meaning, and conversation-copy already does it. Not in this PR.

One difference from the export, on purpose

The export refuses to migrate its source, because it only reads. This opens the target the ordinary way: an import is a write the user asked for, and the target is frequently a workspace with no database yet — moving to a new machine is the point of the feature.

Follow-up from #5113

backupOperationalState() translated every acquisition failure into schema_unsupported, so an unreadable runtime.sqlite told the caller to upgrade when the real answer was that the file could not be opened. Only a blocked migration is a statement about the schema now; permission, busy and I/O failures are the environment talking and are preserved, as the operational store intends. Covered by a chmod 000 regression.

Tests

Five import tests, 18 export tests (one new), and the 20 existing storage bundle/context/lock tests.

The round-trip is the acceptance criterion #5113 could not state on its own: export a Session, import it into a different workspace that already holds someone else's Session, and compare the rows the model reads field for field — including the JSON columns whose bytes a re-encoding would change. It also asserts the receiving workspace kept what it had.

The subtree test carries a parent and its subagent child with the child's artifact bytes, and checks subagent_spawns arrives: without it the target holds two Sessions and nothing saying which tool call joined them.

Each assertion was checked by removing what it covers — the conflict precheck, the derived-table skip, the artifact copy, the bundle-schema comparison, the pragma restore, and the narrowed error translation — and confirming the corresponding test fails.

Gates: storage and runtime build and typecheck clean, biome check on every changed file, check:asf-headers, and the published-entrypoints check.

What this does not do

Desktop menus, IPC and the protocol epoch. Importing a Session that already exists as a copy. The snapshot | portable mode collapse suggested on #5113 — I would still rather let a second real profile prove itself before naming one, and this PR is the first evidence about that, not the last.

Opening the source for backup translated every acquisition failure into
`schema_unsupported`, so an unreadable `runtime.sqlite` told the caller to
upgrade when the real answer was that the file could not be opened.

Only a blocked migration is a statement about the schema. Permission, busy and
I/O failures are the environment talking, and the operational store preserves
them on purpose.
The mirror of the export, and the asymmetry is the design. The export owns a
private copy and deletes what is not the subtree; the import writes into a live
workspace holding other people's Sessions and can only add.

What keeps that from needing a table list is that the bundle's database already
contains nothing else, so the merge copies every table it has. A table added to
the schema later travels in both directions without anyone maintaining a list —
the same property that made the export's deny-by-default filter right.

Three kinds of table are not copied. The workspace-level ones belong to the
target, which has its own. `session_catalog_projection` is derived, and
`session_metadata` carries triggers that maintain it — inserting the bundle's
copy and then the Session row makes the trigger collide with what was just
inserted, and letting the target derive it is the only way it stays correct
when the derivation changes.

Write order is the safety argument. Artifact bytes land first and the database
transaction commits last, so a failure between them leaves files nothing points
at — reclaimable — rather than rows pointing at files that are not there. The
context-offload closure is merged too: without it an imported Session arrives
with its read-image references intact and none of the bytes behind them.

A Session id already present refuses the whole bundle. Ids are generated rather
than chosen, so one already here means this Session is already here, not that
two of them collided. Importing over it would merge two histories that share
ids and agree about nothing else. Copying a Session that already exists is a
separate operation and belongs to `conversation-copy`.

Unlike the export, this opens the target the ordinary way. The export refuses
to migrate its source because it only reads; an import is a write the user
asked for, and the target is often a workspace with no database yet — moving to
a new machine is the point.

The round-trip test is the acceptance criterion the export PR could not state:
export, import into a different workspace, and compare the rows the model reads
field for field, including the JSON columns whose bytes a re-encoding would
change.
@github-actions github-actions Bot added the effort/L Under 1000 readable lines label Sep 10, 2026
Three defects from reviewing the import against the workspace it writes into.

**The merge drove a shared connection directly.** The operational store hands
out one reference-counted connection per workspace with its own transaction
depth, and this ran `BEGIN IMMEDIATE` on it and left `PRAGMA foreign_keys` off
afterwards. Every later user of that workspace would have run without
constraint checking, and nothing would have reported it. The merge now goes
through the lease's transaction and puts the pragma back.

**The bundle's schema was never compared with the target's.** Rows are copied
with `INSERT ... SELECT *`, which maps by position: a bundle whose tables carry
the same column count in a different order would be inserted transposed --
rows that read as data and are not. The export only writes a bundle at its own
current schema, so a mismatch means two builds disagree, and saying so is the
only honest answer.

**A target this build cannot open reported as an IO failure.** Same shape as
the export's own follow-up: a blocked migration is a schema verdict, and
flattening it sends the caller after the wrong problem.

The bundle is also attached read-only now, so reading one can never write it.

The first regression I wrote for the pragma passed with the fix removed: it
read `PRAGMA foreign_keys` on a fresh handle, and the pragma is per-connection.
It now holds a lease across the import the way a running Runtime Host does, on
the canonicalised path the import itself resolves -- without that it observes a
different connection and proves nothing.
@likun666661

Copy link
Copy Markdown
Member

Reviewed at e5c094167. The overall approach is appropriately scoped: reuse the filtered bundle, preserve raw rows, reject existing Session IDs, and let the target rebuild its catalog projection. However, I found three correctness gaps in context import:

1. [P1] A fresh target fails after the Session has already been committed

importSessionBundleState commits the operational database before calling mergeBundleContext (lines 930-939).

withOfflineContextSnapshot returns contextLocked=false when the target has no context database. An incoming bundle containing context then fails the lock check at lines 1175-1179, even with no Runtime Host running. This is the normal fresh-workspace case.

The caller receives failure, but the Session rows have already been committed; retrying hits session_exists. More generally, any subsequent context-copy/merge failure leaves the same partially published Session.

Please acquire the required authority based on incoming context as well as target state, and prepare the context closure before publishing the Session. Failure/retry handling also needs to account for data left by earlier attempts.

2. [P1] Managed context payloads are skipped while import reports success

The copy loop only visits immediate children of the values directory and skips directories (lines 1182-1197). Actual managed payloads live under sha256/<prefix>/<hash>, so the top-level sha256 directory is skipped and no managed payloads are copied.

An isolated execution of this merge function returned one imported reference while its target payload file was absent. Please copy the validated managed-file paths with their directory structure, and test reading the imported payload rather than only counting references.

3. [P2] Existing context databases get stale usage accounting

The merge inserts only context_blobs and context_refs (lines 1209-1215). context_store_usage and context_session_usage are maintained explicitly by the context store, not by triggers.

In an isolated SQLite fixture, importing one 3-byte blob and one reference produced:

  • actual blobs / bytes: 1 / 3;
  • stored blob count / physical bytes: 0 / 0;
  • Session usage: no row.

These values drive quotas and cleanup consistency checks, so this is not just a display issue. Please maintain the accounting invariants in the same transaction.

Minimal completion path

No general migration framework or history-merging machinery seems necessary. Keep the current design, but complete its invariant: prepare all referenced data first, publish the Session last. Add regressions for a fresh target with context, nested managed-file payloads, merging into an existing context store with correct usage, and retry after an injected failure.

Verification: complete diff and related storage code reviewed; the context merge function was extracted/transpiled from this commit and exercised against real temporary SQLite databases using the repository's initial context schema. This was an isolated helper-level reproduction, not a full project test run.

Three gaps in the context half of the import, all of which let a Session
arrive without the bytes it points at.

**The Session was published before its context.** The rows committed, then the
context merge ran — so a failure there left a Session visible whose payloads
never arrived, and a retry hit `session_exists` forever because the ids were
now taken. The order is inverted: artifacts and context land first, and the
Session rows are the last thing written. A failure before that leaves data
nothing points at, which the store reclaims.

**The offline authority was decided by the wrong side.** It was taken only when
the TARGET already had a context store, so a bundle carrying context into a
fresh workspace failed the check with no Runtime Host anywhere — the common
case, and the one where the store is about to be created. It is now taken for
what is being written.

**Managed payloads were never copied.** They live at `sha256/<prefix>/<hash>`,
and the copy visited only immediate children and skipped directories: it saw
one directory, skipped it, and reported a successful import whose referenced
bytes were all absent. The tree is copied whole, and the regression reads an
imported payload rather than counting the reference that names it — a reference
whose bytes never arrived counts exactly the same.

**Usage accounting went stale.** `context_store_usage` and
`context_session_usage` are maintained by the context store explicitly; no
trigger does it. They are recomputed in the same transaction, the way the
export recomputes them when it filters. These numbers drive quotas and the
cleanup consistency checks.

One more, found while testing the above: the merge now asks the same question
of a bundle table that the export asks before emptying one. A table with no
Session column and no referential rule describes the workspace, and the target
has its own. The export empties those, so this inserts nothing in practice —
but an import that relies on the other side having tidied up is one bundle away
from writing a workspace singleton into somebody else's workspace.
@Joob1n

Joob1n commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

All three fixed. The reproductions were precise enough to confirm each one directly — thanks.

The first is the one that mattered most, and your framing of it is the fix: prepare everything referenced, publish the Session last. Artifacts and context now land before the Session rows, so a failure leaves data nothing points at — which the store reclaims — rather than a Session already visible whose payloads never arrived and whose ids a retry can no longer claim.

The authority follows from the same correction: it is taken for what is being written, not for what the target already has. A bundle carrying context into a fresh workspace is the common case and was the one running unprotected.

Managed payloads. Copied with their tree now. The regression reads an imported payload rather than counting the reference that names it, since a reference whose bytes never arrived counts exactly the same — that distinction is what your repro turned on.

Usage accounting. Recomputed in the same transaction, using the same recomputation the export does when it filters. Covered by merging into an existing store and asserting blob count, physical bytes and the per-Session rows.

One more that surfaced while writing those tests: the merge now asks the same question of a bundle table that the export asks before emptying one — a table with no Session column and no referential rule describes the workspace, not a Session. The export empties those, so this inserts nothing in practice, but an import relying on the other side having tidied up is one bundle away from writing a workspace singleton into someone else's workspace.

Regressions added for a fresh target with context, nested managed payloads read back, merging into an existing store with correct accounting, and retry after an injected failure. Each was checked by removing the fix it covers; the publish-order one was the one I had no coverage for until you named it.

@likun666661

Copy link
Copy Markdown
Member

Follow-up review at 4e7ec3cba.

The recursive managed-payload copy is fixed, and publishing the Session after its context is the right ordering correction. Per-Session usage is now rebuilt too. However, the failure/retry and fresh-target cases are not yet closed, and the new physical-byte recomputation introduces a GC accounting regression.

1. [P1] A failed import still prevents retry when the bundle contains artifacts

Artifacts are copied before the context merge, but they are neither rolled back on failure nor recognized as identical leftovers on retry. The unchanged COPYFILE_EXCL handling rejects them.

I reproduced this through importSessionBundleState with the real context schema:

  1. Import a bundle containing an artifact and context; arrange the same context-ref conflict used in the new regression.
  2. The first attempt fails with UNIQUE constraint failed: context_refs.ref_id after copying the artifact.
  3. Remove the original context-ref conflict and invoke the import again.
  4. The retry fails with conflict: Artifact already present: <session>/file.txt.

Moving publication last prevents a broken visible Session, but does not by itself make the operation retryable. Please clean up only files owned by the failed attempt, or safely recognize identical staged leftovers without overwriting unrelated data. The new test named “so a retry is still possible” neither includes an artifact nor actually retries; it only checks that Session metadata is absent. Please remove the injected failure and assert the second import succeeds with both payloads intact. Also cover failure at the final operational merge after context has already committed, since context_refs insertion is not idempotent either.

2. [P2] A genuinely fresh, unmarked target still cannot import context

requireAuthority now correctly follows the incoming bundle, but the helper still calls discoverMarkedStorageRoot. Discovery does not initialize a new root.

Using an existing empty directory as the target and a valid source containing context now fails with root_unmarked: Storage root is not marked. This happens before the ordinary target initialization path. A marked root without context and a fresh directory are different cases; the new tests seed/mark both targets and therefore do not exercise this boundary.

Please initialize/resolve the target through the existing root authority before taking its owner lease, or explicitly require and validate an initialized root at the public API/CLI boundary. The latter would narrow the currently described fresh-workspace behavior.

3. [P2] Recomputing physical_bytes from live blobs loses pending-file-deletion bytes

The new UPDATE replaces physical_bytes with only SUM(context_blobs.size_bytes). In a live target, bytes in context_file_deletions are intentionally still charged until deletion completes: the context store removes a managed blob row before draining its file, then #releasePendingFileBytes subtracts those bytes.

Reproduction using the actual migrated context schema:

  • Target starts with 10 bytes in a pending managed-file deletion and physical_bytes = 10.
  • Import a distinct 3-byte managed blob.
  • Import succeeds, but physical_bytes becomes 3 rather than 13; the 10-byte deletion remains queued.
  • The deletion-drain accounting UPDATE (physical_bytes >= 10) changes zero rows, which the store reports as Context physical byte accounting underflow.

The export can use the simpler sum because it explicitly empties the deletion queue on its private copy. The live import preserves that queue, so it cannot reuse the export formula unchanged. Please preserve pending-file accounting and reconcile any pending deletion whose payload the import makes live again.

Verification / test coverage

I bundled this commit's source with esbuild and ran the complete session-import.test.ts file under Node: 7/7 passed. The additional reproductions above invoke the real importSessionBundleState and use the repository's context schema migration, not a hand-written approximation. This is focused verification, not the full repository build/typecheck/test suite.

Please also consider replacing seedContext's ad hoc schema with the real migration/store fixture. It declares schema version 3 but omits real columns, uses ref_count instead of reference_count, and inserts an owner kind the real schema does not allow. Those tests demonstrate the local SQL flow but cannot establish that the imported database is usable by the actual context store.

The main design still does not need a new framework. The remaining work is to complete the existing ownership, retry, and accounting invariants rather than add another abstraction.

A failed import left three kinds of residue that turned the second
attempt into a permanent conflict:

- Artifacts were staged before the context merge and neither rolled
  back nor recognised on retry, so `COPYFILE_EXCL` refused the import's
  own leftover. The copy now reports what it created, the caller removes
  exactly those on failure, and a leftover from a crashed attempt is
  accepted only when its bytes are identical.
- `context_refs` insertion was not idempotent, so a failure after the
  context transaction committed blocked the retry. Identical rows are
  now skipped; a row naming different content is still a conflict.
- A genuinely fresh workspace could not import context at all: the
  snapshot lock discovered a marked Storage Root rather than resolving
  one. A writer that is about to create the store now initialises it.

Also stop recomputing `physical_bytes` from live blobs alone. Bytes
queued in `context_file_deletions` are on disk and still charged, and
the store subtracts them when the drain completes -- so dropping them
here made that later subtraction underflow. The export can use the
simpler sum because it empties the queue on its private copy.

The context fixture now uses the real v3 schema rather than an
approximation of it.
@Joob1n

Joob1n commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

All three fixed in 64642fa4.

Artifacts blocking the retry. The copy now reports the paths it created, and the caller removes exactly those when a later step fails — nothing else in artifacts/ is touched. A leftover from a crash that got past the rollback is accepted only when its bytes are identical; different bytes under the same path are still a conflict.

context_refs idempotency. Identical rows are skipped on re-insert; a row whose id names a different Session or blob is reported as a conflict instead of being ignored. Both halves are covered.

Fresh unmarked target. withOfflineContextSnapshot resolves the Storage Root when the caller says it is about to write, rather than only discovering a marked one — a workspace that has never opened a Session is exactly the first thing an import writes to.

Pending deletions. physical_bytes now sums live blobs plus context_file_deletions. Those bytes are on disk and still charged; the store subtracts them when the drain completes, so recomputing without them made that subtraction underflow. The export can use the simpler sum because it empties the queue on its private copy.

Tests: the retry test now stages an artifact, fails, and actually retries, asserting both payloads and the artifact bytes survive; a second test fails at the operational merge after the context transaction has committed and retries from there; the identical-vs-different leftover is covered both ways; a bare directory with no marker imports context; and the pending-deletion total is asserted. seedContext now builds the real v3 schema — reference_count, the allowed owner kinds, and the columns the store actually writes.

Each fix was checked by reverting it: rejecting any leftover, plain INSERT for refs, discovery instead of resolve, and blobs-only bytes each turn the matching test red. 11 pass in session-import, and session-export, session-bundle-policy, context-offload-snapshot, artifact-writer-lock and production-session-snapshot are unchanged and green.

@likun666661
likun666661 merged commit 898ac52 into apache:main Sep 11, 2026
13 checks passed
Shouly pushed a commit to Shouly/maka that referenced this pull request Sep 11, 2026
Seven upstream commits: managed WSL recovery and handoff (apache#5175, apache#5161,
apache#5160), Session bundle import (apache#5139), WorkHub model routing and delegated
results (apache#5152, apache#5150), React dev performance measures (apache#5156).

Resolution per the sync policy: every conflict under the old renderer's
WorkHub, composition and platform trees, its stories and the main tests that
import them stays deleted; upstream's new files in those trees
(delegation-feedback.ts, react-performance-measures.ts and its test) are
dropped; `components/ui/textarea.tsx` was a rename/rename against
`features/workhub/index.ts` and keeps ours; `locales/conversation-copy.ts`
auto-merged and is Biome-formatted. Nothing in this round is behaviour the new
renderer has to re-implement.

Gates: build:test + build:renderer, typecheck, ledger, biome, knip, locale
hygiene, ASF headers, desktop dist tests (1575), renderer state (237),
Electron smoke (44), core-dialogue smoke.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/L Under 1000 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants