Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ Compatibility is documented in release notes, not encoded in the version string.

## [Unreleased]

### Changed

- The control-assistant tutorial's two-user roster now ships alice as the
write-capable operator and bob as the read-only viewer. The tiers differ
visibly, not just in enforcement: the write-armed terminal keeps the full
expert workspace with the EVENTS and BLUESKY panels, the read-only one gets
a chat-first simple layout without them, both default to the light theme,
and each browser tab is titled after its role.

### Added

- A tokenless `queue_start` now files a **start request** the operator confirms
Expand Down
Binary file modified docs/source/_static/resources/multi_user_landing.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
58 changes: 35 additions & 23 deletions docs/source/how-to/multi-user.rst
Original file line number Diff line number Diff line change
Expand Up @@ -114,10 +114,14 @@ The config block
channel_finder_base_port: 9591
default_persona: readonly
users:
- alice # bare name → default_persona
- name: alice
index: 0
persona: readwrite
display_name: "Control Room (Alice)"
- name: bob
index: 1
persona: readwrite
persona: readonly
display_name: "Read-Only View (Bob)"
personas:
readonly:
project: control-assistant-readonly
Expand All @@ -138,8 +142,10 @@ The config block
``osprey deploy up``.

The ``users`` list is the roster — the single source of truth for who
exists. A bare name resolves to ``default_persona``; an entry with an
explicit ``persona`` picks its tier. Each user's host ports are
exists. A bare name (``- carol``) resolves to ``default_persona`` —
read-only, so a hastily added user lands on the safe side; an entry with
an explicit ``persona`` picks its tier, and an optional ``display_name``
becomes that user's browser tab title. Each user's host ports are
``base + index`` in every port family — one family per companion panel
(artifact gallery, ARIEL, channel finder, lattice dashboard, …) plus the
terminal itself — so alice (index 0) serves her terminal on ``9091`` and
Expand Down Expand Up @@ -289,22 +295,26 @@ each labelled with the persona it resolves to:
:align: center
:width: 100%

The grouped landing page: alice resolves to the readonly persona, bob to
readwrite. Click a card to open that user's session.
The grouped landing page: alice resolves to the readwrite persona, bob to
readonly. Click a card to open that user's session.

alice is a bare roster entry, so she resolves to the preset's
``default_persona`` (readonly). bob names his persona (readwrite) explicitly.
Clicking a card opens that user's terminal at ``/u/<name>/``, proxied by nginx
to the user's own container.
Both entries name their persona explicitly — alice the readwrite tier, bob the
readonly one. (A bare roster entry would fall back to the preset's
``default_persona``, readonly, so an implicit user always lands on the safe
side.) Clicking a card opens that user's terminal at ``/u/<name>/``, proxied
by nginx to the user's own container.

Two sessions, two write postures
--------------------------------

Each persona is a self-contained OSPREY project with its **own** permissions,
because permissions are a property of a project's ``config.yml`` — the two
tiers are genuinely different agents, not one agent with a UI toggle. They
differ on exactly **one** config key, the reference monitor's master write
switch:
tiers are genuinely different agents, not one agent with a UI toggle. The
enforcement boundary is exactly **one** config key, the reference monitor's
master write switch; the tiers additionally differ in presentation — the
write-armed terminal gets the full expert workspace with the EVENTS and
BLUESKY control panels, the read-only one a chat-first simple surface without
them:

.. list-table::
:header-rows: 1
Expand All @@ -314,15 +324,15 @@ switch:
- ``control_system.writes_enabled``
- What that means in the session
* - **alice**
- ``false``
- Read-only. Channel reads, the channel finder, the archiver, and logbook
search all work — but every write surface refuses: channel writes,
read-write Python execution, all of it, from the single switch.
* - **bob**
- ``true``
- Write-capable — and supervised, not unguarded. A channel write still
passes the writes-check hook, per-channel min/max limits, and a human
approval prompt before the connector executes it.
* - **bob**
- ``false``
- Read-only. Channel reads, the channel finder, the archiver, and logbook
search all work — but every write surface refuses: channel writes,
read-write Python execution, all of it, from the single switch.

The posture is a property of the **session**, not a statement about the
person: which teammates get a write-capable tier is your roster's call, and
Expand All @@ -335,17 +345,19 @@ user's terminal and ask both agents to do the same two things:
**Read.** Ask either agent about a channel — a corrector setpoint, a BPM
reading. Both sessions answer identically: reads are ungated on both tiers.

