Compare commits
4 Commits
bab19e2f36
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c29504c75 | |||
| 39572aa04c | |||
| 3ae0d00b0c | |||
| a2b5fa4669 |
@@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* @file The compact lane tile used in the Workspace carousel. It carries only
|
* @file The compact lane tile used in the Workspace's vertical lane list. It
|
||||||
* what you need to pick a lane — which lane, is it alive, what stage, how far —
|
* carries only what you need to pick a lane — which lane, is it alive, what
|
||||||
* because the full card, its controls and its working-copy facts live in the
|
* stage, how far — because the full card, its controls and its working-copy
|
||||||
* detail panel below. Keeping the tile small is what lets a dozen lanes stay
|
* facts live in the detail panel beside it. Keeping the tile small and full
|
||||||
* scannable in one horizontal row.
|
* width is what lets many lanes stay scannable in one scrolling column.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ export default function LaneStripCard({
|
|||||||
aria-pressed={selected}
|
aria-pressed={selected}
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
title={lane.cwd}
|
title={lane.cwd}
|
||||||
className={`w-56 shrink-0 snap-start rounded-lg border p-3 text-left shadow-sm transition-colors ${
|
className={`w-full shrink-0 rounded-lg border p-3 text-left shadow-sm transition-colors ${
|
||||||
selected
|
selected
|
||||||
? "border-accent bg-accent/10"
|
? "border-accent bg-accent/10"
|
||||||
: "border-border bg-surface-2 hover:border-border-light hover:bg-surface-3"
|
: "border-border bg-surface-2 hover:border-border-light hover:bg-surface-3"
|
||||||
|
|||||||
@@ -90,6 +90,8 @@
|
|||||||
"git.uncommitted": "{{dirty}} modified · {{untracked}} untracked",
|
"git.uncommitted": "{{dirty}} modified · {{untracked}} untracked",
|
||||||
"kind.adopted": "adopted",
|
"kind.adopted": "adopted",
|
||||||
"kind.managed": "managed",
|
"kind.managed": "managed",
|
||||||
|
"laneDetail.hide": "Hide details",
|
||||||
|
"laneDetail.show": "Lane details",
|
||||||
"laneHeader": "Lane {{id}} · {{title}} · {{pipeline}}",
|
"laneHeader": "Lane {{id}} · {{title}} · {{pipeline}}",
|
||||||
"locks.held_one": "{{count}} lock held",
|
"locks.held_one": "{{count}} lock held",
|
||||||
"locks.held_other": "{{count}} locks held",
|
"locks.held_other": "{{count}} locks held",
|
||||||
|
|||||||
@@ -90,6 +90,8 @@
|
|||||||
"git.uncommitted": "{{dirty}} đã sửa · {{untracked}} chưa theo dõi",
|
"git.uncommitted": "{{dirty}} đã sửa · {{untracked}} chưa theo dõi",
|
||||||
"kind.adopted": "đã nhận",
|
"kind.adopted": "đã nhận",
|
||||||
"kind.managed": "được quản lý",
|
"kind.managed": "được quản lý",
|
||||||
|
"laneDetail.hide": "Ẩn chi tiết",
|
||||||
|
"laneDetail.show": "Chi tiết lane",
|
||||||
"laneHeader": "Làn đường {{id}} · {{title}} · {{pipeline}}",
|
"laneHeader": "Làn đường {{id}} · {{title}} · {{pipeline}}",
|
||||||
"locks.held_one": "Đang giữ {{count}} khóa",
|
"locks.held_one": "Đang giữ {{count}} khóa",
|
||||||
"locks.held_other": "Đang giữ {{count}} khóa",
|
"locks.held_other": "Đang giữ {{count}} khóa",
|
||||||
|
|||||||
@@ -54,39 +54,16 @@ import type { SplitViewState, SplitLayout } from "../lib/splitViewStorage";
|
|||||||
|
|
||||||
// ── Page ──────────────────────────────────────────────────────────────
|
// ── Page ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ConsoleArea({
|
function SplitLayoutToggle({
|
||||||
lanes,
|
|
||||||
selectedLaneId,
|
|
||||||
splitView,
|
splitView,
|
||||||
setLayout,
|
setLayout,
|
||||||
setPaneLaneId,
|
|
||||||
binaryStatus,
|
|
||||||
cwdSuggestions,
|
|
||||||
activeRuns,
|
|
||||||
wsConnected,
|
|
||||||
defaultCwd,
|
|
||||||
onHasActiveRunChange,
|
|
||||||
onLaneCreated,
|
|
||||||
onLaneIdChange,
|
|
||||||
}: {
|
}: {
|
||||||
lanes: Lane[];
|
|
||||||
selectedLaneId: number | null;
|
|
||||||
splitView: SplitViewState;
|
splitView: SplitViewState;
|
||||||
setLayout: (layout: SplitLayout) => void;
|
setLayout: (layout: SplitLayout) => void;
|
||||||
setPaneLaneId: (index: number, id: number) => void;
|
|
||||||
binaryStatus: { found: boolean; path: string | null } | null;
|
|
||||||
cwdSuggestions: CwdSuggestion[];
|
|
||||||
activeRuns: RunListResponse | null;
|
|
||||||
wsConnected: boolean;
|
|
||||||
defaultCwd: string;
|
|
||||||
onHasActiveRunChange: (val: boolean) => void;
|
|
||||||
onLaneCreated: (lane: Lane) => void;
|
|
||||||
onLaneIdChange: (id: number | null) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const { t: tLanes } = useTranslation("lanes");
|
const { t: tLanes } = useTranslation("lanes");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{([1, 2, 4] as const).map((n) => (
|
{([1, 2, 4] as const).map((n) => (
|
||||||
<button
|
<button
|
||||||
@@ -104,6 +81,43 @@ function ConsoleArea({
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConsoleArea({
|
||||||
|
lanes,
|
||||||
|
selectedLaneId,
|
||||||
|
splitView,
|
||||||
|
setLayout,
|
||||||
|
setPaneLaneId,
|
||||||
|
binaryStatus,
|
||||||
|
cwdSuggestions,
|
||||||
|
activeRuns,
|
||||||
|
wsConnected,
|
||||||
|
defaultCwd,
|
||||||
|
onHasActiveRunChange,
|
||||||
|
onLaneCreated,
|
||||||
|
onLaneIdChange,
|
||||||
|
showToggle = true,
|
||||||
|
}: {
|
||||||
|
lanes: Lane[];
|
||||||
|
selectedLaneId: number | null;
|
||||||
|
splitView: SplitViewState;
|
||||||
|
setLayout: (layout: SplitLayout) => void;
|
||||||
|
setPaneLaneId: (index: number, id: number) => void;
|
||||||
|
binaryStatus: { found: boolean; path: string | null } | null;
|
||||||
|
cwdSuggestions: CwdSuggestion[];
|
||||||
|
activeRuns: RunListResponse | null;
|
||||||
|
wsConnected: boolean;
|
||||||
|
defaultCwd: string;
|
||||||
|
onHasActiveRunChange: (val: boolean) => void;
|
||||||
|
onLaneCreated: (lane: Lane) => void;
|
||||||
|
onLaneIdChange: (id: number | null) => void;
|
||||||
|
showToggle?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showToggle && <SplitLayoutToggle splitView={splitView} setLayout={setLayout} />}
|
||||||
{splitView.layout === 1 ? (
|
{splitView.layout === 1 ? (
|
||||||
<LaneConsolePane
|
<LaneConsolePane
|
||||||
lanes={lanes}
|
lanes={lanes}
|
||||||
@@ -175,6 +189,11 @@ export function Workspace() {
|
|||||||
|
|
||||||
// Split view state
|
// Split view state
|
||||||
const [splitView, setSplitView] = useState<SplitViewState>(() => readSplitViewState());
|
const [splitView, setSplitView] = useState<SplitViewState>(() => readSplitViewState());
|
||||||
|
// Info block (pickers, LaneCard, PipelineMap, proof gallery) is always shown
|
||||||
|
// in single-pane view; in split view it starts collapsed so tall content
|
||||||
|
// never crowds the pane grid, and the user expands it on demand.
|
||||||
|
const [infoExpanded, setInfoExpanded] = useState(false);
|
||||||
|
const showInfo = splitView.layout === 1 || infoExpanded;
|
||||||
|
|
||||||
const setLayout = useCallback((layout: SplitLayout) => {
|
const setLayout = useCallback((layout: SplitLayout) => {
|
||||||
setSplitView((prev) => {
|
setSplitView((prev) => {
|
||||||
@@ -420,7 +439,10 @@ export function Workspace() {
|
|||||||
try {
|
try {
|
||||||
const { lane } = await api.lanes.update(id, { pipeline });
|
const { lane } = await api.lanes.update(id, { pipeline });
|
||||||
await refreshLanes();
|
await refreshLanes();
|
||||||
if (!lane.pipeline_nodes.some((n) => n.state === "current")) {
|
// `stage` defaults to the DB sentinel "idle" until a driving session ever
|
||||||
|
// calls `ccam stage` — that's the normal state for most lanes and never
|
||||||
|
// matches a real pipeline node, so it isn't a mismatch worth surfacing.
|
||||||
|
if (lane.stage !== "idle" && !lane.pipeline_nodes.some((n) => n.state === "current")) {
|
||||||
setLaneActionError(
|
setLaneActionError(
|
||||||
tLanes("pipelinePicker.stageMismatch", {
|
tLanes("pipelinePicker.stageMismatch", {
|
||||||
stage: lane.stage,
|
stage: lane.stage,
|
||||||
@@ -488,11 +510,14 @@ export function Workspace() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Lane carousel: pick a lane here, read it below. Horizontal scroll with
|
{/* Lane list + detail live side by side so neither can cover the other:
|
||||||
snap so a dozen lanes stay in one row instead of a wall of cards. */}
|
the list is a scrolling column (holds any number of lanes), the
|
||||||
|
detail/console area to its right never has to compete with it for
|
||||||
|
vertical space. */}
|
||||||
|
<div className="flex min-h-0 flex-1 gap-4">
|
||||||
<div
|
<div
|
||||||
data-testid="lane-strip"
|
data-testid="lane-strip"
|
||||||
className="flex snap-x snap-mandatory gap-2 overflow-x-auto pb-1"
|
className="flex w-60 shrink-0 flex-col gap-2 overflow-y-auto pr-1"
|
||||||
>
|
>
|
||||||
{lanes.map((l) => (
|
{lanes.map((l) => (
|
||||||
<LaneStripCard
|
<LaneStripCard
|
||||||
@@ -509,17 +534,42 @@ export function Workspace() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* The selected lane's pipeline, full width — the thing you actually
|
{/* The selected lane's pipeline — the thing you actually come to this
|
||||||
come to this page to read. */}
|
page to read, beside the list rather than stacked under it. */}
|
||||||
{currentLane && (
|
{currentLane && (
|
||||||
<section data-testid="lane-detail" className="card p-4">
|
<section
|
||||||
|
data-testid="lane-detail"
|
||||||
|
className="flex min-h-0 min-w-0 flex-1 flex-col card p-4"
|
||||||
|
>
|
||||||
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<span className="truncate text-sm font-semibold text-fg-primary">
|
||||||
|
{tLanes("laneHeader", {
|
||||||
|
id: currentLane.id,
|
||||||
|
title: currentLane.title || currentLane.cwd,
|
||||||
|
pipeline: currentLane.pipeline_name,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{splitView.layout !== 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="lane-detail-toggle"
|
||||||
|
aria-expanded={infoExpanded}
|
||||||
|
onClick={() => setInfoExpanded((v) => !v)}
|
||||||
|
className="rounded border border-border-light px-2 py-1 text-xs text-fg-secondary transition-colors hover:text-fg-primary"
|
||||||
|
>
|
||||||
|
{infoExpanded ? tLanes("laneDetail.hide") : tLanes("laneDetail.show")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<SplitLayoutToggle splitView={splitView} setLayout={setLayout} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{showInfo && (
|
||||||
|
<div className="max-h-[45vh] shrink-0 overflow-y-auto">
|
||||||
<div className="mb-3 flex flex-wrap items-baseline gap-2">
|
<div className="mb-3 flex flex-wrap items-baseline gap-2">
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-widest text-fg-muted">
|
<span className="text-[11px] font-semibold uppercase tracking-widest text-fg-muted">
|
||||||
{tLanes("cardId", { id: currentLane.id })}
|
{tLanes("cardId", { id: currentLane.id })}
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate text-sm font-semibold text-fg-primary">
|
|
||||||
{currentLane.title || currentLane.cwd}
|
|
||||||
</span>
|
|
||||||
<select
|
<select
|
||||||
data-testid="pipeline-picker"
|
data-testid="pipeline-picker"
|
||||||
aria-label={tLanes("pipelinePicker.label")}
|
aria-label={tLanes("pipelinePicker.label")}
|
||||||
@@ -592,7 +642,9 @@ export function Workspace() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<PipelineMap
|
<PipelineMap
|
||||||
nodes={viewedFeature ? viewedFeature.pipeline_nodes : currentLane.pipeline_nodes}
|
nodes={
|
||||||
|
viewedFeature ? viewedFeature.pipeline_nodes : currentLane.pipeline_nodes
|
||||||
|
}
|
||||||
detectedSignal={viewedFeature ? undefined : currentLane.detected_signal}
|
detectedSignal={viewedFeature ? undefined : currentLane.detected_signal}
|
||||||
/>
|
/>
|
||||||
{viewedFeature && (
|
{viewedFeature && (
|
||||||
@@ -642,7 +694,9 @@ export function Workspace() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex min-h-0 flex-col gap-2 border-t border-border pt-3">
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col gap-2 border-t border-border pt-3">
|
||||||
<ConsoleArea
|
<ConsoleArea
|
||||||
lanes={lanes}
|
lanes={lanes}
|
||||||
selectedLaneId={selectedLaneId}
|
selectedLaneId={selectedLaneId}
|
||||||
@@ -659,13 +713,14 @@ export function Workspace() {
|
|||||||
setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]))
|
setLanes((prev) => (prev.some((l) => l.id === lane.id) ? prev : [...prev, lane]))
|
||||||
}
|
}
|
||||||
onLaneIdChange={setSelectedLaneId}
|
onLaneIdChange={setSelectedLaneId}
|
||||||
|
showToggle={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!currentLane && (
|
{!currentLane && (
|
||||||
<div className="flex min-h-0 flex-col gap-2">
|
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
||||||
<ConsoleArea
|
<ConsoleArea
|
||||||
lanes={lanes}
|
lanes={lanes}
|
||||||
selectedLaneId={selectedLaneId}
|
selectedLaneId={selectedLaneId}
|
||||||
@@ -685,6 +740,7 @@ export function Workspace() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<AddLaneModal
|
<AddLaneModal
|
||||||
open={addLaneOpen}
|
open={addLaneOpen}
|
||||||
|
|||||||
@@ -5762,7 +5762,10 @@ exports[`screen snapshots > Run 1`] = `
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="flex snap-x snap-mandatory gap-2 overflow-x-auto pb-1"
|
class="flex min-h-0 flex-1 gap-4"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex w-60 shrink-0 flex-col gap-2 overflow-y-auto pr-1"
|
||||||
data-testid="lane-strip"
|
data-testid="lane-strip"
|
||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
@@ -5776,7 +5779,7 @@ exports[`screen snapshots > Run 1`] = `
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="flex min-h-0 flex-col gap-2"
|
class="flex min-h-0 flex-1 flex-col gap-2"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-1.5"
|
class="flex items-center gap-1.5"
|
||||||
@@ -6137,6 +6140,7 @@ exports[`screen snapshots > Run 1`] = `
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`screen snapshots > Session detail 1`] = `
|
exports[`screen snapshots > Session detail 1`] = `
|
||||||
|
|||||||
@@ -80,6 +80,29 @@ function makeRunChild({ exitsOnKill }) {
|
|||||||
return child;
|
return child;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Puts a fake `claude` binary on PATH so a real `/start` spawns a real tmux
|
||||||
|
// session running THIS script instead of the system Claude Code CLI. Tests
|
||||||
|
// that mock tmux's own exec calls (to simulate a stuck/live session) still
|
||||||
|
// spawn this real process underneath — without the stub, that spawn launches
|
||||||
|
// the actual `claude` binary and, because the mock replaces the app's own
|
||||||
|
// kill-session call, the real process is never actually terminated, leaking
|
||||||
|
// a live tmux session + CLI process for good. Returns the restore function.
|
||||||
|
function stubClaudeBinary(name) {
|
||||||
|
const bin = path.join(ROOT, `${name}-bin`);
|
||||||
|
const claude = path.join(bin, "claude");
|
||||||
|
fs.mkdirSync(bin, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
claude,
|
||||||
|
"#!/usr/bin/env node\nprocess.on('SIGTERM', () => process.exit(0));\nsetInterval(() => {}, 1000);\n"
|
||||||
|
);
|
||||||
|
fs.chmodSync(claude, 0o755);
|
||||||
|
const originalPath = process.env.PATH;
|
||||||
|
process.env.PATH = `${bin}${path.delimiter}${originalPath}`;
|
||||||
|
return () => {
|
||||||
|
process.env.PATH = originalPath;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function waitForProvisioning(id) {
|
async function waitForProvisioning(id) {
|
||||||
const deadline = Date.now() + 5000;
|
const deadline = Date.now() + 5000;
|
||||||
let response;
|
let response;
|
||||||
@@ -818,6 +841,7 @@ describe("destructive lane lifecycle actions", () => {
|
|||||||
fs.writeFileSync(sentinel, "still here\n");
|
fs.writeFileSync(sentinel, "still here\n");
|
||||||
|
|
||||||
// Start a run for the lane
|
// Start a run for the lane
|
||||||
|
const restorePath = stubClaudeBinary("await-timeout");
|
||||||
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "stuck" });
|
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "stuck" });
|
||||||
assert.equal(started.status, 200);
|
assert.equal(started.status, 200);
|
||||||
const runId = started.body.lane.run_id;
|
const runId = started.body.lane.run_id;
|
||||||
@@ -848,6 +872,15 @@ describe("destructive lane lifecycle actions", () => {
|
|||||||
assert.equal(fs.readFileSync(sentinel, "utf8"), "still here\n");
|
assert.equal(fs.readFileSync(sentinel, "utf8"), "still here\n");
|
||||||
} finally {
|
} finally {
|
||||||
tmux.__reset();
|
tmux.__reset();
|
||||||
|
// The mocked kill-session above only fools the app's own check — the
|
||||||
|
// real tmux session + claude stub spawned above is still alive and
|
||||||
|
// must be killed for real, or it leaks past this test run.
|
||||||
|
try {
|
||||||
|
execFileSync("tmux", ["kill-session", "-t", runId], { stdio: "ignore" });
|
||||||
|
} catch {
|
||||||
|
// already gone
|
||||||
|
}
|
||||||
|
restorePath();
|
||||||
}
|
}
|
||||||
await request("DELETE", `/api/lanes/${lane.id}`);
|
await request("DELETE", `/api/lanes/${lane.id}`);
|
||||||
});
|
});
|
||||||
@@ -889,6 +922,7 @@ describe("destructive lane lifecycle actions", () => {
|
|||||||
const lane = await createManagedLane("start-twice");
|
const lane = await createManagedLane("start-twice");
|
||||||
|
|
||||||
// Start a run for the lane
|
// Start a run for the lane
|
||||||
|
const restorePath = stubClaudeBinary("start-twice");
|
||||||
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "first" });
|
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "first" });
|
||||||
assert.equal(started.status, 200);
|
assert.equal(started.status, 200);
|
||||||
const runId = started.body.lane.run_id;
|
const runId = started.body.lane.run_id;
|
||||||
@@ -915,6 +949,15 @@ describe("destructive lane lifecycle actions", () => {
|
|||||||
assert.equal(after.body.lane.run_id, runId);
|
assert.equal(after.body.lane.run_id, runId);
|
||||||
} finally {
|
} finally {
|
||||||
tmux.__reset();
|
tmux.__reset();
|
||||||
|
// The real tmux session behind the "first" run is never reset/killed
|
||||||
|
// in this test, mocked or otherwise — kill it for real so it doesn't
|
||||||
|
// leak past this test run.
|
||||||
|
try {
|
||||||
|
execFileSync("tmux", ["kill-session", "-t", runId], { stdio: "ignore" });
|
||||||
|
} catch {
|
||||||
|
// already gone
|
||||||
|
}
|
||||||
|
restorePath();
|
||||||
}
|
}
|
||||||
|
|
||||||
await request("DELETE", `/api/lanes/${lane.id}`);
|
await request("DELETE", `/api/lanes/${lane.id}`);
|
||||||
@@ -1134,6 +1177,7 @@ describe("lane ensure, start mode, lane_id and releasing a finished run", () =>
|
|||||||
const lane = await adoptedLane("release-moved-on");
|
const lane = await adoptedLane("release-moved-on");
|
||||||
|
|
||||||
// Create a run for this lane.
|
// Create a run for this lane.
|
||||||
|
const restorePath = stubClaudeBinary("release-moved-on");
|
||||||
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "test" });
|
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "test" });
|
||||||
assert.equal(started.status, 200, JSON.stringify(started.body));
|
assert.equal(started.status, 200, JSON.stringify(started.body));
|
||||||
const runId = started.body.lane.run_id;
|
const runId = started.body.lane.run_id;
|
||||||
@@ -1161,6 +1205,14 @@ describe("lane ensure, start mode, lane_id and releasing a finished run", () =>
|
|||||||
assert.equal(after.status, "running");
|
assert.equal(after.status, "running");
|
||||||
} finally {
|
} finally {
|
||||||
tmux.__reset();
|
tmux.__reset();
|
||||||
|
// The app never calls kill-session here (healing preserves the "live"
|
||||||
|
// run) — kill the real tmux session directly so it doesn't leak.
|
||||||
|
try {
|
||||||
|
execFileSync("tmux", ["kill-session", "-t", runId], { stdio: "ignore" });
|
||||||
|
} catch {
|
||||||
|
// already gone
|
||||||
|
}
|
||||||
|
restorePath();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1169,6 +1221,7 @@ describe("lane ensure, start mode, lane_id and releasing a finished run", () =>
|
|||||||
const lane = await adoptedLane("release-stale-run");
|
const lane = await adoptedLane("release-stale-run");
|
||||||
|
|
||||||
// Start a run for this lane.
|
// Start a run for this lane.
|
||||||
|
const restorePath = stubClaudeBinary("release-stale-run");
|
||||||
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "test" });
|
const started = await request("POST", `/api/lanes/${lane.id}/start`, { prompt: "test" });
|
||||||
assert.equal(started.status, 200, JSON.stringify(started.body));
|
assert.equal(started.status, 200, JSON.stringify(started.body));
|
||||||
const runId = started.body.lane.run_id;
|
const runId = started.body.lane.run_id;
|
||||||
@@ -1195,6 +1248,14 @@ describe("lane ensure, start mode, lane_id and releasing a finished run", () =>
|
|||||||
assert.equal(after.status, "idle", "status should be idle after run is gone");
|
assert.equal(after.status, "idle", "status should be idle after run is gone");
|
||||||
} finally {
|
} finally {
|
||||||
tmux.__reset();
|
tmux.__reset();
|
||||||
|
// The app believes the session is already gone and never calls
|
||||||
|
// kill-session — kill the real tmux session directly so it doesn't leak.
|
||||||
|
try {
|
||||||
|
execFileSync("tmux", ["kill-session", "-t", runId], { stdio: "ignore" });
|
||||||
|
} catch {
|
||||||
|
// already gone
|
||||||
|
}
|
||||||
|
restorePath();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user