Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions docs/plans/interactive-migration-implementation.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
# Plano de implementação — evolução do SessionMigrate

Status: revisado e autorizado para publicação. Revisão 1, 09/10/2026.
Implementação ainda não iniciada.
Status: implementação em andamento. Spec/plano revisão 1, 09/10/2026; progresso atualizado em 10/10/2026.
F2A integrada em `next`; F2B é o marco atual. Os demais marcos seguem os gates abaixo.

Fonte normativa: [spec](../specs/interactive-migration.md), revisão 1.
Base de código: commit `662976e`. A fase 1 não integra este plano.
Nenhuma tarefa de implementação abaixo foi executada nesta entrega documental.
As checkboxes abaixo definem o plano; o todo registra execução e evidências por marco.

Progresso versionado:
`/storage/Agents/AgentZero/.ai-data/todos/2026-10/todo-session-migrate-evolution-spec-plan-091026.md`.
`/storage/Agents/AgentZero/.ai-data/todos/2026-10/todo-session-migrate-evolution-101026.md`.
Antes de executar, adicionar os itens de cada marco ao todo, com responsável,
escopo e evidência. Checkboxes deste plano são um modelo de execução; o todo é
a fonte de progresso e deve apontar a versão aprovada destes documentos.
Expand Down
106 changes: 106 additions & 0 deletions docs/session-profiles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# Session profiles (F2B core API)

`session_migrate.profiles` provides opt-in, content-free profiles for callers of
the shared core. This milestone adds no CLI commands or UI dependency.
Constructing a `Profile` does not create directories, launch a process, install
anything, or change `os.environ`. CWD remains an independent operation option.

A profile contains a filename-safe ID, display name, format, absolute native
root, optional absolute CLI path and HOME/XDG path overrides. Only `HOME`,
`XDG_DATA_HOME`, `XDG_CONFIG_HOME`, `XDG_CACHE_HOME`, `XDG_STATE_HOME` and
`XDG_RUNTIME_DIR` may be persisted as overrides. Credentials, format-specific
configuration variables and arbitrary environment keys are rejected.
The native root is canonicalized; source roots must exist and be registered
with that exact format in the catalog. Roots are directories, except MastraCode
also accepts its canonical `mastra.db` file as an exact source/target root.
That file is passed directly to the native loader/installer; other filenames
are rejected rather than redirected to a sibling database. A target filesystem
root or canonical MastraCode database may be new.

`profile_from_catalog_root` requires an explicit format and environment mapping;
it does not guess a runtime environment from the catalog. OpenCode/Kilo require
an explicit executable CLI. Their source roots must pass the F2A native
store directory/database identity guard: the exporter opens exactly
`XDG_DATA_HOME/opencode` or `XDG_DATA_HOME/kilo`, including the selected store's
WAL/SHM directory. An explicit XDG data override that contradicts the root is
an error. New native targets must use the canonical `opencode`/`kilo` directory
name. Populated native targets also pass the existing directory/database guard.
No configuration or authentication files are copied between profiles.

## Small interface contracts

- `Profile.environment(base)` returns a fresh runtime mapping. A HOME override
supplies HOME-local defaults for XDG data/config/cache/state unless explicitly
overridden, and removes an inherited runtime directory. Source and target
mappings are constructed independently from the same base; the source mapping
is never used as the target base. Runtime credentials may remain in the caller's
base environment but cannot enter the persisted override mapping.
- `SessionRef` identity includes format, canonical root and native ID, plus an
optional profile ID. Listed refs also retain the catalog ID and a fingerprint
of profile options. Reusing one with another root/profile, or after changing
its CLI/environment, fails explicitly. Two roots may have the same native ID.
- `list_profile_sessions(catalog, profile, ...)` filters the exact root before
applying pagination. It forwards query, status, kind, lifecycle and RFC-3339
start-date filters. Each result carries profile ID, metadata and a qualified
ref (or `None` when native identity is absent). Missing and unsupported rows
remain visible by default, with their catalog reason; adapters determine
which archived/subagent schemas are transferable.
- `resolve_source` verifies the catalog identity/status and physical path under
the selected root. `read_session` then loads the authoritative file or native
export and verifies its format/native ID. Catalog metadata is derived and may
be stale; a refresh is explicit and does not substitute for authoritative read.
- `run_profile_operation` binds a transfer request to source ref and target
profile, rejects conflicting free selectors and calls the existing adapters.
`run_operation(..., source_environ=...)` resolves source HOME, CLI, path,
source CWD, shared database defaults and catalog in the source environment;
target paths, CWD and installers continue to use `environ`. Omitting the new
keyword preserves the legacy single-environment contract.

