From cd0a1235a30c266cfaaf3211cd41e57bc5007845 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 11 Aug 2026 16:52:59 -0700 Subject: [PATCH] feat: sync new-session preferences and recents by identity (#121816) * feat(gateway): add identity preferences and project recents * feat(ui): sync new-session identity state * docs: explain identity-scoped session state * test: track preference temp directories * fix(gateway): preserve identity preference boundaries * chore(protocol): refresh identity preference bindings * test: refresh historical schema hashes * style(gateway): format method order assertion * fix(protocol): emit project recent Swift models * test(gateway): track preference RPC release train * fix(gateway): harden identity preference state * fix(state): keep preference errors internal * chore: refresh split plugin SDK baseline * fix(gateway): use core session store loader * refactor(state): fold additive migration checks * chore: regenerate plugin SDK baseline * chore: regenerate plugin SDK baseline * chore: regenerate plugin SDK baseline * chore: regenerate plugin SDK baseline * chore: regenerate plugin SDK baseline * chore: regenerate plugin SDK baseline * chore: regenerate plugin SDK baseline * test(ui): relocate identity recents e2e * chore: regenerate plugin SDK baseline * chore: regenerate plugin SDK baseline * chore: regenerate plugin SDK baseline * chore: regenerate plugin SDK baseline * chore: regenerate plugin SDK baseline --- .../openclaw/app/gateway/GatewayProtocol.kt | 2 + .../OpenClawProtocol/GatewayModels.swift | 111 +++++++- .../agent-harness-runtime.json | 2 +- .../agent-harness.json | 2 +- .../agent-runtime.json | 2 +- .../plugin-sdk-api-baseline/channel-core.json | 2 +- .../channel-entry-contract.json | 2 +- .../channel-inbound.json | 2 +- .../channel-message.json | 2 +- .../channel-outbound.json | 2 +- .../channel-pairing.json | 2 +- .../channel-plugin-common.json | 2 +- .../channel-reply-pipeline.json | 2 +- .../command-auth-native.json | 2 +- .../plugin-sdk-api-baseline/command-auth.json | 2 +- .../config-runtime.json | 2 +- .../plugin-sdk-api-baseline/core.json | 2 +- .../plugin-sdk-api-baseline/discord.json | 2 +- .../gateway-runtime.json | 2 +- .../inbound-reply-dispatch.json | 2 +- .../meeting-runtime.json | 2 +- .../model-session-runtime.json | 2 +- .../models-provider-runtime.json | 2 +- .../plugin-command-runtime.json | 2 +- .../plugin-sdk-api-baseline/plugin-entry.json | 2 +- .../plugin-runtime.json | 2 +- .../provider-catalog-runtime.json | 2 +- .../reply-dispatch-runtime.json | 2 +- .../reply-runtime.json | 2 +- .../runtime-store.json | 2 +- .../session-catalog.json | 2 +- .../session-store-runtime.json | 2 +- .../skill-commands-runtime.json | 2 +- .../plugin-sdk-api-baseline/tool-plugin.json | 2 +- .../webhook-ingress.json | 2 +- ...-session-transcript-schema-baseline.sha256 | 2 +- docs/concepts/multi-user.md | 6 + docs/web/control-ui.md | 6 + .../src/gateway-error-details.ts | 9 + packages/gateway-protocol/src/index.ts | 7 + .../src/schema/error-codes.ts | 8 + .../src/schema/projects.test.ts | 5 + .../gateway-protocol/src/schema/projects.ts | 20 ++ .../protocol-schema-fragment-agent-control.ts | 3 + .../protocol-schema-fragment-transport.ts | 1 + .../src/schema/users-prefs.test.ts | 54 ++++ packages/gateway-protocol/src/schema/users.ts | 31 +++ .../src/validator-registry.ts | 2 + scripts/protocol-gen-swift.ts | 1 + .../session-accessor.sqlite-entry-store.ts | 2 + .../session-accessor.sqlite-session-row.ts | 1 + src/config/sessions/types.ts | 5 + src/gateway/method-scopes.test.ts | 2 + .../methods/core-descriptors.since.test.ts | 2 + src/gateway/methods/core-descriptors.ts | 3 + src/gateway/server-methods-list.test.ts | 10 +- src/gateway/server-methods/projects.test.ts | 90 ++++++- src/gateway/server-methods/projects.ts | 106 +++++++- src/gateway/server-methods/sessions-create.ts | 1 + .../server-methods/users-preferences.test.ts | 118 +++++++++ src/gateway/server-methods/users.ts | 97 +++++++ .../server.sessions.create.projects.test.ts | 16 +- src/gateway/session-create-service.ts | 4 + src/gateway/session-reset-service.ts | 1 + ...s.media-persistence.historical-v14.test.ts | 2 +- ...s.media-persistence.historical-v15.test.ts | 2 +- src/plugins/session-entry-slot-keys.ts | 1 + src/state/openclaw-agent-db-schema.ts | 24 +- .../openclaw-agent-db-session-migrations.ts | 9 + src/state/openclaw-agent-db.generated.d.ts | 1 + .../openclaw-agent-project-column.test.ts | 57 +++++ src/state/openclaw-agent-schema.sql | 1 + src/state/openclaw-state-db-contract.ts | 1 + src/state/openclaw-state-db.generated.d.ts | 8 + src/state/openclaw-state-schema.sql | 8 + src/state/user-preferences.test.ts | 126 +++++++++ src/state/user-preferences.ts | 223 ++++++++++++++++ src/state/user-profiles.ts | 14 + ...-session-page.workspace-memory.e2e.test.ts | 239 ++++++++++++++++++ .../new-session/new-session-page.test.ts | 71 ++++++ ui/src/pages/new-session/new-session-page.ts | 159 +++++++++++- ui/src/pages/new-session/place-picker.ts | 73 ++++-- ui/src/pages/new-session/preferences.test.ts | 28 +- ui/src/pages/new-session/preferences.ts | 67 +++++ 84 files changed, 1827 insertions(+), 79 deletions(-) create mode 100644 packages/gateway-protocol/src/schema/users-prefs.test.ts create mode 100644 src/gateway/server-methods/users-preferences.test.ts create mode 100644 src/state/openclaw-agent-project-column.test.ts create mode 100644 src/state/user-preferences.test.ts create mode 100644 src/state/user-preferences.ts 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 6db0df61fcf9..fb0ea708d79f 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 @@ -537,6 +537,8 @@ enum class GatewayMethod( SecretsStoreList("secrets.store.list"), SecretsStoreSet("secrets.store.set"), SecretsStoreDelete("secrets.store.delete"), + UsersPrefsGet("users.prefs.get"), + UsersPrefsSet("users.prefs.set"), } enum class GatewayEvent( diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index db7ece7792bb..13619e663bf2 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -1803,6 +1803,28 @@ public struct GatewaySuspendResumeResult: Codable, Sendable { } } +public struct UserPrefsLimitExceededErrorDetails: Codable, Sendable { + public let code: String + public let limit: Int + public let currentcount: Int + + public init( + code: String, + limit: Int, + currentcount: Int) + { + self.code = code + self.limit = limit + self.currentcount = currentcount + } + + private enum CodingKeys: String, CodingKey { + case code + case limit + case currentcount = "currentCount" + } +} + public struct WorkerEnvironmentMetadata: Codable, Sendable { public let providerid: String public let leaseid: String? @@ -3077,19 +3099,71 @@ public struct ProjectRecord: Codable, Sendable { } } +public struct ProjectRecentFolder: Codable, Sendable { + public let kind: String + public let folder: String + public let displayname: String + public let execnode: String? + + public init( + kind: String, + folder: String, + displayname: String, + execnode: String? = nil) + { + self.kind = kind + self.folder = folder + self.displayname = displayname + self.execnode = execnode + } + + private enum CodingKeys: String, CodingKey { + case kind + case folder + case displayname = "displayName" + case execnode = "execNode" + } +} + +public struct ProjectRecentProject: Codable, Sendable { + public let kind: String + public let projectid: String + public let displayname: String + + public init( + kind: String, + projectid: String, + displayname: String) + { + self.kind = kind + self.projectid = projectid + self.displayname = displayname + } + + private enum CodingKeys: String, CodingKey { + case kind + case projectid = "projectId" + case displayname = "displayName" + } +} + public struct ProjectsListParams: Codable, Sendable {} public struct ProjectsListResult: Codable, Sendable { public let projects: [ProjectsRegisterResult] + public let recents: [ProjectRecent]? public init( - projects: [ProjectsRegisterResult]) + projects: [ProjectsRegisterResult], + recents: [ProjectRecent]? = nil) { self.projects = projects + self.recents = recents } private enum CodingKeys: String, CodingKey { case projects + case recents } } @@ -18689,6 +18763,7 @@ public enum BoardCommand: Codable, Sendable { public enum GatewayErrorDetails: Codable, Sendable { case missingScope(MissingScopeErrorDetails) case mcpAppViewExpired(McpAppViewExpiredErrorDetails) + case userPrefsLimitExceeded(UserPrefsLimitExceededErrorDetails) case unknownAgentId(UnknownAgentIdErrorDetails) case wizardNotFound(WizardNotFoundErrorDetails) @@ -18706,6 +18781,7 @@ public enum GatewayErrorDetails: Codable, Sendable { switch self { case .missingScope(let value): value.code case .mcpAppViewExpired(let value): value.code + case .userPrefsLimitExceeded(let value): value.code case .unknownAgentId(let value): value.code case .wizardNotFound(let value): value.code } @@ -18731,6 +18807,7 @@ public enum GatewayErrorDetails: Codable, Sendable { switch discriminator { case "MISSING_SCOPE": self = try .missingScope(MissingScopeErrorDetails(from: decoder)) case "MCP_APP_VIEW_EXPIRED": self = try .mcpAppViewExpired(McpAppViewExpiredErrorDetails(from: decoder)) + case "USER_PREFS_LIMIT_EXCEEDED": self = try .userPrefsLimitExceeded(UserPrefsLimitExceededErrorDetails(from: decoder)) case "UNKNOWN_AGENT_ID": self = try .unknownAgentId(UnknownAgentIdErrorDetails(from: decoder)) case "WIZARD_NOT_FOUND": self = try .wizardNotFound(WizardNotFoundErrorDetails(from: decoder)) default: @@ -18746,6 +18823,7 @@ public enum GatewayErrorDetails: Codable, Sendable { switch self { case .missingScope(let value): try value.encode(to: encoder) case .mcpAppViewExpired(let value): try value.encode(to: encoder) + case .userPrefsLimitExceeded(let value): try value.encode(to: encoder) case .unknownAgentId(let value): try value.encode(to: encoder) case .wizardNotFound(let value): try value.encode(to: encoder) } @@ -18814,6 +18892,37 @@ public enum GatewaySuspendStatusResult: Codable, Sendable { } } +public enum ProjectRecent: Codable, Sendable { + case project(ProjectRecentProject) + case folder(ProjectRecentFolder) + + private enum CodingKeys: String, CodingKey { + case discriminator = "kind" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let discriminator = try container.decode(String.self, forKey: .discriminator) + switch discriminator { + case "project": self = try .project(ProjectRecentProject(from: decoder)) + case "folder": self = try .folder(ProjectRecentFolder(from: decoder)) + default: + throw DecodingError.dataCorruptedError( + forKey: .discriminator, + in: container, + debugDescription: "Unknown ProjectRecent discriminator value" + ) + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .project(let value): try value.encode(to: encoder) + case .folder(let value): try value.encode(to: encoder) + } + } +} + public enum UiCommand: Codable, Sendable { case split(UiSplitCommand) case closePane(UiClosePaneCommand) 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 8eff4ccc675d..2530bf2cdfaf 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":"ee0cb958c335eb884250ae457d04341f195802da7ea2aad4e47a27ce1f18d563","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} +{"contentHash":"ca54298c1a0adf9550f58fc13881d7e7f37e4c755a5f917a595bc4f0177d9e20","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 f2c025130530..e71e0617eeb7 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json @@ -1 +1 @@ -{"contentHash":"5f93fdf2cb78cffd5caba80bba029ec149ec391a9f2664634a15aba97df3b211","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} +{"contentHash":"00743220201e85945df77cc8e276a4842f93602c971e06459ea00dd029f063ff","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json index 6a91d20ab609..2ebf0bb184ef 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-runtime.json @@ -1 +1 @@ -{"contentHash":"b1cac8d9de2fd90003e1b10bc664ccee744b8e3679f791c9e87829431a81108d","entrypoint":"agent-runtime","importSpecifier":"openclaw/plugin-sdk/agent-runtime"} +{"contentHash":"ebd8c50c027f20ce91e23c1a75f79e2c30e433666efc3780df0bba49b2924b0d","entrypoint":"agent-runtime","importSpecifier":"openclaw/plugin-sdk/agent-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-core.json b/docs/.generated/plugin-sdk-api-baseline/channel-core.json index e10642b4ea88..e9a455ddcbb3 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-core.json @@ -1 +1 @@ -{"contentHash":"90c9c4d235765a9e4518b7aed2a23b932f2a7f595f87fb10283b2e60671828c9","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} +{"contentHash":"b0d043c2fc4d36023fa7bd6472c9b9cc5f0ee17ff4375d598e59fecb45cbe85c","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 513095a3f82f..0a71de14a90e 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":"1732d8d3efed8185724dcf3c69a23c84058252882626f87ae6e43f65b4c319f2","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} +{"contentHash":"555310d087ce5a7a801f6291ee837283eb182e9bf86f294e11dc0d4559c9c268","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json index 301f7b04cdf7..42eb6ba796e9 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-inbound.json @@ -1 +1 @@ -{"contentHash":"bdd1ca3b681b92bb24c2400ceffbb0512fad0c32494590791506336add02e9fa","entrypoint":"channel-inbound","importSpecifier":"openclaw/plugin-sdk/channel-inbound"} +{"contentHash":"ac04159e04d93a52c409435271df4dc6ff245fdb0744283d51c825c7fc1bdb77","entrypoint":"channel-inbound","importSpecifier":"openclaw/plugin-sdk/channel-inbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-message.json b/docs/.generated/plugin-sdk-api-baseline/channel-message.json index 42214cd418ea..21745c8b2d54 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-message.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-message.json @@ -1 +1 @@ -{"contentHash":"a2ba9a6efe6f4d1d6c83337aab405f4f0169b753e1f74ae8b12e607303e108ca","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} +{"contentHash":"08111748e2944e6bf1571eda856a46a00c813e2d90fd3da04a9c473b033bc38c","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 6e95d0d64f0a..9c191677ce42 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json @@ -1 +1 @@ -{"contentHash":"e78ecf9d259218143a0129e70b6f681e17128f6a5dbba45745cd089a71acf729","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} +{"contentHash":"4561d105811249b490dcc691888e082fbb5365b0652fee8e97d272c55f7c1eba","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-pairing.json b/docs/.generated/plugin-sdk-api-baseline/channel-pairing.json index 52230d7fe2cc..093fd653a095 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-pairing.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-pairing.json @@ -1 +1 @@ -{"contentHash":"b09818bcd98562155b66aafd6643b7487a4c267f662c1da87434b47a03446b4e","entrypoint":"channel-pairing","importSpecifier":"openclaw/plugin-sdk/channel-pairing"} +{"contentHash":"14f25b50aa79d723797f2656040af10a8d3298d1a433f4ec8428afcf4ab6e258","entrypoint":"channel-pairing","importSpecifier":"openclaw/plugin-sdk/channel-pairing"} 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 c04591e13376..d71fa17626da 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":"1c90991392e4a35f67cff1f7913cdc53920c74369d7018e18a8628aec1752caa","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} +{"contentHash":"8bef6c7c70abc4745029421fef29125646effa27bb8947541e0ca9bea4e2da9b","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-reply-pipeline.json b/docs/.generated/plugin-sdk-api-baseline/channel-reply-pipeline.json index d0ab2becf4fc..456a8961afef 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-reply-pipeline.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-reply-pipeline.json @@ -1 +1 @@ -{"contentHash":"3a94ba6e229f17850208f479e163eccdd0cbd0b01b6638e1feda06ba7c45b79f","entrypoint":"channel-reply-pipeline","importSpecifier":"openclaw/plugin-sdk/channel-reply-pipeline"} +{"contentHash":"1262cc14d9cb4c639ead54a2c2e4c1042cb836b73bdd9d3a39a9664ae04241e9","entrypoint":"channel-reply-pipeline","importSpecifier":"openclaw/plugin-sdk/channel-reply-pipeline"} diff --git a/docs/.generated/plugin-sdk-api-baseline/command-auth-native.json b/docs/.generated/plugin-sdk-api-baseline/command-auth-native.json index 42ea2f19b03e..3197d444a86a 100644 --- a/docs/.generated/plugin-sdk-api-baseline/command-auth-native.json +++ b/docs/.generated/plugin-sdk-api-baseline/command-auth-native.json @@ -1 +1 @@ -{"contentHash":"41c8a0c1c9454afec387fb980900d4795ef29f45a05a025aeb1602bb7739b7a8","entrypoint":"command-auth-native","importSpecifier":"openclaw/plugin-sdk/command-auth-native"} +{"contentHash":"4fa7e5cf0b6aadbaef2a7ad6dcf0cd732f0284a061e8006e6e1353daec6a4224","entrypoint":"command-auth-native","importSpecifier":"openclaw/plugin-sdk/command-auth-native"} diff --git a/docs/.generated/plugin-sdk-api-baseline/command-auth.json b/docs/.generated/plugin-sdk-api-baseline/command-auth.json index b2096f6f80d6..09af1c1af2b4 100644 --- a/docs/.generated/plugin-sdk-api-baseline/command-auth.json +++ b/docs/.generated/plugin-sdk-api-baseline/command-auth.json @@ -1 +1 @@ -{"contentHash":"59750b3a09074d7e6743f4f1ec4b9ee3623862521d6be196131ed50977a15539","entrypoint":"command-auth","importSpecifier":"openclaw/plugin-sdk/command-auth"} +{"contentHash":"e08dc22849e8bca609d45e73a004b3293e8610d105040b93118ab125978d8516","entrypoint":"command-auth","importSpecifier":"openclaw/plugin-sdk/command-auth"} diff --git a/docs/.generated/plugin-sdk-api-baseline/config-runtime.json b/docs/.generated/plugin-sdk-api-baseline/config-runtime.json index 1ac84629e2e8..7123ed1c0dbf 100644 --- a/docs/.generated/plugin-sdk-api-baseline/config-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/config-runtime.json @@ -1 +1 @@ -{"contentHash":"84cac1377fdac6d6665de5b8264a95c17b79823063420b21eb31596c5af7611e","entrypoint":"config-runtime","importSpecifier":"openclaw/plugin-sdk/config-runtime"} +{"contentHash":"4339bf4856bafcbf691b25df94a564a9b7bde09c041b9c0b31df3636a6e58462","entrypoint":"config-runtime","importSpecifier":"openclaw/plugin-sdk/config-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/core.json b/docs/.generated/plugin-sdk-api-baseline/core.json index b2a40dc253a0..4bb2418a5dda 100644 --- a/docs/.generated/plugin-sdk-api-baseline/core.json +++ b/docs/.generated/plugin-sdk-api-baseline/core.json @@ -1 +1 @@ -{"contentHash":"cdb1e0c1b560719ffa1f6dc3cf08106e28a7d845da9e64cbe03f65c02813a424","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} +{"contentHash":"140e69b3a97558ca94edd1fc83fc528e33565d0b3b7b16a2fb7dbf9bd9deb0be","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 dab286b37158..b0fdf422809c 100644 --- a/docs/.generated/plugin-sdk-api-baseline/discord.json +++ b/docs/.generated/plugin-sdk-api-baseline/discord.json @@ -1 +1 @@ -{"contentHash":"da25b80c0de9a00275687bf27958ef3d8161b6dd506bb917d422da73d1c08dd1","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} +{"contentHash":"af02ae51d5c0941991928b998ecac5584639e50912532082fa0d16fe0a40e43f","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 bac18fd06e94..6a38fc05d724 100644 --- a/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json @@ -1 +1 @@ -{"contentHash":"46ded5c5593969f06e7d8fef58d6a1c0d20f7deff515a386d3d03d7423d8f0c1","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"} +{"contentHash":"0b8f513b3563d19ec9fa306d79c645c27d25ede0e83c99c394dd90e121fb1e74","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 44ca07b3461a..1f2a5ddc6317 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":"78d5e1f6ab2f500c8f8c84818f360e758010d4c7195747f9c524a39630062e19","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} +{"contentHash":"de31ab324cc57e8f525cac7559813e83a434c3cb114124388558c0b35e094adc","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 190f999eacfb..3f6ff347022d 100644 --- a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json @@ -1 +1 @@ -{"contentHash":"6d7962425953ba1aa10d1eb84b14b076bb19ad65a408d4db8fafd5efea88ea4b","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} +{"contentHash":"f559df4c86dd30b151db7e159944df99f5870a8564b4ef07adfbff4826737aa4","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/model-session-runtime.json b/docs/.generated/plugin-sdk-api-baseline/model-session-runtime.json index 01af5f2a755c..de80d5630d4c 100644 --- a/docs/.generated/plugin-sdk-api-baseline/model-session-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/model-session-runtime.json @@ -1 +1 @@ -{"contentHash":"8c8acbb70d987d0470c65d2868d9a15f9677922e11ad97604ed0d7d3000a0132","entrypoint":"model-session-runtime","importSpecifier":"openclaw/plugin-sdk/model-session-runtime"} +{"contentHash":"b1b9afd14967ec92dad01bc0a00997b578b9c4ca0f8601597fc58b503fc13606","entrypoint":"model-session-runtime","importSpecifier":"openclaw/plugin-sdk/model-session-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/models-provider-runtime.json b/docs/.generated/plugin-sdk-api-baseline/models-provider-runtime.json index 293aef256b73..35a47c530eb3 100644 --- a/docs/.generated/plugin-sdk-api-baseline/models-provider-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/models-provider-runtime.json @@ -1 +1 @@ -{"contentHash":"56c07204664cddc24532e3f4e07e455e377164308ff85178aaa06f1efd96f532","entrypoint":"models-provider-runtime","importSpecifier":"openclaw/plugin-sdk/models-provider-runtime"} +{"contentHash":"cdc1b761783a3e135f413f08c0f107a7dd45f2f73d9f4b807244590445ff5343","entrypoint":"models-provider-runtime","importSpecifier":"openclaw/plugin-sdk/models-provider-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-command-runtime.json b/docs/.generated/plugin-sdk-api-baseline/plugin-command-runtime.json index cfea6bae200c..ea9fb528ce85 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-command-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-command-runtime.json @@ -1 +1 @@ -{"contentHash":"ff2abc97718e6bdd93ce215eab32e53ca0997b597ed4e387d14d441688ca80db","entrypoint":"plugin-command-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-command-runtime"} +{"contentHash":"71ae4ef997f64854896336411d8f89ac526e27f55477609000385958086d703d","entrypoint":"plugin-command-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-command-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json index ed0257227402..ef26c4bd0087 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json @@ -1 +1 @@ -{"contentHash":"54640c94f33c464b64c818dd51c0f03d1a40d802f72c8f48df94dfeae2e7b0c4","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} +{"contentHash":"9f9e1c49a46f04964615be21556834f4d7d6042aedef30812973efbc2af9871c","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 09213bd001fe..6d8bd87e913b 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json @@ -1 +1 @@ -{"contentHash":"d1f759c8bb3bdc2cc634c242cc97534ae7c00481717c1dad4dc286a4d6eb7ccc","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} +{"contentHash":"b1883cb0c123488b0d8f93934cc27dca32e349d17bab1119e0d273655b368c4a","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 0f9754a76c02..c09ea48f5eb0 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":"d1418ebe155611f8d4ed8c17b4e9eb089d7e05d7cce1a9513ebd1b0ba2a0f6c0","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} +{"contentHash":"7923737351da1ca1c0c0b3c3ce3d105a61739f633d27da4cc8b92c957ccf397f","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/reply-dispatch-runtime.json b/docs/.generated/plugin-sdk-api-baseline/reply-dispatch-runtime.json index fde64789d424..d2cfe93ae0eb 100644 --- a/docs/.generated/plugin-sdk-api-baseline/reply-dispatch-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/reply-dispatch-runtime.json @@ -1 +1 @@ -{"contentHash":"3d4bc42977575af9ec21f48099cb3e74a781839c08e6d3e30baec39f2009ce03","entrypoint":"reply-dispatch-runtime","importSpecifier":"openclaw/plugin-sdk/reply-dispatch-runtime"} +{"contentHash":"cec5c1bb21caf43610e93817539237330ba78d3ebbe7e21a4218914129a564c2","entrypoint":"reply-dispatch-runtime","importSpecifier":"openclaw/plugin-sdk/reply-dispatch-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/reply-runtime.json b/docs/.generated/plugin-sdk-api-baseline/reply-runtime.json index 1ec462d8c0b0..c7eeb7a97fe7 100644 --- a/docs/.generated/plugin-sdk-api-baseline/reply-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/reply-runtime.json @@ -1 +1 @@ -{"contentHash":"d07602091d9f19dc5a53f2df01557a1fb579ea4b298caf596ce9d4e6eb3bef8d","entrypoint":"reply-runtime","importSpecifier":"openclaw/plugin-sdk/reply-runtime"} +{"contentHash":"682fa22e552f9663f901c9928b49546cf73a9e91824923c794660672f8de1288","entrypoint":"reply-runtime","importSpecifier":"openclaw/plugin-sdk/reply-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/runtime-store.json b/docs/.generated/plugin-sdk-api-baseline/runtime-store.json index 03b6ffc1fe46..e5aac112620b 100644 --- a/docs/.generated/plugin-sdk-api-baseline/runtime-store.json +++ b/docs/.generated/plugin-sdk-api-baseline/runtime-store.json @@ -1 +1 @@ -{"contentHash":"6acb8e3118fe9f7a8bf0c3fa16838b0e9c8b69bf440b84c0757d4789a66950bc","entrypoint":"runtime-store","importSpecifier":"openclaw/plugin-sdk/runtime-store"} +{"contentHash":"5cae6135e687178f5b69ca692853ad99689d591545f180b0703ef79bb854456f","entrypoint":"runtime-store","importSpecifier":"openclaw/plugin-sdk/runtime-store"} diff --git a/docs/.generated/plugin-sdk-api-baseline/session-catalog.json b/docs/.generated/plugin-sdk-api-baseline/session-catalog.json index 4cf56be5d1c3..7da2a2587c80 100644 --- a/docs/.generated/plugin-sdk-api-baseline/session-catalog.json +++ b/docs/.generated/plugin-sdk-api-baseline/session-catalog.json @@ -1 +1 @@ -{"contentHash":"3b52fbfd0855f234f80dce3c062c8d0b48f18d512aa7a26223c9c62b8056848e","entrypoint":"session-catalog","importSpecifier":"openclaw/plugin-sdk/session-catalog"} +{"contentHash":"7bf0db396410e16fe263f012333b7d1d352c631a56b24bec6e9cdff4f3e97f76","entrypoint":"session-catalog","importSpecifier":"openclaw/plugin-sdk/session-catalog"} diff --git a/docs/.generated/plugin-sdk-api-baseline/session-store-runtime.json b/docs/.generated/plugin-sdk-api-baseline/session-store-runtime.json index 2aa3a56bb9c7..661dc195c215 100644 --- a/docs/.generated/plugin-sdk-api-baseline/session-store-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/session-store-runtime.json @@ -1 +1 @@ -{"contentHash":"3da3531231ad1e533b81a215f53d3feb555a3c73420bc6d5e13d170edb8dedcc","entrypoint":"session-store-runtime","importSpecifier":"openclaw/plugin-sdk/session-store-runtime"} +{"contentHash":"e1f5b6c7f7c9fe19fe73ec026c9577a93b3a39d024d6083f16914f601cb766af","entrypoint":"session-store-runtime","importSpecifier":"openclaw/plugin-sdk/session-store-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/skill-commands-runtime.json b/docs/.generated/plugin-sdk-api-baseline/skill-commands-runtime.json index e2fba7e586d9..86d1ef437279 100644 --- a/docs/.generated/plugin-sdk-api-baseline/skill-commands-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/skill-commands-runtime.json @@ -1 +1 @@ -{"contentHash":"4339583e3d74b1607bd9585a7824ecb446532010b258b86d1a31343ea0b453e8","entrypoint":"skill-commands-runtime","importSpecifier":"openclaw/plugin-sdk/skill-commands-runtime"} +{"contentHash":"1ffb1002273fa4523e0bcc5c49183627d32584c32f3cade613a9de28db8ebb45","entrypoint":"skill-commands-runtime","importSpecifier":"openclaw/plugin-sdk/skill-commands-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json index add298b15731..40a20b7acb77 100644 --- a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json +++ b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json @@ -1 +1 @@ -{"contentHash":"469aaa1db543b1d6fc966fb70abdee57b71149fc9330f5c0d1736ac4c40ce8c9","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} +{"contentHash":"b2281fac79519417397a7670e25392c7e7e592a84e3ed00459b947b6bef2955b","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 7fed32fe3c22..a3ad46011276 100644 --- a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json +++ b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json @@ -1 +1 @@ -{"contentHash":"725553b0e3c48d879932cfe9f91b3b8e2424eb13fba58f3b0f1b501678fbbdb5","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} +{"contentHash":"14ae7f9d841c6c4e2702239ed2aeb126f89039df8434bf12773a495486a04ff4","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} diff --git a/docs/.generated/sqlite-session-transcript-schema-baseline.sha256 b/docs/.generated/sqlite-session-transcript-schema-baseline.sha256 index 53accfeb1c8d..1d13df5ded6f 100644 --- a/docs/.generated/sqlite-session-transcript-schema-baseline.sha256 +++ b/docs/.generated/sqlite-session-transcript-schema-baseline.sha256 @@ -1 +1 @@ -d057850033d603e2aa97a93247e2d4cd7053e6c8ed66f2c419ba44cd765816a9 sqlite-session-transcript-schema-baseline.sql +b7ce196c35d975dfefee75416c03573f94da00ec35495db8d3c22ab4a5bc72b7 sqlite-session-transcript-schema-baseline.sql diff --git a/docs/concepts/multi-user.md b/docs/concepts/multi-user.md index 05a730ac566c..68934eec4e49 100644 --- a/docs/concepts/multi-user.md +++ b/docs/concepts/multi-user.md @@ -29,6 +29,12 @@ The web app keeps ownership and presence visually distinct: When fewer than two distinct creators appear in the loaded session list, OpenClaw hides all ownership and person-filter chrome. A single-user gateway therefore looks unchanged. +## Identity-scoped convenience state + +When a connection has a durable Gateway profile, new-session preferences and picker recents follow that person across browsers. Preferences remain per agent, while recents are derived only from sessions that person created. Connections without a durable identity keep browser-local preferences and derive recents from the loaded session roster. + +This state improves continuity; it is not an authorization or isolation boundary. Operator scopes still control actions, and a shared Gateway remains one trust domain for sessions, tools, credentials, and files. + ## Drafts Start a session as a draft to keep work in progress out of teammates' sidebars until you publish it. Drafts are never hidden from admins, who see other people's drafts with a faded ghost marker. This is a coordination feature, not a security boundary. diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 48e4d3f69b37..2447aad14c3e 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -110,6 +110,12 @@ An already paired administrator can create the iOS/Android connection QR without Creating a setup code requires `operator.admin`; the button is disabled for sessions without it. A setup code contains a short-lived bootstrap credential, so treat the QR and copied code like a password while they are valid. For remote pairing, the Gateway must resolve to `wss://` (for example, through Tailscale Serve/Funnel); plain `ws://` is limited to loopback and private LAN addresses. See [Pairing](/channels/pairing#pair-from-the-control-ui-recommended) for the full security and fallback details. +## New-session preferences and recents + +For connections with a durable user profile, the Gateway stores each agent's latest folder, worktree, model, and thinking choices. The new-session picker also shows recent projects and folders derived only from sessions created by that profile. These conveniences follow the person across browsers; they do not grant access to a project or path. + +On the first identified connection, the Control UI uploads existing browser-local new-session preferences only when the Gateway has no such preferences yet. Later changes write to the Gateway first and then update the browser mirror. Connections without a durable identity continue using browser-local preferences and the loaded session roster for recents. + ## Personal identity (browser-local) The Control UI supports a per-browser personal identity (display name and avatar) attached to outgoing messages, for attribution in shared sessions. It lives in browser storage, scoped to the current browser profile, and is not synced to other devices or persisted server-side beyond the normal transcript authorship metadata on messages you send. Clearing site data or switching browsers resets it to empty. diff --git a/packages/gateway-protocol/src/gateway-error-details.ts b/packages/gateway-protocol/src/gateway-error-details.ts index 7f144bafda07..1938fe45fcc7 100644 --- a/packages/gateway-protocol/src/gateway-error-details.ts +++ b/packages/gateway-protocol/src/gateway-error-details.ts @@ -25,6 +25,7 @@ export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes]; export const GatewayErrorDetailCodes = { MISSING_SCOPE: "MISSING_SCOPE", MCP_APP_VIEW_EXPIRED: "MCP_APP_VIEW_EXPIRED", + USER_PREFS_LIMIT_EXCEEDED: "USER_PREFS_LIMIT_EXCEEDED", SESSION_COMPANION_BUSY: "SESSION_COMPANION_BUSY", UNKNOWN_AGENT_ID: "UNKNOWN_AGENT_ID", WIZARD_NOT_FOUND: "WIZARD_NOT_FOUND", @@ -41,6 +42,13 @@ export type McpAppViewExpiredErrorDetails = { code: typeof GatewayErrorDetailCodes.MCP_APP_VIEW_EXPIRED; }; +/** Per-profile preference quota details returned by users.prefs.set. */ +export type UserPrefsLimitExceededErrorDetails = { + code: typeof GatewayErrorDetailCodes.USER_PREFS_LIMIT_EXCEEDED; + limit: number; + currentCount: number; +}; + /** Unknown agent details carried by agent-scoped method validation failures. */ export type UnknownAgentIdErrorDetails = { code: typeof GatewayErrorDetailCodes.UNKNOWN_AGENT_ID; @@ -56,6 +64,7 @@ export type WizardNotFoundErrorDetails = { export type GatewayErrorDetails = | MissingScopeErrorDetails | McpAppViewExpiredErrorDetails + | UserPrefsLimitExceededErrorDetails | UnknownAgentIdErrorDetails | WizardNotFoundErrorDetails; diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index 43f244f210eb..ee215426fa76 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -21,6 +21,7 @@ export type { GatewayErrorDetails, McpAppViewExpiredErrorDetails, MissingScopeErrorDetails, + UserPrefsLimitExceededErrorDetails, WizardNotFoundErrorDetails, } from "./schema/error-codes.js"; export * from "./schema/board.js"; @@ -72,6 +73,7 @@ export { ErrorShapeSchema, GatewayErrorDetailsSchema, MissingScopeErrorDetailsSchema, + UserPrefsLimitExceededErrorDetailsSchema, WizardNotFoundErrorDetailsSchema, WorkerAdmissionFailureReasonSchema, WorkerAdmissionHandshakeSchema, @@ -344,6 +346,10 @@ export { UsersLinkEmailResultSchema, UsersListParamsSchema, UsersListResultSchema, + UsersPrefsGetParamsSchema, + UsersPrefsGetResultSchema, + UsersPrefsSetParamsSchema, + UsersPrefsSetResultSchema, UsersSelfParamsSchema, UsersSelfResultSchema, UsersSetAvatarParamsSchema, @@ -631,6 +637,7 @@ export { TickEventSchema, ShutdownEventSchema, ProjectRecordSchema, + ProjectRecentSchema, ProjectsListParamsSchema, ProjectsListResultSchema, ProjectsRegisterParamsSchema, diff --git a/packages/gateway-protocol/src/schema/error-codes.ts b/packages/gateway-protocol/src/schema/error-codes.ts index 32fc7d529fd9..5e0aa6526d58 100644 --- a/packages/gateway-protocol/src/schema/error-codes.ts +++ b/packages/gateway-protocol/src/schema/error-codes.ts @@ -17,6 +17,7 @@ export { type GatewayErrorDetails, type McpAppViewExpiredErrorDetails, type MissingScopeErrorDetails, + type UserPrefsLimitExceededErrorDetails, type UnknownAgentIdErrorDetails, type WizardNotFoundErrorDetails, isMcpAppViewExpiredError, @@ -35,6 +36,12 @@ export const McpAppViewExpiredErrorDetailsSchema = closedObject({ code: Type.Literal(GatewayErrorDetailCodes.MCP_APP_VIEW_EXPIRED), }); +export const UserPrefsLimitExceededErrorDetailsSchema = closedObject({ + code: Type.Literal(GatewayErrorDetailCodes.USER_PREFS_LIMIT_EXCEEDED), + limit: Type.Integer({ minimum: 1 }), + currentCount: Type.Integer({ minimum: 0 }), +}); + export const UnknownAgentIdErrorDetailsSchema = closedObject({ code: Type.Literal(GatewayErrorDetailCodes.UNKNOWN_AGENT_ID), agentId: NonEmptyString, @@ -48,6 +55,7 @@ export const WizardNotFoundErrorDetailsSchema = closedObject({ export const GatewayErrorDetailsSchema = Type.Union([ MissingScopeErrorDetailsSchema, McpAppViewExpiredErrorDetailsSchema, + UserPrefsLimitExceededErrorDetailsSchema, UnknownAgentIdErrorDetailsSchema, WizardNotFoundErrorDetailsSchema, ]); diff --git a/packages/gateway-protocol/src/schema/projects.test.ts b/packages/gateway-protocol/src/schema/projects.test.ts index 9d8d54722520..f8237d577379 100644 --- a/packages/gateway-protocol/src/schema/projects.test.ts +++ b/packages/gateway-protocol/src/schema/projects.test.ts @@ -39,8 +39,13 @@ describe("project protocol schemas", () => { source: "registered", }, ], + recents: [ + { kind: "project", projectId: "openclaw", displayName: "OpenClaw" }, + { kind: "folder", folder: "/repo/scratch", displayName: "scratch" }, + ], }), ).toBe(true); + expect(Value.Check(ProjectsListResultSchema, { projects: [] })).toBe(true); }); 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 e5e0157ba754..fe899b662f30 100644 --- a/packages/gateway-protocol/src/schema/projects.ts +++ b/packages/gateway-protocol/src/schema/projects.ts @@ -26,9 +26,28 @@ export const ProjectRecordSchema = closedObject({ agentId: Type.Optional(NonEmptyString), }); +export const ProjectRecentProjectSchema = closedObject({ + kind: Type.Literal("project"), + projectId: NonEmptyString, + displayName: NonEmptyString, +}); + +export const ProjectRecentFolderSchema = closedObject({ + kind: Type.Literal("folder"), + folder: NonEmptyString, + displayName: NonEmptyString, + execNode: Type.Optional(NonEmptyString), +}); + +export const ProjectRecentSchema = Type.Union([ + ProjectRecentProjectSchema, + ProjectRecentFolderSchema, +]); + export const ProjectsListParamsSchema = closedObject({}); export const ProjectsListResultSchema = closedObject({ projects: Type.Array(ProjectRecordSchema), + recents: Type.Optional(Type.Array(ProjectRecentSchema, { maxItems: 8 })), }); export const ProjectsRegisterParamsSchema = closedObject({ @@ -41,6 +60,7 @@ export const ProjectsRemoveParamsSchema = closedObject({ id: StoredProjectIdSche export const ProjectsRemoveResultSchema = closedObject({ removed: Type.Boolean() }); export type ProjectRecord = Static; +export type ProjectRecent = 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 6c8c0de015cf..882d4b3e15ea 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 @@ -46,6 +46,9 @@ export const AgentControlProtocolSchemas = { AgentWaitParams: agent.AgentWaitParamsSchema, WakeParams: agent.WakeParamsSchema, ProjectRecord: projects.ProjectRecordSchema, + ProjectRecentFolder: projects.ProjectRecentFolderSchema, + ProjectRecentProject: projects.ProjectRecentProjectSchema, + ProjectRecent: projects.ProjectRecentSchema, ProjectsListParams: projects.ProjectsListParamsSchema, ProjectsListResult: projects.ProjectsListResultSchema, ProjectsRegisterParams: projects.ProjectsRegisterParamsSchema, diff --git a/packages/gateway-protocol/src/schema/protocol-schema-fragment-transport.ts b/packages/gateway-protocol/src/schema/protocol-schema-fragment-transport.ts index b464983adbbd..79e1264b2266 100644 --- a/packages/gateway-protocol/src/schema/protocol-schema-fragment-transport.ts +++ b/packages/gateway-protocol/src/schema/protocol-schema-fragment-transport.ts @@ -33,4 +33,5 @@ export const TransportProtocolSchemas = { GatewaySuspendStatusResult: gatewaySuspend.GatewaySuspendStatusResultSchema, GatewaySuspendResumeParams: gatewaySuspend.GatewaySuspendResumeParamsSchema, GatewaySuspendResumeResult: gatewaySuspend.GatewaySuspendResumeResultSchema, + UserPrefsLimitExceededErrorDetails: errorCodes.UserPrefsLimitExceededErrorDetailsSchema, } as const; diff --git a/packages/gateway-protocol/src/schema/users-prefs.test.ts b/packages/gateway-protocol/src/schema/users-prefs.test.ts new file mode 100644 index 000000000000..7e377c600b44 --- /dev/null +++ b/packages/gateway-protocol/src/schema/users-prefs.test.ts @@ -0,0 +1,54 @@ +import { Value } from "typebox/value"; +import { describe, expect, it } from "vitest"; +import { + GatewayErrorDetailCodes, + GatewayErrorDetailsSchema, + UserPrefsLimitExceededErrorDetailsSchema, + UsersPrefsGetResultSchema, + UsersPrefsSetResultSchema, + validateUsersPrefsGetParams, + validateUsersPrefsSetParams, +} from "../index.js"; + +describe("user preference protocol schemas", () => { + it("bounds self-scoped preference requests", () => { + const entries = Object.fromEntries( + Array.from({ length: 32 }, (_, index) => [`key-${index}`, { index }]), + ); + expect(validateUsersPrefsGetParams({})).toBe(true); + expect(validateUsersPrefsGetParams({ keys: Object.keys(entries) })).toBe(true); + expect(validateUsersPrefsSetParams({ entries })).toBe(true); + expect(validateUsersPrefsSetParams({ entries: { deleted: null } })).toBe(true); + expect(validateUsersPrefsGetParams({ keys: [...Object.keys(entries), "overflow"] })).toBe( + false, + ); + expect(validateUsersPrefsGetParams({ keys: ["same", "same"] })).toBe(false); + expect(validateUsersPrefsSetParams({ entries: { ...entries, overflow: true } })).toBe(false); + }); + + it("exposes typed per-profile quota details", () => { + expect( + Value.Check(GatewayErrorDetailsSchema, { + code: GatewayErrorDetailCodes.USER_PREFS_LIMIT_EXCEEDED, + limit: 128, + currentCount: 128, + }), + ).toBe(true); + expect( + Value.Check(UserPrefsLimitExceededErrorDetailsSchema, { + code: GatewayErrorDetailCodes.USER_PREFS_LIMIT_EXCEEDED, + limit: 128, + currentCount: 128, + }), + ).toBe(true); + }); + + it("keeps no-identity results distinct from successful values", () => { + expect(Value.Check(UsersPrefsGetResultSchema, { status: "no_durable_identity" })).toBe(true); + expect( + Value.Check(UsersPrefsGetResultSchema, { status: "ok", entries: { theme: "claw" } }), + ).toBe(true); + expect(Value.Check(UsersPrefsSetResultSchema, { status: "ok" })).toBe(true); + expect(Value.Check(UsersPrefsSetResultSchema, { status: "no_durable_identity" })).toBe(true); + }); +}); diff --git a/packages/gateway-protocol/src/schema/users.ts b/packages/gateway-protocol/src/schema/users.ts index 69b09e49e491..bec2b27763bd 100644 --- a/packages/gateway-protocol/src/schema/users.ts +++ b/packages/gateway-protocol/src/schema/users.ts @@ -4,8 +4,17 @@ import { Type } from "typebox"; import { closedObject } from "./closed-object.js"; import { NonEmptyString } from "./primitives.js"; +export const USER_PREFS_ENTRY_LIMIT = 32; +export const USER_PREFS_PROFILE_KEY_LIMIT = 128; +export const USER_PREFS_VALUE_BYTES = 4 * 1024; + const UserProfileIdSchema = Type.String({ minLength: 1, maxLength: 128 }); const UserProfileDisplayNameSchema = Type.String({ maxLength: 256 }); +const UserPreferenceKeySchema = Type.String({ pattern: "^.{1,256}$" }); +const UserPreferenceEntriesSchema = Type.Record(UserPreferenceKeySchema, Type.Unknown()); +const UserPreferenceSetEntriesSchema = Type.Record(UserPreferenceKeySchema, Type.Unknown(), { + maxProperties: USER_PREFS_ENTRY_LIMIT, +}); export const UserProfileAvatarMimeSchema = Type.Union([ Type.Literal("image/png"), Type.Literal("image/jpeg"), @@ -51,6 +60,24 @@ export const UsersSetAvatarResultSchema = closedObject({ avatarRevision: NonEmptyString, }); +export const UsersPrefsGetParamsSchema = closedObject({ + keys: Type.Optional( + Type.Array(UserPreferenceKeySchema, { + maxItems: USER_PREFS_ENTRY_LIMIT, + uniqueItems: true, + }), + ), +}); +export const UsersPrefsGetResultSchema = Type.Union([ + closedObject({ status: Type.Literal("ok"), entries: UserPreferenceEntriesSchema }), + closedObject({ status: Type.Literal("no_durable_identity") }), +]); +export const UsersPrefsSetParamsSchema = closedObject({ entries: UserPreferenceSetEntriesSchema }); +export const UsersPrefsSetResultSchema = Type.Union([ + closedObject({ status: Type.Literal("ok") }), + closedObject({ status: Type.Literal("no_durable_identity") }), +]); + export type UserProfile = Static; export type UsersListParams = Static; export type UsersListResult = Static; @@ -62,3 +89,7 @@ export type UsersSetDisplayNameParams = Static; export type UsersSetAvatarParams = Static; export type UsersSetAvatarResult = Static; +export type UsersPrefsGetParams = Static; +export type UsersPrefsGetResult = Static; +export type UsersPrefsSetParams = Static; +export type UsersPrefsSetResult = Static; diff --git a/packages/gateway-protocol/src/validator-registry.ts b/packages/gateway-protocol/src/validator-registry.ts index ead7c8875df5..368c4415c00e 100644 --- a/packages/gateway-protocol/src/validator-registry.ts +++ b/packages/gateway-protocol/src/validator-registry.ts @@ -94,6 +94,8 @@ export const validateAuditRunInspectParams = compile( export const validateExecutionIdentityContextV1 = compile(S.ExecutionIdentityContextV1Schema); export const validateAuditListParams = compile(S.AuditListParamsSchema); export const validateUsersListParams = compile(S.UsersListParamsSchema); +export const validateUsersPrefsGetParams = compile(S.UsersPrefsGetParamsSchema); +export const validateUsersPrefsSetParams = compile(S.UsersPrefsSetParamsSchema); export const validateUsersSelfParams = compile(S.UsersSelfParamsSchema); export const validateUsersSelfResult = compile(S.UsersSelfResultSchema); export const validateUsersLinkEmailParams = compile(S.UsersLinkEmailParamsSchema); diff --git a/scripts/protocol-gen-swift.ts b/scripts/protocol-gen-swift.ts index bdd572306acc..d20837ec79a6 100644 --- a/scripts/protocol-gen-swift.ts +++ b/scripts/protocol-gen-swift.ts @@ -631,6 +631,7 @@ function emitDiscriminatedUnionCompatibility(name: string): string[] { " switch self {", " case .missingScope(let value): value.code", " case .mcpAppViewExpired(let value): value.code", + " case .userPrefsLimitExceeded(let value): value.code", " case .unknownAgentId(let value): value.code", " case .wizardNotFound(let value): value.code", " }", diff --git a/src/config/sessions/session-accessor.sqlite-entry-store.ts b/src/config/sessions/session-accessor.sqlite-entry-store.ts index 5d2b62da610e..6fb4c5956301 100644 --- a/src/config/sessions/session-accessor.sqlite-entry-store.ts +++ b/src/config/sessions/session-accessor.sqlite-entry-store.ts @@ -436,6 +436,7 @@ function clearSqliteSessionEntryPreservingWindows( created_via: null, created_actor_type: null, created_actor_id: null, + project_id: null, parent_session_key: null, spawned_by: null, fork_source_session_key: null, @@ -669,6 +670,7 @@ export function writeSessionEntry( created_via: sessionNode.created_via, created_actor_type: sessionNode.created_actor_type, created_actor_id: sessionNode.created_actor_id, + project_id: sessionNode.project_id, parent_session_key: sessionNode.parent_session_key, spawned_by: sessionNode.spawned_by, fork_source_session_key: sessionNode.fork_source_session_key, diff --git a/src/config/sessions/session-accessor.sqlite-session-row.ts b/src/config/sessions/session-accessor.sqlite-session-row.ts index 617507fdce25..fad46fc8de4f 100644 --- a/src/config/sessions/session-accessor.sqlite-session-row.ts +++ b/src/config/sessions/session-accessor.sqlite-session-row.ts @@ -102,6 +102,7 @@ export function bindSessionNode(params: { created_actor_type: normalizeSqliteCreatedActorType(actor?.type) ?? (legacyActorId ? "human" : null), created_actor_id: normalizeText(actor?.id) ?? legacyActorId, + project_id: normalizeText(params.entry.projectId), parent_session_key: normalizeText(params.entry.parentSessionKey) ?? normalizeText(params.entry.spawnedBy), spawned_by: normalizeText(params.entry.spawnedBy), diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index e7d5b448a089..186ceb78ae1e 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -367,6 +367,8 @@ type SessionEntryCore = SessionRestartRecoveryState & * creation and cleared together when a plain New Chat detaches the checkout. */ worktree?: { id: string; branch: string; repoRoot: string }; + /** Project registry id selected when this logical session node was created. */ + projectId?: string; /** Explicit parent session linkage for dashboard-created child sessions. */ parentSessionKey?: string; /** Exact parent incarnation captured when this child was created. */ @@ -762,6 +764,9 @@ function mergeSessionEntryWithPolicy( if (existing.createdAt !== undefined) { next.createdAt = existing.createdAt; } + if (existing.projectId !== undefined) { + next.projectId = existing.projectId; + } if (existing.forkSource !== undefined) { next.forkSource = existing.forkSource; } diff --git a/src/gateway/method-scopes.test.ts b/src/gateway/method-scopes.test.ts index c0fe6020f003..a91c214c8de1 100644 --- a/src/gateway/method-scopes.test.ts +++ b/src/gateway/method-scopes.test.ts @@ -75,6 +75,8 @@ describe("method scope resolution", () => { ["worktrees.branches", ["operator.write"]], ["worktrees.create", ["operator.admin"]], ["projects.list", ["operator.read"]], + ["users.prefs.get", ["operator.read"]], + ["users.prefs.set", ["operator.write"]], ["projects.register", ["operator.admin"]], ["projects.remove", ["operator.admin"]], ["sessions.groups.list", ["operator.read"]], diff --git a/src/gateway/methods/core-descriptors.since.test.ts b/src/gateway/methods/core-descriptors.since.test.ts index eacee134b16c..fc2837959460 100644 --- a/src/gateway/methods/core-descriptors.since.test.ts +++ b/src/gateway/methods/core-descriptors.since.test.ts @@ -92,6 +92,8 @@ const CURRENT_TRAIN_METHODS = [ "secrets.store.list", "secrets.store.set", "secrets.store.delete", + "users.prefs.get", + "users.prefs.set", ] as const; describe("core gateway method release trains", () => { diff --git a/src/gateway/methods/core-descriptors.ts b/src/gateway/methods/core-descriptors.ts index 6956d1838840..9190c6649aca 100644 --- a/src/gateway/methods/core-descriptors.ts +++ b/src/gateway/methods/core-descriptors.ts @@ -502,6 +502,9 @@ const CORE_GATEWAY_METHOD_SPECS = [ ["secrets.store.list", null, "operator.admin", "2026.8"], ["secrets.store.set", null, "operator.admin", "2026.8", { controlPlaneWrite: true }], ["secrets.store.delete", null, "operator.admin", "2026.8", { controlPlaneWrite: true }], + // Self-scoped preferences append so every older advertised index remains stable. + ["users.prefs.get", "users", "operator.read", "2026.8"], + ["users.prefs.set", "users", "operator.write", "2026.8"], ] as const satisfies readonly CoreGatewayMethodSpecRow[]; export type CoreGatewayHandlerFamily = Exclude<(typeof CORE_GATEWAY_METHOD_SPECS)[number][1], null>; diff --git a/src/gateway/server-methods-list.test.ts b/src/gateway/server-methods-list.test.ts index fb7ade43b489..e597c5389247 100644 --- a/src/gateway/server-methods-list.test.ts +++ b/src/gateway/server-methods-list.test.ts @@ -66,7 +66,7 @@ describe("listGatewayMethods", () => { }); it("appends new methods after model probing without shifting older method indices", () => { - expect(listGatewayMethods().slice(-42)).toEqual([ + expect(listGatewayMethods().slice(-44)).toEqual([ "models.probe", "migrations.memory.plan", "migrations.memory.apply", @@ -109,6 +109,8 @@ describe("listGatewayMethods", () => { "secrets.store.list", "secrets.store.set", "secrets.store.delete", + "users.prefs.get", + "users.prefs.set", ]); const methods = listGatewayMethods(); expect(methods.indexOf("node.pluginSurface.refresh")).toBe( @@ -200,7 +202,7 @@ describe("listGatewayMethods", () => { "exec.approval.get", ]); expect(methods).toContain("tts.speak"); - expect(coreMethods.slice(-49)).toEqual([ + expect(coreMethods.slice(-51)).toEqual([ "sessions.catalog.continue", "sessions.catalog.archive", "approval.get", @@ -250,6 +252,8 @@ describe("listGatewayMethods", () => { "secrets.store.list", "secrets.store.set", "secrets.store.delete", + "users.prefs.get", + "users.prefs.set", ]); expect(methods.indexOf("approval.get")).toBeGreaterThan(methods.indexOf("tts.speak")); expect(methods.indexOf("approval.resolve")).toBe(methods.indexOf("approval.get") + 1); @@ -271,6 +275,8 @@ describe("listGatewayMethods", () => { ); expect(methods.indexOf("secrets.store.set")).toBe(methods.indexOf("secrets.store.list") + 1); expect(methods.indexOf("secrets.store.delete")).toBe(methods.indexOf("secrets.store.set") + 1); + expect(methods.indexOf("users.prefs.get")).toBe(methods.indexOf("secrets.store.delete") + 1); + expect(methods.indexOf("users.prefs.set")).toBe(methods.indexOf("users.prefs.get") + 1); }); it("advertises the versioned Talk session RPCs", () => { diff --git a/src/gateway/server-methods/projects.test.ts b/src/gateway/server-methods/projects.test.ts index a38029d9af86..59f05f5d6840 100644 --- a/src/gateway/server-methods/projects.test.ts +++ b/src/gateway/server-methods/projects.test.ts @@ -3,8 +3,10 @@ import fs from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; import { expect, test } from "vitest"; +import { replaceSessionEntrySync } from "../../config/sessions/session-accessor.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { registerProjectRegistry } 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"; @@ -35,6 +37,7 @@ async function invokeProjectMethod( params: Record, cfg = {}, scopes: string[] = ["operator.write"], + profileId?: string, ) { const capture: { result: { @@ -50,7 +53,10 @@ async function invokeProjectMethod( capture.result = { ok, payload, error }; }, context: { getRuntimeConfig: () => cfg as OpenClawConfig } as never, - client: { connect: { scopes } } as never, + client: { + connect: { scopes }, + ...(profileId ? { authenticatedUserProfile: { profileId } } : {}), + } as never, isWebchatConnect: () => false, }); return capture.result; @@ -141,3 +147,85 @@ test("projects.remove returns INVALID_REQUEST for an unknown id", async () => { await state.cleanup(); } }); + +test("projects.list returns only the caller's deterministic resolved recents", async () => { + const state = await createOpenClawTestState({ layout: "state-only", prefix: "projects-rpc-" }); + try { + const repo = await initializeRepository(state.root); + const project = await registerProjectRegistry({ path: repo, name: "Registered" }); + const sourceProfile = ensureProfileForEmail("source@example.test"); + const targetProfile = ensureProfileForEmail("target@example.test"); + const actor = { type: "human" as const, id: sourceProfile.id }; + const entries: Array<{ + key: string; + updatedAt: number; + projectId?: string; + spawnedCwd?: string; + }> = [ + { key: "agent:main:a", updatedAt: 500, projectId: project.id }, + { key: "agent:main:b", updatedAt: 500, projectId: project.id }, + { key: "agent:main:c", updatedAt: 400, projectId: "stale", spawnedCwd: "/work/scratch" }, + ...Array.from({ length: 8 }, (_, index) => ({ + key: `agent:main:folder-${index}`, + updatedAt: 300 - index, + spawnedCwd: `/work/folder-${index}`, + })), + ]; + for (const entry of entries) { + replaceSessionEntrySync( + { agentId: "main", sessionKey: entry.key }, + { + sessionId: `session-${entry.key.split(":").at(-1)}`, + updatedAt: entry.updatedAt, + createdActor: actor, + ...(entry.projectId ? { projectId: entry.projectId } : {}), + ...(entry.spawnedCwd ? { spawnedCwd: entry.spawnedCwd } : {}), + }, + ); + } + replaceSessionEntrySync( + { agentId: "main", sessionKey: "agent:main:other" }, + { + sessionId: "session-other", + updatedAt: 1_000, + createdActor: { type: "human", id: "profile-bob" }, + spawnedCwd: "/work/private-bob", + }, + ); + const cfg = { agents: { list: [{ id: "main", default: true, workspace: "/workspace" }] } }; + linkEmail("source@example.test", targetProfile.id); + const readResult = await invokeProjectMethod( + "projects.list", + {}, + cfg, + ["operator.read"], + targetProfile.id, + ); + if (!readResult?.payload) { + throw new Error("projects.list did not return recents"); + } + expect((readResult.payload as { recents?: unknown[] }).recents).toEqual([ + { kind: "project", projectId: project.id, displayName: "Registered" }, + ]); + const writeResult = await invokeProjectMethod( + "projects.list", + {}, + cfg, + ["operator.write"], + targetProfile.id, + ); + expect((writeResult?.payload as { recents?: unknown[] } | undefined)?.recents).toEqual([ + { kind: "project", projectId: project.id, displayName: "Registered" }, + { kind: "folder", folder: "/work/scratch", displayName: "scratch" }, + ...Array.from({ length: 6 }, (_, index) => ({ + kind: "folder", + folder: `/work/folder-${index}`, + displayName: `folder-${index}`, + })), + ]); + const anonymous = await invokeProjectMethod("projects.list", {}, cfg, ["operator.read"]); + expect(anonymous?.payload).not.toHaveProperty("recents"); + } finally { + await state.cleanup(); + } +}); diff --git a/src/gateway/server-methods/projects.ts b/src/gateway/server-methods/projects.ts index b7b31235375c..f637e50a1ef1 100644 --- a/src/gateway/server-methods/projects.ts +++ b/src/gateway/server-methods/projects.ts @@ -1,6 +1,9 @@ +import path from "node:path"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { ErrorCodes, errorShape, + type ProjectRecent, validateProjectsListParams, validateProjectsRegisterParams, validateProjectsRemoveParams, @@ -12,19 +15,119 @@ import { registerProjectRegistry, removeProjectRegistry, } from "../../projects/project-registry.js"; +import { parseAgentSessionKey } from "../../routing/session-key.js"; +import { listProfiles, resolveUserProfileId } from "../../state/user-profiles.js"; import { WRITE_SCOPE, authorizeOperatorScopesForRequiredScope } from "../method-scopes.js"; +import { loadCombinedSessionStoreForGatewayCore } from "../session-utils.js"; import type { GatewayRequestHandlers } from "./types.js"; import { assertValidParams } from "./validation.js"; +type ProjectRegistryEntry = ReturnType[number]; + +function folderDisplayName(folder: string): string { + const trimmed = folder.replace(/[\\/]+$/u, ""); + return path.posix.basename(trimmed) || path.win32.basename(trimmed) || folder; +} + +function resolvePathProject( + projects: readonly ProjectRegistryEntry[], + folder: string, + sessionKey: string, +): ProjectRegistryEntry | undefined { + const sessionAgentId = parseAgentSessionKey(sessionKey)?.agentId; + return projects + .filter((project) => project.repoRoot === folder) + .toSorted((left, right) => { + const rank = (project: ProjectRegistryEntry) => + project.source === "workspace" && project.agentId === sessionAgentId + ? 0 + : project.source !== "workspace" + ? 1 + : 2; + return rank(left) - rank(right) || left.id.localeCompare(right.id); + })[0]; +} + +function listProjectRecents( + cfg: Parameters[0], + profileIds: ReadonlySet, + projects: readonly ProjectRegistryEntry[], +): ProjectRecent[] { + const store = loadCombinedSessionStoreForGatewayCore(cfg, { projection: "list" }).store; + const candidates = Object.entries(store) + .filter( + ([, entry]) => + entry.createdActor?.type === "human" && + Boolean(entry.createdActor.id && profileIds.has(entry.createdActor.id)), + ) + .toSorted( + ([leftKey, left], [rightKey, right]) => + (right.updatedAt ?? 0) - (left.updatedAt ?? 0) || leftKey.localeCompare(rightKey), + ); + const projectsById = new Map(projects.map((project) => [project.id, project])); + const seen = new Set(); + const recents: ProjectRecent[] = []; + for (const [sessionKey, entry] of candidates) { + const projectId = normalizeOptionalString(entry.projectId); + const explicitProject = projectId ? projectsById.get(projectId) : undefined; + const worktreeRoot = normalizeOptionalString(entry.worktree?.repoRoot); + const spawnedCwd = normalizeOptionalString(entry.spawnedCwd); + const execCwd = normalizeOptionalString(entry.execCwd); + const folder = worktreeRoot ?? spawnedCwd ?? execCwd; + const project = + explicitProject ?? (folder ? resolvePathProject(projects, folder, sessionKey) : undefined); + const key = project + ? `project:${project.id}` + : folder + ? `folder:${normalizeOptionalString(entry.execNode) ?? ""}\0${folder}` + : undefined; + if (!key || seen.has(key)) { + continue; + } + seen.add(key); + recents.push( + project + ? { kind: "project", projectId: project.id, displayName: project.displayName } + : { + kind: "folder", + folder: folder!, + displayName: folderDisplayName(folder!), + ...(normalizeOptionalString(entry.execNode) + ? { execNode: normalizeOptionalString(entry.execNode) } + : {}), + }, + ); + if (recents.length === 8) { + break; + } + } + 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 }, undefined); + respond(true, { projects, ...(recents ? { recents } : {}) }, undefined); return; } // Project identity is read-safe; host paths and origins are placement @@ -46,6 +149,7 @@ export const projectsHandlers: GatewayRequestHandlers = { source: project.source, }, ), + ...(recents ? { recents: recents.filter((recent) => recent.kind === "project") } : {}), }, undefined, ); diff --git a/src/gateway/server-methods/sessions-create.ts b/src/gateway/server-methods/sessions-create.ts index 953be7a596f8..47c6a256d709 100644 --- a/src/gateway/server-methods/sessions-create.ts +++ b/src/gateway/server-methods/sessions-create.ts @@ -525,6 +525,7 @@ export const sessionCreateHandlers: GatewayRequestHandlers = { generatedDisplayName, ...(catalogTarget ? { catalogTarget: catalogTarget.target } : { model: p.model }), thinkingLevel: p.thinkingLevel, + projectId: requestedProjectId, incognito: p.incognito, ...(client?.connect ? { requestingOperatorScopes: clientScopes } : {}), visibility: p.visibility, diff --git a/src/gateway/server-methods/users-preferences.test.ts b/src/gateway/server-methods/users-preferences.test.ts new file mode 100644 index 000000000000..c7279ec1cd7b --- /dev/null +++ b/src/gateway/server-methods/users-preferences.test.ts @@ -0,0 +1,118 @@ +import { afterEach, expect, test } from "vitest"; +import { GatewayErrorDetailCodes } from "../../../packages/gateway-protocol/src/index.js"; +import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js"; +import { ensureProfileForEmail, linkEmail } from "../../state/user-profiles.js"; +import { createOpenClawTestState } from "../../test-utils/openclaw-test-state.js"; +import { usersHandlers } from "./users.js"; + +async function invokePreferenceMethod( + method: "users.prefs.get" | "users.prefs.set", + params: Record, + profileId?: string, +) { + let result: { ok: boolean; payload?: unknown; error?: unknown } | undefined; + await usersHandlers[method]!({ + req: {} as never, + params, + respond: (ok, payload, error) => { + result = { ok, payload, error }; + }, + context: {} as never, + client: { + connect: { scopes: ["operator.admin"] }, + ...(profileId ? { authenticatedUserProfile: { profileId } } : {}), + } as never, + isWebchatConnect: () => false, + }); + return result; +} + +afterEach(() => { + closeOpenClawStateDatabaseForTest(); +}); + +test("users.prefs remains self-scoped across durable identities", async () => { + const state = await createOpenClawTestState({ layout: "state-only", prefix: "users-prefs-rpc-" }); + try { + const ada = ensureProfileForEmail("ada@example.test"); + const grace = ensureProfileForEmail("grace@example.test"); + expect( + await invokePreferenceMethod( + "users.prefs.set", + { entries: { "new-session.v1:main": { folder: "/ada" } } }, + ada.id, + ), + ).toEqual({ ok: true, payload: { status: "ok" }, error: undefined }); + expect(await invokePreferenceMethod("users.prefs.get", {}, ada.id)).toMatchObject({ + ok: true, + payload: { + status: "ok", + entries: { "new-session.v1:main": { folder: "/ada" } }, + }, + }); + expect(await invokePreferenceMethod("users.prefs.get", {}, grace.id)).toMatchObject({ + ok: true, + payload: { status: "ok", entries: {} }, + }); + linkEmail("ada@example.test", grace.id); + expect(await invokePreferenceMethod("users.prefs.get", {}, grace.id)).toMatchObject({ + ok: true, + payload: { + status: "ok", + entries: { "new-session.v1:main": { folder: "/ada" } }, + }, + }); + } finally { + await state.cleanup(); + } +}); + +test("users.prefs returns a typed result without a durable identity", async () => { + expect(await invokePreferenceMethod("users.prefs.get", {})).toMatchObject({ + ok: true, + payload: { status: "no_durable_identity" }, + }); + expect( + await invokePreferenceMethod("users.prefs.set", { entries: { theme: "claw" } }), + ).toMatchObject({ + ok: true, + payload: { status: "no_durable_identity" }, + }); +}); + +test("users.prefs.set returns typed profile quota details", async () => { + const state = await createOpenClawTestState({ + layout: "state-only", + prefix: "users-prefs-quota-", + }); + try { + const profile = ensureProfileForEmail("quota@example.test"); + for (let start = 0; start < 128; start += 32) { + const entries = Object.fromEntries( + Array.from({ length: 32 }, (_, index) => [`key-${start + index}`, true]), + ); + expect( + await invokePreferenceMethod("users.prefs.set", { entries }, profile.id), + ).toMatchObject({ + ok: true, + payload: { status: "ok" }, + }); + } + + expect( + await invokePreferenceMethod("users.prefs.set", { entries: { "key-128": true } }, profile.id), + ).toMatchObject({ + ok: false, + error: { + code: "INVALID_REQUEST", + details: { + code: GatewayErrorDetailCodes.USER_PREFS_LIMIT_EXCEEDED, + limit: 128, + currentCount: 128, + }, + }, + }); + } finally { + await state.cleanup(); + } +}); diff --git a/src/gateway/server-methods/users.ts b/src/gateway/server-methods/users.ts index 2fc7dab4db3b..7e4554353e99 100644 --- a/src/gateway/server-methods/users.ts +++ b/src/gateway/server-methods/users.ts @@ -1,15 +1,19 @@ // Gateway methods for durable user profile administration. import { ErrorCodes, + GatewayErrorDetailCodes, errorShape, formatValidationErrors, validateUsersLinkEmailParams, validateUsersListParams, + validateUsersPrefsGetParams, + validateUsersPrefsSetParams, validateUsersSelfParams, validateUsersSetAvatarParams, validateUsersSetDisplayNameParams, } from "../../../packages/gateway-protocol/src/index.js"; import { formatErrorMessage } from "../../infra/errors.js"; +import { getUserPreferences, setUserPreferences } from "../../state/user-preferences.js"; import { ensureProfileForEmail, getUserProfileDisplay, @@ -148,6 +152,99 @@ export const usersHandlers: GatewayRequestHandlers = { respond(false, undefined, profileError(error)); } }, + "users.prefs.get": ({ client, params, respond }) => { + if (!validateUsersPrefsGetParams(params)) { + respond( + false, + undefined, + invalidParams("users.prefs.get", validateUsersPrefsGetParams.errors), + ); + return; + } + const profileId = client?.authenticatedUserProfile?.profileId ?? ""; + if (!profileId) { + respond(true, { status: "no_durable_identity" }, undefined); + return; + } + try { + const canonicalProfileId = resolveUserProfileId(profileId); + if (!canonicalProfileId) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, "authenticated user profile is unavailable"), + ); + return; + } + respond( + true, + { status: "ok", entries: getUserPreferences(canonicalProfileId, params.keys) }, + undefined, + ); + } catch (error) { + respond(false, undefined, profileError(error)); + } + }, + "users.prefs.set": ({ client, params, respond }) => { + if (!validateUsersPrefsSetParams(params)) { + respond( + false, + undefined, + invalidParams("users.prefs.set", validateUsersPrefsSetParams.errors), + ); + return; + } + const profileId = client?.authenticatedUserProfile?.profileId ?? ""; + if (!profileId) { + respond(true, { status: "no_durable_identity" }, undefined); + return; + } + try { + const canonicalProfileId = resolveUserProfileId(profileId); + if (!canonicalProfileId) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, "authenticated user profile is unavailable"), + ); + return; + } + const result = setUserPreferences(canonicalProfileId, params.entries); + if (!result.ok) { + if (result.error.code === "profile-key-limit") { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `users.prefs.set exceeds the ${result.error.limit}-key profile limit (current count: ${result.error.currentCount})`, + { + details: { + code: GatewayErrorDetailCodes.USER_PREFS_LIMIT_EXCEEDED, + limit: result.error.limit, + currentCount: result.error.currentCount, + }, + }, + ), + ); + return; + } + const key = "key" in result.error ? ` for ${result.error.key}` : ""; + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid users.prefs.set entry${key}: ${result.error.code}`, + ), + ); + return; + } + respond(true, { status: "ok" }, undefined); + } catch (error) { + respond(false, undefined, profileError(error)); + } + }, "users.linkEmail": ({ context, params, respond }) => { if (!validateUsersLinkEmailParams(params)) { respond( diff --git a/src/gateway/server.sessions.create.projects.test.ts b/src/gateway/server.sessions.create.projects.test.ts index 51510ae2e6ec..4efef3ebb870 100644 --- a/src/gateway/server.sessions.create.projects.test.ts +++ b/src/gateway/server.sessions.create.projects.test.ts @@ -5,6 +5,7 @@ import { promisify } from "node:util"; import { afterEach, expect, test } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import { managedWorktrees } from "../agents/worktrees/service.js"; +import { loadSessionEntry } from "../config/sessions/session-accessor.js"; import { registerProjectRegistry } from "../projects/project-registry.js"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { testState } from "./test-helpers.js"; @@ -55,10 +56,13 @@ test("sessions.create starts directly in an outside registered project at write const workspace = await initializeRepository(root, "workspace"); const projectRoot = await initializeRepository(root, "project"); testState.agentConfig = { workspace }; - await createSessionStoreDir(); + const { storePath } = await createSessionStoreDir(); const project = await registerProjectRegistry({ path: projectRoot, name: "Project" }); - const created = await directSessionReq<{ entry?: { spawnedCwd?: string } }>( + const created = await directSessionReq<{ + key?: string; + entry?: { projectId?: string; spawnedCwd?: string }; + }>( "sessions.create", { agentId: "main", projectId: project.id }, { client: { connect: { scopes: ["operator.write"] } } as never }, @@ -66,6 +70,14 @@ test("sessions.create starts directly in an outside registered project at write expect(created.ok).toBe(true); expect(created.payload?.entry?.spawnedCwd).toBe(projectRoot); + expect(created.payload?.entry?.projectId).toBe(project.id); + expect( + loadSessionEntry({ + agentId: "main", + sessionKey: created.payload?.key ?? "", + storePath, + })?.projectId, + ).toBe(project.id); }); test("sessions.create provisions a managed worktree from a registered project at write scope", async () => { diff --git a/src/gateway/session-create-service.ts b/src/gateway/session-create-service.ts index 3b7c2b65d7fc..345f309ce4b9 100644 --- a/src/gateway/session-create-service.ts +++ b/src/gateway/session-create-service.ts @@ -235,6 +235,8 @@ export async function createGatewaySession(params: { generatedDisplayName?: string; model?: string; thinkingLevel?: string; + /** Registry identity recorded only when this request creates a logical session node. */ + projectId?: string; incognito?: boolean; visibility?: SessionVisibility; /** Trusted catalog-owned model/runtime pair, persisted and locked together. */ @@ -293,6 +295,7 @@ export async function createGatewaySession(params: { const requestedKey = normalizeOptionalString(params.key); const parentSessionKey = normalizeOptionalString(params.parentSessionKey); const generatedDisplayName = normalizeOptionalString(params.generatedDisplayName); + const projectId = normalizeOptionalString(params.projectId); const agentId = normalizeAgentId( normalizeOptionalString(params.agentId) ?? resolveDefaultAgentId(params.cfg), ); @@ -1005,6 +1008,7 @@ export async function createGatewaySession(params: { // the merge-level write-once guard), and legacy rows stay "unknown". ...(params.creation && createdNewEntry ? buildSessionCreationStamp(params.creation) : {}), ...(params.visibility && createdNewEntry ? { visibility: params.visibility } : {}), + ...(projectId && createdNewEntry ? { projectId } : {}), ...(generatedDisplayName && createdNewEntry ? { displayName: generatedDisplayName } : {}), ...(catalogResolvedModel && catalogAgentRuntime ? { diff --git a/src/gateway/session-reset-service.ts b/src/gateway/session-reset-service.ts index f367a0d3dc6d..124dbf0a459e 100644 --- a/src/gateway/session-reset-service.ts +++ b/src/gateway/session-reset-service.ts @@ -1564,6 +1564,7 @@ export async function performGatewaySessionReset(params: { createdVia: currentEntry.createdVia, createdActor: currentEntry.createdActor, createdAt: currentEntry.createdAt, + projectId: currentEntry.projectId, } : params.creation ? buildSessionCreationStamp(params.creation) diff --git a/src/infra/state-migrations.media-persistence.historical-v14.test.ts b/src/infra/state-migrations.media-persistence.historical-v14.test.ts index 407d14756d3a..179507e53974 100644 --- a/src/infra/state-migrations.media-persistence.historical-v14.test.ts +++ b/src/infra/state-migrations.media-persistence.historical-v14.test.ts @@ -26,7 +26,7 @@ describe("legacy media persistence Doctor migration from historical v14", () => it("migrates a copy of the exact v2026.7.2-beta.4 schema without losing its session", () => { const historicalSchema = historicalV14AgentSchemaSql(); expect(createHash("sha256").update(historicalSchema).digest("hex")).toBe( - "955889668707fbccab70b80b5058af5a1587fd35ae32a80f8605179a68fb5117", + "dfb2a98c9418eb1032e82e4310c7bde41700e4a0af05a2464673e9c4ece11fd1", ); const stateDir = makeTempDir(tempDirs, "media-persistence-historical-v14-"); diff --git a/src/infra/state-migrations.media-persistence.historical-v15.test.ts b/src/infra/state-migrations.media-persistence.historical-v15.test.ts index bad90b2b548e..8bc4cdcd7331 100644 --- a/src/infra/state-migrations.media-persistence.historical-v15.test.ts +++ b/src/infra/state-migrations.media-persistence.historical-v15.test.ts @@ -26,7 +26,7 @@ describe("legacy media persistence Doctor migration from historical v15", () => it("converges the exact 509a5f0373764 schema before current-index repair", () => { const historicalSchema = historicalV15AgentSchemaSql(); expect(createHash("sha256").update(historicalSchema).digest("hex")).toBe( - "75953ef97a738251822fc5aaf283bbe55fbcabe8702ad771892cdafc85d8e6b9", + "2ad94b064159086923e24acf4e11cb77546fca71646e7f49c7a6d40d2a22890a", ); const stateDir = makeTempDir(tempDirs, "media-persistence-historical-v15-"); diff --git a/src/plugins/session-entry-slot-keys.ts b/src/plugins/session-entry-slot-keys.ts index 9f0d4184dfac..fa00e198cac9 100644 --- a/src/plugins/session-entry-slot-keys.ts +++ b/src/plugins/session-entry-slot-keys.ts @@ -33,6 +33,7 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [ "spawnedCwd", "sessionDiffBaseline", "worktree", + "projectId", "parentSessionKey", "parentSessionId", "createdVia", diff --git a/src/state/openclaw-agent-db-schema.ts b/src/state/openclaw-agent-db-schema.ts index 4cec1be95e50..d52e0335c461 100644 --- a/src/state/openclaw-agent-db-schema.ts +++ b/src/state/openclaw-agent-db-schema.ts @@ -40,6 +40,7 @@ import { } from "./openclaw-agent-db-schema-helpers.js"; import { backfillSessionConversations, + ensureSessionProjectColumn, ensureSessionEntryValidityProjection, migrateConversationDeliveryTargetColumn, migrateSessionEntryStatusProjection, @@ -151,6 +152,11 @@ function hasPendingSessionKeyContractSchemaMigration(db: DatabaseSync): boolean return !sessionNodeColumns.has("entry_valid") || !hasContractTable; } +function hasPendingSessionProjectColumn(db: DatabaseSync): boolean { + const columns = readSqliteTableColumns(db, "session_nodes"); + return Boolean(columns && !columns.has("project_id")); +} + function migrateMemoryChunkMetadataSchema(db: DatabaseSync): void { ensureMemoryRecallMetadataSchema(db); ensureMemoryChunkProvenance(db); @@ -566,18 +572,12 @@ export function assertAgentDatabaseIntegrityBeforeMutation( toVersion: OPENCLAW_AGENT_SCHEMA_VERSION, }); } - const hasPendingMemoryMigration = - userVersion === OPENCLAW_AGENT_SCHEMA_VERSION && - hasPendingMemoryChunkMetadataMigration(database); - const hasPendingSessionContractMigration = - userVersion === OPENCLAW_AGENT_SCHEMA_VERSION && - hasPendingSessionKeyContractSchemaMigration(database); - const hasPendingRetiredLeaseMigration = - userVersion === OPENCLAW_AGENT_SCHEMA_VERSION && hasRetiredAgentStateLeaseSchema(database); const hasPendingCurrentVersionMigration = - hasPendingMemoryMigration || - hasPendingSessionContractMigration || - hasPendingRetiredLeaseMigration; + userVersion === OPENCLAW_AGENT_SCHEMA_VERSION && + (hasPendingMemoryChunkMetadataMigration(database) || + hasPendingSessionKeyContractSchemaMigration(database) || + hasRetiredAgentStateLeaseSchema(database) || + hasPendingSessionProjectColumn(database)); if (userVersion === OPENCLAW_AGENT_SCHEMA_VERSION && !hasPendingCurrentVersionMigration) { verifyAndRepairCanonicalSqliteIndexes(database, pathname, OPENCLAW_AGENT_SCHEMA_SQL, { allowMissingColumns: true, @@ -636,6 +636,7 @@ function ensureAgentSchema( } migrateRetiredAgentStateLeaseSchema(db, pathname, targetVersion); if (previousVersion === targetVersion) { + ensureSessionProjectColumn(db); ensureSessionEntryValidityProjection(db); ensureSessionKeyContractSchemaInTransaction(db); if (hasPendingMemoryChunkMetadataMigration(db)) { @@ -670,6 +671,7 @@ function ensureAgentSchema( } backfillSessionEntryProvenance(db, previousVersion); migrateSessionNodesAndWindows(db, previousVersion); + ensureSessionProjectColumn(db); ensureSessionEntryValidityProjection(db); db.exec(OPENCLAW_AGENT_SCHEMA_SQL); migrateMemoryChunkMetadataSchema(db); diff --git a/src/state/openclaw-agent-db-session-migrations.ts b/src/state/openclaw-agent-db-session-migrations.ts index b8b838950d6f..6f8fca44f535 100644 --- a/src/state/openclaw-agent-db-session-migrations.ts +++ b/src/state/openclaw-agent-db-session-migrations.ts @@ -275,6 +275,15 @@ export function readSqliteTableColumns(db: DatabaseSync, tableName: string): Set return new Set(rows.flatMap((row) => (typeof row.name === "string" ? [row.name] : []))); } +/** Installs the same-version project identity projection on first updated-binary open. */ +export function ensureSessionProjectColumn(db: DatabaseSync): void { + const columns = readSqliteTableColumns(db, "session_nodes"); + if (!columns || columns.has("project_id")) { + return; + } + db.exec("ALTER TABLE session_nodes ADD COLUMN project_id TEXT;"); +} + /** Adds the v11 exact delivery target before the conversation backfill writes canonical rows. */ export function migrateConversationDeliveryTargetColumn(db: DatabaseSync): void { const columns = readSqliteTableColumns(db, "conversations"); diff --git a/src/state/openclaw-agent-db.generated.d.ts b/src/state/openclaw-agent-db.generated.d.ts index c3722a6281cc..67aecf2b402e 100644 --- a/src/state/openclaw-agent-db.generated.d.ts +++ b/src/state/openclaw-agent-db.generated.d.ts @@ -246,6 +246,7 @@ export interface SessionNodes { last_read_at: number | null; parent_session_key: string | null; pinned_at: number | null; + project_id: string | null; session_key: string; spawned_by: string | null; status: string | null; diff --git a/src/state/openclaw-agent-project-column.test.ts b/src/state/openclaw-agent-project-column.test.ts new file mode 100644 index 000000000000..098f646f5dd7 --- /dev/null +++ b/src/state/openclaw-agent-project-column.test.ts @@ -0,0 +1,57 @@ +import { afterEach, expect, test } from "vitest"; +import { createOpenClawTestState } from "../test-utils/openclaw-test-state.js"; +import { OPENCLAW_AGENT_SCHEMA_VERSION } from "./openclaw-agent-db-contract.js"; +import { + closeOpenClawAgentDatabasesForTest, + openOpenClawAgentDatabase, +} from "./openclaw-agent-db.js"; +import { closeOpenClawStateDatabaseForTest } from "./openclaw-state-db.js"; + +afterEach(() => { + closeOpenClawAgentDatabasesForTest(); + closeOpenClawStateDatabaseForTest(); +}); + +test("current-version agent databases lazily add the nullable project column", async () => { + const state = await createOpenClawTestState({ layout: "state-only", prefix: "agent-project-" }); + try { + const options = { agentId: "main", env: state.env }; + const initial = openOpenClawAgentDatabase(options); + initial.db.exec("ALTER TABLE session_nodes DROP COLUMN project_id;"); + initial.db + .prepare( + `INSERT INTO session_nodes + (session_key, current_session_id, entry_json, entry_valid, updated_at) + VALUES (?, ?, ?, ?, ?)`, + ) + .run( + "agent:main:old-shape", + "session-old-shape", + JSON.stringify({ sessionId: "session-old-shape", updatedAt: 1 }), + 1, + 1, + ); + closeOpenClawAgentDatabasesForTest(); + + const reopened = openOpenClawAgentDatabase(options); + const columns = reopened.db.prepare("PRAGMA table_info(session_nodes)").all() as Array<{ + name: string; + notnull: number; + type: string; + }>; + expect(columns.find((column) => column.name === "project_id")).toMatchObject({ + type: "TEXT", + notnull: 0, + }); + expect(reopened.db.prepare("PRAGMA user_version").get()?.user_version).toBe( + OPENCLAW_AGENT_SCHEMA_VERSION, + ); + expect( + reopened.db + .prepare("SELECT project_id FROM session_nodes WHERE session_key = ?") + .get("agent:main:old-shape"), + ).toEqual({ project_id: null }); + } finally { + await state.cleanup(); + } +}); diff --git a/src/state/openclaw-agent-schema.sql b/src/state/openclaw-agent-schema.sql index fb223e87bd43..98314c52d632 100644 --- a/src/state/openclaw-agent-schema.sql +++ b/src/state/openclaw-agent-schema.sql @@ -23,6 +23,7 @@ CREATE TABLE IF NOT EXISTS session_nodes ( created_via TEXT CHECK (created_via IS NULL OR created_via IN ('operator', 'spawn', 'channel', 'cron', 'talk', 'run', 'plugin', 'internal')), created_actor_type TEXT CHECK (created_actor_type IS NULL OR created_actor_type IN ('human', 'agent', 'system')), created_actor_id TEXT, + project_id TEXT, parent_session_key TEXT, spawned_by TEXT, fork_source_session_key TEXT, diff --git a/src/state/openclaw-state-db-contract.ts b/src/state/openclaw-state-db-contract.ts index be2109e52f21..7c7b558dfb4d 100644 --- a/src/state/openclaw-state-db-contract.ts +++ b/src/state/openclaw-state-db-contract.ts @@ -21,6 +21,7 @@ export const LAZY_ADDITIVE_STATE_TABLES = [ "model_catalog_remote", "secret_store_entries", "projects", + "user_preferences", "gateway_origin_device_tokens", "sidebar_sections", "skill_workshop_proposal_events", diff --git a/src/state/openclaw-state-db.generated.d.ts b/src/state/openclaw-state-db.generated.d.ts index a14073ac6ac5..7d1835e53f88 100644 --- a/src/state/openclaw-state-db.generated.d.ts +++ b/src/state/openclaw-state-db.generated.d.ts @@ -1396,6 +1396,13 @@ export interface UpdateCheckState { updated_at_ms: number; } +export interface UserPreferences { + pref_key: string; + profile_id: string; + updated_at_ms: number; + value_json: string; +} + export interface VoicewakeRoutingConfig { config_key: string; default_target_agent_id: string | null; @@ -1751,6 +1758,7 @@ export interface DB { task_runs: TaskRuns; tui_last_sessions: TuiLastSessions; update_check_state: UpdateCheckState; + user_preferences: UserPreferences; voicewake_routing_config: VoicewakeRoutingConfig; voicewake_routing_routes: VoicewakeRoutingRoutes; voicewake_triggers: VoicewakeTriggers; diff --git a/src/state/openclaw-state-schema.sql b/src/state/openclaw-state-schema.sql index e03767b99d40..3d67dec1f356 100644 --- a/src/state/openclaw-state-schema.sql +++ b/src/state/openclaw-state-schema.sql @@ -1867,6 +1867,14 @@ CREATE TABLE IF NOT EXISTS projects ( updated_at_ms INT NOT NULL ) STRICT; +CREATE TABLE IF NOT EXISTS user_preferences ( + profile_id TEXT NOT NULL, + pref_key TEXT NOT NULL, + value_json TEXT NOT NULL, + updated_at_ms INT NOT NULL, + PRIMARY KEY (profile_id, pref_key) +) STRICT; + -- Gateway-owned custom session group catalog (names + display order). -- Membership stays on each session entry's category field; this table only -- owns which groups exist and how operator UIs order them. diff --git a/src/state/user-preferences.test.ts b/src/state/user-preferences.test.ts new file mode 100644 index 000000000000..cf84a8c8e71a --- /dev/null +++ b/src/state/user-preferences.test.ts @@ -0,0 +1,126 @@ +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; +import { tableExists } from "./openclaw-state-db-schema-helpers.js"; +import { + closeOpenClawStateDatabaseForTest, + openOpenClawStateDatabase, +} from "./openclaw-state-db.js"; +import { + getUserPreferences, + mergeUserPreferences, + setUserPreferences, +} from "./user-preferences.js"; + +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + +function stateOptions() { + return { path: join(tempDirs.make("openclaw-user-prefs-"), "openclaw.sqlite") }; +} + +afterEach(() => { + closeOpenClawStateDatabaseForTest(); +}); + +describe("user preferences", () => { + it("lazily creates the additive table and isolates profile rows", () => { + const options = stateOptions(); + const database = openOpenClawStateDatabase(options).db; + const version = database.prepare("PRAGMA user_version").get()?.user_version; + database.exec("DROP TABLE user_preferences;"); + closeOpenClawStateDatabaseForTest(); + const reopened = openOpenClawStateDatabase(options).db; + expect(tableExists(reopened, "user_preferences")).toBe(false); + + expect(setUserPreferences("profile-a", { beta: 2, alpha: { enabled: true } }, options)).toEqual( + { + ok: true, + value: undefined, + }, + ); + expect(getUserPreferences("profile-a", undefined, options)).toEqual({ + alpha: { enabled: true }, + beta: 2, + }); + expect(getUserPreferences("profile-a", ["beta"], options)).toEqual({ beta: 2 }); + expect(getUserPreferences("profile-b", undefined, options)).toEqual({}); + expect(tableExists(reopened, "user_preferences")).toBe(true); + expect(reopened.prepare("PRAGMA user_version").get()?.user_version).toBe(version); + }); + + it("rejects oversized batches and values before writing any row", () => { + const options = stateOptions(); + const tooMany = Object.fromEntries( + Array.from({ length: 33 }, (_, index) => [`key-${index}`, index]), + ); + expect(setUserPreferences("profile-a", tooMany, options)).toMatchObject({ + ok: false, + error: { code: "invalid-entry-count" }, + }); + expect( + setUserPreferences("profile-a", { valid: true, oversized: "馃".repeat(1_025) }, options), + ).toMatchObject({ ok: false, error: { code: "value-too-large", key: "oversized" } }); + expect(getUserPreferences("profile-a", undefined, options)).toEqual({}); + }); + + it("caps each profile at 128 keys while allowing deletions to free capacity", () => { + const options = stateOptions(); + for (let start = 0; start < 127; start += 32) { + const count = Math.min(32, 127 - start); + const entries = Object.fromEntries( + Array.from({ length: count }, (_, index) => [`key-${start + index}`, true]), + ); + expect(setUserPreferences("profile-a", entries, options)).toEqual({ + ok: true, + value: undefined, + }); + } + + expect(setUserPreferences("profile-a", { "key-127": true }, options)).toEqual({ + ok: true, + value: undefined, + }); + expect(setUserPreferences("profile-a", { "key-128": true }, options)).toEqual({ + ok: false, + error: { code: "profile-key-limit", limit: 128, currentCount: 128 }, + }); + expect(setUserPreferences("profile-a", { "key-0": null }, options)).toEqual({ + ok: true, + value: undefined, + }); + expect(setUserPreferences("profile-a", { "key-128": true }, options)).toEqual({ + ok: true, + value: undefined, + }); + expect(getUserPreferences("profile-a", ["key-0", "key-128"], options)).toEqual({ + "key-128": true, + }); + }); + + it("keeps merged profiles within the same preference cap", () => { + const options = stateOptions(); + for (let start = 0; start < 127; start += 32) { + const count = Math.min(32, 127 - start); + expect( + setUserPreferences( + "target", + Object.fromEntries( + Array.from({ length: count }, (_, index) => [`target-${start + index}`, true]), + ), + options, + ), + ).toMatchObject({ ok: true }); + } + expect( + setUserPreferences("source", { "source-a": true, "source-b": true }, options), + ).toMatchObject({ ok: true }); + + mergeUserPreferences(openOpenClawStateDatabase(options).db, "source", "target"); + + expect(Object.keys(getUserPreferences("target", undefined, options))).toHaveLength(128); + expect(getUserPreferences("target", ["source-a", "source-b"], options)).toEqual({ + "source-a": true, + }); + expect(getUserPreferences("source", undefined, options)).toEqual({}); + }); +}); diff --git a/src/state/user-preferences.ts b/src/state/user-preferences.ts new file mode 100644 index 000000000000..f4f8446c913e --- /dev/null +++ b/src/state/user-preferences.ts @@ -0,0 +1,223 @@ +import type { DatabaseSync } from "node:sqlite"; +import { err, ok, type Result } from "@openclaw/normalization-core/result"; +import { + USER_PREFS_ENTRY_LIMIT, + USER_PREFS_PROFILE_KEY_LIMIT, + USER_PREFS_VALUE_BYTES, +} from "../../packages/gateway-protocol/src/schema/users.js"; +import { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-sync.js"; +import { tableExists } from "./openclaw-state-db-schema-helpers.js"; +import type { DB as OpenClawStateKyselyDatabase } from "./openclaw-state-db.generated.js"; +import { + openOpenClawStateDatabase, + runOpenClawStateWriteTransaction, + type OpenClawStateDatabaseOptions, +} from "./openclaw-state-db.js"; + +type UserPreferencesDatabase = Pick; + +const ensuredDatabases = new WeakSet(); +const USER_PREFERENCES_SCHEMA_SQL = ` +CREATE TABLE IF NOT EXISTS user_preferences ( + profile_id TEXT NOT NULL, + pref_key TEXT NOT NULL, + value_json TEXT NOT NULL, + updated_at_ms INT NOT NULL, + PRIMARY KEY (profile_id, pref_key) +) STRICT; +`; + +type UserPreferenceError = + | { code: "invalid-entry-count" } + | { code: "invalid-key" | "invalid-value" | "value-too-large"; key: string } + | { + code: "profile-key-limit"; + limit: number; + currentCount: number; + }; + +function ensureUserPreferencesSchema(options: OpenClawStateDatabaseOptions = {}): void { + const database = openOpenClawStateDatabase(options); + if (ensuredDatabases.has(database.db)) { + return; + } + runOpenClawStateWriteTransaction( + ({ db }) => { + // sqlite-allow-raw -- feature-local additive schema DDL; preference rows use Kysely below. + db.exec(USER_PREFERENCES_SCHEMA_SQL); + }, + options, + { operationLabel: "users.preferences.schema.ensure" }, + ); + ensuredDatabases.add(database.db); +} + +function openUserPreferencesDatabase(options: OpenClawStateDatabaseOptions = {}) { + ensureUserPreferencesSchema(options); + const state = openOpenClawStateDatabase(options); + return { sqlite: state.db, kysely: getNodeSqliteKysely(state.db) }; +} + +function readPreferenceKeys(database: DatabaseSync, profileId: string): Set { + const db = getNodeSqliteKysely(database); + return new Set( + executeSqliteQuerySync( + database, + db.selectFrom("user_preferences").select("pref_key").where("profile_id", "=", profileId), + ).rows.map((row) => row.pref_key), + ); +} + +/** Moves one retired profile's preferences without overwriting the merge target's choices. */ +export function mergeUserPreferences( + database: DatabaseSync, + sourceProfileId: string, + targetProfileId: string, +): void { + if (sourceProfileId === targetProfileId || !tableExists(database, "user_preferences")) { + return; + } + const db = getNodeSqliteKysely(database); + const targetKeys = readPreferenceKeys(database, targetProfileId); + const rows = executeSqliteQuerySync( + database, + db + .selectFrom("user_preferences") + .selectAll() + .where("profile_id", "=", sourceProfileId) + .orderBy("pref_key", "asc"), + ).rows; + for (const row of rows) { + if (targetKeys.has(row.pref_key)) { + continue; + } + if (targetKeys.size >= USER_PREFS_PROFILE_KEY_LIMIT) { + break; + } + executeSqliteQuerySync( + database, + db + .insertInto("user_preferences") + .values({ ...row, profile_id: targetProfileId }) + .onConflict((conflict) => conflict.columns(["profile_id", "pref_key"]).doNothing()), + ); + targetKeys.add(row.pref_key); + } + executeSqliteQuerySync( + database, + db.deleteFrom("user_preferences").where("profile_id", "=", sourceProfileId), + ); +} + +export function getUserPreferences( + profileId: string, + keys?: readonly string[], + options: OpenClawStateDatabaseOptions = {}, +): Record { + if (keys?.length === 0) { + return {}; + } + const { sqlite, kysely } = openUserPreferencesDatabase(options); + let query = kysely + .selectFrom("user_preferences") + .select(["pref_key", "value_json"]) + .where("profile_id", "=", profileId) + .orderBy("pref_key", "asc"); + if (keys) { + query = query.where("pref_key", "in", [...keys]); + } + return Object.fromEntries( + executeSqliteQuerySync(sqlite, query).rows.map((row) => [ + row.pref_key, + JSON.parse(row.value_json) as unknown, + ]), + ); +} + +export function setUserPreferences( + profileId: string, + entries: Record, + options: OpenClawStateDatabaseOptions = {}, +): Result { + const rawEntries = Object.entries(entries); + if (rawEntries.length > USER_PREFS_ENTRY_LIMIT) { + return err({ code: "invalid-entry-count" }); + } + const serialized: Array<{ prefKey: string; valueJson: string }> = []; + const deletionKeys: string[] = []; + for (const [prefKey, value] of rawEntries) { + if (!prefKey || prefKey.length > 256) { + return err({ code: "invalid-key", key: prefKey }); + } + // JSON null is the additive removal form for this record-shaped RPC. + if (value === null) { + deletionKeys.push(prefKey); + continue; + } + let valueJson: string | undefined; + try { + valueJson = JSON.stringify(value); + } catch { + return err({ code: "invalid-value", key: prefKey }); + } + if (valueJson === undefined) { + return err({ code: "invalid-value", key: prefKey }); + } + if (Buffer.byteLength(valueJson, "utf8") > USER_PREFS_VALUE_BYTES) { + return err({ code: "value-too-large", key: prefKey }); + } + serialized.push({ prefKey, valueJson }); + } + if (serialized.length === 0 && deletionKeys.length === 0) { + return ok(undefined); + } + ensureUserPreferencesSchema(options); + return runOpenClawStateWriteTransaction( + ({ db: sqlite }) => { + const db = getNodeSqliteKysely(sqlite); + const currentKeys = readPreferenceKeys(sqlite, profileId); + const nextKeys = new Set(currentKeys); + deletionKeys.forEach((key) => nextKeys.delete(key)); + serialized.forEach((entry) => nextKeys.add(entry.prefKey)); + if (serialized.length > 0 && nextKeys.size > USER_PREFS_PROFILE_KEY_LIMIT) { + return err({ + code: "profile-key-limit", + limit: USER_PREFS_PROFILE_KEY_LIMIT, + currentCount: currentKeys.size, + }); + } + if (deletionKeys.length > 0) { + executeSqliteQuerySync( + sqlite, + db + .deleteFrom("user_preferences") + .where("profile_id", "=", profileId) + .where("pref_key", "in", deletionKeys), + ); + } + const updatedAtMs = Date.now(); + for (const entry of serialized) { + executeSqliteQuerySync( + sqlite, + db + .insertInto("user_preferences") + .values({ + profile_id: profileId, + pref_key: entry.prefKey, + value_json: entry.valueJson, + updated_at_ms: updatedAtMs, + }) + .onConflict((conflict) => + conflict.columns(["profile_id", "pref_key"]).doUpdateSet({ + value_json: entry.valueJson, + updated_at_ms: updatedAtMs, + }), + ), + ); + } + return ok(undefined); + }, + options, + { operationLabel: "users.preferences.set" }, + ); +} diff --git a/src/state/user-profiles.ts b/src/state/user-profiles.ts index 098bd5e2b9b6..07c233a2d2ef 100644 --- a/src/state/user-profiles.ts +++ b/src/state/user-profiles.ts @@ -15,6 +15,7 @@ import { runOpenClawStateWriteTransaction, type OpenClawStateDatabaseOptions, } from "./openclaw-state-db.js"; +import { mergeUserPreferences } from "./user-preferences.js"; import { USER_PROFILES_SCHEMA_SQL } from "./user-profiles-schema.js"; import { fetchTailscaleAvatar, @@ -561,6 +562,19 @@ export function linkEmail( kysely.updateTable("user_profiles").set({ updated_at: now }).where("id", "=", target.id), ); if (remainingAliases.length === 0) { + const mergeSourceIds = [ + existingAlias.profile_id, + ...executeSqliteQuerySync( + db, + kysely + .selectFrom("user_profiles") + .select("id") + .where("merged_into", "=", existingAlias.profile_id), + ).rows.map((row) => row.id), + ]; + for (const sourceProfileId of mergeSourceIds) { + mergeUserPreferences(db, sourceProfileId, target.id); + } executeSqliteQuerySync( db, kysely diff --git a/ui/src/e2e/new-session-page.workspace-memory.e2e.test.ts b/ui/src/e2e/new-session-page.workspace-memory.e2e.test.ts index 181ccd53bd25..6334e135ff35 100644 --- a/ui/src/e2e/new-session-page.workspace-memory.e2e.test.ts +++ b/ui/src/e2e/new-session-page.workspace-memory.e2e.test.ts @@ -1,16 +1,21 @@ +import { gatewayOriginScope } from "@openclaw/gateway-client/browser"; import type { BrowserContextOptions, Page } from "playwright"; import { expect, it } from "vitest"; import { MOVED_WORKSPACE, PICKED, + SESSION_LIST_DEFAULTS, TARGET_REPO, WORKSPACE, + captureProjectUiProof, captureUiProof, + captureUiProofEnabled, choosePackagesFolder, createNewSessionPageE2eSuite, installMockGateway, navigateInApp, pollLocatorText, + projectProofArtifactDir, waitForCommittedChatRoute, } from "./new-session-page.test-support.ts"; @@ -391,6 +396,240 @@ suite.define(() => { }); }); + it("uses identity-scoped server project recents instead of the shared roster", async () => { + const context = await suite.browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + ...(captureUiProofEnabled + ? { + recordVideo: { + dir: projectProofArtifactDir, + size: { height: 900, width: 1280 }, + }, + viewport: { height: 900, width: 1280 }, + } + : {}), + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + workspace: WORKSPACE, + workspaceGit: true, + presenceUsers: [{ self: true, id: "profile-alice", name: "Alice" }], + featureMethods: [ + "chat.metadata", + "chat.startup", + "projects.list", + "sessions.create", + "users.prefs.get", + "users.prefs.set", + ], + methodResponses: { + "projects.list": { + projects: [{ id: "registered", displayName: "Registered", source: "registered" }], + recents: [{ kind: "project", projectId: "registered", displayName: "Registered" }], + }, + "sessions.list": { + count: 1, + defaults: SESSION_LIST_DEFAULTS, + path: "", + sessions: [ + { key: "agent:main:shared", kind: "direct", updatedAt: 99, execCwd: "/shared" }, + ], + ts: Date.now(), + }, + "sessions.create": { key: "agent:main:identity-project" }, + "users.prefs.get": { status: "ok", entries: {} }, + "users.prefs.set": { status: "ok" }, + }, + }); + + try { + await page.goto(`${suite.server.baseUrl}new`); + const trigger = page.locator("#new-session-place-trigger"); + await trigger.click(); + expect(await page.locator('[data-value="recent::/shared"]').count()).toBe(0); + const recent = page.locator('[data-value="recent-project:registered"]'); + await recent.waitFor(); + await captureProjectUiProof(page, "identity-project-recents.png"); + await recent.click(); + await page.locator(".new-session-page__message").fill("continue registered work"); + await page.getByRole("button", { name: "Start session" }).click(); + const create = await gateway.waitForRequest("sessions.create"); + expect(create.params).toMatchObject({ + projectId: "registered", + message: "continue registered work", + }); + } finally { + await context.close(); + } + }); + + it("migrates identity preferences once and mirrors gateway-first writes", async () => { + await withNewSessionPage( + { + ...DESKTOP_CONTEXT, + ...(captureUiProofEnabled + ? { + recordVideo: { + dir: projectProofArtifactDir, + size: { height: 900, width: 1280 }, + }, + } + : {}), + }, + async (page) => { + const appUrl = new URL(suite.server.baseUrl); + const gatewayUrl = `${appUrl.protocol === "https:" ? "wss:" : "ws:"}//${appUrl.host}`; + const storageKey = `openclaw.new-session.preferences.v1:${gatewayOriginScope(gatewayUrl)}`; + await page.addInitScript( + ({ key, folder, workspace }) => { + localStorage.setItem( + key, + JSON.stringify({ + agents: { + main: { + folder, + workspace, + worktree: true, + model: "anthropic/claude-sonnet-4-6", + }, + }, + }), + ); + }, + { key: storageKey, folder: PICKED, workspace: WORKSPACE }, + ); + const gateway = await installMockGateway(page, { + workspaceGit: true, + models: MODELS, + presenceUsers: [{ self: true, id: "profile-alice", name: "Alice" }], + featureMethods: [ + "chat.metadata", + "chat.startup", + "fs.listDir", + "projects.list", + "sessions.create", + "users.prefs.get", + "users.prefs.set", + "worktrees.branches", + ], + methodResponses: { + "agents.list": mainAgentList(), + "fs.listDir": FOLDER_LISTINGS, + "projects.list": { projects: [], recents: [] }, + "users.prefs.get": { + sequence: [ + { status: "ok", entries: {} }, + { + status: "ok", + entries: { + "new-session.migration.v1": true, + "new-session.v1:main": { + folder: PICKED, + workspace: WORKSPACE, + worktree: true, + model: "anthropic/claude-sonnet-4-6", + }, + }, + }, + ], + }, + "users.prefs.set": { status: "ok" }, + "worktrees.branches": GIT_BRANCHES, + }, + }); + await page.goto(`${suite.server.baseUrl}new`); + const migrated = await gateway.waitForRequest("users.prefs.set"); + expect(migrated.params).toMatchObject({ + entries: { + "new-session.v1:main": { + folder: PICKED, + workspace: WORKSPACE, + worktree: true, + model: "anthropic/claude-sonnet-4-6", + }, + }, + }); + const trigger = page.locator("#new-session-place-trigger"); + await pollLocatorText(trigger.locator(".new-session-page__trigger-label")).toBe("packages"); + await expect.poll(() => trigger.getAttribute("data-worktree")).toBe("true"); + await captureProjectUiProof(page, "identity-preferences-migrated.png"); + + await navigateInApp(page, "chat"); + await waitForCommittedChatRoute(page); + await navigateInApp(page, "new-session"); + await expect + .poll(async () => (await gateway.getRequests("users.prefs.get")).length) + .toBe(2); + await expect + .poll(async () => (await gateway.getRequests("users.prefs.set")).length) + .toBe(1); + + await gateway.deferNext("users.prefs.set"); + const modelSelect = page.locator('[data-chat-model-select="true"]'); + await modelSelect.click(); + await page.locator('[data-chat-model-option="openai/gpt-5.5"]').click(); + await expect + .poll(async () => (await gateway.getRequests("users.prefs.set")).length) + .toBe(2); + expect((await gateway.getRequests("users.prefs.set")).at(-1)?.params).toMatchObject({ + entries: { "new-session.v1:main": { model: "" } }, + }); + expect((await readMainPreference(page))?.model).toBe("anthropic/claude-sonnet-4-6"); + await gateway.resolveDeferred("users.prefs.set", { status: "ok" }); + await expect.poll(async () => (await readMainPreference(page))?.model).toBeUndefined(); + }, + ); + }); + + it("resumes a partial multi-batch identity preference migration", async () => { + await withNewSessionPage(BASE_CONTEXT, async (page) => { + const appUrl = new URL(suite.server.baseUrl); + const gatewayUrl = `${appUrl.protocol === "https:" ? "wss:" : "ws:"}//${appUrl.host}`; + const storageKey = `openclaw.new-session.preferences.v1:${gatewayOriginScope(gatewayUrl)}`; + const agentIds = ["main", ...Array.from({ length: 32 }, (_, index) => `agent${index + 1}`)]; + const browserAgents = Object.fromEntries( + agentIds.map((agentId) => [agentId, { workspace: WORKSPACE, folder: WORKSPACE }]), + ); + const remoteEntries = Object.fromEntries( + agentIds + .slice(0, 32) + .map((agentId) => [`new-session.v1:${agentId}`, browserAgents[agentId]]), + ); + await page.addInitScript( + ({ key, agents }) => { + localStorage.setItem(key, JSON.stringify({ agents })); + }, + { key: storageKey, agents: browserAgents }, + ); + const gateway = await installMockGateway(page, { + presenceUsers: [{ self: true, id: "profile-alice", name: "Alice" }], + featureMethods: [ + "chat.metadata", + "chat.startup", + "sessions.create", + "users.prefs.get", + "users.prefs.set", + ], + methodResponses: { + "agents.list": mainAgentList(), + "users.prefs.get": { status: "ok", entries: remoteEntries }, + "users.prefs.set": { status: "ok" }, + }, + }); + + await page.goto(`${suite.server.baseUrl}new`); + const resumed = await gateway.waitForRequest("users.prefs.set"); + expect(resumed.params).toEqual({ + entries: { + "new-session.v1:agent32": { workspace: WORKSPACE, folder: WORKSPACE }, + "new-session.migration.v1": true, + }, + }); + await expect.poll(async () => (await gateway.getRequests("users.prefs.set")).length).toBe(1); + }); + }); + it("blocks an immediate submit until remembered model and worktree choices validate", async () => { await withNewSessionPage(BASE_CONTEXT, async (page) => { const models = MODELS; diff --git a/ui/src/pages/new-session/new-session-page.test.ts b/ui/src/pages/new-session/new-session-page.test.ts index 637f3fdfd425..6cf9c48505a6 100644 --- a/ui/src/pages/new-session/new-session-page.test.ts +++ b/ui/src/pages/new-session/new-session-page.test.ts @@ -1,3 +1,4 @@ +import { render, type TemplateResult } from "lit"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { ApplicationContext } from "../../app/context.ts"; import type { ChatAttachment } from "../../lib/chat/chat-types.ts"; @@ -21,6 +22,12 @@ type TestNewSessionPage = { gatewayConnected: boolean; gatewayRecoveryScope: string; gatewayUrl: string; + projectRecents: unknown; + projectsTask: { + run( + args: readonly [ApplicationContext["gateway"]["snapshot"]["client"], boolean, number], + ): Promise; + }; pendingCloud: { capture(): CloudSessionRecovery | null }; attachmentDraft: { attachments: ChatAttachment[]; @@ -30,6 +37,7 @@ type TestNewSessionPage = { submissionAccess(): { allowed: true }; submit(): Promise; setMessageFromUser(message: string): void; + renderPlaceSelect(): TemplateResult; updated(): void; }; @@ -188,3 +196,66 @@ describe("new session draft route ownership", () => { expect(navigate).toHaveBeenCalledOnce(); }); }); + +describe("new session project recents", () => { + const recentSession = { execCwd: "/workspace/recent" }; + + function createRecentsPage(request: (method: string) => Promise) { + const page = document.createElement( + "openclaw-new-session-page", + ) as unknown as TestNewSessionPage; + const client = { request } as unknown as ApplicationContext["gateway"]["snapshot"]["client"]; + page.agentId = "main"; + page.gatewayClient = client; + page.gatewayConnected = true; + page.gatewayUrl = "ws://gateway.example"; + page.context = { + gateway: { + connection: { gatewayUrl: page.gatewayUrl }, + snapshot: { + phase: "connected", + client, + selfUser: { id: "profile-a" }, + hello: { auth: { role: "operator", scopes: ["operator.read"] } }, + }, + }, + agents: { + state: { + agentsList: { + defaultId: "main", + mainKey: "main", + agents: [{ id: "main", workspace: "/workspace" }], + }, + }, + }, + sessions: { state: { result: { sessions: [recentSession] } } }, + config: { current: {} }, + } as unknown as ApplicationContext; + return { client, page }; + } + + async function expectRosterRecent(page: TestNewSessionPage) { + expect(page.projectRecents).toBeUndefined(); + const host = document.createElement("div"); + render(page.renderPlaceSelect(), host); + expect(host.querySelector('[data-value="recent::/workspace/recent"]')).not.toBeNull(); + } + + it("falls back to roster recents when projects.list omits server recents", async () => { + const { client, page } = createRecentsPage(async () => ({ projects: [] })); + + await page.projectsTask.run([client, true, 1]); + + await expectRosterRecent(page); + }); + + it("falls back to roster recents when projects.list fails", async () => { + const { client, page } = createRecentsPage(async () => { + throw new Error("projects unavailable"); + }); + + await page.projectsTask.run([client, true, 1]); + + await expectRosterRecent(page); + }); +}); diff --git a/ui/src/pages/new-session/new-session-page.ts b/ui/src/pages/new-session/new-session-page.ts index bb2ae5aee325..6aff40ae2b09 100644 --- a/ui/src/pages/new-session/new-session-page.ts +++ b/ui/src/pages/new-session/new-session-page.ts @@ -5,9 +5,12 @@ import { property, state } from "lit/decorators.js"; import type { FsListDirResult, ProjectRecord, + ProjectRecent, ProjectsListResult, ProjectsRegisterResult, SessionsCatalogStartTerminalResult, + UsersPrefsGetResult, + UsersPrefsSetResult, WorktreesBranchesResult, } from "../../../../packages/gateway-protocol/src/index.js"; import { selectApplicationSession } from "../../app/agent-selection.ts"; @@ -78,8 +81,13 @@ import { NewSessionModelControl } from "./model-control.ts"; import { isAbsolutePath, isKnownWorkspacePath } from "./path.ts"; import { renderPlaceSelect } from "./place-picker.ts"; import { + decodeIdentityPreferences, + encodeIdentityPreferences, + loadBrowserPreferences, loadNewSessionPreference, patchNewSessionPreference, + PREFS_MIGRATION_KEY, + replaceBrowserPreference, type NewSessionPreference, } from "./preferences.ts"; import { retainRejectedInitialTurn } from "./rejected-initial-turn.ts"; @@ -96,6 +104,7 @@ class NewSessionPage extends OpenClawLightDomElement { @state() private agentId = ""; @state() private folder = ""; @state() private projects: ProjectRecord[] = []; + @state() private projectRecents: ProjectRecent[] | undefined; @state() private projectId = ""; @state() private worktree = false; @state() private visibility: NewSessionVisibility = "normal"; @@ -166,6 +175,11 @@ class NewSessionPage extends OpenClawLightDomElement { private catalogRetryTimer: ReturnType | undefined; private cloudProfileRetryAttempt = 0; private cloudProfileRetryTimer: ReturnType | undefined; + private preferenceScope = ""; + private preferenceMode: "local" | "loading" | "remote" = "local"; + private identityPreferences: Record = {}; + private preferenceLoad: Promise = Promise.resolve(); + private preferenceWrite: Promise = Promise.resolve(); // Re-render when agents/sessions hydrate so the hero identity and the // recent-chats list appear without a route change. @@ -215,12 +229,14 @@ class NewSessionPage extends OpenClawLightDomElement { ] as const, task: async ([client, advertised]) => { if (!client || !advertised) { - return [] as ProjectRecord[]; + return { projects: [] } as ProjectsListResult; } - return (await client.request("projects.list", {})).projects ?? []; + return await client.request("projects.list", {}); }, - onComplete: (projects) => { + onComplete: (result) => { + const projects = result.projects ?? []; this.projects = projects; + this.projectRecents = result.recents; if (this.projectId && !projects.some((project) => project.id === this.projectId)) { this.projectId = ""; this.maybeLoadBranches(); @@ -228,6 +244,7 @@ class NewSessionPage extends OpenClawLightDomElement { }, onError: () => { this.projects = []; + this.projectRecents = undefined; this.projectId = ""; }, }); @@ -360,6 +377,99 @@ class NewSessionPage extends OpenClawLightDomElement { this.retryPendingCatalogTarget(); } } + this.synchronizeIdentityPreferences(snapshot.selfUser?.id); + } + + private synchronizeIdentityPreferences(profileId: string | undefined) { + const client = this.gatewayConnected ? this.gatewayClient : null; + const advertised = + this.context && + isGatewayMethodAdvertised(this.context.gateway.snapshot, "users.prefs.get") === true && + isGatewayMethodAdvertised(this.context.gateway.snapshot, "users.prefs.set") === true; + const scope = + client && profileId && advertised ? `${this.gatewayConnectionEpoch}\0${profileId}` : "local"; + if (scope === this.preferenceScope) { + return; + } + this.preferenceScope = scope; + this.identityPreferences = {}; + if (!client || !profileId || !advertised) { + this.preferenceMode = "local"; + this.preferenceLoad = Promise.resolve(); + return; + } + this.preferenceMode = "loading"; + this.preferenceLoad = this.loadIdentityPreferences({ + client, + gatewayUrl: this.gatewayUrl, + scope, + }); + } + + private async loadIdentityPreferences(params: { + client: NonNullable; + gatewayUrl: string; + scope: string; + }): Promise { + try { + const result = await params.client.request("users.prefs.get", {}); + if (this.preferenceScope !== params.scope) { + return; + } + if (result.status !== "ok") { + this.preferenceMode = "local"; + return; + } + let preferences = decodeIdentityPreferences(result.entries); + const browserPreferences = loadBrowserPreferences(params.gatewayUrl); + if (result.entries[PREFS_MIGRATION_KEY] !== true) { + const missingBrowserPreferences = Object.fromEntries( + Object.entries(browserPreferences).filter( + ([agentId]) => !Object.hasOwn(preferences, agentId), + ), + ); + const migrationEntries = [ + ...Object.entries(encodeIdentityPreferences(missingBrowserPreferences)), + [PREFS_MIGRATION_KEY, true] as const, + ]; + let migrationFailed = false; + for (let offset = 0; offset < migrationEntries.length; offset += 32) { + const batch = Object.fromEntries(migrationEntries.slice(offset, offset + 32)); + let response: UsersPrefsSetResult; + try { + response = await params.client.request("users.prefs.set", { + entries: batch, + }); + } catch { + migrationFailed = true; + break; + } + if (this.preferenceScope !== params.scope) { + return; + } + if (response.status !== "ok") { + migrationFailed = true; + break; + } + Object.assign(preferences, decodeIdentityPreferences(batch)); + } + if (migrationFailed) { + preferences = { ...browserPreferences, ...preferences }; + } + } + this.identityPreferences = preferences; + this.preferenceMode = "remote"; + for (const [agentId, preference] of Object.entries(preferences)) { + replaceBrowserPreference(params.gatewayUrl, agentId, preference); + } + if (this.agentsHydrated) { + this.adoptAgentDefaults({ preserveSelectedAgent: true, preserveSelectedFolder: true }); + } + } catch { + if (this.preferenceScope === params.scope) { + this.preferenceMode = "local"; + } + } } private invalidateGatewayDiscovery( @@ -397,6 +507,7 @@ class NewSessionPage extends OpenClawLightDomElement { this.agentSelectedByUser = false; this.folder = ""; this.projects = []; + this.projectRecents = undefined; this.projectId = ""; this.folderSelectedByUser = false; this.preferredWorktreeRestore = false; @@ -781,17 +892,51 @@ class NewSessionPage extends OpenClawLightDomElement { if (catalog.isTarget(this.data) || this.pendingCloud.sessionKey) { return null; } - return loadNewSessionPreference(this.gatewayUrl, this.agentId); + return this.preferenceMode === "remote" + ? (this.identityPreferences[normalizeAgentId(this.agentId)] ?? null) + : loadNewSessionPreference(this.gatewayUrl, this.agentId); } private persistPreference(patch: NewSessionPreference) { if (catalog.isTarget(this.data) || this.pendingCloud.sessionKey) { return; } - patchNewSessionPreference(this.gatewayUrl, this.agentId, { + const agentId = normalizeAgentId(this.agentId); + const nextPatch = { workspace: this.workspacePath(), ...patch, - }); + }; + if (this.preferenceMode === "local") { + patchNewSessionPreference(this.gatewayUrl, agentId, nextPatch); + return; + } + const scope = this.preferenceScope; + const client = this.gatewayClient; + const gatewayUrl = this.gatewayUrl; + const write = async () => { + await this.preferenceLoad; + if (!client || this.preferenceScope !== scope) { + return; + } + if (this.preferenceMode === "local") { + patchNewSessionPreference(gatewayUrl, agentId, nextPatch); + return; + } + const next = { ...this.identityPreferences[agentId], ...nextPatch }; + try { + const result = await client.request("users.prefs.set", { + entries: encodeIdentityPreferences({ [agentId]: next }), + }); + if (result.status !== "ok" || this.preferenceScope !== scope) { + return; + } + this.identityPreferences = { ...this.identityPreferences, [agentId]: next }; + replaceBrowserPreference(gatewayUrl, agentId, next); + } catch { + // Gateway state is authoritative for identified users; retain the last mirrored value. + } + }; + this.preferenceWrite = this.preferenceWrite.then(write, write); } private cancelRestoredFolderValidation() { @@ -1204,6 +1349,7 @@ class NewSessionPage extends OpenClawLightDomElement { const gateway = this.context?.gateway; if ( this.submitting || + this.preferenceMode === "loading" || this.requiresModelSetup() || this.attachmentDraft.pendingReads > 0 || (!pendingCloud && this.submissionOutcomeUnknown) || @@ -1967,6 +2113,7 @@ class NewSessionPage extends OpenClawLightDomElement { workspace: this.workspacePath(), workspaceRoots: this.knownWorkspaceRoots(), projects: catalog.isTarget(this.data) ? [] : this.projects, + recents: catalog.isTarget(this.data) ? [] : this.projectRecents, projectId: this.projectId, sessions: this.context?.sessions.state.result?.sessions ?? [], execNodes: this.isAdmin() ? execNodes : [], diff --git a/ui/src/pages/new-session/place-picker.ts b/ui/src/pages/new-session/place-picker.ts index 31a3feda082c..35bf2e354e7f 100644 --- a/ui/src/pages/new-session/place-picker.ts +++ b/ui/src/pages/new-session/place-picker.ts @@ -2,6 +2,7 @@ import { html, nothing } from "lit"; import type { FsListDirResult, ProjectRecord, + ProjectRecent, } from "../../../../packages/gateway-protocol/src/index.js"; import { icons } from "../../components/icons.ts"; import { t } from "../../i18n/index.ts"; @@ -156,6 +157,7 @@ export function renderPlaceSelect(params: { workspace: string; workspaceRoots: readonly string[]; projects: readonly ProjectRecord[]; + recents?: readonly ProjectRecent[]; projectId: string; sessions: readonly RecentPlaceSource[]; execNodes: DraftNode[]; @@ -227,26 +229,52 @@ export function renderPlaceSelect(params: { : gatewayLabel; const label = params.showDestinations ? `${folderLabel} 路 ${destinationLabel}` : folderLabel; const effectiveFolder = folder || params.workspace; - const recents = recentPlaces(params.sessions, { - workspace: params.workspace, - execNodes: params.execNodes, - allowGatewayFolder: (recentFolder) => - params.isAdmin || isKnownWorkspacePath(params.workspaceRoots, recentFolder), - }); + const allowGatewayFolder = (recentFolder: string) => + params.isAdmin || isKnownWorkspacePath(params.workspaceRoots, recentFolder); + const serverRecents = params.recents?.filter((recent) => + recent.kind === "project" + ? params.projects.some((project) => project.id === recent.projectId) + : recent.execNode + ? params.execNodes.some((node) => node.nodeId === recent.execNode) + : allowGatewayFolder(recent.folder), + ); + const recents: ProjectRecent[] = + serverRecents ?? + recentPlaces(params.sessions, { + workspace: params.workspace, + execNodes: params.execNodes, + allowGatewayFolder, + }).map((recent) => { + const item: ProjectRecent = { + kind: "folder", + folder: recent.folder, + displayName: folderDisplayName(recent.folder), + }; + if (recent.execNode) { + item.execNode = recent.execNode; + } + return item; + }); const recentItems = recents.map((recent) => { - const node = params.execNodes.find((candidate) => candidate.nodeId === recent.execNode); + const node = + recent.kind === "folder" && recent.execNode + ? params.execNodes.find((candidate) => candidate.nodeId === recent.execNode) + : undefined; const recentLabel = params.showDestinations && node - ? `${folderDisplayName(recent.folder)} 路 ${node.displayName}` - : folderDisplayName(recent.folder); + ? `${recent.displayName} 路 ${node.displayName}` + : recent.displayName; return { ...recent, label: recentLabel, node }; }); const recentSuffixes = disambiguate(recentItems, (recent) => recent.label, [ - (recent) => parentFolderDisplayName(recent.folder), - (recent) => recent.folder, + (recent) => (recent.kind === "folder" ? parentFolderDisplayName(recent.folder) : undefined), + (recent) => (recent.kind === "folder" ? recent.folder : undefined), (recent) => recent.node?.modelIdentifier, (recent) => recent.node?.remoteIp, - (recent) => `${recent.folder}${recent.execNode ? ` 路 ${recent.execNode.slice(0, 8)}` : ""}`, + (recent) => + recent.kind === "folder" + ? `${recent.folder}${recent.execNode ? ` 路 ${recent.execNode.slice(0, 8)}` : ""}` + : recent.projectId, ]); const nodeSuffixes = disambiguate(params.execNodes, (node) => node.displayName, [ (node) => node.modelIdentifier, @@ -372,15 +400,24 @@ export function renderPlaceSelect(params: { ${recentItems.map((recent, index) => { return renderSessionMenuItem( { - value: `recent:${recent.execNode}:${recent.folder}`, + value: + recent.kind === "project" + ? `recent-project:${recent.projectId}` + : `recent:${recent.execNode ?? ""}:${recent.folder}`, label: recent.label, + icon: recent.kind === "project" ? icons.gitBranch : icons.folder, sub: recentSuffixes[index], checked: - !params.projectId && - params.execNode === recent.execNode && - folder === recent.folder, - title: recent.folder, - onSelect: () => params.onApplyFolder(recent.folder, recent.execNode), + recent.kind === "project" + ? params.projectId === recent.projectId + : !params.projectId && + params.execNode === (recent.execNode ?? "") && + folder === recent.folder, + title: recent.kind === "project" ? undefined : recent.folder, + onSelect: () => + recent.kind === "project" + ? params.onSelectProject(recent.projectId) + : params.onApplyFolder(recent.folder, recent.execNode ?? ""), }, params.submitting, ); diff --git a/ui/src/pages/new-session/preferences.test.ts b/ui/src/pages/new-session/preferences.test.ts index 0f7305c651bf..f30cdb1b6c6b 100644 --- a/ui/src/pages/new-session/preferences.test.ts +++ b/ui/src/pages/new-session/preferences.test.ts @@ -1,6 +1,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { createStorageMock } from "../../test-helpers/storage.ts"; -import { loadNewSessionPreference, patchNewSessionPreference } from "./preferences.ts"; +import { + decodeIdentityPreferences, + encodeIdentityPreferences, + loadBrowserPreferences, + loadNewSessionPreference, + patchNewSessionPreference, + replaceBrowserPreference, +} from "./preferences.ts"; describe("new-session browser preferences", () => { beforeEach(() => { @@ -44,4 +51,23 @@ describe("new-session browser preferences", () => { ); expect(loadNewSessionPreference("ws://one.example", "main")).toBeNull(); }); + + it("round-trips normalized browser preferences through identity keys", () => { + patchNewSessionPreference("ws://one.example", "Main", { folder: "/local", worktree: true }); + const browser = loadBrowserPreferences("ws://one.example"); + expect(encodeIdentityPreferences(browser)).toEqual({ + "new-session.v1:main": { folder: "/local", worktree: true }, + }); + expect( + decodeIdentityPreferences({ + unrelated: { folder: "/ignored" }, + "new-session.v1:main": { folder: "/gateway", model: "openai/test" }, + }), + ).toEqual({ main: { folder: "/gateway", model: "openai/test" } }); + + replaceBrowserPreference("ws://one.example", "main", { folder: "/gateway" }); + expect(loadNewSessionPreference("ws://one.example", "main")).toEqual({ + folder: "/gateway", + }); + }); }); diff --git a/ui/src/pages/new-session/preferences.ts b/ui/src/pages/new-session/preferences.ts index dda7b349d511..55c48eed9e88 100644 --- a/ui/src/pages/new-session/preferences.ts +++ b/ui/src/pages/new-session/preferences.ts @@ -4,6 +4,8 @@ import { normalizeOptionalString } from "../../lib/string-coerce.ts"; import { getSafeLocalStorage } from "../../local-storage.ts"; const STORAGE_KEY_PREFIX = "openclaw.new-session.preferences.v1:"; +const IDENTITY_KEY_PREFIX = "new-session.v1:"; +export const PREFS_MIGRATION_KEY = "new-session.migration.v1"; export type NewSessionPreference = { workspace?: string; @@ -67,6 +69,71 @@ export function loadNewSessionPreference( return normalizePreference(readStore(storage, gatewayUrl).agents?.[normalizedAgentId]); } +export function loadBrowserPreferences(gatewayUrl: string): Record { + const storage = getSafeLocalStorage(); + if (!storage || !gatewayUrl) { + return {}; + } + const entries = Object.entries(readStore(storage, gatewayUrl).agents ?? {}).flatMap( + ([agentId, value]) => { + const normalizedAgentId = normalizeAgentId(agentId); + const preference = normalizePreference(value); + return normalizedAgentId && preference ? [[normalizedAgentId, preference] as const] : []; + }, + ); + return Object.fromEntries(entries); +} + +export function encodeIdentityPreferences( + preferences: Record, +): Record { + return Object.fromEntries( + Object.entries(preferences) + .toSorted(([left], [right]) => left.localeCompare(right)) + .map(([agentId, preference]) => [`${IDENTITY_KEY_PREFIX}${agentId}`, preference]), + ); +} + +export function decodeIdentityPreferences( + entries: Record, +): Record { + return Object.fromEntries( + Object.entries(entries).flatMap(([key, value]) => { + if (!key.startsWith(IDENTITY_KEY_PREFIX)) { + return []; + } + const agentId = normalizeAgentId(key.slice(IDENTITY_KEY_PREFIX.length)); + const preference = normalizePreference(value); + return agentId && preference ? [[agentId, preference] as const] : []; + }), + ); +} + +export function replaceBrowserPreference( + gatewayUrl: string, + agentId: string, + preference: NewSessionPreference, +): void { + const storage = getSafeLocalStorage(); + const normalizedAgentId = normalizeAgentId(agentId); + const normalized = normalizePreference(preference); + if (!storage || !gatewayUrl || !normalizedAgentId || !normalized) { + return; + } + const store = readStore(storage, gatewayUrl); + try { + storage.setItem( + storageKey(gatewayUrl), + JSON.stringify({ + ...store, + agents: { ...store.agents, [normalizedAgentId]: normalized }, + } satisfies PersistedPreferences), + ); + } catch { + // Browser storage can be disabled or full; preferences are best effort. + } +} + export function patchNewSessionPreference( gatewayUrl: string, agentId: string,