feat(sessions): drafts UX — create-as-draft, promote, admin ghost treatment (#113127)

* feat(protocol): support draft session creation

* feat(ui): add draft session workflows

* docs: explain multi-user drafts

* test(ui): tighten draft ownership fixture

* test(ui): satisfy draft E2E lint

* test(ui): keep draft fixtures strictly typed

* docs: add drafts implementation report

* docs: clarify implementation LOC summary

* test: strengthen draft compatibility coverage

* docs: correct drafts cleanup report

* fix: harden draft availability policy

* docs: update draft policy review notes

* test(ui): type draft policy mock control

* fix: preserve keyed draft creation retries

* fix: keep disabled draft retries idempotent

* docs: finalize drafts cleanup report

* fix(ui): keep draft ownership helper internal

* docs: refresh drafts docs map
This commit is contained in:
Peter Steinberger
2026-07-23 13:06:35 -07:00
committed by GitHub
parent 95b86a3b2b
commit cbf94c0d8b
30 changed files with 950 additions and 334 deletions
+145 -314
View File
@@ -1,357 +1,188 @@
# W5 archive attribution report # W4 Drafts UX implementation report
Implementation commit: `083482ea654 feat(sessions): attribute archive transitions` ## Outcome
Alias-rollback follow-up: `14230f2e84a fix(sessions): restore alias layout after archive audit failure` Implemented the complete drafts UX on top of the landed ownership, visibility, membership, and person-filter machinery.
Final rollback redesign: `b5cc8061a9b fix(sessions): isolate archive audit rollback` - New-session creation can start atomically as `visibility: "draft"`; the initial durable session entry carries draft visibility before the create response or any session-change broadcast.
- The create affordance is exposed only when the hello policy allows drafts and the Gateway reports at least two canonical, non-merged sharing identities. Older gateways and solo mode fail closed by hiding the control.
Final best-effort policy: `320237964e0 fix(sessions): make archive audit note best-effort` - Own drafts keep normal row emphasis with a subtle ghost marker. Admin-visible drafts owned by someone else use the same draft class family with a faded, light/dark-safe treatment.
- A dedicated **Publish draft** header menu item calls the existing visibility callback with `"shared"`, which continues through the landed `session.visibility.set` RPC, audit-line, rollback, and live-event path.
- Multi-user and sharing configuration docs now explain create-as-draft behavior, admin visibility, and the non-security-boundary constraint.
- No suggestion queue, invite link, config key, SQLite schema/table/version, membership enforcement, new Gateway event, or changelog change was added.
## Files changed ## Files changed
Session state and mutation: ### Protocol and Gateway
- `src/config/sessions/session-entry-provenance.ts` - `packages/gateway-protocol/src/schema/sessions-create.ts`
- `src/config/sessions/types.ts` - `packages/gateway-protocol/src/schema/sessions-create.test.ts`
- `src/gateway/sessions-patch.ts` - `packages/gateway-protocol/src/schema/frames.ts`
- `src/gateway/server-methods/sessions-mutations.ts` - `src/gateway/session-create-service.ts`
- `src/gateway/server-methods/session-audit.ts` - `src/gateway/server-methods/sessions-create.ts`
- `src/gateway/server-methods/sessions-sharing.ts` - `src/gateway/server/ws-connection/connect-hello.ts`
- `src/plugins/session-entry-slot-keys.ts` - `src/gateway/server.sessions.create.test.ts`
- `src/gateway/server.auth.default-token.suite.ts`
Gateway projection and event contract:
- `packages/gateway-protocol/src/schema/sessions-row.ts`
- `src/gateway/session-utils.ts`
- `src/gateway/session-utils.types.ts`
- `src/gateway/session-event-payload.ts`
- `apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift` - `apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift`
Control UI: The Kotlin generators ran successfully; their tracked outputs were byte-identical. `dist/protocol.schema.json` is generated locally by protocol checks but remains ignored and untracked, matching `origin/main`.
- `ui/src/api/types.ts` ### Control UI
- `ui/src/pages/new-session/new-session-page.ts`
- `ui/src/pages/new-session/create-params.ts`
- `ui/src/pages/new-session/create-params.test.ts`
- `ui/src/pages/new-session/catalog-target.ts`
- `ui/src/pages/new-session/target-controls.ts`
- `ui/src/pages/new-session/target-controls.test.ts`
- `ui/src/pages/chat/components/chat-session-sharing.ts`
- `ui/src/pages/chat/components/chat-session-sharing.test.ts`
- `ui/src/components/app-sidebar-session-navigation-logic.ts` - `ui/src/components/app-sidebar-session-navigation-logic.ts`
- `ui/src/components/app-sidebar-session-navigation-logic.test.ts`
- `ui/src/components/app-sidebar-session-row-render.ts` - `ui/src/components/app-sidebar-session-row-render.ts`
- `ui/src/components/app-sidebar-session-types.ts` - `ui/src/components/app-sidebar-session-types.ts`
- `ui/src/components/session-owner-chip.ts` - `ui/src/styles/components.css`
- `ui/src/styles/new-session.css`
- `ui/src/i18n/locales/en.ts` - `ui/src/i18n/locales/en.ts`
- `ui/src/lib/sessions/reconcile.ts` - `ui/src/e2e/session-ownership.e2e.test.ts`
- `ui/src/test-helpers/control-ui-e2e.ts`
Behavior tests: ### Docs
- `src/gateway/server-methods/sessions-mutations.archive-attribution.test.ts` - `docs/concepts/multi-user.md`
- `src/gateway/sessions-patch.test.ts` - `docs/gateway/config-agents.md`
- `src/gateway/session-utils-creators.test.ts`
- `src/gateway/session-event-payload.test.ts`
- `packages/gateway-protocol/src/schema/sessions-row.test.ts`
- `ui/src/lib/sessions/reconcile.test.ts`
- `ui/src/test-helpers/app-sidebar-cases/session-ownership.ts`
Task handoff: ### Local proof artifacts, not committed
- `REPORT.md` - `.artifacts/control-ui-e2e/drafts-ux/behavior-contract.md`
- `.artifacts/control-ui-e2e/drafts-ux/01-sidebar-draft-treatment.png`
- `.artifacts/control-ui-e2e/drafts-ux/01-sidebar-draft-treatment-dark.png`
- `.artifacts/control-ui-e2e/drafts-ux/02-create-draft-available.png`
- `.artifacts/control-ui-e2e/drafts-ux/03-create-draft-selected.png`
- `.artifacts/control-ui-e2e/drafts-ux/04-publish-draft-action.png`
- `.artifacts/control-ui-e2e/drafts-ux/*.webm`
## Key decisions and resolved ambiguities ## Key decisions and ambiguity resolution
- `archivedBy` uses the canonical `SessionCreatedActor` shape and is written only on a real unarchived-to-archived transition. Repeating `archived: true` preserves the original archiver. Unarchive clears it. 1. `visibility` is optional and additive in `SessionsCreateParamsSchema`. Omission preserves the prior storage shape and projects as `shared`. New rows reject a disallowed visibility with `SESSION_VISIBILITY_DISABLED`; keyed adoption is allowed only when the requested visibility exactly matches the existing effective visibility, including after policy changes. Mismatches and in-place resets remain rejected.
- The acting identity comes only from `gatewayClientSessionCreator(client)`. Unidentified token/password clients get no fabricated identity and no actor-stamped audit line. 2. The hello `policy` object now exposes optional `allowedSessionVisibilities` and `hasMultipleSessionSharingIdentities`. Current Gateways always populate both. Optional schema fields preserve compatibility with older Gateway/client pairs.
- Archive and unarchive audit notes use the same shared `SessionManager.appendMessage` helper as visibility/membership audit notes. Archive notes are deliberately best-effort: if append fails, the durable archive remains committed, the RPC succeeds, and `sessionLog.warn` records the lost note. Visibility audit remains rollback-on-failure because it protects an access boundary. 3. The multi-user boolean comes from canonical non-merged user profiles, not loaded session creators. It reveals only whether the draft UI's two-identity threshold is met, not the exact profile count.
- The existing `applySessionPatchProjection` candidate resolver already supplies the freshest authoritative alias entry. Archive transition comparison uses that `existingEntry`, so alias migration does not invent or suppress a transition. 4. The new-session UI rechecks the hello policy at submit time and clears a checked draft selection as soon as policy/identity availability disappears. A hidden prior choice cannot silently reactivate.
- The session catalog is stored as canonical `session_entries.entry_json`; `archivedBy` is an additive JSON field. No SQL DDL, schema-version bump, backfill, dual path, or migration is needed. 5. Admin ownership styling compares the session creator id with the current authenticated user id because `sharingRole: "admin"` intentionally wins over `"owner"` for administrators.
- `sessions.changed` remains the existing event and is still emitted with its concrete session-key scope, so draft/visibility filtering remains in force. No new event or `EVENT_SCOPE_GUARDS` entry was needed. 6. Promotion remains one existing `session.visibility.set` call with `visibility: "shared"`; no parallel publish method, event, audit path, or invite workflow was introduced.
- In the exact View-archived filter, the existing owner chip renders `archivedBy` with an `Archived by {name}` accessible label. Other session lists retain creator attribution. The existing `sessionOwnershipVisible` gate suppresses this chrome with fewer than two creator identities. 7. The sidebar's pending unsent-composer `showDraft`/`renderDraftSessionRow` machinery was not touched.
- All protocol generators ran. Swift changed. Kotlin did not change because `scripts/protocol-gen-kotlin.ts` does not include `SessionRow` in its emitted schema whitelist. `dist/protocol.schema.json` was generated and checked, but current main intentionally ignores and does not track `dist/`; force-adding the 1.9 MB ignored artifact would undo repository policy.
## Verification ## Verification
Dependency install: All final code commands below ran against implementation HEAD `0c755a401b47a9c12f61d8a01b49017f3f938e2d`, rebased onto `977db1c83261b13e92e7ad74c5f544e61acf1b90`. `origin/main` advanced by six commits during the final proof; no further moving-base rebase was attempted after the successful gate.
```text ### Install and formatting
pnpm install
Done in 19.9s using pnpm v11.2.2 (exit 0)
```
Focused behavior tests: - `pnpm install`
- Initial fresh install: exit 0, 1,272 packages, pnpm 11.2.2.
- Final post-rebase refresh: exit 0 using pnpm 11.15.1; lockfile supply-chain policies passed and 285 packages were refreshed.
- `./node_modules/.bin/oxfmt --write --threads=1 <changed files>`
- Exit 0. The final changed gate reported `All matched files use the correct format.`
- `git diff --check origin/main...HEAD`
- Exit 0, no output.
```text ### Focused Vitest and browser behavior
node scripts/run-vitest.mjs src/gateway/sessions-patch.test.ts src/gateway/server-methods/sessions-mutations.archive-attribution.test.ts src/gateway/session-utils-creators.test.ts src/gateway/session-event-payload.test.ts packages/gateway-protocol/src/schema/sessions-row.test.ts ui/src/components/app-sidebar.test.ts ui/src/lib/sessions/reconcile.test.ts
Test Files 4 passed, Tests 90 passed (gateway-core)
Test Files 1 passed, Tests 1 passed (gateway-client)
Test Files 2 passed, Tests 175 passed (UI)
[test] passed 3 Vitest shards in 32.67s (exit 0)
node scripts/run-vitest.mjs src/gateway/server-methods/sessions-sharing.test.ts - `node scripts/run-vitest.mjs packages/gateway-protocol/src/schema/sessions-create.test.ts ui/src/pages/new-session/create-params.test.ts ui/src/pages/new-session/target-controls.test.ts ui/src/pages/chat/components/chat-session-sharing.test.ts ui/src/components/app-sidebar-session-navigation-logic.test.ts`
Test Files 1 passed, Tests 9 passed - Exit 0.
[test] passed 1 Vitest shard in 17.36s (exit 0) - Gateway-client shard: 1 file, 2 tests passed.
- UI shard: 4 files, 21 tests passed.
- Final wrapper: `passed 2 Vitest shards in 7.32s`.
- `node scripts/run-vitest.mjs src/gateway/server.sessions.create.test.ts`
- Exit 0: 1 file, 67 tests passed; final wrapper `passed 1 Vitest shard in 22.51s`.
- Covers omitted visibility -> shared projection, draft visibility on the first list, disabled new drafts, matching keyed adoption, mismatched adoption rejection, and exact retry after policy disable.
- `node scripts/run-vitest.mjs src/gateway/server.auth.default-token.test.ts`
- Exit 0: 1 file, 23 tests passed; final wrapper `passed 1 Vitest shard in 6.49s`.
- `node scripts/run-vitest.mjs run --config test/vitest/vitest.ui-e2e.config.ts --configLoader runner ui/src/e2e/session-ownership.e2e.test.ts`
- Exit 0: 1 file, 7 tests passed in 5.75s.
- Proves multi-person availability, solo dormancy, atomic `sessions.create` traffic, own/foreign sidebar treatment, light/dark rendering, one-call publish, and clearing a selected draft mode after policy becomes unavailable.
node scripts/run-vitest.mjs src/gateway/server-methods/sessions-mutations.archive-attribution.test.ts ### Required typechecks
Test Files 1 passed, Tests 3 passed (exit 0)
```
The archive-attribution handler tests cover actor stamping, idempotent re-archive, unarchive clearing, exact audit text, unidentified-client behavior, alias archiving, and successful best-effort audit failure with no reversal writes. The UI suite includes the required collaborative and solo-dormancy archive-row cases. - `OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree node scripts/run-tsgo.mjs -p tsconfig.core.json`
- Exit 0, no diagnostics.
- `OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree node scripts/run-tsgo.mjs -p tsconfig.ui.json`
- Exit 0, no diagnostics.
- `OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree node scripts/run-tsgo.mjs -p tsconfig.extensions.json`
- Exit 0, no diagnostics.
- `OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree node scripts/run-tsgo.mjs -b tsconfig.projects.json`
- Exit 0, no diagnostics.
Protocol and i18n generation/checks: ### Protocol and localization
```text - `pnpm protocol:gen`
pnpm protocol:gen && pnpm protocol:gen:swift && pnpm protocol:gen:kotlin - Exit 0; wrote the local ignored `dist/protocol.schema.json` without adding it to Git.
wrote dist/protocol.schema.json - `pnpm protocol:gen:swift`
wrote apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift - Exit 0; wrote `GatewayModels.swift`.
wrote apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt - `pnpm protocol:gen:kotlin`
wrote apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawProtocolConstants.kt - Exit 0; wrote both Kotlin generator targets, with no tracked diff.
exit 0 - `pnpm protocol:check`
- Exit 0; regenerated JSON/Swift/Kotlin and printed `protocol since guard passed: 0 new core methods use train 2026.7`.
- `node --import tsx scripts/control-ui-i18n-verify.ts baseline`
- Exit 0: `raw-copy: baseline entries=105`, `source: keys=3968`.
- `node --import tsx scripts/native-app-i18n.ts baseline --write`
- Exit 0: `entries=5232 changed=true`; the diff contained only unrelated current-main iOS source-line shifts and was discarded rather than added to W4.
pnpm protocol:check ### Docs and changed gate
protocol since guard passed: 0 new core methods use train 2026.7
exit 0
node --import tsx scripts/native-app-i18n.ts baseline --write - `pnpm docs:check-mdx`
native-app-i18n: entries=5232 changed=false - Exit 0: `Docs MDX check passed (751 files, 3308ms).`
- `pnpm check:changed`
- Delegated to Testbox `tbx_01ky81kpwnmn9y6xdzg7jmf99k`, Actions run `30031143438`.
- Final wrapper: `exitCode: 0`, `runStatus: succeeded`, command 25m24.264s, total 25m26.417s; the one-shot Testbox stopped successfully.
- Ran the actual 30-file W4 lanes: ratchets, formatting, API/plugin boundaries, UI i18n, core/core-test/UI typechecks, all core/UI/packages lint shards, macOS CI test shards, native state schema guard, database-first guards, and import cycles.
- Final import-cycle result: `0 runtime value cycle(s)`.
node --import tsx scripts/control-ui-i18n-verify.ts baseline ### Autoreview
control-ui-i18n: raw-copy: baseline entries=105
control-ui-i18n: source: keys=3960
exit 0
```
Requested type gates: - Branch command: `.agents/skills/autoreview/scripts/autoreview --mode branch --base 977db1c83261b13e92e7ad74c5f544e61acf1b90 --stream-engine-output`.
- Accepted and fixed:
```text - replaced the exact global profile count with privacy-preserving `hasMultipleSessionSharingIdentities`;
node scripts/run-tsgo.mjs -p tsconfig.core.json - cleared stale checked draft state when policy/identity availability disappears;
exit 0 (no diagnostics) - preserved matching keyed-create adoption retries;
- preserved those exact retries after the drafts policy is disabled while still rejecting every genuinely new disabled draft.
node scripts/run-tsgo.mjs -p tsconfig.ui.json - Rejected after direct verification:
exit 0 (no diagnostics) - native binding drift: `pnpm protocol:check` regenerated all outputs cleanly; `HelloOk.policy` is intentionally an untyped map in Swift/Kotlin, so policy keys do not produce native field diffs;
- unkeyed policy bypass: `createSessionEntryWithTranscript` owns generated and explicit keys, and the 67-test suite proves unkeyed disabled drafts remain rejected.
node scripts/run-tsgo.mjs -p tsconfig.extensions.json - Final branch result: `autoreview clean: no accepted/actionable findings reported`, overall confidence 0.94.
exit 0 (no diagnostics)
OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree node scripts/run-tsgo.mjs -p test/tsconfig/tsconfig.core.test.json
exit 0 (no diagnostics)
```
Changed gate:
```text
pnpm check:changed --staged
Blacksmith Testbox tbx_01ky7mr2xxsf79mxq8er4r3g7y
command=22m27.882s total=22m29.893s exit=0 runStatus=succeeded
```
After autoreview fixes, the final Blacksmith rerun remained capacity-queued for eight minutes and was stopped cleanly. The trusted-source fallback ran the identical staged path set locally without the slow staged-per-file `git show` mode:
```text
changed_paths=("${(@f)$(git diff --cached --name-only)}")
OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree CI=1 PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN=false pnpm check:changed --base HEAD --head HEAD -- "${changed_paths[@]}"
lanes=core, coreTests, ui, apps
OPENCLAW_* count 529/529
max-lines ratchet OK: 1133 grandfathered suppressions
Control UI i18n verified: baseline entries=105, source keys=3960
Typecheck core, core tests, and UI: passed
Core, UI, packages, and Swift lint: 0 errors/violations
Mac app CI tests: 232 passed across 5 Vitest shards
Native state schema guard: v5 passed
Database-first legacy-store guard: passed
Import cycle check: 0 runtime value cycles
exit 0
```
Review and final sanity:
```text
.agents/skills/autoreview/scripts/autoreview --mode uncommitted
trufflehog: clean
autoreview clean: no accepted/actionable findings reported
overall: patch is correct (0.94)
git diff --cached --check
exit 0
```
## LOC summary ## LOC summary
From `git show --numstat 083482ea654`, classifying `*.test.*` and `ui/src/test-helpers/**` as tests: Implementation diff before adding this report, grouped by role and excluding generated/docs from production:
```text | Group | Added | Deleted |
prod 138 added, 37 deleted, net +101 | ---------------------- | ----: | ------: |
test 322 added, 11 deleted, net +311 | Production | 192 | 18 |
total 460 added, 48 deleted, net +412 | Tests and test support | 562 | 1 |
``` | Generated Swift | 4 | 0 |
| Docs | 5 | 1 |
The shared audit extraction is nearly LOC-neutral by moving the former sharing-only implementation into one reusable helper. The main production growth is the actor field plumbing, event tombstones, protocol/UI types, and accessible archive-chip state. The generated total contains only the tracked Swift protocol model. The JSON schema is deliberately excluded because it is ignored and untracked on `origin/main`.
## Commits
- `ce0ac10f6af feat(protocol): support draft session creation`
- `c73b1076dae feat(ui): add draft session workflows`
- `4b3215e3818 docs: explain multi-user drafts`
- `afcb60ba214 test(ui): tighten draft ownership fixture`
- `d415bd43305 test(ui): satisfy draft E2E lint`
- `9403a9fa1f9 test(ui): keep draft fixtures strictly typed`
- `7eb3abb54da test: strengthen draft compatibility coverage`
- `d91a15034c2 fix: harden draft availability policy`
- `4705acfd2e3 test(ui): type draft policy mock control`
- `0e0e96f339d fix: preserve keyed draft creation retries`
- `0c755a401b4 fix: keep disabled draft retries idempotent`
## Skipped or deferred ## Skipped or deferred
- No live browser/source-blind validation was run. This task had no independent live Gateway/browser fixture, and the same agent was already source-aware. The Control UI DOM behavior suite provides the requested behavior-level proof, including solo dormancy. - No push, pull request, `scripts/pr`, release, publish, `CHANGELOG.md`, or GitHub mutation was performed.
- No SQLite schema version or migration was added because the canonical session entry is stored in `entry_json`; adding one would violate the spec and repository storage rules. - No SQLite change was needed; therefore there was no schema-version bump or lazy table ensure.
- No Kotlin generated diff was committed because the Kotlin generator does not emit `SessionRow` and produced no change. - No new Gateway event was added; existing `sessions.changed` and `session.sharing` paths retain their current scope guards and draft filtering.
- No ignored `dist/protocol.schema.json` was force-added; it was generated and checked, while current repository policy leaves `dist/` untracked. - Foreign-language UI bundles were not edited; the requested `en.ts` plus baseline workflow was used.
- No push, PR, changelog edit, `scripts/pr`, or live Gateway mutation was performed. - `dist/protocol.schema.json` is absent from branch history and `git ls-files`, while final status reports it only as `!! dist/protocol.schema.json` after generation. Swift and both Kotlin targets were verified with `git cat-file -e origin/main:<path>`; no state-generated files changed.
- `origin/main` advanced during the long validation window. The branch is intentionally left for the reviewer to rebase as requested. - Final branch status after proof: feature work is committed and the tracked worktree is clean. `origin/main` advanced by six commits after the successful gate; no push or further moving-base chase was performed.
## Superseded raw-layout rollback follow-up
This section records the intermediate `14230f2e84a` approach for review history. It was superseded and removed by `b5cc8061a9b` because restoring snapshotted `session_members` could revive a membership concurrently revoked outside the archive lifecycle lock.
The first rollback restored only the canonical primary entry after an audit append failure. That was logically complete for the archive fields but not layout-complete: `applySessionPatchProjection` removes every non-primary alias candidate while canonicalizing the freshest row.
The follow-up captures the exact raw `session_entries` rows for every candidate key, plus `session_members` rows that SQLite cascades when an alias entry is removed. On audit failure it deletes the projected candidate set and reinserts the snapshot in one SQLite write transaction, preserving raw `entry_json`, `session_id`, `updated_at`, `status`, alias keys, and memberships. It then emits the inverse session identity diff, matching the forward projection notification path. `sessions.changed` remains post-audit and therefore is never emitted for the failed archive.
The visibility rollback was checked separately. It is alias-safe for its narrower mutation because it patches `current.storeKey` in place and never runs candidate canonicalization; copying that path alone would not have repaired archive rollback.
Follow-up files:
- `src/config/sessions/session-accessor.types.ts`
- `src/config/sessions/session-accessor.lifecycle.ts`
- `src/config/sessions/session-accessor.sqlite-projection.ts`
- `src/config/sessions/session-accessor.sqlite.ts`
- `src/config/sessions/session-accessor.ts`
- `src/gateway/server-methods/sessions-mutations.ts`
- `src/gateway/server-methods/sessions-mutations.archive-attribution.test.ts`
Follow-up proof:
```text
OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree node scripts/run-vitest.mjs src/gateway/server-methods/sessions-mutations.archive-attribution.test.ts -- --reporter=verbose
Test Files 1 passed
Tests 4 passed
The alias case passed in 2834ms and asserted exact raw entry/member row equality.
[test] passed 1 Vitest shard in 52.88s (exit 0)
OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree node scripts/run-vitest.mjs src/gateway/sessions-patch.test.ts -- -t 'attributes the archive transition|does not fabricate archive attribution' --reporter=verbose
Test Files 1 passed
Tests 2 passed, 84 skipped
[test] passed 1 Vitest shard in 33.06s (exit 0)
OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree node scripts/run-vitest.mjs src/gateway/sessions-patch.test.ts -- -t 'atomically snapshots prior selection for agent model patches' --reporter=verbose
Test Files 1 passed
Tests 1 passed, 85 skipped
[test] passed 1 Vitest shard in 38.39s (exit 0)
OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree node scripts/run-tsgo.mjs -p tsconfig.core.json
exit 0 (no diagnostics)
OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree node scripts/run-tsgo.mjs -p test/tsconfig/tsconfig.core.test.json
exit 0 (no diagnostics)
OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree CI=1 PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN=false pnpm check:changed --base HEAD --head HEAD -- src/config/sessions/session-accessor.lifecycle.ts src/config/sessions/session-accessor.sqlite-projection.ts src/config/sessions/session-accessor.sqlite.ts src/config/sessions/session-accessor.ts src/config/sessions/session-accessor.types.ts src/gateway/server-methods/sessions-mutations.archive-attribution.test.ts src/gateway/server-methods/sessions-mutations.ts
lanes=core, coreTests
format, core/core-test typechecks, changed-file lint, database-first guard, runtime sidecar loader guard, import-cycle guard, webhook and pairing guards passed
exit 0
.agents/skills/autoreview/scripts/autoreview --mode uncommitted --engine codex
trufflehog: clean
autoreview clean: no accepted/actionable findings reported
overall: patch is correct (0.97)
```
The first full local `sessions-patch.test.ts` attempt passed 85/86 tests; the unrelated agent-model snapshot test timed out under severe host contention after 120 seconds. Its isolated retry passed in 27.8 seconds, and the two archive projection cases passed independently. The first delegated `check:changed --staged` attempt also stopped before feature lanes because the current trusted-workflow checkout used env budget 525 while this reviewer-owned branch uses 529. The branch-relative local trusted-source fallback above passed; the follow-up adds no environment variables.
Follow-up LOC from `git show --numstat 14230f2e84a`:
```text
prod 154 added, 15 deleted, net +139
test 96 added, 1 deleted, net +95
total 250 added, 16 deleted, net +234
```
No push, PR, changelog edit, protocol change, schema-version bump, or `CLAUDE.md` edit was performed.
## Superseded archive-only rollback redesign
This section records the intermediate `b5cc8061a9b` approach. It was superseded by `320237964e0`; archive audit notes no longer trigger any rollback.
The final design treats candidate-key canonicalization as the normal benign behavior of `sessions.patch`; alias layout is not rolled back. If archive auditing fails, the handler re-reads the current candidate state and applies the inverse archived boolean through a second `applySessionPatchProjection` plus `projectSessionsPatchEntry` pass. It then restores only the prior `archivedAt` and `archivedBy` values. Other valid patch effects remain, and the rollback never reads or writes `session_members`.
The intermediate raw `session_entries`/`session_members` snapshot API and all related accessor plumbing were deleted. This avoids reviving a member removed concurrently between archive projection and audit failure.
Final regression behavior:
- archive is requested through the alias `alias-archive`;
- a canonical session member exists before the request;
- the forced audit failure removes that member before throwing;
- rollback leaves the canonical session unarchived with no `archivedBy`;
- the removed member remains absent;
- alias canonicalization may remain under the primary key by design.
Final proof:
```text
OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree node scripts/run-vitest.mjs src/gateway/server-methods/sessions-mutations.archive-attribution.test.ts -- --reporter=verbose
Test Files 1 passed
Tests 4 passed
[test] passed 1 Vitest shard in 13.59s (exit 0)
OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree node scripts/run-tsgo.mjs -p tsconfig.core.json
exit 0 (no diagnostics)
OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree CI=1 PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN=false pnpm check:changed --base HEAD --head HEAD -- src/config/sessions/session-accessor.lifecycle.ts src/config/sessions/session-accessor.sqlite-projection.ts src/config/sessions/session-accessor.sqlite.ts src/config/sessions/session-accessor.ts src/config/sessions/session-accessor.types.ts src/gateway/server-methods/sessions-mutations.archive-attribution.test.ts src/gateway/server-methods/sessions-mutations.ts
lanes=core, coreTests
format, core/core-test typechecks, changed-file lint, database-first guard, runtime sidecar loader guard, import-cycle guard, webhook and pairing guards passed
exit 0
.agents/skills/autoreview/scripts/autoreview --mode uncommitted --engine codex
trufflehog: clean
autoreview clean: no accepted/actionable findings reported
overall: patch is correct (0.98)
```
Final redesign LOC from `git show --numstat b5cc8061a9b`:
```text
prod 45 added, 166 deleted, net -121
test 24 added, 62 deleted, net -38
total 69 added, 228 deleted, net -159
```
No push was performed. `SPEC.md` remains the only untracked file.
## Final best-effort archive audit policy
The final policy deletes the archive rollback path entirely. `archivedAt` and `archivedBy` are the durable result. If the actor-stamped transcript note cannot be appended, `sessions.patch` logs `sessionLog.warn`, returns success, and keeps the archive. The catch performs no entry, alias, pin, or membership writes. This is a deliberate tradeoff for a non-security lifecycle note; visibility audit remains rollback-on-failure because visibility is an access boundary.
Final focused behavior proof:
```text
OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree node scripts/run-vitest.mjs src/gateway/server-methods/sessions-mutations.archive-attribution.test.ts -- --reporter=verbose
Test Files 1 passed
Tests 4 passed
[test] passed 1 Vitest shard in 8.85s (exit 0)
```
The forced-failure case archives through an alias and verifies:
- the RPC succeeds;
- the derived row has `archived: true`, `archivedAt`, and `archivedBy`;
- the expected warning includes the audit failure and “archive kept” decision;
- candidate entry state and `session_members` match their state at the failure point;
- SQLite `total_changes()` does not advance after the audit throw, proving there are no reversal writes.
Final gates:
```text
OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree node scripts/run-tsgo.mjs -p tsconfig.core.json
exit 0 (no diagnostics)
OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree CI=1 PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN=false pnpm check:changed --base HEAD --head HEAD -- src/gateway/server-methods/sessions-mutations.archive-attribution.test.ts src/gateway/server-methods/sessions-mutations.ts
lanes=core, coreTests
format, core/core-test typechecks, changed-file lint, database-first guard, runtime sidecar loader guard, import-cycle guard, webhook and pairing guards passed
exit 0
.agents/skills/autoreview/scripts/autoreview --mode uncommitted --engine codex
trufflehog: clean
autoreview clean: no accepted/actionable findings reported
overall: patch is correct (0.98)
```
The full branch review uses base `3023d69fd8858882470da009c8874541c2ddd9e9`. Its only initial finding was this report's stale rollback wording; the implementation was accepted as consistent with the deliberate best-effort policy.
Best-effort cleanup LOC from `git show --numstat 320237964e0`:
```text
prod 14 added, 40 deleted, net -26
test 61 added, 38 deleted, net +23
total 75 added, 78 deleted, net -3
```
No push was performed. `SPEC.md` remains the only untracked file.
@@ -6895,6 +6895,7 @@ public struct SessionsCreateParams: Codable, Sendable {
public let model: String? public let model: String?
public let thinkinglevel: String? public let thinkinglevel: String?
public let incognito: Bool? public let incognito: Bool?
public let visibility: SessionVisibility?
public let catalogid: String? public let catalogid: String?
public let parentsessionkey: String? public let parentsessionkey: String?
public let spawndepth: Int? public let spawndepth: Int?
@@ -6917,6 +6918,7 @@ public struct SessionsCreateParams: Codable, Sendable {
model: String? = nil, model: String? = nil,
thinkinglevel: String? = nil, thinkinglevel: String? = nil,
incognito: Bool? = nil, incognito: Bool? = nil,
visibility: SessionVisibility? = nil,
catalogid: String? = nil, catalogid: String? = nil,
parentsessionkey: String? = nil, parentsessionkey: String? = nil,
spawndepth: Int? = nil, spawndepth: Int? = nil,
@@ -6938,6 +6940,7 @@ public struct SessionsCreateParams: Codable, Sendable {
self.model = model self.model = model
self.thinkinglevel = thinkinglevel self.thinkinglevel = thinkinglevel
self.incognito = incognito self.incognito = incognito
self.visibility = visibility
self.catalogid = catalogid self.catalogid = catalogid
self.parentsessionkey = parentsessionkey self.parentsessionkey = parentsessionkey
self.spawndepth = spawndepth self.spawndepth = spawndepth
@@ -6961,6 +6964,7 @@ public struct SessionsCreateParams: Codable, Sendable {
case model case model
case thinkinglevel = "thinkingLevel" case thinkinglevel = "thinkingLevel"
case incognito case incognito
case visibility
case catalogid = "catalogId" case catalogid = "catalogId"
case parentsessionkey = "parentSessionKey" case parentsessionkey = "parentSessionKey"
case spawndepth = "spawnDepth" case spawndepth = "spawnDepth"
+4
View File
@@ -29,6 +29,10 @@ The web app keeps ownership and presence visually distinct:
When fewer than two distinct creators appear in the loaded session list, OpenClaw hides all ownership and person-filter chrome. A single-user gateway therefore looks unchanged. When fewer than two distinct creators appear in the loaded session list, OpenClaw hides all ownership and person-filter chrome. A single-user gateway therefore looks unchanged.
## Drafts
Start a session as a draft to keep work in progress out of teammates' sidebars until you publish it. Drafts are never hidden from admins, who see other people's drafts with a faded ghost marker. This is a coordination feature, not a security boundary.
## Turn attribution ## Turn attribution
Turn sender attribution is best-effort. Steering can merge input into an active turn, so the transcript cannot always represent each person's contribution as a separate turn. Turn sender attribution is best-effort. Steering can merge input into an active turn, so the transcript cannot always represent each person's contribution as a separate turn.
+1
View File
@@ -2786,6 +2786,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- Headings: - Headings:
- H2: Trust boundary - H2: Trust boundary
- H2: Ownership and presence - H2: Ownership and presence
- H2: Drafts
- H2: Turn attribution - H2: Turn attribution
- H2: Related - H2: Related
+1 -1
View File
@@ -1268,7 +1268,7 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden
- `maxAgeHours`: default hard max age in hours (`0` disables; providers can override) - `maxAgeHours`: default hard max age in hours (`0` disables; providers can override)
- `spawnSessions`: default gate for creating thread-bound work sessions from `sessions_spawn` and ACP thread spawns. Defaults to `true` when thread bindings are enabled; providers/accounts can override. - `spawnSessions`: default gate for creating thread-bound work sessions from `sessions_spawn` and ACP thread spawns. Defaults to `true` when thread bindings are enabled; providers/accounts can override.
- `defaultSpawnContext`: default native subagent context for thread-bound spawns (`"fork"` or `"isolated"`). Defaults to `"fork"`. - `defaultSpawnContext`: default native subagent context for thread-bound spawns (`"fork"` or `"isolated"`). Defaults to `"fork"`.
- **`sharing`**: controls which per-session collaboration modes owners and `operator.admin` connections may select. Every flag defaults to `true`; setting one to `false` removes that choice from the Control UI and makes `session.visibility.set` reject it. New sessions always start `shared`. - **`sharing`**: controls which per-session collaboration modes owners and `operator.admin` connections may select. Every flag defaults to `true`; setting one to `false` removes that choice from the Control UI and makes create-time visibility or `session.visibility.set` reject it. New sessions start `shared` unless the Control UI starts one as a draft.
- `readOnly`: allow `read-only`, where non-members can watch but cannot send, steer, abort, approve, or mutate session state. - `readOnly`: allow `read-only`, where non-members can watch but cannot send, steer, abort, approve, or mutate session state.
- `suggest`: allow `suggest`. In this phase it enforces the same admission behavior as `read-only`; the suggestion queue is a later feature. - `suggest`: allow `suggest`. In this phase it enforces the same admission behavior as `read-only`; the suggestion queue is a later feature.
- `drafts`: allow `draft`, which hides the session from non-admin, non-owner session lists and event broadcasts. - `drafts`: allow `draft`, which hides the session from non-admin, non-owner session lists and event broadcasts.
@@ -3,6 +3,7 @@ import type { Static } from "typebox";
import { Type } from "typebox"; import { Type } from "typebox";
import { closedObject } from "./closed-object.js"; import { closedObject } from "./closed-object.js";
import { GatewayClientIdSchema, GatewayClientModeSchema, NonEmptyString } from "./primitives.js"; import { GatewayClientIdSchema, GatewayClientModeSchema, NonEmptyString } from "./primitives.js";
import { SessionVisibilitySchema } from "./sessions-sharing-values.js";
import { SnapshotSchema, StateVersionSchema } from "./snapshot.js"; import { SnapshotSchema, StateVersionSchema } from "./snapshot.js";
export const GATEWAY_SERVER_CAPS = { export const GATEWAY_SERVER_CAPS = {
@@ -137,6 +138,8 @@ export const HelloOkSchema = closedObject({
maxPayload: Type.Integer({ minimum: 1 }), maxPayload: Type.Integer({ minimum: 1 }),
maxBufferedBytes: Type.Integer({ minimum: 1 }), maxBufferedBytes: Type.Integer({ minimum: 1 }),
tickIntervalMs: Type.Integer({ minimum: 1 }), tickIntervalMs: Type.Integer({ minimum: 1 }),
allowedSessionVisibilities: Type.Optional(Type.Array(SessionVisibilitySchema)),
hasMultipleSessionSharingIdentities: Type.Optional(Type.Boolean()),
}), }),
}); });
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { validateSessionsCreateParams } from "../index.js";
describe("sessions.create schema", () => {
it("accepts additive create-time visibility values", () => {
for (const visibility of ["shared", "read-only", "suggest", "draft"]) {
expect(validateSessionsCreateParams({ agentId: "main", visibility })).toBe(true);
}
});
it("rejects unknown visibility values", () => {
expect(validateSessionsCreateParams({ agentId: "main", visibility: "private" })).toBe(false);
});
});
@@ -2,6 +2,7 @@ import { Type } from "typebox";
import { closedObject } from "./closed-object.js"; import { closedObject } from "./closed-object.js";
import { ChatAttachmentsSchema } from "./logs-chat.js"; import { ChatAttachmentsSchema } from "./logs-chat.js";
import { NonEmptyString, SessionLabelString } from "./primitives.js"; import { NonEmptyString, SessionLabelString } from "./primitives.js";
import { SessionVisibilitySchema } from "./sessions-sharing-values.js";
/** Creates or adopts a session with optional model, thinking, label, and parent linkage. */ /** Creates or adopts a session with optional model, thinking, label, and parent linkage. */
export const SessionsCreateParamsSchema = closedObject({ export const SessionsCreateParamsSchema = closedObject({
@@ -11,6 +12,7 @@ export const SessionsCreateParamsSchema = closedObject({
model: Type.Optional(NonEmptyString), model: Type.Optional(NonEmptyString),
thinkingLevel: Type.Optional(NonEmptyString), thinkingLevel: Type.Optional(NonEmptyString),
incognito: Type.Optional(Type.Boolean()), incognito: Type.Optional(Type.Boolean()),
visibility: Type.Optional(SessionVisibilitySchema),
catalogId: Type.Optional(NonEmptyString), catalogId: Type.Optional(NonEmptyString),
parentSessionKey: Type.Optional(NonEmptyString), parentSessionKey: Type.Optional(NonEmptyString),
spawnDepth: Type.Optional( spawnDepth: Type.Optional(
@@ -337,6 +337,7 @@ export const sessionCreateHandlers: GatewayRequestHandlers = {
thinkingLevel: p.thinkingLevel, thinkingLevel: p.thinkingLevel,
incognito: p.incognito, incognito: p.incognito,
...(client?.connect ? { requestingOperatorScopes: clientScopes } : {}), ...(client?.connect ? { requestingOperatorScopes: clientScopes } : {}),
visibility: p.visibility,
allowExistingModelSelection, allowExistingModelSelection,
parentSessionKey: p.parentSessionKey, parentSessionKey: p.parentSessionKey,
spawnDepth: p.spawnDepth, spawnDepth: p.spawnDepth,
@@ -161,6 +161,10 @@ export function registerDefaultAuthTokenSuite(): void {
type?: unknown; type?: unknown;
features?: { capabilities?: unknown }; features?: { capabilities?: unknown };
snapshot?: { configPath?: string; stateDir?: string }; snapshot?: { configPath?: string; stateDir?: string };
policy?: {
allowedSessionVisibilities?: unknown;
hasMultipleSessionSharingIdentities?: unknown;
};
} }
| undefined; | undefined;
expect(payload?.type).toBe("hello-ok"); expect(payload?.type).toBe("hello-ok");
@@ -175,10 +179,37 @@ export function registerDefaultAuthTokenSuite(): void {
); );
expect(payload?.snapshot?.configPath).toBe(createConfigIO().configPath); expect(payload?.snapshot?.configPath).toBe(createConfigIO().configPath);
expect(payload?.snapshot?.stateDir).toBe(STATE_DIR); expect(payload?.snapshot?.stateDir).toBe(STATE_DIR);
expect(payload?.policy?.allowedSessionVisibilities).toEqual([
"shared",
"read-only",
"suggest",
"draft",
]);
expect(payload?.policy?.hasMultipleSessionSharingIdentities).toBe(false);
ws.close(); ws.close();
}); });
test("hello policy counts canonical session-sharing identities", async () => {
const { ensureProfileForEmail, linkEmail } = await import("../state/user-profiles.js");
const suffix = `${process.pid}-${Date.now()}`;
ensureProfileForEmail(`hello-a-${suffix}@example.invalid`);
const target = ensureProfileForEmail(`hello-b-${suffix}@example.invalid`);
ensureProfileForEmail(`hello-merged-${suffix}@example.invalid`);
linkEmail(`hello-merged-${suffix}@example.invalid`, target.id);
const ws = await openWs(port);
try {
const res = await connectReq(ws);
const payload = res.payload as
| { policy?: { hasMultipleSessionSharingIdentities?: unknown } }
| undefined;
expect(payload?.policy?.hasMultipleSessionSharingIdentities).toBe(true);
} finally {
ws.close();
}
});
test("connect (req) handshake resolves server version from runtime precedence", async () => { test("connect (req) handshake resolves server version from runtime precedence", async () => {
const { VERSION } = await import("../version.js"); const { VERSION } = await import("../version.js");
for (const testCase of [ for (const testCase of [
+124
View File
@@ -555,6 +555,109 @@ function requireNonEmptyString(value: string | undefined, label: string): string
return value; return value;
} }
test("sessions.create persists draft visibility in the initial session entry", async () => {
const { storePath } = await createSessionStoreDir();
const created = await directSessionReq<{
key: string;
entry: { visibility?: string };
}>("sessions.create", { agentId: "main", visibility: "draft" });
expect(created.ok).toBe(true);
expect(created.payload?.entry.visibility).toBe("draft");
const key = requireNonEmptyString(created.payload?.key, "created session key");
expect(loadSessionEntry({ agentId: "main", sessionKey: key, storePath })?.visibility).toBe(
"draft",
);
const listed = await directSessionReq<{
sessions?: Array<{ key: string; visibility?: string }>;
}>("sessions.list", {});
expect(listed.payload?.sessions?.find((row) => row.key === key)?.visibility).toBe("draft");
});
test("sessions.create keeps omitted visibility on the prior shared default", async () => {
const { storePath } = await createSessionStoreDir();
const created = await directSessionReq<{
key: string;
entry: { visibility?: string };
}>("sessions.create", { agentId: "main" });
expect(created.ok).toBe(true);
expect(created.payload?.entry.visibility).toBeUndefined();
const key = requireNonEmptyString(created.payload?.key, "created session key");
expect(
loadSessionEntry({ agentId: "main", sessionKey: key, storePath })?.visibility,
).toBeUndefined();
const listed = await directSessionReq<{
sessions?: Array<{ key: string; visibility?: string }>;
}>("sessions.list", {});
expect(listed.payload?.sessions?.find((row) => row.key === key)?.visibility).toBe("shared");
});
test("sessions.create preserves keyed draft adoption idempotency", async () => {
await createSessionStoreDir();
const key = "agent:main:dashboard:idempotent-draft";
const first = await directSessionReq<{
sessionId: string;
entry: { visibility?: string };
}>("sessions.create", { agentId: "main", key, visibility: "draft" });
expect(first.ok).toBe(true);
const retried = await directSessionReq<{
sessionId: string;
entry: { visibility?: string };
}>("sessions.create", { agentId: "main", key, visibility: "draft" });
expect(retried).toMatchObject({
ok: true,
payload: {
sessionId: first.payload?.sessionId,
entry: { visibility: "draft" },
},
});
testState.sessionConfig = { sharing: { drafts: false } };
const retriedAfterPolicyChange = await directSessionReq<{
sessionId: string;
entry: { visibility?: string };
}>("sessions.create", { agentId: "main", key, visibility: "draft" });
expect(retriedAfterPolicyChange).toMatchObject({
ok: true,
payload: {
sessionId: first.payload?.sessionId,
entry: { visibility: "draft" },
},
});
const mismatch = await directSessionReq("sessions.create", {
agentId: "main",
key,
visibility: "shared",
});
expect(mismatch).toMatchObject({
ok: false,
error: {
code: "INVALID_REQUEST",
message: "sessions.create visibility requires a new session",
},
});
});
test("sessions.create rejects draft visibility when policy disables drafts", async () => {
testState.sessionConfig = { sharing: { drafts: false } };
const created = await directSessionReq("sessions.create", {
agentId: "main",
visibility: "draft",
});
expect(created).toMatchObject({
ok: false,
error: {
code: "INVALID_REQUEST",
message: "session visibility is disabled: draft",
details: { code: "SESSION_VISIBILITY_DISABLED", visibility: "draft" },
},
});
});
test("sessions.create provisions and reuses a session worktree for later runs", async () => { test("sessions.create provisions and reuses a session worktree for later runs", async () => {
const root = await fs.mkdtemp( const root = await fs.mkdtemp(
path.join(await fs.realpath(os.tmpdir()), "openclaw-session-worktree-"), path.join(await fs.realpath(os.tmpdir()), "openclaw-session-worktree-"),
@@ -816,6 +919,27 @@ test("sessions.create reset-in-place clears a prior node binding for Gateway exe
expect(gatewaySession.payload?.entry.execCwd).toBeUndefined(); expect(gatewaySession.payload?.entry.execCwd).toBeUndefined();
}); });
test("sessions.create does not apply create-time visibility to an in-place reset", async () => {
testState.sessionConfig = { dmScope: "main" };
await createSessionStoreDir();
await writeSessionStore({ entries: { main: sessionStoreEntry("sess-existing-main") } });
const reset = await directSessionReq("sessions.create", {
agentId: "main",
parentSessionKey: "main",
emitCommandHooks: true,
visibility: "draft",
});
expect(reset).toMatchObject({
ok: false,
error: {
code: "INVALID_REQUEST",
message: "sessions.create visibility requires a new session",
},
});
});
test("sessions.create rejects a Gateway worktree targeting a node", async () => { test("sessions.create rejects a Gateway worktree targeting a node", async () => {
await createSessionStoreDir(); await createSessionStoreDir();
const created = await directSessionReq( const created = await directSessionReq(
@@ -12,6 +12,7 @@ import {
finalizeNodePairingCleanupClaim, finalizeNodePairingCleanupClaim,
recordPairedNodeConnection, recordPairedNodeConnection,
} from "../../../infra/node-pairing.js"; } from "../../../infra/node-pairing.js";
import { listProfiles } from "../../../state/user-profiles.js";
import { resolveRuntimeServiceVersion } from "../../../version.js"; import { resolveRuntimeServiceVersion } from "../../../version.js";
import { import {
listControlUiPluginTabs, listControlUiPluginTabs,
@@ -21,6 +22,7 @@ import { ADMIN_SCOPE } from "../../method-scopes.js";
import { scheduleNodeConnectionNotification } from "../../node-connection-notifications.js"; import { scheduleNodeConnectionNotification } from "../../node-connection-notifications.js";
import { MAX_BUFFERED_BYTES, MAX_PAYLOAD_BYTES, TICK_INTERVAL_MS } from "../../server-constants.js"; import { MAX_BUFFERED_BYTES, MAX_PAYLOAD_BYTES, TICK_INTERVAL_MS } from "../../server-constants.js";
import { formatError } from "../../server-utils.js"; import { formatError } from "../../server-utils.js";
import { allowedSessionVisibilities } from "../../session-sharing.js";
import { formatForLog, logWs } from "../../ws-log.js"; import { formatForLog, logWs } from "../../ws-log.js";
import { buildGatewaySnapshot, getHealthCache, getHealthVersion } from "../health-state.js"; import { buildGatewaySnapshot, getHealthCache, getHealthVersion } from "../health-state.js";
import { emitGatewayAuthSecurityEvent } from "./connect-auth-security.js"; import { emitGatewayAuthSecurityEvent } from "./connect-auth-security.js";
@@ -122,6 +124,9 @@ export async function sendGatewayHello(
maxPayload: MAX_PAYLOAD_BYTES, maxPayload: MAX_PAYLOAD_BYTES,
maxBufferedBytes: MAX_BUFFERED_BYTES, maxBufferedBytes: MAX_BUFFERED_BYTES,
tickIntervalMs: TICK_INTERVAL_MS, tickIntervalMs: TICK_INTERVAL_MS,
allowedSessionVisibilities: allowedSessionVisibilities(context.configSnapshot),
hasMultipleSessionSharingIdentities:
listProfiles().filter((profile) => !profile.mergedInto).length >= 2,
}, },
}; };
advanceHandshakePhase("hello_payload_prepared"); advanceHandshakePhase("hello_payload_prepared");
+42
View File
@@ -6,6 +6,7 @@ import {
import { import {
ErrorCodes, ErrorCodes,
type ErrorShape, type ErrorShape,
type SessionVisibility,
errorShape, errorShape,
missingScopeErrorShape, missingScopeErrorShape,
} from "../../packages/gateway-protocol/src/index.js"; } from "../../packages/gateway-protocol/src/index.js";
@@ -70,6 +71,7 @@ import { createLazyRuntimeModule } from "../shared/lazy-runtime.js";
import { ADMIN_SCOPE } from "./operator-scopes.js"; import { ADMIN_SCOPE } from "./operator-scopes.js";
import { buildForkedGatewaySessionEntry } from "./session-create-fork-entry.js"; import { buildForkedGatewaySessionEntry } from "./session-create-fork-entry.js";
import { shouldPreserveSessionAuthProfileOverride } from "./session-model-patch-origin.js"; import { shouldPreserveSessionAuthProfileOverride } from "./session-model-patch-origin.js";
import { isSessionVisibilityAllowed, resolveSessionVisibility } from "./session-sharing.js";
import { resolveSessionStoreAgentId, resolveSessionStoreKey } from "./session-store-key.js"; import { resolveSessionStoreAgentId, resolveSessionStoreKey } from "./session-store-key.js";
import { loadSessionEntryReadOnly, resolveGatewaySessionStoreTarget } from "./session-utils.js"; import { loadSessionEntryReadOnly, resolveGatewaySessionStoreTarget } from "./session-utils.js";
import { applySessionsPatchToStore, resolveSessionPatchModelSelection } from "./sessions-patch.js"; import { applySessionsPatchToStore, resolveSessionPatchModelSelection } from "./sessions-patch.js";
@@ -272,6 +274,7 @@ export async function createGatewaySession(params: {
model?: string; model?: string;
thinkingLevel?: string; thinkingLevel?: string;
incognito?: boolean; incognito?: boolean;
visibility?: SessionVisibility;
/** Trusted catalog-owned model/runtime pair, persisted and locked together. */ /** Trusted catalog-owned model/runtime pair, persisted and locked together. */
catalogTarget?: TrustedCatalogSessionTarget; catalogTarget?: TrustedCatalogSessionTarget;
parentSessionKey?: string; parentSessionKey?: string;
@@ -615,6 +618,15 @@ export async function createGatewaySession(params: {
); );
const parentMainKey = resolveAgentMainSessionKey({ cfg: params.cfg, agentId: parentAgentId }); const parentMainKey = resolveAgentMainSessionKey({ cfg: params.cfg, agentId: parentAgentId });
if (canonicalParentSessionKey === parentMainKey) { if (canonicalParentSessionKey === parentMainKey) {
if (params.visibility) {
return {
ok: false,
error: errorShape(
ErrorCodes.INVALID_REQUEST,
"sessions.create visibility requires a new session",
),
};
}
const { performGatewaySessionReset } = await loadSessionLifecycleRuntime(); const { performGatewaySessionReset } = await loadSessionLifecycleRuntime();
const spawnedCwd = normalizeOptionalString(params.spawnedCwd); const spawnedCwd = normalizeOptionalString(params.spawnedCwd);
const execCwd = normalizeOptionalString(params.execCwd); const execCwd = normalizeOptionalString(params.execCwd);
@@ -742,6 +754,8 @@ export async function createGatewaySession(params: {
storePath: target.storePath, storePath: target.storePath,
}, },
async ({ existingEntry, sessionEntries }) => { async ({ existingEntry, sessionEntries }) => {
// This callback owns generated and explicit keys alike; no existing row
// is the canonical signal that this request will actually create one.
if ( if (
isAgentHarnessSessionKey(target.canonicalKey) && isAgentHarnessSessionKey(target.canonicalKey) &&
!authorizedHarnessCreation && !authorizedHarnessCreation &&
@@ -782,6 +796,33 @@ export async function createGatewaySession(params: {
), ),
}; };
} }
if (
params.visibility &&
existingEntry === undefined &&
!isSessionVisibilityAllowed(params.cfg, params.visibility)
) {
return {
ok: false,
error: errorShape(
ErrorCodes.INVALID_REQUEST,
`session visibility is disabled: ${params.visibility}`,
{ details: { code: "SESSION_VISIBILITY_DISABLED", visibility: params.visibility } },
),
};
}
if (
params.visibility &&
existingEntry !== undefined &&
resolveSessionVisibility(existingEntry) !== params.visibility
) {
return {
ok: false,
error: errorShape(
ErrorCodes.INVALID_REQUEST,
"sessions.create visibility requires a new session",
),
};
}
// Adoption of an existing key must not stamp provenance or emit a // Adoption of an existing key must not stamp provenance or emit a
// `created` event; only a genuinely new row is a node creation. // `created` event; only a genuinely new row is a node creation.
createdNewEntry = existingEntry === undefined; createdNewEntry = existingEntry === undefined;
@@ -873,6 +914,7 @@ export async function createGatewaySession(params: {
// must not restamp write-once node facts (this direct store write bypasses // must not restamp write-once node facts (this direct store write bypasses
// the merge-level write-once guard), and legacy rows stay "unknown". // the merge-level write-once guard), and legacy rows stay "unknown".
...(params.creation && createdNewEntry ? buildSessionCreationStamp(params.creation) : {}), ...(params.creation && createdNewEntry ? buildSessionCreationStamp(params.creation) : {}),
...(params.visibility && createdNewEntry ? { visibility: params.visibility } : {}),
...(catalogResolvedModel && catalogAgentRuntime ...(catalogResolvedModel && catalogAgentRuntime
? { ? {
providerOverride: catalogResolvedModel.provider, providerOverride: catalogResolvedModel.provider,
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import type { GatewaySessionRow } from "../api/types.ts";
import { buildSidebarSessionNavigationState } from "./app-sidebar-session-navigation-logic.ts";
function projectDraftOwnership(
row: Pick<GatewaySessionRow, "createdActor" | "sharingRole" | "visibility">,
selfUserId?: string,
): boolean | undefined {
const context = {
basePath: "",
agentSelection: { state: { selectedId: "main" } },
gateway: {
snapshot: {
assistantAgentId: "main",
hello: null,
selfUser: selfUserId ? { id: selfUserId } : undefined,
},
},
sessions: { pullRequestSummary: () => undefined },
} as unknown as Parameters<typeof buildSidebarSessionNavigationState>[0]["context"];
const navigation = buildSidebarSessionNavigationState({
context,
routeSessionKey: "agent:main:main",
sessionsResult: null,
sessionsAgentId: null,
showCron: false,
statusFilter: "active",
compareSessions: () => 0,
highlightCurrentSession: false,
runtimeSampledAtByRow: new WeakMap(),
loadingChildSessionKeys: new Set(),
outboxCountForSessionKey: () => 0,
resolveAttention: () => ({ kind: "none" }),
resolveAgentStatusNote: () => undefined,
});
return navigation.toSidebarSession({
key: "agent:main:draft",
kind: "direct",
updatedAt: 1,
...row,
}).draftOwnedBySelf;
}
describe("sidebar draft ownership presentation", () => {
it("keeps owner drafts at normal emphasis", () => {
expect(
projectDraftOwnership({
visibility: "draft",
sharingRole: "owner",
createdActor: undefined,
}),
).toBe(true);
});
it("distinguishes an admin's own draft from another person's draft", () => {
const ownDraft = {
visibility: "draft" as const,
sharingRole: "admin" as const,
createdActor: { type: "human" as const, id: "admin" },
};
expect(projectDraftOwnership(ownDraft, "admin")).toBe(true);
expect(projectDraftOwnership(ownDraft, "teammate")).toBe(false);
});
it("never marks a shared session as an owned draft", () => {
expect(
projectDraftOwnership(
{
visibility: "shared",
sharingRole: "owner",
createdActor: { type: "human", id: "owner" },
},
"owner",
),
).toBe(false);
});
});
@@ -49,6 +49,16 @@ import { isStoppableCloudWorkerPlacement } from "./session-row-badges.ts";
type SessionRow = SessionsListResult["sessions"][number]; type SessionRow = SessionsListResult["sessions"][number];
function isSidebarDraftOwnedBySelf(
row: Pick<SessionRow, "createdActor" | "sharingRole" | "visibility">,
selfUserId: string | undefined,
): boolean {
return (
row.visibility === "draft" &&
(row.sharingRole === "owner" || Boolean(selfUserId && row.createdActor?.id === selfUserId))
);
}
export type SidebarSessionNavigationState = { export type SidebarSessionNavigationState = {
routeSessionKey: string; routeSessionKey: string;
selectedAgentId: string; selectedAgentId: string;
@@ -113,6 +123,7 @@ export function buildSidebarSessionNavigationState(input: {
pinned: row.pinned === true, pinned: row.pinned === true,
archived: row.archived === true, archived: row.archived === true,
visibility: row.visibility, visibility: row.visibility,
draftOwnedBySelf: isSidebarDraftOwnedBySelf(row, context?.gateway.snapshot.selfUser?.id),
icon: row.icon, icon: row.icon,
category: normalizeOptionalString(row.category), category: normalizeOptionalString(row.category),
channel: channelInfo.channel, channel: channelInfo.channel,
@@ -169,6 +169,11 @@ export function renderRecentSession(params: {
session.pinned ? "session-row-host--pinned" : "", session.pinned ? "session-row-host--pinned" : "",
running ? "session-row-host--running" : "", running ? "session-row-host--running" : "",
session.visibility === "draft" ? "session-row-host--draft" : "", session.visibility === "draft" ? "session-row-host--draft" : "",
session.visibility === "draft"
? session.draftOwnedBySelf
? "session-row-host--draft-owner"
: "session-row-host--draft-other"
: "",
session.attention.kind === "error" session.attention.kind === "error"
? "sidebar-recent-session--attention-danger" ? "sidebar-recent-session--attention-danger"
: session.attention.kind !== "none" : session.attention.kind !== "none"
@@ -69,6 +69,7 @@ export type SidebarRecentSession = {
pinned: boolean; pinned: boolean;
archived?: boolean; archived?: boolean;
visibility?: SessionVisibility; visibility?: SessionVisibility;
draftOwnedBySelf?: boolean;
icon?: string; icon?: string;
category?: string; category?: string;
channel?: string; channel?: string;
+210
View File
@@ -1,4 +1,6 @@
// Control UI E2E tests cover session ownership dormancy and creator filtering. // Control UI E2E tests cover session ownership dormancy and creator filtering.
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { chromium, type Browser, type Page } from "playwright"; import { chromium, type Browser, type Page } from "playwright";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { import {
@@ -13,6 +15,8 @@ const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.
const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath); const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1"; const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip; const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
const captureUiProofEnabled = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
const uiProofArtifactDir = path.join(process.cwd(), ".artifacts", "control-ui-e2e", "drafts-ux");
let browser: Browser; let browser: Browser;
let page: Page | undefined; let page: Page | undefined;
@@ -54,6 +58,38 @@ function sessionsList(creators: [string, string]) {
}; };
} }
function draftSessionsList() {
const result = sessionsList(["profile-ada", "profile-bob"]);
for (const session of result.sessions) {
Object.assign(session, { visibility: "draft", sharingRole: "admin" });
}
return result;
}
async function captureUiProof(targetPage: Page, fileName: string) {
if (!captureUiProofEnabled) {
return;
}
await mkdir(uiProofArtifactDir, { recursive: true });
await targetPage.screenshot({
animations: "disabled",
fullPage: true,
path: path.join(uiProofArtifactDir, fileName),
});
}
async function replaceGatewayClient(targetPage: Page) {
await targetPage.evaluate(() => {
const app = document.querySelector("openclaw-app") as HTMLElement & {
runtime?: { context: { gateway: { connect: () => void } } };
};
if (!app.runtime) {
throw new Error("OpenClaw application runtime is unavailable");
}
app.runtime.context.gateway.connect();
});
}
describeControlUiE2e("Control UI session ownership", () => { describeControlUiE2e("Control UI session ownership", () => {
beforeAll(async () => { beforeAll(async () => {
browser = await chromium.launch({ executablePath: chromiumExecutablePath }); browser = await chromium.launch({ executablePath: chromiumExecutablePath });
@@ -132,4 +168,178 @@ describeControlUiE2e("Control UI session ownership", () => {
expect(await currentPage.getByLabel("Filter by creator").count()).toBe(0); expect(await currentPage.getByLabel("Filter by creator").count()).toBe(0);
expect(await currentPage.locator("openclaw-session-owner-chip").count()).toBe(0); expect(await currentPage.locator("openclaw-session-owner-chip").count()).toBe(0);
}); });
it("keeps own drafts subtle and fades admin-visible drafts from other people", async () => {
if (captureUiProofEnabled) {
await mkdir(uiProofArtifactDir, { recursive: true });
}
const context = await browser.newContext({
viewport: { height: 800, width: 1200 },
...(captureUiProofEnabled
? { recordVideo: { dir: uiProofArtifactDir, size: { height: 800, width: 1200 } } }
: {}),
});
const currentPage = await context.newPage();
page = currentPage;
await installMockGateway(currentPage, {
sessionKey: "agent:main:ada",
presenceUsers: [{ self: true, id: "profile-ada", name: "Ada" }],
methodResponses: { "sessions.list": draftSessionsList() },
});
await currentPage.goto(`${server?.baseUrl ?? ""}chat`);
const ownDraft = currentPage.locator('[data-session-key="agent:main:ada"]');
const otherDraft = currentPage.locator('[data-session-key="agent:main:bob"]');
await ownDraft.waitFor();
await otherDraft.waitFor();
await expect
.poll(() => ownDraft.getAttribute("class"))
.toContain("session-row-host--draft-owner");
await expect
.poll(() => otherDraft.getAttribute("class"))
.toContain("session-row-host--draft-other");
expect(await currentPage.locator(".session-row-draft-indicator").count()).toBe(2);
await captureUiProof(currentPage, "01-sidebar-draft-treatment.png");
await currentPage.evaluate(() =>
document.documentElement.setAttribute("data-theme-mode", "dark"),
);
await captureUiProof(currentPage, "01-sidebar-draft-treatment-dark.png");
});
it("creates a draft atomically from the multi-person new-session flow", async () => {
if (captureUiProofEnabled) {
await mkdir(uiProofArtifactDir, { recursive: true });
}
const context = await browser.newContext({
viewport: { height: 800, width: 1200 },
...(captureUiProofEnabled
? { recordVideo: { dir: uiProofArtifactDir, size: { height: 800, width: 1200 } } }
: {}),
});
const currentPage = await context.newPage();
page = currentPage;
const gateway = await installMockGateway(currentPage, {
allowedSessionVisibilities: ["shared", "draft"],
hasMultipleSessionSharingIdentities: true,
methodResponses: {
"sessions.list": sessionsList(["profile-ada", "profile-bob"]),
"sessions.create": { key: "agent:main:new-draft", runStarted: true },
},
});
await currentPage.goto(`${server?.baseUrl ?? ""}new`);
const draftToggle = currentPage.getByLabel("Start as draft");
await draftToggle.waitFor();
await captureUiProof(currentPage, "02-create-draft-available.png");
await draftToggle.check();
await currentPage.locator(".new-session-page__message").fill("work privately first");
await captureUiProof(currentPage, "03-create-draft-selected.png");
await currentPage.getByRole("button", { name: "Start thread" }).click();
const create = await gateway.waitForRequest("sessions.create");
expect(create.params).toMatchObject({
agentId: "main",
message: "work privately first",
visibility: "draft",
});
});
it("publishes a draft through the header sharing menu", async () => {
if (captureUiProofEnabled) {
await mkdir(uiProofArtifactDir, { recursive: true });
}
const context = await browser.newContext({
viewport: { height: 800, width: 1200 },
...(captureUiProofEnabled
? { recordVideo: { dir: uiProofArtifactDir, size: { height: 800, width: 1200 } } }
: {}),
});
const currentPage = await context.newPage();
page = currentPage;
const sessions = draftSessionsList();
const ownerSession = sessions.sessions[0];
if (!ownerSession) {
throw new Error("expected owner draft fixture");
}
Object.assign(ownerSession, { sharingRole: "owner" });
const gateway = await installMockGateway(currentPage, {
sessionKey: "agent:main:ada",
featureMethods: ["chat.metadata", "chat.startup", "session.visibility.set"],
historyMessages: [{ role: "assistant", content: [{ type: "text", text: "Ready." }] }],
methodResponses: {
"sessions.list": sessions,
"session.members.list": {
sessionKey: "agent:main:ada",
members: [],
identities: [],
role: "owner",
allowedVisibilities: ["shared", "draft"],
},
"session.visibility.set": {
ok: true,
sessionKey: "agent:main:ada",
visibility: "shared",
},
},
});
await currentPage.goto(`${server?.baseUrl ?? ""}chat`);
await currentPage.getByText("Ready.", { exact: true }).waitFor();
await currentPage.getByLabel("Thread sharing").click();
const publish = currentPage.getByText("Publish draft", { exact: true });
await publish.waitFor();
await captureUiProof(currentPage, "04-publish-draft-action.png");
await publish.click();
const request = await gateway.waitForRequest("session.visibility.set");
expect(request.params).toMatchObject({
sessionKey: "agent:main:ada",
visibility: "shared",
});
expect(await gateway.getRequests("session.visibility.set")).toHaveLength(1);
});
it("clears a selected draft mode when sharing policy becomes unavailable", async () => {
const context = await browser.newContext({ viewport: { height: 800, width: 1200 } });
const currentPage = await context.newPage();
page = currentPage;
const gateway = await installMockGateway(currentPage, {
allowedSessionVisibilities: ["shared", "draft"],
hasMultipleSessionSharingIdentities: true,
methodResponses: { "sessions.list": sessionsList(["profile-ada", "profile-bob"]) },
});
await currentPage.goto(`${server?.baseUrl ?? ""}new`);
const draftToggle = currentPage.getByLabel("Start as draft");
await draftToggle.check();
await gateway.setSessionSharingPolicy({
allowedSessionVisibilities: ["shared"],
hasMultipleSessionSharingIdentities: false,
});
await replaceGatewayClient(currentPage);
await expect.poll(() => draftToggle.count()).toBe(0);
await gateway.setSessionSharingPolicy({
allowedSessionVisibilities: ["shared", "draft"],
hasMultipleSessionSharingIdentities: true,
});
await replaceGatewayClient(currentPage);
await draftToggle.waitFor();
expect(await draftToggle.isChecked()).toBe(false);
});
it("keeps create-as-draft dormant for one creator", async () => {
const context = await browser.newContext({ viewport: { height: 800, width: 1200 } });
const currentPage = await context.newPage();
page = currentPage;
await installMockGateway(currentPage, {
allowedSessionVisibilities: ["shared", "draft"],
hasMultipleSessionSharingIdentities: false,
methodResponses: { "sessions.list": sessionsList(["profile-ada", "profile-ada"]) },
});
await currentPage.goto(`${server?.baseUrl ?? ""}new`);
await currentPage.locator(".new-session-page__message").waitFor();
expect(await currentPage.getByLabel("Start as draft").count()).toBe(0);
});
}); });
+2
View File
@@ -568,6 +568,7 @@ export const en: TranslationMap = {
worktreeNameInvalid: "Worktree names use lowercase letters, digits, and dashes.", worktreeNameInvalid: "Worktree names use lowercase letters, digits, and dashes.",
incognito: "Incognito", incognito: "Incognito",
incognitoDescription: "Keep this thread only until the Gateway restarts", incognitoDescription: "Keep this thread only until the Gateway restarts",
startAsDraft: "Start as draft",
messagePlaceholder: "What should this thread work on?", messagePlaceholder: "What should this thread work on?",
readingAttachment: "Reading attachment", readingAttachment: "Reading attachment",
start: "Start thread", start: "Start thread",
@@ -3774,6 +3775,7 @@ export const en: TranslationMap = {
readOnly: "Read-only", readOnly: "Read-only",
suggest: "Suggest", suggest: "Suggest",
draft: "Draft", draft: "Draft",
publishDraft: "Publish draft",
members: "Members", members: "Members",
selected: "Member", selected: "Member",
noPeople: "No paired people found.", noPeople: "No paired people found.",
@@ -89,4 +89,42 @@ describe("chat session sharing menu", () => {
expect(root.querySelector("wa-dropdown")).toBeNull(); expect(root.querySelector("wa-dropdown")).toBeNull();
expect(root.querySelector(".chat-pane__draft-indicator")?.textContent).toContain("👻"); expect(root.querySelector(".chat-pane__draft-indicator")?.textContent).toContain("👻");
}); });
it("publishes a manageable draft through the shared visibility callback", () => {
const onVisibilityChange = vi.fn();
const root = mount(
renderChatSessionSharing({
session: {
key: "agent:main:draft",
kind: "direct",
updatedAt: 1,
visibility: "draft",
sharingRole: "owner",
},
state: {
loading: false,
result: {
sessionKey: "agent:main:draft",
members: [],
identities: [],
role: "owner",
allowedVisibilities: ["shared", "draft"],
},
},
onOpen: vi.fn(),
onVisibilityChange,
onMemberChange: vi.fn(),
}),
);
const publish = root.querySelector<HTMLElement>(".chat-pane__publish-draft");
expect(publish?.textContent).toContain("Publish draft");
root.querySelector("wa-dropdown")?.dispatchEvent(
new CustomEvent("wa-select", {
detail: { item: { value: publish?.getAttribute("value") } },
}),
);
expect(onVisibilityChange).toHaveBeenCalledWith("shared");
expect(root.querySelectorAll('wa-dropdown-item[value="visibility:shared"]')).toHaveLength(1);
});
}); });
@@ -56,6 +56,7 @@ export function renderChatSessionSharing(props: ChatSessionSharingProps) {
const identities = const identities =
result?.identities.filter((identity) => identity.id !== result.owner?.id) ?? []; result?.identities.filter((identity) => identity.id !== result.owner?.id) ?? [];
const allowed = result?.allowedVisibilities ?? [visibility]; const allowed = result?.allowedVisibilities ?? [visibility];
const canPublish = visibility === "draft" && allowed.includes("shared");
return html` return html`
<wa-dropdown <wa-dropdown
class="chat-pane__sharing-menu" class="chat-pane__sharing-menu"
@@ -84,17 +85,26 @@ export function renderChatSessionSharing(props: ChatSessionSharingProps) {
> >
${sharingIcon(visibility)} ${sharingIcon(visibility)}
</button> </button>
${canPublish
? html`<wa-dropdown-item value="visibility:shared" class="chat-pane__publish-draft">
<span>${t("chat.sessionSharing.publishDraft")}</span>
<span slot="details" aria-hidden="true">${icons.users}</span>
</wa-dropdown-item>
<div class="session-menu__separator" role="separator"></div>`
: nothing}
<div class="chat-pane__sharing-title">${t("chat.sessionSharing.visibility")}</div> <div class="chat-pane__sharing-title">${t("chat.sessionSharing.visibility")}</div>
${allowed.map( ${allowed
(option) => html` .filter((option) => !canPublish || option !== "shared")
<wa-dropdown-item value=${`visibility:${option}`} ?disabled=${option === visibility}> .map(
<span>${t(VISIBILITY_LABEL_KEYS[option])}</span> (option) => html`
${option === visibility <wa-dropdown-item value=${`visibility:${option}`} ?disabled=${option === visibility}>
? html`<span slot="details" aria-hidden="true">${icons.check}</span>` <span>${t(VISIBILITY_LABEL_KEYS[option])}</span>
: nothing} ${option === visibility
</wa-dropdown-item> ? html`<span slot="details" aria-hidden="true">${icons.check}</span>`
`, : nothing}
)} </wa-dropdown-item>
`,
)}
<div class="session-menu__separator" role="separator"></div> <div class="session-menu__separator" role="separator"></div>
<div class="chat-pane__sharing-title">${t("chat.sessionSharing.members")}</div> <div class="chat-pane__sharing-title">${t("chat.sessionSharing.members")}</div>
${props.state?.loading ${props.state?.loading
+2 -1
View File
@@ -78,6 +78,7 @@ export function renderBar(params: {
data?: NewSessionRouteData; data?: NewSessionRouteData;
agentSelect: unknown; agentSelect: unknown;
placeSelect: unknown; placeSelect: unknown;
draftVisibilityControl: unknown;
retrying: boolean; retrying: boolean;
onRetry: () => void; onRetry: () => void;
}) { }) {
@@ -85,7 +86,7 @@ export function renderBar(params: {
return html` return html`
<div class="new-session-page__triggers"> <div class="new-session-page__triggers">
${renderTarget(params.data)} ${isTarget(params.data) ? nothing : params.agentSelect} ${renderTarget(params.data)} ${isTarget(params.data) ? nothing : params.agentSelect}
${params.placeSelect} ${params.placeSelect} ${params.draftVisibilityControl}
${pending ${pending
? html`<span class="new-session-page__catalog-unavailable"> ? html`<span class="new-session-page__catalog-unavailable">
${t("newSession.catalogUnavailable")} ${t("newSession.catalogUnavailable")}
+45 -1
View File
@@ -1,6 +1,35 @@
// @vitest-environment node // @vitest-environment node
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { buildDraftSessionCreateParams } from "./create-params.ts"; import { buildDraftSessionCreateParams, canStartSessionAsDraft } from "./create-params.ts";
describe("create-as-draft availability", () => {
it("requires both draft policy and multiple creator identities", () => {
expect(
canStartSessionAsDraft({
allowedVisibilities: ["shared", "draft"],
hasMultipleIdentities: true,
}),
).toBe(true);
expect(
canStartSessionAsDraft({
allowedVisibilities: ["shared"],
hasMultipleIdentities: true,
}),
).toBe(false);
expect(canStartSessionAsDraft({ hasMultipleIdentities: true })).toBe(false);
});
it("stays dormant when the gateway has fewer than two identities", () => {
for (const hasMultipleIdentities of [undefined, false]) {
expect(
canStartSessionAsDraft({
allowedVisibilities: ["shared", "draft"],
hasMultipleIdentities,
}),
).toBe(false);
}
});
});
describe("buildDraftSessionCreateParams", () => { describe("buildDraftSessionCreateParams", () => {
it("keeps plain chats minimal", () => { it("keeps plain chats minimal", () => {
@@ -28,6 +57,21 @@ describe("buildDraftSessionCreateParams", () => {
).toEqual({ agentId: "main", message: "private task", incognito: true }); ).toEqual({ agentId: "main", message: "private task", incognito: true });
}); });
it("adds draft visibility only when selected", () => {
expect(
buildDraftSessionCreateParams({
agentId: "main",
message: "private work in progress",
worktree: false,
startAsDraft: true,
}),
).toEqual({
agentId: "main",
message: "private work in progress",
visibility: "draft",
});
});
it("includes initial-message attachments", () => { it("includes initial-message attachments", () => {
const attachments = [ const attachments = [
{ type: "image", mimeType: "image/png", fileName: "pixel.png", content: "aGVsbG8=" }, { type: "image", mimeType: "image/png", fileName: "pixel.png", content: "aGVsbG8=" },
+11
View File
@@ -3,6 +3,15 @@ import { normalizeOptionalString } from "../../lib/string-coerce.ts";
const WORKTREE_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/; const WORKTREE_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
export function canStartSessionAsDraft(params: {
allowedVisibilities?: readonly string[];
hasMultipleIdentities?: boolean;
}): boolean {
return (
params.allowedVisibilities?.includes("draft") === true && params.hasMultipleIdentities === true
);
}
export function isWorktreeNameValid(value: string): boolean { export function isWorktreeNameValid(value: string): boolean {
const name = value.trim(); const name = value.trim();
return !name || WORKTREE_NAME_PATTERN.test(name); return !name || WORKTREE_NAME_PATTERN.test(name);
@@ -24,6 +33,7 @@ export function buildDraftSessionCreateParams(draft: {
workspace?: string; workspace?: string;
execNode?: string; execNode?: string;
catalogId?: string; catalogId?: string;
startAsDraft?: boolean;
}): Record<string, unknown> { }): Record<string, unknown> {
const cwd = normalizeOptionalString(draft.cwd); const cwd = normalizeOptionalString(draft.cwd);
const workspace = normalizeOptionalString(draft.workspace); const workspace = normalizeOptionalString(draft.workspace);
@@ -37,6 +47,7 @@ export function buildDraftSessionCreateParams(draft: {
agentId: normalizeAgentId(draft.agentId), agentId: normalizeAgentId(draft.agentId),
message: draft.message, message: draft.message,
...(draft.incognito ? { incognito: true } : {}), ...(draft.incognito ? { incognito: true } : {}),
...(draft.startAsDraft ? { visibility: "draft" } : {}),
...(draft.attachments?.length ? { attachments: draft.attachments } : {}), ...(draft.attachments?.length ? { attachments: draft.attachments } : {}),
...(catalogId ? { catalogId } : {}), ...(catalogId ? { catalogId } : {}),
...(!catalogId && model ? { model } : {}), ...(!catalogId && model ? { model } : {}),
+32 -2
View File
@@ -29,7 +29,11 @@ import { CloudProfileDiscovery, selectProfiles } from "./cloud-profile-discovery
import { PendingCloudRecoveryState, resolveScope } from "./cloud-recovery-state.ts"; import { PendingCloudRecoveryState, resolveScope } from "./cloud-recovery-state.ts";
import { advanceCloudDraftSession } from "./cloud-submit.ts"; import { advanceCloudDraftSession } from "./cloud-submit.ts";
import { renderDraftError, renderNewSessionDraftComposer } from "./composer.ts"; import { renderDraftError, renderNewSessionDraftComposer } from "./composer.ts";
import { buildDraftSessionCreateParams, isWorktreeNameValid } from "./create-params.ts"; import {
buildDraftSessionCreateParams,
canStartSessionAsDraft,
isWorktreeNameValid,
} from "./create-params.ts";
import { import {
type BrowserTarget, type BrowserTarget,
type DraftCloudProfile, type DraftCloudProfile,
@@ -43,7 +47,7 @@ import { NewSessionModelControl } from "./model-control.ts";
import { isAbsolutePath } from "./path.ts"; import { isAbsolutePath } from "./path.ts";
import { renderPlaceSelect } from "./place-picker.ts"; import { renderPlaceSelect } from "./place-picker.ts";
import { retainRejectedInitialTurn } from "./rejected-initial-turn.ts"; import { retainRejectedInitialTurn } from "./rejected-initial-turn.ts";
import { renderAgentSelect } from "./target-controls.ts"; import { renderAgentSelect, renderStartAsDraftToggle } from "./target-controls.ts";
const CATALOG_RETRY_DELAYS_MS = [0, 1_000, 3_000] as const; const CATALOG_RETRY_DELAYS_MS = [0, 1_000, 3_000] as const;
@@ -58,6 +62,7 @@ class NewSessionPage extends OpenClawLightDomElement {
@state() private worktree = false; @state() private worktree = false;
@state() private incognito = false; @state() private incognito = false;
@state() private worktreeName = ""; @state() private worktreeName = "";
@state() private startAsDraft = false;
@state() private baseRef = ""; @state() private baseRef = "";
@state() private repository: DraftRepositoryState = { kind: "idle" }; @state() private repository: DraftRepositoryState = { kind: "idle" };
@state() private nodes: DraftNode[] = []; @state() private nodes: DraftNode[] = [];
@@ -173,6 +178,9 @@ class NewSessionPage extends OpenClawLightDomElement {
this.gatewayRecoveryScope = recoveryScope.next; this.gatewayRecoveryScope = recoveryScope.next;
this.gatewayRecoveryScopeReady = snapshot.client?.recoveryScopeReady === true; this.gatewayRecoveryScopeReady = snapshot.client?.recoveryScopeReady === true;
this.gatewayConnected = connected; this.gatewayConnected = connected;
if (this.startAsDraft && !this.canStartAsDraft()) {
this.startAsDraft = false;
}
if (gatewayUrlChanged || identityChanged || connectionChanged || recoveryScope.changed) { if (gatewayUrlChanged || identityChanged || connectionChanged || recoveryScope.changed) {
this.invalidateGatewayDiscovery(gatewayUrlChanged || recoveryScope.changed); this.invalidateGatewayDiscovery(gatewayUrlChanged || recoveryScope.changed);
} }
@@ -235,6 +243,7 @@ class NewSessionPage extends OpenClawLightDomElement {
this.worktree = false; this.worktree = false;
this.incognito = false; this.incognito = false;
this.worktreeName = ""; this.worktreeName = "";
this.startAsDraft = false;
this.baseRefEditGeneration += 1; this.baseRefEditGeneration += 1;
this.nodes = []; this.nodes = [];
this.execNode = ""; this.execNode = "";
@@ -410,6 +419,14 @@ class NewSessionPage extends OpenClawLightDomElement {
return hasOperatorAdminAccess(this.context?.gateway.snapshot.hello?.auth ?? null); return hasOperatorAdminAccess(this.context?.gateway.snapshot.hello?.auth ?? null);
} }
private canStartAsDraft(): boolean {
return canStartSessionAsDraft({
allowedVisibilities: this.context?.gateway.snapshot.hello?.policy?.allowedSessionVisibilities,
hasMultipleIdentities:
this.context?.gateway.snapshot.hello?.policy?.hasMultipleSessionSharingIdentities,
});
}
private workspacePath(): string { private workspacePath(): string {
return normalizeOptionalString(this.selectedAgent()?.workspace) ?? ""; return normalizeOptionalString(this.selectedAgent()?.workspace) ?? "";
} }
@@ -455,6 +472,7 @@ class NewSessionPage extends OpenClawLightDomElement {
this.worktree = false; this.worktree = false;
this.incognito = false; this.incognito = false;
this.worktreeName = ""; this.worktreeName = "";
this.startAsDraft = false;
this.baseRef = ""; this.baseRef = "";
this.repository = { kind: "idle" }; this.repository = { kind: "idle" };
this.execNode = ""; this.execNode = "";
@@ -797,6 +815,7 @@ class NewSessionPage extends OpenClawLightDomElement {
workspace: this.workspacePath(), workspace: this.workspacePath(),
execNode: this.execNode, execNode: this.execNode,
catalogId: this.data?.catalogId, catalogId: this.data?.catalogId,
startAsDraft: this.startAsDraft && this.canStartAsDraft(),
}); });
const cloudCreateParams = cloudProfileId const cloudCreateParams = cloudProfileId
? pendingCloud ? pendingCloud
@@ -1349,6 +1368,17 @@ class NewSessionPage extends OpenClawLightDomElement {
data: this.data, data: this.data,
agentSelect: agents.length > 1 ? this.renderAgentSelect(agents) : nothing, agentSelect: agents.length > 1 ? this.renderAgentSelect(agents) : nothing,
placeSelect: this.renderPlaceSelect(), placeSelect: this.renderPlaceSelect(),
draftVisibilityControl: this.canStartAsDraft()
? renderStartAsDraftToggle({
checked: this.startAsDraft,
disabled: this.submitting || Boolean(this.pendingCloud.sessionKey),
onChange: (checked) => {
if (!this.submitting && !this.pendingCloud.sessionKey) {
this.startAsDraft = checked;
}
},
})
: nothing,
retrying: this.catalogRetrying, retrying: this.catalogRetrying,
onRetry: this.handleCatalogRetry, onRetry: this.handleCatalogRetry,
}); });
@@ -0,0 +1,21 @@
import { render } from "lit";
import { describe, expect, it, vi } from "vitest";
import { renderStartAsDraftToggle } from "./target-controls.ts";
describe("start-as-draft control", () => {
it("renders a labeled checkbox and reports the selected state", () => {
const container = document.createElement("div");
const onChange = vi.fn();
render(renderStartAsDraftToggle({ checked: false, disabled: false, onChange }), container);
const input = container.querySelector<HTMLInputElement>('input[type="checkbox"]');
expect(container.textContent).toContain("Start as draft");
expect(input?.checked).toBe(false);
if (!input) {
throw new Error("expected draft checkbox");
}
input.checked = true;
input.dispatchEvent(new Event("change"));
expect(onChange).toHaveBeenCalledWith(true);
});
});
@@ -31,3 +31,21 @@ export function renderAgentSelect(params: {
</span> </span>
`; `;
} }
export function renderStartAsDraftToggle(params: {
checked: boolean;
disabled: boolean;
onChange: (checked: boolean) => void;
}) {
return html`<label class="new-session-page__trigger new-session-page__draft-toggle">
<input
type="checkbox"
.checked=${params.checked}
?disabled=${params.disabled}
@change=${(event: Event) =>
params.onChange((event.currentTarget as HTMLInputElement).checked)}
/>
<span aria-hidden="true">👻</span>
<span>${t("newSession.startAsDraft")}</span>
</label>`;
}
+22 -5
View File
@@ -5445,17 +5445,34 @@ td.data-table-key-col {
} }
.session-row-host--draft { .session-row-host--draft {
opacity: 0.68; --draft-row-opacity: 1;
opacity: var(--draft-row-opacity);
} }
.session-row-host--draft:hover, .session-row-host--draft-owner .session-row-draft-indicator {
.session-row-host--draft:focus-within, opacity: 0.55;
.session-row-host--draft.sidebar-recent-session--selected { }
opacity: 1;
.session-row-host--draft-other {
--draft-row-opacity: 0.58;
background: color-mix(in srgb, var(--bg-muted) 44%, transparent);
}
.session-row-host--draft-other:hover,
.session-row-host--draft-other:focus-within,
.session-row-host--draft-other.sidebar-recent-session--selected {
--draft-row-opacity: 0.88;
} }
.session-row-draft-indicator { .session-row-draft-indicator {
display: inline-grid;
width: 18px;
height: 18px;
flex: 0 0 auto; flex: 0 0 auto;
place-items: center;
border: 1px solid var(--border);
border-radius: var(--radius-full);
background: color-mix(in srgb, var(--bg-elevated) 72%, transparent);
font-size: 12px; font-size: 12px;
} }
+15
View File
@@ -179,6 +179,21 @@ openclaw-new-session-page {
opacity: 0.6; opacity: 0.6;
} }
.new-session-page__draft-toggle {
gap: 6px;
cursor: pointer;
}
.new-session-page__draft-toggle input {
margin: 0;
accent-color: var(--accent);
}
.new-session-page__draft-toggle:has(input:disabled) {
cursor: default;
opacity: 0.6;
}
/* Keep the anchor focusable while Web Awesome finishes its hide animation. */ /* Keep the anchor focusable while Web Awesome finishes its hide animation. */
.new-session-page__trigger--hiding { .new-session-page__trigger--hiding {
pointer-events: none; pointer-events: none;
+43
View File
@@ -59,6 +59,8 @@ export type ControlUiMockGatewayScenario = {
label: string; label: string;
pluginId: string; pluginId: string;
}>; }>;
allowedSessionVisibilities?: Array<"shared" | "read-only" | "suggest" | "draft">;
hasMultipleSessionSharingIdentities?: boolean;
featureCapabilities?: string[]; featureCapabilities?: string[];
defaultAgentId?: string; defaultAgentId?: string;
deferredMethods?: string[]; deferredMethods?: string[];
@@ -131,6 +133,10 @@ export type MockGatewayControls = {
setOnline: (online: boolean) => Promise<void>; setOnline: (online: boolean) => Promise<void>;
setHistoryMessages: (messages: unknown[]) => Promise<void>; setHistoryMessages: (messages: unknown[]) => Promise<void>;
setMethodResponse: (method: string, payload: unknown) => Promise<void>; setMethodResponse: (method: string, payload: unknown) => Promise<void>;
setSessionSharingPolicy: (policy: {
allowedSessionVisibilities: Array<"shared" | "read-only" | "suggest" | "draft">;
hasMultipleSessionSharingIdentities: boolean;
}) => Promise<void>;
waitForRequest: (method: string) => Promise<MockGatewayRequest>; waitForRequest: (method: string) => Promise<MockGatewayRequest>;
}; };
@@ -279,6 +285,13 @@ function normalizeScenario(
basePath, basePath,
controlUiTabs: scenario.controlUiTabs ?? [], controlUiTabs: scenario.controlUiTabs ?? [],
controlUiWidgetKinds: scenario.controlUiWidgetKinds ?? [], controlUiWidgetKinds: scenario.controlUiWidgetKinds ?? [],
allowedSessionVisibilities: scenario.allowedSessionVisibilities ?? [
"shared",
"read-only",
"suggest",
"draft",
],
hasMultipleSessionSharingIdentities: scenario.hasMultipleSessionSharingIdentities ?? false,
featureCapabilities: scenario.featureCapabilities ?? [], featureCapabilities: scenario.featureCapabilities ?? [],
defaultAgentId, defaultAgentId,
deferredMethods: scenario.deferredMethods ?? [], deferredMethods: scenario.deferredMethods ?? [],
@@ -371,6 +384,10 @@ function installControlUiMockGateway(input: {
setOnline: (online: boolean) => void; setOnline: (online: boolean) => void;
setHistoryMessages: (messages: unknown[]) => void; setHistoryMessages: (messages: unknown[]) => void;
setMethodResponse: (method: string, payload: unknown) => void; setMethodResponse: (method: string, payload: unknown) => void;
setSessionSharingPolicy: (policy: {
allowedSessionVisibilities: Array<"shared" | "read-only" | "suggest" | "draft">;
hasMultipleSessionSharingIdentities: boolean;
}) => void;
socketCount: () => number; socketCount: () => number;
socketUrls: () => string[]; socketUrls: () => string[];
}; };
@@ -893,6 +910,13 @@ function installControlUiMockGateway(input: {
: {}), : {}),
protocol: protocolVersion, protocol: protocolVersion,
server: { connId: "control-ui-e2e", version: "e2e" }, server: { connId: "control-ui-e2e", version: "e2e" },
policy: {
maxPayload: 1_048_576,
maxBufferedBytes: 1_048_576,
tickIntervalMs: 30_000,
allowedSessionVisibilities: scenario.allowedSessionVisibilities,
hasMultipleSessionSharingIdentities: scenario.hasMultipleSessionSharingIdentities,
},
snapshot: { snapshot: {
...presenceSnapshot(params), ...presenceSnapshot(params),
sessionDefaults: { sessionDefaults: {
@@ -1306,6 +1330,10 @@ function installControlUiMockGateway(input: {
// Current-document responses still work if browser storage is unavailable. // Current-document responses still work if browser storage is unavailable.
} }
}, },
setSessionSharingPolicy(policy) {
scenario.allowedSessionVisibilities = policy.allowedSessionVisibilities;
scenario.hasMultipleSessionSharingIdentities = policy.hasMultipleSessionSharingIdentities;
},
setHistoryMessages(messages) { setHistoryMessages(messages) {
scenario.historyMessages = Array.isArray(messages) ? messages : []; scenario.historyMessages = Array.isArray(messages) ? messages : [];
const configuredHistory = scenario.methodResponses["chat.history"]; const configuredHistory = scenario.methodResponses["chat.history"];
@@ -1558,6 +1586,21 @@ function createMockGatewayControls(page: Page, defaultSessionKey: string): MockG
{ targetMethod: method, responsePayload: payload }, { targetMethod: method, responsePayload: payload },
); );
}, },
async setSessionSharingPolicy(policy) {
await page.evaluate((nextPolicy) => {
const gateway = (
window as Window & {
openclawControlUiE2eGateway?: {
setSessionSharingPolicy: (policy: typeof nextPolicy) => void;
};
}
).openclawControlUiE2eGateway;
if (!gateway) {
throw new Error("Mock Gateway is not installed");
}
gateway.setSessionSharingPolicy(nextPolicy);
}, policy);
},
async waitForRequest(method) { async waitForRequest(method) {
await page.waitForFunction( await page.waitForFunction(
(targetMethod) => { (targetMethod) => {