Ordering follows the catalog: physical files use filesystem mtime; OpenCode/Kilo
use native `time_updated` with database mtime fallback; other shared databases
use the native inventory update timestamp with database mtime fallback.
`ordering_source` declares that policy. `started_at_source` is `native_metadata`;
a missing timestamp stays missing. Neither value claims observed user activity.
Bodies are not indexed, persisted in profile state, or included in progress.
Read/parsing/export may block and should run in a UI worker in the next milestone.

## Optional private storage

`ProfileStore(absolute_path).create(profiles)` explicitly creates one JSON
snapshot with `schema_version: 1`. IDs and case-insensitive names must be unique.
`load()` and `get(profile_id)` reject absent IDs, unknown schemas, duplicate JSON
keys, unknown fields, invalid formats/environment and files over 1 MiB.

Persistence requires POSIX ownership and no-follow support. Directory traversal
uses directory descriptors and rejects symlinks. The storage directory must be
owned by the current user with mode 0700; the file must be an owned, single-link
regular file with mode 0600. Newly created directories/files use these private
permissions and the complete write is flushed/fsynced. Creation is exclusive:
an existing snapshot, including a symlink, is never overwritten. Editing,
replacing or removing stored profiles needs an explicit future API contract.
No default storage path or automatic writes are introduced.

## Capabilities and validation boundary

`capabilities_for(format, schema=...)` and `list_capabilities()` describe all
18 source/target adapters. Symbolic schema names identify the implemented
contracts; they do not bypass native version/shape validation. Codex exposes
legacy and paginated sources, with only legacy output. OpenCode exposes legacy
and v2 transfer schemas. Pi/OMP expose v3; Hermes/Devin expose pinned database
and portable export schemas; other formats expose their existing native adapter.
Portable image support is scoped for Claude/Codex/OpenCode, Cursor declares
text-only, and unassessed media capabilities remain unknown.

Native title override and in-place update are false for every schema, with
`proof_version=None` and an explanation of the required F3/F5B proof. Discovering
existing native titles, or importing their current default title, does not enable
those future operations. Unknown formats/schemas fail explicitly. Native parsers
and installers remain authoritative for schema validation and loss accounting.

Synthetic tests cover same IDs across roots, distinct titles, pagination,
status/date filters, absent sources, archived Codex, unsupported Claude
sidechains, changed identity/environment, private storage and source/target
runtime isolation. The optional stock OpenCode 2.0.22/2.0.23 oracle imports the
same ID into two temporary native stores and re-exports each selected title.
It selects `SESSION_MIGRATE_TEST_OPENCODE_V2` first, with PATH discovery as a
local fallback. No real session store or provider is required.
1 change: 1 addition & 0 deletions scripts/run-native-test-client.sh
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ case "$client" in
tests/test_opencode_v2.py::test_native_v2_isolated_import_export_dry_run_and_collision
tests/test_opencode_v2_native.py
tests/test_opencode_v2_review.py::test_stock_v2_accepts_and_cold_exports_review_sources_and_fixed_targets
tests/test_profiles.py::test_profile_stock_opencode_two_stores_exact_native_id
)
;;
openhands)
Expand Down
112 changes: 112 additions & 0 deletions src/session_migrate/capabilities.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"""Conservative adapter capabilities, independent of interface or process state.

Schema names describe adapter contracts, not automatic acceptance of arbitrary
native client versions. Native parsers/installers remain the final validators.
"""

from __future__ import annotations

from dataclasses import dataclass
from importlib import import_module

from session_migrate.errors import SessionMigrateError
from session_migrate.formats import opencode_v2
from session_migrate.model import AgentFormat


@dataclass(frozen=True, slots=True)
class AdapterCapabilities:
format: AgentFormat
schema: str
source_supported: bool
target_supported: bool
pinned_cli_version: str
media_source: bool | None = None
media_target: bool | None = None
native_title_supported: bool = False
update_supported: bool = False
proof_version: str | None = None
title_reason: str = "Native title override has not passed the F3 native picker/resume proof."
update_reason: str = (
"In-place update has not passed the F5B concurrency/recovery/native resume proof."
)
media_scope: str = "Unknown; the native adapter validates and reports omissions."