**Write.** Ask each agent to change a setpoint. In bob's session the write
goes to a human approval prompt, then executes. In alice's session the same
request is **refused**: the write tool is denied in her project's rendered
permissions, and the refusal states plainly that writes are disabled in her
**Write.** Ask each agent to change a setpoint. In alice's session the write
goes to a human approval prompt, then executes. In bob's session the same
request is **refused**: the write tool is denied in his project's rendered
permissions, and the refusal states plainly that writes are disabled in his
configuration.

Both agents carry the *same* tool surface — the readonly tier is not a
stripped-down agent that never heard of writing. It is the same agent whose
write path is switched off in its own project, which is exactly what you want
to demonstrate to a control room: the boundary holds at the enforcement layer,
not at the menu.
not at the menu. (The readonly terminal's leaner look — no EVENTS/BLUESKY
tabs, chat-first layout — is presentation for the viewer tier, not the
boundary itself: the refusal above fires with or without it.)

Logging out and switching users
-------------------------------
Expand Down
14 changes: 11 additions & 3 deletions src/osprey/profiles/presets/control-assistant-readonly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,18 @@ deploy_services: false
# after the template renders. Nested YAML under `config:` would deep-merge into
# and clobber the base's rendered config subtree, so keep every override flat.
config:
# The single axis this persona differs on. The tutorial base arms writes for
# its own single-user agent, so this tier boundary MUST be pinned explicitly
# here — it is what makes the read-only terminal read-only.
# The tier boundary. The tutorial base arms writes for its own single-user
# agent, so this MUST be pinned explicitly here — it is what makes the
# read-only terminal read-only.
control_system.writes_enabled: false
# Pared-down operator layout: chat only, workspace hidden until the agent
# puts something in it. Pinned on both sides of the tier boundary (readwrite
# pins `expert`), same rationale as writes_enabled.
#
# This persona also has no EVENTS/BLUESKY panels — not by any key here, but
# because their declarations live in the readwrite persona delta and never
# reach this build (see the note in the base's config: block).
web.ui_mode: simple
# Attached persona: the hosting project owns the web-terminal tier (nginx,
# landing, per-user containers). Without this override the inherited roster
# would make a persona-dir deploy try to host a second web tier on the same
Expand Down
34 changes: 31 additions & 3 deletions src/osprey/profiles/presets/control-assistant-readwrite.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,45 @@ extends: control-assistant
# blocks inherited from the base are all gated on this flag and skip cleanly.
deploy_services: false

# The write-oriented panels, listed beside their web.panels.<id>.url overrides
# below (a panel id and its URL declaration travel together). Persona lists
# UNION over the base, so these are added to the inherited builtin set.
web_panels:
- events # EVENTS dashboard tab (event dispatcher)
- bluesky # Plan authoring, the scan queue, and the run's live results

# ── Config overrides ─────────────────────────────────────────────────────────
# Dotted keys ONLY: each entry is a literal `key.path` written into config.yml
# after the template renders. Nested YAML under `config:` would deep-merge into
# and clobber the base's rendered config subtree, so keep every override flat.
config:
# The single axis this persona differs on. Inherited true from the tutorial
# base's template default, but pinned explicitly here: this key IS the tier
# boundary, so it must not drift silently if the base's default ever changes.
# The tier boundary. Inherited true from the tutorial base's template
# default, but pinned explicitly here: this key must not drift silently if
# the base's default ever changes.
control_system.writes_enabled: true
# Full split-pane terminal + workspace layout for the write-armed operator.
# Pinned on both sides of the tier boundary (readonly pins `simple`) rather
# than left to the server default, for the same reason writes_enabled is.
web.ui_mode: expert
# Attached persona: the hosting project owns the web-terminal tier (nginx,
# landing, per-user containers). Without this override the inherited roster
# would make a persona-dir deploy try to host a second web tier on the same
# host ports.
modules.web_terminals.enabled: false
# EVENTS + BLUESKY: the write-oriented panels, declared HERE and not in the
# base so the readonly persona is built without them (a persona can only add
# config keys, never subtract inherited ones — see the note in the base's
# config: block).
# EVENTS — the event-dispatcher dashboard as an in-terminal tab. URL defaults
# to the host-run dispatcher; override EVENT_DISPATCHER_URL for
# containerized/remote web terminals.
web.panels.events.label: EVENTS
web.panels.events.url: "${EVENT_DISPATCHER_URL:-http://localhost:8020}"
web.panels.events.path: /dashboard
web.panels.events.health_endpoint: /health
# BLUESKY — operator UI for the mediated Bluesky stack, served by the
# bluesky-panels sidecar. Override BLUESKY_PANELS_URL for containerized/
# remote web terminals (same pattern as EVENTS above).
web.panels.bluesky.label: BLUESKY
web.panels.bluesky.url: "${BLUESKY_PANELS_URL:-http://localhost:8095}"
web.panels.bluesky.path: /bluesky/
44 changes: 25 additions & 19 deletions src/osprey/profiles/presets/control-assistant.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,11 @@ web_panels:
- ariel # ARIEL search interface (past experiments, papers)
- channel-finder # Interactive channel-finder web UI
- okf # KNOWLEDGE tab — browse the facility knowledge bundle
- events # EVENTS dashboard tab (event dispatcher)
- bluesky # Plan authoring, the scan queue, and the run's live results
- system-health # SYSTEM tab — framework health dashboard (sidecar-backed)
# events + bluesky (the write-oriented panels) are declared by the readwrite
# persona, beside the web.panels.<id>.url overrides that give them meaning —
# a panel id and its URL declaration travel together (see the note in
# config: below).

