Files
kandev/apps/web/lib/state/slices/session-runtime/migrate-env-keyed-data.test.ts
T
José Almeida 6fa38cd62c fix(terminal): re-key user-shell RPCs to task_environment_id (#770)
* 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
2026-05-02 15:19:02 +01:00

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();
});
});