Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions apps/macos/Sources/OpenClaw/CockpitData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,18 @@ struct CockpitLaneSummary: Codable, Identifiable, Sendable {
}
}

struct CockpitTerminalLane: Codable, Identifiable, Sendable {
let id: String
let repoRoot: String
let worktreePath: String?
let backendProfile: String?
let workerId: String?
let status: String
let title: String?
let createdAt: String
let updatedAt: String
}

struct CockpitWorkerLogs: Codable, Sendable {
let workerId: String
let latestRun: CockpitRunSummary?
Expand Down Expand Up @@ -214,6 +226,7 @@ struct CockpitWorkspaceSummary: Codable, Sendable {
let pendingReviews: [CockpitReviewSummary]
let recentRuns: [CockpitRunSummary]
let activeLanes: [CockpitLaneSummary]
let terminalLanes: [CockpitTerminalLane]
}

extension CockpitGatewayStatus {
Expand Down Expand Up @@ -418,6 +431,18 @@ extension CockpitWorkspaceSummary {
terminationReason: "paused",
updatedAt: "2026-03-19T12:50:00.000Z"),
pendingReview: nil),
],
terminalLanes: [
CockpitTerminalLane(
id: "tl_shell",
repoRoot: "/Users/tessaro/openclaw",
worktreePath: "/Users/tessaro/openclaw/.worktrees/code/shell-lane",
backendProfile: "codex-cli",
workerId: "worker_shell",
status: "open",
title: "shell-lane",
createdAt: "2026-03-19T12:56:00.000Z",
updatedAt: "2026-03-19T12:58:00.000Z"),
])
}

Expand Down
1 change: 1 addition & 0 deletions src/cli/code-remote-timeout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ describe("code remote gateway timeout", () => {
retryBackoffCount: 0,
recentRuns: [],
activeLanes: [],
terminalLanes: [],
generatedAt: "2026-03-19T00:00:00.000Z",
});

Expand Down
1 change: 1 addition & 0 deletions src/code-cockpit/gateway-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ const runtimeMethods = vi.hoisted(() => ({
retryBackoffCount: 0,
recentRuns: [],
activeLanes: [],
terminalLanes: [],
})),
},
getCodeCockpitRuntime: vi.fn(),
Expand Down
37 changes: 37 additions & 0 deletions src/code-cockpit/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,23 @@ import type { ProcessSupervisor, RunExit, SpawnInput } from "../process/supervis
import {
type CreateCodeReviewRequestInput,
type CreateCodeTaskInput,
type CreateCodeTerminalLaneInput,
type UpdateCodeTerminalLaneInput,
createCodeTask,
createCodeReviewRequest,
createCodeRun,
createCodeTerminalLane,
createCodeWorkerSession,
getCodeTask,
getCodeCockpitWorkspaceSummary,
getCodeRun,
getCodeTerminalLane,
getCodeWorkerSession,
listCodeTerminalLanes,
loadCodeCockpitStore,
removeCodeTerminalLane,
updateCodeTerminalLane,
type CodeTerminalLane,
type CodeWorkerAuthHealth,
type CodeWorkerEngineId,
type CodePullRequestState,
Expand Down Expand Up @@ -1744,6 +1752,35 @@ class CodeCockpitRuntime {
await this.ensureInitialized();
return await getCodeCockpitWorkspaceSummary();
}

async addTerminalLane(input: CreateCodeTerminalLaneInput): Promise<CodeTerminalLane> {
await this.ensureInitialized();
return await createCodeTerminalLane(input);
}

async updateTerminalLane(params: {
laneId: string;
patch: UpdateCodeTerminalLaneInput;
}): Promise<CodeTerminalLane> {
await this.ensureInitialized();
return await updateCodeTerminalLane(params.laneId, params.patch);
}

async listTerminalLanes(): Promise<{ terminalLanes: CodeTerminalLane[] }> {
await this.ensureInitialized();
return { terminalLanes: await listCodeTerminalLanes() };
}

async showTerminalLane(params: { laneId: string }): Promise<CodeTerminalLane> {
await this.ensureInitialized();
return await getCodeTerminalLane(params.laneId);
}

async removeTerminalLane(params: { laneId: string }): Promise<{ removed: true }> {
await this.ensureInitialized();
await removeCodeTerminalLane(params.laneId);
return { removed: true };
}
}

let singleton: CodeCockpitRuntime | null = null;
Expand Down
133 changes: 133 additions & 0 deletions src/code-cockpit/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -415,4 +415,137 @@ describe("code cockpit store", () => {
]),
);
});