# ── Bluesky stack (turn-key, VA-backed) ─────────────────────────────────────────
# The tutorial ships the FULL Bluesky-mediated Bluesky stack out of the box:
Expand Down Expand Up @@ -205,21 +207,20 @@ config:
# to the project name when unset. Sits in the same `facility:` block as
# `facility.prefix` below.
# facility.name: My Facility
# EVENTS panel — surfaces the event-dispatcher dashboard as an in-terminal tab.
# URL defaults to the host-run dispatcher (127.0.0.1:8020); override
# EVENT_DISPATCHER_URL for containerized/remote web terminals.
web.panels.events.label: EVENTS
web.panels.events.url: "${EVENT_DISPATCHER_URL:-http://localhost:8020}"
web.panels.events.path: /dashboard
web.panels.events.health_endpoint: /health
# SCAN panel — the operator UI for the mediated Bluesky stack, served by the
# bluesky-panels sidecar. One panel with three tabs: plan authoring, the scan
# queue, and the selected run's live results.
# URL defaults to the host-published sidecar port; override BLUESKY_PANELS_URL for
# a containerized/remote web terminal (same pattern as the EVENTS panel above).
web.panels.bluesky.label: BLUESKY
web.panels.bluesky.url: "${BLUESKY_PANELS_URL:-http://localhost:8095}"
web.panels.bluesky.path: /bluesky/
# Default web theme for every terminal: the `main` family pinned to light
# mode (`light` is main's concrete light id — other families spell theirs
# `desy-light` etc.). A default, not a lock: the in-browser display menu,
# ?theme= and localStorage override it per browser.
web.theme: light
# EVENTS + BLUESKY panel declarations live in the readwrite persona delta,
# NOT here. Deliberate: a persona delta can only ADD config keys (`config:`
# is not excludable), so anything declared here reaches every persona — and
# the read-only persona must be built without these two write-oriented
# panels. Declaring them only in the readwrite delta is the one mechanism
# that makes them genuinely absent from the readonly build (`enabled: false`
# is inert for URL panels — only builtin ids honor it). The base project
# still gets both panels: the dispatch and bluesky-panels injectors fill in
# defaults when the profile doesn't declare them.
# ── Multi-user web-terminal stack (built-in) ───────────────────────────────
# The tutorial ships natively multi-user: `osprey deploy up` stands up nginx,
# the landing page, and one web-terminal container per roster user, alongside
Expand Down Expand Up @@ -279,12 +280,17 @@ config:
# another user's ports; `persona` names a catalog entry below. A bare
# string (`- alice`) is also accepted: it takes its list position as
# index and falls back to `default_persona`.
# `display_name` becomes the browser window/tab title (OSPREY_WEB_APP_NAME)
# — with both terminals on the same light theme it is the visible marker
# of which one is write-armed.
- name: alice
index: 0
persona: readonly
persona: readwrite
display_name: "Control Room (Alice)"
- name: bob
index: 1
persona: readwrite
persona: readonly
display_name: "Read-Only View (Bob)"
personas:
readonly:
project: control-assistant-readonly
Expand Down
4 changes: 3 additions & 1 deletion tests/cli/test_build_profile_emit.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,9 @@ def test_delta_keeps_the_preset_comments() -> None:
profile_filename="my-profile/personas/readonly.yml",
)

assert "it is what makes the read-only terminal read-only" in text
assert "it is what makes the" in text
assert "read-only terminal read-only" in text
assert "Pared-down operator layout" in text


def test_header_explains_the_implicit_merge_and_names_the_source() -> None:
Expand Down
Loading
Loading