From 5ab73296bf624ac239ee73603627acd360a596ae Mon Sep 17 00:00:00 2001 From: lb <542828+lukebuehler@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:48:32 +0200 Subject: [PATCH 1/8] local runtime doc --- .../pNNN-local-runtime-without-temporal.md | 962 +++++++++++------- 1 file changed, 610 insertions(+), 352 deletions(-) diff --git a/docs/roadmap/later/pNNN-local-runtime-without-temporal.md b/docs/roadmap/later/pNNN-local-runtime-without-temporal.md index 1d0781c9b..105b837bb 100644 --- a/docs/roadmap/later/pNNN-local-runtime-without-temporal.md +++ b/docs/roadmap/later/pNNN-local-runtime-without-temporal.md @@ -1,363 +1,621 @@ # PNNN: Local runtime without Temporal **Status** -- Later / exploratory. Written 2026-10-01 as a review for roadmap discussion, - not a decision. -- Effort figures are estimates from reading the code, not from a prototype. -- Direction preference: if we pursue a local runtime, it is Option C (one - orchestration core, two substrates). A separate, forked local runtime - (Option B) is not on the table. - -A local Lightspeed with no Temporal, Postgres or Docker looks achievable in -three to four months with two engineers, with a validation spike running in -parallel to the first phase. The -reason is that Temporal only orchestrates our work: every durable fact already -lives in Postgres and CAS, and the agent loop already runs in-process for -evals. - -Direction: - -- Keep Temporal for the hosted runtime. -- Lift session orchestration out of the Temporal workflow into a sans-IO - `sessions` crate. This is worth doing on its own, before and without a local - runtime. -- Add a local runtime for interactive sessions on top of the same `sessions` - crate: SQLite, a filesystem CAS and an embedded envd, behind the existing - public API. -- Leave bots, channels and schedules as hosted-only features. - -## How much we rely on Temporal - -Temporal is our orchestrator, not our database. The session event log, -checkpoints, CAS and all domain records already live in Postgres, and the bot -controller treats its Postgres row as authoritative. What only Temporal holds -is in-flight orchestration: queued admissions, pending emissions and their -retry backoffs, workflow-start dedupe, the bot inbox and coalescing buffers, -chat delivery state, and Schedules. - -The orchestration surface is broad, though: - -- **7 workflow types:** session, sub-agent execution, environment job, - transcription, bot controller, bot trigger fire, chat conversation. -- **About 20k lines** in `temporal-workflow`, written directly against the - Temporal SDK (`&mut WorkflowContext` everywhere), with no trait in between. - About 40% is pure logic. -- **About 36 client call sites** in the server: starts, signals, queries, - describes and terminates. -- **About 60 activities** across sessions, bots and channels, with about 32 - retry policies. -- **External workers:** the TypeScript chat connectors are Temporal activity - workers on their own task queues. - -The server crate is about 40k lines of production code. Most of it (gateway, -environments, MCP, secrets, `SessionTools`) has little or no Temporal coupling. - -| Temporal capability | What we use it for | Local equivalent | -| --- | --- | --- | -| Durable workflow per session, replay, continue-as-new | `AgentSessionWorkflow` drives `CoreAgentDrive`, races admissions against running activities, runs preparation; rolls over at 10k history events | A tokio task per active session, rebuilt from the log and checkpoint on start (`create_or_load_session` already does this) | -| Signals | About 13 API mutations funnel into one `submit_admissions` signal; `deliver_emission` carries workflow-to-workflow results | A channel into the session task; a persisted inbox only if an admission must survive a crash before it is committed | -| Queries + poll loops | The gateway polls `status` every 500 ms in about 15 wait-until-accepted loops; bot, chat and job snapshots | A direct reply from the session task, which is simpler than today | -| Activities with retry, timeouts, heartbeats | LLM, tools, storage, MCP, environment jobs; heartbeats are how cancellation reaches LLM and tool futures | Plain async calls with a retry helper and a cancellation token. Backoff state is lost on restart, which is acceptable locally. | -| Durable timers | Await deadlines, promise hard deadlines, cancel watchdog, emission and start retries, bot idle-close | Timers recomputed from state on start; the engine's `await_wake` already derives the next wake from state | -| Workflow-id dedupe, signal-with-start | Session start, environment jobs, workflow-tool executions, bot controllers, conversations | Unique keys in the store plus an in-process registry of live session tasks | -| Workflow-tool protocol | Sub-agents, environment-job tools, external plugin workflows (contract is Temporal signals, queries and task queues) | In-process spawn for sub-agents and jobs. External plugins need another transport or stay hosted-only. | -| Schedules | Bot cron and poll triggers | An in-process scheduler, or hosted-only | -| Task queues to external workers | Telegram and WhatsApp connectors | Hosted-only | - -Several parts were already built without Temporal and would carry over -unchanged: - -- The reapers, the environment reconciler and the CAS sweeper are plain tokio - loops. -- Clients follow progress by long-polling events from Postgres; there is no SSE - or WebSocket. -- The expected-head check on event appends and the promise reaper already - assume that signals can be lost. - -## What already runs without Temporal - -Most of the agent already runs without Temporal: an in-process agent loop -exists today and is used by `crates/eval` against real providers. The -architecture work done so far (sans-IO engine, CAS references, store traits) is -what makes a second runtime plausible. - -| Piece | State today | Reusable as-is for local? | + +- Later / exploratory. Written 2026-10-01; direction and code review updated + 2026-10-06. No implementation started. +- Preferred architecture: one orchestration core, two execution substrates + (Option C below). A separately implemented local session runtime is not the + direction. +- Restart policy agreed 2026-10-06: interrupt unfinished ordinary tool attempts, + preserve durable runs and sub-agent supervision, and continue where possible. +- Local v1 excludes external/custom workflow tools, externally managed sessions + and SDK callback functions. A later SDK-host protocol is exploratory. +- Packaging and Platform connectivity still need decisions. +- Code-sharing and effort figures are estimates from source review, not a + prototype or delivery commitment. + +Lightspeed should be easy to deploy as one local universe as well as operate +as a fully managed agent system. The local runtime is a small deployment of +that system, supporting most core session behavior. Building a competing local +coding-agent CLI is not the main objective. + +## Direction: one universe per runtime process + +The preferred local shape is one process owning one universe: the API gateway, +session orchestration, effect execution and runtime maintenance run together, +mostly as asynchronous Tokio tasks, with multiple threads where useful. Do +not introduce independently deployed gateway and worker roles for local v1. +The exact gateway/runtime packaging boundary remains open, but separate crates +or internal interfaces do not require separate processes. + +That universe can contain many sessions, sub-agents, VFS workspaces and +attached environments. It is not one process per conversation, and it is not +necessarily the current checkout or working directory. Several clients may +connect to the same owning process. Running another independent universe means +another process with its own data directory. + +The intended scope is: + +- Keep Temporal and PostgreSQL for the hosted runtime. +- Extract shared session orchestration into a sans-IO `sessions` crate above + `harness`; use it from both Temporal and a local Tokio interpreter. +- Store runtime-owned universe data in **one SQLite database**, with CAS bytes + in the filesystem, together behind **`store-fs`**. This mirrors `store-pg` + owning both record and CAS storage; a separate `store-sqlite` crate is not the + preferred package boundary. +- Support sessions and runs, queue/steer/cancel, approvals, context and + compaction, events, fork/clone, VFS, profiles and skills, models, MCP, + sub-agents, and resuming persisted sessions. Aim to preserve existing session + behavior under the local restart policy below. +- Support different execution environments through the existing environment + protocol. The machine hosting the runtime is just another explicitly enabled + environment, accessed through envd. Bundled, embedded or sidecar envd + packaging is undecided. +- Use environment variables for user/provider credentials by default. Runtime + configuration may store references to those variables, not their values. + OAuth and any necessary local credential persistence remain open. +- Let the Platform attach/adopt a local universe as well as manage hosted + universes. The Platform is an optional, separate management application; + standalone local execution must not require its login or database. +- Leave bots, channels and their schedules out of local v1. Session waits, + promise deadlines and cancellation timers remain in scope. +- Leave external/custom workflow-tool integrations, `session/managed/start` + and SDK-hosted local functions out of local v1. Ordinary sessions remain + controllable through the public API/SDK. Built-in sub-agents and environment + jobs still use the generic protocol internally; MCP remains in scope. + +The single-process rule concerns ownership of the runtime and its store. +Execution environments, shell commands, and potentially SDK tool hosts may +have their own processes. Whether a packaged envd counts as an allowed sidecar +or must run inside the runtime is an explicit packaging question, not a reason +to split session ownership across workers. + +## Why this is feasible, and what single-process ownership does not solve + +The deterministic harness already runs without Temporal. One owning process +also removes the need for distributed worker leases, cross-process session +ownership, and failover coordination. SQLite transactions plus an exclusive +lock on the universe data directory are a plausible local foundation. Async +concurrency and multiple Tokio threads do not require multiple database owners. +Each session still needs serialized state transitions and expected-head checks +when committing its events, even when effects execute concurrently. + +Temporal still supplies behavior that a process and a database do not provide +by themselves. The session event log, checkpoints and CAS are persisted outside +Temporal, but queued admissions, preparation receipts, pending delivery and +other orchestration state also live in Temporal. Replaying only the session log +is not a complete recovery strategy. + +The local design has these persistence and ownership requirements. The restart +behavior is decided below; journal layout and other storage mechanics still +need design work. + +| Boundary | Local requirement | +| --- | --- | +| Ownership | One runtime holds the data-directory lock for its lifetime. A second opener attaches to it or fails clearly; it must not independently drive sessions against the same database. | +| API acknowledgement | Define the durable acceptance point. Work acknowledged as accepted must be committed to the event log or a recoverable inbox before replying; otherwise the response must mean something weaker explicitly. | +| Session restart | Load the harness log/checkpoint plus orchestration records or reproducible cursors. Preparation idempotency receipts, pending admissions and unresolved child executions cannot simply disappear. | +| Emissions and completion | Preserve an outbox or a reconstructible delivery cursor, stable invocation IDs and completion deduplication. There is a crash window between committing an event and delivering its effect. | +| External effects | Apply the agreed restart policy below: interrupt ordinary unfinished tool attempts, restart pending model/compaction operations, and preserve durable child runs. External effects may already have happened; single-process execution does not imply exactly-once effects. | +| Cancellation | Cancellation is a request to the effect adapter. Suppress stale results by operation identity even when the underlying request or remote process cannot be stopped immediately. | +| Sleep and deadlines | Persist absolute deadlines and recompute due work on wake/restart. No local work progresses while the process is stopped; remote environment jobs may continue and need reconciliation. Whether retry backoff itself survives restart is separate from preserving accepted work. | +| CAS consistency | Make blob writes durable before committing references; collect unreferenced files later. SQLite and filesystem CAS are not one atomic transaction, so startup repair, collection and backup need an explicit protocol. | + +These requirements are much smaller than replacing Temporal's hosted +availability and distributed scheduling guarantees. They still need tests that +kill and restart the process at commit/effect boundaries. Reduced availability +is reasonable locally; silently losing accepted work or rerunning an ambiguous +shell command should not be an accidental consequence of that choice. + +## Agreed restart policy + +**Decision, 2026-10-06:** recover durable sessions, runs and their supervision +relationships; terminate unfinished ordinary tool attempts with an interrupted +result; continue the runs where possible. A process restart does not itself +cancel a run or close a session. + +| Work at the time of the crash | Recovery behavior | +| --- | --- | +| Tool result already committed | Preserve it, including completed siblings in a partially finished batch. | +| Ordinary tool call without a committed result | Record a terminal interrupted result. Do not automatically replay the old invocation. The agent receives the error and may inspect the situation before choosing a new action. | +| Model generation or compaction without a committed result | Restart the pending logical operation. Interrupt its execution attempt without synthesizing a terminal generation failure, which currently fails the run. | +| Approval wait or timer | Restore the wait and its existing absolute deadline. Waiting for approval is not an interrupted external execution. | +| Sub-agent delegation | Recover the same child session/run and parent completion promise. Apply the ordinary-tool interruption policy inside the child and resume supervision. | + +Structured concurrency follows durable run/session ownership, not the lifetime +of a Tokio future. Restore that ownership before scheduling recovered work, +retain existing scopes and absolute deadlines, and respect recorded cancellation +or session closure. Do not fail a delegation's promise while independently +resuming its child. Reconcile partially prepared children and terminal children +whose results were not delivered; preserve initialized configuration or pinned +preparation inputs rather than rereading a changed profile and creating new work. + +The existing per-call completion path can represent an interrupted ordinary +call as `Failed` with a `runtime_interrupted` error reason. It preserves completed +sibling results and lets the active run continue once the batch finishes. +For interrupted external executions represented by promises, resolve the +affected promises and use the normal joined/await resume paths. An existing +sub-agent delegation keeps its promise pending while its child recovers. + +“Aborted” describes the runtime abandoning an attempt; it does not prove that +external execution stopped or that its side effects were rolled back. A +hard-killed runtime cannot run cleanup. Separate envd processes deliberately +survive connection loss, and an MCP server may continue processing a request. +The local contract is therefore: + +- On graceful shutdown, cancel owned execution scopes, request targeted remote + cancellation and wait for bounded cleanup. +- On restart, reconcile or cancel known owned remote executions before allowing + conflicting work to continue. Represent unconfirmed external outcomes + explicitly; an interrupted result must not imply that retrying is harmless. +- Persist attempt identity, ownership and remote execution handles before + dispatch, so recovery can locate the work even if the start response was lost. + Distinguish attempts from logical invocations and discard stale completions + without failing the recovered session. +- Complete recovery for a run and its supervision relationships before normal + dispatch resumes. Recovery itself must be safe to repeat after another crash, + producing only one terminal interruption result per abandoned call. +- Cancel only work owned by the interrupted scope. A completed process tool + call may have returned a live handle or left a background service running; + interruption of a later read does not confer ownership of that process. + Never cancel all work on a shared envd as a runtime-recovery shortcut. +- Automatic cancellation while the runtime remains dead would require explicit + envd/tool-host ownership leases or watchdogs. That is a later enhancement, + not a local v1 guarantee, and cannot universally cover arbitrary MCP servers. + +This is a bounded part of the local recovery implementation, not a redesign of +structured concurrency. It still requires durable attempt records, child +execution phases, completion deduplication and crash-boundary tests; it avoids +having to transparently resume every external tool attempt. + +## Storage, environments and credentials + +### One local store package + +[`store-fs`](../../../crates/store-fs/src/lib.rs) already groups a filesystem +CAS, JSONL session store and JSON VFS catalog. It is about 1.6k code lines, +including tests. It is not a SQLite backend and does not implement the full +hosted registry, access, blob-graph and orchestration storage surface. + +The proposed evolution is to put SQLite-backed records and the existing CAS +layout behind that package. Use `store-pg` as the behavioral reference for the +shared store traits, not as a schema to copy wholesale: local v1 does not need +bot/channel tables, PostgreSQL-specific locking or every deployment feature. +It does need session indexes and metadata, VFS heads/mounts, profiles, +environment and MCP configuration, access/identity as required, CAS reachability, +and the local orchestration records described above. Decide whether existing +JSONL stores remain a test adapter or migrate; they should not become a second +authoritative runtime database. + +One SQLite database means one logical database for runtime-owned universe +records; journal/WAL files are implementation details. envd has its own +filesystem domain and state today: daemon identity, job records and transfer +journals. Its state must not be silently folded into the universe database. + +### Environments stay independent + +The existing environment client/protocol and runtime resolver provide the +correct boundary for local and remote machines. There is no need to invent a +special shell executor in the session harness. The host machine should be +registered or configured as an environment and subject to the same attachment, +readiness, capability and credential rules as another machine. + +VFS workspaces remain separate from the host checkout and envd filesystem. +Creating a universe under `.lightspeed` must not implicitly mount, copy or +synchronize the current directory into VFS. The default host environment root, +whether it is enabled automatically, and its permissions remain decisions. + +The runtime can bundle envd for convenience, but packaging should preserve the +protocol boundary. envd currently persists its own private daemon identity and +job state under its configured state directory; it has no SQLite dependency. +Embedding it does not automatically remove that separate lifecycle. + +### Environment-variable credentials first; OAuth unresolved + +There is already an +[`EnvSecretResolver`](../../../crates/llm-runtime/src/secrets.rs), and runtime +model resolution can fall back to environment-configured provider credentials. +That is a good foundation for local configuration. Other paths need adaptation: +[environment credential bindings](../../../crates/temporal-runtime/src/environments/credentials.rs) +currently use auth grants, provider credentials or stored direct secrets. + +OAuth is not just an API key loaded once. Current auth flows persist PKCE +verifiers, access/refresh tokens and rotated refresh tokens through the +[`SecretStore`](../../../crates/auth/src/secrets.rs). Callback registration also +assumes a gateway URL; some current MCP OAuth paths need a publicly fetchable +HTTPS client metadata URL. Full OAuth parity therefore needs a policy for both +mutable secrets and local callback reachability. + +Options to evaluate are an external credential broker, reauthentication with +ephemeral local tokens, or opt-in persistent local credentials (for example an +OS credential store or encrypted storage). No choice is made here. Generated +runtime API credentials and envd's private identity need a separate explicit +policy too. “Environment variables by default” must not be presented as a +claim that the existing stack never persists any secret material. + +## Platform adoption: intended capability, not implemented multi-runtime support + +The Platform should offer two deployment choices through the same session UI: +managed/hosted universes and attached local universes. Adopting a universe means +connecting to its existing identity and data, not importing its sessions into +the hosted runtime or taking over its local process. + +There is useful scaffolding today, but separate runtime endpoints do **not** +currently work end to end: + +- [`universes.gatewayUrl`](../../../platform/db/src/schema/platform.ts) can name + an endpoint, but + [`clientOptions`](../../../platform/backend/src/runtime-client.ts) deliberately + rejects any URL other than `LIGHTSPEED_API_URL` and confines the single + `LIGHTSPEED_PLATFORM_API_KEY` to that endpoint. Tests assert this restriction. +- The existing [`/adopt` route](../../../platform/backend/src/routes/universes.ts) + links an existing universe on that configured deployment. It accepts no + separate runtime endpoint or credential. +- Runtime `single` auth mode rejects Authorization, universe and actor headers, + while Platform member calls send all three. Single-universe ownership is not + the same thing as the existing unauthenticated single mode. +- Platform feature switches hide UI; they are not runtime capability checks. + The current API handshake does not advertise bots/channels/OAuth/local-tool + availability at the granularity this needs. + +Attaching local universes therefore needs: + +1. An explicit runtime connection with an endpoint, bound credential reference, + universe identity, version/capabilities and connection status. Preserve the + key-to-endpoint restriction when supporting multiple connections. +2. Connectivity from the Platform backend: a reachable endpoint or an outbound + tunnel/relay. A hosted Platform cannot reach a laptop's loopback listener + just because the user's browser can. +3. Authentication that preserves the Platform's member checks and actor + attribution, scoped to the one local universe. +4. Capability-aware UI and explicit unsupported-method responses for features + excluded from local v1. Public session wire compatibility alone is not + enough to make the whole current UI work unchanged. +5. Attach/detach and ownership rules distinct from provisioning, repairing or + deleting a managed runtime. Also settle UUID/slug conflicts: the Platform + currently expects globally unique universe IDs and cached slugs, while + runtime slugs are deployment-local. + +A directly reachable local runtime is a useful first adoption prototype; it +can validate adoption before choosing a tunnel architecture. Platform +adoption is part of the intended outcome, not a reason to put the Platform +server inside the one-process local runtime. + +## Workflow tools and SDK-owned local functions: deferred beyond v1 + +**Scope decision, 2026-10-06:** local v1 does not expose external/custom workflow +tools, externally managed sessions or SDK callback functions. It rejects +`session/managed/start` and arbitrary external workflow bindings clearly, with +capability reporting that lets clients avoid offering them. An SDK can still +start, read, steer and cancel ordinary sessions through the public API. + +This leaves built-in sub-agents and environment jobs in scope. The current +[session preparation](../../../crates/temporal-runtime/src/gateway/service/session_preparation.rs) +injects these as system bindings, and the +[harness](../../../crates/harness/src/core/components/workflow_tool.rs) keeps +those bindings separate from the immutable managed-session declaration. Retain +their generic invocation/completion machinery in the shared core and interpret +it locally; do not replace it with feature-specific transports. Existing MCP +integration also remains in scope. + +### Later direction: an SDK process hosts tools and/or controls sessions + +An SDK application could launch local Lightspeed, establish an authenticated +connection, register functions and optionally act as a session's lifecycle +controller. Lightspeed still owns the durable universe, session state, +orchestration and model execution. User function code executes in the SDK +application; the runtime sends invocations and receives results through a +protocol. This preserves one runtime process owning the universe while allowing +separate application/tool processes. + +Launching the process is a packaging convenience, not the source of tool +authority or session ownership. Design the protocol so an authorized SDK host +could also attach to an already-running universe. Tool hosting and lifecycle +control should be independent capabilities: a host may supply functions, manage +sessions, or do both. The current managed-session contract already separates +tool receivers from an optional lifecycle controller. + +Proposals to evaluate after v1: + +- **Registration and admission:** universe registration makes a tool available + for selection, not automatically enabled everywhere. A session explicitly + admits selected definitions/bindings. Session-private tools can be admitted + without publishing them to a universe-wide catalog. +- **Stable bindings:** pin the schema/version, completion semantics and logical + host identity when a session admits a tool. Reconnecting a host or updating + its registry must not silently replace an existing session's tool contract. + Decide separately whether later versions allow explicit binding changes; + today's managed declarations are immutable. +- **Execution protocol:** carry invocation/attempt IDs, arguments or CAS + references, deadlines, cancellation and correlated terminal results. Bind + completion authority to the admitted host and acknowledge persisted results + so retransmission can be deduplicated. SDK languages wrap the same protocol. +- **Lifecycle control:** declare which sessions the SDK manages and deliver + their lifecycle notifications durably. Ordinary run submission, steering and + cancellation can reuse the API; registration alone grants no extra authority + over other sessions. +- **Host loss:** preserve durable sessions and bindings, while interrupted + callback attempts follow the agreed restart policy. Reconnection restores + availability, not abandoned invocations. Distinguish stable host identity from + a live connection and reject completions from retired attempts. Decide the + disconnect grace period and whether controller-dependent work pauses when + its SDK controller is unavailable. + +Reuse the generic workflow-tool semantics rather than introducing another tool +system. The harness already owns schemas, invocation IDs, completion promises, +cancellation and deduplication without importing Temporal. Its `WorkflowStartRef` +is substrate-neutral, but today's +[start adapter](../../../crates/temporal-runtime/src/worker/activities/workflow_tools.rs) +interprets recipes as Temporal workflow types and task queues. A connected SDK +host is a natural candidate for a bound receiver with a new transport; starting +a new host per invocation would be a separate lifecycle choice. + +The existing `bound + pull` mode permits only `accepted` completion, so it is not +already a request/result callback protocol. A local binding needs authenticated +completion, cancellation and recovery semantics for joined results/promises. +The transport (for example an inherited connection, local socket or loopback +RPC), registration API and JavaScript/Python SDK surface remain undecided. This +future work does not block v1 or require language-specific branches in the +harness or stable session worker. + +## How much code can be shared? + +### Measurement and existing reusable code + +The following inventory was measured on 2026-10-06 with `cloc` over tracked +Rust `src` files selected by `git ls-files`. Counts exclude comments and blank +lines, but **include inline tests and `src` test modules**; they are not +production-only LOC. Integration tests, generated non-Rust contracts and the +Platform frontend are outside this inventory. Sharing estimates are code-review +judgments, not an automated classification or a measured final design. + +| Already separate from Temporal | Approximate code lines | Reuse assessment | +| --- | ---: | --- | +| `harness` | 29.7k | Reuse deterministic session state, admissions and `CoreAgentDrive`; keep I/O outside it. | +| `llm-runtime` + `llm-clients` | 20.4k | Reuse provider-native request/response adapters and model execution. | +| `tools` | 24.7k | Reuse tool definitions, VFS, skills, schemas and environment-protocol adapters. | +| `api` + `api-projection` | 16.8k | Reuse public wire contracts, dispatch and projections; expose local availability accurately. | +| `vfs`, `environments`, `environment-client`, `environment-protocol`, `mcp`, `profiles` | 9.1k | Reuse domain contracts and adapters; implement persistent local stores and runtime wiring. | +| `environment-daemon` | 11.1k | Reuse the execution service; packaging and its lifecycle remain to be chosen. | +| `cli` | 21.5k | Reuse HTTP client/TUI; add local launch/attach behavior if wanted. | + +The first five rows are about **101k lines already outside the two Temporal +crates**. This is an inventory of reusable components, not a promise that every +line or feature ships in local v1. Auth also has reusable abstractions, but +credential mode decisions determine how much of it is applicable. + +[`SessionRunner`](../../../crates/test-support/src/runner/drive.rs), used by +evals, proves in-process execution is possible. It does not replace production +orchestration: it awaits model calls and tool batches without the hosted +admission racing and progressive execution behavior. Ultimately evals and +tests should exercise the shared orchestrator, rather than promoting this +runner unchanged as the local implementation. + +### Extracting `temporal-workflow` + +The crate has about **17.7k source code lines**. Roughly 6.6k belong to bots and +channels and can stay hosted-only. The session tree is about 7.5k, including +2.0k in its separate test module plus more inline tests. Preparation/rehydration, +sub-agent/environment-job/transcription workflows and shared DTOs/activity +declarations bring the session-related review surface to about **10.6k**. + +| Area | Shared code to extract | What remains substrate-specific | | --- | --- | --- | -| `harness` (30k lines) incl. `CoreAgentDrive` | Deterministic, zero Temporal dependency. Emits `AppendEvents`, `GenerateLlm`, `CompactContext`, `InvokeTools`, `Idle`, `Closed`. | Yes | -| `SessionRunner` in `test-support` (2.5k lines) | Substrate-neutral loop: `drive_until_quiescent` fulfils LLM, compaction and tool actions in-process. Used by `eval` and replay tests. | Yes, after promoting it out of test-support | -| `llm-runtime` + `llm-clients` (31k lines) | `impl CoreAgentLlm for LlmRuntime`; Anthropic, OpenAI Responses, Completions. | Yes | -| `tools` (27k lines) | `InlineToolRuntime`, local and scoped filesystem tools, VFS, skills. | Mostly. The local process executor is a one-line placeholder, so there is no in-process shell tool. | -| `store-fs` (1.8k lines) | Real filesystem CAS (`.lightspeed/cas/sha256/...`), VFS catalog, partial `FsSessionStore` (no listing, metadata, or cross-process lock). Unused in production. | CAS yes; session store needs finishing | -| In-memory stores | Session, blob, environments, auth, bots, channels, MCP registry. | Tests and ephemeral runs only | -| `environment-daemon` (envd, 12k lines) | Runs on a laptop today (`./dev.sh runtime` starts one on `127.0.0.1:19091`). | Yes, embedded or as a sidecar | -| `bots`, `channels` domain crates | Pure state and policy; `controller/state.rs` (2.5k lines) has zero Temporal references. | Logic yes; the workflow shells around it no | -| `cli` (24k lines) | Pure HTTP client of the hosted gateway (`HttpAgentApi`). | The TUI yes; needs a local backend behind it | -| `platform/web` | Talks to the runtime only through the generated API client; the browser demo already swaps in an in-browser backend. | Yes, if a local runtime serves the same API | - -Two dependencies are not Temporal but still block a local install: PostgreSQL -(`store-pg`, 18k lines, 10 migrations) and S3-compatible object storage -(optional; small blobs are already inlined in Postgres). There is no SQLite -anywhere in the tree. The [first-class runtime CLI](../p183-first-class-runtime-cli.md) -work explicitly put "no embedded database or runtime alternative" out of scope. - -## Four ways to get there - -The options differ mainly in where the session orchestration lives: today it is -about 20k lines of Rust written directly against the Temporal SDK, with no -abstraction between it and the SDK. - -| Option | What it means | Hosted runtime | Local install | Rough effort | Main risk | -| --- | --- | --- | --- | --- | --- | -| **A. Bundle the current stack** | A launcher starts the Temporal dev server (SQLite-backed), an embedded or managed Postgres, envd and `lightspeed-runtime` as subprocesses behind one command. | Unchanged | One command, but Go + Postgres binaries, several processes, and slow startup | 2–4 weeks | Feels like a server install, not a CLI tool, so it may not fix adoption | -| **B. Two runtimes** | Keep the Temporal runtime. Build a separate local runtime around `SessionRunner`, SQLite and the filesystem CAS that serves the same public API. | Unchanged | Single binary, no services | 2–3 months for interactive sessions | Orchestration semantics (admission, steering, cancel, promises, sub-agents) are re-implemented and drift from the hosted behaviour | -| **C. One orchestration core, two substrates** | Do for the session workflow what the engine did for the agent loop: move admission racing, preparation, promise polling, emission delivery and the watchdog into a sans-IO orchestrator. Temporal and a local tokio/SQLite substrate each interpret it. | Temporal stays, behind a thinner shell | Single binary, no services | 3–5 months, mostly refactoring the hosted path first | A large refactor of working, live-validated code; Temporal's determinism rules (e.g. no custom wakers) constrain the shared design | -| **D. Drop Temporal everywhere** | Option C, plus a Postgres-backed durable substrate (inbox, outbox, timers, leases) replaces Temporal in hosted too. | Postgres-only; we own scheduling, leases and failover | Same binary with SQLite | 6+ months | We take on the distributed-systems work Temporal does today: worker leases, failover, timer sweeps, at-least-once delivery, schedules | - -Option C is the preferred direction. Option B is the fastest route to a real -local product, but every orchestration feature then has to be built twice and -the two runtimes drift. Option C costs more up front and leaves a single -definition of session behaviour; it is also the only path that keeps Option D -open later without committing to it now. Option A is worth a short spike only -to test whether "one command" alone moves adoption. - -### Option C pays off without a local runtime - -Lifting orchestration out of the Temporal workflow improves the hosted runtime -even if no local runtime follows: - -- **Testability.** Admission racing, preparation, promise polling, emission - retry, the cancel watchdog and continue-as-new gating are today testable only - through Temporal, and some failures (such as the custom-waker restriction, - TMPRL1100) surface only in live suites. As a plain state machine they get - fast unit tests and replay vectors, as the engine already has. -- **Smaller determinism surface.** Only the thin interpreter has to obey - Temporal's workflow rules, not about 20k lines of orchestration. -- **Less SDK exposure.** The Temporal Rust SDK is at 0.4.0. A thinner shell - limits how much code each SDK upgrade touches. -- **One copy of session behaviour.** `test-support`'s `SessionRunner` already - duplicates hosted behaviour by hand (its prompt-refresh fallback "mirrors the - hosted product"). With a shared `sessions` crate, eval and tests run the - production orchestration. -- **Proven pattern.** `bots::controller::state` is already a pure state machine - inside a Temporal shell; sessions would follow the same pattern. - -The cost is a refactor of live-validated code. It pays back because session -orchestration keeps changing: most recent roadmap items touched it. - -## Where a local runtime plugs in +| Preparation, rehydration and status | Candidate validation, commit decisions, state reconstruction, receipts and readiness semantics | Store calls, effect completion and Temporal continuation wiring | +| Admissions, active run and waits | Admission ordering/eligibility, run-slot policy, wake decisions, cancellation and stale-result rules | Signal/channel receipt, timers, executor racing and cancellation primitives | +| Tool batches | Progressive dispatch policy and per-call effect identity | Futures/activities and transport execution | +| Promise sources and workflow starts | Deadlines, retry decisions, invocation identity, completion/delivery bookkeeping | Temporal workflow start/describe/query/signal or local execution registry | +| Sub-agent and environment-job orchestration | Child preparation, supervision, terminal results, cleanup policy | Temporal shells or local tasks using the same environment/service adapters | + +A plausible shared core is **roughly 3–5k lines of orchestration and contracts +after reshaping**, plus associated conformance coverage. This is a design-size +estimate; it is not a claim that 3–5k current lines can simply be moved. +[`preparation_candidate`](../../../crates/temporal-workflow/src/workflows/session/preparation_candidate.rs), +`session_preparation` and `rehydrate` contain easier pure extractions. The harder +parts are +[`control`](../../../crates/temporal-workflow/src/workflows/session/control.rs), +[`tool_batches`](../../../crates/temporal-workflow/src/workflows/session/tool_batches.rs) +and the preparation loop, where state decisions and async execution are mixed. + +There is already substantial unit coverage of pure helpers; the benefit is +making whole orchestration interleavings testable without Temporal, not claiming +that no unit tests exist today. Continue-as-new, workflow registration, history +handling and Temporal retry/heartbeat mechanics stay in the hosted interpreter. +The hosted bot/channel shells need not all be rewritten to deliver local v1. + +### Extracting `temporal-runtime` + +The runtime has about **53.5k source code lines**, of which the gateway subtree +is about 21.7k. A roughly **20.7k-line candidate pool** consists of `SessionTools`, +native MCP, secret resolution, environments, sub-agent services, checkpointing, +credential injection and activity modules. Those categories do not overlap the +gateway count, but neither pool is already completely neutral. + +Plan on **roughly 20–30k current source lines being extracted or adapted into +shared services**, including their existing tests: approximately **40–55% of +this crate**. The rest includes hosted bot/channel control, deployment setup, +Temporal wrappers, and code that stays optional or needs a local replacement. +This range is a refactoring footprint, not the number of new lines to write. + +The necessary boundaries are broader than a `SessionControl` trait: + +- [`GatewayAgentApi`](../../../crates/temporal-runtime/src/gateway/service/mod.rs) + owns both a Temporal `Client` and `Arc`. Separate session control + (start/admit/status/cancel/close) from shared API behavior, and replace + concrete PostgreSQL registry/access/preparation dependencies with appropriate + store/service interfaces. Authentication also names a PostgreSQL key store. +- [`ActivityState`](../../../crates/temporal-runtime/src/worker/activities/state.rs) + already uses many store, LLM and tool traits, but preparation still holds + `PgStore`. Extract effect services with neutral requests/results and crate-local + error types. Keep `ActivityError`, retries, heartbeats and cancellation context + in Temporal wrappers. +- [`SessionTools`](../../../crates/temporal-runtime/src/worker/session_tools.rs), + environment resolution and native MCP contain much of the shared execution + behavior. They need dependency cleanup rather than a second implementation. + [`SubagentChildRuntime`](../../../crates/temporal-runtime/src/subagents.rs) + is already a useful seam for replacing the child-session backend. +- Some request/response DTOs and helper functions live in `temporal-workflow` + even when they express neutral work. Move them with their shared service + contracts so local code does not depend on the Temporal crate just for types. +- Reapers, environment reconciliation and CAS collection already run as Tokio + loops, but their stores and some workflow operations are hosted-specific. + Reuse policy and services; do not describe these loops as plug-in local code. + +Most of the core agent behavior should therefore remain one implementation. +The work is concentrated in extracting orchestration/services and adding a new +storage/execution substrate. A percentage of the *entire future local product* +would be misleading before its new SQLite, recovery, Platform and tool-host code +exists; the measured component sizes and extraction ranges above are the more +useful planning quantities. + +## Proposed shared architecture ```mermaid flowchart TD - subgraph Clients - CLI[lightspeed CLI TUI] - Web[Web UI] - SDK[API clients and SDKs] - end - subgraph Shared["Shared, runtime-neutral"] - API["Public API
AgentApiService + JSON-RPC"] - Orch["sessions (new)
sans-IO orchestration: admissions,
awaits, promises, sub-agents"] - Engine["harness and adapters
CoreAgentDrive, llm-runtime,
tools, MCP"] - end - subgraph Hosted["Hosted substrate (today)"] - Temporal["Temporal
durable workflows"] - PG[("PostgreSQL
store-pg")] - S3[("S3 CAS
object storage")] - RemoteEnvd["Remote envd
via environment gateway"] - HostedOnly["Hosted only: bots, channels, schedules"] - end - subgraph Local["Local substrate (new)"] - Tasks["Session tasks (new)
tokio, per session"] - SQLite[("SQLite (new)
store-sqlite")] - FsCas[("Filesystem CAS
store-fs, exists")] - EmbeddedEnvd["Embedded envd (new)
local shell, files"] - OneBinary["One binary, data in ~/.lightspeed"] - end - Clients --> Shared - Shared --> Hosted - Shared --> Local + Clients[CLI and SDK clients] --> API[Shared public API services] + Platform[Platform: hosted or attached universe] --> API + API --> Sessions[Shared sessions orchestration] + Sessions --> Harness[Deterministic harness] + Sessions --> Effects[Shared effect services: LLM, tools, MCP, environments] + Sessions --> Hosted[Temporal interpreter] + Sessions --> Local[Tokio interpreter: one universe process] + Hosted --> PG[(store-pg: PostgreSQL and CAS)] + Local --> FS[(store-fs: SQLite and filesystem CAS)] + Effects --> Envd[Environment protocol: local or remote envd] ``` -The local runtime keeps the public API, the engine and the adapters as they -are. The new work is the extracted orchestrator and the substrate under it. The -CLI and web UI need no changes to talk to either runtime. +These are logical boundaries, not separate local services. Proposed crate roles: -### Crate layout - -The orchestration gets its own crate rather than living in the engine: - -| Crate | Role | +| Crate / boundary | Responsibility | +| --- | --- | +| `harness` | Existing event-sourced agent loop; no infrastructure I/O. | +| `sessions` (new) | Pure session orchestration state, ordered inputs and effect intents above the harness. | +| Shared runtime services (name/split TBD) | API service behavior, preparation, effect execution, environment/MCP integration and neutral contracts. I/O is allowed here. | +| `store-fs` | Local SQLite records, CAS, migrations, ownership and recovery storage. | +| `store-pg` | Existing hosted store. | +| `temporal-workflow` | Temporal orchestration interpreter, workflow registration, hosted controller shells. | +| `temporal-runtime` | Hosted composition, Temporal activities/client adapters and roles. | +| `local-runtime` (provisional) | Single-universe composition, Tokio interpreter and shared API/services. Binary/CLI packaging remains open. | + +Keep `sessions` above `harness` because their state has different sources of +truth. Harness state is reconstructed from recorded session events; +orchestration also tracks in-flight admissions, effects and delivery. This +separation preserves the harness replay invariant and leaves room to supervise +an external harness through the same session machinery later. + +### Prefer a synchronous core with async interpreters + +`sessions` should consume ordered inputs such as admission arrival, effect +completion and observed time, and emit intents such as append events, execute +or cancel work, deliver an envelope and arrange a wake. Effects stay outside +that core. Temporal rollover/history policy belongs to the Temporal interpreter, +not to every local session's state machine. + +This makes cancellation/admission races explicit and testable, and permits +serializable orchestration state. Shared code does not need to run futures +under both Temporal's workflow executor and Tokio. Temporal's workflow rules +still apply to the interpreter, and changes to core decisions still need +workflow compatibility/replay review. + +A shared async implementation behind a host trait remains an alternative to +compare during the spike. Port one slice—the wait loop plus admission racing +against a running effect—and compare complexity and tests. Avoid forcing every +linear I/O helper into a state machine; ordinary shared effect services can +remain async. The existing pure bot-controller policy inside its Temporal shell +is a useful precedent, without expanding local v1 to include bots. + +## Alternatives and effort + +| Option | Assessment under the clarified scope | | --- | --- | -| `harness` (renamed from `harness`) | Lightspeed's native agent loop: events, session state, context, tool planning, `CoreAgentDrive`. Deterministic and event-sourced. | -| `sessions` (new) | Sans-IO session orchestration: admission inbox, run slot, preparation steps, promise sources, emission outbox, workflow-start dedupe, wake computation, cancel watchdog. Depends on `harness`. | -| `temporal-workflow` | Thin interpreters that run `sessions`, `bots` and `channels` state machines on Temporal. | -| `temporal-runtime` (renamed from `temporal-runtime`) | Activities, roles and Temporal wiring. | -| `local-runtime` (later) | Tokio interpreter of `sessions` over SQLite, filesystem CAS and embedded envd. | - -Why `sessions` is separate from `harness`: - -- **Different state models.** Harness state is reduced from the event log; - replaying the log reconstructs it. Orchestration state is in-flight - bookkeeping (pending admissions, undelivered emissions, start dedupe, timers) - that is carried across continue-as-new and partly derived from harness state. - Mixing them blurs the "replay the log, get the state" invariant. -- **Vocabulary.** [External harness sessions](../p185-external-harness-sessions.md) - uses "harness" for what owns model calls, context, tools and the inner loop, - and gives Lightspeed admission, orchestration, access policy and supervision. - That maps onto `harness` and `sessions` respectively. Keeping orchestration - above the harness also leaves room to drive an external harness through the - same `sessions` machinery later. -- **Naming convention.** `bots`, `channels` and `environments` already hold - their domain's pure state machines and policy; `sessions` matches. - -A later split could also move the gateway's service layer (about 23k lines, -little Temporal coupling) out of `temporal-runtime` into its own crate once a -`SessionControl` trait exists, so both runtimes serve the API from the same -code. That is independent of the renames. - -### A sync core with async interpreters - -`sessions` should be a synchronous state machine: inputs such as "admission -arrived", "activity completed" or "timer fired"; outputs such as start or -cancel an activity, set a timer, signal or start a workflow, roll over. Each -runtime provides a small async interpreter that owns the racing and the -plumbing. Shared async code generic over a host trait (`start_activity`, -`timer`, `next_admission`, `select`) is a real alternative, but the sync core -is preferred because: - -- **Temporal's rules stay out of shared code.** Under Temporal, await order, - `select` and combinators must be deterministic on its executor. Shared async - code would have to obey that even on tokio, where nothing enforces it, and - violations surface only under Temporal. A machine without futures cannot - violate them. -- **Racing becomes explicit input.** The hard behaviour is what happens when - an admission, cancel or approval arrives during a model or tool call. In - async code that is a `select` whose semantics differ between executors - (branch order; dropping a future versus Temporal's explicit cancellation and - waiting for its result). As ordered inputs, interleavings are testable, - including the awkward ones. -- **State is already a value.** Continue-as-new carry, and a local restart, - need the orchestration state as a serializable struct. Async code keeps it in - future stack frames and needs hand-extracted carry state, which is what - `AgentSessionContinuationState` does today. -- **Cheaper tests.** Feed input sequences, assert emitted commands; no fake - executor or timing. - -Costs: state machines invert control, so linear multi-step flows (such as the -preparation retry loop) read worse than top-to-bottom async code. Versioning -is not avoided either: changing what the machine decides still changes the -commands in Temporal history. The bot controller's split is the working -precedent: decisions in a sync core, racing in a small async shell. Linear -steps that never race can stay async in the interpreter rather than being -forced into states. - -To settle it with evidence rather than preference, the first step of the -extraction ports one slice both ways (the wait loop plus admission racing -against a running activity) and compares the code and its tests. - -## What a local version would take - -A useful local v1 is about eight work items, assuming its scope is interactive -sessions only. Bots, chat channels, schedules, Platform login and -multi-universe tenancy stay hosted features. Those are always-on, multi-user -concerns, and they account for most of the Temporal surface we would otherwise -have to replace (bot controller, trigger fires, conversations, Schedules, -connector task queues). - -In scope for local v1: sessions and runs; steer, cancel and approvals; local -files and shell; MCP; skills and profiles; sub-agents; resuming a session days -later. The public API stays identical, so the CLI TUI and the web UI work -unchanged. - -The effort figures are engineer-weeks for someone who knows the codebase, at -review-level confidence. The "Effort" column assumes Option C; the last column -notes where Option B differs. - -| # | Work item | What exists | What is new | Effort | Option B instead | -| --- | --- | --- | --- | --- | --- | -| 1 | Local backend behind the public API | `AgentApiService` trait (about 119 methods, many with "unavailable" defaults) and a generic `dispatch_json_rpc` in `crates/api` | `LocalAgentApi` implementing the session, run, context, events, VFS and models subset. The CLI calls it in-process; `lightspeed serve` exposes it to the web UI. | 3–4 | Same | -| 2 | Session orchestrator | `CoreAgentDrive`; `SessionRunner` (synchronous drive-until-quiescent); the Temporal session workflow | Admissions handled while a model or tool call runs (steer, cancel, approvals), awaits and timers, promises, queued runs, sub-agents spawned in-process | 8–12, which includes reshaping the hosted workflow | 5–7 on its own, then every later feature built twice | -| 3 | SQLite store | Store traits in `harness`, `vfs`, `environments`, `auth`, `mcp`, `profiles`; `store-pg` as the reference | A `store-sqlite` crate with one-file migrations. jsonb containment becomes `json_each` or filtering in the app; `text[]` becomes JSON; advisory locks become a single-writer process lock. | 3–5 | Same | -| 4 | Filesystem CAS + collection | `FsBlobStore` (sha256 layout) | Wire it in; reference roots and sweeps without Postgres | 1 | Same | -| 5 | Local shell and process tools | envd (12k lines) runs on laptops today; the in-process `ProcessExecutor` is a placeholder | Embed envd as a library over an in-memory transport, or spawn it as a sidecar. Default the active environment to the working directory. | 2–3 | Same | -| 6 | Permission model for a user's own machine | MCP approvals (`AwaitingApproval`, parked runs) | Approvals for shell and writes outside the workspace, with allow rules per session and per directory. Codex and Claude Code users expect this. | 2–3 | Same | -| 7 | Identity and configuration | Single-user auth mode; model defaults; `connect` profiles in the CLI | An implicit local universe and actor; provider keys from environment variables or the OS keychain; a data directory such as `~/.lightspeed` | 1–2 | Same | -| 8 | Packaging and tests | Release pipeline for envd (musl builds); replay vectors | One `lightspeed` binary (TUI by default, plus `serve`); the substrate-neutral test suite run against both substrates | 2–3 | Tests per runtime | - -Total: about 22–33 engineer-weeks for Option C, or 19–28 for Option B before -the duplication cost. With two people in parallel, that is roughly three to -four months of calendar time; the spike in the sequence below runs alongside -the first phase. - -Two items are mostly mechanical. The gateway's Temporal calls are concentrated -in `workflow.rs` and `session_lifecycle.rs` (start, `submit_admissions`, -`status` polling, describe, terminate). Putting them behind a small -`SessionControl` trait would let the hosted gateway and the local backend share -most of the 19k-line service layer. The activity helpers return Temporal error -types (`ActivityError`, `ApplicationFailure`), so they need a neutral error -type before the local substrate can call them. - -## Risks and open questions - -The biggest risk is not the database swap. It is that two runtimes slowly -disagree about what a session does. - -**Risks** - -- **Semantic drift.** Steering, cancellation, promise deadlines and sub-agent - budgets have been hardened against live Temporal suites. A second runtime - needs the same conformance suite, written against the substrate-neutral API - and run against both substrates in CI. -- **Crash and sleep semantics.** Today Temporal retries an activity interrupted - by a worker restart. Locally, a closed laptop lid or a killed process leaves - an LLM call or shell command half-done. We need an explicit rule, probably: - retry model calls, and mark interrupted shell commands as interrupted rather - than re-running them. This overlaps the parked idempotency work for tools. -- **Two writers on one data directory.** Two CLI windows on the same session - need either a file lock per session or one local daemon that owns the store. - Codex and Claude Code avoid this by being one process per conversation. -- **Every schema change twice.** Each `store-pg` migration needs a SQLite twin. - The release metadata currently pins one schema revision. -- **Shared orchestrator under Temporal's rules.** The shared code must stay - deterministic and avoid custom wakers (the TMPRL1100 constraint). The sync - core described under [A sync core with async interpreters](#a-sync-core-with-async-interpreters) - is the mitigation; the risk is that awkward flows get forced into states. -- **Plugin contract.** The workflow-tool contract is defined in Temporal terms - (signals, queries, task queues). External plugin workflows would not run - locally unless the contract gets a second, non-Temporal binding. - -**Open questions** - -- [ ] Is the adoption blocker really infrastructure, or also the first-run - experience (keys, environments, profiles)? Option A answers this cheaply. -- [ ] Should a local session be movable to hosted, e.g. `lightspeed push`? - Sessions are event logs plus CAS, so export and import look plausible, and - that would make local an on-ramp to hosted rather than a fork. -- [ ] What is Lightspeed's differentiator against Codex, Claude Code and Pi on - a laptop? Candidates: provider-native multi-model sessions, durable - resumable sessions, VFS workspaces, sub-agents, and the same agent later - running as a hosted bot. -- [ ] Should local sandbox shell commands (macOS seatbelt, Linux namespaces), - or rely on approvals alone at first? -- [ ] Would hosted ever drop Temporal (Option D)? If not, Option C's main - payoff is a single definition of behaviour, not portability. - -## Suggested sequence - -Because the extraction is worth doing on its own, it does not have to wait for -the spike; the spike gates only the local substrate. Durations are calendar -weeks for two engineers. - -| Phase | Duration | Work | Gate after | -| --- | --- | --- | --- | -| 0 · Spike | 2–4 weeks, in parallel with phase 1 | CLI over `SessionRunner`; in-memory or SQLite store; embedded envd; try with design partners | **Go / no-go on local:** spike used on real tasks | -| 1 · Extract `sessions` | 5–7 weeks | Port one slice both ways and pick sync core or async host trait; sans-IO orchestrator; thin Temporal interpreter; `SessionControl` trait; neutral activity errors; `harness` → `harness` and `temporal-runtime` → `temporal-runtime` renames | **Hosted unchanged:** live Temporal suites green on the thin interpreter | -| 2 · Local substrate | 5–7 weeks | SQLite store; tokio interpreter of `sessions`; shell approvals; one-binary packaging | **Parity:** conformance suite green on both substrates | -| 3 · Beta and bridge | 2–3 weeks | Public local release; push session to hosted; docs and onboarding | Then revisit Option D | - -The spike is throwaway-tolerant: wire the existing CLI to an in-process -`SessionRunner` with an embedded envd and put it in front of a few design -partners to test the adoption hypothesis. Meanwhile, extract `sessions` while -hosted is its only consumer, so live suites prove nothing changed. If the spike -does not land, phase 1 still stands on its own and phases 2 and 3 wait. If it -does, the local substrate is built on the extracted core, and the spike's -`SessionRunner` is replaced by the production orchestration. +| A · Bundle Temporal and PostgreSQL | Could simplify launching today's product, but does not meet the single-process, SQLite local-runtime direction. | +| B · Independently implement local orchestration | Faster initial demo, but duplicates admission, cancellation, promises and sub-agent semantics. Not the chosen direction. | +| C · One orchestration core, two substrates | Preferred. Extract shared session/services first, then add local execution/storage while retaining Temporal hosted. | +| D · Replace hosted Temporal too | Separate, much larger distributed-systems project. Not required for this outcome. | + +The earlier 22–33 engineer-week estimate covered a narrower local session +runtime and assumed more gateway portability than the code currently provides. +Keep it as a historical planning reference, not a new commitment. It also did +not establish the cost of real multi-runtime Platform adoption, complete local +crash recovery, OAuth or an SDK callback/tool-host contract. + +Re-estimate after the first extraction and recovery spike. A credible plan must +budget separately for: + +- shared orchestration, neutral contracts and hosted conformance; +- gateway/effect service extraction, including PostgreSQL coupling; +- SQLite records/migrations and filesystem CAS recovery/collection in `store-fs`; +- local ownership, admission persistence, effect recovery and shutdown; +- environment wiring and envd packaging; +- env-based configuration, local identity and any selected credential exception; +- Platform attachment, authentication, capability handling and connectivity; +- release packaging and end-to-end parity tests. + +SDK local tools and externally managed sessions are excluded from the v1 +estimate. Full OAuth still needs an explicit scope decision. Session export/import +to hosted is independent of Platform adoption and is not an assumed beta +deliverable. + +## Open decisions + +- [ ] **Process/package boundary:** confirm that the API gateway stays inside + the owning runtime process. Choose embedded or sidecar/bundled envd and how + a CLI or SDK starts or connects to the process. +- [ ] **Universe location:** explicit data directory, a default under the user's + home, project-local `.lightspeed`, or named universes with discoverable paths? + Recommendation to evaluate: persist an independent universe UUID and let a + project directory select a universe; do not derive its identity from cwd. + Moving a directory or opening two checkouts should have defined behavior. +- [ ] **Host environment:** enable it by default or opt in; which filesystem + root and permission/sandbox policy? Keep this independent of universe/VFS + storage placement. +- [ ] **Recovery storage mechanics:** exact durable admission point, + attempt/child-execution journal and outbox layout, retry-backoff persistence, + CAS recovery and backups. These implement the agreed restart policy above; + interrupted-tool and sub-agent behavior are no longer open decisions. +- [ ] **Credentials/OAuth:** whether to allow persisted local credentials, where + to keep them, and how callbacks work. Decide runtime connection credentials + and envd identity separately from user/provider secrets. +- [ ] **Platform attachment:** direct connectivity first or a tunnel/relay; + credential ownership; actor attribution; attach/detach/deletion semantics; + universe UUID and slug conflicts across deployments. +- [ ] **Later SDK-host protocol (after v1):** tool registration/admission, + independent lifecycle control, transport, result acknowledgement and host-loss + behavior. Explore SDK launch and attach modes; this is not a v1 dependency. +- [ ] **Capability edge cases:** explicitly enumerate the supported public API + subset, including standalone transcription and credential mutation methods. + Externally managed sessions and custom workflow-tool integrations are already + excluded from v1; advertise and reject unsupported methods consistently. + +## Suggested sequence and validation + +1. **Extraction/recovery spike.** Compare sync and async designs for one racing + slice; run it against both interpreters. Prototype one SQLite-backed universe + with filesystem CAS and an ordinary envd connection. Exercise a sub-agent and + kill the runtime around admission, append, invocation and completion commits. + Verify interrupted calls let the same run continue, completed sibling results + survive, parent/child identities and deadlines are retained, and stale + completions cannot overwrite recovery outcomes. Cover restarted model calls, + preserved approval waits, nested sub-agents, unreachable or still-running + remote executions, and a second crash during recovery itself. +2. **Shared core and services, hosted first.** Extract `sessions`, neutral effect + contracts and shared API services incrementally while Temporal remains the + production interpreter. Reuse existing tests and add deterministic + interleaving/replay coverage; validate hosted behavior with the relevant + serialized live suites during implementation. +3. **Complete the local substrate.** Implement the agreed store and recovery + contract, environment/credential mode and process lifecycle. Run the same + session conformance suite against hosted and local backends, with explicit + additional local crash tests and declared differences in guarantees. Verify + that public managed-session/custom workflow integrations are unavailable + while built-in sub-agents and environment jobs still work. +4. **Platform adoption and packaging.** Attach an existing local universe with + its identity/data intact, then exercise member attribution, capabilities, + offline/reconnect behavior and detach. Choose connectivity and SDK packaging + based on the prototype; release only the agreed capability subset. + +The spike can run beside the first shared extraction. It should test the +intended small universe deployment, rather than validate only a coding-agent +CLI over the eval runner. The hosted extraction remains useful on its own: +less SDK coupling, broader unit coverage and one definition of session behavior. From e3d9af52ae69bcbe2f1899260551c437fa229f9b Mon Sep 17 00:00:00 2001 From: lb <542828+lukebuehler@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:20:46 +0200 Subject: [PATCH 2/8] constrain contributor session config --- clients/typescript/schema/api.schema.json | 158 +++++++++- clients/typescript/src/generated/methods.ts | 74 ++++- clients/typescript/src/generated/types.ts | 71 +++++ crates/api/contract/api-reference.md | 62 ++-- crates/api/contract/api.schema.json | 158 +++++++++- crates/api/contract/methods.json | 84 +++-- crates/api/contract/openrpc.json | 248 +++++++++++++-- crates/api/src/access.rs | 51 ++- crates/api/src/rpc.rs | 18 +- .../src/gateway/service/controller.rs | 19 +- .../access-and-security/people-and-roles.md | 21 +- .../private-and-shared-work.md | 8 +- .../profiles-and-instructions.md | 26 +- .../using-lightspeed/sessions-and-runs.md | 35 ++- ...m-organizations-roles-and-unshared-work.md | 30 +- platform/README.md | 13 +- platform/backend/src/routes/messages.test.ts | 12 +- platform/backend/src/routes/method-roles.ts | 16 +- platform/backend/src/runtime-client.test.ts | 102 +++++- platform/backend/src/runtime-client.ts | 43 ++- .../configurator-mcp/src/generated/tools.ts | 8 +- .../web/src/components/mcp/tool-picker.tsx | 11 +- .../session/session-config-editor.tsx | 292 ++++++++++-------- .../session/session-config-readonly.test.tsx | 53 ++++ .../session-settings-permissions.test.tsx | 59 ++++ .../session/session-settings-sheet.tsx | 27 +- platform/web/src/lib/permissions.test.tsx | 3 + platform/web/src/lib/permissions.tsx | 18 +- .../pages/ProfilesPage.permissions.test.tsx | 7 +- platform/web/src/pages/ProfilesPage.tsx | 28 +- .../pages/SessionsPage.permissions.test.tsx | 26 +- platform/web/src/pages/SessionsPage.tsx | 59 ++-- 32 files changed, 1506 insertions(+), 334 deletions(-) create mode 100644 platform/web/src/components/session/session-config-readonly.test.tsx create mode 100644 platform/web/src/components/session/session-settings-permissions.test.tsx diff --git a/clients/typescript/schema/api.schema.json b/clients/typescript/schema/api.schema.json index ef95aaa89..b826c8b0f 100644 --- a/clients/typescript/schema/api.schema.json +++ b/clients/typescript/schema/api.schema.json @@ -1186,6 +1186,40 @@ ], "type": "object" }, + "AgentApiOutcomeOfDeploymentSessionAuditListResponse": { + "properties": { + "notifications": { + "items": { + "$ref": "#/definitions/AgentNotification" + }, + "type": "array" + }, + "result": { + "$ref": "#/definitions/DeploymentSessionAuditListResponse" + } + }, + "required": [ + "result" + ], + "type": "object" + }, + "AgentApiOutcomeOfDeploymentSessionPurgeResponse": { + "properties": { + "notifications": { + "items": { + "$ref": "#/definitions/AgentNotification" + }, + "type": "array" + }, + "result": { + "$ref": "#/definitions/DeploymentSessionPurgeResponse" + } + }, + "required": [ + "result" + ], + "type": "object" + }, "AgentApiOutcomeOfDeploymentUniverseCreateResponse": { "properties": { "notifications": { @@ -8856,6 +8890,72 @@ ], "type": "object" }, + "DeploymentSessionAuditListParams": { + "additionalProperties": false, + "properties": { + "limit": { + "description": "Most recent records, between 1 and 1000; defaults to 100.", + "format": "uint32", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "universeId": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "DeploymentSessionAuditListResponse": { + "properties": { + "events": { + "items": { + "$ref": "#/definitions/SessionAuditEvent" + }, + "type": "array" + } + }, + "required": [ + "events" + ], + "type": "object" + }, + "DeploymentSessionPurgeParams": { + "additionalProperties": false, + "properties": { + "sessionId": { + "description": "An already deleted session; includes its deleted descendants.", + "type": "string" + }, + "universeId": { + "type": "string" + } + }, + "required": [ + "universeId", + "sessionId" + ], + "type": "object" + }, + "DeploymentSessionPurgeResponse": { + "properties": { + "purgedSessionIds": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "purgedSessionIds" + ], + "type": "object" + }, "DeploymentUniverseCreateParams": { "properties": { "slug": { @@ -11998,7 +12098,8 @@ "mcp", "bots", "deployment/universes", - "deployment/api-keys" + "deployment/api-keys", + "deployment/sessions" ], "type": "string" }, @@ -12007,6 +12108,11 @@ "description": "`session/*`, `blobs/read` and `blobs/has`.", "type": "string" }, + { + "const": "session/delete", + "description": "Destructive session operations and deletion retention.", + "type": "string" + }, { "const": "blobs/put", "description": "`blobs/put` alone, so connectors can upload attachments without\nreading sessions.", @@ -13763,6 +13869,54 @@ } ] }, + "SessionAuditEvent": { + "properties": { + "action": { + "type": "string" + }, + "affectedSessionIds": { + "items": { + "type": "string" + }, + "type": "array" + }, + "attribution": { + "$ref": "#/definitions/Attribution" + }, + "cause": { + "type": "string" + }, + "createdAtMs": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "id": { + "type": "string" + }, + "outcome": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "universeId": { + "type": "string" + } + }, + "required": [ + "id", + "universeId", + "sessionId", + "action", + "attribution", + "cause", + "affectedSessionIds", + "createdAtMs", + "outcome" + ], + "type": "object" + }, "SessionCloseParams": { "properties": { "force": { @@ -17679,7 +17833,9 @@ "read", "create_session", "control_session", + "configure_session", "stop_session", + "close_session", "delete_session", "create_profile", "manage_profile", diff --git a/clients/typescript/src/generated/methods.ts b/clients/typescript/src/generated/methods.ts index 65fa84e07..fa458b3b7 100644 --- a/clients/typescript/src/generated/methods.ts +++ b/clients/typescript/src/generated/methods.ts @@ -125,6 +125,8 @@ export const METHODS = [ "channels/pairings/list", "channels/pairings/delete", "channels/conversations/read", + "deployment/sessions/audit/list", + "deployment/sessions/purge", "deployment/environment-provider-bindings/list", "deployment/universes/create", "deployment/universes/list", @@ -184,7 +186,7 @@ export const METHOD_INFO = { }, "session/config/put": { scope: "universe", - access: {"action":"control_session","kind":"universe"}, + access: {"action":"configure_session","kind":"universe"}, summary: "Replace session configuration", description: "Replaces the complete sparse config while the session is idle. Use the current config revision for safe read-modify-write; omitted features are revoked, an omitted model preserves the current model, and an identical document is a no-op.", }, @@ -196,19 +198,19 @@ export const METHOD_INFO = { }, "session/metadata/put": { scope: "universe", - access: {"action":"control_session","kind":"universe"}, + access: {"action":"configure_session","kind":"universe"}, summary: "Replace session metadata", description: "Replaces the complete descriptive key/value map (bounded like session/start); an omitted or empty map clears it. Record-only: the event log and updatedAtMs are untouched.", }, "session/retention/put": { scope: "universe", - access: {"action":"control_session","kind":"universe"}, + access: {"action":"delete_session","kind":"universe"}, summary: "Replace session retention", description: "Sets the positive close-relative automatic-deletion duration on a retention root, or clears it with null. Forks and delegated children inherit the root policy and cannot override it.", }, "session/close": { scope: "universe", - access: {"action":"stop_session","kind":"universe"}, + access: {"action":"close_session","kind":"universe"}, summary: "Close a session", description: "Closes an idle session and detaches its environment bindings. Force mode cancels active work, drops queued runs, and can recover a session whose workflow is unavailable.", }, @@ -216,7 +218,7 @@ export const METHOD_INFO = { scope: "universe", access: {"action":"delete_session","kind":"universe"}, summary: "Delete closed sessions", - description: "Permanently removes a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Config-only clones are never included.", + description: "Hides a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Retained records are purged after 30 days. Config-only clones are never included.", }, "session/share": { scope: "universe", @@ -298,19 +300,19 @@ export const METHOD_INFO = { }, "session/profiles/apply": { scope: "universe", - access: {"action":"control_session","kind":"universe"}, + access: {"action":"configure_session","kind":"universe"}, summary: "Apply a profile to a session", description: "Applies a named or inline profile's config, instructions, and environment setup to an existing session; mutating profile sections require it to be open and idle. Pass current revisions to guard concurrent changes.", }, "session/environments/activate": { scope: "universe", - access: {"action":"control_session","kind":"universe"}, + access: {"action":"configure_session","kind":"universe"}, summary: "Activate a session environment", description: "Selects an attached, live universe environment for environment-targeted tools while the session is idle.", }, "session/environments/deactivate": { scope: "universe", - access: {"action":"control_session","kind":"universe"}, + access: {"action":"configure_session","kind":"universe"}, summary: "Deactivate the session environment", description: "Clears active environment selection without changing or closing the universe environment.", }, @@ -744,7 +746,7 @@ export const METHOD_INFO = { scope: "universe", access: {"action":"manage_bot","kind":"universe"}, summary: "Delete a bot", - description: "Closes the bot if needed, waits for its controller to complete, deletes the sessions it closed, and removes the record so the bot id is free again.", + description: "Closes the bot if needed, waits for its controller to complete, retains its session history, and removes the record so the bot id is free again.", }, "bots/state/read": { scope: "universe", @@ -866,6 +868,18 @@ export const METHOD_INFO = { summary: "Read a conversation snapshot", description: "Queries the conversation workflow's live state for one chat, for debugging; absent when no workflow exists yet.", }, + "deployment/sessions/audit/list": { + scope: "deployment", + access: {"kind":"deployment"}, + summary: "Read session lifecycle audit", + description: "Returns durable lifecycle records, including deletion and purge evidence that survives session removal.", + }, + "deployment/sessions/purge": { + scope: "deployment", + access: {"kind":"deployment"}, + summary: "Permanently purge deleted sessions", + description: "Permanently removes an already deleted session and its deleted descendants before the automatic purge deadline. Idempotent; does not delete attached workspaces or environments.", + }, "deployment/environment-provider-bindings/list": { scope: "deployment", access: {"kind":"deployment"}, @@ -1091,7 +1105,7 @@ export interface MethodMap { /** * Delete closed sessions * - * Permanently removes a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Config-only clones are never included. + * Hides a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Retained records are purged after 30 days. Config-only clones are never included. */ "session/delete": { params: Api.SessionDeleteParams; @@ -1883,7 +1897,7 @@ export interface MethodMap { /** * Delete a bot * - * Closes the bot if needed, waits for its controller to complete, deletes the sessions it closed, and removes the record so the bot id is free again. + * Closes the bot if needed, waits for its controller to complete, retains its session history, and removes the record so the bot id is free again. */ "bots/delete": { params: Api.BotDeleteParams; @@ -2069,6 +2083,24 @@ export interface MethodMap { params: Api.ChannelConversationReadParams; result: Api.AgentApiOutcomeOfChannelConversationReadResponse; }; + /** + * Read session lifecycle audit + * + * Returns durable lifecycle records, including deletion and purge evidence that survives session removal. + */ + "deployment/sessions/audit/list": { + params: Api.DeploymentSessionAuditListParams; + result: Api.AgentApiOutcomeOfDeploymentSessionAuditListResponse; + }; + /** + * Permanently purge deleted sessions + * + * Permanently removes an already deleted session and its deleted descendants before the automatic purge deadline. Idempotent; does not delete attached workspaces or environments. + */ + "deployment/sessions/purge": { + params: Api.DeploymentSessionPurgeParams; + result: Api.AgentApiOutcomeOfDeploymentSessionPurgeResponse; + }; /** * List a universe's deployment provider bindings * @@ -2332,7 +2364,7 @@ export const rpc = { /** * Delete closed sessions * - * Permanently removes a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Config-only clones are never included. + * Hides a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Retained records are purged after 30 days. Config-only clones are never included. */ sessionDelete(client: RpcCaller, params: Api.SessionDeleteParams): Promise { return client.call("session/delete", params); @@ -3036,7 +3068,7 @@ export const rpc = { /** * Delete a bot * - * Closes the bot if needed, waits for its controller to complete, deletes the sessions it closed, and removes the record so the bot id is free again. + * Closes the bot if needed, waits for its controller to complete, retains its session history, and removes the record so the bot id is free again. */ botsDelete(client: RpcCaller, params: Api.BotDeleteParams): Promise { return client.call("bots/delete", params); @@ -3201,6 +3233,22 @@ export const rpc = { channelsConversationsRead(client: RpcCaller, params: Api.ChannelConversationReadParams): Promise { return client.call("channels/conversations/read", params); }, + /** + * Read session lifecycle audit + * + * Returns durable lifecycle records, including deletion and purge evidence that survives session removal. + */ + deploymentSessionsAuditList(client: RpcCaller, params: Api.DeploymentSessionAuditListParams): Promise { + return client.call("deployment/sessions/audit/list", params); + }, + /** + * Permanently purge deleted sessions + * + * Permanently removes an already deleted session and its deleted descendants before the automatic purge deadline. Idempotent; does not delete attached workspaces or environments. + */ + deploymentSessionsPurge(client: RpcCaller, params: Api.DeploymentSessionPurgeParams): Promise { + return client.call("deployment/sessions/purge", params); + }, /** * List a universe's deployment provider bindings * diff --git a/clients/typescript/src/generated/types.ts b/clients/typescript/src/generated/types.ts index 630fe1779..48e194552 100644 --- a/clients/typescript/src/generated/types.ts +++ b/clients/typescript/src/generated/types.ts @@ -1340,8 +1340,10 @@ export type MethodGroup = | "bots" | "deployment/universes" | "deployment/api-keys" + | "deployment/sessions" ) | "session" + | "session/delete" | "blobs/put" | "environments" | "channels" @@ -1817,7 +1819,9 @@ export type UniverseAction = | "read" | "create_session" | "control_session" + | "configure_session" | "stop_session" + | "close_session" | "delete_session" | "create_profile" | "manage_profile" @@ -4808,6 +4812,51 @@ export interface AgentApiOutcomeOfDeploymentProviderBindingPutResponse { export interface DeploymentProviderBindingPutResponse { binding: EnvironmentProviderBindingView; } +/** + * This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema + * via the `definition` "AgentApiOutcomeOfDeploymentSessionAuditListResponse". + */ +export interface AgentApiOutcomeOfDeploymentSessionAuditListResponse { + notifications?: AgentNotification[]; + result: DeploymentSessionAuditListResponse; +} +/** + * This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema + * via the `definition` "DeploymentSessionAuditListResponse". + */ +export interface DeploymentSessionAuditListResponse { + events: SessionAuditEvent[]; +} +/** + * This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema + * via the `definition` "SessionAuditEvent". + */ +export interface SessionAuditEvent { + action: string; + affectedSessionIds: string[]; + attribution: Attribution; + cause: string; + createdAtMs: number; + id: string; + outcome: string; + sessionId: string; + universeId: string; +} +/** + * This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema + * via the `definition` "AgentApiOutcomeOfDeploymentSessionPurgeResponse". + */ +export interface AgentApiOutcomeOfDeploymentSessionPurgeResponse { + notifications?: AgentNotification[]; + result: DeploymentSessionPurgeResponse; +} +/** + * This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema + * via the `definition` "DeploymentSessionPurgeResponse". + */ +export interface DeploymentSessionPurgeResponse { + purgedSessionIds: string[]; +} /** * This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema * via the `definition` "AgentApiOutcomeOfDeploymentUniverseCreateResponse". @@ -7332,6 +7381,28 @@ export interface DeploymentProviderBindingPutParams { status: EnvironmentProviderBindingStatusView; universeId: string; } +/** + * This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema + * via the `definition` "DeploymentSessionAuditListParams". + */ +export interface DeploymentSessionAuditListParams { + /** + * Most recent records, between 1 and 1000; defaults to 100. + */ + limit?: number | null; + universeId?: string | null; +} +/** + * This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema + * via the `definition` "DeploymentSessionPurgeParams". + */ +export interface DeploymentSessionPurgeParams { + /** + * An already deleted session; includes its deleted descendants. + */ + sessionId: string; + universeId: string; +} /** * This interface was referenced by `LightspeedAgentAPI`'s JSON-Schema * via the `definition` "DeploymentUniverseCreateParams". diff --git a/crates/api/contract/api-reference.md b/crates/api/contract/api-reference.md index 5403403db..e6855a091 100644 --- a/crates/api/contract/api-reference.md +++ b/crates/api/contract/api-reference.md @@ -96,9 +96,9 @@ Returns a cursor-paginated summary list ordered by most recent update, optionall Replaces the complete sparse config while the session is idle. Use the current config revision for safe read-modify-write; omitted features are revoked, an omitted model preserves the current model, and an identical document is a no-op. -- Access: `{"kind":"universe","action":"control_session"}` +- Access: `{"kind":"universe","action":"configure_session"}` - Group: `session` -- Role: `contributor` +- Role: `operator` - Target: `sessionId` - Params: `SessionConfigPutParams` - Result: `AgentApiOutcome` @@ -122,9 +122,9 @@ Sets the display name, or clears it when displayName is omitted. Replaces the complete descriptive key/value map (bounded like session/start); an omitted or empty map clears it. Record-only: the event log and updatedAtMs are untouched. -- Access: `{"kind":"universe","action":"control_session"}` +- Access: `{"kind":"universe","action":"configure_session"}` - Group: `session` -- Role: `contributor` +- Role: `operator` - Target: `sessionId` - Params: `SessionMetadataPutParams` - Result: `AgentApiOutcome` @@ -135,9 +135,9 @@ Replaces the complete descriptive key/value map (bounded like session/start); an Sets the positive close-relative automatic-deletion duration on a retention root, or clears it with null. Forks and delegated children inherit the root policy and cannot override it. -- Access: `{"kind":"universe","action":"control_session"}` -- Group: `session` -- Role: `contributor` +- Access: `{"kind":"universe","action":"delete_session"}` +- Group: `session/delete` +- Role: `admin` - Target: `sessionId` - Params: `SessionRetentionPutParams` - Result: `AgentApiOutcome` @@ -148,7 +148,7 @@ Sets the positive close-relative automatic-deletion duration on a retention root Closes an idle session and detaches its environment bindings. Force mode cancels active work, drops queued runs, and can recover a session whose workflow is unavailable. -- Access: `{"kind":"universe","action":"stop_session"}` +- Access: `{"kind":"universe","action":"close_session"}` - Group: `session` - Role: `contributor` - Target: `sessionId` @@ -159,11 +159,11 @@ Closes an idle session and detaches its environment bindings. Force mode cancels **Delete closed sessions** -Permanently removes a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Config-only clones are never included. +Hides a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Retained records are purged after 30 days. Config-only clones are never included. - Access: `{"kind":"universe","action":"delete_session"}` -- Group: `session` -- Role: `contributor` +- Group: `session/delete` +- Role: `admin` - Target: `sessionId` - Params: `SessionDeleteParams` - Result: `AgentApiOutcome` @@ -343,9 +343,9 @@ Returns separate VFS and environment catalogs with source, reference, availabili Applies a named or inline profile's config, instructions, and environment setup to an existing session; mutating profile sections require it to be open and idle. Pass current revisions to guard concurrent changes. -- Access: `{"kind":"universe","action":"control_session"}` +- Access: `{"kind":"universe","action":"configure_session"}` - Group: `session` -- Role: `contributor` +- Role: `operator` - Target: `sessionId` - Params: `ProfileApplyParams` - Result: `AgentApiOutcome` @@ -356,9 +356,9 @@ Applies a named or inline profile's config, instructions, and environment setup Selects an attached, live universe environment for environment-targeted tools while the session is idle. -- Access: `{"kind":"universe","action":"control_session"}` +- Access: `{"kind":"universe","action":"configure_session"}` - Group: `session` -- Role: `contributor` +- Role: `operator` - Target: `sessionId` - Params: `SessionEnvironmentActivateParams` - Result: `AgentApiOutcome` @@ -369,9 +369,9 @@ Selects an attached, live universe environment for environment-targeted tools wh Clears active environment selection without changing or closing the universe environment. -- Access: `{"kind":"universe","action":"control_session"}` +- Access: `{"kind":"universe","action":"configure_session"}` - Group: `session` -- Role: `contributor` +- Role: `operator` - Target: `sessionId` - Params: `SessionEnvironmentDeactivateParams` - Result: `AgentApiOutcome` @@ -1309,7 +1309,7 @@ Terminal and idempotent: disables every trigger, drops schedules, and tells the **Delete a bot** -Closes the bot if needed, waits for its controller to complete, deletes the sessions it closed, and removes the record so the bot id is free again. +Closes the bot if needed, waits for its controller to complete, retains its session history, and removes the record so the bot id is free again. - Access: `{"kind":"universe","action":"manage_bot"}` - Group: `bots` @@ -1587,6 +1587,32 @@ Queries the conversation workflow's live state for one chat, for debugging; abse ## Deployment methods +### `deployment/sessions/audit/list` + +**Read session lifecycle audit** + +Returns durable lifecycle records, including deletion and purge evidence that survives session removal. + +- Access: `{"kind":"deployment"}` +- Group: `deployment/sessions` +- Role: `none` +- Target: `none` +- Params: `DeploymentSessionAuditListParams` +- Result: `AgentApiOutcome` + +### `deployment/sessions/purge` + +**Permanently purge deleted sessions** + +Permanently removes an already deleted session and its deleted descendants before the automatic purge deadline. Idempotent; does not delete attached workspaces or environments. + +- Access: `{"kind":"deployment"}` +- Group: `deployment/sessions` +- Role: `none` +- Target: `none` +- Params: `DeploymentSessionPurgeParams` +- Result: `AgentApiOutcome` + ### `deployment/environment-provider-bindings/list` **List a universe's deployment provider bindings** diff --git a/crates/api/contract/api.schema.json b/crates/api/contract/api.schema.json index ef95aaa89..b826c8b0f 100644 --- a/crates/api/contract/api.schema.json +++ b/crates/api/contract/api.schema.json @@ -1186,6 +1186,40 @@ ], "type": "object" }, + "AgentApiOutcomeOfDeploymentSessionAuditListResponse": { + "properties": { + "notifications": { + "items": { + "$ref": "#/definitions/AgentNotification" + }, + "type": "array" + }, + "result": { + "$ref": "#/definitions/DeploymentSessionAuditListResponse" + } + }, + "required": [ + "result" + ], + "type": "object" + }, + "AgentApiOutcomeOfDeploymentSessionPurgeResponse": { + "properties": { + "notifications": { + "items": { + "$ref": "#/definitions/AgentNotification" + }, + "type": "array" + }, + "result": { + "$ref": "#/definitions/DeploymentSessionPurgeResponse" + } + }, + "required": [ + "result" + ], + "type": "object" + }, "AgentApiOutcomeOfDeploymentUniverseCreateResponse": { "properties": { "notifications": { @@ -8856,6 +8890,72 @@ ], "type": "object" }, + "DeploymentSessionAuditListParams": { + "additionalProperties": false, + "properties": { + "limit": { + "description": "Most recent records, between 1 and 1000; defaults to 100.", + "format": "uint32", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "universeId": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "DeploymentSessionAuditListResponse": { + "properties": { + "events": { + "items": { + "$ref": "#/definitions/SessionAuditEvent" + }, + "type": "array" + } + }, + "required": [ + "events" + ], + "type": "object" + }, + "DeploymentSessionPurgeParams": { + "additionalProperties": false, + "properties": { + "sessionId": { + "description": "An already deleted session; includes its deleted descendants.", + "type": "string" + }, + "universeId": { + "type": "string" + } + }, + "required": [ + "universeId", + "sessionId" + ], + "type": "object" + }, + "DeploymentSessionPurgeResponse": { + "properties": { + "purgedSessionIds": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "purgedSessionIds" + ], + "type": "object" + }, "DeploymentUniverseCreateParams": { "properties": { "slug": { @@ -11998,7 +12098,8 @@ "mcp", "bots", "deployment/universes", - "deployment/api-keys" + "deployment/api-keys", + "deployment/sessions" ], "type": "string" }, @@ -12007,6 +12108,11 @@ "description": "`session/*`, `blobs/read` and `blobs/has`.", "type": "string" }, + { + "const": "session/delete", + "description": "Destructive session operations and deletion retention.", + "type": "string" + }, { "const": "blobs/put", "description": "`blobs/put` alone, so connectors can upload attachments without\nreading sessions.", @@ -13763,6 +13869,54 @@ } ] }, + "SessionAuditEvent": { + "properties": { + "action": { + "type": "string" + }, + "affectedSessionIds": { + "items": { + "type": "string" + }, + "type": "array" + }, + "attribution": { + "$ref": "#/definitions/Attribution" + }, + "cause": { + "type": "string" + }, + "createdAtMs": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "id": { + "type": "string" + }, + "outcome": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "universeId": { + "type": "string" + } + }, + "required": [ + "id", + "universeId", + "sessionId", + "action", + "attribution", + "cause", + "affectedSessionIds", + "createdAtMs", + "outcome" + ], + "type": "object" + }, "SessionCloseParams": { "properties": { "force": { @@ -17679,7 +17833,9 @@ "read", "create_session", "control_session", + "configure_session", "stop_session", + "close_session", "delete_session", "create_profile", "manage_profile", diff --git a/crates/api/contract/methods.json b/crates/api/contract/methods.json index f97adef00..60ed7c217 100644 --- a/crates/api/contract/methods.json +++ b/crates/api/contract/methods.json @@ -151,7 +151,7 @@ }, { "access": { - "action": "control_session", + "action": "configure_session", "kind": "universe" }, "description": "Replaces the complete sparse config while the session is idle. Use the current config revision for safe read-modify-write; omitted features are revoked, an omitted model preserves the current model, and an identical document is a no-op.", @@ -169,7 +169,7 @@ }, "type": "AgentApiOutcome" }, - "role": "contributor", + "role": "operator", "scope": "universe", "summary": "Replace session configuration", "target": "sessionId" @@ -201,7 +201,7 @@ }, { "access": { - "action": "control_session", + "action": "configure_session", "kind": "universe" }, "description": "Replaces the complete descriptive key/value map (bounded like session/start); an omitted or empty map clears it. Record-only: the event log and updatedAtMs are untouched.", @@ -219,18 +219,18 @@ }, "type": "AgentApiOutcome" }, - "role": "contributor", + "role": "operator", "scope": "universe", "summary": "Replace session metadata", "target": "sessionId" }, { "access": { - "action": "control_session", + "action": "delete_session", "kind": "universe" }, "description": "Sets the positive close-relative automatic-deletion duration on a retention root, or clears it with null. Forks and delegated children inherit the root policy and cannot override it.", - "group": "session", + "group": "session/delete", "method": "session/retention/put", "params": { "schema": { @@ -244,14 +244,14 @@ }, "type": "AgentApiOutcome" }, - "role": "contributor", + "role": "admin", "scope": "universe", "summary": "Replace session retention", "target": "sessionId" }, { "access": { - "action": "stop_session", + "action": "close_session", "kind": "universe" }, "description": "Closes an idle session and detaches its environment bindings. Force mode cancels active work, drops queued runs, and can recover a session whose workflow is unavailable.", @@ -279,8 +279,8 @@ "action": "delete_session", "kind": "universe" }, - "description": "Permanently removes a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Config-only clones are never included.", - "group": "session", + "description": "Hides a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Retained records are purged after 30 days. Config-only clones are never included.", + "group": "session/delete", "method": "session/delete", "params": { "schema": { @@ -294,7 +294,7 @@ }, "type": "AgentApiOutcome" }, - "role": "contributor", + "role": "admin", "scope": "universe", "summary": "Delete closed sessions", "target": "sessionId" @@ -626,7 +626,7 @@ }, { "access": { - "action": "control_session", + "action": "configure_session", "kind": "universe" }, "description": "Applies a named or inline profile's config, instructions, and environment setup to an existing session; mutating profile sections require it to be open and idle. Pass current revisions to guard concurrent changes.", @@ -644,14 +644,14 @@ }, "type": "AgentApiOutcome" }, - "role": "contributor", + "role": "operator", "scope": "universe", "summary": "Apply a profile to a session", "target": "sessionId" }, { "access": { - "action": "control_session", + "action": "configure_session", "kind": "universe" }, "description": "Selects an attached, live universe environment for environment-targeted tools while the session is idle.", @@ -669,14 +669,14 @@ }, "type": "AgentApiOutcome" }, - "role": "contributor", + "role": "operator", "scope": "universe", "summary": "Activate a session environment", "target": "sessionId" }, { "access": { - "action": "control_session", + "action": "configure_session", "kind": "universe" }, "description": "Clears active environment selection without changing or closing the universe environment.", @@ -694,7 +694,7 @@ }, "type": "AgentApiOutcome" }, - "role": "contributor", + "role": "operator", "scope": "universe", "summary": "Deactivate the session environment", "target": "sessionId" @@ -2478,7 +2478,7 @@ "action": "manage_bot", "kind": "universe" }, - "description": "Closes the bot if needed, waits for its controller to complete, deletes the sessions it closed, and removes the record so the bot id is free again.", + "description": "Closes the bot if needed, waits for its controller to complete, retains its session history, and removes the record so the bot id is free again.", "group": "bots", "method": "bots/delete", "params": { @@ -2997,6 +2997,54 @@ "summary": "Read a conversation snapshot", "target": null }, + { + "access": { + "kind": "deployment" + }, + "description": "Returns durable lifecycle records, including deletion and purge evidence that survives session removal.", + "group": "deployment/sessions", + "method": "deployment/sessions/audit/list", + "params": { + "schema": { + "$ref": "#/definitions/DeploymentSessionAuditListParams" + }, + "type": "DeploymentSessionAuditListParams" + }, + "result": { + "schema": { + "$ref": "#/definitions/AgentApiOutcomeOfDeploymentSessionAuditListResponse" + }, + "type": "AgentApiOutcome" + }, + "role": null, + "scope": "deployment", + "summary": "Read session lifecycle audit", + "target": null + }, + { + "access": { + "kind": "deployment" + }, + "description": "Permanently removes an already deleted session and its deleted descendants before the automatic purge deadline. Idempotent; does not delete attached workspaces or environments.", + "group": "deployment/sessions", + "method": "deployment/sessions/purge", + "params": { + "schema": { + "$ref": "#/definitions/DeploymentSessionPurgeParams" + }, + "type": "DeploymentSessionPurgeParams" + }, + "result": { + "schema": { + "$ref": "#/definitions/AgentApiOutcomeOfDeploymentSessionPurgeResponse" + }, + "type": "AgentApiOutcome" + }, + "role": null, + "scope": "deployment", + "summary": "Permanently purge deleted sessions", + "target": null + }, { "access": { "kind": "deployment" diff --git a/crates/api/contract/openrpc.json b/crates/api/contract/openrpc.json index 5c68ae3a6..ff2e61c41 100644 --- a/crates/api/contract/openrpc.json +++ b/crates/api/contract/openrpc.json @@ -1186,6 +1186,40 @@ ], "type": "object" }, + "AgentApiOutcomeOfDeploymentSessionAuditListResponse": { + "properties": { + "notifications": { + "items": { + "$ref": "#/components/schemas/AgentNotification" + }, + "type": "array" + }, + "result": { + "$ref": "#/components/schemas/DeploymentSessionAuditListResponse" + } + }, + "required": [ + "result" + ], + "type": "object" + }, + "AgentApiOutcomeOfDeploymentSessionPurgeResponse": { + "properties": { + "notifications": { + "items": { + "$ref": "#/components/schemas/AgentNotification" + }, + "type": "array" + }, + "result": { + "$ref": "#/components/schemas/DeploymentSessionPurgeResponse" + } + }, + "required": [ + "result" + ], + "type": "object" + }, "AgentApiOutcomeOfDeploymentUniverseCreateResponse": { "properties": { "notifications": { @@ -8856,6 +8890,72 @@ ], "type": "object" }, + "DeploymentSessionAuditListParams": { + "additionalProperties": false, + "properties": { + "limit": { + "description": "Most recent records, between 1 and 1000; defaults to 100.", + "format": "uint32", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "universeId": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "DeploymentSessionAuditListResponse": { + "properties": { + "events": { + "items": { + "$ref": "#/components/schemas/SessionAuditEvent" + }, + "type": "array" + } + }, + "required": [ + "events" + ], + "type": "object" + }, + "DeploymentSessionPurgeParams": { + "additionalProperties": false, + "properties": { + "sessionId": { + "description": "An already deleted session; includes its deleted descendants.", + "type": "string" + }, + "universeId": { + "type": "string" + } + }, + "required": [ + "universeId", + "sessionId" + ], + "type": "object" + }, + "DeploymentSessionPurgeResponse": { + "properties": { + "purgedSessionIds": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "purgedSessionIds" + ], + "type": "object" + }, "DeploymentUniverseCreateParams": { "properties": { "slug": { @@ -11998,7 +12098,8 @@ "mcp", "bots", "deployment/universes", - "deployment/api-keys" + "deployment/api-keys", + "deployment/sessions" ], "type": "string" }, @@ -12007,6 +12108,11 @@ "description": "`session/*`, `blobs/read` and `blobs/has`.", "type": "string" }, + { + "const": "session/delete", + "description": "Destructive session operations and deletion retention.", + "type": "string" + }, { "const": "blobs/put", "description": "`blobs/put` alone, so connectors can upload attachments without\nreading sessions.", @@ -13763,6 +13869,54 @@ } ] }, + "SessionAuditEvent": { + "properties": { + "action": { + "type": "string" + }, + "affectedSessionIds": { + "items": { + "type": "string" + }, + "type": "array" + }, + "attribution": { + "$ref": "#/components/schemas/Attribution" + }, + "cause": { + "type": "string" + }, + "createdAtMs": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "id": { + "type": "string" + }, + "outcome": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "universeId": { + "type": "string" + } + }, + "required": [ + "id", + "universeId", + "sessionId", + "action", + "attribution", + "cause", + "affectedSessionIds", + "createdAtMs", + "outcome" + ], + "type": "object" + }, "SessionCloseParams": { "properties": { "force": { @@ -17679,7 +17833,9 @@ "read", "create_session", "control_session", + "configure_session", "stop_session", + "close_session", "delete_session", "create_profile", "manage_profile", @@ -18785,11 +18941,11 @@ }, "summary": "Replace session configuration", "x-lightspeed-access": { - "action": "control_session", + "action": "configure_session", "kind": "universe" }, "x-lightspeed-group": "session", - "x-lightspeed-role": "contributor", + "x-lightspeed-role": "operator", "x-lightspeed-target": "sessionId" }, { @@ -18841,11 +18997,11 @@ }, "summary": "Replace session metadata", "x-lightspeed-access": { - "action": "control_session", + "action": "configure_session", "kind": "universe" }, "x-lightspeed-group": "session", - "x-lightspeed-role": "contributor", + "x-lightspeed-role": "operator", "x-lightspeed-target": "sessionId" }, { @@ -18869,11 +19025,11 @@ }, "summary": "Replace session retention", "x-lightspeed-access": { - "action": "control_session", + "action": "delete_session", "kind": "universe" }, - "x-lightspeed-group": "session", - "x-lightspeed-role": "contributor", + "x-lightspeed-group": "session/delete", + "x-lightspeed-role": "admin", "x-lightspeed-target": "sessionId" }, { @@ -18897,7 +19053,7 @@ }, "summary": "Close a session", "x-lightspeed-access": { - "action": "stop_session", + "action": "close_session", "kind": "universe" }, "x-lightspeed-group": "session", @@ -18905,7 +19061,7 @@ "x-lightspeed-target": "sessionId" }, { - "description": "Permanently removes a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Config-only clones are never included.", + "description": "Hides a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Retained records are purged after 30 days. Config-only clones are never included.", "name": "session/delete", "paramStructure": "by-name", "params": [ @@ -18928,8 +19084,8 @@ "action": "delete_session", "kind": "universe" }, - "x-lightspeed-group": "session", - "x-lightspeed-role": "contributor", + "x-lightspeed-group": "session/delete", + "x-lightspeed-role": "admin", "x-lightspeed-target": "sessionId" }, { @@ -19317,11 +19473,11 @@ }, "summary": "Apply a profile to a session", "x-lightspeed-access": { - "action": "control_session", + "action": "configure_session", "kind": "universe" }, "x-lightspeed-group": "session", - "x-lightspeed-role": "contributor", + "x-lightspeed-role": "operator", "x-lightspeed-target": "sessionId" }, { @@ -19345,11 +19501,11 @@ }, "summary": "Activate a session environment", "x-lightspeed-access": { - "action": "control_session", + "action": "configure_session", "kind": "universe" }, "x-lightspeed-group": "session", - "x-lightspeed-role": "contributor", + "x-lightspeed-role": "operator", "x-lightspeed-target": "sessionId" }, { @@ -19373,11 +19529,11 @@ }, "summary": "Deactivate the session environment", "x-lightspeed-access": { - "action": "control_session", + "action": "configure_session", "kind": "universe" }, "x-lightspeed-group": "session", - "x-lightspeed-role": "contributor", + "x-lightspeed-role": "operator", "x-lightspeed-target": "sessionId" }, { @@ -21368,7 +21524,7 @@ "x-lightspeed-target": null }, { - "description": "Closes the bot if needed, waits for its controller to complete, deletes the sessions it closed, and removes the record so the bot id is free again.", + "description": "Closes the bot if needed, waits for its controller to complete, retains its session history, and removes the record so the bot id is free again.", "name": "bots/delete", "paramStructure": "by-name", "params": [ @@ -21954,6 +22110,60 @@ "x-lightspeed-role": "viewer", "x-lightspeed-target": null }, + { + "description": "Returns durable lifecycle records, including deletion and purge evidence that survives session removal.", + "name": "deployment/sessions/audit/list", + "paramStructure": "by-name", + "params": [ + { + "name": "params", + "required": true, + "schema": { + "$ref": "#/components/schemas/DeploymentSessionAuditListParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/AgentApiOutcomeOfDeploymentSessionAuditListResponse" + } + }, + "summary": "Read session lifecycle audit", + "x-lightspeed-access": { + "kind": "deployment" + }, + "x-lightspeed-group": "deployment/sessions", + "x-lightspeed-role": null, + "x-lightspeed-target": null + }, + { + "description": "Permanently removes an already deleted session and its deleted descendants before the automatic purge deadline. Idempotent; does not delete attached workspaces or environments.", + "name": "deployment/sessions/purge", + "paramStructure": "by-name", + "params": [ + { + "name": "params", + "required": true, + "schema": { + "$ref": "#/components/schemas/DeploymentSessionPurgeParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/AgentApiOutcomeOfDeploymentSessionPurgeResponse" + } + }, + "summary": "Permanently purge deleted sessions", + "x-lightspeed-access": { + "kind": "deployment" + }, + "x-lightspeed-group": "deployment/sessions", + "x-lightspeed-role": null, + "x-lightspeed-target": null + }, { "description": "Deployment configuration inventory of one universe's provider bindings.", "name": "deployment/environment-provider-bindings/list", diff --git a/crates/api/src/access.rs b/crates/api/src/access.rs index cf05480cc..1c1b92bc7 100644 --- a/crates/api/src/access.rs +++ b/crates/api/src/access.rs @@ -47,6 +47,9 @@ pub enum MethodGroup { /// `session/*`, `blobs/read` and `blobs/has`. #[serde(rename = "session")] Session, + /// Destructive session operations and deletion retention. + #[serde(rename = "session/delete")] + SessionDelete, /// `blobs/put` alone, so connectors can upload attachments without /// reading sessions. #[serde(rename = "blobs/put")] @@ -88,11 +91,14 @@ pub enum MethodGroup { /// `deployment/channels/accounts/list`: a connector host's discovery. #[serde(rename = "deployment/channels")] DeploymentChannels, + #[serde(rename = "deployment/sessions")] + DeploymentSessions, } impl MethodGroup { - pub const ALL: [MethodGroup; 17] = [ + pub const ALL: [MethodGroup; 19] = [ Self::Session, + Self::SessionDelete, Self::BlobsPut, Self::Vfs, Self::Profiles, @@ -109,12 +115,14 @@ impl MethodGroup { Self::DeploymentApiKeys, Self::DeploymentEnvironmentProviders, Self::DeploymentChannels, + Self::DeploymentSessions, ]; /// The stored and wire spelling. pub fn as_str(self) -> &'static str { match self { Self::Session => "session", + Self::SessionDelete => "session/delete", Self::BlobsPut => "blobs/put", Self::Vfs => "vfs", Self::Profiles => "profiles", @@ -131,6 +139,7 @@ impl MethodGroup { Self::DeploymentApiKeys => "deployment/api-keys", Self::DeploymentEnvironmentProviders => "deployment/environment-providers", Self::DeploymentChannels => "deployment/channels", + Self::DeploymentSessions => "deployment/sessions", } } @@ -147,6 +156,7 @@ impl MethodGroup { | Self::DeploymentApiKeys | Self::DeploymentEnvironmentProviders | Self::DeploymentChannels + | Self::DeploymentSessions ) } @@ -166,6 +176,10 @@ impl MethodGroup { let group = |prefix: &str| method.starts_with(prefix); Some(if method == "blobs/put" { Self::BlobsPut + } else if matches!(method, "session/delete" | "session/retention/put") { + Self::SessionDelete + } else if group("deployment/sessions/") { + Self::DeploymentSessions } else if group("session/") || group("blobs/") { Self::Session } else if group("transcriptions/") { @@ -326,7 +340,9 @@ pub enum UniverseAction { Read, CreateSession, ControlSession, + ConfigureSession, StopSession, + CloseSession, DeleteSession, /// Share an unshared session with the universe. ShareSession, @@ -371,7 +387,7 @@ impl MethodAccess { /// The least universe role a person should hold to call the method, for /// gates built on this contract. `None` for machine and deployment - /// methods. Ownership rules (a Contributor deleting only their own work) + /// methods. Ownership rules (a Contributor closing only their own unshared work) /// are the gate's to add. pub const fn recommended_role(self) -> Option { use UniverseAction::*; @@ -380,10 +396,11 @@ impl MethodAccess { }; Some(match action { Read => RecommendedRole::Viewer, - CreateSession | ControlSession | StopSession | DeleteSession | ShareSession + DeleteSession => RecommendedRole::Admin, + CreateSession | ControlSession | StopSession | CloseSession | ShareSession | InvokeBot | UseResource => RecommendedRole::Contributor, - CreateProfile | ManageProfile | CreateBot | ManageBot | ConfigureResource - | CreateWorkspace => RecommendedRole::Operator, + ConfigureSession | CreateProfile | ManageProfile | CreateBot | ManageBot + | ConfigureResource | CreateWorkspace => RecommendedRole::Operator, }) } } @@ -526,6 +543,30 @@ mod tests { ); } + #[test] + fn session_setup_requires_an_operator_but_ordinary_creation_and_runs_do_not() { + for method in [ + "session/config/put", + "session/profiles/apply", + "session/metadata/put", + "session/environments/activate", + "session/environments/deactivate", + ] { + assert_eq!( + method_access(method).unwrap().recommended_role(), + Some(RecommendedRole::Operator), + "{method}", + ); + } + for method in ["session/start", "session/runs/start", "session/rename"] { + assert_eq!( + method_access(method).unwrap().recommended_role(), + Some(RecommendedRole::Contributor), + "{method}", + ); + } + } + #[test] fn every_method_has_explicit_access_and_unknown_names_have_none() { for spec in crate::schema_export::full_method_manifest() { diff --git a/crates/api/src/rpc.rs b/crates/api/src/rpc.rs index d3243eccb..1209a3744 100644 --- a/crates/api/src/rpc.rs +++ b/crates/api/src/rpc.rs @@ -345,17 +345,17 @@ api_methods! { METHOD_SESSION_LIST => list_sessions(SessionListParams) -> SessionListResponse => ["List sessions", "Returns a cursor-paginated summary list ordered by most recent update, optionally narrowed by the audience of each session's root: createdBy, visibility, or visibleTo (shared with the universe or created by that actor). Pages may shift while sessions are changing."], access: MethodAccess::Universe(UniverseAction::Read), METHOD_SESSION_CONFIG_PUT => put_session_config(SessionConfigPutParams) -> SessionConfigPutResponse => - ["Replace session configuration", "Replaces the complete sparse config while the session is idle. Use the current config revision for safe read-modify-write; omitted features are revoked, an omitted model preserves the current model, and an identical document is a no-op."], access: MethodAccess::Universe(UniverseAction::ControlSession), + ["Replace session configuration", "Replaces the complete sparse config while the session is idle. Use the current config revision for safe read-modify-write; omitted features are revoked, an omitted model preserves the current model, and an identical document is a no-op."], access: MethodAccess::Universe(UniverseAction::ConfigureSession), METHOD_SESSION_RENAME => rename_session(SessionRenameParams) -> SessionRenameResponse => ["Rename a session", "Sets the display name, or clears it when displayName is omitted."], access: MethodAccess::Universe(UniverseAction::ControlSession), METHOD_SESSION_METADATA_PUT => put_session_metadata(SessionMetadataPutParams) -> SessionMetadataPutResponse => - ["Replace session metadata", "Replaces the complete descriptive key/value map (bounded like session/start); an omitted or empty map clears it. Record-only: the event log and updatedAtMs are untouched."], access: MethodAccess::Universe(UniverseAction::ControlSession), + ["Replace session metadata", "Replaces the complete descriptive key/value map (bounded like session/start); an omitted or empty map clears it. Record-only: the event log and updatedAtMs are untouched."], access: MethodAccess::Universe(UniverseAction::ConfigureSession), METHOD_SESSION_RETENTION_PUT => put_session_retention(SessionRetentionPutParams) -> SessionRetentionPutResponse => - ["Replace session retention", "Sets the positive close-relative automatic-deletion duration on a retention root, or clears it with null. Forks and delegated children inherit the root policy and cannot override it."], access: MethodAccess::Universe(UniverseAction::ControlSession), + ["Replace session retention", "Sets the positive close-relative automatic-deletion duration on a retention root, or clears it with null. Forks and delegated children inherit the root policy and cannot override it."], access: MethodAccess::Universe(UniverseAction::DeleteSession), METHOD_SESSION_CLOSE => close_session(SessionCloseParams) -> SessionCloseResponse => - ["Close a session", "Closes an idle session and detaches its environment bindings. Force mode cancels active work, drops queued runs, and can recover a session whose workflow is unavailable."], access: MethodAccess::Universe(UniverseAction::StopSession), + ["Close a session", "Closes an idle session and detaches its environment bindings. Force mode cancels active work, drops queued runs, and can recover a session whose workflow is unavailable."], access: MethodAccess::Universe(UniverseAction::CloseSession), METHOD_SESSION_DELETE => delete_session(SessionDeleteParams) -> SessionDeleteResponse => - ["Delete closed sessions", "Permanently removes a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Config-only clones are never included."], access: MethodAccess::Universe(UniverseAction::DeleteSession), + ["Delete closed sessions", "Hides a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Retained records are purged after 30 days. Config-only clones are never included."], access: MethodAccess::Universe(UniverseAction::DeleteSession), METHOD_SESSION_SHARE => share_session(SessionShareParams) -> SessionShareResponse => ["Share a session with the universe", "Moves an unshared root session to universe visibility, one way; its delegated children follow it. Refused on a bot's session, a delegated child, and a session already shared. Core applies it for any caller of the method; who may share is the caller's gate's decision."], access: MethodAccess::Universe(UniverseAction::ShareSession), METHOD_SESSION_EVENTS_READ => read_session_events(SessionEventsReadParams) -> SessionEventsReadResponse => @@ -383,11 +383,11 @@ api_methods! { METHOD_SESSION_SKILLS_LIST => list_skills(SkillListParams) -> SkillListResponse => ["List available session skills", "Returns separate VFS and environment catalogs with source, reference, availability, readable skill paths, and warnings. Refreshes only when open with no active or queued run, without waking environments. Absent catalogs are omitted."], access: MethodAccess::Universe(UniverseAction::Read), METHOD_SESSION_PROFILES_APPLY => apply_profile(ProfileApplyParams) -> ProfileApplyResponse => - ["Apply a profile to a session", "Applies a named or inline profile's config, instructions, and environment setup to an existing session; mutating profile sections require it to be open and idle. Pass current revisions to guard concurrent changes."], access: MethodAccess::Universe(UniverseAction::ControlSession), + ["Apply a profile to a session", "Applies a named or inline profile's config, instructions, and environment setup to an existing session; mutating profile sections require it to be open and idle. Pass current revisions to guard concurrent changes."], access: MethodAccess::Universe(UniverseAction::ConfigureSession), METHOD_SESSION_ENVIRONMENTS_ACTIVATE => activate_session_environment(SessionEnvironmentActivateParams) -> SessionEnvironmentActivateResponse => - ["Activate a session environment", "Selects an attached, live universe environment for environment-targeted tools while the session is idle."], access: MethodAccess::Universe(UniverseAction::ControlSession), + ["Activate a session environment", "Selects an attached, live universe environment for environment-targeted tools while the session is idle."], access: MethodAccess::Universe(UniverseAction::ConfigureSession), METHOD_SESSION_ENVIRONMENTS_DEACTIVATE => deactivate_session_environment(SessionEnvironmentDeactivateParams) -> SessionEnvironmentDeactivateResponse => - ["Deactivate the session environment", "Clears active environment selection without changing or closing the universe environment."], access: MethodAccess::Universe(UniverseAction::ControlSession), + ["Deactivate the session environment", "Clears active environment selection without changing or closing the universe environment."], access: MethodAccess::Universe(UniverseAction::ConfigureSession), METHOD_ENVIRONMENTS_CREDENTIALS_BIND => bind_environment_credential(EnvironmentCredentialBindParams) -> EnvironmentCredentialBindResponse => ["Bind a credential into an environment", "Maps an environment variable name to an existing grant/provider/direct-secret handle for a universe environment. Requires configuring the environment and configuring resources in the universe. The response exposes only the source handle, never secret material."], access: MethodAccess::Universe(UniverseAction::ConfigureResource), METHOD_ENVIRONMENTS_CREDENTIALS_LIST => list_environment_credentials(EnvironmentCredentialListParams) -> EnvironmentCredentialListResponse => @@ -531,7 +531,7 @@ api_methods! { METHOD_BOTS_CLOSE => close_bot(BotCloseParams) -> BotCloseResponse => ["Close a bot", "Terminal and idempotent: disables every trigger, drops schedules, and tells the controller to archive pending events and force-close its sessions. Returns once signalled; follow bots/state/read for closing to closed."], access: MethodAccess::Universe(UniverseAction::ManageBot), METHOD_BOTS_DELETE => delete_bot(BotDeleteParams) -> BotDeleteResponse => - ["Delete a bot", "Closes the bot if needed, waits for its controller to complete, deletes the sessions it closed, and removes the record so the bot id is free again."], access: MethodAccess::Universe(UniverseAction::ManageBot), + ["Delete a bot", "Closes the bot if needed, waits for its controller to complete, retains its session history, and removes the record so the bot id is free again."], access: MethodAccess::Universe(UniverseAction::ManageBot), METHOD_BOTS_STATE_READ => read_bot_state(BotStateReadParams) -> BotStateReadResponse => ["Read bot controller state", "Queries the controller workflow for its live snapshot (sessions, buffers, active and recent deliveries, budget) and lists sub-agent descendants. The controller is absent until the bot's first event."], access: MethodAccess::Universe(UniverseAction::Read), METHOD_BOTS_SESSIONS_ROTATE => rotate_bot_session(BotSessionRotateParams) -> BotSessionRotateResponse => diff --git a/crates/temporal-runtime/src/gateway/service/controller.rs b/crates/temporal-runtime/src/gateway/service/controller.rs index 4939f4428..cf1ae5ca1 100644 --- a/crates/temporal-runtime/src/gateway/service/controller.rs +++ b/crates/temporal-runtime/src/gateway/service/controller.rs @@ -48,7 +48,8 @@ pub fn authorize_controller( || matches!((&context.actor, &target.parent), (ResourceRef::Session(actor), Some(parent)) if actor == parent); match action { Read | CreateSession | UseResource => true, - ControlSession | StopSession | DeleteSession | ManageBot => controls, + ControlSession | ConfigureSession | StopSession | CloseSession | DeleteSession + | ManageBot => controls, _ => false, } } @@ -104,11 +105,23 @@ mod tests { Visibility::Universe, ); let helper = bot("helper"); - for action in [ControlSession, StopSession, DeleteSession, Read] { + for action in [ + ControlSession, + ConfigureSession, + StopSession, + DeleteSession, + Read, + ] { assert!(authorize_controller(&helper, action, Some(&own))); } assert!(authorize_controller(&helper, Read, Some(&other))); - for action in [ControlSession, StopSession, DeleteSession, ShareSession] { + for action in [ + ControlSession, + ConfigureSession, + StopSession, + DeleteSession, + ShareSession, + ] { assert!(!authorize_controller(&helper, action, Some(&other))); } // A bot manages itself, never another bot. diff --git a/docs/documentation/access-and-security/people-and-roles.md b/docs/documentation/access-and-security/people-and-roles.md index 526532803..ec377e051 100644 --- a/docs/documentation/access-and-security/people-and-roles.md +++ b/docs/documentation/access-and-security/people-and-roles.md @@ -21,12 +21,25 @@ the universe's resources. | Role | What the person can do | | --- | --- | | **Viewer** | Read visible sessions, bots, and universe resources. A session they previously created remains visible after a downgrade to Viewer. | -| **Contributor** | Create and continue sessions, steer or stop work, decide tool approvals, invoke bots, and use resources. Create and update workspaces. | -| **Operator** | Create and configure profiles, bots, environments, MCP servers, credentials, and channels; manage bot triggers and replay bot events. | +| **Contributor** | Create sessions with universe defaults or an existing profile; continue sessions, steer or stop work, decide tool approvals, invoke bots, and update workspace contents. | +| **Operator** | Customize session setup and run options; create workspaces and create or configure profiles, bots, environments, MCP servers, credentials, and channels; manage bot triggers and replay bot events. | | **Admin** | Manage universe members, settings, and API keys. Read, control, share, and delete every session, including private sessions. | -Contributors and Operators can control any shared session. Sharing and deleting -a session require its creator, with at least the Contributor role, or an Admin. +Contributors can name a new session and choose its saved profile, but cannot +author inline setup, override the chosen setup, create managed sessions, or +create or edit profiles. After creation, changing configuration, custom +instructions, metadata, or the active environment requires an Operator or +Admin. Per-message model and reasoning overrides also require an Operator or +Admin. These checks apply to the server, including a Contributor’s own sessions. + +Viewers and Contributors can inspect profiles in **Form** or **JSON** view and +open **Session settings** for sessions they can read. The form keeps sections +expandable and values readable, with editing controls protected and save +actions hidden. + +Contributors and Operators can start and control runs in any shared session. +Sharing and deleting a session require its creator, with at least the +Contributor role, or an Admin. The [private and shared work guide](private-and-shared-work.md) develops those rules with an example. diff --git a/docs/documentation/access-and-security/private-and-shared-work.md b/docs/documentation/access-and-security/private-and-shared-work.md index 4c4bfa10a..801264bdb 100644 --- a/docs/documentation/access-and-security/private-and-shared-work.md +++ b/docs/documentation/access-and-security/private-and-shared-work.md @@ -36,8 +36,9 @@ flowchart LR Sharing changes who may use the same continuing conversation. It does not make a snapshot or start another session. Viewers can inspect it; Contributors and -above can continue, steer, cancel, or configure its work, subject to ordinary -session lifecycle rules. Someone joining the universe later can read its +above can continue, steer, or cancel its work, subject to ordinary session +lifecycle rules. Configuring the session or overriding a run's model or +reasoning options requires an Operator or Admin. Someone joining the universe later can read its shared history too. ## What each person may do @@ -48,7 +49,8 @@ creator. These checks apply on the server as well as in the UI. | Operation | Private session | Shared session | | --- | --- | --- | | Read | Creator and Admins | Every member | -| Start, steer, cancel, approve tools, configure, or close | Creator with Contributor or Operator role, and Admins | Contributors, Operators, and Admins | +| Start runs, steer, cancel, approve tools, or close | Creator with Contributor or Operator role, and Admins | Contributors, Operators, and Admins | +| Configure setup or override run options | Creator with Operator role, and Admins | Operators and Admins | | Share | Creator with Contributor or Operator role, and Admins | Already shared; no reverse operation | | Delete | Creator with Contributor or Operator role, and Admins | Creator with Contributor or Operator role, and Admins | diff --git a/docs/documentation/using-lightspeed/profiles-and-instructions.md b/docs/documentation/using-lightspeed/profiles-and-instructions.md index 818369e25..99c3b827e 100644 --- a/docs/documentation/using-lightspeed/profiles-and-instructions.md +++ b/docs/documentation/using-lightspeed/profiles-and-instructions.md @@ -9,7 +9,14 @@ model that can call tools, and write access to the release workspace. A release reviewer can use the same files with read-only access and a different job. Separate profiles let you reuse each setup without configuring it again. -Profiles belong to a universe. Use an Operator or Admin account to manage them. +Profiles belong to a universe. Use an Operator or Admin account to create or +edit them. Contributors can select an existing profile when creating a session, +but cannot customize its setup or save a new profile. + +Viewers and Contributors can inspect the same **Form** view used by editors, +including expandable configuration sections and readable input values. Editing +controls are read-only or disabled, and save actions are hidden. **JSON** remains +available as a read-only view of the underlying document. ## Create a profile for a job @@ -126,9 +133,11 @@ A new ordinary session receives the profile's setup at creation. Saving a later profile revision affects future sessions; existing conversations keep their current setup until you apply a change. -For a one-off change, open the idle session's **Session settings**, edit the -setup, and choose **Apply setup**. To apply a saved profile to an existing -ordinary session, use the CLI with the connection settings described in +For a one-off change in the Platform, an Operator or Admin opens the idle +session's **Session settings**, edits the setup, and chooses **Apply setup**. +Contributors can inspect these settings but cannot change them, including +custom instructions or the active environment. To apply a saved profile to an +existing ordinary session, use the CLI with the connection settings described in [Sessions and runs](sessions-and-runs.md#continue-from-the-cli): ```bash @@ -163,10 +172,11 @@ successor. See [Bots and triggers](bots-and-triggers.md). ## Set limits and a default environment The advanced **Run limits** fields include **Max turns** and **Max tool -rounds**. They bound a run's work under the selected defaults. API callers can -provide per-run overrides, so these fields should not be treated as hard -authorization ceilings. Bot daily budgets and sub-agent tree limits govern -different scopes. +rounds**. They bound a run's work under the selected defaults. Direct runtime +API callers with suitable key access can provide per-run overrides, so these +fields should not be treated as hard authorization ceilings. The Platform +refuses Contributor requests with run configuration overrides. Bot daily budgets +and sub-agent tree limits govern different scopes. Choose a default environment attachment when new sessions should start with an active machine. Without one, the session starts with no active environment. diff --git a/docs/documentation/using-lightspeed/sessions-and-runs.md b/docs/documentation/using-lightspeed/sessions-and-runs.md index 63e73818e..e8c224b6f 100644 --- a/docs/documentation/using-lightspeed/sessions-and-runs.md +++ b/docs/documentation/using-lightspeed/sessions-and-runs.md @@ -12,16 +12,21 @@ retains both the work and its history when you leave the page. Use a Contributor, Operator or Admin account in the universe for the web procedures below. To control an existing private session, you must be its creator or an Admin; shared sessions can be controlled by Contributors and -above. If you haven't completed a task yet, start with +above. Changing session setup or per-message model and reasoning options +requires an Operator or Admin. If you haven't completed a task yet, start with [Build your first agent](../getting-started/first-agent.md). ## Start and continue a session -Open **Sessions → New session**, enter a **Name**, and select a **Profile**. -Choose **Create** to use the saved profile. **Customize setup…** lets you -change the setup for this session without saving those changes back to the -profile. After customizing, choose **Create session**. You can also start -without a profile and configure the session directly. +Open **Sessions → New session**, enter a **Name**, and select an existing +**Profile** or **No profile (universe default)**. Choose **Create**. The profile +is resolved at creation; later profile edits do not change this session. +Contributors use these prepared choices without configuration overrides. + +Operators and Admins can choose **Customize setup…** to change the setup for +this session without saving those changes back to the profile. After +customizing, choose **Create session**. They can also customize a session +that starts without a profile. Send a task in the composer. For the release editor from the first-agent walkthrough, try: @@ -140,8 +145,8 @@ window. Recovery resumes the same run with completed tool results retained. It can still fail when the protected input is too large or its bounded attempts cannot make enough room. -In the profile or idle session's model setup, open **Customize run controls → -Context compaction**. **Engine default** and **Engine managed standalone** +As an Operator or Admin, open **Customize run controls → Context compaction** +in the profile or idle session's model setup. **Engine default** and **Engine managed standalone** enable standalone compaction; **Provider triggered** uses supported OpenAI Responses or Anthropic Messages generation compaction. **Disabled** turns off automatic compaction and recovery. **Input limit tokens** overrides usable @@ -190,9 +195,15 @@ those conversations. **Metadata filters** accept `key=value` pairs, and Metadata is a descriptive map, for example `project=acorn` and `purpose=release-review`. It does not grant access or instruct the model. -Open **Session settings** to edit it, custom instructions, model configuration, -and other setup, then choose **Apply setup**. Changes to the agent's working -setup require an open session with no active or queued runs. +Open **Session settings** to inspect metadata, custom instructions, model +configuration, and attached resources. Viewers and Contributors see a read-only +form: sections still expand, but values cannot be changed and **Apply setup** +is hidden. + +Operators and Admins can edit the setup and choose **Apply setup**. Changes to +the agent's working setup require an open session with no active or queued +runs. The composer's per-message model and reasoning choices, including saving +a model choice as the session default, also require an Operator or Admin. A session keeps its configured provider identity and API kind for its entire lifetime, including before its first run. You can switch model names within @@ -317,6 +328,6 @@ the [bot conversation](bots-and-triggers.md) or connected chat for normal work. | A message is waiting while the agent works | It may be a queued run. Use steering for an instruction intended for the current task. | | Steering has no immediate visible effect | The current model call or tool batch must finish before the next model turn can consume it. | | Work starts again after stopping | Check for other queued runs. Stopping one run leaves those tasks in place. | -| Setup changes are refused | Wait for active work to finish and drain or cancel queued runs. Reload settings if another editor changed them. | +| Setup changes are refused | Confirm you have an Operator or Admin role. Wait for active work to finish and drain or cancel queued runs. Reload settings if another editor changed them. | | A finished child or closed conversation is missing | Under **Filter sessions → Include**, select **Closed sessions** and **Sub-agent sessions**, or follow the child link from the parent transcript. | | The agent lost a detail from much earlier | Inspect the retained history and restate the needed fact or point it to the source file. The current model context can be smaller than the transcript. | diff --git a/docs/roadmap/p180-platform-organizations-roles-and-unshared-work.md b/docs/roadmap/p180-platform-organizations-roles-and-unshared-work.md index bdb1cd943..32785b610 100644 --- a/docs/roadmap/p180-platform-organizations-roles-and-unshared-work.md +++ b/docs/roadmap/p180-platform-organizations-roles-and-unshared-work.md @@ -102,12 +102,15 @@ has nothing to check on `blobs/read` beyond the method's role. | Role | May | | --- | --- | | viewer | read shared work, lists, files, models | -| contributor | viewer, plus start and control sessions and runs, create workspaces, invoke bots, approve tool calls in sessions they control, share their own sessions | -| operator | contributor, plus create and configure profiles, bots, environments, MCP servers, credentials, channels | +| contributor | viewer, plus create sessions from defaults or existing profiles, start and control runs, update workspace contents, invoke bots, approve tool calls in sessions they control, share their own sessions | +| operator | contributor, plus customize session setup and run options, create workspaces, and create and configure profiles, bots, environments, MCP servers, credentials, channels | | admin | operator, plus members and roles, delete any session, share any session, read unshared work | The exact per-method table is the generated file; this table is what the -manifest's `role` metadata must reproduce. +manifest's `role` metadata must reproduce. The member client also checks +creation and run payloads: contributor session creation permits only defaults +or an existing named profile, and run requests cannot carry configuration +overrides. Managed-session creation requires an operator. ### 5. Unshared work in the product @@ -248,6 +251,27 @@ Notes on steps 1 and 2 as built: shows what each key may call. The Configurator installer lets an admin keep its key, mint one with chosen groups, or bring an existing key. +## Contributor setup restrictions — implemented 2026-10-06 + +- [x] Separate `ConfigureSession` from ordinary session control in the method + manifest. Config replacement, profile application, metadata, and environment + selection require an operator. Internal controller authority is preserved. +- [x] Enforce prepared contributor creation in the member client, including + refusal of inline setup, creation overrides, managed sessions, and per-run + configuration overrides before forwarding to core. Profile writes remain + operator-only. +- [x] Limit the contributor creation UI to universe defaults or an existing + profile. Remove contributor model/reasoning overrides and saved-default + controls from the composer. +- [x] Reuse the config form for read-only profile and session inspection. Keep + disclosures usable, protect editing controls and change callbacks, and hide + save actions. Profile JSON remains readable. +- [x] Regenerate the API contract and TypeScript consumers. Focused gate, form, + API access, controller, and contract-freshness tests pass; TypeScript checks + pass. Broader tests encountered concurrent session-deletion changes. +- [x] Update the role, sharing, session, profile, and Platform documentation + with user approval. + ## Validation - Unit (Platform): every manifest method has a role entry; a viewer is diff --git a/platform/README.md b/platform/README.md index d50770f33..fb8f11639 100644 --- a/platform/README.md +++ b/platform/README.md @@ -133,8 +133,8 @@ four roles, least to most: | Role | May | | --- | --- | | viewer | read shared work and their own private sessions | -| contributor | also start and continue sessions and runs, invoke bots, share their own sessions | -| operator | also configure profiles, bots, environments, MCP servers, credentials and channels | +| contributor | also create sessions from defaults or an existing profile, continue runs, invoke bots, share their own sessions | +| operator | also customize sessions and run options, and configure profiles, bots, environments, MCP servers, credentials and channels | | admin | also manage members and keys, and read, share and delete any session | A universe's creator is its admin, and a universe always keeps one. A platform @@ -151,6 +151,9 @@ a route makes for a member goes through one client `backend/src/routes/method-roles.ts`. That table is generated from the core method manifest by `node platform/scripts/generate-method-roles.mjs`, and `npm run check` fails when it is stale; +- limits contributor creation to ordinary sessions with universe defaults or a + named profile, without setup overrides; managed creation and run configuration + overrides require an operator; - for a method that names a session, unless the member is an admin, requires the session to be shared with the universe or created by the member; sharing and deleting need its creator; @@ -161,6 +164,12 @@ a route makes for a member goes through one client The Platform's own refusals are 403 and 404. Core refusing the Platform is a server fault (500 or 502), since the member was already admitted. The web's permission hints come from the same role and never replace these checks. +`configure_session` is separate from `control_session`: configuration, profile +application, metadata, and active-environment changes require an operator, +while ordinary run controls remain available to contributors. Profile creation +and editing also require an operator. Readers use the same expandable +configuration form in profiles and session settings, with protected controls +and no save action; profile JSON remains available for inspection. **Private work.** Sessions start private: their creator and the universe's admins see them. **Share with universe…** in the session's ⋯ menu shares a diff --git a/platform/backend/src/routes/messages.test.ts b/platform/backend/src/routes/messages.test.ts index 4bd084c1a..b3a432652 100644 --- a/platform/backend/src/routes/messages.test.ts +++ b/platform/backend/src/routes/messages.test.ts @@ -41,7 +41,8 @@ function fixture() { return { call, requests, fetch }; } -it("sends attachments as media items before the text, with per-message run options", async () => { +it("sends operator attachments as media items before the text, with per-message run options", async () => { + auth.role = "operator"; const f = fixture(); const response = await f.call("/sessions/s1/messages", { text: "Compare these", submissionId: "sub", attachments: [image, pdf], @@ -104,3 +105,12 @@ it("uploads attachments as blobs for contributors only", async () => { expect((await viewer.call("/attachments", { bytesBase64: btoa("png") })).status).toBe(403); expect(viewer.fetch).not.toHaveBeenCalled(); }); + + +it("rejects contributor model and reasoning overrides before reaching the runtime", async () => { + const f = fixture(); + for (const options of [{ model: route }, { reasoningEffort: "high" }]) { + expect((await f.call("/sessions/s1/messages", { text: "Hello", submissionId: "sub", options })).status).toBe(403); + } + expect(f.fetch).not.toHaveBeenCalled(); +}); diff --git a/platform/backend/src/routes/method-roles.ts b/platform/backend/src/routes/method-roles.ts index a619a3d9d..d1f27e2c9 100644 --- a/platform/backend/src/routes/method-roles.ts +++ b/platform/backend/src/routes/method-roles.ts @@ -87,22 +87,22 @@ export const METHOD_ROLES: Readonly> = { "profiles/put": "operator", "profiles/read": "viewer", "session/close": "contributor", - "session/config/put": "contributor", + "session/config/put": "operator", "session/context/append": "contributor", "session/context/compact": "contributor", "session/context/remove": "contributor", "session/context/replace": "contributor", - "session/delete": "contributor", - "session/environments/activate": "contributor", - "session/environments/deactivate": "contributor", + "session/delete": "admin", + "session/environments/activate": "operator", + "session/environments/deactivate": "operator", "session/events/read": "viewer", "session/list": "viewer", "session/managed/start": "contributor", - "session/metadata/put": "contributor", - "session/profiles/apply": "contributor", + "session/metadata/put": "operator", + "session/profiles/apply": "operator", "session/read": "viewer", "session/rename": "contributor", - "session/retention/put": "contributor", + "session/retention/put": "admin", "session/runs/approvals/decide": "contributor", "session/runs/cancel": "contributor", "session/runs/list": "viewer", @@ -171,6 +171,8 @@ export const UNMEMBERED_METHODS: ReadonlySet = new Set([ "deployment/environment-providers/put", "deployment/environment-providers/read", "deployment/environments/adopt", + "deployment/sessions/audit/list", + "deployment/sessions/purge", "deployment/universes/create", "deployment/universes/delete", "deployment/universes/list", diff --git a/platform/backend/src/runtime-client.test.ts b/platform/backend/src/runtime-client.test.ts index 4b76bf544..35687813e 100644 --- a/platform/backend/src/runtime-client.test.ts +++ b/platform/backend/src/runtime-client.test.ts @@ -98,9 +98,9 @@ describe("session targets", () => { expect(calls.map((call) => call.method)).toEqual(["session/events/read"]); }); - it("keep share and delete with the creator even on shared work", async () => { + it("keep sharing with the creator even on shared work", async () => { core({ team: { visibility: "universe", createdBy: { kind: "actor", id: "bob" } } }); - for (const method of ["session/share", "session/delete"] as const) { + for (const method of ["session/share"] as const) { const error = await refusal(as("contributor").call(method, { sessionId: "team" } as never)); expect((error as GateRefusal).status).toBe(404); } @@ -145,3 +145,101 @@ describe("lists and transport", () => { expect(() => deploymentClient({ ...env, lightspeedApiKey: null })).toThrow("runtime endpoint"); }); }); + + +describe("prepared contributor sessions", () => { + it.each([ + {}, + { displayName: "Research", profile: { kind: "inline", profile: {} } }, + { profile: { kind: "named", profileId: "approved" } }, + ])("allows defaults and existing profiles: %j", async (params) => { + const calls = core(); + await as("contributor").call("session/start", params as never); + expect(calls.map((call) => call.method)).toEqual(["session/start"]); + expect(calls[0]!.params).toMatchObject(params); + }); + + it.each([ + { config: {} }, + { config: { model: { model: "custom" } } }, + { metadata: { team: "custom" } }, + { deleteAfterCloseMs: null }, + { deleteAfterCloseMs: 1000 }, + { access: { visibility: "universe" } }, + { profile: { kind: "inline", profile: { instructions: { type: "text", text: "Override" } } } }, + { profile: { kind: "inline", profile: { config: {} } } }, + { profile: { kind: "named", profileId: "approved", config: {} } }, + { profile: { kind: "named", profileId: "approved" }, config: {} }, + ])("refuses contributor overrides before calling core: %j", async (params) => { + const calls = core(); + const error = await refusal(as("contributor").call("session/start", params as never)); + expect(error).toBeInstanceOf(GateRefusal); + expect((error as GateRefusal).status).toBe(403); + expect(calls).toHaveLength(0); + }); + + it.each([ + "session/config/put", "session/profiles/apply", "session/metadata/put", + "session/environments/activate", "session/environments/deactivate", + "session/managed/start", "profiles/create", "profiles/put", "profiles/delete", + ] as const)("keeps %s out of contributor access, including owned sessions", async (method) => { + const calls = core({ own: { visibility: "restricted", createdBy: { kind: "actor", id: "alice" } } }); + const error = await refusal(as("contributor").call(method, { sessionId: "own" } as never)); + expect(error).toBeInstanceOf(GateRefusal); + expect((error as GateRefusal).status).toBe(403); + expect(calls).toHaveLength(0); + }); + + it("allows contributor runs but refuses per-run configuration overrides", async () => { + const calls = core({ own: { visibility: "restricted", createdBy: { kind: "actor", id: "alice" } } }); + const params = { sessionId: "own", source: { type: "input", items: [] } } as const; + const error = await refusal(as("contributor").call("session/runs/start", { ...params, config: {} } as never)); + expect((error as GateRefusal).status).toBe(403); + expect(calls).toHaveLength(0); + await as("contributor").call("session/runs/start", params as never); + expect(calls.map((call) => call.method)).toEqual(["session/read", "session/runs/start"]); + }); + + it.each(["operator", "admin"] as const)("preserves custom creation and configuration for %s", async (role) => { + const calls = core({ own: { visibility: "restricted", createdBy: { kind: "actor", id: "alice" } } }); + await as(role).call("session/start", { profile: { kind: "inline", profile: { config: {} } } } as never); + await as(role).call("session/config/put", { sessionId: "own", config: {} } as never); + expect(calls.map((call) => call.method)).toContain("session/config/put"); + }); +}); + + +describe("session lifecycle permissions", () => { + for (const role of ["viewer", "contributor", "operator", "admin"] as const) { + for (const creator of [true, false]) for (const shared of [true, false]) { + it(`${role} closes creator=${creator} shared=${shared} only when permitted`, async () => { + core({ s: { visibility: shared ? "universe" : "restricted", createdBy: { kind: "actor", id: creator ? "alice" : "bob" } } }); + const allowed = role === "admin" || (role === "operator" && (creator || shared)) || (role === "contributor" && creator && !shared); + for (const force of [false, true]) { + const error = await refusal(as(role).call("session/close", { sessionId: "s", force })); + expect(error === null).toBe(allowed); + } + }); + } + it(`${role} deletes and configures retention only as admin`, async () => { + const calls = core({ s: { visibility: "restricted", createdBy: { kind: "actor", id: "alice" } } }); + for (const method of ["session/delete", "session/retention/put"] as const) { + const error = await refusal(as(role).call(method, { sessionId: "s", deleteAfterCloseMs: 1 } as never)); + expect(error === null).toBe(role === "admin"); + } + if (role !== "admin") expect(calls).toHaveLength(0); + }); + } + it("contributors still cancel shared runs", async () => { + core({ s: { visibility: "universe" } }); + await expect(as("contributor").call("session/runs/cancel", { sessionId: "s", runId: "r" })).resolves.toBeDefined(); + }); + it("non-admin starts override profile retention and reject an explicit deletion schedule", async () => { + const calls = core(); + for (const method of ["session/start", "session/managed/start"] as const) { + await as("operator").call(method, { profile: { kind: "named", profileId: "scheduled" } } as never); + expect(calls.at(-1)!.params.deleteAfterCloseMs).toBeNull(); + expect(await refusal(as("operator").call(method, { deleteAfterCloseMs: 1 } as never))).toBeInstanceOf(GateRefusal); + } + }); +}); diff --git a/platform/backend/src/runtime-client.ts b/platform/backend/src/runtime-client.ts index 135b22515..cf1a88508 100644 --- a/platform/backend/src/runtime-client.ts +++ b/platform/backend/src/runtime-client.ts @@ -6,7 +6,7 @@ import { type MethodParams, type MethodResult, } from "@lightspeed-ai/sdk"; -import { roleAtLeast, type UniverseRole } from "@lightspeed-ai/platform-shared"; +import { canCloseSession, roleAtLeast, type UniverseRole } from "@lightspeed-ai/platform-shared"; import type { ServerEnv } from "./env.js"; import { METHOD_ROLES, SESSION_TARGET_METHODS } from "./routes/method-roles.js"; @@ -27,14 +27,14 @@ export interface Member { } /// Methods only a session's creator, or an admin, may call. -const CREATOR_METHODS: ReadonlySet = new Set(["session/share", "session/delete"]); +const CREATOR_METHODS: ReadonlySet = new Set(["session/share"]); /// Methods that may name a session that does not exist yet. const CREATION_METHODS: ReadonlySet = new Set(["session/start", "session/managed/start"]); /// Deployment methods, with the Platform's deployment key and no universe or /// actor. Callers check that the user is a platform admin. -export function deploymentClient(env: ServerEnv, endpoint?: string | null): LightspeedClient { - return new LightspeedClient(clientOptions(env, endpoint, {})); +export function deploymentClient(env: ServerEnv, endpoint?: string | null, actorId?: string): LightspeedClient { + return new LightspeedClient(clientOptions(env, endpoint, actorId ? { "x-lightspeed-actor": actorId } : {})); } /// Calls as a universe key someone handed the Platform, to learn which key a @@ -85,7 +85,22 @@ class MemberClient extends LightspeedClient { const required = METHOD_ROLES[method]; if (!required) throw new GateRefusal(403, `${method} is not a member method`); if (!roleAtLeast(this.member.role, required)) throw new GateRefusal(403, `${required} role required`); + if (!roleAtLeast(this.member.role, "operator")) { + if (method === "session/managed/start") { + throw new GateRefusal(403, "operator role required to create managed sessions"); + } + if (method === "session/start") requirePreparedSession(params as MethodParams<"session/start">); + if (method === "session/runs/start" && (params as MethodParams<"session/runs/start">).config != null) { + throw new GateRefusal(403, "operator role required for run configuration overrides"); + } + } const admin = this.member.role === "admin"; + if (!admin && CREATION_METHODS.has(method)) { + const start = params as { deleteAfterCloseMs?: number | null }; + if (start.deleteAfterCloseMs != null) throw new GateRefusal(403, "admin role required for deletion retention"); + // Explicit null also overrides profile-derived deletion schedules. + params = { ...params, deleteAfterCloseMs: null } as MethodParams; + } if (!admin && SESSION_TARGET_METHODS.has(method)) { await this.requireSession(method, (params as { sessionId?: string }).sessionId); } @@ -110,5 +125,25 @@ class MemberClient extends LightspeedClient { const creator = access.createdBy?.kind === "actor" && access.createdBy.id === this.member.userId; const visible = CREATOR_METHODS.has(method) ? creator : creator || access.visibility === "universe"; if (!visible) throw new GateRefusal(404, "session not found"); + if (method === "session/close" && !canCloseSession(this.member.role, creator, access.visibility === "universe")) { + throw new GateRefusal(403, "contributors may close only their own unshared sessions"); + } + } +} + +/** Contributors choose a saved profile or the universe defaults, without overrides. */ +function requirePreparedSession(params: MethodParams<"session/start">): void { + const profile = params.profile; + const defaultProfile = profile == null || (profile.kind === "inline" + && profile.profile != null && Object.keys(profile.profile).length === 0 + && Object.keys(profile).every((key) => key === "kind" || key === "profile")); + const namedProfile = profile?.kind === "named" + && Object.keys(profile).every((key) => key === "kind" || key === "profileId"); + const allowedFields = new Set(["sessionId", "displayName", "profile", "access"]); + const overrides = Object.entries(params).some(([key, value]) => + !allowedFields.has(key) && value !== undefined, + ); + if ((!defaultProfile && !namedProfile) || overrides || params.access?.visibility === "universe") { + throw new GateRefusal(403, "contributors may create only default sessions or use an existing profile without overrides"); } } diff --git a/platform/configurator-mcp/src/generated/tools.ts b/platform/configurator-mcp/src/generated/tools.ts index 76c7c1ce8..cf1842116 100644 --- a/platform/configurator-mcp/src/generated/tools.ts +++ b/platform/configurator-mcp/src/generated/tools.ts @@ -2174,7 +2174,7 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ { "name": "lightspeed_session_retention_put", "method": "session/retention/put", - "group": "session", + "group": "session/delete", "summary": "Replace session retention", "description": "Sets the positive close-relative automatic-deletion duration on a retention root, or clears it with null. Forks and delegated children inherit the root policy and cannot override it.", "paramsType": "SessionRetentionPutParams", @@ -2234,9 +2234,9 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ { "name": "lightspeed_session_delete", "method": "session/delete", - "group": "session", + "group": "session/delete", "summary": "Delete closed sessions", - "description": "Permanently removes a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Config-only clones are never included.", + "description": "Hides a closed retention-tree leaf, or its closed history-fork and delegated-child subtree when cascade is true. Retained records are purged after 30 days. Config-only clones are never included.", "paramsType": "SessionDeleteParams", "resultType": "AgentApiOutcome", "inputSchema": { @@ -9997,7 +9997,7 @@ export const GENERATED_TOOLS: readonly GeneratedToolDescriptor[] = [ "method": "bots/delete", "group": "bots", "summary": "Delete a bot", - "description": "Closes the bot if needed, waits for its controller to complete, deletes the sessions it closed, and removes the record so the bot id is free again.", + "description": "Closes the bot if needed, waits for its controller to complete, retains its session history, and removes the record so the bot id is free again.", "paramsType": "BotDeleteParams", "resultType": "AgentApiOutcome", "inputSchema": { diff --git a/platform/web/src/components/mcp/tool-picker.tsx b/platform/web/src/components/mcp/tool-picker.tsx index d43bdd023..483102865 100644 --- a/platform/web/src/components/mcp/tool-picker.tsx +++ b/platform/web/src/components/mcp/tool-picker.tsx @@ -21,6 +21,7 @@ import { } from "@/lib/mcp/tool-discovery"; type Props = { + readOnly?: boolean; scope: "server" | "session"; serverId: string; revision?: number; @@ -45,15 +46,19 @@ export function McpToolPicker(props: Props) { } function ToolPicker({ + readOnly = false, scope, serverId, revision, source, allowedTools, value, - onChange, + onChange: onToolsChange, discoveryDisabledReason, }: Props) { + const onChange = (tools: string[] | undefined) => { + if (!readOnly) onToolsChange(tools); + }; const id = useId(); const limited = value !== undefined; // A session subset starts closed like every optional setting; its summary @@ -100,7 +105,7 @@ function ToolPicker({ const title = scope === "server" ? "Allowed tools" : "Tools"; const settings = ( <> - updateReasoningEffort(e.target.value || undefined)} @@ -986,9 +996,9 @@ function ModelFields({ config, models, defaultModelLabel, manualModel, onManualM {selected === "manual" && (
- Provider id update("providerId", e.target.value)} placeholder="openai" /> - API kind update("apiKind", e.target.value)} placeholder="openai:responses" /> - Model update("model", e.target.value)} placeholder="gpt-5.5" /> + Provider id update("providerId", e.target.value)} placeholder="openai" /> + API kind update("apiKind", e.target.value)} placeholder="openai:responses" /> + Model update("model", e.target.value)} placeholder="gpt-5.5" />
)} @@ -1125,6 +1135,7 @@ function AdvancedFields({ config, change }: { config: RecordValue; change: (fn: } function GenerationFields({ config, change }: { config: RecordValue; change: (fn: (next: RecordValue) => void) => void }) { + const readOnly = useContext(ConfigReadOnlyContext); const generation = record(config.generation); const supportsProcessingTier = supportsOpenAiProcessingTier({ model: record(config.model) }); const processingTier = string(generation.processingTier) || "providerDefault"; @@ -1140,7 +1151,7 @@ function GenerationFields({ config, change }: { config: RecordValue; change: (fn
Max output tokens - Parallel tool use - update("parallelToolUse", value === "default" ? undefined : value === "true")}> Provider default @@ -1162,7 +1173,7 @@ function GenerationFields({ config, change }: { config: RecordValue; change: (fn {supportsProcessingTier && ( Processing tier - update("toolChoice", value === "default" ? undefined : { type: value })}> + update("toolChoice", { type: "specific", toolId: e.target.value })} placeholder="env.run_process" @@ -1214,6 +1225,7 @@ function GenerationFields({ config, change }: { config: RecordValue; change: (fn } function LimitsFields({ config, change }: { config: RecordValue; change: (fn: (next: RecordValue) => void) => void }) { + const readOnly = useContext(ConfigReadOnlyContext); const limits = record(config.limits); const update = (key: string, value: string) => change((next) => { const item = record(next.limits); @@ -1221,10 +1233,11 @@ function LimitsFields({ config, change }: { config: RecordValue; change: (fn: (n if (number === undefined) delete item[key]; else item[key] = number; if (Object.keys(item).length) next.limits = item; else delete next.limits; }); - return
Max turns update("maxTurns", e.target.value)} />Max tool rounds update("maxToolRounds", e.target.value)} />
; + return
Max turns update("maxTurns", e.target.value)} />Max tool rounds update("maxToolRounds", e.target.value)} />
; } function ContextFields({ config, change }: { config: RecordValue; change: (fn: (next: RecordValue) => void) => void }) { + const readOnly = useContext(ConfigReadOnlyContext); const context = record(config.context); const compaction = record(context.compaction); const mode = string(compaction.mode) || "default"; @@ -1236,7 +1249,7 @@ function ContextFields({ config, change }: { config: RecordValue; change: (fn: ( if (Object.keys(compact).length) context.compaction = compact; else delete context.compaction; if (Object.keys(context).length) next.context = context; else delete next.context; }); - return
ModeEngine default uses standalone compaction. Unknown context limits recover when the provider reports a full context window.Input limit tokens change((next) => { const updated = record(next.context); const value = parseNumber(e.target.value); if (value === undefined) delete updated.inputLimitTokens; else updated.inputLimitTokens = value; if (Object.keys(updated).length) next.context = updated; else delete next.context; })} />Override the usable input capacity. Leave blank to use reported capacity or error-driven recovery.{mode === "providerTriggered" || mode === "providerStandalone" ? Compact threshold tokens update("compactThresholdTokens", parseNumber(e.target.value))} /> : null}{mode === "providerStandalone" ? Target tokens update("targetTokens", parseNumber(e.target.value))} /> : null}
; + return
ModeEngine default uses standalone compaction. Unknown context limits recover when the provider reports a full context window.Input limit tokens change((next) => { const updated = record(next.context); const value = parseNumber(e.target.value); if (value === undefined) delete updated.inputLimitTokens; else updated.inputLimitTokens = value; if (Object.keys(updated).length) next.context = updated; else delete next.context; })} />Override the usable input capacity. Leave blank to use reported capacity or error-driven recovery.{mode === "providerTriggered" || mode === "providerStandalone" ? Compact threshold tokens update("compactThresholdTokens", parseNumber(e.target.value))} /> : null}{mode === "providerStandalone" ? Target tokens update("targetTokens", parseNumber(e.target.value))} /> : null}
; } function FeaturePanel({ @@ -1256,6 +1269,7 @@ function FeaturePanel({ onEnabledChange: (enabled: boolean) => void; children?: React.ReactNode; }) { + const readOnly = useContext(ConfigReadOnlyContext); const [open, setOpen] = useState(false); const info = featureInfo[name]; const Icon = info.icon; @@ -1269,7 +1283,7 @@ function FeaturePanel({
{ const nextEnabled = checked === true; setOpen(nextEnabled && configurable); @@ -1323,6 +1337,7 @@ function VfsFields({ workspacesLoading: boolean; patch: (fn: (feature: RecordValue) => void) => void; }) { + const readOnly = useContext(ConfigReadOnlyContext); const attachments = Array.isArray(feature.workspaces) ? feature.workspaces.map(record) : []; @@ -1361,7 +1376,7 @@ function VfsFields({ Attach workspaces at session paths with their own access grants. Files written here remain visible through the workspace, even when the session is restricted.

-
-