Build App (Preview) / compute-version (pull_request) Successful in 4s
Build Container / build-container (pull_request) Successful in 1m35s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m38s
Build App (Preview) / build-windows (pull_request) Successful in 5m51s
Build App (Preview) / build-linux (pull_request) Successful in 6m50s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
`upload_file_to_container` and `download_container_file` existed on main before
any of this work started. "Ship the Files tab container-side only" removed them
and called it narrowing scope; from a user's side it was a regression they
upgraded into. This restores the feature.
The reason for the removal was real — four consecutive audits found their
criticals in host paths crossing IPC — so the feature comes back only in the
shape that removes the class rather than patching it a fifth time. The dialogs
are opened by **Rust** (`pick_save_path`, `pick_files_to_upload`), not by the
webview. A frontend `open()`/`save()` handing the backend a path string is
exactly what failed, and the backend cannot tell such a string from one a
compromised webview invented. Now the webview can ask for a picker and that is
the whole of its influence: it cannot name a host path as an input. That is the
shape the previous round's own notes named as the honest one if this ever
returned.
None of the machinery the audits condemned returns. No `link(2)` destination
reservation, no placeholder rollback, no collision marker: the OS save dialog
already asks about overwriting and Docker's extractor overwrites on upload the
way `cp` does, so there was nothing left for it to do. Download reuses the
sequence `download_container_backup` has been using unchanged — resolve, stream
into a partial file beside the destination, rename last — so a failed transfer
never touches the file that was already there. Upload reuses the terminal
drop's hardened uploader, with the container's uid/gid resolved once per
selection rather than once per file.
Against a container that is actively hostile rather than merely surprising:
* the read is `dd iflag=nonblock`, not `cat`. `[ -f ]` and the `open` after it
are two syscalls and the container owns the filesystem in between; a loop
swapping the file for a FIFO wins that race, and `cat` then blocks forever
with no writer and no timeout anywhere on the path — the `invoke` never
settles and a partial is left in the user's directory for good. Verified in
a real container that `cat` hangs, that `iflag=nonblock` returns, and that
it is byte-identical on a regular file.
* the read is bracketed by a second `[ -f ]`, because non-blocking turns that
hang into an empty file that would otherwise be renamed over the
destination and reported as a successful save.
* an *undeterminable* exit code is a failure. Backup catches this class with
its `total == 0` check, which download cannot have because an empty file is
a legitimate save; without a replacement, a project restarted mid-download
renames a truncated partial over the user's file and reports the byte count
as if it were whole.
* container stderr is capped. Every other reader of container output in the
tree is capped for this reason; the two streaming commands were the
exception, and stdout was bounded by disk while stderr was bounded by
nothing.
* the script's refusals are framed rather than used verbatim, so a directory
named to look like one of our own sentences cannot become the toast
headline through `readableRefusal`.
* the partial name is capped at NAME_MAX. A bundler's 230-character content
hash is a name that fits its directory and produces a partial name that
does not.
Also: a non-UTF-8 dialog path is refused by name rather than silently mangled
into a different path by U+FFFD substitution; both actions carry in-flight
state, so a second click cannot open a second dialog and a slow save is not
indistinguishable from a dead button; and the upload's completion message names
the directory, since the picker is modal and the user can browse elsewhere
while it is open.
Not restored: drag-and-drop, in either direction. `drag:allow-start-drag` stays
ungranted and `hold/disk-and-dragout` still holds that work.
Two bugs the new tests caught while being written: a double-click on "Save to
host…" opened the file viewer on top of the save dialog, and an N-file upload
made N redundant execs to re-ask `id -u`.
Docs that asserted this feature did not and must not exist are corrected —
CLAUDE.md, README, HOW-TO-USE, TECHNICAL and the capability threat model. The
"no host path crosses IPC" claim is deliberately narrowed to the inbound
direction: paths do still travel outward inside error text, canonical ones
included, and the reviewed record should not overstate.
600 frontend tests, 473 Rust, no new clippy warnings. Every new test was
mutation-checked; four that survived their first mutation were rewritten,
including two whose mutations turned out to be unfaithful and one that was
blind to a dismissal leaving a row stuck on "Saving…".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LHL9ty7arp8FHwvE77ne7y
371 lines
20 KiB
TypeScript
371 lines
20 KiB
TypeScript
import { invoke } from "@tauri-apps/api/core";
|
|
import type { Project, ProjectPath, ContainerInfo, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, UploadOutcome } from "./types";
|
|
|
|
// Docker
|
|
export const checkDocker = () => invoke<boolean>("check_docker");
|
|
export const checkImageExists = () => invoke<boolean>("check_image_exists");
|
|
export const buildImage = () => invoke<void>("build_image");
|
|
export const getContainerInfo = (projectId: string) =>
|
|
invoke<ContainerInfo | null>("get_container_info", { projectId });
|
|
|
|
// Projects
|
|
export const listProjects = () => invoke<Project[]>("list_projects");
|
|
export const addProject = (name: string, paths: ProjectPath[]) =>
|
|
invoke<Project>("add_project", { name, paths });
|
|
export const removeProject = (projectId: string) =>
|
|
invoke<void>("remove_project", { projectId });
|
|
export const updateProject = (project: Project) =>
|
|
invoke<Project>("update_project", { project });
|
|
export const startProjectContainer = (projectId: string) =>
|
|
invoke<Project>("start_project_container", { projectId });
|
|
export const stopProjectContainer = (projectId: string) =>
|
|
invoke<void>("stop_project_container", { projectId });
|
|
export const rebuildProjectContainer = (projectId: string) =>
|
|
invoke<Project>("rebuild_project_container", { projectId });
|
|
export const reconcileProjectStatuses = () =>
|
|
invoke<Project[]>("reconcile_project_statuses");
|
|
|
|
// Settings
|
|
export const getSettings = () => invoke<AppSettings>("get_settings");
|
|
export const updateSettings = (settings: AppSettings) =>
|
|
invoke<AppSettings>("update_settings", { settings });
|
|
export const pullImage = (imageName: string) =>
|
|
invoke<void>("pull_image", { imageName });
|
|
export const detectAwsConfig = () =>
|
|
invoke<string | null>("detect_aws_config");
|
|
export const listAwsProfiles = () =>
|
|
invoke<string[]>("list_aws_profiles");
|
|
/** Check a corporate CA path and report what would be installed. Never
|
|
* rejects for a bad path — the reason comes back in `error`. */
|
|
export const inspectCaCertPath = (path: string) =>
|
|
invoke<CaCertInfo>("inspect_ca_cert_path", { path });
|
|
export const detectHostTimezone = () =>
|
|
invoke<string>("detect_host_timezone");
|
|
|
|
// AWS
|
|
export const awsSsoRefresh = (projectId: string) =>
|
|
invoke<void>("aws_sso_refresh", { projectId });
|
|
|
|
// Terminal
|
|
export const openTerminalSession = (projectId: string, sessionId: string, sessionType?: string, sessionName?: string) =>
|
|
invoke<void>("open_terminal_session", { projectId, sessionId, sessionType, sessionName });
|
|
export const terminalInput = (sessionId: string, data: number[]) =>
|
|
invoke<void>("terminal_input", { sessionId, data });
|
|
export const terminalResize = (sessionId: string, cols: number, rows: number) =>
|
|
invoke<void>("terminal_resize", { sessionId, cols, rows });
|
|
export const closeTerminalSession = (sessionId: string) =>
|
|
invoke<void>("close_terminal_session", { sessionId });
|
|
export const pasteImageToTerminal = (sessionId: string, imageData: number[]) =>
|
|
invoke<string>("paste_image_to_terminal", { sessionId, imageData });
|
|
export const uploadHostFileToTerminal = (sessionId: string, hostPath: string) =>
|
|
invoke<string>("upload_host_file_to_terminal", { sessionId, hostPath });
|
|
export const startAudioBridge = (sessionId: string) =>
|
|
invoke<void>("start_audio_bridge", { sessionId });
|
|
export const sendAudioData = (sessionId: string, data: number[]) =>
|
|
invoke<void>("send_audio_data", { sessionId, data });
|
|
export const stopAudioBridge = (sessionId: string) =>
|
|
invoke<void>("stop_audio_bridge", { sessionId });
|
|
|
|
// Files
|
|
export const listContainerFiles = (projectId: string, path: string) =>
|
|
invoke<FileEntry[]>("list_container_files", { projectId, path });
|
|
/**
|
|
* Save one container file to the host.
|
|
*
|
|
* The **backend** opens the save dialog, so this call cannot name a place on
|
|
* the host — that is the point (see `pick_save_path` in `file_commands.rs`).
|
|
* Paths do come *back* inside error text; what is closed is the inbound
|
|
* direction.
|
|
* Resolves to the number of bytes written, or `null` if the user dismissed the
|
|
* dialog. Zero bytes is a success: an empty file is a file.
|
|
*/
|
|
export const downloadContainerFile = (projectId: string, containerPath: string) =>
|
|
invoke<number | null>("download_container_file", { projectId, containerPath });
|
|
/**
|
|
* Upload host files into `containerDir`, with the backend opening the file
|
|
* picker. Resolves to `null` if the user dismissed it, otherwise to what
|
|
* happened — one dialog can select several files and they need not all succeed.
|
|
*/
|
|
export const uploadFilesToContainer = (projectId: string, containerDir: string) =>
|
|
invoke<UploadOutcome | null>("upload_files_to_container", { projectId, containerDir });
|
|
export const downloadContainerBackup = (projectId: string, hostPath: string, containerPath?: string) =>
|
|
invoke<number>("download_container_backup", { projectId, hostPath, containerPath });
|
|
export const readContainerFile = (projectId: string, path: string, maxBytes?: number) =>
|
|
invoke<FileContents>("read_container_file", { projectId, path, maxBytes });
|
|
/** `toPath` is the new *name*, not a destination — renames never move. */
|
|
export const renameContainerPath = (projectId: string, fromPath: string, toPath: string) =>
|
|
invoke<string>("rename_container_path", { projectId, fromPath, toPath });
|
|
export const createContainerDirectory = (projectId: string, parentPath: string, name: string) =>
|
|
invoke<string>("create_container_directory", { projectId, parentPath, name });
|
|
|
|
// Updates
|
|
export const getAppVersion = () => invoke<string>("get_app_version");
|
|
export const checkForUpdates = () =>
|
|
invoke<UpdateInfo | null>("check_for_updates");
|
|
export const checkImageUpdate = () =>
|
|
invoke<ImageUpdateInfo | null>("check_image_update");
|
|
|
|
// Help
|
|
export const getHelpContent = () => invoke<string>("get_help_content");
|
|
|
|
// Web Terminal
|
|
export const startWebTerminal = () =>
|
|
invoke<WebTerminalInfo>("start_web_terminal");
|
|
export const stopWebTerminal = () =>
|
|
invoke<void>("stop_web_terminal");
|
|
export const getWebTerminalStatus = () =>
|
|
invoke<WebTerminalInfo>("get_web_terminal_status");
|
|
export const regenerateWebTerminalToken = () =>
|
|
invoke<WebTerminalInfo>("regenerate_web_terminal_token");
|
|
|
|
// STT
|
|
export const getSttStatus = () => invoke<SttStatus>("get_stt_status");
|
|
export const startStt = () => invoke<SttStatus>("start_stt");
|
|
export const stopStt = () => invoke<void>("stop_stt");
|
|
export const buildSttImage = () => invoke<void>("build_stt_image");
|
|
export const pullSttImage = () => invoke<void>("pull_stt_image");
|
|
export const transcribeAudio = (audioData: number[]) =>
|
|
invoke<string>("transcribe_audio", { audioData });
|
|
|
|
// Model gateway (LiteLLM)
|
|
export const getGatewayStatus = () => invoke<GatewayStatus>("get_gateway_status");
|
|
export const startGateway = () => invoke<GatewayStatus>("start_gateway");
|
|
export const stopGateway = () => invoke<void>("stop_gateway");
|
|
export const checkGatewayHealth = () => invoke<boolean>("check_gateway_health");
|
|
export const buildGatewayImage = () => invoke<void>("build_gateway_image");
|
|
export const pullGatewayImage = () => invoke<void>("pull_gateway_image");
|
|
/** Write-only: the provider API key is never read back out of the keychain. */
|
|
export const setGatewayApiKey = (apiKey: string) =>
|
|
invoke<void>("set_gateway_api_key", { apiKey });
|
|
export const clearGatewayApiKey = () => invoke<void>("clear_gateway_api_key");
|
|
export const getGatewayAuthToken = () => invoke<string>("get_gateway_auth_token");
|
|
export const regenerateGatewayAuthToken = () =>
|
|
invoke<string>("regenerate_gateway_auth_token");
|
|
|
|
// Docker install helper
|
|
export const detectInstallOptions = () =>
|
|
invoke<InstallOptions>("detect_install_options");
|
|
export const runDockerInstall = () => invoke<void>("run_docker_install");
|
|
|
|
// Container introspection — sessions
|
|
export const listClaudeSessions = (projectId: string) =>
|
|
invoke<ClaudeSession[]>("list_claude_sessions", { projectId });
|
|
export const resumeSessionCommand = (projectId: string, sessionId: string) =>
|
|
invoke<string>("resume_session_command", { projectId, sessionId });
|
|
|
|
// Container introspection — capabilities
|
|
export const listContainerCapabilities = (projectId: string) =>
|
|
invoke<ContainerCapabilities>("list_container_capabilities", { projectId });
|
|
|
|
// Container introspection — scheduler
|
|
export const listScheduledTasks = (projectId: string) =>
|
|
invoke<ScheduledTask[]>("list_scheduled_tasks", { projectId });
|
|
/** Returns the new task's id. */
|
|
export const addScheduledTask = (projectId: string, input: ScheduledTaskInput) =>
|
|
invoke<string>("add_scheduled_task", { projectId, ...input });
|
|
/** Edit = add + remove, so this returns a *new* task id (see the Rust doc). */
|
|
export const updateScheduledTask = (
|
|
projectId: string,
|
|
taskId: string,
|
|
input: ScheduledTaskInput,
|
|
enabled: boolean,
|
|
) => invoke<string>("update_scheduled_task", { projectId, taskId, enabled, ...input });
|
|
export const getScheduledTaskLog = (projectId: string, taskId: string, tailLines?: number) =>
|
|
invoke<string>("get_scheduled_task_log", { projectId, taskId, tailLines });
|
|
export const setScheduledTaskEnabled = (projectId: string, taskId: string, enabled: boolean) =>
|
|
invoke<string>("set_scheduled_task_enabled", { projectId, taskId, enabled });
|
|
export const runScheduledTaskNow = (projectId: string, taskId: string) =>
|
|
invoke<string>("run_scheduled_task_now", { projectId, taskId });
|
|
export const removeScheduledTask = (projectId: string, taskId: string) =>
|
|
invoke<string>("remove_scheduled_task", { projectId, taskId });
|
|
export const getSchedulerNotifications = (projectId: string) =>
|
|
invoke<SchedulerNotification[]>("get_scheduler_notifications", { projectId });
|
|
export const clearSchedulerNotifications = (projectId: string) =>
|
|
invoke<void>("clear_scheduler_notifications", { projectId });
|
|
|
|
// Auth bridge — mirrors container loopback listeners onto host loopback so
|
|
// browser OAuth logins started inside the container can complete.
|
|
export const setAuthBridgeEnabled = (projectId: string, enabled: boolean) =>
|
|
invoke<AuthBridgeStatus>("set_auth_bridge_enabled", { projectId, enabled });
|
|
export const getAuthBridgeStatus = (projectId: string) =>
|
|
invoke<AuthBridgeStatus>("get_auth_bridge_status", { projectId });
|
|
|
|
// Browser view — watch and take over the browser Claude drives with Playwright
|
|
// inside the container. Off by default, per project. Enabling probes the
|
|
// container, starts the Playwright dashboard in it, and puts a token-gated
|
|
// listener on the host's loopback in front of it; the returned `url` is the
|
|
// only way in, and it is never reachable off the machine.
|
|
export const setBrowserViewEnabled = (projectId: string, enabled: boolean) =>
|
|
invoke<BrowserViewStatus>("set_browser_view_enabled", { projectId, enabled });
|
|
export const getBrowserViewStatus = (projectId: string) =>
|
|
invoke<BrowserViewStatus>("get_browser_view_status", { projectId });
|
|
/** Probe for Playwright without starting anything — used to re-check after installing it. */
|
|
export const checkBrowserViewSupport = (projectId: string) =>
|
|
invoke<PlaywrightDetection>("check_browser_view_support", { projectId });
|
|
/**
|
|
* Install `playwright` + `@playwright/cli` into the container's `/workspace`.
|
|
*
|
|
* A container mutation, so it only ever runs from an explicit click. Progress
|
|
* streams on the existing `container-progress` event; the result carries a
|
|
* fresh probe. Browsers are a separate action — see below.
|
|
*/
|
|
export const installBrowserViewSupport = (projectId: string) =>
|
|
invoke<BrowserSetupOutcome>("install_browser_view_support", { projectId });
|
|
/**
|
|
* Install a browser and the apt libraries it needs, then verify it launches.
|
|
* `chromium` is Playwright's own build; `chrome` is the channel
|
|
* `@playwright/mcp` asks for. Hundreds of MB — never call this implicitly.
|
|
*/
|
|
export const installBrowserViewBrowser = (
|
|
projectId: string,
|
|
browser: BrowserInstallTarget,
|
|
) => invoke<BrowserSetupOutcome>("install_browser_view_browser", { projectId, browser });
|
|
|
|
/**
|
|
* Detach the live view into its own OS window, or raise it if already open.
|
|
*
|
|
* Window-only: the viewer, the proxy and the container are untouched, so
|
|
* popping out and back costs nothing. The window loads the same token-bearing
|
|
* loopback URL as the pane, and has no IPC access.
|
|
*/
|
|
export const openBrowserViewPopout = (projectId: string, alwaysOnTop: boolean) =>
|
|
invoke<void>("open_browser_view_popout", { projectId, alwaysOnTop });
|
|
/** Close the pop-out and put the view back in the tab. No-op if it isn't open. */
|
|
export const closeBrowserViewPopout = (projectId: string) =>
|
|
invoke<void>("close_browser_view_popout", { projectId });
|
|
/**
|
|
* Whether the pop-out is open and whether it is pinned, read from the window.
|
|
*
|
|
* Asked on every mount: the pane is unmounted whenever another Project Home
|
|
* sub-tab is selected, while the window carries on — so neither fact can live
|
|
* in component state and survive.
|
|
*/
|
|
export const getBrowserViewPopoutState = (projectId: string) =>
|
|
invoke<BrowserViewPopoutState>("get_browser_view_popout_state", { projectId });
|
|
/**
|
|
* Open a URL in a browser *inside* the container, published so the pane shows it.
|
|
*
|
|
* The same action serves an auth URL — the OAuth callback listener is in the
|
|
* container too, so the loop closes without the host — and a dev server on
|
|
* container loopback, which is how you watch a UI Claude is building. Only
|
|
* http/https; the backend rejects anything else.
|
|
*
|
|
* The viewer is started if it isn't already: asking for a page is asking to
|
|
* watch it, and leaving the user to go and press Start themselves — with no
|
|
* hint that they had to — is what the first version did.
|
|
*/
|
|
export const openPageInContainerBrowser = (
|
|
projectId: string,
|
|
url: string,
|
|
width: number,
|
|
height: number,
|
|
/** Also raise the pop-out window — for callers with no pane on screen. */
|
|
showWindow = false,
|
|
) =>
|
|
invoke<BrowserPageState>("open_page_in_container_browser", {
|
|
projectId,
|
|
url,
|
|
width,
|
|
height,
|
|
showWindow,
|
|
});
|
|
/** Resize that page. Real reflow, not a scaled screencast — see BrowserTab. */
|
|
export const setContainerPageViewport = (projectId: string, width: number, height: number) =>
|
|
invoke<void>("set_container_page_viewport", { projectId, width, height });
|
|
export const getContainerPageState = (projectId: string) =>
|
|
invoke<BrowserPageState>("get_container_page_state", { projectId });
|
|
export const closeContainerPage = (projectId: string) =>
|
|
invoke<void>("close_container_page", { projectId });
|
|
|
|
/**
|
|
* Make the page track the pop-out window's size as it is dragged.
|
|
*
|
|
* Only affects a page this app opened: a bound browser admits no second client,
|
|
* so one `@playwright/mcp` launched keeps the viewport it was given.
|
|
*/
|
|
export const setBrowserViewMatchWindow = (projectId: string, enabled: boolean) =>
|
|
invoke<void>("set_browser_view_match_window", { projectId, enabled });
|
|
export const getBrowserViewMatchWindow = (projectId: string) =>
|
|
invoke<boolean>("get_browser_view_match_window", { projectId });
|
|
|
|
/** Pin the pop-out above other windows — the point of popping it out at all. */
|
|
export const setBrowserViewPopoutAlwaysOnTop = (projectId: string, onTop: boolean) =>
|
|
invoke<void>("set_browser_view_popout_always_on_top", { projectId, onTop });
|
|
|
|
// Shared Claude Code auth token — one `claude setup-token` run authenticates
|
|
// every Anthropic-backend project. The token itself is never exposed here: it
|
|
// lives in the OS keychain and is injected as a container env var.
|
|
//
|
|
// `acquireClaudeToken` borrows the given project's running container to run the
|
|
// login and streams progress on the `claude-token-progress` and
|
|
// `claude-token-output` events. It deliberately does *not* touch the project's
|
|
// auth bridge: `setup-token` finishes on an Anthropic-hosted page and pastes a
|
|
// code back, so there is no loopback callback for a bridge to carry — and an
|
|
// earlier version that enabled it "just in case" persisted that flag to
|
|
// projects.json and left it latched on whenever the flow was killed. See the
|
|
// module comment in `commands/auth_token_commands.rs`. It resolves only
|
|
// once the whole flow finishes, so call it without awaiting the UI on it.
|
|
//
|
|
// Partway through, `claude setup-token` prints a sign-in URL and then waits at
|
|
// a "Paste code here" prompt: the user signs in, copies the code shown by
|
|
// Anthropic, and it is delivered with `submitClaudeTokenCode`. One flow at a
|
|
// time — a second `acquireClaudeToken` call rejects while one is in progress.
|
|
export const acquireClaudeToken = (projectId: string) =>
|
|
invoke<void>("acquire_claude_token", { projectId });
|
|
export const submitClaudeTokenCode = (code: string) =>
|
|
invoke<void>("submit_claude_token_code", { code });
|
|
/** Abort an in-flight acquisition and release the single-flight guard. No-op if nothing is running. */
|
|
export const cancelClaudeToken = () => invoke<void>("cancel_claude_token");
|
|
export const hasClaudeToken = () => invoke<boolean>("has_claude_token");
|
|
/** Revoke the shared token: delete the keychain entry **first**, then rewrite
|
|
* any snapshot image that still has it baked into its env — see
|
|
* `ClearTokenOutcome` for what may be left behind. Destructive; confirm it. */
|
|
export const clearClaudeToken = () =>
|
|
invoke<ClearTokenOutcome>("clear_claude_token");
|
|
/** Rewrite snapshot images that still carry a credential, **without touching
|
|
* the keychain**. This is the retry behind an incomplete revocation, and the
|
|
* standalone "check my images" sweep; it never deletes a token. */
|
|
export const sweepClaudeTokenSnapshots = () =>
|
|
invoke<ClearTokenOutcome>("sweep_claude_token_snapshots");
|
|
|
|
// Container base-image migration — move a project onto the current base image
|
|
// without deleting its volumes. Reset is the destructive alternative: it wipes
|
|
// ~/.claude, the OAuth credential, installed skills and every transcript.
|
|
//
|
|
// Flow: getContainerStaleness (read-only, ~6s — two filesystem probes, so call
|
|
// it on demand rather than polling) → migrateProjectToBase → the project sits
|
|
// in "awaiting-confirmation" while the user tries it → confirmMigration or
|
|
// rollbackMigration.
|
|
//
|
|
// Rollback restores the **system layer only**. Both named volumes are untouched
|
|
// throughout, so anything written to $HOME during the migrated session — a new
|
|
// login, new skills, new transcripts — survives a rollback.
|
|
//
|
|
// Progress arrives on the existing `container-progress` event.
|
|
|
|
/** Read-only. Runs two container/image filesystem probes; not for polling. */
|
|
export const getContainerStaleness = (projectId: string) =>
|
|
invoke<ContainerStaleness>("get_container_staleness", { projectId });
|
|
|
|
/** Runs the whole migration and resolves with its report. Long-running — the
|
|
* apt replay alone was measured at ~70s for 8 packages. Calling it again while
|
|
* a migration is `interrupted` resumes that one instead of starting a new one. */
|
|
export const migrateProjectToBase = (projectId: string, options: MigrationOptions) =>
|
|
invoke<MigrationReport>("migrate_project_to_base", { projectId, options });
|
|
|
|
/** Accept the migration: drops the rollback tag and the staged payload, and
|
|
* clears the record. Idempotent. */
|
|
export const confirmMigration = (projectId: string) =>
|
|
invoke<void>("confirm_migration", { projectId });
|
|
|
|
/** Undo the migration: recreates the container from its pre-migration image.
|
|
* Fails if the migration kept no rollback image (`keep_rollback: false`). */
|
|
export const rollbackMigration = (projectId: string) =>
|
|
invoke<void>("rollback_migration", { projectId });
|
|
|
|
/** The persisted record, or null when no migration is in flight. Worth calling
|
|
* after `reconcileProjectStatuses` at startup: a migration interrupted by an
|
|
* app crash shows up here as phase "interrupted". */
|
|
export const getMigrationState = (projectId: string) =>
|
|
invoke<MigrationState | null>("get_migration_state", { projectId });
|