6fa38cd62c
* refactor(backend): re-key user-shell RPCs to task_environment_id user_shell.list/create/stop now take task_environment_id directly instead of session_id with a session→env translation. Sessions in the same task share one shell list, so the env is the natural scope. HTTP route /api/v1/sessions/:id/terminals → /api/v1/environments/:id/terminals. shell.status/subscribe/input (agent passthrough) stay session-keyed. * fix(backend): heal task_sessions.task_environment_id on boot Sessions created via paths that didn't write the FK left shell ops broken — every env-keyed user-shell RPC failed to resolve the env and the frontend silently swallowed the error. The boot heal links each orphan session to its task's existing env (idempotent, runs after backfillTaskEnvironments + healDuplicateTaskEnvironments). * refactor(web): drop session_id from user-shell API, hooks, and slice createUserShell/stopUserShell/fetchTerminals take environmentId. useUserShells takes envId directly (no environmentIdBySessionId translation). useTerminals takes both (env for shell ops, session for tab UX persistence). Slice actions key writes by env id directly, removing the silent no-op when the session→env mapping wasn't set yet. * refactor(web): wire env id through dockview, right-panel, and SSR call sites Dockview "+" menu, terminal scripts dropdown, dev-preview button, right-panel "+" button, Ctrl+\` toggle, watermark, and panel-close cleanup all source the env id from useEnvironmentId() / store and pass it to createUserShell/stopUserShell. Shell-mode passthrough terminal no longer blocks on session id — env id is sufficient. SSR resolves the active session's task_environment_id from the session list and calls fetchTerminals with the env id. * test(e2e): cover env-keyed terminal new-tab flows + WS payload guard New spec exercises the right-panel "+" button and the dockview "+ → Terminal" menu, asserts the new tab connects, and intercepts outgoing user_shell.create WS frames to verify the payload carries task_environment_id and never session_id. A heal-coverage assertion checks every listed session has task_environment_id populated post-boot. * fix(e2e): drop right-panel '+' test, dockview is the only '+' path on desktop The mobile TaskRightPanel SessionTabs aren't rendered at desktop viewports — the dockview '+' menu is the sole new-tab affordance, so testing the mobile path on chromium just timed out clicking a button that never mounted. Consolidate into a single dockview '+ → Terminal' test that asserts the new panel gets the backend-assigned 'Terminal 2' label (proving env-shared shell list) plus the WS-payload regression guard. * fix(e2e): assert dockview terminal-tab count, not 'Terminal 2' label Dockview hardcodes the panel title to 'Terminal' for every tab — only the mobile right-panel SessionTabs surface the backend's 'Terminal N' label. Switch the assertion to count tabs to keep the e2e green. * test(backend): cover SQL NULL branch of healSessionTaskEnvironmentIDs Adds insertSessionWithNullEnvID and TestHealSessionTaskEnvironmentIDs_LinksNullOrphans so the WHERE-clause IS NULL predicate is exercised — the existing tests only seeded empty-string FKs. Also adds LIMIT 1 to the heal subquery as a defensive measure: the unique index already guarantees ≤1 row per task at runtime, but the SQL now reads as deterministic in isolation. * refactor(web): drop dead taskGateOk on agent terminal WS gate Agent passthrough routes by sessionId, not taskId — taskId was only used for logging. Gating the WS effect on taskId forced agent terminals to wait for useSession() to hydrate, adding a refresh-race window that didn't guard any real precondition. connectionKey + canConnect already cover the meaningful preconditions. * fix(web): stamp env id on terminal panels for accurate cleanup addTerminalPanel now bakes environmentId into the panel's params at creation time. The dockview-layout-setup onDidRemovePanel handler prefers the stamped env over re-deriving from the active session, so closing a terminal that was opened under a different task no longer calls stopUserShell with the wrong env (which silently no-ops on the backend and leaks the shell process until session teardown). Falls back to the active session's env for legacy panels persisted before the param existed. * fix(web): keep task id logging out of terminal reconnect deps
208 lines
7.9 KiB
TypeScript
208 lines
7.9 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { create } from "zustand";
|
|
import { immer } from "zustand/middleware/immer";
|
|
import { createSessionRuntimeSlice } from "./session-runtime-slice";
|
|
import { createSessionSlice } from "../session/session-slice";
|
|
import type { SessionRuntimeSlice } from "./types";
|
|
import type { SessionSlice } from "../session/types";
|
|
|
|
function makeStore() {
|
|
return create<SessionRuntimeSlice>()(immer(createSessionRuntimeSlice));
|
|
}
|
|
|
|
type CombinedSlice = SessionRuntimeSlice & SessionSlice;
|
|
|
|
function makeCombinedStore() {
|
|
return create<CombinedSlice>()(
|
|
immer((set) => ({
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
...(createSessionSlice as any)(set),
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
...(createSessionRuntimeSlice as any)(set),
|
|
})),
|
|
);
|
|
}
|
|
|
|
describe("registerSessionEnvironment — migrateEnvKeyedData", () => {
|
|
it("setUserShells writes directly under the environmentId key (no session translation)", () => {
|
|
const store = makeStore();
|
|
|
|
store.getState().setUserShells("env-1", [{ terminalId: "t1" } as never]);
|
|
|
|
const state = store.getState();
|
|
expect(state.userShells.byEnvironmentId["env-1"]).toEqual([{ terminalId: "t1" }]);
|
|
expect(state.userShells.loaded["env-1"]).toBe(true);
|
|
});
|
|
|
|
it("setUserShells with empty environmentId is a no-op", () => {
|
|
const store = makeStore();
|
|
|
|
store.getState().setUserShells("", [{ terminalId: "t1" } as never]);
|
|
|
|
const state = store.getState();
|
|
expect(state.userShells.byEnvironmentId[""]).toBeUndefined();
|
|
});
|
|
|
|
it("migrates data from sessionId key to environmentId key", () => {
|
|
const store = makeStore();
|
|
|
|
// Simulate data arriving under the fallback sessionId key
|
|
store.getState().setSessionCommits("sess-1", [{ commit_sha: "abc" } as never]);
|
|
store.getState().setGitStatus("sess-1", { branch: "main" } as never);
|
|
store.getState().appendShellOutput("sess-1", "hello");
|
|
|
|
// Register the environment mapping
|
|
store.getState().registerSessionEnvironment("sess-1", "env-1");
|
|
|
|
const state = store.getState();
|
|
// Data should now live under the environmentId key
|
|
expect(state.sessionCommits.byEnvironmentId["env-1"]).toEqual([{ commit_sha: "abc" }]);
|
|
expect(state.gitStatus.byEnvironmentId["env-1"]).toEqual({ branch: "main" });
|
|
expect(state.shell.outputs["env-1"]).toBe("hello");
|
|
// sessionId key should be cleaned up
|
|
expect(state.sessionCommits.byEnvironmentId["sess-1"]).toBeUndefined();
|
|
expect(state.gitStatus.byEnvironmentId["sess-1"]).toBeUndefined();
|
|
expect(state.shell.outputs["sess-1"]).toBeUndefined();
|
|
});
|
|
|
|
it("does not clobber pre-existing environmentId data", () => {
|
|
const store = makeStore();
|
|
|
|
// Data already stored under the environmentId key
|
|
store.getState().registerSessionEnvironment("other-sess", "env-1");
|
|
store.getState().setSessionCommits("other-sess", [{ commit_sha: "existing" } as never]);
|
|
|
|
// Stale data under sessionId from before mapping was known
|
|
store.getState().setGitStatus("sess-2", { branch: "stale" } as never);
|
|
|
|
// A different session wrote commits under its own fallback key
|
|
store.setState((draft) => {
|
|
draft.sessionCommits.byEnvironmentId["sess-2"] = [{ commit_sha: "orphan" } as never];
|
|
});
|
|
|
|
// Register: sess-2 maps to env-1 (which already has commit data)
|
|
store.getState().registerSessionEnvironment("sess-2", "env-1");
|
|
|
|
const state = store.getState();
|
|
// Commits: pre-existing env-1 data preserved, orphaned sess-2 key deleted
|
|
expect(state.sessionCommits.byEnvironmentId["env-1"]).toEqual([{ commit_sha: "existing" }]);
|
|
expect(state.sessionCommits.byEnvironmentId["sess-2"]).toBeUndefined();
|
|
// Git status: no env-1 data existed, so sess-2 data migrated
|
|
expect(state.gitStatus.byEnvironmentId["env-1"]).toEqual({ branch: "stale" });
|
|
expect(state.gitStatus.byEnvironmentId["sess-2"]).toBeUndefined();
|
|
});
|
|
|
|
it("no-ops when sessionId equals environmentId", () => {
|
|
const store = makeStore();
|
|
|
|
store.getState().setSessionCommits("local-1", [{ commit_sha: "abc" } as never]);
|
|
|
|
store.getState().registerSessionEnvironment("local-1", "local-1");
|
|
|
|
const state = store.getState();
|
|
// Data stays under the same key
|
|
expect(state.sessionCommits.byEnvironmentId["local-1"]).toEqual([{ commit_sha: "abc" }]);
|
|
});
|
|
|
|
it("migrates only sub-keys that have data", () => {
|
|
const store = makeStore();
|
|
|
|
// Only put data in shell outputs and userShells, leave others empty
|
|
store.getState().appendShellOutput("sess-3", "output");
|
|
store.setState((draft) => {
|
|
draft.userShells.byEnvironmentId["sess-3"] = [{ terminalId: "t1" } as never];
|
|
});
|
|
|
|
store.getState().registerSessionEnvironment("sess-3", "env-3");
|
|
|
|
const state = store.getState();
|
|
expect(state.shell.outputs["env-3"]).toBe("output");
|
|
expect(state.shell.outputs["sess-3"]).toBeUndefined();
|
|
expect(state.userShells.byEnvironmentId["env-3"]).toEqual([{ terminalId: "t1" }]);
|
|
expect(state.userShells.byEnvironmentId["sess-3"]).toBeUndefined();
|
|
// Other stores should have no data for either key
|
|
expect(state.sessionCommits.byEnvironmentId["env-3"]).toBeUndefined();
|
|
expect(state.sessionCommits.byEnvironmentId["sess-3"]).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("setTaskSession — cross-slice migration", () => {
|
|
it("migrates env-keyed data when task_environment_id is set", () => {
|
|
const store = makeCombinedStore();
|
|
|
|
// Data arrives under fallback sessionId key before the mapping is known
|
|
store.getState().setGitStatus("sess-1", { branch: "main" } as never);
|
|
store.getState().setSessionCommits("sess-1", [{ commit_sha: "abc" } as never]);
|
|
|
|
// Session update arrives with task_environment_id
|
|
store.getState().setTaskSession({
|
|
id: "sess-1",
|
|
task_id: "task-1",
|
|
state: "RUNNING",
|
|
task_environment_id: "env-1",
|
|
started_at: "",
|
|
updated_at: "",
|
|
});
|
|
|
|
const state = store.getState();
|
|
expect(state.environmentIdBySessionId["sess-1"]).toBe("env-1");
|
|
expect(state.gitStatus.byEnvironmentId["env-1"]).toEqual({ branch: "main" });
|
|
expect(state.gitStatus.byEnvironmentId["sess-1"]).toBeUndefined();
|
|
expect(state.sessionCommits.byEnvironmentId["env-1"]).toEqual([{ commit_sha: "abc" }]);
|
|
expect(state.sessionCommits.byEnvironmentId["sess-1"]).toBeUndefined();
|
|
});
|
|
|
|
it("does not migrate when task_environment_id is absent", () => {
|
|
const store = makeCombinedStore();
|
|
|
|
store.getState().setGitStatus("sess-2", { branch: "dev" } as never);
|
|
|
|
store.getState().setTaskSession({
|
|
id: "sess-2",
|
|
task_id: "task-2",
|
|
state: "RUNNING",
|
|
started_at: "",
|
|
updated_at: "",
|
|
});
|
|
|
|
const state = store.getState();
|
|
expect(state.environmentIdBySessionId["sess-2"]).toBeUndefined();
|
|
expect(state.gitStatus.byEnvironmentId["sess-2"]).toEqual({ branch: "dev" });
|
|
});
|
|
});
|
|
|
|
describe("setTaskSessionsForTask — bulk cross-slice migration", () => {
|
|
it("migrates env-keyed data for all sessions with task_environment_id", () => {
|
|
const store = makeCombinedStore();
|
|
|
|
// Data under fallback keys
|
|
store.getState().setGitStatus("sess-a", { branch: "a" } as never);
|
|
store.getState().appendShellOutput("sess-b", "hello");
|
|
|
|
store.getState().setTaskSessionsForTask("task-1", [
|
|
{
|
|
id: "sess-a",
|
|
task_id: "task-1",
|
|
state: "COMPLETED",
|
|
task_environment_id: "env-x",
|
|
started_at: "",
|
|
updated_at: "",
|
|
},
|
|
{
|
|
id: "sess-b",
|
|
task_id: "task-1",
|
|
state: "RUNNING",
|
|
task_environment_id: "env-y",
|
|
started_at: "",
|
|
updated_at: "",
|
|
},
|
|
]);
|
|
|
|
const state = store.getState();
|
|
expect(state.gitStatus.byEnvironmentId["env-x"]).toEqual({ branch: "a" });
|
|
expect(state.gitStatus.byEnvironmentId["sess-a"]).toBeUndefined();
|
|
expect(state.shell.outputs["env-y"]).toBe("hello");
|
|
expect(state.shell.outputs["sess-b"]).toBeUndefined();
|
|
});
|
|
});
|