def _registry() -> dict[tuple[AgentFormat, str], AdapterCapabilities]:
result = {}
for agent_format in AgentFormat:
module = import_module(f"session_migrate.formats.{agent_format.value}")
pin = getattr(module, f"PINNED_{agent_format.value.upper()}_VERSION")
schemas = ("native",)
if agent_format == AgentFormat.CODEX:
schemas = ("legacy", "paginated")
elif agent_format == AgentFormat.OPENCODE:
schemas = ("legacy", "v2")
elif agent_format in {AgentFormat.PI, AgentFormat.OMP}:
schemas = ("v3",)
elif agent_format == AgentFormat.HERMES:
schemas = ("sqlite-v26", "export-v26")
elif agent_format == AgentFormat.DEVIN:
schemas = ("sqlite-v16", "export-v16")
for schema in schemas:
media = None
scope = "Unknown; the native adapter validates and reports omissions."
if agent_format in {AgentFormat.CLAUDE, AgentFormat.CODEX, AgentFormat.OPENCODE}:
media = True
scope = (
"Portable user/tool-result image blocks; "
"role, MIME, size and encoding restrictions apply."
)
elif agent_format == AgentFormat.CURSOR:
media = False
scope = (
"Experimental ordered user/assistant text only; omitted classes are counted."
)
result[agent_format, schema] = AdapterCapabilities(
agent_format,
schema,
True,
not (agent_format == AgentFormat.CODEX and schema == "paginated"),
opencode_v2.VALIDATED_VERSION
if agent_format == AgentFormat.OPENCODE and schema == "v2"
else pin,
media,
media,
media_scope=scope,
)
return result


_CAPABILITIES = _registry()


def capabilities_for(
agent_format: AgentFormat | str,
*,
schema: str | None = None,
) -> AdapterCapabilities:
"""Return a declared schema or fail explicitly, without probing a CLI/store."""
try:
selected = AgentFormat(agent_format)
except (ValueError, TypeError) as exc:
raise SessionMigrateError("adapter format is unsupported") from exc
if schema is not None and not isinstance(schema, str):
raise SessionMigrateError("adapter schema must be a known string identifier")
if schema is None:
return next(value for (format_, _), value in _CAPABILITIES.items() if format_ == selected)
try:
return _CAPABILITIES[selected, schema]
except KeyError as exc:
raise SessionMigrateError(
"adapter schema is unknown; native validation is required"
) from exc


