diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt index e7e0b5e59eb5..0e838d5cf753 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt @@ -166,6 +166,13 @@ data class WorkerDesktopLaunchResult( val status: String = "ready", ) +@Serializable +data class ProjectsListResult( + val projects: List, + val recents: List? = null, + val observedProjects: List? = null, +) + @Serializable data class GatewayEventFrameStateVersion( val presence: Long, @@ -178,6 +185,30 @@ data class GatewayNodeInvokeResultParamsError( val message: String? = null, ) +@Serializable +data class ProjectsListResultProjectsItem( + val id: String, + val displayName: String, + val repoRoot: String? = null, + val originUrl: String? = null, + val source: String, + val agentId: String? = null, +) + +@Serializable +data class ProjectsListResultObservedProjectsItem( + val name: String, + val originUrl: String? = null, + val checkouts: List, + val lastUsedAt: Double, +) + +@Serializable +data class ProjectsListResultObservedProjectsItemCheckoutsItem( + val runnerId: String, + val path: String, +) + enum class GatewayMethod( val rawValue: String, ) { diff --git a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewayProtocolGeneratedTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewayProtocolGeneratedTest.kt index 84ebe30e2bd4..d30f4bd80ff8 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewayProtocolGeneratedTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewayProtocolGeneratedTest.kt @@ -46,6 +46,14 @@ class GatewayProtocolGeneratedTest { assertEquals(5_000L, decoded.timeoutMs) } + @Test + fun projectsListResultDecodesALegacyProjectsOnlyPayload() { + val decoded = json.decodeFromString(ProjectsListResult.serializer(), """{"projects":[]}""") + + assertTrue(decoded.projects.isEmpty()) + assertNull(decoded.observedProjects) + } + @Test fun generatedGatewayCatalogsAreCompleteAndUnique() { val methods = GatewayMethod.entries.map { it.rawValue } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index a452c6140789..4054619e80ad 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -2183,6 +2183,50 @@ public struct WorkerDesktopLaunchResult: Codable, Sendable { } } +public struct ProjectCheckout: Codable, Sendable { + public let runnerid: String + public let path: String + + public init( + runnerid: String, + path: String) + { + self.runnerid = runnerid + self.path = path + } + + private enum CodingKeys: String, CodingKey { + case runnerid = "runnerId" + case path + } +} + +public struct ProjectSummary: Codable, Sendable { + public let name: String + public let originurl: String? + public let checkouts: [ProjectCheckout] + public let lastusedat: Double + + public init( + name: String, + originurl: String? = nil, + checkouts: [ProjectCheckout], + lastusedat: Double) + { + self.name = name + self.originurl = originurl + self.checkouts = checkouts + self.lastusedat = lastusedat + } + + private enum CodingKeys: String, CodingKey { + case name + case originurl = "originUrl" + case checkouts + case lastusedat = "lastUsedAt" + } +} + public struct SystemInfoParams: Codable, Sendable {} public struct SystemInfoResult: Codable, Sendable { @@ -3165,23 +3209,39 @@ public struct ProjectRecentProject: Codable, Sendable { } } -public struct ProjectsListParams: Codable, Sendable {} +public struct ProjectsListParams: Codable, Sendable { + public let includeobserved: Bool? + + public init( + includeobserved: Bool? = nil) + { + self.includeobserved = includeobserved + } + + private enum CodingKeys: String, CodingKey { + case includeobserved = "includeObserved" + } +} public struct ProjectsListResult: Codable, Sendable { public let projects: [ProjectsAddResult] public let recents: [ProjectRecent]? + public let observedprojects: [ProjectSummary]? public init( projects: [ProjectsAddResult], - recents: [ProjectRecent]? = nil) + recents: [ProjectRecent]? = nil, + observedprojects: [ProjectSummary]? = nil) { self.projects = projects self.recents = recents + self.observedprojects = observedprojects } private enum CodingKeys: String, CodingKey { case projects case recents + case observedprojects = "observedProjects" } } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayProtocolGeneratedModelsTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayProtocolGeneratedModelsTests.swift index 0b662155e8b6..dc05ff0035ec 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayProtocolGeneratedModelsTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayProtocolGeneratedModelsTests.swift @@ -68,4 +68,14 @@ struct GatewayProtocolGeneratedModelsTests { #expect(additive.scopes == ["operator.read"]) #expect(additive.locale == "en-US") } + + @Test + func `projects list result decodes a legacy projects-only payload`() throws { + let result = try JSONDecoder().decode( + ProjectsListResult.self, + from: Data(#"{"projects":[]}"#.utf8)) + + #expect(result.projects.isEmpty) + #expect(result.observedprojects == nil) + } } diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json index 706088d0dc2d..71e7a14df4c3 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json @@ -1 +1 @@ -{"contentHash":"92f86077bf8bd7e0ade16592677b6e824e8ace048e40849aa5fd5cd9fe17f280","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} +{"contentHash":"8c6ff70bbf195705355c6599c50819065f5f350f0faaddc4a868b5e86da65e28","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json index 911561a94fd2..24728a1bca39 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json @@ -1 +1 @@ -{"contentHash":"82523c6b1d5b8bc1b0933455ef0d8f798fb938e2b0e2676a434522926fd1d1ed","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} +{"contentHash":"91311c46d7fa124d5b79e0a8a6640f2ef8436c5000c1dafa3391f8c1303054b7","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-core.json b/docs/.generated/plugin-sdk-api-baseline/channel-core.json index 75b83b372c06..a546efe66f2f 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-core.json @@ -1 +1 @@ -{"contentHash":"b771ba5e57fca93e4b49512f7cde5cd1c1b0fe4cf247985aeae5bfd44cb54923","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} +{"contentHash":"c3b22d889dc4c65a0ab8c4f5743e49df7f18a399a77b271a56e2cd60a2671fea","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json index 94540322a878..4bddaffac84c 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json @@ -1 +1 @@ -{"contentHash":"8bc9cfdf0f7299079e3d39ce377b1eb8cf2b4b99ff39d3f8ceee1f7ac72d0dbd","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} +{"contentHash":"7cc678c87c2d6063305951bc9bcd75f78f4fe2a9408a263bc1cb294c66075b50","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-message.json b/docs/.generated/plugin-sdk-api-baseline/channel-message.json index 96aadff0c4d3..505d71483049 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-message.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-message.json @@ -1 +1 @@ -{"contentHash":"69ed324324c86ad863f2804fd60dfc61f6b79040ee34fa52d33d1277857db4b1","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} +{"contentHash":"f30a3ff93dab57ce1a6e73c10b027d2bb748968cd87cda27228654fc41811a03","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json index 51b4f05da2d5..251f4ea554dc 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json @@ -1 +1 @@ -{"contentHash":"a98fa525383537f792dfc06ffb40d1a89cf7c5d1d381e253178f5a2ebad79276","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} +{"contentHash":"b6ef4ba8c3840141bff65a7fee3a5ea8efbe3da79696f5d59b41f8ccf1e32914","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json index 2f5c29760554..4da75b1b6b96 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json @@ -1 +1 @@ -{"contentHash":"8fdceb1f341434d335cbbb84052651df83b29436d1016c694461b072a75121c1","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} +{"contentHash":"43afd1b1996e17f373e75c3922cbea6a0223782b0b68f6d05f7c29bbd83b8ebb","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} diff --git a/docs/.generated/plugin-sdk-api-baseline/core.json b/docs/.generated/plugin-sdk-api-baseline/core.json index ee55b7e02682..ceac45434780 100644 --- a/docs/.generated/plugin-sdk-api-baseline/core.json +++ b/docs/.generated/plugin-sdk-api-baseline/core.json @@ -1 +1 @@ -{"contentHash":"121f16d39c0b147393ca289221d4b020856a661dfc989dc978b5a499bfce5222","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} +{"contentHash":"4d6021d0ae4ad15e1ce39d8700756ec415d604606ed538240c8f8aa9bf0acc37","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/discord.json b/docs/.generated/plugin-sdk-api-baseline/discord.json index 5a43a582629b..a581faedc4ea 100644 --- a/docs/.generated/plugin-sdk-api-baseline/discord.json +++ b/docs/.generated/plugin-sdk-api-baseline/discord.json @@ -1 +1 @@ -{"contentHash":"00e179ad017e2921d85f9d2ffcf5f9e1bb415984d19e6fdc62a1832dbee659b0","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} +{"contentHash":"17eaa6392ffdf071bbe104a7c5720892042a906109be42478dec03b8bf5291a0","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} diff --git a/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json b/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json index 1751f4ed359a..f232b4541473 100644 --- a/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json @@ -1 +1 @@ -{"contentHash":"e31574637f3e276338e3e05e4774f7ad79981042c8021e49fb53df7ffe901917","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"} +{"contentHash":"176c5db037a1e5b685c0550aeee46186b3273e8da4bbb8a720a074596598e8cc","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json index 2bdb8846687f..7c0976da00ab 100644 --- a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json +++ b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json @@ -1 +1 @@ -{"contentHash":"bfde664c63043f9719c8c12ef1e144dc387c05348b0f8b26bb2156438518dc21","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} +{"contentHash":"7bbbe7b8509abe241992eb632b1b484df2c270534f485ecf0f80e48b5671566a","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} diff --git a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json index d8be367b6c13..94e70da01e84 100644 --- a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json @@ -1 +1 @@ -{"contentHash":"2608b548ecca0c2981d80bff3596982d74572558c0fe7eb846a971880b1563af","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} +{"contentHash":"00ae2e13016a7ba0ebd4baef8b34a2b3811e0221799f6351d2189f6e0ea694ba","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json index f655ce4ffdbd..474c9c965de1 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json @@ -1 +1 @@ -{"contentHash":"8220986c0c848a5fe05c909f5c0626a6ebe0800fe5517a196718d4f21d30ebb6","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} +{"contentHash":"e20acaab6a8d3d56b57a487168439da89adfe250f0474a4c53350424415ac498","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json index 4d3025465523..0dd7bab53d6f 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json @@ -1 +1 @@ -{"contentHash":"8350a80731ab2e020c859ea1994acd004fbaa9ca9c0d92fb09acd8703bc79ed3","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} +{"contentHash":"31e0fb8e73f98c8f69f8808aec8d8dc18fc2a074a21b9fc82414729fa1acceee","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json index f7fcdd9e6351..1661414f0136 100644 --- a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json @@ -1 +1 @@ -{"contentHash":"f61cc543e5d007e5da4951ccc61e12fc107fed478dfc8285b37389a77d2a6224","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} +{"contentHash":"4918ded36f8fc0701bd94e4be965577c1c035977e6c5d32db1c17862775d6318","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json index ffa394876c59..bc034e25fcc5 100644 --- a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json +++ b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json @@ -1 +1 @@ -{"contentHash":"f27ec37aa7c772f12aeda7b8aa969b34a7a7bb7877e6d95b26a8b3e9174f4899","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} +{"contentHash":"cd199186dee1148dc53d05710cb9785fdcff475bedbc92f5c18b5ddca675dbe7","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} diff --git a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json index 7b8b290d000f..92ab4d7a41d3 100644 --- a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json +++ b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json @@ -1 +1 @@ -{"contentHash":"6579285290ce10e5bf4ae930bbb2d28de52672fc4c7dd0f55f0dcdc1d711d3c1","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} +{"contentHash":"265be26af18bed1c86ddc4636df78ac2f064848dcfc9e9da8e05842c69750ba4","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} diff --git a/docs/plan/runners.md b/docs/plan/runners.md index 4d3765f0ed74..88fab30f0dfc 100644 --- a/docs/plan/runners.md +++ b/docs/plan/runners.md @@ -21,7 +21,7 @@ advances a milestone. | 1c | Cleanup: node-pairing → device-pairing merge | not started | — | | 2 | `openclaw resume` + web Continue in terminal | in progress | #120664 | | 3 | `openclaw connect` one-paste onboarding + `/j/` join route | not started | — | -| 4 | Picker: liveness, enrichment, Connect-a-machine | not started | — | +| 4 | Picker: grouping, placement, liveness, enrichment | in progress | #120804 | | 5 | Public worker ingress path | not started | — | | 6 | Node worker provider (device runners) | not started | — | | 7 | Bundle push consent + runner updates | not started | — | @@ -272,24 +272,46 @@ it cannot rot into approval fatigue or silent surprise: dispatch to stale nodes with a doctor-style hint instead of failing silently. +### Projects read model (milestone 4 foundation) + +OpenClaw already computes project identity twice without naming it: the +worktree service derives `originUrl` + a 16-char repo fingerprint +(`src/agents/worktrees/service.ts:199-205`), and the sessions catalog groups +Codex/Claude rows by project folder, folding `.claude/worktrees/` into +its origin repo. This component promotes that to a first-class observed read +model alongside the registered projects already returned by `projects.list`, +following the same computed pattern as `environments.list`: + +- **`projects.list.observedProjects` read model** (computed for + write-capable callers, no new store): group known checkouts by repo fingerprint → `{ name, originUrl, checkouts: +[{runnerId, path}], lastUsedAt }`. Sources: session rows + (`execCwd`/`execNode`) and the managed-worktree registry. The observed + paths and sanitized origins are returned only to `operator.write` callers; + read-only callers keep the registered project catalog and project-only + recents. Device-advertised checkouts remain milestone 6 work. + ### UI (milestone 4) Revision 1's design rule stands: normal state is silent; only exceptions speak. Additions: -- The Where picker subscribes to `presence` and pairing events (the devices - page already does), so a freshly connected machine appears live while the - popover is open — the visible payoff of the one-paste flow. -- Sections "This gateway / Your devices / Cloud"; session-capable connected - devices only (capability-gated); busy state from slot occupancy; presence - vocabulary distinguishes _never connected_ from _was connected, lost_ - (the first-connect failure is the top onboarding support case). -- `EnvironmentSummary` enriched additively: platform, session-host - capability, trust class, runner version. -- Placement chip on the session header: current placement + state; reclaim - ("Bring home") for remote placements; stop-and-continue moves once - milestone 8 ships. `runner-offline` shows as a banner with the recorded - reason and the two recovery verbs. +- **Use the existing environment type discriminant** for picker grouping: + local gateway, connected execution-capable nodes, worker environments, and + the separate cloud profiles list. `sessionHost` is deferred to milestone 6, + where device runners introduce the capability fact that needs it. +- **Where picker regrouped** (`ui/src/pages/new-session/place-picker.ts`): + sections "This gateway" / "Devices" / "Cloud". Device rows intersect the + environment catalog with connected, execution-capable nodes; cloud + profiles remain their separate list. Folder and destination stay + orthogonal. +- **Placement chip** on the session header: shows quiet current placement; + active cloud placements reclaim through `sessions.reclaim` with "Bring + home". Stop-and-continue moves arrive with milestone 8. +- **Remaining milestone work**: live presence and pairing subscriptions, the + admin-gated "Connect a machine…" foot, busy and never-connected states, + and additive `EnvironmentSummary` platform, session-host, trust, and runner + version facts. `runner-offline` then shows a banner with the recorded reason + and its recovery verbs. ### Cloud convergence (milestone 10) @@ -370,9 +392,10 @@ Independently mergeable PR series; 3–5 can interleave after 1c. shortcode mint + curl wrapper on the public site. Exit: a fresh machine pairs against a remote gateway with one pasted command and one admin click, no manual approval steps. -4. **Picker**: live presence subscription; "Connect a machine…" foot - (admin-gated) showing the copyable one-liner; regrouped sections; additive - `EnvironmentSummary` enrichment; never-connected vs lost states. +4. **Picker** (in progress): regrouped sections, quiet placement + reclaim, + and the observed projects read model land first; live presence subscription, + the admin-gated "Connect a machine…" foot, additive `EnvironmentSummary` + enrichment, and never-connected vs lost states complete the milestone. 5. **Public worker ingress**: path-tagged worker upgrade on the main TLS endpoint; opaque admission failure; shared preauth budgets. Exit: a worker process on any internet host with a valid dispatch credential completes diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index a89b94a37e7e..a4ffa3eeba41 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -37,6 +37,7 @@ export { } from "./schema/sessions-row.js"; export * from "./schema/session-classification.js"; export * from "./schema/sessions-suggestions.js"; +export * from "./schema/projects.js"; export * from "./migration-api.js"; export type * from "./public-session-catalog.js"; export * from "./validator-registry.js"; diff --git a/packages/gateway-protocol/src/schema/projects.test.ts b/packages/gateway-protocol/src/schema/projects.test.ts index a67ffe651df9..26024f679724 100644 --- a/packages/gateway-protocol/src/schema/projects.test.ts +++ b/packages/gateway-protocol/src/schema/projects.test.ts @@ -1,8 +1,11 @@ import { Value } from "typebox/value"; import { describe, expect, it } from "vitest"; import { + PROJECTS_LIST_DEFAULT_LIMIT, + PROJECTS_LIST_MAX_CHECKOUTS_PER_PROJECT, ProjectRecordSchema, ProjectsAddResultSchema, + ProjectSummarySchema, ProjectsListResultSchema, ProjectsSearchRemoteResultSchema, validateProjectsAddParams, @@ -16,6 +19,9 @@ import { describe("project protocol schemas", () => { it("validates project method inputs as closed objects", () => { expect(validateProjectsListParams({})).toBe(true); + expect(validateProjectsListParams({ includeObserved: true })).toBe(true); + expect(validateProjectsListParams({ includeObserved: false })).toBe(true); + expect(validateProjectsListParams({ includeObserved: "yes" })).toBe(false); expect(validateProjectsListParams({ extra: true })).toBe(false); expect(validateProjectsRegisterParams({ path: "/repo", name: "OpenClaw" })).toBe(true); expect(validateProjectsRegisterParams({ path: "" })).toBe(false); @@ -79,9 +85,36 @@ describe("project protocol schemas", () => { { kind: "project", projectId: "openclaw", displayName: "OpenClaw" }, { kind: "folder", folder: "/repo/scratch", displayName: "scratch" }, ], + observedProjects: [], }), ).toBe(true); expect(Value.Check(ProjectsListResultSchema, { projects: [] })).toBe(true); + expect(Value.Check(ProjectsListResultSchema, { observedProjects: [] })).toBe(false); + }); + + it("bounds observed projects and their checkout lists", () => { + const project = { + name: "openclaw", + originUrl: "https://github.com/openclaw/openclaw.git", + checkouts: [{ runnerId: "gateway", path: "/repo/openclaw" }], + lastUsedAt: 1, + }; + expect(Value.Check(ProjectSummarySchema, project)).toBe(true); + expect( + Value.Check(ProjectSummarySchema, { + ...project, + checkouts: Array.from( + { length: PROJECTS_LIST_MAX_CHECKOUTS_PER_PROJECT + 1 }, + (_, index) => ({ runnerId: "gateway", path: `/repo/openclaw-${index}` }), + ), + }), + ).toBe(false); + expect( + Value.Check(ProjectsListResultSchema, { + projects: [], + observedProjects: Array.from({ length: PROJECTS_LIST_DEFAULT_LIMIT + 1 }, () => project), + }), + ).toBe(false); }); it("accepts projectId as an additive sessions.create parameter", () => { diff --git a/packages/gateway-protocol/src/schema/projects.ts b/packages/gateway-protocol/src/schema/projects.ts index b9052ad35add..30ab05ee141a 100644 --- a/packages/gateway-protocol/src/schema/projects.ts +++ b/packages/gateway-protocol/src/schema/projects.ts @@ -7,6 +7,10 @@ const StoredProjectIdSchema = Type.String({ pattern: "^[a-z0-9][a-z0-9-]{0,63}$", }); +export const PROJECTS_LIST_DEFAULT_LIMIT = 50; +export const PROJECTS_LIST_MAX_CHECKOUTS_PER_PROJECT = 50; +export const PROJECTS_LIST_MAX_IDENTITY_PROBES = 32; + export const ProjectRecordSchema = closedObject({ id: NonEmptyString, displayName: NonEmptyString, @@ -44,10 +48,50 @@ export const ProjectRecentSchema = Type.Union([ ProjectRecentFolderSchema, ]); -export const ProjectsListParamsSchema = closedObject({}); +/** One gateway-visible checkout for an observed repository project. */ +export const ProjectCheckoutSchema = closedObject({ + runnerId: Type.String({ + minLength: 1, + description: "Runner hosting this operator.write-scoped checkout.", + }), + path: Type.String({ + minLength: 1, + description: "Physical checkout path returned only to operator.write-capable callers.", + }), +}); + +/** Repository identity derived from visible checkout and session state. */ +export const ProjectSummarySchema = closedObject({ + name: NonEmptyString, + originUrl: Type.Optional( + Type.String({ + minLength: 1, + description: "Sanitized repository origin returned to operator.write-capable callers.", + }), + ), + checkouts: Type.Array(ProjectCheckoutSchema, { + minItems: 1, + maxItems: PROJECTS_LIST_MAX_CHECKOUTS_PER_PROJECT, + }), + lastUsedAt: Type.Number({ minimum: 0 }), +}); + +export const ProjectsListParamsSchema = closedObject({ + includeObserved: Type.Optional( + Type.Boolean({ + description: "Compute write-scoped observed checkout groups in addition to projects.", + }), + ), +}); export const ProjectsListResultSchema = closedObject({ projects: Type.Array(ProjectRecordSchema), recents: Type.Optional(Type.Array(ProjectRecentSchema, { maxItems: 8 })), + observedProjects: Type.Optional( + Type.Array(ProjectSummarySchema, { + maxItems: PROJECTS_LIST_DEFAULT_LIMIT, + description: "Observed checkout details returned only to operator.write-capable callers.", + }), + ), }); export const ProjectsRegisterParamsSchema = closedObject({ @@ -86,6 +130,8 @@ export const ProjectsRemoveResultSchema = closedObject({ removed: Type.Boolean() export type ProjectRecord = Static; export type ProjectRecent = Static; +export type ProjectCheckout = Static; +export type ProjectSummary = Static; export type ProjectsListParams = Static; export type ProjectsListResult = Static; export type ProjectsRegisterParams = Static; diff --git a/packages/gateway-protocol/src/schema/protocol-schema-fragment-agent-control.ts b/packages/gateway-protocol/src/schema/protocol-schema-fragment-agent-control.ts index 29e4fe56a3b9..2e61aac04f96 100644 --- a/packages/gateway-protocol/src/schema/protocol-schema-fragment-agent-control.ts +++ b/packages/gateway-protocol/src/schema/protocol-schema-fragment-agent-control.ts @@ -24,6 +24,8 @@ export const AgentControlProtocolSchemas = { WorkerDesktopObserveResult: environments.WorkerDesktopObserveResultSchema, WorkerDesktopLaunchParams: environments.WorkerDesktopLaunchParamsSchema, WorkerDesktopLaunchResult: environments.WorkerDesktopLaunchResultSchema, + ProjectCheckout: projects.ProjectCheckoutSchema, + ProjectSummary: projects.ProjectSummarySchema, SystemInfoParams: systemInfo.SystemInfoParamsSchema, SystemInfoResult: systemInfo.SystemInfoResultSchema, AgentEvent: agent.AgentEventSchema, diff --git a/scripts/protocol-gen-kotlin.ts b/scripts/protocol-gen-kotlin.ts index 31731a81d7a6..4291d46d3439 100644 --- a/scripts/protocol-gen-kotlin.ts +++ b/scripts/protocol-gen-kotlin.ts @@ -60,6 +60,7 @@ const schemaNames = new Map([ ["WorkerDesktopObserveResult", "WorkerDesktopObserveResult"], ["WorkerDesktopLaunchParams", "WorkerDesktopLaunchParams"], ["WorkerDesktopLaunchResult", "WorkerDesktopLaunchResult"], + ["ProjectsListResult", "ProjectsListResult"], ]); const androidEnums: EnumSpec[] = [ diff --git a/src/agents/worktrees/service.test.ts b/src/agents/worktrees/service.test.ts index 0c5dc96515f0..f8d88e252a26 100644 --- a/src/agents/worktrees/service.test.ts +++ b/src/agents/worktrees/service.test.ts @@ -159,6 +159,21 @@ describe("ManagedWorktreeService", () => { expect(repeated).toEqual(created); }); + it("reads registry records without retiring a temporarily unavailable worktree", async () => { + const created = await service.create({ + repoRoot: repo, + name: "read-only-list", + baseRef: "HEAD", + }); + await fs.rm(created.path, { recursive: true, force: true }); + + expect(service.listRegistryRecords()).toEqual([expect.objectContaining({ id: created.id })]); + expect(getRegistryWorktree(env, created.id)?.removedAt).toBeUndefined(); + + expect(await service.list()).toEqual([]); + expect(getRegistryWorktree(env, created.id)?.removedAt).toBe(now); + }); + it("does not remove a worktree owned by another caller", async () => { const created = await service.create({ repoRoot: repo, diff --git a/src/agents/worktrees/service.ts b/src/agents/worktrees/service.ts index c932a4b1cc5f..b7e87068c50c 100644 --- a/src/agents/worktrees/service.ts +++ b/src/agents/worktrees/service.ts @@ -772,6 +772,11 @@ export class ManagedWorktreeService { return records.filter((record) => record.removedAt === undefined || record.snapshotRef); } + /** Returns persisted worktree facts without probing paths or mutating lifecycle state. */ + listRegistryRecords(): ManagedWorktreeRecord[] { + return listRegistryWorktrees(this.env); + } + findLiveByOwner( ownerKind: ManagedWorktreeOwnerKind, ownerId: string, @@ -796,6 +801,22 @@ export class ManagedWorktreeService { }; } + /** Resolves the repository facts shared by managed worktrees and project discovery. */ + async resolveRepositoryIdentity(repoRoot: string): Promise<{ + checkoutRoot: string; + repoRoot: string; + originUrl: string; + fingerprint: string; + }> { + const resolved = await resolveRepository(repoRoot); + return { + checkoutRoot: resolved.sourceRoot, + repoRoot: resolved.repoRoot, + originUrl: resolved.originUrl, + fingerprint: resolved.fingerprint, + }; + } + /** * Lists selectable base refs for a repository without touching the network. * Base-ref pickers must stay snappy; resolveWorktreeBase() still fetches on create diff --git a/src/gateway/server-methods.authorization.test.ts b/src/gateway/server-methods.authorization.test.ts index 4e8efb6f32be..eb20f7c0fa3f 100644 --- a/src/gateway/server-methods.authorization.test.ts +++ b/src/gateway/server-methods.authorization.test.ts @@ -106,6 +106,34 @@ describe("gateway method authorization", () => { }); }); + it("allows read-only projects.list to reach its redacting handler", async () => { + const handler = vi.fn(({ respond }) => respond(true, { projects: [] })); + const respond = vi.fn(); + + await handleGatewayRequest({ + req: { type: "req", id: "req-projects-read", method: "projects.list", params: {} }, + respond, + client: { + connId: "conn-projects-read", + connect: { + role: "operator", + scopes: ["operator.read"], + client: { id: "test", version: "1", platform: "test", mode: "test" }, + minProtocol: 1, + maxProtocol: 1, + }, + } as Parameters[0]["client"], + isWebchatConnect: () => false, + context: { logGateway: { warn: vi.fn() } } as unknown as Parameters< + typeof handleGatewayRequest + >[0]["context"], + extraHandlers: { "projects.list": handler }, + }); + + expect(handler).toHaveBeenCalledOnce(); + expect(respond).toHaveBeenCalledWith(true, { projects: [] }); + }); + it("rejects every node RPC when its connection no longer owns the pairing generation", async () => { const handler = vi.fn(({ respond }) => respond(true, { ok: true })); const respond = vi.fn(); diff --git a/src/gateway/server-methods/projects-observed.test.ts b/src/gateway/server-methods/projects-observed.test.ts new file mode 100644 index 000000000000..06010a342921 --- /dev/null +++ b/src/gateway/server-methods/projects-observed.test.ts @@ -0,0 +1,308 @@ +import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + PROJECTS_LIST_MAX_CHECKOUTS_PER_PROJECT, + PROJECTS_LIST_MAX_IDENTITY_PROBES, +} from "../../../packages/gateway-protocol/src/index.js"; +import type { SessionEntry } from "../../config/sessions.js"; +import { createProjectsHandlers } from "./projects.js"; +import type { GatewayClient, GatewayRequestContext, RespondFn } from "./types.js"; + +type ProjectWorktreeService = Parameters[0]; + +const seededSessions = vi.hoisted(() => ({ + store: {} as Record, +})); + +vi.mock("../session-utils.js", () => ({ + loadCombinedSessionStoreForGatewayCore: () => ({ store: seededSessions.store }), +})); + +vi.mock("../../projects/project-registry.js", () => ({ + listProjectRegistry: () => [], + ProjectCheckoutError: class ProjectCheckoutError extends Error {}, + registerProjectRegistry: vi.fn(), + removeProjectRegistry: vi.fn(), +})); + +function authenticatedClient(user: string, scopes = ["operator.write"]): GatewayClient { + return { + connect: { + minProtocol: 1, + maxProtocol: 1, + client: { + id: "openclaw-control-ui", + version: "test", + platform: "test", + mode: "webchat", + }, + role: "operator", + scopes, + }, + authenticatedUserId: user, + authenticatedUserProfile: { + profileId: user, + displayName: user, + hasAvatar: false, + updatedAt: 1, + }, + }; +} + +function assertObservedProjectsPayload( + payload: unknown, +): asserts payload is { observedProjects: unknown[] } { + if (!isRecord(payload) || !Array.isArray(payload.observedProjects)) { + throw new TypeError("projects.list response is missing observedProjects"); + } +} + +async function listObservedProjects(params: { + service: { + listRegistryRecords: () => unknown[]; + resolveRepositoryIdentity: (checkoutPath: string) => Promise<{ + checkoutRoot: string; + repoRoot: string; + originUrl: string; + fingerprint: string; + }>; + }; + client?: GatewayClient; +}) { + const handlers = createProjectsHandlers(params.service as never); + const responses: Parameters[] = []; + await handlers["projects.list"]?.({ + params: { includeObserved: true }, + respond: (...response: Parameters) => responses.push(response), + context: { + getRuntimeConfig: () => ({ agents: { list: [{ id: "main", default: true }] } }), + } as GatewayRequestContext, + client: params.client ?? authenticatedClient("operator@example.com"), + } as never); + expect(responses).toHaveLength(1); + const response = responses[0]; + if (!response) { + throw new Error("projects.list did not respond"); + } + expect(response[0]).toBe(true); + assertObservedProjectsPayload(response[1]); + return response[1].observedProjects; +} + +beforeEach(() => { + seededSessions.store = {}; +}); + +describe("projects.list observed projects", () => { + it.each([["operator.write"], ["operator.admin"]])( + "returns detailed observed projects to %s callers", + async (scope) => { + seededSessions.store = { + "agent:main:old": { + sessionId: "old", + updatedAt: 100, + execCwd: "/links/alpha-old", + }, + "agent:main:new": { + sessionId: "new", + updatedAt: 300, + execCwd: "/links/alpha-new", + }, + "agent:main:device": { + sessionId: "device", + updatedAt: 400, + execCwd: "/device/alpha", + execNode: "paired-mac", + }, + }; + const resolveRepositoryIdentity = vi.fn(async (checkoutPath: string) => ({ + checkoutRoot: checkoutPath.replace("/links/", "/physical/"), + repoRoot: "/physical/alpha", + originUrl: "https://github.com/openclaw/alpha.git", + fingerprint: "alpha-fingerprint", + })); + + await expect( + listObservedProjects({ + service: { listRegistryRecords: () => [], resolveRepositoryIdentity }, + client: authenticatedClient(`${scope}@example.com`, [scope]), + }), + ).resolves.toEqual([ + { + name: "alpha-new", + originUrl: "https://github.com/openclaw/alpha.git", + checkouts: [ + { runnerId: "gateway", path: "/physical/alpha-new" }, + { runnerId: "gateway", path: "/physical/alpha-old" }, + ], + lastUsedAt: 300, + }, + ]); + expect(resolveRepositoryIdentity).not.toHaveBeenCalledWith("/device/alpha"); + }, + ); + + it("admits managed worktrees only when their owning session is visible", async () => { + seededSessions.store = { + "agent:main:visible": { + sessionId: "visible", + updatedAt: 200, + visibility: "shared", + createdActor: { type: "human", id: "owner@example.com" }, + }, + "agent:main:private": { + sessionId: "private", + updatedAt: 300, + visibility: "draft", + createdActor: { type: "human", id: "owner@example.com" }, + }, + }; + const worktree = (name: string, ownerId: string, lastActiveAt: number) => ({ + id: name, + name, + repoFingerprint: name, + repoRoot: `/repos/${name}`, + path: `/worktrees/${name}`, + branch: `openclaw/${name}`, + baseRef: "main", + ownerKind: "session", + ownerId, + createdAt: 100, + lastActiveAt, + }); + const worktrees = [ + worktree("visible", "agent:main:visible", 500), + worktree("private", "agent:main:private", 490), + worktree("orphan", "agent:main:missing", 480), + { + ...worktree("manual", "ignored", 470), + ownerKind: "manual", + ownerId: undefined, + }, + ]; + const resolveRepositoryIdentity = vi.fn(async (checkoutPath: string) => ({ + checkoutRoot: checkoutPath, + repoRoot: checkoutPath, + originUrl: `https://example.test${checkoutPath}.git`, + fingerprint: checkoutPath, + })); + const service = { listRegistryRecords: () => worktrees, resolveRepositoryIdentity }; + + const viewer = (await listObservedProjects({ + service, + client: authenticatedClient("viewer@example.com"), + })) as Array<{ name: string }>; + expect(viewer.map((project) => project.name)).toEqual(["visible"]); + + const admin = (await listObservedProjects({ + service, + client: authenticatedClient("admin@example.com", ["operator.admin"]), + })) as Array<{ name: string }>; + expect(admin.map((project) => project.name)).toEqual([ + "visible", + "private", + "orphan", + "manual", + ]); + }); + + it("redacts URL and SCP-style userinfo and omits unknown remote forms", async () => { + seededSessions.store = Object.fromEntries( + ["url", "token-scp", "git-scp", "unknown"].map((name, index) => [ + `agent:main:${name}`, + { sessionId: name, updatedAt: 400 - index, execCwd: `/repos/${name}` }, + ]), + ); + const origins: Record = { + "/repos/url": ["https://user", ":placeholder", "@host/repo.git?visible=value#branch"].join( + "", + ), + "/repos/token-scp": ["placeholder", "@host:org/private.git"].join(""), + "/repos/git-scp": "git@host:org/public.git", + "/repos/unknown": "opaque credential-shaped remote", + }; + + const projects = (await listObservedProjects({ + service: { + listRegistryRecords: () => [], + resolveRepositoryIdentity: async (checkoutPath: string) => ({ + checkoutRoot: checkoutPath, + repoRoot: checkoutPath, + originUrl: origins[checkoutPath] ?? "", + fingerprint: checkoutPath, + }), + }, + })) as Array<{ name: string; originUrl?: string }>; + + expect(projects.map(({ name, originUrl }) => ({ name, originUrl }))).toEqual([ + { name: "url", originUrl: "https://host/repo.git" }, + { name: "token-scp", originUrl: "host:org/private.git" }, + { name: "git-scp", originUrl: "host:org/public.git" }, + { name: "unknown", originUrl: undefined }, + ]); + }); + + it("caps checkout arrays in deterministic newest-first order", async () => { + const worktrees = Array.from( + { length: PROJECTS_LIST_MAX_CHECKOUTS_PER_PROJECT + 3 }, + (_, index) => ({ + id: `worktree-${index}`, + name: `worktree-${index}`, + repoFingerprint: "alpha-fingerprint", + repoRoot: "/repos/alpha", + path: `/worktrees/${String(index).padStart(2, "0")}`, + branch: `openclaw/worktree-${index}`, + baseRef: "main", + ownerKind: "manual", + createdAt: 1, + lastActiveAt: index, + }), + ); + + const projects = (await listObservedProjects({ + service: { + listRegistryRecords: () => worktrees, + resolveRepositoryIdentity: async (checkoutPath: string) => ({ + checkoutRoot: checkoutPath, + repoRoot: checkoutPath, + originUrl: "https://github.com/openclaw/alpha.git", + fingerprint: "alpha-fingerprint", + }), + }, + client: authenticatedClient("admin@example.com", ["operator.admin"]), + })) as Array<{ checkouts: Array<{ path: string }> }>; + + expect(projects[0]?.checkouts).toHaveLength(PROJECTS_LIST_MAX_CHECKOUTS_PER_PROJECT); + expect(projects[0]?.checkouts[0]?.path).toBe("/worktrees/52"); + expect(projects[0]?.checkouts.at(-1)?.path).toBe("/worktrees/03"); + }); + + it("retains only the newest bounded candidates before identity resolution", async () => { + const rawCandidateLimit = Math.max( + PROJECTS_LIST_MAX_CHECKOUTS_PER_PROJECT, + PROJECTS_LIST_MAX_IDENTITY_PROBES, + 50, + ); + seededSessions.store = Object.fromEntries( + Array.from({ length: rawCandidateLimit + 5 }, (_, index) => [ + `agent:main:session-${index}`, + { sessionId: `session-${index}`, updatedAt: index, execCwd: `/repos/${index}` }, + ]), + ); + const resolveRepositoryIdentity = vi.fn( + async (_checkoutPath) => { + throw new Error("checkout unavailable"); + }, + ); + + await expect( + listObservedProjects({ + service: { listRegistryRecords: () => [], resolveRepositoryIdentity }, + }), + ).resolves.toEqual([]); + expect(resolveRepositoryIdentity).toHaveBeenCalledTimes(PROJECTS_LIST_MAX_IDENTITY_PROBES); + expect(resolveRepositoryIdentity.mock.calls.length).toBeLessThanOrEqual(rawCandidateLimit); + expect(resolveRepositoryIdentity.mock.calls[0]?.[0]).toBe(`/repos/${rawCandidateLimit + 4}`); + expect(resolveRepositoryIdentity).not.toHaveBeenCalledWith("/repos/0"); + }); +}); diff --git a/src/gateway/server-methods/projects.test.ts b/src/gateway/server-methods/projects.test.ts index acf394265d94..36106274b445 100644 --- a/src/gateway/server-methods/projects.test.ts +++ b/src/gateway/server-methods/projects.test.ts @@ -2,7 +2,7 @@ import { execFile } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; -import { expect, test } from "vitest"; +import { beforeEach, expect, test, vi } from "vitest"; import { insertRegistryWorktree } from "../../agents/worktrees/registry.js"; import { replaceSessionEntrySync } from "../../config/sessions/session-accessor.js"; import { upsertSessionEntryCore } from "../../config/sessions/session-accessor.js"; @@ -14,9 +14,25 @@ import { } from "../../projects/project-registry.js"; import { ensureProfileForEmail, linkEmail } from "../../state/user-profiles.js"; import { createOpenClawTestState } from "../../test-utils/openclaw-test-state.js"; -import { projectsHandlers } from "./projects.js"; +import { createProjectsHandlers } from "./projects.js"; const execFileAsync = promisify(execFile); +const listRegistryRecords = vi.fn(() => []); +const resolveRepositoryIdentity = vi.fn(async (checkoutPath: string) => ({ + checkoutRoot: checkoutPath, + repoRoot: checkoutPath, + originUrl: "", + fingerprint: checkoutPath, +})); +const projectsHandlers = createProjectsHandlers({ + listRegistryRecords, + resolveRepositoryIdentity, +} as never); + +beforeEach(() => { + listRegistryRecords.mockClear(); + resolveRepositoryIdentity.mockClear(); +}); async function initializeRepository( root: string, @@ -117,8 +133,18 @@ test("projects.list exposes checkout details only at write scope", async () => { expect(project).not.toHaveProperty("repoRoot"); expect(project).not.toHaveProperty("originUrl"); } + expect(readResult.payload).not.toHaveProperty("observedProjects"); + expect(listRegistryRecords).not.toHaveBeenCalled(); + expect(resolveRepositoryIdentity).not.toHaveBeenCalled(); + + const readOptIn = await invokeProjectMethod("projects.list", { includeObserved: true }, cfg, [ + "operator.read", + ]); + expect(readOptIn?.payload).not.toHaveProperty("observedProjects"); + expect(listRegistryRecords).not.toHaveBeenCalled(); for (const scope of ["operator.write", "operator.admin"]) { + const callsBeforeDefaultList = listRegistryRecords.mock.calls.length; const writeResult = await invokeProjectMethod("projects.list", {}, cfg, [scope]); expect(writeResult).toMatchObject({ ok: true, @@ -133,7 +159,60 @@ test("projects.list exposes checkout details only at write scope", async () => { ], }, }); + expect(writeResult?.payload).not.toHaveProperty("observedProjects"); + expect(listRegistryRecords).toHaveBeenCalledTimes(callsBeforeDefaultList); + + const observedResult = await invokeProjectMethod( + "projects.list", + { includeObserved: true }, + cfg, + [scope], + ); + expect(observedResult).toMatchObject({ + ok: true, + payload: { observedProjects: [] }, + }); } + expect(listRegistryRecords).toHaveBeenCalledTimes(2); + } finally { + await state.cleanup(); + } +}); + +test("project responses redact credentials and URL suffixes from registered origins", async () => { + const state = await createOpenClawTestState({ layout: "state-only", prefix: "projects-rpc-" }); + try { + const repo = await initializeRepository(state.root); + await execFileAsync("git", [ + "-C", + repo, + "remote", + "set-url", + "origin", + ["https://user", ":placeholder", "@host/private.git?visible=value#branch"].join(""), + ]); + + const registered = await invokeProjectMethod( + "projects.register", + { path: repo, name: "Private" }, + {}, + ["operator.admin"], + ); + expect(registered).toMatchObject({ + ok: true, + payload: { originUrl: "https://host/private.git" }, + }); + + const listed = await invokeProjectMethod("projects.list", {}, {}, ["operator.write"]); + expect(listed).toMatchObject({ + ok: true, + payload: { + projects: expect.arrayContaining([ + expect.objectContaining({ id: "workspace:main" }), + expect.objectContaining({ id: "private", originUrl: "https://host/private.git" }), + ]), + }, + }); } finally { await state.cleanup(); } diff --git a/src/gateway/server-methods/projects.ts b/src/gateway/server-methods/projects.ts index 7e3b51961143..a4a1c4027c9d 100644 --- a/src/gateway/server-methods/projects.ts +++ b/src/gateway/server-methods/projects.ts @@ -4,14 +4,20 @@ import { ErrorCodes, GatewayErrorDetailCodes, errorShape, + PROJECTS_LIST_DEFAULT_LIMIT, + PROJECTS_LIST_MAX_CHECKOUTS_PER_PROJECT, + PROJECTS_LIST_MAX_IDENTITY_PROBES, + type ProjectRecord, type ProjectRecent, validateProjectsAddParams, + type ProjectSummary, validateProjectsListParams, validateProjectsRegisterParams, validateProjectsRemoveParams, validateProjectsSearchRemoteParams, } from "../../../packages/gateway-protocol/src/index.js"; import { listRegistryWorktrees } from "../../agents/worktrees/registry.js"; +import { managedWorktrees, type ManagedWorktreeService } from "../../agents/worktrees/service.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { isPathInside } from "../../infra/path-guards.js"; import { ProjectCloneError } from "../../projects/project-clone-runtime.js"; @@ -31,17 +37,118 @@ import { listProfiles, resolveUserProfileId } from "../../state/user-profiles.js import { githubApiToken } from "../control-ui-github-api.js"; import { WRITE_SCOPE, authorizeOperatorScopesForRequiredScope } from "../method-scopes.js"; import { searchRemoteProjects } from "../project-github-search.js"; +import { createSessionListEntryFilter } from "../session-sharing.js"; import { loadCombinedSessionStoreForGatewayCore } from "../session-utils.js"; import type { GatewayRequestHandlers } from "./types.js"; import { assertValidParams } from "./validation.js"; type ProjectRegistryEntry = ReturnType[number]; +type ProjectWorktreeService = Pick< + ManagedWorktreeService, + "listRegistryRecords" | "resolveRepositoryIdentity" +>; + +type ProjectCandidate = { + checkoutPath: string; + fingerprint: string; + lastUsedAt: number; + originUrl?: string; +}; + +type RawProjectCandidate = + | { kind: "session"; checkoutPath: string; lastUsedAt: number } + | { + kind: "worktree"; + checkoutPath: string; + fingerprint: string; + lastUsedAt: number; + repoRoot: string; + }; + +type ProjectGroup = { + checkouts: Map; + lastUsedAt: number; + name: string; + nameUsedAt: number; + originUrl?: string; +}; + +// This buffer must cover the largest possible response/checkouts while remaining independent of +// session history. Identity resolution has its own lower subprocess ceiling within this bound. +const PROJECTS_LIST_MAX_RAW_CANDIDATES = Math.max( + PROJECTS_LIST_DEFAULT_LIMIT, + PROJECTS_LIST_MAX_CHECKOUTS_PER_PROJECT, + PROJECTS_LIST_MAX_IDENTITY_PROBES, +); function folderDisplayName(folder: string): string { const trimmed = folder.replace(/[\\/]+$/u, ""); return path.posix.basename(trimmed) || path.win32.basename(trimmed) || folder; } +function checkoutName(checkoutPath: string): string { + const trimmed = checkoutPath.replace(/[\\/]+$/u, ""); + return trimmed.split(/[\\/]/u).at(-1) || trimmed; +} + +function compareRawProjectCandidates(left: RawProjectCandidate, right: RawProjectCandidate) { + return ( + right.lastUsedAt - left.lastUsedAt || + left.checkoutPath.localeCompare(right.checkoutPath) || + left.kind.localeCompare(right.kind) + ); +} + +function retainNewestRawProjectCandidate( + candidates: RawProjectCandidate[], + candidate: RawProjectCandidate, +) { + const insertionIndex = candidates.findIndex( + (existing) => compareRawProjectCandidates(candidate, existing) < 0, + ); + if (insertionIndex < 0) { + if (candidates.length < PROJECTS_LIST_MAX_RAW_CANDIDATES) { + candidates.push(candidate); + } + return; + } + candidates.splice(insertionIndex, 0, candidate); + if (candidates.length > PROJECTS_LIST_MAX_RAW_CANDIDATES) { + candidates.pop(); + } +} + +function sanitizePublicOriginUrl(originUrl: string): string | undefined { + const trimmed = originUrl.trim(); + const suffixIndex = trimmed.search(/[?#]/u); + const withoutSuffix = suffixIndex < 0 ? trimmed : trimmed.slice(0, suffixIndex); + const scp = /^[^@\s/:]+@(\[[^\]]+\]|[^:\s]+):(.+)$/u.exec(withoutSuffix); + if (scp) { + return `${scp[1]}:${scp[2]}`; + } + let parsed: URL; + try { + parsed = new URL(withoutSuffix); + } catch { + return undefined; + } + if (!parsed.username && !parsed.password) { + return withoutSuffix; + } + parsed.username = ""; + parsed.password = ""; + return parsed.toString(); +} + +function sanitizeProjectRecord(project: ProjectRecord): ProjectRecord { + const { originUrl, ...record } = project; + const sanitizedOriginUrl = originUrl ? sanitizePublicOriginUrl(originUrl) : undefined; + return { + ...record, + ...(sanitizedOriginUrl ? { originUrl: sanitizedOriginUrl } : {}), + }; +} + function resolvePathProject( projects: readonly ProjectRegistryEntry[], folder: string, @@ -117,212 +224,395 @@ function listProjectRecents( return recents; } -export const projectsHandlers: GatewayRequestHandlers = { - "projects.list": ({ params, respond, context, client }) => { - if (!assertValidParams(params, validateProjectsListParams, "projects.list", respond)) { - return; - } - const projects = listProjectRegistry(context.getRuntimeConfig()); - const profileId = client?.authenticatedUserProfile?.profileId; - const canonicalProfileId = profileId - ? (resolveUserProfileId(profileId) ?? profileId) - : undefined; - const recentProfileIds = canonicalProfileId - ? new Set([ - canonicalProfileId, - ...listProfiles() - .filter((profile) => profile.mergedInto === canonicalProfileId) - .map((profile) => profile.id), - ]) - : undefined; - const recents = recentProfileIds - ? listProjectRecents(context.getRuntimeConfig(), recentProfileIds, projects) - : undefined; - const scopes = Array.isArray(client?.connect.scopes) ? client.connect.scopes : []; - if (authorizeOperatorScopesForRequiredScope(WRITE_SCOPE, scopes).allowed) { - respond(true, { projects, ...(recents ? { recents } : {}) }, undefined); - return; - } - // Project identity is read-safe; host paths and origins are placement - // details reserved for clients that can create sessions. - respond( - true, - { - projects: projects.map((project) => - project.agentId - ? { - id: project.id, - displayName: project.displayName, - source: project.source, - agentId: project.agentId, - } - : { - id: project.id, - displayName: project.displayName, - source: project.source, - }, - ), - ...(recents ? { recents: recents.filter((recent) => recent.kind === "project") } : {}), - }, - undefined, - ); - }, - "projects.register": async ({ params, respond }) => { - if (!assertValidParams(params, validateProjectsRegisterParams, "projects.register", respond)) { - return; - } - try { - respond( - true, - await registerProjectRegistry({ path: params.path, name: params.name }), - undefined, - ); - } catch (error) { - respond( - false, - undefined, - errorShape( - error instanceof ProjectCheckoutError - ? ErrorCodes.INVALID_REQUEST - : ErrorCodes.UNAVAILABLE, - formatErrorMessage(error), - ), - ); - } - }, - "projects.add": async ({ params, respond, context, signal }) => { - if (!assertValidParams(params, validateProjectsAddParams, "projects.add", respond)) { - return; - } - try { - respond( - true, - await materializeProjectClone( - { cfg: context.getRuntimeConfig(), gitUrl: params.gitUrl, name: params.name }, - { signal, token: githubApiToken() }, - ), - undefined, - ); - } catch (error) { - if (error instanceof ProjectCloneError) { - respond( - false, - undefined, - errorShape( - error.failure === "invalid_url" ? ErrorCodes.INVALID_REQUEST : ErrorCodes.UNAVAILABLE, - error.message, - { - details: { - code: GatewayErrorDetailCodes.PROJECT_CLONE_FAILED, - cause: error.failure, - }, - retryable: error.failure === "network" || error.failure === "clone_failed", - }, - ), - ); - return; - } - respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error))); - } - }, - "projects.searchRemote": async ({ params, respond }) => { - if ( - !assertValidParams( - params, - validateProjectsSearchRemoteParams, - "projects.searchRemote", - respond, - ) - ) { - return; - } - try { - respond(true, await searchRemoteProjects(params.query), undefined); - } catch { - respond( - false, - undefined, - errorShape(ErrorCodes.UNAVAILABLE, "GitHub project search is unavailable. Retry shortly.", { - retryable: true, - }), - ); - } - }, - "projects.remove": async ({ params, respond, context }) => { - if (!assertValidParams(params, validateProjectsRemoveParams, "projects.remove", respond)) { - return; - } - const project = resolveProjectRegistry(context.getRuntimeConfig(), params.id); - if (!project || project.source === "workspace") { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, `unknown project id: ${params.id}`), - ); - return; - } - if (params.deleteCheckout) { - if (project.source !== "cloned") { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - "Only projects cloned by the Gateway can delete their checkout.", - ), - ); - return; - } - const normalizedRoot = path.resolve(project.repoRoot); - const worktreeReference = listRegistryWorktrees(process.env).find( - (worktree) => !worktree.removedAt && path.resolve(worktree.repoRoot) === normalizedRoot, - ); - const sessionReference = Object.entries( - loadCombinedSessionStoreForGatewayCore(context.getRuntimeConfig(), { projection: "list" }) - .store, - ).find(([, entry]) => { - if (entry.archivedAt) { - return false; - } - const sessionRoot = entry.worktree?.repoRoot; - if (sessionRoot && path.resolve(sessionRoot) === normalizedRoot) { - return true; - } - const cwd = entry.spawnedCwd; - return Boolean( - cwd && - (path.resolve(cwd) === normalizedRoot || isPathInside(normalizedRoot, path.resolve(cwd))), - ); +function projectCandidatesToSummaries(candidates: readonly ProjectCandidate[]): ProjectSummary[] { + const groups = new Map(); + for (const candidate of candidates) { + const group: ProjectGroup = groups.get(candidate.fingerprint) ?? { + checkouts: new Map(), + lastUsedAt: candidate.lastUsedAt, + name: checkoutName(candidate.checkoutPath), + nameUsedAt: candidate.lastUsedAt, + }; + const checkout = group.checkouts.get(candidate.checkoutPath); + if (!checkout || candidate.lastUsedAt > checkout.lastUsedAt) { + group.checkouts.set(candidate.checkoutPath, { + path: candidate.checkoutPath, + lastUsedAt: candidate.lastUsedAt, }); - if (worktreeReference || sessionReference) { - const reference = worktreeReference - ? `managed worktree ${worktreeReference.name}` - : `session ${sessionReference?.[0]}`; - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `Project checkout is still referenced by ${reference}. Remove that reference before deleting the checkout.`, + } + group.lastUsedAt = Math.max(group.lastUsedAt, candidate.lastUsedAt); + if (candidate.lastUsedAt > group.nameUsedAt) { + group.name = checkoutName(candidate.checkoutPath); + group.nameUsedAt = candidate.lastUsedAt; + } + if (!group.originUrl && candidate.originUrl) { + group.originUrl = candidate.originUrl; + } + groups.set(candidate.fingerprint, group); + } + return [...groups.values()] + .toSorted( + (left, right) => right.lastUsedAt - left.lastUsedAt || left.name.localeCompare(right.name), + ) + .slice(0, PROJECTS_LIST_DEFAULT_LIMIT) + .map((group) => { + const summary: ProjectSummary = { + name: group.name, + checkouts: [...group.checkouts.values()] + .toSorted( + (left, right) => + right.lastUsedAt - left.lastUsedAt || left.path.localeCompare(right.path), + ) + .slice(0, PROJECTS_LIST_MAX_CHECKOUTS_PER_PROJECT) + .map((checkout) => ({ runnerId: "gateway", path: checkout.path })), + lastUsedAt: group.lastUsedAt, + }; + if (group.originUrl) { + const originUrl = sanitizePublicOriginUrl(group.originUrl); + if (originUrl) { + summary.originUrl = originUrl; + } + } + return summary; + }); +} + +async function listObservedProjects( + service: ProjectWorktreeService, + context: Parameters[0]["context"], + client: Parameters[0]["client"], +): Promise { + const { store } = loadCombinedSessionStoreForGatewayCore(context.getRuntimeConfig(), { + projection: "list", + }); + const rawCandidates: RawProjectCandidate[] = []; + const visibilityFilter = createSessionListEntryFilter({ client }); + const canSeeAll = !visibilityFilter; + for (const [sessionKey, entry] of Object.entries(store)) { + if (visibilityFilter && !visibilityFilter(sessionKey, entry)) { + continue; + } + const checkoutPath = entry.execCwd?.trim(); + if (checkoutPath && !entry.execNode?.trim()) { + retainNewestRawProjectCandidate(rawCandidates, { + kind: "session", + checkoutPath, + lastUsedAt: entry.updatedAt, + }); + } + } + for (const worktree of service.listRegistryRecords()) { + if (worktree.removedAt !== undefined) { + continue; + } + if (!canSeeAll) { + // Session-owned worktrees use their canonical session key as ownerId, so the same + // visibility policy that admitted the session also owns its managed checkout. + const ownerId = worktree.ownerKind === "session" ? worktree.ownerId?.trim() : undefined; + const ownerEntry = ownerId ? store[ownerId] : undefined; + if (!ownerId || !ownerEntry || !visibilityFilter?.(ownerId, ownerEntry)) { + continue; + } + } + retainNewestRawProjectCandidate(rawCandidates, { + kind: "worktree", + checkoutPath: worktree.path, + fingerprint: worktree.repoFingerprint, + lastUsedAt: worktree.lastActiveAt, + repoRoot: worktree.repoRoot, + }); + } + + const candidates: ProjectCandidate[] = []; + type RepositoryIdentity = Awaited< + ReturnType + >; + const identities = new Map>(); + let identityProbeCount = 0; + const resolveIdentity = (checkoutPath: string) => { + const existing = identities.get(checkoutPath); + if (existing) { + return existing; + } + if (identityProbeCount >= PROJECTS_LIST_MAX_IDENTITY_PROBES) { + return undefined; + } + identityProbeCount += 1; + const identity = Promise.resolve().then(() => service.resolveRepositoryIdentity(checkoutPath)); + identities.set(checkoutPath, identity); + return identity; + }; + + // The buffer is already newest-first, so probes always go to the retained top-K candidates. + for (const raw of rawCandidates) { + if (raw.kind === "worktree") { + let originUrl: string | undefined; + const pendingIdentity = resolveIdentity(raw.repoRoot); + try { + const identity = pendingIdentity ? await pendingIdentity : undefined; + originUrl = identity?.originUrl || undefined; + } catch { + // The registry fingerprint and checkout path remain authoritative if the source checkout + // disappears after the managed worktree record was written. + } + candidates.push({ + checkoutPath: raw.checkoutPath, + fingerprint: raw.fingerprint, + lastUsedAt: raw.lastUsedAt, + ...(originUrl ? { originUrl } : {}), + }); + continue; + } + const pendingIdentity = resolveIdentity(raw.checkoutPath); + if (!pendingIdentity) { + continue; + } + try { + const identity = await pendingIdentity; + candidates.push({ + checkoutPath: identity.checkoutRoot, + fingerprint: identity.fingerprint, + lastUsedAt: raw.lastUsedAt, + ...(identity.originUrl ? { originUrl: identity.originUrl } : {}), + }); + } catch { + // Plain folders remain available through the existing folder picker. + } + } + + // M5: merge operator-enabled device checkout advertisements at this seam. + return projectCandidatesToSummaries(candidates); +} + +export function createProjectsHandlers(service: ProjectWorktreeService): GatewayRequestHandlers { + return { + "projects.list": async ({ params, respond, context, client }) => { + if (!assertValidParams(params, validateProjectsListParams, "projects.list", respond)) { + return; + } + const registryProjects = listProjectRegistry(context.getRuntimeConfig()); + const projects = registryProjects.map(sanitizeProjectRecord); + const profileId = client?.authenticatedUserProfile?.profileId; + const canonicalProfileId = profileId + ? (resolveUserProfileId(profileId) ?? profileId) + : undefined; + const recentProfileIds = canonicalProfileId + ? new Set([ + canonicalProfileId, + ...listProfiles() + .filter((profile) => profile.mergedInto === canonicalProfileId) + .map((profile) => profile.id), + ]) + : undefined; + const recents = recentProfileIds + ? listProjectRecents(context.getRuntimeConfig(), recentProfileIds, registryProjects) + : undefined; + const scopes = Array.isArray(client?.connect.scopes) ? client.connect.scopes : []; + const canWrite = authorizeOperatorScopesForRequiredScope(WRITE_SCOPE, scopes).allowed; + if (params.includeObserved && canWrite) { + try { + const observedProjects = await listObservedProjects(service, context, client); + respond(true, { projects, ...(recents ? { recents } : {}), observedProjects }, undefined); + } catch (error) { + respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error))); + } + return; + } + if (canWrite) { + respond(true, { projects, ...(recents ? { recents } : {}) }, undefined); + return; + } + // Project identity is read-safe; host paths, origins, folders, and observed checkouts are + // placement details reserved for clients that can create sessions. + respond( + true, + { + projects: projects.map((project) => + project.agentId + ? { + id: project.id, + displayName: project.displayName, + source: project.source, + agentId: project.agentId, + } + : { + id: project.id, + displayName: project.displayName, + source: project.source, + }, ), - ); + ...(recents ? { recents: recents.filter((recent) => recent.kind === "project") } : {}), + }, + undefined, + ); + }, + "projects.register": async ({ params, respond }) => { + if ( + !assertValidParams(params, validateProjectsRegisterParams, "projects.register", respond) + ) { return; } try { - await deleteClonedProjectCheckout(project); + respond( + true, + sanitizeProjectRecord( + await registerProjectRegistry({ path: params.path, name: params.name }), + ), + undefined, + ); } catch (error) { - respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error))); + respond( + false, + undefined, + errorShape( + error instanceof ProjectCheckoutError + ? ErrorCodes.INVALID_REQUEST + : ErrorCodes.UNAVAILABLE, + formatErrorMessage(error), + ), + ); + } + }, + "projects.add": async ({ params, respond, context, signal }) => { + if (!assertValidParams(params, validateProjectsAddParams, "projects.add", respond)) { return; } - } - if (!removeProjectRegistry(params.id)) { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, `unknown project id: ${params.id}`), - ); - return; - } - respond(true, { removed: true }, undefined); - }, -}; + try { + respond( + true, + await materializeProjectClone( + { cfg: context.getRuntimeConfig(), gitUrl: params.gitUrl, name: params.name }, + { signal, token: githubApiToken() }, + ), + undefined, + ); + } catch (error) { + if (error instanceof ProjectCloneError) { + respond( + false, + undefined, + errorShape( + error.failure === "invalid_url" ? ErrorCodes.INVALID_REQUEST : ErrorCodes.UNAVAILABLE, + error.message, + { + details: { + code: GatewayErrorDetailCodes.PROJECT_CLONE_FAILED, + cause: error.failure, + }, + retryable: error.failure === "network" || error.failure === "clone_failed", + }, + ), + ); + return; + } + respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error))); + } + }, + "projects.searchRemote": async ({ params, respond }) => { + if ( + !assertValidParams( + params, + validateProjectsSearchRemoteParams, + "projects.searchRemote", + respond, + ) + ) { + return; + } + try { + respond(true, await searchRemoteProjects(params.query), undefined); + } catch { + respond( + false, + undefined, + errorShape( + ErrorCodes.UNAVAILABLE, + "GitHub project search is unavailable. Retry shortly.", + { retryable: true }, + ), + ); + } + }, + "projects.remove": async ({ params, respond, context }) => { + if (!assertValidParams(params, validateProjectsRemoveParams, "projects.remove", respond)) { + return; + } + const project = resolveProjectRegistry(context.getRuntimeConfig(), params.id); + if (!project || project.source === "workspace") { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `unknown project id: ${params.id}`), + ); + return; + } + if (params.deleteCheckout) { + if (project.source !== "cloned") { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "Only projects cloned by the Gateway can delete their checkout.", + ), + ); + return; + } + const normalizedRoot = path.resolve(project.repoRoot); + const worktreeReference = listRegistryWorktrees(process.env).find( + (worktree) => !worktree.removedAt && path.resolve(worktree.repoRoot) === normalizedRoot, + ); + const sessionReference = Object.entries( + loadCombinedSessionStoreForGatewayCore(context.getRuntimeConfig(), { + projection: "list", + }).store, + ).find(([, entry]) => { + if (entry.archivedAt) { + return false; + } + const sessionRoot = entry.worktree?.repoRoot; + if (sessionRoot && path.resolve(sessionRoot) === normalizedRoot) { + return true; + } + const cwd = entry.spawnedCwd; + return Boolean( + cwd && + (path.resolve(cwd) === normalizedRoot || + isPathInside(normalizedRoot, path.resolve(cwd))), + ); + }); + if (worktreeReference || sessionReference) { + const reference = worktreeReference + ? `managed worktree ${worktreeReference.name}` + : `session ${sessionReference?.[0]}`; + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `Project checkout is still referenced by ${reference}. Remove that reference before deleting the checkout.`, + ), + ); + return; + } + try { + await deleteClonedProjectCheckout(project); + } catch (error) { + respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error))); + return; + } + } + if (!removeProjectRegistry(params.id)) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `unknown project id: ${params.id}`), + ); + return; + } + respond(true, { removed: true }, undefined); + }, + }; +} + +export const projectsHandlers = createProjectsHandlers(managedWorktrees); diff --git a/ui/src/e2e/new-session-page.operator-scopes.e2e.test.ts b/ui/src/e2e/new-session-page.operator-scopes.e2e.test.ts index 19ad02291a58..f78406a9c9e6 100644 --- a/ui/src/e2e/new-session-page.operator-scopes.e2e.test.ts +++ b/ui/src/e2e/new-session-page.operator-scopes.e2e.test.ts @@ -10,7 +10,13 @@ const suite = createNewSessionPageE2eSuite(); async function openDraft( operatorScopes: string[], - featureMethods = ["chat.metadata", "chat.startup", "sessions.create", "sessions.dispatch"], + featureMethods = [ + "chat.metadata", + "chat.startup", + "projects.list", + "sessions.create", + "sessions.dispatch", + ], ) { const context = await suite.browser.newContext({ locale: "en-US", @@ -22,6 +28,7 @@ async function openDraft( featureMethods, operatorScopes, methodResponses: { + "projects.list": { projects: [] }, "sessions.create": { key: "agent:main:operator-scope-proof", runStarted: true }, }, }); @@ -32,7 +39,10 @@ async function openDraft( suite.define(() => { it("keeps read-scoped operators out of new-session entry and submission paths", async () => { - const { context, gateway, page } = await openDraft(["operator.read"]); + const { context, gateway, page } = await openDraft( + ["operator.read"], + ["chat.metadata", "chat.startup", "projects.list", "sessions.create", "sessions.dispatch"], + ); try { const sidebarCreate = page.locator(".sidebar-brand__new-thread"); const submit = page.getByRole("button", { name: "Start session" }); @@ -45,6 +55,9 @@ suite.define(() => { "This action requires operator.admin access.", ); await submit.click({ force: true }); + const projectRequests = await gateway.getRequests("projects.list"); + expect(projectRequests).toHaveLength(1); + expect(projectRequests[0]?.params).toEqual({}); expect(await gateway.getRequests("sessions.create")).toHaveLength(0); } finally { await context.close(); @@ -54,6 +67,9 @@ suite.define(() => { it("allows write-scoped normal creation while keeping incognito admin-only", async () => { const { context, gateway, page } = await openDraft(["operator.read", "operator.write"]); try { + await expect(gateway.waitForRequest("projects.list")).resolves.toMatchObject({ + params: {}, + }); const submit = page.getByRole("button", { name: "Start session" }); const incognito = page.getByRole("switch", { name: "Incognito" }); diff --git a/ui/src/e2e/new-session-page.projects-places.e2e.test.ts b/ui/src/e2e/new-session-page.projects-places.e2e.test.ts new file mode 100644 index 000000000000..f98b5d2c5759 --- /dev/null +++ b/ui/src/e2e/new-session-page.projects-places.e2e.test.ts @@ -0,0 +1,816 @@ +import { expect, it } from "vitest"; +import { + EXEC_ONLY_PICKED, + NODE_HOME, + NODE_PICKED, + NODE_UNC, + SESSION_LIST_DEFAULTS, + WORKSPACE, + captureProjectUiProof, + captureUiProof, + createNewSessionPageE2eSuite, + createdSessionListResult, + installMockGateway, + pollLocatorText, + prepareProjectUiProof, + replaceGatewayClient, +} from "./new-session-page.test-support.ts"; + +const suite = createNewSessionPageE2eSuite(); +const gatewayEnvironment = { + id: "gateway", + type: "local", + status: "available", +}; +const deviceEnvironment = (nodeId: string) => ({ + id: `node:${nodeId}`, + type: "node", + status: "available", +}); + +suite.define(() => { + it("registers a Git checkout from Browse and selects the refreshed project", async () => { + await prepareProjectUiProof(); + const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const repoRoot = "/recorded/openclaw"; + const registeredProject = { + id: "recorded-openclaw", + displayName: "openclaw", + repoRoot, + originUrl: "https://github.com/openclaw/openclaw.git", + source: "registered", + }; + const gateway = await installMockGateway(page, { + workspace: WORKSPACE, + workspaceGit: true, + featureMethods: [ + "chat.metadata", + "chat.startup", + "fs.listDir", + "projects.list", + "projects.register", + "sessions.create", + "worktrees.branches", + ], + methodResponses: { + "projects.list": { + sequence: [{ projects: [] }, { projects: [registeredProject] }], + }, + "projects.register": registeredProject, + "fs.listDir": { + cases: [ + { + match: { path: WORKSPACE }, + response: { path: WORKSPACE, home: "/home/peter", entries: [] }, + }, + { + match: { path: repoRoot }, + response: { path: repoRoot, parent: "/recorded", home: "/home/peter", entries: [] }, + }, + ], + }, + "worktrees.branches": { + branches: [{ kind: "local", name: "main" }], + defaultBranch: "main", + repositoryStatus: "git", + }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + await gateway.waitForRequest("projects.list"); + const trigger = page.locator("#new-session-place-trigger"); + const place = page.locator("wa-popover.new-session-page__place-popover"); + await trigger.click(); + await place.getByRole("button", { name: "Browse folders" }).click(); + const pathInput = page.locator("input.new-session-page__browser-path"); + await pathInput.fill(repoRoot); + await pathInput.press("Enter"); + const register = place.getByRole("button", { name: "Register as project" }); + await register.waitFor(); + await captureProjectUiProof(page, "project-register-action.png"); + await register.click(); + + const request = await gateway.waitForRequest("projects.register"); + expect(request.params).toEqual({ path: repoRoot }); + await expect.poll(async () => (await gateway.getRequests("projects.list")).length).toBe(2); + await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe("openclaw"); + expect(await trigger.getAttribute("data-project-id")).toBe("recorded-openclaw"); + } finally { + await context.close(); + } + }); + + it("handles a legacy projects-only response for write-scoped operators", async () => { + const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + workspace: WORKSPACE, + workspaceGit: true, + operatorScopes: ["operator.read", "operator.write"], + featureMethods: ["chat.metadata", "chat.startup", "projects.list", "sessions.create"], + methodResponses: { "projects.list": { projects: [] } }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + await gateway.waitForRequest("projects.list"); + const place = page.locator("wa-popover.new-session-page__place-popover"); + await page.locator("#new-session-place-trigger").click(); + await place.getByText("Projects", { exact: true }).waitFor(); + await place + .getByText("Admins can register projects from Browse folders", { exact: true }) + .waitFor(); + expect(await place.getByRole("button", { name: "Register as project" }).count()).toBe(0); + } finally { + await context.close(); + } + }); + + it("hides the destination axis when the Gateway is the only place", async () => { + const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + workspace: WORKSPACE, + workspaceGit: true, + methodResponses: { + "node.list": { nodes: [] }, + "environments.list": { + environments: [gatewayEnvironment], + profiles: [], + }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + await gateway.waitForRequest("node.list"); + const trigger = page.locator("#new-session-place-trigger"); + await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe("openclaw"); + await trigger.click(); + const place = page.locator("wa-popover.new-session-page__place-popover"); + expect(await place.getByText("Places", { exact: true }).count()).toBe(0); + await place.getByText("Runs on Gateway · local", { exact: true }).waitFor(); + } finally { + await context.close(); + } + }); + + it("uses advertised system info for Gateway place labels", async () => { + const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + workspace: WORKSPACE, + workspaceGit: true, + featureMethods: ["chat.metadata", "chat.startup", "sessions.create", "system.info"], + methodResponses: { + "system.info": { + machineName: "Peters-Mac-Studio", + hostname: "peters-mac-studio.local", + platform: "darwin", + }, + "node.list": { + nodes: [ + { + nodeId: "macbook", + displayName: "MacBook", + connected: true, + commands: ["system.run"], + }, + ], + }, + "environments.list": { + environments: [gatewayEnvironment, deviceEnvironment("macbook")], + profiles: [], + }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + await gateway.waitForRequest("system.info"); + const trigger = page.locator("#new-session-place-trigger"); + await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe( + "openclaw · Gateway · Peters-Mac-Studio", + ); + await trigger.click(); + const place = page.locator("wa-popover.new-session-page__place-popover"); + await place.getByRole("button", { name: "Gateway · Peters-Mac-Studio" }).waitFor(); + await place.getByRole("button", { name: "Browse folders" }).click(); + await expect + .poll(() => + page.locator("input.new-session-page__browser-path").getAttribute("placeholder"), + ) + .toBe("Gateway · Peters-Mac-Studio"); + + await gateway.setMethodResponse("node.list", { nodes: [] }); + const nodeRequests = (await gateway.getRequests("node.list")).length; + await replaceGatewayClient(page); + await expect + .poll(async () => (await gateway.getRequests("node.list")).length) + .toBeGreaterThan(nodeRequests); + await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe("openclaw"); + await trigger.click(); + await place.getByText("Runs on Gateway · Peters-Mac-Studio", { exact: true }).waitFor(); + } finally { + await context.close(); + } + }); + + it("shows live devices when the initial environment catalog is unavailable", async () => { + const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + methodResponses: { + "node.list": { + nodes: [ + { + nodeId: "fallback-device", + displayName: "Fallback device", + connected: true, + commands: ["system.run"], + }, + ], + }, + "environments.list": { + __mockError: { + code: "UNAVAILABLE", + message: "environment catalog unavailable", + }, + }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + await gateway.waitForRequest("node.list"); + await gateway.waitForRequest("environments.list"); + await page.locator("#new-session-place-trigger").click(); + const place = page.locator("wa-popover.new-session-page__place-popover"); + await place.getByText("Devices", { exact: true }).waitFor(); + await place.getByRole("button", { name: "Fallback device" }).waitFor(); + await captureUiProof(page, "04-catalog-unavailable-device-fallback.png"); + } finally { + await context.close(); + } + }); + + it("keeps the last environment catalog across a same-Gateway client refresh failure", async () => { + const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + methodResponses: { + "node.list": { + nodes: [ + { + nodeId: "stable-device", + displayName: "Stable device", + connected: true, + commands: ["system.run"], + }, + ], + }, + "environments.list": { + environments: [deviceEnvironment("stable-device")], + profiles: [], + }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + await gateway.waitForRequest("environments.list"); + const trigger = page.locator("#new-session-place-trigger"); + const place = page.locator("wa-popover.new-session-page__place-popover"); + await trigger.click(); + await place.getByRole("button", { name: "Stable device" }).waitFor(); + await page.keyboard.press("Escape"); + + await gateway.setMethodResponse("environments.list", { + __mockError: { + code: "UNAVAILABLE", + message: "environment refresh unavailable", + }, + }); + const environmentRequests = (await gateway.getRequests("environments.list")).length; + await replaceGatewayClient(page); + await expect + .poll(async () => (await gateway.getRequests("environments.list")).length) + .toBeGreaterThan(environmentRequests); + + await trigger.click(); + await place.getByRole("button", { name: "Stable device" }).waitFor(); + } finally { + await context.close(); + } + }); + + it("disambiguates duplicate node names without changing the selected chip", async () => { + const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + workspace: WORKSPACE, + workspaceGit: true, + methodResponses: { + "node.list": { + nodes: [ + { + nodeId: "11111111aaaaaaaa", + displayName: "Mac Studio", + platform: "darwin", + modelIdentifier: "Mac14,12", + remoteIp: "192.168.1.11", + connected: true, + commands: ["system.run"], + }, + { + nodeId: "22222222bbbbbbbb", + displayName: "Mac Studio", + platform: "darwin", + modelIdentifier: "Mac15,14", + remoteIp: "192.168.1.12", + connected: true, + commands: ["system.run"], + }, + { + nodeId: "33333333cccccccc", + displayName: "iPhone", + platform: "iOS 26.4", + deviceFamily: "iPhone", + modelIdentifier: "iPhone17,2", + remoteIp: "192.168.1.30", + connected: true, + commands: ["system.run"], + }, + ], + }, + "environments.list": { + environments: [ + gatewayEnvironment, + { id: "node:11111111aaaaaaaa", type: "node", status: "available" }, + deviceEnvironment("22222222bbbbbbbb"), + deviceEnvironment("33333333cccccccc"), + ], + profiles: [], + }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + await gateway.waitForRequest("node.list"); + const trigger = page.locator("#new-session-place-trigger"); + await trigger.click(); + const place = page.locator("wa-popover.new-session-page__place-popover"); + await place + .locator(".new-session-page__menu-title") + .getByText("This gateway", { exact: true }) + .waitFor(); + await place.getByText("Devices", { exact: true }).waitFor(); + const first = page.locator('[data-value="node:11111111aaaaaaaa"]'); + const second = page.locator('[data-value="node:22222222bbbbbbbb"]'); + const phone = page.locator('[data-value="node:33333333cccccccc"]'); + await pollLocatorText(first.locator(".session-menu__sub")).toBe("Mac14,12"); + await pollLocatorText(second.locator(".session-menu__sub")).toBe("Mac15,14"); + await pollLocatorText(phone.locator(".session-menu__text")).toBe("iPhone"); + expect(await first.locator(".session-menu__icon svg").count()).toBe(1); + expect(await second.locator(".session-menu__icon svg").count()).toBe(1); + expect(await phone.locator(".session-menu__icon svg").count()).toBe(1); + expect(await first.getAttribute("title")).toBe("macOS · Mac14,12 · 192.168.1.11"); + expect(await second.getAttribute("title")).toContain("192.168.1.12"); + await captureUiProof(page, "03-legacy-device-picker.png"); + await second.click(); + await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe( + "Agent workspace · Mac Studio", + ); + expect(await trigger.textContent()).not.toContain("Mac15,14"); + expect(await trigger.textContent()).not.toContain("192.168.1.12"); + } finally { + await context.close(); + } + }); + + it("keeps and disambiguates recent locations with the same basename", async () => { + const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + workspace: WORKSPACE, + workspaceGit: true, + methodResponses: { + "node.list": { nodes: [] }, + "environments.list": { environments: [], profiles: [] }, + "sessions.list": { + count: 2, + defaults: SESSION_LIST_DEFAULTS, + path: "", + sessions: [ + { key: "agent:main:a", kind: "direct", updatedAt: 2, execCwd: "/a/openclaw" }, + { key: "agent:main:b", kind: "direct", updatedAt: 1, execCwd: "/b/openclaw" }, + ], + ts: Date.now(), + }, + "sessions.create": { key: "agent:main:recent-collision" }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + await gateway.waitForRequest("node.list"); + const trigger = page.locator("#new-session-place-trigger"); + await trigger.click(); + const first = page.locator('[data-value="recent::/a/openclaw"]'); + const second = page.locator('[data-value="recent::/b/openclaw"]'); + await first.waitFor(); + await second.waitFor(); + await pollLocatorText(first.locator(".session-menu__sub")).toBe("a"); + await pollLocatorText(second.locator(".session-menu__sub")).toBe("b"); + const recentValues = await page + .locator('[data-value^="recent::"]') + .evaluateAll((items) => items.map((item) => item.getAttribute("data-value"))); + expect(recentValues).toEqual(["recent::/a/openclaw", "recent::/b/openclaw"]); + await second.click(); + await page.locator(".new-session-page__message").fill("continue in work checkout"); + await page.getByRole("button", { name: "Start session" }).click(); + const create = await gateway.waitForRequest("sessions.create"); + expect(create.params).toMatchObject({ + cwd: "/b/openclaw", + message: "continue in work checkout", + }); + } finally { + await context.close(); + } + }); + + it("applies a recent folder and node as one place", async () => { + const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + workspace: WORKSPACE, + workspaceGit: true, + methodResponses: { + "node.list": { + nodes: [ + { + nodeId: "macbook", + displayName: "MacBook", + connected: true, + commands: ["system.run", "fs.listDir"], + }, + ], + }, + "environments.list": { + environments: [gatewayEnvironment, deviceEnvironment("macbook")], + profiles: [], + }, + "sessions.list": { + count: 2, + defaults: SESSION_LIST_DEFAULTS, + path: "", + sessions: [ + { + key: "agent:main:recent-node", + kind: "direct", + updatedAt: 2, + execCwd: NODE_PICKED, + execNode: "macbook", + }, + { + key: "agent:main:workspace", + kind: "direct", + updatedAt: 1, + execCwd: WORKSPACE, + }, + ], + ts: Date.now(), + }, + "sessions.create": { key: "agent:main:recent-place" }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + await gateway.waitForRequest("node.list"); + const trigger = page.locator("#new-session-place-trigger"); + await trigger.click(); + await page + .locator("wa-popover.new-session-page__place-popover") + .getByRole("button", { name: "Projects · MacBook", exact: true }) + .click(); + await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe( + "Projects · MacBook", + ); + + await page.locator(".new-session-page__message").fill("continue on the recent node"); + await page.getByRole("button", { name: "Start session" }).click(); + const create = await gateway.waitForRequest("sessions.create"); + expect(create.params).toMatchObject({ + cwd: NODE_PICKED, + execNode: "macbook", + message: "continue on the recent node", + }); + } finally { + await context.close(); + } + }); + it("runs directly in a custom non-Git Gateway folder", async () => { + const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + workspaceGit: true, + methodResponses: { + "agents.list": { + agents: [ + { + id: "main", + identity: { name: "Main" }, + name: "Main", + workspace: WORKSPACE, + workspaceGit: true, + }, + ], + defaultId: "main", + mainKey: "main", + scope: "agent", + }, + "environments.list": { + environments: [gatewayEnvironment], + profiles: [{ id: "aws", providerId: "crabbox" }], + }, + "fs.listDir": { path: WORKSPACE, home: "/home/peter", entries: [] }, + "worktrees.branches": { + cases: [ + { + match: { repoRoot: WORKSPACE }, + response: { + branches: [{ kind: "local", name: "main" }], + defaultBranch: "main", + repositoryStatus: "git", + }, + }, + { + match: { repoRoot: "/home" }, + response: { branches: [], repositoryStatus: "not_git" }, + }, + ], + }, + "sessions.create": { key: "agent:main:plain-folder" }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + await gateway.waitForRequest("environments.list"); + const trigger = page.locator("#new-session-place-trigger"); + const place = page.locator("wa-popover.new-session-page__place-popover"); + await trigger.click(); + await place.getByRole("button", { name: "Browse folders" }).click(); + await page.locator("input.new-session-page__browser-path").fill("/home"); + await page.getByRole("button", { name: "Use this folder" }).click(); + await expect + .poll(async () => (await gateway.getRequests("worktrees.branches")).at(-1)?.params) + .toEqual({ repoRoot: "/home", includeRepositoryStatus: true }); + await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe( + "home · Gateway · local", + ); + + await trigger.click(); + expect(await place.getByRole("button", { name: "Worktree" }).count()).toBe(0); + await place + .locator(".new-session-page__menu-title") + .getByText("This gateway", { exact: true }) + .waitFor(); + await place.getByText("Cloud", { exact: true }).waitFor(); + const cloud = place.getByRole("button", { name: "Cloud · aws" }); + expect(await cloud.isDisabled()).toBe(true); + expect(await cloud.getAttribute("title")).toBe("Cloud workers require a managed worktree"); + await page.keyboard.press("Escape"); + + await page.locator(".new-session-page__message").fill("clone and inspect this project"); + await page.getByRole("button", { name: "Start session" }).click(); + const create = await gateway.waitForRequest("sessions.create"); + expect(create.params).toMatchObject({ + agentId: "main", + cwd: "/home", + message: "clone and inspect this project", + }); + expect(create.params).not.toHaveProperty("worktree"); + expect(create.params).not.toHaveProperty("worktreeBaseRef"); + } finally { + await context.close(); + } + }); + + it("browses capable nodes and accepts manual paths for exec-only nodes", async () => { + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + workspaceGit: true, + methodResponses: { + "agents.list": { + agents: [ + { + id: "main", + identity: { name: "Main" }, + name: "Main", + workspace: WORKSPACE, + workspaceGit: true, + }, + { + id: "research", + identity: { name: "Research" }, + name: "Research", + workspace: "/home/peter/research", + workspaceGit: true, + }, + ], + defaultId: "main", + mainKey: "main", + scope: "agent", + }, + "node.list": { + nodes: [ + { + nodeId: "macbook", + displayName: "MacBook", + connected: true, + commands: ["system.run", "fs.listDir"], + }, + { + nodeId: "old-node", + displayName: "Old node", + connected: true, + commands: ["system.run"], + }, + { + nodeId: "offline-node", + displayName: "Offline node", + connected: false, + commands: ["system.run", "fs.listDir"], + }, + ], + }, + "environments.list": { + environments: [ + gatewayEnvironment, + deviceEnvironment("macbook"), + deviceEnvironment("old-node"), + deviceEnvironment("offline-node"), + ], + profiles: [], + }, + "fs.listDir": { + cases: [ + { + match: { nodeId: "macbook", path: NODE_UNC }, + response: { + path: NODE_UNC, + parent: "\\\\server\\share", + home: "C:\\Users\\peter", + entries: [], + }, + }, + { + match: { nodeId: "macbook", path: NODE_PICKED }, + response: { + path: NODE_PICKED, + parent: NODE_HOME, + home: NODE_HOME, + entries: [], + }, + }, + { + match: { nodeId: "macbook" }, + response: { + path: NODE_HOME, + home: NODE_HOME, + entries: [{ name: "Projects", path: NODE_PICKED }], + }, + }, + ], + }, + "sessions.create": { key: "agent:main:node-draft-e2e" }, + "sessions.list": createdSessionListResult("agent:main:node-draft-e2e"), + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + await page.locator(".new-session-page__message").waitFor(); + const placeSelect = page.locator("wa-popover.new-session-page__place-popover"); + const placeTrigger = page.locator("#new-session-place-trigger"); + const placeLabel = placeTrigger.locator(".new-session-page__trigger-label"); + const browserEntries = page.locator(".new-session-page__browser-list"); + + // Pick the node from Devices. + await placeTrigger.click(); + await placeSelect + .locator(".new-session-page__menu-title") + .getByText("This gateway", { exact: true }) + .waitFor(); + await placeSelect.getByText("Devices", { exact: true }).waitFor(); + await placeSelect.getByRole("button", { name: "MacBook" }).click(); + await pollLocatorText(placeLabel).toBe("Agent workspace · MacBook"); + // Node sessions cannot use managed worktrees, so the menu drops the item. + await placeTrigger.click(); + expect(await placeSelect.getByRole("button", { name: "Worktree" }).count()).toBe(0); + await page.keyboard.press("Escape"); + + // Manual path entry in the browser head preserves UNC paths; these + // cannot be rediscovered by starting at the node home directory. + await placeTrigger.click(); + await placeSelect.getByRole("button", { name: "Browse folders" }).click(); + const pathInput = page.locator("input.new-session-page__browser-path"); + await expect.poll(() => pathInput.inputValue()).toBe(NODE_HOME); + await pathInput.fill(NODE_UNC); + await pathInput.press("Enter"); + await expect.poll(() => pathInput.inputValue()).toBe(NODE_UNC); + // Escape returns to the picker root without applying or closing it. + await page.keyboard.press("Escape"); + await expect + .poll(() => + placeSelect.evaluate((element) => (element as HTMLElement & { open: boolean }).open), + ) + .toBe(true); + await placeSelect + .locator(".new-session-page__menu-title") + .getByText("This gateway", { exact: true }) + .waitFor(); + + // Destination selection stays grouped by place; browsing is fixed to the current target. + await placeSelect.getByRole("button", { name: "Gateway · local" }).click(); + await pollLocatorText(placeLabel).toBe("openclaw · Gateway · local"); + await placeTrigger.click(); + expect(await placeSelect.getByRole("button", { name: "Offline node" }).count()).toBe(0); + await placeSelect.getByRole("button", { name: "MacBook" }).click(); + await placeTrigger.click(); + await placeSelect.getByRole("button", { name: "Browse folders" }).click(); + await browserEntries.getByRole("button", { name: "Projects" }).click(); + await page.getByRole("button", { name: "Use this folder" }).click(); + + // Using a node folder retargets the draft to that node. + await pollLocatorText(placeLabel).toBe("Projects · MacBook"); + + // A node cwd belongs to the selected agent's draft and must not leak + // across an agent change, even though the execution node stays selected. + const agentPicker = page.locator(".new-session-page__select--agent openclaw-agent-select"); + await agentPicker.locator(".agent-select__trigger").click(); + await agentPicker + .locator("wa-dropdown-item[data-agent-option]") + .filter({ hasText: "Research" }) + .click(); + await page.getByRole("heading", { name: "Research" }).waitFor(); + await pollLocatorText(placeLabel).toBe("Agent workspace · MacBook"); + + // Clearing the path applies the node's default directory (empty folder), + // the state the replaced clearable folder textbox could express. + await placeTrigger.click(); + await placeSelect.getByRole("button", { name: "Browse folders" }).click(); + await expect.poll(() => pathInput.inputValue()).toBe(NODE_HOME); + await pathInput.fill(""); + await page.getByRole("button", { name: "Use this folder" }).click(); + await pollLocatorText(placeLabel).toBe("Agent workspace · MacBook"); + + // Browse back to the custom folder, then retarget to the exec-only node + // with a manual absolute path for the final create assertion. + await placeTrigger.click(); + await placeSelect.getByRole("button", { name: "Browse folders" }).click(); + await browserEntries.getByRole("button", { name: "Projects" }).click(); + await page.getByRole("button", { name: "Use this folder" }).click(); + await pollLocatorText(placeLabel).toBe("Projects · MacBook"); + + await placeTrigger.click(); + await placeSelect.getByRole("button", { name: "Old node" }).click(); + await placeTrigger.click(); + await placeSelect.getByRole("button", { name: "Browse folders" }).click(); + await expect.poll(() => pathInput.inputValue()).toBe(""); + await pathInput.fill(EXEC_ONLY_PICKED); + await pathInput.press("Enter"); + expect( + (await gateway.getRequests("fs.listDir")).filter( + (request) => (request.params as { nodeId?: string } | undefined)?.nodeId === "old-node", + ), + ).toHaveLength(0); + await page.getByRole("button", { name: "Use this folder" }).click(); + await pollLocatorText(placeLabel).toBe("repo · Old node"); + + await page.locator(".new-session-page__message").fill("inspect the remote checkout"); + await page.getByRole("button", { name: "Start session" }).click(); + const createRequest = await gateway.waitForRequest("sessions.create"); + expect(createRequest.params).toMatchObject({ + agentId: "research", + message: "inspect the remote checkout", + execNode: "old-node", + cwd: EXEC_ONLY_PICKED, + }); + expect(createRequest.params).not.toHaveProperty("worktree"); + } finally { + await context.close(); + } + }); +}); diff --git a/ui/src/e2e/new-session-page.workspace-validation.e2e.test.ts b/ui/src/e2e/new-session-page.workspace-validation.e2e.test.ts index 06f1abeaa377..c70471f80942 100644 --- a/ui/src/e2e/new-session-page.workspace-validation.e2e.test.ts +++ b/ui/src/e2e/new-session-page.workspace-validation.e2e.test.ts @@ -46,6 +46,14 @@ function branchList(name = "main") { }; } +function deviceEnvironment(nodeId: string) { + return { + id: `node:${nodeId}`, + type: "node", + status: "available", + }; +} + async function withNewSessionPage( options: BrowserContextOptions, run: (page: Page) => Promise, @@ -295,12 +303,17 @@ suite.define(() => { }, ], }, + "environments.list": { + environments: [deviceEnvironment("old-device")], + profiles: [], + }, "worktrees.branches": branchList(), "sessions.create": { key: "agent:main:validated-device" }, }, }); await page.goto(`${suite.server.baseUrl}new`); await gateway.waitForRequest("node.list"); + await gateway.waitForRequest("environments.list"); const placeSelect = page.locator("wa-popover.new-session-page__place-popover"); await page.locator("#new-session-place-trigger").click(); await placeSelect.getByRole("button", { name: "Old device" }).click(); @@ -343,12 +356,17 @@ suite.define(() => { }, ], }, + "environments.list": { + environments: [deviceEnvironment("old-device")], + profiles: [], + }, "worktrees.branches": branchList("alpha"), }, }); await page.goto(`${suite.server.baseUrl}new`); await page.getByRole("heading", { name: "Original agent" }).waitFor(); await gateway.waitForRequest("node.list"); + await gateway.waitForRequest("environments.list"); await gateway.waitForRequest("worktrees.branches"); const message = page.locator(".new-session-page__message"); @@ -379,9 +397,14 @@ suite.define(() => { }, ], }); + await gateway.setMethodResponse("environments.list", { + environments: [deviceEnvironment("new-device")], + profiles: [], + }); await gateway.setMethodResponse("worktrees.branches", branchList("beta")); const socketsBefore = await gateway.getSocketCount(); const nodesBefore = (await gateway.getRequests("node.list")).length; + const environmentsBefore = (await gateway.getRequests("environments.list")).length; const branchesBefore = (await gateway.getRequests("worktrees.branches")).length; await replaceGatewayClient(page); @@ -390,6 +413,9 @@ suite.define(() => { await expect .poll(async () => (await gateway.getRequests("node.list")).length) .toBe(nodesBefore + 1); + await expect + .poll(async () => (await gateway.getRequests("environments.list")).length) + .toBe(environmentsBefore + 1); await expect .poll(async () => (await gateway.getRequests("worktrees.branches")).length) .toBe(branchesBefore + 1); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index a8a8dc9e9f0d..7681cc675caa 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -699,6 +699,7 @@ export const en: TranslationMap = { agent: "Agent", where: "Where", gateway: "Gateway · local", + thisGateway: "This gateway", gatewayNamed: "Gateway · {name}", cloudWorker: "Cloud · {profile}", cloudWorkerProvider: "Cloud worker provider: {provider}", @@ -720,6 +721,7 @@ export const en: TranslationMap = { cloneProject: "Clone", cloningProject: "Cloning project…", registerProject: "Register as project", + cloud: "Cloud", recentFolders: "Recent", runsOn: "Runs on {place}", browse: "Browse folders", diff --git a/ui/src/pages/chat/chat-pane-base.ts b/ui/src/pages/chat/chat-pane-base.ts index 46b85af406d1..cdf6e9a51e5e 100644 --- a/ui/src/pages/chat/chat-pane-base.ts +++ b/ui/src/pages/chat/chat-pane-base.ts @@ -160,6 +160,7 @@ export abstract class ChatPaneBase extends OpenClawLightDomElement { @litState() protected headerRenameValue = ""; @litState() protected headerPlatform: string | null = null; @litState() protected headerCopiedAction: ChatPaneHeaderAction | null = null; + @litState() protected headerPlacementReclaimingKey: string | null = null; @litState() protected presencePayload: PresencePayload | undefined; @litState() protected sessionSharingStates = new Map(); protected readonly sessionParticipationTracker = new SessionParticipationTracker(); diff --git a/ui/src/pages/chat/chat-pane-context.ts b/ui/src/pages/chat/chat-pane-context.ts index ad685eeb8c89..5c6a3a7a95d5 100644 --- a/ui/src/pages/chat/chat-pane-context.ts +++ b/ui/src/pages/chat/chat-pane-context.ts @@ -1,3 +1,4 @@ +import type { GatewaySessionRow } from "../../api/types.ts"; import { invalidateAssistantIdentityCache } from "../../app/assistant-identity.ts"; import type { ApplicationContext, ApplicationGatewaySnapshot } from "../../app/context.ts"; import { hasOperatorAdminAccess } from "../../app/operator-access.ts"; @@ -22,6 +23,7 @@ import { import { invalidateChatAvatarCache } from "./chat-avatar.ts"; import { applyChatAgentsList, syncSelectedSessionMessageSubscription } from "./chat-history.ts"; import { ChatPaneLifecycle } from "./chat-pane-lifecycle.ts"; +import { reclaimChatPanePlacement } from "./chat-pane-placement.ts"; import { applySelectedSessionProjection } from "./chat-pane-state.ts"; import { resolveAssistantAttachmentAuthToken } from "./chat-pane-state.ts"; import { markQueuedChatSendsWaitingForReconnect } from "./chat-queue.ts"; @@ -53,6 +55,29 @@ export abstract class ChatPaneContext extends ChatPaneLifecycle { super.disconnectedCallback(); } + protected async reclaimHeaderPlacement(row: GatewaySessionRow): Promise { + const onReclaimingChange = (reclaimingKey: string | null) => { + // A later reclaim may take ownership before this request settles. Only + // the request that still owns the row may clear the pane's progress key. + if (reclaimingKey !== null || this.headerPlacementReclaimingKey === row.key) { + this.headerPlacementReclaimingKey = reclaimingKey; + } + }; + await reclaimChatPanePlacement({ + client: this.connectedClient, + connectionGeneration: this.connectionGeneration, + gatewaySnapshot: this.context.gateway.snapshot, + reclaimingKey: this.headerPlacementReclaimingKey, + row, + isCurrent: (client, generation) => + this.connectedClient === client && this.connectionGeneration === generation, + onReclaimingChange, + publishError: (error) => this.publishHeaderError(error), + refreshReplacement: (agentId) => this.context.sessions.refreshReplacement(agentId), + requestUpdate: () => this.requestUpdate(), + }); + } + protected applySessionsState(stateValue: ApplicationContext["sessions"]["state"]) { const state = this.state; if (!state) { diff --git a/ui/src/pages/chat/chat-pane-header.ts b/ui/src/pages/chat/chat-pane-header.ts index 41407f413b7b..ef43f5294eb1 100644 --- a/ui/src/pages/chat/chat-pane-header.ts +++ b/ui/src/pages/chat/chat-pane-header.ts @@ -25,6 +25,7 @@ import { import { normalizeOptionalString } from "../../lib/string-coerce.ts"; import { isActiveTask } from "../../lib/tasks/data.ts"; import { renderBoardViewSwitch } from "./board-session-surface.ts"; +import { resolveChatPanePlacement } from "./chat-pane-placement.ts"; import { ChatPaneSessionMenu } from "./chat-pane-session-menu.ts"; import { readChatSessionActionAccess } from "./chat-session-action-access.ts"; import { renderBackgroundTasksToggle } from "./components/chat-background-tasks-render.ts"; @@ -311,6 +312,11 @@ export abstract class ChatPaneHeader extends ChatPaneSessionMenu { onActivate: () => this.onSplitRight?.(this.paneId), }); } + const placement = resolveChatPanePlacement({ + gatewaySnapshot: this.context.gateway.snapshot, + reclaimingKey: this.headerPlacementReclaimingKey, + row, + }); return renderChatPaneHeader({ paneId: this.paneId, narrow: this.narrow, @@ -452,6 +458,7 @@ export abstract class ChatPaneHeader extends ChatPaneSessionMenu { .onAction=${(action: HeaderMenuAction) => this.handleHeaderSessionAction(action, row)} >` : nothing, + placementReclaimDisabledReason: placement.reclaimDisabledReason, nativeGateways: this.nativeGateways, gatewaysSnapshot: this.gatewaysSnapshot, onboarding: this.onboarding, @@ -474,6 +481,7 @@ export abstract class ChatPaneHeader extends ChatPaneSessionMenu { onOpenParentSession: (sessionKey) => { this.onPaneSessionChange?.(this.paneId, sessionKey); }, + onPlacementReclaim: () => row && void this.reclaimHeaderPlacement(row), onBranchSelect: (leafEntryId) => { const access = readChatSessionActionAccess( this.context.gateway.snapshot, diff --git a/ui/src/pages/chat/chat-pane-placement.test.ts b/ui/src/pages/chat/chat-pane-placement.test.ts new file mode 100644 index 000000000000..eaa2d6c85a97 --- /dev/null +++ b/ui/src/pages/chat/chat-pane-placement.test.ts @@ -0,0 +1,217 @@ +/* @vitest-environment jsdom */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import type { GatewaySessionRow } from "../../api/types.ts"; +import { t } from "../../i18n/index.ts"; +import type { SessionCapability } from "../../lib/sessions/index.ts"; +import { + answerConfirmDialog, + installDialogPolyfill, + waitForConfirmDialogActions, +} from "../../test-helpers/modal-dialog.ts"; +import { resolveChatPanePlacement } from "./chat-pane-placement.ts"; +import { createTestChatPane } from "./chat-pane.test-support.ts"; + +let restoreDialogPolyfill: () => void; + +beforeEach(() => { + restoreDialogPolyfill = installDialogPolyfill(); +}); + +afterEach(() => { + document.body.replaceChildren(); + restoreDialogPolyfill(); + vi.unstubAllGlobals(); +}); + +type ActivePlacement = Extract, { state: "active" }>; + +function activePlacementSession( + key = "agent:main:cloud", +): GatewaySessionRow & { placement: ActivePlacement } { + return { + key, + kind: "direct", + updatedAt: 0, + placement: { + state: "active", + generation: 1, + createdAtMs: 1, + updatedAtMs: 1, + stateChangedAtMs: 1, + environmentId: "worker:one", + activeOwnerEpoch: 1, + workerBundleHash: "a".repeat(64), + workspaceBaseManifestRef: "base-manifest", + remoteWorkspaceDir: "/worker/repo", + }, + }; +} + +describe("chat pane placement", () => { + it("does not reclaim a provisioning placement with a destroyable environment", async () => { + const request = vi.fn(async () => ({ ok: true })); + const { pane } = createTestChatPane({ + client: { request } as unknown as GatewayBrowserClient, + sessions: {} as SessionCapability, + }); + pane.context.gateway.snapshot.hello = { + features: { methods: ["sessions.reclaim"] }, + auth: { role: "operator", scopes: ["operator.admin"] }, + } as never; + + const session = { + key: "agent:main:provisioning", + kind: "direct", + updatedAt: 0, + placement: { + state: "provisioning", + environmentId: "worker:one", + } as GatewaySessionRow["placement"], + } satisfies GatewaySessionRow; + const placement = resolveChatPanePlacement({ + gatewaySnapshot: pane.context.gateway.snapshot, + reclaimingKey: null, + row: session, + }); + const dialogsBefore = document.body.querySelectorAll("openclaw-modal-dialog").length; + await pane.reclaimHeaderPlacement(session); + + expect(placement).toEqual({ + reclaimDisabledReason: "This Gateway does not support this session action.", + }); + expect(document.body.querySelectorAll("openclaw-modal-dialog")).toHaveLength(dialogsBefore); + expect(request).not.toHaveBeenCalled(); + }); + + it("reclaims an active placement after the operator confirms", async () => { + vi.stubGlobal( + "confirm", + vi.fn(() => { + throw new Error("native confirm must not be used"); + }), + ); + const request = vi.fn(async () => ({ ok: true })); + const refreshReplacement = vi.fn(async () => undefined); + const { pane } = createTestChatPane({ + client: { request } as unknown as GatewayBrowserClient, + sessions: { refreshReplacement } as unknown as SessionCapability, + }); + pane.context.gateway.snapshot.hello = { + features: { methods: ["sessions.reclaim"] }, + auth: { role: "operator", scopes: ["operator.admin"] }, + } as never; + const session = activePlacementSession(); + + const reclaim = pane.reclaimHeaderPlacement(session); + const actions = await waitForConfirmDialogActions(); + expect(actions.textContent).toContain("Stop worker"); + answerConfirmDialog(actions, "confirm"); + await reclaim; + + expect(request).toHaveBeenCalledWith( + "sessions.reclaim", + { key: session.key, agentId: "main" }, + { timeoutMs: 10 * 60_000 }, + ); + expect(refreshReplacement).toHaveBeenCalledWith("main"); + }); + + it("does not reclaim when the operator cancels", async () => { + const request = vi.fn(async () => ({ ok: true })); + const { pane } = createTestChatPane({ + client: { request } as unknown as GatewayBrowserClient, + sessions: {} as SessionCapability, + }); + pane.context.gateway.snapshot.hello = { + features: { methods: ["sessions.reclaim"] }, + auth: { role: "operator", scopes: ["operator.admin"] }, + } as never; + const session = activePlacementSession(); + + const reclaim = pane.reclaimHeaderPlacement(session); + const actions = await waitForConfirmDialogActions(); + answerConfirmDialog(actions, "cancel"); + await reclaim; + + expect(request).not.toHaveBeenCalled(); + }); + + it("does not reclaim after the connection changes while confirmation is open", async () => { + const request = vi.fn(async () => ({ ok: true })); + const { pane, state } = createTestChatPane({ + client: { request } as unknown as GatewayBrowserClient, + sessions: {} as SessionCapability, + }); + pane.context.gateway.snapshot.hello = { + features: { methods: ["sessions.reclaim"] }, + auth: { role: "operator", scopes: ["operator.admin"] }, + } as never; + const session = activePlacementSession(); + + const reclaim = pane.reclaimHeaderPlacement(session); + const actions = await waitForConfirmDialogActions(); + pane.connectionGeneration += 1; + answerConfirmDialog(actions, "confirm"); + await reclaim; + + expect(request).not.toHaveBeenCalled(); + expect(state.chatError).toBe(t("sessionsView.actionUnavailable")); + }); + + it("keeps reclaim progress with its session when the pane switches rows", async () => { + let resolveRequest!: (result: { ok: true }) => void; + const request = vi.fn( + () => + new Promise<{ ok: true }>((resolve) => { + resolveRequest = resolve; + }), + ); + const refreshReplacement = vi.fn(async () => undefined); + const { pane, state } = createTestChatPane({ + client: { request } as unknown as GatewayBrowserClient, + sessions: { refreshReplacement } as unknown as SessionCapability, + }); + pane.context.gateway.snapshot.hello = { + features: { methods: ["sessions.reclaim"] }, + auth: { role: "operator", scopes: ["operator.admin"] }, + } as never; + const sessionA = activePlacementSession("agent:main:cloud-a"); + const sessionB = { + ...sessionA, + key: "agent:main:cloud-b", + placement: { + ...sessionA.placement, + environmentId: "worker:two", + remoteWorkspaceDir: "/worker/repo-b", + }, + } satisfies GatewaySessionRow; + + const pendingReclaim = pane.reclaimHeaderPlacement(sessionA); + const actions = await waitForConfirmDialogActions(); + answerConfirmDialog(actions, "confirm"); + await vi.waitFor(() => expect(pane.headerPlacementReclaimingKey).toBe(sessionA.key)); + expect(pane.headerPlacementReclaimingKey).toBe(sessionA.key); + + state.sessionKey = sessionB.key; + expect(state.sessionKey).toBe(sessionB.key); + const placementA = resolveChatPanePlacement({ + gatewaySnapshot: pane.context.gateway.snapshot, + reclaimingKey: pane.headerPlacementReclaimingKey, + row: sessionA, + }); + const placementB = resolveChatPanePlacement({ + gatewaySnapshot: pane.context.gateway.snapshot, + reclaimingKey: pane.headerPlacementReclaimingKey, + row: sessionB, + }); + expect(placementA.reclaimDisabledReason).toBe(t("common.loading")); + expect(placementB.reclaimDisabledReason).toBeUndefined(); + + resolveRequest({ ok: true }); + await pendingReclaim; + + expect(pane.headerPlacementReclaimingKey).toBeNull(); + }); +}); diff --git a/ui/src/pages/chat/chat-pane-placement.ts b/ui/src/pages/chat/chat-pane-placement.ts new file mode 100644 index 000000000000..86ad5707b7fc --- /dev/null +++ b/ui/src/pages/chat/chat-pane-placement.ts @@ -0,0 +1,102 @@ +import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import type { GatewaySessionRow } from "../../api/types.ts"; +import type { ApplicationGatewaySnapshot } from "../../app/context.ts"; +import { + requestCloudWorkerStop, + resolveCloudWorkerStopAction, +} from "../../components/cloud-worker-stop.ts"; +import { t } from "../../i18n/index.ts"; +import { readSessionMethodAccess } from "../../lib/session-method-access.ts"; +import { parseAgentSessionKey } from "../../lib/sessions/session-key.ts"; + +export function resolveChatPanePlacement(params: { + gatewaySnapshot: ApplicationGatewaySnapshot; + reclaimingKey: string | null; + row: GatewaySessionRow | undefined; +}): { reclaimDisabledReason: string | undefined } { + const reclaiming = params.reclaimingKey === params.row?.key; + const action = resolveCloudWorkerStopAction(params.row?.placement); + const access = readSessionMethodAccess(params.gatewaySnapshot, { + method: "sessions.reclaim", + requiredScope: "operator.admin", + }); + const reclaimDisabledReason = reclaiming + ? t("common.loading") + : params.row?.hasActiveRun === true + ? t("sessionsView.activeRun") + : action?.method !== "sessions.reclaim" + ? t("sessionsView.actionUnavailable") + : access.allowed + ? undefined + : access.reason; + return { + reclaimDisabledReason, + }; +} + +export async function reclaimChatPanePlacement(params: { + client: GatewayBrowserClient | null; + connectionGeneration: number; + gatewaySnapshot: ApplicationGatewaySnapshot; + reclaimingKey: string | null; + row: GatewaySessionRow; + isCurrent: (client: GatewayBrowserClient, generation: number) => boolean; + onReclaimingChange: (reclaimingKey: string | null) => void; + publishError: (error: unknown) => void; + refreshReplacement: (agentId?: string | null) => Promise; + requestUpdate: () => void; +}): Promise { + const client = params.client; + const connectionGeneration = params.connectionGeneration; + const action = resolveCloudWorkerStopAction(params.row.placement); + const reclaiming = params.reclaimingKey === params.row.key; + if ( + !client || + reclaiming || + params.row.hasActiveRun === true || + action?.method !== "sessions.reclaim" + ) { + return; + } + const access = readSessionMethodAccess(params.gatewaySnapshot, { + method: "sessions.reclaim", + requiredScope: "operator.admin", + }); + if (!access.allowed) { + params.publishError(access.reason); + return; + } + const { showConfirmDialog } = await import("../../components/confirm-dialog.js"); + const confirmed = await showConfirmDialog({ + message: t("sessionsView.stopCloudWorkerConfirm", { + session: params.row.label || params.row.key, + }), + confirmLabel: t("sessionsView.stopCloudWorkerConfirmAction"), + danger: true, + }); + if (!confirmed) { + return; + } + if (!params.isCurrent(client, connectionGeneration)) { + params.publishError(t("sessionsView.actionUnavailable")); + return; + } + const agentId = parseAgentSessionKey(params.row.key)?.agentId; + params.onReclaimingChange(params.row.key); + try { + await requestCloudWorkerStop(client, action, { + key: params.row.key, + ...(agentId ? { agentId } : {}), + }); + if (params.isCurrent(client, connectionGeneration)) { + await params.refreshReplacement(agentId); + } + } catch (error) { + if (params.isCurrent(client, connectionGeneration)) { + params.publishError(error); + } + } finally { + params.onReclaimingChange(null); + params.requestUpdate(); + } +} diff --git a/ui/src/pages/chat/chat-pane-task-suggestions.ts b/ui/src/pages/chat/chat-pane-task-suggestions.ts index 5bec8c4827a7..78b5b8332279 100644 --- a/ui/src/pages/chat/chat-pane-task-suggestions.ts +++ b/ui/src/pages/chat/chat-pane-task-suggestions.ts @@ -13,7 +13,7 @@ import { taskSuggestionAcceptParams, type TaskSuggestionAcceptMode, } from "../../lib/task-suggestion-acceptance.ts"; -import { discoverCloudProfiles } from "../new-session/cloud-profile-discovery.ts"; +import { discoverPlaceCatalog } from "../new-session/cloud-profile-discovery.ts"; import { ChatPaneSharing } from "./chat-pane-sharing.ts"; import { resolveChatAgentId } from "./chat-state-route.ts"; @@ -49,7 +49,7 @@ export abstract class ChatPaneTaskSuggestions extends ChatPaneSharing { return; } try { - const profiles = await discoverCloudProfiles(scope.client, true); + const { profiles } = await discoverPlaceCatalog(scope.client, true); if (!this.isConnectionScopeCurrent(scope)) { return; } diff --git a/ui/src/pages/chat/chat-pane.test-support.ts b/ui/src/pages/chat/chat-pane.test-support.ts index 4023b39d22c7..1560afe11047 100644 --- a/ui/src/pages/chat/chat-pane.test-support.ts +++ b/ui/src/pages/chat/chat-pane.test-support.ts @@ -120,6 +120,8 @@ export type TestChatPane = HTMLElement & { agentWorkspace: string | undefined, workspaceGit: boolean, ) => Promise; + headerPlacementReclaimingKey: string | null; + reclaimHeaderPlacement: (row: GatewaySessionRow) => Promise; markSessionRead: (row: GatewaySessionRow | undefined) => void; renderPaneHeader: ( workspace: ReturnType, diff --git a/ui/src/pages/chat/components/chat-pane-header.test.ts b/ui/src/pages/chat/components/chat-pane-header.test.ts index 828e4b7c33fb..c100c12bbf0d 100644 --- a/ui/src/pages/chat/components/chat-pane-header.test.ts +++ b/ui/src/pages/chat/components/chat-pane-header.test.ts @@ -302,6 +302,56 @@ describe("chat pane header", () => { expect(props.onBeginRename).toHaveBeenCalledOnce(); }); + it("renders a quiet cloud placement chip with the canonical stop action", () => { + const onPlacementReclaim = vi.fn(); + const { container } = mount({ + session: row({ + placement: { + state: "active", + generation: 1, + createdAtMs: 100_000, + updatedAtMs: 300_000, + stateChangedAtMs: 300_000, + environmentId: "worker:one", + activeOwnerEpoch: 1, + workerBundleHash: "a".repeat(64), + workspaceBaseManifestRef: "base-manifest", + remoteWorkspaceDir: "/worker/repo", + }, + }), + onPlacementReclaim, + }); + + expect(container.querySelector(".chat-pane__placement-chip")?.textContent?.trim()).toBe( + "Runs on Cloud", + ); + expect(container.querySelector(".chat-pane__placement-state")).toBeNull(); + expect(container.querySelector(".chat-pane__placement-note")).toBeNull(); + const actions = container.querySelectorAll(".chat-pane__placement-menu wa-dropdown-item"); + expect(actions).toHaveLength(1); + expect(actions[0]?.textContent?.trim()).toBe("Stop cloud worker…"); + expect(actions[0]?.classList.contains("session-menu__item--destructive")).toBe(true); + expect(actions[0]?.getAttribute("variant")).toBe("danger"); + expect(actions[0]?.querySelector(".session-menu__icon")).not.toBeNull(); + actions[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(onPlacementReclaim).toHaveBeenCalledOnce(); + }); + + it.each(["local", "reclaimed"] as const)("hides the placement chip for %s state", (state) => { + const { container } = mount({ + session: row({ + placement: { + state, + generation: 1, + createdAtMs: 1, + updatedAtMs: 1, + stateChangedAtMs: 1, + }, + }), + }); + expect(container.querySelector(".chat-pane__placement-chip")).toBeNull(); + }); + it("places pane presence between the identity trail and face control", () => { const { container } = mount({ presence: html``, @@ -456,7 +506,7 @@ describe("chat pane header", () => { }), canReveal: false, }); - expect(container.querySelector(".chat-pane__cloud")).not.toBeNull(); + expect(container.querySelector(".chat-pane__placement-chip")).not.toBeNull(); expect(container.querySelector('wa-dropdown-item[value="reveal"]')).toBeNull(); expect(container.querySelector('wa-dropdown-item[value="copy-path"]')).not.toBeNull(); }); diff --git a/ui/src/pages/chat/components/chat-pane-header.ts b/ui/src/pages/chat/components/chat-pane-header.ts index dd5cffadaa25..4a383d4267a1 100644 --- a/ui/src/pages/chat/components/chat-pane-header.ts +++ b/ui/src/pages/chat/components/chat-pane-header.ts @@ -27,6 +27,7 @@ import { areUiSessionKeysEquivalent, resolveUiSessionNavigationParentKey, } from "../../../lib/sessions/session-key.ts"; +import { renderChatPanePlacement } from "./chat-pane-placement.ts"; export type ChatPaneHeaderAction = "reveal" | "copy-path" | "copy-branch"; @@ -68,6 +69,7 @@ type ChatPaneHeaderProps = { faceControl?: TemplateResult | typeof nothing; sharingControl?: TemplateResult | typeof nothing; sessionMenuAction: TemplateResult | typeof nothing; + placementReclaimDisabledReason?: string; nativeGateways?: NativeGatewaysCapability | null; gatewaysSnapshot?: NativeGatewaysSnapshot | null; onboarding?: boolean; @@ -78,6 +80,7 @@ type ChatPaneHeaderProps = { onMenuOpenChange: (open: boolean) => void; onMenuAction: (action: ChatPaneHeaderAction) => void; onOpenParentSession: (sessionKey: string) => void; + onPlacementReclaim?: () => void; onBranchSelect: (leafEntryId: string) => void; onOpenSplitView?: () => void; onSplitDown?: (paneId: string) => void; @@ -396,9 +399,6 @@ function renderGatewayPicker(props: ChatPaneHeaderProps) { } export function renderChatPaneHeader(props: ChatPaneHeaderProps) { - const placementState = props.session?.placement?.state; - const cloud = isCloudWorkerPlacementState(placementState); - const cloudLabel = cloud ? t("sessionsView.cloudWorkerPlacement", { state: placementState }) : ""; const copyPathLabel = props.copiedAction === "copy-path" ? t("chat.sessionHeader.copied") @@ -432,15 +432,6 @@ export function renderChatPaneHeader(props: ChatPaneHeaderProps) { ` : nothing} - ${cloud - ? html`${icons.globe}` - : nothing} ${props.session?.incognito ? html` 1 ? html` diff --git a/ui/src/pages/chat/components/chat-pane-placement.ts b/ui/src/pages/chat/components/chat-pane-placement.ts new file mode 100644 index 000000000000..e239bb767035 --- /dev/null +++ b/ui/src/pages/chat/components/chat-pane-placement.ts @@ -0,0 +1,42 @@ +import { html, nothing, type TemplateResult } from "lit"; +import type { GatewaySessionRow } from "../../../api/types.ts"; +import { icons } from "../../../components/icons.ts"; +import { isCloudWorkerPlacementState } from "../../../components/session-row-badges.ts"; +import { t } from "../../../i18n/index.ts"; +import { formatRelativeTimestamp } from "../../../lib/format.ts"; + +export function renderChatPanePlacement(props: { + session: GatewaySessionRow | undefined; + placementReclaimDisabledReason?: string; + onPlacementReclaim?: () => void; +}): TemplateResult | typeof nothing { + const placementState = props.session?.placement?.state; + if (!isCloudWorkerPlacementState(placementState)) { + return nothing; + } + const label = t("newSession.runsOn", { place: t("newSession.cloud") }); + const disabledReason = props.placementReclaimDisabledReason; + const age = formatRelativeTimestamp(props.session?.placement?.stateChangedAtMs, { + fallback: "", + }); + const exceptionState = + placementState === "active" ? nothing : `${placementState}${age ? ` · ${age}` : ""}`; + return html` + + + ${exceptionState === nothing + ? nothing + : html`
${exceptionState}
`} + !disabledReason && props.onPlacementReclaim?.()} + > + + ${t("sessionsView.stopCloudWorker")} + +
+ `; +} diff --git a/ui/src/pages/new-session/cloud-profile-discovery.ts b/ui/src/pages/new-session/cloud-profile-discovery.ts index db35175ee901..f128a1a8d78f 100644 --- a/ui/src/pages/new-session/cloud-profile-discovery.ts +++ b/ui/src/pages/new-session/cloud-profile-discovery.ts @@ -1,6 +1,6 @@ import type { GatewayBrowserClient } from "../../api/gateway.ts"; -import { requestCloudProfiles } from "./cloud-target.ts"; -import type { DraftCloudProfile } from "./discovery.ts"; +import { requestPlaceCatalog } from "./cloud-target.ts"; +import type { DraftCloudProfile, DraftEnvironment } from "./discovery.ts"; export const CLOUD_PROFILE_RETRY_DELAYS_MS = [1_000, 3_000, 10_000, 30_000, 60_000] as const; @@ -13,9 +13,9 @@ export function selectProfiles( return { profiles: unsupported ? [] : profiles, unsupported }; } -export function discoverCloudProfiles( +export function discoverPlaceCatalog( client: Pick, admin: boolean, -): Promise { - return admin ? requestCloudProfiles(client) : Promise.resolve([]); +): Promise<{ profiles: DraftCloudProfile[]; environments: DraftEnvironment[] }> { + return admin ? requestPlaceCatalog(client) : Promise.resolve({ profiles: [], environments: [] }); } diff --git a/ui/src/pages/new-session/cloud-target.ts b/ui/src/pages/new-session/cloud-target.ts index f00f32261541..94444fd83745 100644 --- a/ui/src/pages/new-session/cloud-target.ts +++ b/ui/src/pages/new-session/cloud-target.ts @@ -3,14 +3,17 @@ import type { EnvironmentsListResult } from "../../../../packages/gateway-protoc import type { GatewayBrowserClient } from "../../api/gateway.ts"; import { icons } from "../../components/icons.ts"; import { t } from "../../i18n/index.ts"; -import type { DraftCloudProfile } from "./discovery.ts"; -import { readDraftCloudProfiles } from "./discovery.ts"; +import type { DraftCloudProfile, DraftEnvironment } from "./discovery.ts"; +import { readDraftCloudProfiles, readDraftEnvironments } from "./discovery.ts"; -export async function requestCloudProfiles( +export async function requestPlaceCatalog( client: Pick, -): Promise { +): Promise<{ profiles: DraftCloudProfile[]; environments: DraftEnvironment[] }> { const result = await client.request("environments.list", {}); - return readDraftCloudProfiles(result?.profiles); + return { + profiles: readDraftCloudProfiles(result?.profiles), + environments: readDraftEnvironments(result?.environments), + }; } type SessionMenuItemOptions = { diff --git a/ui/src/pages/new-session/discovery.test.ts b/ui/src/pages/new-session/discovery.test.ts index 453f775c6fcc..91a642c43a1e 100644 --- a/ui/src/pages/new-session/discovery.test.ts +++ b/ui/src/pages/new-session/discovery.test.ts @@ -1,6 +1,6 @@ // @vitest-environment node import { describe, expect, it } from "vitest"; -import { readDraftCloudProfiles, readDraftNodes } from "./discovery.ts"; +import { readDraftCloudProfiles, readDraftEnvironments, readDraftNodes } from "./discovery.ts"; describe("readDraftNodes", () => { it("ignores non-record array entries without throwing", () => { @@ -29,21 +29,45 @@ describe("readDraftNodes", () => { ]); }); }); - describe("readDraftCloudProfiles", () => { it("keeps closed profile summaries in stable order", () => { expect( readDraftCloudProfiles([ null, 42, - { id: " zeta ", providerId: " static-ssh ", settings: { token: "hidden" } }, + { + id: " zeta ", + providerId: " static-ssh ", + settings: { token: "hidden" }, + }, { id: "aws", providerId: "crabbox" }, + { id: "legacy", providerId: "static-ssh" }, { id: "", providerId: "crabbox" }, { id: "missing-provider" }, ]), ).toEqual([ { id: "aws", providerId: "crabbox" }, + { id: "legacy", providerId: "static-ssh" }, { id: "zeta", providerId: "static-ssh" }, ]); }); }); + +describe("readDraftEnvironments", () => { + it("keeps the closed environment types while rejecting malformed entries", () => { + expect( + readDraftEnvironments([ + { id: "gateway", type: "local", label: "Gateway" }, + { id: "node:macbook", type: "node" }, + { id: "worker:aws", type: "worker" }, + { id: "future", type: "future" }, + { id: "", type: "node" }, + { id: "missing-type" }, + ]), + ).toEqual([ + { id: "gateway", type: "local" }, + { id: "node:macbook", type: "node" }, + { id: "worker:aws", type: "worker" }, + ]); + }); +}); diff --git a/ui/src/pages/new-session/discovery.ts b/ui/src/pages/new-session/discovery.ts index d8ff8f454286..153a74549d3b 100644 --- a/ui/src/pages/new-session/discovery.ts +++ b/ui/src/pages/new-session/discovery.ts @@ -32,6 +32,11 @@ export type DraftCloudProfile = { providerId: string; }; +export type DraftEnvironment = { + id: string; + type: "local" | "node" | "worker"; +}; + export type BrowserTarget = { nodeId: string; label: string }; export function readDraftNodes(value: unknown): DraftNode[] { @@ -83,14 +88,40 @@ export function readDraftNodes(value: unknown): DraftNode[] { export function readDraftCloudProfiles(value: unknown): DraftCloudProfile[] { return (Array.isArray(value) ? value : []) - .flatMap((raw) => { + .flatMap((raw) => { if (!raw || typeof raw !== "object") { return []; } - const profile = raw as { id?: unknown; providerId?: unknown }; + const profile = raw as { + id?: unknown; + providerId?: unknown; + }; const id = normalizeOptionalString(profile.id); const providerId = normalizeOptionalString(profile.providerId); - return id && providerId ? [{ id, providerId }] : []; + if (!id || !providerId) { + return []; + } + return [{ id, providerId }]; + }) + .toSorted((left, right) => left.id.localeCompare(right.id)); +} + +export function readDraftEnvironments(value: unknown): DraftEnvironment[] { + return (Array.isArray(value) ? value : []) + .flatMap((raw) => { + if (!raw || typeof raw !== "object") { + return []; + } + const environment = raw as { + id?: unknown; + type?: unknown; + }; + const id = normalizeOptionalString(environment.id); + const type = normalizeOptionalString(environment.type); + if (!id || (type !== "local" && type !== "node" && type !== "worker")) { + return []; + } + return [{ id, type }]; }) .toSorted((left, right) => left.id.localeCompare(right.id)); } diff --git a/ui/src/pages/new-session/draft-gateway-state.ts b/ui/src/pages/new-session/draft-gateway-state.ts index e4fcd67afbfc..f10b124d9e51 100644 --- a/ui/src/pages/new-session/draft-gateway-state.ts +++ b/ui/src/pages/new-session/draft-gateway-state.ts @@ -11,7 +11,7 @@ import { normalizeAgentId } from "../../lib/sessions/session-key.ts"; import * as catalog from "./catalog-target.ts"; import { CLOUD_PROFILE_RETRY_DELAYS_MS, - discoverCloudProfiles, + discoverPlaceCatalog, selectProfiles, } from "./cloud-profile-discovery.ts"; import { @@ -19,7 +19,7 @@ import { resolveSubmissionOutcomeReason, type SubmissionOutcomeReason, } from "./cloud-recovery-state.ts"; -import type { DraftCloudProfile } from "./discovery.ts"; +import type { DraftCloudProfile, DraftEnvironment } from "./discovery.ts"; import { discoverGatewayName } from "./gateway-name-discovery.ts"; import type { NewSessionRouteData } from "./location.ts"; import { @@ -66,6 +66,7 @@ type DraftGatewayCallbacks = { export class DraftGatewayState { private gatewayNameValue = ""; private cloudProfilesValue: DraftCloudProfile[] = []; + private environmentsValue: DraftEnvironment[] | null = null; private cloudProfilesReadyValue = false; private catalogRetryingValue = false; private gatewaySource: ApplicationContext["gateway"] | null = null; @@ -87,7 +88,10 @@ export class DraftGatewayState { private preferenceWrite: Promise = Promise.resolve(); private readonly gatewayNameTask: Task; - private readonly cloudProfileTask: Task; + private readonly cloudProfileTask: Task< + readonly unknown[], + { profiles: DraftCloudProfile[]; environments: DraftEnvironment[] } + >; constructor( host: ReactiveControllerHost, @@ -118,14 +122,16 @@ export class DraftGatewayState { this.gatewayRecoveryScopeValue, ] as const, task: ([client, _connectionEpoch, admin]) => - client ? discoverCloudProfiles(client, admin) : initialState, - onComplete: (profiles) => { + client ? discoverPlaceCatalog(client, admin) : initialState, + onComplete: (placeCatalog) => { this.resetCloudProfileRetry(); - this.applyCloudProfiles(profiles); + this.environmentsValue = placeCatalog.environments; + this.applyCloudProfiles(placeCatalog.profiles); this.cloudProfilesReadyValue = true; this.callbacks.requestUpdate(); }, onError: () => { + // Keep the last environment catalog across a transient client refresh on this Gateway. this.cloudProfilesValue = []; this.cloudProfilesReadyValue = false; this.scheduleCloudProfileRetry(); @@ -142,6 +148,10 @@ export class DraftGatewayState { return this.cloudProfilesValue; } + get environments(): readonly DraftEnvironment[] | null { + return this.environmentsValue; + } + get cloudProfilesReady(): boolean { return this.cloudProfilesReadyValue; } @@ -183,8 +193,9 @@ export class DraftGatewayState { const connected = snapshot.phase === "connected"; const firstBind = this.gatewaySource === null; const gatewayUrlChanged = !firstBind && this.gatewayUrlValue !== gateway.connection.gatewayUrl; + const gatewaySourceChanged = !firstBind && this.gatewaySource !== gateway; const identityChanged = - !firstBind && (this.gatewaySource !== gateway || this.gatewayClientValue !== snapshot.client); + !firstBind && (gatewaySourceChanged || this.gatewayClientValue !== snapshot.client); const connectionChanged = !firstBind && this.gatewayConnectedValue !== connected; const becameConnected = connected && (identityChanged || !this.gatewayConnectedValue); const recoveryScopeBecameReady = @@ -204,9 +215,10 @@ export class DraftGatewayState { this.callbacks.onVisibilityRetired(); } if (gatewayUrlChanged || identityChanged || connectionChanged || recoveryScope.changed) { + const ownerChanged = gatewaySourceChanged || gatewayUrlChanged || recoveryScope.changed; const gatewayIdentityChanged = gatewayUrlChanged || recoveryScope.changed; this.invalidateDiscovery( - gatewayIdentityChanged, + ownerChanged, resolveSubmissionOutcomeReason({ gatewayIdentityChanged, cloudDraftOwned: Boolean(this.read().pendingCloud.sessionKey), @@ -244,6 +256,9 @@ export class DraftGatewayState { this.gatewayNameValue = ""; this.cloudProfilesValue = []; this.cloudProfilesReadyValue = false; + if (resetHostSelection) { + this.environmentsValue = null; + } this.resetCloudProfileRetry(); this.callbacks.onInvalidate(resetHostSelection, submissionOutcome); this.callbacks.requestUpdate(); diff --git a/ui/src/pages/new-session/new-session-page.ts b/ui/src/pages/new-session/new-session-page.ts index 5c73aa02e6f4..7cd4848659f8 100644 --- a/ui/src/pages/new-session/new-session-page.ts +++ b/ui/src/pages/new-session/new-session-page.ts @@ -370,6 +370,7 @@ class NewSessionPage extends OpenClawLightDomElement { projectCloneError: this.browser.projectCloneError, projectId: this.place.projectId, execNodes: this.place.isAdmin() ? execNodes : [], + environments: this.place.isAdmin() ? this.gateway.environments : [], gatewayName: this.gateway.gatewayName, cloudProfiles: this.place.isAdmin() ? cloudProfiles : [], cloudProfileId: this.place.cloudProfileId, diff --git a/ui/src/pages/new-session/place-picker-sections.ts b/ui/src/pages/new-session/place-picker-sections.ts new file mode 100644 index 000000000000..2059e8bac790 --- /dev/null +++ b/ui/src/pages/new-session/place-picker-sections.ts @@ -0,0 +1,25 @@ +import type { DraftCloudProfile, DraftEnvironment, DraftNode } from "./discovery.ts"; + +export function resolvePlacePickerSections(params: { + environments: readonly DraftEnvironment[] | null; + execNodes: readonly DraftNode[]; + cloudProfiles: readonly DraftCloudProfile[]; +}): { deviceNodes: DraftNode[]; cloudProfiles: DraftCloudProfile[] } { + const environmentById = params.environments + ? new Map(params.environments.map((environment) => [environment.id, environment])) + : null; + return { + deviceNodes: params.execNodes.filter((node) => { + if (!node.connected || !node.canExec) { + return false; + } + if (environmentById === null || environmentById.size === 0) { + // Missing and empty catalogs preserve the established live-node fallback. + return true; + } + const environment = environmentById.get(`node:${node.nodeId}`); + return environment?.type === "node"; + }), + cloudProfiles: [...params.cloudProfiles], + }; +} diff --git a/ui/src/pages/new-session/place-picker.test.ts b/ui/src/pages/new-session/place-picker.test.ts index f5f88d5b1ca9..c09f2546fd12 100644 --- a/ui/src/pages/new-session/place-picker.test.ts +++ b/ui/src/pages/new-session/place-picker.test.ts @@ -1,5 +1,7 @@ import { render } from "lit"; import { describe, expect, it, vi } from "vitest"; +import { readDraftEnvironments } from "./discovery.ts"; +import { resolvePlacePickerSections } from "./place-picker-sections.ts"; import { projectCloneInput, renderPlaceSelect } from "./place-picker.ts"; type PlaceSelectParams = Parameters[0]; @@ -24,6 +26,7 @@ function placeParams(overrides: Partial = {}): PlaceSelectPar projectCloneError: null, projectId: "", execNodes: [], + environments: null, gatewayName: "", cloudProfiles: [], cloudProfileId: "", @@ -160,3 +163,117 @@ describe("project picker", () => { expect(onCloneProject).toHaveBeenCalledWith(gitUrl); }); }); + +describe("Where picker", () => { + it("uses node presence until a non-empty authoritative environment catalog arrives", () => { + const execNodes = [ + { + nodeId: "usable", + displayName: "Usable", + connected: true, + canExec: true, + canBrowse: false, + }, + { + nodeId: "disconnected", + displayName: "Disconnected", + connected: false, + canExec: true, + canBrowse: false, + }, + { + nodeId: "no-exec", + displayName: "No exec", + connected: true, + canExec: false, + canBrowse: false, + }, + ]; + + expect( + resolvePlacePickerSections({ environments: null, execNodes, cloudProfiles: [] }).deviceNodes, + ).toEqual([execNodes[0]]); + expect( + resolvePlacePickerSections({ environments: [], execNodes, cloudProfiles: [] }).deviceNodes, + ).toEqual([execNodes[0]]); + }); + + it("groups usable places from environment types and the legacy node catalog", () => { + const container = document.createElement("div"); + const connectedExecNodes = [ + "macbook", + "worker", + "local", + "missing-environment", + "future-type", + ].map((nodeId) => ({ + nodeId, + displayName: nodeId, + connected: true, + canExec: true, + canBrowse: false, + })); + render( + renderPlaceSelect( + placeParams({ + folder: "", + execNodes: [ + ...connectedExecNodes, + { + nodeId: "offline", + displayName: "Offline Mac", + connected: false, + canExec: false, + canBrowse: false, + }, + { + nodeId: "no-exec", + displayName: "No exec", + connected: true, + canExec: false, + canBrowse: false, + }, + ], + environments: readDraftEnvironments([ + { id: "gateway", type: "local" }, + { id: "node:macbook", type: "node" }, + { id: "node:worker", type: "worker" }, + { id: "node:local", type: "local" }, + { id: "node:offline", type: "node" }, + { id: "node:no-exec", type: "node" }, + { id: "node:future-type", type: "future" }, + ]), + gatewayName: "Studio", + cloudProfiles: [ + { id: "aws", providerId: "crabbox" }, + { id: "legacy", providerId: "static-ssh" }, + ], + worktreeAvailable: true, + showDestinations: true, + }), + ), + container, + ); + + const titles = [...container.querySelectorAll(".new-session-page__menu-title")].map((element) => + element.textContent?.trim(), + ); + expect(titles).toEqual(["Folder", "Projects", "Places", "This gateway", "Devices", "Cloud"]); + expect(container.querySelector('[data-value="node:macbook"]')).not.toBeNull(); + for (const nodeId of [ + "worker", + "local", + "missing-environment", + "future-type", + "offline", + "no-exec", + ]) { + expect(container.querySelector(`[data-value="node:${nodeId}"]`)).toBeNull(); + } + expect(container.querySelector('[data-value="cloud:aws"]')).not.toBeNull(); + expect(container.querySelector('[data-value="cloud:legacy"]')).not.toBeNull(); + + const gateway = container.querySelector('[data-value="gateway"]'); + expect(gateway?.lastElementChild?.classList.contains("session-menu__check")).toBe(true); + }); +}); diff --git a/ui/src/pages/new-session/place-picker.ts b/ui/src/pages/new-session/place-picker.ts index 7afe7c47cc5c..488048ec65b4 100644 --- a/ui/src/pages/new-session/place-picker.ts +++ b/ui/src/pages/new-session/place-picker.ts @@ -8,9 +8,16 @@ import type { import { icons } from "../../components/icons.ts"; import { t } from "../../i18n/index.ts"; import { renderCloudProfileMenuItems, renderSessionMenuItem } from "./cloud-target.ts"; -import type { BrowserTarget, DraftBranches, DraftCloudProfile, DraftNode } from "./discovery.ts"; +import type { + BrowserTarget, + DraftBranches, + DraftCloudProfile, + DraftEnvironment, + DraftNode, +} from "./discovery.ts"; import { folderDisplayName } from "./path.ts"; import { disambiguate, isPhoneFamily, nodeTooltip } from "./place-labels.ts"; +import { resolvePlacePickerSections } from "./place-picker-sections.ts"; function parentFolderDisplayName(path: string): string | undefined { const trimmed = path.replace(/[\\/]+$/u, ""); @@ -177,6 +184,7 @@ export function renderPlaceSelect(params: { projectCloneError: string | null; projectId: string; execNodes: DraftNode[]; + environments: readonly DraftEnvironment[] | null; gatewayName: string; cloudProfiles: readonly DraftCloudProfile[]; cloudProfileId: string; @@ -248,6 +256,7 @@ export function renderPlaceSelect(params: { const activeProfile = params.cloudProfiles.find( (profile) => profile.id === params.cloudProfileId, ); + const { deviceNodes, cloudProfiles } = resolvePlacePickerSections(params); const gatewayLabel = params.gatewayName ? t("newSession.gatewayNamed", { name: params.gatewayName }) : t("newSession.gateway"); @@ -258,10 +267,16 @@ export function renderPlaceSelect(params: { : gatewayLabel; const label = params.showDestinations ? `${folderLabel} · ${destinationLabel}` : folderLabel; const effectiveFolder = folder || params.workspace; - const recentItems = params.recents.map((recent) => { + const recents = params.recents.filter( + (recent) => + recent.kind !== "folder" || + !recent.execNode || + deviceNodes.some((node) => node.nodeId === recent.execNode), + ); + const recentItems = recents.map((recent) => { const node = recent.kind === "folder" && recent.execNode - ? params.execNodes.find((candidate) => candidate.nodeId === recent.execNode) + ? deviceNodes.find((candidate) => candidate.nodeId === recent.execNode) : undefined; const recentLabel = params.showDestinations && node @@ -279,7 +294,7 @@ export function renderPlaceSelect(params: { ? `${recent.folder}${recent.execNode ? ` · ${recent.execNode.slice(0, 8)}` : ""}` : recent.projectId, ]); - const nodeSuffixes = disambiguate(params.execNodes, (node) => node.displayName, [ + const nodeSuffixes = disambiguate(deviceNodes, (node) => node.displayName, [ (node) => node.modelIdentifier, (node) => node.remoteIp, (node) => node.nodeId.slice(0, 8), @@ -469,7 +484,7 @@ export function renderPlaceSelect(params: { ${t("newSession.projectsAdminHint")} ` : nothing} - ${params.recents.length > 0 + ${recents.length > 0 ? html`
${t("newSession.recentFolders")}
${recentItems.map((recent, index) => { @@ -519,6 +534,7 @@ export function renderPlaceSelect(params: { ${params.showDestinations ? html`
${t("newSession.places")}
+
${t("newSession.thisGateway")}
${renderSessionMenuItem( { value: "gateway", @@ -529,24 +545,34 @@ export function renderPlaceSelect(params: { }, params.submitting, )} - ${params.execNodes.map((node, index) => - renderSessionMenuItem( - { - value: `node:${node.nodeId}`, - label: node.displayName, - icon: isPhoneFamily(node.deviceFamily) - ? icons.monitorSmartphone - : icons.monitor, - sub: nodeSuffixes[index], - checked: params.execNode === node.nodeId, - title: nodeTooltip(node), - onSelect: () => params.onSelectExecNode(node.nodeId), - }, - params.submitting, - ), - )} + ${deviceNodes.length > 0 + ? html` +
${t("tabs.devices")}
+ ${deviceNodes.map((node, index) => + renderSessionMenuItem( + { + value: `node:${node.nodeId}`, + label: node.displayName, + icon: isPhoneFamily(node.deviceFamily) + ? icons.monitorSmartphone + : icons.monitor, + sub: nodeSuffixes[index], + checked: params.execNode === node.nodeId, + title: nodeTooltip(node), + onSelect: () => params.onSelectExecNode(node.nodeId), + }, + params.submitting, + ), + )} + ` + : nothing} + ${cloudProfiles.length > 0 || (params.cloudProfileId && !activeProfile) + ? html`
+ ${t("newSession.cloud")} +
` + : nothing} ${renderCloudProfileMenuItems({ - profiles: params.cloudProfiles, + profiles: cloudProfiles, selectedId: params.cloudProfileId, submitting: params.submitting, icon: icons.server, diff --git a/ui/src/pages/new-session/recent-places.test.ts b/ui/src/pages/new-session/recent-places.test.ts index b66ddd247b6c..cdcbf0cd3e2d 100644 --- a/ui/src/pages/new-session/recent-places.test.ts +++ b/ui/src/pages/new-session/recent-places.test.ts @@ -4,16 +4,17 @@ import { isKnownWorkspacePath } from "./path.ts"; import { recentPlaces } from "./recent-places.ts"; describe("recentPlaces", () => { - it("deduplicates, caps, skips the workspace and unknown nodes, and prefers exec cwd", () => { + it("deduplicates locations, caps newest-first, and keeps matching basenames on distinct runners", () => { expect( recentPlaces( [ { execCwd: "/workspace" }, { execCwd: "/node/repo", execNode: "macbook" }, { execCwd: "/node/repo", execNode: "macbook" }, + { execCwd: "/gateway/repo" }, { execCwd: "/gone/repo", execNode: "retired" }, { - execCwd: "/preferred/repo", + execCwd: "/preferred/selected", worktree: { repoRoot: "/ignored/worktree" }, }, { worktree: { repoRoot: "/worktree/one" } }, @@ -28,9 +29,9 @@ describe("recentPlaces", () => { ), ).toEqual([ { folder: "/node/repo", execNode: "macbook" }, - { folder: "/preferred/repo", execNode: "" }, + { folder: "/gateway/repo", execNode: "" }, + { folder: "/preferred/selected", execNode: "" }, { folder: "/worktree/one", execNode: "" }, - { folder: "/cwd/two", execNode: "" }, ]); }); diff --git a/ui/src/styles/chat/split-view.css b/ui/src/styles/chat/split-view.css index ea702915aa6a..b5b873551ee4 100644 --- a/ui/src/styles/chat/split-view.css +++ b/ui/src/styles/chat/split-view.css @@ -286,7 +286,6 @@ openclaw-chat-pane { } } -.chat-pane__cloud, .chat-pane__incognito { display: inline-flex; flex: 0 0 auto; @@ -294,7 +293,6 @@ openclaw-chat-pane { color: var(--muted); } -.chat-pane__cloud svg, .chat-pane__incognito svg, .chat-pane__workspace-chip svg { width: 14px; @@ -328,6 +326,42 @@ openclaw-chat-pane { min-width: 0; } +.chat-pane__placement-menu { + flex: 0 0 auto; +} + +.chat-pane__placement-menu::part(menu) { + width: 250px; + padding: 6px; + border: 1px solid color-mix(in srgb, var(--border-strong) 78%, transparent); + border-radius: 8px; + background: var(--bg-elevated); + box-shadow: var(--shadow-lg); +} + +.chat-pane__placement-chip { + padding: 2px; + border: 0; + background: transparent; + color: var(--muted); + font: inherit; + font-size: 11px; + cursor: var(--cursor-action); +} + +.chat-pane__placement-chip:hover, +.chat-pane__placement-chip:focus-visible { + color: var(--text); + outline: none; +} + +.chat-pane__placement-state { + padding: 6px 8px; + color: var(--muted); + font-size: 11px; + font-weight: 600; +} + .chat-pane__gateway-menu { flex: 0 1 auto; min-width: 0;