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
18 changes: 9 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,21 +40,21 @@ openclaw onboard

Arc has one runtime with two surfaces:

| Surface | Role |
| --- | --- |
| Surface | Role |
| ------------------- | ------------------------------------------------------ |
| **Swift macOS app** | Flagship review workstation — diffs, queues, decisions |
| **VPS TUI** | Fast remote operator console — queue, inspect, unblock |
| **VPS TUI** | Fast remote operator console — queue, inspect, unblock |

### The Layer Model

Arc only makes sense if the layers stay clean:

| Layer | Role |
| --- | --- |
| **Arc** | product, workflow, workstation, project cockpit |
| **OpenClaw** | runtime, gateway, worktrees, worker lifecycle, durable state |
| **Claude + Codex** | worker engines that do the coding work |
| **Obsidian** | planning, notes, specs, architecture, project memory |
| Layer | Role |
| ------------------ | ------------------------------------------------------------ |
| **Arc** | product, workflow, workstation, project cockpit |
| **OpenClaw** | runtime, gateway, worktrees, worker lifecycle, durable state |
| **Claude + Codex** | worker engines that do the coding work |
| **Obsidian** | planning, notes, specs, architecture, project memory |

Obsidian should hold thinking. Arc should hold execution.

Expand Down
6 changes: 6 additions & 0 deletions apps/macos/Sources/OpenClaw/CockpitData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,12 @@ struct CockpitSupervisorTickResult: Codable, Sendable {
let run: CockpitRunSummary?
}

struct CockpitWorkspaceState: Codable, Sendable {
let selectedWorkerId: String?
let lastProjectRoot: String?
let updatedAt: String?
}

