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:
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,7 @@ import { useCallback } from "react";
|
|||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useAppState } from "../store/appState";
|
import { useAppState } from "../store/appState";
|
||||||
import * as commands from "../lib/tauri-commands";
|
import * as commands from "../lib/tauri-commands";
|
||||||
import type { ProjectPath } from "../lib/types";
|
import type { ProjectPath, ProjectStatus } from "../lib/types";
|
||||||
|
|
||||||
export function useProjects() {
|
export function useProjects() {
|
||||||
const {
|
const {
|
||||||
@@ -61,34 +61,85 @@ export function useProjects() {
|
|||||||
[updateProjectInList],
|
[updateProjectInList],
|
||||||
);
|
);
|
||||||
|
|
||||||
const start = useCallback(
|
/**
|
||||||
async (id: string) => {
|
* Paint the optimistic status, run the command, and **put the status back if
|
||||||
setOptimisticStatus(id, "starting");
|
* the command never happened.**
|
||||||
const updated = await commands.startProjectContainer(id);
|
*
|
||||||
updateProjectInList(updated);
|
* The optimistic write exists so a click moves the row immediately. It used
|
||||||
return updated;
|
* 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;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[updateProjectInList, setOptimisticStatus],
|
[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(
|
const stop = useCallback(
|
||||||
async (id: string) => {
|
(id: string) =>
|
||||||
setOptimisticStatus(id, "stopping");
|
withOptimisticStatus(id, "stopping", async () => {
|
||||||
await commands.stopProjectContainer(id);
|
await commands.stopProjectContainer(id);
|
||||||
const list = await commands.listProjects();
|
const list = await commands.listProjects();
|
||||||
setProjects(list);
|
setProjects(list);
|
||||||
},
|
}),
|
||||||
[setProjects, setOptimisticStatus],
|
[setProjects, withOptimisticStatus],
|
||||||
);
|
);
|
||||||
|
|
||||||
const rebuild = useCallback(
|
const rebuild = useCallback(
|
||||||
async (id: string) => {
|
(id: string) =>
|
||||||
setOptimisticStatus(id, "starting");
|
withOptimisticStatus(id, "starting", async () => {
|
||||||
const updated = await commands.rebuildProjectContainer(id);
|
const updated = await commands.rebuildProjectContainer(id);
|
||||||
updateProjectInList(updated);
|
updateProjectInList(updated);
|
||||||
return updated;
|
return updated;
|
||||||
},
|
}),
|
||||||
[updateProjectInList, setOptimisticStatus],
|
[updateProjectInList, withOptimisticStatus],
|
||||||
);
|
);
|
||||||
|
|
||||||
const update = useCallback(
|
const update = useCallback(
|
||||||
|
|||||||
+30
-6
@@ -52,7 +52,13 @@ export interface Project {
|
|||||||
* certificate file or a directory of them). null falls back to
|
* certificate file or a directory of them). null falls back to
|
||||||
* `AppSettings.ca_cert_path`. Changing it recreates the container. */
|
* `AppSettings.ca_cert_path`. Changing it recreates the container. */
|
||||||
ca_cert_path: string | null;
|
ca_cert_path: string | null;
|
||||||
git_token: string | null;
|
/** **Write-only.** Rust marks this `#[serde(skip_serializing)]`, so it is
|
||||||
|
* absent from every project the backend hands back — reading it gives
|
||||||
|
* `undefined`, never `null`. Optional here so that is the type, and so a
|
||||||
|
* `=== null` test against it is a compile error rather than a branch that
|
||||||
|
* silently never runs. Still sent on the way *in*: this is how the secret
|
||||||
|
* is set. */
|
||||||
|
git_token?: string | null;
|
||||||
git_user_name: string | null;
|
git_user_name: string | null;
|
||||||
git_user_email: string | null;
|
git_user_email: string | null;
|
||||||
custom_env_vars: EnvVar[];
|
custom_env_vars: EnvVar[];
|
||||||
@@ -96,11 +102,23 @@ export type BedrockAuthMethod = "static_credentials" | "profile" | "bearer_token
|
|||||||
export interface BedrockConfig {
|
export interface BedrockConfig {
|
||||||
auth_method: BedrockAuthMethod;
|
auth_method: BedrockAuthMethod;
|
||||||
aws_region: string;
|
aws_region: string;
|
||||||
aws_access_key_id: string | null;
|
/** **Write-only.** Rust marks this `#[serde(skip_serializing)]`, so it is
|
||||||
aws_secret_access_key: string | null;
|
* absent from every project the backend hands back — reading it gives
|
||||||
aws_session_token: string | null;
|
* `undefined`, never `null`. Optional here so that is the type, and so a
|
||||||
|
* `=== null` test against it is a compile error rather than a branch that
|
||||||
|
* silently never runs. Still sent on the way *in*: this is how the secret
|
||||||
|
* is set. */
|
||||||
|
aws_access_key_id?: string | null;
|
||||||
|
aws_secret_access_key?: string | null;
|
||||||
|
aws_session_token?: string | null;
|
||||||
aws_profile: string | null;
|
aws_profile: string | null;
|
||||||
aws_bearer_token: string | null;
|
/** **Write-only.** Rust marks this `#[serde(skip_serializing)]`, so it is
|
||||||
|
* absent from every project the backend hands back — reading it gives
|
||||||
|
* `undefined`, never `null`. Optional here so that is the type, and so a
|
||||||
|
* `=== null` test against it is a compile error rather than a branch that
|
||||||
|
* silently never runs. Still sent on the way *in*: this is how the secret
|
||||||
|
* is set. */
|
||||||
|
aws_bearer_token?: string | null;
|
||||||
model_id: string | null;
|
model_id: string | null;
|
||||||
disable_prompt_caching: boolean;
|
disable_prompt_caching: boolean;
|
||||||
service_tier: string | null;
|
service_tier: string | null;
|
||||||
@@ -127,7 +145,13 @@ export interface LlamaCppConfig {
|
|||||||
* implement the **Anthropic** Messages API — e.g. LiteLLM. */
|
* implement the **Anthropic** Messages API — e.g. LiteLLM. */
|
||||||
export interface OpenAiCompatibleConfig {
|
export interface OpenAiCompatibleConfig {
|
||||||
base_url: string;
|
base_url: string;
|
||||||
api_key: string | null;
|
/** **Write-only.** Rust marks this `#[serde(skip_serializing)]`, so it is
|
||||||
|
* absent from every project the backend hands back — reading it gives
|
||||||
|
* `undefined`, never `null`. Optional here so that is the type, and so a
|
||||||
|
* `=== null` test against it is a compile error rather than a branch that
|
||||||
|
* silently never runs. Still sent on the way *in*: this is how the secret
|
||||||
|
* is set. */
|
||||||
|
api_key?: string | null;
|
||||||
model_id: string | null;
|
model_id: string | null;
|
||||||
/** See `OllamaConfig.haiku_model_id`. */
|
/** See `OllamaConfig.haiku_model_id`. */
|
||||||
haiku_model_id: string | null;
|
haiku_model_id: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user