diff --git a/README.md b/README.md index a04a13293..1ba5a65d2 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/apps/macos/Sources/OpenClaw/CockpitData.swift b/apps/macos/Sources/OpenClaw/CockpitData.swift index 50dc049f8..442ff40ab 100644 --- a/apps/macos/Sources/OpenClaw/CockpitData.swift +++ b/apps/macos/Sources/OpenClaw/CockpitData.swift @@ -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 diff --git a/apps/macos/Sources/OpenClaw/CockpitStore.swift b/apps/macos/Sources/OpenClaw/CockpitStore.swift index 94df591b0..60a6adf9a 100644 --- a/apps/macos/Sources/OpenClaw/CockpitStore.swift +++ b/apps/macos/Sources/OpenClaw/CockpitStore.swift @@ -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) @@ -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 } @@ -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 ?? { @@ -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 { @@ -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 { @@ -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 { @@ -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)") + } + } } diff --git a/apps/macos/Sources/OpenClaw/GatewayConnection.swift b/apps/macos/Sources/OpenClaw/GatewayConnection.swift index 454e17dd2..9f4c37a75 100644 --- a/apps/macos/Sources/OpenClaw/GatewayConnection.swift +++ b/apps/macos/Sources/OpenClaw/GatewayConnection.swift @@ -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 @@ -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) diff --git a/src/code-cockpit/runtime.ts b/src/code-cockpit/runtime.ts index d1288aed8..eb161f9d0 100644 --- a/src/code-cockpit/runtime.ts +++ b/src/code-cockpit/runtime.ts @@ -44,6 +44,10 @@ import { updateCodeTask, updateCodeTaskStatus, updateCodeWorkerSession, + type CodeCockpitWorkspaceState, + type SaveWorkspaceStateInput, + saveCodeCockpitWorkspaceState, + loadCodeCockpitWorkspaceState, } from "./store.js"; import { isTaskInRetryBackoff, resolveTaskFailure } from "./task-reliability.js"; @@ -1744,6 +1748,16 @@ class CodeCockpitRuntime { await this.ensureInitialized(); return await getCodeCockpitWorkspaceSummary(); } + + async saveWorkspaceState(input: SaveWorkspaceStateInput): Promise { + await this.ensureInitialized(); + return await saveCodeCockpitWorkspaceState(input); + } + + async loadWorkspaceState(): Promise { + await this.ensureInitialized(); + return await loadCodeCockpitWorkspaceState(); + } } let singleton: CodeCockpitRuntime | null = null; diff --git a/src/code-cockpit/store.test.ts b/src/code-cockpit/store.test.ts index 08f91d285..70f863a8d 100644 --- a/src/code-cockpit/store.test.ts +++ b/src/code-cockpit/store.test.ts @@ -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(); + }); }); diff --git a/src/code-cockpit/store.ts b/src/code-cockpit/store.ts index 90f8d744f..c6af4987d 100644 --- a/src/code-cockpit/store.ts +++ b/src/code-cockpit/store.ts @@ -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; @@ -179,6 +185,7 @@ export type CodeCockpitStore = { decisions: CodeDecisionLog[]; contextSnapshots: CodeContextSnapshot[]; runs: CodeRun[]; + workspaceState?: CodeCockpitWorkspaceState; }; export type CodeCockpitSummary = { @@ -415,6 +422,7 @@ function createEmptyStore(updatedAt: string): CodeCockpitStore { decisions: [], contextSnapshots: [], runs: [], + workspaceState: undefined, }; } @@ -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, }; } @@ -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 { + 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 { + const store = await loadCodeCockpitStore(options); + return store.workspaceState ?? null; +} diff --git a/src/gateway/server-methods/code-cockpit.ts b/src/gateway/server-methods/code-cockpit.ts index 73f9f1e1f..6fd1f0595 100644 --- a/src/gateway/server-methods/code-cockpit.ts +++ b/src/gateway/server-methods/code-cockpit.ts @@ -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(), + ); + }, };