def list_capabilities(
agent_format: AgentFormat | str | None = None,
) -> tuple[AdapterCapabilities, ...]:
if agent_format is None:
return tuple(_CAPABILITIES.values())
selected = capabilities_for(agent_format).format
return tuple(value for (format_, _), value in _CAPABILITIES.items() if format_ == selected)
8 changes: 8 additions & 0 deletions src/session_migrate/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -1752,6 +1752,7 @@ def list_sessions(
include_paths: bool = False,
include_missing: bool = False,
agent_format: AgentFormat | None = None,
root_id: int | None = None,
statuses: Sequence[str] = (),
kinds: Sequence[str] = (),
lifecycles: Sequence[str] = (),
Expand All @@ -1766,6 +1767,13 @@ def list_sessions(
raise SessionMigrateError("catalog offset cannot be negative")
where: list[str] = []
parameters: list[Any] = []
if root_id is not None:
if isinstance(root_id, bool) or not isinstance(root_id, int) or root_id < 1:
raise SessionMigrateError("catalog root ID must be a positive integer")
if not any(root.id == root_id for root in self.roots()):
raise SessionMigrateError("catalog root ID was not found")
where.append("s.root_id = ?")
parameters.append(root_id)
if not include_missing:
where.append("s.status != 'missing'")
if agent_format:
Expand Down
56 changes: 33 additions & 23 deletions src/session_migrate/operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,32 +209,41 @@ def run_operation(
*,
environ: Mapping[str, str] | None = None,
progress: ProgressCallback | None = None,
source_environ: Mapping[str, str] | None = None,
) -> OperationResult:
"""Snapshot the environment once and use existing validated adapters."""
"""Snapshot target/source environments independently and use validated adapters.

Omitted source_environ preserves the legacy single-environment contract.
"""

values = dict(os.environ if environ is None else environ)
if environ is None and not values.get("HOME"):
values["HOME"] = str(Path.home())
source_values = dict(values if source_environ is None else source_environ)
source_fields = {"path", "source_home", "source_cwd", "source_cli", "catalog"}
paths = {
field.name: expand_environment_path(value, values)
field.name: expand_environment_path(
value, source_values if field.name in source_fields else values
)
for field in fields(request)
if isinstance(value := getattr(request, field.name), Path)
}
request = replace(request, **paths)
for key in (
"OPENCODE_BIN",
"KILO_BIN",
"XDG_DATA_HOME",
"XDG_STATE_HOME",
"SESSION_MIGRATE_CATALOG",
):
if values.get(key):
values[key] = str(expand_environment_path(Path(values[key]), values))
# Explicit temp roots avoid tempfile's process-wide, cached environment.
values["TMPDIR"] = str(_temporary_root(values))
for selected in (values, source_values):
for key in (
"OPENCODE_BIN",
"KILO_BIN",
"XDG_DATA_HOME",
"XDG_STATE_HOME",
"SESSION_MIGRATE_CATALOG",
):
if selected.get(key):
selected[key] = str(expand_environment_path(Path(selected[key]), selected))
# Explicit temp roots avoid tempfile's process-wide, cached environment.
selected["TMPDIR"] = str(_temporary_root(selected))
_notify(progress, ProgressStage.LOADING)
try:
result = _execute(request, values, progress)
result = _execute(request, values, progress, source_values)
except NativeImportPartialError:
_notify(progress, ProgressStage.NEEDS_RECONCILE)
raise
Expand All @@ -249,6 +258,7 @@ def _execute(
request: OperationRequest,
environ: Mapping[str, str],
progress: ProgressCallback | None,
source_environ: Mapping[str, str],
) -> OperationResult:
if request.command != OperationKind.TRANSFER and (
request.path is None or request.target_format is None
Expand All @@ -269,7 +279,7 @@ def _execute(
raise SessionMigrateError(
"--source-home/--source-cwd do not apply with catalog selection"
)
with Catalog(request.catalog or _catalog_default(environ)) as catalog:
with Catalog(request.catalog or _catalog_default(source_environ)) as catalog:
catalog_id = request.catalog_id
if request.title:
title = request.title.strip()
Expand Down Expand Up @@ -309,7 +319,7 @@ def _execute(
requested_source_id = entry.session_id
if source_format in {AgentFormat.OPENCODE, AgentFormat.KILO}:
virtual_source_environ = _catalog_virtual_source_environment(
source_format, source_reference.root, environ
source_format, source_reference.root, source_environ
)
else:
if not request.source_id or not request.source_format:
Expand All @@ -331,11 +341,11 @@ def _execute(
else load_kilo_session
)
session = loader(
requested_source_id, source_cli=request.source_cli, environ=environ
requested_source_id, source_cli=request.source_cli, environ=source_environ
)
elif source_format in _SHARED_DATABASE_SOURCES:
source_home = request.source_home or default_target_home(
source_format, environ=environ
source_format, environ=source_environ
)
session = _load_shared_database_source(
source_format,
Expand All @@ -344,19 +354,19 @@ def _execute(
)
else:
source_home = request.source_home or default_target_home(
source_format, environ=environ
source_format, environ=source_environ
)
source_path = locate_session(
source_format,
requested_source_id,
source_home,
cwd=request.source_cwd,
user_home=(
environment_home(environ)
environment_home(source_environ)
if source_format == AgentFormat.OMP and request.source_cwd is not None
else _optional_home(environ)
else _optional_home(source_environ)
),
temp_root=_temporary_root(environ),
temp_root=_temporary_root(source_environ),
)
if request.source_cli and source_format not in {
AgentFormat.OPENCODE,
Expand All @@ -377,7 +387,7 @@ def _execute(
session = loader(
requested_source_id,
source_cli=request.source_cli,
environ=virtual_source_environ or environ,
environ=virtual_source_environ or source_environ,
)
elif source_format in _SHARED_DATABASE_SOURCES:
if not requested_source_id:
Expand Down
Loading
Loading