mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-15 15:13:48 -06:00
Start new sessions with folder group defaults (#123276)
* feat(ui): add folder group session defaults * fix(ci): align folder group contracts * fix(protocol): refresh Android gateway methods * fix(ui): reuse folder picker for group defaults * fix(ui): harden session group defaults * test(ui): align group defaults with current main * test(ui): keep group catalog defaults path-free * fix(ui): close folder group CI gaps * fix(ui): satisfy folder group CI contracts * fix(session-groups): enforce defaults safety * test(gateway): keep group defaults in workspace * fix(session-groups): enforce defaults participation * fix(session-groups): close review authorization races * fix(session-groups): canonicalize defaults authorization --------- Co-authored-by: Jesse Merhi <jesse-merhi@users.noreply.github.com> Co-authored-by: Jesse Merhi <openclaw@users.noreply.github.com>
This commit is contained in:
@@ -406,8 +406,10 @@ enum class GatewayMethod(
|
||||
SessionsDelete("sessions.delete"),
|
||||
SessionsCompact("sessions.compact"),
|
||||
SessionsGroupsList("sessions.groups.list"),
|
||||
SessionsGroupsDefaults("sessions.groups.defaults"),
|
||||
SessionsGroupsPut("sessions.groups.put"),
|
||||
SessionsGroupsRename("sessions.groups.rename"),
|
||||
SessionsGroupsUpdate("sessions.groups.update"),
|
||||
SessionsGroupsDelete("sessions.groups.delete"),
|
||||
LastHeartbeat("last-heartbeat"),
|
||||
SetHeartbeats("set-heartbeats"),
|
||||
|
||||
@@ -8437,6 +8437,7 @@ public struct SessionsCreateParams: Codable, Sendable {
|
||||
public let key: String?
|
||||
public let agentid: String?
|
||||
public let label: String?
|
||||
public let category: String?
|
||||
public let model: String?
|
||||
public let thinkinglevel: String?
|
||||
public let incognito: Bool?
|
||||
@@ -8462,6 +8463,7 @@ public struct SessionsCreateParams: Codable, Sendable {
|
||||
key: String? = nil,
|
||||
agentid: String? = nil,
|
||||
label: String? = nil,
|
||||
category: String? = nil,
|
||||
model: String? = nil,
|
||||
thinkinglevel: String? = nil,
|
||||
incognito: Bool? = nil,
|
||||
@@ -8486,6 +8488,7 @@ public struct SessionsCreateParams: Codable, Sendable {
|
||||
self.key = key
|
||||
self.agentid = agentid
|
||||
self.label = label
|
||||
self.category = category
|
||||
self.model = model
|
||||
self.thinkinglevel = thinkinglevel
|
||||
self.incognito = incognito
|
||||
@@ -8512,6 +8515,7 @@ public struct SessionsCreateParams: Codable, Sendable {
|
||||
case key
|
||||
case agentid = "agentId"
|
||||
case label
|
||||
case category
|
||||
case model
|
||||
case thinkinglevel = "thinkingLevel"
|
||||
case incognito
|
||||
@@ -9217,6 +9221,28 @@ public struct SessionGroup: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionGroupDefaults: Codable, Sendable {
|
||||
public let name: String
|
||||
public let cwd: String?
|
||||
public let worktree: Bool?
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
cwd: String? = nil,
|
||||
worktree: Bool? = nil)
|
||||
{
|
||||
self.name = name
|
||||
self.cwd = cwd
|
||||
self.worktree = worktree
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case cwd
|
||||
case worktree
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsGroupsListParams: Codable, Sendable {}
|
||||
|
||||
public struct SessionsGroupsListResult: Codable, Sendable {
|
||||
@@ -9237,6 +9263,22 @@ public struct SessionsGroupsListResult: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsGroupsDefaultsParams: Codable, Sendable {}
|
||||
|
||||
public struct SessionsGroupsDefaultsResult: Codable, Sendable {
|
||||
public let defaults: [SessionGroupDefaults]
|
||||
|
||||
public init(
|
||||
defaults: [SessionGroupDefaults])
|
||||
{
|
||||
self.defaults = defaults
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case defaults
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsGroupsPutParams: Codable, Sendable {
|
||||
public let names: [String]
|
||||
public let sectionorder: [String]?
|
||||
@@ -9273,6 +9315,46 @@ public struct SessionsGroupsRenameParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsGroupsUpdateParams: Codable, Sendable {
|
||||
public let name: String
|
||||
public let cwd: AnyCodable
|
||||
public let worktree: Bool
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
cwd: AnyCodable,
|
||||
worktree: Bool)
|
||||
{
|
||||
self.name = name
|
||||
self.cwd = cwd
|
||||
self.worktree = worktree
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case cwd
|
||||
case worktree
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsGroupsUpdateResult: Codable, Sendable {
|
||||
public let ok: Bool
|
||||
public let defaults: [SessionGroupDefaults]
|
||||
|
||||
public init(
|
||||
ok: Bool,
|
||||
defaults: [SessionGroupDefaults])
|
||||
{
|
||||
self.ok = ok
|
||||
self.defaults = defaults
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ok
|
||||
case defaults
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsGroupsDeleteParams: Codable, Sendable {
|
||||
public let name: String
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"startupJsGzipBytes": 333338,
|
||||
"reason": "cumulative 2026-08-15 UI fix growth (tab retry/session attention/talk backpressure stream) plus chat transcript virtualizer re-attach fix (#123974); CI runs 31861213699 and 31861214469 build-artifacts bytes",
|
||||
"startupJsGzipBytes": 334391,
|
||||
"reason": "cumulative 2026-08-15 UI growth plus folder group defaults catalog and lazy New Session route resolution",
|
||||
"updatedAt": "2026-08-15"
|
||||
}
|
||||
|
||||
@@ -649,9 +649,9 @@ methods. Treat this as feature discovery, not a full enumeration of
|
||||
- `sessions.preview` returns bounded transcript previews for specific session keys.
|
||||
- `sessions.describe` returns one gateway session row for an exact session key.
|
||||
- `sessions.resolve` resolves or canonicalizes a session target by key, raw session ID, label, or Control UI short ID. Ambiguous short IDs return a bounded candidate list as a successful RPC result.
|
||||
- `sessions.create` creates a new session entry. Optional `model` and `thinkingLevel` values persist the initial model and reasoning overrides atomically. `worktree: true` provisions a managed worktree; optional `worktreeBaseRef`/`worktreeName` select the base ref and branch name, and `execNode` (`operator.admin`) binds session exec to a node host. Without `worktreeName`, OpenClaw derives a readable name from the session label or generated first-message title, then falls back to a crustacean-themed name; names already occupied by another owner, local branch, or unmanaged path receive a numeric suffix. The created worktree is echoed in the result and persisted on the session row (`worktree: { id, branch, repoRoot }`). When the entry is created but its nested initial `chat.send` is rejected, the successful result includes `runStarted: false` and `runError`; clients can preserve the prompt and retry against the returned session key. A caller that passes `parentSessionKey` with `emitCommandHooks: true` should also declare the lifecycle disposition of a distinct child: `succeedsParent: true` ends the parent with `session_end`, while `false` keeps the parent active and emits only the child's `session_start`. Omitting `succeedsParent` preserves the legacy parent-rollover behavior for existing clients. The disposition requires both parent linkage and command hooks; a fork cannot succeed its parent. Main-session reset-in-place behavior is unchanged because no distinct child is created. New rows are stamped with write-once creation provenance (`createdVia`, `createdActor`, `createdAt`) from the trusted creation seam; adopting an existing key never restamps it. For human profile actors, `createdActor.label` is resolved from the current user profile when the row is projected and is never stored on the session entry, so profile renames do not drift. Session rows also carry `parentSessionKey` (navigation parent, persisted), `controlOwnerSessionKey` (runtime controller when live), `forkSource` (exact source key + transcript generation for forks), and `previousSessionId` (prior transcript generation under the same key).
|
||||
- `sessions.create` creates a new session entry. Optional `model` and `thinkingLevel` values persist the initial model and reasoning overrides atomically; optional `category` assigns the session to a custom group and registers that group when first used. `worktree: true` provisions a managed worktree; optional `worktreeBaseRef`/`worktreeName` select the base ref and branch name, and `execNode` (`operator.admin`) binds session exec to a node host. Without `worktreeName`, OpenClaw derives a readable name from the session label or generated first-message title, then falls back to a crustacean-themed name; names already occupied by another owner, local branch, or unmanaged path receive a numeric suffix. The created worktree is echoed in the result and persisted on the session row (`worktree: { id, branch, repoRoot }`). When the entry is created but its nested initial `chat.send` is rejected, the successful result includes `runStarted: false` and `runError`; clients can preserve the prompt and retry against the returned session key. A caller that passes `parentSessionKey` with `emitCommandHooks: true` should also declare the lifecycle disposition of a distinct child: `succeedsParent: true` ends the parent with `session_end`, while `false` keeps the parent active and emits only the child's `session_start`. Omitting `succeedsParent` preserves the legacy parent-rollover behavior for existing clients. The disposition requires both parent linkage and command hooks; a fork cannot succeed its parent. Main-session reset-in-place behavior is unchanged because no distinct child is created. New rows are stamped with write-once creation provenance (`createdVia`, `createdActor`, `createdAt`) from the trusted creation seam; adopting an existing key never restamps it. For human profile actors, `createdActor.label` is resolved from the current user profile when the row is projected and is never stored on the session entry, so profile renames do not drift. Session rows also carry `parentSessionKey` (navigation parent, persisted), `controlOwnerSessionKey` (runtime controller when live), `forkSource` (exact source key + transcript generation for forks), and `previousSessionId` (prior transcript generation under the same key).
|
||||
- `sessions.dispatch` (`operator.admin`) moves an existing local OpenClaw session with a live, registry-owned session managed worktree to a configured cloud-worker profile. Pass `{ key, profileId, agentId? }`. The Gateway does not advertise the method when no worker profile is configured. Dispatch closes local turn admission before draining active work and returns only after placement reaches `active` worker ownership. Arbitrary plain directories are not dispatchable; after admission, the workspace transport may use manifest mirroring if the managed worktree's Git metadata later becomes unavailable. SSH fallback candidates rotate only for idempotent probes, content-addressed transfers, receipt/lock-guarded artifact installation, convergent managed-worktree mirroring, and tunnel reconnects. Ambiguous unguarded stateful commands fail closed and are not replayed. Dispatch is one-way; worker-to-local pull-back is not part of this RPC.
|
||||
- `sessions.groups.list`, `sessions.groups.put`, `sessions.groups.rename`, and `sessions.groups.delete` manage the gateway-owned custom session group catalog (names + display order). Membership stays on each session's `category` field; rename and delete update member sessions server-side.
|
||||
- `sessions.groups.list`, `sessions.groups.put`, `sessions.groups.rename`, and `sessions.groups.delete` manage the gateway-owned custom session group catalog (names + display order). The read-scoped list result is intentionally path-free. `sessions.groups.defaults` and `sessions.groups.update` require `operator.write` and read or replace one custom group's optional working-directory and worktree defaults. Non-admin callers can save only directories inside a configured agent workspace; other absolute Gateway paths require `operator.admin`. Membership stays on each session's `category` field; rename and delete update member sessions server-side.
|
||||
- `sessions.send` sends a message into an existing session.
|
||||
- `sessions.steer` is the interrupt-and-steer variant for an active session.
|
||||
- `sessions.abort` aborts active work for a session. Pass `key` plus optional `runId`, or `runId` alone for active runs the gateway can resolve to a session. Supplying `runId` keeps cancellation scoped to that run. Set `clearQueued: true` on a key-only non-global request to also discard followup and lane queues owned by that session. Existing callers that omit `clearQueued` preserve those queues. The literal `global` key keeps the existing agent-qualified `chat.abort` ownership rules and does not perform non-global followup or lane cleanup.
|
||||
|
||||
@@ -96,7 +96,7 @@ Disable the feature entirely with **Settings → General → Quick Chat**; the s
|
||||
- Data plane: Gateway WS methods `chat.history`, `chat.message.get`, `chat.send`, `chat.abort`, `chat.inject`, plus `question.list` and `question.resolve`, and events `chat`, `agent`, `presence`, `tick`, `health`; question cards follow `question.requested` and `question.resolved` events and refresh from `question.list` after reconnects.
|
||||
- `chat.history` returns a display-normalized transcript: inline directive tags are stripped from visible text, plain-text tool-call XML payloads (`<tool_call>`, `<function_call>`, `<tool_calls>`, `<function_calls>`, including truncated blocks) and leaked model control tokens are stripped, pure silent-token assistant rows such as exact `NO_REPLY`/`no_reply` are omitted, and oversized rows can be replaced with a truncated placeholder.
|
||||
- Session: defaults to the primary session as above; the UI can switch between sessions.
|
||||
- Session groups: `sessions.groups.list`, `sessions.groups.put`, `sessions.groups.rename`, and `sessions.groups.delete` own the group catalog. Membership is the session `category` updated through `sessions.patch`.
|
||||
- Session groups: `sessions.groups.list`, `sessions.groups.put`, `sessions.groups.rename`, and `sessions.groups.delete` own the path-free group catalog. Write-scoped `sessions.groups.defaults` and `sessions.groups.update` own optional New Session folder/worktree defaults. Membership is the session `category` updated through `sessions.patch` or assigned during `sessions.create`.
|
||||
- Unread state: after a session activates and its live history loads successfully, the app clears that session's unread marker. Failed history loads do not clear it; a transient patch failure retries on the next activation.
|
||||
- Onboarding uses a dedicated session to keep first-run setup separate.
|
||||
- Offline cache: the app keeps a small read-only cache of recent chat sessions and transcripts per gateway (`~/Library/Application Support/OpenClaw/chat-cache.sqlite`): cold opens paint the last known transcript immediately and refresh once the Gateway responds, and recent chats stay browsable while disconnected (sending stays disabled until the connection is back).
|
||||
|
||||
@@ -238,7 +238,7 @@ and [Linux](/platforms/linux) desktop apps, the
|
||||
|
||||
## Sidebar navigation
|
||||
|
||||
The sidebar organizes everything around the agent. The identity row at the top is the active agent; below it, the **Pages** section starts with **Home** — the agent's rolling main session, badged with its unread or running state — followed by the pinned destinations (**Automations** and **Plugins** by default). The customize control on the Pages header opens a menu with every other destination, including **Usage** and plugin-provided tabs, plus **Edit pinned items**; right-clicking the navigation area opens the pin editor directly. The session list below splits into zones: **Threads** for the agent's chat sessions (the main session stays behind Home; sessions it spawned appear here as top-level threads, and named threads show without a type prefix), **Groups** for group and room conversations, and **Coding** for sessions bound to a managed worktree or exec node (rows show a `repo ⎇ branch` line plus the node host), ACP-backed harness sessions, and the Codex/Claude CLI catalogs. Coding starts collapsed on first run and remembers your choice; its collapsed header keeps the true count and shows a running indicator while contained sessions work. Custom groups (the session `category`) and **Pinned** rows sit above Threads, and assigning a session to a custom group always wins over the automatic zone classification. The Threads header holds the sort control (Created, Last updated, or People when the Gateway reports multiple server identities), Group by, a persisted **Status** filter for Active, Archived, or All, and the **+** that opens the New session page. People orders creator groups by name and keeps Created order within each group. Archived rows stay inline, dimmed with an archive glyph; they do not contribute unread or attention state and stay outside lineage promotion. Opening a session moves the selection highlight without reordering rows. Parent sessions with recent child runs show a disclosure and child count; expand it to inspect nested child sessions, live or terminal status, and runtime without leaving the sidebar. Selecting a child opens its chat and automatically reveals its ancestor path. Child rows stay outside root grouping, pinning, dragging, multi-select, and pagination; collapsed zones do not consume the visible page budget. Sessions with new activity since they were last read show an unread dot, and opening one marks it read. A session holding composer text you typed but never sent shows a pencil badge until the draft is sent or cleared; the active session hides it because its composer is already in view. An agent can also publish a short expiring status line and optionally request attention with a curated amber icon; that declaration clears when you open the session, send the next message, clear it explicitly, or its TTL expires. Cloud-worker lifecycle states use a globe badge; local and reclaimed sessions omit a placement badge because local execution is the default. Each root session row has a context menu (kebab button or right-click) with Pin/Unpin, Mark as unread/read, Rename, Fork, Move to group (including New group and Remove from group), Archive or Unarchive, and Delete; touch layouts keep the direct pin and menu controls visible. Cmd/Ctrl-click toggles root rows into a multi-select and Shift-click extends it across the visible order; opening the menu on a selected row then offers batch actions (Mark N as unread/read, Move N to group, Archive N, Delete N) that apply to every selected session, with a single confirmation for batch delete. Drag a root session onto **Pinned** to pin it, or onto a custom group to move it. Custom group headers can be collapsed, expanded, or dragged to reorder them; group names and their order live in the gateway (`sessions.groups.*`), so they follow you across browsers, while collapsed state stays in the browser profile. Group headers also have a menu (kebab button or right-click) with Rename group, New group, and Delete group; renaming or deleting a group updates every member session server-side, including archived ones, and deleting a group keeps its sessions and moves them back to Threads.
|
||||
The sidebar organizes everything around the agent. The identity row at the top is the active agent; below it, the **Pages** section starts with **Home** — the agent's rolling main session, badged with its unread or running state — followed by the pinned destinations (**Automations** and **Plugins** by default). The customize control on the Pages header opens a menu with every other destination, including **Usage** and plugin-provided tabs, plus **Edit pinned items**; right-clicking the navigation area opens the pin editor directly. The session list below splits into zones: **Threads** for the agent's chat sessions (the main session stays behind Home; sessions it spawned appear here as top-level threads, and named threads show without a type prefix), **Groups** for group and room conversations, and **Coding** for sessions bound to a managed worktree or exec node (rows show a `repo ⎇ branch` line plus the node host), ACP-backed harness sessions, and the Codex/Claude CLI catalogs. Coding starts collapsed on first run and remembers your choice; its collapsed header keeps the true count and shows a running indicator while contained sessions work. Custom groups (the session `category`) and **Pinned** rows sit above Threads, and assigning a session to a custom group always wins over the automatic zone classification. The Threads header holds the sort control (Created, Last updated, or People when the Gateway reports multiple server identities), Group by, a persisted **Status** filter for Active, Archived, or All, and the **+** that opens the New session page. People orders creator groups by name and keeps Created order within each group. Archived rows stay inline, dimmed with an archive glyph; they do not contribute unread or attention state and stay outside lineage promotion. Opening a session moves the selection highlight without reordering rows. Parent sessions with recent child runs show a disclosure and child count; expand it to inspect nested child sessions, live or terminal status, and runtime without leaving the sidebar. Selecting a child opens its chat and automatically reveals its ancestor path. Child rows stay outside root grouping, pinning, dragging, multi-select, and pagination; collapsed zones do not consume the visible page budget. Sessions with new activity since they were last read show an unread dot, and opening one marks it read. A session holding composer text you typed but never sent shows a pencil badge until the draft is sent or cleared; the active session hides it because its composer is already in view. An agent can also publish a short expiring status line and optionally request attention with a curated amber icon; that declaration clears when you open the session, send the next message, clear it explicitly, or its TTL expires. Cloud-worker lifecycle states use a globe badge; local and reclaimed sessions omit a placement badge because local execution is the default. Each root session row has a context menu (kebab button or right-click) with Pin/Unpin, Mark as unread/read, Rename, Fork, Move to group (including New group and Remove from group), Archive or Unarchive, and Delete; touch layouts keep the direct pin and menu controls visible. Cmd/Ctrl-click toggles root rows into a multi-select and Shift-click extends it across the visible order; opening the menu on a selected row then offers batch actions (Mark N as unread/read, Move N to group, Archive N, Delete N) that apply to every selected session, with a single confirmation for batch delete. Drag a root session onto **Pinned** to pin it, or onto a custom group to move it. Custom group headers can be collapsed, expanded, or dragged to reorder them; group names, order, and New Session defaults live in the gateway (`sessions.groups.*`), so they follow you across browsers, while collapsed state stays in the browser profile. Each custom group header has a **+** that opens the normal New Session page and assigns the created session to that group. **New session defaults** in the group menu sets its working directory and Local or Worktree preference; the page prefills those values but leaves them editable. Leaving the directory empty uses the selected agent's workspace. The menu also has Rename group, New group, and Delete group; renaming or deleting a group updates every member session server-side, including archived ones, and deleting a group keeps its sessions and moves them back to Threads.
|
||||
|
||||
## New session page
|
||||
|
||||
|
||||
@@ -318,10 +318,15 @@ export {
|
||||
SessionsResetParamsSchema,
|
||||
SessionsDeleteParamsSchema,
|
||||
SessionGroupSchema,
|
||||
SessionGroupDefaultsSchema,
|
||||
SessionsGroupsListParamsSchema,
|
||||
SessionsGroupsListResultSchema,
|
||||
SessionsGroupsDefaultsParamsSchema,
|
||||
SessionsGroupsDefaultsResultSchema,
|
||||
SessionsGroupsPutParamsSchema,
|
||||
SessionsGroupsRenameParamsSchema,
|
||||
SessionsGroupsUpdateParamsSchema,
|
||||
SessionsGroupsUpdateResultSchema,
|
||||
SessionsGroupsDeleteParamsSchema,
|
||||
SessionsGroupsMutationResultSchema,
|
||||
SessionsCompactParamsSchema,
|
||||
|
||||
@@ -58,10 +58,15 @@ export const SessionLifecycleProtocolSchemas = {
|
||||
SessionsResetParams: sessions.SessionsResetParamsSchema,
|
||||
SessionsDeleteParams: sessions.SessionsDeleteParamsSchema,
|
||||
SessionGroup: sessions.SessionGroupSchema,
|
||||
SessionGroupDefaults: sessions.SessionGroupDefaultsSchema,
|
||||
SessionsGroupsListParams: sessions.SessionsGroupsListParamsSchema,
|
||||
SessionsGroupsListResult: sessions.SessionsGroupsListResultSchema,
|
||||
SessionsGroupsDefaultsParams: sessions.SessionsGroupsDefaultsParamsSchema,
|
||||
SessionsGroupsDefaultsResult: sessions.SessionsGroupsDefaultsResultSchema,
|
||||
SessionsGroupsPutParams: sessions.SessionsGroupsPutParamsSchema,
|
||||
SessionsGroupsRenameParams: sessions.SessionsGroupsRenameParamsSchema,
|
||||
SessionsGroupsUpdateParams: sessions.SessionsGroupsUpdateParamsSchema,
|
||||
SessionsGroupsUpdateResult: sessions.SessionsGroupsUpdateResultSchema,
|
||||
SessionsGroupsDeleteParams: sessions.SessionsGroupsDeleteParamsSchema,
|
||||
SessionsGroupsMutationResult: sessions.SessionsGroupsMutationResultSchema,
|
||||
SessionsCompactParams: sessions.SessionsCompactParamsSchema,
|
||||
|
||||
@@ -9,6 +9,7 @@ export const SessionsCreateParamsSchema = closedObject({
|
||||
key: Type.Optional(NonEmptyString),
|
||||
agentId: Type.Optional(NonEmptyString),
|
||||
label: Type.Optional(SessionLabelString),
|
||||
category: Type.Optional(SessionLabelString),
|
||||
model: Type.Optional(NonEmptyString),
|
||||
thinkingLevel: Type.Optional(NonEmptyString),
|
||||
incognito: Type.Optional(Type.Boolean()),
|
||||
|
||||
@@ -569,6 +569,13 @@ export const SessionGroupSchema = closedObject({
|
||||
position: Type.Integer({ minimum: 0 }),
|
||||
});
|
||||
|
||||
/** New Session defaults visible only to operators who can update them. */
|
||||
export const SessionGroupDefaultsSchema = closedObject({
|
||||
name: SessionLabelString,
|
||||
cwd: Type.Optional(NonEmptyString),
|
||||
worktree: Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
const SidebarSectionIdString = Type.String({ minLength: 1, maxLength: 512 });
|
||||
|
||||
/** Custom session group catalog in display order. */
|
||||
@@ -577,6 +584,14 @@ export const SessionsGroupsListResultSchema = closedObject({
|
||||
sectionOrder: Type.Optional(Type.Array(SidebarSectionIdString, { maxItems: 232 })),
|
||||
});
|
||||
|
||||
/** Reads the New Session defaults for the custom group catalog. */
|
||||
export const SessionsGroupsDefaultsParamsSchema = closedObject({});
|
||||
|
||||
/** Write-scoped group defaults, kept separate from the read-scoped catalog. */
|
||||
export const SessionsGroupsDefaultsResultSchema = closedObject({
|
||||
defaults: Type.Array(SessionGroupDefaultsSchema),
|
||||
});
|
||||
|
||||
/** Replaces the ordered group catalog; creates listed names, keeps member categories untouched. */
|
||||
export const SessionsGroupsPutParamsSchema = closedObject({
|
||||
names: Type.Array(SessionLabelString, { maxItems: 200 }),
|
||||
@@ -589,6 +604,19 @@ export const SessionsGroupsRenameParamsSchema = closedObject({
|
||||
to: SessionLabelString,
|
||||
});
|
||||
|
||||
/** Updates the New Session defaults owned by one custom group. */
|
||||
export const SessionsGroupsUpdateParamsSchema = closedObject({
|
||||
name: SessionLabelString,
|
||||
cwd: Type.Union([NonEmptyString, Type.Null()]),
|
||||
worktree: Type.Boolean(),
|
||||
});
|
||||
|
||||
/** Result after updating defaults without widening the read-scoped catalog. */
|
||||
export const SessionsGroupsUpdateResultSchema = closedObject({
|
||||
ok: Type.Literal(true),
|
||||
defaults: Type.Array(SessionGroupDefaultsSchema),
|
||||
});
|
||||
|
||||
/** Deletes a group and clears every member session's category. */
|
||||
export const SessionsGroupsDeleteParamsSchema = closedObject({ name: SessionLabelString });
|
||||
|
||||
@@ -831,10 +859,15 @@ export type SessionsPluginPatchResult = Static<typeof SessionsPluginPatchResultS
|
||||
export type SessionsResetParams = Static<typeof SessionsResetParamsSchema>;
|
||||
export type SessionsDeleteParams = Static<typeof SessionsDeleteParamsSchema>;
|
||||
export type SessionGroup = Static<typeof SessionGroupSchema>;
|
||||
export type SessionGroupDefaults = Static<typeof SessionGroupDefaultsSchema>;
|
||||
export type SessionsGroupsListParams = Static<typeof SessionsGroupsListParamsSchema>;
|
||||
export type SessionsGroupsListResult = Static<typeof SessionsGroupsListResultSchema>;
|
||||
export type SessionsGroupsDefaultsParams = Static<typeof SessionsGroupsDefaultsParamsSchema>;
|
||||
export type SessionsGroupsDefaultsResult = Static<typeof SessionsGroupsDefaultsResultSchema>;
|
||||
export type SessionsGroupsPutParams = Static<typeof SessionsGroupsPutParamsSchema>;
|
||||
export type SessionsGroupsRenameParams = Static<typeof SessionsGroupsRenameParamsSchema>;
|
||||
export type SessionsGroupsUpdateParams = Static<typeof SessionsGroupsUpdateParamsSchema>;
|
||||
export type SessionsGroupsUpdateResult = Static<typeof SessionsGroupsUpdateResultSchema>;
|
||||
export type SessionsGroupsDeleteParams = Static<typeof SessionsGroupsDeleteParamsSchema>;
|
||||
export type SessionsGroupsMutationResult = Static<typeof SessionsGroupsMutationResultSchema>;
|
||||
export type SessionsCompactParams = Static<typeof SessionsCompactParamsSchema>;
|
||||
|
||||
@@ -1,9 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateSessionsGroupsListResult, validateSessionsGroupsMutationResult } from "./index.js";
|
||||
import {
|
||||
validateSessionsGroupsDefaultsResult,
|
||||
validateSessionsGroupsListResult,
|
||||
validateSessionsGroupsMutationResult,
|
||||
validateSessionsGroupsUpdateParams,
|
||||
validateSessionsGroupsUpdateResult,
|
||||
} from "./index.js";
|
||||
|
||||
describe("session group result validators", () => {
|
||||
it("accepts legacy gateway payloads without sectionOrder", () => {
|
||||
expect(validateSessionsGroupsListResult({ groups: [] })).toBe(true);
|
||||
expect(validateSessionsGroupsMutationResult({ ok: true, groups: [] })).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts group defaults", () => {
|
||||
expect(validateSessionsGroupsListResult({ groups: [{ name: "Client", position: 0 }] })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
validateSessionsGroupsListResult({
|
||||
groups: [{ name: "Client", position: 0, cwd: "/repos/client" }],
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
validateSessionsGroupsDefaultsResult({
|
||||
defaults: [{ name: "Client", cwd: "/repos/client", worktree: true }],
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
validateSessionsGroupsUpdateResult({
|
||||
ok: true,
|
||||
defaults: [{ name: "Client", cwd: "/repos/client", worktree: true }],
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(validateSessionsGroupsUpdateParams({ name: "Client", cwd: null, worktree: false })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -261,8 +261,12 @@ export const validateSessionsResetParams = compile(S.SessionsResetParamsSchema);
|
||||
export const validateSessionsDeleteParams = compile(S.SessionsDeleteParamsSchema);
|
||||
export const validateSessionsGroupsListParams = compile(S.SessionsGroupsListParamsSchema);
|
||||
export const validateSessionsGroupsListResult = compile(S.SessionsGroupsListResultSchema);
|
||||
export const validateSessionsGroupsDefaultsParams = compile(S.SessionsGroupsDefaultsParamsSchema);
|
||||
export const validateSessionsGroupsDefaultsResult = compile(S.SessionsGroupsDefaultsResultSchema);
|
||||
export const validateSessionsGroupsPutParams = compile(S.SessionsGroupsPutParamsSchema);
|
||||
export const validateSessionsGroupsRenameParams = compile(S.SessionsGroupsRenameParamsSchema);
|
||||
export const validateSessionsGroupsUpdateParams = compile(S.SessionsGroupsUpdateParamsSchema);
|
||||
export const validateSessionsGroupsUpdateResult = compile(S.SessionsGroupsUpdateResultSchema);
|
||||
export const validateSessionsGroupsDeleteParams = compile(S.SessionsGroupsDeleteParamsSchema);
|
||||
export const validateSessionsGroupsMutationResult = compile(S.SessionsGroupsMutationResultSchema);
|
||||
export const validateSessionsCompactParams = compile(S.SessionsCompactParamsSchema);
|
||||
|
||||
@@ -82,8 +82,10 @@ describe("method scope resolution", () => {
|
||||
["projects.add", ["operator.write"]],
|
||||
["projects.searchRemote", ["operator.read"]],
|
||||
["sessions.groups.list", ["operator.read"]],
|
||||
["sessions.groups.defaults", ["operator.write"]],
|
||||
["sessions.groups.put", ["operator.write"]],
|
||||
["sessions.groups.rename", ["operator.write"]],
|
||||
["sessions.groups.update", ["operator.write"]],
|
||||
["sessions.groups.delete", ["operator.write"]],
|
||||
["sessions.catalog.list", ["operator.read"]],
|
||||
["sessions.catalog.read", ["operator.read"]],
|
||||
|
||||
@@ -83,6 +83,8 @@ const TRAIN_2026_7_METHODS = [
|
||||
const CURRENT_TRAIN_METHODS = [
|
||||
"device.pair.setupStatus",
|
||||
"sessions.patchMany",
|
||||
"sessions.groups.update",
|
||||
"sessions.groups.defaults",
|
||||
"sessions.recover",
|
||||
"update.hold",
|
||||
"sessions.catalog.startTerminal",
|
||||
|
||||
@@ -252,8 +252,10 @@ const CORE_GATEWAY_METHOD_SPECS = [
|
||||
["sessions.delete", "sessions-delete", "dynamic", "<=2026.7"],
|
||||
["sessions.compact", "sessions-compact", "operator.admin", "<=2026.7"],
|
||||
["sessions.groups.list", "sessions-groups", "operator.read", "<=2026.7"],
|
||||
["sessions.groups.defaults", "sessions-groups", "operator.write", "2026.8"],
|
||||
["sessions.groups.put", "sessions-groups", "operator.write", "<=2026.7"],
|
||||
["sessions.groups.rename", "sessions-groups", "operator.write", "<=2026.7"],
|
||||
["sessions.groups.update", "sessions-groups", "operator.write", "2026.8"],
|
||||
["sessions.groups.delete", "sessions-groups", "operator.write", "<=2026.7"],
|
||||
["last-heartbeat", "system", "operator.read", "<=2026.7"],
|
||||
["set-heartbeats", "system", "operator.admin", "<=2026.7"],
|
||||
|
||||
@@ -30,6 +30,7 @@ import { resolveUserPath } from "../../utils.js";
|
||||
import { buildDashboardSessionTitleSource } from "../dashboard-session-title.js";
|
||||
import { ADMIN_SCOPE, authorizeOperatorScopesForRequiredScope } from "../method-scopes.js";
|
||||
import { buildDashboardSessionKey, createGatewaySession } from "../session-create-service.js";
|
||||
import { ensureSessionGroupRegistered } from "../session-groups.js";
|
||||
import type { PrepareGatewaySessionLifecycle } from "../session-lifecycle-preparation.js";
|
||||
import { resolveRequestedSessionAgentId as resolveRequestedGlobalAgentId } from "../session-request-agent.js";
|
||||
import { resolveSessionStoreAgentId } from "../session-store-key.js";
|
||||
@@ -53,14 +54,47 @@ import type { GatewayRequestHandlers } from "./types.js";
|
||||
import { assertValidParams } from "./validation.js";
|
||||
import { resolveWorkspacePathContainment } from "./workspace-path-containment.js";
|
||||
|
||||
function registerCreatedSessionCategory(
|
||||
category: string | undefined,
|
||||
context: Parameters<typeof emitSessionsChanged>[0],
|
||||
): void {
|
||||
if (!category) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (ensureSessionGroupRegistered(category)) {
|
||||
// Catalog bookkeeping follows the authoritative session commit and has
|
||||
// its own invalidation. Its failure must not make a durable create ambiguous.
|
||||
emitSessionsChanged(context, { reason: "groups" });
|
||||
}
|
||||
} catch (error) {
|
||||
sessionLog.warn(`failed to register created session category: ${formatErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const sessionCreateHandlers: GatewayRequestHandlers = {
|
||||
"sessions.create": async ({ req, params, respond, context, client, isWebchatConnect }) => {
|
||||
"sessions.create": async ({
|
||||
req,
|
||||
params,
|
||||
respond,
|
||||
context,
|
||||
client,
|
||||
isWebchatConnect,
|
||||
sessionMutationAuthorization,
|
||||
}) => {
|
||||
if (!assertValidParams(params, validateSessionsCreateParams, "sessions.create", respond)) {
|
||||
return;
|
||||
}
|
||||
const p = params;
|
||||
const cfg = context.getRuntimeConfig();
|
||||
const authority = createAgentRuntimeAuthorityGuard(client, context, respond);
|
||||
const commitGuard =
|
||||
authority.commitGuard || sessionMutationAuthorization
|
||||
? () => {
|
||||
authority.commitGuard?.();
|
||||
sessionMutationAuthorization?.assertCurrent();
|
||||
}
|
||||
: undefined;
|
||||
const catalogId = normalizeOptionalString(p.catalogId);
|
||||
if (catalogId && p.model) {
|
||||
respond(
|
||||
@@ -405,7 +439,7 @@ export const sessionCreateHandlers: GatewayRequestHandlers = {
|
||||
// Checkout hooks and .openclaw/worktree-setup.sh run repo code; keep them
|
||||
// admin-only so this write-scoped path cannot execute gated repo scripts.
|
||||
runSetupScript: scopes.includes(ADMIN_SCOPE),
|
||||
...(authority.commitGuard ? { commitGuard: authority.commitGuard } : {}),
|
||||
...(commitGuard ? { commitGuard } : {}),
|
||||
});
|
||||
provisioned = true;
|
||||
}
|
||||
@@ -489,11 +523,13 @@ export const sessionCreateHandlers: GatewayRequestHandlers = {
|
||||
if (!authority.ensureActive()) {
|
||||
return;
|
||||
}
|
||||
const category = normalizeOptionalString(p.category);
|
||||
const created = await createGatewaySession({
|
||||
cfg,
|
||||
key: sessionKey,
|
||||
agentId: sessionAgentId,
|
||||
label: p.label,
|
||||
category: p.category,
|
||||
...(catalogTarget ? { catalogTarget: catalogTarget.target } : { model: p.model }),
|
||||
thinkingLevel: p.thinkingLevel,
|
||||
projectId: requestedProjectId,
|
||||
@@ -534,7 +570,7 @@ export const sessionCreateHandlers: GatewayRequestHandlers = {
|
||||
authorizedPluginId: normalizeOptionalString(client?.internal?.pluginRuntimeOwnerId),
|
||||
loadGatewayModelCatalog: () =>
|
||||
context.loadGatewayModelCatalog({ agentId: modelCatalogAgentId }),
|
||||
...(authority.commitGuard ? { commitGuard: authority.commitGuard } : {}),
|
||||
...(commitGuard ? { commitGuard } : {}),
|
||||
afterCreate: async ({ key, agentId, entry, storePath }) => {
|
||||
// Session persistence already committed under the guard. Closure after
|
||||
// that point may suppress follow-on work, but cannot roll back the session.
|
||||
@@ -588,6 +624,7 @@ export const sessionCreateHandlers: GatewayRequestHandlers = {
|
||||
respond(false, undefined, created.error);
|
||||
return;
|
||||
}
|
||||
registerCreatedSessionCategory(category, context);
|
||||
if (created.resetExisting) {
|
||||
await captureCreatedSessionDiffBaseline({
|
||||
key: created.key,
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GatewayRequestHandlerOptions } from "./types.js";
|
||||
|
||||
const groupMocks = vi.hoisted(() => ({
|
||||
NotFound: class SessionGroupNotFoundError extends Error {},
|
||||
rename: vi.fn(),
|
||||
update: vi.fn(),
|
||||
}));
|
||||
const pathMocks = vi.hoisted(() => ({
|
||||
isCurrent: vi.fn(),
|
||||
resolveContainment: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../session-groups.js", () => ({
|
||||
deleteSessionGroup: vi.fn(),
|
||||
listSessionGroupDefaults: vi.fn(() => []),
|
||||
listSessionGroups: vi.fn(() => []),
|
||||
listSidebarSectionOrder: vi.fn(() => []),
|
||||
putSessionGroups: vi.fn(() => []),
|
||||
renameSessionGroup: groupMocks.rename,
|
||||
SessionGroupNotFoundError: groupMocks.NotFound,
|
||||
updateSessionGroupDefaults: groupMocks.update,
|
||||
}));
|
||||
vi.mock("./workspace-path-containment.js", () => ({
|
||||
isWorkspacePathContainmentCurrent: pathMocks.isCurrent,
|
||||
resolveWorkspacePathContainment: pathMocks.resolveContainment,
|
||||
}));
|
||||
|
||||
import { sessionGroupHandlers } from "./sessions-groups.js";
|
||||
|
||||
function updateOptions(
|
||||
params: Record<string, unknown>,
|
||||
respond: ReturnType<typeof vi.fn>,
|
||||
scopes = ["operator.write", "operator.admin"],
|
||||
) {
|
||||
return {
|
||||
params,
|
||||
respond,
|
||||
client: { connect: { scopes } },
|
||||
context: {
|
||||
getRuntimeConfig: () => ({}),
|
||||
getSessionEventSubscriberConnIds: () => new Set<string>(),
|
||||
},
|
||||
} as unknown as GatewayRequestHandlerOptions;
|
||||
}
|
||||
|
||||
function renameOptions(params: Record<string, unknown>, respond: ReturnType<typeof vi.fn>) {
|
||||
return {
|
||||
params,
|
||||
respond,
|
||||
context: { getRuntimeConfig: () => ({}) },
|
||||
} as unknown as GatewayRequestHandlerOptions;
|
||||
}
|
||||
|
||||
describe("sessions.groups.update", () => {
|
||||
beforeEach(() => {
|
||||
groupMocks.update.mockReset();
|
||||
pathMocks.isCurrent.mockReset();
|
||||
pathMocks.isCurrent.mockReturnValue(true);
|
||||
pathMocks.resolveContainment.mockReset();
|
||||
});
|
||||
|
||||
it("rejects a relative cwd before mutating defaults", async () => {
|
||||
const respond = vi.fn();
|
||||
await expectDefined(
|
||||
sessionGroupHandlers["sessions.groups.update"],
|
||||
'sessionGroupHandlers["sessions.groups.update"] test invariant',
|
||||
)(updateOptions({ name: "Travel", cwd: "tmp/travel", worktree: false }, respond));
|
||||
|
||||
expect(groupMocks.update).not.toHaveBeenCalled();
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({ code: "INVALID_REQUEST" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a stale target without recreating it", async () => {
|
||||
groupMocks.update.mockReturnValue(null);
|
||||
const respond = vi.fn();
|
||||
await expectDefined(
|
||||
sessionGroupHandlers["sessions.groups.update"],
|
||||
'sessionGroupHandlers["sessions.groups.update"] test invariant',
|
||||
)(updateOptions({ name: "Travel", cwd: "/tmp/travel", worktree: true }, respond));
|
||||
|
||||
expect(groupMocks.update).toHaveBeenCalledOnce();
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
code: "INVALID_REQUEST",
|
||||
message: "unknown session group: Travel",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a non-admin cwd outside configured workspaces", async () => {
|
||||
pathMocks.resolveContainment.mockResolvedValue(null);
|
||||
const respond = vi.fn();
|
||||
await expectDefined(
|
||||
sessionGroupHandlers["sessions.groups.update"],
|
||||
'sessionGroupHandlers["sessions.groups.update"] test invariant',
|
||||
)(
|
||||
updateOptions({ name: "Travel", cwd: "/outside/travel", worktree: false }, respond, [
|
||||
"operator.write",
|
||||
]),
|
||||
);
|
||||
|
||||
expect(groupMocks.update).not.toHaveBeenCalled();
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({ message: expect.stringContaining("operator.admin") }),
|
||||
);
|
||||
});
|
||||
|
||||
it("persists the canonical workspace-contained cwd for a write caller", async () => {
|
||||
pathMocks.resolveContainment.mockResolvedValue({
|
||||
path: "/workspace/client",
|
||||
workspaceRoot: "/workspace",
|
||||
});
|
||||
groupMocks.update.mockReturnValue([
|
||||
{ name: "Client", cwd: "/workspace/client", worktree: true },
|
||||
]);
|
||||
const respond = vi.fn();
|
||||
const assertCurrent = vi.fn();
|
||||
const options = updateOptions(
|
||||
{ name: "Client", cwd: "/workspace/link", worktree: true },
|
||||
respond,
|
||||
["operator.write"],
|
||||
);
|
||||
options.sessionMutationAuthorization = {
|
||||
assertCurrent,
|
||||
assertTargetCurrent: vi.fn(),
|
||||
};
|
||||
await expectDefined(
|
||||
sessionGroupHandlers["sessions.groups.update"],
|
||||
'sessionGroupHandlers["sessions.groups.update"] test invariant',
|
||||
)(options);
|
||||
|
||||
expect(assertCurrent).toHaveBeenCalledOnce();
|
||||
expect(groupMocks.update).toHaveBeenCalledWith("Client", {
|
||||
cwd: "/workspace/client",
|
||||
worktree: true,
|
||||
});
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
{
|
||||
ok: true,
|
||||
defaults: [{ name: "Client", cwd: "/workspace/client", worktree: true }],
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects containment retired by a runtime config change before commit", async () => {
|
||||
const containment = {
|
||||
path: "/workspace/client",
|
||||
workspaceRoot: "/workspace",
|
||||
};
|
||||
let finishContainment: ((value: typeof containment) => void) | undefined;
|
||||
pathMocks.resolveContainment.mockImplementation(
|
||||
async () =>
|
||||
await new Promise<typeof containment>((resolve) => {
|
||||
finishContainment = resolve;
|
||||
}),
|
||||
);
|
||||
const initialConfig = { agents: { defaults: { workspace: "/workspace" } } };
|
||||
const retiredConfig = { agents: { defaults: { workspace: "/replacement" } } };
|
||||
let runtimeConfig = initialConfig;
|
||||
pathMocks.isCurrent.mockImplementation((_containment, cfg) => cfg === initialConfig);
|
||||
const respond = vi.fn();
|
||||
const options = updateOptions(
|
||||
{ name: "Client", cwd: "/workspace/client", worktree: true },
|
||||
respond,
|
||||
["operator.write"],
|
||||
);
|
||||
options.context.getRuntimeConfig = () => runtimeConfig;
|
||||
const update = expectDefined(
|
||||
sessionGroupHandlers["sessions.groups.update"],
|
||||
'sessionGroupHandlers["sessions.groups.update"] test invariant',
|
||||
)(options);
|
||||
|
||||
await vi.waitFor(() => expect(pathMocks.resolveContainment).toHaveBeenCalledOnce());
|
||||
runtimeConfig = retiredConfig;
|
||||
finishContainment?.(containment);
|
||||
await update;
|
||||
|
||||
expect(pathMocks.isCurrent).toHaveBeenCalledWith(containment, retiredConfig);
|
||||
expect(groupMocks.update).not.toHaveBeenCalled();
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({ message: expect.stringContaining("operator.admin") }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sessions.groups.rename", () => {
|
||||
beforeEach(() => {
|
||||
groupMocks.rename.mockReset();
|
||||
});
|
||||
|
||||
it("rejects an unknown source group", async () => {
|
||||
groupMocks.rename.mockRejectedValue(new groupMocks.NotFound("unknown session group: Missing"));
|
||||
const respond = vi.fn();
|
||||
await expectDefined(
|
||||
sessionGroupHandlers["sessions.groups.rename"],
|
||||
'sessionGroupHandlers["sessions.groups.rename"] test invariant',
|
||||
)(renameOptions({ name: "Missing", to: "Other" }, respond));
|
||||
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
code: "INVALID_REQUEST",
|
||||
message: "unknown session group: Missing",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,24 +1,39 @@
|
||||
// Session group catalog mutations.
|
||||
import path from "node:path";
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import {
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
missingScopeErrorShape,
|
||||
validateSessionsGroupsDefaultsParams,
|
||||
validateSessionsGroupsDeleteParams,
|
||||
validateSessionsGroupsListParams,
|
||||
validateSessionsGroupsPutParams,
|
||||
validateSessionsGroupsRenameParams,
|
||||
validateSessionsGroupsUpdateParams,
|
||||
} from "../../../packages/gateway-protocol/src/index.js";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import { ADMIN_SCOPE } from "../method-scopes.js";
|
||||
import { filterMutableSessionGroupRecords } from "../session-group-defaults-access.js";
|
||||
import { resolveSessionGroupMutationTargetsByName } from "../session-group-mutation-targets.js";
|
||||
import {
|
||||
deleteSessionGroup,
|
||||
listSessionGroupDefaults,
|
||||
listSidebarSectionOrder,
|
||||
listSessionGroups,
|
||||
putSessionGroups,
|
||||
renameSessionGroup,
|
||||
SessionGroupNotFoundError,
|
||||
updateSessionGroupDefaults,
|
||||
} from "../session-groups.js";
|
||||
import { SessionMutationAuthorizationChangedError } from "../session-sharing.js";
|
||||
import { emitSessionsChanged } from "./session-change-event.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
import { assertValidParams } from "./validation.js";
|
||||
import {
|
||||
isWorkspacePathContainmentCurrent,
|
||||
resolveWorkspacePathContainment,
|
||||
} from "./workspace-path-containment.js";
|
||||
|
||||
export const sessionGroupHandlers: GatewayRequestHandlers = {
|
||||
"sessions.groups.list": async ({ params, respond }) => {
|
||||
@@ -33,6 +48,24 @@ export const sessionGroupHandlers: GatewayRequestHandlers = {
|
||||
undefined,
|
||||
);
|
||||
},
|
||||
"sessions.groups.defaults": async ({ params, respond, client, context }) => {
|
||||
if (
|
||||
!assertValidParams(
|
||||
params,
|
||||
validateSessionsGroupsDefaultsParams,
|
||||
"sessions.groups.defaults",
|
||||
respond,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const defaults = filterMutableSessionGroupRecords({
|
||||
cfg: context.getRuntimeConfig(),
|
||||
client,
|
||||
records: listSessionGroupDefaults(),
|
||||
});
|
||||
respond(true, { defaults }, undefined);
|
||||
},
|
||||
"sessions.groups.put": async ({ params, respond, context }) => {
|
||||
if (
|
||||
!assertValidParams(params, validateSessionsGroupsPutParams, "sessions.groups.put", respond)
|
||||
@@ -69,9 +102,98 @@ export const sessionGroupHandlers: GatewayRequestHandlers = {
|
||||
if (error instanceof SessionMutationAuthorizationChangedError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof SessionGroupNotFoundError) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, error.message));
|
||||
return;
|
||||
}
|
||||
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error)));
|
||||
}
|
||||
},
|
||||
"sessions.groups.update": async ({
|
||||
params,
|
||||
respond,
|
||||
context,
|
||||
client,
|
||||
sessionMutationAuthorization,
|
||||
}) => {
|
||||
if (
|
||||
!assertValidParams(
|
||||
params,
|
||||
validateSessionsGroupsUpdateParams,
|
||||
"sessions.groups.update",
|
||||
respond,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (params.cwd && !path.isAbsolute(params.cwd)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "session group cwd must be absolute"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const name = normalizeOptionalString(params.name);
|
||||
if (!name) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "session group name must not be empty"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let cwd = params.cwd;
|
||||
const clientScopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : [];
|
||||
if (cwd && !clientScopes.includes(ADMIN_SCOPE)) {
|
||||
const containment = await resolveWorkspacePathContainment(cwd, context.getRuntimeConfig());
|
||||
if (
|
||||
!containment ||
|
||||
!isWorkspacePathContainmentCurrent(containment, context.getRuntimeConfig())
|
||||
) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
missingScopeErrorShape({ missingScope: ADMIN_SCOPE, requiredScopes: [ADMIN_SCOPE] }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
cwd = containment.path;
|
||||
}
|
||||
sessionMutationAuthorization?.assertCurrent();
|
||||
if (sessionMutationAuthorization) {
|
||||
const currentTargets =
|
||||
resolveSessionGroupMutationTargetsByName(context.getRuntimeConfig()).get(name) ?? [];
|
||||
for (const target of currentTargets) {
|
||||
sessionMutationAuthorization.assertTargetCurrent(target);
|
||||
}
|
||||
}
|
||||
const defaults = updateSessionGroupDefaults(name, {
|
||||
cwd,
|
||||
worktree: params.worktree,
|
||||
});
|
||||
if (!defaults) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, `unknown session group: ${name}`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
respond(
|
||||
true,
|
||||
{
|
||||
ok: true,
|
||||
defaults: filterMutableSessionGroupRecords({
|
||||
cfg: context.getRuntimeConfig(),
|
||||
client,
|
||||
records: defaults,
|
||||
}),
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
emitSessionsChanged(context, { reason: "groups" });
|
||||
},
|
||||
"sessions.groups.delete": async ({ params, respond, context, sessionMutationAuthorization }) => {
|
||||
if (
|
||||
!assertValidParams(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import fsSync from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { listAgentIds, resolveAgentWorkspaceDir } from "../../agents/agent-scope.js";
|
||||
@@ -72,3 +73,20 @@ export async function resolveWorkspacePathContainment(
|
||||
.toSorted((left, right) => right.length - left.length)[0];
|
||||
return workspaceRoot ? { path: requestedRealPath, workspaceRoot } : null;
|
||||
}
|
||||
|
||||
/** Revalidates an async containment result against the current workspace configuration. */
|
||||
export function isWorkspacePathContainmentCurrent(
|
||||
containment: { path: string; workspaceRoot: string },
|
||||
cfg: OpenClawConfig,
|
||||
): boolean {
|
||||
return listAgentIds(cfg).some((agentId) => {
|
||||
try {
|
||||
const currentRoot = fsSync.realpathSync(resolveAgentWorkspaceDir(cfg, agentId));
|
||||
return (
|
||||
currentRoot === containment.workspaceRoot && isPathInside(currentRoot, containment.path)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
attachGatewayLocalUserIngress,
|
||||
prepareGatewayLocalUserIngress,
|
||||
} from "./local-user-ingress.js";
|
||||
import { listSessionGroups } from "./session-groups.js";
|
||||
import { resolveGatewaySessionStoreTarget } from "./session-utils.js";
|
||||
import {
|
||||
agentCommandMock,
|
||||
@@ -194,6 +195,109 @@ function describeSessionStoreForensics(storePath: string): string {
|
||||
return JSON.stringify({ storeDir, files, resolvedTargetPath: target.path, rows });
|
||||
}
|
||||
|
||||
test("sessions.create assigns and registers its requested group", async () => {
|
||||
const { storePath } = await createSessionStoreDir();
|
||||
const broadcastToConnIds = vi.fn();
|
||||
|
||||
const created = await directSessionReq<{ key: string }>(
|
||||
"sessions.create",
|
||||
{
|
||||
agentId: "main",
|
||||
category: "Client work",
|
||||
},
|
||||
{
|
||||
context: {
|
||||
broadcastToConnIds,
|
||||
getSessionEventSubscriberConnIds: () => new Set(["conn-1"]),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(created.ok).toBe(true);
|
||||
const key = requireNonEmptyString(created.payload?.key, "grouped session key");
|
||||
expect(loadSessionEntry({ sessionKey: key, storePath })?.category).toBe("Client work");
|
||||
expect(listSessionGroups().map((group) => group.name)).toContain("Client work");
|
||||
expect(broadcastToConnIds).toHaveBeenCalledWith(
|
||||
"sessions.changed",
|
||||
expect.objectContaining({ reason: "groups" }),
|
||||
new Set(["conn-1"]),
|
||||
{ dropIfSlow: true },
|
||||
);
|
||||
});
|
||||
|
||||
test("sessions.create carries keyed adoption authorization through the durable commit", async () => {
|
||||
const { storePath } = await createSessionStoreDir();
|
||||
const key = "agent:main:dashboard:categorized-adoption";
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
[key]: sessionStoreEntry("session-categorized-adoption", { category: "Personal" }),
|
||||
},
|
||||
});
|
||||
const assertCurrent = vi.fn();
|
||||
|
||||
const adopted = await directSessionReq(
|
||||
"sessions.create",
|
||||
{ agentId: "main", key, category: "Projects" },
|
||||
{
|
||||
sessionMutationAuthorization: {
|
||||
assertCurrent,
|
||||
assertTargetCurrent: vi.fn(),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(adopted.ok).toBe(true);
|
||||
expect(assertCurrent).toHaveBeenCalled();
|
||||
expect(loadSessionEntry({ sessionKey: key, storePath })?.category).toBe("Projects");
|
||||
});
|
||||
|
||||
test("sessions.create registers a category only after the session commit succeeds", async () => {
|
||||
await createSessionStoreDir();
|
||||
const category = "Deferred category";
|
||||
let validations = 0;
|
||||
|
||||
const failed = await directSessionReq(
|
||||
"sessions.create",
|
||||
{ agentId: "main", category, key: "agent:main:dashboard:failed-category-create" },
|
||||
{
|
||||
context: {
|
||||
validateAgentRuntimeApprovalAuthority: () => ++validations < 3,
|
||||
},
|
||||
client: {
|
||||
connect: { scopes: ["operator.write"] },
|
||||
internal: {
|
||||
agentRuntimeIdentity: {
|
||||
kind: "agentRuntime",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:main",
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
expect(failed.ok).toBe(false);
|
||||
expect(listSessionGroups().map((group) => group.name)).not.toContain(category);
|
||||
|
||||
const broadcastToConnIds = vi.fn();
|
||||
const created = await directSessionReq(
|
||||
"sessions.create",
|
||||
{ agentId: "main", category, key: "agent:main:dashboard:successful-category-create" },
|
||||
{
|
||||
context: {
|
||||
broadcastToConnIds,
|
||||
getSessionEventSubscriberConnIds: () => new Set(["conn-1"]),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(created.ok).toBe(true);
|
||||
expect(listSessionGroups().filter((group) => group.name === category)).toHaveLength(1);
|
||||
expect(
|
||||
broadcastToConnIds.mock.calls.filter(([, payload]) => payload?.reason === "groups"),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("concurrent sessions.create requests adopt one canonical keyed session", async () => {
|
||||
const { storePath } = await createSessionStoreDir();
|
||||
const key = "agent:main:dashboard:concurrent-keyed-session";
|
||||
|
||||
@@ -20,7 +20,8 @@ import {
|
||||
getSessionsHandlers,
|
||||
} from "./test/server-sessions.test-helpers.js";
|
||||
|
||||
const { createSessionStoreDir, openClient } = setupGatewaySessionsTestHarness();
|
||||
const { createSessionStoreDir, defaultAgentWorkspace, openClient } =
|
||||
setupGatewaySessionsTestHarness();
|
||||
|
||||
async function seedLinearTranscript(params: {
|
||||
contents: string[];
|
||||
@@ -853,6 +854,20 @@ test("write-scoped operators manage chat organization but not admin session sett
|
||||
expect(reordered.payload?.groups.map((group) => group.name)).toEqual(["Someday", "Travel"]);
|
||||
expect(reordered.payload?.sectionOrder).toEqual(["work", "category:Travel", "ungrouped"]);
|
||||
|
||||
const defaultsUpdated = await rpcReq<{
|
||||
ok: true;
|
||||
defaults: Array<{ name: string; cwd?: string; worktree?: boolean }>;
|
||||
}>(ws, "sessions.groups.update", {
|
||||
name: "Travel",
|
||||
cwd: defaultAgentWorkspace,
|
||||
worktree: true,
|
||||
});
|
||||
expect(defaultsUpdated.ok).toBe(true);
|
||||
expect(defaultsUpdated.payload?.defaults).toContainEqual({
|
||||
name: "Travel",
|
||||
cwd: defaultAgentWorkspace,
|
||||
worktree: true,
|
||||
});
|
||||
const renamedGroup = await rpcReq<{
|
||||
ok: true;
|
||||
sectionOrder: string[];
|
||||
|
||||
@@ -228,6 +228,7 @@ export async function createGatewaySession(params: {
|
||||
key?: string;
|
||||
agentId?: string;
|
||||
label?: string;
|
||||
category?: string;
|
||||
/** Trusted model-generated title, persisted with a newly created dashboard session. */
|
||||
generatedDisplayName?: string;
|
||||
model?: string;
|
||||
@@ -968,6 +969,7 @@ export async function createGatewaySession(params: {
|
||||
patch: {
|
||||
key: target.canonicalKey,
|
||||
label: normalizeOptionalString(params.label),
|
||||
category: normalizeOptionalString(params.category),
|
||||
model: catalogModel ?? requestedModel,
|
||||
thinkingLevel: requestedThinkingLevel,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { GatewayClient } from "./server-methods/types.js";
|
||||
import { resolveSessionGroupMutationTargetsByName } from "./session-group-mutation-targets.js";
|
||||
import {
|
||||
authorizeSessionSharingTarget,
|
||||
isGatewayAdmin,
|
||||
resolveSessionSharingTarget,
|
||||
} from "./session-sharing.js";
|
||||
import type {
|
||||
GatewaySessionStoreCache,
|
||||
GatewaySessionStoreDiscoveryCache,
|
||||
} from "./session-utils-store-lookup.js";
|
||||
|
||||
/** Keep shared group settings visible only where every member session is mutable. */
|
||||
export function filterMutableSessionGroupRecords<T extends { name: string }>(params: {
|
||||
cfg: OpenClawConfig;
|
||||
client: GatewayClient | null;
|
||||
records: readonly T[];
|
||||
}): T[] {
|
||||
const allowed = new Set(params.records.map((record) => record.name));
|
||||
if (isGatewayAdmin(params.client)) {
|
||||
return [...params.records];
|
||||
}
|
||||
const storeCache: GatewaySessionStoreCache = new Map();
|
||||
const targetDiscoveryCache: GatewaySessionStoreDiscoveryCache = new Map();
|
||||
for (const [name, targetRefs] of resolveSessionGroupMutationTargetsByName(params.cfg)) {
|
||||
if (!allowed.has(name)) {
|
||||
continue;
|
||||
}
|
||||
for (const targetRef of targetRefs) {
|
||||
const target = resolveSessionSharingTarget({
|
||||
cfg: params.cfg,
|
||||
sessionKey: targetRef.sessionKey,
|
||||
agentId: targetRef.agentId,
|
||||
storeCache,
|
||||
targetDiscoveryCache,
|
||||
});
|
||||
if (target && authorizeSessionSharingTarget({ client: params.client, target })) {
|
||||
allowed.delete(name);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return params.records.filter((record) => allowed.has(record.name));
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { resolveAllAgentSessionStoreTargetsSync } from "../config/sessions.js";
|
||||
import { listSessionEntriesCore } from "../config/sessions/session-accessor.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { SessionMutationTarget } from "./session-sharing-target-input.js";
|
||||
|
||||
export function resolveSessionGroupMutationTargetsByName(
|
||||
cfg: OpenClawConfig,
|
||||
): Map<string, SessionMutationTarget[]> {
|
||||
const targetsByName = new Map<string, SessionMutationTarget[]>();
|
||||
for (const storeTarget of resolveAllAgentSessionStoreTargetsSync(cfg)) {
|
||||
for (const { sessionKey, entry } of listSessionEntriesCore({
|
||||
agentId: storeTarget.agentId,
|
||||
storePath: storeTarget.storePath,
|
||||
})) {
|
||||
const groupName = normalizeOptionalString(entry.category);
|
||||
if (!groupName) {
|
||||
continue;
|
||||
}
|
||||
const targets = targetsByName.get(groupName) ?? [];
|
||||
targets.push({ sessionKey, agentId: storeTarget.agentId });
|
||||
targetsByName.set(groupName, targets);
|
||||
}
|
||||
}
|
||||
return targetsByName;
|
||||
}
|
||||
@@ -14,10 +14,12 @@ import {
|
||||
import {
|
||||
deleteSessionGroup,
|
||||
ensureSessionGroupRegistered,
|
||||
listSessionGroupDefaults,
|
||||
listSidebarSectionOrder,
|
||||
listSessionGroups,
|
||||
putSessionGroups,
|
||||
renameSessionGroup,
|
||||
updateSessionGroupDefaults,
|
||||
} from "./session-groups.js";
|
||||
|
||||
describe("session groups catalog", () => {
|
||||
@@ -117,6 +119,133 @@ describe("session groups catalog", () => {
|
||||
).toEqual({ name: "sidebar_sections" });
|
||||
});
|
||||
|
||||
it("keeps catalog reads and reorders schema-read-only until defaults are used", async () => {
|
||||
const databasePath = openOpenClawStateDatabase({ env }).path;
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
const { DatabaseSync } = requireNodeSqlite();
|
||||
const legacy = new DatabaseSync(databasePath);
|
||||
legacy.exec("ALTER TABLE session_groups DROP COLUMN cwd;");
|
||||
legacy.exec("ALTER TABLE session_groups DROP COLUMN worktree;");
|
||||
legacy
|
||||
.prepare("INSERT INTO session_groups (name, position, created_at) VALUES (?, ?, ?)")
|
||||
.run("Client", 0, Date.now());
|
||||
legacy.close();
|
||||
|
||||
const beforeFeatureUse = openOpenClawStateDatabase({ env })
|
||||
.db.prepare("PRAGMA table_info(session_groups)")
|
||||
.all() as Array<{ name: string }>;
|
||||
expect(beforeFeatureUse.map((column) => column.name)).not.toEqual(
|
||||
expect.arrayContaining(["cwd", "worktree"]),
|
||||
);
|
||||
|
||||
expect(listSessionGroups(env)).toEqual([{ name: "Client", position: 0 }]);
|
||||
expect(putSessionGroups(["Client"], undefined, env)).toEqual([{ name: "Client", position: 0 }]);
|
||||
const afterCatalogUse = openOpenClawStateDatabase({ env })
|
||||
.db.prepare("PRAGMA table_info(session_groups)")
|
||||
.all() as Array<{ name: string }>;
|
||||
expect(afterCatalogUse.map((column) => column.name)).not.toEqual(
|
||||
expect.arrayContaining(["cwd", "worktree"]),
|
||||
);
|
||||
|
||||
expect(listSessionGroupDefaults(env)).toEqual([{ name: "Client" }]);
|
||||
await renameSessionGroup({ cfg, name: "Client", to: "Customer", env });
|
||||
expect(listSessionGroupDefaults(env)).toEqual([{ name: "Customer" }]);
|
||||
const afterDefaultsReadAndRename = openOpenClawStateDatabase({ env })
|
||||
.db.prepare("PRAGMA table_info(session_groups)")
|
||||
.all() as Array<{ dflt_value: unknown; name: string; notnull: number; type: string }>;
|
||||
expect(afterDefaultsReadAndRename.map((column) => column.name)).not.toEqual(
|
||||
expect.arrayContaining(["cwd", "worktree"]),
|
||||
);
|
||||
expect(
|
||||
updateSessionGroupDefaults("Customer", { cwd: "/repos/customer", worktree: true }, env),
|
||||
).toContainEqual({ name: "Customer", cwd: "/repos/customer", worktree: true });
|
||||
const columns = openOpenClawStateDatabase({ env })
|
||||
.db.prepare("PRAGMA table_info(session_groups)")
|
||||
.all() as Array<{ dflt_value: unknown; name: string; notnull: number; type: string }>;
|
||||
expect(columns.filter((column) => column.name === "cwd" || column.name === "worktree")).toEqual(
|
||||
[
|
||||
expect.objectContaining({ dflt_value: null, name: "cwd", notnull: 0, type: "TEXT" }),
|
||||
expect.objectContaining({
|
||||
dflt_value: null,
|
||||
name: "worktree",
|
||||
notnull: 0,
|
||||
type: "INTEGER",
|
||||
}),
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves New Session defaults through reorder and rename", async () => {
|
||||
putSessionGroups(["Client", "Other"], undefined, env);
|
||||
expect(
|
||||
updateSessionGroupDefaults("Client", { cwd: "/repos/client", worktree: true }, env),
|
||||
).toContainEqual({
|
||||
name: "Client",
|
||||
cwd: "/repos/client",
|
||||
worktree: true,
|
||||
});
|
||||
|
||||
putSessionGroups(["Other", "Client"], undefined, env);
|
||||
await renameSessionGroup({ cfg, name: "Client", to: "Customer", env });
|
||||
expect(listSessionGroupDefaults(env)).toContainEqual({
|
||||
name: "Customer",
|
||||
cwd: "/repos/client",
|
||||
worktree: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects renaming an unknown group after defaults schema activation", async () => {
|
||||
putSessionGroups(["Client"], undefined, env);
|
||||
updateSessionGroupDefaults("Client", { cwd: "/repos/client", worktree: true }, env);
|
||||
|
||||
await expect(renameSessionGroup({ cfg, name: "Missing", to: "Other", env })).rejects.toThrow(
|
||||
"unknown session group: Missing",
|
||||
);
|
||||
expect(listSessionGroups(env)).toEqual([{ name: "Client", position: 0 }]);
|
||||
expect(listSessionGroupDefaults(env)).toEqual([
|
||||
{ name: "Client", cwd: "/repos/client", worktree: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("clears New Session defaults without removing the group", () => {
|
||||
putSessionGroups(["Client"], undefined, env);
|
||||
updateSessionGroupDefaults("Client", { cwd: "/repos/client", worktree: true }, env);
|
||||
|
||||
expect(updateSessionGroupDefaults("Client", { cwd: null, worktree: false }, env)).toEqual([
|
||||
{ name: "Client", worktree: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not recreate a deleted group from a stale defaults update", async () => {
|
||||
putSessionGroups(["Client"], undefined, env);
|
||||
await deleteSessionGroup({ cfg, name: "Client", env });
|
||||
|
||||
expect(
|
||||
updateSessionGroupDefaults("Client", { cwd: "/repos/client", worktree: true }, env),
|
||||
).toBeNull();
|
||||
expect(listSessionGroups(env)).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps a stale defaults update schema-free on a legacy database", () => {
|
||||
const databasePath = openOpenClawStateDatabase({ env }).path;
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
const { DatabaseSync } = requireNodeSqlite();
|
||||
const legacy = new DatabaseSync(databasePath);
|
||||
legacy.exec("ALTER TABLE session_groups DROP COLUMN cwd;");
|
||||
legacy.exec("ALTER TABLE session_groups DROP COLUMN worktree;");
|
||||
legacy.close();
|
||||
|
||||
expect(
|
||||
updateSessionGroupDefaults("Missing", { cwd: "/repos/missing", worktree: true }, env),
|
||||
).toBeNull();
|
||||
const columns = openOpenClawStateDatabase({ env })
|
||||
.db.prepare("PRAGMA table_info(session_groups)")
|
||||
.all() as Array<{ name: string }>;
|
||||
expect(columns.map((column) => column.name)).not.toEqual(
|
||||
expect.arrayContaining(["cwd", "worktree"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("absorbs ad-hoc categories at the end of the catalog", () => {
|
||||
putSessionGroups(["Work"], undefined, env);
|
||||
ensureSessionGroupRegistered("Travel", env);
|
||||
|
||||
+143
-19
@@ -7,6 +7,7 @@ import { resolveAllAgentSessionStoreTargetsSync } from "../config/sessions.js";
|
||||
import { applySessionEntryReplacements } from "../config/sessions/session-accessor.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-sync.js";
|
||||
import { ensureColumn, tableHasColumn } from "../state/openclaw-state-db-schema-helpers.js";
|
||||
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
|
||||
import {
|
||||
openOpenClawStateDatabase,
|
||||
@@ -18,14 +19,31 @@ import { SessionMutationAuthorizationChangedError } from "./session-sharing.js";
|
||||
// statements; a bare transaction would open the default state DB while the
|
||||
// SQL hits the override, losing atomicity under OPENCLAW_STATE_DIR overrides.
|
||||
|
||||
type SessionGroupRecord = { name: string; position: number };
|
||||
type SessionGroupRecord = {
|
||||
name: string;
|
||||
position: number;
|
||||
};
|
||||
|
||||
type SessionGroupDefaultsRecord = {
|
||||
name: string;
|
||||
cwd?: string;
|
||||
worktree?: boolean;
|
||||
};
|
||||
|
||||
type SessionGroupsDatabase = Pick<
|
||||
OpenClawStateKyselyDatabase,
|
||||
"session_groups" | "sidebar_sections"
|
||||
>;
|
||||
|
||||
export class SessionGroupNotFoundError extends Error {
|
||||
constructor(name: string) {
|
||||
super(`unknown session group: ${name}`);
|
||||
this.name = "SessionGroupNotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
const ensuredSidebarSectionDatabases = new WeakSet<DatabaseSync>();
|
||||
const ensuredSessionGroupDefaultsDatabases = new WeakSet<DatabaseSync>();
|
||||
const SIDEBAR_SECTIONS_SCHEMA_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS sidebar_sections (
|
||||
section_id TEXT NOT NULL PRIMARY KEY,
|
||||
@@ -57,6 +75,12 @@ function ensureSidebarSectionsSchema(env: NodeJS.ProcessEnv): void {
|
||||
ensuredSidebarSectionDatabases.add(database.db);
|
||||
}
|
||||
|
||||
function hasSessionGroupDefaultsSchema(db: DatabaseSync): boolean {
|
||||
return (
|
||||
tableHasColumn(db, "session_groups", "cwd") && tableHasColumn(db, "session_groups", "worktree")
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeGroupNames(names: readonly string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const normalized: string[] = [];
|
||||
@@ -110,10 +134,33 @@ export function listSessionGroups(env: NodeJS.ProcessEnv = process.env): Session
|
||||
.select(["name", "position"])
|
||||
.orderBy("position", "asc")
|
||||
.orderBy("name", "asc");
|
||||
return executeSqliteQuerySync(db, query).rows.map((row) => ({
|
||||
name: row.name,
|
||||
position: row.position,
|
||||
}));
|
||||
return executeSqliteQuerySync(db, query).rows;
|
||||
}
|
||||
|
||||
export function listSessionGroupDefaults(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): SessionGroupDefaultsRecord[] {
|
||||
const db = dbFor(env);
|
||||
if (!hasSessionGroupDefaultsSchema(db)) {
|
||||
return listSessionGroups(env).map(({ name }) => ({ name }));
|
||||
}
|
||||
return executeSqliteQuerySync(
|
||||
db,
|
||||
kyselyFor(db)
|
||||
.selectFrom("session_groups")
|
||||
.select(["name", "cwd", "worktree"])
|
||||
.orderBy("position", "asc")
|
||||
.orderBy("name", "asc"),
|
||||
).rows.map((row) => {
|
||||
const group: SessionGroupDefaultsRecord = { name: row.name };
|
||||
if (row.cwd) {
|
||||
group.cwd = row.cwd;
|
||||
}
|
||||
if (row.worktree !== null) {
|
||||
group.worktree = row.worktree === 1;
|
||||
}
|
||||
return group;
|
||||
});
|
||||
}
|
||||
|
||||
export function listSidebarSectionOrder(env: NodeJS.ProcessEnv = process.env): string[] {
|
||||
@@ -149,17 +196,25 @@ export function putSessionGroups(
|
||||
executeSqliteQuerySync(
|
||||
db,
|
||||
kysely.selectFrom("session_groups").select(["name", "created_at"]),
|
||||
).rows.map((row) => [row.name, row.created_at]),
|
||||
).rows.map((row) => [row.name, row]),
|
||||
);
|
||||
executeSqliteQuerySync(
|
||||
db,
|
||||
normalized.length === 0
|
||||
? kysely.deleteFrom("session_groups")
|
||||
: kysely.deleteFrom("session_groups").where("name", "not in", normalized),
|
||||
);
|
||||
executeSqliteQuerySync(db, kysely.deleteFrom("session_groups"));
|
||||
normalized.forEach((name, position) => {
|
||||
const prior = existing.get(name);
|
||||
executeSqliteQuerySync(
|
||||
db,
|
||||
kysely.insertInto("session_groups").values({
|
||||
name,
|
||||
position,
|
||||
created_at: existing.get(name) ?? now,
|
||||
}),
|
||||
prior
|
||||
? kysely.updateTable("session_groups").set({ position }).where("name", "=", name)
|
||||
: kysely.insertInto("session_groups").values({
|
||||
name,
|
||||
position,
|
||||
created_at: now,
|
||||
}),
|
||||
);
|
||||
});
|
||||
if (normalizedSectionOrder) {
|
||||
@@ -176,7 +231,7 @@ export function putSessionGroups(
|
||||
},
|
||||
{ env },
|
||||
);
|
||||
return normalized.map((name, position) => ({ name, position }));
|
||||
return listSessionGroups(env);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -226,10 +281,20 @@ function renameCatalogEntry(from: string, to: string, env: NodeJS.ProcessEnv): v
|
||||
runOpenClawStateWriteTransaction(
|
||||
({ db }) => {
|
||||
const kysely = kyselyFor(db);
|
||||
const hasDefaults = hasSessionGroupDefaultsSchema(db);
|
||||
const source = executeSqliteQuerySync(
|
||||
db,
|
||||
kysely.selectFrom("session_groups").selectAll().where("name", "=", from).limit(1),
|
||||
hasDefaults
|
||||
? kysely.selectFrom("session_groups").selectAll().where("name", "=", from).limit(1)
|
||||
: kysely
|
||||
.selectFrom("session_groups")
|
||||
.select(["name", "position", "created_at"])
|
||||
.where("name", "=", from)
|
||||
.limit(1),
|
||||
).rows[0];
|
||||
if (!source) {
|
||||
throw new SessionGroupNotFoundError(from);
|
||||
}
|
||||
const targetExists = executeSqliteQuerySync(
|
||||
db,
|
||||
kysely.selectFrom("session_groups").select("name").where("name", "=", to).limit(1),
|
||||
@@ -264,19 +329,78 @@ function renameCatalogEntry(from: string, to: string, env: NodeJS.ProcessEnv): v
|
||||
// Rename into an existing group merges memberships; keep its catalog row.
|
||||
return;
|
||||
}
|
||||
const base = {
|
||||
name: to,
|
||||
position: source.position,
|
||||
created_at: source.created_at,
|
||||
};
|
||||
executeSqliteQuerySync(
|
||||
db,
|
||||
kysely.insertInto("session_groups").values({
|
||||
name: to,
|
||||
position: source?.position ?? 0,
|
||||
created_at: source?.created_at ?? Date.now(),
|
||||
}),
|
||||
kysely.insertInto("session_groups").values(
|
||||
hasDefaults
|
||||
? {
|
||||
...base,
|
||||
cwd: "cwd" in source && typeof source.cwd === "string" ? source.cwd : null,
|
||||
worktree:
|
||||
"worktree" in source && typeof source.worktree === "number"
|
||||
? source.worktree
|
||||
: null,
|
||||
}
|
||||
: base,
|
||||
),
|
||||
);
|
||||
},
|
||||
{ env },
|
||||
);
|
||||
}
|
||||
|
||||
export function updateSessionGroupDefaults(
|
||||
name: string,
|
||||
defaults: { cwd: string | null; worktree: boolean },
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): SessionGroupDefaultsRecord[] | null {
|
||||
const normalized = normalizeOptionalString(name);
|
||||
if (!normalized) {
|
||||
throw new Error("group defaults update requires a non-empty name");
|
||||
}
|
||||
const database = openOpenClawStateDatabase({ env });
|
||||
let updated = false;
|
||||
let defaultsSchemaEnsured = false;
|
||||
runOpenClawStateWriteTransaction(
|
||||
({ db }) => {
|
||||
const kysely = kyselyFor(db);
|
||||
const existing = executeSqliteQuerySync(
|
||||
db,
|
||||
kysely.selectFrom("session_groups").select("name").where("name", "=", normalized).limit(1),
|
||||
).rows[0];
|
||||
if (!existing) {
|
||||
return;
|
||||
}
|
||||
if (!ensuredSessionGroupDefaultsDatabases.has(db)) {
|
||||
ensureColumn(db, "session_groups", "cwd TEXT");
|
||||
ensureColumn(db, "session_groups", "worktree INTEGER");
|
||||
defaultsSchemaEnsured = true;
|
||||
}
|
||||
const result = executeSqliteQuerySync(
|
||||
db,
|
||||
kysely
|
||||
.updateTable("session_groups")
|
||||
.set({
|
||||
cwd: normalizeOptionalString(defaults.cwd) ?? null,
|
||||
worktree: defaults.worktree ? 1 : 0,
|
||||
})
|
||||
.where("name", "=", normalized),
|
||||
);
|
||||
updated = result.numAffectedRows === 1n;
|
||||
},
|
||||
{ env },
|
||||
);
|
||||
if (defaultsSchemaEnsured) {
|
||||
ensuredSessionGroupDefaultsDatabases.add(database.db);
|
||||
}
|
||||
return updated ? listSessionGroupDefaults(env) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-updates member session categories across every agent store without
|
||||
* bumping updatedAt: group maintenance must not reshuffle recency ordering.
|
||||
|
||||
@@ -3,7 +3,14 @@ import { upsertSessionEntryCore } from "../config/sessions/session-accessor.js";
|
||||
import { addSessionMember } from "../config/sessions/session-sharing-store.js";
|
||||
import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js";
|
||||
import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js";
|
||||
import { sessionGroupHandlers } from "./server-methods/sessions-groups.js";
|
||||
import type { GatewayClient } from "./server-methods/types.js";
|
||||
import type { GatewayRequestContext, RespondFn } from "./server-methods/types.js";
|
||||
import {
|
||||
listSessionGroupDefaults,
|
||||
putSessionGroups,
|
||||
updateSessionGroupDefaults,
|
||||
} from "./session-groups.js";
|
||||
import {
|
||||
allowedSessionVisibilities,
|
||||
authorizeIncognitoSessionTarget,
|
||||
@@ -12,6 +19,7 @@ import {
|
||||
createSessionListEntryFilter,
|
||||
resolveSessionSharingRole,
|
||||
resolveSessionVisibility,
|
||||
SessionMutationAuthorizationChangedError,
|
||||
} from "./session-sharing.js";
|
||||
|
||||
afterEach(() => closeOpenClawAgentDatabasesForTest());
|
||||
@@ -88,6 +96,142 @@ function target(createdActor?: { type: "human"; id: string; label?: string }): S
|
||||
}
|
||||
|
||||
describe("session sharing policy", () => {
|
||||
it("requires participation before sessions.create can adopt a categorized key", async () => {
|
||||
await withOpenClawTestState({ scenario: "minimal" }, async () => {
|
||||
const sessionKey = "agent:main:dashboard:categorized-adoption";
|
||||
await upsertSessionEntryCore(
|
||||
{ agentId: "main", sessionKey },
|
||||
{
|
||||
sessionId: "session-categorized-adoption",
|
||||
updatedAt: 1,
|
||||
visibility: "read-only",
|
||||
category: "Personal",
|
||||
createdActor: { type: "human", id: "owner@example.com" },
|
||||
},
|
||||
);
|
||||
|
||||
const authorization = resolveSessionMutationAuthorization({
|
||||
client: client({ user: "viewer@example.com" }),
|
||||
method: "sessions.create",
|
||||
requestParams: { key: sessionKey, category: "Projects" },
|
||||
context: { getRuntimeConfig: () => ({}) } as GatewayRequestContext,
|
||||
});
|
||||
|
||||
expect(authorization.error).toMatchObject({
|
||||
details: { code: "SESSION_PARTICIPATION_REQUIRED" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("rechecks group members before committing a defaults update", async () => {
|
||||
await withOpenClawTestState({ scenario: "minimal" }, async () => {
|
||||
putSessionGroups(["Race"]);
|
||||
updateSessionGroupDefaults("Race", { cwd: "/repos/race", worktree: true });
|
||||
const viewer = client({ user: "viewer@example.com" });
|
||||
const context = {
|
||||
getRuntimeConfig: () => ({}),
|
||||
getSessionEventSubscriberConnIds: () => new Set<string>(),
|
||||
} as unknown as GatewayRequestContext;
|
||||
const authorization = resolveSessionMutationAuthorization({
|
||||
client: viewer,
|
||||
method: "sessions.groups.update",
|
||||
requestParams: { name: " Race ", cwd: null, worktree: false },
|
||||
context,
|
||||
});
|
||||
expect(authorization.error).toBeNull();
|
||||
|
||||
await upsertSessionEntryCore(
|
||||
{ agentId: "main", sessionKey: "agent:main:late-restricted-member" },
|
||||
{
|
||||
sessionId: "session-late-restricted-member",
|
||||
updatedAt: 1,
|
||||
visibility: "read-only",
|
||||
category: "Race",
|
||||
createdActor: { type: "human", id: "owner@example.com" },
|
||||
},
|
||||
);
|
||||
|
||||
await expect(
|
||||
sessionGroupHandlers["sessions.groups.update"]?.({
|
||||
params: { name: " Race ", cwd: null, worktree: false },
|
||||
client: viewer,
|
||||
context,
|
||||
sessionMutationAuthorization: authorization.authorization,
|
||||
respond: () => undefined,
|
||||
} as never),
|
||||
).rejects.toBeInstanceOf(SessionMutationAuthorizationChangedError);
|
||||
expect(listSessionGroupDefaults()).toEqual([
|
||||
{ name: "Race", cwd: "/repos/race", worktree: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("filters group defaults and blocks updates for sessions the caller cannot mutate", async () => {
|
||||
await withOpenClawTestState({ scenario: "minimal" }, async () => {
|
||||
putSessionGroups(["Projects", "Personal"]);
|
||||
updateSessionGroupDefaults("Projects", { cwd: "/repos/projects", worktree: true });
|
||||
updateSessionGroupDefaults("Personal", { cwd: "/repos/personal", worktree: false });
|
||||
await upsertSessionEntryCore(
|
||||
{ agentId: "main", sessionKey: "agent:main:restricted-project" },
|
||||
{
|
||||
sessionId: "session-restricted-project",
|
||||
updatedAt: 1,
|
||||
visibility: "read-only",
|
||||
category: "Projects",
|
||||
createdActor: { type: "human", id: "owner@example.com" },
|
||||
},
|
||||
);
|
||||
const viewer = client({ user: "viewer@example.com" });
|
||||
const context = {
|
||||
getRuntimeConfig: () => ({}),
|
||||
getSessionEventSubscriberConnIds: () => new Set<string>(),
|
||||
} as unknown as GatewayRequestContext;
|
||||
|
||||
expect(
|
||||
resolveSessionMutationAuthorization({
|
||||
client: viewer,
|
||||
method: "sessions.groups.update",
|
||||
requestParams: { name: "Projects", cwd: null, worktree: false },
|
||||
context,
|
||||
}).error,
|
||||
).toMatchObject({ details: { code: "SESSION_PARTICIPATION_REQUIRED" } });
|
||||
|
||||
const responses: Parameters<RespondFn>[] = [];
|
||||
await sessionGroupHandlers["sessions.groups.defaults"]?.({
|
||||
params: {},
|
||||
client: viewer,
|
||||
context,
|
||||
respond: (...response: Parameters<RespondFn>) => responses.push(response),
|
||||
} as never);
|
||||
expect(responses).toEqual([
|
||||
[
|
||||
true,
|
||||
{ defaults: [{ name: "Personal", cwd: "/repos/personal", worktree: false }] },
|
||||
undefined,
|
||||
],
|
||||
]);
|
||||
|
||||
const personalAuthorization = resolveSessionMutationAuthorization({
|
||||
client: viewer,
|
||||
method: "sessions.groups.update",
|
||||
requestParams: { name: "Personal", cwd: null, worktree: false },
|
||||
context,
|
||||
});
|
||||
expect(personalAuthorization.error).toBeNull();
|
||||
const updateResponses: Parameters<RespondFn>[] = [];
|
||||
await sessionGroupHandlers["sessions.groups.update"]?.({
|
||||
params: { name: "Personal", cwd: null, worktree: false },
|
||||
client: viewer,
|
||||
context,
|
||||
sessionMutationAuthorization: personalAuthorization.authorization,
|
||||
respond: (...response: Parameters<RespondFn>) => updateResponses.push(response),
|
||||
} as never);
|
||||
expect(updateResponses).toEqual([
|
||||
[true, { ok: true, defaults: [{ name: "Personal", worktree: false }] }, undefined],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("reports an incognito denial against the caller's requested key", () => {
|
||||
const hiddenTarget = {
|
||||
...target({ type: "human", id: "owner@example.com" }),
|
||||
|
||||
@@ -7,12 +7,7 @@ import {
|
||||
type SessionVisibility,
|
||||
} from "../../packages/gateway-protocol/src/index.js";
|
||||
import { AgentSelectionRequiredError } from "../agents/agent-scope.js";
|
||||
import {
|
||||
isSessionMember,
|
||||
resolveAllAgentSessionStoreTargetsSync,
|
||||
type SessionEntry,
|
||||
} from "../config/sessions.js";
|
||||
import { listSessionEntriesCore } from "../config/sessions/session-accessor.js";
|
||||
import { isSessionMember, type SessionEntry } from "../config/sessions.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { isIncognitoSessionKey } from "../routing/session-key.js";
|
||||
import { verifyBoardViewTicket } from "./board-view-ticket.js";
|
||||
@@ -23,6 +18,7 @@ import type {
|
||||
SessionMutationAuthorization,
|
||||
} from "./server-methods/types.js";
|
||||
import type { GatewayWsClient } from "./server/ws-types.js";
|
||||
import { resolveSessionGroupMutationTargetsByName } from "./session-group-mutation-targets.js";
|
||||
import {
|
||||
invalidateSessionSharingSnapshot,
|
||||
loadCachedSessionSharingSnapshot,
|
||||
@@ -284,6 +280,7 @@ const SESSION_KEY_PARAM_BY_METHOD = new Map<string, "key" | "sessionKey">([
|
||||
["sessions.compaction.branch", "key"],
|
||||
["sessions.compaction.restore", "key"],
|
||||
["sessions.compact", "key"],
|
||||
["sessions.create", "key"],
|
||||
["sessions.delete", "key"],
|
||||
["sessions.dispatch", "key"],
|
||||
["sessions.files.set", "sessionKey"],
|
||||
@@ -320,6 +317,7 @@ const REQUIRED_SESSION_TARGET_METHODS = new Set([
|
||||
"sessions.fork",
|
||||
"sessions.groups.delete",
|
||||
"sessions.groups.rename",
|
||||
"sessions.groups.update",
|
||||
"sessions.patch",
|
||||
"sessions.pluginPatch",
|
||||
"sessions.reclaim",
|
||||
@@ -334,17 +332,9 @@ function resolveSessionGroupMutationTargets(params: {
|
||||
requestParams: unknown;
|
||||
}): SessionMutationTarget[] | undefined {
|
||||
const groupName = readStringParam(params.requestParams, "name");
|
||||
if (!groupName) {
|
||||
return undefined;
|
||||
}
|
||||
return resolveAllAgentSessionStoreTargetsSync(params.getCfg()).flatMap((storeTarget) =>
|
||||
listSessionEntriesCore({
|
||||
agentId: storeTarget.agentId,
|
||||
storePath: storeTarget.storePath,
|
||||
}).flatMap(({ sessionKey, entry }) =>
|
||||
entry.category?.trim() === groupName ? [{ sessionKey, agentId: storeTarget.agentId }] : [],
|
||||
),
|
||||
);
|
||||
return groupName
|
||||
? (resolveSessionGroupMutationTargetsByName(params.getCfg()).get(groupName) ?? [])
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function resolveApprovalSessionTarget(
|
||||
@@ -393,7 +383,11 @@ function resolveSessionMutationTargets(params: {
|
||||
})
|
||||
: undefined;
|
||||
}
|
||||
if (params.method === "sessions.groups.rename" || params.method === "sessions.groups.delete") {
|
||||
if (
|
||||
params.method === "sessions.groups.rename" ||
|
||||
params.method === "sessions.groups.delete" ||
|
||||
params.method === "sessions.groups.update"
|
||||
) {
|
||||
return resolveSessionGroupMutationTargets({
|
||||
getCfg: params.getCfg,
|
||||
requestParams: params.requestParams,
|
||||
|
||||
@@ -316,11 +316,13 @@ export function setupGatewaySessionsTestHarness() {
|
||||
function createGatewaySessionsTestHarness(startServer: boolean) {
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
|
||||
const defaultAgentWorkspace = path.join(os.tmpdir(), "openclaw-gateway-test");
|
||||
let harness: GatewayServerHarness | undefined;
|
||||
let sharedSessionStoreDir: string | undefined;
|
||||
let sessionStoreCaseSeq = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
await fs.mkdir(defaultAgentWorkspace, { recursive: true });
|
||||
if (startServer) {
|
||||
const { startGatewayServerHarness } = await getGatewayServerHarnessModule();
|
||||
harness = await startGatewayServerHarness();
|
||||
@@ -535,6 +537,7 @@ function createGatewaySessionsTestHarness(startServer: boolean) {
|
||||
createConfiguredGlobalAgentSessionStore,
|
||||
createSessionStoreDir,
|
||||
createSelectedGlobalSessionStore,
|
||||
defaultAgentWorkspace,
|
||||
getHarness: requireHarness,
|
||||
openClient,
|
||||
resetConfiguredGlobalAgentSessionStore,
|
||||
@@ -667,14 +670,16 @@ export function expectNoSessionQueueCleanup() {
|
||||
}
|
||||
|
||||
type SessionsHandlers = Awaited<ReturnType<typeof getSessionsHandlers>>;
|
||||
type SessionsHandlerOptions = Parameters<SessionsHandlers[keyof SessionsHandlers]>[0];
|
||||
|
||||
export async function directSessionReq<TPayload = unknown>(
|
||||
method: keyof SessionsHandlers,
|
||||
params: Record<string, unknown>,
|
||||
opts?: {
|
||||
context?: Record<string, unknown>;
|
||||
client?: Parameters<SessionsHandlers[keyof SessionsHandlers]>[0]["client"];
|
||||
isWebchatConnect?: Parameters<SessionsHandlers[keyof SessionsHandlers]>[0]["isWebchatConnect"];
|
||||
client?: SessionsHandlerOptions["client"];
|
||||
isWebchatConnect?: SessionsHandlerOptions["isWebchatConnect"];
|
||||
sessionMutationAuthorization?: SessionsHandlerOptions["sessionMutationAuthorization"];
|
||||
coercePayload?: (payload: unknown) => TPayload;
|
||||
},
|
||||
): Promise<{ ok: boolean; payload?: TPayload; error?: { code?: string; message?: string } }> {
|
||||
@@ -719,6 +724,7 @@ export async function directSessionReq<TPayload = unknown>(
|
||||
} as never,
|
||||
client: opts?.client ?? null,
|
||||
isWebchatConnect: opts?.isWebchatConnect ?? (() => false),
|
||||
sessionMutationAuthorization: opts?.sessionMutationAuthorization,
|
||||
});
|
||||
if (!result) {
|
||||
throw new Error(`${method} did not respond`);
|
||||
|
||||
@@ -152,12 +152,13 @@ describe("OpenClaw database maintenance schema validation", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps every registered same-version column bare, canonical, and ensured", () => {
|
||||
expect(
|
||||
CLAW_LAZY_ADDITIVE_STATE_COLUMN_DEFINITIONS.map(
|
||||
({ columnName, tableName }) => `${tableName}.${columnName}`,
|
||||
),
|
||||
).toEqual(OPENCLAW_STATE_MAINTENANCE_SCHEMA_COMPATIBILITY.allowedMissingColumns);
|
||||
it("keeps every same-version additive column bare and canonical", () => {
|
||||
const additiveColumns = CLAW_LAZY_ADDITIVE_STATE_COLUMN_DEFINITIONS.map(
|
||||
({ columnName, tableName }) => `${tableName}.${columnName}`,
|
||||
);
|
||||
expect(OPENCLAW_STATE_MAINTENANCE_SCHEMA_COMPATIBILITY.allowedMissingColumns).toEqual(
|
||||
additiveColumns,
|
||||
);
|
||||
expect(
|
||||
CLAW_LAZY_ADDITIVE_STATE_COLUMN_DEFINITIONS.map(
|
||||
({ columnName, dataType, tableName }) => `${tableName}.${columnName} ${dataType}`,
|
||||
@@ -178,6 +179,8 @@ describe("OpenClaw database maintenance schema validation", () => {
|
||||
"worker_session_placements.terminal_at_ms INTEGER",
|
||||
"worktrees.run_end_cleanup_json TEXT",
|
||||
"device_bootstrap_tokens.setup_id TEXT",
|
||||
"session_groups.cwd TEXT",
|
||||
"session_groups.worktree INTEGER",
|
||||
"installed_plugin_index.workspace_dir TEXT",
|
||||
"secret_store_entries.allowed_hosts TEXT",
|
||||
]);
|
||||
|
||||
@@ -230,6 +230,21 @@ describe("OpenClaw database schema preflight", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts first-use session group columns without requiring a startup write", async () => {
|
||||
const databasePath = createExplicitStateDatabase(
|
||||
OPENCLAW_STATE_SCHEMA_SQL.replace(
|
||||
" created_at INTEGER NOT NULL,\n cwd TEXT,\n worktree INTEGER\n",
|
||||
" created_at INTEGER NOT NULL\n",
|
||||
),
|
||||
);
|
||||
|
||||
await expect(preflightOpenClawStateDatabasePath(databasePath)).resolves.toMatchObject({
|
||||
status: "exact",
|
||||
requiresWrite: false,
|
||||
issues: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an explicit preflight path with sidecars without touching it", async () => {
|
||||
const databasePath = createExplicitStateDatabase();
|
||||
const sqlite = requireNodeSqlite();
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
} from "./openclaw-state-ownership.js";
|
||||
import {
|
||||
getOpenClawStateRuntimeSchema,
|
||||
isOpenClawStateFirstUseSchemaIssue,
|
||||
isOpenClawStateStartupRepairableSchemaIssue,
|
||||
OPENCLAW_STATE_MAINTENANCE_SCHEMA_COMPATIBILITY,
|
||||
STATE_PERSISTENT_SCHEMA_COMPATIBILITY,
|
||||
@@ -256,7 +257,9 @@ export async function preflightOpenClawStateDatabasePath(
|
||||
OPENCLAW_STATE_MAINTENANCE_SCHEMA_COMPATIBILITY,
|
||||
);
|
||||
const blockingIssues = maintenanceIssues.filter(
|
||||
(issue) => !isOpenClawStateStartupRepairableSchemaIssue(issue),
|
||||
(issue) =>
|
||||
!isOpenClawStateStartupRepairableSchemaIssue(issue) &&
|
||||
!isOpenClawStateFirstUseSchemaIssue(issue),
|
||||
);
|
||||
if (blockingIssues.length > 0) {
|
||||
return result("incompatible", { issues: deduplicateSchemaIssues(blockingIssues) });
|
||||
@@ -267,7 +270,9 @@ export async function preflightOpenClawStateDatabasePath(
|
||||
STATE_PERSISTENT_SCHEMA_COMPATIBILITY,
|
||||
);
|
||||
const projectedRuntimeBlockingIssues = projectedRuntimeIssues.filter(
|
||||
(issue) => !isOpenClawStateStartupRepairableSchemaIssue(issue),
|
||||
(issue) =>
|
||||
!isOpenClawStateStartupRepairableSchemaIssue(issue) &&
|
||||
!isOpenClawStateFirstUseSchemaIssue(issue),
|
||||
);
|
||||
if (projectedRuntimeBlockingIssues.length > 0) {
|
||||
return result("incompatible", {
|
||||
@@ -275,8 +280,8 @@ export async function preflightOpenClawStateDatabasePath(
|
||||
});
|
||||
}
|
||||
const startupRepairableIssues = deduplicateSchemaIssues([
|
||||
...maintenanceIssues,
|
||||
...projectedRuntimeIssues,
|
||||
...maintenanceIssues.filter(isOpenClawStateStartupRepairableSchemaIssue),
|
||||
...projectedRuntimeIssues.filter(isOpenClawStateStartupRepairableSchemaIssue),
|
||||
]);
|
||||
return result(startupRepairableIssues.length > 0 ? "startup-repairable" : "exact", {
|
||||
issues: startupRepairableIssues,
|
||||
|
||||
@@ -23,21 +23,28 @@ export const CLAW_LAZY_ADDITIVE_STATE_COLUMN_DEFINITIONS = [
|
||||
{ columnName: "terminal_at_ms", dataType: "INTEGER", tableName: "worker_session_placements" },
|
||||
{ columnName: "run_end_cleanup_json", dataType: "TEXT", tableName: "worktrees" },
|
||||
{ columnName: "setup_id", dataType: "TEXT", tableName: "device_bootstrap_tokens" },
|
||||
{ columnName: "cwd", dataType: "TEXT", tableName: "session_groups" },
|
||||
{ columnName: "worktree", dataType: "INTEGER", tableName: "session_groups" },
|
||||
{ columnName: "workspace_dir", dataType: "TEXT", tableName: "installed_plugin_index" },
|
||||
{ columnName: "allowed_hosts", dataType: "TEXT", tableName: "secret_store_entries" },
|
||||
] as const satisfies readonly LazyAdditiveStateColumnDefinition[];
|
||||
|
||||
// Most same-version columns repair during a writable shared-state open. Setup
|
||||
// correlation is different: unrelated opens and generic bootstrap credentials
|
||||
// must leave older databases untouched until setup pairing first uses it.
|
||||
function isFirstUseAdditiveStateColumn({
|
||||
columnName,
|
||||
tableName,
|
||||
}: LazyAdditiveStateColumnDefinition): boolean {
|
||||
return (
|
||||
(tableName === "device_bootstrap_tokens" && columnName === "setup_id") ||
|
||||
(tableName === "session_groups" && (columnName === "cwd" || columnName === "worktree"))
|
||||
);
|
||||
}
|
||||
|
||||
// Most same-version columns repair during a writable shared-state open. These
|
||||
// feature-owned columns stay absent until setup or group defaults first uses them.
|
||||
export const CLAW_STARTUP_ADDITIVE_STATE_COLUMN_DEFINITIONS =
|
||||
CLAW_LAZY_ADDITIVE_STATE_COLUMN_DEFINITIONS.filter(
|
||||
({ columnName, tableName }) =>
|
||||
tableName !== "device_bootstrap_tokens" || columnName !== "setup_id",
|
||||
(definition) => !isFirstUseAdditiveStateColumn(definition),
|
||||
);
|
||||
|
||||
export const CLAW_FIRST_USE_ADDITIVE_STATE_COLUMN_DEFINITIONS =
|
||||
CLAW_LAZY_ADDITIVE_STATE_COLUMN_DEFINITIONS.filter(
|
||||
({ columnName, tableName }) =>
|
||||
tableName === "device_bootstrap_tokens" && columnName === "setup_id",
|
||||
);
|
||||
CLAW_LAZY_ADDITIVE_STATE_COLUMN_DEFINITIONS.filter(isFirstUseAdditiveStateColumn);
|
||||
|
||||
+2
@@ -1144,8 +1144,10 @@ export interface SecretStoreEntries {
|
||||
|
||||
export interface SessionGroups {
|
||||
created_at: number;
|
||||
cwd: string | null;
|
||||
name: string;
|
||||
position: number;
|
||||
worktree: number | null;
|
||||
}
|
||||
|
||||
export interface SessionStateEvents {
|
||||
|
||||
@@ -25,6 +25,9 @@ const CLAW_LAZY_ADDITIVE_STATE_COLUMNS = CLAW_LAZY_ADDITIVE_STATE_COLUMN_DEFINIT
|
||||
const CLAW_FIRST_USE_ADDITIVE_STATE_COLUMNS = CLAW_FIRST_USE_ADDITIVE_STATE_COLUMN_DEFINITIONS.map(
|
||||
({ columnName, tableName }) => `${tableName}.${columnName}`,
|
||||
);
|
||||
const CLAW_FIRST_USE_ADDITIVE_STATE_COLUMN_SET = new Set<string>(
|
||||
CLAW_FIRST_USE_ADDITIVE_STATE_COLUMNS,
|
||||
);
|
||||
const CLAW_STARTUP_ADDITIVE_STATE_COLUMN_SET = new Set<string>(
|
||||
CLAW_STARTUP_ADDITIVE_STATE_COLUMN_DEFINITIONS.map(
|
||||
({ columnName, tableName }) => `${tableName}.${columnName}`,
|
||||
@@ -131,3 +134,11 @@ export function isOpenClawStateStartupRepairableSchemaIssue(issue: SqliteSchemaI
|
||||
getOpenClawStateCanonicalNamedIndexSet().has(issue.objectName)
|
||||
);
|
||||
}
|
||||
|
||||
/** Identify compatible schema differences repaired only by their feature owner. */
|
||||
export function isOpenClawStateFirstUseSchemaIssue(issue: SqliteSchemaIssue): boolean {
|
||||
return (
|
||||
issue.code === "missing-column" &&
|
||||
CLAW_FIRST_USE_ADDITIVE_STATE_COLUMN_SET.has(issue.objectName)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2002,7 +2002,9 @@ CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
CREATE TABLE IF NOT EXISTS session_groups (
|
||||
name TEXT NOT NULL PRIMARY KEY,
|
||||
position INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
created_at INTEGER NOT NULL,
|
||||
cwd TEXT,
|
||||
worktree INTEGER
|
||||
) STRICT;
|
||||
|
||||
-- Gateway-owned sidebar section layout. IDs are ungrouped, groups, work, or
|
||||
|
||||
@@ -194,6 +194,21 @@ function renderSessionSection(params: {
|
||||
: nothing}
|
||||
${group
|
||||
? html`
|
||||
<button
|
||||
type="button"
|
||||
class="sidebar-session-group-actions sidebar-new-session"
|
||||
title=${newSessionAccess.allowed
|
||||
? t("sessionsView.newSessionInGroup", { group })
|
||||
: newSessionAccess.reason}
|
||||
aria-label=${t("sessionsView.newSessionInGroup", { group })}
|
||||
?disabled=${!newSessionAccess.allowed}
|
||||
@click=${(event: MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
host.openNewSession({ group });
|
||||
}}
|
||||
>
|
||||
${icons.plus}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="sidebar-session-group-actions"
|
||||
|
||||
@@ -19,7 +19,11 @@ import {
|
||||
trackDropdownKeyboardDismissal,
|
||||
} from "./web-awesome.ts";
|
||||
|
||||
type SidebarSessionGroupMenuAction = "rename-group" | "new-group" | "delete-group";
|
||||
type SidebarSessionGroupMenuAction =
|
||||
| "group-defaults"
|
||||
| "rename-group"
|
||||
| "new-group"
|
||||
| "delete-group";
|
||||
|
||||
function renderSidebarMenuTrigger(position: { x: number; y: number }, label: string) {
|
||||
return html`
|
||||
@@ -87,6 +91,7 @@ export function renderSidebarSessionGroupMenu(params: {
|
||||
menu: SidebarSessionGroupMenuState | null;
|
||||
trigger: HTMLElement | null;
|
||||
connected: boolean;
|
||||
groupDefaultsUnavailable?: boolean;
|
||||
actionDisabledReasons?: Partial<Record<SidebarSessionGroupMenuAction, string>>;
|
||||
onAction: (action: SidebarSessionGroupMenuAction, group: string) => void;
|
||||
onClose: (restoreFocus: boolean) => void;
|
||||
@@ -109,7 +114,10 @@ export function renderSidebarSessionGroupMenu(params: {
|
||||
event.preventDefault();
|
||||
const value = event.detail.item.value;
|
||||
if (
|
||||
(value === "rename-group" || value === "new-group" || value === "delete-group") &&
|
||||
(value === "group-defaults" ||
|
||||
value === "rename-group" ||
|
||||
value === "new-group" ||
|
||||
value === "delete-group") &&
|
||||
!params.actionDisabledReasons?.[value]
|
||||
) {
|
||||
params.onAction(value, menu.group);
|
||||
@@ -121,6 +129,20 @@ export function renderSidebarSessionGroupMenu(params: {
|
||||
params.onClose(consumeDropdownKeyboardDismissal(event))}
|
||||
>
|
||||
${renderSidebarMenuTrigger(menu, t("sessionsView.groupMenu", { group: menu.group }))}
|
||||
<wa-dropdown-item
|
||||
class="session-menu__item"
|
||||
value="group-defaults"
|
||||
?disabled=${!params.connected ||
|
||||
Boolean(params.actionDisabledReasons?.["group-defaults"])}
|
||||
title=${params.actionDisabledReasons?.["group-defaults"] ?? nothing}
|
||||
>
|
||||
<span slot="icon" class="session-menu__icon" aria-hidden="true">${icons.settings}</span>
|
||||
<span class="session-menu__text"
|
||||
>${params.groupDefaultsUnavailable
|
||||
? `${t("common.retry")}: ${t("sessionsView.groupDefaultsMenu")}`
|
||||
: t("sessionsView.groupDefaultsMenu")}</span
|
||||
>
|
||||
</wa-dropdown-item>
|
||||
<wa-dropdown-item
|
||||
class="session-menu__item"
|
||||
value="rename-group"
|
||||
|
||||
@@ -116,7 +116,7 @@ export interface SessionListHost {
|
||||
startSidebarSectionDrag(sectionId: string): void;
|
||||
finishSidebarSectionDrag(): void;
|
||||
toggleSection(sectionId: string): void;
|
||||
openNewSession(): void;
|
||||
openNewSession(target?: NewSessionTarget): void;
|
||||
readNewSessionAccess(): import("../lib/session-method-access.ts").SessionMethodAccess;
|
||||
readSessionMutationAccess(request: {
|
||||
method: string;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { html, nothing, type PropertyValues, type TemplateResult } from "lit";
|
||||
import { state } from "lit/decorators.js";
|
||||
import type { FsListDirResult } from "../../../packages/gateway-protocol/src/index.js";
|
||||
import type { SessionObserverDigest } from "../../../packages/gateway-protocol/src/schema/sessions.js";
|
||||
import { isSessionRouteId, pathForRoute } from "../app-route-paths.ts";
|
||||
import { beginNativeWindowDragFromTopInset } from "../app/native-window-drag.ts";
|
||||
@@ -19,6 +20,7 @@ import type { CatalogProjectGrouping } from "../lib/sessions/catalog-project-gro
|
||||
import { showToast } from "../lib/toast.ts";
|
||||
import { SubscriptionsController } from "../lit/subscriptions-controller.ts";
|
||||
import { SETTINGS_SEARCH_TARGETS } from "../pages/config/settings-targets.ts";
|
||||
import type { NewSessionTarget } from "../pages/new-session/location.ts";
|
||||
import { sidebarPluginTabs } from "./app-sidebar-nav-menus.ts";
|
||||
import {
|
||||
renderAppSidebarAttention,
|
||||
@@ -75,6 +77,26 @@ class AppSidebar extends AppSidebarSessionNavigationElement implements SessionLi
|
||||
override readonly sessionOrganizer = new SessionOrganizerController(this);
|
||||
override readonly sidebarMenus = new SidebarMenusController(this);
|
||||
|
||||
sessionGroupDefaults(name: string) {
|
||||
if (this.context?.sessions.groupsStatus() !== "ready") {
|
||||
return null;
|
||||
}
|
||||
const group = this.context?.sessions.state.groupSettings.find((entry) => entry.name === name);
|
||||
return group ? { cwd: group.cwd ?? "", worktree: group.worktree === true } : null;
|
||||
}
|
||||
|
||||
async listSessionGroupFolders(path?: string): Promise<FsListDirResult> {
|
||||
const scope = this.sessionData.beginSessionMutation();
|
||||
if (!scope) {
|
||||
throw new Error(t("sessionsView.groupDefaultsStale"));
|
||||
}
|
||||
const result = await scope.client.request<FsListDirResult>("fs.listDir", path ? { path } : {});
|
||||
if (!this.sessionData.isSessionMutationScopeCurrent(scope)) {
|
||||
throw new Error(t("sessionsView.groupDefaultsStale"));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Lazy: the controller pulls core token-suppression modules that must stay
|
||||
// out of the startup chunk (QA smoke startup-JS budget). It loads on the
|
||||
// first update with the preference enabled; earlier events are safely
|
||||
@@ -334,8 +356,8 @@ class AppSidebar extends AppSidebarSessionNavigationElement implements SessionLi
|
||||
this.sessionOrganizer.handleSessionListDrop(event);
|
||||
}
|
||||
|
||||
openNewSession(): void {
|
||||
this.requestOpenNewSession(this.expandedAgentId());
|
||||
openNewSession(target?: NewSessionTarget): void {
|
||||
this.requestOpenNewSession(this.expandedAgentId(), target);
|
||||
}
|
||||
|
||||
setVisibleSessionLimit(sectionId: string, limit: number): void {
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import { readMissingScopeError } from "@openclaw/gateway-client/browser";
|
||||
import { html, nothing, render } from "lit";
|
||||
import type { FsListDirResult } from "../../../packages/gateway-protocol/src/index.js";
|
||||
import { t } from "../i18n/index.ts";
|
||||
import { renderSessionMenuItem } from "../pages/new-session/cloud-target.ts";
|
||||
import type { BrowserTarget } from "../pages/new-session/discovery.ts";
|
||||
import { folderDisplayName, isAbsolutePath } from "../pages/new-session/path.ts";
|
||||
import { renderPlaceBrowser } from "../pages/new-session/place-browser.ts";
|
||||
import "../styles/new-session.css";
|
||||
import { icons } from "./icons.ts";
|
||||
import "./modal-dialog.ts";
|
||||
import "./web-awesome-popover.ts";
|
||||
|
||||
export type SessionGroupDefaults = { cwd: string; worktree: boolean };
|
||||
|
||||
type Options = {
|
||||
group: string;
|
||||
defaults: SessionGroupDefaults;
|
||||
listDirectory: (path?: string) => Promise<FsListDirResult>;
|
||||
submit: (defaults: SessionGroupDefaults) => Promise<string | null>;
|
||||
};
|
||||
|
||||
let active = false;
|
||||
|
||||
export function showSessionGroupDefaultsDialog(options: Options): Promise<void> {
|
||||
if (active) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
active = true;
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
return new Promise<void>((resolve) => {
|
||||
let cwd = options.defaults.cwd;
|
||||
let submitting = false;
|
||||
let failure: string | null = null;
|
||||
let browserVisible = false;
|
||||
let browserLoading = false;
|
||||
let browserError: string | null = null;
|
||||
let browserListing: FsListDirResult | null = null;
|
||||
let browserPathDraft = "";
|
||||
let browserRequestToken = 0;
|
||||
const browserTarget: BrowserTarget = { nodeId: "", label: t("newSession.gateway") };
|
||||
|
||||
const finish = () => {
|
||||
browserRequestToken += 1;
|
||||
render(nothing, host);
|
||||
host.remove();
|
||||
active = false;
|
||||
resolve();
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: Event) => {
|
||||
event.preventDefault();
|
||||
if (submitting) {
|
||||
return;
|
||||
}
|
||||
const form = event.currentTarget as HTMLFormElement;
|
||||
const data = new FormData(form);
|
||||
submitting = true;
|
||||
failure = null;
|
||||
paint();
|
||||
try {
|
||||
failure = await options.submit({
|
||||
cwd: cwd.trim(),
|
||||
worktree: data.get("mode") === "worktree",
|
||||
});
|
||||
} catch (error) {
|
||||
failure = String(error);
|
||||
}
|
||||
if (!failure) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
submitting = false;
|
||||
paint();
|
||||
};
|
||||
|
||||
const closePicker = () => {
|
||||
const picker = host.querySelector<HTMLElement & { open: boolean }>(
|
||||
"wa-popover.session-group-defaults__folder-popover",
|
||||
);
|
||||
if (picker) {
|
||||
picker.open = false;
|
||||
}
|
||||
};
|
||||
|
||||
const showPickerRoot = () => {
|
||||
browserRequestToken += 1;
|
||||
browserVisible = false;
|
||||
browserLoading = false;
|
||||
browserError = null;
|
||||
browserListing = null;
|
||||
browserPathDraft = "";
|
||||
paint();
|
||||
};
|
||||
|
||||
const applyFolder = (path: string) => {
|
||||
cwd = path.trim();
|
||||
showPickerRoot();
|
||||
closePicker();
|
||||
};
|
||||
|
||||
const loadDirectory = async (path?: string) => {
|
||||
const requestToken = ++browserRequestToken;
|
||||
const requestedPath = path?.trim() || undefined;
|
||||
browserLoading = true;
|
||||
browserError = null;
|
||||
browserListing = null;
|
||||
browserPathDraft = requestedPath ?? "";
|
||||
paint();
|
||||
try {
|
||||
const listing = await options.listDirectory(requestedPath);
|
||||
if (requestToken !== browserRequestToken) {
|
||||
return;
|
||||
}
|
||||
browserListing = listing;
|
||||
if (listing.path && browserPathDraft === (requestedPath ?? "")) {
|
||||
browserPathDraft = listing.path;
|
||||
}
|
||||
} catch (error) {
|
||||
if (requestToken !== browserRequestToken) {
|
||||
return;
|
||||
}
|
||||
browserError = readMissingScopeError(error)?.missingScope
|
||||
? t("newSession.browseRequiresAdmin")
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: t("newSession.browserLoadFailed");
|
||||
} finally {
|
||||
if (requestToken === browserRequestToken) {
|
||||
browserLoading = false;
|
||||
paint();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const showBrowser = () => {
|
||||
browserVisible = true;
|
||||
void loadDirectory(cwd || undefined);
|
||||
};
|
||||
|
||||
function paint() {
|
||||
const trimmedCwd = cwd.trim();
|
||||
const folderLabel = trimmedCwd
|
||||
? folderDisplayName(trimmedCwd)
|
||||
: t("sessionsView.groupDefaultsCwdPlaceholder");
|
||||
const usableBrowserPath = isAbsolutePath(browserPathDraft.trim())
|
||||
? browserPathDraft.trim()
|
||||
: null;
|
||||
render(
|
||||
html`
|
||||
<openclaw-modal-dialog
|
||||
label=${t("sessionsView.groupDefaultsTitle", { group: options.group })}
|
||||
@modal-cancel=${(event: Event) => {
|
||||
if (submitting) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
finish();
|
||||
}}
|
||||
>
|
||||
<form class="exec-approval-card" @submit=${handleSubmit}>
|
||||
<div class="exec-approval-header">
|
||||
<div class="exec-approval-title">
|
||||
${t("sessionsView.groupDefaultsTitle", { group: options.group })}
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span>${t("sessionsView.groupDefaultsCwd")}</span>
|
||||
<span class="new-session-page__select">
|
||||
<button
|
||||
id="session-group-defaults-folder-trigger"
|
||||
type="button"
|
||||
class="new-session-page__trigger"
|
||||
aria-label="${t("sessionsView.groupDefaultsCwd")}: ${folderLabel}"
|
||||
aria-haspopup="dialog"
|
||||
?disabled=${submitting}
|
||||
>
|
||||
<span class="new-session-page__target-icon" aria-hidden="true"
|
||||
>${icons.folder}</span
|
||||
>
|
||||
<span class="new-session-page__trigger-label">${folderLabel}</span>
|
||||
<span class="new-session-page__trigger-chevron" aria-hidden="true"
|
||||
>${icons.chevronDown}</span
|
||||
>
|
||||
</button>
|
||||
</span>
|
||||
<wa-popover
|
||||
class="new-session-page__select new-session-page__project-popover new-session-page__picker-popover session-group-defaults__folder-popover"
|
||||
for="session-group-defaults-folder-trigger"
|
||||
placement="bottom-start"
|
||||
without-arrow
|
||||
@wa-hide=${showPickerRoot}
|
||||
>
|
||||
${browserVisible
|
||||
? renderPlaceBrowser({
|
||||
listing: browserListing,
|
||||
target: browserTarget,
|
||||
loading: browserLoading,
|
||||
error: browserError,
|
||||
pathDraft: browserPathDraft,
|
||||
usablePath: usableBrowserPath,
|
||||
registerProjectPath: null,
|
||||
registeringProject: false,
|
||||
onPathDraftChange: (value) => {
|
||||
browserPathDraft = value;
|
||||
paint();
|
||||
},
|
||||
onNavigate: (path) => void loadDirectory(path),
|
||||
onBack: showPickerRoot,
|
||||
onRegisterProject: () => undefined,
|
||||
onClose: showPickerRoot,
|
||||
onApplyFolder: applyFolder,
|
||||
})
|
||||
: html`
|
||||
<div class="new-session-page__picker-root">
|
||||
${renderSessionMenuItem(
|
||||
{
|
||||
value: "agent-workspace",
|
||||
label: t("sessionsView.groupDefaultsCwdPlaceholder"),
|
||||
icon: icons.folder,
|
||||
checked: !trimmedCwd,
|
||||
onSelect: () => applyFolder(""),
|
||||
},
|
||||
submitting,
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
class="session-menu__item"
|
||||
data-value="browse"
|
||||
aria-pressed="false"
|
||||
?disabled=${submitting}
|
||||
@click=${showBrowser}
|
||||
>
|
||||
<span class="session-menu__check" aria-hidden="true"></span>
|
||||
<span class="session-menu__text">${t("newSession.browse")}</span>
|
||||
<span class="new-session-page__menu-chevron" aria-hidden="true"
|
||||
>${icons.chevronRight}</span
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
`}
|
||||
</wa-popover>
|
||||
<span class="muted" title=${trimmedCwd || nothing}
|
||||
>${trimmedCwd || t("sessionsView.groupDefaultsCwdHint")}</span
|
||||
>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span>${t("sessionsView.groupDefaultsMode")}</span>
|
||||
<select name="mode" ?disabled=${submitting}>
|
||||
<option value="local" ?selected=${!options.defaults.worktree}>
|
||||
${t("sessionsView.groupDefaultsLocal")}
|
||||
</option>
|
||||
<option value="worktree" ?selected=${options.defaults.worktree}>
|
||||
${t("sessionsView.groupDefaultsWorktree")}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
${failure
|
||||
? html`<div class="exec-approval-error" role="alert">${failure}</div>`
|
||||
: nothing}
|
||||
<div class="exec-approval-actions">
|
||||
<button type="submit" class="btn primary" ?disabled=${submitting}>
|
||||
${t("common.save")}
|
||||
</button>
|
||||
<button type="button" class="btn" ?disabled=${submitting} @click=${finish}>
|
||||
${t("common.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</openclaw-modal-dialog>
|
||||
`,
|
||||
host,
|
||||
);
|
||||
}
|
||||
|
||||
paint();
|
||||
});
|
||||
}
|
||||
@@ -117,6 +117,37 @@ export async function deleteSessionGroup(
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSessionGroupDefaults(
|
||||
host: SessionOrganizerControllerHost,
|
||||
group: string,
|
||||
defaults: { cwd: string | null; worktree: boolean },
|
||||
scope: SidebarSessionMutationScope,
|
||||
): Promise<SidebarSessionMutationResult> {
|
||||
if (!host.sessionData.isSessionMutationScopeCurrent(scope)) {
|
||||
return "stale";
|
||||
}
|
||||
if (
|
||||
!requireSessionMutationAccess(host, scope, {
|
||||
method: "sessions.groups.update",
|
||||
requiredScope: "operator.write",
|
||||
})
|
||||
) {
|
||||
return "failed";
|
||||
}
|
||||
try {
|
||||
const outcome = await scope.sessions.groupsUpdate(group, defaults);
|
||||
return outcome === "completed" && host.sessionData.isSessionMutationScopeCurrent(scope)
|
||||
? "completed"
|
||||
: "stale";
|
||||
} catch (error) {
|
||||
if (!host.sessionData.isSessionMutationScopeCurrent(scope)) {
|
||||
return "stale";
|
||||
}
|
||||
host.sessionData.publishSessionMutationError(scope, error);
|
||||
return "failed";
|
||||
}
|
||||
}
|
||||
|
||||
export async function reorderSidebarSection(
|
||||
host: SessionOrganizerControllerHost,
|
||||
sourceSectionId: string,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactiveController, ReactiveControllerHost } from "lit";
|
||||
import type { ReactiveControllerHost } from "lit";
|
||||
import type { FsListDirResult } from "../../../packages/gateway-protocol/src/index.js";
|
||||
import {
|
||||
parseSidebarEntry,
|
||||
SIDEBAR_NAV_ROUTES,
|
||||
@@ -38,6 +39,8 @@ import type { SessionMenuAction } from "./session-menu.ts";
|
||||
|
||||
type SessionOrganizerOperations = typeof import("./session-organizer-operations.runtime.ts");
|
||||
type InputDialogOpener = (typeof import("./input-dialog.ts"))["showInputDialog"];
|
||||
type SessionGroupDefaultsDialogOpener =
|
||||
(typeof import("./session-group-defaults-dialog.ts"))["showSessionGroupDefaultsDialog"];
|
||||
|
||||
export interface SessionOrganizerControllerHost extends ReactiveControllerHost {
|
||||
readonly sessionData: Pick<
|
||||
@@ -57,6 +60,8 @@ export interface SessionOrganizerControllerHost extends ReactiveControllerHost {
|
||||
clearSessionSelection(): void;
|
||||
findSidebarSessionByKey(sessionKey: string): SidebarRecentSession | undefined;
|
||||
knownSessionGroups(): string[];
|
||||
listSessionGroupFolders(path?: string): Promise<FsListDirResult>;
|
||||
sessionGroupDefaults(name: string): { cwd: string; worktree: boolean } | null;
|
||||
knownSessionCatalogIds(): string[];
|
||||
knownSectionOrder(): string[];
|
||||
pruneSidebarSessionEntry(key: string): void;
|
||||
@@ -67,7 +72,7 @@ export interface SessionOrganizerControllerHost extends ReactiveControllerHost {
|
||||
}
|
||||
|
||||
/** Custom session groups, collapse state, and drag-and-drop assignment. */
|
||||
export class SessionOrganizerController implements ReactiveController {
|
||||
export class SessionOrganizerController {
|
||||
collapsedSessionSections = loadStoredCollapsedSessionSections();
|
||||
draggingSessionKey: string | null = null;
|
||||
draggingSidebarSection: string | null = null;
|
||||
@@ -81,19 +86,7 @@ export class SessionOrganizerController implements ReactiveController {
|
||||
sessionListRemovalDrop = false;
|
||||
private operationsLoad: Promise<SessionOrganizerOperations> | null = null;
|
||||
|
||||
constructor(private readonly host: SessionOrganizerControllerHost) {
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
hostConnected(): void {}
|
||||
|
||||
// No dialog teardown here on purpose. The sidebar detaches for reasons that
|
||||
// are not the operator leaving — a narrow viewport drops it entirely — and
|
||||
// cancelling on those would throw away a name mid-edit. The dialog is a
|
||||
// body-level modal, so it outlives the sidebar's DOM position by design; the
|
||||
// Sessions page binds its own dialogs because a page unmount really is a
|
||||
// navigation.
|
||||
hostDisconnected(): void {}
|
||||
constructor(private readonly host: SessionOrganizerControllerHost) {}
|
||||
|
||||
private async loadOperations(
|
||||
scope: SidebarSessionMutationScope,
|
||||
@@ -530,6 +523,47 @@ export class SessionOrganizerController implements ReactiveController {
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
|
||||
async editSessionGroupDefaults(group: string): Promise<void> {
|
||||
let showDialog: SessionGroupDefaultsDialogOpener;
|
||||
try {
|
||||
showDialog = (await import("./session-group-defaults-dialog.ts"))
|
||||
.showSessionGroupDefaultsDialog;
|
||||
} catch (error) {
|
||||
const scope = this.host.sessionData.beginSessionMutation();
|
||||
if (scope) {
|
||||
this.host.sessionData.publishSessionMutationError(scope, error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const defaults = this.host.sessionGroupDefaults(group);
|
||||
if (defaults) {
|
||||
await showDialog({
|
||||
group,
|
||||
defaults,
|
||||
listDirectory: (path) => this.host.listSessionGroupFolders(path),
|
||||
submit: async (nextDefaults) => {
|
||||
const scope = this.host.sessionData.beginSessionMutation();
|
||||
if (!scope || !this.host.sessionGroupDefaults(group)) {
|
||||
return t("sessionsView.groupDefaultsStale");
|
||||
}
|
||||
const operations = await this.loadOperations(scope);
|
||||
const result = await operations?.updateSessionGroupDefaults(
|
||||
this.host,
|
||||
group,
|
||||
{ cwd: nextDefaults.cwd || null, worktree: nextDefaults.worktree },
|
||||
scope,
|
||||
);
|
||||
return result === "completed"
|
||||
? null
|
||||
: result === "stale"
|
||||
? t("sessionsView.groupDefaultsStale")
|
||||
: (this.host.sessionData.sessionMutationError ??
|
||||
t("sessionsView.groupDefaultsFailed"));
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
saveCollapsedSessionSections(sections: ReadonlySet<string>) {
|
||||
this.collapsedSessionSections = new Set(sections);
|
||||
this.host.requestUpdate();
|
||||
|
||||
@@ -34,6 +34,7 @@ export {
|
||||
deleteSessionGroup,
|
||||
renameSessionGroup,
|
||||
reorderSidebarSection,
|
||||
updateSessionGroupDefaults,
|
||||
} from "./session-organizer-catalog.ts";
|
||||
|
||||
export async function patchSession(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { html, nothing } from "lit";
|
||||
import { keyed } from "lit/directives/keyed.js";
|
||||
import { DEFAULT_SIDEBAR_ENTRIES, serializeSidebarEntry } from "../app-navigation.ts";
|
||||
import { readPresenceEntries, resolveCurrentSelfUser } from "../app/user-profile.ts";
|
||||
import { t } from "../i18n/index.ts";
|
||||
import { normalizeAgentLabel } from "../lib/agents/display.ts";
|
||||
import { openEditor } from "../lib/editor-links.ts";
|
||||
import { isGatewayMethodAdvertised } from "../lib/gateway-methods.ts";
|
||||
@@ -265,7 +266,12 @@ export function renderSidebarSessionMenuForController(controller: SidebarMenusCo
|
||||
export function renderSidebarSessionGroupMenuForController(controller: SidebarMenusController) {
|
||||
const { host } = controller;
|
||||
const menu = controller.sessionGroupMenu;
|
||||
const groupDefaultsStatus = host.sessionDataContext?.sessions.groupsStatus() ?? "idle";
|
||||
const groupActionAccess = {
|
||||
"group-defaults": readSessionMethodAccess(host.sessionDataContext?.gateway.snapshot, {
|
||||
method: "sessions.groups.update",
|
||||
requiredScope: "operator.write",
|
||||
}),
|
||||
"rename-group": readSessionMethodAccess(host.sessionDataContext?.gateway.snapshot, {
|
||||
method: "sessions.groups.rename",
|
||||
requiredScope: "operator.write",
|
||||
@@ -283,14 +289,30 @@ export function renderSidebarSessionGroupMenuForController(controller: SidebarMe
|
||||
menu,
|
||||
trigger: controller.sessionGroupMenuTrigger,
|
||||
connected: host.connected,
|
||||
groupDefaultsUnavailable: groupDefaultsStatus === "unavailable",
|
||||
actionDisabledReasons: Object.fromEntries(
|
||||
Object.entries(groupActionAccess).flatMap(([action, access]) =>
|
||||
access.allowed ? [] : [[action, access.reason]],
|
||||
),
|
||||
Object.entries(groupActionAccess).flatMap(([action, access]) => {
|
||||
if (!access.allowed) {
|
||||
return [[action, access.reason]];
|
||||
}
|
||||
return action === "group-defaults" &&
|
||||
groupDefaultsStatus !== "ready" &&
|
||||
groupDefaultsStatus !== "unavailable"
|
||||
? [[action, t("common.loading")]]
|
||||
: [];
|
||||
}),
|
||||
),
|
||||
onAction: (action, group) => {
|
||||
controller.closeSessionGroupMenu({ restoreFocus: true });
|
||||
switch (action) {
|
||||
case "group-defaults":
|
||||
if (groupDefaultsStatus === "unavailable") {
|
||||
host.sessionDataContext?.sessions.groupsInvalidate();
|
||||
void host.sessionDataContext?.sessions.groupsLoad();
|
||||
break;
|
||||
}
|
||||
void host.sessionOrganizer.editSessionGroupDefaults(group);
|
||||
break;
|
||||
case "rename-group":
|
||||
void host.sessionOrganizer.renameSessionGroupFromMenu(group);
|
||||
break;
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
import { expect, it } from "vitest";
|
||||
import {
|
||||
captureUiProof,
|
||||
createSessionManagementE2eSuite,
|
||||
installMockGateway,
|
||||
sessionsListResponse,
|
||||
} from "./session-management.test-support.ts";
|
||||
|
||||
const suite = createSessionManagementE2eSuite();
|
||||
|
||||
suite.define(() => {
|
||||
it("starts a session from a group with its saved folder and worktree defaults", async () => {
|
||||
const workspace = "/home/peter/openclaw";
|
||||
const initialGroupCwd = "/home/peter";
|
||||
const groupCwd = "/home/peter/client-work";
|
||||
const context = await suite.browser.newContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1280 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const gateway = await installMockGateway(page, {
|
||||
methodResponses: {
|
||||
"fs.listDir": {
|
||||
path: groupCwd,
|
||||
parent: "/home/peter",
|
||||
home: "/home/peter",
|
||||
entries: [],
|
||||
},
|
||||
"sessions.create": { key: "agent:main:client-work", runStarted: true },
|
||||
"sessions.list": sessionsListResponse([]),
|
||||
"worktrees.branches": {
|
||||
branches: [{ kind: "local", name: "main" }],
|
||||
defaultBranch: "main",
|
||||
repositoryStatus: "git",
|
||||
},
|
||||
},
|
||||
sessionGroups: ["Client work"],
|
||||
sessionGroupDefaults: { "Client work": { cwd: initialGroupCwd, worktree: false } },
|
||||
workspace,
|
||||
workspaceGit: true,
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(`${suite.server.baseUrl}chat`);
|
||||
const group = page.locator('[data-session-section="category:Client work"]');
|
||||
await group.waitFor({ state: "visible", timeout: 10_000 });
|
||||
await group.locator(".sidebar-recent-sessions__head").hover();
|
||||
await group.getByRole("button", { name: "Group options for Client work" }).click();
|
||||
await page.getByRole("menuitem", { name: "New session defaults…" }).click();
|
||||
await page.evaluate(async () => customElements.whenDefined("wa-popover"));
|
||||
const dialog = page.locator(
|
||||
`openclaw-modal-dialog[label='New session defaults for "Client work"']`,
|
||||
);
|
||||
await dialog.waitFor({ state: "visible" });
|
||||
const folderTrigger = dialog.locator("#session-group-defaults-folder-trigger");
|
||||
await expect.poll(() => folderTrigger.getAttribute("aria-label")).toContain("peter");
|
||||
await folderTrigger.click();
|
||||
const folderPicker = dialog.locator("wa-popover.session-group-defaults__folder-popover");
|
||||
await folderPicker.getByRole("button", { name: "Browse folders" }).click();
|
||||
expect((await gateway.waitForRequest("fs.listDir")).params).toEqual({
|
||||
path: initialGroupCwd,
|
||||
});
|
||||
await expect
|
||||
.poll(() => folderPicker.locator("input.new-session-page__browser-path").inputValue())
|
||||
.toBe(groupCwd);
|
||||
for (const viewport of [
|
||||
{ height: 844, name: "phone", width: 390 },
|
||||
{ height: 1024, name: "tablet", width: 768 },
|
||||
{ height: 900, name: "desktop", width: 1440 },
|
||||
{ height: 500, name: "landscape", width: 932 },
|
||||
]) {
|
||||
await page.setViewportSize(viewport);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bounds = await folderPicker.locator(".new-session-page__browser").boundingBox();
|
||||
return {
|
||||
horizontal: Boolean(
|
||||
bounds && bounds.x >= 0 && bounds.x + bounds.width <= viewport.width,
|
||||
),
|
||||
vertical: Boolean(
|
||||
bounds && bounds.y >= 0 && bounds.y + bounds.height <= viewport.height,
|
||||
),
|
||||
};
|
||||
})
|
||||
.toEqual({ horizontal: true, vertical: true });
|
||||
await captureUiProof(page, `group-defaults-folder-picker-${viewport.name}.png`);
|
||||
}
|
||||
await folderPicker.getByRole("button", { name: "Use this folder" }).click();
|
||||
await page.setViewportSize({ height: 900, width: 1280 });
|
||||
await expect.poll(() => folderTrigger.textContent()).toContain("client-work");
|
||||
await expect.poll(() => dialog.locator('input[name="cwd"]').count()).toBe(0);
|
||||
await dialog.locator('select[name="mode"]').selectOption("worktree");
|
||||
await dialog.getByRole("button", { name: "Save" }).click();
|
||||
expect((await gateway.waitForRequest("sessions.groups.update")).params).toMatchObject({
|
||||
name: "Client work",
|
||||
cwd: groupCwd,
|
||||
worktree: true,
|
||||
});
|
||||
|
||||
await group.locator(".sidebar-recent-sessions__head").hover();
|
||||
await group.getByRole("button", { name: "New session in Client work" }).click();
|
||||
await page.locator(".new-session-page__message").waitFor();
|
||||
await expect.poll(() => new URL(page.url()).searchParams.get("group")).toBe("Client work");
|
||||
await expect
|
||||
.poll(() =>
|
||||
page
|
||||
.locator("#new-session-project-trigger .new-session-page__trigger-label")
|
||||
.textContent(),
|
||||
)
|
||||
.toContain("client-work");
|
||||
expect((await gateway.waitForRequest("worktrees.branches")).params).toMatchObject({
|
||||
repoRoot: groupCwd,
|
||||
});
|
||||
await expect
|
||||
.poll(() => page.locator("#new-session-detail-trigger").getAttribute("data-worktree"))
|
||||
.toBe("true");
|
||||
|
||||
await page.locator(".new-session-page__message").fill("prepare the client release");
|
||||
await page.getByRole("button", { name: "Start session" }).click();
|
||||
expect((await gateway.waitForRequest("sessions.create")).params).toMatchObject({
|
||||
agentId: "main",
|
||||
category: "Client work",
|
||||
cwd: groupCwd,
|
||||
message: "prepare the client release",
|
||||
worktree: true,
|
||||
});
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("omits the group category for a legacy Gateway", async () => {
|
||||
const context = await suite.browser.newContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1280 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const gateway = await installMockGateway(page, {
|
||||
featureMethods: ["sessions.create", "sessions.groups.list"],
|
||||
methodResponses: {
|
||||
"sessions.create": { key: "agent:main:legacy-group", runStarted: true },
|
||||
"sessions.list": sessionsListResponse([]),
|
||||
},
|
||||
sessionGroups: ["Client work"],
|
||||
workspace: "/home/peter/openclaw",
|
||||
workspaceGit: false,
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(`${suite.server.baseUrl}new?group=Client+work`);
|
||||
await page.locator(".new-session-page__message").fill("start on an older Gateway");
|
||||
await page.getByRole("button", { name: "Start session" }).click();
|
||||
const create = await gateway.waitForRequest("sessions.create");
|
||||
expect(create.params).toMatchObject({
|
||||
agentId: "main",
|
||||
message: "start on an older Gateway",
|
||||
});
|
||||
expect(create.params).not.toHaveProperty("category");
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("revalidates an open group route when its defaults or identity change", async () => {
|
||||
const initialCwd = "/home/peter/client-work";
|
||||
const refreshedCwd = "/home/peter/refreshed-client-work";
|
||||
const context = await suite.browser.newContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1280 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await installMockGateway(page, {
|
||||
methodResponses: {
|
||||
"sessions.list": sessionsListResponse([]),
|
||||
"worktrees.branches": {
|
||||
branches: [{ kind: "local", name: "main" }],
|
||||
defaultBranch: "main",
|
||||
repositoryStatus: "git",
|
||||
},
|
||||
},
|
||||
sessionGroups: ["Client work"],
|
||||
sessionGroupDefaults: { "Client work": { cwd: initialCwd, worktree: true } },
|
||||
workspace: "/home/peter/openclaw",
|
||||
workspaceGit: true,
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(`${suite.server.baseUrl}new?group=Client+work`);
|
||||
const project = page.locator("#new-session-project-trigger .new-session-page__trigger-label");
|
||||
await expect.poll(() => project.textContent()).toContain("client-work");
|
||||
await page.locator(".new-session-page__message").fill("keep this draft");
|
||||
|
||||
await page.evaluate(async (cwd) => {
|
||||
const app = document.querySelector("openclaw-app") as HTMLElement & {
|
||||
runtime?: {
|
||||
context: {
|
||||
sessions: {
|
||||
groupsUpdate: (
|
||||
name: string,
|
||||
defaults: { cwd: string | null; worktree: boolean },
|
||||
) => Promise<unknown>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
await app.runtime?.context.sessions.groupsUpdate("Client work", {
|
||||
cwd,
|
||||
worktree: false,
|
||||
});
|
||||
}, refreshedCwd);
|
||||
|
||||
await expect.poll(() => project.textContent()).toContain("refreshed-client-work");
|
||||
await expect
|
||||
.poll(() => page.locator("#new-session-detail-trigger").getAttribute("data-worktree"))
|
||||
.toBe("false");
|
||||
await expect
|
||||
.poll(() => page.locator(".new-session-page__message").inputValue())
|
||||
.toBe("keep this draft");
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const app = document.querySelector("openclaw-app") as HTMLElement & {
|
||||
runtime?: {
|
||||
context: {
|
||||
sessions: {
|
||||
groupsRename: (from: string, to: string) => Promise<unknown>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
await app.runtime?.context.sessions.groupsRename("Client work", "Customer work");
|
||||
});
|
||||
|
||||
const unavailable = page.locator(".new-session-page__catalog-unavailable");
|
||||
await expect
|
||||
.poll(() => unavailable.textContent())
|
||||
.toContain("This session target is unavailable.");
|
||||
await expect
|
||||
.poll(() => page.getByRole("button", { name: "Start session" }).isDisabled())
|
||||
.toBe(true);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("fails an open group route closed while remote catalog invalidation is unresolved", async () => {
|
||||
const context = await suite.browser.newContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1280 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const gateway = await installMockGateway(page, {
|
||||
methodResponses: {
|
||||
"sessions.list": sessionsListResponse([]),
|
||||
"worktrees.branches": {
|
||||
branches: [{ kind: "local", name: "main" }],
|
||||
defaultBranch: "main",
|
||||
repositoryStatus: "git",
|
||||
},
|
||||
},
|
||||
sessionGroups: ["Client work"],
|
||||
sessionGroupDefaults: {
|
||||
"Client work": { cwd: "/home/peter/client-work", worktree: true },
|
||||
},
|
||||
workspace: "/home/peter/openclaw",
|
||||
workspaceGit: true,
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(`${suite.server.baseUrl}new?group=Client+work`);
|
||||
const start = page.getByRole("button", { name: "Start session" });
|
||||
await page.locator(".new-session-page__message").fill("wait for fresh defaults");
|
||||
await expect.poll(() => start.isEnabled()).toBe(true);
|
||||
|
||||
await gateway.deferNext("sessions.groups.list");
|
||||
await gateway.emitGatewayEvent("sessions.changed", { reason: "groups" });
|
||||
await gateway.waitForRequest("sessions.groups.list");
|
||||
await expect.poll(() => start.isDisabled()).toBe(true);
|
||||
await expect
|
||||
.poll(() => page.locator(".new-session-page__catalog-unavailable button").isDisabled())
|
||||
.toBe(true);
|
||||
await gateway.deferNext("sessions.groups.list");
|
||||
await gateway.rejectDeferred("sessions.groups.list", {
|
||||
code: "UNAVAILABLE",
|
||||
message: "catalog reload failed",
|
||||
});
|
||||
await gateway.waitForRequest("sessions.groups.list");
|
||||
await expect
|
||||
.poll(() => page.locator(".new-session-page__catalog-unavailable").textContent())
|
||||
.toContain("This session target is unavailable.");
|
||||
await expect.poll(() => start.isDisabled()).toBe(true);
|
||||
await expect
|
||||
.poll(() => page.locator(".new-session-page__catalog-unavailable button").isDisabled())
|
||||
.toBe(true);
|
||||
|
||||
await gateway.resolveDeferred("sessions.groups.list", {
|
||||
groups: [{ name: "Client work", position: 0 }],
|
||||
});
|
||||
await expect.poll(() => start.isEnabled()).toBe(true);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps rejected group defaults editable and allows retry", async () => {
|
||||
const groupCwd = "/home/peter/client-work";
|
||||
const context = await suite.browser.newContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1280 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const gateway = await installMockGateway(page, {
|
||||
deferredMethods: ["sessions.groups.update"],
|
||||
methodResponses: { "sessions.list": sessionsListResponse([]) },
|
||||
sessionGroups: ["Client work"],
|
||||
sessionGroupDefaults: { "Client work": { cwd: groupCwd, worktree: true } },
|
||||
workspace: "/home/peter/openclaw",
|
||||
workspaceGit: true,
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(`${suite.server.baseUrl}chat`);
|
||||
const group = page.locator('[data-session-section="category:Client work"]');
|
||||
await group.waitFor({ state: "visible", timeout: 10_000 });
|
||||
await group.locator(".sidebar-recent-sessions__head").hover();
|
||||
await group.getByRole("button", { name: "Group options for Client work" }).click();
|
||||
await page.getByRole("menuitem", { name: "New session defaults…" }).click();
|
||||
const dialog = page.locator(
|
||||
`openclaw-modal-dialog[label='New session defaults for "Client work"']`,
|
||||
);
|
||||
await dialog.waitFor({ state: "visible" });
|
||||
await dialog.locator('select[name="mode"]').selectOption("local");
|
||||
await dialog.getByRole("button", { name: "Save" }).click();
|
||||
await gateway.waitForRequest("sessions.groups.update");
|
||||
await gateway.rejectDeferred("sessions.groups.update", {
|
||||
code: "INVALID_REQUEST",
|
||||
message: "rejected group defaults",
|
||||
});
|
||||
|
||||
const alert = dialog.getByRole("alert");
|
||||
await alert.waitFor({ state: "visible" });
|
||||
await expect.poll(() => alert.textContent()).toContain("rejected group defaults");
|
||||
await expect.poll(() => dialog.locator('select[name="mode"]').inputValue()).toBe("local");
|
||||
|
||||
await dialog.getByRole("button", { name: "Save" }).click();
|
||||
await expect
|
||||
.poll(async () => (await gateway.getRequests("sessions.groups.update")).length)
|
||||
.toBe(2);
|
||||
await dialog.waitFor({ state: "detached" });
|
||||
|
||||
await group.locator(".sidebar-recent-sessions__head").hover();
|
||||
await group.getByRole("button", { name: "New session in Client work" }).click();
|
||||
await page.locator("#new-session-detail-trigger").waitFor();
|
||||
await expect
|
||||
.poll(() => page.locator("#new-session-detail-trigger").getAttribute("data-worktree"))
|
||||
.toBe("false");
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("offers retry when authoritative group defaults are unavailable", async () => {
|
||||
const groupCwd = "/home/peter/client-work";
|
||||
const context = await suite.browser.newContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1280 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const gateway = await installMockGateway(page, {
|
||||
deferredMethods: ["sessions.groups.defaults"],
|
||||
methodResponses: { "sessions.list": sessionsListResponse([]) },
|
||||
sessionGroups: ["Client work"],
|
||||
sessionGroupDefaults: { "Client work": { cwd: groupCwd, worktree: true } },
|
||||
workspace: "/home/peter/openclaw",
|
||||
workspaceGit: true,
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(`${suite.server.baseUrl}chat`);
|
||||
const group = page.locator('[data-session-section="category:Client work"]');
|
||||
await group.waitFor({ state: "visible", timeout: 10_000 });
|
||||
await gateway.waitForRequest("sessions.groups.list");
|
||||
await gateway.waitForRequest("sessions.groups.defaults");
|
||||
await group.locator(".sidebar-recent-sessions__head").hover();
|
||||
await group.getByRole("button", { name: "Group options for Client work" }).click();
|
||||
const defaultsAction = page.locator('wa-dropdown-item[value="group-defaults"]');
|
||||
await defaultsAction.waitFor({ state: "attached" });
|
||||
await expect.poll(() => defaultsAction.isDisabled()).toBe(true);
|
||||
expect(await gateway.getRequests("sessions.groups.update")).toHaveLength(0);
|
||||
expect(
|
||||
await page
|
||||
.locator(`openclaw-modal-dialog[label='New session defaults for "Client work"']`)
|
||||
.count(),
|
||||
).toBe(0);
|
||||
|
||||
await gateway.rejectDeferred("sessions.groups.defaults", {
|
||||
code: "INVALID_REQUEST",
|
||||
message: "defaults unavailable",
|
||||
});
|
||||
await expect.poll(() => defaultsAction.textContent()).toContain("Retry");
|
||||
await expect.poll(() => defaultsAction.isEnabled()).toBe(true);
|
||||
|
||||
await gateway.deferNext("sessions.groups.list");
|
||||
await defaultsAction.click();
|
||||
await gateway.waitForRequest("sessions.groups.list");
|
||||
await gateway.resolveDeferred("sessions.groups.list", {
|
||||
groups: [{ name: "Client work", position: 0 }],
|
||||
});
|
||||
await expect
|
||||
.poll(async () => (await gateway.getRequests("sessions.groups.defaults")).length)
|
||||
.toBe(2);
|
||||
|
||||
await group.locator(".sidebar-recent-sessions__head").hover();
|
||||
await group.getByRole("button", { name: "Group options for Client work" }).click();
|
||||
const readyDefaultsAction = page.locator('wa-dropdown-item[value="group-defaults"]');
|
||||
await expect.poll(() => readyDefaultsAction.textContent()).not.toContain("Retry");
|
||||
await expect.poll(() => readyDefaultsAction.isEnabled()).toBe(true);
|
||||
await readyDefaultsAction.click();
|
||||
|
||||
const dialog = page.locator(
|
||||
`openclaw-modal-dialog[label='New session defaults for "Client work"']`,
|
||||
);
|
||||
await dialog.waitFor({ state: "visible" });
|
||||
await expect
|
||||
.poll(() =>
|
||||
dialog.locator("#session-group-defaults-folder-trigger").getAttribute("aria-label"),
|
||||
)
|
||||
.toContain("client-work");
|
||||
await expect.poll(() => dialog.locator('select[name="mode"]').inputValue()).toBe("worktree");
|
||||
expect(await gateway.getRequests("sessions.groups.update")).toHaveLength(0);
|
||||
await dialog.getByRole("button", { name: "Cancel" }).click();
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks a missing group until a fresh catalog retry resolves it", async () => {
|
||||
const context = await suite.browser.newContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1280 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const gateway = await installMockGateway(page, {
|
||||
methodResponses: { "sessions.list": sessionsListResponse([]) },
|
||||
sessionGroups: [],
|
||||
workspace: "/home/peter/openclaw",
|
||||
workspaceGit: true,
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(`${suite.server.baseUrl}new?group=Deleted`);
|
||||
const unavailable = page.locator(".new-session-page__catalog-unavailable");
|
||||
await unavailable.waitFor();
|
||||
await expect
|
||||
.poll(() => unavailable.textContent())
|
||||
.toContain("This session target is unavailable.");
|
||||
await page.locator(".new-session-page__message").fill("do not create this session");
|
||||
const start = page.getByRole("button", { name: "Start session" });
|
||||
await expect.poll(() => start.isDisabled()).toBe(true);
|
||||
expect(await gateway.getRequests("sessions.create")).toHaveLength(0);
|
||||
|
||||
const listRequestsBeforeRetry = (await gateway.getRequests("sessions.groups.list")).length;
|
||||
await page.getByRole("button", { name: "Retry" }).click();
|
||||
await expect
|
||||
.poll(async () => (await gateway.getRequests("sessions.groups.list")).length)
|
||||
.toBeGreaterThan(listRequestsBeforeRetry);
|
||||
await expect.poll(() => start.isDisabled()).toBe(true);
|
||||
expect(await gateway.getRequests("sessions.create")).toHaveLength(0);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1031,6 +1031,17 @@ export const en: TranslationMap = {
|
||||
removeFromGroup: "Remove from group",
|
||||
moveBackToGroups: "Move back to Groups",
|
||||
groupMenu: "Group options for {group}",
|
||||
newSessionInGroup: "New session in {group}",
|
||||
groupDefaultsMenu: "New session defaults…",
|
||||
groupDefaultsTitle: 'New session defaults for "{group}"',
|
||||
groupDefaultsCwd: "Working directory",
|
||||
groupDefaultsCwdPlaceholder: "Use the agent workspace",
|
||||
groupDefaultsCwdHint: "Leave empty to use the selected agent's workspace.",
|
||||
groupDefaultsMode: "Environment",
|
||||
groupDefaultsLocal: "Local",
|
||||
groupDefaultsWorktree: "Worktree",
|
||||
groupDefaultsFailed: "Could not save the group defaults.",
|
||||
groupDefaultsStale: "Gateway connection replaced before the defaults were saved. Try again.",
|
||||
renameGroupMenu: "Rename group…",
|
||||
renameGroupTitle: 'Rename group "{group}"',
|
||||
groupNameLabel: "Group name",
|
||||
|
||||
@@ -68,6 +68,7 @@ function createState(): { state: AgentsState; request: ReturnType<typeof vi.fn<T
|
||||
error: null,
|
||||
deletedSessions: [],
|
||||
groups: [],
|
||||
groupSettings: [],
|
||||
sectionOrder: [],
|
||||
},
|
||||
},
|
||||
@@ -332,6 +333,7 @@ describe("loadToolsCatalog", () => {
|
||||
agentId: "main",
|
||||
profiles: [{ id: "full", label: "Full" }],
|
||||
groups: [],
|
||||
groupSettings: [],
|
||||
});
|
||||
await pending;
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ export type CloudSessionRecovery = {
|
||||
// Keep the create -> dispatch -> first-send handoff recoverable across reloads,
|
||||
// while scoping it to this tab, Gateway, and authenticated credential.
|
||||
const CLOUD_CREATE_STRING_FIELDS = [
|
||||
"category",
|
||||
"model",
|
||||
"thinkingLevel",
|
||||
"worktreeBaseRef",
|
||||
|
||||
@@ -19,6 +19,7 @@ export type SessionCreateParams = {
|
||||
forkFrom?: "last-completed";
|
||||
succeedsParent?: boolean;
|
||||
label?: string;
|
||||
category?: string;
|
||||
model?: string;
|
||||
thinkingLevel?: string;
|
||||
incognito?: boolean;
|
||||
|
||||
@@ -1,15 +1,45 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readSessionCustomGroupNames, readSidebarSectionOrder } from "./custom-groups.ts";
|
||||
import {
|
||||
mergeSessionGroupDefaults,
|
||||
readSessionCustomGroups,
|
||||
readSidebarSectionOrder,
|
||||
} from "./custom-groups.ts";
|
||||
|
||||
describe("session group catalog readers", () => {
|
||||
it("normalizes valid names and ignores malformed entries", () => {
|
||||
expect(
|
||||
readSessionCustomGroupNames({
|
||||
readSessionCustomGroups({
|
||||
groups: [{ name: " Alpha " }, { name: "" }, { name: 42 }, null],
|
||||
}),
|
||||
).toEqual(["Alpha"]);
|
||||
expect(readSessionCustomGroupNames(null)).toEqual([]);
|
||||
).toEqual([{ name: "Alpha", position: 0 }]);
|
||||
expect(readSessionCustomGroups(null)).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the catalog path-free and merges validated New Session defaults", () => {
|
||||
const groups = readSessionCustomGroups({
|
||||
groups: [
|
||||
{ name: " Client ", position: 4, cwd: " /leaked/client ", worktree: false },
|
||||
{ name: "Local", position: "bad", cwd: "/leaked/local", worktree: true },
|
||||
],
|
||||
});
|
||||
expect(groups).toEqual([
|
||||
{ name: "Client", position: 4 },
|
||||
{ name: "Local", position: 1 },
|
||||
]);
|
||||
expect(
|
||||
mergeSessionGroupDefaults(groups, {
|
||||
defaults: [
|
||||
{ name: " Client ", cwd: " /repos/client ", worktree: true },
|
||||
{ name: "Local", cwd: 42, worktree: false },
|
||||
{ name: "Missing", cwd: "/repos/missing", worktree: true },
|
||||
null,
|
||||
],
|
||||
}),
|
||||
).toEqual([
|
||||
{ name: "Client", position: 4, cwd: "/repos/client", worktree: true },
|
||||
{ name: "Local", position: 1, worktree: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads normalized section order", () => {
|
||||
|
||||
@@ -4,14 +4,57 @@
|
||||
|
||||
const BUILT_IN_SESSION_SECTION_IDS = new Set(["ungrouped", "groups", "work"]);
|
||||
|
||||
export function readSessionCustomGroupNames(payload: unknown): string[] {
|
||||
const groups = (payload as { groups?: Array<{ name?: unknown }> } | null)?.groups;
|
||||
export type SessionGroupSettings = {
|
||||
name: string;
|
||||
position: number;
|
||||
cwd?: string;
|
||||
worktree?: boolean;
|
||||
};
|
||||
|
||||
export function readSessionCustomGroups(payload: unknown): SessionGroupSettings[] {
|
||||
const groups = (payload as { groups?: unknown } | null)?.groups;
|
||||
if (!Array.isArray(groups)) {
|
||||
return [];
|
||||
}
|
||||
return groups.flatMap((group) =>
|
||||
typeof group?.name === "string" && group.name.trim() ? [group.name.trim()] : [],
|
||||
);
|
||||
return groups.flatMap((entry, index) => {
|
||||
const group = entry as Record<string, unknown> | null;
|
||||
const name = typeof group?.name === "string" ? group.name.trim() : "";
|
||||
if (!name) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
name,
|
||||
position:
|
||||
typeof group?.position === "number" && Number.isSafeInteger(group.position)
|
||||
? group.position
|
||||
: index,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
export function mergeSessionGroupDefaults(
|
||||
groups: readonly SessionGroupSettings[],
|
||||
payload: unknown,
|
||||
): SessionGroupSettings[] {
|
||||
const values = (payload as { defaults?: unknown } | null)?.defaults;
|
||||
const defaults = new Map<string, { cwd?: string; worktree?: boolean }>();
|
||||
if (Array.isArray(values)) {
|
||||
for (const value of values) {
|
||||
const record = value as Record<string, unknown> | null;
|
||||
const name = typeof record?.name === "string" ? record.name.trim() : "";
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
const cwd = typeof record?.cwd === "string" ? record.cwd.trim() : "";
|
||||
defaults.set(name, {
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(typeof record?.worktree === "boolean" ? { worktree: record.worktree } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
return groups.map((group) => ({ ...group, ...defaults.get(group.name) }));
|
||||
}
|
||||
|
||||
export function readSidebarSectionOrder(payload: unknown): string[] {
|
||||
|
||||
@@ -150,6 +150,23 @@ describe("createSessionCapability", () => {
|
||||
sessions.dispose();
|
||||
});
|
||||
|
||||
it("loads a metadata-less group catalog without probing the newer defaults method", async () => {
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "sessions.groups.list") {
|
||||
return { groups: [{ name: "Research", position: 0 }] };
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
});
|
||||
const client = { request } as unknown as GatewayBrowserClient;
|
||||
const { gateway } = createGatewayHarness(client);
|
||||
const sessions = createSessionCapability(gateway);
|
||||
|
||||
await expect(sessions.groupsLoad()).resolves.toEqual([{ name: "Research", position: 0 }]);
|
||||
expect(request).toHaveBeenCalledOnce();
|
||||
expect(sessions.state.groups).toEqual(["Research"]);
|
||||
sessions.dispose();
|
||||
});
|
||||
|
||||
it("publishes state.error when group rename is rejected", async () => {
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "sessions.groups.rename") {
|
||||
|
||||
@@ -92,6 +92,7 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
|
||||
error: null,
|
||||
deletedSessions: [],
|
||||
groups: [],
|
||||
groupSettings: [],
|
||||
sectionOrder: [],
|
||||
};
|
||||
const connection = createGatewayConnectionLifecycle(gateway.snapshot);
|
||||
@@ -374,6 +375,7 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
|
||||
error: null,
|
||||
deletedSessions: [],
|
||||
groups: state.groups,
|
||||
groupSettings: state.groupSettings,
|
||||
sectionOrder: state.sectionOrder,
|
||||
});
|
||||
return;
|
||||
@@ -521,8 +523,12 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
|
||||
listBranches: operations.listBranches,
|
||||
switchBranch: operations.switchBranch,
|
||||
groupsLoad: groups.load,
|
||||
groupsGeneration: groups.generation,
|
||||
groupsStatus: groups.status,
|
||||
groupsInvalidate: groups.invalidate,
|
||||
groupsPut: groups.put,
|
||||
groupsRename: groups.rename,
|
||||
groupsUpdate: groups.update,
|
||||
groupsDelete: groups.delete,
|
||||
subscribeCreated(listener) {
|
||||
createdListeners.add(listener);
|
||||
|
||||
@@ -7,16 +7,32 @@ describe("gateway-owned sidebar section order", () => {
|
||||
it("sends and publishes the order", async () => {
|
||||
const sectionOrder = ["catalog:codex", "work", "category:Beta", "ungrouped", "category:Alpha"];
|
||||
const request = vi.fn(async (method: string, params: unknown) => {
|
||||
expect(method).toBe("sessions.groups.put");
|
||||
expect(params).toEqual({ names: ["Beta", "Alpha"], sectionOrder });
|
||||
return {
|
||||
ok: true,
|
||||
groups: [
|
||||
{ name: "Beta", position: 0 },
|
||||
{ name: "Alpha", position: 1 },
|
||||
],
|
||||
sectionOrder,
|
||||
};
|
||||
if (method === "sessions.groups.put") {
|
||||
expect(params).toEqual({ names: ["Beta", "Alpha"], sectionOrder });
|
||||
return {
|
||||
ok: true,
|
||||
groups: [
|
||||
{ name: "Beta", position: 0 },
|
||||
{ name: "Alpha", position: 1 },
|
||||
],
|
||||
sectionOrder,
|
||||
};
|
||||
}
|
||||
if (method === "sessions.groups.list") {
|
||||
expect(params).toEqual({});
|
||||
return {
|
||||
groups: [
|
||||
{ name: "Beta", position: 0 },
|
||||
{ name: "Alpha", position: 1 },
|
||||
],
|
||||
sectionOrder,
|
||||
};
|
||||
}
|
||||
if (method === "sessions.groups.defaults") {
|
||||
expect(params).toEqual({});
|
||||
return { defaults: [] };
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
});
|
||||
const client = { request } as unknown as GatewayBrowserClient;
|
||||
const sessions = createSessionCapability({
|
||||
@@ -25,7 +41,11 @@ describe("gateway-owned sidebar section order", () => {
|
||||
phase: "connected",
|
||||
sessionKey: "agent:main:main",
|
||||
assistantAgentId: "main",
|
||||
hello: { features: { methods: ["sessions.groups.put"] } } as GatewayHelloOk,
|
||||
hello: {
|
||||
features: {
|
||||
methods: ["sessions.groups.put", "sessions.groups.list", "sessions.groups.defaults"],
|
||||
},
|
||||
} as GatewayHelloOk,
|
||||
},
|
||||
subscribe: () => () => undefined,
|
||||
subscribeEvents: () => () => undefined,
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
import type { ApplicationGatewayPhase } from "../../app/gateway.ts";
|
||||
import type { GatewayConnectionScope } from "../gateway-connection-lifecycle.ts";
|
||||
import type { SessionCreateOutcome, SessionCreateParams } from "./create.ts";
|
||||
import type { SessionGroupSettings } from "./custom-groups.ts";
|
||||
import type { SessionArchivedFilter } from "./navigation.ts";
|
||||
import type { SessionPatchRoute } from "./patch.ts";
|
||||
import type {
|
||||
@@ -36,11 +37,14 @@ export type SessionState = {
|
||||
deletedSessions: readonly SessionDeleteTarget[];
|
||||
/** Gateway-owned custom group catalog in display order. */
|
||||
groups: readonly string[];
|
||||
/** New Session defaults associated with each gateway-owned group. */
|
||||
groupSettings: readonly SessionGroupSettings[];
|
||||
/** Gateway-owned sidebar section order; pinned is intentionally absent. */
|
||||
sectionOrder: readonly string[];
|
||||
};
|
||||
|
||||
export type SessionGroupMutationResult = "completed" | "stale";
|
||||
export type SessionGroupDefaultsStatus = "idle" | "loading" | "ready" | "unavailable";
|
||||
|
||||
export type SessionListOptions = {
|
||||
agentId?: string;
|
||||
@@ -244,8 +248,14 @@ export type SessionCapability = {
|
||||
leafEntryId: string,
|
||||
options?: { agentId?: string | null },
|
||||
) => Promise<SessionsBranchesSwitchResult>;
|
||||
/** Loads the gateway-owned group catalog, coalescing successful connection attempts. */
|
||||
groupsLoad: () => Promise<void>;
|
||||
/** Loads one connection-owned group catalog; null means the attempt retired or failed. */
|
||||
groupsLoad: () => Promise<readonly SessionGroupSettings[] | null>;
|
||||
/** Generation of the catalog/defaults snapshot used by group-target routes. */
|
||||
groupsGeneration: () => number;
|
||||
/** Whether group defaults are current enough for a group-target route. */
|
||||
groupsStatus: () => SessionGroupDefaultsStatus;
|
||||
/** Invalidates the connection-owned group catalog before an explicit route retry. */
|
||||
groupsInvalidate: () => void;
|
||||
/** Replaces the group catalog; stale means the initiating connection retired. */
|
||||
groupsPut: (
|
||||
names: readonly string[],
|
||||
@@ -253,6 +263,11 @@ export type SessionCapability = {
|
||||
) => Promise<SessionGroupMutationResult>;
|
||||
/** Renames a group; stale means the initiating connection retired before reconciliation. */
|
||||
groupsRename: (from: string, to: string) => Promise<SessionGroupMutationResult>;
|
||||
/** Updates the New Session defaults for one group. */
|
||||
groupsUpdate: (
|
||||
name: string,
|
||||
defaults: { cwd: string | null; worktree: boolean },
|
||||
) => Promise<SessionGroupMutationResult>;
|
||||
/** Deletes a group; stale means the initiating connection retired before reconciliation. */
|
||||
groupsDelete: (name: string) => Promise<SessionGroupMutationResult>;
|
||||
subscribeCreated: (listener: (key: string) => void) => () => void;
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { getSafeLocalStorage } from "../../local-storage.ts";
|
||||
import { isGatewayMethodAdvertised } from "../gateway-methods.ts";
|
||||
import { readSessionMethodAccess } from "../session-method-access.ts";
|
||||
import { readSessionCustomGroupNames, readSidebarSectionOrder } from "./custom-groups.ts";
|
||||
import {
|
||||
readSessionCustomGroups,
|
||||
readSidebarSectionOrder,
|
||||
mergeSessionGroupDefaults,
|
||||
type SessionGroupSettings,
|
||||
} from "./custom-groups.ts";
|
||||
import type {
|
||||
SessionConnectionOwner,
|
||||
SessionConnectionScope,
|
||||
SessionGateway,
|
||||
SessionGroupDefaultsStatus,
|
||||
SessionGroupMutationResult,
|
||||
SessionState,
|
||||
} from "./session-capability.ts";
|
||||
@@ -21,18 +27,20 @@ type SessionGroupCatalogHost = {
|
||||
|
||||
const LEGACY_GROUPS_STORAGE_KEY = "openclaw:sessions:custom-groups";
|
||||
const GROUPS_LIST_METHOD = "sessions.groups.list";
|
||||
const GROUPS_DEFAULTS_METHOD = "sessions.groups.defaults";
|
||||
|
||||
function readLegacyStoredGroups(): string[] {
|
||||
try {
|
||||
const raw = getSafeLocalStorage()?.getItem(LEGACY_GROUPS_STORAGE_KEY);
|
||||
const parsed: unknown = raw ? JSON.parse(raw) : [];
|
||||
const parsed: unknown = JSON.parse(
|
||||
getSafeLocalStorage()?.getItem(LEGACY_GROUPS_STORAGE_KEY) ?? "[]",
|
||||
);
|
||||
return Array.isArray(parsed)
|
||||
? [
|
||||
...new Set(
|
||||
parsed.flatMap((name) => {
|
||||
const normalized = typeof name === "string" ? name.trim() : "";
|
||||
return normalized ? [normalized] : [];
|
||||
}),
|
||||
parsed
|
||||
.filter((name): name is string => typeof name === "string")
|
||||
.map((name) => name.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
]
|
||||
: [];
|
||||
@@ -44,11 +52,14 @@ function readLegacyStoredGroups(): string[] {
|
||||
export function createSessionGroupCatalog(host: SessionGroupCatalogHost) {
|
||||
let loadedEpoch = -1;
|
||||
let loadGeneration = 0;
|
||||
let catalogGeneration = 0;
|
||||
let defaultsStatus: SessionGroupDefaultsStatus = "idle";
|
||||
let pendingLoad: Promise<readonly SessionGroupSettings[] | null> | null = null;
|
||||
let retryTimer: ReturnType<typeof globalThis.setTimeout> | null = null;
|
||||
|
||||
const clearRetry = () => {
|
||||
if (retryTimer !== null) {
|
||||
globalThis.clearTimeout(retryTimer);
|
||||
clearTimeout(retryTimer);
|
||||
retryTimer = null;
|
||||
}
|
||||
};
|
||||
@@ -56,19 +67,55 @@ export function createSessionGroupCatalog(host: SessionGroupCatalogHost) {
|
||||
const invalidate = () => {
|
||||
loadedEpoch = -1;
|
||||
loadGeneration += 1;
|
||||
catalogGeneration += 1;
|
||||
pendingLoad = null;
|
||||
clearRetry();
|
||||
defaultsStatus = "loading";
|
||||
// Every invalidation publishes its generation, including back-to-back
|
||||
// events while the previous reload is still pending.
|
||||
host.publish({ ...host.readState() });
|
||||
};
|
||||
|
||||
const dispose = () => {
|
||||
loadedEpoch = -1;
|
||||
loadGeneration += 1;
|
||||
pendingLoad = null;
|
||||
clearRetry();
|
||||
};
|
||||
|
||||
const publishCatalog = (groups: readonly string[], sectionOrder: readonly string[]) => {
|
||||
const publishCatalog = (
|
||||
groupSettings: readonly SessionGroupSettings[],
|
||||
sectionOrder: readonly string[],
|
||||
status: SessionGroupDefaultsStatus,
|
||||
) => {
|
||||
const state = host.readState();
|
||||
const groups = groupSettings.map((group) => group.name);
|
||||
const groupsUnchanged =
|
||||
groups.length === state.groups.length &&
|
||||
groups.every((group, i) => group === state.groups[i]);
|
||||
const orderUnchanged =
|
||||
sectionOrder.length === state.sectionOrder.length &&
|
||||
sectionOrder.every((sectionId, i) => sectionId === state.sectionOrder[i]);
|
||||
if (!groupsUnchanged || !orderUnchanged) {
|
||||
host.publish({ ...state, groups: [...groups], sectionOrder: [...sectionOrder] });
|
||||
const settingsUnchanged =
|
||||
groupSettings.length === state.groupSettings.length &&
|
||||
groupSettings.every((group, index) => {
|
||||
const current = state.groupSettings[index];
|
||||
return (
|
||||
current?.name === group.name &&
|
||||
current.position === group.position &&
|
||||
current.cwd === group.cwd &&
|
||||
current.worktree === group.worktree
|
||||
);
|
||||
});
|
||||
const statusChanged = defaultsStatus !== status;
|
||||
defaultsStatus = status;
|
||||
if (!groupsUnchanged || !settingsUnchanged || !orderUnchanged || statusChanged) {
|
||||
host.publish({
|
||||
...state,
|
||||
groups: [...groups],
|
||||
groupSettings: [...groupSettings],
|
||||
sectionOrder: [...sectionOrder],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -80,6 +127,35 @@ export function createSessionGroupCatalog(host: SessionGroupCatalogHost) {
|
||||
throw error;
|
||||
};
|
||||
|
||||
const finishLoadFailure = (
|
||||
scope: SessionConnectionScope,
|
||||
generation: number,
|
||||
error: unknown,
|
||||
retry: boolean,
|
||||
) => {
|
||||
if (!host.connection.isCurrent(scope) || generation !== loadGeneration) {
|
||||
return null;
|
||||
}
|
||||
if (defaultsStatus !== "unavailable") {
|
||||
defaultsStatus = "unavailable";
|
||||
host.publish({ ...host.readState() });
|
||||
}
|
||||
if (!retry) {
|
||||
return null;
|
||||
}
|
||||
loadedEpoch = -1;
|
||||
const delay = host.retryDelayMs(error);
|
||||
if (delay !== null) {
|
||||
retryTimer = setTimeout(() => {
|
||||
retryTimer = null;
|
||||
if (host.connection.isCurrent(scope) && generation === loadGeneration) {
|
||||
void load();
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const loadAttempt = async (
|
||||
scope: SessionConnectionScope,
|
||||
generation: number,
|
||||
@@ -88,71 +164,102 @@ export function createSessionGroupCatalog(host: SessionGroupCatalogHost) {
|
||||
try {
|
||||
const listed = await scope.client.request(GROUPS_LIST_METHOD, {});
|
||||
if (!host.connection.isCurrent(scope) || generation !== loadGeneration) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
let names = readSessionCustomGroupNames(listed);
|
||||
let settings = readSessionCustomGroups(listed);
|
||||
let sectionOrder = readSidebarSectionOrder(listed);
|
||||
// Browser-local catalogs predate the gateway store and migrate exactly once.
|
||||
const legacy = readLegacyStoredGroups();
|
||||
const legacyMigrationAccess = readSessionMethodAccess(host.snapshot(), {
|
||||
method: "sessions.groups.put",
|
||||
requiredScope: "operator.write",
|
||||
});
|
||||
if (names.length === 0 && legacy.length > 0 && legacyMigrationAccess.allowed) {
|
||||
const put = await scope.client.request("sessions.groups.put", { names: legacy });
|
||||
if (!host.connection.isCurrent(scope) || generation !== loadGeneration) {
|
||||
return;
|
||||
if (
|
||||
legacy.length > 0 &&
|
||||
readSessionMethodAccess(host.snapshot(), {
|
||||
method: "sessions.groups.put",
|
||||
requiredScope: "operator.write",
|
||||
}).allowed
|
||||
) {
|
||||
if (settings.length === 0) {
|
||||
const put = await scope.client.request("sessions.groups.put", { names: legacy });
|
||||
if (!host.connection.isCurrent(scope) || generation !== loadGeneration) {
|
||||
return null;
|
||||
}
|
||||
settings = readSessionCustomGroups(put);
|
||||
sectionOrder = readSidebarSectionOrder(put);
|
||||
}
|
||||
names = readSessionCustomGroupNames(put);
|
||||
sectionOrder = readSidebarSectionOrder(put);
|
||||
}
|
||||
if (legacy.length > 0 && legacyMigrationAccess.allowed) {
|
||||
try {
|
||||
getSafeLocalStorage()?.removeItem(LEGACY_GROUPS_STORAGE_KEY);
|
||||
} catch {
|
||||
// The gateway catalog is canonical even when browser cleanup fails.
|
||||
}
|
||||
}
|
||||
publishCatalog(names, sectionOrder);
|
||||
} catch (error) {
|
||||
if (
|
||||
!host.connection.isCurrent(scope) ||
|
||||
generation !== loadGeneration ||
|
||||
advertised !== true
|
||||
) {
|
||||
// Gateways without feature metadata retain the legacy one-shot probe.
|
||||
return;
|
||||
const defaultsAllowed =
|
||||
isGatewayMethodAdvertised(host.snapshot(), GROUPS_DEFAULTS_METHOD) === true &&
|
||||
readSessionMethodAccess(host.snapshot(), {
|
||||
method: GROUPS_DEFAULTS_METHOD,
|
||||
requiredScope: "operator.write",
|
||||
}).allowed;
|
||||
if (!defaultsAllowed) {
|
||||
publishCatalog(settings, sectionOrder, "ready");
|
||||
return settings;
|
||||
}
|
||||
loadedEpoch = -1;
|
||||
const delay = host.retryDelayMs(error);
|
||||
if (delay === null) {
|
||||
return;
|
||||
}
|
||||
// An older rejection cannot revive retries after a newer catalog load wins.
|
||||
retryTimer = globalThis.setTimeout(() => {
|
||||
retryTimer = null;
|
||||
if (host.connection.isCurrent(scope) && generation === loadGeneration) {
|
||||
void load();
|
||||
// The path-free catalog is independently useful to the sidebar. Defaults
|
||||
// readiness only gates group-target routes and must not erase those names.
|
||||
publishCatalog(settings, sectionOrder, "loading");
|
||||
try {
|
||||
const defaults = await scope.client.request(GROUPS_DEFAULTS_METHOD, {});
|
||||
if (!host.connection.isCurrent(scope) || generation !== loadGeneration) {
|
||||
return null;
|
||||
}
|
||||
}, delay);
|
||||
settings = mergeSessionGroupDefaults(settings, defaults);
|
||||
} catch (error) {
|
||||
return finishLoadFailure(scope, generation, error, true);
|
||||
}
|
||||
publishCatalog(settings, sectionOrder, "ready");
|
||||
return settings;
|
||||
} catch (error) {
|
||||
// Gateways without feature metadata retain the legacy one-shot probe.
|
||||
return finishLoadFailure(scope, generation, error, advertised === true);
|
||||
}
|
||||
};
|
||||
|
||||
/** Group consumers may probe once per connection; explicitly absent features never probe. */
|
||||
const load = async () => {
|
||||
const scope = host.connection.capture();
|
||||
if (!scope || loadedEpoch === scope.epoch) {
|
||||
return;
|
||||
if (!scope) {
|
||||
return null;
|
||||
}
|
||||
if (loadedEpoch === scope.epoch) {
|
||||
return pendingLoad ?? host.readState().groupSettings;
|
||||
}
|
||||
const advertised = isGatewayMethodAdvertised(host.snapshot(), GROUPS_LIST_METHOD);
|
||||
clearRetry();
|
||||
const generation = ++loadGeneration;
|
||||
loadedEpoch = scope.epoch;
|
||||
if (advertised === false) {
|
||||
publishCatalog([], []);
|
||||
return;
|
||||
if (defaultsStatus !== "loading") {
|
||||
defaultsStatus = "loading";
|
||||
host.publish({ ...host.readState() });
|
||||
}
|
||||
await loadAttempt(scope, generation, advertised);
|
||||
if (advertised === false) {
|
||||
publishCatalog([], [], "ready");
|
||||
return [];
|
||||
}
|
||||
const promise = loadAttempt(scope, generation, advertised).finally(() => {
|
||||
if (pendingLoad === promise) {
|
||||
pendingLoad = null;
|
||||
}
|
||||
});
|
||||
pendingLoad = promise;
|
||||
return promise;
|
||||
};
|
||||
|
||||
const publishPathFreeMutation = (
|
||||
groupSettings: readonly SessionGroupSettings[],
|
||||
sectionOrder: readonly string[],
|
||||
) => {
|
||||
// Catalog mutations do not carry authoritative defaults. Retire any older
|
||||
// defaults read and keep group routes blocked until a fresh read completes.
|
||||
invalidate();
|
||||
publishCatalog(groupSettings, sectionOrder, "loading");
|
||||
void load();
|
||||
};
|
||||
|
||||
const put = async (
|
||||
@@ -171,7 +278,12 @@ export function createSessionGroupCatalog(host: SessionGroupCatalogHost) {
|
||||
if (!host.connection.isCurrent(scope)) {
|
||||
return "stale";
|
||||
}
|
||||
publishCatalog(readSessionCustomGroupNames(result), readSidebarSectionOrder(result));
|
||||
publishPathFreeMutation(
|
||||
mergeSessionGroupDefaults(readSessionCustomGroups(result), {
|
||||
defaults: host.readState().groupSettings,
|
||||
}),
|
||||
readSidebarSectionOrder(result),
|
||||
);
|
||||
return "completed";
|
||||
} catch (error) {
|
||||
return finishMutationFailure(host.connection.isCurrent(scope), error);
|
||||
@@ -188,7 +300,15 @@ export function createSessionGroupCatalog(host: SessionGroupCatalogHost) {
|
||||
if (!host.connection.isCurrent(scope)) {
|
||||
return "stale";
|
||||
}
|
||||
publishCatalog(readSessionCustomGroupNames(result), readSidebarSectionOrder(result));
|
||||
const current = host.readState().groupSettings;
|
||||
const targetExists = current.some((group) => group.name === to);
|
||||
const renamedDefaults = current.flatMap((group) =>
|
||||
group.name === from ? (targetExists ? [] : [{ ...group, name: to }]) : [group],
|
||||
);
|
||||
publishPathFreeMutation(
|
||||
mergeSessionGroupDefaults(readSessionCustomGroups(result), { defaults: renamedDefaults }),
|
||||
readSidebarSectionOrder(result),
|
||||
);
|
||||
// Mutation response commits before a background member-row reconciliation.
|
||||
void host.refreshRows();
|
||||
return "completed";
|
||||
@@ -207,7 +327,12 @@ export function createSessionGroupCatalog(host: SessionGroupCatalogHost) {
|
||||
if (!host.connection.isCurrent(scope)) {
|
||||
return "stale";
|
||||
}
|
||||
publishCatalog(readSessionCustomGroupNames(result), readSidebarSectionOrder(result));
|
||||
publishPathFreeMutation(
|
||||
mergeSessionGroupDefaults(readSessionCustomGroups(result), {
|
||||
defaults: host.readState().groupSettings,
|
||||
}),
|
||||
readSidebarSectionOrder(result),
|
||||
);
|
||||
void host.refreshRows();
|
||||
return "completed";
|
||||
} catch (error) {
|
||||
@@ -215,5 +340,44 @@ export function createSessionGroupCatalog(host: SessionGroupCatalogHost) {
|
||||
}
|
||||
};
|
||||
|
||||
return { delete: remove, dispose: invalidate, invalidate, load, put, rename };
|
||||
const update = async (
|
||||
name: string,
|
||||
defaults: { cwd: string | null; worktree: boolean },
|
||||
): Promise<SessionGroupMutationResult> => {
|
||||
const scope = host.connection.capture();
|
||||
if (!scope) {
|
||||
return "stale";
|
||||
}
|
||||
try {
|
||||
const result = await scope.client.request("sessions.groups.update", { name, ...defaults });
|
||||
if (!host.connection.isCurrent(scope)) {
|
||||
return "stale";
|
||||
}
|
||||
const state = host.readState();
|
||||
const pathFreeGroups = state.groupSettings.map(({ name: groupName, position }) => ({
|
||||
name: groupName,
|
||||
position,
|
||||
}));
|
||||
publishCatalog(
|
||||
mergeSessionGroupDefaults(pathFreeGroups, result),
|
||||
state.sectionOrder,
|
||||
"ready",
|
||||
);
|
||||
return "completed";
|
||||
} catch (error) {
|
||||
return finishMutationFailure(host.connection.isCurrent(scope), error);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
delete: remove,
|
||||
dispose,
|
||||
generation: () => catalogGeneration,
|
||||
invalidate,
|
||||
load,
|
||||
put,
|
||||
rename,
|
||||
status: () => defaultsStatus,
|
||||
update,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createDeferred } from "../../../../test/helpers/promise.js";
|
||||
import type { GatewayBrowserClient, GatewayHelloOk } from "../../api/gateway.ts";
|
||||
import { createStorageMock } from "../../test-helpers/storage.ts";
|
||||
import { createSessionCapability } from "./index.ts";
|
||||
@@ -15,7 +16,9 @@ function createGateway(request: ReturnType<typeof vi.fn>, scopes: string[]): Ses
|
||||
assistantAgentId: "main",
|
||||
hello: {
|
||||
auth: { role: "operator", scopes },
|
||||
features: { methods: ["sessions.groups.list", "sessions.groups.put"] },
|
||||
features: {
|
||||
methods: ["sessions.groups.list", "sessions.groups.defaults", "sessions.groups.put"],
|
||||
},
|
||||
} as GatewayHelloOk,
|
||||
},
|
||||
subscribe: () => () => undefined,
|
||||
@@ -23,6 +26,30 @@ function createGateway(request: ReturnType<typeof vi.fn>, scopes: string[]): Ses
|
||||
};
|
||||
}
|
||||
|
||||
function createGatewayHarness(request: ReturnType<typeof vi.fn>, scopes: string[]) {
|
||||
const gateway = createGateway(request, scopes);
|
||||
let snapshot = gateway.snapshot;
|
||||
const listeners = new Set<(next: SessionGateway["snapshot"]) => void>();
|
||||
return {
|
||||
gateway: {
|
||||
get snapshot() {
|
||||
return snapshot;
|
||||
},
|
||||
subscribe(listener: (next: SessionGateway["snapshot"]) => void) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
subscribeEvents: () => () => undefined,
|
||||
} satisfies SessionGateway,
|
||||
publish(connected: boolean) {
|
||||
snapshot = { ...snapshot, phase: connected ? "connected" : "stopped" };
|
||||
for (const listener of listeners) {
|
||||
listener(snapshot);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
@@ -59,6 +86,9 @@ describe("legacy session group migration", () => {
|
||||
if (method === "sessions.groups.put") {
|
||||
return { groups: [{ name: "Research" }] };
|
||||
}
|
||||
if (method === "sessions.groups.defaults") {
|
||||
return { defaults: [{ name: "Research" }] };
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
});
|
||||
const sessions = createSessionCapability(createGateway(request, ["operator.write"]));
|
||||
@@ -71,3 +101,192 @@ describe("legacy session group migration", () => {
|
||||
sessions.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("session group catalog loading", () => {
|
||||
it("clears cached cwd when a defaults update omits the removed value", async () => {
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "sessions.groups.list") {
|
||||
return { groups: [{ name: "Client", position: 0 }] };
|
||||
}
|
||||
if (method === "sessions.groups.defaults") {
|
||||
return { defaults: [{ name: "Client", cwd: "/repos/client", worktree: true }] };
|
||||
}
|
||||
if (method === "sessions.groups.update") {
|
||||
return { defaults: [{ name: "Client", worktree: false }] };
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
});
|
||||
const gateway = createGateway(request, ["operator.write"]);
|
||||
gateway.snapshot.hello = {
|
||||
...gateway.snapshot.hello,
|
||||
features: {
|
||||
methods: ["sessions.groups.list", "sessions.groups.defaults", "sessions.groups.update"],
|
||||
},
|
||||
} as GatewayHelloOk;
|
||||
const sessions = createSessionCapability(gateway);
|
||||
|
||||
await sessions.groupsLoad();
|
||||
expect(sessions.state.groupSettings).toEqual([
|
||||
{ name: "Client", position: 0, cwd: "/repos/client", worktree: true },
|
||||
]);
|
||||
|
||||
await expect(sessions.groupsUpdate("Client", { cwd: null, worktree: false })).resolves.toBe(
|
||||
"completed",
|
||||
);
|
||||
expect(sessions.state.groupSettings).toEqual([
|
||||
{ name: "Client", position: 0, worktree: false },
|
||||
]);
|
||||
sessions.dispose();
|
||||
});
|
||||
|
||||
it("keeps a loaded path-free catalog when defaults are unavailable", async () => {
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "sessions.groups.list") {
|
||||
return { groups: [{ name: "Client", position: 0 }] };
|
||||
}
|
||||
if (method === "sessions.groups.defaults") {
|
||||
throw new Error("defaults unavailable");
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
});
|
||||
const sessions = createSessionCapability(createGateway(request, ["operator.write"]));
|
||||
|
||||
await expect(sessions.groupsLoad()).resolves.toBeNull();
|
||||
|
||||
expect(sessions.state.groups).toEqual(["Client"]);
|
||||
expect(sessions.state.groupSettings).toEqual([{ name: "Client", position: 0 }]);
|
||||
expect(sessions.groupsStatus()).toBe("unavailable");
|
||||
sessions.dispose();
|
||||
});
|
||||
|
||||
it("keeps path-free mutations pending until fresh defaults arrive", async () => {
|
||||
const refreshedDefaults = createDeferred<{
|
||||
defaults: Array<{ name: string; cwd: string; worktree: boolean }>;
|
||||
}>();
|
||||
let defaultsCalls = 0;
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "sessions.groups.list") {
|
||||
return { groups: [{ name: "Client", position: 0 }] };
|
||||
}
|
||||
if (method === "sessions.groups.defaults") {
|
||||
defaultsCalls += 1;
|
||||
if (defaultsCalls === 1) {
|
||||
throw new Error("defaults unavailable");
|
||||
}
|
||||
return await refreshedDefaults.promise;
|
||||
}
|
||||
if (method === "sessions.groups.put") {
|
||||
return { groups: [{ name: "Client", position: 0 }] };
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
});
|
||||
const sessions = createSessionCapability(createGateway(request, ["operator.write"]));
|
||||
|
||||
await expect(sessions.groupsLoad()).resolves.toBeNull();
|
||||
expect(sessions.groupsStatus()).toBe("unavailable");
|
||||
|
||||
await expect(sessions.groupsPut(["Client"])).resolves.toBe("completed");
|
||||
await vi.waitFor(() => expect(defaultsCalls).toBe(2));
|
||||
expect(sessions.state.groups).toEqual(["Client"]);
|
||||
expect(sessions.groupsStatus()).toBe("loading");
|
||||
|
||||
refreshedDefaults.resolve({
|
||||
defaults: [{ name: "Client", cwd: "/repos/client", worktree: true }],
|
||||
});
|
||||
await vi.waitFor(() => expect(sessions.groupsStatus()).toBe("ready"));
|
||||
expect(sessions.state.groupSettings).toEqual([
|
||||
{ name: "Client", position: 0, cwd: "/repos/client", worktree: true },
|
||||
]);
|
||||
sessions.dispose();
|
||||
});
|
||||
|
||||
it("joins an in-flight load so route consumers see current defaults", async () => {
|
||||
const defaults = createDeferred<{
|
||||
defaults: Array<{ name: string; cwd: string; worktree: boolean }>;
|
||||
}>();
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "sessions.groups.list") {
|
||||
return { groups: [{ name: "Client", position: 0 }] };
|
||||
}
|
||||
if (method === "sessions.groups.defaults") {
|
||||
return await defaults.promise;
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
});
|
||||
const sessions = createSessionCapability(createGateway(request, ["operator.write"]));
|
||||
|
||||
const backgroundLoad = sessions.groupsLoad();
|
||||
await vi.waitFor(() => expect(request).toHaveBeenCalledWith("sessions.groups.defaults", {}));
|
||||
let joined = false;
|
||||
const routeLoad = sessions.groupsLoad().then(() => {
|
||||
joined = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(joined).toBe(false);
|
||||
|
||||
defaults.resolve({
|
||||
defaults: [{ name: "Client", cwd: "/repos/client", worktree: true }],
|
||||
});
|
||||
await Promise.all([backgroundLoad, routeLoad]);
|
||||
expect(sessions.state.groupSettings).toEqual([
|
||||
{ name: "Client", position: 0, cwd: "/repos/client", worktree: true },
|
||||
]);
|
||||
sessions.dispose();
|
||||
});
|
||||
|
||||
it("returns only defaults owned by the current same-client connection", async () => {
|
||||
const stale = createDeferred<{
|
||||
defaults: Array<{ name: string; cwd: string; worktree: boolean }>;
|
||||
}>();
|
||||
const current = createDeferred<{
|
||||
defaults: Array<{ name: string; cwd: string; worktree: boolean }>;
|
||||
}>();
|
||||
let calls = 0;
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "sessions.subscribe") {
|
||||
return { subscribed: true };
|
||||
}
|
||||
if (method === "sessions.list") {
|
||||
return {
|
||||
ts: 1,
|
||||
path: "",
|
||||
count: 0,
|
||||
defaults: { modelProvider: null, model: null, contextTokens: null },
|
||||
sessions: [],
|
||||
};
|
||||
}
|
||||
if (method === "sessions.groups.list") {
|
||||
return { groups: [{ name: "Client", position: 0 }] };
|
||||
}
|
||||
if (method === "sessions.groups.defaults") {
|
||||
calls += 1;
|
||||
return await (calls === 1 ? stale.promise : current.promise);
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
});
|
||||
const harness = createGatewayHarness(request, ["operator.write"]);
|
||||
const sessions = createSessionCapability(harness.gateway);
|
||||
|
||||
const staleLoad = sessions.groupsLoad();
|
||||
await vi.waitFor(() => expect(calls).toBe(1));
|
||||
harness.publish(false);
|
||||
harness.publish(true);
|
||||
const currentLoad = sessions.groupsLoad();
|
||||
await vi.waitFor(() => expect(calls).toBe(2));
|
||||
|
||||
stale.resolve({
|
||||
defaults: [{ name: "Client", cwd: "/gateway-a", worktree: true }],
|
||||
});
|
||||
await expect(staleLoad).resolves.toBeNull();
|
||||
current.resolve({
|
||||
defaults: [{ name: "Client", cwd: "/gateway-b", worktree: false }],
|
||||
});
|
||||
await expect(currentLoad).resolves.toEqual([
|
||||
{ name: "Client", position: 0, cwd: "/gateway-b", worktree: false },
|
||||
]);
|
||||
expect(sessions.state.groupSettings).toEqual([
|
||||
{ name: "Client", position: 0, cwd: "/gateway-b", worktree: false },
|
||||
]);
|
||||
sessions.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -283,6 +283,7 @@ export function createSessionRosterRefresh(host: SessionRosterRefreshHost) {
|
||||
error,
|
||||
deletedSessions: [],
|
||||
groups: state.groups,
|
||||
groupSettings: state.groupSettings,
|
||||
sectionOrder: state.sectionOrder,
|
||||
},
|
||||
error ? "session-observer" : undefined,
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import type { SessionCapability } from "../../lib/sessions/session-capability.ts";
|
||||
import {
|
||||
allowsSelectedAgent,
|
||||
GroupRouteRevalidation,
|
||||
resolveAgentId,
|
||||
resolveCreateTarget,
|
||||
routeKey,
|
||||
routeKeyFromSearch,
|
||||
} from "./catalog-target.ts";
|
||||
import type { NewSessionRouteData } from "./location.ts";
|
||||
|
||||
describe("new-session catalog target", () => {
|
||||
const agents = [{ id: "main" }, { id: "research" }];
|
||||
@@ -139,4 +142,38 @@ describe("new-session catalog target", () => {
|
||||
expect(resolveAgentId(location, [], "main")).toBe("main");
|
||||
expect(resolveAgentId(location, [{ id: "roboclaw" }], "roboclaw")).toBe("roboclaw");
|
||||
});
|
||||
|
||||
it("revalidates when a missing group reappears with empty defaults", async () => {
|
||||
let data: NewSessionRouteData = {
|
||||
agentId: "main",
|
||||
requestedAgentId: "main",
|
||||
catalogId: "",
|
||||
model: "",
|
||||
catalogLabel: "",
|
||||
startTerminal: false,
|
||||
group: "Client",
|
||||
groupStatus: "resolved",
|
||||
groupCwd: "",
|
||||
groupWorktree: false,
|
||||
groupCatalogGeneration: 1,
|
||||
groupDefaultsStatus: "ready",
|
||||
};
|
||||
const state = { groupSettings: [] as Array<{ name: string; position: number }> };
|
||||
const sessions = {
|
||||
state,
|
||||
groupsGeneration: () => 1,
|
||||
groupsStatus: () => "ready",
|
||||
} as unknown as SessionCapability;
|
||||
const revalidate = vi.fn(async () => {
|
||||
data = { ...data, groupStatus: "missing" };
|
||||
});
|
||||
const coordinator = new GroupRouteRevalidation(() => data, revalidate);
|
||||
|
||||
coordinator.synchronize(sessions);
|
||||
await vi.waitFor(() => expect(revalidate).toHaveBeenCalledTimes(1));
|
||||
state.groupSettings = [{ name: "Client", position: 0 }];
|
||||
coordinator.synchronize(sessions);
|
||||
|
||||
await vi.waitFor(() => expect(revalidate).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,11 +3,12 @@ import type { SessionsCatalogListResult } from "../../../../packages/gateway-pro
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import { icons } from "../../components/icons.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import type { SessionCapability } from "../../lib/sessions/session-capability.ts";
|
||||
import { normalizeAgentId } from "../../lib/sessions/session-key.ts";
|
||||
import { newSessionLocationFromSearch, type NewSessionRouteData } from "./location.ts";
|
||||
|
||||
function draftRouteKey(requestedAgentId: string, catalogId: string): string {
|
||||
return JSON.stringify([requestedAgentId, catalogId]);
|
||||
function draftRouteKey(requestedAgentId: string, catalogId: string, group: string): string {
|
||||
return JSON.stringify([requestedAgentId, catalogId, group]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -17,22 +18,136 @@ function draftRouteKey(requestedAgentId: string, catalogId: string): string {
|
||||
* would make that fill-in look like a navigation and discard the draft.
|
||||
*/
|
||||
export function routeKey(data?: NewSessionRouteData): string {
|
||||
return draftRouteKey(data?.requestedAgentId ?? "", data?.catalogId ?? "");
|
||||
return draftRouteKey(data?.requestedAgentId ?? "", data?.catalogId ?? "", data?.group ?? "");
|
||||
}
|
||||
|
||||
export function routeKeyFromSearch(search: string): string {
|
||||
const location = newSessionLocationFromSearch(search);
|
||||
return draftRouteKey(location.agentId, location.catalogId);
|
||||
return draftRouteKey(location.agentId, location.catalogId, location.group ?? "");
|
||||
}
|
||||
|
||||
export function isTarget(data?: NewSessionRouteData): boolean {
|
||||
return Boolean(data?.catalogId);
|
||||
}
|
||||
|
||||
export function isResolvedTarget(data?: NewSessionRouteData): boolean {
|
||||
function isResolvedTarget(data?: NewSessionRouteData): boolean {
|
||||
return Boolean(data?.catalogId && data.model && data.catalogLabel);
|
||||
}
|
||||
|
||||
function isPendingRouteTarget(data?: NewSessionRouteData): boolean {
|
||||
return (
|
||||
(isTarget(data) && !isResolvedTarget(data)) ||
|
||||
Boolean(data?.group && data.groupStatus !== "resolved")
|
||||
);
|
||||
}
|
||||
|
||||
export function groupDefaultsKey(data?: NewSessionRouteData): string {
|
||||
return JSON.stringify([
|
||||
data?.groupStatus ?? "",
|
||||
data?.groupCwd ?? "",
|
||||
data?.groupWorktree === true,
|
||||
data?.groupCatalogGeneration ?? -1,
|
||||
data?.groupDefaultsStatus ?? "idle",
|
||||
]);
|
||||
}
|
||||
|
||||
function groupRouteNeedsRevalidation(
|
||||
data: NewSessionRouteData | undefined,
|
||||
sessions: SessionCapability,
|
||||
): boolean {
|
||||
const groupName = data?.group?.trim();
|
||||
if (!groupName) {
|
||||
return false;
|
||||
}
|
||||
const generation = sessions.groupsGeneration();
|
||||
const status = sessions.groupsStatus();
|
||||
if (data?.groupCatalogGeneration !== generation || data.groupDefaultsStatus !== status) {
|
||||
return true;
|
||||
}
|
||||
if (status !== "ready") {
|
||||
return false;
|
||||
}
|
||||
const current = sessions.state.groupSettings.find((group) => group.name === groupName);
|
||||
return current
|
||||
? data.groupStatus !== "resolved" ||
|
||||
(data.groupCwd ?? "") !== (current.cwd ?? "") ||
|
||||
data.groupWorktree !== (current.worktree === true)
|
||||
: data.groupStatus === "resolved";
|
||||
}
|
||||
|
||||
function groupRouteCatalogKey(
|
||||
data: NewSessionRouteData | undefined,
|
||||
sessions: SessionCapability,
|
||||
): string {
|
||||
const current = sessions.state.groupSettings.find((group) => group.name === data?.group);
|
||||
return JSON.stringify([
|
||||
data?.group ?? "",
|
||||
sessions.groupsGeneration(),
|
||||
sessions.groupsStatus(),
|
||||
Boolean(current),
|
||||
current?.cwd ?? "",
|
||||
current?.worktree === true,
|
||||
]);
|
||||
}
|
||||
|
||||
export function isGroupRoutePending(
|
||||
data: NewSessionRouteData | undefined,
|
||||
sessions: SessionCapability | undefined,
|
||||
): boolean {
|
||||
return Boolean(data?.group && (!sessions || groupRouteNeedsRevalidation(data, sessions)));
|
||||
}
|
||||
|
||||
export function isRoutePending(
|
||||
data: NewSessionRouteData | undefined,
|
||||
sessions: SessionCapability | undefined,
|
||||
): boolean {
|
||||
return isPendingRouteTarget(data) || isGroupRoutePending(data, sessions);
|
||||
}
|
||||
|
||||
export function resolvedGroupName(
|
||||
data: NewSessionRouteData | undefined,
|
||||
sessions: SessionCapability | undefined,
|
||||
): string | undefined {
|
||||
return data?.groupStatus === "resolved" && !isGroupRoutePending(data, sessions)
|
||||
? data.group
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export class GroupRouteRevalidation {
|
||||
private pending: Promise<unknown> | null = null;
|
||||
private lastKey = "";
|
||||
|
||||
constructor(
|
||||
private readonly readData: () => NewSessionRouteData | undefined,
|
||||
private readonly revalidate: () => Promise<unknown> | undefined,
|
||||
) {}
|
||||
|
||||
synchronize(sessions: SessionCapability) {
|
||||
if (this.pending) {
|
||||
return;
|
||||
}
|
||||
const data = this.readData();
|
||||
const key = groupRouteCatalogKey(data, sessions);
|
||||
if (this.lastKey === key || !groupRouteNeedsRevalidation(data, sessions)) {
|
||||
return;
|
||||
}
|
||||
const pending = this.revalidate();
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
this.lastKey = key;
|
||||
this.pending = pending;
|
||||
void pending
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
if (this.pending === pending) {
|
||||
this.pending = null;
|
||||
this.synchronize(sessions);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveAgentId(
|
||||
data: Pick<NewSessionRouteData, "agentId" | "catalogId"> | undefined,
|
||||
availableAgents: readonly { id: string }[],
|
||||
@@ -101,8 +216,9 @@ export function renderBar(params: {
|
||||
placeSelect: unknown;
|
||||
retrying: boolean;
|
||||
onRetry: () => void;
|
||||
groupPending?: boolean;
|
||||
}) {
|
||||
const pending = isTarget(params.data) && !isResolvedTarget(params.data);
|
||||
const pending = isPendingRouteTarget(params.data) || params.groupPending === true;
|
||||
return html`
|
||||
<div class="new-session-page__triggers">
|
||||
${renderTarget(params.data)} ${isTarget(params.data) ? nothing : params.agentSelect}
|
||||
|
||||
@@ -393,6 +393,7 @@ describe("cloud session recovery", () => {
|
||||
key: recovery.sessionKey,
|
||||
agentId: "cloud",
|
||||
message: "" as const,
|
||||
category: "Client work",
|
||||
thinkingLevel: "high",
|
||||
worktree: true as const,
|
||||
},
|
||||
|
||||
@@ -155,6 +155,25 @@ describe("buildDraftSessionCreateParams", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("assigns a session created from a custom group", () => {
|
||||
expect(
|
||||
buildDraftSessionCreateParams({
|
||||
agentId: "main",
|
||||
message: "start grouped work",
|
||||
worktree: true,
|
||||
cwd: "/repos/client",
|
||||
workspace: "/workspace",
|
||||
category: " Client work ",
|
||||
}),
|
||||
).toEqual({
|
||||
agentId: "main",
|
||||
message: "start grouped work",
|
||||
category: "Client work",
|
||||
cwd: "/repos/client",
|
||||
worktree: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("sends a custom Gateway folder without requiring a worktree", () => {
|
||||
expect(
|
||||
buildDraftSessionCreateParams({
|
||||
|
||||
@@ -40,11 +40,13 @@ export function buildDraftSessionCreateParams(draft: {
|
||||
workspace?: string;
|
||||
execNode?: string;
|
||||
catalogId?: string;
|
||||
category?: string;
|
||||
}): Record<string, unknown> {
|
||||
const cwd = normalizeOptionalString(draft.cwd);
|
||||
const workspace = normalizeOptionalString(draft.workspace);
|
||||
const execNode = normalizeOptionalString(draft.execNode);
|
||||
const catalogId = normalizeOptionalString(draft.catalogId);
|
||||
const category = normalizeOptionalString(draft.category);
|
||||
const model = normalizeOptionalString(draft.model);
|
||||
const thinkingLevel = normalizeOptionalString(draft.thinkingLevel);
|
||||
const projectId = normalizeOptionalString(draft.projectId);
|
||||
@@ -57,6 +59,7 @@ export function buildDraftSessionCreateParams(draft: {
|
||||
...(draft.visibility === "draft" ? { visibility: "draft" } : {}),
|
||||
...(draft.attachments?.length ? { attachments: draft.attachments } : {}),
|
||||
...(catalogId ? { catalogId } : {}),
|
||||
...(category ? { category } : {}),
|
||||
...(!catalogId && model ? { model } : {}),
|
||||
...(!catalogId && thinkingLevel ? { thinkingLevel } : {}),
|
||||
...(projectId ? { projectId } : {}),
|
||||
|
||||
@@ -188,6 +188,16 @@ export class DraftGatewayState {
|
||||
return this.preferenceModeValue === "loading";
|
||||
}
|
||||
|
||||
resolvedGroupCategory(): string | undefined {
|
||||
const snapshot = this.read();
|
||||
return isGatewayMethodAdvertised(
|
||||
snapshot.context?.gateway.snapshot ?? {},
|
||||
"sessions.groups.defaults",
|
||||
) === true
|
||||
? catalog.resolvedGroupName(snapshot.data, snapshot.context?.sessions)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
refreshCloudProfiles() {
|
||||
return this.cloudProfileTask.run();
|
||||
}
|
||||
@@ -269,11 +279,16 @@ export class DraftGatewayState {
|
||||
}
|
||||
|
||||
retryPendingCatalogTarget() {
|
||||
const { data } = this.read();
|
||||
const { context, data } = this.read();
|
||||
if (this.catalogRetryingValue) {
|
||||
return;
|
||||
}
|
||||
if (!this.gatewayConnectedValue || !catalog.isTarget(data) || catalog.isResolvedTarget(data)) {
|
||||
if (data?.group && context?.sessions.groupsStatus() === "loading") {
|
||||
globalThis.clearTimeout(this.catalogRetryTimer);
|
||||
this.catalogRetryTimer = undefined;
|
||||
return;
|
||||
}
|
||||
if (!this.gatewayConnectedValue || !catalog.isRoutePending(data, context?.sessions)) {
|
||||
globalThis.clearTimeout(this.catalogRetryTimer);
|
||||
this.catalogRetryTimer = undefined;
|
||||
this.catalogRetryScope = "";
|
||||
@@ -298,11 +313,14 @@ export class DraftGatewayState {
|
||||
if (
|
||||
this.catalogRetryScope !== retryScope ||
|
||||
!this.gatewayConnectedValue ||
|
||||
!catalog.isTarget(current.data) ||
|
||||
catalog.isResolvedTarget(current.data)
|
||||
(current.data?.group && current.context?.sessions.groupsStatus() === "loading") ||
|
||||
!catalog.isRoutePending(current.data, current.context?.sessions)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (current.data?.group) {
|
||||
current.context?.sessions.groupsInvalidate();
|
||||
}
|
||||
const revalidation = current.context?.revalidate("new-session");
|
||||
if (!revalidation) {
|
||||
return;
|
||||
@@ -319,11 +337,14 @@ export class DraftGatewayState {
|
||||
if (
|
||||
this.catalogRetryingValue ||
|
||||
!this.gatewayConnectedValue ||
|
||||
!catalog.isTarget(data) ||
|
||||
catalog.isResolvedTarget(data)
|
||||
(data?.group && context?.sessions.groupsStatus() === "loading") ||
|
||||
!catalog.isRoutePending(data, context?.sessions)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (data?.group) {
|
||||
context?.sessions.groupsInvalidate();
|
||||
}
|
||||
const revalidation = context?.revalidate("new-session");
|
||||
if (!revalidation) {
|
||||
return;
|
||||
@@ -344,7 +365,11 @@ export class DraftGatewayState {
|
||||
|
||||
readPreference(agentId: string): NewSessionPreference | null {
|
||||
const snapshot = this.read();
|
||||
if (catalog.isTarget(snapshot.data) || snapshot.pendingCloud.sessionKey) {
|
||||
if (
|
||||
catalog.isTarget(snapshot.data) ||
|
||||
snapshot.data?.group ||
|
||||
snapshot.pendingCloud.sessionKey
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return this.preferenceModeValue === "remote"
|
||||
@@ -354,7 +379,11 @@ export class DraftGatewayState {
|
||||
|
||||
persistPreference(agentIdValue: string, workspace: string, patch: NewSessionPreference) {
|
||||
const snapshot = this.read();
|
||||
if (catalog.isTarget(snapshot.data) || snapshot.pendingCloud.sessionKey) {
|
||||
if (
|
||||
catalog.isTarget(snapshot.data) ||
|
||||
snapshot.data?.group ||
|
||||
snapshot.pendingCloud.sessionKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const agentId = normalizeAgentId(agentIdValue);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { ReactiveController, ReactiveControllerHost } from "lit";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ApplicationContext } from "../../app/context.ts";
|
||||
import { DraftGatewayState } from "./draft-gateway-state.ts";
|
||||
import { DraftPlaceBrowser } from "./draft-place-browser.ts";
|
||||
import type { NewSessionRouteData } from "./location.ts";
|
||||
import { loadNewSessionPreference, patchNewSessionPreference } from "./preferences.ts";
|
||||
|
||||
class ControllerHost implements ReactiveControllerHost {
|
||||
readonly updateComplete = Promise.resolve(true);
|
||||
@@ -11,7 +13,11 @@ class ControllerHost implements ReactiveControllerHost {
|
||||
requestUpdate() {}
|
||||
}
|
||||
|
||||
function createBrowser(request: (method: string) => Promise<unknown>) {
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
function createBrowser(request: (method: string) => Promise<unknown>, data?: NewSessionRouteData) {
|
||||
const host = new ControllerHost();
|
||||
const client = { request, recoveryScope: "principal-a", recoveryScopeReady: true };
|
||||
const context = {
|
||||
@@ -26,12 +32,19 @@ function createBrowser(request: (method: string) => Promise<unknown>) {
|
||||
},
|
||||
},
|
||||
},
|
||||
sessions: {
|
||||
state: {
|
||||
groupSettings: [{ name: "Client", cwd: "/workspace/client", worktree: false }],
|
||||
},
|
||||
groupsGeneration: () => 1,
|
||||
groupsStatus: () => "ready",
|
||||
},
|
||||
} as unknown as ApplicationContext;
|
||||
const gateway = new DraftGatewayState(
|
||||
host,
|
||||
() => ({
|
||||
context,
|
||||
data: undefined,
|
||||
data,
|
||||
isConnected: true,
|
||||
isAdmin: false,
|
||||
canStartAsDraft: false,
|
||||
@@ -73,7 +86,7 @@ function createBrowser(request: (method: string) => Promise<unknown>) {
|
||||
body: () => null,
|
||||
},
|
||||
);
|
||||
return browser;
|
||||
return { browser, gateway };
|
||||
}
|
||||
|
||||
describe("DraftPlaceBrowser", () => {
|
||||
@@ -86,7 +99,7 @@ describe("DraftPlaceBrowser", () => {
|
||||
},
|
||||
],
|
||||
])("keeps roster recents when %s", async (_label, request) => {
|
||||
const browser = createBrowser(request);
|
||||
const { browser } = createBrowser(request);
|
||||
|
||||
await browser.refreshProjects();
|
||||
|
||||
@@ -107,3 +120,36 @@ describe("DraftPlaceBrowser", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DraftGatewayState", () => {
|
||||
it("keeps group route defaults isolated from ordinary New Session preferences", () => {
|
||||
patchNewSessionPreference("ws://gateway.example", "main", {
|
||||
folder: "/workspace/ordinary",
|
||||
worktree: true,
|
||||
});
|
||||
const { gateway } = createBrowser(async () => ({}), {
|
||||
agentId: "main",
|
||||
requestedAgentId: "main",
|
||||
catalogId: "",
|
||||
group: "Client",
|
||||
groupStatus: "resolved",
|
||||
groupCwd: "/workspace/client",
|
||||
groupWorktree: false,
|
||||
groupCatalogGeneration: 1,
|
||||
groupDefaultsStatus: "ready",
|
||||
model: "",
|
||||
catalogLabel: "",
|
||||
startTerminal: false,
|
||||
});
|
||||
|
||||
expect(gateway.readPreference("main")).toBeNull();
|
||||
gateway.persistPreference("main", "/workspace", {
|
||||
folder: "/workspace/client",
|
||||
worktree: false,
|
||||
});
|
||||
expect(loadNewSessionPreference("ws://gateway.example", "main")).toEqual({
|
||||
folder: "/workspace/ordinary",
|
||||
worktree: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -131,6 +131,22 @@ export class DraftPlaceState {
|
||||
return this.repositoryState.preferenceReady;
|
||||
}
|
||||
|
||||
canAdoptGroupDefaults(): boolean {
|
||||
return (
|
||||
!this.folderSelectedByUser &&
|
||||
!this.whereSelectedByUser &&
|
||||
!this.projectSelectedByUser &&
|
||||
!this.repositoryState.hasUserSelection
|
||||
);
|
||||
}
|
||||
|
||||
adoptGroupDefaults() {
|
||||
if (this.read().data?.groupStatus !== "resolved" || !this.canAdoptGroupDefaults()) {
|
||||
return;
|
||||
}
|
||||
this.adoptAgentDefaults({ preserveSelectedAgent: true });
|
||||
}
|
||||
|
||||
setAgentsHydrated(value: boolean) {
|
||||
this.agentsHydratedValue = value;
|
||||
}
|
||||
@@ -242,16 +258,30 @@ export class DraftPlaceState {
|
||||
storedFolder === preference?.workspace &&
|
||||
preference.workspace !== workspace;
|
||||
const storedFolderUsable = Boolean(storedFolder) && !storedWorkspaceMoved;
|
||||
this.folderValue = storedFolderUsable ? storedFolder : workspace;
|
||||
const groupTarget = Boolean(snapshot.data?.group);
|
||||
const groupFolder = snapshot.data?.groupCwd ?? "";
|
||||
const groupWorktree = snapshot.data?.groupWorktree === true;
|
||||
this.folderValue = groupTarget
|
||||
? groupFolder || workspace
|
||||
: storedFolderUsable
|
||||
? storedFolder
|
||||
: workspace;
|
||||
this.folderGatewayApproved = false;
|
||||
this.folderSelectedByUser = false;
|
||||
this.repositoryState.adoptPreference(preference);
|
||||
const preferredWhere = preference?.where ?? { kind: "local" };
|
||||
this.repositoryState.adoptPreference(groupTarget ? { worktree: groupWorktree } : preference);
|
||||
if (groupTarget) {
|
||||
// Group defaults own the initial local/worktree choice. Repository
|
||||
// discovery still rejects worktrees when the selected folder is not Git.
|
||||
this.repositoryState.forceWorktree(groupWorktree);
|
||||
}
|
||||
const preferredWhere = groupTarget
|
||||
? { kind: "local" as const }
|
||||
: (preference?.where ?? { kind: "local" as const });
|
||||
this.preferredWhereRestore = preferredWhere.kind === "local" ? null : preferredWhere;
|
||||
this.preferredProjectRestore = preference?.projectId ?? "";
|
||||
this.preferredProjectRestore = groupTarget ? "" : (preference?.projectId ?? "");
|
||||
this.whereSelectedByUser = false;
|
||||
this.projectSelectedByUser = false;
|
||||
if (storedWorkspaceMoved) {
|
||||
if (storedWorkspaceMoved && !groupTarget) {
|
||||
this.persistPreference({ folder: workspace });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ export class DraftRepositoryController {
|
||||
private preferredWorktreeRestore = false;
|
||||
private preferredBaseRefRestore = "";
|
||||
private worktreeSelectedByUser = false;
|
||||
private detailsSelectedByUser = false;
|
||||
|
||||
constructor(
|
||||
private readonly read: () => DraftRepositorySnapshot,
|
||||
@@ -58,11 +59,16 @@ export class DraftRepositoryController {
|
||||
return !this.preferredWorktreeRestore;
|
||||
}
|
||||
|
||||
get hasUserSelection(): boolean {
|
||||
return this.worktreeSelectedByUser || this.detailsSelectedByUser;
|
||||
}
|
||||
|
||||
adoptPreference(preference: NewSessionPreference | null) {
|
||||
this.preferredWorktreeRestore = preference?.worktree === true;
|
||||
this.preferredBaseRefRestore = preference?.baseRef ?? "";
|
||||
this.worktreeNameValue = preference?.worktreeName ?? "";
|
||||
this.worktreeSelectedByUser = false;
|
||||
this.detailsSelectedByUser = false;
|
||||
}
|
||||
|
||||
reset() {
|
||||
@@ -75,6 +81,7 @@ export class DraftRepositoryController {
|
||||
this.preferredWorktreeRestore = false;
|
||||
this.preferredBaseRefRestore = "";
|
||||
this.worktreeSelectedByUser = false;
|
||||
this.detailsSelectedByUser = false;
|
||||
}
|
||||
|
||||
invalidate() {
|
||||
@@ -125,6 +132,7 @@ export class DraftRepositoryController {
|
||||
this.baseRefEditGeneration += 1;
|
||||
this.baseRefValue = baseRef;
|
||||
this.preferredBaseRefRestore = "";
|
||||
this.detailsSelectedByUser = true;
|
||||
this.callbacks.persistPreference({ baseRef });
|
||||
this.callbacks.requestUpdate();
|
||||
}
|
||||
@@ -134,6 +142,7 @@ export class DraftRepositoryController {
|
||||
return;
|
||||
}
|
||||
this.worktreeNameValue = worktreeName;
|
||||
this.detailsSelectedByUser = true;
|
||||
this.callbacks.persistPreference({ worktreeName });
|
||||
this.callbacks.requestUpdate();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ApplicationContext } from "../../app/context.ts";
|
||||
import type { NewSessionRouteData } from "./location.ts";
|
||||
|
||||
export type DraftSubmissionSnapshot = Readonly<{
|
||||
context: ApplicationContext | undefined;
|
||||
data: NewSessionRouteData | undefined;
|
||||
isConnected: boolean;
|
||||
}>;
|
||||
|
||||
export type DraftSubmissionCallbacks = {
|
||||
requestUpdate: () => void;
|
||||
closeTransientUi: () => void;
|
||||
};
|
||||
@@ -32,20 +32,12 @@ import {
|
||||
} from "./create-params.ts";
|
||||
import type { DraftGatewayState } from "./draft-gateway-state.ts";
|
||||
import type { DraftPlaceState } from "./draft-place-state.ts";
|
||||
import type { NewSessionRouteData } from "./location.ts";
|
||||
import type {
|
||||
DraftSubmissionCallbacks,
|
||||
DraftSubmissionSnapshot,
|
||||
} from "./draft-submission-contract.ts";
|
||||
import { retainRejectedInitialTurn } from "./rejected-initial-turn.ts";
|
||||
|
||||
type DraftSubmissionSnapshot = Readonly<{
|
||||
context: ApplicationContext | undefined;
|
||||
data: NewSessionRouteData | undefined;
|
||||
isConnected: boolean;
|
||||
}>;
|
||||
|
||||
type DraftSubmissionCallbacks = {
|
||||
requestUpdate: () => void;
|
||||
closeTransientUi: () => void;
|
||||
};
|
||||
|
||||
export class DraftSubmissionFlow {
|
||||
private visibilityValue: NewSessionVisibility = "normal";
|
||||
private messageValue = "";
|
||||
@@ -153,6 +145,7 @@ export class DraftSubmissionFlow {
|
||||
visibility?: NewSessionVisibility;
|
||||
} = {},
|
||||
): Record<string, unknown> {
|
||||
const snapshot = this.read();
|
||||
return assembleDraftSessionCreateParams({
|
||||
agentId: this.place.agentId,
|
||||
message: options.message ?? "",
|
||||
@@ -167,7 +160,8 @@ export class DraftSubmissionFlow {
|
||||
cwd: this.place.folder,
|
||||
workspace: this.place.workspacePath(),
|
||||
execNode: this.place.execNode,
|
||||
catalogId: this.read().data?.catalogId,
|
||||
catalogId: snapshot.data?.catalogId,
|
||||
category: this.gateway.resolvedGroupCategory(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -200,6 +194,9 @@ export class DraftSubmissionFlow {
|
||||
}
|
||||
|
||||
submitDisabledReason(): string | undefined {
|
||||
if (catalog.isRoutePending(this.read().data, this.read().context?.sessions)) {
|
||||
return t("newSession.catalogUnavailable");
|
||||
}
|
||||
if (this.place.modelControl.isModelUnavailable(this.place.selectedAgent())) {
|
||||
return `${t("modelSetup.failure.auth")}. ${t("modelSetup.failureGuidance.auth")}`;
|
||||
}
|
||||
@@ -232,6 +229,7 @@ export class DraftSubmissionFlow {
|
||||
this.submittingValue ||
|
||||
this.gateway.preferenceLoading ||
|
||||
this.requiresModelSetup() ||
|
||||
catalog.isRoutePending(this.read().data, this.read().context?.sessions) ||
|
||||
this.place.modelControl.isModelUnavailable(this.place.selectedAgent()) ||
|
||||
this.attachmentDraft.pendingReads > 0 ||
|
||||
(!pendingCloud && this.submissionOutcomeUnknownValue) ||
|
||||
|
||||
@@ -13,6 +13,18 @@ describe("new-session location", () => {
|
||||
).toEqual({
|
||||
agentId: "main/agent",
|
||||
catalogId: "claude",
|
||||
group: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("round-trips a custom group target", () => {
|
||||
const search = newSessionSearch("main", { group: "Client work" });
|
||||
|
||||
expect(search).toBe("?agent=main&group=Client+work");
|
||||
expect(newSessionLocationFromSearch(search)).toEqual({
|
||||
agentId: "main",
|
||||
catalogId: "",
|
||||
group: "Client work",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +33,7 @@ describe("new-session location", () => {
|
||||
expect(newSessionLocationFromSearch("")).toEqual({
|
||||
agentId: "",
|
||||
catalogId: "",
|
||||
group: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,30 +4,42 @@ export type NewSessionRouteData = {
|
||||
/** The agent the URL asked for, which only a navigation can change. */
|
||||
requestedAgentId: string;
|
||||
catalogId: string;
|
||||
group?: string;
|
||||
groupStatus?: "resolved" | "missing" | "unavailable";
|
||||
groupCwd?: string;
|
||||
groupWorktree?: boolean;
|
||||
groupCatalogGeneration?: number;
|
||||
groupDefaultsStatus?: import("../../lib/sessions/session-capability.ts").SessionGroupDefaultsStatus;
|
||||
model: string;
|
||||
catalogLabel: string;
|
||||
startTerminal: boolean;
|
||||
};
|
||||
|
||||
export type NewSessionTarget = { catalogId: string };
|
||||
export type NewSessionTarget =
|
||||
| { catalogId: string; group?: never }
|
||||
| { group: string; catalogId?: never };
|
||||
|
||||
export function newSessionSearch(agentId: string, target?: NewSessionTarget): string {
|
||||
const params = new URLSearchParams();
|
||||
if (agentId) {
|
||||
params.set("agent", agentId);
|
||||
}
|
||||
if (target) {
|
||||
if (target?.catalogId) {
|
||||
params.set("catalog", target.catalogId);
|
||||
}
|
||||
if (target?.group) {
|
||||
params.set("group", target.group);
|
||||
}
|
||||
return params.size > 0 ? `?${params.toString()}` : "";
|
||||
}
|
||||
|
||||
export function newSessionLocationFromSearch(
|
||||
search: string,
|
||||
): Pick<NewSessionRouteData, "agentId" | "catalogId"> {
|
||||
): Pick<NewSessionRouteData, "agentId" | "catalogId" | "group"> {
|
||||
const params = new URLSearchParams(search);
|
||||
return {
|
||||
agentId: params.get("agent")?.trim() ?? "",
|
||||
catalogId: params.get("catalog")?.trim() ?? "",
|
||||
group: params.get("group")?.trim() ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ class NewSessionPage extends OpenClawLightDomElement {
|
||||
private context?: ApplicationContext;
|
||||
|
||||
private openedFor: string | null = null;
|
||||
private openedGroupDefaults = "";
|
||||
private openedAgentId = "";
|
||||
private messageOwnerKey = "";
|
||||
private presenceSignature = "";
|
||||
@@ -61,6 +62,10 @@ class NewSessionPage extends OpenClawLightDomElement {
|
||||
private connectMachineSetup: DevicePairSetup | null = null;
|
||||
private connectMachineRequestId = 0;
|
||||
@state() private imageLightbox: ImageLightboxItem | null = null;
|
||||
private readonly groupRouteRevalidation = new catalog.GroupRouteRevalidation(
|
||||
() => this.data,
|
||||
() => this.context?.revalidate("new-session"),
|
||||
);
|
||||
private readonly gateway: DraftGatewayState;
|
||||
private readonly browser: DraftPlaceBrowser;
|
||||
private readonly place: DraftPlaceState;
|
||||
@@ -193,6 +198,7 @@ class NewSessionPage extends OpenClawLightDomElement {
|
||||
.watch(
|
||||
() => this.context?.sessions,
|
||||
(sessions, notify) => sessions.subscribe(notify),
|
||||
(sessions) => this.groupRouteRevalidation.synchronize(sessions),
|
||||
)
|
||||
.watch(
|
||||
() => this.context?.config,
|
||||
@@ -255,15 +261,21 @@ class NewSessionPage extends OpenClawLightDomElement {
|
||||
? catalog.routeKey(this.data)
|
||||
: catalog.routeKeyFromSearch(window.location.search);
|
||||
const resolvedAgentId = this.data?.agentId ?? "";
|
||||
const groupDefaults = catalog.groupDefaultsKey(this.data);
|
||||
if (this.openedFor !== openKey) {
|
||||
const ownedMessage = this.messageOwnerKey === openKey ? this.submission.message : "";
|
||||
this.openedFor = openKey;
|
||||
this.openedGroupDefaults = groupDefaults;
|
||||
this.openedAgentId = resolvedAgentId;
|
||||
this.place.setAgentsHydrated(agentsReady);
|
||||
this.resetDraft();
|
||||
this.messageOwnerKey = restoreDraft(this.context, this.submission, openKey, ownedMessage);
|
||||
return;
|
||||
}
|
||||
if (this.openedGroupDefaults !== groupDefaults) {
|
||||
this.openedGroupDefaults = groupDefaults;
|
||||
this.place.adoptGroupDefaults();
|
||||
}
|
||||
if (this.openedAgentId !== resolvedAgentId) {
|
||||
this.openedAgentId = resolvedAgentId;
|
||||
this.place.setAgentsHydrated(false);
|
||||
@@ -329,11 +341,15 @@ class NewSessionPage extends OpenClawLightDomElement {
|
||||
|
||||
private renderTargetBar() {
|
||||
const agents = this.place.agents();
|
||||
const sessions = this.context?.sessions;
|
||||
return catalog.renderBar({
|
||||
data: this.data,
|
||||
groupPending: catalog.isGroupRoutePending(this.data, sessions),
|
||||
agentSelect: agents.length > 1 ? this.renderAgentSelect() : nothing,
|
||||
placeSelect: this.renderPlaceChips(),
|
||||
retrying: this.gateway.catalogRetrying,
|
||||
retrying:
|
||||
this.gateway.catalogRetrying ||
|
||||
Boolean(this.data?.group && sessions?.groupsStatus() === "loading"),
|
||||
onRetry: this.gateway.handleCatalogRetry,
|
||||
});
|
||||
}
|
||||
@@ -700,3 +716,6 @@ class NewSessionPage extends OpenClawLightDomElement {
|
||||
if (!customElements.get("openclaw-new-session-page")) {
|
||||
customElements.define("openclaw-new-session-page", NewSessionPage);
|
||||
}
|
||||
|
||||
export const render = (data: unknown) =>
|
||||
html`<openclaw-new-session-page .data=${data}></openclaw-new-session-page>`;
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { ApplicationContext } from "../../app/context.ts";
|
||||
import { listSelectableAgents } from "../../lib/agents/display.ts";
|
||||
import { normalizeAgentId } from "../../lib/sessions/session-key.ts";
|
||||
import { resolveAgentId, resolveCreateTarget } from "./catalog-target.ts";
|
||||
import { newSessionLocationFromSearch, type NewSessionRouteData } from "./location.ts";
|
||||
|
||||
export async function load(
|
||||
context: ApplicationContext,
|
||||
search: string,
|
||||
): Promise<NewSessionRouteData> {
|
||||
const requestedLocation = newSessionLocationFromSearch(search);
|
||||
const requestedAgentId = requestedLocation.agentId.trim();
|
||||
let groupCwd = "";
|
||||
let groupWorktree = false;
|
||||
let groupStatus: NewSessionRouteData["groupStatus"];
|
||||
let groupCatalogGeneration: number | undefined;
|
||||
let groupDefaultsStatus: NewSessionRouteData["groupDefaultsStatus"];
|
||||
if (requestedLocation.group) {
|
||||
const startedGeneration = context.sessions.groupsGeneration();
|
||||
const settings = await context.sessions.groupsLoad();
|
||||
groupCatalogGeneration = context.sessions.groupsGeneration();
|
||||
groupDefaultsStatus = context.sessions.groupsStatus();
|
||||
const currentSettings =
|
||||
startedGeneration === groupCatalogGeneration && groupDefaultsStatus === "ready"
|
||||
? settings
|
||||
: null;
|
||||
const group = currentSettings?.find((candidate) => candidate.name === requestedLocation.group);
|
||||
groupStatus = currentSettings === null ? "unavailable" : group ? "resolved" : "missing";
|
||||
groupCwd = group?.cwd ?? "";
|
||||
groupWorktree = group?.worktree === true;
|
||||
}
|
||||
if (!requestedLocation.catalogId) {
|
||||
return {
|
||||
...requestedLocation,
|
||||
requestedAgentId,
|
||||
groupStatus,
|
||||
groupCwd,
|
||||
groupWorktree,
|
||||
groupCatalogGeneration,
|
||||
groupDefaultsStatus,
|
||||
model: "",
|
||||
catalogLabel: "",
|
||||
startTerminal: false,
|
||||
};
|
||||
}
|
||||
const unresolved = (agentId = ""): NewSessionRouteData => ({
|
||||
...requestedLocation,
|
||||
agentId,
|
||||
requestedAgentId,
|
||||
groupStatus,
|
||||
groupCwd,
|
||||
groupWorktree,
|
||||
groupCatalogGeneration,
|
||||
groupDefaultsStatus,
|
||||
model: "",
|
||||
catalogLabel: "",
|
||||
startTerminal: false,
|
||||
});
|
||||
const initialGateway = context.gateway.snapshot;
|
||||
const initialAgentsState = context.agents.state;
|
||||
if (
|
||||
initialGateway.phase !== "connected" ||
|
||||
!initialGateway.client ||
|
||||
!initialAgentsState.connected ||
|
||||
initialAgentsState.client !== initialGateway.client
|
||||
) {
|
||||
return unresolved();
|
||||
}
|
||||
// ensureList is fail-closed: offline and request-error paths return cached
|
||||
// data or null, allowing the unresolved catalog page to mount and retry.
|
||||
const loadedAgentsList = initialAgentsState.agentsList ?? (await context.agents.ensureList());
|
||||
const gateway = context.gateway.snapshot;
|
||||
const agentsState = context.agents.state;
|
||||
if (
|
||||
gateway.phase !== "connected" ||
|
||||
!gateway.client ||
|
||||
gateway.client !== initialGateway.client ||
|
||||
!agentsState.connected ||
|
||||
agentsState.client !== gateway.client ||
|
||||
agentsState.agentsList !== loadedAgentsList
|
||||
) {
|
||||
return unresolved();
|
||||
}
|
||||
const agentsList = loadedAgentsList;
|
||||
const availableAgents = listSelectableAgents(agentsList?.agents ?? []);
|
||||
const gatewayDefaultId =
|
||||
gateway.phase === "connected" && gateway.hello ? gateway.assistantAgentId : null;
|
||||
if (
|
||||
!agentsList &&
|
||||
requestedAgentId &&
|
||||
(!gatewayDefaultId || normalizeAgentId(requestedAgentId) !== normalizeAgentId(gatewayDefaultId))
|
||||
) {
|
||||
return unresolved();
|
||||
}
|
||||
const fallbackAgentId = agentsList
|
||||
? availableAgents.some((agent) => agent.id === agentsList.defaultId)
|
||||
? agentsList.defaultId
|
||||
: availableAgents[0]?.id
|
||||
: gatewayDefaultId;
|
||||
const agentId = fallbackAgentId
|
||||
? agentsList
|
||||
? resolveAgentId(requestedLocation, availableAgents, fallbackAgentId)
|
||||
: resolveAgentId(undefined, [], fallbackAgentId)
|
||||
: "";
|
||||
const plain = unresolved(agentId);
|
||||
if (gateway.phase !== "connected" || !gateway.client || !agentId) {
|
||||
return plain;
|
||||
}
|
||||
const target = await resolveCreateTarget(gateway.client, requestedLocation.catalogId, agentId);
|
||||
return target ? { ...plain, ...target } : plain;
|
||||
}
|
||||
@@ -56,6 +56,43 @@ function createContext(params: {
|
||||
}
|
||||
|
||||
describe("new-session route catalog target", () => {
|
||||
it("does not apply group defaults from a retired connection", async () => {
|
||||
const context = {
|
||||
sessions: {
|
||||
state: {
|
||||
groupSettings: [
|
||||
{ name: "Client", position: 0, cwd: "/gateway-a/client", worktree: true },
|
||||
],
|
||||
},
|
||||
groupsLoad: vi.fn(async () => null),
|
||||
groupsGeneration: vi.fn(() => 1),
|
||||
groupsStatus: vi.fn(() => "unavailable"),
|
||||
},
|
||||
} as unknown as ApplicationContext;
|
||||
|
||||
const data = await loadNewSessionData(context, "?group=Client");
|
||||
|
||||
expect(data.groupStatus).toBe("unavailable");
|
||||
expect(data.groupCwd).toBe("");
|
||||
expect(data.groupWorktree).toBe(false);
|
||||
});
|
||||
|
||||
it("marks a deleted group target missing", async () => {
|
||||
const context = {
|
||||
sessions: {
|
||||
state: { groupSettings: [] },
|
||||
groupsLoad: vi.fn(async () => []),
|
||||
groupsGeneration: vi.fn(() => 1),
|
||||
groupsStatus: vi.fn(() => "ready"),
|
||||
},
|
||||
} as unknown as ApplicationContext;
|
||||
|
||||
const data = await loadNewSessionData(context, "?group=Deleted");
|
||||
|
||||
expect(data.group).toBe("Deleted");
|
||||
expect(data.groupStatus).toBe("missing");
|
||||
});
|
||||
|
||||
it("defers an unvalidated route agent before roster hydration", async () => {
|
||||
const { context, request } = createContext({
|
||||
assistantAgentId: "roboclaw",
|
||||
|
||||
@@ -1,98 +1,12 @@
|
||||
import type { RouteLocation } from "@openclaw/uirouter";
|
||||
import { definePage } from "@openclaw/uirouter";
|
||||
import { html } from "lit";
|
||||
import { routePageSpec } from "../../app-route-paths.ts";
|
||||
import type { ApplicationContext } from "../../app/context.ts";
|
||||
import { listSelectableAgents } from "../../lib/agents/display.ts";
|
||||
import { normalizeAgentId } from "../../lib/sessions/session-key.ts";
|
||||
import { newSessionLocationFromSearch, type NewSessionRouteData } from "./location.ts";
|
||||
|
||||
async function loadNewSessionData(
|
||||
context: ApplicationContext,
|
||||
search: string,
|
||||
): Promise<NewSessionRouteData> {
|
||||
const requestedLocation = newSessionLocationFromSearch(search);
|
||||
const requestedAgentId = requestedLocation.agentId.trim();
|
||||
if (!requestedLocation.catalogId) {
|
||||
return {
|
||||
...requestedLocation,
|
||||
requestedAgentId,
|
||||
model: "",
|
||||
catalogLabel: "",
|
||||
startTerminal: false,
|
||||
};
|
||||
}
|
||||
const { resolveAgentId, resolveCreateTarget } = await import("./catalog-target.ts");
|
||||
const unresolved = (agentId = ""): NewSessionRouteData => ({
|
||||
...requestedLocation,
|
||||
agentId,
|
||||
requestedAgentId,
|
||||
model: "",
|
||||
catalogLabel: "",
|
||||
startTerminal: false,
|
||||
});
|
||||
const initialGateway = context.gateway.snapshot;
|
||||
const initialAgentsState = context.agents.state;
|
||||
if (
|
||||
initialGateway.phase !== "connected" ||
|
||||
!initialGateway.client ||
|
||||
!initialAgentsState.connected ||
|
||||
initialAgentsState.client !== initialGateway.client
|
||||
) {
|
||||
return unresolved();
|
||||
}
|
||||
// ensureList is fail-closed: offline and request-error paths return cached
|
||||
// data or null, allowing the unresolved catalog page to mount and retry.
|
||||
const loadedAgentsList = initialAgentsState.agentsList ?? (await context.agents.ensureList());
|
||||
const gateway = context.gateway.snapshot;
|
||||
const agentsState = context.agents.state;
|
||||
if (
|
||||
gateway.phase !== "connected" ||
|
||||
!gateway.client ||
|
||||
gateway.client !== initialGateway.client ||
|
||||
!agentsState.connected ||
|
||||
agentsState.client !== gateway.client ||
|
||||
agentsState.agentsList !== loadedAgentsList
|
||||
) {
|
||||
return unresolved();
|
||||
}
|
||||
const agentsList = loadedAgentsList;
|
||||
const availableAgents = listSelectableAgents(agentsList?.agents ?? []);
|
||||
const gatewayDefaultId =
|
||||
gateway.phase === "connected" && gateway.hello ? gateway.assistantAgentId : null;
|
||||
if (
|
||||
!agentsList &&
|
||||
requestedAgentId &&
|
||||
(!gatewayDefaultId || normalizeAgentId(requestedAgentId) !== normalizeAgentId(gatewayDefaultId))
|
||||
) {
|
||||
return unresolved();
|
||||
}
|
||||
const fallbackAgentId = agentsList
|
||||
? availableAgents.some((agent) => agent.id === agentsList.defaultId)
|
||||
? agentsList.defaultId
|
||||
: availableAgents[0]?.id
|
||||
: gatewayDefaultId;
|
||||
const agentId = fallbackAgentId
|
||||
? agentsList
|
||||
? resolveAgentId(requestedLocation, availableAgents, fallbackAgentId)
|
||||
: resolveAgentId(undefined, [], fallbackAgentId)
|
||||
: "";
|
||||
const plain = unresolved(agentId);
|
||||
if (gateway.phase !== "connected" || !gateway.client || !agentId) {
|
||||
return plain;
|
||||
}
|
||||
const target = await resolveCreateTarget(gateway.client, requestedLocation.catalogId, agentId);
|
||||
return target ? { ...plain, ...target } : plain;
|
||||
}
|
||||
|
||||
export const page = definePage({
|
||||
...routePageSpec("new-session"),
|
||||
loaderDeps: (_context: ApplicationContext, location: RouteLocation) => location.search,
|
||||
loader: (context: ApplicationContext, { location }) =>
|
||||
loadNewSessionData(context, location.search),
|
||||
component: () =>
|
||||
import("./new-session-page.ts").then(() => ({
|
||||
render: (data: unknown) =>
|
||||
html`<openclaw-new-session-page .data=${data}></openclaw-new-session-page>`,
|
||||
})),
|
||||
loader: async (context: ApplicationContext, { location }) =>
|
||||
(await import("./route-loader.ts")).load(context, location.search),
|
||||
component: () => import("./new-session-page.ts"),
|
||||
});
|
||||
|
||||
@@ -767,9 +767,4 @@ wa-dropdown.new-session-page__start-menu {
|
||||
.new-session-page__select {
|
||||
position: static;
|
||||
}
|
||||
|
||||
wa-popover.new-session-page__select::part(body) {
|
||||
width: auto;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ApplicationGatewaySnapshot } from "../../app/context.ts";
|
||||
import {
|
||||
createGatewayHarness,
|
||||
createSessionsHarness,
|
||||
deferred,
|
||||
mountSidebar,
|
||||
type SessionGroupMutationResult,
|
||||
type SidebarLifecycleState,
|
||||
@@ -83,7 +84,7 @@ describe("AppSidebar group mutation collapsed state", () => {
|
||||
|
||||
async function openGroupMenu(sidebar: SidebarLifecycleState) {
|
||||
const actions = sidebar.querySelector<HTMLButtonElement>(
|
||||
'[data-session-section="category:Alpha"] .sidebar-session-group-actions',
|
||||
'[data-session-section="category:Alpha"] .sidebar-session-group-actions[aria-haspopup="menu"]',
|
||||
);
|
||||
if (!actions) {
|
||||
throw new Error("expected group actions trigger");
|
||||
@@ -97,16 +98,36 @@ describe("AppSidebar group mutation collapsed state", () => {
|
||||
return menu;
|
||||
}
|
||||
|
||||
function selectGroupMenuAction(menu: Element, value: string) {
|
||||
const item = menu.querySelector(`[value="${value}"]`);
|
||||
if (!item) {
|
||||
throw new Error(`expected ${value} group action`);
|
||||
}
|
||||
menu.dispatchEvent(
|
||||
new CustomEvent("wa-select", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
detail: { item: { value } },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function renameGroupThroughDialog(sidebar: SidebarLifecycleState, name: string) {
|
||||
const menu = await openGroupMenu(sidebar);
|
||||
menu.querySelectorAll<HTMLButtonElement>(".session-menu__item")[0]?.click();
|
||||
selectGroupMenuAction(menu, "rename-group");
|
||||
await waitForFast(() => {
|
||||
const input = document.body.querySelector('openclaw-modal-dialog input[name="value"]');
|
||||
if (!(input instanceof HTMLInputElement)) {
|
||||
throw new Error("expected group rename input");
|
||||
}
|
||||
return input;
|
||||
});
|
||||
await submitInputDialog(name);
|
||||
}
|
||||
|
||||
async function deleteGroupThroughConfirm(sidebar: SidebarLifecycleState) {
|
||||
const menu = await openGroupMenu(sidebar);
|
||||
const items = menu.querySelectorAll<HTMLButtonElement>(".session-menu__item");
|
||||
items[items.length - 1]?.click();
|
||||
selectGroupMenuAction(menu, "delete-group");
|
||||
const confirm = await waitForFast(() => {
|
||||
const button = document.body.querySelector<HTMLButtonElement>(
|
||||
"openclaw-modal-dialog .exec-approval-actions .btn.danger",
|
||||
@@ -194,8 +215,7 @@ describe("AppSidebar group mutation collapsed state", () => {
|
||||
const toast = document.body.appendChild(document.createElement("openclaw-toast-host"));
|
||||
await toast.updateComplete;
|
||||
const menu = await openGroupMenu(sidebar);
|
||||
const items = menu.querySelectorAll<HTMLButtonElement>(".session-menu__item");
|
||||
items[items.length - 1]?.click();
|
||||
selectGroupMenuAction(menu, "delete-group");
|
||||
const confirm = await waitForFast(() => {
|
||||
const button = document.body.querySelector<HTMLButtonElement>(
|
||||
"openclaw-modal-dialog .exec-approval-actions .btn.danger",
|
||||
@@ -225,11 +245,42 @@ describe("AppSidebar group mutation collapsed state", () => {
|
||||
toast.remove();
|
||||
});
|
||||
|
||||
it("rejects a folder listing from a retired same-client connection", async () => {
|
||||
const listing = deferred<{
|
||||
path: string;
|
||||
home: string;
|
||||
entries: Array<{ name: string; path: string; type: "directory" }>;
|
||||
}>();
|
||||
const client = {
|
||||
request: async (method: string) => {
|
||||
if (method === "fs.listDir") {
|
||||
return await listing.promise;
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
},
|
||||
} as GatewayBrowserClient;
|
||||
const gatewayHarness = createGatewayHarness(client);
|
||||
const harness = createSessionsHarness("main", ["agent:main:main"]);
|
||||
const { sidebar } = await mountSidebar(gatewayHarness.gateway, harness.sessions);
|
||||
|
||||
const pending = sidebar.listSessionGroupFolders("/repos/client");
|
||||
gatewayHarness.publish({ phase: "stopped" });
|
||||
gatewayHarness.publish({ phase: "connected" });
|
||||
listing.resolve({
|
||||
path: "/repos/client",
|
||||
home: "/home/peter",
|
||||
entries: [],
|
||||
});
|
||||
|
||||
await expect(pending).rejects.toThrow(
|
||||
"Gateway connection replaced before the defaults were saved. Try again.",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves the catalog alone when the delete confirm is cancelled", async () => {
|
||||
const { sidebar, harness } = await mountCollapsedGroup({});
|
||||
const menu = await openGroupMenu(sidebar);
|
||||
const items = menu.querySelectorAll<HTMLButtonElement>(".session-menu__item");
|
||||
items[items.length - 1]?.click();
|
||||
selectGroupMenuAction(menu, "delete-group");
|
||||
// Cancel is the focused default; answering it must end the flow with the
|
||||
// group, its members and the collapsed key untouched.
|
||||
const cancel = await waitForFast(() => {
|
||||
|
||||
@@ -70,6 +70,11 @@ export type SidebarLifecycleState = HTMLElement & {
|
||||
) => void;
|
||||
readonly sessionData: SessionDataController;
|
||||
readonly sessionOrganizer: SessionOrganizerController;
|
||||
listSessionGroupFolders(path?: string): Promise<{
|
||||
path: string;
|
||||
home: string;
|
||||
entries: Array<{ name: string; path: string; type: "directory" | "file" }>;
|
||||
}>;
|
||||
requestUpdate: () => void;
|
||||
updateComplete: Promise<boolean>;
|
||||
updateAvailable: { currentVersion: string; latestVersion: string; channel: string } | null;
|
||||
@@ -202,6 +207,7 @@ export function createSessionState(agentId: string, keys: string[]): SessionStat
|
||||
error: null,
|
||||
deletedSessions: [],
|
||||
groups: [],
|
||||
groupSettings: [],
|
||||
sectionOrder: [],
|
||||
};
|
||||
}
|
||||
@@ -310,6 +316,9 @@ export function createSessionsHarness(agentId: string, keys: string[]) {
|
||||
}
|
||||
},
|
||||
groupsLoad: () => Promise.resolve(),
|
||||
groupsGeneration: () => 0,
|
||||
groupsStatus: () => "ready",
|
||||
groupsInvalidate: () => undefined,
|
||||
groupsPut,
|
||||
groupsRename,
|
||||
groupsDelete,
|
||||
|
||||
@@ -221,9 +221,11 @@ const defaultControlUiFeatureMethods = [
|
||||
"sessions.dispatch",
|
||||
"sessions.fork",
|
||||
"sessions.groups.delete",
|
||||
"sessions.groups.defaults",
|
||||
"sessions.groups.list",
|
||||
"sessions.groups.put",
|
||||
"sessions.groups.rename",
|
||||
"sessions.groups.update",
|
||||
"sessions.patch",
|
||||
"sessions.reclaim",
|
||||
"sessions.reset",
|
||||
@@ -231,6 +233,7 @@ const defaultControlUiFeatureMethods = [
|
||||
"update.hold",
|
||||
"update.run",
|
||||
"update.status",
|
||||
"worktrees.branches",
|
||||
] as const;
|
||||
|
||||
export type MockGatewayRequest = {
|
||||
@@ -312,6 +315,8 @@ export type ControlUiMockGatewayScenario = {
|
||||
sessionKey?: string;
|
||||
/** Initial gateway-owned custom group catalog (sessions.groups.*), in order. */
|
||||
sessionGroups?: string[];
|
||||
/** Optional New Session defaults keyed by custom group name. */
|
||||
sessionGroupDefaults?: Record<string, { cwd?: string; worktree?: boolean }>;
|
||||
terminalEnabled?: boolean;
|
||||
cliAgentsEnabled?: boolean;
|
||||
workspace?: string;
|
||||
@@ -825,6 +830,7 @@ function normalizeScenario(
|
||||
sessionArchiveFiltering: scenario.sessionArchiveFiltering ?? false,
|
||||
sessionKey,
|
||||
sessionGroups: scenario.sessionGroups ?? [],
|
||||
sessionGroupDefaults: scenario.sessionGroupDefaults ?? {},
|
||||
terminalEnabled: scenario.terminalEnabled ?? false,
|
||||
cliAgentsEnabled: scenario.cliAgentsEnabled ?? false,
|
||||
workspace: scenario.workspace ?? "",
|
||||
@@ -973,10 +979,12 @@ function installControlUiMockGateway(
|
||||
const groupsStateKey = "openclaw.control-ui-e2e.sessionGroups";
|
||||
let groupsState: {
|
||||
names: string[];
|
||||
defaults: Record<string, { cwd?: string; worktree?: boolean }>;
|
||||
sectionOrder: string[];
|
||||
renames: Array<{ from: string; to: string | null }>;
|
||||
} = {
|
||||
names: [...input.scenario.sessionGroups],
|
||||
defaults: { ...input.scenario.sessionGroupDefaults },
|
||||
sectionOrder: [],
|
||||
renames: [],
|
||||
};
|
||||
@@ -991,6 +999,7 @@ function installControlUiMockGateway(
|
||||
if (rawGroups) {
|
||||
groupsState = JSON.parse(rawGroups) as typeof groupsState;
|
||||
groupsState.sectionOrder ??= [];
|
||||
groupsState.defaults ??= {};
|
||||
}
|
||||
} catch {
|
||||
// Storage-disabled browser contexts still get the scenario catalog.
|
||||
@@ -1081,6 +1090,12 @@ function installControlUiMockGateway(
|
||||
};
|
||||
}
|
||||
|
||||
function groupDefaultsPayload() {
|
||||
return {
|
||||
defaults: groupsState.names.map((name) => ({ name, ...groupsState.defaults[name] })),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizedGroupNames(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
@@ -1775,6 +1790,8 @@ function installControlUiMockGateway(
|
||||
}
|
||||
case "sessions.groups.list":
|
||||
return groupsPayload();
|
||||
case "sessions.groups.defaults":
|
||||
return groupDefaultsPayload();
|
||||
case "sessions.groups.put": {
|
||||
groupsState.names = normalizedGroupNames(isRecord(params) ? params.names : undefined);
|
||||
if (isRecord(params) && Array.isArray(params.sectionOrder)) {
|
||||
@@ -1794,6 +1811,10 @@ function installControlUiMockGateway(
|
||||
names.splice(sourceIndex < 0 ? names.length : sourceIndex, 0, to);
|
||||
}
|
||||
groupsState.names = names;
|
||||
if (!groupsState.defaults[to] && groupsState.defaults[from]) {
|
||||
groupsState.defaults[to] = groupsState.defaults[from];
|
||||
}
|
||||
delete groupsState.defaults[from];
|
||||
const sourceSectionId = `category:${from}`;
|
||||
const targetSectionId = `category:${to}`;
|
||||
groupsState.sectionOrder = groupsState.sectionOrder.flatMap((sectionId) => {
|
||||
@@ -1807,10 +1828,23 @@ function installControlUiMockGateway(
|
||||
}
|
||||
return { ok: true, updatedSessions: 0, ...groupsPayload() };
|
||||
}
|
||||
case "sessions.groups.update": {
|
||||
const name = isRecord(params) && typeof params.name === "string" ? params.name.trim() : "";
|
||||
if (name) {
|
||||
const cwd = isRecord(params) && typeof params.cwd === "string" ? params.cwd.trim() : "";
|
||||
groupsState.defaults[name] = {
|
||||
...(cwd ? { cwd } : {}),
|
||||
worktree: isRecord(params) && params.worktree === true,
|
||||
};
|
||||
persistGroupsState();
|
||||
}
|
||||
return { ok: true, ...groupDefaultsPayload() };
|
||||
}
|
||||
case "sessions.groups.delete": {
|
||||
const name = isRecord(params) && typeof params.name === "string" ? params.name.trim() : "";
|
||||
if (name) {
|
||||
groupsState.names = groupsState.names.filter((existing) => existing !== name);
|
||||
delete groupsState.defaults[name];
|
||||
groupsState.sectionOrder = groupsState.sectionOrder.filter(
|
||||
(sectionId) => sectionId !== `category:${name}`,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user