Files
Triple-C/app/src/hooks/useProjects.ts
T
shadow-testandClaude Sonnet 5 d8bb5ab262
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
Address review findings: durability, stale container ids, honest toasts
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
2026-08-27 08:36:27 -07:00

169 lines
5.5 KiB
TypeScript

import { useCallback } from "react";
import { useShallow } from "zustand/react/shallow";
import { useAppState } from "../store/appState";
import * as commands from "../lib/tauri-commands";
import type { ProjectPath, ProjectStatus } from "../lib/types";
export function useProjects() {
const {
projects,
selectedProjectId,
setProjects,
setSelectedProject,
updateProjectInList,
removeProjectFromList,
} = useAppState(
useShallow(s => ({
projects: s.projects,
selectedProjectId: s.selectedProjectId,
setProjects: s.setProjects,
setSelectedProject: s.setSelectedProject,
updateProjectInList: s.updateProjectInList,
removeProjectFromList: s.removeProjectFromList,
}))
);
const selectedProject = projects.find((p) => p.id === selectedProjectId) ?? null;
const refresh = useCallback(async () => {
const list = await commands.listProjects();
setProjects(list);
}, [setProjects]);
const add = useCallback(
async (name: string, paths: ProjectPath[]) => {
const project = await commands.addProject(name, paths);
// Refresh from backend to avoid stale closure issues
const list = await commands.listProjects();
setProjects(list);
setSelectedProject(project.id);
return project;
},
[setProjects, setSelectedProject],
);
const remove = useCallback(
async (id: string) => {
const report = await commands.removeProject(id);
removeProjectFromList(id);
return report;
},
[removeProjectFromList],
);
const setOptimisticStatus = useCallback(
(id: string, status: "starting" | "stopping") => {
const { projects } = useAppState.getState();
const project = projects.find((p) => p.id === id);
if (project) {
updateProjectInList({ ...project, status });
}
},
[updateProjectInList],
);
/**
* Paint the optimistic status, run the command, and **put the status back if
* the command never happened.**
*
* The optimistic write exists so a click moves the row immediately. It used
* to be safe to leave in place on failure, because the only way these three
* commands could fail was after the backend had already started changing
* things — so a stale "starting" was at worst premature.
*
* That stopped being true when every lifecycle command started taking the
* per-project lock and failing fast: a compaction, a reset or another start
* holding the project now refuses `start`, `stop` **and** `rebuild` before
* one byte of state changes. `stop` in particular could not fail this way at
* all before — it took no exclusion. The optimistic paint then has nothing to
* become, and `isTransitioning` disables both Start and Stop, so the row is
* stuck: the only thing that clears it is `reconcileProjectStatuses`, which
* runs once, from `App.tsx`, when Docker first appears. A restart.
*
* Re-reading the list is preferred over restoring what was on screen, because
* a refusal is not the only way these throw — a start that dies half-way
* really has changed the world, and `listProjects` is the thing that knows.
* The captured status is only the fallback for when that call fails too:
* leaving the row transitioning is the one outcome the user cannot get out
* of, so it must not be what a second failure lands on.
*/
const withOptimisticStatus = useCallback(
async <T,>(
id: string,
status: "starting" | "stopping",
run: () => Promise<T>,
): Promise<T> => {
const previous: ProjectStatus | null =
useAppState.getState().projects.find((p) => p.id === id)?.status ?? null;
setOptimisticStatus(id, status);
try {
return await run();
} catch (e) {
try {
setProjects(await commands.listProjects());
} catch {
const project = useAppState.getState().projects.find((p) => p.id === id);
if (project && previous) updateProjectInList({ ...project, status: previous });
}
// Rethrown unchanged: `useProjectActions` is what turns this into a
// toast, and it must still see the original failure.
throw e;
}
},
[setOptimisticStatus, setProjects, updateProjectInList],
);
const start = useCallback(
(id: string) =>
withOptimisticStatus(id, "starting", async () => {
const updated = await commands.startProjectContainer(id);
updateProjectInList(updated);
return updated;
}),
[updateProjectInList, withOptimisticStatus],
);
const stop = useCallback(
(id: string) =>
withOptimisticStatus(id, "stopping", async () => {
await commands.stopProjectContainer(id);
const list = await commands.listProjects();
setProjects(list);
}),
[setProjects, withOptimisticStatus],
);
const rebuild = useCallback(
(id: string) =>
withOptimisticStatus(id, "starting", async () => {
const outcome = await commands.rebuildProjectContainer(id);
updateProjectInList(outcome.project);
return outcome;
}),
[updateProjectInList, withOptimisticStatus],
);
const update = useCallback(
async (project: Parameters<typeof commands.updateProject>[0]) => {
const updated = await commands.updateProject(project);
updateProjectInList(updated);
return updated;
},
[updateProjectInList],
);
return {
projects,
selectedProject,
selectedProjectId,
setSelectedProject,
refresh,
add,
remove,
start,
stop,
rebuild,
update,
};
}