diff --git a/REPORT.md b/REPORT.md index 1aad25c1323a..ed7383d40d20 100644 --- a/REPORT.md +++ b/REPORT.md @@ -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` - -Final best-effort policy: `320237964e0 fix(sessions): make archive audit note best-effort` +- 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. +- 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 -Session state and mutation: +### Protocol and Gateway -- `src/config/sessions/session-entry-provenance.ts` -- `src/config/sessions/types.ts` -- `src/gateway/sessions-patch.ts` -- `src/gateway/server-methods/sessions-mutations.ts` -- `src/gateway/server-methods/session-audit.ts` -- `src/gateway/server-methods/sessions-sharing.ts` -- `src/plugins/session-entry-slot-keys.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` +- `packages/gateway-protocol/src/schema/sessions-create.ts` +- `packages/gateway-protocol/src/schema/sessions-create.test.ts` +- `packages/gateway-protocol/src/schema/frames.ts` +- `src/gateway/session-create-service.ts` +- `src/gateway/server-methods/sessions-create.ts` +- `src/gateway/server/ws-connection/connect-hello.ts` +- `src/gateway/server.sessions.create.test.ts` +- `src/gateway/server.auth.default-token.suite.ts` - `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.test.ts` - `ui/src/components/app-sidebar-session-row-render.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/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` -- `src/gateway/sessions-patch.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/lib/sessions/reconcile.test.ts` -- `ui/src/test-helpers/app-sidebar-cases/session-ownership.ts` +- `docs/concepts/multi-user.md` +- `docs/gateway/config-agents.md` -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. -- The acting identity comes only from `gatewayClientSessionCreator(client)`. Unidentified token/password clients get no fabricated identity and no actor-stamped audit line. -- 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. -- 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. -- 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. -- `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. -- 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. -- 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. +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. +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. +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. +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. +5. Admin ownership styling compares the session creator id with the current authenticated user id because `sharingRole: "admin"` intentionally wins over `"owner"` for administrators. +6. Promotion remains one existing `session.visibility.set` call with `visibility: "shared"`; no parallel publish method, event, audit path, or invite workflow was introduced. +7. The sidebar's pending unsent-composer `showDraft`/`renderDraftSessionRow` machinery was not touched. ## 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 -pnpm install -Done in 19.9s using pnpm v11.2.2 (exit 0) -``` +### Install and formatting -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 ` + - 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 -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) +### Focused Vitest and browser behavior -node scripts/run-vitest.mjs src/gateway/server-methods/sessions-sharing.test.ts -Test Files 1 passed, Tests 9 passed -[test] passed 1 Vitest shard in 17.36s (exit 0) +- `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` + - 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 -Test Files 1 passed, Tests 3 passed (exit 0) -``` +### Required typechecks -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:swift && pnpm protocol:gen:kotlin -wrote dist/protocol.schema.json -wrote apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift -wrote apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt -wrote apps/android/app/src/main/java/ai/openclaw/app/protocol/OpenClawProtocolConstants.kt -exit 0 +- `pnpm protocol:gen` + - Exit 0; wrote the local ignored `dist/protocol.schema.json` without adding it to Git. +- `pnpm protocol:gen:swift` + - Exit 0; wrote `GatewayModels.swift`. +- `pnpm protocol:gen:kotlin` + - Exit 0; wrote both Kotlin generator targets, with no tracked diff. +- `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 -protocol since guard passed: 0 new core methods use train 2026.7 -exit 0 +### Docs and changed gate -node --import tsx scripts/native-app-i18n.ts baseline --write -native-app-i18n: entries=5232 changed=false +- `pnpm docs:check-mdx` + - 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 -control-ui-i18n: raw-copy: baseline entries=105 -control-ui-i18n: source: keys=3960 -exit 0 -``` +### Autoreview -Requested type gates: - -```text -node scripts/run-tsgo.mjs -p tsconfig.core.json -exit 0 (no diagnostics) - -node scripts/run-tsgo.mjs -p tsconfig.ui.json -exit 0 (no diagnostics) - -node scripts/run-tsgo.mjs -p tsconfig.extensions.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) -``` - -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 -``` +- Branch command: `.agents/skills/autoreview/scripts/autoreview --mode branch --base 977db1c83261b13e92e7ad74c5f544e61acf1b90 --stream-engine-output`. +- Accepted and fixed: + - replaced the exact global profile count with privacy-preserving `hasMultipleSessionSharingIdentities`; + - cleared stale checked draft state when policy/identity availability disappears; + - preserved matching keyed-create adoption retries; + - preserved those exact retries after the drafts policy is disabled while still rejecting every genuinely new disabled draft. +- Rejected after direct verification: + - 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. +- Final branch result: `autoreview clean: no accepted/actionable findings reported`, overall confidence 0.94. ## 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 -prod 138 added, 37 deleted, net +101 -test 322 added, 11 deleted, net +311 -total 460 added, 48 deleted, net +412 -``` +| Group | Added | Deleted | +| ---------------------- | ----: | ------: | +| Production | 192 | 18 | +| 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 -- 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 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 Kotlin generated diff was committed because the Kotlin generator does not emit `SessionRow` and produced no change. -- No ignored `dist/protocol.schema.json` was force-added; it was generated and checked, while current repository policy leaves `dist/` untracked. -- No push, PR, changelog edit, `scripts/pr`, or live Gateway mutation was performed. -- `origin/main` advanced during the long validation window. The branch is intentionally left for the reviewer to rebase as requested. - -## 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. +- No push, pull request, `scripts/pr`, release, publish, `CHANGELOG.md`, or GitHub mutation was performed. +- No SQLite change was needed; therefore there was no schema-version bump or lazy table ensure. +- No new Gateway event was added; existing `sessions.changed` and `session.sharing` paths retain their current scope guards and draft filtering. +- Foreign-language UI bundles were not edited; the requested `en.ts` plus baseline workflow was used. +- `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:`; no state-generated files changed. +- 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. diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 60b45292043a..16f0db053c70 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -6895,6 +6895,7 @@ public struct SessionsCreateParams: Codable, Sendable { public let model: String? public let thinkinglevel: String? public let incognito: Bool? + public let visibility: SessionVisibility? public let catalogid: String? public let parentsessionkey: String? public let spawndepth: Int? @@ -6917,6 +6918,7 @@ public struct SessionsCreateParams: Codable, Sendable { model: String? = nil, thinkinglevel: String? = nil, incognito: Bool? = nil, + visibility: SessionVisibility? = nil, catalogid: String? = nil, parentsessionkey: String? = nil, spawndepth: Int? = nil, @@ -6938,6 +6940,7 @@ public struct SessionsCreateParams: Codable, Sendable { self.model = model self.thinkinglevel = thinkinglevel self.incognito = incognito + self.visibility = visibility self.catalogid = catalogid self.parentsessionkey = parentsessionkey self.spawndepth = spawndepth @@ -6961,6 +6964,7 @@ public struct SessionsCreateParams: Codable, Sendable { case model case thinkinglevel = "thinkingLevel" case incognito + case visibility case catalogid = "catalogId" case parentsessionkey = "parentSessionKey" case spawndepth = "spawnDepth" diff --git a/docs/concepts/multi-user.md b/docs/concepts/multi-user.md index d1a7e726aaae..05a730ac566c 100644 --- a/docs/concepts/multi-user.md +++ b/docs/concepts/multi-user.md @@ -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. +## 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 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. diff --git a/docs/docs_map.md b/docs/docs_map.md index b7ae3d43f332..59388cc3089e 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -2786,6 +2786,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - Headings: - H2: Trust boundary - H2: Ownership and presence + - H2: Drafts - H2: Turn attribution - H2: Related diff --git a/docs/gateway/config-agents.md b/docs/gateway/config-agents.md index 9956ed775503..4933072e6602 100644 --- a/docs/gateway/config-agents.md +++ b/docs/gateway/config-agents.md @@ -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) - `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"`. -- **`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. - `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. diff --git a/packages/gateway-protocol/src/schema/frames.ts b/packages/gateway-protocol/src/schema/frames.ts index a80bf6405f8c..2d0b10a09945 100644 --- a/packages/gateway-protocol/src/schema/frames.ts +++ b/packages/gateway-protocol/src/schema/frames.ts @@ -3,6 +3,7 @@ import type { Static } from "typebox"; import { Type } from "typebox"; import { closedObject } from "./closed-object.js"; import { GatewayClientIdSchema, GatewayClientModeSchema, NonEmptyString } from "./primitives.js"; +import { SessionVisibilitySchema } from "./sessions-sharing-values.js"; import { SnapshotSchema, StateVersionSchema } from "./snapshot.js"; export const GATEWAY_SERVER_CAPS = { @@ -137,6 +138,8 @@ export const HelloOkSchema = closedObject({ maxPayload: Type.Integer({ minimum: 1 }), maxBufferedBytes: Type.Integer({ minimum: 1 }), tickIntervalMs: Type.Integer({ minimum: 1 }), + allowedSessionVisibilities: Type.Optional(Type.Array(SessionVisibilitySchema)), + hasMultipleSessionSharingIdentities: Type.Optional(Type.Boolean()), }), }); diff --git a/packages/gateway-protocol/src/schema/sessions-create.test.ts b/packages/gateway-protocol/src/schema/sessions-create.test.ts new file mode 100644 index 000000000000..a697478befd6 --- /dev/null +++ b/packages/gateway-protocol/src/schema/sessions-create.test.ts @@ -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); + }); +}); diff --git a/packages/gateway-protocol/src/schema/sessions-create.ts b/packages/gateway-protocol/src/schema/sessions-create.ts index efa4435fe284..3e7e53533dec 100644 --- a/packages/gateway-protocol/src/schema/sessions-create.ts +++ b/packages/gateway-protocol/src/schema/sessions-create.ts @@ -2,6 +2,7 @@ import { Type } from "typebox"; import { closedObject } from "./closed-object.js"; import { ChatAttachmentsSchema } from "./logs-chat.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. */ export const SessionsCreateParamsSchema = closedObject({ @@ -11,6 +12,7 @@ export const SessionsCreateParamsSchema = closedObject({ model: Type.Optional(NonEmptyString), thinkingLevel: Type.Optional(NonEmptyString), incognito: Type.Optional(Type.Boolean()), + visibility: Type.Optional(SessionVisibilitySchema), catalogId: Type.Optional(NonEmptyString), parentSessionKey: Type.Optional(NonEmptyString), spawnDepth: Type.Optional( diff --git a/src/gateway/server-methods/sessions-create.ts b/src/gateway/server-methods/sessions-create.ts index 85339b4ad659..af57ad07c9e3 100644 --- a/src/gateway/server-methods/sessions-create.ts +++ b/src/gateway/server-methods/sessions-create.ts @@ -337,6 +337,7 @@ export const sessionCreateHandlers: GatewayRequestHandlers = { thinkingLevel: p.thinkingLevel, incognito: p.incognito, ...(client?.connect ? { requestingOperatorScopes: clientScopes } : {}), + visibility: p.visibility, allowExistingModelSelection, parentSessionKey: p.parentSessionKey, spawnDepth: p.spawnDepth, diff --git a/src/gateway/server.auth.default-token.suite.ts b/src/gateway/server.auth.default-token.suite.ts index 076379daeae9..b9b99df5ae61 100644 --- a/src/gateway/server.auth.default-token.suite.ts +++ b/src/gateway/server.auth.default-token.suite.ts @@ -161,6 +161,10 @@ export function registerDefaultAuthTokenSuite(): void { type?: unknown; features?: { capabilities?: unknown }; snapshot?: { configPath?: string; stateDir?: string }; + policy?: { + allowedSessionVisibilities?: unknown; + hasMultipleSessionSharingIdentities?: unknown; + }; } | undefined; expect(payload?.type).toBe("hello-ok"); @@ -175,10 +179,37 @@ export function registerDefaultAuthTokenSuite(): void { ); expect(payload?.snapshot?.configPath).toBe(createConfigIO().configPath); 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(); }); + 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 () => { const { VERSION } = await import("../version.js"); for (const testCase of [ diff --git a/src/gateway/server.sessions.create.test.ts b/src/gateway/server.sessions.create.test.ts index 6f4b6fe78f57..f1011af7a273 100644 --- a/src/gateway/server.sessions.create.test.ts +++ b/src/gateway/server.sessions.create.test.ts @@ -555,6 +555,109 @@ function requireNonEmptyString(value: string | undefined, label: string): string 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 () => { const root = await fs.mkdtemp( 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(); }); +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 () => { await createSessionStoreDir(); const created = await directSessionReq( diff --git a/src/gateway/server/ws-connection/connect-hello.ts b/src/gateway/server/ws-connection/connect-hello.ts index c865d78e5bc0..633c4f86553d 100644 --- a/src/gateway/server/ws-connection/connect-hello.ts +++ b/src/gateway/server/ws-connection/connect-hello.ts @@ -12,6 +12,7 @@ import { finalizeNodePairingCleanupClaim, recordPairedNodeConnection, } from "../../../infra/node-pairing.js"; +import { listProfiles } from "../../../state/user-profiles.js"; import { resolveRuntimeServiceVersion } from "../../../version.js"; import { listControlUiPluginTabs, @@ -21,6 +22,7 @@ import { ADMIN_SCOPE } from "../../method-scopes.js"; import { scheduleNodeConnectionNotification } from "../../node-connection-notifications.js"; import { MAX_BUFFERED_BYTES, MAX_PAYLOAD_BYTES, TICK_INTERVAL_MS } from "../../server-constants.js"; import { formatError } from "../../server-utils.js"; +import { allowedSessionVisibilities } from "../../session-sharing.js"; import { formatForLog, logWs } from "../../ws-log.js"; import { buildGatewaySnapshot, getHealthCache, getHealthVersion } from "../health-state.js"; import { emitGatewayAuthSecurityEvent } from "./connect-auth-security.js"; @@ -122,6 +124,9 @@ export async function sendGatewayHello( maxPayload: MAX_PAYLOAD_BYTES, maxBufferedBytes: MAX_BUFFERED_BYTES, tickIntervalMs: TICK_INTERVAL_MS, + allowedSessionVisibilities: allowedSessionVisibilities(context.configSnapshot), + hasMultipleSessionSharingIdentities: + listProfiles().filter((profile) => !profile.mergedInto).length >= 2, }, }; advanceHandshakePhase("hello_payload_prepared"); diff --git a/src/gateway/session-create-service.ts b/src/gateway/session-create-service.ts index 9c70ebe6b91d..3bb169a8d195 100644 --- a/src/gateway/session-create-service.ts +++ b/src/gateway/session-create-service.ts @@ -6,6 +6,7 @@ import { import { ErrorCodes, type ErrorShape, + type SessionVisibility, errorShape, missingScopeErrorShape, } 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 { buildForkedGatewaySessionEntry } from "./session-create-fork-entry.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 { loadSessionEntryReadOnly, resolveGatewaySessionStoreTarget } from "./session-utils.js"; import { applySessionsPatchToStore, resolveSessionPatchModelSelection } from "./sessions-patch.js"; @@ -272,6 +274,7 @@ export async function createGatewaySession(params: { model?: string; thinkingLevel?: string; incognito?: boolean; + visibility?: SessionVisibility; /** Trusted catalog-owned model/runtime pair, persisted and locked together. */ catalogTarget?: TrustedCatalogSessionTarget; parentSessionKey?: string; @@ -615,6 +618,15 @@ export async function createGatewaySession(params: { ); const parentMainKey = resolveAgentMainSessionKey({ cfg: params.cfg, agentId: parentAgentId }); 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 spawnedCwd = normalizeOptionalString(params.spawnedCwd); const execCwd = normalizeOptionalString(params.execCwd); @@ -742,6 +754,8 @@ export async function createGatewaySession(params: { storePath: target.storePath, }, 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 ( isAgentHarnessSessionKey(target.canonicalKey) && !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 // `created` event; only a genuinely new row is a node creation. createdNewEntry = existingEntry === undefined; @@ -873,6 +914,7 @@ export async function createGatewaySession(params: { // must not restamp write-once node facts (this direct store write bypasses // the merge-level write-once guard), and legacy rows stay "unknown". ...(params.creation && createdNewEntry ? buildSessionCreationStamp(params.creation) : {}), + ...(params.visibility && createdNewEntry ? { visibility: params.visibility } : {}), ...(catalogResolvedModel && catalogAgentRuntime ? { providerOverride: catalogResolvedModel.provider, diff --git a/ui/src/components/app-sidebar-session-navigation-logic.test.ts b/ui/src/components/app-sidebar-session-navigation-logic.test.ts new file mode 100644 index 000000000000..2de9a5103a10 --- /dev/null +++ b/ui/src/components/app-sidebar-session-navigation-logic.test.ts @@ -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, + 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[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); + }); +}); diff --git a/ui/src/components/app-sidebar-session-navigation-logic.ts b/ui/src/components/app-sidebar-session-navigation-logic.ts index 76a2288fcc06..1621a0bd5215 100644 --- a/ui/src/components/app-sidebar-session-navigation-logic.ts +++ b/ui/src/components/app-sidebar-session-navigation-logic.ts @@ -49,6 +49,16 @@ import { isStoppableCloudWorkerPlacement } from "./session-row-badges.ts"; type SessionRow = SessionsListResult["sessions"][number]; +function isSidebarDraftOwnedBySelf( + row: Pick, + selfUserId: string | undefined, +): boolean { + return ( + row.visibility === "draft" && + (row.sharingRole === "owner" || Boolean(selfUserId && row.createdActor?.id === selfUserId)) + ); +} + export type SidebarSessionNavigationState = { routeSessionKey: string; selectedAgentId: string; @@ -113,6 +123,7 @@ export function buildSidebarSessionNavigationState(input: { pinned: row.pinned === true, archived: row.archived === true, visibility: row.visibility, + draftOwnedBySelf: isSidebarDraftOwnedBySelf(row, context?.gateway.snapshot.selfUser?.id), icon: row.icon, category: normalizeOptionalString(row.category), channel: channelInfo.channel, diff --git a/ui/src/components/app-sidebar-session-row-render.ts b/ui/src/components/app-sidebar-session-row-render.ts index 5ff9d6b7a58d..cfc1e3cf204f 100644 --- a/ui/src/components/app-sidebar-session-row-render.ts +++ b/ui/src/components/app-sidebar-session-row-render.ts @@ -169,6 +169,11 @@ export function renderRecentSession(params: { session.pinned ? "session-row-host--pinned" : "", running ? "session-row-host--running" : "", 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" ? "sidebar-recent-session--attention-danger" : session.attention.kind !== "none" diff --git a/ui/src/components/app-sidebar-session-types.ts b/ui/src/components/app-sidebar-session-types.ts index 15d188e1c48a..d68fc17d996e 100644 --- a/ui/src/components/app-sidebar-session-types.ts +++ b/ui/src/components/app-sidebar-session-types.ts @@ -69,6 +69,7 @@ export type SidebarRecentSession = { pinned: boolean; archived?: boolean; visibility?: SessionVisibility; + draftOwnedBySelf?: boolean; icon?: string; category?: string; channel?: string; diff --git a/ui/src/e2e/session-ownership.e2e.test.ts b/ui/src/e2e/session-ownership.e2e.test.ts index 35c405c82cf3..81573189e023 100644 --- a/ui/src/e2e/session-ownership.e2e.test.ts +++ b/ui/src/e2e/session-ownership.e2e.test.ts @@ -1,4 +1,6 @@ // 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 { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { @@ -13,6 +15,8 @@ const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium. const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath); const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1"; 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 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", () => { beforeAll(async () => { 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.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); + }); }); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 1e4387c2cc3d..5673a6cbcece 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -568,6 +568,7 @@ export const en: TranslationMap = { worktreeNameInvalid: "Worktree names use lowercase letters, digits, and dashes.", incognito: "Incognito", incognitoDescription: "Keep this thread only until the Gateway restarts", + startAsDraft: "Start as draft", messagePlaceholder: "What should this thread work on?", readingAttachment: "Reading attachment", start: "Start thread", @@ -3774,6 +3775,7 @@ export const en: TranslationMap = { readOnly: "Read-only", suggest: "Suggest", draft: "Draft", + publishDraft: "Publish draft", members: "Members", selected: "Member", noPeople: "No paired people found.", diff --git a/ui/src/pages/chat/components/chat-session-sharing.test.ts b/ui/src/pages/chat/components/chat-session-sharing.test.ts index e9498f58be85..8394134f158f 100644 --- a/ui/src/pages/chat/components/chat-session-sharing.test.ts +++ b/ui/src/pages/chat/components/chat-session-sharing.test.ts @@ -89,4 +89,42 @@ describe("chat session sharing menu", () => { expect(root.querySelector("wa-dropdown")).toBeNull(); 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(".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); + }); }); diff --git a/ui/src/pages/chat/components/chat-session-sharing.ts b/ui/src/pages/chat/components/chat-session-sharing.ts index 41f21603d378..ec0cf9c89b60 100644 --- a/ui/src/pages/chat/components/chat-session-sharing.ts +++ b/ui/src/pages/chat/components/chat-session-sharing.ts @@ -56,6 +56,7 @@ export function renderChatSessionSharing(props: ChatSessionSharingProps) { const identities = result?.identities.filter((identity) => identity.id !== result.owner?.id) ?? []; const allowed = result?.allowedVisibilities ?? [visibility]; + const canPublish = visibility === "draft" && allowed.includes("shared"); return html`