Unstick a project row whose lifecycle command was refused

Two more pieces of drift from the same merges, both invisible to `tsc`.

**A refused Start/Stop/Reset strands the row.** `start`, `stop` and
`rebuild` paint an optimistic "starting"/"stopping" so a click moves the
row at once. That was safe while the only way these could fail was after
the backend had begun changing things. `fix/sec`'s per-project lock ended
that: all three now take the lock and are refused *before* any state
changes, and `stop` could not fail this way at all before — it took no
exclusion. So the optimistic paint has nothing to become, `isTransitioning`
disables both Start and Stop, and the only thing that clears it is
`reconcileProjectStatuses`, which runs once from `App.tsx` when Docker
first appears. Clicking Stop during a compaction left the project
unusable until the app was restarted.

`withOptimisticStatus` re-reads the authoritative list when the command
throws, and falls back to the status that was on screen if even that call
fails — two failures in a row must not land on the one state there is no
way out of. The error is rethrown unchanged, so the toast is unaffected.
Five of the six new tests fail against the previous code; the sixth pins
that the optimistic paint still happens on the way in.

**Six secrets are typed as if they arrive, and they never do.**
`git_token`, the four Bedrock credentials and `OpenAiCompatibleConfig
.api_key` are `#[serde(skip_serializing)]` in Rust, so the key is absent
from every project the backend returns — reading one gives `undefined`,
not the `null` the type promised. Every current reader happens to use
`?? ""`, so nothing is broken today; a single `=== null` would have been a
branch that silently never ran. They are optional now, which makes that a
compile error, and documented as write-only, which is what they are.

669 frontend tests pass, `tsc --noEmit` clean, `npm run build` green.
Nothing under `src-tauri/` touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
This commit is contained in:
2026-08-23 12:12:50 -07:00
co-authored by Claude Opus 5
parent 7e1f8df1ff
commit 1768240861
3 changed files with 245 additions and 28 deletions
+142
View File
@@ -0,0 +1,142 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { useProjects } from "./useProjects";
import { useAppState } from "../store/appState";
import type { Project, ProjectStatus } from "../lib/types";
const startProjectContainer = vi.fn();
const stopProjectContainer = vi.fn();
const rebuildProjectContainer = vi.fn();
const listProjects = vi.fn();
vi.mock("../lib/tauri-commands", () => ({
startProjectContainer: (id: string) => startProjectContainer(id),
stopProjectContainer: (id: string) => stopProjectContainer(id),
rebuildProjectContainer: (id: string) => rebuildProjectContainer(id),
listProjects: () => listProjects(),
addProject: vi.fn(),
removeProject: vi.fn(),
updateProject: vi.fn(),
}));
const project = (status: ProjectStatus): Project =>
({ id: "p1", name: "whp", status }) as unknown as Project;
/** The status the sidebar row and Project Home both read. */
const statusOf = () => useAppState.getState().projects.find((p) => p.id === "p1")?.status;
beforeEach(() => {
vi.clearAllMocks();
useAppState.setState({ projects: [project("running")] });
});
/**
* The optimistic status is what makes a click move the row immediately. It is
* also what strands the row when the command it was betting on never ran.
*
* Since every lifecycle command started taking the per-project lock and failing
* fast, all three can be refused *before* the backend changes anything — and
* `stop` could not fail this way at all before, because it took no exclusion.
* `isTransitioning` disables both Start and Stop, and the only thing that
* clears it is `reconcileProjectStatuses`, which runs once when Docker first
* appears. So a stale optimistic status is not a cosmetic problem: it is a row
* the user cannot operate again until the app is restarted.
*/
describe("useProjects puts the status back when a refused command never ran", () => {
const refusal =
"This project's snapshot is being compacted. Wait for it to finish before starting or recreating its container.";
it("does not leave a refused Stop showing 'stopping' forever", async () => {
stopProjectContainer.mockRejectedValue(refusal);
// The lock is taken before `update_status`, so the backend still holds the
// truth — which is why re-reading it is the correction, not a guess.
listProjects.mockResolvedValue([project("running")]);
const { result } = renderHook(() => useProjects());
await act(async () => {
await expect(result.current.stop("p1")).rejects.toBe(refusal);
});
expect(statusOf()).toBe("running");
});
it("does not leave a refused Start showing 'starting' forever", async () => {
useAppState.setState({ projects: [project("stopped")] });
startProjectContainer.mockRejectedValue(refusal);
listProjects.mockResolvedValue([project("stopped")]);
const { result } = renderHook(() => useProjects());
await act(async () => {
await expect(result.current.start("p1")).rejects.toBe(refusal);
});
expect(statusOf()).toBe("stopped");
});
it("does not leave a refused Reset showing 'starting' forever", async () => {
rebuildProjectContainer.mockRejectedValue(refusal);
listProjects.mockResolvedValue([project("running")]);
const { result } = renderHook(() => useProjects());
await act(async () => {
await expect(result.current.rebuild("p1")).rejects.toBe(refusal);
});
expect(statusOf()).toBe("running");
});
it("falls back to what was on screen when even the re-read fails", async () => {
// Two failures in a row must not land on the one state there is no way out
// of. The backend's answer is preferred, but "unknown" is never a reason to
// keep showing a transition that is not happening.
stopProjectContainer.mockRejectedValue(refusal);
listProjects.mockRejectedValue("Docker is not running");
const { result } = renderHook(() => useProjects());
await act(async () => {
await expect(result.current.stop("p1")).rejects.toBe(refusal);
});
expect(statusOf()).toBe("running");
});
it("prefers the backend's answer over the status it captured", async () => {
// A start that dies half-way really has changed the world, so the captured
// status would be a lie. `listProjects` is the thing that knows.
useAppState.setState({ projects: [project("stopped")] });
startProjectContainer.mockRejectedValue("container exited during startup");
listProjects.mockResolvedValue([project("error")]);
const { result } = renderHook(() => useProjects());
await act(async () => {
await expect(result.current.start("p1")).rejects.toBe(
"container exited during startup",
);
});
expect(statusOf()).toBe("error");
});
it("still paints the optimistic status on the way in", async () => {
let release: (() => void) | undefined;
stopProjectContainer.mockReturnValue(
new Promise<void>((resolve) => {
release = resolve;
}),
);
listProjects.mockResolvedValue([project("stopped")]);
const { result } = renderHook(() => useProjects());
let pending: Promise<void> | undefined;
act(() => {
pending = result.current.stop("p1");
});
expect(statusOf()).toBe("stopping");
await act(async () => {
release?.();
await pending;
});
expect(statusOf()).toBe("stopped");
});
});