struct CockpitWorkspaceSummary: Codable, Sendable {
let storePath: String
let generatedAt: String
Expand Down
42 changes: 41 additions & 1 deletion apps/macos/Sources/OpenClaw/CockpitStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ typealias CockpitWorkerLogsLoader = @Sendable (_ workerId: String) async throws
typealias CockpitSupervisorTickPerformer = @Sendable (_ repoRoot: String?) async throws -> CockpitSupervisorTickResult
typealias CockpitWorkerActionPerformer = @Sendable (_ action: CockpitWorkerAction, _ workerId: String) async throws -> Void
typealias CockpitRemoteReconnectAction = @Sendable () async throws -> Void
typealias CockpitWorkspaceStateSaver = @Sendable (_ selectedWorkerId: String?, _ lastProjectRoot: String?) async throws -> Void
typealias CockpitWorkspaceStateLoader = @Sendable () async throws -> CockpitWorkspaceState

enum CockpitLoadError: LocalizedError {
case gatewayUnavailable(String)
Expand Down Expand Up @@ -56,6 +58,9 @@ final class CockpitStore {
private let performSupervisorTickImpl: CockpitSupervisorTickPerformer
private let performWorkerActionImpl: CockpitWorkerActionPerformer
private let reconnectRemoteGatewayImpl: CockpitRemoteReconnectAction
private let saveWorkspaceStateImpl: CockpitWorkspaceStateSaver
private let loadWorkspaceStateImpl: CockpitWorkspaceStateLoader
private var hasRestoredWorkspaceState = false

var selectedLane: CockpitLaneSummary? {
guard let snapshot = self.snapshot else { return nil }
Expand All @@ -80,7 +85,9 @@ final class CockpitStore {
loadWorkerLogs: CockpitWorkerLogsLoader? = nil,
performSupervisorTick: CockpitSupervisorTickPerformer? = nil,
performWorkerAction: CockpitWorkerActionPerformer? = nil,
reconnectRemoteGateway: CockpitRemoteReconnectAction? = nil)
reconnectRemoteGateway: CockpitRemoteReconnectAction? = nil,
saveWorkspaceState: CockpitWorkspaceStateSaver? = nil,
loadWorkspaceState: CockpitWorkspaceStateLoader? = nil)
{
self.isPreview = isPreview
self.loadGatewayStatus = loadGatewayStatus ?? {
Expand Down Expand Up @@ -113,6 +120,14 @@ final class CockpitStore {
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
await GatewayEndpointStore.shared.refresh()
}
self.saveWorkspaceStateImpl = saveWorkspaceState ?? { selectedWorkerId, lastProjectRoot in
try await GatewayConnection.shared.codeWorkspaceStateSave(
selectedWorkerId: selectedWorkerId,
lastProjectRoot: lastProjectRoot)
}
self.loadWorkspaceStateImpl = loadWorkspaceState ?? {
try await GatewayConnection.shared.codeWorkspaceStateLoad()
}
}

func startNextWorker() async {
Expand Down Expand Up @@ -160,6 +175,10 @@ final class CockpitStore {
do {
self.gatewayStatus = try await self.loadGatewayStatus()
self.snapshot = try await self.loadSummary()
if !self.hasRestoredWorkspaceState {
self.hasRestoredWorkspaceState = true
await self.restoreWorkspaceState()
}
self.reconcileSelection()
await self.refreshSelectedWorkerLogs()
} catch {
Expand Down Expand Up @@ -188,6 +207,7 @@ final class CockpitStore {
func selectWorker(_ workerId: String) async {
self.selectedWorkerId = workerId
await self.refreshSelectedWorkerLogs()
await self.persistWorkspaceState()
}

func performWorkerAction(_ action: CockpitWorkerAction, workerId: String) async {
Expand Down Expand Up @@ -282,4 +302,24 @@ final class CockpitStore {
self.lastError = message
}
}

private func restoreWorkspaceState() async {
do {
let state = try await self.loadWorkspaceStateImpl()
if let workerId = state.selectedWorkerId, !workerId.isEmpty {
self.selectedWorkerId = workerId
}
} catch {
self.logger.debug("code cockpit workspace state restore skipped: \(error.localizedDescription, privacy: .public)")
}
}

private func persistWorkspaceState() async {
guard !self.isPreview else { return }
do {
try await self.saveWorkspaceStateImpl(self.selectedWorkerId, self.projectRootLabel)
} catch {
self.logger.debug("code cockpit workspace state save failed: \(error.localizedDescription, privacy: .public)")
}
}
}
20 changes: 20 additions & 0 deletions apps/macos/Sources/OpenClaw/GatewayConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ actor GatewayConnection {
case codeWorkerResume = "code.worker.resume"
case codeWorkerCancel = "code.worker.cancel"
case codeWorkerLogs = "code.worker.logs"
case codeWorkspaceStateSave = "code.cockpit.workspace-state.save"
case codeWorkspaceStateLoad = "code.cockpit.workspace-state.load"
}

private let configProvider: @Sendable () async throws -> Config
Expand Down Expand Up @@ -848,6 +850,24 @@ extension GatewayConnection {
timeoutMs: 10000)
}

func codeWorkspaceStateSave(selectedWorkerId: String?, lastProjectRoot: String?) async throws {
var params: [String: AnyCodable] = [:]
if let selectedWorkerId, !selectedWorkerId.isEmpty {
params["selectedWorkerId"] = AnyCodable(selectedWorkerId)
}
if let lastProjectRoot, !lastProjectRoot.isEmpty {
params["lastProjectRoot"] = AnyCodable(lastProjectRoot)
}
try await self.requestVoid(
method: .codeWorkspaceStateSave,
params: params.isEmpty ? nil : params,
timeoutMs: 5000)
}

func codeWorkspaceStateLoad() async throws -> CockpitWorkspaceState {
try await self.requestDecoded(method: .codeWorkspaceStateLoad, timeoutMs: 5000)
}

nonisolated static func decodeCronListResponse(_ data: Data) throws -> [CronJob] {
let decoded = try JSONDecoder().decode(LossyCronListResponse.self, from: data)
let jobs = decoded.jobs.compactMap(\.value)
Expand Down
14 changes: 14 additions & 0 deletions src/code-cockpit/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ import {
updateCodeTask,
updateCodeTaskStatus,
updateCodeWorkerSession,
type CodeCockpitWorkspaceState,
type SaveWorkspaceStateInput,
saveCodeCockpitWorkspaceState,
loadCodeCockpitWorkspaceState,
} from "./store.js";
import { isTaskInRetryBackoff, resolveTaskFailure } from "./task-reliability.js";

Expand Down Expand Up @@ -1744,6 +1748,16 @@ class CodeCockpitRuntime {
await this.ensureInitialized();
return await getCodeCockpitWorkspaceSummary();
}

async saveWorkspaceState(input: SaveWorkspaceStateInput): Promise<CodeCockpitWorkspaceState> {
await this.ensureInitialized();
return await saveCodeCockpitWorkspaceState(input);
}

async loadWorkspaceState(): Promise<CodeCockpitWorkspaceState | null> {
await this.ensureInitialized();
return await loadCodeCockpitWorkspaceState();
}
}

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

it("persists and loads workspace state", async () => {
const storeModule = await importStoreModule();

const before = await storeModule.loadCodeCockpitWorkspaceState();
expect(before).toBeNull();

const saved = await storeModule.saveCodeCockpitWorkspaceState({
selectedWorkerId: "worker_abc",
lastProjectRoot: "/tmp/project",
});
expect(saved).toMatchObject({
selectedWorkerId: "worker_abc",
lastProjectRoot: "/tmp/project",
});
expect(saved.updatedAt).toBeTruthy();

const loaded = await storeModule.loadCodeCockpitWorkspaceState();
expect(loaded).toMatchObject({
selectedWorkerId: "worker_abc",
lastProjectRoot: "/tmp/project",
});
});

it("overwrites workspace state on subsequent saves", async () => {
const storeModule = await importStoreModule();

await storeModule.saveCodeCockpitWorkspaceState({
selectedWorkerId: "worker_1",
lastProjectRoot: "/tmp/first",
});

await storeModule.saveCodeCockpitWorkspaceState({
selectedWorkerId: "worker_2",
lastProjectRoot: "/tmp/second",
});

const loaded = await storeModule.loadCodeCockpitWorkspaceState();
expect(loaded).toMatchObject({
selectedWorkerId: "worker_2",
lastProjectRoot: "/tmp/second",
});
});

it("clears workspace state fields when null is passed", async () => {
const storeModule = await importStoreModule();

await storeModule.saveCodeCockpitWorkspaceState({
selectedWorkerId: "worker_abc",
lastProjectRoot: "/tmp/project",
});

await storeModule.saveCodeCockpitWorkspaceState({
selectedWorkerId: null,
lastProjectRoot: null,
});

const loaded = await storeModule.loadCodeCockpitWorkspaceState();
expect(loaded).toMatchObject({});
expect(loaded?.selectedWorkerId).toBeUndefined();
expect(loaded?.lastProjectRoot).toBeUndefined();
});
});
39 changes: 39 additions & 0 deletions src/code-cockpit/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,12 @@ export type CodeRun = {
updatedAt: string;
};

export type CodeCockpitWorkspaceState = {
selectedWorkerId?: string;
lastProjectRoot?: string;
updatedAt: string;
};

export type CodeCockpitStore = {
version: number;
updatedAt: string;
Expand All @@ -179,6 +185,7 @@ export type CodeCockpitStore = {
decisions: CodeDecisionLog[];
contextSnapshots: CodeContextSnapshot[];
runs: CodeRun[];
workspaceState?: CodeCockpitWorkspaceState;
};

export type CodeCockpitSummary = {
Expand Down Expand Up @@ -415,6 +422,7 @@ function createEmptyStore(updatedAt: string): CodeCockpitStore {
decisions: [],
contextSnapshots: [],
runs: [],
workspaceState: undefined,
};
}

Expand Down Expand Up @@ -469,6 +477,10 @@ function normalizeStore(
decisions: Array.isArray(candidate.decisions) ? candidate.decisions : [],
contextSnapshots: Array.isArray(candidate.contextSnapshots) ? candidate.contextSnapshots : [],
runs: Array.isArray(candidate.runs) ? candidate.runs : [],
workspaceState:
candidate.workspaceState && typeof candidate.workspaceState === "object"
? candidate.workspaceState
: undefined,
};
}

Expand Down Expand Up @@ -1336,3 +1348,30 @@ export async function getCodeCockpitWorkspaceSummary(
activeLanes,
};
}

export type SaveWorkspaceStateInput = {
selectedWorkerId?: string | null;
lastProjectRoot?: string | null;
};

export async function saveCodeCockpitWorkspaceState(
input: SaveWorkspaceStateInput,
options?: CodeCockpitStoreOptions,
): Promise<CodeCockpitWorkspaceState> {
return await mutateStore(options, (store, updatedAt) => {
const state: CodeCockpitWorkspaceState = {
selectedWorkerId: normalizePatchString(input.selectedWorkerId) ?? undefined,
lastProjectRoot: normalizePatchString(input.lastProjectRoot) ?? undefined,
updatedAt,
};
store.workspaceState = state;
return state;
});
}

export async function loadCodeCockpitWorkspaceState(
options?: CodeCockpitStoreOptions,
): Promise<CodeCockpitWorkspaceState | null> {
const store = await loadCodeCockpitStore(options);
return store.workspaceState ?? null;
}
16 changes: 16 additions & 0 deletions src/gateway/server-methods/code-cockpit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,4 +253,20 @@ export const codeCockpitHandlers: GatewayRequestHandlers = {
}),
);
},
"code.cockpit.workspace-state.save": async ({ params, respond }) => {
await withRuntimeResult(
respond,
async () =>
await getCodeCockpitRuntime().saveWorkspaceState({
selectedWorkerId: optionalString(params.selectedWorkerId),
lastProjectRoot: optionalString(params.lastProjectRoot),
}),
);
},
"code.cockpit.workspace-state.load": async ({ respond }) => {
await withRuntimeResult(
respond,
async () => await getCodeCockpitRuntime().loadWorkspaceState(),
);
},
};
Loading