Address review findings: durability, stale container ids, honest toasts
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 3s
Secret Scan / scan (pull_request) Successful in 6s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 2m38s
Build App (Preview) / build-windows (pull_request) Successful in 6m18s
Build App (Preview) / build-linux (pull_request) Successful in 7m48s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 3s
Secret Scan / scan (pull_request) Successful in 6s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 2m38s
Build App (Preview) / build-windows (pull_request) Successful in 6m18s
Build App (Preview) / build-linux (pull_request) Successful in 7m48s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
An Opus review of the previous commit found several real gaps: - pending_cleanup::save used plain write-temp-then-rename, unlike migration_store's fsync'd write it claimed to mirror — a crash in that window left a truncated record that list() would skip forever, silently reproducing the exact bug this module exists to fix. Now matches migration_store's File::create/write_all/sync_all/rename/sync_dir shape, and the tests exercise the real save/list/clear functions against a temp dir instead of re-implementing their bodies inline. - remove_project and rebuild_project_container only ever looked at project.container_id, unlike every other container-destroying path in the codebase, which falls back to find_existing_container for exactly this race (a crash between creating a container and persisting its id). A miss here left a container that then blocked every subsequent volume removal with a 409, forever. Both now resolve the same way the rest of the codebase does, and record the container by its deterministic name rather than its id so a retry still has something that resolves. - remove_project's toast promised an automatic retry unconditionally, even when writing the pending-cleanup record itself failed (the one case where nothing will actually retry). ProjectRemovalReport now carries retry_scheduled, and the UI is honest about which case it's in. - remove_volumes_by_name now retries once after a short delay on a 409, since Docker releasing a volume's mount reference right after its container is removed is not always instantaneous, and this is exactly the sequence remove_project runs. - rebuild_project_container (Reset) returns ProjectResetOutcome so the UI can warn when Reset could not fully clear a project's volumes, instead of only logging it — the new container silently reuses old data otherwise, which is what Reset promises not to do. - retry_pending_cleanup_logged escalates a record's log level after it has failed for a week, since recorded_at was otherwise write-only. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGjXq6fqtAFHdbhk4f3PfZ
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import type { ProjectRemovalReport } from "../../../lib/types";
|
||||
import { projectRemovalIsClean, type ProjectRemovalReport } from "../../../lib/types";
|
||||
import { useAppState } from "../../../store/appState";
|
||||
import { useProjectActions } from "../../../hooks/useProjectActions";
|
||||
import { useProjects } from "../../../hooks/useProjects";
|
||||
@@ -31,7 +31,15 @@ const TABS = [
|
||||
|
||||
export type ProjectHomeTabId = (typeof TABS)[number]["id"];
|
||||
|
||||
/** Names what a `ProjectRemovalReport` says survived, for the leftover toast. */
|
||||
/**
|
||||
* Names what a `ProjectRemovalReport` says survived, for the leftover toast.
|
||||
*
|
||||
* Worded as "could not confirm" rather than "is still on disk": the same
|
||||
* report shape covers a genuine leftover (a locked volume) and a daemon that
|
||||
* was simply unreachable at the time, in which case nothing was ever created
|
||||
* and there is nothing to find — asserting certainty either way would be
|
||||
* wrong in one of those cases.
|
||||
*/
|
||||
function describeLeftovers(report: ProjectRemovalReport): string {
|
||||
const parts: string[] = [];
|
||||
if (report.container) parts.push("its container");
|
||||
@@ -294,12 +302,22 @@ export default function ProjectHome({ projectId, active }: Props) {
|
||||
setConfirmRemove(false);
|
||||
try {
|
||||
const report = await remove(project.id);
|
||||
if (report.container || report.image || report.volumes.length > 0) {
|
||||
useAppState.getState().pushToast({
|
||||
kind: "info",
|
||||
message: `“${project.name}” was removed, but some Docker resources are still on disk`,
|
||||
detail: `Triple-C will retry removing ${describeLeftovers(report)} the next time it starts.`,
|
||||
});
|
||||
if (!projectRemovalIsClean(report)) {
|
||||
if (report.retry_scheduled) {
|
||||
useAppState.getState().pushToast({
|
||||
kind: "info",
|
||||
message: `“${project.name}” was removed, but Triple-C could not confirm all its Docker resources were cleaned up`,
|
||||
detail: `Triple-C could not confirm ${describeLeftovers(report)} were removed. It will check again the next time it starts.`,
|
||||
});
|
||||
} else {
|
||||
// The pending-cleanup record itself failed to save — no
|
||||
// retry will happen, so this must not promise one.
|
||||
useAppState.getState().pushToast({
|
||||
kind: "error",
|
||||
message: `“${project.name}” was removed, but its Docker resources could not be cleaned up`,
|
||||
detail: `Triple-C could not confirm ${describeLeftovers(report)} were removed, and could not record this for a retry. You may need to remove them manually (\`docker rm\` / \`docker rmi\` / \`docker volume rm\`).`,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
useAppState.getState().pushToast({
|
||||
|
||||
@@ -28,13 +28,14 @@ export function useProjectActions(project: Project) {
|
||||
);
|
||||
|
||||
const run = useCallback(
|
||||
async (label: string, fn: () => Promise<unknown>) => {
|
||||
async <T,>(label: string, fn: () => Promise<T>): Promise<T | undefined> => {
|
||||
setBusy(true);
|
||||
setContainerProgress(project.id, null);
|
||||
try {
|
||||
await fn();
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
fail(`${label} failed for “${project.name}”`, e);
|
||||
return undefined;
|
||||
} finally {
|
||||
setContainerProgress(project.id, null);
|
||||
setBusy(false);
|
||||
@@ -54,8 +55,22 @@ export function useProjectActions(project: Project) {
|
||||
);
|
||||
|
||||
const handleReset = useCallback(
|
||||
() => run("Reset", () => rebuild(project.id)),
|
||||
[run, rebuild, project.id],
|
||||
() =>
|
||||
run("Reset", async () => {
|
||||
const outcome = await rebuild(project.id);
|
||||
if (outcome.leftover_volumes.length > 0) {
|
||||
const n = outcome.leftover_volumes.length;
|
||||
pushToast({
|
||||
kind: "error",
|
||||
message: `Reset for “${project.name}” could not fully clean up`,
|
||||
detail: `${n === 1 ? "A volume" : `${n} volumes`} could not be removed, so the new \
|
||||
container may still contain data from before the reset. You may need to remove ${n === 1 ? "it" : "them"} \
|
||||
manually with \`docker volume rm\`.`,
|
||||
});
|
||||
}
|
||||
return outcome;
|
||||
}),
|
||||
[run, rebuild, project.id, project.name, pushToast],
|
||||
);
|
||||
|
||||
const openClaudeTerminal = useCallback(async () => {
|
||||
|
||||
@@ -136,9 +136,9 @@ export function useProjects() {
|
||||
const rebuild = useCallback(
|
||||
(id: string) =>
|
||||
withOptimisticStatus(id, "starting", async () => {
|
||||
const updated = await commands.rebuildProjectContainer(id);
|
||||
updateProjectInList(updated);
|
||||
return updated;
|
||||
const outcome = await commands.rebuildProjectContainer(id);
|
||||
updateProjectInList(outcome.project);
|
||||
return outcome;
|
||||
}),
|
||||
[updateProjectInList, withOptimisticStatus],
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Project, ProjectPath, ProjectRemovalReport, 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";
|
||||
import type { Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, 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");
|
||||
@@ -21,7 +21,7 @@ export const startProjectContainer = (projectId: string) =>
|
||||
export const stopProjectContainer = (projectId: string) =>
|
||||
invoke<void>("stop_project_container", { projectId });
|
||||
export const rebuildProjectContainer = (projectId: string) =>
|
||||
invoke<Project>("rebuild_project_container", { projectId });
|
||||
invoke<ProjectResetOutcome>("rebuild_project_container", { projectId });
|
||||
export const reconcileProjectStatuses = () =>
|
||||
invoke<Project[]>("reconcile_project_statuses");
|
||||
|
||||
|
||||
+20
-2
@@ -78,12 +78,30 @@ export type ProjectStatus =
|
||||
| "error";
|
||||
|
||||
/** What `removeProject` could not delete. The project is removed from the
|
||||
* sidebar either way; anything named here is recorded on the host and
|
||||
* retried automatically the next time the app starts. */
|
||||
* sidebar either way. When `retry_scheduled` is true, anything named here
|
||||
* was recorded on the host and will be retried automatically the next time
|
||||
* the app starts; when false, the record itself could not be saved and
|
||||
* nothing will retry it. `retry_scheduled` is meaningless when nothing was
|
||||
* left behind. */
|
||||
export interface ProjectRemovalReport {
|
||||
container: string | null;
|
||||
image: string | null;
|
||||
volumes: string[];
|
||||
retry_scheduled: boolean;
|
||||
}
|
||||
|
||||
/** True when a `ProjectRemovalReport` left nothing behind. Mirrors the
|
||||
* Rust-side `ProjectRemovalReport::is_clean`. */
|
||||
export function projectRemovalIsClean(report: ProjectRemovalReport): boolean {
|
||||
return !report.container && !report.image && report.volumes.length === 0;
|
||||
}
|
||||
|
||||
/** What Reset (`rebuildProjectContainer`) produced: the project as it stands
|
||||
* after restarting, and any volume Reset could not clear — which is reused
|
||||
* as-is by the new container instead of starting clean. */
|
||||
export interface ProjectResetOutcome {
|
||||
project: Project;
|
||||
leftover_volumes: string[];
|
||||
}
|
||||
|
||||
export type Backend =
|
||||
|
||||
Reference in New Issue
Block a user