it("creates and persists terminal lanes bound to worktrees", async () => {
const storeModule = await importStoreModule();
const lane = await storeModule.createCodeTerminalLane({
repoRoot: "/tmp/openclaw",
worktreePath: "/tmp/openclaw/.worktrees/code/feature-a",
backendProfile: "codex-cli",
title: "feature-a",
});

expect(lane).toMatchObject({
repoRoot: "/tmp/openclaw",
worktreePath: "/tmp/openclaw/.worktrees/code/feature-a",
backendProfile: "codex-cli",
status: "open",
title: "feature-a",
});
expect(lane.id).toMatch(/^tl_/);

const store = await storeModule.loadCodeCockpitStore();
expect(store.terminalLanes).toEqual(
expect.arrayContaining([expect.objectContaining({ id: lane.id })]),
);
});

it("links a terminal lane to an existing worker", async () => {
const storeModule = await importStoreModule();
const task = await storeModule.createCodeTask({
title: "Worker-linked lane",
repoRoot: "/tmp/openclaw",
});
const worker = await storeModule.createCodeWorkerSession({
taskId: task.id,
name: "lane-worker",
repoRoot: "/tmp/openclaw",
worktreePath: "/tmp/openclaw/.worktrees/code/lane-worker",
});
const lane = await storeModule.createCodeTerminalLane({
repoRoot: "/tmp/openclaw",
worktreePath: worker.worktreePath,
workerId: worker.id,
});

expect(lane.workerId).toBe(worker.id);
expect(lane.worktreePath).toBe(worker.worktreePath);
});

it("rejects terminal lane creation with invalid worker id", async () => {
const storeModule = await importStoreModule();
await expect(
storeModule.createCodeTerminalLane({
repoRoot: "/tmp/openclaw",
workerId: "worker_nonexistent",
}),
).rejects.toThrow(/not found/);
});

it("updates terminal lane fields including worktree rebinding", async () => {
const storeModule = await importStoreModule();
const lane = await storeModule.createCodeTerminalLane({
repoRoot: "/tmp/openclaw",
worktreePath: "/tmp/openclaw/.worktrees/code/old-wt",
title: "old-title",
});

const updated = await storeModule.updateCodeTerminalLane(lane.id, {
worktreePath: "/tmp/openclaw/.worktrees/code/new-wt",
backendProfile: "claude-cli",
title: "new-title",
});

expect(updated.worktreePath).toBe("/tmp/openclaw/.worktrees/code/new-wt");
expect(updated.backendProfile).toBe("claude-cli");
expect(updated.title).toBe("new-title");
});

it("closes a terminal lane via status update", async () => {
const storeModule = await importStoreModule();
const lane = await storeModule.createCodeTerminalLane({
repoRoot: "/tmp/openclaw",
});
expect(lane.status).toBe("open");

const closed = await storeModule.updateCodeTerminalLane(lane.id, { status: "closed" });
expect(closed.status).toBe("closed");
});

it("lists terminal lanes sorted by most recently updated", async () => {
const storeModule = await importStoreModule();
const older = await storeModule.createCodeTerminalLane({
repoRoot: "/tmp/openclaw",
title: "older",
});
const newer = await storeModule.createCodeTerminalLane({
repoRoot: "/tmp/openclaw",
title: "newer",
});

const lanes = await storeModule.listCodeTerminalLanes();
expect(lanes.length).toBe(2);
expect(lanes[0].id).toBe(newer.id);
expect(lanes[1].id).toBe(older.id);
});

it("removes a terminal lane from the store", async () => {
const storeModule = await importStoreModule();
const lane = await storeModule.createCodeTerminalLane({
repoRoot: "/tmp/openclaw",
});

await storeModule.removeCodeTerminalLane(lane.id);

const lanes = await storeModule.listCodeTerminalLanes();
expect(lanes).toHaveLength(0);
});

it("includes open terminal lanes in the workspace summary", async () => {
const storeModule = await importStoreModule();
const open = await storeModule.createCodeTerminalLane({
repoRoot: "/tmp/openclaw",
worktreePath: "/tmp/openclaw/.worktrees/code/a",
title: "open-lane",
});
const closed = await storeModule.createCodeTerminalLane({
repoRoot: "/tmp/openclaw",
title: "closed-lane",
});
await storeModule.updateCodeTerminalLane(closed.id, { status: "closed" });

const summary = await storeModule.getCodeCockpitWorkspaceSummary();
expect(summary.terminalLanes).toHaveLength(1);
expect(summary.terminalLanes[0].id).toBe(open.id);
});
});
Loading
Loading