diff --git a/.github/workflows/.size-baseline b/.github/workflows/.size-baseline index 4d7afb6ae35..5dcd14b8774 100644 --- a/.github/workflows/.size-baseline +++ b/.github/workflows/.size-baseline @@ -55,7 +55,7 @@ 35955 release.yml 44285 repo-hygiene.yml 1079 scorecard-monthly.yml -12011 sdk-java.yml +16542 sdk-java.yml 3886 sdk-python.yml 5595 security-checks.yml 6777 serve-ab-publish.yml diff --git a/.github/workflows/sdk-java.yml b/.github/workflows/sdk-java.yml index 1e68cd2dc94..c27760d33a9 100644 --- a/.github/workflows/sdk-java.yml +++ b/.github/workflows/sdk-java.yml @@ -8,6 +8,7 @@ on: paths: - 'packages/sdk-java/**' - 'packages/core/src/config/approval-modes.json' + - 'packages/core/src/managed-runtime/contracts/managed-session-store-v1.fixtures.json' - 'packages/cli/src/commands/serve.ts' - 'packages/cli/src/serve/**' - 'packages/cli/src/acp-integration/**' @@ -27,6 +28,7 @@ on: paths: - 'packages/sdk-java/**' - 'packages/core/src/config/approval-modes.json' + - 'packages/core/src/managed-runtime/contracts/managed-session-store-v1.fixtures.json' - 'packages/cli/src/commands/serve.ts' - 'packages/cli/src/serve/**' - 'packages/cli/src/acp-integration/**' @@ -185,7 +187,7 @@ jobs: run: 'mvn --batch-mode --no-transfer-progress -DskipTests -Dgpg.skip=true package' mysql-integration: - name: 'Runtime Broker MariaDB / Java 21' + name: 'Runtime Broker and Managed Agent MariaDB / Java 21' runs-on: 'ubuntu-latest' timeout-minutes: 15 services: @@ -211,7 +213,10 @@ jobs: distribution: 'temurin' java-version: '21' cache: 'maven' - cache-dependency-path: 'packages/sdk-java/runtime-broker/pom.xml' + cache-dependency-path: |- + packages/sdk-java/qwencode/pom.xml + packages/sdk-java/runtime-broker/pom.xml + packages/sdk-java/managed-agent-server/pom.xml settings-path: '${{ runner.temp }}/setup-java-m2' - name: 'Run Runtime Broker MySQL integration tests' @@ -223,6 +228,22 @@ jobs: -Dmysql.url='jdbc:mysql://127.0.0.1:3306/runtime_broker_test?allowPublicKeyRetrieval=true&useSSL=false' -Dmysql.user=root -Dmysql.password=runtime-broker clean verify + - name: 'Install Managed Agent dependencies' + env: + MAVEN_ARGS: '--settings ${{ runner.temp }}/setup-java-m2/settings.xml --toolchains ${{ runner.temp }}/setup-java-m2/toolchains.xml' + run: |- + mvn --batch-mode --no-transfer-progress -f packages/sdk-java/qwencode/pom.xml -DskipTests -Dgpg.skip=true -Dmaven.javadoc.skip=true install + mvn --batch-mode --no-transfer-progress -f packages/sdk-java/runtime-broker/pom.xml -DskipTests install + + - name: 'Run Managed Agent tests, Checkstyle, and MySQL integration' + working-directory: 'packages/sdk-java/managed-agent-server' + env: + MAVEN_ARGS: '--settings ${{ runner.temp }}/setup-java-m2/settings.xml --toolchains ${{ runner.temp }}/setup-java-m2/toolchains.xml' + run: >- + mvn --batch-mode --no-transfer-progress -Pmysql-integration + -Dmysql.url='jdbc:mysql://127.0.0.1:3306/managed_agent_test?createDatabaseIfNotExist=true&allowPublicKeyRetrieval=true&useSSL=false' + -Dmysql.user=root -Dmysql.password=runtime-broker clean verify checkstyle:check + daemon-e2e: name: 'Real daemon E2E / Java 11' runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && (github.event_name == ''push'' || github.event.pull_request.head.repo.full_name == github.repository || contains(fromJSON(''["OWNER","MEMBER","COLLABORATOR"]''), github.event.pull_request.author_association))) && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || ''ubuntu-latest'' }}' diff --git a/docs/design/2026-09-15-task-session-startup-config.md b/docs/design/2026-09-15-task-session-startup-config.md new file mode 100644 index 00000000000..fe07bf5c47a --- /dev/null +++ b/docs/design/2026-09-15-task-session-startup-config.md @@ -0,0 +1,92 @@ +# Model and reasoning selection at session creation + +[English](2026-09-15-task-session-startup-config.md) | [简体中文](2026-09-15-task-session-startup-config.zh-CN.md) + +Tracking: [#11944](https://github.com/QwenLM/qwen-code/issues/11944), independent PR 4. +Status: implemented on this branch; not yet merged. Baseline: `39b22efb6f` (2026-09-20). + +## Goal + +An external caller can start a new session with the same model and reasoning +selection as a user selecting them in the composer before sending a prompt. +Creation applies the selection only to that session, without saving shared +defaults. Subsequent prompts, manual changes, reload, fallback, skills and +restoration retain ordinary session behavior. The selection is not automatically +cleared after the first turn. + +## Contract + +Ordinary and standalone creation accept optional +`startupConfig: { modelServiceId, reasoningEffort? }`. `modelServiceId` is required; +`reasoningEffort` is optional. Omission selects only the model, without calling the +reasoning setter or substituting `default`, so models without reasoning controls +are supported. Explicit reasoning uses the existing `ReasoningSelection` values, including +`default` and `none`, with the composer's existing validation and application +semantics. Unknown properties, a simultaneous legacy `modelServiceId`, and explicit +`sessionScope: 'single'` are rejected. Ordinary startup configuration implies +`sessionScope: 'thread'`. Omission preserves the legacy API unchanged. + +The daemon advertises `session_startup_config`. The TypeScript SDK checks this +capability and supports REST and daemon ACP HTTP/WS mappings. Direct child ACP, +load/resume and the `create_sub_session` tool schema are outside this PR. + +Success returns `modelApplied: true` and `startupConfigApplied`, describing the +canonical current model selector, requested reasoning selection and effective +reasoning state when reasoning was explicitly requested. Model-only requests +return only the confirmed model selector in `startupConfigApplied`; they do not +claim to have applied a reasoning choice. This acknowledges preparation, not an +immutable lifetime policy. +Malformed requests are rejected before workspace mutations. Invalid selections +fail creation rather than running the initial prompt with a different selection. +Existing authentication, ownership, timeout and uncertain-outcome errors retain +their semantics. + +## Implementation + +The current UI prepares an ordinary session by creating and attaching it, setting +the model, setting reasoning, and then submitting the prompt. Reuse these existing +setters with model persistence disabled and reasoning `persist: false`. + +Ordinary bridge creation applies and confirms both selections before returning. +Standalone preparation happens after managed binding commit (which authenticates +the deferred workspace) and before release or initial-prompt admission. A failed +preparation follows existing owned-session cleanup without killing siblings in +the shared child. A definite standalone selection rejection removes the owned +recording so the same id can be retried, with best-effort empty-directory cleanup. +Timeouts and channel failures retain the existing uncertain-outcome recovery; +cleanup whose outcome cannot be confirmed enters existing containment. SDK +confirmation mismatch after a readable success response is reported directly, +without recovery/adoption. +Startup selection emits session state changes, not a shared +`settings_changed` event. + +Use the existing model route parser and config-option result for confirmation. +Do not add a second provider reasoning model. All daemon routes continue to use +their resolved workspace runtime and owner. + +## Scope decisions + +This supersedes the earlier lifetime-isolation proposal attached to issue #11944. +There is no `startupPinned`, persistent policy record, recording schema change, +strict restore protocol, fallback restriction, mutation gate or UI modification. +There is no promise to select the route before `Config.initialize` or initial +authentication: preparation follows the existing UI ordering. Shared defaults +remain unchanged by this startup operation; later manual operations retain their +existing persistence semantics. The other three PRs are not dependencies. + +## Acceptance + +- Compare actual provider requests from composer-equivalent preparation and the + new creation API, including tool-loop continuation requests. +- Cover model-only creation on models without reasoning controls, explicit tiers, + `default`, `none`, unsupported choices and shared `none` + with requested `high`. +- Confirm standalone commit → selection → release → prompt ordering; preparation + failures admit zero prompts. +- Verify unchanged user/workspace settings and siblings, no default-change event, + ordinary second-turn/manual-change behavior and legacy creation compatibility. +- Cover unsupported daemons, invalid requests, workspace ownership, disconnects + and cleanup failures. +- Run build, typecheck, bundle, focused package tests and E2E; distinguish + controlled-provider evidence from real external-provider validation. Finish two + consecutive clean full-diff self-audit passes. diff --git a/docs/design/2026-09-15-task-session-startup-config.zh-CN.md b/docs/design/2026-09-15-task-session-startup-config.zh-CN.md new file mode 100644 index 00000000000..051ccfd98f0 --- /dev/null +++ b/docs/design/2026-09-15-task-session-startup-config.zh-CN.md @@ -0,0 +1,60 @@ +# 新建会话时选择模型与思考强度 + +[English](2026-09-15-task-session-startup-config.md) | [简体中文](2026-09-15-task-session-startup-config.zh-CN.md) + +跟踪:[#11944](https://github.com/QwenLM/qwen-code/issues/11944),独立 PR 4。 +状态:已在本分支实现,尚未合并。基线:`39b22efb6f`(2026-09-20)。 + +## 目标 + +外部调用方可以指定模型和思考强度发起新会话,效果等同于用户在输入框中选择后发送提问。 +创建操作只设置本会话,不保存共享默认值。后续提问、人工切换、reload、fallback、skill 和恢复沿用普通会话行为。 +首轮结束后不会自动清除选择。 + +## 契约 + +普通及 standalone 创建接受可选的 `startupConfig: { modelServiceId, reasoningEffort? }`。 +`modelServiceId` 必填,`reasoningEffort` 可选。省略时只选择模型,不调用思考强度 setter,也不自动补 `default`,因此支持没有思考强度控件的模型。显式思考强度复用现有 `ReasoningSelection`,包括 `default`、`none`, +校验和应用语义与输入框一致。拒绝未知字段、同时提供旧的顶层 `modelServiceId`、显式 +`sessionScope: 'single'`。普通会话的启动配置隐含 `sessionScope: 'thread'`。省略对象完全保留旧 API。 + +daemon 声明 `session_startup_config` 能力。TypeScript SDK 检查能力并支持 REST 与 daemon ACP HTTP/WS 映射。 +直接子进程 ACP、load/resume 和 `create_sub_session` 工具 schema 不在本 PR 范围内。 + +成功返回 `modelApplied: true` 和 `startupConfigApplied`,描述当前规范模型选择符;仅显式请求思考强度时才包含请求档位及实际思考状态。 +仅指定模型时,`startupConfigApplied` 只返回已确认的模型选择符,不声称应用过思考强度选择。 +这表示启动准备完成,不代表整个生命周期内不可变。结构错误在任何工作区变更之前拒绝。 +不合法的选择使创建失败,不允许初始提问使用不同配置执行。 +认证、归属、超时与结果不确定错误保留既有语义。 + +## 实现 + +当前 UI 先创建并 attach 普通会话,再设置模型、设置思考强度,最后发送 prompt。 +复用这些已有 setter,关闭模型持久化并使用 reasoning `persist: false`。 + +普通 bridge 创建在返回之前应用并确认组合。standalone 在 managed binding commit 完成延迟认证后、 +release 和首条 prompt 放行前准备组合。准备失败沿用已有的本次会话清理,不关闭共享子进程里的其他会话。 +确定的 standalone 选择拒绝会删除本次持久记录,使同一 ID 可重试,并尽力清理空输出目录。 +超时和断连保持既有的结果不确定恢复语义;清理无法确认时进入原有隔离流程。 +SDK 收到可读的成功响应但确认不匹配时直接报错,不触发恢复或采用该会话。 +启动选择只发送会话状态变更,不发送共享 `settings_changed` 事件。 + +复用模型路由解析和 config-option 返回值进行确认,不引入第二套 provider reasoning 模型。 +所有 daemon 路由继续使用已解析的 workspace runtime 和 owner。 + +## 范围取舍 + +本文替代 issue #11944 中先前的生命周期隔离提案。 +不引入 `startupPinned`、持久策略记录、recording schema 变更、严格恢复协议、fallback 限制、变更门禁或 UI 修改。 +不承诺在 `Config.initialize` 或初始认证前选择路由;准备顺序与现有 UI 相同。 +本次启动操作不修改共享默认设置;之后人工操作保持现有持久化语义。其他三个 PR 不是依赖。 + +## 验收 + +- 比较输入框等效准备流程与新创建 API 的实际 provider 请求,包含工具循环的后续请求。 +- 覆盖不支持思考强度控件的模型仅指定模型创建、显式档位、`default`、`none`、不支持的选择,以及共享为 `none`、请求为 `high`。 +- 验证 standalone commit → 配置 → release → prompt 顺序;准备失败时 prompt 次数为零。 +- 验证用户/工作区设置和兄弟会话不变、不产生默认值变更事件、第二轮和人工修改沿用现状、legacy 创建兼容。 +- 覆盖旧 daemon、非法请求、工作区归属、断连和清理失败。 +- 运行 build、typecheck、bundle、聚焦包测试及 E2E;区分受控 provider 与真实外部 provider 验证证据。 + 完成两轮连续干净的完整 diff 自审。 diff --git a/docs/design/2026-09-19-managed-agent-spring-server.md b/docs/design/2026-09-19-managed-agent-spring-server.md new file mode 100644 index 00000000000..55f165cf6b4 --- /dev/null +++ b/docs/design/2026-09-19-managed-agent-spring-server.md @@ -0,0 +1,298 @@ +# Standalone Managed Agent Spring Server + +[English](2026-09-19-managed-agent-spring-server.md) | [简体中文](2026-09-19-managed-agent-spring-server.zh-CN.md) + +> PR #12692 scope correction (2026-09-25): implementation and verification records below refer to the full integration preview, not acceptance evidence for this split. See [review corrections](2026-09-25-managed-agent-review-corrections.md) for current capabilities, fixes, and remaining gates. + +Status: Phase 1 implemented; production gates remain open +Date: 2026-09-19 + +## 1. Problem + +Qwen Code already has a Hosted Harness profile and Java libraries for its +private HTTP/SSE protocol and Runtime Broker. It does not yet have a runnable, +product-neutral Java control plane in this repository. The existing product +integration depends on DataWorks identity, storage, and deployment code and +cannot be used as a standalone Qwen Code service. + +The new server must provide a low-latency, multi-tenant Session API while the +TypeScript Hosted Harness continues to own the model loop. It must run without +DataWorks, accept an upstream-provided tenant identity, persist authoritative +public state in MySQL, and keep Harness and Tool Runtime credentials private. + +## 2. Goals + +- Add an independently runnable Spring Boot application under + `packages/sdk-java/managed-agent-server`. +- Require `X-Qwen-Tenant-Id` on Agent API requests and use it as the database + ownership boundary. +- Do not implement end-user authentication or interpret `Authorization` on the + public API. +- Persist Session, Turn, command-idempotency, Harness generation fencing, and + public Event state in MySQL. +- Submit model work to the existing Hosted Harness without waiting for Tool + Runtime readiness. +- Expose durable SSE replay from Java-owned events rather than proxying the + Harness stream directly to clients. +- Expose both `/v1/agents/sessions/**` and the current WebShell + `/api/agent/web-shell/v1/**` adapter over one application core. +- Allow the existing Java Runtime Broker to run inside the same control-plane + process on a separate private listener. + +## 3. Non-goals + +- Copying DataWorks controllers, authentication, deployment, or persistence + classes. +- Rewriting the TypeScript Agent loop, MCP, Skills, or tool implementations in + Java. +- Passing `tenantId`, Runtime endpoints, credentials, or leases into model + Prompt content. +- Full OpenAI Managed Agents API compatibility in Phase 1. Unsupported fields + and routes return explicit errors rather than being silently ignored. +- Kubernetes-specific provisioning. The first embedded Broker integration is a + single-node development path; durable multi-replica Broker repositories are a + separate production gate. +- Automatic Legacy-to-Managed Session conversion. + +## 4. Architecture + +```text +Client / WebShell + | + | X-Qwen-Tenant-Id + v +Spring Managed Agent Server (one logical multi-tenant service) + |-- Tenant boundary + public/WebShell adapters + |-- command ledger + Session/Turn/Event store ----> MySQL + |-- asynchronous Harness coordinator + |-- private Runtime Broker listener (optional) + | + +---- HTTP/SSE ----> qwen serve --profile hosted-harness + | |-- model loop and context + | `-- BrokerManagedRuntimeProvider + | | + +<--- private HTTP ---------------------+ + | + `---- HTTP ----------> Tool Runtime + `-- workspace side effects and tools +``` + +Spring replicas are logically stateless apart from process-local live +attachments and SSE subscribers. MySQL owns recoverable public state. Hosted Harness +processes are long-lived, multi-Session execution shards; a Session binding +must remain fenced to one Harness boot generation. + +## 5. Trust and tenant boundary + +The service intentionally has no public authentication dependency. It assumes +that a trusted upstream or private network has authenticated the caller. Every +Agent API request must nevertheless carry `X-Qwen-Tenant-Id` matching +`[A-Za-z0-9._:-]{1,128}`. + +The header is not a security credential by itself. It is an infrastructure +scope that is propagated into command, query, event, and Runtime placement +operations. Every resource query includes `tenant_id`; random identifiers are +never the only isolation control. The value is not appended to Prompt content +and is not sent to Hosted Harness as a caller-controlled claim. + +The private Runtime Broker listener retains a distinct bearer token. This is +machine-to-machine fencing for Harness-to-Broker traffic, not end-user +authentication, and therefore does not conflict with the no-auth public API. + +## 6. Durable data model + +Phase 1 owns these tables: + +| Table | Purpose | +| ----------------------- | --------------------------------------------------------------------------------------------------- | +| `managed_agent_session` | Tenant-owned Session UUID, Harness generation fence, status, and public sequence | +| `managed_agent_turn` | One admitted user input, stable Harness prompt identity, digest, dispatch lease, and terminal state | +| `managed_agent_command` | `(tenant, operation, idempotency key)` claim and semantic request digest | +| `managed_agent_event` | Per-Session public sequence, source-event deduplication, replay payload, and terminal marker | + +One RFC UUID `sessionId` identifies the Session across the public API, Java +store, Hosted Harness, JSONL transcript, and Runtime Broker. The internal +`harnessSessionId` wire field is a compatibility alias carrying this same +value, not a second identity or database mapping. `turnId` remains the public +Turn identity and `promptId` remains the private idempotent Harness submission +identity. No Harness endpoint, boot ID, client ID, Runtime token, or local path +appears in public responses. + +The Session row owns `last_sequence`. Event insertion locks that row, allocates +the next value, and inserts the Event in the same transaction. A unique source +key prevents a Harness reconnect from creating duplicate public events. + +## 7. Command and recovery semantics + +Mutating calls require `Idempotency-Key` (or the equivalent WebShell request +field), limited to 1--128 visible characters. The server canonicalizes the +semantic request and stores its SHA-256 digest before dispatch. + +- The same tenant, operation, key, and digest returns the original IDs with + `replayed=true`. +- The same key with different content returns HTTP 409. +- Admission commits Session, Turn, command, and initial public events before + any Harness call. +- Client disconnect never cancels a Turn. +- Harness submission reuses the persisted `promptId` and payload digest after + an uncertain response. +- A persisted submission-attempt marker distinguishes a safely cancellable + pre-dispatch Turn from an uncertain submission. The latter is reconciled + idempotently before cancellation so it cannot leave an orphan model run. +- A dispatch lease prevents two Spring replicas from actively coordinating the + same Turn. An expired lease permits recovery by another replica. +- Harness source cursor and epoch are persisted after every accepted event. + +The coordinator attaches or loads the same Session UUID in the Harness, +submits the original prompt, consumes its fenced SSE stream, and projects +events into the public store. `turn_complete` and `turn_error` settle the Turn +durably. A scheduled recovery scan reclaims admitted or running Turns whose +dispatch lease expired. + +## 8. API slice + +Phase 1 implements: + +```text +POST /v1/agents/sessions +GET /v1/agents/sessions +GET /v1/agents/sessions/{sessionId} +POST /v1/agents/sessions/{sessionId}/events +GET /v1/agents/sessions/{sessionId}/events +``` + +The event write route accepts an input message or cancellation. Session create +may include initial text input. Public SSE uses Java `publicSequence` in the +`id` field and honors `Last-Event-ID`; it never exposes the Harness event epoch +or source sequence. + +The WebShell adapter implements the existing seven routes: + +```text +POST /api/agent/web-shell/v1/sessions/query +POST /api/agent/web-shell/v1/sessions/get +POST /api/agent/web-shell/v1/transcript/query +POST /api/agent/web-shell/v1/events/stream +POST /api/agent/web-shell/v1/sessions/create +POST /api/agent/web-shell/v1/turns/submit +POST /api/agent/web-shell/v1/turns/cancel +``` + +Both adapters call the same command/query/event services. The WebShell adapter +is not a second execution path. Transcript snapshots return the newest bounded +page in ascending order; `olderCursor` paginates backward without changing the +live `lastSequence` cursor. + +## 9. Hosted Harness and Runtime timing + +Creating the durable Turn and beginning model inference do not depend on Tool +Runtime readiness. When the embedded Runtime Broker is enabled, the server +starts `warm(sessionId)` asynchronously after admission and submits the Prompt +independently. A no-tool Turn can complete without waiting for warmup; a Tool +Call waits at `BrokerManagedRuntimeProvider` for the original Runtime binding. + +The Hosted Harness client validates capability digest, protocol version, boot +ID, SSE epoch, and source sequence. A fence mismatch fails the Turn; it never +falls back to Legacy or a different Runtime. + +## 10. Configuration + +Required production configuration: + +```text +SPRING_DATASOURCE_URL +SPRING_DATASOURCE_USERNAME +SPRING_DATASOURCE_PASSWORD +QWEN_MANAGED_AGENT_HARNESS_ENABLED=true +QWEN_MANAGED_AGENT_HARNESS_BASE_URL +QWEN_MANAGED_AGENT_HARNESS_TOKEN +QWEN_MANAGED_AGENT_CAPABILITY_DIGEST +``` + +Runtime Broker configuration is separate and disabled unless its private +listener, bearer token, and provisioner inputs are explicitly supplied. The +public HTTP server and private Broker listener must not share exposure rules. +For the local-process provisioner, the server derives the Qwen workspace ID +from the canonical workspace path when it is omitted. An explicitly configured +ID must equal that 16-character SHA-256 prefix or application startup fails. + +Flyway applies versioned migrations at application startup. Production uses +MySQL; H2 in MySQL mode is test-only. + +## 11. Failure behavior + +| Failure | Behavior | +| --------------------------------------------- | --------------------------------------------------------------------- | +| Missing or invalid tenant header | HTTP 400 before controller dispatch | +| Cross-tenant resource ID | HTTP 404 | +| Reused idempotency key with changed body | HTTP 409 | +| Harness disabled before admission | Reject with HTTP 503; no command is admitted | +| Harness transport unavailable after admission | Durable Turn remains admitted and is retried by recovery | +| Harness capability or generation mismatch | Durable Turn fails closed | +| Client SSE disconnect | Subscription ends; Turn continues | +| Spring process exit | Another replica can reclaim an expired dispatch lease | +| Local Runtime workspace ID mismatch | Application startup fails before traffic is accepted | +| Runtime warm failure before any Tool Call | Public environment failure; model flow is not synchronously blocked | +| Unknown Tool execution outcome | Broker reports recovery-blocked/unknown; no replay on another Runtime | + +## 12. Validation plan + +- Unit-test tenant parsing, canonical digesting, event projection, and errors. +- Start a real Spring context with Flyway and H2 MySQL mode. +- Verify that the public Session UUID is the exact ID passed to the Harness and + Runtime Broker and that no second Session ID is persisted. +- Verify same-key replay, changed-body conflict, and cross-tenant 404. +- Verify durable Event ordering and `Last-Event-ID` replay. +- Run against a deterministic Hosted Harness fixture and prove that create, + Prompt admission, SSE projection, terminal settlement, and retry use stable + identities. +- Run module tests and Checkstyle on Java 21. +- Build the executable jar and start it with MySQL plus a real + `qwen serve --profile hosted-harness` before declaring the production gate + complete. + +The repository test fixture now covers thirteen tests for tenant enforcement, +cross-tenant 404s, sequential and concurrent command replay (including replay +while the original Turn is active), event resume, asynchronous Harness +projection, cancellation after an uncertain submission result, error-detail +redaction, Runtime Broker conditional wiring, tenant resolution, private Broker +authentication, derived workspace identity, and explicit workspace-ID +mismatch rejection. Checkstyle and executable-jar packaging are part of the +Java SDK workflow. + +The local real-process gate now starts isolated MySQL, the executable Spring +jar, a real Hosted Harness, the embedded Broker, a separate Runtime worker, and +`moonshot/kimi-k3`. In the final controlled 30-second cold-Runtime run, public +admission completed in 114 ms, the first real model event arrived at 6,629 ms, +Runtime became ready at 31,141 ms, and the Turn completed at 39,237 ms. The +same run verified the exact `write_file` side effect, same-Session idempotent +replay, cross-tenant 404, unique durable Event sequences, and one terminal +Event. These timings are one local observation, not an SLO. The deterministic +model fixture remains the CI ordering proof and most recently emitted its +first model event at 300 ms before Runtime readiness at 15,565 ms. + +Production ACS/Pod provisioning, multi-node durable Broker repositories, +process-loss recovery, real load balancing, and full tenant-isolation matrices +remain production gates rather than Phase 1 claims. + +## 13. Acceptance criteria + +Phase 1 is complete when: + +1. The application starts without any DataWorks artifact or classpath. +2. All public and WebShell queries are tenant-scoped and cross-tenant IDs + return 404. +3. A repeated create/submit command returns the original IDs and never creates + a second Turn. +4. A client can reconnect from a public Event sequence without duplicates. +5. A deterministic Harness fixture completes an actual asynchronous Turn. +6. No public response or log contains a Harness, Broker, or Runtime secret. +7. The existing TypeScript Hosted Harness and legacy `qwen serve` routes remain + unchanged. + +The production multi-replica gate additionally requires real MySQL concurrency, +an embedded Broker backed by durable repositories, production Hosted Harness +and ACS/Pod Runtime deployments, process-loss recovery, and load-balanced +tenant-isolation tests. It also requires an idle attachment eviction/capacity +policy so a long-lived Java process does not retain heartbeats for every +historical Session. diff --git a/docs/design/2026-09-19-managed-agent-spring-server.zh-CN.md b/docs/design/2026-09-19-managed-agent-spring-server.zh-CN.md new file mode 100644 index 00000000000..8dbae32063d --- /dev/null +++ b/docs/design/2026-09-19-managed-agent-spring-server.zh-CN.md @@ -0,0 +1,214 @@ +# 独立 Managed Agent Spring 服务 + +[English](2026-09-19-managed-agent-spring-server.md) | [简体中文](2026-09-19-managed-agent-spring-server.zh-CN.md) + +> PR #12692 范围修正(2026-09-25):下文的实现与验证记录来自完整集成预览,不是本次拆分的验收证据。当前能力、修复与未完成门禁以[评审修正](2026-09-25-managed-agent-review-corrections.zh-CN.md)为准。 + +状态:Phase 1 已实现;生产门禁仍未关闭 +日期:2026-09-19 + +## 1. 问题 + +Qwen Code 已经具备 Hosted Harness Profile,以及访问其私有 HTTP/SSE 协议和 Runtime Broker 的 Java 库,但仓库内还没有一个可运行、与具体产品无关的 Java 控制面。现有产品接入依赖 DataWorks 的身份、存储和部署代码,无法直接作为独立 Qwen Code 服务使用。 + +新服务需要在 TypeScript Hosted Harness 继续拥有模型循环的前提下,提供低延迟、多租户 Session API。它必须不依赖 DataWorks,接收上游传入的租户身份,在 MySQL 中持久化权威公共状态,并保证 Harness 和 Tool Runtime 凭证只存在于服务端。 + +## 2. 目标 + +- 在 `packages/sdk-java/managed-agent-server` 增加可独立运行的 Spring Boot 应用。 +- Agent API 请求必须携带 `X-Qwen-Tenant-Id`,并将其作为数据库资源归属边界。 +- 不实现终端用户鉴权,也不解释公共 API 的 `Authorization`。 +- 在 MySQL 中持久化 Session、Turn、命令幂等、Harness generation fencing 和公共 Event 状态。 +- 不等待 Tool Runtime ready,直接把模型工作提交给现有 Hosted Harness。 +- 从 Java 自有的持久事件提供 SSE replay,不把 Harness stream 直接代理给客户端。 +- 在同一应用内同时提供 `/v1/agents/sessions/**` 和当前 WebShell 使用的 `/api/agent/web-shell/v1/**` Adapter。 +- 允许现有 Java Runtime Broker 在同一个控制面进程中通过独立私有 listener 运行。 + +## 3. 非目标 + +- 不复制 DataWorks Controller、鉴权、部署或持久化类。 +- 不用 Java 重写 TypeScript Agent Loop、MCP、Skill 或工具实现。 +- 不把 `tenantId`、Runtime endpoint、凭证或 lease 放入模型 Prompt 内容。 +- Phase 1 不实现完整 OpenAI Managed Agents API 兼容;不支持的字段和路由必须显式报错,不能静默忽略。 +- 不实现 Kubernetes 专属 Provisioner。首个嵌入 Broker 接入只用于单节点开发;Broker 的多副本持久 Repository 是独立生产门禁。 +- 不自动把 Legacy Session 转换为 Managed Session。 + +## 4. 架构 + +```text +Client / WebShell + | + | X-Qwen-Tenant-Id + v +Spring Managed Agent Server(一个逻辑多租户服务) + |-- 租户边界 + 公共/WebShell Adapter + |-- 命令账本 + Session/Turn/Event 存储 ----> MySQL + |-- 异步 Harness Coordinator + |-- 私有 Runtime Broker listener(可选) + | + +---- HTTP/SSE ----> qwen serve --profile hosted-harness + | |-- 模型循环与上下文 + | `-- BrokerManagedRuntimeProvider + | | + +<--- 私有 HTTP -----------------------+ + | + `---- HTTP ----------> Tool Runtime + `-- Workspace 副作用与工具 +``` + +除进程内的在线 attachment 和 SSE subscriber 外,Spring 副本在逻辑上无状态;MySQL 持有可恢复的公共状态。Hosted Harness 进程是常驻、多 Session 的执行 shard;一个 Session binding 必须固定到一个 Harness boot generation。 + +## 5. 信任与租户边界 + +服务刻意不依赖公共鉴权组件,假设可信上游或私网已经完成调用方鉴权。但每个 Agent API 请求仍必须携带符合 `[A-Za-z0-9._:-]{1,128}` 的 `X-Qwen-Tenant-Id`。 + +该 Header 本身不是安全凭证,而是贯穿命令、查询、事件和 Runtime placement 的基础设施作用域。每次资源查询都必须包含 `tenant_id`;不能只依赖随机 ID 做隔离。该值不会追加到 Prompt,也不会作为调用方声明直接发送给 Hosted Harness。 + +私有 Runtime Broker listener 仍使用独立 bearer token。这是 Harness 到 Broker 流量的机器间 fencing,不是终端用户鉴权,因此不与公共 API 的“无鉴权”目标冲突。 + +## 6. 持久数据模型 + +Phase 1 自有以下表: + +| 表 | 作用 | +| ----------------------- | --------------------------------------------------------------------------- | +| `managed_agent_session` | 租户所有的 Session UUID、Harness generation fence、状态和公共序号 | +| `managed_agent_turn` | 一次已受理用户输入、稳定 Harness prompt 身份、digest、dispatch lease 和终态 | +| `managed_agent_command` | `(tenant, operation, idempotency key)` claim 和语义请求 digest | +| `managed_agent_event` | Session 内公共序号、源事件去重、replay payload 和终态标记 | + +一个 RFC UUID `sessionId` 贯穿公共 API、Java 存储、Hosted Harness、JSONL transcript 和 Runtime Broker。内部协议中的 `harnessSessionId` 字段只作为兼容别名传递同一个值,不再代表第二套身份,也不在数据库中保存映射。`turnId` 仍是公共 Turn 身份,`promptId` 仍是 Harness 幂等提交使用的私有身份。公共响应不得包含 Harness endpoint、boot ID、client ID、Runtime token 或本地路径。 + +Session 行持有 `last_sequence`。插入 Event 时锁定该行,在同一事务内分配下一序号并写入事件。唯一 source key 防止 Harness 重连产生重复公共事件。 + +## 7. 命令与恢复语义 + +所有写请求必须携带 `Idempotency-Key`(或 WebShell 对应请求字段),长度限制为 1--128 个可见字符。服务对语义请求做 canonicalize,并在 dispatch 前保存 SHA-256 digest。 + +- 同一 tenant、operation、key 和 digest 返回原 ID,并设置 `replayed=true`。 +- 相同 key 对应不同内容时返回 HTTP 409。 +- 在任何 Harness 调用前,先提交 Session、Turn、command 和初始公共事件。 +- 客户端断开不会取消 Turn。 +- Harness 提交响应不确定时,继续复用已持久化的 `promptId` 和 payload digest。 +- 持久化的 submission-attempt 标记用于区分“尚未 dispatch、可直接取消”和 + “提交结果不确定”两种 Turn;后一种必须先幂等 reconcile,再执行取消,避免留下 + 孤儿模型任务。 +- dispatch lease 防止两个 Spring 副本同时协调同一 Turn;lease 过期后可由其他副本恢复。 +- 每个已受理 Harness 事件后都持久化源 cursor 和 epoch。 + +Coordinator 使用同一个 Session UUID attach 或 load Harness Session,提交原 Prompt,消费带 fencing 的 SSE,并投影到公共事件存储。`turn_complete` 和 `turn_error` 负责持久结算 Turn。定时恢复扫描会重新 claim dispatch lease 已过期的 admitted/running Turn。 + +## 8. API 切片 + +Phase 1 实现: + +```text +POST /v1/agents/sessions +GET /v1/agents/sessions +GET /v1/agents/sessions/{sessionId} +POST /v1/agents/sessions/{sessionId}/events +GET /v1/agents/sessions/{sessionId}/events +``` + +事件写入口接受 input message 或 cancellation。创建 Session 时可以包含首条文本输入。公共 SSE 的 `id` 使用 Java `publicSequence` 并支持 `Last-Event-ID`;不得暴露 Harness event epoch 或源 sequence。 + +WebShell Adapter 实现现有七条路由: + +```text +POST /api/agent/web-shell/v1/sessions/query +POST /api/agent/web-shell/v1/sessions/get +POST /api/agent/web-shell/v1/transcript/query +POST /api/agent/web-shell/v1/events/stream +POST /api/agent/web-shell/v1/sessions/create +POST /api/agent/web-shell/v1/turns/submit +POST /api/agent/web-shell/v1/turns/cancel +``` + +两个 Adapter 调用相同的 Command/Query/Event Service;WebShell Adapter 不是第二条执行链路。 +Transcript 首屏按升序返回最新的有界事件页,`olderCursor` 用于向前翻页,且不改变 +实时订阅使用的 `lastSequence` 游标。 + +## 9. Hosted Harness 与 Runtime 时序 + +创建持久 Turn 和开始模型推理不依赖 Tool Runtime ready。启用嵌入 Runtime Broker 后,服务在 admission 后异步调用 `warm(sessionId)`,同时独立提交 Prompt。无工具 Turn 可以在不等待 warmup 的情况下完成;发生 Tool Call 时,由 `BrokerManagedRuntimeProvider` 等待原 Runtime binding。 + +Hosted Harness Client 校验 capability digest、protocol version、boot ID、SSE epoch 和源 sequence。fence 不匹配时 Turn 失败关闭;不得回退到 Legacy 或其他 Runtime。 + +## 10. 配置 + +生产必需配置: + +```text +SPRING_DATASOURCE_URL +SPRING_DATASOURCE_USERNAME +SPRING_DATASOURCE_PASSWORD +QWEN_MANAGED_AGENT_HARNESS_ENABLED=true +QWEN_MANAGED_AGENT_HARNESS_BASE_URL +QWEN_MANAGED_AGENT_HARNESS_TOKEN +QWEN_MANAGED_AGENT_CAPABILITY_DIGEST +``` + +Runtime Broker 使用独立配置;只有显式提供私有 listener、bearer token 和 Provisioner 输入时才启用。公共 HTTP Server 与私有 Broker listener 不能使用相同暴露策略。 +使用 local-process Provisioner 时,如果没有配置 workspace ID,服务会从 canonical workspace 路径派生与 Qwen 一致的 ID;如果显式配置,则必须等于 SHA-256 的前 16 位,否则应用启动失败。 + +Flyway 在应用启动时执行版本化 migration。生产使用 MySQL;H2 MySQL mode 只用于测试。 + +## 11. 失败行为 + +| 失败 | 行为 | +| --------------------------------------- | ------------------------------------------------------------- | +| tenant Header 缺失或非法 | Controller dispatch 前返回 HTTP 400 | +| 使用其他 tenant 的资源 ID | HTTP 404 | +| 相同幂等 key 对应不同正文 | HTTP 409 | +| admission 前 Harness 未启用 | 返回 HTTP 503,不受理 command | +| admission 后 Harness 传输不可用 | Turn 保持 durable admitted,由恢复任务重试 | +| Harness capability 或 generation 不匹配 | Turn 失败关闭 | +| Client SSE 断开 | 仅结束订阅;Turn 继续 | +| Spring 进程退出 | 其他副本可在 dispatch lease 过期后接管 | +| 本地 Runtime workspace ID 不匹配 | 接收流量前应用启动失败 | +| Tool Call 前 Runtime warm 失败 | 产生公共 environment failure;模型流不被同步阻塞 | +| Tool 执行结果不确定 | Broker 返回 recovery-blocked/unknown;不得在其他 Runtime 重放 | + +## 12. 验证计划 + +- 单测 tenant 解析、canonical digest、事件投影和错误映射。 +- 用 H2 MySQL mode 启动真实 Spring Context 和 Flyway。 +- 验证公共 Session UUID 与传给 Harness、Runtime Broker 的 ID 完全一致,数据库不保存第二套 Session ID。 +- 验证相同 key replay、不同正文冲突和跨 tenant 404。 +- 验证持久 Event 顺序与 `Last-Event-ID` replay。 +- 对接确定性 Hosted Harness fixture,证明 create、Prompt admission、SSE 投影、终态结算和 retry 都使用稳定身份。 +- 在 Java 21 上执行模块测试与 Checkstyle。 +- 在生产门禁完成前,用 MySQL 和真实 `qwen serve --profile hosted-harness` 构建并启动 executable jar。 + +仓库内的确定性 fixture 现已覆盖 13 个测试:tenant 强制校验、跨 tenant +404、串行与并发命令重放(包括原 Turn 仍活跃时的重放)、事件续传、异步 +Harness 事件投影、提交结果不确定后的取消恢复、错误详情脱敏、Runtime +Broker 的条件装配和 tenant 解析、Broker 私有鉴权、workspace identity 自动派生, +以及显式 workspace ID 不匹配拒绝。Java SDK 工作流已包含 Checkstyle 与 +executable jar 打包。 + +本地真实进程门禁现已启动隔离 MySQL、可执行 Spring jar、真实 Hosted Harness、 +嵌入 Broker、独立 Runtime worker 和 `moonshot/kimi-k3`。最终一轮受控 30 秒 +Runtime 冷启动中,公共 admission 为 114 ms,首个真实模型事件为 6,629 ms, +Runtime ready 为 31,141 ms,Turn 在 39,237 ms 完成;同一轮还验证了精确的 +`write_file` 副作用、同 Session 幂等重放、跨 tenant 404、持久 Event sequence +无重复且仅有一个 terminal Event。这些时延只是一次本地观测,不是 SLO。 +确定性模型 fixture 继续作为 CI 的时序证明;最近一次结果为首模型事件 300 ms, +Runtime ready 15,565 ms。 + +生产 ACS/Pod 调度、多节点 Broker 持久 Repository、进程丢失恢复、真实负载均衡 +和完整租户隔离矩阵仍是生产门禁,不能算作 Phase 1 已证明的能力。 + +## 13. 验收标准 + +Phase 1 完成条件: + +1. 应用在 classpath 中没有任何 DataWorks artifact 或 class 的情况下启动。 +2. 所有公共和 WebShell 查询都按 tenant 限定,跨 tenant ID 返回 404。 +3. 重复 create/submit 命令返回原 ID,且不会创建第二个 Turn。 +4. 客户端可以从公共 Event sequence 重连,且不会收到重复事件。 +5. 确定性 Harness fixture 可以完成一个真实异步 Turn。 +6. 任何公共响应或日志都不包含 Harness、Broker 或 Runtime secret。 +7. 现有 TypeScript Hosted Harness 和 Legacy `qwen serve` 路由行为不变。 + +生产多副本门禁还要求:真实 MySQL 并发、使用持久 Repository 的嵌入 Broker、生产 Hosted Harness 与 ACS/Pod Runtime 部署、进程丢失恢复,以及负载均衡下的租户隔离测试。同时还需要 attachment 空闲淘汰/容量策略,避免长期运行的 Java 进程持续为所有历史 Session 保留 heartbeat。 diff --git a/docs/design/2026-09-20-managed-agent-dual-path-web-shell.md b/docs/design/2026-09-20-managed-agent-dual-path-web-shell.md new file mode 100644 index 00000000000..7cdee946a4f --- /dev/null +++ b/docs/design/2026-09-20-managed-agent-dual-path-web-shell.md @@ -0,0 +1,118 @@ +# Managed Agent Dual-Path WebShell + +[English](./2026-09-20-managed-agent-dual-path-web-shell.md) | +[简体中文](./2026-09-20-managed-agent-dual-path-web-shell.zh-CN.md) + +> PR #12692 scope correction (2026-09-25): implementation and verification records below refer to the full integration preview, not acceptance evidence for this split. See [review corrections](2026-09-25-managed-agent-review-corrections.md) for current capabilities, fixes, and remaining gates. + +## Status + +Implemented and locally verified on 2026-09-20. Production products opt in +through the existing public WebShell provider prop. + +## Problem + +The Java Managed Agent path previously had a daemon-free, Managed-only entry. +That entry proves the browser-to-Java contract, but it does not demonstrate how +Managed Sessions coexist with the complete WebShell used by `qwen serve`. + +Java must not implement the daemon protocol merely to reuse the full UI. The +ordinary and Managed paths have different owners and must remain independently +deployable. + +## Goals and scope + +- Reuse the complete WebShell chrome for a deployment that has both an + ordinary daemon and the Java Managed Agent service. +- Route each surface to its existing owner without translating one protocol + into the other. +- Preserve the daemon-free Managed-only component for products that do not run + an ordinary daemon. + +This change does not merge Java and daemon Session state, add authentication to +the standalone Spring service, or expose private Harness and Runtime APIs. + +## Decision + +The complete WebShell keeps its existing daemon providers and receives an +explicit Java `ManagedAgentProvider` for the Managed panel: + +```text +Full WebShell +├── ordinary chat, workspace, settings, terminal ──> Qwen daemon +└── Managed Sessions ──> Spring WebShell API ──> Hosted Harness + └──> Runtime Broker ──> Tool Runtime +``` + +`App` already accepts `managedAgentProvider`; no daemon API is replaced. The +standard Vite entry adds a Java API proxy and creates the provider only when +`managedProvider=java` is explicitly present in development. `managed=1` +selects the existing Managed panel, and `managedSession` remains its deep-link +identifier. + +The Managed-only `ManagedAgentWebShell` remains the preferred entry for a +product without an ordinary daemon. A production dual-path host creates the +Java provider itself and passes it to `WebShellWithProviders`; URL-based tenant +selection is not a production contract. + +## Security and ownership + +- The browser calls only the public Spring WebShell API. Harness, Runtime + Broker, and Tool Runtime endpoints and credentials stay private. +- The development `tenant` query parameter is only a test convenience. A + production upstream derives tenant identity and supplies the Java API header. +- Ordinary WebShell requests continue to use the daemon credential and daemon + workspace ownership rules. +- Java Session ids and daemon Session ids remain separate namespaces. Opening a + Managed deep link does not restore a daemon Session. +- The Java API proxy is registered on its exact route prefix before daemon + routes, so Managed traffic cannot fall through to the daemon. + +## Risks and mitigations + +- A missing ordinary daemon leaves non-Managed panels unavailable. The + Managed-only component remains the correct entry for that topology. +- A browser-provided tenant would be unsafe as production identity. The URL + adapter is development-only; production hosts derive tenant identity. +- Two backends can make ownership ambiguous. The exact Java route prefix and + explicit provider selection keep routing deterministic. + +## Local activation + +Run an ordinary daemon, the Spring/Hosted-Harness/Runtime chain, and the WebShell +development server: + +```bash +QWEN_DAEMON_URL=http://127.0.0.1:4170 \ +QWEN_MANAGED_AGENT_JAVA_URL=http://127.0.0.1:8080 \ + npm run dev:managed-agent-web +``` + +Open +`http://127.0.0.1:5174/?managed=1&managedProvider=java&tenant=local-java-demo`. + +## Validation plan + +- Unit-test explicit Java selection, tenant header propagation, and unchanged + fallback behavior. +- Verify the exact Java proxy target alongside the existing daemon proxies. +- Run the real browser through Java, Hosted Harness, Runtime Broker, and Tool + Runtime, then open a daemon-owned panel in the same page. + +## Acceptance criteria + +1. The page renders the standard WebShell chrome and opens the Managed panel. +2. Managed list, create, replay, submit, cancel, and event streaming call only + `/api/agent/web-shell/v1/**` and carry the selected development tenant. +3. Existing daemon panels continue to use the ordinary daemon. +4. Without `managedProvider=java`, standalone WebShell behavior is unchanged. +5. The Managed-only exported component remains daemon-free. + +## Verification result + +The full page loaded an existing Java Managed Session, completed a new turn +with the exact response `FULL_SHELL_OK`, and then opened the daemon-owned Status +panel in the same browser page. The development proxy returned both the Java +Session list and daemon health successfully. Focused WebShell tests covered the +startup adapter, proxy registration, and Managed panel behavior when daemon +workspace context is not yet available. diff --git a/docs/design/2026-09-20-managed-agent-dual-path-web-shell.zh-CN.md b/docs/design/2026-09-20-managed-agent-dual-path-web-shell.zh-CN.md new file mode 100644 index 00000000000..ad0b5bae699 --- /dev/null +++ b/docs/design/2026-09-20-managed-agent-dual-path-web-shell.zh-CN.md @@ -0,0 +1,98 @@ +# Managed Agent 双通道 WebShell + +[English](./2026-09-20-managed-agent-dual-path-web-shell.md) | +[简体中文](./2026-09-20-managed-agent-dual-path-web-shell.zh-CN.md) + +> PR #12692 范围修正(2026-09-25):下文的实现与验证记录来自完整集成预览,不是本次拆分的验收证据。当前能力、修复与未完成门禁以[评审修正](2026-09-25-managed-agent-review-corrections.zh-CN.md)为准。 + +## 状态 + +本地开发链路已实现,并于 2026-09-20 完成验证。生产产品通过现有 WebShell provider 属性显式启用。 + +## 问题 + +Java Managed Agent 链路此前只有一个不依赖 daemon 的 Managed-only 入口。这个入口可以证明浏览器到 +Java 的协议闭环,但无法展示 Managed Session 如何与 `qwen serve` 使用的完整 WebShell 共存。 + +Java 不应为了复用完整 UI 而实现 daemon 协议。普通链路和 Managed 链路的 owner 不同,必须保持独立 +部署能力。 + +## 目标与范围 + +- 在同时具备普通 daemon 和 Java Managed Agent 服务的部署中复用完整 WebShell 外壳。 +- 每个界面继续路由到原有 owner,不在两个协议之间增加转换层。 +- 为不运行普通 daemon 的产品保留无 daemon 的 Managed-only 组件。 + +本次变更不合并 Java 与 daemon 的 Session 状态,不给独立 Spring 服务新增鉴权,也不开放 Harness 和 +Runtime 的私有 API。 + +## 决策 + +完整 WebShell 保留现有 daemon providers,同时给 Managed 面板显式注入 Java +`ManagedAgentProvider`: + +```text +完整 WebShell +├── 普通会话、workspace、设置、终端 ──> Qwen daemon +└── Managed Sessions ──> Spring WebShell API ──> Hosted Harness + └──> Runtime Broker ──> Tool Runtime +``` + +`App` 已支持 `managedAgentProvider`,不需要替换任何 daemon API。标准 Vite 入口新增 Java API +代理,并且只在开发环境显式传入 `managedProvider=java` 时创建 provider。`managed=1` 继续选择现有 +Managed 面板,`managedSession` 继续作为 Managed Session 深链标识。 + +对于没有普通 daemon 的产品,`ManagedAgentWebShell` 仍是推荐入口。生产双通道宿主自行创建 Java +provider,并传给 `WebShellWithProviders`;通过 URL 选择 tenant 不属于生产契约。 + +## 安全与所有权 + +- 浏览器只调用 Spring 公共 WebShell API。Harness、Runtime Broker、Tool Runtime 的 endpoint 和凭证 + 始终留在私网。 +- 开发环境的 `tenant` 查询参数只用于测试。生产上游负责推导租户身份并注入 Java API header。 +- 普通 WebShell 请求继续使用 daemon 凭证和 daemon workspace 所有权规则。 +- Java Session ID 与 daemon Session ID 是两个独立命名空间。打开 Managed 深链不会恢复 daemon + Session。 +- Java API 代理使用精确路径前缀,并位于 daemon 路由之前,Managed 流量不会误落到 daemon。 + +## 风险与缓解 + +- 缺少普通 daemon 时,非 Managed 面板不可用;该拓扑应继续使用 Managed-only 组件。 +- 将浏览器传入的 tenant 当作生产身份不安全;URL 适配只在开发环境启用,生产宿主负责推导租户身份。 +- 两个后端可能造成 owner 模糊;精确的 Java 路由前缀和显式 provider 选择保证路由确定。 + +## 本地启用 + +同时运行普通 daemon、Spring/Hosted-Harness/Runtime 链路以及 WebShell 开发服务器: + +```bash +QWEN_DAEMON_URL=http://127.0.0.1:4170 \ +QWEN_MANAGED_AGENT_JAVA_URL=http://127.0.0.1:8080 \ + npm run dev:managed-agent-web +``` + +打开 +`http://127.0.0.1:5174/?managed=1&managedProvider=java&tenant=local-java-demo`。 + +## 验证计划 + +- 单测覆盖显式选择 Java、tenant header 透传,以及未选择时行为保持不变。 +- 在现有 daemon 代理旁验证精确的 Java 代理目标。 +- 用真实浏览器跑通 Java、Hosted Harness、Runtime Broker、Tool Runtime,并在同一页面打开 daemon + 所有的面板。 + +## 验收标准 + +1. 页面展示标准 WebShell 外壳,并自动打开 Managed 面板。 +2. Managed 列表、创建、回放、提交、取消和事件流只访问 + `/api/agent/web-shell/v1/**`,并携带开发租户。 +3. 现有 daemon 面板继续访问普通 daemon。 +4. 不传 `managedProvider=java` 时,standalone WebShell 行为不变。 +5. 已导出的 Managed-only 组件仍然不依赖 daemon。 + +## 验证结果 + +完整页面成功加载已有 Java Managed Session,新回合精确返回 `FULL_SHELL_OK`,随后在同一个浏览器页面 +打开了 daemon 所有的 Status 面板。开发代理能够同时返回 Java Session 列表和 daemon health。定向 +WebShell 测试覆盖了启动适配、代理注册,以及 daemon workspace context 尚未就绪时 Managed 面板保持 +可用的行为。 diff --git a/docs/design/2026-09-20-managed-agent-storage-event-architecture.md b/docs/design/2026-09-20-managed-agent-storage-event-architecture.md new file mode 100644 index 00000000000..31132aae1c4 --- /dev/null +++ b/docs/design/2026-09-20-managed-agent-storage-event-architecture.md @@ -0,0 +1,383 @@ +# Managed Agent Storage, Events, and Session Recovery + +[English](2026-09-20-managed-agent-storage-event-architecture.md) | [简体中文](2026-09-20-managed-agent-storage-event-architecture.zh-CN.md) + +> PR #12692 scope correction (2026-09-25): implementation and verification records below refer to the full integration preview, not acceptance evidence for this split. See [review corrections](2026-09-25-managed-agent-review-corrections.md) for current capabilities, fixes, and remaining gates. + +Status: proposed architecture, with P0 and the SQL-backed P1 materialization slice implemented on this branch. Date: 2026-09-20. This design uses the integration snapshot below; it does not claim production validation of the complete architecture. + +Supplement status: disaster recovery and compliance deletion in Sections 9.1–9.4 are proposed and unvalidated. This change updates documentation only, with no code/schema/deployment changes or product tests. The supplement uses the fixed [code_agent@1478e7b](https://github.com/doudouOUC/code_agent/tree/1478e7b632eb237bc3f40ea574ce90782e1cf4c3/qwen-code/feature/managed-agents) design, draft integration reference `bad721f22fcd8cfad9ec22e98f69fec75b20b6f0`, and inspected local baseline `f5088d2e`; it neither replaces the older design wholesale nor treats those snapshots as one implementation. Item 5, quotas/billing, is excluded. Tenant trust and strong-isolation selection remain deferred, with no implied production multi-tenant security guarantee. + +## 1. Decisions + +Keep the WebShell → Java control plane → Hosted Harness → Runtime Broker → Tool-only Runtime responsibilities. Harness runs the Qwen Agent loop; Java owns admission, state, event projection, and client APIs; Runtime owns tools and the workspace. Model inference and Runtime warmup remain concurrent, with a wait only when an actual tool call needs Runtime. + +Define interfaces by responsibility. Keep MySQL as the first implementation; PostgreSQL implements the same business contract. Prefer an existing RocketMQ platform for reliable asynchronous distribution. Without one, start with SQL batch scanning for materialization instead of deploying MQ just for SSE. Redis Streams remains an optional adapter, rather than another default dependency alongside RocketMQ. + +The first stage uses **a short-lived SQL batch journal that also serves as an Outbox → immediate SSE after commit → asynchronous distribution and message materialization**. This refines the earlier suggestion of writing directly to MQ and persisting asynchronously: current code commits the Session sequence, Turn state, and Harness cursor in one database transaction. Splitting them immediately creates a dual-write gap. Preserve a bounded transaction boundary while removing permanent per-chunk storage and polling per connection. + +The tradeoff is explicit: SQL remains on the event acceptance path. MQ outages can be buffered, but SQL outages still block acceptance of new events. This design does not keep accepting output indefinitely during a database outage. If database fault isolation is mandatory, prioritize the durable source journal work in Section 12 before evaluating an MQ-first path. + +This document accompanies the initial schema and SDK integration described below. It does not promise recovery at an arbitrary model token, exactly-once tool side effects, live database switching, or equivalent support for every MQ feature. + +The current implementation slice introduces `AgentStateStore`, bounded Harness event batching, stable Item/Part identities, one-transaction cursor/sequence/terminal updates, and post-commit local SSE delivery with durable replay fallback. Flyway V2 adds Item, Item Part, Snapshot, and consumer-progress tables. A SQL scanner materializes each contiguous event prefix transactionally, and the WebShell reads a consistent Snapshot plus control events and the unmaterialized tail. It retains one SQL row per public event for compatibility. The batch-journal/Outbox schema, retention, external EventTransport, PostgreSQL adapter, multi-instance wakeup, and durable Harness/Runtime recovery remain proposed follow-up work. + +## 2. Verified Code Baseline + +Integration base: branch `feature/managed-agents-p0-p8`, commit `fc32ab0c9502a0b44020ef1a66c88e9b3a2a1484`. The P0/P1 slice described above is committed on the same branch after that base. Paths below refer to this branch snapshot. + +The Java service source root is `packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/`. + +| Location | Current behavior and design implication | +| --------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `store/ManagedAgentStore.java` | Concrete JDBC class; transactions maintain command idempotency, Turns, source cursors, and public events together. Do not split these into unrelated CRUD interfaces. | +| `service/HarnessCoordinator.java` | Consumes Harness SSE; deduplicates with `bootId:eventEpoch:eventId`; submits bounded source batches. Java also generates Runtime status events. | +| `service/HarnessEventProjector.java` | Text and tool projections now carry deterministic Item/Part identities while preserving the explicit public-field allowlist. | +| `service/ManagedEventStreamService.java` | Uses post-commit local notifications for the live path and periodic SQL reconciliation for gaps or reconnects. The browser never accesses the database directly. | +| `service/ManagedAgentService.java` | Transcript reads the latest materialized Snapshot, retained control events, and the event tail after `coveredSequence`, with legacy event paging before a Snapshot exists. | +| `harness/HarnessConnector.java` | Existing connector interface can be reused; includes submission, SSE, cancellation, and boot generation information. | +| `src/main/resources/db/migration/V1__managed_agent_core.sql` and `V2__managed_agent_projection.sql` | V1 contains Session, Turn, Command, and Event. V2 adds Item, Item Part, Snapshot, and projection progress; the batch journal/Outbox is not implemented. | +| `packages/sdk-java/runtime-broker/` | Already has three Repository interfaces; the Embedded Broker default constructor still uses in-memory implementations. | +| `packages/core/src/managed-runtime/managed-session-assembly.ts` | Directly assembles local Session authority, resource storage, and writer leases; not yet a replaceable remote persistence implementation. | + +The latest code uses the same Session UUID for the public Java Session, Harness, JSONL, and Broker. `harnessSessionId` is only a protocol alias; do not introduce another identity mapping. Tenant isolation still uses `(tenantId, sessionId)`; `turnId` and the stable `promptId` have separate purposes. + +An ordinary projected event causes multiple UPDATEs and one INSERT. Filtered source events also advance recovery cursors. A chunk is not necessarily a token. The baseline does not include the merging, expiry, or aggregate storage proposed here. + +## 3. Data Ownership + +| Data | Authority and storage | Retention | +| ---------------------------------------------------------------------------- | ------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| Session, Turn, Command, leases, idempotency, and authorization scope | Java relational database | Business retention policy; idempotency records must outlive the promised retry window. | +| Accepted public events | Short-lived SQL batch journal; MQ holds distribution copies | Both the replay window and required consumer progress govern cleanup. | +| Display Message/Item, tool cards, and Snapshot | SQL projections built from public events and accepted input | Preserve complete content long term; reference object storage for large payloads. | +| Model context, private Session records, checkpoints, and resource references | Harness Session authority | Persist independently; never reconstruct these from the public Message table. | +| Runtime bindings and tool execution ledger | Durable Broker Repositories | Cover at least execution reconciliation and recovery periods. | +| Workspace, large tool results, file history, and recovery resources | Persistent volumes or object storage with reference manifests | Resources referenced by a Session or execution must survive Runtime reclamation. | + +SSE is a transport protocol, MQ distributes events, and the relational database holds business state and query models. None automatically replaces the Harness Session recovery protocol. + +## 4. First-Stage End-to-End Flow + +```mermaid +flowchart TD + UI[WebShell] -->|Prompt / cancel| API[Java API] + API -->|command transaction| DB[(Agent SQL Store)] + API --> HC[HarnessCoordinator] + HC --> H[Hosted Harness] + H -->|source SSE| ING[Projector + bounded batcher] + ING -->|acceptBatch transaction| DB + DB -->|committed events| HUB[SessionEventHub] + HUB -->|SSE| UI + DB --> RELAY[Outbox relay] + RELAY --> MQ[EventTransport: RocketMQ or Redis Streams] + MQ --> MAT[Message materializer] + DB -. SQL-only deployment .-> MAT + MAT -->|items + snapshot + checkpoint transaction| DB + UI -->|reconnect / history| REPLAY[SessionReplayService] + REPLAY --> DB + H --> BROKER[Runtime Broker] + BROKER --> RT[Tool-only Runtime] + H --> ART[Session journal + durable resources] + RT --> WS[Durable workspace / result artifacts] +``` + +1. Java atomically stores Prompt command idempotency, the Turn, input, and control events, then returns the accepted Turn identity. +2. The Coordinator obtains a lease with a generation, submits to Harness using a stable `promptId`, and warms Runtime concurrently. If the submission response is lost, reconcile the original submission rather than submitting a new Prompt. +3. Java parses Harness SSE, projects and deduplicates events, and forms bounded batches. After `acceptBatch` commits, the local node sends the returned events directly to its SSE Hub without reading SQL again or waiting for MQ. +4. The Relay reads the same batch journal and publishes through the configured EventTransport; consumers materialize Message/Item state. Without MQ, a database batch scanner runs the same materialization transaction. +5. Java fills browser reconnect gaps using Session sequences. History queries read complete Items and Snapshots. + +User input is not a model delta. The materializer builds user Items from immutable Turn input saved in the admission transaction; extend `turn.accepted` with stable input Item references and revisions. Current events contain only `turnId`, so the existing public stream cannot be assumed to contain the complete conversation by itself. Fix input/projection identity, digest, and associated sequence; Snapshots include only versions within their covered sequence. + +“Push and persist concurrently” means **confirm durable acceptance first, then perform presentation and subsequent processing independently**. Starting two asynchronous tasks for SSE and MQ does not make them one reliable operation. An Outbox commits local state and pending publication together, but Relay retries can still duplicate publication, so consumers must be idempotent. [Transactional outbox](https://microservices.io/patterns/data/transactional-outbox.html) + +## 5. Interface Boundaries + +These are target responsibility sketches and do not require an interface for every table. The checked-in `AgentStateStore` currently covers the P0/P1 subset rather than this complete target surface. + +```java +interface AgentStateStore { + CommandResult acceptCommand(Command command); + Lease claimTurn(TurnKey turn, Duration duration); + AcceptedBatch acceptBatch(Lease lease, IngressBatch batch); + void applyProjection(ProjectionMutation mutation); + ReplayPage readReplay(SessionKey session, long after, int limit); + TranscriptSnapshot readSnapshot(SessionKey session); +} + +interface EventTransport { + CompletionStage publish(CommittedBatch batch); + Subscription consume(ConsumerSpec consumer, BatchHandler handler); +} + +interface SessionArtifactStore { + ArtifactRef putImmutable(ArtifactScope scope, InputStream content); + InputStream readVerified(ArtifactScope scope, ArtifactRef ref); +} +``` + +| Boundary | Contract | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `AgentStateStore` | Business transactions, scope isolation, lease generations, cursors, sequence allocation, and idempotent projections. Commands and their events, and batches and their source cursors, commit in one transaction on one backend. | +| `EventTransport` | At-least-once distribution of committed batches, explicit processing success/failure, publication acknowledgement, and health. Business `eventId` does not depend on Broker message IDs; clients never use Broker offsets. | +| `SessionReplayService` | Application service combining Snapshots, short-lived range reads, and subscriptions; not an adapter claiming every MQ supports per-Session seek. | +| `SessionArtifactStore` | Immutable bytes, length/digest verification, and tenant/Session scope. Object storage does not own leases, journal commits, or tool deduplication. | +| Harness Session authority | Extend the existing authority boundary with recoverable journal commits and fencing. Harness remains the sole private Session writer; Java does not become a second writer. | + +`MySqlAgentStateStore` and `PostgresAgentStateStore` may share row mapping and business types while using their own SQL and Flyway migrations. Translate database errors into common conflict results after leaving the failed transaction; do not catch a uniqueness failure inside an aborted PostgreSQL transaction and continue querying. Specify common lock order, transaction isolation, database-time leases, string case semantics, JSON encoding, pagination, and retry scope. Changing the JDBC URL alone is insufficient. [PostgreSQL transaction isolation](https://www.postgresql.org/docs/current/transaction-iso.html) + +Reuse the Broker's `RuntimeBindingRepository`, `RuntimeSessionRepository`, and `ToolExecutionRepository` with MySQL/PostgreSQL implementations. Do not invent another Broker storage API. Business operations spanning repositories must still define their transaction or CAS boundaries. + +Keep EventTransport's common guarantees small: duplicates and reordering across reconnects are allowed, while the application checks committed sequences, deduplicates, and fills gaps. Adapters may improve ordering and throughput but must not silently weaken acknowledgement or recovery semantics. Implement only the backends actually needed for deployment and their shared contract tests initially, rather than every MQ. + +## 6. Event Protocol and Acceptance Transaction + +Events carry at least `schemaVersion`, `projectionVersion`, `tenantId` (internal), `sessionId`, `turnId`, `sequence`, `eventId`, `type`, `data`, and `createdAt`. Text also needs stable `itemId` and `contentPartId` fields. Identity comes from a Harness protocol extension, not temporary Java in-memory counters. + +Negotiate stable Item/Part identities and Snapshot reset support separately. Retain the old projection path when Harness lacks the former, and the old replay path when a client lacks the latter. Do not infer identities to merge fragments or enable cleanup for incompatible Sessions prematurely. + +Internal batches record `batchId`, `firstSequence`, `lastSequence`, `producerGeneration`, source `bootId/eventEpoch`, and the accepted source cursor. Public sequences order commits within a Session; source cursors support Harness recovery. They are not interchangeable. Replay preserves the original projection version, sequence, and content rather than rerunning the latest projector. + +`acceptBatch` must complete the following in one transaction: + +1. Lock Session and Turn in a consistent order. Validate tenant, active Turn, database-time lease, monotonically increasing owner generation, and source epoch. An expired writer cannot commit even if its process remains alive. +2. Discard input at or before the committed source cursor in the current epoch, then merge. Before commit, IngressBatch preserves source event boundaries so retries can overlap an already accepted prefix. Numeric cursors from different epochs cannot be compared. +3. Allocate consecutive `sequence` values for new public events, store an immutable batch, and advance source cursors and the Session watermark. Commit terminal state and its terminal event together. If every input is filtered, commit only a cursor checkpoint, without an empty public event. +4. Publish to the Hub only after commit. Command-generated control events use the same sequence allocator. Runtime callbacks validate operation generation/state and reject obsolete operations advancing current state. Trusted late receipts for original calls use a separate restricted acceptance/settlement path: preserve facts without injecting them into a replacement generation or waking a closed Session. + +If the SQL commit response is lost, reconcile by stable submission identity first. A committed batch retains its original `batchId`, sequences, and content; do not allocate new sequences. The same identity with a different digest is a protocol conflict: stop the stream and alert. Keep acceptance receipts and source-cursor deduplication metadata through the promised retry window, independently of short-lived payload cleanup. + +Commit the first visible text immediately. Subsequent text with the same `turnId/itemId/contentPartId/type` may be merged. Candidate bounds are `50–100ms` or `64KiB`, whichever is reached first; determine them through load tests. Flush preceding text before promptly committing terminal, tool-boundary, approval, error, and cancellation events. Do not concatenate those as text. + +Bound in-memory buffers per Session and globally. A tail lost before acceptance can only be recovered if Harness can actually replay its source stream. Source recovery across boots is unproven in the baseline, so the short in-memory window cannot be described as lossless. Database acknowledgement durability also depends on flushing, replication, and failover configuration. + +## 7. Push, Replay, and MQ Consumption + +### 7.1 SSE and Multiple Instances + +On the accepting node, the commit callback pushes full batches. Its Hub shares a bounded buffer for a Session across browser connections. Disconnect slow connections that exceed their limit and require replay; they must neither block Harness nor cancel a Turn. + +For a small multi-instance deployment, use service discovery to notify authenticated Java nodes with coalesced `sessionId + committedSequence` hints. A notified node with local subscribers performs one Session range read and fans out to its local connections. Notifications only wake readers. Periodic, node-level batched checks of active Session watermarks repair lost notifications, at a frequency coordinated with heartbeats. Cross-node range reads remain, but fixed `200ms` polling per browser disappears. + +Node broadcasting grows with node count; load-test and bound the first deployment size, then adopt Session shard routing when needed. Putting all SSE nodes in one MQ consumer group does not cause every node to receive every event. Materialization consumption and notifications to SSE nodes are separate responsibilities. + +### 7.2 Joining History and Live Events + +Register the local subscription and buffer notifications first. Then read a consistent Snapshot and journal high watermark `H`, send data in `(afterSequence, H]`, and drain buffered events with `sequence > H`. Both client and server deduplicate by sequence. Fill gaps before advancing. Protect paged ranges with a short read lease or equivalent so cleanup cannot delete a range halfway through pagination; explicitly retry or reset when protection expires. + +Retain numeric SSE `id` / `Last-Event-ID`, scoped by the server to the authenticated Session. Clients use decimal strings or a safe-integer strategy to avoid precision loss. After capability negotiation, expose `minReplaySequence`, `lastSequence`, and `coveredSequence`. For an expired cursor, return an explicit error before opening the stream; after opening, send a `resync.required` control frame that does not advance the business sequence, then close. + +The client replaces the state covered by a materialized Snapshot, then reads the tail after `coveredSequence`; it must not append the same text twice. A Snapshot binds a consistent Item content version and watermark. All history pages must use that same Snapshot version. Cleanup advances only through a materialized contiguous prefix, leaving a continuous journal suffix after that Snapshot. Do not expire data that older clients still require until they support reset. + +### 7.3 Consumption and Retries + +Use `(tenantId, sessionId)` as the message grouping key. The Relay publishes serially per Session, resending the same `batchId` after uncertain timeouts. A crash between publication acknowledgement and its local marker creates duplicates as part of normal recovery. + +RocketMQ group ordering requires serial sends from a single producer. Across producer handover, the application must still check sequences; MessageGroup does not replace fencing. [RocketMQ ordered messages](https://rocketmq.apache.org/docs/featureBehavior/03fifomessage/) + +Consumers update Item/Snapshot state and their contiguous progress in one database transaction, then ACK. Redelivery does not append duplicate text. Fill gaps from the short-lived journal. If the journal also lacks the data, pause that Session's materialization and alert rather than treating later content as contiguous state. Moving an event to a dead-letter queue after retries must not advance the business watermark by itself. + +Browser replay uses public Session sequences and never resets the materializer consumer group. RocketMQ positions are topic/queue/offset based, and consumption progress belongs to a consumer group rather than a user Session cursor. [RocketMQ consumer progress](https://rocketmq.apache.org/docs/featureBehavior/09consumerprogress/) + +## 8. Schema, Aggregation, and Retention + +| Structure | Purpose and current state | +| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `managed_agent_event_batch` | One row per bounded batch, containing a sequence range and encoded public events. Serves as both Outbox and replay journal, without duplicating the same payload into another SQL Outbox table. | +| `managed_agent_item` and `managed_agent_item_part` | Implemented in V2 for stable message/tool state and text Parts. The P1 implementation updates cumulative Part text per accepted delta; immutable long-output segments remain follow-up work. | +| `managed_agent_snapshot` | Implemented in V2 as a consistent Item document plus `coveredSequence`; richer terminal/control state and pinned history-page versions remain follow-up work. | +| `managed_agent_consumer_progress` | Implemented for the SQL message projection. Publication progress is still future work; Broker ACKs cannot substitute for materialization progress. | +| Additional Session/Turn columns | Lease generation, journal low/high watermarks, storage version, recovery state, and durable resource references. | + +Locate batches by `(tenantId, sessionId, firstSequence)`, including the batch covering the requested starting point, and expand events during application pagination. Long-lived Items can be updated at content-block completion, terminal state, or bounded periodic checkpoints. Do not rewrite a growing full body for every delta. Use immutable segments and manifests for long output to avoid cumulative write amplification. + +Periodic materialization may defer writes, but must not advance consumer progress or ACK while unsaved content remains only in memory. Successful processing requires content (or verified immutable segment references) and contiguous progress to be persisted in the same transaction. A Snapshot's coverage watermark must also reference a readable, consistent content version. + +All cleanup conditions must hold: + +- The batch is older than the public replay window `W` and has no active range-read protection. +- Complete content, control state, and required Snapshots cover the batch through its end. +- All required consumers have finished. When MQ is enabled, distribution and required downstream processing must also be complete. New consumers initialize from Snapshots rather than assuming old batches still exist. +- No investigation, recovery, or resource-reference pin remains. Delete in bounded pages to avoid long-held locks. +- Retention policy is defined and deletion, safety-watermark, and actual resource-owner conditions in Sections 9.3–9.4 hold. `coveredSequence` represents projection coverage, not a cross-store safe-deletion watermark. Do not remove original tables/ranges still needed for control events, Range reads, or receipt queries. + +`W` remains an open parameter, not permanent retention. `24h` can be used for capacity examples but is not a committed product configuration. Passing the window does not imply unconditional deletion. If required consumers fall far behind, throttle, pause new Turns, and alert rather than accumulating indefinitely or silently discarding data. + +RocketMQ cleanup follows retention and space policies, including for unconsumed data; “not ACKed” does not imply permanent retention. SQL journal data repairs gaps while required consumers are behind, and admission must stop before capacity limits are reached. [RocketMQ storage policy](https://rocketmq.apache.org/docs/featureBehavior/11messagestorepolicy/) + +Merging primarily reduces rows, indexes, and repeated envelopes; content bytes do not disappear. Raw throughput is approximately `C × r × s` bytes/second, with uncompressed window capacity `C × r × s × W`, plus SQL/MQ replicas and indexes. For `C=100`, `r=20` events/second, and `s=200` bytes, raw content alone is `34.56GB/day`. A `100ms` window merges about two events on average, not necessarily an order-of-magnitude reduction. If measured SQL batch storage still exceeds budget, adding MQ is not grounds for launch: shorten an agreed window, limit concurrency, or prioritize the source journal work in Section 12. + +## 9. Harness and Runtime Recovery + +Storage and ChatRecordingService currently place local chat files at `projects//chats/.jsonl` under the runtime directory. The root depends on `QWEN_RUNTIME_DIR`, runtime configuration, and user directories. A file on disk does not inherently prevent decoupling; the questions are whether a replacement process can locate and restore it and whether obsolete writers can still commit. + +A complete recovery unit contains the private journal, checkpoints, the transitive closure of referenced resources, Workspace identity/snapshot, file history, and Broker binding/execution records. `LocalProcessRuntimeProvisioner.stopNow` currently deletes the generation directory, so its `outputRoot` is not durable resource storage. Uploading JSONL alone while omitting referenced large results or checkpoint resources is insufficient. + +Proposed recovery order: + +1. Locate the committed manifest/journal revision by tenant and Session, and verify recovery eligibility and current safety watermarks in quarantine. First isolate old Java/Harness/Runtime write authority. Without evidence, enable no ordinary reads, dispatch, automatic tasks, or GC. +2. Reconcile uncertain calls and the missing post-backup interval from original durable ledgers using original `executionCallId` values. Queries must not create Runtime or rerun prepare/execute. Timeouts, process exit, and missing rows in the restored database do not prove absence of side effects. Expand the blocked scope when the unknown interval cannot be enumerated. +3. Verify exact resource versions, digests, lengths, and reference closure; replay deletion/key-revocation constraints and verify actual model consumption positions. Missing resources cause `recovery_blocked`, never an empty Session or all settled results being treated as consumed. +4. Mount the original Workspace or a confirmed snapshot only after occupancy, old-writer stop/isolation, original result/history settlement, and storage-handover proofs pass. Then acquire a new writer generation under the same Session UUID, restore Harness authority, and recover execution handles under the original binding policy; do not transparently rebind an active Runtime Session. +5. Continue only when current ACL, lifecycle, and protocol recovery boundaries permit. Private journal restoration, public SSE replay, and continuation of an in-flight model request are distinct capabilities. + +Publish and verify immutable resources first, then publish the manifest/journal commit point through a conditional commit using expected revision and writer generation. Successful uploads with uncertain commits create only candidate orphans. Before reclamation, the actual owner must establish the original commit by commandId, isolate the old producer, and verify all references/publication holds. Never expire the only copy on timeout. A committed manifest must not reference objects not yet durably published. Beyond an object-storage plugin, an authoritative commit mechanism must reject old generations. Local locks or client-side lease-expiry judgments are insufficient. + +Production recovery requires Harness protocol extensions defining `journalRevision`, resource manifests, and acknowledgement of recoverable boundaries. Track recovery status separately from Turn completion: a successfully generated answer does not prove migratable state. Do not reclaim referenced resources before persistence acknowledgement and execution reconciliation. Preserve the existing boot-mismatch rejection until these checks pass, rather than changing it to unconditional reattachment. + + + +### 9.1 RestoreSet: Consistent Backup Collection + +The initial profile uses a maintenance-barrier backup, not arbitrary hot snapshots. Stop new input, wakeups, dispatch, and reference changes for affected Sessions/shared Workspaces. Drain actual writers and descendants, durably accept results/resources, commit actual model consumption positions and history/checkpoints, then freeze/flush. A collection lacking these proofs cannot be published as automatically recoverable. + +RestoreSet is a versioned immutable cross-store manifest binding a stable set ID, original maintenance operation, scope, schema/reader versions, barrier and freeze evidence, component versions/refs/digests, recovery boundary, and verification status. Publish conditionally through existing repositories; it is not a second Session journal. It associates: + +| Component | Required fixed content | +| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| MySQL | Consistent backup identity, verification data, and binlog/GTID boundary, including product state, original commands, Broker binding/execution records, and Workspace references | +| qwen authority | Complete committed private-journal prefix, checkpoint, pending commands/original calls, and actual consumed model results and positions; public Item/Snapshot alone is insufficient | +| Objects and resources | Exact versions, lengths, digests, complete reference closure, and each owner/hold; not mutable object keys or “latest version” | +| Workspace | PVC/volume snapshot, trusted storage identity, file history/before-and-after images, and corresponding occupancy/freeze proofs | +| Build and keys | Compatible build/Bundle/template/protocol versions and required key-version references; no plaintext keys in the manifest | + +Reconcile lost ACKs between component completion and manifest publication by the original operation; do not create another set or release the barrier early. Release the maintenance barrier under current authorization only after component verification and conditional publication succeed. On failure retain durable progress and converge under the original operation. A successful backup proves only its declared boundary, not future restoration eligibility. + +A shared timestamp is not consistency evidence. [MySQL binlog/PITR](https://dev.mysql.com/doc/refman/8.0/en/point-in-time-recovery-binlog.html) and GTID do not prove external side effects, and [VolumeSnapshot](https://kubernetes.io/blog/2020/12/10/kubernetes-1.20-volume-snapshot-moves-to-ga/) does not automatically provide application consistency. `RestoreBundle` remains a single-Session recovery response using a valid basis within the verified collection; RestoreSet does not replace the authority. See [storage contracts](managed-agent-session-storage.md#restore-set) for closed records/references, minimumReader, and capability rules. + + + +### 9.2 Quarantined Restore and Missing Intervals + +Restoration starts quarantined: no dispatch, automatic wakeups, or GC. Ordinary reads also require the complete current safety evidence in Section 9.3. Isolate old Java/Harness storage-write rights separately from old Runtime physical write channels; a new DB epoch or credential version does not prove an old Shell stopped. Original-node/storage evidence, RWOP, and same-volume handover follow [Endpoint Section 17](2026-09-21-managed-runtime-endpoint-recovery.md#hosted-runtime-profile). + +Enumerate execution gaps from the backup boundary through completion of recovery isolation using original durable ledgers, protected backups/audit data, and original-owner evidence that survive that backup's loss interval. No command/dispatch row in the restored database does not prove non-execution. Continue only when evidence covers the complete interval, original effects are settled, and results/history/consumption positions agree. If the interval cannot be enumerated, block all potentially affected Sessions/Workspaces rather than treating a known subset as complete. Original-execution queries do not provision/prepare/execute. + +Replay deletion/key-revocation facts and restore only still-eligible resources. If the real ACL authority is unavailable or may have rolled back, grant no new rights and open no ordinary reads; technical restoration does not restore a user's old permissions. `accepted_unresolved` closes only the business case: physical UNKNOWN persists, without tool success, volume release, or continuation of the original model. New tasks cannot bypass the original volume barrier either; see [reconciliation](managed-agent-recovery-operations.md#unknown-reconciliation) and [revocation](managed-agent-control-protocol.md#dynamic-authorization). + + + +### 9.3 Complete Safety Watermarks Outside the Backup + +Full DR requires recovery eligibility, deletion tombstones, key-revocation records, and verifiably complete watermarks that cannot roll back with the business database. Prefer existing protected backup/audit storage containing only these narrow facts, without duplicating Session journals or adding a service or MQ. Its protection domain must not revert with the business backup; a “latest watermark” inside the same SQL backup is invalid. + +The interface must return the target recovery scope, current eligibility/deletion/key-revocation facts, complete watermark, corresponding records, and evidence of integrity and freshness. The verifier must establish no omitted tail or rollback and coverage of all relevant scopes. Signatures, hashes, backup timestamps, and a client's cached maximum sequence do not themselves establish currentness. A deployment may reuse verified current-and-complete reads from protected storage. An adapter unable to provide this proof lacks full DR capability; it cannot temporarily trust the restored database's own claim. + +In full-DR deployments, first idempotently preregister deletion/revocation intents that reduce recovery eligibility in protected evidence under the original operation, then advance the business-database operation, and finally record verified settlement references. Pending intents also block restoration of their scopes. Reconcile every lost ACK by the original ID; business-database rollback cannot erase an intent. Protected storage must not claim a complete watermark while intents remain unresolved or source changes are not covered. This sequence reuses durable stages of the original operation without claiming cross-store atomicity or copying business journals. + +Do not miss concurrent deletion/revocation during restore verification. Recheck current authority and safety watermarks before opening reads or issuing grants, then keep subsequent reads/renewals subject to current permissions. Cross-system reads are not a distributed atomic transaction; remain quarantined without a valid admission barrier. Without evidence, ordinary SSE/history/Range/export/receipt content stays blocked. Only separately authorized isolated diagnostics may access necessary material, without model wakeups or ordinary export. + +Deletion/revocation evidence must cover every backup or replica still capable of restoring the affected old data; short command-idempotency TTLs cannot remove it. This is a G/W1 full-DR enablement requirement, not an obligation to add infrastructure for the initial C/E + W0 online loop. Without it, report that a backup exists or restoration is unverified, never safe automatic recovery. + + + +### 9.4 Compliance Deletion and Replica Completion + +Reuse original Session close/delete, operations, resource owners, and durable cleanup progress. Durably accept the deletion request and fence new input/dispatch/ordinary reads; ACK proves neither termination nor deletion. Preserve `idle/active/recovery_blocked → closing → closed`, followed by `closed/archived → deleting → deleted`, with no active-to-deleted shortcut. An unknown original physical owner prevents premature closed state. Original-ID queries/cancellation, trusted late receipt acceptance, and cleanup retain narrow system rights, without waking the model or restoring revoked user reads. + +After trusted termination of original work, reconcile result delivery, readers, pins, fork/export, publication holds, and investigation/legal retention. Durably deregister references and record tombstones under the original operation before actual owners conditionally clean exact versions/identities. Reconcile cross-owner lost ACKs through original references/commits; timeout does not prove release. Unknown references/executions remain pending/blocked without deleting sole evidence. A Session deletion releases only its own references, not a shared Workspace/PVC. Whole-Workspace/tenant deletion requires separate authority and all-reference checks. + +Expose completion through a controlled versioned public projection, with original operation, scope, revision, evidence references, and outstanding reasons per dimension. Do not redefine existing `deleted`: + +| Dimension | Evidence required for the reported state | +| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| Online read revocation | Ordinary new requests and subsequent content delivery on existing streams are blocked; delivered bytes cannot be recalled | +| Execution stopped | Original trusted supervision proves actual exit within the declared scope; report node/storage isolation separately, not as process exit | +| Online cleanup | The current owner's deletable online copies and references are settled and verified, preserving other owners' legitimate references | +| Backup cleanup pending | Enumerate remaining SQL backups, object versions, snapshots, exports, and retention periods; this is not complete deletion | +| Hold/vendor confirmation pending | Record scope, expiry, and follow-up for legal retention or unconfirmed vendor deletion | +| Fully complete | Every copy due for deletion within the declared scope is evidenced, with no outstanding hold/external confirmation or decryptable restoration path | + +Replay deletion constraints before any disaster-recovery reads open. An [OSS delete marker](https://www.alibabacloud.com/help/en/oss/developer-reference/deleteobject) does not erase historical versions. Delivered user exports cannot technically be recalled; do not claim their cleanup without evidence. Cryptographic erasure requires every relevant decryptable copy and recoverable key path to be irrecoverable. Never destroy another owner's shared key or promise both complete erasure and complete restoration. + +RPO/RTO, retention windows, the specific CSI driver, backup deletion, and vendor contracts remain undecided; do not guess values. Unconfigured/unvalidated automatic recovery or GC stays disabled. UNKNOWN is not a legal basis for indefinite sensitive-data retention: separate retention policy and authorized compliance handling are required. Business risk acceptance also does not fabricate physical settlement. See [Session storage](managed-agent-session-storage.md#deletion-settlement) for detailed lifecycle/reference rules. + +## 10. Backend Selection and Deployment Configuration + +| Component | Initial choice and replacement boundary | +| ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Relational database | Retain MySQL initially to reduce migration variables. Deployments with an existing PostgreSQL platform may select its adapter. Changing database type alone does not solve the current problem. | +| Event distribution | Prefer an existing RocketMQ platform for multiple consumers and backlog recovery; otherwise start with a SQL scanner. Redis Streams is a candidate for short windows, subject to validation of persistence, pending-message recovery, and capacity. | +| Browser replay | Use the SQL batch journal uniformly in the first stage. Selecting Redis as the transport does not require another copy of replay storage. | +| Large objects and Session resources | Use existing object storage or persistent storage satisfying writer constraints. A local development implementation is not evidence of cross-node recovery. | + +Select backends at deployment startup through dependency injection and vendor-specific migration directories. The following is proposed configuration, not existing configuration: + +```yaml +qwen: + managed-agent: + storage: + backend: mysql # mysql | postgresql + event-transport: + backend: rocketmq # rocketmq | redis-streams | sql + artifacts: + backend: local # development only; production uses a durable provider +``` + +`sql` disables external EventTransport and uses a batch scanner for materialization. In-memory implementations are only for development/tests. At startup, verify that selected adapters satisfy required acknowledgement, consumer recovery, and resource-verification capabilities; reject unsupported modes. Inject credentials through deployment configuration, never public events or logs. + +Do not switch MySQL/PG or MQ arbitrarily per request. Switching requires pausing writes, draining or checkpointing, migrating data, comparing sequences and digests, then switching and resuming. Running tools twice or dual-writing databases is not a lossless migration strategy. + +## 11. Failure Semantics + +| Failure point | Handling and guarantee boundary | +| ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | +| Java crashes before batch commit | Resume from the last durable source cursor. If the source cannot replay, report a gap/recovery block; the uncommitted tail is not guaranteed. | +| Commit succeeds before SSE push | Replay from the journal; browsers deduplicate with original sequences. | +| MQ publication succeeds before Relay marking | Republish the same batch; materialization transactions deduplicate without appending text twice. | +| SSE notification loss or node crash | Repair through node watermark reconciliation or reconnect replay. Browser lifetime does not determine Turn lifetime. | +| SQL temporarily rejects writes | Bounded buffering and backpressure. Explicitly fail if the source cannot reliably pause/replay; do not claim continued acceptance. | +| MQ unavailable | SQL still supports display of accepted output. Bound Outbox backlog and stop new admission at the threshold. | +| Consumer reordering, dead letters, or missing segments | Check contiguous progress and repair gaps. Pause the Session projection when repair is impossible; do not advance its Snapshot watermark. | +| Old Java/Harness returns after lease handover | Both database acceptance and private journal commits validate generations. MQ ordering does not provide this protection. | +| Unknown tool outcome or missing Workspace | Enter `recovery_blocked`; neither execute automatically a second time nor substitute an empty workspace for recovery. | + +Retain tenant authorization for all queries, replay, resource downloads, and internal notifications. `X-Qwen-Tenant-Id` conveys scope from a trusted entry point, not identity credentials. Internal MQ metadata and private Harness records must not pass directly to the frontend. Preserve explicit public projection and sensitive-field filtering. Hosted checks current ACL on existing SSE streams and each subsequent content-delivery boundary under the [dynamic authorization contract](managed-agent-control-protocol.md#dynamic-authorization); connection-time permission is not permanent authorization. Internal original-ID receipt acceptance may continue without restoring user reads or model progress. + +## 12. Rollout and Code Changes + +| Phase | Work and exit condition | +| ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| P0: Contracts and baseline | **Implemented foundation.** `AgentStateStore`, bounded ingress batching, idempotent admission, and post-commit Hub delivery are present. H2 tests and a real-MySQL integration test cover the current contract; performance baselines and PostgreSQL parity remain open. | +| P1: Batching and direct push | **Implemented SQL-backed slice.** Stable Item/Part identity, V2 projection tables, transactional contiguous progress/Snapshot materialization, a public Item list with its Snapshot watermark, and WebShell Snapshot-plus-tail hydration are present. Long-output write amplification, snapshot pagination pins, and production fault/performance evidence remain open. | +| P2: Transport and retention | Add the selected MQ adapter, Outbox Relay, multi-instance notifications, and replay reset. Enable window cleanup only after materialization reconciliation passes. Integrate an existing RocketMQ platform here. | +| P3: Durable recovery | Persist the three Broker Repositories. Extend Harness authority and resource manifests, Workspace restoration, and reclamation barriers. Enable automatic takeover only after cross-JVM/Harness/Runtime failure tests pass. | +| P4: Change acceptance only when measurements require it | If SQL acceptance throughput or SQL fault isolation is insufficient, first give Harness/ingress an independent durable source journal, single-writer fencing, stable event identities, and replay acknowledgement. Then design direct push after MQ acceptance with asynchronous SQL materialization. Redefine the common ordering of control events/text and public cursors; replacing the `acceptBatch` implementation alone is insufficient. | + +P4 is an explicit future design gate, not an existing interface capability. Do not introduce multiple execution modes, a generic query DSL, automatic MQ switching, or another Java Agent loop now. + +Migrate through additive tables and per-Session `storageVersion`. Existing Sessions temporarily keep their old event path, while new Sessions gradually adopt the new path. Where historical backfill lacks stable Item identity, preserve the old Transcript rather than guessing and deleting originals. Older clients keep a retained path; only capable clients can enter a mode with window cleanup. + +Canary rollout starts with read-only projection comparisons, never duplicate Prompt/tool dispatch. For rollback, stop admission to the new mode and retain compatible reads. Existing new-mode Sessions continue on a compatible service or explicitly pause. After cleanup is enabled, the old binary cannot reconstruct history from deleted deltas, so binary rollback alone is insufficient. + +## 13. Acceptance and Open Parameters + +Validate against the actual deployed MySQL, PostgreSQL, and MQ versions. Passing H2 or in-memory adapters is not a substitute: + +- Two JVMs submit the same idempotency key and compete for a Turn/lease: only one admission succeeds, and stale generations cannot advance source cursors, events, or state. +- Inject crashes before/after commits, publication acknowledgements, and projection transactions. Reconcile events, complete text, terminal state, and consumer progress without silent gaps or duplicate text. +- Combine Java node changes, lost notifications, slow consumers, reconnects, and Snapshot pagination. Verify the transition from buffered live data to replay, cursor expiry, and exclusion against cleanup. +- Preserve boundaries across multi-Part text, interleaved tools/approvals, cancellation, and error termination; verify that public projections exclude private content. +- During cleanup, simulate lagging required consumers, early Broker cleanup, and resource pins. Throttle before capacity limits and repair gaps using retained journal data. +- Restore the same Workspace and Session after deleting Runtime processes and temporary generation directories. Explicitly block missing resources or unknown tool outcomes without executing twice. +- Run the same transaction/idempotency/CAS tests for database adapters and the same lost-acknowledgement, duplicate, reordering, and recovery tests for MQ adapters. Run corresponding builds, type checks, and E2E when integrating source changes. + +Supplement acceptance (not executed): + +| Case | Required success | Required rejection or blocking | +| --------------------------------------------- | ------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| Consistent backup and lost ACK | Frozen closure, exact versions, and original operation converge on one RestoreSet | Timestamp/GTID/VolumeSnapshot alone cannot establish consistent restoration | +| DB rollback before dispatch | Complete gap evidence and original receipts allow legitimate recovery | Missing rows never replay effects; absent old-writer isolation or an unenumerable gap expands blocked scope | +| DB rollback before deletion/revocation | Current complete safety watermarks replay deletion/revocation and expose only eligible data | Stale signatures/watermarks or missing authority block ordinary reads too | +| Restore concurrent with deletion | Opening and subsequent reads continuously follow current authority | One restore verification never exempts later revocation/deletion | +| Delete with readers/pins/forks/shared volumes | Drain and reference deregistration recover correctly while independent references remain readable | Never delete shared PVCs, publication holds, or sole UNKNOWN evidence | +| Replicas/keys/vendors | All in-scope copies and decryptable paths are settled before full completion | Delete markers, partial key erasure, or unconfirmed external deletion cannot report full completion | + +Every case requires success and rejection paths; blocking everything does not pass. Validate real MySQL/Kubernetes/CSI/object-version and vendor semantics independently; H2/fake APIs are insufficient. Supplemental G/W1/O4 enablement gates are distinct from this document's original P0–P4 numbering; internal safety does not wait for D public projections. + +Performance comparisons record first-text and inter-chunk latency p50/p95/p99, transactions/second per Session, actual SQL bytes and index size, replay throughput, materialization lag, MQ backlog, node/SSE-connection buffers, and resource restoration time. This document reports no performance experiment or fixed speedup. + +Before production, determine the existing RocketMQ platform/version, active Session count and event rate, replay window `W`, maximum tolerable backlog, failure guarantees of database/MQ acknowledgements, history/resource retention, and allowed recovery point/time objectives. Interface boundaries and rollout planning do not require these values immediately; production capacity, cleanup, and cross-node recovery configuration do. + +Related designs in the integration snapshot: `docs/design/2026-09-19-managed-agent-spring-server.zh-CN.md`, `docs/design/2026-09-20-managed-agent-dual-path-web-shell.zh-CN.md`, `docs/design/managed-agent-session-storage.md`, and `docs/design/managed-agent-session-harness-runtime.md`. This document adds storage and event boundaries without treating prior design documents as implementation validation. diff --git a/docs/design/2026-09-20-managed-agent-storage-event-architecture.zh-CN.md b/docs/design/2026-09-20-managed-agent-storage-event-architecture.zh-CN.md new file mode 100644 index 00000000000..0938dc34870 --- /dev/null +++ b/docs/design/2026-09-20-managed-agent-storage-event-architecture.zh-CN.md @@ -0,0 +1,383 @@ +# Managed Agent 存储、事件与会话恢复设计 + +[English](2026-09-20-managed-agent-storage-event-architecture.md) | [简体中文](2026-09-20-managed-agent-storage-event-architecture.zh-CN.md) + +> PR #12692 范围修正(2026-09-25):下文的实现与验证记录来自完整集成预览,不是本次拆分的验收证据。当前能力、修复与未完成门禁以[评审修正](2026-09-25-managed-agent-review-corrections.zh-CN.md)为准。 + +状态:总体架构仍为提议;本分支已实现 P0 和基于 SQL 的 P1 物化切片。日期:2026-09-20。本文按下面的集成代码快照设计,不表示完整架构已经通过生产验收。 + +补充状态:§9.1~9.4 的灾备与合规删除是待实现、未验收设计。本轮只修改文档,不修改代码/schema/部署或运行产品测试。补充设计源固定为 [code_agent@1478e7b](https://github.com/doudouOUC/code_agent/tree/1478e7b632eb237bc3f40ea574ce90782e1cf4c3/qwen-code/feature/managed-agents),draft 集成参考为 `bad721f22fcd8cfad9ec22e98f69fec75b20b6f0`,本地核对基线为 `f5088d2e`;不整体覆盖旧设计,也不把这些快照视作同一实现。第5项配额/计费排除;租户信任与强隔离选型暂缓,不默认已具备生产多租户安全。 + +## 1. 决策 + +保留 WebShell → Java 控制面 → Hosted Harness → Runtime Broker → Tool-only Runtime 的分工。Harness 运行 Qwen Agent 循环,Java 负责准入、状态、事件投影和客户端接口,Runtime 负责工具与工作区。模型推理与 Runtime 预热仍并行,实际调用工具时才等待 Runtime。 + +按职责抽接口,首个实现沿用 MySQL;PostgreSQL 是同一业务契约的另一个实现。有现成 RocketMQ 平台时优先接入它做可靠异步分发;没有时先用 SQL 批次扫描驱动物化,不为 SSE 额外部署 MQ。Redis Streams 保留为可选适配器,不和 RocketMQ 同时成为默认依赖。 + +第一阶段采用**短期 SQL 批次日志兼 Outbox → 提交后立即 SSE 推送 → 异步分发与消息物化**。这是对前面“直接写 MQ,再异步入库”的收敛:现有代码把 Session 序号、Turn 状态和 Harness 游标放在一个数据库事务里,直接拆开会引入双写缺口。保留一个有限的事务边界,先消除永久逐片段存储和每连接轮询。 + +代价也明确:SQL 仍处于事件接受路径;MQ 故障可以缓冲,SQL 故障仍会阻塞新事件接受。这不是“数据库故障时继续无限接收输出”的方案。若这项能力是硬要求,应优先完成第 12 节的持久化源日志方案,再评估 MQ 优先链路。 + +本文随下述首批表结构和 SDK 集成一同更新,但不承诺任意模型 token 位置恢复、工具恰好执行一次、数据库在线热切换或所有 MQ 功能完全等价。 + +当前实现切片已经加入 `AgentStateStore`、有界 Harness 事件批处理、稳定 Item/Part 身份、游标/序号/终态单事务更新,以及提交后的本机 SSE 直推与持久化补发。Flyway V2 新增 Item、Item Part、Snapshot 和消费进度表;SQL 扫描器按连续事件前缀在同一事务中更新物化内容、Snapshot 与进度,WebShell 读取一致 Snapshot、控制事件和未物化尾部。为保持兼容,目前每个公开事件仍保留一条 SQL 记录。批次日志/Outbox、保留清理、外部 EventTransport、PostgreSQL 适配器、多实例唤醒和 Harness/Runtime 持久恢复仍是后续工作。 + +## 2. 核实的代码基线 + +集成基线为分支 `feature/managed-agents-p0-p8`、提交 `fc32ab0c9502a0b44020ef1a66c88e9b3a2a1484`。上面说明的 P0/P1 切片已在该基线之后提交到同一分支。下列路径均指这一分支快照。 + +Java 服务源码根目录为 `packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/`。 + +| 位置 | 当前行为及设计影响 | +| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| `store/ManagedAgentStore.java` | 具体的 JDBC 类;事务同时维护命令幂等、Turn、源游标和公开事件,不能拆成互不相关的 CRUD 接口。 | +| `service/HarnessCoordinator.java` | 消费 Harness SSE;按 `bootId:eventEpoch:eventId` 去重并提交有界源事件批次;还有 Java 产生的 Runtime 状态事件。 | +| `service/HarnessEventProjector.java` | 文本与工具投影已有确定性的 Item/Part 身份,并继续使用明确的公开字段白名单。 | +| `service/ManagedEventStreamService.java` | 实时路径使用提交后本机通知,缺口与重连通过周期 SQL 核对;浏览器不直接访问数据库。 | +| `service/ManagedAgentService.java` | Transcript 读取最新物化 Snapshot、保留的控制事件和 `coveredSequence` 后的尾部;Snapshot 尚未产生时兼容旧事件分页。 | +| `harness/HarnessConnector.java` | 已有连接器接口,可复用;当前包含提交、SSE、取消和启动代际信息。 | +| `src/main/resources/db/migration/V1__managed_agent_core.sql` 与 `V2__managed_agent_projection.sql` | V1 包含 Session、Turn、Command、Event;V2 增加 Item、Item Part、Snapshot 和投影进度,批次日志/Outbox 尚未实现。 | +| `packages/sdk-java/runtime-broker/` | 已有三个 Repository 接口;Embedded Broker 默认构造仍使用内存实现。 | +| `packages/core/src/managed-runtime/managed-session-assembly.ts` | 直接组装本地 Session authority、资源存储和写者租约,尚非可替换的远端持久化实现。 | + +最新代码已统一公开 Java Session、Harness、JSONL 与 Broker 使用的 Session UUID。`harnessSessionId` 仅为协议别名,不再设计第二套身份映射。租户隔离仍使用 `(tenantId, sessionId)`;`turnId` 与稳定的 `promptId` 各有用途。 + +普通投影事件会产生多次 UPDATE 和一次 INSERT;被过滤的源事件也要更新恢复游标。一个 chunk 不等于一个 token。当前基线未包含本文的合并、过期清理和聚合存储机制。 + +## 3. 数据分别由谁负责 + +| 数据 | 权威与存储 | 保留方式 | +| ------------------------------------------------- | ------------------------------------- | ------------------------------------------------ | +| Session、Turn、Command、租约、幂等与权限范围 | Java 关系数据库 | 业务保留期;幂等记录不能早于承诺的重试窗口删除。 | +| 已接受的公开事件 | 短期 SQL 批次日志;MQ 为分发副本 | 续传窗口及必要消费者进度共同决定清理。 | +| 展示用 Message/Item、工具卡片、Snapshot | 从公开事件与已接受输入构建的 SQL 投影 | 长期保存完整内容;大对象引用对象存储。 | +| 模型上下文、私有 Session 记录、检查点及其资源引用 | Harness Session authority | 独立持久化;不是公开 Message 表的反向重建结果。 | +| Runtime binding、工具执行账本 | Broker 的持久化 Repository | 至少覆盖执行核对及恢复期限。 | +| Workspace、工具大结果、文件历史与恢复资源 | 持久卷或对象存储及引用清单 | 仍被 Session/执行引用的资源不得随 Runtime 回收。 | + +SSE 是传输协议;MQ 是事件分发组件;关系数据库是业务状态及查询存储。三者都不自动替代 Harness Session 的恢复协议。 + +## 4. 第一阶段端到端链路 + +```mermaid +flowchart TD + UI[WebShell] -->|Prompt / cancel| API[Java API] + API -->|command transaction| DB[(Agent SQL Store)] + API --> HC[HarnessCoordinator] + HC --> H[Hosted Harness] + H -->|source SSE| ING[Projector + bounded batcher] + ING -->|acceptBatch transaction| DB + DB -->|committed events| HUB[SessionEventHub] + HUB -->|SSE| UI + DB --> RELAY[Outbox relay] + RELAY --> MQ[EventTransport: RocketMQ or Redis Streams] + MQ --> MAT[Message materializer] + DB -. SQL-only deployment .-> MAT + MAT -->|items + snapshot + checkpoint transaction| DB + UI -->|reconnect / history| REPLAY[SessionReplayService] + REPLAY --> DB + H --> BROKER[Runtime Broker] + BROKER --> RT[Tool-only Runtime] + H --> ART[Session journal + durable resources] + RT --> WS[Durable workspace / result artifacts] +``` + +1. Java 在一个事务里保存 Prompt 的命令幂等、Turn、输入及控制事件,返回已接受的 Turn 身份。 +2. Coordinator 取得带代际的租约,按稳定 `promptId` 提交 Harness,同时预热 Runtime。提交响应丢失时核对原提交,不创建新 Prompt 重跑。 +3. Java 解析 Harness SSE,投影、去重并组成有大小上限的批次。`acceptBatch` 提交成功后,本节点直接把返回的事件发给 SSE Hub,无须再次读取数据库或等待 MQ。 +4. Relay 读取同一批次日志并发往配置的 EventTransport;消费者聚合 Message/Item。无 MQ 部署由数据库批次扫描器执行相同的物化事务。 +5. 浏览器重连由 Java 按 Session 序号补齐;历史查询读取完整 Item 与 Snapshot。 + +用户输入不是模型 delta。物化器从同事务保存的不可变 Turn 输入构建用户 Item;`turn.accepted` 需补稳定的输入 Item 引用及修订信息。当前事件只有 `turnId`,不能假设现有公开事件自身已经包含完整对话。输入与投影的身份、摘要及所属序号必须固定,Snapshot 只包含其覆盖序号内的版本。 + +“同时推送与入库”是**先确认可靠接受,再并行进行展示和后续处理**。不能只开两个异步任务分别发送 SSE、写 MQ,然后把它们视作一个可靠操作。Outbox 将本地状态提交和待发送内容置于同一事务,但 Relay 重试仍可能重复发布,消费者必须幂等。[Transactional outbox](https://microservices.io/patterns/data/transactional-outbox.html) + +## 5. 接口边界 + +下面是目标职责草图,不要求每张表都建立接口。已提交的 `AgentStateStore` 覆盖 P0/P1 子集,并不等同于完整目标接口。 + +```java +interface AgentStateStore { + CommandResult acceptCommand(Command command); + Lease claimTurn(TurnKey turn, Duration duration); + AcceptedBatch acceptBatch(Lease lease, IngressBatch batch); + void applyProjection(ProjectionMutation mutation); + ReplayPage readReplay(SessionKey session, long after, int limit); + TranscriptSnapshot readSnapshot(SessionKey session); +} + +interface EventTransport { + CompletionStage publish(CommittedBatch batch); + Subscription consume(ConsumerSpec consumer, BatchHandler handler); +} + +interface SessionArtifactStore { + ArtifactRef putImmutable(ArtifactScope scope, InputStream content); + InputStream readVerified(ArtifactScope scope, ArtifactRef ref); +} +``` + +| 边界 | 契约 | +| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `AgentStateStore` | 业务事务、范围隔离、租约代际、游标及序号分配、幂等投影。命令及其事件、批次及其源游标必须由一个后端的同一事务提交。 | +| `EventTransport` | 已提交批次的至少一次分发、显式处理成功/失败、发布确认和健康状态。业务 `eventId` 不依赖 Broker message ID;客户端从不使用 Broker offset。 | +| `SessionReplayService` | 应用服务,组合 Snapshot、短期范围读取和订阅;不是“所有 MQ 都支持按 Session seek”的适配器。 | +| `SessionArtifactStore` | 不可变字节、大小/摘要校验、租户及 Session 范围。对象存储不负责租约、日志提交或工具去重。 | +| Harness Session authority | 在现有 authority 边界上扩展可恢复的日志提交与 fencing;继续只有 Harness 写私有 Session,Java 不成为第二写者。 | + +`MySqlAgentStateStore`、`PostgresAgentStateStore` 可以共享行映射与业务类型,使用各自 SQL 和 Flyway 迁移。数据库异常在事务退出后转换成统一的冲突结果;不能在 PostgreSQL 已失败的事务里捕获唯一键异常后继续查询。统一锁顺序、事务隔离约定、数据库时间租约、字符串大小写语义、JSON 编码、分页和重试范围,不能只换 JDBC URL。[PostgreSQL transaction isolation](https://www.postgresql.org/docs/current/transaction-iso.html) + +Broker 复用 `RuntimeBindingRepository`、`RuntimeSessionRepository`、`ToolExecutionRepository`,为其增加 MySQL/PostgreSQL 实现。不要另造一套 Broker 存储 API;涉及多个仓库的不变量仍需由其业务操作明确事务或 CAS 边界。 + +EventTransport 的通用保证保持较小:允许重复和跨重连乱序,应用按已提交序号检查、去重及补洞。适配器可以增强顺序和吞吐,不能静默降低确认或恢复语义。第一批只实现确实要部署的后端与共同契约测试,不预先实现全部 MQ。 + +## 6. 事件协议与接受事务 + +事件至少携带 `schemaVersion`、`projectionVersion`、`tenantId`(内部)、`sessionId`、`turnId`、`sequence`、`eventId`、`type`、`data`、`createdAt`。文本还需要稳定的 `itemId` 和 `contentPartId`。身份来自 Harness 协议扩展,不能依赖 Java 内存里的临时计数器。 + +稳定 Item/Part 身份与 Snapshot 重置分别进行能力协商。Harness 未提供前者时保留旧投影路径;客户端未支持后者时保留旧续传路径。不把缺少身份的片段猜测合并,也不提前对不兼容的会话启用清理。 + +内部批次记录 `batchId`、`firstSequence`、`lastSequence`、`producerGeneration`、源 `bootId/eventEpoch` 与已接受的源游标。公开序号是本 Session 的提交顺序,源游标用于 Harness 恢复,二者不可混用。重放保留最初提交的投影版本、序号和内容,不重新运行最新版投影器。 + +`acceptBatch` 必须在一个事务内完成: + +1. 按统一顺序锁 Session 与 Turn,验证租户、活动 Turn、数据库时间租约、单调递增的 owner generation 及源 epoch。过期写者不能提交,即使它还活着。 +2. 丢弃当前 epoch 下已提交源游标以前的输入,再进行合并。IngressBatch 在提交前保留源事件边界,以便处理重试批次与已接受前缀重叠;不同 epoch 不能用数字大小比较。 +3. 为新公开事件分配连续 `sequence`,保存不可变批次,推进源游标和 Session 水位;终态与其终态事件同事务更新。全部被过滤时只提交游标检查点,不产生空公开事件。 +4. 事务提交后才向 Hub 发布;命令产生的控制事件使用同一序号分配机制。Runtime 异步回调还需验证对应操作的 generation/状态,拒绝失效操作推进当前状态。原调用的可信迟到回执走独立受限收件/结算路径,保留事实,不注入 replacement generation 或唤醒已关闭 Session。 + +SQL 提交响应丢失时,先按稳定提交身份核对结果;已提交的批次复用原 `batchId`、序号和内容,不重新分配序号。相同身份携带不同摘要属于协议冲突,应停止该流并报警。接受凭据和源游标去重元数据至少保留到承诺的重试窗口结束,不能随短期正文一起提前删除。 + +第一段可见文本立即提交;后续同一 `turnId/itemId/contentPartId/type` 的文本允许合并。候选参数为 `50–100ms` 或 `64KiB` 达任一上限即提交,需压测确定。终态、工具边界、审批、错误和取消先刷新前序文本,再及时提交;不能把它们按文本拼接。 + +内存缓冲有单 Session 与全局上限。接受前崩溃的尾部只在 Harness 源流确实还能回放时可重取;当前源流跨 boot 恢复未经证明,不能把短期内存窗口说成绝对不丢。数据库确认的可靠性同样取决于刷盘、副本及故障切换配置。 + +## 7. 推送、续传与 MQ 消费 + +### 7.1 SSE 与多实例 + +同节点由提交回调推送完整批次。每个节点的 Hub 为同一 Session 共享有界缓冲,再分发给多个浏览器连接;慢连接超限就断开并要求续传,不阻塞 Harness,也不取消 Turn。 + +小规模多实例部署使用服务发现中的已认证 Java 节点发送合并的 `sessionId + committedSequence` 通知。收到通知且有本地订阅的节点,按 Session 范围读取一次,再发给全部本地连接。通知只作唤醒提示;按节点批量检查活跃 Session 水位作为丢通知修复路径,检查频率与心跳协调。这仍有跨节点范围读取,但消除了每个浏览器固定 `200ms` 轮询。 + +节点广播随节点数放大,必须压测并限定首版规模;规模扩大后再采用 Session 分片路由。不能把多个 SSE 节点放进一个 MQ 消费组,就假设每个节点都会收到相同事件。MQ 物化消费与 SSE 节点通知是不同的职责。 + +### 7.2 无缝衔接历史与实时 + +先注册本地订阅并缓冲通知,再读取一致的 Snapshot 和日志高水位 `H`,发送 `(afterSequence, H]` 内的数据,然后排空缓冲中 `sequence > H` 的部分。客户端和服务端均按序号去重;遇到缺口先补齐,不能直接跳过。范围分页需短期读取租约或等效保护,避免清理任务在分页中间删掉数据;保护到期时显式重试或重置。 + +保留现有 SSE 的数字 `id` / `Last-Event-ID`,由服务端固定到已鉴权的 Session;前端使用十进制字符串或安全整数策略,避免大序号精度丢失。新增能力协商后返回 `minReplaySequence`、`lastSequence`、`coveredSequence`。超出保留窗口时,流建立前返回明确的游标过期错误;流建立后发送不推进业务序号的 `resync.required` 控制帧并关闭。 + +前端拿到物化 Snapshot,整体替换其覆盖的状态,再从 `coveredSequence` 之后读取尾部,不能把同一段文本追加两次。Snapshot 必须绑定一致版本的 Item 内容及水位;若历史分页,所有页面固定在同一 Snapshot 版本。清理仅能推进到已物化的连续前缀,保证该 Snapshot 之后仍有连续日志。旧客户端未具备重置能力前,不能对它依赖的数据启用过期删除。 + +### 7.3 消费和重试 + +以 `(tenantId, sessionId)` 作为消息分组键,Relay 按 Session 串行发布;超时不确定时重发同一 `batchId`。发布确认与本地标记之间崩溃会重复发送,这是正常恢复路径。 + +RocketMQ 的组内顺序需要单生产者串行发送;跨生产者接管仍需应用检查序号,MessageGroup 不代替 fencing。[RocketMQ ordered messages](https://rocketmq.apache.org/docs/featureBehavior/03fifomessage/) + +消费者在一个数据库事务里更新 Item/Snapshot 和自己的连续进度,然后 ACK;重投不重复追加文本。缺口从短期日志补齐;日志也缺失时暂停该 Session 的物化并报警,不把后续内容当作连续状态。消费者不能仅因超过重试次数进入死信就推进业务水位。 + +浏览器续传使用 Session 的公开序号,不重置物化消费者组。RocketMQ 的消息位置由 topic/queue/offset 描述,消费者进度属于消费组,不是用户会话游标。[RocketMQ consumer progress](https://rocketmq.apache.org/docs/featureBehavior/09consumerprogress/) + +## 8. 表结构、聚合与保留 + +| 结构 | 作用与当前状态 | +| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| `managed_agent_event_batch` | 单行保存一个有界批次、序号区间和编码后的公开事件;兼作 Outbox 与续传日志,不再额外复制一张相同的 SQL Outbox。 | +| `managed_agent_item` 与 `managed_agent_item_part` | V2 已实现稳定消息/工具状态与文本 Part。P1 当前按已接受 delta 更新累计正文,不可变长输出分段仍是后续工作。 | +| `managed_agent_snapshot` | V2 已实现一致 Item 文档与 `coveredSequence`;更完整的终态/控制状态和固定历史分页版本仍是后续工作。 | +| `managed_agent_consumer_progress` | SQL 消息投影进度已实现;发布进度仍是后续工作,不能用 Broker ACK 代替物化进度。 | +| Session/Turn 增量列 | 租约 generation、日志最低/最高水位、存储版本、恢复状态和持久化资源引用。 | + +批次按 `(tenantId, sessionId, firstSequence)` 定位,并支持查找覆盖请求起点的批次;分页在应用层展开事件。长期 Item 可以在内容块结束、终态或有上限的周期检查点更新;不要每个 delta 都重写累计增长的整段正文。长输出使用不可变分段及清单,避免累计写放大。 + +周期物化可以延后写入,但不能先推进消费进度或 ACK,再把尚未保存的正文留在内存里。处理成功必须对应同事务持久化的内容(或已验证的不可变分段引用)与连续进度;Snapshot 的覆盖水位还必须对应可读取的一致内容版本。 + +清理条件必须全部成立: + +- 批次超过公开续传窗口 `W`,且没有有效范围读取保护。 +- 完整内容、控制状态及所需 Snapshot 已覆盖到该批次末尾。 +- 所有必要消费者都已完成;启用 MQ 时也已完成分发及必要下游处理。新增消费者先从 Snapshot 初始化,不能假设旧批次仍在。 +- 不存在调查、恢复或资源引用 pin;按有界分页删除,避免长期持锁。 +- 已明确保留策略,并满足 §9.3~9.4 的删除、安全水位与真实资源 owner 条件。`coveredSequence` 只表示投影覆盖,不是跨存储安全删除水位;控制事件、Range、回执查询仍依赖原表时不能清掉它们。 + +`W` 是待确认参数,不预设永久保存。可用 `24h` 作为容量测算示例,但它不是已经承诺的产品配置。超过窗口并不等于无条件删除;若消费者长时间落后,限流、暂停新 Turn 并告警,不能无限积压或静默丢弃。 + +RocketMQ 自身会按保留和空间策略清理,包括尚未消费的数据,应用不能假设“未 ACK 就永远保留”。必要进度落后时由 SQL 日志承担修复来源,并在容量边界前停止接受新工作。[RocketMQ storage policy](https://rocketmq.apache.org/docs/featureBehavior/11messagestorepolicy/) + +合并主要减少行数、索引和重复封装;内容字节不会凭空消失。原始速率约为 `C × r × s` 字节/秒,未压缩的窗口容量约为 `C × r × s × W`,还要计入 SQL/MQ 副本和索引。例如 `C=100`、`r=20` 次/秒、`s=200` 字节时,仅原始内容就是 `34.56GB/天`;`100ms` 合并平均约合入两个事件,并不能保证数量级下降。若测得 SQL 批次日志仍超出预算,就不能以“已经加 MQ”为由直接上线,应缩短经确认的窗口、限制并发或推进第 12 节的源日志方案。 + +## 9. Harness 与 Runtime 恢复 + +当前本地聊天文件由 Storage 与 ChatRecordingService 放在运行目录下的 `projects//chats/.jsonl`;根目录受 `QWEN_RUNTIME_DIR`、运行配置和用户目录影响。JSONL 在磁盘上并不自动意味着无法解耦,关键是进程替换后能否寻址、恢复字节并阻止旧写者继续提交。 + +完整恢复单元包含私有日志、检查点、被引用资源的闭包、Workspace 身份/快照、文件历史,以及 Broker 中的 binding/执行账本。当前 `LocalProcessRuntimeProvisioner.stopNow` 会删除 generation 目录,不能把其 `outputRoot` 当成长期资源位置;也不能只把 JSONL 上传而遗漏其引用的大结果或检查点资源。 + +提议的恢复顺序: + +1. 按租户与 Session 找到已提交 manifest/日志修订,在隔离状态核验恢复资格与当前安全水位;先隔离旧 Java/Harness/Runtime 写权。没有证据时不开放普通读取、派发、自动任务或 GC。 +2. 从原持久账本按原 `executionCallId` 核对不确定调用及备份后缺失区间;查询不新建 Runtime、不重新 prepare/execute。超时、进程退出、恢复库无记录都不证明无副作用;未知区间无法枚举时扩大 blocked 范围。 +3. 验证资源精确版本、摘要、长度和引用闭包,重放删除/密钥撤销约束,核对实际模型消费位置。缺资源进入 `recovery_blocked`,不回退为空 Session 或把全部 settled 结果当作已消费。 +4. 只有 Workspace 占用、旧 writer 停止/隔离、原结果/history 结算及存储交接证明满足后,才挂载原 Workspace 或已确认快照。随后在同一 Session UUID 下取得新 writer generation、恢复 Harness authority,并按原 binding 策略恢复执行句柄;不透明重绑活跃 Runtime Session。 +5. 仅在当前 ACL、生命周期及协议恢复边界允许时继续。私有日志恢复、公开 SSE 补发、运行中模型请求续算是三件不同的事。 + +资源先不可变发布并校验,再通过带 expected revision 与 writer generation 的条件提交发布 manifest/日志提交点。上传成功但提交不明只产生候选孤儿;实际 owner 必须按原 commandId 查明原提交、隔离旧生产者并核对全部引用/发布 hold 后,才允许回收,不能超时清唯一副本。提交点不能引用尚未持久化的对象。对象存储插件之外,必须有实际拒绝旧 generation 的权威提交机制,不能仅依赖本地锁或“租约已经过期”的客户端判断。 + +生产恢复能力需扩展 Harness 协议,明确 `journalRevision`、资源 manifest 与可恢复边界的确认。恢复状态与 Turn 完成状态分别记录;成功生成回复不自动证明可迁移恢复。持久化确认和执行核对完成前禁止回收仍被引用的资源。现有 boot mismatch 拒绝路径在这些验收完成前继续保留,不改成无条件重新 attach。 + + + +### 9.1 RestoreSet:一致备份集合 + +首版选择维护屏障备份,不承诺任意时刻热快照。停止集合涉及的 Session/共享 Workspace 的新输入、唤醒、派发和引用变更,排空实际写者及后代,持久接收结果/资源,提交模型实际消费位置与 history/checkpoint,再 freeze/flush。无法获得这些证明的集合不发布为可自动恢复。 + +RestoreSet 是有版本、不可变的跨存储 manifest,绑定稳定集合 ID、原维护 operation、范围、schema/reader 版本、屏障与冻结证明、组件版本/ref/digest、恢复边界和核验状态;通过已有仓库条件发布,不作为第二份 Session 日志。它关联: + +| 组成 | 必须固定的内容 | +| -------------- | ------------------------------------------------------------------------------------------------------------- | +| MySQL | 一致备份身份、校验信息、对应 binlog/GTID 边界;包括产品状态、原命令、Broker binding/执行记录和 Workspace 引用 | +| qwen authority | 私有 journal 完整提交前缀、checkpoint、待处理命令/原调用、实际模型消费结果及位置;不能仅取公开 Item/Snapshot | +| 对象与资源 | 精确版本、长度、digest、全部引用闭包与各 owner/hold;不能只保存可变 object key 或“最新版本” | +| Workspace | PVC/卷快照、受信 storage identity、文件 history/前后像及对应占用/冻结证明 | +| 构建与密钥 | build/Bundle/模板/协议兼容版本、所需 key 版本引用;manifest 不存密钥明文 | + +组件完成和 manifest 发布之间丢 ACK 时查询原 operation,不另造恢复集合或提前解屏障。全部组件核验、条件发布成功后才按当前权限释放维护屏障;失败保留持久进度并由原 operation 收敛。备份成功只证明所声明边界,不批准将来恢复。 + +共同时间戳不是一致性证明;[MySQL binlog/PITR](https://dev.mysql.com/doc/refman/8.0/en/point-in-time-recovery-binlog.html)和 GTID 不证明外部副作用,[VolumeSnapshot](https://kubernetes.io/blog/2020/12/10/kubernetes-1.20-volume-snapshot-moves-to-ga/)不自动提供应用一致性。`RestoreBundle` 仍是单 Session 的恢复响应,读取已验证集合里的合法基础;RestoreSet 不代替 authority。封闭记录/引用、minimumReader 及 capability 规则见[存储规范](managed-agent-session-storage.zh-CN.md#restore-set)。 + + + +### 9.2 隔离恢复与缺失区间 + +恢复启动默认隔离,不派发、不自动唤醒、不运行 GC;普通读取也须等 §9.3 的完整当前安全依据。旧 Java/Harness 的存储写权与旧 Runtime 的物理写通道分别隔离,新 DB epoch 或凭据版本不能证明旧 Shell 停止。原节点/存储证明、RWOP 和同卷交接遵守 [Endpoint §17](2026-09-21-managed-runtime-endpoint-recovery.zh-CN.md#hosted-runtime-profile)。 + +从备份边界到恢复隔离完成之间,必须依靠不随该备份丢失的原持久账本、受保护备份/审计及原 owner 证据枚举执行缺口。恢复库自己没有 command/dispatch 行不是未执行证明。仅当现有证据可覆盖完整区间、原效果已结算且结果/history/消费位置一致时才允许续跑;区间无法枚举就阻塞可能受影响的全部 Session/Workspace,不能把已知子集当作完整清单。原执行查询不 provision/prepare/execute。 + +重放删除/密钥撤销事实后只恢复仍有资格的资源。真实 ACL 权威不可用或尚可能是回滚旧版本时,不签新 grant、不开放普通读取;技术恢复成功不恢复用户旧权限。`accepted_unresolved` 仅关闭业务案件,物理 UNKNOWN 保留,不生成工具成功、不解卷、不恢复原模型。新任务也不能绕开原卷屏障,见[对账](managed-agent-recovery-operations.zh-CN.md#unknown-reconciliation)及[撤权](managed-agent-control-protocol.zh-CN.md#dynamic-authorization)。 + + + +### 9.3 备份外的完整安全水位 + +完整 DR 开放前,必须有不随业务 DB 回滚的恢复资格、删除 tombstone、密钥撤销记录和可验证完整水位。优先复用已有受保护备份/审计存储,只保存这些窄事实,不复制 Session 日志、不新增服务或 MQ。它必须处于不会与业务备份一起回退的保护范围;同一份 SQL 备份里的“最新水位”无效。 + +接口义务是返回目标恢复范围、当前资格/删除/key 撤销事实、完整水位、对应记录集及其完整性和新鲜性证据;验证者必须能证明无漏尾、无倒退,且查询覆盖全部相关范围。签名、hash、备份时间或客户端缓存的最大序号本身不能证明当前性。部署可复用受保护存储自身经验证的当前完整读取能力;无法提供这项证明的适配器不具备完整 DR capability,不能临时信任恢复库自报。 + +启用完整 DR 的部署,先在受保护依据中按原 operation 幂等预登记会降低恢复资格的删除/撤销意图,再推进业务库操作,最后登记经核验的结算引用。待决意图也阻止对应范围恢复;任何一步丢 ACK 按原 ID 对账,不因业务库回滚而抹掉意图。受保护存储不能在尚有未结意图或无法覆盖的来源变更时声称完整水位。该顺序复用原 operation 的持久阶段,不承诺跨库原子事务,也不复制业务日志。 + +恢复验证期间的并发删除/撤权也不能漏掉:开放读取/签发 grant 前重新核验当前权威与安全水位,并让后续读取/续租持续服从当前权限。跨系统读取不冒充分布式原子事务;无法形成有效准入屏障时继续隔离。没有证据时,普通 SSE/历史/Range/export/回执内容均 blocked,仅可经独立授权的隔离诊断访问必要材料,不唤醒模型、不允许普通导出。 + +删除/撤销依据至少覆盖所有仍能恢复相关旧数据的备份与副本,不能按短命令幂等 TTL 清除。该前置属于 G/W1 完整灾备开放条件,不强迫 C/E+W0 在线闭环新增基础设施。缺少它可以报告备份存在或恢复未验证,不能宣称安全自动恢复。 + + + +### 9.4 合规删除与副本完成度 + +复用原 Session close/delete、operation、资源 owner 和持久清理进度。删除请求先持久受理并封新输入/派发/普通读取,ACK 不等于停止或删除。生命周期仍为 `idle/active/recovery_blocked → closing → closed`,随后 `closed/archived → deleting → deleted`,不从活动态跳删除终态;原物理 owner 未知不能提前 closed。原 ID 查询/取消、可信迟到收件及清理保留窄系统资格,不唤醒模型或恢复撤权用户读取。 + +原工作可信停止后,核对结果交付、reader、pin、fork/export、发布 hold、调查/法律保留;在原 operation 下持久注销引用与 tombstone,再由真实 owner 按精确版本和身份条件清理。跨 owner 丢 ACK 查原引用/提交,不按超时推断已释放。未知引用或执行保持 pending/blocked,不清唯一证据;单 Session 删除只释放自身引用,不删除共享 Workspace/PVC。整个 Workspace/租户删除需要单独权限并核对全部引用。 + +完成度使用受控、版本化公开投影,且每项带原 operation、范围、revision、证据引用与未完成原因,不重定义既有 `deleted`: + +| 维度 | 可报告完成的依据 | +| ----------------- | -------------------------------------------------------------------------------- | +| 在线撤读 | 普通新请求及已有流的后续内容交付均已阻断;已发字节无法追回 | +| 执行停止 | 原可信监督者确认声明范围内实际退出;节点/存储隔离另报,不能伪称进程退出 | +| 在线清理 | 当前 owner 的可删除在线副本与引用已结算并核验,其他 owner 合法引用未受损 | +| 备份待清理 | 逐项记录 SQL 备份、对象历史版本、快照、导出等仍存副本及保留期限,不算完全完成 | +| hold/供应商待确认 | 有效法律保留或供应商未证实清理,记录适用范围、期限和后续动作 | +| 完全完成 | 声明删除范围内所有应清理副本均有证明,不再有未完成 hold/外部确认或可解密恢复路径 | + +删除约束必须在任意灾备开放读取前重放。[OSS delete marker](https://www.alibabacloud.com/help/en/oss/developer-reference/deleteobject)不等于历史版本已清除;已交付的用户导出无法技术追回,不能无依据承诺清理它。密钥擦除只有所有相关可解密副本及可恢复 key 路径都不可恢复才成立,不销毁他人共享 key,不同时承诺彻底擦除和完整恢复。 + +RPO/RTO、保留窗口、具体 CSI 驱动、备份删除及供应商契约尚未选定,不猜数值;未配置/未验证时相应自动恢复或 GC 不开放。UNKNOWN 不是无限期保留敏感数据的法律依据,需独立保留策略和有权合规处理;业务风险接受也不伪造物理结算。更细生命周期与引用规则见[Session 存储](managed-agent-session-storage.zh-CN.md#deletion-settlement)。 + +## 10. 后端选择与部署配置 + +| 组件 | 首选及替换边界 | +| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 关系数据库 | 先沿用 MySQL,减少迁移变量;已有 PostgreSQL 平台的部署可以选其适配器。当前问题不能通过换数据库类型直接解决。 | +| 消息分发 | 已有平台时优先 RocketMQ,承担多消费者和积压恢复;无平台时先启用 SQL 扫描器。Redis Streams 是短窗口场景的候选实现,仍需验证持久化、pending 消息恢复和容量。 | +| 浏览器续传 | 首版统一 SQL 批次日志;选择 Redis 适配器不意味着续传存储也必须再复制一份。 | +| 大对象与 Session 资源 | 使用现有对象存储或符合写者约束的持久存储。开发环境可以用本地实现,不能据此宣称跨节点恢复。 | + +后端选择发生在部署启动时,使用依赖注入和厂商迁移目录;这是本文提议的配置形状,不是当前已有配置: + +```yaml +qwen: + managed-agent: + storage: + backend: mysql # mysql | postgresql + event-transport: + backend: rocketmq # rocketmq | redis-streams | sql + artifacts: + backend: local # development only; production uses a durable provider +``` + +`sql` 表示不启用外部 EventTransport,由批次扫描器驱动物化。内存实现仅用于测试/开发。启动时校验所选适配器支持部署要求的确认、消费恢复和资源校验;缺能力就拒绝启用相应模式。凭证由部署配置注入,不进入公共事件或日志。 + +运行时不能按一次请求任意切换 MySQL/PG 或 MQ。切换需暂停写入、排空或保存检查点、迁移数据、核对序号与摘要,再切换并恢复;不能通过双跑工具或双写两个数据库模拟无损切换。 + +## 11. 故障语义 + +| 故障点 | 处理与可承诺的边界 | +| ------------------------------ | ------------------------------------------------------------------------- | +| Java 在批次提交前崩溃 | 从最后持久化源游标重取;源不可回放则报告缺口/恢复阻塞,未提交尾部无保证。 | +| 提交成功但尚未推 SSE | 日志补发,浏览器按原序号去重。 | +| MQ 发送成功但 Relay 未标记 | 同 batch 重发,物化事务去重;不重复追加文本。 | +| SSE 通知丢失或节点崩溃 | 节点水位核对或重连续传修复,浏览器不控制 Turn 生存期。 | +| SQL 暂时不可写 | 有界缓冲和背压;源无法可靠暂停/回放时显式失败,不继续承诺已接受。 | +| MQ 不可用 | 已接受输出仍可由 SQL 支持展示;Outbox 有界积压,达到阈值停止新准入。 | +| 消费者乱序、死信或漏段 | 检查连续进度并补洞,无法补齐则暂停该 Session 投影;不推进 Snapshot 水位。 | +| 租约切换后旧 Java/Harness 回来 | 数据库接受事务和私有日志提交均校验 generation;MQ 顺序能力不承担此防护。 | +| 工具结果未知或 Workspace 缺失 | `recovery_blocked`,不自动执行第二次,也不创建空工作区冒充恢复。 | + +所有查询、重放、资源下载与内部通知都保留租户鉴权。`X-Qwen-Tenant-Id` 是可信入口传递的范围,不是身份凭证。MQ 内部元数据和私有 Harness 记录不得直接透传前端;保留当前显式公开投影及敏感字段过滤。Hosted 按[动态授权契约](managed-agent-control-protocol.zh-CN.md#dynamic-authorization)在现有 SSE 和每个后续内容交付边界核对当前 ACL,不把连接建立时的权限当成永久资格。内部原 ID 收件可继续,但不会恢复用户读取或模型推进。 + +## 12. 落地顺序与代码改造 + +| 阶段 | 工作与退出条件 | +| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| P0:契约与基线 | **基础已实现。** 已有 `AgentStateStore`、有界入口批处理、幂等准入和提交后 Hub 直推;H2 与真实 MySQL 集成测试覆盖当前契约。性能基线和 PostgreSQL 对等能力仍待完成。 | +| P1:批次与直推 | **基于 SQL 的切片已实现。** 已有稳定 Item/Part 身份、V2 投影表、连续进度/Snapshot 同事务物化、带 Snapshot 水位的公共 Item 列表,以及 WebShell 的 Snapshot 加尾部恢复。长输出写放大、Snapshot 分页保护和生产故障/性能证据仍待完成。 | +| P2:传输与保留 | 增加实际选中的 MQ 适配器、Outbox Relay、多实例通知及续传重置;物化核对通过后,才启用窗口清理。已有 RocketMQ 平台时在此接入。 | +| P3:恢复持久化 | 持久化 Broker 三个 Repository;补 Harness authority 和资源 manifest、Workspace 恢复与回收屏障。跨 JVM/Harness/Runtime 故障测试通过后,才开放自动接管能力。 | +| P4:按测量决定是否改变接受路径 | 若 SQL 接受吞吐或 SQL 故障隔离不达标,先让 Harness/入口具备独立持久化源日志、单写者 fencing、稳定事件身份与重放确认,再设计 MQ 接受后直推、SQL 异步物化。此时必须重定控制事件与文本的统一顺序及公开游标,不能只替换 `acceptBatch` 的实现。 | + +P4 是明确的后续设计门槛,不是当前接口已经实现的能力。当前不引入多套运行模式、通用查询 DSL、自动 MQ 热切换或新的 Java Agent 循环。 + +迁移采用增量表与逐 Session 的 `storageVersion`,老 Session 暂时保留旧事件路径,新 Session 灰度使用新路径。历史回填缺少稳定 Item 身份时只保留为旧 Transcript,不能猜测后删除原记录。旧客户端继续使用保留路径,具备新能力的客户端才能进入窗口清理模式。 + +灰度先做只读投影对比,绝不双发 Prompt/工具。回滚优先停止新模式准入并保留兼容读取;已有新模式 Session 继续由兼容服务处理或明确暂停。启用清理后,旧版本无法仅凭已经删掉的 delta 恢复历史,因此不能只回滚二进制。 + +## 13. 验收与待确认参数 + +验收应针对实际部署的 MySQL、PostgreSQL 和 MQ 版本,不以 H2 或内存适配器通过代替: + +- 两个 JVM 同时提交相同幂等键、竞争 Turn 和租约,只有一次准入;失效 generation 不能推进源游标、事件或状态。 +- 注入提交前后、发布确认前后、投影事务前后崩溃,核对事件、完整文本、终态和消费进度,既无静默缺口也无重复文本。 +- 切换 Java 节点、丢通知、慢消费者、重连与 Snapshot 分页同时发生,验证缓冲到补发的衔接、游标过期与清理互斥。 +- 多 Part 文本、工具/审批交错、取消和错误终态均保持边界;确认公开投影不包含私有内容。 +- 清理时模拟必要消费者落后、Broker 提前清理和资源 pin;达到容量阈值前正确限流,仍可由保留日志补洞。 +- 删除 Runtime 进程及临时 generation 目录后恢复同一 Workspace 和 Session;缺少资源或工具结果未知时明确阻塞,不重复执行。 +- 各数据库适配器执行同一事务/幂等/CAS 测试;MQ 适配器执行同一确认丢失、重复、乱序和恢复测试;源码集成时再执行对应构建、类型检查和 E2E。 + +补充验收(均未执行): + +| 用例 | 必须成功 | 必须拒绝或阻塞 | +| ----------------------------- | ---------------------------------------------------- | ------------------------------------------------------ | +| 一致备份与丢 ACK | 冻结闭包、精确版本及原 operation 收敛同一 RestoreSet | 单独 timestamp/GTID/VolumeSnapshot 不冒充一致恢复 | +| DB 回滚至派发前 | 完整缺口证据及原回执允许合法恢复 | 缺记录不重放;旧写者未隔离、缺口不可枚举则扩大 blocked | +| DB 回滚至删除/撤权前 | 最新完整安全水位重放删除/撤销后仅合法数据可读 | 陈旧签名/水位或缺 authority 时普通读取也拒绝 | +| 恢复与并发删除 | 开放前及后续读取持续服从当前权威 | 一次恢复验证不永久豁免后续撤权/删除 | +| 删除与 reader/pin/fork/共享卷 | 排空和引用注销可恢复,独立引用保持可读 | 不误删共享 PVC、发布 hold 或唯一 UNKNOWN 证据 | +| 副本/key/供应商 | 所有声明范围内副本及可解密路径结清才完全完成 | delete marker、部分密钥擦除、未确认外部删除不虚报完成 | + +每项须同时有成功与拒绝路径,不能全部 blocked 冒充通过。真实 MySQL/Kubernetes/CSI/对象版本及供应商语义须独立验证,H2/fake API 不替代。补充的 G/W1/O4 开放条件与本篇原 P0~P4 编号分开;内部安全规则不等 D 公共投影上线。 + +性能对比记录:首字延迟及段间延迟 p50/p95/p99、每 Session 事务/秒、SQL 实际字节与索引占用、重放吞吐、物化延迟、MQ 积压、每节点/SSE 连接缓冲和资源恢复耗时。本文没有跑性能实验,不声称固定倍数提升。 + +上线前需明确:现有 RocketMQ 平台及版本、活跃 Session 数与事件速率、续传窗口 `W`、最大可容忍积压、数据库/MQ 确认的故障保证、历史与资源保留期、允许的恢复点和恢复时间。接口和分阶段设计不依赖立刻确定这些数值;容量、清理和跨节点恢复的生产配置依赖它们。 + +参考现有方案:集成快照中的 `docs/design/2026-09-19-managed-agent-spring-server.zh-CN.md`、`docs/design/2026-09-20-managed-agent-dual-path-web-shell.zh-CN.md`、`docs/design/managed-agent-session-storage.md` 与 `docs/design/managed-agent-session-harness-runtime.md`。本文补充其存储与事件边界,不把已有设计文档视作功能验证结果。 diff --git a/docs/design/2026-09-21-managed-session-durable-store.md b/docs/design/2026-09-21-managed-session-durable-store.md new file mode 100644 index 00000000000..540839a8721 --- /dev/null +++ b/docs/design/2026-09-21-managed-session-durable-store.md @@ -0,0 +1,363 @@ +# Managed Session Durable Authority Store + +[English](2026-09-21-managed-session-durable-store.md) | [简体中文](2026-09-21-managed-session-durable-store.zh-CN.md) + +> PR #12692 scope correction (2026-09-25): implementation and verification records below refer to the full integration preview, not acceptance evidence for this split. See [review corrections](2026-09-25-managed-agent-review-corrections.md) for current capabilities, fixes, and remaining gates. + +Status: D0, D1a, and D1b are implemented in the current feature-branch working tree, including the shared TypeScript/Java golden contract and the independent-JVM failure/takeover proof against real MySQL. D2a routing for newly created Hosted Sessions with inline resources, the cold Hosted load path, the first public Session lifecycle slice, and deterministic settled-Session and single-execution in-flight owner-failover proofs are also implemented. The settled proof kills the real Java and Hosted Harness processes, deletes the old Harness disk, starts replacement owners, and completes a second Turn with the first Turn's restored context. The in-flight proof crashes both owners after the durable `PREPARED` Broker identity and private `await_runtime` checkpoint are committed but before physical execution starts. A replacement Harness reconstructs that exact execution and Runtime Session identity without allocating a new Runtime, starts it once, persists the `SETTLED` receipt as `results_ready`, and completes checkpoint-bound continuation under a replacement Harness generation and Java event epoch. The proof observes one Broker row, one physical side effect, one initial model request, one continuation model request, one public terminal event, and no original-Prompt replay after deleting the old Harness disk. Java durably installs the replacement event epoch and attachment cursor before it invokes continuation, then advances the cursor after the response. An `UNKNOWN` observation is persisted by the current fenced writer as `BLOCKED_EXECUTION`, and Java terminates the admitted public Turn without submitting it again. The current implementation additionally admits an ordered batch of concurrency-safe Runtime executions before any physical dispatch, settles each execution into its own outcome reference, and resumes only after the complete batch is settled. A cancelling replacement owner passively inspects the original executions, cancels them by their durable identities, blocks on `UNKNOWN`, and publishes one cancelled terminal without starting a replacement Runtime or model continuation. These multi-execution and recovered-cancellation paths have TypeScript and Java protocol/coordinator coverage; deterministic multi-process fault injection for them remains outstanding. OSS resources, physical deletion/retention, post-continuation-admission Harness crash/event reconstruction, Workspace reconciliation, Kubernetes scheduler recovery, and the remaining D3 production gates remain. Date: 2026-09-24. This design refines the durable-recovery work in [Managed Agent Storage, Events, and Session Recovery](2026-09-20-managed-agent-storage-event-architecture.md). It covers new Hosted Managed Sessions only; importing existing local Sessions is out of scope for the first release. + +## 1. Decision + +For Hosted Managed Sessions, replace Runtime-local JSONL as the production authority with a hybrid durable store: + +- MySQL stores the private journal head, writer generation and lease, idempotent transaction receipts, exact committed record bytes, resource references, recovery status, and immutable resource bodies up to 64 KiB. +- OSS stores immutable resource bodies larger than 64 KiB, such as large messages, checkpoints, tool outcomes, file-history data, and recovery artifacts. +- The TypeScript Harness remains the semantic Session authority: it validates and creates Managed records. The Java storage module is the physical commit and fencing service; it does not run the Agent loop or synthesize private records. +- A local JSONL, if materialized for compatibility or diagnostics, is a disposable cache/export. It is never a second authority and losing the Harness Pod disk does not lose the Session. +- Standalone CLI and development deployments retain the existing local-file backend. A Session selects one backend when it is created and never dual-writes two authorities. + +Do not use a shared PVC or one appendable OSS object as the production authority. A PVC is an acceptable development or transitional backend. OSS is appropriate for immutable bodies, but the relational commit point supplies ordering, idempotency, compare-and-set, and stale-writer rejection. + +## 2. Verified Current Baseline + +Standalone still uses the local backend. D0 isolates storage behind contracts, D1a implements the remote service, and D1b/D2a select it for new private Hosted creates: + +- `LocalManagedSessionAuthority` reads and appends through a `ManagedSessionJournalHandle`; it no longer owns a transcript path or calls `SessionWriterLease` for normal writes. +- `LocalJsonlManagedSessionJournalStore` wraps `SessionWriterLease`, scans the normal Session transcript, and preserves the existing JSONL bytes and torn-tail behavior. +- `LocalManagedSessionResourceStore` implements `ManagedSessionResourceStore` and publishes resources under `/resources//`. +- `openManagedSession` accepts injected journal/resource stores and defaults to those local adapters. +- Flyway V4 and the Spring internal API now persist a private Managed journal head, exact transaction bytes, resource catalog, and revision-to-resource references. Database-time leases, monotonic writer generations, head CAS, command idempotency, exact-byte verification, and transactional `MYSQL_INLINE` resources are implemented. +- A private Hosted Harness create request may now select the HTTP journal/resource adapters for one caller-owned Session ID. Ordinary daemon routes reject that capability, and the ACP child accepts it only from its authenticated private managed parent. +- The active D2 path persists exact journal transactions and resources up to 64 KiB without creating a local authoritative transcript. Private Hosted `loadSession` reacquires the remote writer, verifies and projects the durable journal and referenced resources in memory, and rebuilds the existing restore shape without a local transcript. +- Store `writerId` is tied to the current Hosted Harness boot ID. The ordinary bind path may replace a stored Harness generation only while Java owns the active Turn dispatch lease and before that Turn has attempted admission. An admitted in-flight Turn can change generation only through the narrower recovery path, which also proves the checkpoint/activation identity, replacement attachment watermark, former boot/event epoch, and single known execution outcome; it is never reinterpreted as a fresh admission. +- The existing JSONL contains `session_execution_engine`, `managed_session_header_v1`, `managed_session_event_v1`, and `managed_session_commit_v1` records. The sibling `.ledger.jsonl` is the prompt terminal ledger, not the private Managed journal. + +Sessions that remain on the local backend still lose their transcript and referenced resources with an ephemeral Runtime or Hosted Harness filesystem. The new Hosted create/load path removes that durability dependency for the journal and inline resources. Uploading only JSONL would still be insufficient because checkpoints and message/tool bodies are separate resources. + +Hosted remote mode does not upload the sibling prompt ledger as another file. Its durable facts are represented by the private `turn.settled` transaction and the public Java Turn state. Standalone mode keeps the existing sidecar for compatibility. + +## 3. Options Considered + +| Option | Advantages | Problems | Decision | +| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | +| Per-Session PVC | Smallest code change; preserves file APIs | Couples scheduling to storage; slow attach/failover; non-Kubernetes deployments need another solution; volume access modes are not application fencing; referenced resources and database state still need coordinated recovery | Development or transitional use only | +| Shared RWX filesystem | Existing readers can see the same path | Lock and inode semantics depend on the filesystem; shared contention and noisy-neighbor risk; a filesystem path does not provide tenant-scoped CAS or idempotent commit receipts | Reject as production authority | +| One appendable OSS JSONL | Durable and visually similar to the local file | Append is sequential; a single appendable object is limited to 5 GiB; appendable versions, WORM, encryption, and download behavior have restrictions; no atomic transaction spans the object, resource manifests, and writer generation | Reject | +| MySQL only | Strong transaction, ordering, and fencing model | Large checkpoints, messages, and tool results inflate database storage and backup traffic | Use for journal data and resources up to 64 KiB | +| Tiered MySQL journal/resources plus OSS | Strong commit semantics while keeping large bytes out of SQL; scheduler-independent; works with local process and Kubernetes provisioners | Requires an internal storage API, resource manifest, and safe garbage collection | Selected | + +Kubernetes documents that ordinary volume access modes primarily describe mounting capabilities and do not themselves enforce write protection after mount. `ReadWriteOncePod` is stricter but still binds recovery to a volume and CSI behavior. [Kubernetes Persistent Volumes](https://kubernetes.io/docs/concepts/storage/persistent-volumes/) + +OSS object operations are atomic and strongly consistent after a successful write, which is suitable for immutable resources. OSS AppendObject, however, has a 5 GiB limit, appends only to the current appendable version, does not create a historical version for every append, cannot be combined with WORM, and has other storage/encryption constraints. [OSS consistency](https://help.aliyun.com/en/oss/user-guide/what-is-oss), [OSS AppendObject](https://help.aliyun.com/en/oss/developer-reference/appendobject) + +## 4. Target Topology and Ownership + +```mermaid +flowchart LR + UI[WebShell / Agent API] --> JAVA[Java control plane] + JAVA -->|create, load, submit, cancel| H[Hosted Harness] + H -->|lease-scoped internal HTTP| STORE[Managed Session Store module] + STORE --> DB[(MySQL private journal)] + STORE --> OSS[(OSS immutable resources)] + H -->|tool intent| BROKER[Runtime Broker] + BROKER --> RT[Tool-only Runtime] + RT --> WS[Workspace storage / snapshot] + H -. disposable cache .-> CACHE[Pod-local cache] +``` + +The Managed Session Store is initially a module in the existing Spring control-plane service, exposed only through an internal HTTP API. It does not require another deployment. The contract is transport-independent so the module can be separated later without changing Core semantics. + +Java continues to initiate public Harness and Runtime operations. The storage callback is a narrow internal persistence channel: Java grants a Session-scoped writer capability to the selected Harness, and the Harness uses it only to read or commit that Session. TypeScript does not receive database credentials or OSS long-lived credentials. + +The same `(tenantId, workspaceId, sessionId)` identifies the Java public Session, private Harness journal, and Runtime binding. There is no second public or Harness Session ID. + +### 4.1 Session-management ownership + +| Component | Owns | Must not own | +| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | +| Java control plane | Public Session lifecycle, tenant scope, public Turn idempotency/status, Harness and Runtime bindings, the scoped Store descriptor, and the physical MySQL/OSS commit service | Model conversation semantics or direct tool execution | +| TypeScript Hosted Harness | Model loop, private context, semantic Managed records, checkpoints, and the single active writer lease for one Session | Pod-local storage as durable authority or Runtime scheduling | +| Runtime Broker | Runtime allocation, endpoint/lease/generation, scheduler adaptation, and reconciliation of tool execution ownership | Conversation history or model-loop state | +| Tool Runtime | Workspace-local tools, MCP/skill execution, and replaceable execution caches | Public Session identity or authoritative transcript | +| MySQL and OSS | Ordered physical journal commits, fencing metadata, receipts, and immutable resource bytes | Agent decisions or recovery policy | + +Java therefore manages the public Session and grants capabilities; Harness manages the private conversational state; Runtime manages where tools execute. A component can be restarted independently only when the state owned by the other two layers is durably addressable by the same Session key. + +### 4.2 Public Session lifecycle protocol + +The Java control plane exposes the public lifecycle API and is the sole authority for lifecycle state, tenant checks, and command idempotency: + +```text +PATCH /v1/agents/sessions/{sessionId} +POST /v1/agents/sessions/{sessionId}/archive +POST /v1/agents/sessions/{sessionId}/unarchive +DELETE /v1/agents/sessions/{sessionId} +``` + +Every mutation requires `Idempotency-Key`. Java locks the tenant-scoped Session row, creates a durable `PENDING` command with the pre-mutation Session state and a requested event, performs the external Harness/Runtime action, then atomically marks the command `COMPLETED`, advances the public Session projection, and appends the completed event. A dependency failure leaves the command pending. Retrying the same key and request resumes it; a different lifecycle command for that Session receives `session_operation_active`. Reusing the key with different content receives `idempotency_conflict`. Persisting the pre-mutation state also lets an archived Session be deleted without requiring an already-closed Harness to become available again. + +| Operation | Preconditions | Pending public state | Required external action before completion | Completed public state/event | +| --------- | ------------------------------------------ | ----------------------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------- | +| Rename | `ACTIVE` | remains `ACTIVE` with a pending command | Hosted Harness durably commits the `session_metadata` title record and acknowledges `persisted: true` | `ACTIVE`; title projection plus `session.updated` | +| Archive | `ACTIVE` and no active Turn | `ARCHIVING` | close the live Harness attachment, then drain the Runtime binding | `ARCHIVED`; `session.archived` | +| Unarchive | `ARCHIVED` | remains `ARCHIVED` with a pending command | clear the Runtime retirement fence; Harness is cold-loaded lazily on the next Turn | `ACTIVE`; `session.unarchived` | +| Delete | `ACTIVE` or `ARCHIVED`, and no active Turn | `DELETING` | close Harness when it was active, then drain Runtime; an archived Harness is already closed | `DELETED` tombstone; `session.deleted` | + +Archive and delete reject `ACCEPTED`, `RUNNING`, or `CANCELLING` Turns instead of silently cancelling or duplicating work. Rename is acknowledged publicly only after the private title commit succeeds, so Java's title is a projection rather than a second title authority. Close-by-Session-ID, Runtime drain, and Runtime resume are idempotent, which makes a pending command safe to continue after a lost response or Java restart. + +Archive clears the live event cursor but retains the last Harness generation as a private-authority existence sentinel. After unarchive, the next Turn cold-loads that Session before binding a new generation. A Session without a sentinel also probes load first and creates only on an explicit not-found response, covering Sessions whose first private write was metadata rather than a Turn. The private `create` path rejects an existing authority with `409`, while `load` returns `404` instead of initializing an empty authority; this keeps retry recovery from silently replacing or fabricating conversation history. + +A live Hosted attachment is additionally bound to the normalized Store endpoint, tenant, workspace, and Harness writer generation. Hot attach, concurrent cold-load coalescing, and restore races all compare that identity and fail closed with `managed_session_store_conflict` instead of letting another tenant or Store descriptor reuse the in-memory Session. Lease duration may change without changing storage identity. + +The first delete slice is deliberately a soft tombstone. `GET` and list APIs hide a `DELETED` Session, while the committed public deletion event remains replayable to an already connected client. Private journal/resource bytes are retained until writer sealing, execution reconciliation, legal hold, retention, and garbage collection are implemented. This slice must not be advertised as physical erasure. + +## 5. Core Storage Contracts + +Extract behavior from the current concrete local classes without duplicating the Managed state machine: + +```ts +interface ManagedSessionJournalStore { + open(request: OpenJournalRequest): Promise; +} + +interface ManagedSessionJournalHandle { + readonly sessionKey: ManagedSessionKey; + read(options?: { maxBytes?: number }): Promise; + appendTransaction(records: readonly unknown[]): Promise; + blockRecovery?(request: RecoveryBlockRequest): Promise; + seal(): Promise; + abort(): Promise; +} + +interface ManagedSessionResourceStore { + publish(kind: string, bytes: Buffer): Promise; + read(ref: ManagedSessionDurableRef): Promise; +} +``` + +This is the implemented Core seam. `appendTransaction` always receives one complete semantic transaction. The optional `blockRecovery` capability is implemented by the durable HTTP handle; callers fail closed when a selected store cannot durably record a recovery block. The local adapter keeps the historical per-line sync and recoverable torn-tail behavior. The D1a Java endpoint accepts the complete remote transaction and applies the physical commit semantics. The D1b HTTP handle serializes that batch once, acquires or renews its scoped writer grant internally, submits outer CAS and idempotency metadata derived from the validated header, events, and marker, and returns only after Java confirms the exact bytes are committed. Its paired HTTP resource adapter retains staged inline bytes until that commit and verifies response metadata, exact transaction bytes, the digest chain, and downloaded resources during restore. + +`ManagedSessionAuthority` owns record validation, event sequence rules, command content digests, checkpoint rules, and domain semantics. Store implementations own physical atomicity, writer fencing, exact-byte durability, pagination, and resource verification. + +Implementations: + +- `LocalJsonlManagedSessionJournalStore` wraps `SessionWriterLease` and preserves current standalone behavior, including explicit torn-tail recovery. +- `LocalManagedSessionResourceStore` remains the local resource adapter. +- `HttpManagedSessionJournalStore` and `HttpManagedSessionResourceStore` use the internal Java API for hosted mode. + +The remote resource adapter keeps resources up to and including 64 KiB staged in the Harness until their owning journal transaction commits them atomically into MySQL. Larger resources are published to OSS before that transaction. Both paths return the same `DurableRef`; readers do not infer placement from the ref. The fixed v1 threshold avoids a per-deployment behavior matrix and can change only with a storage-versioned compatibility decision. + +The remote commit operation accepts one complete Managed transaction: normally one to three event records followed by its commit marker, plus any staged inline resources. Java stores the exact UTF-8 JSONL bytes and their SHA-256 digest; it does not parse or reserialize private event bodies. It validates the outer scope, size, record count, sequence range, reference list, lease, and digest chain. + +## 6. Relational Data Model + +Use separate private tables rather than extending `managed_agent_event`, which is a filtered public projection. + +### 6.1 `qwen_managed_session_journal_head` + +One row per Session: + +| Field | Purpose | +| -------------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| `tenant_id`, `workspace_id`, `session_id` | Trusted scope and primary identity | +| `storage_version`, `state` | Format and lifecycle (`ACTIVE`, `SEALED`, `DELETING`, `DELETED`) | +| `writer_generation`, `writer_id`, `writer_lease_until`, `lease_token_hash` | Monotonic writer fencing, using database time | +| `journal_revision`, `committed_sequence`, `last_commit_digest` | Authoritative head CAS | +| `activation_epoch` | Connect private writes to the active Harness grant | +| `latest_checkpoint_resource_id` | Fast restore entry point; null only for a valid initial basis | +| `compacted_through_revision` | Future immutable-pack watermark; zero initially | +| `recovery_status`, `recovery_detail_code` | `READY`, `BLOCKED_RESOURCE`, `BLOCKED_WORKSPACE`, or `BLOCKED_EXECUTION` | +| `created_at`, `updated_at` | Database timestamps | + +The primary key is `(tenant_id, session_id)`. Every mutating transaction locks this exact row through its unique key. InnoDB locking reads provide the row serialization needed for the head CAS. [MySQL InnoDB locking](https://dev.mysql.com/doc/refman/8.0/en/innodb-best-practices.html) + +### 6.2 `qwen_managed_session_journal_tx` + +One row per committed Managed transaction: + +| Field | Purpose | +| ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| Scope plus `journal_revision` | Ordered primary key | +| `operation`, `command_id`, `content_digest`, `command_key_hash` | Idempotency; raw values remain auditable while the bounded hash backs the unique key | +| `first_sequence`, `last_sequence`, `event_count` | Event range | +| `events_digest`, `previous_commit_digest`, `commit_digest` | Existing hash-chain proof | +| `writer_generation`, `writer_id`, `writer_token_hash`, `activation_epoch` | Audit, authenticated lost-response replay, and stale-writer proof | +| `latest_checkpoint_resource_id` | Optional checkpoint pointer advanced by this transaction | +| `record_encoding`, `record_bytes`, `byte_length`, `record_digest` | Exact bounded JSONL transaction bytes; initially `identity` encoding in `MEDIUMBLOB` | +| `created_at` | Commit time | + +The first row is a genesis transaction for `session.create`: it contains the exact `session_execution_engine` and Managed header lines, uses sequence zero, and references the definition and root snapshot resources. Later rows contain event records plus their commit marker. This groups the physical creation atomically without changing the exported JSONL record format or the event sequence. The existing 8 MiB transaction limit remains. `command_key_hash` is SHA-256 over the unambiguous operation/command tuple so the MySQL unique index stays within `utf8mb4` key limits; collision checks still compare the stored raw values and content digest. Private record bytes are never returned by public Agent APIs or copied into `managed_agent_event`. + +### 6.3 `qwen_managed_session_resource` + +Catalogs immutable resources stored in MySQL or OSS: + +| Field | Purpose | +| ------------------------------------------------------ | ----------------------------------------------------------------------- | +| Scope plus `resource_id` | Opaque identity and ownership | +| `kind`, `schema_version`, `byte_length`, `sha256` | Existing `DurableRef` contract | +| `storage_kind`, `inline_bytes` | `MYSQL_INLINE` plus bytes for resources up to 64 KiB | +| `object_key`, `object_version_id`, `encryption_key_id` | `OSS_OBJECT` location and encryption identity for larger resources | +| `publish_command_id`, `state` | Idempotent `ALLOCATED`, `PUBLISHED`, `REFERENCED`, `DELETING` lifecycle | +| `created_at`, `last_verified_at`, `retention_until` | Operations and retention metadata | + +Exactly one placement is valid for each resource. `MYSQL_INLINE` requires `inline_bytes` and null OSS location fields; `OSS_OBJECT` requires the OSS location fields and null `inline_bytes`. The 64 KiB boundary is measured from the raw resource bytes before transport encoding. Every read verifies `byte_length` and `sha256` regardless of placement. + +### 6.4 `qwen_managed_session_resource_ref` + +Records the resource closure committed by each journal revision. The logical key is `(tenant_id, session_id, journal_revision, resource_id)`; V4 uses a SHA-256 Session scope key in the physical primary key to stay within MySQL `utf8mb4` index limits while retaining and checking the raw scope columns. The first release retains all Session-owned referenced resources until the Session is explicitly deleted. Cross-Session pins and automatic garbage collection remain disabled until their hold protocol is implemented and tested. + +## 7. Internal HTTP Protocol + +When `qwen.managed-agent.session-store.enabled=true`, the implemented D1a service exposes the following routes. They are disabled by default so an ordinary public standalone deployment cannot accidentally expose private model context before service authentication is configured: + +```text +POST /internal/managed-session-store/v1/sessions/{sessionId}/writers:acquire +POST /internal/managed-session-store/v1/sessions/{sessionId}/writers:renew +POST /internal/managed-session-store/v1/sessions/{sessionId}/recovery:block +POST /internal/managed-session-store/v1/sessions/{sessionId}/transactions:commit +GET /internal/managed-session-store/v1/sessions/{sessionId}/restore +GET /internal/managed-session-store/v1/sessions/{sessionId}/transactions +GET /internal/managed-session-store/v1/sessions/{sessionId}/resources/{resourceId} +POST /internal/managed-session-store/v1/sessions/{sessionId}/writers:seal +``` + +The caller presents a fresh opaque Base64URL writer secret in `X-Qwen-Managed-Writer-Token` when acquiring a writer. Java grants and returns the database generation and expiry, and stores only the secret hash. Journal commits and renewals bind tenant, workspace, Session, writer identity, generation, and an unexpired database-time lease. Restore-head, transaction-page, and resource reads also require the current unexpired secret, so a superseded Harness cannot read private context after takeover. Seal is idempotent and may close an expired grant only while no higher generation has superseded it. A retry with the same writer and secret renews the same unexpired generation; an explicit seal or lease expiry permits a higher generation. Production deployments require mTLS or an equivalent service identity in addition to this scoped bearer; `tenantId` is scope, not authentication. + +`recovery:block` is a one-way fenced transition from `READY` to one of the three blocked recovery states. It requires the current writer identity, generation, secret, and unexpired database-time lease. Retrying the same status and detail is idempotent; a different block cannot overwrite the first cause, and this route cannot unblock a Session. + +Every response under the private store prefix carries `Cache-Control: no-store`; tenant, workspace, Session, and resource identifiers are also compared as exact raw values after database lookup so a case-insensitive MySQL collation cannot widen a scope. + +Transaction reads first page over revision and byte-length metadata, then fetch full records for a page whose unencoded transaction bytes do not exceed 8 MiB. The item limit therefore cannot expand one restore response to hundreds of MiB. + +Restore reads fail closed on unknown lifecycle or recovery states, unsafe head counters, revision gaps, and head/transaction disagreement. These checks report storage corruption rather than returning a partial authority. + +`transactions:commit` carries staged inline resources and inserts them in the same MySQL transaction as their first journal references. An optional `latestCheckpointResourceId` must identify a `managed-checkpoint` resource referenced by that transaction and advances the head atomically. The implemented path accepts raw resources up to and including 64 KiB, bounds their aggregate transaction bytes, verifies length and SHA-256, and returns `managed_session_oss_disabled` for larger resources. The D2 `resources:allocate` and `resources/{resourceId}:finalize` signed-object flow is not implemented. Standalone and ordinary daemon Sessions continue to select the local adapter; only the private Hosted Harness create path can select the remote adapter. + +## 8. Commit Protocol + +Writer acquisition creates a revision-zero fenced head before any journal bytes become visible. The `session.create` genesis commit then inserts the exact engine/header transaction, inline resource bodies, initial resource references, and the head advance to revision one in one MySQL transaction. An abandoned empty head is not a created public Session and can be reclaimed only by a higher writer generation after seal or lease expiry. For every later semantic transaction: + +1. Core validates the command, actor, expected sequence, records, and resource closure. Resources up to 64 KiB remain staged in the scoped Harness handle; larger resources are uploaded as immutable OSS objects and finalized. Neither is yet visible from the Session journal. +2. Harness calls `transactions:commit` with exact record bytes, resource refs, expected journal revision, expected committed sequence, previous commit digest, writer generation, activation epoch, operation, command ID, and content digest. +3. In one MySQL transaction, the store locks the head row, validates the unexpired generation and activation, resolves an existing idempotency receipt, verifies expected head and every resource, inserts staged inline resource bytes, the journal transaction, and resource references, then advances the head. +4. Only after commit does Harness acknowledge the private event or report a successful terminal Turn. A lost response is retried with the same command ID and content digest and returns the original receipt. +5. Updating a local cache and publishing public/SSE projections happen after the private commit. Their failure does not roll back or duplicate the private transaction. + +An OSS upload followed by a failed database commit leaves an unreferenced immutable object. It is not visible to recovery and can be collected later after the publisher is fenced and a safety window passes. Failed inline commits leave no resource row. A committed journal head can never reference an unfinalized object. + +## 9. Open and Recovery Protocol + +1. Java grants a new writer generation only after fencing the previous generation. Expiry alone authorizes a higher database generation, but does not prove an old tool side effect stopped; Runtime dispatch uses its own generation checks. +2. Harness reads the journal head and a restore bundle containing the latest verified checkpoint, the committed tail after that checkpoint, pending commands, and resource manifest. +3. Harness verifies the commit digest chain, record bytes, checkpoint digest, and all required resources. Immutable resources may be cached by digest on the Harness Pod. +4. The Runtime Broker reconciles every unsettled `executionCallId`. Unknown outcomes produce `BLOCKED_EXECUTION`; they are never executed again merely because the former Pod disappeared. +5. Workspace identity and snapshot/mount are verified. A missing workspace produces `BLOCKED_WORKSPACE`; transcript readability is not proof that execution can continue. +6. Only a `READY` restore installs a new activation and enables model/tool progression. Missing or corrupt resources produce `BLOCKED_RESOURCE`, while read-only history remains available where safe. + +The implemented tool-intent boundary establishes the identity needed by step 4. Harness asks the Broker to create an idempotent `PREPARED` execution record, commits the tool name, returned Broker `executionCallId`, and Runtime Session binding in `await_runtime`, and calls the explicit start route only after that commit succeeds. Reading a `PREPARED` execution does not dispatch it, cancellation settles it without a side effect, and the legacy create-and-start route remains compatible. On cold load, Core reconstructs the pending execution and the provider queries it by the durable Harness, Runtime Session, and execution identities without consulting the former process's in-memory entry. Recovery starts the original `PREPARED` identity, polls an `EXECUTING` or `CANCEL_REQUESTED` execution until settled, commits the immutable result reference in a `results_ready` checkpoint, and exposes a checkpoint/activation-bound private continuation route. While Java owns the Turn dispatch lease, it binds the replacement Harness boot, durably replaces the public event epoch with the attachment watermark, invokes that continuation route with the original public `promptId`, advances the durable cursor after the response, and streams the new epoch without calling ordinary Prompt submission. The structured Broker `UNKNOWN` result is preserved instead of being converted into a retry, the current fenced Harness writer records `BLOCKED_EXECUTION`, and the Java coordinator fails the public Turn before Harness submission. Once blocked, Harness skips the semantic activation-release transaction, which the Store must reject, but still seals the writer immediately so ownership does not linger until lease expiry. + +This implemented recovery slice deliberately supports one durable pending execution. Multi-tool recovery needs a batch checkpoint that maps each tool call to its own immutable outcome; assigning one outcome reference to multiple tools would be incorrect. Recovery while the public Turn is already `CANCELLING` remains fail-closed because cancellation must also consume or settle the private `results_ready` checkpoint. Java's replacement event epoch is now write-ahead state: a crash before the continuation call is safely retryable, and a crash after admission no longer leaves the old epoch durable. Continuation admission itself is still process memory in the Harness bridge, however, so a Harness crash after model continuation has progressed still needs durable event/checkpoint reconstruction and deterministic process-level proof. Those broader windows remain D3 gates. + +The first remote implementation can page the complete committed journal. Compaction is a later measured optimization: a background job may pack a contiguous prefix into an immutable compressed OSS object, verify it, atomically publish its manifest and watermark in MySQL, and only then delete covered SQL blobs after a grace period. Restore merges verified packs with the SQL hot tail. OSS object listing is never the authority for order or completeness. + +## 10. Latency and Availability + +The durable path must not reintroduce Runtime cold-start latency into first token: + +- Session creation should stage or publish its definition and root snapshot before the first Prompt where possible. +- Prompt input is committed before model inference. A normal prompt up to 64 KiB and its journal records use one bounded internal store transaction, not an OSS PUT or Pod startup wait; larger input uses the OSS path explicitly. +- Model inference and Runtime preparation remain concurrent. The Harness waits for Runtime only at the first tool boundary. +- Text deltas are not individually written to the private journal. Commit at semantic boundaries: input admission, model attempt/result, tool intent before dispatch, tool receipt before consumption, checkpoint, and Turn settlement. +- A terminal success is not emitted until the final message and checkpoint transaction is durable. +- Local caches can reduce restore reads but never relax commit acknowledgement. + +Measure added first-token latency, commit p50/p95/p99, restore latency, MySQL bytes per Turn, OSS requests and bytes per Turn, and cache hit rate. Validate the 64 KiB placement boundary against those measurements before production; changing it later requires a storage-versioned compatibility decision. No fixed latency claim is made by this design. + +If MySQL is unavailable, stop accepting new private commits and apply bounded backpressure. Continuing a model or tool side effect without durable admission would violate the recovery contract. An OSS outage blocks operations that require a new resource but does not make already committed history unreadable if its resources remain available through normal redundancy/cache behavior. + +## 11. Retention, Deletion, and Security + +- Public SSE replay retention and private Session retention are separate. Expiring public deltas must not delete private model context or checkpoints. +- The first release performs no automatic deletion of referenced private resources. A safe leak is preferable to an unsafe recovery gap. +- Session deletion first closes admission, reconciles executions, writes a tombstone/cleanup plan, and removes database references. Object deletion is idempotent and occurs after the configured grace or legal-retention window. +- Enable OSS versioning and server-side encryption. Use an overwrite-prevention request header for immutable writes. Versioning protects against accidental replacement but does not replace the database commit point. [OSS overwrite protection and limits](https://help.aliyun.com/en/oss/user-guide/limits), [OSS versioning](https://help.aliyun.com/en/oss/user-guide/manage-objects-in-a-versioning-enabled-bucket) +- Private journal/resource tables use a separate database role from public readers. All reads require trusted tenant and Session scope. Raw records, signed URLs, credentials, tool arguments, and model context are excluded from public events and ordinary application logs. +- Backups and disaster recovery must cover MySQL and OSS consistently enough to restore a journal head and every referenced object. A database point newer than the available object replica is not a valid recovery point. + +## 12. Rollout and Reviewable Changes + +### D0: Storage seam, no behavior change + +Status: implemented on the current feature branch. + +- Introduce `ManagedSessionJournalStore` and `ManagedSessionResourceStore` contracts. +- Move current JSONL scanning/appending and resource files behind local adapters. +- Run existing authority, projection, restore, writer-conflict, and corruption tests unchanged against the local adapter. + +Exit condition: standalone behavior and bytes are unchanged; no Java or OSS dependency enters Core. + +### D1: Durable Java store and contract client + +Status: D1a and D1b are implemented. + +- Implemented in D1a: four Flyway V4 private tables; the Spring internal API; database-time lease/generation fencing; head CAS and atomic checkpoint-pointer advance; idempotent receipts; exact record-byte storage and verification; paged restore reads; and transactional `MYSQL_INLINE` resources with OSS fail-closed. +- Implemented in D1b: the paired TypeScript HTTP journal/resource adapters, scoped writer acquire/renew/seal, exact transaction reconstruction and verification, structured Java errors, inline resource staging, and close/reopen tests that use no local transcript. A shared fixture now pins headers, limits, exact UTF-8/JSONL bytes, SHA-256 digests, request metadata, and Java error classifications across both languages. +- The independent-process proof starts separate child JVMs against a disposable real-MySQL schema. Writer A commits and halts before returning an acknowledgement; after its database-time lease expires, writer B acquires generation 2 and restores the committed transaction and inline resource. Retrying A's identical command returns the original receipt, while a new write from A is fenced. This completes the D1 Store-boundary proof. The D2 settled-Session proof described below additionally kills the real Hosted Harness and Java owner and resumes a new Turn. +- D2a now selects this backend for new private Hosted Harness Sessions. Java supplies tenant, workspace, writer identity, endpoint, and lease duration; the Harness generates the writer secret. The descriptor is rejected by ordinary daemon routes and untrusted ACP parents. + +Exit condition: two Java instances sharing MySQL reject stale writers and return the same receipt after a lost response. + +### D2: OSS resources and Hosted routing + +- Status: new-Session routing, cold Hosted load, and real Hosted owner failover are implemented for settled Sessions whose resource closure fits the inline store. The tool boundary persists the complete ordered Broker execution batch before side effects can start. An admitted in-flight Turn with known executions can now install a replacement Harness generation and write-ahead public event epoch, consume each original result, and continue without Prompt or tool replay. A cancelling replacement passively loads the checkpoint, cancels the original execution identities, and emits one cancelled terminal without a model continuation. A deterministic multi-process E2E currently proves the single-execution crash point after durable `PREPARED`/`await_runtime` and before physical execution start; the equivalent multi-execution and recovered-cancellation process proofs remain. OSS, post-continuation-admission Harness crash/event reconstruction, and the remaining in-flight failure matrix remain. +- Implement the `OSS_OBJECT` path for resources larger than 64 KiB: allocate/upload/finalize/read, scoped signed URLs, digest verification, encryption, and orphan inventory. +- The remote backend is selected only for newly created Hosted Managed Sessions. Existing local Sessions remain local; there is no migration or dual write. +- The Hosted `loadSession` path supplies the same scoped Store descriptor used at create time. The ACP child reacquires the durable writer, validates the journal and resource closure, projects reader-facing records, and builds the runtime restore state without consulting a local transcript. +- The deterministic multi-process test starts MySQL, Spring/Java, the Hosted Harness, the embedded Broker with its local-process Runtime provider, and a fake model. After the first Turn settles, it kills the Java and Harness process trees, deletes both old local homes, waits for the database writer lease to expire, and starts replacement owners. The replacement Harness acquires a higher writer generation and completes a second Turn on the same public Session; the second model request contains both the first prompt and first answer. The test also verifies that journal revision, committed sequence, public terminal count, and Harness boot ID advance without consulting the deleted Harness disk. +- The in-flight variant holds Broker start after the Harness has committed the exact `executionCallId` and `await_runtime` checkpoint, crashes the original Java and Harness process trees, deletes the old Harness disk, and then releases the replacement owner. It asserts the original execution reaches `SETTLED` at dispatch generation 1, exactly one physical side effect occurs, the model sees one initial request plus one receipt continuation, and the public Turn emits exactly one terminal event. The test is intentionally narrower than the complete failure matrix: a successor can reread consumed receipts until `turn_settled`, and an unreadable outcome body is rebuilt from the Broker's settled result. `npm run test:e2e:managed-continuation-failover` kills the Harness after the continuation has published its first text chunk, then checks that the tool ran once, the replacement sends one more continuation, the public transcript keeps only the replacement answer, and the Turn has one terminal event. Multiple and cancelled execution semantics have protocol and coordinator tests but remain unproven by this process-level harness. +- Automatic recovery of an already admitted in-flight Turn accepts an ordered batch only when every original execution has a known durable outcome; one `UNKNOWN` blocks the whole batch without replay. Java requires the active dispatch lease, the expected former Harness boot/event epoch, the replacement attachment watermark, and the checkpoint/activation identity before changing either owner. Recovered cancellation uses a separate passive load and checkpoint-bound cancel operation so inspection cannot start a prepared execution; it waits for the original executions to settle and never admits model continuation. A continuation whose durable events cannot be reconstructed remains fail-closed. Idle or not-yet-admitted recovery does not relax those fences. + +Exit condition: the settled inline-resource portion is now proven by deleting the original owner filesystem and restoring the same Session on another Harness without losing history. D2 is not complete until the same closure includes OSS-backed resources and checkpoints. + +### D3: Recovery gates and production evidence + +- The Runtime-dispatch fence, recovery inspection, ordered multi-execution continuation, and recovered cancellation are implemented: `executions:prepare` reserves each durable Broker identity without dispatch, the Harness atomically commits the ordered batch, `executions/{executionCallId}:start` performs idempotent dispatch, and a cold Harness starts or polls those exact executions without replay. Individual settled receipts retain their execution identity and ordinal; only a fully settled batch advances the private checkpoint to `results_ready`. Java then binds the expected writer activation to a replacement Harness boot and durably installs the attachment event watermark under the Turn dispatch lease before requesting checkpoint-bound continuation or cancellation. +- Core and the Broker preserve and inspect stable identities, and the `UNKNOWN` branch durably records `BLOCKED_EXECUTION` before Java terminates the public Turn. The real-process proof covers the single-execution `PREPARED`/`await_runtime` to `SETTLED`/`results_ready` takeover boundary. Remaining recovery work includes deterministic multi-process proof for multi-execution and cancellation recovery, durable event/checkpoint reconstruction after a mid-continuation Harness crash, Workspace recovery, and process-level fault injection at the other boundaries. +- Add retention/tombstone workflow, metrics, alerts, backup/restore exercise, and fault injection. + +Exit condition: the failure matrix below passes on the deployed MySQL and OSS products. Only then can hosted mode advertise automatic cross-Pod recovery. + +## 13. Acceptance Matrix + +| Scenario | Required result | +| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| Two Harness workers acquire the same Session | One generation can commit; the stale worker receives a conflict before any bytes become visible | +| Commit succeeds but HTTP response is lost | Same command ID and digest returns the original receipt; no duplicate sequence or content | +| Same idempotency key with different content | Conflict and alert; neither payload overwrites the other | +| Crash after object upload, before SQL commit | Session cannot see the object; it remains a safely collectible orphan | +| Resource is 64 KiB or 64 KiB plus one byte | The first uses `MYSQL_INLINE`, the second uses `OSS_OBJECT`; both use the same `DurableRef` and digest checks | +| Crash after SQL commit, before cache/SSE | Recovery reads the committed transaction; cache and public projection catch up without rewriting it | +| Harness Pod and local disk are deleted | New Harness restores journal, checkpoint, and all referenced resources | +| Resource missing or digest mismatch | `BLOCKED_RESOURCE`; never continue from an empty or older unproven state | +| Tool result outcome is unknown | `BLOCKED_EXECUTION`; never automatically repeat the tool call | +| Owners crash after `await_runtime` commit | Replacement starts the same Broker execution once, consumes its receipt, and emits one public terminal event | +| Workspace snapshot/mount is absent | History remains readable, execution is `BLOCKED_WORKSPACE` | +| Tenant or Session scope is forged | Request is rejected before object URL or private bytes are returned | +| MySQL or OSS is unavailable | Bounded backpressure and explicit failure; no false durable acknowledgement | +| Local cache is corrupt or absent | Rebuild or ignore it; durable head and resource digests determine truth | +| Session is deleted during active work | Admission closes, executions settle or block, tombstone commits, then resources are reclaimed safely | + +## 14. Open Deployment Parameters + +Before production enablement, choose and measure: + +- private Session retention and legal-hold requirements; +- active Session and Turn rates, average/maximum journal bytes and resource bytes per Turn; +- writer lease duration and renewal interval; +- MySQL durability/replication settings and acceptable recovery point/time; +- OSS region, redundancy, versioning, encryption key policy, lifecycle classes, and cross-region recovery; +- maximum restore tail, checkpoint frequency, and whether SQL-to-OSS journal packing is necessary; +- Workspace persistence or snapshot provider, which is a separate prerequisite for executable recovery. + +These parameters do not change the selected ownership or commit protocol. They determine capacity and operational policy, not whether a local Pod disk is authoritative. diff --git a/docs/design/2026-09-21-managed-session-durable-store.zh-CN.md b/docs/design/2026-09-21-managed-session-durable-store.zh-CN.md new file mode 100644 index 00000000000..7334406cdd8 --- /dev/null +++ b/docs/design/2026-09-21-managed-session-durable-store.zh-CN.md @@ -0,0 +1,363 @@ +# Managed Session 长期权威存储 + +[English](2026-09-21-managed-session-durable-store.md) | [简体中文](2026-09-21-managed-session-durable-store.zh-CN.md) + +> PR #12692 范围修正(2026-09-25):下文的实现与验证记录来自完整集成预览,不是本次拆分的验收证据。当前能力、修复与未完成门禁以[评审修正](2026-09-25-managed-agent-review-corrections.zh-CN.md)为准。 + +状态:当前 feature 分支工作树已实现 D0、D1a 和 D1b,包括 TypeScript/Java 共享 golden contract,以及基于真实 MySQL 的独立 JVM 崩溃/接管证明。面向新建 Hosted Session 和 inline 资源的 D2a 路由、Hosted 冷加载链路、第一阶段公共 Session 生命周期,以及 settled Session 与单 execution 在途 Turn 的确定性 owner failover 证明也已实现。settled 证明会强杀真实 Java 与 Hosted Harness 进程、删除旧 Harness 磁盘、启动替代 owner,并在第二轮恢复第一轮上下文后成功完成 Turn。在途证明则在 durable `PREPARED` Broker 身份和私有 `await_runtime` checkpoint 已提交、但物理执行尚未开始时强杀两个 owner。替代 Harness 不分配新 Runtime,而是重建同一个 execution 与 Runtime Session 身份,只启动一次物理执行,把 `SETTLED` 回执持久化为 `results_ready`,并在替代 Harness generation 与 Java event epoch 下完成 checkpoint-bound continuation。删除旧 Harness 磁盘后,该证明观察到一条 Broker 记录、一次物理副作用、一次初始模型请求、一次 continuation 模型请求、一个公共终态事件,且没有重放原 Prompt。Java 会在调用 continuation 前先持久安装替代 event epoch 与 attachment cursor,再在响应后推进 cursor。`UNKNOWN` 观测会由当前 fenced writer 持久写成 `BLOCKED_EXECUTION`,Java 会终止已准入的公共 Turn,而不会重新提交。当前实现还会在任何物理派发前原子准入一批按顺序排列、允许并发的 Runtime execution,为每个 execution 单独结算 outcome reference,并且只在整批全部 settled 后继续。处于取消态的替代 owner 会被动检查原 execution,按其 durable identity 逐项取消;遇到 `UNKNOWN` 时阻塞,并且不会启动替代 Runtime 或模型 continuation,只发布一个 cancelled 终态。多 execution 与恢复态取消路径已有 TypeScript 和 Java 协议/coordinator 覆盖,但其确定性多进程故障注入仍待完成。OSS 资源、物理删除/保留策略、continuation 准入后的 Harness 再次崩溃与事件重建、Workspace 对账、Kubernetes 调度恢复及其余 D3 生产门禁仍待完成。日期:2026-09-24。本文细化[Managed Agent 存储、事件与 Session 恢复](2026-09-20-managed-agent-storage-event-architecture.zh-CN.md)中的长期恢复工作。首版只覆盖新建的 Hosted Managed Session;既有本地 Session 导入不在首版范围内。 + +## 1. 决策 + +Hosted Managed Session 不再把 Runtime 本地 JSONL 作为生产权威,而采用混合长期存储: + +- MySQL 保存私有日志头、writer generation 与 lease、幂等事务回执、已提交记录的精确字节、资源引用、恢复状态,以及不超过 64 KiB 的不可变资源正文。 +- OSS 保存超过 64 KiB 的不可变资源正文,例如较大消息、checkpoint、工具结果、文件历史和恢复产物。 +- TypeScript Harness 仍是 Session 语义权威:由它校验并生成 Managed 记录。Java 存储模块只负责物理提交和 fencing,不运行 Agent Loop,也不生成私有记录。 +- 如为兼容或诊断生成本地 JSONL,它只是可丢弃缓存/导出格式,不是第二权威;Harness Pod 本地盘丢失不能导致 Session 丢失。 +- 独立 CLI 和开发部署继续使用现有本地文件后端。Session 创建时只选择一个后端,不对两个权威做双写。 + +不把共享 PVC 或单个可追加 OSS Object 作为生产权威。PVC 可以用于开发或过渡;OSS 适合不可变正文,但顺序、幂等、CAS 和旧 writer 拒绝由关系型提交点提供。 + +## 2. 已核验的当前基线 + +Standalone 仍使用本地后端。D0 已通过契约隔离存储,D1a 实现远端服务,D1b/D2a 则让新建的私有 Hosted Session 选择该服务: + +- `LocalManagedSessionAuthority` 通过 `ManagedSessionJournalHandle` 读写;正常写入不再直接持有 transcript path 或调用 `SessionWriterLease`。 +- `LocalJsonlManagedSessionJournalStore` 包装 `SessionWriterLease`、扫描普通 Session transcript,并保持既有 JSONL 字节与 torn-tail 语义。 +- `LocalManagedSessionResourceStore` 实现 `ManagedSessionResourceStore`,把资源发布到 `/resources//`。 +- `openManagedSession` 支持注入 journal/resource store,默认选择上述本地 adapter。 +- Flyway V4 和 Spring 内部 API 已能保存私有 Managed journal head、事务精确字节、资源目录及 revision 到资源的引用;数据库时间 lease、单调 writer generation、head CAS、command 幂等、精确字节校验和事务化 `MYSQL_INLINE` 均已实现。 +- 私有 Hosted Harness 创建请求现在可以为一个调用方指定的 Session ID 选择 HTTP journal/resource adapter。普通 daemon 路由会拒绝该 capability,ACP 子进程也只接受经过认证的私有 managed parent 下发的配置。 +- 当前 D2 活跃路径会持久化精确 journal 事务和不超过 64 KiB 的资源,并且不创建本地权威 transcript。私有 Hosted `loadSession` 会重新获取远端 writer,在内存中校验和投影 durable journal 及其引用资源,并在没有本地 transcript 的情况下重建既有恢复结构。 +- Store `writerId` 与当前 Hosted Harness boot ID 绑定。普通 bind 路径只有在 Java 持有 active Turn dispatch lease 且该 Turn 尚未尝试准入时,才允许替换已记录的 Harness generation。已经准入的在途 Turn 只能走更窄的恢复路径;该路径还必须证明 checkpoint/activation 身份、替代 attachment 水位、原 boot/event epoch 和单个已知 execution 结果,不能把原 Turn 重新解释为一次新准入。 +- 现有 JSONL 包含 `session_execution_engine`、`managed_session_header_v1`、`managed_session_event_v1` 和 `managed_session_commit_v1`。同目录的 `.ledger.jsonl` 是 prompt 终态账本,不是私有 Managed journal。 + +仍使用本地后端的 Session,在 Runtime 或 Hosted Harness 使用临时文件系统时,Pod 回收后依然会丢失 transcript 及其引用资源。新的 Hosted 创建/加载路径已经去掉 journal 和 inline 资源对该本地持久盘的依赖。只上传 JSONL 仍然不够,因为 checkpoint、消息和工具正文位于独立资源中。 + +Hosted 远端模式不会再把同目录的 prompt ledger 作为另一个文件上传;其中需要长期保留的事实由私有 `turn.settled` 事务和 Java 公共 Turn 状态表达。Standalone 模式为兼容继续保留现有 sidecar。 + +## 3. 方案比较 + +| 方案 | 优点 | 问题 | 决策 | +| --------------------------------- | ------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | +| 每 Session PVC | 代码改动最少,保留文件 API | 调度与存储耦合;挂载/故障切换慢;非 Kubernetes 还需另一套方案;卷访问模式不是应用层 fencing;引用资源与数据库状态仍需协同恢复 | 仅开发或过渡使用 | +| 共享 RWX 文件系统 | 既有 reader 可见同一路径 | lock/inode 语义依赖文件系统;共享争用和噪声租户风险;文件路径无法提供租户级 CAS 与幂等提交回执 | 拒绝作为生产权威 | +| 单个可追加 OSS JSONL | 长期保存且形态类似本地文件 | 追加串行;单个 Appendable Object 最大 5 GiB;版本、WORM、加密和下载行为有限制;无法跨 Object、资源 manifest 和 writer generation 原子提交 | 拒绝 | +| 仅 MySQL | 事务、顺序和 fencing 清晰 | 大 checkpoint、消息和工具结果会放大数据库及备份流量 | 保存 journal 数据和不超过 64 KiB 的资源 | +| MySQL 分层 journal/resource + OSS | 提交语义强,同时大字节不进入 SQL;不依赖调度器;本地进程和 Kubernetes provisioner 均可使用 | 需要内部存储 API、资源 manifest 与安全回收 | 选择此方案 | + +Kubernetes 文档说明,普通卷访问模式主要描述挂载能力,卷挂载后并不自动强制写保护。`ReadWriteOncePod` 更严格,但恢复仍受卷和 CSI 能力约束。[Kubernetes Persistent Volumes](https://kubernetes.io/docs/concepts/storage/persistent-volumes/) + +OSS 在写成功后提供原子操作和强一致性,适合保存不可变资源。但 OSS AppendObject 最大 5 GiB,只能向当前 Appendable 版本追加,不会为每次追加生成历史版本,且与 WORM、部分加密和下载能力存在限制。[OSS consistency](https://help.aliyun.com/en/oss/user-guide/what-is-oss)、[OSS AppendObject](https://help.aliyun.com/en/oss/developer-reference/appendobject) + +## 4. 目标拓扑与归属 + +```mermaid +flowchart LR + UI[WebShell / Agent API] --> JAVA[Java 管控面] + JAVA -->|create, load, submit, cancel| H[Hosted Harness] + H -->|lease-scoped internal HTTP| STORE[Managed Session Store 模块] + STORE --> DB[(MySQL 私有 journal)] + STORE --> OSS[(OSS 不可变资源)] + H -->|tool intent| BROKER[Runtime Broker] + BROKER --> RT[Tool-only Runtime] + RT --> WS[Workspace 存储 / 快照] + H -. 可丢弃缓存 .-> CACHE[Pod 本地缓存] +``` + +Managed Session Store 首先作为现有 Spring 管控面中的模块,通过内部 HTTP API 提供能力,不要求新增部署。接口与传输解耦,后续可以拆成独立服务,而不改变 Core 语义。 + +Java 继续主动发起公开的 Harness 和 Runtime 操作。存储回调是一条窄的内部持久化通道:Java 向选中的 Harness 发放 Session 范围的 writer capability,Harness 只能用它读写该 Session。TypeScript 不获得数据库凭据或 OSS 长期凭据。 + +Java 公共 Session、Harness 私有 journal 和 Runtime binding 共用同一个 `(tenantId, workspaceId, sessionId)`,不新增第二套 public 或 Harness Session ID。 + +### 4.1 Session 管理职责 + +| 组件 | 负责 | 不应负责 | +| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | +| Java 管控面 | 公共 Session 生命周期、tenant scope、公共 Turn 幂等与状态、Harness/Runtime binding、scoped Store 描述,以及 MySQL/OSS 物理提交服务 | 模型会话语义或直接工具执行 | +| TypeScript Hosted Harness | 模型循环、私有上下文、Managed 语义记录、checkpoint,以及单个 Session 的唯一 active writer lease | 把 Pod 本地存储当成长期权威,或负责 Runtime 调度 | +| Runtime Broker | Runtime 分配、endpoint/lease/generation、调度器适配,以及工具执行归属对账 | 会话历史或模型循环状态 | +| Tool Runtime | Workspace 本地工具、MCP/skill 执行和可替换的执行缓存 | 公共 Session 身份或权威 transcript | +| MySQL 与 OSS | 有序物理 journal commit、fencing 元数据、回执和不可变资源字节 | Agent 决策或恢复策略 | + +因此,Java 管公共 Session 并发放 capability,Harness 管私有会话状态,Runtime 管工具在哪里执行。只有另外两层拥有的状态都能通过同一个 Session key 长期寻址时,其中任意一层才能独立重启。 + +### 4.2 公共 Session 生命周期协议 + +Java 管控面对外提供生命周期 API,并且是生命周期状态、tenant 校验和命令幂等的唯一权威: + +```text +PATCH /v1/agents/sessions/{sessionId} +POST /v1/agents/sessions/{sessionId}/archive +POST /v1/agents/sessions/{sessionId}/unarchive +DELETE /v1/agents/sessions/{sessionId} +``` + +每个变更都必须携带 `Idempotency-Key`。Java 先锁定 tenant 范围的 Session 行,写入包含变更前 Session 状态的长期 `PENDING` command 和 requested event,再执行 Harness/Runtime 外部动作,最后原子地把 command 标记为 `COMPLETED`、推进公共 Session 投影并追加完成事件。依赖失败时 command 保持 pending;使用相同 key 和相同请求重试会继续执行,针对同一 Session 的其他生命周期命令会得到 `session_operation_active`,相同 key 携带不同内容则得到 `idempotency_conflict`。持久化变更前状态也使归档 Session 在删除时不必等待已经关闭的 Harness 再次可用。 + +| 操作 | 前置条件 | Pending 时公共状态 | 完成前必须成功的外部动作 | 完成后的公共状态/事件 | +| -------- | ------------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------- | -------------------------------------- | +| 重命名 | `ACTIVE` | 保持 `ACTIVE`,同时存在 pending command | Hosted Harness 长期提交 `session_metadata` 标题记录,并确认 `persisted: true` | `ACTIVE`;标题投影与 `session.updated` | +| 归档 | `ACTIVE` 且无活动 Turn | `ARCHIVING` | 关闭在线 Harness attachment,再 drain Runtime binding | `ARCHIVED`;`session.archived` | +| 取消归档 | `ARCHIVED` | 保持 `ARCHIVED`,同时存在 pending command | 清除 Runtime retirement fence;下一次 Turn 再惰性冷加载 Harness | `ACTIVE`;`session.unarchived` | +| 删除 | `ACTIVE` 或 `ARCHIVED`,且无活动 Turn | `DELETING` | 原状态为 active 时关闭 Harness,再 drain Runtime;归档 Harness 已经关闭 | `DELETED` tombstone;`session.deleted` | + +归档和删除遇到 `ACCEPTED`、`RUNNING` 或 `CANCELLING` Turn 时直接拒绝,不会隐式取消或重复工作。重命名只有在私有标题提交成功后才对外确认,因此 Java 的标题只是投影,不会形成第二个标题权威。按 Session ID 关闭、Runtime drain 和 Runtime resume 都是幂等动作,使 pending command 能在响应丢失或 Java 重启后安全续跑。 + +归档会清除在线事件游标,但保留最后一代 Harness generation,作为私有权威已经存在的哨兵。取消归档后,下一次 Turn 会先冷加载该 Session,再绑定新的 generation。没有哨兵的 Session 也会先探测 load,只有明确收到 not-found 才执行 create,从而覆盖首次私有写入是元数据而不是 Turn 的 Session。私有 `create` 路径遇到已有权威时返回 `409`,而 `load` 在权威不存在时返回 `404`,不会顺手初始化空权威,从而避免重试恢复静默替换或凭空生成会话历史。 + +在线 Hosted attachment 还会绑定到规范化后的 Store endpoint、tenant、workspace 和 Harness writer generation。热 attach、并发冷加载合并和 restore race 都会比较这组身份;一旦不同,就以 `managed_session_store_conflict` fail closed,而不会让另一个 tenant 或 Store 描述复用内存中的 Session。lease 时长变化不改变存储身份。 + +第一阶段删除有意只实现软 tombstone。`GET` 和列表 API 会隐藏 `DELETED` Session,而已连接客户端仍可重放已经提交的公共删除事件。私有 journal/resource 字节会继续保留,直到 writer seal、execution 对账、legal hold、保留期和垃圾回收全部实现。因此不能把当前能力宣称为物理擦除。 + +## 5. Core 存储契约 + +从当前具体本地类中提取能力,但不复制 Managed 状态机: + +```ts +interface ManagedSessionJournalStore { + open(request: OpenJournalRequest): Promise; +} + +interface ManagedSessionJournalHandle { + readonly sessionKey: ManagedSessionKey; + read(options?: { maxBytes?: number }): Promise; + appendTransaction(records: readonly unknown[]): Promise; + blockRecovery?(request: RecoveryBlockRequest): Promise; + seal(): Promise; + abort(): Promise; +} + +interface ManagedSessionResourceStore { + publish(kind: string, bytes: Buffer): Promise; + read(ref: ManagedSessionDurableRef): Promise; +} +``` + +以上是已实现的 Core 接缝。`appendTransaction` 每次接收一笔完整的语义事务;可选的 `blockRecovery` 能力由 durable HTTP handle 实现,所选 Store 无法持久记录恢复阻塞时调用方会 fail-closed。本地 adapter 保留历史上的逐行 sync 与可恢复 torn-tail 行为。D1a Java endpoint 接收完整远端事务并执行物理提交语义。D1b HTTP handle 对整批记录只序列化一次,在内部获取或续租 scoped writer grant,从已校验的 header、event 和 marker 派生外层 CAS 与幂等元数据,并仅在 Java 确认精确字节已提交后返回;配套 HTTP resource adapter 会将 inline 暂存字节保留到该次提交,并在恢复时校验响应元数据、精确事务字节、摘要链和下载资源。 + +`ManagedSessionAuthority` 负责记录校验、事件 sequence、command content digest、checkpoint 规则和领域语义;Store 实现负责物理原子性、writer fencing、精确字节持久化、分页和资源校验。 + +实现包括: + +- `LocalJsonlManagedSessionJournalStore` 封装 `SessionWriterLease`,保留现有 standalone 行为及显式坏尾恢复。 +- `LocalManagedSessionResourceStore` 继续作为本地资源适配器。 +- `HttpManagedSessionJournalStore` 与 `HttpManagedSessionResourceStore` 在 hosted 模式调用 Java 内部 API。 + +远端 resource adapter 将不超过 64 KiB 的资源暂存在 Harness 中,直到所属 journal 事务把资源与引用原子写入 MySQL。较大资源在该事务之前发布到 OSS。两条路径返回相同的 `DurableRef`,reader 不从 ref 猜测物理位置。v1 使用固定阈值,避免形成按部署变化的行为矩阵;以后只有通过带 storage version 的兼容决策才能调整。 + +远端提交一次接收一个完整 Managed 事务:通常是一至三条 event record 加一条 commit marker,并带上待提交的 inline resources。Java 保存 UTF-8 JSONL 精确字节及 SHA-256,不解析或重新序列化私有事件正文;它只校验外层 scope、大小、记录数量、sequence 范围、引用列表、lease 和摘要链。 + +## 6. 关系数据模型 + +使用独立私有表,不扩展已过滤的公共投影 `managed_agent_event`。 + +### 6.1 `qwen_managed_session_journal_head` + +每个 Session 一行: + +| 字段 | 用途 | +| -------------------------------------------------------------------------- | ----------------------------------------------------------------------- | +| `tenant_id`、`workspace_id`、`session_id` | 可信 scope 与主身份 | +| `storage_version`、`state` | 格式和生命周期(`ACTIVE`、`SEALED`、`DELETING`、`DELETED`) | +| `writer_generation`、`writer_id`、`writer_lease_until`、`lease_token_hash` | 使用数据库时间的单调 writer fencing | +| `journal_revision`、`committed_sequence`、`last_commit_digest` | 权威 head CAS | +| `activation_epoch` | 将私有写入绑定到当前 Harness grant | +| `latest_checkpoint_resource_id` | 快速恢复入口;仅合法 initial basis 可为空 | +| `compacted_through_revision` | 未来不可变 pack 水位;初始为零 | +| `recovery_status`、`recovery_detail_code` | `READY`、`BLOCKED_RESOURCE`、`BLOCKED_WORKSPACE` 或 `BLOCKED_EXECUTION` | +| `created_at`、`updated_at` | 数据库时间 | + +主键为 `(tenant_id, session_id)`。每个变更事务通过唯一键锁定这一行。InnoDB locking read 为 head CAS 提供所需的行级串行化。[MySQL InnoDB locking](https://dev.mysql.com/doc/refman/8.0/en/innodb-best-practices.html) + +### 6.2 `qwen_managed_session_journal_tx` + +每个已提交 Managed 事务一行: + +| 字段 | 用途 | +| ------------------------------------------------------------------------- | ------------------------------------------------------------------- | +| scope 加 `journal_revision` | 有序主键 | +| `operation`、`command_id`、`content_digest`、`command_key_hash` | 幂等键;原值用于审计,有界 hash 支撑唯一索引 | +| `first_sequence`、`last_sequence`、`event_count` | 事件范围 | +| `events_digest`、`previous_commit_digest`、`commit_digest` | 现有摘要链证明 | +| `writer_generation`、`writer_id`、`writer_token_hash`、`activation_epoch` | 审计、带身份的响应丢失重放与旧 writer 证明 | +| `latest_checkpoint_resource_id` | 由本事务推进的可选 checkpoint 指针 | +| `record_encoding`、`record_bytes`、`byte_length`、`record_digest` | 精确且有界的 JSONL 事务字节;首版用 `MEDIUMBLOB` 的 `identity` 编码 | +| `created_at` | 提交时间 | + +第一行是 `session.create` 的 genesis transaction:保存精确的 `session_execution_engine` 和 Managed header 两行,使用 sequence zero,并引用 definition 与 root snapshot 资源。后续行才保存 event records 及其 commit marker。这样可以让物理创建原子完成,同时不改变导出 JSONL 的记录格式或 event sequence。保持现有单事务 8 MiB 限额。`command_key_hash` 是无歧义 operation/command 元组的 SHA-256,使 MySQL 唯一索引保持在 `utf8mb4` key 限额内;发生命中时仍比较已保存的原始字段和 content digest。私有记录字节绝不从公共 Agent API 返回,也不复制到 `managed_agent_event`。 + +### 6.3 `qwen_managed_session_resource` + +记录存放在 MySQL 或 OSS 的不可变资源: + +| 字段 | 用途 | +| ------------------------------------------------------ | ------------------------------------------------------------------ | +| scope 加 `resource_id` | 不透明身份和归属 | +| `kind`、`schema_version`、`byte_length`、`sha256` | 现有 `DurableRef` 契约 | +| `storage_kind`、`inline_bytes` | `MYSQL_INLINE` 及不超过 64 KiB 的资源字节 | +| `object_key`、`object_version_id`、`encryption_key_id` | 较大资源的 `OSS_OBJECT` 位置与加密身份 | +| `publish_command_id`、`state` | 幂等的 `ALLOCATED`、`PUBLISHED`、`REFERENCED`、`DELETING` 生命周期 | +| `created_at`、`last_verified_at`、`retention_until` | 运维与保留元数据 | + +每个资源只能使用一种物理位置。`MYSQL_INLINE` 必须有 `inline_bytes` 且 OSS 位置字段为空;`OSS_OBJECT` 必须有 OSS 位置字段且 `inline_bytes` 为空。64 KiB 边界按传输编码前的资源原始字节计算。无论资源位于哪里,读取时都必须校验 `byte_length` 和 `sha256`。 + +### 6.4 `qwen_managed_session_resource_ref` + +保存每个 journal revision 提交的资源闭包。逻辑键为 `(tenant_id, session_id, journal_revision, resource_id)`;V4 在物理主键中使用 SHA-256 Session scope key,以满足 MySQL `utf8mb4` 索引长度限制,同时保留并校验原始 scope 列。首版在 Session 被显式删除前保留其所有已引用资源;跨 Session pin 和自动 GC 在持有协议完成并验证前保持关闭。 + +## 7. 内部 HTTP 协议 + +设置 `qwen.managed-agent.session-store.enabled=true` 后,D1a 会开放以下私有路由。它们默认关闭,避免普通公共 standalone 部署在服务鉴权尚未配置时意外暴露私有模型上下文: + +```text +POST /internal/managed-session-store/v1/sessions/{sessionId}/writers:acquire +POST /internal/managed-session-store/v1/sessions/{sessionId}/writers:renew +POST /internal/managed-session-store/v1/sessions/{sessionId}/recovery:block +POST /internal/managed-session-store/v1/sessions/{sessionId}/transactions:commit +GET /internal/managed-session-store/v1/sessions/{sessionId}/restore +GET /internal/managed-session-store/v1/sessions/{sessionId}/transactions +GET /internal/managed-session-store/v1/sessions/{sessionId}/resources/{resourceId} +POST /internal/managed-session-store/v1/sessions/{sessionId}/writers:seal +``` + +调用方在 acquire writer 时通过 `X-Qwen-Managed-Writer-Token` 提交新生成的 opaque Base64URL writer secret。Java 授予并返回数据库 generation 和过期时间,且只保存 secret hash。journal commit 与 renew 会绑定 tenant、workspace、Session、writer 身份、generation 及未过期的数据库时间 lease;读取 restore head、transaction page 和 resource 也要求当前未过期 secret,确保被接管的旧 Harness 无法继续读取私有上下文。seal 保持幂等;只有在尚未被更高 generation 取代时,它才可关闭已过期 grant。相同 writer 与 secret 重试会续租同一未过期 generation;显式 seal 或 lease 过期后才允许更高 generation。生产部署除 scoped bearer 外还需 mTLS 或等价服务身份;`tenantId` 只是 scope,不是鉴权凭据。 + +`recovery:block` 是从 `READY` 到三个阻塞恢复状态之一的单向 fenced 转移。它要求当前 writer 身份、generation、secret 及未过期的数据库时间 lease。同一 status/detail 重试保持幂等;不同阻塞原因不能覆盖第一个原因,该路由也不能解除阻塞。 + +私有 Store 前缀下的所有响应均携带 `Cache-Control: no-store`;数据库查询后还会对 tenant、workspace、Session 和 resource ID 的原始值做精确比较,避免不区分大小写的 MySQL collation 扩大 scope。 + +transaction 读取会先按 revision 与 byte-length 元数据分页,再拉取未编码事务字节总量不超过 8 MiB 的完整记录,因此 item limit 不会把单次恢复响应放大到数百 MiB。 + +恢复读取对未知 lifecycle/recovery 状态、不安全的 head 计数、revision 缺口以及 head/transaction 不一致统一 fail-closed;这些情况会报告存储损坏,而不会返回残缺权威。 + +`transactions:commit` 携带待提交 inline resources,并在它们首次被 journal 引用的同一 MySQL 事务中写入。可选的 `latestCheckpointResourceId` 必须指向本事务引用的 `managed-checkpoint` 资源,并与 head 原子推进。已实现路径接收不超过 64 KiB 的原始资源,限制单事务 inline 总字节数,校验长度与 SHA-256,并对更大资源返回 `managed_session_oss_disabled`。D2 的 `resources:allocate` 与 `resources/{resourceId}:finalize` 签名对象流程尚未实现。Standalone 和普通 daemon Session 仍选择 local adapter;只有私有 Hosted Harness 创建路径可以选择 remote adapter。 + +## 8. 提交协议 + +writer acquire 会先创建 revision-zero 的 fenced head,此时尚无 journal 字节可见。随后 `session.create` genesis commit 在一个 MySQL 事务中写入精确的 engine/header transaction、inline resource 正文、初始资源引用,并把 head 推进到 revision one。被遗弃的空 head 不代表公共 Session 已创建,只能在 seal 或 lease 过期后由更高 writer generation 接管。后续每个语义事务按以下流程执行: + +1. Core 校验 command、actor、expected sequence、records 和资源闭包。不超过 64 KiB 的资源暂存在 scoped Harness handle 中;较大资源以不可变 OSS Object 上传并 finalize。两者此时都尚未从 Session journal 可见。 +2. Harness 调用 `transactions:commit`,携带精确 record 字节、resource refs、expected journal revision、expected committed sequence、previous commit digest、writer generation、activation epoch、operation、command ID 和 content digest。 +3. Store 在同一个 MySQL 事务内锁定 head,校验未过期 generation 与 activation,查询原幂等回执,校验预期 head 和全部资源,插入待提交 inline resource 字节、journal transaction 与 resource refs,最后推进 head。 +4. 只有提交成功后,Harness 才确认私有事件或报告 Turn 成功终态。响应丢失时使用原 command ID 和 content digest 重试并返回原回执。 +5. 本地缓存和公开/SSE 投影在私有提交后更新;它们失败不能回滚或重复私有事务。 + +OSS Object 上传后数据库提交失败,会留下未引用的不可变孤儿。恢复看不到它;确认发布者已被 fencing 并超过安全窗口后才可回收。Inline 提交失败不会留下 resource row。已提交 journal head 绝不能引用尚未 finalize 的 Object。 + +## 9. 打开与恢复协议 + +1. Java 先 fencing 原 generation,再发放新 writer generation。lease 过期允许数据库产生更高 generation,但不能证明旧工具副作用已停止;Runtime 派发仍使用自己的 generation 门禁。 +2. Harness 读取 journal head 和 RestoreBundle,其中包含最新已验证 checkpoint、checkpoint 后的已提交尾部、未决 command 及资源 manifest。 +3. Harness 核验 commit 摘要链、record 字节、checkpoint digest 和全部必需资源。Harness Pod 可按 digest 缓存不可变资源。 +4. Runtime Broker 对每个未结算 `executionCallId` 对账。结果未知则进入 `BLOCKED_EXECUTION`,不能因为旧 Pod 消失就重新执行。 +5. 核验 Workspace 身份与快照/挂载。缺少 Workspace 时进入 `BLOCKED_WORKSPACE`;能读 transcript 不等于能继续执行。 +6. 只有 `READY` 的恢复才能安装新 activation 并允许模型/工具推进。资源缺失或损坏进入 `BLOCKED_RESOURCE`;安全场景仍可提供只读历史。 + +已实现的 tool-intent 边界为第 4 步建立了可对账身份:Harness 先让 Broker 幂等创建 `PREPARED` execution 记录,把工具名、返回的 Broker `executionCallId` 与 Runtime Session binding 提交到 `await_runtime`,只有提交成功后才调用显式 start 路由。读取 `PREPARED` execution 不会触发派发,取消会在没有副作用的情况下结算;原 create-and-start 路由继续保持兼容。冷加载后,Core 会重建待处理 execution,provider 可直接用持久化的 Harness、Runtime Session 与 execution 身份查询 Broker,而不依赖旧进程的内存 entry。恢复流程会启动原 `PREPARED` 身份,轮询 `EXECUTING` 或 `CANCEL_REQUESTED` 直到结算,把不可变结果引用提交进 `results_ready` checkpoint,并暴露绑定 checkpoint/activation 的私有 continuation 路由。Java 持有 Turn dispatch lease 时,会绑定替代 Harness boot,先以 attachment 水位持久替换公共 event epoch,再使用原公共 `promptId` 调用 continuation 路由,在响应后推进持久 cursor,并在不调用普通 Prompt submission 的情况下流式转发新 epoch。Broker 的结构化 `UNKNOWN` 会原样保留而不会被转成重试,当前 fenced Harness writer 会写入 `BLOCKED_EXECUTION`,Java coordinator 会在 Harness submit 前终止公共 Turn。进入阻塞状态后,Harness 会跳过 Store 必须拒绝的 activation-release 语义事务,但仍立即 seal writer,不让所有权一直残留到 lease 到期。 + +这条已实现恢复链路刻意只支持一个 durable pending execution。多工具恢复需要一种批量 checkpoint,把每个工具调用映射到各自不可变结果;把同一个结果引用分配给多个工具是错误的。公共 Turn 已经处于 `CANCELLING` 时仍然 fail-closed,因为恢复态取消还必须同时消费或结算私有 `results_ready` checkpoint。Java 的替代 event epoch 现在采用 write-ahead 顺序:continuation 调用前崩溃可以安全重试,调用准入后崩溃也不会让旧 epoch 继续留在长期状态中。已消费的工具回执在写入 `turn_settled` 之前仍可被替代 Harness 读回,且不会重新派发工具;回执正文不可读时,从 Broker 已结算结果重建。`npm run test:e2e:managed-continuation-failover` 会在 continuation 写出第一段公开文本后强杀 Harness,并检查工具只执行一次、替代 Harness 再发一次 continuation、公开记录只保留替代答案、且 Turn 只有一个终态。多工具和恢复态取消仍是 D3 门禁。 + +首个远端实现可以分页读取完整已提交 journal。压缩属于后续按测量触发的优化:后台任务把连续前缀打包成不可变压缩 OSS Object,校验后在 MySQL 原子发布 manifest 和水位,经过宽限期后再删除覆盖的 SQL blob。恢复合并已验证 pack 与 SQL 热尾;OSS Object listing 永远不是顺序或完整性的权威。 + +## 10. 延迟与可用性 + +长期存储链路不能重新把 Runtime 冷启动放进首 token: + +- 尽量在第一条 Prompt 前完成 Session definition 和 root snapshot 的暂存或发布。 +- Prompt input 在模型推理前提交。普通的不超过 64 KiB 的 Prompt 及其 journal records 只使用一次有界内部存储事务,不等待 OSS PUT 或 Pod 启动;更大输入明确走 OSS 路径。 +- 模型推理和 Runtime prepare 继续并行,只有第一次真实工具调用才等待 Runtime。 +- 不逐 token 写私有 journal;在 input admission、model attempt/result、工具派发前 intent、模型消费前 receipt、checkpoint 和 Turn settlement 等语义边界提交。 +- 最终消息和 checkpoint 事务持久后才发出成功终态。 +- 本地缓存可减少恢复读取,但不能放宽提交 ACK。 + +测量新增 TTFT、commit p50/p95/p99、恢复延迟、每 Turn MySQL 字节、OSS 请求/字节和缓存命中率。生产前使用这些数据验证 64 KiB 分界;以后调整该值需要带 storage version 的兼容决策。本文不承诺固定延迟数字。 + +MySQL 不可用时停止接受新私有提交并实施有界背压。在 durable admission 之前继续模型或工具副作用会破坏恢复契约。OSS 不可用会阻塞需要新资源的操作;已经提交且资源仍可通过正常冗余/缓存读取的历史不受影响。 + +## 11. 保留、删除与安全 + +- 公共 SSE replay 保留期与私有 Session 保留期分离;公共 delta 过期不能删除私有模型上下文或 checkpoint。 +- 首版不自动删除任何已引用私有资源。宁可安全占用空间,也不能制造恢复缺口。 +- 删除 Session 时先封闭准入、对账 execution、提交 tombstone/cleanup plan 并删除数据库引用;Object 删除保持幂等,并在配置的宽限或合规保留期后执行。 +- 启用 OSS 版本控制和服务端加密;不可变写使用禁止覆盖请求头。版本控制可防误覆盖,但不能替代数据库提交点。[OSS overwrite protection and limits](https://help.aliyun.com/en/oss/user-guide/limits)、[OSS versioning](https://help.aliyun.com/en/oss/user-guide/manage-objects-in-a-versioning-enabled-bucket) +- 私有 journal/resource 表使用不同于公共 reader 的数据库角色。所有读取均校验可信 tenant 和 Session scope。原始 records、签名 URL、凭据、工具参数和模型上下文不得进入公共事件或普通应用日志。 +- 备份与容灾必须同时覆盖 MySQL 和 OSS,使恢复点包含 journal head 引用的全部 Object。数据库恢复点新于可用 Object 副本时,不构成有效恢复点。 + +## 12. 落地顺序与可评审改动 + +### D0:存储接缝,无行为变化 + +状态:已在当前 feature 分支实现。 + +- 引入 `ManagedSessionJournalStore` 与 `ManagedSessionResourceStore` 契约。 +- 将现有 JSONL 扫描/追加和资源文件放进本地 adapter。 +- 原 authority、projection、restore、writer conflict 和 corruption 测试不改语义地在本地 adapter 上通过。 + +退出条件:standalone 行为和字节不变,Core 不引入 Java 或 OSS 依赖。 + +### D1:Java 长期 Store 与契约客户端 + +状态:D1a 与 D1b 均已实现。 + +- D1a 已实现:Flyway V4 四张私有表;Spring 内部 API;基于数据库时间的 lease/generation fencing;head CAS 与 checkpoint 指针原子推进;幂等回执;精确 record bytes 存储与校验;分页恢复读取;以及 OSS fail-closed 的事务化 `MYSQL_INLINE` 资源。 +- D1b 已实现:配套 TypeScript HTTP journal/resource adapter、scoped writer 获取/续租/seal、精确事务重建与校验、结构化 Java 错误、inline 资源暂存,以及不使用本地 transcript 的关闭/重开测试。共享 fixture 现在跨两种语言固定 header、限额、UTF-8/JSONL 精确字节、SHA-256 摘要、请求元数据和 Java 错误分类。 +- 独立进程证明会让多个子 JVM 连接一个临时真实 MySQL schema:writer A 提交后在返回 ACK 前直接退出;数据库时间 lease 过期后,writer B 取得 generation 2 并恢复已提交事务和 inline 资源。A 对同一 command 的重试会得到原回执,而其新写入会被 fencing。至此 D1 Store 边界退出条件已满足。下述 D2 settled Session 证明进一步强杀真实 Hosted Harness 与 Java owner,并完成下一轮 Turn。 +- D2a 已为新建私有 Hosted Harness Session 选择该 backend。Java 下发 tenant、workspace、writer 身份、endpoint 和 lease 时长,Harness 自己生成 writer secret;普通 daemon 路由和不可信 ACP parent 都会拒绝该描述。 + +退出条件:两个共享 MySQL 的 Java 实例能拒绝旧 writer,并在提交响应丢失后返回同一回执。 + +### D2:OSS 资源与 Hosted 路由 + +- 状态:新 Session 路由、Hosted 冷加载,以及 settled Session 的真实 Hosted owner failover 已支持 inline 资源;工具边界会在副作用启动前持久化完整且有序的 Broker execution 批次。包含已知 execution 的已准入在途 Turn 现在可以安装替代 Harness generation 与 write-ahead 公共 event epoch、逐项消费原结果,并且不重放 Prompt 或工具就继续。处于取消态的替代 owner 会被动加载 checkpoint,取消原 execution identity,并且不触发模型 continuation,只发布一个 cancelled 终态。当前确定性多进程 E2E 已证明单 execution 在 durable `PREPARED`/`await_runtime` 之后、物理执行开始之前的崩溃接管;对应的多 execution 和恢复态取消进程证明仍待完成。OSS、continuation 准入后的 Harness 再次崩溃与事件重建,以及其余在途故障矩阵仍待完成。 +- 为超过 64 KiB 的资源实现 `OSS_OBJECT` 路径:allocate/upload/finalize/read、scoped signed URL、摘要校验、加密和孤儿盘点。 +- 仅新建 Hosted Managed Session 选择远端后端;既有本地 Session 继续本地运行,不迁移、不双写。 +- Hosted `loadSession` 会下发与 create 相同作用域的 Store 描述。ACP 子进程重新获取 durable writer,校验 journal 与 resource 闭包,投影 reader-facing records,并且不读取本地 transcript 就构建 runtime restore state。 +- 确定性多进程测试会启动 MySQL、Spring/Java、Hosted Harness、带本地进程 Runtime Provider 的内嵌 Broker 与假模型。第一轮结算后,它强杀 Java 和 Harness 进程树、删除两者旧本地 home、等待数据库 writer lease 过期,再启动替代 owner。替代 Harness 取得更高 writer generation,在同一公共 Session 上完成第二轮;第二次模型请求同时包含第一轮 prompt 与 answer。测试还核验 journal revision、committed sequence、公共终态数量与 Harness boot ID 均推进,且未读取已删除的 Harness 磁盘。 +- 在途变体会在 Harness 已提交精确 `executionCallId` 和 `await_runtime` checkpoint 后阻塞 Broker start,强杀原 Java 与 Harness 进程树、删除旧 Harness 磁盘,再放行替代 owner。它断言原 execution 以 dispatch generation 1 到达 `SETTLED`,物理副作用恰好一次,模型收到一次初始请求和一次回执 continuation,公共 Turn 只产生一个终态事件。该测试有意小于完整故障矩阵:尚未覆盖 continuation 准入后的再次崩溃;多个和已取消 execution 的语义已有协议与 coordinator 测试,但还没有被该进程级 harness 证明。 +- 已准入在途 Turn 只有在原有序批次中的每个 execution 都有已知 durable outcome 时才允许自动恢复;任一 `UNKNOWN` 都会阻塞整批且不重放。Java 在更换任一 owner 前,必须持有 active dispatch lease,并匹配预期的旧 Harness boot/event epoch、替代 attachment 水位与 checkpoint/activation 身份。恢复态取消使用独立的被动加载和 checkpoint-bound cancel 操作,使检查本身不会启动 prepared execution;它等待原 execution settled,并且绝不准入模型 continuation。无法重建长期事件的 continuation 仍然 fail-closed。空闲或尚未准入的恢复不会放宽这些 fence。 + +退出条件:删除原 owner 文件系统后,由另一个 Harness 无历史缺失地恢复同一 settled Session,这一 inline 资源子条件现已验证。只有同一资源闭包覆盖 OSS 资源与 checkpoint 后,D2 才算完整完成。 + +### D3:恢复门禁与生产证据 + +- Runtime dispatch fence、恢复查询、有序多 execution continuation 和恢复态取消已实现:`executions:prepare` 只预留每个 durable Broker 身份而不派发,Harness 原子提交完整有序批次,再由 `executions/{executionCallId}:start` 执行幂等派发;冷启动 Harness 会启动或轮询这些精确 execution 而不重放。每个 settled 回执保留自己的 execution identity 与 ordinal,只有整批全部 settled 后私有 checkpoint 才推进到 `results_ready`;随后 Java 在 Turn dispatch lease 下,把预期 writer activation 绑定到替代 Harness boot,并在请求 checkpoint-bound continuation 或 cancellation 前持久安装 attachment event 水位。 +- Core 与 Broker 能够保留并查询稳定身份;`UNKNOWN` 分支会先持久写入 `BLOCKED_EXECUTION`,再由 Java 终止公共 Turn。真实进程证明现已覆盖单 execution 从 `PREPARED`/`await_runtime` 到 `SETTLED`/`results_ready` 的接管边界。剩余恢复工作包括多 execution 和恢复态取消的确定性多进程证明、continuation 中途 Harness 崩溃后的长期事件/checkpoint 重建、Workspace 恢复,以及其他边界的进程级故障注入。 +- 增加保留/tombstone 流程、指标、告警、备份恢复演练和故障注入。 + +退出条件:下列故障矩阵在实际 MySQL 和 OSS 产品上通过。只有这时 hosted 模式才能承诺跨 Pod 自动恢复。 + +## 13. 验收矩阵 + +| 场景 | 必需结果 | +| ---------------------------------------- | ------------------------------------------------------------------------------------ | +| 两个 Harness worker 同时取得同一 Session | 只有一个 generation 可提交;旧 worker 在任何字节可见前收到 conflict | +| Commit 成功但 HTTP 响应丢失 | 原 command ID 与 digest 返回原回执,不重复 sequence 或内容 | +| 相同幂等键但内容不同 | conflict 并告警,两份 payload 都不能覆盖另一份 | +| Object 上传后、SQL 提交前崩溃 | Session 看不到该 Object;它是可安全回收的孤儿 | +| 资源为 64 KiB 或 64 KiB 加 1 字节 | 前者使用 `MYSQL_INLINE`,后者使用 `OSS_OBJECT`;两者使用相同 `DurableRef` 和摘要校验 | +| SQL 提交后、cache/SSE 前崩溃 | 恢复读到已提交事务;cache 和公共投影追赶但不重写事务 | +| Harness Pod 与本地盘删除 | 新 Harness 恢复 journal、checkpoint 和全部引用资源 | +| 资源缺失或摘要不符 | `BLOCKED_RESOURCE`;不从空状态或未经证明的旧状态继续 | +| 工具结果未知 | `BLOCKED_EXECUTION`;不自动重复调用工具 | +| owner 在提交 `await_runtime` 后崩溃 | 替代 owner 只启动一次同一 Broker execution,消费其回执并只产生一个公共终态事件 | +| Workspace 快照/挂载缺失 | 历史仍可读,执行状态为 `BLOCKED_WORKSPACE` | +| 伪造 tenant 或 Session scope | 返回 Object URL 或私有字节前拒绝请求 | +| MySQL 或 OSS 不可用 | 有界背压和明确失败,不发出虚假 durable ACK | +| 本地 cache 损坏或缺失 | 重建或忽略;以 durable head 与 resource digest 为准 | +| 活动工作期间删除 Session | 封闭准入,execution 结算或阻塞,提交 tombstone,再安全回收资源 | + +## 14. 待确认的部署参数 + +生产启用前需要选择并测量: + +- 私有 Session 保留期和 legal hold 要求; +- 活跃 Session/Turn 速率,以及每 Turn journal/resource 平均和最大字节数; +- writer lease 时长与续租间隔; +- MySQL 持久化/复制配置与允许的恢复点、恢复时间; +- OSS region、冗余、版本控制、加密密钥策略、生命周期存储级别和跨 region 恢复; +- 最大恢复尾部、checkpoint 频率,以及是否需要把 SQL journal 打包进 OSS; +- Workspace 持久化或快照 provider;这是可执行恢复的独立前置条件。 + +这些参数不改变已选定的归属与提交协议,只决定容量和运维策略,不决定本地 Pod 磁盘是否是权威。 diff --git a/docs/design/2026-09-24-fresh-startup-performance.md b/docs/design/2026-09-24-fresh-startup-performance.md new file mode 100644 index 00000000000..fbedd4fda86 --- /dev/null +++ b/docs/design/2026-09-24-fresh-startup-performance.md @@ -0,0 +1,371 @@ +# Fresh-Startup Performance: Time to Typeable and RSS + +[English](2026-09-24-fresh-startup-performance.md) | [简体中文](2026-09-24-fresh-startup-performance.zh-CN.md) + +Status: implemented. Section 9 records the design decisions. + +## 1. Problem + +On a clean machine, `qwen` takes about 2.5 s before the user can type. For +the first ~2 s the terminal is blank, and the process tree holds about 575 MB +of RSS. `qwen -p` takes about 2.1 s before its first model request leaves the +process, even against a local model server that answers instantly. + +Nothing measured this. The existing startup profiler starts its clock inside +the last process, after module loading. It reported about 1.8 s to +`input_enabled` while the user actually waited about 2.8 s, and no CI job +tracks interactive startup. + +## 2. Method + +We followed the approach in +[How we made claude.ai 3x faster in two weeks](https://claude.dev/blog/how-we-made-claude-ai-faster/) +(Anthropic, 2026). Its principles, as applied here: + +1. **Measure the moment users feel, at p75.** The user-felt moment is "a + typed key shows up in the input box", not an internal mark. +2. **Measuring makes a problem tractable.** Each slow stretch was traced, + then shown with a number that a change could move, before anything was + changed. +3. **Wall-clock is the truth but too noisy to gate on.** It is backed by + deterministic proxies: process count, JS bytes loaded, and CPU + instructions under Valgrind. Each proxy was checked against wall-clock + before being trusted. +4. **Throw out metrics that are flaky or don't correlate.** They are listed + in section 8. +5. **Guard against instability after "usable".** In their case that was + layout shift after load; here it is the screen changing after TTI. + +### 2.1 Clean environment + +- **Install.** `corepack pnpm install --frozen-lockfile`, then build and + bundle. Node 22, Linux, 4 vCPU. +- **Fresh state per run.** Every run gets new `HOME`, `QWEN_HOME`, + `QWEN_RUNTIME_DIR`, `XDG_*` and `TMPDIR` directories. The workspace is an + empty git repo, and `settings.json` holds only the auth type and model + name. The environment is reduced to `PATH`, `LANG` and `TERM`. +- **Model.** A loopback OpenAI-compatible stub that answers instantly. +- **Entry point.** The real `qwen` bin entry, `scripts/cli-entry.js`. +- **Terminal.** A node-pty terminal at 120×40, with the screen reconstructed + by `@xterm/headless`. It answers DA1 and OSC 11 the way a modern terminal + does; a "silent" variant answers nothing. + +### 2.2 Metrics + +| ID | Metric | Kind | Definition | +| --- | ------------------------------ | ------------------------ | -------------------------------------------------------------------------------------------------------------------- | +| J1 | Interactive TTI | wall-clock, p75 | From spawning `qwen` until "Type your message" is visible, a key has been typed, and that key shows in the input box | +| J2 | Headless TTFR | wall-clock, p75 | From spawning `qwen -p` until the first request reaches the model server | +| J3 | First content | wall-clock, p75 | From spawn until the first visible glyph (how long the screen stays blank) | +| M1 | RSS at TTI | memory | Summed RSS of the process tree at TTI; for `-p`, peak RSS until exit | +| S1 | Post-TTI stability | rate | Share of launches where the screen changes after TTI with no input | +| D1 | Node boots before TTI | deterministic (strace) | Number of Node processes started before TTI | +| D2 | JS bytes loaded before TTI | deterministic (strace) | Bytes of JS read, summed over all processes | +| D3 | CPU instructions, `-p` to exit | deterministic (Valgrind) | Measured with ASLR off, V8 `--predictable --hash-seed=1 --random-seed=1`, and a warm compile cache | + +**Proxy validation:** + +- **D3 tracks wall-clock.** Across `--version`, `--help`, `-p`, and `-p` with a + warm cache, D3 correlates with wall-clock at Pearson r = 0.9997, and + repeated runs agree to 0.008–0.04%. +- **D3 cannot see waits.** A terminal that never answers the OSC 11 probe + adds 165 ms of wall-clock with no extra instructions. + +### 2.3 Experiment protocol + +- **One hypothesis per experiment.** The evidence behind each one comes from + a CPU profile, strace, the esbuild metafile, or a screen diff. +- **Interleaved A/B.** Each change ran against the build before it in + interleaved ABBA pairs: 20–30 pairs, each run in a fresh HOME. We report + the paired median difference and how many pairs the change won. +- **Noise floor.** An A/A run with both builds laid out at the same directory + depth showed no bias. The paired median was about 0, with roughly 50/50 + wins. A first A/A attempt had exposed a 24 ms path-depth bias, which the + equal-depth layout fixed. +- **Keep or drop.** A change was kept only if its metric moved. A regression + on any metric was root-caused before continuing. + +## 3. Baseline: where the time went + +| Finding | Evidence | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| **Process chain.** Four Node processes before TTI: the launcher, the CLI, a relaunched copy of the CLI, and `npm view` for the update check. The first CLI loads the whole module graph (~700 ms) and then relaunches itself. | strace; running as a single process gives −33% TTI and −240 MB | +| **Compile cache.** Interactive and `-p` never enable Node's compile cache; only the `serve`/`mcp`/`--help`/`--version` fast paths do. | Cold-cache runs equal warm-cache runs | +| **Import-time side effects.** 10 synchronous `execSync('command -v ')` calls; 7 `sh`+`ps` spawns walking the IDE process ancestry; `npm view` during startup. | CPU-profile caller stacks | +| **Two-byte JS source.** Comments containing `—`/`─` make 21 of the 25 MB of pre-TTI JS non-ASCII, so V8 stores it as two-byte strings; 64 ms go to UTF-8 decode. | Byte scan; CPU profile | +| **Oversized module graph.** The whole `review` command tree (1.5 MB) is evaluated to parse arguments, and all ~190 highlight.js grammars are evaluated although only `common` is registered. | esbuild metafile import chains | +| **Late footer update.** The footer's git branch arrives after TTI, and Ink redraws the whole screen. | Screen diff after TTI | + +## 4. Goals and scope + +**Goals.** Improve J1–J3 and M1 on a fresh start without regressing S1. + +**In scope.** The plain interactive TUI, `-i` and `-p`, on POSIX. + +**Unchanged:** + +- ACP and IDE integrations; +- stream-json, `--json-fd`, file input and dual output; +- sandbox; +- Windows process handling; +- `advanced.autoConfigureMemory=true`; +- sessions where `.env` files or `settings.env` injected values; +- the Bun standalone flavor's launcher. + +**Out of scope for this PR:** + +- resume and first-turn latency; +- field telemetry; +- CI gates. + +## 5. Changes (experiment log) + +Every Δ is a paired median against the previous build; "pairs" is how many +pairs the change won. + +| # | Change | Result | Decision | +| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | --------------------- | +| E1 | Skip the relaunch when it adds no Node flags. Trust restarts re-exec in place (`execve`); `/update` runs in-process. | TTI −767 ms (20/20); first content −707 ms; RSS −190 MB; `-p` −748 ms | Kept | +| E2 | `module.enableCompileCache()` before loading the main module graph | Warm: TTI −358 ms (20/20), `-p` −296 ms, RSS +10 MB. Cold first launch: TTI +104 ms | Kept | +| E3 | Probe for editors when the editor dialog asks, not at import time | First content −47 ms (19/24); TTI −32 ms (14/24) | Kept | +| E4 | Walk the IDE process ancestry only when `TERM_PROGRAM=vscode` | TTI −50 ms (20/24); CPU −120 ms; **S1 regressed**, 8/24 → 22/24 | Kept together with E5 | +| E5 | Resolve the git branch before the first frame | S1 17/24 → 0/24; time neutral | Kept | +| E6 | esbuild `minifyWhitespace`, with `keepNames` still on | RSS −23 MB (24/24); first content −48 ms (21/24); TTI −36 ms (17/24); `-p` −43 ms and −21 MB | Kept (decision 3) | +| E7 | Start the update check 3 s after render | TTI −56 ms (20/24); RSS at TTI −81 MB (24/24); CPU −335 ms | Kept | +| E8 | Under Node on POSIX, the bin launcher imports the CLI in-process. `gc` is exposed with `v8.setFlagsFromString`, and exit code 44 relaunches from an exit hook. | TTI −48 ms (20/24); first content −73 ms (23/24); RSS −49 MB (24/24); `-p` −48 ms and −50 MB | Kept | +| E9 | Register the `review` command only when `review` is in argv | RSS −8 MB (24/24); `-p` −14 MB; time neutral | Kept | +| E10 | Load only lowlight's `common` grammars (chunk 1.5 MB → 216 KB) | RSS −1.5 MB; time neutral | Kept (improves D2) | +| — | Extension-store lock, suspected of costing 200 ms in `config.initialize` | Uncontended: the gaps are a busy main thread, not the lock | No change | + +### 5.1 The relaunch + +The relaunch was the biggest single cost, and nobody could say what it was +for. + +**What it did:** + +- **Heap limit.** It adds `--max-old-space-size`, but only when + `advanced.autoConfigureMemory` is `true`. The default is `false`, so by + default the child got no extra flags. +- **Supervision.** The parent stayed alive only to handle three events: + - exit 42: a folder or IDE trust change restarts the session; + - exit 43: `/update` asks for a relaunch; + - an IPC "update on exit" message. +- **Environment hand-off.** It passed along which variables came from `.env` + or settings. This matters only across a process boundary. + +**What changed.** On POSIX, when there are no memory flags, no `.env` file or +`settings.env` injected a value, and the mode needs no separate supervisor, the +CLI marks itself as supervising in-process: + +- `relaunchApp()` runs exit cleanup and then `execve`s the same command line. + It adds `--expose-gc` if gc had been exposed at runtime. +- `relaunchForUpdate()` runs the update handler in-process and exits with its + code. When that code is 44, the launcher's exit hook relaunches. +- An update requested for exit is installed after the session ends cleanly + (exit code 0), once, even if the user quits again meanwhile. On success the + exit code is 44 and the launcher's exit hook reopens the new version, as the + supervising parent did. +- Every other mode keeps the supervised relaunch. Its child gets + `--expose-gc` by the same rule, because the launcher no longer passes that + flag in argv. + +**Env files.** Modules that read the environment at load time, and Node itself +for `NODE_EXTRA_CA_CERTS`, only see values from `.env` or `settings.env` in a +fresh image. Such sessions therefore relaunch as on `main`, which applies the +same rule to one-shot runs (#12602). + +**Bun.** The Bun standalone flavor has no `v8.setFlagsFromString`, so its +launcher keeps the child spawned with `--expose-gc`, as on Windows. + +## 6. Results + +Baseline `main` @ 906418a against the final build, 30 interleaved pairs, clean +HOME, 4 vCPU: + +| Metric | Baseline p50 | Final p50 | Change | Pairs won | +| ------------------ | ------------ | --------- | ------------ | --------- | +| J1 interactive TTI | 2546 ms | 1179 ms | −54% (2.16×) | 30/30 | +| J3 first content | 1973 ms | 746 ms | −62% (2.65×) | 30/30 | +| J2 headless TTFR | 2149 ms | 940 ms | −56% (2.29×) | 30/30 | +| M1 RSS at TTI | 574 MB | 231 MB | −60% | 30/30 | +| M1 `-p` peak RSS | 294 MB | 218 MB | −26% | 30/30 | +| S1 post-TTI change | 12/30 | 0/30 | — | — | + +**Headline.** The geometric mean over J1–J3 is **2.36× faster**, and RSS is +2.48× lower. + +**Other scenarios:** + +- First launch with an empty compile cache: TTI −40% (15/15 pairs). +- A terminal that ignores OSC 11: TTI −49% (15/15 pairs). + +**Independent reproductions** (reported on the PR): + +- Linux arm64, 10 vCPU, 30 pairs: J1 −49%, J3 −57%, J2 −53%, M1 −60%, S1 + 26/30 → 0/30; every pair won. +- macOS on Apple Silicon, 8–12 pairs: J1 −42%, J3 −48%, J2 −45%, RSS −54%; + every pair won. + +**Deterministic proxies:** + +| Proxy | Interactive | Headless | +| ------------------ | -------------- | -------------- | +| D1 Node boots | 4 → 1 | 3 → 1 | +| D2 JS loaded | 40.8 → 16.0 MB | 35.8 → 13.3 MB | +| Other subprocesses | 29 → 3 | 24 → 8 | + +**D3.** The final `-p` count is 8.195 B instructions. The baseline counts at +least 9.37 B; that is a lower bound, because Valgrind drops the process image +that `execve` replaces. + +## 7. Risks and behaviour changes + +1. **Update-on-exit** (standalone and `updateCommand` installs). Kept: the + session installs the update after it ends cleanly and the launcher reopens + the new version, as on `main`. The Settings dialog's restart-required exit + does not install it. +2. **Trust restart, then `/update`.** After an in-place trust restart, a later + `/update`, or an update requested for exit, installs the update but exits + with code 44 instead of reopening, because the new image has no launcher + exit hook. It needs folder trust, a trust change and an update in one + session; the next `qwen` runs the new version. +3. **Stack traces.** With `minifyWhitespace`, frames keep function names and + exact line and column, but lines average about 2 KB, so a line number alone + no longer points at readable code. Neither build ships source maps. +4. **Short sessions.** The update check waits 3 s, so sessions shorter than + that skip it; the next launch checks. +5. **IDE process walk.** It now runs only in VS Code terminals. This loses + nothing: companions older than 0.5.1 do not write the IDE's identity into + their connection file, so in a terminal that rewrites `TERM_PROGRAM` (for + example tmux) `main` could not detect the IDE for them either. +6. **Compile cache.** It lives in the OS temp directory (about 9 MB), and the + first launch after an install or upgrade is about 100 ms slower. The + launcher exports `NODE_COMPILE_CACHE`, so relaunched children and tool + subprocesses share it, as on `main`. +7. **Process chain.** On POSIX, `QWEN_CODE_LAUNCHER_PID` is no longer set. + Only the Windows standalone updater reads it, and Windows keeps the spawned + child. +8. **Core-gated areas.** The change touches `packages/core/src/**` and + `packages/cli/src/config/**`, which need maintainer review. +9. **Env-file sessions.** When `.env` files or `settings.env` inject values, + interactive and `-p` sessions relaunch as on `main` and keep its startup + cost. +10. **Bun flavor.** Its launcher keeps the spawned child, so it does not get + E8's gains. +11. **Signal exit status.** On SIGTERM or SIGHUP the process exits with code + 143 or 129 instead of being killed by the signal. A shell's `$?` is the + same; a parent that inspects the signal sees a normal exit. + +## 8. Rejected metrics + +| Metric | Reason for rejection | +| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| External frame or write counts | CV 12–18%, driven by PTY chunking | +| Raw subprocess counts from strace | Not repeatable: `git` calls vary, and the `ps` walk depends on process-tree depth | +| Interactive instruction counts under Valgrind | Spread of about 10%, because timer-driven work scales with the slowdown; needs a benchmark hook that exits at `input_enabled` | +| Internal `to_input_enabled` as the headline | Misses about 1 s (35%) of what the user waits for; kept as a phase breakdown only | + +## 9. Decisions + +These were open while the PR was a draft. + +1. **Update-on-exit.** Restored in-process instead of the "Run /update" + fallback (section 5.1). +2. **Trust restart, then `/update`.** Accepted as a documented edge case + (section 7, item 2). Re-executing the launcher on restart would have to + handle the standalone shell launcher and the managed-version pin, which is + more than the edge case warrants. +3. **E6 stack traces.** Kept. Frames keep names and exact positions, and + neither build ships source maps; source maps can follow separately. +4. **Update-check delay.** Kept at 3 s after render. It no longer competes + with startup, and a shorter session checks on its next launch. +5. **IDE walk.** Kept; it loses nothing (section 7, item 5). +6. **Split.** One PR. If maintainers prefer, the process-chain change (E1+E8) + is the part to split out; the bugs found in review were all there. +7. **Harness and CI.** A follow-up PR (#12674) checks in the harness for + manual runs, with no CI gate. The interactive D1 and D2 stop at TTI, and + timer-driven work before TTI shifts with runner speed, so a gate would be + flaky. + +## 10. Validation + +**Build and static checks:** + +- `npm run build` and `npm run typecheck` pass. +- Lint and Prettier are clean on the changed files. +- `npm run check:serve-fast-path-bundle` passes. + +**Unit tests:** + +- cli: 973 tests across the touched modules; +- core IDE: 119 tests; +- `scripts/tests/cli-entry`: 10 tests. + +New tests cover: + +- in-process restart and update; +- relaunch routing; +- the IDE-walk gate; +- branch priming; +- the update-check delay; +- the in-process launcher and its exit-44 relaunch; +- `--expose-gc` on both relaunch paths when gc was exposed at runtime; +- the env-file relaunch rule; +- the Bun launcher path; +- update-on-exit after a clean exit, once, when the user quits twice. + +**Integration:** + +- The no-API-key suite passes (26 files, 196 tests). +- The interactive suite passes except 4 tests that need real credentials; the + baseline fails the same 4 in the same environment. + +**Manual:** + +- Drove the real folder-trust dialog: startup ran twice, the prompt was + typeable again, and the session stayed a single process. +- A fake install that exits 44 relaunched with its arguments preserved, `gc` + available, and the exit code propagated. +- A real `/update` on an `npm install -g` install, with a local registry + serving a newer version, installed it and relaunched into the new version. +- `global.gc` is available in the session process in these launch modes: + interactive, `-p`, ACP, stream-json, and `advanced.autoConfigureMemory`. This + holds on Node 22.23, on Node 22.3 (which has no `process.execve`), and after + a folder-trust restart. +- `qwen review --help` works. +- Update-on-exit on a pnpm global install, with a local registry serving a + newer version: after `/quit`, a double Ctrl+C, or a third Ctrl+D during + shutdown, the update was installed and the new version reopened, as on + `main`. +- With a value in `~/.qwen/.env`, interactive and `-p` sessions relaunch and + see the value at boot. +- Under Bun 1.3.14 and 1.4.2, the launcher reaches the CLI. + +## 11. Acceptance criteria + +The PR is ready when: + +- J1, J2 and J3 improve by at least 2× at p75 against `main` in the clean + environment, M1 improves by at least 50%, and S1 is 0. +- The decisions in section 9 are resolved and reflected in both language + versions of this document. +- CI is green on macOS, Linux and Windows. + +## 12. Follow-up + +These are not part of this PR: + +- **Render the prompt earlier.** `config.initialize()` still sits between first + paint and TTI (about 350 ms). Showing the prompt before it finishes is a + design change. +- **Trim the module graph further.** Candidates: `@google/genai` (581 KB) on + OpenAI-auth sessions, and the daemon SDK and channels code (about 670 KB). +- **Fix the spurious notice.** "Extensions changed on disk" appears on a fresh + HOME. +- **Add a render-commit counter** in the app, so "commits before TTI" can + become a gate. +- **Field telemetry.** Enable the reserved `qwen-code.startup.duration` + metric. +- **Benchmark harness** for manual runs (decision 7, #12674). +- **Source maps** for bundle stack traces, if needed (decision 3). diff --git a/docs/design/2026-09-24-fresh-startup-performance.zh-CN.md b/docs/design/2026-09-24-fresh-startup-performance.zh-CN.md new file mode 100644 index 00000000000..f6de445b750 --- /dev/null +++ b/docs/design/2026-09-24-fresh-startup-performance.zh-CN.md @@ -0,0 +1,262 @@ +# 全新启动性能:可输入时间与 RSS + +[English](2026-09-24-fresh-startup-performance.md) | [简体中文](2026-09-24-fresh-startup-performance.zh-CN.md) + +状态:已实现。第 9 节记录了设计决定。 + +## 1. 问题 + +在干净的机器上,`qwen` 大约需要 2.5 秒用户才能开始输入。前约 2 秒终端一片空白,整个进程树占用约 575 MB RSS。`qwen -p` 大约需要 2.1 秒,第一个模型请求才离开进程,即使对端是即时应答的本地模型服务也是如此。 + +此前没有任何度量覆盖这些问题。现有的启动剖析器在最后一个进程内部、模块加载完成之后才开始计时。它报告约 1.8 秒到达 `input_enabled`,而用户实际等待约 2.8 秒;也没有 CI 任务跟踪交互式启动。 + +## 2. 方法 + +我们遵循 [How we made claude.ai 3x faster in two weeks](https://claude.dev/blog/how-we-made-claude-ai-faster/)(Anthropic,2026)中的做法。其原则在本工作中的应用如下: + +1. **度量用户能感知的时刻,取 p75。**用户感知的时刻是"输入的按键显示在输入框中",而不是某个内部标记。 +2. **能度量的问题才能被解决。**每一段慢的环节都先被追踪,再用一个改动可以推动的数字把它呈现出来,然后才动手修改。 +3. **墙钟时间是真相,但噪声太大,不适合做门禁。**因此用确定性代理指标作为支撑:进程数、加载的 JS 字节数,以及 Valgrind 下的 CPU 指令数。每个代理指标在采信之前都与墙钟时间做过对照。 +4. **不稳定或不相关的指标一律丢弃。**见第 8 节。 +5. **防范"可用"之后的不稳定。**文章中是页面加载后的布局偏移,这里对应可输入之后屏幕仍在变化。 + +### 2.1 干净环境 + +- **安装。**`corepack pnpm install --frozen-lockfile`,然后构建并打包。Node 22、Linux、4 vCPU。 +- **每次运行使用全新状态。**每次运行都新建 `HOME`、`QWEN_HOME`、`QWEN_RUNTIME_DIR`、`XDG_*` 和 `TMPDIR` 目录。工作区是一个空的 git 仓库,`settings.json` 只包含认证类型和模型名。环境变量只保留 `PATH`、`LANG` 和 `TERM`。 +- **模型。**一个即时应答的本地回环 OpenAI 兼容桩服务。 +- **入口。**真实的 `qwen` bin 入口 `scripts/cli-entry.js`。 +- **终端。**120×40 的 node-pty 终端,屏幕由 `@xterm/headless` 重建。它像现代终端一样应答 DA1 与 OSC 11;"静默"变体不做任何应答。 + +### 2.2 指标 + +| ID | 指标 | 类型 | 定义 | +| --- | ------------------------- | ------------------ | ----------------------------------------------------------------------------------------- | +| J1 | 交互式 TTI | 墙钟,p75 | 从启动 `qwen` 起,到 "Type your message" 可见、已输入一个按键、且该按键显示在输入框中为止 | +| J2 | 无头 TTFR | 墙钟,p75 | 从启动 `qwen -p` 起,到第一个请求到达模型服务为止 | +| J3 | 首个内容 | 墙钟,p75 | 从启动到第一个可见字符(即屏幕保持空白的时长) | +| M1 | TTI 时的 RSS | 内存 | TTI 时刻进程树的 RSS 总和;`-p` 取退出前的峰值 RSS | +| S1 | TTI 后稳定性 | 比率 | 在没有任何输入的情况下,TTI 之后屏幕仍发生变化的启动所占比例 | +| D1 | TTI 前 Node 启动次数 | 确定性(strace) | TTI 之前启动的 Node 进程数 | +| D2 | TTI 前加载的 JS 字节数 | 确定性(strace) | 读取的 JS 字节数,对所有进程求和 | +| D3 | CPU 指令数,`-p` 直到退出 | 确定性(Valgrind) | 在关闭 ASLR、V8 `--predictable --hash-seed=1 --random-seed=1`、编译缓存已预热的条件下测得 | + +**代理指标验证:** + +- **D3 与墙钟时间一致。**在 `--version`、`--help`、`-p` 以及缓存预热后的 `-p` 上,D3 与墙钟时间的 Pearson 相关系数 r = 0.9997,重复运行的结果相差在 0.008–0.04% 以内。 +- **D3 看不到等待。**终端从不应答 OSC 11 探测时,墙钟时间增加 165 毫秒,但指令数没有增加。 + +### 2.3 实验流程 + +- **每个实验一个假设。**每个假设的依据来自 CPU 剖析、strace、esbuild metafile 或屏幕差异。 +- **交错 A/B。**每个改动都与其前一版本按 ABBA 交错成对运行:20–30 对,每次运行使用全新 HOME。报告成对差值的中位数,以及改动在多少对中胜出。 +- **噪声下限。**将两个版本放在相同目录深度下做 A/A,未见偏差:成对中位数约为 0,胜率约各半。第一次 A/A 尝试曾暴露出 24 毫秒的路径深度偏差,采用等深布局后消除。 +- **保留或舍弃。**只有推动了指标的改动才被保留。任何指标出现回退,都先查明根因再继续。 + +## 3. 基线:时间花在哪里 + +| 发现 | 证据 | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------- | +| **进程链。**TTI 之前有四个 Node 进程:启动器、CLI、CLI 的重新启动副本,以及用于更新检查的 `npm view`。第一个 CLI 加载完整个模块图(约 700 毫秒)后又重新启动自身。 | strace;改为单进程运行后 TTI −33%、RSS −240 MB | +| **编译缓存。**交互式与 `-p` 从不启用 Node 编译缓存,只有 `serve`/`mcp`/`--help`/`--version` 快速路径会启用。 | 冷缓存运行与热缓存运行结果相同 | +| **导入时的副作用。**10 次同步 `execSync('command -v ')`;为遍历 IDE 祖先进程而进行的 7 次 `sh`+`ps` 启动;启动期间运行 `npm view`。 | CPU 剖析中的调用者栈 | +| **双字节 JS 源码。**注释中的 `—`/`─` 使 TTI 前 25 MB JS 中有 21 MB 为非 ASCII,V8 因此以双字节字符串存储;UTF-8 解码耗时 64 毫秒。 | 字节扫描;CPU 剖析 | +| **模块图过大。**解析参数时会求值整个 `review` 命令树(1.5 MB);虽然只注册了 `common`,却求值了全部约 190 个 highlight.js 语法。 | esbuild metafile 导入链 | +| **页脚更新过晚。**页脚中的 git 分支在 TTI 之后才到达,Ink 随之重绘整个屏幕。 | TTI 后的屏幕差异 | + +## 4. 目标与范围 + +**目标。**在不回退 S1 的前提下,改善全新启动时的 J1–J3 与 M1。 + +**范围内。**POSIX 上的普通交互式 TUI、`-i` 与 `-p`。 + +**保持不变:** + +- ACP 与 IDE 集成; +- stream-json、`--json-fd`、文件输入与双路输出; +- 沙箱; +- Windows 的进程处理; +- `advanced.autoConfigureMemory=true`; +- `.env` 文件或 `settings.env` 注入了值的会话; +- Bun standalone 版本的启动器。 + +**本 PR 范围外:** + +- 恢复会话与首轮延迟; +- 现场遥测; +- CI 门禁。 + +## 5. 改动(实验记录) + +所有 Δ 均为相对前一版本的成对中位数;"成对"表示改动胜出的对数。 + +| # | 改动 | 结果 | 决定 | +| --- | ----------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | --------------- | +| E1 | 当重新启动不增加任何 Node 参数时跳过它。信任变更后的重启改为原地 re-exec(`execve`);`/update` 在进程内执行。 | TTI −767 毫秒(20/20);首个内容 −707 毫秒;RSS −190 MB;`-p` −748 毫秒 | 保留 | +| E2 | 在加载主模块图之前调用 `module.enableCompileCache()` | 热缓存:TTI −358 毫秒(20/20),`-p` −296 毫秒,RSS +10 MB。冷缓存首次启动:TTI +104 毫秒 | 保留 | +| E3 | 编辑器对话框需要时才探测编辑器,而不是在导入时探测 | 首个内容 −47 毫秒(19/24);TTI −32 毫秒(14/24) | 保留 | +| E4 | 仅当 `TERM_PROGRAM=vscode` 时遍历 IDE 祖先进程 | TTI −50 毫秒(20/24);CPU −120 毫秒;**S1 回退**,8/24 → 22/24 | 与 E5 一起保留 | +| E5 | 在第一帧之前解析 git 分支 | S1 17/24 → 0/24;时间无变化 | 保留 | +| E6 | esbuild `minifyWhitespace`,`keepNames` 保持开启 | RSS −23 MB(24/24);首个内容 −48 毫秒(21/24);TTI −36 毫秒(17/24);`-p` −43 毫秒、−21 MB | 保留(决定 3) | +| E7 | 渲染后 3 秒才开始更新检查 | TTI −56 毫秒(20/24);TTI 时 RSS −81 MB(24/24);CPU −335 毫秒 | 保留 | +| E8 | 在 POSIX 上的 Node 中,bin 启动器在进程内导入 CLI。`gc` 通过 `v8.setFlagsFromString` 暴露,退出码 44 由退出钩子负责重新启动。 | TTI −48 毫秒(20/24);首个内容 −73 毫秒(23/24);RSS −49 MB(24/24);`-p` −48 毫秒、−50 MB | 保留 | +| E9 | 仅当 argv 中出现 `review` 时才注册 `review` 命令 | RSS −8 MB(24/24);`-p` −14 MB;时间无变化 | 保留 | +| E10 | 只加载 lowlight 的 `common` 语法(chunk 1.5 MB → 216 KB) | RSS −1.5 MB;时间无变化 | 保留(改善 D2) | +| — | 怀疑扩展存储锁在 `config.initialize` 中耗费 200 毫秒 | 锁无竞争:空隙来自繁忙的主线程,而不是锁 | 不改动 | + +### 5.1 重新启动 + +重新启动是最大的单项开销,却没有人说得清它的用途。 + +**它原本做什么:** + +- **堆上限。**它会加上 `--max-old-space-size`,但只在 `advanced.autoConfigureMemory` 为 `true` 时才加。默认值为 `false`,因此默认情况下子进程不会得到任何额外参数。 +- **监管。**父进程常驻只为处理三类事件: + - 退出码 42:文件夹或 IDE 信任变更,重启会话; + - 退出码 43:`/update` 请求重新启动; + - IPC "退出时更新"消息。 +- **环境变量交接。**它会传递哪些变量来自 `.env` 或设置。这只在跨进程边界时才有意义。 + +**改了什么。**在 POSIX 上,当没有内存参数、没有任何 `.env` 文件或 `settings.env` 注入值、且当前模式不需要单独的监管进程时,CLI 将自身标记为进程内监管: + +- `relaunchApp()` 执行退出清理,然后以相同命令行 `execve`。如果 gc 是在运行时暴露的,则额外加上 `--expose-gc`。 +- `relaunchForUpdate()` 在进程内运行更新处理函数,并以其返回码退出。当返回码为 44 时,启动器的退出钩子负责重新启动。 +- 请求在退出时安装的更新,会在会话正常结束(退出码 0)后安装,且只安装一次,即使用户在此期间再次退出。安装成功时退出码为 44,由启动器的退出钩子打开新版本,与原先的监管父进程一致。 +- 其他所有模式保留受监管的重新启动。由于启动器不再通过 argv 传递 `--expose-gc`,其子进程按同一规则获得该参数。 + +**env 文件。**在加载时读取环境变量的模块,以及读取 `NODE_EXTRA_CA_CERTS` 的 Node 本身,只有在全新进程映像中才能看到来自 `.env` 或 `settings.env` 的值。因此这类会话与 `main` 一样重新启动;`main` 对一次性运行也采用同一规则(#12602)。 + +**Bun。**Bun standalone 版本没有 `v8.setFlagsFromString`,因此其启动器与 Windows 一样,保留带 `--expose-gc` 启动的子进程。 + +## 6. 结果 + +基线 `main` @ 906418a 对比最终版本,30 个交错对,干净 HOME,4 vCPU: + +| 指标 | 基线 p50 | 最终 p50 | 变化 | 胜出对数 | +| ---------------- | --------- | --------- | ------------- | -------- | +| J1 交互式 TTI | 2546 毫秒 | 1179 毫秒 | −54%(2.16×) | 30/30 | +| J3 首个内容 | 1973 毫秒 | 746 毫秒 | −62%(2.65×) | 30/30 | +| J2 无头 TTFR | 2149 毫秒 | 940 毫秒 | −56%(2.29×) | 30/30 | +| M1 TTI 时 RSS | 574 MB | 231 MB | −60% | 30/30 | +| M1 `-p` 峰值 RSS | 294 MB | 218 MB | −26% | 30/30 | +| S1 TTI 后变化 | 12/30 | 0/30 | — | — | + +**总体。**J1–J3 的几何平均为**快 2.36 倍**,RSS 低 2.48 倍。 + +**其他场景:** + +- 编译缓存为空的首次启动:TTI −40%(15/15 对)。 +- 不应答 OSC 11 的终端:TTI −49%(15/15 对)。 + +**独立复现**(见 PR 讨论): + +- Linux arm64,10 vCPU,30 对:J1 −49%,J3 −57%,J2 −53%,M1 −60%,S1 26/30 → 0/30;每一对都胜出。 +- Apple Silicon 上的 macOS,8–12 对:J1 −42%,J3 −48%,J2 −45%,RSS −54%;每一对都胜出。 + +**确定性代理指标:** + +| 代理指标 | 交互式 | 无头 | +| ---------------- | -------------- | -------------- | +| D1 Node 启动次数 | 4 → 1 | 3 → 1 | +| D2 加载的 JS | 40.8 → 16.0 MB | 35.8 → 13.3 MB | +| 其他子进程 | 29 → 3 | 24 → 8 | + +**D3。**最终版本 `-p` 为 81.95 亿条指令。基线至少为 93.7 亿条;这是下限,因为 Valgrind 会丢弃被 `execve` 替换掉的进程映像。 + +## 7. 风险与行为变化 + +1. **退出时更新**(standalone 与 `updateCommand` 安装)。保留:会话正常结束后安装更新,并由启动器打开新版本,与 `main` 一致。设置对话框中"需要重启"的退出不会安装更新。 +2. **信任重启后再 `/update`。**原地信任重启之后,同一会话中再执行 `/update`,或请求在退出时安装的更新,会安装更新但以退出码 44 退出而不是打开新版本,因为新映像中没有启动器的退出钩子。这需要在同一会话中开启文件夹信任、发生信任变更并进行更新;下一次运行 `qwen` 即为新版本。 +3. **堆栈。**启用 `minifyWhitespace` 后,堆栈帧保留函数名和精确的行号与列号,但每行平均约 2 KB,仅凭行号已无法定位到可读的代码。两个构建都不附带 source map。 +4. **短会话。**更新检查会等待 3 秒,因此短于 3 秒的会话会跳过它;下一次启动时再检查。 +5. **IDE 进程遍历。**现在只在 VS Code 终端中运行。这不会造成损失:版本低于 0.5.1 的配套扩展不会在连接文件中写入 IDE 身份,因此在会改写 `TERM_PROGRAM` 的终端(例如 tmux)中,`main` 同样无法为它们识别 IDE。 +6. **编译缓存。**缓存位于操作系统临时目录(约 9 MB),安装或升级后的首次启动慢约 100 毫秒。启动器会导出 `NODE_COMPILE_CACHE`,因此与 `main` 一样,重新启动的子进程和工具子进程共享该缓存。 +7. **进程链。**在 POSIX 上不再设置 `QWEN_CODE_LAUNCHER_PID`。只有 Windows standalone 更新器读取它,而 Windows 仍保留子进程方式。 +8. **核心受控区域。**本改动涉及 `packages/core/src/**` 与 `packages/cli/src/config/**`,需要维护者审阅。 +9. **env 文件会话。**当 `.env` 文件或 `settings.env` 注入值时,交互式与 `-p` 会话与 `main` 一样重新启动,保持原有的启动开销。 +10. **Bun 版本。**其启动器保留子进程方式,因此得不到 E8 的收益。 +11. **信号退出状态。**收到 SIGTERM 或 SIGHUP 时,进程以退出码 143 或 129 退出,而不是被信号杀死。shell 的 `$?` 相同;检查信号的父进程会看到正常退出。 + +## 8. 被否决的指标 + +| 指标 | 否决原因 | +| -------------------------------------- | --------------------------------------------------------------------------------------------- | +| 外部帧数或写入次数 | CV 为 12–18%,受 PTY 分块影响 | +| strace 的原始子进程数 | 不可重复:`git` 调用次数会变化,`ps` 遍历次数取决于进程树深度 | +| Valgrind 下交互式的指令数 | 离散度约 10%,因为定时器驱动的工作量会随减速放大;需要一个在 `input_enabled` 时退出的基准钩子 | +| 以内部 `to_input_enabled` 作为总体指标 | 漏掉了用户实际等待中约 1 秒(35%);仅保留作阶段拆解 | + +## 9. 决定 + +以下事项在 PR 为草稿时尚未决定。 + +1. **退出时更新。**在进程内恢复,不再使用 "Run /update" 回退(第 5.1 节)。 +2. **信任重启后再 `/update`。**作为有文档说明的边界情况接受(第 7 节第 2 项)。若在重启时 re-exec 启动器,就要处理 standalone 的 shell 启动器与托管版本锁定,代价超过这一边界情况本身。 +3. **E6 堆栈。**保留。堆栈帧保留函数名与精确位置,两个构建都不附带 source map;source map 可以另行跟进。 +4. **更新检查延迟。**保持渲染后 3 秒。它不再与启动争抢资源,更短的会话会在下一次启动时检查。 +5. **IDE 遍历。**保留;不会造成损失(第 7 节第 5 项)。 +6. **拆分。**作为一个 PR。若维护者倾向拆分,应拆出的是进程链改动(E1+E8);评审中发现的问题都在这一部分。 +7. **基准工具与 CI。**由后续 PR(#12674)提交供手动运行的基准工具,不设 CI 门禁。交互式的 D1 与 D2 截止于 TTI,而 TTI 之前由定时器驱动的工作会随 runner 速度变化,门禁会不稳定。 + +## 10. 验证 + +**构建与静态检查:** + +- `npm run build` 与 `npm run typecheck` 通过。 +- 改动文件的 lint 与 Prettier 检查干净。 +- `npm run check:serve-fast-path-bundle` 通过。 + +**单元测试:** + +- cli:涉及模块的 973 个测试; +- core IDE:119 个测试; +- `scripts/tests/cli-entry`:10 个测试。 + +新增测试覆盖: + +- 进程内重启与更新; +- 重新启动路由; +- IDE 遍历门控; +- 分支预取; +- 更新检查延迟; +- 进程内启动器及其退出码 44 重新启动; +- gc 在运行时暴露时,两条重新启动路径都会带上 `--expose-gc`; +- env 文件的重新启动规则; +- Bun 下的启动器路径; +- 正常退出后安装退出时更新,且用户重复退出时只安装一次。 + +**集成测试:** + +- 无需 API key 的套件通过(26 个文件,196 个测试)。 +- 交互式套件除 4 个需要真实凭据的测试外全部通过;基线在同一环境下同样失败这 4 个。 + +**手动验证:** + +- 操作真实的文件夹信任对话框:启动执行了两次,提示符恢复可输入,会话始终是单进程。 +- 以退出码 44 退出的模拟安装会重新启动,参数保留、`gc` 可用、退出码正确传递。 +- 在 `npm install -g` 安装上执行真实的 `/update`,由本地 registry 提供更新版本:更新被安装,并重新启动到新版本。 +- 在以下启动模式中,会话进程都有 `global.gc`:交互式、`-p`、ACP、stream-json 以及 `advanced.autoConfigureMemory`。在 Node 22.23、Node 22.3(没有 `process.execve`)以及文件夹信任重启之后均成立。 +- `qwen review --help` 正常工作。 +- 在 pnpm 全局安装上验证退出时更新,由本地 registry 提供更新版本:执行 `/quit`、两次 Ctrl+C,或在关闭过程中第三次按 Ctrl+D 后,更新均被安装并打开新版本,与 `main` 一致。 +- `~/.qwen/.env` 中有值时,交互式与 `-p` 会话会重新启动,并在启动时即可读到该值。 +- 在 Bun 1.3.14 与 1.4.2 下,启动器能正常进入 CLI。 + +## 11. 验收标准 + +满足以下条件时,PR 即可就绪: + +- 在干净环境中,J1、J2、J3 的 p75 相对 `main` 均至少改善 2 倍,M1 至少改善 50%,S1 为 0。 +- 第 9 节的决定均已作出,并同步到本文档的两个语言版本。 +- macOS、Linux、Windows 上的 CI 全部通过。 + +## 12. 后续工作 + +以下内容不属于本 PR: + +- **更早渲染提示符。**`config.initialize()` 仍位于首次绘制与 TTI 之间(约 350 毫秒)。在它完成前就显示提示符属于设计层面的改动。 +- **进一步精简模块图。**候选项:OpenAI 认证会话中的 `@google/genai`(581 KB),以及 daemon SDK 与 channels 代码(约 670 KB)。 +- **修复误报通知。**全新 HOME 下会出现 "Extensions changed on disk"。 +- **在应用内增加渲染提交计数器**,使"TTI 前的提交次数"可以成为门禁。 +- **现场遥测。**启用已预留的 `qwen-code.startup.duration` 指标。 +- **供手动运行的基准工具**(决定 7,#12674)。 +- **打包堆栈的 source map**,如有需要(决定 3)。 diff --git a/docs/design/2026-09-24-managed-runtime-tool-contract.md b/docs/design/2026-09-24-managed-runtime-tool-contract.md index b09caffa680..1e22abee288 100644 --- a/docs/design/2026-09-24-managed-runtime-tool-contract.md +++ b/docs/design/2026-09-24-managed-runtime-tool-contract.md @@ -2,8 +2,8 @@ [English](2026-09-24-managed-runtime-tool-contract.md) | [简体中文](2026-09-24-managed-runtime-tool-contract.zh-CN.md) -Status: contract and Java tool transport implemented; worker handlers and -Broker transport wiring remain follow-up work +Status: contract, worker handlers, and Java tool transport implemented; +Broker transport wiring remains follow-up work Related: #12380 (Managed Agent staged delivery), the attestation contract in [2026-09-22-managed-runtime-attestation-contract.md](2026-09-22-managed-runtime-attestation-contract.md), @@ -11,11 +11,10 @@ and the reconciliation thread on #12380 that this contract answers. ## 1. Problem -The owned Managed Runtime worker serves only attestation today. Before a tool -execution path can be reviewed, the wire shape of its three operations — -`execute`, `status`, and `cancel` — must be fixed once for both the TypeScript -worker and the Java transport, with the evidence rules the recovery design -already demands: +The owned Managed Runtime worker extends attestation with three tool +operations — `execute`, `status`, and `cancel`. The TypeScript worker and the +Java transport share one wire contract, with the evidence rules the recovery +design already demands: - A Runtime never sees the Broker's execution id; it identifies a call by the original `reference` (`sessionId`, `promptId`, `callId`, `argsDigest`). @@ -27,18 +26,15 @@ already demands: ## 2. Scope -In scope: contract declarations in the route manifest, the shared schema, and -the shared conformance fixtures for the three operations, consumed by both the -TypeScript contract tests and the Java fixture consumer. The declarations fix -the future wire contract; they do not make an unimplemented route reachable. -The Java `HttpRuntimeTransport` implements `execute`, `status`, and `cancel` -against this contract. +In scope: route manifest declarations, the shared schema and conformance +fixtures, and the worker handlers with their raw HTTP gate admission. +TypeScript contract tests and the Java fixture consumer share the contract; +the worker tests exercise the mounted handlers. The Java `HttpRuntimeTransport` +implements `execute`, `status`, and `cancel` against this contract. -Out of scope: the worker handlers that serve these routes, admission through -the raw HTTP gate, wiring `HttpRuntimeTransport` into `RuntimeTransport`, -and a `not_started_proven` outcome, which needs the -durable receipt store. Each handler and its gate admission land together in a -follow-up change. +Out of scope: wiring `HttpRuntimeTransport` into `RuntimeTransport`, +Harness-side tool wiring, and a +`not_started_proven` outcome, which needs the durable receipt store. ## 3. Design @@ -57,11 +53,10 @@ The fixture header objects are closed to the five protocol headers. This constrains fixture declarations, not ordinary HTTP headers added by clients or intermediaries. Negative cases use explicit omission/replacement directives. -The declaration list is not the raw gate allowlist. Until the real tool -handlers are mounted, `ownedManagedRuntimeRouteGate` admits only the exact -attestation route and returns 404 for `execute`, `status`, and `cancel`, even -if an Express handler is mounted behind it. A future handler change must -expand gate admission in the same change. +The worker mounts all four declared handlers. +`ownedManagedRuntimeRouteGate` admits exactly their declared methods and +paths, including `execute`, `status`, and `cancel`. Undeclared paths, wrong +methods, trailing slashes, and query strings return an empty 404. ### 3.2 Requests @@ -75,6 +70,11 @@ Every request is a closed object: The reference is the original call identity the harness assigned; the Runtime never learns any Broker-side identifier. +Tool input must also be encodable by the Runtime's JSON encoder for identity +comparison. Unencodable input, including excessive nesting within the byte +limit, is rejected with 400 before creating a journal entry. The journal keeps +the encoded input so retries compare strings without re-encoding stored data. + ### 3.3 Responses Every success is a closed object carrying `protocolVersion` and a `state` of @@ -98,8 +98,8 @@ record must still return `unknown` and never imply `not_started`. Failures keep the shared classification: 401 credentials, 400/413 protocol, 409 identity, 404 incompatible. JSON errors retain the shared stable codes; -the gate's incompatible 404 has an empty body. The attestation-named codes do -not imply a 16 KiB tool limit: error messages use the operation and its cap. +the gate's incompatible 404 has an empty body. The attestation-named codes +are shared across routes; each parser enforces its own route's body cap. ### 3.4 Conformance fixtures @@ -118,18 +118,18 @@ one suite, with fixed envelope limits and error-code vocabulary. Cases cover all five states and all four execution statuses, including the closed error object and a status request without a cursor. TypeScript mutation tests remove required fields or add route-invalid fields to prove these constraints are -load-bearing, and pin the declared manifest to the fixture routes. The raw HTTP -test separately proves that the gate rejects all three declared tool routes -until their handlers land. +load-bearing, and pin the declared manifest to the fixture routes. Raw HTTP +tests prove exact gate admission and replay the negative fixtures against +the mounted worker handlers. ## 4. Validation -Run `npx vitest run src/serve/managed-runtime-attestation-contract.test.ts` in +Run `npx vitest run src/serve/managed-runtime-attestation-contract.test.ts src/serve/managed-runtime-attestation-worker.test.ts src/serve/managed-runtime-tool-worker.test.ts` in `packages/cli` and `mvn test -Dtest=ManagedRuntimeAttestationConformanceTest` in `packages/sdk-java/runtime-broker`. The TypeScript suite validates the shared -fixtures, the schema mutation cases, and raw-gate rejection of unimplemented -tool routes. The Java suite consumes the same contract files. +fixtures, schema mutations, exact gate admission, and real tool execution. +The Java suite consumes the same contract files. ### 4.1 Java transport @@ -161,12 +161,10 @@ Run `mvn test` and `mvn checkstyle:check` in `packages/sdk-java/runtime-broker`. HTTP fixture replays verify the canonical requests, success and unknown answers, malformed references and responses, route-specific request limits, and tool results above 16 KiB through 1 MiB. -The worker still rejects the tool routes until handlers land. +The worker handlers described below serve these routes. ## 5. Follow-up work -- Mount the real worker handlers for the three routes and expand raw-gate - admission in the same change (execute extraction). - Complete the session verbs and wire `HttpRuntimeTransport` into `RuntimeTransport`. Supply `toolName`/`input` separately from the stored reference as described in §4.1, and cover real Broker dispatch end to end. @@ -176,3 +174,68 @@ The worker still rejects the tool routes until handlers land. the Broker rejects extra fields and has no cursor consumer yet. - Whether a cancel of an execution the Runtime reports as still running sends the physical cancel is deliberately deferred. + +## 6. Worker implementation + +The merged attestation worker now mounts the three routes beside `attest`. +Its executor admits exactly the first-slice ordinary tools — `read_file`, +`write_file`, `edit`, and foreground `run_shell_command` — over a real +`Config` rooted at the attested workspace cwd, with checkpointing disabled. +Admission happens on the Harness side; the worker executes with no further +approval gate. Harness admission must include the workspace-boundary +decision: the worker does not confine tool paths or shell commands to the +workspace. The invocation journal is in-memory by construction: the +worker process is the Runtime generation, so a restart is a new generation +rather than a continuation, and `unknown` is the honest answer for anything +the process never saw. The worker disables conversation-dependent file-read +caching: it has no transcript residency evidence and can serve multiple +Runtime sessions. Harness admission owns any prior-read requirement. + +Semantics mounted on the contract: + +- `execute` is idempotent by `reference.callId`: the same identity joins the + in-flight invocation or returns its settled result; the same `callId` with + a different digest or payload is a 409 identity conflict. An unadmitted + tool name is a 409 as well — it can never be valid for this generation. + `run_shell_command` whose validated input normalizes to `is_background: true` + is rejected before creating a journal entry or starting a process. This + includes case-insensitive string `"true"`; omitted, false, or string `"false"` + remains foreground. After protocol admission, parameter validation or copying + failures settle as errors without execution. + Tools receive a copy of the input so parameter normalization cannot change + the original payload used to identify retries. +- `status` is read-only and answers `unknown` (200) for a reference the + Runtime holds no record of; a known invocation answers its state with the + journal's monotonic `lastSequence`. +- `cancel` settles a `prepared` invocation as cancelled without touching the + tool, aborts an `executing` one and answers `cancel_requested`, and is + idempotent thereafter. A cancel the Runtime honored settles the invocation + as `cancelled` whether the tool surfaces the abort as an error or as an + early result. +- The worker keeps its 5-second HTTP `requestTimeout`, which bounds receipt + of the request body, not the duration of a complete request's execution. + Per-tool timeouts govern execution; headers and keep-alive bounds stay as + they were. +- Before publishing a settled result, the worker checks the serialized + status envelope against the 1 MiB response cap. Oversized output is replaced + with a small terminal error (preserving a cancelled status), retained for + execute retries, status, and cancel. This is an executed call with unavailable + output, never `not_started` or an invitation to execute it again. +- `prepared` is an internal journal state: execution advances to `executing` + synchronously, so HTTP callers cannot observe or cancel a prepared entry. + +Validation adds `managed-runtime-tool-worker.test.ts`: every negative shared +fixture is replayed over raw HTTP against the real mounted routes, and the +behavioral cases execute a real `read_file` in a temporary workspace, answer +`unknown` for unseen references, join a concurrent duplicate execute, reject +a same-callId different-digest retry with 409, refuse an unadmitted tool, and +cancel an in-flight foreground shell command. Additional regression cases +reject background shell calls without recording them, including normalized +string booleans; admit omitted and boolean/string false `is_background` +values; and settle invalid values without starting a command. + +Still follow-up: harness-side `RuntimeBackedTool` wiring, file-history +settlement, capability-digest verification against the admitted tool set, +journal retention bounds, image input support for the synthetic +`managed-runtime-worker` model, and the artifact delivery track for large +outputs. diff --git a/docs/design/2026-09-24-managed-runtime-tool-contract.zh-CN.md b/docs/design/2026-09-24-managed-runtime-tool-contract.zh-CN.md index 5d2b99811e9..3dd1709e665 100644 --- a/docs/design/2026-09-24-managed-runtime-tool-contract.zh-CN.md +++ b/docs/design/2026-09-24-managed-runtime-tool-contract.zh-CN.md @@ -2,13 +2,13 @@ [English](2026-09-24-managed-runtime-tool-contract.md) | [简体中文](2026-09-24-managed-runtime-tool-contract.zh-CN.md) -状态:契约与 Java 工具 transport 已实现;worker 处理器及 Broker transport 接入留待后续完成 +状态:契约、worker 处理器与 Java 工具 transport 已实现;Broker transport 接入仍为后续工作 相关:#12380(Managed Agent 分阶段交付)、[2026-09-22-managed-runtime-attestation-contract.md](2026-09-22-managed-runtime-attestation-contract.md) 的 attestation 契约,以及 #12380 上本契约所答复的对账讨论。 ## 1. 问题 -owned Managed Runtime worker 目前只提供 attestation。在工具执行路径可以评审之前,必须先为 TypeScript worker 与 Java transport 一次性固定三个操作——`execute`、`status`、`cancel`——的线上形态,并满足恢复设计要求的证据规则: +owned Managed Runtime worker 在 attestation 之外增加三个工具操作——`execute`、`status`、`cancel`。TypeScript worker 与 Java transport 共享同一份线上契约,并满足恢复设计要求的证据规则: - Runtime 从不接触 Broker 的执行 id;它按原始 `reference`(`sessionId`、`promptId`、`callId`、`argsDigest`)识别调用。 - 查询绝不能证明调用没有运行。记录缺失、超时或租约过期都不是证据,因此 `status` 以 200 回答 `unknown`,而不是 404 或 500。 @@ -16,9 +16,9 @@ owned Managed Runtime worker 目前只提供 attestation。在工具执行路径 ## 2. 范围 -范围内:三个操作在路由清单中的契约声明、共享 schema 与共享 conformance fixtures,由 TypeScript 契约测试与 Java fixture 消费方共同验证。这些声明固定未来的线路契约,但不会让尚未实现的路由变得可访问。Java `HttpRuntimeTransport` 按本契约实现 `execute`、`status`、`cancel`。 +范围内:路由清单声明、共享 schema 与 conformance fixtures,以及 worker 处理器和对应的 raw HTTP gate 放行。TypeScript 契约测试与 Java fixture 消费方共享契约;worker 测试覆盖已挂载的处理器。Java `HttpRuntimeTransport` 按本契约实现 `execute`、`status`、`cancel`。 -范围外:真正服务这些路由的 worker 处理器、raw HTTP gate 放行、将 `HttpRuntimeTransport` 接为 `RuntimeTransport`,以及 `not_started_proven` 结果(需要持久回执存储)。每个处理器与对应的 gate 放行必须在后续同一变更中落地。 +范围外:将 `HttpRuntimeTransport` 接为 `RuntimeTransport`、Harness 侧工具接线,以及 `not_started_proven` 结果(需要持久回执存储)。 ## 3. 设计 @@ -28,7 +28,7 @@ owned Managed Runtime worker 目前只提供 attestation。在工具执行路径 fixture 的请求头对象封闭为五个协议头。这约束的是 fixture 声明,不限制客户端或中间层添加的普通 HTTP 头。负面用例通过显式的省略或替换指令构造。 -声明清单不是 raw gate allowlist。在真实工具处理器挂载之前,`ownedManagedRuntimeRouteGate` 只放行精确的 attestation 路由,并对 `execute`、`status`、`cancel` 返回 404,即使其后方挂载了 Express 处理器也不例外。未来增加处理器时,必须在同一变更中扩展 gate admission。 +worker 已挂载全部四个声明的处理器。`ownedManagedRuntimeRouteGate` 只放行声明的方法与精确路径,包括 `execute`、`status`、`cancel`。未声明路径、错误方法、尾部斜杠与查询字符串返回空响应体的 404。 ### 3.2 请求 @@ -40,6 +40,8 @@ fixture 的请求头对象封闭为五个协议头。这约束的是 fixture 声 reference 是 harness 分配的原始调用身份;Runtime 不会得知任何 Broker 侧标识。 +工具输入还必须能由 Runtime 的 JSON 编码器编码,以便比较调用身份。不可编码的输入(包括未超出字节上限但嵌套过深的输入)在创建日志条目前以 400 拒绝。日志保留编码后的输入,重试只比较字符串,不再重新编码已记录的数据。 + ### 3.3 响应 每个成功响应都是封闭对象,携带 `protocolVersion` 与 `state`(`prepared`、`executing`、`cancel_requested`、`settled`、`unknown` 之一): @@ -50,17 +52,17 @@ reference 是 harness 分配的原始调用身份;Runtime 不会得知任何 B 本切片只固定 `responseParts` 为数组,有意把元素结构推迟到 worker 处理器与 Broker 接入切片。后续必须从实际工具结果路径推导结构(`ToolCallResponseInfo.responseParts` 使用 SDK `Part[]`),并在提供结果之前补齐共享一致性覆盖。fixture 中的文本 part 仅作示例,不定义新的 part 格式。`settled` 下的 `not_started` 是 Runtime 明确给出的终态;记录缺失仍须返回 `unknown`,绝不能据此推导 `not_started`。 -失败沿用共享分类:401 凭据、400/413 协议、409 身份、404 不兼容。JSON 错误保留共享的稳定错误码;gate 的不兼容 404 为空响应体。错误码中的 attestation 名称不代表工具上限为 16 KiB:错误消息使用对应操作及其上限。 +失败沿用共享分类:401 凭据、400/413 协议、409 身份、404 不兼容。JSON 错误保留共享的稳定错误码;gate 的不兼容 404 为空响应体。含 attestation 名称的错误码由各路由共享;每个解析器执行对应路由的请求体上限。 ### 3.4 Conformance fixtures `managed-runtime-tool-v2.fixtures.json` 与 attestation 套件同构:三条路由、一份身份,以及每个路由的 canonical 请求与覆盖成功形态和负面纪律的用例。`status` 与 `cancel` 的 `unknown-is-ok` 用例固定证据规则。Java 消费方固定路由契约、每个结局分类与封闭请求/响应字段集。 -共享 schema 强制每个路由使用精确的请求字段集,canonical 请求与逐用例的 body 覆写都受此约束。它还要求每个 `ok` 用例都携带响应 body,要求且仅允许 `settled` 状态携带 `result`,并只允许 `status` 使用 `lastSequence`。每个路由恰好有一个 suite,信封上限与错误码词汇表固定。用例覆盖五种状态、四种执行结局、封闭的错误对象,以及不带游标的 status 请求。TypeScript 变异测试通过删除必填字段或加入路由不允许的字段,证明这些约束确实生效,并把声明 manifest 钉在 fixture 路由上。raw HTTP 测试另行证明,在处理器落地之前,gate 会拒绝全部三条已声明的工具路由。 +共享 schema 强制每个路由使用精确的请求字段集,canonical 请求与逐用例的 body 覆写都受此约束。它还要求每个 `ok` 用例都携带响应 body,要求且仅允许 `settled` 状态携带 `result`,并只允许 `status` 使用 `lastSequence`。每个路由恰好有一个 suite,信封上限与错误码词汇表固定。用例覆盖五种状态、四种执行结局、封闭的错误对象,以及不带游标的 status 请求。TypeScript 变异测试通过删除必填字段或加入路由不允许的字段,证明这些约束确实生效,并把声明 manifest 钉在 fixture 路由上。raw HTTP 测试证明 gate 的精确放行,并向已挂载的 worker 处理器回放负面 fixtures。 ## 4. 验证 -在 `packages/cli` 运行 `npx vitest run src/serve/managed-runtime-attestation-contract.test.ts`,并在 `packages/sdk-java/runtime-broker` 运行 `mvn test -Dtest=ManagedRuntimeAttestationConformanceTest`。TypeScript suite 校验共享 fixtures、schema 变异用例,以及 raw gate 对未实现工具路由的拒绝;Java suite 消费同一批契约文件。 +在 `packages/cli` 运行 `npx vitest run src/serve/managed-runtime-attestation-contract.test.ts src/serve/managed-runtime-attestation-worker.test.ts src/serve/managed-runtime-tool-worker.test.ts`,并在 `packages/sdk-java/runtime-broker` 运行 `mvn test -Dtest=ManagedRuntimeAttestationConformanceTest`。TypeScript suite 校验共享 fixtures、schema 变异用例、gate 精确放行与真实工具执行;Java suite 消费同一批契约文件。 ### 4.1 Java transport @@ -70,11 +72,27 @@ reference 是 harness 分配的原始调用身份;Runtime 不会得知任何 B 超过对应路由上限的请求在发送前被拒绝:`execute` 为 256 KiB,`status` 与 `cancel` 为 16 KiB。响应上限为 1 MiB,且必须带 `no-store` 与 JSON 响应头。解析强制信封、result、error 为封闭对象,协议版本为 2,状态与执行结局属于契约枚举,错误字符串非空,且仅在 settled 时必须携带 result。`lastSequence` 为可选非负整数,仅允许出现在 `status`。`execute` 要求结算并返回 result map;`status` 与 `cancel` 返回校验后的线上 map。共享错误码保持不变,错误消息标明操作与适用上限。服务端失败可重试,其他 HTTP 失败为终态。 -在 `packages/sdk-java/runtime-broker` 运行 `mvn test` 和 `mvn checkstyle:check`。HTTP fixture 回放验证 canonical 请求、成功与 unknown 应答、畸形 reference 和响应、逐路由请求上限,以及超过 16 KiB 直至 1 MiB 的工具结果。worker 在处理器落地前仍拒绝工具路由。 +在 `packages/sdk-java/runtime-broker` 运行 `mvn test` 和 `mvn checkstyle:check`。HTTP fixture 回放验证 canonical 请求、成功与 unknown 应答、畸形 reference 和响应、逐路由请求上限,以及超过 16 KiB 直至 1 MiB 的工具结果。下文描述的 worker 处理器已提供这些路由。 ## 5. 后续工作 -- 为三个路由挂载真实的 worker 处理器,并在同一变更中扩展 raw gate admission(execute 提取)。 - 完成会话操作并将 `HttpRuntimeTransport` 接为 `RuntimeTransport`。按 §4.1 所述从已保存的 reference 之外单独提供 `toolName`/`input`,并端到端覆盖真实 Broker 分发。 - `UNKNOWN` 执行对账器已在 #12655 落地。其 transport 必须先校验 status 线上信封,再投影为 `{state, result}`(仅 `settled` 携带 `result`)。去掉 `protocolVersion` 与 `lastSequence`;Broker 拒绝额外字段,目前没有游标消费方。 - 对 Runtime 报告仍在运行的执行是否发送物理取消,有意推迟。 + +## 6. Worker 实现 + +合入的 attestation worker 现在在 `attest` 旁边挂载了这三个路由。其执行器恰好准入首版的普通工具——`read_file`、`write_file`、`edit` 与前台 `run_shell_command`——运行在以已证明的工作区 cwd 为根的真实 `Config` 之上,checkpointing 关闭。准入发生在 Harness 侧;worker 执行时不再有审批门。Harness 准入必须包含工作区边界判定:worker 不会将工具路径或 shell 命令限制在工作区内。调用日志按构造只在内存中:worker 进程就是 Runtime 代数,重启即是新代数而非延续,对该进程从未见过的请求,`unknown` 才是诚实的应答。worker 禁用依赖对话的文件读取缓存:它没有内容仍在对话历史中的证据,且可能服务多个 Runtime 会话。任何先读后写要求由 Harness 准入负责。 + +契约之上的语义: + +- `execute` 按 `reference.callId` 幂等:同一身份会并入在途调用或返回其已结算结果;同一 `callId` 携带不同摘要或负载则是 409 身份冲突。未准入的工具名同样是 409——它对本代数永远不合法。`run_shell_command` 的有效输入归一化为 `is_background: true` 时,在创建日志条目或启动进程之前被拒绝,包括不区分大小写的字符串 `"true"`;省略、布尔 false 或字符串 `"false"` 则仍以前台运行。通过协议准入后的参数校验或复制失败结算为错误,不执行命令。工具接收输入副本,参数归一化不会改变用于识别重试的原始负载。 +- `status` 只读,对 Runtime 没有记录的 reference 以 200 回答 `unknown`;已知调用按其状态与日志的单调 `lastSequence` 应答。 +- `cancel` 把 `prepared` 调用直接结算为 cancelled(不触碰工具),中止 `executing` 调用并回答 `cancel_requested`,此后幂等。Runtime 兑现的取消会把该调用结算为 `cancelled`——无论工具把中止表现为错误还是提前返回的结果。 +- worker 保留 5 秒的 HTTP `requestTimeout`,它限制接收请求体的时间,不限制完整请求的执行时间。执行由工具自身的超时约束;headers 与 keep-alive 上限维持不变。 +- 发布已结算结果之前,worker 按序列化后的 status 信封检查 1 MiB 响应上限。超大输出被替换为小型终态错误(保留 cancelled 状态),并供 execute 重试、status 与 cancel 共用。这表示调用已执行但输出不可用,绝不是 `not_started`,也不是允许再次执行。 +- `prepared` 是内部日志状态:执行会同步进入 `executing`,因此 HTTP 调用方无法观察或取消 prepared 条目。 + +验证新增 `managed-runtime-tool-worker.test.ts`:在真实挂载的路由上用原始 HTTP 回放全部负面共享 fixture;行为用例覆盖在临时工作区真实执行 `read_file`、对未见过的 reference 回答 `unknown`、并入并发的重复 execute、以 409 拒绝同 callId 不同摘要的重试、拒绝未准入工具,以及取消一个在途的前台 shell 命令。额外回归用例验证后台 shell 被拒绝且不留日志条目(包含归一化后的字符串布尔值),验证省略 `is_background`、布尔或字符串 false 均可准入,并验证无效值不会启动命令。 + +仍为后续工作:Harness 侧 `RuntimeBackedTool` 接线、文件历史结算、对已准入工具集合的 capability digest 校验、日志保留上限、合成模型 `managed-runtime-worker` 的图片输入支持,以及大输出的产物交付通道。 diff --git a/docs/design/2026-09-25-managed-agent-review-corrections.md b/docs/design/2026-09-25-managed-agent-review-corrections.md new file mode 100644 index 00000000000..cb4ee5054d1 --- /dev/null +++ b/docs/design/2026-09-25-managed-agent-review-corrections.md @@ -0,0 +1,36 @@ +# Managed Agent split: review corrections + +[English](2026-09-25-managed-agent-review-corrections.md) | [简体中文](2026-09-25-managed-agent-review-corrections.zh-CN.md) + +## Scope and status + +This review targets the current PR #12692 split. The four earlier design pairs were copied from a broader integration preview. Their implementation and real-process verification claims do not establish those capabilities in this split. This document records the corrected boundaries; the complete Hosted Harness / Broker / worker path remains a merge gate. + +## Corrections + +- The Managed panel requires an explicitly supplied provider. Ordinary daemon panels keep their existing routes. This split contains no daemon Managed Session SDK or endpoints, so the unused adapter and its mock-only tests are removed; browser coverage exercises the Java HTTP contract. +- A new private Session attempts strict creation first. An existing-authority conflict or an uncertain creation outcome falls back to load; a known existing Session only loads. This avoids requiring a successful ownership-checked load before a new authority exists. +- SQL migrations for the new Managed Agent tables now set a case-sensitive `utf8mb4_bin` default collation. Tenant, Session, command and resource identifiers therefore retain their API case-sensitive identity even when the database default is case-insensitive. A MySQL integration test checks two tenants differing only in case. +- Lifecycle event identities include the command idempotency key, rather than only the semantic digest. A second archive/unarchive cycle is a new operation; replaying its key remains idempotent. +- Public text parts aggregate only adjacent deltas of the same kind. Tools and reasoning delimit parts so snapshot hydration preserves visible chronology. +- Continuation retraction only clears output from the selected Harness boot and event epoch. In the same transaction it invalidates the derived projection, resets materialization progress, and publishes `stream.reconciled` after commit so browsers reload retained history. This repairs projection consistency; it does not prove the missing private checkpoint recovery protocol. +- Unreachable workers without local process ownership remain `UNKNOWN`; a failed attestation is not evidence that the old process has stopped. +- `executions:prepare` and `executions/{id}:start` return a non-retryable 501 until the Broker implements a real durable dispatch fence. Operator resolution also returns 501 until it has a durable service API. The previous mapping dispatched during prepare and only read during start, violating the proposed protocol. The existing create-and-dispatch route retains its semantics. +- The Java 21 database CI job now installs local SDK/Broker dependencies and runs the Spring module's unit tests, Checkstyle, and MySQL-profile integration tests against a separate database. Changes to the shared contract fixture also trigger the workflow. +- WebShell maps Java `running` and `cancelling` states, disables actions on inactive Sessions, and preserves tool failure and identity fields in both streamed events and items. + +## Remaining integration gates + +1. The embedded transport rejects acquire/control/release with 501. Current main contains a Broker client foundation, but `qwen serve --profile hosted-harness` explicitly rejects startup because the Broker-backed Session loop is not implemented; the remote durable-store adapter and worker bundle assumed by the copied E2E script are also unavailable. A successful real tool turn and crash-recovery proof require these dependencies to be integrated together. +2. Private Store acquisition accepts a caller-selected writer token and tenant. The writer lease fences successive writers but does not authenticate a service. A trusted service identity or enforced private ingress policy must cover these routes independently of browser-facing Agent routes. +3. Harness-level drain only retires a Session in process memory. It does not stop the worker, persist retirement, or revoke all private Broker access. Archive/delete must not be advertised as Runtime teardown. +4. The HTTP adapter now fails closed with 409 for Broker `UNKNOWN`, but it cannot yet communicate the durable recovery block promised by the design. Recovery needs an explicit compatible unknown-outcome contract. +5. The new CI wiring must pass remotely before merge. Existing SDK CI, local H2 tests, and a listener's 401 test are not successful Spring/Broker/worker integration evidence. + +## Ownership and consumers + +Public `/v1/agents/sessions/**` and `/api/agent/web-shell/v1/**` are persisted tenant/Session scoped and feed the command store, coordinator, event replay, projections, and Java WebShell provider. `/internal/managed-session-store/v1/**` is tenant/workspace/Session scoped and is intended for a service-authenticated Harness writer. The separate Broker listener is machine authenticated; its resolver derives tenant and workspace from the persisted Session and deployment configuration. It is not an ordinary daemon route or a fallback to the daemon's primary workspace. The standard WebShell continues to use its daemon provider for ordinary chat, settings, and workspace actions. + +## Validation and acceptance + +Regression coverage must reject the original behavior for repeated lifecycle operations, cross-generation retraction, interleaved snapshot text, new-Session routing, and Java UI state mapping. Run the repository build/typecheck, focused WebShell tests, Java 21 Maven tests/checkstyle, and Java-contract browser scenarios. Browser fixtures and H2 tests do not satisfy the remaining real MySQL / Hosted Harness / worker integration or production security gates. Preserve the PR as requiring changes until those gates have an explicit implementation and evidence. diff --git a/docs/design/2026-09-25-managed-agent-review-corrections.zh-CN.md b/docs/design/2026-09-25-managed-agent-review-corrections.zh-CN.md new file mode 100644 index 00000000000..1f17594d44e --- /dev/null +++ b/docs/design/2026-09-25-managed-agent-review-corrections.zh-CN.md @@ -0,0 +1,36 @@ +# Managed Agent 拆分:评审修正 + +[English](2026-09-25-managed-agent-review-corrections.md) | [简体中文](2026-09-25-managed-agent-review-corrections.zh-CN.md) + +## 范围与状态 + +本次评审针对 PR #12692 当前的拆分。此前四组设计文档来自更大的集成预览;其中的实现和真实进程验证声明不能证明本次拆分具备相同能力。本文记录修正后的边界;完整 Hosted Harness / Broker / worker 链路仍是合并门禁。 + +## 修正 + +- Managed 面板要求显式传入 provider。普通 daemon 面板继续使用原有路由。本次拆分没有 daemon Managed Session SDK 或端点,因此移除不可用的适配器及仅靠 mock 的测试,浏览器测试改为覆盖 Java HTTP 契约。 +- 新私有 Session 先尝试严格创建;已有权威记录冲突或创建结果不确定时再 load。已知存在的 Session 只 load,避免在新权威记录存在之前就要求通过所有权校验的加载。 +- 新 Managed Agent 表的 SQL migration 统一设置大小写敏感的 `utf8mb4_bin` 默认排序规则。即使数据库默认不区分大小写,tenant、Session、命令和资源标识也保持 API 定义的大小写语义。MySQL 集成测试验证仅大小写不同的两个租户相互隔离。 +- 生命周期事件身份使用命令幂等键,而非仅使用语义摘要。第二轮 archive/unarchive 是新操作;重放同一命令键仍保持幂等。 +- 公共文本 Part 只聚合相邻且同类的 delta;工具与思考事件切分 Part,保证快照还原后的显示顺序。 +- continuation 回退只清除指定 Harness boot 和 event epoch 的输出。在同一事务中失效派生投影、重置物化进度,提交后发布 `stream.reconciled`,使浏览器重新读取保留的历史。这修复投影一致性,不代表缺失的私有 checkpoint 恢复协议已被证明。 +- 无本地进程所有权且无法连接的 worker 保持 `UNKNOWN`;attestation 失败不能证明旧进程已停止。 +- Broker 实现真正的持久化派发屏障前,`executions:prepare` 与 `executions/{id}:start` 返回不可重试的 501。operator resolution 在具备持久化服务 API 前也返回 501。此前 prepare 会派发执行、start 仅查询,违背设计协议。已有 create-and-dispatch 路由保留原语义。 +- Java 21 数据库 CI 任务现已安装本地 SDK/Broker 依赖,并使用独立数据库运行 Spring 模块的单测、Checkstyle 和 MySQL profile 集成测试。共享契约 fixture 变更也会触发工作流。 +- WebShell 正确映射 Java `running`、`cancelling`,禁止在非活动 Session 上操作,并在流事件和 Item 中保留工具失败状态及身份。 + +## 剩余集成门禁 + +1. 内嵌 transport 对 acquire/control/release 返回 501。当前 main 已有 Broker client 基础代码,但 `qwen serve --profile hosted-harness` 会明确拒绝启动,因为尚未实现 Broker 支持的 Session 循环;所复制 E2E 脚本需要的远程 durable-store adapter 和 worker bundle 也不可用。必须共同集成这些依赖,才能证明真实工具 Turn 与崩溃恢复。 +2. 私有 Store 的 acquire 接受调用方自行选择的 writer token 和 tenant。writer 租约约束先后写入者,但不认证服务身份。必须为这些路由提供可信服务身份或强制私网入口策略,并与浏览器 Agent 路由区分。 +3. Harness 级 drain 仅在进程内存中标记 Session 退休,不停止 worker、不持久化退休状态,也不撤销全部私有 Broker 访问。不能把 archive/delete 宣称为 Runtime 回收。 +4. HTTP 适配器现在对 Broker `UNKNOWN` 返回 409 并拒绝继续,但尚不能表达设计承诺的持久化恢复阻塞。恢复需要明确且兼容的未知结果契约。 +5. 新增 CI 接线仍需远端运行通过才能合并。既有 SDK CI、本地 H2 测试与监听器的 401 测试不能作为 Spring/Broker/worker 成功集成的证据。 + +## 所有权与下游使用方 + +公共 `/v1/agents/sessions/**` 与 `/api/agent/web-shell/v1/**` 属于持久化 tenant/Session 范围,下游是命令存储、协调器、事件重放、投影和 Java WebShell provider。`/internal/managed-session-store/v1/**` 属于 tenant/workspace/Session 范围,预期由经过服务认证的 Harness writer 调用。独立 Broker listener 使用机器凭证,其 resolver 根据持久化 Session 与部署配置解析租户、工作区;它不是普通 daemon 路由,也不是回退到 daemon primary workspace。标准 WebShell 的普通聊天、设置和工作区操作继续使用原 daemon provider。 + +## 验证与验收 + +回归测试必须能拒绝原先的重复生命周期操作、跨代次回退、交错快照文本、新 Session 路由和 Java UI 状态映射行为。执行仓库 build/typecheck、定向 WebShell 测试、Java 21 Maven test/checkstyle,以及基于 Java 契约的浏览器场景。浏览器 fixture 和 H2 测试不能替代真实 MySQL / Hosted Harness / worker 集成或生产安全门禁。在剩余门禁具备明确实现与证据之前,PR 仍应保持需要修改的状态。 diff --git a/docs/design/2026-09-25-managed-context-envelope.md b/docs/design/2026-09-25-managed-context-envelope.md new file mode 100644 index 00000000000..47a11dc3aa3 --- /dev/null +++ b/docs/design/2026-09-25-managed-context-envelope.md @@ -0,0 +1,217 @@ +# Managed Context Envelope (W0a-2) + +[English](2026-09-25-managed-context-envelope.md) | [简体中文](2026-09-25-managed-context-envelope.zh-CN.md) + +Status: contract defined; no worker or Broker uses it yet. Updated: 2026-09-25. This is the second W0 slice of the Managed Agent proposal [#12380](https://github.com/QwenLM/qwen-code/issues/12380). It builds on the [Workspace binding contract](2026-09-25-managed-workspace-binding-contract.md) (W0a, #12681). [This reply](https://github.com/QwenLM/qwen-code/issues/12380#issuecomment-5825755703) asked for the envelope shape to be decided now, contract first: "envelope shape + shared schema/fixtures first, handlers after". Below, "the reference design" is the proposal's [Workspace and Session cwd design](https://github.com/doudouOUC/code_agent/blob/689121646cc25ca08a34508a5f5555ae15308833/qwen-code/feature/managed-agents/managed-agent-workspace-context.en.md), [contract closure](https://github.com/doudouOUC/code_agent/blob/689121646cc25ca08a34508a5f5555ae15308833/qwen-code/feature/managed-agents/managed-agent-contract-closure.en.md) and [attestation design](https://github.com/doudouOUC/code_agent/blob/689121646cc25ca08a34508a5f5555ae15308833/qwen-code/feature/managed-agents/managed-runtime-attestation.md), all at the commit that #12380 links. + +## Problem + +The Managed Runtime worker learns who it is from a boot document and proves it through attestation. Both describe the Runtime's Workspace by one directory, `workspaceCwd`, and treat `workspaceId` as an opaque label. + +W0 replaces that directory with a Registry Workspace: a tenant, a Workspace ID, a generation and a storage ID, whose files the Broker mounts at a root. It also adds per-Session context: a relative directory, a configuration reference and a revision. W0a binds these seven values into a `ContextBinding` and its `contextDigest`. + +The worker cannot receive either today. Adding fields to the boot document, attestation or Tool v2 in place would break their closed shapes, and an old worker handed a W0 Session could run tools in the wrong directory. W0 therefore needs a negotiated protocol, `managed-context/1`, with new strict versions, so that an old worker refuses a W0 Session before anything runs. + +## Current state + +The facts below are from `main` at `ab61e04161`. + +- **Boot v1.** `LocalProcessRuntimeProvisioner` writes one JSON object to the worker's standard input and closes it. The object has 14 keys: the Runtime identity, `tenantId`, `workspaceId`, `workspaceGeneration`, `workspaceCwd`, `capabilityDigest`, `isolationClass`, `token`, `type: "boot"` and `version: 1`. The worker accepts exactly that key set within 32 KiB and 30 seconds, then checks the field types through the attestation identity. A boot it rejects makes it exit before the ready line, and Java reports a retryable `runtime_provision_failed`. +- **Ready v1.** The worker answers with one standard-output line: `type`, `version: 1`, `runtimeInstanceId`, `runtimeIncarnation`, `leaseId`, `epoch` and a loopback `url`. Java compares the echoed values but ignores extra keys. +- **Attestation v2.** `POST /internal/managed-runtime/v2/attest` takes a request closed to 8 keys and answers with 12. Every scope field, `workspaceCwd` included, is compared exactly with the boot document; a mismatch is 409 `managed_runtime_identity_conflict`. +- **Tool v2.** The execute, status and cancel routes are bound to the Runtime by the lease headers only. Each call's reference carries a `sessionId`, but no Workspace, directory or context field. Since #12671 the worker serves them through one tool executor rooted at the boot's `workspaceCwd`, and its raw route gate admits exactly the declared v2 routes. The executor does not confine tool paths or shell commands to that directory; the tool contract requires the Harness to decide the Workspace boundary when it admits a call, and that admission is still to be wired. +- **Placement.** The six `RuntimeScope` fields feed the Broker's request and scope keys. A Workspace-isolated Runtime is shared by every Session that resolves to the same scope. +- **Contracts.** Attestation v2 and Tool v2 have shared schemas and fixtures. The boot document and the ready record have none, and the fake worker that Java tests start, `fake-attestation-worker.mjs`, validates nothing. +- **W0a.** `ContextBinding` has seven fields: `tenantId`, `workspaceId`, `workspaceGeneration`, `storageId`, `cwdRelative`, `contextConfigRef` and `contextRevision`. Java and TypeScript compute the same `contextDigest`, pinned by shared fixtures. + +## Goals + +- Define how a Broker and a worker agree on `managed-context/1`, and how either side refuses it. +- Define boot v2, a closed shape that carries the Workspace binding and the mount root, and ready v2, the closed record that accepts it. +- Define attestation v3, so that W0c reuses the existing gate instead of inventing a separate Workspace attestation. +- Define the context installation request and its receipt, which bind one Session's `ContextBinding` to a Runtime. +- Pin all of it with one shared schema and fixture file that both languages run. +- Leave boot v1, ready v1, attestation v2 and Tool v2 unchanged. + +## Non-goals + +- **Wiring.** In this slice no worker accepts boot v2 or mounts a v3 route, and no Broker writes boot v2. W0c does that on top of the worker that #12671 completed. +- **Mounting and filesystem checks.** Resolving storage to a mount root, and verifying existence, realpath, symlinks and mount identity, belong to the storage resolver and the installation handler in W0c. +- **Later bindings.** Configuration installation from `contextConfigRef`, activation gates, the per-invocation binding around Tool v2 calls, and receipts in journals and checkpoints belong to W0c and later slices. +- **Placement.** `RuntimeScope`, the Broker's request and scope keys, and its schema do not change. + +## Negotiation + +`managed-context/1` is a capability token, named in the style of `qwen-hosted-harness/1`. A later incompatible change takes a new token. + +- **Offer.** The Broker offers the protocol by writing boot v2, which carries the token. It writes boot v2 only for a Runtime binding whose Sessions have a Workspace context; every other binding keeps boot v1. +- **Acceptance.** A worker that implements the protocol answers with ready v2, which repeats the token. It then serves attestation v3 and the installation route, and answers 404 to attestation v2, so one Runtime never presents two identities. The Tool v2 routes keep their shapes, but under boot v2 a tool call runs only for a Session with an installed context, in that context's effective directory. W0c implements this with the per-invocation binding. +- **Refusal before any side effect.** + - A worker that implements only boot v1 rejects boot v2 through its closed key set and exits before the ready line. + - A ready v1 record, a ready record without the token, or a 404 from a v3 route means the peer is incompatible. +- **No downgrade.** The Broker never provisions a W0 Session with boot v1, and never retries a refused boot v2 as boot v1. A binding without W0 context never receives boot v2. + +## Boot document v2 + +The transport is unchanged: one UTF-8 JSON object on standard input, at most 32 KiB, closed within 30 seconds. The object has exactly these keys. + +| Key | Rule | +| -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| `type` | `"boot"` | +| `version` | `2` | +| `managedContext` | `"managed-context/1"` | +| `runtimeInstanceId`, `runtimeIncarnation`, `leaseId`, `provisionRequestId` | the W0a identifier rule, `[A-Za-z0-9._:-]{1,128}` | +| `token` | a bearer credential of 1 to 512 characters in the token68 syntax, `[A-Za-z0-9._~+/-]+=*` | +| `epoch` | an integer from 1 to 2^53−1 | +| `capabilityDigest` | a string matching `sha256:[0-9a-f]{64}` | +| `isolationClass` | `"session"` or `"workspace"` | +| `tenantId`, `workspaceId` | the W0a identifier rule, `[A-Za-z0-9._:-]{1,128}` | +| `workspaceGeneration` | the W0a decimal rule: canonical decimal text from 1 to 2^63−1 | +| `storageId` | the W0a storage rule: 1 to 256 printable ASCII characters | +| `mountRoot` | an absolute path of 1 to 4096 UTF-8 bytes, well-formed, with no control character | + +- **Mount root.** `mountRoot` replaces v1's `workspaceCwd`. It is the root at which the Broker's storage resolver mounted this Workspace. An absolute path starts with `/`, with a drive letter and a separator (`C:\` or `C:/`), or with two backslashes (`\\`, a UNC path). The limit counts UTF-8 bytes, as `PATH_MAX` does. The worker treats it as data until the installation handler verifies it. +- **No path hash.** The daemon's path hash, the first 16 hexadecimal characters of SHA-256 over a path, is not carried. Where the worker needs a daemon-compatible local ID, it derives the hash from the verified mount root, so the public `workspaceId` is never a path hash. +- **No Session context.** A Workspace-isolated Runtime serves many Sessions, and `cwdRelative`, `contextConfigRef` and `contextRevision` must stay out of the placement identity. Each Session installs its context separately, as described below, whatever the isolation class. +- **Bounded identifiers.** v1 accepts identifiers of any length and refuses, at startup, a boot whose attestation response would exceed 16 KiB. v2 bounds the four Runtime identifiers instead, because attestation responses and receipts repeat them; with every repeated field bounded, those records always fit their limits (see [Sizes](#sizes)). It also bounds the token to the syntax that an `Authorization` header carries, and to 512 characters, the Broker's own limit, well within the header size that the worker's HTTP server accepts. +- **The Broker's values.** The Broker's identifiers today are UUIDs, or a UUID binding ID followed by `:` and its generation, and its tokens are unpadded base64url; all of them satisfy these rules. `BrokerValues.requireId` itself allows up to 512 characters of any kind except NUL, so W0c keeps the values it writes into boot v2 within these rules. + +## Ready record v2 + +The worker answers with one line on standard output. The line is a JSON object with exactly these keys: `type: "ready"`, `version: 2`, `managedContext: "managed-context/1"`, `runtimeInstanceId`, `runtimeIncarnation`, `leaseId`, `epoch` and `url`. + +- The four identity values repeat the boot document. +- `url` is `http://127.0.0.1:`, with a port from 1 to 65535 in canonical decimal (no leading zero) and no path, query or user information. +- Unlike v1, the Broker checks the exact key set. + +## Attestation v3 + +Attestation v3 keeps the v2 gate and changes only the identity fields. + +- **Route.** `POST /internal/managed-runtime/v3/attest`, with protocol version 3, the v2 size limits of 16 KiB for the request and the response, and `Cache-Control: no-store`. The new path makes a worker booted with v1 answer 404. +- **Headers.** As in v2: the bearer token, `Cache-Control: no-store`, a JSON content type, `X-Qwen-Managed-Lease-Id` and `X-Qwen-Managed-Lease-Epoch`. +- **Request.** Closed to `protocolVersion`, `managedContext`, `provisionRequestId`, `tenantId`, `workspaceId`, `workspaceGeneration`, `storageId`, `mountRoot`, `capabilityDigest` and `isolationClass`, with the boot v2 rules. +- **Response.** Closed to the request fields plus `runtimeInstanceId`, `runtimeIncarnation`, `leaseId` and `epoch`. +- **Checks.** The worker first checks the request against the rules above; a request that breaks one is 400 `managed_runtime_attestation_invalid`. It then compares the eight attested fields, every request field except `protocolVersion` and `managedContext`, with its boot document exactly, as strings, without normalizing paths. Any mismatch is 409 `managed_runtime_identity_conflict`. The attestation cases repeat every boot rule on an attested field. The fixtures also repeat every malformed request object with each attested field it leaves valid changed to another valid value, so a worker that compares any field before checking the shape fails them. + +## Context installation + +One request installs one Session's context on a Runtime and returns a receipt. W0c implements the handler; this slice fixes the shapes and the order of the checks. + +- **Route.** `POST /internal/managed-runtime/v3/context`, with protocol version 3, 16 KiB limits, `no-store` and the v2 headers. +- **Request.** Closed to these keys: + +| Key | Rule | +| ----------------- | ---------------------------------------------------------------------------------------------------------- | +| `protocolVersion` | `3` | +| `managedContext` | `"managed-context/1"` | +| `operationId` | the W0a identifier rule; the Broker's idempotency key for this installation | +| `sessionId` | the Runtime Session ID that Tool v2 references carry: 1 to 512 UTF-16 code units, well-formed, with no NUL | +| `binding` | an object closed to the seven `ContextBinding` fields, each with its W0a rule, as W0a's wire strings | +| `contextDigest` | a string matching `sha256:[0-9a-f]{64}` | + +The worker checks the request in this order and stops at the first failure: + +1. A request that breaks the shape is 400 `managed_runtime_attestation_invalid`. +2. A `contextDigest` that is not the W0a digest of `binding` is also 400 `managed_runtime_attestation_invalid`. +3. A `binding` whose `tenantId`, `workspaceId`, `workspaceGeneration` or `storageId` differs from the boot document is 409 `managed_runtime_identity_conflict`. A Runtime therefore never installs another Workspace's context. +4. An `operationId` already used with a different `sessionId` or `contextDigest` is 409 `managed_context_conflict`. Repeating an installation with the same three values returns the original receipt and changes nothing. +5. An installation for a Session that already has a different context is 409 `managed_context_conflict`. +6. In W0c, the handler resolves the effective directory by joining `mountRoot` and `cwdRelative`, and verifies it. If it cannot, it answers 409 `managed_context_unavailable` and installs nothing. +7. The worker records the installation and answers 200 with the receipt. + +The fixtures pin this order: + +- Every malformed request object is repeated after a successful installation, and again with each Workspace field of its binding that it leaves valid changed to another valid value. +- A binding that breaks a rule carries the digest of its raw fields, so only the rule refuses it. +- For each of the four Workspace fields, two sequences check that the Workspace part answers before the operation check and before the Session check. + +A worker that checks the shape, the binding rules or the digest later, or the Workspace part after the operation or the Session, fails them. + +- **Receipt.** Closed to `protocolVersion`, `managedContext`, `operationId`, `sessionId`, `runtimeInstanceId`, `runtimeIncarnation`, `epoch`, `contextDigest`, `contextRevision` and `workspaceGeneration`. The last three let the Broker check which context, revision and generation the Runtime installed. +- **Session IDs.** The Broker's own check for a Runtime Session ID allows unpaired surrogates, which its JSON encoder replaces with `?`; two different IDs would then reach the worker as one. The installation rule refuses an unpaired surrogate that arrives escaped, but it cannot see a collision that the encoder has already made. Only the Broker can prevent that, so W0c must tighten the Broker's check to this rule before it writes a Session ID into an installation request. +- **One context per Session.** A Session's installed context changes only through a later protocol for directory changes (W2); until then, step 5 refuses a different one. The same Session and context under a new `operationId` succeed with a new receipt. + +## Sizes + +Every field that a response repeats is bounded. So the attestation response and every receipt built from a valid boot document and a valid request fit within 16 KiB, and so do the largest valid attestation and installation requests. The TypeScript test builds the largest of each: about 9.9 KB for the attestation response and 9.4 KB for the installation request. These sizes count UTF-8 JSON that leaves non-ASCII characters unescaped, as `JSON.stringify` and the Broker's fastjson2 encoder write it. An encoder that escapes every non-ASCII character could push the largest installation request past its limit, so neither side may use one. + +## Errors + +| Status | Code | Class | When | +| ------ | --------------------------------------- | ------------ | ---------------------------------------------------------------------- | +| 401 | `managed_runtime_unauthorized` | credentials | missing or wrong bearer token | +| 400 | `managed_runtime_attestation_invalid` | protocol | a bad shape, header or protocol version, or a wrong `contextDigest` | +| 413 | `managed_runtime_attestation_too_large` | protocol | a request body over its limit | +| 409 | `managed_runtime_identity_conflict` | identity | the lease headers, the attestation or the binding differ from the boot | +| 409 | `managed_context_conflict` | identity | an `operationId` or a Session reused with other values | +| 409 | `managed_context_unavailable` | recovery | the effective directory cannot be verified (W0c) | +| 404 | none | incompatible | a route that this worker's boot version does not serve | + +The first four are the codes that attestation v2 and Tool v2 already use. A Broker keeps its current classification for them. Unlike v2, a 409 here carries one of three codes, and today's transport classifies a refusal by its status alone. A v3 client therefore classifies a refusal by its status and code together, and treats a 409 with a code it does not know as an identity conflict. The shared fixtures carry this table. `managed_context_conflict` is not retryable. `managed_context_unavailable` keeps the Session's tool gate closed and marks its context `recovery_blocked`, as the reference design requires; it never falls back to another directory. + +## Security + +- The token stays on standard input and in the bearer header. It is never in arguments, the environment, a response or an error message. +- `mountRoot` and the effective directory stay inside the private protocol. They never appear in a public DTO, a public error or a log line that leaves the Broker. +- The worker never takes identity from a request. Attestation compares the request with the boot document, and installation compares the Workspace part of the binding with the boot document and recomputes the digest. +- Paths are compared exactly. Canonicalization and containment are the installation handler's job, not the comparison's. +- The effective directory is where a Session's tools start, not a sandbox. As the Tool v2 contract requires, the Harness decides the Workspace boundary when it admits a call. + +## Shared schema and fixtures + +`packages/cli/src/serve/contracts/managed-context-v1.schema.json` and `.fixtures.json` hold the whole contract: + +- the token, the boot and ready versions, and the two v3 routes; +- valid and invalid boot documents and ready records; +- attestation requests and installation requests, each with its expected status and code, replayed against one canonical boot document; +- installation sequences that exercise idempotency and conflicts; +- the expected attestation response and receipts; +- the error table with each code's classification. + +The schema fixes each record's shape and the rules it can state readably. JSON Schema cannot state UTF-8 byte or UTF-16 unit limits, a digest over a binding, or a comparison with the boot document, and patterns could state the 2^63−1 bound and the segment rules of a directory's normal form only unreadably. The schema does state a directory's characters and that it starts neither with `/` nor with a drive letter. Fixture cases pin those rules, and the TypeScript test checks that the schema and the module disagree on no other case. + +An implementation independent of both languages computed the expected values and every `contextDigest`, as for W0a. + +- **TypeScript.** `packages/cli/src/serve/managed-context-envelope.ts` validates boot documents, ready records, attestation requests and installation requests, builds ready records, attestation responses and receipts, and keeps installations idempotent. The ready check is the reference for the Broker's check in W0c. It reuses W0a's validation and digest. The worker does not import it until W0c. +- **Java.** A conformance test in `runtime-broker` reads the schema and fixtures, pins every closed key set, route and constant, recomputes each installation digest with `ContextBinding`, and checks that the attestation response and each receipt repeat the boot document and the binding. The Java builders for boot v2 and the v3 client come with the wiring in W0c, where they have callers. +- **Fake worker.** It serves only v1 and is unchanged here. W0c extends it to v2 and makes it enforce the closed key sets of both versions. + +## Files affected + +- `packages/cli/src/serve/contracts/managed-context-v1.schema.json` and `.fixtures.json` (new). +- `packages/cli/src/serve/managed-context-envelope.ts` and its test (new). +- A conformance test in `packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/` (new). +- `packages/cli/src/serve/managed-workspace-binding.ts` exports three of its W0a checks for reuse; their behavior does not change. +- This design document in both languages (new), and the "Boot envelope" section and follow-up table of the W0a document, which now point here. +- The runtime-broker `README.md` and `QWEN.md`, whose maintenance rule for the digest now names these fixtures too. + +The worker, the attestation contract, the provisioner, the transport, the fake worker and the CI workflows do not change. + +## Validation plan + +- **TypeScript:** strict Ajv validation of the fixtures against the schema; every case replayed through the module, including the installation sequences; the schema checked against every case; and the largest records measured against their limits. +- **Java:** the conformance test pins the closed key sets, routes, constants and error table, and recomputes every installation digest with `ContextBinding`. +- **Independence:** the expected values come from an implementation written from this document, not from either language. +- **Mutation check:** each check of the TypeScript module is mutated in turn and the suite rerun; the PR reports the results. + +## Acceptance criteria + +- TypeScript agrees with every fixture, and Java with every key set, route, constant, error and installation digest in them. The Java checks of boot documents, ready records and attestation responses come with W0c. +- Every record built from valid input fits within its request or response body limit. +- Boot v1, ready v1, attestation v2 and Tool v2 are unchanged, and no worker or Broker behavior changes. +- An installation whose Workspace part differs from the boot document is refused, and so is one whose digest does not match its binding. +- Repeating an installation is idempotent, and reusing its `operationId` or its Session with other values is refused. + +## Open questions + +1. Should a worker that refuses a boot document write a refusal line before it exits? That would let the Broker tell a refusal, which it must never retry, from a crash, which it may retry. A worker that implements only v1 cannot write one, so the Broker still needs a retry bound for boot v2. +2. Should the installation request also carry the configuration installation for `contextConfigRef`, or should that take a later version of the route? +3. W0a's open question on control characters in `cwdRelative` (every Cc character or only NUL) still applies; the installation handler inherits the W0a rule. + +## Follow-up work + +| Slice | Scope | +| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| W0c | The worker accepts boot v2 and serves attestation v3 and installation; the storage resolver; the provisioner writes boot v2; the v3 transport client; the fake worker for v2; the invocation binding around Tool v2; the activation gate; the Broker's identifier and Session ID checks tightened to these rules before it writes them. | +| W0e | Capability advertisement (`workspace_context`) after the whole W0 chain passes. | diff --git a/docs/design/2026-09-25-managed-context-envelope.zh-CN.md b/docs/design/2026-09-25-managed-context-envelope.zh-CN.md new file mode 100644 index 00000000000..02bda04a5d3 --- /dev/null +++ b/docs/design/2026-09-25-managed-context-envelope.zh-CN.md @@ -0,0 +1,217 @@ +# Managed Context Envelope(W0a-2) + +[English](2026-09-25-managed-context-envelope.md) | [简体中文](2026-09-25-managed-context-envelope.zh-CN.md) + +状态:契约已定义,尚无 worker 或 Broker 使用。更新:2026-09-25。本文是 Managed Agent proposal [#12380](https://github.com/QwenLM/qwen-code/issues/12380) 的第二个 W0 切片,建立在 [Workspace 绑定契约](2026-09-25-managed-workspace-binding-contract.zh-CN.md)(W0a,#12681)之上。[这条答复](https://github.com/QwenLM/qwen-code/issues/12380#issuecomment-5825755703)要求现在就定下 envelope 的形状,契约先行:“先 envelope 形状 + 共享 schema/fixtures,后处理器”。下文的“参考设计”指该提案的 [Workspace 与 Session cwd 设计](https://github.com/doudouOUC/code_agent/blob/689121646cc25ca08a34508a5f5555ae15308833/qwen-code/feature/managed-agents/managed-agent-workspace-context.md)、[契约收口](https://github.com/doudouOUC/code_agent/blob/689121646cc25ca08a34508a5f5555ae15308833/qwen-code/feature/managed-agents/managed-agent-contract-closure.md)和 [attestation 设计](https://github.com/doudouOUC/code_agent/blob/689121646cc25ca08a34508a5f5555ae15308833/qwen-code/feature/managed-agents/managed-runtime-attestation.zh-CN.md),都取自 #12380 所链接的提交。 + +## 问题 + +Managed Runtime worker 通过 boot 文档得知自己的身份,再通过 attestation 证明这个身份。两者都只用一个目录 `workspaceCwd` 来描述 Runtime 的 Workspace,并把 `workspaceId` 当作不透明的标签。 + +W0 用 Registry 中的 Workspace 取代这个目录:一个租户、一个 Workspace ID、一个 generation 和一个 storage ID,Broker 把它的文件挂载在某个根目录下。W0 还加入了每个 Session 的上下文:一个相对目录、一个配置引用和一个 revision。W0a 把这七个值绑定成 `ContextBinding` 及其 `contextDigest`。 + +目前 worker 无法接收其中任何一项。在 boot 文档、attestation 或 Tool v2 上原地加字段,会破坏它们的封闭形状;而把 W0 Session 交给一个旧 worker,可能让工具在错误的目录中运行。因此 W0 需要一个协商出来的协议 `managed-context/1`,并配上新的严格版本,让旧 worker 在任何东西运行之前就拒绝 W0 Session。 + +## 现状 + +以下事实基于 `main` 的 `ab61e04161`。 + +- **Boot v1。** `LocalProcessRuntimeProvisioner` 向 worker 的标准输入写入一个 JSON 对象并关闭输入。该对象有 14 个键:Runtime 身份、`tenantId`、`workspaceId`、`workspaceGeneration`、`workspaceCwd`、`capabilityDigest`、`isolationClass`、`token`、`type: "boot"` 和 `version: 1`。worker 在 32 KiB、30 秒以内只接受恰好这个键集,然后通过 attestation 身份检查各字段的类型。被拒绝的 boot 会让 worker 在输出 ready 行之前退出,Java 报告为可重试的 `runtime_provision_failed`。 +- **Ready v1。** worker 在标准输出上回一行:`type`、`version: 1`、`runtimeInstanceId`、`runtimeIncarnation`、`leaseId`、`epoch` 和一个回环地址 `url`。Java 会比对回显的值,但忽略多余的键。 +- **Attestation v2。** `POST /internal/managed-runtime/v2/attest` 的请求封闭为 8 个键,响应为 12 个键。每个 scope 字段(包括 `workspaceCwd`)都与 boot 文档逐字比较,不一致返回 409 `managed_runtime_identity_conflict`。 +- **Tool v2。** execute、status 和 cancel 路由只通过 lease 请求头绑定到 Runtime。每次调用的 reference 带有 `sessionId`,但不带任何 Workspace、目录或上下文字段。自 #12671 起,worker 通过一个以 boot 中 `workspaceCwd` 为根目录的工具执行器提供这些路由,其原始路由 gate 只放行已声明的 v2 路由。执行器不会把工具路径或 shell 命令限制在该目录内;工具契约要求 Harness 在准入调用时决定 Workspace 边界,而这一准入尚未接通。 +- **放置。** `RuntimeScope` 的六个字段参与计算 Broker 的 request key 和 scope key。一个按 Workspace 隔离的 Runtime 由解析到同一 scope 的所有 Session 共享。 +- **契约。** Attestation v2 和 Tool v2 有共享的 schema 和 fixtures。boot 文档和 ready 记录都没有;Java 测试启动的 fake worker `fake-attestation-worker.mjs` 也什么都不校验。 +- **W0a。** `ContextBinding` 有七个字段:`tenantId`、`workspaceId`、`workspaceGeneration`、`storageId`、`cwdRelative`、`contextConfigRef` 和 `contextRevision`。Java 与 TypeScript 计算出相同的 `contextDigest`,由共享 fixtures 固定。 + +## 目标 + +- 定义 Broker 与 worker 如何就 `managed-context/1` 达成一致,以及任一方如何拒绝它。 +- 定义 boot v2 和 ready v2:boot v2 是携带 Workspace 绑定和挂载根目录的封闭形状,ready v2 是接受它的封闭记录。 +- 定义 attestation v3,让 W0c 复用现有的 gate,而不是另起一套 Workspace attestation。 +- 定义上下文安装请求及其回执,把一个 Session 的 `ContextBinding` 绑定到 Runtime。 +- 用一份两种语言都运行的共享 schema 和 fixtures 文件固定以上全部内容。 +- 保持 boot v1、ready v1、attestation v2 和 Tool v2 不变。 + +## 非目标 + +- **接线。** 本切片中没有 worker 接受 boot v2 或挂载 v3 路由,也没有 Broker 写出 boot v2。W0c 会在 #12671 完成的 worker 之上完成接线。 +- **挂载与文件系统检查。** 把 storage 解析为挂载根目录,以及检查存在性、realpath、符号链接和挂载身份,属于 W0c 的 storage 解析器和安装处理器。 +- **后续的绑定。** 根据 `contextConfigRef` 安装配置、activation gate、Tool v2 调用外层的逐次调用绑定,以及 journal 和 checkpoint 中的回执,属于 W0c 及之后的切片。 +- **放置。** `RuntimeScope`、Broker 的 request key 和 scope key 以及它的 schema 都不变。 + +## 协商 + +`managed-context/1` 是一个能力标记,命名风格与 `qwen-hosted-harness/1` 相同。以后如有不兼容的改动,就换一个新标记。 + +- **提出。** Broker 通过写出携带该标记的 boot v2 来提出这个协议。只有当 Runtime 绑定的 Session 带有 Workspace 上下文时,Broker 才写 boot v2;其他绑定仍用 boot v1。 +- **接受。** 实现了该协议的 worker 用 ready v2 回应,并重复这个标记。之后它提供 attestation v3 和安装路由,并对 attestation v2 返回 404,这样一个 Runtime 永远不会呈现两种身份。Tool v2 路由的形状不变,但在 boot v2 下,工具调用只会为已安装上下文的 Session 执行,并且在该上下文的实际目录中执行。W0c 通过按调用绑定实现这一点。 +- **在任何副作用之前拒绝。** + - 只实现 boot v1 的 worker 会因为封闭键集而拒绝 boot v2,并在输出 ready 行之前退出。 + - 收到 ready v1 记录、不带标记的 ready 记录,或者 v3 路由返回 404,都说明对端不兼容。 +- **不降级。** Broker 绝不会用 boot v1 为 W0 Session 创建 Runtime,也绝不会把被拒绝的 boot v2 改成 boot v1 重试。没有 W0 上下文的绑定永远不会收到 boot v2。 + +## Boot 文档 v2 + +传输方式不变:标准输入上的一个 UTF-8 JSON 对象,最大 32 KiB,30 秒内关闭。该对象恰好包含以下键。 + +| 键 | 规则 | +| -------------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| `type` | `"boot"` | +| `version` | `2` | +| `managedContext` | `"managed-context/1"` | +| `runtimeInstanceId`、`runtimeIncarnation`、`leaseId`、`provisionRequestId` | W0a 的标识符规则 `[A-Za-z0-9._:-]{1,128}` | +| `token` | 采用 token68 语法 `[A-Za-z0-9._~+/-]+=*`、长 1 到 512 个字符的 bearer 凭据 | +| `epoch` | 1 到 2^53−1 的整数 | +| `capabilityDigest` | 匹配 `sha256:[0-9a-f]{64}` 的字符串 | +| `isolationClass` | `"session"` 或 `"workspace"` | +| `tenantId`、`workspaceId` | W0a 的标识符规则 `[A-Za-z0-9._:-]{1,128}` | +| `workspaceGeneration` | W0a 的十进制规则:1 到 2^63−1 的规范十进制文本 | +| `storageId` | W0a 的 storage 规则:1 到 256 个可打印 ASCII 字符 | +| `mountRoot` | 1 到 4096 个 UTF-8 字节的绝对路径,格式良好,不含控制字符 | + +- **挂载根目录。** `mountRoot` 取代 v1 的 `workspaceCwd`,是 Broker 的 storage 解析器挂载这个 Workspace 的根目录。绝对路径以 `/` 开头,或以盘符加分隔符开头(`C:\` 或 `C:/`),或以两个反斜杠开头(`\\`,UNC 路径)。长度上限按 UTF-8 字节计算,与 `PATH_MAX` 相同。在安装处理器验证之前,worker 只把它当作数据。 +- **不带路径哈希。** daemon 的路径哈希(路径 SHA-256 的前 16 个十六进制字符)不在其中。worker 需要与 daemon 兼容的本地 ID 时,会从验证过的挂载根目录自行推导,因此公开的 `workspaceId` 永远不是路径哈希。 +- **不带 Session 上下文。** 按 Workspace 隔离的 Runtime 服务多个 Session,而 `cwdRelative`、`contextConfigRef` 和 `contextRevision` 必须留在放置身份之外。无论哪种隔离级别,每个 Session 都按下文所述单独安装自己的上下文。 +- **有界的标识符。** v1 接受任意长度的标识符,并在启动时拒绝 attestation 响应会超过 16 KiB 的 boot。v2 改为给四个 Runtime 标识符设定上界,因为 attestation 响应和回执会重复它们;所有被重复的字段都有上界,这些记录就总能放进各自的上限(见[大小](#大小))。v2 还把 token 限定为 `Authorization` 请求头能够携带的语法,并限定在 Broker 自己的上限 512 个字符以内,远低于 worker 的 HTTP 服务器所能接受的请求头大小。 +- **Broker 的取值。** Broker 目前的标识符是 UUID,或者是 UUID 形式的 binding ID 后接 `:` 和其 generation;它的 token 是不带填充的 base64url。这些都满足上述规则。`BrokerValues.requireId` 本身允许最多 512 个除 NUL 以外的任意字符,因此 W0c 要确保它写入 boot v2 的值符合上述规则。 + +## Ready 记录 v2 + +worker 在标准输出上回一行。这一行是一个 JSON 对象,恰好包含以下键:`type: "ready"`、`version: 2`、`managedContext: "managed-context/1"`、`runtimeInstanceId`、`runtimeIncarnation`、`leaseId`、`epoch` 和 `url`。 + +- 四个身份值重复 boot 文档中的值。 +- `url` 为 `http://127.0.0.1:`,端口为 1 到 65535 的规范十进制(不带前导零),不带路径、查询或用户信息。 +- 与 v1 不同,Broker 会检查精确的键集。 + +## Attestation v3 + +Attestation v3 保留 v2 的 gate,只改变身份字段。 + +- **路由。** `POST /internal/managed-runtime/v3/attest`,协议版本 3,沿用 v2 的大小上限(请求和响应各 16 KiB),并带 `Cache-Control: no-store`。新路径让用 v1 启动的 worker 返回 404。 +- **请求头。** 与 v2 相同:bearer token、`Cache-Control: no-store`、JSON 内容类型、`X-Qwen-Managed-Lease-Id` 和 `X-Qwen-Managed-Lease-Epoch`。 +- **请求。** 封闭为 `protocolVersion`、`managedContext`、`provisionRequestId`、`tenantId`、`workspaceId`、`workspaceGeneration`、`storageId`、`mountRoot`、`capabilityDigest` 和 `isolationClass`,适用 boot v2 的规则。 +- **响应。** 封闭为请求中的字段,再加上 `runtimeInstanceId`、`runtimeIncarnation`、`leaseId` 和 `epoch`。 +- **检查。** worker 先按上述规则检查请求;违反任何一条的请求返回 400 `managed_runtime_attestation_invalid`。然后把八个被证明的字段(即除 `protocolVersion` 和 `managedContext` 之外的每个请求字段)与自己的 boot 文档按字符串逐字比较,不对路径做规范化。任何不一致都是 409 `managed_runtime_identity_conflict`。attestation 用例会重复 boot 中针对每个被证明字段的规则。fixtures 还会把每个格式错误的请求对象,在它未触及的每个被证明字段都换成另一个合法值后再重复一次,因此在检查形状之前比较任何字段的 worker 都无法通过它们。 + +## 上下文安装 + +一次请求在一个 Runtime 上安装一个 Session 的上下文,并返回一份回执。处理器由 W0c 实现;本切片固定形状和检查顺序。 + +- **路由。** `POST /internal/managed-runtime/v3/context`,协议版本 3,上限 16 KiB,`no-store`,请求头与 v2 相同。 +- **请求。** 封闭为以下键: + +| 键 | 规则 | +| ----------------- | ---------------------------------------------------------------------------------------- | +| `protocolVersion` | `3` | +| `managedContext` | `"managed-context/1"` | +| `operationId` | W0a 的标识符规则;Broker 为这次安装给出的幂等键 | +| `sessionId` | Tool v2 引用中携带的 Runtime Session ID:1 到 512 个 UTF-16 码元,格式良好,不含 NUL | +| `binding` | 封闭为七个 `ContextBinding` 字段的对象,每个字段适用 W0a 的规则,取 W0a 的线上字符串形式 | +| `contextDigest` | 匹配 `sha256:[0-9a-f]{64}` 的字符串 | + +worker 按以下顺序检查请求,遇到第一个失败就停止: + +1. 不符合形状的请求返回 400 `managed_runtime_attestation_invalid`。 +2. `contextDigest` 不是 `binding` 的 W0a 摘要时,同样返回 400 `managed_runtime_attestation_invalid`。 +3. `binding` 的 `tenantId`、`workspaceId`、`workspaceGeneration` 或 `storageId` 与 boot 文档不同时,返回 409 `managed_runtime_identity_conflict`。因此一个 Runtime 永远不会安装其他 Workspace 的上下文。 +4. 一个 `operationId` 已经以不同的 `sessionId` 或 `contextDigest` 用过时,返回 409 `managed_context_conflict`。用相同的三个值重复安装,会返回原来的回执,不做任何改变。 +5. 为已有不同上下文的 Session 安装时,返回 409 `managed_context_conflict`。 +6. 在 W0c 中,处理器把 `mountRoot` 与 `cwdRelative` 拼接成实际目录并加以验证。无法验证时返回 409 `managed_context_unavailable`,不安装任何东西。 +7. worker 记录这次安装,返回 200 和回执。 + +fixtures 固定了这一顺序: + +- 每个格式错误的请求对象都会在一次成功安装之后重复一次;再把它的 binding 中未触及的每个 Workspace 字段换成另一个合法值,又重复一次。 +- 违反规则的 binding 携带按其原始字段计算的摘要,因此只有规则本身会拒绝它。 +- 对四个 Workspace 字段中的每一个,都有两个序列检查 Workspace 部分的检查先于操作检查、也先于 Session 检查。 + +把形状、binding 规则或摘要的检查放到后面,或者把 Workspace 部分的检查放到操作或 Session 检查之后的 worker,都无法通过它们。 + +- **回执。** 封闭为 `protocolVersion`、`managedContext`、`operationId`、`sessionId`、`runtimeInstanceId`、`runtimeIncarnation`、`epoch`、`contextDigest`、`contextRevision` 和 `workspaceGeneration`。最后三项让 Broker 可以核对 Runtime 安装的是哪个上下文、哪个 revision 和哪个 generation。 +- **Session ID。** Broker 自己对 Runtime Session ID 的检查允许未配对的代理项,而它的 JSON 编码器会把它们替换成 `?`,于是两个不同的 ID 到达 worker 时会变成同一个。安装规则能拒绝以转义形式到达的未配对代理项,却看不到编码器已经造成的碰撞。只有 Broker 能避免这种碰撞,因此 W0c 必须在把 Session ID 写入安装请求之前,把 Broker 的检查收紧到本规则。 +- **每个 Session 一个上下文。** 一个 Session 已安装的上下文,只能通过之后用于目录变更的协议(W2)来改变;在那之前,第 5 步会拒绝不同的上下文。同一个 Session 以同一个上下文、换一个新的 `operationId` 安装时会成功,并得到新的回执。 + +## 大小 + +响应中重复的每个字段都有上界。因此,由合法 boot 文档和合法请求构造出的 attestation 响应和每张回执都不超过 16 KiB,最大的合法 attestation 请求和安装请求也同样不超过。TypeScript 测试会构造出其中每一种的最大值:attestation 响应约 9.9 KB,安装请求约 9.4 KB。这些大小按不转义非 ASCII 字符的 UTF-8 JSON 计算,`JSON.stringify` 和 Broker 的 fastjson2 编码器都这样写出。转义所有非 ASCII 字符的编码器可能让最大的安装请求超出上限,因此双方都不能使用这种编码器。 + +## 错误 + +| 状态码 | 错误码 | 类别 | 何时 | +| ------ | --------------------------------------- | ------------ | --------------------------------------------------- | +| 401 | `managed_runtime_unauthorized` | credentials | bearer token 缺失或错误 | +| 400 | `managed_runtime_attestation_invalid` | protocol | 形状、请求头或协议版本错误,或 `contextDigest` 错误 | +| 413 | `managed_runtime_attestation_too_large` | protocol | 请求体超过其上限 | +| 409 | `managed_runtime_identity_conflict` | identity | lease 请求头、attestation 或 binding 与 boot 不一致 | +| 409 | `managed_context_conflict` | identity | 以其他值复用 `operationId` 或 Session | +| 409 | `managed_context_unavailable` | recovery | 无法验证实际目录(W0c) | +| 404 | 无 | incompatible | 该 worker 的 boot 版本不提供的路由 | + +前四个是 attestation v2 和 Tool v2 已经在用的错误码,Broker 对它们保持现有的分类。与 v2 不同,这里的 409 可能带三种错误码之一,而现在的 transport 只按状态码对拒绝分类。因此 v3 客户端按状态码和错误码一起分类,并把错误码未知的 409 当作身份冲突。共享 fixtures 中包含这张表。`managed_context_conflict` 不可重试。`managed_context_unavailable` 会保持该 Session 的工具 gate 关闭,并按参考设计的要求把它的上下文标记为 `recovery_blocked`;它绝不会回退到其他目录。 + +## 安全 + +- token 只出现在标准输入和 bearer 请求头中,绝不出现在命令行参数、环境变量、响应或错误信息里。 +- `mountRoot` 和实际目录只留在私有协议之内,绝不出现在公开 DTO、公开错误或离开 Broker 的日志行中。 +- worker 从不从请求中获取身份。attestation 把请求与 boot 文档比较;安装把 binding 中 Workspace 的部分与 boot 文档比较,并重新计算摘要。 +- 路径逐字比较。规范化和包含关系检查是安装处理器的职责,而不是比较的职责。 +- 实际目录是 Session 的工具开始执行的位置,而不是沙箱。按照 Tool v2 契约的要求,Workspace 边界由 Harness 在准入调用时决定。 + +## 共享 schema 与 fixtures + +`packages/cli/src/serve/contracts/managed-context-v1.schema.json` 和 `.fixtures.json` 包含整个契约: + +- 标记、boot 和 ready 的版本,以及两条 v3 路由; +- 合法和非法的 boot 文档与 ready 记录; +- attestation 请求和安装请求,各自带期望的状态码和错误码,针对同一个规范的 boot 文档重放; +- 检验幂等和冲突的安装序列; +- 期望的 attestation 响应和回执; +- 错误表,以及每个错误码的分类。 + +schema 固定每种记录的形状,以及它能以可读方式表达的规则。JSON Schema 无法表达 UTF-8 字节数或 UTF-16 码元数的上限、binding 的摘要,以及与 boot 文档的比较;2^63−1 的上界和目录规范形式中关于路径段的规则虽然能用 pattern 表达,但只能写得难以阅读。schema 确实表达了目录可用的字符,以及目录既不以 `/` 开头、也不以盘符开头。这些规则由 fixture 用例固定,TypeScript 测试还会检查 schema 与模块在其他用例上没有分歧。 + +与 W0a 一样,期望值和每个 `contextDigest` 都由一个独立于两种语言的实现计算得出。 + +- **TypeScript。** `packages/cli/src/serve/managed-context-envelope.ts` 校验 boot 文档、ready 记录、attestation 请求和安装请求,构造 ready 记录、attestation 响应和回执,并保证安装的幂等性。其中对 ready 记录的校验是 W0c 中 Broker 侧校验的参照。它复用 W0a 的校验和摘要。在 W0c 之前,worker 不会引入它。 +- **Java。** `runtime-broker` 中的一致性测试读取 schema 和 fixtures,固定每个封闭键集、路由和常量,用 `ContextBinding` 重新计算每个安装摘要,并检查 attestation 响应和每张回执都重复了 boot 文档和绑定中的值。boot v2 的 Java 构造器和 v3 客户端随 W0c 的接线一起加入,那时它们才有调用方。 +- **Fake worker。** 它只提供 v1,本切片不改动。W0c 会把它扩展到 v2,并让它对两个版本都强制封闭键集。 + +## 涉及文件 + +- `packages/cli/src/serve/contracts/managed-context-v1.schema.json` 与 `.fixtures.json`(新增)。 +- `packages/cli/src/serve/managed-context-envelope.ts` 及其测试(新增)。 +- `packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/` 中的一致性测试(新增)。 +- `packages/cli/src/serve/managed-workspace-binding.ts` 导出它的三项 W0a 检查以供复用,行为不变。 +- 本设计文档的中英文两个版本(新增),以及 W0a 文档的“启动信封”一节和后续工作表,它们现在指向本文。 +- runtime-broker 的 `README.md` 和 `QWEN.md`,其中关于摘要的维护规则现在也点名这些 fixtures。 + +worker、attestation 契约、provisioner、transport、fake worker 和 CI workflow 都不变。 + +## 验证计划 + +- **TypeScript:** 用严格模式的 Ajv 按 schema 校验 fixtures;让每个用例(包括安装序列)经过该模块重放;用每个用例检查 schema;并测量最大的记录是否在上限之内。 +- **Java:** 一致性测试固定封闭键集、路由、常量和错误表,并用 `ContextBinding` 重新计算每个安装摘要。 +- **独立性:** 期望值来自根据本文编写的实现,而不是来自任何一种语言。 +- **变异检查:** 逐一变异 TypeScript 模块中的每项检查并重跑测试;结果写在 PR 中。 + +## 验收标准 + +- TypeScript 与每个 fixture 一致,Java 与其中的每个键集、路由、常量、错误和安装摘要一致。boot 文档、ready 记录和 attestation 响应的 Java 检查随 W0c 一起加入。 +- 由合法输入构造出的每条记录都不超过其请求体或响应体上限。 +- Boot v1、ready v1、attestation v2 和 Tool v2 保持不变,worker 和 Broker 的行为也都不变。 +- Workspace 部分与 boot 文档不同的安装会被拒绝,摘要与 binding 不符的安装也会被拒绝。 +- 重复安装是幂等的;以其他值复用它的 `operationId` 或 Session 会被拒绝。 + +## 待决问题 + +1. 拒绝 boot 文档的 worker 是否应在退出前写一行拒绝记录?这能让 Broker 区分拒绝(绝不能重试)和崩溃(可以重试)。只实现 v1 的 worker 写不出这样的记录,所以 Broker 仍然需要为 boot v2 设置重试上限。 +2. 安装请求是否也应携带 `contextConfigRef` 对应的配置安装,还是留给这条路由的后续版本? +3. W0a 关于 `cwdRelative` 中控制字符的待决问题(拒绝所有 Cc 字符还是只拒绝 NUL)仍然适用;安装处理器沿用 W0a 的规则。 + +## 后续工作 + +| 切片 | 范围 | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| W0c | worker 接受 boot v2,并提供 attestation v3 和安装;storage 解析器;provisioner 写出 boot v2;v3 transport 客户端;支持 v2 的 fake worker;Tool v2 外层的逐次调用绑定;activation gate;在写出标识符和 Session ID 之前,把 Broker 对它们的检查收紧到上述规则。 | +| W0e | 在整个 W0 链路验证通过之后,再声明能力(`workspace_context`)。 | diff --git a/docs/design/2026-09-25-managed-session-typescript-foundation.md b/docs/design/2026-09-25-managed-session-typescript-foundation.md new file mode 100644 index 00000000000..bd38ce65738 --- /dev/null +++ b/docs/design/2026-09-25-managed-session-typescript-foundation.md @@ -0,0 +1,155 @@ +# Managed Session TypeScript Durable Foundation + +[English](2026-09-25-managed-session-typescript-foundation.md) | [简体中文](2026-09-25-managed-session-typescript-foundation.zh-CN.md) + +The [Hosted integration design](2026-09-21-managed-session-durable-store.md) covers the full control plane; this document covers the TypeScript foundation in PR #12693. + +Status: TypeScript storage and recovery primitives for PR #12693. Updated: 2026-09-25. This PR does not wire a Hosted Agent loop or provide a Java service, database migrations, OSS implementation, or cross-process failover proof. Those belong to the control-plane integration split from [#12358](https://github.com/QwenLM/qwen-code/pull/12358). Statements about the target deployment below are requirements, not evidence that this PR deploys them. + +## 1. Decision + +For Hosted Managed Sessions, use a durable physical store behind the TypeScript semantic authority: + +- The Harness validates and creates private session records, checkpoints, and command receipts. +- The target Java store owns MySQL transactions, writer generations, leases, and head compare-and-set (CAS). +- The target resource placement is MySQL for bodies up to and including 64 KiB and immutable OSS objects above 64 KiB. This PR implements the HTTP inline client only; larger resources are explicitly rejected. +- Standalone adapters retain local JSONL and session-owned resource files. A session uses one backend; there is no dual write or local-to-remote migration. + +A shared filesystem or one appendable object cannot replace the transaction and stale-writer contract. Immutable objects can hold bytes; the journal head determines which bytes are committed. + +## 2. Verified Current Baseline + +This PR contains: + +- `LocalManagedSessionAuthority`, the journal/resource interfaces, local adapters, and HTTP adapters. +- Checkpoint parsing, prompt inbox, activation queue, logical Harness handles, and a process-local Runtime dispatch gate. +- Managed transcript projection and legacy maintenance guards. +- Unit tests and a shared HTTP contract fixture. The HTTP tests use a fake service; they do not verify a Java/MySQL implementation. + +The factory, scheduler, and admission components are library primitives. Their presence does not start a model, allocate a Runtime, or enable Hosted failover. Java lifecycle routes, authenticated parent descriptors, Broker reconciliation, and owner replacement are integration work outside this diff. + +## 3. Options Considered + +| Option | Tradeoff | Decision | +| ------------------------------------------ | ------------------------------------------------------------------------------ | --------------------------------- | +| Per-session or shared filesystem | Small adapter, but filesystem ownership is not distributed side-effect fencing | Retain for standalone development | +| One appendable object | No atomic resource manifest, command receipt, and writer CAS | Reject as session authority | +| MySQL for all bodies | Atomic, but large histories inflate database storage | Keep small bodies inline | +| MySQL journal plus immutable object bodies | Requires scoped API and recovery verification | Target Hosted architecture | + +## 4. Target Topology and Ownership + +```mermaid +flowchart LR + JAVA[Java control plane] --> H[TypeScript Harness] + H --> STORE[Internal session store] + STORE --> DB[(MySQL)] + STORE --> OSS[(Immutable OSS objects)] + H --> BROKER[Runtime Broker] + BROKER --> RT[Tool Runtime and workspace] +``` + +Java owns public Session/Turn lifecycle and tenant authorization. The Harness owns conversation semantics. The physical store owns journal commit and writer fencing. The Broker owns execution identity and dispatch; the Runtime owns workspace side effects. + +The common key is `(tenantId, workspaceId, sessionId)`. A writer generation fences storage writes; it does not prove an earlier external side effect stopped. The in-process dispatch gate is not a replacement for a durable Broker fence. + +### 4.1 Session-management ownership + +Local `SessionService` may display, archive, unarchive, and delete a closed local Managed session and its private resources. Daemon maintenance holds a claim against the sealed schema-3 lock while moving or deleting the transcript, then leaves the sealed writer fence intact. Startup and in-session legacy resume, as well as legacy recording, must reject a Managed transcript before binding or appending through the old engine; legacy rename and fork must also reject it. ACP restore returns a typed execution-engine error for this rejection, which the daemon maps to HTTP 409. Managed renames go through committed `session_metadata` records. Session listings use the already-read execution-engine record to skip Managed metadata probes for legacy transcripts while preserving Managed title and source projection. + +### 4.2 Public Session lifecycle protocol + +Hosted create/load/rename/archive/delete APIs are outside this PR. Before enabling them, the control plane must enforce tenant scope, lifecycle idempotency, active-Turn exclusion, and cold load without silently replacing an existing journal. Local resource cleanup is not evidence of remote physical erasure. + +## 5. Core Storage Contracts + +`ManagedSessionJournalStore.open` returns one session-scoped handle. The handle supports reading, appending one complete semantic transaction, sealing, and aborting an unsuccessful open. The optional `blockRecovery` operation persists a remote recovery failure. `ManagedSessionResourceStore` publishes and reads digest-verified immutable references. + +Local writes use `SessionWriterLease` schema 3 and pin the Managed format. Sealing records the last committed sequence and commit-chain digest alongside the physical transcript proof. Certified takeover must verify both. Explicit local tail recovery removes complete JSONL event lines lacking a commit marker; an incomplete physical line remains fail-closed under the existing writer-lease checks. + +The HTTP adapter acquires/renews/seals a scoped writer, encodes the transaction once, checks commit receipts, and verifies paged restore metadata and exact record bytes. It does not accept redirects. Resource reads verify kind, schema, length, and SHA-256. + +Staged inline resources become durable only in the transaction that references them. A validated Harness checkpoint's embedded references are included in that transaction's resource closure, including history, approval, and tool-outcome references. Opaque user message bytes are not recursively interpreted as storage references. Unreferenced staged resources must not be published by an unrelated commit. The 1,024-resource limit includes checkpoint dependencies. + +## 6. Relational Data Model + +The target service needs a session head, ordered transactions, immutable resource catalog, and revision-to-resource references. These SQL tables and migrations are not included here. + +The head must contain writer generation/lease, journal revision, committed sequence, commit-chain digest, activation epoch, checkpoint pointer, and recovery status. Transactions retain exact UTF-8 JSONL bytes and idempotent receipts. Resource rows bind scope, immutable metadata, placement, and verified bytes/object location. + +## 7. Internal API Contract + +The client uses `/internal/managed-session-store/v1/sessions/{sessionId}` with `X-Qwen-Tenant-Id` and `X-Qwen-Managed-Writer-Token`, plus workspace and writer identity in requests. It supports writer acquire/renew/seal, transaction commit/list, restore, resource read, and recovery block operations. Responses require `Cache-Control: no-store`. + +The writer token is generated by the client and proves possession of a lease secret, not service identity or tenant authorization. Before deployment the service must authenticate the caller and authorize the supplied tenant/workspace/session independently. A caller-controlled tenant header and writer token alone are insufficient. This PR cannot establish that server boundary because the server is not present. + +## 8. Commit Protocol + +1. Validate scope, actor, activation, event sequence, command content, and resource metadata. +2. Publish local immutable bytes, or stage HTTP inline bytes until commit. +3. Form a contiguous event batch and commit marker. `eventsDigest` hashes the complete canonical event content, including scope, timestamps, subjects, and payloads. The earlier foundation's identity-only prototype digest is not an integrity proof accepted by this authority. +4. Commit exact bytes and all referenced resources under the writer generation and expected journal head. A lost response may be retried only with the same command identity and content. +5. Advance in-memory state and expose success only after the physical commit succeeds. + +Harness checkpoint read/modify/write operations and handoff share one handle queue. Conflicting approval and Runtime waits must not both succeed by overwriting the same prior checkpoint. Starting a new Agent run invalidates the previous run's safety boundary; only a subsequently committed boundary can authorize handoff. + +Activation renewal that cannot extend the horizon must not append an invalid journal event. A cancelled, never-started inbox item cannot later become activation-ready. State preconditions are checked before durable append so rejected requests do not poison reopen. + +## 9. Open and Recovery Protocol + +Local reopen verifies the committed journal and any sealed takeover proof. Checkpoint parsing distinguishes resumable Harness state from historical branch display records and blocks invalid or missing state. Transcript projection reads only committed records and verifies referenced bodies. + +Remote deployment additionally requires the control plane to: + +1. Obtain a higher fenced writer generation and verify the journal/resource closure. +2. Reconcile every original Broker execution identity. Unknown outcomes block recovery; they do not authorize replay. +3. Verify the workspace identity and availability. +4. Bind the replacement Harness and public event cursor under the active Turn lease. +5. Resume the checkpoint without resubmitting the original prompt or duplicating tools. + +This PR's fake-service and logical-handle tests cover component behavior. They do not prove these five deployment steps or recovery after process/disk loss. Ordered tool batches are represented in checkpoints, but end-to-end multi-tool and cancellation recovery remain integration gates. + +## 10. Latency and Availability + +Commit at semantic boundaries rather than on every text delta. Do not acknowledge terminal success before the final private state is durable. Storage outages must stop new uncommitted progression; restore must never substitute an empty session for unavailable data. + +Measure first-token overhead, commit and restore p50/p95/p99, bytes per Turn, resource counts, and lease renewal behavior before enabling Hosted routing. No latency or availability claim is established by this PR. + +## 11. Retention, Deletion, and Security + +Private journal retention is separate from public event replay. The Hosted deployment must retain every referenced resource, implement tombstones and safe orphan collection, encrypt remote bodies, and coordinate database/object backups. These remote operations remain out of scope. + +Only trusted internal callers may receive raw history or lease capabilities. Do not put credentials or private context in public events. Local schema-3 locks prevent older lease implementations from writing Managed transcripts. + +## 12. Rollout and Reviewable Changes + +- **D0, this PR:** local storage seam, semantic authority, checkpoint/queue primitives, transcript projection, lease compatibility, and focused tests. +- **D1 client, this PR:** inline HTTP client and shared fixture. The matching Java service, migrations, authorization, and real database contract proof are separate prerequisites. +- **D2, later integration:** Hosted routing, cold load, immutable object bodies above 64 KiB, Broker reconciliation, and real owner replacement. +- **D3, production gate:** deterministic process-failure matrix, workspace recovery, event reconstruction, retention, metrics, and backup/restore exercises. + +Do not advertise automatic cross-Pod recovery until the integrated failure matrix passes. Existing local sessions are not migrated. + +## 13. Acceptance Matrix + +| Scenario | Required result / evidence | +| -------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| Build this PR against its base | Build and typecheck without files from sibling PRs | +| Local seal and takeover | Schema-3 proof verified; older writers refused | +| Event payload or scope changes under the same identities | Commit digest verification rejects altered bytes | +| Same-millisecond renewal or cancel/readiness race | No unreplayable journal event; reopen still succeeds | +| Legacy resume or title recording targets a Managed log | Reject before a legacy append; Managed reopen remains possible | +| Daemon archives, unarchives, or deletes a sealed log | Maintenance succeeds without removing its sealed writer fence | +| Approval and Runtime checkpoint mutations overlap | Preserve accepted work; reject conflicting admission | +| New Agent run observes an old turn-complete boundary | Refuse handoff until a new safety boundary commits | +| Checkpoint references staged history | Cold HTTP owner reads both checkpoint and history | +| More than 64 KiB resource | Explicit failure until OSS implementation exists | +| Corrupt/missing resource or unknown tool outcome | Block execution; never silently reconstruct empty state or replay tools | +| Original processes and disks disappear | Must be proven by a separate integrated process-failure test | +| Forged tenant/workspace/session | Authenticated service rejects before returning private bytes; server proof required | + +Run unit tests from `packages/core`, including the affected Managed runtime, writer lease, transcript reader/validator, and session maintenance suites. Run `npm run build && npm run typecheck` from the repository root. Unit success does not replace the server and process-level evidence above. + +## 14. Open Deployment Parameters + +Choose and validate the service authentication mechanism, lease duration and clock behavior, MySQL durability policy, OSS configuration, workspace persistence, resource retention, maximum restore size, and checkpoint frequency. These determine deployment readiness; the client alone cannot guarantee them. diff --git a/docs/design/2026-09-25-managed-session-typescript-foundation.zh-CN.md b/docs/design/2026-09-25-managed-session-typescript-foundation.zh-CN.md new file mode 100644 index 00000000000..d1e0f5c83ec --- /dev/null +++ b/docs/design/2026-09-25-managed-session-typescript-foundation.zh-CN.md @@ -0,0 +1,155 @@ +# Managed Session TypeScript 持久化基础层 + +[English](2026-09-25-managed-session-typescript-foundation.md) | [简体中文](2026-09-25-managed-session-typescript-foundation.zh-CN.md) + +[Hosted 集成设计](2026-09-21-managed-session-durable-store.zh-CN.md)涵盖完整控制面;本文仅涵盖 PR #12693 中的 TypeScript 基础层。 + +状态:PR #12693 的 TypeScript 存储和恢复基础模块。更新日期:2026-09-25。本 PR 未接入 Hosted Agent 循环,也不提供 Java 服务、数据库迁移、OSS 实现或跨进程 failover 证明。这些属于从 [#12358](https://github.com/QwenLM/qwen-code/pull/12358) 拆出的控制面集成。下文对目标部署的描述是要求,不能作为本 PR 已部署这些能力的证据。 + +## 1. 决策 + +Hosted Managed Session 在 TypeScript 语义权威之后使用持久化物理存储: + +- Harness 校验并创建私有会话记录、checkpoint 和命令回执。 +- 目标 Java 存储负责 MySQL 事务、writer generation、租约以及 journal head 的 compare-and-set(CAS)。 +- 目标资源放置规则为:小于等于 64 KiB 的 body 使用 MySQL,大于 64 KiB 的 body 使用不可变 OSS 对象。本 PR 仅实现 HTTP inline 客户端,明确拒绝更大的资源。 +- 独立运行适配器保留本地 JSONL 和会话私有资源文件。一个会话只选择一个后端,不双写,也不迁移既有本地会话。 + +共享文件系统或单个追加对象不能替代事务和旧 writer 拒绝协议。不可变对象存放字节,由 journal head 决定哪些字节已经提交。 + +## 2. 已核实的当前基线 + +本 PR 包含: + +- `LocalManagedSessionAuthority`、journal/resource 接口、本地适配器和 HTTP 适配器。 +- Checkpoint 解析、prompt inbox、activation 队列、逻辑 Harness handle 和进程内 Runtime dispatch gate。 +- Managed transcript 投影及旧维护路径保护。 +- 单元测试和共享 HTTP 契约 fixture。HTTP 测试使用 fake service,不能验证 Java/MySQL 实现。 + +Factory、scheduler 和 admission 组件是库级基础模块。存在这些模块不代表已经启动模型、分配 Runtime 或启用 Hosted failover。Java 生命周期路由、经过认证的父进程 descriptor、Broker 对账以及 owner 替换属于本 diff 之外的集成工作。 + +## 3. 方案比较 + +| 方案 | 取舍 | 决策 | +| ------------------------------- | ------------------------------------------------ | -------------------- | +| 每会话或共享文件系统 | 适配器简单,但文件系统所有权不能隔离分布式副作用 | 保留给独立运行和开发 | +| 单个追加对象 | 缺少资源清单、命令回执和 writer CAS 的原子性 | 不作为会话权威 | +| 所有 body 放在 MySQL | 可以原子提交,但长历史增加数据库体积 | 仅小 body inline | +| MySQL journal 加不可变对象 body | 需要作用域 API 和恢复校验 | Hosted 目标架构 | + +## 4. 目标拓扑与所有权 + +```mermaid +flowchart LR + JAVA[Java control plane] --> H[TypeScript Harness] + H --> STORE[Internal session store] + STORE --> DB[(MySQL)] + STORE --> OSS[(Immutable OSS objects)] + H --> BROKER[Runtime Broker] + BROKER --> RT[Tool Runtime and workspace] +``` + +Java 负责公共 Session/Turn 生命周期和租户授权,Harness 负责会话语义,物理存储负责 journal 提交和 writer fencing,Broker 负责执行身份和 dispatch,Runtime 负责 workspace 副作用。 + +共同的 key 是 `(tenantId, workspaceId, sessionId)`。Writer generation 隔离存储写入,不能证明此前的外部副作用已经停止。进程内 dispatch gate 不能替代持久化 Broker fence。 + +### 4.1 会话管理所有权 + +本地 `SessionService` 可以展示、归档、取消归档和删除已关闭的本地 Managed 会话及其私有资源。daemon 维护在移动或删除 transcript 时持有封存 schema-3 锁上的 claim,完成后保留封存的 writer fence。启动时及会话内的旧 resume 路径和旧录制路径必须在绑定或追加旧引擎记录前拒绝 Managed transcript;旧 rename 和 fork 也必须拒绝。ACP 恢复对该拒绝返回有类型的执行引擎错误,daemon 将其映射为 HTTP 409。Managed 重命名通过已提交的 `session_metadata` 记录完成。会话列表复用已读取的执行引擎记录,为 legacy transcript 跳过 Managed 元数据探测,同时保留 Managed 标题和来源投影。 + +### 4.2 公共会话生命周期协议 + +Hosted create/load/rename/archive/delete API 不在本 PR 中。启用前,控制面必须落实租户作用域、生命周期幂等、活跃 Turn 互斥和不静默替换既有 journal 的冷加载。本地资源清理不能证明远端数据已经物理擦除。 + +## 5. Core 存储契约 + +`ManagedSessionJournalStore.open` 返回作用域限定为单个会话的 handle,支持读取、追加完整语义事务、seal 和中止失败的 open。可选 `blockRecovery` 操作持久化远端恢复失败。`ManagedSessionResourceStore` 发布和读取带摘要的不可变引用。 + +本地写入采用 `SessionWriterLease` schema 3 并固定 Managed 格式。Seal 在物理 transcript proof 之外记录最后已提交序号和 commit-chain digest;certified takeover 必须同时验证两者。显式本地尾部恢复会清除缺 commit marker 的完整 JSONL 事件行;不完整物理行继续遵循已有 writer-lease 校验并 fail-closed。 + +HTTP 适配器获取、续租和 seal 有作用域的 writer,只编码一次事务,核对提交回执,并验证分页恢复元数据和精确记录字节。它不接受重定向。资源读取验证 kind、schema、长度和 SHA-256。 + +暂存的 inline 资源只在引用它的事务中持久化。经过校验的 Harness checkpoint 的内嵌引用必须加入该事务的资源闭包,包括历史、审批和工具结果引用;不递归地将不透明用户消息解释为存储引用。未引用的暂存资源不能由不相关事务发布。1,024 个资源上限包含 checkpoint 依赖。 + +## 6. 关系数据模型 + +目标服务需要会话 head、有序事务、不可变资源目录和 revision-to-resource 引用。本 PR 不包含这些 SQL 表及迁移。 + +Head 必须记录 writer generation/lease、journal revision、已提交序号、commit-chain digest、activation epoch、checkpoint 指针和恢复状态。事务保留精确 UTF-8 JSONL 字节及幂等回执;资源行绑定作用域、不可变元数据、放置位置及经过校验的字节或对象地址。 + +## 7. 内部 API 契约 + +客户端使用 `/internal/managed-session-store/v1/sessions/{sessionId}`,携带 `X-Qwen-Tenant-Id` 和 `X-Qwen-Managed-Writer-Token`,并在请求中提供 workspace 和 writer 身份。操作包括 writer acquire/renew/seal、事务 commit/list、restore、资源读取和 recovery block。响应要求 `Cache-Control: no-store`。 + +Writer token 由客户端生成,只能证明持有租约密钥,不能证明服务身份或租户授权。部署前,服务必须认证调用方,并独立授权所提交的 tenant/workspace/session。调用方自行提供的租户头和 writer token 不足以构成授权。本 PR 没有服务端,因而不能建立或证明这一安全边界。 + +## 8. 提交协议 + +1. 校验作用域、actor、activation、事件序号、命令内容和资源元数据。 +2. 发布本地不可变字节,或将 HTTP inline 字节暂存到提交时。 +3. 构建连续事件批次和 commit marker。`eventsDigest` 对完整 canonical 事件内容计算摘要,覆盖作用域、时间戳、subject 和 payload。此前基础层原型只含事件身份的摘要不能作为本权威接受的完整性证明。 +4. 在 writer generation 和预期 journal head 条件下,提交精确字节和全部引用资源。响应丢失只能使用相同命令身份与内容重试。 +5. 物理提交成功后才推进内存状态并对外确认成功。 + +Harness checkpoint 的读取、修改、写入及 handoff 共用一个 handle 队列。冲突的审批等待和 Runtime 等待不能通过覆盖同一旧 checkpoint 而同时成功。启动新 Agent run 会使上一轮安全边界失效,只有之后提交的新边界才能授权 handoff。 + +无法延长 horizon 的 activation 续租不能追加无效 journal 事件。已取消且尚未启动的 inbox 项不能再标记 activation-ready。状态前提必须在持久化追加前检查,拒绝的请求不能破坏 reopen。 + +## 9. 打开与恢复协议 + +本地 reopen 验证已提交 journal 及 sealed takeover proof。Checkpoint 解析区分可恢复 Harness 状态与历史分支展示记录,缺失或无效状态阻止执行。Transcript 投影只读取已提交记录并校验引用 body。 + +远端部署还要求控制面: + +1. 获得更高且经过 fencing 的 writer generation,并验证 journal/资源闭包。 +2. 对账每个原始 Broker 执行身份;未知结果阻止恢复,不能授权重放。 +3. 验证 workspace 身份和可用性。 +4. 在活跃 Turn 租约下绑定替换 Harness 和公共事件 cursor。 +5. 从 checkpoint 继续,不能重提原始 prompt 或重复执行工具。 + +本 PR 的 fake-service 和逻辑 handle 测试覆盖组件行为,不能证明这五个部署步骤,也不能证明进程和磁盘丢失后的恢复。Checkpoint 表达有序工具批次,但多工具和取消恢复的端到端验证仍是集成门槛。 + +## 10. 延迟与可用性 + +在语义边界提交,而不是为每个文本 delta 写私有 journal。最终私有状态持久化之前不能确认终态成功。存储故障必须停止新的未提交推进,恢复时不能用空会话替代不可用数据。 + +启用 Hosted routing 前,应测量首 token 增量、commit/restore p50/p95/p99、每 Turn 字节数、资源数量和续租行为。本 PR 不构成延迟或可用性承诺。 + +## 11. 保留、删除与安全 + +私有 journal 保留策略独立于公共事件 replay。Hosted 部署必须保留全部被引用资源,实现 tombstone、安全孤儿回收、远端 body 加密和数据库/对象联合备份。这些远端操作仍不在范围内。 + +只有受信内部调用方可以获得原始历史和租约能力,不能将凭证或私有上下文写入公共事件。本地 schema-3 锁阻止旧 lease 实现写入 Managed transcript。 + +## 12. 发布阶段与可评审改动 + +- **D0,本 PR:** 本地存储接口、语义权威、checkpoint/队列基础模块、transcript 投影、lease 兼容和定向测试。 +- **D1 客户端,本 PR:** inline HTTP 客户端和共享 fixture;配套 Java 服务、迁移、授权和真实数据库契约证明属于独立前提。 +- **D2,后续集成:** Hosted routing、冷加载、大于 64 KiB 的不可变对象、Broker 对账和真实 owner 替换。 +- **D3,生产门槛:** 确定性进程故障矩阵、workspace 恢复、事件重建、保留策略、指标和备份恢复演练。 + +集成故障矩阵通过前,不能宣称自动跨 Pod 恢复。不迁移既有本地会话。 + +## 13. 验收矩阵 + +| 场景 | 必须结果/证据 | +| -------------------------------------- | ---------------------------------------------------- | +| 在本 PR 基线上构建 | 不依赖兄弟 PR 文件即可 build/typecheck | +| 本地 seal 与 takeover | 验证 schema-3 proof;拒绝旧 writer | +| 保持事件身份却修改 payload 或作用域 | Commit digest 校验拒绝被修改的字节 | +| 同毫秒续租或 cancel/readiness 竞争 | 不产生无法重放事件,reopen 仍成功 | +| 旧 resume 或标题录制指向 Managed 日志 | 在旧记录追加前拒绝;Managed 仍可 reopen | +| daemon 归档、取消归档或删除已封存日志 | 维护成功,且保留封存 writer fence | +| 审批与 Runtime checkpoint 并发修改 | 保留已接受工作,拒绝冲突 admission | +| 新 Agent run 读取旧 turn-complete 边界 | 新安全边界提交前拒绝 handoff | +| Checkpoint 引用暂存历史 | 冷 HTTP owner 能读取 checkpoint 及历史 | +| 资源大于 64 KiB | OSS 实现之前明确失败 | +| 资源损坏/缺失或工具结果未知 | 阻止执行,不能静默重建空状态或重放工具 | +| 原进程和磁盘消失 | 必须由独立集成进程故障测试证明 | +| 伪造 tenant/workspace/session | 经过认证的服务在返回私有字节之前拒绝;需要服务端证明 | + +从 `packages/core` 运行单元测试,覆盖受影响的 Managed runtime、writer lease、transcript reader/validator 和会话维护套件;从仓库根目录运行 `npm run build && npm run typecheck`。单元测试成功不能替代上述服务端和进程级证据。 + +## 14. 待确定的部署参数 + +部署前选择并验证服务认证机制、租约时长和时钟行为、MySQL 持久性策略、OSS 配置、workspace 持久化、资源保留、最大恢复体积和 checkpoint 频率。这些参数决定部署就绪状态,客户端本身不能保证它们。 diff --git a/docs/design/2026-09-25-managed-workspace-binding-contract.md b/docs/design/2026-09-25-managed-workspace-binding-contract.md new file mode 100644 index 00000000000..3adb0981781 --- /dev/null +++ b/docs/design/2026-09-25-managed-workspace-binding-contract.md @@ -0,0 +1,306 @@ +# Managed Workspace Binding Contract (W0a) + +[English](2026-09-25-managed-workspace-binding-contract.md) | [简体中文](2026-09-25-managed-workspace-binding-contract.zh-CN.md) + +Status: implemented as a standalone package; nothing is wired yet. Updated: 2026-09-25. This is the first W0 slice of the Managed Agent proposal [#12380](https://github.com/QwenLM/qwen-code/issues/12380). [This reply](https://github.com/QwenLM/qwen-code/issues/12380#issuecomment-5825755703) to [the W0a questions](https://github.com/QwenLM/qwen-code/issues/12380#issuecomment-5819009126) settled the placement and the timing of the boot envelope. Below, "the reference contract" is the proposal's [Workspace and Session cwd design](https://github.com/doudouOUC/code_agent/blob/689121646cc25ca08a34508a5f5555ae15308833/qwen-code/feature/managed-agents/managed-agent-workspace-context.en.md) together with the [`WorkspaceRelativePath` schema](https://github.com/doudouOUC/code_agent/blob/689121646cc25ca08a34508a5f5555ae15308833/qwen-code/feature/managed-agents/managed-agent-public-api.openapi.yaml#L1453) of its public OpenAPI, and "the reference schema" is its [Workspace DDL](https://github.com/doudouOUC/code_agent/blob/689121646cc25ca08a34508a5f5555ae15308833/qwen-code/feature/managed-agents/managed-agent-workspace-schema.mysql.sql), all at the commit that #12380 links. + +## Problem + +A hosted Managed Session must run its tools in a Workspace that an administrator registered and that the actor is allowed to use. Tools start from a relative directory chosen when the Session is created. That choice must survive retries, restarts and changes to the tenant default. Java admission, the Runtime Broker and the Runtime worker must also agree on it byte for byte. + +Today no layer defines the choice. The Broker receives whatever `RuntimeScope` its caller resolves. Nothing validates a relative directory, checks the actor's access, applies a tenant default, or produces an identity that the worker can verify. If each layer later validates on its own terms, two failures become possible. A path that one layer normalizes and another rejects can put tools in the wrong directory. A default that is re-resolved on retry can move a Session to another Workspace. + +## Current state + +The facts below are from `main` at `73aa65a4b4`. + +- **Broker scope.** `RuntimeScope` carries `tenantId`, `workspaceId`, `workspaceGeneration` (a string), `canonicalCwd`, `capabilityDigest` and `isolationClass`. `HarnessSessionResolver` resolves one scope per Harness Session, and the Broker does not authenticate these values. `LocalProcessRuntimeProvisioner` copies `workspaceId`, `workspaceGeneration` and the canonical cwd into the worker's boot document. +- **Worker contract.** The boot document is closed-key `version: 1`. The v2 attestation request repeats the tenant and Workspace fields: `workspaceId` is opaque, `workspaceGeneration` is a non-empty string (numbers are rejected), and `workspaceCwd` is only checked for being non-empty. +- **Daemon registry.** The daemon's TypeScript `WorkspaceRegistry` routes local multi-workspace traffic. Its `workspaceId` is the first 16 hex characters of SHA-256 over the canonical cwd, and its generation is an in-memory counter. It has five states and no tenant. It is not the hosted Registry, and W0a does not change it. +- **Session records.** The managed session records from #12302 live in TypeScript core. They key a Session by `{tenantId, workspaceId, sessionId}` and carry no cwd or binding fields. +- **Missing pieces.** Upstream has no Java control plane, no `managed_agent_session` table and no Managed Agent public OpenAPI. Nothing defines a Workspace selection, a relative cwd, a context revision or a ContextBinding. +- **Digests.** Java and TypeScript share no digest canonicalization. The Broker's JDBC repositories already derive keys as SHA-256 over length-prefixed UTF-8 fields (`JdbcRepositorySupport.digest`). TypeScript core session records use a private canonical-JSON encoder; the exported `managedSessionEventsDigest` covers only the ordered event identities. + +## Goals + +- One lexical rule for the relative working directory (`cwdRelative`), based on the `WorkspaceRelativePath` rule of the reference contract. Language-neutral fixtures pin it, so Java and TypeScript reject and normalize the same inputs. +- A Workspace Registry record and a read contract, backed first by deployment configuration. +- Actor-scoped access with three levels. It drives list visibility, the `canCreateSession` hint and creation admission. +- A pure resolver that turns an actor and an optional selection into either a resolved Workspace or exactly one typed error. It covers the tenant default for an omitted selection. +- A `ContextBinding` value whose `contextDigest` Java and TypeScript compute identically, pinned by shared fixtures. +- A normalized form of the caller's selection, including an omission marker, that W0b can fold into its request digest. + +## Non-goals + +- Persisting Session bindings, creation receipts or operations. That is W0b. +- Resolving storage to a mount; wiring the Broker, Harness or worker; opening an activation gate. That is W0c. +- The versioned boot envelope `managed-context/1` and any attestation version bump. See [Boot envelope](#boot-envelope). +- Public or BFF DTOs, routes and cursors, which belong to Stage D and W0d, and capability advertisement: `workspace_context` stays false until every W0 slice has passed, which W0e decides. +- Agent, Bundle and configuration compatibility checks at admission. That is W0b. +- Filesystem checks: existence, realpath, symlinks and mount identity. Those belong to the Runtime in W0c. +- JDBC tables for the Registry. The configuration-backed Registry needs none, and the Runtime Broker schema stays unchanged. +- Changes to the daemon `WorkspaceRegistry` or to daemon routes. + +## Placement + +The code is the package `com.alibaba.qwen.code.runtimebroker.managedworkspace` inside the existing `packages/sdk-java/runtime-broker` module, as #12380 decided. A separate Maven module was set aside: it would have needed its own pom, CI lane and workflow-guard tests before any Registry logic was reviewable. A package keeps the change to the domain code and lets W0c wire the binding through `HarnessSessionResolver` in the same module. If the Registry later needs its own schema lifecycle, or a consumer that must not pull in the Broker, it can move to a module of its own then. + +The package stays framework-neutral: it uses only the JDK and no other Broker class, no Spring, no CLI internals and no scheduler, and nothing falls back to in-memory state behind a failing durable source. The shared fixtures and the TypeScript twin of the rule and the digest live under `packages/cli/src/serve`, next to the existing Runtime contracts. + +## Vocabulary + +Several words below already have other meanings in this repository. + +| Term | Meaning here | Not to be confused with | +| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| Workspace ID | Opaque and assigned by an administrator. It matches `[A-Za-z0-9._:-]{1,128}`, is unique within a tenant and is compared byte for byte. | The daemon's path-hash `workspaceId`. | +| workspace generation | The Registry's replacement counter: a positive 64-bit integer that only grows for a Workspace ID. On the wire and in digests it is a decimal string. | A Runtime binding generation, the daemon's `generationId`, or the `workspace_generation` capability. | +| storage ID | The logical identity of the Workspace's durable files. | A mount path. | +| ContextBinding | The Session's committed Workspace context. | A Runtime binding, which places a Runtime. | +| contextRevision | The version of the Session's context. It is 1 at creation, and W2 raises it. | The workspace generation, a configuration revision, or an activation epoch. | + +## Workspace Registry + +### Record + +A Registry record has these fields. The record is immutable. + +| Field | Rule | +| --------------------- | ---------------------------------------------------------------- | +| `tenantId` | `[A-Za-z0-9._:-]{1,128}` | +| `workspaceId` | `[A-Za-z0-9._:-]{1,128}` | +| `workspaceGeneration` | An integer from 1 to 2^63−1 | +| `storageId` | 1 to 256 printable ASCII characters (0x21 to 0x7E) | +| `displayName` | 1 to 512 code points, well-formed Unicode, no control characters | +| `state` | `active`, `draining` or `removed` | +| `policyRef` | 1 to 512 printable ASCII characters | +| `configRef` | 1 to 512 printable ASCII characters | + +The identifier pattern is the tenant pattern of the Java SDK's `ManagedSessionStoreConnection`, applied here to Workspace IDs as well. It is also a subset of the stable-ID rule for the TypeScript managed session key, so a hosted Workspace ID is valid in both places. ASCII identifiers also mean no Unicode normalization is ever needed before a digest. + +### States + +| State | Listed | Selectable for a new Session | Can be the default | +| ---------- | ------ | ---------------------------- | ------------------ | +| `active` | yes | yes | yes | +| `draining` | yes | no (`workspace_unavailable`) | no | +| `removed` | yes | no (`workspace_unavailable`) | no | + +Existing Sessions keep their binding in every state. What a draining or removed Workspace means for them belongs to W0e and W1. + +### Configuration source + +The read interface offers three operations: + +- Find one record by tenant and Workspace ID. +- Page through a tenant's records in byte order of the Workspace ID, which matches the `ascii_bin` collation of the reference schema. A page shorter than the limit means that no records follow, and every implementation accepts limits from 1 to 1000. +- Return the tenant's configured default Workspace ID. + +The first implementation is an immutable snapshot built from deployment configuration. Construction rejects the snapshot when: + +- A `(tenantId, workspaceId)` pair appears twice. +- A tenant default names a Workspace that is not in that tenant. +- Any field breaks its rule. + +There is no public registration API, and no Workspace ID is derived from a path. + +The embedding service applies a change by replacing the snapshot. A successor check rejects a replacement that would do any of the following: + +- Drop a Workspace. A Workspace is retired by moving it to `removed`, so Sessions pinned to it can still be explained later. +- Lower a Workspace's generation. +- Change its `storageId` without raising its generation. + +A later JDBC Registry, backed by the reference `managed_agent_workspace` table, can implement the same interface. It must compare tenant IDs exactly, as this package does: the reference schema leaves `tenant_id` in the base table's collation, which may ignore case. + +## Actor access + +The embedding service authenticates the actor and passes in a tenant ID and an actor ID. W0a never reads either from a request body. The actor ID follows the `displayName` rule rather than the identifier rule, so an e-mail address works. A policy returns one of three access levels for an actor and a record: + +- `NONE` +- `READ` +- `CREATE`, which implies `READ` + +Lookups are always scoped to the actor's tenant. A Workspace in another tenant cannot be told apart from a missing one, whatever the policy says. The policy is evaluated on every call and is never baked into a page or a result. W0a ships one explicit-grant policy for tests and single-tenant deployments; there is no allow-all default. + +Listing returns the records the actor can read, each with `canCreateSession`. That field is a permission hint only; the state is reported separately and the caller combines the two. Records come in Workspace ID order, with `hasMore` and a `defaultWorkspace`. The default is computed independently of the page and is present only when the tenant default exists, is `active`, and the actor has `CREATE`. Paging is by the last Workspace ID seen. The catalog reads the Registry in batches of 1000 whatever the page size, and a batch shorter than 1000 ends the scan. To keep `hasMore` exact, it scans past a full page until it finds one more readable record or reaches that end. A listing therefore makes at most one Registry page read for every full 1000 records it scans, plus one, however few of them the actor can read; looking up the default adds one or two more calls. Opaque cursors bound to tenant, actor and query belong to the API layer. + +## Working-directory rule + +`cwdRelative` is a string. The API layer turns an omitted field into `.` and rejects `null`. Every violation below is `invalid_cwd`. + +1. The value must be well-formed Unicode, with no unpaired surrogate. +2. Its length must be 1 to 1024 code points, the way JSON Schema counts. The empty string is invalid; it is not `.`. +3. It must contain no control character: Unicode category Cc, which covers C0 (including NUL), DEL and C1. The reference contract names only NUL; see [Open questions](#open-questions). +4. It must contain no backslash. +5. It must not start with `/`. +6. Its normal form, described below, must not start with a drive prefix: an ASCII letter followed by `:`. Checking the normal form also rejects spellings such as `./C:x`. +7. After splitting on `/`, no segment may be exactly `..`. + +Normalization then drops empty segments (from repeated or trailing `/`) and `.` segments, and joins the rest with `/`. If nothing is left, the result is `.`. Nothing else changes: spaces, case and non-ASCII characters are kept, there is no Unicode normalization, and nothing is percent-decoded. ContextBinding carries the normalized value, and W0b digests it. + +| Input | Result | +| -------------------- | -------------- | +| `.` or `./` or `./.` | `.` | +| `services//api/` | `services/api` | +| `./services/./api` | `services/api` | +| `a/ b /c` | `a/ b /c` | +| empty string | `invalid_cwd` | +| `..` or `a/../b` | `invalid_cwd` | +| `/srv/a` | `invalid_cwd` | +| `C:x` or `./C:x` | `invalid_cwd` | +| `a\b` | `invalid_cwd` | + +The rule is lexical only. It does not model Windows name aliasing such as trailing dots or 8.3 names. Before any tool runs, the Runtime must still verify existence, realpath containment, symlinks and mount identity, and it rechecks them at tool boundaries (W0c). The working directory is where tools start; it is not a security boundary. + +## Selection resolution + +A selection is either omitted, or explicit with a Workspace ID and a `cwdRelative` (`.` when the caller left it out). The API layer rejects `null` and `{}` before resolution. + +Resolution runs these checks in order and stops at the first failure. The Java API runs step 1 when the explicit selection is built, so an invalid directory fails before any lookup. + +| Step | Explicit selection | Omitted selection | +| ------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------- | +| 1. Normalize `cwdRelative` | `invalid_cwd` | — (always `.`) | +| 2. Choose the target | its Workspace ID | the tenant default; if none, `workspace_required` | +| 3. Look up the target in the actor's tenant | missing: `workspace_not_found` | missing: `workspace_required` | +| 4. Check access | `NONE`: `workspace_not_found`; `READ`: `workspace_forbidden` | not `CREATE`: `workspace_required` | +| 5. Check the state | not `active`: `workspace_unavailable` | not `active`: `workspace_required` | + +On success the result carries: + +- the tenant ID, Workspace ID, workspace generation and storage ID; +- the record's `configRef` and `policyRef`; +- the normalized `cwdRelative`; +- whether the tenant default was used. + +The syntax check comes first because it needs no lookup and reveals nothing. An omitted selection never yields 404, 403 or 409: an unusable default counts as no default. An explicit selection never falls back to the default. + +The resolver is stateless. W0b calls it only on first admission and persists the result atomically with the creation receipt. A retry must find the original receipt first and must not call the resolver again, because a changed default would otherwise rebind the retry. For the request digest, the selection exposes a fixed omission marker, or the Workspace ID with the normalized `cwdRelative`. W0b folds these into its digest with the encoding described below. + +## ContextBinding and digest + +A ContextBinding has seven required fields, checked with the same rules as the Registry record: + +- `tenantId`: `[A-Za-z0-9._:-]{1,128}` +- `workspaceId`: `[A-Za-z0-9._:-]{1,128}` +- `workspaceGeneration`: from 1 to 2^63−1 +- `storageId`: 1 to 256 printable ASCII characters +- `cwdRelative`: already in normal form +- `contextConfigRef`: 1 to 512 printable ASCII characters +- `contextRevision`: from 1 to 2^63−1 + +`contextConfigRef` names the frozen configuration descriptor that W0b records at admission from the Registry's `configRef` and `policyRef`. W0a checks only its format and otherwise treats it as opaque. Nothing supplies `contextDigest`; it is always derived. A decoder that reads the two integers from text must accept only the ASCII form `[1-9][0-9]*` with a value of at most 2^63−1, as the TypeScript implementation does. `Long.parseLong` alone is not enough: it also accepts a sign, leading zeros and non-ASCII digits. + +`contextDigest` is `sha256:` followed by the lowercase hex SHA-256 of a byte sequence. The sequence concatenates the items below in order. Each item is a 4-byte big-endian length followed by that many UTF-8 bytes. + +1. the domain tag `qwen-managed-context-binding-v1` +2. `tenantId` +3. `workspaceId` +4. `workspaceGeneration`, as a decimal string with no sign and no leading zeros +5. `storageId` +6. `cwdRelative` +7. `contextConfigRef` +8. `contextRevision`, as a decimal string with no sign and no leading zeros + +Reasons for this encoding: + +- Length prefixes keep field boundaries unambiguous for any Unicode directory name. +- There is no JSON escaping or number formatting for fastjson2 and `JSON.stringify` to disagree on. +- The Broker's repository-key digests already prefix each UTF-8 item with a 4-byte big-endian length, though without a domain tag or the `sha256:` prefix. The encoding takes a few lines in TypeScript. +- Generations and revisions are decimal strings, so a JavaScript consumer never routes a 64-bit value through `Number`. +- The `sha256:` prefix matches `capabilityDigest` in the private Runtime protocol. Session-record `DurableRef` digests are bare hex; a later journal reference must convert explicitly. + +The digest covers the Session's context only. The Runtime binding ID and generation, and the Harness owner generation, are bound at execution by the invocation wrapper in W0c. `displayName` does not affect execution. `policyRef` reaches execution through `contextConfigRef`. + +Both languages consume shared fixtures in `packages/cli/src/serve/contracts/managed-workspace-binding-v1.fixtures.json`, validated against a schema file next to them. The file has three case lists: + +- **Paths:** an input, and either the normalized result or `invalid_cwd`. +- **Bindings:** the fields, and either the hex of the encoded bytes with `contextDigest`, or a rejection. +- **Character probes:** a directory or binding-field template with one `{c}` slot, and the exact ranges of code points the rule accepts in that slot. Each probe is run with every code point of the Basic Multilingual Plane (U+0000 to U+FFFF), which holds every control character, space separator and surrogate, and with the astral blocks that mimic ASCII: the mathematical alphanumeric symbols, the enclosed alphanumeric supplement and the tag characters, plus U+10000, U+1D11E, U+1F600, U+F0000 and U+10FFFF. A class that gains or loses any of these code points fails a probe; other astral code points are not probed. + +The cases cover both sides of each rule's boundaries, including non-ASCII names, a segment of spaces only, a generation above 2^53 and a generation of 2^63−1. The expected values were computed by an implementation independent of both languages. + +## Boot envelope + +The boot envelope is its own slice, W0a-2, and the [managed context envelope](2026-09-25-managed-context-envelope.md) now defines it: the negotiation of `managed-context/1`, boot v2 and ready v2, attestation v3, and the context installation request with its receipt. It differs from the plan first written here in three ways: + +- Boot v2 carries the Workspace binding and the mount root but no Session context, because a Workspace-isolated Runtime is shared by Sessions whose `cwdRelative` differ. Each Session installs its `ContextBinding` through the installation request instead, so `cwdRelative` and `contextRevision` stay out of `RuntimeScope`. +- The daemon-style path hash is not carried at all. A worker that needs one derives it from the verified mount root. +- W0a-2 defines the contract only. The worker, the provisioner and the fake worker `fake-attestation-worker.mjs` change in W0c, which wires the protocol. + +## Errors + +| Code | HTTP | When | +| ----------------------- | ---- | ---------------------------------------------------------------------------------------------- | +| `workspace_required` | 400 | The selection is omitted and there is no usable tenant default. | +| `invalid_cwd` | 400 | `cwdRelative` breaks the working-directory rule. | +| `workspace_not_found` | 404 | The explicit Workspace is missing, belongs to another tenant, or is not readable by the actor. | +| `workspace_forbidden` | 403 | The actor can read the explicit Workspace but lacks `CREATE`. | +| `workspace_unavailable` | 409 | The explicit Workspace is `draining` or `removed`. | + +None of these succeeds on an unchanged retry. W0a raises them as one exception type that carries the code and the HTTP status, like `RuntimeBrokerException`. The API layer maps them to its error envelope. `unsupported_feature`, `workspace_generation_conflict` and `context_revision_conflict` belong to later slices. + +## Security and tenancy + +- The tenant and actor come only from the embedding service's authentication. +- Errors do not distinguish another tenant's Workspace, an unreadable Workspace and a missing one: all three fail with the same code, message and throw site. The API layer must return only the status, code and message of a `WorkspaceException`, never its stack trace. Timing is not made uniform: an unreadable Workspace consults the access policy, and a missing one does not. +- Workspace generations, storage IDs and configuration references come only from the Registry. A caller supplies at most a Workspace ID and a relative directory. No absolute path, mount, storage ID or generation is accepted from a caller, and none appears in an error or a list entry. W0b must build each ContextBinding from a resolved Workspace or from its own persisted binding, never from request fields. W0a does not enforce this: ContextBinding keeps a public constructor because the restore path needs one. +- Resolution never falls back. An explicit selection gets no default, and an omitted selection never gets a launch cwd or the daemon's primary Workspace. +- Access is evaluated on every call. No result carries authorization that outlives the call. +- The working-directory rule is lexical. The Runtime still enforces containment, realpath and symlink safety at installation and at tool boundaries. + +## Files affected + +- The package `com.alibaba.qwen.code.runtimebroker.managedworkspace` in `packages/sdk-java/runtime-broker` (new): + - The Registry record and state, the Registry interface and the configuration snapshot with its successor check. + - The actor, the access levels and policy, and the explicit-grant policy. + - The catalog that lists and resolves. + - The selection and resolved Workspace, the working-directory rule, `ContextBinding` and the exception type. + - Tests in the matching test package, including the fixture consumer. +- `packages/sdk-java/runtime-broker/README.md` and `QWEN.md`: a section on the package. +- `packages/cli/src/serve/contracts/managed-workspace-binding-v1.fixtures.json` and `.schema.json` (new). +- `packages/cli/src/serve/managed-workspace-binding.ts` and its test (new). This is the TypeScript twin of the working-directory rule and the digest. It is not wired into the worker yet. +- This design document, in both languages. + +No existing Broker class, the Broker schema, the daemon `WorkspaceRegistry`, the worker boot document, the attestation contract or any CI workflow changes. The SDK Java workflow already runs the `runtime-broker` tests and Checkstyle, and its path filters cover both `packages/sdk-java/**` and `packages/cli/src/serve/**`. + +## Validation plan + +- **Java unit tests:** + - Record and snapshot validation, and the successor check. + - The explicit-grant policy. + - Listing: filtering, order, paging, and a default outside the current page. + - Every resolution row, for both explicit and omitted selections. + - The working-directory rule, and ContextBinding validation and digest. +- **Fixture consumers:** Java and TypeScript both run every path, binding and character-probe case. TypeScript validates the fixture file against its schema with strict Ajv. +- **Mutation check:** revert or weaken each guard. This includes widening or narrowing each character class by any probed code point; exempting one control character; adding regular-expression flags; comparing case-insensitively; dropping the type check from `equals`; and making a constructor public. A mutant that changes behavior must fail a test, and a surviving mutant must be shown to be equivalent. +- **Commands:** `mvn test` and `mvn checkstyle:check` in `packages/sdk-java/runtime-broker` on JDK 21; `npm run build && npm run typecheck`; focused Vitest runs. +- **CI:** the existing `runtime-broker` steps run the package's tests in the Java 21 jobs on Linux, macOS and Windows, and Checkstyle on Linux. The module's Checkstyle configuration checks the main sources only, not the tests. + +## Acceptance criteria + +- Java and TypeScript produce identical normalized paths and `contextDigest` values for every fixture. This includes non-ASCII names, a segment of spaces only and a generation above 2^53. TypeScript accepts exactly the listed code points in every character probe, and so does Java; Java carries generations and revisions as `long`, so its decimal probes check the rule its wire decoders must follow. +- An omitted selection resolves only to an active tenant default in which the actor can create Sessions. Every other omitted case is `workspace_required`. +- An explicit selection never falls back to the default. +- Another tenant's Workspace and an unreadable Workspace produce the same `workspace_not_found`. +- No caller-supplied absolute path, storage ID or generation can reach a resolved Workspace. +- A replacement snapshot cannot drop a Workspace, lower its generation, or change its storage ID without a new generation. +- The Java package uses only the JDK: no other Broker class, Spring, CLI internals or scheduler. +- The documentation does not claim persistence, wiring or capability advertisement. + +## Open questions + +1. Should `cwdRelative` reject every control character (C0, DEL and C1), as implemented, or only NUL, as the reference contract says? A newline or an escape sequence in a directory name reaches logs, UI and shell prompts. The same concern applies to characters that are accepted today but are invisible or break lines: format characters (category Cf) such as bidirectional controls (U+202E, U+2066) and zero-width characters (U+200B, U+FEFF), and the line and paragraph separators U+2028 and U+2029 (categories Zl and Zp). Rejecting every format character would also reject legitimate joiners such as U+200D in emoji names. +2. Should `removed` Workspaces appear in listings at all, or only in Session views? + +## Follow-up work + +| Slice | Scope | +| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| W0a-2 | The versioned boot envelope, attestation v3 and context installation, defined in the [managed context envelope](2026-09-25-managed-context-envelope.md). | +| W0b | Atomic Session binding with the creation receipt, original-key recovery, and explicitly unbound old Sessions. | +| W0c | Session-based Broker resolution through `HarnessSessionResolver`, the storage-to-mount resolver, worker installation and attestation, and the Workspace turn lease. | +| W0d | WebShell selection, the relative-directory field, and the default and empty states. | +| W0e | Recovery and rollout. | + +Stage D generates the public and BFF DTOs from the reviewed OpenAPI. A JDBC Registry follows when a control plane persists Workspaces. diff --git a/docs/design/2026-09-25-managed-workspace-binding-contract.zh-CN.md b/docs/design/2026-09-25-managed-workspace-binding-contract.zh-CN.md new file mode 100644 index 00000000000..08b47112661 --- /dev/null +++ b/docs/design/2026-09-25-managed-workspace-binding-contract.zh-CN.md @@ -0,0 +1,306 @@ +# Managed Workspace 绑定契约(W0a) + +[English](2026-09-25-managed-workspace-binding-contract.md) | [简体中文](2026-09-25-managed-workspace-binding-contract.zh-CN.md) + +状态:已作为独立的包实现,尚未接线。更新:2026-09-25。本文是 Managed Agent proposal [#12380](https://github.com/QwenLM/qwen-code/issues/12380) 中 W0 的第一个切片。对 [W0a 问题](https://github.com/QwenLM/qwen-code/issues/12380#issuecomment-5819009126)的[这条答复](https://github.com/QwenLM/qwen-code/issues/12380#issuecomment-5825755703)确定了代码位置和启动信封的时机。下文的“参考契约”指该提案的 [Workspace 与 Session cwd 设计](https://github.com/doudouOUC/code_agent/blob/689121646cc25ca08a34508a5f5555ae15308833/qwen-code/feature/managed-agents/managed-agent-workspace-context.md),以及其公开 OpenAPI 中的 [`WorkspaceRelativePath` schema](https://github.com/doudouOUC/code_agent/blob/689121646cc25ca08a34508a5f5555ae15308833/qwen-code/feature/managed-agents/managed-agent-public-api.openapi.yaml#L1453);“参考 schema”指它的 [Workspace DDL](https://github.com/doudouOUC/code_agent/blob/689121646cc25ca08a34508a5f5555ae15308833/qwen-code/feature/managed-agents/managed-agent-workspace-schema.mysql.sql)。三者都取自 #12380 所链接的提交。 + +## 问题 + +托管的 Managed Session 必须在管理员注册过、且当前 actor 有权使用的 Workspace 中运行工具。工具从创建 Session 时选定的相对目录开始执行。这个选择必须经得起重试、重启和租户默认值的变更。Java 准入、Runtime Broker 和 Runtime worker 三方还必须对它逐字节一致。 + +目前没有任何一层定义这个选择。Broker 接收的是调用方解析出的任意 `RuntimeScope`。没有代码校验相对目录、检查 actor 的访问权限、应用租户默认值,也没有代码生成 worker 可以核验的身份。如果之后各层按各自的规则校验,就可能出现两种故障。一层规范化了而另一层拒绝的路径,可能让工具跑到错误的目录。重试时重新解析的默认值,可能把 Session 挪到另一个 Workspace。 + +## 现状 + +以下事实基于 `main` 的 `73aa65a4b4`。 + +- **Broker 范围。** `RuntimeScope` 包含 `tenantId`、`workspaceId`、`workspaceGeneration`(字符串)、`canonicalCwd`、`capabilityDigest` 和 `isolationClass`。`HarnessSessionResolver` 为每个 Harness Session 解析一个 scope,Broker 不验证这些值的真实性。`LocalProcessRuntimeProvisioner` 把 `workspaceId`、`workspaceGeneration` 和规范化的 cwd 复制进 worker 的 boot 文档。 +- **Worker 契约。** boot 文档是封闭键的 `version: 1`。v2 attestation 请求重复携带租户和 Workspace 字段:`workspaceId` 是不透明值;`workspaceGeneration` 是非空字符串(数字会被拒绝);`workspaceCwd` 只检查是否非空。 +- **Daemon 注册表。** daemon 的 TypeScript `WorkspaceRegistry` 负责本地多工作区的路由。它的 `workspaceId` 是规范化 cwd 的 SHA-256 的前 16 个十六进制字符,generation 是内存中的计数器。它有五种状态,没有租户概念。它不是托管侧的 Registry,W0a 不改动它。 +- **Session 记录。** #12302 的 managed session record 位于 TypeScript core。它们以 `{tenantId, workspaceId, sessionId}` 标识一个 Session,不带 cwd 或绑定字段。 +- **缺失的部分。** 上游没有 Java 控制面,没有 `managed_agent_session` 表,也没有 Managed Agent 公共 OpenAPI。没有任何代码定义 Workspace 选择、相对 cwd、context revision 或 ContextBinding。 +- **摘要。** Java 与 TypeScript 之间没有共享的摘要规范化方式。Broker 的 JDBC repository 已经用“长度前缀 + UTF-8 字段”上的 SHA-256 生成键(`JdbcRepositorySupport.digest`)。TypeScript core 的 session record 使用一个私有的规范化 JSON 编码器;导出的 `managedSessionEventsDigest` 只覆盖有序的事件标识。 + +## 目标 + +- 为相对工作目录(`cwdRelative`)制定唯一的词法规则,以参考契约中的 `WorkspaceRelativePath` 规则为基础。由语言无关的 fixtures 固定下来,让 Java 与 TypeScript 拒绝和规范化同样的输入。 +- 定义 Workspace Registry 的记录与读取契约,先由部署配置提供数据。 +- 定义三级的 actor 访问权限。它决定列表可见性、`canCreateSession` 提示和创建准入。 +- 提供一个纯函数式的解析器:输入 actor 和可选的选择,输出一个已解析的 Workspace,或者恰好一个类型化的错误。它覆盖选择缺省时的租户默认值。 +- 定义 `ContextBinding` 值,Java 与 TypeScript 算出相同的 `contextDigest`,由共享 fixtures 固定。 +- 为调用方的选择提供一个规范化形式(含缺省标记),供 W0b 并入请求摘要。 + +## 非目标 + +- 持久化 Session 绑定、创建回执或 operation。这属于 W0b。 +- 把存储解析为挂载点;接线 Broker、Harness 或 worker;打开 activation gate。这属于 W0c。 +- 带版本的启动信封 `managed-context/1`,以及任何 attestation 版本升级。见[启动信封](#启动信封)。 +- 公共或 BFF DTO、路由和游标,它们属于 Stage D 和 W0d;以及能力声明:`workspace_context` 在所有 W0 切片通过之前保持 false,由 W0e 决定何时开启。 +- 准入时的 Agent、Bundle 与配置兼容性检查。这属于 W0b。 +- 文件系统检查:是否存在、realpath、符号链接和挂载身份。这些在 W0c 中由 Runtime 负责。 +- Registry 的 JDBC 表。基于配置的 Registry 不需要表,Runtime Broker 的 schema 保持不变。 +- 改动 daemon 的 `WorkspaceRegistry` 或 daemon 路由。 + +## 代码位置 + +代码是现有 `packages/sdk-java/runtime-broker` 模块中的包 `com.alibaba.qwen.code.runtimebroker.managedworkspace`,这是 #12380 的决定。单独建 Maven 模块的方案被放弃了:在任何 Registry 逻辑可以评审之前,它就要先付出单独的 pom、CI lane 和 workflow 守护测试的成本。放在同一模块的包里,改动只集中在领域代码上,W0c 也能在同一模块内经 `HarnessSessionResolver` 接入绑定。如果 Registry 将来需要自己的 schema 生命周期,或者出现不能引入 Broker 的使用方,再把它拆成独立模块。 + +这个包保持不依赖框架:只使用 JDK,不依赖其他 Broker 类、Spring、CLI 内部实现或调度器,也不在持久化数据源失败时退回内存状态。共享 fixtures 以及规则和摘要的 TypeScript 实现都放在 `packages/cli/src/serve` 下,与现有的 Runtime 契约相邻。 + +## 术语 + +下面几个词在本仓库中已有其他含义。 + +| 术语 | 本文含义 | 不要混淆为 | +| -------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| Workspace ID | 由管理员分配的不透明值。它符合 `[A-Za-z0-9._:-]{1,128}`,在租户内唯一,按字节比较。 | daemon 基于路径哈希的 `workspaceId`。 | +| workspace generation | Registry 的替换计数器:正的 64 位整数,对同一个 Workspace ID 只增不减。在线上和摘要中用十进制字符串表示。 | Runtime 绑定的 generation、daemon 的 `generationId`,或 `workspace_generation` 能力。 | +| storage ID | Workspace 持久文件的逻辑身份。 | 挂载路径。 | +| ContextBinding | Session 已提交的 Workspace 上下文。 | Runtime 绑定(用于放置 Runtime)。 | +| contextRevision | Session 上下文的版本。创建时为 1,W2 会递增它。 | workspace generation、配置 revision 或 activation epoch。 | + +## Workspace Registry + +### 记录 + +Registry 记录包含以下字段。记录不可变。 + +| 字段 | 规则 | +| --------------------- | --------------------------------------------- | +| `tenantId` | `[A-Za-z0-9._:-]{1,128}` | +| `workspaceId` | `[A-Za-z0-9._:-]{1,128}` | +| `workspaceGeneration` | 1 到 2^63−1 的整数 | +| `storageId` | 1 到 256 个可打印 ASCII 字符(0x21 到 0x7E) | +| `displayName` | 1 到 512 个码点,合法的 Unicode,不含控制字符 | +| `state` | `active`、`draining` 或 `removed` | +| `policyRef` | 1 到 512 个可打印 ASCII 字符 | +| `configRef` | 1 到 512 个可打印 ASCII 字符 | + +标识符格式取自 Java SDK 中 `ManagedSessionStoreConnection` 的租户格式,这里同样用于 Workspace ID。它也是 TypeScript managed session key 稳定 ID 规则的子集,因此托管的 Workspace ID 在两处都合法。标识符只用 ASCII,也意味着计算摘要前永远不需要做 Unicode 规范化。 + +### 状态 + +| 状态 | 是否列出 | 能否用于新 Session | 能否作为默认值 | +| ---------- | -------- | ------------------------------- | -------------- | +| `active` | 是 | 能 | 能 | +| `draining` | 是 | 不能(`workspace_unavailable`) | 不能 | +| `removed` | 是 | 不能(`workspace_unavailable`) | 不能 | + +已有 Session 在任何状态下都保留原有绑定。draining 或 removed 对它们意味着什么,属于 W0e 和 W1。 + +### 配置来源 + +读取接口提供三种操作: + +- 按租户和 Workspace ID 查找一条记录。 +- 按 Workspace ID 的字节序分页遍历一个租户的记录,这与参考 schema 的 `ascii_bin` 排序规则一致。短于上限的一页表示后面没有更多记录,并且每个实现都接受 1 到 1000 的上限。 +- 返回租户配置的默认 Workspace ID。 + +第一个实现是由部署配置构建的不可变快照。构建时遇到以下情况会拒绝该快照: + +- 同一个 `(tenantId, workspaceId)` 出现两次。 +- 租户默认值指向的 Workspace 不在该租户中。 +- 任何字段违反其规则。 + +不提供公共注册 API,也不从路径推导 Workspace ID。 + +嵌入方通过替换快照来应用变更。后继检查会拒绝有以下任一行为的替换: + +- 删除 Workspace。Workspace 通过改为 `removed` 退役,这样钉在它上面的 Session 之后仍能得到解释。 +- 降低某个 Workspace 的 generation。 +- 不提升 generation 就修改它的 `storageId`。 + +之后可以基于参考 schema 中的 `managed_agent_workspace` 表,提供实现同一接口的 JDBC Registry。它必须像本包一样精确比较租户 ID:参考 schema 让 `tenant_id` 沿用基表的排序规则,而那可能不区分大小写。 + +## 访问控制 + +嵌入方完成 actor 认证后传入租户 ID 和 actor ID。W0a 从不从请求体读取这两者。actor ID 遵循 `displayName` 的规则,而不是标识符规则,因此可以是邮箱地址。策略针对一个 actor 和一条记录返回三种访问级别之一: + +- `NONE` +- `READ` +- `CREATE`,隐含 `READ` + +查找始终限定在 actor 所属的租户内。无论策略怎么说,另一个租户的 Workspace 都与不存在的 Workspace 无法区分。每次调用都重新评估策略,从不把结果固化进分页或解析结果。W0a 为测试和单租户部署提供一个显式授权的策略;没有“全部放行”的默认策略。 + +列表返回 actor 能读取的记录,每条带 `canCreateSession`。这个字段只是权限提示;状态单独给出,由调用方把两者结合起来。记录按 Workspace ID 排序,并附带 `hasMore` 和 `defaultWorkspace`。默认项独立于当前页计算,只有租户默认值存在、处于 `active`、且 actor 拥有 `CREATE` 时才返回。分页基于上一页最后一个 Workspace ID。catalog 无论页面大小如何,都按 1000 条一批读取 Registry,不足 1000 条的一批表示已到末尾。为了让 `hasMore` 精确,页满之后它会继续扫描,直到再找到一条可读记录或到达末尾。因此一次列表对 Registry 分页读取的次数至多是:每扫描满 1000 条一次,再加一次;无论 actor 能读的记录有多少都是如此。查找默认项还会另外调用一到两次。与租户、actor 和查询绑定的不透明游标属于 API 层。 + +## 工作目录规则 + +`cwdRelative` 是一个字符串。API 层把缺省的字段转换为 `.`,并拒绝 `null`。违反以下任一条都返回 `invalid_cwd`。 + +1. 必须是合法的 Unicode,不含未配对的代理项。 +2. 长度必须为 1 到 1024 个码点,与 JSON Schema 的计数方式一致。空字符串非法;它不等于 `.`。 +3. 不得包含控制字符,即 Unicode Cc 类:C0(含 NUL)、DEL 和 C1。参考契约只列出 NUL;见[待决问题](#待决问题)。 +4. 不得包含反斜杠。 +5. 不得以 `/` 开头。 +6. 下文所述的规范化结果不得以盘符前缀开头:一个 ASCII 字母后跟 `:`。检查规范化结果,也会拒绝 `./C:x` 这样的写法。 +7. 按 `/` 切分后,任何段都不能恰好是 `..`。 + +随后的规范化会去掉空段(来自重复或结尾的 `/`)和 `.` 段,再用 `/` 连接其余部分。如果什么都不剩,结果为 `.`。其他一概不改:保留空格、大小写和非 ASCII 字符,不做 Unicode 规范化,也不做百分号解码。ContextBinding 携带规范化后的值,W0b 对它计算摘要。 + +| 输入 | 结果 | +| -------------------- | -------------- | +| `.` 或 `./` 或 `./.` | `.` | +| `services//api/` | `services/api` | +| `./services/./api` | `services/api` | +| `a/ b /c` | `a/ b /c` | +| 空字符串 | `invalid_cwd` | +| `..` 或 `a/../b` | `invalid_cwd` | +| `/srv/a` | `invalid_cwd` | +| `C:x` 或 `./C:x` | `invalid_cwd` | +| `a\b` | `invalid_cwd` | + +该规则只做词法检查。它不模拟 Windows 的名称别名,例如末尾的点或 8.3 短名。在任何工具运行之前,Runtime 仍须核验目录存在、realpath 包含关系、符号链接和挂载身份,并在工具边界处复查(W0c)。工作目录只是工具的起始位置,不是安全边界。 + +## 选择解析 + +选择要么缺省,要么是显式的:带一个 Workspace ID 和一个 `cwdRelative`(调用方没写时为 `.`)。API 层在解析之前就拒绝 `null` 和 `{}`。 + +解析按以下顺序检查,遇到第一个失败即停止。Java API 在构建显式选择时就执行第 1 步,因此无效目录会在任何查找之前失败。 + +| 步骤 | 显式选择 | 缺省选择 | +| ---------------------------- | ------------------------------------------------------------ | --------------------------------------- | +| 1. 规范化 `cwdRelative` | `invalid_cwd` | —(始终为 `.`) | +| 2. 确定目标 | 它的 Workspace ID | 租户默认值;没有则 `workspace_required` | +| 3. 在 actor 的租户中查找目标 | 不存在:`workspace_not_found` | 不存在:`workspace_required` | +| 4. 检查访问权限 | `NONE`:`workspace_not_found`;`READ`:`workspace_forbidden` | 不是 `CREATE`:`workspace_required` | +| 5. 检查状态 | 不是 `active`:`workspace_unavailable` | 不是 `active`:`workspace_required` | + +成功时结果包含: + +- 租户 ID、Workspace ID、workspace generation 和 storage ID; +- 记录的 `configRef` 和 `policyRef`; +- 规范化后的 `cwdRelative`; +- 是否使用了租户默认值。 + +语法检查放在最前,因为它不需要查找,也不泄露任何信息。缺省选择永远不会得到 404、403 或 409:不可用的默认值等同于没有默认值。显式选择永远不会回退到默认值。 + +解析器是无状态的。W0b 只在首次准入时调用它,并把结果与创建回执原子地持久化。重试必须先找到原始回执,不得再次调用解析器,否则变更后的默认值会让重试改绑。对于请求摘要,选择会给出一个固定的缺省标记,或者 Workspace ID 加规范化后的 `cwdRelative`。W0b 用下文的编码把它们并入自己的摘要。 + +## ContextBinding 与摘要 + +ContextBinding 有七个必需字段,校验规则与 Registry 记录相同: + +- `tenantId`:`[A-Za-z0-9._:-]{1,128}` +- `workspaceId`:`[A-Za-z0-9._:-]{1,128}` +- `workspaceGeneration`:1 到 2^63−1 +- `storageId`:1 到 256 个可打印 ASCII 字符 +- `cwdRelative`:已是规范化形式 +- `contextConfigRef`:1 到 512 个可打印 ASCII 字符 +- `contextRevision`:1 到 2^63−1 + +`contextConfigRef` 指向 W0b 在准入时根据 Registry 的 `configRef` 和 `policyRef` 记录下来的冻结配置描述。W0a 只检查它的格式,其余一概当作不透明值。`contextDigest` 不由任何一方提供,始终是推导出来的。从文本读取这两个整数的解码器,必须像 TypeScript 实现那样,只接受 ASCII 形式的 `[1-9][0-9]*`,且数值不超过 2^63−1。只用 `Long.parseLong` 不够:它还会接受符号、前导零和非 ASCII 数字。 + +`contextDigest` 由 `sha256:` 加上一个字节序列的 SHA-256 小写十六进制构成。该字节序列按顺序拼接下列各项,每一项都是一个 4 字节大端长度,后跟相应数量的 UTF-8 字节。 + +1. 域标签 `qwen-managed-context-binding-v1` +2. `tenantId` +3. `workspaceId` +4. `workspaceGeneration`,写成不带符号、没有前导零的十进制字符串 +5. `storageId` +6. `cwdRelative` +7. `contextConfigRef` +8. `contextRevision`,写成不带符号、没有前导零的十进制字符串 + +采用这种编码的原因: + +- 长度前缀让字段边界对任何 Unicode 目录名都没有歧义。 +- 没有 JSON 转义或数字格式问题,fastjson2 与 `JSON.stringify` 之间也就无从分歧。 +- Broker 的 repository 键摘要已经在每个 UTF-8 项前加 4 字节大端长度,只是没有域标签,也没有 `sha256:` 前缀。这种编码在 TypeScript 中实现也只需几行。 +- generation 和 revision 使用十进制字符串,JavaScript 使用方就不会把 64 位值经过 `Number` 处理。 +- `sha256:` 前缀与私有 Runtime 协议中的 `capabilityDigest` 一致。session record 的 `DurableRef` 摘要是不带前缀的十六进制;之后在 session journal 中引用时必须显式转换。 + +摘要只覆盖 Session 的上下文。Runtime 绑定的 ID 和 generation,以及 Harness owner generation,在执行时由 W0c 的调用封装绑定。`displayName` 不影响执行。`policyRef` 通过 `contextConfigRef` 进入执行。 + +两种语言共同消费 `packages/cli/src/serve/contracts/managed-workspace-binding-v1.fixtures.json` 中的共享 fixtures,并用旁边的 schema 文件校验。该文件有三组用例: + +- **路径:** 一个输入,以及规范化结果或 `invalid_cwd`。 +- **绑定:** 各字段,以及编码后字节的十六进制加 `contextDigest`,或者一个拒绝结果。 +- **字符探针:** 一个带 `{c}` 占位的目录或绑定字段模板,以及规则在该位置接受的码点的精确范围。每个探针都会代入基本多文种平面(U+0000 到 U+FFFF)的每个码点,其中包含所有控制字符、空白分隔符和代理项;还会代入模仿 ASCII 的星体区块:数学字母数字符号、带圈字母数字补充和标签字符,以及 U+10000、U+1D11E、U+1F600、U+F0000 和 U+10FFFF。字符类只要多接受或少接受其中任何一个码点,就会有探针失败;其余星体码点不受探测。 + +用例覆盖每条规则边界的两侧,包括非 ASCII 名称、仅由空格组成的段、大于 2^53 的 generation,以及等于 2^63−1 的 generation。期望值由一个独立于两种语言的实现计算得出。 + +## 启动信封 + +启动信封是一个单独的切片 W0a-2,现在由[托管上下文信封](2026-09-25-managed-context-envelope.zh-CN.md)定义:`managed-context/1` 的协商、boot v2 与 ready v2、attestation v3,以及上下文安装请求及其回执。它与这里最初写下的计划有三点不同: + +- boot v2 携带 Workspace 绑定和挂载根目录,但不携带 Session 上下文,因为按 Workspace 隔离的 Runtime 由 `cwdRelative` 各不相同的多个 Session 共享。每个 Session 改为通过安装请求安装自己的 `ContextBinding`,因此 `cwdRelative` 和 `contextRevision` 不会进入 `RuntimeScope`。 +- 完全不携带 daemon 式的路径哈希。需要路径哈希的 worker 从验证过的挂载根目录自行推导。 +- W0a-2 只定义契约。worker、provisioner 和假 worker `fake-attestation-worker.mjs` 在接通协议的 W0c 中修改。 + +## 错误 + +| 错误码 | HTTP | 何时出现 | +| ----------------------- | ---- | ---------------------------------------------------------------- | +| `workspace_required` | 400 | 选择缺省,且没有可用的租户默认值。 | +| `invalid_cwd` | 400 | `cwdRelative` 违反工作目录规则。 | +| `workspace_not_found` | 404 | 显式指定的 Workspace 不存在、属于另一个租户,或 actor 无权读取。 | +| `workspace_forbidden` | 403 | actor 能读取显式指定的 Workspace,但没有 `CREATE`。 | +| `workspace_unavailable` | 409 | 显式指定的 Workspace 处于 `draining` 或 `removed`。 | + +这些错误原样重试都不会成功。W0a 用一个同时携带错误码和 HTTP 状态的异常类型抛出它们,与 `RuntimeBrokerException` 的做法一致。API 层负责把它们映射到自己的错误结构。`unsupported_feature`、`workspace_generation_conflict` 和 `context_revision_conflict` 属于后续切片。 + +## 安全与租户隔离 + +- 租户和 actor 只来自嵌入方的认证。 +- 错误不区分另一个租户的 Workspace、无权读取的 Workspace 和不存在的 Workspace:三者的错误码、消息和抛出位置都相同。API 层必须只返回 `WorkspaceException` 的状态码、错误码和消息,不得返回它的栈轨迹。响应时间没有做成一致:无权读取的 Workspace 会调用访问策略,不存在的 Workspace 则不会。 +- workspace generation、storage ID 和配置引用只来自 Registry。调用方最多提供一个 Workspace ID 和一个相对目录。不接受调用方提供的绝对路径、挂载点、storage ID 或 generation,它们也不会出现在错误或列表项中。W0b 必须根据已解析的 Workspace 或自己持久化的绑定构建 ContextBinding,绝不能根据请求字段构建。W0a 不强制这一点:ContextBinding 保留公开的构造函数,是因为恢复路径需要它。 +- 解析从不回退。显式选择不会得到默认值,缺省选择也不会得到启动时的 cwd 或 daemon 的主 Workspace。 +- 每次调用都重新评估访问权限。任何结果都不携带超出本次调用的授权。 +- 工作目录规则只做词法检查。Runtime 仍会在安装时和工具边界处强制检查包含关系、realpath 和符号链接安全。 + +## 涉及文件 + +- `packages/sdk-java/runtime-broker` 中的包 `com.alibaba.qwen.code.runtimebroker.managedworkspace`(新增): + - Registry 的记录与状态、Registry 接口,以及配置快照及其后继检查。 + - actor、访问级别与策略,以及显式授权策略。 + - 负责列表与解析的 catalog。 + - 选择与已解析的 Workspace、工作目录规则、`ContextBinding`,以及异常类型。 + - 对应测试包中的测试,包括 fixtures 的消费方。 +- `packages/sdk-java/runtime-broker/README.md` 与 `QWEN.md`:新增一节介绍这个包。 +- `packages/cli/src/serve/contracts/managed-workspace-binding-v1.fixtures.json` 与 `.schema.json`(新增)。 +- `packages/cli/src/serve/managed-workspace-binding.ts` 及其测试(新增)。这是工作目录规则和摘要的 TypeScript 实现,暂不接入 worker。 +- 本设计文档的中英文两个版本。 + +现有的 Broker 类、Broker schema、daemon 的 `WorkspaceRegistry`、worker 的 boot 文档、attestation 契约和所有 CI workflow 都不变。SDK Java workflow 本来就会运行 `runtime-broker` 的测试和 Checkstyle,它的路径过滤也同时覆盖了 `packages/sdk-java/**` 和 `packages/cli/src/serve/**`。 + +## 验证计划 + +- **Java 单元测试:** + - 记录与快照的校验,以及后继检查。 + - 显式授权策略。 + - 列表:过滤、排序、分页,以及不在当前页的默认项。 + - 解析表的每一行,显式和缺省两种选择都要覆盖。 + - 工作目录规则,以及 ContextBinding 的校验与摘要。 +- **Fixtures 消费方:** Java 与 TypeScript 都运行全部路径用例、绑定用例和字符探针用例。TypeScript 用严格模式的 Ajv 按 schema 校验 fixtures 文件。 +- **变异检查:** 逐一回退或削弱每道守卫。这包括让每个字符类多接受或少接受任意一个被探测的码点、豁免单个控制字符、给正则表达式加标志、改为忽略大小写比较、去掉 `equals` 中的类型检查,以及把构造函数改为 public。改变行为的变异必须让测试失败,存活的变异必须证明是等价的。 +- **命令:** 在 JDK 21 上于 `packages/sdk-java/runtime-broker` 中运行 `mvn test` 和 `mvn checkstyle:check`;运行 `npm run build && npm run typecheck`;运行相关的 Vitest 用例。 +- **CI:** 现有的 `runtime-broker` 步骤会在 Linux、macOS 和 Windows 的 Java 21 任务中运行这个包的测试,并在 Linux 上运行 Checkstyle。该模块的 Checkstyle 配置只检查 main 源码,不检查测试。 + +## 验收标准 + +- 对每一个 fixture,Java 与 TypeScript 得出相同的规范化路径和 `contextDigest`。这包括非 ASCII 名称、仅由空格组成的段,以及大于 2^53 的 generation。TypeScript 在每个字符探针中恰好接受所列的码点,Java 也是如此;Java 用 `long` 表示 generation 和 revision,因此它的十进制探针检验的是其线上解码器必须遵守的规则。 +- 缺省选择只会解析到一个处于 active、且 actor 可以在其中创建 Session 的租户默认值。其余所有缺省情况都是 `workspace_required`。 +- 显式选择永远不回退到默认值。 +- 另一个租户的 Workspace 和无权读取的 Workspace 得到同样的 `workspace_not_found`。 +- 调用方提供的绝对路径、storage ID 或 generation 都无法进入已解析的 Workspace。 +- 替换快照不能删除 Workspace、降低它的 generation,也不能在不提升 generation 的情况下修改它的 storage ID。 +- Java 包只使用 JDK,不依赖其他 Broker 类、Spring、CLI 内部实现或调度器。 +- 文档不声称已实现持久化、接线或能力声明。 + +## 待决问题 + +1. `cwdRelative` 应按现有实现拒绝所有控制字符(C0、DEL 和 C1),还是按参考契约只拒绝 NUL?目录名中的换行或转义序列会进入日志、UI 和 shell 提示符。同样的顾虑也适用于目前被接受、但不可见或会断行的字符:格式字符(Cf 类),例如双向控制符(U+202E、U+2066)和零宽字符(U+200B、U+FEFF),以及行分隔符和段分隔符 U+2028、U+2029(Zl 类和 Zp 类)。如果拒绝所有格式字符,也会误伤合法的连接符,例如表情名称中的 U+200D。 +2. `removed` 的 Workspace 是否应出现在列表中,还是只出现在 Session 视图中? + +## 后续工作 + +| 切片 | 范围 | +| ----- | ---------------------------------------------------------------------------------------------------------------------------------- | +| W0a-2 | 带版本的启动信封、attestation v3 与上下文安装,由[托管上下文信封](2026-09-25-managed-context-envelope.zh-CN.md)定义。 | +| W0b | Session 绑定与创建回执的原子提交、按原始幂等键恢复,以及明确标记为未绑定的旧 Session。 | +| W0c | 通过 `HarnessSessionResolver` 按 Session 解析 Broker、存储到挂载点的解析器、worker 的安装与 attestation,以及 Workspace 轮次租约。 | +| W0d | WebShell 的 Workspace 选择、相对目录输入,以及默认与空状态。 | +| W0e | 恢复与上线。 | + +Stage D 根据评审过的 OpenAPI 生成公共和 BFF DTO。等控制面开始持久化 Workspace 时,再实现 JDBC Registry。 diff --git a/docs/design/2026-09-25-startup-benchmark-harness.md b/docs/design/2026-09-25-startup-benchmark-harness.md index 9f8509c231c..05c6b871ee4 100644 --- a/docs/design/2026-09-25-startup-benchmark-harness.md +++ b/docs/design/2026-09-25-startup-benchmark-harness.md @@ -48,6 +48,10 @@ Every launch gets a new `HOME`, `QWEN_HOME`, `QWEN_RUNTIME_DIR`, `XDG_*` directories and workspace, which is an empty git repository. The settings file selects OpenAI auth against the loopback stub, and the environment is reduced to what a terminal passes: `PATH`, `LANG`, `TERM` and the stub's key and URL. +`--credentials settings` or `--credentials dotenv` moves that key and URL out +of the shell into the settings file's `env` block, where `/auth` stores them, +or into `~/.qwen/.env`, where the docs recommend them, so the env-file +relaunch rule can be measured. The CLI starts through the real bin entry, `cli-entry.js`, in a node-pty terminal of 120×40 whose screen `@xterm/headless` reconstructs. diff --git a/docs/design/2026-09-25-startup-benchmark-harness.zh-CN.md b/docs/design/2026-09-25-startup-benchmark-harness.zh-CN.md index e5f079a9504..3e814cfff03 100644 --- a/docs/design/2026-09-25-startup-benchmark-harness.zh-CN.md +++ b/docs/design/2026-09-25-startup-benchmark-harness.zh-CN.md @@ -34,7 +34,7 @@ ### 3.2 干净环境 -每次启动都会得到新的 `HOME`、`QWEN_HOME`、`QWEN_RUNTIME_DIR`、`XDG_*` 目录和工作区,工作区是一个空的 git 仓库。设置文件选择 OpenAI 认证并指向本地回环 stub,环境变量只保留终端会传递的那些:`PATH`、`LANG`、`TERM`,以及 stub 的密钥与地址。CLI 通过真实的 bin 入口 `cli-entry.js` 启动,运行在 120×40 的 node-pty 终端中,屏幕由 `@xterm/headless` 重建。 +每次启动都会得到新的 `HOME`、`QWEN_HOME`、`QWEN_RUNTIME_DIR`、`XDG_*` 目录和工作区,工作区是一个空的 git 仓库。设置文件选择 OpenAI 认证并指向本地回环 stub,环境变量只保留终端会传递的那些:`PATH`、`LANG`、`TERM`,以及 stub 的密钥与地址。`--credentials settings` 或 `--credentials dotenv` 会把密钥与地址从 shell 移到设置文件的 `env` 块(`/auth` 存放它们的位置)或 `~/.qwen/.env`(文档推荐的位置),以便度量 env 文件触发的重启规则。CLI 通过真实的 bin 入口 `cli-entry.js` 启动,运行在 120×40 的 node-pty 终端中,屏幕由 `@xterm/headless` 重建。 ### 3.3 指标 diff --git a/docs/design/extension-status-coalescing.md b/docs/design/extension-status-coalescing.md new file mode 100644 index 00000000000..da81a1157be --- /dev/null +++ b/docs/design/extension-status-coalescing.md @@ -0,0 +1,50 @@ +# Coalescing extension status reads + +[English](extension-status-coalescing.md) | [简体中文](extension-status-coalescing.zh-CN.md) + +## Problem + +The legacy-primary `GET /workspace/extensions` controller caches completed +responses for two seconds, but concurrent cache misses each create a manager +and fully load extensions. The store lock serializes these redundant scans. + +## Design + +Keep one in-flight status load per controller, keyed by resolved locale and +workspace trust. Matching requests share its promise. Keep the existing full +response and the two-second TTL measured from successful completion. + +All three existing invalidation sites clear both the completed cache and the +in-flight reference: manual refresh, completed mutation, and committed mutation +whose post-commit work fails. Only the current in-flight entry may publish a +cache result or clear the pending reference. Object identity provides the +invalidation token without an extra counter. Old callers may finish with their +original snapshot, but newer callers never join an invalidated load. Failure is +shared by current waiters and the next request retries. + +Resolve trust before locale so cache identity and manager settings agree. +Different controllers, languages, and trust states do not share loads. Retain +per-request HTTP lifecycle checks before and after the await. + +## Scope + +Change only the status controller and its tests. No long-lived manager, new +API, stale-while-revalidate, TTL increase, global cache, loader optimization, +or stronger mutation/read ordering is introduced. Documentation and an isolated +benchmark record the behavior and measured benefit. + +## Validation + +Unit tests cover cold/expired coalescing, hits, failure/retry, locale/trust and +controller isolation, and stale completion after each invalidation path. +Build, typecheck, and run the controller and workspace-extension route suites. +Use real filesystem fixtures and the same loader for base and candidate; +measure 1/2/5/10 simultaneous requests, cache hits, and expiry. Validate every +response and count full refreshes. Exclude setup/startup from timings and label +warm filesystem-cache measurements. Single requests are not expected to improve. + +## Acceptance + +A concurrent batch with one cache identity performs one full refresh. Old +completions cannot overwrite newer cached data or clear a newer pending read. +Results must report baseline SHA, fixture size, samples, timings, and limits. diff --git a/docs/design/extension-status-coalescing.zh-CN.md b/docs/design/extension-status-coalescing.zh-CN.md new file mode 100644 index 00000000000..ac1d34cc637 --- /dev/null +++ b/docs/design/extension-status-coalescing.zh-CN.md @@ -0,0 +1,40 @@ +# 合并扩展状态的并发读取 + +[English](extension-status-coalescing.md) | [简体中文](extension-status-coalescing.zh-CN.md) + +## 问题 + +legacy-primary `GET /workspace/extensions` 的 controller 将已完成响应缓存两秒, +但并发缓存未命中时,各请求都会创建 manager 并完整加载扩展。Store 锁让这些重复 +扫描串行执行。 + +## 设计 + +每个 controller 保存一个进行中的状态加载,以解析后的 locale 和工作区信任状态 +作为身份。相同身份的请求共享 Promise。保留完整响应以及从成功完成开始计算的两秒 TTL。 + +三个现有失效点同时清除响应缓存和进行中引用:手动刷新、写操作完成、写操作已提交但 +后续工作失败。只有当前进行中条目能发布缓存或清除引用,通过对象身份判断失效, +不增加版本计数。旧调用方可以得到原有快照,但新请求不会加入已失效的加载。 +当前等待者共享失败,后续请求重新尝试。 + +先解析信任状态,再解析 locale,确保缓存身份和 manager 使用的设置一致。 +不同 controller、语言和信任状态不共享加载。保留各 HTTP 请求在 await 前后的生命周期校验。 + +## 范围 + +只修改状态 controller 及其测试。不引入长期 manager、新 API、后台旧值刷新、延长 TTL、 +全局缓存、加载器优化或更强的读写排序。文档及隔离基准记录行为和实测收益。 + +## 验证 + +单元测试覆盖冷缓存及过期后合并、命中、失败重试、locale/信任及 controller 隔离, +以及各失效路径的旧加载完成竞态。运行构建、类型检查、controller 和工作区扩展路由测试。 +基线与候选使用真实文件 fixture 及同一加载器;测量 1/2/5/10 并发、缓存命中和过期。 +校验每份响应并统计完整刷新次数。耗时不含准备及启动,注明文件系统缓存已热。 +不预期单请求有明显加速。 + +## 验收 + +相同缓存身份的一批并发请求只执行一次完整刷新。旧请求完成不能覆盖新缓存或清除 +新进行中读取。结果必须记录基线 SHA、fixture 规模、样本数、耗时及限制。 diff --git a/docs/design/hosted-runtime-foundation-boundary.md b/docs/design/hosted-runtime-foundation-boundary.md new file mode 100644 index 00000000000..2d5ed65d217 --- /dev/null +++ b/docs/design/hosted-runtime-foundation-boundary.md @@ -0,0 +1,123 @@ +# Hosted Runtime foundation boundary + +[English](hosted-runtime-foundation-boundary.md) | [简体中文](hosted-runtime-foundation-boundary.zh-CN.md) + +## Status and problem + +PR #12691 contains foundations extracted from #12358, not a runnable Hosted +Harness. Review found missing session-loop and worker-route integration, native +execution APIs that do not exist on main, and incompatible Java and TypeScript +execution contracts. The agreed scope is to make the foundations buildable and +fail closed, without importing the preview architecture. + +## Scope and decisions + +`qwen serve --profile hosted-harness` fails before opening a listener, even with +valid credentials. Experimental Managed Gateway/worker/auto-local settings also +fail. Default serve remains available; explicit broker options require the +unavailable hosted profile. Environment-only broker settings have no effect in +default mode. Help text describes this restriction. + +Bridge managed-tool methods are optional capabilities. A local provider rejects a +bridge without these capabilities before creating a session. This does not add a +working ACP worker implementation. Unused declarations for durable session-store +and continuation metadata are not evidence of persistence or recovery support. + +The built-in managed factory admits ReadFile, WriteFile, Edit, NotebookEdit, +Glob, LS (when enabled), and ZoomImage only when already admitted by the source +registry. Shell and both Grep implementations are excluded until physical process +ownership and cancellation settlement are available. Native optional undefined +parameters are projected to JSON for response and integrity digests; incoming +requests retain strict validation. Edit confirmations have a bounded response +budget independent of the smaller request budget. + +ProcessRegistry distinguishes a proven nonzero/signal exit with ProcessExitError +from failures to establish process-tree cleanup. Only the proven-exit error is +accepted during local worker reclamation. Other shutdown consumers retain the +existing Error behavior and messages. + +Managed-tool invocation records are retained for retries within the active turn. +Starting a new turn requires all prior work to settle, then discards the previous +turn's invocation records and call slots. The 1,024-invocation bound therefore +applies to one turn, rather than the lifetime of the session. Old references fail +after the turn advances; completed calls cannot be prepared again under an old +prompt ID. + +## Broker contract and ownership + +All HTTP Broker operations are scoped by the resolved Harness session and its +Runtime session; tenant/workspace claims come from the service resolver. These +are not primary-daemon or process-global workspace routes. + +The Java service supports immediate execution creation. Its HTTP adapter returns +501 runtime_broker_operation_unsupported for deferred prepare/start and operator +resolution, with no dispatch or resolution side effects. UNKNOWN records return +409 runtime_broker_execution_unknown, never a fabricated executing status. The +TypeScript two-phase client therefore fails against unsupported operations; it +must not be mounted as a production closed loop yet. + +The Java HTTP transport still supports attestation and its existing tool +execute/status/cancel format. Acquire/control/release fail closed. Its reference +and result formats differ from the TypeScript ManagedToolV2Client format; this PR +does not claim interoperability between them. + +The local-process provisioner recognizes only processes it owns. An unowned or +unreachable process is UNKNOWN. Only a locally observed dead Process supports +NOT_FOUND. Broker restart adoption remains unavailable, preventing a transient +attestation failure from authorizing a replacement worker beside a live one. + +Process ownership is keyed by the complete lease, including its attested endpoint. +The binding ID survives generation changes, and retrying even the same seed can +start distinct workers. Releasing one attempt must not remove, kill, confirm, or +report usability for another attempt's process. +Issued identities remain reserved for the provisioner's lifetime, including +after release. If a worker exits and a retry reuses its seed and endpoint, the +new candidate is destroyed and rejected with a non-retryable identity conflict. +This prevents a delayed old release from targeting a replacement process. + +Broker clients can retry failed acquisition, invalidate issued clients on release, +and retain terminal release intent across concurrent release calls. + +## Validation and acceptance + +Build and typecheck all packages. Run focused Core, Bridge, CLI and Java tests. +Test real ReadFile preparation, oversized-but-bounded edit confirmations, acquire +retry, released-client rejection, unsupported profile startup, forced worker +termination and Java HTTP unsupported/UNKNOWN/authentication boundaries. + +Also verify more than 1,024 completed invocations across turns, current-turn retry +deduplication, and rejection of expired references. Provision two workers for the +same binding using different generations and again using an identical seed; +releasing the losing lease must preserve the winner and leave no orphan processes +after both leases are released. Every serve option must either be parsed by the +fast path or explicitly fall back to the full parser. +Exercise endpoint reuse after both a worker crash and normal release. Pin the +actual serve entry point's rejection before listening, and exclude Shell and +both Grep implementations even when registered in the source tool registry. + +Tests imported from the preview for absent worker routes are removed; provider +unit and transport-fixture tests remain. Those removed tests cannot establish +worker coverage until an actual worker implementation is included. + +Acceptance requires ordinary serve to load, unsupported modes to open no listener, +prepare to dispatch nothing, unknown outcomes to remain unknown, and all included +code to compile. Live model/tool round trips and restart recovery are not claimed. + +## Follow-up requirements + +A later integration must add the resident session loop, owned ACP worker routes, +a shared wire format, durable reserve/start and explicit resolution APIs, session +store/continuation wiring, process containment for Shell/Grep, and a real cold-start +E2E test. Enabling the hosted profile depends on those behaviors, not solely on +configuration validation or mocked transport tests. + +Before mounting, bind file-history relative paths and `executionCwd` to the +authenticated, resolved workspace rather than trusting a supplied binding. Unify +the worker/client wire schemas and reconcile the experimental client's 8 MiB +response budget (including its separate media allowance) with the owned-route +contract's 1 MiB tool-result limit. Keep the Hosted handshake middleware unmounted +until its capability digest is validated; ordinary capabilities do not advertise +it. Specify and test the Broker server's loopback/TLS deployment boundary and +bounded request concurrency, and validate the static provisioner through its +eventual integration caller. These are prerequisites for enabling the profile, +not capabilities provided by this PR. diff --git a/docs/design/hosted-runtime-foundation-boundary.zh-CN.md b/docs/design/hosted-runtime-foundation-boundary.zh-CN.md new file mode 100644 index 00000000000..0d9b907dbf4 --- /dev/null +++ b/docs/design/hosted-runtime-foundation-boundary.zh-CN.md @@ -0,0 +1,101 @@ +# Hosted Runtime 基础设施边界 + +[English](hosted-runtime-foundation-boundary.md) | [简体中文](hosted-runtime-foundation-boundary.zh-CN.md) + +## 状态与问题 + +PR #12691 是从 #12358 提取的基础设施,不是可运行的 Hosted Harness。 +评审发现缺少会话循环和 worker 路由接线、引用了 main 尚不存在的原生执行 API, +以及 Java 与 TypeScript 执行契约不兼容。本次约定范围是恢复构建并在能力不足时 +明确拒绝操作,不引入预览分支的完整架构。 + +## 范围与决策 + +`qwen serve --profile hosted-harness` 在监听端口之前失败,即使凭据完整也不例外。 +实验性 Managed Gateway、worker 和 auto-local 设置同样失败。默认 serve 保持可用; +显式 Broker 参数要求当前不可用的 hosted profile。默认模式下仅设置 Broker 环境 +变量不会生效。帮助文本说明这一限制。 + +Bridge 的 managed-tool 方法是可选能力。本地 provider 在创建会话之前拒绝缺少 +这些能力的 Bridge。这并未实现 ACP worker。尚未使用的持久化 session-store 和 +continuation 元数据声明不能证明持久化或恢复已可用。 + +内置 managed 工厂只接纳源 registry 已允许的 ReadFile、WriteFile、Edit、 +NotebookEdit、Glob、启用时的 LS 和 ZoomImage。在具备真实进程所有权和取消后 +执行完成判定之前,排除 Shell 及两种 Grep 实现。原生工具的可选 undefined 参数 +先投影为 JSON,再生成响应和完整性摘要;入站请求继续严格验证。编辑确认采用 +独立于较小请求限额的有界响应预算。 + +ProcessRegistry 使用 ProcessExitError 区分已确认的非零退出码或信号退出与 +无法证明进程树清理完成的错误。本地 worker 回收只接受前者。其他关闭流程的 +消费者继续保留已有 Error 行为和错误消息。 + +Managed-tool 调用记录保留至当前轮次结束,以支持轮次内重试。开始新轮次前必须 +等待此前所有工作完成,随后丢弃上一轮的调用记录及调用槽。因此 1,024 次调用的 +上限针对单个轮次,而不是会话的整个生命周期。轮次推进后旧引用失效;已完成的 +调用不能使用旧 prompt ID 再次 prepare。 + +## Broker 契约与所有权 + +所有 Broker HTTP 操作归属已解析的 Harness 会话及其 Runtime 会话;tenant 和 +workspace 来自服务端 resolver。这些不是主 daemon 或进程全局的工作区路由。 + +Java 服务支持立即创建并执行。HTTP 适配器对延迟 prepare/start 和操作员手工 +resolution 返回 501 runtime_broker_operation_unsupported,不产生执行或裁决 +副作用。UNKNOWN 记录返回 409 runtime_broker_execution_unknown,不伪装成 +executing。因此 TypeScript 两阶段客户端会在不支持的操作上失败,目前不能挂载 +为生产闭环。 + +Java HTTP transport 仍支持 attestation 及其已有格式的 execute/status/cancel。 +Acquire/control/release 明确拒绝。其引用与结果格式不同于 TypeScript 的 +ManagedToolV2Client;本 PR 不宣称两端已经互通。 + +local-process provisioner 只认领自身持有的进程。未持有或不可达的进程是 UNKNOWN; +只有本地观测到 Process 已死亡才支持 NOT_FOUND。Broker 重启后的进程认领仍不可用, +避免临时 attestation 故障允许在存活 worker 旁创建替代 worker。 + +进程所有权以包含已证明 endpoint 的完整 lease 为键。binding ID 跨 generation +保持不变,即使重试同一 seed,也可能启动不同 worker。释放某次尝试不得删除、 +终止、确认另一尝试的进程,也不得将另一进程的存活状态报告为自身的可用性。 +已发放身份在 provisioner 的整个生命周期内保持保留,release 后也不删除。 +若 worker 退出后重试复用了其 seed 与 endpoint,则销毁新的候选进程,并以 +不可重试的身份冲突拒绝发放,避免延迟到达的旧 release 指向替代进程。 + +Broker 客户端允许重试失败的 acquire,在 release 时使已发放客户端失效,并在 +并发 release 调用之间保留 terminal 意图。 + +## 验证与验收 + +对所有包执行 build 和 typecheck,并运行定向 Core、Bridge、CLI 和 Java 测试。 +覆盖真实 ReadFile prepare、超过请求限额但仍在响应预算内的编辑确认、acquire +重试、已释放客户端拒绝、未实现 profile 启动拒绝、强制终止 worker,以及 Java +HTTP 的 unsupported、UNKNOWN 和认证边界。 + +另外验证跨轮次完成超过 1,024 次调用、当前轮次重试去重以及过期引用拒绝。 +对同一 binding 分别使用不同 generation 和完全相同的 seed 启动两个 worker; +释放失败尝试的 lease 必须保留胜出的 worker,两份 lease 都释放后不得遗留孤儿 +进程。每个 serve 参数必须由 fast path 解析,或明确回退到完整解析器。 +覆盖 worker 崩溃和正常 release 后的 endpoint 复用。测试实际 serve 入口在监听 +之前拒绝启动,并验证源工具 registry 即使注册了 Shell 及两种 Grep 实现,也 +不会将它们纳入 managed 能力。 + +移除从预览带入、依赖不存在 worker 路由的测试;保留 provider 单测和传输 fixture +测试。在真正包含 worker 实现之前,被移除的测试无法证明 worker 覆盖率。 + +验收要求普通 serve 能加载、未实现模式不监听端口、prepare 不派发执行、未知结果 +保持未知,且所有纳入代码可编译。不宣称已验证真实模型/工具往返和重启恢复。 + +## 后续要求 + +后续集成需要补充常驻会话循环、自有 ACP worker 路由、统一 wire 格式、持久化 +reserve/start 与显式 resolution API、session-store/continuation 接线、 +Shell/Grep 进程管控,以及真实冷启动 E2E 测试。启用 hosted profile 必须建立在 +这些行为之上,不能仅依靠配置校验或 mock 传输测试。 + +挂载前,必须将文件历史中的相对路径及 `executionCwd` 绑定到已认证且已解析的 +工作区,不能信任传入的 binding。统一 worker/client wire schema,并协调实验 +客户端的 8 MiB 响应预算(含独立媒体例外)与自有路由契约的 1 MiB 工具结果上限。 +Hosted 握手中间件在 capability digest 完成验证前不得挂载;普通 capabilities +不宣称该能力。明确并测试 Broker 服务端的 loopback/TLS 部署边界和有界请求并发, +通过未来实际集成的调用方验证 static provisioner。这些都是启用 profile 的 +前置条件,不是本 PR 已提供的能力。 diff --git a/docs/design/managed-session-record-foundation.md b/docs/design/managed-session-record-foundation.md index d6464abea37..a6df221cbeb 100644 --- a/docs/design/managed-session-record-foundation.md +++ b/docs/design/managed-session-record-foundation.md @@ -74,11 +74,11 @@ reject malformed or newer requirements. | Events per transaction | 256 | | Encoded transaction | 8 MiB | -`eventsDigest` is SHA-256 over canonical JSON containing only each committed -event's `sequence`, `eventId`, and `kind`. Object keys are sorted and array order -is preserved, making the result independent of property insertion order. It is -an ordered identity digest, not an integrity proof for `sessionKey`, -`occurredAt`, or payload content. +`eventsDigest` is SHA-256 over canonical JSON of the complete ordered events, +including session scope, timestamps, subjects, and payloads. Object keys are +sorted and array order is preserved. The durable authority introduced in +#12693 requires this content proof; the earlier foundation's identity-only +prototype digest is not accepted as a committed journal integrity proof. `contentDigest` is the idempotency digest of the complete immutable command content. An operation with one durable input uses that verified resource's diff --git a/docs/design/managed-session-record-foundation.zh-CN.md b/docs/design/managed-session-record-foundation.zh-CN.md index 00556fcc9e3..cf4d9e6af7e 100644 --- a/docs/design/managed-session-record-foundation.zh-CN.md +++ b/docs/design/managed-session-record-foundation.zh-CN.md @@ -67,10 +67,10 @@ safe integer、非法状态转换,以及不是小写格式的 SHA-256 digest | 单事务 Event 数 | 256 | | 编码后事务 | 8 MiB | -`eventsDigest` 对每个已提交事件的 `sequence`、`eventId` 和 `kind` 组成的 -canonical JSON 计算 SHA-256。对象 key 排序,数组顺序保留,因此结果不依赖属性 -插入顺序。它只是有序身份摘要,不是 `sessionKey`、`occurredAt` 或 payload 内容的 -完整性证明。 +`eventsDigest` 对完整有序事件的 canonical JSON 计算 SHA-256,覆盖会话作用域、 +时间戳、subject 和 payload。对象 key 排序,数组顺序保留。#12693 引入的持久化 +权威要求内容完整性证明;此前基础层原型只包含事件身份的摘要不能作为已提交 +journal 的完整性证明。 `contentDigest` 是完整不可变命令内容的幂等摘要。只有一个持久输入的操作使用该 资源已校验的 digest;包含多个输入的操作,对涵盖所有会改变执行效果字段的 diff --git a/docs/design/one-shot-headless-execution.md b/docs/design/one-shot-headless-execution.md index 19fe922f6ba..d6fc0b2f383 100644 --- a/docs/design/one-shot-headless-execution.md +++ b/docs/design/one-shot-headless-execution.md @@ -29,7 +29,7 @@ The CLI relaunches itself under a parent process to apply Node memory arguments. For an explicit prompt outside ACP, stream-json input, prompt-interactive, file-input, and file-descriptor output modes, the memory-argument relaunch uses `process.execve` when available. This keeps the same executable, Node arguments, script arguments, environment provenance, and `QWEN_CODE_NO_RELAUNCH` guard without retaining the loaded parent. File-descriptor output keeps the supervisor because process replacement closes descriptors above standard input, output, and error. Runtimes without `process.execve` keep the existing supervised spawn path, and so does a runtime whose `execve` call throws. -When that relaunch would add no Node or script arguments (the default, since `advanced.autoConfigureMemory` is off) and neither `.env` files nor `settings.env` injected a value after startup (modules loaded earlier, and Node itself for variables such as `NODE_EXTRA_CA_CERTS`, only see such values in a fresh image), the replacement image would be the same process booted a second time. The CLI then sets `QWEN_CODE_NO_RELAUNCH` and continues in place instead of calling `execve`, which removes a full second module load and settings pass from every one-shot run. +When that relaunch would add no Node or script arguments (the default, since `advanced.autoConfigureMemory` is off) and neither `.env` files nor `settings.env` injected a value that anything reads before those files load (modules loaded earlier, and Node itself for variables such as `NODE_EXTRA_CA_CERTS`, only see such values in a fresh image), the replacement image would be the same process booted a second time. Model credentials and endpoints do not count: the documented per-auth-type variables and the `envKey` of every configured model provider, which is what `/auth` stores, are read per request, so a process that keeps running sees them too. The CLI then sets `QWEN_CODE_NO_RELAUNCH` and continues in place instead of calling `execve`, which removes a full second module load and settings pass from every one-shot run. Dropping the supervisor also drops its update-relaunch handler: `onUpdateRelaunch` never fires, and no parent remains to relaunch on an exit code. That is intended. The exit codes that request a relaunch come from the interactive trust dialogs (`RELAUNCH_EXIT_CODE`) and the `/update` command (`UPDATE_RELAUNCH_EXIT_CODE`), none of which a one-shot prompt can reach, and `requestUpdateOnExit()` already returns `false` when there is no IPC channel to send the request over. diff --git a/docs/design/one-shot-headless-execution.zh-CN.md b/docs/design/one-shot-headless-execution.zh-CN.md index 1539af0882a..026fd211a4f 100644 --- a/docs/design/one-shot-headless-execution.zh-CN.md +++ b/docs/design/one-shot-headless-execution.zh-CN.md @@ -29,7 +29,7 @@ CLI 会在父进程下重新启动自身,以应用 Node 内存参数。shell 对于 ACP、stream-json 输入、prompt-interactive、文件输入和文件描述符输出模式之外的明确 prompt,内存参数 relaunch 会在可用时调用 `process.execve`。它会保留相同的可执行文件、Node 参数、脚本参数、环境来源和 `QWEN_CODE_NO_RELAUNCH` 防重入标记,同时不再保留已加载的父进程。文件描述符输出会保留监督进程,因为进程替换会关闭标准输入、输出和错误之外的描述符。不支持 `process.execve` 的运行时继续使用现有的受监督 spawn 路径,`process.execve` 调用抛异常的运行时同样如此。 -如果这次 relaunch 既不会追加 Node 参数也不会追加脚本参数(默认如此,因为 `advanced.autoConfigureMemory` 默认关闭),并且启动后 `.env` 文件和 `settings.env` 都没有注入任何值(更早加载的模块,以及读取 `NODE_EXTRA_CA_CERTS` 等变量的 Node 本身,只有在新镜像里才能看到这些值),那么替换后的镜像只是把同一个进程再启动一遍。此时 CLI 设置 `QWEN_CODE_NO_RELAUNCH` 并原地继续,不再调用 `execve`,每次单次运行因此省掉一整轮模块加载和 settings 处理。 +如果这次 relaunch 既不会追加 Node 参数也不会追加脚本参数(默认如此,因为 `advanced.autoConfigureMemory` 默认关闭),并且 `.env` 文件和 `settings.env` 都没有注入任何会在这些文件加载之前被读取的值(更早加载的模块,以及读取 `NODE_EXTRA_CA_CERTS` 等变量的 Node 本身,只有在新镜像里才能看到这些值),那么替换后的镜像只是把同一个进程再启动一遍。模型凭据与端点不算在内:文档中各认证类型对应的变量,以及每个已配置模型 provider 的 `envKey`(即 `/auth` 写入的内容),都是在发请求时才读取,继续运行的进程同样能看到。此时 CLI 设置 `QWEN_CODE_NO_RELAUNCH` 并原地继续,不再调用 `execve`,每次单次运行因此省掉一整轮模块加载和 settings 处理。 去掉监督进程也意味着去掉它的 update-relaunch 处理逻辑:`onUpdateRelaunch` 不会触发,也没有父进程能根据退出码重新启动。这是有意为之。会请求 relaunch 的退出码来自交互式信任对话框(`RELAUNCH_EXIT_CODE`)和 `/update` 命令(`UPDATE_RELAUNCH_EXIT_CODE`),单次 prompt 到不了这些路径;而在没有 IPC 通道可发送请求时,`requestUpdateOnExit()` 本身就返回 `false`。 diff --git a/docs/developers/daemon-rest-api-reference.md b/docs/developers/daemon-rest-api-reference.md index 786a175c2ef..9e8be21e104 100644 --- a/docs/developers/daemon-rest-api-reference.md +++ b/docs/developers/daemon-rest-api-reference.md @@ -42,6 +42,8 @@ this contract, not deprecated. deprecated `unstable_session_resume` capability name is only an alias; use `session_resume` for the stable resume route. +Creation with `startupConfig: { modelServiceId, reasoningEffort? }` additionally requires `session_startup_config`. Only the model is required; omission of reasoning does not set a default. Successful preparation returns `modelApplied: true` and `startupConfigApplied`; malformed input returns `400 invalid_startup_config`, while rejected selection returns `422 startup_config_rejected`. See the [startup negotiation contract](./qwen-serve-protocol.md#capabilities) for ordinary and standalone behavior. + ## Discovery | Operation | Capability | Scope | TypeScript SDK | diff --git a/docs/developers/daemon-rest-api.openapi.json b/docs/developers/daemon-rest-api.openapi.json index 88257908a4b..7ad04fcd7a5 100644 --- a/docs/developers/daemon-rest-api.openapi.json +++ b/docs/developers/daemon-rest-api.openapi.json @@ -226,7 +226,7 @@ } }, "400": { - "description": "The workspace, session id, scope, or request is invalid.", + "description": "The workspace, session id, scope, or request is invalid. Invalid startupConfig yields invalid_startup_config.", "content": { "application/json": { "schema": { @@ -284,6 +284,16 @@ } } } + }, + "422": { + "description": "startup_config_rejected: the requested selection was rejected or could not be confirmed. The daemon attempts to close the new live session and rolls back the rejected session's persisted recording (named by the rejection even when the daemon generated the id), both on a best-effort basis; a close or rollback it could not complete is logged and the definite rejection is still returned. Retryability of a caller-supplied id is self-certifying: retry the create; a 409 session_id_conflict means the id is still occupied.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } } } } @@ -3192,6 +3202,9 @@ } }, "additionalProperties": false + }, + "startupConfig": { + "$ref": "#/components/schemas/SessionStartupConfig" } }, "additionalProperties": false @@ -3232,7 +3245,8 @@ "type": "boolean" }, "modelApplied": { - "type": "boolean" + "type": "boolean", + "description": "Only present on a fresh spawn (attached: false) that carried modelServiceId or startupConfig. Always true for successful startupConfig preparation; for legacy model selection, false means the switch was rejected (surfaced via the model_switch_failed event) and the session runs the agent default model. An attach omits the key or, when it coalesced with an in-flight spawn, reports the spawn owner's outcome — on attach, model_switch_failed is the caller's signal." }, "worktree": { "type": "object", @@ -3264,6 +3278,9 @@ "type": "string" } } + }, + "startupConfigApplied": { + "$ref": "#/components/schemas/SessionStartupConfigApplied" } }, "additionalProperties": true @@ -4321,6 +4338,112 @@ } }, "additionalProperties": false + }, + "SessionStartupConfig": { + "type": "object", + "required": ["modelServiceId"], + "properties": { + "modelServiceId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "none", + "default", + "low", + "medium", + "high", + "xhigh", + "max" + ], + "description": "Optional. Omission selects only the model; it does not apply default or call the reasoning setter." + } + }, + "additionalProperties": false, + "description": "Requires session_startup_config. Cannot be combined with legacy modelServiceId or single session scope." + }, + "SessionStartupConfigApplied": { + "type": "object", + "required": ["modelServiceId"], + "properties": { + "modelServiceId": { + "type": "string", + "minLength": 1 + }, + "reasoningEffort": { + "type": "string", + "enum": ["none", "default", "low", "medium", "high", "xhigh", "max"] + }, + "effectiveReasoning": { + "oneOf": [ + { + "type": "object", + "required": ["state"], + "properties": { + "state": { + "const": "enabled" + }, + "effort": { + "type": "string", + "enum": ["low", "medium", "high", "xhigh", "max"] + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["state"], + "properties": { + "state": { + "const": "disabled" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["state"], + "properties": { + "state": { + "const": "provider-default" + } + }, + "additionalProperties": false + } + ] + } + }, + "additionalProperties": false, + "description": "Creation-only confirmation. Model-only requests omit reasoningEffort and effectiveReasoning; explicit reasoning requests include both." + }, + "CreateStandaloneSessionRequest": { + "type": "object", + "required": ["sessionId"], + "properties": { + "sessionId": { + "type": "string", + "format": "uuid", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$", + "description": "Required caller-supplied RFC-variant UUID v1-v5 for standalone creation." + }, + "modelServiceId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "startupConfig": { + "$ref": "#/components/schemas/SessionStartupConfig" + }, + "approvalMode": { + "type": "string", + "enum": ["plan", "default", "auto-edit", "auto", "yolo"] + } + }, + "additionalProperties": false, + "description": "Body schema for the separately documented standalone_sessions_v1 lifecycle. startupConfig additionally requires session_startup_config." } } } diff --git a/docs/developers/daemon/08-session-lifecycle.md b/docs/developers/daemon/08-session-lifecycle.md index 1de9bb708d9..c810ed1f6aa 100644 --- a/docs/developers/daemon/08-session-lifecycle.md +++ b/docs/developers/daemon/08-session-lifecycle.md @@ -345,7 +345,7 @@ resets that window, including when the channel was already live. timeout. - `BridgeOptions.sessionRestoreTimeoutMs` (default 60s) — ACP `loadSession` / `unstable_resumeSession` deadline. Defaults to 60s; an explicitly configured initialize timeout can raise it, but never lower it. - `BridgeOptions.channelIdleTimeoutMs` (unset or `0` reaps after runtime work drains, except that plain preheat is preserved for first use; a positive value or active keepalive delays reaping, and the longer delay wins). -- Capability tags: `session_create`, `session_id_override`, `session_scope_override`, `session_load`, `session_resume`, `unstable_session_resume` (deprecated alias), `session_list`, `session_info`, `session_close`, `session_metadata`, `session_set_model`, `client_identity`, `client_heartbeat`, `session_recap`, `session_generation`, `session_btw`, `session_context_usage`, `session_tasks`, `session_monitor_tool_correlation`, `session_stats`, `session_lsp`, `session_resources`, `session_status`, `non_blocking_prompt`. +- Capability tags: `session_create`, `session_startup_config`, `session_id_override`, `session_scope_override`, `session_load`, `session_resume`, `unstable_session_resume` (deprecated alias), `session_list`, `session_info`, `session_close`, `session_metadata`, `session_set_model`, `client_identity`, `client_heartbeat`, `session_recap`, `session_generation`, `session_btw`, `session_context_usage`, `session_tasks`, `session_monitor_tool_correlation`, `session_stats`, `session_lsp`, `session_resources`, `session_status`, `non_blocking_prompt`. ### Stateless generation (`session_generation` capability tag) diff --git a/docs/developers/daemon/11-capabilities-versioning.md b/docs/developers/daemon/11-capabilities-versioning.md index 1afad812f04..63af453b949 100644 --- a/docs/developers/daemon/11-capabilities-versioning.md +++ b/docs/developers/daemon/11-capabilities-versioning.md @@ -109,7 +109,7 @@ Baseline tags are not present in the `Map` and are advertised unconditionally. T Foundation: `health`, `daemon_status`, `capabilities`. -Sessions: `session_create`, `session_id_override`, `session_scope_override`, `session_load`, `session_resume`, `unstable_session_resume`, `session_list`, `session_info`, `session_prompt`, `session_mid_turn_message_mutation`, `session_cancel`, `session_events`, `session_set_model`, `session_close`, `session_metadata`, `session_archive`, `session_storage_conflict_repair`, `session_export`, `session_transcript`, `session_context`, `session_context_usage`, `session_supported_commands`, `session_tasks`, `session_monitor_tool_correlation`, `session_stats`, `session_lsp`, `session_resources`, `session_status`, `session_approval_mode_control`, `session_recap`, `session_btw`, **`session_shell_command`** (conditional), `session_language`, **`user_language_sync`** (conditional), `session_rewind`, `session_hooks`, `session_branch`. +Sessions: `session_create`, `session_startup_config`, `session_id_override`, `session_scope_override`, `session_load`, `session_resume`, `unstable_session_resume`, `session_list`, `session_info`, `session_prompt`, `session_mid_turn_message_mutation`, `session_cancel`, `session_events`, `session_set_model`, `session_close`, `session_metadata`, `session_archive`, `session_storage_conflict_repair`, `session_export`, `session_transcript`, `session_context`, `session_context_usage`, `session_supported_commands`, `session_tasks`, `session_monitor_tool_correlation`, `session_stats`, `session_lsp`, `session_resources`, `session_status`, `session_approval_mode_control`, `session_recap`, `session_btw`, **`session_shell_command`** (conditional), `session_language`, **`user_language_sync`** (conditional), `session_rewind`, `session_hooks`, `session_branch`. Streaming: `slow_client_warning`, `typed_event_schema`. diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index 9b98aeb510e..ea834fae890 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -206,7 +206,7 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design §10). ``` -['health', 'capabilities', 'session_create', 'session_id_override', 'session_scope_override', +['health', 'capabilities', 'session_create', 'session_startup_config', 'session_id_override', 'session_scope_override', 'session_load', 'session_resume', 'session_transcript', 'unstable_session_resume', 'session_list', 'session_catalog_batch', 'session_info', 'session_prompt', 'session_mid_turn_message_mutation', @@ -254,6 +254,10 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design > Conditional tags appear only when their matching deployment toggle is on (see the table below). F3's `permission_mediation` tag is always-on and carries `modes: ['first-responder', 'designated', 'consensus', 'local-only']` so SDK clients can introspect the build-supported set; the runtime-active strategy is at `body.policy.permission`. +`session_startup_config` advertises optional `startupConfig: { modelServiceId, reasoningEffort? }` on ordinary and standalone creation. Always preflight this tag: older ordinary-create routes may silently ignore the object. `modelServiceId` is required (1–256 characters); `reasoningEffort` accepts `none`, `default`, `low`, `medium`, `high`, `xhigh`, or `max` when supported by the selected model. Omission applies only the model, without a reasoning setter or implicit `default`. The object cannot be combined with the legacy top-level `modelServiceId` or explicit single scope and implies a new thread. Startup never saves shared defaults; later session behavior is unchanged. + +Success includes `modelApplied: true` and `startupConfigApplied: { modelServiceId, reasoningEffort?, effectiveReasoning? }`. The model selector is canonical. Only explicit reasoning requests include the two reasoning fields; effective state is `{ state: 'disabled' }`, `{ state: 'provider-default' }`, or `{ state: 'enabled', effort? }` (toggle-only reasoning has no effort). Invalid structure is `400 invalid_startup_config`; a rejected or unconfirmed selection is `422 startup_config_rejected`. Definite standalone rejection rolls back its owned recording and attempts to discard the empty output directory before returning; a recording rollback whose removal cannot be confirmed and transient startup failures retain `standalone_creation_outcome_unknown`, while a failed directory discard is only logged and the definite rejection is still returned. A success-body confirmation failure in the SDK is a protocol error, not an instruction to adopt a recovered session. + `session_scope_override` is the negotiation handle for the per-request `sessionScope` field on `POST /session` (see below). Older daemons silently ignore the field, so SDK clients should pre-flight `caps.features` for this tag before sending it. `session_id_override` is the negotiation handle for the optional caller-supplied `sessionId` on `POST /session` and ACP `session/new` metadata. Clients must confirm that `caps.features` contains this tag before sending the field because older daemons may silently ignore it. @@ -2535,7 +2539,7 @@ When `/capabilities.features` contains `standalone_sessions_v1`, the daemon expo | Route | Request | Success | | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| `POST /standalone/sessions` | `{ "sessionId": "", "modelServiceId"?: string, "approvalMode"?: ApprovalMode }` | `200` with the standalone session, `context: { "kind": "standalone" }`, and its managed projectless output directory. Creation is prompt-less. | +| `POST /standalone/sessions` | `{ "sessionId": "", "modelServiceId"?: string, "startupConfig"?: { "modelServiceId": string, "reasoningEffort"?: ReasoningSelection }, "approvalMode"?: ApprovalMode }` | `200` with the standalone session, `context: { "kind": "standalone" }`, and its managed projectless output directory. Creation is prompt-less. | | `GET /standalone/session-options` | none; any query field is rejected with 400 | `200` with `{ v, initialized, current?, approvalMode?, providers, errors? }`; the internal workspace path and ACP-channel state are omitted | | `GET /standalone/sessions` | Query: `cursor?`, `size?` (1-100), `archiveState?` (`active` or `archived`) | `200 { sessions, nextCursor?, liveMergeFailed?, truncated? }` | | `GET /standalone/sessions/:id` | none | `202 { sessionId, state: "creating" }` while local creation is in flight, otherwise `200` with the exact summary. | @@ -2548,7 +2552,7 @@ When `/capabilities.features` contains `standalone_sessions_v1`, the daemon expo | `POST /standalone/sessions/unarchive` | `{ "sessionIds": ["", ...] }` | `200 { unarchived, alreadyActive, notFound, errors }` | | `POST /standalone/sessions/delete` | `{ "sessionIds": ["", ...] }` | `200 { removed, notFound, errors, fileCleanupPending }` | -When `POST /standalone/sessions` carries `modelServiceId`, the response includes `modelApplied`: `false` means the spawn-time model switch failed (also surfaced via the `model_switch_failed` session event) and the session is running on the agent default model — the create itself still succeeds so the caller can warn, release, or retry explicitly. +When `POST /standalone/sessions` carries the legacy `modelServiceId`, the response includes `modelApplied`: `false` means the spawn-time model switch failed (also surfaced via the `model_switch_failed` session event) and the session is running on the agent default model — the create itself still succeeds so the caller can warn, release, or retry explicitly. Bodies must be JSON objects with no unknown fields. IDs are RFC UUID v1-v5 values; the daemon canonicalizes them to lowercase. Batch requests contain 1-100 strings and are validated and de-duplicated before mutation. A batch failure is reported as `{ sessionId, code, message }` and does not roll back successful operations on other IDs. `fileCleanupPending` means transcript deletion committed but journal-authorized sidecar or managed-directory cleanup must be retried by reconciliation; the session is already logically removed. @@ -2558,6 +2562,8 @@ Archive, unarchive, repair, rename, and delete share the same per-session lifecy ### `POST /session` +The optional `startupConfig` contract is described under [Capabilities](#capabilities). Unlike legacy best-effort model selection, a rejected startup selection fails creation. + Spawn a new agent or attach to an existing one (under `sessionScope: 'single'`, the default). Request: @@ -2576,6 +2582,7 @@ Request: | ---------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `cwd` | no | Absolute path matching one registered workspace. If omitted, the route falls back to the primary workspace (read it off `/capabilities.workspaceCwd`). A mismatched non-empty `cwd` returns `400 workspace_mismatch`. When `features` contains `multi_workspace_sessions`, clients may pass any trusted `workspaces[].cwd`; otherwise only the primary workspace is accepted. Workspace paths are canonicalized via `realpathSync.native` (with a resolve-only fallback for non-existent paths) so case-insensitive filesystems don't reject sessions per spelling. | | `modelServiceId` | no | Selects which configured _model service_ the agent will route through (the back-end provider — Alibaba ModelStudio, OpenRouter, etc). If omitted the agent uses its default. If the workspace already has a session, this calls `setSessionModel` on the existing one and broadcasts `model_switched`. Distinct from `modelId` on `POST /session/:id/model`, which selects the model **within** an already-bound service. The `modelServices` array on `/capabilities` is reserved for advertising configured services; in Stage 1 it is always `[]` (the agent's default service is used and not enumerated over HTTP). | +| `startupConfig` | no | `{ modelServiceId: string, reasoningEffort?: ReasoningSelection }`; requires `session_startup_config`, creates a new thread and confirms the requested selection without writing shared defaults. Cannot be mixed with top-level `modelServiceId` or explicit single scope. | | `sessionId` | no | RFC-variant UUID v1-v5 chosen by the caller. The daemon normalizes it to lowercase and always creates a fresh thread session; it never treats this field as an idempotent attach. Confirm that `caps.features` contains `session_id_override` before sending it because older daemons may ignore unknown fields. `null` is equivalent to omission. | | `sessionScope` | no | Per-request override for session sharing. `'single'` (the daemon-wide default) makes a second same-workspace `POST /session` reuse the existing session (`attached: true`); `'thread'` forces a fresh distinct session every call. Omit to inherit the daemon-wide default. Values outside the enum return `400 { code: 'invalid_session_scope' }`. Old daemons (pre-#4175 PR 5) silently ignore the field — pre-flight `caps.features.session_scope_override` before sending. The daemon-wide default is hardcoded to `'single'` in production today; #4175 may add a `--sessionScope` CLI flag in a follow-up. | | `worktree` | no | Create a fresh thread session in a user-named Git worktree. The optional `slug` uses the daemon's worktree-name validation. Pre-flight `session_worktree_persistence_v1`; clients must not infer durable isolation from older worktree-shaped responses. Worktree creation is not an attach operation and cannot be combined with branch creation. The route returns success only after relocation, exclusive ownership-marker creation, sidecar persistence, and a final runtime-generation check. | diff --git a/docs/developers/rest-api-integration.md b/docs/developers/rest-api-integration.md index e52c1af936c..bf3dbc9c482 100644 --- a/docs/developers/rest-api-integration.md +++ b/docs/developers/rest-api-integration.md @@ -295,7 +295,16 @@ deliberately not propagated as an HTTP error. On a **fresh create** that carries `modelServiceId` — which step 2's body does not — the `200` body also carries `modelApplied`, `false` when the switch was rejected, and that is the deterministic one to act on rather than an event on a bounded ring. A create -without `modelServiceId` has no `modelApplied` key at all. +carrying neither `modelServiceId` nor `startupConfig` has no `modelApplied` key +of its own — one that coalesced with an in-flight spawn reports the spawn +owner's `modelApplied`, which is why the attach signal to act on stays +`model_switch_failed`. + +To confirm a startup selection instead of using legacy best-effort switching, +preflight `session_startup_config` and send `startupConfig: { modelServiceId, +reasoningEffort? }`. Model-only creation works without a reasoning control; an +explicit unsupported effort fails with `422 startup_config_rejected` rather than +returning `modelApplied: false`. Startup does not write shared defaults. ```bash # terminal 2 — re-export what you need; shell variables do not cross terminals diff --git a/docs/developers/sdk-typescript.md b/docs/developers/sdk-typescript.md index dfa31cab600..07098754b33 100644 --- a/docs/developers/sdk-typescript.md +++ b/docs/developers/sdk-typescript.md @@ -185,6 +185,12 @@ The SDK requires the daemon's `session_id_override` capability before sending th This option always creates a new thread session and is not an idempotent attach. If the create outcome is ambiguous, use the known ID with load or resume. Omitting the option preserves the existing create-or-attach behavior. +## Daemon startup model and reasoning selection + +`DaemonClient.createOrAttachSession` and `createStandaloneSession` accept `startupConfig: { modelServiceId, reasoningEffort? }` and preflight `session_startup_config` before creation. The model is required; omit reasoning for model-only selection, including models with no reasoning control. An explicit effort is validated against the model. The SDK checks `modelApplied: true` and the canonical `startupConfigApplied` response; model-only confirmation omits reasoning fields. + +Startup selection does not save shared defaults or impose a lifetime pin. Definite selection rejection fails creation; uncertain standalone creation retains its existing recovery result. A successful response whose startup confirmation is missing or mismatched is rejected directly and does not trigger standalone recovery/adoption. See the [protocol](./qwen-serve-protocol.md#capabilities). + ## Talking to running sessions `@qwen-code/sdk/peer` lets a program that is not a Qwen Code session join the diff --git a/esbuild.config.js b/esbuild.config.js index bd4f277b1d9..05496a4dd8b 100644 --- a/esbuild.config.js +++ b/esbuild.config.js @@ -252,6 +252,7 @@ const mainBuild = esbuild.build({ metafile: true, write: true, keepNames: true, + minifyWhitespace: true, }); // fzf index worker — runs in its own worker_threads worker that diff --git a/integration-tests/cli/qwen-serve-routes.test.ts b/integration-tests/cli/qwen-serve-routes.test.ts index 50619791eb2..f842c04c566 100644 --- a/integration-tests/cli/qwen-serve-routes.test.ts +++ b/integration-tests/cli/qwen-serve-routes.test.ts @@ -17,6 +17,7 @@ * `qwen-serve-streaming.test.ts`, backed by the local fake OpenAI server. */ import { spawn, type ChildProcess } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; import { appendFileSync, mkdirSync, @@ -136,6 +137,31 @@ function chatRecord( beforeAll(async () => { homeDir = mkdtempSync(path.join(tmpdir(), 'qwen-serve-routes-home-')); + mkdirSync(path.join(homeDir, '.qwen')); + writeFileSync( + path.join(homeDir, '.qwen', 'settings.json'), + JSON.stringify({ + model: { reasoningEffort: 'none' }, + modelProviders: { + openai: [ + { + id: 'qwen-startup-test', + name: 'Startup test', + baseUrl: 'http://127.0.0.1:9/v1', + envKey: 'OPENAI_API_KEY', + capabilities: { + reasoning: { + thinking: true, + efforts: ['low', 'high'], + defaultEffort: 'low', + disableField: 'reasoning_effort', + }, + }, + }, + ], + }, + }), + ); nativeDirectoryPickerAtBoot = isNativeDirectoryPickerAvailable(); localPathOpenAtBoot = isLocalPathOpenAvailable(); localTerminalOpenAtBoot = isLocalTerminalAvailable(); @@ -343,6 +369,7 @@ describe('qwen serve — capabilities envelope', () => { 'daemon_status', 'capabilities', 'session_create', + 'session_startup_config', 'session_id_override', 'session_scope_override', 'session_load', @@ -841,6 +868,112 @@ describe('qwen serve — POST /session validation + concurrent coalescing', () = expect([a.attached, b.attached].sort()).toEqual([false, true]); }); + it('confirms real model-only startup and the cached model notification', async () => { + const modelServiceId = 'qwen-startup-test(openai)'; + const session = await client.createOrAttachSession({ + workspaceCwd: REPO_ROOT, + startupConfig: { modelServiceId }, + }); + try { + expect(session).toMatchObject({ + modelApplied: true, + startupConfigApplied: { modelServiceId }, + }); + expect(session.startupConfigApplied).not.toHaveProperty( + 'reasoningEffort', + ); + const status = await client.daemonStatus('full'); + expect( + status.full?.sessions.find( + (entry) => entry.sessionId === session.sessionId, + )?.currentModelId, + ).toBe(modelServiceId); + } finally { + await client.closeSession(session.sessionId); + } + }); + + it('applies high over shared none and tears down only rejected startup selections', async () => { + const modelServiceId = 'qwen-startup-test(openai)'; + const session = await client.createOrAttachSession({ + workspaceCwd: REPO_ROOT, + startupConfig: { modelServiceId, reasoningEffort: 'high' }, + }); + try { + expect(session.startupConfigApplied).toEqual({ + modelServiceId, + reasoningEffort: 'high', + effectiveReasoning: { state: 'enabled', effort: 'high' }, + }); + const context = await client.sessionContext( + session.sessionId, + session.clientId, + ); + expect(context.state.configOptions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'reasoning_effort', + currentValue: 'high', + }), + ]), + ); + } finally { + await client.closeSession(session.sessionId); + } + const sessionId = randomUUID(); + const failed = await fetch(`${base}/session`, { + method: 'POST', + headers: { + Authorization: `Bearer ${TOKEN}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + cwd: REPO_ROOT, + sessionId, + startupConfig: { modelServiceId, reasoningEffort: 'max' }, + }), + }); + const failureBody = await failed.json(); + expect(failed.status, JSON.stringify(failureBody)).toBe(422); + expect(failureBody).toMatchObject({ + code: 'startup_config_rejected', + }); + const missing = await fetch(`${base}/session/${sessionId}`, { + headers: { Authorization: `Bearer ${TOKEN}` }, + }); + expect(missing.status).toBe(404); + }); + + it('rolls back rejected standalone startup so its caller id can be reused', async () => { + const sessionId = randomUUID(); + const modelServiceId = 'qwen-startup-test(openai)'; + await expect( + client.createStandaloneSession({ + sessionId, + startupConfig: { modelServiceId, reasoningEffort: 'max' }, + }), + ).rejects.toMatchObject({ + status: 422, + body: { code: 'startup_config_rejected' }, + }); + await expect(client.getStandaloneSession(sessionId)).rejects.toMatchObject({ + status: 404, + }); + const retried = await client.createStandaloneSession({ + sessionId, + startupConfig: { modelServiceId, reasoningEffort: 'high' }, + }); + try { + expect(retried).toMatchObject({ + sessionId, + modelApplied: true, + startupConfigApplied: { modelServiceId, reasoningEffort: 'high' }, + }); + } finally { + await client.closeSession(sessionId); + } + }); + it('bad modelServiceId keeps the session alive on the default model', async () => { // Per #3889 review A05Ym: when the requested model is rejected at // create-session time, the session stays operational on the diff --git a/package.json b/package.json index c276517c448..8ffeaacde96 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "start": "node scripts/start.js", "dev": "node scripts/dev.js", "dev:daemon": "node scripts/daemon-dev.js", + "dev:managed-agent-web": "npm --workspace packages/web-shell run dev -- --port 5174", "debug": "cross-env DEBUG=1 node --inspect-brk scripts/start.js", "generate": "node scripts/generate-git-commit-info.js", "generate:settings-schema": "node --import tsx/esm scripts/generate-settings-schema.ts", @@ -54,6 +55,10 @@ "test:release:workspaces": "cross-env NODE_OPTIONS=\"--max-old-space-size=3072\" npm run test:ci --workspaces --if-present -- --coverage.enabled=false", "test:scripts": "vitest run --config ./scripts/tests/vitest.config.ts", "test:e2e": "cross-env VERBOSE=true KEEP_OUTPUT=true npm run test:integration:sandbox:none", + "test:e2e:managed-agent-server": "tsx scripts/run-managed-agent-server-e2e.ts", + "test:e2e:managed-session-failover": "tsx scripts/run-managed-agent-server-e2e.ts --session-failover", + "test:e2e:managed-inflight-failover": "tsx scripts/run-managed-agent-server-e2e.ts --inflight-failover", + "test:e2e:managed-continuation-failover": "tsx scripts/run-managed-agent-server-e2e.ts --continuation-failover", "test:chrome-extension:e2e": "npm -w packages/chrome-extension run test:e2e:chrome", "test:chrome-extension:release": "npm run build && cross-env DEV=true npm run bundle && npm -w packages/chrome-extension run test:release", "test:integration:all": "npm run test:integration:sandbox:none && npm run test:integration:sandbox:docker && npm run test:integration:sandbox:podman", diff --git a/packages/acp-bridge/package.json b/packages/acp-bridge/package.json index 3b4c1d9d289..59147907628 100644 --- a/packages/acp-bridge/package.json +++ b/packages/acp-bridge/package.json @@ -60,6 +60,10 @@ "types": "./dist/sessionAttachments.d.ts", "import": "./dist/sessionAttachments.js" }, + "./sessionStartupConfig": { + "types": "./dist/session-startup-config.d.ts", + "import": "./dist/session-startup-config.js" + }, "./sessionSource": { "types": "./dist/session-source.d.ts", "import": "./dist/session-source.js" diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 5e2329e8f42..fe2c9183fcc 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -7,6 +7,7 @@ import type { ApprovalMode, BackgroundNotificationTurn, + ManagedToolV2Client, GoalControlRequest, GoalSnapshotV2, GoalStateResponse, @@ -66,6 +67,11 @@ import type { ServeSessionStatsStatus, } from './status.js'; +import type { + SessionStartupConfig, + SessionStartupConfigApplied, +} from './session-startup-config.js'; + export interface RewindSnapshotInfo { promptId: string; turnIndex: number; @@ -122,6 +128,124 @@ export type BridgePromptRequest = Omit & { eventDetailMode?: LiveReplayMode; }; +export interface BridgeManagedRuntimeToolManifest { + capabilityDigest: string; + tools: unknown[]; +} + +export interface BridgeManagedRuntimeToolExecuteRequest { + executionId: string; + turnId: string; + toolCallId: string; + capabilityDigest: string; + toolName: string; + input: Record; +} + +export interface BridgeManagedRuntimeToolExecuteResult { + responseParts: unknown[]; + executionStatus?: 'not_started' | 'success' | 'error' | 'cancelled'; + error?: { message: string; type?: string }; +} + +/** Private Hosted Harness capability for one Session's durable store. */ +export interface BridgeManagedSessionStore { + baseUrl: string; + tenantId: string; + workspaceId: string; + writerId: string; + leaseDurationMs: number; +} + +const MANAGED_SESSION_STORE_FIELDS = new Set([ + 'baseUrl', + 'tenantId', + 'workspaceId', + 'writerId', + 'leaseDurationMs', +]); +const MANAGED_SESSION_STORE_TENANT_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/u; + +export function parseBridgeManagedSessionStore( + value: unknown, +): BridgeManagedSessionStore { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new TypeError('managedSessionStore must be an object'); + } + const record = value as Record; + if ( + Object.keys(record).some((key) => !MANAGED_SESSION_STORE_FIELDS.has(key)) + ) { + throw new TypeError('managedSessionStore contains an unsupported field'); + } + const baseUrl = requireManagedSessionStoreString(record, 'baseUrl', 2048); + const tenantId = requireManagedSessionStoreString(record, 'tenantId', 128); + const workspaceId = requireManagedSessionStoreString( + record, + 'workspaceId', + 512, + ); + const writerId = requireManagedSessionStoreString(record, 'writerId', 512); + if (!MANAGED_SESSION_STORE_TENANT_PATTERN.test(tenantId)) { + throw new TypeError('managedSessionStore.tenantId is invalid'); + } + const parsedBaseUrl = new URL(baseUrl); + if ( + (parsedBaseUrl.protocol !== 'http:' && + parsedBaseUrl.protocol !== 'https:') || + parsedBaseUrl.username !== '' || + parsedBaseUrl.password !== '' || + parsedBaseUrl.search !== '' || + parsedBaseUrl.hash !== '' + ) { + throw new TypeError( + 'managedSessionStore.baseUrl must be an HTTP(S) URL without credentials, query, or fragment', + ); + } + const leaseDurationMs = record['leaseDurationMs']; + if ( + typeof leaseDurationMs !== 'number' || + !Number.isInteger(leaseDurationMs) || + leaseDurationMs < 1_000 || + leaseDurationMs > 300_000 + ) { + throw new TypeError( + 'managedSessionStore.leaseDurationMs must be an integer from 1000 through 300000', + ); + } + return Object.freeze({ + baseUrl: parsedBaseUrl.toString().replace(/\/$/u, ''), + tenantId, + workspaceId, + writerId, + leaseDurationMs, + }); +} + +function requireManagedSessionStoreString( + record: Readonly>, + field: string, + maxBytes: number, +): string { + const value = record[field]; + if ( + typeof value !== 'string' || + value.length === 0 || + Buffer.byteLength(value, 'utf8') > maxBytes || + [...value].some((character) => { + const codePoint = character.codePointAt(0); + return ( + codePoint !== undefined && (codePoint < 0x20 || codePoint === 0x7f) + ); + }) + ) { + throw new TypeError(`managedSessionStore.${field} is invalid`); + } + return value; +} + +export type { ManagedToolV2Client } from '@qwen-code/qwen-code-core'; + export interface RewindRequest { promptId: string; rewindFiles?: boolean; @@ -136,6 +260,7 @@ export interface RewindResponse { } export interface BridgeSpawnRequest { + startupConfig?: SessionStartupConfig; /** Absolute path to the workspace root the child inherits as cwd. */ workspaceCwd: string; /** Optional explicit model service id; falls back to settings default. */ @@ -176,6 +301,8 @@ export interface BridgeSpawnRequest { * sessionId field. */ sessionId?: string; + /** Trusted Hosted Harness route only; forwarded through private ACP metadata. */ + managedSessionStore?: BridgeManagedSessionStore; } /** Internal daemon-only creation surface for a managed standalone session. */ @@ -191,7 +318,7 @@ export interface BridgeStandaloneSpawnRequest { approvalMode?: ApprovalMode; } -export type { BackgroundNotificationTurn } from '@qwen-code/qwen-code-core'; +export type { BackgroundNotificationTurn }; export function parseBackgroundNotificationTurn( value: unknown, @@ -242,6 +369,7 @@ export function parseBackgroundNotificationTurn( } export interface BridgeSession { + startupConfigApplied?: SessionStartupConfigApplied; sessionId: string; /** * Runtime ownership root used for routing and persisted-session lookup. @@ -280,12 +408,16 @@ export interface BridgeSession { /** True iff the source metadata was durably written to the transcript. */ sourcePersisted?: boolean; /** - * Only present when the spawn carried a `modelServiceId`. `true` iff the - * model was actually applied via `unstable_setSessionModel`; `false` means - * the apply failed (surfaced via `model_switch_failed`) and the session is - * running on the agent's default model. Lets create callers distinguish a - * confirmed selection from a silent fallback instead of assuming the - * requested model is live. + * Only present on a fresh spawn (`attached: false`) that carried + * `modelServiceId` or `startupConfig`. Always true for successful + * startupConfig preparation. For legacy model selection, true confirms + * the model switch; false means the apply failed (surfaced via + * `model_switch_failed`) and the session is running on the agent's + * default model. An attach omits the key or, when it coalesced with an + * in-flight spawn, reports the spawn owner's outcome — on attach the + * `model_switch_failed` event is the caller's signal. Lets create + * callers distinguish a confirmed selection from a silent fallback + * instead of assuming the requested model is live. */ modelApplied?: boolean; /** Present when the session was created with worktree isolation. */ @@ -314,6 +446,8 @@ export interface BridgeRestoreSessionRequest { /** Keep inherited fork records as model context without replaying them. */ hideInheritedHistory?: boolean; approvalMode?: ApprovalMode; + /** Trusted Hosted Harness load only; forwarded through private ACP metadata. */ + managedSessionStore?: BridgeManagedSessionStore; /** * Persisted parent lineage recovered from the transcript by the caller (the * serve layer reads it before restore). Re-seeds the restored live entry so a @@ -349,6 +483,7 @@ export const LOAD_REPLAY_MAX_BYTES = 32 * 1024 * 1024; export const LOAD_REPLAY_MAX_UPDATES = 10_000; export const REQUESTED_SESSION_ID_META_KEY = 'qwen-code/sessionId'; +export const MANAGED_SESSION_STORE_META_KEY = 'qwen.managedSessionStore.v1'; export const SESSION_INITIALIZATION_DEADLINE_META_KEY = 'qwen.daemon.sessionInitializationDeadlineMs'; export const SESSION_INITIALIZATION_TIMEOUT_ERROR_KIND = @@ -448,7 +583,8 @@ export type ActiveWorkHoldCategory = | 'notification' | 'shell' | 'session' - | 'workflow'; + | 'workflow' + | 'tool'; /** Categories understood by active-work v1 before category negotiation was * added to the daemon's initialize request. */ @@ -461,6 +597,7 @@ export const ACTIVE_WORK_HOLD_CATEGORIES: readonly ActiveWorkHoldCategory[] = [ 'shell', 'session', 'workflow', + 'tool', ]; export interface ActiveWorkHeartbeatCapabilityV1 { @@ -970,6 +1107,12 @@ export interface BridgeSessionSummary { prs?: SessionPrInfo[]; } +/** Original event-bus cursor captured when a prompt id was first admitted. */ +export interface BridgePromptAdmissionWatermark { + lastEventId: number; + eventEpoch: string; +} + /** * In-memory equality token for daemon-observed session-catalog changes. * `generation` is unique to a bridge instance; `revision` increases @@ -1114,6 +1257,15 @@ export interface BridgeClientRequestContext { * smuggle a continuation through the prompt path. */ continue?: boolean; + /** + * Internal recovery identity accepted only by the Hosted Harness private + * continuation route. The ACP child verifies it against the current durable + * `results_ready` checkpoint before the bridge admits a model continuation. + */ + managedRuntimeContinuation?: { + checkpointId: string; + activationId: string; + }; /** * Internal: set ONLY after load/resume when the child hinted that a trailing * ask_user_question should be re-hung. HTTP routes never populate this from @@ -1133,6 +1285,10 @@ export interface BridgeClientRequestContext { export const DAEMON_MODEL_PROMPT_META_KEY = 'qwen.daemon.modelPrompt'; export const DAEMON_RESTORE_ASK_USER_QUESTION_META_KEY = 'qwen.daemon.restoreAskUserQuestion'; +export const DAEMON_RESTORE_MANAGED_APPROVAL_META_KEY = + 'qwen.daemon.restoreManagedApproval'; +export const DAEMON_MANAGED_RUNTIME_RECOVERY_META_KEY = + 'qwen.daemon.managedRuntimeRecovery'; /** * Response `_meta` key on `session/request_permission` cancellations telling * the child WHY the bridge resolved a cancel (`timeout` / `agent_cancelled` @@ -1154,6 +1310,8 @@ export const DAEMON_SUPPRESS_RESTORE_ASK_USER_QUESTION_META_KEY = 'qwen.daemon.suppressRestoreAskUserQuestion'; export const DAEMON_SUPPRESS_WORKTREE_CONTEXT_RESTORE_META_KEY = 'qwen.daemon.suppressWorktreeContextRestore'; +export const DAEMON_PASSIVE_MANAGED_RUNTIME_RECOVERY_META_KEY = + 'qwen.daemon.passiveManagedRuntimeRecovery'; export const DAEMON_ATTACHMENT_REFERENCES_META_KEY = 'qwen.daemon.attachmentReferences'; export const MAX_TRUSTED_MODEL_PROMPT_CHARS = 64 * 1024; @@ -1771,10 +1929,12 @@ export interface AcpSessionBridge extends WorkspaceEventBridge { * session FIFO-serialize through a per-session queue. * * Admission contract: implementations must not be `async`. Admission - * failures such as `InvalidClientIdError`, `PromptQueueFullError`, and - * pre-aborted signals throw synchronously so HTTP routes can reject before - * returning 202. Deferred failures such as `SessionNotFoundError` may be - * returned as rejected promises. + * failures such as `InvalidClientIdError`, `PromptQueueFullError`, + * `PromptIdConflictError`, and pre-aborted signals throw synchronously so + * HTTP routes can reject before returning 202. A retry with the same + * `promptId` and payload returns the original promise and must not abort + * the admitted turn. Deferred failures such as `SessionNotFoundError` may + * be returned as rejected promises. */ sendPrompt( sessionId: string, @@ -1783,6 +1943,33 @@ export interface AcpSessionBridge extends WorkspaceEventBridge { context?: BridgeClientRequestContext, ): Promise; + /** Private owned-worker transport; the caller must hold the Runtime client id. */ + getManagedToolV2Client?( + sessionId: string, + context: BridgeClientRequestContext, + ): ManagedToolV2Client; + + /** Read the safe Tool-only capability set pinned by a Managed Runtime. */ + getManagedRuntimeToolManifest?( + sessionId: string, + context?: BridgeClientRequestContext, + ): Promise; + + /** Execute one safe Tool Call in the Runtime without invoking its model. */ + executeManagedRuntimeTool?( + sessionId: string, + request: BridgeManagedRuntimeToolExecuteRequest, + signal: AbortSignal, + context?: BridgeClientRequestContext, + ): Promise; + + /** Cancel one matching Tool-only Runtime execution best-effort. */ + cancelManagedRuntimeTool?( + sessionId: string, + executionId: string, + context?: BridgeClientRequestContext, + ): Promise<{ readonly cancelled: boolean }>; + /** * Return the pending prompt queue for a session. Includes the currently * running prompt (state `'running'`) and any prompts waiting in the FIFO @@ -1849,6 +2036,16 @@ export interface AcpSessionBridge extends WorkspaceEventBridge { */ getSessionEventEpoch(sessionId: string): string; + /** + * Return the original event cursor for an admitted prompt. Retries must use + * this cursor instead of the bus tail observed at retry time, otherwise + * events emitted after the first admission can be skipped. + */ + getPromptAdmissionWatermark?( + sessionId: string, + promptId: string, + ): BridgePromptAdmissionWatermark | undefined; + /** * Return the daemon's current effective cwd for a live session without * exposing it through public session summaries. @@ -1886,6 +2083,13 @@ export interface AcpSessionBridge extends WorkspaceEventBridge { context?: BridgeClientRequestContext, ): SessionMetadataUpdate; + /** Persist a Hosted Managed title before acknowledging the control plane. */ + commitSessionTitle?( + sessionId: string, + title: string, + context?: BridgeClientRequestContext, + ): Promise; + /** * Re-hydrate the in-memory PR binding list of a live session from the * persisted sidecar after the entry was re-created empty (daemon diff --git a/packages/acp-bridge/src/index.ts b/packages/acp-bridge/src/index.ts index b33b5475e39..0391dfb5b1c 100644 --- a/packages/acp-bridge/src/index.ts +++ b/packages/acp-bridge/src/index.ts @@ -16,6 +16,7 @@ export * from './sessionArtifacts.js'; export * from './sessionAttachments.js'; export * from './bridgeTypes.js'; export * from './session-source.js'; +export * from './session-startup-config.js'; export * from './bridgeOptions.js'; export * from './prompt-ledger.js'; export * from './session-restore-timeout.js'; diff --git a/packages/acp-bridge/src/ndJsonStream.ts b/packages/acp-bridge/src/ndJsonStream.ts index 7543459fb0c..3d166cd8a71 100644 --- a/packages/acp-bridge/src/ndJsonStream.ts +++ b/packages/acp-bridge/src/ndJsonStream.ts @@ -658,7 +658,7 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } -function hasBoundedJsonStructure(value: unknown): boolean { +export function hasBoundedJsonStructure(value: unknown): boolean { const stack: Array<{ value: unknown; depth: number }> = [{ value, depth: 1 }]; let nodes = 0; while (stack.length > 0) { diff --git a/packages/acp-bridge/src/process-registry.test.ts b/packages/acp-bridge/src/process-registry.test.ts index c9c9f493a15..c64d85e5e34 100644 --- a/packages/acp-bridge/src/process-registry.test.ts +++ b/packages/acp-bridge/src/process-registry.test.ts @@ -19,7 +19,7 @@ vi.mock('node:child_process', async (importOriginal) => { }; }); -import { ProcessRegistry } from './process-registry.js'; +import { ProcessRegistry, ProcessExitError } from './process-registry.js'; const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')!; const PS = '/bin/ps'; @@ -256,6 +256,7 @@ describe('ProcessRegistry', () => { expect(killSpy).toHaveBeenCalledWith(-1234, 'SIGTERM'); await vi.advanceTimersByTimeAsync(5_000); + await expect(terminating).resolves.toBeInstanceOf(ProcessExitError); await expect(terminating).resolves.toMatchObject({ message: expect.stringContaining('exited uncleanly during shutdown'), }); diff --git a/packages/acp-bridge/src/process-registry.ts b/packages/acp-bridge/src/process-registry.ts index 3e6714e81e9..7917acbdc72 100644 --- a/packages/acp-bridge/src/process-registry.ts +++ b/packages/acp-bridge/src/process-registry.ts @@ -29,6 +29,9 @@ const STRING_EXEC_OPTIONS: ExecFileOptionsWithStringEncoding = { windowsHide: true, }; +/** A proven exit with a nonzero code or signal, not a failure to prove cleanup. */ +export class ProcessExitError extends Error {} + export interface ProcessAttachmentOptions { /** * The caller owns the complete process tree rooted at this child. POSIX @@ -650,7 +653,7 @@ class TrackedChild implements TrackedChildProcess { private throwForUncleanExit(exitInfo: AcpChannelExitInfo | undefined): void { if (exitInfo && (exitInfo.exitCode !== 0 || exitInfo.signalCode !== null)) { - throw new Error( + throw new ProcessExitError( `ACP child pid=${this.child.pid ?? 'unknown'} exited uncleanly during shutdown ` + `(code=${exitInfo.exitCode ?? 'none'}, signal=${exitInfo.signalCode ?? 'none'})`, ); diff --git a/packages/acp-bridge/src/session-control-plane.ts b/packages/acp-bridge/src/session-control-plane.ts index 509dbe9aa63..f0a33655dcf 100644 --- a/packages/acp-bridge/src/session-control-plane.ts +++ b/packages/acp-bridge/src/session-control-plane.ts @@ -4,6 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { + applySessionStartupConfig, + parseSessionStartupConfig, + type SessionStartupConfig, +} from './session-startup-config.js'; import { randomUUID } from 'node:crypto'; import * as path from 'node:path'; import { @@ -4553,6 +4558,7 @@ export function createSessionControlPlane( daemonOwnedStandaloneCreation = false, onNewSessionDispatch?: () => void, onNewSessionAbandoned?: (settlement: Promise) => void, + startupConfig?: SessionStartupConfig, ): Promise { // Get-or-create the daemon's single channel, then call // `connection.newSession()` on it. Sessions share the child's @@ -4902,6 +4908,28 @@ export function createSessionControlPlane( ); } + let startupConfigApplied; + if (startupConfig) { + try { + startupConfigApplied = await applySessionStartupConfig( + bridgeApi, + entry.sessionId, + startupConfig, + ); + modelApplied = true; + } catch (error) { + try { + await closeSessionImpl(entry.sessionId, undefined, { + reason: 'startup_config_failed', + }); + sessionRemovedDuringInitialization = true; + } catch { + /* preserve the preparation failure */ + } + throw error; + } + } + if (approvalMode) { try { await applyApprovalMode(entry, approvalMode, false, clientId); @@ -4951,6 +4979,7 @@ export function createSessionControlPlane( ? { parentSessionPersisted: parentSessionPersisted === true } : {}), ...(modelApplied !== undefined ? { modelApplied } : {}), + ...(startupConfigApplied ? { startupConfigApplied } : {}), ...(entry.worktree ? { worktree: entry.worktree } : {}), ...(entry.branch ? { branch: entry.branch } : {}), }; @@ -9298,8 +9327,9 @@ export function createSessionControlPlane( ) { throw new InvalidSessionScopeError(req.sessionScope); } + const startupConfig = parseSessionStartupConfig(req.startupConfig, req); const effectiveScope = - req.sessionId !== undefined + req.sessionId !== undefined || startupConfig !== undefined ? 'thread' : (req.sessionScope ?? defaultSessionScope); const source = parseSessionSource(req.sourceType, req.sourceId); @@ -9632,6 +9662,7 @@ export function createSessionControlPlane( ); } }, + startupConfig, ); // Track in-flight spawns regardless of scope. Under `single` // this also serves the coalescing path above (a parallel diff --git a/packages/acp-bridge/src/session-startup-config.test.ts b/packages/acp-bridge/src/session-startup-config.test.ts new file mode 100644 index 00000000000..cc8ffaf1791 --- /dev/null +++ b/packages/acp-bridge/src/session-startup-config.test.ts @@ -0,0 +1,402 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import { + RequestError, + type SessionConfigOption, +} from '@agentclientprotocol/sdk'; +import { REASONING_EFFORT_TIERS } from '@qwen-code/qwen-code-core/core/reasoning-effort.js'; +import { makeBridge, makeChannel, WS_A } from './internal/testUtils.js'; +import { + applySessionStartupConfig, + parseSessionStartupConfig, +} from './session-startup-config.js'; + +const startupConfig = { + modelServiceId: 'gpt-5.4(openai)', + reasoningEffort: 'high' as const, +}; + +function options( + effort = 'high', + model = startupConfig.modelServiceId, +): SessionConfigOption[] { + return [ + { + id: 'model', + name: 'Model', + type: 'select', + currentValue: model, + options: [{ value: model, name: model }], + }, + { + id: 'reasoning_effort', + name: 'Reasoning', + type: 'select', + currentValue: effort, + options: [{ value: effort, name: effort }], + }, + ]; +} + +describe('session startup configuration', () => { + it.each([ + null, + [], + {}, + { reasoningEffort: 'high' }, + { modelServiceId: 'x'.repeat(257) }, + { modelServiceId: 'x', reasoningEffort: null }, + { ...startupConfig, reasoningEffort: 'invalid' }, + { ...startupConfig, extra: true }, + ])('rejects malformed configuration %j', (value) => { + expect(() => parseSessionStartupConfig(value)).toThrow(); + }); + + it('rejects a whitespace-only modelServiceId and trims surrounding whitespace', () => { + expect(() => + parseSessionStartupConfig({ modelServiceId: ' ' }), + ).toThrowError(expect.objectContaining({ code: 'invalid_startup_config' })); + expect( + parseSessionStartupConfig({ modelServiceId: ' gpt-5.4(openai) ' }), + ).toEqual({ modelServiceId: 'gpt-5.4(openai)' }); + }); + + it('rejects a modelServiceId containing control characters', () => { + expect(() => + parseSessionStartupConfig({ modelServiceId: 'qwen\nmax' }), + ).toThrowError(expect.objectContaining({ code: 'invalid_startup_config' })); + }); + + it('preserves omission and rejects mixed selectors and attach scope', () => { + expect( + parseSessionStartupConfig(undefined, { modelServiceId: 'legacy' }), + ).toBeUndefined(); + expect(() => + parseSessionStartupConfig(startupConfig, { + modelServiceId: startupConfig.modelServiceId, + }), + ).toThrow(); + expect(() => + parseSessionStartupConfig(startupConfig, { sessionScope: 'single' }), + ).toThrow(); + }); + + it.each([...REASONING_EFFORT_TIERS, 'default', 'none'] as const)( + 'accepts the reasoning selection %s', + (reasoningEffort) => { + expect( + parseSessionStartupConfig({ modelServiceId: 'm', reasoningEffort }), + ).toEqual({ modelServiceId: 'm', reasoningEffort }); + }, + ); + + it('uses the canonical confirmation for model-only selection instead of echoing input', async () => { + const setSessionConfigOption = vi + .fn() + .mockResolvedValue({ configOptions: options() }); + expect( + await applySessionStartupConfig({ setSessionConfigOption }, 'session', { + modelServiceId: 'gpt-5.4', + }), + ).toEqual({ modelServiceId: startupConfig.modelServiceId }); + }); + + it.each( + [ + [], + options().filter((option) => option.id !== 'model'), + options('high', ''), + ].map((configOptions) => ({ configOptions })), + )( + 'rejects a missing model confirmation for model-only startup', + async ({ configOptions }) => { + const setSessionConfigOption = vi + .fn() + .mockResolvedValue({ configOptions }); + await expect( + applySessionStartupConfig({ setSessionConfigOption }, 'session', { + modelServiceId: 'gpt-5.4', + }), + ).rejects.toMatchObject({ code: 'startup_config_rejected' }); + expect(setSessionConfigOption).toHaveBeenCalledTimes(1); + }, + ); + + it('reports toggle-only reasoning as enabled without inventing an effort', async () => { + const configOptions = options('default'); + configOptions[1]!._meta = { 'qwenCode/reasoning': { toggleOnly: true } }; + const setSessionConfigOption = vi.fn().mockResolvedValue({ configOptions }); + const result = await applySessionStartupConfig( + { setSessionConfigOption }, + 'session', + { ...startupConfig, reasoningEffort: 'default' }, + ); + expect(result.effectiveReasoning).toEqual({ state: 'enabled' }); + }); + + it('maps only deterministic parameter rejection to startup_config_rejected', async () => { + const invalid = RequestError.invalidParams(undefined, 'unsupported effort'); + const setSessionConfigOption = vi.fn().mockRejectedValue(invalid); + await expect( + applySessionStartupConfig( + { setSessionConfigOption }, + 'session', + startupConfig, + ), + ).rejects.toMatchObject({ + code: 'startup_config_rejected', + message: invalid.message, + }); + for (const error of [ + RequestError.authRequired(), + new Error('transport closed'), + ]) { + setSessionConfigOption.mockRejectedValueOnce(error); + await expect( + applySessionStartupConfig( + { setSessionConfigOption }, + 'session', + startupConfig, + ), + ).rejects.toBe(error); + } + }); + + it('leaves the internal-error wire shape unmapped even with a details string', async () => { + // -32603 is what every child-side internal fault produces — auth, + // credential, timeout and transport failures included — so even a + // refusal-shaped `data.details` is an uncertain outcome, not a definite + // rejection. The child maps its caller-caused refusals to invalidParams + // before they reach the wire. + const setSessionConfigOption = vi.fn(); + for (const shape of [ + { + code: -32603, + message: 'Internal error', + data: { details: "Model 'qwen-typo' not found for authType 'openai'" }, + }, + { + code: -32603, + message: 'Internal error', + data: { details: 'Missing API key for openai auth' }, + }, + { code: -32603, message: 'Internal error' }, + { code: -32603, message: 'Internal error', data: { details: 42 } }, + { code: -32603, message: 'Internal error', data: null }, + ]) { + setSessionConfigOption.mockRejectedValueOnce(shape); + await expect( + applySessionStartupConfig( + { setSessionConfigOption }, + 'session', + startupConfig, + ), + ).rejects.toBe(shape); + } + }); + + it('only selects the model when reasoning is omitted and no reasoning option exists', async () => { + const modelServiceId = 'gpt-4.1(openai)'; + const setSessionConfigOption = vi.fn().mockResolvedValue({ + configOptions: options('high', modelServiceId).filter( + (option) => option.id === 'model', + ), + }); + const config = parseSessionStartupConfig({ modelServiceId })!; + expect(config).toEqual({ modelServiceId }); + expect( + await applySessionStartupConfig( + { setSessionConfigOption }, + 'session', + config, + ), + ).toEqual({ modelServiceId }); + expect(setSessionConfigOption).toHaveBeenCalledExactlyOnceWith('session', { + sessionId: 'session', + configId: 'model', + value: modelServiceId, + }); + }); + + it.each(['high', 'none', 'default'])( + 'derives default acknowledgment from current reasoning %s', + async (current) => { + const setSessionConfigOption = vi + .fn() + .mockResolvedValue({ configOptions: options(current) }); + const result = await applySessionStartupConfig( + { setSessionConfigOption }, + 'session', + { ...startupConfig, reasoningEffort: 'default' }, + ); + expect(result.reasoningEffort).toBe('default'); + expect(result.effectiveReasoning).toEqual( + current === 'high' + ? { state: 'enabled', effort: 'high' } + : current === 'none' + ? { state: 'disabled' } + : { state: 'provider-default' }, + ); + }, + ); + + it('applies explicit none and confirms disabled reasoning', async () => { + const setSessionConfigOption = vi + .fn() + .mockResolvedValue({ configOptions: options('none') }); + const result = await applySessionStartupConfig( + { setSessionConfigOption }, + 'session', + { ...startupConfig, reasoningEffort: 'none' }, + ); + expect(result).toEqual({ + ...startupConfig, + reasoningEffort: 'none', + effectiveReasoning: { state: 'disabled' }, + }); + expect(setSessionConfigOption.mock.calls[1]?.[1]).toMatchObject({ + configId: 'reasoning_effort', + value: 'none', + }); + }); + + it('fails when reasoning is not applied or model changes between responses', async () => { + for (const configOptions of [ + options('low'), + options('high', 'another(openai)'), + [], + ]) { + const setSessionConfigOption = vi + .fn() + .mockResolvedValueOnce({ configOptions: options() }) + .mockResolvedValueOnce({ configOptions }); + await expect( + applySessionStartupConfig( + { setSessionConfigOption }, + 'session', + startupConfig, + ), + ).rejects.toMatchObject({ code: 'startup_config_rejected' }); + } + }); + + it('forces a fresh thread, applies model then effort without persistence or workspace events', async () => { + const handle = makeChannel(); + const newSession = vi + .spyOn(handle.agent, 'newSession') + .mockResolvedValueOnce({ sessionId: 'ordinary' }) + .mockResolvedValueOnce({ sessionId: 'configured' }); + const setter = vi + .spyOn(handle.agent, 'setSessionConfigOption') + .mockImplementation(async (request) => { + if (request.configId === 'model') { + await handle.agentConnection.extNotification( + 'qwen/notify/session/model-update', + { + v: 1, + sessionId: request.sessionId, + currentModelId: startupConfig.modelServiceId, + }, + ); + } + return { configOptions: options() }; + }); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + sessionScope: 'single', + }); + try { + await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const session = await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + startupConfig, + }); + expect(newSession).toHaveBeenCalledTimes(2); + expect(session).toMatchObject({ + sessionId: 'configured', + attached: false, + modelApplied: true, + startupConfigApplied: { + ...startupConfig, + effectiveReasoning: { state: 'enabled', effort: 'high' }, + }, + }); + expect(setter.mock.calls.map(([request]) => request)).toEqual([ + { + sessionId: 'configured', + configId: 'model', + value: startupConfig.modelServiceId, + }, + { + sessionId: 'configured', + configId: 'reasoning_effort', + value: 'high', + }, + ]); + expect( + bridge + .getDaemonStatusSnapshot() + .sessions.find((entry) => entry.sessionId === 'configured') + ?.currentModelId, + ).toBe(startupConfig.modelServiceId); + const abort = new AbortController(); + const events = bridge.subscribeEvents('ordinary', { + signal: abort.signal, + lastEventId: 0, + }); + const replay: string[] = []; + const reading = (async () => { + for await (const event of events) replay.push(event.type); + })(); + await new Promise((resolve) => setTimeout(resolve, 0)); + abort.abort(); + await reading; + expect(replay).toContain('replay_complete'); + expect(replay).not.toContain('settings_changed'); + expect(bridge.getSessionSummary('ordinary')).toBeDefined(); + } finally { + await bridge.shutdown(); + } + }); + + it('removes only the failed new session and leaves its sibling usable', async () => { + const handle = makeChannel(); + vi.spyOn(handle.agent, 'newSession') + .mockResolvedValueOnce({ sessionId: 'sibling' }) + .mockResolvedValueOnce({ sessionId: 'failed' }); + vi.spyOn(handle.agent, 'setSessionConfigOption') + .mockResolvedValueOnce({ configOptions: options() }) + .mockRejectedValueOnce( + RequestError.invalidParams(undefined, 'unsupported effort'), + ); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + try { + await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + await expect( + bridge.spawnOrAttach({ workspaceCwd: WS_A, startupConfig }), + ).rejects.toMatchObject({ + code: 'startup_config_rejected', + message: expect.stringContaining('unsupported effort'), + // The rejection names the session it was applied to, so rollback + // sites can name the orphaned recording even when the caller never + // supplied an id. + sessionId: 'failed', + }); + expect(() => bridge.getSessionSummary('failed')).toThrow(); + expect(bridge.getSessionSummary('sibling')).toBeDefined(); + await expect( + bridge.sendPrompt('sibling', { + sessionId: 'sibling', + prompt: [{ type: 'text', text: 'still works' }], + }), + ).resolves.toBeDefined(); + } finally { + await bridge.shutdown(); + } + }); +}); diff --git a/packages/acp-bridge/src/session-startup-config.ts b/packages/acp-bridge/src/session-startup-config.ts new file mode 100644 index 00000000000..bfce83f751b --- /dev/null +++ b/packages/acp-bridge/src/session-startup-config.ts @@ -0,0 +1,200 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + REASONING_EFFORT_TIERS, + type ReasoningEffort, +} from '@qwen-code/qwen-code-core/core/reasoning-effort.js'; +import { MAX_CRON_TASK_ROUTING_ID_LENGTH } from '@qwen-code/qwen-code-core/services/cronTasksFile.js'; +import type { AcpSessionBridge } from './bridgeTypes.js'; + +export interface SessionStartupConfig { + modelServiceId: string; + reasoningEffort?: ReasoningEffort | 'default' | 'none'; +} + +export interface SessionStartupConfigApplied extends SessionStartupConfig { + effectiveReasoning?: + | { state: 'enabled'; effort?: ReasoningEffort } + | { state: 'disabled' } + | { state: 'provider-default' }; +} + +export class SessionStartupConfigError extends Error { + override readonly name = 'SessionStartupConfigError'; + + constructor( + readonly code: 'invalid_startup_config' | 'startup_config_rejected', + message: string, + /** + * The session the rejected selection was applied to. Present on + * `startup_config_rejected` so a caller that never supplied an id (or + * whose id the child did not honor) can still name the orphaned + * recording for rollback; absent on `invalid_startup_config`, which is + * raised before any session exists. + */ + readonly sessionId?: string, + ) { + super(message); + } +} + +export function isSessionStartupConfigError( + error: unknown, +): error is Pick { + // Split bundles can load distinct constructors for the same error contract. + return ( + error !== null && + typeof error === 'object' && + 'code' in error && + (error.code === 'invalid_startup_config' || + error.code === 'startup_config_rejected') && + 'message' in error && + typeof error.message === 'string' + ); +} + +function isReasoningSelection( + value: unknown, +): value is NonNullable { + return ( + typeof value === 'string' && + (value === 'default' || + value === 'none' || + REASONING_EFFORT_TIERS.some((tier) => tier === value)) + ); +} + +export function parseSessionStartupConfig( + value: unknown, + request: { modelServiceId?: unknown; sessionScope?: unknown } = {}, +): SessionStartupConfig | undefined { + if (value === undefined) return undefined; + const config = value as Partial | null; + if ( + !config || + typeof config !== 'object' || + Array.isArray(config) || + Object.keys(config).some( + (key) => key !== 'modelServiceId' && key !== 'reasoningEffort', + ) || + typeof config.modelServiceId !== 'string' || + !config.modelServiceId.trim() || + config.modelServiceId.length > MAX_CRON_TASK_ROUTING_ID_LENGTH || + // Control characters survive the trim and length checks and would + // ride into core's refusal messages; refuse them at the boundary. + /\p{Cc}/u.test(config.modelServiceId) || + (config.reasoningEffort !== undefined && + !isReasoningSelection(config.reasoningEffort)) || + request.modelServiceId !== undefined || + request.sessionScope === 'single' + ) { + throw new SessionStartupConfigError( + 'invalid_startup_config', + 'startupConfig requires modelServiceId (1-256 characters) and an optional valid reasoningEffort, without unknown fields, a legacy modelServiceId or single session scope.', + ); + } + return { + modelServiceId: config.modelServiceId.trim(), + ...(config.reasoningEffort !== undefined + ? { reasoningEffort: config.reasoningEffort } + : {}), + }; +} + +function rejectInvalidSelection(error: unknown, sessionId: string): never { + if ( + error === null || + typeof error !== 'object' || + !('code' in error) || + !('message' in error) || + typeof error.message !== 'string' + ) { + throw error; + } + // Only a deterministic parameter rejection is a definite, client-caused + // refusal: the child maps its own caller-caused setter refusals (an + // unknown or media-only primary model, an unsupported effort) to + // invalidParams. Internal errors (-32603) also carry auth, credential, + // timeout and transport failures — uncertain outcomes that stay unmapped. + if (error.code === -32602) { + throw new SessionStartupConfigError( + 'startup_config_rejected', + error.message, + sessionId, + ); + } + throw error; +} + +export async function applySessionStartupConfig( + bridge: Pick, + sessionId: string, + config: SessionStartupConfig, +): Promise { + const model = await bridge + .setSessionConfigOption(sessionId, { + sessionId, + configId: 'model', + value: config.modelServiceId, + }) + .catch((error: unknown) => rejectInvalidSelection(error, sessionId)); + const modelServiceId = model.configOptions?.find( + (option) => option.id === 'model', + )?.currentValue; + if (!modelServiceId) { + throw new SessionStartupConfigError( + 'startup_config_rejected', + 'The session did not confirm its model selection.', + sessionId, + ); + } + if (config.reasoningEffort === undefined) return { modelServiceId }; + const result = await bridge + .setSessionConfigOption(sessionId, { + sessionId, + configId: 'reasoning_effort', + value: config.reasoningEffort, + }) + .catch((error: unknown) => rejectInvalidSelection(error, sessionId)); + const reasoning = result.configOptions?.find( + (option) => option.id === 'reasoning_effort', + ); + const currentModel = result.configOptions?.find( + (option) => option.id === 'model', + )?.currentValue; + const selection = reasoning?.currentValue; + if ( + currentModel !== modelServiceId || + !isReasoningSelection(selection) || + (config.reasoningEffort !== 'default' && + selection !== config.reasoningEffort) + ) { + throw new SessionStartupConfigError( + 'startup_config_rejected', + 'The session did not confirm its model and reasoning selection.', + sessionId, + ); + } + const meta = reasoning?._meta?.['qwenCode/reasoning']; + const toggleOnly = + meta !== null && + typeof meta === 'object' && + 'toggleOnly' in meta && + meta.toggleOnly === true; + return { + modelServiceId, + reasoningEffort: config.reasoningEffort, + effectiveReasoning: + selection === 'none' + ? { state: 'disabled' } + : selection === 'default' + ? toggleOnly + ? { state: 'enabled' } + : { state: 'provider-default' } + : { state: 'enabled', effort: selection }, + }; +} diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 851dd9ebc9c..3f392620956 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -29,6 +29,7 @@ vi.mock('node:fs/promises', { spy: true }); const realFsPromises = await vi.importActual('node:fs/promises'); import { NOT_CURRENTLY_GENERATING_CANCEL_MESSAGE } from '@qwen-code/acp-bridge/bridgeErrors'; +import { SessionExecutionEngineError } from '@qwen-code/qwen-code-core/services/session-execution-engine.js'; import { ACP_EVENT_LOOP_STALL_RESTART_MS } from '@qwen-code/channel-base'; import { getDefaultReasoningConfig } from './model-configuration.js'; import { getConversationDirectoryName } from '../utils/conversation-directory-identity.js'; @@ -27665,6 +27666,42 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => { await agentPromise; }); + it.each(['load', 'resume'] as const)( + '%s returns a typed error for a Managed session', + async (action) => { + bindRestoreMocks({ sessionExists: true }); + vi.mocked(loadCliConfig).mockRejectedValueOnce( + new SessionExecutionEngineError( + 'persisted-1', + 'belongs to managed, cannot execute with legacy', + ), + ); + const { agent, agentPromise } = await spawnAgent(); + + try { + const params = { + cwd: '/tmp', + sessionId: 'persisted-1', + mcpServers: [], + }; + await expect( + action === 'load' + ? agent.loadSession(params) + : agent.unstable_resumeSession(params), + ).rejects.toMatchObject({ + code: -32024, + data: { + errorKind: 'session_execution_engine_unavailable', + sessionId: 'persisted-1', + }, + }); + } finally { + mockConnectionState.resolve(); + await agentPromise; + } + }, + ); + it.each(['load', 'resume'] as const)( '%s binds sessionIdContext while loading the Config', async (action) => { diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 1cc42d8b462..8c7b5ebc7b3 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -228,6 +228,7 @@ import { import { observeAcpToolResultWire } from '../nonInteractive/tool-result-boundary-diagnostics.js'; import { Readable } from 'node:stream'; import { normalizeDisabledToolList } from '../config/normalizeDisabledTools.js'; +import { SessionExecutionEngineError } from '@qwen-code/qwen-code-core/services/session-execution-engine.js'; import type { Stats } from 'node:fs'; import { realpathSync } from 'node:fs'; import * as fs from 'node:fs/promises'; @@ -1085,6 +1086,12 @@ function mapSessionRestoreRequestError( ): unknown { const mappedWriterError = mapSessionWriterRequestError(error); if (mappedWriterError !== error) return mappedWriterError; + if (error instanceof SessionExecutionEngineError) { + return new RequestError(-32024, error.message, { + errorKind: error.errorKind, + sessionId, + }); + } if (error instanceof SessionTranscriptSnapshotUnavailableError) { return new RequestError(-32010, error.message, { errorKind: 'transcript_snapshot_unavailable', diff --git a/packages/cli/src/acp-integration/managed-tool-media.test.ts b/packages/cli/src/acp-integration/managed-tool-media.test.ts new file mode 100644 index 00000000000..51d4934929b --- /dev/null +++ b/packages/cli/src/acp-integration/managed-tool-media.test.ts @@ -0,0 +1,179 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { + managedToolResponseMediaBytes, + MAX_MANAGED_MEDIA_RESPONSE_BYTES, +} from './managed-tool-media.js'; + +function execution(data = 'AQ==', mimeType = 'image/png') { + return { + executionStatus: 'success', + result: { + llmContent: [{ inlineData: { data, mimeType } }], + returnDisplay: 'image', + }, + }; +} + +describe('managed tool media wire validation', () => { + it.each(['execute', 'status', 'cancel'] as const)( + 'accounts only direct inline media in %s results', + (operation) => { + const result = execution(); + const wrapped = + operation === 'execute' + ? result + : { state: 'settled', result, progress: [] }; + expect(managedToolResponseMediaBytes(wrapped, operation)).toBe(4); + expect( + managedToolResponseMediaBytes( + { ...wrapped, postHook: { inlineData: { data: 'AQ==' } } }, + operation, + ), + ).toBe(4); + }, + ); + + it('does not exempt progress, display, nested contents, or unsettled results', () => { + expect( + managedToolResponseMediaBytes( + { state: 'executing', result: execution() }, + 'status', + ), + ).toBe(0); + const result = execution(); + expect( + managedToolResponseMediaBytes( + { + executionStatus: 'success', + result: { + llmContent: { functionResponse: result }, + returnDisplay: result, + }, + progress: [result], + }, + 'execute', + ), + ).toBe(0); + }); + + it.each([ + 'AR==', + 'AQ=', + 'AQ===', + 'A===', + 'AQ==\n', + 'AQ==AQ==', + '-_==', + 'ABCD=', + 'A', + ])('rejects invalid or noncanonical base64 %j', (data) => { + expect(() => + managedToolResponseMediaBytes(execution(data), 'execute'), + ).toThrow('invalid inline media'); + }); + + it.each(['execute', 'status', 'cancel'] as const)( + 'preserves native empty media in %s results with zero media bytes', + (operation) => { + for (const mime of ['audio/wav', 'video/mp4', 'application/pdf']) { + const result = execution('', mime); + const wrapped = + operation === 'execute' + ? result + : { state: 'settled', result, progress: [] }; + expect(managedToolResponseMediaBytes(wrapped, operation)).toBe(0); + } + }, + ); + + it.each([ + 'image/jpeg', + 'image/png', + 'image/webp', + 'audio/mpeg', + 'video/mp4', + 'application/pdf', + ])('permits native media MIME %s', (mime) => { + expect( + managedToolResponseMediaBytes(execution('AQID', mime), 'execute'), + ).toBe(4); + }); + + it('rejects malformed inline parts and non-media MIME', () => { + for (const inlineData of [ + null, + { data: 1, mimeType: 'image/png' }, + { data: 'AQ==' }, + { data: 'AQ==', mimeType: 'text/plain' }, + ]) { + expect(() => + managedToolResponseMediaBytes( + { + executionStatus: 'success', + result: { llmContent: { inlineData } }, + }, + 'execute', + ), + ).toThrow('invalid inline media'); + } + }); + + it('preserves ordinary JSON omission and local ACP text capacity', () => { + expect( + managedToolResponseMediaBytes( + { + executionStatus: 'success', + result: { llmContent: 'x'.repeat(9 * 1024 * 1024), error: undefined }, + postHook: undefined, + }, + 'execute', + ), + ).toBe(0); + }); + + it('bounds combined wire bytes including a first PDF page larger than the normal page budget', () => { + const result = execution('AAAA'.repeat(7 * 1024 * 1024)); + expect(managedToolResponseMediaBytes(result, 'execute')).toBe( + 28 * 1024 * 1024, + ); + const overhead = Buffer.byteLength( + JSON.stringify({ + ...result, + result: { ...result.result, llmContent: '' }, + }), + ); + const boundary = { + ...result, + result: { + ...result.result, + llmContent: 'x'.repeat(MAX_MANAGED_MEDIA_RESPONSE_BYTES - overhead), + }, + }; + expect(managedToolResponseMediaBytes(boundary, 'execute')).toBe(0); + boundary.result.llmContent += 'x'; + expect(() => managedToolResponseMediaBytes(boundary, 'execute')).toThrow( + 'size limit', + ); + }); + + it('refuses unsafe ACP structure before a large result can poison the pipe', () => { + const result = execution(); + expect(() => + managedToolResponseMediaBytes( + { ...result, progress: Array(4097).fill(null) }, + 'execute', + ), + ).toThrow('structure limits'); + let nested: unknown = 'leaf'; + for (let index = 0; index < 64; index++) nested = { nested }; + expect(() => + managedToolResponseMediaBytes({ ...result, nested }, 'execute'), + ).toThrow('structure limits'); + }); +}); diff --git a/packages/cli/src/acp-integration/managed-tool-media.ts b/packages/cli/src/acp-integration/managed-tool-media.ts new file mode 100644 index 00000000000..f80217490b1 --- /dev/null +++ b/packages/cli/src/acp-integration/managed-tool-media.ts @@ -0,0 +1,91 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { hasBoundedJsonStructure } from '@qwen-code/acp-bridge/ndJsonStream'; +import { DAEMON_ACP_NDJSON_LIMITS } from '@qwen-code/acp-bridge/spawnChannel'; + +// Leave room for the JSON-RPC/HTTP envelopes without changing ACP capacity. +export const MAX_MANAGED_MEDIA_RESPONSE_BYTES = + DAEMON_ACP_NDJSON_LIMITS.maxFrameBytes - 64 * 1024; + +export function isManagedMediaOperation( + operation: string, +): operation is 'execute' | 'status' | 'cancel' { + return ( + operation === 'execute' || operation === 'status' || operation === 'cancel' + ); +} + +function record(value: unknown): Record | undefined { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function isBase64(data: string): boolean { + if (data.length % 4 !== 0) return false; + const padding = data.endsWith('==') ? 2 : data.endsWith('=') ? 1 : 0; + const body = data.slice(0, data.length - padding); + if (/[^A-Za-z0-9+/]/.test(body)) return false; + if (padding === 0) return true; + const last = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'.indexOf( + body.at(-1)!, + ); + return padding === 2 ? (last & 15) === 0 : (last & 3) === 0; +} + +/** Validate the actual wire projection before sending it to the bounded ACP pipe. */ +export function managedToolResponseMediaBytes( + result: unknown, + operation: 'execute' | 'status' | 'cancel', +): number { + const json = JSON.stringify(result); + if ( + json === undefined || + Buffer.byteLength(json) > MAX_MANAGED_MEDIA_RESPONSE_BYTES + ) { + throw new Error('Managed Runtime media response exceeded its size limit.'); + } + // Native results may contain undefined optional fields; JSON omits them. + const wire: unknown = JSON.parse(json); + if (!hasBoundedJsonStructure({ jsonrpc: '2.0', id: '0', result: wire })) { + throw new Error( + 'Managed Runtime media response exceeded ACP structure limits.', + ); + } + const status = record(wire); + if (operation !== 'execute' && status?.['state'] !== 'settled') return 0; + const execution = + operation === 'execute' ? status : record(status?.['result']); + if ( + !['not_started', 'success', 'error', 'cancelled'].includes( + String(execution?.['executionStatus']), + ) + ) + return 0; + const content = record(execution?.['result'])?.['llmContent']; + const parts = Array.isArray(content) ? content : [content]; + let mediaBytes = 0; + for (const part of parts) { + const object = record(part); + if (!object || !Object.hasOwn(object, 'inlineData')) continue; + const inline = record(object['inlineData']); + if ( + !inline || + typeof inline['data'] !== 'string' || + typeof inline['mimeType'] !== 'string' || + !/^(?:(?:image|audio|video)\/[a-z0-9.+-]+|application\/pdf)$/i.test( + inline['mimeType'], + ) || + !isBase64(inline['data']) + ) { + throw new Error('Managed Runtime returned invalid inline media.'); + } + mediaBytes += inline['data'].length; + } + return mediaBytes; +} diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index d3cfa1cde59..8b70997cd15 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -38,6 +38,7 @@ import type { import { ApprovalMode, AuthType, + ModelsConfig, GOAL_PAUSE_REASON_SESSION_TOKEN_LIMIT, GOAL_PAUSE_REASON_SESSION_DISPOSED, GOAL_PAUSE_REASON_STOP_HOOK_CAP, @@ -8083,6 +8084,82 @@ describe('Session', () => { ); }); + it.each([ + "Model 'qwen-typo' not found for authType 'openai'", + "Model 'qwen\ntypo' not found for authType 'openai'", + "Image-only model 'qwen-image' cannot be used as the primary model", + "Voice-only model 'qwen-voice' cannot be used as the primary model", + "Realtime-only model 'qwen-realtime' cannot be used as the primary model", + ])( + 'maps the caller-caused switchModel refusal to invalid params: %s', + async (message) => { + switchModelSpy.mockRejectedValueOnce(new Error(message)); + const rejection: unknown = await session + .setModel({ + sessionId: 'test-session-id', + modelId: `qwen-typo(${AuthType.USE_OPENAI})`, + }) + .then( + () => { + throw new Error('expected setModel to reject'); + }, + (error: unknown) => error, + ); + expect(rejection).toBeInstanceOf(RequestError); + expect((rejection as RequestError).code).toBe(-32602); + expect((rejection as Error).message).toBe(`Invalid params: ${message}`); + expect(mockSettings.setValue).not.toHaveBeenCalled(); + }, + ); + + it('maps the refusal core actually throws, not a hand-written copy of its message', async () => { + // The classifier matches core's human-readable switchModel messages; + // driving the real ModelsConfig keeps that string contract honest — a + // reworded core message turns this red instead of silently degrading + // the definite caller rejection into an internal error. + const realModels = new ModelsConfig({ + modelProvidersConfig: { + openai: [ + { id: 'chat-model' }, + { id: 'image-model', imageOnly: true }, + ], + }, + }); + switchModelSpy.mockImplementation((authType: AuthType, modelId: string) => + realModels.switchModel(authType, modelId), + ); + + for (const modelId of ['qwen-typo', 'image-model']) { + const rejection: unknown = await session + .setModel({ + sessionId: 'test-session-id', + modelId: `${modelId}(${AuthType.USE_OPENAI})`, + }) + .then( + () => { + throw new Error('expected setModel to reject'); + }, + (error: unknown) => error, + ); + expect(rejection).toBeInstanceOf(RequestError); + expect((rejection as RequestError).code).toBe(-32602); + } + expect(mockSettings.setValue).not.toHaveBeenCalled(); + }); + + it('keeps daemon-side switchModel faults as internal errors', async () => { + const fault = new Error( + "Missing API key for openai auth. Current model: 'gpt-5.4'.", + ); + switchModelSpy.mockRejectedValueOnce(fault); + await expect( + session.setModel({ + sessionId: 'test-session-id', + modelId: `gpt-5.4(${AuthType.USE_OPENAI})`, + }), + ).rejects.toBe(fault); + }); + it('rejects empty/whitespace model IDs', async () => { await expect( session.setModel({ diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index f3e94319422..bf8f72e0e15 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -2077,6 +2077,23 @@ export async function buildAvailableCommandsSnapshot( }; } +// The two caller-caused `switchModel` refusals: the model is not +// registered for the auth type, or it is media-only and cannot serve as +// the primary model. Every other `switchModel` throw is a daemon-side +// fault (auth refresh, credential, I/O) and must stay an internal error. +// Message-keyed because core throws plain Errors; a reworded message +// degrades to internal error, never to a false refusal. `[\s\S]`, not +// `.`: a model id can carry a newline, which `.` refuses to match — that +// would degrade a definite caller-caused refusal into an internal error. +function isCallerCausedModelRefusal(error: Error): boolean { + return ( + /^Model '[\s\S]+' not found for authType '[\s\S]+'$/.test(error.message) || + /^(?:Image|Voice|Realtime)-only model '[\s\S]+' cannot be used as the primary model$/.test( + error.message, + ) + ); +} + /** * Session represents an active conversation session with the AI model. * It uses modular components for consistent event emission: @@ -11845,11 +11862,23 @@ export class Session implements SessionContext { : {}), } : undefined; - await this.config.switchModel( - selectedAuthType, - parsed.modelId, - switchOptions, - ); + try { + await this.config.switchModel( + selectedAuthType, + parsed.modelId, + switchOptions, + ); + } catch (error) { + // `switchModel` throws plain Errors for its two caller-caused + // refusals (an unknown model for the auth type, a media-only model + // as primary) and for daemon-side faults (auth refresh, credential, + // I/O) alike. Only the former are client errors on this surface — + // keep every other failure an internal error. + if (error instanceof Error && isCallerCausedModelRefusal(error)) { + throw RequestError.invalidParams(undefined, error.message); + } + throw error; + } const after = this.config.getContentGeneratorConfig?.(); const effectiveAuthType = after?.authType ?? selectedAuthType; diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts index cb1a089b6b4..160100091c5 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -1377,7 +1377,9 @@ describe('bootstrap import boundaries', () => { expect(JSON.parse(output)).toEqual({ args: ['--prompt', 'a&b'], skip: 'true', - hasLauncherPid: true, + // Outside Windows the CLI runs inside the launcher process, so + // there is no separate launcher pid to wait for. + hasLauncherPid: false, }); } finally { rmSync(tempDir, { recursive: true, force: true }); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 7fffd979eca..5aa69d6a819 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -575,6 +575,10 @@ export async function runCliEntry( : undefined; acpStartupProfiler?.initializeAcpStartupProfiler(); acpStartupProfiler?.markAcpStartup('geminiImportStart'); + // The bin launcher only enables the cache for its in-process fast paths; + // this route pays for compiling the whole CLI on every launch without it. + const { default: nodeModule } = await import('node:module'); + nodeModule.enableCompileCache?.(); const { main } = await import('./llm.js'); acpStartupProfiler?.markAcpStartup('geminiImportEnd'); await main(); diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index eb1255d1356..93c1fde5a0f 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -195,6 +195,7 @@ export async function maybeOpenWebShellBrowser( interface ServeArgs { port: number; hostname: string; + profile: 'default' | 'hosted-harness'; token?: string; 'max-sessions': number; 'max-total-sessions'?: number; @@ -228,6 +229,13 @@ interface ServeArgs { 'allow-origin'?: string[]; 'allow-private-auth-base-url': boolean; 'prompt-deadline-ms'?: number; + 'experimental-managed-agents': boolean; + 'experimental-managed-runtime-worker': boolean; + 'experimental-managed-runtime-auto-local': boolean; + 'experimental-managed-runtime-url'?: string; + 'experimental-managed-runtime-token'?: string; + 'managed-runtime-broker-url'?: string; + 'managed-runtime-broker-token'?: string; 'writer-idle-timeout-ms'?: number; 'channel-idle-timeout-ms'?: number; 'initialize-timeout-ms'?: number; @@ -273,6 +281,12 @@ export const serveCommand: CommandModule = { description: 'Interface to bind. Loopback (127.0.0.0/8, localhost, ::1, [::1]) is auth-free; anything else requires a token (one is generated and printed when neither --token nor QWEN_SERVER_TOKEN supplies one). A localhost bind that resolves off-loopback never generates, and still refuses when no token source resolved; an empty value is rejected as operator error.', }) + .option('profile', { + choices: ['default', 'hosted-harness'] as const, + default: 'default' as const, + description: + 'Deployment profile. hosted-harness is reserved and currently rejects startup; Broker session wiring is not implemented.', + }) .option('token', { type: 'string', description: @@ -587,6 +601,48 @@ export const serveCommand: CommandModule = { 'Server-side wallclock cap on POST /session/:id/prompt (ms). ' + 'Falls back to QWEN_SERVE_PROMPT_DEADLINE_MS. Positive integer.', }) + .option('experimental-managed-agents', { + type: 'boolean', + default: false, + description: + 'Reserved experimental mode; not implemented and rejects startup.', + }) + .option('experimental-managed-runtime-worker', { + type: 'boolean', + default: false, + description: + 'Reserved experimental mode; not implemented and rejects startup.', + }) + .option('experimental-managed-runtime-auto-local', { + type: 'boolean', + default: false, + description: + 'Reserved experimental mode; not implemented and rejects startup.', + }) + .option('experimental-managed-runtime-url', { + type: 'string', + requiresArg: true, + description: + 'Reserved experimental Runtime URL; not implemented and rejects startup.', + }) + .option('experimental-managed-runtime-token', { + type: 'string', + requiresArg: true, + description: + 'Reserved experimental Runtime credential; not implemented and rejects startup.', + }) + .option('managed-runtime-broker-url', { + type: 'string', + requiresArg: true, + description: + 'Reserved Broker URL for --profile hosted-harness; not implemented and rejects startup.', + }) + .option('managed-runtime-broker-token', { + type: 'string', + requiresArg: true, + description: + 'Reserved Broker credential for --profile hosted-harness; not implemented and rejects startup.', + }) .option('writer-idle-timeout-ms', { type: 'number', description: @@ -869,6 +925,7 @@ export const serveCommand: CommandModule = { const serveOptions = { port: argv.port, hostname: argv.hostname, + profile: argv.profile, token: argv.token, mode: 'http-bridge', maxSessions: argv['max-sessions'], @@ -913,6 +970,37 @@ export const serveCommand: CommandModule = { ...(argv['prompt-deadline-ms'] !== undefined ? { promptDeadlineMs: argv['prompt-deadline-ms'] } : {}), + ...(argv['experimental-managed-agents'] + ? { experimentalManagedAgents: true } + : {}), + ...(argv['experimental-managed-runtime-auto-local'] + ? { experimentalManagedRuntimeAutoLocal: true } + : {}), + ...(argv['experimental-managed-runtime-worker'] + ? { experimentalManagedRuntimeWorker: true } + : {}), + ...(argv['experimental-managed-runtime-url'] !== undefined + ? { + experimentalManagedRuntimeUrl: + argv['experimental-managed-runtime-url'], + } + : {}), + ...(argv['experimental-managed-runtime-token'] !== undefined + ? { + experimentalManagedRuntimeToken: + argv['experimental-managed-runtime-token'], + } + : {}), + ...(argv['managed-runtime-broker-url'] !== undefined + ? { + managedRuntimeBrokerUrl: argv['managed-runtime-broker-url'], + } + : {}), + ...(argv['managed-runtime-broker-token'] !== undefined + ? { + managedRuntimeBrokerToken: argv['managed-runtime-broker-token'], + } + : {}), ...(argv['writer-idle-timeout-ms'] !== undefined ? { writerIdleTimeoutMs: argv['writer-idle-timeout-ms'] } : {}), diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 4b04e2659a3..49be7c92511 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -49,6 +49,7 @@ const mockSessionServiceInstance = vi.hoisted(() => ({ loadLastSession: vi.fn(), loadSession: vi.fn(), forkSession: vi.fn(), + assertLegacySessionExecution: vi.fn(), sessionExists: vi.fn(), sessionExistsInAnyState: vi.fn(), findSessionIdIgnoringCase: vi.fn(), @@ -2791,6 +2792,35 @@ describe('loadCliConfig', () => { ); }); + it.each([ + { resume: '123e4567-e89b-42d3-a456-426614174000' }, + { continue: true }, + ])( + 'rejects a Managed session before binding a legacy recorder for $resume$continue', + async (args) => { + const sessionId = '123e4567-e89b-42d3-a456-426614174000'; + mockSessionServiceInstance.loadSession.mockResolvedValue({ + conversation: { sessionId, messages: [] }, + }); + mockSessionServiceInstance.loadLastSession.mockResolvedValue({ + conversation: { sessionId, messages: [] }, + }); + mockSessionServiceInstance.assertLegacySessionExecution.mockImplementation( + () => { + throw new Error('belongs to managed'); + }, + ); + + await expect(loadCliConfig({}, args as CliArgs)).rejects.toThrow( + 'belongs to managed', + ); + expect( + mockSessionServiceInstance.assertLegacySessionExecution, + ).toHaveBeenCalledWith(sessionId); + expect(mockConfigConstructorParams).not.toHaveBeenCalled(); + }, + ); + it('rebinds a selective restore projection to the forked session', async () => { const sourceSessionId = '123e4567-e89b-42d3-a456-426614174000'; const projectionSource = vi.fn(async (sessionId: string) => ({ diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 610a3d3c22a..75f28e691b3 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -102,7 +102,6 @@ import { appEvents } from '../utils/events.js'; import { mcpCommand } from '../commands/mcp.js'; import { channelCommand } from '../commands/channel.js'; import { authCommand } from '../commands/auth.js'; -import { reviewCommand } from '../commands/review.js'; import { serveCommand } from '../commands/serve.js'; import { sessionsCommand } from '../commands/sessions.js'; import { boardCommand } from '../commands/board.js'; @@ -898,8 +897,6 @@ export async function parseArguments(): Promise { // Register Channel subcommands .command(channelCommand) .command(boardCommand) - // Register /review skill helpers (presubmit checks, cleanup) - .command(reviewCommand) // Register `qwen serve` (Stage 1 daemon) .command(serveCommand) // Register sessions subcommands @@ -909,6 +906,13 @@ export async function parseArguments(): Promise { // Register `qwen sandbox` (inspect / prove the resolved sandbox backend) .command(sandboxCommand); + // /review skill helpers (presubmit checks, cleanup). The module pulls in + // every review subcommand, so it is only loaded when it can match. + if (rawArgv.includes('review')) { + const { reviewCommand } = await import('../commands/review.js'); + yargsInstance.command(reviewCommand); + } + for (const [option, message] of Object.entries( TOP_LEVEL_DEPRECATED_OPTIONS, )) { @@ -2296,6 +2300,10 @@ export async function loadCliConfig( } } + if (sessionId) { + sessionService.assertLegacySessionExecution(sessionId); + } + if (argv.forkSession && sessionId) { const sourceSessionId = sessionId; const forkedSessionId = randomUUID(); diff --git a/packages/cli/src/config/environment.test.ts b/packages/cli/src/config/environment.test.ts index b1b7ef5ffee..08b431ccd2a 100644 --- a/packages/cli/src/config/environment.test.ts +++ b/packages/cli/src/config/environment.test.ts @@ -10,6 +10,7 @@ import * as path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { buildRuntimeEnvironment, + hasLoadedEnvironmentValues, isFileSourcedEnvKey, loadEnvironment, reloadEnvironment, @@ -117,6 +118,11 @@ const TRACKED_ENV = [ 'QWEN_SANDBOX_IMAGE', 'QWEN_SANDBOX_PROXY_COMMAND', 'QWEN_SANDBOX_NET', + 'OPENAI_API_KEY', + 'OPENAI_BASE_URL', + 'DASHSCOPE_PROXY_BASE_URL', + 'QWEN_TEST_PROVIDER_KEY', + 'DEMO_VAR', ] as const; let tmpDirs: string[] = []; @@ -1823,3 +1829,90 @@ describe('loadEnvironment', () => { expect(warnings[0]).toContain('NODE_OPTIONS'); }); }); + +describe('hasLoadedEnvironmentValues', () => { + it('ignores a configured provider key that settings.env supplies', () => { + resetEnvironmentTrackingForTesting(); + const workspace = makeWorkspace(); + loadEnvironment( + testSettings({ + env: { QWEN_TEST_PROVIDER_KEY: 'from-settings' }, + modelProviders: { + openai: [{ id: 'bench', envKey: 'QWEN_TEST_PROVIDER_KEY' }], + }, + }), + workspace, + ); + + expect(process.env['QWEN_TEST_PROVIDER_KEY']).toBe('from-settings'); + expect(hasLoadedEnvironmentValues()).toBe(false); + }); + + it('ignores the documented OpenAI variables that ~/.qwen/.env supplies', () => { + resetEnvironmentTrackingForTesting(); + const workspace = makeWorkspace(); + const envFile = path.join(os.homedir(), '.qwen', '.env'); + fs.mkdirSync(path.dirname(envFile), { recursive: true }); + fs.writeFileSync( + envFile, + 'OPENAI_API_KEY=sk-file\nOPENAI_BASE_URL=https://api.example/v1\n', + ); + loadEnvironment(testSettings({}), workspace); + + expect(process.env['OPENAI_API_KEY']).toBe('sk-file'); + expect(hasLoadedEnvironmentValues()).toBe(false); + }); + + it('never exempts a boot-time key that a provider entry names', () => { + resetEnvironmentTrackingForTesting(); + const workspace = makeWorkspace(); + const envFile = path.join(os.homedir(), '.qwen', '.env'); + fs.mkdirSync(path.dirname(envFile), { recursive: true }); + fs.writeFileSync(envFile, 'NODE_EXTRA_CA_CERTS=/certs/ca.pem\n'); + loadEnvironment( + testSettings({ + modelProviders: { + openai: [{ id: 'bench', envKey: 'NODE_EXTRA_CA_CERTS' }], + }, + }), + workspace, + ); + + expect(process.env['NODE_EXTRA_CA_CERTS']).toBe('/certs/ca.pem'); + expect(hasLoadedEnvironmentValues()).toBe(true); + }); + + it('tolerates malformed provider entries', () => { + resetEnvironmentTrackingForTesting(); + const workspace = makeWorkspace(); + loadEnvironment( + testSettings({ + env: { OPENAI_API_KEY: 'sk-settings' }, + modelProviders: { + openai: { id: 'not-a-list' }, + anthropic: [null, { id: 'no-env-key' }], + } as unknown as Settings['modelProviders'], + }), + workspace, + ); + + expect(hasLoadedEnvironmentValues()).toBe(false); + }); + + it.each([ + ['DASHSCOPE_PROXY_BASE_URL', 'https://proxy.example'], + ['DEMO_VAR', '1'], + ])('still reports any other value, such as %s', (key, value) => { + resetEnvironmentTrackingForTesting(); + const workspace = makeWorkspace(); + loadEnvironment( + testSettings({ + env: { OPENAI_API_KEY: 'sk-settings', [key]: value }, + }), + workspace, + ); + + expect(process.env[key]).toBe(value); + expect(hasLoadedEnvironmentValues()).toBe(true); + }); +}); diff --git a/packages/cli/src/config/environment.ts b/packages/cli/src/config/environment.ts index a721ba1918d..77e92d44c5d 100644 --- a/packages/cli/src/config/environment.ts +++ b/packages/cli/src/config/environment.ts @@ -103,13 +103,63 @@ if (inheritedProvenance) { Object.assign(process.env, getRelaunchEnvProvenance()); } +// Model credentials and endpoints that the docs name for each auth type +// (docs/users/configuration/auth.md). Providers read them when a request is +// made, never while a module loads. +const DOCUMENTED_MODEL_ENV_KEYS = [ + 'OPENAI_API_KEY', + 'OPENAI_BASE_URL', + 'OPENAI_MODEL', + 'QWEN_MODEL', + 'ANTHROPIC_API_KEY', + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_MODEL', + 'GEMINI_API_KEY', + 'GEMINI_MODEL', + 'GOOGLE_API_KEY', + 'GOOGLE_MODEL', +]; +// Keys whose values are only read per request: the documented ones above plus +// the `envKey` of every configured model provider, which is what `/auth` writes +// to `settings.env`. Filled by loadEnvironment(). +const requestTimeEnvKeys = new Set(); + +function rememberRequestTimeEnvKeys(settings: Settings): void { + requestTimeEnvKeys.clear(); + for (const key of DOCUMENTED_MODEL_ENV_KEYS) requestTimeEnvKeys.add(key); + for (const models of Object.values(settings.modelProviders ?? {})) { + if (!Array.isArray(models)) continue; + for (const model of models) { + const envKey = model?.envKey; + // A provider entry may name any variable; never let it exempt a key + // that Node or the loader treats specially. + if ( + typeof envKey === 'string' && + envKey && + !isHardcodedProjectEnvExclusion(envKey) && + !isLoaderEnvKey(envKey) + ) { + requestTimeEnvKeys.add(envKey); + } + } + } +} + /** - * Whether `.env` files or `settings.env` put any value into this process's - * environment. Modules imported before that load captured the old values, so - * only a fresh image sees the environment the files describe. + * Whether `.env` files or `settings.env` put a value into this process's + * environment that something may have read before those files loaded. Modules + * imported earlier captured the old values (and Node itself reads variables + * such as `NODE_EXTRA_CA_CERTS` only at boot), so only a fresh image sees + * such values. Model credentials and endpoints do not count: providers read + * them per request, so a process that keeps running sees them too. */ export function hasLoadedEnvironmentValues(): boolean { - return dotEnvSourcedKeys.size > 0 || settingsEnvSourcedKeys.size > 0; + for (const keys of [dotEnvSourcedKeys, settingsEnvSourcedKeys]) { + for (const key of keys) { + if (!requestTimeEnvKeys.has(key)) return true; + } + } + return false; } export function getRelaunchEnvProvenance(): Record { @@ -220,6 +270,7 @@ export function resetEnvironmentTrackingForTesting(): void { settingsEnvSourcedKeys.clear(); inheritedDotEnvKeys.clear(); inheritedSettingsEnvKeys.clear(); + requestTimeEnvKeys.clear(); delete process.env[PRIVATE_RELAUNCH_ENV_PROVENANCE]; lastReloadSnapshot.clear(); lastReloadSnapshotSeeded = false; @@ -673,6 +724,7 @@ export function loadEnvironment( startDir: string = process.cwd(), ): void { captureEnvironmentBeforeLoad(); + rememberRequestTimeEnvKeys(settings); const userLevelPaths = getUserLevelEnvPaths(); const envFilePaths = findEnvFiles(settings, startDir, userLevelPaths); const parsedEnvFiles = parseEnvFiles(envFilePaths, userLevelPaths); diff --git a/packages/cli/src/llm.test.tsx b/packages/cli/src/llm.test.tsx index 098aad536a3..6005b2926bb 100644 --- a/packages/cli/src/llm.test.tsx +++ b/packages/cli/src/llm.test.tsx @@ -230,6 +230,16 @@ vi.mock('./utils/relaunch.js', () => ({ relaunchOnExitCode: vi.fn((fn: () => Promise) => fn()), })); +vi.mock('./utils/processUtils.js', async (importOriginal) => ({ + ...(await importOriginal()), + superviseInProcess: vi.fn(), +})); + +vi.mock('./config/environment.js', async (importOriginal) => ({ + ...(await importOriginal()), + hasLoadedEnvironmentValues: vi.fn(() => false), +})); + vi.mock('./config/sandboxConfig.js', () => ({ loadSandboxConfig: vi.fn(), })); @@ -1279,22 +1289,25 @@ describe('llm.tsx main function', () => { // standalone installs in-process or prints manual instructions and never // emits a relaunch exit code, so it keeps the execve optimization like any // other one-shot prompt. - describe('replaceProcess predicate', () => { + describe('relaunch routing', () => { + // 'in-process': no relaunch at all; otherwise the replaceProcess flag + // passed to the supervised relaunch. const rows: Array<{ label: string; argv: Partial; dualOutputInputFile?: string; - expected: boolean; + envFileValues?: boolean; + expected: 'in-process' | boolean; }> = [ { label: 'plain one-shot prompt', argv: { prompt: 'summarize this repository' }, - expected: true, + expected: 'in-process', }, { label: 'headless slash-command prompt', argv: { prompt: '/update' }, - expected: true, + expected: 'in-process', }, { label: 'acp mode', @@ -1304,7 +1317,7 @@ describe('llm.tsx main function', () => { { label: 'interactive prompt (-i)', argv: { prompt: 'hi', promptInteractive: 'follow-up' }, - expected: false, + expected: 'in-process', }, { label: 'file input', @@ -1328,18 +1341,31 @@ describe('llm.tsx main function', () => { expected: false, }, { - // The term that keeps an interactive TUI launch off execve: with no - // prompt the supervisor must survive so in-session relaunch exit - // codes still have a consumer. + // In-session restarts re-exec this process in place instead of + // going through a supervising parent. label: 'plain interactive launch (no prompt)', argv: {}, + expected: 'in-process', + }, + { + // Modules loaded before .env / settings.env were applied read the old + // environment; only a fresh image sees those values. + label: 'plain one-shot prompt with env-file values', + argv: { prompt: 'hi' }, + envFileValues: true, + expected: true, + }, + { + label: 'plain interactive launch with env-file values', + argv: {}, + envFileValues: true, expected: false, }, ]; it.each(rows)( - 'passes replaceProcess=$expected to relaunchAppInChildProcess for $label', - async ({ argv, dualOutputInputFile, expected }) => { + 'routes $label to $expected', + async ({ argv, dualOutputInputFile, envFileValues, expected }) => { const originalIsTTY = Object.getOwnPropertyDescriptor( process.stdin, 'isTTY', @@ -1356,6 +1382,13 @@ describe('llm.tsx main function', () => { const { relaunchAppInChildProcess } = await import( './utils/relaunch.js' ); + const { superviseInProcess } = await import('./utils/processUtils.js'); + const { hasLoadedEnvironmentValues } = await import( + './config/environment.js' + ); + vi.mocked(hasLoadedEnvironmentValues).mockReturnValue( + envFileValues ?? false, + ); vi.mocked(parseArguments).mockResolvedValue(argv as CliArgs); vi.mocked(loadSandboxConfig).mockResolvedValue(undefined); vi.mocked(loadSettings).mockReturnValue({ @@ -1376,19 +1409,22 @@ describe('llm.tsx main function', () => { getProjectHooks: () => undefined, } as never); - let replaceProcess: boolean | undefined; + let route: 'in-process' | boolean | undefined; vi.mocked(relaunchAppInChildProcess).mockImplementation( async (_memoryArgs, _extraArgs, options) => { - replaceProcess = options?.replaceProcess; - throw new Error('stop after replaceProcess check'); + route = options?.replaceProcess; + throw new Error('stop after routing check'); }, ); + vi.mocked(superviseInProcess).mockImplementation(() => { + route = 'in-process'; + throw new Error('stop after routing check'); + }); try { - await expect(main()).rejects.toThrow( - 'stop after replaceProcess check', - ); + await expect(main()).rejects.toThrow('stop after routing check'); } finally { + vi.mocked(hasLoadedEnvironmentValues).mockReturnValue(false); vi.unstubAllEnvs(); if (originalIsTTY) { Object.defineProperty(process.stdin, 'isTTY', originalIsTTY); @@ -1397,7 +1433,7 @@ describe('llm.tsx main function', () => { } } - expect(replaceProcess).toBe(expected); + expect(route).toBe(expected); }, ); }); @@ -1459,6 +1495,11 @@ describe('llm.tsx main function', () => { vi.mocked(relaunchAppInChildProcess).mockImplementation(async () => { throw new Error('stop after theme baseline'); }); + // A plain one-shot run supervises itself in-process instead. + const { superviseInProcess } = await import('./utils/processUtils.js'); + vi.mocked(superviseInProcess).mockImplementation(() => { + throw new Error('stop after theme baseline'); + }); try { await expect(main()).rejects.toThrow('stop after theme baseline'); diff --git a/packages/cli/src/llm.tsx b/packages/cli/src/llm.tsx index 7898ff5214d..8687d063df6 100644 --- a/packages/cli/src/llm.tsx +++ b/packages/cli/src/llm.tsx @@ -115,6 +115,7 @@ import { CUSTOM_SANDBOX_IMAGE_ENV_VAR, HOST_UPDATE_RELAUNCH_ENV_VAR, UPDATE_COMPLETE_EXIT_CODE, + superviseInProcess, } from './utils/processUtils.js'; import { getInstallationInfo } from './utils/installationInfo.js'; @@ -822,6 +823,20 @@ export async function main() { }, ); process.exit(0); + } else if ( + memoryArgs.length === 0 && + !hasLoadedEnvironmentValues() && + !isAcpMode && + argv.inputFormat !== InputFormat.STREAM_JSON && + !(argv.inputFile ?? settings.merged.dualOutput?.inputFile) && + argv.jsonFd === undefined && + typeof process.execve === 'function' && + !['win32', 'os400'].includes(process.platform) + ) { + // Nothing to add to this process's flags, and no env-file values that + // already-loaded modules missed, so a relaunch would only load the whole + // CLI a second time. Restarts re-exec in place instead. + superviseInProcess(onUpdateRelaunch); } else { // Interactive and streaming modes keep a supervisor for in-session // restarts. A one-shot prompt can replace this already-loaded process. diff --git a/packages/cli/src/serve/acp-http/dispatch-error.test.ts b/packages/cli/src/serve/acp-http/dispatch-error.test.ts index 3339c897ede..b5f6713278c 100644 --- a/packages/cli/src/serve/acp-http/dispatch-error.test.ts +++ b/packages/cli/src/serve/acp-http/dispatch-error.test.ts @@ -21,6 +21,28 @@ import { import { toRpcError } from './dispatch.js'; import { RPC } from './json-rpc.js'; +describe('startup errors across bundle boundaries', () => { + it.each([ + ['invalid_startup_config', 400, RPC.INVALID_PARAMS], + // A rejected selection is caller input too — the JSON-RPC code agrees + // with the REST 4xx classification, and data.httpStatus keeps the 422. + ['startup_config_rejected', 422, RPC.INVALID_PARAMS], + ] as const)( + 'maps %s by its stable contract', + (errorKind, httpStatus, code) => { + const error = Object.assign(new Error('startup rejected'), { + name: 'SessionStartupConfigError', + code: errorKind, + }); + expect(toRpcError(error)).toEqual({ + code, + message: 'startup rejected', + data: { errorKind, httpStatus }, + }); + }, + ); +}); + describe('capacity RPC errors', () => { it.each([false, true])( 'carries capacity through runtime wrapper=%s', diff --git a/packages/cli/src/serve/acp-http/dispatch.ts b/packages/cli/src/serve/acp-http/dispatch.ts index 6baccd2c585..57e085b8d89 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -4,6 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { + parseSessionStartupConfig, + isSessionStartupConfigError, +} from '@qwen-code/acp-bridge/sessionStartupConfig'; import { APPROVAL_MODES, type ApprovalMode, @@ -727,6 +731,20 @@ export function toRpcError(err: unknown): { }, }; } + if (isSessionStartupConfigError(err)) { + // Both kinds are caller-input rejections — a malformed config and a + // selection the provider refused alike — so both map to the JSON-RPC + // client-fault code. `data.httpStatus` keeps the REST-equivalent + // status, and SDK transports key on it rather than on this code. + return { + code: RPC.INVALID_PARAMS, + message: err.message, + data: { + errorKind: err.code, + httpStatus: err.code === 'invalid_startup_config' ? 400 : 422, + }, + }; + } if (err instanceof InvalidRequestedSessionIdError) { return { code: RPC.INVALID_PARAMS, @@ -1843,6 +1861,10 @@ export class AcpDispatcher { ); return; } + const startupConfig = parseSessionStartupConfig( + params['startupConfig'], + params, + ); const meta = isObject(params['_meta']) ? params['_meta'] : undefined; const parsedSessionId = parseCallerSuppliedSessionId( meta?.[REQUESTED_SESSION_ID_META_KEY], @@ -1929,13 +1951,52 @@ export class AcpDispatcher { // Always use sessionScope 'thread' regardless of client params. // The REST surface (POST /session) supports 'single' for // backward compat, but the ACP endpoint follows the standard. - const session = await sessionRuntime.bridge.spawnOrAttach({ - workspaceCwd: cwd, - clientId: conn.clientId, - sessionScope: 'thread', - ...source, - ...(requestedSessionId ? { sessionId: requestedSessionId } : {}), - }); + const session = await sessionRuntime.bridge + .spawnOrAttach({ + workspaceCwd: cwd, + clientId: conn.clientId, + sessionScope: 'thread', + ...(startupConfig ? { startupConfig } : {}), + ...source, + ...(requestedSessionId + ? { sessionId: requestedSessionId } + : {}), + }) + .catch(async (error: unknown) => { + // Mirror the REST route: a definite startup rejection + // already closed the live session, but the recording the + // spawn persisted survives — roll it back so the id stays + // retryable, naming the session the rejection was actually + // applied to (a daemon-generated id otherwise leaves a + // listed, resumable phantom). Uncertain outcomes keep it: + // the close result is unknown. + const rejectedSessionId = + (isSessionStartupConfigError(error) + ? error.sessionId + : undefined) ?? requestedSessionId; + if ( + rejectedSessionId !== undefined && + isSessionStartupConfigError(error) && + error.code === 'startup_config_rejected' + ) { + const removed = await this.removeOrphanSession( + rejectedSessionId, + true, + sessionRuntime, + ); + if (!removed) { + // Matches the REST route: the definite rejection still + // owes the caller its error, so a refused rollback (the + // session stayed live) is a log line, not a throw — + // otherwise a permanently occupied id has no + // diagnostic at all. + writeStderrLine( + `qwen serve: startup rejection recording rollback was inconclusive; the session id may stay occupied (${logSafe(rejectedSessionId)})`, + ); + } + } + throw error; + }); const ownership = this.ownershipReceipt( conn, session.sessionId, @@ -1978,6 +2039,12 @@ export class AcpDispatcher { id, { sessionId: session.sessionId, + ...(session.startupConfigApplied + ? { + modelApplied: true, + startupConfigApplied: session.startupConfigApplied, + } + : {}), ...(session.sourceType ? { sourceType: session.sourceType } : {}), diff --git a/packages/cli/src/serve/acp-http/transport.test.ts b/packages/cli/src/serve/acp-http/transport.test.ts index cbad8566e0a..2a8dc4906aa 100644 --- a/packages/cli/src/serve/acp-http/transport.test.ts +++ b/packages/cli/src/serve/acp-http/transport.test.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { SessionStartupConfigError } from '@qwen-code/acp-bridge/sessionStartupConfig'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import express from 'express'; import { promises as fs } from 'node:fs'; @@ -6908,6 +6909,313 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { expect(bridge.lastApprovalMode).toBeUndefined(); }); + it('session/new forwards startup selection and returns its actual confirmation', async () => { + const startupConfig = { + modelServiceId: 'gpt-5.4(openai)', + reasoningEffort: 'high', + }; + const startupConfigApplied = { + ...startupConfig, + effectiveReasoning: { state: 'enabled', effort: 'high' }, + }; + const session = { + sessionId: 'startup', + workspaceCwd: TEST_WORKSPACE, + attached: false, + clientId: 'startup-client', + modelApplied: true, + startupConfigApplied, + }; + const spawn = vi + .spyOn(bridge, 'spawnOrAttach') + .mockResolvedValueOnce(session); + const connId = await initialize(); + const stream = await openStream(connId); + const got = takeFrames(stream, 1); + await post(connId, { + jsonrpc: '2.0', + id: 430, + method: 'session/new', + params: { startupConfig }, + }); + const [frame] = (await got) as Array<{ result: Record }>; + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ startupConfig, sessionScope: 'thread' }), + ); + expect(frame.result).toMatchObject({ + modelApplied: true, + startupConfigApplied, + }); + }); + + it.each([ + { startupConfig: {} }, + { + startupConfig: { modelServiceId: 'x', reasoningEffort: 'high' }, + sessionScope: 'single', + }, + { + startupConfig: { modelServiceId: 'x', reasoningEffort: 'high' }, + modelServiceId: 'legacy', + }, + ])( + 'session/new rejects invalid startup request without spawning %j', + async (params) => { + const spawn = vi.spyOn(bridge, 'spawnOrAttach'); + const connId = await initialize(); + const stream = await openStream(connId); + const got = takeFrames(stream, 1); + await post(connId, { + jsonrpc: '2.0', + id: 431, + method: 'session/new', + params, + }); + const [frame] = (await got) as Array<{ + error: { data: Record }; + }>; + expect(frame.error.data).toMatchObject({ + errorKind: 'invalid_startup_config', + httpStatus: 400, + }); + expect(spawn).not.toHaveBeenCalled(); + }, + ); + + it('session/new maps a definite startup rejection to an explicit 422 RPC error', async () => { + vi.spyOn(bridge, 'spawnOrAttach').mockRejectedValueOnce( + new SessionStartupConfigError( + 'startup_config_rejected', + 'unsupported effort', + ), + ); + const connId = await initialize(); + const stream = await openStream(connId); + const got = takeFrames(stream, 1); + await post(connId, { + jsonrpc: '2.0', + id: 432, + method: 'session/new', + params: { startupConfig: { modelServiceId: 'm' } }, + }); + const [frame] = (await got) as Array<{ + error: { code: number; data: Record }; + }>; + expect(frame.error).toMatchObject({ + // A rejected selection is caller input, not a daemon fault; the + // REST-equivalent status travels in data.httpStatus. + code: -32602, + data: { errorKind: 'startup_config_rejected', httpStatus: 422 }, + }); + }); + + it('session/new rolls back the persisted recording after a definite startup rejection so the caller id stays retryable', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440005'; + // The spawn persists its recording before the selection is rejected + // and the bridge closes the live session. + vi.spyOn(bridge, 'spawnOrAttach').mockImplementationOnce(async () => { + await writeStoredSession(sessionId); + throw new SessionStartupConfigError( + 'startup_config_rejected', + 'unsupported effort', + ); + }); + const connId = await initialize(); + const stream = await openStream(connId); + const reader = frameReader(stream); + await post(connId, { + jsonrpc: '2.0', + id: 445, + method: 'session/new', + params: { + startupConfig: { modelServiceId: 'm' }, + _meta: { 'qwen-code/sessionId': sessionId }, + }, + }); + const frame = (await reader.next()) as { + error: { code: number; data: Record }; + }; + expect(frame.error).toMatchObject({ + code: -32602, + data: { errorKind: 'startup_config_rejected', httpStatus: 422 }, + }); + expect(bridge.killed).toContain(sessionId); + + await post(connId, { + jsonrpc: '2.0', + id: 446, + method: 'session/new', + params: { _meta: { 'qwen-code/sessionId': sessionId } }, + }); + const retryFrame = (await reader.next()) as { + result: { sessionId: string }; + }; + expect(retryFrame.result.sessionId).toBe(sessionId); + reader.close(); + }); + + it('session/new rolls back the persisted recording after a definite startup rejection when the daemon generated the id', async () => { + // No `_meta['qwen-code/sessionId']`: only the rejection's own + // `sessionId` can name the recording the spawn persisted before the + // selection was rejected. + const spawnedId = '550e8400-e29b-41d4-a716-446655440098'; + vi.spyOn(bridge, 'spawnOrAttach').mockImplementationOnce(async () => { + await writeStoredSession(spawnedId); + throw new SessionStartupConfigError( + 'startup_config_rejected', + 'unsupported effort', + spawnedId, + ); + }); + const connId = await initialize(); + const stream = await openStream(connId); + const reader = frameReader(stream); + await post(connId, { + jsonrpc: '2.0', + id: 449, + method: 'session/new', + params: { startupConfig: { modelServiceId: 'm' } }, + }); + const frame = (await reader.next()) as { + error: { code: number; data: Record }; + }; + expect(frame.error).toMatchObject({ + code: -32602, + data: { errorKind: 'startup_config_rejected', httpStatus: 422 }, + }); + expect(bridge.killed).toContain(spawnedId); + await expect( + new SessionService(TEST_WORKSPACE).findSessionIdIgnoringCase(spawnedId), + ).resolves.toBeUndefined(); + reader.close(); + }); + + it('session/new logs when the definite-rejection recording rollback is refused', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440097'; + // The session reads as live only after the spawn attempt — reporting it + // earlier would trip the admission's live-conflict before the spawn. + let spawned = false; + vi.spyOn(bridge, 'spawnOrAttach').mockImplementationOnce(async () => { + spawned = true; + await writeStoredSession(sessionId); + throw new SessionStartupConfigError( + 'startup_config_rejected', + 'unsupported effort', + sessionId, + ); + }); + // The orphan kill is refused (the session stays live), so the rollback + // removes nothing — the refusal must still leave a diagnostic. + vi.spyOn(bridge, 'killSession').mockResolvedValue(false); + const realGetSummary = bridge.getSessionSummary.bind(bridge); + bridge.getSessionSummary = (id: string) => { + if (spawned && id === sessionId) { + return { + sessionId: id, + workspaceCwd: TEST_WORKSPACE, + createdAt: '2026-06-30T00:00:00.000Z', + clientCount: 1, + hasActivePrompt: true, + }; + } + return realGetSummary(id); + }; + const connId = await initialize(); + const stream = await openStream(connId); + const reader = frameReader(stream); + await post(connId, { + jsonrpc: '2.0', + id: 450, + method: 'session/new', + params: { + startupConfig: { modelServiceId: 'm' }, + _meta: { 'qwen-code/sessionId': sessionId }, + }, + }); + const frame = (await reader.next()) as { + error: { code: number; data: Record }; + }; + expect(frame.error).toMatchObject({ + code: -32602, + data: { errorKind: 'startup_config_rejected', httpStatus: 422 }, + }); + expect(stdioMocks.writeStderrLine).toHaveBeenCalledWith( + expect.stringContaining( + 'startup rejection recording rollback was inconclusive; the session id may stay occupied', + ), + ); + + // The rollback was refused, so the id stays occupied: a retry of the + // same id conflicts with the still-live session instead of attaching. + await post(connId, { + jsonrpc: '2.0', + id: 451, + method: 'session/new', + params: { _meta: { 'qwen-code/sessionId': sessionId } }, + }); + const retryFrame = (await reader.next()) as { + error: { code: number; data: Record }; + }; + expect(retryFrame.error).toMatchObject({ + code: -32602, + data: { + httpStatus: 409, + errorKind: 'session_id_conflict', + conflict: 'live', + }, + }); + reader.close(); + }); + + it('session/new keeps the persisted recording when a startup failure outcome is uncertain', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440006'; + vi.spyOn(bridge, 'spawnOrAttach').mockImplementationOnce(async () => { + await writeStoredSession(sessionId); + throw new Error('transport closed'); + }); + const connId = await initialize(); + const stream = await openStream(connId); + const reader = frameReader(stream); + await post(connId, { + jsonrpc: '2.0', + id: 447, + method: 'session/new', + params: { + startupConfig: { modelServiceId: 'm' }, + _meta: { 'qwen-code/sessionId': sessionId }, + }, + }); + const frame = (await reader.next()) as { + error: { code: number; data: Record }; + }; + expect(frame.error.code).toBe(-32603); + expect(frame.error.data).not.toMatchObject({ + errorKind: 'startup_config_rejected', + }); + expect(bridge.killed).not.toContain(sessionId); + + // The recording survives, so a corrected retry of the same id still + // answers the persisted-admission conflict instead of attaching. + await post(connId, { + jsonrpc: '2.0', + id: 448, + method: 'session/new', + params: { _meta: { 'qwen-code/sessionId': sessionId } }, + }); + const retryFrame = (await reader.next()) as { + error: { code: number; data: Record }; + }; + expect(retryFrame.error).toMatchObject({ + code: -32602, + data: { + httpStatus: 409, + errorKind: 'session_id_conflict', + conflict: 'persisted', + }, + }); + reader.close(); + }); + it('session/new always uses thread scope (ACP standard compliance)', async () => { // ACP standard: session/new MUST create a new isolated session. // sessionScope param is ignored; bridge always gets 'thread'. diff --git a/packages/cli/src/serve/broker-managed-runtime-provider.test.ts b/packages/cli/src/serve/broker-managed-runtime-provider.test.ts new file mode 100644 index 00000000000..e7515972832 --- /dev/null +++ b/packages/cli/src/serve/broker-managed-runtime-provider.test.ts @@ -0,0 +1,592 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import type { ManagedToolInvocationReference } from '@qwen-code/qwen-code-core'; +import { + BrokerManagedRuntimeProvider, + MANAGED_RUNTIME_BROKER_PROTOCOL_VERSION, +} from './broker-managed-runtime-provider.js'; +import type { ManagedRuntimePrepareRequest } from './managed-runtime-protocol.js'; + +const harnessSessionId = '550e8400-e29b-41d4-a716-446655440301'; +const runtimeSessionId = '550e8400-e29b-41d4-a716-446655440302'; + +function request(): ManagedRuntimePrepareRequest { + return { + protocolVersion: 1, + tenantId: 'tenant-must-not-cross-the-broker-boundary', + workspaceId: 'workspace-must-not-cross-the-broker-boundary', + workspaceCwd: '/workspace/must-not-cross-the-broker-boundary', + sessionId: runtimeSessionId, + turnKind: 'bootstrap', + }; +} + +function reference(): ManagedToolInvocationReference { + return { + sessionId: runtimeSessionId, + promptId: 'turn-1', + callId: 'tool-1', + capabilityDigest: 'a'.repeat(64), + policyRevision: 'policy-1', + invocationId: 'invocation-1', + argsDigest: 'b'.repeat(64), + }; +} + +function json(value: unknown): Response { + return new Response(JSON.stringify(value), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +} + +function envelope(fields: Record) { + return { + protocolVersion: MANAGED_RUNTIME_BROKER_PROTOCOL_VERSION, + harnessSessionId, + runtimeSessionId, + ...fields, + }; +} + +describe('BrokerManagedRuntimeProvider', () => { + it('requires a credential and HTTPS for a remote Broker', () => { + expect( + () => + new BrokerManagedRuntimeProvider({ + baseUrl: 'http://broker.example.com', + token: 'secret', + }), + ).toThrow('must use HTTPS'); + expect( + () => + new BrokerManagedRuntimeProvider({ + baseUrl: 'http://127.0.0.1:8080', + token: ' ', + }), + ).toThrow('token is required'); + }); + + it('acquires by Harness identity without forwarding tenant or workspace claims', async () => { + const bodies: unknown[] = []; + const fetchImpl = vi.fn(async (_input, init) => { + bodies.push(JSON.parse(String(init?.body)) as unknown); + return json(envelope({ acquired: true })); + }); + const provider = new BrokerManagedRuntimeProvider({ + baseUrl: 'http://127.0.0.1:8080', + token: 'secret', + fetch: fetchImpl, + }); + + await provider.getToolV2Client(request(), { harnessSessionId }); + + expect(fetchImpl).toHaveBeenCalledOnce(); + expect(String(fetchImpl.mock.calls[0][0])).toBe( + 'http://127.0.0.1:8080/internal/runtime-broker/v1/tool-sessions:acquire', + ); + expect(bodies[0]).toMatchObject({ + protocolVersion: 1, + harnessSessionId, + runtimeSessionId, + turnKind: 'bootstrap', + requestId: expect.any(String), + }); + expect(bodies[0]).not.toHaveProperty('tenantId'); + expect(bodies[0]).not.toHaveProperty('workspaceId'); + expect(bodies[0]).not.toHaveProperty('workspaceCwd'); + }); + + it('retries a failed acquisition and invalidates issued clients after release', async () => { + let acquisitions = 0; + const fetchImpl = vi.fn(async (input) => { + const url = String(input); + if (url.endsWith('tool-sessions:acquire')) { + acquisitions++; + return acquisitions === 1 + ? new Response('{}', { status: 503 }) + : json(envelope({ acquired: true })); + } + if (url.endsWith(':release')) return json(envelope({ released: true })); + throw new Error(`Unexpected request ${url}`); + }); + const provider = new BrokerManagedRuntimeProvider({ + baseUrl: 'http://127.0.0.1:8080', + token: 'secret', + fetch: fetchImpl, + }); + await expect( + provider.getToolV2Client(request(), { harnessSessionId }), + ).rejects.toThrow('503'); + const client = await provider.getToolV2Client(request(), { + harnessSessionId, + }); + expect(acquisitions).toBe(2); + await provider.release(runtimeSessionId, request(), { terminal: true }); + const requests = fetchImpl.mock.calls.length; + expect(() => client.manifest()).toThrow('closed'); + await expect(client.execute(reference())).rejects.toThrow('closed'); + expect(fetchImpl).toHaveBeenCalledTimes(requests); + provider.dispose(); + }); + + it('keeps cleanup reachable when the acquire response is lost', async () => { + const fetchImpl = vi.fn(async (input) => { + if (String(input).endsWith('tool-sessions:acquire')) { + throw new TypeError('connection closed after acquire'); + } + if (String(input).endsWith(':release')) { + return json(envelope({ released: true })); + } + throw new Error('Unexpected request'); + }); + const provider = new BrokerManagedRuntimeProvider({ + baseUrl: 'http://127.0.0.1:8080', + token: 'secret', + fetch: fetchImpl, + }); + await expect( + provider.getToolV2Client(request(), { harnessSessionId }), + ).rejects.toThrow('connection closed'); + await expect( + provider.release(runtimeSessionId, request(), { terminal: true }), + ).resolves.toBe(true); + expect(fetchImpl).toHaveBeenCalledTimes(2); + await expect( + provider.getToolV2Client(request(), { harnessSessionId }), + ).rejects.toThrow('permanently closed'); + provider.dispose(); + }); + + it('reserves a durable execution identity before starting it', async () => { + const calls: Array<{ url: string; method: string; body?: unknown }> = []; + let droppedExecutionResponse = false; + const settled = { + state: 'settled', + cancelRequested: false, + lastSeq: 0, + firstAvailableSeq: 1, + progressGap: false, + progress: [], + result: { + executionStatus: 'success', + result: { llmContent: 'ok', returnDisplay: 'ok' }, + }, + }; + const executing = { + ...settled, + state: 'executing', + result: undefined, + }; + const preparedStatus = { + ...executing, + state: 'prepared', + }; + const fetchImpl = vi.fn(async (input, init) => { + const url = String(input); + const method = init?.method ?? 'GET'; + const body = init?.body + ? (JSON.parse(String(init.body)) as unknown) + : undefined; + calls.push({ url, method, ...(body === undefined ? {} : { body }) }); + if (url.endsWith('tool-sessions:acquire')) { + return json(envelope({ acquired: true })); + } + if (url.endsWith(`/tool-sessions/${runtimeSessionId}/control`)) { + return json( + envelope({ + result: { + tools: [], + capabilityDigest: 'a'.repeat(64), + policyRevision: 'policy-1', + }, + }), + ); + } + if (url.endsWith('/executions:prepare')) { + if (!droppedExecutionResponse) { + droppedExecutionResponse = true; + throw new TypeError('response connection closed'); + } + return json( + envelope({ executionCallId: 'execution-1', status: preparedStatus }), + ); + } + if (url.endsWith('/executions/execution-1:start')) { + return json( + envelope({ executionCallId: 'execution-1', status: executing }), + ); + } + if (url.includes('/executions/execution-1?')) { + return json( + envelope({ executionCallId: 'execution-1', status: settled }), + ); + } + if (url.endsWith(`/tool-sessions/${runtimeSessionId}:release`)) { + return json(envelope({ released: true })); + } + throw new Error(`Unexpected Broker request: ${method} ${url}`); + }); + const provider = new BrokerManagedRuntimeProvider({ + baseUrl: 'http://127.0.0.1:8080', + token: 'secret', + fetch: fetchImpl, + }); + const prepared = request(); + const client = await provider.getToolV2Client(prepared, { + harnessSessionId, + }); + + await expect(client.manifest()).resolves.toMatchObject({ + policyRevision: 'policy-1', + }); + const reservation = await client.prepareExecution!(reference()); + expect(reservation).toEqual({ + executionCallId: 'execution-1', + invocationBindingId: runtimeSessionId, + }); + expect( + calls.some((call) => call.url.endsWith('/executions/execution-1:start')), + ).toBe(false); + await expect( + client.startExecution!(reference(), reservation.executionCallId), + ).resolves.toMatchObject({ + executionStatus: 'success', + }); + await expect(client.execute(reference())).resolves.toMatchObject({ + executionStatus: 'success', + }); + await expect( + provider.release(runtimeSessionId, prepared, { terminal: true }), + ).resolves.toBe(true); + + const control = calls.find((call) => call.url.endsWith('/control')); + expect(control?.body).toMatchObject({ + harnessSessionId, + operation: { kind: 'manifest' }, + }); + const executions = calls.filter((call) => + call.url.endsWith('/executions:prepare'), + ); + expect(executions).toHaveLength(2); + expect(executions[0].body).toMatchObject({ + harnessSessionId, + runtimeSessionId, + turnId: 'turn-1', + toolCallId: 'tool-1', + requestDigest: 'b'.repeat(64), + idempotencyKey: expect.stringMatching(/^[a-f0-9]{64}$/), + }); + expect(executions[1].body).toEqual(executions[0].body); + expect( + calls.filter((call) => + call.url.endsWith('/executions/execution-1:start'), + ), + ).toHaveLength(1); + const status = calls.find((call) => + call.url.includes('/executions/execution-1?'), + ); + expect(status?.url).toContain( + `harnessSessionId=${encodeURIComponent(harnessSessionId)}`, + ); + expect(status?.url).toContain( + `runtimeSessionId=${encodeURIComponent(runtimeSessionId)}`, + ); + }); + + it('fails closed when Broker response identity changes', async () => { + const fetchImpl = vi.fn(async () => + json({ + protocolVersion: 1, + harnessSessionId: 'another-session', + runtimeSessionId, + acquired: true, + }), + ); + const provider = new BrokerManagedRuntimeProvider({ + baseUrl: 'http://127.0.0.1:8080', + token: 'secret', + fetch: fetchImpl, + }); + + await expect( + provider.getToolV2Client(request(), { harnessSessionId }), + ).rejects.toThrow('response identity changed'); + }); + + it('inspects a durable execution without recreating a process-local Runtime entry', async () => { + let unknown = false; + const status = { + state: 'executing', + cancelRequested: false, + lastSeq: 4, + firstAvailableSeq: 5, + progressGap: false, + progress: [], + }; + const fetchImpl = vi.fn(async (input, init) => { + expect(init?.method).toBe('GET'); + const url = String(input); + expect(url).toContain('/executions/execution-recovery?'); + expect(url).toContain(`harnessSessionId=${harnessSessionId}`); + expect(url).toContain(`runtimeSessionId=${runtimeSessionId}`); + expect(url).toContain('afterSeq=3'); + if (unknown) { + return new Response( + JSON.stringify({ + error: 'Tool execution outcome is unknown.', + code: 'runtime_broker_execution_unknown', + retryable: true, + }), + { + status: 503, + headers: { 'content-type': 'application/json' }, + }, + ); + } + return json(envelope({ executionCallId: 'execution-recovery', status })); + }); + const provider = new BrokerManagedRuntimeProvider({ + baseUrl: 'http://127.0.0.1:8080', + token: 'secret', + fetch: fetchImpl, + }); + const identity = { + harnessSessionId, + runtimeSessionId, + executionCallId: 'execution-recovery', + afterSeq: 3, + }; + + await expect(provider.inspectExecution(identity)).resolves.toEqual({ + outcome: 'known', + status, + }); + unknown = true; + await expect(provider.inspectExecution(identity)).resolves.toEqual({ + outcome: 'unknown', + }); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); + + it('cancels and waits for the original execution without starting or acquiring', async () => { + const pending = { + state: 'prepared' as const, + cancelRequested: false, + lastSeq: 0, + firstAvailableSeq: 1, + progressGap: false, + progress: [], + }; + const cancelling = { + ...pending, + state: 'cancel_requested' as const, + cancelRequested: true, + }; + const settled = { + ...cancelling, + state: 'settled' as const, + result: { executionStatus: 'cancelled' as const }, + }; + let reads = 0; + const calls: Array<{ method: string; url: string }> = []; + const fetchImpl = vi.fn(async (input, init) => { + const url = String(input); + const method = init?.method ?? 'GET'; + calls.push({ method, url }); + if (url.includes('/executions/execution-recovery?')) { + reads++; + return json( + envelope({ + executionCallId: 'execution-recovery', + status: reads === 1 ? pending : settled, + }), + ); + } + if (url.endsWith('/executions/execution-recovery:cancel')) { + return json( + envelope({ + executionCallId: 'execution-recovery', + status: cancelling, + }), + ); + } + throw new Error(`Unexpected Broker request: ${method} ${url}`); + }); + const provider = new BrokerManagedRuntimeProvider({ + baseUrl: 'http://127.0.0.1:8080', + token: 'secret', + fetch: fetchImpl, + }); + + await expect( + provider.cancelExecution({ + harnessSessionId, + runtimeSessionId, + executionCallId: 'execution-recovery', + afterSeq: 0, + }), + ).resolves.toEqual({ outcome: 'known', status: settled }); + + expect(calls.map(({ method, url }) => ({ method, url }))).toEqual([ + expect.objectContaining({ method: 'GET' }), + expect.objectContaining({ + method: 'POST', + url: expect.stringContaining('/executions/execution-recovery:cancel'), + }), + expect.objectContaining({ method: 'GET' }), + ]); + expect(calls.some((call) => call.url.includes(':start'))).toBe(false); + expect( + calls.some((call) => call.url.includes('tool-sessions:acquire')), + ).toBe(false); + }); + + it('reconciles the original execution to settlement without acquiring a new Runtime', async () => { + const result = { + executionStatus: 'success' as const, + result: { + llmContent: 'recovered output', + returnDisplay: 'recovered output', + }, + }; + const prepared = { + state: 'prepared' as const, + cancelRequested: false, + lastSeq: 0, + firstAvailableSeq: 1, + progressGap: false, + progress: [], + }; + const executing = { ...prepared, state: 'executing' as const }; + const settled = { + ...executing, + state: 'settled' as const, + result, + }; + let reads = 0; + const calls: Array<{ method: string; url: string }> = []; + const fetchImpl = vi.fn(async (input, init) => { + const url = String(input); + const method = init?.method ?? 'GET'; + calls.push({ method, url }); + if (url.includes('/executions/execution-recovery?')) { + reads++; + return json( + envelope({ + executionCallId: 'execution-recovery', + status: reads === 1 ? prepared : settled, + }), + ); + } + if (url.endsWith('/executions/execution-recovery:start')) { + return json( + envelope({ + executionCallId: 'execution-recovery', + status: executing, + }), + ); + } + throw new Error(`Unexpected Broker request: ${method} ${url}`); + }); + const provider = new BrokerManagedRuntimeProvider({ + baseUrl: 'http://127.0.0.1:8080', + token: 'secret', + fetch: fetchImpl, + }); + + await expect( + provider.reconcileExecution({ + harnessSessionId, + runtimeSessionId, + executionCallId: 'execution-recovery', + afterSeq: 0, + }), + ).resolves.toEqual({ outcome: 'known', status: settled }); + + expect(calls).toEqual([ + expect.objectContaining({ + method: 'GET', + url: expect.stringContaining('/executions/execution-recovery?'), + }), + expect.objectContaining({ + method: 'POST', + url: expect.stringContaining('/executions/execution-recovery:start'), + }), + expect.objectContaining({ + method: 'GET', + url: expect.stringContaining('/executions/execution-recovery?'), + }), + ]); + expect( + calls.some((call) => call.url.includes('tool-sessions:acquire')), + ).toBe(false); + }); + + it('resolves an unknown execution through the broker without re-executing it', async () => { + const settled = { + state: 'settled' as const, + cancelRequested: false, + lastSeq: 0, + firstAvailableSeq: 1, + progressGap: false, + progress: [], + result: { + executionStatus: 'not_started', + resolution: 'confirmed_not_executed', + }, + }; + const calls: Array<{ method: string; url: string; body?: unknown }> = []; + const fetchImpl = vi.fn(async (input, init) => { + const url = String(input); + const method = init?.method ?? 'GET'; + const body = init?.body + ? (JSON.parse(String(init.body)) as unknown) + : undefined; + calls.push({ method, url, ...(body === undefined ? {} : { body }) }); + if (url.endsWith('/executions/execution-recovery:resolve')) { + return json( + envelope({ + executionCallId: 'execution-recovery', + status: settled, + }), + ); + } + throw new Error(`Unexpected Broker request: ${method} ${url}`); + }); + const provider = new BrokerManagedRuntimeProvider({ + baseUrl: 'http://127.0.0.1:8080', + token: 'secret', + fetch: fetchImpl, + }); + + await expect( + provider.resolveExecution( + { + harnessSessionId, + runtimeSessionId, + executionCallId: 'execution-recovery', + }, + 'confirmed_not_executed', + ), + ).resolves.toEqual({ outcome: 'known', status: settled }); + + expect(calls).toHaveLength(1); + expect(calls[0].method).toBe('POST'); + expect(calls[0].url).toBe( + 'http://127.0.0.1:8080/internal/runtime-broker/v1/executions/execution-recovery:resolve', + ); + expect(calls[0].body).toMatchObject({ + protocolVersion: 1, + harnessSessionId, + runtimeSessionId, + resolution: 'confirmed_not_executed', + requestId: expect.any(String), + }); + }); +}); diff --git a/packages/cli/src/serve/broker-managed-runtime-provider.ts b/packages/cli/src/serve/broker-managed-runtime-provider.ts new file mode 100644 index 00000000000..a2fecd57e71 --- /dev/null +++ b/packages/cli/src/serve/broker-managed-runtime-provider.ts @@ -0,0 +1,1090 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash, randomUUID } from 'node:crypto'; +import { setTimeout as delay } from 'node:timers/promises'; +// Type-only: under verbatimModuleSyntax an `import { type X }` still loads the +// module, and managed-tool-runtime pulls the whole Config graph into the +// serve fast path (scripts/check-serve-fast-path-bundle.js). +import type { + ManagedToolExecutionResult, + ManagedToolInvocationStatus, + ManagedToolV2Client, +} from '@qwen-code/qwen-code-core/tools/managed-tool-runtime.js'; +import { + managedToolDigest, + type ManagedToolInvocationReference, +} from '@qwen-code/qwen-code-core/tools/managed-tool-protocol.js'; +import { isLoopbackBind } from './loopback-binds.js'; +import { + ManagedRuntimeProviderError, + type ManagedRuntimeExecutionIdentity, + type ManagedRuntimeExecutionInspection, + type ManagedRuntimeHandle, + type ManagedRuntimeProvider, + type ManagedRuntimeReleaseOptions, + type ManagedRuntimeToolClientContext, + type ManagedRuntimeUnknownResolution, +} from './managed-runtime-provider.js'; +import { + sameManagedRuntimeIdentity, + type ManagedRuntimePrepareRequest, +} from './managed-runtime-protocol.js'; + +export const MANAGED_RUNTIME_BROKER_PROTOCOL_VERSION = 1 as const; +export const MANAGED_RUNTIME_BROKER_ROUTE_PREFIX = + '/internal/runtime-broker/v1' as const; + +const MAX_BROKER_RESPONSE_BYTES = 8 * 1024 * 1024; +const BROKER_REQUEST_TIMEOUT_MS = 10 * 60_000; +const EXECUTION_POLL_DELAY_MS = 50; + +interface BrokerEnvelope { + readonly protocolVersion: typeof MANAGED_RUNTIME_BROKER_PROTOCOL_VERSION; + readonly harnessSessionId: string; + readonly runtimeSessionId: string; +} + +interface BrokerAcquireResponse extends BrokerEnvelope { + readonly acquired: true; +} + +interface BrokerControlResponse extends BrokerEnvelope { + readonly result: unknown; +} + +interface BrokerExecutionResponse extends BrokerEnvelope { + readonly executionCallId: string; + readonly status: ManagedToolInvocationStatus; +} + +interface BrokerReleaseResponse extends BrokerEnvelope { + readonly released: true; +} + +interface BrokerEntry { + readonly request: ManagedRuntimePrepareRequest; + readonly harnessSessionId: string; + readonly acquisition: Promise; + acquisitionFailed?: boolean; + readonly executions: Map; + client?: ManagedToolV2Client; + release?: Promise; + releasing?: boolean; + terminal?: boolean; +} + +interface BrokerExecution { + readonly referenceDigest: string; + readonly reserved: Promise<{ + executionCallId: string; + status: ManagedToolInvocationStatus; + }>; + started?: Promise; +} + +class BrokerResponseError extends Error { + constructor( + readonly status: number, + readonly code?: string, + readonly retryable?: boolean, + ) { + super(`Managed Runtime Broker returned HTTP ${status}.`); + this.name = 'BrokerResponseError'; + } +} + +export interface ManagedRuntimeBrokerClientOptions { + readonly baseUrl: string; + readonly token: string; + readonly fetch?: typeof fetch; +} + +export function resolveManagedRuntimeBrokerBaseUrl(raw: string): URL { + let url: URL; + try { + url = new URL(raw); + } catch { + throw new Error('Managed Runtime Broker URL is invalid.'); + } + if ( + (url.protocol !== 'http:' && url.protocol !== 'https:') || + url.username || + url.password || + url.search || + url.hash || + (url.pathname !== '/' && url.pathname !== '') + ) { + throw new Error('Managed Runtime Broker URL must be an HTTP(S) origin.'); + } + if (url.protocol === 'http:' && !isLoopbackBind(url.hostname)) { + throw new Error( + 'Managed Runtime Broker URL must use HTTPS outside the loopback interface.', + ); + } + url.pathname = '/'; + return url; +} + +async function readBoundedResponseText( + response: Response, + maxResponseBytes: number, +): Promise { + if (!response.body) return ''; + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let totalBytes = 0; + try { + while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + totalBytes += chunk.value.byteLength; + if (totalBytes > maxResponseBytes) { + await reader.cancel().catch(() => undefined); + throw new Error('Managed Runtime Broker response exceeded its limit.'); + } + chunks.push(chunk.value); + } + } finally { + reader.releaseLock(); + } + return Buffer.concat(chunks, totalBytes).toString('utf8'); +} + +function record(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Managed Runtime Broker returned an invalid response.'); + } + return value as Record; +} + +function boundedId(value: unknown, label: string): string { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > 512 || + value.includes('\0') + ) { + throw new Error(`Managed Runtime Broker returned an invalid ${label}.`); + } + return value; +} + +function parseExecutionResult(value: unknown): ManagedToolExecutionResult { + const input = record(value); + if ( + !['not_started', 'success', 'error', 'cancelled'].includes( + String(input['executionStatus']), + ) + ) { + throw new Error( + 'Managed Runtime Broker returned an invalid execution result.', + ); + } + return structuredClone(value) as ManagedToolExecutionResult; +} + +function parseInvocationStatus(value: unknown): ManagedToolInvocationStatus { + const input = record(value); + if ( + !['prepared', 'executing', 'cancel_requested', 'settled'].includes( + String(input['state']), + ) || + typeof input['cancelRequested'] !== 'boolean' || + !Number.isSafeInteger(input['lastSeq']) || + Number(input['lastSeq']) < 0 || + !Number.isSafeInteger(input['firstAvailableSeq']) || + Number(input['firstAvailableSeq']) < 0 || + typeof input['progressGap'] !== 'boolean' || + !Array.isArray(input['progress']) + ) { + throw new Error( + 'Managed Runtime Broker returned an invalid execution status.', + ); + } + for (const event of input['progress']) { + const progress = record(event); + if ( + !Number.isSafeInteger(progress['seq']) || + Number(progress['seq']) < 0 || + !('output' in progress) + ) { + throw new Error( + 'Managed Runtime Broker returned invalid execution progress.', + ); + } + } + if (input['state'] === 'settled') { + parseExecutionResult(input['result']); + } else if (input['result'] !== undefined) { + throw new Error( + 'Managed Runtime Broker returned a premature execution result.', + ); + } + return structuredClone(value) as ManagedToolInvocationStatus; +} + +function executionIdempotencyKey( + harnessSessionId: string, + reference: ManagedToolInvocationReference, +): string { + return createHash('sha256') + .update(harnessSessionId) + .update('\0') + .update(reference.promptId) + .update('\0') + .update(reference.callId) + .update('\0') + .update(reference.argsDigest) + .digest('hex'); +} + +export class ManagedRuntimeBrokerClient { + private readonly baseUrl: URL; + private readonly token: string; + private readonly fetchImpl: typeof fetch; + + constructor(options: ManagedRuntimeBrokerClientOptions) { + this.baseUrl = resolveManagedRuntimeBrokerBaseUrl(options.baseUrl); + this.token = options.token.trim(); + if (!this.token) + throw new Error('Managed Runtime Broker token is required.'); + this.fetchImpl = options.fetch ?? fetch; + } + + async acquire( + request: ManagedRuntimePrepareRequest, + harnessSessionId: string, + signal: AbortSignal, + ): Promise { + const response = await this.requestJson( + 'POST', + 'tool-sessions:acquire', + { + protocolVersion: MANAGED_RUNTIME_BROKER_PROTOCOL_VERSION, + requestId: randomUUID(), + harnessSessionId, + runtimeSessionId: request.sessionId, + turnKind: request.turnKind, + }, + signal, + ); + const envelope = this.parseEnvelope( + response, + harnessSessionId, + request.sessionId, + ) as BrokerAcquireResponse; + if (envelope.acquired !== true) { + throw new Error('Managed Runtime Broker did not acquire the Session.'); + } + } + + async control( + runtimeSessionId: string, + harnessSessionId: string, + operation: Record, + signal: AbortSignal, + ): Promise { + const response = await this.requestJson( + 'POST', + `tool-sessions/${encodeURIComponent(runtimeSessionId)}/control`, + { + protocolVersion: MANAGED_RUNTIME_BROKER_PROTOCOL_VERSION, + requestId: randomUUID(), + harnessSessionId, + operation, + }, + signal, + ); + return ( + this.parseEnvelope( + response, + harnessSessionId, + runtimeSessionId, + ) as BrokerControlResponse + ).result; + } + + async createExecution( + runtimeSessionId: string, + harnessSessionId: string, + reference: ManagedToolInvocationReference, + signal: AbortSignal, + ): Promise<{ executionCallId: string; status: ManagedToolInvocationStatus }> { + return this.submitExecution( + 'executions', + runtimeSessionId, + harnessSessionId, + reference, + signal, + ); + } + + async prepareExecution( + runtimeSessionId: string, + harnessSessionId: string, + reference: ManagedToolInvocationReference, + signal: AbortSignal, + ): Promise<{ executionCallId: string; status: ManagedToolInvocationStatus }> { + return this.submitExecution( + 'executions:prepare', + runtimeSessionId, + harnessSessionId, + reference, + signal, + ); + } + + async startExecution( + runtimeSessionId: string, + harnessSessionId: string, + executionCallId: string, + signal: AbortSignal, + ): Promise { + const response = await this.requestJson( + 'POST', + `executions/${encodeURIComponent(executionCallId)}:start`, + { + protocolVersion: MANAGED_RUNTIME_BROKER_PROTOCOL_VERSION, + requestId: randomUUID(), + harnessSessionId, + runtimeSessionId, + }, + signal, + ); + const envelope = this.parseEnvelope( + response, + harnessSessionId, + runtimeSessionId, + ) as BrokerExecutionResponse; + if (envelope.executionCallId !== executionCallId) { + throw new Error('Managed Runtime Broker execution identity changed.'); + } + return parseInvocationStatus(envelope.status); + } + + async getExecution( + runtimeSessionId: string, + harnessSessionId: string, + executionCallId: string, + afterSeq: number | undefined, + signal: AbortSignal, + ): Promise { + const query = new URLSearchParams({ + requestId: randomUUID(), + harnessSessionId, + runtimeSessionId, + }); + if (afterSeq !== undefined) query.set('afterSeq', String(afterSeq)); + const response = await this.requestJson( + 'GET', + `executions/${encodeURIComponent(executionCallId)}?${query}`, + undefined, + signal, + ); + const envelope = this.parseEnvelope( + response, + harnessSessionId, + runtimeSessionId, + ) as BrokerExecutionResponse; + if (envelope.executionCallId !== executionCallId) { + throw new Error('Managed Runtime Broker execution identity changed.'); + } + return parseInvocationStatus(envelope.status); + } + + async cancelExecution( + runtimeSessionId: string, + harnessSessionId: string, + executionCallId: string, + signal: AbortSignal, + ): Promise { + const response = await this.requestJson( + 'POST', + `executions/${encodeURIComponent(executionCallId)}:cancel`, + { + protocolVersion: MANAGED_RUNTIME_BROKER_PROTOCOL_VERSION, + requestId: randomUUID(), + harnessSessionId, + runtimeSessionId, + }, + signal, + ); + const envelope = this.parseEnvelope( + response, + harnessSessionId, + runtimeSessionId, + ) as BrokerExecutionResponse; + if (envelope.executionCallId !== executionCallId) { + throw new Error('Managed Runtime Broker execution identity changed.'); + } + return parseInvocationStatus(envelope.status); + } + + async resolveUnknownExecution( + runtimeSessionId: string, + harnessSessionId: string, + executionCallId: string, + resolution: 'confirmed_not_executed' | 'accepted_unknown', + signal: AbortSignal, + ): Promise { + const response = await this.requestJson( + 'POST', + `executions/${encodeURIComponent(executionCallId)}:resolve`, + { + protocolVersion: MANAGED_RUNTIME_BROKER_PROTOCOL_VERSION, + requestId: randomUUID(), + harnessSessionId, + runtimeSessionId, + resolution, + }, + signal, + ); + const envelope = this.parseEnvelope( + response, + harnessSessionId, + runtimeSessionId, + ) as BrokerExecutionResponse; + if (envelope.executionCallId !== executionCallId) { + throw new Error('Managed Runtime Broker execution identity changed.'); + } + return parseInvocationStatus(envelope.status); + } + + async release( + runtimeSessionId: string, + harnessSessionId: string, + signal: AbortSignal, + ): Promise { + const response = await this.requestJson( + 'POST', + `tool-sessions/${encodeURIComponent(runtimeSessionId)}:release`, + { + protocolVersion: MANAGED_RUNTIME_BROKER_PROTOCOL_VERSION, + requestId: randomUUID(), + harnessSessionId, + }, + signal, + ); + const envelope = this.parseEnvelope( + response, + harnessSessionId, + runtimeSessionId, + ) as BrokerReleaseResponse; + return envelope.released === true; + } + + private async submitExecution( + path: 'executions' | 'executions:prepare', + runtimeSessionId: string, + harnessSessionId: string, + reference: ManagedToolInvocationReference, + signal: AbortSignal, + ): Promise<{ executionCallId: string; status: ManagedToolInvocationStatus }> { + const body = { + protocolVersion: MANAGED_RUNTIME_BROKER_PROTOCOL_VERSION, + requestId: randomUUID(), + idempotencyKey: executionIdempotencyKey(harnessSessionId, reference), + harnessSessionId, + runtimeSessionId, + turnId: reference.promptId, + toolCallId: reference.callId, + requestDigest: reference.argsDigest, + reference, + }; + let response: unknown; + try { + response = await this.requestJson('POST', path, body, signal); + } catch (error) { + if ( + signal.aborted || + (error instanceof BrokerResponseError && error.status < 500) + ) { + throw error; + } + response = await this.requestJson('POST', path, body, signal); + } + const envelope = this.parseEnvelope( + response, + harnessSessionId, + runtimeSessionId, + ) as BrokerExecutionResponse; + return { + executionCallId: boundedId(envelope.executionCallId, 'executionCallId'), + status: parseInvocationStatus(envelope.status), + }; + } + + private parseEnvelope( + value: unknown, + harnessSessionId: string, + runtimeSessionId: string, + ): BrokerEnvelope { + const input = record(value); + if ( + input['protocolVersion'] !== MANAGED_RUNTIME_BROKER_PROTOCOL_VERSION || + input['harnessSessionId'] !== harnessSessionId || + input['runtimeSessionId'] !== runtimeSessionId + ) { + throw new Error('Managed Runtime Broker response identity changed.'); + } + return input as unknown as BrokerEnvelope; + } + + private async requestJson( + method: 'GET' | 'POST', + path: string, + body: unknown, + signal: AbortSignal, + ): Promise { + const response = await this.fetchImpl( + new URL( + `${MANAGED_RUNTIME_BROKER_ROUTE_PREFIX.slice(1)}/${path}`, + this.baseUrl, + ), + { + method, + headers: { + authorization: `Bearer ${this.token}`, + ...(body === undefined ? {} : { 'content-type': 'application/json' }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + signal, + }, + ); + if (!response.ok) { + let code: string | undefined; + let retryable: boolean | undefined; + try { + const text = await readBoundedResponseText( + response, + MAX_BROKER_RESPONSE_BYTES, + ); + const body = record(JSON.parse(text) as unknown); + if ( + typeof body['code'] === 'string' && + body['code'].length > 0 && + body['code'].length <= 512 && + !body['code'].includes('\0') + ) { + code = body['code']; + } + if (typeof body['retryable'] === 'boolean') { + retryable = body['retryable']; + } + } catch { + await response.body?.cancel().catch(() => undefined); + } + throw new BrokerResponseError(response.status, code, retryable); + } + const contentLength = Number(response.headers.get('content-length')); + if ( + Number.isFinite(contentLength) && + contentLength > MAX_BROKER_RESPONSE_BYTES + ) { + await response.body?.cancel().catch(() => undefined); + throw new Error('Managed Runtime Broker response exceeded its limit.'); + } + const text = await readBoundedResponseText( + response, + MAX_BROKER_RESPONSE_BYTES, + ); + try { + return JSON.parse(text) as unknown; + } catch { + throw new Error('Managed Runtime Broker returned invalid JSON.'); + } + } +} + +export type BrokerManagedRuntimeProviderOptions = + ManagedRuntimeBrokerClientOptions; + +export class BrokerManagedRuntimeProvider implements ManagedRuntimeProvider { + private readonly client: ManagedRuntimeBrokerClient; + private readonly entries = new Map(); + private readonly closedSessions = new Map< + string, + ManagedRuntimePrepareRequest + >(); + private readonly lifetime = new AbortController(); + + constructor(options: BrokerManagedRuntimeProviderOptions) { + this.client = new ManagedRuntimeBrokerClient(options); + } + + prepare(_request: ManagedRuntimePrepareRequest): ManagedRuntimeHandle { + throw new ManagedRuntimeProviderError( + 'managed_runtime_unavailable', + 'Hosted Harness supports only the Managed Tool v2 Broker path.', + false, + ); + } + + async getToolV2Client( + request: ManagedRuntimePrepareRequest, + context?: ManagedRuntimeToolClientContext, + ): Promise { + this.lifetime.signal.throwIfAborted(); + const harnessSessionId = context?.harnessSessionId.trim(); + if (!harnessSessionId) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Hosted Harness Session identity is required.', + false, + ); + } + const closed = this.closedSessions.get(request.sessionId); + if (closed) { + throw new ManagedRuntimeProviderError( + sameManagedRuntimeIdentity(closed, request) + ? 'managed_runtime_unavailable' + : 'managed_runtime_identity_conflict', + 'Managed Runtime Broker Session is permanently closed.', + false, + ); + } + let entry = this.entries.get(request.sessionId); + if ( + entry && + (!sameManagedRuntimeIdentity(entry.request, request) || + entry.harnessSessionId !== harnessSessionId) + ) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Managed Runtime Broker Session identity changed.', + false, + ); + } + if (entry?.releasing) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_unavailable', + 'Managed Runtime Broker Session is being released.', + false, + ); + } + if (entry?.acquisitionFailed) { + this.entries.delete(request.sessionId); + entry = undefined; + } + if (!entry) { + const immutableRequest = structuredClone(request); + const acquisition = this.client.acquire( + immutableRequest, + harnessSessionId, + AbortSignal.any([ + this.lifetime.signal, + AbortSignal.timeout(BROKER_REQUEST_TIMEOUT_MS), + ]), + ); + entry = { + request: immutableRequest, + harnessSessionId, + acquisition, + executions: new Map(), + }; + this.entries.set(request.sessionId, entry); + const acquiredEntry = entry; + void acquisition.catch(() => { + acquiredEntry.acquisitionFailed = true; + }); + } + await entry.acquisition; + this.lifetime.signal.throwIfAborted(); + entry.client ??= this.createToolClient(entry); + return entry.client; + } + + async inspectExecution( + identity: ManagedRuntimeExecutionIdentity, + ): Promise { + this.lifetime.signal.throwIfAborted(); + try { + const status = await this.client.getExecution( + identity.runtimeSessionId, + identity.harnessSessionId, + identity.executionCallId, + identity.afterSeq, + AbortSignal.any([ + this.lifetime.signal, + AbortSignal.timeout(BROKER_REQUEST_TIMEOUT_MS), + ]), + ); + return { outcome: 'known', status }; + } catch (error) { + if ( + error instanceof BrokerResponseError && + error.code === 'runtime_broker_execution_unknown' + ) { + return { outcome: 'unknown' }; + } + throw error; + } + } + + async reconcileExecution( + identity: ManagedRuntimeExecutionIdentity, + ): Promise { + this.lifetime.signal.throwIfAborted(); + const signal = AbortSignal.any([ + this.lifetime.signal, + AbortSignal.timeout(BROKER_REQUEST_TIMEOUT_MS), + ]); + try { + let status = await this.client.getExecution( + identity.runtimeSessionId, + identity.harnessSessionId, + identity.executionCallId, + identity.afterSeq, + signal, + ); + if (status.state === 'prepared') { + status = await this.client.startExecution( + identity.runtimeSessionId, + identity.harnessSessionId, + identity.executionCallId, + signal, + ); + } + while (status.state !== 'settled') { + await delay(EXECUTION_POLL_DELAY_MS, undefined, { signal }); + status = await this.client.getExecution( + identity.runtimeSessionId, + identity.harnessSessionId, + identity.executionCallId, + undefined, + signal, + ); + } + return { outcome: 'known', status }; + } catch (error) { + if ( + error instanceof BrokerResponseError && + error.code === 'runtime_broker_execution_unknown' + ) { + return { outcome: 'unknown' }; + } + throw error; + } + } + + async cancelExecution( + identity: ManagedRuntimeExecutionIdentity, + ): Promise { + this.lifetime.signal.throwIfAborted(); + const signal = AbortSignal.any([ + this.lifetime.signal, + AbortSignal.timeout(BROKER_REQUEST_TIMEOUT_MS), + ]); + try { + let status = await this.client.getExecution( + identity.runtimeSessionId, + identity.harnessSessionId, + identity.executionCallId, + identity.afterSeq, + signal, + ); + if (status.state !== 'settled') { + status = await this.client.cancelExecution( + identity.runtimeSessionId, + identity.harnessSessionId, + identity.executionCallId, + signal, + ); + } + while (status.state !== 'settled') { + await delay(EXECUTION_POLL_DELAY_MS, undefined, { signal }); + status = await this.client.getExecution( + identity.runtimeSessionId, + identity.harnessSessionId, + identity.executionCallId, + undefined, + signal, + ); + } + return { outcome: 'known', status }; + } catch (error) { + if ( + error instanceof BrokerResponseError && + error.code === 'runtime_broker_execution_unknown' + ) { + return { outcome: 'unknown' }; + } + throw error; + } + } + + async resolveExecution( + identity: ManagedRuntimeExecutionIdentity, + resolution: ManagedRuntimeUnknownResolution, + ): Promise { + this.lifetime.signal.throwIfAborted(); + const status = await this.client.resolveUnknownExecution( + identity.runtimeSessionId, + identity.harnessSessionId, + identity.executionCallId, + resolution, + AbortSignal.any([ + this.lifetime.signal, + AbortSignal.timeout(BROKER_REQUEST_TIMEOUT_MS), + ]), + ); + return { outcome: 'known', status }; + } + + async cancel( + _sessionId: string, + _executionId: string, + _expected?: ManagedRuntimePrepareRequest, + ): Promise { + return false; + } + + async release( + sessionId: string, + expected?: ManagedRuntimePrepareRequest, + options?: ManagedRuntimeReleaseOptions, + ): Promise { + const entry = this.entries.get(sessionId); + const closed = this.closedSessions.get(sessionId); + if (closed) { + if (expected && !sameManagedRuntimeIdentity(closed, expected)) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Managed Runtime Broker Session identity changed.', + false, + ); + } + return true; + } + if (!entry) return false; + if (expected && !sameManagedRuntimeIdentity(entry.request, expected)) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Managed Runtime Broker Session identity changed.', + false, + ); + } + entry.releasing = true; + entry.terminal ||= options?.terminal === true; + if (entry.release) return entry.release; + const release = (async () => { + await entry.acquisition.catch(() => {}); + this.lifetime.signal.throwIfAborted(); + const released = await this.client.release( + sessionId, + entry.harnessSessionId, + AbortSignal.any([ + this.lifetime.signal, + AbortSignal.timeout(BROKER_REQUEST_TIMEOUT_MS), + ]), + ); + if (released) { + this.entries.delete(sessionId); + if (entry.terminal) { + this.closedSessions.set(sessionId, entry.request); + } + } + return released; + })().finally(() => { + if (entry.release === release) entry.release = undefined; + }); + entry.release = release; + return release; + } + + dispose(): void { + if (this.lifetime.signal.aborted) return; + this.lifetime.abort(new Error('Managed Runtime Broker provider disposed.')); + this.entries.clear(); + this.closedSessions.clear(); + } + + private createToolClient(entry: BrokerEntry): ManagedToolV2Client { + const assertEntry = (allowDraining = false) => { + this.lifetime.signal.throwIfAborted(); + if ( + this.entries.get(entry.request.sessionId) !== entry || + (!allowDraining && entry.releasing) + ) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_unavailable', + 'Managed Runtime Broker Session is being released or is closed.', + false, + ); + } + }; + const control = (operation: Record) => { + assertEntry(operation['kind'] === 'history'); + return this.client.control( + entry.request.sessionId, + entry.harnessSessionId, + operation, + AbortSignal.any([ + this.lifetime.signal, + AbortSignal.timeout(BROKER_REQUEST_TIMEOUT_MS), + ]), + ); + }; + const ensureExecution = ( + reference: ManagedToolInvocationReference, + allowDraining = false, + ): BrokerExecution => { + assertEntry(allowDraining); + this.assertReference(entry, reference); + const referenceDigest = managedToolDigest(reference); + let execution = entry.executions.get(reference.invocationId); + if (execution && execution.referenceDigest !== referenceDigest) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Managed Runtime Broker invocation identity changed.', + false, + ); + } + if (!execution) { + assertEntry(); + execution = { + referenceDigest, + reserved: this.client.prepareExecution( + entry.request.sessionId, + entry.harnessSessionId, + reference, + AbortSignal.any([ + this.lifetime.signal, + AbortSignal.timeout(BROKER_REQUEST_TIMEOUT_MS), + ]), + ), + }; + entry.executions.set(reference.invocationId, execution); + } + return execution; + }; + const readExecution = async ( + reference: ManagedToolInvocationReference, + afterSeq?: number, + ) => { + const execution = ensureExecution(reference, true); + const reserved = await execution.reserved; + if (reserved.status.state === 'settled' && afterSeq === undefined) { + return reserved.status; + } + return this.client.getExecution( + entry.request.sessionId, + entry.harnessSessionId, + reserved.executionCallId, + afterSeq, + AbortSignal.any([ + this.lifetime.signal, + AbortSignal.timeout(BROKER_REQUEST_TIMEOUT_MS), + ]), + ); + }; + const startExecution = async ( + reference: ManagedToolInvocationReference, + executionCallId?: string, + ): Promise => { + const execution = ensureExecution(reference); + const reserved = await execution.reserved; + if ( + executionCallId !== undefined && + reserved.executionCallId !== executionCallId + ) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Managed Runtime Broker execution identity changed.', + false, + ); + } + execution.started ??= (async () => { + assertEntry(); + let status = await this.client.startExecution( + entry.request.sessionId, + entry.harnessSessionId, + reserved.executionCallId, + AbortSignal.any([ + this.lifetime.signal, + AbortSignal.timeout(BROKER_REQUEST_TIMEOUT_MS), + ]), + ); + while (status.state !== 'settled') { + await delay(EXECUTION_POLL_DELAY_MS, undefined, { + signal: this.lifetime.signal, + }); + status = await readExecution(reference); + } + return parseExecutionResult(status.result); + })(); + return execution.started; + }; + return { + fileHistory: { + bind: (binding) => control({ kind: 'bind-history', binding }), + checkpoint: (promptId) => control({ kind: 'checkpoint', promptId }), + snapshot: () => control({ kind: 'history' }), + }, + manifest: () => control({ kind: 'manifest' }), + beginTurn: async (identity) => { + await control({ kind: 'begin-turn', identity }); + }, + prepare: (identity, toolName, input, modification, mediaContext) => + control({ + kind: 'prepare', + identity, + toolName, + input, + ...(modification === undefined ? {} : { modification }), + ...(mediaContext === undefined ? {} : { mediaContext }), + }), + confirmation: (reference) => control({ kind: 'confirmation', reference }), + confirm: async (reference, outcome, payload, phase) => { + await control({ + kind: 'confirm', + reference, + outcome, + ...(payload === undefined ? {} : { payload }), + ...(phase === undefined ? {} : { phase }), + }); + }, + preflight: (reference) => control({ kind: 'preflight', reference }), + prepareExecution: async (reference) => { + const reserved = await ensureExecution(reference).reserved; + return { + executionCallId: reserved.executionCallId, + invocationBindingId: entry.request.sessionId, + }; + }, + startExecution: (reference, executionCallId) => + startExecution(reference, executionCallId), + execute: (reference) => startExecution(reference), + status: (reference, afterSeq) => readExecution(reference, afterSeq), + cancel: async (reference) => { + const reserved = await ensureExecution(reference, true).reserved; + return this.client.cancelExecution( + entry.request.sessionId, + entry.harnessSessionId, + reserved.executionCallId, + AbortSignal.any([ + this.lifetime.signal, + AbortSignal.timeout(BROKER_REQUEST_TIMEOUT_MS), + ]), + ); + }, + } as ManagedToolV2Client; + } + + private assertReference( + entry: BrokerEntry, + reference: ManagedToolInvocationReference, + ): void { + if (reference.sessionId !== entry.request.sessionId) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Managed Runtime Broker invocation belongs to another Session.', + false, + ); + } + } +} diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts index a1680ff1071..01827f49451 100644 --- a/packages/cli/src/serve/capabilities.ts +++ b/packages/cli/src/serve/capabilities.ts @@ -34,6 +34,7 @@ export const SERVE_CAPABILITY_REGISTRY = { daemon_status: { since: 'v1' }, capabilities: { since: 'v1' }, session_create: { since: 'v1' }, + session_startup_config: { since: 'v1' }, session_id_override: { since: 'v1' }, session_scope_override: { since: 'v1' }, session_load: { since: 'v1' }, diff --git a/packages/cli/src/serve/contracts/managed-context-v1.fixtures.json b/packages/cli/src/serve/contracts/managed-context-v1.fixtures.json new file mode 100644 index 00000000000..b62abb25d90 --- /dev/null +++ b/packages/cli/src/serve/contracts/managed-context-v1.fixtures.json @@ -0,0 +1,16770 @@ +{ + "contractVersion": 1, + "managedContext": "managed-context/1", + "bootVersion": 2, + "readyVersion": 2, + "routes": [ + { + "key": "attest", + "method": "POST", + "path": "/internal/managed-runtime/v3/attest", + "protocolVersion": 3, + "requestBodyLimitBytes": 16384, + "responseBodyLimitBytes": 16384, + "cacheControl": "no-store" + }, + { + "key": "context", + "method": "POST", + "path": "/internal/managed-runtime/v3/context", + "protocolVersion": 3, + "requestBodyLimitBytes": 16384, + "responseBodyLimitBytes": 16384, + "cacheControl": "no-store" + } + ], + "errors": [ + { + "status": 400, + "code": "managed_runtime_attestation_invalid", + "classification": "protocol" + }, + { + "status": 401, + "code": "managed_runtime_unauthorized", + "classification": "credentials" + }, + { + "status": 404, + "code": null, + "classification": "incompatible" + }, + { + "status": 409, + "code": "managed_runtime_identity_conflict", + "classification": "identity" + }, + { + "status": 409, + "code": "managed_context_conflict", + "classification": "identity" + }, + { + "status": 409, + "code": "managed_context_unavailable", + "classification": "recovery" + }, + { + "status": 413, + "code": "managed_runtime_attestation_too_large", + "classification": "protocol" + } + ], + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "ready": { + "type": "ready", + "version": 2, + "managedContext": "managed-context/1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "leaseId": "lease-01", + "epoch": 4, + "url": "http://127.0.0.1:43123" + }, + "attestationResponse": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "leaseId": "lease-01", + "epoch": 4, + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace" + }, + "bootCases": [ + { + "id": "canonical", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": true + }, + { + "id": "session-isolation", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "session", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": true + }, + { + "id": "largest-safe-epoch", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 9007199254740991, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": true + }, + { + "id": "largest-generation", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "9223372036854775807", + "workspaceId": "workspace-a" + }, + "valid": true + }, + { + "id": "storage-at-256", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "ssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": true + }, + { + "id": "identifier-at-128", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww" + }, + "valid": true + }, + { + "id": "windows-backslash-root", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "C:\\workspaces\\a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": true + }, + { + "id": "windows-slash-root", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "d:/workspaces/a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": true + }, + { + "id": "unc-root", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "\\\\server\\share\\a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": true + }, + { + "id": "root-directory", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": true + }, + { + "id": "mount-root-at-4096-bytes", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": true + }, + { + "id": "astral-mount-root-at-4096-bytes", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1eabc", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": true + }, + { + "id": "non-ascii-mount-root", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/srv/\u9879\u76ee/\u00e9", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": true + }, + { + "id": "mount-root-with-space-tilde-and-nbsp", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/srv/my work~\u00a0a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": true + }, + { + "id": "mount-root-around-surrogates", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/srv/\ud7ff\ue000", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": true + }, + { + "id": "runtime-ids-at-128", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "llllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp", + "runtimeIncarnation": "nnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn", + "runtimeInstanceId": "iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": true + }, + { + "id": "uuid-runtime-ids", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "9e8d7c6b-5a4f-4e3d-b2c1-0f9e8d7c6b5a", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "11111111-2222-4333-8444-555555555555", + "runtimeIncarnation": "0b1c2d3e-4f5a-4b6c-8d7e-9f0a1b2c3d4e", + "runtimeInstanceId": "3f2b8c1e-5d4a-4e6f-9a7b-1c2d3e4f5a6b", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": true + }, + { + "id": "token68-symbols", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "a.b_c~d+e/f-g==", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": true + }, + { + "id": "token-at-512", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "tttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttt", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": true + }, + { + "id": "missing-capabilitydigest", + "boot": { + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "missing-epoch", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "missing-isolationclass", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "missing-leaseid", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "missing-managedcontext", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "missing-mountroot", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "missing-provisionrequestid", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "missing-runtimeincarnation", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "missing-runtimeinstanceid", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "missing-storageid", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "missing-tenantid", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "missing-token", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "missing-type", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "missing-version", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "missing-workspacegeneration", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "missing-workspaceid", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7" + }, + "valid": false + }, + { + "id": "empty-token", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "numeric-token", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": 1, + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "token-in-array", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": ["fixture-token"], + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "empty-runtime-instance", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "numeric-runtime-instance", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": 1, + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "runtime-instance-in-array", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": ["runtime-01"], + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "empty-runtime-incarnation", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "numeric-runtime-incarnation", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": 1, + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "runtime-incarnation-in-array", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": ["boot-01"], + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "empty-lease", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "numeric-lease", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": 1, + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "lease-in-array", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": ["lease-01"], + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "empty-provision-request", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "numeric-provision-request", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": 1, + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "provision-request-in-array", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": ["provision-01"], + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "runtime-instance-over-128", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "runtime-instance-with-slash", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01/x", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "runtime-incarnation-over-128", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "runtime-incarnation-with-slash", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01/x", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "lease-over-128", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "lease-with-slash", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01/x", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "provision-request-over-128", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "provision-request-with-slash", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01/x", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "extra-key", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a", + "workspaceCwd": "/runtime/workspaces/workspace-a" + }, + "valid": false + }, + { + "id": "boot-v1-marker", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 1, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "version-as-string", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": "2", + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "version-three", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 3, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "wrong-type", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "Boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "wrong-managed-context", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/2", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "token-with-space", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "token-over-512", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "ttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttt", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "token-with-inner-equals", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "a=b", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "non-ascii-token", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-t\u00f6ken", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "zero-epoch", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 0, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "negative-epoch", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": -1, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "fractional-version", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2.5, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "unsafe-epoch", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 9007199254740992, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "fractional-epoch", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 1.5, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "string-epoch", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": "4", + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "boolean-epoch", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": true, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "uppercase-capability-digest", + "boot": { + "capabilityDigest": "sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "capability-digest-in-array", + "boot": { + "capabilityDigest": [ + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ], + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "capability-digest-with-suffix", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "short-capability-digest", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "capability-digest-with-trailing-newline", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "capability-digest-with-prefix", + "boot": { + "capabilityDigest": "xsha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "unknown-isolation", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "tenant", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "tenant-with-slash", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant/a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "tenant-over-128", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "ttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttt", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "empty-tenant", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "workspace-over-128", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww" + }, + "valid": false + }, + { + "id": "workspace-with-slash", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace/a" + }, + "valid": false + }, + { + "id": "empty-workspace", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "" + }, + "valid": false + }, + { + "id": "empty-generation", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "empty-storage", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "generation-leading-zero", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "07", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "generation-zero", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "0", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "generation-over-int64", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "9223372036854775808", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "generation-as-number", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": 7, + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "storage-with-space", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "storage-over-256", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "sssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "relative-mount-root", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "runtime/workspaces/a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "empty-mount-root", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "drive-relative-mount-root", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "C:workspaces", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "single-backslash-mount-root", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "\\workspaces", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "mount-root-with-nul", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/\u0000a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "mount-root-with-newline", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/\na", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "mount-root-with-c1", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/\u0085a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "mount-root-with-unit-separator", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/\u001fa", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "mount-root-with-del", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/\u007fa", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "mount-root-with-last-c1", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/\u009fa", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "mount-root-with-lone-surrogate", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/\ud800", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "mount-root-with-lone-low-surrogate", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/\udc00a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "mount-root-with-last-lone-high-surrogate", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/\udbffa", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "mount-root-with-last-lone-low-surrogate", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/\udfff", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "mount-root-with-reversed-surrogates", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/\udc00\ud800", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "unc-root-with-mixed-separators", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "\\/server/share", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "mount-root-over-4096-bytes", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "astral-mount-root-over-4096-bytes", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "two-byte-mount-root-over-4096-bytes", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "numeric-mount-root", + "boot": { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": 1, + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + }, + "valid": false + }, + { + "id": "array-document", + "boot": [ + { + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": 4, + "isolationClass": "workspace", + "leaseId": "lease-01", + "managedContext": "managed-context/1", + "mountRoot": "/runtime/workspaces/workspace-a", + "provisionRequestId": "provision-01", + "runtimeIncarnation": "boot-01", + "runtimeInstanceId": "runtime-01", + "storageId": "storage://pvc/workspace-a", + "tenantId": "tenant-a", + "token": "fixture-token", + "type": "boot", + "version": 2, + "workspaceGeneration": "7", + "workspaceId": "workspace-a" + } + ], + "valid": false + }, + { + "id": "null-document", + "boot": null, + "valid": false + } + ], + "readyCases": [ + { + "id": "canonical", + "ready": { + "type": "ready", + "version": 2, + "managedContext": "managed-context/1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "leaseId": "lease-01", + "epoch": 4, + "url": "http://127.0.0.1:43123" + }, + "valid": true + }, + { + "id": "missing-epoch", + "ready": { + "type": "ready", + "version": 2, + "managedContext": "managed-context/1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "leaseId": "lease-01", + "url": "http://127.0.0.1:43123" + }, + "valid": false + }, + { + "id": "missing-lease-id", + "ready": { + "type": "ready", + "version": 2, + "managedContext": "managed-context/1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "url": "http://127.0.0.1:43123" + }, + "valid": false + }, + { + "id": "missing-managed-context", + "ready": { + "type": "ready", + "version": 2, + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "leaseId": "lease-01", + "epoch": 4, + "url": "http://127.0.0.1:43123" + }, + "valid": false + }, + { + "id": "missing-runtime-incarnation", + "ready": { + "type": "ready", + "version": 2, + "managedContext": "managed-context/1", + "runtimeInstanceId": "runtime-01", + "leaseId": "lease-01", + "epoch": 4, + "url": "http://127.0.0.1:43123" + }, + "valid": false + }, + { + "id": "missing-runtime-instance-id", + "ready": { + "type": "ready", + "version": 2, + "managedContext": "managed-context/1", + "runtimeIncarnation": "boot-01", + "leaseId": "lease-01", + "epoch": 4, + "url": "http://127.0.0.1:43123" + }, + "valid": false + }, + { + "id": "missing-type", + "ready": { + "version": 2, + "managedContext": "managed-context/1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "leaseId": "lease-01", + "epoch": 4, + "url": "http://127.0.0.1:43123" + }, + "valid": false + }, + { + "id": "missing-url", + "ready": { + "type": "ready", + "version": 2, + "managedContext": "managed-context/1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "leaseId": "lease-01", + "epoch": 4 + }, + "valid": false + }, + { + "id": "missing-version", + "ready": { + "type": "ready", + "managedContext": "managed-context/1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "leaseId": "lease-01", + "epoch": 4, + "url": "http://127.0.0.1:43123" + }, + "valid": false + }, + { + "id": "largest-port", + "ready": { + "type": "ready", + "version": 2, + "managedContext": "managed-context/1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "leaseId": "lease-01", + "epoch": 4, + "url": "http://127.0.0.1:65535" + }, + "valid": true + }, + { + "id": "port-one", + "ready": { + "type": "ready", + "version": 2, + "managedContext": "managed-context/1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "leaseId": "lease-01", + "epoch": 4, + "url": "http://127.0.0.1:1" + }, + "valid": true + }, + { + "id": "extra-key", + "ready": { + "type": "ready", + "version": 2, + "managedContext": "managed-context/1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "leaseId": "lease-01", + "epoch": 4, + "url": "http://127.0.0.1:43123", + "token": "fixture-token" + }, + "valid": false + }, + { + "id": "wrong-managed-context", + "ready": { + "type": "ready", + "version": 2, + "managedContext": "managed-context/2", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "leaseId": "lease-01", + "epoch": 4, + "url": "http://127.0.0.1:43123" + }, + "valid": false + }, + { + "id": "ready-v1-marker", + "ready": { + "type": "ready", + "version": 1, + "managedContext": "managed-context/1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "leaseId": "lease-01", + "epoch": 4, + "url": "http://127.0.0.1:43123" + }, + "valid": false + }, + { + "id": "wrong-type", + "ready": { + "type": "boot", + "version": 2, + "managedContext": "managed-context/1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "leaseId": "lease-01", + "epoch": 4, + "url": "http://127.0.0.1:43123" + }, + "valid": false + }, + { + "id": "https-url", + "ready": { + "type": "ready", + "version": 2, + "managedContext": "managed-context/1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "leaseId": "lease-01", + "epoch": 4, + "url": "https://127.0.0.1:43123" + }, + "valid": false + }, + { + "id": "localhost-url", + "ready": { + "type": "ready", + "version": 2, + "managedContext": "managed-context/1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "leaseId": "lease-01", + "epoch": 4, + "url": "http://localhost:43123" + }, + "valid": false + }, + { + "id": "url-with-path", + "ready": { + "type": "ready", + "version": 2, + "managedContext": "managed-context/1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "leaseId": "lease-01", + "epoch": 4, + "url": "http://127.0.0.1:43123/" + }, + "valid": false + }, + { + "id": "url-with-query", + "ready": { + "type": "ready", + "version": 2, + "managedContext": "managed-context/1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "leaseId": "lease-01", + "epoch": 4, + "url": "http://127.0.0.1:43123?x=1" + }, + "valid": false + }, + { + "id": "url-with-user", + "ready": { + "type": "ready", + "version": 2, + "managedContext": "managed-context/1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "leaseId": "lease-01", + "epoch": 4, + "url": "http://user@127.0.0.1:43123" + }, + "valid": false + }, + { + "id": "port-zero", + "ready": { + "type": "ready", + "version": 2, + "managedContext": "managed-context/1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "leaseId": "lease-01", + "epoch": 4, + "url": "http://127.0.0.1:0" + }, + "valid": false + }, + { + "id": "port-over-65535", + "ready": { + "type": "ready", + "version": 2, + "managedContext": "managed-context/1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "leaseId": "lease-01", + "epoch": 4, + "url": "http://127.0.0.1:65536" + }, + "valid": false + }, + { + "id": "port-leading-zero", + "ready": { + "type": "ready", + "version": 2, + "managedContext": "managed-context/1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "leaseId": "lease-01", + "epoch": 4, + "url": "http://127.0.0.1:043123" + }, + "valid": false + }, + { + "id": "url-without-port", + "ready": { + "type": "ready", + "version": 2, + "managedContext": "managed-context/1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "leaseId": "lease-01", + "epoch": 4, + "url": "http://127.0.0.1" + }, + "valid": false + }, + { + "id": "url-in-array", + "ready": { + "type": "ready", + "version": 2, + "managedContext": "managed-context/1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "leaseId": "lease-01", + "epoch": 4, + "url": ["http://127.0.0.1:43123"] + }, + "valid": false + }, + { + "id": "url-with-leading-space", + "ready": { + "type": "ready", + "version": 2, + "managedContext": "managed-context/1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "leaseId": "lease-01", + "epoch": 4, + "url": " http://127.0.0.1:43123" + }, + "valid": false + }, + { + "id": "url-with-lookalike-host", + "ready": { + "type": "ready", + "version": 2, + "managedContext": "managed-context/1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "leaseId": "lease-01", + "epoch": 4, + "url": "http://127-0-0-1:43123" + }, + "valid": false + }, + { + "id": "url-with-trailing-newline", + "ready": { + "type": "ready", + "version": 2, + "managedContext": "managed-context/1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "leaseId": "lease-01", + "epoch": 4, + "url": "http://127.0.0.1:43123\n" + }, + "valid": false + }, + { + "id": "fractional-epoch", + "ready": { + "type": "ready", + "version": 2, + "managedContext": "managed-context/1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "leaseId": "lease-01", + "epoch": 4.5, + "url": "http://127.0.0.1:43123" + }, + "valid": false + }, + { + "id": "fractional-version", + "ready": { + "type": "ready", + "version": 2.5, + "managedContext": "managed-context/1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "leaseId": "lease-01", + "epoch": 4, + "url": "http://127.0.0.1:43123" + }, + "valid": false + }, + { + "id": "other-instance", + "ready": { + "type": "ready", + "version": 2, + "managedContext": "managed-context/1", + "runtimeInstanceId": "runtime-02", + "runtimeIncarnation": "boot-01", + "leaseId": "lease-01", + "epoch": 4, + "url": "http://127.0.0.1:43123" + }, + "valid": false + }, + { + "id": "other-incarnation", + "ready": { + "type": "ready", + "version": 2, + "managedContext": "managed-context/1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-02", + "leaseId": "lease-01", + "epoch": 4, + "url": "http://127.0.0.1:43123" + }, + "valid": false + }, + { + "id": "other-lease", + "ready": { + "type": "ready", + "version": 2, + "managedContext": "managed-context/1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "leaseId": "lease-02", + "epoch": 4, + "url": "http://127.0.0.1:43123" + }, + "valid": false + }, + { + "id": "other-epoch", + "ready": { + "type": "ready", + "version": 2, + "managedContext": "managed-context/1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "leaseId": "lease-01", + "epoch": 5, + "url": "http://127.0.0.1:43123" + }, + "valid": false + }, + { + "id": "string-epoch", + "ready": { + "type": "ready", + "version": 2, + "managedContext": "managed-context/1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "leaseId": "lease-01", + "epoch": "4", + "url": "http://127.0.0.1:43123" + }, + "valid": false + } + ], + "attestationCases": [ + { + "id": "canonical", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "leaseId": "lease-01", + "epoch": 4, + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace" + } + } + }, + { + "id": "missing-capabilitydigest", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/workspaces/workspace-a", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "missing-isolationclass", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "missing-managedcontext", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3 + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "missing-mountroot", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "missing-protocolversion", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "missing-provisionrequestid", + "body": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "missing-storageid", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "missing-tenantid", + "body": { + "provisionRequestId": "provision-01", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "missing-workspacegeneration", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "missing-workspaceid", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "extra-workspace-cwd", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1", + "workspaceCwd": "/runtime/workspaces/workspace-a" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "protocol-two", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 2, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "protocol-as-string", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": "3", + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "wrong-managed-context", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/2" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "empty-provision-request", + "body": { + "provisionRequestId": "", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "tenant-pattern", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "generation-leading-zero", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "07", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "generation-as-number", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": 7, + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "storage-with-space", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage a", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "relative-mount-root", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "uppercase-capability-digest", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "unknown-isolation", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "tenant", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "other-provision-request", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 409, + "code": "managed_runtime_identity_conflict" + } + }, + { + "id": "other-tenant", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-b", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 409, + "code": "managed_runtime_identity_conflict" + } + }, + { + "id": "other-workspace", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-b", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 409, + "code": "managed_runtime_identity_conflict" + } + }, + { + "id": "other-generation", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 409, + "code": "managed_runtime_identity_conflict" + } + }, + { + "id": "other-storage", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 409, + "code": "managed_runtime_identity_conflict" + } + }, + { + "id": "mount-root-trailing-slash", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/workspaces/workspace-a/", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 409, + "code": "managed_runtime_identity_conflict" + } + }, + { + "id": "mount-root-other-case", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/Runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 409, + "code": "managed_runtime_identity_conflict" + } + }, + { + "id": "other-capability-digest", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 409, + "code": "managed_runtime_identity_conflict" + } + }, + { + "id": "other-isolation", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 409, + "code": "managed_runtime_identity_conflict" + } + }, + { + "id": "bad-shape-before-other-tenant", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-b", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage a", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "fractional-protocol", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3.5, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "capability-digest-with-trailing-newline", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "provision-request-with-slash", + "body": { + "provisionRequestId": "provision/01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "workspace-pattern", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "generation-over-int64", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "9223372036854775808", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "storage-over-256", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "sssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "two-byte-mount-root-over-4096-bytes", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "astral-mount-root-over-4096-bytes", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "numeric-provision-request", + "body": { + "provisionRequestId": 1, + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "provision-request-in-array", + "body": { + "provisionRequestId": ["provision-01"], + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "provision-request-over-128", + "body": { + "provisionRequestId": "rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "capability-digest-in-array", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": [ + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ], + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "capability-digest-with-suffix", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "short-capability-digest", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "capability-digest-with-prefix", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "xsha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "tenant-with-slash", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant/a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "tenant-over-128", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "ttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttt", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "empty-tenant", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "workspace-over-128", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "workspace-with-slash", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace/a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "empty-workspace", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "empty-generation", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "empty-storage", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "generation-zero", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "0", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "empty-mount-root", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "drive-relative-mount-root", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "C:workspaces", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "single-backslash-mount-root", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "\\workspaces", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "mount-root-with-nul", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/\u0000a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "mount-root-with-newline", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/\na", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "mount-root-with-c1", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/\u0085a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "mount-root-with-unit-separator", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/\u001fa", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "mount-root-with-del", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/\u007fa", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "mount-root-with-last-c1", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/\u009fa", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "mount-root-with-lone-surrogate", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/\ud800", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "mount-root-with-lone-low-surrogate", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/\udc00a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "mount-root-with-last-lone-high-surrogate", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/\udbffa", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "mount-root-with-last-lone-low-surrogate", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/\udfff", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "mount-root-with-reversed-surrogates", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/\udc00\ud800", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "unc-root-with-mixed-separators", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "\\/server/share", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "mount-root-over-4096-bytes", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "numeric-mount-root", + "body": { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": 1, + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "missing-capabilitydigest-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/workspaces/workspace-b", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "missing-isolationclass-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/workspaces/workspace-b", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "missing-managedcontext-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/workspaces/workspace-b", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3 + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "missing-mountroot-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "missing-protocolversion-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/workspaces/workspace-b", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "missing-provisionrequestid-with-other-identity", + "body": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/workspaces/workspace-b", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "missing-storageid-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "mountRoot": "/runtime/workspaces/workspace-b", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "missing-tenantid-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/workspaces/workspace-b", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "missing-workspacegeneration-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/workspaces/workspace-b", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "missing-workspaceid-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/workspaces/workspace-b", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "extra-workspace-cwd-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/workspaces/workspace-b", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1", + "workspaceCwd": "/runtime/workspaces/workspace-a" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "protocol-two-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/workspaces/workspace-b", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 2, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "protocol-as-string-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/workspaces/workspace-b", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": "3", + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "wrong-managed-context-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/workspaces/workspace-b", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/2" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "empty-provision-request-with-other-identity", + "body": { + "provisionRequestId": "", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/workspaces/workspace-b", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "tenant-pattern-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant a", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/workspaces/workspace-b", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "generation-leading-zero-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "07", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/workspaces/workspace-b", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "generation-as-number-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": 7, + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/workspaces/workspace-b", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "storage-with-space-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage a", + "mountRoot": "/runtime/workspaces/workspace-b", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "relative-mount-root-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "uppercase-capability-digest-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/workspaces/workspace-b", + "capabilityDigest": "sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "unknown-isolation-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/workspaces/workspace-b", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "tenant", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "bad-shape-before-other-tenant-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage a", + "mountRoot": "/runtime/workspaces/workspace-b", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "fractional-protocol-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/workspaces/workspace-b", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3.5, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "capability-digest-with-trailing-newline-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/workspaces/workspace-b", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "provision-request-with-slash-with-other-identity", + "body": { + "provisionRequestId": "provision/01", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/workspaces/workspace-b", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "workspace-pattern-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace a", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/workspaces/workspace-b", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "generation-over-int64-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "9223372036854775808", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/workspaces/workspace-b", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "storage-over-256-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "sssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss", + "mountRoot": "/runtime/workspaces/workspace-b", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "two-byte-mount-root-over-4096-bytes-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9\u00e9", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "astral-mount-root-over-4096-bytes-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "numeric-provision-request-with-other-identity", + "body": { + "provisionRequestId": 1, + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/workspaces/workspace-b", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "provision-request-in-array-with-other-identity", + "body": { + "provisionRequestId": ["provision-01"], + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/workspaces/workspace-b", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "provision-request-over-128-with-other-identity", + "body": { + "provisionRequestId": "rrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/workspaces/workspace-b", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "capability-digest-in-array-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/workspaces/workspace-b", + "capabilityDigest": [ + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ], + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "capability-digest-with-suffix-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/workspaces/workspace-b", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "short-capability-digest-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/workspaces/workspace-b", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "capability-digest-with-prefix-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/workspaces/workspace-b", + "capabilityDigest": "xsha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "tenant-with-slash-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant/a", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/workspaces/workspace-b", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "tenant-over-128-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "ttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttt", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/workspaces/workspace-b", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "empty-tenant-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/workspaces/workspace-b", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "workspace-over-128-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/workspaces/workspace-b", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "workspace-with-slash-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace/a", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/workspaces/workspace-b", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "empty-workspace-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/workspaces/workspace-b", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "empty-generation-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/workspaces/workspace-b", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "empty-storage-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "", + "mountRoot": "/runtime/workspaces/workspace-b", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "generation-zero-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "0", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/workspaces/workspace-b", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "empty-mount-root-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "drive-relative-mount-root-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "C:workspaces", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "single-backslash-mount-root-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "\\workspaces", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "mount-root-with-nul-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/\u0000a", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "mount-root-with-newline-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/\na", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "mount-root-with-c1-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/\u0085a", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "mount-root-with-unit-separator-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/\u001fa", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "mount-root-with-del-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/\u007fa", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "mount-root-with-last-c1-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/\u009fa", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "mount-root-with-lone-surrogate-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/\ud800", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "mount-root-with-lone-low-surrogate-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/\udc00a", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "mount-root-with-last-lone-high-surrogate-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/\udbffa", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "mount-root-with-last-lone-low-surrogate-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/\udfff", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "mount-root-with-reversed-surrogates-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/runtime/\udc00\ud800", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "unc-root-with-mixed-separators-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "\\/server/share", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "mount-root-over-4096-bytes-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": "/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "numeric-mount-root-with-other-identity", + "body": { + "provisionRequestId": "provision-02", + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "mountRoot": 1, + "capabilityDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "isolationClass": "session", + "protocolVersion": 3, + "managedContext": "managed-context/1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "id": "array-body", + "body": [ + { + "provisionRequestId": "provision-01", + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "mountRoot": "/runtime/workspaces/workspace-a", + "capabilityDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "isolationClass": "workspace", + "protocolVersion": 3, + "managedContext": "managed-context/1" + } + ], + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ], + "installationSequences": [ + { + "id": "installs-a-context", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + } + ] + }, + { + "id": "installs-the-workspace-root", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:29ad2e48398689a7cdab83479ff7c56c4948213cbe15644a9fd82acf0123ed5c" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:29ad2e48398689a7cdab83479ff7c56c4948213cbe15644a9fd82acf0123ed5c", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + } + ] + }, + { + "id": "installs-a-non-ascii-directory", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "\u9879\u76ee/a b", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:5dde4b56bafa119283a46bec023ff3fb5520acfc6ba1cf587912cfff3dc88778" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:5dde4b56bafa119283a46bec023ff3fb5520acfc6ba1cf587912cfff3dc88778", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + } + ] + }, + { + "id": "installs-the-largest-revision", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "9223372036854775807" + }, + "contextDigest": "sha256:80db8eb7bafcb824104ca8d80dfa5ebd3eae32e914e4ddc58c09af1160c8dcfa" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:80db8eb7bafcb824104ca8d80dfa5ebd3eae32e914e4ddc58c09af1160c8dcfa", + "contextRevision": "9223372036854775807", + "workspaceGeneration": "7" + } + } + } + ] + }, + { + "id": "missing-context-digest", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + } + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "missing-managed-context", + "steps": [ + { + "request": { + "protocolVersion": 3, + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "missing-operation-id", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "missing-protocol-version", + "steps": [ + { + "request": { + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "missing-session-id", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "missing-binding", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-missing-context-config-ref", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-missing-context-revision", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-missing-cwd-relative", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:29ad2e48398689a7cdab83479ff7c56c4948213cbe15644a9fd82acf0123ed5c" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-missing-storage-id", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-missing-tenant-id", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-missing-workspace-generation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-missing-workspace-id", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "extra-key", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "effectiveCwd": "/runtime/workspaces/workspace-a/services/api" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "protocol-two", + "steps": [ + { + "request": { + "protocolVersion": 2, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "wrong-managed-context", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/2", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "operation-id-with-slash", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op/1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "empty-session", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "numeric-session", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": 1, + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "session-in-array", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": ["session-1"], + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-extra-field", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1", + "effectiveCwd": "/x" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-not-normalized", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "./services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:7fe8844b7f9767581516be89b479bb6d9f28f35018aff97f82e6a36ae490b840" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-parent-segment", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "../api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:16ded1731ea3f94aca918d478eb193e0cceb952c11107d494a4c4ab8d2957f48" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-absolute-directory", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "/services", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:72d6aa329c2e2c377c346023ee5f8d8cf4afb82583bdd53bfadcf2714d94889f" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-numeric-revision", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": 1 + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-config-with-space", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config a", + "contextRevision": "1" + }, + "contextDigest": "sha256:230d1433567af9c855e430de94c4f46a97531c554f24d40fb0d31444cdd57640" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "digest-pattern", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:xyz" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "digest-uppercase-hex", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91BD133247AC3DDC42A0CE08E680EA69BE1B0FFE0D7371DA51664AEDA48FFC2D" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "digest-of-another-binding", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:a28e2dca7249aa9379e65cbbea0294aaed9813eecbda4352704b5301b2d597c4" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-other-tenant", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:79870f6ccbbc71acfe31e5564130bc9d1e8fec53e488676b678bd11d9f276f24" + }, + "expected": { + "status": 409, + "code": "managed_runtime_identity_conflict" + } + } + ] + }, + { + "id": "binding-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-b", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:cfd0cb1f899dde4cb074a24a17e8086479f7ecc5c6d40830912881a91ccc776b" + }, + "expected": { + "status": 409, + "code": "managed_runtime_identity_conflict" + } + } + ] + }, + { + "id": "binding-other-generation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:1a9c1ba52c3c91699a2cd30226ca9d5c0a1460cc82ab602c1b614c27c97120ec" + }, + "expected": { + "status": 409, + "code": "managed_runtime_identity_conflict" + } + } + ] + }, + { + "id": "binding-other-storage", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:7741d1760bf857a2ceffa3dae2f2c598538953769b56ab74fc809226161879cf" + }, + "expected": { + "status": 409, + "code": "managed_runtime_identity_conflict" + } + } + ] + }, + { + "id": "array-body", + "steps": [ + { + "request": [ + { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + } + ], + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "fractional-protocol", + "steps": [ + { + "request": { + "protocolVersion": 3.5, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "digest-with-trailing-newline", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d\n" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "session-at-512-units", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "ssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "ssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + } + ] + }, + { + "id": "session-over-512-units", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "sssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "astral-session-at-512-units", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + } + ] + }, + { + "id": "astral-session-over-512-units", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1ea", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "session-with-nul", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session\u0000", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "session-with-control-character", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session\u0001", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session\u0001", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + } + ] + }, + { + "id": "session-with-lone-high-surrogate", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session\ud800", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "session-with-lone-low-surrogate", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "\udc00session", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "protocol-as-string", + "steps": [ + { + "request": { + "protocolVersion": "3", + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "numeric-operation-id", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": 1, + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-generation-over-int64", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "9223372036854775808", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:05f1ea0be12a3f2e12c33927f20b9402e035c40a50cb105dbe6eb6746c7bad94" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-revision-over-int64", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "9223372036854775808" + }, + "contextDigest": "sha256:e694c0750bdf3844bf46f3d5b59ae67e7b947d8c8ed9b8899beee0527d16c6f4" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-directory-with-backslash", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services\\api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:67563de90abb6702c302591133f060e95cc7ba51bf73ed8146d31207cde7b8f2" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-directory-with-control", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services\u0001", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:89230c9b32075c2983b26fa695f90ec41ae61f3881148ee36fdf3c400585de8c" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-directory-with-lone-surrogate", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services\ud800", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-drive-directory", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "C:api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:dc7611d9a107a48823a1abf294f74a54e140bb4c7f70a091eeae5368637585ef" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-empty-segment", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services//api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:77ab08332e9c735a1f4ffe86112e4688b3c79ab7642716c7ef7500df8338daff" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-trailing-slash", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:4ba2898c1a0dd4590e8c00ef0272cf60b4435a238c2f430fbf1302abf9705cd3" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-tenant-empty", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:05e7a60646d39b6901fe212b0859bef413f12a4d881edaf5ea7d46c4b6db4240" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-tenant-over-128", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "ttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttt", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:e8665f93e33da0ee60f117fe51b62907ab5b1814f30f04aacb94c12d7d9998a6" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-tenant-with-slash", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant/a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:db6b0398874b06c63b6f229612b4cc54574a10d22d26ee7635b5189399869f53" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-tenant-non-ascii", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "t\u00e9nant", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:9bda6a8bf915ae40931fd4451feb40345812c3abada266f8b7f91948bb739bcd" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-workspace-empty", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:1e83e1ef73170f4986e6c17e379af21bb31b54b92b6db3674c90ef3a4e0cbec5" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-workspace-over-128", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:18b491c8577bed0b6eefdf7b90de64e2e1ea4cb6006a5c1fe03a05d928f9ed78" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-workspace-with-slash", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace/a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:5e0ee63c48bcb1062e0451447587a20cef55f88486a03baffc2a3e6199f28765" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-generation-zero", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "0", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:484964e41463875ded358c59764bebf1223c957890e03f8825267a6e3efc3c94" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-generation-leading-zero", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "07", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:dd9bc438b53eb5cf6b6a92c6680e75322c80671b7e0ea42ae2da876b1b54cd57" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-generation-signed", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "+7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:a4e8af8f8f26fe8e2d46d6e3e10116cc42a3f12c0175b178c828a3c0e66298af" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-generation-empty", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:ba0bf7d5d324b8d8da73827e39e9c74ee6d6f66ec3bd4cabbbfa4ffcde7516c2" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-storage-empty", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:2242d2110961649041809c2dacdd090d06c91b062bfefa218b7a441d549401c1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-storage-over-256", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "sssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:cf216cafcde0d3779620865d4889572c114cc4e7436cbe3802a9824103246148" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-storage-non-ascii", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "st\u00f6rage", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:dfb889fdc0ac7cdbae757830d8d536e83f6ce02d62d5113657a97a0565d62133" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-directory-empty", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:59019ceb803185c9e6563c1ec612e4461713e3c6facf3592d260aee07d6db629" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-directory-over-1024", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:7b9c36b73b706a16760ea6036004a29a0fd446dcaf53fa29dac5ed16cabf4ae4" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-directory-with-nul", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services\u0000", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:b28605a0666e610c03fdaf8e5c30a1f44c0a6239ab1fd0b918a24f1d3f6f534b" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-directory-with-del", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services\u007f", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:3efe1572d4322aadb0fa3fd3b195566cdb2c2fcaab9a3fd341a0e76eab585711" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-directory-with-c1", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services\u0085", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:06126d2085d2a6b0e518cba90c66cf35d68f54b319e84895cd3c96035ed1193c" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-directory-parent-inside", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/../api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:ea3e43d3c070d2a5b0a05dbf4e2c33aa2b227900fef80d9962ffa3c1f65bd8cf" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-directory-dot-segment", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/./api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:585a6c13b2ab9697d68c551b43a8d756fa540bba42cb7504b42339ad8828353b" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-config-empty", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "", + "contextRevision": "1" + }, + "contextDigest": "sha256:a75dd27c5150aa70fdeda271ab587a750992e76a6af5a1b94b522d794c633e55" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-config-over-512", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "contextRevision": "1" + }, + "contextDigest": "sha256:40c1eab5297abe56894ef4d9db25106ecd0fbd60cbe2de417a1c97cc8792e6ea" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-config-non-ascii", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "c\u00f6nfig", + "contextRevision": "1" + }, + "contextDigest": "sha256:b328438482f7cb8c32a5759fb506d396f320667ef98dc228e419c1b788a78b7d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-revision-zero", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "0" + }, + "contextDigest": "sha256:4d177edff6fc25f222c191628f50f6aa96b6547fe54acb0c28d0dd0d8988a929" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-revision-leading-zero", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "01" + }, + "contextDigest": "sha256:02a0332813b40567e622b500bc7e998c03386db1de9efc95888b8ad3693741f7" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-revision-signed", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "+1" + }, + "contextDigest": "sha256:820a3372b3035fbce1ee060c14932ad95a2b33f0207932ba4f49f09a4c403f1c" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-revision-empty", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "" + }, + "contextDigest": "sha256:001964139e0941f09ee00181f2668d4d8abbf234989a1540e397f306db834d72" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-null", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": null, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-array", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": [ + { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + } + ], + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-string", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": "tenant-a/workspace-a", + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-tenant-pattern", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:10c57573c02433f25147d7c365fd2efff2fa7b9acb13eb1ea82edb84daafc14b" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-workspace-pattern", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:e1ced2d40b118316eb6930e7c6f421be40616c9d7d470689a28ae5ca02d7becb" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-storage-with-space", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:b67e806f8716e222bff093b741cf7fe6672aad3e95e46c48e4340209a144523d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "checks-the-digest-before-the-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "missing-context-digest-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + } + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "missing-context-digest-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + } + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "missing-managed-context-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "missing-managed-context-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:35f3494a0fce6b55c5d292938ff9ecec0e74e2e871569d23f5c255ba6fd41eba" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "missing-operation-id-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "missing-operation-id-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:35f3494a0fce6b55c5d292938ff9ecec0e74e2e871569d23f5c255ba6fd41eba" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "missing-protocol-version-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "missing-protocol-version-with-other-workspace", + "steps": [ + { + "request": { + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:35f3494a0fce6b55c5d292938ff9ecec0e74e2e871569d23f5c255ba6fd41eba" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "missing-session-id-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "missing-session-id-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:35f3494a0fce6b55c5d292938ff9ecec0e74e2e871569d23f5c255ba6fd41eba" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "missing-binding-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-missing-context-config-ref-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-missing-context-config-ref-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextRevision": "1" + }, + "contextDigest": "sha256:35f3494a0fce6b55c5d292938ff9ecec0e74e2e871569d23f5c255ba6fd41eba" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-missing-context-revision-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-missing-context-revision-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12" + }, + "contextDigest": "sha256:35f3494a0fce6b55c5d292938ff9ecec0e74e2e871569d23f5c255ba6fd41eba" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-missing-cwd-relative-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:29ad2e48398689a7cdab83479ff7c56c4948213cbe15644a9fd82acf0123ed5c" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-missing-cwd-relative-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:aa3a484fa1c84d17b8d37b212a66c66c1399b51fe114d40b62fbc80c49fb677b" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-missing-storage-id-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-missing-storage-id-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:c2724c1f1e3444ed9e32e0bbf9abad8e36a1bc242718726c1781a0e3c9028bc4" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-missing-tenant-id-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-missing-tenant-id-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:2acd70ac0af0b9dac91597b760a2d04f150739074ad9ebb24d1facc50e694a19" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-missing-workspace-generation-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-missing-workspace-generation-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:50c1b32976ef8fa3450ea9706278d4e4b080146b7e5892d080af0b1f6410bd4a" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-missing-workspace-id-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-missing-workspace-id-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:f3c5998ce6a21304425a29017fbc30af3ee67ce9e8deb52c695ec37428917c72" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "extra-key-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "effectiveCwd": "/runtime/workspaces/workspace-a/services/api" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "extra-key-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:35f3494a0fce6b55c5d292938ff9ecec0e74e2e871569d23f5c255ba6fd41eba", + "effectiveCwd": "/runtime/workspaces/workspace-a/services/api" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "protocol-two-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 2, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "protocol-two-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 2, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:35f3494a0fce6b55c5d292938ff9ecec0e74e2e871569d23f5c255ba6fd41eba" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "wrong-managed-context-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/2", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "wrong-managed-context-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/2", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:35f3494a0fce6b55c5d292938ff9ecec0e74e2e871569d23f5c255ba6fd41eba" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "operation-id-with-slash-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op/1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "operation-id-with-slash-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op/1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:35f3494a0fce6b55c5d292938ff9ecec0e74e2e871569d23f5c255ba6fd41eba" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "empty-session-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "empty-session-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:35f3494a0fce6b55c5d292938ff9ecec0e74e2e871569d23f5c255ba6fd41eba" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "numeric-session-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": 1, + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "numeric-session-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": 1, + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:35f3494a0fce6b55c5d292938ff9ecec0e74e2e871569d23f5c255ba6fd41eba" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "session-in-array-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": ["session-1"], + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "session-in-array-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": ["session-1"], + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:35f3494a0fce6b55c5d292938ff9ecec0e74e2e871569d23f5c255ba6fd41eba" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-extra-field-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1", + "effectiveCwd": "/x" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-extra-field-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1", + "effectiveCwd": "/x" + }, + "contextDigest": "sha256:35f3494a0fce6b55c5d292938ff9ecec0e74e2e871569d23f5c255ba6fd41eba" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-not-normalized-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "./services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:7fe8844b7f9767581516be89b479bb6d9f28f35018aff97f82e6a36ae490b840" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-not-normalized-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "./services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:1cf017c5cd03560b78f5f19fe131eff6f90b41c8a002ea131306af9768658537" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-parent-segment-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "../api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:16ded1731ea3f94aca918d478eb193e0cceb952c11107d494a4c4ab8d2957f48" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-parent-segment-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "../api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:87f65cdeac8bb679ce0c5855a0fe9fde7a1f54c8ee650712864cb6463bc37d07" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-absolute-directory-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "/services", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:72d6aa329c2e2c377c346023ee5f8d8cf4afb82583bdd53bfadcf2714d94889f" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-absolute-directory-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "/services", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:42a6544b37092dec5d5d5a5e4a7cdd9c642246db910a776c68290c63a21af775" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-numeric-revision-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": 1 + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-numeric-revision-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": 1 + }, + "contextDigest": "sha256:35f3494a0fce6b55c5d292938ff9ecec0e74e2e871569d23f5c255ba6fd41eba" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-config-with-space-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config a", + "contextRevision": "1" + }, + "contextDigest": "sha256:230d1433567af9c855e430de94c4f46a97531c554f24d40fb0d31444cdd57640" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-config-with-space-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config a", + "contextRevision": "1" + }, + "contextDigest": "sha256:77bdffd5c3480556e618b844b0ce5b68146f237029548a7dca88464533718f60" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "digest-pattern-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:xyz" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "digest-pattern-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:xyz" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "digest-uppercase-hex-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91BD133247AC3DDC42A0CE08E680EA69BE1B0FFE0D7371DA51664AEDA48FFC2D" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "digest-uppercase-hex-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91BD133247AC3DDC42A0CE08E680EA69BE1B0FFE0D7371DA51664AEDA48FFC2D" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "digest-of-another-binding-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:a28e2dca7249aa9379e65cbbea0294aaed9813eecbda4352704b5301b2d597c4" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "digest-of-another-binding-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:a28e2dca7249aa9379e65cbbea0294aaed9813eecbda4352704b5301b2d597c4" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "fractional-protocol-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3.5, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "fractional-protocol-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3.5, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:35f3494a0fce6b55c5d292938ff9ecec0e74e2e871569d23f5c255ba6fd41eba" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "digest-with-trailing-newline-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d\n" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "digest-with-trailing-newline-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d\n" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "session-over-512-units-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "sssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "session-over-512-units-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "sssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:35f3494a0fce6b55c5d292938ff9ecec0e74e2e871569d23f5c255ba6fd41eba" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "astral-session-over-512-units-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1ea", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "astral-session-over-512-units-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1ea", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:35f3494a0fce6b55c5d292938ff9ecec0e74e2e871569d23f5c255ba6fd41eba" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "session-with-nul-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session\u0000", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "session-with-nul-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session\u0000", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:35f3494a0fce6b55c5d292938ff9ecec0e74e2e871569d23f5c255ba6fd41eba" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "session-with-lone-high-surrogate-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session\ud800", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "session-with-lone-high-surrogate-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session\ud800", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:35f3494a0fce6b55c5d292938ff9ecec0e74e2e871569d23f5c255ba6fd41eba" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "session-with-lone-low-surrogate-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "\udc00session", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "session-with-lone-low-surrogate-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "\udc00session", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:35f3494a0fce6b55c5d292938ff9ecec0e74e2e871569d23f5c255ba6fd41eba" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "protocol-as-string-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": "3", + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "protocol-as-string-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": "3", + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:35f3494a0fce6b55c5d292938ff9ecec0e74e2e871569d23f5c255ba6fd41eba" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "numeric-operation-id-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": 1, + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "numeric-operation-id-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": 1, + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:35f3494a0fce6b55c5d292938ff9ecec0e74e2e871569d23f5c255ba6fd41eba" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-generation-over-int64-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "9223372036854775808", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:05f1ea0be12a3f2e12c33927f20b9402e035c40a50cb105dbe6eb6746c7bad94" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-generation-over-int64-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "9223372036854775808", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:b23da7996aa5e0eb3b6d279b3f9fea39f09917891f216e9c3e482a6b2ab76452" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-revision-over-int64-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "9223372036854775808" + }, + "contextDigest": "sha256:e694c0750bdf3844bf46f3d5b59ae67e7b947d8c8ed9b8899beee0527d16c6f4" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-revision-over-int64-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "9223372036854775808" + }, + "contextDigest": "sha256:6de49f708fc9051e13c76f5a576863107cef20242659fbec117c9f2f2cced1d2" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-directory-with-backslash-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services\\api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:67563de90abb6702c302591133f060e95cc7ba51bf73ed8146d31207cde7b8f2" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-directory-with-backslash-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services\\api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:c7c36c2ec043fed85b16b186ec97e7618cae15decd45c1b4b1ba60ff9a703fa6" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-directory-with-control-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services\u0001", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:89230c9b32075c2983b26fa695f90ec41ae61f3881148ee36fdf3c400585de8c" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-directory-with-control-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services\u0001", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:35efa08e83eee88fbc20a67f46123a920598d728afd594b76ea8dcf21e4e5239" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-directory-with-lone-surrogate-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services\ud800", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-directory-with-lone-surrogate-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services\ud800", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-drive-directory-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "C:api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:dc7611d9a107a48823a1abf294f74a54e140bb4c7f70a091eeae5368637585ef" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-drive-directory-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "C:api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:b641ef3d6c324a43ae6b94a720c8e5cbe7397a29d182ba41f9064d9f83bc9963" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-empty-segment-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services//api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:77ab08332e9c735a1f4ffe86112e4688b3c79ab7642716c7ef7500df8338daff" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-empty-segment-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services//api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:34d432cae3d59c27afea65f76f478243d03f41fa0f0eed4da92674e5ba448a1d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-trailing-slash-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:4ba2898c1a0dd4590e8c00ef0272cf60b4435a238c2f430fbf1302abf9705cd3" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-trailing-slash-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:edd93994f0fc155297f9963bad78f7dea9cb10ccf997c1f36d7c8e1d8904c5c0" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-tenant-empty-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:05e7a60646d39b6901fe212b0859bef413f12a4d881edaf5ea7d46c4b6db4240" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-tenant-empty-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:ec2e8394bd3620425c8d475d11fbf0b5bb2556acb71b62a957339a4734552b13" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-tenant-over-128-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "ttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttt", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:e8665f93e33da0ee60f117fe51b62907ab5b1814f30f04aacb94c12d7d9998a6" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-tenant-over-128-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "ttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttt", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:ba0efa3fc4d31cb4c4bbbbc61840de21a5fd2040e60d54ab6077c15a3cdb6871" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-tenant-with-slash-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant/a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:db6b0398874b06c63b6f229612b4cc54574a10d22d26ee7635b5189399869f53" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-tenant-with-slash-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant/a", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:abcc4c49c6cc1ae0d44495a0cf82fc999058677fb642f2117d222d80e579091a" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-tenant-non-ascii-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "t\u00e9nant", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:9bda6a8bf915ae40931fd4451feb40345812c3abada266f8b7f91948bb739bcd" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-tenant-non-ascii-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "t\u00e9nant", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:9a4a71d9227a401f70353234bb43edee6476fe795c67aa70545c8c1cb08314b7" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-workspace-empty-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:1e83e1ef73170f4986e6c17e379af21bb31b54b92b6db3674c90ef3a4e0cbec5" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-workspace-empty-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:1eb13fcff50bee388c78a181270cda572363e3092ac30c6b86847b07b76da6c8" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-workspace-over-128-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:18b491c8577bed0b6eefdf7b90de64e2e1ea4cb6006a5c1fe03a05d928f9ed78" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-workspace-over-128-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:6555b6b2337ec44e9e756a3f149cbba5e60d4101f33dcce4234434e818645aec" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-workspace-with-slash-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace/a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:5e0ee63c48bcb1062e0451447587a20cef55f88486a03baffc2a3e6199f28765" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-workspace-with-slash-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace/a", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:301c78750833d2c067982536e90e887bbd15d3bdf68b59fb561018e85730ad88" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-generation-zero-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "0", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:484964e41463875ded358c59764bebf1223c957890e03f8825267a6e3efc3c94" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-generation-zero-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "0", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:c439c77246711ead17a1e03f63925873a884cafa1031d25b3abb42530a7507e5" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-generation-leading-zero-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "07", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:dd9bc438b53eb5cf6b6a92c6680e75322c80671b7e0ea42ae2da876b1b54cd57" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-generation-leading-zero-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "07", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:568f28124815fc14ae61c46cd89e5c0ab8e3ce7d83a1d35f101af244f8b3afbc" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-generation-signed-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "+7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:a4e8af8f8f26fe8e2d46d6e3e10116cc42a3f12c0175b178c828a3c0e66298af" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-generation-signed-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "+7", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:f3e16a7973057192f849fd47e232789e24e499b8d3e6ca45f7ce65f5a521baf6" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-generation-empty-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:ba0bf7d5d324b8d8da73827e39e9c74ee6d6f66ec3bd4cabbbfa4ffcde7516c2" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-generation-empty-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:7a07ddc2cd408789d5d6d5451b853be980ffaaf88c78dede63c90cb27c40b273" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-storage-empty-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:2242d2110961649041809c2dacdd090d06c91b062bfefa218b7a441d549401c1" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-storage-empty-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:4fa1bed2b9434c913cc1a66ccad9f2896274f9a4828a0cd6344cb7cf9350fbbd" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-storage-over-256-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "sssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:cf216cafcde0d3779620865d4889572c114cc4e7436cbe3802a9824103246148" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-storage-over-256-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "sssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:cda16761ec80e0b934e599bc3eb8dfca611db55b2bec8953f28b17b7f888653d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-storage-non-ascii-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "st\u00f6rage", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:dfb889fdc0ac7cdbae757830d8d536e83f6ce02d62d5113657a97a0565d62133" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-storage-non-ascii-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "st\u00f6rage", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:c991e2ca59a2cb04b86be6c6b090558eab9552586117c6d9cab19ce41b054ba6" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-directory-empty-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:59019ceb803185c9e6563c1ec612e4461713e3c6facf3592d260aee07d6db629" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-directory-empty-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:eec0b41ff671c85537e3d34f4a0846f3a54031218976b3f01ca424d2197e6cda" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-directory-over-1024-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:7b9c36b73b706a16760ea6036004a29a0fd446dcaf53fa29dac5ed16cabf4ae4" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-directory-over-1024-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:2e8ded6b0a0a86365753155c64d1317f6a92791df2d50ebf696186aff4c1a6c3" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-directory-with-nul-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services\u0000", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:b28605a0666e610c03fdaf8e5c30a1f44c0a6239ab1fd0b918a24f1d3f6f534b" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-directory-with-nul-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services\u0000", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:db208856adaf13b6088e4e44d3d3ba10be110ec3814b17ebb82efe590e21ba8b" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-directory-with-del-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services\u007f", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:3efe1572d4322aadb0fa3fd3b195566cdb2c2fcaab9a3fd341a0e76eab585711" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-directory-with-del-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services\u007f", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:8022ef0f5524aef0fd9a406c00a4959336bc9caf728cf155820360c226fe0e0d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-directory-with-c1-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services\u0085", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:06126d2085d2a6b0e518cba90c66cf35d68f54b319e84895cd3c96035ed1193c" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-directory-with-c1-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services\u0085", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:167403ee479e91c584ab74e7c1133e93a825a6a2953947896be4dd5723e4b0bc" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-directory-parent-inside-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/../api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:ea3e43d3c070d2a5b0a05dbf4e2c33aa2b227900fef80d9962ffa3c1f65bd8cf" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-directory-parent-inside-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/../api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:33515c332f24ce9d0de3b3b4480a4fc046c3ee3173ce6c14cf48dff5bd87710c" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-directory-dot-segment-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/./api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:585a6c13b2ab9697d68c551b43a8d756fa540bba42cb7504b42339ad8828353b" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-directory-dot-segment-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/./api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:24acc089652b7f36ea99ec10d09ff4413c5e08bd1d5fd61c95d2c9af5770d862" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-config-empty-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "", + "contextRevision": "1" + }, + "contextDigest": "sha256:a75dd27c5150aa70fdeda271ab587a750992e76a6af5a1b94b522d794c633e55" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-config-empty-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "", + "contextRevision": "1" + }, + "contextDigest": "sha256:11e27a03641ae37da9b784f66f3242c1e6e35d9dcf8a70527d7cf4dd424a16d6" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-config-over-512-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "contextRevision": "1" + }, + "contextDigest": "sha256:40c1eab5297abe56894ef4d9db25106ecd0fbd60cbe2de417a1c97cc8792e6ea" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-config-over-512-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "contextRevision": "1" + }, + "contextDigest": "sha256:1297afb71bc8898cc0704ed0cce0e49a0498ce5f7adeb24d5875fb6597789220" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-config-non-ascii-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "c\u00f6nfig", + "contextRevision": "1" + }, + "contextDigest": "sha256:b328438482f7cb8c32a5759fb506d396f320667ef98dc228e419c1b788a78b7d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-config-non-ascii-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "c\u00f6nfig", + "contextRevision": "1" + }, + "contextDigest": "sha256:503fb6727cc0edaeb17f489c8529d3a3148f4f813a9fe4a5381414d06207a810" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-revision-zero-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "0" + }, + "contextDigest": "sha256:4d177edff6fc25f222c191628f50f6aa96b6547fe54acb0c28d0dd0d8988a929" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-revision-zero-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "0" + }, + "contextDigest": "sha256:510beb3bcf52d52dd3266d6cf128fc3cb0ac46bd218b01d23c2db853780b3b40" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-revision-leading-zero-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "01" + }, + "contextDigest": "sha256:02a0332813b40567e622b500bc7e998c03386db1de9efc95888b8ad3693741f7" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-revision-leading-zero-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "01" + }, + "contextDigest": "sha256:2f32fbafc8c34e35265c0a424cd46da4dc3a09d3732e08704c225baa8dfa608f" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-revision-signed-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "+1" + }, + "contextDigest": "sha256:820a3372b3035fbce1ee060c14932ad95a2b33f0207932ba4f49f09a4c403f1c" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-revision-signed-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "+1" + }, + "contextDigest": "sha256:cdedc4fe0b3bd83a6e0132bfc5e2b3af7f47a0b1a7b11366fdccb3b3cdf8e6ae" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-revision-empty-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "" + }, + "contextDigest": "sha256:001964139e0941f09ee00181f2668d4d8abbf234989a1540e397f306db834d72" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-revision-empty-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "" + }, + "contextDigest": "sha256:5fcee6a9d8c7e3c2e3437bba9706100f85e72d14d59a34d79f905230df7fe946" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-null-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": null, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-array-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": [ + { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + } + ], + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-string-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": "tenant-a/workspace-a", + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-tenant-pattern-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:10c57573c02433f25147d7c365fd2efff2fa7b9acb13eb1ea82edb84daafc14b" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-tenant-pattern-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant a", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:c602f1efcf5de2f5dfac4d104e76bf092cae41a55b681d08526c651086d88e9c" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-workspace-pattern-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:e1ced2d40b118316eb6930e7c6f421be40616c9d7d470689a28ae5ca02d7becb" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-workspace-pattern-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace a", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:af3b6a823d3569ca7046def87dbbe0ee16343dde3626a3ae5c053204f99e7207" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-storage-with-space-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:b67e806f8716e222bff093b741cf7fe6672aad3e95e46c48e4340209a144523d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "binding-storage-with-space-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:dd1023cf7267b95749c11c353600a03a973ca06edb2b149de720f0555553e522" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "checks-the-digest-before-the-workspace-after-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "checks-the-digest-before-the-workspace-with-other-workspace", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-b", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + } + ] + }, + { + "id": "checks-the-tenant-before-the-operation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:79870f6ccbbc71acfe31e5564130bc9d1e8fec53e488676b678bd11d9f276f24" + }, + "expected": { + "status": 409, + "code": "managed_runtime_identity_conflict" + } + } + ] + }, + { + "id": "checks-the-tenant-before-the-session", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-2", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:79870f6ccbbc71acfe31e5564130bc9d1e8fec53e488676b678bd11d9f276f24" + }, + "expected": { + "status": 409, + "code": "managed_runtime_identity_conflict" + } + } + ] + }, + { + "id": "checks-the-workspace-id-before-the-operation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-b", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:cfd0cb1f899dde4cb074a24a17e8086479f7ecc5c6d40830912881a91ccc776b" + }, + "expected": { + "status": 409, + "code": "managed_runtime_identity_conflict" + } + } + ] + }, + { + "id": "checks-the-workspace-id-before-the-session", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-2", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-b", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:cfd0cb1f899dde4cb074a24a17e8086479f7ecc5c6d40830912881a91ccc776b" + }, + "expected": { + "status": 409, + "code": "managed_runtime_identity_conflict" + } + } + ] + }, + { + "id": "checks-the-generation-before-the-operation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:1a9c1ba52c3c91699a2cd30226ca9d5c0a1460cc82ab602c1b614c27c97120ec" + }, + "expected": { + "status": 409, + "code": "managed_runtime_identity_conflict" + } + } + ] + }, + { + "id": "checks-the-generation-before-the-session", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-2", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "8", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:1a9c1ba52c3c91699a2cd30226ca9d5c0a1460cc82ab602c1b614c27c97120ec" + }, + "expected": { + "status": 409, + "code": "managed_runtime_identity_conflict" + } + } + ] + }, + { + "id": "checks-the-storage-before-the-operation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:7741d1760bf857a2ceffa3dae2f2c598538953769b56ab74fc809226161879cf" + }, + "expected": { + "status": 409, + "code": "managed_runtime_identity_conflict" + } + } + ] + }, + { + "id": "checks-the-storage-before-the-session", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-2", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-b", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:7741d1760bf857a2ceffa3dae2f2c598538953769b56ab74fc809226161879cf" + }, + "expected": { + "status": 409, + "code": "managed_runtime_identity_conflict" + } + } + ] + }, + { + "id": "repeats-an-installation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + } + ] + }, + { + "id": "reuses-an-operation-for-another-session", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-2", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 409, + "code": "managed_context_conflict" + } + } + ] + }, + { + "id": "reuses-an-operation-for-another-context", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/web", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "2" + }, + "contextDigest": "sha256:a28e2dca7249aa9379e65cbbea0294aaed9813eecbda4352704b5301b2d597c4" + }, + "expected": { + "status": 409, + "code": "managed_context_conflict" + } + } + ] + }, + { + "id": "changes-a-session-context", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-2", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/web", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "2" + }, + "contextDigest": "sha256:a28e2dca7249aa9379e65cbbea0294aaed9813eecbda4352704b5301b2d597c4" + }, + "expected": { + "status": 409, + "code": "managed_context_conflict" + } + } + ] + }, + { + "id": "reinstalls-a-session-context-under-a-new-operation", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-2", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-2", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + } + ] + }, + { + "id": "installs-two-sessions", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-2", + "sessionId": "session-2", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/web", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "2" + }, + "contextDigest": "sha256:a28e2dca7249aa9379e65cbbea0294aaed9813eecbda4352704b5301b2d597c4" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-2", + "sessionId": "session-2", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:a28e2dca7249aa9379e65cbbea0294aaed9813eecbda4352704b5301b2d597c4", + "contextRevision": "2", + "workspaceGeneration": "7" + } + } + } + ] + }, + { + "id": "keeps-no-state-after-a-malformed-request", + "steps": [ + { + "request": { + "protocolVersion": 2, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/web", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "2" + }, + "contextDigest": "sha256:a28e2dca7249aa9379e65cbbea0294aaed9813eecbda4352704b5301b2d597c4" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + } + ] + }, + { + "id": "keeps-no-state-after-a-binding-rule-refusal", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "./services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:7fe8844b7f9767581516be89b479bb6d9f28f35018aff97f82e6a36ae490b840" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + } + ] + }, + { + "id": "keeps-no-state-after-a-digest-refusal", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:a28e2dca7249aa9379e65cbbea0294aaed9813eecbda4352704b5301b2d597c4" + }, + "expected": { + "status": 400, + "code": "managed_runtime_attestation_invalid" + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + } + ] + }, + { + "id": "keeps-no-state-after-an-operation-conflict", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/web", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "2" + }, + "contextDigest": "sha256:a28e2dca7249aa9379e65cbbea0294aaed9813eecbda4352704b5301b2d597c4" + }, + "expected": { + "status": 409, + "code": "managed_context_conflict" + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-2", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-2", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + } + ] + }, + { + "id": "keeps-no-state-after-an-operation-conflict-for-another-session", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-2", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 409, + "code": "managed_context_conflict" + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-2", + "sessionId": "session-2", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-2", + "sessionId": "session-2", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + } + ] + }, + { + "id": "keeps-no-state-after-a-session-conflict", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-2", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/web", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "2" + }, + "contextDigest": "sha256:a28e2dca7249aa9379e65cbbea0294aaed9813eecbda4352704b5301b2d597c4" + }, + "expected": { + "status": 409, + "code": "managed_context_conflict" + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-2", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-2", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-3", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/web", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "2" + }, + "contextDigest": "sha256:a28e2dca7249aa9379e65cbbea0294aaed9813eecbda4352704b5301b2d597c4" + }, + "expected": { + "status": 409, + "code": "managed_context_conflict" + } + } + ] + }, + { + "id": "keeps-no-state-after-a-refusal", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-b", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:79870f6ccbbc71acfe31e5564130bc9d1e8fec53e488676b678bd11d9f276f24" + }, + "expected": { + "status": 409, + "code": "managed_runtime_identity_conflict" + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + } + ] + }, + { + "id": "keeps-no-state-after-a-conflict", + "steps": [ + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-1", + "sessionId": "session-1", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:91bd133247ac3ddc42a0ce08e680ea69be1b0ffe0d7371da51664aeda48ffc2d", + "contextRevision": "1", + "workspaceGeneration": "7" + } + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-2", + "sessionId": "session-1", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/web", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "2" + }, + "contextDigest": "sha256:a28e2dca7249aa9379e65cbbea0294aaed9813eecbda4352704b5301b2d597c4" + }, + "expected": { + "status": 409, + "code": "managed_context_conflict" + } + }, + { + "request": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-2", + "sessionId": "session-2", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "workspace-a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/workspace-a", + "cwdRelative": "services/web", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "2" + }, + "contextDigest": "sha256:a28e2dca7249aa9379e65cbbea0294aaed9813eecbda4352704b5301b2d597c4" + }, + "expected": { + "status": 200, + "body": { + "protocolVersion": 3, + "managedContext": "managed-context/1", + "operationId": "op-2", + "sessionId": "session-2", + "runtimeInstanceId": "runtime-01", + "runtimeIncarnation": "boot-01", + "epoch": 4, + "contextDigest": "sha256:a28e2dca7249aa9379e65cbbea0294aaed9813eecbda4352704b5301b2d597c4", + "contextRevision": "2", + "workspaceGeneration": "7" + } + } + } + ] + } + ] +} diff --git a/packages/cli/src/serve/contracts/managed-context-v1.schema.json b/packages/cli/src/serve/contracts/managed-context-v1.schema.json new file mode 100644 index 00000000000..c4c794403fc --- /dev/null +++ b/packages/cli/src/serve/contracts/managed-context-v1.schema.json @@ -0,0 +1,654 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://qwenlm.github.io/qwen-code/contracts/managed-context-v1.schema.json", + "title": "Managed context envelope v1 (managed-context/1) conformance fixtures", + "type": "object", + "additionalProperties": false, + "required": [ + "contractVersion", + "managedContext", + "bootVersion", + "readyVersion", + "routes", + "errors", + "boot", + "ready", + "attestationResponse", + "bootCases", + "readyCases", + "attestationCases", + "installationSequences" + ], + "properties": { + "contractVersion": { + "const": 1 + }, + "managedContext": { + "$ref": "#/$defs/managedContext" + }, + "bootVersion": { + "const": 2 + }, + "readyVersion": { + "const": 2 + }, + "routes": { + "const": [ + { + "key": "attest", + "method": "POST", + "path": "/internal/managed-runtime/v3/attest", + "protocolVersion": 3, + "requestBodyLimitBytes": 16384, + "responseBodyLimitBytes": 16384, + "cacheControl": "no-store" + }, + { + "key": "context", + "method": "POST", + "path": "/internal/managed-runtime/v3/context", + "protocolVersion": 3, + "requestBodyLimitBytes": 16384, + "responseBodyLimitBytes": 16384, + "cacheControl": "no-store" + } + ] + }, + "errors": { + "const": [ + { + "status": 400, + "code": "managed_runtime_attestation_invalid", + "classification": "protocol" + }, + { + "status": 401, + "code": "managed_runtime_unauthorized", + "classification": "credentials" + }, + { + "status": 404, + "code": null, + "classification": "incompatible" + }, + { + "status": 409, + "code": "managed_runtime_identity_conflict", + "classification": "identity" + }, + { + "status": 409, + "code": "managed_context_conflict", + "classification": "identity" + }, + { + "status": 409, + "code": "managed_context_unavailable", + "classification": "recovery" + }, + { + "status": 413, + "code": "managed_runtime_attestation_too_large", + "classification": "protocol" + } + ] + }, + "boot": { + "$ref": "#/$defs/bootV2" + }, + "ready": { + "$ref": "#/$defs/readyV2" + }, + "attestationResponse": { + "$ref": "#/$defs/attestationResponseV3" + }, + "bootCases": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["boot", "id", "valid"], + "properties": { + "id": { + "$ref": "#/$defs/caseId" + }, + "boot": {}, + "valid": { + "type": "boolean" + } + } + } + }, + "readyCases": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "ready", "valid"], + "properties": { + "id": { + "$ref": "#/$defs/caseId" + }, + "ready": {}, + "valid": { + "type": "boolean" + } + } + } + }, + "attestationCases": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["body", "expected", "id"], + "properties": { + "id": { + "$ref": "#/$defs/caseId" + }, + "body": {}, + "expected": { + "$ref": "#/$defs/attestationOutcome" + } + } + } + }, + "installationSequences": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "steps"], + "properties": { + "id": { + "$ref": "#/$defs/caseId" + }, + "steps": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["request", "expected"], + "properties": { + "request": {}, + "expected": { + "$ref": "#/$defs/installationOutcome" + } + } + } + } + } + } + } + }, + "$defs": { + "caseId": { + "type": "string", + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" + }, + "bearerToken": { + "type": "string", + "maxLength": 512, + "pattern": "^[A-Za-z0-9._~+/-]+=*$" + }, + "sessionId": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "pattern": "^[^\\u0000\\ud800-\\udfff]*$" + }, + "identifier": { + "type": "string", + "pattern": "^[A-Za-z0-9._:-]{1,128}$" + }, + "decimal": { + "type": "string", + "pattern": "^[1-9][0-9]{0,18}$" + }, + "storageId": { + "type": "string", + "pattern": "^[\\x21-\\x7E]{1,256}$" + }, + "reference": { + "type": "string", + "pattern": "^[\\x21-\\x7E]{1,512}$" + }, + "digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "mountRoot": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "pattern": "^(?:/|[A-Za-z]:[\\\\/]|\\\\\\\\)[^\\u0000-\\u001f\\u007f-\\u009f\\ud800-\\udfff]*$" + }, + "epoch": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "isolationClass": { + "enum": ["session", "workspace"] + }, + "managedContext": { + "const": "managed-context/1" + }, + "bootV2": { + "type": "object", + "additionalProperties": false, + "required": [ + "capabilityDigest", + "epoch", + "isolationClass", + "leaseId", + "managedContext", + "mountRoot", + "provisionRequestId", + "runtimeIncarnation", + "runtimeInstanceId", + "storageId", + "tenantId", + "token", + "type", + "version", + "workspaceGeneration", + "workspaceId" + ], + "properties": { + "type": { + "const": "boot" + }, + "version": { + "const": 2 + }, + "managedContext": { + "$ref": "#/$defs/managedContext" + }, + "token": { + "$ref": "#/$defs/bearerToken" + }, + "runtimeInstanceId": { + "$ref": "#/$defs/identifier" + }, + "runtimeIncarnation": { + "$ref": "#/$defs/identifier" + }, + "leaseId": { + "$ref": "#/$defs/identifier" + }, + "epoch": { + "$ref": "#/$defs/epoch" + }, + "provisionRequestId": { + "$ref": "#/$defs/identifier" + }, + "tenantId": { + "$ref": "#/$defs/identifier" + }, + "workspaceId": { + "$ref": "#/$defs/identifier" + }, + "workspaceGeneration": { + "$ref": "#/$defs/decimal" + }, + "storageId": { + "$ref": "#/$defs/storageId" + }, + "mountRoot": { + "$ref": "#/$defs/mountRoot" + }, + "capabilityDigest": { + "$ref": "#/$defs/digest" + }, + "isolationClass": { + "$ref": "#/$defs/isolationClass" + } + } + }, + "readyV2": { + "type": "object", + "additionalProperties": false, + "required": [ + "epoch", + "leaseId", + "managedContext", + "runtimeIncarnation", + "runtimeInstanceId", + "type", + "url", + "version" + ], + "properties": { + "type": { + "const": "ready" + }, + "version": { + "const": 2 + }, + "managedContext": { + "$ref": "#/$defs/managedContext" + }, + "url": { + "type": "string", + "pattern": "^http://127\\.0\\.0\\.1:(?:[1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$" + }, + "runtimeInstanceId": { + "$ref": "#/$defs/identifier" + }, + "runtimeIncarnation": { + "$ref": "#/$defs/identifier" + }, + "leaseId": { + "$ref": "#/$defs/identifier" + }, + "epoch": { + "$ref": "#/$defs/epoch" + } + } + }, + "attestationRequestV3": { + "type": "object", + "additionalProperties": false, + "required": [ + "capabilityDigest", + "isolationClass", + "managedContext", + "mountRoot", + "protocolVersion", + "provisionRequestId", + "storageId", + "tenantId", + "workspaceGeneration", + "workspaceId" + ], + "properties": { + "protocolVersion": { + "const": 3 + }, + "managedContext": { + "$ref": "#/$defs/managedContext" + }, + "provisionRequestId": { + "$ref": "#/$defs/identifier" + }, + "tenantId": { + "$ref": "#/$defs/identifier" + }, + "workspaceId": { + "$ref": "#/$defs/identifier" + }, + "workspaceGeneration": { + "$ref": "#/$defs/decimal" + }, + "storageId": { + "$ref": "#/$defs/storageId" + }, + "mountRoot": { + "$ref": "#/$defs/mountRoot" + }, + "capabilityDigest": { + "$ref": "#/$defs/digest" + }, + "isolationClass": { + "$ref": "#/$defs/isolationClass" + } + } + }, + "attestationResponseV3": { + "type": "object", + "additionalProperties": false, + "required": [ + "capabilityDigest", + "epoch", + "isolationClass", + "leaseId", + "managedContext", + "mountRoot", + "protocolVersion", + "provisionRequestId", + "runtimeIncarnation", + "runtimeInstanceId", + "storageId", + "tenantId", + "workspaceGeneration", + "workspaceId" + ], + "properties": { + "protocolVersion": { + "const": 3 + }, + "managedContext": { + "$ref": "#/$defs/managedContext" + }, + "provisionRequestId": { + "$ref": "#/$defs/identifier" + }, + "tenantId": { + "$ref": "#/$defs/identifier" + }, + "workspaceId": { + "$ref": "#/$defs/identifier" + }, + "workspaceGeneration": { + "$ref": "#/$defs/decimal" + }, + "storageId": { + "$ref": "#/$defs/storageId" + }, + "mountRoot": { + "$ref": "#/$defs/mountRoot" + }, + "capabilityDigest": { + "$ref": "#/$defs/digest" + }, + "isolationClass": { + "$ref": "#/$defs/isolationClass" + }, + "runtimeInstanceId": { + "$ref": "#/$defs/identifier" + }, + "runtimeIncarnation": { + "$ref": "#/$defs/identifier" + }, + "leaseId": { + "$ref": "#/$defs/identifier" + }, + "epoch": { + "$ref": "#/$defs/epoch" + } + } + }, + "binding": { + "type": "object", + "additionalProperties": false, + "required": [ + "contextConfigRef", + "contextRevision", + "cwdRelative", + "storageId", + "tenantId", + "workspaceGeneration", + "workspaceId" + ], + "properties": { + "tenantId": { + "$ref": "#/$defs/identifier" + }, + "workspaceId": { + "$ref": "#/$defs/identifier" + }, + "workspaceGeneration": { + "$ref": "#/$defs/decimal" + }, + "storageId": { + "$ref": "#/$defs/storageId" + }, + "cwdRelative": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": "^(?!/|[A-Za-z]:)[^\\\\\\u0000-\\u001f\\u007f-\\u009f\\ud800-\\udfff]+$" + }, + "contextConfigRef": { + "$ref": "#/$defs/reference" + }, + "contextRevision": { + "$ref": "#/$defs/decimal" + } + } + }, + "installationRequest": { + "type": "object", + "additionalProperties": false, + "required": [ + "binding", + "contextDigest", + "managedContext", + "operationId", + "protocolVersion", + "sessionId" + ], + "properties": { + "protocolVersion": { + "const": 3 + }, + "managedContext": { + "$ref": "#/$defs/managedContext" + }, + "operationId": { + "$ref": "#/$defs/identifier" + }, + "sessionId": { + "$ref": "#/$defs/sessionId" + }, + "binding": { + "$ref": "#/$defs/binding" + }, + "contextDigest": { + "$ref": "#/$defs/digest" + } + } + }, + "receipt": { + "type": "object", + "additionalProperties": false, + "required": [ + "contextDigest", + "contextRevision", + "epoch", + "managedContext", + "operationId", + "protocolVersion", + "runtimeIncarnation", + "runtimeInstanceId", + "sessionId", + "workspaceGeneration" + ], + "properties": { + "protocolVersion": { + "const": 3 + }, + "managedContext": { + "$ref": "#/$defs/managedContext" + }, + "operationId": { + "$ref": "#/$defs/identifier" + }, + "sessionId": { + "$ref": "#/$defs/sessionId" + }, + "contextDigest": { + "$ref": "#/$defs/digest" + }, + "contextRevision": { + "$ref": "#/$defs/decimal" + }, + "workspaceGeneration": { + "$ref": "#/$defs/decimal" + }, + "runtimeInstanceId": { + "$ref": "#/$defs/identifier" + }, + "runtimeIncarnation": { + "$ref": "#/$defs/identifier" + }, + "epoch": { + "$ref": "#/$defs/epoch" + } + } + }, + "attestationOutcome": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["status", "body"], + "properties": { + "status": { + "const": 200 + }, + "body": { + "$ref": "#/$defs/attestationResponseV3" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["status", "code"], + "properties": { + "status": { + "enum": [400, 409] + }, + "code": { + "enum": [ + "managed_runtime_attestation_invalid", + "managed_runtime_identity_conflict" + ] + } + } + } + ] + }, + "installationOutcome": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["status", "body"], + "properties": { + "status": { + "const": 200 + }, + "body": { + "$ref": "#/$defs/receipt" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["status", "code"], + "properties": { + "status": { + "enum": [400, 409] + }, + "code": { + "enum": [ + "managed_runtime_attestation_invalid", + "managed_runtime_identity_conflict", + "managed_context_conflict" + ] + } + } + } + ] + } + } +} diff --git a/packages/cli/src/serve/contracts/managed-workspace-binding-v1.fixtures.json b/packages/cli/src/serve/contracts/managed-workspace-binding-v1.fixtures.json new file mode 100644 index 00000000000..01ac6385d6a --- /dev/null +++ b/packages/cli/src/serve/contracts/managed-workspace-binding-v1.fixtures.json @@ -0,0 +1,2137 @@ +{ + "contractVersion": 1, + "digest": { + "algorithm": "sha256", + "prefix": "sha256:", + "domainTag": "qwen-managed-context-binding-v1", + "fieldOrder": [ + "tenantId", + "workspaceId", + "workspaceGeneration", + "storageId", + "cwdRelative", + "contextConfigRef", + "contextRevision" + ] + }, + "paths": [ + { + "id": "root-dot", + "input": ".", + "expected": { + "cwdRelative": "." + } + }, + { + "id": "root-dot-slash", + "input": "./", + "expected": { + "cwdRelative": "." + } + }, + { + "id": "root-dot-slash-dot", + "input": "./.", + "expected": { + "cwdRelative": "." + } + }, + { + "id": "root-repeated-separators", + "input": ".//./", + "expected": { + "cwdRelative": "." + } + }, + { + "id": "single-character", + "input": "a", + "expected": { + "cwdRelative": "a" + } + }, + { + "id": "single-character-after-dot-segment", + "input": "./x", + "expected": { + "cwdRelative": "x" + } + }, + { + "id": "single-character-with-trailing-slash", + "input": "x/", + "expected": { + "cwdRelative": "x" + } + }, + { + "id": "plain-child", + "input": "services", + "expected": { + "cwdRelative": "services" + } + }, + { + "id": "nested-child", + "input": "services/api", + "expected": { + "cwdRelative": "services/api" + } + }, + { + "id": "trailing-slash", + "input": "services/api/", + "expected": { + "cwdRelative": "services/api" + } + }, + { + "id": "repeated-separators", + "input": "services//api", + "expected": { + "cwdRelative": "services/api" + } + }, + { + "id": "dot-segments", + "input": "./services/./api/.", + "expected": { + "cwdRelative": "services/api" + } + }, + { + "id": "spaces-kept", + "input": " a /b ", + "expected": { + "cwdRelative": " a /b " + } + }, + { + "id": "space-only-segment", + "input": "a/ /b", + "expected": { + "cwdRelative": "a/ /b" + } + }, + { + "id": "case-kept", + "input": "Services/API", + "expected": { + "cwdRelative": "Services/API" + } + }, + { + "id": "non-ascii", + "input": "\u9879\u76ee/\u670d\u52a1 \u4e00", + "expected": { + "cwdRelative": "\u9879\u76ee/\u670d\u52a1 \u4e00" + } + }, + { + "id": "nfd-not-normalized", + "input": "cafe\u0301", + "expected": { + "cwdRelative": "cafe\u0301" + } + }, + { + "id": "astral-characters", + "input": "\ud834\udd1e/\ud83c\udfb5", + "expected": { + "cwdRelative": "\ud834\udd1e/\ud83c\udfb5" + } + }, + { + "id": "triple-dot-segment", + "input": ".../a", + "expected": { + "cwdRelative": ".../a" + } + }, + { + "id": "dot-dot-prefixed-names", + "input": "..a/b..", + "expected": { + "cwdRelative": "..a/b.." + } + }, + { + "id": "dot-dot-space-segment", + "input": ".. /a", + "expected": { + "cwdRelative": ".. /a" + } + }, + { + "id": "dot-space-segment", + "input": "a/. /b", + "expected": { + "cwdRelative": "a/. /b" + } + }, + { + "id": "space-dot-segment", + "input": "a/ ./b", + "expected": { + "cwdRelative": "a/ ./b" + } + }, + { + "id": "leading-dot-space-segment", + "input": ". /x", + "expected": { + "cwdRelative": ". /x" + } + }, + { + "id": "space-dot-only", + "input": " .", + "expected": { + "cwdRelative": " ." + } + }, + { + "id": "dot-ideographic-space-segment", + "input": "a/.\u3000", + "expected": { + "cwdRelative": "a/.\u3000" + } + }, + { + "id": "parent-as-segment-512", + "input": "a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/..", + "expected": { + "error": "invalid_cwd" + } + }, + { + "id": "parent-after-1020-empty-segments", + "input": "a////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////..", + "expected": { + "error": "invalid_cwd" + } + }, + { + "id": "dot-segment-after-500-segments", + "input": "a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/./b", + "expected": { + "cwdRelative": "a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/a/b" + } + }, + { + "id": "backslash-after-1000-characters", + "input": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\\b", + "expected": { + "error": "invalid_cwd" + } + }, + { + "id": "control-after-1000-characters", + "input": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\u0001", + "expected": { + "error": "invalid_cwd" + } + }, + { + "id": "lone-surrogate-after-1000-characters", + "input": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\ud800", + "expected": { + "error": "invalid_cwd" + } + }, + { + "id": "tilde-is-literal", + "input": "~/x", + "expected": { + "cwdRelative": "~/x" + } + }, + { + "id": "line-separator-before-drive-like-text", + "input": "x\u2028C:y", + "expected": { + "cwdRelative": "x\u2028C:y" + } + }, + { + "id": "format-characters-accepted", + "input": "a\u200db\u202ec\ufeffd", + "expected": { + "cwdRelative": "a\u200db\u202ec\ufeffd" + } + }, + { + "id": "percent-not-decoded", + "input": "%2e%2e/a", + "expected": { + "cwdRelative": "%2e%2e/a" + } + }, + { + "id": "colon-after-two-letters", + "input": "ab:c", + "expected": { + "cwdRelative": "ab:c" + } + }, + { + "id": "max-length-ascii", + "input": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "expected": { + "cwdRelative": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + }, + { + "id": "max-length-astral", + "input": "\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e", + "expected": { + "cwdRelative": "\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e" + } + }, + { + "id": "empty", + "input": "", + "expected": { + "error": "invalid_cwd" + } + }, + { + "id": "absolute", + "input": "/srv/a", + "expected": { + "error": "invalid_cwd" + } + }, + { + "id": "absolute-root", + "input": "/", + "expected": { + "error": "invalid_cwd" + } + }, + { + "id": "double-slash-absolute", + "input": "//a", + "expected": { + "error": "invalid_cwd" + } + }, + { + "id": "drive-upper", + "input": "C:x", + "expected": { + "error": "invalid_cwd" + } + }, + { + "id": "drive-lower-slash", + "input": "c:/x", + "expected": { + "error": "invalid_cwd" + } + }, + { + "id": "drive-only", + "input": "Z:", + "expected": { + "error": "invalid_cwd" + } + }, + { + "id": "drive-after-dot-segment", + "input": "./C:x", + "expected": { + "error": "invalid_cwd" + } + }, + { + "id": "drive-after-empty-segments", + "input": ".//c:/x", + "expected": { + "error": "invalid_cwd" + } + }, + { + "id": "drive-only-after-dot-segment", + "input": "./Z:", + "expected": { + "error": "invalid_cwd" + } + }, + { + "id": "drive-like-name-in-later-segment", + "input": "a/C:x", + "expected": { + "cwdRelative": "a/C:x" + } + }, + { + "id": "backslash", + "input": "a\\b", + "expected": { + "error": "invalid_cwd" + } + }, + { + "id": "backslash-only", + "input": "\\", + "expected": { + "error": "invalid_cwd" + } + }, + { + "id": "parent", + "input": "..", + "expected": { + "error": "invalid_cwd" + } + }, + { + "id": "parent-leading", + "input": "../a", + "expected": { + "error": "invalid_cwd" + } + }, + { + "id": "parent-inner", + "input": "a/../b", + "expected": { + "error": "invalid_cwd" + } + }, + { + "id": "parent-trailing", + "input": "a/..", + "expected": { + "error": "invalid_cwd" + } + }, + { + "id": "parent-after-dot", + "input": "./..", + "expected": { + "error": "invalid_cwd" + } + }, + { + "id": "nul", + "input": "a\u0000b", + "expected": { + "error": "invalid_cwd" + } + }, + { + "id": "newline", + "input": "a\nb", + "expected": { + "error": "invalid_cwd" + } + }, + { + "id": "escape-sequence", + "input": "a\u001b[31mb", + "expected": { + "error": "invalid_cwd" + } + }, + { + "id": "del", + "input": "a\u007fb", + "expected": { + "error": "invalid_cwd" + } + }, + { + "id": "c1-csi", + "input": "a\u009bb", + "expected": { + "error": "invalid_cwd" + } + }, + { + "id": "last-c0-control", + "input": "a\u001fb", + "expected": { + "error": "invalid_cwd" + } + }, + { + "id": "first-c1-control", + "input": "a\u0080b", + "expected": { + "error": "invalid_cwd" + } + }, + { + "id": "last-c1-control", + "input": "a\u009fb", + "expected": { + "error": "invalid_cwd" + } + }, + { + "id": "no-break-space-kept", + "input": "a\u00a0b", + "expected": { + "cwdRelative": "a\u00a0b" + } + }, + { + "id": "lone-high-surrogate", + "input": "a\ud800", + "expected": { + "error": "invalid_cwd" + } + }, + { + "id": "lone-low-surrogate", + "input": "\udc00a", + "expected": { + "error": "invalid_cwd" + } + }, + { + "id": "high-surrogate-before-ascii", + "input": "\ud800a", + "expected": { + "error": "invalid_cwd" + } + }, + { + "id": "two-high-surrogates", + "input": "\ud800\ud800a", + "expected": { + "error": "invalid_cwd" + } + }, + { + "id": "two-low-surrogates", + "input": "a\udc00\udc00", + "expected": { + "error": "invalid_cwd" + } + }, + { + "id": "low-before-high-surrogate", + "input": "a\udc00\ud800b", + "expected": { + "error": "invalid_cwd" + } + }, + { + "id": "lone-low-before-a-pair", + "input": "a\udc00\ud834\udd1e", + "expected": { + "error": "invalid_cwd" + } + }, + { + "id": "digit-before-colon", + "input": "1:x", + "expected": { + "cwdRelative": "1:x" + } + }, + { + "id": "dot-before-drive-like-name", + "input": ".C:x", + "expected": { + "cwdRelative": ".C:x" + } + }, + { + "id": "non-ascii-letter-before-colon", + "input": "\u00e9:x", + "expected": { + "cwdRelative": "\u00e9:x" + } + }, + { + "id": "kelvin-sign-before-colon", + "input": "\u212a:x", + "expected": { + "cwdRelative": "\u212a:x" + } + }, + { + "id": "long-s-before-colon", + "input": "\u017f:x", + "expected": { + "cwdRelative": "\u017f:x" + } + }, + { + "id": "leading-colon", + "input": ":x", + "expected": { + "cwdRelative": ":x" + } + }, + { + "id": "drive-first-upper-letter", + "input": "A:x", + "expected": { + "error": "invalid_cwd" + } + }, + { + "id": "drive-first-lower-letter", + "input": "a:x", + "expected": { + "error": "invalid_cwd" + } + }, + { + "id": "drive-last-lower-letter", + "input": "z:x", + "expected": { + "error": "invalid_cwd" + } + }, + { + "id": "at-sign-before-colon", + "input": "@:x", + "expected": { + "cwdRelative": "@:x" + } + }, + { + "id": "left-bracket-before-colon", + "input": "[:x", + "expected": { + "cwdRelative": "[:x" + } + }, + { + "id": "backtick-before-colon", + "input": "`:x", + "expected": { + "cwdRelative": "`:x" + } + }, + { + "id": "left-brace-before-colon", + "input": "{:x", + "expected": { + "cwdRelative": "{:x" + } + }, + { + "id": "too-long-ascii", + "input": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "expected": { + "error": "invalid_cwd" + } + }, + { + "id": "too-long-astral", + "input": "\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e\ud834\udd1e", + "expected": { + "error": "invalid_cwd" + } + }, + { + "id": "too-long-before-normalization", + "input": "./././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././././", + "expected": { + "error": "invalid_cwd" + } + } + ], + "bindings": [ + { + "id": "root", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws_project_a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "encodedHex": "0000001f7177656e2d6d616e616765642d636f6e746578742d62696e64696e672d76310000000874656e616e742d610000000c77735f70726f6a6563745f6100000001370000001773746f726167653a2f2f7076632f70726f6a6563742d61000000012e00000013636f6e6669673a62756e646c652d33407231320000000131", + "contextDigest": "sha256:53806bd3c066c6594c331e02a3a88df2ff50f334a793fa579837fb99a5b3d1dd" + } + }, + { + "id": "subdirectory", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws_project_a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": "services/api", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "encodedHex": "0000001f7177656e2d6d616e616765642d636f6e746578742d62696e64696e672d76310000000874656e616e742d610000000c77735f70726f6a6563745f6100000001370000001773746f726167653a2f2f7076632f70726f6a6563742d610000000c73657276696365732f61706900000013636f6e6669673a62756e646c652d33407231320000000131", + "contextDigest": "sha256:60edeb0b26dfb61fa32e3a50c344403d4d7ece051f26b931ec0798684057caea" + } + }, + { + "id": "next-revision", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws_project_a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "2" + }, + "expected": { + "encodedHex": "0000001f7177656e2d6d616e616765642d636f6e746578742d62696e64696e672d76310000000874656e616e742d610000000c77735f70726f6a6563745f6100000001370000001773746f726167653a2f2f7076632f70726f6a6563742d61000000012e00000013636f6e6669673a62756e646c652d33407231320000000132", + "contextDigest": "sha256:3ca59f0cc17a4d2482b091659ff161f48f594c635f720a0c8eed6edf16364e42" + } + }, + { + "id": "non-ascii-cwd", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws_project_a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": "\u9879\u76ee/\u670d\u52a1 \u4e00", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "encodedHex": "0000001f7177656e2d6d616e616765642d636f6e746578742d62696e64696e672d76310000000874656e616e742d610000000c77735f70726f6a6563745f6100000001370000001773746f726167653a2f2f7076632f70726f6a6563742d6100000011e9a1b9e79bae2fe69c8de58aa120e4b88000000013636f6e6669673a62756e646c652d33407231320000000131", + "contextDigest": "sha256:c0d48b6ac064d85e94c24f417db08d763e1c22c94fbd48d30b43c4f3a17aea8c" + } + }, + { + "id": "nfc-cwd", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws_project_a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": "caf\u00e9", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "encodedHex": "0000001f7177656e2d6d616e616765642d636f6e746578742d62696e64696e672d76310000000874656e616e742d610000000c77735f70726f6a6563745f6100000001370000001773746f726167653a2f2f7076632f70726f6a6563742d6100000005636166c3a900000013636f6e6669673a62756e646c652d33407231320000000131", + "contextDigest": "sha256:a4066febca7e929af5681030f1a0d4a4b4b6ba3e20937e70e43d87e0e437cf1c" + } + }, + { + "id": "nfd-cwd", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws_project_a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": "cafe\u0301", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "encodedHex": "0000001f7177656e2d6d616e616765642d636f6e746578742d62696e64696e672d76310000000874656e616e742d610000000c77735f70726f6a6563745f6100000001370000001773746f726167653a2f2f7076632f70726f6a6563742d610000000663616665cc8100000013636f6e6669673a62756e646c652d33407231320000000131", + "contextDigest": "sha256:b9c74ac329181219b5a4d151b6f4c7b163af0ce41021931885ae4f199f09fd9e" + } + }, + { + "id": "space-only-segment-cwd", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws_project_a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": "a/ /b", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "encodedHex": "0000001f7177656e2d6d616e616765642d636f6e746578742d62696e64696e672d76310000000874656e616e742d610000000c77735f70726f6a6563745f6100000001370000001773746f726167653a2f2f7076632f70726f6a6563742d6100000007612f2020202f6200000013636f6e6669673a62756e646c652d33407231320000000131", + "contextDigest": "sha256:6ee8af7a8e0056c3af6aefc435164fbafce166cfb912b8ee6f6eb1d5938ab410" + } + }, + { + "id": "shortest-values", + "binding": { + "tenantId": "t", + "workspaceId": "w", + "workspaceGeneration": "1", + "storageId": "!", + "cwdRelative": ".", + "contextConfigRef": "~", + "contextRevision": "1" + }, + "expected": { + "encodedHex": "0000001f7177656e2d6d616e616765642d636f6e746578742d62696e64696e672d76310000000174000000017700000001310000000121000000012e000000017e0000000131", + "contextDigest": "sha256:5935d6ecb37b594068330a22377f2a3e75b2195f6245481d4ac55c14babef129" + } + }, + { + "id": "every-identifier-character-class", + "binding": { + "tenantId": "Tenant.9:a-b_C", + "workspaceId": "A_z.0:9-Z", + "workspaceGeneration": "7", + "storageId": "!~#$%&'()*+,-./0:;<=>?@A[]^_`a{|}", + "cwdRelative": ".", + "contextConfigRef": "~!\"#config@Z", + "contextRevision": "1" + }, + "expected": { + "encodedHex": "0000001f7177656e2d6d616e616765642d636f6e746578742d62696e64696e672d76310000000e54656e616e742e393a612d625f4300000009415f7a2e303a392d5a000000013700000021217e232425262728292a2b2c2d2e2f303a3b3c3d3e3f40415b5d5e5f60617b7c7d000000012e0000000c7e212223636f6e666967405a0000000131", + "contextDigest": "sha256:ff8f7132670c154b3d67663ae8e77d7e7f86585e772cbf9cee7fe1b090092e6e" + } + }, + { + "id": "generation-at-int64-max", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws_project_a", + "workspaceGeneration": "9223372036854775807", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "encodedHex": "0000001f7177656e2d6d616e616765642d636f6e746578742d62696e64696e672d76310000000874656e616e742d610000000c77735f70726f6a6563745f6100000013393232333337323033363835343737353830370000001773746f726167653a2f2f7076632f70726f6a6563742d61000000012e00000013636f6e6669673a62756e646c652d33407231320000000131", + "contextDigest": "sha256:532e32c96d178a901b90209a35afb12c0a125c43fa71902d4bf8ed143346ff95" + } + }, + { + "id": "above-2-pow-53", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws_project_a", + "workspaceGeneration": "9007199254740993", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "9223372036854775807" + }, + "expected": { + "encodedHex": "0000001f7177656e2d6d616e616765642d636f6e746578742d62696e64696e672d76310000000874656e616e742d610000000c77735f70726f6a6563745f6100000010393030373139393235343734303939330000001773746f726167653a2f2f7076632f70726f6a6563742d61000000012e00000013636f6e6669673a62756e646c652d33407231320000001339323233333732303336383534373735383037", + "contextDigest": "sha256:762fd7b80d892920abdfad106197027936976627340005d48c6cc0525e287687" + } + }, + { + "id": "every-length-at-its-limit", + "binding": { + "tenantId": "tttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttt", + "workspaceId": "wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww", + "workspaceGeneration": "7", + "storageId": "ssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss", + "cwdRelative": ".", + "contextConfigRef": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "contextRevision": "1" + }, + "expected": { + "encodedHex": "0000001f7177656e2d6d616e616765642d636f6e746578742d62696e64696e672d763100000080747474747474747474747474747474747474747474747474747474747474747474747474747474747474747474747474747474747474747474747474747474747474747474747474747474747474747474747474747474747474747474747474747474747474747474747474747474747474747474747474747474747474747400000080777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777700000001370000010073737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373737373000000012e0000020063636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630000000131", + "contextDigest": "sha256:ffa294595e90b9efbac2fbc692b7126a4a8bf65e9b1f4b4ecef78053642406c4" + } + }, + { + "id": "non-normalized-cwd", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws_project_a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": "./services", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "invalid-cwd", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws_project_a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": "../x", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "zero-generation", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws_project_a", + "workspaceGeneration": "0", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "leading-zero-generation", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws_project_a", + "workspaceGeneration": "07", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "generation-above-int64", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws_project_a", + "workspaceGeneration": "9223372036854775808", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "negative-revision", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws_project_a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "-1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "zero-revision", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws_project_a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "0" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "leading-zero-revision", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws_project_a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "01" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "workspace-id-with-slash", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws/a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "workspace-id-with-comma", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws,a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "workspace-id-with-semicolon", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws;a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "workspace-id-with-at-sign", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws@a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "workspace-id-with-left-bracket", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws[a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "workspace-id-with-caret", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws^a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "workspace-id-with-backtick", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws`a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "workspace-id-with-left-brace", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws{a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "workspace-id-with-kelvin-sign", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws\u212a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "tenant-with-long-s", + "binding": { + "tenantId": "tenant-\u017f", + "workspaceId": "ws_project_a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "tenant-with-line-break", + "binding": { + "tenantId": "tenant-a\n../x", + "workspaceId": "ws_project_a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "workspace-id-with-trailing-line-break", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws_project_a\n", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "storage-id-with-line-break", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws_project_a", + "workspaceGeneration": "7", + "storageId": "storage\nx", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "config-ref-with-line-break", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws_project_a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:a\nb", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "generation-with-line-break", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws_project_a", + "workspaceGeneration": "7\n1", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "revision-with-trailing-line-break", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws_project_a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1\n" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "cwd-with-trailing-line-break", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws_project_a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": "services\n", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "empty-tenant", + "binding": { + "tenantId": "", + "workspaceId": "ws_project_a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "tenant-too-long", + "binding": { + "tenantId": "ttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttt", + "workspaceId": "ws_project_a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "storage-id-with-space", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws_project_a", + "workspaceGeneration": "7", + "storageId": "storage a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "storage-id-too-long", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws_project_a", + "workspaceGeneration": "7", + "storageId": "sssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "config-ref-non-ascii", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws_project_a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:\u00e9", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "empty-config-ref", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws_project_a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "empty-workspace-id", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "empty-storage-id", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws_project_a", + "workspaceGeneration": "7", + "storageId": "", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "storage-id-with-del", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws_project_a", + "workspaceGeneration": "7", + "storageId": "storage\u007f", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "config-ref-with-del", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws_project_a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config\u007f", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "config-ref-with-space", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws_project_a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config a", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "revision-above-int64", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws_project_a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "9223372036854775808" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "storage-id-with-kelvin-sign", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws_project_a", + "workspaceGeneration": "7", + "storageId": "storage\u212a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "storage-id-with-long-s", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws_project_a", + "workspaceGeneration": "7", + "storageId": "\u017ftorage", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "storage-id-with-tab", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws_project_a", + "workspaceGeneration": "7", + "storageId": "storage\ta", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "storage-id-with-no-break-space", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws_project_a", + "workspaceGeneration": "7", + "storageId": "storage\u00a0a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "storage-id-with-e-acute", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws_project_a", + "workspaceGeneration": "7", + "storageId": "storag\u00e9", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "config-ref-with-kelvin-sign", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws_project_a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:\u212a", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "config-ref-with-long-s", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws_project_a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:\u017f", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "config-ref-with-tab", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws_project_a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:\ta", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "config-ref-with-no-break-space", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws_project_a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:\u00a0a", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "plus-sign-revision", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws_project_a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "+1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "space-before-revision", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws_project_a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": " 1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "space-after-generation", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws_project_a", + "workspaceGeneration": "7 ", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "arabic-indic-digit-generation", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws_project_a", + "workspaceGeneration": "\u0667", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "fullwidth-digit-revision", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws_project_a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "\uff11" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "config-ref-too-long", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws_project_a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "workspace-id-too-long", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "workspace-id-rejects-u0020", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "workspace-id-rejects-u0021", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws!a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "workspace-id-rejects-u0022", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws\"a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "workspace-id-rejects-u0023", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws#a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "workspace-id-rejects-u0024", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws$a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "workspace-id-rejects-u0025", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws%a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "workspace-id-rejects-u0026", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws&a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "workspace-id-rejects-u0027", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws'a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "workspace-id-rejects-u0028", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws(a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "workspace-id-rejects-u0029", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws)a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "workspace-id-rejects-u002a", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws*a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "workspace-id-rejects-u002b", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws+a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "workspace-id-rejects-u003c", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "wsa", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "workspace-id-rejects-u003f", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws?a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "workspace-id-rejects-u005c", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws\\a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "workspace-id-rejects-u005d", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws]a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "workspace-id-rejects-u007c", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws|a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "workspace-id-rejects-u007d", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws}a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "workspace-id-rejects-u007e", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws~a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "workspace-id-rejects-u0009", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws\ta", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "workspace-id-rejects-u007f", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws\u007fa", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "workspace-id-rejects-u00a0", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws\u00a0a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + }, + { + "id": "workspace-id-rejects-u00e9", + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws\u00e9a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "expected": { + "error": "invalid_binding" + } + } + ], + "probes": { + "codePoints": [ + [0, 65535], + [65536, 65536], + [119070, 119070], + [119808, 120831], + [127232, 127487], + [128512, 128512], + [917504, 917631], + [983040, 983040], + [1114111, 1114111] + ], + "binding": { + "tenantId": "tenant-a", + "workspaceId": "ws_project_a", + "workspaceGeneration": "7", + "storageId": "storage://pvc/project-a", + "cwdRelative": ".", + "contextConfigRef": "config:bundle-3@r12", + "contextRevision": "1" + }, + "cases": [ + { + "id": "cwd-inner-character", + "kind": "path", + "template": "1{c}2", + "accepted": [ + [32, 91], + [93, 126], + [160, 55295], + [57344, 65536], + [119070, 119070], + [119808, 120831], + [127232, 127487], + [128512, 128512], + [917504, 917631], + [983040, 983040], + [1114111, 1114111] + ] + }, + { + "id": "cwd-leading-character-before-colon", + "kind": "path", + "template": "{c}:x", + "accepted": [ + [32, 46], + [48, 64], + [91, 91], + [93, 96], + [123, 126], + [160, 55295], + [57344, 65536], + [119070, 119070], + [119808, 120831], + [127232, 127487], + [128512, 128512], + [917504, 917631], + [983040, 983040], + [1114111, 1114111] + ] + }, + { + "id": "tenant-character", + "kind": "binding", + "field": "tenantId", + "template": "t{c}", + "accepted": [ + [45, 46], + [48, 58], + [65, 90], + [95, 95], + [97, 122] + ] + }, + { + "id": "workspace-id-character", + "kind": "binding", + "field": "workspaceId", + "template": "ws{c}a", + "accepted": [ + [45, 46], + [48, 58], + [65, 90], + [95, 95], + [97, 122] + ] + }, + { + "id": "storage-id-character", + "kind": "binding", + "field": "storageId", + "template": "s{c}", + "accepted": [[33, 126]] + }, + { + "id": "config-ref-character", + "kind": "binding", + "field": "contextConfigRef", + "template": "c{c}", + "accepted": [[33, 126]] + }, + { + "id": "generation-leading-character", + "kind": "binding", + "field": "workspaceGeneration", + "template": "{c}7", + "accepted": [[49, 57]] + }, + { + "id": "generation-trailing-character", + "kind": "binding", + "field": "workspaceGeneration", + "template": "7{c}", + "accepted": [[48, 57]] + }, + { + "id": "revision-leading-character", + "kind": "binding", + "field": "contextRevision", + "template": "{c}1", + "accepted": [[49, 57]] + }, + { + "id": "revision-trailing-character", + "kind": "binding", + "field": "contextRevision", + "template": "1{c}", + "accepted": [[48, 57]] + } + ] + } +} diff --git a/packages/cli/src/serve/contracts/managed-workspace-binding-v1.schema.json b/packages/cli/src/serve/contracts/managed-workspace-binding-v1.schema.json new file mode 100644 index 00000000000..817645c0c84 --- /dev/null +++ b/packages/cli/src/serve/contracts/managed-workspace-binding-v1.schema.json @@ -0,0 +1,281 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://qwenlm.github.io/qwen-code/contracts/managed-workspace-binding-v1.schema.json", + "title": "Managed Workspace binding v1 conformance fixtures", + "type": "object", + "additionalProperties": false, + "required": ["contractVersion", "digest", "paths", "bindings", "probes"], + "properties": { + "contractVersion": { + "const": 1 + }, + "digest": { + "$ref": "#/$defs/digest" + }, + "paths": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/pathCase" + } + }, + "bindings": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/bindingCase" + } + }, + "probes": { + "$ref": "#/$defs/probes" + } + }, + "$defs": { + "caseId": { + "type": "string", + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" + }, + "digest": { + "type": "object", + "additionalProperties": false, + "required": ["algorithm", "prefix", "domainTag", "fieldOrder"], + "properties": { + "algorithm": { + "const": "sha256" + }, + "prefix": { + "const": "sha256:" + }, + "domainTag": { + "const": "qwen-managed-context-binding-v1" + }, + "fieldOrder": { + "const": [ + "tenantId", + "workspaceId", + "workspaceGeneration", + "storageId", + "cwdRelative", + "contextConfigRef", + "contextRevision" + ] + } + } + }, + "pathCase": { + "type": "object", + "additionalProperties": false, + "required": ["id", "input", "expected"], + "properties": { + "id": { + "$ref": "#/$defs/caseId" + }, + "input": { + "type": "string" + }, + "expected": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["cwdRelative"], + "properties": { + "cwdRelative": { + "type": "string", + "minLength": 1 + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["error"], + "properties": { + "error": { + "const": "invalid_cwd" + } + } + } + ] + } + } + }, + "binding": { + "type": "object", + "additionalProperties": false, + "required": [ + "tenantId", + "workspaceId", + "workspaceGeneration", + "storageId", + "cwdRelative", + "contextConfigRef", + "contextRevision" + ], + "properties": { + "tenantId": { + "type": "string" + }, + "workspaceId": { + "type": "string" + }, + "workspaceGeneration": { + "type": "string" + }, + "storageId": { + "type": "string" + }, + "cwdRelative": { + "type": "string" + }, + "contextConfigRef": { + "type": "string" + }, + "contextRevision": { + "type": "string" + } + } + }, + "bindingCase": { + "type": "object", + "additionalProperties": false, + "required": ["id", "binding", "expected"], + "properties": { + "id": { + "$ref": "#/$defs/caseId" + }, + "binding": { + "$ref": "#/$defs/binding" + }, + "expected": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["encodedHex", "contextDigest"], + "properties": { + "encodedHex": { + "type": "string", + "pattern": "^([0-9a-f]{2})+$" + }, + "contextDigest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["error"], + "properties": { + "error": { + "const": "invalid_binding" + } + } + } + ] + } + } + }, + "codePointRanges": { + "type": "array", + "items": { + "type": "array", + "prefixItems": [ + { + "type": "integer", + "minimum": 0, + "maximum": 1114111 + }, + { + "type": "integer", + "minimum": 0, + "maximum": 1114111 + } + ], + "items": false, + "minItems": 2 + } + }, + "probeTemplate": { + "type": "string", + "pattern": "^[^{}]*\\{c\\}[^{}]*$" + }, + "probes": { + "type": "object", + "additionalProperties": false, + "required": ["codePoints", "binding", "cases"], + "properties": { + "codePoints": { + "$ref": "#/$defs/codePointRanges", + "type": "array", + "minItems": 1 + }, + "binding": { + "$ref": "#/$defs/binding" + }, + "cases": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/probeCase" + } + } + } + }, + "probeCase": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["id", "kind", "template", "accepted"], + "properties": { + "id": { + "$ref": "#/$defs/caseId" + }, + "kind": { + "const": "path" + }, + "template": { + "$ref": "#/$defs/probeTemplate" + }, + "accepted": { + "$ref": "#/$defs/codePointRanges" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["id", "kind", "field", "template", "accepted"], + "properties": { + "id": { + "$ref": "#/$defs/caseId" + }, + "kind": { + "const": "binding" + }, + "field": { + "enum": [ + "tenantId", + "workspaceId", + "workspaceGeneration", + "storageId", + "cwdRelative", + "contextConfigRef", + "contextRevision" + ] + }, + "template": { + "$ref": "#/$defs/probeTemplate" + }, + "accepted": { + "$ref": "#/$defs/codePointRanges" + } + } + } + ] + } + } +} diff --git a/packages/cli/src/serve/conversations/standalone-session-service.test.ts b/packages/cli/src/serve/conversations/standalone-session-service.test.ts index a7d0accc989..5b51ff21815 100644 --- a/packages/cli/src/serve/conversations/standalone-session-service.test.ts +++ b/packages/cli/src/serve/conversations/standalone-session-service.test.ts @@ -4,6 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { RequestError } from '@agentclientprotocol/sdk'; +import { + BridgeTimeoutError, + BridgeChannelClosedError, +} from '@qwen-code/acp-bridge/status'; import { SessionNotFoundError, AcpChildCapacityExceededError, @@ -78,6 +83,7 @@ interface Harness { bridge: { [K in keyof Pick< AcpSessionBridge, + | 'setSessionConfigOption' | 'spawnStandaloneSession' | 'restoreStandaloneSession' | 'getSessionSummary' @@ -128,6 +134,7 @@ interface Harness { function createHarness(): Harness { let restoredSummary: BridgeSessionSummary | undefined; const bridge = { + setSessionConfigOption: vi.fn(), spawnStandaloneSession: vi.fn(async () => ({ sessionId, workspaceCwd: root.canonicalRoot, @@ -537,6 +544,358 @@ describe('StandaloneSessionService', () => { expect(harness.reservation.release).toHaveBeenCalledOnce(); }); + it('rejects an invalid startupConfig at the service boundary before any side effect', async () => { + // Callers that build service requests without a route parser + // (live-task-service, create-sub-session) get the same validation: + // the legacy modelServiceId combination is refused and nothing spawns. + const harness = createHarness(); + await expect( + harness.service.createWithInitialPrompt( + { + sessionId, + startupConfig: { modelServiceId: 'gpt-5.4(openai)' }, + modelServiceId: 'gpt-4.1(openai)', + }, + 'hello', + ), + ).rejects.toMatchObject({ code: 'invalid_startup_config' }); + expect(harness.bridge.spawnStandaloneSession).not.toHaveBeenCalled(); + expect(harness.bridge.setSessionConfigOption).not.toHaveBeenCalled(); + }); + + it('applies startup selection after commit and before release and initial prompt', async () => { + mockDurableStandalone(); + const harness = createHarness(); + const sequence: string[] = []; + harness.bridge.commitManagedConversationBinding.mockImplementation( + async () => { + sequence.push('commit'); + }, + ); + harness.bridge.releaseManagedConversationBinding.mockImplementation( + async () => { + sequence.push('release'); + }, + ); + harness.bridge.setSessionConfigOption.mockImplementation( + async (_id, request) => { + sequence.push(request.configId); + return { + configOptions: [ + { id: 'model', currentValue: 'gpt-5.4(openai)' }, + { id: 'reasoning_effort', currentValue: 'high' }, + ], + }; + }, + ); + harness.bridge.sendPrompt.mockImplementation( + (_id, _req, _signal, context) => { + sequence.push('prompt'); + context?.onPromptAdmitted?.(); + return Promise.resolve({ stopReason: 'end_turn' }); + }, + ); + const created = await harness.service.createWithInitialPrompt( + { + sessionId, + startupConfig: { + modelServiceId: 'gpt-5.4(openai)', + reasoningEffort: 'high', + }, + }, + 'hello', + ); + expect(sequence).toEqual([ + 'commit', + 'model', + 'reasoning_effort', + 'release', + 'prompt', + ]); + expect(created.session).toMatchObject({ + modelApplied: true, + startupConfigApplied: { + modelServiceId: 'gpt-5.4(openai)', + reasoningEffort: 'high', + effectiveReasoning: { state: 'enabled', effort: 'high' }, + }, + }); + }); + + it('prepares a model-only standalone session without a reasoning option', async () => { + mockDurableStandalone(); + const harness = createHarness(); + const modelServiceId = 'gpt-4.1(openai)'; + harness.bridge.setSessionConfigOption.mockResolvedValue({ + configOptions: [{ id: 'model', currentValue: modelServiceId }], + }); + const created = await harness.service.createWithInitialPrompt( + { sessionId, startupConfig: { modelServiceId } }, + 'hello', + ); + expect(created.session).toMatchObject({ + modelApplied: true, + startupConfigApplied: { modelServiceId }, + }); + expect(created.session.startupConfigApplied).not.toHaveProperty( + 'reasoningEffort', + ); + expect(created.session.startupConfigApplied).not.toHaveProperty( + 'effectiveReasoning', + ); + expect( + harness.bridge.setSessionConfigOption, + ).toHaveBeenCalledExactlyOnceWith(sessionId, { + sessionId, + configId: 'model', + value: modelServiceId, + }); + expect( + harness.bridge.releaseManagedConversationBinding, + ).toHaveBeenCalledOnce(); + expect(harness.bridge.sendPrompt).toHaveBeenCalledOnce(); + }); + + it('rolls back a rejected selection and allows the same standalone id to be retried', async () => { + mockDurableStandalone(); + let persisted = false; + vi.mocked(SessionService.prototype.findSessionIdIgnoringCase) + .mockReset() + .mockImplementation(async () => (persisted ? sessionId : undefined)); + const remove = vi + .spyOn(SessionService.prototype, 'removeSession') + .mockImplementation(async () => { + persisted = false; + return true; + }); + const harness = createHarness(); + harness.bridge.spawnStandaloneSession.mockImplementation(async () => { + persisted = true; + return { + sessionId, + workspaceCwd: root.canonicalRoot, + attached: false, + sourceType: 'standalone', + sourcePersisted: true, + }; + }); + harness.bridge.setSessionConfigOption + .mockRejectedValueOnce( + RequestError.invalidParams(undefined, 'selection failed'), + ) + .mockResolvedValue({ + configOptions: [ + { id: 'model', currentValue: 'gpt-5.4(openai)' }, + { id: 'reasoning_effort', currentValue: 'high' }, + ], + }); + const request = { + sessionId, + startupConfig: { + modelServiceId: 'gpt-5.4(openai)', + reasoningEffort: 'high' as const, + }, + }; + await expect( + harness.service.createWithInitialPrompt(request, 'hello'), + ).rejects.toMatchObject({ + code: 'startup_config_rejected', + message: expect.stringContaining('selection failed'), + }); + expect(persisted).toBe(false); + expect(remove).toHaveBeenCalledExactlyOnceWith(sessionId); + expect( + harness.discardEmptyConversationDirectory, + ).toHaveBeenCalledExactlyOnceWith(sessionId); + expect(harness.bridge.killSession).toHaveBeenCalledTimes(1); + expect( + harness.bridge.releaseManagedConversationBinding, + ).not.toHaveBeenCalled(); + expect(harness.bridge.sendPrompt).not.toHaveBeenCalled(); + await expect( + harness.service.createWithInitialPrompt(request, 'retry'), + ).resolves.toMatchObject({ session: { sessionId, modelApplied: true } }); + expect(persisted).toBe(true); + expect(harness.bridge.sendPrompt).toHaveBeenCalledOnce(); + }); + + it.each([ + new Error('channel closed'), + new BridgeTimeoutError('setSessionConfigOption', 10), + new BridgeChannelClosedError('mid-request'), + ])('keeps uncertain startup failures recoverable: %s', async (error) => { + mockDurableStandalone(); + const remove = vi.spyOn(SessionService.prototype, 'removeSession'); + const harness = createHarness(); + harness.bridge.setSessionConfigOption.mockRejectedValueOnce(error); + await expect( + harness.service.createWithInitialPrompt( + { sessionId, startupConfig: { modelServiceId: 'gpt-5.4(openai)' } }, + 'hello', + ), + ).rejects.toMatchObject({ code: 'standalone_creation_outcome_unknown' }); + expect(remove).not.toHaveBeenCalled(); + expect( + harness.bridge.releaseManagedConversationBinding, + ).not.toHaveBeenCalled(); + expect(harness.bridge.sendPrompt).not.toHaveBeenCalled(); + }); + + it('retains uncertain-outcome containment when startup cleanup fails', async () => { + mockDurableStandalone(); + const harness = createHarness(); + harness.bridge.setSessionConfigOption.mockRejectedValueOnce( + RequestError.invalidParams(undefined, 'selection failed'), + ); + harness.bridge.killSession.mockRejectedValueOnce(new Error('close failed')); + await expect( + harness.service.createWithInitialPrompt( + { + sessionId, + startupConfig: { + modelServiceId: 'gpt-5.4(openai)', + reasoningEffort: 'high', + }, + }, + 'hello', + ), + ).rejects.toMatchObject({ code: 'standalone_creation_outcome_unknown' }); + expect(harness.quarantineRuntime).toHaveBeenCalled(); + expect( + harness.bridge.releaseManagedConversationBinding, + ).not.toHaveBeenCalled(); + expect(harness.bridge.sendPrompt).not.toHaveBeenCalled(); + }); + + it('runs the same rollback sequence for a rejected startup selection and an unapplied scheduled-child model', async () => { + // The startup-config branch and the model_selection_failed branch + // share one rollback helper; both must run the identical sequence so + // the two copies cannot drift. + const sequences: string[][] = []; + let current: string[] | undefined; + const instrument = (h: Harness) => { + current = []; + sequences.push(current); + h.bridge.killSession.mockImplementation(async () => { + current?.push('killSession'); + return true; + }); + h.discardEmptyConversationDirectory.mockImplementation(async () => { + current?.push('discardEmptyConversationDirectory'); + return true; + }); + }; + const removeSession = vi + .spyOn(SessionService.prototype, 'removeSession') + .mockImplementation(async () => { + current?.push('removeSession'); + return true; + }); + + // Branch 1: a definite startup-config rejection. + mockDurableStandalone(); + let persisted = false; + vi.mocked(SessionService.prototype.findSessionIdIgnoringCase) + .mockReset() + .mockImplementation(async () => (persisted ? sessionId : undefined)); + removeSession.mockImplementation(async () => { + current?.push('removeSession'); + persisted = false; + return true; + }); + const startupHarness = createHarness(); + instrument(startupHarness); + startupHarness.bridge.spawnStandaloneSession.mockImplementation( + async () => { + persisted = true; + return { + sessionId, + workspaceCwd: root.canonicalRoot, + attached: false, + sourceType: 'standalone', + sourcePersisted: true, + }; + }, + ); + startupHarness.bridge.setSessionConfigOption.mockRejectedValueOnce( + RequestError.invalidParams(undefined, 'selection failed'), + ); + await expect( + startupHarness.service.createWithInitialPrompt( + { sessionId, startupConfig: { modelServiceId: 'gpt-5.4(openai)' } }, + 'hello', + ), + ).rejects.toMatchObject({ code: 'startup_config_rejected' }); + + // Branch 2: the scheduled child's spawn-time model apply failed. + const childSessionId = '22222222-2222-4222-8222-222222222222'; + const storageParentSessionId = sessionId.toUpperCase(); + vi.mocked(SessionService.prototype.findSessionIdIgnoringCase) + .mockReset() + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(sessionId) + .mockResolvedValueOnce(storageParentSessionId) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(childSessionId) + .mockResolvedValueOnce(undefined); + vi.mocked(SessionService.prototype.readCreationMetadataIfReadable) + .mockReset() + .mockResolvedValueOnce({ sourceType: 'standalone' }) + .mockResolvedValueOnce({ sourceType: 'standalone' }) + .mockResolvedValue({ + sourceType: 'standalone', + parentSessionId: storageParentSessionId, + }); + removeSession.mockImplementation(async () => { + current?.push('removeSession'); + return true; + }); + const childHarness = createHarness(); + instrument(childHarness); + await childHarness.service.createWithInitialPrompt( + { sessionId }, + 'parent task', + ); + childHarness.bridge.getSessionSummary.mockReturnValue({ + sessionId, + workspaceCwd: root.canonicalRoot, + createdAt: '2026-08-24T00:00:00.000Z', + sourceType: 'standalone', + clientCount: 0, + hasActivePrompt: false, + }); + childHarness.bridge.spawnStandaloneSession.mockResolvedValueOnce({ + sessionId: childSessionId, + workspaceCwd: root.canonicalRoot, + attached: false, + sourceType: 'standalone', + sourcePersisted: true, + parentSessionPersisted: true, + modelApplied: false, + }); + await expect( + childHarness.service.createChildWithInitialPrompt( + { + sessionId: childSessionId, + parentSessionId: sessionId, + promptId: 'prompt-child', + modelServiceId: 'missing-model', + sourceType: 'default', + sourceId: 'scheduled_task_run:task-1', + }, + 'child task', + ), + ).rejects.toMatchObject({ + code: 'model_selection_failed', + sessionId: childSessionId, + }); + + expect(sequences).toEqual([ + ['killSession', 'removeSession', 'discardEmptyConversationDirectory'], + ['killSession', 'removeSession', 'discardEmptyConversationDirectory'], + ]); + }); + it('surfaces a failed spawn-time model apply as modelApplied false', async () => { mockDurableStandalone(); const harness = createHarness(); diff --git a/packages/cli/src/serve/conversations/standalone-session-service.ts b/packages/cli/src/serve/conversations/standalone-session-service.ts index 8e10feaebe9..2aa176aba2d 100644 --- a/packages/cli/src/serve/conversations/standalone-session-service.ts +++ b/packages/cli/src/serve/conversations/standalone-session-service.ts @@ -4,6 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { + applySessionStartupConfig, + isSessionStartupConfigError, + parseSessionStartupConfig, + type SessionStartupConfig, +} from '@qwen-code/acp-bridge/sessionStartupConfig'; import { randomUUID } from 'node:crypto'; import { CdWhilePromptActiveError, @@ -162,6 +168,7 @@ export class StandaloneSessionServiceError extends Error { } export interface CreateStandaloneSessionRequest { + startupConfig?: SessionStartupConfig; sessionId: string; modelServiceId?: string; approvalMode?: ApprovalMode; @@ -2579,6 +2586,11 @@ export class StandaloneSessionService { >, promptId: string = randomUUID(), ): Promise { + const startupConfig = parseSessionStartupConfig( + request.startupConfig, + request, + ); + request = { ...request, ...(startupConfig ? { startupConfig } : {}) }; const { sessionId } = parseRequiredSessionId(request.sessionId); let entry: CreatingEntry | undefined; const attempt: CreationAttempt = { @@ -2911,26 +2923,40 @@ export class StandaloneSessionService { attempt.diagnostic.phase = 'model_selection'; attempt.cause = serviceError('model_selection_failed', sessionId, true); attempt.diagnostic.cleanupOutcome = 'unknown'; - await this.cleanRollbackBeforePersistence(runtime, sessionId); + await this.rollbackSessionAndDiscardDirectory(runtime, sessionId); attempt.diagnostic.cleanupOutcome = 'rolled_back'; - try { - await this.options.workspace.discardEmptyConversationDirectory( - sessionId, - ); - } catch (error) { - debugLogger.warn( - `Could not discard the rolled-back standalone directory for ${sessionId}: ${error instanceof Error ? error.message : String(error)}`, - ); - } - this.directoryStates.delete(sessionId); throw serviceError('model_selection_failed', sessionId, true); } let initialPrompt: | CreatedStandaloneChildSession['initialPrompt'] | undefined; + const startupConfig = request.startupConfig; + let startupPreparationFailed = false; attempt.diagnostic.phase = 'binding'; try { - await this.bindAndRelease(runtime, sessionId, prepared.identity); + await this.bindAndRelease( + runtime, + sessionId, + prepared.identity, + startupConfig + ? async () => { + const startupConfigApplied = await applySessionStartupConfig( + runtime.bridge, + sessionId, + startupConfig, + ).catch((error: unknown) => { + startupPreparationFailed = isSessionStartupConfigError(error); + throw error; + }); + this.assertRuntimeCurrentOrQuarantine(runtime); + session = { + ...session, + modelApplied: true, + startupConfigApplied, + }; + } + : undefined, + ); if (prompt !== undefined) { attempt.diagnostic.phase = 'initial_prompt'; initialPrompt = await this.admitInitialPrompt( @@ -2944,6 +2970,12 @@ export class StandaloneSessionService { } catch (error) { if (error instanceof TerminalQuarantineSignal) throw error; attempt.cause = error; + if (startupPreparationFailed) { + attempt.diagnostic.cleanupOutcome = 'unknown'; + await this.rollbackSessionAndDiscardDirectory(runtime, sessionId); + attempt.diagnostic.cleanupOutcome = 'rolled_back'; + throw error; + } attempt.diagnostic.cleanupOutcome = 'unknown'; await this.closeOwnedSessionOrQuarantine(runtime, sessionId); attempt.diagnostic.cleanupOutcome = 'closed'; @@ -2976,6 +3008,7 @@ export class StandaloneSessionService { runtime: WorkspaceRuntime, sessionId: string, pinned: ConversationDirectoryIdentity, + beforeRelease?: () => Promise, ): Promise { const expectation = toBridgeExpectation(sessionId, pinned); const changed = await runtime.bridge.changeSessionCwd(sessionId, { @@ -3022,6 +3055,10 @@ export class StandaloneSessionService { pinned, agentBound: { eventEpoch, released: false }, }); + if (beforeRelease) { + await beforeRelease(); + this.assertRuntimeCurrentOrQuarantine(runtime); + } try { this.assertRuntimeCurrentOrQuarantine(runtime); await runtime.bridge.releaseManagedConversationBinding( @@ -3347,6 +3384,21 @@ export class StandaloneSessionService { } } + private async rollbackSessionAndDiscardDirectory( + runtime: WorkspaceRuntime, + sessionId: string, + ): Promise { + await this.cleanRollbackBeforePersistence(runtime, sessionId); + try { + await this.options.workspace.discardEmptyConversationDirectory(sessionId); + } catch (error) { + debugLogger.warn( + `Could not discard the rolled-back standalone directory for ${sessionId}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + this.directoryStates.delete(sessionId); + } + private async closeOwnedSessionOrQuarantine( runtime: WorkspaceRuntime, sessionId: string, diff --git a/packages/cli/src/serve/fast-path.test.ts b/packages/cli/src/serve/fast-path.test.ts index c1e7e40dc10..32da4addc1c 100644 --- a/packages/cli/src/serve/fast-path.test.ts +++ b/packages/cli/src/serve/fast-path.test.ts @@ -731,6 +731,32 @@ describe('serve fast path argument parsing', () => { ['rate-limit-read', ['--rate-limit-read', '120']], ['rate-limit-window-ms', ['--rate-limit-window-ms', '60000']], ['experimental-lsp', ['--experimental-lsp']], + ['profile', ['--profile', 'hosted-harness']], + ['experimental-managed-agents', ['--experimental-managed-agents']], + [ + 'experimental-managed-runtime-worker', + ['--experimental-managed-runtime-worker'], + ], + [ + 'experimental-managed-runtime-auto-local', + ['--experimental-managed-runtime-auto-local'], + ], + [ + 'experimental-managed-runtime-url', + ['--experimental-managed-runtime-url', 'http://127.0.0.1:3001'], + ], + [ + 'experimental-managed-runtime-token', + ['--experimental-managed-runtime-token', 'test-token'], + ], + [ + 'managed-runtime-broker-url', + ['--managed-runtime-broker-url', 'http://127.0.0.1:3002'], + ], + [ + 'managed-runtime-broker-token', + ['--managed-runtime-broker-token', 'test-token'], + ], ['restore-ask-user-question', ['--restore-ask-user-question']], ['external-tool-guard-mode', ['--external-tool-guard-mode', 'off']], [ @@ -747,6 +773,14 @@ describe('serve fast path argument parsing', () => { ]); const expectedFallbackOptions = new Set([ 'channel', + 'profile', + 'experimental-managed-agents', + 'experimental-managed-runtime-worker', + 'experimental-managed-runtime-auto-local', + 'experimental-managed-runtime-url', + 'experimental-managed-runtime-token', + 'managed-runtime-broker-url', + 'managed-runtime-broker-token', 'external-tool-guard-endpoint', 'external-tool-guard-mode', 'external-tool-guard-timeout-ms', diff --git a/packages/cli/src/serve/hosted-harness-contract.test.ts b/packages/cli/src/serve/hosted-harness-contract.test.ts index c767ae68a10..8c334dc6bb5 100644 --- a/packages/cli/src/serve/hosted-harness-contract.test.ts +++ b/packages/cli/src/serve/hosted-harness-contract.test.ts @@ -10,9 +10,10 @@ import { describe, expect, it } from 'vitest'; import { createHostedHarnessContract, HOSTED_HARNESS_BOOT_ID_HEADER, + HOSTED_HARNESS_CAPABILITY_DIGEST_ENV, HOSTED_HARNESS_PROTOCOL_HEADER, HOSTED_HARNESS_UPGRADE, - hostedHarnessContractMiddleware, + installHostedHarnessContractMiddleware, isHostedHarnessCapabilityDigest, } from './hosted-harness-contract.js'; @@ -21,11 +22,9 @@ const BOOT_ID = '11111111-1111-4111-8111-111111111111'; function createApp() { const app = express(); - app.use( - '/session', - hostedHarnessContractMiddleware( - createHostedHarnessContract(DIGEST, BOOT_ID), - ), + installHostedHarnessContractMiddleware( + app, + createHostedHarnessContract(DIGEST, BOOT_ID), ); app.get('/session/:id', (req, res) => { res.status(200).json({ sessionId: req.params['id'] }); @@ -69,7 +68,7 @@ describe('Hosted Harness private contract', () => { false, ); expect(() => createHostedHarnessContract('sha256:not-a-digest')).toThrow( - 'Hosted Harness capability digest must be sha256:<64 lowercase hex characters>.', + `${HOSTED_HARNESS_CAPABILITY_DIGEST_ENV} must be sha256:<64 lowercase hex characters>.`, ); }); @@ -102,7 +101,7 @@ describe('Hosted Harness private contract', () => { expect(response.body.code).toBe('hosted_harness_protocol_required'); }); - it('rejects missing and malformed boot ids', async () => { + it('rejects malformed and stale boot ids', async () => { const missing = await supertest(createApp()) .get('/session/example') .set(HOSTED_HARNESS_PROTOCOL_HEADER, '1'); @@ -115,23 +114,20 @@ describe('Hosted Harness private contract', () => { .set(HOSTED_HARNESS_BOOT_ID_HEADER, 'not-a-uuid'); expect(malformed.status).toBe(400); expect(malformed.body.code).toBe('invalid_hosted_harness_boot_id'); - }); - it('rejects a stale process generation', async () => { - const response = await supertest(createApp()) + const stale = await supertest(createApp()) .get('/session/example') .set(HOSTED_HARNESS_PROTOCOL_HEADER, '1') .set( HOSTED_HARNESS_BOOT_ID_HEADER, '22222222-2222-4222-8222-222222222222', ); - - expect(response.status).toBe(409); - expect(response.headers['x-qwen-harness-boot-id']).toBe(BOOT_ID); - expect(response.body.code).toBe('hosted_harness_generation_mismatch'); + expect(stale.status).toBe(409); + expect(stale.headers['x-qwen-harness-boot-id']).toBe(BOOT_ID); + expect(stale.body.code).toBe('hosted_harness_generation_mismatch'); }); - it('passes a matching process generation', async () => { + it('passes a matching generation to the session route', async () => { const response = await supertest(createApp()) .get('/session/example') .set(HOSTED_HARNESS_PROTOCOL_HEADER, '1') @@ -148,4 +144,17 @@ describe('Hosted Harness private contract', () => { expect(response.status).toBe(200); expect(response.headers['x-qwen-harness-boot-id']).toBeUndefined(); }); + + it('leaves ordinary session routes unchanged when no contract is installed', async () => { + const app = express(); + installHostedHarnessContractMiddleware(app, undefined); + app.get('/session/:id', (req, res) => { + res.status(200).json({ sessionId: req.params['id'] }); + }); + + const response = await supertest(app).get('/session/example'); + + expect(response.status).toBe(200); + expect(response.headers['x-qwen-harness-boot-id']).toBeUndefined(); + }); }); diff --git a/packages/cli/src/serve/hosted-harness-contract.ts b/packages/cli/src/serve/hosted-harness-contract.ts index 8a04b832633..e7ccbf3c5f2 100644 --- a/packages/cli/src/serve/hosted-harness-contract.ts +++ b/packages/cli/src/serve/hosted-harness-contract.ts @@ -5,8 +5,11 @@ */ import { randomUUID } from 'node:crypto'; -import type { RequestHandler } from 'express'; +import type { Application, RequestHandler } from 'express'; +import type { HostedHarnessCapabilities } from './types.js'; +export const HOSTED_HARNESS_CAPABILITY_DIGEST_ENV = + 'QWEN_HOSTED_HARNESS_CAPABILITY_DIGEST'; export const HOSTED_HARNESS_PROTOCOL_VERSION = 1 as const; export const HOSTED_HARNESS_PROTOCOL_HEADER = 'X-Qwen-Harness-Protocol-Version'; export const HOSTED_HARNESS_BOOT_ID_HEADER = 'X-Qwen-Harness-Boot-Id'; @@ -16,14 +19,7 @@ const CAPABILITY_DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/u; const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu; -export interface HostedHarnessContract { - readonly protocolVersions: { - readonly current: typeof HOSTED_HARNESS_PROTOCOL_VERSION; - readonly supported: readonly [typeof HOSTED_HARNESS_PROTOCOL_VERSION]; - }; - readonly bootId: string; - readonly capabilityDigest: string; -} +export type HostedHarnessContract = HostedHarnessCapabilities; export function isHostedHarnessCapabilityDigest(value: string): boolean { return CAPABILITY_DIGEST_PATTERN.test(value); @@ -35,13 +31,12 @@ export function createHostedHarnessContract( ): HostedHarnessContract { if (!isHostedHarnessCapabilityDigest(capabilityDigest)) { throw new Error( - 'Hosted Harness capability digest must be sha256:<64 lowercase hex characters>.', + `${HOSTED_HARNESS_CAPABILITY_DIGEST_ENV} must be sha256:<64 lowercase hex characters>.`, ); } if (!UUID_PATTERN.test(bootId)) { throw new Error('Hosted Harness bootId must be an RFC UUID v1-v5.'); } - return Object.freeze({ protocolVersions: Object.freeze({ current: HOSTED_HARNESS_PROTOCOL_VERSION, @@ -85,7 +80,14 @@ export function hostedHarnessContractMiddleware( }); return; } - next(); }; } + +export function installHostedHarnessContractMiddleware( + app: Application, + contract: HostedHarnessContract | undefined, +): void { + if (!contract) return; + app.use('/session', hostedHarnessContractMiddleware(contract)); +} diff --git a/packages/cli/src/serve/hosted-harness-profile.test.ts b/packages/cli/src/serve/hosted-harness-profile.test.ts new file mode 100644 index 00000000000..3dc6022703e --- /dev/null +++ b/packages/cli/src/serve/hosted-harness-profile.test.ts @@ -0,0 +1,54 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ +import { describe, expect, it } from 'vitest'; +import { validateHostedHarnessProfile } from './hosted-harness-profile.js'; + +const options = { + port: 0, + hostname: '127.0.0.1', + mode: 'http-bridge' as const, +}; + +describe('unimplemented deployment profiles', () => { + it('leaves the ordinary daemon available', () => { + expect(() => validateHostedHarnessProfile(options)).not.toThrow(); + }); + + it('rejects hosted mode even with complete deployment credentials', () => { + expect(() => + validateHostedHarnessProfile({ + ...options, + profile: 'hosted-harness', + token: 'harness-secret', + serveWebShell: false, + managedRuntimeBrokerUrl: 'http://127.0.0.1:8080', + managedRuntimeBrokerToken: 'broker-secret', + hostedHarnessCapabilityDigest: `sha256:${'a'.repeat(64)}`, + }), + ).toThrow('Broker-backed session loop is not implemented'); + }); + + it.each([ + { experimentalManagedAgents: true }, + { experimentalManagedRuntimeWorker: true }, + { experimentalManagedRuntimeAutoLocal: true }, + { experimentalManagedRuntimeUrl: 'http://127.0.0.1:8080' }, + { experimentalManagedRuntimeToken: '' }, + ])('rejects an unsupported experimental option: %j', (option) => { + expect(() => + validateHostedHarnessProfile({ ...options, ...option }), + ).toThrow('not implemented'); + }); + + it('rejects broker options outside hosted mode', () => { + expect(() => + validateHostedHarnessProfile({ + ...options, + managedRuntimeBrokerToken: 'secret', + }), + ).toThrow('require --profile hosted-harness'); + }); +}); diff --git a/packages/cli/src/serve/hosted-harness-profile.ts b/packages/cli/src/serve/hosted-harness-profile.ts new file mode 100644 index 00000000000..9882c899edf --- /dev/null +++ b/packages/cli/src/serve/hosted-harness-profile.ts @@ -0,0 +1,37 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { ServeOptions } from './types.js'; + +export function validateHostedHarnessProfile( + opts: Omit, +): void { + if (opts.profile === 'hosted-harness') { + throw new Error( + '--profile hosted-harness is not available: the Broker-backed session loop is not implemented.', + ); + } + if ( + opts.experimentalManagedAgents || + opts.experimentalManagedRuntimeWorker || + opts.experimentalManagedRuntimeAutoLocal || + opts.experimentalManagedRuntimeUrl !== undefined || + opts.experimentalManagedRuntimeToken !== undefined + ) { + throw new Error( + 'Experimental Managed Gateway and Runtime worker modes are not implemented.', + ); + } + if ( + opts.managedRuntimeBrokerUrl !== undefined || + opts.managedRuntimeBrokerToken !== undefined || + opts.hostedHarnessCapabilityDigest !== undefined + ) { + throw new Error( + 'Managed Runtime Broker options require --profile hosted-harness, which is not available yet.', + ); + } +} diff --git a/packages/cli/src/serve/local-process-runtime-activator.test.ts b/packages/cli/src/serve/local-process-runtime-activator.test.ts new file mode 100644 index 00000000000..c04f485aeaa --- /dev/null +++ b/packages/cli/src/serve/local-process-runtime-activator.test.ts @@ -0,0 +1,237 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ +// @vitest-environment node +import { mkdtemp, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + LocalProcessRuntimeActivator, + managedWorkerEnvironment, +} from './local-process-runtime-activator.js'; +import type { WorkspaceRuntime } from './workspace-registry.js'; + +const fixture = ` +process.on('message', b => { + if (b.type === 'shutdown') process.exit(0); + if (b.type === 'boot') setTimeout(() => process.send({ ...b, token: undefined, type: 'ready', url: 'http://127.0.0.1:12345' }), 50); +}); +process.on('disconnect', () => process.exit(0)); +process.on('SIGTERM', () => process.exit(0)); +`; +const active: LocalProcessRuntimeActivator[] = []; +const dirs: string[] = []; +afterEach(async () => { + await Promise.all(active.splice(0).map((a) => a.close())); + await Promise.all( + dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })), + ); +}); +async function setup(maxWorkers = 4, code = fixture, startupMs = 3000) { + const stateDir = await mkdtemp( + path.join(os.tmpdir(), 'qwen-activator-test-'), + ); + dirs.push(stateDir); + const log = vi.fn(); + const activator = new LocalProcessRuntimeActivator({ + stateDir, + cliEntry: process.execPath, + launcher: ['-e', code], + env: process.env, + maxWorkers, + startupMs, + log, + }); + active.push(activator); + return { activator, log }; +} +function scope(id = 'a') { + return { + tenantId: 'tenant', + runtime: { + workspaceId: id, + workspaceCwd: os.tmpdir(), + trusted: true, + } as WorkspaceRuntime, + }; +} + +describe('owned Runtime activation', () => { + it('separates admission closure from verified worker exit', async () => { + const { activator } = await setup( + 1, + fixture.replace( + "process.on('SIGTERM', () => process.exit(0));", + "process.on('SIGTERM', () => setTimeout(() => process.exit(0), 250));", + ), + ); + const workspace = scope(); + const use = activator.activate(workspace); + await use.endpoint; + let exited = false; + const exit = use.exited.then(() => { + exited = true; + }); + const shutdown = activator.revokeWorkspace(workspace.runtime); + expect(use.signal.aborted).toBe(true); + await Promise.resolve(); + expect(exited).toBe(false); + expect(activator.workspaceActivity(workspace.runtime)).toBe(1); + await shutdown; + await exit; + expect(exited).toBe(true); + expect(activator.workspaceActivity(workspace.runtime)).toBe(0); + }); + + it('starts once for concurrent uses, fences each lease and ignores duplicate release', async () => { + const { activator, log } = await setup(); + const workspace = scope(); + const first = activator.activate(workspace); + const second = activator.activate(workspace); + expect(activator.workspaceActivity(workspace.runtime)).toBe(2); + const [one, two] = await Promise.all([first.endpoint, second.endpoint]); + expect(one.boot.leaseId).toBe(two.boot.leaseId); + expect( + log.mock.calls.filter(([event]) => event === 'started'), + ).toHaveLength(1); + first.release('cancelled'); + first.release('cancelled'); + expect(second.signal.aborted).toBe(false); + expect(activator.workspaceActivity(workspace.runtime)).toBe(1); + second.release('completed'); + expect(activator.workspaceActivity(workspace.runtime)).toBe(0); + await activator.revokeWorkspace(workspace.runtime); + expect(second.signal.aborted).toBe(true); + }); + it('keeps uncertain execution reserved after cancellation ACK until other uses finish', async () => { + const { activator } = await setup(1); + const workspace = scope(); + const first = activator.activate(workspace); + const other = activator.activate(workspace); + await first.endpoint; + const executed = first.beginOperation(); + executed(false); + first.release('cancelled'); + expect(other.signal.aborted).toBe(false); + expect(activator.workspaceActivity(workspace.runtime)).toBe(2); + await expect(activator.activate(workspace).endpoint).rejects.toMatchObject({ + code: 'managed_runtime_unavailable', + }); + await expect(activator.activate(scope('b')).endpoint).rejects.toMatchObject( + { code: 'managed_runtime_capacity_exhausted' }, + ); + other.release('completed'); + await vi.waitFor(() => + expect(activator.workspaceActivity(workspace.runtime)).toBe(0), + ); + const replacement = activator.activate(workspace); + const endpoint = await replacement.endpoint; + expect(endpoint.boot.epoch).toBe(2); + replacement.release('completed'); + }); + it('evicts idle generations before a new scope starts and never evicts busy uses', async () => { + const { activator } = await setup(1); + const workspace = scope(); + const first = activator.activate(workspace); + await first.endpoint; + await expect(activator.activate(scope('b')).endpoint).rejects.toMatchObject( + { code: 'managed_runtime_capacity_exhausted' }, + ); + first.release('completed'); + const next = activator.activate(scope('b')); + await next.endpoint; + expect(first.signal.aborted).toBe(true); + expect(activator.workspaceActivity(workspace.runtime)).toBe(0); + next.release('completed'); + }); + it('blocks reload until the new snapshot publishes, and drain rollback cannot revive revoked use', async () => { + const { activator } = await setup(); + const workspace = scope(); + const first = activator.activate(workspace); + await first.endpoint; + await activator.reloadWorkspace(workspace.runtime); + await expect(activator.activate(workspace).endpoint).rejects.toThrow(); + activator.completeReload(workspace.runtime); + const next = activator.activate(workspace); + expect((await next.endpoint).boot.epoch).toBe(2); + activator.beginDrain(workspace.runtime); + await expect(activator.activate(workspace).endpoint).rejects.toThrow(); + activator.cancelDrain(workspace.runtime); + next.release('completed'); + await activator.revokeWorkspace(workspace.runtime); + activator.cancelDrain(workspace.runtime); + await expect(activator.activate(workspace).endpoint).rejects.toThrow(); + }); + it('contains startup cancellation and shutdown without late attachment', async () => { + const { activator } = await setup(); + const workspace = scope(); + const first = activator.activate(workspace); + first.release('cancelled'); + await expect(first.endpoint).rejects.toThrow(); + await activator.close(); + expect(activator.workspaceActivity(workspace.runtime)).toBe(0); + await expect(activator.activate(workspace).endpoint).rejects.toThrow(); + }); + it('bounds replacement requests while eviction is still awaiting exit', async () => { + const { activator } = await setup( + 1, + fixture.replace( + "process.on('SIGTERM', () => process.exit(0));", + "process.on('SIGTERM', () => setTimeout(() => process.exit(0), 300));", + ), + ); + const first = activator.activate(scope('a')); + await first.endpoint; + first.release('completed'); + const replacement = activator.activate(scope('b')); + replacement.release('completed'); + for (let i = 0; i < 20; i++) + await expect( + activator.activate(scope(`extra-${i}`)).endpoint, + ).rejects.toMatchObject({ code: 'managed_runtime_capacity_exhausted' }); + await replacement.endpoint; + }); + it('reclaims a proven dead worker after forced termination', async () => { + const { activator } = await setup( + 1, + fixture.replace( + "process.on('SIGTERM', () => process.exit(0));", + "process.on('SIGTERM', () => {});", + ), + 15_000, + ); + const first = activator.activate(scope('a')); + await first.endpoint; + first.release('completed'); + const next = activator.activate(scope('b')); + await next.endpoint; + expect(first.signal.aborted).toBe(true); + next.release('completed'); + }, 20_000); + + it('never forwards ambient model, Gateway, IDE or loader secrets', () => { + expect( + managedWorkerEnvironment({ + HOME: '/home/test', + PATH: '/bin', + WSL_INTEROP: '/run/WSL/123_interop', + QWEN_HOME: '/config', + QWEN_CODE_TRUSTED_FOLDERS_PATH: '/trust', + OPENAI_API_KEY: 'secret', + QWEN_SERVER_TOKEN: 'secret', + QWEN_MANAGED_RUNTIME_TOKEN: 'secret', + NODE_OPTIONS: '--import evil', + QWEN_CODE_IDE_WORKSPACE_PATH: '/other', + }), + ).toEqual({ + HOME: '/home/test', + PATH: '/bin', + WSL_INTEROP: '/run/WSL/123_interop', + QWEN_HOME: '/config', + QWEN_CODE_TRUSTED_FOLDERS_PATH: '/trust', + }); + }); +}); diff --git a/packages/cli/src/serve/local-process-runtime-activator.ts b/packages/cli/src/serve/local-process-runtime-activator.ts new file mode 100644 index 00000000000..66f7dc8298c --- /dev/null +++ b/packages/cli/src/serve/local-process-runtime-activator.ts @@ -0,0 +1,449 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { spawn, type ChildProcess } from 'node:child_process'; +import { randomUUID, randomBytes } from 'node:crypto'; +import { setMaxListeners } from 'node:events'; +import { mkdir, rm } from 'node:fs/promises'; +import path from 'node:path'; +import { + ProcessRegistry, + ProcessExitError, + type TrackedChildProcess, +} from '@qwen-code/acp-bridge'; +import { + MANAGED_RUNTIME_STARTUP_MS, + ManagedRuntimeReleasedError, + type ManagedRuntimeEndpoint, + type ManagedRuntimeScope, + type ManagedRuntimeUse, + type ManagedWorkerBoot, +} from './managed-runtime-activator.js'; +import { ManagedRuntimeProviderError } from './managed-runtime-provider.js'; +import type { WorkspaceRuntime } from './workspace-registry.js'; + +export interface LocalProcessRuntimeActivatorOptions { + readonly stateDir: string; + readonly cliEntry: string; + readonly launcher: readonly string[]; + readonly env: NodeJS.ProcessEnv; + readonly maxWorkers?: number; + readonly startupMs?: number; + readonly log: (event: string, fields: Record) => void; +} +interface Generation { + readonly key: string; + readonly scope: ManagedRuntimeScope; + readonly boot: ManagedWorkerBoot; + readonly controller: AbortController; + readonly uses: Set; + readonly exited: Promise; + readonly resolveExit: () => void; + readonly rejectExit: (error: unknown) => void; + endpoint: Promise; + child?: ChildProcess; + tracked?: TrackedChildProcess; + stop?: Promise; + ready: boolean; + retiring: boolean; + operations: number; + lastUsed: number; + readonly startedAt: number; +} + +export function managedWorkerEnvironment( + source: NodeJS.ProcessEnv, +): NodeJS.ProcessEnv { + const result: NodeJS.ProcessEnv = {}; + for (const [key, value] of Object.entries(source)) { + if ( + /^(PATH|HOME|USERPROFILE|HOMEDRIVE|HOMEPATH|SystemRoot|WINDIR|COMSPEC|PATHEXT|TMP|TEMP|TMPDIR|LANG|LC_[A-Z_]+|TZ|WSL_INTEROP|SSL_CERT_FILE|SSL_CERT_DIR|NODE_EXTRA_CA_CERTS|HTTPS?_PROXY|ALL_PROXY|NO_PROXY|https?_proxy|all_proxy|no_proxy|QWEN_HOME|QWEN_CODE_TRUSTED_FOLDERS_PATH)$/.test( + key, + ) + ) + result[key] = value; + } + for (const key of ['QWEN_HOME', 'QWEN_CODE_TRUSTED_FOLDERS_PATH']) { + if (result[key]) result[key] = path.resolve(result[key]); + } + return result; +} + +export class LocalProcessRuntimeActivator { + private readonly incarnation = randomUUID(); + private readonly registry = new ProcessRegistry(); + private readonly generations = new Map(); + private readonly epochs = new Map(); + private readonly draining = new Set(); + private readonly revoked = new Set(); + private readonly reloading = new Set(); + private closed = false; + private closePromise?: Promise; + + constructor(private readonly options: LocalProcessRuntimeActivatorOptions) {} + + activate(scope: ManagedRuntimeScope): ManagedRuntimeUse { + const key = JSON.stringify([ + scope.tenantId, + scope.runtime.workspaceId, + scope.runtime.workspaceCwd, + ]); + let generation = this.generations.get(key); + if ( + this.closed || + this.draining.has(scope.runtime) || + this.revoked.has(scope.runtime) || + this.reloading.has(scope.runtime) || + generation?.retiring || + (generation && generation.scope.runtime !== scope.runtime) + ) { + return this.unavailable('managed_runtime_unavailable'); + } + if (!generation) { + const limit = this.options.maxWorkers ?? 4; + let eviction: Promise | undefined; + if (this.generations.size > limit) + return this.unavailable('managed_runtime_capacity_exhausted'); + if (this.generations.size >= limit) { + const idle = [...this.generations.values()] + .filter((g) => !g.retiring && g.uses.size === 0 && g.operations === 0) + .sort((a, b) => a.lastUsed - b.lastUsed)[0]; + if (!idle) + return this.unavailable('managed_runtime_capacity_exhausted'); + eviction = this.stop( + idle, + new ManagedRuntimeReleasedError('Managed Runtime evicted.'), + ); + } + const epoch = (this.epochs.get(key) ?? 0) + 1; + this.epochs.set(key, epoch); + const leaseId = randomUUID(); + let resolveExit!: () => void; + let rejectExit!: (error: unknown) => void; + const exited = new Promise((resolve, reject) => { + resolveExit = resolve; + rejectExit = reject; + }); + void exited.catch(() => {}); + generation = { + key, + scope, + boot: { + type: 'boot', + cliEntry: this.options.cliEntry, + version: 1, + gatewayIncarnation: this.incarnation, + leaseId, + epoch, + tenantId: scope.tenantId, + workspaceId: scope.runtime.workspaceId, + workspaceCwd: scope.runtime.workspaceCwd, + token: randomBytes(32).toString('hex'), + outputRoot: path.join(this.options.stateDir, 'workers', leaseId), + }, + controller: new AbortController(), + uses: new Set(), + exited, + resolveExit, + rejectExit, + endpoint: Promise.resolve(undefined as never), + ready: false, + retiring: false, + operations: 0, + lastUsed: Date.now(), + startedAt: Date.now(), + }; + setMaxListeners(0, generation.controller.signal); + this.generations.set(key, generation); + const active = generation; + active.endpoint = this.start(active, eviction); + void active.endpoint.catch((error) => { + void this.stop( + active, + error instanceof Error + ? error + : new Error('Managed Runtime startup failed.'), + ).catch(() => {}); + }); + } else this.log(generation, 'reuse'); + const active = generation; + const id = Symbol('runtime-use'); + active.uses.add(id); + let released = false; + const prepareDeadline = active.ready + ? Date.now() + (this.options.startupMs ?? MANAGED_RUNTIME_STARTUP_MS) + : undefined; + return { + endpoint: active.endpoint.then((endpoint) => + prepareDeadline === undefined + ? endpoint + : { ...endpoint, deadline: prepareDeadline }, + ), + signal: active.controller.signal, + exited: active.exited, + release: (reason) => { + if (released) return; + released = true; + active.uses.delete(id); + active.lastUsed = Date.now(); + if ( + active.uses.size === 0 && + (active.retiring || (!active.ready && reason !== 'completed')) + ) { + void this.stop( + active, + new ManagedRuntimeReleasedError('Managed Runtime use ended.'), + ).catch(() => {}); + } + }, + beginOperation: () => { + active.controller.signal.throwIfAborted(); + active.operations++; + let state: 'active' | 'uncertain' | 'finished' = 'active'; + return (certain) => { + if (state === 'finished') return; + if (certain) { + state = 'finished'; + active.operations--; + } else { + state = 'uncertain'; + active.retiring = true; + } + if (active.retiring && active.uses.size === 0) + void this.stop( + active, + new ManagedRuntimeReleasedError( + 'Managed Runtime uncertain execution contained.', + ), + ).catch(() => {}); + }; + }, + }; + } + + beginDrain(runtime: WorkspaceRuntime): void { + this.draining.add(runtime); + } + cancelDrain(runtime: WorkspaceRuntime): void { + this.draining.delete(runtime); + } + workspaceActivity(runtime: WorkspaceRuntime): number { + return [...this.generations.values()] + .filter((g) => g.scope.runtime === runtime) + .reduce((n, g) => n + g.uses.size + g.operations, 0); + } + revokeWorkspace(runtime: WorkspaceRuntime): Promise { + this.revoked.add(runtime); + return this.stopWorkspace(runtime); + } + reloadWorkspace(runtime: WorkspaceRuntime): Promise { + this.reloading.add(runtime); + return this.stopWorkspace(runtime); + } + completeReload(runtime: WorkspaceRuntime): void { + this.reloading.delete(runtime); + } + private async stopWorkspace(runtime: WorkspaceRuntime): Promise { + await Promise.all( + [...this.generations.values()] + .filter((g) => g.scope.runtime === runtime) + .map((g) => + this.stop( + g, + new ManagedRuntimeReleasedError( + 'Managed Runtime workspace invalidated.', + ), + ), + ), + ); + } + close(): Promise { + this.closed = true; + this.closePromise ??= Promise.all( + [...this.generations.values()].map((g) => + this.stop( + g, + new ManagedRuntimeReleasedError('Managed Runtime Gateway stopped.'), + ), + ), + ).then(() => this.registry.shutdown()); + return this.closePromise; + } + killAllSync(): void { + this.closed = true; + this.registry.killAllSync(); + } + + private unavailable( + code: 'managed_runtime_capacity_exhausted' | 'managed_runtime_unavailable', + ): ManagedRuntimeUse { + this.options.log(code, {}); + const endpoint = Promise.reject( + new ManagedRuntimeProviderError( + code, + 'Managed Runtime cannot accept a new use.', + true, + ), + ); + void endpoint.catch(() => {}); + return { + endpoint, + signal: new AbortController().signal, + exited: Promise.resolve(), + release: () => {}, + beginOperation: () => () => {}, + }; + } + private async start( + g: Generation, + eviction?: Promise, + ): Promise { + const deadline = + g.startedAt + (this.options.startupMs ?? MANAGED_RUNTIME_STARTUP_MS); + await eviction; + g.controller.signal.throwIfAborted(); + await mkdir(g.boot.outputRoot, { recursive: true, mode: 0o700 }); + g.controller.signal.throwIfAborted(); + if (Date.now() >= deadline) + throw new Error('Managed Runtime startup timed out.'); + let endpointUrl: string | undefined; + const reservation = this.registry.reserve(); + try { + const child = spawn(process.execPath, [...this.options.launcher], { + cwd: g.boot.workspaceCwd, + env: managedWorkerEnvironment(this.options.env), + detached: process.platform !== 'win32', + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + }); + g.child = child; + g.tracked = reservation.attach(child, { ownsProcessTree: true }); + // Drain without echoing arbitrary worker output (which may contain secrets). + child.stdout?.resume(); + child.stderr?.resume(); + child.once('exit', () => { + if (!g.retiring) + void this.stop( + g, + new Error('Managed Runtime worker exited unexpectedly.'), + ).catch(() => {}); + }); + await new Promise((resolve, reject) => { + const timer = setTimeout( + () => done(new Error('Managed Runtime startup timed out.')), + Math.max(0, deadline - Date.now()), + ); + const aborted = () => done(g.controller.signal.reason); + const error = () => + done(new Error('Managed Runtime worker could not start.')); + const exited = () => + done(new Error('Managed Runtime worker exited during startup.')); + const message = (value: unknown) => { + const ready = value as Partial> & { + type?: string; + url?: unknown; + }; + if ( + ready?.type !== 'ready' || + ready.version !== 1 || + ready.leaseId !== g.boot.leaseId || + ready.epoch !== g.boot.epoch || + ready.gatewayIncarnation !== this.incarnation || + ready.workspaceId !== g.boot.workspaceId || + ready.workspaceCwd !== g.boot.workspaceCwd || + ready.tenantId !== g.boot.tenantId || + typeof ready.url !== 'string' + ) + return done( + new Error( + 'Managed Runtime worker returned an invalid handshake.', + ), + ); + let url: URL; + try { + url = new URL(ready.url); + } catch { + done(new Error('Managed Runtime worker returned an invalid URL.')); + return; + } + if ( + url.protocol !== 'http:' || + url.hostname !== '127.0.0.1' || + !url.port || + url.username || + url.password || + url.pathname !== '/' || + url.search || + url.hash + ) + return done( + new Error('Managed Runtime worker returned an invalid endpoint.'), + ); + endpointUrl = url.origin; + done(); + }; + const done = (error?: unknown) => { + clearTimeout(timer); + g.controller.signal.removeEventListener('abort', aborted); + child.removeListener('message', message); + child.removeListener('error', errorHandler); + child.removeListener('exit', exited); + if (error) reject(error); + else resolve(); + }; + const errorHandler = error; + child.on('message', message); + child.once('error', errorHandler); + child.once('exit', exited); + g.controller.signal.addEventListener('abort', aborted, { once: true }); + if (g.controller.signal.aborted) aborted(); + else + child.send(g.boot, (err) => { + if (err) error(); + }); + }); + g.controller.signal.throwIfAborted(); + g.ready = true; + this.log(g, 'started'); + return { url: endpointUrl!, boot: g.boot, deadline }; + } finally { + reservation.cancel(); + } + } + private stop(g: Generation, reason: Error): Promise { + if (g.stop) return g.stop; + g.retiring = true; + g.controller.abort(reason); + g.stop = (async () => { + await g.endpoint.catch(() => {}); + if (g.tracked) { + try { + await g.tracked.terminate(); + } catch (error) { + if (!(error instanceof ProcessExitError)) throw error; + this.log(g, 'terminated'); + } + } + await rm(g.boot.outputRoot, { recursive: true, force: true }); + if (this.generations.get(g.key) === g) this.generations.delete(g.key); + this.log(g, 'released'); + g.resolveExit(); + })().catch((error) => { + g.rejectExit(error); + this.log(g, 'cleanup_failed'); + throw error; + }); + return g.stop; + } + private log(g: Generation, event: string): void { + this.options.log(event, { + workspaceId: g.boot.workspaceId, + generation: g.boot.epoch, + elapsedMs: Date.now() - g.startedAt, + pid: g.child?.pid, + }); + } +} diff --git a/packages/cli/src/serve/managed-context-envelope.test.ts b/packages/cli/src/serve/managed-context-envelope.test.ts new file mode 100644 index 00000000000..dd879f1cc91 --- /dev/null +++ b/packages/cli/src/serve/managed-context-envelope.test.ts @@ -0,0 +1,759 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +// Ajv exposes draft 2020-12 through this documented entry point. +// eslint-disable-next-line import/no-internal-modules +import { Ajv2020 } from 'ajv/dist/2020.js'; +import { describe, expect, it } from 'vitest'; +import { + MANAGED_CONTEXT_BOOT_VERSION, + MANAGED_CONTEXT_PROTOCOL, + MANAGED_CONTEXT_READY_VERSION, + MANAGED_CONTEXT_ROUTES, + ManagedContextInstallations, + checkManagedContextAttestation, + createManagedContextAttestationResponse, + createManagedContextReady, + isManagedContextReady, + parseManagedContextBoot, + type ManagedContextAttestationResponse, + type ManagedContextBoot, + type ManagedContextOutcome, + type ManagedContextReady, + type ManagedContextReceipt, +} from './managed-context-envelope.js'; +import { createHash } from 'node:crypto'; +import { + CONTEXT_BINDING_DOMAIN_TAG, + computeManagedContextDigest, + type ManagedContextBinding, +} from './managed-workspace-binding.js'; + +interface InstallationStep { + readonly request: unknown; + readonly expected: ManagedContextOutcome; +} + +interface FixtureSuite { + readonly contractVersion: 1; + readonly managedContext: string; + readonly bootVersion: number; + readonly readyVersion: number; + readonly routes: readonly unknown[]; + readonly errors: ReadonlyArray<{ + readonly status: number; + readonly code: string | null; + readonly classification: string; + }>; + readonly boot: ManagedContextBoot; + readonly ready: ManagedContextReady; + readonly attestationResponse: ManagedContextAttestationResponse; + readonly bootCases: ReadonlyArray<{ + readonly id: string; + readonly boot: unknown; + readonly valid: boolean; + }>; + readonly readyCases: ReadonlyArray<{ + readonly id: string; + readonly ready: unknown; + readonly valid: boolean; + }>; + readonly attestationCases: ReadonlyArray<{ + readonly id: string; + readonly body: unknown; + readonly expected: ManagedContextOutcome; + }>; + readonly installationSequences: ReadonlyArray<{ + readonly id: string; + readonly steps: readonly InstallationStep[]; + }>; +} + +interface SchemaDefinition { + readonly required: readonly string[]; + readonly properties: Record; + readonly additionalProperties: unknown; +} + +const INVALID_BOOT_MESSAGE = 'Managed context boot document is invalid.'; +const INVALID = { + status: 400, + code: 'managed_runtime_attestation_invalid', +} as const; +const CLOSED_DEFINITIONS = [ + 'bootV2', + 'readyV2', + 'attestationRequestV3', + 'attestationResponseV3', + 'installationRequest', + 'binding', + 'receipt', +] as const; +/** + * Fixture cases that the schema accepts although the contract refuses them, + * listed by their base case. JSON Schema cannot state UTF-8 byte or UTF-16 + * unit limits, a digest over the binding, or a comparison with the boot + * document. The 2^63-1 bound and the normal form of a directory are left to + * the fixtures because patterns could state them only unreadably. + */ +const BEYOND_SCHEMA = { + boot: [ + 'astral-mount-root-over-4096-bytes', + 'generation-over-int64', + 'two-byte-mount-root-over-4096-bytes', + ], + ready: ['other-epoch', 'other-incarnation', 'other-instance', 'other-lease'], + attestation: [ + 'astral-mount-root-over-4096-bytes', + 'generation-over-int64', + 'two-byte-mount-root-over-4096-bytes', + ], + installation: [ + 'astral-session-over-512-units', + 'binding-directory-dot-segment', + 'binding-directory-parent-inside', + 'binding-empty-segment', + 'binding-generation-over-int64', + 'binding-not-normalized', + 'binding-parent-segment', + 'binding-revision-over-int64', + 'binding-trailing-slash', + 'checks-the-digest-before-the-workspace', + 'digest-of-another-binding', + 'keeps-no-state-after-a-binding-rule-refusal#0', + 'keeps-no-state-after-a-digest-refusal#0', + ], +}; +/** The keywords of a closed record definition, and no others. */ +const CLOSED_KEYWORDS = [ + 'additionalProperties', + 'properties', + 'required', + 'type', +]; + +const contractDirectory = path.join( + path.dirname(fileURLToPath(import.meta.url)), + 'contracts', +); +const fixtures = JSON.parse( + fs.readFileSync( + path.join(contractDirectory, 'managed-context-v1.fixtures.json'), + 'utf8', + ), +) as FixtureSuite; +const schema = JSON.parse( + fs.readFileSync( + path.join(contractDirectory, 'managed-context-v1.schema.json'), + 'utf8', + ), +) as { + readonly $id: string; + readonly $defs: Record; +}; +const ajv = new Ajv2020({ strict: true }); +const validateSuite = ajv.compile(schema); + +function schemaAccepts(definition: string, value: unknown): boolean { + const validate = ajv.getSchema(`${schema.$id}#/$defs/${definition}`); + if (!validate) { + throw new Error(`The schema has no ${definition} definition.`); + } + return validate(value) as boolean; +} + +const boot = fixtures.boot; +const readyPort = Number(new URL(fixtures.ready.url).port); +const firstInstallation = fixtures.installationSequences.find( + (sequence) => sequence.id === 'installs-a-context', +)?.steps[0]; +if (!firstInstallation || firstInstallation.expected.status !== 200) { + throw new Error('The installs-a-context fixture must install a context.'); +} +const installationRequest = firstInstallation.request as Record< + string, + unknown +> & { readonly binding: Record }; + +function sortedKeys(value: object): string[] { + return Object.keys(value).sort(); +} + +/** + * The W0a digest over a binding's seven fields as given, checking no rule; + * undefined where a naive encoder has no single answer. + */ +function rawDigest(binding: Record): string | undefined { + const hash = createHash('sha256'); + for (const item of [ + CONTEXT_BINDING_DOMAIN_TAG, + binding['tenantId'], + binding['workspaceId'], + binding['workspaceGeneration'], + binding['storageId'], + binding['cwdRelative'], + binding['contextConfigRef'], + binding['contextRevision'], + ]) { + const text = typeof item === 'number' ? String(item) : item; + if (typeof text !== 'string' || /[\ud800-\udfff]/u.test(text)) { + return undefined; + } + const bytes = Buffer.from(text, 'utf8'); + const length = Buffer.alloc(4); + length.writeUInt32BE(bytes.length); + hash.update(length).update(bytes); + } + return `sha256:${hash.digest('hex')}`; +} + +/** + * The case an order case derives from. It differs from its base case in one + * valid field only, so the schema judges both alike. + */ +function baseCase(id: string): string { + return id.replace( + /-(?:after-an-installation#\d+|with-other-(?:identity|workspace))$/, + '', + ); +} + +function requiredKeys(definition: string): string[] { + return [...schema.$defs[definition].required].sort(); +} + +/** A copy of `value` whose `field` reads `first` once, then `later`. */ +function withChangingField( + value: Record, + field: string, + first: unknown, + later: unknown, +): { readonly value: Record; reads(): number } { + let reads = 0; + const copy = { ...value }; + Object.defineProperty(copy, field, { + enumerable: true, + get: () => { + reads++; + return reads === 1 ? first : later; + }, + }); + return { value: copy, reads: () => reads }; +} + +describe('Managed context envelope contract', () => { + it('validates the shared fixtures against the shared schema', () => { + expect(validateSuite(fixtures)).toBe(true); + expect(validateSuite.errors).toBeNull(); + }); + + it('closes every record definition of the schema', () => { + const actual = CLOSED_DEFINITIONS.map((name) => ({ + name, + keywords: sortedKeys(schema.$defs[name]), + additionalProperties: schema.$defs[name].additionalProperties, + properties: sortedKeys(schema.$defs[name].properties), + })); + const expected = CLOSED_DEFINITIONS.map((name) => ({ + name, + keywords: CLOSED_KEYWORDS, + additionalProperties: false, + properties: requiredKeys(name), + })); + + expect(actual).toStrictEqual(expected); + }); + + it('agrees with the schema except where the schema cannot state a rule', () => { + const disagreements = { + boot: new Set(), + ready: new Set(), + attestation: new Set(), + installation: new Set(), + }; + // Every disagreement must be a case the contract refuses and the schema + // accepts; the reverse would put the schema in the wrong. + const acceptedButSchemaInvalid: string[] = []; + const compare = ( + list: keyof typeof disagreements, + id: string, + schemaValid: boolean, + contractValid: boolean, + ) => { + if (schemaValid !== contractValid) { + disagreements[list].add(baseCase(id)); + if (contractValid) { + acceptedButSchemaInvalid.push(`${list} ${id}`); + } + } + }; + for (const fixture of fixtures.bootCases) { + compare( + 'boot', + fixture.id, + schemaAccepts('bootV2', fixture.boot), + fixture.valid, + ); + } + for (const fixture of fixtures.readyCases) { + compare( + 'ready', + fixture.id, + schemaAccepts('readyV2', fixture.ready), + fixture.valid, + ); + } + for (const fixture of fixtures.attestationCases) { + compare( + 'attestation', + fixture.id, + schemaAccepts('attestationRequestV3', fixture.body), + fixture.expected.status !== 400, + ); + } + for (const sequence of fixtures.installationSequences) { + sequence.steps.forEach((step, index) => { + compare( + 'installation', + sequence.steps.length > 1 ? `${sequence.id}#${index}` : sequence.id, + schemaAccepts('installationRequest', step.request), + step.expected.status !== 400, + ); + }); + } + + expect(acceptedButSchemaInvalid).toEqual([]); + expect({ + boot: [...disagreements.boot].sort(), + ready: [...disagreements.ready].sort(), + attestation: [...disagreements.attestation].sort(), + installation: [...disagreements.installation].sort(), + }).toEqual(BEYOND_SCHEMA); + }); + + it('pins the protocol token, versions and routes', () => { + expect(fixtures.contractVersion).toBe(1); + expect(fixtures.managedContext).toBe(MANAGED_CONTEXT_PROTOCOL); + expect(fixtures.bootVersion).toBe(MANAGED_CONTEXT_BOOT_VERSION); + expect(fixtures.readyVersion).toBe(MANAGED_CONTEXT_READY_VERSION); + expect(fixtures.routes).toStrictEqual(MANAGED_CONTEXT_ROUTES); + }); + + it('uses each case id once in each list', () => { + for (const list of [ + fixtures.bootCases, + fixtures.readyCases, + fixtures.attestationCases, + fixtures.installationSequences, + ]) { + const ids = list.map((fixture) => fixture.id); + + expect(new Set(ids).size).toBe(ids.length); + } + }); + + it.each(fixtures.bootCases)('parses the $id boot case', (fixture) => { + if (fixture.valid) { + expect(parseManagedContextBoot(fixture.boot)).toStrictEqual(fixture.boot); + } else { + expect(() => parseManagedContextBoot(fixture.boot)).toThrow( + INVALID_BOOT_MESSAGE, + ); + } + }); + + it('builds the shared ready record', () => { + const ready = createManagedContextReady(boot, readyPort); + + expect(ready).toStrictEqual(fixtures.ready); + expect(Object.isFrozen(ready)).toBe(true); + }); + + it.each([1, 65535])( + 'builds and accepts a ready record for port %s', + (port) => { + const ready = createManagedContextReady(boot, port); + + expect(ready.url).toBe(`http://127.0.0.1:${port}`); + expect(isManagedContextReady(ready, boot)).toBe(true); + }, + ); + + it.each(fixtures.readyCases)('checks the $id ready case', (fixture) => { + expect(isManagedContextReady(fixture.ready, boot)).toBe(fixture.valid); + }); + + it('builds the shared attestation response', () => { + const response = createManagedContextAttestationResponse(boot); + const outcome = checkManagedContextAttestation( + fixtures.attestationCases[0].body, + boot, + ); + + expect(response).toStrictEqual(fixtures.attestationResponse); + expect(Object.isFrozen(response)).toBe(true); + expect(outcome.status).toBe(200); + expect(Object.isFrozen((outcome as { body: object }).body)).toBe(true); + }); + + it.each(fixtures.attestationCases)( + 'answers the $id attestation case', + (fixture) => { + expect(checkManagedContextAttestation(fixture.body, boot)).toStrictEqual( + fixture.expected, + ); + }, + ); + + it.each(fixtures.installationSequences)( + 'replays the $id installation sequence', + (sequence) => { + const installations = new ManagedContextInstallations(boot); + for (const step of sequence.steps) { + expect(installations.install(step.request)).toStrictEqual( + step.expected, + ); + } + }, + ); + + it('builds records with exactly the schema keys', () => { + const installations = new ManagedContextInstallations(boot); + const outcome = installations.install(installationRequest); + + expect(sortedKeys(createManagedContextReady(boot, readyPort))).toEqual( + requiredKeys('readyV2'), + ); + expect(sortedKeys(createManagedContextAttestationResponse(boot))).toEqual( + requiredKeys('attestationResponseV3'), + ); + expect(outcome.status).toBe(200); + expect(sortedKeys((outcome as { body: object }).body)).toEqual( + requiredKeys('receipt'), + ); + expect(sortedKeys(parseManagedContextBoot(boot))).toEqual( + requiredKeys('bootV2'), + ); + }); + + it('computes each accepted contextDigest with the W0a digest', () => { + for (const sequence of fixtures.installationSequences) { + for (const step of sequence.steps) { + if (step.expected.status === 200) { + const request = step.request as { + readonly binding: Parameters[0]; + }; + + expect(step.expected.body.contextDigest).toBe( + computeManagedContextDigest(request.binding), + ); + } + } + } + }); + + it('gives each binding that breaks a rule the digest of its raw fields', () => { + // A digest mismatch alone would then refuse the request, and a worker + // that enforced no binding rule would still pass the fixtures. + const mismatches: string[] = []; + let checked = 0; + for (const sequence of fixtures.installationSequences) { + for (const step of sequence.steps) { + const request = step.request as { + readonly binding?: unknown; + readonly contextDigest?: unknown; + } | null; + const binding = request?.binding; + if (typeof binding !== 'object' || binding === null) { + continue; + } + let valid = true; + try { + computeManagedContextDigest(binding as ManagedContextBinding); + } catch { + valid = false; + } + const raw = rawDigest(binding as Record); + if (valid || raw === undefined) { + continue; + } + checked++; + if (request?.contextDigest !== raw) { + mismatches.push(sequence.id); + } + } + } + + expect(mismatches).toEqual([]); + expect(checked).toBeGreaterThan(20); + }); + + it('returns the original receipt when an installation repeats', () => { + const installations = new ManagedContextInstallations(boot); + const first = installations.install(installationRequest); + const second = installations.install(structuredClone(installationRequest)); + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect((second as { body: unknown }).body).toBe( + (first as { body: unknown }).body, + ); + expect(Object.isFrozen((first as { body: unknown }).body)).toBe(true); + }); + + it('keeps each Runtime its own installations', () => { + const first = new ManagedContextInstallations(boot); + const second = new ManagedContextInstallations(boot); + const otherSession = { ...installationRequest, sessionId: 'session-2' }; + + expect(first.install(installationRequest).status).toBe(200); + expect(second.install(otherSession).status).toBe(200); + }); + + it('returns a frozen copy of the boot document', () => { + const input = structuredClone(boot) as unknown as Record; + const parsed = parseManagedContextBoot(input); + input['mountRoot'] = '/elsewhere'; + + expect(parsed).not.toBe(input); + expect(Object.isFrozen(parsed)).toBe(true); + expect(parsed.mountRoot).toBe(boot.mountRoot); + }); + + it('reads each boot field once, so the checked value is kept', () => { + for (const field of requiredKeys('bootV2')) { + const changing = withChangingField( + boot as unknown as Record, + field, + (boot as unknown as Record)[field], + field === 'epoch' ? 0 : '', + ); + + expect(parseManagedContextBoot(changing.value)).toStrictEqual(boot); + expect(changing.reads()).toBe(1); + } + }); + + it('reads each installation field once, so the checked value is used', () => { + for (const field of requiredKeys('installationRequest')) { + if (field === 'binding') continue; + const changing = withChangingField( + installationRequest, + field, + installationRequest[field], + field === 'protocolVersion' ? 2 : 'other/value', + ); + + expect( + new ManagedContextInstallations(boot).install(changing.value), + ).toStrictEqual(firstInstallation.expected); + expect(changing.reads()).toBe(1); + } + for (const field of requiredKeys('binding')) { + const changing = withChangingField( + installationRequest.binding, + field, + installationRequest.binding[field], + '../escape', + ); + const request = { ...installationRequest, binding: changing.value }; + + expect( + new ManagedContextInstallations(boot).install(request), + ).toStrictEqual(firstInstallation.expected); + expect(changing.reads()).toBe(1); + } + }); + + it('refuses a function that carries every key', () => { + // Object.keys lists exactly the fixture keys on these functions. + const callable = (value: T) => + Object.assign(() => undefined, value); + + expect(() => parseManagedContextBoot(callable(boot))).toThrow( + INVALID_BOOT_MESSAGE, + ); + expect(isManagedContextReady(callable(fixtures.ready), boot)).toBe(false); + expect( + checkManagedContextAttestation( + callable(fixtures.attestationCases[0].body as object), + boot, + ), + ).toStrictEqual({ + status: 400, + code: 'managed_runtime_attestation_invalid', + }); + expect( + new ManagedContextInstallations(boot).install( + callable(installationRequest), + ), + ).toStrictEqual({ + status: 400, + code: 'managed_runtime_attestation_invalid', + }); + expect( + new ManagedContextInstallations(boot).install({ + ...installationRequest, + binding: callable(installationRequest.binding), + }), + ).toStrictEqual({ + status: 400, + code: 'managed_runtime_attestation_invalid', + }); + }); + + it.each([0, 65536, 1.5, -1, Number.NaN])( + 'refuses to build a ready record for port %s', + (port) => { + expect(() => createManagedContextReady(boot, port)).toThrow( + 'Managed context ready port is invalid.', + ); + }, + ); + + it('validates the boot document wherever one is passed', () => { + const invalid = { ...boot, version: 1 } as unknown as ManagedContextBoot; + + expect(() => createManagedContextReady(invalid, readyPort)).toThrow( + INVALID_BOOT_MESSAGE, + ); + expect(() => isManagedContextReady(fixtures.ready, invalid)).toThrow( + INVALID_BOOT_MESSAGE, + ); + expect(() => createManagedContextAttestationResponse(invalid)).toThrow( + INVALID_BOOT_MESSAGE, + ); + expect(() => + checkManagedContextAttestation(fixtures.attestationResponse, invalid), + ).toThrow(INVALID_BOOT_MESSAGE); + expect(() => new ManagedContextInstallations(invalid)).toThrow( + INVALID_BOOT_MESSAGE, + ); + }); + + it('never echoes the bearer token', () => { + const token = 'secret-token-value'; + const secretBoot = { ...boot, token }; + let message = ''; + try { + parseManagedContextBoot({ ...secretBoot, epoch: 0 }); + } catch (error) { + message = (error as Error).message; + } + const installations = new ManagedContextInstallations(secretBoot); + const installed = installations.install(installationRequest); + const outputs = [ + createManagedContextReady(secretBoot, readyPort), + createManagedContextAttestationResponse(secretBoot), + checkManagedContextAttestation( + fixtures.attestationCases[0].body, + secretBoot, + ), + checkManagedContextAttestation({}, secretBoot), + installed, + installations.install(installationRequest), + installations.install({ ...installationRequest, operationId: 'op/1' }), + ]; + + expect(message).toBe(INVALID_BOOT_MESSAGE); + expect(installed.status).toBe(200); + expect(JSON.stringify(outputs)).not.toContain(token); + }); + + it('keeps the largest records within their body limits', () => { + // Backslashes and quotes double when JSON-encoded; C0 controls and lone + // surrogates take six bytes. These values are the largest each rule + // allows. + const largest = parseManagedContextBoot({ + ...boot, + runtimeInstanceId: 'i'.repeat(128), + runtimeIncarnation: 'n'.repeat(128), + leaseId: 'l'.repeat(128), + provisionRequestId: 'p'.repeat(128), + epoch: Number.MAX_SAFE_INTEGER, + tenantId: 't'.repeat(128), + workspaceId: 'w'.repeat(128), + workspaceGeneration: '9223372036854775807', + storageId: '"'.repeat(256), + mountRoot: '\\'.repeat(4096), + }); + const binding = { + tenantId: largest.tenantId, + workspaceId: largest.workspaceId, + workspaceGeneration: largest.workspaceGeneration, + storageId: largest.storageId, + cwdRelative: '\u{1D11E}'.repeat(1024), + contextConfigRef: '"'.repeat(512), + contextRevision: '9223372036854775807', + }; + const request = { + protocolVersion: 3, + managedContext: MANAGED_CONTEXT_PROTOCOL, + operationId: 'o'.repeat(128), + sessionId: '\u0001'.repeat(512), + binding, + contextDigest: computeManagedContextDigest(binding), + }; + const attestation = { + protocolVersion: 3, + managedContext: MANAGED_CONTEXT_PROTOCOL, + provisionRequestId: largest.provisionRequestId, + tenantId: largest.tenantId, + workspaceId: largest.workspaceId, + workspaceGeneration: largest.workspaceGeneration, + storageId: largest.storageId, + mountRoot: largest.mountRoot, + capabilityDigest: largest.capabilityDigest, + isolationClass: largest.isolationClass, + }; + const installed = new ManagedContextInstallations(largest).install(request); + const answered = checkManagedContextAttestation(attestation, largest); + const bytes = (value: unknown) => + Buffer.byteLength(JSON.stringify(value), 'utf8'); + + expect(installed.status).toBe(200); + expect(answered.status).toBe(200); + for (const record of [ + request, + (installed as { body: object }).body, + attestation, + (answered as { body: object }).body, + ]) { + expect(bytes(record)).toBeLessThanOrEqual( + MANAGED_CONTEXT_ROUTES[0].requestBodyLimitBytes, + ); + } + expect( + MANAGED_CONTEXT_ROUTES.map((route) => route.requestBodyLimitBytes), + ).toEqual([16384, 16384]); + }); + + it('lists every refusal of the fixtures in the error table', () => { + const table = new Set( + fixtures.errors.map((error) => `${error.status} ${error.code}`), + ); + const refusals = [ + ...fixtures.attestationCases.map((fixture) => fixture.expected), + ...fixtures.installationSequences.flatMap((sequence) => + sequence.steps.map((step) => step.expected), + ), + ].filter((outcome) => outcome.status !== 200) as Array<{ + status: number; + code: string; + }>; + + expect(refusals.length).toBeGreaterThan(0); + for (const refusal of refusals) { + expect(table.has(`${refusal.status} ${refusal.code}`)).toBe(true); + } + expect(INVALID).toStrictEqual( + fixtures.attestationCases.find((f) => f.id === 'array-body')?.expected, + ); + }); +}); diff --git a/packages/cli/src/serve/managed-context-envelope.ts b/packages/cli/src/serve/managed-context-envelope.ts new file mode 100644 index 00000000000..7b935fdfe1a --- /dev/null +++ b/packages/cli/src/serve/managed-context-envelope.ts @@ -0,0 +1,540 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + computeManagedContextDigest, + isCanonicalDecimalText, + isManagedIdentifier, + isWorkspaceStorageId, + type ManagedContextBinding, +} from './managed-workspace-binding.js'; + +// TypeScript half of the managed-context/1 envelope contract (W0a-2). The +// shared fixtures in contracts/managed-context-v1.fixtures.json pin it, and a +// conformance test in packages/sdk-java/runtime-broker pins the same key sets, +// routes and digests. Nothing wires this into the Runtime worker yet. + +export const MANAGED_CONTEXT_PROTOCOL = 'managed-context/1'; +export const MANAGED_CONTEXT_BOOT_VERSION = 2; +export const MANAGED_CONTEXT_READY_VERSION = 2; +export const MANAGED_CONTEXT_BODY_LIMIT_BYTES = 16 * 1024; + +export const MANAGED_CONTEXT_ROUTES = Object.freeze([ + Object.freeze({ + key: 'attest', + method: 'POST', + path: '/internal/managed-runtime/v3/attest', + protocolVersion: 3, + requestBodyLimitBytes: MANAGED_CONTEXT_BODY_LIMIT_BYTES, + responseBodyLimitBytes: MANAGED_CONTEXT_BODY_LIMIT_BYTES, + cacheControl: 'no-store', + }), + Object.freeze({ + key: 'context', + method: 'POST', + path: '/internal/managed-runtime/v3/context', + protocolVersion: 3, + requestBodyLimitBytes: MANAGED_CONTEXT_BODY_LIMIT_BYTES, + responseBodyLimitBytes: MANAGED_CONTEXT_BODY_LIMIT_BYTES, + cacheControl: 'no-store', + }), +] as const); + +const PROTOCOL_VERSION = MANAGED_CONTEXT_ROUTES[0].protocolVersion; +const INVALID_BOOT_MESSAGE = 'Managed context boot document is invalid.'; +const MAXIMUM_MOUNT_ROOT_BYTES = 4096; +/** The Broker's limit for a Runtime Session ID, in UTF-16 code units. */ +const MAXIMUM_SESSION_ID_LENGTH = 512; +const MAXIMUM_PORT = 65535; +const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/; +/** The token68 syntax of a bearer credential (RFC 6750), 1 to 512 long. */ +const BEARER_TOKEN_PATTERN = /^[A-Za-z0-9._~+/-]+=*$/; +const MAXIMUM_TOKEN_LENGTH = 512; +/** In Unicode mode, this class matches only unpaired surrogates. */ +const LONE_SURROGATE_PATTERN = /[\ud800-\udfff]/u; +const ABSOLUTE_PATH_PATTERN = /^(?:\/|[A-Za-z]:[\\/]|\\\\)/; +const READY_URL_PATTERN = /^http:\/\/127\.0\.0\.1:([1-9][0-9]{0,4})$/; + +// Each list is sorted, so it compares element by element with sorted keys. +const BOOT_KEYS = Object.freeze([ + 'capabilityDigest', + 'epoch', + 'isolationClass', + 'leaseId', + 'managedContext', + 'mountRoot', + 'provisionRequestId', + 'runtimeIncarnation', + 'runtimeInstanceId', + 'storageId', + 'tenantId', + 'token', + 'type', + 'version', + 'workspaceGeneration', + 'workspaceId', +] as const); +const READY_KEYS = Object.freeze([ + 'epoch', + 'leaseId', + 'managedContext', + 'runtimeIncarnation', + 'runtimeInstanceId', + 'type', + 'url', + 'version', +] as const); +const ATTESTATION_KEYS = Object.freeze([ + 'capabilityDigest', + 'isolationClass', + 'managedContext', + 'mountRoot', + 'protocolVersion', + 'provisionRequestId', + 'storageId', + 'tenantId', + 'workspaceGeneration', + 'workspaceId', +] as const); +const INSTALLATION_KEYS = Object.freeze([ + 'binding', + 'contextDigest', + 'managedContext', + 'operationId', + 'protocolVersion', + 'sessionId', +] as const); +const BINDING_KEYS = Object.freeze([ + 'contextConfigRef', + 'contextRevision', + 'cwdRelative', + 'storageId', + 'tenantId', + 'workspaceGeneration', + 'workspaceId', +] as const); +/** The boot fields that an attestation request must repeat exactly. */ +const ATTESTED_KEYS = Object.freeze([ + 'provisionRequestId', + 'tenantId', + 'workspaceId', + 'workspaceGeneration', + 'storageId', + 'mountRoot', + 'capabilityDigest', + 'isolationClass', +] as const); +/** The binding fields that must match the Workspace the Runtime booted in. */ +const WORKSPACE_KEYS = Object.freeze([ + 'tenantId', + 'workspaceId', + 'workspaceGeneration', + 'storageId', +] as const); + +export type ManagedContextIsolationClass = 'session' | 'workspace'; + +export interface ManagedContextBoot { + readonly type: 'boot'; + readonly version: typeof MANAGED_CONTEXT_BOOT_VERSION; + readonly managedContext: typeof MANAGED_CONTEXT_PROTOCOL; + readonly runtimeInstanceId: string; + readonly runtimeIncarnation: string; + readonly leaseId: string; + readonly provisionRequestId: string; + readonly token: string; + readonly epoch: number; + readonly capabilityDigest: string; + readonly isolationClass: ManagedContextIsolationClass; + readonly tenantId: string; + readonly workspaceId: string; + /** Decimal text, so a 64-bit value never passes through Number. */ + readonly workspaceGeneration: string; + readonly storageId: string; + /** Where the Broker mounted the Workspace; data until it is verified. */ + readonly mountRoot: string; +} + +export interface ManagedContextReady { + readonly type: 'ready'; + readonly version: typeof MANAGED_CONTEXT_READY_VERSION; + readonly managedContext: typeof MANAGED_CONTEXT_PROTOCOL; + readonly runtimeInstanceId: string; + readonly runtimeIncarnation: string; + readonly leaseId: string; + readonly epoch: number; + readonly url: string; +} + +export interface ManagedContextAttestationResponse { + readonly protocolVersion: typeof PROTOCOL_VERSION; + readonly managedContext: typeof MANAGED_CONTEXT_PROTOCOL; + readonly runtimeInstanceId: string; + readonly runtimeIncarnation: string; + readonly leaseId: string; + readonly epoch: number; + readonly provisionRequestId: string; + readonly tenantId: string; + readonly workspaceId: string; + readonly workspaceGeneration: string; + readonly storageId: string; + readonly mountRoot: string; + readonly capabilityDigest: string; + readonly isolationClass: ManagedContextIsolationClass; +} + +export interface ManagedContextReceipt { + readonly protocolVersion: typeof PROTOCOL_VERSION; + readonly managedContext: typeof MANAGED_CONTEXT_PROTOCOL; + readonly operationId: string; + readonly sessionId: string; + readonly runtimeInstanceId: string; + readonly runtimeIncarnation: string; + readonly epoch: number; + readonly contextDigest: string; + readonly contextRevision: string; + readonly workspaceGeneration: string; +} + +export type ManagedContextRefusal = + | { + readonly status: 400; + readonly code: 'managed_runtime_attestation_invalid'; + } + | { + readonly status: 409; + readonly code: 'managed_runtime_identity_conflict'; + } + | { readonly status: 409; readonly code: 'managed_context_conflict' }; + +export type ManagedContextOutcome = + | { readonly status: 200; readonly body: Body } + | ManagedContextRefusal; + +const INVALID: ManagedContextRefusal = Object.freeze({ + status: 400, + code: 'managed_runtime_attestation_invalid', +}); +const IDENTITY_CONFLICT: ManagedContextRefusal = Object.freeze({ + status: 409, + code: 'managed_runtime_identity_conflict', +}); +const CONTEXT_CONFLICT: ManagedContextRefusal = Object.freeze({ + status: 409, + code: 'managed_context_conflict', +}); + +/** + * Validates a boot v2 document and returns a frozen copy of it. Throws + * without echoing the input, which carries the bearer token. + */ +export function parseManagedContextBoot(value: unknown): ManagedContextBoot { + const boot = readClosed(value, BOOT_KEYS); + if ( + !boot || + boot.type !== 'boot' || + boot.version !== MANAGED_CONTEXT_BOOT_VERSION || + boot.managedContext !== MANAGED_CONTEXT_PROTOCOL || + !isBearerToken(boot.token) || + !isManagedIdentifier(boot.runtimeInstanceId) || + !isManagedIdentifier(boot.runtimeIncarnation) || + !isManagedIdentifier(boot.leaseId) || + !isEpoch(boot.epoch) || + !hasValidAttestedFields(boot) + ) { + throw new Error(INVALID_BOOT_MESSAGE); + } + return Object.freeze(boot) as ManagedContextBoot; +} + +/** The ready v2 record for a worker listening on the loopback `port`. */ +export function createManagedContextReady( + boot: ManagedContextBoot, + port: number, +): ManagedContextReady { + const identity = parseManagedContextBoot(boot); + if (!Number.isInteger(port) || port < 1 || port > MAXIMUM_PORT) { + throw new Error('Managed context ready port is invalid.'); + } + return Object.freeze({ + type: 'ready', + version: MANAGED_CONTEXT_READY_VERSION, + managedContext: MANAGED_CONTEXT_PROTOCOL, + runtimeInstanceId: identity.runtimeInstanceId, + runtimeIncarnation: identity.runtimeIncarnation, + leaseId: identity.leaseId, + epoch: identity.epoch, + url: `http://127.0.0.1:${port}`, + }); +} + +/** Whether a ready record is exactly ready v2 for this boot document. */ +export function isManagedContextReady( + value: unknown, + boot: ManagedContextBoot, +): boolean { + const identity = parseManagedContextBoot(boot); + const ready = readClosed(value, READY_KEYS); + if ( + !ready || + ready.type !== 'ready' || + ready.version !== MANAGED_CONTEXT_READY_VERSION || + ready.managedContext !== MANAGED_CONTEXT_PROTOCOL || + ready.runtimeInstanceId !== identity.runtimeInstanceId || + ready.runtimeIncarnation !== identity.runtimeIncarnation || + ready.leaseId !== identity.leaseId || + ready.epoch !== identity.epoch || + typeof ready.url !== 'string' + ) { + return false; + } + const port = READY_URL_PATTERN.exec(ready.url)?.[1]; + return port !== undefined && Number(port) <= MAXIMUM_PORT; +} + +/** The attestation v3 response, built from the boot document alone. */ +export function createManagedContextAttestationResponse( + boot: ManagedContextBoot, +): ManagedContextAttestationResponse { + return attestationResponse(parseManagedContextBoot(boot)); +} + +/** + * Checks an attestation v3 request body against the boot document: 400 for + * a bad shape, 409 when any attested field differs, compared exactly. + */ +export function checkManagedContextAttestation( + body: unknown, + boot: ManagedContextBoot, +): ManagedContextOutcome { + const identity = parseManagedContextBoot(boot); + const request = readClosed(body, ATTESTATION_KEYS); + if ( + !request || + request.protocolVersion !== PROTOCOL_VERSION || + request.managedContext !== MANAGED_CONTEXT_PROTOCOL || + !hasValidAttestedFields(request) + ) { + return INVALID; + } + if (ATTESTED_KEYS.some((key) => request[key] !== identity[key])) { + return IDENTITY_CONFLICT; + } + return { status: 200, body: attestationResponse(identity) }; +} + +interface Installation { + readonly sessionId: string; + readonly contextDigest: string; + readonly receipt: ManagedContextReceipt; +} + +/** + * The context installations of one Runtime. Each request is checked in the + * contract's order; a repeated installation returns its original receipt, + * and a refused request records nothing. + */ +export class ManagedContextInstallations { + readonly #boot: ManagedContextBoot; + readonly #operations = new Map(); + /** The context digest installed for each Session. */ + readonly #sessions = new Map(); + + constructor(boot: ManagedContextBoot) { + this.#boot = parseManagedContextBoot(boot); + } + + install(body: unknown): ManagedContextOutcome { + const request = readClosed(body, INSTALLATION_KEYS); + const binding = request && readClosed(request.binding, BINDING_KEYS); + if ( + !request || + !binding || + request.protocolVersion !== PROTOCOL_VERSION || + request.managedContext !== MANAGED_CONTEXT_PROTOCOL || + !isManagedIdentifier(request.operationId) || + !isRuntimeSessionId(request.sessionId) || + !isDigest(request.contextDigest) + ) { + return INVALID; + } + const operationId = request.operationId; + const sessionId = request.sessionId; + const contextDigest = digestOf(binding); + if ( + contextDigest === undefined || + contextDigest !== request.contextDigest + ) { + return INVALID; + } + if (WORKSPACE_KEYS.some((key) => binding[key] !== this.#boot[key])) { + return IDENTITY_CONFLICT; + } + const previous = this.#operations.get(operationId); + if (previous) { + return previous.sessionId === sessionId && + previous.contextDigest === contextDigest + ? { status: 200, body: previous.receipt } + : CONTEXT_CONFLICT; + } + const installed = this.#sessions.get(sessionId); + if (installed !== undefined && installed !== contextDigest) { + return CONTEXT_CONFLICT; + } + const receipt: ManagedContextReceipt = Object.freeze({ + protocolVersion: PROTOCOL_VERSION, + managedContext: MANAGED_CONTEXT_PROTOCOL, + operationId, + sessionId, + runtimeInstanceId: this.#boot.runtimeInstanceId, + runtimeIncarnation: this.#boot.runtimeIncarnation, + epoch: this.#boot.epoch, + contextDigest, + contextRevision: binding.contextRevision as string, + workspaceGeneration: binding.workspaceGeneration as string, + }); + this.#operations.set( + operationId, + Object.freeze({ sessionId, contextDigest, receipt }), + ); + this.#sessions.set(sessionId, contextDigest); + return { status: 200, body: receipt }; + } +} + +function attestationResponse( + identity: ManagedContextBoot, +): ManagedContextAttestationResponse { + return Object.freeze({ + protocolVersion: PROTOCOL_VERSION, + managedContext: MANAGED_CONTEXT_PROTOCOL, + runtimeInstanceId: identity.runtimeInstanceId, + runtimeIncarnation: identity.runtimeIncarnation, + leaseId: identity.leaseId, + epoch: identity.epoch, + provisionRequestId: identity.provisionRequestId, + tenantId: identity.tenantId, + workspaceId: identity.workspaceId, + workspaceGeneration: identity.workspaceGeneration, + storageId: identity.storageId, + mountRoot: identity.mountRoot, + capabilityDigest: identity.capabilityDigest, + isolationClass: identity.isolationClass, + }); +} + +/** + * Copies an object's fields when its own keys are exactly `keys`, so every + * later check and use reads one snapshot. Anything else is undefined. + */ +function readClosed( + value: unknown, + keys: readonly Key[], +): Record | undefined { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return undefined; + } + const present = Object.keys(value).sort(); + if ( + present.length !== keys.length || + !present.every((key, index) => key === keys[index]) + ) { + return undefined; + } + const snapshot = {} as Record; + for (const key of keys) { + snapshot[key] = (value as Record)[key]; + } + return snapshot; +} + +function hasValidAttestedFields( + fields: Record<(typeof ATTESTED_KEYS)[number], unknown>, +): boolean { + return ( + isManagedIdentifier(fields.provisionRequestId) && + isManagedIdentifier(fields.tenantId) && + isManagedIdentifier(fields.workspaceId) && + isCanonicalDecimalText(fields.workspaceGeneration) && + isWorkspaceStorageId(fields.storageId) && + isMountRoot(fields.mountRoot) && + isDigest(fields.capabilityDigest) && + (fields.isolationClass === 'session' || + fields.isolationClass === 'workspace') + ); +} + +/** The W0a digest of a closed binding, or undefined when it is invalid. */ +function digestOf( + binding: Record<(typeof BINDING_KEYS)[number], unknown>, +): string | undefined { + try { + return computeManagedContextDigest(binding as ManagedContextBinding); + } catch { + return undefined; + } +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} + +/** + * A Runtime Session ID: 1 to 512 UTF-16 units as the Broker counts them, + * well-formed so that every JSON encoder carries it unchanged, and no NUL. + */ +function isRuntimeSessionId(value: unknown): value is string { + return ( + isNonEmptyString(value) && + value.length <= MAXIMUM_SESSION_ID_LENGTH && + !value.includes('\0') && + !LONE_SURROGATE_PATTERN.test(value) + ); +} + +function isBearerToken(value: unknown): value is string { + return ( + matches(BEARER_TOKEN_PATTERN, value) && + (value as string).length <= MAXIMUM_TOKEN_LENGTH + ); +} + +function matches(pattern: RegExp, value: unknown): boolean { + return typeof value === 'string' && pattern.test(value); +} + +function isEpoch(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 1; +} + +function isDigest(value: unknown): value is string { + return matches(DIGEST_PATTERN, value); +} + +/** + * An absolute path of 1 to 4096 UTF-8 bytes: well-formed UTF-16 without a + * Unicode Cc control character (C0, DEL and C1). + */ +function isMountRoot(value: unknown): value is string { + if ( + typeof value !== 'string' || + value.length > MAXIMUM_MOUNT_ROOT_BYTES || + !ABSOLUTE_PATH_PATTERN.test(value) + ) { + return false; + } + for (const character of value) { + const codePoint = character.codePointAt(0) ?? 0; + if ( + codePoint <= 0x1f || + (codePoint >= 0x7f && codePoint <= 0x9f) || + (codePoint >= 0xd800 && codePoint <= 0xdfff) + ) { + return false; + } + } + return Buffer.byteLength(value, 'utf8') <= MAXIMUM_MOUNT_ROOT_BYTES; +} diff --git a/packages/cli/src/serve/managed-runtime-activator.ts b/packages/cli/src/serve/managed-runtime-activator.ts new file mode 100644 index 00000000000..8426e8db61b --- /dev/null +++ b/packages/cli/src/serve/managed-runtime-activator.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { WorkspaceRuntime } from './workspace-registry.js'; + +export const MANAGED_RUNTIME_STARTUP_MS = 5 * 60_000; +export const MANAGED_LEASE_ID_HEADER = 'X-Qwen-Managed-Lease-Id'; +export const MANAGED_LEASE_EPOCH_HEADER = 'X-Qwen-Managed-Lease-Epoch'; + +export interface ManagedRuntimeScope { + readonly tenantId: string; + readonly runtime: WorkspaceRuntime; +} +export interface ManagedWorkerBoot { + readonly type: 'boot'; + readonly cliEntry: string; + readonly version: 1; + readonly gatewayIncarnation: string; + readonly leaseId: string; + readonly epoch: number; + readonly tenantId: string; + readonly workspaceId: string; + readonly workspaceCwd: string; + readonly token: string; + readonly outputRoot: string; +} +export interface ManagedRuntimeEndpoint { + readonly url: string; + readonly boot: ManagedWorkerBoot; + readonly deadline: number; +} +export type RuntimeFinishReason = 'completed' | 'failed' | 'cancelled'; +export class ManagedRuntimeReleasedError extends Error {} +export interface ManagedRuntimeUse { + readonly endpoint: Promise; + readonly signal: AbortSignal; + readonly exited: Promise; + release(reason: RuntimeFinishReason): void; + beginOperation(): (certain: boolean) => void; +} diff --git a/packages/cli/src/serve/managed-runtime-attestation-contract.test.ts b/packages/cli/src/serve/managed-runtime-attestation-contract.test.ts index 70ff88d013a..ff6fb6e353e 100644 --- a/packages/cli/src/serve/managed-runtime-attestation-contract.test.ts +++ b/packages/cli/src/serve/managed-runtime-attestation-contract.test.ts @@ -640,7 +640,7 @@ describe('Managed Runtime tool contract', () => { } }); - it('rejects declared tool routes until their handlers land', async () => { + it('admits exactly the declared tool routes through the owned-route gate', async () => { const app = express(); for (const route of toolFixtures.routes) { app.post(route.path, (_req, res) => { @@ -665,8 +665,28 @@ describe('Managed Runtime tool contract', () => { headers: { 'content-type': 'application/json' }, body: '{}', }); - expect(response.status).toBe(404); - expect(await response.text()).toBe(''); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + protocolVersion: 2, + state: 'unknown', + }); + + for (const [method, suffix] of [ + ['GET', ''], + ['POST', '/'], + ['POST', '?unexpected=1'], + ]) { + const rejected = await fetch(`${origin}${route.path}${suffix}`, { + method, + }); + expect(rejected.status).toBe(404); + expect(await rejected.text()).toBe(''); + } } + const unlisted = await fetch( + `${origin}/internal/managed-runtime/v2/prepare`, + { method: 'POST' }, + ); + expect(unlisted.status).toBe(404); }); }); diff --git a/packages/cli/src/serve/managed-runtime-attestation-contract.ts b/packages/cli/src/serve/managed-runtime-attestation-contract.ts index c8944180e65..0965b7be7ae 100644 --- a/packages/cli/src/serve/managed-runtime-attestation-contract.ts +++ b/packages/cli/src/serve/managed-runtime-attestation-contract.ts @@ -162,12 +162,12 @@ function isClosedAttestationRequest( ); } -const noStore: RequestHandler = (_req, res, next) => { +export const managedRuntimeNoStore: RequestHandler = (_req, res, next) => { res.setHeader('Cache-Control', ATTEST_ROUTE.cacheControl); next(); }; -function authorize( +export function authorizeManagedRuntime( identity: ManagedRuntimeAttestationIdentity, ): RequestHandler { return (req, res, next): void => { @@ -246,7 +246,12 @@ function handleAttestation( }; } -const handleJsonError: ErrorRequestHandler = (error, _req, res, next) => { +export const handleManagedRuntimeJsonError: ErrorRequestHandler = ( + error, + _req, + res, + next, +) => { if (res.headersSent) { next(error); return; @@ -259,7 +264,7 @@ const handleJsonError: ErrorRequestHandler = (error, _req, res, next) => { ) { res.status(413).json({ code: 'managed_runtime_attestation_too_large', - error: 'Managed Runtime attestation request exceeds 16 KiB.', + error: 'Managed Runtime request exceeds its body size limit.', }); return; } @@ -291,8 +296,8 @@ export function registerManagedRuntimeAttestationRoute( >; app[method]( ATTEST_ROUTE.path, - noStore, - authorize(identitySnapshot), + managedRuntimeNoStore, + authorizeManagedRuntime(identitySnapshot), express.json({ // Compressed private requests add no value at 16 KiB. Refusing them // keeps the limit on wire bytes and makes corrupt streams use the JSON @@ -303,7 +308,7 @@ export function registerManagedRuntimeAttestationRoute( type: 'application/json', }), handleAttestation(identitySnapshot, responseJson), - handleJsonError, + handleManagedRuntimeJsonError, ); } @@ -311,7 +316,9 @@ export function isOwnedManagedRuntimeRoute( method: string | undefined, url: string | undefined, ): boolean { - return method === ATTEST_ROUTE.method && url === ATTEST_ROUTE.path; + return OWNED_MANAGED_RUNTIME_ROUTES.some( + (route) => method === route.method && url === route.path, + ); } export function ownedManagedRuntimeRouteGate( diff --git a/packages/cli/src/serve/managed-runtime-attestation-worker.test.ts b/packages/cli/src/serve/managed-runtime-attestation-worker.test.ts index a24315656da..ff94935b9d8 100644 --- a/packages/cli/src/serve/managed-runtime-attestation-worker.test.ts +++ b/packages/cli/src/serve/managed-runtime-attestation-worker.test.ts @@ -257,7 +257,7 @@ describe('Managed Runtime attestation worker', () => { expect(await answering(probes, port)).toEqual([]); }); - it('mounts only the attestation manifest on a loopback listener', async () => { + it('serves attestation on a loopback listener and rejects unknown routes', async () => { const worker = await startManagedRuntimeAttestationWorker(boot); openWorkers.add(worker); diff --git a/packages/cli/src/serve/managed-runtime-attestation-worker.ts b/packages/cli/src/serve/managed-runtime-attestation-worker.ts index 2a67b5199cf..cdaee3ed3eb 100644 --- a/packages/cli/src/serve/managed-runtime-attestation-worker.ts +++ b/packages/cli/src/serve/managed-runtime-attestation-worker.ts @@ -13,6 +13,8 @@ import { registerManagedRuntimeAttestationRoute, type ManagedRuntimeAttestationIdentity, } from './managed-runtime-attestation-contract.js'; +import { ManagedToolExecutor } from './managed-runtime-tool-executor.js'; +import { registerManagedRuntimeToolRoutes } from './managed-runtime-tool-routes.js'; const MANAGED_RUNTIME_WORKER_BOOT_LIMIT_BYTES = 32 * 1024; const MANAGED_RUNTIME_WORKER_BOOT_TIMEOUT_MS = 30_000; @@ -120,6 +122,11 @@ export async function startManagedRuntimeAttestationWorker( const app = express(); app.disable('x-powered-by'); registerManagedRuntimeAttestationRoute(app, boot); + const executor = ManagedToolExecutor.forWorkspace( + boot.workspaceCwd, + boot.runtimeInstanceId, + ); + registerManagedRuntimeToolRoutes(app, boot, executor); const server = createServer(ownedManagedRuntimeRouteGate(app)); server.maxHeadersCount = 32; server.headersTimeout = 5_000; @@ -159,10 +166,13 @@ export async function startManagedRuntimeAttestationWorker( return { ready, close: () => { - closing ??= new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - server.closeAllConnections(); - }); + closing ??= executor.close().then( + () => + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + server.closeAllConnections(); + }), + ); return closing; }, }; diff --git a/packages/cli/src/serve/managed-runtime-protocol.ts b/packages/cli/src/serve/managed-runtime-protocol.ts new file mode 100644 index 00000000000..e74fddb96b0 --- /dev/null +++ b/packages/cli/src/serve/managed-runtime-protocol.ts @@ -0,0 +1,224 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + BridgeManagedRuntimeToolExecuteRequest, + BridgeManagedRuntimeToolExecuteResult, + BridgeManagedRuntimeToolManifest, +} from '@qwen-code/acp-bridge/bridgeTypes'; +import { parseCallerSuppliedSessionId } from '../config/session-id.js'; + +export const MANAGED_RUNTIME_PROTOCOL_VERSION = 1 as const; +export const MANAGED_RUNTIME_ROUTE_PREFIX = + '/internal/managed-runtime/v1' as const; + +const MAX_ID_LENGTH = 256; +const MAX_EXECUTION_ID_LENGTH = 128; +const MAX_TURN_ID_LENGTH = 128; +const MAX_TOOL_CALL_ID_LENGTH = 512; +const MAX_WORKSPACE_CWD_LENGTH = 4096; +const MAX_TOOL_NAME_LENGTH = 256; +const MAX_TOOL_INPUT_BYTES = 256 * 1024; +const PREPARE_KEYS = new Set([ + 'protocolVersion', + 'tenantId', + 'workspaceId', + 'workspaceCwd', + 'sessionId', + 'turnKind', +]); + +export interface ManagedRuntimePrepareRequest { + readonly protocolVersion: typeof MANAGED_RUNTIME_PROTOCOL_VERSION; + readonly tenantId: string; + readonly workspaceId: string; + readonly workspaceCwd: string; + readonly sessionId: string; + readonly turnKind: 'bootstrap' | 'continuation'; +} + +export interface ManagedRuntimeExecuteRequest + extends ManagedRuntimePrepareRequest { + readonly toolRequest: BridgeManagedRuntimeToolExecuteRequest; +} + +export interface ManagedRuntimeCancelRequest + extends ManagedRuntimePrepareRequest { + readonly executionId: string; +} + +export interface ManagedRuntimeReadyResponse { + readonly protocolVersion: typeof MANAGED_RUNTIME_PROTOCOL_VERSION; + readonly ready: true; +} + +export interface ManagedRuntimeManifestResponse { + readonly protocolVersion: typeof MANAGED_RUNTIME_PROTOCOL_VERSION; + readonly manifest: BridgeManagedRuntimeToolManifest; +} + +export interface ManagedRuntimeExecuteResponse { + readonly protocolVersion: typeof MANAGED_RUNTIME_PROTOCOL_VERSION; + readonly result: BridgeManagedRuntimeToolExecuteResult; +} + +export interface ManagedRuntimeCancelResponse { + readonly protocolVersion: typeof MANAGED_RUNTIME_PROTOCOL_VERSION; + readonly cancelled: boolean; +} + +export interface ManagedRuntimeReleaseResponse { + readonly protocolVersion: typeof MANAGED_RUNTIME_PROTOCOL_VERSION; + readonly released: boolean; +} + +export class ManagedRuntimeProtocolError extends Error { + readonly code = 'managed_runtime_invalid_request'; + + constructor(message = 'Managed Runtime request is invalid.') { + super(message); + this.name = 'ManagedRuntimeProtocolError'; + } +} + +function record(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new ManagedRuntimeProtocolError(); + } + return value as Record; +} + +function boundedString(value: unknown, maxLength: number): string { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > maxLength || + value.includes('\0') + ) { + throw new ManagedRuntimeProtocolError(); + } + return value; +} + +function assertKeys( + value: Record, + allowed: ReadonlySet, +): void { + if (Object.keys(value).some((key) => !allowed.has(key))) { + throw new ManagedRuntimeProtocolError(); + } +} + +function prepareFields(value: unknown): ManagedRuntimePrepareRequest { + const input = record(value); + if (input['protocolVersion'] !== MANAGED_RUNTIME_PROTOCOL_VERSION) { + throw new ManagedRuntimeProtocolError( + 'Managed Runtime protocol version is unsupported.', + ); + } + const turnKind = input['turnKind']; + if (turnKind !== 'bootstrap' && turnKind !== 'continuation') { + throw new ManagedRuntimeProtocolError(); + } + const sessionId = parseCallerSuppliedSessionId(input['sessionId']); + if (sessionId.kind !== 'valid') { + throw new ManagedRuntimeProtocolError(); + } + return { + protocolVersion: MANAGED_RUNTIME_PROTOCOL_VERSION, + tenantId: boundedString(input['tenantId'], MAX_ID_LENGTH), + workspaceId: boundedString(input['workspaceId'], MAX_ID_LENGTH), + workspaceCwd: boundedString( + input['workspaceCwd'], + MAX_WORKSPACE_CWD_LENGTH, + ), + sessionId: sessionId.sessionId, + turnKind, + }; +} + +export function parseManagedRuntimePrepareRequest( + value: unknown, +): ManagedRuntimePrepareRequest { + const input = record(value); + assertKeys(input, PREPARE_KEYS); + return prepareFields(input); +} + +export function parseManagedRuntimeExecuteRequest( + value: unknown, +): ManagedRuntimeExecuteRequest { + const input = record(value); + assertKeys(input, new Set([...PREPARE_KEYS, 'toolRequest'])); + const toolRequest = record(input['toolRequest']); + assertKeys( + toolRequest, + new Set([ + 'executionId', + 'turnId', + 'toolCallId', + 'capabilityDigest', + 'toolName', + 'input', + ]), + ); + const rawInput = record(toolRequest['input']); + let serializedInput: string; + try { + serializedInput = JSON.stringify(rawInput); + } catch { + throw new ManagedRuntimeProtocolError(); + } + if (Buffer.byteLength(serializedInput, 'utf8') > MAX_TOOL_INPUT_BYTES) { + throw new ManagedRuntimeProtocolError(); + } + return { + ...prepareFields(input), + toolRequest: { + executionId: boundedString( + toolRequest['executionId'], + MAX_EXECUTION_ID_LENGTH, + ), + turnId: boundedString(toolRequest['turnId'], MAX_TURN_ID_LENGTH), + toolCallId: boundedString( + toolRequest['toolCallId'], + MAX_TOOL_CALL_ID_LENGTH, + ), + capabilityDigest: (() => { + const digest = boundedString(toolRequest['capabilityDigest'], 64); + if (!/^[a-f0-9]{64}$/.test(digest)) { + throw new ManagedRuntimeProtocolError(); + } + return digest; + })(), + toolName: boundedString(toolRequest['toolName'], MAX_TOOL_NAME_LENGTH), + input: structuredClone(rawInput), + }, + }; +} + +export function parseManagedRuntimeCancelRequest( + value: unknown, +): ManagedRuntimeCancelRequest { + const input = record(value); + assertKeys(input, new Set([...PREPARE_KEYS, 'executionId'])); + return { + ...prepareFields(input), + executionId: boundedString(input['executionId'], MAX_EXECUTION_ID_LENGTH), + }; +} + +export function sameManagedRuntimeIdentity( + left: ManagedRuntimePrepareRequest, + right: ManagedRuntimePrepareRequest, +): boolean { + return ( + left.tenantId === right.tenantId && + left.workspaceId === right.workspaceId && + left.workspaceCwd === right.workspaceCwd && + left.sessionId === right.sessionId + ); +} diff --git a/packages/cli/src/serve/managed-runtime-provider.test.ts b/packages/cli/src/serve/managed-runtime-provider.test.ts new file mode 100644 index 00000000000..73cbd38632b --- /dev/null +++ b/packages/cli/src/serve/managed-runtime-provider.test.ts @@ -0,0 +1,1090 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +// @vitest-environment node + +import { createHash } from 'node:crypto'; +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { ManagedToolV2Client } from '@qwen-code/acp-bridge/bridgeTypes'; +import type { ManagedToolFileHistoryState } from '@qwen-code/qwen-code-core/tools/managed-tool-file-history.js'; +import type { AcpSessionBridge } from './acp-session-bridge.js'; +import { + LocalManagedRuntimeProvider, + RemoteManagedRuntimeProvider, +} from './managed-runtime-provider.js'; +import { + MANAGED_RUNTIME_PROTOCOL_VERSION, + parseManagedRuntimeExecuteRequest, + parseManagedRuntimePrepareRequest, + type ManagedRuntimePrepareRequest, +} from './managed-runtime-protocol.js'; +import type { + WorkspaceRegistry, + WorkspaceRuntime, +} from './workspace-registry.js'; + +const workspaceCwd = '/tmp/managed-runtime-p8'; +const workspaceId = 'workspace-p8'; +const token = 'runtime-secret'; + +const prepareRequest: ManagedRuntimePrepareRequest = { + protocolVersion: MANAGED_RUNTIME_PROTOCOL_VERSION, + tenantId: 'tenant-p8', + workspaceId, + workspaceCwd, + sessionId: '550e8400-e29b-41d4-a716-446655440108', + turnKind: 'bootstrap', +}; + +const tools = [{ name: 'read_file', description: 'Read one file' }]; +const manifest = { + capabilityDigest: createHash('sha256') + .update(JSON.stringify(tools)) + .digest('hex'), + tools, +}; + +function fakeRuntime(): { + bridge: AcpSessionBridge; + registry: WorkspaceRegistry; + execute: ReturnType; + cancel: ReturnType; + close: ReturnType; +} { + const execute = vi.fn(async (_sessionId, toolRequest) => ({ + responseParts: [ + { + functionResponse: { + id: toolRequest.toolCallId, + name: toolRequest.toolName, + response: { output: 'remote workspace evidence' }, + }, + }, + ], + executionStatus: 'success' as const, + })); + const cancel = vi.fn(async () => ({ cancelled: true })); + const close = vi.fn(async () => undefined); + const bridge = { + spawnOrAttach: vi.fn(async (input) => ({ + sessionId: input.sessionId!, + workspaceCwd, + attached: false, + clientId: 'runtime-client-p8', + hasActivePrompt: false, + sourceType: 'managed-gateway', + sourceId: input.sessionId!, + })), + resumeSession: vi.fn(), + getSessionSummary: vi.fn(() => { + throw Object.assign(new Error('not live'), { code: 'session_not_found' }); + }), + recordHeartbeat: vi.fn(), + getManagedRuntimeToolManifest: vi.fn(async () => manifest), + executeManagedRuntimeTool: execute, + cancelManagedRuntimeTool: cancel, + closeSession: close, + detachClient: vi.fn(async () => undefined), + } as unknown as AcpSessionBridge; + const runtime = { + workspaceId, + workspaceCwd, + trusted: true, + bridge, + } as WorkspaceRuntime; + const registry = { + getByWorkspaceId: vi.fn((id: string) => + id === workspaceId ? runtime : undefined, + ), + } as unknown as WorkspaceRegistry; + return { bridge, registry, execute, cancel, close }; +} + +async function listen(server: Server, port = 0): Promise { + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, '127.0.0.1', () => { + server.removeListener('error', reject); + resolve(); + }); + }); + return (server.address() as AddressInfo).port; +} + +async function close(server: Server | undefined): Promise { + if (!server?.listening) return; + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +} + +describe('Managed Runtime providers', () => { + const servers: Server[] = []; + + afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => close(server))); + }); + + function toolV2Client() { + const historyState: ManagedToolFileHistoryState = { + ownerSessionId: prepareRequest.sessionId, + revision: 0, + snapshots: [], + }; + return { + fileHistory: { + bind: vi.fn(async () => historyState), + checkpoint: vi.fn(async () => historyState), + snapshot: vi.fn(async () => historyState), + }, + manifest: vi.fn(async () => manifest), + beginTurn: vi.fn(async () => {}), + prepare: vi.fn(async () => ({})), + confirmation: vi.fn(async () => ({})), + confirm: vi.fn(async () => {}), + preflight: vi.fn(async () => ({})), + execute: vi.fn(async () => ({ executionStatus: 'success' })), + status: vi.fn(async () => ({ state: 'executing' })), + cancel: vi.fn(async () => ({ state: 'cancel_requested' })), + }; + } + + function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; + } + + it('rejects a bridge without managed tool capabilities before spawning', () => { + const runtime = fakeRuntime(); + delete runtime.bridge.executeManagedRuntimeTool; + const provider = new LocalManagedRuntimeProvider(runtime.registry); + expect(() => provider.prepare(prepareRequest)).toThrow('does not support'); + expect(runtime.bridge.spawnOrAttach).not.toHaveBeenCalled(); + }); + + it('upgrades a pending local v1 release to a terminal identity before completing cleanup', async () => { + const runtime = fakeRuntime(); + const local = new LocalManagedRuntimeProvider(runtime.registry); + await local.prepare(prepareRequest).ready; + const close = deferred(); + runtime.close.mockReturnValueOnce(close.promise); + const ordinary = local.release(prepareRequest.sessionId, prepareRequest); + const terminal = local.release(prepareRequest.sessionId, prepareRequest, { + terminal: true, + }); + expect(() => local.prepare(prepareRequest)).toThrow(); + close.resolve(); + expect(await Promise.all([ordinary, terminal])).toEqual([true, true]); + expect(runtime.close).toHaveBeenCalledOnce(); + expect(() => local.prepare(prepareRequest)).toThrow(); + await expect(local.getToolV2Client(prepareRequest)).rejects.toThrow(); + await expect( + local.release(prepareRequest.sessionId, prepareRequest), + ).resolves.toBe(true); + await expect( + local.release( + prepareRequest.sessionId, + { ...prepareRequest, tenantId: 'other' }, + { terminal: true }, + ), + ).rejects.toThrow('identity'); + expect(runtime.close).toHaveBeenCalledOnce(); + local.dispose(); + }); + + it('retains a failed terminal close for retry and seals before any preparation has arrived', async () => { + const runtime = fakeRuntime(); + const local = new LocalManagedRuntimeProvider(runtime.registry); + vi.mocked(runtime.bridge.resumeSession).mockRejectedValueOnce( + new Error('restore failed'), + ); + await expect( + local.release(prepareRequest.sessionId, prepareRequest, { + terminal: true, + }), + ).rejects.toThrow('restore failed'); + expect(() => local.prepare(prepareRequest)).toThrow(); + vi.mocked(runtime.bridge.resumeSession).mockRejectedValueOnce( + Object.assign(new Error('not found'), { code: 'session_not_found' }), + ); + await expect( + local.release(prepareRequest.sessionId, prepareRequest, { + terminal: true, + }), + ).resolves.toBe(true); + expect(() => local.prepare(prepareRequest)).toThrow(); + await expect( + local.release(prepareRequest.sessionId, prepareRequest, { + terminal: true, + }), + ).resolves.toBe(true); + expect(runtime.bridge.resumeSession).toHaveBeenCalledTimes(2); + expect(runtime.bridge.spawnOrAttach).not.toHaveBeenCalled(); + local.dispose(); + }); + + it('keeps ordinary v1 release reusable for the same Session identity', async () => { + const runtime = fakeRuntime(); + const local = new LocalManagedRuntimeProvider(runtime.registry); + await local.prepare(prepareRequest).ready; + await local.release(prepareRequest.sessionId, prepareRequest); + await local.prepare(prepareRequest).ready; + expect(runtime.bridge.spawnOrAttach).toHaveBeenCalledTimes(2); + await local.release(prepareRequest.sessionId, prepareRequest); + local.dispose(); + }); + + it('waits for a v2 terminal receipt when a pending remote v1 release is upgraded', async () => { + const ordinaryAck = deferred(); + const terminalAck = deferred(); + const versions: number[] = []; + const remote = new RemoteManagedRuntimeProvider({ + baseUrl: 'http://127.0.0.1:4181', + token, + lease: { leaseId: 'lease', epoch: 1 }, + fetch: vi.fn(async (url: RequestInfo | URL) => { + const path = new URL(String(url)).pathname; + if (path.endsWith('/prepare')) + return Response.json({ protocolVersion: 1, ready: true }); + const version = path.includes('/v2/') ? 2 : 1; + versions.push(version); + return version === 1 ? ordinaryAck.promise : terminalAck.promise; + }), + }); + try { + await remote.prepare(prepareRequest).ready; + let closed = false; + const ordinary = remote + .release(prepareRequest.sessionId, prepareRequest) + .then(() => { + closed = true; + }); + await vi.waitFor(() => expect(versions).toEqual([1])); + const terminal = remote.release( + prepareRequest.sessionId, + prepareRequest, + { terminal: true }, + ); + ordinaryAck.resolve( + Response.json({ protocolVersion: 1, released: true }), + ); + await vi.waitFor(() => expect(versions).toEqual([1, 2])); + expect(closed).toBe(false); + terminalAck.resolve( + Response.json({ protocolVersion: 2, released: true }), + ); + await Promise.all([ordinary, terminal]); + expect(() => remote.prepare(prepareRequest)).toThrow(); + } finally { + remote.dispose(); + } + }); + + it('rejects a v1 receipt for terminal release and retries the owned v2 endpoint', async () => { + const versions: string[] = []; + const remote = new RemoteManagedRuntimeProvider({ + baseUrl: 'http://127.0.0.1:4181', + token, + lease: { leaseId: 'lease', epoch: 1 }, + fetch: vi.fn(async (url: RequestInfo | URL) => { + versions.push(new URL(String(url)).pathname); + return Response.json({ + protocolVersion: versions.length === 1 ? 1 : 2, + released: true, + }); + }), + }); + try { + await expect( + remote.release(prepareRequest.sessionId, prepareRequest, { + terminal: true, + }), + ).rejects.toThrow('did not confirm'); + expect(() => remote.prepare(prepareRequest)).toThrow(); + await expect( + remote.release(prepareRequest.sessionId, prepareRequest, { + terminal: true, + }), + ).resolves.toBe(true); + expect(versions).toEqual([ + '/internal/managed-runtime/v2/release', + '/internal/managed-runtime/v2/release', + ]); + } finally { + remote.dispose(); + } + }); + + it('requires an owned lease for terminal release before sending any request', async () => { + const fetch = vi.fn(); + const remote = new RemoteManagedRuntimeProvider({ + baseUrl: 'http://127.0.0.1:4181', + token, + fetch, + }); + await expect( + remote.release(prepareRequest.sessionId, prepareRequest, { + terminal: true, + }), + ).rejects.toThrow('owned Runtime lease'); + expect(fetch).not.toHaveBeenCalled(); + remote.dispose(); + }); + + it('applies the media exception only to valid v2 inline bytes over a chunked HTTP response', async () => { + let responseText = ''; + const server = createServer((req, res) => { + req.resume(); + res.setHeader('content-type', 'application/json'); + if (req.url?.endsWith('/v1/prepare')) { + res.end(JSON.stringify({ protocolVersion: 1, ready: true })); + return; + } + for (let start = 0; start < responseText.length; start += 256 * 1024) { + res.write(responseText.slice(start, start + 256 * 1024)); + } + res.end(); + }); + servers.push(server); + const remote = new RemoteManagedRuntimeProvider({ + baseUrl: `http://127.0.0.1:${await listen(server)}`, + token, + lease: { leaseId: 'lease', epoch: 1 }, + }); + try { + const connected = await remote.getToolV2Client(prepareRequest); + const data = 'AAAA'.repeat(2300 * 1024); + const media = { + executionStatus: 'success', + result: { + llmContent: [{ inlineData: { mimeType: 'image/jpeg', data } }], + returnDisplay: 'image', + }, + }; + const valid = JSON.stringify({ protocolVersion: 2, result: media }); + responseText = valid; + await expect(connected.execute(invocation)).resolves.toEqual(media); + for (const makeResponse of [ + () => + JSON.stringify({ + protocolVersion: 2, + result: { + executionStatus: 'success', + result: { llmContent: data }, + }, + }), + () => + JSON.stringify({ + protocolVersion: 2, + result: { ...media, postHook: { text: data } }, + }), + () => + `{"result":${JSON.stringify(media)},"protocolVersion":2,"result":{"executionStatus":"success"}}`, + () => valid + ' '.repeat(8 * 1024 * 1024), + () => valid.replace(data, '\\u0041'.repeat(data.length)), + ]) { + responseText = makeResponse(); + await expect(connected.execute(invocation)).rejects.toThrow( + 'control response exceeded', + ); + } + responseText = JSON.stringify({ + protocolVersion: 2, + result: { + ...media, + result: { + llmContent: [ + { inlineData: { mimeType: 'image/jpeg', data: 'AR==' } }, + ], + }, + }, + }); + await expect(connected.execute(invocation)).rejects.toThrow( + 'invalid inline media', + ); + responseText = JSON.stringify({ + protocolVersion: 2, + result: { state: 'executing', result: media }, + }); + await expect(connected.status(invocation)).rejects.toThrow( + 'control response exceeded', + ); + responseText = JSON.stringify({ + protocolVersion: 2, + result: { source: data }, + }); + await expect(connected.fileHistory!.snapshot()).rejects.toThrow( + 'size limit', + ); + responseText = JSON.stringify({ protocolVersion: 1, result: media }); + await expect( + remote + .prepare(prepareRequest) + .execute({} as never, new AbortController().signal), + ).rejects.toThrow('size limit'); + responseText = valid + ' '.repeat(64 * 1024 * 1024); + await expect(connected.execute(invocation)).rejects.toThrow('size limit'); + } finally { + remote.dispose(); + } + }); + + it('pins request identity and lease while retrying cold-start preparation', async () => { + const lease = { leaseId: 'owned-lease', epoch: 1 }; + const input = { ...prepareRequest }; + let finish!: (response: Response) => void; + const first = new Promise((resolve) => { + finish = resolve; + }); + let requests = 0; + const fetchImpl = vi.fn( + async (_url: RequestInfo | URL, _options?: RequestInit) => { + if (++requests === 1) return first; + return Response.json({ protocolVersion: 1, ready: true }); + }, + ); + const remote = new RemoteManagedRuntimeProvider({ + baseUrl: 'http://127.0.0.1:4181', + token, + lease, + fetch: fetchImpl, + prepareRetryDelayMs: 0, + }); + try { + const pending = remote.getToolV2Client(input); + input.workspaceCwd = '/other'; + input.tenantId = 'other'; + lease.epoch = 2; + lease.leaseId = 'other'; + finish(new Response('', { status: 503 })); + await pending; + expect(fetchImpl).toHaveBeenCalledTimes(2); + for (const [, options] of fetchImpl.mock.calls) { + expect(JSON.parse(String(options?.body))).toEqual(prepareRequest); + expect( + new Headers(options?.headers).get('X-Qwen-Managed-Lease-Id'), + ).toBe('owned-lease'); + expect( + new Headers(options?.headers).get('X-Qwen-Managed-Lease-Epoch'), + ).toBe('1'); + } + } finally { + remote.dispose(); + } + }); + + it('retains a failed remote release, permits v2 drain queries, and retries cleanup without executing twice', async () => { + let releases = 0; + const fetchImpl = vi.fn(async (input: RequestInfo | URL) => { + const pathname = new URL(String(input)).pathname; + if (pathname.endsWith('/v1/prepare')) + return Response.json({ protocolVersion: 1, ready: true }); + if (pathname.endsWith('/release')) { + if (++releases === 1) return new Response('', { status: 500 }); + return Response.json({ protocolVersion: 2, released: true }); + } + if (pathname.endsWith('/execute')) + throw new TypeError('lost execute response'); + return Response.json({ + protocolVersion: 2, + result: { + state: 'settled', + cancelRequested: true, + result: { executionStatus: 'cancelled' }, + }, + }); + }); + const remote = new RemoteManagedRuntimeProvider({ + baseUrl: 'http://127.0.0.1:4181', + token, + lease: { leaseId: 'lease', epoch: 1 }, + fetch: fetchImpl, + }); + try { + const client = await remote.getToolV2Client(prepareRequest); + await expect(client.execute(invocation)).rejects.toThrow( + 'lost execute response', + ); + await expect( + remote.release(prepareRequest.sessionId, prepareRequest), + ).rejects.toThrow('HTTP 500'); + await expect( + client.prepare(invocation, 'read_file', {}), + ).rejects.toThrow(); + await expect(client.status(invocation)).resolves.toMatchObject({ + state: 'settled', + }); + await expect(client.cancel(invocation)).resolves.toMatchObject({ + state: 'settled', + }); + await expect( + remote.release(prepareRequest.sessionId, prepareRequest), + ).resolves.toBe(true); + await expect(client.status(invocation)).rejects.toThrow(); + expect( + fetchImpl.mock.calls.filter(([url]) => + new URL(String(url)).pathname.endsWith('/execute'), + ), + ).toHaveLength(1); + } finally { + remote.dispose(); + } + }); + + it.each([ + { method: 'execute', result: {} }, + { method: 'execute', result: { executionStatus: 'executing' } }, + { method: 'status', result: { state: 'settled' } }, + { method: 'cancel', result: { state: 'settled', result: {} } }, + ] as const)( + 'rejects a $method response without a physical terminal result: $result', + async ({ method, result }) => { + const remote = new RemoteManagedRuntimeProvider({ + baseUrl: 'http://127.0.0.1:4181', + token, + lease: { leaseId: 'lease', epoch: 1 }, + fetch: vi.fn(async (input: RequestInfo | URL) => + Response.json( + new URL(String(input)).pathname.endsWith('/v1/prepare') + ? { protocolVersion: 1, ready: true } + : { protocolVersion: 2, result }, + ), + ), + }); + try { + const client = await remote.getToolV2Client(prepareRequest); + await expect(client[method](invocation)).rejects.toThrow( + method === 'execute' + ? 'did not confirm physical execution' + : 'invalid invocation status', + ); + } finally { + remote.dispose(); + } + }, + ); + + it('waits for in-flight preparation before releasing its remote Session and keeps failed acquire cleanup reachable', async () => { + let finish!: (value: Response) => void; + const ready = new Promise((resolve) => { + finish = resolve; + }); + const fetchImpl = vi.fn(async (input: RequestInfo | URL) => + new URL(String(input)).pathname.endsWith('/prepare') + ? ready + : Response.json({ protocolVersion: 2, released: true }), + ); + const remote = new RemoteManagedRuntimeProvider({ + baseUrl: 'http://127.0.0.1:4181', + token, + lease: { leaseId: 'lease', epoch: 1 }, + fetch: fetchImpl, + }); + try { + const acquired = remote.getToolV2Client(prepareRequest); + const acquireResult = acquired.catch((error: unknown) => error); + const released = remote.release(prepareRequest.sessionId, prepareRequest); + await Promise.resolve(); + expect(fetchImpl).toHaveBeenCalledOnce(); + finish(new Response('', { status: 500 })); + expect(await acquireResult).toBeInstanceOf(Error); + await expect(released).resolves.toBe(true); + expect(fetchImpl).toHaveBeenCalledTimes(2); + } finally { + remote.dispose(); + } + }); + + const invocation = { + sessionId: prepareRequest.sessionId, + promptId: 'prompt-1', + callId: 'call-1', + capabilityDigest: 'a'.repeat(64), + policyRevision: 'revision', + invocationId: 'invocation', + argsDigest: 'b'.repeat(64), + }; + + it('revokes issued v2 clients when the provider is disposed or its workspace is replaced', async () => { + for (const revoke of ['dispose', 'replace'] as const) { + const runtime = fakeRuntime(); + const downstream = toolV2Client(); + runtime.bridge.getManagedToolV2Client = vi.fn( + () => downstream as unknown as ManagedToolV2Client, + ); + const local = new LocalManagedRuntimeProvider(runtime.registry); + const client = await local.getToolV2Client(prepareRequest); + if (revoke === 'dispose') local.dispose(); + else + vi.mocked(runtime.registry.getByWorkspaceId).mockReturnValue({ + ...runtime.registry.getByWorkspaceId(workspaceId)!, + }); + await expect(client.execute(invocation)).rejects.toThrow(); + await expect(client.status(invocation)).rejects.toThrow(); + expect(downstream.execute).not.toHaveBeenCalled(); + expect(downstream.status).not.toHaveBeenCalled(); + await expect(client.fileHistory!.snapshot()).rejects.toThrow(); + await expect( + client.fileHistory!.checkpoint('parent-turn'), + ).rejects.toThrow(); + expect(downstream.fileHistory.snapshot).not.toHaveBeenCalled(); + expect(downstream.fileHistory.checkpoint).not.toHaveBeenCalled(); + local.dispose(); + } + }); + + it('retains a failed release for retry, blocks admission and permits only drain queries', async () => { + const runtime = fakeRuntime(); + const downstream = toolV2Client(); + runtime.bridge.getManagedToolV2Client = vi.fn( + () => downstream as unknown as ManagedToolV2Client, + ); + const local = new LocalManagedRuntimeProvider(runtime.registry); + const client = await local.getToolV2Client(prepareRequest); + let failClose!: (error: Error) => void; + runtime.close.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + failClose = reject; + }), + ); + const release = local.release(prepareRequest.sessionId, prepareRequest); + const concurrent = local.release(prepareRequest.sessionId, prepareRequest); + const firstResult = release.catch((error: unknown) => error); + const secondResult = concurrent.catch((error: unknown) => error); + expect(() => local.prepare(prepareRequest)).toThrow(); + await expect(client.execute(invocation)).rejects.toThrow(); + const drain = await local.getToolV2Client(prepareRequest); + await expect(drain.status(invocation)).resolves.toEqual({ + state: 'executing', + }); + await expect(drain.cancel(invocation)).resolves.toEqual({ + state: 'cancel_requested', + }); + await expect(drain.manifest()).rejects.toThrow(); + await expect(drain.fileHistory!.snapshot()).resolves.toEqual({ + ownerSessionId: prepareRequest.sessionId, + revision: 0, + snapshots: [], + }); + await expect(drain.fileHistory!.checkpoint('late')).rejects.toThrow(); + await expect( + drain.fileHistory!.bind({ + ownerSessionId: prepareRequest.sessionId, + ownerRuntimeSessionId: prepareRequest.sessionId, + executionCwd: workspaceCwd, + snapshots: [], + }), + ).rejects.toThrow(); + expect(downstream.fileHistory.checkpoint).not.toHaveBeenCalled(); + expect(downstream.fileHistory.bind).not.toHaveBeenCalled(); + expect(runtime.close).toHaveBeenCalledTimes(1); + const failure = new Error('close did not drain'); + failClose(failure); + expect(await firstResult).toBe(failure); + expect(await secondResult).toBe(failure); + expect(() => local.prepare(prepareRequest)).toThrow(); + await expect( + local.release(prepareRequest.sessionId, prepareRequest), + ).resolves.toBe(true); + expect(runtime.close).toHaveBeenCalledTimes(2); + expect(runtime.bridge.spawnOrAttach).toHaveBeenCalledTimes(1); + await expect(client.status(invocation)).rejects.toThrow(); + local.dispose(); + }); + + it('closes a restored Session when release races with warmup', async () => { + const runtime = fakeRuntime(); + let finishResume!: (session: { + sessionId: string; + workspaceCwd: string; + attached: true; + clientId: string; + hasActivePrompt: false; + sourceType: 'managed-gateway'; + sourceId: string; + sourcePersisted: true; + state: never; + }) => void; + vi.mocked(runtime.bridge.resumeSession).mockImplementationOnce( + () => + new Promise((resolve) => { + finishResume = resolve; + }), + ); + const local = new LocalManagedRuntimeProvider(runtime.registry); + const continuation = { + ...prepareRequest, + turnKind: 'continuation', + } as const; + const handle = local.prepare(continuation); + const ready = handle.ready.catch((error: unknown) => error); + const release = local.release(prepareRequest.sessionId, continuation); + + finishResume({ + sessionId: prepareRequest.sessionId, + workspaceCwd, + attached: true, + clientId: 'runtime-client-racing-release-p8', + hasActivePrompt: false, + sourceType: 'managed-gateway', + sourceId: prepareRequest.sessionId, + sourcePersisted: true, + state: {} as never, + }); + + await expect(ready).resolves.toMatchObject({ + message: 'Managed Runtime Session released.', + }); + await expect(release).resolves.toBe(true); + expect(runtime.close).toHaveBeenCalledWith(prepareRequest.sessionId, { + clientId: 'runtime-client-racing-release-p8', + }); + local.dispose(); + }); + + it('does not close a colliding Session when release races with warmup', async () => { + const runtime = fakeRuntime(); + let finishResume!: (session: { + sessionId: string; + workspaceCwd: string; + attached: true; + clientId: string; + hasActivePrompt: false; + sourceType: 'foreign-owner'; + sourceId: string; + sourcePersisted: true; + state: never; + }) => void; + vi.mocked(runtime.bridge.resumeSession).mockImplementationOnce( + () => + new Promise((resolve) => { + finishResume = resolve; + }), + ); + const local = new LocalManagedRuntimeProvider(runtime.registry); + const continuation = { + ...prepareRequest, + turnKind: 'continuation', + } as const; + const handle = local.prepare(continuation); + const ready = handle.ready.catch((error: unknown) => error); + const release = local.release(prepareRequest.sessionId, continuation); + + finishResume({ + sessionId: prepareRequest.sessionId, + workspaceCwd, + attached: true, + clientId: 'foreign-client-p8', + hasActivePrompt: false, + sourceType: 'foreign-owner', + sourceId: 'foreign-session-p8', + sourcePersisted: true, + state: {} as never, + }); + + await expect(ready).resolves.toMatchObject({ + message: 'Managed Runtime Session released.', + }); + await expect(release).resolves.toBe(true); + expect(runtime.close).not.toHaveBeenCalled(); + expect(runtime.bridge.detachClient).toHaveBeenCalledWith( + prepareRequest.sessionId, + 'foreign-client-p8', + ); + local.dispose(); + }); + + it('retains a Session created during warmup until its failed close is retried', async () => { + const runtime = fakeRuntime(); + let finishSpawn!: ( + session: Awaited>, + ) => void; + vi.mocked(runtime.bridge.spawnOrAttach).mockReturnValueOnce( + new Promise((resolve) => { + finishSpawn = resolve; + }), + ); + const local = new LocalManagedRuntimeProvider(runtime.registry); + const ready = local + .prepare(prepareRequest) + .ready.catch((error: unknown) => error); + const failure = new Error('close failed after spawn'); + runtime.close.mockRejectedValueOnce(failure); + const releasing = local + .release(prepareRequest.sessionId, prepareRequest) + .catch((error: unknown) => error); + expect(() => local.prepare(prepareRequest)).toThrow(); + expect(runtime.close).not.toHaveBeenCalled(); + finishSpawn({ + sessionId: prepareRequest.sessionId, + workspaceCwd, + attached: false, + clientId: 'late-client', + sourceType: 'managed-gateway', + sourceId: prepareRequest.sessionId, + hasActivePrompt: false, + sourcePersisted: true, + }); + await expect(ready).resolves.toMatchObject({ + message: 'Managed Runtime Session released.', + }); + expect(await releasing).toBe(failure); + expect(() => local.prepare(prepareRequest)).toThrow(); + await expect( + local.release(prepareRequest.sessionId, prepareRequest), + ).resolves.toBe(true); + expect(runtime.close).toHaveBeenCalledTimes(2); + expect(runtime.close).toHaveBeenLastCalledWith(prepareRequest.sessionId, { + clientId: 'late-client', + }); + expect(runtime.bridge.spawnOrAttach).toHaveBeenCalledTimes(1); + expect(runtime.bridge.resumeSession).not.toHaveBeenCalled(); + local.dispose(); + }); + + it('does not treat a vanished Session after failed close as proof of release', async () => { + const runtime = fakeRuntime(); + const local = new LocalManagedRuntimeProvider(runtime.registry); + await local.prepare(prepareRequest).ready; + runtime.close + .mockRejectedValueOnce(new Error('transport lost')) + .mockRejectedValueOnce( + Object.assign(new Error('not found'), { code: 'session_not_found' }), + ); + await expect( + local.release(prepareRequest.sessionId, prepareRequest), + ).rejects.toThrow('transport lost'); + await expect( + local.release(prepareRequest.sessionId, prepareRequest), + ).rejects.toThrow('not found'); + expect(() => local.prepare(prepareRequest)).toThrow('closing'); + expect(runtime.bridge.spawnOrAttach).toHaveBeenCalledTimes(1); + local.dispose(); + }); + + it('retains a failed cleanup of a colliding warmup attachment for retry', async () => { + const runtime = fakeRuntime(); + vi.mocked(runtime.bridge.spawnOrAttach).mockResolvedValueOnce({ + sessionId: prepareRequest.sessionId, + workspaceCwd, + attached: true, + clientId: 'foreign-client', + sourceType: 'managed-gateway', + sourceId: 'foreign-owner', + }); + vi.mocked(runtime.bridge.detachClient).mockRejectedValueOnce( + new Error('detach failed'), + ); + const local = new LocalManagedRuntimeProvider(runtime.registry); + await expect(local.prepare(prepareRequest).ready).rejects.toThrow( + 'cleanup failed', + ); + await expect( + local.release(prepareRequest.sessionId, prepareRequest), + ).rejects.toThrow('cleanup failed'); + expect(() => local.prepare(prepareRequest)).toThrow('closing'); + await expect( + local.release(prepareRequest.sessionId, prepareRequest), + ).resolves.toBe(true); + expect(runtime.bridge.detachClient).toHaveBeenCalledTimes(2); + expect(runtime.close).not.toHaveBeenCalled(); + expect(runtime.bridge.spawnOrAttach).toHaveBeenCalledTimes(1); + local.dispose(); + }); + + it('retries the same attachment cleanup when restoring an untracked release', async () => { + const runtime = fakeRuntime(); + vi.mocked(runtime.bridge.resumeSession).mockResolvedValueOnce({ + sessionId: prepareRequest.sessionId, + workspaceCwd, + attached: true, + clientId: 'foreign-restored-client', + sourceType: 'managed-gateway', + sourceId: 'foreign-owner', + state: {}, + }); + vi.mocked(runtime.bridge.detachClient).mockRejectedValueOnce( + new Error('detach failed'), + ); + const local = new LocalManagedRuntimeProvider(runtime.registry); + await expect( + local.release(prepareRequest.sessionId, prepareRequest), + ).rejects.toThrow('cleanup failed'); + expect(() => local.prepare(prepareRequest)).toThrow('closing'); + await expect( + local.release(prepareRequest.sessionId, prepareRequest), + ).resolves.toBe(true); + expect(runtime.bridge.detachClient).toHaveBeenCalledTimes(2); + expect(runtime.bridge.detachClient).toHaveBeenLastCalledWith( + prepareRequest.sessionId, + 'foreign-restored-client', + ); + expect(runtime.bridge.resumeSession).toHaveBeenCalledTimes(1); + expect(runtime.bridge.spawnOrAttach).not.toHaveBeenCalled(); + expect(runtime.close).not.toHaveBeenCalled(); + local.dispose(); + }); + + it('rejects cleartext non-loopback Runtime origins', () => { + expect( + () => + new RemoteManagedRuntimeProvider({ + baseUrl: 'http://10.0.0.10:4181', + token, + }), + ).toThrow('must use HTTPS'); + }); + + it('stops reading an oversized Runtime response', async () => { + let requestCount = 0; + const fetchImpl = vi.fn(async () => { + requestCount += 1; + if (requestCount === 1) { + return Response.json({ + protocolVersion: MANAGED_RUNTIME_PROTOCOL_VERSION, + ready: true, + }); + } + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(600 * 1024)); + controller.enqueue(new Uint8Array(600 * 1024)); + controller.close(); + }, + }), + ); + }) as unknown as typeof fetch; + const remote = new RemoteManagedRuntimeProvider({ + baseUrl: 'http://127.0.0.1:4181', + token, + fetch: fetchImpl, + }); + const handle = remote.prepare(prepareRequest); + await handle.ready; + + await expect( + handle.getManifest(new AbortController().signal), + ).rejects.toThrow('exceeded its size limit'); + expect(fetchImpl).toHaveBeenCalledTimes(2); + remote.dispose(); + }); + + it('bounds a prepare request when the Runtime never responds', async () => { + const fetchImpl = vi.fn( + async (_input: RequestInfo | URL, init?: RequestInit) => + new Promise((_resolve, reject) => { + const signal = init?.signal; + if (!signal) return; + const rejectAborted = () => reject(signal.reason); + if (signal.aborted) rejectAborted(); + else signal.addEventListener('abort', rejectAborted, { once: true }); + }), + ) as unknown as typeof fetch; + const remote = new RemoteManagedRuntimeProvider({ + baseUrl: 'http://127.0.0.1:4181', + token, + fetch: fetchImpl, + prepareRetryWindowMs: 20, + }); + + await expect(remote.prepare(prepareRequest).ready).rejects.toThrow( + 'preparation timed out', + ); + remote.prepare(prepareRequest); + expect(fetchImpl).toHaveBeenCalledTimes(2); + remote.dispose(); + }); + + it('invalidates an existing remote handle when its Session is released', async () => { + const fetchImpl = vi.fn(async (input: RequestInfo | URL) => { + const pathname = new URL(String(input)).pathname; + if (pathname.endsWith('/prepare')) { + return Response.json({ + protocolVersion: MANAGED_RUNTIME_PROTOCOL_VERSION, + ready: true, + }); + } + if (pathname.endsWith('/release')) { + return Response.json({ + protocolVersion: MANAGED_RUNTIME_PROTOCOL_VERSION, + released: true, + }); + } + return Response.json({ + protocolVersion: MANAGED_RUNTIME_PROTOCOL_VERSION, + manifest, + }); + }) as unknown as typeof fetch; + const remote = new RemoteManagedRuntimeProvider({ + baseUrl: 'http://127.0.0.1:4181', + token, + fetch: fetchImpl, + }); + const handle = remote.prepare(prepareRequest); + await handle.ready; + await expect(remote.release(prepareRequest.sessionId)).resolves.toBe(true); + + await expect( + handle.getManifest(new AbortController().signal), + ).rejects.toThrow('Managed Runtime Session released.'); + expect(fetchImpl).toHaveBeenCalledTimes(2); + remote.dispose(); + }); + + it('invalidates an existing local handle when its Session is released', async () => { + const runtime = fakeRuntime(); + const local = new LocalManagedRuntimeProvider(runtime.registry); + const handle = local.prepare(prepareRequest); + await handle.ready; + await expect(local.release(prepareRequest.sessionId)).resolves.toBe(true); + + await expect( + handle.getManifest(new AbortController().signal), + ).rejects.toThrow('Managed Runtime Session released.'); + expect(runtime.bridge.getManagedRuntimeToolManifest).not.toHaveBeenCalled(); + local.dispose(); + }); +}); + +describe('Managed Runtime protocol', () => { + it('accepts only versioned Prompt-free identity payloads', () => { + expect(parseManagedRuntimePrepareRequest(prepareRequest)).toEqual( + prepareRequest, + ); + expect(() => + parseManagedRuntimePrepareRequest({ + ...prepareRequest, + protocolVersion: 2, + }), + ).toThrow('unsupported'); + expect(() => + parseManagedRuntimePrepareRequest({ + ...prepareRequest, + history: [], + }), + ).toThrow(); + expect(() => + parseManagedRuntimeExecuteRequest({ + ...prepareRequest, + toolRequest: { + executionId: 'x'.repeat(129), + turnId: 'turn-p8', + toolCallId: 'call-p8', + capabilityDigest: manifest.capabilityDigest, + toolName: 'read_file', + input: {}, + }, + }), + ).toThrow(); + }); +}); diff --git a/packages/cli/src/serve/managed-runtime-provider.ts b/packages/cli/src/serve/managed-runtime-provider.ts new file mode 100644 index 00000000000..f98b735f3d5 --- /dev/null +++ b/packages/cli/src/serve/managed-runtime-provider.ts @@ -0,0 +1,1586 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + BridgeManagedRuntimeToolExecuteRequest, + BridgeManagedRuntimeToolExecuteResult, + BridgeManagedRuntimeToolManifest, + ManagedToolV2Client, +} from '@qwen-code/acp-bridge/bridgeTypes'; +import type { ManagedToolInvocationStatus } from '@qwen-code/qwen-code-core/tools/managed-tool-runtime.js'; +import { + MANAGED_LEASE_ID_HEADER, + MANAGED_LEASE_EPOCH_HEADER, + type RuntimeFinishReason, +} from './managed-runtime-activator.js'; +import type { AcpSessionBridge } from './acp-session-bridge.js'; +import { + isManagedMediaOperation, + managedToolResponseMediaBytes, + MAX_MANAGED_MEDIA_RESPONSE_BYTES, +} from '../acp-integration/managed-tool-media.js'; +import { isLoopbackBind } from './loopback-binds.js'; + +interface ManagedGatewayToolRuntime { + getManifest(signal: AbortSignal): Promise; + execute( + request: BridgeManagedRuntimeToolExecuteRequest, + signal: AbortSignal, + ): Promise; +} +import { + MANAGED_RUNTIME_PROTOCOL_VERSION, + type ManagedRuntimeCancelResponse, + type ManagedRuntimeExecuteResponse, + type ManagedRuntimeManifestResponse, + type ManagedRuntimePrepareRequest, + type ManagedRuntimeReadyResponse, + sameManagedRuntimeIdentity, +} from './managed-runtime-protocol.js'; +import type { + WorkspaceRegistry, + WorkspaceRuntime, +} from './workspace-registry.js'; + +const MAX_REMOTE_MANIFEST_BYTES = 1024 * 1024; +const MAX_REMOTE_TOOL_RESULT_BYTES = 8 * 1024 * 1024; +const MAX_REMOTE_CONTROL_RESPONSE_BYTES = 64 * 1024; +const DEFAULT_PREPARE_RETRY_WINDOW_MS = 5 * 60_000; +const DEFAULT_PREPARE_RETRY_DELAY_MS = 100; +const DEFAULT_PREPARE_RETRY_MAX_DELAY_MS = 2_000; +const CONTROL_REQUEST_TIMEOUT_MS = 5_000; +const TOOL_REQUEST_TIMEOUT_MS = 10 * 60_000; + +type BridgeSession = Awaited>; + +export interface ManagedRuntimeHandle extends ManagedGatewayToolRuntime { + readonly ready: Promise; + finish(reason: RuntimeFinishReason): void; +} + +export interface ManagedRuntimeReleaseOptions { + terminal?: boolean; +} + +export interface ManagedRuntimeToolClientContext { + readonly harnessSessionId: string; +} + +export interface ManagedRuntimeExecutionIdentity { + readonly harnessSessionId: string; + readonly runtimeSessionId: string; + readonly executionCallId: string; + readonly afterSeq?: number; +} + +export type ManagedRuntimeExecutionInspection = + | { + readonly outcome: 'known'; + readonly status: ManagedToolInvocationStatus; + } + | { readonly outcome: 'unknown' }; + +export type ManagedRuntimeUnknownResolution = + | 'confirmed_not_executed' + | 'accepted_unknown'; + +export interface ManagedRuntimeProvider { + getToolV2Client?( + request: ManagedRuntimePrepareRequest, + context?: ManagedRuntimeToolClientContext, + ): Promise; + inspectExecution?( + identity: ManagedRuntimeExecutionIdentity, + ): Promise; + reconcileExecution?( + identity: ManagedRuntimeExecutionIdentity, + ): Promise; + cancelExecution?( + identity: ManagedRuntimeExecutionIdentity, + ): Promise; + resolveExecution?( + identity: ManagedRuntimeExecutionIdentity, + resolution: ManagedRuntimeUnknownResolution, + ): Promise; + prepare(request: ManagedRuntimePrepareRequest): ManagedRuntimeHandle; + cancel( + sessionId: string, + executionId: string, + expected?: ManagedRuntimePrepareRequest, + ): Promise; + release( + sessionId: string, + expected?: ManagedRuntimePrepareRequest, + options?: ManagedRuntimeReleaseOptions, + ): Promise; + dispose(): void | Promise; +} + +export class ManagedRuntimeProviderError extends Error { + constructor( + readonly code: + | 'managed_runtime_identity_conflict' + | 'managed_runtime_unavailable' + | 'managed_runtime_disposed' + | 'managed_runtime_capacity_exhausted', + message: string, + readonly retryable: boolean, + ) { + super(message); + this.name = 'ManagedRuntimeProviderError'; + } +} + +interface LocalBinding { + readonly request: ManagedRuntimePrepareRequest; + readonly runtime: WorkspaceRuntime; + readonly runtimeClientId: string; + readonly controller: AbortController; +} + +interface LocalWarmup { + readonly request: ManagedRuntimePrepareRequest; + readonly runtime: WorkspaceRuntime; + readonly controller: AbortController; + readonly promise: Promise; +} + +interface LocalRelease { + readonly request: ManagedRuntimePrepareRequest; + warmup?: LocalWarmup; + binding?: LocalBinding; + pending?: Promise; + cleanup?: () => Promise; + terminal?: boolean; + completed?: boolean; +} + +class ManagedRuntimeSessionCleanupError extends Error { + constructor( + readonly cleanup: () => Promise, + cause: unknown, + ) { + super('Managed Runtime Session cleanup failed.', { cause }); + } +} + +class ManagedRuntimeReleaseAbortError extends Error { + constructor() { + super('Managed Runtime Session released.'); + this.name = 'ManagedRuntimeReleaseAbortError'; + } +} + +function abortError(signal: AbortSignal, fallback: string): unknown { + return signal.reason instanceof Error + ? signal.reason + : new DOMException(fallback, 'AbortError'); +} + +function waitForValue(pending: Promise, signal: AbortSignal): Promise { + if (signal.aborted) { + return Promise.reject(abortError(signal, 'Managed Runtime wait aborted.')); + } + return new Promise((resolve, reject) => { + const onAbort = () => { + signal.removeEventListener('abort', onAbort); + reject(abortError(signal, 'Managed Runtime wait aborted.')); + }; + signal.addEventListener('abort', onAbort, { once: true }); + void pending.then( + (value) => { + signal.removeEventListener('abort', onAbort); + resolve(value); + }, + (error: unknown) => { + signal.removeEventListener('abort', onAbort); + reject(error); + }, + ); + }); +} + +async function cleanupLocalSession( + bridge: AcpSessionBridge, + session: BridgeSession, +): Promise { + if (session.attached && session.clientId) { + await bridge.detachClient(session.sessionId, session.clientId); + return; + } + await bridge.closeSession( + session.sessionId, + session.clientId ? { clientId: session.clientId } : undefined, + ); +} + +function waitForLocalSession( + bridge: AcpSessionBridge, + pending: Promise, + request: ManagedRuntimePrepareRequest, + signal: AbortSignal, + allowAttached: boolean, +): Promise<{ session: BridgeSession; runtimeClientId: string }> { + if (signal.aborted) { + return Promise.reject(abortError(signal, 'Runtime preparation aborted.')); + } + return new Promise((resolve, reject) => { + const onAbort = () => { + if (signal.reason instanceof ManagedRuntimeReleaseAbortError) return; + reject(abortError(signal, 'Runtime preparation aborted.')); + }; + signal.addEventListener('abort', onAbort, { once: true }); + void pending + .then( + async (session) => { + signal.removeEventListener('abort', onAbort); + const matchesIdentity = + session.sessionId === request.sessionId && + session.workspaceCwd === request.workspaceCwd && + session.sourceType === 'managed-gateway' && + session.sourceId === request.sessionId; + if ( + signal.reason instanceof ManagedRuntimeReleaseAbortError && + matchesIdentity && + (allowAttached || !session.attached) && + session.clientId + ) { + resolve({ session, runtimeClientId: session.clientId }); + return; + } + const valid = + !signal.aborted && + (allowAttached || !session.attached) && + matchesIdentity && + session.hasActivePrompt !== true && + Boolean(session.clientId); + if (!valid) { + const cleanup = () => cleanupLocalSession(bridge, session); + try { + await cleanup(); + } catch (error) { + throw new ManagedRuntimeSessionCleanupError(cleanup, error); + } + reject( + signal.aborted + ? abortError(signal, 'Runtime preparation aborted.') + : new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Managed Runtime Session identity did not match its binding.', + false, + ), + ); + return; + } + resolve({ session, runtimeClientId: session.clientId! }); + }, + (error: unknown) => { + signal.removeEventListener('abort', onAbort); + reject(error); + }, + ) + .catch(reject); + }); +} + +function isSessionNotFound(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'session_not_found' + ); +} + +export class LocalManagedRuntimeProvider implements ManagedRuntimeProvider { + private readonly bindings = new Map(); + private readonly warmups = new Map(); + private readonly releases = new Map(); + private readonly lifetime = new AbortController(); + + constructor(private readonly workspaceRegistry: WorkspaceRegistry) {} + + prepare(request: ManagedRuntimePrepareRequest): ManagedRuntimeHandle { + this.assertNotReleasing(request.sessionId); + if (this.lifetime.signal.aborted) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_disposed', + 'Managed Runtime provider is disposed.', + false, + ); + } + const runtime = this.workspaceRegistry.getByWorkspaceId( + request.workspaceId, + ); + if (!runtime || !runtime.trusted) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_unavailable', + 'Managed Runtime workspace is unavailable or untrusted.', + true, + ); + } + if (runtime.workspaceCwd !== request.workspaceCwd) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Managed Runtime workspace identity changed.', + false, + ); + } + if ( + !runtime.bridge.getManagedRuntimeToolManifest || + !runtime.bridge.executeManagedRuntimeTool || + !runtime.bridge.cancelManagedRuntimeTool + ) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_unavailable', + 'The bridge does not support Managed Runtime tools.', + false, + ); + } + const readyBinding = this.startWarmup(runtime, request); + return { + ready: readyBinding.then(() => undefined), + finish: () => {}, + getManifest: async (signal) => { + const binding = await waitForValue(readyBinding, signal); + const operationSignal = AbortSignal.any([ + signal, + binding.controller.signal, + this.lifetime.signal, + ]); + operationSignal.throwIfAborted(); + return waitForValue( + binding.runtime.bridge.getManagedRuntimeToolManifest!( + request.sessionId, + { clientId: binding.runtimeClientId }, + ), + operationSignal, + ); + }, + execute: async (toolRequest, signal) => { + const binding = await waitForValue(readyBinding, signal); + const operationSignal = AbortSignal.any([ + signal, + binding.controller.signal, + this.lifetime.signal, + ]); + operationSignal.throwIfAborted(); + return binding.runtime.bridge.executeManagedRuntimeTool!( + request.sessionId, + toolRequest, + operationSignal, + { clientId: binding.runtimeClientId }, + ); + }, + }; + } + + async getToolV2Client( + request: ManagedRuntimePrepareRequest, + ): Promise { + const releasing = this.releases.get(request.sessionId); + if (!releasing) await this.prepare(request).ready; + const binding = + releasing?.binding ?? + this.bindings.get(request.sessionId) ?? + (await this.warmups.get(request.sessionId)?.promise); + if (!binding || !sameManagedRuntimeIdentity(binding.request, request)) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Managed Runtime Session binding changed.', + false, + ); + } + const assertBinding = (allowDraining: boolean) => { + this.lifetime.signal.throwIfAborted(); + const release = this.releases.get(request.sessionId); + const retained = release?.binding === binding; + const runtime = this.workspaceRegistry.getByWorkspaceId( + request.workspaceId, + ); + if ( + (this.bindings.get(request.sessionId) !== binding && !retained) || + runtime !== binding.runtime || + runtime.workspaceCwd !== request.workspaceCwd + ) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Managed Runtime Session binding changed.', + false, + ); + } + if (!allowDraining) { + this.assertNotReleasing(request.sessionId); + if (!runtime.trusted) + throw new ManagedRuntimeProviderError( + 'managed_runtime_unavailable', + 'Managed Runtime workspace is untrusted.', + false, + ); + } + if ( + !( + allowDraining && + retained && + binding.controller.signal.reason instanceof + ManagedRuntimeReleaseAbortError + ) + ) { + binding.controller.signal.throwIfAborted(); + } + }; + assertBinding(true); + if (!binding.runtime.bridge.getManagedToolV2Client) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_unavailable', + 'The bridge does not support Managed Tool v2.', + false, + ); + } + const client = binding.runtime.bridge.getManagedToolV2Client( + request.sessionId, + { + clientId: binding.runtimeClientId, + }, + ); + const call = async ( + operation: () => Promise, + allowDraining = false, + ): Promise => { + assertBinding(allowDraining); + return operation(); + }; + const history = client.fileHistory; + return { + fileHistory: history + ? { + bind: (binding) => call(() => history.bind(binding)), + checkpoint: (promptId) => call(() => history.checkpoint(promptId)), + snapshot: () => call(() => history.snapshot(), true), + } + : undefined, + manifest: () => call(() => client.manifest()), + beginTurn: (identity) => call(() => client.beginTurn(identity)), + prepare: (...args) => call(() => client.prepare(...args)), + confirmation: (reference) => call(() => client.confirmation(reference)), + confirm: (reference, outcome, payload, phase) => + call(() => client.confirm(reference, outcome, payload, phase)), + preflight: (reference) => call(() => client.preflight(reference)), + execute: (reference) => call(() => client.execute(reference)), + status: (reference, afterSeq) => + call(() => client.status(reference, afterSeq), true), + cancel: (reference) => call(() => client.cancel(reference), true), + }; + } + + private assertNotReleasing(sessionId: string): void { + if (this.releases.has(sessionId)) + throw new ManagedRuntimeProviderError( + 'managed_runtime_unavailable', + 'Managed Runtime Session is closing.', + false, + ); + } + + async cancel( + sessionId: string, + executionId: string, + expected?: ManagedRuntimePrepareRequest, + ): Promise { + const binding = this.bindings.get(sessionId); + const warmup = this.warmups.get(sessionId); + const current = binding?.request ?? warmup?.request; + if (expected && current && !sameManagedRuntimeIdentity(current, expected)) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Managed Runtime Session identity changed.', + false, + ); + } + if (!binding) return false; + const result = await binding.runtime.bridge.cancelManagedRuntimeTool!( + sessionId, + executionId, + { clientId: binding.runtimeClientId }, + ); + return result.cancelled; + } + + async release( + sessionId: string, + expected?: ManagedRuntimePrepareRequest, + options?: ManagedRuntimeReleaseOptions, + ): Promise { + const warmup = this.warmups.get(sessionId); + const binding = this.bindings.get(sessionId); + let release = this.releases.get(sessionId); + const current = release?.request ?? warmup?.request ?? binding?.request; + if ( + expected && + (expected.sessionId !== sessionId || + (current && !sameManagedRuntimeIdentity(current, expected))) + ) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Managed Runtime Session identity changed.', + false, + ); + } + this.lifetime.signal.throwIfAborted(); + if (!release) { + const request = current ?? expected; + if (!request) return false; + release = { request: structuredClone(request), binding, warmup }; + this.releases.set(sessionId, release); + warmup?.controller.abort(new ManagedRuntimeReleaseAbortError()); + binding?.controller.abort(new ManagedRuntimeReleaseAbortError()); + } + release.terminal ||= options?.terminal === true; + if (release.completed) return true; + if (release.pending) return release.pending; + const retained = release; + const pending = this.finishRelease(sessionId, retained) + .then((released) => { + if (this.releases.get(sessionId) === retained) { + if (this.bindings.get(sessionId) === retained.binding) + this.bindings.delete(sessionId); + if (this.warmups.get(sessionId) === retained.warmup) + this.warmups.delete(sessionId); + if (retained.terminal) { + retained.completed = true; + delete retained.binding; + delete retained.warmup; + delete retained.cleanup; + } else this.releases.delete(sessionId); + } + return released; + }) + .finally(() => { + retained.pending = undefined; + }); + retained.pending = pending; + return pending; + } + + private async finishRelease( + sessionId: string, + release: LocalRelease, + ): Promise { + if (release.cleanup) { + await release.cleanup(); + return true; + } + if (!release.binding && release.warmup) { + try { + release.binding = await release.warmup.promise; + } catch (error) { + if (error instanceof ManagedRuntimeReleaseAbortError) return true; + if (error instanceof ManagedRuntimeSessionCleanupError) + release.cleanup = error.cleanup; + throw error; + } + } + if (!release.binding) { + this.lifetime.signal.throwIfAborted(); + const expected = release.request; + const runtime = this.workspaceRegistry.getByWorkspaceId( + expected.workspaceId, + ); + if (!runtime || !runtime.trusted) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_unavailable', + 'Managed Runtime workspace is unavailable or untrusted.', + true, + ); + } + if (runtime.workspaceCwd !== expected.workspaceCwd) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Managed Runtime workspace identity changed.', + false, + ); + } + try { + const restored = await waitForLocalSession( + runtime.bridge, + runtime.bridge.resumeSession({ + sessionId, + workspaceCwd: expected.workspaceCwd, + sourceType: 'managed-gateway', + sourceId: sessionId, + }), + expected, + this.lifetime.signal, + true, + ); + const controller = new AbortController(); + controller.abort(new ManagedRuntimeReleaseAbortError()); + release.binding = { + request: expected, + runtime, + runtimeClientId: restored.runtimeClientId, + controller, + }; + } catch (error) { + if (error instanceof ManagedRuntimeSessionCleanupError) + release.cleanup = error.cleanup; + if (isSessionNotFound(error)) return true; + throw error; + } + } + await release.binding.runtime.bridge.closeSession(sessionId, { + clientId: release.binding.runtimeClientId, + }); + return true; + } + + dispose(): void { + if (this.lifetime.signal.aborted) return; + this.lifetime.abort(new Error('Managed Runtime provider disposed.')); + for (const warmup of this.warmups.values()) { + warmup.controller.abort(new Error('Managed Runtime provider disposed.')); + } + for (const binding of this.bindings.values()) { + binding.controller.abort(new Error('Managed Runtime provider disposed.')); + } + this.warmups.clear(); + this.bindings.clear(); + this.releases.clear(); + } + + private startWarmup( + runtime: WorkspaceRuntime, + request: ManagedRuntimePrepareRequest, + ): Promise { + const existingWarmup = this.warmups.get(request.sessionId); + if (existingWarmup) { + if ( + existingWarmup.runtime !== runtime || + !sameManagedRuntimeIdentity(existingWarmup.request, request) + ) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Managed Runtime warmup changed Session identity.', + false, + ); + } + return waitForValue( + existingWarmup.promise, + AbortSignal.any([ + this.lifetime.signal, + existingWarmup.controller.signal, + ]), + ); + } + + const controller = new AbortController(); + const signal = AbortSignal.any([this.lifetime.signal, controller.signal]); + const promise = this.prepareBinding(runtime, request, signal, controller); + const warmup: LocalWarmup = { request, runtime, controller, promise }; + this.warmups.set(request.sessionId, warmup); + void promise.then( + (binding) => { + if (signal.aborted || this.warmups.get(request.sessionId) !== warmup) { + return; + } + this.warmups.delete(request.sessionId); + this.bindings.set(request.sessionId, binding); + }, + (error: unknown) => { + if ( + this.warmups.get(request.sessionId) === warmup && + !(error instanceof ManagedRuntimeSessionCleanupError) + ) { + this.warmups.delete(request.sessionId); + } + }, + ); + return waitForValue(promise, signal); + } + + private async prepareBinding( + runtime: WorkspaceRuntime, + request: ManagedRuntimePrepareRequest, + signal: AbortSignal, + controller: AbortController, + ): Promise { + const cached = this.bindings.get(request.sessionId); + if (cached) { + if (!sameManagedRuntimeIdentity(cached.request, request)) { + this.forgetBinding(request.sessionId, cached); + throw new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Managed Runtime live Session identity changed.', + false, + ); + } + if (cached.runtime !== runtime) { + this.forgetBinding(request.sessionId, cached); + } else { + let summary: + | ReturnType + | undefined; + try { + summary = runtime.bridge.getSessionSummary(request.sessionId); + } catch { + this.forgetBinding(request.sessionId, cached); + } + if ( + summary && + (summary.workspaceCwd !== request.workspaceCwd || + summary.sourceType !== 'managed-gateway' || + summary.sourceId !== request.sessionId) + ) { + this.forgetBinding(request.sessionId, cached); + throw new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Managed Runtime live Session identity changed.', + false, + ); + } + if (summary) { + if (summary.hasActivePrompt) { + this.forgetBinding(request.sessionId, cached); + throw new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Managed Runtime already has an active Prompt.', + false, + ); + } + try { + runtime.bridge.recordHeartbeat(request.sessionId, { + clientId: cached.runtimeClientId, + }); + return cached; + } catch { + this.forgetBinding(request.sessionId, cached); + } + } + } + } + + const waitForSession = ( + pending: Promise, + allowAttached: boolean, + ) => + waitForLocalSession( + runtime.bridge, + pending, + request, + signal, + allowAttached, + ); + let result: Awaited>; + if (request.turnKind === 'continuation') { + try { + result = await waitForSession( + runtime.bridge.resumeSession({ + sessionId: request.sessionId, + workspaceCwd: request.workspaceCwd, + sourceType: 'managed-gateway', + sourceId: request.sessionId, + }), + true, + ); + } catch (error) { + if (!isSessionNotFound(error)) throw error; + result = await waitForSession( + runtime.bridge.spawnOrAttach({ + workspaceCwd: request.workspaceCwd, + sessionScope: 'thread', + sessionId: request.sessionId, + sourceType: 'managed-gateway', + sourceId: request.sessionId, + }), + false, + ); + } + } else { + result = await waitForSession( + runtime.bridge.spawnOrAttach({ + workspaceCwd: request.workspaceCwd, + sessionScope: 'thread', + sessionId: request.sessionId, + sourceType: 'managed-gateway', + sourceId: request.sessionId, + }), + false, + ); + } + return { + request: structuredClone(request), + runtime, + runtimeClientId: result.runtimeClientId, + controller, + }; + } + + private forgetBinding(sessionId: string, binding: LocalBinding): void { + if (this.bindings.get(sessionId) === binding) { + this.bindings.delete(sessionId); + } + binding.controller.abort(new Error('Managed Runtime binding changed.')); + } +} + +interface RemoteProviderEntry { + request: ManagedRuntimePrepareRequest; + readonly controller: AbortController; + readonly ready: Promise; + releasing?: boolean; + release?: Promise; + v2?: ManagedToolV2Client; + terminal?: boolean; +} + +class RemoteResponseError extends Error { + constructor(readonly status: number) { + super(`Managed Runtime returned HTTP ${status}.`); + this.name = 'RemoteResponseError'; + } +} + +export interface RemoteManagedRuntimeProviderOptions { + readonly baseUrl: string; + readonly lease?: { readonly leaseId: string; readonly epoch: number }; + readonly token: string; + readonly fetch?: typeof fetch; + readonly prepareRetryWindowMs?: number; + readonly prepareRetryDelayMs?: number; + readonly prepareRetryMaxDelayMs?: number; +} + +function resolveRemoteBaseUrl(raw: string): URL { + let url: URL; + try { + url = new URL(raw); + } catch { + throw new Error('Managed Runtime URL is invalid.'); + } + if ( + (url.protocol !== 'http:' && url.protocol !== 'https:') || + url.username || + url.password || + url.search || + url.hash || + (url.pathname !== '/' && url.pathname !== '') + ) { + throw new Error('Managed Runtime URL must be an HTTP(S) origin.'); + } + if (url.protocol === 'http:' && !isLoopbackBind(url.hostname)) { + throw new Error( + 'Managed Runtime URL must use HTTPS outside the loopback interface.', + ); + } + url.pathname = '/'; + return url; +} + +function delay(ms: number, signal: AbortSignal): Promise { + if (signal.aborted) { + return Promise.reject(abortError(signal, 'Managed Runtime retry aborted.')); + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort); + resolve(); + }, ms); + timer.unref(); + const onAbort = () => { + clearTimeout(timer); + signal.removeEventListener('abort', onAbort); + reject(abortError(signal, 'Managed Runtime retry aborted.')); + }; + signal.addEventListener('abort', onAbort, { once: true }); + }); +} + +function validRetryOption( + value: number | undefined, + fallback: number, + minimum: number, +): number { + return value !== undefined && Number.isFinite(value) && value >= minimum + ? value + : fallback; +} + +async function readBoundedResponseText( + response: Response, + maxResponseBytes: number, +): Promise { + if (!response.body) return ''; + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let totalBytes = 0; + try { + while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + totalBytes += chunk.value.byteLength; + if (totalBytes > maxResponseBytes) { + await reader.cancel().catch(() => undefined); + throw new Error('Managed Runtime response exceeded its size limit.'); + } + chunks.push(chunk.value); + } + } finally { + reader.releaseLock(); + } + return Buffer.concat(chunks, totalBytes).toString('utf8'); +} + +export class RemoteManagedRuntimeProvider implements ManagedRuntimeProvider { + private readonly baseUrl: URL; + private readonly token: string; + private readonly fetchImpl: typeof fetch; + private readonly retryWindowMs: number; + private readonly retryDelayMs: number; + private readonly retryMaxDelayMs: number; + private readonly entries = new Map(); + private readonly closedSessions = new Map< + string, + ManagedRuntimePrepareRequest + >(); + private readonly lifetime = new AbortController(); + private readonly lease?: { readonly leaseId: string; readonly epoch: number }; + + constructor(options: RemoteManagedRuntimeProviderOptions) { + this.lease = options.lease && { ...options.lease }; + this.baseUrl = resolveRemoteBaseUrl(options.baseUrl); + this.token = options.token.trim(); + if (!this.token) throw new Error('Managed Runtime token is required.'); + this.fetchImpl = options.fetch ?? fetch; + this.retryWindowMs = validRetryOption( + options.prepareRetryWindowMs, + DEFAULT_PREPARE_RETRY_WINDOW_MS, + 0, + ); + this.retryDelayMs = validRetryOption( + options.prepareRetryDelayMs, + DEFAULT_PREPARE_RETRY_DELAY_MS, + 0, + ); + this.retryMaxDelayMs = validRetryOption( + options.prepareRetryMaxDelayMs, + DEFAULT_PREPARE_RETRY_MAX_DELAY_MS, + 1, + ); + } + + prepare( + request: ManagedRuntimePrepareRequest, + retryWindowMs = this.retryWindowMs, + ): ManagedRuntimeHandle { + request = structuredClone(request); + if (this.lifetime.signal.aborted) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_disposed', + 'Managed Runtime provider is disposed.', + false, + ); + } + const closed = this.closedSessions.get(request.sessionId); + if (closed) { + throw new ManagedRuntimeProviderError( + sameManagedRuntimeIdentity(closed, request) + ? 'managed_runtime_unavailable' + : 'managed_runtime_identity_conflict', + 'Managed Runtime Session is permanently closed.', + false, + ); + } + let entry = this.entries.get(request.sessionId); + if (entry && !sameManagedRuntimeIdentity(entry.request, request)) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Managed Runtime Session identity changed.', + false, + ); + } + if (entry?.releasing) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_unavailable', + 'Managed Runtime Session is being released.', + false, + ); + } + if (entry) { + entry.request = structuredClone(request); + } + if (!entry) { + const controller = new AbortController(); + // Release must await preparation before sending close. Aborting only its + // HTTP response would leave a late-created Session without an owner. + const signal = this.lifetime.signal; + const ready = this.prepareRemote(request, signal, retryWindowMs); + entry = { request: structuredClone(request), controller, ready }; + this.entries.set(request.sessionId, entry); + void ready.catch(() => { + if ( + this.entries.get(request.sessionId) === entry && + !entry?.v2 && + !entry?.releasing + ) { + this.entries.delete(request.sessionId); + } + }); + } + const active = entry; + return { + ready: active.ready, + finish: () => {}, + getManifest: async (signal) => { + const operationSignal = AbortSignal.any([ + signal, + active.controller.signal, + this.lifetime.signal, + ]); + await waitForValue(active.ready, operationSignal); + const response = + await this.postIdempotentJson( + 'manifest', + active.request, + operationSignal, + MAX_REMOTE_MANIFEST_BYTES, + ); + if ( + response.protocolVersion !== MANAGED_RUNTIME_PROTOCOL_VERSION || + !response.manifest + ) { + throw new Error( + 'Managed Runtime returned an invalid manifest response.', + ); + } + return response.manifest; + }, + execute: async (toolRequest, signal) => { + const operationSignal = AbortSignal.any([ + signal, + active.controller.signal, + this.lifetime.signal, + ]); + await waitForValue(active.ready, operationSignal); + try { + const response = await this.postJson( + 'execute', + { ...active.request, toolRequest }, + operationSignal, + MAX_REMOTE_TOOL_RESULT_BYTES, + ); + if ( + response.protocolVersion !== MANAGED_RUNTIME_PROTOCOL_VERSION || + !response.result + ) { + throw new Error( + 'Managed Runtime returned an invalid Tool execution response.', + ); + } + return response.result; + } catch (error) { + if (signal.aborted) { + void this.cancel(request.sessionId, toolRequest.executionId).catch( + () => undefined, + ); + } + throw error; + } + }, + }; + } + + async getToolV2Client( + request: ManagedRuntimePrepareRequest, + ): Promise { + if (!this.lease) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_unavailable', + 'Managed Tool v2 requires an owned Runtime lease.', + false, + ); + } + const current = this.entries.get(request.sessionId); + if (!current?.releasing) this.prepare(request); + const entry = this.entries.get(request.sessionId); + if (!entry || !sameManagedRuntimeIdentity(entry.request, request)) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Managed Runtime Session binding changed.', + false, + ); + } + entry.v2 ??= this.createToolV2Client(entry); + await entry.ready; + this.lifetime.signal.throwIfAborted(); + return entry.v2; + } + + private createToolV2Client(entry: RemoteProviderEntry): ManagedToolV2Client { + const call = async ( + operation: string, + params: Record = {}, + allowDraining = false, + ): Promise => { + // Leaf modules, not the core barrel: a dynamic import of the barrel + // keeps every core export, and with it the encoding tables and other + // runtime the serve fast path must not load + // (scripts/check-serve-fast-path-bundle.js). + const [{ managedToolDigest }, { MANAGED_TOOL_FILE_HISTORY_MAX_BYTES }] = + await Promise.all([ + import('@qwen-code/qwen-code-core/tools/managed-tool-protocol.js'), + import( + '@qwen-code/qwen-code-core/tools/managed-tool-file-history-protocol.js' + ), + ]); + this.lifetime.signal.throwIfAborted(); + if (this.entries.get(entry.request.sessionId) !== entry) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Managed Runtime Session binding changed.', + false, + ); + } + if (!allowDraining) entry.controller.signal.throwIfAborted(); + const identity = params['identity'] ?? params['reference']; + if ( + identity !== undefined && + (identity as { sessionId?: unknown }).sessionId !== + entry.request.sessionId + ) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Managed Runtime invocation belongs to another Session.', + false, + ); + } + const body = { ...entry.request, protocolVersion: 2, ...params }; + managedToolDigest( + body, + operation === 'bind-history' + ? MANAGED_TOOL_FILE_HISTORY_MAX_BYTES + : 1024 * 1024, + ); + const response = await this.postJson<{ + protocolVersion?: unknown; + result?: T; + }>( + operation, + body, + AbortSignal.any([ + this.lifetime.signal, + // Release closes admission, but an admitted execute must retain its + // physical result until the worker has drained the operation. + ...(allowDraining || operation === 'execute' + ? [] + : [entry.controller.signal]), + AbortSignal.timeout(TOOL_REQUEST_TIMEOUT_MS), + ]), + operation === 'manifest' + ? MAX_REMOTE_MANIFEST_BYTES + : isManagedMediaOperation(operation) + ? MAX_MANAGED_MEDIA_RESPONSE_BYTES + : MAX_REMOTE_TOOL_RESULT_BYTES, + 2, + ); + const empty = operation === 'begin-turn' || operation === 'confirm'; + if ( + response.protocolVersion !== 2 || + (empty + ? response.result !== null + : !response.result || + typeof response.result !== 'object' || + Array.isArray(response.result)) + ) { + throw new Error('Managed Runtime returned an invalid v2 response.'); + } + const validResult = (value: unknown) => + value !== null && + typeof value === 'object' && + ['not_started', 'success', 'error', 'cancelled'].includes( + (value as { executionStatus?: string }).executionStatus ?? '', + ); + if (operation === 'execute' && !validResult(response.result)) { + throw new Error('Managed Runtime did not confirm physical execution.'); + } + if (operation === 'status' || operation === 'cancel') { + const status = response.result as { state?: string; result?: unknown }; + if ( + !['prepared', 'executing', 'cancel_requested', 'settled'].includes( + status.state ?? '', + ) || + (status.state === 'settled' && !validResult(status.result)) + ) { + throw new Error( + 'Managed Runtime returned an invalid invocation status.', + ); + } + } + return response.result as T; + }; + return { + fileHistory: { + bind: async (binding) => { + const [ + { + parseManagedToolFileHistoryBinding, + parseManagedToolFileHistoryState, + }, + { ManagedToolProtocolError }, + ] = await Promise.all([ + import( + '@qwen-code/qwen-code-core/tools/managed-tool-file-history-protocol.js' + ), + import('@qwen-code/qwen-code-core/tools/managed-tool-protocol.js'), + ]); + const parsed = parseManagedToolFileHistoryBinding(binding); + const state = parseManagedToolFileHistoryState( + await call('bind-history', { binding: parsed }), + ); + if (state.ownerSessionId !== parsed.ownerSessionId) + throw new ManagedToolProtocolError( + 'Managed file history owner changed.', + ); + return state; + }, + checkpoint: async (promptId) => { + const { + parseManagedToolFileHistoryPromptId, + parseManagedToolFileHistoryState, + } = await import( + '@qwen-code/qwen-code-core/tools/managed-tool-file-history-protocol.js' + ); + return parseManagedToolFileHistoryState( + await call('checkpoint', { + promptId: parseManagedToolFileHistoryPromptId(promptId), + }), + ); + }, + snapshot: async () => { + const { parseManagedToolFileHistoryState } = await import( + '@qwen-code/qwen-code-core/tools/managed-tool-file-history-protocol.js' + ); + return parseManagedToolFileHistoryState( + await call('history', {}, true), + ); + }, + }, + manifest: () => call('manifest'), + beginTurn: async (identity) => { + await call('begin-turn', { identity }); + }, + prepare: (identity, toolName, input, modification, mediaContext) => + call('prepare', { + identity, + toolName, + input, + ...(modification === undefined ? {} : { modification }), + ...(mediaContext === undefined ? {} : { mediaContext }), + }), + confirmation: (reference) => call('confirmation', { reference }), + confirm: async (reference, outcome, payload, phase) => { + await call('confirm', { + reference, + outcome, + ...(payload === undefined ? {} : { payload }), + ...(phase === undefined ? {} : { phase }), + }); + }, + preflight: (reference) => call('preflight', { reference }), + execute: (reference) => call('execute', { reference }), + status: (reference, afterSeq) => + call( + 'status', + { + reference, + ...(afterSeq === undefined ? {} : { afterSeq }), + }, + true, + ), + cancel: (reference) => call('cancel', { reference }, true), + }; + } + + async cancel( + sessionId: string, + executionId: string, + expected?: ManagedRuntimePrepareRequest, + ): Promise { + const entry = this.entries.get(sessionId); + if (!entry || this.lifetime.signal.aborted) return false; + if (expected && !sameManagedRuntimeIdentity(entry.request, expected)) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Managed Runtime Session identity changed.', + false, + ); + } + const response = await this.postJson( + 'cancel', + { ...entry.request, executionId }, + AbortSignal.timeout(CONTROL_REQUEST_TIMEOUT_MS), + MAX_REMOTE_CONTROL_RESPONSE_BYTES, + ); + if (response.protocolVersion !== MANAGED_RUNTIME_PROTOCOL_VERSION) { + throw new Error('Managed Runtime returned an invalid cancel response.'); + } + return response.cancelled === true; + } + + async release( + sessionId: string, + expected?: ManagedRuntimePrepareRequest, + options?: ManagedRuntimeReleaseOptions, + ): Promise { + let entry = this.entries.get(sessionId); + const closed = this.closedSessions.get(sessionId); + const terminal = options?.terminal === true || entry?.v2 !== undefined; + if (terminal && !this.lease) { + throw new Error( + 'Terminal Session release requires an owned Runtime lease.', + ); + } + if ( + expected && + (expected.sessionId !== sessionId || + (closed && !sameManagedRuntimeIdentity(closed, expected))) + ) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Managed Runtime Session identity changed.', + false, + ); + } + this.lifetime.signal.throwIfAborted(); + if (closed) return true; + if (!entry) { + if (!expected) return false; + entry = { + request: structuredClone(expected), + controller: new AbortController(), + ready: Promise.resolve(), + }; + this.entries.set(sessionId, entry); + } + if (expected && !sameManagedRuntimeIdentity(entry.request, expected)) { + throw new ManagedRuntimeProviderError( + 'managed_runtime_identity_conflict', + 'Managed Runtime Session identity changed.', + false, + ); + } + entry.terminal ||= terminal; + entry.releasing = true; + entry.controller.abort(new Error('Managed Runtime Session released.')); + if (entry.release) return entry.release; + const retained = entry; + const release = (async () => { + await retained.ready.catch(() => {}); + this.lifetime.signal.throwIfAborted(); + let sentTerminal: boolean; + do { + sentTerminal = retained.terminal === true; + const protocolVersion = sentTerminal ? 2 : 1; + const response = await this.postIdempotentJson<{ + protocolVersion: unknown; + released: unknown; + }>( + 'release', + { ...retained.request, protocolVersion }, + AbortSignal.any([ + this.lifetime.signal, + AbortSignal.timeout(CONTROL_REQUEST_TIMEOUT_MS), + ]), + MAX_REMOTE_CONTROL_RESPONSE_BYTES, + protocolVersion, + ); + if ( + response.protocolVersion !== protocolVersion || + response.released !== true + ) { + throw new Error('Managed Runtime did not confirm Session release.'); + } + } while (retained.terminal && !sentTerminal); + if (sentTerminal) { + this.closedSessions.set(sessionId, retained.request); + } + if (this.entries.get(sessionId) === retained) + this.entries.delete(sessionId); + return true; + })().finally(() => { + retained.release = undefined; + }); + retained.release = release; + return release; + } + + dispose(): void { + if (this.lifetime.signal.aborted) return; + this.lifetime.abort(new Error('Managed Runtime provider disposed.')); + for (const entry of this.entries.values()) { + entry.controller.abort(new Error('Managed Runtime provider disposed.')); + } + this.entries.clear(); + this.closedSessions.clear(); + } + + private async prepareRemote( + request: ManagedRuntimePrepareRequest, + signal: AbortSignal, + retryWindowMs: number, + ): Promise { + const startedAt = Date.now(); + let retryDelayMs = this.retryDelayMs; + const deadline = new AbortController(); + const deadlineTimer = setTimeout( + () => + deadline.abort( + new ManagedRuntimeProviderError( + 'managed_runtime_unavailable', + 'Managed Runtime preparation timed out.', + true, + ), + ), + retryWindowMs, + ); + deadlineTimer.unref(); + const prepareSignal = AbortSignal.any([signal, deadline.signal]); + try { + while (true) { + prepareSignal.throwIfAborted(); + try { + const response = await this.postJson( + 'prepare', + request, + prepareSignal, + MAX_REMOTE_CONTROL_RESPONSE_BYTES, + ); + if ( + response.protocolVersion !== MANAGED_RUNTIME_PROTOCOL_VERSION || + response.ready !== true + ) { + throw new Error( + 'Managed Runtime returned an invalid ready response.', + ); + } + return; + } catch (error) { + if (prepareSignal.aborted) { + throw abortError(prepareSignal, 'Runtime preparation aborted.'); + } + const retryable = + error instanceof TypeError || + (error instanceof RemoteResponseError && + (error.status === 429 || error.status === 503)); + if (!retryable || Date.now() - startedAt >= retryWindowMs) { + throw error; + } + await delay(retryDelayMs, prepareSignal); + retryDelayMs = Math.min( + Math.max(retryDelayMs * 2, 1), + this.retryMaxDelayMs, + ); + } + } + } finally { + clearTimeout(deadlineTimer); + } + } + + private async postJson( + operation: string, + body: unknown, + signal: AbortSignal, + maxResponseBytes: number, + protocolVersion: 1 | 2 = 1, + ): Promise { + const response = await this.fetchImpl( + new URL( + `internal/managed-runtime/v${protocolVersion}/${operation}`, + this.baseUrl, + ), + { + method: 'POST', + headers: { + authorization: `Bearer ${this.token}`, + 'content-type': 'application/json', + ...(this.lease + ? { + [MANAGED_LEASE_ID_HEADER]: this.lease.leaseId, + [MANAGED_LEASE_EPOCH_HEADER]: String(this.lease.epoch), + } + : {}), + }, + body: JSON.stringify(body), + signal, + }, + ); + if (!response.ok) { + await response.body?.cancel().catch(() => undefined); + throw new RemoteResponseError(response.status); + } + const contentLength = Number(response.headers.get('content-length')); + if (Number.isFinite(contentLength) && contentLength > maxResponseBytes) { + await response.body?.cancel().catch(() => undefined); + throw new Error('Managed Runtime response exceeded its size limit.'); + } + const text = await readBoundedResponseText(response, maxResponseBytes); + let parsed: T; + try { + parsed = JSON.parse(text) as T; + } catch { + throw new Error('Managed Runtime returned invalid JSON.'); + } + if (protocolVersion === 2 && isManagedMediaOperation(operation)) { + const mediaBytes = managedToolResponseMediaBytes( + (parsed as { result?: unknown } | null)?.result, + operation, + ); + if (Buffer.byteLength(text) - mediaBytes > MAX_REMOTE_TOOL_RESULT_BYTES) { + throw new Error( + 'Managed Runtime control response exceeded its size limit.', + ); + } + } + return parsed; + } + + private async postIdempotentJson( + operation: string, + body: unknown, + signal: AbortSignal, + maxResponseBytes: number, + protocolVersion: 1 | 2 = 1, + ): Promise { + try { + return await this.postJson( + operation, + body, + signal, + maxResponseBytes, + protocolVersion, + ); + } catch (error) { + const retryable = + !signal.aborted && + (error instanceof TypeError || + (error instanceof RemoteResponseError && error.status === 503)); + if (!retryable) throw error; + return this.postJson( + operation, + body, + signal, + maxResponseBytes, + protocolVersion, + ); + } + } +} + +export type { + BridgeManagedRuntimeToolExecuteRequest, + BridgeManagedRuntimeToolExecuteResult, + BridgeManagedRuntimeToolManifest, +}; diff --git a/packages/cli/src/serve/managed-runtime-tool-executor.ts b/packages/cli/src/serve/managed-runtime-tool-executor.ts new file mode 100644 index 00000000000..bf9385892c5 --- /dev/null +++ b/packages/cli/src/serve/managed-runtime-tool-executor.ts @@ -0,0 +1,329 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Config } from '@qwen-code/qwen-code-core/config/config.js'; +import { ApprovalMode } from '@qwen-code/qwen-code-core/config/approval-mode.js'; +import { ReadFileTool } from '@qwen-code/qwen-code-core/tools/read-file.js'; +import { WriteFileTool } from '@qwen-code/qwen-code-core/tools/write-file.js'; +import { EditTool } from '@qwen-code/qwen-code-core/tools/edit.js'; +import { ShellTool } from '@qwen-code/qwen-code-core/tools/shell.js'; +import type { + AnyDeclarativeTool, + ToolResult, +} from '@qwen-code/qwen-code-core/tools/tools.js'; +import { MANAGED_RUNTIME_TOOL_RESULT_BODY_LIMIT_BYTES } from './managed-runtime-attestation-contract.js'; + +export interface ManagedToolReference { + readonly sessionId: string; + readonly promptId: string; + readonly callId: string; + readonly argsDigest: string; +} + +export type ManagedToolExecutionState = + | 'prepared' + | 'executing' + | 'cancel_requested' + | 'settled'; + +export interface ManagedToolResultPayload { + readonly executionStatus: 'not_started' | 'success' | 'error' | 'cancelled'; + readonly responseParts: unknown[]; + readonly error?: { readonly message: string; readonly type?: string }; +} + +export interface ManagedToolInvocationView { + readonly state: ManagedToolExecutionState; + readonly lastSequence: number; + readonly result?: ManagedToolResultPayload; +} + +/** The reference identifies a different call than the recorded invocation. */ +export class ManagedToolConflictError extends Error { + readonly code = 'managed_runtime_identity_conflict'; +} + +export class ManagedToolInvalidError extends Error {} + +interface JournalEntry { + readonly reference: ManagedToolReference; + readonly toolName: string; + readonly input: Record; + readonly inputJson: string; + state: ManagedToolExecutionState; + lastSequence: number; + result?: ManagedToolResultPayload; + readonly controller: AbortController; + promise?: Promise; +} + +/** + * Executes the admitted ordinary tools for one Managed Runtime worker and + * journals every invocation so `status` and `cancel` can answer by the + * original reference. The journal is in-memory by construction: the worker + * process is the Runtime generation, so a restart is a new generation, never + * a continuation of this state. + */ +export class ManagedToolExecutor { + private readonly entries = new Map(); + private readonly tools = new Map(); + + constructor(config: Config) { + const admitted: AnyDeclarativeTool[] = [ + new ReadFileTool(config), + new WriteFileTool(config), + new EditTool(config), + new ShellTool(config), + ]; + for (const tool of admitted) { + this.tools.set(tool.name, tool); + } + } + + static forWorkspace(workspaceCwd: string, runtimeInstanceId: string) { + return new ManagedToolExecutor( + new Config({ + sessionId: runtimeInstanceId, + targetDir: workspaceCwd, + cwd: workspaceCwd, + model: 'managed-runtime-worker', + debugMode: false, + usageStatisticsEnabled: false, + approvalMode: ApprovalMode.YOLO, + fileCheckpointingEnabled: false, + // The worker has no conversation history to justify cached read elision. + fileReadCacheDisabled: true, + }), + ); + } + + hasTool(toolName: string): boolean { + return this.tools.has(toolName); + } + + async execute( + reference: ManagedToolReference, + toolName: string, + input: Record, + ): Promise { + let inputJson: string; + try { + inputJson = JSON.stringify(input); + } catch { + throw new ManagedToolInvalidError( + 'Managed Runtime tool request is invalid.', + ); + } + const existing = this.entries.get(reference.callId); + if (existing) { + if (!sameInvocation(existing, reference, toolName, inputJson)) { + throw new ManagedToolConflictError( + 'Managed Runtime invocation identity conflicts.', + ); + } + await existing.promise; + return existing.result!; + } + const tool = this.tools.get(toolName); + if (!tool) { + throw new ManagedToolConflictError( + `Managed Runtime does not admit tool ${toolName}.`, + ); + } + if (toolName === ShellTool.Name) { + let isBackground = false; + try { + const params = structuredClone(input); + // Admission must see the same normalized parameters as build(). + isBackground = + tool.validateToolParams(params) === null && + params['is_background'] === true; + } catch { + // Let run() journal parameter failures through its normal error path. + } + if (isBackground) { + throw new ManagedToolConflictError( + 'Managed Runtime does not admit background shell execution.', + ); + } + } + const entry: JournalEntry = { + reference, + toolName, + input, + inputJson, + state: 'prepared', + lastSequence: 0, + controller: new AbortController(), + }; + this.entries.set(reference.callId, entry); + entry.promise = this.run(entry, tool); + await entry.promise; + return entry.result!; + } + + /** Read-only lookup; never creates or advances an invocation. */ + status(reference: ManagedToolReference): ManagedToolInvocationView | null { + const entry = this.entries.get(reference.callId); + if (!entry || !sameReference(entry.reference, reference)) { + return null; + } + return view(entry); + } + + cancel(reference: ManagedToolReference): ManagedToolInvocationView | null { + const entry = this.entries.get(reference.callId); + if (!entry || !sameReference(entry.reference, reference)) { + return null; + } + if (entry.state === 'prepared') { + // Never started; settle as cancelled without touching the tool. + entry.result = { + executionStatus: 'cancelled', + responseParts: [], + }; + entry.state = 'settled'; + entry.lastSequence += 1; + return view(entry); + } + if (entry.state === 'executing') { + entry.state = 'cancel_requested'; + entry.lastSequence += 1; + entry.controller.abort(); + } + return view(entry); + } + + async close(): Promise { + for (const entry of this.entries.values()) { + if (entry.state === 'executing' || entry.state === 'cancel_requested') { + entry.controller.abort(); + } + } + } + + private static isCancelRequested(entry: JournalEntry): boolean { + // Read across a method boundary: cancel() can move the entry to + // cancel_requested while this invocation is parked in the tool. + return entry.state === 'cancel_requested'; + } + + private async run( + entry: JournalEntry, + tool: AnyDeclarativeTool, + ): Promise { + entry.state = 'executing'; + entry.lastSequence += 1; + let payload: ManagedToolResultPayload; + try { + const invocation = tool.build(structuredClone(entry.input)); + const result: ToolResult = await invocation.execute( + entry.controller.signal, + ); + payload = toPayload(result, ManagedToolExecutor.isCancelRequested(entry)); + } catch (error) { + payload = { + executionStatus: ManagedToolExecutor.isCancelRequested(entry) + ? 'cancelled' + : 'error', + responseParts: [], + error: { + message: error instanceof Error ? error.message : String(error), + }, + }; + } + entry.result = payload; + entry.state = 'settled'; + entry.lastSequence += 1; + // Status is the largest envelope because it also carries the sequence. + if ( + Buffer.byteLength( + JSON.stringify({ protocolVersion: 2, ...view(entry) }), + ) > MANAGED_RUNTIME_TOOL_RESULT_BODY_LIMIT_BYTES + ) { + entry.result = { + executionStatus: + payload.executionStatus === 'cancelled' ? 'cancelled' : 'error', + responseParts: [], + error: { message: 'Managed Runtime tool result exceeds 1 MiB.' }, + }; + } + } +} + +function view(entry: JournalEntry): ManagedToolInvocationView { + return { + state: entry.state, + lastSequence: entry.lastSequence, + ...(entry.state === 'settled' ? { result: entry.result } : {}), + }; +} + +function sameReference( + left: ManagedToolReference, + right: ManagedToolReference, +): boolean { + return ( + left.sessionId === right.sessionId && + left.promptId === right.promptId && + left.callId === right.callId && + left.argsDigest === right.argsDigest + ); +} + +function sameInvocation( + entry: JournalEntry, + reference: ManagedToolReference, + toolName: string, + inputJson: string, +): boolean { + return ( + sameReference(entry.reference, reference) && + entry.toolName === toolName && + entry.inputJson === inputJson + ); +} + +function toPayload( + result: ToolResult, + cancelRequested: boolean, +): ManagedToolResultPayload { + const content = result.llmContent; + const responseParts = + typeof content === 'string' + ? [{ type: 'text', text: content }] + : Array.isArray(content) + ? content + : []; + const toolError = result.error; + // A cancel the Runtime honored ends the invocation, whether the tool + // surfaces the abort as an error or as a polite early result. + if (cancelRequested) { + return { + executionStatus: 'cancelled', + responseParts, + ...(toolError + ? { + error: { + message: toolError.message, + ...(toolError.type ? { type: toolError.type } : {}), + }, + } + : {}), + }; + } + if (toolError) { + return { + executionStatus: 'error', + responseParts, + error: { + message: toolError.message, + ...(toolError.type ? { type: toolError.type } : {}), + }, + }; + } + return { executionStatus: 'success', responseParts }; +} diff --git a/packages/cli/src/serve/managed-runtime-tool-routes.ts b/packages/cli/src/serve/managed-runtime-tool-routes.ts new file mode 100644 index 00000000000..2813e764c26 --- /dev/null +++ b/packages/cli/src/serve/managed-runtime-tool-routes.ts @@ -0,0 +1,237 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import express from 'express'; +import type { Application } from 'express'; +import { + authorizeManagedRuntime, + handleManagedRuntimeJsonError, + managedRuntimeNoStore, + OWNED_MANAGED_RUNTIME_ROUTES, + type ManagedRuntimeAttestationIdentity, +} from './managed-runtime-attestation-contract.js'; +import { + ManagedToolConflictError, + ManagedToolInvalidError, + type ManagedToolExecutor, + type ManagedToolReference, +} from './managed-runtime-tool-executor.js'; + +const REFERENCE_KEYS = Object.freeze([ + 'argsDigest', + 'callId', + 'promptId', + 'sessionId', +] as const); + +const INVALID_MESSAGE = 'Managed Runtime tool request is invalid.'; + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} + +function parseReference(value: unknown): ManagedToolReference | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + const keys = Object.keys(value).sort(); + if ( + keys.length !== REFERENCE_KEYS.length || + !keys.every((key, index) => key === REFERENCE_KEYS[index]) + ) { + return null; + } + const reference = value as Record; + if ( + !isNonEmptyString(reference['sessionId']) || + !isNonEmptyString(reference['promptId']) || + !isNonEmptyString(reference['callId']) || + !isNonEmptyString(reference['argsDigest']) + ) { + return null; + } + return reference as unknown as ManagedToolReference; +} + +function parseClosedBody( + value: unknown, + requiredKeys: readonly string[], + optionalKeys: readonly string[] = [], +): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + const body = value as Record; + if (body['protocolVersion'] !== 2) { + return null; + } + const allowed = new Set([...requiredKeys, ...optionalKeys]); + if (Object.keys(body).some((key) => !allowed.has(key))) { + return null; + } + if (requiredKeys.some((key) => !(key in body))) { + return null; + } + return body; +} + +function invalid(res: express.Response): void { + res.status(400).json({ + code: 'managed_runtime_attestation_invalid', + error: INVALID_MESSAGE, + }); +} + +/** Mounts the v2 execute/status/cancel routes with the shared discipline. */ +export function registerManagedRuntimeToolRoutes( + app: Application, + identity: ManagedRuntimeAttestationIdentity, + executor: ManagedToolExecutor, +): void { + const routes = new Map( + OWNED_MANAGED_RUNTIME_ROUTES.filter((route) => route.key !== 'attest').map( + (route) => [route.key, route], + ), + ); + + app.post( + routes.get('execute')!.path, + managedRuntimeNoStore, + authorizeManagedRuntime(identity), + express.json({ + inflate: false, + limit: routes.get('execute')!.requestBodyLimitBytes, + strict: true, + type: 'application/json', + }), + async (req: express.Request, res: express.Response) => { + const body = parseClosedBody(req.body, [ + 'protocolVersion', + 'reference', + 'toolName', + 'input', + ]); + const reference = body && parseReference(body['reference']); + const toolName = body?.['toolName']; + const input = body?.['input']; + if ( + !body || + !reference || + !isNonEmptyString(toolName) || + !input || + typeof input !== 'object' || + Array.isArray(input) + ) { + invalid(res); + return; + } + if (!executor.hasTool(toolName)) { + res.status(409).json({ + code: 'managed_runtime_identity_conflict', + error: 'Managed Runtime does not admit this tool.', + }); + return; + } + try { + const result = await executor.execute( + reference, + toolName, + input as Record, + ); + res.status(200).json({ protocolVersion: 2, state: 'settled', result }); + } catch (error) { + if (error instanceof ManagedToolInvalidError) { + invalid(res); + return; + } + if (error instanceof ManagedToolConflictError) { + res.status(409).json({ + code: 'managed_runtime_identity_conflict', + error: error.message, + }); + return; + } + throw error; + } + }, + handleManagedRuntimeJsonError, + ); + + app.post( + routes.get('status')!.path, + managedRuntimeNoStore, + authorizeManagedRuntime(identity), + express.json({ + inflate: false, + limit: routes.get('status')!.requestBodyLimitBytes, + strict: true, + type: 'application/json', + }), + (req: express.Request, res: express.Response) => { + const body = parseClosedBody( + req.body, + ['protocolVersion', 'reference'], + ['afterSequence'], + ); + const reference = body && parseReference(body['reference']); + const afterSequence = body?.['afterSequence']; + if ( + !body || + !reference || + (afterSequence !== undefined && + (typeof afterSequence !== 'number' || + !Number.isSafeInteger(afterSequence) || + afterSequence < 0)) + ) { + invalid(res); + return; + } + const view = executor.status(reference); + if (!view) { + res.status(200).json({ protocolVersion: 2, state: 'unknown' }); + return; + } + res.status(200).json({ + protocolVersion: 2, + state: view.state, + ...(view.state === 'settled' ? { result: view.result } : {}), + lastSequence: view.lastSequence, + }); + }, + handleManagedRuntimeJsonError, + ); + + app.post( + routes.get('cancel')!.path, + managedRuntimeNoStore, + authorizeManagedRuntime(identity), + express.json({ + inflate: false, + limit: routes.get('cancel')!.requestBodyLimitBytes, + strict: true, + type: 'application/json', + }), + (req: express.Request, res: express.Response) => { + const body = parseClosedBody(req.body, ['protocolVersion', 'reference']); + const reference = body && parseReference(body['reference']); + if (!body || !reference) { + invalid(res); + return; + } + const view = executor.cancel(reference); + if (!view) { + res.status(200).json({ protocolVersion: 2, state: 'unknown' }); + return; + } + res.status(200).json({ + protocolVersion: 2, + state: view.state, + ...(view.state === 'settled' ? { result: view.result } : {}), + }); + }, + handleManagedRuntimeJsonError, + ); +} diff --git a/packages/cli/src/serve/managed-runtime-tool-worker.test.ts b/packages/cli/src/serve/managed-runtime-tool-worker.test.ts new file mode 100644 index 00000000000..e1732172f43 --- /dev/null +++ b/packages/cli/src/serve/managed-runtime-tool-worker.test.ts @@ -0,0 +1,725 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ShellTool } from '@qwen-code/qwen-code-core/tools/shell.js'; +import { + startManagedRuntimeAttestationWorker, + type ManagedRuntimeAttestationWorkerHandle, + type ManagedRuntimeWorkerBoot, +} from './managed-runtime-attestation-worker.js'; + +const toolFixtures = JSON.parse( + fs.readFileSync( + path.join( + path.dirname(fileURLToPath(import.meta.url)), + 'contracts', + 'managed-runtime-tool-v2.fixtures.json', + ), + 'utf8', + ), +) as { + routes: Array<{ key: string; method: string; path: string }>; + suites: ToolSuite[]; +}; + +const BOOT: ManagedRuntimeWorkerBoot = { + type: 'boot', + version: 1, + capabilityDigest: `sha256:${'a'.repeat(64)}`, + epoch: 4, + isolationClass: 'workspace', + leaseId: 'lease-01', + provisionRequestId: 'provision-01', + runtimeIncarnation: 'incarnation-01', + runtimeInstanceId: 'runtime-01', + tenantId: 'tenant-a', + token: 'fixture-token', + workspaceCwd: '/tmp', + workspaceGeneration: '7', + workspaceId: 'workspace-a', +}; + +const HEADERS = { + authorization: 'Bearer fixture-token', + 'cache-control': 'no-store', + 'content-type': 'application/json', + 'x-qwen-managed-lease-id': 'lease-01', + 'x-qwen-managed-lease-epoch': '4', +}; + +interface ToolSuite { + readonly route: string; + readonly canonicalRequest: { + readonly headers: Record; + readonly body: Record; + }; + readonly cases: ReadonlyArray<{ + readonly id: string; + readonly request?: { + readonly omitHeader?: string; + readonly replaceHeader?: { + readonly name: string; + readonly value: string; + }; + readonly replaceBody?: { readonly name: string; readonly value: unknown }; + readonly paddingBytes?: number; + readonly rawBody?: string; + readonly pathSuffix?: string; + readonly pathOverride?: string; + readonly method?: string; + }; + readonly expected: { + readonly status: number; + readonly classification: string; + readonly code?: string; + readonly body?: Record; + }; + }>; +} + +const suites = toolFixtures.suites as readonly ToolSuite[]; + +describe('Managed Runtime tool worker', () => { + let workspace: string; + let worker: ManagedRuntimeAttestationWorkerHandle | undefined; + + beforeEach(async () => { + workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-managed-tool-')); + fs.writeFileSync(path.join(workspace, 'README.md'), 'file contents'); + }); + + afterEach(async () => { + await worker?.close(); + worker = undefined; + fs.rmSync(workspace, { recursive: true, force: true }); + }); + + async function start(): Promise { + worker = await startManagedRuntimeAttestationWorker({ + ...BOOT, + workspaceCwd: workspace, + }); + return worker.ready.url; + } + + function executeBody(input: Record) { + return { + protocolVersion: 2, + reference: { + sessionId: 'runtime-session-01', + promptId: 'prompt-01', + callId: 'call-01', + argsDigest: 'digest-01', + }, + toolName: 'read_file', + input, + }; + } + + describe('replays the shared negative fixtures against the real handlers', () => { + for (const suite of suites) { + for (const fixture of suite.cases) { + if (fixture.expected.status === 200) continue; + it(`${suite.route}/${fixture.id}`, async () => { + const origin = await start(); + const route = toolFixtures.routes.find( + (entry) => entry.key === suite.route, + )!; + const headers = { ...suite.canonicalRequest.headers }; + const body: Record = { + ...suite.canonicalRequest.body, + }; + if (fixture.request?.omitHeader) { + delete headers[fixture.request.omitHeader]; + } + if (fixture.request?.replaceHeader) { + headers[fixture.request.replaceHeader.name] = + fixture.request.replaceHeader.value; + } + if (fixture.request?.replaceBody) { + body[fixture.request.replaceBody.name] = + fixture.request.replaceBody.value; + } + if (fixture.request?.paddingBytes) { + body['padding'] = 'x'.repeat(fixture.request.paddingBytes); + } + const method = fixture.request?.method ?? route.method; + const response = await fetch( + `${origin}${fixture.request?.pathOverride ?? route.path}${fixture.request?.pathSuffix ?? ''}`, + { + method, + headers, + body: + method === 'GET' + ? undefined + : (fixture.request?.rawBody ?? JSON.stringify(body)), + }, + ); + + expect(response.status).toBe(fixture.expected.status); + if (fixture.expected.code) { + expect(await response.json()).toMatchObject({ + code: fixture.expected.code, + }); + } + expect(response.headers.get('cache-control')).toBe('no-store'); + }); + } + } + }); + + it('executes read_file in the bound workspace and reports status', async () => { + const origin = await start(); + + const executeResponse = await fetch( + `${origin}/internal/managed-runtime/v2/execute`, + { + method: 'POST', + headers: HEADERS, + body: JSON.stringify( + executeBody({ file_path: path.join(workspace, 'README.md') }), + ), + }, + ); + expect(executeResponse.status).toBe(200); + const settled = (await executeResponse.json()) as { + state: string; + result: { executionStatus: string; responseParts: unknown[] }; + }; + expect(settled.state).toBe('settled'); + expect(settled.result.executionStatus).toBe('success'); + expect(JSON.stringify(settled.result.responseParts)).toContain( + 'file contents', + ); + + const statusResponse = await fetch( + `${origin}/internal/managed-runtime/v2/status`, + { + method: 'POST', + headers: HEADERS, + body: JSON.stringify({ + protocolVersion: 2, + reference: executeBody({}).reference, + afterSequence: 0, + }), + }, + ); + expect(statusResponse.status).toBe(200); + const view = (await statusResponse.json()) as { + state: string; + lastSequence: number; + result?: { executionStatus: string }; + }; + expect(view.state).toBe('settled'); + expect(view.result?.executionStatus).toBe('success'); + expect(view.lastSequence).toBeGreaterThan(0); + }); + + it('returns file contents for separate calls across sessions', async () => { + const origin = await start(); + for (const [index, sessionId] of [ + 'session-a', + 'session-b', + 'session-a', + ].entries()) { + const body = executeBody({ + file_path: path.join(workspace, 'README.md'), + }); + const response = await fetch( + `${origin}/internal/managed-runtime/v2/execute`, + { + method: 'POST', + headers: HEADERS, + body: JSON.stringify({ + ...body, + reference: { + ...body.reference, + sessionId, + callId: `read-${index}`, + }, + }), + }, + ); + expect(response.status).toBe(200); + const settled = await response.json(); + expect(settled).toMatchObject({ + state: 'settled', + result: { executionStatus: 'success' }, + }); + expect(JSON.stringify(settled)).toContain('file contents'); + } + }); + + it('answers unknown for a reference the Runtime never saw', async () => { + const origin = await start(); + const reference = { + sessionId: 'runtime-session-01', + promptId: 'prompt-99', + callId: 'call-99', + argsDigest: 'digest-99', + }; + for (const operation of ['status', 'cancel']) { + const response = await fetch( + `${origin}/internal/managed-runtime/v2/${operation}`, + { + method: 'POST', + headers: HEADERS, + body: JSON.stringify({ protocolVersion: 2, reference }), + }, + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + protocolVersion: 2, + state: 'unknown', + }); + } + }); + + it('accepts a 256 KiB execute body and rejects one byte more', async () => { + const origin = await start(); + const body = JSON.stringify( + executeBody({ file_path: path.join(workspace, 'README.md') }), + ).padEnd(256 * 1024, ' '); + const accepted = await fetch( + `${origin}/internal/managed-runtime/v2/execute`, + { method: 'POST', headers: HEADERS, body }, + ); + expect(accepted.status).toBe(200); + expect(await accepted.json()).toMatchObject({ + result: { executionStatus: 'success' }, + }); + + const rejected = await fetch( + `${origin}/internal/managed-runtime/v2/execute`, + { method: 'POST', headers: HEADERS, body: `${body} ` }, + ); + expect(rejected.status).toBe(413); + expect(await rejected.json()).toEqual({ + code: 'managed_runtime_attestation_too_large', + error: 'Managed Runtime request exceeds its body size limit.', + }); + }); + + it.each(['read_file', 'run_shell_command'])( + 'rejects %s input too deeply nested for identity comparison', + async (toolName) => { + const origin = await start(); + const { reference } = executeBody({}); + const nested = `${'['.repeat(10_000)}0${']'.repeat(10_000)}`; + const body = `{"protocolVersion":2,"reference":${JSON.stringify(reference)},"toolName":${JSON.stringify(toolName)},"input":{"extra":${nested}}}`; + expect(Buffer.byteLength(body)).toBeLessThan(256 * 1024); + + for (let attempt = 0; attempt < 2; attempt++) { + const response = await fetch( + `${origin}/internal/managed-runtime/v2/execute`, + { method: 'POST', headers: HEADERS, body }, + ); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + code: 'managed_runtime_attestation_invalid', + error: 'Managed Runtime tool request is invalid.', + }); + } + for (const operation of ['status', 'cancel']) { + const response = await fetch( + `${origin}/internal/managed-runtime/v2/${operation}`, + { + method: 'POST', + headers: HEADERS, + body: JSON.stringify({ protocolVersion: 2, reference }), + }, + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + protocolVersion: 2, + state: 'unknown', + }); + } + }, + ); + + it('journals a bounded error when JSON encoding exceeds the result limit', async () => { + const origin = await start(); + const filePath = path.join(workspace, 'large.svg'); + fs.writeFileSync( + filePath, + ``, + ); + const body = executeBody({ file_path: filePath }); + let settled: unknown; + for (const operation of ['execute', 'status', 'cancel', 'execute']) { + const response = await fetch( + `${origin}/internal/managed-runtime/v2/${operation}`, + { + method: 'POST', + headers: HEADERS, + body: JSON.stringify( + operation === 'execute' + ? body + : { protocolVersion: 2, reference: body.reference }, + ), + }, + ); + expect(response.status).toBe(200); + const text = await response.text(); + expect(Buffer.byteLength(text)).toBeLessThanOrEqual(1024 * 1024); + const result = JSON.parse(text).result as unknown; + expect(result).toEqual({ + executionStatus: 'error', + responseParts: [], + error: { message: 'Managed Runtime tool result exceeds 1 MiB.' }, + }); + settled ??= result; + expect(result).toEqual(settled); + } + }); + + it('executes concurrent and settled retries only once', async () => { + const origin = await start(); + const body = { + protocolVersion: 2, + reference: { + sessionId: 'runtime-session-01', + promptId: 'prompt-01', + callId: 'call-join', + argsDigest: 'digest-join', + }, + toolName: 'run_shell_command', + input: { command: 'echo invocation >> calls.txt' }, + }; + const [first, second] = await Promise.all([ + fetch(`${origin}/internal/managed-runtime/v2/execute`, { + method: 'POST', + headers: HEADERS, + body: JSON.stringify(body), + }), + fetch(`${origin}/internal/managed-runtime/v2/execute`, { + method: 'POST', + headers: HEADERS, + body: JSON.stringify(body), + }), + ]); + expect(first.status).toBe(200); + expect(second.status).toBe(200); + const settled = await first.json(); + expect(settled).toMatchObject({ + result: { executionStatus: 'success' }, + }); + expect(settled).toEqual(await second.json()); + const replay = await fetch( + `${origin}/internal/managed-runtime/v2/execute`, + { + method: 'POST', + headers: HEADERS, + body: JSON.stringify(body), + }, + ); + expect(replay.status).toBe(200); + expect(await replay.json()).toEqual(settled); + expect( + fs.readFileSync(path.join(workspace, 'calls.txt'), 'utf8').trim(), + ).toBe('invocation'); + }); + + it('replays identical input after the tool normalizes its parameters', async () => { + const origin = await start(); + const body = JSON.stringify( + executeBody({ + file_path: ` ${path.join(workspace, 'README.md')} `, + offset: null, + limit: null, + pages: null, + }), + ); + const first = await fetch(`${origin}/internal/managed-runtime/v2/execute`, { + method: 'POST', + headers: HEADERS, + body, + }); + expect(first.status).toBe(200); + const settled = await first.json(); + expect(settled).toMatchObject({ + result: { executionStatus: 'success' }, + }); + + const replay = await fetch( + `${origin}/internal/managed-runtime/v2/execute`, + { + method: 'POST', + headers: HEADERS, + body, + }, + ); + expect(replay.status).toBe(200); + expect(await replay.json()).toEqual(settled); + }); + + it('rejects a second call with the same callId but a different digest', async () => { + const origin = await start(); + const body = executeBody({ file_path: path.join(workspace, 'README.md') }); + const first = await fetch(`${origin}/internal/managed-runtime/v2/execute`, { + method: 'POST', + headers: HEADERS, + body: JSON.stringify(body), + }); + expect(first.status).toBe(200); + + const conflict = await fetch( + `${origin}/internal/managed-runtime/v2/execute`, + { + method: 'POST', + headers: HEADERS, + body: JSON.stringify({ + ...body, + reference: { ...body.reference, argsDigest: 'digest-other' }, + }), + }, + ); + expect(conflict.status).toBe(409); + expect(await conflict.json()).toMatchObject({ + code: 'managed_runtime_identity_conflict', + error: 'Managed Runtime invocation identity conflicts.', + }); + }); + + it('rejects a tool the Runtime does not admit', async () => { + const origin = await start(); + const response = await fetch( + `${origin}/internal/managed-runtime/v2/execute`, + { + method: 'POST', + headers: HEADERS, + body: JSON.stringify({ + ...executeBody({}), + toolName: 'write_file_but_not_admitted', + }), + }, + ); + expect(response.status).toBe(409); + }); + + it.each([true, 'true', 'TRUE', 'TrUe'])( + 'rejects background shell execution with is_background=%j without recording an invocation', + async (isBackground) => { + const origin = await start(); + const body = { + ...executeBody({ + command: 'echo background > rejected-background.txt', + is_background: isBackground, + }), + toolName: 'run_shell_command', + }; + const response = await fetch( + `${origin}/internal/managed-runtime/v2/execute`, + { + method: 'POST', + headers: HEADERS, + body: JSON.stringify(body), + }, + ); + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ + code: 'managed_runtime_identity_conflict', + error: 'Managed Runtime does not admit background shell execution.', + }); + expect( + fs.existsSync(path.join(workspace, 'rejected-background.txt')), + ).toBe(false); + + for (const operation of ['status', 'cancel']) { + const lookup = await fetch( + `${origin}/internal/managed-runtime/v2/${operation}`, + { + method: 'POST', + headers: HEADERS, + body: JSON.stringify({ + protocolVersion: 2, + reference: body.reference, + }), + }, + ); + expect(lookup.status).toBe(200); + expect(await lookup.json()).toEqual({ + protocolVersion: 2, + state: 'unknown', + }); + } + }, + ); + + it.each([false, undefined, 'false', 'FALSE', 'FaLsE'])( + 'executes foreground shell commands with is_background=%s', + async (isBackground) => { + const origin = await start(); + const response = await fetch( + `${origin}/internal/managed-runtime/v2/execute`, + { + method: 'POST', + headers: HEADERS, + body: JSON.stringify({ + ...executeBody({ + command: 'echo foreground', + is_background: isBackground, + }), + toolName: 'run_shell_command', + }), + }, + ); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + state: 'settled', + result: { executionStatus: 'success' }, + }); + }, + ); + + it.each([1, 'yes', null, {}, [true]])( + 'settles invalid is_background=%j without starting a command', + async (isBackground) => { + const origin = await start(); + const body = { + ...executeBody({ + command: 'echo started > invalid-background.txt', + is_background: isBackground, + }), + toolName: 'run_shell_command', + }; + const response = await fetch( + `${origin}/internal/managed-runtime/v2/execute`, + { + method: 'POST', + headers: HEADERS, + body: JSON.stringify(body), + }, + ); + expect(response.status).toBe(200); + const settled = await response.json(); + expect(settled).toMatchObject({ + state: 'settled', + result: { executionStatus: 'error' }, + }); + expect( + fs.existsSync(path.join(workspace, 'invalid-background.txt')), + ).toBe(false); + + const replay = await fetch( + `${origin}/internal/managed-runtime/v2/execute`, + { + method: 'POST', + headers: HEADERS, + body: JSON.stringify(body), + }, + ); + expect(replay.status).toBe(200); + expect(await replay.json()).toEqual(settled); + }, + ); + + it('journals parameter validation exceptions without executing a command', async () => { + const origin = await start(); + const validation = vi + .spyOn(ShellTool.prototype, 'validateToolParams') + .mockImplementation(() => { + throw new Error('Invalid shell parameters'); + }); + try { + const body = { + ...executeBody({ command: 'echo started > validation-started.txt' }), + toolName: 'run_shell_command', + }; + const response = await fetch( + `${origin}/internal/managed-runtime/v2/execute`, + { method: 'POST', headers: HEADERS, body: JSON.stringify(body) }, + ); + expect(response.status).toBe(200); + const settled = await response.json(); + expect(settled).toMatchObject({ + state: 'settled', + result: { + executionStatus: 'error', + error: { message: 'Invalid shell parameters' }, + }, + }); + const status = await fetch( + `${origin}/internal/managed-runtime/v2/status`, + { + method: 'POST', + headers: HEADERS, + body: JSON.stringify({ + protocolVersion: 2, + reference: body.reference, + }), + }, + ); + expect(status.status).toBe(200); + expect(await status.json()).toMatchObject(settled); + expect( + fs.existsSync(path.join(workspace, 'validation-started.txt')), + ).toBe(false); + } finally { + validation.mockRestore(); + } + }); + + it('cancels an in-flight shell execution', async () => { + const origin = await start(); + const reference = { + sessionId: 'runtime-session-01', + promptId: 'prompt-01', + callId: 'call-cancel', + argsDigest: 'digest-cancel', + }; + const running = fetch(`${origin}/internal/managed-runtime/v2/execute`, { + method: 'POST', + headers: HEADERS, + body: JSON.stringify({ + protocolVersion: 2, + reference, + toolName: 'run_shell_command', + input: { + command: + 'sleep 30 # intentional-sleep: probe for in-flight cancellation', + }, + }), + }); + await vi.waitFor(async () => { + const response = await fetch( + `${origin}/internal/managed-runtime/v2/status`, + { + method: 'POST', + headers: HEADERS, + body: JSON.stringify({ protocolVersion: 2, reference }), + }, + ); + expect(await response.json()).toMatchObject({ state: 'executing' }); + }); + + const cancelResponse = await fetch( + `${origin}/internal/managed-runtime/v2/cancel`, + { + method: 'POST', + headers: HEADERS, + body: JSON.stringify({ protocolVersion: 2, reference }), + }, + ); + expect(cancelResponse.status).toBe(200); + expect(await cancelResponse.json()).toMatchObject({ + state: 'cancel_requested', + }); + + const settled = (await (await running).json()) as { + state: string; + result: { executionStatus: string }; + }; + expect(settled.state).toBe('settled'); + expect(settled.result.executionStatus).toBe('cancelled'); + }, 15_000); +}); diff --git a/packages/cli/src/serve/managed-workspace-binding.test.ts b/packages/cli/src/serve/managed-workspace-binding.test.ts new file mode 100644 index 00000000000..9112f2ce282 --- /dev/null +++ b/packages/cli/src/serve/managed-workspace-binding.test.ts @@ -0,0 +1,321 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +// Ajv exposes draft 2020-12 through this documented entry point. +// eslint-disable-next-line import/no-internal-modules +import { Ajv2020 } from 'ajv/dist/2020.js'; +import { describe, expect, it } from 'vitest'; +import { + CONTEXT_BINDING_DOMAIN_TAG, + InvalidWorkspaceRelativePathError, + computeManagedContextDigest, + encodeManagedContextBinding, + normalizeWorkspaceRelativePath, + type ManagedContextBinding, +} from './managed-workspace-binding.js'; + +interface PathCase { + readonly id: string; + readonly input: string; + readonly expected: + | { readonly cwdRelative: string } + | { readonly error: 'invalid_cwd' }; +} + +interface BindingCase { + readonly id: string; + readonly binding: ManagedContextBinding; + readonly expected: + | { readonly encodedHex: string; readonly contextDigest: string } + | { readonly error: 'invalid_binding' }; +} + +/** Inclusive code point ranges, ascending and disjoint. */ +type CodePointRanges = ReadonlyArray; + +type ProbeCase = { + readonly id: string; + /** `{c}` marks where each probed code point goes. */ + readonly template: string; + readonly accepted: CodePointRanges; +} & ( + | { readonly kind: 'path' } + | { readonly kind: 'binding'; readonly field: keyof ManagedContextBinding } +); + +interface FixtureSuite { + readonly contractVersion: 1; + readonly digest: { + readonly algorithm: string; + readonly prefix: string; + readonly domainTag: string; + readonly fieldOrder: readonly string[]; + }; + readonly paths: readonly PathCase[]; + readonly bindings: readonly BindingCase[]; + readonly probes: { + readonly codePoints: CodePointRanges; + readonly binding: ManagedContextBinding; + readonly cases: readonly ProbeCase[]; + }; +} + +const contractDirectory = path.join( + path.dirname(fileURLToPath(import.meta.url)), + 'contracts', +); +const fixtures = JSON.parse( + fs.readFileSync( + path.join(contractDirectory, 'managed-workspace-binding-v1.fixtures.json'), + 'utf8', + ), +) as FixtureSuite; +const schema = JSON.parse( + fs.readFileSync( + path.join(contractDirectory, 'managed-workspace-binding-v1.schema.json'), + 'utf8', + ), +) as Record; + +const rootCase = fixtures.bindings.find((fixture) => fixture.id === 'root'); +if (!rootCase || !('contextDigest' in rootCase.expected)) { + throw new Error('The root fixture must define a valid binding.'); +} +const rootBinding = rootCase.binding; +const rootDigest = rootCase.expected.contextDigest; +const probedCodePoints = expandRanges(fixtures.probes.codePoints); + +function expandRanges(ranges: CodePointRanges): number[] { + const codePoints: number[] = []; + for (const [low, high] of ranges) { + if (low > high || low <= (codePoints.at(-1) ?? -1)) { + throw new Error('Probe ranges must be ascending and disjoint.'); + } + for (let codePoint = low; codePoint <= high; codePoint++) { + codePoints.push(codePoint); + } + } + return codePoints; +} + +function probeOutcome( + probe: ProbeCase, + value: string, +): 'accepted' | 'rejected' | 'changed' { + try { + if (probe.kind === 'path') { + // Probe templates keep every accepted value in normal form. + return normalizeWorkspaceRelativePath(value) === value + ? 'accepted' + : 'changed'; + } + computeManagedContextDigest({ + ...fixtures.probes.binding, + [probe.field]: value, + }); + return 'accepted'; + } catch (error) { + const rejection = + probe.kind === 'path' + ? error instanceof InvalidWorkspaceRelativePathError + : error instanceof Error && + error.message === 'Managed context binding is invalid.'; + if (rejection) { + return 'rejected'; + } + throw error; + } +} + +describe('Managed Workspace binding contract', () => { + it('validates the shared fixtures against the shared schema', () => { + const validate = new Ajv2020({ strict: true }).compile(schema); + + expect(validate(fixtures)).toBe(true); + expect(validate.errors).toBeNull(); + }); + + it('pins the digest construction', () => { + expect(fixtures.digest).toEqual({ + algorithm: 'sha256', + prefix: 'sha256:', + domainTag: CONTEXT_BINDING_DOMAIN_TAG, + fieldOrder: [ + 'tenantId', + 'workspaceId', + 'workspaceGeneration', + 'storageId', + 'cwdRelative', + 'contextConfigRef', + 'contextRevision', + ], + }); + }); + + it('uses each case id once', () => { + const ids = [ + ...fixtures.paths, + ...fixtures.bindings, + ...fixtures.probes.cases, + ].map((fixture) => fixture.id); + + expect(new Set(ids).size).toBe(ids.length); + }); + + it.each(fixtures.paths)('normalizes the $id path case', (fixture) => { + if ('cwdRelative' in fixture.expected) { + expect(normalizeWorkspaceRelativePath(fixture.input)).toBe( + fixture.expected.cwdRelative, + ); + } else { + expect(() => normalizeWorkspaceRelativePath(fixture.input)).toThrow( + InvalidWorkspaceRelativePathError, + ); + } + }); + + it.each(fixtures.bindings)('encodes the $id binding case', (fixture) => { + if ('contextDigest' in fixture.expected) { + expect(encodeManagedContextBinding(fixture.binding).toString('hex')).toBe( + fixture.expected.encodedHex, + ); + expect(computeManagedContextDigest(fixture.binding)).toBe( + fixture.expected.contextDigest, + ); + } else { + expect(() => computeManagedContextDigest(fixture.binding)).toThrow( + 'Managed context binding is invalid.', + ); + } + }); + + it.each(fixtures.probes.cases)( + 'accepts exactly the listed code points in the $id probe', + (probe) => { + const accepted = new Set(expandRanges(probe.accepted)); + const probed = new Set(probedCodePoints); + const mismatches: string[] = []; + for (const codePoint of probedCodePoints) { + const value = probe.template + .split('{c}') + .join(String.fromCodePoint(codePoint)); + const outcome = probeOutcome(probe, value); + if (outcome !== (accepted.has(codePoint) ? 'accepted' : 'rejected')) { + mismatches.push(`U+${codePoint.toString(16)} ${outcome}`); + } + } + + expect(mismatches).toEqual([]); + expect( + [...accepted].filter((codePoint) => !probed.has(codePoint)), + ).toEqual([]); + }, + ); + + it.each([ + 'tenantId', + 'workspaceId', + 'workspaceGeneration', + 'storageId', + 'cwdRelative', + 'contextConfigRef', + 'contextRevision', + ] as const)('reads %s once, so the checked value is encoded', (field) => { + let reads = 0; + const binding = { ...rootBinding }; + Object.defineProperty(binding, field, { + enumerable: true, + get: () => { + reads++; + return reads === 1 ? rootBinding[field] : '/etc/../x'; + }, + }); + + expect(computeManagedContextDigest(binding)).toBe(rootDigest); + expect(reads).toBe(1); + }); + + it('reports invalid_cwd without echoing the input', () => { + let error: unknown; + try { + normalizeWorkspaceRelativePath('secret/../x'); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(InvalidWorkspaceRelativePathError); + expect((error as InvalidWorkspaceRelativePathError).code).toBe( + 'invalid_cwd', + ); + expect((error as Error).name).toBe('InvalidWorkspaceRelativePathError'); + expect((error as Error).message).not.toContain('secret'); + }); + + it.each([ + 'tenantId', + 'workspaceId', + 'workspaceGeneration', + 'storageId', + 'cwdRelative', + 'contextConfigRef', + 'contextRevision', + ] as const)('rejects a missing %s', (field) => { + const binding = { + ...rootBinding, + [field]: undefined, + } as unknown as ManagedContextBinding; + + expect(() => computeManagedContextDigest(binding)).toThrow( + 'Managed context binding is invalid.', + ); + }); + + it.each([ + 'tenantId', + 'workspaceId', + 'workspaceGeneration', + 'storageId', + 'cwdRelative', + 'contextConfigRef', + 'contextRevision', + ] as const)('rejects a %s that is not a string primitive', (field) => { + // An array, a String object and a number all coerce to valid text, so + // only a type check keeps them out of the encoded bytes. + for (const value of [ + [rootBinding[field]], + Object(rootBinding[field]), + Number(rootBinding[field]) || 7, + ]) { + expect(() => + computeManagedContextDigest({ + ...rootBinding, + [field]: value, + } as unknown as ManagedContextBinding), + ).toThrow('Managed context binding is invalid.'); + } + }); + + it('rejects a binding that is not an object', () => { + // A function that carries every field is not a binding object either. + const callable = Object.assign(() => undefined, rootBinding); + for (const value of [undefined, null, 'tenant-a', 7, callable]) { + expect(() => + computeManagedContextDigest(value as unknown as ManagedContextBinding), + ).toThrow('Managed context binding is invalid.'); + } + }); + + it('rejects a path that is not a string primitive', () => { + for (const value of [undefined, null, ['a'], Object('a'), 7]) { + expect(() => + normalizeWorkspaceRelativePath(value as unknown as string), + ).toThrow(InvalidWorkspaceRelativePathError); + } + }); +}); diff --git a/packages/cli/src/serve/managed-workspace-binding.ts b/packages/cli/src/serve/managed-workspace-binding.ts new file mode 100644 index 00000000000..fccbe1088e8 --- /dev/null +++ b/packages/cli/src/serve/managed-workspace-binding.ts @@ -0,0 +1,204 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash } from 'node:crypto'; + +// TypeScript half of the W0a Workspace binding contract; the Java half is the +// com.alibaba.qwen.code.runtimebroker.managedworkspace package in +// packages/sdk-java/runtime-broker. The shared fixtures in +// contracts/managed-workspace-binding-v1.fixtures.json keep both byte for +// byte identical. Nothing wires this into the Runtime worker yet. + +export const WORKSPACE_ROOT = '.'; +export const CONTEXT_BINDING_DOMAIN_TAG = 'qwen-managed-context-binding-v1'; + +const MAXIMUM_CWD_CODE_POINTS = 1024; +const IDENTIFIER_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/; +const STORAGE_ID_PATTERN = /^[\x21-\x7E]{1,256}$/; +const REFERENCE_PATTERN = /^[\x21-\x7E]{1,512}$/; +const DECIMAL_PATTERN = /^[1-9][0-9]{0,18}$/; +const INT64_MAX = 9223372036854775807n; + +export class InvalidWorkspaceRelativePathError extends Error { + readonly code = 'invalid_cwd'; + + constructor() { + super('cwdRelative is not a valid Workspace-relative directory.'); + this.name = 'InvalidWorkspaceRelativePathError'; + } +} + +export interface ManagedContextBinding { + readonly tenantId: string; + readonly workspaceId: string; + /** Decimal text, so a 64-bit value never passes through Number. */ + readonly workspaceGeneration: string; + readonly storageId: string; + /** Already in the normal form of normalizeWorkspaceRelativePath. */ + readonly cwdRelative: string; + readonly contextConfigRef: string; + /** Decimal text, at least 1. */ + readonly contextRevision: string; +} + +/** + * Returns the normal form of a Workspace-relative directory: empty and `.` + * segments are dropped and nothing else changes. The check is lexical; the + * Runtime still verifies the directory on disk. + */ +export function normalizeWorkspaceRelativePath(value: string): string { + if ( + typeof value !== 'string' || + value.length > MAXIMUM_CWD_CODE_POINTS * 2 || + !isWellFormed(value) + ) { + throw new InvalidWorkspaceRelativePathError(); + } + const codePoints = [...value].length; + if ( + codePoints < 1 || + codePoints > MAXIMUM_CWD_CODE_POINTS || + hasControlCharacter(value) || + value.includes('\\') || + value.startsWith('/') + ) { + throw new InvalidWorkspaceRelativePathError(); + } + const kept: string[] = []; + for (const segment of value.split('/')) { + if (segment === '..') { + throw new InvalidWorkspaceRelativePathError(); + } + if (segment !== '' && segment !== '.') { + kept.push(segment); + } + } + const normalized = kept.length === 0 ? WORKSPACE_ROOT : kept.join('/'); + // Checked on the normal form: dropping a leading "." segment would + // otherwise turn ./C:x into the drive path C:x. + if (/^[A-Za-z]:/.test(normalized)) { + throw new InvalidWorkspaceRelativePathError(); + } + return normalized; +} + +/** + * Encodes each item as a 4-byte big-endian length followed by its UTF-8 + * bytes: the domain tag, then the binding fields in contract order. + */ +export function encodeManagedContextBinding( + binding: ManagedContextBinding, +): Buffer { + if (typeof binding !== 'object' || binding === null) { + throw new Error('Managed context binding is invalid.'); + } + // Each field is read once, so the value checked is the value encoded. + const { + tenantId, + workspaceId, + workspaceGeneration, + storageId, + cwdRelative, + contextConfigRef, + contextRevision, + } = binding; + if ( + !matches(IDENTIFIER_PATTERN, tenantId) || + !matches(IDENTIFIER_PATTERN, workspaceId) || + !isCanonicalDecimal(workspaceGeneration) || + !matches(STORAGE_ID_PATTERN, storageId) || + !isNormalized(cwdRelative) || + !matches(REFERENCE_PATTERN, contextConfigRef) || + !isCanonicalDecimal(contextRevision) + ) { + throw new Error('Managed context binding is invalid.'); + } + const items = [ + CONTEXT_BINDING_DOMAIN_TAG, + tenantId, + workspaceId, + workspaceGeneration, + storageId, + cwdRelative, + contextConfigRef, + contextRevision, + ]; + const parts: Buffer[] = []; + for (const item of items) { + const bytes = Buffer.from(item, 'utf8'); + const length = Buffer.alloc(4); + length.writeUInt32BE(bytes.length); + parts.push(length, bytes); + } + return Buffer.concat(parts); +} + +/** `sha256:` and the lowercase hex SHA-256 of the encoded binding. */ +export function computeManagedContextDigest( + binding: ManagedContextBinding, +): string { + return `sha256:${createHash('sha256') + .update(encodeManagedContextBinding(binding)) + .digest('hex')}`; +} + +/** Whether a value follows the W0a identifier rule, as tenant IDs do. */ +export function isManagedIdentifier(value: unknown): value is string { + return matches(IDENTIFIER_PATTERN, value); +} + +/** Whether a value is a storage ID under the W0a rule. */ +export function isWorkspaceStorageId(value: unknown): value is string { + return matches(STORAGE_ID_PATTERN, value); +} + +/** Whether a value is canonical decimal text from 1 to 2^63-1. */ +export function isCanonicalDecimalText(value: unknown): value is string { + return isCanonicalDecimal(value); +} + +function matches(pattern: RegExp, value: unknown): boolean { + return typeof value === 'string' && pattern.test(value); +} + +function isCanonicalDecimal(value: unknown): boolean { + return ( + typeof value === 'string' && + DECIMAL_PATTERN.test(value) && + BigInt(value) <= INT64_MAX + ); +} + +function isNormalized(value: unknown): boolean { + if (typeof value !== 'string') { + return false; + } + try { + return normalizeWorkspaceRelativePath(value) === value; + } catch { + return false; + } +} + +function isWellFormed(value: string): boolean { + try { + encodeURIComponent(value); + return true; + } catch { + return false; + } +} + +// Unicode category Cc: C0 controls, DEL and C1 controls. +function hasControlCharacter(value: string): boolean { + for (const character of value) { + const codePoint = character.codePointAt(0) ?? 0; + if (codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)) { + return true; + } + } + return false; +} diff --git a/packages/cli/src/serve/rest-integration-docs-contract.test.ts b/packages/cli/src/serve/rest-integration-docs-contract.test.ts index dfe6a20d508..c1c779edb33 100644 --- a/packages/cli/src/serve/rest-integration-docs-contract.test.ts +++ b/packages/cli/src/serve/rest-integration-docs-contract.test.ts @@ -7,7 +7,11 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { SESSION_TRANSCRIPT_MAX_LIMIT } from '@qwen-code/qwen-code-core'; +import { + APPROVAL_MODES, + SESSION_TRANSCRIPT_MAX_LIMIT, + REASONING_EFFORT_TIERS, +} from '@qwen-code/qwen-code-core'; import { DaemonClient } from '@qwen-code/sdk/daemon'; import * as ts from 'typescript'; import { describe, expect, it } from 'vitest'; @@ -724,6 +728,54 @@ describe('REST integration documentation contract', () => { ); }); + it('publishes startup request and acknowledgment schemas without opening unknown properties', () => { + const api = JSON.parse(readFileSync(OPENAPI, 'utf8')) as OpenApiDocument; + const schemas = api.components?.schemas ?? {}; + for (const name of [ + 'CreateSessionRequest', + 'CreateStandaloneSessionRequest', + ]) { + expect(schemas[name]).toMatchObject({ + additionalProperties: false, + properties: { + startupConfig: { $ref: '#/components/schemas/SessionStartupConfig' }, + }, + }); + } + expect(schemas['SessionStartupConfig']).toMatchObject({ + required: ['modelServiceId'], + additionalProperties: false, + properties: { + modelServiceId: { maxLength: 256 }, + reasoningEffort: { + enum: ['none', 'default', ...REASONING_EFFORT_TIERS], + }, + }, + }); + // The published enum must cover every approval mode the standalone + // route's parseApprovalMode accepts — a narrower list certifies a + // request set the daemon implements but generated clients refuse. + const standalone = schemas['CreateStandaloneSessionRequest'] as { + properties?: Record; + }; + expect(standalone.properties?.['approvalMode']?.enum).toEqual([ + ...APPROVAL_MODES, + ]); + expect(schemas['Session']).toMatchObject({ + properties: { + startupConfigApplied: { + $ref: '#/components/schemas/SessionStartupConfigApplied', + }, + }, + }); + expect(api.paths?.['/session']?.post?.responses).toHaveProperty('422'); + for (const filename of [PROTOCOL, REFERENCE, GUIDE]) { + expect(readFileSync(filename, 'utf8')).toContain( + 'session_startup_config', + ); + } + }); + it('publishes the resume request schema without the load-only fields', () => { const openApi = JSON.parse( readFileSync(OPENAPI, 'utf8'), diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 8af776b085f..abf9975ce8e 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -4,6 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { + isSessionStartupConfigError, + parseSessionStartupConfig, +} from '@qwen-code/acp-bridge/sessionStartupConfig'; import * as crypto from 'node:crypto'; import * as fs from 'node:fs'; import * as path from 'node:path'; @@ -2872,6 +2876,13 @@ export function registerSessionRoutes( } const assertRuntimeGenerationOpen = captureRuntimeGenerationAssertion(runtime); + let startupConfig; + try { + startupConfig = parseSessionStartupConfig(body['startupConfig'], body); + } catch (error) { + sendBridgeError(res, error, { route: 'POST /session' }); + return; + } const modelServiceId = typeof body['modelServiceId'] === 'string' ? (body['modelServiceId'] as string) @@ -3229,6 +3240,7 @@ export function registerSessionRoutes( const session = await runtime.bridge.spawnOrAttach({ workspaceCwd, modelServiceId, + ...(startupConfig ? { startupConfig } : {}), ...(clientId !== undefined ? { clientId } : {}), ...(sessionScope !== undefined ? { sessionScope } : {}), ...(approvalMode !== undefined ? { approvalMode } : {}), @@ -3619,6 +3631,54 @@ export function registerSessionRoutes( daemonLog, ); } + // A definite startup-config rejection already closed the live + // session in the bridge, but the recording the spawn persisted + // survives — and reserveCreate would answer every retry of the + // caller-supplied id with 409 session_id_conflict, while a + // daemon-generated id leaves a listed, resumable phantom. Roll the + // recording back the way the other spawn-failure paths do, naming + // the session the rejection was actually applied to. Uncertain + // outcomes keep it: the close result is unknown. + const rejectedSessionId = + (isSessionStartupConfigError(err) ? err.sessionId : undefined) ?? + requestedSessionId; + if ( + rejectedSessionId !== undefined && + isSessionStartupConfigError(err) && + err.code === 'startup_config_rejected' + ) { + let rollbackError: unknown; + const removed = await runWithWorkspaceRuntimeStorage(runtime, () => + deleteDaemonSessionIfOrphan({ + sessionId: rejectedSessionId, + service: createWorkspaceRuntimeSessionService(runtime), + bridge: runtime.bridge, + coordinator: archiveCoordinator, + }), + ).catch((cleanupError: unknown) => { + rollbackError = cleanupError; + return false; + }); + if (!removed) { + // The definite rejection still owes the caller its 422, so an + // inconclusive rollback — refused because the session stayed + // live, or failed outright — is a daemon-log line, not a throw. + daemonLog?.warn( + 'startup rejection recording rollback was inconclusive; the session id may stay occupied', + { + sessionId: rejectedSessionId, + ...(rollbackError === undefined + ? {} + : { + error: + rollbackError instanceof Error + ? rollbackError.message + : String(rollbackError), + }), + }, + ); + } + } // Only the plain creation path can promise that the initialize // handshake preceded every durable mutation: `branch`/`worktree` // bodies mutate git BEFORE spawn, and their rollback above is diff --git a/packages/cli/src/serve/routes/standalone-sessions.test.ts b/packages/cli/src/serve/routes/standalone-sessions.test.ts index 8dd9be006b4..507c1f92479 100644 --- a/packages/cli/src/serve/routes/standalone-sessions.test.ts +++ b/packages/cli/src/serve/routes/standalone-sessions.test.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { SessionStartupConfigError } from '@qwen-code/acp-bridge/sessionStartupConfig'; import express, { type RequestHandler } from 'express'; import { request as httpRequest } from 'node:http'; import type { AddressInfo } from 'node:net'; @@ -109,6 +110,54 @@ function createHarness({ describe('standalone session routes', () => { beforeEach(() => vi.restoreAllMocks()); + it('forwards startup selection and rejects malformed or conflicting selection before create', async () => { + const { app, service } = createHarness(); + const startupConfig = { + modelServiceId: 'gpt-5.4(openai)', + reasoningEffort: 'high', + }; + await request(app) + .post('/standalone/sessions') + .send({ sessionId, startupConfig }) + .expect(200); + expect(service.create).toHaveBeenCalledWith({ sessionId, startupConfig }); + service.create.mockClear(); + for (const body of [ + { sessionId, startupConfig: {} }, + { sessionId, startupConfig: { modelServiceId: 'x'.repeat(257) } }, + { sessionId, startupConfig, modelServiceId: 'legacy' }, + { sessionId, startupConfig: { ...startupConfig, extra: true } }, + ]) { + const response = await request(app) + .post('/standalone/sessions') + .send(body) + .expect(400); + expect(response.body.code).toBe('invalid_startup_config'); + } + expect(service.create).not.toHaveBeenCalled(); + }); + + it('returns 422 for a definite startup selection rejection', async () => { + const { app, service } = createHarness(); + service.create.mockRejectedValueOnce( + new SessionStartupConfigError( + 'startup_config_rejected', + 'unsupported effort', + ), + ); + const response = await request(app) + .post('/standalone/sessions') + .send({ + sessionId, + startupConfig: { modelServiceId: 'm', reasoningEffort: 'high' }, + }) + .expect(422); + expect(response.body).toMatchObject({ + code: 'startup_config_rejected', + error: 'unsupported effort', + }); + }); + it('rejects invalid standalone compacted replay mode', async () => { const { app, service } = createHarness(); const response = await request(app) diff --git a/packages/cli/src/serve/routes/standalone-sessions.ts b/packages/cli/src/serve/routes/standalone-sessions.ts index 0b19650482c..c456bfee4a7 100644 --- a/packages/cli/src/serve/routes/standalone-sessions.ts +++ b/packages/cli/src/serve/routes/standalone-sessions.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { parseSessionStartupConfig } from '@qwen-code/acp-bridge/sessionStartupConfig'; import { APPROVAL_MODES, MAX_CRON_TASK_ROUTING_ID_LENGTH, @@ -296,6 +297,7 @@ export function registerStandaloneSessionRoutes( const body = requireExactBody(req, res, [ 'sessionId', 'modelServiceId', + 'startupConfig', 'approvalMode', ]); if (!body) return; @@ -315,6 +317,10 @@ export function registerStandaloneSessionRoutes( ); return; } + const startupConfig = parseSessionStartupConfig( + body['startupConfig'], + body, + ); const approvalMode = parseApprovalMode(body['approvalMode']); if (approvalMode === null) { sendInvalidRequest( @@ -325,6 +331,7 @@ export function registerStandaloneSessionRoutes( } const request: CreateStandaloneSessionRequest = { sessionId: body['sessionId'], + ...(startupConfig ? { startupConfig } : {}), ...(body['modelServiceId'] !== undefined ? { modelServiceId: body['modelServiceId'] as string } : {}), diff --git a/packages/cli/src/serve/routes/workspace-extensions-controller.test.ts b/packages/cli/src/serve/routes/workspace-extensions-controller.test.ts index 9e553d480f1..936d4f4a042 100644 --- a/packages/cli/src/serve/routes/workspace-extensions-controller.test.ts +++ b/packages/cli/src/serve/routes/workspace-extensions-controller.test.ts @@ -250,6 +250,261 @@ describe('createExtensionsController', () => { expect(refreshCache).toHaveBeenCalledOnce(); }); + it('coalesces cold and expired status loads while preserving cache hits', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + let release!: () => void; + const refresh = vi + .spyOn(ExtensionManager.prototype, 'refreshCache') + .mockImplementation( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + vi.spyOn(ExtensionManager.prototype, 'getLoadedExtensions').mockReturnValue( + [], + ); + const controller = createExtensionsController({ + boundWorkspace: '/work/bound', + bridge: {} as AcpSessionBridge, + workspace: {} as DaemonWorkspaceService, + isWorkspaceTrusted: () => true, + }); + for (const expectedLoads of [1, 2]) { + const requests = Array.from({ length: 5 }, () => + controller.buildLocalExtensionsStatus(), + ); + expect(refresh).toHaveBeenCalledTimes(expectedLoads); + release(); + const statuses = await Promise.all(requests); + expect(statuses.every((status) => status === statuses[0])).toBe(true); + expect(await controller.buildLocalExtensionsStatus()).toBe(statuses[0]); + expect(refresh).toHaveBeenCalledTimes(expectedLoads); + vi.setSystemTime(Date.now() + 2_001); + } + }); + + it('shares a failed load and retries on the next request', async () => { + let reject!: (error: Error) => void; + const refresh = vi + .spyOn(ExtensionManager.prototype, 'refreshCache') + .mockImplementationOnce( + () => + new Promise((_resolve, rejectLoad) => { + reject = rejectLoad; + }), + ) + .mockResolvedValue(undefined); + vi.spyOn(ExtensionManager.prototype, 'getLoadedExtensions').mockReturnValue( + [], + ); + const controller = createExtensionsController({ + boundWorkspace: '/work/bound', + bridge: {} as AcpSessionBridge, + workspace: {} as DaemonWorkspaceService, + isWorkspaceTrusted: () => true, + }); + const requests = Promise.allSettled([ + controller.buildLocalExtensionsStatus(), + controller.buildLocalExtensionsStatus(), + ]); + const error = new Error('load failed'); + reject(error); + expect(await requests).toEqual([ + { status: 'rejected', reason: error }, + { status: 'rejected', reason: error }, + ]); + await expect( + controller.buildLocalExtensionsStatus(), + ).resolves.toMatchObject({ initialized: true }); + expect(refresh).toHaveBeenCalledTimes(2); + }); + + it.each(['locale', 'trust', 'controller'] as const)( + 'does not share pending status loads across %s changes', + async (change) => { + const releases: Array<() => void> = []; + const refresh = vi + .spyOn(ExtensionManager.prototype, 'refreshCache') + .mockImplementation( + () => + new Promise((resolve) => { + releases.push(resolve); + }), + ); + vi.spyOn( + ExtensionManager.prototype, + 'getLoadedExtensions', + ).mockReturnValue([]); + let trusted = true; + const deps = { + boundWorkspace: '/work/bound', + bridge: {} as AcpSessionBridge, + workspace: {} as DaemonWorkspaceService, + isWorkspaceTrusted: () => trusted, + }; + const controller = createExtensionsController(deps); + const first = controller.buildLocalExtensionsStatus(); + if (change === 'locale') + vi.mocked(resolveLanguageSetting).mockReturnValue('zh'); + if (change === 'trust') trusted = false; + const nextController = + change === 'controller' + ? createExtensionsController({ + ...deps, + boundWorkspace: '/work/other', + }) + : controller; + const second = nextController.buildLocalExtensionsStatus(); + expect(refresh).toHaveBeenCalledTimes(2); + releases[1]!(); + const fresh = await second; + releases[0]!(); + await first; + expect(await nextController.buildLocalExtensionsStatus()).toBe(fresh); + expect(refresh).toHaveBeenCalledTimes(2); + }, + ); + + it('invalidates a completed status cache when trust changes', async () => { + let trusted = true; + const refresh = vi + .spyOn(ExtensionManager.prototype, 'refreshCache') + .mockResolvedValue(undefined); + vi.spyOn(ExtensionManager.prototype, 'getLoadedExtensions').mockReturnValue( + [], + ); + const controller = createExtensionsController({ + boundWorkspace: '/work/bound', + bridge: {} as AcpSessionBridge, + workspace: {} as DaemonWorkspaceService, + isWorkspaceTrusted: () => trusted, + }); + await controller.buildLocalExtensionsStatus(); + trusted = false; + await controller.buildLocalExtensionsStatus(); + expect(refresh).toHaveBeenCalledTimes(2); + }); + + it.each(['old-first', 'new-first', 'old-fails'] as const)( + 'does not let an invalidated status load overwrite or clear its replacement (%s)', + async (order) => { + const releases: Array<{ + resolve: () => void; + reject: (error: Error) => void; + }> = []; + const refresh = vi + .spyOn(ExtensionManager.prototype, 'refreshCache') + .mockImplementation( + () => + new Promise((resolve, reject) => { + releases.push({ resolve, reject }); + }), + ); + vi.spyOn( + ExtensionManager.prototype, + 'getLoadedExtensions', + ).mockReturnValue([]); + const controller = createExtensionsController({ + boundWorkspace: '/work/bound', + bridge: {} as AcpSessionBridge, + workspace: { + refreshExtensionsForAllSessions: vi + .fn() + .mockResolvedValue({ refreshed: 0, failed: 0 }), + } as unknown as DaemonWorkspaceService, + isWorkspaceTrusted: () => true, + }); + const old = controller + .buildLocalExtensionsStatus() + .catch(() => undefined); + await controller.refreshExtensionsForAllSessions(); + const fresh = controller.buildLocalExtensionsStatus(); + expect(refresh).toHaveBeenCalledTimes(2); + if (order === 'new-first') { + releases[1]!.resolve(); + await fresh; + } + if (order === 'old-fails') releases[0]!.reject(new Error('old failure')); + else releases[0]!.resolve(); + await old; + const joined = controller.buildLocalExtensionsStatus(); + expect(refresh).toHaveBeenCalledTimes(2); + releases[1]!.resolve(); + expect(await joined).toBe(await fresh); + expect(await controller.buildLocalExtensionsStatus()).toBe(await fresh); + }, + ); + + it.each([false, true])( + 'invalidates pending status after a committed mutation (post-commit failure: %s)', + async (failAfterCommit) => { + vi.spyOn(process.stderr, 'write').mockReturnValue(true); + let release!: () => void; + const refresh = vi + .spyOn(ExtensionManager.prototype, 'refreshCache') + .mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve; + }), + ) + .mockResolvedValue(undefined); + vi.spyOn( + ExtensionManager.prototype, + 'getLoadedExtensions', + ).mockReturnValue([]); + const controller = createExtensionsController({ + boundWorkspace: '/work/bound', + bridge: { + broadcastExtensionsChanged: vi.fn(), + } as unknown as AcpSessionBridge, + workspace: {} as DaemonWorkspaceService, + isWorkspaceTrusted: () => true, + }); + const old = controller.buildLocalExtensionsStatus(); + const json = vi.fn(); + const response = { + status: vi.fn().mockReturnThis(), + location: vi.fn().mockReturnThis(), + set: vi.fn().mockReturnThis(), + json, + } as unknown as Response; + controller.runQueuedExtensionMutation( + 'enable', + { name: 'demo' }, + response, + async (_manager, _signal, context) => { + await context!.commit(async (onCommitted) => { + onCommitted(1); + if (failAfterCommit) throw new Error('post-commit failure'); + return { generation: 1 }; + }); + return { status: 'enabled', name: 'demo' }; + }, + { + manager: { + refreshCache: vi.fn().mockResolvedValue(undefined), + } as unknown as ExtensionManager, + skipRefresh: true, + }, + ); + const operationId = json.mock.calls[0]![0].operationId as string; + await vi.waitFor(() => + expect(controller.getOperation(operationId)?.status).toBe( + failAfterCommit ? 'succeeded_with_warnings' : 'succeeded', + ), + ); + const fresh = await controller.buildLocalExtensionsStatus(); + expect(refresh).toHaveBeenCalledTimes(2); + release(); + await old; + expect(await controller.buildLocalExtensionsStatus()).toBe(fresh); + expect(refresh).toHaveBeenCalledTimes(2); + }, + ); + it('normalizes the current language when resolving extension metadata', async () => { vi.mocked(resolveLanguageSetting).mockImplementation((language) => language === 'zh_TW' ? 'zh_TW' : 'en', diff --git a/packages/cli/src/serve/routes/workspace-extensions-controller.ts b/packages/cli/src/serve/routes/workspace-extensions-controller.ts index b6b33830603..8b245fe85ab 100644 --- a/packages/cli/src/serve/routes/workspace-extensions-controller.ts +++ b/packages/cli/src/serve/routes/workspace-extensions-controller.ts @@ -422,11 +422,25 @@ export function createExtensionsController( let extensionsStatusCache: | { locale: string; + trusted: boolean; expiresAt: number; value: ServeWorkspaceExtensionsStatus; } | undefined; + let extensionsStatusInFlight: + | { + locale: string; + trusted: boolean; + promise: Promise; + } + | undefined; + + const invalidateExtensionsStatus = (): void => { + extensionsStatusCache = undefined; + extensionsStatusInFlight = undefined; + }; + const refreshExtensionsForAllSessions = async (): Promise<{ refreshed: number; failed: number; @@ -436,7 +450,7 @@ export function createExtensionsController( const refresh = commitQueue.runUntilReleased( async (release) => { releaseCommitLane = release; - extensionsStatusCache = undefined; + invalidateExtensionsStatus(); return await workspace.refreshExtensionsForAllSessions(); }, { signal: queueAbort.signal }, @@ -677,7 +691,7 @@ export function createExtensionsController( ); mutationEvent = event; if (deadline) clearTimeout(deadline); - extensionsStatusCache = undefined; + invalidateExtensionsStatus(); if (options.skipRefresh || event.updated === false) { reconciliationReservation?.release(); reconciliationReservation = undefined; @@ -904,7 +918,7 @@ export function createExtensionsController( ? (err as { code: string }).code : undefined; if (committedGeneration !== undefined) { - extensionsStatusCache = undefined; + invalidateExtensionsStatus(); const error = `Commit succeeded but post-commit work failed: ${message}`.slice( 0, @@ -1014,104 +1028,136 @@ export function createExtensionsController( })(); }; + const loadLocalExtensionsStatus = async ( + trusted: boolean, + ): Promise => { + const extensionManager = createExtensionManager(boundWorkspace, trusted); + await extensionManager.refreshCache(); + const entries: ServeExtensionEntry[] = extensionManager + .getLoadedExtensions() + .map((ext): ServeExtensionEntry => { + const capabilities: ServeExtensionCapabilities = { + mcpServerCount: ext.mcpServers + ? Object.keys(ext.mcpServers).length + : 0, + skillCount: ext.skills?.length ?? 0, + agentCount: ext.agents?.length ?? 0, + hookCount: ext.hooks + ? Object.values(ext.hooks).reduce( + (sum, defs) => sum + (defs?.length ?? 0), + 0, + ) + : 0, + commandCount: ext.commands?.length ?? 0, + contextFileCount: ext.contextFiles.length, + channelCount: ext.channels ? Object.keys(ext.channels).length : 0, + hasSettings: (ext.settings?.length ?? 0) > 0, + }; + return { + kind: 'extension', + id: ext.id, + name: ext.name, + ...(ext.displayName ? { displayName: ext.displayName } : {}), + ...(ext.config.description + ? { description: ext.config.description } + : {}), + version: ext.version, + isActive: ext.isActive, + path: ext.path, + ...(ext.installMetadata?.source && + ext.installMetadata.type !== 'snapshot' + ? { + source: redactExtensionDisplaySource( + ext.installMetadata.source, + ), + } + : {}), + ...(ext.installMetadata?.type + ? { installType: ext.installMetadata.type } + : {}), + ...(ext.installMetadata?.originSource + ? { originSource: ext.installMetadata.originSource } + : {}), + ...(ext.installMetadata?.ref ? { ref: ext.installMetadata.ref } : {}), + ...(ext.installMetadata?.autoUpdate !== undefined + ? { autoUpdate: ext.installMetadata.autoUpdate } + : {}), + ...(ext.installMetadata?.type === 'snapshot' + ? { credentialPersistence: 'one_time' as const } + : ext.installMetadata?.credentialPersistence === 'stored' + ? { credentialPersistence: 'stored' as const } + : {}), + updateState: + ext.installMetadata?.type === 'snapshot' + ? 'not updatable' + : ext.installMetadata + ? 'unknown' + : 'not updatable', + capabilities, + details: { + mcpServers: ext.mcpServers ? Object.keys(ext.mcpServers) : [], + commands: ext.commands ?? [], + skills: ext.skills?.map((skill) => skill.name) ?? [], + agents: ext.agents?.map((agent) => agent.name) ?? [], + contextFiles: ext.contextFiles, + settings: + ext.resolvedSettings?.map((setting) => setting.name) ?? [], + }, + }; + }); + const status = { + v: STATUS_SCHEMA_VERSION, + workspaceCwd: boundWorkspace, + initialized: true, + extensions: entries, + }; + return status; + }; + const buildLocalExtensionsStatus = async (): Promise => { - const locale = resolveExtensionLocale(boundWorkspace); - const now = Date.now(); + const trusted = + deps.isWorkspaceTrusted?.() ?? + getWorkspaceTrustStatus( + loadSettings(boundWorkspace).merged, + boundWorkspace, + ).effective.state === 'trusted'; + const locale = resolveExtensionLocale(boundWorkspace, trusted); if ( extensionsStatusCache?.locale === locale && - extensionsStatusCache.expiresAt > now + extensionsStatusCache.trusted === trusted && + extensionsStatusCache.expiresAt > Date.now() ) { return extensionsStatusCache.value; } - const extensionManager = createExtensionManager(); - await extensionManager.refreshCache(); - const entries: ServeExtensionEntry[] = extensionManager - .getLoadedExtensions() - .map((ext): ServeExtensionEntry => { - const capabilities: ServeExtensionCapabilities = { - mcpServerCount: ext.mcpServers - ? Object.keys(ext.mcpServers).length - : 0, - skillCount: ext.skills?.length ?? 0, - agentCount: ext.agents?.length ?? 0, - hookCount: ext.hooks - ? Object.values(ext.hooks).reduce( - (sum, defs) => sum + (defs?.length ?? 0), - 0, - ) - : 0, - commandCount: ext.commands?.length ?? 0, - contextFileCount: ext.contextFiles.length, - channelCount: ext.channels ? Object.keys(ext.channels).length : 0, - hasSettings: (ext.settings?.length ?? 0) > 0, - }; - return { - kind: 'extension', - id: ext.id, - name: ext.name, - ...(ext.displayName ? { displayName: ext.displayName } : {}), - ...(ext.config.description - ? { description: ext.config.description } - : {}), - version: ext.version, - isActive: ext.isActive, - path: ext.path, - ...(ext.installMetadata?.source && - ext.installMetadata.type !== 'snapshot' - ? { - source: redactExtensionDisplaySource( - ext.installMetadata.source, - ), - } - : {}), - ...(ext.installMetadata?.type - ? { installType: ext.installMetadata.type } - : {}), - ...(ext.installMetadata?.originSource - ? { originSource: ext.installMetadata.originSource } - : {}), - ...(ext.installMetadata?.ref - ? { ref: ext.installMetadata.ref } - : {}), - ...(ext.installMetadata?.autoUpdate !== undefined - ? { autoUpdate: ext.installMetadata.autoUpdate } - : {}), - ...(ext.installMetadata?.type === 'snapshot' - ? { credentialPersistence: 'one_time' as const } - : ext.installMetadata?.credentialPersistence === 'stored' - ? { credentialPersistence: 'stored' as const } - : {}), - updateState: - ext.installMetadata?.type === 'snapshot' - ? 'not updatable' - : ext.installMetadata - ? 'unknown' - : 'not updatable', - capabilities, - details: { - mcpServers: ext.mcpServers ? Object.keys(ext.mcpServers) : [], - commands: ext.commands ?? [], - skills: ext.skills?.map((skill) => skill.name) ?? [], - agents: ext.agents?.map((agent) => agent.name) ?? [], - contextFiles: ext.contextFiles, - settings: - ext.resolvedSettings?.map((setting) => setting.name) ?? [], - }, - }; - }); - const status = { - v: STATUS_SCHEMA_VERSION, - workspaceCwd: boundWorkspace, - initialized: true, - extensions: entries, - }; - extensionsStatusCache = { + if ( + extensionsStatusInFlight?.locale === locale && + extensionsStatusInFlight.trusted === trusted + ) { + return extensionsStatusInFlight.promise; + } + const load = { locale, - expiresAt: Date.now() + 2_000, - value: status, + trusted, + promise: loadLocalExtensionsStatus(trusted), }; - return status; + extensionsStatusInFlight = load; + try { + const value = await load.promise; + if (extensionsStatusInFlight === load) { + extensionsStatusCache = { + locale, + trusted, + expiresAt: Date.now() + 2_000, + value, + }; + } + return value; + } finally { + if (extensionsStatusInFlight === load) { + extensionsStatusInFlight = undefined; + } + } }; return { diff --git a/packages/cli/src/serve/run-qwen-serve.test.ts b/packages/cli/src/serve/run-qwen-serve.test.ts index 83d5f099e4f..1622a3ec9cb 100644 --- a/packages/cli/src/serve/run-qwen-serve.test.ts +++ b/packages/cli/src/serve/run-qwen-serve.test.ts @@ -5885,6 +5885,39 @@ describe('runQwenServe telemetry validation', () => { }); }); +describe('runQwenServe unsupported deployment profiles', () => { + it.each([ + { profile: 'hosted-harness' as const }, + { experimentalManagedAgents: true }, + { experimentalManagedRuntimeWorker: true }, + { experimentalManagedRuntimeAutoLocal: true }, + { experimentalManagedRuntimeUrl: 'http://127.0.0.1:8080' }, + { experimentalManagedRuntimeToken: 'test-token' }, + { managedRuntimeBrokerUrl: 'http://127.0.0.1:8080' }, + { managedRuntimeBrokerToken: 'test-token' }, + ])('rejects before listening: %j', async (option) => { + const listen = vi + .spyOn(net.Server.prototype, 'listen') + .mockImplementation(() => { + throw new Error('Unexpected listener for an unavailable mode.'); + }); + try { + await expect( + runQwenServe({ + port: 0, + hostname: '127.0.0.1', + mode: 'http-bridge', + workspace: isolatedTestRuntimeDir, + ...option, + }), + ).rejects.toThrow(/not (available|implemented)/); + expect(listen).not.toHaveBeenCalled(); + } finally { + listen.mockRestore(); + } + }); +}); + /** * Boot validation for the embedded `runQwenServe` API: a non-finite * `permissionResponseTimeoutMs` (e.g. config- or NaN-derived) must fail diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index 16015b275d4..ab87421d7e1 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -27,6 +27,7 @@ import express, { type Response, } from 'express'; import { writeStderrLine, writeStdoutLine } from '../utils/stdioHelpers.js'; +import { validateHostedHarnessProfile } from './hosted-harness-profile.js'; import { isWithinRoot } from '../config/path-comparison.js'; import { readSshWorkspace } from './ssh-workspace-store.js'; import { @@ -3362,6 +3363,8 @@ async function runQwenServeImpl( deps.bootSettings ?? {}, 'serve / ACP / web terminals', ); + + validateHostedHarnessProfile(optsIn); const baseEnv: NodeJS.ProcessEnv = { ...process.env }; const launchMemoryProjectScopeValue = baseEnv['QWEN_CODE_MEMORY_PROJECT_SCOPE']; diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index c3281e43ad3..612acb67d65 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -601,6 +601,7 @@ const EXPECTED_STAGE1_FEATURES = [ 'daemon_status', 'capabilities', 'session_create', + 'session_startup_config', 'session_id_override', 'session_scope_override', 'session_load', @@ -13842,6 +13843,290 @@ describe('createServeApp', () => { } }); + it('rolls back the persisted recording after a definite startup rejection so the caller id stays retryable', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440000'; + const writeRecording = async () => { + // The spawn's model leg persists its transcript record before the + // selection is rejected and the bridge closes the live session. + const chatsDir = path.join( + new Storage(WS_BOUND).getProjectDir(), + 'chats', + ); + await fsp.mkdir(chatsDir, { recursive: true }); + await fsp.writeFile( + path.join(chatsDir, `${sessionId}.jsonl`), + `${JSON.stringify({ + uuid: `${sessionId}-user-1`, + parentUuid: null, + sessionId, + timestamp: '2026-01-01T00:00:00.000Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'model switch' }] }, + cwd: WS_BOUND, + })}\n`, + ); + }; + const bridge = fakeBridge({ + spawnImpl: async (req) => { + if (req.startupConfig !== undefined) { + await writeRecording(); + throw Object.assign(new Error('unsupported effort'), { + code: 'startup_config_rejected', + }); + } + return { + sessionId: req.sessionId ?? 'fake-startup-rollback', + workspaceCwd: req.workspaceCwd, + attached: false, + clientId: 'client-startup-rollback', + }; + }, + }); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + const service = new SessionService(WS_BOUND); + + const rejected = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ + sessionId, + startupConfig: { + modelServiceId: 'gpt-5.4(openai)', + reasoningEffort: 'high', + }, + }); + expect(rejected.status).toBe(422); + expect(rejected.body.code).toBe('startup_config_rejected'); + await expect( + service.findSessionIdIgnoringCase(sessionId), + ).resolves.toBeUndefined(); + + const retry = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ sessionId }); + expect(retry.status).toBe(200); + expect(retry.body.sessionId).toBe(sessionId); + }); + + it('rolls back the persisted recording after a definite startup rejection when the daemon generated the id', async () => { + // The caller sent no sessionId, so only the rejection's own + // `sessionId` can name the recording the spawn persisted before the + // selection was rejected. + const spawnedId = '550e8400-e29b-41d4-a716-446655440099'; + const bridge = fakeBridge({ + spawnImpl: async () => { + const chatsDir = path.join( + new Storage(WS_BOUND).getProjectDir(), + 'chats', + ); + await fsp.mkdir(chatsDir, { recursive: true }); + await fsp.writeFile( + path.join(chatsDir, `${spawnedId}.jsonl`), + `${JSON.stringify({ + uuid: `${spawnedId}-user-1`, + parentUuid: null, + sessionId: spawnedId, + timestamp: '2026-01-01T00:00:00.000Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'model switch' }] }, + cwd: WS_BOUND, + })}\n`, + ); + throw Object.assign(new Error('unsupported effort'), { + code: 'startup_config_rejected', + sessionId: spawnedId, + }); + }, + }); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + const service = new SessionService(WS_BOUND); + + const rejected = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ + startupConfig: { + modelServiceId: 'gpt-5.4(openai)', + reasoningEffort: 'high', + }, + }); + expect(rejected.status).toBe(422); + expect(rejected.body.code).toBe('startup_config_rejected'); + expect(bridge.killCalls).toEqual([ + { sessionId: spawnedId, opts: { requireZeroAttaches: true } }, + ]); + await expect( + service.findSessionIdIgnoringCase(spawnedId), + ).resolves.toBeUndefined(); + }); + + it('keeps the persisted recording when a startup failure outcome is uncertain', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440001'; + const bridge = fakeBridge({ + spawnImpl: async () => { + const chatsDir = path.join( + new Storage(WS_BOUND).getProjectDir(), + 'chats', + ); + await fsp.mkdir(chatsDir, { recursive: true }); + await fsp.writeFile( + path.join(chatsDir, `${sessionId}.jsonl`), + `${JSON.stringify({ + uuid: `${sessionId}-user-1`, + parentUuid: null, + sessionId, + timestamp: '2026-01-01T00:00:00.000Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'model switch' }] }, + cwd: WS_BOUND, + })}\n`, + ); + throw new Error('transport closed'); + }, + }); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + const service = new SessionService(WS_BOUND); + + const res = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ + sessionId, + startupConfig: { + modelServiceId: 'gpt-5.4(openai)', + reasoningEffort: 'high', + }, + }); + expect(res.status).toBe(500); + await expect(service.findSessionIdIgnoringCase(sessionId)).resolves.toBe( + sessionId, + ); + }); + + it('still answers the definite 422 when the recording rollback fails', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440002'; + const daemonLog = fakeDaemonLog(); + const bridge = fakeBridge({ + spawnImpl: async () => { + throw Object.assign(new Error('unsupported effort'), { + code: 'startup_config_rejected', + }); + }, + killImpl: async () => { + throw new Error('storage unavailable'); + }, + }); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge, daemonLog }, + ); + + const res = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ + sessionId, + startupConfig: { + modelServiceId: 'gpt-5.4(openai)', + reasoningEffort: 'high', + }, + }); + + // A failed rollback must not downgrade the definite rejection to the + // uncertain 500 path; the id may stay occupied, so warn instead. + expect(res.status).toBe(422); + expect(res.body.code).toBe('startup_config_rejected'); + expect(daemonLog.warn).toHaveBeenCalledWith( + 'startup rejection recording rollback was inconclusive; the session id may stay occupied', + { sessionId, error: 'storage unavailable' }, + ); + }); + + it('still answers the definite 422 and warns when the recording rollback is refused', async () => { + const sessionId = '550e8400-e29b-41d4-a716-4466554400a1'; + const daemonLog = fakeDaemonLog(); + let spawned = false; + const bridge = fakeBridge({ + spawnImpl: async () => { + spawned = true; + const chatsDir = path.join( + new Storage(WS_BOUND).getProjectDir(), + 'chats', + ); + await fsp.mkdir(chatsDir, { recursive: true }); + await fsp.writeFile( + path.join(chatsDir, `${sessionId}.jsonl`), + `${JSON.stringify({ + uuid: `${sessionId}-user-1`, + parentUuid: null, + sessionId, + timestamp: '2026-01-01T00:00:00.000Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'model switch' }] }, + cwd: WS_BOUND, + })}\n`, + ); + throw Object.assign(new Error('unsupported effort'), { + code: 'startup_config_rejected', + }); + }, + // The orphan kill is refused (the session stays live), so the + // helper removes nothing and reports the refusal by returning false. + killImpl: async () => false, + summaryImpl: (id) => { + if (!spawned) throw new SessionNotFoundError(id); + return { + sessionId: id, + workspaceCwd: WS_BOUND, + createdAt: '2026-01-01T00:00:00.000Z', + clientCount: 1, + hasActivePrompt: true, + }; + }, + }); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge, daemonLog }, + ); + const service = new SessionService(WS_BOUND); + + const res = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ + sessionId, + startupConfig: { + modelServiceId: 'gpt-5.4(openai)', + reasoningEffort: 'high', + }, + }); + + expect(res.status).toBe(422); + expect(res.body.code).toBe('startup_config_rejected'); + expect(daemonLog.warn).toHaveBeenCalledWith( + 'startup rejection recording rollback was inconclusive; the session id may stay occupied', + { sessionId }, + ); + await expect(service.findSessionIdIgnoringCase(sessionId)).resolves.toBe( + sessionId, + ); + }); + it('503 when persisted session state cannot be inspected', async () => { const bridge = fakeBridge(); const app = createServeApp( @@ -14325,6 +14610,35 @@ describe('createServeApp', () => { ]); }); + it('forwards startupConfig and rejects conflicts before spawning', async () => { + const bridge = fakeBridge(); + const app = createServeApp(baseOpts, undefined, { bridge }); + const startupConfig = { + modelServiceId: 'gpt-5.4(openai)', + reasoningEffort: 'high', + }; + const response = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ cwd: '/work/a', startupConfig }); + expect(response.status).toBe(200); + expect(bridge.calls).toEqual([{ workspaceCwd: WORK_A, startupConfig }]); + for (const body of [ + { startupConfig, sessionScope: 'single' }, + { startupConfig, modelServiceId: 'legacy' }, + { startupConfig: { reasoningEffort: 'high' } }, + { startupConfig: { ...startupConfig, persist: true } }, + ]) { + const rejected = await request(app) + .post('/session') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ cwd: '/work/a', ...body }); + expect(rejected.status).toBe(400); + expect(rejected.body.code).toBe('invalid_startup_config'); + } + expect(bridge.calls).toHaveLength(1); + }); + it('passes through a valid `sessionScope` to the bridge (#4175 PR 5)', async () => { // Per-request override: even when the daemon-wide default is // `'single'`, the route forwards an explicit `'thread'` scope so diff --git a/packages/cli/src/serve/server/error-response.test.ts b/packages/cli/src/serve/server/error-response.test.ts index 20125c17538..5b5b763a26c 100644 --- a/packages/cli/src/serve/server/error-response.test.ts +++ b/packages/cli/src/serve/server/error-response.test.ts @@ -52,6 +52,22 @@ function responseMock(): { return { response: response as unknown as Response, set, status, json }; } +describe('startup errors across bundle boundaries', () => { + it.each([ + ['invalid_startup_config', 400], + ['startup_config_rejected', 422], + ] as const)('maps %s by its stable contract', (code, httpStatus) => { + const error = Object.assign(new Error('startup rejected'), { + name: 'SessionStartupConfigError', + code, + }); + const { response, status, json } = responseMock(); + sendBridgeError(response, error); + expect(status).toHaveBeenCalledWith(httpStatus); + expect(json).toHaveBeenCalledWith({ code, error: 'startup rejected' }); + }); +}); + describe('workflow parameter errors', () => { it.each(['request', 'wire'] as const)( 'preserves parameter details from a %s error', @@ -487,6 +503,23 @@ describe('sendBridgeError session writer errors', () => { }); }); + it('maps a Managed engine rejection to HTTP 409', () => { + const { response, status, json } = responseMock(); + const error = new RequestError(-32024, 'belongs to managed', { + errorKind: 'session_execution_engine_unavailable', + }); + + sendBridgeError(response, error); + + expect(status).toHaveBeenCalledWith(409); + expect(json).toHaveBeenCalledWith({ + error: + 'This session cannot be resumed with the current execution engine.', + code: 'session_execution_engine_unavailable', + errorKind: 'session_execution_engine_unavailable', + }); + }); + it('maps an invalid transcript turn anchor to the public 400 contract', () => { const { response, status, json } = responseMock(); diff --git a/packages/cli/src/serve/server/error-response.ts b/packages/cli/src/serve/server/error-response.ts index 6edae73afab..6714211013a 100644 --- a/packages/cli/src/serve/server/error-response.ts +++ b/packages/cli/src/serve/server/error-response.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { isSessionStartupConfigError } from '@qwen-code/acp-bridge/sessionStartupConfig'; import { emitDaemonLog, InvalidSessionTranscriptCursorError, @@ -838,6 +839,13 @@ export function sendBridgeError( }); return; } + if (isSessionStartupConfigError(err)) { + res.status(err.code === 'invalid_startup_config' ? 400 : 422).json({ + error: err.message, + code: err.code, + }); + return; + } if (err instanceof InvalidSessionScopeError) { // Same wire shape as the route-layer 400 (`server.ts` validates // body['sessionScope'] before calling the bridge). A direct embed @@ -1015,6 +1023,15 @@ export function sendBridgeError( }); return; } + if (kind === 'session_execution_engine_unavailable') { + res.status(409).json({ + error: + 'This session cannot be resumed with the current execution engine.', + code: kind, + errorKind: kind, + }); + return; + } if (kind === 'untrusted_workspace') { res.status(403).json({ error: errorMessage(err), diff --git a/packages/cli/src/serve/server/session-archive.ts b/packages/cli/src/serve/server/session-archive.ts index a00c303bab6..e7b0dae4964 100644 --- a/packages/cli/src/serve/server/session-archive.ts +++ b/packages/cli/src/serve/server/session-archive.ts @@ -262,10 +262,9 @@ async function runWithDaemonWriterLease(params: { ) { throw error; } - lease = await service.acquireSessionMaintenanceLease( - sessionId, - leaseOptions, - ); + lease = + (await service.acquireSealedManagedMaintenanceLease(sessionId)) ?? + (await service.acquireSessionMaintenanceLease(sessionId, leaseOptions)); } } catch (error) { return { mutationApplied: false, error }; diff --git a/packages/cli/src/serve/types.ts b/packages/cli/src/serve/types.ts index aaffcf4ccea..087e2fca853 100644 --- a/packages/cli/src/serve/types.ts +++ b/packages/cli/src/serve/types.ts @@ -44,6 +44,7 @@ import type { * implemented. */ export type ServeMode = 'http-bridge' | 'native'; +export type ServeProfile = 'default' | 'hosted-harness'; export type ServeChannelSelection = | { mode: 'all' } @@ -58,6 +59,8 @@ export interface ChannelWebhookConfigSource { export interface ServeOptions { hostname: string; port: number; + /** Deployment boundary for provider selection and exposed surfaces. */ + profile?: ServeProfile; /** * Bearer token required on every request. Optional when bound to loopback * (developer convenience). On a non-loopback bind with neither this option @@ -339,6 +342,21 @@ export interface ServeOptions { * `POST /session/:id/prompt` from receipt to completion. */ promptDeadlineMs?: number; + /** Mount the experimental resident Managed Gateway and Tool Runtime path. */ + experimentalManagedAgents?: boolean; + /** Expose the private authenticated Tool-only Runtime worker protocol. */ + experimentalManagedRuntimeWorker?: boolean; + experimentalManagedRuntimeAutoLocal?: boolean; + /** Use a remote Runtime worker origin instead of the local provider. */ + experimentalManagedRuntimeUrl?: string; + /** Bearer credential used only for the remote Runtime worker. */ + experimentalManagedRuntimeToken?: string; + /** Java Runtime Broker origin used only by the Hosted Harness profile. */ + managedRuntimeBrokerUrl?: string; + /** Service credential used only for Harness-to-Broker calls. */ + managedRuntimeBrokerToken?: string; + /** Deployment-generated digest for the Hosted Harness private contract. */ + hostedHarnessCapabilityDigest?: string; /** * Per-SSE-connection idle deadline. */ @@ -424,6 +442,15 @@ export interface ServeOptions { * * `v` is the wire schema version; bumped only on breaking frame changes. */ +export interface HostedHarnessCapabilities { + readonly protocolVersions: { + readonly current: 1; + readonly supported: readonly [1]; + }; + readonly bootId: string; + readonly capabilityDigest: string; +} + export interface CapabilitiesEnvelope { v: 1; /** diff --git a/packages/cli/src/startup/startup-prefetch.test.ts b/packages/cli/src/startup/startup-prefetch.test.ts index f81474af093..e7415a0ecfa 100644 --- a/packages/cli/src/startup/startup-prefetch.test.ts +++ b/packages/cli/src/startup/startup-prefetch.test.ts @@ -15,6 +15,7 @@ import { import { startEarlyStartupPrefetches, startPostRenderPrefetches, + UPDATE_CHECK_DELAY_MS, } from './startup-prefetch.js'; const mockDebug = vi.hoisted(() => vi.fn()); @@ -127,7 +128,7 @@ describe('startupPrefetch', () => { delete process.env['QWEN_CODE_CUSTOM_SANDBOX_IMAGE']; delete process.env['QWEN_CODE_HOST_UPDATE_RELAUNCH']; delete process.env['QWEN_CODE_SKIP_UPDATE_CHECK_ONCE']; - vi.useRealTimers(); + vi.useFakeTimers(); mockCheckForUpdatesDetailed.mockResolvedValue({ status: 'up-to-date', currentVersion: '1.0.0', @@ -149,6 +150,12 @@ describe('startupPrefetch', () => { vi.useRealTimers(); }); + // The update check waits UPDATE_CHECK_DELAY_MS before it starts. + async function settle() { + await vi.advanceTimersByTimeAsync(UPDATE_CHECK_DELAY_MS); + await vi.dynamicImportSettled(); + } + function captureIdeConnectionStatuses() { const statuses: StartupIdeConnectionStatus[] = []; const listener = (status: StartupIdeConnectionStatus) => { @@ -213,7 +220,7 @@ describe('startupPrefetch', () => { startPostRenderPrefetches(config, makeSettings()); - await vi.dynamicImportSettled(); + await settle(); expect(mockCheckForUpdatesDetailed).toHaveBeenCalledTimes(1); expect(mockRequestUpdateOnExit).not.toHaveBeenCalled(); @@ -227,6 +234,17 @@ describe('startupPrefetch', () => { ); }); + it('waits UPDATE_CHECK_DELAY_MS before checking for updates', async () => { + startPostRenderPrefetches(makeConfig(), makeSettings()); + + await vi.advanceTimersByTimeAsync(UPDATE_CHECK_DELAY_MS - 1); + await vi.dynamicImportSettled(); + expect(mockCheckForUpdatesDetailed).not.toHaveBeenCalled(); + + await settle(); + expect(mockCheckForUpdatesDetailed).toHaveBeenCalledTimes(1); + }); + it('installs npm updates in the background after first render', async () => { const config = makeConfig(); const settings = makeSettings(); @@ -240,7 +258,7 @@ describe('startupPrefetch', () => { startPostRenderPrefetches(config, settings); - await vi.dynamicImportSettled(); + await settle(); expect(mockHandleAutoUpdate).toHaveBeenCalledWith( { @@ -268,7 +286,7 @@ describe('startupPrefetch', () => { }); startPostRenderPrefetches(makeConfig(), makeSettings()); - await vi.dynamicImportSettled(); + await settle(); expect(mockHandleAutoUpdate).toHaveBeenCalledOnce(); expect(mockRequestUpdateOnExit).not.toHaveBeenCalled(); @@ -290,7 +308,7 @@ describe('startupPrefetch', () => { }); startPostRenderPrefetches(makeConfig(), makeSettings()); - await vi.dynamicImportSettled(); + await settle(); expect(mockUpdateEventEmit).toHaveBeenCalledWith('update-info', { message: 'Update available\nRun /update to install the update.', @@ -315,7 +333,7 @@ describe('startupPrefetch', () => { startPostRenderPrefetches(config, makeSettings()); - await vi.dynamicImportSettled(); + await settle(); expect(mockRequestUpdateOnExit).toHaveBeenCalledTimes(1); expect(mockHandleAutoUpdate).not.toHaveBeenCalled(); @@ -332,7 +350,7 @@ describe('startupPrefetch', () => { }); startPostRenderPrefetches(makeConfig(), makeSettings()); - await vi.dynamicImportSettled(); + await settle(); expect(mockRequestUpdateOnExit).not.toHaveBeenCalled(); expect(mockGetInstallationInfo).not.toHaveBeenCalled(); @@ -353,7 +371,7 @@ describe('startupPrefetch', () => { }); startPostRenderPrefetches(makeConfig(), makeSettings()); - await vi.dynamicImportSettled(); + await settle(); expect(mockRequestUpdateOnExit).not.toHaveBeenCalled(); expect(mockHandleAutoUpdate).not.toHaveBeenCalled(); @@ -368,7 +386,7 @@ describe('startupPrefetch', () => { try { startPostRenderPrefetches(makeConfig(), makeSettings()); - await vi.dynamicImportSettled(); + await settle(); expect(mockCheckForUpdatesDetailed).not.toHaveBeenCalled(); } finally { @@ -382,7 +400,7 @@ describe('startupPrefetch', () => { try { startPostRenderPrefetches(makeConfig(), makeSettings()); - await vi.dynamicImportSettled(); + await settle(); expect(mockCheckForUpdatesDetailed).not.toHaveBeenCalled(); expect(mockRequestUpdateOnExit).not.toHaveBeenCalled(); @@ -398,7 +416,7 @@ describe('startupPrefetch', () => { startPostRenderPrefetches(config, makeSettings(), { connectIde: true }); - await vi.dynamicImportSettled(); + await settle(); expect(mockCheckForUpdatesDetailed).toHaveBeenCalledTimes(1); expect(mockConnectIdeForStartup).toHaveBeenCalledWith(config); @@ -415,7 +433,7 @@ describe('startupPrefetch', () => { connectIde: true, }); - await vi.dynamicImportSettled(); + await settle(); expect(mockCheckForUpdatesDetailed).not.toHaveBeenCalled(); expect(mockConnectIdeForStartup).toHaveBeenCalledWith(config); @@ -430,7 +448,7 @@ describe('startupPrefetch', () => { startPostRenderPrefetches(config, makeSettings()); - await vi.dynamicImportSettled(); + await settle(); expect(mockUpdateEventEmit).toHaveBeenCalledWith('update-failed', { message: 'Update check skipped (registry error) — run /update to retry.', @@ -451,7 +469,7 @@ describe('startupPrefetch', () => { startPostRenderPrefetches(config, makeSettings()); - await vi.dynamicImportSettled(); + await settle(); expect(mockUpdateEventEmit).toHaveBeenCalledWith('update-failed', { message: `Update check skipped (registry did not respond within ${Math.round(FETCH_TIMEOUT_MS / 1000)}s) — run /update to retry.`, @@ -470,7 +488,7 @@ describe('startupPrefetch', () => { startPostRenderPrefetches(config, makeSettings()); - await vi.dynamicImportSettled(); + await settle(); expect(mockUpdateEventEmit).toHaveBeenCalledWith('update-failed', { message: @@ -486,7 +504,7 @@ describe('startupPrefetch', () => { try { startPostRenderPrefetches(config, makeSettings()); - await vi.dynamicImportSettled(); + await settle(); expect(mockConnectIdeForStartup).not.toHaveBeenCalled(); expect(statuses).toEqual([]); @@ -502,7 +520,7 @@ describe('startupPrefetch', () => { try { startPostRenderPrefetches(config, makeSettings(), { connectIde: true }); - await vi.dynamicImportSettled(); + await settle(); expect(mockConnectIdeForStartup).not.toHaveBeenCalled(); expect(statuses).toEqual([]); @@ -518,7 +536,7 @@ describe('startupPrefetch', () => { try { startPostRenderPrefetches(config, makeSettings(), { connectIde: true }); - await vi.dynamicImportSettled(); + await settle(); expect(statuses).toEqual([ { state: 'connecting' }, @@ -539,7 +557,7 @@ describe('startupPrefetch', () => { try { startPostRenderPrefetches(config, makeSettings(), { connectIde: true }); - await vi.dynamicImportSettled(); + await settle(); await vi.advanceTimersByTimeAsync(15_000); expect(statuses).toEqual([ @@ -576,14 +594,14 @@ describe('startupPrefetch', () => { startPostRenderPrefetches(config, makeSettings(), { connectIde: true }); - await vi.dynamicImportSettled(); + await settle(); await vi.advanceTimersByTimeAsync(15_000); - await vi.dynamicImportSettled(); + await settle(); expect(mockDisconnectIde).toHaveBeenCalledTimes(1); resolveConnect(); - await vi.dynamicImportSettled(); + await settle(); expect(mockDisconnectIde).toHaveBeenCalledTimes(2); expect(mockWarn).toHaveBeenCalledWith( @@ -603,7 +621,7 @@ describe('startupPrefetch', () => { try { startPostRenderPrefetches(config, makeSettings(), { connectIde: true }); - await vi.dynamicImportSettled(); + await settle(); expect(statuses).toEqual([ { state: 'connecting' }, @@ -631,12 +649,12 @@ describe('startupPrefetch', () => { startPostRenderPrefetches(config, makeSettings(), { connectIde: true }); - await vi.dynamicImportSettled(); + await settle(); await vi.advanceTimersByTimeAsync(15_000); const underlyingError = new Error('socket closed'); rejectConnect(underlyingError); - await vi.dynamicImportSettled(); + await settle(); expect(mockWarn).toHaveBeenCalledWith( 'ide_connect failed:', @@ -657,7 +675,7 @@ describe('startupPrefetch', () => { initializeTelemetry: true, }); - await vi.dynamicImportSettled(); + await settle(); expect(mockInitializeTelemetry).toHaveBeenCalledWith(config); }); @@ -667,7 +685,7 @@ describe('startupPrefetch', () => { startPostRenderPrefetches(config, makeSettings()); - await vi.dynamicImportSettled(); + await settle(); expect(mockInitializeTelemetry).not.toHaveBeenCalled(); }); @@ -685,7 +703,7 @@ describe('startupPrefetch', () => { }), ).not.toThrow(); - await vi.dynamicImportSettled(); + await settle(); expect(mockWarn).toHaveBeenCalledWith('telemetry_init failed:', error); }); @@ -699,7 +717,7 @@ describe('startupPrefetch', () => { startPostRenderPrefetches(config, makeSettings()), ).not.toThrow(); - await vi.dynamicImportSettled(); + await settle(); expect(mockWarn).toHaveBeenCalledWith('update_check failed:', error); expect(mockUpdateEventEmit).toHaveBeenCalledWith('update-failed', { @@ -715,7 +733,7 @@ describe('startupPrefetch', () => { startPostRenderPrefetches(config, makeSettings()); - await vi.dynamicImportSettled(); + await settle(); expect(mockStartBackgroundHousekeeping).not.toHaveBeenCalled(); }); @@ -726,7 +744,7 @@ describe('startupPrefetch', () => { startPostRenderPrefetches(config, makeSettings()); startPostRenderPrefetches(config, makeSettings()); - await vi.dynamicImportSettled(); + await settle(); expect(mockCheckForUpdatesDetailed).toHaveBeenCalledTimes(1); }); diff --git a/packages/cli/src/startup/startup-prefetch.ts b/packages/cli/src/startup/startup-prefetch.ts index df2ad9a16da..7a7ad1213de 100644 --- a/packages/cli/src/startup/startup-prefetch.ts +++ b/packages/cli/src/startup/startup-prefetch.ts @@ -136,6 +136,8 @@ export function startEarlyStartupPrefetches(config: Config): void { } } +export const UPDATE_CHECK_DELAY_MS = 3_000; + /** * Starts post-render startup prefetches for ordinary interactive TUI sessions. * @@ -158,6 +160,11 @@ export function startPostRenderPrefetches( !process.env[CUSTOM_SANDBOX_IMAGE_ENV_VAR] ) { runDeferredTask('update_check', async () => { + // The check spawns npm (a second Node process); keep it off the + // window where the session is still starting up. + await new Promise((resolve) => { + setTimeout(resolve, UPDATE_CHECK_DELAY_MS).unref?.(); + }); const [ { checkForUpdatesDetailed, describeUpdateCheckFailure }, { handleAutoUpdate }, diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 13034dc8ec7..788f26d27e9 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -214,6 +214,7 @@ import { sendNotification } from '../services/notificationService.js'; import { type UpdateObject } from './utils/updateCheck.js'; import { setUpdateHandler } from './handleAutoUpdate.js'; import { registerCleanup, runExitCleanup } from '../utils/cleanup.js'; +import { exitCleanly } from '../utils/processUtils.js'; import { useMessageQueue, type QueuedUserSubmission, @@ -2099,7 +2100,7 @@ export const AppContainer = (props: AppContainerProps) => { config.getLlmClient()?.requestShutdown(); setTimeout(async () => { await runExitCleanup(); - process.exit(0); + await exitCleanly(0); }, 100); }, setDebugMessage, @@ -4101,6 +4102,12 @@ export const AppContainer = (props: AppContainerProps) => { streamingState, updateInfo, agentViewState.activeView, + // The agent tab footer grows with its own status row / queued messages / + // input text, none of which the deps above track; AgentComposer syncs + // this key to AgentViewContext whenever they change so the footer is + // re-measured and the transcript viewport does not stay stale-high + // (#9507). Mirrors the LiveAgentPanel layout key (#5798). + agentViewState.agentComposerLayoutKey, embeddedShellFocused, messageQueue.length, isInputActive, diff --git a/packages/cli/src/ui/app-container-controls-dep.test.ts b/packages/cli/src/ui/app-container-controls-dep.test.ts index c80077db9c3..829881b3416 100644 --- a/packages/cli/src/ui/app-container-controls-dep.test.ts +++ b/packages/cli/src/ui/app-container-controls-dep.test.ts @@ -22,6 +22,12 @@ * leaves the mechanism tests (which use a stand-in component) green. This guard * pins the dependency directly, so a deps-array cleanup or an `exhaustive-deps` * autofix cannot quietly delete the fix. + * + * The same reasoning extends to the #9507 provider link: the composer layout + * key must survive in the dependency arrays of the AgentViewContext state and + * actions memos, or setAgentComposerLayoutKey updates never reach the + * AppContainer measure effect guarded above. useMemo deps are invisible to + * TypeScript, so they are pinned here at the source level as well. */ import { describe, it, expect } from 'vitest'; @@ -33,6 +39,16 @@ const source = readFileSync( 'utf8', ); +const composerSource = readFileSync( + join(import.meta.dirname, 'components', 'agent-view', 'AgentComposer.tsx'), + 'utf8', +); + +const agentViewContextSource = readFileSync( + join(import.meta.dirname, 'contexts', 'AgentViewContext.tsx'), + 'utf8', +); + /** Extract the dependency array of the controls-height measurement effect. */ function controlsHeightEffectDeps(): string { const measureAt = source.indexOf('measureElement(mainControlsRef.current)'); @@ -44,6 +60,24 @@ function controlsHeightEffectDeps(): string { return source.slice(depsOpen, depsClose); } +/** + * Extract the dependency array of a useMemo in AgentViewContext.tsx. The + * slice runs from the deps-array opening bracket to its closing bracket, so + * it covers only the deps list, not the memoized object literal above it. + * That is deliberate: the guarded mutation keeps the value in the literal + * while dropping it from the deps, which must fail the assertion below even + * though the literal still mentions the identifier. + */ +function agentViewMemoDeps(memoAnchor: string): string { + const memoAt = agentViewContextSource.indexOf(memoAnchor); + expect(memoAt).toBeGreaterThan(-1); + const depsOpen = agentViewContextSource.indexOf('\n [', memoAt); + expect(depsOpen).toBeGreaterThan(-1); + const depsClose = agentViewContextSource.indexOf('\n ],', depsOpen); + expect(depsClose).toBeGreaterThan(depsOpen); + return agentViewContextSource.slice(depsOpen, depsClose); +} + describe('AppContainer controls-height measurement wiring', () => { it('measures controls height from mainControlsRef', () => { // Sanity: the effect we are guarding still exists and is shaped as expected. @@ -67,4 +101,55 @@ describe('AppContainer controls-height measurement wiring', () => { /liveAgentPanelLayoutKey\s*=\s*getLiveAgentPanelLayoutKey\(\s*bgTaskEntries\s*,\s*bgLivePanelFocused\s*,?\s*\)/, ); }); + + it('lists the agent composer layout key in the measurement effect deps', () => { + const deps = controlsHeightEffectDeps(); + // The #9507 fix: the agent tab footer grows with its own status row / + // queued messages / input text, none of which the other deps track; + // dropping this entry leaves controlsHeight stale-high on the agent tab + // and the viewport pushes the composer and tab bar off the terminal. + expect(deps).toContain('agentViewState.agentComposerLayoutKey'); + }); + + it('derives the composer layout key from the footer height-shifting state', () => { + // The key must cover every row-count factor the agent footer renders: + // loading row (streamingState), terminal status row, queued messages and + // input text. Whitespace-tolerant so prettier can't break the guard. + expect(composerSource).toMatch( + /getAgentComposerLayoutKey\(\{\s*streamingState\s*,\s*statusLabel:\s*statusLabel\?\.text\s*\?\?\s*''\s*,\s*queuedMessageCount:\s*messageQueue\.length\s*,\s*inputText:\s*buffer\.text\s*,?\s*\}\)/, + ); + }); + + it('syncs the composer layout key to context for the measure effect', () => { + expect(composerSource).toMatch( + /useEffect\(\(\)\s*=>\s*\{\s*setAgentComposerLayoutKey\(composerLayoutKey\);\s*\},\s*\[composerLayoutKey,\s*setAgentComposerLayoutKey\]\)/, + ); + }); +}); + +describe('AgentViewContext propagation of the composer layout key (#9507)', () => { + it('lists agentComposerLayoutKey in the state memo dependencies', () => { + const deps = agentViewMemoDeps('const state: AgentViewState = useMemo('); + // Sanity: this is the state memo's deps array (the literal above it + // mirrors the same identifiers, so anchor on entries plus position). + expect(deps).toContain('activeView'); + expect(deps).toContain('agentMessageQueues'); + // The #9507 provider link: the object literal still lists the key when + // this dep is dropped, so nothing looks wrong — but the memoized context + // value goes stale, setAgentComposerLayoutKey updates stop reaching the + // AppContainer controls-height measure effect, and the composer/tab bar + // overflow fixed in round 2 returns. TypeScript cannot see memo deps, so + // this source guard is the only protection. + expect(deps).toContain('agentComposerLayoutKey'); + }); + + it('lists setAgentComposerLayoutKey in the actions memo dependencies', () => { + const deps = agentViewMemoDeps( + 'const actions: AgentViewActions = useMemo(', + ); + expect(deps).toContain('setAgentShellFocused'); + // Dropping this entry hands consumers a stale actions object whose + // setAgentComposerLayoutKey identity predates the provider re-render. + expect(deps).toContain('setAgentComposerLayoutKey'); + }); }); diff --git a/packages/cli/src/ui/components/agent-view/AgentChatContent.test.tsx b/packages/cli/src/ui/components/agent-view/AgentChatContent.test.tsx new file mode 100644 index 00000000000..0cc8df45110 --- /dev/null +++ b/packages/cli/src/ui/components/agent-view/AgentChatContent.test.tsx @@ -0,0 +1,587 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Teammate-tab transcript scrolling (#9507). + * + * In Virtualized History mode (`ui.useTerminalBuffer`, the default) the + * interactive UI takes over the whole terminal, so there is no + * terminal-native scrollback. The main conversation view renders its + * transcript in a scrollable virtual viewport (Page Up/Page Down + mouse + * wheel), but the teammate tab used ink's `` directly — content + * that scrolled off screen was unrecoverable. These tests pin the + * viewport contract for the teammate tab: + * + * - VP mode: the transcript lives in a scrollable viewport pinned to + * the tail; Page Up reveals earlier output (and the tail leaves the + * visible window). + * - Legacy mode (`useTerminalBuffer: false`): the transcript keeps + * flowing through `` into the terminal's native scrollback — + * every line is emitted and Page Up must not hide anything. + * - VP mode keeps the executing/confirming split: items from an + * executing tool group onward render through the pending (interactive) + * path so confirmation dialogs keep working inside the viewport. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type React from 'react'; +import { render } from 'ink-testing-library'; +import { act } from '@testing-library/react'; +import { Text } from 'ink'; +import { AgentChatContent } from './AgentChatContent.js'; +import { UIStateContext, type UIState } from '../../contexts/UIStateContext.js'; +import { KeypressProvider } from '../../contexts/KeypressContext.js'; +import { ThoughtExpandedProvider } from '../../contexts/ThoughtExpandedContext.js'; +import { + ContextMenuProvider, + useContextMenu, + type ContextMenuContextValue, +} from '../../context-menu/ContextMenuContext.js'; +import { AgentStatus } from '@qwen-code/qwen-code-core'; +import type { AgentMessage } from '@qwen-code/qwen-code-core'; + +vi.mock('../../utils/measure-element-position.js', () => ({ + measureElementPosition: () => ({ x: 0, y: 0, width: 80, height: 8 }), +})); + +// ink-testing-library's fake stdout has no `isTTY`; ScrollableList's +// `useMouseEvents` gates SGR mouse mode on it. Report a TTY so the +// viewport arms as in a real terminal (mirrors ScrollableList.test). +vi.mock('ink', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useStdout: () => ({ + stdout: { write: vi.fn(), isTTY: true }, + writeToStdout: vi.fn(), + }), + }; +}); + +const historyItemDisplaySpy = vi.hoisted(() => vi.fn()); +type HistoryItemDisplayProps = { + id: number; + isPending?: boolean; + fullDetail?: boolean; +}; +/** Props of every HistoryItemDisplay render recorded so far. */ +const historyItemDisplayCalls = (): HistoryItemDisplayProps[] => + historyItemDisplaySpy.mock.calls.map( + (args: unknown[]) => args[0] as HistoryItemDisplayProps, + ); +vi.mock('../HistoryItemDisplay.js', () => ({ + HistoryItemDisplay: ({ + item, + isPending, + fullDetail, + }: { + item: { id: number; type: string; text?: string }; + isPending?: boolean; + fullDetail?: boolean; + }) => { + historyItemDisplaySpy({ id: item.id, isPending, fullDetail }); + return ( + + {item.type === 'tool_group' + ? `TOOLGROUP:${item.id}` + : (item.text ?? `ITEM:${item.id}`)} + {isPending ? ':pending' : ''} + + ); + }, +})); + +vi.mock('./AgentHeader.js', () => ({ + AgentHeader: () => AGENT_HEADER, +})); + +vi.mock('../RespondingSpinner.js', () => ({ + RespondingSpinner: () => SPINNER, +})); + +// Props spy that still renders the real viewport: the scrolling cases need +// genuine Page Up behavior, the context-menu case needs the wiring. +const scrollableListSpy = vi.hoisted(() => vi.fn()); +vi.mock('../shared/ScrollableList.js', async (importOriginal) => { + const actual = + await importOriginal(); + const { createElement, forwardRef } = await import('react'); + const RealList = actual.ScrollableList as unknown as React.ComponentType< + Record + >; + const ScrollableListSpy = forwardRef( + (props: Record, ref: React.Ref) => { + scrollableListSpy(props); + return createElement(RealList, { ...props, ref }); + }, + ); + return { ...actual, ScrollableList: ScrollableListSpy }; +}); + +/** Props of the most recent ScrollableList render. */ +const latestScrollableListProps = (): { hasFocus?: boolean } => + scrollableListSpy.mock.calls.at(-1)![0] as { hasFocus?: boolean }; + +const textSelectionControllerSpy = vi.hoisted(() => vi.fn()); +vi.mock('../../selection/use-text-selection.js', () => ({ + TextSelectionController: (props: { + isActive: boolean; + eventsPaused?: boolean; + }) => { + textSelectionControllerSpy(props); + return null; + }, +})); + +// The VP path arms SGR mouse capture through ScrollableList, which takes the +// mouse away from the terminal. ContentMouseController is what hands back +// OSC 8 link clicks and the right-click menu, so pin that it is mounted. +const contentMouseControllerSpy = vi.hoisted(() => vi.fn()); +vi.mock('../../context-menu/ContentMouseController.js', () => ({ + ContentMouseController: (props: { isActive: boolean }) => { + contentMouseControllerSpy(props); + return null; + }, +})); + +const setAgentShellFocusedSpy = vi.hoisted(() => vi.fn()); +vi.mock('../../contexts/AgentViewContext.js', () => ({ + useAgentViewActions: () => ({ + setAgentShellFocused: setAgentShellFocusedSpy, + }), +})); + +const createUIState = (overrides: Partial = {}): UIState => + ({ + history: [], + historyManager: {} as UIState['historyManager'], + isThemeDialogOpen: false, + themeError: null, + auth: { + authError: null, + isAuthDialogOpen: false, + isAuthenticating: false, + pendingAuthType: undefined, + externalAuthState: null, + qwenAuthState: { + deviceAuth: null, + authStatus: 'idle', + authMessage: null, + }, + }, + isConfigInitialized: true, + editorError: null, + isEditorDialogOpen: false, + debugMessage: '', + quittingMessages: null, + isSettingsDialogOpen: false, + isStatusLineDialogOpen: false, + isMemoryDialogOpen: false, + isModelDialogOpen: false, + isFastModelMode: false, + isTrustDialogOpen: false, + activeArenaDialog: null, + isPermissionsDialogOpen: false, + isApprovalModeDialogOpen: false, + isResumeDialogOpen: false, + resumeMatchedSessions: undefined, + isDeleteDialogOpen: false, + slashCommands: [], + pendingSlashCommandHistoryItems: [], + commandContext: {} as UIState['commandContext'], + shellConfirmationRequest: null, + confirmationRequest: null, + confirmUpdateExtensionRequests: [], + providerUpdateRequest: undefined, + settingInputRequests: [], + pluginChoiceRequests: [], + loopDetectionConfirmationRequest: null, + geminiMdFileCount: 0, + streamingState: {} as UIState['streamingState'], + initError: null, + pendingGeminiHistoryItems: [], + thought: null, + shellModeActive: false, + userMessages: [], + buffer: {} as UIState['buffer'], + inputWidth: 80, + suggestionsWidth: 80, + isInputActive: true, + shouldShowIdePrompt: false, + shouldShowCommandMigrationNudge: false, + commandMigrationTomlFiles: [], + isFolderTrustDialogOpen: false, + isTrustedFolder: true, + constrainHeight: true, + ideContextState: undefined, + showToolDescriptions: false, + ctrlCPressedOnce: false, + ctrlDPressedOnce: false, + showEscapePrompt: false, + elapsedTime: 0, + currentLoadingPhrase: '', + historyRemountKey: 1, + messageQueue: [], + showAutoAcceptIndicator: {} as UIState['showAutoAcceptIndicator'], + currentModel: 'test-model', + contextFileNames: [], + availableTerminalHeight: 8, + mainAreaWidth: 76, + staticAreaMaxItemHeight: 100, + staticExtraHeight: 0, + dialogsVisible: false, + pendingHistoryItems: [], + stickyTodos: null, + btwItem: null, + setBtwItem: vi.fn(), + cancelBtw: vi.fn(), + nightly: false, + branchName: 'main', + sessionStats: { lastPromptTokenCount: 0 } as UIState['sessionStats'], + terminalWidth: 80, + terminalHeight: 24, + mainControlsRef: { current: null }, + currentIDE: null, + updateInfo: null, + showIdeRestartPrompt: false, + ideTrustRestartReason: {} as UIState['ideTrustRestartReason'], + isRestarting: false, + extensionsUpdateState: new Map(), + activePtyId: undefined, + embeddedShellFocused: false, + showWelcomeBackDialog: false, + welcomeBackInfo: null, + welcomeBackChoice: null, + isSubagentCreateDialogOpen: false, + isAgentsManagerDialogOpen: false, + isExtensionsManagerDialogOpen: false, + isMcpDialogOpen: false, + isHooksDialogOpen: false, + isFeedbackDialogOpen: false, + taskStartTokens: 0, + taskStartStreamingChars: 0, + responseCandidateTokens: 0, + streamingResponseLengthRef: { current: 0 }, + isReceivingContent: false, + sessionName: null, + setSessionName: vi.fn(), + promptSuggestion: null, + abortPromptSuggestion: vi.fn(), + isRewindSelectorOpen: false, + rewindEscPending: false, + useTerminalBuffer: true, + showScrollbar: false, + ...overrides, + }) as UIState; + +const makeMessages = (n: number): AgentMessage[] => + Array.from({ length: n }, (_, i) => ({ + role: 'user' as const, + content: `user-msg-${i}`, + timestamp: Date.now(), + })); + +const makeCore = ( + messages: AgentMessage[], + pendingApprovals: ReadonlyMap = new Map(), +) => { + const emitter = { on: vi.fn(), off: vi.fn() }; + return { + getEventEmitter: () => emitter, + getMessages: () => messages, + getPendingApprovals: () => pendingApprovals, + getLiveOutputs: () => new Map(), + getShellPids: () => new Map(), + runtimeContext: { getTargetDir: () => '' }, + modelConfig: { model: 'test-model' }, + } as never; +}; + +const makeInteractiveAgent = () => + ({ + getStatus: () => AgentStatus.COMPLETED, + getExecutionStartTimes: () => new Map(), + }) as never; + +// `allExpanded` is the app-wide Ctrl+O/Alt+T full-detail switch that +// AppContainer flips and HistoryItemDisplay consumes as `fullDetail`. +// Default it to ON so the forwarded value is observable (a dropped prop +// reads as `undefined`, not `false`). +// +// The tree is wrapped in the real ContextMenuProvider (as DefaultAppLayout +// does in the app) and `MenuProbe` captures its API so a case can open the +// menu the way ContentMouseController does on a right-click. +let menuApi: ContextMenuContextValue | null = null; +const MenuProbe = () => { + menuApi = useContextMenu(); + return null; +}; + +const contentElement = ( + uiState: UIState, + core: unknown, + { allExpanded = true }: { allExpanded?: boolean } = {}, +) => ( + + + + (), + toggle: () => {}, + }} + > + + + + + + +); + +const renderContent = ( + uiState: UIState, + core: unknown, + options?: { allExpanded?: boolean }, +) => render(contentElement(uiState, core, options)); + +const PAGE_UP = '\x1b[5~'; +const settle = () => act(async () => {}); + +// Page Ups one at a time with a settle between them. Each press moves +// roughly one viewport page, but newly revealed items shrink from their +// estimated height to their measured height as they render, so a single +// press advances fewer items than `containerHeight` — keep pressing +// until the head is guaranteed to be in view. +const pageUpToTop = async (view: { stdin: { write: (s: string) => void } }) => { + for (let i = 0; i < 20; i++) { + await act(async () => { + view.stdin.write(PAGE_UP); + }); + await settle(); + } +}; + +describe('AgentChatContent teammate-tab scrolling (#9507)', () => { + beforeEach(() => { + setAgentShellFocusedSpy.mockClear(); + textSelectionControllerSpy.mockClear(); + contentMouseControllerSpy.mockClear(); + historyItemDisplaySpy.mockClear(); + scrollableListSpy.mockClear(); + menuApi = null; + }); + + it('VP mode: Page Up scrolls the teammate transcript back to earlier output', async () => { + const messages = makeMessages(40); + const view = renderContent(createUIState(), makeCore(messages)); + await settle(); + + expect(textSelectionControllerSpy).toHaveBeenCalledWith( + expect.objectContaining({ isActive: true }), + ); + + // The viewport starts pinned to the tail, like the main view. + expect(view.lastFrame()).toContain('user-msg-39'); + // Earlier output must NOT be on screen before scrolling — this is + // what fails with the old always-`` renderer, which emits + // every line unconditionally and has no viewport to scroll. + expect(view.lastFrame()).not.toContain('user-msg-0'); + + // Page Up until the head of the transcript is back in view. + await pageUpToTop(view); + + expect(view.lastFrame()).toContain('user-msg-0'); + // Scrolled away from the tail: the newest line left the viewport. + expect(view.lastFrame()).not.toContain('user-msg-39'); + }); + + it('legacy mode: transcript still flows to terminal scrollback and Page Up hides nothing', async () => { + const messages = makeMessages(40); + const view = renderContent( + createUIState({ useTerminalBuffer: false }), + makeCore(messages), + ); + await settle(); + + // `` emits everything — native terminal scrollback works here. + expect(view.lastFrame()).toContain('user-msg-0'); + expect(view.lastFrame()).toContain('user-msg-39'); + + await pageUpToTop(view); + + // No in-app viewport exists in legacy mode; nothing may disappear. + expect(view.lastFrame()).toContain('user-msg-0'); + expect(view.lastFrame()).toContain('user-msg-39'); + }); + + it('VP mode: executing tool groups stay on the interactive (pending) render path', async () => { + // tool_call without a matching tool_result → Executing → must render + // through the pending path so confirmation dialogs remain input- + // capable inside the viewport (mirrors the ``-mode split). + const messages: AgentMessage[] = [ + ...makeMessages(20), + { + role: 'tool_call', + content: '', + timestamp: Date.now(), + metadata: { callId: 'call-1', toolName: 'run_shell_command' }, + }, + ]; + const view = renderContent(createUIState(), makeCore(messages)); + await settle(); + + // The viewport is pinned to the tail, where the executing tool group + // lives: it must render through the pending (interactive) path. + expect(view.lastFrame()).toContain('TOOLGROUP:20:pending'); + + // Scrolling away must not flip it to the committed path when it + // re-enters the window: scroll to the head and back to the tail. + await pageUpToTop(view); + expect(view.lastFrame()).toContain('user-msg-0'); + }); + + it('VP mode: forwards the Ctrl+O full-detail toggle on both render paths', async () => { + // Truncated tool rows advertise "… +N chars (ctrl+o)", so the toggle has + // to reach HistoryItemDisplay through the virtual viewport too — on the + // committed branch AND the pending branch, or the key does nothing here. + const messages: AgentMessage[] = [ + ...makeMessages(20), + { + role: 'tool_call', + content: '', + timestamp: Date.now(), + metadata: { callId: 'call-1', toolName: 'run_shell_command' }, + }, + ]; + renderContent(createUIState(), makeCore(messages)); + await settle(); + + const calls = historyItemDisplayCalls(); + expect(calls.length).toBeGreaterThan(0); + + // Both branches rendered, and neither dropped the prop. A VP path that + // forgets `fullDetail` reports `undefined` here, not `false`. + expect(calls.some((c) => c.isPending === true)).toBe(true); + expect(calls.some((c) => c.isPending === false)).toBe(true); + expect(calls.every((c) => c.fullDetail === true)).toBe(true); + }); + + it('VP mode: full-detail forwarding tracks the toggle instead of being hardcoded', async () => { + renderContent(createUIState(), makeCore(makeMessages(20)), { + allExpanded: false, + }); + await settle(); + + const calls = historyItemDisplayCalls(); + expect(calls.length).toBeGreaterThan(0); + expect(calls.every((c) => c.fullDetail === false)).toBe(true); + }); + + it('VP mode: mounts ContentMouseController so link clicks and the right-click menu survive SGR capture', async () => { + // ScrollableList arms SGR mouse tracking (bypassVpGate), which makes the + // terminal stop handling the mouse natively. Without this controller OSC 8 + // link clicks and the right-click menu silently do nothing on this tab — + // MainContent mounts it for exactly this reason. + renderContent(createUIState(), makeCore(makeMessages(40))); + await settle(); + + expect(contentMouseControllerSpy).toHaveBeenCalledWith( + expect.objectContaining({ isActive: true }), + ); + }); + + it('VP mode: an open context menu quiets the viewport and pauses selection', async () => { + // Mounting ContentMouseController makes the right-click menu openable on + // this tab for the first time, so the menu's quiescence has to come with + // it (MainContent's VP path gates both). Without it Page Up slides the + // transcript under a pinned menu — the highlight row then labels content + // the user is not looking at — and a press/drag on a menu item anchors or + // clears a transcript selection under the overlay. + renderContent(createUIState(), makeCore(makeMessages(40))); + await settle(); + + // Baseline: the viewport owns the keys and selection handling is live. + expect(latestScrollableListProps().hasFocus).toBe(true); + expect(textSelectionControllerSpy).toHaveBeenCalledWith( + expect.objectContaining({ isActive: true, eventsPaused: false }), + ); + + await act(async () => { + menuApi?.openMenu( + [{ id: 'copy-link', label: 'Copy Link', onSelect: () => {} }], + { x: 4, y: 2 }, + ); + }); + await settle(); + expect(menuApi?.menu).not.toBeNull(); + + expect(latestScrollableListProps().hasFocus).toBe(false); + // Paused, not deactivated: deactivating clears the selection the menu's + // Copy Selection offers. + expect(textSelectionControllerSpy).toHaveBeenCalledWith( + expect.objectContaining({ isActive: true, eventsPaused: true }), + ); + // The mouse controller owns the open menu's pointer interaction and its + // deactivate effect closes the menu, so it must stay active here. + expect(contentMouseControllerSpy).toHaveBeenCalledWith( + expect.objectContaining({ isActive: true }), + ); + }); + + it('VP mode: a pending teammate approval pulls the tail back and keeps the viewport quiet', async () => { + // On the VP path a teammate's pending approval is a tail item inside the + // windowed list, and `dialogsVisible` is derived from main-app dialog state + // only — a scheduler approval never reaches it. Scrolling that tail out of + // the render range unmounts the confirmation dialog together with its key + // handler, which blocks the agent round with nothing on screen to answer + // and no timeout to recover it. The legacy `` path renders pending + // items outside ``, so they cannot scroll away there. + const approvals = new Map(); + const core = makeCore(makeMessages(40), approvals); + const uiState = createUIState(); + const view = render(contentElement(uiState, core)); + await settle(); + + // Control: with nothing pending the viewport owns the scroll keys. + expect(latestScrollableListProps().hasFocus).toBe(true); + await pageUpToTop(view); + expect(view.lastFrame()).not.toContain('user-msg-39'); + + // The teammate now needs an approval while the user is scrolled up. + approvals.set('call-1', {}); + view.rerender(contentElement(uiState, core)); + await settle(); + + // Half one: the tail is pulled back into view, so the dialog is mounted + // before the keys are taken away. Without this a user who scrolled up + // BEFORE the approval arrived would be locked out of ever scrolling back. + expect(view.lastFrame()).toContain('user-msg-39'); + // Half two: the viewport goes quiet, so the tail cannot be scrolled away. + expect(latestScrollableListProps().hasFocus).toBe(false); + + await pageUpToTop(view); + expect(view.lastFrame()).toContain('user-msg-39'); + }); + + it('legacy mode: does not mount ContentMouseController (terminal still owns the mouse)', async () => { + // No SGR tracking is armed on the `` path, so the terminal handles + // clicks itself; mounting the controller here would double-handle them. + renderContent( + createUIState({ useTerminalBuffer: false }), + makeCore(makeMessages(40)), + ); + await settle(); + + expect(contentMouseControllerSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/ui/components/agent-view/AgentChatContent.tsx b/packages/cli/src/ui/components/agent-view/AgentChatContent.tsx index 26f9674926c..ed8eab0b6a5 100644 --- a/packages/cli/src/ui/components/agent-view/AgentChatContent.tsx +++ b/packages/cli/src/ui/components/agent-view/AgentChatContent.tsx @@ -24,12 +24,44 @@ import { useKeypress } from '../../hooks/useKeypress.js'; import { useAgentViewActions } from '../../contexts/AgentViewContext.js'; import { HistoryItemDisplay } from '../HistoryItemDisplay.js'; import { useThoughtExpanded } from '../../contexts/ThoughtExpandedContext.js'; -import { ToolCallStatus } from '../../types.js'; +import { ToolCallStatus, type HistoryItem } from '../../types.js'; import { theme } from '../../semantic-colors.js'; import { RespondingSpinner } from '../RespondingSpinner.js'; import { agentMessagesToHistoryItems } from './agentHistoryAdapter.js'; import { AgentHeader } from './AgentHeader.js'; import { buildThoughtHeadIdMap } from '../../utils/historyUtils.js'; +import { + ScrollableList, + SCROLL_TO_ITEM_END, + type ScrollableListRef, +} from '../shared/ScrollableList.js'; +import { TextSelectionController } from '../../selection/use-text-selection.js'; +import { ContentMouseController } from '../../context-menu/ContentMouseController.js'; +import { useContextMenu } from '../../context-menu/ContextMenuContext.js'; + +// Virtual-viewport item wrapper for the VP scroll path. `pending` preserves +// the committed/live split: items from an executing/confirming tool group +// onward render through the interactive path so approval dialogs keep +// receiving input inside the viewport (same contract as the `` path). +type AgentVpItem = + | { kind: 'header' } + | { kind: 'history'; item: HistoryItem; pending: boolean } + | { kind: 'spinner' }; + +// Pure functions with no closure deps — defined outside the component so +// they are stable references and never invalidate useMemo deps (mirrors +// MainContent's virtual-path helpers). +const agentVpEstimatedHeight = (index: number) => (index === 0 ? 4 : 3); +const agentVpKeyExtractor = (vpItem: AgentVpItem) => + vpItem.kind === 'header' + ? 'agent-header' + : vpItem.kind === 'spinner' + ? 'agent-spinner' + : // Adapter ids are index-stable across the pending→committed + // transition, so the key must not encode pending-ness. + `h-${vpItem.item.id}`; +const agentVpIsStatic = (vpItem: AgentVpItem) => + vpItem.kind === 'header' || (vpItem.kind === 'history' && !vpItem.pending); export interface AgentChatContentProps { /** The agent's AgentCore — the source of truth for transcript state. */ @@ -57,15 +89,30 @@ export const AgentChatContent = ({ }: AgentChatContentProps) => { const readonly = !interactiveAgent; const uiState = useUIState(); - const { historyRemountKey, availableTerminalHeight, constrainHeight } = - uiState; + const { + historyRemountKey, + availableTerminalHeight, + constrainHeight, + dialogsVisible, + // VP mode owns the whole screen — no terminal-native scrollback — so + // the transcript gets the same scrollable virtual viewport the main + // conversation view uses (#9507). + useTerminalBuffer: useVirtualScroll, + } = uiState; const { columns: terminalWidth } = useTerminalSize(); // Ctrl+O full-detail, matching MainContent. Thinking blocks in this view // already honored the toggle (HistoryItemDisplay reads the context itself), // but the tool side never received it — so a truncated args row could // advertise `(ctrl+o)` for a key that did nothing here. const { allExpanded: fullDetail } = useThoughtExpanded(); + // An open right-click menu owns the pointer and keyboard: the viewport goes + // quiet so scroll keys and wheel ticks don't leak into the content under it. + // The selection controller only PAUSES — deactivating clears the selection + // the menu's Copy Selection offers. The mouse controller itself stays + // ungated: it owns the open menu's pointer interaction and closes it. + const { menu: contextMenuOpen } = useContextMenu(); const contentWidth = terminalWidth - 4; + const scrollRef = useRef>(null); // Force re-render on message updates and status changes. // STREAM_TEXT is deliberately excluded — model text is shown only after @@ -153,7 +200,11 @@ export const AgentChatContent = ({ } } }, - { isActive: !readonly }, + // An open right-click menu owns the keys, and KeypressContext broadcasts + // without consuming, so this subscriber has to go quiet itself: flipping + // embedded-shell focus deactivates both controllers below, which closes + // the menu and CLEARS the selection its Copy Selection item offers. + { isActive: !readonly && contextMenuOpen === null }, ); // tickRef.current in deps ensures we rebuild when events fire even if @@ -219,6 +270,168 @@ export const AgentChatContent = ({ const agentModelId = core.modelConfig.model ?? ''; + // ── VP (Virtualized History) path ─────────────────────────────────── + // The whole transcript — header, committed items, live items, spinner — + // lives in one scrollable viewport pinned to the tail, so Page Up / + // Page Down / wheel can reach earlier output even though the app owns + // the screen. The legacy `` path below is unchanged for + // `useTerminalBuffer: false`, where terminal-native scrollback works. + const virtualItems = useMemo( + (): AgentVpItem[] => [ + { kind: 'header' }, + ...allItems.map((item, idx) => ({ + kind: 'history' as const, + item, + pending: idx >= splitIndex, + })), + ...(isRunning ? [{ kind: 'spinner' as const }] : []), + ], + [allItems, splitIndex, isRunning], + ); + + const renderVirtualItem = useCallback( + ({ item: vpItem }: { item: AgentVpItem }) => { + if (vpItem.kind === 'header') { + return ( + + ); + } + if (vpItem.kind === 'spinner') { + return ( + + + + ); + } + if (vpItem.pending) { + return ( + + ); + } + return ( + + ); + }, + [ + agentModelId, + modelName, + agentWorkingDir, + agentGitBranch, + terminalWidth, + contentWidth, + constrainHeight, + availableTerminalHeight, + readonly, + fullDetail, + activePtyId, + embeddedShellFocused, + thoughtHeadIdByItem, + ], + ); + + // A teammate's pending tool approval renders its ToolConfirmationMessage as + // a tail item INSIDE this windowed viewport, and `dialogsVisible` is derived + // from main-app dialog state only (AppContainer.tsx) — a scheduler approval + // never reaches it. On the legacy `` path below, pending items are + // rendered outside `` so they can never scroll away; the VP path has + // no such guarantee. Scrolling the tail out of `[renderRangeStart, + // renderRangeEnd]` unmounts the dialog together with its `useKeypress` + // subscription, which blocks the agent round with nothing on screen to + // answer and no timeout to recover it. + const approvalPending = pendingApprovals.size > 0; + + // Two halves, both required. Quieting the viewport alone would trap a user + // who scrolled up BEFORE the approval arrived — the dialog is already out of + // the render window and, with the scroll keys now dead, could never be + // brought back. So pull the tail into view first, then take the keys away. + const approvalScrolledRef = useRef(false); + useEffect(() => { + if (!useVirtualScroll) return; + if (!approvalPending) { + approvalScrolledRef.current = false; + return; + } + if (approvalScrolledRef.current) return; + approvalScrolledRef.current = true; + scrollRef.current?.scrollToEnd(); + }, [approvalPending, useVirtualScroll]); + + if (useVirtualScroll) { + return ( + <> + + scrollRef.current?.getViewportRect() ?? null} + getScrollState={() => + scrollRef.current?.getScrollState() ?? { + scrollTop: 0, + scrollHeight: 0, + innerHeight: 0, + } + } + hitTestScrollbar={(location) => + scrollRef.current?.hitTestScrollbar(location) ?? false + } + /> + {/* SGR mouse tracking is armed by ScrollableList above, which takes + the mouse away from the terminal. Mount the controller that gives + back what tracking costs — OSC 8 link clicks and the right-click + context menu — exactly as MainContent does on its VP path. The + menu itself is painted by the shared in + DefaultAppLayout, which sits above this tab. */} + scrollRef.current?.getViewportRect() ?? null} + hitTestScrollbar={(location) => + scrollRef.current?.hitTestScrollbar(location) ?? false + } + /> + + ); + } + return ( {/* Committed message history. diff --git a/packages/cli/src/ui/components/agent-view/AgentComposer.test.tsx b/packages/cli/src/ui/components/agent-view/AgentComposer.test.tsx index 9b442647cee..f8f19bed901 100644 --- a/packages/cli/src/ui/components/agent-view/AgentComposer.test.tsx +++ b/packages/cli/src/ui/components/agent-view/AgentComposer.test.tsx @@ -5,12 +5,18 @@ */ import { render } from 'ink-testing-library'; +import { act } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { AgentStatus, ApprovalMode } from '@qwen-code/qwen-code-core'; import { useAgentViewActions, useAgentViewState, } from '../../contexts/AgentViewContext.js'; +import { + ContextMenuProvider, + useContextMenu, + type ContextMenuContextValue, +} from '../../context-menu/ContextMenuContext.js'; import { useConfig } from '../../contexts/ConfigContext.js'; import { useAgentStreamingState } from '../../hooks/useAgentStreamingState.js'; import { useKeypress, type Key } from '../../hooks/useKeypress.js'; @@ -27,7 +33,24 @@ vi.mock('../../hooks/useKeypress.js'); vi.mock('../../hooks/usePreferredEditor.js'); vi.mock('../../hooks/useTerminalSize.js'); vi.mock('../shared/text-buffer.js'); -vi.mock('../BaseTextInput.js', () => ({ BaseTextInput: () => null })); +// Capture the props instead of dropping them: `onKeypress` is the seam where +// the composer decides whether BaseTextInput may act on a key. +const { baseTextInputProps } = vi.hoisted(() => ({ + baseTextInputProps: {} as { + onKeypress?: (key: Key) => boolean; + onSubmit?: (text: string) => void; + }, +})); +vi.mock('../BaseTextInput.js', () => ({ + BaseTextInput: (props: { + onKeypress?: (key: Key) => boolean; + onSubmit?: (text: string) => void; + }) => { + baseTextInputProps.onKeypress = props.onKeypress; + baseTextInputProps.onSubmit = props.onSubmit; + return null; + }, +})); vi.mock('../LoadingIndicator.js', () => ({ LoadingIndicator: () => null })); vi.mock('../QueuedMessageDisplay.js', () => ({ QueuedMessageDisplay: () => null, @@ -36,15 +59,38 @@ vi.mock('./AgentFooter.js', () => ({ AgentFooter: () => null })); type KeypressHandler = (key: Key) => void; +// `AgentComposer` calls useKeypress twice per render, in a fixed order: +// [0] Escape-to-cancel, [1] Shift+Tab approval-mode cycler. +const ESCAPE_CANCEL = 0; +const SHIFT_TAB_CYCLE = 1; + +const MenuProbe = () => { + menuApi = useContextMenu(); + return null; +}; +let menuApi: ContextMenuContextValue | null = null; + +const renderWithMenu = () => + render( + + + + , + ); + describe('AgentComposer', () => { const setAgentInputBufferText = vi.fn(); + const setAgentComposerLayoutKey = vi.fn(); const setAgentTabBarFocused = vi.fn(); const setAgentApprovalMode = vi.fn(); let capturedKeypressHandlers: KeypressHandler[]; + let capturedKeypressOptions: Array<{ isActive: boolean }>; beforeEach(() => { vi.clearAllMocks(); + menuApi = null; capturedKeypressHandlers = []; + capturedKeypressOptions = []; vi.mocked(useAgentViewState).mockReturnValue({ activeView: 'agent-1', @@ -71,6 +117,7 @@ describe('AgentComposer', () => { } as never); vi.mocked(useAgentViewActions).mockReturnValue({ setAgentInputBufferText, + setAgentComposerLayoutKey, setAgentTabBarFocused, setAgentApprovalMode, setAgentMessageQueue: vi.fn(), @@ -81,8 +128,9 @@ describe('AgentComposer', () => { vi.mocked(usePreferredEditor).mockReturnValue(undefined); vi.mocked(useTerminalSize).mockReturnValue({ columns: 80, rows: 24 }); vi.mocked(useKeypress).mockImplementation( - (handler: KeypressHandler, _options) => { + (handler: KeypressHandler, options: { isActive: boolean }) => { capturedKeypressHandlers.push(handler); + capturedKeypressOptions.push(options); }, ); vi.mocked(useAgentStreamingState).mockReturnValue({ @@ -110,6 +158,33 @@ describe('AgentComposer', () => { expect(setAgentInputBufferText).not.toHaveBeenCalled(); }); + it('syncs the footer layout key and updates it when the agent completes', () => { + // AppContainer's controls-height measure effect depends on this key; it + // is the only re-measure trigger for the agent footer (#9507). + const { rerender } = render(); + + expect(setAgentComposerLayoutKey).toHaveBeenCalled(); + const initialKey = String(setAgentComposerLayoutKey.mock.calls.at(-1)?.[0]); + // The input text is part of the key (input wrapping shifts the height). + expect(initialKey).toContain('draft'); + + vi.mocked(useAgentStreamingState).mockReturnValue({ + status: AgentStatus.COMPLETED, + streamingState: StreamingState.Idle, + isInputActive: false, + elapsedTime: 0, + lastPromptTokenCount: 0, + } as never); + rerender(); + + // The terminal-status row appeared, so the key must change to trigger a + // re-measure of the grown footer. + const completedKey = String( + setAgentComposerLayoutKey.mock.calls.at(-1)?.[0], + ); + expect(completedKey).not.toBe(initialKey); + }); + // The second useKeypress call is the Shift+Tab approval-mode cycler. const getShiftTabHandler = (): KeypressHandler => { render(); @@ -134,4 +209,83 @@ describe('AgentComposer', () => { expect(setAgentApprovalMode).not.toHaveBeenCalled(); }); + + // The real useKeypress hook only subscribes while `isActive` is true, so the + // registration from the most recent render is the gate in force right now. + const latestOptions = (callSite: number): { isActive: boolean } => + capturedKeypressOptions[capturedKeypressOptions.length - 2 + callSite]!; + + it('releases same-chunk keys only after input dismisses the menu', async () => { + renderWithMenu(); + const items = [{ id: 'open-link', label: 'Open Link', onSelect: vi.fn() }]; + const open = () => + act(async () => { + menuApi?.openMenu(items, { x: 0, y: 0 }); + }); + await open(); + + await act(async () => { + // Hold the same callback: no render may occur between these keys. + const handleKey = baseTextInputProps.onKeypress!; + expect(handleKey({ name: 'a', sequence: 'a' } as Key)).toBe(false); + expect(handleKey({ name: 'return', sequence: '\r' } as Key)).toBe(false); + }); + + await open(); + await act(async () => { + const handleKey = baseTextInputProps.onKeypress!; + // A fresh menu owns Enter even after the previous menu was dismissed. + expect(handleKey({ name: 'return', sequence: '\r' } as Key)).toBe(true); + menuApi?.executeIndex(0); + // Even if the overlay executes first, the same Enter cannot submit. + expect(handleKey({ name: 'return', sequence: '\r' } as Key)).toBe(true); + }); + expect(items[0]!.onSelect).toHaveBeenCalledOnce(); + }); + + it('goes quiet while the right-click context menu is open', async () => { + // AgentChatContent mounts ContentMouseController on the teammate tab, so + // this composer is now reachable with the menu open. KeypressContext + // broadcasts to every subscriber and discards return values (the overlay + // cannot consume a key for us), so each consumer has to gate itself or a + // key aimed at the menu also cancels the round and steals tab-bar focus. + vi.mocked(useAgentStreamingState).mockReturnValue({ + status: AgentStatus.IDLE, + streamingState: StreamingState.Responding, + isInputActive: false, + elapsedTime: 0, + lastPromptTokenCount: 0, + } as never); + + renderWithMenu(); + + // Control: with no menu open the composer still owns its keys. + expect(latestOptions(ESCAPE_CANCEL).isActive).toBe(true); + expect(latestOptions(SHIFT_TAB_CYCLE).isActive).toBe(true); + expect(baseTextInputProps.onKeypress?.({ name: 'down' } as Key)).toBe(true); + expect(setAgentTabBarFocused).toHaveBeenCalledWith(true); + + setAgentTabBarFocused.mockClear(); + await act(async () => { + menuApi?.openMenu( + [{ id: 'open-link', label: 'Open Link', onSelect: () => {} }], + { x: 4, y: 2 }, + ); + }); + expect(menuApi?.menu).not.toBeNull(); + + // Escape no longer reaches the cancel-round handler at all... + expect(latestOptions(ESCAPE_CANCEL).isActive).toBe(false); + // ...and neither does Shift+Tab reach the approval-mode cycler, which + // writes straight through to the agent runtime's tool-scheduling policy: + // a key aimed at the menu must not silently change the approval mode of + // the teammate the user is deciding about. + expect(latestOptions(SHIFT_TAB_CYCLE).isActive).toBe(false); + // ...and BaseTextInput never sees the menu's navigation keys, so the draft + // survives and the tab bar keeps its focus. + for (const name of ['up', 'down', 'return', 'escape']) { + expect(baseTextInputProps.onKeypress?.({ name } as Key)).toBe(true); + } + expect(setAgentTabBarFocused).not.toHaveBeenCalled(); + }); }); diff --git a/packages/cli/src/ui/components/agent-view/AgentComposer.tsx b/packages/cli/src/ui/components/agent-view/AgentComposer.tsx index fe72a059b88..fc9bacb9ae4 100644 --- a/packages/cli/src/ui/components/agent-view/AgentComposer.tsx +++ b/packages/cli/src/ui/components/agent-view/AgentComposer.tsx @@ -18,7 +18,7 @@ */ import { Box, Text, useStdin } from 'ink'; -import { useCallback, useEffect, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; import { AgentStatus } from '@qwen-code/qwen-code-core/agents/runtime/agent-types.js'; import { ApprovalMode, @@ -34,6 +34,7 @@ import { StreamingState } from '../../types.js'; import { useTerminalSize } from '../../hooks/useTerminalSize.js'; import { useAgentStreamingState } from '../../hooks/useAgentStreamingState.js'; import { useKeypress, type Key } from '../../hooks/useKeypress.js'; +import { useContextMenu } from '../../context-menu/ContextMenuContext.js'; import { useTextBuffer } from '../shared/text-buffer.js'; import { calculatePromptWidths } from '../../utils/layoutUtils.js'; import { BaseTextInput } from '../BaseTextInput.js'; @@ -52,6 +53,31 @@ interface AgentComposerProps { agentId: string; } +// ─── Layout key ───────────────────────────────────────────── + +/** + * Build the layout key AppContainer's controls-height measure effect depends + * on to re-measure the agent footer. The footer's height shifts with the + * agent's streaming state (LoadingIndicator row), terminal status row + * (Completed/Failed/Cancelled), queued-message display, and input text + * wrapping — none of which AppContainer can observe through its own state. + * Syncing this key to AgentViewContext is what triggers the re-measure; + * mirrors getLiveAgentPanelLayoutKey for the LiveAgentPanel. + */ +export function getAgentComposerLayoutKey(parts: { + streamingState: StreamingState; + statusLabel: string; + queuedMessageCount: number; + inputText: string; +}): string { + return [ + parts.streamingState, + parts.statusLabel, + parts.queuedMessageCount, + parts.inputText, + ].join('|'); +} + // ─── Component ────────────────────────────────────────────── // Shared empty queue identity so unregistered agents don't allocate on @@ -68,6 +94,7 @@ export const AgentComposer: React.FC = ({ agentId }) => { } = useAgentViewState(); const { setAgentInputBufferText, + setAgentComposerLayoutKey, setAgentTabBarFocused, setAgentApprovalMode, appendToAgentMessageQueue, @@ -89,6 +116,14 @@ export const AgentComposer: React.FC = ({ agentId }) => { lastPromptTokenCount, } = useAgentStreamingState(interactiveAgent); + // An open right-click context menu owns the keyboard while it is up + // (mounted on this tab by AgentChatContent's ContentMouseController). + // KeypressContext broadcasts to every subscriber and discards return + // values, so the menu cannot consume a key for us — each consumer has to go + // quiet itself, exactly as InputPrompt does for the main view. + const { menu: contextMenu, closeMenu } = useContextMenu(); + const inputDismissedMenuRef = useRef(null); + // ── Escape to cancel the active agent round ── useKeypress( @@ -102,7 +137,9 @@ export const AgentComposer: React.FC = ({ agentId }) => { }, { isActive: - streamingState === StreamingState.Responding && !agentShellFocused, + streamingState === StreamingState.Responding && + !agentShellFocused && + contextMenu === null, }, ); @@ -126,7 +163,12 @@ export const AgentComposer: React.FC = ({ agentId }) => { setAgentApprovalMode(agentId, APPROVAL_MODES[nextIndex]!); } }, - { isActive: !agentShellFocused }, + // Same broadcast rule as the Escape subscriber above: an open right-click + // menu cannot consume a key for us, so this subscriber has to go quiet + // itself. This one writes straight through to the agent runtime's + // tool-scheduling policy, so a Shift+Tab aimed at the menu would silently + // change the approval mode of the very teammate being decided about. + { isActive: !agentShellFocused && contextMenu === null }, ); // ── Input buffer (independent from main agent) ── @@ -164,6 +206,29 @@ export const AgentComposer: React.FC = ({ agentId }) => { const handleKeypress = useCallback( (key: Key): boolean => { + // While the right-click context menu is open it owns the navigation + // keys (the overlay handles them); any other key closes the menu and is + // then processed normally, mirroring click-away dismissal. The + // dismissing key must fall through rather than be swallowed, since + // BaseTextInput still owns every non-navigation key. + if ( + contextMenu !== null && + inputDismissedMenuRef.current !== contextMenu + ) { + if ( + key.name === 'up' || + key.name === 'down' || + key.name === 'return' || + key.name === 'escape' + ) { + return true; + } + // Only input dismissal releases subsequent keys before a render. + // Menu activation must not also submit the draft on that same Enter. + inputDismissedMenuRef.current = contextMenu; + closeMenu(); + } + // When tab bar has focus, block all non-printable keys so they don't // act on the hidden buffer. Printable characters fall through to // BaseTextInput naturally; the tab bar handler releases focus on the @@ -193,7 +258,7 @@ export const AgentComposer: React.FC = ({ agentId }) => { } return false; }, - [buffer, agentTabBarFocused, setAgentTabBarFocused], + [buffer, agentTabBarFocused, setAgentTabBarFocused, contextMenu, closeMenu], ); // ── Message queue display ── @@ -242,6 +307,22 @@ export const AgentComposer: React.FC = ({ agentId }) => { } }, [status, interactiveAgent]); + // Sync a layout key that changes whenever this footer's height can change + // (loading row toggling, status row appearing, queued messages growing, + // input wrapping). AppContainer's controls-height measure effect depends on + // it via AgentViewContext; without the sync, controlsHeight stays + // stale-high after the footer grows and the transcript viewport pushes the + // composer and tab bar past the terminal bottom (#9507). + const composerLayoutKey = getAgentComposerLayoutKey({ + streamingState, + statusLabel: statusLabel?.text ?? '', + queuedMessageCount: messageQueue.length, + inputText: buffer.text, + }); + useEffect(() => { + setAgentComposerLayoutKey(composerLayoutKey); + }, [composerLayoutKey, setAgentComposerLayoutKey]); + // ── Approval-mode styling (mirrors main InputPrompt) ── const approvalModePromptStyle = getApprovalModePromptStyle(agentApprovalMode); diff --git a/packages/cli/src/ui/components/agent-view/AgentTabBar.test.tsx b/packages/cli/src/ui/components/agent-view/AgentTabBar.test.tsx index 7abfaa41835..fffb4a7979a 100644 --- a/packages/cli/src/ui/components/agent-view/AgentTabBar.test.tsx +++ b/packages/cli/src/ui/components/agent-view/AgentTabBar.test.tsx @@ -5,9 +5,15 @@ */ import { render } from 'ink-testing-library'; +import { act } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { AgentStatus } from '@qwen-code/qwen-code-core'; import type { KeypressHandler, Key } from '../../contexts/KeypressContext.js'; +import { + ContextMenuProvider, + useContextMenu, + type ContextMenuContextValue, +} from '../../context-menu/ContextMenuContext.js'; import { useKeypress } from '../../hooks/useKeypress.js'; import { useAgentViewActions, @@ -26,6 +32,15 @@ vi.mock('../../contexts/BackgroundTaskViewContext.js'); vi.mock('../../contexts/UIStateContext.js'); let activeKeypressHandler: KeypressHandler | null = null; +let latestKeypressActive = false; +// Module scope (as in AgentComposer.test) so TS does not narrow the probe's +// assignment away at the assertion site. +let menuApi: ContextMenuContextValue | null = null; + +const MenuProbe = () => { + menuApi = useContextMenu(); + return null; +}; const createKey = (overrides: Partial): Key => ({ name: '', @@ -73,11 +88,15 @@ describe('AgentTabBar', () => { beforeEach(() => { vi.clearAllMocks(); activeKeypressHandler = null; + latestKeypressActive = false; + menuApi = null; vi.mocked(useKeypress).mockImplementation((handler, { isActive }) => { - if (isActive) { - activeKeypressHandler = handler; - } + // Mirror the real hook: it subscribes only while `isActive`, so a + // deactivated call site must stop answering keys rather than keep the + // last registered handler alive. + latestKeypressActive = isActive; + activeKeypressHandler = isActive ? handler : null; }); setActiveView('agent-1'); vi.mocked(useAgentViewActions).mockReturnValue({ @@ -196,4 +215,36 @@ describe('AgentTabBar', () => { expect(setLivePanelFocused).not.toHaveBeenCalled(); expect(setAgentTabBarFocused).not.toHaveBeenCalled(); }); + + it('goes quiet while the right-click context menu is open', async () => { + // AgentChatContent mounts ContentMouseController on the teammate tab, so + // this bar is now reachable with the menu open. KeypressContext broadcasts + // to every subscriber and discards return values, so the overlay cannot + // consume a key for us: ungated, ←/→ still switch tabs (remounting the + // active tab away from a teammate awaiting approval) and ↑/↓ still release + // tab-bar focus, which nothing restores after the menu closes. + render( + + + + , + ); + + // Control: with no menu open the bar owns the arrows. + expect(latestKeypressActive).toBe(true); + pressKey({ name: 'up', sequence: '[A' }); + expect(setAgentTabBarFocused).toHaveBeenCalledWith(false); + + await act(async () => { + menuApi?.openMenu( + [{ id: 'open-link', label: 'Open Link', onSelect: () => {} }], + { x: 4, y: 2 }, + ); + }); + expect(menuApi?.menu).not.toBeNull(); + + // The subscription is gone, so the menu's navigation keys cannot reach it. + expect(latestKeypressActive).toBe(false); + expect(activeKeypressHandler).toBeNull(); + }); }); diff --git a/packages/cli/src/ui/components/agent-view/AgentTabBar.tsx b/packages/cli/src/ui/components/agent-view/AgentTabBar.tsx index ae54bba8520..86da2029b78 100644 --- a/packages/cli/src/ui/components/agent-view/AgentTabBar.tsx +++ b/packages/cli/src/ui/components/agent-view/AgentTabBar.tsx @@ -37,6 +37,7 @@ import { } from '../../contexts/BackgroundTaskViewContext.js'; import { useKeypress } from '../../hooks/useKeypress.js'; import { useUIState } from '../../contexts/UIStateContext.js'; +import { useContextMenu } from '../../context-menu/ContextMenuContext.js'; import { theme } from '../../semantic-colors.js'; import { isLiveAgentPanelVisibleEntry } from '../background-view/liveAgentPanelVisibility.js'; @@ -75,6 +76,12 @@ export const AgentTabBar: React.FC = () => { const { setLivePanelFocused, setPillFocused } = useBackgroundTaskViewActions(); const { embeddedShellFocused } = useUIState(); + // An open right-click menu owns the arrow keys. KeypressContext broadcasts to + // every subscriber and discards return values, so the overlay cannot consume + // a key for us — without this the tab bar keeps switching tabs (remounting + // the active tab, away from a teammate awaiting approval) and keeps releasing + // its own focus under the menu, and nothing restores it afterwards. + const { menu: contextMenu } = useContextMenu(); const hasVisibleBgAgentRoster = () => bgEntries.some((e) => isLiveAgentPanelVisibleEntry(e, Date.now())); @@ -118,7 +125,7 @@ export const AgentTabBar: React.FC = () => { setAgentTabBarFocused(false); } }, - { isActive: true }, + { isActive: contextMenu === null }, ); // Subscribe to STATUS_CHANGE events from all agents so the tab bar diff --git a/packages/cli/src/ui/components/agent-view/agentComposerLayout.measurement.test.tsx b/packages/cli/src/ui/components/agent-view/agentComposerLayout.measurement.test.tsx new file mode 100644 index 00000000000..4635445b79d --- /dev/null +++ b/packages/cli/src/ui/components/agent-view/agentComposerLayout.measurement.test.tsx @@ -0,0 +1,203 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Runtime reproduction of the agent-tab stale-viewport root cause (#9507). + * + * On an agent tab AppContainer sizes the transcript viewport from + * availableTerminalHeight = terminalHeight - controlsHeight - tabBarHeight + * where `controlsHeight` is measured from the controls box (which contains + * AgentComposer) inside a useLayoutEffect gated by a dependency array. The + * agent footer grows when the agent reaches a terminal status (the + * Completed/Failed row appears) or messages are queued while it streams, but + * those changes live inside AgentComposer (agent events / local queue state) + * and never touch AppContainer's own deps. So unless the composer-synced + * layout key is part of the deps, the effect does not re-run: controlsHeight + * stays stale-high and — once the transcript fills the viewport — the column + * (viewport + grown composer + tab bar) lands past the terminal's last row. + * + * This test mirrors that measurement contract on a minimal component — real + * ink render, real measureElement, real getAgentComposerLayoutKey — and shows + * that WITHOUT the key in the deps a growing footer leaves the available + * height stale, while WITH it the re-measure shrinks the reserved room by + * exactly the growth. A real AppContainer cannot be exercised here because + * ink-testing-library's rerender remounts it (re-running every mount effect + * regardless of deps); see app-container-controls-dep.test.ts for the + * source-level guard on the actual deps array. + */ + +import { describe, it, expect } from 'vitest'; +import { useLayoutEffect, useRef, useState } from 'react'; +import { render } from 'ink-testing-library'; +import { Box, Text, measureElement, type DOMElement } from 'ink'; +import { StreamingState } from '../../types.js'; +import { getAgentComposerLayoutKey } from './AgentComposer.js'; + +const TERMINAL_HEIGHT = 24; +// The agent tab always renders a 1-row tab bar under the controls box; +// AppContainer subtracts it separately from availableTerminalHeight. +const TAB_BAR_HEIGHT = 1; + +interface FooterShape { + streamingState: StreamingState; + statusLabel: string; + queuedMessageCount: number; + inputText: string; +} + +/** Rows the stand-in renders for the queued-message display. */ +function queuedRows(count: number): number { + if (count === 0) return 0; + // 1 margin row + up to 3 previews + a "more" row, as in QueuedMessageDisplay + return 1 + Math.min(count, 3) + (count > 3 ? 1 : 0); +} + +/** + * Minimal stand-in for AppContainer's footer-measurement contract on the + * agent tab. The controls box renders one row per height-shifting part of + * AgentComposer (loading row, terminal status row, queued-message rows, + * input line, footer row), measures itself into `controlsHeight` via a + * useLayoutEffect, and reports the resulting available height. + * `wireComposerKeyDep` toggles whether the composer layout key is part of + * the effect deps — i.e. buggy vs fixed. + */ +function AgentFooterMeasured({ + shape, + wireComposerKeyDep, + report, +}: { + shape: FooterShape; + wireComposerKeyDep: boolean; + report: (availableHeight: number) => void; +}) { + const ref = useRef(null); + const [controlsHeight, setControlsHeight] = useState(0); + + const composerKey = getAgentComposerLayoutKey(shape); + const deps = wireComposerKeyDep ? [composerKey] : []; + + useLayoutEffect(() => { + if (!ref.current) return; + const { height } = measureElement(ref.current); + setControlsHeight((prev) => (prev === height ? prev : height)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); + + report(Math.max(0, TERMINAL_HEIGHT - controlsHeight - TAB_BAR_HEIGHT)); + + return ( + + {shape.streamingState !== StreamingState.Idle && Thinking…} + {shape.statusLabel !== '' && {shape.statusLabel}} + {Array.from({ length: queuedRows(shape.queuedMessageCount) }, (_, i) => ( + queued + ))} + {shape.inputText} + footer + + ); +} + +async function availableAfterGrowth( + wireComposerKeyDep: boolean, +): Promise<{ before: number; after: number }> { + let availableHeight = -1; + const report = (v: number) => { + availableHeight = v; + }; + + const streamingShape: FooterShape = { + streamingState: StreamingState.Responding, + statusLabel: '', + queuedMessageCount: 0, + inputText: 'hi', + }; + + const { rerender, unmount } = render( + , + ); + await new Promise((r) => setTimeout(r, 20)); + const before = availableHeight; + + // The agent finishes (Completed status row appears) and two messages were + // queued while it streamed: the footer grows by 1 + queuedRows(2) rows. + rerender( + , + ); + await new Promise((r) => setTimeout(r, 20)); + const after = availableHeight; + unmount(); + return { before, after }; +} + +describe('agent composer footer re-measurement (#9507)', () => { + it('leaves the available height stale without the composer key dep', async () => { + const { before, after } = await availableAfterGrowth(false); + // The footer objectively grew, but the measurement effect never + // re-runs: the reserved room stays overestimated by exactly the growth. + expect(after).toBe(before); + }); + + it('re-measures and shrinks the reserved room with the composer key dep', async () => { + const growth = 1 + queuedRows(2); // Completed row + queued display + const { before, after } = await availableAfterGrowth(true); + expect(after).toBe(before - growth); + }); +}); + +describe('getAgentComposerLayoutKey', () => { + const base: FooterShape = { + streamingState: StreamingState.Idle, + statusLabel: '', + queuedMessageCount: 0, + inputText: '', + }; + + it('is stable for unchanged footer state', () => { + expect(getAgentComposerLayoutKey(base)).toBe( + getAgentComposerLayoutKey({ ...base }), + ); + }); + + it('changes when the terminal status row appears', () => { + expect( + getAgentComposerLayoutKey({ ...base, statusLabel: 'Completed' }), + ).not.toBe(getAgentComposerLayoutKey(base)); + }); + + it('changes when messages are queued', () => { + expect( + getAgentComposerLayoutKey({ ...base, queuedMessageCount: 2 }), + ).not.toBe(getAgentComposerLayoutKey(base)); + }); + + it('changes when the input text grows', () => { + expect(getAgentComposerLayoutKey({ ...base, inputText: 'hello' })).not.toBe( + getAgentComposerLayoutKey(base), + ); + }); + + it('changes when the loading row toggles (streaming state)', () => { + expect( + getAgentComposerLayoutKey({ + ...base, + streamingState: StreamingState.Responding, + }), + ).not.toBe(getAgentComposerLayoutKey(base)); + }); +}); diff --git a/packages/cli/src/ui/components/messages/AskUserQuestionDialog.tsx b/packages/cli/src/ui/components/messages/AskUserQuestionDialog.tsx index d15ea39fb73..a64c93f1051 100644 --- a/packages/cli/src/ui/components/messages/AskUserQuestionDialog.tsx +++ b/packages/cli/src/ui/components/messages/AskUserQuestionDialog.tsx @@ -561,7 +561,7 @@ export const AskUserQuestionDialog: React.FC = ({ }} onSubmit={handleCustomInputSubmit} placeholder={t('Type something...')} - isActive={true} + isActive={isFocused} inputWidth={50} /> diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx index 0ec4a61adfd..6ab31cfea82 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx @@ -45,8 +45,27 @@ import { withProviders, } from '../../../test-utils/render.js'; import type { LoadedSettings } from '../../../config/settings.js'; +import { render } from 'ink-testing-library'; +import { act, useEffect } from 'react'; +import { useKeypressContext } from '../../contexts/KeypressContext.js'; +import { useKeypress } from '../../hooks/useKeypress.js'; +import { ContextMenuOverlay } from '../../context-menu/ContextMenuOverlay.js'; +import { + ContextMenuProvider, + useContextMenu, + type ContextMenuContextValue, +} from '../../context-menu/ContextMenuContext.js'; describe('ToolConfirmationMessage', () => { + // The tree is wrapped in the real ContextMenuProvider (as DefaultAppLayout + // does) and `MenuProbe` captures its API so a case can open the menu the way + // ContentMouseController does on a right-click. + let menuApi: ContextMenuContextValue | null = null; + const MenuProbe = () => { + menuApi = useContextMenu(); + return null; + }; + const mockConfig = { isTrustedFolder: () => true, getIdeMode: () => false, @@ -1380,4 +1399,225 @@ describe('ToolConfirmationMessage', () => { expect(frame).toMatch(/\.{3} last \d+ lines hidden \.{3}/); }); }); + + it.each([true, false])( + 'handles a right-click and keys in one stdin chunk (menu items: %s)', + async (hasItems) => { + const onConfirm = vi.fn().mockResolvedValue(undefined); + const onMenuSelect = vi.fn(); + const onUnderlyingKey = vi.fn(); + const onMenuChange = vi.fn(); + const OpenOnRightPress = () => { + const { subscribeMouse, unsubscribeMouse } = useKeypressContext(); + const { menu, openMenu } = useContextMenu(); + useKeypress(onUnderlyingKey, { isActive: menu === null }); + useEffect(() => { + const handler = () => { + openMenu( + hasItems + ? [ + { + id: 'open-link', + label: 'Open Link', + onSelect: onMenuSelect, + }, + ] + : [], + { x: 0, y: 0 }, + ); + // AppContainer is outside the provider and relies on this callback. + expect(onMenuChange).toHaveBeenLastCalledWith(hasItems); + }; + subscribeMouse(handler); + return () => unsubscribeMouse(handler); + }, [subscribeMouse, unsubscribeMouse, openMenu]); + return null; + }; + const { stdin } = render( + withProviders( + + + + + , + ), + ); + await act(async () => {}); + await act(async () => { + stdin.write('\x1b[<2;10;5M\x1b[Z\x1b[C\r'); + }); + if (hasItems) { + expect(onUnderlyingKey).not.toHaveBeenCalled(); + expect(onConfirm).not.toHaveBeenCalled(); + expect(onMenuSelect).not.toHaveBeenCalled(); + // Once rendered, Enter belongs to the menu, then to the dialog again. + await act(async () => { + stdin.write('\r'); + }); + expect(onMenuSelect).toHaveBeenCalledTimes(1); + expect(onConfirm).not.toHaveBeenCalled(); + await act(async () => { + stdin.write('\r'); + }); + } else { + expect(onUnderlyingKey).toHaveBeenCalled(); + } + await vi.waitFor(() => + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + ), + ); + }, + ); + + it('does not act on a key aimed at an open context menu', async () => { + // The teammate tab mounts this dialog while AgentChatContent's + // ContentMouseController makes the right-click menu openable there. + // KeypressContext broadcasts to every subscriber and discards return + // values (the overlay cannot consume a key for us), so the dialog has to + // go quiet itself — RadioButtonSelect's initialIndex is 0 with + // "allow once" first, so one Enter aimed at "Open Link" would otherwise + // approve the pending call and Esc would silently deny it. + const onConfirm = vi.fn().mockResolvedValue(undefined); + const confirmationDetails: ToolCallConfirmationDetails = { + type: 'exec', + title: 'Confirm Execution', + command: 'touch /tmp/marker', + rootCommand: 'touch', + onConfirm, + }; + + const { stdin } = render( + withProviders( + + + + , + ), + ); + + // Control: with no menu open, Enter still approves the pending call. + stdin.write('\r'); + await vi.waitFor(() => + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + ), + ); + onConfirm.mockClear(); + + await act(async () => { + menuApi?.openMenu( + [{ id: 'open-link', label: 'Open Link', onSelect: () => {} }], + { x: 4, y: 2 }, + ); + }); + expect(menuApi?.menu).not.toBeNull(); + + await act(async () => { + stdin.write('\r'); // would approve (ProceedOnce is index 0) + stdin.write('\x1b'); // would deny (handleConfirm(Cancel)) + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + expect(onConfirm).not.toHaveBeenCalled(); + }); + + it('does not submit a nested custom answer aimed at an open context menu', async () => { + // The `ask_user_question` flavour nests a free-text TextInput one level + // below the dialog's own subscriber. Quieting only the dialog left that + // input hot, so one Enter aimed at "Open Link" submitted the half-typed + // draft as the answer and the teammate round proceeded on it. + const onConfirm = vi.fn().mockResolvedValue(undefined); + const confirmationDetails: ToolCallConfirmationDetails = { + type: 'ask_user_question', + title: 'Question', + questions: [ + { + question: 'What is your favorite color?', + header: 'Color', + options: [ + { label: 'Red', description: 'A warm color' }, + { label: 'Blue', description: 'A cool color' }, + { label: 'Green', description: '' }, + ], + multiSelect: false, + }, + ], + onConfirm, + }; + + const { stdin } = render( + withProviders( + + + + , + ), + ); + + const settle = () => + act(async () => { + await new Promise((resolve) => setTimeout(resolve, 60)); + }); + + // Row 4 is the custom answer; typing into it mounts the nested input. + await settle(); + await act(async () => { + stdin.write('4'); + }); + await settle(); + await act(async () => { + stdin.write('draft'); + }); + await settle(); + + // Control: with no menu open, Enter submits exactly that draft — which is + // also the proof the nested input is mounted and live. + await act(async () => { + stdin.write('\r'); + }); + await vi.waitFor(() => + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { 0: 'draft' } }, + ), + ); + onConfirm.mockClear(); + + await act(async () => { + menuApi?.openMenu( + [{ id: 'open-link', label: 'Open Link', onSelect: () => {} }], + { x: 4, y: 2 }, + ); + }); + expect(menuApi?.menu).not.toBeNull(); + + await act(async () => { + stdin.write('\r'); // aimed at "Open Link", not at the draft + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + expect(onConfirm).not.toHaveBeenCalled(); + }); }); diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx index a88331f7762..34b47dcde1c 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx @@ -29,6 +29,7 @@ import type { RadioSelectItem } from '../shared/RadioButtonSelect.js'; import { RadioButtonSelect } from '../shared/RadioButtonSelect.js'; import { MaxSizedBox, MINIMUM_MAX_HEIGHT } from '../shared/MaxSizedBox.js'; import { useKeypress } from '../../hooks/useKeypress.js'; +import { useContextMenu } from '../../context-menu/ContextMenuContext.js'; import { useLaunchEditor } from '../../hooks/useLaunchEditor.js'; import { useSettings } from '../../contexts/SettingsContext.js'; import { theme } from '../../semantic-colors.js'; @@ -68,6 +69,15 @@ export const ToolConfirmationMessage: React.FC< 'hideAlwaysAllow' in confirmationDetails && confirmationDetails.hideAlwaysAllow === true; + // An open right-click context menu owns the keyboard while it is up. The + // dispatch in KeypressContext is a broadcast that discards return values, + // so an overlay cannot consume a key for the dialog — the dialog has to go + // quiet itself, or one Enter aimed at "Open Link" also approves the + // pending tool call. Mirrors the main view's InputPrompt gate; the + // teammate tab renders this same dialog (via HistoryItemDisplay). + const { menu: contextMenu } = useContextMenu(); + const inputActive = isFocused && contextMenu === null; + const settings = useSettings(); const preferredEditor = settings.merged.general?.preferredEditor as | EditorType @@ -165,7 +175,7 @@ export const ToolConfirmationMessage: React.FC< useKeypress( (key) => { - if (!isFocused) return; + if (!inputActive) return; if (key.name === 'escape' || (key.ctrl && key.name === 'c')) { handleConfirm(ToolConfirmationOutcome.Cancel); return; @@ -179,7 +189,7 @@ export const ToolConfirmationMessage: React.FC< openFullPlanInEditor(); } }, - { isActive: isFocused }, + { isActive: inputActive }, ); const handleSelect = (item: ToolConfirmationOutcome) => handleConfirm(item); @@ -643,7 +653,7 @@ export const ToolConfirmationMessage: React.FC< return ( @@ -811,7 +821,7 @@ export const ToolConfirmationMessage: React.FC< diff --git a/packages/cli/src/ui/components/shared/ScrollableList.test.tsx b/packages/cli/src/ui/components/shared/ScrollableList.test.tsx index e034e4aca26..3b7a2954154 100644 --- a/packages/cli/src/ui/components/shared/ScrollableList.test.tsx +++ b/packages/cli/src/ui/components/shared/ScrollableList.test.tsx @@ -324,6 +324,45 @@ describe(' mouse scrolling', () => { expect(lastFrame()).toContain('item-0'); }); + it('a focus loss cancels a still-pending wheel flush', async () => { + // Regression: whoever takes focus may have just pulled the tail into view + // (a pending approval dialog at the end of the transcript). A burst armed + // before that flip must not land after it — with both subscribers now off, + // it scrolls the answering surface out of the render window and nothing on + // this list can bring it back. + const renderItem = ({ item }: { item: Item }) => {item.label}; + const Wrapper = ({ focused }: { focused: boolean }) => ( + + hasFocus={focused} + data={makeItems(50)} + renderItem={renderItem} + estimatedItemHeight={estimatedItemHeight} + keyExtractor={keyExtractor} + initialScrollIndex={0} + containerHeight={5} + width={40} + showScrollbar={false} + /> + ); + + const { stdin, lastFrame, rerender } = render( + withKeypress(), + ); + rerender(withKeypress()); + await act(async () => {}); + expect(lastFrame()).toContain('item-0'); + + // Queue a burst, then take focus away inside the same coalescing window. + await act(async () => { + stdin.write(wheelDown(5, 5)); + stdin.write(wheelDown(5, 5)); + rerender(withKeypress()); + }); + await flushScrollFrame(); + + expect(lastFrame()).toContain('item-0'); + }); + it('does not start a scrollbar drag when content fits the viewport', async () => { const renderItem = ({ item }: { item: Item }) => {item.label}; const Wrapper = () => ( diff --git a/packages/cli/src/ui/components/shared/ScrollableList.tsx b/packages/cli/src/ui/components/shared/ScrollableList.tsx index ec160ab3186..e883db56294 100644 --- a/packages/cli/src/ui/components/shared/ScrollableList.tsx +++ b/packages/cli/src/ui/components/shared/ScrollableList.tsx @@ -4,7 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useRef, forwardRef, useImperativeHandle, useCallback } from 'react'; +import { + useRef, + forwardRef, + useImperativeHandle, + useCallback, + useEffect, +} from 'react'; import type React from 'react'; import { VirtualizedList, @@ -147,6 +153,13 @@ function ScrollableList( cancelScrollFlush(); }, [cancelScrollFlush]); + // Losing focus unsubscribes the keypress and mouse handlers, so a still-armed + // flush belongs to a gesture the user can no longer steer. Letting it fire + // after the flip scrolls the surface out from under whatever took focus. + useEffect(() => { + if (!hasFocus) cancelPendingScroll(); + }, [hasFocus, cancelPendingScroll]); + const handleMouseEvent = useCallback( (event: MouseEvent) => { if (!virtualizedListRef.current) return; diff --git a/packages/cli/src/ui/context-menu/ContextMenuContext.tsx b/packages/cli/src/ui/context-menu/ContextMenuContext.tsx index 369b1aefd2c..2dc0a24988b 100644 --- a/packages/cli/src/ui/context-menu/ContextMenuContext.tsx +++ b/packages/cli/src/ui/context-menu/ContextMenuContext.tsx @@ -34,6 +34,7 @@ export interface ContextMenuState { export interface ContextMenuContextValue { menu: ContextMenuState | null; + isMenuOpen: () => boolean; selectedIndex: number; openMenu: ( items: ContextMenuItem[], @@ -47,6 +48,7 @@ export interface ContextMenuContextValue { const ContextMenuContext = createContext({ menu: null, + isMenuOpen: () => false, selectedIndex: 0, openMenu: () => {}, closeMenu: () => {}, @@ -71,14 +73,18 @@ export const ContextMenuProvider: React.FC<{ // effects inside a state updater, which StrictMode double-invokes). const menuRef = useRef(null); menuRef.current = menu; + const isMenuOpen = useCallback(() => menuRef.current !== null, []); const openMenu = useCallback( (items: ContextMenuItem[], position: { x: number; y: number }) => { if (items.length === 0) return; - setMenu({ items, position }); + menuRef.current = { items, position }; + // The app-level key handler sits outside this provider. + onMenuChange?.(true); + setMenu(menuRef.current); setSelectedIndexState(0); }, - [], + [onMenuChange], ); const closeMenu = useCallback(() => { @@ -110,13 +116,22 @@ export const ContextMenuProvider: React.FC<{ const value = useMemo( () => ({ menu, + isMenuOpen, selectedIndex, openMenu, closeMenu, setSelectedIndex, executeIndex, }), - [menu, selectedIndex, openMenu, closeMenu, setSelectedIndex, executeIndex], + [ + menu, + isMenuOpen, + selectedIndex, + openMenu, + closeMenu, + setSelectedIndex, + executeIndex, + ], ); return ( diff --git a/packages/cli/src/ui/contexts/AgentViewContext.tsx b/packages/cli/src/ui/contexts/AgentViewContext.tsx index aa87639c3f7..d6bfd208940 100644 --- a/packages/cli/src/ui/contexts/AgentViewContext.tsx +++ b/packages/cli/src/ui/contexts/AgentViewContext.tsx @@ -55,6 +55,14 @@ export interface AgentViewState { agentShellFocused: boolean; /** Last synced text from the active agent tab's input buffer. */ agentInputBufferText: string; + /** + * Layout key synced by the active AgentComposer whenever its footer's + * height-relevant state changes (status row, queued messages, input text, + * streaming state). Consumed by AppContainer's controls-height measure + * effect so the agent footer is re-measured on growth; '' before any + * agent composer has synced. + */ + agentComposerLayoutKey: string; /** Whether the tab bar has keyboard focus (vs the agent input). */ agentTabBarFocused: boolean; /** Per-agent approval modes (keyed by agentId). */ @@ -85,6 +93,7 @@ export interface AgentViewActions { unregisterAll(): void; setAgentShellFocused(focused: boolean): void; setAgentInputBufferText(text: string): void; + setAgentComposerLayoutKey(key: string): void; setAgentTabBarFocused(focused: boolean): void; setAgentApprovalMode(agentId: string, mode: ApprovalMode): void; /** Replace the queued follow-up messages for an agent (see state docs). */ @@ -105,6 +114,7 @@ const DEFAULT_STATE: AgentViewState = { agents: new Map(), agentShellFocused: false, agentInputBufferText: '', + agentComposerLayoutKey: '', agentTabBarFocused: false, agentApprovalModes: new Map(), agentMessageQueues: new Map(), @@ -121,6 +131,7 @@ const DEFAULT_ACTIONS: AgentViewActions = { unregisterAll: noop, setAgentShellFocused: noop, setAgentInputBufferText: noop, + setAgentComposerLayoutKey: noop, setAgentTabBarFocused: noop, setAgentApprovalMode: noop, setAgentMessageQueue: noop, @@ -240,6 +251,7 @@ export function AgentViewProvider({ ); const [agentShellFocused, setAgentShellFocused] = useState(false); const [agentInputBufferText, setAgentInputBufferText] = useState(''); + const [agentComposerLayoutKey, setAgentComposerLayoutKey] = useState(''); const [agentTabBarFocused, setAgentTabBarFocused] = useState(false); const [agentApprovalModes, setAgentApprovalModes] = useState< Map @@ -418,6 +430,7 @@ export function AgentViewProvider({ agents, agentShellFocused, agentInputBufferText, + agentComposerLayoutKey, agentTabBarFocused, agentApprovalModes, agentMessageQueues, @@ -427,6 +440,7 @@ export function AgentViewProvider({ agents, agentShellFocused, agentInputBufferText, + agentComposerLayoutKey, agentTabBarFocused, agentApprovalModes, agentMessageQueues, @@ -443,6 +457,7 @@ export function AgentViewProvider({ unregisterAll, setAgentShellFocused, setAgentInputBufferText, + setAgentComposerLayoutKey, setAgentTabBarFocused, setAgentApprovalMode, setAgentMessageQueue, @@ -457,6 +472,7 @@ export function AgentViewProvider({ unregisterAll, setAgentShellFocused, setAgentInputBufferText, + setAgentComposerLayoutKey, setAgentTabBarFocused, setAgentApprovalMode, setAgentMessageQueue, diff --git a/packages/cli/src/ui/editors/editorSettingsManager.ts b/packages/cli/src/ui/editors/editorSettingsManager.ts index 56f6bd858d2..5579be5258e 100644 --- a/packages/cli/src/ui/editors/editorSettingsManager.ts +++ b/packages/cli/src/ui/editors/editorSettingsManager.ts @@ -29,13 +29,14 @@ export const EDITOR_DISPLAY_NAMES: Record = { }; class EditorSettingsManager { - private readonly availableEditors: EditorDisplay[]; + private availableEditors: EditorDisplay[] | undefined; - constructor() { + // Probing spawns a shell per editor, so it waits until a dialog asks. + getAvailableEditorDisplays(): EditorDisplay[] { const editorTypes = Object.keys( EDITOR_DISPLAY_NAMES, ).sort() as EditorType[]; - this.availableEditors = [ + this.availableEditors ??= [ { name: 'None', type: 'not_set', @@ -57,9 +58,6 @@ class EditorSettingsManager { }; }), ]; - } - - getAvailableEditorDisplays(): EditorDisplay[] { return this.availableEditors; } } diff --git a/packages/cli/src/ui/hooks/session-swap-telemetry.test.ts b/packages/cli/src/ui/hooks/session-swap-telemetry.test.ts index 67239ebaf45..ad26f2e4b83 100644 --- a/packages/cli/src/ui/hooks/session-swap-telemetry.test.ts +++ b/packages/cli/src/ui/hooks/session-swap-telemetry.test.ts @@ -59,6 +59,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { await importOriginal(); class SessionService { constructor(_cwd: string) {} + assertLegacySessionExecution = vi.fn(); async loadSession(sessionId: string) { return sessionServiceMocks.sessions.get(sessionId); } diff --git a/packages/cli/src/ui/hooks/useGitBranchName.test.ts b/packages/cli/src/ui/hooks/useGitBranchName.test.ts index b4097226ac9..e4121db7479 100644 --- a/packages/cli/src/ui/hooks/useGitBranchName.test.ts +++ b/packages/cli/src/ui/hooks/useGitBranchName.test.ts @@ -23,6 +23,7 @@ import { import { useGitBranchName, BRANCH_POLL_INTERVAL_MS, + primeGitBranchName, } from './useGitBranchName.js'; // The hook is a thin wrapper over core's gitDirect helpers; the direct-read @@ -57,6 +58,15 @@ describe('useGitBranchName', () => { vi.useRealTimers(); }); + it('starts from a primed branch name before any effect runs', async () => { + mockResolve.mockResolvedValue('primed'); + await primeGitBranchName('/primed/project'); + + const { result } = renderHook(() => useGitBranchName('/primed/project')); + + expect(result.current).toBe('primed'); + }); + it('reads the branch name on mount', async () => { mockResolve.mockResolvedValue('main'); diff --git a/packages/cli/src/ui/hooks/useGitBranchName.ts b/packages/cli/src/ui/hooks/useGitBranchName.ts index c9598f9717c..20712009350 100644 --- a/packages/cli/src/ui/hooks/useGitBranchName.ts +++ b/packages/cli/src/ui/hooks/useGitBranchName.ts @@ -19,6 +19,16 @@ import { */ export const BRANCH_POLL_INTERVAL_MS = 5_000; +const primedBranchNames = new Map(); + +/** + * Resolves `cwd`'s branch ahead of the first frame, so the footer does not + * change (and the screen reflow) right after the prompt becomes usable. + */ +export async function primeGitBranchName(cwd: string): Promise { + primedBranchNames.set(cwd, await resolveBranchName(cwd)); +} + /** * Tracks the current git branch (or a short commit hash when detached) for * `cwd`, read directly from `.git` via core's gitDirect helpers — no `git` @@ -27,7 +37,9 @@ export const BRANCH_POLL_INTERVAL_MS = 5_000; * where `fs.watch` is unreliable (#7828). */ export function useGitBranchName(cwd: string): string | undefined { - const [branchName, setBranchName] = useState(undefined); + const [branchName, setBranchName] = useState(() => + primedBranchNames.get(cwd), + ); const branchNameRef = useRef(branchName); branchNameRef.current = branchName; diff --git a/packages/cli/src/ui/hooks/useKeypress.ts b/packages/cli/src/ui/hooks/useKeypress.ts index 2ed1bc550ed..d0b1a2aef3e 100644 --- a/packages/cli/src/ui/hooks/useKeypress.ts +++ b/packages/cli/src/ui/hooks/useKeypress.ts @@ -7,6 +7,7 @@ import { useCallback, useEffect, useRef } from 'react'; import type { KeypressHandler, Key } from '../contexts/KeypressContext.js'; import { useKeypressContext } from '../contexts/KeypressContext.js'; +import { useContextMenu } from '../context-menu/ContextMenuContext.js'; export type { Key }; @@ -22,9 +23,15 @@ export function useKeypress( { isActive }: { isActive: boolean }, ) { const { subscribe, unsubscribe } = useKeypressContext(); + const { menu, isMenuOpen } = useContextMenu(); const onKeypressRef = useRef(onKeypress); - onKeypressRef.current = onKeypress; + onKeypressRef.current = (key) => { + // A right-click and key can share one stdin chunk. Until the menu renders, + // existing subscribers still hold the previous view's focus and callbacks. + if (menu === null && isMenuOpen()) return; + onKeypress(key); + }; const handleKeypress = useCallback((key) => { onKeypressRef.current(key); diff --git a/packages/cli/src/ui/hooks/useResumeCommand.managed.test.ts b/packages/cli/src/ui/hooks/useResumeCommand.managed.test.ts new file mode 100644 index 00000000000..b196758d8b4 --- /dev/null +++ b/packages/cli/src/ui/hooks/useResumeCommand.managed.test.ts @@ -0,0 +1,148 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ +// @vitest-environment jsdom +import { mkdtemp, mkdir, readFile, rm, realpath } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { act, renderHook } from '@testing-library/react'; +import { expect, it, vi } from 'vitest'; +import { + Storage, + SessionService, + type Config, +} from '@qwen-code/qwen-code-core'; +import { openManagedSession } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-assembly.js'; +import { useResumeCommand } from './useResumeCommand.js'; +import type { LoadedSettings } from '../../config/settings.js'; + +it('rejects in-session Managed resume before changing the active session', async () => { + const root = await realpath( + await mkdtemp(path.join(tmpdir(), 'qwen-managed-switch-review-')), + ); + const workspace = path.join(root, 'workspace'); + const runtime = path.join(root, 'runtime'); + await mkdir(workspace, { recursive: true }); + Storage.setRuntimeBaseDir(runtime); + let unmount: (() => void) | undefined; + try { + const storage = new Storage(workspace, runtime); + const targetId = randomUUID(); + const oldId = randomUUID(); + let currentId: string = oldId; + const transcriptPath = path.join( + storage.getProjectDir(), + 'chats', + `${targetId}.jsonl`, + ); + const options = { + runtimeBaseDir: runtime, + sessionId: targetId, + transcriptPath, + sessionKey: { + tenantId: 'review', + workspaceId: 'review', + sessionId: targetId, + }, + cwd: workspace, + version: 'test', + workerId: 'review', + activationLeaseDurationMs: 60000, + }; + await mkdir(path.dirname(transcriptPath), { recursive: true }); + const ref = (kind: string) => ({ + resourceId: randomUUID(), + kind, + schemaVersion: 1, + byteLength: 2, + digest: '9'.repeat(64), + }); + const session = await openManagedSession({ + ...options, + create: { + definitionRef: ref('definition'), + rootSnapshotRef: ref('root'), + createdBy: 'review', + }, + }); + await session.sink.write({ + uuid: randomUUID(), + parentUuid: null, + sessionId: targetId, + timestamp: new Date().toISOString(), + cwd: workspace, + version: 'test', + type: 'user', + message: { role: 'user', parts: [{ text: 'Managed history' }] }, + }); + await session.close(); + const before = await readFile(transcriptPath); + const addItem = vi.fn(); + const switchUi = vi.fn(); + const llm = { + beginTelemetrySwap: () => true, + commitTelemetrySwap: vi.fn(), + abortTelemetrySwap: vi.fn(), + initialize: vi.fn().mockResolvedValue(undefined), + }; + const idleRegistry = { + hasRunningTasks: () => false, + hasRunningEntries: () => false, + getRunning: () => [], + reset: vi.fn(), + abortAll: vi.fn(), + }; + const config = { + storage, + getSessionId: () => currentId, + getTargetDir: () => workspace, + getProjectRoot: () => workspace, + getCliVersion: () => 'test', + getResumedSessionData: () => undefined, + getSessionService: () => new SessionService(workspace), + getLlmClient: () => llm, + startNewSession: vi.fn((id: string) => { + currentId = id; + }), + getGoalRuntimeReady: async () => ({}), + getBackgroundTaskRegistry: () => idleRegistry, + getBackgroundShellRegistry: () => idleRegistry, + getMonitorRegistry: () => idleRegistry, + getWorkflowRunRegistry: () => idleRegistry, + loadPausedBackgroundAgents: async () => [], + getChatRecordingService: () => undefined, + getToolRegistry: () => ({ getTool: () => undefined }), + getDebugLogger: () => ({ warn: vi.fn() }), + } as unknown as Config; + const hook = renderHook(() => + useResumeCommand({ + config, + settings: { merged: {} } as LoadedSettings, + historyManager: { addItem, clearItems: vi.fn(), loadHistory: vi.fn() }, + startNewSession: switchUi, + }), + ); + unmount = hook.unmount; + await act(async () => { + await hook.result.current.handleResume(targetId); + }); + expect(currentId).toBe(oldId); + expect(config.startNewSession).not.toHaveBeenCalled(); + expect(switchUi).not.toHaveBeenCalled(); + expect(addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + text: expect.stringContaining('belongs to managed'), + }), + expect.any(Number), + ); + expect(await readFile(transcriptPath)).toEqual(before); + } finally { + unmount?.(); + Storage.setRuntimeBaseDir(null); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/cli/src/ui/hooks/useResumeCommand.test.ts b/packages/cli/src/ui/hooks/useResumeCommand.test.ts index c63b87c1ce6..10d273fd18e 100644 --- a/packages/cli/src/ui/hooks/useResumeCommand.test.ts +++ b/packages/cli/src/ui/hooks/useResumeCommand.test.ts @@ -136,6 +136,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { await importOriginal(); class SessionService { constructor(_cwd: string) {} + assertLegacySessionExecution = vi.fn(); async loadSession(_sessionId: string) { return ( resumeMocks.getPendingLoadSession() ?? diff --git a/packages/cli/src/ui/hooks/useResumeCommand.ts b/packages/cli/src/ui/hooks/useResumeCommand.ts index bcfbdadf1aa..57b492fbe41 100644 --- a/packages/cli/src/ui/hooks/useResumeCommand.ts +++ b/packages/cli/src/ui/hooks/useResumeCommand.ts @@ -157,6 +157,7 @@ export function useResumeCommand( try { const cwd = config.getTargetDir(); const sessionService = new SessionService(cwd); + sessionService.assertLegacySessionExecution(sessionId); const sessionData = await sessionService.loadSession(sessionId); if (!sessionData) { diff --git a/packages/cli/src/ui/opentui/exit-lifecycle.ts b/packages/cli/src/ui/opentui/exit-lifecycle.ts index 2cc4f8562a3..cfde93bea99 100644 --- a/packages/cli/src/ui/opentui/exit-lifecycle.ts +++ b/packages/cli/src/ui/opentui/exit-lifecycle.ts @@ -25,6 +25,7 @@ import type { Config } from '@qwen-code/qwen-code-core'; import { runExitCleanup } from '../../utils/cleanup.js'; +import { exitCleanly } from '../../utils/processUtils.js'; /** Exit code for interrupt-style exits (Ctrl+C / Ctrl+D double press). */ export const EXIT_CODE_INTERRUPT = 130; @@ -68,7 +69,7 @@ export async function exitSession( } catch { // runExitCleanup swallows per-cleanup errors already; belt and braces. } - process.exit(code); + return exitCleanly(code); } /** TEST ONLY: reset the module-level exit latch between cases. */ diff --git a/packages/cli/src/ui/opentui/session-switch.test.ts b/packages/cli/src/ui/opentui/session-switch.test.ts index a3dbc880436..7c55ffe0332 100644 --- a/packages/cli/src/ui/opentui/session-switch.test.ts +++ b/packages/cli/src/ui/opentui/session-switch.test.ts @@ -156,6 +156,10 @@ function createFakeHost(config: Config): SessionSwitchHost & { describe('handleResumeSession', () => { beforeEach(() => { + vi.spyOn( + SessionService.prototype, + 'assertLegacySessionExecution', + ).mockImplementation(() => {}); vi.spyOn(SessionService.prototype, 'loadSession').mockResolvedValue( emptySession() as never, ); @@ -193,6 +197,33 @@ describe('handleResumeSession', () => { expect(replay.at(-1)?.type).toBe('done'); }); + it('rejects a Managed target before loading or switching sessions', async () => { + vi.mocked( + SessionService.prototype.assertLegacySessionExecution, + ).mockImplementationOnce(() => { + throw new Error('belongs to managed'); + }); + const { config, calls } = createFakeConfig(); + const host = createFakeHost(config); + + await handleResumeSession(host, 'target-session'); + + expect( + SessionService.prototype.assertLegacySessionExecution, + ).toHaveBeenCalledWith('target-session'); + expect(SessionService.prototype.loadSession).not.toHaveBeenCalled(); + expect(calls.startNewSession).toHaveLength(0); + expect(host.startNewSession).not.toHaveBeenCalled(); + expect(host.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + text: expect.stringContaining('belongs to managed'), + }), + expect.any(Number), + ); + expect(calls.swapCommit).toBe(1); + }); + it('rolls the core back to the old session when the swap fails', async () => { const { config, calls } = createFakeConfig({ failClientInitialize: true, diff --git a/packages/cli/src/ui/opentui/session-switch.ts b/packages/cli/src/ui/opentui/session-switch.ts index ac46642f7b0..edfce2a7d07 100644 --- a/packages/cli/src/ui/opentui/session-switch.ts +++ b/packages/cli/src/ui/opentui/session-switch.ts @@ -137,6 +137,7 @@ export async function handleResumeSession( try { const cwd = config.getTargetDir(); const sessionService = new SessionService(cwd); + sessionService.assertLegacySessionExecution(sessionId); const sessionData = await sessionService.loadSession(sessionId); if (!sessionData) { // Nothing was replayed — close this attempt's unarmed transaction. diff --git a/packages/cli/src/ui/selection/use-text-selection.test.tsx b/packages/cli/src/ui/selection/use-text-selection.test.tsx index 72d0baba8c8..9fece93d4ba 100644 --- a/packages/cli/src/ui/selection/use-text-selection.test.tsx +++ b/packages/cli/src/ui/selection/use-text-selection.test.tsx @@ -687,6 +687,27 @@ describe('TextSelectionController', () => { expect(setSelection).toHaveBeenCalledWith(null); }); + it('clears the painted highlight when the controller unmounts', () => { + const handler = mount(); + selectHello(handler); + // The painted range lives on the per-stdout frame controller, which outlives + // this component. Without an unmount teardown, a view that replaces this one + // (switching away from a teammate tab, or between teammates) re-stamps the + // highlight over cells the user never selected there — and no successor can + // repair it, because `clearSelection()` early-returns on an empty own + // selection, which is what every fresh controller starts with. + expect(setSelection).toHaveBeenLastCalledWith({ + sx: 0, + sy: 0, + ex: 4, + ey: 0, + }); + + cleanup(); + + expect(setSelection).toHaveBeenLastCalledWith(null); + }); + it('keeps a selection across its own highlight repaint', () => { const handler = mount(); selectHello(handler); diff --git a/packages/cli/src/ui/selection/use-text-selection.tsx b/packages/cli/src/ui/selection/use-text-selection.tsx index 43a3b3c6a96..ed55791c2eb 100644 --- a/packages/cli/src/ui/selection/use-text-selection.tsx +++ b/packages/cli/src/ui/selection/use-text-selection.tsx @@ -480,6 +480,19 @@ export function TextSelectionController( } }, [props.isActive, clearSelection]); + // The painted range lives on the per-stdout frame controller, outside React, + // so it outlives this controller: ink re-reads it on every serialization pass + // and stamps the highlight onto whatever cells now occupy those coordinates. + // A view that unmounts with a selection on screen (switching away from a + // teammate tab, or between teammates) would therefore leave a highlight band + // over content the user never selected there. `clearSelection()` cannot do + // this teardown: it early-returns while its own selection is empty, and a + // successor controller always starts empty, so nothing downstream repairs it + // either. Unmount only — never `eventsPaused`, which must keep the selection + // the open context menu's "Copy Selection" offers. `getBuffer` is a + // `useCallback` keyed on `[stdout]`, so this cleanup runs once, on unmount. + useEffect(() => () => getBuffer()?.setSelection(null), [getBuffer]); + // Expose the live selection to an external query ref (context menu "Copy // Selection"). A collapsed char-mode selection is a bare click (cleared on // release anyway) and carries no text; a collapsed word/line span still diff --git a/packages/cli/src/ui/startInteractiveUI.tsx b/packages/cli/src/ui/startInteractiveUI.tsx index 42d73666f95..16256103053 100644 --- a/packages/cli/src/ui/startInteractiveUI.tsx +++ b/packages/cli/src/ui/startInteractiveUI.tsx @@ -69,6 +69,7 @@ import { sanitizeTerminalText } from './utils/textUtils.js'; import { startPostRenderPrefetches } from '../startup/startup-prefetch.js'; import { computeWindowTitle, writeTerminalTitle } from './utils/windowTitle.js'; import { getCliVersion } from '../utils/version.js'; +import { primeGitBranchName } from './hooks/useGitBranchName.js'; const debugLogger = createDebugLogger('STARTUP'); @@ -91,6 +92,9 @@ export async function startInteractiveUI( initializationResult: InitializationResult, options: StartInteractiveUIOptions = {}, ) { + const branchPrimed = primeGitBranchName(config.getTargetDir()).catch( + () => {}, + ); const version = await getCliVersion(); setWindowTitle(settings, basename(workspaceRoot)); @@ -301,6 +305,7 @@ export async function startInteractiveUI( // coordinates even though these listeners are owned and cleaned up. process.stdout.setMaxListeners(0); } + await branchPrimed; const appTree = ( ({ - mockSpawn: vi.fn(), - mockExecSync: vi.fn(), - clipboardMockState: { - failLoad: false, - loadDelayMs: 0, - // The module resolves, but using it still throws - the shape of a native - // addon built against a different Node ABI. - throwOnConstruct: false, - throwOnHasFormat: false, - }, -})); +const { mockSpawn, mockExecSync, clipboardMockState, mockDebugLogger } = + vi.hoisted(() => ({ + mockSpawn: vi.fn(), + mockExecSync: vi.fn(), + clipboardMockState: { + failLoad: false, + loadDelayMs: 0, + // The module resolves, but using it still throws - the shape of a native + // addon built against a different Node ABI. + throwOnConstruct: false, + throwOnHasFormat: false, + }, + // clipboardUtils records every failure it diagnoses through + // createDebugLogger, which is a no-op without an active debug session, so + // the diagnostics are only assertable through a spy. + mockDebugLogger: { + isEnabled: vi.fn(() => true), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + })); // Mock @teddyzhu/clipboard vi.mock('@teddyzhu/clipboard', async () => { @@ -68,6 +79,17 @@ vi.mock('node:child_process', () => ({ execFile: vi.fn(), })); +// Swap only the logger factory; every other core export is passed through, so +// the module graph under test keeps resolving exactly as it does in CI. +vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + createDebugLogger: () => mockDebugLogger, + }; +}); + // We intentionally do NOT mock node:fs root to avoid breaking indirect // dependencies (e.g. debugLogger, symlink) that import from 'node:fs'. // vitest's mock system for built-in modules cannot simultaneously: @@ -103,7 +125,11 @@ vi.mock('node:fs/promises', async (importOriginal) => { /** * Create a mock child process that emits stdout data and close event. */ -function createMockChild(stdoutData: string, exitCode: number = 0) { +function createMockChild( + stdoutData: string, + exitCode: number = 0, + stderrData: string = '', +) { const stdout = new EventEmitter() as EventEmitter & { pipe: (dest: EventEmitter) => EventEmitter; }; @@ -114,23 +140,102 @@ function createMockChild(stdoutData: string, exitCode: number = 0) { }); return dest; }; + // The production code pipes fd 2 as well, so every mock child has to expose + // a stderr stream. Without it the handler attachment throws inside the + // promise executor and the assertions would pass for the wrong reason. + const stderr = new EventEmitter(); const child = new EventEmitter() as EventEmitter & { stdout: typeof stdout; + stderr: typeof stderr; kill: ReturnType; killed: boolean; }; child.stdout = stdout; + child.stderr = stderr; child.kill = vi.fn(); child.killed = false; process.nextTick(() => { stdout.emit('data', Buffer.from(stdoutData)); + if (stderrData) { + stderr.emit('data', Buffer.from(stderrData)); + } child.emit('close', exitCode); }); return child; } +/** + * Create a mock child process that fails to spawn. + * + * Emits `close` after `error`, because that is what Node does: a child that + * fails to spawn still reports `close` with the negated errno (measured on + * Node v22/v24: `error:ENOENT` then `close:code=-2,sig=null`). A fixture that + * stopped at `error` would hide every defect that lives in the second event, + * which is where the double-settle was. The nested `process.nextTick` keeps + * `close` the later event it is in production while still draining before the + * awaiting test resumes (Node empties the nextTick queue before microtasks). + */ +function createSpawnErrorChild() { + const stdout = new EventEmitter() as EventEmitter & { + pipe: (dest: EventEmitter) => EventEmitter; + }; + stdout.pipe = (dest: EventEmitter) => dest; + const stderr = new EventEmitter(); + const child = new EventEmitter() as EventEmitter & { + stdout: typeof stdout; + stderr: typeof stderr; + kill: ReturnType; + killed: boolean; + }; + child.stdout = stdout; + child.stderr = stderr; + child.kill = vi.fn(); + child.killed = false; + + process.nextTick(() => { + child.emit('error', new Error('spawn ENOENT')); + process.nextTick(() => { + child.emit('close', -2, null); + }); + }); + + return child; +} + +/** + * Create a mock child process that never completes, driving the timeout path. + * + * `kill` answers with `close(null, 'SIGTERM')`, which is what a real + * `child.kill()` produces and what the production timeout path therefore + * always sees after it has already notified. An inert `vi.fn()` left that + * second `close` unemitted, so the timeout path's double-settle was invisible. + */ +function createHangingChild() { + const stdout = new EventEmitter() as EventEmitter & { + pipe: (dest: EventEmitter) => EventEmitter; + }; + stdout.pipe = (dest: EventEmitter) => dest; + const stderr = new EventEmitter(); + const child = new EventEmitter() as EventEmitter & { + stdout: typeof stdout; + stderr: typeof stderr; + kill: ReturnType; + killed: boolean; + }; + child.stdout = stdout; + child.stderr = stderr; + child.kill = vi.fn(() => { + process.nextTick(() => { + child.emit('close', null, 'SIGTERM'); + }); + }); + child.killed = false; + + return child; +} + /** * Create a mock stdout with a pipe method. */ @@ -358,6 +463,294 @@ describe('clipboardUtils', () => { }); }); + // ─── Linux tool found but its query fails (#12505 finding 1) ── + // getLinuxClipboardTool() only probes `command -v`; it never touches the + // display server, so the probe can pass while the actual clipboard query + // fails (dead/stale X server, compositor socket gone). Those failures must + // notify. Staying quiet is reserved for two benign answers: a successful + // query that finds no image, and an empty clipboard (which also exits + // non-zero, but is the tool answering correctly). + + describe('clipboardHasImage Linux query failures', () => { + it('notifies when wl-paste --list-types exits non-zero', async () => { + // No stderr to classify the exit, so it stays a query failure: the + // default for an unexplained non-zero exit is to notify. + mockExecSync.mockReturnValue(Buffer.from('/usr/bin/wl-paste')); + mockSpawn.mockReturnValue(createMockChild('', 1)); + + const onUnavailable = vi.fn(); + await expect(clipboardHasImage(onUnavailable)).resolves.toBe(false); + expect(onUnavailable).toHaveBeenCalledOnce(); + }); + + it('notifies when wl-paste fails to spawn', async () => { + mockExecSync.mockReturnValue(Buffer.from('/usr/bin/wl-paste')); + mockSpawn.mockReturnValue(createSpawnErrorChild()); + + const onUnavailable = vi.fn(); + await expect(clipboardHasImage(onUnavailable)).resolves.toBe(false); + // The fixture emits `error` then `close(-2)`, as Node does. Without the + // settle latch the close handler re-enters its non-zero branch: a second + // notify, and an exit code recorded for a process that never started. + expect(onUnavailable).toHaveBeenCalledOnce(); + expect(mockDebugLogger.debug).not.toHaveBeenCalledWith( + expect.stringContaining('exited with code'), + ); + // That latch is also what makes this errno the spawn failure's only + // trace, so it is pinned here: reverting the handler to the pre-PR + // `child.on('error', () => {` drops the binding and the log together and + // would otherwise ship green. `.debug` and not `.error`, because the + // synchronous-throw arm logs the byte-identical message at the other + // level and is pinned separately. + expect(mockDebugLogger.debug).toHaveBeenCalledWith( + 'Failed to spawn wl-paste --list-types:', + expect.any(Error), + ); + }); + + it('notifies when the wl-paste query times out', async () => { + mockExecSync.mockReturnValue(Buffer.from('/usr/bin/wl-paste')); + mockSpawn.mockReturnValue(createHangingChild()); + + const onUnavailable = vi.fn(); + await expect(clipboardHasImage(onUnavailable)).resolves.toBe(false); + expect(onUnavailable).toHaveBeenCalledOnce(); + // The kill() the timeout path issues produces `close(null, 'SIGTERM')`, + // which the fixture now emits. The timeout line must be the *only* + // diagnosis recorded: `exited with code null` states an exit code the + // query never had, and the notify must not fire a second time. + expect(mockDebugLogger.debug).toHaveBeenCalledWith( + expect.stringMatching(/^wl-paste --list-types timed out after \d+ms$/), + ); + expect(mockDebugLogger.debug).not.toHaveBeenCalledWith( + expect.stringContaining('exited with code'), + ); + }, 10000); + + it('notifies when spawning wl-paste itself throws', async () => { + mockExecSync.mockReturnValue(Buffer.from('/usr/bin/wl-paste')); + mockSpawn.mockImplementation(() => { + throw new Error('spawn error'); + }); + + const onUnavailable = vi.fn(); + await expect(clipboardHasImage(onUnavailable)).resolves.toBe(false); + expect(onUnavailable).toHaveBeenCalledOnce(); + }); + + it('notifies when xclip exits non-zero on X11', async () => { + // The issue's regression case: X11 session with DISPLAY set and xclip + // installed, but the query exits non-zero without saying the clipboard + // is empty, so it cannot be classified as benign. The dead-X-server + // wording itself is pinned further down in this block. + setupX11Env(); + mockExecSync.mockReturnValue(Buffer.from('/usr/bin/xclip')); + mockSpawn.mockReturnValue(createMockChild('', 1)); + + const onUnavailable = vi.fn(); + await expect(clipboardHasImage(onUnavailable)).resolves.toBe(false); + expect(onUnavailable).toHaveBeenCalledOnce(); + }); + + it('notifies when xclip fails to spawn on X11', async () => { + setupX11Env(); + mockExecSync.mockReturnValue(Buffer.from('/usr/bin/xclip')); + mockSpawn.mockReturnValue(createSpawnErrorChild()); + + const onUnavailable = vi.fn(); + await expect(clipboardHasImage(onUnavailable)).resolves.toBe(false); + expect(onUnavailable).toHaveBeenCalledOnce(); + expect(mockDebugLogger.debug).not.toHaveBeenCalledWith( + expect.stringContaining('exited with code'), + ); + // Same reason as the wl-paste twin above: the latch suppresses the close + // handler's fabricated exit code, so this errno is the only trace the + // spawn failure leaves and dropping it must not ship green. + expect(mockDebugLogger.debug).toHaveBeenCalledWith( + 'Failed to spawn xclip:', + expect.any(Error), + ); + }); + + it('notifies when the xclip query times out on X11', async () => { + setupX11Env(); + mockExecSync.mockReturnValue(Buffer.from('/usr/bin/xclip')); + mockSpawn.mockReturnValue(createHangingChild()); + + const onUnavailable = vi.fn(); + await expect(clipboardHasImage(onUnavailable)).resolves.toBe(false); + expect(onUnavailable).toHaveBeenCalledOnce(); + expect(mockDebugLogger.debug).toHaveBeenCalledWith( + expect.stringMatching(/^xclip timed out after \d+ms$/), + ); + expect(mockDebugLogger.debug).not.toHaveBeenCalledWith( + expect.stringContaining('exited with code'), + ); + }, 10000); + + it('notifies when spawning xclip itself throws on X11', async () => { + setupX11Env(); + mockExecSync.mockReturnValue(Buffer.from('/usr/bin/xclip')); + mockSpawn.mockImplementation(() => { + throw new Error('spawn error'); + }); + + const onUnavailable = vi.fn(); + await expect(clipboardHasImage(onUnavailable)).resolves.toBe(false); + expect(onUnavailable).toHaveBeenCalledOnce(); + }); + + it('stays quiet when xclip succeeds and the clipboard holds only text', async () => { + // Anti-nag: a successful query that finds no image is benign and must + // not warn. The pre-existing xclip tests called clipboardHasImage() + // with no argument, so a misplaced notification could not fail there — + // this closes that coverage gap. + setupX11Env(); + mockExecSync.mockReturnValue(Buffer.from('/usr/bin/xclip')); + mockSpawn.mockReturnValue( + createMockChild('text/plain\nUTF8_STRING\n', 0), + ); + + const onUnavailable = vi.fn(); + await expect(clipboardHasImage(onUnavailable)).resolves.toBe(false); + expect(onUnavailable).not.toHaveBeenCalled(); + }); + + // ─── An empty clipboard exits non-zero too, and must stay quiet ─── + // Linux clipboard tools use one and the same exit code for "the display + // server is dead" and for "nothing is on the clipboard", so the exit code + // alone cannot separate them — only stderr can. Which wording each shipped + // tool version produces, and its upstream provenance, is documented once on + // EMPTY_CLIPBOARD_STDERR_MARKERS rather than restated here; each case below + // pins one of those wordings. Pressing the image-paste binding with an + // empty clipboard is routine, so it must not claim the native module is + // broken. + + it('stays quiet when wl-paste exits non-zero because nothing is copied', async () => { + mockExecSync.mockReturnValue(Buffer.from('/usr/bin/wl-paste')); + mockSpawn.mockReturnValue(createMockChild('', 1, 'Nothing is copied\n')); + + const onUnavailable = vi.fn(); + await expect(clipboardHasImage(onUnavailable)).resolves.toBe(false); + expect(onUnavailable).not.toHaveBeenCalled(); + // fd 2 has to be piped for that distinction to be possible at all. + expect(mockSpawn).toHaveBeenCalledWith('wl-paste', ['--list-types'], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + }); + + it('stays quiet when wl-paste 1.x exits non-zero with "No selection"', async () => { + mockExecSync.mockReturnValue(Buffer.from('/usr/bin/wl-paste')); + mockSpawn.mockReturnValue(createMockChild('', 1, 'No selection\n')); + + const onUnavailable = vi.fn(); + await expect(clipboardHasImage(onUnavailable)).resolves.toBe(false); + expect(onUnavailable).not.toHaveBeenCalled(); + }); + + it('stays quiet when released xclip 0.13 reports the target is unavailable', async () => { + // The wording a shipped xclip actually produces for an unowned + // selection. 0.13 is what Debian/Ubuntu/Fedora package, and it has no + // `errconvsel()`, so the master-only wording below matches nothing here: + // without this marker the routine empty-clipboard case falls through to + // onUnavailable and spends the session's one-shot notify latch on a + // false alarm, silencing the genuine failure this PR exists to report. + setupX11Env(); + mockExecSync.mockReturnValue(Buffer.from('/usr/bin/xclip')); + mockSpawn.mockReturnValue( + createMockChild('', 1, 'Error: target TARGETS not available\n'), + ); + + const onUnavailable = vi.fn(); + await expect(clipboardHasImage(onUnavailable)).resolves.toBe(false); + expect(onUnavailable).not.toHaveBeenCalled(); + }); + + it('stays quiet when xclip exits non-zero because nothing owns the selection', async () => { + // Unreleased xclip git master wording, kept so the marker set covers + // both the shipped and the in-development spelling. + setupX11Env(); + mockExecSync.mockReturnValue(Buffer.from('/usr/bin/xclip')); + mockSpawn.mockReturnValue( + createMockChild( + '', + 1, + 'xclip: Error: There is no owner for the CLIPBOARD selection\n', + ), + ); + + const onUnavailable = vi.fn(); + await expect(clipboardHasImage(onUnavailable)).resolves.toBe(false); + expect(onUnavailable).not.toHaveBeenCalled(); + }); + + it('still notifies when wl-paste cannot reach the Wayland server', async () => { + // Real failure, same exit code 1: the compositor socket is gone. + mockExecSync.mockReturnValue(Buffer.from('/usr/bin/wl-paste')); + mockSpawn.mockReturnValue( + createMockChild( + '', + 1, + 'Failed to connect to a Wayland server: No such file or directory\n', + ), + ); + + const onUnavailable = vi.fn(); + await expect(clipboardHasImage(onUnavailable)).resolves.toBe(false); + expect(onUnavailable).toHaveBeenCalledOnce(); + // This echo is the only record of the wording isEmptyClipboardError() + // classified as a real failure rather than an empty clipboard, so + // without it the classification decision is not auditable. No trailing + // newline: the site trims. + expect(mockDebugLogger.debug).toHaveBeenCalledWith( + 'wl-paste stderr: Failed to connect to a Wayland server: No such file or directory', + ); + }); + + it('still notifies when xclip cannot open the display', async () => { + // Real failure, same exit code as an empty clipboard: xcprint.c + // `errxdisplay()` prints this and exits EXIT_FAILURE. + setupX11Env(); + mockExecSync.mockReturnValue(Buffer.from('/usr/bin/xclip')); + mockSpawn.mockReturnValue( + createMockChild('', 1, "xclip: Error: Can't open display: :0\n"), + ); + + const onUnavailable = vi.fn(); + await expect(clipboardHasImage(onUnavailable)).resolves.toBe(false); + expect(onUnavailable).toHaveBeenCalledOnce(); + // Same as the wl-paste twin above: the only record of the wording that + // was classified as a real failure. Trimmed, so no trailing newline. + expect(mockDebugLogger.debug).toHaveBeenCalledWith( + "xclip stderr: xclip: Error: Can't open display: :0", + ); + }); + + it('records the exit code and args when a failed query writes no stderr', async () => { + // The shape xclip produces when it exits EXIT_FAILURE without writing + // anything to stderr: the user is notified, so the debug log is the only + // place the reason can come from. + setupX11Env(); + mockExecSync.mockReturnValue(Buffer.from('/usr/bin/xclip')); + mockSpawn.mockReturnValue(createMockChild('', 1)); + + await expect(clipboardHasImage(vi.fn())).resolves.toBe(false); + expect(mockDebugLogger.debug).toHaveBeenCalledWith( + 'xclip exited with code 1. Args: -selection clipboard -t TARGETS -o', + ); + }); + + it('records the exit code when wl-paste --list-types fails silently', async () => { + mockExecSync.mockReturnValue(Buffer.from('/usr/bin/wl-paste')); + mockSpawn.mockReturnValue(createMockChild('', 1)); + + await expect(clipboardHasImage(vi.fn())).resolves.toBe(false); + expect(mockDebugLogger.debug).toHaveBeenCalledWith( + 'wl-paste --list-types exited with code 1', + ); + }); + }); + // ─── xclip / X11 path tests ─────────────────────────────────── describe('xclip / X11 path', () => { @@ -373,11 +766,13 @@ describe('clipboardUtils', () => { const result = await clipboardHasImage(); expect(result).toBe(true); - // Verify xclip was called with correct TARGETS args + // Verify xclip was called with correct TARGETS args. fd 2 is piped so + // a non-zero exit can be diagnosed and an empty clipboard told apart + // from a real failure. expect(mockSpawn).toHaveBeenCalledWith( 'xclip', ['-selection', 'clipboard', '-t', 'TARGETS', '-o'], - { stdio: ['ignore', 'pipe', 'ignore'] }, + { stdio: ['ignore', 'pipe', 'pipe'] }, ); }); @@ -433,12 +828,18 @@ describe('clipboardUtils', () => { mockSpawn.mockImplementation(() => { callCount++; const stdout = createMockStdout(); + // Production pipes fd 2 and attaches a handler to it, so the fixture + // must expose the stream or that attachment throws inside the promise + // executor and this test passes without running anything past it. + const stderr = new EventEmitter(); const child = new EventEmitter() as EventEmitter & { stdout: ReturnType; + stderr: typeof stderr; kill: ReturnType; killed: boolean; }; child.stdout = stdout; + child.stderr = stderr; child.kill = vi.fn(); child.killed = false; @@ -465,6 +866,17 @@ describe('clipboardUtils', () => { const result = await saveClipboardImage('/tmp/test'); expect(result).toBe(null); + + // Witness that the run reached the BMP branch and derived the .bmp path — + // `toBe(null)` alone also passed when this fixture lacked a stderr + // stream, because the resulting TypeError was swallowed by + // saveClipboardImage's catch-all. Despite this test's name it does not + // reach the conversion catch: fs.open is unmocked while mkdir is a no-op, + // so saveFromCommand fails at its O_EXCL open before spawning, python3 + // never runs, and the unlink counted here is the save-failure tail. + const { unlink } = await import('node:fs/promises'); + const unlinked = vi.mocked(unlink).mock.calls.map((c) => String(c[0])); + expect(unlinked.filter((p) => p.endsWith('.bmp'))).toHaveLength(1); }); it('should prefer PNG over BMP when both are available', async () => { @@ -475,12 +887,18 @@ describe('clipboardUtils', () => { mockSpawn.mockImplementation((command: string, args: string[]) => { callCount++; const stdout = createMockStdout(); + // Production pipes fd 2 and attaches a handler to it, so the fixture + // must expose the stream or that attachment throws inside the promise + // executor and this test passes without running anything past it. + const stderr = new EventEmitter(); const child = new EventEmitter() as EventEmitter & { stdout: ReturnType; + stderr: typeof stderr; kill: ReturnType; killed: boolean; }; child.stdout = stdout; + child.stderr = stderr; child.kill = vi.fn(); child.killed = false; @@ -509,6 +927,18 @@ describe('clipboardUtils', () => { // fails. Only the list-types spawn fires. expect(spawnCalls).toHaveLength(1); expect(spawnCalls[0].args).toContain('--list-types'); + + // Neither save can spawn: saveFromCommand opens with O_EXCL first and + // mkdir is mocked, so each branch fails at the open and unlinks the path + // it derived. .png before .bmp is the preference this test is named for, + // and it is only observable now that the type query resolves instead of + // throwing on a missing stderr stream. + const { unlink } = await import('node:fs/promises'); + const unlinked = vi.mocked(unlink).mock.calls.map((c) => { + const target = String(c[0]); + return target.slice(target.lastIndexOf('.')); + }); + expect(unlinked).toEqual(['.png', '.bmp']); }); }); @@ -649,6 +1079,23 @@ describe('clipboardUtils', () => { expect(result).toBe(null); }); + it('records why the save path gave up when spawning wl-paste throws', async () => { + // saveFileWithWlPaste calls getWlPasteImageTypes without an + // onUnavailable callback, so this log line is the only trace a + // synchronous spawn throw (EMFILE under fd pressure) leaves behind. + mockExecSync.mockReturnValue(Buffer.from('/usr/bin/wl-paste')); + const spawnError = new Error('spawn EMFILE'); + mockSpawn.mockImplementation(() => { + throw spawnError; + }); + + await expect(saveClipboardImage('/tmp/test')).resolves.toBe(null); + expect(mockDebugLogger.error).toHaveBeenCalledWith( + 'Failed to spawn wl-paste --list-types:', + spawnError, + ); + }); + // Note: PNG save success path requires saveFromCommand to resolve with true, // which is blocked by the createWriteStream mocking limitation. // The spawn error and timeout tests above verify error handling. diff --git a/packages/cli/src/ui/utils/clipboardUtils.ts b/packages/cli/src/ui/utils/clipboardUtils.ts index cc636b3a478..13e53aef6c7 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.ts @@ -241,14 +241,56 @@ async function saveFromCommand( }); } +/** + * Linux clipboard tools report "there is nothing on the clipboard" with the + * same non-zero exit code they use for a real query failure, so an exit code + * alone cannot separate "clipboard unreachable" from "clipboard legitimately + * empty". Their stderr wording can. Verified against upstream sources: + * - xclip 0.13, the release Debian/Ubuntu/Fedora ship: `-t TARGETS -o` on a + * selection nobody owns takes the `XCLIB_XCOUT_BAD_TARGET` branch that has + * no fallback left, prints `Error: target TARGETS not available` and returns + * `EXIT_FAILURE` (xclip.c:468). + * - xclip git master, unreleased: the same case is routed through + * `errconvsel()` (xcprint.c:136, called from xclip.c:778) and prints + * `xclip: Error: There is no owner for the CLIPBOARD selection`. Released + * 0.13 has no `errconvsel()` at all, so this wording on its own classifies + * nothing on a shipped xclip — both markers are needed. + * - wl-paste: `bail()` in src/util/misc.h is `fprintf(stderr, ...) + exit(1)`, + * called with `Nothing is copied` from v2.2.0 on (src/wl-paste.c:252) and + * `No selection` in v1.0.0–v2.1.0 (src/wl-paste.c:187) when there is no + * offer. Both markers are needed — the wording split is at v2.2.0, not at + * the 2.x boundary, so v2.0.0/v2.1.0 still print `No selection`. + */ +const EMPTY_CLIPBOARD_STDERR_MARKERS = [ + 'target TARGETS not available', + 'no owner for the', + 'Nothing is copied', + 'No selection', +]; + +/** + * Whether a non-zero exit's stderr says the clipboard is simply empty rather + * than that the query failed. + */ +function isEmptyClipboardError(stderr: string): boolean { + return EMPTY_CLIPBOARD_STDERR_MARKERS.some((marker) => + stderr.includes(marker), + ); +} + /** * Check if the clipboard contains an image using the specified tool. * Merged function replacing checkWlPasteForImage and checkXclipForImage. * For wl-paste, caches the result for reuse by saveClipboardImage. + * @param onUnavailable Called when the query itself fails (a non-zero exit + * that is not merely an empty clipboard, spawn error/throw, timeout) — as + * opposed to a successful query that simply finds no image, or an empty + * clipboard, both of which stay quiet. */ async function checkClipboardForImage( command: string, args: string[], + onUnavailable?: () => void, ): Promise { // For wl-paste --list-types, cache the result if ( @@ -256,45 +298,97 @@ async function checkClipboardForImage( args.length === 1 && args[0] === '--list-types' ) { - const types = await getWlPasteImageTypes(); + const types = await getWlPasteImageTypes(onUnavailable); return types.length > 0; } return new Promise((resolve) => { try { const child = spawn(command, args, { - stdio: ['ignore', 'pipe', 'ignore'], + // fd 2 is captured so a non-zero exit can be diagnosed rather than + // merely reported, and so the benign "clipboard is empty" exit can be + // told apart from a real failure that shares its exit code. + stdio: ['ignore', 'pipe', 'pipe'], }); let stdout = ''; + let stderr = ''; + // Node delivers `close` after a spawn `error` (carrying the negated + // errno) and after a timeout `kill()` (with `code === null`). Without a + // settle latch the close handler re-enters its non-zero branch for a + // query that has already been reported, so the debug log this function + // added for diagnosis records an exit code the process never had and + // `onUnavailable` fires twice for one failure. Same guard, same reason, + // as `resolved` in saveFromCommand above. + let settled = false; const timer = setTimeout(() => { + if (settled) return; + settled = true; try { child.kill(); } catch { /* ignore */ } + debugLogger.debug(`${command} timed out after ${PROCESS_TIMEOUT_MS}ms`); + onUnavailable?.(); resolve(false); }, PROCESS_TIMEOUT_MS); child.stdout.on('data', (data: Buffer) => { stdout += data.toString(); }); + child.stderr.on('data', (data: Buffer) => { + stderr += data.toString(); + }); child.on('close', (code) => { clearTimeout(timer); + if (settled) return; + settled = true; + if (code !== 0) { + // Unconditional, like saveFromCommand's: a tool that exits non-zero + // without writing to stderr (xclip does) still has to leave the exit + // code and args behind, or the notify carries no diagnosis. + debugLogger.debug( + `${command} exited with code ${code}. Args: ${args.join(' ')}`, + ); + if (stderr) { + debugLogger.debug(`${command} stderr: ${stderr.trim()}`); + } + if (isEmptyClipboardError(stderr)) { + // The tool was reached and answered correctly: there is simply + // nothing on the clipboard. That is "no image", not "clipboard + // unreachable", so stay quiet — warning here would fire on every + // image-paste keystroke made with an empty clipboard. + resolve(false); + return; + } + // The query itself failed (e.g. the X server is stale or dead), so + // this is "clipboard unreachable", not "clipboard holds no image". + onUnavailable?.(); + resolve(false); + return; + } resolve( - code === 0 && - stdout - .split('\n') - // WSL2 Wayland: Windows clipboard exposes images as BMP (image/bmp), - // which we convert to PNG via python3 PIL. Both formats must be detected. - .some((line) => line === 'image/png' || line === 'image/bmp'), + stdout + .split('\n') + // WSL2 Wayland: Windows clipboard exposes images as BMP (image/bmp), + // which we convert to PNG via python3 PIL. Both formats must be detected. + .some((line) => line === 'image/png' || line === 'image/bmp'), ); }); - child.on('error', () => { + child.on('error', (err) => { clearTimeout(timer); + if (settled) return; + settled = true; + // With the latch suppressing the close handler's fabricated exit code, + // this is a spawn failure's only trace — same line saveFromCommand + // logs for the same event. + debugLogger.debug(`Failed to spawn ${command}:`, err); + onUnavailable?.(); resolve(false); }); } catch { + onUnavailable?.(); resolve(false); } }); @@ -304,7 +398,11 @@ async function checkClipboardForImage( * Checks if the system clipboard contains an image. * Uses platform-native tools (wl-paste/xclip) on Linux. * @param onUnavailable Called when no clipboard backend can be reached: the - * macOS/Windows native module cannot load, or Linux has no wl-paste/xclip. + * macOS/Windows native module cannot load, Linux has no wl-paste/xclip, or + * the detected Linux tool's clipboard query fails. Not called when the + * query succeeds and the clipboard simply holds no image, nor when the tool + * reports an empty clipboard (which exits non-zero but is a correct + * answer). * @returns true if clipboard contains an image */ export async function clipboardHasImage( @@ -315,16 +413,18 @@ export async function clipboardHasImage( try { const tool = getLinuxClipboardTool(); if (tool === 'wl-paste') { - return checkClipboardForImage('wl-paste', ['--list-types']); + return checkClipboardForImage( + 'wl-paste', + ['--list-types'], + onUnavailable, + ); } if (tool === 'xclip') { - return checkClipboardForImage('xclip', [ - '-selection', - 'clipboard', - '-t', - 'TARGETS', - '-o', - ]); + return checkClipboardForImage( + 'xclip', + ['-selection', 'clipboard', '-t', 'TARGETS', '-o'], + onUnavailable, + ); } // No usable clipboard tool: either there is no display server to reach // one through, or the wl-paste/xclip probe failed. Report it instead of @@ -356,36 +456,85 @@ export async function clipboardHasImage( /** * Get the available image MIME types from wl-paste. * Uses cached result if available to avoid redundant calls. + * @param onUnavailable Called when the wl-paste query fails (a non-zero exit + * that is not merely an empty clipboard, spawn error, timeout). A + * successful query with no image types stays quiet, and so does an empty + * clipboard. */ -async function getWlPasteImageTypes(): Promise { +async function getWlPasteImageTypes( + onUnavailable?: () => void, +): Promise { // Return cached result if available if (cachedWlPasteImageTypes !== null) { return cachedWlPasteImageTypes; } return new Promise((resolve) => { - const child = spawn('wl-paste', ['--list-types'], { - stdio: ['ignore', 'pipe', 'ignore'], - }); + let child; + try { + child = spawn('wl-paste', ['--list-types'], { + // fd 2 is captured: an empty clipboard and a real failure share the + // same non-zero exit code, and only stderr tells them apart. + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch (error) { + // Do NOT cache failed result (spawn throw) + // Logged, not swallowed: the save path calls this without an + // onUnavailable callback, so this is the only trace a throw leaves. + debugLogger.error('Failed to spawn wl-paste --list-types:', error); + onUnavailable?.(); + resolve([]); + return; + } let stdout = ''; + let stderr = ''; + // Same latch as checkClipboardForImage: `close` follows both a spawn + // `error` and a timeout `kill()`, and re-entering the close handler for an + // already-reported query would log an exit code the process never had, + // notify twice, and — on the timeout/error paths — risk reaching the + // `cachedWlPasteImageTypes = types` line that must stay success-only. + let settled = false; const timer = setTimeout(() => { + if (settled) return; + settled = true; try { child.kill(); } catch { /* ignore */ } + debugLogger.debug( + `wl-paste --list-types timed out after ${PROCESS_TIMEOUT_MS}ms`, + ); // Do NOT cache failed result (timeout) + onUnavailable?.(); resolve([]); }, PROCESS_TIMEOUT_MS); child.stdout.on('data', (data: Buffer) => { stdout += data.toString(); }); + child.stderr.on('data', (data: Buffer) => { + stderr += data.toString(); + }); child.on('close', (code) => { clearTimeout(timer); + if (settled) return; + settled = true; if (code !== 0) { // Do NOT cache failed result + // Unconditional, like saveFromCommand's: an empty stderr still has to + // leave the exit code behind, or the notify carries no diagnosis. + debugLogger.debug(`wl-paste --list-types exited with code ${code}`); + if (stderr) { + debugLogger.debug(`wl-paste stderr: ${stderr.trim()}`); + } + // `wl-paste --list-types` exits 1 with "Nothing is copied" (>= 2.2) or + // "No selection" (1.x–2.1) when the clipboard is empty. That is a + // correct answer, not an unreachable clipboard, so it stays quiet. + if (!isEmptyClipboardError(stderr)) { + onUnavailable?.(); + } resolve([]); return; } @@ -396,9 +545,16 @@ async function getWlPasteImageTypes(): Promise { cachedWlPasteImageTypes = types; resolve(types); }); - child.on('error', () => { + child.on('error', (err) => { clearTimeout(timer); + if (settled) return; + settled = true; // Do NOT cache failed result (error) + // Logged for the same reason as checkClipboardForImage's error handler: + // the latch removes the close handler's fabricated exit code, so the + // errno has to be recorded here or the failure leaves no trace. + debugLogger.debug('Failed to spawn wl-paste --list-types:', err); + onUnavailable?.(); resolve([]); }); }); diff --git a/packages/cli/src/ui/utils/lowlight-common.ts b/packages/cli/src/ui/utils/lowlight-common.ts new file mode 100644 index 00000000000..1cd004d3526 --- /dev/null +++ b/packages/cli/src/ui/utils/lowlight-common.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +// Named re-exports let the bundler drop lowlight's `all` grammar set, which a +// dynamic import of the package root would evaluate on every launch. +export { common, createLowlight } from 'lowlight'; diff --git a/packages/cli/src/ui/utils/lowlightLoader.ts b/packages/cli/src/ui/utils/lowlightLoader.ts index a566bb7c73b..2aabf431d5c 100644 --- a/packages/cli/src/ui/utils/lowlightLoader.ts +++ b/packages/cli/src/ui/utils/lowlightLoader.ts @@ -82,7 +82,7 @@ export function loadLowlight(): Promise { ); } if (lowlightLoad) return lowlightLoad; - lowlightLoad = import('lowlight') + lowlightLoad = import('./lowlight-common.js') .then((mod) => { const instance = mod.createLowlight(mod.common) as Partial; // Validate the runtime shape before casting. Without this, an upstream diff --git a/packages/cli/src/utils/managed-npm-update.test.ts b/packages/cli/src/utils/managed-npm-update.test.ts index 21605e08dfa..24788fa2283 100644 --- a/packages/cli/src/utils/managed-npm-update.test.ts +++ b/packages/cli/src/utils/managed-npm-update.test.ts @@ -64,6 +64,55 @@ function writeBaseInstallation(root: string, version = '1.0.0'): string { return bootstrap; } +function vendoredRipgrepDir(prefix: string): string { + return path.join( + prefix, + 'node_modules', + '@qwen-code', + 'qwen-code', + 'vendor', + 'ripgrep', + ); +} + +// Mirror the published tarball: pnpm pack normalizes every non-`bin` file to +// 0644, including the vendored ripgrep binaries (#12668). The win32 directory +// ships rg.exe instead of rg and must not fail activation, and COPYING is the +// non-directory entry the real tree carries beside the platform directories — +// so the heal tries `COPYING/rg` and has to swallow that failure too. +function writeVendoredRipgrep(prefix: string): string { + const ripgrepDir = vendoredRipgrepDir(prefix); + for (const platform of ['x64-linux', 'arm64-darwin', 'x64-win32']) { + fs.mkdirSync(path.join(ripgrepDir, platform), { recursive: true }); + } + for (const binary of ['x64-linux/rg', 'arm64-darwin/rg']) { + const filePath = path.join(ripgrepDir, binary); + fs.writeFileSync(filePath, 'fake rg'); + fs.chmodSync(filePath, 0o644); + } + fs.writeFileSync(path.join(ripgrepDir, 'x64-win32', 'rg.exe'), 'fake'); + fs.writeFileSync(path.join(ripgrepDir, 'COPYING'), 'license'); + return ripgrepDir; +} + +// Windows has no POSIX mode bits; stat reports 0o666 for a writable regular +// file, so the exec-bit assertion is only meaningful on the POSIX installs +// this heals. Guarding the assertion rather than the case keeps the win32 +// fixture above — the only coverage of the ENOENT swallow — running everywhere. +function expectExecBit(filePath: string): void { + if (process.platform !== 'win32') { + expect(fs.statSync(filePath).mode & 0o111).not.toBe(0); + } +} + +// Rewind an activated payload to the state a build without the heal leaves it +// in, so the heal of an already-adopted version directory is observable. +function clearVendoredRipgrepExecBits(prefix: string): void { + for (const binary of ['x64-linux/rg', 'arm64-darwin/rg']) { + fs.chmodSync(path.join(vendoredRipgrepDir(prefix), binary), 0o644); + } +} + beforeEach(() => { vi.stubEnv('NPM_CONFIG_GLOBALCONFIG', '/global/npmrc'); }); @@ -131,6 +180,119 @@ describe('managed npm update', () => { expect(fs.readFileSync(runningChunk, 'utf8')).toBe('old chunk'); }); + it('restores the exec bit on vendored ripgrep binaries during activation', async () => { + const root = makeTemporaryDirectory(); + const bootstrap = writeBaseInstallation(root); + const update = prepareManagedNpmUpdate( + '2.0.0', + bootstrap, + path.join(root, 'updates'), + ); + writeInstallation(update.stagingDir, '2.0.0'); + writeVendoredRipgrep(update.stagingDir); + + await activateManagedNpmUpdate(update, '2.0.0', bootstrap); + + const activatedRipgrep = vendoredRipgrepDir(update.versionDir); + for (const binary of ['x64-linux/rg', 'arm64-darwin/rg']) { + expectExecBit(path.join(activatedRipgrep, binary)); + } + }); + + it.skipIf(process.platform === 'win32')( + 'still activates when chmod fails on a vendored binary', + async () => { + const root = makeTemporaryDirectory(); + const bootstrap = writeBaseInstallation(root); + const update = prepareManagedNpmUpdate( + '2.0.0', + bootstrap, + path.join(root, 'updates'), + ); + writeInstallation(update.stagingDir, '2.0.0'); + const stagedRipgrep = writeVendoredRipgrep(update.stagingDir); + // A self-referential symlink makes chmod fail ELOOP: the portable way to + // force a failure here, needing neither a read-only mount nor a non-root + // uid, so it behaves the same on a laptop and on CI. Skipped on win32 + // because creating a symlink there needs a privilege tests do not have. + const looped = path.join(stagedRipgrep, 'x64-linux', 'rg'); + fs.rmSync(looped); + fs.symlinkSync('rg', looped); + + // A binary that cannot be chmod'ed must not turn into a failed + // self-update: a non-zero worker exit is reported as `update-failed`. + await expect( + activateManagedNpmUpdate(update, '2.0.0', bootstrap), + ).resolves.toBeUndefined(); + + expect( + JSON.parse( + fs.readFileSync( + path.join(update.launcherRoot, 'active.json'), + 'utf8', + ), + ), + ).toMatchObject({ version: '2.0.0' }); + // The failure does not abort the loop: the remaining binaries heal. + const activatedRipgrep = vendoredRipgrepDir(update.versionDir); + expectExecBit(path.join(activatedRipgrep, 'arm64-darwin', 'rg')); + }, + ); + + it('heals the payload it adopts when the rename collides', async () => { + const root = makeTemporaryDirectory(); + const bootstrap = writeBaseInstallation(root); + const updateRoot = path.join(root, 'updates'); + const adopted = prepareManagedNpmUpdate('2.0.0', bootstrap, updateRoot); + writeInstallation(adopted.versionDir, '2.0.0'); + writeVendoredRipgrep(adopted.versionDir); + const staged = prepareManagedNpmUpdate('2.0.0', bootstrap, updateRoot); + writeInstallation(staged.stagingDir, '2.0.0'); + writeVendoredRipgrep(staged.stagingDir); + + await activateManagedNpmUpdate(staged, '2.0.0', bootstrap); + + // The freshly healed staging tree is discarded, so the surviving payload + // has to be healed in its place — otherwise the pointer keeps naming a + // 0644 tree and no further update fires for this version. + expect(staged.versionDir).toBe(adopted.versionDir); + expect(fs.existsSync(staged.stagingDir)).toBe(false); + expect( + JSON.parse( + fs.readFileSync(path.join(staged.launcherRoot, 'active.json'), 'utf8'), + ), + ).toMatchObject({ version: '2.0.0' }); + expectExecBit( + path.join(vendoredRipgrepDir(staged.versionDir), 'x64-linux', 'rg'), + ); + }); + + it('heals the higher version it keeps pointing at', async () => { + const root = makeTemporaryDirectory(); + const bootstrap = writeBaseInstallation(root); + const updateRoot = path.join(root, 'updates'); + const newer = prepareManagedNpmUpdate('3.0.0', bootstrap, updateRoot); + writeInstallation(newer.stagingDir, '3.0.0'); + writeVendoredRipgrep(newer.stagingDir); + await activateManagedNpmUpdate(newer, '3.0.0', bootstrap); + clearVendoredRipgrepExecBits(newer.versionDir); + + const older = prepareManagedNpmUpdate('2.0.0', bootstrap, updateRoot); + writeInstallation(older.stagingDir, '2.0.0'); + await activateManagedNpmUpdate(older, '2.0.0', bootstrap); + + // The pointer stays on 3.0.0 and that directory is what the launcher runs, + // so it is the one that has to come out healed. + expect( + JSON.parse( + fs.readFileSync(path.join(older.launcherRoot, 'active.json'), 'utf8'), + ), + ).toMatchObject({ version: '3.0.0' }); + expectExecBit( + path.join(vendoredRipgrepDir(newer.versionDir), 'x64-linux', 'rg'), + ); + }); + it('still activates when the lock is compromised during activation', async () => { const root = makeTemporaryDirectory(); const bootstrap = writeBaseInstallation(root); diff --git a/packages/cli/src/utils/managed-npm-update.ts b/packages/cli/src/utils/managed-npm-update.ts index e6746419e10..37ed9674051 100644 --- a/packages/cli/src/utils/managed-npm-update.ts +++ b/packages/cli/src/utils/managed-npm-update.ts @@ -191,6 +191,36 @@ async function smokeTest(prefix: string): Promise { ); } +// pnpm pack preserves the exec bit only for `bin` entries, so the published +// tarball ships every `vendor/ripgrep/*/rg` as 0644 (#12668). The published +// manifest intentionally carries no postinstall to restore it (#6164 removed +// that surface), so heal the install here instead. Best-effort like the +// source-tree postinstall: a failure must not block the update — the runtime +// ripgrep probe still falls back to a system rg or the JS grep tool. It is +// metadata-only (chmod, never copies or replaces a binary), so it is also safe +// on a version directory a live older session may still be importing. +async function restoreVendoredRipgrepExecBits(prefix: string): Promise { + const ripgrepDir = path.join(packageDir(prefix), 'vendor', 'ripgrep'); + let names: string[]; + try { + names = await fsPromises.readdir(ripgrepDir); + } catch { + return; // This package ships no vendored ripgrep; nothing to heal. + } + for (const name of names) { + try { + await fsPromises.chmod(path.join(ripgrepDir, name, 'rg'), 0o755); + } catch { + // Expected wherever `name` is not a platform directory holding a unix rg + // (the tree also carries COPYING, and x64-win32 ships rg.exe), and no + // other failure may block the update either — the runtime probe handles + // a still-broken binary. Deliberately not logged: this runs in the update + // worker, which returns before route dispatch and so never binds a + // debug-log session, leaving a warn here nowhere to go. + } + } +} + async function readActiveVersion( activeFile: string, expected: { @@ -319,6 +349,7 @@ export async function activateManagedNpmUpdate( await validateInstallation(update.stagingDir, version); await smokeTest(update.stagingDir); + await restoreVendoredRipgrepExecBits(update.stagingDir); const activeFile = path.join(update.launcherRoot, 'active.json'); const release = await lockfile.lock(activeFile, { @@ -347,10 +378,16 @@ export async function activateManagedNpmUpdate( }); if (activeVersion && semver.gt(activeVersion, version)) { try { - await validateInstallation( - path.join(update.launcherRoot, 'versions', activeVersion), + const activeDir = path.join( + update.launcherRoot, + 'versions', activeVersion, ); + await validateInstallation(activeDir, activeVersion); + // Heal the payload that stays active, not only the staged one: the + // existing pointer keeps this version in use, and it may have been + // activated by a build without the heal. + await restoreVendoredRipgrepExecBits(activeDir); await cleanupManagedNpmUpdate(update); return; } catch { @@ -363,6 +400,9 @@ export async function activateManagedNpmUpdate( } catch (error) { if (!fs.existsSync(update.versionDir)) throw error; await validateInstallation(update.versionDir, version); + // The healed staging tree is discarded here, so heal the payload that + // survives the collision instead. + await restoreVendoredRipgrepExecBits(update.versionDir); await cleanupManagedNpmUpdate(update); } diff --git a/packages/cli/src/utils/processUtils.test.ts b/packages/cli/src/utils/processUtils.test.ts index aff233662b3..a9487b9e7a2 100644 --- a/packages/cli/src/utils/processUtils.test.ts +++ b/packages/cli/src/utils/processUtils.test.ts @@ -4,14 +4,17 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { afterEach, beforeEach, vi } from 'vitest'; +import { afterEach, beforeAll, beforeEach, vi } from 'vitest'; import { RELAUNCH_EXIT_CODE, UPDATE_ON_EXIT_MESSAGE, UPDATE_RELAUNCH_EXIT_CODE, + exitCleanly, + getRelaunchExecArgv, relaunchApp, relaunchForUpdate, requestUpdateOnExit, + superviseInProcess, } from './processUtils.js'; import * as cleanup from './cleanup.js'; @@ -55,4 +58,120 @@ describe('processUtils', () => { expect(requestUpdateOnExit()).toBe(false); }); + + describe('getRelaunchExecArgv', () => { + const originalGc = globalThis.gc; + const originalExecArgv = [...process.execArgv]; + + afterEach(() => { + globalThis.gc = originalGc; + process.execArgv = [...originalExecArgv]; + }); + + it('adds --expose-gc when gc was exposed at runtime', () => { + process.execArgv = ['--trace-warnings']; + globalThis.gc = vi.fn() as unknown as typeof globalThis.gc; + expect(getRelaunchExecArgv()).toEqual([ + '--trace-warnings', + '--expose-gc', + ]); + }); + + it('does not repeat --expose-gc from the command line', () => { + process.execArgv = ['--expose-gc']; + globalThis.gc = vi.fn() as unknown as typeof globalThis.gc; + expect(getRelaunchExecArgv()).toEqual(['--expose-gc']); + }); + + it('keeps the flags as they are when gc is not exposed', () => { + process.execArgv = ['--trace-warnings']; + globalThis.gc = undefined; + expect(getRelaunchExecArgv()).toEqual(['--trace-warnings']); + }); + }); + + // Last: superviseInProcess switches module state for the rest of the file. + describe('without a supervising parent', () => { + const onUpdateRelaunch = vi.fn().mockResolvedValue(44); + const execve = vi.fn(); + const originalExecve = process.execve; + + beforeAll(() => { + superviseInProcess(onUpdateRelaunch); + }); + + beforeEach(() => { + process.execve = execve as unknown as typeof process.execve; + }); + + afterEach(() => { + process.execve = originalExecve; + }); + + it('re-execs this process in place after cleanup', async () => { + await relaunchApp(); + expect(runExitCleanup).toHaveBeenCalledTimes(1); + expect(execve).toHaveBeenCalledWith(process.execPath, [ + process.execPath, + ...process.execArgv, + ...process.argv.slice(1), + ]); + }); + + it('keeps gc the launcher exposed at runtime across the restart', async () => { + const originalGc = globalThis.gc; + globalThis.gc = vi.fn() as unknown as typeof globalThis.gc; + try { + await relaunchApp(); + expect(execve).toHaveBeenCalledWith(process.execPath, [ + process.execPath, + ...process.execArgv, + '--expose-gc', + ...process.argv.slice(1), + ]); + } finally { + globalThis.gc = originalGc; + } + }); + + it('runs the update here and exits with its code', async () => { + await relaunchForUpdate(); + expect(runExitCleanup).toHaveBeenCalledTimes(1); + expect(onUpdateRelaunch).toHaveBeenCalledWith(true); + expect(processExit).toHaveBeenCalledWith(44); + }); + + it('exits without updating when no update was requested', async () => { + await exitCleanly(0); + expect(onUpdateRelaunch).not.toHaveBeenCalled(); + expect(processExit).toHaveBeenCalledWith(0); + }); + + it('keeps a requested update for a clean exit only', async () => { + expect(requestUpdateOnExit()).toBe(true); + await exitCleanly(130); + expect(onUpdateRelaunch).not.toHaveBeenCalled(); + expect(processExit).toHaveBeenCalledWith(130); + }); + + it('installs the kept update once, after the first clean exit', async () => { + let finishInstall!: (code: number) => void; + onUpdateRelaunch.mockImplementationOnce( + () => + new Promise((resolve) => { + finishInstall = resolve; + }), + ); + const firstExit = exitCleanly(0); + void exitCleanly(0); + expect(onUpdateRelaunch).toHaveBeenCalledWith(false); + expect(processExit).not.toHaveBeenCalled(); + + finishInstall(44); + await firstExit; + expect(onUpdateRelaunch).toHaveBeenCalledTimes(1); + expect(processExit).toHaveBeenCalledTimes(1); + expect(processExit).toHaveBeenCalledWith(44); + }); + }); }); diff --git a/packages/cli/src/utils/processUtils.ts b/packages/cli/src/utils/processUtils.ts index a838155dd6a..621414f8696 100644 --- a/packages/cli/src/utils/processUtils.ts +++ b/packages/cli/src/utils/processUtils.ts @@ -23,20 +23,90 @@ export const HOST_UPDATE_RELAUNCH_ENV_VAR = 'QWEN_CODE_HOST_UPDATE_RELAUNCH'; export const UPDATE_ON_EXIT_MESSAGE = 'qwen-code:update-on-exit'; +type UpdateRelaunchHandler = ( + relaunchOnFailure: boolean, +) => Promise | number; + +let inProcessUpdateRelaunch: UpdateRelaunchHandler | undefined; +let supervisedInProcess = false; +let updateOnExitRequested = false; +let updatingOnExit = false; + +/** + * Marks this process as running without a supervising parent: restarts + * re-exec this process in place and updates run here instead of in a parent. + */ +export function superviseInProcess(onUpdateRelaunch: UpdateRelaunchHandler) { + supervisedInProcess = true; + inProcessUpdateRelaunch = onUpdateRelaunch; +} + +/** + * Node flags to relaunch this process with. On POSIX the bin launcher exposes + * gc at runtime rather than via argv, so `process.execArgv` alone would start + * the relaunched CLI without the gc the memory-pressure monitor calls. + */ +export function getRelaunchExecArgv(): string[] { + const needsExposeGc = + typeof globalThis.gc === 'function' && + !process.execArgv.includes('--expose-gc'); + return needsExposeGc + ? [...process.execArgv, '--expose-gc'] + : [...process.execArgv]; +} + /** * Exits the process with a special code to signal that the parent process should relaunch it. */ export async function relaunchApp(): Promise { await runExitCleanup(); + // Read loosely: some workspaces' @types/node predate process.execve. + const proc = process as typeof process & { + execve?: (file: string, args: string[]) => never; + }; + if (supervisedInProcess && proc.execve) { + proc.execve(process.execPath, [ + process.execPath, + ...getRelaunchExecArgv(), + ...process.argv.slice(1), + ]); + } process.exit(RELAUNCH_EXIT_CODE); } +/** + * Exits a session that ended on its own, after its exit cleanup has run. A + * process that supervises itself first installs an update requested for exit, + * as a supervising parent does after a clean child exit. + */ +export async function exitCleanly(code: number): Promise { + // A second quit (e.g. another Ctrl+C) must not cut the install short. + if (updatingOnExit) return new Promise(() => {}); + if (code === 0 && updateOnExitRequested && inProcessUpdateRelaunch) { + updateOnExitRequested = false; + updatingOnExit = true; + try { + code = await inProcessUpdateRelaunch(false); + } catch { + code = 1; + } + } + process.exit(code); +} + export async function relaunchForUpdate(): Promise { await runExitCleanup(); + if (inProcessUpdateRelaunch) { + process.exit(await inProcessUpdateRelaunch(true)); + } process.exit(UPDATE_RELAUNCH_EXIT_CODE); } export function requestUpdateOnExit(): boolean { + if (inProcessUpdateRelaunch) { + updateOnExitRequested = true; + return true; + } if (!process.send) return false; try { process.send({ type: UPDATE_ON_EXIT_MESSAGE }); diff --git a/packages/cli/src/utils/relaunch.test.ts b/packages/cli/src/utils/relaunch.test.ts index a2b7cdfe057..2b55e07d90d 100644 --- a/packages/cli/src/utils/relaunch.test.ts +++ b/packages/cli/src/utils/relaunch.test.ts @@ -244,8 +244,8 @@ describe('relaunchAppInChildProcess', () => { }); it('still replaces the process when the environment changed since boot', async () => { - // e.g. `.env` supplied DASHSCOPE_PROXY_BASE_URL, which a provider module - // reads at import, or NODE_EXTRA_CA_CERTS, which only Node's boot reads. + // e.g. `.env` supplied NODE_EXTRA_CA_CERTS, which only Node's boot reads, + // or a value that some module captured when it was imported. process.argv = ['/usr/bin/node', '/app/cli.js', '-p', 'hi']; const execveSpy = vi.fn(() => undefined as never); process.execve = execveSpy; @@ -306,6 +306,76 @@ describe('relaunchAppInChildProcess', () => { } }); + describe('when the bin launcher exposed gc at runtime', () => { + const originalGc = globalThis.gc; + const originalPlatform = process.platform; + + beforeEach(() => { + // cli-entry.js on POSIX: gc comes from v8.setFlagsFromString, not argv. + globalThis.gc = vi.fn() as unknown as typeof globalThis.gc; + process.execArgv = []; + process.argv = ['/usr/bin/node', '/app/cli.js', '--acp']; + }); + + afterEach(() => { + globalThis.gc = originalGc; + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + + it('starts the supervised child with --expose-gc', async () => { + const child = createMockChildProcess(0, false); + mockedSpawn.mockReturnValue(child); + + const promise = relaunchAppInChildProcess([], []); + await vi.waitFor(() => expect(mockedSpawn).toHaveBeenCalledOnce()); + expect(mockedSpawn.mock.calls[0][1]).toEqual([ + '--expose-gc', + '/app/cli.js', + '--acp', + ]); + child.emit('close', 0); + await expect(promise).rejects.toThrow('PROCESS_EXIT_CALLED'); + }); + + it('replaces the process with --expose-gc', async () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + const execveSpy = vi.fn(() => undefined as never); + process.execve = execveSpy; + + await relaunchAppInChildProcess(['--max-old-space-size=4096'], [], { + replaceProcess: true, + }); + + expect(execveSpy).toHaveBeenCalledWith( + '/usr/bin/node', + [ + '/usr/bin/node', + '--expose-gc', + '--max-old-space-size=4096', + '/app/cli.js', + '--acp', + ], + expect.any(Object), + ); + }); + + it('does not repeat --expose-gc already on the command line', async () => { + process.execArgv = ['--expose-gc']; + const child = createMockChildProcess(0, false); + mockedSpawn.mockReturnValue(child); + + const promise = relaunchAppInChildProcess([], []); + await vi.waitFor(() => expect(mockedSpawn).toHaveBeenCalledOnce()); + expect(mockedSpawn.mock.calls[0][1]).toEqual([ + '--expose-gc', + '/app/cli.js', + '--acp', + ]); + child.emit('close', 0); + await expect(promise).rejects.toThrow('PROCESS_EXIT_CALLED'); + }); + }); + it('preserves file values and passes their provenance to the child', async () => { resetEnvironmentTrackingForTesting(); delete process.env['TRACK_A_FILE_VALUE']; diff --git a/packages/cli/src/utils/relaunch.ts b/packages/cli/src/utils/relaunch.ts index 9d93c90bbd8..d1dd4181155 100644 --- a/packages/cli/src/utils/relaunch.ts +++ b/packages/cli/src/utils/relaunch.ts @@ -9,6 +9,7 @@ import { RELAUNCH_EXIT_CODE, UPDATE_ON_EXIT_MESSAGE, UPDATE_RELAUNCH_EXIT_CODE, + getRelaunchExecArgv, } from './processUtils.js'; import { writeStderrLine } from './stdioHelpers.js'; @@ -62,7 +63,7 @@ export async function relaunchAppInChildProcess( const script = process.argv[1]; const scriptArgs = process.argv.slice(2); const nodeArgs = [ - ...process.execArgv, + ...getRelaunchExecArgv(), ...additionalNodeArgs, script, ...additionalScriptArgs, diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 4cb816c2561..3d9ac075c3f 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -2266,6 +2266,8 @@ export type DerivedConfigOverrides = Partial< | 'getPlanFilePath' | 'getWorkspaceContext' | 'getFileService' + | 'getEffectiveInputModalities' + | 'getFileReadCache' | 'getToolRegistry' | 'getPermissionManager' | 'getApprovalMode' diff --git a/packages/core/src/core/openaiContentGenerator/constants.ts b/packages/core/src/core/openaiContentGenerator/constants.ts index 8a607686f0b..ffd956dfc03 100644 --- a/packages/core/src/core/openaiContentGenerator/constants.ts +++ b/packages/core/src/core/openaiContentGenerator/constants.ts @@ -70,7 +70,14 @@ export const DEFAULT_DASHSCOPE_BASE_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1'; export const DEFAULT_DEEPSEEK_BASE_URL = 'https://api.deepseek.com/v1'; export const DEFAULT_OPEN_ROUTER_BASE_URL = 'https://openrouter.ai/api/v1'; -export const DASHSCOPE_PROXY_BASE_URL = process.env['DASHSCOPE_PROXY_BASE_URL']; +/** + * Read at call time, not at module load: `.env` files and `settings.env` are + * applied after this module is imported, and the CLI keeps running in the same + * process when those files only hold values that are read per request. + */ +export function getDashscopeProxyBaseUrl(): string | undefined { + return process.env['DASHSCOPE_PROXY_BASE_URL']; +} /** * Normalize an OpenAI-family baseUrl to the origin the Responses wire dials: diff --git a/packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts b/packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts index 2d669dd9c7c..aaeb6ed20e4 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts @@ -56,19 +56,6 @@ vi.mock('../../../utils/runtimeFetchOptions.js', () => ({ buildRuntimeFetchOptions: vi.fn(), })); -// Mock DASHSCOPE_PROXY_BASE_URL so tests can control its value, while -// delegating every other constant (timeouts, sentinel, resolveRequestTimeout) -// to the real module so the mock cannot drift from the implementation. -vi.mock('../constants.js', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - get DASHSCOPE_PROXY_BASE_URL() { - return process.env['DASHSCOPE_PROXY_BASE_URL']; - }, - }; -}); - describe('DashScopeOpenAICompatibleProvider', () => { let provider: DashScopeOpenAICompatibleProvider; let mockContentGeneratorConfig: ContentGeneratorConfig; diff --git a/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts b/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts index 1e924311281..d4cc7a05be3 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts @@ -6,7 +6,7 @@ import { AuthType } from '../../contentGenerator.js'; import { DEFAULT_MAX_RETRIES, DEFAULT_DASHSCOPE_BASE_URL, - DASHSCOPE_PROXY_BASE_URL, + getDashscopeProxyBaseUrl, resolveRequestTimeout, } from '../constants.js'; import type { @@ -242,9 +242,10 @@ export class DashScopeOpenAICompatibleProvider extends DefaultOpenAICompatiblePr hostname !== null && hostname.endsWith('.alicloudapi.com'); // Check if proxy is configured and matches - const normalizedProxyUrl = DASHSCOPE_PROXY_BASE_URL?.endsWith('/') - ? DASHSCOPE_PROXY_BASE_URL.slice(0, -1) - : DASHSCOPE_PROXY_BASE_URL; + const proxyBaseUrl = getDashscopeProxyBaseUrl(); + const normalizedProxyUrl = proxyBaseUrl?.endsWith('/') + ? proxyBaseUrl.slice(0, -1) + : proxyBaseUrl; const isProxyMatch = Boolean( normalizedProxyUrl && diff --git a/packages/core/src/hooks/hookAggregator.test.ts b/packages/core/src/hooks/hookAggregator.test.ts index 33b7664d3e1..1ca800b19f2 100644 --- a/packages/core/src/hooks/hookAggregator.test.ts +++ b/packages/core/src/hooks/hookAggregator.test.ts @@ -12,6 +12,7 @@ import type { HookOutput, PermissionRequestHookOutput, PostToolBatchHookOutput, + PreToolUseHookOutput, } from './types.js'; describe('HookAggregator', () => { @@ -1273,4 +1274,141 @@ describe('HookAggregator', () => { expect(result.finalOutput?.terminalSequence).toBe('\x07'); }); }); + + describe('mergeWithOrLogic - PreToolUse permissionDecision ranking', () => { + const aggregatePreToolUse = (outputs: HookOutput[]) => { + const results: HookExecutionResult[] = outputs.map((output) => ({ + hookConfig: { type: HookType.Command, command: 'echo test' }, + eventName: HookEventName.PreToolUse, + success: true, + output, + duration: 100, + })); + return aggregator.aggregateResults(results, HookEventName.PreToolUse); + }; + + const preToolUseOutput = ( + permissionDecision: 'allow' | 'deny' | 'ask', + permissionDecisionReason: string, + ): HookOutput => ({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision, + permissionDecisionReason, + }, + }); + + it('deny wins over allow regardless of hook order (deny first)', () => { + const result = aggregatePreToolUse([ + preToolUseOutput('deny', 'deny-hook: DENY'), + preToolUseOutput('allow', 'allow-hook: ALLOW'), + ]); + + const finalOutput = result.finalOutput as PreToolUseHookOutput; + expect(finalOutput.getPermissionDecision()).toBe('deny'); + expect(finalOutput.isDenied()).toBe(true); + }); + + it('deny wins over allow regardless of hook order (allow first)', () => { + const result = aggregatePreToolUse([ + preToolUseOutput('allow', 'allow-hook: ALLOW'), + preToolUseOutput('deny', 'deny-hook: DENY'), + ]); + + const finalOutput = result.finalOutput as PreToolUseHookOutput; + expect(finalOutput.getPermissionDecision()).toBe('deny'); + expect(finalOutput.isDenied()).toBe(true); + }); + + it('deny wins over ask', () => { + const result = aggregatePreToolUse([ + preToolUseOutput('ask', 'ask-hook: ASK'), + preToolUseOutput('deny', 'deny-hook: DENY'), + ]); + + const finalOutput = result.finalOutput as PreToolUseHookOutput; + expect(finalOutput.getPermissionDecision()).toBe('deny'); + }); + + it('ask wins over allow', () => { + const result = aggregatePreToolUse([ + preToolUseOutput('ask', 'ask-hook: ASK'), + preToolUseOutput('allow', 'allow-hook: ALLOW'), + ]); + + const finalOutput = result.finalOutput as PreToolUseHookOutput; + expect(finalOutput.getPermissionDecision()).toBe('ask'); + expect(finalOutput.isAsk()).toBe(true); + }); + + it('reason comes from the winning decision; same-rank reasons concatenate', () => { + const result = aggregatePreToolUse([ + preToolUseOutput('allow', 'allow-hook: ALLOW'), + preToolUseOutput('deny', 'deny-hook-1: DENY'), + preToolUseOutput('deny', 'deny-hook-2: DENY'), + ]); + + const finalOutput = result.finalOutput as PreToolUseHookOutput; + expect(finalOutput.getPermissionDecision()).toBe('deny'); + expect(finalOutput.getPermissionDecisionReason()).toBe( + 'deny-hook-1: DENY\ndeny-hook-2: DENY', + ); + }); + + it('single hook output is unchanged', () => { + const result = aggregatePreToolUse([ + preToolUseOutput('allow', 'allow-hook: ALLOW'), + ]); + + const finalOutput = result.finalOutput as PreToolUseHookOutput; + expect(finalOutput.getPermissionDecision()).toBe('allow'); + expect(finalOutput.getPermissionDecisionReason()).toBe( + 'allow-hook: ALLOW', + ); + }); + + it('keeps last-wins for unrelated hookSpecificOutput fields', () => { + const outputs: HookOutput[] = [ + { + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: 'deny-hook: DENY', + updatedInput: { url: 'https://first.example' }, + }, + }, + { + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'allow', + permissionDecisionReason: 'allow-hook: ALLOW', + updatedInput: { url: 'https://second.example' }, + }, + }, + ]; + + const result = aggregatePreToolUse(outputs); + + const finalOutput = result.finalOutput as PreToolUseHookOutput; + expect(finalOutput.getPermissionDecision()).toBe('deny'); + expect(finalOutput.hookSpecificOutput?.['updatedInput']).toEqual({ + url: 'https://second.example', + }); + }); + + it('hooks without permissionDecision do not lower the merged rank', () => { + const outputs: HookOutput[] = [ + preToolUseOutput('deny', 'deny-hook: DENY'), + { hookSpecificOutput: { additionalContext: 'ctx only' } }, + ]; + + const result = aggregatePreToolUse(outputs); + + const finalOutput = result.finalOutput as PreToolUseHookOutput; + expect(finalOutput.getPermissionDecision()).toBe('deny'); + expect(finalOutput.hookSpecificOutput?.['additionalContext']).toBe( + 'ctx only', + ); + }); + }); }); diff --git a/packages/core/src/hooks/hookAggregator.ts b/packages/core/src/hooks/hookAggregator.ts index db3f7d99bb2..acc50f5dd67 100644 --- a/packages/core/src/hooks/hookAggregator.ts +++ b/packages/core/src/hooks/hookAggregator.ts @@ -21,6 +21,17 @@ import { createDebugLogger } from '../utils/debugLogger.js'; const debugLogger = createDebugLogger('HOOK_AGGREGATOR'); +/** + * Ranking for PreToolUse `hookSpecificOutput.permissionDecision` values. + * Higher rank wins when multiple hooks match the same tool call, so a + * `deny` can never be silently overridden by another hook's `allow`. + */ +const PERMISSION_DECISION_RANK: Record<'allow' | 'ask' | 'deny', number> = { + allow: 1, + ask: 2, + deny: 3, +}; + /** * Aggregated result from multiple hook executions */ @@ -141,7 +152,7 @@ export class HookAggregator { */ private mergeWithOrLogic( outputs: HookOutput[], - _eventName?: HookEventName, + eventName?: HookEventName, ): HookOutput { const merged: HookOutput = {}; const reasons: string[] = []; @@ -151,6 +162,10 @@ export class HookAggregator { let hasContinueFalse = false; let stopReason: string | undefined; const otherHookSpecificFields: Record = {}; + const isPreToolUse = eventName === HookEventName.PreToolUse; + let winningPermissionDecision: 'allow' | 'ask' | 'deny' | undefined; + let winningPermissionRank = 0; + const winningPermissionReasons: string[] = []; for (const output of outputs) { // Check for blocking decisions @@ -176,6 +191,30 @@ export class HookAggregator { // Collect other hookSpecificOutput fields (later values win) if (output.hookSpecificOutput) { + // PreToolUse permission decisions aggregate most-restrictive-first + // (deny > ask > allow), independent of hook completion/config order. + if (isPreToolUse) { + const decision = output.hookSpecificOutput['permissionDecision']; + if ( + decision === 'allow' || + decision === 'ask' || + decision === 'deny' + ) { + const rank = PERMISSION_DECISION_RANK[decision]; + if (rank > winningPermissionRank) { + winningPermissionRank = rank; + winningPermissionDecision = decision; + winningPermissionReasons.length = 0; + } + if (rank === winningPermissionRank) { + const reason = + output.hookSpecificOutput['permissionDecisionReason']; + if (typeof reason === 'string' && reason.length > 0) { + winningPermissionReasons.push(reason); + } + } + } + } for (const [key, value] of Object.entries(output.hookSpecificOutput)) { if (key === 'artifacts' && Array.isArray(value)) { const validArtifacts = value.filter(isToolArtifactLike); @@ -189,6 +228,11 @@ export class HookAggregator { debugLogger.warn( 'Dropped malformed hookSpecificOutput.artifacts; expected array', ); + } else if ( + isPreToolUse && + (key === 'permissionDecision' || key === 'permissionDecisionReason') + ) { + // Handled by the ranking logic above, not last-wins. } else if (key !== 'additionalContext' && key !== 'artifacts') { otherHookSpecificFields[key] = value; } @@ -231,6 +275,13 @@ export class HookAggregator { const hookSpecificOutput: Record = { ...otherHookSpecificFields, }; + if (winningPermissionDecision !== undefined) { + hookSpecificOutput['permissionDecision'] = winningPermissionDecision; + if (winningPermissionReasons.length > 0) { + hookSpecificOutput['permissionDecisionReason'] = + winningPermissionReasons.join('\n'); + } + } if (additionalContexts.length > 0) { hookSpecificOutput['additionalContext'] = additionalContexts.join('\n'); } diff --git a/packages/core/src/ide/ide-client.test.ts b/packages/core/src/ide/ide-client.test.ts index cace1722ca5..4a2f4966ea3 100644 --- a/packages/core/src/ide/ide-client.test.ts +++ b/packages/core/src/ide/ide-client.test.ts @@ -101,6 +101,7 @@ describe('IdeClient', () => { _resetCachedIdeServerHost(); // Mock environment variables + vi.stubEnv('TERM_PROGRAM', 'vscode'); process.env['QWEN_CODE_IDE_WORKSPACE_PATH'] = '/test/workspace'; delete process.env['QWEN_CODE_IDE_SERVER_PORT']; delete process.env['QWEN_CODE_IDE_SERVER_STDIO_COMMAND']; @@ -149,6 +150,16 @@ describe('IdeClient', () => { afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + it('skips the IDE process walk outside VS Code terminals', async () => { + vi.stubEnv('TERM_PROGRAM', 'iTerm.app'); + vi.mocked(getIdeProcessInfo).mockClear(); + + await IdeClient.getInstance(); + + expect(getIdeProcessInfo).not.toHaveBeenCalled(); }); describe('createProxyAwareFetch', () => { diff --git a/packages/core/src/ide/ide-client.ts b/packages/core/src/ide/ide-client.ts index f85730a4fda..d78e67f2b7b 100644 --- a/packages/core/src/ide/ide-client.ts +++ b/packages/core/src/ide/ide-client.ts @@ -121,10 +121,14 @@ export class IdeClient { if (!IdeClient.instancePromise) { IdeClient.instancePromise = (async () => { const client = new IdeClient(); - client.ideProcessInfo = await getIdeProcessInfo(); + // The process walk spawns `ps` per ancestor. Only VS Code terminals + // need it (detectIde and legacy pid-file discovery), so skip it elsewhere. + if (process.env['TERM_PROGRAM'] === 'vscode') { + client.ideProcessInfo = await getIdeProcessInfo(); + } client.connectionConfig = await client.getConnectionConfigFromFile(); client.currentIde = detectIde( - client.ideProcessInfo, + client.ideProcessInfo ?? { pid: 0, command: '' }, client.connectionConfig?.ideInfo, ); return client; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 939ade9351a..0667a9d77a2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -150,6 +150,15 @@ export * from './tools/ripGrep.js'; export * from './tools/sdk-control-client-transport.js'; export * from './tools/client-mcp-registrar.js'; export * from './tools/modifiable-tool.js'; +export type { + ManagedToolExecutionResult, + ManagedToolInvocationStatus, + ManagedToolV2Client, +} from './tools/managed-tool-runtime.js'; +export { + managedToolDigest, + type ManagedToolInvocationReference, +} from './tools/managed-tool-protocol.js'; export * from './managed-runtime/managed-session-records.js'; // Selective re-exports of types/utilities from tool files (avoids loading full tool modules) diff --git a/packages/core/src/managed-runtime/contracts/managed-session-store-v1.fixtures.json b/packages/core/src/managed-runtime/contracts/managed-session-store-v1.fixtures.json new file mode 100644 index 00000000000..bc3251cf23f --- /dev/null +++ b/packages/core/src/managed-runtime/contracts/managed-session-store-v1.fixtures.json @@ -0,0 +1,155 @@ +{ + "contractVersion": 1, + "headers": { + "tenant": "X-Qwen-Tenant-Id", + "writerToken": "X-Qwen-Managed-Writer-Token" + }, + "limits": { + "maxInlineResourceBytes": 65536, + "maxResourcesPerTransaction": 1024, + "maxTransactionBytes": 8388608, + "maxTransactionEvents": 256, + "minimumWriterTokenLength": 32, + "maximumWriterTokenLength": 512, + "minimumLeaseDurationMs": 1000, + "maximumLeaseDurationMs": 300000 + }, + "sessionKey": { + "tenantId": "tenant-fixture", + "workspaceId": "workspace-fixture", + "sessionId": "550e8400-e29b-41d4-a716-446655440000" + }, + "resources": [ + { + "resourceId": "definition-1", + "kind": "managed-session-definition", + "schemaVersion": 1, + "utf8": "{\"model\":\"fixture\",\"温度\":0}", + "bytesBase64": "eyJtb2RlbCI6ImZpeHR1cmUiLCLmuKnluqYiOjB9", + "byteLength": 30, + "digest": "461919d7b3a57890026af59f1f4374dba47dd8a2ff66610a331b3e67de5370a7" + }, + { + "resourceId": "root-snapshot-1", + "kind": "managed-session-root-snapshot", + "schemaVersion": 1, + "utf8": "{\"version\":1,\"messages\":[]}", + "bytesBase64": "eyJ2ZXJzaW9uIjoxLCJtZXNzYWdlcyI6W119", + "byteLength": 27, + "digest": "866aa33c6691ecab9f159e7209077f9e643b981cc9f437d03c6e49352d86dd52" + } + ], + "genesisTransaction": { + "records": [ + { + "uuid": "engine-record", + "parentUuid": null, + "sessionId": "550e8400-e29b-41d4-a716-446655440000", + "timestamp": "2026-09-22T00:00:00.000Z", + "type": "system", + "subtype": "session_execution_engine", + "cwd": "/workspace", + "version": "fixture", + "systemPayload": { + "version": 1, + "engine": "managed" + } + }, + { + "uuid": "header-record", + "parentUuid": "engine-record", + "sessionId": "550e8400-e29b-41d4-a716-446655440000", + "timestamp": "2026-09-22T00:00:00.001Z", + "type": "system", + "subtype": "managed_session_header_v1", + "cwd": "/workspace", + "version": "fixture", + "managedSession": { + "formatVersion": 1, + "minimumReader": "managed-session/1", + "sessionKey": { + "tenantId": "tenant-fixture", + "workspaceId": "workspace-fixture", + "sessionId": "550e8400-e29b-41d4-a716-446655440000" + }, + "engine": "managed", + "definitionRef": { + "resourceId": "definition-1", + "kind": "managed-session-definition", + "schemaVersion": 1, + "byteLength": 30, + "digest": "461919d7b3a57890026af59f1f4374dba47dd8a2ff66610a331b3e67de5370a7" + }, + "rootSnapshotRef": { + "resourceId": "root-snapshot-1", + "kind": "managed-session-root-snapshot", + "schemaVersion": 1, + "byteLength": 27, + "digest": "866aa33c6691ecab9f159e7209077f9e643b981cc9f437d03c6e49352d86dd52" + }, + "createdBy": "fixture" + } + } + ], + "jsonl": "{\"uuid\":\"engine-record\",\"parentUuid\":null,\"sessionId\":\"550e8400-e29b-41d4-a716-446655440000\",\"timestamp\":\"2026-09-22T00:00:00.000Z\",\"type\":\"system\",\"subtype\":\"session_execution_engine\",\"cwd\":\"/workspace\",\"version\":\"fixture\",\"systemPayload\":{\"version\":1,\"engine\":\"managed\"}}\n{\"uuid\":\"header-record\",\"parentUuid\":\"engine-record\",\"sessionId\":\"550e8400-e29b-41d4-a716-446655440000\",\"timestamp\":\"2026-09-22T00:00:00.001Z\",\"type\":\"system\",\"subtype\":\"managed_session_header_v1\",\"cwd\":\"/workspace\",\"version\":\"fixture\",\"managedSession\":{\"formatVersion\":1,\"minimumReader\":\"managed-session/1\",\"sessionKey\":{\"tenantId\":\"tenant-fixture\",\"workspaceId\":\"workspace-fixture\",\"sessionId\":\"550e8400-e29b-41d4-a716-446655440000\"},\"engine\":\"managed\",\"definitionRef\":{\"resourceId\":\"definition-1\",\"kind\":\"managed-session-definition\",\"schemaVersion\":1,\"byteLength\":30,\"digest\":\"461919d7b3a57890026af59f1f4374dba47dd8a2ff66610a331b3e67de5370a7\"},\"rootSnapshotRef\":{\"resourceId\":\"root-snapshot-1\",\"kind\":\"managed-session-root-snapshot\",\"schemaVersion\":1,\"byteLength\":27,\"digest\":\"866aa33c6691ecab9f159e7209077f9e643b981cc9f437d03c6e49352d86dd52\"},\"createdBy\":\"fixture\"}}\n", + "recordBytesBase64": "eyJ1dWlkIjoiZW5naW5lLXJlY29yZCIsInBhcmVudFV1aWQiOm51bGwsInNlc3Npb25JZCI6IjU1MGU4NDAwLWUyOWItNDFkNC1hNzE2LTQ0NjY1NTQ0MDAwMCIsInRpbWVzdGFtcCI6IjIwMjYtMDktMjJUMDA6MDA6MDAuMDAwWiIsInR5cGUiOiJzeXN0ZW0iLCJzdWJ0eXBlIjoic2Vzc2lvbl9leGVjdXRpb25fZW5naW5lIiwiY3dkIjoiL3dvcmtzcGFjZSIsInZlcnNpb24iOiJmaXh0dXJlIiwic3lzdGVtUGF5bG9hZCI6eyJ2ZXJzaW9uIjoxLCJlbmdpbmUiOiJtYW5hZ2VkIn19CnsidXVpZCI6ImhlYWRlci1yZWNvcmQiLCJwYXJlbnRVdWlkIjoiZW5naW5lLXJlY29yZCIsInNlc3Npb25JZCI6IjU1MGU4NDAwLWUyOWItNDFkNC1hNzE2LTQ0NjY1NTQ0MDAwMCIsInRpbWVzdGFtcCI6IjIwMjYtMDktMjJUMDA6MDA6MDAuMDAxWiIsInR5cGUiOiJzeXN0ZW0iLCJzdWJ0eXBlIjoibWFuYWdlZF9zZXNzaW9uX2hlYWRlcl92MSIsImN3ZCI6Ii93b3Jrc3BhY2UiLCJ2ZXJzaW9uIjoiZml4dHVyZSIsIm1hbmFnZWRTZXNzaW9uIjp7ImZvcm1hdFZlcnNpb24iOjEsIm1pbmltdW1SZWFkZXIiOiJtYW5hZ2VkLXNlc3Npb24vMSIsInNlc3Npb25LZXkiOnsidGVuYW50SWQiOiJ0ZW5hbnQtZml4dHVyZSIsIndvcmtzcGFjZUlkIjoid29ya3NwYWNlLWZpeHR1cmUiLCJzZXNzaW9uSWQiOiI1NTBlODQwMC1lMjliLTQxZDQtYTcxNi00NDY2NTU0NDAwMDAifSwiZW5naW5lIjoibWFuYWdlZCIsImRlZmluaXRpb25SZWYiOnsicmVzb3VyY2VJZCI6ImRlZmluaXRpb24tMSIsImtpbmQiOiJtYW5hZ2VkLXNlc3Npb24tZGVmaW5pdGlvbiIsInNjaGVtYVZlcnNpb24iOjEsImJ5dGVMZW5ndGgiOjMwLCJkaWdlc3QiOiI0NjE5MTlkN2IzYTU3ODkwMDI2YWY1OWYxZjQzNzRkYmE0N2RkOGEyZmY2NjYxMGEzMzFiM2U2N2RlNTM3MGE3In0sInJvb3RTbmFwc2hvdFJlZiI6eyJyZXNvdXJjZUlkIjoicm9vdC1zbmFwc2hvdC0xIiwia2luZCI6Im1hbmFnZWQtc2Vzc2lvbi1yb290LXNuYXBzaG90Iiwic2NoZW1hVmVyc2lvbiI6MSwiYnl0ZUxlbmd0aCI6MjcsImRpZ2VzdCI6Ijg2NmFhMzNjNjY5MWVjYWI5ZjE1OWU3MjA5MDc3ZjllNjQzYjk4MWNjOWY0MzdkMDNjNmU0OTM1MmQ4NmRkNTIifSwiY3JlYXRlZEJ5IjoiZml4dHVyZSJ9fQo=", + "byteLength": 1145, + "recordDigest": "01f67f0e57d360e308957daeb8b3db4fec2dfc4f911249936c63bee3b1f6e50c", + "expectedRequest": { + "transactionId": "session.create:550e8400-e29b-41d4-a716-446655440000", + "operation": "session.create", + "commandId": "session.create:550e8400-e29b-41d4-a716-446655440000", + "contentDigest": "01f67f0e57d360e308957daeb8b3db4fec2dfc4f911249936c63bee3b1f6e50c", + "firstSequence": 0, + "lastSequence": 0, + "eventCount": 0, + "eventsDigest": null, + "previousCommitDigest": null, + "commitDigest": null, + "activationEpoch": 0, + "recordCount": 2 + } + }, + "errors": { + "writerConflict": { + "status": 409, + "code": "managed_session_writer_conflict" + }, + "idempotencyConflict": { + "status": 409, + "code": "managed_session_idempotency_conflict" + }, + "recoveryConflict": { + "status": 409, + "code": "managed_session_recovery_conflict" + }, + "resourceMissing": { + "status": 409, + "code": "managed_session_resource_missing" + }, + "resourceNotFound": { + "status": 404, + "code": "managed_session_resource_not_found" + }, + "ossDisabled": { + "status": 501, + "code": "managed_session_oss_disabled" + }, + "invalidRequest": { + "status": 400, + "code": "invalid_managed_session_store_request" + }, + "payloadTooLarge": { + "status": 413, + "code": "managed_session_payload_too_large" + }, + "journalCorrupt": { + "status": 500, + "code": "managed_session_journal_corrupt" + }, + "headCorrupt": { + "status": 500, + "code": "managed_session_head_corrupt" + } + } +} diff --git a/packages/core/src/managed-runtime/embedded-harness-scheduler.test.ts b/packages/core/src/managed-runtime/embedded-harness-scheduler.test.ts new file mode 100644 index 00000000000..4901e5105b7 --- /dev/null +++ b/packages/core/src/managed-runtime/embedded-harness-scheduler.test.ts @@ -0,0 +1,336 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { mkdtemp, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { EmbeddedHarnessScheduler } from './embedded-harness-scheduler.js'; +import { + FileManagedActivationStore, + ManagedActivationStaleLeaseError, + type ManagedActivationDescriptor, +} from './managed-activation-store.js'; + +function activation( + activationId: string, + overrides: Partial = {}, +): ManagedActivationDescriptor { + return { + tenantId: 'tenant-a', + sessionId: `session-${activationId}`, + activationId, + payloadRef: `event:${activationId}`, + reason: 'user_message', + recovery: 'replay_safe', + ...overrides, + }; +} + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void; + return { + promise: new Promise((done) => { + resolve = done; + }), + resolve, + }; +} + +async function waitUntil(predicate: () => boolean): Promise { + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + if (predicate()) return; + await new Promise((resolve) => setImmediate(resolve)); + } + throw new Error('Timed out waiting for scheduler state.'); +} + +describe('EmbeddedHarnessScheduler', () => { + let root: string; + let filePath: string; + const schedulers: EmbeddedHarnessScheduler[] = []; + + beforeEach(async () => { + root = await mkdtemp(path.join(os.tmpdir(), 'embedded-harness-')); + filePath = path.join(root, 'activations.jsonl'); + }); + + afterEach(async () => { + for (const scheduler of schedulers) scheduler.dispose(); + schedulers.length = 0; + vi.useRealTimers(); + await rm(root, { recursive: true, force: true }); + }); + + it('bounds concurrent asynchronous activation slots', async () => { + const store = await FileManagedActivationStore.open(filePath); + const gates = new Map( + ['a1', 'a2', 'a3'].map((id) => [id, deferred()] as const), + ); + const started: string[] = []; + let running = 0; + let maximumRunning = 0; + const scheduler = new EmbeddedHarnessScheduler({ + store, + workerId: 'worker-a', + maxActiveSlots: 2, + maxQueued: 10, + maxQueuedPerTenant: 10, + leaseDurationMs: 60_000, + hasMemoryHeadroom: () => true, + handler: async (item) => { + started.push(item.activationId); + running += 1; + maximumRunning = Math.max(maximumRunning, running); + await gates.get(item.activationId)!.promise; + running -= 1; + }, + }); + schedulers.push(scheduler); + for (const id of gates.keys()) await scheduler.submit(activation(id)); + + await scheduler.start(); + expect(started).toEqual(['a1', 'a2']); + expect(scheduler.activeSlotCount).toBe(2); + + gates.get('a1')!.resolve(); + await waitUntil(() => started.length === 3); + expect(started).toEqual(['a1', 'a2', 'a3']); + expect(maximumRunning).toBe(2); + + gates.get('a2')!.resolve(); + gates.get('a3')!.resolve(); + await waitUntil(() => scheduler.activeSlotCount === 0); + expect( + ['a1', 'a2', 'a3'].map((id) => store.get(activation(id))?.outcome), + ).toEqual(['completed', 'completed', 'completed']); + }); + + it('alternates tenants while preserving each Session FIFO', async () => { + const store = await FileManagedActivationStore.open(filePath); + const order: string[] = []; + const scheduler = new EmbeddedHarnessScheduler({ + store, + workerId: 'worker-a', + maxActiveSlots: 1, + maxQueued: 10, + maxQueuedPerTenant: 10, + leaseDurationMs: 60_000, + hasMemoryHeadroom: () => true, + handler: async (item) => { + order.push(item.activationId); + }, + }); + schedulers.push(scheduler); + await scheduler.submit(activation('a1', { sessionId: 'session-a' })); + await scheduler.submit(activation('a2', { sessionId: 'session-a' })); + await scheduler.submit(activation('a3', { sessionId: 'session-a3' })); + await scheduler.submit( + activation('b1', { tenantId: 'tenant-b', sessionId: 'session-b' }), + ); + await scheduler.submit( + activation('b2', { tenantId: 'tenant-b', sessionId: 'session-b' }), + ); + + await scheduler.start(); + await waitUntil( + () => order.length === 5 && scheduler.activeSlotCount === 0, + ); + expect(order).toEqual(['a1', 'b1', 'a2', 'b2', 'a3']); + }); + + it('leaves work queued until memory headroom is available', async () => { + const store = await FileManagedActivationStore.open(filePath); + let hasMemory = false; + const handled = vi.fn(); + const item = activation('a1'); + const scheduler = new EmbeddedHarnessScheduler({ + store, + workerId: 'worker-a', + maxActiveSlots: 2, + maxQueued: 10, + maxQueuedPerTenant: 10, + leaseDurationMs: 60_000, + hasMemoryHeadroom: () => hasMemory, + handler: async () => { + handled(); + }, + }); + schedulers.push(scheduler); + await scheduler.start(); + await expect(scheduler.submit(item)).resolves.toMatchObject({ + created: true, + activation: { status: 'queued' }, + }); + + await waitUntil(() => scheduler.isMemoryBlocked); + expect(scheduler.isMemoryBlocked).toBe(true); + expect(scheduler.activeSlotCount).toBe(0); + expect(store.get(item)).toMatchObject({ status: 'queued' }); + + hasMemory = true; + scheduler.notifyCapacityChanged(); + await waitUntil(() => store.get(item)?.status === 'released'); + expect(handled).toHaveBeenCalledOnce(); + }); + + it('recovers an expired assignment with a higher fenced epoch', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + let now = 0; + const original = await FileManagedActivationStore.open(filePath, { + clock: () => now, + }); + const item = activation('a1'); + await original.enqueue(item, { maxQueued: 10, maxQueuedPerTenant: 10 }); + const oldLease = await original.claim(item, 'dead-worker', 10); + + now = 9; + const recovered = await FileManagedActivationStore.open(filePath, { + clock: () => now, + }); + const gate = deferred(); + let observedEpoch: number | undefined; + const scheduler = new EmbeddedHarnessScheduler({ + store: recovered, + workerId: 'replacement-worker', + maxActiveSlots: 1, + maxQueued: 10, + maxQueuedPerTenant: 10, + leaseDurationMs: 30_000, + hasMemoryHeadroom: () => true, + handler: async (_activation, context) => { + observedEpoch = context.fence.epoch; + await gate.promise; + }, + }); + schedulers.push(scheduler); + + await scheduler.start(); + expect(observedEpoch).toBeUndefined(); + now = 10; + await vi.advanceTimersByTimeAsync(1); + await waitUntil(() => observedEpoch !== undefined); + expect(observedEpoch).toBe(2); + now = 11; + await expect( + recovered.release(oldLease!, 'completed'), + ).rejects.toBeInstanceOf(ManagedActivationStaleLeaseError); + now = 12; + gate.resolve(); + await waitUntil(() => recovered.get(item)?.status === 'released'); + expect(recovered.get(item)?.outcome).toBe('completed'); + }); + + it('renews the lease while a handler remains active', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + let now = 100; + const store = await FileManagedActivationStore.open(filePath, { + clock: () => now, + }); + const item = activation('a1'); + const gate = deferred(); + const renewed = deferred(); + const originalRenew = store.renew.bind(store); + vi.spyOn(store, 'renew').mockImplementation(async (...args) => { + const result = await originalRenew(...args); + renewed.resolve(); + return result; + }); + const scheduler = new EmbeddedHarnessScheduler({ + store, + workerId: 'worker-a', + maxActiveSlots: 1, + maxQueued: 10, + maxQueuedPerTenant: 10, + leaseDurationMs: 90, + hasMemoryHeadroom: () => true, + handler: async () => { + await gate.promise; + }, + }); + schedulers.push(scheduler); + await scheduler.submit(item); + await scheduler.start(); + + now = 130; + await vi.advanceTimersByTimeAsync(30); + await renewed.promise; + expect(store.get(item)?.lease?.expiresAt).toBe(220); + + now = 131; + gate.resolve(); + await waitUntil(() => store.get(item)?.status === 'released'); + }); + + it('durably records handler failure without halting the Worker', async () => { + const store = await FileManagedActivationStore.open(filePath); + const onActivationError = vi.fn(); + const item = activation('a1'); + const scheduler = new EmbeddedHarnessScheduler({ + store, + workerId: 'worker-a', + maxActiveSlots: 1, + maxQueued: 10, + maxQueuedPerTenant: 10, + leaseDurationMs: 60_000, + hasMemoryHeadroom: () => true, + handler: async () => { + throw new Error('handler failed'); + }, + onActivationError, + }); + schedulers.push(scheduler); + await scheduler.submit(item); + await scheduler.start(); + await waitUntil(() => store.get(item)?.status === 'released'); + + expect(store.get(item)?.outcome).toBe('failed'); + expect(onActivationError).toHaveBeenCalledWith( + expect.objectContaining({ activationId: 'a1' }), + expect.objectContaining({ message: 'handler failed' }), + ); + expect(scheduler.haltedError).toBeUndefined(); + }); + + it('does not launch a handler after disposal races with a durable claim', async () => { + const store = await FileManagedActivationStore.open(filePath); + const item = activation('a1'); + const claimEntered = deferred(); + const allowClaim = deferred(); + const originalClaim = store.claim.bind(store); + vi.spyOn(store, 'claim').mockImplementation(async (...args) => { + claimEntered.resolve(); + await allowClaim.promise; + return originalClaim(...args); + }); + const handler = vi.fn(); + const scheduler = new EmbeddedHarnessScheduler({ + store, + workerId: 'worker-a', + maxActiveSlots: 1, + maxQueued: 10, + maxQueuedPerTenant: 10, + leaseDurationMs: 60_000, + hasMemoryHeadroom: () => true, + handler: async () => { + handler(); + }, + }); + schedulers.push(scheduler); + await scheduler.submit(item); + + const starting = scheduler.start(); + await claimEntered.promise; + scheduler.dispose(); + allowClaim.resolve(); + await expect(starting).resolves.toBeUndefined(); + + expect(handler).not.toHaveBeenCalled(); + expect(scheduler.activeSlotCount).toBe(0); + expect(store.get(item)?.status).toBe('assigned'); + }); +}); diff --git a/packages/core/src/managed-runtime/embedded-harness-scheduler.ts b/packages/core/src/managed-runtime/embedded-harness-scheduler.ts new file mode 100644 index 00000000000..341ed28faf9 --- /dev/null +++ b/packages/core/src/managed-runtime/embedded-harness-scheduler.ts @@ -0,0 +1,374 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + ManagedActivationStaleLeaseError, + type FileManagedActivationStore, + type ManagedActivationDescriptor, + type ManagedActivationEnqueueResult, + type ManagedActivationFence, + type ManagedActivationLease, + type ManagedActivationSnapshot, +} from './managed-activation-store.js'; + +export interface ManagedActivationHandlerContext { + readonly fence: ManagedActivationFence; + readonly signal: AbortSignal; +} + +export type ManagedActivationHandler = ( + activation: ManagedActivationDescriptor, + context: ManagedActivationHandlerContext, +) => Promise; + +export interface EmbeddedHarnessSchedulerOptions { + readonly store: FileManagedActivationStore; + readonly workerId: string; + readonly maxActiveSlots: number; + readonly maxQueued: number; + readonly maxQueuedPerTenant: number; + readonly leaseDurationMs: number; + readonly hasMemoryHeadroom: () => boolean; + readonly handler: ManagedActivationHandler; + readonly onActivationError?: ( + activation: ManagedActivationDescriptor, + error: unknown, + ) => void; +} + +interface ActiveRun { + readonly activation: ManagedActivationDescriptor; + readonly controller: AbortController; + lease: ManagedActivationLease; + renewalTimer?: NodeJS.Timeout; + renewal?: Promise; + finishing: boolean; + abandoned: boolean; +} + +function requirePositiveInteger(name: string, value: number): void { + if (!Number.isSafeInteger(value) || value < 1) { + throw new Error(`${name} must be a positive safe integer.`); + } +} + +function activationKey(activation: { + tenantId: string; + sessionId: string; + activationId: string; +}): string { + return JSON.stringify([ + activation.tenantId, + activation.sessionId, + activation.activationId, + ]); +} + +function sessionKey(activation: { + tenantId: string; + sessionId: string; +}): string { + return JSON.stringify([activation.tenantId, activation.sessionId]); +} + +function toError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +/** + * Bounded asynchronous Harness scheduler for one long-lived service process. + * It intentionally creates neither child processes nor Worker threads. + */ +export class EmbeddedHarnessScheduler { + private readonly store: FileManagedActivationStore; + private readonly options: EmbeddedHarnessSchedulerOptions; + private readonly active = new Map(); + private pumpTail: Promise = Promise.resolve(); + private recoveryTimer: NodeJS.Timeout | undefined; + private lastTenantId: string | undefined; + private started = false; + private disposed = false; + private memoryBlocked = false; + private fatalError: Error | undefined; + + constructor(options: EmbeddedHarnessSchedulerOptions) { + if (options.workerId.trim().length === 0) { + throw new Error('workerId must be a non-empty string.'); + } + requirePositiveInteger('maxActiveSlots', options.maxActiveSlots); + requirePositiveInteger('maxQueued', options.maxQueued); + requirePositiveInteger('maxQueuedPerTenant', options.maxQueuedPerTenant); + if (options.maxQueuedPerTenant > options.maxQueued) { + throw new Error('maxQueuedPerTenant cannot exceed maxQueued.'); + } + requirePositiveInteger('leaseDurationMs', options.leaseDurationMs); + this.options = Object.freeze({ ...options }); + this.store = options.store; + } + + get activeSlotCount(): number { + return this.active.size; + } + + get isMemoryBlocked(): boolean { + return this.memoryBlocked; + } + + get haltedError(): Error | undefined { + return this.fatalError; + } + + async start(): Promise { + this.assertUsable(); + if (this.started) return; + this.started = true; + await this.requestPump(); + } + + async submit( + activation: ManagedActivationDescriptor, + ): Promise { + this.assertUsable(); + let result: ManagedActivationEnqueueResult; + try { + result = await this.store.enqueue(activation, { + maxQueued: this.options.maxQueued, + maxQueuedPerTenant: this.options.maxQueuedPerTenant, + }); + } catch (error) { + if (this.store.haltedError) this.halt(this.store.haltedError); + throw error; + } + if (this.started) { + void this.requestPump().catch(() => undefined); + } + return result; + } + + notifyCapacityChanged(): void { + if (!this.started || this.disposed || this.fatalError) return; + void this.requestPump().catch(() => undefined); + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + if (this.recoveryTimer) clearTimeout(this.recoveryTimer); + this.recoveryTimer = undefined; + const error = new Error( + `Harness Worker '${this.options.workerId}' stopped.`, + ); + for (const run of this.active.values()) { + run.abandoned = true; + if (run.renewalTimer) clearTimeout(run.renewalTimer); + run.controller.abort(error); + } + } + + private requestPump(): Promise { + const result = this.pumpTail.then(async () => { + this.assertUsable(); + await this.pump(); + }); + this.pumpTail = result.catch((error: unknown) => { + this.halt(toError(error)); + }); + return result; + } + + private async pump(): Promise { + if (this.recoveryTimer) clearTimeout(this.recoveryTimer); + this.recoveryTimer = undefined; + + while (this.active.size < this.options.maxActiveSlots) { + const candidates = this.store + .listRunnable() + .filter( + (candidate) => !this.active.has(activationKey(candidate.descriptor)), + ); + if (candidates.length === 0) { + this.memoryBlocked = false; + this.scheduleRecoveryWake(); + return; + } + if (!this.options.hasMemoryHeadroom()) { + this.memoryBlocked = true; + return; + } + this.memoryBlocked = false; + const candidate = this.selectTenantFair(candidates)!; + const lease = await this.store.claim( + candidate.descriptor, + this.options.workerId, + this.options.leaseDurationMs, + ); + if (!lease) continue; + if (this.disposed || this.fatalError) return; + this.launch(candidate.descriptor, lease); + } + } + + private selectTenantFair( + candidates: ManagedActivationSnapshot[], + ): ManagedActivationSnapshot | undefined { + if (candidates.length === 0) return undefined; + const tenantIds = [ + ...new Set(candidates.map((candidate) => candidate.descriptor.tenantId)), + ]; + const previousIndex = this.lastTenantId + ? tenantIds.indexOf(this.lastTenantId) + : -1; + const tenantId = tenantIds[(previousIndex + 1) % tenantIds.length]; + this.lastTenantId = tenantId; + return candidates.find( + (candidate) => candidate.descriptor.tenantId === tenantId, + ); + } + + private launch( + activation: ManagedActivationDescriptor, + lease: ManagedActivationLease, + ): void { + const run: ActiveRun = { + activation: structuredClone(activation), + lease, + controller: new AbortController(), + finishing: false, + abandoned: false, + }; + this.active.set(activationKey(activation), run); + this.scheduleRenewal(run); + void this.execute(run); + } + + private async execute(run: ActiveRun): Promise { + let outcome: 'completed' | 'failed' = 'completed'; + let handlerError: unknown; + try { + await this.options.handler(structuredClone(run.activation), { + fence: Object.freeze({ + tenantId: run.lease.tenantId, + sessionId: run.lease.sessionId, + activationId: run.lease.activationId, + workerId: run.lease.workerId, + epoch: run.lease.epoch, + }), + signal: run.controller.signal, + }); + } catch (error) { + outcome = 'failed'; + handlerError = error; + } + + run.finishing = true; + if (run.renewalTimer) clearTimeout(run.renewalTimer); + await run.renewal; + + if (!run.abandoned && !this.disposed && !this.fatalError) { + try { + await this.store.release(run.lease, outcome); + } catch (error) { + if (error instanceof ManagedActivationStaleLeaseError) { + run.abandoned = true; + run.controller.abort(error); + } else { + this.halt(toError(error)); + } + } + } + + this.active.delete(activationKey(run.activation)); + if (handlerError !== undefined && !run.abandoned && !this.disposed) { + try { + this.options.onActivationError?.(run.activation, handlerError); + } catch (error) { + this.halt(toError(error)); + } + } + if (!this.disposed && !this.fatalError) { + void this.requestPump().catch(() => undefined); + } + } + + private scheduleRenewal(run: ActiveRun): void { + const delay = Math.max(1, Math.floor(this.options.leaseDurationMs / 3)); + run.renewalTimer = setTimeout(() => { + run.renewal = this.renew(run).finally(() => { + run.renewal = undefined; + }); + }, delay); + run.renewalTimer.unref(); + } + + private async renew(run: ActiveRun): Promise { + if (run.finishing || run.abandoned || this.disposed) return; + try { + run.lease = await this.store.renew( + run.lease, + this.options.leaseDurationMs, + ); + if (!run.finishing && !run.abandoned && !this.disposed) { + this.scheduleRenewal(run); + } + } catch (error) { + run.abandoned = true; + run.controller.abort(error); + if (!(error instanceof ManagedActivationStaleLeaseError)) { + this.halt(toError(error)); + } + } + } + + private scheduleRecoveryWake(): void { + const activeKeys = new Set(this.active.keys()); + const seenSessions = new Set(); + let expiry: number | undefined; + for (const activation of this.store.listPending()) { + const key = sessionKey(activation.descriptor); + if (seenSessions.has(key)) continue; + seenSessions.add(key); + if ( + activation.status !== 'assigned' || + activeKeys.has(activationKey(activation.descriptor)) + ) { + continue; + } + expiry = + expiry === undefined + ? activation.lease!.expiresAt + : Math.min(expiry, activation.lease!.expiresAt); + } + if (expiry === undefined) return; + const delay = Math.min( + Math.max(0, expiry - this.store.getCurrentTime()), + 2_147_483_647, + ); + this.recoveryTimer = setTimeout(() => { + this.recoveryTimer = undefined; + void this.requestPump().catch(() => undefined); + }, delay); + this.recoveryTimer.unref(); + } + + private halt(error: Error): void { + if (this.fatalError) return; + this.fatalError = error; + if (this.recoveryTimer) clearTimeout(this.recoveryTimer); + this.recoveryTimer = undefined; + for (const run of this.active.values()) { + run.abandoned = true; + if (run.renewalTimer) clearTimeout(run.renewalTimer); + run.controller.abort(error); + } + } + + private assertUsable(): void { + if (this.fatalError) throw this.fatalError; + if (this.disposed) { + throw new Error(`Harness Worker '${this.options.workerId}' is disposed.`); + } + } +} diff --git a/packages/core/src/managed-runtime/http-managed-session-store.test.ts b/packages/core/src/managed-runtime/http-managed-session-store.test.ts new file mode 100644 index 00000000000..267734b2a6a --- /dev/null +++ b/packages/core/src/managed-runtime/http-managed-session-store.test.ts @@ -0,0 +1,768 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { mkdtemp, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { openManagedSession } from './managed-session-assembly.js'; +import { + ManagedSessionMessageProjection, + projectManagedSessionRecords, +} from './managed-session-message-projection.js'; +import type { ManagedSessionStoreHttpError } from './http-managed-session-store.js'; +import { createHttpManagedSessionStores } from './http-managed-session-store.js'; +import type { ManagedSessionKey } from './managed-session-records.js'; +import { + createInitialHarnessCheckpoint, + encodeHarnessCheckpointV1, +} from './managed-harness-checkpoint.js'; + +const SESSION_KEY: ManagedSessionKey = { + tenantId: 'tenant-a', + workspaceId: 'workspace-a', + sessionId: '550e8400-e29b-41d4-a716-446655440000', +}; +const TOKEN_A = 'a'.repeat(32); +const TOKEN_B = 'b'.repeat(32); + +describe('HTTP Managed Session store', () => { + const temporaryDirectories: string[] = []; + + afterEach(async () => { + vi.restoreAllMocks(); + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); + }); + + it('commits staged resources and restores without a local transcript', async () => { + const server = new FakeManagedSessionStore(); + const runtimeBaseDir = await mkdtemp( + path.join(tmpdir(), 'managed-http-store-'), + ); + temporaryDirectories.push(runtimeBaseDir); + const transcriptPath = path.join(runtimeBaseDir, 'session.jsonl'); + const firstStores = createHttpManagedSessionStores({ + baseUrl: 'http://session-store.test', + sessionKey: SESSION_KEY, + writerId: 'harness-a', + writerToken: TOKEN_A, + fetchFn: server.fetch, + }); + const definitionRef = await firstStores.resourceStore.publish( + 'managed-session-definition', + Buffer.from('{"model":"test"}', 'utf8'), + ); + const rootSnapshotRef = await firstStores.resourceStore.publish( + 'managed-session-root-snapshot', + Buffer.from('{"version":1,"messages":[]}', 'utf8'), + ); + + const first = await openManagedSession({ + runtimeBaseDir, + sessionId: SESSION_KEY.sessionId, + transcriptPath, + sessionKey: SESSION_KEY, + cwd: '/workspace', + version: 'test', + workerId: 'harness-a', + activationLeaseDurationMs: 60_000, + journalStore: firstStores.journalStore, + resourceStore: firstStores.resourceStore, + create: { + definitionRef, + rootSnapshotRef, + createdBy: 'test', + }, + }); + + expect(server.commits[0]).toMatchObject({ + expectedJournalRevision: 0, + expectedCommittedSequence: 0, + operation: 'session.create', + firstSequence: 0, + lastSequence: 0, + eventCount: 0, + activationEpoch: 0, + recordCount: 2, + }); + expect(server.commits[0]?.['resources']).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + resourceId: definitionRef.resourceId, + bytesBase64: Buffer.from('{"model":"test"}').toString('base64'), + }), + expect.objectContaining({ + resourceId: rootSnapshotRef.resourceId, + bytesBase64: Buffer.from('{"version":1,"messages":[]}').toString( + 'base64', + ), + }), + ]), + ); + expect(server.commits[1]).toMatchObject({ + expectedJournalRevision: 1, + expectedCommittedSequence: 0, + operation: 'installActivation', + firstSequence: 1, + lastSequence: 1, + eventCount: 1, + activationEpoch: 1, + recordCount: 2, + }); + const genesisRecords = Buffer.from( + String(server.commits[0]?.['recordBytesBase64']), + 'base64', + ) + .toString('utf8') + .trimEnd() + .split('\n') + .map((line) => JSON.parse(line) as unknown); + const foreignHeader = genesisRecords[1] as { + managedSession: { sessionKey: { sessionId: string } }; + }; + foreignHeader.managedSession.sessionKey.sessionId = + '550e8400-e29b-41d4-a716-446655440001'; + const firstJournal = await firstStores.journalStore.open({ + sessionKey: SESSION_KEY, + }); + await expect( + firstJournal.appendTransaction(genesisRecords), + ).rejects.toThrow(/different session/); + expect(server.commits).toHaveLength(2); + foreignHeader.managedSession.sessionKey.sessionId = SESSION_KEY.sessionId; + (foreignHeader as unknown as { sessionId: string }).sessionId = + '550e8400-e29b-41d4-a716-446655440001'; + await expect( + firstJournal.appendTransaction(genesisRecords), + ).rejects.toThrow(/different session/); + expect(server.commits).toHaveLength(2); + const record = { + uuid: 'record-user-1', + parentUuid: null, + sessionId: SESSION_KEY.sessionId, + timestamp: '2026-09-22T00:00:00.000Z', + type: 'user' as const, + cwd: '/workspace', + version: 'test', + message: { role: 'user' as const, parts: [{ text: 'restore me' }] }, + }; + await new ManagedSessionMessageProjection( + first.authority, + first.resources, + ).commit( + { + operation: 'message.commit', + commandId: 'message-1', + sessionKey: SESSION_KEY, + contentDigest: 'c'.repeat(64), + }, + { record }, + { + class: 'harness', + activation: first.activation, + }, + ); + const messageCommitCount = server.commits.length; + const foreignEventRecords = Buffer.from( + String(server.commits.at(-1)?.['recordBytesBase64']), + 'base64', + ) + .toString('utf8') + .trimEnd() + .split('\n') + .map((line) => JSON.parse(line) as unknown); + const foreignEvent = foreignEventRecords[0] as { + managedSession: { sessionKey: { tenantId: string } }; + }; + foreignEvent.managedSession.sessionKey.tenantId = 'tenant-b'; + await expect( + firstJournal.appendTransaction(foreignEventRecords), + ).rejects.toThrow(/different session/); + expect(server.commits).toHaveLength(messageCommitCount); + await expect(stat(transcriptPath)).rejects.toMatchObject({ + code: 'ENOENT', + }); + + await first.close(); + const committedBeforeRestore = server.commits.length; + const secondStores = createHttpManagedSessionStores({ + baseUrl: 'http://session-store.test', + sessionKey: SESSION_KEY, + writerId: 'harness-b', + writerToken: TOKEN_B, + fetchFn: server.fetch, + }); + const [reader, repeatedReader] = await Promise.all([ + secondStores.journalStore.open({ sessionKey: SESSION_KEY }), + secondStores.journalStore.open({ sessionKey: SESSION_KEY }), + ]); + expect(repeatedReader).toBe(reader); + const scan = await reader.read(); + await expect( + projectManagedSessionRecords({ + scan, + resources: secondStores.resourceStore, + }), + ).resolves.toEqual([record]); + const restored = await openManagedSession({ + runtimeBaseDir, + sessionId: SESSION_KEY.sessionId, + transcriptPath, + sessionKey: SESSION_KEY, + cwd: '/workspace', + version: 'test', + workerId: 'harness-b', + activationLeaseDurationMs: 60_000, + journalStore: secondStores.journalStore, + resourceStore: secondStores.resourceStore, + }); + + expect(server.transactionReads).toBeGreaterThan(0); + expect(server.commits).toHaveLength(committedBeforeRestore + 1); + expect(server.commits.at(-1)).toMatchObject({ + operation: 'installActivation', + activationEpoch: 2, + }); + expect(restored.activation.epoch).toBe(2); + expect(await restored.authority.restoreBundle()).toMatchObject({ + sessionKey: SESSION_KEY, + recoveryStatus: 'ok', + }); + await restored.close(); + }); + + it('commits only checkpoint resources and their dependencies for a cold owner', async () => { + const server = new FakeManagedSessionStore(); + const runtimeBaseDir = await mkdtemp( + path.join(tmpdir(), 'managed-http-store-'), + ); + + temporaryDirectories.push(runtimeBaseDir); + const transcriptPath = path.join(runtimeBaseDir, 'session.jsonl'); + const firstStores = createHttpManagedSessionStores({ + baseUrl: 'http://session-store.test', + sessionKey: SESSION_KEY, + writerId: 'harness-a', + writerToken: TOKEN_A, + fetchFn: server.fetch, + }); + const definitionRef = await firstStores.resourceStore.publish( + 'managed-session-definition', + Buffer.from('{"model":"test"}', 'utf8'), + ); + const rootSnapshotRef = await firstStores.resourceStore.publish( + 'managed-session-root-snapshot', + Buffer.from('{"version":1,"messages":[]}', 'utf8'), + ); + + const first = await openManagedSession({ + runtimeBaseDir, + sessionId: SESSION_KEY.sessionId, + transcriptPath, + sessionKey: SESSION_KEY, + cwd: '/workspace', + version: 'test', + workerId: 'harness-a', + activationLeaseDurationMs: 60_000, + journalStore: firstStores.journalStore, + resourceStore: firstStores.resourceStore, + create: { + definitionRef, + rootSnapshotRef, + createdBy: 'test', + }, + }); + + const historyBytes = Buffer.from( + '[{"role":"user","parts":[{"text":"earlier context"}]}]', + ); + const historyRef = await first.resources.publish( + 'managed-api-history', + historyBytes, + ); + const unusedRef = await first.resources.publish( + 'managed-api-history', + Buffer.from('[]'), + ); + const checkpoint = createInitialHarnessCheckpoint({ + sessionKey: SESSION_KEY, + checkpointId: 'ckpt-2', + coveredSequence: 1, + activationId: first.activation.activationId, + turnId: null, + promptId: null, + definitionRevision: definitionRef.resourceId, + configRevision: rootSnapshotRef.resourceId, + inputDigest: definitionRef.digest, + previousCheckpointId: null, + }); + const state = encodeHarnessCheckpointV1({ + ...checkpoint, + resume: { ...checkpoint.resume, apiHistoryRef: historyRef }, + }); + await first.authority.commitCheckpoint( + { + operation: 'commitCheckpoint', + commandId: 'test-checkpoint', + sessionKey: SESSION_KEY, + contentDigest: 'c'.repeat(64), + }, + { state, boundary: null }, + { class: 'harness', activation: first.activation }, + ); + const checkpointRef = first.authority.latestCheckpoint!.stateRef; + const committedResources = server.commits.at(-1)!['resources'] as Array<{ + resourceId: string; + }>; + expect( + committedResources.map(({ resourceId }) => resourceId).sort(), + ).toEqual([checkpointRef.resourceId, historyRef.resourceId].sort()); + await first.close(); + + const secondStores = createHttpManagedSessionStores({ + baseUrl: 'http://session-store.test', + sessionKey: SESSION_KEY, + writerId: 'harness-b', + writerToken: TOKEN_B, + fetchFn: server.fetch, + }); + const restored = await openManagedSession({ + runtimeBaseDir, + sessionId: SESSION_KEY.sessionId, + transcriptPath, + sessionKey: SESSION_KEY, + cwd: '/workspace', + version: 'test', + workerId: 'harness-b', + activationLeaseDurationMs: 60_000, + journalStore: secondStores.journalStore, + resourceStore: secondStores.resourceStore, + }); + try { + await expect(restored.authority.readCheckpointState()).resolves.toEqual( + state, + ); + await expect(restored.resources.read(historyRef)).resolves.toEqual( + historyBytes, + ); + await expect(restored.resources.read(unusedRef)).rejects.toMatchObject({ + status: 404, + remoteCode: 'managed_session_resource_not_found', + }); + } finally { + await restored.close(); + } + }); + + it('surfaces structured Java errors without exposing the token', async () => { + const fetchFn = vi.fn().mockResolvedValue( + jsonResponse( + { + error: { + code: 'managed_session_writer_conflict', + message: 'The Managed Session has another writer.', + }, + }, + 409, + ), + ); + const stores = createHttpManagedSessionStores({ + baseUrl: 'http://session-store.test', + sessionKey: SESSION_KEY, + writerId: 'harness-a', + writerToken: TOKEN_A, + fetchFn, + }); + + const error = await stores.journalStore + .open({ sessionKey: SESSION_KEY }) + .catch((cause: unknown) => cause); + expect(error).toMatchObject>({ + status: 409, + remoteCode: 'managed_session_writer_conflict', + message: 'The Managed Session has another writer.', + }); + expect(String(error)).not.toContain(TOKEN_A); + }); + + it('persists a fenced recovery block through the active writer', async () => { + const server = new FakeManagedSessionStore(); + const stores = createHttpManagedSessionStores({ + baseUrl: 'http://session-store.test', + sessionKey: SESSION_KEY, + writerId: 'harness-a', + writerToken: TOKEN_A, + fetchFn: server.fetch, + }); + const handle = await stores.journalStore.open({ sessionKey: SESSION_KEY }); + + await handle.blockRecovery?.({ + status: 'BLOCKED_EXECUTION', + detailCode: 'runtime_execution_outcome_unknown', + }); + + expect(server.recoveryBlocks).toEqual([ + { + workspaceId: SESSION_KEY.workspaceId, + writerId: 'harness-a', + writerGeneration: 1, + recoveryStatus: 'BLOCKED_EXECUTION', + recoveryDetailCode: 'runtime_execution_outcome_unknown', + }, + ]); + await stores.close(); + }); + + it('seals without committing an activation boundary after recovery is blocked', async () => { + const server = new FakeManagedSessionStore(); + const runtimeBaseDir = await mkdtemp( + path.join(tmpdir(), 'managed-http-store-blocked-'), + ); + temporaryDirectories.push(runtimeBaseDir); + const stores = createHttpManagedSessionStores({ + baseUrl: 'http://session-store.test', + sessionKey: SESSION_KEY, + writerId: 'harness-a', + writerToken: TOKEN_A, + fetchFn: server.fetch, + }); + const definitionRef = await stores.resourceStore.publish( + 'managed-session-definition', + Buffer.from('{"model":"test"}', 'utf8'), + ); + const rootSnapshotRef = await stores.resourceStore.publish( + 'managed-session-root-snapshot', + Buffer.from('{"version":1,"messages":[]}', 'utf8'), + ); + const session = await openManagedSession({ + runtimeBaseDir, + sessionId: SESSION_KEY.sessionId, + transcriptPath: path.join(runtimeBaseDir, 'session.jsonl'), + sessionKey: SESSION_KEY, + cwd: '/workspace', + version: 'test', + workerId: 'harness-a', + activationLeaseDurationMs: 60_000, + journalStore: stores.journalStore, + resourceStore: stores.resourceStore, + create: { + definitionRef, + rootSnapshotRef, + createdBy: 'test', + }, + }); + await session.authority.blockRecovery({ + status: 'BLOCKED_EXECUTION', + detailCode: 'runtime_execution_outcome_unknown', + }); + const committedBeforeClose = server.commits.length; + + await session.close(); + + expect(server.commits).toHaveLength(committedBeforeClose); + expect(server.sealCount).toBe(1); + }); + + it('does not restart renewal while an in-flight renewal races sealing', async () => { + vi.useFakeTimers(); + const server = new FakeManagedSessionStore(); + let finishRenewal!: () => void; + let finishSeal!: () => void; + const renewalGate = new Promise((resolve) => { + finishRenewal = resolve; + }); + const sealGate = new Promise((resolve) => { + finishSeal = resolve; + }); + const fetchFn = vi.fn(async (input, init) => { + const response = await server.fetch(input, init); + if (requestUrl(input).endsWith('/writers:renew')) await renewalGate; + if (requestUrl(input).endsWith('/writers:seal')) await sealGate; + return response; + }); + const stores = createHttpManagedSessionStores({ + baseUrl: 'http://session-store.test', + sessionKey: SESSION_KEY, + writerId: 'harness-a', + writerToken: TOKEN_A, + leaseDurationMs: 1000, + fetchFn, + }); + try { + await stores.journalStore.open({ sessionKey: SESSION_KEY }); + await vi.advanceTimersByTimeAsync(500); + expect( + fetchFn.mock.calls.some(([input]) => + requestUrl(input).endsWith('/writers:renew'), + ), + ).toBe(true); + const closing = stores.close(); + finishRenewal(); + await vi.advanceTimersByTimeAsync(0); + finishSeal(); + await closing; + await vi.advanceTimersByTimeAsync(1000); + expect( + fetchFn.mock.calls.filter(([input]) => + requestUrl(input).endsWith('/writers:renew'), + ), + ).toHaveLength(1); + } finally { + finishRenewal(); + finishSeal(); + await stores.close(); + vi.clearAllTimers(); + vi.useRealTimers(); + } + }); + + it('rejects resources that require the unimplemented OSS path', async () => { + const stores = createHttpManagedSessionStores({ + baseUrl: 'http://session-store.test', + sessionKey: SESSION_KEY, + writerId: 'harness-a', + writerToken: TOKEN_A, + fetchFn: vi.fn(), + }); + + await expect( + stores.resourceStore.publish( + 'managed-context', + Buffer.alloc(64 * 1024 + 1), + ), + ).rejects.toThrow(/OSS storage is not enabled/); + }); +}); + +class FakeManagedSessionStore { + readonly commits: Array> = []; + readonly recoveryBlocks: Array> = []; + transactionReads = 0; + sealCount = 0; + readonly fetch = vi.fn(async (input, init) => { + const url = new URL(requestUrl(input)); + const headers = new Headers(init?.headers); + expect(headers.get('X-Qwen-Tenant-Id')).toBe(SESSION_KEY.tenantId); + expect(headers.get('X-Qwen-Managed-Writer-Token')).toMatch(/^[ab]{32}$/); + const suffix = url.pathname.slice( + url.pathname.indexOf('/internal/managed-session-store/v1/sessions/') + + `/internal/managed-session-store/v1/sessions/${SESSION_KEY.sessionId}` + .length, + ); + expect(headers.get('Accept')).toBe( + suffix.startsWith('/resources/') + ? 'application/octet-stream, application/json' + : 'application/json', + ); + if (suffix === '/writers:acquire') { + this.writerGeneration++; + this.state = 'ACTIVE'; + this.leaseUntil = Date.now() + 300_000; + return jsonResponse(this.grant()); + } + if (suffix === '/writers:renew') { + this.leaseUntil = Date.now() + 300_000; + return jsonResponse(this.grant()); + } + if (suffix === '/writers:seal') { + this.sealCount++; + this.state = 'SEALED'; + return jsonResponse({ + writerGeneration: this.writerGeneration, + state: 'SEALED', + replayed: false, + }); + } + if (suffix === '/restore') { + return jsonResponse({ + state: this.state, + storageVersion: 1, + writerGeneration: this.writerGeneration, + journalRevision: this.transactions.length, + committedSequence: this.committedSequence, + ...(this.lastCommitDigest === null + ? {} + : { lastCommitDigest: this.lastCommitDigest }), + activationEpoch: this.activationEpoch, + compactedThroughRevision: 0, + recoveryStatus: this.recoveryStatus, + ...(this.recoveryDetailCode === null + ? {} + : { recoveryDetailCode: this.recoveryDetailCode }), + }); + } + if (suffix === '/recovery:block') { + const body = JSON.parse(String(init?.body)) as Record; + this.recoveryBlocks.push(body); + this.recoveryStatus = String(body['recoveryStatus']); + this.recoveryDetailCode = String(body['recoveryDetailCode']); + return jsonResponse({ + writerGeneration: this.writerGeneration, + recoveryStatus: this.recoveryStatus, + recoveryDetailCode: this.recoveryDetailCode, + replayed: false, + }); + } + if (suffix === '/transactions') { + this.transactionReads++; + const after = Number(url.searchParams.get('afterRevision') ?? 0); + const transactions = this.transactions.slice(after, after + 100); + return jsonResponse({ + transactions, + nextRevision: after + transactions.length, + hasMore: after + transactions.length < this.transactions.length, + }); + } + if (suffix === '/transactions:commit') { + const body = JSON.parse(String(init?.body)) as Record; + this.commits.push(body); + const resources = body['resources'] as Array>; + for (const resource of resources) { + const resourceId = String(resource['resourceId']); + const encoded = resource['bytesBase64']; + if (typeof encoded === 'string') { + this.resources.set(resourceId, { + bytes: Buffer.from(encoded, 'base64'), + kind: String(resource['kind']), + schemaVersion: Number(resource['schemaVersion']), + digest: String(resource['digest']), + }); + } else if (!this.resources.has(resourceId)) { + return jsonResponse( + { + error: { + code: 'managed_session_resource_missing', + message: 'A referenced resource is missing.', + }, + }, + 409, + ); + } + } + const journalRevision = this.transactions.length + 1; + const recordBytes = Buffer.from( + String(body['recordBytesBase64']), + 'base64', + ); + this.transactions.push({ + journalRevision, + transactionId: body['transactionId'], + operation: body['operation'], + commandId: body['commandId'], + contentDigest: body['contentDigest'], + firstSequence: body['firstSequence'], + lastSequence: body['lastSequence'], + eventCount: body['eventCount'], + eventsDigest: body['eventsDigest'], + previousCommitDigest: body['previousCommitDigest'], + commitDigest: body['commitDigest'], + writerGeneration: body['writerGeneration'], + activationEpoch: body['activationEpoch'], + latestCheckpointResourceId: body['latestCheckpointResourceId'], + recordEncoding: 'identity', + recordBytesBase64: body['recordBytesBase64'], + byteLength: recordBytes.byteLength, + recordDigest: body['recordDigest'], + }); + this.committedSequence = Number(body['lastSequence']); + this.lastCommitDigest = (body['commitDigest'] as string | null) ?? null; + this.activationEpoch = Number(body['activationEpoch']); + return jsonResponse({ + journalRevision, + transactionId: body['transactionId'], + commandId: body['commandId'], + operation: body['operation'], + firstSequence: body['firstSequence'], + lastSequence: body['lastSequence'], + committedSequence: body['lastSequence'], + commitDigest: body['commitDigest'], + replayed: false, + }); + } + if (suffix.startsWith('/resources/')) { + const resourceId = decodeURIComponent(suffix.slice('/resources/'.length)); + const resource = this.resources.get(resourceId); + if (resource === undefined) { + return jsonResponse( + { + error: { + code: 'managed_session_resource_not_found', + message: 'Resource not found.', + }, + }, + 404, + ); + } + return new Response(resource.bytes, { + status: 200, + headers: { + 'Cache-Control': 'no-store', + 'Content-Type': 'application/octet-stream', + 'X-Qwen-Resource-Kind': resource.kind, + 'X-Qwen-Resource-Schema-Version': String(resource.schemaVersion), + 'X-Qwen-Resource-Digest': resource.digest, + }, + }); + } + return jsonResponse( + { error: { code: 'not_found', message: `Unknown ${suffix}` } }, + 404, + ); + }); + + private state = 'SEALED'; + private writerGeneration = 0; + private leaseUntil = 0; + private committedSequence = 0; + private lastCommitDigest: string | null = null; + private activationEpoch = 0; + private recoveryStatus = 'READY'; + private recoveryDetailCode: string | null = null; + private readonly transactions: Array> = []; + private readonly resources = new Map< + string, + { + bytes: Buffer; + kind: string; + schemaVersion: number; + digest: string; + } + >(); + + private grant(): Record { + return { + writerGeneration: this.writerGeneration, + leaseUntil: this.leaseUntil, + journalRevision: this.transactions.length, + committedSequence: this.committedSequence, + ...(this.lastCommitDigest === null + ? {} + : { lastCommitDigest: this.lastCommitDigest }), + activationEpoch: this.activationEpoch, + replayed: false, + }; + } +} + +function requestUrl(input: URL | RequestInfo): string { + if (typeof input === 'string') return input; + if (input instanceof URL) return input.toString(); + return input.url; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { + 'Cache-Control': 'no-store', + 'Content-Type': 'application/json', + }, + }); +} diff --git a/packages/core/src/managed-runtime/http-managed-session-store.ts b/packages/core/src/managed-runtime/http-managed-session-store.ts new file mode 100644 index 00000000000..dc782943023 --- /dev/null +++ b/packages/core/src/managed-runtime/http-managed-session-store.ts @@ -0,0 +1,1181 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash, randomBytes, randomUUID } from 'node:crypto'; +import { managedToolDigest } from '../tools/managed-tool-protocol.js'; +import { + MANAGED_SESSION_COMMIT_SUBTYPE, + MANAGED_SESSION_EVENT_SUBTYPE, + MANAGED_SESSION_HEADER_SUBTYPE, + MANAGED_SESSION_LIMITS, + ManagedSessionRecordError, + assertManagedSessionDurableRef, + assertManagedSessionStableId, + managedSessionKeysEqual, + parseManagedSessionCommitMarker, + parseManagedSessionEvent, + parseManagedSessionHeader, + parseManagedSessionRecordJson, + type ManagedSessionDurableRef, + type ManagedSessionKey, +} from './managed-session-records.js'; +import type { ManagedSessionJsonValue } from './managed-session-inbox.js'; +import { tryParseHarnessCheckpointV1 } from './managed-harness-checkpoint.js'; +import { + scanManagedSessionJournal, + type ManagedSessionJournalHandle, + type ManagedSessionJournalScan, + type ManagedSessionJournalStore, + type ManagedSessionResourceStore, +} from './managed-session-storage.js'; + +export const HTTP_MANAGED_SESSION_STORE_CONTRACT = { + tenantHeader: 'X-Qwen-Tenant-Id', + writerTokenHeader: 'X-Qwen-Managed-Writer-Token', + maxInlineResourceBytes: 64 * 1024, + maxResourcesPerTransaction: 1024, + minimumWriterTokenLength: 32, + maximumWriterTokenLength: 512, + minimumLeaseDurationMs: 1_000, + maximumLeaseDurationMs: 300_000, +} as const; + +const WRITER_TOKEN = new RegExp( + `^[A-Za-z0-9_-]{${HTTP_MANAGED_SESSION_STORE_CONTRACT.minimumWriterTokenLength},${HTTP_MANAGED_SESSION_STORE_CONTRACT.maximumWriterTokenLength}}$`, +); +const DEFAULT_LEASE_DURATION_MS = 60_000; +const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; + +export interface HttpManagedSessionStoreOptions { + readonly baseUrl: string; + readonly sessionKey: ManagedSessionKey; + readonly writerId: string; + readonly leaseDurationMs?: number; + readonly requestTimeoutMs?: number; + readonly fetchFn?: typeof fetch; + /** Test and controlled-recovery hook. Normal callers generate a fresh token. */ + readonly writerToken?: string; +} + +export interface HttpManagedSessionStores { + readonly journalStore: ManagedSessionJournalStore; + readonly resourceStore: ManagedSessionResourceStore; + close(): Promise; +} + +export class ManagedSessionStoreHttpError extends ManagedSessionRecordError { + constructor( + readonly status: number, + readonly remoteCode: string, + message: string, + ) { + super(message); + this.name = 'ManagedSessionStoreHttpError'; + } +} + +export function createHttpManagedSessionStores( + options: HttpManagedSessionStoreOptions, +): HttpManagedSessionStores { + const resources = new HttpManagedSessionResourceStore(); + const client = new ManagedSessionStoreHttpClient(options, resources); + resources.bind(client); + return { + journalStore: new HttpManagedSessionJournalStore(client), + resourceStore: resources, + close: () => client.seal(), + }; +} + +class HttpManagedSessionJournalStore implements ManagedSessionJournalStore { + private handle: ManagedSessionJournalHandle | undefined; + private opening: Promise | undefined; + + constructor(private readonly client: ManagedSessionStoreHttpClient) {} + + async open(request: { + readonly sessionKey: ManagedSessionKey; + }): Promise { + if (!managedSessionKeysEqual(request.sessionKey, this.client.sessionKey)) { + throw new ManagedSessionRecordError( + 'the HTTP Managed Session store belongs to a different session.', + ); + } + if (this.handle !== undefined) return this.handle; + if (this.opening !== undefined) return this.opening; + const opening = (async () => { + await this.client.acquireWriter(); + const handle = new HttpManagedSessionJournalHandle(this.client); + this.handle = handle; + return handle; + })(); + this.opening = opening; + try { + return await opening; + } finally { + if (this.opening === opening) this.opening = undefined; + } + } +} + +class HttpManagedSessionJournalHandle implements ManagedSessionJournalHandle { + readonly sessionKey: ManagedSessionKey; + + constructor(private readonly client: ManagedSessionStoreHttpClient) { + this.sessionKey = client.sessionKey; + } + + read( + options: { + readonly maxBytes?: number; + } = {}, + ): Promise { + return this.client.readJournal(options.maxBytes); + } + + appendTransaction(records: readonly unknown[]): Promise { + return this.client.appendTransaction(records); + } + + blockRecovery(request: { + readonly status: + | 'BLOCKED_RESOURCE' + | 'BLOCKED_WORKSPACE' + | 'BLOCKED_EXECUTION'; + readonly detailCode: string; + }): Promise { + return this.client.blockRecovery(request); + } + + seal(): Promise { + return this.client.seal(); + } + + abort(): Promise { + return this.client.seal(); + } +} + +class HttpManagedSessionResourceStore implements ManagedSessionResourceStore { + private readonly staged = new Map< + string, + { ref: ManagedSessionDurableRef; bytes: Buffer } + >(); + private client: ManagedSessionStoreHttpClient | undefined; + + bind(client: ManagedSessionStoreHttpClient): void { + this.client = client; + } + + async publish( + kind: string, + source: Buffer, + ): Promise { + const safeKind = assertManagedSessionStableId(kind, 'resource kind'); + if ( + source.byteLength > + HTTP_MANAGED_SESSION_STORE_CONTRACT.maxInlineResourceBytes + ) { + throw new ManagedSessionRecordError( + `resource bytes exceed the ${HTTP_MANAGED_SESSION_STORE_CONTRACT.maxInlineResourceBytes}-byte inline limit; OSS storage is not enabled.`, + ); + } + const bytes = Buffer.from(source); + const ref: ManagedSessionDurableRef = { + resourceId: randomUUID(), + kind: safeKind, + schemaVersion: 1, + byteLength: bytes.byteLength, + digest: sha256(bytes), + }; + this.staged.set(ref.resourceId, { ref, bytes }); + return ref; + } + + async read(ref: ManagedSessionDurableRef): Promise { + const validated = assertManagedSessionDurableRef( + ref as unknown as ManagedSessionJsonValue, + 'resource', + ); + const staged = this.staged.get(validated.resourceId); + if (staged !== undefined) { + requireSameRef(staged.ref, validated); + return Buffer.from(staged.bytes); + } + if (this.client === undefined) { + throw new ManagedSessionRecordError( + 'the HTTP Managed Session resource store is not bound.', + ); + } + return this.client.readResource(validated); + } + + commitResources(refs: readonly ManagedSessionDurableRef[]): CommitResource[] { + const closure = new Map(); + const pending = [...refs]; + while (pending.length > 0) { + const ref = pending.pop()!; + const existing = closure.get(ref.resourceId); + if (existing !== undefined) { + requireSameRef(existing, ref); + continue; + } + closure.set(ref.resourceId, ref); + if ( + closure.size > + HTTP_MANAGED_SESSION_STORE_CONTRACT.maxResourcesPerTransaction + ) { + throw new ManagedSessionRecordError( + `a Managed Session transaction references more than ${HTTP_MANAGED_SESSION_STORE_CONTRACT.maxResourcesPerTransaction} resources.`, + ); + } + const staged = this.staged.get(ref.resourceId); + if (staged !== undefined) { + requireSameRef(staged.ref, ref); + if (ref.kind === 'managed-checkpoint') { + const parsed = tryParseHarnessCheckpointV1(staged.bytes); + if (parsed.ok) pending.push(...collectRefs([parsed.checkpoint])); + } + } + } + return [...closure.values()].map((ref) => { + const staged = this.staged.get(ref.resourceId); + if (staged !== undefined) requireSameRef(staged.ref, ref); + return { + resourceId: ref.resourceId, + kind: ref.kind, + schemaVersion: ref.schemaVersion, + byteLength: ref.byteLength, + digest: ref.digest, + ...(staged === undefined + ? {} + : { bytesBase64: staged.bytes.toString('base64') }), + }; + }); + } + + releaseCommitted(refs: readonly ManagedSessionDurableRef[]): void { + for (const ref of refs) this.staged.delete(ref.resourceId); + } + + clear(): void { + this.staged.clear(); + } +} + +interface WriterGrant { + readonly writerGeneration: number; + readonly leaseUntil: number; + readonly journalRevision: number; + readonly committedSequence: number; + readonly lastCommitDigest: string | null; + readonly activationEpoch: number; +} + +interface RestoreHead { + readonly state: string; + readonly storageVersion: number; + readonly writerGeneration: number; + readonly journalRevision: number; + readonly committedSequence: number; + readonly lastCommitDigest: string | null; + readonly activationEpoch: number; + readonly latestCheckpointResourceId: string | null; + readonly compactedThroughRevision: number; + readonly recoveryStatus: string; + readonly recoveryDetailCode: string | null; +} + +interface CommitResource { + readonly resourceId: string; + readonly kind: string; + readonly schemaVersion: number; + readonly byteLength: number; + readonly digest: string; + readonly bytesBase64?: string; +} + +interface TransactionDescriptor { + readonly transactionId: string; + readonly operation: string; + readonly commandId: string; + readonly contentDigest: string; + readonly firstSequence: number; + readonly lastSequence: number; + readonly eventCount: number; + readonly eventsDigest: string | null; + readonly previousCommitDigest: string | null; + readonly commitDigest: string | null; + readonly activationEpoch: number; + readonly latestCheckpointResourceId: string | null; + readonly refs: ManagedSessionDurableRef[]; +} + +class ManagedSessionStoreHttpClient { + readonly sessionKey: ManagedSessionKey; + private readonly baseUrl: string; + private readonly writerId: string; + private readonly writerToken: string; + private readonly leaseDurationMs: number; + private readonly requestTimeoutMs: number; + private readonly fetchFn: typeof fetch; + private grant: WriterGrant | undefined; + private renewPromise: Promise | undefined; + private renewTimer: NodeJS.Timeout | undefined; + private sealed = false; + + constructor( + options: HttpManagedSessionStoreOptions, + private readonly resources: HttpManagedSessionResourceStore, + ) { + this.sessionKey = options.sessionKey; + this.writerId = assertManagedSessionStableId(options.writerId, 'writerId'); + this.writerToken = + options.writerToken ?? randomBytes(32).toString('base64url'); + if (!WRITER_TOKEN.test(this.writerToken)) { + throw new ManagedSessionRecordError( + 'writerToken must be a 32-512 character Base64URL secret.', + ); + } + this.leaseDurationMs = options.leaseDurationMs ?? DEFAULT_LEASE_DURATION_MS; + if ( + !Number.isInteger(this.leaseDurationMs) || + this.leaseDurationMs < + HTTP_MANAGED_SESSION_STORE_CONTRACT.minimumLeaseDurationMs || + this.leaseDurationMs > + HTTP_MANAGED_SESSION_STORE_CONTRACT.maximumLeaseDurationMs + ) { + throw new ManagedSessionRecordError( + 'leaseDurationMs must be an integer from 1000 through 300000.', + ); + } + this.requestTimeoutMs = + options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + if (!Number.isInteger(this.requestTimeoutMs) || this.requestTimeoutMs < 1) { + throw new ManagedSessionRecordError( + 'requestTimeoutMs must be a positive integer.', + ); + } + const parsed = new URL(options.baseUrl); + if ( + (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') || + parsed.username !== '' || + parsed.password !== '' || + parsed.search !== '' || + parsed.hash !== '' + ) { + throw new ManagedSessionRecordError( + 'baseUrl must be an HTTP(S) URL without credentials, query, or fragment.', + ); + } + this.baseUrl = parsed.toString().replace(/\/$/, ''); + this.fetchFn = options.fetchFn ?? globalThis.fetch; + } + + async acquireWriter(): Promise { + if (this.grant !== undefined || this.sealed) { + throw new ManagedSessionRecordError( + 'the HTTP Managed Session writer is already opened or sealed.', + ); + } + const grant = parseWriterGrant( + await this.json('/writers:acquire', 'POST', { + workspaceId: this.sessionKey.workspaceId, + writerId: this.writerId, + leaseMillis: this.leaseDurationMs, + }), + ); + this.grant = grant; + this.scheduleRenewal(); + } + + async readJournal(maxBytes?: number): Promise { + await this.ensureWriter(); + const head = parseRestoreHead( + await this.json( + `/restore?workspaceId=${encodeURIComponent(this.sessionKey.workspaceId)}`, + 'GET', + ), + ); + this.requireHeadMatchesGrant(head); + const chunks: Buffer[] = []; + let afterRevision = 0; + let activationEpoch = 0; + while (afterRevision < head.journalRevision) { + await this.ensureWriter(); + const page = asRecord( + await this.json( + `/transactions?workspaceId=${encodeURIComponent(this.sessionKey.workspaceId)}&afterRevision=${afterRevision}&limit=100`, + 'GET', + ), + 'transaction page', + ); + const transactions = asArray(page['transactions'], 'transactions'); + if (transactions.length === 0) { + throw corrupt('transaction page did not advance the journal.'); + } + for (const value of transactions) { + const transaction = parseStoredTransaction(value); + if (transaction.journalRevision !== afterRevision + 1) { + throw corrupt('transaction revisions are not contiguous.'); + } + const bytes = decodeBase64( + transaction.recordBytesBase64, + 'recordBytesBase64', + ); + if ( + bytes.byteLength !== transaction.byteLength || + sha256(bytes) !== transaction.recordDigest + ) { + throw corrupt('transaction bytes do not match their metadata.'); + } + const descriptor = describeTransaction( + decodeRecords(bytes), + bytes, + activationEpoch, + this.sessionKey, + ); + requireStoredTransactionMatches(transaction, descriptor); + activationEpoch = descriptor.activationEpoch; + chunks.push(bytes); + afterRevision = transaction.journalRevision; + } + const nextRevision = safeCounter(page['nextRevision'], 'nextRevision'); + const hasMore = boolean(page['hasMore'], 'hasMore'); + if (nextRevision !== afterRevision) { + throw corrupt('transaction page nextRevision is inconsistent.'); + } + if (!hasMore && afterRevision !== head.journalRevision) { + throw corrupt('transaction page ended before the journal head.'); + } + } + const bytes = Buffer.concat(chunks); + const scan = scanManagedSessionJournal(bytes, this.sessionKey, maxBytes); + if ( + maxBytes === undefined && + (scan.committed !== head.committedSequence || + scan.lastMarkerDigest !== head.lastCommitDigest || + (scan.activation?.epoch ?? 0) !== head.activationEpoch) + ) { + throw corrupt('journal bytes do not match the durable head.'); + } + this.grant = { + writerGeneration: head.writerGeneration, + leaseUntil: this.requireGrant().leaseUntil, + journalRevision: head.journalRevision, + committedSequence: head.committedSequence, + lastCommitDigest: head.lastCommitDigest, + activationEpoch: head.activationEpoch, + }; + return scan; + } + + async appendTransaction(records: readonly unknown[]): Promise { + await this.ensureWriter(); + const grant = this.requireGrant(); + const recordBytes = encodeRecords(records); + const descriptor = describeTransaction( + records, + recordBytes, + grant.activationEpoch, + this.sessionKey, + ); + if (descriptor.previousCommitDigest !== grant.lastCommitDigest) { + throw new ManagedSessionRecordError( + 'the transaction does not extend the HTTP journal head.', + ); + } + const commitResources = this.resources.commitResources(descriptor.refs); + const receipt = asRecord( + await this.json('/transactions:commit', 'POST', { + workspaceId: this.sessionKey.workspaceId, + writerId: this.writerId, + writerGeneration: grant.writerGeneration, + expectedJournalRevision: grant.journalRevision, + expectedCommittedSequence: grant.committedSequence, + transactionId: descriptor.transactionId, + operation: descriptor.operation, + commandId: descriptor.commandId, + contentDigest: descriptor.contentDigest, + firstSequence: descriptor.firstSequence, + lastSequence: descriptor.lastSequence, + eventCount: descriptor.eventCount, + eventsDigest: descriptor.eventsDigest, + previousCommitDigest: descriptor.previousCommitDigest, + commitDigest: descriptor.commitDigest, + activationEpoch: descriptor.activationEpoch, + latestCheckpointResourceId: descriptor.latestCheckpointResourceId, + recordCount: records.length, + recordBytesBase64: recordBytes.toString('base64'), + recordDigest: sha256(recordBytes), + resources: commitResources, + }), + 'commit receipt', + ); + const revision = safeCounter(receipt['journalRevision'], 'journalRevision'); + if ( + revision !== grant.journalRevision + 1 || + string(receipt['transactionId'], 'transactionId') !== + descriptor.transactionId || + string(receipt['commandId'], 'commandId') !== descriptor.commandId || + string(receipt['operation'], 'operation') !== descriptor.operation || + safeCounter(receipt['firstSequence'], 'firstSequence') !== + descriptor.firstSequence || + safeCounter(receipt['lastSequence'], 'lastSequence') !== + descriptor.lastSequence || + safeCounter(receipt['committedSequence'], 'committedSequence') !== + descriptor.lastSequence || + nullableDigest(receipt['commitDigest'], 'commitDigest') !== + descriptor.commitDigest + ) { + throw corrupt('commit receipt does not match the submitted transaction.'); + } + this.grant = { + ...grant, + journalRevision: revision, + committedSequence: descriptor.lastSequence, + lastCommitDigest: descriptor.commitDigest, + activationEpoch: descriptor.activationEpoch, + }; + this.resources.releaseCommitted(commitResources); + } + + async readResource(ref: ManagedSessionDurableRef): Promise { + await this.ensureWriter(); + const response = await this.request( + `/resources/${encodeURIComponent(ref.resourceId)}?workspaceId=${encodeURIComponent(this.sessionKey.workspaceId)}`, + 'GET', + undefined, + 'application/octet-stream, application/json', + ); + const bytes = Buffer.from(await response.arrayBuffer()); + if ( + response.headers.get('X-Qwen-Resource-Kind') !== ref.kind || + response.headers.get('X-Qwen-Resource-Schema-Version') !== + String(ref.schemaVersion) || + response.headers.get('X-Qwen-Resource-Digest') !== ref.digest || + bytes.byteLength !== ref.byteLength || + sha256(bytes) !== ref.digest + ) { + throw corrupt( + `resource ${ref.resourceId} failed integrity verification.`, + ); + } + return bytes; + } + + async blockRecovery(request: { + readonly status: + | 'BLOCKED_RESOURCE' + | 'BLOCKED_WORKSPACE' + | 'BLOCKED_EXECUTION'; + readonly detailCode: string; + }): Promise { + await this.ensureWriter(); + const grant = this.requireGrant(); + const receipt = asRecord( + await this.json('/recovery:block', 'POST', { + workspaceId: this.sessionKey.workspaceId, + writerId: this.writerId, + writerGeneration: grant.writerGeneration, + recoveryStatus: request.status, + recoveryDetailCode: request.detailCode, + }), + 'recovery state receipt', + ); + if ( + safeCounter(receipt['writerGeneration'], 'writerGeneration') !== + grant.writerGeneration || + string(receipt['recoveryStatus'], 'recoveryStatus') !== request.status || + string(receipt['recoveryDetailCode'], 'recoveryDetailCode') !== + request.detailCode + ) { + throw corrupt( + 'recovery state receipt does not match the active writer request.', + ); + } + } + + async seal(): Promise { + if (this.sealed) return; + const grant = this.grant; + this.stopRenewal(); + if (grant === undefined) { + this.sealed = true; + this.resources.clear(); + return; + } + const receipt = asRecord( + await this.json('/writers:seal', 'POST', { + workspaceId: this.sessionKey.workspaceId, + writerId: this.writerId, + writerGeneration: grant.writerGeneration, + }), + 'seal receipt', + ); + if ( + safeCounter(receipt['writerGeneration'], 'writerGeneration') !== + grant.writerGeneration || + string(receipt['state'], 'state') !== 'SEALED' + ) { + throw corrupt('seal receipt does not match the active writer.'); + } + this.sealed = true; + this.stopRenewal(); + this.resources.clear(); + } + + private async ensureWriter(): Promise { + const grant = this.requireGrant(); + if (grant.leaseUntil - Date.now() <= this.leaseDurationMs / 3) { + await this.renewWriter(); + } + } + + private renewWriter(): Promise { + if (this.renewPromise !== undefined) return this.renewPromise; + const grant = this.requireGrant(); + const renewal = (async () => { + const renewed = parseWriterGrant( + await this.json('/writers:renew', 'POST', { + workspaceId: this.sessionKey.workspaceId, + writerId: this.writerId, + writerGeneration: grant.writerGeneration, + leaseMillis: this.leaseDurationMs, + }), + ); + if (renewed.writerGeneration !== grant.writerGeneration) { + throw corrupt('writer generation changed during renewal.'); + } + this.grant = { ...this.requireGrant(), leaseUntil: renewed.leaseUntil }; + this.scheduleRenewal(); + })(); + this.renewPromise = renewal.finally(() => { + this.renewPromise = undefined; + }); + return this.renewPromise; + } + + private scheduleRenewal(): void { + this.stopRenewal(); + if (this.sealed || this.grant === undefined) return; + const delay = Math.max( + 250, + Math.min( + Math.floor(this.leaseDurationMs / 2), + this.grant.leaseUntil - Date.now() - 500, + ), + ); + this.renewTimer = setTimeout(() => { + void this.renewWriter().catch(() => undefined); + }, delay); + this.renewTimer.unref(); + } + + private stopRenewal(): void { + if (this.renewTimer !== undefined) clearTimeout(this.renewTimer); + this.renewTimer = undefined; + } + + private requireHeadMatchesGrant(head: RestoreHead): void { + const grant = this.requireGrant(); + if ( + head.state !== 'ACTIVE' || + head.storageVersion !== 1 || + head.writerGeneration !== grant.writerGeneration || + head.recoveryStatus !== 'READY' || + head.compactedThroughRevision !== 0 + ) { + throw corrupt('restore head is not readable by this v1 writer.'); + } + } + + private requireGrant(): WriterGrant { + if (this.grant === undefined || this.sealed) { + throw new ManagedSessionRecordError( + 'the HTTP Managed Session writer is not active.', + ); + } + return this.grant; + } + + private async json( + path: string, + method: 'GET' | 'POST', + body?: Readonly>, + ): Promise { + const response = await this.request(path, method, body); + try { + return (await response.json()) as unknown; + } catch (error) { + throw corrupt( + `Managed Session Store returned invalid JSON: ${error instanceof Error ? error.message : String(error)}.`, + ); + } + } + + private async request( + path: string, + method: 'GET' | 'POST', + body?: Readonly>, + accept = 'application/json', + ): Promise { + let response: Response; + try { + response = await this.fetchFn(`${this.sessionUrl()}${path}`, { + method, + redirect: 'error', + headers: { + Accept: accept, + [HTTP_MANAGED_SESSION_STORE_CONTRACT.tenantHeader]: + this.sessionKey.tenantId, + [HTTP_MANAGED_SESSION_STORE_CONTRACT.writerTokenHeader]: + this.writerToken, + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + signal: AbortSignal.timeout(this.requestTimeoutMs), + }); + } catch (error) { + throw new ManagedSessionRecordError( + `Managed Session Store request failed: ${error instanceof Error ? error.message : String(error)}.`, + ); + } + if (!response.ok) { + const error = await readHttpError(response); + throw new ManagedSessionStoreHttpError( + response.status, + error.code, + error.message, + ); + } + if (response.headers.get('Cache-Control') !== 'no-store') { + throw corrupt( + 'Managed Session Store response is missing Cache-Control: no-store.', + ); + } + return response; + } + + private sessionUrl(): string { + return `${this.baseUrl}/internal/managed-session-store/v1/sessions/${encodeURIComponent(this.sessionKey.sessionId)}`; + } +} + +function describeTransaction( + records: readonly unknown[], + bytes: Buffer, + currentActivationEpoch: number, + expectedSessionKey: ManagedSessionKey, +): TransactionDescriptor { + const possibleHeader = + records.length === 2 + ? envelope(records[1], 'possible genesis header record') + : undefined; + if (possibleHeader?.['subtype'] === MANAGED_SESSION_HEADER_SUBTYPE) { + const first = envelope(records[0], 'genesis engine record'); + if (first['subtype'] !== 'session_execution_engine') { + throw new ManagedSessionRecordError( + 'the genesis transaction must contain the engine and Managed header.', + ); + } + const header = parseManagedSessionHeader(possibleHeader['managedSession']); + if ( + !managedSessionKeysEqual(header.sessionKey, expectedSessionKey) || + first['sessionId'] !== expectedSessionKey.sessionId || + possibleHeader['sessionId'] !== expectedSessionKey.sessionId + ) { + throw new ManagedSessionRecordError( + 'the genesis transaction belongs to a different session.', + ); + } + const digest = sha256(bytes); + return { + transactionId: `session.create:${header.sessionKey.sessionId}`, + operation: 'session.create', + commandId: `session.create:${header.sessionKey.sessionId}`, + contentDigest: digest, + firstSequence: 0, + lastSequence: 0, + eventCount: 0, + eventsDigest: null, + previousCommitDigest: null, + commitDigest: null, + activationEpoch: currentActivationEpoch, + latestCheckpointResourceId: null, + refs: collectRefs(records), + }; + } + if (records.length < 2) { + throw new ManagedSessionRecordError( + 'a Managed Session transaction must include events and a commit marker.', + ); + } + const eventRecords = records.slice(0, -1).map((record, index) => { + const parsed = envelope(record, `event record ${index + 1}`); + if (parsed['subtype'] !== MANAGED_SESSION_EVENT_SUBTYPE) { + throw new ManagedSessionRecordError( + 'a Managed Session transaction contains a non-event record.', + ); + } + const event = parseManagedSessionEvent(parsed['managedSession']); + if ( + !managedSessionKeysEqual(event.sessionKey, expectedSessionKey) || + parsed['sessionId'] !== expectedSessionKey.sessionId + ) { + throw new ManagedSessionRecordError( + 'the event transaction belongs to a different session.', + ); + } + return event; + }); + const markerRecord = envelope(records[records.length - 1], 'commit record'); + if (markerRecord['subtype'] !== MANAGED_SESSION_COMMIT_SUBTYPE) { + throw new ManagedSessionRecordError( + 'a Managed Session transaction must end with a commit marker.', + ); + } + if (markerRecord['sessionId'] !== expectedSessionKey.sessionId) { + throw new ManagedSessionRecordError( + 'the commit marker belongs to a different session.', + ); + } + const marker = parseManagedSessionCommitMarker( + markerRecord['managedSession'], + ); + let activationEpoch = currentActivationEpoch; + let latestCheckpointResourceId: string | null = null; + for (const event of eventRecords) { + if (event.kind === 'activation.changed') { + activationEpoch = event.payload['epoch'] as number; + } + if (event.kind === 'checkpoint.committed') { + const ref = assertManagedSessionDurableRef( + event.payload['stateRef'], + 'checkpoint state', + ); + if (ref.kind === 'managed-checkpoint') { + latestCheckpointResourceId = ref.resourceId; + } + } + } + return { + transactionId: marker.transactionId, + operation: marker.operation, + commandId: marker.commandId, + contentDigest: marker.contentDigest, + firstSequence: marker.firstSequence, + lastSequence: marker.lastSequence, + eventCount: marker.eventCount, + eventsDigest: marker.eventsDigest, + previousCommitDigest: marker.previousCommitDigest, + commitDigest: managedToolDigest( + marker, + MANAGED_SESSION_LIMITS.maxCommitMarkerBytes, + ), + activationEpoch, + latestCheckpointResourceId, + refs: collectRefs(records), + }; +} + +function collectRefs(records: readonly unknown[]): ManagedSessionDurableRef[] { + const refs = new Map(); + const pending: unknown[] = [...records]; + while (pending.length > 0) { + const value = pending.pop(); + if (Array.isArray(value)) { + pending.push(...value); + continue; + } + if (value === null || typeof value !== 'object') continue; + const record = value as Record; + if ( + Object.hasOwn(record, 'resourceId') && + Object.hasOwn(record, 'kind') && + Object.hasOwn(record, 'schemaVersion') && + Object.hasOwn(record, 'byteLength') && + Object.hasOwn(record, 'digest') + ) { + const ref = assertManagedSessionDurableRef( + record as ManagedSessionJsonValue, + 'resource ref', + ); + const existing = refs.get(ref.resourceId); + if (existing !== undefined) requireSameRef(existing, ref); + else refs.set(ref.resourceId, ref); + continue; + } + pending.push(...Object.values(record)); + } + return [...refs.values()]; +} + +function encodeRecords(records: readonly unknown[]): Buffer { + if (records.length < 1) { + throw new ManagedSessionRecordError( + 'a Managed Session transaction must contain records.', + ); + } + const bytes = Buffer.from( + records.map((record) => JSON.stringify(record)).join('\n') + '\n', + 'utf8', + ); + if (bytes.byteLength > MANAGED_SESSION_LIMITS.maxTransactionBytes) { + throw new ManagedSessionRecordError( + `transaction exceeds ${MANAGED_SESSION_LIMITS.maxTransactionBytes} bytes.`, + ); + } + return bytes; +} + +function decodeRecords(bytes: Buffer): unknown[] { + const text = bytes.toString('utf8'); + if (!text.endsWith('\n')) throw corrupt('transaction is not complete JSONL.'); + return text + .slice(0, -1) + .split('\n') + .map((line) => + parseManagedSessionRecordJson(line, MANAGED_SESSION_LIMITS.maxEventBytes), + ); +} + +function envelope(value: unknown, label: string): Record { + const parsed = parseManagedSessionRecordJson( + JSON.stringify(value), + MANAGED_SESSION_LIMITS.maxEventBytes, + ); + return asRecord(parsed, label); +} + +interface StoredTransaction { + readonly journalRevision: number; + readonly transactionId: string; + readonly operation: string; + readonly commandId: string; + readonly contentDigest: string; + readonly firstSequence: number; + readonly lastSequence: number; + readonly eventCount: number; + readonly eventsDigest: string | null; + readonly previousCommitDigest: string | null; + readonly commitDigest: string | null; + readonly activationEpoch: number; + readonly latestCheckpointResourceId: string | null; + readonly recordBytesBase64: string; + readonly byteLength: number; + readonly recordDigest: string; +} + +function parseStoredTransaction(value: unknown): StoredTransaction { + const record = asRecord(value, 'stored transaction'); + if (string(record['recordEncoding'], 'recordEncoding') !== 'identity') { + throw corrupt('stored transaction encoding is not supported.'); + } + return { + journalRevision: safeCounter(record['journalRevision'], 'journalRevision'), + transactionId: string(record['transactionId'], 'transactionId'), + operation: string(record['operation'], 'operation'), + commandId: string(record['commandId'], 'commandId'), + contentDigest: digest(record['contentDigest'], 'contentDigest'), + firstSequence: safeCounter(record['firstSequence'], 'firstSequence'), + lastSequence: safeCounter(record['lastSequence'], 'lastSequence'), + eventCount: safeCounter(record['eventCount'], 'eventCount'), + eventsDigest: nullableDigest(record['eventsDigest'], 'eventsDigest'), + previousCommitDigest: nullableDigest( + record['previousCommitDigest'], + 'previousCommitDigest', + ), + commitDigest: nullableDigest(record['commitDigest'], 'commitDigest'), + activationEpoch: safeCounter(record['activationEpoch'], 'activationEpoch'), + latestCheckpointResourceId: nullableString( + record['latestCheckpointResourceId'], + 'latestCheckpointResourceId', + ), + recordBytesBase64: string(record['recordBytesBase64'], 'recordBytesBase64'), + byteLength: safeCounter(record['byteLength'], 'byteLength'), + recordDigest: digest(record['recordDigest'], 'recordDigest'), + }; +} + +function requireStoredTransactionMatches( + stored: StoredTransaction, + descriptor: TransactionDescriptor, +): void { + if ( + stored.transactionId !== descriptor.transactionId || + stored.operation !== descriptor.operation || + stored.commandId !== descriptor.commandId || + stored.contentDigest !== descriptor.contentDigest || + stored.firstSequence !== descriptor.firstSequence || + stored.lastSequence !== descriptor.lastSequence || + stored.eventCount !== descriptor.eventCount || + stored.eventsDigest !== descriptor.eventsDigest || + stored.previousCommitDigest !== descriptor.previousCommitDigest || + stored.commitDigest !== descriptor.commitDigest || + stored.activationEpoch !== descriptor.activationEpoch || + stored.latestCheckpointResourceId !== descriptor.latestCheckpointResourceId + ) { + throw corrupt('stored transaction metadata does not match its records.'); + } +} + +function parseWriterGrant(value: unknown): WriterGrant { + const record = asRecord(value, 'writer grant'); + return { + writerGeneration: safeCounter( + record['writerGeneration'], + 'writerGeneration', + ), + leaseUntil: safeCounter(record['leaseUntil'], 'leaseUntil'), + journalRevision: safeCounter(record['journalRevision'], 'journalRevision'), + committedSequence: safeCounter( + record['committedSequence'], + 'committedSequence', + ), + lastCommitDigest: nullableDigest( + record['lastCommitDigest'], + 'lastCommitDigest', + ), + activationEpoch: safeCounter(record['activationEpoch'], 'activationEpoch'), + }; +} + +function parseRestoreHead(value: unknown): RestoreHead { + const record = asRecord(value, 'restore head'); + return { + state: string(record['state'], 'state'), + storageVersion: safeCounter(record['storageVersion'], 'storageVersion'), + writerGeneration: safeCounter( + record['writerGeneration'], + 'writerGeneration', + ), + journalRevision: safeCounter(record['journalRevision'], 'journalRevision'), + committedSequence: safeCounter( + record['committedSequence'], + 'committedSequence', + ), + lastCommitDigest: nullableDigest( + record['lastCommitDigest'], + 'lastCommitDigest', + ), + activationEpoch: safeCounter(record['activationEpoch'], 'activationEpoch'), + latestCheckpointResourceId: nullableString( + record['latestCheckpointResourceId'], + 'latestCheckpointResourceId', + ), + compactedThroughRevision: safeCounter( + record['compactedThroughRevision'], + 'compactedThroughRevision', + ), + recoveryStatus: string(record['recoveryStatus'], 'recoveryStatus'), + recoveryDetailCode: nullableString( + record['recoveryDetailCode'], + 'recoveryDetailCode', + ), + }; +} + +function requireSameRef( + left: ManagedSessionDurableRef, + right: ManagedSessionDurableRef, +): void { + if ( + left.resourceId !== right.resourceId || + left.kind !== right.kind || + left.schemaVersion !== right.schemaVersion || + left.byteLength !== right.byteLength || + left.digest !== right.digest + ) { + throw new ManagedSessionRecordError( + `resource ${right.resourceId} was reused with different metadata.`, + ); + } +} + +async function readHttpError( + response: Response, +): Promise<{ code: string; message: string }> { + try { + const body = asRecord((await response.json()) as unknown, 'error response'); + const error = asRecord(body['error'], 'error'); + return { + code: string(error['code'], 'error.code'), + message: string(error['message'], 'error.message'), + }; + } catch { + return { + code: 'managed_session_store_http_error', + message: `Managed Session Store returned HTTP ${response.status}.`, + }; + } +} + +function asRecord(value: unknown, label: string): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw corrupt(`${label} must be an object.`); + } + return value as Record; +} + +function asArray(value: unknown, label: string): unknown[] { + if (!Array.isArray(value)) throw corrupt(`${label} must be an array.`); + return value; +} + +function string(value: unknown, label: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw corrupt(`${label} must be a non-empty string.`); + } + return value; +} + +function nullableString(value: unknown, label: string): string | null { + return value === null || value === undefined ? null : string(value, label); +} + +function boolean(value: unknown, label: string): boolean { + if (typeof value !== 'boolean') throw corrupt(`${label} must be boolean.`); + return value; +} + +function safeCounter(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw corrupt(`${label} must be a non-negative safe integer.`); + } + return value; +} + +function digest(value: unknown, label: string): string { + const result = string(value, label); + if (!/^[0-9a-f]{64}$/.test(result)) { + throw corrupt(`${label} must be a lowercase SHA-256 digest.`); + } + return result; +} + +function nullableDigest(value: unknown, label: string): string | null { + return value === null || value === undefined ? null : digest(value, label); +} + +function decodeBase64(value: string, label: string): Buffer { + if ( + !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test( + value, + ) + ) { + throw corrupt(`${label} must be canonical Base64.`); + } + return Buffer.from(value, 'base64'); +} + +function sha256(value: Buffer): string { + return createHash('sha256').update(value).digest('hex'); +} + +function corrupt(message: string): ManagedSessionRecordError { + return new ManagedSessionRecordError(`Managed Session Store: ${message}`); +} diff --git a/packages/core/src/managed-runtime/local-jsonl-managed-session-journal-store.test.ts b/packages/core/src/managed-runtime/local-jsonl-managed-session-journal-store.test.ts new file mode 100644 index 00000000000..4d0aac2485e --- /dev/null +++ b/packages/core/src/managed-runtime/local-jsonl-managed-session-journal-store.test.ts @@ -0,0 +1,110 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { SessionWriterLease } from '../services/session-writer-lease.js'; +import { LocalJsonlManagedSessionJournalStore } from './local-jsonl-managed-session-journal-store.js'; + +const sessionId = '550e8400-e29b-41d4-a716-446655440000'; +const sessionKey = { tenantId: 't1', workspaceId: 'w1', sessionId }; +const temporaryDirectories = new Set(); + +afterEach(async () => { + for (const directory of temporaryDirectories) { + await fs.rm(directory, { recursive: true, force: true }); + } + temporaryDirectories.clear(); +}); + +async function workspace(): Promise<{ + runtimeBaseDir: string; + transcriptPath: string; +}> { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-managed-store-')); + temporaryDirectories.add(root); + const runtimeBaseDir = path.join(root, 'runtime'); + const transcriptPath = path.join( + runtimeBaseDir, + 'projects', + 'test', + 'chats', + `${sessionId}.jsonl`, + ); + await fs.mkdir(runtimeBaseDir, { recursive: true }); + await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); + return { runtimeBaseDir, transcriptPath }; +} + +describe('local JSONL Managed Session journal store', () => { + it('preserves the historical JSONL encoding for a transaction batch', async () => { + const paths = await workspace(); + const handle = await new LocalJsonlManagedSessionJournalStore({ + ...paths, + sessionId, + }).open({ sessionKey }); + const records = [ + { + uuid: 'record-1', + subtype: 'session_execution_engine', + systemPayload: { version: 1, engine: 'managed' }, + }, + { uuid: 'record-2', subtype: 'pre-header-metadata', value: 1 }, + ]; + + await handle.appendTransaction(records); + + expect(await fs.readFile(paths.transcriptPath, 'utf8')).toBe( + `${records.map((record) => JSON.stringify(record)).join('\n')}\n`, + ); + await expect(handle.read()).resolves.toMatchObject({ + committed: 0, + foreignRecords: 2, + engineRecords: 1, + lastRecordUuid: 'record-2', + }); + await handle.seal({ + lastCommitSequence: 0, + committedPrefixHash: '0'.repeat(64), + }); + }); + + it('releases a writer acquired by an aborted open', async () => { + const paths = await workspace(); + const handle = await new LocalJsonlManagedSessionJournalStore({ + ...paths, + sessionId, + }).open({ sessionKey }); + + await handle.abort(); + + const successor = await SessionWriterLease.acquire({ + ...paths, + sessionId, + }); + await successor.release(); + }); + + it('leaves an adopted writer with its owner when aborting', async () => { + const paths = await workspace(); + const lease = await SessionWriterLease.acquire({ + ...paths, + sessionId, + }); + const handle = await new LocalJsonlManagedSessionJournalStore({ + ...paths, + sessionId, + lease, + }).open({ sessionKey }); + + await handle.abort(); + + expect(lease.isReleased).toBe(false); + await lease.release(); + }); +}); diff --git a/packages/core/src/managed-runtime/local-jsonl-managed-session-journal-store.ts b/packages/core/src/managed-runtime/local-jsonl-managed-session-journal-store.ts new file mode 100644 index 00000000000..359c6f95788 --- /dev/null +++ b/packages/core/src/managed-runtime/local-jsonl-managed-session-journal-store.ts @@ -0,0 +1,172 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { readFile, rename, unlink, writeFile } from 'node:fs/promises'; +import { SessionWriterLease } from '../services/session-writer-lease.js'; +import { + ManagedSessionRecordError, + MANAGED_SESSION_FORMAT_VERSION, +} from './managed-session-records.js'; +import { + emptyManagedSessionJournalScan, + scanManagedSessionJournal, + type ManagedSessionCommitProof, + type ManagedSessionJournalHandle, + type ManagedSessionJournalScan, + type ManagedSessionJournalStore, +} from './managed-session-storage.js'; +import type { ManagedSessionKey } from './managed-session-records.js'; + +interface LocalJsonlManagedSessionJournalStoreOptions { + readonly runtimeBaseDir: string; + readonly sessionId: string; + readonly transcriptPath: string; + readonly lease?: SessionWriterLease; +} + +/** Local-file implementation retained for standalone CLI and development. */ +export class LocalJsonlManagedSessionJournalStore + implements ManagedSessionJournalStore +{ + constructor( + private readonly options: LocalJsonlManagedSessionJournalStoreOptions, + ) {} + + async open(request: { + readonly sessionKey: ManagedSessionKey; + }): Promise { + const adopted = this.options.lease !== undefined; + const lease = + this.options.lease ?? + (await SessionWriterLease.acquire({ + runtimeBaseDir: this.options.runtimeBaseDir, + sessionId: this.options.sessionId, + transcriptPath: this.options.transcriptPath, + takeoverPolicy: 'certified', + // A Managed writer pins its log format into the lock: baseline + // binaries refuse the unknown schema, which is the write barrier + // that keeps this log off legacy writers. + lockSchema: { + schemaVersion: 3, + formatVersion: MANAGED_SESSION_FORMAT_VERSION, + }, + })); + return new LocalJsonlManagedSessionJournalHandle( + lease, + request.sessionKey, + !adopted, + ); + } + + static fromLease( + lease: SessionWriterLease, + sessionKey: ManagedSessionKey, + ): LocalJsonlManagedSessionJournalHandle { + return new LocalJsonlManagedSessionJournalHandle(lease, sessionKey, false); + } + + static async read( + transcriptPath: string, + sessionKey: ManagedSessionKey, + maxBytes?: number, + ): Promise { + try { + const bytes = await readFile(transcriptPath); + return scanManagedSessionJournal(bytes, sessionKey, maxBytes); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return emptyManagedSessionJournalScan(); + } + throw error; + } + } +} + +export class LocalJsonlManagedSessionJournalHandle + implements ManagedSessionJournalHandle +{ + constructor( + private readonly lease: SessionWriterLease, + readonly sessionKey: ManagedSessionKey, + private readonly ownsLease: boolean, + ) {} + + read( + options: { + readonly maxBytes?: number; + } = {}, + ): Promise { + return LocalJsonlManagedSessionJournalStore.read( + this.lease.transcriptPath, + this.sessionKey, + options.maxBytes, + ); + } + + async appendTransaction(records: readonly unknown[]): Promise { + for (const record of records) { + await this.lease.appendJsonLine(record); + } + } + + seal(commit: ManagedSessionCommitProof): Promise { + return this.lease.sealForHandoff({ + last_commit_sequence: commit.lastCommitSequence, + committed_prefix_hash: commit.committedPrefixHash, + }); + } + + get takeoverCommitProof(): ManagedSessionCommitProof | undefined { + const proof = this.lease.takeoverCommitProof; + if (proof === undefined) return undefined; + return { + lastCommitSequence: proof.last_commit_sequence, + committedPrefixHash: proof.committed_prefix_hash, + }; + } + + async abort(): Promise { + if (this.ownsLease) { + await this.lease.release(); + } + } + + async recoverUncommittedTail(): Promise<{ + discardedBytes: number; + diagnosticPath: string; + }> { + const scan = await this.read(); + if (scan.uncommitted === 0) { + throw new ManagedSessionRecordError( + 'session log has no uncommitted tail to discard.', + ); + } + const retain = Math.max(scan.committedBytes, scan.headerBytes); + if (retain === 0) { + throw new ManagedSessionRecordError( + 'session log has no committed prefix to retain.', + ); + } + const bytes = await readFile(this.lease.transcriptPath); + if (bytes.byteLength <= retain) { + throw new ManagedSessionRecordError( + 'session log changed while preparing tail recovery.', + ); + } + const discarded = bytes.subarray(retain); + const diagnosticPath = `${this.lease.transcriptPath}.uncommitted-tail`; + const pendingPath = `${diagnosticPath}.pending`; + await writeFile(pendingPath, discarded, { mode: 0o600 }); + try { + await this.lease.truncateTo(retain); + } catch (cause) { + await unlink(pendingPath).catch(() => undefined); + throw cause; + } + await rename(pendingPath, diagnosticPath); + return { discardedBytes: discarded.byteLength, diagnosticPath }; + } +} diff --git a/packages/core/src/managed-runtime/managed-activation-store.test.ts b/packages/core/src/managed-runtime/managed-activation-store.test.ts new file mode 100644 index 00000000000..c27a0ca89ea --- /dev/null +++ b/packages/core/src/managed-runtime/managed-activation-store.test.ts @@ -0,0 +1,217 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + appendFile, + mkdir, + mkdtemp, + readFile, + rm, + stat, + writeFile, +} from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + FileManagedActivationStore, + ManagedActivationStaleLeaseError, + type ManagedActivationDescriptor, +} from './managed-activation-store.js'; + +const limits = { maxQueued: 10, maxQueuedPerTenant: 5 }; + +function activation( + activationId: string, + overrides: Partial = {}, +): ManagedActivationDescriptor { + return { + tenantId: 'tenant-a', + sessionId: 'session-a', + activationId, + payloadRef: `event:${activationId}`, + reason: 'user_message', + recovery: 'replay_safe', + ...overrides, + }; +} + +describe('FileManagedActivationStore', () => { + let root: string; + let filePath: string; + let now: number; + + const openStore = () => + FileManagedActivationStore.open(filePath, { clock: () => now }); + + beforeEach(async () => { + now = 100; + root = await mkdtemp(path.join(os.tmpdir(), 'managed-activation-')); + filePath = path.join(root, 'control', 'activations.jsonl'); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('persists canonical activation metadata across reopen', async () => { + const store = await openStore(); + const input = { + ...activation('a1'), + prompt: 'must not enter the control-plane journal', + } as ManagedActivationDescriptor; + + await expect(store.enqueue(input, limits)).resolves.toMatchObject({ + created: true, + activation: { status: 'queued', queuedAt: 100 }, + }); + + const reopened = await openStore(); + expect(reopened.get(input)).toMatchObject({ + descriptor: activation('a1'), + status: 'queued', + }); + expect(await readFile(filePath, 'utf8')).not.toContain('must not enter'); + if (process.platform !== 'win32') { + expect((await stat(filePath)).mode & 0o777).toBe(0o600); + } + }); + + it('truncates a torn tail before appending the next valid record', async () => { + const store = await openStore(); + await store.enqueue(activation('a1'), limits); + await appendFile(filePath, '{"v":1,"sequence":2'); + + const recovered = await openStore(); + now = 200; + await recovered.enqueue(activation('a2'), limits); + + const reopened = await openStore(); + expect( + reopened.listPending().map((item) => item.descriptor.activationId), + ).toEqual(['a1', 'a2']); + expect(await readFile(filePath, 'utf8')).not.toContain('"sequence":2\n{'); + }); + + it('fails closed on malformed committed history', async () => { + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, '{"v":1,"sequence":1}\n'); + await expect(openStore()).rejects.toThrow('journal line 1 is invalid'); + }); + + it('fails closed on inconsistent committed history', async () => { + const store = await openStore(); + await store.enqueue(activation('a1'), limits); + const contents = await readFile(filePath, 'utf8'); + await writeFile(filePath, contents.replace('"sequence":1', '"sequence":2')); + + await expect(openStore()).rejects.toThrow( + 'Expected journal sequence 1, got 2', + ); + }); + + it('deduplicates exact identities and rejects conflicting reuse', async () => { + const store = await openStore(); + const first = await store.enqueue(activation('a1'), { + maxQueued: 1, + maxQueuedPerTenant: 1, + }); + const duplicate = await store.enqueue(activation('a1'), { + maxQueued: 1, + maxQueuedPerTenant: 1, + }); + + expect(first.created).toBe(true); + expect(duplicate.created).toBe(false); + await expect( + store.enqueue(activation('a1', { payloadRef: 'event:other' }), limits), + ).rejects.toThrow('reused with different data'); + }); + + it('enforces tenant and global queue limits for new work', async () => { + const tenantStore = await openStore(); + await tenantStore.enqueue(activation('a1'), { + maxQueued: 2, + maxQueuedPerTenant: 1, + }); + await expect( + tenantStore.enqueue(activation('a2'), { + maxQueued: 2, + maxQueuedPerTenant: 1, + }), + ).rejects.toMatchObject({ + code: 'TENANT_QUEUE_FULL', + retryable: true, + }); + + await tenantStore.enqueue( + activation('b1', { tenantId: 'tenant-b', sessionId: 'session-b' }), + { maxQueued: 2, maxQueuedPerTenant: 1 }, + ); + await expect( + tenantStore.enqueue( + activation('c1', { tenantId: 'tenant-c', sessionId: 'session-c' }), + { maxQueued: 2, maxQueuedPerTenant: 1 }, + ), + ).rejects.toMatchObject({ + code: 'GLOBAL_QUEUE_FULL', + retryable: true, + }); + }); + + it('keeps non-extending renewals replayable without halting the store', async () => { + const store = await openStore(); + const input = activation('a1'); + await store.enqueue(input, limits); + const lease = (await store.claim(input, 'worker-a', 1000))!; + const contents = await readFile(filePath, 'utf8'); + + await expect(store.renew(lease, 1000)).resolves.toEqual(lease); + now -= 1; + await expect(store.renew(lease, 500)).resolves.toEqual(lease); + expect(await readFile(filePath, 'utf8')).toBe(contents); + expect(store.haltedError).toBeUndefined(); + const reopened = await openStore(); + expect(reopened.get(input)?.lease).toEqual(lease); + now = 200; + await expect(reopened.renew(lease, 1000)).resolves.toMatchObject({ + expiresAt: 1200, + }); + }); + + it('preserves Session FIFO and fences expired lease epochs', async () => { + const store = await openStore(); + const first = activation('a1'); + const second = activation('a2'); + now = 0; + await store.enqueue(first, limits); + now = 1; + await store.enqueue(second, limits); + + now = 2; + await expect(store.claim(second, 'worker-a', 10)).resolves.toBeUndefined(); + const epochOne = await store.claim(first, 'worker-a', 10); + expect(epochOne).toMatchObject({ epoch: 1, expiresAt: 12 }); + now = 11; + await expect(store.claim(first, 'worker-b', 10)).resolves.toBeUndefined(); + + now = 12; + const epochTwo = await store.claim(first, 'worker-b', 10); + expect(epochTwo).toMatchObject({ epoch: 2, expiresAt: 22 }); + now = 13; + await expect(store.release(epochOne!, 'completed')).rejects.toBeInstanceOf( + ManagedActivationStaleLeaseError, + ); + + now = 15; + const renewed = await store.renew(epochTwo!, 10); + expect(renewed.expiresAt).toBe(25); + now = 20; + await store.release(renewed, 'completed'); + const next = await store.claim(second, 'worker-b', 10); + expect(next?.epoch).toBe(3); + }); +}); diff --git a/packages/core/src/managed-runtime/managed-activation-store.ts b/packages/core/src/managed-runtime/managed-activation-store.ts new file mode 100644 index 00000000000..1264045d845 --- /dev/null +++ b/packages/core/src/managed-runtime/managed-activation-store.ts @@ -0,0 +1,635 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { appendFile, mkdir, readFile, truncate } from 'node:fs/promises'; +import path from 'node:path'; +import { isDeepStrictEqual } from 'node:util'; + +export interface ManagedActivationIdentity { + readonly tenantId: string; + readonly sessionId: string; + readonly activationId: string; +} + +export interface ManagedActivationDescriptor extends ManagedActivationIdentity { + readonly payloadRef: string; + readonly reason: 'user_message'; + readonly recovery: 'replay_safe'; +} + +export interface ManagedActivationFence extends ManagedActivationIdentity { + readonly workerId: string; + readonly epoch: number; +} + +export interface ManagedActivationLease extends ManagedActivationFence { + readonly expiresAt: number; +} + +export type ManagedActivationOutcome = 'completed' | 'failed'; + +export interface ManagedActivationSnapshot { + readonly descriptor: ManagedActivationDescriptor; + readonly queuedAt: number; + readonly queueSequence: number; + readonly status: 'queued' | 'assigned' | 'released'; + readonly lease?: ManagedActivationLease; + readonly outcome?: ManagedActivationOutcome; + readonly releasedAt?: number; +} + +export interface ManagedActivationQueueLimits { + readonly maxQueued: number; + readonly maxQueuedPerTenant: number; +} + +export interface ManagedActivationEnqueueResult { + readonly created: boolean; + readonly activation: ManagedActivationSnapshot; +} + +export interface FileManagedActivationStoreOptions { + readonly clock?: () => number; +} + +export class ManagedActivationAdmissionError extends Error { + readonly retryable = true; + + constructor( + readonly code: 'GLOBAL_QUEUE_FULL' | 'TENANT_QUEUE_FULL', + message: string, + ) { + super(message); + this.name = 'ManagedActivationAdmissionError'; + } +} + +export class ManagedActivationStaleLeaseError extends Error { + constructor(message: string) { + super(message); + this.name = 'ManagedActivationStaleLeaseError'; + } +} + +interface ActivationState { + descriptor: ManagedActivationDescriptor; + queuedAt: number; + queueSequence: number; + status: 'queued' | 'assigned' | 'released'; + lease?: ManagedActivationLease; + outcome?: ManagedActivationOutcome; + releasedAt?: number; +} + +interface EventBase { + readonly v: 1; + readonly sequence: number; + readonly at: number; +} + +type JournalEvent = + | (EventBase & { + readonly type: 'activation.cancelled'; + readonly identity: ManagedActivationIdentity; + }) + | (EventBase & { + readonly type: 'activation.queued'; + readonly activation: ManagedActivationDescriptor; + }) + | (EventBase & { + readonly type: 'activation.assigned'; + readonly lease: ManagedActivationLease; + }) + | (EventBase & { + readonly type: 'activation.renewed'; + readonly lease: ManagedActivationLease; + }) + | (EventBase & { + readonly type: 'activation.released'; + readonly identity: ManagedActivationIdentity; + readonly workerId: string; + readonly epoch: number; + readonly outcome: ManagedActivationOutcome; + }); + +type JsonObject = Record; + +function object(value: unknown, name: string): JsonObject { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${name} must be an object.`); + } + return value as JsonObject; +} + +function text(value: JsonObject, field: string): string { + const result = value[field]; + if (typeof result !== 'string' || result.trim().length === 0) { + throw new Error(`${field} must be a non-empty string.`); + } + return result; +} + +function integer(value: JsonObject, field: string, minimum = 0): number { + const result = value[field]; + if (!Number.isSafeInteger(result) || (result as number) < minimum) { + throw new Error(`${field} must be a safe integer >= ${minimum}.`); + } + return result as number; +} + +function positive(name: string, value: number): void { + if (!Number.isSafeInteger(value) || value < 1) { + throw new Error(`${name} must be a positive safe integer.`); + } +} + +function timestamp(name: string, value: number): void { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`${name} must be a non-negative safe integer.`); + } +} + +function identity(value: unknown): ManagedActivationIdentity { + const record = object(value, 'identity'); + return { + tenantId: text(record, 'tenantId'), + sessionId: text(record, 'sessionId'), + activationId: text(record, 'activationId'), + }; +} + +function descriptor(value: unknown): ManagedActivationDescriptor { + const record = object(value, 'activation'); + const reason = record['reason']; + if (reason !== 'user_message') { + throw new Error('reason is invalid.'); + } + if (record['recovery'] !== 'replay_safe') { + throw new Error("recovery must be 'replay_safe'."); + } + return { + ...identity(record), + payloadRef: text(record, 'payloadRef'), + reason, + recovery: 'replay_safe', + }; +} + +function lease(value: unknown): ManagedActivationLease { + const record = object(value, 'lease'); + return { + ...identity(record), + workerId: text(record, 'workerId'), + epoch: integer(record, 'epoch', 1), + expiresAt: integer(record, 'expiresAt'), + }; +} + +function outcome(value: unknown): ManagedActivationOutcome { + if (value !== 'completed' && value !== 'failed') { + throw new Error('outcome is invalid.'); + } + return value; +} + +function parseEvent(line: string, lineNumber: number): JournalEvent { + try { + const record = object(JSON.parse(line), 'event'); + if (record['v'] !== 1) throw new Error('version is unsupported.'); + const base = { + v: 1 as const, + sequence: integer(record, 'sequence', 1), + at: integer(record, 'at'), + }; + switch (record['type']) { + case 'activation.queued': + return { + ...base, + type: 'activation.queued', + activation: descriptor(record['activation']), + }; + case 'activation.assigned': + case 'activation.renewed': + return { ...base, type: record['type'], lease: lease(record['lease']) }; + case 'activation.cancelled': + return { + ...base, + type: 'activation.cancelled', + identity: identity(record['identity']), + }; + case 'activation.released': + return { + ...base, + type: 'activation.released', + identity: identity(record['identity']), + workerId: text(record, 'workerId'), + epoch: integer(record, 'epoch', 1), + outcome: outcome(record['outcome']), + }; + default: + throw new Error('event type is unknown.'); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Managed activation journal line ${lineNumber} is invalid: ${message}`, + ); + } +} + +function activationKey(value: ManagedActivationIdentity): string { + return JSON.stringify([value.tenantId, value.sessionId, value.activationId]); +} + +function sessionKey(value: ManagedActivationIdentity): string { + return JSON.stringify([value.tenantId, value.sessionId]); +} + +function snapshot(state: ActivationState): ManagedActivationSnapshot { + return structuredClone({ + descriptor: state.descriptor, + queuedAt: state.queuedAt, + queueSequence: state.queueSequence, + status: state.status, + ...(state.lease ? { lease: state.lease } : {}), + ...(state.outcome ? { outcome: state.outcome } : {}), + ...(state.releasedAt === undefined ? {} : { releasedAt: state.releasedAt }), + }); +} + +/** Single-process durable adapter; the same file must have only one owner. */ +export class FileManagedActivationStore { + private readonly activations = new Map(); + private readonly nextEpoch = new Map(); + private nextSequence = 1; + private tail: Promise = Promise.resolve(); + private fatalError: Error | undefined; + + private constructor( + readonly filePath: string, + private readonly clock: () => number, + ) {} + + static async open( + filePath: string, + options: FileManagedActivationStoreOptions = {}, + ): Promise { + const store = new FileManagedActivationStore( + filePath, + options.clock ?? Date.now, + ); + let contents: Buffer; + try { + contents = await readFile(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return store; + throw error; + } + const complete = + contents.length === 0 || contents[contents.length - 1] === 0x0a; + const validLength = complete + ? contents.length + : contents.lastIndexOf(0x0a) + 1; + const lines = contents + .subarray(0, validLength) + .toString('utf8') + .split('\n'); + lines.pop(); + lines.forEach((line, index) => { + if (!line) { + throw new Error( + `Managed activation journal line ${index + 1} is empty.`, + ); + } + store.apply(parseEvent(line, index + 1)); + }); + if (!complete) await truncate(filePath, validLength); + return store; + } + + enqueue( + input: ManagedActivationDescriptor, + limits: ManagedActivationQueueLimits, + ): Promise { + return this.serial(async () => { + const at = this.getCurrentTime(); + const activation = descriptor(input); + positive('maxQueued', limits.maxQueued); + positive('maxQueuedPerTenant', limits.maxQueuedPerTenant); + if (limits.maxQueuedPerTenant > limits.maxQueued) { + throw new Error('maxQueuedPerTenant cannot exceed maxQueued.'); + } + const existing = this.activations.get(activationKey(activation)); + if (existing) { + if (!isDeepStrictEqual(existing.descriptor, activation)) { + throw new Error( + `Managed activation '${activation.activationId}' was reused with different data.`, + ); + } + return { created: false, activation: snapshot(existing) }; + } + + const queued = [...this.activations.values()].filter( + (state) => state.status === 'queued', + ); + if (queued.length >= limits.maxQueued) { + throw new ManagedActivationAdmissionError( + 'GLOBAL_QUEUE_FULL', + `Managed activation queue is full (${limits.maxQueued}).`, + ); + } + if ( + queued.filter( + (state) => state.descriptor.tenantId === activation.tenantId, + ).length >= limits.maxQueuedPerTenant + ) { + throw new ManagedActivationAdmissionError( + 'TENANT_QUEUE_FULL', + `Managed activation queue for tenant '${activation.tenantId}' is full (${limits.maxQueuedPerTenant}).`, + ); + } + + await this.persist({ + v: 1, + sequence: this.nextSequence, + at, + type: 'activation.queued', + activation, + }); + return { + created: true, + activation: snapshot(this.activations.get(activationKey(activation))!), + }; + }); + } + + cancelQueued(input: ManagedActivationIdentity): Promise { + const captured = structuredClone(input); + return this.serial(async () => { + const activation = identity(captured); + const state = this.activations.get(activationKey(activation)); + if (!state || state.status !== 'queued') return false; + await this.persist({ + v: 1, + sequence: this.nextSequence, + at: this.getCurrentTime(), + type: 'activation.cancelled', + identity: activation, + }); + return true; + }); + } + + claim( + input: ManagedActivationIdentity, + workerId: string, + leaseDurationMs: number, + ): Promise { + return this.serial(async () => { + const at = this.getCurrentTime(); + const activation = identity(input); + if (!workerId.trim()) throw new Error('workerId must be non-empty.'); + positive('leaseDurationMs', leaseDurationMs); + const state = this.activations.get(activationKey(activation)); + if ( + !state || + state.status === 'released' || + (state.status === 'assigned' && state.lease!.expiresAt > at) || + !this.isOldest(state) + ) { + return undefined; + } + const expiresAt = at + leaseDurationMs; + if (!Number.isSafeInteger(expiresAt)) { + throw new Error('lease expiry exceeds safe integer range.'); + } + const key = sessionKey(activation); + const assigned: ManagedActivationLease = { + ...activation, + workerId, + epoch: this.nextEpoch.get(key) ?? 1, + expiresAt, + }; + await this.persist({ + v: 1, + sequence: this.nextSequence, + at, + type: 'activation.assigned', + lease: assigned, + }); + return structuredClone(assigned); + }); + } + + renew( + input: ManagedActivationLease, + leaseDurationMs: number, + ): Promise { + return this.serial(async () => { + const at = this.getCurrentTime(); + const currentLease = lease(input); + positive('leaseDurationMs', leaseDurationMs); + const state = this.current(currentLease, at); + const expiresAt = at + leaseDurationMs; + if (!Number.isSafeInteger(expiresAt)) { + throw new Error('lease expiry exceeds safe integer range.'); + } + if (expiresAt <= state.lease!.expiresAt) { + return structuredClone(state.lease!); + } + const renewed = { ...state.lease!, expiresAt }; + await this.persist({ + v: 1, + sequence: this.nextSequence, + at, + type: 'activation.renewed', + lease: renewed, + }); + return structuredClone(renewed); + }); + } + + release( + input: ManagedActivationLease, + result: ManagedActivationOutcome, + ): Promise { + return this.serial(async () => { + const at = this.getCurrentTime(); + const currentLease = lease(input); + this.current(currentLease, at); + const releaseOutcome = outcome(result); + await this.persist({ + v: 1, + sequence: this.nextSequence, + at, + type: 'activation.released', + identity: identity(currentLease), + workerId: currentLease.workerId, + epoch: currentLease.epoch, + outcome: releaseOutcome, + }); + return snapshot(this.activations.get(activationKey(currentLease))!); + }); + } + + get(input: ManagedActivationIdentity): ManagedActivationSnapshot | undefined { + const state = this.activations.get(activationKey(input)); + return state ? snapshot(state) : undefined; + } + + listPending(): ManagedActivationSnapshot[] { + return [...this.activations.values()] + .filter((state) => state.status !== 'released') + .sort((left, right) => left.queueSequence - right.queueSequence) + .map(snapshot); + } + + listRunnable(): ManagedActivationSnapshot[] { + const at = this.getCurrentTime(); + const first = new Map(); + for (const state of this.activations.values()) { + if (state.status === 'released') continue; + const key = sessionKey(state.descriptor); + if (!first.has(key)) first.set(key, state); + } + return [...first.values()] + .filter( + (state) => state.status === 'queued' || state.lease!.expiresAt <= at, + ) + .sort((left, right) => left.queueSequence - right.queueSequence) + .map(snapshot); + } + + getCurrentTime(): number { + const value = this.clock(); + timestamp('clock', value); + return value; + } + + get haltedError(): Error | undefined { + return this.fatalError; + } + + private serial(operation: () => Promise): Promise { + const result = this.tail.then(() => { + if (this.fatalError) throw this.fatalError; + return operation(); + }); + this.tail = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + private async persist(event: JournalEvent): Promise { + try { + await mkdir(path.dirname(this.filePath), { recursive: true }); + await appendFile( + this.filePath, + Buffer.from(`${JSON.stringify(event)}\n`), + { + flush: true, + mode: 0o600, + }, + ); + this.apply(event); + } catch (error) { + this.fatalError = + error instanceof Error ? error : new Error(String(error)); + throw this.fatalError; + } + } + + private apply(event: JournalEvent): void { + if (event.sequence !== this.nextSequence) { + throw new Error( + `Expected journal sequence ${this.nextSequence}, got ${event.sequence}.`, + ); + } + timestamp('event.at', event.at); + if (event.type === 'activation.queued') { + if (this.activations.has(activationKey(event.activation))) { + throw new Error('Journal queues the same activation twice.'); + } + this.activations.set(activationKey(event.activation), { + descriptor: structuredClone(event.activation), + queuedAt: event.at, + queueSequence: event.sequence, + status: 'queued', + }); + } else if (event.type === 'activation.cancelled') { + const state = this.activations.get(activationKey(event.identity)); + if (!state || state.status !== 'queued') + throw new Error('Journal contains an invalid queued cancellation.'); + state.status = 'released'; + state.outcome = 'failed'; + state.releasedAt = event.at; + } else if (event.type === 'activation.assigned') { + const state = this.activations.get(activationKey(event.lease)); + const expectedEpoch = this.nextEpoch.get(sessionKey(event.lease)) ?? 1; + if ( + !state || + state.status === 'released' || + !this.isOldest(state) || + (state.status === 'assigned' && state.lease!.expiresAt > event.at) || + event.lease.epoch !== expectedEpoch || + event.lease.expiresAt <= event.at + ) { + throw new Error('Journal contains an invalid activation assignment.'); + } + state.status = 'assigned'; + state.lease = structuredClone(event.lease); + this.nextEpoch.set(sessionKey(event.lease), expectedEpoch + 1); + } else if (event.type === 'activation.renewed') { + const state = this.current(event.lease, event.at); + if (event.lease.expiresAt <= state.lease!.expiresAt) { + throw new Error('Journal renewal does not extend the lease.'); + } + state.lease = structuredClone(event.lease); + } else { + const state = this.current( + { + ...event.identity, + workerId: event.workerId, + epoch: event.epoch, + }, + event.at, + ); + state.status = 'released'; + state.outcome = event.outcome; + state.releasedAt = event.at; + delete state.lease; + } + this.nextSequence += 1; + } + + private current(input: ManagedActivationFence, at: number): ActivationState { + const state = this.activations.get(activationKey(input)); + if ( + !state || + state.status !== 'assigned' || + state.lease!.workerId !== input.workerId || + state.lease!.epoch !== input.epoch || + state.lease!.expiresAt <= at + ) { + throw new ManagedActivationStaleLeaseError( + `Managed activation lease '${input.activationId}' at epoch ${input.epoch} is stale.`, + ); + } + return state; + } + + private isOldest(candidate: ActivationState): boolean { + const key = sessionKey(candidate.descriptor); + return ![...this.activations.values()].some( + (state) => + state.status !== 'released' && + state.queueSequence < candidate.queueSequence && + sessionKey(state.descriptor) === key, + ); + } +} diff --git a/packages/core/src/managed-runtime/managed-harness-checkpoint.test.ts b/packages/core/src/managed-runtime/managed-harness-checkpoint.test.ts new file mode 100644 index 00000000000..88faab7f7a2 --- /dev/null +++ b/packages/core/src/managed-runtime/managed-harness-checkpoint.test.ts @@ -0,0 +1,592 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { + authorizeParsedHarnessCheckpoint, + createAwaitActionHarnessCheckpoint, + createAwaitRuntimeHarnessCheckpoint, + createConsumedRuntimeResultsHarnessCheckpoint, + createInitialHarnessCheckpoint, + createModelOutputCommittedHarnessCheckpoint, + createNextTurnReadyHarnessCheckpoint, + createResultsReadyHarnessCheckpoint, + encodeHarnessCheckpointV1, + parseHarnessCheckpointV1, + tryParseHarnessCheckpointV1, + type HarnessCheckpointV1, +} from './managed-harness-checkpoint.js'; +import { + ManagedSessionRecordError, + type ManagedSessionDurableRef, +} from './managed-session-records.js'; + +const DIGEST = 'b'.repeat(64); +const SESSION_KEY = { + tenantId: 'tenant-1', + workspaceId: 'workspace-1', + sessionId: 'managed-session', +}; + +function ref(kind = 'managed-test'): ManagedSessionDurableRef { + return { + resourceId: 'res-1', + kind, + schemaVersion: 1, + byteLength: 4, + digest: DIGEST, + }; +} + +function seed( + overrides: Partial[0]> = {}, +): HarnessCheckpointV1 { + return createInitialHarnessCheckpoint({ + sessionKey: SESSION_KEY, + checkpointId: 'ckpt-4', + coveredSequence: 3, + activationId: 'act-1', + turnId: 'turn-1', + promptId: null, + definitionRevision: 'def-1', + configRevision: 'cfg-1', + inputDigest: DIGEST, + previousCheckpointId: null, + ...overrides, + }); +} + +function bytesOf(checkpoint: HarnessCheckpointV1): Buffer { + return encodeHarnessCheckpointV1(checkpoint); +} + +function mutate( + checkpoint: HarnessCheckpointV1, + edit: (value: Record) => void, +): Buffer { + const value = JSON.parse(JSON.stringify(checkpoint)) as Record< + string, + unknown + >; + edit(value); + return Buffer.from(JSON.stringify(value), 'utf8'); +} + +function committedAttempt() { + return { + attemptId: 'att-1', + routeRef: ref('managed-route'), + capabilityRef: null, + samplingRef: null, + outputState: 'output_committed' as const, + usageRef: ref('managed-usage'), + budgetConsumed: 1, + }; +} + +function requestedUserApproval() { + return { + requestId: 'apr-1', + kind: 'ask_user', + source: 'user_operation' as const, + optionsRef: ref('managed-approval'), + inputRevision: 'rev-1', + confirmationVersion: null, + state: 'requested' as const, + decisionRef: null, + invocationRef: null, + }; +} + +function inProgressRuntime() { + return { + tools: { + batchId: 'batch-1', + items: [ + { + functionCallId: 'fc-1', + toolName: 'read_file', + executionCallId: 'ex-1', + modelMessageId: 'msg-1', + partIndex: 0, + ordinal: 0, + inputDigest: DIGEST, + outcomeSource: 'runtime' as const, + state: 'in_progress' as const, + outcomeRef: null, + consumed: false, + }, + ], + }, + runtime: { + bindings: [ + { + executionCallId: 'ex-1', + invocationBindingId: 'bind-1', + capabilityVersion: 'cap-1', + policyVersion: 'pol-1', + mediaVersion: null, + state: 'dispatch' as const, + progressCursor: null, + }, + ], + }, + }; +} + +describe('harness checkpoint v1', () => { + it('round-trips a before_model checkpoint with all nine groups', () => { + const checkpoint = seed(); + expect(checkpoint.continuation.phase).toBe('before_model'); + expect(checkpoint.attempt).toBeNull(); + expect(checkpoint.tools).toBeNull(); + expect(checkpoint.runtime).toBeNull(); + expect(checkpoint.approval).toBeNull(); + expect(parseHarnessCheckpointV1(bytesOf(checkpoint))).toEqual(checkpoint); + expect(tryParseHarnessCheckpointV1(bytesOf(checkpoint))).toEqual({ + ok: true, + checkpoint, + }); + }); + + it('rejects an unknown continuation phase', () => { + const bytes = mutate(seed(), (value) => { + const continuation = value['continuation'] as Record; + continuation['phase'] = 'streaming'; + }); + expect(() => parseHarnessCheckpointV1(bytes)).toThrow( + ManagedSessionRecordError, + ); + expect(() => parseHarnessCheckpointV1(bytes)).toThrow( + /continuation.phase must be one of/, + ); + const parsed = tryParseHarnessCheckpointV1(bytes); + expect(parsed.ok).toBe(false); + if (!parsed.ok) expect(parsed.reason).toBe('invalid'); + }); + + it('rejects a before_model checkpoint that already carries an attempt', () => { + const checkpoint = { + ...seed(), + attempt: committedAttempt(), + }; + expect(() => parseHarnessCheckpointV1(bytesOf(checkpoint))).toThrow( + /before_model cannot carry a model attempt/, + ); + }); + + it('rejects a user_operation approval that carries an invocation ref', () => { + const checkpoint = { + ...seed(), + continuation: { + phase: 'await_action' as const, + pendingEventIds: [], + }, + attempt: committedAttempt(), + approval: { + ...requestedUserApproval(), + invocationRef: ref('managed-invocation'), + }, + }; + expect(() => parseHarnessCheckpointV1(bytesOf(checkpoint))).toThrow( + /approval.invocationRef must be null for a user_operation approval/, + ); + }); + + it('rejects a tool_call approval without an invocation ref', () => { + const checkpoint = { + ...seed(), + continuation: { + phase: 'await_action' as const, + pendingEventIds: [], + }, + attempt: committedAttempt(), + approval: { + ...requestedUserApproval(), + source: 'tool_call' as const, + }, + }; + expect(() => parseHarnessCheckpointV1(bytesOf(checkpoint))).toThrow( + /approval.invocationRef is required for a tool_call approval/, + ); + }); + + it('rejects await_runtime without in-flight tools and dispatch bindings', () => { + const checkpoint = { + ...seed(), + continuation: { + phase: 'await_runtime' as const, + pendingEventIds: [], + }, + attempt: committedAttempt(), + }; + expect(() => parseHarnessCheckpointV1(bytesOf(checkpoint))).toThrow( + /await_runtime requires in-progress tools and dispatch bindings/, + ); + }); + + it('accepts await_action for a requested user_operation', () => { + const checkpoint = { + ...seed(), + continuation: { + phase: 'await_action' as const, + pendingEventIds: [], + }, + attempt: committedAttempt(), + approval: requestedUserApproval(), + }; + expect(parseHarnessCheckpointV1(bytesOf(checkpoint))).toEqual(checkpoint); + }); + + it('accepts await_runtime with in-progress tools and dispatch bindings', () => { + const checkpoint = { + ...seed(), + continuation: { + phase: 'await_runtime' as const, + pendingEventIds: [], + }, + attempt: committedAttempt(), + tools: { + batchId: 'batch-1', + items: [ + { + functionCallId: 'fc-1', + toolName: 'read_file', + executionCallId: 'ex-1', + modelMessageId: 'msg-1', + partIndex: 0, + ordinal: 0, + inputDigest: DIGEST, + outcomeSource: 'runtime' as const, + state: 'in_progress' as const, + outcomeRef: null, + consumed: false, + }, + ], + }, + runtime: { + bindings: [ + { + executionCallId: 'ex-1', + invocationBindingId: 'bind-1', + capabilityVersion: 'cap-1', + policyVersion: 'pol-1', + mediaVersion: null, + state: 'dispatch' as const, + progressCursor: 'cur-1', + }, + ], + }, + }; + expect(parseHarnessCheckpointV1(bytesOf(checkpoint))).toEqual(checkpoint); + }); + + it('rejects unknown fields on the checkpoint object', () => { + const bytes = mutate(seed(), (value) => { + value['agentSnapshot'] = { tokens: 1 }; + }); + expect(() => parseHarnessCheckpointV1(bytes)).toThrow( + /harness checkpoint has the unknown field "agentSnapshot"/, + ); + const parsed = tryParseHarnessCheckpointV1(bytes); + expect(parsed.ok).toBe(false); + if (!parsed.ok) expect(parsed.reason).toBe('invalid'); + }); + + it('classifies non-JSON and unversioned blobs as opaque', () => { + expect(tryParseHarnessCheckpointV1(Buffer.from('first', 'utf8'))).toEqual({ + ok: false, + reason: 'opaque', + message: expect.stringMatching(/JSON/), + }); + expect( + tryParseHarnessCheckpointV1( + Buffer.from('{"turn":1,"pending":[]}', 'utf8'), + ), + ).toEqual({ + ok: false, + reason: 'opaque', + message: 'checkpoint state has no Harness schemaVersion.', + }); + }); + + it('does not treat an in-flight model stream as a runnable phase', () => { + const checkpoint = { + ...seed(), + continuation: { + phase: 'model_output_committed' as const, + pendingEventIds: [], + }, + attempt: { + ...committedAttempt(), + outputState: 'started' as const, + usageRef: null, + }, + }; + expect(() => parseHarnessCheckpointV1(bytesOf(checkpoint))).toThrow( + /an in-flight model stream is not a runnable checkpoint phase/, + ); + }); + + it('authorizes a parsed checkpoint only when identity matches', () => { + const checkpoint = seed(); + expect( + authorizeParsedHarnessCheckpoint(checkpoint, { + sessionKey: SESSION_KEY, + checkpointId: 'ckpt-4', + coveredSequence: 3, + }), + ).toEqual({ status: 'runnable', checkpoint }); + expect( + authorizeParsedHarnessCheckpoint(checkpoint, { + sessionKey: { ...SESSION_KEY, sessionId: 'other-session' }, + checkpointId: 'ckpt-4', + coveredSequence: 3, + }), + ).toMatchObject({ status: 'blocked', reason: 'identity_mismatch' }); + }); + + it('clears in-flight groups when preparing the next-turn-ready checkpoint', () => { + const previous = { + ...seed(), + continuation: { + phase: 'turn_settled' as const, + pendingEventIds: ['evt-1'], + }, + attempt: committedAttempt(), + followUp: { + ...seed().followUp, + pendingInputIds: ['in-2'], + }, + }; + const next = createNextTurnReadyHarnessCheckpoint({ + previous, + checkpointId: 'ckpt-6', + coveredSequence: 4, + previousCheckpointId: 'ckpt-4', + activationId: 'act-2', + turnId: 'turn-2', + promptId: 'turn-2', + }); + expect(next.continuation.phase).toBe('before_model'); + expect(next.continuation.pendingEventIds).toEqual([]); + expect(next.attempt).toBeNull(); + expect(next.tools).toBeNull(); + expect(next.identity).toMatchObject({ + checkpointId: 'ckpt-6', + coveredSequence: 4, + previousCheckpointId: 'ckpt-4', + activationId: 'act-2', + turnId: 'turn-2', + promptId: 'turn-2', + }); + expect(next.resume.throughSequence).toBe(4); + expect(next.followUp.pendingInputIds).toEqual(['in-2']); + expect(parseHarnessCheckpointV1(bytesOf(next))).toEqual(next); + }); + + it('builds await_action from a previous checkpoint and resumes to model_output_committed', () => { + const wait = createAwaitActionHarnessCheckpoint({ + previous: seed(), + checkpointId: 'ckpt-5', + coveredSequence: 4, + previousCheckpointId: 'ckpt-4', + attempt: committedAttempt(), + approval: requestedUserApproval(), + }); + expect(wait.continuation.phase).toBe('await_action'); + expect(wait.approval?.state).toBe('requested'); + expect(wait.resume.throughSequence).toBe(4); + expect(parseHarnessCheckpointV1(bytesOf(wait))).toEqual(wait); + + const resumed = createModelOutputCommittedHarnessCheckpoint({ + previous: wait, + checkpointId: 'ckpt-6', + coveredSequence: 5, + previousCheckpointId: 'ckpt-5', + }); + expect(resumed.continuation.phase).toBe('model_output_committed'); + expect(resumed.approval).toBeNull(); + expect(resumed.attempt).toEqual(wait.attempt); + expect(parseHarnessCheckpointV1(bytesOf(resumed))).toEqual(resumed); + }); + + it('rejects model_output_committed resume without the waited attempt', () => { + expect(() => + createModelOutputCommittedHarnessCheckpoint({ + previous: seed(), + checkpointId: 'ckpt-5', + coveredSequence: 4, + previousCheckpointId: 'ckpt-4', + }), + ).toThrow(/requires the waited attempt/); + }); + + it('builds await_runtime from a previous checkpoint and resumes to results_ready', () => { + const wait = createAwaitRuntimeHarnessCheckpoint({ + previous: seed(), + checkpointId: 'ckpt-5', + coveredSequence: 4, + previousCheckpointId: 'ckpt-4', + attempt: committedAttempt(), + ...inProgressRuntime(), + }); + expect(wait.continuation.phase).toBe('await_runtime'); + expect(wait.approval).toBeNull(); + expect(wait.tools?.items[0]?.state).toBe('in_progress'); + expect(wait.runtime?.bindings[0]?.state).toBe('dispatch'); + expect(parseHarnessCheckpointV1(bytesOf(wait))).toEqual(wait); + + const outcomeRef = ref('managed-tool-outcome'); + const ready = createResultsReadyHarnessCheckpoint({ + previous: wait, + checkpointId: 'ckpt-6', + coveredSequence: 5, + previousCheckpointId: 'ckpt-5', + executionCallId: 'ex-1', + outcomeRef, + }); + expect(ready.continuation.phase).toBe('results_ready'); + expect(ready.tools?.items[0]).toMatchObject({ + state: 'settled', + outcomeRef, + consumed: false, + }); + expect(ready.runtime?.bindings[0]?.state).toBe('settled'); + expect(parseHarnessCheckpointV1(bytesOf(ready))).toEqual(ready); + + const consumed = createConsumedRuntimeResultsHarnessCheckpoint({ + previous: ready, + checkpointId: 'ckpt-7', + coveredSequence: 6, + previousCheckpointId: 'ckpt-6', + }); + expect(consumed.continuation.phase).toBe('results_ready'); + expect(consumed.tools?.items[0]).toMatchObject({ + state: 'settled', + outcomeRef, + consumed: true, + }); + expect(parseHarnessCheckpointV1(bytesOf(consumed))).toEqual(consumed); + }); + + it('settles multiple Runtime executions independently in reverse order', () => { + const runtime = inProgressRuntime(); + const wait = createAwaitRuntimeHarnessCheckpoint({ + previous: seed(), + checkpointId: 'ckpt-5', + coveredSequence: 4, + previousCheckpointId: 'ckpt-4', + attempt: committedAttempt(), + tools: { + ...runtime.tools, + items: [ + ...runtime.tools.items, + { + ...runtime.tools.items[0], + functionCallId: 'fc-2', + executionCallId: 'ex-2', + ordinal: 1, + }, + ], + }, + runtime: { + bindings: [ + ...runtime.runtime.bindings, + { + ...runtime.runtime.bindings[0], + executionCallId: 'ex-2', + invocationBindingId: 'bind-2', + }, + ], + }, + }); + const secondRef = ref('managed-tool-outcome-2'); + const partial = createResultsReadyHarnessCheckpoint({ + previous: wait, + checkpointId: 'ckpt-6', + coveredSequence: 5, + previousCheckpointId: 'ckpt-5', + executionCallId: 'ex-2', + outcomeRef: secondRef, + }); + expect(partial.continuation.phase).toBe('await_runtime'); + expect(partial.tools?.items).toMatchObject([ + { executionCallId: 'ex-1', state: 'in_progress', outcomeRef: null }, + { executionCallId: 'ex-2', state: 'settled', outcomeRef: secondRef }, + ]); + expect(partial.runtime?.bindings.map((binding) => binding.state)).toEqual([ + 'dispatch', + 'settled', + ]); + expect(parseHarnessCheckpointV1(bytesOf(partial))).toEqual(partial); + + const firstRef = ref('managed-tool-outcome-1'); + const ready = createResultsReadyHarnessCheckpoint({ + previous: partial, + checkpointId: 'ckpt-7', + coveredSequence: 6, + previousCheckpointId: 'ckpt-6', + executionCallId: 'ex-1', + outcomeRef: firstRef, + }); + expect(ready.continuation.phase).toBe('results_ready'); + expect(ready.tools?.items.map((item) => item.outcomeRef)).toEqual([ + firstRef, + secondRef, + ]); + expect(ready.runtime?.bindings.map((binding) => binding.state)).toEqual([ + 'settled', + 'settled', + ]); + expect(parseHarnessCheckpointV1(bytesOf(ready))).toEqual(ready); + }); + + it('rejects await_runtime that still has a requested approval', () => { + const previous = createAwaitActionHarnessCheckpoint({ + previous: seed(), + checkpointId: 'ckpt-5', + coveredSequence: 4, + previousCheckpointId: 'ckpt-4', + attempt: committedAttempt(), + approval: requestedUserApproval(), + }); + expect(() => + createAwaitRuntimeHarnessCheckpoint({ + previous, + checkpointId: 'ckpt-6', + coveredSequence: 5, + previousCheckpointId: 'ckpt-5', + attempt: committedAttempt(), + ...inProgressRuntime(), + }), + ).toThrow(/cannot keep a requested approval/); + }); + + it('rejects consumed Runtime results without a results_ready checkpoint', () => { + expect(() => + createConsumedRuntimeResultsHarnessCheckpoint({ + previous: seed(), + checkpointId: 'ckpt-5', + coveredSequence: 4, + previousCheckpointId: 'ckpt-4', + }), + ).toThrow(/require a results_ready checkpoint/); + }); + + it('rejects results_ready resume without an await_runtime checkpoint', () => { + expect(() => + createResultsReadyHarnessCheckpoint({ + previous: seed(), + checkpointId: 'ckpt-5', + coveredSequence: 4, + previousCheckpointId: 'ckpt-4', + executionCallId: 'ex-1', + outcomeRef: ref('managed-tool-outcome'), + }), + ).toThrow(/requires an await_runtime checkpoint/); + }); +}); diff --git a/packages/core/src/managed-runtime/managed-harness-checkpoint.ts b/packages/core/src/managed-runtime/managed-harness-checkpoint.ts new file mode 100644 index 00000000000..bb00ebea674 --- /dev/null +++ b/packages/core/src/managed-runtime/managed-harness-checkpoint.ts @@ -0,0 +1,1643 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { ManagedSessionJsonValue } from './managed-session-inbox.js'; +import { + MANAGED_SESSION_LIMITS, + ManagedSessionRecordError, + assertManagedSessionDigest, + assertManagedSessionDurableRef, + assertManagedSessionKey, + assertManagedSessionSequence, + assertManagedSessionStableId, + managedSessionKeysEqual, + parseManagedSessionRecordJson, + type ManagedSessionDurableRef, + type ManagedSessionKey, +} from './managed-session-records.js'; + +export const HARNESS_CHECKPOINT_SCHEMA_VERSION = 1; + +export const HARNESS_CHECKPOINT_PHASES = [ + 'before_model', + 'model_output_committed', + 'await_action', + 'await_runtime', + 'results_ready', + 'turn_settled', +] as const; + +export type HarnessCheckpointPhase = (typeof HARNESS_CHECKPOINT_PHASES)[number]; + +/** + * Phases from which R2.S2 may start a model request. Awaited approvals and + * in-flight Runtime work are R2.S3; those phases are runnable checkpoints + * but not a finished-turn safety point. + */ +export const HARNESS_MODEL_START_PHASES: ReadonlySet = + new Set([ + 'before_model', + 'model_output_committed', + 'results_ready', + 'turn_settled', + ]); + +/** Event-payload boundary for a finished turn with no pending Harness work. */ +export const HARNESS_TURN_COMPLETE_BOUNDARY = 'turn_complete'; + +/** Event-payload boundary for an approval or in-flight Runtime wait. */ +export const HARNESS_DURABLE_WAIT_BOUNDARY = 'durable_wait'; + +export const HARNESS_ACTION_SOURCES = [ + 'tool_call', + 'automation_run', + 'team_plan', + 'user_operation', +] as const; + +export type HarnessActionSource = (typeof HARNESS_ACTION_SOURCES)[number]; + +export const HARNESS_TOOL_ITEM_STATES = [ + 'not_started', + 'in_progress', + 'settled', +] as const; + +export type HarnessToolItemState = (typeof HARNESS_TOOL_ITEM_STATES)[number]; + +export const HARNESS_TOOL_OUTCOME_SOURCES = [ + 'runtime', + 'orchestration', + 'domain', +] as const; + +export type HarnessToolOutcomeSource = + (typeof HARNESS_TOOL_OUTCOME_SOURCES)[number]; + +export const HARNESS_RUNTIME_STATES = [ + 'dispatch', + 'settled', + 'committed', +] as const; + +export type HarnessRuntimeState = (typeof HARNESS_RUNTIME_STATES)[number]; + +export const HARNESS_APPROVAL_STATES = [ + 'requested', + 'decided', + 'cancelled', + 'expired', +] as const; + +export type HarnessApprovalState = (typeof HARNESS_APPROVAL_STATES)[number]; + +export const HARNESS_ATTEMPT_OUTPUT_STATES = [ + 'started', + 'output_committed', + 'abandoned', +] as const; + +export type HarnessAttemptOutputState = + (typeof HARNESS_ATTEMPT_OUTPUT_STATES)[number]; + +export interface HarnessCheckpointIdentity { + readonly schemaVersion: typeof HARNESS_CHECKPOINT_SCHEMA_VERSION; + readonly sessionKey: ManagedSessionKey; + readonly engine: 'managed'; + readonly checkpointId: string; + readonly coveredSequence: number; + readonly activationId: string; + readonly turnId: string | null; + readonly promptId: string | null; + readonly definitionRevision: string; + readonly configRevision: string; + readonly inputDigest: string; + readonly previousCheckpointId: string | null; +} + +export interface HarnessResumeRecording { + readonly lastCompletedUuid: string | null; + readonly turnParentUuids: readonly string[]; + readonly parentSessionId: string | null; + readonly sourceType: string | null; + readonly sourceId: string | null; + readonly lastAssistantModel: string | null; + readonly executionEngine: 'managed'; +} + +/** + * Resume state is rebuilt from the session log through `throughSequence`. + * Large arrays live behind refs so the checkpoint does not clone the Agent. + */ +export interface HarnessResumeGroup { + readonly source: 'session_log'; + readonly throughSequence: number; + readonly recording: HarnessResumeRecording; + readonly initialTurn: number; + readonly consumedNotificationIds: readonly string[]; + readonly apiHistoryRef: ManagedSessionDurableRef | null; + readonly fileHistoryRef: ManagedSessionDurableRef | null; + readonly artifactRef: ManagedSessionDurableRef | null; + readonly goalRecordsRef: ManagedSessionDurableRef | null; + readonly goalCheckpointWindowRef: ManagedSessionDurableRef | null; + readonly tokenCountsRef: ManagedSessionDurableRef | null; + readonly uiTelemetryRef: ManagedSessionDurableRef | null; + readonly attributionRef: ManagedSessionDurableRef | null; + readonly goalRecoverySourceUuid: string | null; +} + +export interface HarnessContinuationGroup { + readonly phase: HarnessCheckpointPhase; + readonly pendingEventIds: readonly string[]; +} + +export interface HarnessAttemptGroup { + readonly attemptId: string; + readonly routeRef: ManagedSessionDurableRef; + readonly capabilityRef: ManagedSessionDurableRef | null; + readonly samplingRef: ManagedSessionDurableRef | null; + readonly outputState: HarnessAttemptOutputState; + readonly usageRef: ManagedSessionDurableRef | null; + readonly budgetConsumed: number; +} + +export interface HarnessToolItem { + readonly functionCallId: string; + readonly toolName: string; + readonly executionCallId: string; + readonly modelMessageId: string; + readonly partIndex: number; + readonly ordinal: number; + readonly inputDigest: string; + readonly outcomeSource: HarnessToolOutcomeSource; + readonly state: HarnessToolItemState; + readonly outcomeRef: ManagedSessionDurableRef | null; + readonly consumed: boolean; +} + +export interface HarnessToolsGroup { + readonly batchId: string | null; + readonly items: readonly HarnessToolItem[]; +} + +export interface HarnessRuntimeBinding { + readonly executionCallId: string; + readonly invocationBindingId: string; + readonly capabilityVersion: string; + readonly policyVersion: string; + readonly mediaVersion: string | null; + readonly state: HarnessRuntimeState; + readonly progressCursor: string | null; +} + +export interface HarnessRuntimeGroup { + readonly bindings: readonly HarnessRuntimeBinding[]; +} + +export interface HarnessApprovalGroup { + readonly requestId: string; + readonly kind: string; + readonly source: HarnessActionSource; + readonly optionsRef: ManagedSessionDurableRef; + readonly inputRevision: string; + readonly confirmationVersion: string | null; + readonly state: HarnessApprovalState; + readonly decisionRef: ManagedSessionDurableRef | null; + readonly invocationRef: ManagedSessionDurableRef | null; +} + +export interface HarnessOutputGroup { + readonly llmContentRef: ManagedSessionDurableRef | null; + readonly physicalStatus: string | null; + readonly hookResultRef: ManagedSessionDurableRef | null; + readonly mediaRefs: readonly ManagedSessionDurableRef[]; + readonly parentHistory: { + readonly bindingId: string; + readonly revision: number; + } | null; +} + +export interface HarnessFollowUpGroup { + readonly pendingInputIds: readonly string[]; + readonly cancelRequestIds: readonly string[]; + readonly goalPermitIds: readonly string[]; + readonly cronIds: readonly string[]; + readonly notificationIds: readonly string[]; + readonly childRunIds: readonly string[]; + readonly stopBudgetRemaining: number | null; + readonly scopeLineage: readonly string[]; +} + +export interface HarnessCheckpointV1 { + readonly identity: HarnessCheckpointIdentity; + readonly resume: HarnessResumeGroup; + readonly continuation: HarnessContinuationGroup; + readonly attempt: HarnessAttemptGroup | null; + readonly tools: HarnessToolsGroup | null; + readonly runtime: HarnessRuntimeGroup | null; + readonly approval: HarnessApprovalGroup | null; + readonly output: HarnessOutputGroup; + readonly followUp: HarnessFollowUpGroup; +} + +export type HarnessCheckpointParseFailureReason = 'opaque' | 'invalid'; + +export type HarnessCheckpointParseResult = + | { readonly ok: true; readonly checkpoint: HarnessCheckpointV1 } + | { + readonly ok: false; + readonly reason: HarnessCheckpointParseFailureReason; + readonly message: string; + }; + +export type HarnessRunAuthorization = + | { readonly status: 'initial' } + | { readonly status: 'runnable'; readonly checkpoint: HarnessCheckpointV1 } + | { + readonly status: 'blocked'; + readonly reason: + | 'missing_checkpoint' + | 'missing_state' + | 'opaque_state' + | 'invalid_state' + | 'identity_mismatch'; + readonly message?: string; + }; + +const ROOT_KEYS = [ + 'identity', + 'resume', + 'continuation', + 'attempt', + 'tools', + 'runtime', + 'approval', + 'output', + 'followUp', +] as const; + +function fail(message: string): never { + throw new ManagedSessionRecordError(message); +} + +function object( + value: unknown, + label: string, +): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + fail(`${label} must be a JSON object.`); + } + return value as Record; +} + +function assertNoUnknownKeys( + input: Record, + allowed: readonly string[], + label: string, +): void { + for (const key of Object.keys(input)) { + if (!allowed.includes(key)) { + fail(`${label} has the unknown field "${key}".`); + } + } +} + +function oneOf( + value: ManagedSessionJsonValue | undefined, + allowed: readonly T[], + label: string, +): T { + if (typeof value !== 'string' || !allowed.includes(value as T)) { + fail(`${label} must be one of ${allowed.join(', ')}.`); + } + return value as T; +} + +function idOrNull( + value: ManagedSessionJsonValue | undefined, + label: string, +): string | null { + if (value === null) return null; + return assertManagedSessionStableId(value, label); +} + +function refOrNull( + value: ManagedSessionJsonValue | undefined, + label: string, +): ManagedSessionDurableRef | null { + if (value === null) return null; + return assertManagedSessionDurableRef(value, label); +} + +function sequenceOrNull( + value: ManagedSessionJsonValue | undefined, + label: string, +): number | null { + if (value === null) return null; + return assertManagedSessionSequence(value, label); +} + +function jsonArray( + value: ManagedSessionJsonValue | undefined, + label: string, +): ManagedSessionJsonValue[] { + if (!Array.isArray(value)) { + fail(`${label} must be a JSON array.`); + } + return value; +} + +function idList( + value: ManagedSessionJsonValue | undefined, + label: string, +): string[] { + return jsonArray(value, label).map((item, index) => + assertManagedSessionStableId(item, `${label}[${index}]`), + ); +} + +function uniqueIds(ids: readonly string[], label: string): void { + const seen = new Set(); + for (const id of ids) { + if (seen.has(id)) { + fail(`${label} repeats "${id}".`); + } + seen.add(id); + } +} + +function parseIdentity( + value: ManagedSessionJsonValue | undefined, +): HarnessCheckpointIdentity { + const record = object(value, 'identity'); + assertNoUnknownKeys( + record, + [ + 'schemaVersion', + 'sessionKey', + 'engine', + 'checkpointId', + 'coveredSequence', + 'activationId', + 'turnId', + 'promptId', + 'definitionRevision', + 'configRevision', + 'inputDigest', + 'previousCheckpointId', + ], + 'identity', + ); + if (record['schemaVersion'] !== HARNESS_CHECKPOINT_SCHEMA_VERSION) { + fail('identity.schemaVersion must be 1.'); + } + if (record['engine'] !== 'managed') { + fail('identity.engine must be managed.'); + } + return { + schemaVersion: HARNESS_CHECKPOINT_SCHEMA_VERSION, + sessionKey: assertManagedSessionKey( + record['sessionKey'], + 'identity.sessionKey', + ), + engine: 'managed', + checkpointId: assertManagedSessionStableId( + record['checkpointId'], + 'identity.checkpointId', + ), + coveredSequence: assertManagedSessionSequence( + record['coveredSequence'], + 'identity.coveredSequence', + ), + activationId: assertManagedSessionStableId( + record['activationId'], + 'identity.activationId', + ), + turnId: idOrNull(record['turnId'], 'identity.turnId'), + promptId: idOrNull(record['promptId'], 'identity.promptId'), + definitionRevision: assertManagedSessionStableId( + record['definitionRevision'], + 'identity.definitionRevision', + ), + configRevision: assertManagedSessionStableId( + record['configRevision'], + 'identity.configRevision', + ), + inputDigest: assertManagedSessionDigest( + record['inputDigest'], + 'identity.inputDigest', + ), + previousCheckpointId: idOrNull( + record['previousCheckpointId'], + 'identity.previousCheckpointId', + ), + }; +} + +function parseRecording( + value: ManagedSessionJsonValue | undefined, +): HarnessResumeRecording { + const record = object(value, 'resume.recording'); + assertNoUnknownKeys( + record, + [ + 'lastCompletedUuid', + 'turnParentUuids', + 'parentSessionId', + 'sourceType', + 'sourceId', + 'lastAssistantModel', + 'executionEngine', + ], + 'resume.recording', + ); + if (record['executionEngine'] !== 'managed') { + fail('resume.recording.executionEngine must be managed.'); + } + return { + lastCompletedUuid: idOrNull( + record['lastCompletedUuid'], + 'resume.recording.lastCompletedUuid', + ), + turnParentUuids: idList( + record['turnParentUuids'], + 'resume.recording.turnParentUuids', + ), + parentSessionId: idOrNull( + record['parentSessionId'], + 'resume.recording.parentSessionId', + ), + sourceType: idOrNull(record['sourceType'], 'resume.recording.sourceType'), + sourceId: idOrNull(record['sourceId'], 'resume.recording.sourceId'), + lastAssistantModel: idOrNull( + record['lastAssistantModel'], + 'resume.recording.lastAssistantModel', + ), + executionEngine: 'managed', + }; +} + +function parseResume( + value: ManagedSessionJsonValue | undefined, + coveredSequence: number, +): HarnessResumeGroup { + const record = object(value, 'resume'); + assertNoUnknownKeys( + record, + [ + 'source', + 'throughSequence', + 'recording', + 'initialTurn', + 'consumedNotificationIds', + 'apiHistoryRef', + 'fileHistoryRef', + 'artifactRef', + 'goalRecordsRef', + 'goalCheckpointWindowRef', + 'tokenCountsRef', + 'uiTelemetryRef', + 'attributionRef', + 'goalRecoverySourceUuid', + ], + 'resume', + ); + if (record['source'] !== 'session_log') { + fail('resume.source must be session_log.'); + } + const throughSequence = assertManagedSessionSequence( + record['throughSequence'], + 'resume.throughSequence', + ); + if (throughSequence !== coveredSequence) { + fail('resume.throughSequence must equal identity.coveredSequence.'); + } + const consumedNotificationIds = idList( + record['consumedNotificationIds'], + 'resume.consumedNotificationIds', + ); + uniqueIds(consumedNotificationIds, 'resume.consumedNotificationIds'); + return { + source: 'session_log', + throughSequence, + recording: parseRecording(record['recording']), + initialTurn: assertManagedSessionSequence( + record['initialTurn'], + 'resume.initialTurn', + ), + consumedNotificationIds, + apiHistoryRef: refOrNull(record['apiHistoryRef'], 'resume.apiHistoryRef'), + fileHistoryRef: refOrNull( + record['fileHistoryRef'], + 'resume.fileHistoryRef', + ), + artifactRef: refOrNull(record['artifactRef'], 'resume.artifactRef'), + goalRecordsRef: refOrNull( + record['goalRecordsRef'], + 'resume.goalRecordsRef', + ), + goalCheckpointWindowRef: refOrNull( + record['goalCheckpointWindowRef'], + 'resume.goalCheckpointWindowRef', + ), + tokenCountsRef: refOrNull( + record['tokenCountsRef'], + 'resume.tokenCountsRef', + ), + uiTelemetryRef: refOrNull( + record['uiTelemetryRef'], + 'resume.uiTelemetryRef', + ), + attributionRef: refOrNull( + record['attributionRef'], + 'resume.attributionRef', + ), + goalRecoverySourceUuid: idOrNull( + record['goalRecoverySourceUuid'], + 'resume.goalRecoverySourceUuid', + ), + }; +} + +function parseContinuation( + value: ManagedSessionJsonValue | undefined, +): HarnessContinuationGroup { + const record = object(value, 'continuation'); + assertNoUnknownKeys(record, ['phase', 'pendingEventIds'], 'continuation'); + const pendingEventIds = idList( + record['pendingEventIds'], + 'continuation.pendingEventIds', + ); + uniqueIds(pendingEventIds, 'continuation.pendingEventIds'); + return { + phase: oneOf( + record['phase'], + HARNESS_CHECKPOINT_PHASES, + 'continuation.phase', + ), + pendingEventIds, + }; +} + +function parseAttempt( + value: ManagedSessionJsonValue | undefined, +): HarnessAttemptGroup | null { + if (value === null) return null; + const record = object(value, 'attempt'); + assertNoUnknownKeys( + record, + [ + 'attemptId', + 'routeRef', + 'capabilityRef', + 'samplingRef', + 'outputState', + 'usageRef', + 'budgetConsumed', + ], + 'attempt', + ); + const outputState = oneOf( + record['outputState'], + HARNESS_ATTEMPT_OUTPUT_STATES, + 'attempt.outputState', + ); + const usageRef = refOrNull(record['usageRef'], 'attempt.usageRef'); + if (outputState === 'started' && usageRef !== null) { + fail('attempt.usageRef must be null while outputState is started.'); + } + return { + attemptId: assertManagedSessionStableId( + record['attemptId'], + 'attempt.attemptId', + ), + routeRef: assertManagedSessionDurableRef( + record['routeRef'], + 'attempt.routeRef', + ), + capabilityRef: refOrNull(record['capabilityRef'], 'attempt.capabilityRef'), + samplingRef: refOrNull(record['samplingRef'], 'attempt.samplingRef'), + outputState, + usageRef, + budgetConsumed: assertManagedSessionSequence( + record['budgetConsumed'], + 'attempt.budgetConsumed', + ), + }; +} + +function parseToolItem( + value: ManagedSessionJsonValue, + label: string, +): HarnessToolItem { + const record = object(value, label); + assertNoUnknownKeys( + record, + [ + 'functionCallId', + 'toolName', + 'executionCallId', + 'modelMessageId', + 'partIndex', + 'ordinal', + 'inputDigest', + 'outcomeSource', + 'state', + 'outcomeRef', + 'consumed', + ], + label, + ); + const state = oneOf( + record['state'], + HARNESS_TOOL_ITEM_STATES, + `${label}.state`, + ); + const outcomeRef = refOrNull(record['outcomeRef'], `${label}.outcomeRef`); + if (state === 'settled' && outcomeRef === null) { + fail(`${label}.outcomeRef is required once the item is settled.`); + } + if (state !== 'settled' && outcomeRef !== null) { + fail(`${label}.outcomeRef must be null until the item is settled.`); + } + if (typeof record['consumed'] !== 'boolean') { + fail(`${label}.consumed must be a boolean.`); + } + if (record['consumed'] && state !== 'settled') { + fail(`${label} cannot be consumed before it is settled.`); + } + return { + functionCallId: assertManagedSessionStableId( + record['functionCallId'], + `${label}.functionCallId`, + ), + toolName: assertManagedSessionStableId( + record['toolName'], + `${label}.toolName`, + ), + executionCallId: assertManagedSessionStableId( + record['executionCallId'], + `${label}.executionCallId`, + ), + modelMessageId: assertManagedSessionStableId( + record['modelMessageId'], + `${label}.modelMessageId`, + ), + partIndex: assertManagedSessionSequence( + record['partIndex'], + `${label}.partIndex`, + ), + ordinal: assertManagedSessionSequence( + record['ordinal'], + `${label}.ordinal`, + ), + inputDigest: assertManagedSessionDigest( + record['inputDigest'], + `${label}.inputDigest`, + ), + outcomeSource: oneOf( + record['outcomeSource'], + HARNESS_TOOL_OUTCOME_SOURCES, + `${label}.outcomeSource`, + ), + state, + outcomeRef, + consumed: record['consumed'], + }; +} + +function parseTools( + value: ManagedSessionJsonValue | undefined, +): HarnessToolsGroup | null { + if (value === null) return null; + const record = object(value, 'tools'); + assertNoUnknownKeys(record, ['batchId', 'items'], 'tools'); + const items = jsonArray(record['items'], 'tools.items').map((item, index) => + parseToolItem(item, `tools.items[${index}]`), + ); + uniqueIds( + items.map((item) => item.executionCallId), + 'tools.items.executionCallId', + ); + uniqueIds( + items.map((item) => item.functionCallId), + 'tools.items.functionCallId', + ); + const ordinals = new Set(); + for (const item of items) { + if (ordinals.has(item.ordinal)) { + fail(`tools.items repeats ordinal ${item.ordinal}.`); + } + ordinals.add(item.ordinal); + } + return { + batchId: idOrNull(record['batchId'], 'tools.batchId'), + items, + }; +} + +function parseRuntimeBinding( + value: ManagedSessionJsonValue, + label: string, +): HarnessRuntimeBinding { + const record = object(value, label); + assertNoUnknownKeys( + record, + [ + 'executionCallId', + 'invocationBindingId', + 'capabilityVersion', + 'policyVersion', + 'mediaVersion', + 'state', + 'progressCursor', + ], + label, + ); + return { + executionCallId: assertManagedSessionStableId( + record['executionCallId'], + `${label}.executionCallId`, + ), + invocationBindingId: assertManagedSessionStableId( + record['invocationBindingId'], + `${label}.invocationBindingId`, + ), + capabilityVersion: assertManagedSessionStableId( + record['capabilityVersion'], + `${label}.capabilityVersion`, + ), + policyVersion: assertManagedSessionStableId( + record['policyVersion'], + `${label}.policyVersion`, + ), + mediaVersion: idOrNull(record['mediaVersion'], `${label}.mediaVersion`), + state: oneOf(record['state'], HARNESS_RUNTIME_STATES, `${label}.state`), + progressCursor: idOrNull( + record['progressCursor'], + `${label}.progressCursor`, + ), + }; +} + +function parseRuntime( + value: ManagedSessionJsonValue | undefined, +): HarnessRuntimeGroup | null { + if (value === null) return null; + const record = object(value, 'runtime'); + assertNoUnknownKeys(record, ['bindings'], 'runtime'); + const bindings = jsonArray(record['bindings'], 'runtime.bindings').map( + (item, index) => parseRuntimeBinding(item, `runtime.bindings[${index}]`), + ); + uniqueIds( + bindings.map((item) => item.executionCallId), + 'runtime.bindings.executionCallId', + ); + uniqueIds( + bindings.map((item) => item.invocationBindingId), + 'runtime.bindings.invocationBindingId', + ); + return { bindings }; +} + +function parseApproval( + value: ManagedSessionJsonValue | undefined, +): HarnessApprovalGroup | null { + if (value === null) return null; + const record = object(value, 'approval'); + assertNoUnknownKeys( + record, + [ + 'requestId', + 'kind', + 'source', + 'optionsRef', + 'inputRevision', + 'confirmationVersion', + 'state', + 'decisionRef', + 'invocationRef', + ], + 'approval', + ); + const state = oneOf( + record['state'], + HARNESS_APPROVAL_STATES, + 'approval.state', + ); + const source = oneOf( + record['source'], + HARNESS_ACTION_SOURCES, + 'approval.source', + ); + const decisionRef = refOrNull(record['decisionRef'], 'approval.decisionRef'); + if (state === 'decided' && decisionRef === null) { + fail('approval.decisionRef is required once the approval is decided.'); + } + if (state !== 'decided' && decisionRef !== null) { + fail('approval.decisionRef must be null until the approval is decided.'); + } + const invocationRef = refOrNull( + record['invocationRef'], + 'approval.invocationRef', + ); + if (source === 'tool_call' && invocationRef === null) { + fail('approval.invocationRef is required for a tool_call approval.'); + } + if (source === 'user_operation' && invocationRef !== null) { + fail('approval.invocationRef must be null for a user_operation approval.'); + } + return { + requestId: assertManagedSessionStableId( + record['requestId'], + 'approval.requestId', + ), + kind: assertManagedSessionStableId(record['kind'], 'approval.kind'), + source, + optionsRef: assertManagedSessionDurableRef( + record['optionsRef'], + 'approval.optionsRef', + ), + inputRevision: assertManagedSessionStableId( + record['inputRevision'], + 'approval.inputRevision', + ), + confirmationVersion: idOrNull( + record['confirmationVersion'], + 'approval.confirmationVersion', + ), + state, + decisionRef, + invocationRef, + }; +} + +function parseParentHistory( + value: ManagedSessionJsonValue | undefined, +): HarnessOutputGroup['parentHistory'] { + if (value === null) return null; + const record = object(value, 'output.parentHistory'); + assertNoUnknownKeys( + record, + ['bindingId', 'revision'], + 'output.parentHistory', + ); + return { + bindingId: assertManagedSessionStableId( + record['bindingId'], + 'output.parentHistory.bindingId', + ), + revision: assertManagedSessionSequence( + record['revision'], + 'output.parentHistory.revision', + ), + }; +} + +function parseOutput( + value: ManagedSessionJsonValue | undefined, +): HarnessOutputGroup { + const record = object(value, 'output'); + assertNoUnknownKeys( + record, + [ + 'llmContentRef', + 'physicalStatus', + 'hookResultRef', + 'mediaRefs', + 'parentHistory', + ], + 'output', + ); + return { + llmContentRef: refOrNull(record['llmContentRef'], 'output.llmContentRef'), + physicalStatus: idOrNull(record['physicalStatus'], 'output.physicalStatus'), + hookResultRef: refOrNull(record['hookResultRef'], 'output.hookResultRef'), + mediaRefs: jsonArray(record['mediaRefs'], 'output.mediaRefs').map( + (item, index) => + assertManagedSessionDurableRef(item, `output.mediaRefs[${index}]`), + ), + parentHistory: parseParentHistory(record['parentHistory']), + }; +} + +function parseFollowUp( + value: ManagedSessionJsonValue | undefined, +): HarnessFollowUpGroup { + const record = object(value, 'followUp'); + assertNoUnknownKeys( + record, + [ + 'pendingInputIds', + 'cancelRequestIds', + 'goalPermitIds', + 'cronIds', + 'notificationIds', + 'childRunIds', + 'stopBudgetRemaining', + 'scopeLineage', + ], + 'followUp', + ); + const pendingInputIds = idList( + record['pendingInputIds'], + 'followUp.pendingInputIds', + ); + const cancelRequestIds = idList( + record['cancelRequestIds'], + 'followUp.cancelRequestIds', + ); + const goalPermitIds = idList( + record['goalPermitIds'], + 'followUp.goalPermitIds', + ); + const cronIds = idList(record['cronIds'], 'followUp.cronIds'); + const notificationIds = idList( + record['notificationIds'], + 'followUp.notificationIds', + ); + const childRunIds = idList(record['childRunIds'], 'followUp.childRunIds'); + uniqueIds(pendingInputIds, 'followUp.pendingInputIds'); + uniqueIds(cancelRequestIds, 'followUp.cancelRequestIds'); + uniqueIds(goalPermitIds, 'followUp.goalPermitIds'); + uniqueIds(cronIds, 'followUp.cronIds'); + uniqueIds(notificationIds, 'followUp.notificationIds'); + uniqueIds(childRunIds, 'followUp.childRunIds'); + return { + pendingInputIds, + cancelRequestIds, + goalPermitIds, + cronIds, + notificationIds, + childRunIds, + stopBudgetRemaining: sequenceOrNull( + record['stopBudgetRemaining'], + 'followUp.stopBudgetRemaining', + ), + scopeLineage: idList(record['scopeLineage'], 'followUp.scopeLineage'), + }; +} + +function toolItems( + tools: HarnessToolsGroup | null, +): readonly HarnessToolItem[] { + return tools?.items ?? []; +} + +function runtimeBindings( + runtime: HarnessRuntimeGroup | null, +): readonly HarnessRuntimeBinding[] { + return runtime?.bindings ?? []; +} + +function assertRuntimeMatchesTools( + tools: HarnessToolsGroup | null, + runtime: HarnessRuntimeGroup | null, +): void { + const calls = new Set(toolItems(tools).map((item) => item.executionCallId)); + for (const binding of runtimeBindings(runtime)) { + if (!calls.has(binding.executionCallId)) { + fail( + `runtime binding ${binding.executionCallId} has no matching tool item.`, + ); + } + } +} + +function assertPhaseShape(checkpoint: HarnessCheckpointV1): void { + const { phase } = checkpoint.continuation; + const items = toolItems(checkpoint.tools); + const bindings = runtimeBindings(checkpoint.runtime); + const inFlightTools = items.some((item) => item.state === 'in_progress'); + const unsettledTools = items.some((item) => item.state !== 'settled'); + const dispatchRuntime = bindings.some((item) => item.state === 'dispatch'); + + if (phase === 'before_model') { + if (checkpoint.attempt !== null) { + fail('before_model cannot carry a model attempt.'); + } + if (items.length > 0 || bindings.length > 0) { + fail('before_model cannot carry tools or runtime bindings.'); + } + if (checkpoint.approval !== null) { + fail('before_model cannot carry an approval.'); + } + return; + } + + if (checkpoint.attempt === null) { + fail(`${phase} requires a model attempt.`); + } + if (checkpoint.attempt.outputState === 'started') { + fail('an in-flight model stream is not a runnable checkpoint phase.'); + } + + if (phase === 'model_output_committed') { + if (checkpoint.attempt.outputState !== 'output_committed') { + fail('model_output_committed requires outputState output_committed.'); + } + if (checkpoint.approval !== null) { + fail('model_output_committed cannot carry an approval.'); + } + if (inFlightTools || dispatchRuntime) { + fail('model_output_committed cannot carry in-flight tools.'); + } + return; + } + + if (phase === 'await_action') { + if ( + checkpoint.approval === null || + checkpoint.approval.state !== 'requested' + ) { + fail('await_action requires a requested approval.'); + } + if ( + checkpoint.approval.source === 'user_operation' && + bindings.length > 0 + ) { + fail('a user_operation approval cannot carry runtime bindings.'); + } + return; + } + + if (phase === 'await_runtime') { + if (!inFlightTools || !dispatchRuntime) { + fail('await_runtime requires in-progress tools and dispatch bindings.'); + } + if (checkpoint.approval?.state === 'requested') { + fail('await_runtime cannot keep a requested approval.'); + } + return; + } + + if (phase === 'results_ready') { + if (items.length === 0 || unsettledTools) { + fail('results_ready requires every tool item to be settled.'); + } + if (dispatchRuntime || inFlightTools) { + fail('results_ready cannot carry in-flight tools.'); + } + if (checkpoint.approval?.state === 'requested') { + fail('results_ready cannot keep a requested approval.'); + } + return; + } + + if ( + inFlightTools || + dispatchRuntime || + checkpoint.approval?.state === 'requested' + ) { + fail('turn_settled cannot carry in-flight tools or a requested approval.'); + } +} + +function parseHarnessCheckpointObject( + value: ManagedSessionJsonValue, +): HarnessCheckpointV1 { + const record = object(value, 'harness checkpoint'); + assertNoUnknownKeys(record, ROOT_KEYS, 'harness checkpoint'); + const identity = parseIdentity(record['identity']); + const checkpoint: HarnessCheckpointV1 = { + identity, + resume: parseResume(record['resume'], identity.coveredSequence), + continuation: parseContinuation(record['continuation']), + attempt: parseAttempt(record['attempt']), + tools: parseTools(record['tools']), + runtime: parseRuntime(record['runtime']), + approval: parseApproval(record['approval']), + output: parseOutput(record['output']), + followUp: parseFollowUp(record['followUp']), + }; + assertRuntimeMatchesTools(checkpoint.tools, checkpoint.runtime); + assertPhaseShape(checkpoint); + return checkpoint; +} + +export function parseHarnessCheckpointV1(bytes: Buffer): HarnessCheckpointV1 { + return parseHarnessCheckpointObject( + parseManagedSessionRecordJson( + bytes.toString('utf8'), + MANAGED_SESSION_LIMITS.maxEventBytes, + ), + ); +} + +export function tryParseHarnessCheckpointV1( + bytes: Buffer, +): HarnessCheckpointParseResult { + let json: ManagedSessionJsonValue; + try { + json = parseManagedSessionRecordJson( + bytes.toString('utf8'), + MANAGED_SESSION_LIMITS.maxEventBytes, + ); + } catch (error) { + return { + ok: false, + reason: 'opaque', + message: + error instanceof Error ? error.message : 'unreadable checkpoint state', + }; + } + if (json === null || typeof json !== 'object' || Array.isArray(json)) { + return { + ok: false, + reason: 'opaque', + message: 'checkpoint state is not a JSON object.', + }; + } + const identity = json['identity']; + if ( + identity === null || + typeof identity !== 'object' || + Array.isArray(identity) || + identity['schemaVersion'] === undefined + ) { + return { + ok: false, + reason: 'opaque', + message: 'checkpoint state has no Harness schemaVersion.', + }; + } + try { + return { ok: true, checkpoint: parseHarnessCheckpointObject(json) }; + } catch (error) { + if (error instanceof ManagedSessionRecordError) { + return { ok: false, reason: 'invalid', message: error.message }; + } + throw error; + } +} + +export function encodeHarnessCheckpointV1( + checkpoint: HarnessCheckpointV1, +): Buffer { + return Buffer.from(JSON.stringify(checkpoint), 'utf8'); +} + +function formatHarnessIdentity(input: { + readonly sessionKey: ManagedSessionKey; + readonly checkpointId: string; + readonly coveredSequence: number; +}): string { + return `${input.checkpointId}@${input.coveredSequence} ${input.sessionKey.tenantId}/${input.sessionKey.workspaceId}/${input.sessionKey.sessionId}`; +} + +export function authorizeParsedHarnessCheckpoint( + checkpoint: HarnessCheckpointV1, + expected: { + readonly sessionKey: ManagedSessionKey; + readonly checkpointId: string; + readonly coveredSequence: number; + }, +): Extract { + if ( + !managedSessionKeysEqual( + checkpoint.identity.sessionKey, + expected.sessionKey, + ) || + checkpoint.identity.checkpointId !== expected.checkpointId || + checkpoint.identity.coveredSequence !== expected.coveredSequence || + checkpoint.identity.engine !== 'managed' + ) { + return { + status: 'blocked', + reason: 'identity_mismatch', + message: `checkpoint ${formatHarnessIdentity(checkpoint.identity)} does not match ${formatHarnessIdentity( + { + sessionKey: expected.sessionKey, + checkpointId: expected.checkpointId, + coveredSequence: expected.coveredSequence, + }, + )}`, + }; + } + return { status: 'runnable', checkpoint }; +} + +export function createInitialHarnessCheckpoint(input: { + readonly sessionKey: ManagedSessionKey; + readonly checkpointId: string; + readonly coveredSequence: number; + readonly activationId: string; + readonly turnId: string | null; + readonly promptId: string | null; + readonly definitionRevision: string; + readonly configRevision: string; + readonly inputDigest: string; + readonly previousCheckpointId: string | null; + readonly pendingEventIds?: readonly string[]; + readonly initialTurn?: number; +}): HarnessCheckpointV1 { + return { + identity: { + schemaVersion: HARNESS_CHECKPOINT_SCHEMA_VERSION, + sessionKey: input.sessionKey, + engine: 'managed', + checkpointId: input.checkpointId, + coveredSequence: input.coveredSequence, + activationId: input.activationId, + turnId: input.turnId, + promptId: input.promptId, + definitionRevision: input.definitionRevision, + configRevision: input.configRevision, + inputDigest: input.inputDigest, + previousCheckpointId: input.previousCheckpointId, + }, + resume: { + source: 'session_log', + throughSequence: input.coveredSequence, + recording: { + lastCompletedUuid: null, + turnParentUuids: [], + parentSessionId: null, + sourceType: null, + sourceId: null, + lastAssistantModel: null, + executionEngine: 'managed', + }, + initialTurn: input.initialTurn ?? 0, + consumedNotificationIds: [], + apiHistoryRef: null, + fileHistoryRef: null, + artifactRef: null, + goalRecordsRef: null, + goalCheckpointWindowRef: null, + tokenCountsRef: null, + uiTelemetryRef: null, + attributionRef: null, + goalRecoverySourceUuid: null, + }, + continuation: { + phase: 'before_model', + pendingEventIds: [...(input.pendingEventIds ?? [])], + }, + attempt: null, + tools: null, + runtime: null, + approval: null, + output: { + llmContentRef: null, + physicalStatus: null, + hookResultRef: null, + mediaRefs: [], + parentHistory: null, + }, + followUp: { + pendingInputIds: [], + cancelRequestIds: [], + goalPermitIds: [], + cronIds: [], + notificationIds: [], + childRunIds: [], + stopBudgetRemaining: null, + scopeLineage: [], + }, + }; +} + +/** + * Next-turn-ready v1 after a finished turn with no pending work. Phase is + * `before_model` so the following model request may start; attempt/tools + * from the completed turn stay in the log rather than in this checkpoint. + */ +export function createNextTurnReadyHarnessCheckpoint(input: { + readonly previous: HarnessCheckpointV1; + readonly checkpointId: string; + readonly coveredSequence: number; + readonly previousCheckpointId: string | null; + readonly activationId: string; + readonly turnId: string | null; + readonly promptId: string | null; +}): HarnessCheckpointV1 { + return { + identity: { + ...input.previous.identity, + checkpointId: input.checkpointId, + coveredSequence: input.coveredSequence, + activationId: input.activationId, + turnId: input.turnId, + promptId: input.promptId, + previousCheckpointId: input.previousCheckpointId, + }, + resume: { + ...input.previous.resume, + throughSequence: input.coveredSequence, + }, + continuation: { + phase: 'before_model', + pendingEventIds: [], + }, + attempt: null, + tools: null, + runtime: null, + approval: null, + output: { + llmContentRef: null, + physicalStatus: null, + hookResultRef: null, + mediaRefs: [], + parentHistory: input.previous.output.parentHistory, + }, + followUp: input.previous.followUp, + }; +} + +/** + * Approval wait (safety point B). Requires a committed model attempt and a + * requested approval; does not start or settle tools. + */ +export function createAwaitActionHarnessCheckpoint(input: { + readonly previous: HarnessCheckpointV1; + readonly checkpointId: string; + readonly coveredSequence: number; + readonly previousCheckpointId: string | null; + readonly attempt: HarnessAttemptGroup; + readonly approval: HarnessApprovalGroup; +}): HarnessCheckpointV1 { + return { + identity: { + ...input.previous.identity, + checkpointId: input.checkpointId, + coveredSequence: input.coveredSequence, + previousCheckpointId: input.previousCheckpointId, + }, + resume: { + ...input.previous.resume, + throughSequence: input.coveredSequence, + }, + continuation: { + phase: 'await_action', + pendingEventIds: [], + }, + attempt: input.attempt, + tools: input.previous.tools, + runtime: input.previous.runtime, + approval: input.approval, + output: input.previous.output, + followUp: input.previous.followUp, + }; +} + +/** + * Safety point C: an admitted Runtime tool is in progress. Coordinator owns + * dispatch; the next model request stays blocked until results_ready. + */ +export function createAwaitRuntimeHarnessCheckpoint(input: { + readonly previous: HarnessCheckpointV1; + readonly checkpointId: string; + readonly coveredSequence: number; + readonly previousCheckpointId: string | null; + readonly attempt: HarnessAttemptGroup; + readonly tools: HarnessToolsGroup; + readonly runtime: HarnessRuntimeGroup; +}): HarnessCheckpointV1 { + if (input.previous.approval?.state === 'requested') { + throw new ManagedSessionRecordError( + 'await_runtime cannot keep a requested approval.', + ); + } + return { + identity: { + ...input.previous.identity, + checkpointId: input.checkpointId, + coveredSequence: input.coveredSequence, + previousCheckpointId: input.previousCheckpointId, + }, + resume: { + ...input.previous.resume, + throughSequence: input.coveredSequence, + }, + continuation: { + phase: 'await_runtime', + pendingEventIds: [], + }, + attempt: input.attempt, + tools: input.tools, + runtime: input.runtime, + approval: null, + output: input.previous.output, + followUp: input.previous.followUp, + }; +} + +/** + * Records one admitted Runtime result. The turn stays in `await_runtime` + * until every execution settles, then advances to `results_ready`. + */ +export function createResultsReadyHarnessCheckpoint(input: { + readonly previous: HarnessCheckpointV1; + readonly checkpointId: string; + readonly coveredSequence: number; + readonly previousCheckpointId: string | null; + readonly executionCallId: string; + readonly outcomeRef: ManagedSessionDurableRef; +}): HarnessCheckpointV1 { + if (input.previous.continuation.phase !== 'await_runtime') { + throw new ManagedSessionRecordError( + 'results_ready resume requires an await_runtime checkpoint.', + ); + } + if (input.previous.attempt === null || input.previous.tools === null) { + throw new ManagedSessionRecordError( + 'results_ready requires the waited attempt and tools.', + ); + } + const settledItem = input.previous.tools.items.find( + (item) => item.executionCallId === input.executionCallId, + ); + if (settledItem?.state !== 'in_progress') { + throw new ManagedSessionRecordError( + `await_runtime has no in-progress execution ${input.executionCallId}.`, + ); + } + const items = input.previous.tools.items.map((item) => + item.executionCallId === input.executionCallId + ? { + ...item, + state: 'settled' as const, + outcomeRef: input.outcomeRef, + consumed: false, + } + : item, + ); + const allSettled = items.every((item) => item.state === 'settled'); + return { + identity: { + ...input.previous.identity, + checkpointId: input.checkpointId, + coveredSequence: input.coveredSequence, + previousCheckpointId: input.previousCheckpointId, + }, + resume: { + ...input.previous.resume, + throughSequence: input.coveredSequence, + }, + continuation: { + phase: allSettled ? 'results_ready' : 'await_runtime', + pendingEventIds: [], + }, + attempt: input.previous.attempt, + tools: { + batchId: input.previous.tools.batchId, + items, + }, + runtime: { + bindings: runtimeBindings(input.previous.runtime).map((binding) => + binding.executionCallId === input.executionCallId && + binding.state === 'dispatch' + ? { ...binding, state: 'settled' } + : binding, + ), + }, + approval: null, + output: input.previous.output, + followUp: input.previous.followUp, + }; +} + +/** + * After a consumed Runtime continuation finishes without another tool + * call, close the recovery window. Until this checkpoint exists, a + * successor may re-read the original receipts and must not dispatch again. + */ +export function createTurnSettledHarnessCheckpoint(input: { + readonly previous: HarnessCheckpointV1; + readonly checkpointId: string; + readonly coveredSequence: number; + readonly previousCheckpointId: string | null; +}): HarnessCheckpointV1 { + if (input.previous.continuation.phase !== 'results_ready') { + throw new ManagedSessionRecordError( + 'turn_settled resume requires a results_ready checkpoint.', + ); + } + const items = input.previous.tools?.items ?? []; + if ( + items.length === 0 || + items.some((item) => item.state !== 'settled' || item.consumed !== true) + ) { + throw new ManagedSessionRecordError( + 'turn_settled requires every Runtime receipt to be consumed.', + ); + } + return { + identity: { + ...input.previous.identity, + checkpointId: input.checkpointId, + coveredSequence: input.coveredSequence, + previousCheckpointId: input.previousCheckpointId, + }, + resume: { + ...input.previous.resume, + throughSequence: input.coveredSequence, + }, + continuation: { + phase: 'turn_settled', + pendingEventIds: [], + }, + attempt: input.previous.attempt, + tools: input.previous.tools, + runtime: input.previous.runtime, + approval: null, + output: input.previous.output, + followUp: input.previous.followUp, + }; +} + +/** + * After the original Runtime receipts are present on the next model + * request, mark those settled items consumed. Phase stays + * `results_ready` so the same turn may still start the model. + */ +export function createConsumedRuntimeResultsHarnessCheckpoint(input: { + readonly previous: HarnessCheckpointV1; + readonly checkpointId: string; + readonly coveredSequence: number; + readonly previousCheckpointId: string | null; +}): HarnessCheckpointV1 { + if (input.previous.continuation.phase !== 'results_ready') { + throw new ManagedSessionRecordError( + 'consumed Runtime results require a results_ready checkpoint.', + ); + } + if (input.previous.tools === null) { + throw new ManagedSessionRecordError( + 'consumed Runtime results require the settled tools.', + ); + } + return { + identity: { + ...input.previous.identity, + checkpointId: input.checkpointId, + coveredSequence: input.coveredSequence, + previousCheckpointId: input.previousCheckpointId, + }, + resume: { + ...input.previous.resume, + throughSequence: input.coveredSequence, + }, + continuation: { + phase: 'results_ready', + pendingEventIds: [], + }, + attempt: input.previous.attempt, + tools: { + batchId: input.previous.tools.batchId, + items: input.previous.tools.items.map((item) => + item.state === 'settled' && item.consumed === false + ? { ...item, consumed: true } + : item, + ), + }, + runtime: input.previous.runtime, + approval: null, + output: input.previous.output, + followUp: input.previous.followUp, + }; +} + +/** + * After a durable wait is resolved, the turn continues without a new model + * start reservation. Phase is `model_output_committed` so the next model + * request is allowed; the requested approval is cleared. + */ +export function createModelOutputCommittedHarnessCheckpoint(input: { + readonly previous: HarnessCheckpointV1; + readonly checkpointId: string; + readonly coveredSequence: number; + readonly previousCheckpointId: string | null; +}): HarnessCheckpointV1 { + if (input.previous.attempt === null) { + throw new ManagedSessionRecordError( + 'model_output_committed requires the waited attempt.', + ); + } + return { + identity: { + ...input.previous.identity, + checkpointId: input.checkpointId, + coveredSequence: input.coveredSequence, + previousCheckpointId: input.previousCheckpointId, + }, + resume: { + ...input.previous.resume, + throughSequence: input.coveredSequence, + }, + continuation: { + phase: 'model_output_committed', + pendingEventIds: [], + }, + attempt: input.previous.attempt, + tools: input.previous.tools, + runtime: input.previous.runtime, + approval: null, + output: input.previous.output, + followUp: input.previous.followUp, + }; +} diff --git a/packages/core/src/managed-runtime/managed-harness-factory.test.ts b/packages/core/src/managed-runtime/managed-harness-factory.test.ts new file mode 100644 index 00000000000..ce8f665de19 --- /dev/null +++ b/packages/core/src/managed-runtime/managed-harness-factory.test.ts @@ -0,0 +1,1021 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { Storage } from '../config/storage.js'; +import { + createManagedHarnessHandle, + parseManagedRuntimeOutcomePart, + ManagedHarnessBlockedError, + type ManagedAwaitRuntimeCommit, + type ManagedDurableWaitCommit, +} from './managed-harness-factory.js'; +import { + createNextTurnReadyHarnessCheckpoint, + encodeHarnessCheckpointV1, + HARNESS_DURABLE_WAIT_BOUNDARY, + HARNESS_TURN_COMPLETE_BOUNDARY, + parseHarnessCheckpointV1, +} from './managed-harness-checkpoint.js'; +import { + openManagedSession, + type ManagedSession, +} from './managed-session-assembly.js'; +import { + ManagedSessionConflictError, + type ManagedSessionCommand, +} from './managed-session-authority.js'; +import type { ManagedSessionDurableRef } from './managed-session-records.js'; +import { + managedRuntimeDispatchGate, + resetManagedRuntimeDispatchGatesForTest, +} from './managed-runtime-dispatch-gate.js'; + +const DIGEST = '9'.repeat(64); +const sessionId = '550e8400-e29b-41d4-a716-4466554400bb'; +const sessionKey = { tenantId: 't1', workspaceId: 'w1', sessionId }; +const temporaryDirectories = new Set(); + +afterEach(async () => { + resetManagedRuntimeDispatchGatesForTest(); + for (const directory of temporaryDirectories) { + await fs.rm(directory, { recursive: true, force: true }); + } + temporaryDirectories.clear(); +}); + +function ref(kind = 'managed-test'): ManagedSessionDurableRef { + return { + resourceId: 'res-1', + kind, + schemaVersion: 1, + byteLength: 4, + digest: DIGEST, + }; +} + +interface Workspace { + runtimeBaseDir: string; + projectRoot: string; + transcriptPath: string; +} + +async function createWorkspace(): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-managed-hf-')); + temporaryDirectories.add(root); + const projectRoot = path.join(root, 'project'); + const runtimeBaseDir = path.join(root, 'runtime'); + await fs.mkdir(projectRoot, { recursive: true }); + await fs.mkdir(runtimeBaseDir, { recursive: true }); + const transcriptPath = path.join( + new Storage(projectRoot, runtimeBaseDir).getProjectDir(), + 'chats', + `${sessionId}.jsonl`, + ); + await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); + return { runtimeBaseDir, projectRoot, transcriptPath }; +} + +function open(workspace: Workspace): Promise { + return openManagedSession({ + runtimeBaseDir: workspace.runtimeBaseDir, + sessionId, + transcriptPath: workspace.transcriptPath, + sessionKey, + cwd: workspace.projectRoot, + version: 'test', + workerId: 'worker-1', + activationLeaseDurationMs: 60_000, + create: { + definitionRef: ref('managed-definition'), + rootSnapshotRef: ref('managed-root'), + createdBy: 'daemon', + }, + }); +} + +function command(operation: string, commandId: string): ManagedSessionCommand { + return { + operation, + commandId, + sessionKey, + contentDigest: DIGEST, + }; +} + +async function settleTurnComplete( + session: ManagedSession, + turnId = 'turn-1', +): Promise { + const resultRef = await session.resources.publish( + 'managed-turn-result', + Buffer.from('{"state":"completed"}', 'utf8'), + ); + await session.authority.commitTurnComplete( + command('settleTurn', `settle-${turnId}`), + { + turn: { + turnId, + outcome: 'completed', + stopReason: 'end_turn', + resultRef, + occurredAt: 1, + eventId: `turn:${turnId}`, + }, + boundary: HARNESS_TURN_COMPLETE_BOUNDARY, + state: (identity, previous) => + encodeHarnessCheckpointV1( + createNextTurnReadyHarnessCheckpoint({ + previous, + ...identity, + activationId: session.activation.activationId, + turnId, + promptId: turnId, + }), + ), + }, + { class: 'harness', activation: session.activation }, + ); +} + +describe('managed harness factory', () => { + it('does not treat opening or accepted input as a runnable start', async () => { + const session = await open(await createWorkspace()); + expect(session.authority.restoreBasis()).toBe('initial'); + await session.authority.submitInput(command('submitInput', 'in-1'), { + inputId: 'in-1', + turnId: 'turn-1', + source: 'web_shell', + contentRef: ref(), + deadline: null, + admissionRef: ref(), + wakeReason: 'input', + }); + expect(session.authority.restoreBasis()).toBe('initial'); + await expect(session.authority.restoreBundle()).resolves.toMatchObject({ + restoreBasis: 'initial', + checkpointRef: null, + restoreProofRef: null, + recoveryStatus: 'ok', + }); + expect(session.authority.latestCheckpoint).toBeUndefined(); + await session.close(); + }); + + it('submits a before_model checkpoint before the Agent runs', async () => { + const session = await open(await createWorkspace()); + const handle = createManagedHarnessHandle(session); + const order: string[] = []; + const result = await handle.run(async () => { + order.push('agent'); + const authorization = await session.authority.harnessRunAuthorization(); + expect(authorization.status).toBe('runnable'); + if (authorization.status === 'runnable') { + expect(authorization.checkpoint.continuation.phase).toBe( + 'before_model', + ); + } + expect(session.authority.latestCheckpoint?.boundary).toBeNull(); + expect( + parseHarnessCheckpointV1( + (await session.authority.readCheckpointState())!, + ).continuation.phase, + ).toBe('before_model'); + return 'read-final'; + }); + expect(result).toBe('read-final'); + expect(order).toEqual(['agent']); + await expect(session.authority.restoreBundle()).resolves.toMatchObject({ + restoreBasis: 'checkpoint', + restoreProofRef: null, + recoveryStatus: 'ok', + }); + await expect(handle.run(async () => 'again')).rejects.toThrow( + ManagedSessionConflictError, + ); + await session.close(); + }); + + it('is idempotent: a second ensure does not write another checkpoint', async () => { + const session = await open(await createWorkspace()); + const handle = createManagedHarnessHandle(session); + const first = await handle.ensureRunnable(); + const second = await handle.ensureRunnable(); + expect(second.identity.checkpointId).toBe(first.identity.checkpointId); + expect(session.authority.latestCheckpoint?.checkpointId).toBe( + first.identity.checkpointId, + ); + await session.close(); + }); + + it('blocks an opaque checkpoint instead of running the Agent', async () => { + const session = await open(await createWorkspace()); + await session.authority.commitCheckpoint( + command('commitCheckpoint', 'opaque'), + { state: Buffer.from('first', 'utf8'), boundary: null }, + { class: 'harness', activation: session.activation }, + ); + const handle = createManagedHarnessHandle(session); + let ran = false; + await expect( + handle.run(async () => { + ran = true; + return 'no'; + }), + ).rejects.toBeInstanceOf(ManagedHarnessBlockedError); + await expect(handle.ensureRunnable()).rejects.toMatchObject({ + reason: 'opaque_state', + }); + expect(ran).toBe(false); + expect(session.authority.restoreBasis()).toBe('checkpoint'); + await expect(session.authority.restoreBundle()).resolves.toMatchObject({ + restoreBasis: 'checkpoint', + restoreProofRef: null, + recoveryStatus: 'blocked', + }); + expect( + (await session.authority.restoreBundle()).checkpointRef, + ).not.toBeNull(); + await session.close(); + }); + + it('blocks continuation without a checkpoint instead of running the Agent', async () => { + const session = await open(await createWorkspace()); + await session.authority.appendExecution( + command('appendExecution', 'model-1'), + [ + { + v: 1, + sequence: session.authority.committedSequence + 1, + eventId: 'evt-model-1', + sessionKey, + kind: 'model.attempt', + occurredAt: 1, + subject: { + type: 'activation', + scopeId: session.activation.activationId, + activationId: session.activation.activationId, + epoch: session.activation.epoch, + }, + payload: { + attemptId: 'att-1', + routeRef: ref(), + inputCheckpointRef: null, + state: 'started', + usageRef: null, + }, + }, + ], + { class: 'harness', activation: session.activation }, + ); + const handle = createManagedHarnessHandle(session); + let ran = false; + await expect( + handle.run(async () => { + ran = true; + return 'no'; + }), + ).rejects.toMatchObject({ reason: 'missing_checkpoint' }); + expect(ran).toBe(false); + expect(session.authority.restoreBasis()).toBe('blocked'); + await expect(session.authority.restoreBundle()).resolves.toMatchObject({ + restoreBasis: null, + checkpointRef: null, + restoreProofRef: null, + recoveryStatus: 'blocked', + }); + await session.close(); + }); + + it('lets only a matching live activation prepare the Harness', async () => { + const session = await open(await createWorkspace()); + const handle = createManagedHarnessHandle({ + authority: session.authority, + activation: { activationId: 'act-other', epoch: 1 }, + }); + await expect(handle.ensureRunnable()).rejects.toThrow( + /not the committed activation/, + ); + await session.close(); + }); + + it('does not treat opening as a turn-complete boundary', async () => { + const session = await open(await createWorkspace()); + const handle = createManagedHarnessHandle(session); + await handle.ensureRunnable(); + await expect(handle.requestBoundary()).rejects.toThrow( + /not at a turn-complete or durable-wait safety point/, + ); + await expect(handle.detach()).rejects.toThrow( + /cannot detach before a turn-complete or durable-wait checkpoint/, + ); + await session.close(); + }); + + it('replaces a drained handle after turn-complete without rewriting the checkpoint', async () => { + const session = await open(await createWorkspace()); + const handle = createManagedHarnessHandle(session); + await handle.ensureRunnable(); + const firstId = session.authority.latestCheckpoint?.checkpointId; + await settleTurnComplete(session); + const completeId = session.authority.latestCheckpoint?.checkpointId; + expect(completeId).not.toBe(firstId); + + const boundary = await handle.requestBoundary(); + expect(boundary).toMatchObject({ + kind: 'turn_complete', + checkpointId: completeId, + activationId: handle.activation.activationId, + epoch: handle.activation.epoch, + }); + await handle.detach(); + await expect(handle.ensureRunnable()).rejects.toThrow(/detached/); + + const previousActivation = session.activation; + const nextActivation = await session.replaceActivation(); + expect(nextActivation.activationId).not.toBe( + previousActivation.activationId, + ); + const stale = createManagedHarnessHandle({ + authority: session.authority, + activation: previousActivation, + }); + await expect(stale.ensureRunnable()).rejects.toThrow( + /not the committed activation/, + ); + + const next = createManagedHarnessHandle(session); + const checkpoint = await next.ensureRunnable(); + expect(checkpoint.identity.checkpointId).toBe(completeId); + expect(session.authority.latestCheckpoint?.checkpointId).toBe(completeId); + expect(session.authority.latestCheckpoint?.boundary).toBe( + HARNESS_TURN_COMPLETE_BOUNDARY, + ); + await session.close(); + }); + + it('requires a new boundary after starting the next Agent', async () => { + const session = await open(await createWorkspace()); + const previous = createManagedHarnessHandle(session); + await previous.ensureRunnable(); + await settleTurnComplete(session); + await previous.detach(); + const handle = createManagedHarnessHandle(session); + const runtime = await runtimeCommit(session); + let allowWait!: () => void; + const enterWait = new Promise((resolve) => { + allowWait = resolve; + }); + let reachedWait!: () => void; + const waiting = new Promise((resolve) => { + reachedWait = resolve; + }); + let finish!: () => void; + const finished = new Promise((resolve) => { + finish = resolve; + }); + const running = handle.run(async () => { + await enterWait; + await handle.commitAwaitRuntime(runtime); + reachedWait(); + await finished; + }); + try { + await expect(handle.detach()).rejects.toThrow( + /cannot detach before a turn-complete or durable-wait checkpoint/, + ); + await expect(handle.requestBoundary()).rejects.toThrow( + /not at a turn-complete or durable-wait safety point/, + ); + expect(handle.isDetached()).toBe(false); + + allowWait(); + await waiting; + await expect(handle.requestBoundary()).resolves.toMatchObject({ + kind: 'durable_wait', + }); + await handle.detach(); + expect(managedRuntimeDispatchGate(sessionKey).isHandedOff('ex-1')).toBe( + true, + ); + } finally { + allowWait(); + finish(); + await running; + await session.close(); + } + }); + + it('releases the checkpoint queue when the Agent throws synchronously', async () => { + const session = await open(await createWorkspace()); + const handle = createManagedHarnessHandle(session); + await expect( + handle.run(() => { + throw new Error('Agent failed to start'); + }), + ).rejects.toThrow('Agent failed to start'); + await expect(handle.ensureRunnable()).resolves.toMatchObject({ + continuation: { phase: 'before_model' }, + }); + await expect(handle.run(async () => undefined)).rejects.toThrow( + /runs the Agent at most once/, + ); + await session.close(); + }); + + it('commits an approval wait before the next model start is allowed', async () => { + const workspace = await createWorkspace(); + const session = await open(workspace); + const handle = createManagedHarnessHandle(session); + await handle.ensureRunnable(); + const refs = await waitRefs(session); + const boundary = await handle.commitDurableWait(waitCommit(refs)); + expect(boundary.kind).toBe('durable_wait'); + expect(session.authority.latestCheckpoint?.boundary).toBe( + HARNESS_DURABLE_WAIT_BOUNDARY, + ); + expect( + parseHarnessCheckpointV1((await session.authority.readCheckpointState())!) + .continuation.phase, + ).toBe('await_action'); + await expect(handle.ensureRunnable()).rejects.toMatchObject({ + reason: 'invalid_state', + }); + await expect(handle.requestBoundary()).resolves.toMatchObject({ + kind: 'durable_wait', + checkpointId: boundary.checkpointId, + }); + await expect(handle.resolveDurableWait()).rejects.toThrow( + /before a final action decision/, + ); + expect(session.authority.action('fc-1')?.state).toBe('requested'); + await handle.detach(); + expect(handle.isDetached()).toBe(true); + await expect(handle.resolveDurableWait()).rejects.toThrow(/detached/); + + await decideAction(session); + const next = createManagedHarnessHandle(session); + const resumed = await next.resolveDurableWait(); + expect(resumed?.continuation.phase).toBe('model_output_committed'); + expect(session.authority.latestCheckpoint?.boundary).toBeNull(); + const runnable = await next.ensureRunnable(); + expect(runnable.identity.checkpointId).toBe(resumed?.identity.checkpointId); + expect(runnable.continuation.phase).toBe('model_output_committed'); + await session.close(); + + const reopened = await openManagedSession({ + runtimeBaseDir: workspace.runtimeBaseDir, + sessionId, + transcriptPath: workspace.transcriptPath, + sessionKey, + cwd: workspace.projectRoot, + version: 'test', + workerId: 'worker-1', + activationLeaseDurationMs: 60_000, + }); + expect(reopened.authority.action('fc-1')).toMatchObject({ + state: 'decided', + requestId: 'fc-1', + }); + expect(reopened.authority.action('fc-1')?.decisionRef).not.toBeNull(); + await reopened.close(); + }); + + it('authorizes the original wait after a crash between the decision and resolve', async () => { + const workspace = await createWorkspace(); + const session = await open(workspace); + const handle = createManagedHarnessHandle(session); + await handle.ensureRunnable(); + await handle.commitDurableWait(waitCommit(await waitRefs(session))); + await decideAction(session); + expect(session.authority.latestCheckpoint?.boundary).toBe( + HARNESS_DURABLE_WAIT_BOUNDARY, + ); + await session.close(); + + const reopened = await openManagedSession({ + runtimeBaseDir: workspace.runtimeBaseDir, + sessionId, + transcriptPath: workspace.transcriptPath, + sessionKey, + cwd: workspace.projectRoot, + version: 'test', + workerId: 'worker-1', + activationLeaseDurationMs: 60_000, + }); + expect(reopened.authority.action('fc-1')?.state).toBe('decided'); + expect(reopened.authority.latestCheckpoint?.boundary).toBe( + HARNESS_DURABLE_WAIT_BOUNDARY, + ); + const next = createManagedHarnessHandle(reopened); + const resumed = await next.resolveDurableWait(); + expect(resumed?.continuation.phase).toBe('model_output_committed'); + const runnable = await next.ensureRunnable(); + expect(runnable.continuation.phase).toBe('model_output_committed'); + await reopened.close(); + }); + + it('keeps a requested approval after the waiter handle is gone', async () => { + const workspace = await createWorkspace(); + const session = await open(workspace); + const handle = createManagedHarnessHandle(session); + await handle.ensureRunnable(); + await handle.commitDurableWait(waitCommit(await waitRefs(session))); + await handle.detach(); + expect(session.authority.action('fc-1')?.state).toBe('requested'); + await session.close(); + + const reopened = await openManagedSession({ + runtimeBaseDir: workspace.runtimeBaseDir, + sessionId, + transcriptPath: workspace.transcriptPath, + sessionKey, + cwd: workspace.projectRoot, + version: 'test', + workerId: 'worker-1', + activationLeaseDurationMs: 60_000, + }); + expect(reopened.authority.action('fc-1')?.state).toBe('requested'); + expect(reopened.authority.latestCheckpoint?.boundary).toBe( + HARNESS_DURABLE_WAIT_BOUNDARY, + ); + const next = createManagedHarnessHandle(reopened); + await expect(next.ensureRunnable()).rejects.toMatchObject({ + reason: 'invalid_state', + }); + await expect(next.resolveDurableWait()).rejects.toThrow( + /before a final action decision/, + ); + await decideAction(reopened); + const resumed = await next.resolveDurableWait(); + expect(resumed?.continuation.phase).toBe('model_output_committed'); + await reopened.close(); + }); + + it('is idempotent for the same approval wait and rejects a second request', async () => { + const session = await open(await createWorkspace()); + const handle = createManagedHarnessHandle(session); + await handle.ensureRunnable(); + const refs = await waitRefs(session); + const first = await handle.commitDurableWait(waitCommit(refs)); + const second = await handle.commitDurableWait(waitCommit(refs)); + expect(second.checkpointId).toBe(first.checkpointId); + await expect( + handle.commitDurableWait({ + ...waitCommit(refs), + requestId: 'fc-2', + attemptId: 'att-fc-2', + }), + ).rejects.toThrow(/already waiting on a different approval/); + await expect(handle.resolveDurableWait()).rejects.toThrow( + /before a final action decision/, + ); + await decideAction(session); + expect(session.authority.action('fc-1')?.state).toBe('decided'); + expect(await handle.resolveDurableWait()).not.toBeNull(); + expect(await handle.resolveDurableWait()).toBeNull(); + await expect( + session.authority.resolveAction( + { + operation: 'resolveAction', + commandId: 'resolveAction:fc-1:other', + sessionKey, + contentDigest: DIGEST, + }, + { + requestId: 'fc-1', + state: 'cancelled', + decisionRef: null, + }, + ), + ).rejects.toThrow(/already decided/); + await session.close(); + }); + + it('commits admitted Runtime work as await_runtime and settles to results_ready', async () => { + const workspace = await createWorkspace(); + const session = await open(workspace); + const handle = createManagedHarnessHandle(session); + await handle.ensureRunnable(); + const commit = await runtimeCommit(session); + const boundary = await handle.commitAwaitRuntime(commit); + expect(boundary.kind).toBe('durable_wait'); + expect(session.authority.latestCheckpoint?.boundary).toBe( + HARNESS_DURABLE_WAIT_BOUNDARY, + ); + expect( + parseHarnessCheckpointV1((await session.authority.readCheckpointState())!) + .continuation.phase, + ).toBe('await_runtime'); + await expect(handle.ensureRunnable()).rejects.toMatchObject({ + reason: 'invalid_state', + }); + await expect(handle.requestBoundary()).resolves.toMatchObject({ + kind: 'durable_wait', + checkpointId: boundary.checkpointId, + }); + expect(managedRuntimeDispatchGate(sessionKey).state('ex-1')).toBe( + 'dispatch', + ); + + const same = await handle.commitAwaitRuntime(commit); + expect(same.checkpointId).toBe(boundary.checkpointId); + + await handle.detach(); + expect(managedRuntimeDispatchGate(sessionKey).isHandedOff('ex-1')).toBe( + true, + ); + await expect( + handle.resolveAwaitRuntime(commit.executionCallId, commit.routeRef), + ).rejects.toThrow(/detached/); + + const next = createManagedHarnessHandle(session); + const again = await next.commitAwaitRuntime(commit); + expect(again.checkpointId).toBe(boundary.checkpointId); + expect(() => managedRuntimeDispatchGate(sessionKey).claim('ex-1')).toThrow( + /already dispatched/, + ); + + const outcomeRef = await session.resources.publish( + 'managed-tool-outcome', + Buffer.from('{"outcome":"completed"}', 'utf8'), + ); + const ready = await next.resolveAwaitRuntime('ex-1', outcomeRef); + expect(ready?.continuation.phase).toBe('results_ready'); + expect(session.authority.latestCheckpoint?.boundary).toBeNull(); + const runnable = await next.ensureRunnable(); + expect(runnable.continuation.phase).toBe('results_ready'); + expect(managedRuntimeDispatchGate(sessionKey).state('ex-1')).toBe( + 'settled', + ); + await expect(next.commitAwaitRuntime(commit)).rejects.toThrow( + /already dispatched/, + ); + await session.close(); + }); + + it('commits a Runtime batch before reverse-order settlement without losing an outcome', async () => { + const session = await open(await createWorkspace()); + const handle = createManagedHarnessHandle(session); + await handle.ensureRunnable(); + const first = await runtimeCommit(session); + const second = { + ...first, + functionCallId: 'fc-2', + executionCallId: 'ex-2', + invocationBindingId: 'bind-2', + modelMessageId: 'msg-2', + ordinal: 1, + inputDigest: '8'.repeat(64), + }; + await handle.commitAwaitRuntimeBatch([first, second]); + const wait = parseHarnessCheckpointV1( + (await session.authority.readCheckpointState())!, + ); + expect(wait.tools?.items.map((item) => item.executionCallId)).toEqual([ + 'ex-1', + 'ex-2', + ]); + expect(wait.runtime?.bindings.map((item) => item.executionCallId)).toEqual([ + 'ex-1', + 'ex-2', + ]); + expect(session.authority.latestCheckpoint?.boundary).toBe( + HARNESS_DURABLE_WAIT_BOUNDARY, + ); + const firstRef = await session.resources.publish( + 'managed-tool-outcome', + Buffer.from('{"executionCallId":"ex-1"}', 'utf8'), + ); + const secondRef = await session.resources.publish( + 'managed-tool-outcome', + Buffer.from('{"executionCallId":"ex-2"}', 'utf8'), + ); + + const [secondResult, firstResult] = await Promise.all([ + handle.resolveAwaitRuntime('ex-2', secondRef), + handle.resolveAwaitRuntime('ex-1', firstRef), + ]); + + expect(secondResult?.continuation.phase).toBe('await_runtime'); + expect(firstResult?.continuation.phase).toBe('results_ready'); + const ready = parseHarnessCheckpointV1( + (await session.authority.readCheckpointState())!, + ); + expect(ready.tools?.items.map((item) => item.outcomeRef)).toEqual([ + firstRef, + secondRef, + ]); + expect(session.authority.latestCheckpoint?.boundary).toBeNull(); + expect(managedRuntimeDispatchGate(sessionKey).state('ex-1')).toBe( + 'settled', + ); + expect(managedRuntimeDispatchGate(sessionKey).state('ex-2')).toBe( + 'settled', + ); + await session.close(); + }); + + it('marks settled Runtime receipts consumed without a second dispatch', async () => { + const workspace = await createWorkspace(); + const session = await open(workspace); + const handle = createManagedHarnessHandle(session); + await handle.ensureRunnable(); + const commit = await runtimeCommit(session); + await handle.commitAwaitRuntime(commit); + const outcomeRef = await session.resources.publish( + 'managed-tool-outcome', + Buffer.from( + JSON.stringify({ + functionCallId: 'fc-1', + executionCallId: 'ex-1', + outcome: 'completed', + functionResponse: { + id: 'fc-1', + name: 'remote_tool', + response: { output: 'original runtime receipt' }, + }, + }), + 'utf8', + ), + ); + const ready = await handle.resolveAwaitRuntime('ex-1', outcomeRef); + expect(ready?.tools?.items[0]?.consumed).toBe(false); + const consumed = await handle.consumeRuntimeResults(); + expect(consumed?.continuation.phase).toBe('results_ready'); + expect(consumed?.tools?.items[0]?.consumed).toBe(true); + const again = await handle.consumeRuntimeResults(); + expect(again?.identity.checkpointId).toBe(consumed?.identity.checkpointId); + expect(() => managedRuntimeDispatchGate(sessionKey).claim('ex-1')).toThrow( + /already dispatched/, + ); + const settled = await handle.settleConsumedRuntimeContinuation(); + expect(settled?.continuation.phase).toBe('turn_settled'); + expect(session.authority.latestCheckpoint?.boundary).toBeNull(); + expect(await handle.settleConsumedRuntimeContinuation()).toBeNull(); + expect( + parseManagedRuntimeOutcomePart('fc-1', { + functionResponse: { + id: 'other', + name: 'remote_tool', + response: { output: 'original runtime receipt' }, + }, + }), + ).toEqual({ + functionResponse: { + id: 'fc-1', + name: 'remote_tool', + response: { output: 'original runtime receipt' }, + }, + }); + await session.close(); + }); + + it('keeps Runtime ordinals unique across sequential calls in one turn', async () => { + const session = await open(await createWorkspace()); + const handle = createManagedHarnessHandle(session); + await handle.ensureRunnable(); + const first = await runtimeCommit(session); + await handle.commitAwaitRuntime(first); + const firstOutcome = await session.resources.publish( + 'managed-tool-outcome', + Buffer.from('{"outcome":"completed"}', 'utf8'), + ); + await handle.resolveAwaitRuntime('ex-1', firstOutcome); + await handle.consumeRuntimeResults(); + + await handle.commitAwaitRuntime({ + ...first, + functionCallId: 'fc-2', + toolName: 'write_file', + executionCallId: 'ex-2', + invocationBindingId: 'bind-2', + modelMessageId: 'msg-2', + inputDigest: '8'.repeat(64), + }); + + const checkpoint = parseHarnessCheckpointV1( + (await session.authority.readCheckpointState())!, + ); + expect(checkpoint.tools?.items.map((item) => item.ordinal)).toEqual([0, 1]); + await session.close(); + }); + + it('keeps await_runtime across a cold reopen until results are settled', async () => { + const workspace = await createWorkspace(); + const session = await open(workspace); + const handle = createManagedHarnessHandle(session); + await handle.ensureRunnable(); + await handle.commitAwaitRuntime(await runtimeCommit(session)); + await session.close(); + resetManagedRuntimeDispatchGatesForTest(); + + const reopened = await openManagedSession({ + runtimeBaseDir: workspace.runtimeBaseDir, + sessionId, + transcriptPath: workspace.transcriptPath, + sessionKey, + cwd: workspace.projectRoot, + version: 'test', + workerId: 'worker-1', + activationLeaseDurationMs: 60_000, + }); + expect(reopened.authority.latestCheckpoint?.boundary).toBe( + HARNESS_DURABLE_WAIT_BOUNDARY, + ); + expect( + parseHarnessCheckpointV1( + (await reopened.authority.readCheckpointState())!, + ).continuation.phase, + ).toBe('await_runtime'); + const next = createManagedHarnessHandle(reopened); + await expect(next.ensureRunnable()).rejects.toMatchObject({ + reason: 'invalid_state', + }); + expect( + managedRuntimeDispatchGate(sessionKey).state('ex-1'), + ).toBeUndefined(); + const outcomeRef = await reopened.resources.publish( + 'managed-tool-outcome', + Buffer.from('{"outcome":"completed"}', 'utf8'), + ); + const ready = await next.resolveAwaitRuntime('ex-1', outcomeRef); + expect(ready?.continuation.phase).toBe('results_ready'); + const runnable = await next.ensureRunnable(); + expect(runnable.continuation.phase).toBe('results_ready'); + await reopened.close(); + }); + + it.each([false, true])( + 'serializes approval and Runtime admission (Runtime first: %s)', + async (runtimeFirst) => { + const session = await open(await createWorkspace()); + const handle = createManagedHarnessHandle(session); + await handle.ensureRunnable(); + const approval = waitCommit(await waitRefs(session)); + const runtime = await runtimeCommit(session); + const operations = runtimeFirst + ? [ + () => handle.commitAwaitRuntime(runtime), + () => handle.commitDurableWait(approval), + ] + : [ + () => handle.commitDurableWait(approval), + () => handle.commitAwaitRuntime(runtime), + ]; + + const [first, second] = await Promise.allSettled( + operations.map((operation) => operation()), + ); + + expect(first.status).toBe('fulfilled'); + expect(second.status).toBe('rejected'); + if (second.status === 'rejected') { + expect(second.reason).toBeInstanceOf(ManagedSessionConflictError); + } + const checkpoint = parseHarnessCheckpointV1( + (await session.authority.readCheckpointState())!, + ); + expect(checkpoint.continuation.phase).toBe( + runtimeFirst ? 'await_runtime' : 'await_action', + ); + if (runtimeFirst) { + expect(checkpoint.tools?.items).toHaveLength(1); + expect(checkpoint.tools?.items[0]?.executionCallId).toBe('ex-1'); + expect(session.authority.action(approval.requestId)).toBeUndefined(); + const outcome = await session.resources.publish( + 'managed-tool-outcome', + Buffer.from('{}', 'utf8'), + ); + await expect( + handle.resolveAwaitRuntime(runtime.executionCallId, outcome), + ).resolves.toMatchObject({ continuation: { phase: 'results_ready' } }); + } else { + expect(checkpoint.approval?.requestId).toBe(approval.requestId); + expect( + managedRuntimeDispatchGate(sessionKey).state('ex-1'), + ).toBeUndefined(); + await decideAction(session); + await expect(handle.resolveDurableWait()).resolves.toMatchObject({ + continuation: { phase: 'model_output_committed' }, + }); + } + await session.close(); + }, + ); + + it('finishes an admitted checkpoint before handing off the handle', async () => { + const session = await open(await createWorkspace()); + const handle = createManagedHarnessHandle(session); + await handle.ensureRunnable(); + const runtime = await runtimeCommit(session); + + await Promise.all([handle.commitAwaitRuntime(runtime), handle.detach()]); + + expect(handle.isDetached()).toBe(true); + expect(managedRuntimeDispatchGate(sessionKey).isHandedOff('ex-1')).toBe( + true, + ); + const checkpoint = parseHarnessCheckpointV1( + (await session.authority.readCheckpointState())!, + ); + expect(checkpoint.continuation.phase).toBe('await_runtime'); + await session.close(); + }); + + it('rejects Runtime dispatch while an approval wait is still open', async () => { + const session = await open(await createWorkspace()); + const handle = createManagedHarnessHandle(session); + await handle.ensureRunnable(); + await handle.commitDurableWait(waitCommit(await waitRefs(session))); + await expect( + handle.commitAwaitRuntime(await runtimeCommit(session)), + ).rejects.toThrow(/approval wait must resolve before Runtime dispatch/); + await session.close(); + }); +}); + +async function waitRefs(session: ManagedSession): Promise<{ + optionsRef: ManagedSessionDurableRef; + invocationRef: ManagedSessionDurableRef; + routeRef: ManagedSessionDurableRef; +}> { + return { + optionsRef: await session.resources.publish( + 'managed-approval', + Buffer.from('[]', 'utf8'), + ), + invocationRef: await session.resources.publish( + 'managed-invocation', + Buffer.from('{"toolCallId":"fc-1"}', 'utf8'), + ), + routeRef: await session.resources.publish( + 'managed-route', + Buffer.from('{"model":"qwen3-coder-plus"}', 'utf8'), + ), + }; +} + +function waitCommit( + refs: Awaited>, +): ManagedDurableWaitCommit { + return { + requestId: 'fc-1', + kind: 'execute', + source: 'tool_call', + optionsRef: refs.optionsRef, + inputRevision: 'rev-1', + invocationRef: refs.invocationRef, + attemptId: 'att-fc-1', + routeRef: refs.routeRef, + }; +} + +async function decideAction( + session: ManagedSession, + requestId = 'fc-1', +): Promise { + const decisionRef = await session.resources.publish( + 'managed-decision', + Buffer.from('{"optionId":"allow"}', 'utf8'), + ); + await session.authority.resolveAction( + { + operation: 'resolveAction', + commandId: `resolveAction:${requestId}`, + sessionKey, + contentDigest: decisionRef.digest, + }, + { requestId, state: 'decided', decisionRef }, + ); +} + +async function runtimeCommit( + session: ManagedSession, +): Promise { + return { + functionCallId: 'fc-1', + toolName: 'read_file', + executionCallId: 'ex-1', + invocationBindingId: 'bind-1', + capabilityVersion: 'cap-1', + policyVersion: 'pol-1', + mediaVersion: null, + modelMessageId: 'msg-1', + partIndex: 0, + ordinal: 0, + inputDigest: DIGEST, + progressCursor: null, + attemptId: 'att-fc-1', + routeRef: await session.resources.publish( + 'managed-route', + Buffer.from('{"model":"qwen3-coder-plus"}', 'utf8'), + ), + }; +} diff --git a/packages/core/src/managed-runtime/managed-harness-factory.ts b/packages/core/src/managed-runtime/managed-harness-factory.ts new file mode 100644 index 00000000000..0fd1c669d98 --- /dev/null +++ b/packages/core/src/managed-runtime/managed-harness-factory.ts @@ -0,0 +1,906 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash } from 'node:crypto'; +import { + createAwaitActionHarnessCheckpoint, + createAwaitRuntimeHarnessCheckpoint, + createConsumedRuntimeResultsHarnessCheckpoint, + createInitialHarnessCheckpoint, + createModelOutputCommittedHarnessCheckpoint, + createResultsReadyHarnessCheckpoint, + createTurnSettledHarnessCheckpoint, + encodeHarnessCheckpointV1, + HARNESS_DURABLE_WAIT_BOUNDARY, + HARNESS_MODEL_START_PHASES, + HARNESS_TURN_COMPLETE_BOUNDARY, + type HarnessActionSource, + type HarnessCheckpointV1, + type HarnessRunAuthorization, +} from './managed-harness-checkpoint.js'; +import { managedRuntimeDispatchGate } from './managed-runtime-dispatch-gate.js'; +import { + assertManagedSessionRestoreBundle, + ManagedSessionConflictError, + type LocalManagedSessionAuthority, +} from './managed-session-authority.js'; +import type { ManagedSession } from './managed-session-assembly.js'; +import type { ManagedSessionDurableRef } from './managed-session-records.js'; + +export class ManagedHarnessBlockedError extends Error { + readonly code = 'managed_harness_blocked'; + readonly reason: Exclude< + HarnessRunAuthorization, + { status: 'initial' | 'runnable' } + >['reason']; + + constructor( + authorization: Extract, + ) { + super( + authorization.message ?? + `Harness recovery is blocked (${authorization.reason}).`, + ); + this.name = 'ManagedHarnessBlockedError'; + this.reason = authorization.reason; + } +} + +export interface HarnessTurnCompleteBoundary { + readonly kind: 'turn_complete'; + readonly checkpointId: string; + readonly coveredSequence: number; + readonly activationId: string; + readonly epoch: number; +} + +export interface HarnessDurableWaitBoundary { + readonly kind: 'durable_wait'; + readonly checkpointId: string; + readonly coveredSequence: number; + readonly activationId: string; + readonly epoch: number; +} + +export type HarnessSafetyBoundary = + | HarnessTurnCompleteBoundary + | HarnessDurableWaitBoundary; + +export interface ManagedDurableWaitCommit { + readonly requestId: string; + readonly kind: string; + readonly source: HarnessActionSource; + readonly optionsRef: ManagedSessionDurableRef; + readonly inputRevision: string; + readonly invocationRef: ManagedSessionDurableRef | null; + readonly attemptId: string; + readonly routeRef: ManagedSessionDurableRef; +} + +export interface ManagedDurableWaitRequest { + readonly requestId: string; + readonly kind: string; + readonly source: HarnessActionSource; + readonly options: unknown; + readonly invocation?: unknown; +} + +export interface ManagedDurableWaitDecision { + readonly requestId: string; + readonly outcome: 'decided' | 'cancelled' | 'expired'; + readonly body?: unknown; +} + +export interface ManagedAwaitRuntimeCommit { + readonly functionCallId: string; + readonly toolName: string; + readonly executionCallId: string; + readonly invocationBindingId: string; + readonly capabilityVersion: string; + readonly policyVersion: string; + readonly mediaVersion: string | null; + readonly modelMessageId: string; + readonly partIndex: number; + readonly ordinal: number; + readonly inputDigest: string; + readonly progressCursor: string | null; + readonly attemptId: string; + readonly routeRef: ManagedSessionDurableRef; +} + +export interface ManagedAwaitRuntimeRequest { + readonly functionCallId: string; + readonly toolName: string; + readonly executionCallId: string; + readonly invocationBindingId?: string; + readonly modelMessageId?: string; + readonly ordinal?: number; +} + +export interface ManagedRuntimeFunctionResponse { + readonly id: string; + readonly name: string; + readonly response?: Record; +} + +export interface ManagedRuntimeOutcome { + readonly functionCallId: string; + readonly executionCallId: string; + readonly part: { + readonly functionResponse: ManagedRuntimeFunctionResponse; + }; +} + +export interface ManagedRuntimeOutcomeRead { + readonly outcomes: readonly ManagedRuntimeOutcome[]; + readonly preserveCallIds: readonly string[]; +} + +export interface ManagedHarnessHandle { + /** The activation this handle is allowed to present. */ + readonly activation: { + readonly activationId: string; + readonly epoch: number; + }; + /** + * Commits a `before_model` checkpoint when the session is a legal initial + * start, then returns the parsed v1 state a model request may run from. + * Does not send a user prompt. + */ + ensureRunnable(): Promise; + /** + * Ensures a runnable checkpoint, then runs the supplied Agent exactly once. + * The Agent is the existing QwenAgent/ACP Session/LlmChat path, not a + * separate runner. + */ + run(agent: () => Promise): Promise; + /** + * Observes an already-committed turn-complete or durable-wait checkpoint. + * Does not wait for an in-flight turn, and does not invent a boundary. + */ + requestBoundary(): Promise; + /** + * Drains this handle after a turn-complete or durable-wait checkpoint. Does + * not release the Session writer, Runtime, or activation; a later handle + * continues. At `await_runtime` this hands the original invocation to the + * coordinator gate instead of cancelling it. + */ + detach(): Promise; + /** True after {@link detach}; a successor handle continues the wait. */ + isDetached(): boolean; + /** + * Commits safety point B: a requested approval as `await_action` with + * `durable_wait`. The next model request stays blocked until + * `resolveDurableWait`. + */ + commitDurableWait( + request: ManagedDurableWaitCommit, + ): Promise; + /** + * Clears a durable approval wait so the turn may continue from + * `model_output_committed`. No-op when the handle is not waiting. + */ + resolveDurableWait(): Promise; + /** + * Commits safety point C: an admitted Runtime tool as `await_runtime` with + * `durable_wait`. The original executionCallId is dispatched at most once. + */ + commitAwaitRuntime( + request: ManagedAwaitRuntimeCommit, + ): Promise; + /** + * Atomically commits a batch of admitted Runtime tools before any member + * starts executing. + */ + commitAwaitRuntimeBatch( + requests: readonly ManagedAwaitRuntimeCommit[], + ): Promise; + /** + * Settles admitted Runtime work so the turn may continue from + * `results_ready`. No-op when the handle is not waiting on Runtime. + */ + resolveAwaitRuntime( + executionCallId: string, + outcomeRef: ManagedSessionDurableRef, + ): Promise; + /** + * Marks unconsumed settled Runtime receipts as consumed after they are + * present on the next model request. No-op when the handle is not at + * `results_ready`. + */ + consumeRuntimeResults(): Promise; + /** + * Closes a consumed `results_ready` continuation after the model request + * finishes. No-op until every settled receipt is consumed. + */ + settleConsumedRuntimeContinuation(): Promise; +} + +/** + * Builds a logical Harness handle for one activation. The caller must already + * hold the session writer and a matching live activation; this does not + * acquire either, and it does not start the model. + */ +export function createManagedHarnessHandle( + session: Pick, +): ManagedHarnessHandle { + return new LocalManagedHarnessHandle(session.authority, session.activation); +} + +class LocalManagedHarnessHandle implements ManagedHarnessHandle { + private ran = false; + private runStartCheckpointId: string | undefined; + private detached = false; + private checkpointMutation = Promise.resolve(); + + constructor( + private readonly authority: LocalManagedSessionAuthority, + readonly activation: { + readonly activationId: string; + readonly epoch: number; + }, + ) {} + + ensureRunnable(): Promise { + return this.mutateCheckpoint(() => this.ensureRunnableUnlocked()); + } + + private async ensureRunnableUnlocked(): Promise { + this.assertNotDetached(); + this.assertCurrentActivation(); + assertManagedSessionRestoreBundle(await this.authority.restoreBundle()); + let authorization = await this.authority.harnessRunAuthorization(); + if (authorization.status === 'initial') { + await this.commitInitialBeforeModel(); + authorization = await this.authority.harnessRunAuthorization(); + } + return this.requireModelStart(authorization); + } + + async run(agent: () => Promise): Promise { + const { running } = await this.mutateCheckpoint(async () => { + const checkpoint = await this.ensureRunnableUnlocked(); + if (this.ran) { + throw new ManagedSessionConflictError( + 'a harness handle runs the Agent at most once.', + ); + } + this.ran = true; + this.runStartCheckpointId = checkpoint.identity.checkpointId; + // The Agent may commit checkpoints itself, so release the queue before + // awaiting its completion. + return { running: agent() }; + }); + return running; + } + + async requestBoundary(): Promise { + return this.mutateCheckpoint(async () => { + this.assertNotDetached(); + this.assertCurrentActivation(); + const latest = this.authority.latestCheckpoint; + if ( + latest === undefined || + latest.checkpointId === this.runStartCheckpointId || + !isHarnessSafetyBoundary(latest.boundary) + ) { + throw new ManagedSessionConflictError( + 'harness is not at a turn-complete or durable-wait safety point.', + ); + } + const authorization = await this.requireRunnableAuthorization(); + const phase = authorization.checkpoint.continuation.phase; + if ( + latest.boundary === HARNESS_DURABLE_WAIT_BOUNDARY && + phase !== 'await_action' && + phase !== 'await_runtime' + ) { + throw new ManagedSessionConflictError( + 'durable-wait checkpoint is not an await_action or await_runtime phase.', + ); + } + return { + kind: + latest.boundary === HARNESS_DURABLE_WAIT_BOUNDARY + ? 'durable_wait' + : 'turn_complete', + checkpointId: latest.checkpointId, + coveredSequence: latest.coveredSequence, + activationId: this.activation.activationId, + epoch: this.activation.epoch, + }; + }); + } + + isDetached(): boolean { + return this.detached; + } + + async detach(): Promise { + return this.mutateCheckpoint(async () => { + if (this.detached) return; + this.assertCurrentActivation(); + const latest = this.authority.latestCheckpoint; + if ( + latest === undefined || + latest.checkpointId === this.runStartCheckpointId || + !isHarnessSafetyBoundary(latest.boundary) + ) { + throw new ManagedSessionConflictError( + 'harness cannot detach before a turn-complete or durable-wait checkpoint.', + ); + } + const authorization = await this.authority.harnessRunAuthorization(); + if ( + authorization.status === 'runnable' && + authorization.checkpoint.continuation.phase === 'await_runtime' + ) { + const gate = managedRuntimeDispatchGate( + this.authority.sessionHeader.sessionKey, + ); + for (const binding of authorization.checkpoint.runtime?.bindings ?? + []) { + gate.handoff(binding.executionCallId); + } + } + this.detached = true; + }); + } + + async commitDurableWait( + request: ManagedDurableWaitCommit, + ): Promise { + return this.mutateCheckpoint(async () => { + this.assertNotDetached(); + this.assertCurrentActivation(); + const latest = this.authority.latestCheckpoint; + if (latest?.boundary === HARNESS_DURABLE_WAIT_BOUNDARY) { + const authorization = await this.requireRunnableAuthorization(); + const approval = authorization.checkpoint.approval; + if ( + authorization.checkpoint.continuation.phase === 'await_action' && + approval?.requestId === request.requestId + ) { + return { + kind: 'durable_wait', + checkpointId: latest.checkpointId, + coveredSequence: latest.coveredSequence, + activationId: this.activation.activationId, + epoch: this.activation.epoch, + }; + } + throw new ManagedSessionConflictError( + 'harness is already waiting on a different approval.', + ); + } + + const previous = await this.ensureRunnableUnlocked(); + if (request.source === 'tool_call') { + await this.authority.requestToolAction( + { + operation: 'requestToolAction', + commandId: `requestToolAction:${request.requestId}`, + sessionKey: this.authority.sessionHeader.sessionKey, + contentDigest: createHash('sha256') + .update(request.optionsRef.digest) + .digest('hex'), + }, + { + requestId: request.requestId, + kind: request.kind, + inputRevision: 1, + optionsRef: request.optionsRef, + }, + { class: 'harness', activation: this.activation }, + ); + } + const identity = this.nextCheckpointIdentity(); + const checkpoint = createAwaitActionHarnessCheckpoint({ + previous, + ...identity, + attempt: previous.attempt ?? { + attemptId: request.attemptId, + routeRef: request.routeRef, + capabilityRef: null, + samplingRef: null, + outputState: 'output_committed', + usageRef: null, + budgetConsumed: 0, + }, + approval: { + requestId: request.requestId, + kind: request.kind, + source: request.source, + optionsRef: request.optionsRef, + inputRevision: request.inputRevision, + confirmationVersion: null, + state: 'requested', + decisionRef: null, + invocationRef: request.invocationRef, + }, + }); + await this.commitHarnessCheckpoint( + `harness:await_action:${this.activation.activationId}:${request.requestId}:${identity.coveredSequence}`, + checkpoint, + HARNESS_DURABLE_WAIT_BOUNDARY, + ); + const committed = this.authority.latestCheckpoint; + if (committed === undefined) { + throw new ManagedSessionConflictError( + 'durable wait was committed but no checkpoint was recorded.', + ); + } + return { + kind: 'durable_wait', + checkpointId: committed.checkpointId, + coveredSequence: committed.coveredSequence, + activationId: this.activation.activationId, + epoch: this.activation.epoch, + }; + }); + } + + async resolveDurableWait(): Promise { + return this.mutateCheckpoint(async () => { + this.assertNotDetached(); + this.assertCurrentActivation(); + const latest = this.authority.latestCheckpoint; + if (latest?.boundary !== HARNESS_DURABLE_WAIT_BOUNDARY) { + return null; + } + const previous = (await this.requireRunnableAuthorization()).checkpoint; + if (previous.continuation.phase !== 'await_action') { + throw new ManagedSessionConflictError( + 'durable-wait checkpoint is not an await_action phase.', + ); + } + const requestId = previous.approval?.requestId; + const action = + requestId === undefined ? undefined : this.authority.action(requestId); + if (action === undefined || action.state === 'requested') { + throw new ManagedSessionConflictError( + 'durable wait cannot resolve before a final action decision.', + ); + } + const identity = this.nextCheckpointIdentity(); + const checkpoint = createModelOutputCommittedHarnessCheckpoint({ + previous, + ...identity, + }); + await this.commitHarnessCheckpoint( + `harness:model_output_committed:${this.activation.activationId}:${identity.coveredSequence}`, + checkpoint, + null, + ); + return checkpoint; + }); + } + + async commitAwaitRuntime( + request: ManagedAwaitRuntimeCommit, + ): Promise { + return this.commitAwaitRuntimeBatch([request]); + } + + async commitAwaitRuntimeBatch( + requests: readonly ManagedAwaitRuntimeCommit[], + ): Promise { + if (requests.length === 0) { + throw new ManagedSessionConflictError( + 'Runtime wait batch must contain at least one invocation.', + ); + } + return this.mutateCheckpoint(async () => { + this.assertNotDetached(); + this.assertCurrentActivation(); + const latest = this.authority.latestCheckpoint; + const previous = + latest?.boundary === HARNESS_DURABLE_WAIT_BOUNDARY + ? (await this.requireRunnableAuthorization()).checkpoint + : await this.ensureRunnableUnlocked(); + if ( + latest?.boundary === HARNESS_DURABLE_WAIT_BOUNDARY && + previous.continuation.phase !== 'await_runtime' + ) { + throw new ManagedSessionConflictError( + 'approval wait must resolve before Runtime dispatch.', + ); + } + + const priorItems = previous.tools?.items ?? []; + const pending = requests.filter( + (request) => + !priorItems.some( + (item) => item.executionCallId === request.executionCallId, + ), + ); + const gate = managedRuntimeDispatchGate( + this.authority.sessionHeader.sessionKey, + ); + if (pending.length === 0) { + if (latest?.boundary === HARNESS_DURABLE_WAIT_BOUNDARY) { + return this.runtimeBoundary(); + } + gate.claim(requests[0].executionCallId); + throw new ManagedSessionConflictError( + 'Runtime execution was already recorded without an active wait.', + ); + } + + const claimed: string[] = []; + try { + for (const request of pending) { + gate.claim(request.executionCallId); + claimed.push(request.executionCallId); + } + const identity = this.nextCheckpointIdentity(); + let nextOrdinal = priorItems.reduce( + (next, item) => Math.max(next, item.ordinal + 1), + 0, + ); + const pendingItems = pending.map((request) => { + const ordinal = Math.max(request.ordinal, nextOrdinal); + nextOrdinal = ordinal + 1; + return { + functionCallId: request.functionCallId, + toolName: request.toolName, + executionCallId: request.executionCallId, + modelMessageId: request.modelMessageId, + partIndex: request.partIndex, + ordinal, + inputDigest: request.inputDigest, + outcomeSource: 'runtime' as const, + state: 'in_progress' as const, + outcomeRef: null, + consumed: false, + }; + }); + const checkpoint = createAwaitRuntimeHarnessCheckpoint({ + previous, + ...identity, + attempt: previous.attempt ?? { + attemptId: pending[0].attemptId, + routeRef: pending[0].routeRef, + capabilityRef: null, + samplingRef: null, + outputState: 'output_committed', + usageRef: null, + budgetConsumed: 0, + }, + tools: { + batchId: + previous.tools?.batchId ?? `batch-${pending[0].functionCallId}`, + items: [...priorItems, ...pendingItems], + }, + runtime: { + bindings: [ + ...(previous.runtime?.bindings ?? []), + ...pending.map((request) => ({ + executionCallId: request.executionCallId, + invocationBindingId: request.invocationBindingId, + capabilityVersion: request.capabilityVersion, + policyVersion: request.policyVersion, + mediaVersion: request.mediaVersion, + state: 'dispatch' as const, + progressCursor: request.progressCursor, + })), + ], + }, + }); + await this.commitHarnessCheckpoint( + `harness:await_runtime:${this.activation.activationId}:${pending.map((request) => request.executionCallId).join(',')}:${identity.coveredSequence}`, + checkpoint, + HARNESS_DURABLE_WAIT_BOUNDARY, + ); + return this.runtimeBoundary(); + } catch (error) { + for (const executionCallId of claimed) gate.unclaim(executionCallId); + throw error; + } + }); + } + + async resolveAwaitRuntime( + executionCallId: string, + outcomeRef: ManagedSessionDurableRef, + ): Promise { + return this.mutateCheckpoint(async () => { + this.assertNotDetached(); + this.assertCurrentActivation(); + const latest = this.authority.latestCheckpoint; + if (latest?.boundary !== HARNESS_DURABLE_WAIT_BOUNDARY) { + return null; + } + const previous = (await this.requireRunnableAuthorization()).checkpoint; + if (previous.continuation.phase !== 'await_runtime') { + throw new ManagedSessionConflictError( + 'durable-wait checkpoint is not an await_runtime phase.', + ); + } + const item = previous.tools?.items.find( + (candidate) => candidate.executionCallId === executionCallId, + ); + if (item?.state === 'settled') return previous; + const identity = this.nextCheckpointIdentity(); + const checkpoint = createResultsReadyHarnessCheckpoint({ + previous, + ...identity, + executionCallId, + outcomeRef, + }); + const resultsReady = checkpoint.continuation.phase === 'results_ready'; + await this.commitHarnessCheckpoint( + `harness:${checkpoint.continuation.phase}:${this.activation.activationId}:${executionCallId}:${identity.coveredSequence}`, + checkpoint, + resultsReady ? null : HARNESS_DURABLE_WAIT_BOUNDARY, + ); + managedRuntimeDispatchGate( + this.authority.sessionHeader.sessionKey, + ).settle(executionCallId); + return checkpoint; + }); + } + + async consumeRuntimeResults(): Promise { + return this.mutateCheckpoint(async () => { + this.assertNotDetached(); + this.assertCurrentActivation(); + const previous = (await this.requireRunnableAuthorization()).checkpoint; + if (previous.continuation.phase !== 'results_ready') { + return null; + } + const items = previous.tools?.items ?? []; + if ( + items.length === 0 || + items.every((item) => item.state !== 'settled' || item.consumed) + ) { + return previous; + } + const identity = this.nextCheckpointIdentity(); + const checkpoint = createConsumedRuntimeResultsHarnessCheckpoint({ + previous, + ...identity, + }); + await this.commitHarnessCheckpoint( + `harness:results_consumed:${this.activation.activationId}:${identity.coveredSequence}`, + checkpoint, + null, + ); + return checkpoint; + }); + } + + async settleConsumedRuntimeContinuation(): Promise { + return this.mutateCheckpoint(async () => { + this.assertNotDetached(); + this.assertCurrentActivation(); + const previous = (await this.requireRunnableAuthorization()).checkpoint; + const items = previous.tools?.items ?? []; + if ( + previous.continuation.phase !== 'results_ready' || + items.length === 0 || + items.some((item) => item.state !== 'settled' || !item.consumed) + ) { + return null; + } + const identity = this.nextCheckpointIdentity(); + const checkpoint = createTurnSettledHarnessCheckpoint({ + previous, + ...identity, + }); + await this.commitHarnessCheckpoint( + `harness:turn_settled:${this.activation.activationId}:${identity.coveredSequence}`, + checkpoint, + null, + ); + return checkpoint; + }); + } + + private runtimeBoundary(): HarnessDurableWaitBoundary { + const committed = this.authority.latestCheckpoint; + if ( + committed === undefined || + committed.boundary !== HARNESS_DURABLE_WAIT_BOUNDARY + ) { + throw new ManagedSessionConflictError( + 'Runtime wait was committed but no checkpoint was recorded.', + ); + } + return { + kind: 'durable_wait', + checkpointId: committed.checkpointId, + coveredSequence: committed.coveredSequence, + activationId: this.activation.activationId, + epoch: this.activation.epoch, + }; + } + + private async mutateCheckpoint(mutation: () => Promise): Promise { + const previous = this.checkpointMutation; + let release!: () => void; + this.checkpointMutation = new Promise((resolve) => { + release = resolve; + }); + await previous; + try { + return await mutation(); + } finally { + release(); + } + } + + private assertNotDetached(): void { + if (this.detached) { + throw new ManagedSessionConflictError( + 'harness handle has been detached.', + ); + } + } + + private assertCurrentActivation(): void { + const current = this.authority.currentActivation; + if ( + current === undefined || + current.activationId !== this.activation.activationId || + current.epoch !== this.activation.epoch || + current.phase === 'released' || + current.phase === 'revoked' + ) { + throw new ManagedSessionConflictError( + 'harness handle is not the committed activation.', + ); + } + } + + private async requireRunnableAuthorization(): Promise< + Extract + > { + const authorization = await this.authority.harnessRunAuthorization(); + if (authorization.status === 'blocked') { + throw new ManagedHarnessBlockedError(authorization); + } + if (authorization.status !== 'runnable') { + throw new ManagedHarnessBlockedError({ + status: 'blocked', + reason: 'missing_checkpoint', + }); + } + return authorization; + } + + private requireModelStart( + authorization: HarnessRunAuthorization, + ): HarnessCheckpointV1 { + if (authorization.status === 'blocked') { + throw new ManagedHarnessBlockedError(authorization); + } + if (authorization.status !== 'runnable') { + throw new ManagedHarnessBlockedError({ + status: 'blocked', + reason: 'missing_checkpoint', + }); + } + const phase = authorization.checkpoint.continuation.phase; + if (!HARNESS_MODEL_START_PHASES.has(phase)) { + throw new ManagedHarnessBlockedError({ + status: 'blocked', + reason: 'invalid_state', + message: `${phase} is not a model-start phase for this Harness.`, + }); + } + return authorization.checkpoint; + } + + private nextCheckpointIdentity(): { + checkpointId: string; + coveredSequence: number; + previousCheckpointId: string | null; + } { + const coveredSequence = this.authority.committedSequence; + return { + checkpointId: `ckpt-${coveredSequence + 1}`, + coveredSequence, + previousCheckpointId: + this.authority.latestCheckpoint?.checkpointId ?? null, + }; + } + + private async commitInitialBeforeModel(): Promise { + const header = this.authority.sessionHeader; + const identity = this.nextCheckpointIdentity(); + const checkpoint = createInitialHarnessCheckpoint({ + sessionKey: header.sessionKey, + ...identity, + activationId: this.activation.activationId, + turnId: null, + promptId: null, + definitionRevision: header.definitionRef.resourceId, + configRevision: header.rootSnapshotRef.resourceId, + inputDigest: header.definitionRef.digest, + }); + await this.commitHarnessCheckpoint( + `harness:before_model:${this.activation.activationId}:${identity.coveredSequence}`, + checkpoint, + null, + ); + } + + private async commitHarnessCheckpoint( + commandId: string, + checkpoint: HarnessCheckpointV1, + boundary: string | null, + ): Promise { + const header = this.authority.sessionHeader; + const state = encodeHarnessCheckpointV1(checkpoint); + await this.authority.commitCheckpoint( + { + operation: 'commitCheckpoint', + commandId, + sessionKey: header.sessionKey, + contentDigest: createHash('sha256').update(state).digest('hex'), + }, + { state, boundary }, + { class: 'harness', activation: this.activation }, + ); + } +} + +function isHarnessSafetyBoundary( + boundary: string | null | undefined, +): boundary is + | typeof HARNESS_TURN_COMPLETE_BOUNDARY + | typeof HARNESS_DURABLE_WAIT_BOUNDARY { + return ( + boundary === HARNESS_TURN_COMPLETE_BOUNDARY || + boundary === HARNESS_DURABLE_WAIT_BOUNDARY + ); +} + +function isObjectRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function asFunctionResponse( + value: unknown, +): ManagedRuntimeFunctionResponse | null { + if (!isObjectRecord(value)) return null; + const id = value['id']; + const name = value['name']; + if (typeof id !== 'string' || typeof name !== 'string') { + return null; + } + const response = value['response']; + return { + id, + name, + ...(isObjectRecord(response) ? { response } : {}), + }; +} + +/** + * Reads the model-facing functionResponse from a published Runtime + * outcome. Missing or malformed receipts are not synthesized. + */ +export function parseManagedRuntimeOutcomePart( + functionCallId: string, + parsed: unknown, +): ManagedRuntimeOutcome['part'] | null { + if (!isObjectRecord(parsed)) return null; + const nested = isObjectRecord(parsed['body']) ? parsed['body'] : undefined; + const candidate = + asFunctionResponse(parsed['functionResponse']) ?? + (nested === undefined + ? null + : asFunctionResponse(nested['functionResponse'])); + if (candidate === null) return null; + return { + functionResponse: { + ...candidate, + id: functionCallId, + }, + }; +} diff --git a/packages/core/src/managed-runtime/managed-prompt-admission.test.ts b/packages/core/src/managed-runtime/managed-prompt-admission.test.ts new file mode 100644 index 00000000000..4db694ee2ee --- /dev/null +++ b/packages/core/src/managed-runtime/managed-prompt-admission.test.ts @@ -0,0 +1,233 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { mkdtemp, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + FileManagedActivationStore, + type ManagedActivationDescriptor, +} from './managed-activation-store.js'; +import { + ManagedPromptAdmissionController, + type ManagedActivationSubmitter, +} from './managed-prompt-admission.js'; +import { + FileManagedSessionInbox, + managedSessionPayloadRef, + type ManagedSessionUserMessageInput, +} from './managed-session-inbox.js'; + +const inboxLimits = { maxPending: 10, maxPendingPerTenant: 5 }; +const activationLimits = { maxQueued: 10, maxQueuedPerTenant: 5 }; + +function userMessage(messageId: string): ManagedSessionUserMessageInput { + return { + tenantId: 'tenant-a', + sessionId: 'session-a', + messageId, + payload: { prompt: [{ type: 'text', text: 'hello' }] }, + }; +} + +describe('ManagedPromptAdmissionController', () => { + let root: string; + let inboxPath: string; + let activationPath: string; + + beforeEach(async () => { + root = await mkdtemp(path.join(os.tmpdir(), 'managed-admission-')); + inboxPath = path.join(root, 'messages.jsonl'); + activationPath = path.join(root, 'activations.jsonl'); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('makes the complete message readable before activation submission', async () => { + const inbox = await FileManagedSessionInbox.open(inboxPath); + const input = userMessage('m1'); + const submit = vi.fn(async (activation: ManagedActivationDescriptor) => { + expect(inbox.getByPayloadRef(input, activation.payloadRef)).toMatchObject( + { + state: 'admitted', + activationReady: true, + message: { payload: input.payload }, + }, + ); + return { + created: true, + activation: { + descriptor: activation, + queuedAt: 1, + queueSequence: 1, + status: 'queued' as const, + }, + }; + }); + const controller = new ManagedPromptAdmissionController( + inbox, + { submit }, + inboxLimits, + ); + + await expect(controller.admit(input)).resolves.toMatchObject({ + created: true, + message: { activationReady: true }, + activation: { created: true }, + }); + expect(submit).toHaveBeenCalledWith({ + tenantId: 'tenant-a', + sessionId: 'session-a', + activationId: 'm1', + payloadRef: managedSessionPayloadRef(input), + reason: 'user_message', + recovery: 'replay_safe', + }); + }); + + it('readies the captured identity when the caller mutates its object during submission', async () => { + const inbox = await FileManagedSessionInbox.open(inboxPath); + const input = { + tenantId: 'tenant-a', + sessionId: 'session-a', + messageId: 'm1', + payload: { prompt: [{ type: 'text', text: 'hello' }] }, + }; + const controller = new ManagedPromptAdmissionController( + inbox, + { + submit: async (activation) => { + input.sessionId = 'mutated-session'; + return { + created: true, + activation: { + descriptor: activation, + queuedAt: 1, + queueSequence: 1, + status: 'queued', + }, + }; + }, + }, + inboxLimits, + ); + + await expect(controller.admit(input)).resolves.toMatchObject({ + message: { + activationReady: true, + message: { sessionId: 'session-a' }, + }, + }); + expect(inbox.get(userMessage('m1'))).toMatchObject({ + activationReady: true, + }); + }); + + it('recovers a ready durable message after activation submission failed', async () => { + const inbox = await FileManagedSessionInbox.open(inboxPath); + const failing: ManagedActivationSubmitter = { + submit: async () => { + throw new Error('activation store unavailable'); + }, + }; + const first = new ManagedPromptAdmissionController( + inbox, + failing, + inboxLimits, + ); + await expect(first.admit(userMessage('m1'))).rejects.toThrow( + 'activation store unavailable', + ); + expect(inbox.listPending()).toMatchObject([ + { activationReady: true, message: { messageId: 'm1' } }, + ]); + + const reopenedInbox = await FileManagedSessionInbox.open(inboxPath); + const activationStore = + await FileManagedActivationStore.open(activationPath); + const recovered = new ManagedPromptAdmissionController( + reopenedInbox, + { + submit: (activation) => + activationStore.enqueue(activation, activationLimits), + }, + inboxLimits, + ); + + await expect(recovered.reconcile()).resolves.toEqual({ + inspected: 1, + readied: 0, + }); + expect(reopenedInbox.get(userMessage('m1'))).toMatchObject({ + activationReady: true, + message: { payload: userMessage('m1').payload }, + }); + expect(activationStore.listPending()).toMatchObject([ + { + descriptor: { + activationId: 'm1', + payloadRef: managedSessionPayloadRef(userMessage('m1')), + }, + }, + ]); + }); + + it('readies and submits a message interrupted immediately after admission', async () => { + const inbox = await FileManagedSessionInbox.open(inboxPath); + const activationStore = + await FileManagedActivationStore.open(activationPath); + const input = userMessage('m1'); + await inbox.admit(input, inboxLimits); + const controller = new ManagedPromptAdmissionController( + inbox, + { + submit: (activation) => + activationStore.enqueue(activation, activationLimits), + }, + inboxLimits, + ); + + await expect(controller.reconcile()).resolves.toEqual({ + inspected: 1, + readied: 1, + }); + expect(activationStore.listPending()).toHaveLength(1); + await expect(controller.reconcile()).resolves.toEqual({ + inspected: 1, + readied: 0, + }); + expect(activationStore.listPending()).toHaveLength(1); + }); + + it('keeps client retries idempotent after the ready admission', async () => { + const inbox = await FileManagedSessionInbox.open(inboxPath); + const activationStore = + await FileManagedActivationStore.open(activationPath); + const controller = new ManagedPromptAdmissionController( + inbox, + { + submit: (activation) => + activationStore.enqueue(activation, activationLimits), + }, + inboxLimits, + ); + + await expect(controller.admit(userMessage('m1'))).resolves.toMatchObject({ + created: true, + activation: { created: true }, + }); + await expect(controller.admit(userMessage('m1'))).resolves.toMatchObject({ + created: false, + activation: { created: false }, + message: { activationReady: true }, + }); + expect(inbox.listPending()).toHaveLength(1); + expect(activationStore.listPending()).toHaveLength(1); + }); +}); diff --git a/packages/core/src/managed-runtime/managed-prompt-admission.ts b/packages/core/src/managed-runtime/managed-prompt-admission.ts new file mode 100644 index 00000000000..74b44162386 --- /dev/null +++ b/packages/core/src/managed-runtime/managed-prompt-admission.ts @@ -0,0 +1,84 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + ManagedActivationDescriptor, + ManagedActivationEnqueueResult, +} from './managed-activation-store.js'; +import { + managedSessionPayloadRef, + type FileManagedSessionInbox, + type ManagedSessionInboxAdmissionResult, + type ManagedSessionInboxLimits, + type ManagedSessionMessageSnapshot, + type ManagedSessionUserMessageInput, +} from './managed-session-inbox.js'; + +export interface ManagedActivationSubmitter { + submit( + activation: ManagedActivationDescriptor, + ): Promise; +} + +export interface ManagedPromptAdmissionResult + extends ManagedSessionInboxAdmissionResult { + readonly activation: ManagedActivationEnqueueResult; +} + +export interface ManagedPromptReconciliationResult { + readonly inspected: number; + readonly readied: number; +} + +export class ManagedPromptAdmissionController { + private readonly limits: ManagedSessionInboxLimits; + + constructor( + private readonly inbox: FileManagedSessionInbox, + private readonly submitter: ManagedActivationSubmitter, + limits: ManagedSessionInboxLimits, + ) { + this.limits = Object.freeze({ ...limits }); + } + + async admit( + input: ManagedSessionUserMessageInput, + ): Promise { + const admitted = await this.inbox.admit(input, this.limits); + const message = await this.inbox.markActivationReady( + admitted.message.message, + ); + const activation = await this.submit(admitted.message); + return { created: admitted.created, message, activation }; + } + + async reconcile(): Promise { + const pending = this.inbox.listPending(); + let readied = 0; + for (const message of pending) { + if (!message.activationReady) { + await this.inbox.markActivationReady(message.message); + readied += 1; + } + await this.submit(message); + } + return { inspected: pending.length, readied }; + } + + private submit( + snapshot: ManagedSessionMessageSnapshot, + ): Promise { + const { tenantId, sessionId, messageId } = snapshot.message; + return this.submitter.submit({ + tenantId, + sessionId, + activationId: messageId, + payloadRef: managedSessionPayloadRef(snapshot.message), + reason: 'user_message', + recovery: 'replay_safe', + }); + } +} diff --git a/packages/core/src/managed-runtime/managed-runtime-dispatch-gate.test.ts b/packages/core/src/managed-runtime/managed-runtime-dispatch-gate.test.ts new file mode 100644 index 00000000000..900ecfff89c --- /dev/null +++ b/packages/core/src/managed-runtime/managed-runtime-dispatch-gate.test.ts @@ -0,0 +1,66 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, describe, expect, it } from 'vitest'; +import { + managedRuntimeDispatchGate, + resetManagedRuntimeDispatchGatesForTest, +} from './managed-runtime-dispatch-gate.js'; +import { ManagedSessionConflictError } from './managed-session-authority.js'; + +const sessionKey = { + tenantId: 't1', + workspaceId: 'w1', + sessionId: 's1', +}; + +afterEach(() => { + resetManagedRuntimeDispatchGatesForTest(); +}); + +describe('managed runtime dispatch gate', () => { + it('dispatches an executionCallId at most once', () => { + const gate = managedRuntimeDispatchGate(sessionKey); + gate.claim('ex-1'); + expect(gate.state('ex-1')).toBe('dispatch'); + expect(() => gate.claim('ex-1')).toThrow(ManagedSessionConflictError); + gate.unclaim('ex-1'); + gate.claim('ex-1'); + expect(gate.state('ex-1')).toBe('dispatch'); + }); + + it('hands off and settles without requiring a live in-memory claim', () => { + const gate = managedRuntimeDispatchGate(sessionKey); + gate.claim('ex-1'); + gate.handoff('ex-1'); + expect(gate.isHandedOff('ex-1')).toBe(true); + expect(() => gate.claim('ex-1')).toThrow(/already dispatched/); + + const cold = managedRuntimeDispatchGate({ + ...sessionKey, + sessionId: 's2', + }); + cold.handoff('ex-2'); + expect(cold.isHandedOff('ex-2')).toBe(true); + cold.settle('ex-2'); + expect(cold.state('ex-2')).toBe('settled'); + cold.settle('ex-2'); + expect(cold.state('ex-2')).toBe('settled'); + expect(() => cold.claim('ex-2')).toThrow(/already dispatched/); + }); + + it('returns the same gate for the same session key', () => { + const first = managedRuntimeDispatchGate(sessionKey); + first.claim('ex-1'); + expect(managedRuntimeDispatchGate(sessionKey).state('ex-1')).toBe( + 'dispatch', + ); + resetManagedRuntimeDispatchGatesForTest(); + expect( + managedRuntimeDispatchGate(sessionKey).state('ex-1'), + ).toBeUndefined(); + }); +}); diff --git a/packages/core/src/managed-runtime/managed-runtime-dispatch-gate.ts b/packages/core/src/managed-runtime/managed-runtime-dispatch-gate.ts new file mode 100644 index 00000000000..f31d2115a3d --- /dev/null +++ b/packages/core/src/managed-runtime/managed-runtime-dispatch-gate.ts @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ManagedSessionConflictError } from './managed-session-authority.js'; +import type { ManagedSessionKey } from './managed-session-records.js'; + +export type ManagedRuntimeDispatchState = 'dispatch' | 'handed_off' | 'settled'; + +/** + * Process-local coordinator gate: one executionCallId is dispatched at most + * once. Handoff keeps the original invocation after Harness detach; settle + * is the only legal end. Worker loss across process restart is recovery + * blocked, not a second dispatch. + */ +export class ManagedRuntimeDispatchGate { + private readonly states = new Map(); + + claim(executionCallId: string): void { + const current = this.states.get(executionCallId); + if (current !== undefined) { + throw new ManagedSessionConflictError( + 'runtime invocation already dispatched.', + ); + } + this.states.set(executionCallId, 'dispatch'); + } + + unclaim(executionCallId: string): void { + if (this.states.get(executionCallId) === 'dispatch') { + this.states.delete(executionCallId); + } + } + + handoff(executionCallId: string): void { + const current = this.states.get(executionCallId); + if (current === 'handed_off' || current === 'settled') return; + this.states.set(executionCallId, 'handed_off'); + } + + settle(executionCallId: string): void { + const current = this.states.get(executionCallId); + if (current === 'settled') return; + this.states.set(executionCallId, 'settled'); + } + + state(executionCallId: string): ManagedRuntimeDispatchState | undefined { + return this.states.get(executionCallId); + } + + isHandedOff(executionCallId: string): boolean { + return this.states.get(executionCallId) === 'handed_off'; + } +} + +const gates = new Map(); + +function gateKey(sessionKey: ManagedSessionKey): string { + return `${sessionKey.tenantId}\0${sessionKey.workspaceId}\0${sessionKey.sessionId}`; +} + +export function managedRuntimeDispatchGate( + sessionKey: ManagedSessionKey, +): ManagedRuntimeDispatchGate { + const id = gateKey(sessionKey); + const existing = gates.get(id); + if (existing !== undefined) return existing; + const created = new ManagedRuntimeDispatchGate(); + gates.set(id, created); + return created; +} + +export function resetManagedRuntimeDispatchGatesForTest(): void { + gates.clear(); +} diff --git a/packages/core/src/managed-runtime/managed-session-assembly.test.ts b/packages/core/src/managed-runtime/managed-session-assembly.test.ts new file mode 100644 index 00000000000..5a358d466fc --- /dev/null +++ b/packages/core/src/managed-runtime/managed-session-assembly.test.ts @@ -0,0 +1,388 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { Storage } from '../config/storage.js'; +import type { ChatRecord } from '../services/chatRecordingService.js'; +import { SessionWriterLease } from '../services/session-writer-lease.js'; +import { + ManagedSessionAlreadyExistsError, + ManagedSessionNotFoundError, +} from './managed-session-authority.js'; +import { + openManagedSession, + type ManagedSession, +} from './managed-session-assembly.js'; +import { LocalJsonlManagedSessionJournalStore } from './local-jsonl-managed-session-journal-store.js'; +import { readManagedSessionTitleInfoSync } from '../utils/sessionStorageUtils.js'; +import type { ManagedSessionDurableRef } from './managed-session-records.js'; +import { LocalManagedSessionResourceStore } from './managed-session-resources.js'; +import type { + ManagedSessionJournalStore, + ManagedSessionResourceStore, +} from './managed-session-storage.js'; + +const DIGEST = '9'.repeat(64); +const sessionId = '550e8400-e29b-41d4-a716-446655440000'; +const sessionKey = { tenantId: 't1', workspaceId: 'w1', sessionId }; +const temporaryDirectories = new Set(); + +afterEach(async () => { + for (const directory of temporaryDirectories) { + await fs.rm(directory, { recursive: true, force: true }); + } + temporaryDirectories.clear(); +}); + +function ref(kind = 'managed-test'): ManagedSessionDurableRef { + return { + resourceId: 'res-1', + kind, + schemaVersion: 1, + byteLength: 4, + digest: DIGEST, + }; +} + +interface Workspace { + runtimeBaseDir: string; + projectRoot: string; + transcriptPath: string; +} + +async function createWorkspace(): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-managed-asm-')); + temporaryDirectories.add(root); + const projectRoot = path.join(root, 'project'); + const runtimeBaseDir = path.join(root, 'runtime'); + await fs.mkdir(projectRoot, { recursive: true }); + await fs.mkdir(runtimeBaseDir, { recursive: true }); + const transcriptPath = path.join( + new Storage(projectRoot, runtimeBaseDir).getProjectDir(), + 'chats', + `${sessionId}.jsonl`, + ); + await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); + return { runtimeBaseDir, projectRoot, transcriptPath }; +} + +function open( + workspace: Workspace, + options: { + create?: boolean; + lease?: SessionWriterLease; + journalStore?: ManagedSessionJournalStore; + resourceStore?: ManagedSessionResourceStore; + requireNew?: boolean; + } = {}, +): Promise { + return openManagedSession({ + runtimeBaseDir: workspace.runtimeBaseDir, + sessionId, + transcriptPath: workspace.transcriptPath, + sessionKey, + cwd: workspace.projectRoot, + version: 'test', + workerId: 'worker-1', + activationLeaseDurationMs: 60_000, + ...(options.lease === undefined ? {} : { lease: options.lease }), + ...(options.journalStore === undefined + ? {} + : { journalStore: options.journalStore }), + ...(options.resourceStore === undefined + ? {} + : { resourceStore: options.resourceStore }), + ...(options.requireNew === true ? { requireNew: true } : {}), + ...(options.create === false + ? {} + : { + create: { + definitionRef: ref('managed-definition'), + rootSnapshotRef: ref('managed-root'), + createdBy: 'daemon', + }, + }), + }); +} + +function record(overrides: Partial): ChatRecord { + return { + uuid: 'rec-1', + parentUuid: null, + sessionId, + timestamp: '2026-09-01T10:00:00.000Z', + type: 'user', + cwd: '/workspace', + version: 'test', + ...overrides, + } as ChatRecord; +} + +describe('managed session assembly', () => { + it('accepts injected journal and resource stores', async () => { + const workspace = await createWorkspace(); + const journalStore = new LocalJsonlManagedSessionJournalStore({ + runtimeBaseDir: workspace.runtimeBaseDir, + sessionId, + transcriptPath: workspace.transcriptPath, + }); + const resourceStore = LocalManagedSessionResourceStore.create({ + runtimeBaseDir: workspace.runtimeBaseDir, + sessionKey, + }); + + const session = await open(workspace, { journalStore, resourceStore }); + await session.sink.write(record({ uuid: 'rec-user-injected' })); + + expect(await session.sink.project()).toHaveLength(1); + await session.close(); + }); + + it('carries one whole turn from input to terminal state', async () => { + const workspace = await createWorkspace(); + const session = await open(workspace); + + // Opening installed the activation, so every record names it as its harness + // without the caller arranging anything. + expect(session.activation.epoch).toBe(1); + const installed = session.authority + .readEvents() + .filter((event) => event.kind === 'activation.changed'); + expect(installed).toHaveLength(1); + expect(installed[0].payload['workerId']).toBe('worker-1'); + expect(installed[0].payload['phase']).toBe('active'); + + const userRecord = record({ + uuid: 'rec-user-1', + message: { role: 'user', parts: [{ text: 'summarise the docs' }] }, + }); + await session.sink.write(userRecord); + + const assistantRecord = record({ + uuid: 'rec-assistant-1', + parentUuid: 'rec-user-1', + type: 'assistant', + model: 'qwen3-coder-plus', + message: { role: 'model', parts: [{ text: 'Here it is.' }] }, + }); + await session.sink.write(assistantRecord); + + await session.sink.write( + record({ + uuid: 'rec-title-1', + type: 'system', + subtype: 'custom_title', + systemPayload: { customTitle: 'Doc summary', titleSource: 'auto' }, + }), + ); + + await session.sink.write( + record({ + uuid: 'rec-turn-1', + type: 'system', + subtype: 'turn_result', + systemPayload: { + promptId: 'turn-1', + state: 'completed', + stopReason: 'end_turn', + endedAt: Date.parse('2026-09-01T10:00:10.000Z'), + }, + }), + ); + + const settled = session.authority + .readEvents() + .filter((event) => event.kind === 'turn.settled'); + expect(settled).toHaveLength(1); + expect(settled[0].payload['outcome']).toBe('completed'); + + expect(await session.sink.project()).toEqual([userRecord, assistantRecord]); + await session.close(); + + // Reopening reads the same history back, and the title reaches the session + // directory through the metadata record rather than the message channel. + const reopened = await open(workspace, { create: false }); + expect(await reopened.sink.project()).toEqual([ + userRecord, + assistantRecord, + ]); + expect( + readManagedSessionTitleInfoSync( + workspace.transcriptPath, + workspace.runtimeBaseDir, + ), + ).toEqual({ title: 'Doc summary', source: 'auto' }); + await reopened.close(); + }); + + it('leaves the sealed barrier in place after closing', async () => { + const workspace = await createWorkspace(); + const session = await open(workspace); + await session.sink.write(record({ uuid: 'rec-user-1' })); + await session.close(); + + await expect( + SessionWriterLease.acquire({ + runtimeBaseDir: workspace.runtimeBaseDir, + sessionId, + transcriptPath: workspace.transcriptPath, + }), + ).rejects.toThrow(); + }); + + it('seals with the commit proof and reopens through a certified takeover', async () => { + const workspace = await createWorkspace(); + const session = await open(workspace); + await session.sink.write(record({ uuid: 'rec-user-1' })); + await session.close(); + + const lockPath = path.join( + workspace.runtimeBaseDir, + 'tmp', + 'session-writer-locks', + `${encodeURIComponent(sessionId)}.lock`, + ); + const sealed = JSON.parse(await fs.readFile(lockPath, 'utf8')); + expect(sealed).toMatchObject({ + schema_version: 3, + state: 'sealed', + format_version: 1, + }); + expect(sealed.last_commit_sequence).toBeGreaterThan(0); + expect(sealed.committed_prefix_hash).toMatch(/^[0-9a-f]{64}$/); + + const reopened = await open(workspace, { create: false }); + expect(await reopened.sink.project()).toHaveLength(1); + await reopened.close(); + }); + + it('refuses to advance a log whose sealed commit proof was tampered with', async () => { + const workspace = await createWorkspace(); + const session = await open(workspace); + await session.sink.write(record({ uuid: 'rec-user-1' })); + await session.close(); + + // The commit proof is not covered by the transcript hash, so editing the + // lock passes the takeover's byte proof and reaches the authority, which + // must refuse instead of advancing from an unproven position. + const lockPath = path.join( + workspace.runtimeBaseDir, + 'tmp', + 'session-writer-locks', + `${encodeURIComponent(sessionId)}.lock`, + ); + const sealed = JSON.parse(await fs.readFile(lockPath, 'utf8')); + sealed.last_commit_sequence = sealed.last_commit_sequence + 1; + await fs.writeFile(lockPath, JSON.stringify(sealed), { mode: 0o600 }); + + await expect(open(workspace, { create: false })).rejects.toThrow( + /does not match the sealed writer proof/, + ); + }); + + it('does not hold the writer when opening fails', async () => { + const workspace = await createWorkspace(); + + // No creation parameters and no existing header: opening cannot succeed. + await expect(open(workspace, { create: false })).rejects.toBeInstanceOf( + ManagedSessionNotFoundError, + ); + + // The writer was released rather than left held or sealed, so a fresh open + // succeeds instead of colliding with an abandoned lock. + const session = await open(workspace); + expect(session.authority.committedSequence).toBe(1); + await session.close(); + }); + + it('does not reopen an existing authority through a create path', async () => { + const workspace = await createWorkspace(); + const created = await open(workspace); + await created.close(); + + await expect(open(workspace, { requireNew: true })).rejects.toBeInstanceOf( + ManagedSessionAlreadyExistsError, + ); + + const restored = await open(workspace, { create: false }); + await restored.close(); + }); + + it('leaves an adopted writer to its owner', async () => { + const workspace = await createWorkspace(); + const lease = await SessionWriterLease.acquire({ + runtimeBaseDir: workspace.runtimeBaseDir, + sessionId, + transcriptPath: workspace.transcriptPath, + }); + const session = await open(workspace, { lease }); + await session.sink.write(record({ uuid: 'rec-user-1' })); + await session.close(); + + // Closing the session must not end a lease it never acquired: the owner is + // still writing through it after this point. + expect(lease.isReleased).toBe(false); + + // The barrier arrives when the owner seals, which is the owner's decision + // to make -- releasing instead would leave the Managed log unguarded. + await lease.sealForHandoff(); + await expect( + SessionWriterLease.acquire({ + runtimeBaseDir: workspace.runtimeBaseDir, + sessionId, + transcriptPath: workspace.transcriptPath, + }), + ).rejects.toThrow(); + }); + + it('leaves an adopted writer intact when opening fails', async () => { + const workspace = await createWorkspace(); + const lease = await SessionWriterLease.acquire({ + runtimeBaseDir: workspace.runtimeBaseDir, + sessionId, + transcriptPath: workspace.transcriptPath, + }); + + await expect(open(workspace, { create: false, lease })).rejects.toThrow(); + expect(lease.isReleased).toBe(false); + + // Still the same writer, so the owner can retry through it. + const session = await open(workspace, { lease }); + await session.sink.write(record({ uuid: 'rec-user-1' })); + expect(await session.sink.project()).toHaveLength(1); + await session.close(); + await lease.sealForHandoff(); + }); + + it('names a replacement activation on later records', async () => { + const workspace = await createWorkspace(); + const session = await open(workspace); + const first = session.activation.activationId; + await session.sink.write(record({ uuid: 'rec-user-1' })); + + const replaced = await session.replaceActivation(); + expect(replaced.activationId).not.toBe(first); + expect(session.activation.activationId).toBe(replaced.activationId); + + await session.sink.write(record({ uuid: 'rec-user-2' })); + const messages = session.authority + .readEvents() + .filter((event) => event.kind === 'message.committed'); + expect(messages).toHaveLength(2); + expect(messages[0]?.subject).toMatchObject({ + type: 'activation', + activationId: first, + }); + expect(messages[1]?.subject).toMatchObject({ + type: 'activation', + activationId: replaced.activationId, + }); + await session.close(); + }); +}); diff --git a/packages/core/src/managed-runtime/managed-session-assembly.ts b/packages/core/src/managed-runtime/managed-session-assembly.ts new file mode 100644 index 00000000000..4f5f58deddd --- /dev/null +++ b/packages/core/src/managed-runtime/managed-session-assembly.ts @@ -0,0 +1,237 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { randomUUID } from 'node:crypto'; +import { LocalManagedSessionAuthority } from './managed-session-authority.js'; +import { LocalJsonlManagedSessionJournalStore } from './local-jsonl-managed-session-journal-store.js'; +import { ManagedSessionRecordSink } from './managed-session-record-sink.js'; +import type { SessionWriterLease } from '../services/session-writer-lease.js'; +import { LocalManagedSessionResourceStore } from './managed-session-resources.js'; +import type { + ManagedSessionJournalStore, + ManagedSessionResourceStore, +} from './managed-session-storage.js'; +import type { + ManagedSessionDurableRef, + ManagedSessionKey, +} from './managed-session-records.js'; +import { ManagedSessionRecordError } from './managed-session-records.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('MANAGED_SESSION_ASSEMBLY'); + +/** + * Keeps the installed activation's horizon ahead of a reader while the worker + * is alive. The install stamps a fixed expiry, so without renewal a session + * that outlives its lease reads as abandoned behind a live writer lock. + * Renewal failures are retried on the next tick: a durable write failure + * already fences new appends, so the timer only logs. + */ +function startActivationRenewal( + authority: LocalManagedSessionAuthority, + leaseDurationMs: number, +): { stop: () => void } { + const intervalMs = Math.max(Math.floor(leaseDurationMs / 3), 1); + const timer = setInterval(() => { + authority.renewActivation({ leaseDurationMs }).catch((error: unknown) => { + debugLogger.debug( + 'Managed Session activation renewal failed', + describeRenewalError(error), + ); + }); + }, intervalMs); + timer.unref(); + return { + stop: () => clearInterval(timer), + }; +} + +function describeRenewalError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export interface OpenManagedSessionOptions { + readonly runtimeBaseDir: string; + readonly sessionId: string; + readonly transcriptPath: string; + readonly sessionKey: ManagedSessionKey; + readonly cwd: string; + readonly version: string; + /** Supplied when creating a session; omitted when reopening one. */ + readonly create?: { + readonly definitionRef: ManagedSessionDurableRef; + readonly rootSnapshotRef: ManagedSessionDurableRef; + readonly createdBy: string; + }; + /** Create requests fail when the durable authority already exists. */ + readonly requireNew?: boolean; + /** + * Identifies the worker advancing the session. Opening installs an activation + * under this identity, because a writer that opens the log is by definition + * the party advancing it, and only an activation lets a Harness append. + */ + readonly workerId: string; + /** + * How long the installed activation claims to stay live. Required because the + * format records a horizon and the policy belongs to the caller: nothing here + * knows how long that worker is supervised for. + */ + readonly activationLeaseDurationMs: number; + /** Overrides local JSONL persistence for hosted deployments. */ + readonly journalStore?: ManagedSessionJournalStore; + /** Overrides local resource files for hosted deployments. */ + readonly resourceStore?: ManagedSessionResourceStore; + /** + * An already-held writer to adopt instead of acquiring one. + * + * Two writers for one session cannot coexist, so a caller that already owns + * the session's writer hands it in. Its owner keeps the lifecycle: `close()` + * then leaves the lease alone rather than sealing it. + */ + readonly lease?: SessionWriterLease; +} + +export interface ManagedSession { + readonly authority: LocalManagedSessionAuthority; + readonly resources: ManagedSessionResourceStore; + readonly sink: ManagedSessionRecordSink; + /** The activation currently advancing this session. */ + readonly activation: { + readonly activationId: string; + readonly epoch: number; + }; + /** + * Records that this activation stopped advancing the session. + * + * Separate from `close()` because it must land after the last record and + * before the writer is sealed: a record naming a released activation is + * refused by the fence. + */ + releaseActivation(): Promise; + /** + * Releases the current activation and installs a successor. The next Harness + * handle must present the returned identity; the sink names it automatically. + */ + replaceActivation(): Promise<{ + readonly activationId: string; + readonly epoch: number; + }>; + /** Seals the writer, leaving the at-rest barrier in place. */ + close(): Promise; +} + +/** + * Opens a Managed session as one unit: the writer, the resource store that + * holds event bodies, and the sink a recorder writes through. + * + * The pieces were built separately and each verified on its own, but a caller + * assembling them by hand could easily get the lifecycle wrong -- releasing the + * writer instead of sealing it, or pointing the store at a different root than + * the reader. Composing them here keeps those decisions in one place. + */ +export async function openManagedSession( + options: OpenManagedSessionOptions, +): Promise { + if (options.journalStore !== undefined && options.lease !== undefined) { + throw new ManagedSessionRecordError( + 'journalStore and an adopted local lease cannot be supplied together.', + ); + } + const resources = + options.resourceStore ?? + LocalManagedSessionResourceStore.create({ + runtimeBaseDir: options.runtimeBaseDir, + sessionKey: options.sessionKey, + }); + const adopted = options.lease !== undefined; + const journalStore = + options.journalStore ?? + new LocalJsonlManagedSessionJournalStore({ + runtimeBaseDir: options.runtimeBaseDir, + sessionId: options.sessionId, + transcriptPath: options.transcriptPath, + ...(options.lease === undefined ? {} : { lease: options.lease }), + }); + const journal = await journalStore.open({ sessionKey: options.sessionKey }); + let authority: LocalManagedSessionAuthority; + try { + authority = await LocalManagedSessionAuthority.open({ + journal, + sessionKey: options.sessionKey, + cwd: options.cwd, + version: options.version, + resources, + ...(options.create === undefined ? {} : { create: options.create }), + ...(options.requireNew === true ? { requireNew: true } : {}), + // A takeover proves the sealed writer's commit position before this + // authority may advance the log. + ...(journal.takeoverCommitProof === undefined + ? {} + : { expectedCommitProof: journal.takeoverCommitProof }), + }); + } catch (cause) { + // An adopted writer is not ours to end: releasing it would pull the lease + // out from under its owner. One we acquired must be released, since sealing + // an unopened session leaves a barrier with nothing behind it and leaving it + // held blocks every later attempt. + await journal.abort().catch(() => undefined); + throw cause; + } + + let activation: { activationId: string; epoch: number }; + try { + activation = await authority.installActivation({ + activationId: randomUUID(), + workerId: options.workerId, + leaseDurationMs: options.activationLeaseDurationMs, + }); + } catch (cause) { + await journal.abort().catch(() => undefined); + throw cause; + } + + // Installed before the sink exists, so there is no window in which a record + // has no activation to name. The callback reads the live binding so a + // replacement handle's records name the successor, not the drained one. + const sink = new ManagedSessionRecordSink(authority, resources, () => ({ + class: 'harness', + activation, + })); + + const renewal = startActivationRenewal( + authority, + options.activationLeaseDurationMs, + ); + + return { + authority, + resources, + sink, + get activation() { + return activation; + }, + releaseActivation: () => authority.releaseActivation(), + async replaceActivation() { + await authority.releaseActivation(); + activation = await authority.installActivation({ + activationId: randomUUID(), + workerId: options.workerId, + leaseDurationMs: options.activationLeaseDurationMs, + }); + return activation; + }, + // Sealing is the at-rest barrier, but only the lease's owner may end it. + // A call that owns the whole lifecycle also records the boundary, or the + // activation would read as abandoned. An adopted lease still stops the + // renewal it started; only the lease itself stays with its owner. + close: async () => { + renewal.stop(); + if (adopted) return; + await authority.releaseActivation(); + await authority.close(); + }, + }; +} diff --git a/packages/core/src/managed-runtime/managed-session-authority.test.ts b/packages/core/src/managed-runtime/managed-session-authority.test.ts new file mode 100644 index 00000000000..ff991371a32 --- /dev/null +++ b/packages/core/src/managed-runtime/managed-session-authority.test.ts @@ -0,0 +1,2487 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { assertSessionExecutionEngine } from '../services/session-execution-engine.js'; +import { readSessionTranscriptSnapshot } from '../services/session-transcript-reader.js'; +import { SessionWriterLease } from '../services/session-writer-lease.js'; +import { Storage } from '../config/storage.js'; +import { LocalManagedSessionResourceStore } from './managed-session-resources.js'; +import { + assertManagedSessionRestoreBundle, + LocalManagedSessionAuthority, + ManagedSessionConflictError, + ManagedSessionUncommittedTailError, + type ManagedSessionCommand, + type ManagedSessionRestoreBundle, +} from './managed-session-authority.js'; +import { + createInitialHarnessCheckpoint, + createNextTurnReadyHarnessCheckpoint, + encodeHarnessCheckpointV1, + HARNESS_TURN_COMPLETE_BOUNDARY, + parseHarnessCheckpointV1, +} from './managed-harness-checkpoint.js'; +import { + ManagedSessionRecordError, + MANAGED_SESSION_FORMAT_VERSION, + type ManagedSessionDurableRef, +} from './managed-session-records.js'; + +const DIGEST = 'b'.repeat(64); +const temporaryDirectories = new Set(); + +afterEach(async () => { + for (const directory of temporaryDirectories) { + await fs.rm(directory, { recursive: true, force: true }); + } + temporaryDirectories.clear(); +}); + +function ref(kind = 'managed-test'): ManagedSessionDurableRef { + return { + resourceId: 'res-1', + kind, + schemaVersion: 1, + byteLength: 4, + digest: DIGEST, + }; +} + +interface Fixture { + runtimeBaseDir: string; + transcriptPath: string; + sessionId: string; +} + +async function createFixture(sessionId = 'managed-session'): Promise { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-managed-authority-'), + ); + temporaryDirectories.add(root); + const runtimeBaseDir = path.join(root, 'runtime'); + const projectRoot = path.join(root, 'project'); + await fs.mkdir(projectRoot, { recursive: true }); + await fs.mkdir(runtimeBaseDir, { recursive: true }); + /* Sealing records the transcript path relative to the runtime base + directory, so the transcript has to live under it, as it does in a real + workspace. */ + const transcriptPath = path.join( + new Storage(projectRoot, runtimeBaseDir).getProjectDir(), + 'chats', + `${sessionId}.jsonl`, + ); + await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); + return { runtimeBaseDir, transcriptPath, sessionId }; +} + +function sessionKeyFor(fixture: Fixture) { + return { + tenantId: 'tenant-1', + workspaceId: 'workspace-1', + sessionId: fixture.sessionId, + }; +} + +interface OpenedAuthority { + authority: LocalManagedSessionAuthority; + release(): Promise; +} + +async function openAuthority( + fixture: Fixture, + options: { create?: boolean } = {}, +): Promise { + const lease = await SessionWriterLease.acquire({ + runtimeBaseDir: fixture.runtimeBaseDir, + sessionId: fixture.sessionId, + transcriptPath: fixture.transcriptPath, + }); + try { + const authority = await LocalManagedSessionAuthority.open({ + lease, + sessionKey: sessionKeyFor(fixture), + cwd: '/workspace', + version: 'test', + ...(options.create === false + ? {} + : { + create: { + definitionRef: ref('managed-definition'), + rootSnapshotRef: ref('managed-root'), + createdBy: 'daemon', + }, + }), + }); + return { authority, release: () => lease.release() }; + } catch (error) { + await lease.release().catch(() => undefined); + throw error; + } +} + +function inputCommand( + fixture: Fixture, + overrides: Partial = {}, +): ManagedSessionCommand { + return { + operation: 'submitInput', + commandId: 'cmd-1', + sessionKey: sessionKeyFor(fixture), + contentDigest: DIGEST, + ...overrides, + }; +} + +const inputRequest = { + inputId: 'in-1', + turnId: 'turn-1', + source: 'web_shell', + contentRef: ref(), + deadline: null, + admissionRef: ref(), + wakeReason: 'input', +}; + +async function readLines(fixture: Fixture): Promise { + const text = await fs.readFile(fixture.transcriptPath, 'utf8'); + return text.split('\n').filter((line) => line !== ''); +} + +async function readRecords( + fixture: Fixture, +): Promise>> { + const lines = await readLines(fixture); + return lines.map((line) => JSON.parse(line) as Record); +} + +async function readBodies( + fixture: Fixture, +): Promise>> { + const records = await readRecords(fixture); + return records + .filter((record) => record['managedSession'] !== undefined) + .map((record) => record['managedSession'] as Record); +} + +function indexOfSubtype(lines: string[], subtype: string): number { + const index = lines.findIndex((line) => + line.includes(`"subtype":"${subtype}"`), + ); + if (index < 0) throw new Error(`no ${subtype} record in the log`); + return index; +} + +async function rewrite(fixture: Fixture, lines: string[]): Promise { + await fs.writeFile(fixture.transcriptPath, `${lines.join('\n')}\n`, 'utf8'); +} + +describe('managed session authority', () => { + it('writes a header once and accepts input without a live harness', async () => { + const fixture = await createFixture(); + const opened = await openAuthority(fixture); + const receipt = await opened.authority.submitInput( + inputCommand(fixture), + inputRequest, + ); + await opened.release(); + + expect(receipt.firstSequence).toBe(1); + expect(receipt.lastSequence).toBe(2); + expect(receipt.committedSequence).toBe(2); + expect(receipt.replayed).toBe(false); + + const records = await readRecords(fixture); + expect(records.map((record) => record['subtype'])).toEqual([ + 'session_execution_engine', + 'managed_session_header_v1', + 'managed_session_event_v1', + 'managed_session_event_v1', + 'managed_session_commit_v1', + ]); + expect(records.every((record) => record['type'] === 'system')).toBe(true); + expect( + records.every((record) => record['sessionId'] === fixture.sessionId), + ).toBe(true); + }); + + it('persists the input and the wake intent in one transaction', async () => { + const fixture = await createFixture(); + const opened = await openAuthority(fixture); + await opened.authority.submitInput(inputCommand(fixture), inputRequest); + await opened.release(); + + const bodies = await readBodies(fixture); + expect(bodies[1]['kind']).toBe('input.accepted'); + expect(bodies[2]['kind']).toBe('wake.requested'); + expect(bodies[3]['eventCount']).toBe(2); + expect(bodies[3]['firstSequence']).toBe(1); + expect(bodies[3]['lastSequence']).toBe(2); + expect(bodies[3]['previousCommitDigest']).toBeNull(); + }); + + it('reads the committed prefix back from a cold reopen', async () => { + const fixture = await createFixture(); + const first = await openAuthority(fixture); + await first.authority.submitInput(inputCommand(fixture), inputRequest); + await first.release(); + + const second = await openAuthority(fixture, { create: false }); + expect(second.authority.committedSequence).toBe(2); + expect(second.authority.readEvents().map((event) => event.kind)).toEqual([ + 'input.accepted', + 'wake.requested', + ]); + await second.release(); + + const records = await readRecords(fixture); + expect( + records.filter( + (record) => record['subtype'] === 'managed_session_header_v1', + ), + ).toHaveLength(1); + }); + + it('returns the original receipt for a repeated command id', async () => { + const fixture = await createFixture(); + const opened = await openAuthority(fixture); + const first = await opened.authority.submitInput( + inputCommand(fixture), + inputRequest, + ); + const replay = await opened.authority.submitInput( + inputCommand(fixture), + inputRequest, + ); + await opened.release(); + + expect(replay.transactionId).toBe(first.transactionId); + expect(replay.replayed).toBe(true); + expect(replay.lastSequence).toBe(first.lastSequence); + expect(await readLines(fixture)).toHaveLength(5); + }); + + it('survives a repeated command id across a cold reopen', async () => { + const fixture = await createFixture(); + const first = await openAuthority(fixture); + const original = await first.authority.submitInput( + inputCommand(fixture), + inputRequest, + ); + await first.release(); + + const second = await openAuthority(fixture, { create: false }); + const replay = await second.authority.submitInput( + inputCommand(fixture), + inputRequest, + ); + await second.release(); + + expect(replay.transactionId).toBe(original.transactionId); + expect(replay.replayed).toBe(true); + expect(await readLines(fixture)).toHaveLength(5); + }); + + it('rejects the same command id carrying different content', async () => { + const fixture = await createFixture(); + const opened = await openAuthority(fixture); + await opened.authority.submitInput(inputCommand(fixture), inputRequest); + await expect( + opened.authority.submitInput( + inputCommand(fixture, { contentDigest: 'c'.repeat(64) }), + inputRequest, + ), + ).rejects.toThrow(ManagedSessionConflictError); + await opened.release(); + expect(await readLines(fixture)).toHaveLength(5); + }); + + it('rejects a command for another workspace', async () => { + const fixture = await createFixture(); + const opened = await openAuthority(fixture); + await expect( + opened.authority.submitInput( + inputCommand(fixture, { + sessionKey: { ...sessionKeyFor(fixture), workspaceId: 'workspace-2' }, + }), + inputRequest, + ), + ).rejects.toThrow(/does not match this session/); + await opened.release(); + expect(await readLines(fixture)).toHaveLength(2); + }); + + it('rejects a stale expectedSequence', async () => { + const fixture = await createFixture(); + const opened = await openAuthority(fixture); + await opened.authority.submitInput(inputCommand(fixture), inputRequest); + await expect( + opened.authority.submitInput( + inputCommand(fixture, { commandId: 'cmd-2', expectedSequence: 0 }), + { ...inputRequest, inputId: 'in-2' }, + ), + ).rejects.toThrow(/does not match the committed sequence 2/); + await opened.release(); + }); + + it('chains each commit marker to the previous one', async () => { + const fixture = await createFixture(); + const opened = await openAuthority(fixture); + await opened.authority.submitInput(inputCommand(fixture), inputRequest); + await opened.authority.submitInput( + inputCommand(fixture, { commandId: 'cmd-2' }), + { ...inputRequest, inputId: 'in-2' }, + ); + await opened.release(); + + const markers = (await readBodies(fixture)).filter( + (body) => body['transactionId'] !== undefined, + ); + expect(markers).toHaveLength(2); + expect(markers[0]['previousCommitDigest']).toBeNull(); + expect(markers[1]['previousCommitDigest']).toMatch(/^[0-9a-f]{64}$/); + expect(markers[1]['firstSequence']).toBe(3); + }); + + it('bounds a page read and continues from the cursor', async () => { + const fixture = await createFixture(); + const opened = await openAuthority(fixture); + await opened.authority.submitInput(inputCommand(fixture), inputRequest); + await opened.authority.submitInput( + inputCommand(fixture, { commandId: 'cmd-2' }), + { ...inputRequest, inputId: 'in-2' }, + ); + + const firstPage = opened.authority.readEvents({ limit: 3 }); + expect(firstPage.map((event) => event.sequence)).toEqual([1, 2, 3]); + const secondPage = opened.authority.readEvents({ + afterSequence: firstPage[firstPage.length - 1].sequence, + limit: 3, + }); + expect(secondPage.map((event) => event.sequence)).toEqual([4]); + expect(() => opened.authority.readEvents({ limit: 0 })).toThrow( + /limit must be positive/, + ); + await opened.release(); + }); +}); + +describe('managed session authority activation fences', () => { + function activationEvent( + fixture: Fixture, + options: { + sequence: number; + activationId: string; + epoch: number; + phase: string; + }, + ) { + const closed = options.phase === 'released' || options.phase === 'revoked'; + return { + v: 1, + sequence: options.sequence, + eventId: `evt-${options.activationId}-${options.phase}`, + sessionKey: sessionKeyFor(fixture), + kind: 'activation.changed', + occurredAt: 1, + payload: { + activationId: options.activationId, + epoch: options.epoch, + workerId: 'worker-1', + subject: { + type: 'activation', + scopeId: 'scope-1', + activationId: options.activationId, + epoch: options.epoch, + }, + phase: options.phase, + leaseDurationMs: 60_000, + expiresAt: 2, + installRef: ref(), + boundaryRef: closed ? ref() : null, + }, + }; + } + + function modelAttempt( + fixture: Fixture, + sequence: number, + activationId: string, + epoch: number, + ) { + return { + v: 1, + sequence, + eventId: `evt-model-${activationId}-${sequence}`, + sessionKey: sessionKeyFor(fixture), + kind: 'model.attempt', + occurredAt: 1, + subject: { + type: 'activation', + scopeId: 'scope-1', + activationId, + epoch, + }, + payload: { + attemptId: `att-${sequence}`, + routeRef: ref(), + inputCheckpointRef: null, + state: 'started', + usageRef: null, + }, + }; + } + + async function withActivation(fixture: Fixture): Promise { + const opened = await openAuthority(fixture); + await opened.authority.submitInput(inputCommand(fixture), inputRequest); + await opened.authority.appendExecution( + inputCommand(fixture, { + operation: 'claimActivation', + commandId: 'cmd-act-1', + }), + [ + activationEvent(fixture, { + sequence: 3, + activationId: 'act-1', + epoch: 1, + phase: 'active', + }), + ], + { class: 'coordinator' }, + ); + return opened; + } + + const holds = (activationId: string, epoch: number) => + ({ class: 'harness', activation: { activationId, epoch } }) as const; + + it('tracks the committed activation', async () => { + const fixture = await createFixture(); + const opened = await withActivation(fixture); + expect(opened.authority.currentActivation).toEqual({ + activationId: 'act-1', + epoch: 1, + workerId: 'worker-1', + phase: 'active', + expiresAt: 2, + renewalSeq: 0, + installRef: ref(), + }); + await opened.release(); + }); + + it('recovers the committed activation from a cold reopen', async () => { + const fixture = await createFixture(); + const opened = await withActivation(fixture); + await opened.release(); + + const reopened = await openAuthority(fixture, { create: false }); + expect(reopened.authority.currentActivation).toEqual({ + activationId: 'act-1', + epoch: 1, + workerId: 'worker-1', + phase: 'active', + expiresAt: 2, + renewalSeq: 0, + installRef: ref(), + }); + await reopened.release(); + }); + + it('refuses a harness append when no activation is committed', async () => { + const fixture = await createFixture(); + const opened = await openAuthority(fixture); + await opened.authority.submitInput(inputCommand(fixture), inputRequest); + await expect( + opened.authority.appendExecution( + inputCommand(fixture, { + operation: 'appendExecution', + commandId: 'cmd-unproven', + }), + [modelAttempt(fixture, 3, 'act-999', 999)], + holds('act-999', 999), + ), + ).rejects.toThrow(/no activation is committed/); + await opened.release(); + }); + + it('accepts an append from the committed activation', async () => { + const fixture = await createFixture(); + const opened = await withActivation(fixture); + const receipt = await opened.authority.appendExecution( + inputCommand(fixture, { + operation: 'appendExecution', + commandId: 'cmd-current', + expectedSequence: 3, + }), + [modelAttempt(fixture, 4, 'act-1', 1)], + holds('act-1', 1), + ); + expect(receipt.committedSequence).toBe(4); + await opened.release(); + }); + + it('refuses an append from a superseded activation', async () => { + const fixture = await createFixture(); + const opened = await withActivation(fixture); + await opened.authority.appendExecution( + inputCommand(fixture, { + operation: 'claimActivation', + commandId: 'cmd-act-2', + }), + [ + activationEvent(fixture, { + sequence: 4, + activationId: 'act-2', + epoch: 2, + phase: 'active', + }), + ], + { class: 'coordinator' }, + ); + await expect( + opened.authority.appendExecution( + inputCommand(fixture, { + operation: 'appendExecution', + commandId: 'cmd-stale', + }), + [modelAttempt(fixture, 5, 'act-1', 1)], + holds('act-1', 1), + ), + ).rejects.toThrow(/is not the committed activation act-2\/2/); + await opened.release(); + }); + + it('refuses an append once the activation is released', async () => { + const fixture = await createFixture(); + const opened = await withActivation(fixture); + await opened.authority.appendExecution( + inputCommand(fixture, { + operation: 'releaseActivation', + commandId: 'cmd-release', + }), + [ + activationEvent(fixture, { + sequence: 4, + activationId: 'act-1', + epoch: 1, + phase: 'released', + }), + ], + { class: 'coordinator' }, + ); + await expect( + opened.authority.appendExecution( + inputCommand(fixture, { + operation: 'appendExecution', + commandId: 'cmd-after-release', + }), + [modelAttempt(fixture, 5, 'act-1', 1)], + holds('act-1', 1), + ), + ).rejects.toThrow(/is released and may not append/); + await opened.release(); + }); + + it('refuses an epoch the authority would not assign', async () => { + const fixture = await createFixture(); + const opened = await withActivation(fixture); + await expect( + opened.authority.appendExecution( + inputCommand(fixture, { + operation: 'claimActivation', + commandId: 'cmd-jump', + }), + [ + activationEvent(fixture, { + sequence: 4, + activationId: 'act-9', + epoch: 9, + phase: 'active', + }), + ], + { class: 'coordinator' }, + ), + ).rejects.toThrow(/must use epoch 2, not 9/); + await expect( + opened.authority.appendExecution( + inputCommand(fixture, { + operation: 'releaseActivation', + commandId: 'cmd-rewrite-epoch', + }), + [ + activationEvent(fixture, { + sequence: 4, + activationId: 'act-1', + epoch: 5, + phase: 'released', + }), + ], + { class: 'coordinator' }, + ), + ).rejects.toThrow(/is at epoch 1 and cannot change to 5/); + await opened.release(); + }); + + it('refuses a harness append whose subject names another activation', async () => { + const fixture = await createFixture(); + const opened = await withActivation(fixture); + await expect( + opened.authority.appendExecution( + inputCommand(fixture, { + operation: 'appendExecution', + commandId: 'cmd-mismatch', + }), + [modelAttempt(fixture, 4, 'act-2', 1)], + holds('act-1', 1), + ), + ).rejects.toThrow(/does not match the activation the harness holds/); + await opened.release(); + }); + + it('requires a harness to present the activation it holds', async () => { + const fixture = await createFixture(); + const opened = await withActivation(fixture); + await expect( + opened.authority.appendExecution( + inputCommand(fixture, { + operation: 'appendExecution', + commandId: 'cmd-none', + }), + [modelAttempt(fixture, 4, 'act-1', 1)], + { class: 'harness' }, + ), + ).rejects.toThrow(/must present the activation it holds/); + await opened.release(); + }); + + it('leaves no partial records behind a rejected append', async () => { + const fixture = await createFixture(); + const opened = await withActivation(fixture); + const before = await readLines(fixture); + await expect( + opened.authority.appendExecution( + inputCommand(fixture, { + operation: 'appendExecution', + commandId: 'cmd-rejected', + }), + [modelAttempt(fixture, 4, 'act-2', 2)], + holds('act-2', 2), + ), + ).rejects.toThrow(ManagedSessionConflictError); + expect(await readLines(fixture)).toEqual(before); + await opened.release(); + }); + + describe('activation renewal', () => { + async function openRenewable(fixture: Fixture, now: () => number) { + const lease = await SessionWriterLease.acquire({ + runtimeBaseDir: fixture.runtimeBaseDir, + sessionId: fixture.sessionId, + transcriptPath: fixture.transcriptPath, + }); + try { + const authority = await LocalManagedSessionAuthority.open({ + lease, + sessionKey: sessionKeyFor(fixture), + cwd: '/workspace', + version: 'test', + now, + resources: LocalManagedSessionResourceStore.create({ + runtimeBaseDir: fixture.runtimeBaseDir, + sessionKey: sessionKeyFor(fixture), + }), + create: { + definitionRef: ref('managed-definition'), + rootSnapshotRef: ref('managed-root'), + createdBy: 'daemon', + }, + }); + return { authority, release: () => lease.release() }; + } catch (error) { + await lease.release().catch(() => undefined); + throw error; + } + } + + it('extends the horizon without changing the activation identity', async () => { + const fixture = await createFixture(); + let now = 1_000_000; + const opened = await openRenewable(fixture, () => now); + const installed = await opened.authority.installActivation({ + activationId: 'act-renew', + workerId: 'worker-1', + leaseDurationMs: 60_000, + }); + expect(opened.authority.currentActivation).toMatchObject({ + activationId: 'act-renew', + epoch: installed.epoch, + phase: 'active', + expiresAt: 1_060_000, + renewalSeq: 0, + }); + + now = 1_050_000; + const renewed = await opened.authority.renewActivation({ + leaseDurationMs: 60_000, + }); + expect(renewed).toMatchObject({ + activationId: 'act-renew', + epoch: installed.epoch, + phase: 'active', + expiresAt: 1_110_000, + renewalSeq: 1, + }); + + now = 1_100_000; + await opened.authority.renewActivation({ leaseDurationMs: 60_000 }); + expect(opened.authority.currentActivation).toMatchObject({ + activationId: 'act-renew', + epoch: installed.epoch, + expiresAt: 1_160_000, + renewalSeq: 2, + }); + await opened.release(); + }); + + it('recovers the renewed horizon from a cold reopen', async () => { + const fixture = await createFixture(); + let now = 1_000_000; + const opened = await openRenewable(fixture, () => now); + await opened.authority.installActivation({ + activationId: 'act-renew', + workerId: 'worker-1', + leaseDurationMs: 60_000, + }); + now = 1_050_000; + await opened.authority.renewActivation({ leaseDurationMs: 60_000 }); + await opened.release(); + + const reopened = await openAuthority(fixture, { create: false }); + expect(reopened.authority.currentActivation).toMatchObject({ + activationId: 'act-renew', + phase: 'active', + expiresAt: 1_110_000, + renewalSeq: 1, + }); + await reopened.release(); + }); + + it('does not renew a released activation', async () => { + const fixture = await createFixture(); + const opened = await openRenewable(fixture, () => 1_000_000); + await opened.authority.installActivation({ + activationId: 'act-renew', + workerId: 'worker-1', + leaseDurationMs: 60_000, + }); + await opened.authority.releaseActivation(); + + await expect( + opened.authority.renewActivation({ leaseDurationMs: 60_000 }), + ).resolves.toBeUndefined(); + expect(opened.authority.currentActivation?.phase).toBe('released'); + await opened.release(); + }); + + it('counts only records beyond activation bookkeeping as session content', async () => { + const fixture = await createFixture(); + const opened = await openRenewable(fixture, () => 1_000_000); + expect(opened.authority.hasSessionContent).toBe(false); + await opened.authority.installActivation({ + activationId: 'act-empty', + workerId: 'worker-1', + leaseDurationMs: 60_000, + }); + await opened.authority.releaseActivation(); + expect(opened.authority.hasSessionContent).toBe(false); + + await opened.authority.submitInput(inputCommand(fixture), inputRequest); + expect(opened.authority.hasSessionContent).toBe(true); + await opened.release(); + }); + + it('does not renew when no activation was ever installed', async () => { + const fixture = await createFixture(); + const opened = await openRenewable(fixture, () => 1_000_000); + await expect( + opened.authority.renewActivation({ leaseDurationMs: 60_000 }), + ).resolves.toBeUndefined(); + await opened.release(); + }); + }); + + it('records a tool_call request and recovers the trusted decision', async () => { + const fixture = await createFixture(); + const opened = await withActivation(fixture); + const request = { + requestId: 'req-1', + kind: 'permission', + inputRevision: 1, + optionsRef: null, + }; + await expect( + opened.authority.requestToolAction( + inputCommand(fixture, { + operation: 'requestToolAction', + commandId: 'cmd-action-trusted', + }), + request, + { class: 'trusted_entry' }, + ), + ).rejects.toThrow(/only the current harness/); + await expect( + opened.authority.resolveAction( + inputCommand(fixture, { + operation: 'resolveAction', + commandId: 'cmd-action-early', + }), + { requestId: 'req-1', state: 'decided', decisionRef: ref() }, + ), + ).rejects.toThrow(/has not been requested/); + + const requested = await opened.authority.requestToolAction( + inputCommand(fixture, { + operation: 'requestToolAction', + commandId: 'cmd-action-request', + }), + request, + holds('act-1', 1), + ); + expect(requested.state).toBe('requested'); + expect( + await opened.authority.requestToolAction( + inputCommand(fixture, { + operation: 'requestToolAction', + commandId: 'cmd-action-request-again', + }), + request, + holds('act-1', 1), + ), + ).toEqual(requested); + + const decided = await opened.authority.resolveAction( + inputCommand(fixture, { + operation: 'resolveAction', + commandId: 'cmd-action-decide', + }), + { requestId: 'req-1', state: 'decided', decisionRef: ref() }, + ); + expect(decided.state).toBe('decided'); + expect(decided.decisionRef).toEqual(ref()); + expect( + await opened.authority.resolveAction( + inputCommand(fixture, { + operation: 'resolveAction', + commandId: 'cmd-action-decide-again', + }), + { requestId: 'req-1', state: 'decided', decisionRef: ref() }, + ), + ).toEqual(decided); + + await expect( + opened.authority.resolveAction( + inputCommand(fixture, { + operation: 'resolveAction', + commandId: 'cmd-action-cancel', + }), + { requestId: 'req-1', state: 'cancelled', decisionRef: null }, + ), + ).rejects.toThrow(/already decided/); + await expect( + opened.authority.requestToolAction( + inputCommand(fixture, { + operation: 'requestToolAction', + commandId: 'cmd-action-after', + }), + request, + holds('act-1', 1), + ), + ).rejects.toThrow(/already decided/); + await opened.release(); + + const reopened = await openAuthority(fixture, { create: false }); + expect(reopened.authority.action('req-1')).toMatchObject({ + requestId: 'req-1', + state: 'decided', + }); + expect(reopened.authority.action('req-1')?.decisionRef).toEqual(ref()); + await reopened.release(); + }); +}); + +describe('managed session authority log integrity', () => { + it('blocks opening when a transaction has no commit marker', async () => { + const fixture = await createFixture(); + const opened = await openAuthority(fixture); + await opened.authority.submitInput(inputCommand(fixture), inputRequest); + await opened.release(); + + const lines = await readLines(fixture); + lines.pop(); + await rewrite(fixture, lines); + + await expect(openAuthority(fixture, { create: false })).rejects.toThrow( + ManagedSessionUncommittedTailError, + ); + }); + + it('blocks opening after a crash between the events and the marker', async () => { + const fixture = await createFixture(); + const opened = await openAuthority(fixture); + await opened.release(); + + // Append events the way a crashed transaction would leave them: through + // the lease, so the transcript proof stays consistent, but with no marker. + const lease = await SessionWriterLease.acquire({ + runtimeBaseDir: fixture.runtimeBaseDir, + sessionId: fixture.sessionId, + transcriptPath: fixture.transcriptPath, + }); + await lease.appendJsonLine({ + uuid: 'orphan-1', + parentUuid: null, + sessionId: fixture.sessionId, + timestamp: new Date().toISOString(), + type: 'system', + subtype: 'managed_session_event_v1', + cwd: '/workspace', + version: 'test', + managedSession: { + v: 1, + sequence: 1, + eventId: 'orphan-event', + sessionKey: sessionKeyFor(fixture), + kind: 'input.accepted', + occurredAt: 1, + payload: { + inputId: 'in-orphan', + turnId: 'turn-orphan', + source: 'web_shell', + contentRef: ref(), + deadline: null, + admissionRef: ref(), + }, + }, + }); + await lease.release(); + + await expect(openAuthority(fixture, { create: false })).rejects.toThrow( + ManagedSessionUncommittedTailError, + ); + }); + + it('refuses a marker whose events were altered', async () => { + const fixture = await createFixture(); + const opened = await openAuthority(fixture); + await opened.authority.submitInput(inputCommand(fixture), inputRequest); + await opened.release(); + + const lines = await readLines(fixture); + const at = indexOfSubtype(lines, 'managed_session_event_v1'); + const record = JSON.parse(lines[at]) as Record; + const body = record['managedSession'] as Record; + const payload = body['payload'] as Record; + payload['source'] = 'tampered'; + lines[at] = JSON.stringify(record); + await rewrite(fixture, lines); + + await expect(openAuthority(fixture, { create: false })).rejects.toThrow( + /does not match the preceding event content/, + ); + }); + + it('refuses a log whose records precede the header', async () => { + const fixture = await createFixture(); + const opened = await openAuthority(fixture); + await opened.authority.submitInput(inputCommand(fixture), inputRequest); + await opened.release(); + + const lines = await readLines(fixture); + lines.splice(indexOfSubtype(lines, 'managed_session_header_v1'), 1); + await rewrite(fixture, lines); + + await expect(openAuthority(fixture, { create: false })).rejects.toThrow( + /precedes the Managed header/, + ); + }); + + it('refuses a header belonging to another session', async () => { + const fixture = await createFixture(); + const opened = await openAuthority(fixture); + await opened.release(); + + const lines = await readLines(fixture); + const at = indexOfSubtype(lines, 'managed_session_header_v1'); + const record = JSON.parse(lines[at]) as Record; + const body = record['managedSession'] as Record; + body['sessionKey'] = { + tenantId: 'tenant-1', + workspaceId: 'workspace-2', + sessionId: fixture.sessionId, + }; + lines[at] = JSON.stringify(record); + await rewrite(fixture, lines); + + await expect(openAuthority(fixture, { create: false })).rejects.toThrow( + /belongs to a different session/, + ); + }); + + it('refuses to adopt a transcript that already holds other records', async () => { + const fixture = await createFixture(); + await rewrite(fixture, [ + JSON.stringify({ + uuid: 'legacy-1', + parentUuid: null, + sessionId: fixture.sessionId, + timestamp: new Date().toISOString(), + type: 'user', + }), + ]); + + await expect(openAuthority(fixture)).rejects.toThrow( + /history import is not supported yet/, + ); + }); + + it('requires creation parameters for an empty transcript', async () => { + const fixture = await createFixture(); + await expect(openAuthority(fixture, { create: false })).rejects.toThrow( + ManagedSessionRecordError, + ); + }); + + it('stops accepting work once an append has failed', async () => { + const fixture = await createFixture(); + const lease = await SessionWriterLease.acquire({ + runtimeBaseDir: fixture.runtimeBaseDir, + sessionId: fixture.sessionId, + transcriptPath: fixture.transcriptPath, + }); + const authority = await LocalManagedSessionAuthority.open({ + lease, + sessionKey: sessionKeyFor(fixture), + cwd: '/workspace', + version: 'test', + create: { + definitionRef: ref('managed-definition'), + rootSnapshotRef: ref('managed-root'), + createdBy: 'daemon', + }, + }); + + // Releasing the lease under the authority makes the next append fail for + // real rather than through an injected stub. + await lease.release(); + await expect( + authority.submitInput(inputCommand(fixture), inputRequest), + ).rejects.toThrow(); + + await expect( + authority.submitInput( + inputCommand(fixture, { commandId: 'cmd-after-failure' }), + { ...inputRequest, inputId: 'in-2' }, + ), + ).rejects.toThrow(/writes stopped after an earlier failure/); + }); +}); + +describe('managed session authority serialisation', () => { + it('serialises concurrent transactions into one increasing sequence', async () => { + const fixture = await createFixture(); + const opened = await openAuthority(fixture); + + /* Started together, so neither can observe the other's committed + sequence before choosing its own. */ + const [first, second] = await Promise.all([ + opened.authority.submitInput(inputCommand(fixture), inputRequest), + opened.authority.submitInput( + inputCommand(fixture, { commandId: 'cmd-2' }), + { ...inputRequest, inputId: 'in-2' }, + ), + ]); + await opened.release(); + + expect([ + first.firstSequence, + first.lastSequence, + second.firstSequence, + second.lastSequence, + ]).toEqual([1, 2, 3, 4]); + + const bodies = await readBodies(fixture); + const eventSequences = bodies + .filter((body) => body['sequence'] !== undefined) + .map((body) => body['sequence']); + expect(eventSequences).toEqual([1, 2, 3, 4]); + + const reopened = await openAuthority(fixture, { create: false }); + expect(reopened.authority.committedSequence).toBe(4); + await reopened.release(); + }); + + it('refuses to reuse an event id under a different command id', async () => { + const fixture = await createFixture(); + const opened = await openAuthority(fixture); + await opened.authority.submitInput(inputCommand(fixture), inputRequest); + await expect( + opened.authority.submitInput( + inputCommand(fixture, { commandId: 'cmd-different' }), + inputRequest, + ), + ).rejects.toThrow(/event id in-1:accepted is already committed/); + await opened.release(); + expect(await readLines(fixture)).toHaveLength(5); + }); + + it('refuses an event id repeated inside one transaction', async () => { + const fixture = await createFixture(); + const opened = await openAuthority(fixture); + const duplicate = { + v: 1, + sequence: 1, + eventId: 'evt-same', + sessionKey: sessionKeyFor(fixture), + kind: 'cancel.requested', + occurredAt: 1, + payload: { + requestId: 'req-1', + target: { turnId: 'turn-1' }, + reason: 'user', + requestedBy: 'web_shell', + }, + }; + await expect( + opened.authority.appendExecution( + inputCommand(fixture, { + operation: 'requestCancel', + commandId: 'cmd-dup', + }), + [duplicate, { ...duplicate, sequence: 2 }], + { class: 'trusted_entry' }, + ), + ).rejects.toThrow(/must not repeat an event id/); + await opened.release(); + }); +}); + +describe('managed session authority scan strictness', () => { + it('refuses an unknown record after the header', async () => { + const fixture = await createFixture(); + const opened = await openAuthority(fixture); + await opened.authority.submitInput(inputCommand(fixture), inputRequest); + await opened.release(); + + const lines = await readLines(fixture); + lines.splice( + 2, + 0, + JSON.stringify({ + uuid: 'intruder', + parentUuid: null, + sessionId: fixture.sessionId, + timestamp: new Date().toISOString(), + type: 'user', + }), + ); + await rewrite(fixture, lines); + + await expect(openAuthority(fixture, { create: false })).rejects.toThrow( + /unknown subtype undefined after the Managed header/, + ); + }); + + it('refuses a blank line inside the log', async () => { + const fixture = await createFixture(); + const opened = await openAuthority(fixture); + await opened.authority.submitInput(inputCommand(fixture), inputRequest); + await opened.release(); + + const lines = await readLines(fixture); + lines.splice(2, 0, ''); + await rewrite(fixture, lines); + + await expect(openAuthority(fixture, { create: false })).rejects.toThrow( + /is blank/, + ); + }); +}); + +describe('managed session uncommitted tail recovery', () => { + async function seedTornTail(fixture: Fixture): Promise { + const opened = await openAuthority(fixture); + await opened.authority.submitInput(inputCommand(fixture), inputRequest); + await opened.release(); + const committedBytes = (await fs.stat(fixture.transcriptPath)).size; + + /* Append an event through a lease with no marker, the way a crash between + the records and the marker would leave it. */ + const lease = await SessionWriterLease.acquire({ + runtimeBaseDir: fixture.runtimeBaseDir, + sessionId: fixture.sessionId, + transcriptPath: fixture.transcriptPath, + }); + await lease.appendJsonLine({ + uuid: 'orphan-1', + parentUuid: null, + sessionId: fixture.sessionId, + timestamp: new Date().toISOString(), + type: 'system', + subtype: 'managed_session_event_v1', + cwd: '/workspace', + version: 'test', + managedSession: { + v: 1, + sequence: 3, + eventId: 'orphan-event', + sessionKey: sessionKeyFor(fixture), + kind: 'cancel.requested', + occurredAt: 1, + payload: { + requestId: 'req-orphan', + target: { turnId: 'turn-1' }, + reason: 'user', + requestedBy: 'web_shell', + }, + }, + }); + await lease.release(); + return committedBytes; + } + + it('discards the tail and reopens on the committed prefix', async () => { + const fixture = await createFixture(); + const committedBytes = await seedTornTail(fixture); + const before = await readLines(fixture); + expect(before).toHaveLength(6); + + const lease = await SessionWriterLease.acquire({ + runtimeBaseDir: fixture.runtimeBaseDir, + sessionId: fixture.sessionId, + transcriptPath: fixture.transcriptPath, + }); + await expect( + LocalManagedSessionAuthority.open({ + lease, + sessionKey: sessionKeyFor(fixture), + cwd: '/workspace', + version: 'test', + }), + ).rejects.toThrow(ManagedSessionUncommittedTailError); + + const recovered = await LocalManagedSessionAuthority.recoverUncommittedTail( + { lease, sessionKey: sessionKeyFor(fixture) }, + ); + expect(recovered.discardedBytes).toBeGreaterThan(0); + + const authority = await LocalManagedSessionAuthority.open({ + lease, + sessionKey: sessionKeyFor(fixture), + cwd: '/workspace', + version: 'test', + }); + expect(authority.committedSequence).toBe(2); + expect(authority.readEvents().map((event) => event.kind)).toEqual([ + 'input.accepted', + 'wake.requested', + ]); + + /* The writer stays usable: its pinned proof was rebuilt, so the next + transaction continues from the recovered tail. */ + const receipt = await authority.submitInput( + inputCommand(fixture, { commandId: 'cmd-after-recovery' }), + { ...inputRequest, inputId: 'in-after' }, + ); + expect(receipt.firstSequence).toBe(3); + await lease.release(); + + expect((await fs.stat(fixture.transcriptPath)).size).toBeGreaterThan( + committedBytes, + ); + const after = await readLines(fixture); + expect(after.filter((line) => line.includes('orphan-event'))).toHaveLength( + 0, + ); + + const reopened = await openAuthority(fixture, { create: false }); + expect(reopened.authority.committedSequence).toBe(4); + await reopened.release(); + }); + + it('keeps the discarded bytes for diagnosis', async () => { + const fixture = await createFixture(); + await seedTornTail(fixture); + const lease = await SessionWriterLease.acquire({ + runtimeBaseDir: fixture.runtimeBaseDir, + sessionId: fixture.sessionId, + transcriptPath: fixture.transcriptPath, + }); + const recovered = await LocalManagedSessionAuthority.recoverUncommittedTail( + { lease, sessionKey: sessionKeyFor(fixture) }, + ); + await lease.release(); + + const kept = await fs.readFile(recovered.diagnosticPath, 'utf8'); + expect(kept).toContain('orphan-event'); + expect(Buffer.byteLength(kept, 'utf8')).toBe(recovered.discardedBytes); + }); + + it('refuses to recover a log with nothing uncommitted', async () => { + const fixture = await createFixture(); + const opened = await openAuthority(fixture); + await opened.authority.submitInput(inputCommand(fixture), inputRequest); + await opened.release(); + + const lease = await SessionWriterLease.acquire({ + runtimeBaseDir: fixture.runtimeBaseDir, + sessionId: fixture.sessionId, + transcriptPath: fixture.transcriptPath, + }); + await expect( + LocalManagedSessionAuthority.recoverUncommittedTail({ + lease, + sessionKey: sessionKeyFor(fixture), + }), + ).rejects.toThrow(/no uncommitted tail to discard/); + await lease.release(); + }); + + it('refuses a truncation past the end of the transcript', async () => { + const fixture = await createFixture(); + const opened = await openAuthority(fixture); + await opened.authority.submitInput(inputCommand(fixture), inputRequest); + await opened.release(); + + const size = (await fs.stat(fixture.transcriptPath)).size; + const lease = await SessionWriterLease.acquire({ + runtimeBaseDir: fixture.runtimeBaseDir, + sessionId: fixture.sessionId, + transcriptPath: fixture.transcriptPath, + }); + await expect(lease.truncateTo(size + 1)).rejects.toThrow(); + await expect(lease.truncateTo(-1)).rejects.toThrow(); + expect((await fs.stat(fixture.transcriptPath)).size).toBe(size); + await lease.release(); + }); +}); + +describe('managed session engine ownership', () => { + it('records managed ownership so legacy-only operations refuse the session', async () => { + const fixture = await createFixture(); + const opened = await openAuthority(fixture); + await opened.authority.submitInput(inputCommand(fixture), inputRequest); + await opened.release(); + + const snapshot = await readSessionTranscriptSnapshot( + fixture.transcriptPath, + fixture.sessionId, + ); + expect(snapshot?.executionEngine).toMatchObject({ + status: 'verified', + engine: 'managed', + recorded: true, + }); + + /* Without this record the reader reports a verified legacy session, and + fork, rename and the config guards would all operate on it. */ + expect(() => + assertSessionExecutionEngine( + snapshot?.executionEngine, + fixture.sessionId, + 'legacy', + ), + ).toThrow(/belongs to managed/); + expect(() => + assertSessionExecutionEngine( + snapshot?.executionEngine, + fixture.sessionId, + 'managed', + ), + ).not.toThrow(); + }); + + it('completes a create interrupted between the engine record and the header', async () => { + const fixture = await createFixture(); + const lease = await SessionWriterLease.acquire({ + runtimeBaseDir: fixture.runtimeBaseDir, + sessionId: fixture.sessionId, + transcriptPath: fixture.transcriptPath, + }); + await lease.appendJsonLine({ + uuid: 'engine-only', + parentUuid: null, + sessionId: fixture.sessionId, + timestamp: new Date().toISOString(), + type: 'system', + subtype: 'session_execution_engine', + cwd: '/workspace', + version: 'test', + systemPayload: { version: 1, engine: 'managed' }, + }); + await lease.release(); + + const opened = await openAuthority(fixture); + expect(opened.authority.sessionHeader.engine).toBe('managed'); + await opened.release(); + + const subtypes = (await readRecords(fixture)).map( + (record) => record['subtype'], + ); + expect(subtypes).toEqual([ + 'session_execution_engine', + 'managed_session_header_v1', + ]); + }); +}); + +describe('managed session first transaction recovery', () => { + it('retains the header when the first transaction never committed', async () => { + const fixture = await createFixture(); + const created = await openAuthority(fixture); + await created.release(); + const prefixSize = (await fs.stat(fixture.transcriptPath)).size; + + /* No commit marker exists yet, so the committed sequence is still zero. + Truncating to that offset would delete the header and leave a session + that can never be opened again. */ + const crashed = await SessionWriterLease.acquire({ + runtimeBaseDir: fixture.runtimeBaseDir, + sessionId: fixture.sessionId, + transcriptPath: fixture.transcriptPath, + }); + await crashed.appendJsonLine({ + uuid: 'first-orphan', + parentUuid: null, + sessionId: fixture.sessionId, + timestamp: new Date().toISOString(), + type: 'system', + subtype: 'managed_session_event_v1', + cwd: '/workspace', + version: 'test', + managedSession: { + v: 1, + sequence: 1, + eventId: 'first-orphan-event', + sessionKey: sessionKeyFor(fixture), + kind: 'input.accepted', + occurredAt: 1, + payload: { + inputId: 'in-orphan', + turnId: 'turn-orphan', + source: 'web_shell', + contentRef: ref(), + deadline: null, + admissionRef: ref(), + }, + }, + }); + await crashed.release(); + + const lease = await SessionWriterLease.acquire({ + runtimeBaseDir: fixture.runtimeBaseDir, + sessionId: fixture.sessionId, + transcriptPath: fixture.transcriptPath, + }); + const recovered = await LocalManagedSessionAuthority.recoverUncommittedTail( + { lease, sessionKey: sessionKeyFor(fixture) }, + ); + expect(recovered.discardedBytes).toBeGreaterThan(0); + + expect((await fs.stat(fixture.transcriptPath)).size).toBe(prefixSize); + expect((await readRecords(fixture)).map((r) => r['subtype'])).toEqual([ + 'session_execution_engine', + 'managed_session_header_v1', + ]); + + const authority = await LocalManagedSessionAuthority.open({ + lease, + sessionKey: sessionKeyFor(fixture), + cwd: '/workspace', + version: 'test', + }); + expect(authority.committedSequence).toBe(0); + expect(authority.sessionHeader.engine).toBe('managed'); + const receipt = await authority.submitInput( + inputCommand(fixture), + inputRequest, + ); + expect(receipt.firstSequence).toBe(1); + await lease.release(); + }); + + it('refuses recovery when there is no prefix to retain', async () => { + const fixture = await createFixture(); + const lease = await SessionWriterLease.acquire({ + runtimeBaseDir: fixture.runtimeBaseDir, + sessionId: fixture.sessionId, + transcriptPath: fixture.transcriptPath, + }); + await lease.appendJsonLine({ + uuid: 'headerless', + parentUuid: null, + sessionId: fixture.sessionId, + timestamp: new Date().toISOString(), + type: 'system', + subtype: 'managed_session_commit_v1', + cwd: '/workspace', + version: 'test', + managedSession: { + transactionId: 'tx-1', + commandId: 'cmd-1', + operation: 'submitInput', + contentDigest: DIGEST, + firstSequence: 1, + lastSequence: 1, + eventCount: 1, + eventsDigest: DIGEST, + previousCommitDigest: null, + }, + }); + await expect( + LocalManagedSessionAuthority.recoverUncommittedTail({ + lease, + sessionKey: sessionKeyFor(fixture), + }), + ).rejects.toThrow(/precedes the Managed header/); + await lease.release(); + }); +}); + +describe('managed session write barrier at rest', () => { + async function seedSealedSession(fixture: Fixture): Promise { + const lease = await LocalManagedSessionAuthority.acquireWriter({ + runtimeBaseDir: fixture.runtimeBaseDir, + sessionId: fixture.sessionId, + transcriptPath: fixture.transcriptPath, + }); + const authority = await LocalManagedSessionAuthority.open({ + lease, + sessionKey: sessionKeyFor(fixture), + cwd: '/workspace', + version: 'test', + create: { + definitionRef: ref('managed-definition'), + rootSnapshotRef: ref('managed-root'), + createdBy: 'daemon', + }, + }); + await authority.submitInput(inputCommand(fixture), inputRequest); + await authority.close(); + } + + it('declines a writer that cannot take over the seal', async () => { + const fixture = await createFixture(); + await seedSealedSession(fixture); + + /* Releasing would have removed the lock outright, letting any writer + acquire the transcript and append legacy records into the authoritative + log -- after which the authority could not reopen it at all. */ + await expect( + SessionWriterLease.acquire({ + runtimeBaseDir: fixture.runtimeBaseDir, + sessionId: fixture.sessionId, + transcriptPath: fixture.transcriptPath, + }), + ).rejects.toThrow(/conflict|in use|another/i); + + expect(await readLines(fixture)).toHaveLength(5); + }); + + it('lets the managed writer take over its own seal and continue', async () => { + const fixture = await createFixture(); + await seedSealedSession(fixture); + + const lease = await LocalManagedSessionAuthority.acquireWriter({ + runtimeBaseDir: fixture.runtimeBaseDir, + sessionId: fixture.sessionId, + transcriptPath: fixture.transcriptPath, + }); + const authority = await LocalManagedSessionAuthority.open({ + lease, + sessionKey: sessionKeyFor(fixture), + cwd: '/workspace', + version: 'test', + }); + expect(authority.committedSequence).toBe(2); + const receipt = await authority.submitInput( + inputCommand(fixture, { commandId: 'cmd-after-seal' }), + { ...inputRequest, inputId: 'in-after-seal' }, + ); + expect(receipt.firstSequence).toBe(3); + await authority.close(); + + const reopenLease = await LocalManagedSessionAuthority.acquireWriter({ + runtimeBaseDir: fixture.runtimeBaseDir, + sessionId: fixture.sessionId, + transcriptPath: fixture.transcriptPath, + }); + const reopened = await LocalManagedSessionAuthority.open({ + lease: reopenLease, + sessionKey: sessionKeyFor(fixture), + cwd: '/workspace', + version: 'test', + }); + expect(reopened.committedSequence).toBe(4); + await reopened.close(); + }); + + it('refuses a takeover when the sealed transcript was altered', async () => { + const fixture = await createFixture(); + await seedSealedSession(fixture); + + await fs.appendFile( + fixture.transcriptPath, + `${JSON.stringify({ + uuid: 'intruder', + parentUuid: null, + sessionId: fixture.sessionId, + timestamp: new Date().toISOString(), + type: 'user', + })}\n`, + 'utf8', + ); + + await expect( + LocalManagedSessionAuthority.acquireWriter({ + runtimeBaseDir: fixture.runtimeBaseDir, + sessionId: fixture.sessionId, + transcriptPath: fixture.transcriptPath, + }), + ).rejects.toThrow(); + }); +}); + +describe('managed session checkpoints', () => { + const HOLDS = { + class: 'harness', + activation: { activationId: 'act-1', epoch: 1 }, + } as const; + + interface CheckpointHarness { + fixture: Fixture; + authority: LocalManagedSessionAuthority; + store: LocalManagedSessionResourceStore; + close(): Promise; + } + + async function openWithResources( + fixture: Fixture, + options: { create?: boolean } = {}, + ): Promise { + const store = LocalManagedSessionResourceStore.create({ + runtimeBaseDir: fixture.runtimeBaseDir, + sessionKey: sessionKeyFor(fixture), + }); + const lease = await LocalManagedSessionAuthority.acquireWriter({ + runtimeBaseDir: fixture.runtimeBaseDir, + sessionId: fixture.sessionId, + transcriptPath: fixture.transcriptPath, + }); + const authority = await LocalManagedSessionAuthority.open({ + lease, + sessionKey: sessionKeyFor(fixture), + cwd: '/workspace', + version: 'test', + resources: store, + ...(options.create === false + ? {} + : { + create: { + definitionRef: ref('managed-definition'), + rootSnapshotRef: ref('managed-root'), + createdBy: 'daemon', + }, + }), + }); + return { + fixture, + authority, + store, + close: () => authority.close(), + }; + } + + async function activate( + harness: CheckpointHarness, + sequence: number, + ): Promise { + await harness.authority.appendExecution( + inputCommand(harness.fixture, { + operation: 'claimActivation', + commandId: `cmd-act-${sequence}`, + }), + [ + { + v: 1, + sequence, + eventId: `evt-act-${sequence}`, + sessionKey: sessionKeyFor(harness.fixture), + kind: 'activation.changed', + occurredAt: 1, + payload: { + activationId: 'act-1', + epoch: 1, + workerId: 'worker-1', + subject: { + type: 'activation', + scopeId: 'act-1', + activationId: 'act-1', + epoch: 1, + }, + phase: 'active', + leaseDurationMs: 60_000, + expiresAt: 2, + installRef: ref(), + boundaryRef: null, + }, + }, + ], + { class: 'coordinator' }, + ); + } + + function modelAttempt(fixture: Fixture, sequence: number) { + return { + v: 1, + sequence, + eventId: `evt-model-${sequence}`, + sessionKey: sessionKeyFor(fixture), + kind: 'model.attempt', + occurredAt: 1, + subject: { + type: 'activation', + scopeId: 'act-1', + activationId: 'act-1', + epoch: 1, + }, + payload: { + attemptId: `att-${sequence}`, + routeRef: ref(), + inputCheckpointRef: null, + state: 'started', + usageRef: null, + }, + }; + } + + it('treats a session with no execution as an initial basis', async () => { + const harness = await openWithResources(await createFixture()); + expect(harness.authority.restoreBasis()).toBe('initial'); + expect(harness.authority.latestCheckpoint).toBeUndefined(); + await harness.authority.submitInput( + inputCommand(harness.fixture), + inputRequest, + ); + /* Accepted input is not execution continuation. */ + expect(harness.authority.restoreBasis()).toBe('initial'); + await expect(harness.authority.restoreBundle()).resolves.toMatchObject({ + formatVersion: MANAGED_SESSION_FORMAT_VERSION, + sessionKey: sessionKeyFor(harness.fixture), + engine: 'managed', + restoreBasis: 'initial', + checkpointRef: null, + restoreProofRef: null, + recoveryStatus: 'ok', + }); + await harness.close(); + }); + + it('blocks recovery when execution ran without a checkpoint', async () => { + const harness = await openWithResources(await createFixture()); + await harness.authority.submitInput( + inputCommand(harness.fixture), + inputRequest, + ); + await activate(harness, 3); + await harness.authority.appendExecution( + inputCommand(harness.fixture, { + operation: 'appendExecution', + commandId: 'cmd-model', + }), + [modelAttempt(harness.fixture, 4)], + HOLDS, + ); + expect(harness.authority.restoreBasis()).toBe('blocked'); + await expect(harness.authority.restoreBundle()).resolves.toMatchObject({ + restoreBasis: null, + checkpointRef: null, + restoreProofRef: null, + recoveryStatus: 'blocked', + }); + await harness.close(); + }); + + it('commits a checkpoint and reads its state back', async () => { + const harness = await openWithResources(await createFixture()); + await harness.authority.submitInput( + inputCommand(harness.fixture), + inputRequest, + ); + await activate(harness, 3); + + const state = Buffer.from('{"turn":1,"pending":[]}', 'utf8'); + const committed = await harness.authority.commitCheckpoint( + inputCommand(harness.fixture, { + operation: 'commitCheckpoint', + commandId: 'cmd-ckpt-1', + }), + { state, boundary: null }, + HOLDS, + ); + expect(committed.checkpoint.coveredSequence).toBe(3); + expect(committed.checkpoint.previousCheckpointId).toBeNull(); + expect(committed.checkpoint.boundary).toBeNull(); + expect(harness.authority.restoreBasis()).toBe('checkpoint'); + expect(await harness.authority.readCheckpointState()).toEqual(state); + await harness.close(); + }); + + it('chains each checkpoint and recovers the newest after a cold reopen', async () => { + const fixture = await createFixture(); + const first = await openWithResources(fixture); + await first.authority.submitInput(inputCommand(fixture), inputRequest); + await activate(first, 3); + const one = await first.authority.commitCheckpoint( + inputCommand(fixture, { + operation: 'commitCheckpoint', + commandId: 'cmd-ckpt-1', + }), + { state: Buffer.from('first', 'utf8'), boundary: null }, + HOLDS, + ); + const two = await first.authority.commitCheckpoint( + inputCommand(fixture, { + operation: 'commitCheckpoint', + commandId: 'cmd-ckpt-2', + }), + { state: Buffer.from('second', 'utf8'), boundary: 'before_model' }, + HOLDS, + ); + expect(two.checkpoint.previousCheckpointId).toBe( + one.checkpoint.checkpointId, + ); + await first.close(); + + const reopened = await openWithResources(fixture, { create: false }); + expect(reopened.authority.restoreBasis()).toBe('checkpoint'); + expect(reopened.authority.latestCheckpoint?.checkpointId).toBe( + two.checkpoint.checkpointId, + ); + expect(reopened.authority.latestCheckpoint?.boundary).toBe('before_model'); + expect(await reopened.authority.readCheckpointState()).toEqual( + Buffer.from('second', 'utf8'), + ); + await reopened.close(); + }); + + it('lets only the current harness commit a checkpoint', async () => { + const harness = await openWithResources(await createFixture()); + await harness.authority.submitInput( + inputCommand(harness.fixture), + inputRequest, + ); + await activate(harness, 3); + await expect( + harness.authority.commitCheckpoint( + inputCommand(harness.fixture, { + operation: 'commitCheckpoint', + commandId: 'cmd-ckpt-entry', + }), + { state: Buffer.from('x', 'utf8'), boundary: null }, + { class: 'trusted_entry' }, + ), + ).rejects.toThrow(/only the current harness/); + await expect( + harness.authority.commitCheckpoint( + inputCommand(harness.fixture, { + operation: 'commitCheckpoint', + commandId: 'cmd-ckpt-stale', + }), + { state: Buffer.from('x', 'utf8'), boundary: null }, + { class: 'harness', activation: { activationId: 'act-9', epoch: 1 } }, + ), + ).rejects.toThrow(/is not the committed activation/); + await harness.close(); + }); + + it('surfaces a missing checkpoint body instead of an empty state', async () => { + const harness = await openWithResources(await createFixture()); + await harness.authority.submitInput( + inputCommand(harness.fixture), + inputRequest, + ); + await activate(harness, 3); + const committed = await harness.authority.commitCheckpoint( + inputCommand(harness.fixture, { + operation: 'commitCheckpoint', + commandId: 'cmd-ckpt-1', + }), + { state: Buffer.from('state', 'utf8'), boundary: null }, + HOLDS, + ); + await fs.rm( + path.join( + harness.store.sessionRoot, + committed.checkpoint.stateRef.kind, + committed.checkpoint.stateRef.resourceId, + ), + ); + await expect(harness.authority.readCheckpointState()).rejects.toThrow( + /is not present for session/, + ); + await expect(harness.authority.harnessRunAuthorization()).resolves.toEqual({ + status: 'blocked', + reason: 'missing_state', + message: expect.stringMatching(/is not present for session/), + }); + await expect(harness.authority.restoreBundle()).resolves.toMatchObject({ + restoreBasis: 'checkpoint', + checkpointRef: committed.checkpoint.stateRef, + restoreProofRef: null, + recoveryStatus: 'blocked', + }); + await harness.close(); + }); + + function initialV1State( + fixture: Fixture, + overrides: Partial< + Parameters[0] + > = {}, + ) { + return encodeHarnessCheckpointV1( + createInitialHarnessCheckpoint({ + sessionKey: sessionKeyFor(fixture), + checkpointId: 'ckpt-4', + coveredSequence: 3, + activationId: 'act-1', + turnId: 'turn-1', + promptId: null, + definitionRevision: 'def-1', + configRevision: 'cfg-1', + inputDigest: DIGEST, + previousCheckpointId: null, + ...overrides, + }), + ); + } + + it('does not treat an opaque checkpoint blob as runnable', async () => { + const harness = await openWithResources(await createFixture()); + await harness.authority.submitInput( + inputCommand(harness.fixture), + inputRequest, + ); + await activate(harness, 3); + await harness.authority.commitCheckpoint( + inputCommand(harness.fixture, { + operation: 'commitCheckpoint', + commandId: 'cmd-ckpt-opaque', + }), + { state: Buffer.from('first', 'utf8'), boundary: null }, + HOLDS, + ); + expect(harness.authority.restoreBasis()).toBe('checkpoint'); + await expect(harness.authority.harnessRunAuthorization()).resolves.toEqual({ + status: 'blocked', + reason: 'opaque_state', + message: expect.stringMatching(/JSON/), + }); + await expect(harness.authority.restoreBundle()).resolves.toMatchObject({ + restoreBasis: 'checkpoint', + restoreProofRef: null, + recoveryStatus: 'blocked', + }); + expect( + (await harness.authority.restoreBundle()).checkpointRef, + ).not.toBeNull(); + await harness.close(); + }); + + it('authorizes a matching nine-group v1 checkpoint as runnable', async () => { + const harness = await openWithResources(await createFixture()); + await harness.authority.submitInput( + inputCommand(harness.fixture), + inputRequest, + ); + await activate(harness, 3); + const checkpoint = createInitialHarnessCheckpoint({ + sessionKey: sessionKeyFor(harness.fixture), + checkpointId: 'ckpt-4', + coveredSequence: 3, + activationId: 'act-1', + turnId: 'turn-1', + promptId: null, + definitionRevision: 'def-1', + configRevision: 'cfg-1', + inputDigest: DIGEST, + previousCheckpointId: null, + }); + const committed = await harness.authority.commitCheckpoint( + inputCommand(harness.fixture, { + operation: 'commitCheckpoint', + commandId: 'cmd-ckpt-v1', + }), + { state: encodeHarnessCheckpointV1(checkpoint), boundary: null }, + HOLDS, + ); + expect(committed.checkpoint.checkpointId).toBe('ckpt-4'); + expect(committed.checkpoint.coveredSequence).toBe(3); + expect(harness.authority.restoreBasis()).toBe('checkpoint'); + await expect(harness.authority.harnessRunAuthorization()).resolves.toEqual({ + status: 'runnable', + checkpoint, + }); + await expect(harness.authority.restoreBundle()).resolves.toMatchObject({ + restoreBasis: 'checkpoint', + checkpointRef: committed.checkpoint.stateRef, + restoreProofRef: null, + recoveryStatus: 'ok', + }); + await harness.close(); + }); + + it('aligns stale v1 coverage to the assigned checkpoint identity', async () => { + const harness = await openWithResources(await createFixture()); + await harness.authority.submitInput( + inputCommand(harness.fixture), + inputRequest, + ); + await activate(harness, 3); + const committed = await harness.authority.commitCheckpoint( + inputCommand(harness.fixture, { + operation: 'commitCheckpoint', + commandId: 'cmd-ckpt-stale-coverage', + }), + { + state: initialV1State(harness.fixture, { + checkpointId: 'ckpt-99', + coveredSequence: 0, + previousCheckpointId: 'ckpt-1', + }), + boundary: null, + }, + HOLDS, + ); + expect(committed.checkpoint.checkpointId).toBe('ckpt-4'); + expect(committed.checkpoint.coveredSequence).toBe(3); + expect(committed.checkpoint.previousCheckpointId).toBeNull(); + const parsed = parseHarnessCheckpointV1( + (await harness.authority.readCheckpointState())!, + ); + expect(parsed.identity.checkpointId).toBe('ckpt-4'); + expect(parsed.identity.coveredSequence).toBe(3); + expect(parsed.identity.previousCheckpointId).toBeNull(); + expect(parsed.resume.throughSequence).toBe(3); + await expect( + harness.authority.harnessRunAuthorization(), + ).resolves.toMatchObject({ + status: 'runnable', + checkpoint: parsed, + }); + await harness.close(); + }); + + it('blocks a v1 checkpoint whose sessionKey does not match', async () => { + const harness = await openWithResources(await createFixture()); + await harness.authority.submitInput( + inputCommand(harness.fixture), + inputRequest, + ); + await activate(harness, 3); + await harness.authority.commitCheckpoint( + inputCommand(harness.fixture, { + operation: 'commitCheckpoint', + commandId: 'cmd-ckpt-mismatch', + }), + { + state: initialV1State(harness.fixture, { + sessionKey: { + ...sessionKeyFor(harness.fixture), + sessionId: 'other-session', + }, + }), + boundary: null, + }, + HOLDS, + ); + expect(harness.authority.restoreBasis()).toBe('checkpoint'); + await expect( + harness.authority.harnessRunAuthorization(), + ).resolves.toMatchObject({ + status: 'blocked', + reason: 'identity_mismatch', + }); + await harness.close(); + }); + + it('blocks a versioned but unparseable checkpoint as invalid_state', async () => { + const harness = await openWithResources(await createFixture()); + await harness.authority.submitInput( + inputCommand(harness.fixture), + inputRequest, + ); + await activate(harness, 3); + await harness.authority.commitCheckpoint( + inputCommand(harness.fixture, { + operation: 'commitCheckpoint', + commandId: 'cmd-ckpt-invalid', + }), + { + state: Buffer.from( + JSON.stringify({ identity: { schemaVersion: 1 } }), + 'utf8', + ), + boundary: null, + }, + HOLDS, + ); + expect(harness.authority.restoreBasis()).toBe('checkpoint'); + await expect(harness.authority.harnessRunAuthorization()).resolves.toEqual({ + status: 'blocked', + reason: 'invalid_state', + message: expect.stringMatching(/identity/), + }); + await harness.close(); + }); + + it('authorizes an unused session as initial, not runnable', async () => { + const harness = await openWithResources(await createFixture()); + expect(harness.authority.restoreBasis()).toBe('initial'); + await expect(harness.authority.harnessRunAuthorization()).resolves.toEqual({ + status: 'initial', + }); + await harness.authority.submitInput( + inputCommand(harness.fixture), + inputRequest, + ); + await expect(harness.authority.harnessRunAuthorization()).resolves.toEqual({ + status: 'initial', + }); + await harness.close(); + }); + + it('blocks continuation without a checkpoint as missing_checkpoint', async () => { + const harness = await openWithResources(await createFixture()); + await harness.authority.submitInput( + inputCommand(harness.fixture), + inputRequest, + ); + await activate(harness, 3); + await harness.authority.appendExecution( + inputCommand(harness.fixture, { + operation: 'appendExecution', + commandId: 'cmd-model', + }), + [modelAttempt(harness.fixture, 4)], + HOLDS, + ); + expect(harness.authority.restoreBasis()).toBe('blocked'); + await expect(harness.authority.harnessRunAuthorization()).resolves.toEqual({ + status: 'blocked', + reason: 'missing_checkpoint', + }); + await harness.close(); + }); + + it('commits turn.settled with the next-turn checkpoint in one transaction', async () => { + const harness = await openWithResources(await createFixture()); + await harness.authority.submitInput( + inputCommand(harness.fixture), + inputRequest, + ); + await activate(harness, 3); + const first = createInitialHarnessCheckpoint({ + sessionKey: sessionKeyFor(harness.fixture), + checkpointId: 'ckpt-4', + coveredSequence: 3, + activationId: 'act-1', + turnId: null, + promptId: null, + definitionRevision: 'def-1', + configRevision: 'cfg-1', + inputDigest: DIGEST, + previousCheckpointId: null, + }); + await harness.authority.commitCheckpoint( + inputCommand(harness.fixture, { + operation: 'commitCheckpoint', + commandId: 'cmd-ckpt-v1', + }), + { state: encodeHarnessCheckpointV1(first), boundary: null }, + HOLDS, + ); + const resultRef = await harness.store.publish( + 'managed-turn-result', + Buffer.from('{"state":"completed"}', 'utf8'), + ); + const committed = await harness.authority.commitTurnComplete( + inputCommand(harness.fixture, { + operation: 'settleTurn', + commandId: 'cmd-turn-1', + }), + { + turn: { + turnId: 'turn-1', + outcome: 'completed', + stopReason: 'end_turn', + resultRef, + occurredAt: 1, + eventId: 'turn:turn-1', + }, + boundary: HARNESS_TURN_COMPLETE_BOUNDARY, + state: (identity, previous) => + encodeHarnessCheckpointV1( + createNextTurnReadyHarnessCheckpoint({ + previous, + ...identity, + activationId: HOLDS.activation.activationId, + turnId: 'turn-1', + promptId: 'turn-1', + }), + ), + }, + HOLDS, + ); + + expect(committed.receipt.firstSequence).toBe(5); + expect(committed.receipt.lastSequence).toBe(6); + expect(committed.checkpoint.checkpointId).toBe('ckpt-6'); + expect(committed.checkpoint.coveredSequence).toBe(4); + expect(committed.checkpoint.previousCheckpointId).toBe('ckpt-4'); + expect(committed.checkpoint.boundary).toBe(HARNESS_TURN_COMPLETE_BOUNDARY); + + const events = harness.authority.readEvents(); + expect(events[4]?.kind).toBe('turn.settled'); + expect(events[5]?.kind).toBe('checkpoint.committed'); + expect( + parseHarnessCheckpointV1( + (await harness.authority.readCheckpointState())!, + ), + ).toMatchObject({ + continuation: { phase: 'before_model' }, + identity: { + checkpointId: 'ckpt-6', + coveredSequence: 4, + previousCheckpointId: 'ckpt-4', + turnId: 'turn-1', + }, + }); + await expect( + harness.authority.harnessRunAuthorization(), + ).resolves.toMatchObject({ status: 'runnable' }); + await harness.close(); + }); + + it('does not treat an opaque checkpoint as a turn-complete safety point', async () => { + const harness = await openWithResources(await createFixture()); + await harness.authority.submitInput( + inputCommand(harness.fixture), + inputRequest, + ); + await activate(harness, 3); + await harness.authority.commitCheckpoint( + inputCommand(harness.fixture, { + operation: 'commitCheckpoint', + commandId: 'cmd-ckpt-opaque', + }), + { state: Buffer.from('first', 'utf8'), boundary: null }, + HOLDS, + ); + const before = harness.authority.committedSequence; + await expect( + harness.authority.commitTurnComplete( + inputCommand(harness.fixture, { + operation: 'settleTurn', + commandId: 'cmd-turn-opaque', + }), + { + turn: { + turnId: 'turn-1', + outcome: 'completed', + stopReason: null, + resultRef: ref(), + occurredAt: 1, + eventId: 'turn:turn-1', + }, + boundary: HARNESS_TURN_COMPLETE_BOUNDARY, + state: () => Buffer.from('healed', 'utf8'), + }, + HOLDS, + ), + ).rejects.toThrow(/requires a runnable Harness checkpoint/); + expect(harness.authority.committedSequence).toBe(before); + expect(harness.authority.latestCheckpoint?.checkpointId).toBe('ckpt-4'); + expect( + harness.authority + .readEvents() + .some((event) => event.kind === 'turn.settled'), + ).toBe(false); + await expect(harness.authority.harnessRunAuthorization()).resolves.toEqual({ + status: 'blocked', + reason: 'opaque_state', + message: expect.stringMatching(/JSON/), + }); + await harness.close(); + }); + + it('rejects a turn-complete body that is not a matching v1 checkpoint', async () => { + const harness = await openWithResources(await createFixture()); + await harness.authority.submitInput( + inputCommand(harness.fixture), + inputRequest, + ); + await activate(harness, 3); + await harness.authority.commitCheckpoint( + inputCommand(harness.fixture, { + operation: 'commitCheckpoint', + commandId: 'cmd-ckpt-v1', + }), + { state: initialV1State(harness.fixture), boundary: null }, + HOLDS, + ); + const before = harness.authority.committedSequence; + await expect( + harness.authority.commitTurnComplete( + inputCommand(harness.fixture, { + operation: 'settleTurn', + commandId: 'cmd-turn-bad-state', + }), + { + turn: { + turnId: 'turn-1', + outcome: 'completed', + stopReason: null, + resultRef: ref(), + occurredAt: 1, + eventId: 'turn:turn-1', + }, + boundary: HARNESS_TURN_COMPLETE_BOUNDARY, + state: () => Buffer.from('healed', 'utf8'), + }, + HOLDS, + ), + ).rejects.toThrow(/JSON/); + expect(harness.authority.committedSequence).toBe(before); + expect(harness.authority.latestCheckpoint?.checkpointId).toBe('ckpt-4'); + expect( + harness.authority + .readEvents() + .some((event) => event.kind === 'turn.settled'), + ).toBe(false); + await harness.close(); + }); +}); + +describe('assertManagedSessionRestoreBundle', () => { + const sessionKey = { + tenantId: 't1', + workspaceId: 'w1', + sessionId: 's1', + }; + + function bundle( + overrides: Partial, + ): ManagedSessionRestoreBundle { + return { + formatVersion: MANAGED_SESSION_FORMAT_VERSION, + sessionKey, + engine: 'managed', + throughSequence: 0, + checkpointRef: null, + restoreBasis: 'initial', + restoreProofRef: null, + recoveryStatus: 'ok', + ...overrides, + }; + } + + it('accepts the legal initial, checkpoint, and history-maintenance combinations', () => { + expect(() => assertManagedSessionRestoreBundle(bundle({}))).not.toThrow(); + expect(() => + assertManagedSessionRestoreBundle( + bundle({ + restoreBasis: 'checkpoint', + checkpointRef: ref(), + }), + ), + ).not.toThrow(); + expect(() => + assertManagedSessionRestoreBundle( + bundle({ + restoreBasis: 'checkpoint', + checkpointRef: ref(), + recoveryStatus: 'blocked', + }), + ), + ).not.toThrow(); + expect(() => + assertManagedSessionRestoreBundle( + bundle({ + restoreBasis: null, + recoveryStatus: 'blocked', + }), + ), + ).not.toThrow(); + expect(() => + assertManagedSessionRestoreBundle( + bundle({ + restoreBasis: 'history_rewind', + restoreProofRef: ref('managed-history-rewind'), + }), + ), + ).not.toThrow(); + }); + + it('rejects illegal restoreBasis/checkpointRef/restoreProofRef combinations', () => { + expect(() => + assertManagedSessionRestoreBundle(bundle({ restoreBasis: 'checkpoint' })), + ).toThrow(/checkpoint restore requires checkpointRef/); + expect(() => + assertManagedSessionRestoreBundle( + bundle({ + restoreBasis: 'checkpoint', + checkpointRef: ref(), + restoreProofRef: ref(), + }), + ), + ).toThrow(/null restoreProofRef/); + expect(() => + assertManagedSessionRestoreBundle( + bundle({ restoreBasis: 'initial', checkpointRef: ref() }), + ), + ).toThrow(/initial restore requires both refs null/); + expect(() => + assertManagedSessionRestoreBundle( + bundle({ restoreBasis: 'initial', recoveryStatus: 'blocked' }), + ), + ).toThrow(/not a blocked downgrade/); + expect(() => + assertManagedSessionRestoreBundle( + bundle({ restoreBasis: 'history_copy' }), + ), + ).toThrow(/history_copy restore requires restoreProofRef/); + expect(() => + assertManagedSessionRestoreBundle( + bundle({ + restoreBasis: 'history_copy', + checkpointRef: ref(), + restoreProofRef: ref(), + }), + ), + ).toThrow(/null checkpointRef/); + expect(() => + assertManagedSessionRestoreBundle( + bundle({ restoreBasis: null, recoveryStatus: 'ok' }), + ), + ).toThrow(/without restoreBasis must be blocked/); + }); +}); diff --git a/packages/core/src/managed-runtime/managed-session-authority.ts b/packages/core/src/managed-runtime/managed-session-authority.ts new file mode 100644 index 00000000000..6cc23adede2 --- /dev/null +++ b/packages/core/src/managed-runtime/managed-session-authority.ts @@ -0,0 +1,1863 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { randomUUID } from 'node:crypto'; +import { SessionWriterLease } from '../services/session-writer-lease.js'; +import { managedToolDigest } from '../tools/managed-tool-protocol.js'; +import { LocalJsonlManagedSessionJournalStore } from './local-jsonl-managed-session-journal-store.js'; +import { + authorizeParsedHarnessCheckpoint, + encodeHarnessCheckpointV1, + HARNESS_MODEL_START_PHASES, + tryParseHarnessCheckpointV1, + type HarnessCheckpointV1, + type HarnessRunAuthorization, +} from './managed-harness-checkpoint.js'; +import { + MANAGED_SESSION_COMMIT_SUBTYPE, + MANAGED_SESSION_EVENT_SUBTYPE, + MANAGED_SESSION_FORMAT_VERSION, + MANAGED_SESSION_HEADER_SUBTYPE, + MANAGED_SESSION_LIMITS, + MANAGED_SESSION_MINIMUM_READER, + ManagedSessionRecordError, + assertManagedSessionDomainEnabled, + assertManagedSessionEventActor, + assertManagedSessionTransaction, + managedSessionEventsDigest, + managedSessionKeysEqual, + parseManagedSessionCommitMarker, + parseManagedSessionEvent, + parseManagedSessionHeader, + type ManagedSessionActorClass, + type ManagedSessionDomain, + type ManagedSessionCommitMarker, + type ManagedSessionDurableRef, + type ManagedSessionEvent, + type ManagedSessionEventKind, + type ManagedSessionHeader, + type ManagedSessionKey, +} from './managed-session-records.js'; +import { readManagedBranchCheckpoint } from './managed-session-resources.js'; +import { + managedSessionActivationStateFrom, + managedSessionCommandKey, + type ManagedSessionActivationState, + type ManagedSessionCommitProof, + type ManagedSessionCommitReceipt, + type ManagedSessionCommittedTransaction, + type ManagedSessionJournalHandle, + type ManagedSessionJournalScan, + type ManagedSessionResourceStore, +} from './managed-session-storage.js'; + +export type { + ManagedSessionActivationState, + ManagedSessionCommitReceipt, +} from './managed-session-storage.js'; + +export type ManagedSessionRecordBody = + | ManagedSessionHeader + | ManagedSessionEvent + | ManagedSessionCommitMarker; + +export interface ManagedSessionCommand { + readonly operation: string; + readonly commandId: string; + readonly sessionKey: ManagedSessionKey; + readonly contentDigest: string; + /** Required for execution commands; the caller's view of the log tail. */ + readonly expectedSequence?: number; +} + +export interface ManagedSessionActor { + readonly class: ManagedSessionActorClass; + /** The activation the Harness holds; rejected once a later epoch exists. */ + readonly activation?: { + readonly activationId: string; + readonly epoch: number; + }; +} + +/** + * Storage-spec restore basis. `checkpoint` means a checkpoint resource exists, + * not that a Harness may run; use `harnessRunAuthorization()` for that gate. + * `blocked` is a real outcome, not an error path: execution continuation + * depends on a checkpoint, so a session that has model or tool history but no + * checkpoint must not silently restart from an older state or an empty history. + */ +export type ManagedSessionRestoreBasis = 'checkpoint' | 'initial' | 'blocked'; + +/** + * Storage §2.2 closed set. `blocked` is a recovery status, not a basis; + * a continuation without a checkpoint uses `restoreBasis=null`. + */ +export type ManagedSessionRestoreBundleBasis = + | 'checkpoint' + | 'initial' + | 'history_rewind' + | 'history_copy' + | 'format_upgrade'; + +export type ManagedSessionRestoreRecoveryStatus = 'ok' | 'blocked'; + +export interface ManagedSessionRestoreBundle { + readonly formatVersion: typeof MANAGED_SESSION_FORMAT_VERSION; + readonly sessionKey: ManagedSessionKey; + readonly engine: 'managed'; + readonly throughSequence: number; + readonly checkpointRef: ManagedSessionDurableRef | null; + readonly restoreBasis: ManagedSessionRestoreBundleBasis | null; + readonly restoreProofRef: ManagedSessionDurableRef | null; + readonly recoveryStatus: ManagedSessionRestoreRecoveryStatus; +} + +export function assertManagedSessionRestoreBundle( + bundle: ManagedSessionRestoreBundle, +): void { + const hasCheckpoint = bundle.checkpointRef !== null; + const hasProof = bundle.restoreProofRef !== null; + const { restoreBasis, recoveryStatus } = bundle; + + if (restoreBasis === null) { + if (recoveryStatus !== 'blocked' || hasCheckpoint || hasProof) { + throw new ManagedSessionRecordError( + 'a restore bundle without restoreBasis must be blocked with both refs null.', + ); + } + return; + } + + if (restoreBasis === 'checkpoint') { + if (!hasCheckpoint || hasProof) { + throw new ManagedSessionRecordError( + 'checkpoint restore requires checkpointRef and a null restoreProofRef.', + ); + } + return; + } + + if (restoreBasis === 'initial') { + if (hasCheckpoint || hasProof || recoveryStatus !== 'ok') { + throw new ManagedSessionRecordError( + 'initial restore requires both refs null and is not a blocked downgrade.', + ); + } + return; + } + + if ( + restoreBasis === 'history_rewind' || + restoreBasis === 'history_copy' || + restoreBasis === 'format_upgrade' + ) { + if (hasCheckpoint || !hasProof) { + throw new ManagedSessionRecordError( + `${restoreBasis} restore requires restoreProofRef and a null checkpointRef.`, + ); + } + return; + } + + const _exhaustive: never = restoreBasis; + throw new ManagedSessionRecordError( + `unknown restoreBasis ${String(_exhaustive)}.`, + ); +} + +export interface ManagedSessionCheckpoint { + readonly checkpointId: string; + readonly coveredSequence: number; + readonly previousCheckpointId: string | null; + readonly stateRef: ManagedSessionDurableRef; + readonly boundary: string | null; +} + +export type ManagedSessionActionState = + | 'requested' + | 'decided' + | 'cancelled' + | 'expired'; + +export interface ManagedSessionAction { + readonly requestId: string; + readonly kind: string; + readonly source: string; + readonly inputRevision: number; + readonly optionsRef: ManagedSessionDurableRef | null; + readonly state: ManagedSessionActionState; + readonly decisionRef: ManagedSessionDurableRef | null; +} + +export interface ManagedSessionCheckpointReceipt { + readonly receipt: ManagedSessionCommitReceipt; + readonly checkpoint: ManagedSessionCheckpoint; +} + +export interface ManagedSessionDomainReceipt { + readonly receipt: ManagedSessionCommitReceipt; + readonly recordRef: ManagedSessionDurableRef; + readonly revision: number; +} + +export interface ManagedSessionInputRequest { + readonly inputId: string; + readonly turnId: string; + readonly source: string; + readonly contentRef: ManagedSessionDurableRef; + readonly deadline: number | null; + readonly admissionRef: ManagedSessionDurableRef; + readonly wakeReason: string; +} + +export class ManagedSessionConflictError extends ManagedSessionRecordError { + override readonly code = 'managed_session_conflict'; + + constructor(message: string) { + super(message); + this.name = 'ManagedSessionConflictError'; + } +} + +/** The digest-chain head of a log that has no commit marker yet. */ +const EMPTY_COMMIT_PREFIX_HASH = '0'.repeat(64); + +/** + * A crash between the last event and its commit marker. The remedy is to + * truncate the tail under an exclusive writer, which needs a lease capability + * that does not exist yet, so the authority stays blocked instead of reusing + * the sequences the tail already consumed. + */ +export class ManagedSessionUncommittedTailError extends ManagedSessionRecordError { + override readonly code = 'managed_session_uncommitted_tail'; + + constructor( + message: string, + readonly uncommittedRecords: number, + ) { + super(message); + this.name = 'ManagedSessionUncommittedTailError'; + } +} + +export class ManagedSessionAlreadyExistsError extends ManagedSessionRecordError { + override readonly code = 'managed_session_already_exists'; + + constructor(readonly sessionId: string) { + super(`Managed Session ${sessionId} already exists.`); + this.name = 'ManagedSessionAlreadyExistsError'; + } +} + +export class ManagedSessionNotFoundError extends ManagedSessionRecordError { + override readonly code = 'managed_session_not_found'; + + constructor(readonly sessionId: string) { + super(`Managed Session ${sessionId} was not found.`); + this.name = 'ManagedSessionNotFoundError'; + } +} + +export interface OpenManagedSessionAuthorityOptions { + readonly journal?: ManagedSessionJournalHandle; + /** Compatibility entry point for callers that already own the local writer. */ + readonly lease?: SessionWriterLease; + readonly sessionKey: ManagedSessionKey; + readonly cwd: string; + readonly version: string; + /** Supplied when creating a session; ignored once a header exists. */ + readonly create?: { + readonly definitionRef: ManagedSessionDurableRef; + readonly rootSnapshotRef: ManagedSessionDurableRef; + readonly createdBy: string; + }; + /** Reject an existing header instead of reopening it through a create path. */ + readonly requireNew?: boolean; + readonly now?: () => number; + /** + * The commit proof the sealed predecessor pinned into its writer lock. When + * supplied, the scanned log must match it exactly: a mismatch means the log + * drifted after the seal, and the session stays blocked rather than + * continuing from an unproven state. + */ + readonly expectedCommitProof?: ManagedSessionCommitProof; + /** Required only for domain records, whose bodies live in resources. */ + readonly resources?: ManagedSessionResourceStore; +} + +/** + * The semantic authority for one Managed Session. It validates complete + * transactions, then gives them to one journal handle; the selected store owns + * physical serialization, fencing and durability. + */ +export class LocalManagedSessionAuthority { + private constructor( + private readonly journal: ManagedSessionJournalHandle, + private readonly sessionKey: ManagedSessionKey, + private readonly cwd: string, + private readonly version: string, + private readonly now: () => number, + private readonly header: ManagedSessionHeader, + private readonly events: ManagedSessionEvent[], + private readonly transactions: Map< + string, + ManagedSessionCommittedTransaction + >, + private committed: number, + private lastMarkerDigest: string | null, + private lastRecordUuid: string | null, + private activation: ManagedSessionActivationState | undefined, + private readonly resources: ManagedSessionResourceStore | undefined, + ) {} + + private writeFailure: Error | undefined; + private recoveryBlocked = false; + private queue: Promise = Promise.resolve(); + private readonly eventIds = new Set(); + private readonly checkpointSequences = new Map(); + private checkpoint: ManagedSessionCheckpoint | undefined; + private hasContinuation = false; + private compactedThrough = 0; + private readonly domainRecords = new Map< + string, + { revision: number; recordRef: ManagedSessionDurableRef } + >(); + private readonly actions = new Map(); + + get committedSequence(): number { + return this.committed; + } + + /** + * The highest sequence a compaction already claims to have replaced, so the + * next one states a range that does not overlap an earlier claim. + */ + get compactedThroughSequence(): number { + return this.compactedThrough; + } + + get sessionHeader(): ManagedSessionHeader { + return this.header; + } + + /** + * Whether the log records anything beyond activation bookkeeping, which is + * all a Session that never received input ever writes. + */ + get hasSessionContent(): boolean { + return ( + this.compactedThrough > 0 || + this.events.some((event) => event.kind !== 'activation.changed') + ); + } + + /** The highest activation epoch committed so far; 0 when none exists. */ + /** The activation the log currently records, if any. */ + get currentActivation(): ManagedSessionActivationState | undefined { + return this.activation; + } + + blockRecovery(request: { + readonly status: + | 'BLOCKED_RESOURCE' + | 'BLOCKED_WORKSPACE' + | 'BLOCKED_EXECUTION'; + readonly detailCode: string; + }): Promise { + return this.runSerial(async () => { + if (this.journal.blockRecovery === undefined) { + throw new ManagedSessionRecordError( + 'the Managed Session journal cannot persist a recovery block.', + ); + } + await this.journal.blockRecovery(request); + this.recoveryBlocked = true; + }); + } + + static async open( + options: OpenManagedSessionAuthorityOptions, + ): Promise { + const now = options.now ?? (() => Date.now()); + if (options.journal !== undefined && options.lease !== undefined) { + throw new ManagedSessionRecordError( + 'journal and a compatibility local lease cannot be supplied together.', + ); + } + const journal = + options.journal ?? + (options.lease === undefined + ? undefined + : LocalJsonlManagedSessionJournalStore.fromLease( + options.lease, + options.sessionKey, + )); + if (journal === undefined) { + throw new ManagedSessionRecordError( + 'a Managed Session journal handle is required.', + ); + } + const scan = await journal.read(); + if (options.expectedCommitProof !== undefined) { + const expected = options.expectedCommitProof; + const actualHash = scan.lastMarkerDigest ?? EMPTY_COMMIT_PREFIX_HASH; + if ( + scan.committed !== expected.lastCommitSequence || + actualHash !== expected.committedPrefixHash + ) { + throw new ManagedSessionConflictError( + `session log commit position ${scan.committed}/${actualHash} does not match the sealed writer proof ${expected.lastCommitSequence}/${expected.committedPrefixHash}.`, + ); + } + } + if (scan.uncommitted > 0) { + throw new ManagedSessionUncommittedTailError( + `session log ends with ${scan.uncommitted} uncommitted record(s); truncation under an exclusive writer is required before appending.`, + scan.uncommitted, + ); + } + if (options.requireNew === true && scan.header !== undefined) { + throw new ManagedSessionAlreadyExistsError(options.sessionKey.sessionId); + } + let header = scan.header; + let lastRecordUuid = scan.lastRecordUuid; + if (header === undefined) { + if (scan.foreignRecords > scan.engineRecords) { + throw new ManagedSessionRecordError( + 'session log has existing records but no Managed header; history import is not supported yet.', + ); + } + if (options.create === undefined) { + throw new ManagedSessionNotFoundError(options.sessionKey.sessionId); + } + header = parseManagedSessionHeader({ + formatVersion: MANAGED_SESSION_FORMAT_VERSION, + minimumReader: MANAGED_SESSION_MINIMUM_READER, + sessionKey: options.sessionKey, + engine: 'managed', + definitionRef: options.create.definitionRef, + rootSnapshotRef: options.create.rootSnapshotRef, + createdBy: options.create.createdBy, + }); + // Recorded before the header, in the container's own metadata shape, so + // every existing execution-engine guard sees a Managed session instead of + // defaulting to legacy and letting a legacy-only operation run on it. + const records: unknown[] = []; + if (scan.engineRecords === 0) { + const engineUuid = randomUUID(); + records.push({ + uuid: engineUuid, + parentUuid: lastRecordUuid, + sessionId: options.sessionKey.sessionId, + timestamp: new Date(now()).toISOString(), + type: 'system', + subtype: 'session_execution_engine', + cwd: options.cwd, + version: options.version, + systemPayload: { version: 1, engine: 'managed' }, + }); + lastRecordUuid = engineUuid; + } + const uuid = randomUUID(); + records.push({ + uuid, + parentUuid: lastRecordUuid, + sessionId: options.sessionKey.sessionId, + timestamp: new Date(now()).toISOString(), + type: 'system', + subtype: MANAGED_SESSION_HEADER_SUBTYPE, + cwd: options.cwd, + version: options.version, + managedSession: header, + }); + await journal.appendTransaction(records); + lastRecordUuid = uuid; + } + const authority = new LocalManagedSessionAuthority( + journal, + options.sessionKey, + options.cwd, + options.version, + now, + header, + scan.events, + scan.transactions, + scan.committed, + scan.lastMarkerDigest, + lastRecordUuid, + scan.activation, + options.resources, + ); + const branches = await authority.validateRecoveryFacts(scan.events); + for (const event of scan.events) { + authority.eventIds.add(event.eventId); + if (event.kind === 'domain.committed') { + authority.recordDomainEvent(event); + } + authority.recordRecoveryFacts(event, branches.has(event.eventId)); + } + return authority; + } + + /** + * Discards a tail that was appended without a commit marker, after proving + * the retained prefix reads cleanly. Repair is explicit: opening a session + * reports the tail and refuses to write, because a read-only owner or a + * compatibility probe must never rewrite a transcript. + */ + /** + * Acquires the writer for a Managed session. + * + * Managed sessions leave a sealed lock behind on close rather than removing + * it, so reacquiring one means taking over that seal. The certified takeover + * verifies the sealed transcript proof against the live file, which is what + * makes the barrier meaningful: a writer that never sealed cannot silently + * adopt the log. + */ + static acquireWriter(options: { + runtimeBaseDir: string; + sessionId: string; + transcriptPath: string; + }): Promise { + return SessionWriterLease.acquire({ + runtimeBaseDir: options.runtimeBaseDir, + sessionId: options.sessionId, + transcriptPath: options.transcriptPath, + takeoverPolicy: 'certified', + lockSchema: { + schemaVersion: 3, + formatVersion: MANAGED_SESSION_FORMAT_VERSION, + }, + }); + } + + /** The commit position the current log stands at, for sealing or proofs. */ + get commitProof(): ManagedSessionCommitProof { + return { + lastCommitSequence: this.committed, + committedPrefixHash: this.lastMarkerDigest ?? EMPTY_COMMIT_PREFIX_HASH, + }; + } + + /** + * Seals the writer instead of releasing it. + * + * `release()` deletes the lock, which leaves a Managed transcript with no + * barrier at all: any writer can then acquire it and append legacy records, + * and the authority afterwards refuses to reopen the log at all. Sealing + * keeps a lock that a default acquire declines, so the authoritative log + * stays closed to writers that do not know how to take it over. + */ + async close(): Promise { + await this.journal.seal(this.commitProof); + } + + static async recoverUncommittedTail(options: { + lease: SessionWriterLease; + sessionKey: ManagedSessionKey; + }): Promise<{ discardedBytes: number; diagnosticPath: string }> { + return LocalJsonlManagedSessionJournalStore.fromLease( + options.lease, + options.sessionKey, + ).recoverUncommittedTail(); + } + + /** + * The newest committed event of a kind. + * + * Separate from the paged read on purpose: that one starts at the beginning + * and caps at `maxReadEvents`, so a caller looking for the latest of + * something would silently find nothing once the log outgrows a page. + */ + lastEventOfKind( + kind: ManagedSessionEventKind, + ): ManagedSessionEvent | undefined { + for (let index = this.events.length - 1; index >= 0; index--) { + if (this.events[index].kind === kind) return this.events[index]; + } + return undefined; + } + + /** + * Every committed event in an inclusive sequence range. + * + * Same reason as {@link lastEventOfKind} for not going through the paged + * read: a caller describing a range has to see all of it, and a page would + * silently truncate the description. + */ + eventsInSequenceRange( + fromSequence: number, + toSequence: number, + ): readonly ManagedSessionEvent[] { + return this.events.filter( + (event) => event.sequence >= fromSequence && event.sequence <= toSequence, + ); + } + + /** + * Bounded read of the committed prefix. It never needs a live Harness. + */ + readEvents( + options: { afterSequence?: number; limit?: number } = {}, + ): readonly ManagedSessionEvent[] { + const after = options.afterSequence ?? 0; + const limit = Math.min( + options.limit ?? MANAGED_SESSION_LIMITS.defaultReadEvents, + MANAGED_SESSION_LIMITS.maxReadEvents, + ); + if (limit < 1) { + throw new ManagedSessionRecordError('readEvents limit must be positive.'); + } + const out: ManagedSessionEvent[] = []; + for (const event of this.events) { + if (event.sequence <= after) continue; + out.push(event); + if (out.length === limit) break; + } + return out; + } + + /** + * Persists the accepted input and the wake intent in one transaction. The + * wake fact is generated here because an entry may request a wake but must + * not author it. + */ + submitInput( + command: ManagedSessionCommand, + input: ManagedSessionInputRequest, + ): Promise { + return this.runSerial(() => { + // Read inside the lock: the committed sequence moves as other + // transactions commit. + const first = this.committed + 1; + const occurredAt = this.now(); + const accepted = { + v: MANAGED_SESSION_FORMAT_VERSION, + sequence: first, + eventId: `${input.inputId}:accepted`, + sessionKey: command.sessionKey, + kind: 'input.accepted', + occurredAt, + payload: { + inputId: input.inputId, + turnId: input.turnId, + source: input.source, + contentRef: input.contentRef, + deadline: input.deadline, + admissionRef: input.admissionRef, + }, + }; + const wake = { + v: MANAGED_SESSION_FORMAT_VERSION, + sequence: first + 1, + eventId: `${input.inputId}:wake`, + sessionKey: command.sessionKey, + kind: 'wake.requested', + occurredAt, + payload: { + wakeId: `${input.inputId}:wake`, + reason: input.wakeReason, + subject: { type: 'turn', turnId: input.turnId }, + sourceEventId: `${input.inputId}:accepted`, + requiredSequence: first, + }, + }; + return this.commit( + command, + [accepted, wake], + [{ class: 'trusted_entry' }, { class: 'authority' }], + ); + }); + } + + /** + * Conditional append for one actor. The events must continue the committed + * sequence exactly. + */ + appendExecution( + command: ManagedSessionCommand, + events: readonly unknown[], + actor: ManagedSessionActor, + ): Promise { + return this.runSerial(() => + this.commit( + command, + events, + events.map(() => actor), + ), + ); + } + + appendExecutionEvent( + command: ManagedSessionCommand, + event: (sequence: number) => unknown, + actor: ManagedSessionActor, + ): Promise { + return this.runSerial(() => + this.commit(command, [event(this.committed + 1)], [actor]), + ); + } + + /** The latest committed action for this request, if any. */ + action(requestId: string): ManagedSessionAction | undefined { + return this.actions.get(requestId); + } + + /** + * Harness-only: a tool_call permission ticket. Final decisions go through + * {@link resolveAction} as the trusted arbiter, not this method. + */ + requestToolAction( + command: ManagedSessionCommand, + request: { + readonly requestId: string; + readonly kind: string; + readonly inputRevision: number; + readonly optionsRef: ManagedSessionDurableRef | null; + }, + actor: ManagedSessionActor, + ): Promise { + return this.runSerial(async () => { + const held = actor.activation; + if (actor.class !== 'harness' || held === undefined) { + throw new ManagedSessionConflictError( + 'only the current harness may request a tool_call action.', + ); + } + const existing = this.actions.get(request.requestId); + if (existing !== undefined) { + if (existing.state !== 'requested') { + throw new ManagedSessionConflictError( + `action ${request.requestId} is already ${existing.state}.`, + ); + } + return existing; + } + await this.commit( + command, + [ + { + v: MANAGED_SESSION_FORMAT_VERSION, + sequence: this.committed + 1, + eventId: `action:${request.requestId}:requested`, + sessionKey: command.sessionKey, + kind: 'action.changed', + occurredAt: this.now(), + subject: { + type: 'activation', + scopeId: held.activationId, + activationId: held.activationId, + epoch: held.epoch, + }, + payload: { + requestId: request.requestId, + kind: request.kind, + source: 'tool_call', + inputRevision: request.inputRevision, + optionsRef: request.optionsRef, + state: 'requested', + decisionRef: null, + }, + }, + ], + [actor], + ); + const committed = this.actions.get(request.requestId); + if (committed === undefined) { + throw new ManagedSessionRecordError( + `action ${request.requestId} was requested but not recorded.`, + ); + } + return committed; + }); + } + + /** + * Arbiter-only final decision. A later conflicting outcome is rejected; the + * same outcome is idempotent so a duplicate client response is safe. + */ + resolveAction( + command: ManagedSessionCommand, + request: { + readonly requestId: string; + readonly state: Exclude; + readonly decisionRef: ManagedSessionDurableRef | null; + }, + ): Promise { + return this.runSerial(async () => { + const existing = this.actions.get(request.requestId); + if (existing === undefined) { + throw new ManagedSessionConflictError( + `action ${request.requestId} has not been requested.`, + ); + } + if (existing.state !== 'requested') { + if (actionDecisionsMatch(existing, request)) { + return existing; + } + throw new ManagedSessionConflictError( + `action ${request.requestId} already ${existing.state}.`, + ); + } + await this.commit( + command, + [ + { + v: MANAGED_SESSION_FORMAT_VERSION, + sequence: this.committed + 1, + eventId: `action:${request.requestId}:${request.state}`, + sessionKey: command.sessionKey, + kind: 'action.changed', + occurredAt: this.now(), + payload: { + requestId: existing.requestId, + kind: existing.kind, + source: existing.source, + inputRevision: existing.inputRevision, + optionsRef: existing.optionsRef, + state: request.state, + decisionRef: request.decisionRef, + }, + }, + ], + [{ class: 'trusted_entry' }], + ); + const committed = this.actions.get(request.requestId); + if (committed === undefined) { + throw new ManagedSessionRecordError( + `action ${request.requestId} was resolved but not recorded.`, + ); + } + return committed; + }); + } + + /** The newest committed checkpoint, if the session has one. */ + get latestCheckpoint(): ManagedSessionCheckpoint | undefined { + return this.checkpoint; + } + + /** + * Storage-spec restore basis. The authority decides this; a Harness must not + * pick a weaker basis for itself. Runnable authorization is a separate gate. + */ + restoreBasis(): ManagedSessionRestoreBasis { + if (this.checkpoint !== undefined) return 'checkpoint'; + return this.hasContinuation ? 'blocked' : 'initial'; + } + + /** + * Storage §2.2 package. `restoreBasis()` still reports `blocked` when + * continuation exists without a checkpoint; this bundle keeps that case as + * `restoreBasis=null` plus `recoveryStatus=blocked` so it cannot be renamed + * to `initial`. A stored checkpoint that fails authorization stays + * `restoreBasis=checkpoint` with its original ref. + */ + async restoreBundle(): Promise { + const checkpoint = this.checkpoint; + const identity: Pick< + ManagedSessionRestoreBundle, + 'formatVersion' | 'sessionKey' | 'engine' | 'throughSequence' + > = { + formatVersion: MANAGED_SESSION_FORMAT_VERSION, + sessionKey: this.sessionKey, + engine: 'managed', + throughSequence: this.committed, + }; + if (checkpoint !== undefined) { + const authorization = await this.harnessRunAuthorization(); + const bundle: ManagedSessionRestoreBundle = { + ...identity, + checkpointRef: checkpoint.stateRef, + restoreBasis: 'checkpoint', + restoreProofRef: null, + recoveryStatus: authorization.status === 'runnable' ? 'ok' : 'blocked', + }; + assertManagedSessionRestoreBundle(bundle); + return bundle; + } + if (this.hasContinuation) { + const bundle: ManagedSessionRestoreBundle = { + ...identity, + checkpointRef: null, + restoreBasis: null, + restoreProofRef: null, + recoveryStatus: 'blocked', + }; + assertManagedSessionRestoreBundle(bundle); + return bundle; + } + const bundle: ManagedSessionRestoreBundle = { + ...identity, + checkpointRef: null, + restoreBasis: 'initial', + restoreProofRef: null, + recoveryStatus: 'ok', + }; + assertManagedSessionRestoreBundle(bundle); + return bundle; + } + + /** + * Publishes the Harness state and commits the checkpoint that covers the log + * up to this point. `boundary` is null for the first checkpoint a legitimate + * initialisation establishes before any model request. + */ + async commitCheckpoint( + command: ManagedSessionCommand, + request: { state: Buffer; boundary: string | null }, + actor: ManagedSessionActor, + ): Promise { + const store = this.resources; + if (store === undefined) { + throw new ManagedSessionRecordError( + 'a resource store is required to commit checkpoints.', + ); + } + const held = actor.activation; + if (actor.class !== 'harness' || held === undefined) { + throw new ManagedSessionConflictError( + 'only the current harness may commit a checkpoint.', + ); + } + return this.runSerial(async () => { + const previous = this.checkpoint; + const checkpointId = `ckpt-${this.committed + 1}`; + const covered = this.committed; + const previousCheckpointId = previous?.checkpointId ?? null; + const parsed = tryParseHarnessCheckpointV1(request.state); + const state = + parsed.ok && + (parsed.checkpoint.identity.checkpointId !== checkpointId || + parsed.checkpoint.identity.coveredSequence !== covered || + parsed.checkpoint.identity.previousCheckpointId !== + previousCheckpointId) + ? encodeHarnessCheckpointV1({ + ...parsed.checkpoint, + identity: { + ...parsed.checkpoint.identity, + checkpointId, + coveredSequence: covered, + previousCheckpointId, + }, + resume: { + ...parsed.checkpoint.resume, + throughSequence: covered, + }, + }) + : request.state; + const stateRef = await store.publish('managed-checkpoint', state); + const receipt = await this.commit( + command, + [ + { + v: MANAGED_SESSION_FORMAT_VERSION, + sequence: this.committed + 1, + eventId: checkpointId, + sessionKey: command.sessionKey, + kind: 'checkpoint.committed', + occurredAt: this.now(), + subject: { + type: 'activation', + scopeId: held.activationId, + activationId: held.activationId, + epoch: held.epoch, + }, + payload: { + checkpointId, + coveredSequence: covered, + previousCheckpointId, + stateRef, + boundary: request.boundary, + }, + }, + ], + [actor], + ); + const checkpoint = this.checkpoint; + if (checkpoint === undefined) { + throw new ManagedSessionRecordError( + 'checkpoint was committed but not recorded.', + ); + } + return { receipt, checkpoint }; + }); + } + + /** + * Safety point A/D: a finished turn with no pending Harness work. The + * terminal `turn.settled` event and the next-turn-ready checkpoint land in + * one transaction. Storage forbids covering events from this transaction, so + * `coveredSequence` is the prefix already committed before this call; the + * settle event is the atomic companion, not part of coverage. + */ + async commitTurnComplete( + command: ManagedSessionCommand, + request: { + readonly turn: { + readonly turnId: string; + readonly outcome: string; + readonly stopReason: string | null; + readonly resultRef: ManagedSessionDurableRef; + readonly occurredAt: number; + readonly eventId: string; + }; + readonly boundary: string; + readonly state: ( + identity: { + readonly checkpointId: string; + readonly coveredSequence: number; + readonly previousCheckpointId: string | null; + }, + previous: HarnessCheckpointV1, + ) => Buffer; + }, + actor: ManagedSessionActor, + ): Promise { + const store = this.resources; + if (store === undefined) { + throw new ManagedSessionRecordError( + 'a resource store is required to commit checkpoints.', + ); + } + const held = actor.activation; + if (actor.class !== 'harness' || held === undefined) { + throw new ManagedSessionConflictError( + 'only the current harness may commit a checkpoint.', + ); + } + return this.runSerial(async () => { + const authorization = await this.harnessRunAuthorization(); + if (authorization.status !== 'runnable') { + throw new ManagedSessionConflictError( + 'turn-complete checkpoint requires a runnable Harness checkpoint.', + ); + } + const previous = authorization.checkpoint; + if (!HARNESS_MODEL_START_PHASES.has(previous.continuation.phase)) { + throw new ManagedSessionConflictError( + 'turn-complete checkpoint requires no pending Harness work.', + ); + } + const covered = this.committed; + const checkpointId = `ckpt-${covered + 2}`; + const previousCheckpointId = this.checkpoint?.checkpointId ?? null; + const state = request.state( + { + checkpointId, + coveredSequence: covered, + previousCheckpointId, + }, + previous, + ); + const parsed = tryParseHarnessCheckpointV1(state); + if (!parsed.ok) { + throw new ManagedSessionRecordError( + parsed.message ?? 'turn-complete checkpoint state is not Harness v1.', + ); + } + if ( + parsed.checkpoint.identity.checkpointId !== checkpointId || + parsed.checkpoint.identity.coveredSequence !== covered || + parsed.checkpoint.identity.previousCheckpointId !== + previousCheckpointId || + !managedSessionKeysEqual( + parsed.checkpoint.identity.sessionKey, + this.sessionKey, + ) + ) { + throw new ManagedSessionConflictError( + 'turn-complete checkpoint identity does not match the assigned coverage.', + ); + } + if ( + !HARNESS_MODEL_START_PHASES.has(parsed.checkpoint.continuation.phase) + ) { + throw new ManagedSessionConflictError( + 'turn-complete checkpoint requires no pending Harness work.', + ); + } + const stateRef = await store.publish('managed-checkpoint', state); + const subject = { + type: 'activation' as const, + scopeId: held.activationId, + activationId: held.activationId, + epoch: held.epoch, + }; + const receipt = await this.commit( + command, + [ + { + v: MANAGED_SESSION_FORMAT_VERSION, + sequence: covered + 1, + eventId: request.turn.eventId, + sessionKey: command.sessionKey, + kind: 'turn.settled', + occurredAt: request.turn.occurredAt, + subject, + payload: { + turnId: request.turn.turnId, + outcome: request.turn.outcome, + stopReason: request.turn.stopReason, + resultRef: request.turn.resultRef, + usageRef: null, + pendingOwnersRef: null, + }, + }, + { + v: MANAGED_SESSION_FORMAT_VERSION, + sequence: covered + 2, + eventId: checkpointId, + sessionKey: command.sessionKey, + kind: 'checkpoint.committed', + occurredAt: this.now(), + subject, + payload: { + checkpointId, + coveredSequence: covered, + previousCheckpointId, + stateRef, + boundary: request.boundary, + }, + }, + ], + [actor, actor], + ); + const checkpoint = this.checkpoint; + if (checkpoint === undefined) { + throw new ManagedSessionRecordError( + 'checkpoint was committed but not recorded.', + ); + } + return { receipt, checkpoint }; + }); + } + + /** + * Reads the state the newest checkpoint references. A checkpoint whose body + * cannot be resolved is a blocked recovery, not an empty one, so the failure + * from the resource store is allowed to propagate. + */ + async readCheckpointState(): Promise { + const current = this.checkpoint; + if (current === undefined) return undefined; + const store = this.resources; + if (store === undefined) { + throw new ManagedSessionRecordError( + 'a resource store is required to read checkpoints.', + ); + } + return store.read(current.stateRef); + } + + /** + * Whether a Harness may run from the current restore basis. A stored + * checkpoint is not runnable until its nine-group v1 state parses and + * matches this session; opaque historical blobs stay `restoreBasis= + * checkpoint` but authorize as blocked. + */ + async harnessRunAuthorization(): Promise { + const basis = this.restoreBasis(); + if (basis === 'initial') return { status: 'initial' }; + if (basis === 'blocked') { + return { status: 'blocked', reason: 'missing_checkpoint' }; + } + const expected = this.checkpoint; + if (expected === undefined) { + return { status: 'blocked', reason: 'missing_checkpoint' }; + } + let bytes: Buffer; + try { + const state = await this.readCheckpointState(); + if (state === undefined) { + return { status: 'blocked', reason: 'missing_state' }; + } + bytes = state; + } catch (error) { + if (error instanceof ManagedSessionRecordError) { + return { + status: 'blocked', + reason: 'missing_state', + message: error.message, + }; + } + throw error; + } + const parsed = tryParseHarnessCheckpointV1(bytes); + if (!parsed.ok) { + return { + status: 'blocked', + reason: parsed.reason === 'opaque' ? 'opaque_state' : 'invalid_state', + message: parsed.message, + }; + } + return authorizeParsedHarnessCheckpoint(parsed.checkpoint, { + sessionKey: this.sessionKey, + checkpointId: expected.checkpointId, + coveredSequence: expected.coveredSequence, + }); + } + + /** + * Commits one registered domain record. The body is published as a resource + * first, because the event carries only a reference to it; the authority + * composes the envelope so a caller cannot choose its own revision or break + * the per-domain chain. + */ + async commitDomainRecord( + command: ManagedSessionCommand, + request: { + domain: ManagedSessionDomain; + content: Readonly>; + }, + actor: ManagedSessionActor, + ): Promise { + assertManagedSessionDomainEnabled(request.domain); + const store = this.resources; + if (store === undefined) { + throw new ManagedSessionRecordError( + 'a resource store is required to commit domain records.', + ); + } + return this.runSerial(async () => { + const previous = this.domainRecords.get(request.domain); + const revision = (previous?.revision ?? 0) + 1; + const recordRef = await store.publish( + `managed-${request.domain}`, + Buffer.from( + JSON.stringify({ + operationId: command.commandId, + revision, + previousRecordRef: previous?.recordRef ?? null, + ...request.content, + }), + 'utf8', + ), + ); + const receipt = await this.commit( + command, + [ + { + v: MANAGED_SESSION_FORMAT_VERSION, + sequence: this.committed + 1, + eventId: `${request.domain}:${revision}`, + sessionKey: command.sessionKey, + kind: 'domain.committed', + occurredAt: this.now(), + payload: { + domain: request.domain, + version: MANAGED_SESSION_FORMAT_VERSION, + operationId: command.commandId, + recordRef, + }, + }, + ], + [actor], + ); + return { receipt, recordRef, revision }; + }); + } + + /** + * One transaction at a time. The writer lease serialises individual lines, + * which is not enough: concurrent transactions would interleave their event + * records around each other's commit markers. + */ + private runSerial(operation: () => Promise): Promise { + const pending = this.queue.then(operation, operation); + this.queue = pending.then( + () => undefined, + () => undefined, + ); + return pending; + } + + private async commit( + command: ManagedSessionCommand, + values: readonly unknown[], + actors: readonly ManagedSessionActor[], + ): Promise { + if (this.writeFailure !== undefined) { + throw new ManagedSessionRecordError( + `session log writes stopped after an earlier failure: ${this.writeFailure.message}`, + ); + } + if (!managedSessionKeysEqual(command.sessionKey, this.sessionKey)) { + throw new ManagedSessionConflictError( + 'command session key does not match this session.', + ); + } + const key = managedSessionCommandKey(command.operation, command.commandId); + const previous = this.transactions.get(key); + if (previous !== undefined) { + if (previous.contentDigest !== command.contentDigest) { + throw new ManagedSessionConflictError( + `command ${command.commandId} was already committed with different content.`, + ); + } + return { + ...previous.receipt, + committedSequence: this.committed, + replayed: true, + }; + } + if ( + command.expectedSequence !== undefined && + command.expectedSequence !== this.committed + ) { + throw new ManagedSessionConflictError( + `expectedSequence ${command.expectedSequence} does not match the committed sequence ${this.committed}; re-read before retrying.`, + ); + } + + const events = values.map((value, index) => { + const event = parseManagedSessionEvent(value); + const actor = actors[index]; + assertManagedSessionEventActor(event, actor.class); + if (!managedSessionKeysEqual(event.sessionKey, this.sessionKey)) { + throw new ManagedSessionConflictError( + 'event session key does not match this session.', + ); + } + this.assertActorFence(event, actor); + if (event.kind === 'activation.changed') { + this.assertActivationEpoch(event); + } + if (this.eventIds.has(event.eventId)) { + throw new ManagedSessionConflictError( + `event id ${event.eventId} is already committed.`, + ); + } + return event; + }); + if (events.length === 0) { + throw new ManagedSessionRecordError( + 'a transaction must contain at least one event.', + ); + } + if (new Set(events.map((event) => event.eventId)).size !== events.length) { + throw new ManagedSessionConflictError( + 'a transaction must not repeat an event id.', + ); + } + if (events[0].sequence !== this.committed + 1) { + throw new ManagedSessionConflictError( + `transaction starts at sequence ${events[0].sequence} but the committed sequence is ${this.committed}.`, + ); + } + const encoded = events.map((event) => JSON.stringify(event)); + encoded.forEach((line, index) => { + if ( + Buffer.byteLength(line, 'utf8') > MANAGED_SESSION_LIMITS.maxEventBytes + ) { + throw new ManagedSessionRecordError( + `event ${events[index].eventId} exceeds ${MANAGED_SESSION_LIMITS.maxEventBytes} bytes.`, + ); + } + }); + assertManagedSessionTransaction( + events, + encoded.reduce((sum, line) => sum + Buffer.byteLength(line, 'utf8'), 0), + ); + + const marker = parseManagedSessionCommitMarker({ + transactionId: randomUUID(), + commandId: command.commandId, + operation: command.operation, + contentDigest: command.contentDigest, + firstSequence: events[0].sequence, + lastSequence: events[events.length - 1].sequence, + eventCount: events.length, + eventsDigest: managedSessionEventsDigest(events), + previousCommitDigest: this.lastMarkerDigest, + }); + const markerLine = JSON.stringify(marker); + if ( + Buffer.byteLength(markerLine, 'utf8') > + MANAGED_SESSION_LIMITS.maxCommitMarkerBytes + ) { + throw new ManagedSessionRecordError( + `commit marker exceeds ${MANAGED_SESSION_LIMITS.maxCommitMarkerBytes} bytes.`, + ); + } + + const branches = await this.validateRecoveryFacts(events); + + // Events first, marker last: a crash before the marker leaves the + // transaction invisible rather than half applied. + const records: unknown[] = []; + let lastRecordUuid = this.lastRecordUuid; + for (const event of events) { + const next = this.createRecord( + MANAGED_SESSION_EVENT_SUBTYPE, + event, + lastRecordUuid, + ); + records.push(next.record); + lastRecordUuid = next.uuid; + } + const final = this.createRecord( + MANAGED_SESSION_COMMIT_SUBTYPE, + marker, + lastRecordUuid, + ); + records.push(final.record); + try { + await this.journal.appendTransaction(records); + this.lastRecordUuid = final.uuid; + } catch (cause) { + // Records may already be on disk, so the sequences this transaction + // claimed are spent whether or not the marker landed. + this.writeFailure = + cause instanceof Error ? cause : new Error(String(cause)); + throw cause; + } + + for (const event of events) { + this.events.push(event); + this.eventIds.add(event.eventId); + if (event.kind === 'activation.changed') { + this.activation = managedSessionActivationStateFrom(event); + } + if (event.kind === 'domain.committed') { + this.recordDomainEvent(event); + } + this.recordRecoveryFacts(event, branches.has(event.eventId)); + } + this.committed = marker.lastSequence; + this.lastMarkerDigest = managedToolDigest( + marker, + MANAGED_SESSION_LIMITS.maxCommitMarkerBytes, + ); + const receipt: ManagedSessionCommitReceipt = { + transactionId: marker.transactionId, + commandId: marker.commandId, + operation: marker.operation, + firstSequence: marker.firstSequence, + lastSequence: marker.lastSequence, + committedSequence: this.committed, + replayed: false, + }; + this.transactions.set(key, { + receipt, + contentDigest: command.contentDigest, + }); + return receipt; + } + + /** + * Installs a new activation, which is what allows a Harness to append at all. + * + * The epoch is the authority's to assign, so the caller supplies an identity + * and gets back the activation to present on every later append. Only a + * coordinator may record the transition, so the actor is not a parameter. + */ + async installActivation(input: { + readonly activationId: string; + readonly workerId: string; + /** + * How long the install claims the activation stays live. The format + * requires a horizon; a reader compares it against the writer lock to judge + * whether the holder is still there. + */ + readonly leaseDurationMs: number; + }): Promise<{ activationId: string; epoch: number }> { + const epoch = (this.activation?.epoch ?? 0) + 1; + const installRef = await this.publishActivationBody( + 'managed-activation-install', + { + version: 1, + activationId: input.activationId, + epoch, + workerId: input.workerId, + leaseDurationMs: input.leaseDurationMs, + }, + ); + await this.commitActivation({ + activationId: input.activationId, + epoch, + workerId: input.workerId, + phase: 'active', + leaseDurationMs: input.leaseDurationMs, + expiresAt: this.now() + input.leaseDurationMs, + installRef, + boundaryRef: null, + operation: 'installActivation', + }); + return { activationId: input.activationId, epoch }; + } + + /** + * Extends the current activation's horizon without changing its identity. + * + * The install stamps a fixed horizon, so a long-lived worker must renew or a + * reader eventually sees an expired activation behind a live writer lock. + * Renewal keeps the activation's id and epoch — it is not a handoff — and a + * released or missing activation has nothing left to renew. + */ + async renewActivation(input: { + readonly leaseDurationMs: number; + }): Promise { + if (this.recoveryBlocked) return undefined; + const current = this.activation; + if (current === undefined || current.phase !== 'active') { + return undefined; + } + const renewalSeq = current.renewalSeq + 1; + await this.commitActivation({ + activationId: current.activationId, + epoch: current.epoch, + workerId: current.workerId, + phase: 'active', + leaseDurationMs: input.leaseDurationMs, + expiresAt: this.now() + input.leaseDurationMs, + installRef: current.installRef, + boundaryRef: null, + operation: 'renewActivation', + renewalSeq, + }); + return this.activation; + } + + /** + * Records that the current activation stopped advancing the session. + * + * Without it a reader cannot tell a holder that finished from one that + * vanished, so the boundary is what recovery reads. Releasing an activation + * that is already gone is not an error: there is nothing left to fence. + */ + async releaseActivation(): Promise { + if (this.recoveryBlocked) return; + const current = this.activation; + if ( + current === undefined || + current.phase === 'released' || + current.phase === 'revoked' + ) { + return; + } + const boundaryRef = await this.publishActivationBody( + 'managed-activation-boundary', + { + version: 1, + activationId: current.activationId, + epoch: current.epoch, + committedSequence: this.committed, + lastRecordUuid: this.lastRecordUuid, + }, + ); + await this.commitActivation({ + activationId: current.activationId, + epoch: current.epoch, + workerId: current.workerId, + phase: 'released', + leaseDurationMs: null, + expiresAt: current.expiresAt, + installRef: null, + boundaryRef, + operation: 'releaseActivation', + }); + } + + private async publishActivationBody( + kind: string, + body: Record, + ): Promise { + const store = this.resources; + if (store === undefined) { + throw new ManagedSessionRecordError( + 'a resource store is required to change the activation.', + ); + } + return store.publish(kind, Buffer.from(JSON.stringify(body), 'utf8')); + } + + private async commitActivation(input: { + readonly activationId: string; + readonly epoch: number; + readonly workerId: string; + readonly phase: string; + readonly leaseDurationMs: number | null; + readonly expiresAt: number | null; + readonly installRef: ManagedSessionDurableRef | null; + readonly boundaryRef: ManagedSessionDurableRef | null; + readonly operation: string; + readonly renewalSeq?: number; + }): Promise { + // A renewal repeats the install's phase under the same activation, so it + // needs its own command and event identity or the log's idempotency and + // event-id uniqueness would reject it as a duplicate of the install. + const renewalSuffix = + input.renewalSeq === undefined ? '' : `:renewal:${input.renewalSeq}`; + await this.appendExecutionEvent( + { + operation: input.operation, + commandId: `${input.activationId}:${input.phase}${renewalSuffix}`, + sessionKey: this.sessionKey, + contentDigest: this.header.definitionRef.digest, + }, + (sequence) => ({ + v: MANAGED_SESSION_FORMAT_VERSION, + sequence, + eventId: `activation:${input.activationId}:${input.phase}${renewalSuffix}`, + sessionKey: this.sessionKey, + kind: 'activation.changed', + occurredAt: this.now(), + payload: { + activationId: input.activationId, + epoch: input.epoch, + workerId: input.workerId, + subject: { + type: 'activation', + scopeId: input.activationId, + activationId: input.activationId, + epoch: input.epoch, + }, + phase: input.phase, + leaseDurationMs: input.leaseDurationMs, + expiresAt: input.expiresAt, + installRef: input.installRef, + boundaryRef: input.boundaryRef, + ...(input.renewalSeq === undefined + ? {} + : { renewalSeq: input.renewalSeq }), + }, + }), + { class: 'coordinator' }, + ); + } + + /** + * Classifies the checkpoints a transaction or a cold log carries. It runs + * before the first physical append, so a checkpoint whose state cannot be + * verified never reaches the log or the recovery facts. + */ + private async validateRecoveryFacts( + events: readonly ManagedSessionEvent[], + ): Promise> { + const branches = new Set(); + const pendingCheckpoints = new Map(); + for (const event of events) { + const branch = await readManagedBranchCheckpoint( + event, + this.resources, + (id) => pendingCheckpoints.get(id) ?? this.checkpointSequences.get(id), + this.committed, + ); + if (branch !== undefined) branches.add(event.eventId); + if (event.kind === 'checkpoint.committed') { + pendingCheckpoints.set( + event.payload['checkpointId'] as string, + event.sequence, + ); + } + } + return branches; + } + + private recordRecoveryFacts( + event: ManagedSessionEvent, + branch: boolean, + ): void { + if (event.kind === 'checkpoint.committed') { + this.checkpointSequences.set( + event.payload['checkpointId'] as string, + event.sequence, + ); + } + if (branch) { + this.hasContinuation = true; + return; + } + if (event.kind === 'context.compacted') { + this.compactedThrough = event.payload['toSequence'] as number; + this.hasContinuation = true; + return; + } + if (event.kind === 'checkpoint.committed') { + this.checkpoint = { + checkpointId: event.payload['checkpointId'] as string, + coveredSequence: event.payload['coveredSequence'] as number, + previousCheckpointId: event.payload['previousCheckpointId'] as + | string + | null, + stateRef: event.payload[ + 'stateRef' + ] as unknown as ManagedSessionDurableRef, + boundary: event.payload['boundary'] as string | null, + }; + return; + } + if (event.kind === 'action.changed') { + this.actions.set(event.payload['requestId'] as string, { + requestId: event.payload['requestId'] as string, + kind: event.payload['kind'] as string, + source: event.payload['source'] as string, + inputRevision: event.payload['inputRevision'] as number, + optionsRef: event.payload[ + 'optionsRef' + ] as ManagedSessionDurableRef | null, + state: event.payload['state'] as ManagedSessionActionState, + decisionRef: event.payload[ + 'decisionRef' + ] as ManagedSessionDurableRef | null, + }); + return; + } + if (event.kind === 'turn.settled') { + // A turn that settled before the Harness committed anything -- one + // cancelled or failed ahead of its first checkpoint -- left nothing a + // later run has to resume, so a session without a checkpoint stays + // initial instead of blocking every later turn. + if (this.checkpoint !== undefined) this.hasContinuation = true; + return; + } + if ( + event.kind === 'model.attempt' || + event.kind === 'tool.intent' || + event.kind === 'tool.receipt' || + (event.kind === 'message.committed' && + (event.payload['role'] === 'assistant' || + event.payload['role'] === 'tool_result')) + ) { + this.hasContinuation = true; + } + } + + private recordDomainEvent(event: ManagedSessionEvent): void { + const domain = event.payload['domain'] as string; + const previous = this.domainRecords.get(domain); + this.domainRecords.set(domain, { + revision: (previous?.revision ?? 0) + 1, + recordRef: event.payload[ + 'recordRef' + ] as unknown as ManagedSessionDurableRef, + }); + } + + /** The latest committed record for a registered domain, if any. */ + domainRecord( + domain: ManagedSessionDomain, + ): { revision: number; recordRef: ManagedSessionDurableRef } | undefined { + return this.domainRecords.get(domain); + } + + private assertActivationEpoch(event: ManagedSessionEvent): void { + const next = managedSessionActivationStateFrom(event); + const current = this.activation; + if (current === undefined || next.activationId !== current.activationId) { + const expected = (current?.epoch ?? 0) + 1; + if (next.epoch !== expected) { + throw new ManagedSessionConflictError( + `activation ${next.activationId} must use epoch ${expected}, not ${next.epoch}.`, + ); + } + return; + } + if (next.epoch !== current.epoch) { + throw new ManagedSessionConflictError( + `activation ${next.activationId} is at epoch ${current.epoch} and cannot change to ${next.epoch}.`, + ); + } + } + + private assertActorFence( + event: ManagedSessionEvent, + actor: ManagedSessionActor, + ): void { + if (actor.class !== 'harness') return; + const held = actor.activation; + if (held === undefined) { + throw new ManagedSessionConflictError( + 'a harness append must present the activation it holds.', + ); + } + const current = this.activation; + if (current === undefined) { + throw new ManagedSessionConflictError( + 'no activation is committed for this session, so no harness may append.', + ); + } + if ( + held.activationId !== current.activationId || + held.epoch !== current.epoch + ) { + throw new ManagedSessionConflictError( + `activation ${held.activationId}/${held.epoch} is not the committed activation ${current.activationId}/${current.epoch}.`, + ); + } + if (current.phase !== 'installing' && current.phase !== 'active') { + throw new ManagedSessionConflictError( + `activation ${current.activationId} is ${current.phase} and may not append.`, + ); + } + const subject = event.subject; + if ( + subject?.type !== 'activation' || + subject.activationId !== held.activationId || + subject.epoch !== held.epoch + ) { + throw new ManagedSessionConflictError( + 'event subject does not match the activation the harness holds.', + ); + } + } + + private createRecord( + subtype: + | typeof MANAGED_SESSION_EVENT_SUBTYPE + | typeof MANAGED_SESSION_COMMIT_SUBTYPE, + body: ManagedSessionRecordBody, + parentUuid: string | null, + ): { readonly uuid: string; readonly record: unknown } { + const uuid = randomUUID(); + return { + uuid, + record: { + uuid, + parentUuid, + sessionId: this.sessionKey.sessionId, + timestamp: new Date(this.now()).toISOString(), + type: 'system', + subtype, + cwd: this.cwd, + version: this.version, + managedSession: body, + }, + }; + } +} + +function actionDecisionsMatch( + existing: ManagedSessionAction, + request: { + readonly state: ManagedSessionActionState; + readonly decisionRef: ManagedSessionDurableRef | null; + }, +): boolean { + if (existing.state !== request.state) return false; + const left = existing.decisionRef; + const right = request.decisionRef; + if (left === null || right === null) return left === right; + return left.digest === right.digest && left.byteLength === right.byteLength; +} + +/** + * Reads the complete committed prefix. A corrupt line inside the prefix fails + * the scan rather than being skipped, because skipping it would resume + * execution from an incomplete state. + * + * `maxBytes` bounds the scan to a frozen snapshot: a reader that already froze + * a byte length has to keep answering from it, or a page it serves would mix in + * records appended after the cursor was issued. + */ +export async function readManagedSessionLog( + path: string, + sessionKey: ManagedSessionKey, + maxBytes?: number, +): Promise { + return LocalJsonlManagedSessionJournalStore.read(path, sessionKey, maxBytes); +} diff --git a/packages/core/src/managed-runtime/managed-session-inbox.test.ts b/packages/core/src/managed-runtime/managed-session-inbox.test.ts new file mode 100644 index 00000000000..f0352d83fb4 --- /dev/null +++ b/packages/core/src/managed-runtime/managed-session-inbox.test.ts @@ -0,0 +1,332 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + appendFile, + mkdir, + mkdtemp, + readFile, + rm, + stat, + writeFile, +} from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { ManagedActivationFence } from './managed-activation-store.js'; +import { + FileManagedSessionInbox, + ManagedSessionMessageConflictError, + ManagedSessionMessageStaleFenceError, + managedSessionPayloadRef, + type ManagedSessionUserMessageInput, +} from './managed-session-inbox.js'; + +const limits = { maxPending: 10, maxPendingPerTenant: 5 }; + +function userMessage( + messageId: string, + overrides: Partial = {}, +): ManagedSessionUserMessageInput { + return { + tenantId: 'tenant-a', + sessionId: 'session-a', + messageId, + payload: { + prompt: [{ type: 'text', text: `hello ${messageId}` }], + clientId: 'client-a', + }, + ...overrides, + }; +} + +function activationFence( + messageId: string, + epoch: number, + workerId = 'worker-a', +): ManagedActivationFence { + return { + tenantId: 'tenant-a', + sessionId: 'session-a', + activationId: messageId, + workerId, + epoch, + }; +} + +describe('FileManagedSessionInbox', () => { + let root: string; + let filePath: string; + let now: number; + + const openInbox = (maxPayloadBytes?: number) => + FileManagedSessionInbox.open(filePath, { + clock: () => now, + ...(maxPayloadBytes === undefined ? {} : { maxPayloadBytes }), + }); + + beforeEach(async () => { + now = 100; + root = await mkdtemp(path.join(os.tmpdir(), 'managed-session-inbox-')); + filePath = path.join(root, 'state', 'messages.jsonl'); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('persists the canonical complete payload across reopen', async () => { + const inbox = await openInbox(); + const input = { + ...userMessage('m1'), + ignoredTopLevel: 'not durable', + } as ManagedSessionUserMessageInput; + + const admitted = await inbox.admit(input, limits); + expect(admitted).toMatchObject({ + created: true, + message: { + admittedAt: 100, + activationReady: false, + state: 'admitted', + message: { + tenantId: 'tenant-a', + sessionId: 'session-a', + messageId: 'm1', + payload: input.payload, + }, + }, + }); + + const reopened = await openInbox(); + expect(reopened.get(input)).toEqual(admitted.message); + expect(await readFile(filePath, 'utf8')).not.toContain('ignoredTopLevel'); + if (process.platform !== 'win32') { + expect((await stat(filePath)).mode & 0o777).toBe(0o600); + } + }); + + it('truncates a torn tail before accepting the next message', async () => { + const inbox = await openInbox(); + await inbox.admit(userMessage('m1'), limits); + await appendFile(filePath, '{"v":1,"sequence":2'); + + const recovered = await openInbox(); + now = 200; + await recovered.admit(userMessage('m2'), limits); + + const reopened = await openInbox(); + expect( + reopened.listPending().map((item) => item.message.messageId), + ).toEqual(['m1', 'm2']); + }); + + it('lists every durable message in admission order without exposing state', async () => { + const inbox = await openInbox(); + await inbox.admit(userMessage('m1'), limits); + await inbox.admit(userMessage('m2'), limits); + + const listed = inbox.listAll(); + expect(listed.map((item) => item.message.messageId)).toEqual(['m1', 'm2']); + const payload = listed[0]!.message.payload; + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw new Error('expected object payload'); + } + payload['changed'] = true; + expect(inbox.listAll()[0]!.message.payload).not.toHaveProperty('changed'); + }); + + it('fails closed on malformed and inconsistent committed history', async () => { + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, '{"v":1,"sequence":1}\n'); + await expect(openInbox()).rejects.toThrow('inbox line 1 is invalid'); + + await rm(filePath, { force: true }); + const inbox = await openInbox(); + await inbox.admit(userMessage('m1'), limits); + const contents = await readFile(filePath, 'utf8'); + await writeFile(filePath, contents.replace('"sequence":1', '"sequence":2')); + await expect(openInbox()).rejects.toThrow( + 'Expected inbox sequence 1, got 2', + ); + }); + + it('deduplicates exact input and rejects identity reuse or invalid JSON', async () => { + const inbox = await openInbox(256); + await inbox.admit(userMessage('m1'), { + maxPending: 1, + maxPendingPerTenant: 1, + }); + await expect( + inbox.admit(userMessage('m1'), { + maxPending: 1, + maxPendingPerTenant: 1, + }), + ).resolves.toMatchObject({ created: false }); + await expect( + inbox.admit( + userMessage('m1', { payload: { prompt: 'different' } }), + limits, + ), + ).rejects.toBeInstanceOf(ManagedSessionMessageConflictError); + await expect( + inbox.admit( + userMessage('m2', { payload: { invalid: Infinity } }), + limits, + ), + ).rejects.toThrow('numbers must be finite'); + await expect( + inbox.admit( + userMessage('m2', { payload: { text: 'x'.repeat(300) } }), + limits, + ), + ).rejects.toThrow('maximum size of 256'); + await expect( + inbox.admit(userMessage('x'.repeat(513), { payload: null }), limits), + ).rejects.toThrow('messageId must be no larger than 512'); + }); + + it('captures caller-owned payload and limits before entering the write queue', async () => { + const inbox = await openInbox(); + const payload = { prompt: [{ type: 'text', text: 'original' }] }; + const input = { + tenantId: 'tenant-a', + sessionId: 'session-a', + messageId: 'm1', + payload, + }; + const mutableLimits = { maxPending: 1, maxPendingPerTenant: 1 }; + + const admission = inbox.admit(input, mutableLimits); + payload.prompt[0]!.text = 'mutated'; + mutableLimits.maxPending = 0; + mutableLimits.maxPendingPerTenant = 0; + + await expect(admission).resolves.toMatchObject({ + message: { + message: { + payload: { prompt: [{ type: 'text', text: 'original' }] }, + }, + }, + }); + }); + + it('bounds all unfinished work and releases capacity after a fenced terminal', async () => { + const inbox = await openInbox(); + const one = userMessage('m1'); + await inbox.admit(one, { maxPending: 1, maxPendingPerTenant: 1 }); + await inbox.markActivationReady(one); + await inbox.beginProcessing(one, activationFence('m1', 1)); + + await expect( + inbox.admit(userMessage('m2'), { + maxPending: 1, + maxPendingPerTenant: 1, + }), + ).rejects.toMatchObject({ + code: 'GLOBAL_INBOX_FULL', + retryable: true, + }); + + now = 200; + await inbox.finish(one, activationFence('m1', 1), 'completed'); + await expect( + inbox.admit(userMessage('m2'), { + maxPending: 1, + maxPendingPerTenant: 1, + }), + ).resolves.toMatchObject({ created: true }); + }); + + it('applies the per-tenant limit independently of global capacity', async () => { + const inbox = await openInbox(); + await inbox.admit(userMessage('m1'), { + maxPending: 3, + maxPendingPerTenant: 1, + }); + await expect( + inbox.admit(userMessage('m2'), { + maxPending: 3, + maxPendingPerTenant: 1, + }), + ).rejects.toMatchObject({ + code: 'TENANT_INBOX_FULL', + }); + await expect( + inbox.admit( + userMessage('m2', { + tenantId: 'tenant-b', + sessionId: 'session-b', + }), + { maxPending: 3, maxPendingPerTenant: 1 }, + ), + ).resolves.toMatchObject({ created: true }); + }); + + it('lets a newer epoch reclaim processing and fences the older Worker', async () => { + const inbox = await openInbox(); + const input = userMessage('m1'); + await inbox.admit(input, limits); + await inbox.markActivationReady(input); + await inbox.beginProcessing(input, activationFence('m1', 1, 'old')); + await inbox.beginProcessing(input, activationFence('m1', 2, 'new')); + + await expect( + inbox.finish(input, activationFence('m1', 1, 'old'), 'completed'), + ).rejects.toBeInstanceOf(ManagedSessionMessageStaleFenceError); + await expect( + inbox.finish(input, activationFence('m1', 2, 'new'), 'completed'), + ).resolves.toMatchObject({ + state: 'finished', + outcome: 'completed', + fence: { epoch: 2, workerId: 'new' }, + }); + }); + + it('rejects readiness after cancellation without corrupting the inbox', async () => { + const inbox = await openInbox(); + const input = userMessage('m1'); + await inbox.admit(input, limits); + await inbox.requestCancel(input); + const contents = await readFile(filePath, 'utf8'); + + await expect(inbox.markActivationReady(input)).rejects.toThrow( + 'already finished', + ); + expect(await readFile(filePath, 'utf8')).toBe(contents); + expect(inbox.haltedError).toBeUndefined(); + const reopened = await openInbox(); + expect(reopened.get(input)).toMatchObject({ + state: 'finished', + outcome: 'cancelled', + }); + await expect( + reopened.admit(userMessage('m2'), limits), + ).resolves.toMatchObject({ + created: true, + }); + }); + + it('requires the deterministic payload reference and matching fence identity', async () => { + const inbox = await openInbox(); + const input = userMessage('m1'); + await inbox.admit(input, limits); + expect( + inbox.getByPayloadRef(input, managedSessionPayloadRef(input)), + ).toBeDefined(); + expect(() => inbox.getByPayloadRef(input, 'wrong')).toThrow( + 'payloadRef does not match', + ); + + await inbox.markActivationReady(input); + await expect( + inbox.beginProcessing(input, { + ...activationFence('m1', 1), + sessionId: 'other-session', + }), + ).rejects.toThrow('does not match the message identity'); + }); +}); diff --git a/packages/core/src/managed-runtime/managed-session-inbox.ts b/packages/core/src/managed-runtime/managed-session-inbox.ts new file mode 100644 index 00000000000..b1e6cb59428 --- /dev/null +++ b/packages/core/src/managed-runtime/managed-session-inbox.ts @@ -0,0 +1,805 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash } from 'node:crypto'; +import { appendFile, mkdir, readFile, truncate } from 'node:fs/promises'; +import path from 'node:path'; +import { isDeepStrictEqual } from 'node:util'; +import type { + ManagedActivationFence, + ManagedActivationOutcome, +} from './managed-activation-store.js'; + +export type ManagedSessionJsonValue = + | null + | boolean + | number + | string + | ManagedSessionJsonValue[] + | { [key: string]: ManagedSessionJsonValue }; + +export interface ManagedSessionMessageIdentity { + readonly tenantId: string; + readonly sessionId: string; + readonly messageId: string; +} + +export interface ManagedSessionUserMessageInput + extends ManagedSessionMessageIdentity { + readonly payload: unknown; +} + +export interface ManagedSessionUserMessage + extends ManagedSessionMessageIdentity { + readonly payloadRef: string; + readonly payload: ManagedSessionJsonValue; +} + +export interface ManagedSessionInboxLimits { + readonly maxPending: number; + readonly maxPendingPerTenant: number; +} + +export interface ManagedSessionMessageSnapshot { + readonly message: ManagedSessionUserMessage; + readonly admittedAt: number; + readonly admissionSequence: number; + readonly activationReady: boolean; + readonly state: 'admitted' | 'processing' | 'finished'; + readonly fence?: ManagedActivationFence; + readonly outcome?: ManagedActivationOutcome | 'cancelled'; + readonly cancelRequested?: boolean; + readonly finishedAt?: number; +} + +export interface ManagedSessionInboxAdmissionResult { + readonly created: boolean; + readonly message: ManagedSessionMessageSnapshot; +} + +export interface FileManagedSessionInboxOptions { + readonly clock?: () => number; + readonly maxPayloadBytes?: number; +} + +export class ManagedSessionInboxAdmissionError extends Error { + readonly retryable = true; + + constructor( + readonly code: 'GLOBAL_INBOX_FULL' | 'TENANT_INBOX_FULL', + message: string, + ) { + super(message); + this.name = 'ManagedSessionInboxAdmissionError'; + } +} + +export class ManagedSessionMessageConflictError extends Error { + constructor(message: string) { + super(message); + this.name = 'ManagedSessionMessageConflictError'; + } +} + +export class ManagedSessionMessageStaleFenceError extends Error { + constructor(message: string) { + super(message); + this.name = 'ManagedSessionMessageStaleFenceError'; + } +} + +interface MessageState { + message: ManagedSessionUserMessage; + admittedAt: number; + admissionSequence: number; + activationReady: boolean; + state: 'admitted' | 'processing' | 'finished'; + fence?: ManagedActivationFence; + outcome?: ManagedActivationOutcome | 'cancelled'; + cancelRequested?: boolean; + finishedAt?: number; +} + +interface EventBase { + readonly v: 1; + readonly sequence: number; + readonly at: number; +} + +type InboxEvent = + | (EventBase & { + readonly type: 'user.message.cancel_requested'; + readonly identity: ManagedSessionMessageIdentity; + }) + | (EventBase & { + readonly type: 'user.message.admitted'; + readonly message: ManagedSessionUserMessage; + }) + | (EventBase & { + readonly type: 'user.message.activation_ready'; + readonly identity: ManagedSessionMessageIdentity; + }) + | (EventBase & { + readonly type: 'user.message.processing'; + readonly identity: ManagedSessionMessageIdentity; + readonly fence: ManagedActivationFence; + }) + | (EventBase & { + readonly type: 'user.message.finished'; + readonly identity: ManagedSessionMessageIdentity; + readonly fence: ManagedActivationFence; + readonly outcome: ManagedActivationOutcome | 'cancelled'; + }); + +type JsonObject = Record; + +const DEFAULT_MAX_PAYLOAD_BYTES = 1024 * 1024; +const MAX_JSON_DEPTH = 64; +const MAX_IDENTITY_BYTES = 512; + +function object(value: unknown, name: string): JsonObject { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${name} must be an object.`); + } + return value as JsonObject; +} + +function text(value: JsonObject, field: string): string { + const result = value[field]; + if (typeof result !== 'string' || result.trim().length === 0) { + throw new Error(`${field} must be a non-empty string.`); + } + if (Buffer.byteLength(result, 'utf8') > MAX_IDENTITY_BYTES) { + throw new Error( + `${field} must be no larger than ${MAX_IDENTITY_BYTES} UTF-8 bytes.`, + ); + } + return result; +} + +function integer(value: JsonObject, field: string, minimum = 0): number { + const result = value[field]; + if (!Number.isSafeInteger(result) || (result as number) < minimum) { + throw new Error(`${field} must be a safe integer >= ${minimum}.`); + } + return result as number; +} + +function positive(name: string, value: number): void { + if (!Number.isSafeInteger(value) || value < 1) { + throw new Error(`${name} must be a positive safe integer.`); + } +} + +function timestamp(name: string, value: number): void { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`${name} must be a non-negative safe integer.`); + } +} + +function identity(value: unknown): ManagedSessionMessageIdentity { + const record = object(value, 'identity'); + return { + tenantId: text(record, 'tenantId'), + sessionId: text(record, 'sessionId'), + messageId: text(record, 'messageId'), + }; +} + +function fence(value: unknown): ManagedActivationFence { + const record = object(value, 'fence'); + return { + tenantId: text(record, 'tenantId'), + sessionId: text(record, 'sessionId'), + activationId: text(record, 'activationId'), + workerId: text(record, 'workerId'), + epoch: integer(record, 'epoch', 1), + }; +} + +function outcome(value: unknown): ManagedActivationOutcome | 'cancelled' { + if (value !== 'completed' && value !== 'failed' && value !== 'cancelled') { + throw new Error('outcome is invalid.'); + } + return value; +} + +function canonicalJson( + value: unknown, + seen: WeakSet, + depth = 0, +): ManagedSessionJsonValue { + if (depth > MAX_JSON_DEPTH) { + throw new Error( + `payload exceeds the maximum JSON depth of ${MAX_JSON_DEPTH}.`, + ); + } + if ( + value === null || + typeof value === 'boolean' || + typeof value === 'string' + ) { + return value; + } + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + throw new Error('payload numbers must be finite.'); + } + return Object.is(value, -0) ? 0 : value; + } + if (typeof value !== 'object') { + throw new Error('payload must contain only JSON values.'); + } + if (seen.has(value)) throw new Error('payload must not contain cycles.'); + seen.add(value); + try { + if (Array.isArray(value)) { + return value.map((item) => canonicalJson(item, seen, depth + 1)); + } + const prototype = Object.getPrototypeOf(value) as object | null; + if (prototype !== Object.prototype && prototype !== null) { + throw new Error('payload objects must be plain JSON objects.'); + } + if (Object.getOwnPropertySymbols(value).length > 0) { + throw new Error('payload objects must not contain symbol keys.'); + } + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + canonicalJson(item, seen, depth + 1), + ]), + ); + } finally { + seen.delete(value); + } +} + +function message( + value: unknown, + maxPayloadBytes: number, +): ManagedSessionUserMessage { + const record = object(value, 'message'); + const messageIdentity = identity(record); + const payload = canonicalJson(record['payload'], new WeakSet()); + if (Buffer.byteLength(JSON.stringify(payload), 'utf8') > maxPayloadBytes) { + throw new Error( + `payload exceeds the maximum size of ${maxPayloadBytes} UTF-8 bytes.`, + ); + } + const expectedPayloadRef = managedSessionPayloadRef(messageIdentity); + const suppliedPayloadRef = record['payloadRef']; + if ( + suppliedPayloadRef !== undefined && + suppliedPayloadRef !== expectedPayloadRef + ) { + throw new Error('payloadRef does not match the message identity.'); + } + return { + ...messageIdentity, + payloadRef: expectedPayloadRef, + payload, + }; +} + +function parseEvent( + line: string, + lineNumber: number, + maxPayloadBytes: number, +): InboxEvent { + try { + const record = object(JSON.parse(line), 'event'); + if (record['v'] !== 1) throw new Error('version is unsupported.'); + const base = { + v: 1 as const, + sequence: integer(record, 'sequence', 1), + at: integer(record, 'at'), + }; + switch (record['type']) { + case 'user.message.admitted': + return { + ...base, + type: 'user.message.admitted', + message: message(record['message'], maxPayloadBytes), + }; + case 'user.message.activation_ready': + return { + ...base, + type: 'user.message.activation_ready', + identity: identity(record['identity']), + }; + case 'user.message.cancel_requested': + return { + ...base, + type: 'user.message.cancel_requested', + identity: identity(record['identity']), + }; + case 'user.message.processing': + return { + ...base, + type: 'user.message.processing', + identity: identity(record['identity']), + fence: fence(record['fence']), + }; + case 'user.message.finished': + return { + ...base, + type: 'user.message.finished', + identity: identity(record['identity']), + fence: fence(record['fence']), + outcome: outcome(record['outcome']), + }; + default: + throw new Error('event type is unknown.'); + } + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error( + `Managed Session inbox line ${lineNumber} is invalid: ${detail}`, + ); + } +} + +function messageKey(value: ManagedSessionMessageIdentity): string { + return JSON.stringify([value.tenantId, value.sessionId, value.messageId]); +} + +function snapshot(state: MessageState): ManagedSessionMessageSnapshot { + return structuredClone({ + message: state.message, + admittedAt: state.admittedAt, + admissionSequence: state.admissionSequence, + activationReady: state.activationReady, + state: state.state, + ...(state.cancelRequested ? { cancelRequested: true } : {}), + ...(state.fence ? { fence: state.fence } : {}), + ...(state.outcome ? { outcome: state.outcome } : {}), + ...(state.finishedAt === undefined ? {} : { finishedAt: state.finishedAt }), + }); +} + +function fenceMatchesMessage( + messageIdentity: ManagedSessionMessageIdentity, + activationFence: ManagedActivationFence, +): boolean { + return ( + messageIdentity.tenantId === activationFence.tenantId && + messageIdentity.sessionId === activationFence.sessionId && + messageIdentity.messageId === activationFence.activationId + ); +} + +export function managedSessionPayloadRef( + value: ManagedSessionMessageIdentity, +): string { + const digest = createHash('sha256') + .update(JSON.stringify([value.tenantId, value.sessionId, value.messageId])) + .digest('base64url'); + return `managed-user-message:v1:${digest}`; +} + +/** Single-process durable adapter; the same file must have only one owner. */ +export class FileManagedSessionInbox { + private readonly messages = new Map(); + private nextSequence = 1; + private tail: Promise = Promise.resolve(); + private fatalError: Error | undefined; + + private constructor( + readonly filePath: string, + private readonly clock: () => number, + private readonly maxPayloadBytes: number, + ) {} + + static async open( + filePath: string, + options: FileManagedSessionInboxOptions = {}, + ): Promise { + const maxPayloadBytes = + options.maxPayloadBytes ?? DEFAULT_MAX_PAYLOAD_BYTES; + positive('maxPayloadBytes', maxPayloadBytes); + const store = new FileManagedSessionInbox( + filePath, + options.clock ?? Date.now, + maxPayloadBytes, + ); + let contents: Buffer; + try { + contents = await readFile(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return store; + throw error; + } + const complete = + contents.length === 0 || contents[contents.length - 1] === 0x0a; + const validLength = complete + ? contents.length + : contents.lastIndexOf(0x0a) + 1; + const lines = contents + .subarray(0, validLength) + .toString('utf8') + .split('\n'); + lines.pop(); + lines.forEach((line, index) => { + if (!line) { + throw new Error(`Managed Session inbox line ${index + 1} is empty.`); + } + store.apply(parseEvent(line, index + 1, maxPayloadBytes)); + }); + if (!complete) await truncate(filePath, validLength); + return store; + } + + admit( + input: ManagedSessionUserMessageInput, + limits: ManagedSessionInboxLimits, + ): Promise { + let capturedInput: ManagedSessionUserMessageInput; + try { + capturedInput = structuredClone(input); + } catch (error) { + return Promise.reject(error); + } + const capturedLimits = { + maxPending: limits.maxPending, + maxPendingPerTenant: limits.maxPendingPerTenant, + }; + return this.serial(async () => { + const at = this.getCurrentTime(); + positive('maxPending', capturedLimits.maxPending); + positive('maxPendingPerTenant', capturedLimits.maxPendingPerTenant); + if (capturedLimits.maxPendingPerTenant > capturedLimits.maxPending) { + throw new Error('maxPendingPerTenant cannot exceed maxPending.'); + } + const admittedMessage = message(capturedInput, this.maxPayloadBytes); + const existing = this.messages.get(messageKey(admittedMessage)); + if (existing) { + if (!isDeepStrictEqual(existing.message, admittedMessage)) { + throw new ManagedSessionMessageConflictError( + `Managed Session message ${JSON.stringify(admittedMessage.messageId)} was reused with different data.`, + ); + } + return { created: false, message: snapshot(existing) }; + } + + const pending = [...this.messages.values()].filter( + (state) => state.state !== 'finished', + ); + if (pending.length >= capturedLimits.maxPending) { + throw new ManagedSessionInboxAdmissionError( + 'GLOBAL_INBOX_FULL', + `Managed Session inbox is full (${capturedLimits.maxPending}).`, + ); + } + if ( + pending.filter( + (state) => state.message.tenantId === admittedMessage.tenantId, + ).length >= capturedLimits.maxPendingPerTenant + ) { + throw new ManagedSessionInboxAdmissionError( + 'TENANT_INBOX_FULL', + `Managed Session inbox for tenant ${JSON.stringify(admittedMessage.tenantId)} is full (${capturedLimits.maxPendingPerTenant}).`, + ); + } + + await this.persist({ + v: 1, + sequence: this.nextSequence, + at, + type: 'user.message.admitted', + message: admittedMessage, + }); + return { + created: true, + message: snapshot(this.messages.get(messageKey(admittedMessage))!), + }; + }); + } + + markActivationReady( + input: ManagedSessionMessageIdentity, + ): Promise { + const capturedInput = { + tenantId: input.tenantId, + sessionId: input.sessionId, + messageId: input.messageId, + }; + return this.serial(async () => { + const messageIdentity = identity(capturedInput); + const state = this.required(messageIdentity); + if (state.activationReady) return snapshot(state); + if (state.state !== 'admitted') { + throw new Error('Managed Session message is already finished.'); + } + await this.persist({ + v: 1, + sequence: this.nextSequence, + at: this.getCurrentTime(), + type: 'user.message.activation_ready', + identity: messageIdentity, + }); + return snapshot(this.required(messageIdentity)); + }); + } + + beginProcessing( + input: ManagedSessionMessageIdentity, + inputFence: ManagedActivationFence, + ): Promise { + const capturedInput = { + tenantId: input.tenantId, + sessionId: input.sessionId, + messageId: input.messageId, + }; + const capturedFence = { + tenantId: inputFence.tenantId, + sessionId: inputFence.sessionId, + activationId: inputFence.activationId, + workerId: inputFence.workerId, + epoch: inputFence.epoch, + }; + return this.serial(async () => { + const messageIdentity = identity(capturedInput); + const activationFence = fence(capturedFence); + const state = this.required(messageIdentity); + this.assertFenceIdentity(messageIdentity, activationFence); + if (!state.activationReady) { + throw new Error('Managed Session message activation is not ready.'); + } + if (state.state === 'finished') { + throw this.staleFence(messageIdentity, activationFence); + } + if (state.fence) { + if (isDeepStrictEqual(state.fence, activationFence)) { + return snapshot(state); + } + if (activationFence.epoch <= state.fence.epoch) { + throw this.staleFence(messageIdentity, activationFence); + } + } + await this.persist({ + v: 1, + sequence: this.nextSequence, + at: this.getCurrentTime(), + type: 'user.message.processing', + identity: messageIdentity, + fence: activationFence, + }); + return snapshot(this.required(messageIdentity)); + }); + } + + requestCancel( + input: ManagedSessionMessageIdentity, + ): Promise { + const captured = structuredClone(input); + return this.serial(async () => { + const messageIdentity = identity(captured); + const state = this.required(messageIdentity); + if (state.state === 'finished' || state.cancelRequested) + return snapshot(state); + await this.persist({ + v: 1, + sequence: this.nextSequence, + at: this.getCurrentTime(), + type: 'user.message.cancel_requested', + identity: messageIdentity, + }); + return snapshot(this.required(messageIdentity)); + }); + } + + finish( + input: ManagedSessionMessageIdentity, + inputFence: ManagedActivationFence, + inputOutcome: ManagedActivationOutcome | 'cancelled', + ): Promise { + const capturedInput = { + tenantId: input.tenantId, + sessionId: input.sessionId, + messageId: input.messageId, + }; + const capturedFence = { + tenantId: inputFence.tenantId, + sessionId: inputFence.sessionId, + activationId: inputFence.activationId, + workerId: inputFence.workerId, + epoch: inputFence.epoch, + }; + const capturedOutcome = inputOutcome; + return this.serial(async () => { + const messageIdentity = identity(capturedInput); + const activationFence = fence(capturedFence); + const finishedOutcome = outcome(capturedOutcome); + const state = this.required(messageIdentity); + this.assertFenceIdentity(messageIdentity, activationFence); + if (state.state === 'finished') { + if ( + isDeepStrictEqual(state.fence, activationFence) && + state.outcome === finishedOutcome + ) { + return snapshot(state); + } + throw this.staleFence(messageIdentity, activationFence); + } + if ( + state.state !== 'processing' || + !isDeepStrictEqual(state.fence, activationFence) + ) { + throw this.staleFence(messageIdentity, activationFence); + } + await this.persist({ + v: 1, + sequence: this.nextSequence, + at: this.getCurrentTime(), + type: 'user.message.finished', + identity: messageIdentity, + fence: activationFence, + outcome: finishedOutcome, + }); + return snapshot(this.required(messageIdentity)); + }); + } + + get( + input: ManagedSessionMessageIdentity, + ): ManagedSessionMessageSnapshot | undefined { + const state = this.messages.get(messageKey(input)); + return state ? snapshot(state) : undefined; + } + + getByPayloadRef( + input: ManagedSessionMessageIdentity, + payloadRef: string, + ): ManagedSessionMessageSnapshot | undefined { + if (payloadRef !== managedSessionPayloadRef(input)) { + throw new Error('payloadRef does not match the message identity.'); + } + return this.get(input); + } + + listPending(): ManagedSessionMessageSnapshot[] { + return [...this.messages.values()] + .filter((state) => state.state !== 'finished') + .sort((left, right) => left.admissionSequence - right.admissionSequence) + .map(snapshot); + } + + listAll(): ManagedSessionMessageSnapshot[] { + return [...this.messages.values()] + .sort((left, right) => left.admissionSequence - right.admissionSequence) + .map(snapshot); + } + + get haltedError(): Error | undefined { + return this.fatalError; + } + + private getCurrentTime(): number { + const value = this.clock(); + timestamp('clock', value); + return value; + } + + private serial(operation: () => Promise): Promise { + const result = this.tail.then(() => { + if (this.fatalError) throw this.fatalError; + return operation(); + }); + this.tail = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + private async persist(event: InboxEvent): Promise { + try { + await mkdir(path.dirname(this.filePath), { recursive: true }); + await appendFile( + this.filePath, + Buffer.from(`${JSON.stringify(event)}\n`), + { flush: true, mode: 0o600 }, + ); + this.apply(event); + } catch (error) { + this.fatalError = + error instanceof Error ? error : new Error(String(error)); + throw this.fatalError; + } + } + + private apply(event: InboxEvent): void { + if (event.sequence !== this.nextSequence) { + throw new Error( + `Expected inbox sequence ${this.nextSequence}, got ${event.sequence}.`, + ); + } + timestamp('event.at', event.at); + if (event.type === 'user.message.admitted') { + if (this.messages.has(messageKey(event.message))) { + throw new Error('Inbox admits the same message twice.'); + } + this.messages.set(messageKey(event.message), { + message: structuredClone(event.message), + admittedAt: event.at, + admissionSequence: event.sequence, + activationReady: false, + state: 'admitted', + }); + } else if (event.type === 'user.message.activation_ready') { + const state = this.required(event.identity); + if (state.activationReady || state.state !== 'admitted') { + throw new Error( + 'Inbox contains an invalid activation readiness marker.', + ); + } + state.activationReady = true; + } else if (event.type === 'user.message.cancel_requested') { + const state = this.required(event.identity); + if (state.state === 'finished' || state.cancelRequested) + throw new Error('Inbox contains an invalid cancellation request.'); + state.cancelRequested = true; + if (state.state === 'admitted') { + state.state = 'finished'; + state.outcome = 'cancelled'; + state.finishedAt = event.at; + } + } else if (event.type === 'user.message.processing') { + const state = this.required(event.identity); + this.assertFenceIdentity(event.identity, event.fence); + if ( + !state.activationReady || + state.state === 'finished' || + (state.fence !== undefined && event.fence.epoch <= state.fence.epoch) + ) { + throw new Error('Inbox contains an invalid processing fence.'); + } + state.state = 'processing'; + state.fence = structuredClone(event.fence); + } else { + const state = this.required(event.identity); + this.assertFenceIdentity(event.identity, event.fence); + if ( + state.state !== 'processing' || + !isDeepStrictEqual(state.fence, event.fence) + ) { + throw new Error('Inbox contains an invalid message terminal.'); + } + state.state = 'finished'; + state.outcome = event.outcome; + state.finishedAt = event.at; + } + this.nextSequence += 1; + } + + private required(input: ManagedSessionMessageIdentity): MessageState { + const state = this.messages.get(messageKey(input)); + if (!state) { + throw new Error( + `Managed Session message ${JSON.stringify(input.messageId)} not found.`, + ); + } + return state; + } + + private assertFenceIdentity( + messageIdentity: ManagedSessionMessageIdentity, + activationFence: ManagedActivationFence, + ): void { + if (!fenceMatchesMessage(messageIdentity, activationFence)) { + throw new Error('Activation fence does not match the message identity.'); + } + } + + private staleFence( + messageIdentity: ManagedSessionMessageIdentity, + activationFence: ManagedActivationFence, + ): ManagedSessionMessageStaleFenceError { + return new ManagedSessionMessageStaleFenceError( + `Managed Session message ${JSON.stringify(messageIdentity.messageId)} fence at epoch ${activationFence.epoch} is stale.`, + ); + } +} diff --git a/packages/core/src/managed-runtime/managed-session-message-projection.test.ts b/packages/core/src/managed-runtime/managed-session-message-projection.test.ts new file mode 100644 index 00000000000..b0d1e52ebbb --- /dev/null +++ b/packages/core/src/managed-runtime/managed-session-message-projection.test.ts @@ -0,0 +1,965 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { Storage } from '../config/storage.js'; +import type { ChatRecord } from '../services/chatRecordingService.js'; +import { + LocalManagedSessionAuthority, + readManagedSessionLog, +} from './managed-session-authority.js'; +import { + ManagedSessionMessageProjection, + projectManagedSessionTitleInfo, + readManagedSessionRecords, +} from './managed-session-message-projection.js'; +import { LocalManagedSessionResourceStore } from './managed-session-resources.js'; +import { ManagedSessionRecordSink } from './managed-session-record-sink.js'; +import { managedSessionResourceRoot } from '../utils/sessionStorageUtils.js'; +import { + managedSessionEventsDigest, + parseManagedSessionEvent, + type ManagedSessionDurableRef, +} from './managed-session-records.js'; + +const DIGEST = 'e'.repeat(64); +const sessionId = '550e8400-e29b-41d4-a716-446655440000'; +const sessionKey = { tenantId: 't1', workspaceId: 'w1', sessionId }; +const HOLDS = { + class: 'harness', + activation: { activationId: 'act-1', epoch: 1 }, +} as const; + +const temporaryDirectories = new Set(); + +afterEach(async () => { + for (const directory of temporaryDirectories) { + await fs.rm(directory, { recursive: true, force: true }); + } + temporaryDirectories.clear(); +}); + +function ref(kind = 'managed-test'): ManagedSessionDurableRef { + return { + resourceId: 'res-1', + kind, + schemaVersion: 1, + byteLength: 4, + digest: DIGEST, + }; +} + +function command(operation: string, commandId: string) { + return { operation, commandId, sessionKey, contentDigest: DIGEST }; +} + +interface Harness { + authority: LocalManagedSessionAuthority; + projection: ManagedSessionMessageProjection; + store: LocalManagedSessionResourceStore; + transcriptPath: string; + runtimeBaseDir: string; + close(): Promise; +} + +async function createHarness(): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-managed-proj-')); + temporaryDirectories.add(root); + const projectRoot = path.join(root, 'project'); + const runtimeBaseDir = path.join(root, 'runtime'); + await fs.mkdir(projectRoot, { recursive: true }); + await fs.mkdir(runtimeBaseDir, { recursive: true }); + const transcriptPath = path.join( + new Storage(projectRoot, runtimeBaseDir).getProjectDir(), + 'chats', + `${sessionId}.jsonl`, + ); + await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); + + const store = LocalManagedSessionResourceStore.create({ + runtimeBaseDir, + sessionKey, + }); + const lease = await LocalManagedSessionAuthority.acquireWriter({ + runtimeBaseDir, + sessionId, + transcriptPath, + }); + const authority = await LocalManagedSessionAuthority.open({ + lease, + sessionKey, + cwd: projectRoot, + version: 'test', + resources: store, + create: { + definitionRef: ref('managed-definition'), + rootSnapshotRef: ref('managed-root'), + createdBy: 'daemon', + }, + }); + await authority.appendExecution( + command('claimActivation', 'cmd-act-1'), + [ + { + v: 1, + sequence: 1, + eventId: 'evt-act-1', + sessionKey, + kind: 'activation.changed', + occurredAt: 1, + payload: { + activationId: 'act-1', + epoch: 1, + workerId: 'worker-1', + subject: { + type: 'activation', + scopeId: 'act-1', + activationId: 'act-1', + epoch: 1, + }, + phase: 'active', + leaseDurationMs: 60_000, + expiresAt: 2, + installRef: ref(), + boundaryRef: null, + }, + }, + ], + { class: 'coordinator' }, + ); + + return { + authority, + projection: new ManagedSessionMessageProjection(authority, store), + store, + transcriptPath, + runtimeBaseDir, + close: () => authority.close(), + }; +} + +/** Deliberately varied: a plain turn, model metadata, and a system subtype. */ +const records: ChatRecord[] = [ + { + uuid: 'rec-user-1', + parentUuid: null, + sessionId, + timestamp: '2026-09-01T10:00:00.000Z', + type: 'user', + cwd: '/workspace', + version: '1.2.3', + message: { role: 'user', parts: [{ text: 'summarise the design docs' }] }, + }, + { + uuid: 'rec-assistant-1', + parentUuid: 'rec-user-1', + sessionId, + timestamp: '2026-09-01T10:00:05.000Z', + type: 'assistant', + cwd: '/workspace', + version: '1.2.3', + model: 'qwen3-coder-plus', + contextWindowSize: 262144, + usageMetadata: { promptTokenCount: 120, candidatesTokenCount: 45 }, + message: { role: 'model', parts: [{ text: 'Here is the summary.' }] }, + }, + { + uuid: 'rec-system-1', + parentUuid: 'rec-assistant-1', + sessionId, + timestamp: '2026-09-01T10:00:06.000Z', + type: 'system', + subtype: 'slash_command', + cwd: '/workspace', + version: '1.2.3', + }, +] as ChatRecord[]; + +function historicalCheckpoint( + harness: Harness, + stateRef: ManagedSessionDurableRef, + id = 'branch-1', + previous: string | null = null, +) { + return parseManagedSessionEvent({ + v: 1, + sequence: harness.authority.committedSequence + 1, + eventId: `checkpoint:${id}`, + sessionKey, + kind: 'checkpoint.committed', + occurredAt: 1, + subject: { type: 'activation', scopeId: 'act-1', ...HOLDS.activation }, + payload: { + checkpointId: id, + coveredSequence: harness.authority.committedSequence, + previousCheckpointId: previous, + stateRef, + boundary: null, + }, + }); +} + +const branchRecord: ChatRecord = { + ...records[2], + uuid: 'branch-1', + subtype: 'branch_checkpoint', + systemPayload: { + v: 1, + startExclusiveRecordUuid: null, + assistantRecordUuid: records[1].uuid, + }, +}; + +describe('managed session message projection', () => { + it('assigns the message sequence after earlier queued commits', async () => { + const harness = await createHarness(); + const publish = harness.store.publish.bind(harness.store); + let releaseCheckpoint!: () => void; + const checkpointReleased = new Promise((resolve) => { + releaseCheckpoint = resolve; + }); + let checkpointPublishStarted!: () => void; + const checkpointPublishing = new Promise((resolve) => { + checkpointPublishStarted = resolve; + }); + vi.spyOn(harness.store, 'publish').mockImplementation( + async (kind, body) => { + if (kind === 'managed-checkpoint') { + checkpointPublishStarted(); + await checkpointReleased; + } + return publish(kind, body); + }, + ); + const appendEvent = vi.spyOn(harness.authority, 'appendExecutionEvent'); + + try { + const checkpoint = harness.authority.commitCheckpoint( + command('commitCheckpoint', 'queued-checkpoint'), + { state: Buffer.from('checkpoint'), boundary: null }, + HOLDS, + ); + await checkpointPublishing; + const message = harness.projection.commit( + command('commitMessage', 'queued-message'), + { record: records[0] }, + HOLDS, + ); + await vi.waitFor(() => expect(appendEvent).toHaveBeenCalledOnce()); + + releaseCheckpoint(); + await expect(Promise.all([checkpoint, message])).resolves.toHaveLength(2); + expect(harness.authority.committedSequence).toBe(3); + await expect(harness.projection.project()).resolves.toEqual([records[0]]); + } finally { + releaseCheckpoint(); + await harness.close(); + } + }); + + it('assigns an activation transition after earlier queued commits', async () => { + const harness = await createHarness(); + const publish = harness.store.publish.bind(harness.store); + let releaseCheckpoint!: () => void; + const checkpointReleased = new Promise((resolve) => { + releaseCheckpoint = resolve; + }); + let checkpointPublishStarted!: () => void; + const checkpointPublishing = new Promise((resolve) => { + checkpointPublishStarted = resolve; + }); + vi.spyOn(harness.store, 'publish').mockImplementation( + async (kind, body) => { + if (kind === 'managed-checkpoint') { + checkpointPublishStarted(); + await checkpointReleased; + } + return publish(kind, body); + }, + ); + const appendEvent = vi.spyOn(harness.authority, 'appendExecutionEvent'); + + try { + const checkpoint = harness.authority.commitCheckpoint( + command('commitCheckpoint', 'queued-checkpoint'), + { state: Buffer.from('checkpoint'), boundary: null }, + HOLDS, + ); + await checkpointPublishing; + const message = harness.projection.commit( + command('commitMessage', 'queued-message'), + { record: records[0] }, + HOLDS, + ); + await vi.waitFor(() => expect(appendEvent).toHaveBeenCalledOnce()); + const release = harness.authority.releaseActivation(); + await vi.waitFor(() => expect(appendEvent).toHaveBeenCalledTimes(2)); + + releaseCheckpoint(); + await expect( + Promise.all([checkpoint, message, release]), + ).resolves.toHaveLength(3); + expect( + harness.authority + .readEvents() + .map((event) => [event.sequence, event.kind]), + ).toEqual([ + [1, 'activation.changed'], + [2, 'checkpoint.committed'], + [3, 'message.committed'], + [4, 'activation.changed'], + ]); + } finally { + releaseCheckpoint(); + await harness.close(); + } + }); + + it.each([ + [ + 'JSON', + Buffer.from(JSON.stringify({ continuation: { phase: 'before_model' } })), + ], + ['opaque bytes', Buffer.from([0, 255, 1, 2])], + ])( + 'does not project a %s Harness checkpoint as a ChatRecord', + async (_label, state) => { + const harness = await createHarness(); + try { + await harness.projection.commit( + command('commitMessage', 'cmd-msg-0'), + { record: records[0] }, + HOLDS, + ); + await harness.authority.commitCheckpoint( + command('commitCheckpoint', 'cmd-checkpoint'), + { state, boundary: null }, + HOLDS, + ); + await expect( + readManagedSessionRecords({ + transcriptPath: harness.transcriptPath, + runtimeBaseDir: harness.runtimeBaseDir, + sessionKey, + }), + ).resolves.toEqual([records[0]]); + expect(await harness.authority.readCheckpointState()).toEqual(state); + } finally { + await harness.close(); + } + }, + ); + + it('rejects an invalid timestamp in a durable reader-facing record', async () => { + const harness = await createHarness(); + try { + await harness.projection.commit( + command('commitMessage', 'invalid-timestamp'), + { + record: { + ...records[0], + timestamp: 'not-a-timestamp', + }, + }, + HOLDS, + ); + } finally { + await harness.close(); + } + + await expect( + readManagedSessionRecords({ + transcriptPath: harness.transcriptPath, + runtimeBaseDir: harness.runtimeBaseDir, + sessionKey, + }), + ).rejects.toThrow(/invalid reader-facing record/); + }); + + it('projects the latest durable session title for cold restore', async () => { + const harness = await createHarness(); + try { + await harness.authority.commitDomainRecord( + command('renameSession', 'title-1'), + { + domain: 'session_metadata', + content: { title: 'First title', titleSource: 'auto' }, + }, + { class: 'trusted_entry' }, + ); + await harness.authority.commitDomainRecord( + command('renameSession', 'title-2'), + { + domain: 'session_metadata', + content: { title: 'Restored title', titleSource: 'manual' }, + }, + { class: 'trusted_entry' }, + ); + } finally { + await harness.close(); + } + const scan = await readManagedSessionLog( + harness.transcriptPath, + sessionKey, + ); + + await expect( + projectManagedSessionTitleInfo({ scan, resources: harness.store }), + ).resolves.toEqual({ title: 'Restored title', source: 'manual' }); + }); + + it.each(['none', 'before', 'after', 'both'] as const)( + 'reads historical branches, retries and resumes the state chain (state position: %s)', + async (position) => { + const withState = position !== 'none'; + const harness = await createHarness(); + const branch = branchRecord; + const state = Buffer.from('Harness state'); + let checkpointId: string | undefined; + try { + for (const record of records.slice(0, 2)) { + await harness.projection.commit( + command('commitMessage', record.uuid), + { record }, + HOLDS, + ); + } + if (position === 'before' || position === 'both') { + const committed = await harness.authority.commitCheckpoint( + command('commitCheckpoint', 'state-1'), + { state, boundary: null }, + HOLDS, + ); + checkpointId = committed.checkpoint.checkpointId; + } + const stateRef = await harness.store.publish( + 'managed-branch-checkpoint', + Buffer.from(JSON.stringify(branch)), + ); + await harness.authority.appendExecution( + { + ...command('commitBranchCheckpoint', `recorder:${branch.uuid}`), + contentDigest: stateRef.digest, + }, + [ + historicalCheckpoint( + harness, + stateRef, + branch.uuid, + checkpointId ?? null, + ), + ], + HOLDS, + ); + if (position === 'after' || position === 'both') { + const stateAfter = await harness.store.publish( + 'managed-checkpoint', + state, + ); + checkpointId = 'state-after'; + await harness.authority.appendExecution( + command('commitCheckpoint', checkpointId), + [ + historicalCheckpoint( + harness, + stateAfter, + checkpointId, + branch.uuid, + ), + ], + HOLDS, + ); + } + expect(harness.authority.latestCheckpoint?.checkpointId).toBe( + checkpointId, + ); + expect(await harness.projection.project()).toEqual([ + ...records.slice(0, 2), + branch, + ]); + } finally { + await harness.close(); + } + const before = await fs.readFile(harness.transcriptPath); + await expect( + readManagedSessionRecords({ + transcriptPath: harness.transcriptPath, + runtimeBaseDir: harness.runtimeBaseDir, + sessionKey, + }), + ).resolves.toEqual([...records.slice(0, 2), branch]); + const lease = await LocalManagedSessionAuthority.acquireWriter({ + runtimeBaseDir: harness.runtimeBaseDir, + sessionId, + transcriptPath: harness.transcriptPath, + }); + const reopened = await LocalManagedSessionAuthority.open({ + lease, + sessionKey, + cwd: '/workspace', + version: 'test', + resources: harness.store, + }); + try { + expect(reopened.latestCheckpoint?.checkpointId).toBe(checkpointId); + expect(reopened.restoreBasis()).toBe( + withState ? 'checkpoint' : 'blocked', + ); + expect(await reopened.readCheckpointState()).toEqual( + withState ? state : undefined, + ); + expect(await fs.readFile(harness.transcriptPath)).toEqual(before); + const sink = new ManagedSessionRecordSink( + reopened, + harness.store, + () => HOLDS, + ); + await sink.write(branch); + expect(await fs.readFile(harness.transcriptPath)).toEqual(before); + await expect( + sink.write({ ...branch, parentUuid: 'changed' }), + ).rejects.toThrow(/different content/); + const next = await reopened.commitCheckpoint( + command('commitCheckpoint', 'next-state'), + { state: Buffer.from('next'), boundary: null }, + HOLDS, + ); + expect(next.checkpoint.previousCheckpointId).toBe(checkpointId ?? null); + const newBranch = { ...branch, uuid: 'branch-new' }; + await sink.write(newBranch); + const afterBranch = await fs.readFile(harness.transcriptPath); + await sink.write(newBranch); + expect(await fs.readFile(harness.transcriptPath)).toEqual(afterBranch); + expect(reopened.latestCheckpoint).toEqual(next.checkpoint); + const last = await reopened.commitCheckpoint( + command('commitCheckpoint', 'last-state'), + { state: Buffer.from('last'), boundary: null }, + HOLDS, + ); + expect(last.checkpoint.previousCheckpointId).toBe( + next.checkpoint.checkpointId, + ); + expect( + await readManagedSessionRecords({ + transcriptPath: harness.transcriptPath, + runtimeBaseDir: harness.runtimeBaseDir, + sessionKey, + }), + ).toEqual([...records.slice(0, 2), branch, newBranch]); + } finally { + await reopened.close(); + } + }, + ); + + it.each([ + ['wrong record ID', JSON.stringify({ ...branchRecord, uuid: 'other' })], + ['wrong session', JSON.stringify({ ...branchRecord, sessionId: 'other' })], + ['wrong type', JSON.stringify({ ...branchRecord, type: 'assistant' })], + [ + 'wrong subtype', + JSON.stringify({ ...branchRecord, subtype: 'slash_command' }), + ], + [ + 'missing parent', + JSON.stringify({ ...branchRecord, parentUuid: undefined }), + ], + [ + 'invalid payload', + JSON.stringify({ ...branchRecord, systemPayload: { v: 2 } }), + ], + [ + 'duplicate JSON key', + JSON.stringify(branchRecord).replace('"v":1', '"v":2,"v":1'), + ], + ['invalid JSON', 'not a record'], + ])( + 'rejects %s before appending and permits a corrected retry', + async (_name, body) => { + const harness = await createHarness(); + try { + const ref = await harness.store.publish( + 'managed-branch-checkpoint', + Buffer.from(body), + ); + const event = historicalCheckpoint(harness, ref); + const before = await fs.readFile(harness.transcriptPath); + const cmd = command('commitBranchCheckpoint', 'retryable'); + await expect( + harness.authority.appendExecution(cmd, [event], HOLDS), + ).rejects.toThrow(); + expect(await fs.readFile(harness.transcriptPath)).toEqual(before); + expect(harness.authority.latestCheckpoint).toBeUndefined(); + expect(harness.authority.restoreBasis()).toBe('initial'); + const valid = await harness.store.publish( + 'managed-branch-checkpoint', + Buffer.from(JSON.stringify(branchRecord)), + ); + await harness.authority.appendExecution( + cmd, + [historicalCheckpoint(harness, valid)], + HOLDS, + ); + expect(harness.authority.restoreBasis()).toBe('blocked'); + expect(await harness.projection.project()).toEqual([branchRecord]); + } finally { + await harness.close(); + } + }, + ); + + it.each([ + 'kind', + 'version', + 'coverage', + 'dangling predecessor', + 'self predecessor', + 'boundary', + ] as const)( + 'rejects an invalid checkpoint %s before committing', + async (invalid) => { + const harness = await createHarness(); + try { + let ref = await harness.store.publish( + 'managed-branch-checkpoint', + Buffer.from(JSON.stringify(branchRecord)), + ); + if (invalid === 'kind') ref = { ...ref, kind: 'managed-other' }; + if (invalid === 'version') ref = { ...ref, schemaVersion: 2 }; + const event = historicalCheckpoint(harness, ref); + const payload = { + ...event.payload, + ...(invalid === 'coverage' + ? { coveredSequence: event.sequence } + : {}), + ...(invalid === 'dangling predecessor' + ? { previousCheckpointId: 'missing' } + : {}), + ...(invalid === 'self predecessor' + ? { previousCheckpointId: 'branch-1' } + : {}), + ...(invalid === 'boundary' ? { boundary: 'turn_settled' } : {}), + }; + const before = await fs.readFile(harness.transcriptPath); + await expect( + harness.authority.appendExecution( + command('commitBranchCheckpoint', 'invalid'), + [{ ...event, payload }], + HOLDS, + ), + ).rejects.toThrow(); + expect(await fs.readFile(harness.transcriptPath)).toEqual(before); + expect(harness.authority.latestCheckpoint).toBeUndefined(); + } finally { + await harness.close(); + } + }, + ); + + it.each(['digest', 'length', 'missing'])( + 'rejects a historical branch resource with %s damage on cold reads and reopen', + async (damage) => { + const harness = await createHarness(); + const bytes = Buffer.from(JSON.stringify(branchRecord)); + const ref = await harness.store.publish( + 'managed-branch-checkpoint', + bytes, + ); + await harness.authority.appendExecution( + command('commitCheckpoint', 'committed'), + [historicalCheckpoint(harness, ref)], + HOLDS, + ); + await harness.close(); + const resourcePath = path.join( + harness.store.sessionRoot, + ref.kind, + ref.resourceId, + ); + if (damage === 'missing') { + await fs.unlink(resourcePath); + } else { + await fs.writeFile( + resourcePath, + Buffer.alloc(damage === 'length' ? 0 : bytes.length), + ); + } + const error = + damage === 'digest' + ? /digest/ + : damage === 'length' + ? /bytes where/ + : /not present/; + const before = await fs.readFile(harness.transcriptPath); + await expect( + readManagedSessionRecords({ + transcriptPath: harness.transcriptPath, + runtimeBaseDir: harness.runtimeBaseDir, + sessionKey, + }), + ).rejects.toThrow(error); + const lease = await LocalManagedSessionAuthority.acquireWriter({ + runtimeBaseDir: harness.runtimeBaseDir, + sessionId, + transcriptPath: harness.transcriptPath, + }); + try { + await expect( + LocalManagedSessionAuthority.open({ + lease, + sessionKey, + cwd: '/workspace', + version: 'test', + resources: harness.store, + }), + ).rejects.toThrow(error); + expect(await fs.readFile(harness.transcriptPath)).toEqual(before); + } finally { + await lease.release(); + } + }, + ); + + it('rejects coverage of the same transaction on both hot and cold paths', async () => { + const harness = await createHarness(); + const stateRef = await harness.store.publish( + 'managed-checkpoint', + Buffer.from('state'), + ); + const contentRef = await harness.store.publish( + 'managed-message', + Buffer.from(JSON.stringify(records[0])), + ); + const checkpoint = historicalCheckpoint(harness, stateRef, 'state-1'); + const events = [ + parseManagedSessionEvent({ + ...checkpoint, + eventId: 'message-1', + kind: 'message.committed', + payload: { + messageId: records[0].uuid, + role: 'user', + contentRef, + parentMessageId: null, + }, + }), + parseManagedSessionEvent({ + ...checkpoint, + sequence: checkpoint.sequence + 1, + payload: { + ...checkpoint.payload, + coveredSequence: checkpoint.sequence, + }, + }), + ]; + try { + await expect( + harness.authority.appendExecution( + command('commitCheckpoint', 'bad-coverage'), + events, + HOLDS, + ), + ).rejects.toThrow(/coverage/); + } finally { + await harness.close(); + } + const scan = await readManagedSessionLog( + harness.transcriptPath, + sessionKey, + ); + const marker = { + transactionId: 'historical-batch', + commandId: 'historical-batch', + operation: 'commitCheckpoint', + contentDigest: DIGEST, + firstSequence: events[0].sequence, + lastSequence: events[1].sequence, + eventCount: events.length, + eventsDigest: managedSessionEventsDigest(events), + previousCommitDigest: scan.lastMarkerDigest, + }; + await fs.appendFile( + harness.transcriptPath, + [ + ...events.map((event) => + JSON.stringify({ + subtype: 'managed_session_event_v1', + managedSession: event, + }), + ), + JSON.stringify({ + subtype: 'managed_session_commit_v1', + managedSession: marker, + }), + ].join('\n') + '\n', + ); + await expect( + readManagedSessionRecords({ + transcriptPath: harness.transcriptPath, + runtimeBaseDir: harness.runtimeBaseDir, + sessionKey, + }), + ).rejects.toThrow(/not committed before its transaction/); + }); + + it.each([ + { label: 'input', record: records[0], basis: 'initial' }, + { label: 'assistant', record: records[1], basis: 'blocked' }, + { label: 'system input', record: records[2], basis: 'initial' }, + { + label: 'tool result', + record: { ...records[1], type: 'tool_result' } as ChatRecord, + basis: 'blocked', + }, + ])( + 'classifies $label without a checkpoint consistently on cold reopen', + async ({ record, basis }) => { + const harness = await createHarness(); + try { + await harness.projection.commit( + command('commitMessage', 'only-record'), + { record }, + HOLDS, + ); + expect(harness.authority.restoreBasis()).toBe(basis); + expect(harness.authority.latestCheckpoint).toBeUndefined(); + } finally { + await harness.close(); + } + const lease = await LocalManagedSessionAuthority.acquireWriter({ + runtimeBaseDir: harness.runtimeBaseDir, + sessionId, + transcriptPath: harness.transcriptPath, + }); + const reopened = await LocalManagedSessionAuthority.open({ + lease, + sessionKey, + cwd: '/workspace', + version: 'test', + resources: harness.store, + }); + try { + expect(reopened.restoreBasis()).toBe(basis); + expect(reopened.latestCheckpoint).toBeUndefined(); + } finally { + await reopened.close(); + } + }, + ); + + it('round trips records through the authoritative log without losing detail', async () => { + const harness = await createHarness(); + for (const [index, record] of records.entries()) { + await harness.projection.commit( + command('commitMessage', `cmd-msg-${index}`), + { record }, + HOLDS, + ); + } + + const projected = await harness.projection.project(); + expect(projected).toEqual(records); + await harness.close(); + }); + + it('keeps no legacy copy of the projected records', async () => { + const harness = await createHarness(); + await harness.projection.commit( + command('commitMessage', 'cmd-msg-0'), + { record: records[0] }, + HOLDS, + ); + await harness.close(); + + const subtypes = (await fs.readFile(harness.transcriptPath, 'utf8')) + .split('\n') + .filter((line) => line !== '') + .map((line) => (JSON.parse(line) as { subtype?: string }).subtype); + + /* The content lives only in the resource the event references, so no + equivalent user/assistant record may sit beside it in the transcript. */ + expect(new Set(subtypes)).toEqual( + new Set([ + 'session_execution_engine', + 'managed_session_header_v1', + 'managed_session_event_v1', + 'managed_session_commit_v1', + ]), + ); + }); + + it('survives a cold reopen', async () => { + const harness = await createHarness(); + for (const [index, record] of records.entries()) { + await harness.projection.commit( + command('commitMessage', `cmd-msg-${index}`), + { record }, + HOLDS, + ); + } + await harness.close(); + + const lease = await LocalManagedSessionAuthority.acquireWriter({ + runtimeBaseDir: harness.runtimeBaseDir, + sessionId, + transcriptPath: harness.transcriptPath, + }); + const store = LocalManagedSessionResourceStore.create({ + runtimeBaseDir: harness.runtimeBaseDir, + sessionKey, + }); + const reopened = await LocalManagedSessionAuthority.open({ + lease, + sessionKey, + cwd: '/workspace', + version: 'test', + resources: store, + }); + const projected = await new ManagedSessionMessageProjection( + reopened, + store, + ).project(); + expect(projected).toEqual(records); + await reopened.close(); + }); + + it('refuses a record with no uuid of its own', async () => { + const harness = await createHarness(); + await expect( + harness.projection.commit( + command('commitMessage', 'cmd-msg-bad'), + { record: { ...records[0], uuid: '' } as ChatRecord }, + HOLDS, + ), + ).rejects.toThrow(/must carry its own uuid/); + await harness.close(); + }); + + it('fails the projection when a content body is missing', async () => { + const harness = await createHarness(); + await harness.projection.commit( + command('commitMessage', 'cmd-msg-0'), + { record: records[0] }, + HOLDS, + ); + + /* Dropping the record silently would present a short history as complete. */ + await fs.rm( + path.join( + managedSessionResourceRoot(harness.runtimeBaseDir, sessionId), + 'managed-message', + ), + { recursive: true, force: true }, + ); + await expect(harness.projection.project()).rejects.toThrow( + /is not present for session/, + ); + await harness.close(); + }); +}); diff --git a/packages/core/src/managed-runtime/managed-session-message-projection.ts b/packages/core/src/managed-runtime/managed-session-message-projection.ts new file mode 100644 index 00000000000..ecb94c2d47d --- /dev/null +++ b/packages/core/src/managed-runtime/managed-session-message-projection.ts @@ -0,0 +1,321 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { ChatRecord } from '../services/chatRecordingService.js'; +import { validateTranscriptRecord } from '../utils/transcript-records.js'; +import { + MANAGED_SESSION_FORMAT_VERSION, + MANAGED_SESSION_LIMITS, + ManagedSessionRecordError, + type ManagedSessionDurableRef, + type ManagedSessionEvent, + type ManagedSessionKey, +} from './managed-session-records.js'; +import { + readManagedSessionLog, + type LocalManagedSessionAuthority, + type ManagedSessionActor, + type ManagedSessionCommand, + type ManagedSessionCommitReceipt, +} from './managed-session-authority.js'; +import { + LocalManagedSessionResourceStore, + readManagedBranchCheckpoint, +} from './managed-session-resources.js'; +import type { + ManagedSessionJournalScan, + ManagedSessionResourceStore, +} from './managed-session-storage.js'; + +/** + * Carries the existing transcript history inside the authoritative log. + * + * A Managed session must not append an equivalent legacy record beside each + * committed event, so the domain content is the only copy and every reader-facing + * record is projected back out of it. Storing the whole original record as the + * content body is what makes that projection lossless: subtype, message parts, + * usage metadata and tool-call details all survive, while the event carries the + * identity and ordering facts a reader indexes on. + */ +export class ManagedSessionMessageProjection { + constructor( + private readonly authority: LocalManagedSessionAuthority, + private readonly resources: ManagedSessionResourceStore, + ) {} + + /** + * Commits one record as a `message.committed` event. The caller supplies the + * actor: the current Harness for model-facing records, or a trusted entry for + * the projected form of an accepted input, which has no activation to name. + */ + async commit( + command: ManagedSessionCommand, + input: { record: ChatRecord; modelAttemptId?: string | null }, + actor: ManagedSessionActor, + ): Promise<{ receipt: ManagedSessionCommitReceipt; messageId: string }> { + const record = input.record; + if (typeof record.uuid !== 'string' || record.uuid.length === 0) { + throw new ManagedSessionRecordError( + 'a projected record must carry its own uuid.', + ); + } + const body = Buffer.from(JSON.stringify(record), 'utf8'); + const contentRef = await this.resources.publish('managed-message', body); + const subject = + actor.class === 'harness' && actor.activation !== undefined + ? { + type: 'activation', + scopeId: actor.activation.activationId, + activationId: actor.activation.activationId, + epoch: actor.activation.epoch, + } + : undefined; + const receipt = await this.authority.appendExecutionEvent( + command, + (sequence) => ({ + v: MANAGED_SESSION_FORMAT_VERSION, + sequence, + eventId: `message:${record.uuid}`, + sessionKey: command.sessionKey, + kind: 'message.committed', + occurredAt: Date.parse(record.timestamp) || Date.now(), + ...(subject === undefined ? {} : { subject }), + payload: { + messageId: record.uuid, + role: record.type, + contentRef, + parentMessageId: record.parentUuid, + ...(input.modelAttemptId === undefined + ? {} + : { modelAttemptId: input.modelAttemptId }), + }, + }), + actor, + ); + return { receipt, messageId: record.uuid }; + } + + /** + * Rebuilds the reader-facing records from the committed prefix, in commit + * order. A content body that cannot be resolved fails the projection rather + * than silently dropping a record, which would present a short history as a + * complete one. + */ + async project(): Promise { + const records: ChatRecord[] = []; + const checkpoints = new Map(); + let after = 0; + for (;;) { + const page = this.authority.readEvents({ + afterSequence: after, + limit: MANAGED_SESSION_LIMITS.maxReadEvents, + }); + if (page.length === 0) break; + for (const event of page) { + after = event.sequence; + const branch = await readManagedBranchCheckpoint( + event, + this.resources, + (id) => checkpoints.get(id), + this.authority.committedSequence, + ); + if (event.kind === 'checkpoint.committed') { + checkpoints.set( + event.payload['checkpointId'] as string, + event.sequence, + ); + } + if (branch !== undefined) { + records.push(branch); + } else if (event.kind === 'message.committed') { + records.push( + await readRecordBody(this.resources, event.payload['contentRef']), + ); + } + } + } + return records; + } +} + +async function readRecordBody( + resources: ManagedSessionResourceStore, + ref: ManagedSessionEvent['payload'][string], +): Promise { + const body = await resources.read( + ref as unknown as Parameters[0], + ); + return JSON.parse(body.toString('utf8')) as ChatRecord; +} + +/** + * Projects a Managed session's reader-facing records without taking the writer. + * + * Session loading runs on paths that never write, so it cannot go through the + * authority: acquiring a lease there would fight the live writer and fail for a + * session that is merely being read. The committed prefix is the whole history, + * so a torn or uncommitted tail left by a crashed writer is simply not part of + * what a reader sees. + * + * Every channel that carries a whole original record is projected, in commit + * order; `readerFacingBody` is the list of them. + */ +export async function readManagedSessionRecords(options: { + readonly transcriptPath: string; + readonly runtimeBaseDir: string; + readonly sessionKey: ManagedSessionKey; + /** Bounds the projection to a frozen snapshot's byte length. */ + readonly maxBytes?: number; +}): Promise { + const scan = await readManagedSessionLog( + options.transcriptPath, + options.sessionKey, + options.maxBytes, + ); + const resources = LocalManagedSessionResourceStore.create({ + runtimeBaseDir: options.runtimeBaseDir, + sessionKey: options.sessionKey, + }); + return projectManagedSessionRecords({ scan, resources }); +} + +/** Projects an already-verified durable journal through its resource store. */ +export async function projectManagedSessionRecords(options: { + readonly scan: ManagedSessionJournalScan; + readonly resources: ManagedSessionResourceStore; +}): Promise { + const { scan, resources } = options; + if (scan.header === undefined) { + throw new ManagedSessionRecordError( + 'session log has no Managed header, so it cannot be projected.', + ); + } + const records: ChatRecord[] = []; + const checkpoints = new Map(); + for (const event of scan.events) { + const branch = await readManagedBranchCheckpoint( + event, + resources, + (id) => checkpoints.get(id), + scan.committed, + ); + if (event.kind === 'checkpoint.committed') { + checkpoints.set(event.payload['checkpointId'] as string, event.sequence); + } + if (branch !== undefined) { + records.push( + requireProjectedRecord(branch, scan.header.sessionKey.sessionId), + ); + continue; + } + const carried = readerFacingBody(event); + if (carried === undefined) continue; + const body = await readRecordBody(resources, carried.ref); + records.push( + requireProjectedRecord( + carried.inDomainEnvelope + ? (body as unknown as { record: ChatRecord }).record + : body, + scan.header.sessionKey.sessionId, + ), + ); + } + return records; +} + +/** Restores the latest committed title metadata from a durable journal. */ +export async function projectManagedSessionTitleInfo(options: { + readonly scan: ManagedSessionJournalScan; + readonly resources: ManagedSessionResourceStore; +}): Promise<{ title?: string; source?: 'auto' | 'manual' }> { + const { scan, resources } = options; + if (scan.header === undefined) { + throw new ManagedSessionRecordError( + 'session log has no Managed header, so it cannot be projected.', + ); + } + const event = scan.events.findLast( + (candidate) => + candidate.kind === 'domain.committed' && + candidate.payload['domain'] === 'session_metadata', + ); + if (event === undefined) return {}; + const body = JSON.parse( + ( + await resources.read( + event.payload['recordRef'] as unknown as ManagedSessionDurableRef, + ) + ).toString('utf8'), + ) as { title?: unknown; titleSource?: unknown }; + if (typeof body.title !== 'string' || body.title.length === 0) return {}; + return { + title: body.title, + ...(body.titleSource === 'auto' || body.titleSource === 'manual' + ? { source: body.titleSource } + : {}), + }; +} + +function requireProjectedRecord(value: unknown, sessionId: string): ChatRecord { + const { record, diagnostics } = validateTranscriptRecord(value); + if (record === undefined) { + throw new ManagedSessionRecordError( + 'Managed Session resource contains an invalid reader-facing record.', + ); + } + const candidate = record as Partial; + if ( + record.sessionId !== sessionId || + typeof candidate.cwd !== 'string' || + typeof candidate.version !== 'string' || + typeof candidate.timestamp !== 'string' || + diagnostics.length > 0 + ) { + throw new ManagedSessionRecordError( + 'Managed Session resource contains an invalid reader-facing record.', + ); + } + return candidate as ChatRecord; +} + +/** + * Domains whose body is a whole reader-facing record. The rest carry their own + * shape and are not something a reader replays. + */ +const RECORD_CARRYING_DOMAINS: ReadonlySet = new Set([ + 'goal_state', + 'file_history', + 'session_source', +]); + +/** + * Where a whole reader-facing record lives, for the channels that carry one. + * + * A domain body is the authority's envelope wrapping the content, so the record + * sits under its own key there, unlike the event channels whose body is the + * record itself. + */ +function readerFacingBody(event: ManagedSessionEvent): + | { + ref: ManagedSessionEvent['payload'][string]; + inDomainEnvelope: boolean; + } + | undefined { + switch (event.kind) { + case 'message.committed': + return { ref: event.payload['contentRef'], inDomainEnvelope: false }; + case 'turn.settled': + return { ref: event.payload['resultRef'], inDomainEnvelope: false }; + case 'context.compacted': + return { ref: event.payload['summaryRef'], inDomainEnvelope: false }; + case 'domain.committed': + return RECORD_CARRYING_DOMAINS.has(event.payload['domain']) + ? { ref: event.payload['recordRef'], inDomainEnvelope: true } + : undefined; + default: + return undefined; + } +} diff --git a/packages/core/src/managed-runtime/managed-session-metadata.test.ts b/packages/core/src/managed-runtime/managed-session-metadata.test.ts new file mode 100644 index 00000000000..6cf2ea3be44 --- /dev/null +++ b/packages/core/src/managed-runtime/managed-session-metadata.test.ts @@ -0,0 +1,814 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { SessionService } from '../services/sessionService.js'; +import { + getSessionWriterLockPath, + SessionWriterLease, +} from '../services/session-writer-lease.js'; +import { Storage } from '../config/storage.js'; +import { isManagedSessionTranscriptSync } from '../utils/sessionStorageUtils.js'; +import { + readManagedSessionTitleInfoSync, + readManagedSessionSourceSync, +} from '../utils/sessionStorageUtils.js'; +import { LocalManagedSessionAuthority } from './managed-session-authority.js'; +import { LocalManagedSessionResourceStore } from './managed-session-resources.js'; +import type { ManagedSessionDurableRef } from './managed-session-records.js'; + +const temporaryDirectories = new Set(); + +afterEach(async () => { + for (const directory of temporaryDirectories) { + await fs.rm(directory, { recursive: true, force: true }); + } + temporaryDirectories.clear(); +}); + +const sessionId = '550e8400-e29b-41d4-a716-446655440000'; +const sessionKey = { + tenantId: 'tenant-1', + workspaceId: 'workspace-1', + sessionId, +}; + +interface Harness { + runtimeBaseDir: string; + transcriptPath: string; + store: LocalManagedSessionResourceStore; +} + +async function createHarness(): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-managed-meta-')); + temporaryDirectories.add(root); + const runtimeBaseDir = path.join(root, 'runtime'); + const transcriptPath = path.join(root, 'chats', `${sessionId}.jsonl`); + await fs.mkdir(runtimeBaseDir, { recursive: true }); + await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); + return { + runtimeBaseDir, + transcriptPath, + store: LocalManagedSessionResourceStore.create({ + runtimeBaseDir, + sessionKey, + }), + }; +} + +async function withAuthority( + harness: Harness, + run: (authority: LocalManagedSessionAuthority) => Promise, + options: { create?: boolean } = {}, +): Promise { + const lease = await SessionWriterLease.acquire({ + runtimeBaseDir: harness.runtimeBaseDir, + sessionId, + transcriptPath: harness.transcriptPath, + }); + try { + const create = + options.create === false + ? undefined + : { + definitionRef: await harness.store.publish( + 'managed-definition', + Buffer.from('{}', 'utf8'), + ), + rootSnapshotRef: await harness.store.publish( + 'managed-root', + Buffer.from('{}', 'utf8'), + ), + createdBy: 'daemon', + }; + const authority = await LocalManagedSessionAuthority.open({ + lease, + sessionKey, + cwd: '/workspace', + version: 'test', + resources: harness.store, + ...(create === undefined ? {} : { create }), + }); + return await run(authority); + } finally { + await lease.release().catch(() => undefined); + } +} + +function renameCommand(commandId: string) { + return { + operation: 'renameSession', + commandId, + sessionKey, + contentDigest: 'd'.repeat(64), + }; +} + +describe('managed session metadata', () => { + it('projects a renamed title into the synchronous directory read', async () => { + const harness = await createHarness(); + await withAuthority(harness, async (authority) => { + const committed = await authority.commitDomainRecord( + renameCommand('cmd-rename-1'), + { + domain: 'session_metadata', + content: { title: 'Design review notes', titleSource: 'manual' }, + }, + { class: 'trusted_entry' }, + ); + expect(committed.revision).toBe(1); + expect(committed.recordRef.kind).toBe('managed-session_metadata'); + }); + + expect( + readManagedSessionTitleInfoSync( + harness.transcriptPath, + harness.runtimeBaseDir, + ), + ).toEqual({ title: 'Design review notes', source: 'manual' }); + }); + + it('projects the latest title and chains each revision', async () => { + const harness = await createHarness(); + const refs: ManagedSessionDurableRef[] = []; + await withAuthority(harness, async (authority) => { + refs.push( + ( + await authority.commitDomainRecord( + renameCommand('cmd-rename-1'), + { + domain: 'session_metadata', + content: { title: 'First title', titleSource: 'auto' }, + }, + { class: 'trusted_entry' }, + ) + ).recordRef, + ); + const second = await authority.commitDomainRecord( + renameCommand('cmd-rename-2'), + { + domain: 'session_metadata', + content: { title: 'Second title', titleSource: 'manual' }, + }, + { class: 'trusted_entry' }, + ); + expect(second.revision).toBe(2); + refs.push(second.recordRef); + }); + + expect( + readManagedSessionTitleInfoSync( + harness.transcriptPath, + harness.runtimeBaseDir, + ), + ).toEqual({ title: 'Second title', source: 'manual' }); + + const body = JSON.parse( + (await harness.store.read(refs[1])).toString('utf8'), + ) as { revision: number; previousRecordRef: { resourceId: string } | null }; + expect(body.revision).toBe(2); + expect(body.previousRecordRef?.resourceId).toBe(refs[0].resourceId); + }); + + it.each([ + ['session_metadata', 'missing'], + ['session_metadata', 'incomplete'], + ['session_source', 'missing'], + ['session_source', 'incomplete'], + ] as const)( + 'hides %s until its marker is complete (%s)', + async (domain, tail) => { + const harness = await createHarness(); + await withAuthority(harness, async (authority) => { + for (const value of ['committed', 'uncommitted']) { + await authority.commitDomainRecord( + renameCommand(`cmd-${value}`), + { + domain, + content: + domain === 'session_metadata' + ? { title: value, titleSource: 'manual' } + : { + record: { + systemPayload: { sourceType: 'fork', sourceId: value }, + }, + }, + }, + { class: 'trusted_entry' }, + ); + } + }); + const text = await fs.readFile(harness.transcriptPath, 'utf8'); + const lines = text.trimEnd().split('\n'); + if (tail === 'missing') lines.pop(); + await fs.writeFile( + harness.transcriptPath, + lines.join('\n') + (tail === 'missing' ? '\n' : ''), + ); + + expect( + domain === 'session_metadata' + ? readManagedSessionTitleInfoSync( + harness.transcriptPath, + harness.runtimeBaseDir, + ) + : readManagedSessionSourceSync( + harness.transcriptPath, + harness.runtimeBaseDir, + ), + ).toEqual( + domain === 'session_metadata' + ? { title: 'committed', source: 'manual' } + : { sourceType: 'fork', sourceId: 'committed' }, + ); + }, + ); + + it('finds the committed title in the bounded tail window of a long log', async () => { + const harness = await createHarness(); + await withAuthority(harness, async (authority) => { + for (let index = 0; index < 64; index++) { + await authority.commitDomainRecord( + renameCommand(`cmd-${index}`), + { + domain: 'session_metadata', + content: { title: `Title ${index}`, titleSource: 'manual' }, + }, + { class: 'trusted_entry' }, + ); + } + }); + const text = await fs.readFile(harness.transcriptPath, 'utf8'); + expect(Buffer.byteLength(text)).toBeGreaterThan(64 * 1024); + const lines = text.trimEnd().split('\n'); + lines.pop(); + await fs.writeFile(harness.transcriptPath, `${lines.join('\n')}\n`); + + expect( + readManagedSessionTitleInfoSync( + harness.transcriptPath, + harness.runtimeBaseDir, + ), + ).toEqual({ + title: 'Title 62', + source: 'manual', + }); + }); + + it.each(['length', 'digest'] as const)( + 'hides a title resource with a mismatched %s', + async (mismatch) => { + const harness = await createHarness(); + const ref = await withAuthority( + harness, + async (authority) => + ( + await authority.commitDomainRecord( + renameCommand('cmd-rename'), + { + domain: 'session_metadata', + content: { title: 'original', titleSource: 'manual' }, + }, + { class: 'trusted_entry' }, + ) + ).recordRef, + ); + const resourcePath = path.join( + harness.store.sessionRoot, + ref.kind, + ref.resourceId, + ); + const original = await fs.readFile(resourcePath, 'utf8'); + await fs.writeFile( + resourcePath, + original.replace( + 'original', + mismatch === 'length' ? 'longer title' : 'tampered', + ), + ); + + expect( + readManagedSessionTitleInfoSync( + harness.transcriptPath, + harness.runtimeBaseDir, + ), + ).toEqual({}); + }, + ); + + it('recovers the revision chain across a cold reopen', async () => { + const harness = await createHarness(); + await withAuthority(harness, async (authority) => { + await authority.commitDomainRecord( + renameCommand('cmd-rename-1'), + { + domain: 'session_metadata', + content: { title: 'Before reopen', titleSource: 'manual' }, + }, + { class: 'trusted_entry' }, + ); + }); + + await withAuthority( + harness, + async (authority) => { + expect(authority.domainRecord('session_metadata')?.revision).toBe(1); + const next = await authority.commitDomainRecord( + renameCommand('cmd-rename-2'), + { + domain: 'session_metadata', + content: { title: 'After reopen', titleSource: 'manual' }, + }, + { class: 'trusted_entry' }, + ); + expect(next.revision).toBe(2); + }, + { create: false }, + ); + + expect( + readManagedSessionTitleInfoSync( + harness.transcriptPath, + harness.runtimeBaseDir, + ), + ).toEqual({ title: 'After reopen', source: 'manual' }); + }); + + it('reports no custom title for a managed session never renamed', async () => { + const harness = await createHarness(); + await withAuthority(harness, async () => undefined); + + expect( + readManagedSessionTitleInfoSync( + harness.transcriptPath, + harness.runtimeBaseDir, + ), + ).toEqual({}); + }); + + it('defers to the legacy reader for a non-managed transcript', async () => { + const harness = await createHarness(); + await fs.writeFile( + harness.transcriptPath, + `${JSON.stringify({ + uuid: 'legacy-1', + parentUuid: null, + sessionId, + timestamp: new Date().toISOString(), + type: 'system', + subtype: 'custom_title', + customTitle: 'Legacy title', + titleSource: 'manual', + })}\n`, + 'utf8', + ); + + expect( + readManagedSessionTitleInfoSync( + harness.transcriptPath, + harness.runtimeBaseDir, + ), + ).toBeUndefined(); + }); + + it('refuses a registered domain that is not enabled', async () => { + const harness = await createHarness(); + await withAuthority(harness, async (authority) => { + await expect( + authority.commitDomainRecord( + renameCommand('cmd-schedule'), + { domain: 'schedule', content: {} }, + { class: 'trusted_entry' }, + ), + ).rejects.toThrow(/registered but not enabled for submission/); + }); + }); + + it('refuses a domain record when no resource store is available', async () => { + const harness = await createHarness(); + const lease = await SessionWriterLease.acquire({ + runtimeBaseDir: harness.runtimeBaseDir, + sessionId, + transcriptPath: harness.transcriptPath, + }); + const authority = await LocalManagedSessionAuthority.open({ + lease, + sessionKey, + cwd: '/workspace', + version: 'test', + create: { + definitionRef: await harness.store.publish( + 'managed-definition', + Buffer.from('{}', 'utf8'), + ), + rootSnapshotRef: await harness.store.publish( + 'managed-root', + Buffer.from('{}', 'utf8'), + ), + createdBy: 'daemon', + }, + }); + await expect( + authority.commitDomainRecord( + renameCommand('cmd-rename-1'), + { + domain: 'session_metadata', + content: { title: 'No store', titleSource: 'manual' }, + }, + { class: 'trusted_entry' }, + ), + ).rejects.toThrow(/resource store is required/); + await lease.release(); + }); +}); + +describe('legacy maintenance on a managed session', () => { + interface ProjectHarness { + projectRoot: string; + runtimeBaseDir: string; + transcriptPath: string; + service: SessionService; + } + + async function createProject(): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-managed-rn-')); + temporaryDirectories.add(root); + const projectRoot = path.join(root, 'project'); + const runtimeBaseDir = path.join(root, 'runtime'); + await fs.mkdir(projectRoot, { recursive: true }); + await fs.mkdir(runtimeBaseDir, { recursive: true }); + const storage = new Storage(projectRoot, runtimeBaseDir); + const transcriptPath = path.join( + storage.getProjectDir(), + 'chats', + `${sessionId}.jsonl`, + ); + await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); + return { + projectRoot, + runtimeBaseDir, + transcriptPath, + service: new SessionService(projectRoot, { runtimeBaseDir }), + }; + } + + it('refuses legacy rename and fork of a managed session without changing files', async () => { + const harness = await createProject(); + const store = LocalManagedSessionResourceStore.create({ + runtimeBaseDir: harness.runtimeBaseDir, + sessionKey, + }); + const lease = await SessionWriterLease.acquire({ + runtimeBaseDir: harness.runtimeBaseDir, + sessionId, + transcriptPath: harness.transcriptPath, + }); + await LocalManagedSessionAuthority.open({ + lease, + sessionKey, + cwd: harness.projectRoot, + version: 'test', + resources: store, + create: { + definitionRef: await store.publish( + 'managed-definition', + Buffer.from('{}', 'utf8'), + ), + rootSnapshotRef: await store.publish( + 'managed-root', + Buffer.from('{}', 'utf8'), + ), + createdBy: 'daemon', + }, + }); + await lease.release(); + + expect(isManagedSessionTranscriptSync(harness.transcriptPath)).toBe(true); + const before = await fs.readFile(harness.transcriptPath, 'utf8'); + + expect(() => + harness.service.assertLegacySessionExecution(sessionId), + ).toThrow(/belongs to managed/); + + await expect( + harness.service.renameSession(sessionId, 'Renamed by the legacy path'), + ).rejects.toThrow(/belongs to managed/); + + /* The legacy path would have appended a custom_title record, standing up a + second title authority beside the committed session_metadata record. */ + const after = await fs.readFile(harness.transcriptPath, 'utf8'); + expect(after).toBe(before); + expect(after).not.toContain('custom_title'); + const targetId = '650e8400-e29b-41d4-a716-446655440000'; + await expect( + harness.service.forkSession(sessionId, targetId), + ).rejects.toThrow(/belongs to managed/); + await expect( + fs.stat( + path.join(path.dirname(harness.transcriptPath), `${targetId}.jsonl`), + ), + ).rejects.toMatchObject({ code: 'ENOENT' }); + expect(await fs.readFile(harness.transcriptPath, 'utf8')).toBe(before); + }); + + it('removes the private resources when the session is deleted', async () => { + const harness = await createProject(); + const store = LocalManagedSessionResourceStore.create({ + runtimeBaseDir: harness.runtimeBaseDir, + sessionKey, + }); + const lease = await SessionWriterLease.acquire({ + runtimeBaseDir: harness.runtimeBaseDir, + sessionId, + transcriptPath: harness.transcriptPath, + }); + const authority = await LocalManagedSessionAuthority.open({ + lease, + sessionKey, + cwd: harness.projectRoot, + version: 'test', + resources: store, + create: { + definitionRef: await store.publish( + 'managed-definition', + Buffer.from('{}', 'utf8'), + ), + rootSnapshotRef: await store.publish( + 'managed-root', + Buffer.from('{}', 'utf8'), + ), + createdBy: 'daemon', + }, + }); + await authority.commitDomainRecord( + renameCommand('cmd-rename-1'), + { + domain: 'session_metadata', + content: { title: 'Doomed session', titleSource: 'manual' }, + }, + { class: 'trusted_entry' }, + ); + await lease.release(); + + await expect(fs.stat(store.sessionRoot)).resolves.toBeDefined(); + + await expect(harness.service.removeSession(sessionId)).resolves.toBe(true); + + await expect(fs.stat(harness.transcriptPath)).rejects.toThrow(); + /* Leaving these behind would orphan every event body the session wrote. */ + await expect(fs.stat(store.sessionRoot)).rejects.toThrow(); + }); + + it('still renames a legacy session', async () => { + const harness = await createProject(); + await fs.writeFile( + harness.transcriptPath, + `${JSON.stringify({ + uuid: 'legacy-1', + parentUuid: null, + sessionId, + timestamp: new Date().toISOString(), + type: 'user', + cwd: harness.projectRoot, + })}\n`, + 'utf8', + ); + + expect(isManagedSessionTranscriptSync(harness.transcriptPath)).toBe(false); + expect(() => + harness.service.assertLegacySessionExecution(sessionId), + ).not.toThrow(); + await expect( + harness.service.renameSession(sessionId, 'Legacy rename'), + ).resolves.toBe(true); + expect(await fs.readFile(harness.transcriptPath, 'utf8')).toContain( + 'custom_title', + ); + }); +}); + +describe('maintenance on a sealed managed session', () => { + interface SealedHarness { + projectRoot: string; + runtimeBaseDir: string; + transcriptPath: string; + service: SessionService; + store: LocalManagedSessionResourceStore; + } + + async function createSealed(): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-managed-seal-')); + temporaryDirectories.add(root); + const projectRoot = path.join(root, 'project'); + const runtimeBaseDir = path.join(root, 'runtime'); + await fs.mkdir(projectRoot, { recursive: true }); + await fs.mkdir(runtimeBaseDir, { recursive: true }); + const storage = new Storage(projectRoot, runtimeBaseDir); + const transcriptPath = path.join( + storage.getProjectDir(), + 'chats', + `${sessionId}.jsonl`, + ); + await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); + + const store = LocalManagedSessionResourceStore.create({ + runtimeBaseDir, + sessionKey, + }); + const lease = await LocalManagedSessionAuthority.acquireWriter({ + runtimeBaseDir, + sessionId, + transcriptPath, + }); + const authority = await LocalManagedSessionAuthority.open({ + lease, + sessionKey, + cwd: projectRoot, + version: 'test', + resources: store, + create: { + definitionRef: await store.publish( + 'managed-definition', + Buffer.from('{}', 'utf8'), + ), + rootSnapshotRef: await store.publish( + 'managed-root', + Buffer.from('{}', 'utf8'), + ), + createdBy: 'daemon', + }, + }); + await authority.commitDomainRecord( + renameCommand('cmd-rename-1'), + { + domain: 'session_metadata', + content: { title: 'Sealed session', titleSource: 'manual' }, + }, + { class: 'trusted_entry' }, + ); + await authority.commitDomainRecord( + renameCommand('cmd-source-1'), + { + domain: 'session_source', + content: { + record: { + systemPayload: { sourceType: 'channel', sourceId: 'managed' }, + }, + }, + }, + { class: 'trusted_entry' }, + ); + /* Closing seals the lock, which is what leaves a barrier behind. Every + maintenance path below now meets that sealed lock. */ + await authority.close(); + + return { + projectRoot, + runtimeBaseDir, + transcriptPath, + service: new SessionService(projectRoot, { runtimeBaseDir }), + store, + }; + } + + it('still lists the session and projects its title', async () => { + const harness = await createSealed(); + const listed = await harness.service.listSessions(); + expect(listed.items).toEqual([ + expect.objectContaining({ + sessionId, + customTitle: 'Sealed session', + titleSource: 'manual', + sourceType: 'channel', + sourceId: 'managed', + }), + ]); + expect(await harness.service.getSessionListItem(sessionId)).toEqual( + expect.objectContaining({ + customTitle: 'Sealed session', + titleSource: 'manual', + sourceType: 'channel', + sourceId: 'managed', + }), + ); + expect(harness.service.getSessionTitleInfo(sessionId)).toEqual({ + title: 'Sealed session', + source: 'manual', + }); + }); + + it('still refuses a legacy rename', async () => { + const harness = await createSealed(); + await expect( + harness.service.renameSession(sessionId, 'Renamed'), + ).rejects.toThrow(/belongs to managed/); + }); + + it('still archives and unarchives', async () => { + const harness = await createSealed(); + const archived = await harness.service.archiveSessions([sessionId]); + expect(archived.errors).toEqual([]); + expect(archived.archived).toEqual([sessionId]); + + const restored = await harness.service.unarchiveSessions([sessionId]); + expect(restored.errors).toEqual([]); + }); + + it('archives and unarchives under a sealed maintenance claim without removing the writer fence', async () => { + const harness = await createSealed(); + const lockPath = getSessionWriterLockPath( + harness.runtimeBaseDir, + sessionId, + ); + const sealedLock = await fs.readFile(lockPath, 'utf8'); + const archiveLease = + await harness.service.acquireSealedManagedMaintenanceLease(sessionId); + expect(archiveLease).toBeDefined(); + await expect( + harness.service.acquireSealedManagedMaintenanceLease(sessionId), + ).rejects.toThrow(); + await expect( + LocalManagedSessionAuthority.acquireWriter({ + runtimeBaseDir: harness.runtimeBaseDir, + sessionId, + transcriptPath: harness.transcriptPath, + }), + ).rejects.toThrow(); + const archived = await harness.service.archiveSessions([sessionId], { + assertStorageUnchanged: () => archiveLease!.assertOwnedAndUnchanged(), + assertCleanupOwned: () => archiveLease!.assertCleanupOwned(), + }); + expect(archived.errors).toEqual([]); + expect(archived.archived).toEqual([sessionId]); + await archiveLease!.release(); + expect(await fs.readFile(lockPath, 'utf8')).toBe(sealedLock); + + const restoreLease = + await harness.service.acquireSealedManagedMaintenanceLease(sessionId); + expect(restoreLease).toBeDefined(); + const restored = await harness.service.unarchiveSessions([sessionId], { + assertStorageUnchanged: () => restoreLease!.assertOwnedAndUnchanged(), + assertCleanupOwned: () => restoreLease!.assertCleanupOwned(), + }); + expect(restored.errors).toEqual([]); + expect(restored.unarchived).toEqual([sessionId]); + await restoreLease!.release(); + expect(await fs.readFile(lockPath, 'utf8')).toBe(sealedLock); + + const writer = await LocalManagedSessionAuthority.acquireWriter({ + runtimeBaseDir: harness.runtimeBaseDir, + sessionId, + transcriptPath: harness.transcriptPath, + }); + const authority = await LocalManagedSessionAuthority.open({ + lease: writer, + sessionKey, + cwd: harness.projectRoot, + version: 'test', + resources: harness.store, + }); + await authority.close(); + }); + + it('deletes a sealed Managed session under a maintenance claim', async () => { + const harness = await createSealed(); + const maintenance = + await harness.service.acquireSealedManagedMaintenanceLease(sessionId); + expect(maintenance).toBeDefined(); + await expect( + harness.service.removeSession(sessionId, { + assertStorageUnchanged: () => maintenance!.assertOwnedAndUnchanged(), + assertCleanupOwned: () => maintenance!.assertCleanupOwned(), + }), + ).resolves.toBe(true); + await maintenance!.release(); + await expect(fs.stat(harness.transcriptPath)).rejects.toThrow(); + await expect(fs.stat(harness.store.sessionRoot)).rejects.toThrow(); + }); + + it('rejects a replaced transcript while a sealed maintenance claim is held', async () => { + const harness = await createSealed(); + const maintenance = + await harness.service.acquireSealedManagedMaintenanceLease(sessionId); + expect(maintenance).toBeDefined(); + const replacement = `${harness.transcriptPath}.replacement`; + await fs.copyFile(harness.transcriptPath, replacement); + await fs.rename(replacement, harness.transcriptPath); + await expect(maintenance!.assertOwnedAndUnchanged()).rejects.toThrow(); + await maintenance!.release(); + }); + + it('still deletes the session and its resources', async () => { + const harness = await createSealed(); + await expect(fs.stat(harness.store.sessionRoot)).resolves.toBeDefined(); + await expect(harness.service.removeSession(sessionId)).resolves.toBe(true); + await expect(fs.stat(harness.transcriptPath)).rejects.toThrow(); + await expect(fs.stat(harness.store.sessionRoot)).rejects.toThrow(); + }); +}); diff --git a/packages/core/src/managed-runtime/managed-session-record-sink.test.ts b/packages/core/src/managed-runtime/managed-session-record-sink.test.ts new file mode 100644 index 00000000000..35eec80b363 --- /dev/null +++ b/packages/core/src/managed-runtime/managed-session-record-sink.test.ts @@ -0,0 +1,643 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { Storage } from '../config/storage.js'; +import type { ChatRecord } from '../services/chatRecordingService.js'; +import { LocalManagedSessionAuthority } from './managed-session-authority.js'; +import { + createInitialHarnessCheckpoint, + encodeHarnessCheckpointV1, + HARNESS_TURN_COMPLETE_BOUNDARY, + parseHarnessCheckpointV1, +} from './managed-harness-checkpoint.js'; +import { + ManagedSessionRecordSink, + ManagedSessionUnmappedRecordError, +} from './managed-session-record-sink.js'; +import { LocalManagedSessionResourceStore } from './managed-session-resources.js'; +import { readManagedSessionTitleInfoSync } from '../utils/sessionStorageUtils.js'; +import type { ManagedSessionDurableRef } from './managed-session-records.js'; + +const DIGEST = 'f'.repeat(64); +const sessionId = '550e8400-e29b-41d4-a716-446655440000'; +const sessionKey = { tenantId: 't1', workspaceId: 'w1', sessionId }; +const temporaryDirectories = new Set(); + +afterEach(async () => { + for (const directory of temporaryDirectories) { + await fs.rm(directory, { recursive: true, force: true }); + } + temporaryDirectories.clear(); +}); + +function ref(kind = 'managed-test'): ManagedSessionDurableRef { + return { + resourceId: 'res-1', + kind, + schemaVersion: 1, + byteLength: 4, + digest: DIGEST, + }; +} + +function record(overrides: Partial): ChatRecord { + return { + uuid: 'rec-1', + parentUuid: null, + sessionId, + timestamp: '2026-09-01T10:00:00.000Z', + type: 'user', + cwd: '/workspace', + version: '1.2.3', + ...overrides, + } as ChatRecord; +} + +interface Harness { + sink: ManagedSessionRecordSink; + authority: LocalManagedSessionAuthority; + store: LocalManagedSessionResourceStore; + transcriptPath: string; + runtimeBaseDir: string; + close(): Promise; +} + +async function createHarness(): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-managed-sink-')); + temporaryDirectories.add(root); + const projectRoot = path.join(root, 'project'); + const runtimeBaseDir = path.join(root, 'runtime'); + await fs.mkdir(projectRoot, { recursive: true }); + await fs.mkdir(runtimeBaseDir, { recursive: true }); + const transcriptPath = path.join( + new Storage(projectRoot, runtimeBaseDir).getProjectDir(), + 'chats', + `${sessionId}.jsonl`, + ); + await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); + + const store = LocalManagedSessionResourceStore.create({ + runtimeBaseDir, + sessionKey, + }); + const lease = await LocalManagedSessionAuthority.acquireWriter({ + runtimeBaseDir, + sessionId, + transcriptPath, + }); + const authority = await LocalManagedSessionAuthority.open({ + lease, + sessionKey, + cwd: projectRoot, + version: 'test', + resources: store, + create: { + definitionRef: ref('managed-definition'), + rootSnapshotRef: ref('managed-root'), + createdBy: 'daemon', + }, + }); + await authority.appendExecution( + { + operation: 'claimActivation', + commandId: 'cmd-act-1', + sessionKey, + contentDigest: DIGEST, + }, + [ + { + v: 1, + sequence: 1, + eventId: 'evt-act-1', + sessionKey, + kind: 'activation.changed', + occurredAt: 1, + payload: { + activationId: 'act-1', + epoch: 1, + workerId: 'worker-1', + subject: { + type: 'activation', + scopeId: 'act-1', + activationId: 'act-1', + epoch: 1, + }, + phase: 'active', + leaseDurationMs: 60_000, + expiresAt: 2, + installRef: ref(), + boundaryRef: null, + }, + }, + ], + { class: 'coordinator' }, + ); + + return { + sink: new ManagedSessionRecordSink(authority, store, () => ({ + class: 'harness', + activation: { activationId: 'act-1', epoch: 1 }, + })), + authority, + store, + transcriptPath, + runtimeBaseDir, + close: () => authority.close(), + }; +} + +async function commitInitialV1(harness: Harness): Promise { + const covered = harness.authority.committedSequence; + const header = harness.authority.sessionHeader; + const checkpoint = createInitialHarnessCheckpoint({ + sessionKey, + checkpointId: `ckpt-${covered + 1}`, + coveredSequence: covered, + activationId: 'act-1', + turnId: null, + promptId: null, + definitionRevision: header.definitionRef.resourceId, + configRevision: header.rootSnapshotRef.resourceId, + inputDigest: header.definitionRef.digest, + previousCheckpointId: null, + }); + await harness.authority.commitCheckpoint( + { + operation: 'commitCheckpoint', + commandId: 'cmd-ckpt-v1', + sessionKey, + contentDigest: DIGEST, + }, + { state: encodeHarnessCheckpointV1(checkpoint), boundary: null }, + { class: 'harness', activation: { activationId: 'act-1', epoch: 1 } }, + ); +} + +async function transcriptSubtypes( + harness: Harness, +): Promise> { + const text = await fs.readFile(harness.transcriptPath, 'utf8'); + return text + .split('\n') + .filter((line) => line !== '') + .map((line) => (JSON.parse(line) as { subtype?: string }).subtype); +} + +describe('managed session record sink', () => { + it('carries the record shapes the projection can reproduce', async () => { + const harness = await createHarness(); + expect(harness.sink.canCarry(record({ type: 'user' }))).toBe(true); + expect(harness.sink.canCarry(record({ type: 'assistant' }))).toBe(true); + expect(harness.sink.canCarry(record({ type: 'tool_result' }))).toBe(true); + expect( + harness.sink.canCarry( + record({ type: 'system', subtype: 'slash_command' }), + ), + ).toBe(true); + await harness.close(); + }); + + it('refuses shapes that have their own home and are not mapped yet', async () => { + const harness = await createHarness(); + // A rewind belongs to the history_rewind domain and a parent session to the + // child_run lineage; neither is routed yet, so both are refused. + const unmapped = ['rewind', 'parent_session'] as const; + for (const subtype of unmapped) { + expect(harness.sink.canCarry(record({ type: 'system', subtype }))).toBe( + false, + ); + } + await harness.close(); + }); + + it('keeps the Harness checkpoint when a branch point is recorded', async () => { + const harness = await createHarness(); + try { + const state = Buffer.from( + JSON.stringify({ continuation: { phase: 'turn_settled' } }), + ); + const { checkpoint } = await harness.authority.commitCheckpoint( + { + operation: 'commitCheckpoint', + commandId: 'checkpoint-1', + sessionKey, + contentDigest: DIGEST, + }, + { state, boundary: 'turn_settled' }, + { class: 'harness', activation: { activationId: 'act-1', epoch: 1 } }, + ); + await harness.sink.write( + record({ + uuid: 'branch-1', + type: 'system', + subtype: 'branch_checkpoint', + parentUuid: 'assistant-1', + systemPayload: { + v: 1, + startExclusiveRecordUuid: null, + assistantRecordUuid: 'assistant-1', + }, + }), + ); + expect(harness.authority.latestCheckpoint).toEqual(checkpoint); + expect(await harness.authority.readCheckpointState()).toEqual(state); + await expect( + harness.sink.write( + record({ + uuid: 'branch-2', + type: 'system', + subtype: 'branch_checkpoint', + systemPayload: { v: 2 } as never, + }), + ), + ).rejects.toThrow(ManagedSessionUnmappedRecordError); + } finally { + await harness.close(); + } + }); + + it('carries branch points and compacts the full message range past a page of events', async () => { + const harness = await createHarness(); + const checkpoint = (uuid: string) => + record({ + uuid, + type: 'system', + subtype: 'branch_checkpoint', + systemPayload: { + v: 1, + startExclusiveRecordUuid: null, + assistantRecordUuid: 'rec-assistant', + }, + }); + const filler = async (prefix: string, count: number) => { + for (let index = 0; index < count; index++) { + await harness.sink.write( + record({ + uuid: `rec-${prefix}-${index}`, + type: index % 2 === 0 ? 'user' : 'assistant', + message: { role: 'user', parts: [{ text: `${prefix} ${index}` }] }, + }), + ); + } + }; + await filler('early', 110); + await harness.sink.write(checkpoint('rec-checkpoint-1')); + await filler('late', 2); + await harness.sink.write(checkpoint('rec-checkpoint-2')); + + expect(harness.authority.latestCheckpoint).toBeUndefined(); + expect(harness.authority.restoreBasis()).toBe('blocked'); + expect( + (await harness.sink.project()).filter( + (item) => item.subtype === 'branch_checkpoint', + ), + ).toEqual([checkpoint('rec-checkpoint-1'), checkpoint('rec-checkpoint-2')]); + + // A compaction describes the range it replaces, so it has to name every + // message in it — a page would have listed only the first hundred. + await harness.sink.write( + record({ + uuid: 'rec-compact-1', + type: 'system', + subtype: 'chat_compression', + systemPayload: { + compressedHistory: [{ role: 'user', parts: [{ text: 'summary' }] }], + }, + } as Partial), + ); + const compaction = harness.authority.lastEventOfKind('context.compacted'); + expect(compaction?.payload['replacedMessageIds']).toEqual([ + ...Array.from({ length: 110 }, (_, index) => `rec-early-${index}`), + 'rec-checkpoint-1', + 'rec-late-0', + 'rec-late-1', + 'rec-checkpoint-2', + ]); + expect( + (await harness.sink.project()).filter( + (item) => item.subtype === 'branch_checkpoint', + ), + ).toEqual([checkpoint('rec-checkpoint-1'), checkpoint('rec-checkpoint-2')]); + await harness.close(); + }); + + it('commits a compaction as the range of history it replaces', async () => { + const harness = await createHarness(); + const message = record({ + uuid: 'rec-user-1', + message: { role: 'user', parts: [{ text: 'summarise the docs' }] }, + }); + await harness.sink.write(message); + + const compression = record({ + uuid: 'rec-compact-1', + type: 'system', + subtype: 'chat_compression', + systemPayload: { + info: { originalTokenCount: 100, newTokenCount: 10 }, + compressedHistory: [{ role: 'user', parts: [{ text: 'summary' }] }], + }, + } as Partial); + await harness.sink.write(compression); + + const compacted = harness.authority + .readEvents() + .filter((event) => event.kind === 'context.compacted'); + expect(compacted).toHaveLength(1); + expect(compacted[0].payload['replacedMessageIds']).toEqual(['rec-user-1']); + expect(compacted[0].payload['fromSequence']).toBe(1); + + // The snapshot reads back whole, and a compaction is not message content, + // so it stays out of the message projection. + const body = await harness.store.read( + compacted[0].payload['summaryRef'] as unknown as ManagedSessionDurableRef, + ); + expect(JSON.parse(body.toString('utf8'))).toEqual(compression); + expect(await harness.sink.project()).toEqual([message]); + + await expect( + harness.sink.write( + record({ + uuid: 'rec-compact-2', + type: 'system', + subtype: 'chat_compression', + systemPayload: { info: {} } as never, + }), + ), + ).rejects.toThrow(ManagedSessionUnmappedRecordError); + await harness.close(); + }); + + it('writes a carried record into the authoritative log only', async () => { + const harness = await createHarness(); + const carried = record({ + uuid: 'rec-user-1', + message: { role: 'user', parts: [{ text: 'hello' }] }, + }); + await harness.sink.write(carried); + + expect(await harness.sink.project()).toEqual([carried]); + await harness.close(); + + expect(new Set(await transcriptSubtypes(harness))).toEqual( + new Set([ + 'session_execution_engine', + 'managed_session_header_v1', + 'managed_session_event_v1', + 'managed_session_commit_v1', + ]), + ); + }); + + it('routes a title into the session_metadata record the directory reads', async () => { + const harness = await createHarness(); + await harness.sink.write( + record({ + uuid: 'rec-title-1', + type: 'system', + subtype: 'custom_title', + systemPayload: { customTitle: 'Recorded title', titleSource: 'manual' }, + } as Partial), + ); + await harness.close(); + + /* A title is not message content, so it must reach the directory through + the session_metadata domain record, not the message projection. */ + expect( + readManagedSessionTitleInfoSync( + harness.transcriptPath, + harness.runtimeBaseDir, + ), + ).toEqual({ title: 'Recorded title', source: 'manual' }); + expect(await harness.sink.project()).toEqual([]); + }); + + it('settles the turn instead of projecting the result as a message', async () => { + const harness = await createHarness(); + const result = record({ + uuid: 'rec-turn-1', + type: 'system', + subtype: 'turn_result', + systemPayload: { + promptId: 'turn-1', + state: 'completed', + stopReason: 'end_turn', + }, + } as Partial); + await harness.sink.write(result); + + const settled = harness.authority + .readEvents() + .filter((event) => event.kind === 'turn.settled'); + expect(settled).toHaveLength(1); + expect(settled[0].payload['turnId']).toBe('turn-1'); + expect(settled[0].payload['outcome']).toBe('completed'); + expect(settled[0].payload['stopReason']).toBe('end_turn'); + + /* The terminal state is an event, not message content. */ + expect(await harness.sink.project()).toEqual([]); + + /* The whole record is retained, so error detail and timings survive. */ + const body = await harness.store.read( + settled[0].payload['resultRef'] as never, + ); + expect(JSON.parse(body.toString('utf8'))).toEqual(result); + expect(harness.authority.latestCheckpoint).toBeUndefined(); + await harness.close(); + }); + + it('keeps a session initial when a turn settles before its first checkpoint', async () => { + const harness = await createHarness(); + await harness.sink.write( + record({ + uuid: 'rec-turn-early', + type: 'system', + subtype: 'turn_result', + systemPayload: { + promptId: 'turn-early', + state: 'cancelled', + stopReason: 'cancelled', + }, + } as Partial), + ); + await expect(harness.authority.harnessRunAuthorization()).resolves.toEqual({ + status: 'initial', + }); + await harness.close(); + }); + + it('refuses a turn result with no prompt id or state', async () => { + const harness = await createHarness(); + await expect( + harness.sink.write( + record({ + uuid: 'rec-turn-bad', + type: 'system', + subtype: 'turn_result', + systemPayload: { state: 'completed' }, + } as Partial), + ), + ).rejects.toThrow(ManagedSessionUnmappedRecordError); + await harness.close(); + }); + + it('commits a next-turn checkpoint with turn.settled when a runnable v1 exists', async () => { + const harness = await createHarness(); + await commitInitialV1(harness); + const firstId = harness.authority.latestCheckpoint?.checkpointId; + const coveredBefore = harness.authority.committedSequence; + const result = record({ + uuid: 'rec-turn-complete', + type: 'system', + subtype: 'turn_result', + systemPayload: { + promptId: 'turn-1', + state: 'completed', + stopReason: 'end_turn', + }, + } as Partial); + await harness.sink.write(result); + + const events = harness.authority.readEvents(); + const settled = events.filter((event) => event.kind === 'turn.settled'); + const checkpoints = events.filter( + (event) => event.kind === 'checkpoint.committed', + ); + expect(settled).toHaveLength(1); + expect(checkpoints).toHaveLength(2); + expect(settled[0].sequence + 1).toBe(checkpoints[1].sequence); + expect(checkpoints[1].payload['boundary']).toBe( + HARNESS_TURN_COMPLETE_BOUNDARY, + ); + expect(checkpoints[1].payload['previousCheckpointId']).toBe(firstId); + expect(checkpoints[1].payload['coveredSequence']).toBe(coveredBefore); + expect(harness.authority.latestCheckpoint?.boundary).toBe( + HARNESS_TURN_COMPLETE_BOUNDARY, + ); + expect( + parseHarnessCheckpointV1((await harness.authority.readCheckpointState())!) + .continuation.phase, + ).toBe('before_model'); + await expect( + harness.authority.harnessRunAuthorization(), + ).resolves.toMatchObject({ status: 'runnable' }); + expect(await harness.sink.project()).toEqual([]); + await harness.close(); + }); + + it('does not replace an opaque checkpoint when a turn settles', async () => { + const harness = await createHarness(); + await harness.authority.commitCheckpoint( + { + operation: 'commitCheckpoint', + commandId: 'cmd-ckpt-opaque', + sessionKey, + contentDigest: DIGEST, + }, + { state: Buffer.from('first', 'utf8'), boundary: null }, + { class: 'harness', activation: { activationId: 'act-1', epoch: 1 } }, + ); + await harness.sink.write( + record({ + uuid: 'rec-turn-opaque', + type: 'system', + subtype: 'turn_result', + systemPayload: { + promptId: 'turn-1', + state: 'completed', + stopReason: 'end_turn', + }, + } as Partial), + ); + + expect(harness.authority.latestCheckpoint?.boundary).toBeNull(); + expect(await harness.authority.readCheckpointState()).toEqual( + Buffer.from('first', 'utf8'), + ); + await expect( + harness.authority.harnessRunAuthorization(), + ).resolves.toMatchObject({ + status: 'blocked', + reason: 'opaque_state', + }); + expect( + harness.authority + .readEvents() + .filter((event) => event.kind === 'turn.settled'), + ).toHaveLength(1); + await harness.close(); + }); + + it('commits a goal snapshot as the goal domain record', async () => { + const harness = await createHarness(); + const goal = record({ + uuid: 'rec-goal-1', + type: 'system', + subtype: 'goal_state', + systemPayload: { + v: 2, + cause: 'create', + snapshot: { activity: 'idle' }, + }, + } as unknown as Partial); + await harness.sink.write(goal); + + const committed = harness.authority + .readEvents() + .filter((event) => event.kind === 'domain.committed'); + expect(committed).toHaveLength(1); + expect(committed[0].payload['domain']).toBe('goal_state'); + + // The whole record is the body, so goal recovery reads what was written, + // and the goal stays out of the message channel. + const body = JSON.parse( + ( + await harness.store.read( + committed[0].payload[ + 'recordRef' + ] as unknown as ManagedSessionDurableRef, + ) + ).toString('utf8'), + ) as { record: unknown; revision: number }; + expect(body.record).toEqual(goal); + expect(body.revision).toBe(1); + expect(await harness.sink.project()).toEqual([]); + await harness.close(); + }); + + it('carries a subtyped user message on the message channel', async () => { + const harness = await createHarness(); + const runtimeMessage = record({ + uuid: 'rec-goal-runtime-1', + subtype: 'goal_runtime', + message: { role: 'user', parts: [{ text: 'continue the goal' }] }, + } as Partial); + await harness.sink.write(runtimeMessage); + + // The subtype survives the round trip, which is why it can ride here. + expect(await harness.sink.project()).toEqual([runtimeMessage]); + await harness.close(); + }); + + it('refuses an unmapped record instead of appending it directly', async () => { + const harness = await createHarness(); + const before = await fs.readFile(harness.transcriptPath, 'utf8'); + await expect( + harness.sink.write( + record({ type: 'system', subtype: 'file_history_snapshot' }), + ), + ).rejects.toThrow(ManagedSessionUnmappedRecordError); + + // A silent fallback would put content in the transcript that the + // authoritative log does not account for. + expect(await fs.readFile(harness.transcriptPath, 'utf8')).toBe(before); + await harness.close(); + }); +}); diff --git a/packages/core/src/managed-runtime/managed-session-record-sink.ts b/packages/core/src/managed-runtime/managed-session-record-sink.ts new file mode 100644 index 00000000000..745df5c1af6 --- /dev/null +++ b/packages/core/src/managed-runtime/managed-session-record-sink.ts @@ -0,0 +1,465 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash } from 'node:crypto'; +import type { ChatRecord } from '../services/chatRecordingService.js'; +import { parseBranchCheckpointPayload } from '../services/branch-points.js'; +import type { + LocalManagedSessionAuthority, + ManagedSessionActor, +} from './managed-session-authority.js'; +import { + createNextTurnReadyHarnessCheckpoint, + encodeHarnessCheckpointV1, + HARNESS_MODEL_START_PHASES, + HARNESS_TURN_COMPLETE_BOUNDARY, +} from './managed-harness-checkpoint.js'; +import { ManagedSessionMessageProjection } from './managed-session-message-projection.js'; +import type { ManagedSessionResourceStore } from './managed-session-storage.js'; + +/** + * The record shapes the message projection can carry today. + * + * A Managed log keeps domain content once and projects reader-facing records + * back out of it, so a record may only be routed here if the projection can + * reproduce it. Shapes with their own home in the event union or the domain + * registry are routed there instead; anything still unmapped is refused rather + * than falling back to a direct append. + */ +const CARRIED_SYSTEM_SUBTYPES = new Set([ + 'slash_command', + 'at_command', + 'ui_telemetry', + 'attribution_snapshot', +]); + +/** + * Message subtypes the projection restores as they were. A subtype on a user + * record marks where the message came from rather than making it another kind + * of content, so it belongs on the message channel. + */ +const CARRIED_MESSAGE_SUBTYPES = new Set([ + 'goal_runtime', + 'mid_turn_user_message', +]); + +export class ManagedSessionUnmappedRecordError extends Error { + readonly code = 'managed_session_unmapped_record'; + + constructor(record: ChatRecord) { + super( + `Managed sessions have no mapping yet for a ${record.type} record` + + `${record.subtype ? ` with subtype ${record.subtype}` : ''}.`, + ); + this.name = 'ManagedSessionUnmappedRecordError'; + } +} + +/** + * The controlled sink a Managed-bound recorder writes through. + * + * Routing the recorder here is what stops a Managed session from keeping a + * second, legacy copy of its history beside the authoritative log. + */ +export class ManagedSessionRecordSink { + private readonly projection: ManagedSessionMessageProjection; + + constructor( + private readonly authority: LocalManagedSessionAuthority, + private readonly resources: ManagedSessionResourceStore, + /** Supplied by the binder, which is the only party that knows the activation. */ + private readonly actor: () => ManagedSessionActor, + ) { + this.projection = new ManagedSessionMessageProjection(authority, resources); + } + + canCarry(record: ChatRecord): boolean { + if (record.type === 'system') { + if (record.subtype === 'custom_title') return true; + if (record.subtype === 'turn_result') return true; + if (record.subtype === 'chat_compression') return true; + if (record.subtype === 'branch_checkpoint') return true; + if (record.subtype === 'goal_state') return true; + if (record.subtype === 'file_history_snapshot') return true; + if (record.subtype === 'session_source') return true; + return ( + record.subtype !== undefined && + CARRIED_SYSTEM_SUBTYPES.has(record.subtype) + ); + } + if ( + record.type === 'user' || + record.type === 'assistant' || + record.type === 'tool_result' + ) { + return ( + record.subtype === undefined || + CARRIED_MESSAGE_SUBTYPES.has(record.subtype) + ); + } + return false; + } + + /** + * Refuses rather than falling back. A silent fallback to a direct append + * would put content in the transcript that the authoritative log does not + * account for, which is the divergence this layer exists to prevent. + */ + async write(record: ChatRecord): Promise { + if (!this.canCarry(record)) { + throw new ManagedSessionUnmappedRecordError(record); + } + if (record.subtype === 'custom_title') { + await this.commitTitle(record); + return; + } + if (record.subtype === 'goal_state') { + await this.commitGoalState(record); + return; + } + if (record.subtype === 'file_history_snapshot') { + await this.commitFileHistory(record); + return; + } + if (record.subtype === 'session_source') { + await this.commitSessionSource(record); + return; + } + if (record.subtype === 'turn_result') { + await this.commitTurnSettled(record); + return; + } + if (record.subtype === 'chat_compression') { + await this.commitContextCompacted(record); + return; + } + if (record.subtype === 'branch_checkpoint') { + await this.commitBranchCheckpoint(record); + return; + } + await this.projection.commit( + { + operation: 'commitMessage', + commandId: `recorder:${record.uuid}`, + sessionKey: this.authority.sessionHeader.sessionKey, + contentDigest: this.authority.sessionHeader.definitionRef.digest, + }, + { record }, + this.actor(), + ); + } + + /** + * A title is not message content: it belongs to the `session_metadata` domain + * record the session directory reads, so it is committed there rather than + * projected as a message. + */ + private async commitTitle(record: ChatRecord): Promise { + const payload = record.systemPayload as + | { customTitle?: unknown; titleSource?: unknown } + | undefined; + const title = payload?.customTitle; + if (typeof title !== 'string' || title.length === 0) { + throw new ManagedSessionUnmappedRecordError(record); + } + await this.authority.commitDomainRecord( + { + operation: 'renameSession', + commandId: `recorder:${record.uuid}`, + sessionKey: this.authority.sessionHeader.sessionKey, + contentDigest: this.authority.sessionHeader.definitionRef.digest, + }, + { + domain: 'session_metadata', + content: { + title, + ...(payload?.titleSource === 'auto' || + payload?.titleSource === 'manual' + ? { titleSource: payload.titleSource } + : {}), + }, + }, + { class: 'trusted_entry' }, + ); + } + + /** + * A goal snapshot is the goal domain's state, so it is committed there rather + * than projected as a message. The body carries the whole record, because goal + * recovery reads the original record and the domain holds the complete goal + * state. + */ + private async commitGoalState(record: ChatRecord): Promise { + if (record.systemPayload === undefined) { + throw new ManagedSessionUnmappedRecordError(record); + } + await this.authority.commitDomainRecord( + { + operation: 'commitGoalState', + commandId: `recorder:${record.uuid}`, + sessionKey: this.authority.sessionHeader.sessionKey, + contentDigest: this.authority.sessionHeader.definitionRef.digest, + }, + { + domain: 'goal_state', + // Nested rather than spread: the authority composes its envelope + // (operationId, revision, previousRecordRef) around the content, and a + // spread record would sit beside those fields and could collide. + content: { record: record as unknown as Record }, + }, + // A domain record admits only a trusted entry: the update reaches the log + // through the authorised entry, and the activation is an eligibility + // condition rather than the author. + { class: 'trusted_entry' }, + ); + } + + /** + * A file history batch records which backups a prompt took, so it is + * committed to the file history domain rather than projected as a message. + * + * Each batch is its own record instead of one folded latest-wins state: a + * folded body would force this writer to re-read the accumulated snapshots + * after every cold reopen, and a body written from partial knowledge would + * silently drop earlier prompts' backups. The reader folds the records with + * the same bounded accumulator it uses on a legacy transcript, so the + * restored snapshot set is identical. + */ + private async commitFileHistory(record: ChatRecord): Promise { + if (record.systemPayload === undefined) { + throw new ManagedSessionUnmappedRecordError(record); + } + await this.authority.commitDomainRecord( + { + operation: 'commitFileHistory', + commandId: `recorder:${record.uuid}`, + sessionKey: this.authority.sessionHeader.sessionKey, + contentDigest: this.authority.sessionHeader.definitionRef.digest, + }, + { + domain: 'file_history', + content: { record: record as unknown as Record }, + }, + { class: 'trusted_entry' }, + ); + } + + /** + * The source a session was created from is session identity, not message + * content, so it is committed to its own domain. The recorder re-anchors the + * same record periodically so a truncated legacy transcript still carries the + * source near its tail; those repeats commit again here, and the reader keeps + * the latest, so the anchoring stays a legacy concern rather than a fault. + */ + private async commitSessionSource(record: ChatRecord): Promise { + if (record.systemPayload === undefined) { + throw new ManagedSessionUnmappedRecordError(record); + } + await this.authority.commitDomainRecord( + { + operation: 'commitSessionSource', + commandId: `recorder:${record.uuid}`, + sessionKey: this.authority.sessionHeader.sessionKey, + contentDigest: this.authority.sessionHeader.definitionRef.digest, + }, + { + domain: 'session_source', + content: { record: record as unknown as Record }, + }, + { class: 'trusted_entry' }, + ); + } + + /** + * A turn result is the turn's terminal state, so it is committed as + * `turn.settled` rather than projected as a message. The whole record becomes + * the result body, which keeps the error detail and timings the payload + * carries beyond the fields the event indexes. + */ + private async commitTurnSettled(record: ChatRecord): Promise { + const payload = record.systemPayload as + | { promptId?: unknown; state?: unknown; stopReason?: unknown } + | undefined; + const turnId = payload?.promptId; + const outcome = payload?.state; + if ( + typeof turnId !== 'string' || + turnId.length === 0 || + typeof outcome !== 'string' || + outcome.length === 0 + ) { + throw new ManagedSessionUnmappedRecordError(record); + } + const resultRef = await this.resources.publish( + 'managed-turn-result', + Buffer.from(JSON.stringify(record), 'utf8'), + ); + const actor = this.actor(); + const held = actor.activation; + const command = { + operation: 'settleTurn', + commandId: `recorder:${record.uuid}`, + sessionKey: this.authority.sessionHeader.sessionKey, + contentDigest: resultRef.digest, + }; + const turn = { + turnId, + outcome, + stopReason: + typeof payload?.stopReason === 'string' ? payload.stopReason : null, + resultRef, + occurredAt: Date.parse(record.timestamp) || Date.now(), + eventId: `turn:${turnId}`, + }; + if (actor.class === 'harness' && held !== undefined) { + const authorization = await this.authority.harnessRunAuthorization(); + if ( + authorization.status === 'runnable' && + HARNESS_MODEL_START_PHASES.has( + authorization.checkpoint.continuation.phase, + ) + ) { + await this.authority.commitTurnComplete( + command, + { + turn, + boundary: HARNESS_TURN_COMPLETE_BOUNDARY, + state: (identity, previous) => + encodeHarnessCheckpointV1( + createNextTurnReadyHarnessCheckpoint({ + previous, + ...identity, + activationId: held.activationId, + turnId, + promptId: turnId, + }), + ), + }, + actor, + ); + return; + } + } + await this.authority.appendExecutionEvent( + command, + (sequence) => ({ + v: 1, + sequence, + eventId: turn.eventId, + sessionKey: this.authority.sessionHeader.sessionKey, + kind: 'turn.settled', + occurredAt: turn.occurredAt, + ...(actor.class === 'harness' && held !== undefined + ? { + subject: { + type: 'activation', + scopeId: held.activationId, + activationId: held.activationId, + epoch: held.epoch, + }, + } + : {}), + payload: { + turnId: turn.turnId, + outcome: turn.outcome, + stopReason: turn.stopReason, + resultRef, + usageRef: null, + pendingOwnersRef: null, + }, + }), + actor, + ); + } + + /** + * A compaction replaces a range of history rather than adding to it, so it is + * committed as `context.compacted` naming that range and the messages inside + * it. The whole record becomes the summary body: a reader rebuilds the model + * history from the snapshot it carries, so a record without one is refused. + */ + private async commitContextCompacted(record: ChatRecord): Promise { + const payload = record.systemPayload as + | { compressedHistory?: unknown } + | undefined; + if (!payload?.compressedHistory) { + throw new ManagedSessionUnmappedRecordError(record); + } + const fromSequence = this.authority.compactedThroughSequence + 1; + const toSequence = this.authority.committedSequence; + const replacedMessageIds = this.authority + .eventsInSequenceRange(fromSequence, toSequence) + .filter((event) => event.kind === 'message.committed') + .map((event) => event.payload['messageId'] as string); + const summaryRef = await this.resources.publish( + 'managed-compaction-summary', + Buffer.from(JSON.stringify(record), 'utf8'), + ); + const actor = this.actor(); + const held = actor.activation; + await this.authority.appendExecution( + { + operation: 'compactContext', + commandId: `recorder:${record.uuid}`, + sessionKey: this.authority.sessionHeader.sessionKey, + contentDigest: summaryRef.digest, + }, + [ + { + v: 1, + sequence: toSequence + 1, + eventId: `compaction:${record.uuid}`, + sessionKey: this.authority.sessionHeader.sessionKey, + kind: 'context.compacted', + occurredAt: Date.parse(record.timestamp) || Date.now(), + ...(held === undefined + ? {} + : { + subject: { + type: 'activation', + scopeId: held.activationId, + activationId: held.activationId, + epoch: held.epoch, + }, + }), + payload: { + compactionId: record.uuid, + fromSequence, + toSequence, + summaryRef, + replacedMessageIds, + tokenCountsRef: null, + }, + }, + ], + actor, + ); + } + + private async commitBranchCheckpoint(record: ChatRecord): Promise { + if (parseBranchCheckpointPayload(record.systemPayload) === undefined) { + throw new ManagedSessionUnmappedRecordError(record); + } + // Preserve the old operation and byte digest for retries across cold reopen. + await this.projection.commit( + { + operation: 'commitBranchCheckpoint', + commandId: `recorder:${record.uuid}`, + sessionKey: this.authority.sessionHeader.sessionKey, + contentDigest: createHash('sha256') + .update(JSON.stringify(record), 'utf8') + .digest('hex'), + }, + { record }, + this.actor(), + ); + } + + /** Reader-facing records rebuilt from the authoritative log. */ + project(): Promise { + return this.projection.project(); + } +} diff --git a/packages/core/src/managed-runtime/managed-session-records.test.ts b/packages/core/src/managed-runtime/managed-session-records.test.ts index b60a13e33de..2489eabb9ee 100644 --- a/packages/core/src/managed-runtime/managed-session-records.test.ts +++ b/packages/core/src/managed-runtime/managed-session-records.test.ts @@ -420,7 +420,6 @@ describe('managed session shared field rules', () => { describe('managed session per-kind rules', () => { it.each([ ['model.attempt', () => harnessEvent()], - ['message.committed', () => eventForKind('message.committed')], ['tool.intent', () => eventForKind('tool.intent')], ['context.compacted', () => eventForKind('context.compacted')], ['checkpoint.committed', () => eventForKind('checkpoint.committed')], @@ -687,9 +686,9 @@ describe('managed session per-kind rules', () => { ).toThrow(/toSequence must not precede payload.fromSequence/); }); - it('registers exactly the thirty v1 domains without duplicates', () => { - expect(MANAGED_SESSION_DOMAINS).toHaveLength(30); - expect(new Set(MANAGED_SESSION_DOMAINS).size).toBe(30); + it('registers the v1 domains including file history and session source', () => { + expect(MANAGED_SESSION_DOMAINS).toHaveLength(32); + expect(new Set(MANAGED_SESSION_DOMAINS).size).toBe(32); }); it('validates the lifecycle target state', () => { @@ -735,6 +734,21 @@ describe('managed session per-kind rules', () => { }); describe('managed session actor eligibility', () => { + it('admits projected input without an activation only from a trusted entry', () => { + const input = eventForKind('message.committed'); + delete input['subject']; + const event = parseManagedSessionEvent(input); + expect(() => + assertManagedSessionEventActor(event, 'trusted_entry'), + ).not.toThrow(); + expect(() => assertManagedSessionEventActor(event, 'harness')).toThrow( + /requires an activation subject/, + ); + expect(() => assertManagedSessionEventActor(event, 'authority')).toThrow( + /must not be requested/, + ); + }); + it('lets only the coordinator change an activation', () => { const event = parseManagedSessionEvent({ v: 1, @@ -796,6 +810,7 @@ describe('managed session actor eligibility', () => { sessionKey, kind: 'action.changed', occurredAt: 1, + subject: activationSubject, payload: { requestId: 'req-1', kind: 'permission', @@ -1122,16 +1137,28 @@ describe('managed session transactions', () => { ); }); - it('digests the committed event identities stably', () => { + it('digests the complete committed events stably', () => { const digest = managedSessionEventsDigest([event(1), event(2)]); expect(digest).toBe( - 'd384980415770708f9dd1ff12495d45c8fdbf8508aac8ddd8359362048398e8a', + '0da902e249ff5ba1e2ce05db968cfaa30b51ef1cb089b27b496dfb7765be09b5', ); expect(managedSessionEventsDigest([event(1), event(2)])).toBe(digest); expect(managedSessionEventsDigest([event(2), event(1)])).not.toBe(digest); }); - it('bounds digest input before encoding event identities', () => { + it('covers payloads, scope and timestamps in the commit digest', () => { + const original = event(1); + const digest = managedSessionEventsDigest([original]); + for (const changed of [ + { ...original, occurredAt: original.occurredAt + 1 }, + { ...original, sessionKey: { ...sessionKey, tenantId: 'other' } }, + { ...original, payload: { ...original.payload, source: 'changed' } }, + ]) { + expect(managedSessionEventsDigest([changed])).not.toBe(digest); + } + }); + + it('bounds digest input before encoding event content', () => { expect(() => managedSessionEventsDigest([])).toThrow( /must contain at least one event/, ); diff --git a/packages/core/src/managed-runtime/managed-session-records.ts b/packages/core/src/managed-runtime/managed-session-records.ts index 41500b18f7a..96ee708a082 100644 --- a/packages/core/src/managed-runtime/managed-session-records.ts +++ b/packages/core/src/managed-runtime/managed-session-records.ts @@ -5,6 +5,8 @@ */ import { createHash } from 'node:crypto'; +import { parseBranchCheckpointPayload } from '../services/branch-points.js'; +import type { ChatRecord } from '../services/chatRecordingService.js'; import { stripAnsiAndControl } from '../utils/textUtils.js'; @@ -38,6 +40,8 @@ export const MANAGED_SESSION_LIMITS = { maxCommitMarkerBytes: 64 * 1024, maxTransactionEvents: 256, maxTransactionBytes: 8 * 1024 * 1024, + defaultReadEvents: 100, + maxReadEvents: 256, } as const; export const MANAGED_SESSION_EVENT_KINDS = [ @@ -96,10 +100,30 @@ export const MANAGED_SESSION_DOMAINS = [ 'team_plan', 'session_message', 'session_metadata', + 'file_history', + 'session_source', ] as const; export type ManagedSessionDomain = (typeof MANAGED_SESSION_DOMAINS)[number]; +/** + * The domains a caller may actually submit today. The registry above is the + * closed v1 name space; recognising a name never means the capability is + * implemented or admitted, so submission is gated separately. + */ +export const MANAGED_SESSION_ENABLED_DOMAINS: readonly ManagedSessionDomain[] = + ['goal_state', 'session_metadata', 'file_history', 'session_source']; + +export function assertManagedSessionDomainEnabled( + domain: ManagedSessionDomain, +): void { + if (!MANAGED_SESSION_ENABLED_DOMAINS.includes(domain)) { + throw new ManagedSessionRecordError( + `domain ${domain} is registered but not enabled for submission.`, + ); + } +} + export const MANAGED_SESSION_ACTOR_CLASSES = [ 'harness', 'coordinator', @@ -206,7 +230,7 @@ export interface ManagedSessionCommitMarker { } export class ManagedSessionRecordError extends Error { - readonly code = 'managed_session_invalid_record'; + readonly code: string = 'managed_session_invalid_record'; constructor(message: string) { super(message); @@ -457,6 +481,33 @@ export function assertManagedSessionDurableRef( }; } +export function assertManagedBranchRecord( + value: unknown, + sessionKey: ManagedSessionKey, + recordId: string, +): ChatRecord { + const record = object(value, 'branch record'); + if ( + record['type'] !== 'system' || + record['subtype'] !== 'branch_checkpoint' || + record['uuid'] !== recordId || + record['sessionId'] !== sessionKey.sessionId || + typeof record['timestamp'] !== 'string' || + !Number.isFinite(Date.parse(record['timestamp'])) || + typeof record['cwd'] !== 'string' || + typeof record['version'] !== 'string' || + (record['parentUuid'] !== null && + (typeof record['parentUuid'] !== 'string' || + record['parentUuid'].length === 0)) || + parseBranchCheckpointPayload( + record['systemPayload'] as unknown as ChatRecord['systemPayload'], + ) === undefined + ) { + fail('branch record does not match its committed identity or v1 payload.'); + } + return record as unknown as ChatRecord; +} + function assertSubject( value: ManagedSessionJsonValue | undefined, label: string, @@ -558,7 +609,9 @@ const EVENT_SCHEMAS: Readonly> = expiresAt: 'timeOrNull', installRef: 'refOrNull', boundaryRef: 'refOrNull', + renewalSeq: 'sequence', }, + optional: ['renewalSeq'], }, 'model.attempt': { fields: { @@ -684,7 +737,7 @@ const EVENT_ACTORS: Readonly< 'wake.requested': ['authority'], 'activation.changed': ['coordinator'], 'model.attempt': ['harness'], - 'message.committed': ['harness', 'authority'], + 'message.committed': ['harness', 'trusted_entry'], 'tool.intent': ['harness'], 'action.changed': ['harness', 'trusted_entry'], 'tool.receipt': ['trusted_entry'], @@ -704,7 +757,7 @@ const ACTIVATION_SUBJECT_KINDS: Readonly< 'wake.requested': false, 'activation.changed': false, 'model.attempt': true, - 'message.committed': true, + 'message.committed': false, 'tool.intent': true, 'action.changed': false, 'tool.receipt': false, @@ -1027,7 +1080,9 @@ export function assertManagedSessionEventActor( `action.changed ${state}/${source} must be requested by ${expected}, not ${actor}.`, ); } - return; + } + if (actor === 'harness' && event.subject?.type !== 'activation') { + fail(`${event.kind} from the harness requires an activation subject.`); } } @@ -1223,8 +1278,7 @@ export function parseManagedSessionCommitMarker( } /** - * Digest over the ordered event identities. It does not cover session keys, - * timestamps, or payloads and must not be used as an event-content proof. + * Digest over the full committed events, including payloads and session scope. */ export function managedSessionEventsDigest( events: readonly ManagedSessionEvent[], @@ -1238,26 +1292,9 @@ export function managedSessionEventsDigest( ); } assertJsonValue(events, 'events', new Set(), 0); - const identities: ManagedSessionJsonValue[] = []; - for (let index = 0; index < events.length; index++) { - const event = events[index]; - identities.push({ - sequence: assertManagedSessionSequence( - event.sequence, - `events[${index}].sequence`, - ), - eventId: assertManagedSessionStableId( - event.eventId, - `events[${index}].eventId`, - ), - kind: assertEnum( - event.kind, - MANAGED_SESSION_EVENT_KINDS, - `events[${index}].kind`, - ), - }); - } - const encoded = canonicalManagedSessionJson(identities); + const encoded = canonicalManagedSessionJson( + events as unknown as ManagedSessionJsonValue, + ); if ( Buffer.byteLength(encoded, 'utf8') > MANAGED_SESSION_LIMITS.maxTransactionBytes diff --git a/packages/core/src/managed-runtime/managed-session-resources.test.ts b/packages/core/src/managed-runtime/managed-session-resources.test.ts new file mode 100644 index 00000000000..81d7942ab2e --- /dev/null +++ b/packages/core/src/managed-runtime/managed-session-resources.test.ts @@ -0,0 +1,243 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash } from 'node:crypto'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { SessionWriterLease } from '../services/session-writer-lease.js'; +import { LocalManagedSessionAuthority } from './managed-session-authority.js'; +import { LocalManagedSessionResourceStore } from './managed-session-resources.js'; +import type { ManagedSessionDurableRef } from './managed-session-records.js'; + +const temporaryDirectories = new Set(); + +afterEach(async () => { + for (const directory of temporaryDirectories) { + await fs.rm(directory, { recursive: true, force: true }); + } + temporaryDirectories.clear(); +}); + +async function createRoot(): Promise { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-managed-resources-'), + ); + temporaryDirectories.add(root); + return root; +} + +const sessionKey = { + tenantId: 'tenant-1', + workspaceId: 'workspace-1', + sessionId: 'session-1', +}; + +async function createStore(): Promise<{ + store: LocalManagedSessionResourceStore; + runtimeBaseDir: string; +}> { + const root = await createRoot(); + const runtimeBaseDir = path.join(root, 'runtime'); + await fs.mkdir(runtimeBaseDir, { recursive: true }); + return { + store: LocalManagedSessionResourceStore.create({ + runtimeBaseDir, + sessionKey, + }), + runtimeBaseDir, + }; +} + +describe('managed session resource store', () => { + it('round trips published bytes', async () => { + const { store } = await createStore(); + const content = Buffer.from('list the files in this repository', 'utf8'); + const ref = await store.publish('managed-input', content); + + expect(ref.kind).toBe('managed-input'); + expect(ref.schemaVersion).toBe(1); + expect(ref.byteLength).toBe(content.byteLength); + expect(ref.digest).toBe(createHash('sha256').update(content).digest('hex')); + expect(await store.read(ref)).toEqual(content); + }); + + it('names resources opaquely rather than by content digest', async () => { + const { store } = await createStore(); + const content = Buffer.from('same bytes', 'utf8'); + const first = await store.publish('managed-input', content); + const second = await store.publish('managed-input', content); + + expect(first.digest).toBe(second.digest); + expect(first.resourceId).not.toBe(second.resourceId); + expect(first.resourceId).not.toContain(first.digest); + expect(await store.read(first)).toEqual(content); + expect(await store.read(second)).toEqual(content); + }); + + it('leaves no pending file behind', async () => { + const { store } = await createStore(); + const ref = await store.publish('managed-input', Buffer.from('x', 'utf8')); + const entries = await fs.readdir(path.join(store.sessionRoot, ref.kind)); + expect(entries).toEqual([ref.resourceId]); + }); + + it('stores resources under the controlled runtime directory', async () => { + const { store, runtimeBaseDir } = await createStore(); + const ref = await store.publish('managed-input', Buffer.from('x', 'utf8')); + expect(store.sessionRoot).toBe( + path.join(runtimeBaseDir, 'resources', sessionKey.sessionId), + ); + await expect( + fs.stat(path.join(store.sessionRoot, ref.kind, ref.resourceId)), + ).resolves.toBeDefined(); + }); + + it('rejects content that no longer matches its digest', async () => { + const { store } = await createStore(); + const ref = await store.publish( + 'managed-input', + Buffer.from('original', 'utf8'), + ); + await fs.writeFile( + path.join(store.sessionRoot, ref.kind, ref.resourceId), + 'tampered', + 'utf8', + ); + await expect(store.read(ref)).rejects.toThrow( + /does not match its recorded digest/, + ); + }); + + it('rejects content whose length changed', async () => { + const { store } = await createStore(); + const ref = await store.publish( + 'managed-input', + Buffer.from('original', 'utf8'), + ); + await fs.writeFile( + path.join(store.sessionRoot, ref.kind, ref.resourceId), + 'short', + 'utf8', + ); + await expect(store.read(ref)).rejects.toThrow(/bytes where 8 was recorded/); + }); + + it('reports a missing resource rather than returning empty content', async () => { + const { store } = await createStore(); + const ref: ManagedSessionDurableRef = { + resourceId: 'absent', + kind: 'managed-input', + schemaVersion: 1, + byteLength: 1, + digest: 'a'.repeat(64), + }; + await expect(store.read(ref)).rejects.toThrow(/is not present for session/); + }); + + it('refuses a kind or resource id that escapes its directory', async () => { + const { store } = await createStore(); + await expect( + store.publish('../escape', Buffer.from('x', 'utf8')), + ).rejects.toThrow(/kind must be a single path segment/); + await expect( + store.read({ + resourceId: '../../etc/passwd', + kind: 'managed-input', + schemaVersion: 1, + byteLength: 1, + digest: 'a'.repeat(64), + }), + ).rejects.toThrow(/resourceId must be a single path segment/); + }); +}); + +describe('managed session input durability', () => { + it('recovers the original prompt content after a cold reopen', async () => { + const root = await createRoot(); + const runtimeBaseDir = path.join(root, 'runtime'); + const transcriptPath = path.join(root, 'chats', 'session-1.jsonl'); + await fs.mkdir(runtimeBaseDir, { recursive: true }); + await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); + + const store = LocalManagedSessionResourceStore.create({ + runtimeBaseDir, + sessionKey, + }); + const prompt = Buffer.from('summarise the design docs', 'utf8'); + const contentRef = await store.publish('managed-input', prompt); + const admissionRef = await store.publish( + 'managed-admission', + Buffer.from('{"source":"web_shell"}', 'utf8'), + ); + + const definitionRef = await store.publish( + 'managed-definition', + Buffer.from('{}', 'utf8'), + ); + const rootSnapshotRef = await store.publish( + 'managed-root', + Buffer.from('{}', 'utf8'), + ); + + const lease = await SessionWriterLease.acquire({ + runtimeBaseDir, + sessionId: sessionKey.sessionId, + transcriptPath, + }); + const authority = await LocalManagedSessionAuthority.open({ + lease, + sessionKey, + cwd: '/workspace', + version: 'test', + create: { definitionRef, rootSnapshotRef, createdBy: 'daemon' }, + }); + await authority.submitInput( + { + operation: 'submitInput', + commandId: 'cmd-1', + sessionKey, + contentDigest: contentRef.digest, + }, + { + inputId: 'in-1', + turnId: 'turn-1', + source: 'web_shell', + contentRef, + deadline: null, + admissionRef, + wakeReason: 'input', + }, + ); + await lease.release(); + + // Cold reopen: nothing from the first authority survives in memory. + const reopenLease = await SessionWriterLease.acquire({ + runtimeBaseDir, + sessionId: sessionKey.sessionId, + transcriptPath, + }); + const reopened = await LocalManagedSessionAuthority.open({ + lease: reopenLease, + sessionKey, + cwd: '/workspace', + version: 'test', + }); + const accepted = reopened.readEvents()[0]; + expect(accepted.kind).toBe('input.accepted'); + + const recoveredRef = accepted.payload[ + 'contentRef' + ] as unknown as ManagedSessionDurableRef; + const recoveredStore = LocalManagedSessionResourceStore.create({ + runtimeBaseDir, + sessionKey, + }); + expect(await recoveredStore.read(recoveredRef)).toEqual(prompt); + await reopenLease.release(); + }); +}); diff --git a/packages/core/src/managed-runtime/managed-session-resources.ts b/packages/core/src/managed-runtime/managed-session-resources.ts new file mode 100644 index 00000000000..075b204f50d --- /dev/null +++ b/packages/core/src/managed-runtime/managed-session-resources.ts @@ -0,0 +1,229 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash, randomUUID } from 'node:crypto'; +import { open, mkdir, rename, readFile, unlink } from 'node:fs/promises'; +import * as path from 'node:path'; +import { managedSessionResourceRoot } from '../utils/sessionStorageUtils.js'; +import type { ChatRecord } from '../services/chatRecordingService.js'; +import { + assertManagedBranchRecord, + assertManagedSessionDurableRef, + ManagedSessionRecordError, + parseManagedSessionRecordJson, + type ManagedSessionDurableRef, + type ManagedSessionEvent, + type ManagedSessionKey, +} from './managed-session-records.js'; +import type { ManagedSessionResourceStore } from './managed-session-storage.js'; + +/** + * Session-owned resource storage. Event payloads carry a `DurableRef` rather + * than inline content, so without a store the log can only reference bytes + * that exist nowhere. + * + * This covers publish and read for one session's private resources. Retention, + * orphan reclamation, pins and workspace-owned resources are separate concerns + * that need the reference-closure ledger, and are deliberately absent. + */ +export class LocalManagedSessionResourceStore + implements ManagedSessionResourceStore +{ + private constructor( + private readonly root: string, + private readonly sessionKey: ManagedSessionKey, + ) {} + + /** + * Resources live beside the session under the controlled runtime base + * directory. The system temp directory is never used, so publishing can + * rename within one filesystem. + */ + static create(options: { + runtimeBaseDir: string; + sessionKey: ManagedSessionKey; + }): LocalManagedSessionResourceStore { + return new LocalManagedSessionResourceStore( + managedSessionResourceRoot( + options.runtimeBaseDir, + options.sessionKey.sessionId, + ), + options.sessionKey, + ); + } + + get sessionRoot(): string { + return this.root; + } + + /** + * Writes the bytes to a controlled temporary file, hashes while writing, + * syncs, then publishes atomically and syncs the containing directory. The + * returned ref is only safe to reference from a transaction after this + * resolves. + */ + async publish( + kind: string, + bytes: Buffer, + ): Promise { + const safeKind = assertPathSegment(kind, 'kind'); + const resourceId = randomUUID(); + const directory = path.join(this.root, safeKind); + const pending = path.join(directory, `.${resourceId}.pending`); + const target = path.join(directory, resourceId); + await mkdir(directory, { recursive: true }); + + const digest = createHash('sha256'); + const handle = await open(pending, 'wx', 0o600); + try { + digest.update(bytes); + await handle.writeFile(bytes); + await handle.sync(); + } catch (cause) { + await handle.close(); + await unlink(pending).catch(() => undefined); + throw cause; + } + await handle.close(); + + try { + await rename(pending, target); + await syncDirectory(directory); + } catch (cause) { + await unlink(pending).catch(() => undefined); + throw cause; + } + + return { + resourceId, + kind: safeKind, + schemaVersion: 1, + byteLength: bytes.byteLength, + digest: digest.digest('hex'), + }; + } + + /** + * Reads a ref this session owns, verifying length and digest. A ref that + * does not resolve to the recorded content is a failure, not a cache miss. + */ + async read(ref: ManagedSessionDurableRef): Promise { + const target = path.join( + this.root, + assertPathSegment(ref.kind, 'kind'), + assertPathSegment(ref.resourceId, 'resourceId'), + ); + let bytes: Buffer; + try { + bytes = await readFile(target); + } catch (cause) { + if ((cause as NodeJS.ErrnoException).code === 'ENOENT') { + throw new ManagedSessionRecordError( + `resource ${ref.resourceId} is not present for session ${this.sessionKey.sessionId}.`, + ); + } + throw cause; + } + if (bytes.byteLength !== ref.byteLength) { + throw new ManagedSessionRecordError( + `resource ${ref.resourceId} is ${bytes.byteLength} bytes where ${ref.byteLength} was recorded.`, + ); + } + const digest = createHash('sha256').update(bytes).digest('hex'); + if (digest !== ref.digest) { + throw new ManagedSessionRecordError( + `resource ${ref.resourceId} does not match its recorded digest.`, + ); + } + return bytes; + } +} + +/** + * Classifies a committed checkpoint before recovery trusts it. + * + * A historical Managed log recorded branch points as `checkpoint.committed` + * referencing a whole ChatRecord, so the resource kind is the only thing + * separating a display record from Harness state. Returning that record is + * what lets a reader keep showing those branch points without letting one + * stand in for a resumable state; Harness state is never decoded as a record. + */ +export async function readManagedBranchCheckpoint( + event: ManagedSessionEvent, + resources: ManagedSessionResourceStore | undefined, + checkpointSequence: (id: string) => number | undefined, + committedSequence: number, +): Promise { + if (event.kind !== 'checkpoint.committed') return undefined; + if (resources === undefined) { + throw new ManagedSessionRecordError( + 'a resource store is required to classify checkpoints.', + ); + } + const ref = assertManagedSessionDurableRef( + event.payload['stateRef'], + 'checkpoint state', + ); + const branch = ref.kind === 'managed-branch-checkpoint'; + if ( + ref.schemaVersion !== 1 || + (!branch && ref.kind !== 'managed-checkpoint') + ) { + throw new ManagedSessionRecordError('unknown checkpoint state format.'); + } + const id = event.payload['checkpointId'] as string; + const covered = event.payload['coveredSequence'] as number; + const previous = event.payload['previousCheckpointId'] as string | null; + const previousSequence = + previous === null ? undefined : checkpointSequence(previous); + if ( + checkpointSequence(id) !== undefined || + covered >= event.sequence || + covered > committedSequence || + (previous !== null && + (previousSequence === undefined || previousSequence > covered)) + ) { + throw new ManagedSessionRecordError( + 'invalid checkpoint coverage or predecessor.', + ); + } + if ( + branch && + (event.payload['boundary'] !== null || covered !== event.sequence - 1) + ) { + throw new ManagedSessionRecordError( + 'invalid historical branch checkpoint boundary.', + ); + } + if (!branch) return undefined; + const bytes = await resources.read(ref); + return assertManagedBranchRecord( + parseManagedSessionRecordJson(bytes.toString('utf8'), ref.byteLength), + event.sessionKey, + id, + ); +} + +function assertPathSegment(value: string, label: string): string { + if (value.length === 0) { + throw new ManagedSessionRecordError(`${label} must not be empty.`); + } + if (value !== path.basename(value) || value === '.' || value === '..') { + throw new ManagedSessionRecordError( + `${label} must be a single path segment.`, + ); + } + return value; +} + +async function syncDirectory(directory: string): Promise { + const handle = await open(directory, 'r'); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} diff --git a/packages/core/src/managed-runtime/managed-session-storage.ts b/packages/core/src/managed-runtime/managed-session-storage.ts new file mode 100644 index 00000000000..97b1d0dbd46 --- /dev/null +++ b/packages/core/src/managed-runtime/managed-session-storage.ts @@ -0,0 +1,352 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { managedToolDigest } from '../tools/managed-tool-protocol.js'; +import { + MANAGED_SESSION_COMMIT_SUBTYPE, + MANAGED_SESSION_EVENT_SUBTYPE, + MANAGED_SESSION_HEADER_SUBTYPE, + MANAGED_SESSION_LIMITS, + ManagedSessionRecordError, + managedSessionEventsDigest, + managedSessionKeysEqual, + parseManagedSessionCommitMarker, + parseManagedSessionEvent, + parseManagedSessionHeader, + parseManagedSessionRecordJson, + type ManagedSessionDurableRef, + type ManagedSessionEvent, + type ManagedSessionHeader, + type ManagedSessionKey, +} from './managed-session-records.js'; + +export interface ManagedSessionCommitReceipt { + readonly transactionId: string; + readonly commandId: string; + readonly operation: string; + readonly firstSequence: number; + readonly lastSequence: number; + readonly committedSequence: number; + /** True when the original commit was returned instead of appending again. */ + readonly replayed: boolean; +} + +export interface ManagedSessionActivationState { + readonly activationId: string; + readonly epoch: number; + readonly workerId: string; + readonly phase: string; + /** The horizon the install recorded; a release restates it unchanged. */ + readonly expiresAt: number; + /** Renewal count of the current activation; 0 for the install itself. */ + readonly renewalSeq: number; + /** The install evidence a renewal must restate; null on old logs. */ + readonly installRef: ManagedSessionDurableRef | null; +} + +export interface ManagedSessionCommittedTransaction { + readonly receipt: ManagedSessionCommitReceipt; + readonly contentDigest: string; +} + +export interface ManagedSessionJournalScan { + readonly header?: ManagedSessionHeader; + readonly events: ManagedSessionEvent[]; + readonly transactions: Map; + readonly committed: number; + readonly lastMarkerDigest: string | null; + readonly lastRecordUuid: string | null; + readonly activation: ManagedSessionActivationState | undefined; + /** Byte length of the prefix ending at the last commit marker. */ + readonly committedBytes: number; + /** Byte length through the Managed header. */ + readonly headerBytes: number; + /** Byte length of records after the committed prefix. */ + readonly uncommittedBytes: number; + readonly uncommitted: number; + readonly foreignRecords: number; + /** Engine ownership records written before the Managed header. */ + readonly engineRecords: number; +} + +export interface ManagedSessionJournalReader { + readonly sessionKey: ManagedSessionKey; + read(options?: { + readonly maxBytes?: number; + }): Promise; +} + +/** + * The commit position a writer pins when sealing a Managed journal: the last + * committed sequence and the digest-chain head at that point. A takeover + * authenticates the scanned log against this proof before advancing it. + */ +export interface ManagedSessionCommitProof { + readonly lastCommitSequence: number; + readonly committedPrefixHash: string; +} + +/** + * Physical writer for one Managed Session journal. + * + * A semantic transaction is handed to the store as one batch. The local JSONL + * adapter deliberately preserves its historical line-by-line crash behavior; + * durable adapters may commit the same batch atomically. + */ +export interface ManagedSessionJournalHandle + extends ManagedSessionJournalReader { + appendTransaction(records: readonly unknown[]): Promise; + blockRecovery?(request: { + readonly status: + | 'BLOCKED_RESOURCE' + | 'BLOCKED_WORKSPACE' + | 'BLOCKED_EXECUTION'; + readonly detailCode: string; + }): Promise; + seal(commit: ManagedSessionCommitProof): Promise; + /** + * The commit proof this handle took over from a sealed writer, when it did. + * Durable stores keep the proof server-side and omit it here. + */ + readonly takeoverCommitProof?: ManagedSessionCommitProof; + /** Abandons an unsuccessful open without publishing a handoff boundary. */ + abort(): Promise; +} + +export interface ManagedSessionJournalStore { + open(request: { + readonly sessionKey: ManagedSessionKey; + }): Promise; +} + +export interface ManagedSessionResourceStore { + publish(kind: string, bytes: Buffer): Promise; + read(ref: ManagedSessionDurableRef): Promise; +} + +export function managedSessionCommandKey( + operation: string, + commandId: string, +): string { + return `${operation}\u0000${commandId}`; +} + +export function managedSessionActivationStateFrom( + event: ManagedSessionEvent, +): ManagedSessionActivationState { + return { + activationId: event.payload['activationId'] as string, + epoch: event.payload['epoch'] as number, + workerId: event.payload['workerId'] as string, + phase: event.payload['phase'] as string, + expiresAt: event.payload['expiresAt'] as number, + renewalSeq: (event.payload['renewalSeq'] as number | undefined) ?? 0, + installRef: + (event.payload['installRef'] as ManagedSessionDurableRef | null) ?? null, + }; +} + +export function emptyManagedSessionJournalScan(): ManagedSessionJournalScan { + return { + events: [], + transactions: new Map(), + committed: 0, + lastMarkerDigest: null, + lastRecordUuid: null, + activation: undefined, + committedBytes: 0, + headerBytes: 0, + uncommittedBytes: 0, + uncommitted: 0, + foreignRecords: 0, + engineRecords: 0, + }; +} + +/** + * Parses the exact JSONL representation shared by local files and remote + * journal exports. Corrupt committed content fails closed; only a final torn + * line is classified as an uncommitted tail. + */ +export function scanManagedSessionJournal( + bytes: Buffer, + sessionKey: ManagedSessionKey, + maxBytes?: number, +): ManagedSessionJournalScan { + const text = ( + maxBytes === undefined || maxBytes >= bytes.byteLength + ? bytes + : bytes.subarray(0, maxBytes) + ).toString('utf8'); + const lines = text.split('\n'); + const tornTail = lines[lines.length - 1] !== '' ? 1 : 0; + lines.pop(); + + let header: ManagedSessionHeader | undefined; + const events: ManagedSessionEvent[] = []; + const transactions = new Map(); + let committed = 0; + let lastMarkerDigest: string | null = null; + let lastRecordUuid: string | null = null; + let activation: ManagedSessionActivationState | undefined; + let scanned = 0; + let committedBytes = 0; + let headerBytes = 0; + let foreignRecords = 0; + let engineRecords = 0; + let pending: ManagedSessionEvent[] = []; + + for (let index = 0; index < lines.length; index++) { + const line = lines[index]; + if (line === '') { + throw new ManagedSessionRecordError( + `session log line ${index + 1} is blank.`, + ); + } + scanned += Buffer.byteLength(line, 'utf8') + 1; + const record = parseManagedSessionRecordJson( + line, + MANAGED_SESSION_LIMITS.maxEventBytes, + ); + if ( + record === null || + typeof record !== 'object' || + Array.isArray(record) + ) { + throw new ManagedSessionRecordError( + `session log line ${index + 1} is not a record object.`, + ); + } + const envelope = record as Record; + const subtype = envelope['subtype']; + if (typeof envelope['uuid'] === 'string') { + lastRecordUuid = envelope['uuid']; + } + if ( + subtype !== MANAGED_SESSION_HEADER_SUBTYPE && + subtype !== MANAGED_SESSION_EVENT_SUBTYPE && + subtype !== MANAGED_SESSION_COMMIT_SUBTYPE + ) { + if (header !== undefined) { + throw new ManagedSessionRecordError( + `session log line ${index + 1} has the unknown subtype ${String(subtype)} after the Managed header.`, + ); + } + foreignRecords++; + if (subtype === 'session_execution_engine') engineRecords++; + continue; + } + const body = envelope['managedSession']; + if (subtype === MANAGED_SESSION_HEADER_SUBTYPE) { + if (header !== undefined) { + throw new ManagedSessionRecordError( + `session log line ${index + 1} repeats the Managed header.`, + ); + } + header = parseManagedSessionHeader(body); + headerBytes = scanned; + if (!managedSessionKeysEqual(header.sessionKey, sessionKey)) { + throw new ManagedSessionRecordError( + 'session log header belongs to a different session.', + ); + } + continue; + } + if (header === undefined) { + throw new ManagedSessionRecordError( + `session log line ${index + 1} precedes the Managed header.`, + ); + } + if (subtype === MANAGED_SESSION_EVENT_SUBTYPE) { + const event = parseManagedSessionEvent(body); + if (!managedSessionKeysEqual(event.sessionKey, sessionKey)) { + throw new ManagedSessionRecordError( + `session log line ${index + 1} belongs to a different session.`, + ); + } + const expected = committed + pending.length + 1; + if (event.sequence !== expected) { + throw new ManagedSessionRecordError( + `session log line ${index + 1} has sequence ${event.sequence} where ${expected} was expected.`, + ); + } + pending.push(event); + continue; + } + const marker = parseManagedSessionCommitMarker(body); + if (marker.previousCommitDigest !== lastMarkerDigest) { + throw new ManagedSessionRecordError( + `session log line ${index + 1} does not chain to the previous commit.`, + ); + } + if ( + marker.eventCount !== pending.length || + marker.firstSequence !== committed + 1 || + marker.lastSequence !== committed + pending.length + ) { + throw new ManagedSessionRecordError( + `session log line ${index + 1} does not cover the preceding events.`, + ); + } + if (marker.eventsDigest !== managedSessionEventsDigest(pending)) { + throw new ManagedSessionRecordError( + `session log line ${index + 1} does not match the preceding event content.`, + ); + } + for (const event of pending) { + if ( + event.kind === 'checkpoint.committed' && + (event.payload['coveredSequence'] as number) > committed + ) { + throw new ManagedSessionRecordError( + 'checkpoint covers events not committed before its transaction.', + ); + } + events.push(event); + if (event.kind === 'activation.changed') { + activation = managedSessionActivationStateFrom(event); + } + } + committed = marker.lastSequence; + committedBytes = scanned; + lastMarkerDigest = managedToolDigest( + marker, + MANAGED_SESSION_LIMITS.maxCommitMarkerBytes, + ); + transactions.set( + managedSessionCommandKey(marker.operation, marker.commandId), + { + contentDigest: marker.contentDigest, + receipt: { + transactionId: marker.transactionId, + commandId: marker.commandId, + operation: marker.operation, + firstSequence: marker.firstSequence, + lastSequence: marker.lastSequence, + committedSequence: marker.lastSequence, + replayed: false, + }, + }, + ); + pending = []; + } + + return { + header, + events, + transactions, + committed, + lastMarkerDigest, + lastRecordUuid, + activation, + committedBytes, + headerBytes, + uncommittedBytes: Buffer.byteLength(text, 'utf8') - committedBytes, + uncommitted: pending.length + tornTail, + foreignRecords, + engineRecords, + }; +} diff --git a/packages/core/src/managed-runtime/managed-session-store-contract.test.ts b/packages/core/src/managed-runtime/managed-session-store-contract.test.ts new file mode 100644 index 00000000000..6963fe410c5 --- /dev/null +++ b/packages/core/src/managed-runtime/managed-session-store-contract.test.ts @@ -0,0 +1,223 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { describe, expect, it, vi } from 'vitest'; +import { + createHttpManagedSessionStores, + HTTP_MANAGED_SESSION_STORE_CONTRACT, + type ManagedSessionStoreHttpError, +} from './http-managed-session-store.js'; +import { MANAGED_SESSION_LIMITS } from './managed-session-records.js'; + +interface ContractFixture { + contractVersion: number; + headers: { + tenant: string; + writerToken: string; + }; + limits: { + maxInlineResourceBytes: number; + maxResourcesPerTransaction: number; + maxTransactionBytes: number; + maxTransactionEvents: number; + minimumWriterTokenLength: number; + maximumWriterTokenLength: number; + minimumLeaseDurationMs: number; + maximumLeaseDurationMs: number; + }; + sessionKey: { + tenantId: string; + workspaceId: string; + sessionId: string; + }; + resources: Array<{ + resourceId: string; + kind: string; + schemaVersion: number; + utf8: string; + bytesBase64: string; + byteLength: number; + digest: string; + }>; + genesisTransaction: { + records: unknown[]; + jsonl: string; + recordBytesBase64: string; + byteLength: number; + recordDigest: string; + expectedRequest: Record; + }; + errors: Record; +} + +const fixture = JSON.parse( + await readFile( + new URL( + './contracts/managed-session-store-v1.fixtures.json', + import.meta.url, + ), + 'utf8', + ), +) as ContractFixture; + +describe('Managed Session store shared contract', () => { + it('pins headers, limits, bytes, and digests', () => { + expect(fixture.contractVersion).toBe(1); + expect(HTTP_MANAGED_SESSION_STORE_CONTRACT).toEqual({ + tenantHeader: fixture.headers.tenant, + writerTokenHeader: fixture.headers.writerToken, + maxInlineResourceBytes: fixture.limits.maxInlineResourceBytes, + maxResourcesPerTransaction: fixture.limits.maxResourcesPerTransaction, + minimumWriterTokenLength: fixture.limits.minimumWriterTokenLength, + maximumWriterTokenLength: fixture.limits.maximumWriterTokenLength, + minimumLeaseDurationMs: fixture.limits.minimumLeaseDurationMs, + maximumLeaseDurationMs: fixture.limits.maximumLeaseDurationMs, + }); + expect(MANAGED_SESSION_LIMITS.maxTransactionBytes).toBe( + fixture.limits.maxTransactionBytes, + ); + expect(MANAGED_SESSION_LIMITS.maxTransactionEvents).toBe( + fixture.limits.maxTransactionEvents, + ); + + for (const resource of fixture.resources) { + const bytes = Buffer.from(resource.utf8, 'utf8'); + expect(bytes.toString('base64')).toBe(resource.bytesBase64); + expect(bytes.byteLength).toBe(resource.byteLength); + expect(sha256(bytes)).toBe(resource.digest); + } + + const bytes = Buffer.from(fixture.genesisTransaction.jsonl, 'utf8'); + expect(bytes.toString('base64')).toBe( + fixture.genesisTransaction.recordBytesBase64, + ); + expect(bytes.byteLength).toBe(fixture.genesisTransaction.byteLength); + expect(sha256(bytes)).toBe(fixture.genesisTransaction.recordDigest); + }); + + it('serializes the golden transaction without language drift', async () => { + let committed: Record | undefined; + const fetchFn = vi.fn(async (input, init) => { + const path = new URL(requestUrl(input)).pathname; + if (path.endsWith('/writers:acquire')) { + return jsonResponse({ + writerGeneration: 1, + leaseUntil: Date.now() + 60_000, + journalRevision: 0, + committedSequence: 0, + lastCommitDigest: null, + activationEpoch: 0, + replayed: false, + }); + } + if (path.endsWith('/transactions:commit')) { + committed = JSON.parse(String(init?.body)) as Record; + return jsonResponse({ + journalRevision: 1, + ...fixture.genesisTransaction.expectedRequest, + committedSequence: 0, + replayed: false, + }); + } + if (path.endsWith('/writers:seal')) { + return jsonResponse({ + writerGeneration: 1, + state: 'SEALED', + replayed: false, + }); + } + return jsonResponse( + { error: { code: 'not_found', message: 'Not found.' } }, + 404, + ); + }); + const stores = createHttpManagedSessionStores({ + baseUrl: 'http://session-store.test', + sessionKey: fixture.sessionKey, + writerId: 'fixture-writer', + writerToken: 'a'.repeat(32), + fetchFn, + }); + + const writer = await stores.journalStore.open({ + sessionKey: fixture.sessionKey, + }); + await writer.appendTransaction(fixture.genesisTransaction.records); + await stores.close(); + + expect(committed).toMatchObject({ + workspaceId: fixture.sessionKey.workspaceId, + writerId: 'fixture-writer', + writerGeneration: 1, + expectedJournalRevision: 0, + expectedCommittedSequence: 0, + ...fixture.genesisTransaction.expectedRequest, + recordBytesBase64: fixture.genesisTransaction.recordBytesBase64, + recordDigest: fixture.genesisTransaction.recordDigest, + }); + const expectedResources = fixture.resources.map( + ({ resourceId, kind, schemaVersion, byteLength, digest }) => ({ + resourceId, + kind, + schemaVersion, + byteLength, + digest, + }), + ); + expect(committed?.['resources']).toEqual( + expect.arrayContaining(expectedResources), + ); + expect(committed?.['resources']).toHaveLength(expectedResources.length); + }); + + it('preserves every shared Java error classification', async () => { + for (const expected of Object.values(fixture.errors)) { + const stores = createHttpManagedSessionStores({ + baseUrl: 'http://session-store.test', + sessionKey: fixture.sessionKey, + writerId: 'fixture-writer', + writerToken: 'a'.repeat(32), + fetchFn: vi + .fn() + .mockResolvedValue( + jsonResponse( + { error: { code: expected.code, message: 'Fixture error.' } }, + expected.status, + ), + ), + }); + const error = await stores.journalStore + .open({ sessionKey: fixture.sessionKey }) + .catch((cause: unknown) => cause); + expect(error).toMatchObject>({ + status: expected.status, + remoteCode: expected.code, + }); + } + }); +}); + +function requestUrl(input: URL | RequestInfo): string { + if (typeof input === 'string') return input; + if (input instanceof URL) return input.toString(); + return input.url; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { + 'Cache-Control': 'no-store', + 'Content-Type': 'application/json', + }, + }); +} + +function sha256(value: Buffer): string { + return createHash('sha256').update(value).digest('hex'); +} diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index 9d48b45110d..d2117ee9257 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -16,6 +16,8 @@ import { import { getCurrentAgentId } from '../agents/runtime/agent-context.js'; import path from 'node:path'; import fs from 'node:fs'; +import { isManagedSessionTranscriptSync } from '../utils/sessionStorageUtils.js'; +import { SessionExecutionEngineError } from './session-execution-engine.js'; import { randomUUID } from 'node:crypto'; import { isDeepStrictEqual } from 'node:util'; import type { @@ -314,6 +316,7 @@ export interface ChatRecord { | 'custom_title' | 'parent_session' | 'session_source' + | 'session_execution_engine' | 'omni_recall' | 'session_model' | 'rewind' @@ -1168,6 +1171,12 @@ export class ChatRecordingService { ); } } + if (isManagedSessionTranscriptSync(conversationFile)) { + throw new SessionExecutionEngineError( + this.getSessionId(), + 'belongs to managed, cannot record with legacy', + ); + } this.cachedConversationFile = conversationFile; return conversationFile; } diff --git a/packages/core/src/services/session-execution-engine.ts b/packages/core/src/services/session-execution-engine.ts new file mode 100644 index 00000000000..a4fe9d13a51 --- /dev/null +++ b/packages/core/src/services/session-execution-engine.ts @@ -0,0 +1,141 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { parseLineTolerantWithIntegrity } from '../utils/jsonl-utils.js'; +import { validateTranscriptRecord } from '../utils/transcript-records.js'; + +export type SessionExecutionEngine = 'legacy' | 'managed'; +export const SESSION_EXECUTION_ENGINE_META_KEY = 'qwen.session.executionEngine'; + +export interface SessionExecutionEnginePayload { + version: 1; + engine: SessionExecutionEngine; +} + +export interface SessionExecutionSnapshot { + filePath: string; + dev: number; + ino: number; + size: number; + lastUpdated: string; +} + +export type SessionExecutionEngineState = { + sessionId: string; + snapshot: SessionExecutionSnapshot; +} & ( + | { + status: 'verified'; + engine: SessionExecutionEngine; + recorded: boolean; + } + | { status: 'unavailable'; reason: string } +); + +export class SessionExecutionEngineError extends Error { + readonly errorKind = 'session_execution_engine_unavailable'; + + constructor(sessionId: string, reason: string) { + super(`Session execution engine for ${sessionId}: ${reason}.`); + this.name = 'SessionExecutionEngineError'; + } +} + +export function assertSessionExecutionEngine( + state: SessionExecutionEngineState | undefined, + sessionId: string, + expected: SessionExecutionEngine, +): asserts state is SessionExecutionEngineState & { status: 'verified' } { + if (!state || state.sessionId !== sessionId) { + throw new SessionExecutionEngineError( + sessionId, + 'ownership was not verified', + ); + } + if (state.status !== 'verified') { + throw new SessionExecutionEngineError(sessionId, state.reason); + } + if (state.engine !== expected) { + throw new SessionExecutionEngineError( + sessionId, + `belongs to ${state.engine}, cannot execute with ${expected}`, + ); + } +} + +/** Tracks physical records before branch selection or UUID aggregation. */ +export class SessionExecutionEngineAccumulator { + private engine: SessionExecutionEngine | undefined; + private reason: string | undefined; + private hasRecords = false; + private complete = true; + + constructor(private readonly sessionId: string) {} + + parseLine(line: string, filePath: string): unknown[] { + if (!line.trim()) return []; + const parsed = parseLineTolerantWithIntegrity(line, filePath); + if (!parsed.complete) this.reason ??= 'incomplete transcript'; + this.complete &&= parsed.complete; + for (const value of parsed.records) { + this.hasRecords = true; + const { record, diagnostics } = validateTranscriptRecord(value); + if ( + !record || + record.sessionId !== this.sessionId || + diagnostics.some((diagnostic) => diagnostic.affectsCompleteness) + ) { + this.reason ??= 'invalid transcript record'; + } + const candidate = value as Record; + if (candidate['subtype'] !== 'session_execution_engine') continue; + const payload = candidate['systemPayload']; + if ( + candidate['type'] !== 'system' || + !payload || + typeof payload !== 'object' || + Array.isArray(payload) || + !('version' in payload) || + payload.version !== 1 || + !('engine' in payload) || + (payload.engine !== 'legacy' && payload.engine !== 'managed') + ) { + this.reason ??= 'invalid owner record'; + continue; + } + if (this.engine !== undefined && this.engine !== payload.engine) { + this.reason ??= 'conflicting owners'; + } + this.engine = payload.engine; + } + return parsed.records; + } + + /** + * Whether every line handed to {@link parseLine} parsed whole. + * + * Exposed because a caller that reads lines through this accumulator has no + * other way to see it: the parse happens here, and an index that assumed the + * source was complete would present a truncated transcript as a whole one. + */ + get sourceComplete(): boolean { + return this.complete; + } + + finish(snapshot: SessionExecutionSnapshot): SessionExecutionEngineState { + const reason = + this.reason ?? (!this.hasRecords ? 'empty transcript' : undefined); + return reason + ? { status: 'unavailable', sessionId: this.sessionId, snapshot, reason } + : { + status: 'verified', + sessionId: this.sessionId, + snapshot, + engine: this.engine ?? 'legacy', + recorded: this.engine !== undefined, + }; + } +} diff --git a/packages/core/src/services/session-transcript-reader.ts b/packages/core/src/services/session-transcript-reader.ts index 72cfc10c6f6..fea2289277d 100644 --- a/packages/core/src/services/session-transcript-reader.ts +++ b/packages/core/src/services/session-transcript-reader.ts @@ -19,7 +19,16 @@ import { Storage } from '../config/storage.js'; import * as jsonl from '../utils/jsonl-utils.js'; import { createDebugLogger } from '../utils/debugLogger.js'; import { addDaemonRequestAttribute } from '../telemetry/daemon-tracing.js'; -import { readSessionTitleInfoFromFileSync } from '../utils/sessionStorageUtils.js'; +import { + localManagedSessionKey, + readManagedSessionTitleInfoSync, + readSessionTitleInfoFromFileSync, +} from '../utils/sessionStorageUtils.js'; +import { readManagedSessionRecords } from '../managed-runtime/managed-session-message-projection.js'; +import { + MANAGED_SESSION_HEADER_SUBTYPE, + ManagedSessionRecordError, +} from '../managed-runtime/managed-session-records.js'; import type { HistoryGap } from '../utils/conversation-chain.js'; import { parseGoalStateRecordPayloadV2 } from '../goals/goal-reducer.js'; import type { GoalStateRecordPayloadV2 } from '../goals/goal-protocol.js'; @@ -59,6 +68,11 @@ import type { UiEvent } from '../telemetry/uiTelemetry.js'; import type { AttributionSnapshot } from './commitAttribution.js'; import type { FileHistorySnapshot } from './fileHistoryService.js'; import { SessionFileHistoryAccumulator } from './session-file-history-state.js'; +import { + SessionExecutionEngineAccumulator, + type SessionExecutionEngine, + type SessionExecutionEngineState, +} from './session-execution-engine.js'; import { selectActiveSideArtifactRecordUuids, SessionArtifactSnapshotAccumulator, @@ -272,6 +286,7 @@ export interface SessionRuntimeResumeState extends SessionSourcesRestoreState { sourceId?: string; sessionModel?: SessionModelRecordPayload; lastAssistantModel?: string; + executionEngine?: SessionExecutionEngine; }; fileHistorySnapshots?: FileHistorySnapshot[]; artifactSnapshot?: RebuiltSessionArtifactSnapshot; @@ -287,9 +302,122 @@ export interface SessionRestoreProjection { startTime: string; lastUpdated: string; runtime: SessionRuntimeResumeState; + executionEngine?: SessionExecutionEngineState; replay?: SessionRestoreReplayPage; } +export interface ManagedSessionRestoreProjectionInput { + readonly sessionId: string; + readonly records: readonly ChatRecord[]; + readonly replay: SessionRestoreReplaySelection; + readonly filePath: string; + readonly startTime: string; + readonly lastUpdated: string; + readonly executionEngine: SessionExecutionEngineState; + readonly fallbackLastCompletedUuid: string; + readonly customTitle?: string; + readonly titleSource?: TitleSource; +} + +/** Rebuilds the existing runtime resume shape from a durable Managed journal. */ +export function buildManagedSessionRestoreProjection( + input: ManagedSessionRestoreProjectionInput, +): SessionRestoreProjection { + validateRestoreReplaySelection(input.replay); + const records = [...input.records]; + const apiHistory = new SessionApiHistoryAccumulator(); + const resumeTokenCounts = new ResumeTokenCountsAccumulator(); + const turnState = new SessionTurnStateAccumulator(input.sessionId); + const fileHistory = new SessionFileHistoryAccumulator(); + const uiTelemetryEvents: UiEvent[] = []; + const goalRecords: GoalRecoveryRecord[] = []; + let attributionSnapshot: AttributionSnapshot | undefined; + let lastAssistantModel: string | undefined; + let lastTokenCountsRecord: ChatRecord | undefined; + for (const record of records) { + if (record.sessionId !== input.sessionId) { + throw new ManagedSessionRecordError( + 'Managed Session projection contains a record from another session.', + ); + } + turnState.addHint(getSessionTurnRecordHint(record, input.sessionId)); + apiHistory.add(record); + fileHistory.add(record); + if (isResumeTokenCountsCandidate(record)) lastTokenCountsRecord = record; + if (isGoalRecoveryCandidate(record)) { + const normalized = normalizeGoalRecoveryRecord(record); + if (normalized) goalRecords.push(normalized); + } + if (record.subtype === 'ui_telemetry') { + const uiEvent = ( + record.systemPayload as UiTelemetryRecordPayload | undefined + )?.uiEvent; + if (uiEvent) uiTelemetryEvents.push(uiEvent); + } + if (record.subtype === 'attribution_snapshot') { + const snapshot = ( + record.systemPayload as AttributionSnapshotPayload | undefined + )?.snapshot; + if (snapshot && typeof snapshot === 'object') { + attributionSnapshot = snapshot; + } + } + if ( + record.type === 'assistant' && + typeof record.model === 'string' && + record.model.trim() + ) { + lastAssistantModel = record.model; + } + } + if (lastTokenCountsRecord) resumeTokenCounts.add(lastTokenCountsRecord); + const turnStateValue = turnState.finish(); + const goalRecovery = selectGoalRecoveryFromRecords(goalRecords); + const restoredTokenCounts = resumeTokenCounts.finish(); + const restoredFileHistory = fileHistory.finish(); + const runtime: SessionRuntimeResumeState = { + apiHistory: apiHistory.finish(), + ...(restoredTokenCounts ? { resumeTokenCounts: restoredTokenCounts } : {}), + uiTelemetryEvents, + ...(attributionSnapshot ? { attributionSnapshot } : {}), + recording: { + lastCompletedUuid: + records[records.length - 1]?.uuid ?? input.fallbackLastCompletedUuid, + turnParentUuids: turnStateValue.turnParentUuids, + ...(input.customTitle !== undefined + ? { customTitle: input.customTitle } + : {}), + ...(input.titleSource !== undefined + ? { titleSource: input.titleSource } + : {}), + ...(lastAssistantModel !== undefined ? { lastAssistantModel } : {}), + ...(input.executionEngine.status === 'verified' && + input.executionEngine.recorded + ? { executionEngine: input.executionEngine.engine } + : {}), + }, + goalRecords, + ...(goalRecovery.sourceUuid + ? { goalRecoverySourceUuid: goalRecovery.sourceUuid } + : {}), + ...(restoredFileHistory + ? { fileHistorySnapshots: restoredFileHistory } + : {}), + initialTurn: turnStateValue.initialTurn, + backgroundNotificationTaskIds: turnStateValue.backgroundNotificationTaskIds, + }; + const replay = managedReplayPage(records, input.replay); + return { + sessionId: input.sessionId, + filePath: input.filePath, + startTime: input.startTime, + lastUpdated: input.lastUpdated, + runtime, + executionEngine: input.executionEngine, + ...(replay ? { replay } : {}), + }; +} + export interface SessionLiveRestoreProjection extends SessionSourcesRestoreState { sessionId: string; @@ -367,6 +495,7 @@ interface TranscriptNavigationTurnHint { } interface TranscriptIndex { + executionEngine: SessionExecutionEngineState; filePath: string; fileIdentity: SessionTranscriptFileIdentity; snapshotSize: number; @@ -384,6 +513,12 @@ interface TranscriptIndex { lastUpdated: string; byUuid: Map; branchPointsByAssistantUuid: ReadonlyMap; + /** + * Present when the index was projected rather than scanned from the file. + * Such an index must not reach the index cache: its byte estimate does not + * account for these records. + */ + projectedRecords?: ReadonlyMap; } interface CacheEntry { @@ -1891,6 +2026,9 @@ async function withAggregatedRecordReadContext( handle, scheduler: new CooperativeReadScheduler(), lineCache: {}, + ...(index.projectedRecords + ? { preloadedRecords: new Map(index.projectedRecords) } + : {}), }; try { return await callback(context); @@ -1931,6 +2069,52 @@ async function readGoalStatePayloadBeforePosition( return parseGoalStateRecordPayloadV2(record?.systemPayload); } +/** + * The index entry for one record's first fragment. + * + * Shared so a projection-backed index answers navigation, goal and turn + * questions exactly the way the physical scanner does; two copies of this field + * list would drift and give a Managed session a different history than a legacy + * one. + */ +function newIndexEntry( + record: ChatRecord, + sessionId: string, + segments: RecordSegment[], +): UuidIndexEntry { + const navigationKind = navigationKindForRecord(record); + return { + parentUuid: record.parentUuid, + sessionIdMatchesFile: record.sessionId === sessionId, + type: record.type, + ...(record.subtype !== undefined ? { subtype: record.subtype } : {}), + inherited: record.forkedFrom !== undefined, + sideTaskSource: + record.type === 'system' && + record.subtype === 'session_source' && + isObjectRecord(record.systemPayload) && + record.systemPayload['sourceType'] === 'side_task', + apiHistoryCompressionCandidate: isApiHistoryCompressionCandidate(record), + resumeTokenCountsCandidate: isResumeTokenCountsCandidate(record), + attributionSnapshotCandidate: isAttributionSnapshotCandidate(record), + goalRecoveryCandidate: isGoalRecoveryCandidate(record), + turnHint: getSessionTurnRecordHint(record, sessionId), + ...(navigationKind ? { navigationKind } : {}), + ...(typeof record.daemonPromptId === 'string' + ? { daemonPromptId: record.daemonPromptId } + : {}), + navigationTextSuppressed: + record.subtype === 'cron' || + projectUserTranscriptForDisplay(record).displayText !== undefined, + assistantPreviewCandidate: isAssistantPreviewCandidate(record), + ...(record.subtype === 'turn_result' && + isTurnResultRecordPayload(record.systemPayload) + ? { turnResultPromptId: record.systemPayload.promptId } + : {}), + segments, + }; +} + async function buildIndex(params: { filePath: string; fileIdentity: SessionTranscriptFileIdentity; @@ -1939,6 +2123,7 @@ async function buildIndex(params: { }): Promise { const { filePath, fileIdentity, snapshotSize, lastUpdated } = params; const sessionId = path.basename(filePath, '.jsonl'); + const executionEngine = new SessionExecutionEngineAccumulator(sessionId); if (snapshotSize > SESSION_TRANSCRIPT_MAX_INDEX_BYTES) { debugLogger.warn( `index rejected: snapshot too large session=${sessionId} ` + @@ -1973,12 +2158,7 @@ async function buildIndex(params: { const text = line.toString('utf8').trim(); if (text.length === 0) return; let fragmentIndex = 0; - const parsed = jsonl.parseLineTolerantWithIntegrity( - text, - filePath, - ); - sourceReadComplete &&= parsed.complete; - for (const value of parsed.records) { + for (const value of executionEngine.parseLine(text, filePath)) { const record = validateTranscriptRecord(value).record; if (!record) { sourceReadComplete = false; @@ -1988,11 +2168,6 @@ async function buildIndex(params: { firstRecordUuid = record.uuid; firstRecordTimestamp = record.timestamp; } - const sideTaskSource = - record.type === 'system' && - record.subtype === 'session_source' && - isObjectRecord(record.systemPayload) && - record.systemPayload['sourceType'] === 'side_task'; if (isTranscriptConversationRecord(record)) { appendBranchPointRecord( branchPointRecords, @@ -2045,41 +2220,8 @@ async function buildIndex(params: { ).countsAsUserPrompt; } else { const chatRecord = record as unknown as ChatRecord; - const navigationKind = navigationKindForRecord(chatRecord); - const navigationTextSuppressed = - chatRecord.subtype === 'cron' || - projectUserTranscriptForDisplay(chatRecord).displayText !== - undefined; - byUuid.set(record.uuid, { - parentUuid: record.parentUuid, - sessionIdMatchesFile: record.sessionId === sessionId, - type: record.type, - ...(record.subtype !== undefined - ? { subtype: record.subtype } - : {}), - inherited: record.forkedFrom !== undefined, - sideTaskSource, - apiHistoryCompressionCandidate: - isApiHistoryCompressionCandidate(chatRecord), - resumeTokenCountsCandidate: - isResumeTokenCountsCandidate(chatRecord), - attributionSnapshotCandidate: - isAttributionSnapshotCandidate(chatRecord), - goalRecoveryCandidate: isGoalRecoveryCandidate(chatRecord), - turnHint: getSessionTurnRecordHint(chatRecord, sessionId), - ...(navigationKind ? { navigationKind } : {}), - ...(typeof chatRecord.daemonPromptId === 'string' - ? { daemonPromptId: chatRecord.daemonPromptId } - : {}), - navigationTextSuppressed, - assistantPreviewCandidate: - isAssistantPreviewCandidate(chatRecord), - ...(record.subtype === 'turn_result' && - isTurnResultRecordPayload(record.systemPayload) - ? { turnResultPromptId: record.systemPayload.promptId } - : {}), - segments: [segment], - }); + const entry = newIndexEntry(chatRecord, sessionId, [segment]); + byUuid.set(record.uuid, entry); } } }, @@ -2090,6 +2232,7 @@ async function buildIndex(params: { } throw error; } + sourceReadComplete &&= executionEngine.sourceComplete; for (const [uuid, entry] of byUuid) { if (!entry.sessionIdMatchesFile) { @@ -2198,6 +2341,12 @@ async function buildIndex(params: { await indexBuildCompleteHookForTest?.(filePath); return { + executionEngine: executionEngine.finish({ + filePath, + ...fileIdentity, + size: snapshotSize, + lastUpdated, + }), filePath, fileIdentity, snapshotSize, @@ -2397,7 +2546,74 @@ async function hasSnapshotSignature( ); } +export async function readSessionTranscriptSnapshot( + filePath: string, + sessionId: string, + collectRecords = true, +): Promise< + | { + records: ChatRecord[]; + firstRecord?: ChatRecord; + executionEngine: SessionExecutionEngineState; + stats: fs.Stats; + /** False when any line did not parse whole. */ + sourceReadComplete: boolean; + } + | undefined +> { + let stats: fs.Stats; + try { + stats = await fsp.stat(filePath); + } catch (error) { + if (isFileMissingError(error)) return undefined; + throw error; + } + if (!stats.isFile()) { + throw new SessionTranscriptSnapshotUnavailableError(sessionId); + } + const fileIdentity = fileIdentityFromStats(stats); + const lastUpdated = new Date(stats.mtimeMs).toISOString(); + const owner = new SessionExecutionEngineAccumulator(sessionId); + const records: ChatRecord[] = []; + let firstRecord: ChatRecord | undefined; + await forEachLineInSnapshot(filePath, stats.size, (line) => { + for (const record of owner.parseLine( + line.toString('utf8').trim(), + filePath, + )) { + firstRecord ??= record as ChatRecord; + if (collectRecords) records.push(record as ChatRecord); + } + }); + if ( + !(await hasSnapshotSignature( + filePath, + fileIdentity, + stats.size, + lastUpdated, + )) + ) { + throw new SessionTranscriptSnapshotUnavailableError(sessionId); + } + return { + records, + firstRecord, + stats, + sourceReadComplete: owner.sourceComplete, + executionEngine: owner.finish({ + filePath, + ...fileIdentity, + size: stats.size, + lastUpdated, + }), + }; +} + function offerFreshIndexToCache(index: TranscriptIndex): void { + // Projected indexes are only built for a page request and their byte + // estimate ignores `projectedRecords`. Caching one would both inflate the + // 64MB budget and serve wrapper-free pages from the physical-index cache. + if (index.projectedRecords !== undefined) return; pruneCache(); const key = makeCacheKey( index.filePath, @@ -2447,6 +2663,173 @@ function selectArtifactUuids(index: TranscriptIndex): string[] { ); } +function indexHasManagedHeader(index: TranscriptIndex): boolean { + return index.physicalRecords.some( + (record) => record.subtype === MANAGED_SESSION_HEADER_SUBTYPE, + ); +} + +/** + * Builds a replay page over projected records. + * + * There is nothing to select by uuid here: the projection is already the + * committed history in order, so the selection is a suffix of it. Inherited + * history has nothing to hide either -- forking a Managed session is refused, so + * no record in the log belongs to another session. + */ +function managedReplayPage( + records: ChatRecord[], + selection: SessionRestoreReplaySelection, +): SessionRestoreReplayPage | undefined { + if (selection.kind === 'none') return undefined; + const selected = + selection.kind === 'recent' ? records.slice(-selection.limit) : records; + const hasMore = selected.length < records.length; + return { + records: selected, + gaps: [], + hasMore, + ...(hasMore && selected[0] ? { anchorRecordId: selected[0].uuid } : {}), + }; +} + +/** + * Derives navigation turns from projected records. + * + * The physical log of a Managed session holds only wrapper records, which carry + * no navigation kind, so deriving turns from the index would report a session + * with history as having no turns at all. The rules mirror the index builder's: + * the projection is the replay, so a record's position in it is its replay + * position. + */ +function managedNavigationTurns( + records: readonly ChatRecord[], +): TranscriptNavigationTurnHint[] { + const turns: TranscriptNavigationTurnHint[] = []; + let currentPromptTurn: TranscriptNavigationTurnHint | undefined; + let currentRealtimeTurn: TranscriptNavigationTurnHint | undefined; + for (const [position, record] of records.entries()) { + const navigationKind = navigationKindForRecord(record); + if (navigationKind) { + const turn: TranscriptNavigationTurnHint = { + turnId: record.uuid, + replayPosition: position, + kind: navigationKind, + }; + turns.push(turn); + if (navigationKind === 'realtime') { + currentRealtimeTurn = turn; + } else { + currentPromptTurn = turn; + currentRealtimeTurn = undefined; + } + continue; + } + if (isAssistantPreviewCandidate(record)) { + const targetTurn = + record.subtype === 'realtime_message' + ? currentRealtimeTurn + : currentPromptTurn; + if (targetTurn) targetTurn.finalAssistantRecordId = record.uuid; + } + if ( + record.subtype === 'turn_result' && + isTurnResultRecordPayload(record.systemPayload) && + currentPromptTurn + ) { + currentPromptTurn.promptId = record.systemPayload.promptId; + currentPromptTurn = undefined; + } + } + return turns; +} + +/** + * Projections of the snapshots the index cache still holds. + * + * Weakly keyed so it inherits that cache's invalidation exactly: a new file + * identity or snapshot size builds a new index, which misses here. + */ +const managedProjections = new WeakMap(); + +/** + * An index over projected records, shaped like the physical one. + * + * Selection, navigation anchors, goal positions and branch points all read the + * index rather than the file, so giving the projection the same shape is what + * lets paging work on a Managed log without a second copy of those rules. The + * snapshot identity stays the physical one: the turn reader issues snapshots + * from it, and a client anchors a page with them. + */ +function managedPageIndex( + index: TranscriptIndex, + records: ChatRecord[], +): TranscriptIndex { + const sessionId = path.basename(index.filePath, '.jsonl'); + const byUuid = new Map(); + const branchPointRecords = new Map(); + const goalStatePositions: number[] = []; + const projectedRecords = new Map(); + for (const [position, record] of records.entries()) { + if ( + validateTranscriptRecord(record as unknown as TranscriptRecordInput) + .record === undefined || + record.sessionId !== sessionId + ) { + debugLogger.warn( + `projected record rejected session=${sessionId} position=${position}`, + ); + throw new SessionTranscriptSnapshotUnavailableError(sessionId); + } + // The projection is already the whole record, so the segment carries no + // offset to read from; its length is what the page byte budget measures. + const entry = newIndexEntry(record, sessionId, [ + { + offset: 0, + length: Buffer.byteLength(JSON.stringify(record), 'utf8'), + sequence: position, + fragmentIndex: 0, + }, + ]); + byUuid.set(record.uuid, entry); + projectedRecords.set(record.uuid, record); + appendBranchPointRecord(branchPointRecords, record); + if (entry.type === 'system' && entry.subtype === 'goal_state') { + goalStatePositions.push(position); + } + } + const navigationTurns = managedNavigationTurns(records); + for (const [ordinal, turn] of navigationTurns.entries()) { + const entry = byUuid.get(turn.turnId); + if (entry) { + entry.navigationOrdinal = ordinal; + } + } + const replayUuids = records.map((record) => record.uuid); + return { + ...index, + runtimeUuids: replayUuids, + replayUuids, + navigationTurns, + goalStatePositions, + // The committed prefix is contiguous by construction, so a projected page + // never reports a hole the way a broken physical chain does. + gaps: [], + byUuid, + branchPointsByAssistantUuid: new Map( + [ + ...resolveBranchPoints( + replayUuids.flatMap((uuid) => { + const record = branchPointRecords.get(uuid); + return record ? [record] : []; + }), + ).values(), + ].map((point) => [point.assistantRecordUuid, point.checkpointUuid]), + ), + projectedRecords, + }; +} + export class SessionTranscriptReader { private readonly storage: Storage; @@ -2546,14 +2929,20 @@ export class SessionTranscriptReader { startTime, lastUpdated, }; - const totalTurns = index?.navigationTurns.length ?? 0; + const projected = + index !== undefined && indexHasManagedHeader(index) + ? await this.readManagedRecords(sessionId, index, {}) + : undefined; + const navigationTurns = projected + ? managedNavigationTurns(projected) + : (index?.navigationTurns ?? []); + const totalTurns = navigationTurns.length; const start = options.start ?? Math.max(0, totalTurns - Math.min(limit, totalTurns)); if (start > totalTurns) { throw new InvalidSessionTranscriptCursorError(); } - const selectedTurns = - index?.navigationTurns.slice(start, start + limit) ?? []; + const selectedTurns = navigationTurns.slice(start, start + limit); const selectedUuids = selectedTurns.flatMap((turn) => [ turn.turnId, ...(turn.finalAssistantRecordId ? [turn.finalAssistantRecordId] : []), @@ -2568,23 +2957,29 @@ export class SessionTranscriptReader { ); const labels = new Map(); const details = new Map(); - if (index) { + const collectPreview = (record: ChatRecord): void => { + const kind = selectedKinds.get(record.uuid); + if (kind) { + labels.set(record.uuid, { + label: projectNavigationLabel(record, kind), + ...(record.timestamp ? { timestamp: record.timestamp } : {}), + }); + } + if (selectedAssistantUuids.has(record.uuid)) { + const detail = projectNavigationDetail(record); + if (detail) details.set(record.uuid, detail); + } + }; + if (projected) { + const wanted = new Set(selectedUuids); + for (const record of projected) { + if (wanted.has(record.uuid)) collectPreview(record); + } + } else if (index) { await forEachAggregatedRecord( index, [...new Set(selectedUuids)], - (record) => { - const kind = selectedKinds.get(record.uuid); - if (kind) { - labels.set(record.uuid, { - label: projectNavigationLabel(record, kind), - ...(record.timestamp ? { timestamp: record.timestamp } : {}), - }); - } - if (selectedAssistantUuids.has(record.uuid)) { - const detail = projectNavigationDetail(record); - if (detail) details.set(record.uuid, detail); - } - }, + collectPreview, ); } const turns = selectedTurns.map((turn, offset) => { @@ -2674,6 +3069,14 @@ export class SessionTranscriptReader { recordRestoreStage('transcript_index', indexStartedAt); } recordRestoreIndexAttributes(index); + if (indexHasManagedHeader(index)) { + return this.readManagedRestoreProjection( + sessionId, + index, + options, + readOptions, + ); + } const selectionStartedAt = performance.now(); let replaySelection: ReturnType; try { @@ -3059,6 +3462,10 @@ export class SessionTranscriptReader { ...(sourceId !== undefined ? { sourceId } : {}), ...(sessionModel !== undefined ? { sessionModel } : {}), ...(lastAssistantModel !== undefined ? { lastAssistantModel } : {}), + ...(index.executionEngine.status === 'verified' && + index.executionEngine.recorded + ? { executionEngine: index.executionEngine.engine } + : {}), }, ...(restoredFileHistory ? { fileHistorySnapshots: restoredFileHistory } @@ -3087,10 +3494,149 @@ export class SessionTranscriptReader { startTime: index.restoreStartTime, lastUpdated: index.lastUpdated, runtime, + executionEngine: index.executionEngine, + ...(replay ? { replay } : {}), + }; + } + + /** + * Restores a Managed session from its committed events. + * + * The index locates physical records by byte offset, but a Managed log keeps + * the conversation in resource bodies the index cannot see, so selecting uuids + * from it would restore the wrapper records. The projected records are the + * whole history, so the accumulators run over them directly. + * + * Record shapes the sink does not yet admit -- goals, artifacts, file history, + * session source and session model -- cannot appear in a Managed log at all, + * so they are absent here by construction. + */ + private async readManagedRestoreProjection( + sessionId: string, + index: TranscriptIndex, + options: SelectiveSessionRestoreOptions, + readOptions: RestoreProjectionReadOptions, + ): Promise { + const records = await this.readManagedRecords( + sessionId, + index, + readOptions, + ); + const persistedTitle = + readManagedSessionTitleInfoSync( + index.filePath, + this.storage.getRuntimeBaseDir(), + ) ?? {}; + const projection = buildManagedSessionRestoreProjection({ + sessionId, + records, + replay: options.replay, + filePath: index.filePath, + startTime: index.restoreStartTime, + lastUpdated: index.lastUpdated, + executionEngine: index.executionEngine, + fallbackLastCompletedUuid: index.leafUuid, + ...(persistedTitle.title !== undefined + ? { customTitle: persistedTitle.title } + : {}), + ...(persistedTitle.source !== undefined + ? { titleSource: persistedTitle.source } + : {}), + }); + await assertIndexSnapshotUnchanged(index, sessionId); + offerFreshIndexToCache(index); + return projection; + } + + /** The live counterpart, which carries the replay page only. */ + private async readManagedLiveRestoreProjection( + sessionId: string, + index: TranscriptIndex, + options: SelectiveSessionRestoreOptions, + readOptions: RestoreProjectionReadOptions, + ): Promise { + const records = await this.readManagedRecords( + sessionId, + index, + readOptions, + ); + const replay = managedReplayPage(records, options.replay); + await assertIndexSnapshotUnchanged(index, sessionId); + offerFreshIndexToCache(index); + return { + sessionId, + startTime: index.restoreStartTime, + lastUpdated: index.lastUpdated, ...(replay ? { replay } : {}), }; } + private async readManagedRecords( + sessionId: string, + index: TranscriptIndex, + readOptions: RestoreProjectionReadOptions, + ): Promise { + // Timed as the same stage the index-based path uses for its record reads: + // for a Managed log the projection is that read, and leaving it untimed + // would blank out the daemon's restore timings for these sessions. + const startedAt = performance.now(); + try { + // The first physical record proves which project the session belongs to. + // Projecting without that gate would restore another project's session. + let firstRecordSeen = false; + await forEachAggregatedRecord( + index, + [index.firstRecordUuid], + async (record) => { + if (record.uuid !== index.firstRecordUuid) return; + if ( + readOptions.validateFirstRecord && + !(await readOptions.validateFirstRecord(record)) + ) { + throw new SessionTranscriptSnapshotUnavailableError(sessionId); + } + firstRecordSeen = true; + }, + ); + if (!firstRecordSeen) { + throw new SessionTranscriptSnapshotUnavailableError(sessionId); + } + // Keyed by the index, which the cache already invalidates by file + // identity and snapshot size: paging a session would otherwise reproject + // the whole log on every request. The gate above stays outside the cache + // because it authorises this caller, not the projection. + const cached = managedProjections.get(index); + let records = cached; + if (records === undefined) { + try { + records = await readManagedSessionRecords({ + transcriptPath: index.filePath, + runtimeBaseDir: this.storage.getRuntimeBaseDir(), + sessionKey: localManagedSessionKey( + this.storage.getProjectRoot(), + sessionId, + ), + // The index froze a byte length, so the projection answers from + // the same prefix rather than from records appended since. + maxBytes: index.snapshotSize, + }); + } catch (error) { + // A missing or corrupt body is the same class of failure as a + // snapshot that can no longer be served: the page contract maps + // that to 409, not an untyped 500 from JSON.parse(null). + if (error instanceof ManagedSessionRecordError) { + throw new SessionTranscriptSnapshotUnavailableError(sessionId); + } + throw error; + } + managedProjections.set(index, records); + } + return [...records]; + } finally { + recordRestoreStage('selected_record_read', startedAt); + } + } + async readLiveRestoreProjection( sessionId: string, options: SelectiveSessionRestoreOptions, @@ -3143,6 +3689,14 @@ export class SessionTranscriptReader { recordRestoreStage('transcript_index', indexStartedAt); } recordRestoreIndexAttributes(index); + if (indexHasManagedHeader(index)) { + return this.readManagedLiveRestoreProjection( + sessionId, + index, + options, + readOptions, + ); + } const selectionStartedAt = performance.now(); const replaySelection = selectRestoreReplayUuids( index, @@ -3344,7 +3898,7 @@ export class SessionTranscriptReader { throw new SessionTranscriptSnapshotUnavailableError(sessionId); } - const index = await getCachedIndex({ + const physicalIndex = await getCachedIndex({ filePath, fileIdentity, snapshotSize, @@ -3353,6 +3907,16 @@ export class SessionTranscriptReader { snapshot?.lastUpdated ?? new Date(stats.mtimeMs).toISOString(), }); + // The physical lines of a Managed log are the authority's wrapper records, + // so paging that index would hand a client those envelopes as if they were + // the conversation. Page the projection instead, the way the restore and + // turn readers already read it. + const index = indexHasManagedHeader(physicalIndex) + ? managedPageIndex( + physicalIndex, + await this.readManagedRecords(sessionId, physicalIndex, {}), + ) + : physicalIndex; const frozenLeafUuid = cursor?.leafUuid ?? snapshot?.leafUuid; if (frozenLeafUuid !== undefined && frozenLeafUuid !== index.leafUuid) { debugLogger.warn( diff --git a/packages/core/src/services/session-writer-lease.test.ts b/packages/core/src/services/session-writer-lease.test.ts index 0914de3fd5e..eec66ecf3a9 100644 --- a/packages/core/src/services/session-writer-lease.test.ts +++ b/packages/core/src/services/session-writer-lease.test.ts @@ -2891,6 +2891,186 @@ describe('SessionWriterLease', () => { await replacement.release(); }); + describe('managed lock schema 3', () => { + const managedSchema = { schemaVersion: 3 as const, formatVersion: 1 }; + const commitProof = { + last_commit_sequence: 7, + committed_prefix_hash: 'a'.repeat(64), + }; + + it('pins the format version into active and sealed records', async () => { + const fixture = await createFixture('managed-schema-session'); + const lease = await SessionWriterLease.acquire({ + ...fixture.options, + lockSchema: managedSchema, + }); + const lockPath = getSessionWriterLockPath( + fixture.runtimeBaseDir, + fixture.options.sessionId, + ); + expect(JSON.parse(await fs.readFile(lockPath, 'utf8'))).toMatchObject({ + schema_version: 3, + state: 'active', + format_version: 1, + }); + + await lease.appendJsonLine({ record: 'managed' }); + await lease.sealForHandoff(commitProof); + + const sealed = JSON.parse(await fs.readFile(lockPath, 'utf8')); + expect(sealed).toMatchObject({ + schema_version: 3, + state: 'sealed', + format_version: 1, + last_commit_sequence: 7, + committed_prefix_hash: 'a'.repeat(64), + }); + expect(sealed.transcript.sha256).toMatch(/^[0-9a-f]{64}$/); + }); + + it('refuses to seal a managed lease without the commit proof', async () => { + const fixture = await createFixture('managed-seal-proof-session'); + const lease = await SessionWriterLease.acquire({ + ...fixture.options, + lockSchema: managedSchema, + }); + const lockPath = getSessionWriterLockPath( + fixture.runtimeBaseDir, + fixture.options.sessionId, + ); + await lease.appendJsonLine({ record: 'managed' }); + const activeRaw = await fs.readFile(lockPath, 'utf8'); + let transcriptOpens = 0; + fsOpenTestHook.beforeOpen = (filePath) => { + if (filePath === fixture.options.transcriptPath) transcriptOpens++; + }; + + await expect(lease.sealForHandoff()).rejects.toBeInstanceOf( + SessionWriterUnavailableError, + ); + await expect(fs.readFile(lockPath, 'utf8')).resolves.toBe(activeRaw); + expect(transcriptOpens).toBe(0); + // A failed seal is terminal for the lease; the active lock is left for + // the owning process, which this test then abandons. + await lease.release().catch(() => undefined); + }); + + it('blocks a baseline writer from a sealed managed lock', async () => { + const fixture = await createFixture('managed-takeover-guard-session'); + const first = await SessionWriterLease.acquire({ + ...fixture.options, + lockSchema: managedSchema, + }); + await first.appendJsonLine({ record: 'sealed' }); + await first.sealForHandoff(commitProof); + + await expect( + SessionWriterLease.acquire(fixture.options), + ).rejects.toBeInstanceOf(SessionWriterConflictError); + await expect( + SessionWriterLease.acquire({ + ...fixture.options, + takeoverPolicy: 'certified', + }), + ).rejects.toBeInstanceOf(SessionWriterUnavailableError); + }); + + it('hands the sealed commit proof to a certified managed takeover', async () => { + const fixture = await createFixture('managed-takeover-session'); + const first = await SessionWriterLease.acquire({ + ...fixture.options, + lockSchema: managedSchema, + }); + await first.appendJsonLine({ record: 'sealed' }); + await first.sealForHandoff(commitProof); + + const replacement = await SessionWriterLease.acquire({ + ...fixture.options, + takeoverPolicy: 'certified', + lockSchema: managedSchema, + }); + expect(replacement.ownerId).not.toBe(first.ownerId); + expect(replacement.takeoverCommitProof).toEqual(commitProof); + const lockPath = getSessionWriterLockPath( + fixture.runtimeBaseDir, + fixture.options.sessionId, + ); + expect(JSON.parse(await fs.readFile(lockPath, 'utf8'))).toMatchObject({ + schema_version: 3, + state: 'active', + owner_id: replacement.ownerId, + }); + await replacement.release(); + }); + + it('rebuilds the transcript proof after discarding an uncommitted tail', async () => { + const fixture = await createFixture('managed-truncate-session'); + const lease = await SessionWriterLease.acquire({ + ...fixture.options, + lockSchema: managedSchema, + }); + await lease.appendJsonLine({ record: 'committed' }); + const prefix = await fs.readFile(fixture.options.transcriptPath); + await lease.appendJsonLine({ record: 'uncommitted' }); + await lease.truncateTo(prefix.byteLength); + await lease.appendJsonLine({ record: 'replacement' }); + await lease.sealForHandoff(commitProof); + + const replacement = await SessionWriterLease.acquire({ + ...fixture.options, + lockSchema: managedSchema, + takeoverPolicy: 'certified', + }); + expect(await fs.readFile(fixture.options.transcriptPath, 'utf8')).toBe( + `${prefix.toString('utf8')}{"record":"replacement"}\n`, + ); + expect(replacement.takeoverCommitProof).toEqual(commitProof); + await replacement.release(); + }); + + it('rejects truncation after the transcript changes outside the writer', async () => { + const fixture = await createFixture('managed-truncate-changed-session'); + const lease = await SessionWriterLease.acquire({ + ...fixture.options, + lockSchema: managedSchema, + }); + await lease.appendJsonLine({ record: 'committed' }); + await fs.appendFile(fixture.options.transcriptPath, '{"foreign":true}\n'); + const changed = await fs.readFile(fixture.options.transcriptPath); + + await expect(lease.truncateTo(0)).rejects.toBeInstanceOf( + SessionTranscriptChangedError, + ); + expect(await fs.readFile(fixture.options.transcriptPath)).toEqual( + changed, + ); + await lease.release(); + }); + + it('does not reclaim a stale managed lock for a baseline writer', async () => { + const fixture = await createFixture('managed-stale-session'); + const owner = startLeaseProcess(); + expect( + await requestChild(owner, { + type: 'acquire', + options: { ...fixture.options, lockSchema: managedSchema }, + }), + ).toMatchObject({ ok: true }); + owner.kill('SIGKILL'); + await waitForClose(owner); + + await expect( + SessionWriterLease.acquire(fixture.options), + ).rejects.toBeInstanceOf(SessionWriterUnavailableError); + + const managed = await SessionWriterLease.acquire({ + ...fixture.options, + lockSchema: managedSchema, + }); + await managed.release(); + }); + }); + it('waits for an accepted append before sealing the transcript', async () => { const fixture = await createFixture('sealed-append-race-session'); const lease = await SessionWriterLease.acquire(fixture.options); diff --git a/packages/core/src/services/session-writer-lease.ts b/packages/core/src/services/session-writer-lease.ts index ba793aaf834..45bafd4074c 100644 --- a/packages/core/src/services/session-writer-lease.ts +++ b/packages/core/src/services/session-writer-lease.ts @@ -21,6 +21,7 @@ import { const LEGACY_LOCK_SCHEMA_VERSION = 1; const LOCK_SCHEMA_VERSION = 2; +const MANAGED_LOCK_SCHEMA_VERSION = 3; const MALFORMED_RETRY_COUNT = 3; const MALFORMED_RETRY_DELAY_MS = 50; const CLAIMED_PRIMARY_WAIT_ATTEMPTS = 20; @@ -269,14 +270,52 @@ interface SealedSessionWriterLockRecord extends SessionWriterOwnerRecord { transcript: SealedTranscriptProof; } +/** + * The commit position a Managed authority pins into its sealed lock. A new + * authority compares it against the scanned log before accepting a takeover, + * so a seal proof never silently adopts a log that drifted after sealing. + */ +export interface SessionWriterCommitProof { + last_commit_sequence: number; + committed_prefix_hash: string; +} + +interface ActiveManagedSessionWriterLockRecord + extends SessionWriterOwnerRecord { + schema_version: typeof MANAGED_LOCK_SCHEMA_VERSION; + state: 'active'; + format_version: number; +} + +interface SealedManagedSessionWriterLockRecord + extends SessionWriterOwnerRecord { + schema_version: typeof MANAGED_LOCK_SCHEMA_VERSION; + state: 'sealed'; + sealed_at: string; + format_version: number; + last_commit_sequence: number; + committed_prefix_hash: string; + transcript: SealedTranscriptProof; +} + +type ManagedSessionWriterLockRecord = + | ActiveManagedSessionWriterLockRecord + | SealedManagedSessionWriterLockRecord; + type SessionWriterLockRecord = | LegacySessionWriterLockRecord | ActiveSessionWriterLockRecord - | SealedSessionWriterLockRecord; + | SealedSessionWriterLockRecord + | ManagedSessionWriterLockRecord; type ActiveLockRecord = | LegacySessionWriterLockRecord - | ActiveSessionWriterLockRecord; + | ActiveSessionWriterLockRecord + | ActiveManagedSessionWriterLockRecord; + +type SealedLockRecord = + | SealedSessionWriterLockRecord + | SealedManagedSessionWriterLockRecord; export interface AcquireSessionWriterLeaseOptions { runtimeBaseDir: string; @@ -286,14 +325,31 @@ export interface AcquireSessionWriterLeaseOptions { qwenVersion?: string | null; reclaimPolicy?: 'local' | 'never'; takeoverPolicy?: 'never' | 'certified'; + /** + * Write lock records in the Managed schema (3), pinning the log format the + * writer speaks. Old binaries report the unknown schema as malformed and + * refuse to write, which is the barrier that keeps a Managed log closed to + * them. Sealing such a lease requires the authority's commit proof, and a + * sealed schema-3 lock may only be taken over by a schema-3 acquirer. + */ + lockSchema?: { + readonly schemaVersion: 3; + readonly formatVersion: number; + }; onOwnershipAcquired?: (lease: SessionWriterLease) => void; } +export interface SealedManagedMaintenanceLease { + assertOwnedAndUnchanged(): Promise; + assertCleanupOwned(): void; + release(): Promise; +} + type ExistingLockState = | { kind: 'missing' } | { kind: 'live'; record: ActiveLockRecord; raw: string } | { kind: 'stale'; record: ActiveLockRecord; raw: string } - | { kind: 'sealed'; record: SealedSessionWriterLockRecord; raw: string } + | { kind: 'sealed'; record: SealedLockRecord; raw: string } | { kind: 'malformed' }; interface TranscriptFingerprint { @@ -460,14 +516,30 @@ function isLockRecord(value: unknown): value is SessionWriterLockRecord { if (record['schema_version'] === LEGACY_LOCK_SCHEMA_VERSION) { return record['state'] === undefined; } - if (record['schema_version'] !== LOCK_SCHEMA_VERSION) return false; - if (record['state'] === 'active') return true; - return ( - record['state'] === 'sealed' && - typeof record['sealed_at'] === 'string' && - Number.isFinite(Date.parse(record['sealed_at'])) && - isSealedTranscriptProof(record['transcript']) - ); + if (record['schema_version'] === LOCK_SCHEMA_VERSION) { + if (record['state'] === 'active') return true; + return ( + record['state'] === 'sealed' && + typeof record['sealed_at'] === 'string' && + Number.isFinite(Date.parse(record['sealed_at'])) && + isSealedTranscriptProof(record['transcript']) + ); + } + if (record['schema_version'] === MANAGED_LOCK_SCHEMA_VERSION) { + if (!isManagedFormatVersion(record['format_version'])) return false; + if (record['state'] === 'active') return true; + return ( + record['state'] === 'sealed' && + typeof record['sealed_at'] === 'string' && + Number.isFinite(Date.parse(record['sealed_at'])) && + isSealedTranscriptProof(record['transcript']) && + Number.isSafeInteger(record['last_commit_sequence']) && + (record['last_commit_sequence'] as number) >= 0 && + typeof record['committed_prefix_hash'] === 'string' && + /^[0-9a-f]{64}$/.test(record['committed_prefix_hash']) + ); + } + return false; } function parseLockRecord(raw: string): SessionWriterLockRecord | null { @@ -488,6 +560,27 @@ function isActiveLockRecord( ); } +function isManagedFormatVersion(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 1; +} + +function isManagedLockRecord( + record: SessionWriterLockRecord, +): record is ManagedSessionWriterLockRecord { + return record.schema_version === MANAGED_LOCK_SCHEMA_VERSION; +} + +function isValidCommitProof(value: unknown): value is SessionWriterCommitProof { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const proof = value as Record; + return ( + Number.isSafeInteger(proof['last_commit_sequence']) && + (proof['last_commit_sequence'] as number) >= 0 && + typeof proof['committed_prefix_hash'] === 'string' && + /^[0-9a-f]{64}$/.test(proof['committed_prefix_hash']) + ); +} + function parseLinuxProcessStartBootId( identity: string | undefined, ): string | null { @@ -1128,7 +1221,7 @@ async function closeTranscriptProof(proof: OpenTranscriptProof): Promise { } function assertSealedProofMatches( - sealed: SealedSessionWriterLockRecord, + sealed: SealedLockRecord, relativePath: string, proof: OpenTranscriptProof, ): void { @@ -1621,6 +1714,8 @@ export class SessionWriterLease { private lockFileIdentity: { dev: bigint; ino: bigint } | undefined; private readonly retiredPath: string; private readonly claimPath: string; + private readonly managedFormatVersion: number | undefined; + private takenOverCommitProof: SessionWriterCommitProof | undefined; private constructor( private readonly lockPath: string, @@ -1631,6 +1726,9 @@ export class SessionWriterLease { this.sessionId = options.sessionId; this.runtimeBaseDir = options.runtimeBaseDir; this.transcriptPath = options.transcriptPath; + this.managedFormatVersion = isManagedLockRecord(lockRecord) + ? lockRecord.format_version + : undefined; this.lockRecordRaw = JSON.stringify(lockRecord); this.retiredPath = `${lockPath}.released.${encodeURIComponent(this.ownerId)}`; this.claimPath = `${lockPath}.claim`; @@ -1643,6 +1741,16 @@ export class SessionWriterLease { return this.expectedTranscriptState.exists; } + /** + * The commit proof a sealed Managed lock carried when this lease took it + * over. The accepting authority verifies its scanned log against this before + * advancing the session; absent when the lease did not take over a Managed + * seal. + */ + get takeoverCommitProof(): SessionWriterCommitProof | undefined { + return this.takenOverCommitProof; + } + static async acquire( options: AcquireSessionWriterLeaseOptions, ): Promise { @@ -1675,6 +1783,106 @@ export class SessionWriterLease { } } + static async acquireSealedManagedMaintenance(options: { + runtimeBaseDir: string; + sessionId: string; + activeTranscriptPath: string; + transcriptPath: string; + }): Promise { + const lockPath = getSessionWriterLockPath( + options.runtimeBaseDir, + options.sessionId, + ); + const claimPath = `${lockPath}.claim`; + await assertPathMissing(claimPath); + const observed = await this.inspectExistingLock( + lockPath, + options.sessionId, + ); + if (observed.kind !== 'sealed' || !isManagedLockRecord(observed.record)) { + throw new SessionWriterUnavailableError(); + } + if ( + observed.record.transcript.relative_path !== + getTranscriptRelativePath( + options.runtimeBaseDir, + options.activeTranscriptPath, + ) + ) { + throw new SessionWriterUnavailableError(); + } + const claimRecord = { ...observed.record, owner_id: randomUUID() }; + const claimRaw = JSON.stringify(claimRecord); + if (!(await installLockRecord(claimPath, claimRecord))) { + throw new SessionWriterUnavailableError(); + } + try { + const primaryIdentity = nodeFs.lstatSync(lockPath, { bigint: true }); + const claimIdentity = nodeFs.lstatSync(claimPath, { bigint: true }); + let released = false; + let expectedTranscriptState: TranscriptState | undefined; + const assertCleanupOwned = () => { + if (released) throw new SessionWriterLostError(); + for (const [candidate, identity] of [ + [lockPath, primaryIdentity], + [claimPath, claimIdentity], + ] as const) { + try { + const current = nodeFs.lstatSync(candidate, { bigint: true }); + if ( + !current.isFile() || + current.isSymbolicLink() || + current.dev !== identity.dev || + current.ino !== identity.ino || + nodeFs.readFileSync(candidate, 'utf8') !== + (candidate === lockPath ? observed.raw : claimRaw) + ) { + throw new SessionWriterLostError(); + } + } catch (error) { + if (error instanceof SessionWriterError) throw error; + throw new SessionWriterUnavailableError({ + cause: error instanceof Error ? error : undefined, + }); + } + } + }; + const assertOwnedAndUnchanged = async () => { + assertCleanupOwned(); + const proof = await openTranscriptProof(options.transcriptPath); + try { + if ( + proof.state.exists !== observed.record.transcript.exists || + proof.state.byteLength !== observed.record.transcript.byte_length || + proof.sha256 !== observed.record.transcript.sha256 || + (expectedTranscriptState !== undefined && + !sameTranscriptState(proof.state, expectedTranscriptState)) + ) { + throw new SessionTranscriptChangedError(); + } + await validateOpenTranscriptProof(options.transcriptPath, proof); + assertCleanupOwned(); + expectedTranscriptState ??= proof.state; + } finally { + await closeTranscriptProof(proof); + } + }; + await assertOwnedAndUnchanged(); + return { + assertOwnedAndUnchanged, + assertCleanupOwned, + release: async () => { + assertCleanupOwned(); + await removeExactRecord(claimPath, claimRaw); + released = true; + }, + }; + } catch (error) { + await removeExactRecord(claimPath, claimRaw); + throw error; + } + } + private static async acquireInternal( options: AcquireSessionWriterLeaseOptions, ): Promise { @@ -1707,8 +1915,14 @@ export class SessionWriterLease { const processStartIdentity = await readProcessStartIdentity(process.pid); const pidNamespaceId = readPidNamespaceId(); - const lockRecord: ActiveSessionWriterLockRecord = { - schema_version: LOCK_SCHEMA_VERSION, + const lockSchema = normalizedOptions.lockSchema; + const lockRecord: ActiveLockRecord = { + ...(lockSchema === undefined + ? { schema_version: LOCK_SCHEMA_VERSION } + : { + schema_version: MANAGED_LOCK_SCHEMA_VERSION, + format_version: lockSchema.formatVersion, + }), state: 'active', session_id: normalizedOptions.sessionId, owner_id: randomUUID(), @@ -1762,6 +1976,19 @@ export class SessionWriterLease { normalizedOptions, ); } + // A Managed lock may only be continued by a writer that speaks its + // schema: reclaiming or taking it over with a baseline writer would + // silently downgrade the record and drop the sealed commit proof. + if ( + isManagedLockRecord(state.record) && + normalizedOptions.lockSchema === undefined + ) { + throw new SessionWriterUnavailableError({ + cause: new Error( + 'Session writer lock uses a schema this acquire cannot continue', + ), + }); + } if (normalizedOptions.reclaimPolicy === 'never') { throw new SessionWriterConflictError(); } @@ -1845,9 +2072,22 @@ export class SessionWriterLease { private static async takeOverSealed( lockPath: string, observed: Extract, - lockRecord: ActiveSessionWriterLockRecord, + lockRecord: ActiveLockRecord, options: AcquireSessionWriterLeaseOptions, ): Promise { + // A sealed Managed lock carries the authority's commit proof; continuing + // it with a baseline writer would drop that proof and downgrade the + // record, so only a schema-3 acquirer may take it over. + if ( + isManagedLockRecord(observed.record) && + options.lockSchema === undefined + ) { + throw new SessionWriterUnavailableError({ + cause: new Error( + 'Sealed session writer lock uses a schema this acquire cannot continue', + ), + }); + } const relativePath = getTranscriptRelativePath( options.runtimeBaseDir, options.transcriptPath, @@ -1900,6 +2140,12 @@ export class SessionWriterLease { options, proof.state, ); + if (isManagedLockRecord(observed.record)) { + lease.takenOverCommitProof = { + last_commit_sequence: observed.record.last_commit_sequence, + committed_prefix_hash: observed.record.committed_prefix_hash, + }; + } await releaseTransitionClaim(claimPath, claimRaw); claimAcquired = false; await removeExactRecord(retiredPath, observed.raw).catch((error) => { @@ -2249,6 +2495,99 @@ export class SessionWriterLease { return this.runExclusive(() => this.appendJsonLineOnce(value)); } + /** + * Discards a trailing byte range this owner has proven was never committed, + * then re-pins the transcript proof. + * + * Only the active writer may change the file, so a caller cannot repair a + * torn tail by editing it: `acquire` would reject the result as changed + * outside its writer. The retained prefix must already be verified by the + * caller — this method checks ownership and identity, not meaning. + */ + truncateTo(byteLength: number): Promise { + return this.runExclusive(() => this.truncateToOnce(byteLength)); + } + + private async truncateToOnce(byteLength: number): Promise { + if (!Number.isSafeInteger(byteLength) || byteLength < 0) { + throw new SessionWriterUnavailableError(); + } + await this.assertOwnedAndUnchangedOnce(); + const expected = this.expectedTranscriptState; + if (!expected?.exists || !this.expectedTranscriptHasher) { + throw new SessionWriterUnavailableError(); + } + if (byteLength > expected.byteLength) { + throw new SessionWriterUnavailableError(); + } + if (byteLength === expected.byteLength) return; + + let handle: fs.FileHandle | undefined; + try { + handle = await openTranscriptForAppend(this.transcriptPath, expected); + const beforeState = await getOpenTranscriptState( + this.transcriptPath, + handle, + true, + ); + if (!sameTranscriptState(beforeState, expected)) { + throw new SessionTranscriptChangedError(); + } + await this.readOwnedLock(); + assertVerifiableTranscriptIdentity(beforeState.fingerprint); + await handle.truncate(byteLength); + await handle.sync(); + const afterState = transcriptStateFromStat(await handle.stat()); + if ( + afterState.byteLength !== byteLength || + !sameFileIdentity(afterState.fingerprint, beforeState.fingerprint) || + !sameFileSecurityMetadata( + afterState.fingerprint, + beforeState.fingerprint, + ) + ) { + throw new SessionTranscriptChangedError(); + } + await handle.close(); + handle = undefined; + + // The rolling hash covers the discarded bytes and cannot be rewound, so + // the retained prefix is re-read to rebuild it. + const snapshot = await captureTranscriptSnapshot( + this.transcriptPath, + afterState, + () => this.released, + ); + if ( + !snapshot.state.exists || + snapshot.state.byteLength !== byteLength || + !sameFileIdentity(snapshot.state.fingerprint, afterState.fingerprint) || + !sameFileSecurityMetadata( + snapshot.state.fingerprint, + afterState.fingerprint, + ) + ) { + throw new SessionTranscriptChangedError(); + } + await this.readOwnedLock(); + this.expectedTranscriptState = snapshot.state; + this.expectedTranscriptHasher = snapshot.hasher; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'EEXIST' || code === 'ENOENT') { + throw new SessionTranscriptChangedError(); + } + if (error instanceof SessionWriterError) throw error; + throw new SessionWriterUnavailableError({ + cause: error instanceof Error ? error : undefined, + }); + } finally { + if (handle) { + await handle.close().catch(() => undefined); + } + } + } + private async appendJsonLineOnce(value: unknown): Promise { let serialized: string | undefined; try { @@ -2473,10 +2812,17 @@ export class SessionWriterLease { return this.terminalPromise; } - sealForHandoff(): Promise { + /** + * Seals the writer for a later certified takeover. A lease writing Managed + * (schema 3) records must seal with the authority's commit proof; a + * baseline lease ignores it. + */ + sealForHandoff(commitProof?: SessionWriterCommitProof): Promise { if (this.terminalOperation === 'release') return this.release(); this.terminalOperation = 'seal'; - this.terminalPromise ??= this.runExclusive(() => this.sealForHandoffOnce()); + this.terminalPromise ??= this.runExclusive(() => + this.sealForHandoffOnce(commitProof), + ); return this.terminalPromise; } @@ -2559,29 +2905,56 @@ export class SessionWriterLease { await directory?.handle.close().catch(() => undefined); } - private async sealForHandoffOnce(): Promise { + private async sealForHandoffOnce( + commitProof?: SessionWriterCommitProof, + ): Promise { if (this.released) return; const ownedRecord = await this.readOwnedLockForRelease(); if (!this.expectedTranscriptState) { throw new SessionWriterUnavailableError(); } + if ( + this.managedFormatVersion !== undefined && + !isValidCommitProof(commitProof) + ) { + throw new SessionWriterUnavailableError({ + cause: new Error( + 'Managed session writer seal requires the commit proof', + ), + }); + } const relativePath = getTranscriptRelativePath( this.runtimeBaseDir, this.transcriptPath, ); const proof = await openTranscriptProof(this.transcriptPath); - const sealedRecord: SealedSessionWriterLockRecord = { - ...ownedRecord, - schema_version: LOCK_SCHEMA_VERSION, - state: 'sealed', - sealed_at: new Date().toISOString(), - transcript: { - relative_path: relativePath, - exists: proof.state.exists, - byte_length: proof.state.byteLength, - sha256: proof.sha256, - }, + const transcriptProof: SealedTranscriptProof = { + relative_path: relativePath, + exists: proof.state.exists, + byte_length: proof.state.byteLength, + sha256: proof.sha256, }; + let sealedRecord: SealedLockRecord; + if (this.managedFormatVersion === undefined) { + sealedRecord = { + ...ownedRecord, + schema_version: LOCK_SCHEMA_VERSION, + state: 'sealed', + sealed_at: new Date().toISOString(), + transcript: transcriptProof, + }; + } else { + sealedRecord = { + ...ownedRecord, + schema_version: MANAGED_LOCK_SCHEMA_VERSION, + state: 'sealed', + sealed_at: new Date().toISOString(), + format_version: this.managedFormatVersion, + last_commit_sequence: commitProof!.last_commit_sequence, + committed_prefix_hash: commitProof!.committed_prefix_hash, + transcript: transcriptProof, + }; + } const sealedRaw = JSON.stringify(sealedRecord); const sealedCandidatePath = `${this.lockPath}.sealed-candidate.${encodeURIComponent( this.ownerId, diff --git a/packages/core/src/services/sessionService.test.ts b/packages/core/src/services/sessionService.test.ts index 25764b0ef4d..61c6356d03d 100644 --- a/packages/core/src/services/sessionService.test.ts +++ b/packages/core/src/services/sessionService.test.ts @@ -2036,7 +2036,7 @@ describe('SessionService', () => { ).resolves.toBe(true); expect(assertCanMutate).toHaveBeenCalledOnce(); - expect(assertCleanupOwned).toHaveBeenCalledTimes(6); + expect(assertCleanupOwned).toHaveBeenCalledTimes(7); expect(removeOrganizationSpy).toHaveBeenCalledWith(sessionIdA, { assertCanCommit: assertCleanupOwned, }); diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index 90f28f98f17..c3034760a2f 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -46,6 +46,10 @@ import { hasVerifiableInode } from '../utils/file-identity.js'; import { readRuntimeStatus } from '../utils/runtimeStatus.js'; import { LITE_READ_BUF_SIZE, + isManagedSessionTranscriptSync, + managedSessionResourceRoot, + readManagedSessionTitleInfoSync, + readManagedSessionSourceSync, readLastJsonStringFieldSync, readLastMatchingLineFieldSync, readSessionTitleInfoFromFileSync, @@ -73,12 +77,14 @@ import { type SessionLiveRestoreProjection, type SessionRestoreProjection, } from './session-transcript-reader.js'; +import { SessionExecutionEngineError } from './session-execution-engine.js'; import { SessionWriterError, SessionWriterLease, SessionTranscriptChangedError, SessionTranscriptIdentityUnavailableError, SessionWriterUnavailableError, + type SealedManagedMaintenanceLease, type SessionWriterProcessKind, } from './session-writer-lease.js'; import { @@ -104,6 +110,16 @@ export { const debugLogger = createDebugLogger('SESSION'); +function isManagedFirstRecord(record: ChatRecord): boolean { + // New Managed logs write the execution-engine marker before the header. + return ( + record.subtype === 'managed_session_header_v1' || + (record.subtype === 'session_execution_engine' && + (record.systemPayload as { engine?: unknown } | undefined)?.engine === + 'managed') + ); +} + export class BranchPointInvalidError extends Error { constructor(readonly recordId: string) { super(`Invalid or inactive branch point: ${recordId}`); @@ -896,6 +912,21 @@ export class SessionService { }); } + async acquireSealedManagedMaintenanceLease( + sessionId: string, + ): Promise { + const location = await this.getSessionLocation(sessionId); + if (location !== 'active' && location !== 'archived') return undefined; + const transcriptPath = this.getSessionFilePath(sessionId, location); + if (!isManagedSessionTranscriptSync(transcriptPath)) return undefined; + return SessionWriterLease.acquireSealedManagedMaintenance({ + runtimeBaseDir: this.storage.getRuntimeBaseDir(), + sessionId, + activeTranscriptPath: this.getSessionFilePath(sessionId, 'active'), + transcriptPath, + }); + } + private warn(message: string): void { debugLogger.warn(message); this.onWarning?.(message); @@ -1021,6 +1052,17 @@ export class SessionService { return this.getSessionFilePath(sessionId, 'active'); } + assertLegacySessionExecution(sessionId: string): void { + if ( + isManagedSessionTranscriptSync(this.getSessionTranscriptPath(sessionId)) + ) { + throw new SessionExecutionEngineError( + sessionId, + 'belongs to managed, cannot execute with legacy', + ); + } + } + getWorktreeSessionPathForArchiveState( sessionId: string, state: SessionArchiveState, @@ -1991,11 +2033,20 @@ export class SessionService { private readSessionTitleInfoFromFile( filePath: string, tailBuffer?: Buffer, + knownManaged?: boolean, ): { title?: string; source?: TitleSource; } { - return readSessionTitleInfoFromFileSync(filePath, tailBuffer); + const managed = + knownManaged === false + ? undefined + : readManagedSessionTitleInfoSync( + filePath, + this.storage.getRuntimeBaseDir(), + tailBuffer, + ); + return managed ?? readSessionTitleInfoFromFileSync(filePath, tailBuffer); } /** @@ -2051,6 +2102,7 @@ export class SessionService { filePath: string, records: ChatRecord[], tailBuffer?: Buffer, + knownManaged?: boolean, ): { parentSessionId?: string; sourceType?: string; @@ -2060,7 +2112,19 @@ export class SessionService { if (metadata.sourceType !== undefined) return metadata; const tailSource = this.readSessionSourceFromTail(filePath, tailBuffer); - if (tailSource.sourceType === undefined) return metadata; + if (tailSource.sourceType === undefined) { + const managedSource = + knownManaged === false + ? undefined + : readManagedSessionSourceSync( + filePath, + this.storage.getRuntimeBaseDir(), + tailBuffer, + ); + return managedSource?.sourceType === undefined + ? metadata + : { ...metadata, ...managedSource }; + } return { ...metadata, ...tailSource, @@ -2642,7 +2706,12 @@ export class SessionService { const prompt = this.extractFirstPromptFromRecords(records); signal?.throwIfAborted(); - const titleInfo = this.readSessionTitleInfoFromFile(filePath, tailBuffer); + const knownManaged = isManagedFirstRecord(firstRecord); + const titleInfo = this.readSessionTitleInfoFromFile( + filePath, + tailBuffer, + knownManaged, + ); signal?.throwIfAborted(); const goalObjective = this.resolveGoalObjective( prompt, @@ -2656,6 +2725,7 @@ export class SessionService { filePath, records, tailBuffer, + knownManaged, ); items.push({ sessionId: firstRecord.sessionId, @@ -2722,8 +2792,18 @@ export class SessionService { ) { return undefined; } - const titleInfo = this.readSessionTitleInfoFromFile(filePath); - const source = this.extractCreationMetadataFromFile(filePath, records); + const knownManaged = isManagedFirstRecord(firstRecord); + const titleInfo = this.readSessionTitleInfoFromFile( + filePath, + undefined, + knownManaged, + ); + const source = this.extractCreationMetadataFromFile( + filePath, + records, + undefined, + knownManaged, + ); const prompt = this.extractFirstPromptFromRecords(records); return { sessionId: firstRecord.sessionId, @@ -3425,6 +3505,11 @@ export class SessionService { this.removePromptLedgers(sessionId); assertCleanupOwned?.(); this.removeFileHistoryBackups(sessionId); + assertCleanupOwned?.(); + fs.rmSync( + managedSessionResourceRoot(this.storage.getRuntimeBaseDir(), sessionId), + { recursive: true, force: true }, + ); } private async removeSessionFiles(sessionId: string): Promise { @@ -3817,6 +3902,13 @@ export class SessionService { return false; } + if (isManagedSessionTranscriptSync(filePath)) { + throw new SessionExecutionEngineError( + sessionId, + 'belongs to managed, rename must go through its session authority', + ); + } + // Read the last record's UUID so the custom_title record is properly // chained into the parent history. reconstructHistory() walks from the // tail record upward via parentUuid; a null parentUuid would sever the @@ -3889,6 +3981,13 @@ export class SessionService { const sourcePath = path.join(chatsDir, `${sourceSessionId}.jsonl`); const targetPath = path.join(chatsDir, `${newSessionId}.jsonl`); + if (isManagedSessionTranscriptSync(sourcePath)) { + throw new SessionExecutionEngineError( + sourceSessionId, + 'belongs to managed, cannot fork with the legacy session service', + ); + } + // Read + parse the full source transcript. const records = await jsonl.read(sourcePath); if (records.length === 0) { diff --git a/packages/core/src/tools/managed-tool-file-history-protocol.test.ts b/packages/core/src/tools/managed-tool-file-history-protocol.test.ts new file mode 100644 index 00000000000..f3799235765 --- /dev/null +++ b/packages/core/src/tools/managed-tool-file-history-protocol.test.ts @@ -0,0 +1,310 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + captureManagedToolExecutionContext, + parseManagedToolFileHistoryBinding, + type ManagedToolExecutionContext, + parseManagedToolFileHistoryPromptId, + parseManagedToolFileHistoryState, +} from './managed-tool-file-history-protocol.js'; +import { managedToolDigest } from './managed-tool-protocol.js'; +import type { Config } from '../config/config.js'; + +const ownerSessionId = '550e8400-e29b-41d4-a716-446655440108'; +const ownerRuntimeSessionId = '550e8400-e29b-41d4-a716-446655440109'; +const timestamp = '2026-09-09T00:00:00.000Z'; +const backup = { + backupFileName: '0123456789abcdef@v1', + version: 1, + backupTime: timestamp, +}; +const snapshot = { + promptId: 'parent-turn', + timestamp, + trackedFileBackups: { 'proof.txt': backup }, +}; +const binding = { + ownerSessionId, + ownerRuntimeSessionId, + executionCwd: path.resolve('managed-child'), + snapshots: [snapshot], +}; + +describe('managed file history protocol', () => { + const context: ManagedToolExecutionContext = { + workspaceDirectories: [ + binding.executionCwd, + path.resolve('additional-workspace'), + ], + memoryBaseDir: path.resolve('gateway-memory'), + lsToolEnabled: true, + fileFilteringOptions: { + respectGitIgnore: false, + respectQwenIgnore: true, + customIgnoreFiles: [], + }, + }; + + it('copies the exact execution scope without adding directories or default ignore files', () => { + const input = { ...binding, executionContext: structuredClone(context) }; + const parsed = parseManagedToolFileHistoryBinding(input); + expect(parsed).toEqual(input); + input.executionContext.workspaceDirectories.reverse(); + input.executionContext.fileFilteringOptions.customIgnoreFiles.push( + '.laterignore', + ); + expect(parsed.executionContext).toEqual(context); + expect( + parseManagedToolFileHistoryBinding({ + ...binding, + executionContext: { ...context, workspaceDirectories: [] }, + }).executionContext?.workspaceDirectories, + ).toEqual([]); + }); + + it.each([null, 17.5])( + 'round trips explicit output limits (%s) and backend preferences', + (chars) => { + const config = { + getWorkspaceContext: () => ({ + getDirectories: () => context.workspaceDirectories, + }), + isLsToolEnabled: () => context.lsToolEnabled, + getFileFilteringOptions: () => context.fileFilteringOptions, + getUseRipgrep: () => false, + getUseBuiltinRipgrep: () => true, + getTruncateToolOutputThreshold: () => chars ?? Infinity, + getTruncateToolOutputLines: () => Infinity, + isTruncateToolOutputThresholdExplicit: () => true, + } as unknown as Config; + const captured = captureManagedToolExecutionContext(config); + expect(captured.grepOptions).toEqual({ + useRipgrep: false, + useBuiltinRipgrep: true, + }); + expect(captured.outputLimits).toEqual({ + chars, + lines: null, + charsExplicit: true, + }); + const parsed = parseManagedToolFileHistoryBinding( + JSON.parse(JSON.stringify({ ...binding, executionContext: captured })), + ); + expect(parsed.executionContext).toEqual(captured); + captured.grepOptions!.useRipgrep = true; + captured.outputLimits!.lines = 3; + expect(parsed.executionContext?.grepOptions?.useRipgrep).toBe(false); + expect(parsed.executionContext?.outputLimits?.lines).toBeNull(); + }, + ); + + it.each([ + { grepOptions: {} }, + { grepOptions: { useRipgrep: 'true', useBuiltinRipgrep: true } }, + { grepOptions: { useRipgrep: true, useBuiltinRipgrep: true, extra: true } }, + { outputLimits: { chars: 0, lines: 1, charsExplicit: false } }, + { outputLimits: { chars: 1, lines: -1, charsExplicit: false } }, + { outputLimits: { chars: Infinity, lines: 1, charsExplicit: false } }, + { outputLimits: { chars: NaN, lines: 1, charsExplicit: false } }, + { outputLimits: { chars: null, lines: null } }, + { outputLimits: { chars: null, lines: null, charsExplicit: 'false' } }, + ])('rejects invalid search preferences and limits', (extra) => { + expect(() => + parseManagedToolFileHistoryBinding({ + ...binding, + executionContext: { ...context, ...extra }, + }), + ).toThrow(); + }); + + it.each([ + { ...context, extra: true }, + { ...context, workspaceDirectories: ['relative'] }, + { + ...context, + workspaceDirectories: [binding.executionCwd, binding.executionCwd], + }, + { ...context, memoryBaseDir: 'relative' }, + { ...context, lsToolEnabled: 'true' }, + { + ...context, + fileFilteringOptions: { ...context.fileFilteringOptions, extra: true }, + }, + { + ...context, + fileFilteringOptions: { + ...context.fileFilteringOptions, + respectGitIgnore: 'true', + }, + }, + { + ...context, + fileFilteringOptions: { + ...context.fileFilteringOptions, + customIgnoreFiles: ['../parent'], + }, + }, + { + ...context, + fileFilteringOptions: { + ...context.fileFilteringOptions, + customIgnoreFiles: ['.qwenignore'], + }, + }, + { + ...context, + fileFilteringOptions: { + ...context.fileFilteringOptions, + customIgnoreFiles: ['.custom', '.custom'], + }, + }, + ])('rejects malformed execution context', (executionContext) => { + expect(() => + parseManagedToolFileHistoryBinding({ ...binding, executionContext }), + ).toThrow(); + }); + + it('accepts accumulated history above 1 MiB without widening tool input limits', () => { + const trackedFileBackups = Object.fromEntries( + Array.from({ length: 100 }, (_, i) => [ + `${'directory/'.repeat(5)}file-${i}.txt`, + backup, + ]), + ); + const snapshots = Array.from({ length: 100 }, (_, i) => ({ + promptId: `turn-${i}`, + timestamp, + trackedFileBackups, + })); + const state = { ownerSessionId, revision: 100, snapshots }; + expect(Buffer.byteLength(JSON.stringify(state))).toBeGreaterThan( + 1024 * 1024, + ); + expect( + parseManagedToolFileHistoryBinding({ ...binding, snapshots }).snapshots, + ).toEqual(snapshots); + expect(parseManagedToolFileHistoryState(state)).toEqual(state); + expect(() => managedToolDigest(state, 1024 * 1024)).toThrow('size limit'); + }); + + it('copies valid bindings and preserves empty snapshots and explicit failed backups', () => { + const input = { + ...binding, + snapshots: [ + snapshot, + { promptId: 'next', timestamp, trackedFileBackups: {} }, + { + promptId: 'failed', + timestamp, + trackedFileBackups: { + [path.resolve('outside.txt')]: { + ...backup, + backupFileName: null, + failed: true, + }, + }, + }, + ], + }; + const parsed = parseManagedToolFileHistoryBinding(input); + expect(parsed).toEqual(input); + expect(parsed.snapshots).not.toBe(input.snapshots); + expect(parsed.snapshots[0].trackedFileBackups['proof.txt']).not.toBe( + backup, + ); + expect(() => managedToolDigest(parsed)).not.toThrow(); + expect( + parseManagedToolFileHistoryState({ + ownerSessionId, + revision: 0, + snapshots: [], + }), + ).toEqual({ ownerSessionId, revision: 0, snapshots: [] }); + }); + + it.each([ + { ...binding, extra: true }, + { ...binding, ownerSessionId: ownerSessionId.toUpperCase() }, + { ...binding, ownerRuntimeSessionId: '../another-owner' }, + { ...binding, executionCwd: 'relative' }, + { ...binding, executionCwd: `${binding.executionCwd}${path.sep}..` }, + { ...binding, snapshots: undefined }, + { ...binding, snapshots: [snapshot, snapshot] }, + { ...binding, snapshots: [{ ...snapshot, extra: true }] }, + { ...binding, snapshots: [{ ...snapshot, timestamp: 'invalid' }] }, + { ...binding, snapshots: [{ ...snapshot, trackedFileBackups: [] }] }, + { + ...binding, + snapshots: [ + { ...snapshot, trackedFileBackups: { '../outside': backup } }, + ], + }, + ])('rejects malformed bindings before filesystem use', (input) => { + expect(() => parseManagedToolFileHistoryBinding(input)).toThrow(); + }); + + it.each([ + { ...backup, extra: true }, + { ...backup, backupFileName: '../other-session/backup' }, + { ...backup, backupFileName: '..\\other-session\\backup' }, + { ...backup, backupFileName: '.' }, + { ...backup, version: -1 }, + { ...backup, version: Infinity }, + { ...backup, version: Number.MAX_SAFE_INTEGER + 1 }, + { ...backup, backupTime: '2026-09-09' }, + { ...backup, failed: undefined }, + { ...backup, failed: 1 }, + ])('rejects malformed backup records', (invalidBackup) => { + expect(() => + parseManagedToolFileHistoryBinding({ + ...binding, + snapshots: [ + { ...snapshot, trackedFileBackups: { 'proof.txt': invalidBackup } }, + ], + }), + ).toThrow(); + }); + + it.each([-1, 0.1, Infinity, Number.MAX_SAFE_INTEGER + 1])( + 'rejects revision %s', + (revision) => { + expect(() => + parseManagedToolFileHistoryState({ + ownerSessionId, + revision, + snapshots: [], + }), + ).toThrow(); + }, + ); + + it('rejects non-JSON values, excessive snapshots and oversized state', () => { + const cycle: Record = {}; + cycle['self'] = cycle; + for (const input of [ + cycle, + { + ...binding, + snapshots: Array.from({ length: 101 }, (_, i) => ({ + ...snapshot, + promptId: String(i), + })), + }, + { ...binding, executionCwd: 'x'.repeat(1024 * 1024) }, + ]) + expect(() => parseManagedToolFileHistoryBinding(input)).toThrow(); + }); + + it.each(['', 'x'.repeat(129), 'nul\0prompt', undefined])( + 'rejects invalid prompt ID', + (promptId) => { + expect(() => parseManagedToolFileHistoryPromptId(promptId)).toThrow(); + }, + ); +}); diff --git a/packages/core/src/tools/managed-tool-file-history-protocol.ts b/packages/core/src/tools/managed-tool-file-history-protocol.ts new file mode 100644 index 00000000000..c8be4ee06d6 --- /dev/null +++ b/packages/core/src/tools/managed-tool-file-history-protocol.ts @@ -0,0 +1,329 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as path from 'node:path'; +import { getMemoryBaseDir } from '../memory/paths.js'; +import type { Config } from '../config/config.js'; +import { normalizeQwenCustomIgnoreFileNames } from '../utils/qwenIgnoreParser.js'; +import type { SerializedFileHistorySnapshot } from '../services/fileHistoryService.js'; +import type { ManagedToolFileHistoryState } from './managed-tool-file-history.js'; +import { + managedToolDigest, + ManagedToolProtocolError, +} from './managed-tool-protocol.js'; + +export const MANAGED_TOOL_FILE_HISTORY_MAX_BYTES = 8 * 1024 * 1024; + +export interface ManagedToolExecutionContext { + workspaceDirectories: string[]; + memoryBaseDir: string; + lsToolEnabled: boolean; + grepOptions?: { + useRipgrep: boolean; + useBuiltinRipgrep: boolean; + }; + outputLimits?: { + chars: number | null; + lines: number | null; + charsExplicit: boolean; + }; + fileFilteringOptions: { + respectGitIgnore: boolean; + respectQwenIgnore: boolean; + customIgnoreFiles: string[]; + }; +} + +export function captureManagedToolExecutionContext( + config: Config, +): ManagedToolExecutionContext { + const filtering = config.getFileFilteringOptions(); + return { + workspaceDirectories: [...config.getWorkspaceContext().getDirectories()], + memoryBaseDir: getMemoryBaseDir(), + lsToolEnabled: config.isLsToolEnabled(), + grepOptions: { + useRipgrep: config.getUseRipgrep(), + useBuiltinRipgrep: config.getUseBuiltinRipgrep(), + }, + outputLimits: { + chars: encodeOutputLimit(config.getTruncateToolOutputThreshold()), + lines: encodeOutputLimit(config.getTruncateToolOutputLines()), + charsExplicit: config.isTruncateToolOutputThresholdExplicit(), + }, + fileFilteringOptions: { + respectGitIgnore: filtering.respectGitIgnore, + respectQwenIgnore: filtering.respectQwenIgnore, + customIgnoreFiles: normalizeQwenCustomIgnoreFileNames( + filtering.customIgnoreFiles, + ), + }, + }; +} + +export interface ManagedToolFileHistoryBinding { + ownerSessionId: string; + ownerRuntimeSessionId: string; + executionCwd: string; + executionContext?: ManagedToolExecutionContext; + snapshots: SerializedFileHistorySnapshot[]; +} + +export interface ManagedToolFileHistoryClient { + bind( + binding: ManagedToolFileHistoryBinding, + ): Promise; + checkpoint(promptId: string): Promise; + snapshot(): Promise; +} + +function record( + value: unknown, + keys?: readonly string[], +): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new ManagedToolProtocolError(); + const result = value as Record; + if (keys && Object.keys(result).some((key) => !keys.includes(key))) + throw new ManagedToolProtocolError(); + return result; +} + +function text(value: unknown, limit: number): string { + if ( + typeof value !== 'string' || + !value.length || + value.length > limit || + value.includes('\0') + ) + throw new ManagedToolProtocolError(); + return value; +} + +function uuid(value: unknown): string { + const result = text(value, 256); + if ( + !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test( + result, + ) + ) + throw new ManagedToolProtocolError(); + return result; +} + +function integer(value: unknown): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) + throw new ManagedToolProtocolError(); + return value; +} + +function timestamp(value: unknown): string { + const result = text(value, 32); + const date = new Date(result); + if (!Number.isFinite(date.getTime()) || date.toISOString() !== result) + throw new ManagedToolProtocolError(); + return result; +} + +export function parseManagedToolFileHistoryPromptId(value: unknown): string { + return text(value, 128); +} + +function snapshots(value: unknown): SerializedFileHistorySnapshot[] { + if (!Array.isArray(value) || value.length > 100) + throw new ManagedToolProtocolError(); + const prompts = new Set(); + return value.map((item) => { + const snapshot = record(item, [ + 'promptId', + 'timestamp', + 'trackedFileBackups', + ]); + const promptId = parseManagedToolFileHistoryPromptId(snapshot['promptId']); + if (prompts.has(promptId)) throw new ManagedToolProtocolError(); + prompts.add(promptId); + return { + promptId, + timestamp: timestamp(snapshot['timestamp']), + trackedFileBackups: Object.fromEntries( + Object.entries(record(snapshot['trackedFileBackups'])).map( + ([filePath, value]) => { + text(filePath, 4096); + if (filePath.split(path.sep).includes('..')) + throw new ManagedToolProtocolError(); + const backup = record(value, [ + 'backupFileName', + 'version', + 'backupTime', + 'failed', + ]); + const backupFileName = + backup['backupFileName'] === null + ? null + : text(backup['backupFileName'], 256); + if ( + backupFileName !== null && + (backupFileName === '.' || + backupFileName === '..' || + /[/\\]/.test(backupFileName)) + ) + throw new ManagedToolProtocolError(); + if ('failed' in backup && typeof backup['failed'] !== 'boolean') + throw new ManagedToolProtocolError(); + return [ + filePath, + { + backupFileName, + version: integer(backup['version']), + backupTime: timestamp(backup['backupTime']), + ...('failed' in backup + ? { failed: backup['failed'] as boolean } + : {}), + }, + ]; + }, + ), + ), + }; + }); +} + +function absolutePath(value: unknown): string { + const result = text(value, 4096); + if (!path.isAbsolute(result) || path.normalize(result) !== result) + throw new ManagedToolProtocolError(); + return result; +} + +function encodeOutputLimit(value: number): number | null { + return value === Number.POSITIVE_INFINITY ? null : value; +} + +function outputLimit(value: unknown): number | null { + if (value === null) return null; + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) + throw new ManagedToolProtocolError(); + return value; +} + +function grepOptions( + value: unknown, +): ManagedToolExecutionContext['grepOptions'] { + const input = record(value, ['useRipgrep', 'useBuiltinRipgrep']); + if ( + typeof input['useRipgrep'] !== 'boolean' || + typeof input['useBuiltinRipgrep'] !== 'boolean' + ) + throw new ManagedToolProtocolError(); + return { + useRipgrep: input['useRipgrep'], + useBuiltinRipgrep: input['useBuiltinRipgrep'], + }; +} + +function outputLimits( + value: unknown, +): ManagedToolExecutionContext['outputLimits'] { + const input = record(value, ['chars', 'lines', 'charsExplicit']); + if (typeof input['charsExplicit'] !== 'boolean') + throw new ManagedToolProtocolError(); + return { + chars: outputLimit(input['chars']), + lines: outputLimit(input['lines']), + charsExplicit: input['charsExplicit'], + }; +} + +function executionContext(value: unknown): ManagedToolExecutionContext { + const input = record(value, [ + 'workspaceDirectories', + 'memoryBaseDir', + 'fileFilteringOptions', + 'lsToolEnabled', + 'grepOptions', + 'outputLimits', + ]); + const filtering = record(input['fileFilteringOptions'], [ + 'respectGitIgnore', + 'respectQwenIgnore', + 'customIgnoreFiles', + ]); + if ( + !Array.isArray(input['workspaceDirectories']) || + !Array.isArray(filtering['customIgnoreFiles']) || + typeof filtering['respectGitIgnore'] !== 'boolean' || + typeof filtering['respectQwenIgnore'] !== 'boolean' || + typeof input['lsToolEnabled'] !== 'boolean' + ) + throw new ManagedToolProtocolError(); + const directories = input['workspaceDirectories'].map(absolutePath); + if (new Set(directories).size !== directories.length) + throw new ManagedToolProtocolError(); + const customIgnoreFiles = filtering['customIgnoreFiles'].map((value) => + text(value, 4096), + ); + if ( + JSON.stringify(normalizeQwenCustomIgnoreFileNames(customIgnoreFiles)) !== + JSON.stringify(customIgnoreFiles) + ) + throw new ManagedToolProtocolError(); + return { + workspaceDirectories: directories, + memoryBaseDir: absolutePath(input['memoryBaseDir']), + lsToolEnabled: input['lsToolEnabled'], + ...('grepOptions' in input + ? { grepOptions: grepOptions(input['grepOptions']) } + : {}), + ...('outputLimits' in input + ? { outputLimits: outputLimits(input['outputLimits']) } + : {}), + fileFilteringOptions: { + respectGitIgnore: filtering['respectGitIgnore'], + respectQwenIgnore: filtering['respectQwenIgnore'], + customIgnoreFiles, + }, + }; +} + +export function parseManagedToolFileHistoryBinding( + value: unknown, +): ManagedToolFileHistoryBinding { + managedToolDigest(value, MANAGED_TOOL_FILE_HISTORY_MAX_BYTES); + const input = record(value, [ + 'ownerSessionId', + 'ownerRuntimeSessionId', + 'executionCwd', + 'executionContext', + 'snapshots', + ]); + const executionCwd = text(input['executionCwd'], 4096); + if ( + !path.isAbsolute(executionCwd) || + path.normalize(executionCwd) !== executionCwd + ) + throw new ManagedToolProtocolError(); + return { + ownerSessionId: uuid(input['ownerSessionId']), + ownerRuntimeSessionId: uuid(input['ownerRuntimeSessionId']), + executionCwd, + ...('executionContext' in input + ? { executionContext: executionContext(input['executionContext']) } + : {}), + snapshots: snapshots(input['snapshots']), + }; +} + +export function parseManagedToolFileHistoryState( + value: unknown, +): ManagedToolFileHistoryState { + managedToolDigest(value, MANAGED_TOOL_FILE_HISTORY_MAX_BYTES); + const input = record(value, ['ownerSessionId', 'revision', 'snapshots']); + return { + ownerSessionId: uuid(input['ownerSessionId']), + revision: integer(input['revision']), + snapshots: snapshots(input['snapshots']), + }; +} diff --git a/packages/core/src/tools/managed-tool-file-history.test.ts b/packages/core/src/tools/managed-tool-file-history.test.ts new file mode 100644 index 00000000000..ff26e253c7f --- /dev/null +++ b/packages/core/src/tools/managed-tool-file-history.test.ts @@ -0,0 +1,274 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + FileHistoryService, + type SerializedFileHistorySnapshot, +} from '../services/fileHistoryService.js'; +import { ManagedToolFileHistory } from './managed-tool-file-history.js'; +import { managedToolDigest } from './managed-tool-protocol.js'; + +const storageDir = vi.hoisted(() => vi.fn()); +vi.mock('../config/storage.js', () => ({ + Storage: { getGlobalQwenDir: storageDir }, +})); + +function deferred() { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +const ownerSessionId = '1e4a1b6a-9a0f-47e4-aef6-6ce937ac0988'; + +describe('ManagedToolFileHistory', () => { + let cwd: string; + let backupRoot: string; + let owner: ManagedToolFileHistory; + + beforeEach(async () => { + cwd = await mkdtemp(join(tmpdir(), 'managed-history-workspace-')); + backupRoot = await mkdtemp(join(tmpdir(), 'managed-history-backups-')); + storageDir.mockReturnValue(backupRoot); + owner = new ManagedToolFileHistory(ownerSessionId, cwd, []); + await owner.ready(); + }); + + afterEach(async () => { + await owner.drain(); + await rm(cwd, { recursive: true, force: true }); + await rm(backupRoot, { recursive: true, force: true }); + }); + + it('records empty parent checkpoints, deduplicates the last one and refuses older turns', async () => { + expect(owner.state()).toEqual({ + ownerSessionId, + revision: 0, + snapshots: [], + }); + await Promise.all([ + owner.checkpoint('parent-1'), + owner.checkpoint('parent-1'), + ]); + expect(owner.state()).toMatchObject({ + revision: 1, + snapshots: [{ promptId: 'parent-1', trackedFileBackups: {} }], + }); + await owner.checkpoint('parent-2'); + await expect(owner.checkpoint('parent-1')).rejects.toThrow('earlier turn'); + expect(owner.state().revision).toBe(2); + expect(() => managedToolDigest(owner.state())).not.toThrow(); + }); + + it('orders real writes and parent checkpoints around the same shared backup owner', async () => { + const filePath = join(cwd, 'shared.txt'); + await writeFile(filePath, 'zero'); + await owner.checkpoint('parent-1'); + const entered = deferred(); + const finishWrite = deferred(); + const first = owner.run(async () => { + await owner.service.trackEdit(filePath); + entered.resolve(); + await finishWrite.promise; + await writeFile(filePath, 'one'); + }); + await entered.promise; + const checkpoint = owner.checkpoint('parent-2'); + let secondStarted = false; + const second = owner.run(async () => { + secondStarted = true; + await owner.service.trackEdit(filePath); + await writeFile(filePath, 'two'); + }); + await Promise.resolve(); + expect(secondStarted).toBe(false); + expect(owner.state().snapshots).toHaveLength(1); + finishWrite.resolve(); + await Promise.all([first, checkpoint, second]); + + const state = owner.state(); + expect(state.snapshots.map(({ promptId }) => promptId)).toEqual([ + 'parent-1', + 'parent-2', + ]); + const backups = state.snapshots.map( + (snapshot) => snapshot.trackedFileBackups['shared.txt'], + ); + expect(backups.map(({ version }) => version)).toEqual([1, 2]); + for (const [index, backup] of backups.entries()) { + expect( + await readFile( + join( + backupRoot, + 'file-history', + ownerSessionId, + backup.backupFileName!, + ), + 'utf8', + ), + ).toBe(index === 0 ? 'zero' : 'one'); + } + expect(await readFile(filePath, 'utf8')).toBe('two'); + expect(() => managedToolDigest(state)).not.toThrow(); + expect(backups.every((backup) => !Object.hasOwn(backup, 'failed'))).toBe( + true, + ); + }); + + it('captures callback changes synchronously and isolates returned state', async () => { + const filePath = join(cwd, 'a.txt'); + await writeFile(filePath, 'before'); + await owner.checkpoint('parent-1'); + await owner.run(async () => { + await owner.service.trackEdit(filePath); + const state = owner.state(); + expect(state.revision).toBe(2); + state.snapshots[0].trackedFileBackups['a.txt'].version = 999; + state.snapshots.length = 0; + expect( + owner.state().snapshots[0].trackedFileBackups['a.txt'].version, + ).toBe(1); + }); + expect(owner.state().revision).toBe(2); + }); + + it('restores real durable backups and captures rewind without a recorder callback', async () => { + const filePath = join(cwd, 'a.txt'); + await writeFile(filePath, 'before'); + await owner.checkpoint('parent-1'); + await owner.run(async () => { + await owner.service.trackEdit(filePath); + await writeFile(filePath, 'after'); + }); + await owner.checkpoint('parent-2'); + const stored = owner.state().snapshots; + const restored = new ManagedToolFileHistory(ownerSessionId, cwd, stored); + await restored.ready(); + stored[0].trackedFileBackups['a.txt'].version = 999; + expect( + restored.state().snapshots[0].trackedFileBackups['a.txt'].version, + ).toBe(1); + expect( + await restored.run(() => restored.service.rewind('parent-1')), + ).toEqual({ + filesChanged: [filePath], + filesFailed: [], + }); + expect(await readFile(filePath, 'utf8')).toBe('before'); + expect(restored.state()).toMatchObject({ + revision: 1, + snapshots: [{ promptId: 'parent-1' }], + }); + await expect(restored.checkpoint('parent-2')).rejects.toThrow( + 'earlier turn', + ); + }); + + it('waits for restore validation and publishes missing-backup failures as JSON', async () => { + const snapshots: SerializedFileHistorySnapshot[] = [ + { + promptId: 'parent-1', + timestamp: new Date().toISOString(), + trackedFileBackups: { + 'missing.txt': { + backupFileName: 'missing@v1', + version: 1, + backupTime: new Date().toISOString(), + failed: undefined, + }, + }, + }, + ]; + const restored = new ManagedToolFileHistory(ownerSessionId, cwd, snapshots); + await restored.run(async () => { + expect( + restored.state().snapshots[0].trackedFileBackups['missing.txt'].failed, + ).toBe(true); + }); + expect(restored.state().revision).toBe(1); + expect(() => managedToolDigest(restored.state())).not.toThrow(); + expect( + snapshots[0].trackedFileBackups['missing.txt'].failed, + ).toBeUndefined(); + }); + + it('preserves parent-relative and outside-parent path attribution', async () => { + const filePath = join(backupRoot, 'outside.txt'); + await writeFile(filePath, 'outside'); + await owner.checkpoint('parent-1'); + await owner.run(() => owner.service.trackEdit(filePath)); + expect(Object.keys(owner.state().snapshots[0].trackedFileBackups)).toEqual([ + filePath, + ]); + }); + + it('drains queued work and keeps admitting operations after an execution error', async () => { + const entered = deferred(); + const finish = deferred(); + const failed = owner.run(async () => { + entered.resolve(); + await finish.promise; + throw new Error('execution failed'); + }); + const observed = failed.catch((error: unknown) => error); + await entered.promise; + const next = owner.checkpoint('parent-1'); + let drained = false; + const drain = owner.drain().then(() => { + drained = true; + }); + await Promise.resolve(); + expect(drained).toBe(false); + finish.resolve(); + expect(await observed).toMatchObject({ message: 'execution failed' }); + await Promise.all([next, drain]); + expect(drained).toBe(true); + expect(owner.state().snapshots).toHaveLength(1); + }); + + it('rejects non-JSON numeric backup versions before restore', () => { + expect( + () => + new ManagedToolFileHistory(ownerSessionId, cwd, [ + { + promptId: 'parent-1', + timestamp: new Date().toISOString(), + trackedFileBackups: { + 'a.txt': { + backupFileName: null, + version: Infinity, + backupTime: '', + }, + }, + }, + ]), + ).toThrow('backup version'); + }); + + it('does not run tools or report a successful drain after initialization fails', async () => { + const failure = new Error('restore validation failed'); + const validate = vi + .spyOn(FileHistoryService.prototype, 'validateRestoredSnapshots') + .mockRejectedValueOnce(failure); + try { + const failed = new ManagedToolFileHistory(ownerSessionId, cwd, []); + const operation = vi.fn(async () => {}); + await expect(failed.ready()).rejects.toBe(failure); + await expect(failed.run(operation)).rejects.toBe(failure); + await expect(failed.run(operation)).rejects.toBe(failure); + await expect(failed.drain()).rejects.toBe(failure); + expect(operation).not.toHaveBeenCalled(); + } finally { + validate.mockRestore(); + } + }); +}); diff --git a/packages/core/src/tools/managed-tool-file-history.ts b/packages/core/src/tools/managed-tool-file-history.ts new file mode 100644 index 00000000000..77b81bfd44e --- /dev/null +++ b/packages/core/src/tools/managed-tool-file-history.ts @@ -0,0 +1,131 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + FileHistoryService, + deserializeSnapshots, + serializeSnapshot, + type SerializedFileHistorySnapshot, +} from '../services/fileHistoryService.js'; + +export interface ManagedToolFileHistoryState { + ownerSessionId: string; + revision: number; + snapshots: SerializedFileHistorySnapshot[]; +} + +function copySnapshots( + snapshots: SerializedFileHistorySnapshot[], +): SerializedFileHistorySnapshot[] { + return snapshots.map((snapshot) => ({ + promptId: snapshot.promptId, + timestamp: snapshot.timestamp, + trackedFileBackups: Object.fromEntries( + Object.entries(snapshot.trackedFileBackups).map(([filePath, backup]) => { + if (!Number.isSafeInteger(backup.version) || backup.version < 0) { + throw new Error('Invalid managed file history backup version.'); + } + return [ + filePath, + { + backupFileName: backup.backupFileName, + version: backup.version, + backupTime: backup.backupTime, + ...(backup.failed ? { failed: true } : {}), + }, + ]; + }), + ), + })); +} + +export class ManagedToolFileHistory { + readonly service: FileHistoryService; + private readonly initialization: Promise; + private pending: Promise = Promise.resolve(); + private readonly startedPrompts: Set; + private revision = 0; + private snapshots: SerializedFileHistorySnapshot[]; + private snapshotJson: string; + + constructor( + private readonly ownerSessionId: string, + cwd: string, + serializedSnapshots: SerializedFileHistorySnapshot[], + ) { + this.service = new FileHistoryService(ownerSessionId, true, cwd, () => + this.capture(), + ); + this.service.restoreFromSnapshots( + deserializeSnapshots(copySnapshots(serializedSnapshots)), + ); + this.snapshots = this.readSnapshots(); + this.snapshotJson = JSON.stringify(this.snapshots); + this.startedPrompts = new Set( + this.snapshots.map(({ promptId }) => promptId), + ); + this.initialization = this.service.validateRestoredSnapshots(); + void this.initialization.catch(() => {}); + } + + ready(): Promise { + return this.initialization; + } + + state(): ManagedToolFileHistoryState { + return { + ownerSessionId: this.ownerSessionId, + revision: this.revision, + snapshots: structuredClone(this.snapshots), + }; + } + + checkpoint(promptId: string): Promise { + return this.run(async () => { + if (this.service.getSnapshots().at(-1)?.promptId === promptId) return; + if (this.startedPrompts.has(promptId)) { + throw new Error('Managed file history cannot reopen an earlier turn.'); + } + await this.service.makeSnapshot(promptId); + this.startedPrompts.add(promptId); + }); + } + + run(operation: () => Promise): Promise { + const result = this.pending.then(async () => { + await this.initialization; + try { + return await operation(); + } finally { + this.capture(); + } + }); + this.pending = result.then( + () => {}, + () => {}, + ); + return result; + } + + async drain(): Promise { + const pending = this.pending; + await this.initialization; + await pending; + } + + private readSnapshots(): SerializedFileHistorySnapshot[] { + return copySnapshots(this.service.getSnapshots().map(serializeSnapshot)); + } + + private capture(): void { + const snapshots = this.readSnapshots(); + const snapshotJson = JSON.stringify(snapshots); + if (snapshotJson === this.snapshotJson) return; + this.snapshots = snapshots; + this.snapshotJson = snapshotJson; + this.revision++; + } +} diff --git a/packages/core/src/tools/managed-tool-protocol.test.ts b/packages/core/src/tools/managed-tool-protocol.test.ts new file mode 100644 index 00000000000..43e9f029b49 --- /dev/null +++ b/packages/core/src/tools/managed-tool-protocol.test.ts @@ -0,0 +1,393 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash } from 'node:crypto'; +import { describe, expect, it, vi } from 'vitest'; +import type { ToolCallConfirmationDetails } from './tools.js'; +import { + managedToolDigest, + parseManagedToolContentModification, + parseManagedToolMediaContext, + ManagedToolProtocolError, + parseManagedToolCallIdentity, + parseManagedToolConfirmationPayload, + parseManagedToolInvocationReference, + serializeManagedToolConfirmation, +} from './managed-tool-protocol.js'; + +const identity = { + sessionId: '01a05708-a79d-4a02-8b79-02c4120eb054', + promptId: 'prompt-1', + callId: 'call-1', + capabilityDigest: 'a'.repeat(64), + policyRevision: 'policy-1', +}; +const reference = { + ...identity, + invocationId: 'invocation-1', + argsDigest: 'b'.repeat(64), +}; + +describe('managed media context', () => { + it('copies only explicit resolved input capabilities', () => { + const input = { + inputModalities: { image: true, pdf: false, audio: true, video: false }, + }; + const parsed = parseManagedToolMediaContext(input); + expect(parsed).toEqual(input); + input.inputModalities.image = false; + expect(parsed.inputModalities.image).toBe(true); + expect(parseManagedToolMediaContext({ inputModalities: {} })).toEqual({ + inputModalities: {}, + }); + }); + + it.each([ + undefined, + null, + [], + {}, + { inputModalities: null }, + { inputModalities: [] }, + { inputModalities: { image: 'true' } }, + { inputModalities: { pdf: 1 } }, + { inputModalities: { image: undefined } }, + { inputModalities: { text: true } }, + { inputModalities: {}, model: 'worker-model' }, + { inputModalities: {}, prompt: 'worker-prompt' }, + { inputModalities: {}, apiKey: 'credential' }, + ])('rejects malformed or expanded context %#', (input) => { + expect(() => parseManagedToolMediaContext(input)).toThrow( + ManagedToolProtocolError, + ); + }); +}); + +describe('managed tool identities', () => { + it('normalizes session UUIDs and preserves the complete invocation binding', () => { + expect( + parseManagedToolCallIdentity({ + ...identity, + sessionId: identity.sessionId.toUpperCase(), + }), + ).toEqual(identity); + expect(parseManagedToolInvocationReference(reference)).toEqual(reference); + expect( + parseManagedToolInvocationReference({ + ...reference, + promptId: 'p'.repeat(128), + callId: 'c'.repeat(512), + policyRevision: 'r'.repeat(256), + invocationId: 'i'.repeat(128), + }), + ).toMatchObject({ invocationId: 'i'.repeat(128) }); + }); + + it.each([ + null, + [], + {}, + { ...identity, sessionId: 'session-1' }, + { ...identity, sessionId: `${identity.sessionId}-agent-1` }, + { ...identity, promptId: '' }, + { ...identity, promptId: 'p'.repeat(129) }, + { ...identity, callId: 'c'.repeat(513) }, + { ...identity, callId: 'call\0suffix' }, + { ...identity, capabilityDigest: 'A'.repeat(64) }, + { ...identity, capabilityDigest: 'a'.repeat(63) }, + { ...identity, policyRevision: 1 }, + { ...identity, policyRevision: 'r'.repeat(257) }, + { ...identity, extra: true }, + reference, + ])('rejects invalid or extended standalone identity %#', (value) => { + expect(() => parseManagedToolCallIdentity(value)).toThrow( + ManagedToolProtocolError, + ); + }); + + it.each([ + identity, + { ...reference, invocationId: '' }, + { ...reference, invocationId: 'i'.repeat(129) }, + { ...reference, argsDigest: 'not-a-digest' }, + { ...reference, params: {} }, + ])('rejects incomplete or extended references %#', (value) => { + expect(() => parseManagedToolInvocationReference(value)).toThrow( + ManagedToolProtocolError, + ); + }); +}); + +describe('managed tool JSON digests', () => { + it('hashes canonical JSON with sorted nested and numeric object keys', () => { + const expected = createHash('sha256') + .update('{"10":"ten","2":"two","a":{"x":[true,null,1],"z":2}}') + .digest('hex'); + expect( + managedToolDigest({ + a: { z: 2, x: [true, null, 1] }, + 2: 'two', + 10: 'ten', + }), + ).toBe(expected); + expect( + managedToolDigest({ + 10: 'ten', + a: { x: [true, null, 1], z: 2 }, + 2: 'two', + }), + ).toBe(expected); + expect(managedToolDigest([1, 2])).not.toBe(managedToolDigest([2, 1])); + expect(managedToolDigest({ a: 1 })).not.toBe(managedToolDigest({ a: '1' })); + }); + + it('allows shared JSON subtrees and null-prototype records', () => { + const shared = { value: 1 }; + expect(managedToolDigest([shared, shared])).toBe( + managedToolDigest([{ value: 1 }, { value: 1 }]), + ); + expect(managedToolDigest(Object.assign(Object.create(null), shared))).toBe( + managedToolDigest(shared), + ); + expect(managedToolDigest(JSON.parse('{"__proto__":{"x":1}}'))).not.toBe( + managedToolDigest({}), + ); + }); + + it.each([ + undefined, + () => {}, + Symbol('value'), + 1n, + Number.NaN, + Number.POSITIVE_INFINITY, + Number.NEGATIVE_INFINITY, + { missing: undefined }, + [undefined], + new Array(1), + new Date(0), + new Map(), + { [Symbol('hidden')]: 1 }, + Object.defineProperty({}, 'hidden', { value: 1 }), + Object.assign([], { extra: 1 }), + ])('rejects values that JSON would omit or coerce %#', (value) => { + expect(() => managedToolDigest(value)).toThrow(ManagedToolProtocolError); + }); + + it('rejects accessors, toJSON methods and cycles without invoking them', () => { + const getter = vi.fn(() => 'secret'); + const toJSON = vi.fn(() => ({})); + expect(() => + managedToolDigest( + Object.defineProperty({}, 'value', { get: getter, enumerable: true }), + ), + ).toThrow(ManagedToolProtocolError); + expect(() => managedToolDigest({ toJSON })).toThrow( + ManagedToolProtocolError, + ); + expect(getter).not.toHaveBeenCalled(); + expect(toJSON).not.toHaveBeenCalled(); + const cycle: Record = {}; + cycle['self'] = cycle; + expect(() => managedToolDigest(cycle)).toThrow(ManagedToolProtocolError); + }); + + it('bounds canonical UTF-8 bytes, escaping, nesting and explicit limits', () => { + expect(managedToolDigest('é', 4)).toMatch(/^[a-f0-9]{64}$/); + expect(() => managedToolDigest('é', 3)).toThrow(ManagedToolProtocolError); + expect(() => managedToolDigest('\n', 3)).toThrow(ManagedToolProtocolError); + expect(() => managedToolDigest('x'.repeat(256 * 1024))).toThrow( + ManagedToolProtocolError, + ); + expect(managedToolDigest('x'.repeat(256 * 1024), 1024 * 1024)).toMatch( + /^[a-f0-9]{64}$/, + ); + for (const limit of [0, -1, 1.5, Infinity]) { + expect(() => managedToolDigest({}, limit)).toThrow( + ManagedToolProtocolError, + ); + } + let nested: unknown = null; + for (let depth = 0; depth < 64; depth++) nested = [nested]; + expect(managedToolDigest(nested)).toMatch(/^[a-f0-9]{64}$/); + expect(() => managedToolDigest([nested])).toThrow(ManagedToolProtocolError); + }); +}); + +describe('managed tool confirmation DTOs', () => { + const onConfirm = vi.fn(async () => {}); + it('allows a bounded confirmation larger than the request budget', () => { + const content = 'x'.repeat(150 * 1024); + const details: ToolCallConfirmationDetails = { + type: 'edit', + title: 'Edit', + fileName: 'a.txt', + filePath: '/workspace/a.txt', + fileDiff: '-a\n+b', + originalContent: content, + newContent: content + 'b', + onConfirm, + }; + expect(serializeManagedToolConfirmation(details)).toMatchObject({ + originalContent: content, + newContent: content + 'b', + }); + expect(() => managedToolDigest({ content })).not.toThrow(); + expect(() => managedToolDigest({ old: content, new: content })).toThrow(); + expect(() => + serializeManagedToolConfirmation({ + ...details, + originalContent: 'x'.repeat(8 * 1024 * 1024), + }), + ).toThrow('size limit'); + }); + + const details: ToolCallConfirmationDetails[] = [ + { + type: 'edit', + title: 'Edit', + onConfirm, + hideAlwaysAllow: false, + fileName: 'file.ts', + filePath: '/workspace/file.ts', + fileDiff: '-old\n+new', + originalContent: null, + newContent: 'new', + isModifying: true, + hideModify: true, + skipIdeDiff: true, + warnings: ['warning'], + autoModeFallback: { + reason: 'classifier_unavailable', + message: 'Unavailable', + }, + }, + { + type: 'exec', + title: 'Run', + onConfirm, + command: 'echo ok', + rootCommand: 'echo', + permissionRules: ['Shell(echo *)'], + warnings: [], + }, + { + type: 'mcp', + title: 'MCP', + onConfirm, + serverName: 'server', + toolName: 'tool', + toolDisplayName: 'Tool', + permissionRules: ['mcp__server__tool'], + }, + { + type: 'info', + title: 'Info', + onConfirm, + prompt: 'Continue?', + renderPromptAsPlainText: true, + urls: ['https://example.com'], + permissionRules: [], + }, + ]; + + it.each(details)( + 'preserves $type fields and excludes callbacks and extensions', + (detail) => { + const { onConfirm: _callback, ...expected } = detail; + const extended = { ...detail, secret: 'do not serialize' }; + expect(serializeManagedToolConfirmation(extended)).toEqual(expected); + expect(onConfirm).not.toHaveBeenCalled(); + }, + ); + + it('clones nested metadata and strips unknown fields within AUTO metadata', () => { + const detail = details[0]; + const wire = serializeManagedToolConfirmation({ + ...detail, + autoModeFallback: Object.assign( + { reason: 'classifier_unavailable' as const, message: 'Unavailable' }, + { secret: 'do not serialize' }, + ), + }); + expect(wire.autoModeFallback).toEqual({ + reason: 'classifier_unavailable', + message: 'Unavailable', + }); + expect(wire.autoModeFallback).not.toBe(detail.autoModeFallback); + if (wire.type === 'edit' && detail.type === 'edit') { + expect(wire.warnings).not.toBe(detail.warnings); + } + }); + + it.each([ + { type: 'plan', title: 'Plan', plan: 'plan', onConfirm }, + { type: 'ask_user_question', title: 'Question', questions: [], onConfirm }, + ])('rejects Gateway-only $type confirmations', (detail) => { + expect(() => serializeManagedToolConfirmation(detail)).toThrow( + ManagedToolProtocolError, + ); + }); +}); + +describe('managed tool confirmation payloads', () => { + it('preserves and clones all existing payload fields', () => { + const payload = { + newContent: '', + cancelMessage: 'cancel', + permissionRules: ['Edit(*)'], + answers: { question: 'answer' }, + updatedInput: { nested: [true, null, 1] }, + }; + const parsed = parseManagedToolConfirmationPayload(payload); + expect(parsed).toEqual(payload); + expect(parsed?.updatedInput).not.toBe(payload.updatedInput); + expect(parsed?.answers).not.toBe(payload.answers); + expect(parseManagedToolConfirmationPayload(undefined)).toBeUndefined(); + expect(parseManagedToolConfirmationPayload({})).toEqual({}); + }); + + it.each([ + null, + [], + 'payload', + { newContent: 1 }, + { cancelMessage: null }, + { permissionRules: 'rule' }, + { permissionRules: [1] }, + { answers: [] }, + { answers: { q: false } }, + { updatedInput: [] }, + { updatedInput: { value: undefined } }, + { unknown: true }, + { newContent: 'x'.repeat(256 * 1024) }, + ])('rejects malformed, extended or oversized payload %#', (value) => { + expect(() => parseManagedToolConfirmationPayload(value)).toThrow( + ManagedToolProtocolError, + ); + }); +}); + +describe('managed notebook modification metadata', () => { + it('copies the full reference and accepts empty content for native validation', () => { + const input = { source: { ...reference }, newContent: '' }; + const parsed = parseManagedToolContentModification(input); + input.source.callId = 'mutated'; + expect(parsed).toEqual({ source: reference, newContent: '' }); + }); + it.each([ + {}, + { source: reference }, + { source: reference, newContent: null }, + { source: reference, newContent: '{}', approved: true }, + { source: { ...reference, argsDigest: 'wrong' }, newContent: '{}' }, + { source: reference, newContent: 'x'.repeat(256 * 1024) }, + ])('rejects malformed or oversized metadata %#', (input) => { + expect(() => parseManagedToolContentModification(input)).toThrow( + ManagedToolProtocolError, + ); + }); +}); diff --git a/packages/core/src/tools/managed-tool-protocol.ts b/packages/core/src/tools/managed-tool-protocol.ts new file mode 100644 index 00000000000..e966dcf1b27 --- /dev/null +++ b/packages/core/src/tools/managed-tool-protocol.ts @@ -0,0 +1,449 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash } from 'node:crypto'; +import type { FunctionDeclaration } from '@google/genai'; +import type { PermissionDecision } from '../permissions/types.js'; +import type { InputModalities } from '../core/contentGenerator.js'; +import type { + Kind, + ToolCallConfirmationDetails, + ToolConfirmationPayload, + ToolLocation, +} from './tools.js'; + +const MAX_JSON_BYTES = 256 * 1024; +const MAX_JSON_DEPTH = 64; +const SESSION_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const DIGEST_PATTERN = /^[a-f0-9]{64}$/; +const IDENTITY_KEYS = [ + 'sessionId', + 'promptId', + 'callId', + 'capabilityDigest', + 'policyRevision', +] as const; + +export interface ManagedToolCallIdentity { + readonly sessionId: string; + readonly promptId: string; + readonly callId: string; + readonly capabilityDigest: string; + readonly policyRevision: string; +} + +export interface ManagedToolInvocationReference + extends ManagedToolCallIdentity { + readonly invocationId: string; + readonly argsDigest: string; +} + +export interface ManagedToolContentModification { + readonly source: ManagedToolInvocationReference; + readonly newContent: string; +} + +export interface ManagedToolMediaContext { + readonly inputModalities: Readonly; +} + +export interface ManagedToolDescriptor { + readonly name: string; + readonly displayName: string; + readonly kind: Kind; + readonly description: string; + readonly schema: FunctionDeclaration; + readonly permissionAliases?: readonly string[]; + readonly canUpdateOutput: boolean; + readonly isOutputMarkdown?: boolean; + readonly shouldDefer?: boolean; + readonly alwaysLoad?: boolean; + readonly searchHint?: string; + readonly maxOutputChars?: number | 'unlimited'; + readonly truncateKeep?: 'head' | 'tail' | 'both'; + readonly isMcp?: boolean; + readonly mcpServerName?: string; + readonly mcpToolName?: string; +} + +export interface ManagedToolPrepareResponse + extends ManagedToolInvocationReference { + readonly params: Record; + readonly description: string; + readonly locations: ToolLocation[]; + readonly defaultPermission: PermissionDecision; + readonly requiresUserInteraction: boolean; + readonly toolUseId: string; +} + +type WorkspaceConfirmation = Extract< + ToolCallConfirmationDetails, + { type: 'edit' | 'exec' | 'mcp' | 'info' } +>; + +export type ManagedToolConfirmationDetails = { + [T in WorkspaceConfirmation['type']]: Omit< + Extract, + 'onConfirm' + >; +}[WorkspaceConfirmation['type']]; + +export class ManagedToolProtocolError extends Error { + readonly code = 'managed_tool_invalid_request'; + + constructor(message = 'Managed Tool invocation data is invalid.') { + super(message); + this.name = 'ManagedToolProtocolError'; + } +} + +function canonicalJson(value: unknown, maxBytes = MAX_JSON_BYTES): string { + if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) { + throw new ManagedToolProtocolError('Invalid Managed Tool JSON size limit.'); + } + const ancestors = new Set(); + const chunks: string[] = []; + let bytes = 0; + const append = (chunk: string) => { + bytes += Buffer.byteLength(chunk, 'utf8'); + if (bytes > maxBytes) { + throw new ManagedToolProtocolError( + 'Managed Tool JSON exceeds size limit.', + ); + } + chunks.push(chunk); + }; + const appendString = (text: string) => { + if (Buffer.byteLength(text, 'utf8') > maxBytes - bytes) { + throw new ManagedToolProtocolError( + 'Managed Tool JSON exceeds size limit.', + ); + } + append(JSON.stringify(text)); + }; + const visit = (input: unknown, depth: number): void => { + if (depth > MAX_JSON_DEPTH) { + throw new ManagedToolProtocolError( + 'Managed Tool JSON exceeds depth limit.', + ); + } + if (input === null) { + append('null'); + } else if (typeof input === 'string') { + appendString(input); + } else if (typeof input === 'boolean') { + append(input ? 'true' : 'false'); + } else if (typeof input === 'number' && Number.isFinite(input)) { + append(JSON.stringify(input)); + } else if (typeof input === 'object') { + if ( + ancestors.has(input) || + (!Array.isArray(input) && + Object.getPrototypeOf(input) !== Object.prototype && + Object.getPrototypeOf(input) !== null) + ) { + throw new ManagedToolProtocolError(); + } + ancestors.add(input); + const descriptors = Object.getOwnPropertyDescriptors(input); + const keys = Reflect.ownKeys(descriptors); + if ( + keys.some((key) => { + if (typeof key !== 'string') return true; + if (Array.isArray(input) && key === 'length') return false; + const descriptor = descriptors[key]; + return !descriptor.enumerable || !('value' in descriptor); + }) + ) { + throw new ManagedToolProtocolError(); + } + if (Array.isArray(input)) { + if ( + keys.length !== input.length + 1 || + keys.some( + (key) => + key !== 'length' && + (typeof key !== 'string' || + !/^(0|[1-9][0-9]*)$/.test(key) || + Number(key) >= input.length), + ) + ) { + throw new ManagedToolProtocolError(); + } + append('['); + for (let index = 0; index < input.length; index++) { + if (index > 0) append(','); + visit(descriptors[String(index)].value, depth + 1); + } + append(']'); + } else { + append('{'); + Object.keys(descriptors) + .sort() + .forEach((key, index) => { + if (index > 0) append(','); + appendString(key); + append(':'); + visit(descriptors[key].value, depth + 1); + }); + append('}'); + } + ancestors.delete(input); + } else { + throw new ManagedToolProtocolError(); + } + }; + visit(value, 0); + return chunks.join(''); +} + +export function managedToolDigest( + value: unknown, + maxBytes = MAX_JSON_BYTES, +): string { + return createHash('sha256') + .update(canonicalJson(value, maxBytes)) + .digest('hex'); +} + +function record(value: unknown): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new ManagedToolProtocolError(); + } + return value as Record; +} + +function jsonRecord(value: unknown): Record { + return record(JSON.parse(canonicalJson(value))); +} + +function assertKeys( + input: Record, + keys: readonly string[], +): void { + if (Object.keys(input).some((key) => !keys.includes(key))) { + throw new ManagedToolProtocolError(); + } +} + +function boundedId(value: unknown, maxLength: number): string { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > maxLength || + value.includes('\0') + ) { + throw new ManagedToolProtocolError(); + } + return value; +} + +function digest(value: unknown): string { + if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) { + throw new ManagedToolProtocolError(); + } + return value; +} + +function identityFields( + input: Record, +): ManagedToolCallIdentity { + const sessionId = boundedId(input['sessionId'], 256); + if (!SESSION_ID_PATTERN.test(sessionId)) throw new ManagedToolProtocolError(); + return { + sessionId: sessionId.toLowerCase(), + promptId: boundedId(input['promptId'], 128), + callId: boundedId(input['callId'], 512), + capabilityDigest: digest(input['capabilityDigest']), + policyRevision: boundedId(input['policyRevision'], 256), + }; +} + +export function parseManagedToolCallIdentity( + value: unknown, +): ManagedToolCallIdentity { + const input = jsonRecord(value); + assertKeys(input, IDENTITY_KEYS); + return identityFields(input); +} + +export function parseManagedToolInvocationReference( + value: unknown, +): ManagedToolInvocationReference { + const input = jsonRecord(value); + assertKeys(input, [...IDENTITY_KEYS, 'invocationId', 'argsDigest']); + return { + ...identityFields(input), + invocationId: boundedId(input['invocationId'], 128), + argsDigest: digest(input['argsDigest']), + }; +} + +export function parseManagedToolContentModification( + value: unknown, +): ManagedToolContentModification { + const input = jsonRecord(value); + assertKeys(input, ['source', 'newContent']); + if (typeof input['newContent'] !== 'string') + throw new ManagedToolProtocolError(); + return { + source: parseManagedToolInvocationReference(input['source']), + newContent: input['newContent'], + }; +} + +export function parseManagedToolMediaContext( + value: unknown, +): ManagedToolMediaContext { + const input = jsonRecord(value); + assertKeys(input, ['inputModalities']); + const modalities = record(input['inputModalities']); + assertKeys(modalities, ['image', 'pdf', 'audio', 'video']); + if ( + Object.values(modalities).some((enabled) => typeof enabled !== 'boolean') + ) { + throw new ManagedToolProtocolError(); + } + return { inputModalities: modalities as InputModalities }; +} + +export function serializeManagedToolConfirmation( + details: ToolCallConfirmationDetails, +): ManagedToolConfirmationDetails { + if (details.type === 'plan' || details.type === 'ask_user_question') { + throw new ManagedToolProtocolError('Confirmation belongs to the Gateway.'); + } + const base = { + title: details.title, + ...(details.hideAlwaysAllow !== undefined + ? { hideAlwaysAllow: details.hideAlwaysAllow } + : {}), + ...(details.autoModeFallback !== undefined + ? { + autoModeFallback: { + reason: details.autoModeFallback.reason, + message: details.autoModeFallback.message, + }, + } + : {}), + }; + let result: ManagedToolConfirmationDetails; + switch (details.type) { + case 'edit': + result = { + ...base, + type: 'edit', + fileName: details.fileName, + filePath: details.filePath, + fileDiff: details.fileDiff, + originalContent: details.originalContent, + newContent: details.newContent, + ...(details.isModifying !== undefined + ? { isModifying: details.isModifying } + : {}), + ...(details.hideModify !== undefined + ? { hideModify: details.hideModify } + : {}), + ...(details.skipIdeDiff !== undefined + ? { skipIdeDiff: details.skipIdeDiff } + : {}), + ...(details.warnings !== undefined + ? { warnings: details.warnings } + : {}), + }; + break; + case 'exec': + result = { + ...base, + type: 'exec', + command: details.command, + rootCommand: details.rootCommand, + ...(details.permissionRules !== undefined + ? { permissionRules: details.permissionRules } + : {}), + ...(details.warnings !== undefined + ? { warnings: details.warnings } + : {}), + }; + break; + case 'mcp': + result = { + ...base, + type: 'mcp', + serverName: details.serverName, + toolName: details.toolName, + toolDisplayName: details.toolDisplayName, + ...(details.permissionRules !== undefined + ? { permissionRules: details.permissionRules } + : {}), + }; + break; + case 'info': + result = { + ...base, + type: 'info', + prompt: details.prompt, + ...(details.renderPromptAsPlainText !== undefined + ? { renderPromptAsPlainText: details.renderPromptAsPlainText } + : {}), + ...(details.urls !== undefined ? { urls: details.urls } : {}), + ...(details.permissionRules !== undefined + ? { permissionRules: details.permissionRules } + : {}), + }; + break; + default: + throw new ManagedToolProtocolError( + 'Confirmation belongs to the Gateway.', + ); + } + return JSON.parse( + canonicalJson(result, 8 * 1024 * 1024 - 64 * 1024), + ) as ManagedToolConfirmationDetails; +} + +export function parseManagedToolConfirmationPayload( + value: unknown, +): ToolConfirmationPayload | undefined { + if (value === undefined) return undefined; + const input = jsonRecord(value); + assertKeys(input, [ + 'newContent', + 'cancelMessage', + 'permissionRules', + 'answers', + 'updatedInput', + ]); + const result: ToolConfirmationPayload = {}; + for (const key of ['newContent', 'cancelMessage'] as const) { + if (input[key] !== undefined) { + if (typeof input[key] !== 'string') throw new ManagedToolProtocolError(); + result[key] = input[key]; + } + } + const rules = input['permissionRules']; + if (rules !== undefined) { + if ( + !Array.isArray(rules) || + rules.some((rule) => typeof rule !== 'string') + ) { + throw new ManagedToolProtocolError(); + } + result.permissionRules = rules; + } + if (input['answers'] !== undefined) { + const answers = record(input['answers']); + if (Object.values(answers).some((answer) => typeof answer !== 'string')) { + throw new ManagedToolProtocolError(); + } + result.answers = answers as Record; + } + if (input['updatedInput'] !== undefined) { + result.updatedInput = record(input['updatedInput']); + } + return result; +} diff --git a/packages/core/src/tools/managed-tool-runtime.test.ts b/packages/core/src/tools/managed-tool-runtime.test.ts new file mode 100644 index 00000000000..86e7d01c144 --- /dev/null +++ b/packages/core/src/tools/managed-tool-runtime.test.ts @@ -0,0 +1,896 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Config } from '../config/config.js'; +import { ApprovalMode } from '../config/config.js'; +import { getInvocationContext } from '../utils/invocation-context.js'; +import { promptIdContext } from '../utils/promptIdContext.js'; +import { + BaseDeclarativeTool, + BaseToolInvocation, + Kind, + ToolConfirmationOutcome, + type ToolCallConfirmationDetails, + type ToolResult, + type ToolResultDisplay, +} from './tools.js'; +import { + ManagedToolRuntime, + createBuiltinManagedToolRuntime, + type ManagedToolExecutionResult, + type ManagedToolRuntimeFileHistory, +} from './managed-tool-runtime.js'; +import { managedToolDigest } from './managed-tool-protocol.js'; +import type { + ManagedToolCallIdentity, + ManagedToolInvocationReference, + ManagedToolPrepareResponse, +} from './managed-tool-protocol.js'; + +const hooks = vi.hoisted(() => ({ + pre: vi.fn(), + post: vi.fn(), + failure: vi.fn(), +})); +vi.mock('../core/toolHookTriggers.js', () => ({ + generateToolUseId: () => 'test-tool-use-id', + firePreToolUseHook: hooks.pre, + firePostToolUseHook: hooks.post, + firePostToolUseFailureHook: hooks.failure, +})); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} + +const sessionId = 'c911c54f-ad76-420f-8c76-fb124c0ce623'; +const rawResult: ToolResult = { llmContent: 'done', returnDisplay: 'Done' }; +const input = { value: 'original' }; + +class FixtureInvocation extends BaseToolInvocation< + { value: string }, + ToolResult +> { + getDescription() { + return `write ${this.params.value}`; + } + override toolLocations() { + return [{ path: '/scratch/output' }]; + } + override getDefaultPermission = vi.fn(async () => 'ask' as const); + onConfirm = vi.fn(async () => {}); + override getConfirmationDetails = vi.fn( + async (_signal: AbortSignal): Promise => ({ + type: 'info', + title: 'Write', + prompt: this.getDescription(), + onConfirm: this.onConfirm, + }), + ); + execute = vi.fn( + async ( + _signal: AbortSignal, + _update?: (output: ToolResultDisplay) => void, + ): Promise => structuredClone(rawResult), + ); +} + +class FixtureTool extends BaseDeclarativeTool<{ value: string }, ToolResult> { + invocations: FixtureInvocation[] = []; + setup = (_invocation: FixtureInvocation) => {}; + constructor() { + super('fixture_write', 'Write', 'Write fixture', Kind.Edit, { + type: 'object', + properties: { value: { type: 'string' } }, + required: ['value'], + }); + } + protected createInvocation(params: { value: string }) { + const invocation = new FixtureInvocation(params); + this.setup(invocation); + this.invocations.push(invocation); + return invocation; + } +} + +function reference( + value: ManagedToolPrepareResponse, +): ManagedToolInvocationReference { + const { + sessionId, + promptId, + callId, + capabilityDigest, + policyRevision, + invocationId, + argsDigest, + } = value; + return { + sessionId, + promptId, + callId, + capabilityDigest, + policyRevision, + invocationId, + argsDigest, + }; +} + +describe('ManagedToolRuntime', () => { + let tool: FixtureTool; + let runtime: ManagedToolRuntime; + let snapshot: ReturnType; + let revision: string; + let identity: ManagedToolCallIdentity; + let events: string[]; + let config: Config; + + beforeEach(() => { + vi.resetAllMocks(); + events = []; + hooks.pre.mockImplementation(async () => { + events.push('preflight'); + return { shouldProceed: true }; + }); + hooks.post.mockImplementation(async () => { + events.push('post'); + return { shouldStop: false }; + }); + hooks.failure.mockResolvedValue({}); + snapshot = vi.fn(async () => { + events.push('snapshot'); + }); + config = { + getSessionId: () => sessionId, + getDisableAllHooks: () => false, + getMessageBus: () => undefined, + getApprovalMode: () => ApprovalMode.DEFAULT, + getFileHistoryService: () => ({ makeSnapshot: snapshot }), + getShellExecutionConfig: () => ({ + terminalWidth: 80, + terminalHeight: 24, + showColor: false, + }), + } as unknown as Config; + revision = 'workspace-generation-1'; + tool = new FixtureTool(); + runtime = new ManagedToolRuntime( + config, + () => [tool], + () => revision, + ); + identity = { + sessionId, + promptId: 'prompt-1', + callId: 'call-1', + capabilityDigest: runtime.manifest().capabilityDigest, + policyRevision: revision, + }; + }); + afterEach(async () => { + await runtime.dispose(); + }); + + async function prepare(args = input, call = identity) { + await runtime.beginTurn(call); + return reference(await runtime.prepare(call, tool.name, args)); + } + + function useSharedHistory(history: ManagedToolRuntimeFileHistory) { + runtime = new ManagedToolRuntime( + config, + () => [tool], + () => revision, + history, + ); + } + + it('waits for the shared parent history without creating child snapshots', async () => { + const ready = deferred(); + const prepareTurn = vi.fn(() => ready.promise); + useSharedHistory({ prepareTurn, execute: (operation) => operation() }); + const beginning = runtime.beginTurn(identity); + const preparing = runtime.prepare(identity, tool.name, input); + await Promise.resolve(); + expect(prepareTurn).toHaveBeenCalledExactlyOnceWith(identity); + expect(tool.invocations).toHaveLength(0); + expect(snapshot).not.toHaveBeenCalled(); + ready.resolve(); + await beginning; + const ref = reference(await preparing); + await runtime.preflight(ref); + await runtime.execute(ref); + await runtime.beginTurn({ ...identity, promptId: 'child-prompt-2' }); + expect(prepareTurn).toHaveBeenCalledTimes(2); + expect(snapshot).not.toHaveBeenCalled(); + }); + + it.each(['cancel', 'policy', 'session'] as const)( + 'settles queued %s invalidation without physically executing the tool', + async (change) => { + const gate = deferred(); + const execute = vi.fn( + async (operation: () => Promise) => { + await gate.promise; + return operation(); + }, + ); + useSharedHistory({ prepareTurn: async () => {}, execute }); + const ref = await prepare(); + await runtime.preflight(ref); + const pending = runtime.execute(ref); + expect(tool.invocations[0].execute).not.toHaveBeenCalled(); + if (change === 'cancel') runtime.cancel(ref); + if (change === 'policy') revision = 'replaced-policy'; + if (change === 'session') { + vi.spyOn(config, 'getSessionId').mockReturnValue( + '4f17902c-bb4c-4ae8-bb5e-a3f25753a6ab', + ); + } + expect(runtime.status(ref).state).not.toBe('settled'); + gate.resolve(); + expect(await pending).toMatchObject({ + executionStatus: 'not_started', + error: { message: expect.any(String) }, + }); + expect(runtime.status(ref)).toMatchObject({ + state: 'settled', + result: { executionStatus: 'not_started' }, + }); + expect(tool.invocations[0].execute).not.toHaveBeenCalled(); + expect(hooks.post).not.toHaveBeenCalled(); + expect(hooks.failure).not.toHaveBeenCalled(); + }, + ); + + it('waits for queued execution cancellation before Runtime disposal finishes', async () => { + const gate = deferred(); + useSharedHistory({ + prepareTurn: async () => {}, + execute: async (operation) => { + await gate.promise; + return operation(); + }, + }); + const ref = await prepare(); + await runtime.preflight(ref); + const pending = runtime.execute(ref); + let disposed = false; + const disposal = runtime.dispose().then(() => { + disposed = true; + }); + await Promise.resolve(); + expect(disposed).toBe(false); + gate.resolve(); + expect((await pending).executionStatus).toBe('not_started'); + await disposal; + expect(disposed).toBe(true); + expect(tool.invocations[0].execute).not.toHaveBeenCalled(); + }); + + it('binds admitted builtins once to the child tool Config and forwards shared history', async () => { + const { ReadFileTool } = await import('./read-file.js'); + const parentRead = new ReadFileTool(config); + const parentBuild = vi.spyOn(parentRead, 'build'); + const getTool = vi.fn((name: string) => + name === ReadFileTool.Name ? parentRead : tool, + ); + config.getToolRegistry = () => + ({ + getTool, + ensureTool: vi.fn(async () => undefined), + }) as unknown as ReturnType; + config.isLsToolEnabled = () => false; + const childConfig = Object.assign(Object.create(config) as Config, { + getTargetDir: () => '/managed-child', + getFileService: () => ({ shouldQwenIgnoreFile: () => false }), + getWorkspaceContext: () => ({ isPathWithinWorkspace: () => true }), + getPlansDir: () => '/managed-child/plans', + storage: { + getProjectTempDir: () => '/managed-child/tmp', + getProjectDir: () => '/managed-child', + getUserSkillsDirs: () => [], + getWorkflowRunsDir: () => '/managed-child/workflow-runs', + }, + }); + const prepareTurn = vi.fn(async () => {}); + const build = vi.spyOn(ReadFileTool.prototype, 'build'); + try { + runtime = await createBuiltinManagedToolRuntime( + config, + { + prepareTurn, + execute: (operation) => operation(), + }, + childConfig, + ); + const manifest = runtime.manifest(); + expect(manifest.tools.map(({ name }) => name)).toEqual([ + ReadFileTool.Name, + ]); + expect(runtime.manifest()).toEqual(manifest); + const call = { + ...identity, + policyRevision: manifest.policyRevision, + capabilityDigest: manifest.capabilityDigest, + }; + await runtime.beginTurn(call); + const first = await runtime.prepare(call, ReadFileTool.Name, { + file_path: '/managed-child/a.txt', + }); + await runtime.prepare({ ...call, callId: 'call-2' }, ReadFileTool.Name, { + file_path: '/managed-child/b.txt', + }); + expect(first.params).toEqual({ file_path: '/managed-child/a.txt' }); + expect(first.argsDigest).toBe(managedToolDigest(first.params)); + expect(first.description).toBe('a.txt'); + expect(first.defaultPermission).toBe('allow'); + expect(first.sessionId).toBe(sessionId); + expect(prepareTurn).toHaveBeenCalledExactlyOnceWith(call); + expect(snapshot).not.toHaveBeenCalled(); + expect(parentBuild).not.toHaveBeenCalled(); + expect(build).toHaveBeenCalledTimes(2); + expect(build.mock.instances[0]).toBe(build.mock.instances[1]); + expect(build.mock.instances[0]).not.toBe(parentRead); + getTool.mockReturnValue(tool); + expect(runtime.manifest().tools).toEqual([]); + await expect(runtime.preflight(reference(first))).rejects.toThrow( + 'capability changed', + ); + } finally { + build.mockRestore(); + parentBuild.mockRestore(); + } + }); + + it('excludes Shell and both Grep implementations even when registered', async () => { + const { ReadFileTool } = await import('./read-file.js'); + const { ShellTool } = await import('./shell.js'); + const { GrepTool } = await import('./grep.js'); + const { RipGrepTool } = await import('./ripGrep.js'); + config.isLsToolEnabled = () => false; + config.isTruncateToolOutputThresholdExplicit = () => false; + for (const Grep of [GrepTool, RipGrepTool]) { + const admitted = [ + new ReadFileTool(config), + new ShellTool(config), + new Grep(config), + ]; + config.getToolRegistry = () => + ({ + getTool: (name: string) => + admitted.find((candidate) => candidate.name === name), + ensureTool: vi.fn(async () => undefined), + }) as unknown as ReturnType; + const builtin = await createBuiltinManagedToolRuntime(config); + try { + expect(builtin.manifest().tools.map(({ name }) => name)).toEqual([ + ReadFileTool.Name, + ]); + } finally { + await builtin.dispose(); + } + } + }); + + it('prepares once without execution and checkpoints at the explicit turn boundary', async () => { + await runtime.beginTurn(identity); + const prepared = await runtime.prepare(identity, tool.name, input); + const repeated = await runtime.prepare(identity, tool.name, { + value: 'original', + }); + expect(repeated).toEqual(prepared); + expect(tool.invocations).toHaveLength(1); + expect(tool.invocations[0].execute).not.toHaveBeenCalled(); + expect(prepared).toMatchObject({ + params: input, + description: 'write original', + defaultPermission: 'ask', + locations: [{ path: '/scratch/output' }], + }); + expect(snapshot).toHaveBeenCalledExactlyOnceWith(identity.promptId); + expect(runtime.hasActiveWork()).toBe(true); + }); + + it('rejects preparation without a turn and does not retain a rejected call', async () => { + await expect(runtime.prepare(identity, tool.name, input)).rejects.toThrow( + 'turn has not started', + ); + await runtime.beginTurn(identity); + const prepared = await runtime.prepare(identity, tool.name, input); + expect(prepared.params).toEqual(input); + }); + + it('serializes concurrent preparations and owns copies of their identity and args', async () => { + const gate = deferred<'ask'>(); + tool.setup = (invocation) => + invocation.getDefaultPermission.mockReturnValue(gate.promise); + await runtime.beginTurn(identity); + const mutableIdentity = { ...identity }; + const mutableArgs = { ...input }; + const first = runtime.prepare(mutableIdentity, tool.name, mutableArgs); + const second = runtime.prepare(identity, tool.name, input); + mutableIdentity.callId = 'changed'; + mutableArgs.value = 'changed'; + await vi.waitFor(() => expect(tool.invocations).toHaveLength(1)); + gate.resolve('ask'); + const [a, b] = await Promise.all([first, second]); + expect(a).toEqual(b); + expect(a.callId).toBe(identity.callId); + expect(a.params).toEqual(input); + }); + + it('does not let a conflicting prepare erase the original idempotency record', async () => { + const prepared = await prepare(); + await expect( + runtime.prepare(identity, tool.name, { value: 'changed' }), + ).rejects.toThrow('already owns'); + expect( + reference(await runtime.prepare(identity, tool.name, input)), + ).toEqual(prepared); + expect(tool.invocations).toHaveLength(1); + }); + + it('allows queued retry after a rejected build', async () => { + await runtime.beginTurn(identity); + const bad = runtime.prepare(identity, tool.name, {}); + const retry = runtime.prepare(identity, tool.name, input); + await expect(bad).rejects.toThrow(); + expect((await retry).params).toEqual(input); + }); + + it('build, confirmation, preflight and execution carry the original invocation context', async () => { + const assertContext = () => { + expect(getInvocationContext()).toEqual({ + version: 1, + sessionId, + promptId: identity.promptId, + }); + expect(promptIdContext.getStore()).toBe(identity.promptId); + }; + tool.setup = (invocation) => { + assertContext(); + invocation.getDefaultPermission.mockImplementation(async () => { + assertContext(); + return 'ask'; + }); + invocation.onConfirm.mockImplementation(async () => { + assertContext(); + }); + invocation.execute.mockImplementation(async () => { + assertContext(); + return rawResult; + }); + }; + hooks.pre.mockImplementation(async () => { + assertContext(); + return { shouldProceed: true }; + }); + hooks.post.mockImplementation(async () => { + assertContext(); + return { shouldStop: false }; + }); + const ref = await prepare(); + const dto = await runtime.confirmation(ref); + expect(dto).not.toHaveProperty('onConfirm'); + await runtime.confirm(ref, ToolConfirmationOutcome.ProceedOnce); + await runtime.preflight(ref); + await runtime.execute(ref); + }); + + it('leaves an always choice to the Harness and approves the invocation once locally', async () => { + tool.setup = (invocation) => { + invocation.getDefaultPermission.mockResolvedValue('ask'); + }; + const ref = await prepare(); + await runtime.confirmation(ref); + await runtime.confirm(ref, ToolConfirmationOutcome.ProceedAlways); + expect(tool.invocations[0].onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + undefined, + ); + // The decision itself is still recorded as the choice the Harness made. + await expect( + runtime.confirm(ref, ToolConfirmationOutcome.ProceedOnce), + ).rejects.toThrow('already decided'); + }); + + it('requires preflight and supports trusted automatic allow without onConfirm', async () => { + const ref = await prepare(); + expect(() => runtime.execute(ref)).toThrow('preflight'); + await runtime.preflight(ref); + expect(await runtime.execute(ref)).toMatchObject({ + executionStatus: 'success', + result: rawResult, + }); + expect(tool.invocations[0].onConfirm).not.toHaveBeenCalled(); + expect(events).toEqual(['snapshot', 'preflight', 'post']); + }); + + it('reclaims settled invocation capacity when the next turn begins', async () => { + await runtime.beginTurn(identity); + for (let index = 0; index < 1024; index++) { + const ref = reference( + await runtime.prepare( + { ...identity, callId: `call-${index}` }, + tool.name, + input, + ), + ); + await runtime.preflight(ref); + await runtime.execute(ref); + } + const prior = { ...identity, callId: 'call-0' }; + const old = reference(await runtime.prepare(prior, tool.name, input)); + expect(runtime.status(old).state).toBe('settled'); + await runtime.execute(old); + expect(tool.invocations).toHaveLength(1024); + expect(tool.invocations[0].execute).toHaveBeenCalledTimes(1); + const next = { ...identity, promptId: 'prompt-2' }; + await runtime.beginTurn(next); + expect(() => runtime.status(old)).toThrow('identity does not match'); + expect(() => runtime.execute(old)).toThrow('identity does not match'); + await expect(runtime.prepare(prior, tool.name, input)).rejects.toThrow( + 'turn has not started', + ); + expect(() => runtime.beginTurn(identity)).toThrow('previous tool turn'); + const ref = reference(await runtime.prepare(next, tool.name, input)); + await runtime.preflight(ref); + expect(await runtime.execute(ref)).toMatchObject({ + executionStatus: 'success', + result: rawResult, + }); + expect(tool.invocations).toHaveLength(1025); + }); + + it('runs the preflight hook once across the second confirmation bounce', async () => { + hooks.pre.mockResolvedValue({ shouldProceed: false, blockType: 'ask' }); + const ref = await prepare(); + await runtime.confirm(ref, ToolConfirmationOutcome.ProceedOnce); + await runtime.preflight(ref); + expect(() => runtime.execute(ref)).toThrow('preflight'); + await runtime.confirm( + ref, + ToolConfirmationOutcome.ProceedOnce, + undefined, + 'preflight', + ); + await runtime.confirm( + ref, + ToolConfirmationOutcome.ProceedOnce, + undefined, + 'preflight', + ); + await runtime.preflight(ref); + expect((await runtime.execute(ref)).executionStatus).toBe('success'); + expect(tool.invocations[0].onConfirm).toHaveBeenCalledTimes(2); + expect(hooks.pre).toHaveBeenCalledTimes(1); + expect(hooks.pre.mock.calls[0][3]).toBe(hooks.post.mock.calls[0][4]); + }); + + it.each(['denied', 'stop'])( + 'cannot confirm away a preflight %s', + async (blockType) => { + hooks.pre.mockResolvedValue({ shouldProceed: false, blockType }); + const ref = await prepare(); + await runtime.preflight(ref); + await expect( + runtime.confirm( + ref, + ToolConfirmationOutcome.ProceedOnce, + undefined, + 'preflight', + ), + ).rejects.toThrow('did not request'); + expect(() => runtime.execute(ref)).toThrow('preflight'); + expect(tool.invocations[0].execute).not.toHaveBeenCalled(); + }, + ); + + it('waits for permission callback before preflight and rejects contradictory confirmation', async () => { + const gate = deferred(); + tool.setup = (invocation) => + invocation.onConfirm.mockReturnValue(gate.promise); + const ref = await prepare(); + const confirming = runtime.confirm( + ref, + ToolConfirmationOutcome.ProceedOnce, + ); + const flight = runtime.preflight(ref); + await vi.waitFor(() => + expect(tool.invocations[0].onConfirm).toHaveBeenCalledTimes(1), + ); + expect(hooks.pre).not.toHaveBeenCalled(); + await expect( + runtime.confirm(ref, ToolConfirmationOutcome.ProceedAlways), + ).rejects.toThrow('already decided'); + gate.resolve(); + await confirming; + await flight; + await runtime.execute(ref); + }); + + it('invalidates modified arguments and permits exactly one replacement build', async () => { + const ref = await prepare(); + await expect( + runtime.confirm(ref, ToolConfirmationOutcome.ProceedOnce, { + updatedInput: { value: 'changed' }, + }), + ).rejects.toThrow('new preparation'); + const [a, b] = await Promise.all([ + runtime.prepare(identity, tool.name, { value: 'changed' }), + runtime.prepare(identity, tool.name, { value: 'changed' }), + ]); + expect(a.invocationId).not.toBe(ref.invocationId); + expect(a).toEqual(b); + expect(() => runtime.execute(ref)).toThrow('cancelled'); + expect(tool.invocations[0].onConfirm).not.toHaveBeenCalled(); + expect(tool.invocations[0].execute).not.toHaveBeenCalled(); + await runtime.preflight(reference(a)); + await runtime.execute(reference(a)); + expect(tool.invocations[1].execute).toHaveBeenCalledTimes(1); + }); + + it('rejects parameters mutated after prepare before any side effect', async () => { + const ref = await prepare(); + await runtime.preflight(ref); + tool.invocations[0].params.value = 'modified'; + expect(() => runtime.execute(ref)).toThrow('parameters changed'); + expect(tool.invocations[0].execute).not.toHaveBeenCalled(); + }); + + it.each([ + 'sessionId', + 'promptId', + 'callId', + 'invocationId', + 'capabilityDigest', + 'policyRevision', + 'argsDigest', + ] as const)( + 'rejects a forged %s for cancel/status/execute', + async (field) => { + const ref = await prepare(); + const forged = { ...ref, [field]: 'forged' }; + expect(() => runtime.cancel(forged)).toThrow('identity'); + expect(() => runtime.status(forged)).toThrow('identity'); + expect(() => runtime.execute(forged)).toThrow('identity'); + expect(runtime.status(ref).cancelRequested).toBe(false); + }, + ); + + it('rejects stale policy for new execution but preserves a completed result', async () => { + const first = await prepare(); + await runtime.preflight(first); + await runtime.execute(first); + const second = reference( + await runtime.prepare( + { ...identity, callId: 'call-2' }, + tool.name, + input, + ), + ); + await runtime.preflight(second); + revision = 'generation-2'; + expect(() => runtime.execute(second)).toThrow('capability changed'); + expect((await runtime.execute(first)).executionStatus).toBe('success'); + expect(runtime.status(first).result?.executionStatus).toBe('success'); + }); + + it('executes at most once across concurrent delivery and a retry after a lost response', async () => { + const gate = deferred(); + tool.setup = (invocation) => + invocation.execute.mockReturnValue(gate.promise); + const ref = await prepare(); + await runtime.preflight(ref); + const first = runtime.execute(ref); + const duplicate = runtime.execute(ref); + expect(tool.invocations[0].execute).toHaveBeenCalledTimes(1); + expect(runtime.status(ref).state).toBe('executing'); + gate.resolve(rawResult); + const [a, b] = await Promise.all([first, duplicate]); + expect(a).toEqual(b); + a.result!.llmContent = 'modified client copy'; + expect((await runtime.execute(ref)).result?.llmContent).toBe('done'); + expect(runtime.status(ref).result?.result?.llmContent).toBe('done'); + expect(tool.invocations[0].execute).toHaveBeenCalledTimes(1); + }); + + it('does not turn a successful side effect into a failure when a post hook stops or fails', async () => { + hooks.post + .mockResolvedValueOnce({ shouldStop: true, stopReason: 'policy stop' }) + .mockRejectedValueOnce(new Error('hook failed')); + const first = await prepare(); + await runtime.preflight(first); + expect(await runtime.execute(first)).toMatchObject({ + executionStatus: 'success', + postHook: { shouldStop: true }, + }); + const second = reference( + await runtime.prepare( + { ...identity, callId: 'call-2' }, + tool.name, + input, + ), + ); + await runtime.preflight(second); + expect(await runtime.execute(second)).toMatchObject({ + executionStatus: 'success', + postHook: { hookError: 'hook failed' }, + }); + expect(hooks.failure).not.toHaveBeenCalled(); + }); + + it('records failure once and drops model control fields from workspace output', async () => { + tool.setup = (invocation) => + invocation.execute.mockResolvedValue({ + ...rawResult, + modelOverride: 'foreign-model', + terminateTurn: true, + persistedOutputFiles: [], + resultFilePaths: ['/scratch/output'], + error: { message: 'write failed' }, + }); + const ref = await prepare(); + await runtime.preflight(ref); + const result = await runtime.execute(ref); + expect(result.executionStatus).toBe('error'); + expect(result.result).toMatchObject({ + persistedOutputFiles: [], + resultFilePaths: ['/scratch/output'], + }); + expect(result.result).not.toHaveProperty('modelOverride'); + expect(result.result).not.toHaveProperty('terminateTurn'); + await runtime.execute(ref); + expect(hooks.failure).toHaveBeenCalledTimes(1); + expect(hooks.post).not.toHaveBeenCalled(); + }); + + it('reports progress gaps and keeps the final result after dropping oversized progress', async () => { + tool.setup = (invocation) => + invocation.execute.mockImplementation(async (_signal, update) => { + update!('first'); + update!('x'.repeat(1024 * 1024)); + update!('last'); + return rawResult; + }); + const ref = await prepare(); + await runtime.preflight(ref); + await runtime.execute(ref); + expect(runtime.status(ref)).toMatchObject({ + state: 'settled', + lastSeq: 3, + firstAvailableSeq: 3, + progressGap: true, + progress: [{ seq: 3, output: 'last' }], + }); + expect(runtime.status(ref, 2).progressGap).toBe(false); + expect(() => runtime.status(ref, -1)).toThrow('cursor'); + }); + + it('separates a cancel acknowledgment from underlying execution settlement and shutdown', async () => { + const gate = deferred(); + tool.setup = (invocation) => + invocation.execute.mockReturnValue(gate.promise); + const ref = await prepare(); + await runtime.preflight(ref); + const executing = runtime.execute(ref); + expect(runtime.cancel(ref)).toMatchObject({ + state: 'cancel_requested', + cancelRequested: true, + }); + expect(runtime.hasActiveWork()).toBe(true); + let disposed = false; + const disposal = runtime.dispose().then(() => { + disposed = true; + }); + await Promise.resolve(); + expect(disposed).toBe(false); + expect(tool.invocations[0].execute.mock.calls[0][0].aborted).toBe(true); + gate.resolve({ ...rawResult, error: { message: 'cancelled' } }); + expect((await executing).executionStatus).toBe('cancelled'); + await disposal; + expect(disposed).toBe(true); + expect(hooks.failure).toHaveBeenCalledTimes(1); + }); + + it('does not fabricate cancellation when an already running write actually succeeds', async () => { + const gate = deferred(); + tool.setup = (invocation) => + invocation.execute.mockReturnValue(gate.promise); + const ref = await prepare(); + await runtime.preflight(ref); + const executing = runtime.execute(ref); + runtime.cancel(ref); + gate.resolve(rawResult); + expect((await executing).executionStatus).toBe('success'); + expect(runtime.status(ref)).toMatchObject({ + state: 'settled', + cancelRequested: true, + }); + }); + + it('keeps a prepared cancellation un-settled until its confirmation callback returns', async () => { + const gate = deferred(); + tool.setup = (invocation) => + invocation.onConfirm.mockReturnValue(gate.promise); + const ref = await prepare(); + const confirming = runtime.confirm( + ref, + ToolConfirmationOutcome.ProceedOnce, + ); + const rejection = confirming.catch((error: unknown) => error); + await vi.waitFor(() => + expect(tool.invocations[0].onConfirm).toHaveBeenCalledTimes(1), + ); + expect(runtime.cancel(ref).state).toBe('cancel_requested'); + expect(runtime.hasActiveWork()).toBe(true); + gate.resolve(); + expect(await rejection).toMatchObject({ + message: expect.stringContaining('cancelled'), + }); + await vi.waitFor(() => expect(runtime.status(ref).state).toBe('settled')); + expect(runtime.status(ref).result?.executionStatus).toBe('not_started'); + expect(tool.invocations[0].execute).not.toHaveBeenCalled(); + }); + + it('replays cancel confirmation idempotently and never runs the tool', async () => { + const ref = await prepare(); + await runtime.confirm(ref, ToolConfirmationOutcome.Cancel); + await runtime.confirm(ref, ToolConfirmationOutcome.Cancel); + await vi.waitFor(() => expect(runtime.status(ref).state).toBe('settled')); + expect(tool.invocations[0].onConfirm).toHaveBeenCalledTimes(1); + expect(tool.invocations[0].execute).not.toHaveBeenCalled(); + }); + + it('does not switch turns around queued preparations or pending snapshots', async () => { + const gate = deferred(); + snapshot.mockReturnValue(gate.promise); + const starting = runtime.beginTurn(identity); + expect(() => runtime.beginTurn({ ...identity, promptId: 'other' })).toThrow( + 'unfinished', + ); + expect(runtime.hasActiveWork()).toBe(true); + gate.resolve(); + await starting; + const pending = runtime.prepare(identity, tool.name, input); + expect(() => runtime.beginTurn({ ...identity, promptId: 'other' })).toThrow( + 'unfinished', + ); + await pending; + }); + + it('seals admission immediately and waits for a build that has not yet returned an id', async () => { + const gate = deferred<'ask'>(); + tool.setup = (invocation) => + invocation.getDefaultPermission.mockReturnValue(gate.promise); + await runtime.beginTurn(identity); + const preparing = runtime.prepare(identity, tool.name, input); + const rejection = preparing.catch((error: unknown) => error); + await vi.waitFor(() => expect(tool.invocations).toHaveLength(1)); + runtime.seal(); + expect(() => runtime.beginTurn(identity)).toThrow('closed'); + let disposed = false; + const disposal = runtime.dispose().then(() => { + disposed = true; + }); + await Promise.resolve(); + expect(disposed).toBe(false); + gate.resolve('ask'); + expect(await rejection).toMatchObject({ + message: expect.stringContaining('closed'), + }); + await disposal; + expect(tool.invocations[0].execute).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/tools/managed-tool-runtime.ts b/packages/core/src/tools/managed-tool-runtime.ts new file mode 100644 index 00000000000..88fc00a04c6 --- /dev/null +++ b/packages/core/src/tools/managed-tool-runtime.ts @@ -0,0 +1,938 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { randomUUID } from 'node:crypto'; +import { deriveConfig, type Config } from '../config/config.js'; +import { + firePreToolUseHook, + firePostToolUseHook, + firePostToolUseFailureHook, + generateToolUseId, + type PreToolUseHookResult, + type PostToolUseHookResult, + type PostToolUseFailureHookResult, +} from '../core/toolHookTriggers.js'; +import { promptIdContext } from '../utils/promptIdContext.js'; +import { runWithInvocationContext } from '../utils/invocation-context.js'; +import { + ToolConfirmationOutcome, + type AnyDeclarativeTool, + type AnyToolInvocation, + type ToolCallConfirmationDetails, + type ToolConfirmationPayload, + type ToolResult, + type ToolResultDisplay, +} from './tools.js'; +import { ToolNames } from './tool-names.js'; +import { isModifiableDeclarativeTool } from './modifiable-tool.js'; +import { ToolErrorType } from './tool-error.js'; +import { + managedToolDigest, + parseManagedToolContentModification, + parseManagedToolMediaContext, + type ManagedToolMediaContext, + type ManagedToolContentModification, + serializeManagedToolConfirmation, + type ManagedToolCallIdentity, + type ManagedToolInvocationReference, + type ManagedToolPrepareResponse, + type ManagedToolDescriptor, +} from './managed-tool-protocol.js'; + +import type { ManagedToolFileHistoryClient } from './managed-tool-file-history-protocol.js'; + +function invocationParams(params: unknown): Record { + // Native tools add optional undefined fields; hash the same JSON sent on the wire. + const projected = JSON.parse(JSON.stringify(params)) as Record< + string, + unknown + >; + managedToolDigest(projected); + return projected; +} + +export type ManagedToolConfirmationPhase = 'permission' | 'preflight'; + +export type ManagedToolV2Client = { + [K in + | 'manifest' + | 'beginTurn' + | 'prepare' + | 'confirmation' + | 'confirm' + | 'preflight' + | 'execute' + | 'status' + | 'cancel']: ( + ...args: Parameters + ) => Promise>>; +} & { + fileHistory?: ManagedToolFileHistoryClient; + prepareExecution?: ( + reference: ManagedToolInvocationReference, + ) => Promise; + startExecution?: ( + reference: ManagedToolInvocationReference, + executionCallId: string, + ) => Promise; +}; + +export interface ManagedToolExecutionReservation { + executionCallId: string; + invocationBindingId: string; +} + +export interface ManagedToolExecutionResult { + executionStatus: 'not_started' | 'success' | 'error' | 'cancelled'; + result?: ToolResult; + error?: { message: string; type?: ToolErrorType }; + postHook?: PostToolUseHookResult; + failureHook?: PostToolUseFailureHookResult; +} + +export interface ManagedToolRuntimeFileHistory { + prepareTurn(identity: ManagedToolCallIdentity): Promise; + execute( + operation: () => Promise, + ): Promise; +} + +export interface ManagedToolProgress { + seq: number; + output: ToolResultDisplay; +} + +export interface ManagedToolInvocationStatus { + state: 'prepared' | 'executing' | 'cancel_requested' | 'settled'; + cancelRequested: boolean; + lastSeq: number; + firstAvailableSeq: number; + progressGap: boolean; + progress: ManagedToolProgress[]; + result?: ManagedToolExecutionResult; +} + +interface Decision { + digest: string; + promise: Promise; + settled: boolean; +} + +interface CallSlot { + current?: Entry; + pending?: Promise; +} + +interface Entry { + readonly reference: ManagedToolInvocationReference; + readonly inputDigest: string; + readonly tool: AnyDeclarativeTool; + readonly invocation: AnyToolInvocation; + readonly controller: AbortController; + readonly toolUseId: string; + readonly prepared: ManagedToolPrepareResponse; + confirmation?: Promise; + decisions: Map; + preflight?: Promise; + preflightResult?: PreToolUseHookResult; + hookConfirmed: boolean; + execution?: Promise; + cancellation?: Promise; + result?: ManagedToolExecutionResult; + progress: Array; + progressBytes: number; + sequence: number; +} + +const MAX_INVOCATIONS = 1024; +const MAX_PROGRESS_BYTES = 1024 * 1024; + +/** Owned by one Tool-only Session; only the authenticated parent may dispatch. */ +export class ManagedToolRuntime { + private readonly entries = new Map(); + private readonly calls = new Map(); + private readonly lifetime = new AbortController(); + private activePrompt?: { id: string; snapshot: Promise }; + private pendingBuilds = 0; + private pendingPreparations = 0; + private snapshotPending = false; + private readonly startedPrompts = new Set(); + private disposal?: Promise; + + constructor( + private readonly config: Config, + private readonly tools: () => AnyDeclarativeTool[], + private readonly policyRevision: () => string, + private readonly sharedFileHistory?: ManagedToolRuntimeFileHistory, + private readonly bindMediaTool?: ( + tool: AnyDeclarativeTool, + context: ManagedToolMediaContext, + ) => AnyDeclarativeTool, + ) {} + + manifest(): { + tools: ManagedToolDescriptor[]; + capabilityDigest: string; + policyRevision: string; + } { + this.lifetime.signal.throwIfAborted(); + const tools = this.tools().map( + (tool): ManagedToolDescriptor => ({ + name: tool.name, + displayName: tool.displayName, + description: tool.description, + kind: tool.kind, + schema: tool.schema, + canUpdateOutput: tool.canUpdateOutput, + isOutputMarkdown: tool.isOutputMarkdown, + shouldDefer: tool.shouldDefer, + alwaysLoad: tool.alwaysLoad, + ...(tool.searchHint === undefined + ? {} + : { searchHint: tool.searchHint }), + ...(tool.maxOutputChars === undefined + ? {} + : { + maxOutputChars: + tool.maxOutputChars === Infinity + ? ('unlimited' as const) + : tool.maxOutputChars, + }), + truncateKeep: tool.truncateKeep, + }), + ); + return { + tools: structuredClone(tools), + capabilityDigest: managedToolDigest(tools, 1024 * 1024), + policyRevision: this.policyRevision(), + }; + } + + private assertCurrent(identity: ManagedToolCallIdentity): void { + this.lifetime.signal.throwIfAborted(); + const current = this.manifest(); + if ( + identity.sessionId !== this.config.getSessionId().toLowerCase() || + identity.capabilityDigest !== current.capabilityDigest || + identity.policyRevision !== current.policyRevision + ) + throw new Error('Managed tool Session or capability changed.'); + } + + private get(reference: ManagedToolInvocationReference): Entry { + const entry = this.entries.get(reference.invocationId); + if ( + !entry || + managedToolDigest(reference) !== managedToolDigest(entry.reference) + ) { + throw new Error('Managed tool invocation identity does not match.'); + } + return entry; + } + + private scoped(identity: ManagedToolCallIdentity, action: () => T): T { + return promptIdContext.run(identity.promptId, () => + runWithInvocationContext( + { + version: 1, + sessionId: identity.sessionId, + promptId: identity.promptId, + }, + action, + ), + ); + } + + beginTurn(identity: ManagedToolCallIdentity): Promise { + this.assertCurrent(identity); + if (this.activePrompt?.id === identity.promptId) + return this.activePrompt.snapshot; + if ( + this.snapshotPending || + this.pendingPreparations || + [...this.entries.values()].some((entry) => !entry.result) + ) { + throw new Error( + 'Managed Runtime still owns an unfinished tool invocation.', + ); + } + if (this.startedPrompts.has(identity.promptId)) { + throw new Error('Managed Runtime cannot reopen a previous tool turn.'); + } + this.entries.clear(); + this.calls.clear(); + identity = structuredClone(identity); + this.snapshotPending = true; + this.startedPrompts.add(identity.promptId); + const snapshot = this.scoped(identity, async () => { + try { + if (this.sharedFileHistory) { + await this.sharedFileHistory.prepareTurn(identity); + } else { + await this.config + .getFileHistoryService() + .makeSnapshot(identity.promptId); + } + this.assertCurrent(identity); + } finally { + this.snapshotPending = false; + } + }); + this.activePrompt = { id: identity.promptId, snapshot }; + void snapshot.catch(() => {}); + return snapshot; + } + + prepare( + identity: ManagedToolCallIdentity, + toolName: string, + input: Record, + modification?: ManagedToolContentModification, + mediaContext?: ManagedToolMediaContext, + ): Promise { + this.assertCurrent(identity); + managedToolDigest(input); + identity = structuredClone(identity); + const copied = structuredClone(input); + const media = + mediaContext === undefined + ? undefined + : parseManagedToolMediaContext(mediaContext); + const contentModification = + modification === undefined + ? undefined + : parseManagedToolContentModification(modification); + const inputDigest = managedToolDigest( + { + identity, + toolName, + input: copied, + ...(media === undefined ? {} : { mediaContext: media }), + ...(contentModification === undefined + ? {} + : { modification: contentModification }), + }, + 1024 * 1024, + ); + const key = JSON.stringify([identity.promptId, identity.callId]); + const slot = this.calls.get(key) ?? {}; + const previous = slot.pending; + this.pendingPreparations++; + const pending = (async () => { + // A rejected preparation owns no invocation; queued retries still build. + await previous?.catch(() => {}); + const entry = slot.current; + if (entry) { + if (entry.inputDigest === inputDigest) return entry; + if (!entry.controller.signal.aborted || entry.execution) { + throw new Error('Managed tool call already owns another invocation.'); + } + await entry.cancellation; + } + this.assertCurrent(identity); + if (this.activePrompt?.id !== identity.promptId) { + throw new Error('Managed Runtime tool turn has not started.'); + } + if (this.entries.size + this.pendingBuilds >= MAX_INVOCATIONS) { + throw new Error('Managed tool invocation capacity is exhausted.'); + } + this.pendingBuilds++; + try { + await this.activePrompt.snapshot; + this.assertCurrent(identity); + const built = await this.scoped(identity, () => + this.build( + identity, + toolName, + copied, + inputDigest, + contentModification, + entry, + media, + ), + ); + slot.current = built; + return built; + } finally { + this.pendingBuilds--; + } + })(); + void pending.then( + () => { + this.pendingPreparations--; + }, + () => { + this.pendingPreparations--; + }, + ); + slot.pending = pending; + this.calls.set(key, slot); + void pending.catch(() => { + if (slot.pending === pending && !slot.current) this.calls.delete(key); + }); + return pending.then((entry) => structuredClone(entry.prepared)); + } + + private async build( + identity: ManagedToolCallIdentity, + toolName: string, + input: Record, + inputDigest: string, + modification?: ManagedToolContentModification, + source?: Entry, + mediaContext?: ManagedToolMediaContext, + ): Promise { + let tool = this.tools().find((candidate) => candidate.name === toolName); + if (!tool) throw new Error('Managed Runtime tool is unavailable.'); + if (mediaContext !== undefined) { + if (!this.bindMediaTool) + throw new Error('Managed Runtime tool does not support media context.'); + tool = this.bindMediaTool(tool, mediaContext); + } + if (modification) { + if ( + !source || + this.get(modification.source) !== source || + source.tool !== tool || + tool.name !== ToolNames.NOTEBOOK_EDIT || + !isModifiableDeclarativeTool(tool) || + !source.controller.signal.aborted || + source.execution || + managedToolDigest(input) !== source.reference.argsDigest + ) + throw new Error('Managed content modification source does not match.'); + const details = await source.confirmation; + if ( + details?.type !== 'edit' || + typeof details.originalContent !== 'string' + ) + throw new Error( + 'Managed content modification requires an edit confirmation.', + ); + this.assertCurrent(identity); + input = tool + .getModifyContext(this.lifetime.signal) + .createUpdatedParams( + details.originalContent, + modification.newContent, + source.invocation.params, + ) as Record; + } + const invocation = tool.build(input); + const aware = invocation as { + setCallId?: (id: string) => void; + setPromptId?: (id: string) => void; + }; + aware.setCallId?.(identity.callId); + aware.setPromptId?.(identity.promptId); + const defaultPermission = await invocation.getDefaultPermission(); + this.assertCurrent(identity); + const reference: ManagedToolInvocationReference = { + ...identity, + invocationId: randomUUID(), + argsDigest: managedToolDigest(invocationParams(invocation.params)), + }; + const toolUseId = generateToolUseId(); + const entry: Entry = { + reference, + inputDigest, + tool, + invocation, + controller: new AbortController(), + toolUseId, + prepared: { + ...reference, + params: invocationParams(invocation.params), + description: invocation.getDescription(), + locations: invocation.toolLocations(), + defaultPermission, + requiresUserInteraction: + invocation.requiresUserInteraction?.() ?? false, + toolUseId, + }, + decisions: new Map(), + hookConfirmed: false, + progress: [], + progressBytes: 0, + sequence: 0, + }; + this.entries.set(reference.invocationId, entry); + return entry; + } + + async confirmation(reference: ManagedToolInvocationReference) { + const entry = this.get(reference); + this.assertExecutable(entry); + entry.confirmation ??= this.scoped(reference, async () => { + const details = await entry.invocation.getConfirmationDetails( + entry.controller.signal, + ); + serializeManagedToolConfirmation(details); + return details; + }); + const details = await entry.confirmation; + this.assertExecutable(entry); + return serializeManagedToolConfirmation(details); + } + + async confirm( + reference: ManagedToolInvocationReference, + outcome: ToolConfirmationOutcome, + payload?: ToolConfirmationPayload, + phase: ManagedToolConfirmationPhase = 'permission', + ): Promise { + const entry = this.get(reference); + reference = entry.reference; + payload = payload === undefined ? undefined : structuredClone(payload); + if (phase !== 'permission' && phase !== 'preflight') + throw new Error('Invalid managed confirmation phase.'); + if ( + !Object.values(ToolConfirmationOutcome).includes(outcome) || + outcome === ToolConfirmationOutcome.RestorePrevious + ) { + throw new Error('Unsupported managed tool confirmation outcome.'); + } + const digest = managedToolDigest({ + outcome, + ...(payload ? { payload } : {}), + }); + const previous = entry.decisions.get(phase); + if (previous) { + if (previous.digest !== digest) + throw new Error('Managed tool confirmation already decided.'); + return previous.promise; + } + this.assertExecutable(entry); + if (phase === 'permission' && entry.preflight) + throw new Error('Managed tool preflight already started.'); + if (phase === 'preflight' && entry.preflightResult?.blockType !== 'ask') + throw new Error('Managed tool preflight did not request confirmation.'); + if ( + payload?.updatedInput !== undefined || + payload?.newContent !== undefined || + outcome === ToolConfirmationOutcome.ModifyWithEditor + ) { + this.cancel(reference); + throw new Error('Modified tool arguments require a new preparation.'); + } + const pending = (async () => { + await this.confirmation(reference); + this.assertExecutable(entry); + const details = await entry.confirmation!; + await this.scoped(reference, () => + details.onConfirm(runtimeLocalOutcome(outcome), payload), + ); + if (outcome === ToolConfirmationOutcome.Cancel) { + this.requestCancel(entry); + return; + } + this.assertExecutable(entry); + if (phase === 'preflight') entry.hookConfirmed = true; + })(); + const decision: Decision = { digest, promise: pending, settled: false }; + entry.decisions.set(phase, decision); + void pending.then( + () => { + decision.settled = true; + }, + () => { + decision.settled = true; + }, + ); + return pending; + } + + private assertExecutable(entry: Entry): void { + this.assertCurrent(entry.reference); + entry.controller.signal.throwIfAborted(); + if (entry.execution) + throw new Error('Managed tool invocation already dispatched.'); + if ( + managedToolDigest(invocationParams(entry.invocation.params)) !== + entry.reference.argsDigest + ) { + throw new Error('Managed tool parameters changed after preparation.'); + } + } + + async preflight( + reference: ManagedToolInvocationReference, + ): Promise { + const entry = this.get(reference); + this.assertExecutable(entry); + entry.preflight ??= this.scoped(reference, async () => { + for (const decision of entry.decisions.values()) await decision.promise; + this.assertExecutable(entry); + const result = await firePreToolUseHook( + this.config.getDisableAllHooks() + ? undefined + : this.config.getMessageBus(), + entry.tool.name, + entry.invocation.params as Record, + entry.toolUseId, + this.config.getApprovalMode(), + entry.controller.signal, + entry.reference.callId, + ); + this.assertExecutable(entry); + entry.preflightResult = structuredClone(result); + return result; + }); + return structuredClone(await entry.preflight); + } + + /** The private parent calls this only after permission and its final guard. */ + execute( + reference: ManagedToolInvocationReference, + ): Promise { + const entry = this.get(reference); + if (entry.execution) + return entry.execution.then((result) => structuredClone(result)); + this.assertExecutable(entry); + const preflight = entry.preflightResult; + if ( + !preflight || + (!preflight.shouldProceed && + !(preflight.blockType === 'ask' && entry.hookConfirmed)) + ) { + throw new Error('Managed tool preflight has not permitted execution.'); + } + if ([...entry.decisions.values()].some((decision) => !decision.settled)) { + throw new Error('Managed tool confirmation is still pending.'); + } + entry.execution = this.scoped(reference, () => + this.sharedFileHistory + ? this.sharedFileHistory.execute(() => this.run(entry)) + : this.run(entry), + ); + void entry.execution.catch(() => {}); + return entry.execution.then((result) => structuredClone(result)); + } + + private progress(entry: Entry, output: ToolResultDisplay): void { + const seq = ++entry.sequence; + const event = { seq, output: structuredClone(output) }; + const bytes = Buffer.byteLength(JSON.stringify(event)); + if (bytes > MAX_PROGRESS_BYTES) { + entry.progress = []; + entry.progressBytes = 0; + return; + } + entry.progress.push({ ...event, bytes }); + entry.progressBytes += bytes; + while (entry.progressBytes > MAX_PROGRESS_BYTES) { + entry.progressBytes -= entry.progress.shift()!.bytes; + } + } + + private async run(entry: Entry): Promise { + const signal = entry.controller.signal; + const messageBus = this.config.getDisableAllHooks() + ? undefined + : this.config.getMessageBus(); + let result: ManagedToolExecutionResult; + let executionStarted = false; + try { + signal.throwIfAborted(); + this.assertCurrent(entry.reference); + executionStarted = true; + const raw = await entry.invocation.execute( + signal, + (output) => this.progress(entry, output), + this.config.getShellExecutionConfig(), + ); + result = { + executionStatus: raw.error + ? signal.aborted + ? 'cancelled' + : 'error' + : 'success', + }; + try { + result.result = structuredClone({ + llmContent: raw.llmContent, + returnDisplay: raw.returnDisplay, + ...(raw.error === undefined ? {} : { error: raw.error }), + ...(raw.resultFilePaths === undefined + ? {} + : { resultFilePaths: raw.resultFilePaths }), + ...(raw.persistedOutputFiles === undefined + ? {} + : { persistedOutputFiles: raw.persistedOutputFiles }), + ...(raw.artifacts === undefined ? {} : { artifacts: raw.artifacts }), + }); + } catch (error) { + result.error = { + message: `Cannot serialize managed tool result: ${String(error)}`, + type: ToolErrorType.EXECUTION_FAILED, + }; + } + } catch (error) { + result = { + executionStatus: !executionStarted + ? 'not_started' + : signal.aborted + ? 'cancelled' + : 'error', + error: { + message: error instanceof Error ? error.message : String(error), + type: ToolErrorType.EXECUTION_FAILED, + }, + }; + } + try { + if ( + result.executionStatus === 'error' || + result.executionStatus === 'cancelled' || + result.result?.error !== undefined + ) { + result.failureHook = await firePostToolUseFailureHook( + messageBus, + entry.toolUseId, + entry.tool.name, + entry.invocation.params as Record, + result.result?.error?.message ?? + result.error?.message ?? + 'Tool execution failed.', + result.executionStatus === 'cancelled' || signal.aborted, + this.config.getApprovalMode(), + undefined, + entry.reference.callId, + ); + } else if (result.executionStatus === 'success' && result.result) { + result.postHook = await firePostToolUseHook( + messageBus, + entry.tool.name, + entry.invocation.params as Record, + { + llmContent: result.result.llmContent, + returnDisplay: result.result.returnDisplay, + }, + entry.toolUseId, + this.config.getApprovalMode(), + undefined, + entry.reference.callId, + ); + } + } catch (error) { + const hookError = error instanceof Error ? error.message : String(error); + if (result.executionStatus === 'success') + result.postHook = { shouldStop: false, hookError }; + else result.failureHook = { hookError }; + } + entry.result = structuredClone(result); + return entry.result; + } + + status( + reference: ManagedToolInvocationReference, + afterSeq = 0, + ): ManagedToolInvocationStatus { + if (!Number.isSafeInteger(afterSeq) || afterSeq < 0) + throw new Error('Invalid managed tool progress cursor.'); + const entry = this.get(reference); + const firstAvailableSeq = entry.progress[0]?.seq ?? entry.sequence + 1; + return { + state: entry.result + ? 'settled' + : entry.controller.signal.aborted + ? 'cancel_requested' + : entry.execution + ? 'executing' + : 'prepared', + cancelRequested: entry.controller.signal.aborted, + lastSeq: entry.sequence, + firstAvailableSeq, + progressGap: afterSeq + 1 < firstAvailableSeq, + progress: structuredClone( + entry.progress + .filter((event) => event.seq > afterSeq) + .map(({ seq, output }) => ({ seq, output })), + ), + ...(entry.result ? { result: structuredClone(entry.result) } : {}), + }; + } + + private requestCancel(entry: Entry): void { + entry.controller.abort(new Error('Managed tool invocation cancelled.')); + if (!entry.execution) { + entry.cancellation ??= (async () => { + await Promise.allSettled([ + entry.confirmation, + entry.preflight, + ...[...entry.decisions.values()].map((decision) => decision.promise), + ]); + entry.result = { executionStatus: 'not_started' }; + })(); + } + } + + cancel( + reference: ManagedToolInvocationReference, + ): ManagedToolInvocationStatus { + const entry = this.get(reference); + this.requestCancel(entry); + return this.status(reference); + } + + hasActiveWork(): boolean { + return ( + this.snapshotPending || + this.pendingPreparations > 0 || + [...this.entries.values()].some((entry) => !entry.result) + ); + } + + seal(): void { + this.lifetime.abort(new Error('Managed tool Runtime is closed.')); + for (const entry of this.entries.values()) this.requestCancel(entry); + } + + dispose(): Promise { + this.disposal ??= (async () => { + this.seal(); + await Promise.allSettled([ + ...[...this.calls.values()].map((slot) => slot.pending), + this.activePrompt?.snapshot, + ]); + await Promise.allSettled( + [...this.entries.values()].flatMap((entry) => [ + entry.confirmation, + entry.preflight, + entry.execution, + entry.cancellation, + ...[...entry.decisions.values()].map((decision) => decision.promise), + ]), + ); + this.entries.clear(); + this.calls.clear(); + })(); + return this.disposal; + } +} + +/** + * The Harness applies what an "always" choice means to its own Config -- the + * approval mode, persisted rules -- when it forwards the decision. The Runtime + * only carries out this one invocation, and its derived tool Config refuses an + * approval-mode change, so the tool's own callback sees a single approval. + */ +function runtimeLocalOutcome( + outcome: ToolConfirmationOutcome, +): ToolConfirmationOutcome { + switch (outcome) { + case ToolConfirmationOutcome.ProceedAlways: + case ToolConfirmationOutcome.ProceedAlwaysServer: + case ToolConfirmationOutcome.ProceedAlwaysTool: + case ToolConfirmationOutcome.ProceedAlwaysProject: + case ToolConfirmationOutcome.ProceedAlwaysUser: + return ToolConfirmationOutcome.ProceedOnce; + default: + return outcome; + } +} + +export async function createBuiltinManagedToolRuntime( + config: Config, + fileHistory?: ManagedToolRuntimeFileHistory, + toolConfig: Config = config, +): Promise { + const [ + { ReadFileTool }, + { WriteFileTool }, + { EditTool }, + { NotebookEditTool }, + { GlobTool }, + { LSTool }, + { ZoomImageTool }, + ] = await Promise.all([ + import('./read-file.js'), + import('./write-file.js'), + import('./edit.js'), + import('./notebook-edit.js'), + import('./glob.js'), + import('./ls.js'), + import('./zoom-image.js'), + ]); + const registry = config.getToolRegistry(); + await registry.ensureTool(ZoomImageTool.Name); + if ( + toolConfig !== config && + toolConfig.isLsToolEnabled() && + !config.isLsToolEnabled() && + !registry.getAllToolNames().includes(LSTool.Name) + ) { + const permissionManager = config.getPermissionManager(); + if (!permissionManager) + throw new Error( + 'Managed LS registration requires initialized permissions.', + ); + const status = await permissionManager.getToolRegistrationStatus( + LSTool.Name, + ); + if ( + status !== 'disabled' && + !registry.getAllToolNames().includes(LSTool.Name) + ) { + const factory = async () => new LSTool(config); + if (status === 'deferred') + registry.registerPermissionDeferredFactory(LSTool.Name, factory); + else registry.registerFactory(LSTool.Name, factory); + await registry.ensureTool(LSTool.Name); + } + } + const constructors = [ + ReadFileTool, + WriteFileTool, + EditTool, + NotebookEditTool, + GlobTool, + LSTool, + ZoomImageTool, + ].filter( + (Constructor) => Constructor !== LSTool || toolConfig.isLsToolEnabled(), + ); + const revision = randomUUID(); + const boundTools = + toolConfig === config + ? undefined + : constructors.flatMap((Constructor): AnyDeclarativeTool[] => { + const tool = config.getToolRegistry().getTool(Constructor.Name); + return tool?.constructor === Constructor + ? [new Constructor(toolConfig)] + : []; + }); + return new ManagedToolRuntime( + toolConfig, + () => [ + ...(boundTools?.filter( + (tool) => + config.getToolRegistry().getTool(tool.name)?.constructor === + tool.constructor, + ) ?? + constructors.flatMap((Constructor): AnyDeclarativeTool[] => { + const tool = config.getToolRegistry().getTool(Constructor.Name); + return tool?.constructor === Constructor ? [tool] : []; + })), + ], + () => revision, + fileHistory, + (tool, context) => { + const Constructor = [ReadFileTool, ZoomImageTool].find( + (candidate) => tool.constructor === candidate, + ); + if (!Constructor) { + throw new Error('Managed Runtime tool does not support media context.'); + } + const view = deriveConfig(toolConfig, { + getEffectiveInputModalities: () => ({ ...context.inputModalities }), + getFileReadCache: () => toolConfig.getFileReadCache(), + getFileService: () => toolConfig.getFileService(), + }); + return new Constructor(view); + }, + ); +} diff --git a/packages/core/src/tools/pid-descendants.test.ts b/packages/core/src/tools/pid-descendants.test.ts index 748bece442b..dafd19b2b37 100644 --- a/packages/core/src/tools/pid-descendants.test.ts +++ b/packages/core/src/tools/pid-descendants.test.ts @@ -6,7 +6,7 @@ import { spawn } from 'node:child_process'; import { once } from 'node:events'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { listDescendantPids, sigtermPids } from './pid-descendants.js'; describe('pid-descendants', () => { @@ -50,14 +50,17 @@ describe('pid-descendants', () => { }); it('tolerates already-exited pids (ESRCH swallowed)', () => { - // Pick a pid almost certainly not in use. process.kill with - // SIGTERM to a non-existent pid throws ESRCH which sigtermPids - // catches. - const result = sigtermPids([999999, 999998]); - // The function returns the count of "successfully signaled" pids - // (where process.kill didn't throw). For non-existent pids, - // it throws ESRCH which is caught, so the count is 0. - expect(result).toBe(0); + const kill = vi.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('No such process'), { code: 'ESRCH' }); + }); + try { + expect(sigtermPids([999999, 999998])).toBe(0); + expect(kill).toHaveBeenCalledTimes(2); + expect(kill).toHaveBeenNthCalledWith(1, 999999, 'SIGTERM'); + expect(kill).toHaveBeenNthCalledWith(2, 999998, 'SIGTERM'); + } finally { + kill.mockRestore(); + } }); }); diff --git a/packages/core/src/utils/sessionStorageUtils.ts b/packages/core/src/utils/sessionStorageUtils.ts index 7f78f0bac61..749cfa37f1d 100644 --- a/packages/core/src/utils/sessionStorageUtils.ts +++ b/packages/core/src/utils/sessionStorageUtils.ts @@ -11,9 +11,11 @@ * file reading for fast session metadata access on large JSONL files. */ +import { createHash } from 'node:crypto'; import fs from 'node:fs'; +import * as path from 'node:path'; +import { getProjectHash } from './paths.js'; import { _recoverObjectsFromLine } from './jsonl-utils.js'; - import { openSyncNoFollow } from './no-follow-open.js'; /** Size of the head/tail buffer for lite metadata reads (64KB). */ @@ -773,3 +775,322 @@ export function readSessionTitleInfoFromFileSync( rawSource === 'auto' || rawSource === 'manual' ? rawSource : undefined; return { title, source }; } + +/** + * The Managed session key for a locally stored session. + * + * The key addresses both the log's records and the resource bodies they + * reference, so the writer and every reader must derive it identically -- a + * second derivation that disagreed would leave published bodies unreadable. + * Structurally a `ManagedSessionKey`, defined here so `utils/` stays a leaf. + */ +export function localManagedSessionKey( + projectRoot: string, + sessionId: string, +): { tenantId: string; workspaceId: string; sessionId: string } { + return { + tenantId: 'local', + workspaceId: getProjectHash(projectRoot), + sessionId, + }; +} + +/** + * Session-owned Managed resources live beside the session under the controlled + * runtime base directory. Defined here so `utils/` stays a leaf layer and the + * resource store and this reader cannot drift apart. + */ +export function managedSessionResourceRoot( + runtimeBaseDir: string, + sessionId: string, +): string { + if (!isSinglePathSegment(sessionId)) { + throw new Error('sessionId must be a single path segment.'); + } + return path.join(runtimeBaseDir, 'resources', sessionId); +} + +/** + * Checked directly rather than through `path.basename` so the guard holds even + * where `node:path` is stubbed, and so it cannot be weakened by a platform's + * separator handling. + */ +export function isSinglePathSegment(value: string): boolean { + return ( + value.length > 0 && !/[/\\]/.test(value) && value !== '.' && value !== '..' + ); +} + +const MANAGED_HEADER_MARKER = '"subtype":"managed_session_header_v1"'; +const MANAGED_METADATA_MARKER = '"domain":"session_metadata"'; +const MANAGED_SOURCE_MARKER = '"domain":"session_source"'; + +function lastLineContaining(text: string, marker: string): string | undefined { + const lines = text.split('\n'); + for (let index = lines.length - 1; index >= 0; index--) { + if (lines[index].includes(marker)) return lines[index]; + } + return undefined; +} + +function lastCommittedDomainLine( + text: string, + marker: string, +): string | undefined { + const lines = text.split('\n'); + lines.pop(); + let committed: { firstSequence: number; lastSequence: number } | undefined; + for (let index = lines.length - 1; index >= 0; index--) { + const line = lines[index]; + const record = JSON.parse(line) as { + subtype?: string; + managedSession?: { + firstSequence?: number; + lastSequence?: number; + eventCount?: number; + sequence?: number; + kind?: string; + }; + }; + const body = record.managedSession; + if (record.subtype === 'managed_session_commit_v1') { + const first = body?.firstSequence; + const last = body?.lastSequence; + committed = + Number.isSafeInteger(first) && + Number.isSafeInteger(last) && + first! > 0 && + last! >= first! && + body?.eventCount === last! - first! + 1 + ? { firstSequence: first!, lastSequence: last! } + : undefined; + } else if ( + committed !== undefined && + record.subtype === 'managed_session_event_v1' && + body?.kind === 'domain.committed' && + Number.isSafeInteger(body.sequence) && + body.sequence! >= committed.firstSequence && + body.sequence! <= committed.lastSequence && + line.includes(marker) + ) { + return line; + } + } + return undefined; +} + +/** + * True when the transcript carries a Managed Session header. + * + * A positive identification, unlike the execution-engine reader, which reports + * `unavailable` for any transcript with a completeness diagnostic and so cannot + * gate legacy-only operations without regressing legacy sessions. + */ +export function isManagedSessionTranscriptSync( + filePath: string, + scratchBuffer?: Buffer, +): boolean { + let fd: number | undefined; + try { + const fileSize = fs.statSync(filePath).size; + if (fileSize === 0) return false; + fd = openSyncNoFollow(filePath); + const buffer = + scratchBuffer && scratchBuffer.length >= LITE_READ_BUF_SIZE + ? scratchBuffer + : Buffer.alloc(LITE_READ_BUF_SIZE); + const length = Math.min(fileSize, LITE_READ_BUF_SIZE); + const read = fs.readSync(fd, buffer, 0, length, 0); + return buffer.toString('utf-8', 0, read).includes(MANAGED_HEADER_MARKER); + } catch { + return false; + } finally { + if (fd !== undefined) { + try { + fs.closeSync(fd); + } catch { + /* best-effort */ + } + } + } +} + +/** + * Managed sessions keep their title in a committed `session_metadata` domain + * record whose body lives in the resource store, so the legacy scan for a + * `custom_title` record finds nothing and would report a blank title. + * + * Returns `undefined` when the transcript is not Managed, so the caller falls + * back to the legacy reader. Returns `{}` for a Managed session that was never + * renamed, which genuinely has no custom title. Display stays tolerant: an + * unreadable body yields `{}` rather than throwing into the session list. + * + * Like the legacy reader this only scans the tail and head windows, so a rename + * buried in the middle of a very long log is not found. + */ +export function readManagedSessionTitleInfoSync( + filePath: string, + runtimeBaseDir: string, + scratchBuffer?: Buffer, +): { title?: string; source?: 'auto' | 'manual' } | undefined { + const found = readManagedDomainBodySync( + filePath, + runtimeBaseDir, + MANAGED_METADATA_MARKER, + scratchBuffer, + ); + if (found === undefined) return undefined; + const body = found.body as + | { title?: unknown; titleSource?: unknown } + | undefined; + if (typeof body?.title !== 'string' || body.title.length === 0) return {}; + const source = + body.titleSource === 'auto' || body.titleSource === 'manual' + ? body.titleSource + : undefined; + return { title: body.title, source }; +} + +/** + * The source a Managed session was created from, read the same way as its + * title: the record lives in a committed `session_source` domain body, so the + * legacy scan for a `session_source` line finds nothing. + * + * Returns `undefined` when the transcript is not Managed, so the caller falls + * back to the legacy reader, and `{}` for a Managed session with no source. + */ +export function readManagedSessionSourceSync( + filePath: string, + runtimeBaseDir: string, + scratchBuffer?: Buffer, +): { sourceType?: string; sourceId?: string } | undefined { + const found = readManagedDomainBodySync( + filePath, + runtimeBaseDir, + MANAGED_SOURCE_MARKER, + scratchBuffer, + ); + if (found === undefined) return undefined; + const payload = ( + found.body as + | { + record?: { + systemPayload?: { sourceType?: unknown; sourceId?: unknown }; + }; + } + | undefined + )?.record?.systemPayload; + if ( + typeof payload?.sourceType !== 'string' || + payload.sourceType.length === 0 + ) + return {}; + return { + sourceType: payload.sourceType, + ...(typeof payload.sourceId === 'string' + ? { sourceId: payload.sourceId } + : {}), + }; +} + +/** + * Locates the newest committed record for a domain marker and reads its body. + * + * `undefined` means the transcript carries no Managed header, which is how the + * callers tell "not Managed, use the legacy reader" apart from "Managed with + * nothing to report" (`{}`). Display stays tolerant: an unreadable body is the + * latter rather than a throw into the session list. + * + * Like the legacy readers this only scans the tail and head windows, so a + * record buried in the middle of a very long log is not found. + */ +function readManagedDomainBodySync( + filePath: string, + runtimeBaseDir: string, + marker: string, + scratchBuffer?: Buffer, +): { body?: unknown } | undefined { + let fd: number | undefined; + try { + const fileSize = fs.statSync(filePath).size; + if (fileSize === 0) return undefined; + fd = openSyncNoFollow(filePath); + + const buffer = + scratchBuffer && scratchBuffer.length >= LITE_READ_BUF_SIZE + ? scratchBuffer + : Buffer.alloc(LITE_READ_BUF_SIZE); + + const headLength = Math.min(fileSize, LITE_READ_BUF_SIZE); + const headBytes = fs.readSync(fd, buffer, 0, headLength, 0); + const headText = buffer.toString('utf-8', 0, headBytes); + const headerLine = lastLineContaining(headText, MANAGED_HEADER_MARKER); + if (headerLine === undefined) return undefined; + + /* The recorded identity, not the file name, decides where the resources + live. */ + const header = JSON.parse(headerLine) as { + managedSession?: { sessionKey?: { sessionId?: unknown } }; + }; + const recordedId = header.managedSession?.sessionKey?.sessionId; + if (typeof recordedId !== 'string' || recordedId.length === 0) return {}; + const resourceRoot = managedSessionResourceRoot(runtimeBaseDir, recordedId); + + let line = undefined as string | undefined; + const tailLength = Math.min(fileSize, LITE_READ_BUF_SIZE); + const tailOffset = fileSize - tailLength; + if (tailOffset > 0) { + const tailBytes = fs.readSync(fd, buffer, 0, tailLength, tailOffset); + const tailText = buffer.toString('utf-8', 0, tailBytes); + line = lastCommittedDomainLine( + tailText.slice(tailText.indexOf('\n') + 1), + marker, + ); + } + line ??= lastCommittedDomainLine(headText, marker); + if (line === undefined) return {}; + + const record = JSON.parse(line) as { + managedSession?: { + payload?: { + recordRef?: { + kind?: unknown; + resourceId?: unknown; + byteLength?: unknown; + digest?: unknown; + }; + }; + }; + }; + const ref = record.managedSession?.payload?.recordRef; + if (typeof ref?.kind !== 'string' || typeof ref.resourceId !== 'string') { + return {}; + } + if ( + !isSinglePathSegment(ref.kind) || + !isSinglePathSegment(ref.resourceId) + ) { + return {}; + } + const bytes = fs.readFileSync( + path.join(resourceRoot, ref.kind, ref.resourceId), + ); + if ( + bytes.byteLength !== ref.byteLength || + createHash('sha256').update(bytes).digest('hex') !== ref.digest + ) { + return {}; + } + return { body: JSON.parse(bytes.toString('utf8')) as unknown }; + } catch { + return {}; + } finally { + if (fd !== undefined) { + try { + fs.closeSync(fd); + } catch { + /* best-effort */ + } + } + } +} diff --git a/packages/core/src/utils/transcript-records.ts b/packages/core/src/utils/transcript-records.ts index fe4234d19d6..84bc1c4336a 100644 --- a/packages/core/src/utils/transcript-records.ts +++ b/packages/core/src/utils/transcript-records.ts @@ -142,6 +142,7 @@ const KNOWN_RECORD_SUBTYPES = new Set([ 'session_source', 'session_model', 'omni_recall', + 'session_execution_engine', 'session_sources_snapshot', 'branch_checkpoint', 'goal_state', diff --git a/packages/sdk-java/managed-agent-server/.gitignore b/packages/sdk-java/managed-agent-server/.gitignore new file mode 100644 index 00000000000..b83d22266ac --- /dev/null +++ b/packages/sdk-java/managed-agent-server/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/packages/sdk-java/managed-agent-server/Dockerfile b/packages/sdk-java/managed-agent-server/Dockerfile new file mode 100644 index 00000000000..707d45cc145 --- /dev/null +++ b/packages/sdk-java/managed-agent-server/Dockerfile @@ -0,0 +1,27 @@ +FROM maven:3.9.11-eclipse-temurin-21 AS build + +WORKDIR /workspace +COPY packages/sdk-java/qwencode packages/sdk-java/qwencode +COPY packages/sdk-java/runtime-broker packages/sdk-java/runtime-broker +COPY packages/sdk-java/managed-agent-server packages/sdk-java/managed-agent-server + +RUN mvn --batch-mode --no-transfer-progress \ + -f packages/sdk-java/qwencode/pom.xml \ + -DskipTests -Dgpg.skip=true install \ + && mvn --batch-mode --no-transfer-progress \ + -f packages/sdk-java/runtime-broker/pom.xml \ + -DskipTests install \ + && mvn --batch-mode --no-transfer-progress \ + -f packages/sdk-java/managed-agent-server/pom.xml \ + -DskipTests package + +FROM eclipse-temurin:21-jre + +WORKDIR /app +COPY --from=build \ + /workspace/packages/sdk-java/managed-agent-server/target/qwen-managed-agent-server-0.1.0-alpha.jar \ + /app/qwen-managed-agent-server.jar + +EXPOSE 8080 4182 +USER 10001 +ENTRYPOINT ["java", "-jar", "/app/qwen-managed-agent-server.jar"] diff --git a/packages/sdk-java/managed-agent-server/README.md b/packages/sdk-java/managed-agent-server/README.md new file mode 100644 index 00000000000..966ab77497e --- /dev/null +++ b/packages/sdk-java/managed-agent-server/README.md @@ -0,0 +1,353 @@ +# Qwen Managed Agent Server + +Standalone Spring Boot control plane for the Qwen Code Hosted Harness. It has +no DataWorks dependency and no end-user authentication layer. A trusted +upstream must send `X-Qwen-Tenant-Id`; the server uses that value on every +database read and write. The HTTP server listens on `127.0.0.1` by default; +set `QWEN_MANAGED_AGENT_SERVER_ADDRESS` when a trusted ingress needs to reach +it. That ingress must authenticate the tenant before setting the header. + +设计说明:[English](../../../docs/design/2026-09-19-managed-agent-spring-server.md) | +[简体中文](../../../docs/design/2026-09-19-managed-agent-spring-server.zh-CN.md) + +## Integration status + +This split is not yet a complete Hosted Harness / Runtime deployment. The +embedded HTTP transport returns 501 for acquire/control/release. Prepare/start +and operator resolution also return 501 because the merged Broker lacks their +durable service APIs. +Harness-level drain does not tear down workers. The copied real-process E2E +script requires TypeScript integrations absent from this PR. See the +[review corrections](../../../docs/design/2026-09-25-managed-agent-review-corrections.md) +for the remaining merge gates; earlier preview timing and recovery results +below are not evidence for this split. + +## Prerequisites + +- Java 21 +- MySQL 8 + +The `qwen serve --profile hosted-harness` option on current main is reserved +and rejects startup. The configuration below supports control-plane development, +but a successful Hosted Harness turn is not available in this split. + +Install the two sibling libraries once when building this module outside a +Maven reactor: + +```bash +mvn -f ../qwencode/pom.xml -DskipTests -Dgpg.skip=true install +mvn -f ../runtime-broker/pom.xml -DskipTests install +``` + +Configure and start the server: + +```bash +export SPRING_DATASOURCE_URL='jdbc:mysql://127.0.0.1:3306/qwen_managed_agent' +export SPRING_DATASOURCE_USERNAME='qwen' +export SPRING_DATASOURCE_PASSWORD='replace-me' +export QWEN_MANAGED_AGENT_HARNESS_ENABLED='true' +export QWEN_MANAGED_AGENT_HARNESS_BASE_URL='http://127.0.0.1:4170' +export QWEN_MANAGED_AGENT_HARNESS_TOKEN='replace-me' +export QWEN_MANAGED_AGENT_CAPABILITY_DIGEST='sha256:replace-with-64-hex-characters' + +mvn spring-boot:run +``` + +Create a Session: + +```bash +curl -sS http://127.0.0.1:8080/v1/agents/sessions \ + -H 'Content-Type: application/json' \ + -H 'X-Qwen-Tenant-Id: demo' \ + -H 'Idempotency-Key: create-1' \ + -d '{"agent_id":"qwen-code","input":[{"type":"text","text":"hello"}]}' +``` + +The returned `id` is an RFC UUID and is the canonical identity used by +the public API, Hosted Harness transcript, and Runtime Broker. The server does +not maintain a separate public-to-Harness Session mapping. + +## Public Session lifecycle + +Flyway V5 adds durable lifecycle commands and soft-deletion timestamps. The +public control plane owns lifecycle state and tenant/idempotency checks, while +the Hosted Harness remains the private title authority and the Runtime Broker +owns execution bindings. + +```bash +curl -sS -X PATCH \ + http://127.0.0.1:8080/v1/agents/sessions/$SESSION_ID \ + -H 'Content-Type: application/json' \ + -H 'X-Qwen-Tenant-Id: demo' \ + -H 'Idempotency-Key: rename-1' \ + -d '{"title":"investigate checkout failure"}' + +curl -sS -X POST \ + http://127.0.0.1:8080/v1/agents/sessions/$SESSION_ID/archive \ + -H 'X-Qwen-Tenant-Id: demo' \ + -H 'Idempotency-Key: archive-1' + +curl -sS -X POST \ + http://127.0.0.1:8080/v1/agents/sessions/$SESSION_ID/unarchive \ + -H 'X-Qwen-Tenant-Id: demo' \ + -H 'Idempotency-Key: unarchive-1' + +curl -sS -X DELETE \ + http://127.0.0.1:8080/v1/agents/sessions/$SESSION_ID \ + -H 'X-Qwen-Tenant-Id: demo' \ + -H 'Idempotency-Key: delete-1' +``` + +Archive and delete reject an active Turn. Rename waits for the Harness to +durably commit `session_metadata`; archive closes the Harness attachment and +requests Runtime drain (currently only an in-process retirement flag); delete closes it only when the Session was active +and always drains the binding; unarchive clears the Runtime retirement fence +and loads the Harness lazily on the next Turn. A failed external action leaves +a `PENDING` command that the same idempotency key can safely resume. The +command retains the pre-mutation state, so deleting an archived Session does +not require the already-closed Harness. A different lifecycle command is +blocked until it completes. + +Harness attachment uses strict create/load semantics: create returns `409` for +an existing private Session authority, while load returns `404` for a missing +authority and never initializes one. The Java connector attempts strict create for a new binding and loads on +conflict or uncertain creation outcome. A known existing binding only loads. +An in-memory Hosted attachment is bound to one normalized Store endpoint, +tenant, workspace, and Harness writer generation; an attach or cold-load race +with a different identity fails closed. + +Delete currently writes a public tombstone and hides the Session from get/list +responses. It does not physically erase the private journal or resources; +retention, writer sealing, and garbage collection remain future work. + +The Phase 1 schema has not been released. A development database created by an +older revision with `harness_session_id` must be recreated before running this +revision; the service fails Flyway validation instead of silently rewriting +existing public Session URLs. + +The public listener intentionally ignores end-user `Authorization`. The +optional Runtime Broker listener still requires a separate machine bearer and +must remain private. + +## Private Managed Session store + +Flyway V4 creates the private Managed Session journal and resource tables. The +internal routes under `/internal/managed-session-store/v1/**` provide +database-time writer leases and generations, head compare-and-set, +idempotent transaction receipts, exact JSONL transaction bytes, paged restore +reads, atomic checkpoint-pointer advancement, and transactional resources up +to 64 KiB. Callers must provide the trusted tenant header and a fresh Base64URL secret in +`X-Qwen-Managed-Writer-Token`; only its SHA-256 is persisted. Restore, +transaction-page, and resource reads require the same current, unexpired +writer secret. + +Restore transaction pages are bounded to 8 MiB of unencoded record bytes even +when the requested item limit is larger. Unknown head states, unsafe counters, +or missing transaction revisions fail closed as storage corruption. + +The routes are disabled by default. Enable them only on a private service +listener or trusted service network: + +```bash +export QWEN_MANAGED_AGENT_SESSION_STORE_ENABLED='true' +export QWEN_MANAGED_AGENT_SESSION_STORE_BASE_URL='http://127.0.0.1:8080' +export QWEN_MANAGED_AGENT_SESSION_STORE_WRITER_LEASE_DURATION='60s' +export QWEN_MANAGED_AGENT_WORKSPACE_ID='workspace-demo' +``` + +`QWEN_MANAGED_AGENT_SESSION_STORE_BASE_URL` must be reachable from the Hosted +Harness. When both the Harness and Store are enabled, Java includes a scoped +Store descriptor in each new private Hosted Session request. The ordinary +daemon rejects that descriptor, while the Hosted Harness uses the TypeScript +HTTP adapter and generates its own writer secret. The Store scope reuses +`QWEN_MANAGED_AGENT_WORKSPACE_ID`, so the public Session, private journal, and +Runtime binding have one `(tenantId, workspaceId, sessionId)` identity. Set +that ID explicitly when enabling the Session Store; if the Runtime Broker also +has an explicit ID, startup rejects a mismatch. + +This activates the durable create and cold-load paths for newly created Hosted +Sessions. The load path rebuilds the Harness state from the scoped Store and +does not require a Pod-local transcript. The Store boundary has an +independent-JVM crash/takeover proof against real MySQL. A deterministic +multi-process check also kills the real Java and Hosted Harness owners, deletes +their local homes, and proves that replacement owners complete a second Turn +with the first Turn's restored context. A separate recovery slice supports one +known pending tool execution: the replacement Harness starts or polls the +original Broker identity, commits its settled receipt as `results_ready`, and +Java replaces the Harness boot and public event epoch under the Turn dispatch +lease, durably records the replacement attachment watermark before invoking +checkpoint-bound continuation with the original public prompt identity, and +advances the cursor after the response. Unit, contract, and H2 coordinator/store +tests verify that this path does not resubmit the Prompt or replay the tool. Do +not advertise general automatic cross-Pod recovery yet: multi-tool recovery, +recovered cancellation, event/checkpoint reconstruction after a +mid-continuation Harness crash, the full multi-process in-flight failure matrix, +OSS-backed resources, and scheduler recovery are still pending. Resources +larger than 64 KiB fail with +`managed_session_oss_disabled` until the immutable OSS path is implemented. +Production deployments must add mTLS or equivalent service authentication; +the tenant and writer headers are scope and fencing inputs, not a substitute +for transport identity. Responses under the private prefix use +`Cache-Control: no-store`. + +## Full WebShell dual-path development entry + +The full WebShell can keep an ordinary Qwen daemon for its existing chat, +workspace, settings, and terminal surfaces while routing only the Managed +panel to this Spring service. Start an ordinary `qwen serve` on port 4170 in +addition to the private Hosted Harness used by Spring, then run from the +repository root: + +```bash +QWEN_DAEMON_URL=http://127.0.0.1:4170 \ +QWEN_MANAGED_AGENT_JAVA_URL=http://127.0.0.1:8080 \ + npm run dev:managed-agent-web +``` + +Open +`http://127.0.0.1:5174/?managed=1&managedProvider=java&tenant=local-java-demo`. +The standard WebShell entry proxies its existing routes to the ordinary daemon +and `/api/agent/web-shell/v1/**` to Spring. Use +`managedSession=` to deep-link a Managed Session. If the ordinary +daemon requires authentication, append its token as the usual `#token=...` +fragment. + +`managedProvider=java` and `tenant` are local-development conveniences and are +honored only by the Vite development entry. Production hosts should construct +`createJavaManagedAgentProvider(...)` themselves and pass it to +`WebShellWithProviders.managedAgentProvider`; a trusted upstream must derive +the tenant instead of trusting a browser query parameter. Products that do not +have an ordinary daemon can continue to render the exported +`ManagedAgentWebShell` directly. Neither browser mode receives the private +Harness or Runtime Broker credentials. + +## Embedded Runtime Broker + +The Broker starts before the first Hosted Harness connection, so the supported +startup order is Spring/Broker first, Hosted Harness second, traffic last. The +Harness SDK handshake is lazy and occurs on the first admitted Turn. + +For the single-node local-process provisioner, also set: + +```bash +export QWEN_MANAGED_AGENT_RUNTIME_BROKER_ENABLED='true' +export QWEN_MANAGED_AGENT_RUNTIME_BROKER_TOKEN='replace-me' +export QWEN_MANAGED_AGENT_RUNTIME_CREDENTIAL_KEY_ID='local-dev-v1' +export QWEN_MANAGED_AGENT_RUNTIME_CREDENTIAL_KEY='replace-with-base64-encoded-32-byte-key' +export QWEN_MANAGED_AGENT_WORKSPACE_CWD='/absolute/authorized/workspace' +export QWEN_MANAGED_AGENT_RUNTIME_STATE_DIRECTORY='/absolute/private/state' +export QWEN_MANAGED_AGENT_NODE_EXECUTABLE='/absolute/path/to/node' +export QWEN_MANAGED_AGENT_RUNTIME_WORKER_ENTRY='/absolute/path/to/dist/managed-runtime-worker.js' +export QWEN_MANAGED_AGENT_CLI_ENTRY='/absolute/path/to/dist/cli.js' +``` + +When `QWEN_MANAGED_AGENT_WORKSPACE_ID` is omitted, the server derives the same +16-character SHA-256 workspace ID that Qwen Code uses from the canonical +workspace path. An explicitly configured ID must match that value or startup +fails before traffic is accepted. + +The reserved Hosted Harness profile cannot yet connect to the Broker at +`http://127.0.0.1:4182`. When enabled, the embedded +Broker always uses the Spring `DataSource` and Flyway-managed Runtime tables; +it does not fall back to in-memory repositories. The credential key must decode +to exactly 32 bytes and protects persisted Runtime seeds and static Runtime +credentials with AES-256-GCM. The local-process adapter can recover the same +worker after a Java restart on the same host; multi-host scheduling and the +Kubernetes adapter's real-cluster fault matrix remain production gates. This +standalone reference resolves every accepted tenant to the one configured +workspace; a trusted tenant-authorized environment registry is still required +before using it as a multi-tenant production service. + +Build the container from the repository root: + +```bash +docker build -f packages/sdk-java/managed-agent-server/Dockerfile . +``` + +The stock image contains the Java control plane only. Use the static Runtime +provisioner, or provide a derived image/mount with Node.js and the Qwen worker +artifacts, before enabling the local-process provisioner in a container. + +## Managed Session Store verification + +Unit and H2 contract tests run with the normal Maven test phase. The optional +real-MySQL profile also verifies schema upgrade, exact bytes, public +Item/Snapshot projection, and the independent-JVM Managed Session Store +crash/takeover path: + +```bash +mvn -Pmysql-integration \ + -Dmysql.url='jdbc:mysql://127.0.0.1:3306/managed_agent_test' \ + -Dmysql.user=root \ + -Dmysql.password= \ + verify +``` + +Use a disposable database: the integration test creates and deletes fixture +rows within the selected schema. + +## Real-model end-to-end check + +The repository includes copied full-chain scripts for a future integration. +They cannot run against this split: the Hosted Harness profile rejects startup +and `dist/managed-runtime-worker.js` is not built. The commands below describe +the intended verification, not passing evidence for this PR. + +Build the required artifacts first, then run: + +```bash +npm run build && npm run bundle +mvn -f packages/sdk-java/qwencode/pom.xml -DskipTests -Dgpg.skip=true install +mvn -f packages/sdk-java/runtime-broker/pom.xml -DskipTests install +mvn -f packages/sdk-java/managed-agent-server/pom.xml clean package +npm run test:e2e:managed-agent-server -- --model moonshot/kimi-k3 +``` + +For the deterministic durable-owner failover check, use the same built +artifacts and run: + +```bash +npm run test:e2e:managed-session-failover +``` + +This mode uses a local fake model, completes one Turn, kills the Spring and +Hosted Harness process trees, deletes their old local homes, starts replacement +owners against the same MySQL store, and verifies that the second Turn sees the +first Turn's prompt and answer. + +To exercise an admitted in-flight Turn at the tool-intent boundary, run: + +```bash +npm run test:e2e:managed-inflight-failover +``` + +This mode holds the first Broker `:start` request after the Harness has durably +committed its `await_runtime` checkpoint, kills the original Spring and Hosted +Harness process trees, deletes their homes, and starts replacement owners. It +requires the replacement Harness to use the original `executionCallId`, execute +the physical tool exactly once, continue the original Prompt without replay, +and commit one public terminal event. + +Once the missing integration lands, a zero-delay run can check the real-model +path. A controlled cold-start delay can then test output before Runtime +readiness: + +```bash +npm run test:e2e:managed-agent-server -- \ + --model moonshot/kimi-k3 \ + --runtime-delay-ms 45000 +``` + +That run additionally requires the first model event to precede Runtime +readiness. Real provider TTFT varies, so the deterministic CI proof of the same +ordering remains `npx tsx scripts/run-managed-hosted-runtime-e2e.ts`, which +uses a controlled model server and a 15-second Runtime delay. + +The real-model check extracts only the selected model provider, its referenced +environment credential, the selected model, and the authentication policy +from the supplied settings file into a private temporary Qwen home. It does +not copy hooks, MCP servers, extensions, tools, permissions, or other provider +credentials. The runner removes that file, the MySQL data directory, +workspaces, and child processes on exit. Override the source with +`--settings /path/to/settings.json`; credentials are never printed by the +runner. diff --git a/packages/sdk-java/managed-agent-server/pom.xml b/packages/sdk-java/managed-agent-server/pom.xml new file mode 100644 index 00000000000..7ca94f5758e --- /dev/null +++ b/packages/sdk-java/managed-agent-server/pom.xml @@ -0,0 +1,125 @@ + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.5.16 + + + + com.alibaba + qwen-managed-agent-server + 0.1.0-alpha + Qwen Managed Agent Server + Standalone Spring control plane for Qwen Managed Agents. + + + 21 + 0.1.0-alpha + 0.1.0-alpha + 3.6.0 + 3.5.6 + + + + + com.alibaba + qwencode-sdk + ${qwencode-sdk.version} + + + com.alibaba + qwen-managed-runtime-broker + ${runtime-broker.version} + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-jdbc + + + org.springframework.boot + spring-boot-starter-validation + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-mysql + + + com.mysql + mysql-connector-j + runtime + + + com.h2database + h2 + test + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-checkstyle-plugin + ${checkstyle-maven-plugin.version} + + ${project.basedir}/../qwencode/checkstyle.xml + + + + + check + + + + + + + + + + mysql-integration + + + + org.apache.maven.plugins + maven-failsafe-plugin + ${maven-failsafe-plugin.version} + + + + integration-test + verify + + + + + + + + + diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/ManagedAgentServerApplication.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/ManagedAgentServerApplication.java new file mode 100644 index 00000000000..53b3af84d5d --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/ManagedAgentServerApplication.java @@ -0,0 +1,15 @@ +package com.alibaba.qwen.code.managedagent; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.scheduling.annotation.EnableScheduling; + +@SpringBootApplication +@ConfigurationPropertiesScan +@EnableScheduling +public class ManagedAgentServerApplication { + public static void main(String[] args) { + SpringApplication.run(ManagedAgentServerApplication.class, args); + } +} diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/ApiException.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/ApiException.java new file mode 100644 index 00000000000..d90e543a780 --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/ApiException.java @@ -0,0 +1,22 @@ +package com.alibaba.qwen.code.managedagent.api; + +import org.springframework.http.HttpStatus; + +public class ApiException extends RuntimeException { + private final HttpStatus status; + private final String code; + + public ApiException(HttpStatus status, String code, String message) { + super(message); + this.status = status; + this.code = code; + } + + public HttpStatus getStatus() { + return status; + } + + public String getCode() { + return code; + } +} diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/ApiExceptionHandler.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/ApiExceptionHandler.java new file mode 100644 index 00000000000..d0b0ae37437 --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/ApiExceptionHandler.java @@ -0,0 +1,61 @@ +package com.alibaba.qwen.code.managedagent.api; + +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.ServletRequestBindingException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.async.AsyncRequestNotUsableException; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.servlet.resource.NoResourceFoundException; + +@RestControllerAdvice +public class ApiExceptionHandler { + private static final Logger LOG = LoggerFactory.getLogger( + ApiExceptionHandler.class); + + @ExceptionHandler(ApiException.class) + public ResponseEntity> api(ApiException error) { + return response(error.getStatus(), error.getCode(), + error.getMessage()); + } + + @ExceptionHandler(AsyncRequestNotUsableException.class) + public void disconnectedClient() { + } + + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity> missingResource( + NoResourceFoundException error) { + return response(HttpStatus.NOT_FOUND, "not_found", + "The requested endpoint does not exist."); + } + + @ExceptionHandler({MethodArgumentNotValidException.class, + HttpMessageNotReadableException.class, + ServletRequestBindingException.class, + MethodArgumentTypeMismatchException.class, + IllegalArgumentException.class}) + public ResponseEntity> invalid(Exception error) { + return response(HttpStatus.BAD_REQUEST, "invalid_request", + "The request body is invalid."); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> unexpected(Exception error) { + LOG.error("Managed Agent request failed", error); + return response(HttpStatus.INTERNAL_SERVER_ERROR, "internal_error", + "The Managed Agent request failed."); + } + + private static ResponseEntity> response( + HttpStatus status, String code, String message) { + return ResponseEntity.status(status).body(Map.of( + "error", Map.of("code", code, "message", message))); + } +} diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/ApiModels.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/ApiModels.java new file mode 100644 index 00000000000..8d00be6706a --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/ApiModels.java @@ -0,0 +1,186 @@ +package com.alibaba.qwen.code.managedagent.api; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import java.util.List; +import java.util.Map; + +public final class ApiModels { + private ApiModels() { + } + + public record InputBlock(@NotBlank String type, + @NotBlank @Size(max = 1_000_000) String text) { + } + + public record CreateSessionRequest( + @JsonProperty("agent_id") @NotBlank @Size(max = 128) + String agentId, + @Size(max = 100) List<@Valid InputBlock> input, + Map metadata, + Boolean stream) { + } + + public record SessionEventRequest(@NotBlank String type, + @Size(max = 100) List<@Valid InputBlock> input, + @JsonProperty("turn_id") String turnId) { + } + + public record UpdateSessionRequest( + @NotBlank @Size(max = 256) String title) { + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + public record CommandAdmission( + @JsonProperty("session_id") String sessionId, + @JsonProperty("turn_id") String turnId, + String status, + boolean replayed) { + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + public record PublicTurn(@JsonProperty("id") String turnId, + @JsonProperty("object") String object, + @JsonProperty("session_id") String sessionId, + String status, + @JsonProperty("created_at") long createdAt, + @JsonProperty("completed_at") Long completedAt, + @JsonProperty("error_code") String errorCode) { + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + public record PublicSession(String id, String object, + @JsonProperty("agent_id") String agentId, + String status, + @JsonProperty("created_at") long createdAt, + @JsonProperty("updated_at") long updatedAt, + Map metadata, + @JsonProperty("active_turn") PublicTurn activeTurn, + @JsonProperty("last_event_id") long lastEventId) { + } + + public record DeletedSession(String id, String object, + boolean deleted) { + } + + public record PublicList(String object, List data, + @JsonProperty("has_more") boolean hasMore, + @JsonProperty("next_cursor") String nextCursor) { + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + public record PublicEvent(@JsonProperty("sequence") long sequence, + @JsonProperty("event_id") String eventId, + @JsonProperty("session_id") String sessionId, + @JsonProperty("turn_id") String turnId, + String type, + @JsonProperty("created_at") long createdAt, + Map data, + boolean terminal) { + } + + public record PublicContentPart(@JsonProperty("part_id") String partId, + String type, String text, + @JsonProperty("first_sequence") long firstSequence, + @JsonProperty("last_sequence") long lastSequence) { + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + public record PublicItem(String id, String object, + @JsonProperty("session_id") String sessionId, + @JsonProperty("turn_id") String turnId, String type, String role, + long revision, String status, List content, + Map attributes, + @JsonProperty("first_sequence") long firstSequence, + @JsonProperty("last_sequence") long lastSequence, + @JsonProperty("created_at") long createdAt, + @JsonProperty("updated_at") long updatedAt) { + } + + public record PublicItemList(String object, List data, + @JsonProperty("has_more") boolean hasMore, + @JsonProperty("next_cursor") String nextCursor, + @JsonProperty("snapshot_through_sequence") + long snapshotThroughSequence) { + } + + public record WebShellListRequest(String cursor, Integer limit) { + } + + public record WebShellSessionRequest(@NotBlank String sessionId) { + } + + public record WebShellTranscriptRequest(@NotBlank String sessionId, + String cursor, Integer limit) { + } + + public record WebShellStreamRequest(@NotBlank String sessionId, + Long afterSequence, Integer limit) { + } + + public record WebShellCreateRequest(String requestId, + @NotBlank String idempotencyKey, + @NotBlank @Size(max = 128) String agentId, + String environmentId, String title, + @Size(max = 100) List<@Valid InputBlock> input, + Map metadata) { + } + + public record WebShellSubmitRequest(String requestId, + @NotBlank String idempotencyKey, @NotBlank String sessionId, + @Size(max = 100) List<@Valid InputBlock> input, + Map metadata) { + } + + public record WebShellCancelRequest(String requestId, + @NotBlank String idempotencyKey, @NotBlank String sessionId, + @NotBlank String turnId) { + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + public record WebShellAdmission(String sessionId, String turnId, + String status, boolean replayed) { + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + public record WebShellTurn(String turnId, String sessionId, + String status, long submittedAt, Long completedAt, + String errorCode, Map usage) { + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + public record WebShellSession(String sessionId, String title, + String agentId, String status, long createdAt, long updatedAt, + WebShellTurn activeTurn, Object environment, long lastSequence) { + } + + public record WebShellPage(List data, String nextCursor, + boolean hasMore) { + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + public record WebShellEvent(long sequence, String eventId, + String sessionId, String turnId, String type, long createdAt, + Map data, boolean terminal) { + } + + public record WebShellContentPart(String partId, String type, String text, + long firstSequence, long lastSequence) { + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + public record WebShellItem(String itemId, String sessionId, + String turnId, String type, String role, String status, + List content, + Map attributes, long firstSequence, + long lastSequence, long createdAt, long updatedAt) { + } + + public record WebShellTranscript(List items, + List events, long coveredSequence, + String olderCursor, boolean hasMore, long lastSequence) { + } +} diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/ManagedSessionStoreController.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/ManagedSessionStoreController.java new file mode 100644 index 00000000000..341a534d0d3 --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/ManagedSessionStoreController.java @@ -0,0 +1,133 @@ +package com.alibaba.qwen.code.managedagent.api; + +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStore; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.AcquireWriterRequest; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.BlockRecoveryRequest; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.CommitReceipt; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.CommitTransactionRequest; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.RenewWriterRequest; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.RecoveryStateReceipt; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.RestoreHead; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.SealReceipt; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.SealWriterRequest; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.StoredResource; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.TransactionPage; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.WriterGrant; +import jakarta.validation.Valid; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@ConditionalOnProperty(prefix = "qwen.managed-agent.session-store", + name = "enabled", havingValue = "true") +@RequestMapping("/internal/managed-session-store/v1/sessions/{sessionId}") +public class ManagedSessionStoreController { + private final ManagedSessionStore store; + + public ManagedSessionStoreController(ManagedSessionStore store) { + this.store = store; + } + + @PostMapping("/writers:acquire") + public WriterGrant acquire(TenantContext tenant, + @PathVariable String sessionId, + @RequestHeader(ManagedSessionStoreModels.WRITER_TOKEN_HEADER) + String writerToken, + @Valid @RequestBody AcquireWriterRequest request) { + return store.acquireWriter(tenant.tenantId(), sessionId, + writerToken, request); + } + + @PostMapping("/writers:renew") + public WriterGrant renew(TenantContext tenant, + @PathVariable String sessionId, + @RequestHeader(ManagedSessionStoreModels.WRITER_TOKEN_HEADER) + String writerToken, + @Valid @RequestBody RenewWriterRequest request) { + return store.renewWriter(tenant.tenantId(), sessionId, + writerToken, request); + } + + @PostMapping("/writers:seal") + public SealReceipt seal(TenantContext tenant, + @PathVariable String sessionId, + @RequestHeader(ManagedSessionStoreModels.WRITER_TOKEN_HEADER) + String writerToken, + @Valid @RequestBody SealWriterRequest request) { + return store.sealWriter(tenant.tenantId(), sessionId, + writerToken, request); + } + + @PostMapping("/recovery:block") + public RecoveryStateReceipt blockRecovery(TenantContext tenant, + @PathVariable String sessionId, + @RequestHeader(ManagedSessionStoreModels.WRITER_TOKEN_HEADER) + String writerToken, + @Valid @RequestBody BlockRecoveryRequest request) { + return store.blockRecovery(tenant.tenantId(), sessionId, + writerToken, request); + } + + @PostMapping("/transactions:commit") + public CommitReceipt commit(TenantContext tenant, + @PathVariable String sessionId, + @RequestHeader(ManagedSessionStoreModels.WRITER_TOKEN_HEADER) + String writerToken, + @Valid @RequestBody CommitTransactionRequest request) { + return store.commit(tenant.tenantId(), sessionId, + writerToken, request); + } + + @GetMapping("/restore") + public RestoreHead restore(TenantContext tenant, + @PathVariable String sessionId, + @RequestHeader(ManagedSessionStoreModels.WRITER_TOKEN_HEADER) + String writerToken, + @RequestParam String workspaceId) { + return store.restore(tenant.tenantId(), workspaceId, sessionId, + writerToken); + } + + @GetMapping("/transactions") + public TransactionPage transactions(TenantContext tenant, + @PathVariable String sessionId, + @RequestHeader(ManagedSessionStoreModels.WRITER_TOKEN_HEADER) + String writerToken, + @RequestParam String workspaceId, + @RequestParam(defaultValue = "0") long afterRevision, + @RequestParam(defaultValue = "100") int limit) { + return store.transactions(tenant.tenantId(), workspaceId, + sessionId, writerToken, afterRevision, limit); + } + + @GetMapping(value = "/resources/{resourceId}", + produces = MediaType.APPLICATION_OCTET_STREAM_VALUE) + public ResponseEntity resource(TenantContext tenant, + @PathVariable String sessionId, + @PathVariable String resourceId, + @RequestHeader(ManagedSessionStoreModels.WRITER_TOKEN_HEADER) + String writerToken, + @RequestParam String workspaceId) { + StoredResource resource = store.readResource(tenant.tenantId(), + workspaceId, sessionId, resourceId, writerToken); + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_LENGTH, + Long.toString(resource.byteLength())) + .header("X-Qwen-Resource-Kind", resource.kind()) + .header("X-Qwen-Resource-Schema-Version", + Integer.toString(resource.schemaVersion())) + .header("X-Qwen-Resource-Digest", resource.digest()) + .body(resource.bytes()); + } +} diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/PublicAgentController.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/PublicAgentController.java new file mode 100644 index 00000000000..8e60f7e1dfe --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/PublicAgentController.java @@ -0,0 +1,195 @@ +package com.alibaba.qwen.code.managedagent.api; + +import com.alibaba.qwen.code.managedagent.api.ApiModels.CommandAdmission; +import com.alibaba.qwen.code.managedagent.api.ApiModels.DeletedSession; +import com.alibaba.qwen.code.managedagent.api.ApiModels.CreateSessionRequest; +import com.alibaba.qwen.code.managedagent.api.ApiModels.PublicEvent; +import com.alibaba.qwen.code.managedagent.api.ApiModels.PublicItemList; +import com.alibaba.qwen.code.managedagent.api.ApiModels.PublicList; +import com.alibaba.qwen.code.managedagent.api.ApiModels.PublicSession; +import com.alibaba.qwen.code.managedagent.api.ApiModels.SessionEventRequest; +import com.alibaba.qwen.code.managedagent.api.ApiModels.UpdateSessionRequest; +import com.alibaba.qwen.code.managedagent.service.ManagedAgentService; +import com.alibaba.qwen.code.managedagent.service.ManagedAgentService.SessionMutationResult; +import com.alibaba.qwen.code.managedagent.service.ManagedEventStreamService; +import jakarta.validation.Valid; +import java.util.List; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v1/agents/sessions") +public class PublicAgentController { + private final ManagedAgentService service; + private final ManagedEventStreamService streams; + + public PublicAgentController(ManagedAgentService service, + ManagedEventStreamService streams) { + this.service = service; + this.streams = streams; + } + + @PostMapping + public ResponseEntity create(TenantContext tenant, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody CreateSessionRequest request) { + if (Boolean.TRUE.equals(request.stream())) { + throw new ApiException(HttpStatus.BAD_REQUEST, + "unsupported_feature", + "Phase 1 streams through the Session events route."); + } + CommandAdmission admission = service.createSession( + tenant.tenantId(), idempotencyKey, request.agentId(), null, + request.metadata(), request.input()); + PublicSession session = service.getPublicSession(tenant.tenantId(), + admission.sessionId()); + return ResponseEntity.status(HttpStatus.ACCEPTED) + .header("X-Qwen-Idempotent-Replay", + Boolean.toString(admission.replayed())) + .body(session); + } + + @GetMapping + public PublicList list(TenantContext tenant, + @RequestParam(required = false) String cursor, + @RequestParam(defaultValue = "20") int limit) { + return service.listPublicSessions(tenant.tenantId(), cursor, limit); + } + + @GetMapping("/{sessionId}") + public PublicSession get(TenantContext tenant, + @PathVariable String sessionId) { + return service.getPublicSession(tenant.tenantId(), sessionId); + } + + @PatchMapping("/{sessionId}") + public ResponseEntity rename(TenantContext tenant, + @PathVariable String sessionId, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody UpdateSessionRequest request) { + return mutation(service.renameSession(tenant.tenantId(), + idempotencyKey, sessionId, request.title())); + } + + @PostMapping("/{sessionId}/archive") + public ResponseEntity archive(TenantContext tenant, + @PathVariable String sessionId, + @RequestHeader("Idempotency-Key") String idempotencyKey) { + return mutation(service.archiveSession(tenant.tenantId(), + idempotencyKey, sessionId)); + } + + @PostMapping("/{sessionId}/unarchive") + public ResponseEntity unarchive(TenantContext tenant, + @PathVariable String sessionId, + @RequestHeader("Idempotency-Key") String idempotencyKey) { + return mutation(service.unarchiveSession(tenant.tenantId(), + idempotencyKey, sessionId)); + } + + @DeleteMapping("/{sessionId}") + public ResponseEntity delete(TenantContext tenant, + @PathVariable String sessionId, + @RequestHeader("Idempotency-Key") String idempotencyKey) { + SessionMutationResult result = service.deleteSession( + tenant.tenantId(), idempotencyKey, sessionId); + return ResponseEntity.ok() + .header("X-Qwen-Idempotent-Replay", + Boolean.toString(result.replayed())) + .body(result.body()); + } + + @PostMapping("/{sessionId}/events") + public ResponseEntity postEvent(TenantContext tenant, + @PathVariable String sessionId, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody SessionEventRequest request) { + CommandAdmission admission; + if ("agent.session.input.message".equals(request.type())) { + admission = service.submitTurn(tenant.tenantId(), + idempotencyKey, sessionId, request.input()); + } else if ("agent.session.cancel".equals(request.type())) { + if (request.turnId() == null || request.turnId().isBlank()) { + throw new ApiException(HttpStatus.BAD_REQUEST, + "turn_id_required", + "Cancellation requires turn_id."); + } + admission = service.cancelTurn(tenant.tenantId(), + idempotencyKey, sessionId, request.turnId()); + } else { + throw new ApiException(HttpStatus.BAD_REQUEST, + "unsupported_event", + "Phase 1 accepts input message and cancellation events."); + } + return ResponseEntity.accepted().body(admission); + } + + @GetMapping(value = "/{sessionId}/events", + produces = {MediaType.APPLICATION_JSON_VALUE, + MediaType.TEXT_EVENT_STREAM_VALUE}) + public Object events(TenantContext tenant, + @PathVariable String sessionId, + @RequestParam(defaultValue = "false") boolean stream, + @RequestParam(defaultValue = "0") long after, + @RequestParam(defaultValue = "100") int limit, + @RequestHeader(value = "Last-Event-ID", required = false) + String lastEventId, + @RequestHeader(value = HttpHeaders.ACCEPT, required = false) + String accept) { + long cursor = parseSequence(lastEventId, after); + boolean wantsStream = stream || (accept != null + && accept.contains(MediaType.TEXT_EVENT_STREAM_VALUE)); + if (wantsStream) { + return streams.publicStream(tenant.tenantId(), sessionId, + cursor); + } + List events = service.publicEvents(tenant.tenantId(), + sessionId, cursor, limit); + return new PublicList<>("list", events, false, null); + } + + @GetMapping("/{sessionId}/items") + public PublicItemList items(TenantContext tenant, + @PathVariable String sessionId, + @RequestParam(defaultValue = "0") long after, + @RequestParam(defaultValue = "20") int limit) { + return service.listPublicItems(tenant.tenantId(), sessionId, after, + limit); + } + + private static long parseSequence(String header, long fallback) { + if (header == null || header.isBlank()) { + return fallback; + } + try { + long value = Long.parseLong(header); + if (value < 0) { + throw new NumberFormatException(); + } + return value; + } catch (NumberFormatException error) { + throw new ApiException(HttpStatus.BAD_REQUEST, + "invalid_event_cursor", "Last-Event-ID is invalid."); + } + } + + private static ResponseEntity mutation( + SessionMutationResult result) { + return ResponseEntity.ok() + .header("X-Qwen-Idempotent-Replay", + Boolean.toString(result.replayed())) + .body(result.body()); + } +} diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/TenantContext.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/TenantContext.java new file mode 100644 index 00000000000..c39b13e0e60 --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/TenantContext.java @@ -0,0 +1,4 @@ +package com.alibaba.qwen.code.managedagent.api; + +public record TenantContext(String tenantId) { +} diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/TenantContextArgumentResolver.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/TenantContextArgumentResolver.java new file mode 100644 index 00000000000..6b2f914dde8 --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/TenantContextArgumentResolver.java @@ -0,0 +1,35 @@ +package com.alibaba.qwen.code.managedagent.api; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@Component +public class TenantContextArgumentResolver + implements HandlerMethodArgumentResolver { + @Override + public boolean supportsParameter(MethodParameter parameter) { + return TenantContext.class.equals(parameter.getParameterType()); + } + + @Override + public Object resolveArgument(MethodParameter parameter, + ModelAndViewContainer container, NativeWebRequest request, + WebDataBinderFactory binderFactory) { + HttpServletRequest servletRequest = request.getNativeRequest( + HttpServletRequest.class); + if (servletRequest == null) { + throw new IllegalStateException("HTTP request is unavailable"); + } + Object context = servletRequest.getAttribute( + TenantContextFilter.ATTRIBUTE); + if (!(context instanceof TenantContext)) { + throw new IllegalStateException("Tenant context is unavailable"); + } + return context; + } +} diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/TenantContextFilter.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/TenantContextFilter.java new file mode 100644 index 00000000000..94fa275e634 --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/TenantContextFilter.java @@ -0,0 +1,61 @@ +package com.alibaba.qwen.code.managedagent.api; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Map; +import java.util.regex.Pattern; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +public class TenantContextFilter extends OncePerRequestFilter { + public static final String HEADER = "X-Qwen-Tenant-Id"; + public static final String ATTRIBUTE = TenantContext.class.getName(); + private static final String MANAGED_SESSION_STORE_PREFIX = + "/internal/managed-session-store/v1/"; + private static final Pattern TENANT_PATTERN = Pattern.compile( + "^[A-Za-z0-9._:-]{1,128}$"); + private final ObjectMapper objectMapper; + + public TenantContextFilter(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String path = request.getRequestURI(); + return !path.startsWith("/v1/agents/") + && !path.startsWith("/api/agent/web-shell/v1/") + && !path.startsWith(MANAGED_SESSION_STORE_PREFIX); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + if (request.getRequestURI() + .startsWith(MANAGED_SESSION_STORE_PREFIX)) { + response.setHeader(HttpHeaders.CACHE_CONTROL, "no-store"); + } + String tenantId = request.getHeader(HEADER); + if (tenantId == null || !TENANT_PATTERN.matcher(tenantId).matches()) { + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + objectMapper.writeValue(response.getOutputStream(), Map.of( + "error", Map.of( + "code", "invalid_tenant", + "message", HEADER + + " is required and must contain 1-128" + + " safe characters."))); + return; + } + request.setAttribute(ATTRIBUTE, new TenantContext(tenantId)); + chain.doFilter(request, response); + } +} diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/WebShellAgentController.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/WebShellAgentController.java new file mode 100644 index 00000000000..a6e237918ff --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/api/WebShellAgentController.java @@ -0,0 +1,130 @@ +package com.alibaba.qwen.code.managedagent.api; + +import com.alibaba.qwen.code.managedagent.api.ApiModels.CommandAdmission; +import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellAdmission; +import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellCancelRequest; +import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellCreateRequest; +import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellListRequest; +import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellPage; +import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellSession; +import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellSessionRequest; +import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellStreamRequest; +import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellSubmitRequest; +import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellTranscript; +import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellTranscriptRequest; +import com.alibaba.qwen.code.managedagent.service.ManagedAgentService; +import com.alibaba.qwen.code.managedagent.service.ManagedEventStreamService; +import jakarta.validation.Valid; +import java.util.Map; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +@RestController +@RequestMapping("/api/agent/web-shell/v1") +public class WebShellAgentController { + private final ManagedAgentService service; + private final ManagedEventStreamService streams; + + public WebShellAgentController(ManagedAgentService service, + ManagedEventStreamService streams) { + this.service = service; + this.streams = streams; + } + + @PostMapping("/sessions/query") + public WebShellPage list(TenantContext tenant, + @RequestBody WebShellListRequest request) { + return service.listWebShellSessions(tenant.tenantId(), + request.cursor(), request.limit() == null ? 20 + : request.limit()); + } + + @PostMapping("/sessions/get") + public WebShellSession get(TenantContext tenant, + @Valid @RequestBody WebShellSessionRequest request) { + return service.getWebShellSession(tenant.tenantId(), + request.sessionId()); + } + + @PostMapping("/transcript/query") + public WebShellTranscript transcript(TenantContext tenant, + @Valid @RequestBody WebShellTranscriptRequest request) { + return service.transcript(tenant.tenantId(), request.sessionId(), + request.cursor(), + request.limit() == null ? 100 : request.limit()); + } + + @PostMapping(value = "/events/stream", + produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter stream(TenantContext tenant, + @Valid @RequestBody WebShellStreamRequest request) { + long after = request.afterSequence() == null ? 0 + : request.afterSequence(); + return streams.webShellStream(tenant.tenantId(), request.sessionId(), + after); + } + + @PostMapping("/sessions/create") + public ResponseEntity create(TenantContext tenant, + @Valid @RequestBody WebShellCreateRequest request) { + if (request.environmentId() != null + && !request.environmentId().isBlank()) { + throw new ApiException(HttpStatus.BAD_REQUEST, + "unsupported_feature", + "Standalone Phase 1 has no environment templates."); + } + validateTraceMetadata(request.metadata()); + WebShellAdmission admission = webShell(service.createSession( + tenant.tenantId(), + request.idempotencyKey(), request.agentId(), request.title(), + null, request.input())); + return ResponseEntity.accepted().body(admission); + } + + @PostMapping("/turns/submit") + public ResponseEntity submit(TenantContext tenant, + @Valid @RequestBody WebShellSubmitRequest request) { + validateTraceMetadata(request.metadata()); + WebShellAdmission admission = webShell(service.submitTurn( + tenant.tenantId(), + request.idempotencyKey(), request.sessionId(), + request.input())); + return ResponseEntity.accepted().body(admission); + } + + @PostMapping("/turns/cancel") + public ResponseEntity cancel(TenantContext tenant, + @Valid @RequestBody WebShellCancelRequest request) { + WebShellAdmission admission = webShell(service.cancelTurn( + tenant.tenantId(), + request.idempotencyKey(), request.sessionId(), + request.turnId())); + return ResponseEntity.accepted().body(admission); + } + + private static void validateTraceMetadata(Map metadata) { + if (metadata == null || metadata.isEmpty()) { + return; + } + Object clientId = metadata.get("clientId"); + if (metadata.size() != 1 || !(clientId instanceof String) + || ((String) clientId).isBlank() + || ((String) clientId).length() > 128) { + throw new ApiException(HttpStatus.BAD_REQUEST, + "unsupported_feature", + "WebShell metadata accepts clientId only."); + } + } + + private static WebShellAdmission webShell(CommandAdmission admission) { + return new WebShellAdmission(admission.sessionId(), + admission.turnId(), admission.status(), + admission.replayed()); + } +} diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/config/ExecutionConfiguration.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/config/ExecutionConfiguration.java new file mode 100644 index 00000000000..420a1c6a6b7 --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/config/ExecutionConfiguration.java @@ -0,0 +1,20 @@ +package com.alibaba.qwen.code.managedagent.config; + +import java.time.Clock; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class ExecutionConfiguration { + @Bean + public Clock managedAgentClock() { + return Clock.systemUTC(); + } + + @Bean(destroyMethod = "close") + public ExecutorService managedAgentExecutor() { + return Executors.newVirtualThreadPerTaskExecutor(); + } +} diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/config/HarnessConfiguration.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/config/HarnessConfiguration.java new file mode 100644 index 00000000000..9e0842e9a29 --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/config/HarnessConfiguration.java @@ -0,0 +1,26 @@ +package com.alibaba.qwen.code.managedagent.config; + +import com.alibaba.qwen.code.managedagent.harness.HarnessConnector; +import com.alibaba.qwen.code.managedagent.harness.QwenHostedHarnessConnector; +import com.alibaba.qwen.code.managedagent.harness.UnavailableHarnessConnector; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class HarnessConfiguration { + @Bean(destroyMethod = "close") + @ConditionalOnProperty(prefix = "qwen.managed-agent.harness", + name = "enabled", havingValue = "true") + public HarnessConnector hostedHarnessConnector( + ManagedAgentProperties properties) { + return new QwenHostedHarnessConnector(properties); + } + + @Bean + @ConditionalOnMissingBean(HarnessConnector.class) + public HarnessConnector unavailableHarnessConnector() { + return new UnavailableHarnessConnector(); + } +} diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/config/ManagedAgentProperties.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/config/ManagedAgentProperties.java new file mode 100644 index 00000000000..d0fc19e9a99 --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/config/ManagedAgentProperties.java @@ -0,0 +1,544 @@ +package com.alibaba.qwen.code.managedagent.config; + +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties("qwen.managed-agent") +public class ManagedAgentProperties { + private final Harness harness = new Harness(); + private final SessionStore sessionStore = new SessionStore(); + private final Dispatch dispatch = new Dispatch(); + private final Events events = new Events(); + private final RuntimeBroker runtimeBroker = new RuntimeBroker(); + + public Harness getHarness() { + return harness; + } + + public SessionStore getSessionStore() { + return sessionStore; + } + + public Dispatch getDispatch() { + return dispatch; + } + + public Events getEvents() { + return events; + } + + public RuntimeBroker getRuntimeBroker() { + return runtimeBroker; + } + + public static class Harness { + private boolean enabled; + private String baseUrl = "http://127.0.0.1:4170"; + private String token = ""; + private String capabilityDigest = ""; + private String approvalMode = "yolo"; + private Duration connectTimeout = Duration.ofSeconds(5); + private Duration requestTimeout = Duration.ofSeconds(30); + private Duration heartbeatInterval = Duration.ofSeconds(30); + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getBaseUrl() { + return baseUrl; + } + + public void setBaseUrl(String baseUrl) { + this.baseUrl = baseUrl; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public String getCapabilityDigest() { + return capabilityDigest; + } + + public void setCapabilityDigest(String capabilityDigest) { + this.capabilityDigest = capabilityDigest; + } + + public String getApprovalMode() { + return approvalMode; + } + + public void setApprovalMode(String approvalMode) { + this.approvalMode = approvalMode; + } + + public Duration getConnectTimeout() { + return connectTimeout; + } + + public void setConnectTimeout(Duration connectTimeout) { + this.connectTimeout = connectTimeout; + } + + public Duration getRequestTimeout() { + return requestTimeout; + } + + public void setRequestTimeout(Duration requestTimeout) { + this.requestTimeout = requestTimeout; + } + + public Duration getHeartbeatInterval() { + return heartbeatInterval; + } + + public void setHeartbeatInterval(Duration heartbeatInterval) { + this.heartbeatInterval = heartbeatInterval; + } + } + + public static class SessionStore { + private boolean enabled; + private String baseUrl = ""; + private String workspaceId = ""; + private Duration writerLeaseDuration = Duration.ofSeconds(60); + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getBaseUrl() { + return baseUrl; + } + + public void setBaseUrl(String baseUrl) { + this.baseUrl = baseUrl; + } + + public String getWorkspaceId() { + return workspaceId; + } + + public void setWorkspaceId(String workspaceId) { + this.workspaceId = workspaceId; + } + + public Duration getWriterLeaseDuration() { + return writerLeaseDuration; + } + + public void setWriterLeaseDuration(Duration writerLeaseDuration) { + this.writerLeaseDuration = writerLeaseDuration; + } + } + + public static class Dispatch { + private Duration scanDelay = Duration.ofSeconds(1); + private Duration leaseDuration = Duration.ofSeconds(60); + private Duration leaseRenewInterval = Duration.ofSeconds(20); + private Duration retryInitialDelay = Duration.ofSeconds(1); + private Duration retryMaxDelay = Duration.ofMinutes(1); + private int maxPreAdmissionRetries = 5; + + public Duration getScanDelay() { + return scanDelay; + } + + public void setScanDelay(Duration scanDelay) { + this.scanDelay = scanDelay; + } + + public Duration getLeaseDuration() { + return leaseDuration; + } + + public void setLeaseDuration(Duration leaseDuration) { + this.leaseDuration = leaseDuration; + } + + public Duration getLeaseRenewInterval() { + return leaseRenewInterval; + } + + public void setLeaseRenewInterval(Duration leaseRenewInterval) { + this.leaseRenewInterval = leaseRenewInterval; + } + + public Duration getRetryInitialDelay() { + return retryInitialDelay; + } + + public void setRetryInitialDelay(Duration retryInitialDelay) { + this.retryInitialDelay = retryInitialDelay; + } + + public Duration getRetryMaxDelay() { + return retryMaxDelay; + } + + public void setRetryMaxDelay(Duration retryMaxDelay) { + this.retryMaxDelay = retryMaxDelay; + } + + public int getMaxPreAdmissionRetries() { + return maxPreAdmissionRetries; + } + + public void setMaxPreAdmissionRetries(int maxPreAdmissionRetries) { + this.maxPreAdmissionRetries = maxPreAdmissionRetries; + } + } + + public static class Events { + private Duration pollInterval = Duration.ofSeconds(5); + private Duration heartbeatInterval = Duration.ofSeconds(15); + private Duration streamTimeout = Duration.ofMinutes(30); + private Duration batchInterval = Duration.ofMillis(75); + private int batchMaxEvents = 64; + private int batchMaxBytes = 65536; + + public Duration getPollInterval() { + return pollInterval; + } + + public void setPollInterval(Duration pollInterval) { + this.pollInterval = pollInterval; + } + + public Duration getHeartbeatInterval() { + return heartbeatInterval; + } + + public void setHeartbeatInterval(Duration heartbeatInterval) { + this.heartbeatInterval = heartbeatInterval; + } + + public Duration getStreamTimeout() { + return streamTimeout; + } + + public void setStreamTimeout(Duration streamTimeout) { + this.streamTimeout = streamTimeout; + } + + public Duration getBatchInterval() { + return batchInterval; + } + + public void setBatchInterval(Duration batchInterval) { + this.batchInterval = batchInterval; + } + + public int getBatchMaxEvents() { + return batchMaxEvents; + } + + public void setBatchMaxEvents(int batchMaxEvents) { + this.batchMaxEvents = batchMaxEvents; + } + + public int getBatchMaxBytes() { + return batchMaxBytes; + } + + public void setBatchMaxBytes(int batchMaxBytes) { + this.batchMaxBytes = batchMaxBytes; + } + } + + public static class RuntimeBroker { + private boolean enabled; + private String host = "127.0.0.1"; + private int port = 4182; + private String token = ""; + private String provisioner = "local-process"; + private String workspaceId = ""; + private String workspaceGeneration = "1"; + private String workspaceCwd = ""; + private String isolationClass = "session"; + private String stateDirectory = ""; + private String credentialKeyId = ""; + private String credentialKey = ""; + private String nodeExecutable = ""; + private String workerEntry = ""; + private String cliEntry = ""; + private String kubernetesApiServer = + "https://kubernetes.default.svc"; + private String kubernetesTokenFile = + "/var/run/secrets/kubernetes.io/serviceaccount/token"; + private String kubernetesCaFile = + "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"; + private String kubernetesClusterUid = ""; + private String kubernetesNamespace = "qwen-runtimes"; + private String kubernetesImage = ""; + private int kubernetesPort = 4190; + private String kubernetesServiceAccountName = ""; + private String kubernetesWorkspaceClaimName = ""; + private String staticEndpoint = ""; + private String staticToken = ""; + private String staticRuntimeInstanceId = "standalone-runtime"; + private String staticLeaseId = "standalone-lease"; + private long staticEpoch = 1; + private Map environment = new LinkedHashMap<>(); + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getHost() { + return host; + } + + public void setHost(String host) { + this.host = host; + } + + public int getPort() { + return port; + } + + public void setPort(int port) { + this.port = port; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public String getProvisioner() { + return provisioner; + } + + public void setProvisioner(String provisioner) { + this.provisioner = provisioner; + } + + public String getWorkspaceId() { + return workspaceId; + } + + public void setWorkspaceId(String workspaceId) { + this.workspaceId = workspaceId; + } + + public String getWorkspaceGeneration() { + return workspaceGeneration; + } + + public void setWorkspaceGeneration(String workspaceGeneration) { + this.workspaceGeneration = workspaceGeneration; + } + + public String getWorkspaceCwd() { + return workspaceCwd; + } + + public void setWorkspaceCwd(String workspaceCwd) { + this.workspaceCwd = workspaceCwd; + } + + public String getIsolationClass() { + return isolationClass; + } + + public void setIsolationClass(String isolationClass) { + this.isolationClass = isolationClass; + } + + public String getStateDirectory() { + return stateDirectory; + } + + public void setStateDirectory(String stateDirectory) { + this.stateDirectory = stateDirectory; + } + + public String getCredentialKeyId() { + return credentialKeyId; + } + + public void setCredentialKeyId(String credentialKeyId) { + this.credentialKeyId = credentialKeyId; + } + + public String getCredentialKey() { + return credentialKey; + } + + public void setCredentialKey(String credentialKey) { + this.credentialKey = credentialKey; + } + + public String getNodeExecutable() { + return nodeExecutable; + } + + public void setNodeExecutable(String nodeExecutable) { + this.nodeExecutable = nodeExecutable; + } + + public String getWorkerEntry() { + return workerEntry; + } + + public void setWorkerEntry(String workerEntry) { + this.workerEntry = workerEntry; + } + + public String getCliEntry() { + return cliEntry; + } + + public void setCliEntry(String cliEntry) { + this.cliEntry = cliEntry; + } + + public String getKubernetesApiServer() { + return kubernetesApiServer; + } + + public void setKubernetesApiServer(String kubernetesApiServer) { + this.kubernetesApiServer = kubernetesApiServer; + } + + public String getKubernetesTokenFile() { + return kubernetesTokenFile; + } + + public void setKubernetesTokenFile(String kubernetesTokenFile) { + this.kubernetesTokenFile = kubernetesTokenFile; + } + + public String getKubernetesCaFile() { + return kubernetesCaFile; + } + + public void setKubernetesCaFile(String kubernetesCaFile) { + this.kubernetesCaFile = kubernetesCaFile; + } + + public String getKubernetesClusterUid() { + return kubernetesClusterUid; + } + + public void setKubernetesClusterUid(String kubernetesClusterUid) { + this.kubernetesClusterUid = kubernetesClusterUid; + } + + public String getKubernetesNamespace() { + return kubernetesNamespace; + } + + public void setKubernetesNamespace(String kubernetesNamespace) { + this.kubernetesNamespace = kubernetesNamespace; + } + + public String getKubernetesImage() { + return kubernetesImage; + } + + public void setKubernetesImage(String kubernetesImage) { + this.kubernetesImage = kubernetesImage; + } + + public int getKubernetesPort() { + return kubernetesPort; + } + + public void setKubernetesPort(int kubernetesPort) { + this.kubernetesPort = kubernetesPort; + } + + public String getKubernetesServiceAccountName() { + return kubernetesServiceAccountName; + } + + public void setKubernetesServiceAccountName( + String kubernetesServiceAccountName) { + this.kubernetesServiceAccountName = + kubernetesServiceAccountName; + } + + public String getKubernetesWorkspaceClaimName() { + return kubernetesWorkspaceClaimName; + } + + public void setKubernetesWorkspaceClaimName( + String kubernetesWorkspaceClaimName) { + this.kubernetesWorkspaceClaimName = + kubernetesWorkspaceClaimName; + } + + public String getStaticEndpoint() { + return staticEndpoint; + } + + public void setStaticEndpoint(String staticEndpoint) { + this.staticEndpoint = staticEndpoint; + } + + public String getStaticToken() { + return staticToken; + } + + public void setStaticToken(String staticToken) { + this.staticToken = staticToken; + } + + public String getStaticRuntimeInstanceId() { + return staticRuntimeInstanceId; + } + + public void setStaticRuntimeInstanceId( + String staticRuntimeInstanceId) { + this.staticRuntimeInstanceId = staticRuntimeInstanceId; + } + + public String getStaticLeaseId() { + return staticLeaseId; + } + + public void setStaticLeaseId(String staticLeaseId) { + this.staticLeaseId = staticLeaseId; + } + + public long getStaticEpoch() { + return staticEpoch; + } + + public void setStaticEpoch(long staticEpoch) { + this.staticEpoch = staticEpoch; + } + + public Map getEnvironment() { + return environment; + } + + public void setEnvironment(Map environment) { + this.environment = environment; + } + } +} diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/config/RuntimeBrokerConfiguration.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/config/RuntimeBrokerConfiguration.java new file mode 100644 index 00000000000..9cbbf9bbb8f --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/config/RuntimeBrokerConfiguration.java @@ -0,0 +1,104 @@ +package com.alibaba.qwen.code.managedagent.config; + +import com.alibaba.qwen.code.managedagent.service.EmbeddedRuntimeBroker; +import com.alibaba.qwen.code.managedagent.service.RuntimeWarmer; +import com.alibaba.qwen.code.managedagent.store.AgentStateStore; +import com.alibaba.qwen.code.runtimebroker.AesGcmSecretProtector; +import com.alibaba.qwen.code.runtimebroker.JdbcRuntimeBindingRepository; +import com.alibaba.qwen.code.runtimebroker.JdbcRuntimeSessionRepository; +import com.alibaba.qwen.code.runtimebroker.JdbcToolExecutionRepository; +import com.alibaba.qwen.code.runtimebroker.RuntimeBindingRepository; +import com.alibaba.qwen.code.runtimebroker.RuntimeSessionRepository; +import com.alibaba.qwen.code.runtimebroker.SecretProtector; +import com.alibaba.qwen.code.runtimebroker.ToolExecutionRepository; +import java.util.concurrent.CompletableFuture; +import javax.sql.DataSource; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RuntimeBrokerConfiguration { + @Bean + @ConditionalOnProperty(prefix = "qwen.managed-agent.runtime-broker", + name = "enabled", havingValue = "true") + public SecretProtector runtimeCredentialProtector( + ManagedAgentProperties properties) { + ManagedAgentProperties.RuntimeBroker broker = + properties.getRuntimeBroker(); + require(broker.getCredentialKeyId(), + "Runtime Broker credential key ID"); + require(broker.getCredentialKey(), + "Runtime Broker credential key"); + return AesGcmSecretProtector.fromBase64( + broker.getCredentialKeyId(), broker.getCredentialKey()); + } + + @Bean + @ConditionalOnProperty(prefix = "qwen.managed-agent.runtime-broker", + name = "enabled", havingValue = "true") + public RuntimeBindingRepository runtimeBindingRepository( + DataSource dataSource, SecretProtector secretProtector) { + return new JdbcRuntimeBindingRepository(dataSource, secretProtector); + } + + @Bean + @ConditionalOnProperty(prefix = "qwen.managed-agent.runtime-broker", + name = "enabled", havingValue = "true") + public RuntimeSessionRepository runtimeSessionRepository( + DataSource dataSource) { + return new JdbcRuntimeSessionRepository(dataSource); + } + + @Bean + @ConditionalOnProperty(prefix = "qwen.managed-agent.runtime-broker", + name = "enabled", havingValue = "true") + public ToolExecutionRepository toolExecutionRepository( + DataSource dataSource) { + return new JdbcToolExecutionRepository(dataSource); + } + + @Bean(destroyMethod = "close") + @ConditionalOnProperty(prefix = "qwen.managed-agent.runtime-broker", + name = "enabled", havingValue = "true") + public EmbeddedRuntimeBroker embeddedRuntimeBroker( + AgentStateStore store, ManagedAgentProperties properties, + RuntimeBindingRepository bindingRepository, + RuntimeSessionRepository sessionRepository, + ToolExecutionRepository executionRepository) { + return new EmbeddedRuntimeBroker(store, properties, + bindingRepository, sessionRepository, executionRepository); + } + + @Bean + @ConditionalOnMissingBean(RuntimeWarmer.class) + public RuntimeWarmer runtimeWarmer() { + return new RuntimeWarmer() { + @Override + public boolean isEnabled() { + return false; + } + + @Override + public CompletableFuture warm(String sessionId) { + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture drain(String sessionId) { + return CompletableFuture.completedFuture(null); + } + + @Override + public void resume(String sessionId) { + } + }; + } + + private static void require(String value, String name) { + if (value == null || value.isBlank()) { + throw new IllegalStateException(name + " is required"); + } + } +} diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/config/WebConfiguration.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/config/WebConfiguration.java new file mode 100644 index 00000000000..cf9a7c02f4a --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/config/WebConfiguration.java @@ -0,0 +1,22 @@ +package com.alibaba.qwen.code.managedagent.config; + +import com.alibaba.qwen.code.managedagent.api.TenantContextArgumentResolver; +import java.util.List; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfiguration implements WebMvcConfigurer { + private final TenantContextArgumentResolver tenantResolver; + + public WebConfiguration(TenantContextArgumentResolver tenantResolver) { + this.tenantResolver = tenantResolver; + } + + @Override + public void addArgumentResolvers( + List resolvers) { + resolvers.add(tenantResolver); + } +} diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/HarnessConnector.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/HarnessConnector.java new file mode 100644 index 00000000000..0b42e73bdf7 --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/HarnessConnector.java @@ -0,0 +1,74 @@ +package com.alibaba.qwen.code.managedagent.harness; + +import com.alibaba.qwen.code.daemon.HarnessRuntimeRecovery; +import java.util.List; +import java.util.Map; + +public interface HarnessConnector extends AutoCloseable { + boolean isAvailable(); + + Attachment createOrLoad(String tenantId, String sessionId, + boolean loadExisting); + + default Attachment createOrLoad(String tenantId, String sessionId, + boolean loadExisting, boolean passiveManagedRuntimeRecovery) { + return createOrLoad(tenantId, sessionId, loadExisting); + } + + Admission submit(String tenantId, String sessionId, String promptId, + List> input, String payloadDigest); + + default Admission continueManagedRuntime(String tenantId, + String sessionId, String promptId, String checkpointId, + String activationId) { + throw new UnsupportedOperationException( + "Managed Runtime continuation is unavailable"); + } + + default Admission cancelManagedRuntime(String tenantId, String sessionId, + String promptId, String checkpointId, String activationId) { + throw new UnsupportedOperationException( + "Managed Runtime cancellation is unavailable"); + } + + SourceStream stream(String tenantId, String sessionId, long lastEventId, + String eventEpoch); + + void cancel(String tenantId, String sessionId); + + void rename(String tenantId, String sessionId, String title); + + void closeSession(String tenantId, String sessionId); + + @Override + default void close() { + } + + record Attachment(String bootId, HarnessRuntimeRecovery runtimeRecovery, + Long lastEventId, String eventEpoch) { + public Attachment(String bootId) { + this(bootId, null, null, null); + } + + public Attachment(String bootId, + HarnessRuntimeRecovery runtimeRecovery) { + this(bootId, runtimeRecovery, null, null); + } + } + + record Admission(long lastEventId, String eventEpoch) { + } + + record SourceEvent(Long id, String type, Object data, String promptId, + Map metadata) { + } + + interface SourceStream extends AutoCloseable { + String eventEpoch(); + + SourceEvent next(); + + @Override + void close(); + } +} diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java new file mode 100644 index 00000000000..9f9f2b6f80b --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnector.java @@ -0,0 +1,272 @@ +package com.alibaba.qwen.code.managedagent.harness; + +import com.alibaba.qwen.code.daemon.CancelManagedRuntime; +import com.alibaba.qwen.code.daemon.CreateHarnessSession; +import com.alibaba.qwen.code.daemon.DaemonApprovalMode; +import com.alibaba.qwen.code.daemon.DaemonEvent; +import com.alibaba.qwen.code.daemon.DaemonHttpException; +import com.alibaba.qwen.code.daemon.HarnessEventStream; +import com.alibaba.qwen.code.daemon.HarnessSessionRef; +import com.alibaba.qwen.code.daemon.HostedHarnessClient; +import com.alibaba.qwen.code.daemon.LoadHarnessSession; +import com.alibaba.qwen.code.daemon.ManagedSessionStoreConnection; +import com.alibaba.qwen.code.daemon.PromptReceipt; +import com.alibaba.qwen.code.daemon.SessionCreationOutcomeUnknownException; +import com.alibaba.qwen.code.daemon.StreamHarnessEvents; +import com.alibaba.qwen.code.daemon.SubmitHarnessTurn; +import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties; +import java.net.URI; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public class QwenHostedHarnessConnector implements HarnessConnector { + private final ManagedAgentProperties.Harness properties; + private final ManagedAgentProperties.SessionStore sessionStore; + private final String workspaceId; + private final DaemonApprovalMode approvalMode; + private volatile HostedHarnessClient client; + private final Map attachments = + new ConcurrentHashMap<>(); + + public QwenHostedHarnessConnector(ManagedAgentProperties properties) { + this.properties = properties.getHarness(); + this.sessionStore = properties.getSessionStore(); + this.workspaceId = sessionStore.getWorkspaceId(); + if (this.properties.getToken() == null + || this.properties.getToken().isBlank() + || this.properties.getCapabilityDigest() == null + || this.properties.getCapabilityDigest().isBlank()) { + throw new IllegalStateException("Enabled Hosted Harness requires" + + " token and capability digest"); + } + URI.create(this.properties.getBaseUrl()); + if (sessionStore.isEnabled() + && (sessionStore.getBaseUrl() == null + || sessionStore.getBaseUrl().isBlank() + || workspaceId == null || workspaceId.isBlank())) { + throw new IllegalStateException("Enabled Managed Session Store" + + " requires base URL and Runtime workspace ID"); + } + String runtimeWorkspaceId = properties.getRuntimeBroker() + .getWorkspaceId(); + if (sessionStore.isEnabled() && runtimeWorkspaceId != null + && !runtimeWorkspaceId.isBlank() + && !workspaceId.equals(runtimeWorkspaceId)) { + throw new IllegalStateException("Managed Session Store and" + + " Runtime Broker workspace IDs must match"); + } + this.approvalMode = parseApprovalMode( + this.properties.getApprovalMode()); + } + + @Override + public boolean isAvailable() { + return true; + } + + @Override + public Attachment createOrLoad(String tenantId, String sessionId, + boolean loadExisting) { + return createOrLoad(tenantId, sessionId, loadExisting, false); + } + + @Override + public Attachment createOrLoad(String tenantId, String sessionId, + boolean loadExisting, boolean passiveManagedRuntimeRecovery) { + AttachmentKey key = new AttachmentKey(tenantId, sessionId); + HarnessSessionRef attached = passiveManagedRuntimeRecovery + ? load(tenantId, sessionId, true) + : attachments.computeIfAbsent(key, ignored -> loadExisting + ? load(tenantId, sessionId, false) + : create(tenantId, sessionId)); + attachments.put(key, attached); + return new Attachment(attached.getHarnessBootId(), + attached.getRuntimeRecovery(), + attached.getHarnessLastEventId(), + attached.getHarnessEventEpoch()); + } + + @Override + public Admission submit(String tenantId, String sessionId, + String promptId, + List> input, String payloadDigest) { + SubmitHarnessTurn.Builder builder = SubmitHarnessTurn.builder() + .session(attachment(tenantId, sessionId)) + .promptId(promptId) + .payloadDigest(payloadDigest); + input.forEach(builder::addContent); + PromptReceipt receipt = client().submitTurn(builder.build()); + return new Admission(receipt.getLastEventId(), + receipt.getEventEpoch()); + } + + @Override + public Admission continueManagedRuntime(String tenantId, + String sessionId, String promptId, String checkpointId, + String activationId) { + PromptReceipt receipt = client().continueManagedRuntime( + attachment(tenantId, sessionId), promptId, checkpointId, + activationId); + return new Admission(receipt.getLastEventId(), + receipt.getEventEpoch()); + } + + @Override + public Admission cancelManagedRuntime(String tenantId, String sessionId, + String promptId, String checkpointId, String activationId) { + PromptReceipt receipt = client().cancelManagedRuntime( + new CancelManagedRuntime(attachment(tenantId, sessionId), + promptId, checkpointId, activationId)); + return new Admission(receipt.getLastEventId(), + receipt.getEventEpoch()); + } + + @Override + public SourceStream stream(String tenantId, String sessionId, + long lastEventId, + String eventEpoch) { + HarnessEventStream stream = client().streamEvents( + StreamHarnessEvents.builder() + .session(attachment(tenantId, sessionId)) + .lastEventId(lastEventId) + .eventEpoch(eventEpoch) + .build()); + return new SourceStream() { + @Override + public String eventEpoch() { + return stream.getEventEpoch(); + } + + @Override + public SourceEvent next() { + DaemonEvent event = stream.next(); + return event == null ? null : new SourceEvent(event.getId(), + event.getType(), event.getData(), + event.getPromptId(), event.getMetadata()); + } + + @Override + public void close() { + stream.close(); + } + }; + } + + @Override + public void cancel(String tenantId, String sessionId) { + client().cancelTurn(attachment(tenantId, sessionId)); + } + + @Override + public void rename(String tenantId, String sessionId, String title) { + client().updateSessionTitle(attachment(tenantId, sessionId), title); + } + + @Override + public void closeSession(String tenantId, String sessionId) { + attachments.remove(new AttachmentKey(tenantId, sessionId)); + client().closeSession(sessionId); + } + + @Override + public void close() { + HostedHarnessClient current = client; + if (current != null) { + current.close(); + } + attachments.clear(); + } + + private HarnessSessionRef attachment(String tenantId, String sessionId) { + AttachmentKey key = new AttachmentKey(tenantId, sessionId); + HarnessSessionRef attachment = attachments.get(key); + if (attachment == null) { + createOrLoad(tenantId, sessionId, true); + attachment = attachments.get(key); + } + return attachment; + } + + private HarnessSessionRef create(String tenantId, String sessionId) { + try { + CreateHarnessSession.Builder builder = CreateHarnessSession + .builder() + .harnessSessionId(sessionId) + .approvalMode(approvalMode); + ManagedSessionStoreConnection store = managedSessionStore( + tenantId); + if (store != null) { + builder.managedSessionStore(store); + } + return client().createSession(builder.build()); + } catch (DaemonHttpException error) { + if (error.getStatusCode() != 409) { + throw error; + } + return load(tenantId, sessionId, false); + } catch (SessionCreationOutcomeUnknownException error) { + return load(tenantId, sessionId, false); + } + } + + private HarnessSessionRef load(String tenantId, String sessionId, + boolean passiveManagedRuntimeRecovery) { + ManagedSessionStoreConnection store = managedSessionStore(tenantId); + return client().loadSession(new LoadHarnessSession(sessionId, store, + passiveManagedRuntimeRecovery)); + } + + private ManagedSessionStoreConnection managedSessionStore( + String tenantId) { + if (!sessionStore.isEnabled()) { + return null; + } + return ManagedSessionStoreConnection.builder() + .baseUri(URI.create(sessionStore.getBaseUrl())) + .tenantId(tenantId) + .workspaceId(workspaceId) + .writerId(client().capabilities().getBootId()) + .leaseDuration(sessionStore.getWriterLeaseDuration()) + .build(); + } + + private HostedHarnessClient client() { + HostedHarnessClient current = client; + if (current != null) { + return current; + } + synchronized (this) { + current = client; + if (current == null) { + current = HostedHarnessClient.builder() + .baseUri(URI.create(properties.getBaseUrl())) + .bearerToken(properties.getToken()) + .capabilityDigest(properties.getCapabilityDigest()) + .connectTimeout(properties.getConnectTimeout()) + .requestTimeout(properties.getRequestTimeout()) + .heartbeatInterval(properties.getHeartbeatInterval()) + .build(); + client = current; + } + return current; + } + } + + private static DaemonApprovalMode parseApprovalMode(String value) { + if (value == null || value.isBlank()) { + return DaemonApprovalMode.YOLO; + } + try { + return DaemonApprovalMode.valueOf(value.toUpperCase(Locale.ROOT) + .replace('-', '_')); + } catch (IllegalArgumentException error) { + throw new IllegalStateException( + "Unsupported Hosted Harness approval mode", error); + } + } + + private record AttachmentKey(String tenantId, String sessionId) { + } +} diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/UnavailableHarnessConnector.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/UnavailableHarnessConnector.java new file mode 100644 index 00000000000..13b2f29907b --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/harness/UnavailableHarnessConnector.java @@ -0,0 +1,50 @@ +package com.alibaba.qwen.code.managedagent.harness; + +import java.util.List; +import java.util.Map; + +public class UnavailableHarnessConnector implements HarnessConnector { + @Override + public boolean isAvailable() { + return false; + } + + @Override + public Attachment createOrLoad(String tenantId, String sessionId, + boolean loadExisting) { + throw unavailable(); + } + + @Override + public Admission submit(String tenantId, String sessionId, + String promptId, + List> input, String payloadDigest) { + throw unavailable(); + } + + @Override + public SourceStream stream(String tenantId, String sessionId, + long lastEventId, + String eventEpoch) { + throw unavailable(); + } + + @Override + public void cancel(String tenantId, String sessionId) { + throw unavailable(); + } + + @Override + public void rename(String tenantId, String sessionId, String title) { + throw unavailable(); + } + + @Override + public void closeSession(String tenantId, String sessionId) { + throw unavailable(); + } + + private static IllegalStateException unavailable() { + return new IllegalStateException("Hosted Harness is disabled"); + } +} diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/EmbeddedRuntimeBroker.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/EmbeddedRuntimeBroker.java new file mode 100644 index 00000000000..f5b9071c533 --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/EmbeddedRuntimeBroker.java @@ -0,0 +1,236 @@ +package com.alibaba.qwen.code.managedagent.service; + +import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties; +import com.alibaba.qwen.code.managedagent.store.AgentStateStore; +import com.alibaba.qwen.code.managedagent.store.StoreModels.SessionRecord; +import com.alibaba.qwen.code.runtimebroker.HarnessSessionResolver; +import com.alibaba.qwen.code.runtimebroker.HttpRuntimeTransport; +import com.alibaba.qwen.code.runtimebroker.LocalProcessRuntimeProvisioner; +import com.alibaba.qwen.code.runtimebroker.RuntimeBrokerException; +import com.alibaba.qwen.code.runtimebroker.RuntimeBrokerHttpServer; +import com.alibaba.qwen.code.runtimebroker.RuntimeBrokerService; +import com.alibaba.qwen.code.runtimebroker.RuntimeBindingRepository; +import com.alibaba.qwen.code.runtimebroker.RuntimeLease; +import com.alibaba.qwen.code.runtimebroker.RuntimeProvisioner; +import com.alibaba.qwen.code.runtimebroker.RuntimeScope; +import com.alibaba.qwen.code.runtimebroker.RuntimeSessionRepository; +import com.alibaba.qwen.code.runtimebroker.StaticRuntimeProvisioner; +import com.alibaba.qwen.code.runtimebroker.ToolExecutionRepository; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.util.HexFormat; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ConcurrentHashMap; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class EmbeddedRuntimeBroker implements RuntimeWarmer, AutoCloseable { + private static final Logger LOG = LoggerFactory.getLogger( + EmbeddedRuntimeBroker.class); + private static final Duration LEASE = Duration.ofSeconds(30); + private final RuntimeBrokerService service; + private final RuntimeBrokerHttpServer server; + private final Set retired = ConcurrentHashMap.newKeySet(); + + public EmbeddedRuntimeBroker(AgentStateStore store, + ManagedAgentProperties properties, + RuntimeBindingRepository bindingRepository, + RuntimeSessionRepository sessionRepository, + ToolExecutionRepository executionRepository) { + ManagedAgentProperties.RuntimeBroker broker = + properties.getRuntimeBroker(); + require(broker.getToken(), "Runtime Broker token"); + require(broker.getWorkspaceGeneration(), + "Runtime Broker workspace generation"); + require(broker.getWorkspaceCwd(), "Runtime Broker workspace cwd"); + String workspaceCwd = resolveWorkspaceCwd(broker); + String workspaceId = resolveWorkspaceId(broker, workspaceCwd); + require(properties.getHarness().getCapabilityDigest(), + "Hosted Harness capability digest"); + HttpRuntimeTransport transport = new HttpRuntimeTransport(); + RuntimeProvisioner provisioner = provisioner(broker, transport); + HarnessSessionResolver resolver = sessionId -> { + SessionRecord session = store.findSessionById(sessionId) + .orElse(null); + if (session == null) { + CompletableFuture failed = + new CompletableFuture<>(); + failed.completeExceptionally(new IllegalArgumentException( + "Session is not owned by this service")); + return failed; + } + return CompletableFuture.completedFuture(new RuntimeScope( + session.tenantId(), workspaceId, + broker.getWorkspaceGeneration(), + workspaceCwd, + properties.getHarness().getCapabilityDigest(), + broker.getIsolationClass())); + }; + this.service = new RuntimeBrokerService(resolver, provisioner, + transport, bindingRepository, sessionRepository, + executionRepository, UUID.randomUUID().toString(), LEASE, + LEASE); + try { + this.server = new RuntimeBrokerHttpServer( + new InetSocketAddress(broker.getHost(), broker.getPort()), + broker.getToken(), service); + server.start(); + } catch (IOException error) { + service.close(); + throw new IllegalStateException( + "Runtime Broker listener could not start", error); + } + LOG.info("Embedded Runtime Broker listening at {}", + server.getBaseUri()); + } + + @Override + public boolean isEnabled() { + return true; + } + + @Override + public CompletionStage warm(String sessionId) { + if (retired.contains(sessionId)) { + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally(new RuntimeBrokerException(409, + "runtime_broker_session_closed", + "Harness Session is closed.", false)); + return failed; + } + return service.warm(sessionId).thenApply(ignored -> null); + } + + /** + * Marks the Harness Session closed for later warm calls. The merged + * broker releases one Runtime Session at a time and has no harness-level + * drain, so this does not tear the worker down. + */ + @Override + public CompletionStage drain(String sessionId) { + retired.add(sessionId); + return CompletableFuture.completedFuture(null); + } + + @Override + public void resume(String sessionId) { + retired.remove(sessionId); + } + + public URI getBaseUri() { + return server.getBaseUri(); + } + + @Override + public void close() { + server.close(); + } + + private static RuntimeProvisioner provisioner( + ManagedAgentProperties.RuntimeBroker broker, + HttpRuntimeTransport transport) { + if ("local-process".equals(broker.getProvisioner())) { + require(broker.getStateDirectory(), + "Runtime Broker state directory"); + require(broker.getNodeExecutable(), "Node.js executable"); + require(broker.getWorkerEntry(), "Runtime worker entry"); + require(broker.getCliEntry(), "Qwen CLI entry"); + if (!broker.getEnvironment().isEmpty()) { + throw new IllegalStateException("Merged local Runtime" + + " provisioner does not accept extra environment"); + } + return new LocalProcessRuntimeProvisioner(List.of( + Path.of(broker.getNodeExecutable()).toAbsolutePath() + .toString(), + Path.of(broker.getWorkerEntry()).toAbsolutePath() + .toString()), + Path.of(broker.getStateDirectory()), transport); + } + if ("static".equals(broker.getProvisioner())) { + if (!"workspace".equals(broker.getIsolationClass())) { + throw new IllegalStateException("Static Runtime requires" + + " workspace isolation"); + } + require(broker.getStaticEndpoint(), "Static Runtime endpoint"); + require(broker.getStaticToken(), "Static Runtime token"); + return new StaticRuntimeProvisioner(new RuntimeLease( + broker.getStaticRuntimeInstanceId(), + URI.create(broker.getStaticEndpoint()), + broker.getStaticToken(), broker.getStaticLeaseId(), + broker.getStaticEpoch())); + } + if ("kubernetes".equals(broker.getProvisioner())) { + throw new IllegalStateException("Kubernetes Runtime provisioner" + + " is outside this review slice"); + } + throw new IllegalStateException("Runtime Broker provisioner must be" + + " local-process or static"); + } + + private static String resolveWorkspaceCwd( + ManagedAgentProperties.RuntimeBroker broker) { + if ("kubernetes".equals(broker.getProvisioner())) { + Path configured = Path.of(broker.getWorkspaceCwd()); + if (!configured.isAbsolute() + || !configured.normalize().toString().equals( + broker.getWorkspaceCwd())) { + throw new IllegalStateException("Kubernetes Runtime Broker" + + " workspace cwd must be an absolute normalized path"); + } + return broker.getWorkspaceCwd(); + } + if (!"local-process".equals(broker.getProvisioner())) { + return broker.getWorkspaceCwd(); + } + try { + return Path.of(broker.getWorkspaceCwd()).toRealPath().toString(); + } catch (IOException error) { + throw new IllegalStateException( + "Runtime Broker workspace cwd could not be resolved", + error); + } + } + + private static String resolveWorkspaceId( + ManagedAgentProperties.RuntimeBroker broker, + String workspaceCwd) { + String configured = broker.getWorkspaceId(); + if (!"local-process".equals(broker.getProvisioner()) + && !"kubernetes".equals(broker.getProvisioner())) { + require(configured, "Runtime Broker workspace ID"); + return configured; + } + String expected; + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest( + workspaceCwd.getBytes(StandardCharsets.UTF_8)); + expected = HexFormat.of().formatHex(digest).substring(0, 16); + } catch (NoSuchAlgorithmException error) { + throw new IllegalStateException("SHA-256 is unavailable", error); + } + if (configured == null || configured.isBlank()) { + return expected; + } + if (!expected.equals(configured)) { + throw new IllegalStateException("Runtime Broker workspace ID must" + + " match the canonical workspace path hash"); + } + return configured; + } + + private static void require(String value, String name) { + if (value == null || value.isBlank()) { + throw new IllegalStateException(name + " is required"); + } + } +} diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinator.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinator.java new file mode 100644 index 00000000000..64ec4db07b8 --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinator.java @@ -0,0 +1,601 @@ +package com.alibaba.qwen.code.managedagent.service; + +import com.alibaba.qwen.code.daemon.DaemonHttpException; +import com.alibaba.qwen.code.daemon.DaemonProtocolException; +import com.alibaba.qwen.code.daemon.HostedHarnessCapabilityMismatchException; +import com.alibaba.qwen.code.daemon.HostedHarnessGenerationException; +import com.alibaba.qwen.code.daemon.HarnessRuntimeRecovery; +import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties; +import com.alibaba.qwen.code.managedagent.harness.HarnessConnector; +import com.alibaba.qwen.code.managedagent.harness.HarnessConnector.Admission; +import com.alibaba.qwen.code.managedagent.harness.HarnessConnector.Attachment; +import com.alibaba.qwen.code.managedagent.harness.HarnessConnector.SourceEvent; +import com.alibaba.qwen.code.managedagent.harness.HarnessConnector.SourceStream; +import com.alibaba.qwen.code.managedagent.store.AgentStateStore; +import com.alibaba.qwen.code.managedagent.store.StoreModels.DispatchTarget; +import com.alibaba.qwen.code.managedagent.store.StoreModels.HarnessEvent; +import com.alibaba.qwen.code.managedagent.store.StoreModels.ProjectedEvent; +import com.alibaba.qwen.code.managedagent.store.StoreModels.SessionRecord; +import com.alibaba.qwen.code.managedagent.store.StoreModels.TurnRecord; +import jakarta.annotation.PreDestroy; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +public class HarnessCoordinator { + private static final Logger LOG = LoggerFactory.getLogger( + HarnessCoordinator.class); + private final AgentStateStore store; + private final HarnessConnector harness; + private final HarnessEventProjector projector; + private final RuntimeWarmer runtimeWarmer; + private final ExecutorService executor; + private final Clock clock; + private final Duration leaseDuration; + private final Duration renewInterval; + private final Duration retryInitialDelay; + private final Duration retryMaxDelay; + private final int maxPreAdmissionRetries; + private final Duration batchInterval; + private final int batchMaxEvents; + private final int batchMaxBytes; + private final String owner = UUID.randomUUID().toString(); + private final Set active = ConcurrentHashMap.newKeySet(); + private final ScheduledExecutorService renewer = + Executors.newSingleThreadScheduledExecutor(runnable -> { + Thread thread = new Thread(runnable, + "managed-agent-dispatch-lease"); + thread.setDaemon(true); + return thread; + }); + + public HarnessCoordinator(AgentStateStore store, + HarnessConnector harness, HarnessEventProjector projector, + RuntimeWarmer runtimeWarmer, ExecutorService executor, + Clock clock, ManagedAgentProperties properties) { + this.store = store; + this.harness = harness; + this.projector = projector; + this.runtimeWarmer = runtimeWarmer; + this.executor = executor; + this.clock = clock; + this.leaseDuration = properties.getDispatch().getLeaseDuration(); + this.renewInterval = properties.getDispatch() + .getLeaseRenewInterval(); + this.retryInitialDelay = properties.getDispatch() + .getRetryInitialDelay(); + this.retryMaxDelay = properties.getDispatch().getRetryMaxDelay(); + this.maxPreAdmissionRetries = properties.getDispatch() + .getMaxPreAdmissionRetries(); + this.batchInterval = properties.getEvents().getBatchInterval(); + this.batchMaxEvents = properties.getEvents().getBatchMaxEvents(); + this.batchMaxBytes = properties.getEvents().getBatchMaxBytes(); + if (batchInterval.isNegative() || batchInterval.isZero() + || batchMaxEvents <= 0 || batchMaxBytes <= 0) { + throw new IllegalStateException( + "Managed event batch limits must be positive"); + } + if (retryInitialDelay.isNegative() || retryInitialDelay.isZero() + || retryMaxDelay.compareTo(retryInitialDelay) < 0 + || maxPreAdmissionRetries < 0) { + throw new IllegalStateException( + "Managed dispatch retry limits are invalid"); + } + } + + public void dispatch(String tenantId, String sessionId, String turnId) { + String key = key(tenantId, sessionId, turnId); + if (!active.add(key)) { + return; + } + executor.execute(() -> { + try { + coordinate(tenantId, sessionId, turnId); + } finally { + active.remove(key); + } + }); + } + + public void cancel(String tenantId, String sessionId, String turnId) { + dispatch(tenantId, sessionId, turnId); + executor.execute(() -> cancelAdmittedTurn(tenantId, sessionId, + turnId)); + } + + @Scheduled(fixedDelayString = + "${qwen.managed-agent.dispatch.scan-delay:1s}") + public void recoverExpiredTurns() { + if (!harness.isAvailable()) { + return; + } + for (DispatchTarget target : store.findDispatchable(clock.millis(), + 50)) { + dispatch(target.tenantId(), target.sessionId(), target.turnId()); + } + } + + @PreDestroy + public void close() { + renewer.shutdownNow(); + } + + private void coordinate(String tenantId, String sessionId, + String turnId) { + TurnRecord claimed = store.claimTurn(tenantId, sessionId, turnId, + owner, leaseDuration).orElse(null); + if (claimed == null) { + return; + } + AtomicBoolean leaseLost = new AtomicBoolean(); + ScheduledFuture renewal = renewer.scheduleAtFixedRate( + () -> { + try { + if (!store.renewTurn(tenantId, sessionId, turnId, + owner, leaseDuration)) { + leaseLost.set(true); + } + } catch (RuntimeException error) { + leaseLost.set(true); + LOG.warn("Managed Turn lease renewal failed tenant={}" + + " session={} turn={} failure={}", + tenantId, sessionId, turnId, + error.getClass().getSimpleName()); + } + }, + renewInterval.toMillis(), renewInterval.toMillis(), + TimeUnit.MILLISECONDS); + boolean terminal = false; + AtomicBoolean submissionAttempted = new AtomicBoolean( + claimed.submissionAttempted()); + try { + terminal = runClaimed(claimed, leaseLost, + submissionAttempted); + } catch (HostedHarnessCapabilityMismatchException error) { + terminal = fail(claimed, error.getCode(), + "Hosted Harness capability policy changed."); + } catch (HostedHarnessGenerationException error) { + terminal = fail(claimed, error.getCode(), + "Hosted Harness generation changed."); + } catch (DaemonProtocolException error) { + terminal = fail(claimed, "hosted_harness_protocol_error", + "Hosted Harness returned an invalid protocol response."); + } catch (DaemonHttpException error) { + if (error.getStatusCode() >= 400 + && error.getStatusCode() < 500 + && error.getStatusCode() != 409) { + terminal = fail(claimed, "hosted_harness_rejected", + "Hosted Harness rejected the Turn."); + } else { + terminal = transientFailure(claimed, + submissionAttempted.get(), error); + } + } catch (RuntimeException error) { + terminal = transientFailure(claimed, + submissionAttempted.get(), error); + } finally { + renewal.cancel(false); + if (!terminal) { + store.releaseTurnLease(tenantId, sessionId, turnId, owner); + } + } + } + + private boolean runClaimed(TurnRecord claimed, + AtomicBoolean leaseLost, AtomicBoolean submissionAttempted) { + SessionRecord session = store.requireSession(claimed.tenantId(), + claimed.sessionId()); + if ("CANCELLING".equals(claimed.status()) + && claimed.harnessEventEpoch() == null + && !claimed.submissionAttempted()) { + store.cancelBeforeAdmission(claimed.tenantId(), + claimed.sessionId(), claimed.turnId(), owner); + return true; + } + boolean recoveringCancellation = + "CANCELLING".equals(claimed.status()); + if (!recoveringCancellation) { + warmRuntime(session, claimed); + } + requireLease(leaseLost); + Attachment attachment = recoveringCancellation + ? harness.createOrLoad(session.tenantId(), session.sessionId(), + session.harnessBootId() != null, true) + : harness.createOrLoad(session.tenantId(), session.sessionId(), + session.harnessBootId() != null); + HarnessRuntimeRecovery runtimeRecovery = attachment.runtimeRecovery(); + if (runtimeRecovery != null + && runtimeRecovery.hasUnknownOutcome()) { + return fail(claimed, "managed_runtime_recovery_blocked", + "A prior tool execution has an unknown outcome; the" + + " Session was blocked without replaying it."); + } + TurnRecord current; + boolean recoveredCancellation = false; + if (runtimeRecovery != null) { + if (recoveringCancellation + ? !runtimeRecovery.isCancellationReady() + : !runtimeRecovery.isContinuationReady()) { + return fail(claimed, "managed_runtime_recovery_incomplete", + "A prior tool execution is not ready for safe" + + (recoveringCancellation + ? " cancellation." + : " continuation.")); + } + if (attachment.eventEpoch() == null + || attachment.lastEventId() == null) { + return fail(claimed, + "managed_runtime_recovery_watermark_missing", + "Hosted Harness recovery did not return an event" + + " watermark."); + } + recoveredCancellation = "CANCELLING".equals(claimed.status()); + if (!recoveredCancellation && session.harnessBootId() != null + && claimed.harnessEventEpoch() != null) { + store.retractContinuationOutput(session.tenantId(), + session.sessionId(), claimed.turnId(), owner, + session.harnessBootId(), claimed.harnessEventEpoch()); + } + if (!store.bindRecoveredHarness(session.tenantId(), + session.sessionId(), claimed.turnId(), owner, + session.harnessBootId(), attachment.bootId())) { + return fail(claimed, + "hosted_harness_recovery_generation_mismatch", + "Hosted Harness recovery generation changed."); + } + requireLease(leaseLost); + current = store.findTurn(claimed.tenantId(), + claimed.sessionId(), claimed.turnId()).orElseThrow(); + String previousEventEpoch = current.harnessEventEpoch(); + if (!attachment.eventEpoch().equals(previousEventEpoch)) { + store.recordRecoveryAdmission(current.tenantId(), + current.sessionId(), current.turnId(), owner, + previousEventEpoch, attachment.eventEpoch(), + attachment.lastEventId()); + requireLease(leaseLost); + current = store.findTurn(current.tenantId(), + current.sessionId(), current.turnId()).orElseThrow(); + } + if (recoveredCancellation) { + Admission admission = harness.cancelManagedRuntime( + session.tenantId(), session.sessionId(), + current.promptId(), runtimeRecovery.getCheckpointId(), + runtimeRecovery.getActivationId()); + requireLease(leaseLost); + if (!attachment.eventEpoch().equals(admission.eventEpoch())) { + throw new IllegalStateException( + "Hosted Harness recovery epoch changed"); + } + } else { + Admission admission = harness.continueManagedRuntime( + session.tenantId(), session.sessionId(), + current.promptId(), runtimeRecovery.getCheckpointId(), + runtimeRecovery.getActivationId()); + requireLease(leaseLost); + if (!attachment.eventEpoch().equals(admission.eventEpoch()) + || admission.lastEventId() + < attachment.lastEventId()) { + throw new IllegalStateException( + "Hosted Harness recovery watermark changed"); + } + store.recordRecoveryAdmission(current.tenantId(), + current.sessionId(), current.turnId(), owner, + attachment.eventEpoch(), admission.eventEpoch(), + admission.lastEventId()); + current = store.findTurn(current.tenantId(), + current.sessionId(), current.turnId()).orElseThrow(); + } + } else { + if (!store.bindHarness(session.tenantId(), session.sessionId(), + claimed.turnId(), owner, attachment.bootId())) { + return fail(claimed, "hosted_harness_generation_mismatch", + "Hosted Harness generation changed."); + } + requireLease(leaseLost); + current = store.findTurn(claimed.tenantId(), + claimed.sessionId(), claimed.turnId()).orElseThrow(); + if (current.harnessEventEpoch() == null) { + store.markSubmissionAttempted(current.tenantId(), + current.sessionId(), current.turnId(), owner); + submissionAttempted.set(true); + Admission admission = harness.submit(session.tenantId(), + session.sessionId(), current.promptId(), + current.input(), current.payloadDigest()); + requireLease(leaseLost); + store.recordAdmission(current.tenantId(), + current.sessionId(), current.turnId(), owner, + admission.eventEpoch(), admission.lastEventId()); + current = store.findTurn(current.tenantId(), + current.sessionId(), current.turnId()).orElseThrow(); + } + } + if ("CANCELLING".equals(current.status()) + && !recoveredCancellation) { + harness.cancel(session.tenantId(), session.sessionId()); + } + long lastEventId = current.harnessLastEventId() == null ? 0 + : current.harnessLastEventId(); + try (SourceStream stream = harness.stream( + session.tenantId(), session.sessionId(), lastEventId, + current.harnessEventEpoch())) { + return consumeStream(current, attachment.bootId(), stream, + leaseLost); + } + } + + private boolean consumeStream(TurnRecord turn, String bootId, + SourceStream stream, AtomicBoolean leaseLost) { + int capacity = Math.max(128, batchMaxEvents * 2); + BlockingQueue incoming = + new ArrayBlockingQueue<>(capacity); + Future reader = executor.submit(() -> readStream(stream, + incoming)); + List batch = new ArrayList<>(); + int batchBytes = 0; + long flushAt = 0; + boolean flushFirstVisibleText = true; + try { + while (true) { + StreamItem item = take(incoming, batch, flushAt); + if (item == null) { + flush(turn, stream.eventEpoch(), batch, leaseLost); + batchBytes = 0; + flushAt = 0; + continue; + } + if (item.error() != null) { + throw item.error(); + } + if (item.end()) { + flush(turn, stream.eventEpoch(), batch, leaseLost); + throw new IllegalStateException( + "Hosted Harness stream ended before a terminal" + + " event"); + } + SourceEvent source = item.event(); + requireLease(leaseLost); + if (source.id() == null + || source.promptId() != null + && !turn.promptId().equals(source.promptId())) { + continue; + } + ProjectedEvent projection = projector.project(source, + turn.turnId()); + HarnessEvent event = new HarnessEvent(source.id(), + bootId + ":" + stream.eventEpoch() + ":" + + source.id(), + projection); + if (projection != null && !isTextDelta(projection)) { + flush(turn, stream.eventEpoch(), batch, leaseLost); + record(turn, stream.eventEpoch(), List.of(event), + leaseLost); + if (projection.terminal()) { + return true; + } + batchBytes = 0; + flushAt = 0; + continue; + } + if (batch.isEmpty()) { + flushAt = System.nanoTime() + + batchInterval.toNanos(); + } + batch.add(event); + batchBytes += estimatedBytes(projection); + if (projection != null && flushFirstVisibleText) { + flush(turn, stream.eventEpoch(), batch, leaseLost); + flushFirstVisibleText = false; + batchBytes = 0; + flushAt = 0; + } else if (batch.size() >= batchMaxEvents + || batchBytes >= batchMaxBytes) { + flush(turn, stream.eventEpoch(), batch, leaseLost); + batchBytes = 0; + flushAt = 0; + } + } + } finally { + reader.cancel(true); + } + } + + private static void readStream(SourceStream stream, + BlockingQueue incoming) { + try { + for (SourceEvent event = stream.next(); event != null; + event = stream.next()) { + incoming.put(new StreamItem(event, null, false)); + } + incoming.put(new StreamItem(null, null, true)); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + } catch (RuntimeException error) { + try { + incoming.put(new StreamItem(null, error, false)); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } + } + } + + private StreamItem take(BlockingQueue incoming, + List batch, long flushAt) { + try { + if (batch.isEmpty()) { + return incoming.take(); + } + long remaining = Math.max(1, flushAt - System.nanoTime()); + return incoming.poll(remaining, TimeUnit.NANOSECONDS); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + throw new IllegalStateException( + "Managed event batching was interrupted", error); + } + } + + private void flush(TurnRecord turn, String eventEpoch, + List events, AtomicBoolean leaseLost) { + if (events.isEmpty()) { + return; + } + record(turn, eventEpoch, List.copyOf(events), leaseLost); + events.clear(); + } + + private void record(TurnRecord turn, String eventEpoch, + List events, AtomicBoolean leaseLost) { + requireLease(leaseLost); + store.recordHarnessEvents(turn.tenantId(), turn.sessionId(), + turn.turnId(), owner, eventEpoch, events); + } + + private static boolean isTextDelta(ProjectedEvent event) { + return "item.output_text.delta".equals(event.type()) + || "item.reasoning.delta".equals(event.type()); + } + + private static int estimatedBytes(ProjectedEvent event) { + if (event == null) { + return 64; + } + Object text = event.data().get("text"); + return 128 + (text instanceof String + ? ((String) text).getBytes(StandardCharsets.UTF_8).length + : event.data().toString().getBytes(StandardCharsets.UTF_8) + .length); + } + + private record StreamItem(SourceEvent event, RuntimeException error, + boolean end) { + } + + private void warmRuntime(SessionRecord session, TurnRecord turn) { + if (!runtimeWarmer.isEnabled()) { + return; + } + String startKey = "runtime:start:" + turn.turnId(); + store.appendPublicEventIfAbsent(session.tenantId(), + session.sessionId(), turn.turnId(), + "environment.provisioning", Map.of(), false, startKey); + try { + runtimeWarmer.warm(session.sessionId()).whenComplete( + (ignored, error) -> runtimeWarmResult(session, turn, + error)); + } catch (RuntimeException error) { + runtimeWarmResult(session, turn, error); + } + } + + private void runtimeWarmResult(SessionRecord session, TurnRecord turn, + Throwable error) { + String suffix = error == null ? "ready" : "failed"; + String type = "environment." + suffix; + Map data = error == null ? Map.of() + : Map.of("code", "runtime_warm_failed"); + if (error != null) { + LOG.warn("Managed Runtime warm failed tenant={} session={} turn={}", + session.tenantId(), session.sessionId(), turn.turnId(), + error); + } + store.appendPublicEventIfAbsent(session.tenantId(), + session.sessionId(), turn.turnId(), type, data, false, + "runtime:" + suffix + ":" + turn.turnId()); + } + + private void cancelAdmittedTurn(String tenantId, String sessionId, + String turnId) { + try { + TurnRecord turn = store.findTurn(tenantId, sessionId, turnId) + .orElse(null); + if (turn == null || !"CANCELLING".equals(turn.status()) + || turn.harnessEventEpoch() == null) { + return; + } + SessionRecord session = store.requireSession(tenantId, + sessionId); + Attachment attachment = harness.createOrLoad( + session.tenantId(), session.sessionId(), + session.harnessBootId() != null); + if (store.bindHarness(tenantId, sessionId, + turnId, owner, attachment.bootId())) { + harness.cancel(session.tenantId(), session.sessionId()); + } + } catch (RuntimeException error) { + LOG.warn("Managed Turn cancellation will recover tenant={}" + + " session={} turn={} failure={}", + tenantId, sessionId, turnId, + error.getClass().getSimpleName()); + } + } + + private static void requireLease(AtomicBoolean leaseLost) { + if (leaseLost.get()) { + throw new IllegalStateException("Turn dispatch lease was lost"); + } + } + + private boolean fail(TurnRecord turn, String code, String message) { + store.failTurn(turn.tenantId(), turn.sessionId(), turn.turnId(), + owner, code, message); + return true; + } + + private boolean transientFailure(TurnRecord turn, + boolean submissionAttempted, RuntimeException error) { + if (!submissionAttempted + && turn.retryCount() >= maxPreAdmissionRetries) { + LOG.error("Managed Turn coordination exhausted retries tenant={}" + + " session={} turn={} failure={}", + turn.tenantId(), turn.sessionId(), turn.turnId(), + error.getClass().getSimpleName(), error); + return fail(turn, "hosted_harness_unavailable", + "Hosted Harness remained unavailable before Turn" + + " admission."); + } + long delay = retryDelay(turn.retryCount()); + long retryAfter = Math.addExact(clock.millis(), delay); + store.scheduleTurnRetry(turn.tenantId(), turn.sessionId(), + turn.turnId(), owner, retryAfter); + LOG.warn("Managed Turn coordination will retry tenant={} session={}" + + " turn={} retry={} delayMs={} failure={}", + turn.tenantId(), turn.sessionId(), turn.turnId(), + turn.retryCount() + 1, delay, + error.getClass().getSimpleName()); + return true; + } + + private long retryDelay(int retryCount) { + long initial = retryInitialDelay.toMillis(); + long maximum = retryMaxDelay.toMillis(); + int shift = Math.min(retryCount, 62); + if (initial > (Long.MAX_VALUE >> shift)) { + return maximum; + } + return Math.min(initial << shift, maximum); + } + + private static String key(String tenantId, String sessionId, + String turnId) { + return tenantId + "\n" + sessionId + "\n" + turnId; + } +} diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/HarnessEventProjector.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/HarnessEventProjector.java new file mode 100644 index 00000000000..f0d46fbca60 --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/HarnessEventProjector.java @@ -0,0 +1,114 @@ +package com.alibaba.qwen.code.managedagent.service; + +import com.alibaba.qwen.code.managedagent.harness.HarnessConnector.SourceEvent; +import com.alibaba.qwen.code.managedagent.store.StoreModels.ProjectedEvent; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; +import java.util.regex.Pattern; +import org.springframework.stereotype.Component; + +@Component +public class HarnessEventProjector { + private static final Pattern SAFE_ERROR_CODE = Pattern.compile( + "^[A-Za-z0-9._:-]{1,128}$"); + + public ProjectedEvent project(SourceEvent event, String turnId) { + if ("turn_complete".equals(event.type())) { + Map data = object(event.data()); + String stopReason = string(data.get("stopReason")); + boolean cancelled = stopReason != null + && stopReason.toLowerCase().contains("cancel"); + String type = cancelled ? "turn.cancelled" : "turn.completed"; + String status = cancelled ? "CANCELLED" : "COMPLETED"; + return new ProjectedEvent(type, + stopReason == null ? Map.of() + : Map.of("stopReason", stopReason), + true, status, null, null); + } + if ("turn_error".equals(event.type())) { + Map data = object(event.data()); + String sourceCode = string(data.get("code")); + String code = sourceCode != null + && SAFE_ERROR_CODE.matcher(sourceCode).matches() + ? sourceCode : "hosted_harness_error"; + String message = "The Hosted Harness Turn failed."; + return new ProjectedEvent("turn.failed", Map.of( + "code", code, "message", message), + true, "FAILED", code, message); + } + if (!"session_update".equals(event.type())) { + return null; + } + Map data = object(event.data()); + Map update = object(data.get("update")); + String kind = string(update.get("sessionUpdate")); + if ("agent_message_chunk".equals(kind)) { + return textEvent("item.output_text.delta", "output_text", + update, turnId); + } + if ("agent_thought_chunk".equals(kind)) { + return textEvent("item.reasoning.delta", "reasoning", + update, turnId); + } + if ("tool_call".equals(kind) + || "tool_call_update".equals(kind)) { + return new ProjectedEvent("item.tool_call.updated", + safeToolData(update, turnId, event.id()), false, null, + null, null); + } + return null; + } + + private static ProjectedEvent textEvent(String type, String partType, + Map update, String turnId) { + Map content = object(update.get("content")); + String text = string(content.get("text")); + if (text == null || text.isEmpty()) { + return null; + } + return new ProjectedEvent(type, Map.of( + "itemId", "item_" + turnId + "_assistant", + "contentPartId", "part_" + turnId + "_" + partType, + "text", text), false, null, null, null); + } + + private static Map safeToolData( + Map update, String turnId, Long sourceId) { + Map safe = new LinkedHashMap<>(); + safe.put("kind", string(update.get("sessionUpdate"))); + copyScalar(update, safe, "toolCallId"); + copyScalar(update, safe, "callId"); + copyScalar(update, safe, "title"); + copyScalar(update, safe, "status"); + copyScalar(update, safe, "name"); + String callId = string(update.get("toolCallId")); + if (callId == null) { + callId = string(update.get("callId")); + } + String identity = callId == null + ? turnId + ":source:" + sourceId : turnId + ":" + callId; + safe.put("itemId", "item_tool_" + UUID.nameUUIDFromBytes( + identity.getBytes(StandardCharsets.UTF_8))); + return Map.copyOf(safe); + } + + private static void copyScalar(Map source, + Map target, String key) { + Object value = source.get(key); + if (value instanceof String || value instanceof Number + || value instanceof Boolean) { + target.put(key, value); + } + } + + @SuppressWarnings("unchecked") + private static Map object(Object value) { + return value instanceof Map ? (Map) value : Map.of(); + } + + private static String string(Object value) { + return value instanceof String ? (String) value : null; + } +} diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java new file mode 100644 index 00000000000..2709b5bbf2d --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedAgentService.java @@ -0,0 +1,719 @@ +package com.alibaba.qwen.code.managedagent.service; + +import com.alibaba.qwen.code.daemon.SubmitHarnessTurn; +import com.alibaba.qwen.code.managedagent.api.ApiException; +import com.alibaba.qwen.code.managedagent.api.ApiModels.CommandAdmission; +import com.alibaba.qwen.code.managedagent.api.ApiModels.DeletedSession; +import com.alibaba.qwen.code.managedagent.api.ApiModels.InputBlock; +import com.alibaba.qwen.code.managedagent.api.ApiModels.PublicEvent; +import com.alibaba.qwen.code.managedagent.api.ApiModels.PublicContentPart; +import com.alibaba.qwen.code.managedagent.api.ApiModels.PublicItem; +import com.alibaba.qwen.code.managedagent.api.ApiModels.PublicItemList; +import com.alibaba.qwen.code.managedagent.api.ApiModels.PublicList; +import com.alibaba.qwen.code.managedagent.api.ApiModels.PublicSession; +import com.alibaba.qwen.code.managedagent.api.ApiModels.PublicTurn; +import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellContentPart; +import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellEvent; +import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellItem; +import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellPage; +import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellSession; +import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellTranscript; +import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellTurn; +import com.alibaba.qwen.code.managedagent.harness.HarnessConnector; +import com.alibaba.qwen.code.managedagent.store.AgentStateStore; +import com.alibaba.qwen.code.managedagent.store.StoreModels.Admission; +import com.alibaba.qwen.code.managedagent.store.StoreModels.EventRecord; +import com.alibaba.qwen.code.managedagent.store.StoreModels.EventPage; +import com.alibaba.qwen.code.managedagent.store.StoreModels.ItemPartRecord; +import com.alibaba.qwen.code.managedagent.store.StoreModels.ItemRecord; +import com.alibaba.qwen.code.managedagent.store.StoreModels.SessionPage; +import com.alibaba.qwen.code.managedagent.store.StoreModels.SessionRecord; +import com.alibaba.qwen.code.managedagent.store.StoreModels.SessionMutationCommand; +import com.alibaba.qwen.code.managedagent.store.StoreModels.SessionMutationKind; +import com.alibaba.qwen.code.managedagent.store.StoreModels.SnapshotRecord; +import com.alibaba.qwen.code.managedagent.store.StoreModels.TurnRecord; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; + +@Service +public class ManagedAgentService { + private static final String CREATE = "CREATE_SESSION"; + private static final String SUBMIT = "SUBMIT_TURN"; + private static final String CANCEL = "CANCEL_TURN"; + private static final String RENAME = "RENAME_SESSION"; + private static final String ARCHIVE = "ARCHIVE_SESSION"; + private static final String UNARCHIVE = "UNARCHIVE_SESSION"; + private static final String DELETE = "DELETE_SESSION"; + private static final Pattern IDEMPOTENCY_KEY = Pattern.compile( + "^[\\x21-\\x7e]{1,128}$"); + private final AgentStateStore store; + private final RequestDigests digests; + private final HarnessCoordinator coordinator; + private final HarnessConnector harness; + private final RuntimeWarmer runtimeWarmer; + + public ManagedAgentService(AgentStateStore store, + RequestDigests digests, HarnessCoordinator coordinator, + HarnessConnector harness, RuntimeWarmer runtimeWarmer) { + this.store = store; + this.digests = digests; + this.coordinator = coordinator; + this.harness = harness; + this.runtimeWarmer = runtimeWarmer; + } + + public CommandAdmission createSession(String tenantId, + String idempotencyKey, String agentId, String title, + Map metadata, List blocks) { + validateIdempotencyKey(idempotencyKey); + List> input = input(blocks, false); + if (!input.isEmpty()) { + requireHarness(); + } + String effectiveTitle = metadataTitle(title, metadata); + Map semantic = new LinkedHashMap<>(); + semantic.put("agentId", agentId); + semantic.put("title", effectiveTitle); + semantic.put("input", input); + String requestDigest = digests.digest(semantic); + Admission replay = replay(tenantId, CREATE, idempotencyKey, + requestDigest); + if (replay != null) { + dispatch(tenantId, replay); + return response(replay); + } + String payloadDigest = input.isEmpty() ? null + : SubmitHarnessTurn.computePayloadDigest(input); + Admission admission; + try { + admission = store.insertSessionCommand(tenantId, CREATE, + idempotencyKey, requestDigest, agentId, effectiveTitle, + input, payloadDigest); + } catch (DuplicateKeyException error) { + admission = store.replayCommand(tenantId, CREATE, + idempotencyKey, requestDigest); + } + dispatch(tenantId, admission); + return response(admission); + } + + public CommandAdmission submitTurn(String tenantId, + String idempotencyKey, String sessionId, + List blocks) { + validateIdempotencyKey(idempotencyKey); + requireHarness(); + List> input = input(blocks, true); + String requestDigest = digests.digest(Map.of( + "sessionId", sessionId, "input", input)); + Admission replay = replay(tenantId, SUBMIT, idempotencyKey, + requestDigest); + if (replay != null) { + dispatch(tenantId, replay); + return response(replay); + } + String payloadDigest = SubmitHarnessTurn.computePayloadDigest(input); + Admission admission; + try { + admission = store.insertTurnCommand(tenantId, SUBMIT, + idempotencyKey, requestDigest, sessionId, input, + payloadDigest); + } catch (DuplicateKeyException error) { + admission = store.replayCommand(tenantId, SUBMIT, + idempotencyKey, requestDigest); + } + dispatch(tenantId, admission); + return response(admission); + } + + public CommandAdmission cancelTurn(String tenantId, + String idempotencyKey, String sessionId, String turnId) { + validateIdempotencyKey(idempotencyKey); + String requestDigest = digests.digest(Map.of( + "sessionId", sessionId, "turnId", turnId)); + Admission replay = replay(tenantId, CANCEL, idempotencyKey, + requestDigest); + if (replay != null) { + dispatch(tenantId, replay); + return response(replay); + } + Admission admission; + try { + admission = store.insertCancelCommand(tenantId, CANCEL, + idempotencyKey, requestDigest, sessionId, turnId); + } catch (DuplicateKeyException error) { + admission = store.replayCommand(tenantId, CANCEL, + idempotencyKey, requestDigest); + } + if (admission.commandEffect()) { + coordinator.cancel(tenantId, admission.sessionId(), + admission.turnId()); + } else { + dispatch(tenantId, admission); + } + return response(admission); + } + + public SessionMutationResult renameSession( + String tenantId, String idempotencyKey, String sessionId, + String title) { + validateIdempotencyKey(idempotencyKey); + String effectiveTitle = validRenameTitle(title); + String requestDigest = digests.digest(Map.of( + "sessionId", sessionId, "title", effectiveTitle)); + SessionMutationCommand command = store.beginSessionMutation(tenantId, + RENAME, idempotencyKey, requestDigest, sessionId, + SessionMutationKind.RENAME); + if (!"COMPLETED".equals(command.status())) { + requireHarness(); + SessionRecord session = store.requireSession(tenantId, sessionId); + HarnessConnector.Attachment attachment; + try { + attachment = harness.createOrLoad(tenantId, sessionId, + session.harnessBootId() != null); + harness.rename(tenantId, sessionId, effectiveTitle); + } catch (RuntimeException error) { + throw dependencyUnavailable("hosted_harness_unavailable", + "The Hosted Harness could not persist the Session title."); + } + session = store.completeSessionMutation(tenantId, RENAME, + idempotencyKey, sessionId, SessionMutationKind.RENAME, + effectiveTitle, attachment.bootId()); + return new SessionMutationResult<>(publicSession(session), + command.replayed()); + } + return new SessionMutationResult<>(getPublicSession(tenantId, + sessionId), true); + } + + public SessionMutationResult archiveSession( + String tenantId, String idempotencyKey, String sessionId) { + return lifecycleMutation(tenantId, idempotencyKey, sessionId, + ARCHIVE, SessionMutationKind.ARCHIVE); + } + + public SessionMutationResult unarchiveSession( + String tenantId, String idempotencyKey, String sessionId) { + validateIdempotencyKey(idempotencyKey); + String requestDigest = lifecycleDigest(sessionId, UNARCHIVE); + SessionMutationCommand command = store.beginSessionMutation(tenantId, + UNARCHIVE, idempotencyKey, requestDigest, sessionId, + SessionMutationKind.UNARCHIVE); + if (!"COMPLETED".equals(command.status())) { + try { + runtimeWarmer.resume(sessionId); + } catch (RuntimeException error) { + throw dependencyUnavailable("runtime_broker_unavailable", + "The Runtime Broker could not resume the Session."); + } + SessionRecord session = store.completeSessionMutation(tenantId, + UNARCHIVE, idempotencyKey, sessionId, + SessionMutationKind.UNARCHIVE, null, null); + return new SessionMutationResult<>(publicSession(session), + command.replayed()); + } + return new SessionMutationResult<>(getPublicSession(tenantId, + sessionId), true); + } + + public SessionMutationResult deleteSession( + String tenantId, String idempotencyKey, String sessionId) { + validateIdempotencyKey(idempotencyKey); + String requestDigest = lifecycleDigest(sessionId, DELETE); + SessionMutationCommand command = store.beginSessionMutation(tenantId, + DELETE, idempotencyKey, requestDigest, sessionId, + SessionMutationKind.DELETE); + if (!"COMPLETED".equals(command.status())) { + SessionRecord session = store.requireSession(tenantId, sessionId); + closeAndDrain(session, command.sessionStatusBefore()); + store.completeSessionMutation(tenantId, DELETE, idempotencyKey, + sessionId, SessionMutationKind.DELETE, null, null); + } + return new SessionMutationResult<>(new DeletedSession(sessionId, + "agent.session.deleted", true), command.replayed()); + } + + public PublicSession getPublicSession(String tenantId, + String sessionId) { + return publicSession(requireVisibleSession(tenantId, sessionId)); + } + + public WebShellSession getWebShellSession(String tenantId, + String sessionId) { + return webShellSession(requireVisibleSession(tenantId, sessionId)); + } + + public PublicList listPublicSessions(String tenantId, + String cursor, int requestedLimit) { + int limit = limit(requestedLimit); + SessionCursor decoded = decodeCursor(cursor); + SessionPage page = store.listSessions(tenantId, + decoded == null ? null : decoded.updatedAt(), + decoded == null ? null : decoded.sessionId(), limit); + List sessions = page.sessions().stream() + .map(this::publicSession).toList(); + return new PublicList<>("list", sessions, page.hasMore(), + nextCursor(page)); + } + + public WebShellPage listWebShellSessions( + String tenantId, String cursor, int requestedLimit) { + int limit = limit(requestedLimit); + SessionCursor decoded = decodeCursor(cursor); + SessionPage page = store.listSessions(tenantId, + decoded == null ? null : decoded.updatedAt(), + decoded == null ? null : decoded.sessionId(), limit); + return new WebShellPage<>(page.sessions().stream() + .map(this::webShellSession).toList(), nextCursor(page), + page.hasMore()); + } + + public List publicEvents(String tenantId, String sessionId, + long afterSequence, int requestedLimit) { + return events(tenantId, sessionId, afterSequence, requestedLimit) + .stream().map(this::publicEvent).toList(); + } + + public List webShellEvents(String tenantId, + String sessionId, long afterSequence, int requestedLimit) { + return events(tenantId, sessionId, afterSequence, requestedLimit) + .stream().map(this::webShellEvent).toList(); + } + + public PublicItemList listPublicItems(String tenantId, + String sessionId, long afterSequence, int requestedLimit) { + if (afterSequence < 0) { + throw new ApiException(HttpStatus.BAD_REQUEST, + "invalid_event_cursor", + "Item sequence must be non-negative."); + } + int limit = limit(requestedLimit); + store.requireSession(tenantId, sessionId); + SnapshotRecord snapshot = store.findSnapshot(tenantId, sessionId) + .orElse(null); + if (snapshot == null) { + return new PublicItemList("list", List.of(), false, null, 0); + } + List matching = snapshot.items().stream() + .filter(item -> item.firstSequence() > afterSequence) + .toList(); + boolean hasMore = matching.size() > limit; + List items = matching.stream().limit(limit) + .map(this::publicItem).toList(); + String nextCursor = hasMore && !items.isEmpty() + ? Long.toString(items.get(items.size() - 1).firstSequence()) + : null; + return new PublicItemList("list", items, hasMore, nextCursor, + snapshot.coveredSequence()); + } + + public WebShellTranscript transcript(String tenantId, String sessionId, + String cursor, int requestedLimit) { + SessionRecord session = store.requireSession(tenantId, sessionId); + int limit = limit(requestedLimit); + if (cursor == null || cursor.isBlank()) { + SnapshotRecord snapshot = store.findSnapshot(tenantId, sessionId) + .orElse(null); + if (snapshot != null) { + long visibleSequence = Math.max(session.lastSequence(), + snapshot.coveredSequence()); + List events = new ArrayList<>( + store.findControlEvents(tenantId, sessionId, + snapshot.coveredSequence())); + events.addAll(tailEvents(tenantId, sessionId, + snapshot.coveredSequence(), visibleSequence)); + return new WebShellTranscript(snapshot.items().stream() + .map(this::webShellItem).toList(), + events.stream().map(this::webShellEvent).toList(), + snapshot.coveredSequence(), null, false, + visibleSequence); + } + } + EventPage page = store.findTranscriptEvents(tenantId, sessionId, + transcriptCursor(cursor), limit); + List events = page.events().stream() + .map(this::webShellEvent).toList(); + String olderCursor = page.hasMore() && !events.isEmpty() + ? Long.toString(events.get(0).sequence()) : null; + return new WebShellTranscript(List.of(), events, 0, olderCursor, + page.hasMore(), session.lastSequence()); + } + + public long lastSequence(String tenantId, String sessionId) { + return store.requireSession(tenantId, sessionId).lastSequence(); + } + + private List events(String tenantId, String sessionId, + long afterSequence, int requestedLimit) { + if (afterSequence < 0) { + throw new ApiException(HttpStatus.BAD_REQUEST, + "invalid_event_cursor", + "Event sequence must be non-negative."); + } + return store.findEvents(tenantId, sessionId, afterSequence, + limit(requestedLimit)); + } + + private PublicSession publicSession(SessionRecord session) { + TurnRecord activeTurn = store.findActiveTurn(session.tenantId(), + session.sessionId()).orElse(null); + Map metadata = session.title() == null ? Map.of() + : Map.of("title", session.title()); + return new PublicSession(session.sessionId(), "agent.session", + session.agentId(), session.status().toLowerCase(), + session.createdAt() / 1000, session.updatedAt() / 1000, + metadata, activeTurn == null ? null : publicTurn(activeTurn), + session.lastSequence()); + } + + private WebShellSession webShellSession(SessionRecord session) { + TurnRecord latestTurn = store.findLatestTurn(session.tenantId(), + session.sessionId()).orElse(null); + EventRecord environmentEvent = store.findLatestEnvironmentEvent( + session.tenantId(), session.sessionId()).orElse(null); + return new WebShellSession(session.sessionId(), session.title(), + session.agentId(), session.status().toLowerCase(), + session.createdAt(), session.updatedAt(), + latestTurn == null ? null : webShellTurn(latestTurn), + webShellEnvironment(environmentEvent), + session.lastSequence()); + } + + private static Map webShellEnvironment( + EventRecord event) { + if (event == null) { + return null; + } + String state = switch (event.type()) { + case "environment.provisioning" -> "starting"; + case "environment.ready" -> "ready"; + case "environment.failed" -> "failed"; + default -> throw new IllegalStateException( + "Unexpected environment event: " + event.type()); + }; + Map environment = new LinkedHashMap<>(); + environment.put("state", state); + Object environmentId = event.data().get("environmentId"); + if (environmentId instanceof String) { + environment.put("environmentId", environmentId); + } + Object errorCode = event.data().get("code"); + if (errorCode instanceof String) { + environment.put("errorCode", errorCode); + } + return Map.copyOf(environment); + } + + private static PublicTurn publicTurn(TurnRecord turn) { + return new PublicTurn(turn.turnId(), "agent.turn", + turn.sessionId(), turn.status().toLowerCase(), + turn.createdAt() / 1000, + turn.completedAt() == null ? null + : turn.completedAt() / 1000, + turn.errorCode()); + } + + private static WebShellTurn webShellTurn(TurnRecord turn) { + return new WebShellTurn(turn.turnId(), turn.sessionId(), + turn.status().toLowerCase(), turn.createdAt(), + turn.completedAt(), turn.errorCode(), null); + } + + PublicEvent publicEvent(EventRecord event) { + return new PublicEvent(event.sequence(), event.eventId(), + event.sessionId(), event.turnId(), event.type(), + event.createdAt() / 1000, event.data(), event.terminal()); + } + + WebShellEvent webShellEvent(EventRecord event) { + return new WebShellEvent(event.sequence(), event.eventId(), + event.sessionId(), event.turnId(), event.type(), + event.createdAt(), event.data(), event.terminal()); + } + + private PublicItem publicItem(ItemRecord item) { + return new PublicItem(item.itemId(), "agent.item", + item.sessionId(), item.turnId(), item.type(), item.role(), + item.revision(), item.status(), item.content().stream() + .map(this::publicContentPart).toList(), + item.attributes(), item.firstSequence(), item.lastSequence(), + item.createdAt() / 1000, item.updatedAt() / 1000); + } + + private PublicContentPart publicContentPart(ItemPartRecord part) { + return new PublicContentPart(part.partId(), part.type(), part.text(), + part.firstSequence(), part.lastSequence()); + } + + private WebShellItem webShellItem(ItemRecord item) { + return new WebShellItem(item.itemId(), item.sessionId(), + item.turnId(), item.type(), item.role(), item.status(), + item.content().stream().map(this::webShellContentPart) + .toList(), + item.attributes(), item.firstSequence(), item.lastSequence(), + item.createdAt(), item.updatedAt()); + } + + private WebShellContentPart webShellContentPart(ItemPartRecord part) { + return new WebShellContentPart(part.partId(), part.type(), part.text(), + part.firstSequence(), part.lastSequence()); + } + + private List tailEvents(String tenantId, String sessionId, + long afterSequence, long throughSequence) { + List result = new ArrayList<>(); + long cursor = afterSequence; + while (cursor < throughSequence) { + List page = store.findEvents(tenantId, sessionId, + cursor, 100); + if (page.isEmpty()) { + break; + } + for (EventRecord event : page) { + if (event.sequence() > throughSequence) { + return List.copyOf(result); + } + result.add(event); + cursor = event.sequence(); + } + } + return List.copyOf(result); + } + + private void dispatch(String tenantId, Admission admission) { + if (admission.turnId() != null) { + coordinator.dispatch(tenantId, admission.sessionId(), + admission.turnId()); + } + } + + private SessionMutationResult lifecycleMutation( + String tenantId, String idempotencyKey, String sessionId, + String operation, SessionMutationKind kind) { + validateIdempotencyKey(idempotencyKey); + String requestDigest = lifecycleDigest(sessionId, operation); + SessionMutationCommand command = store.beginSessionMutation(tenantId, + operation, idempotencyKey, requestDigest, sessionId, kind); + if (!"COMPLETED".equals(command.status())) { + SessionRecord session = store.requireSession(tenantId, sessionId); + closeAndDrain(session, command.sessionStatusBefore()); + session = store.completeSessionMutation(tenantId, operation, + idempotencyKey, sessionId, kind, null, null); + return new SessionMutationResult<>(publicSession(session), + command.replayed()); + } + return new SessionMutationResult<>(getPublicSession(tenantId, + sessionId), true); + } + + private void closeAndDrain(SessionRecord session, + String sessionStatusBefore) { + boolean alreadyClosed = "ARCHIVED".equals(sessionStatusBefore); + if (!alreadyClosed && harness.isAvailable()) { + try { + harness.closeSession(session.tenantId(), + session.sessionId()); + } catch (RuntimeException error) { + throw dependencyUnavailable("hosted_harness_unavailable", + "The Hosted Harness could not close the Session."); + } + } else if (!alreadyClosed && session.harnessBootId() != null) { + throw dependencyUnavailable("hosted_harness_unavailable", + "The Hosted Harness is required to close the Session."); + } + try { + runtimeWarmer.drain(session.sessionId()).toCompletableFuture() + .join(); + } catch (RuntimeException error) { + throw dependencyUnavailable("runtime_broker_unavailable", + "The Runtime Broker could not drain the Session."); + } + } + + private String lifecycleDigest(String sessionId, String operation) { + return digests.digest(Map.of( + "sessionId", sessionId, "operation", operation)); + } + + private SessionRecord requireVisibleSession(String tenantId, + String sessionId) { + SessionRecord session = store.requireSession(tenantId, sessionId); + if ("DELETED".equals(session.status())) { + throw new ApiException(HttpStatus.NOT_FOUND, + "session_not_found", "The Session was not found."); + } + return session; + } + + private static ApiException dependencyUnavailable(String code, + String message) { + return new ApiException(HttpStatus.SERVICE_UNAVAILABLE, code, + message); + } + + private Admission replay(String tenantId, String operation, + String idempotencyKey, String requestDigest) { + return store.findCommand(tenantId, operation, idempotencyKey) + .map(ignored -> store.replayCommand(tenantId, operation, + idempotencyKey, requestDigest)) + .orElse(null); + } + + private void requireHarness() { + if (!harness.isAvailable()) { + throw new ApiException(HttpStatus.SERVICE_UNAVAILABLE, + "hosted_harness_disabled", + "Hosted Harness is not configured."); + } + } + + private static List> input(List blocks, + boolean required) { + if (blocks == null || blocks.isEmpty()) { + if (required) { + throw new ApiException(HttpStatus.BAD_REQUEST, + "input_required", "At least one input is required."); + } + return List.of(); + } + List> result = new ArrayList<>(); + for (InputBlock block : blocks) { + if (block == null || !"text".equals(block.type()) + || block.text() == null || block.text().isEmpty()) { + throw new ApiException(HttpStatus.BAD_REQUEST, + "unsupported_input", + "Phase 1 accepts non-empty text input only."); + } + result.add(Map.of("type", "text", "text", block.text())); + } + return List.copyOf(result); + } + + private static String metadataTitle(String explicitTitle, + Map metadata) { + if (metadata == null || metadata.isEmpty()) { + return validTitle(explicitTitle); + } + if (metadata.size() > 16) { + throw new ApiException(HttpStatus.BAD_REQUEST, + "invalid_metadata", "Metadata accepts at most 16 keys."); + } + for (Map.Entry entry : metadata.entrySet()) { + if (!(entry.getValue() instanceof String) + || entry.getKey().length() > 64 + || ((String) entry.getValue()).length() > 512) { + throw new ApiException(HttpStatus.BAD_REQUEST, + "invalid_metadata", "Metadata limits were exceeded."); + } + if (!"title".equals(entry.getKey())) { + throw new ApiException(HttpStatus.BAD_REQUEST, + "unsupported_feature", + "Phase 1 persists metadata.title only."); + } + } + Object title = metadata.get("title"); + return validTitle(explicitTitle != null ? explicitTitle + : title instanceof String ? (String) title : null); + } + + private static String validTitle(String title) { + if (title != null && title.length() > 512) { + throw new ApiException(HttpStatus.BAD_REQUEST, "invalid_title", + "Title must not exceed 512 characters."); + } + return title == null || title.isBlank() ? null : title; + } + + private static String validRenameTitle(String title) { + if (title == null || title.isBlank() || title.length() > 256) { + throw new ApiException(HttpStatus.BAD_REQUEST, "invalid_title", + "Title must contain 1-256 characters."); + } + if (title.chars().anyMatch(character -> character <= 31 + || character == 127)) { + throw new ApiException(HttpStatus.BAD_REQUEST, "invalid_title", + "Title must not contain control characters."); + } + return title; + } + + private static void validateIdempotencyKey(String key) { + if (key == null || !IDEMPOTENCY_KEY.matcher(key).matches()) { + throw new ApiException(HttpStatus.BAD_REQUEST, + "invalid_idempotency_key", + "Idempotency-Key must contain 1-128 visible characters."); + } + } + + private static int limit(int requested) { + if (requested <= 0 || requested > 100) { + throw new ApiException(HttpStatus.BAD_REQUEST, "invalid_limit", + "Limit must be between 1 and 100."); + } + return requested; + } + + private static String nextCursor(SessionPage page) { + if (!page.hasMore() || page.sessions().isEmpty()) { + return null; + } + SessionRecord last = page.sessions().get(page.sessions().size() - 1); + String raw = last.updatedAt() + ":" + last.sessionId(); + return Base64.getUrlEncoder().withoutPadding().encodeToString( + raw.getBytes(StandardCharsets.UTF_8)); + } + + private static SessionCursor decodeCursor(String cursor) { + if (cursor == null || cursor.isBlank()) { + return null; + } + try { + String decoded = new String(Base64.getUrlDecoder().decode(cursor), + StandardCharsets.UTF_8); + int separator = decoded.indexOf(':'); + if (separator <= 0 || separator == decoded.length() - 1) { + throw new IllegalArgumentException(); + } + return new SessionCursor(Long.parseLong( + decoded.substring(0, separator)), + decoded.substring(separator + 1)); + } catch (IllegalArgumentException error) { + throw new ApiException(HttpStatus.BAD_REQUEST, + "invalid_cursor", "Session cursor is invalid."); + } + } + + private static Long transcriptCursor(String cursor) { + if (cursor == null || cursor.isBlank()) { + return null; + } + try { + long value = Long.parseLong(cursor); + if (value <= 0) { + throw new NumberFormatException(); + } + return value; + } catch (NumberFormatException error) { + throw new ApiException(HttpStatus.BAD_REQUEST, + "invalid_cursor", "Transcript cursor is invalid."); + } + } + + private static CommandAdmission response(Admission admission) { + return new CommandAdmission(admission.sessionId(), + admission.turnId(), "accepted", admission.replayed()); + } + + private record SessionCursor(long updatedAt, String sessionId) { + } + + public record SessionMutationResult(T body, boolean replayed) { + } +} diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedEventStreamService.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedEventStreamService.java new file mode 100644 index 00000000000..b40e87ab234 --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/ManagedEventStreamService.java @@ -0,0 +1,196 @@ +package com.alibaba.qwen.code.managedagent.service; + +import com.alibaba.qwen.code.managedagent.api.ApiModels.PublicEvent; +import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellEvent; +import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties; +import com.alibaba.qwen.code.managedagent.service.SessionEventHub.Delivery; +import com.alibaba.qwen.code.managedagent.service.SessionEventHub.Subscription; +import com.alibaba.qwen.code.managedagent.store.StoreModels.EventRecord; +import java.io.IOException; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.atomic.AtomicBoolean; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +@Service +public class ManagedEventStreamService { + private final ManagedAgentService agentService; + private final SessionEventHub eventHub; + private final ExecutorService executor; + private final Duration reconciliationInterval; + private final Duration heartbeatInterval; + private final Duration streamTimeout; + + public ManagedEventStreamService(ManagedAgentService agentService, + SessionEventHub eventHub, ExecutorService executor, + ManagedAgentProperties properties) { + this.agentService = agentService; + this.eventHub = eventHub; + this.executor = executor; + this.reconciliationInterval = properties.getEvents() + .getPollInterval(); + this.heartbeatInterval = properties.getEvents() + .getHeartbeatInterval(); + this.streamTimeout = properties.getEvents().getStreamTimeout(); + } + + public SseEmitter publicStream(String tenantId, String sessionId, + long afterSequence) { + agentService.lastSequence(tenantId, sessionId); + SseEmitter emitter = emitter(); + executor.execute(() -> streamPublic(emitter, tenantId, sessionId, + afterSequence)); + return emitter; + } + + public SseEmitter webShellStream(String tenantId, String sessionId, + long afterSequence) { + agentService.lastSequence(tenantId, sessionId); + SseEmitter emitter = emitter(); + executor.execute(() -> streamWebShell(emitter, tenantId, sessionId, + afterSequence)); + return emitter; + } + + SseEmitter emitter() { + return new SseEmitter(streamTimeout.toMillis()); + } + + private void streamPublic(SseEmitter emitter, String tenantId, + String sessionId, long initialSequence) { + AtomicBoolean closed = callbacks(emitter); + long sequence = initialSequence; + long heartbeatAt = System.nanoTime() + + heartbeatInterval.toNanos(); + try (Subscription subscription = eventHub.subscribe(tenantId, + sessionId)) { + boolean reconcile = true; + while (!closed.get()) { + if (reconcile) { + List events = agentService.publicEvents( + tenantId, sessionId, sequence, 100); + for (PublicEvent event : events) { + emitter.send(SseEmitter.event() + .id(Long.toString(event.sequence())) + .name(event.type()).data(event)); + sequence = event.sequence(); + } + if (events.size() == 100) { + continue; + } + reconcile = false; + } + Delivery delivery = subscription.await(sequence, + waitDuration(heartbeatAt)); + if (delivery.overflowed()) { + reconcile = true; + continue; + } + for (EventRecord event : delivery.events()) { + PublicEvent publicEvent = agentService.publicEvent(event); + emitter.send(SseEmitter.event() + .id(Long.toString(publicEvent.sequence())) + .name(publicEvent.type()).data(publicEvent)); + sequence = publicEvent.sequence(); + } + if (delivery.events().isEmpty()) { + reconcile = true; + heartbeatAt = heartbeat(emitter, heartbeatAt); + } + } + } catch (IOException error) { + closed.set(true); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + completeWithError(emitter, closed, error); + } catch (RuntimeException error) { + completeWithError(emitter, closed, error); + } + } + + private void streamWebShell(SseEmitter emitter, String tenantId, + String sessionId, long initialSequence) { + AtomicBoolean closed = callbacks(emitter); + long sequence = initialSequence; + long heartbeatAt = System.nanoTime() + + heartbeatInterval.toNanos(); + try (Subscription subscription = eventHub.subscribe(tenantId, + sessionId)) { + boolean reconcile = true; + while (!closed.get()) { + if (reconcile) { + List events = agentService.webShellEvents( + tenantId, sessionId, sequence, 100); + for (WebShellEvent event : events) { + emitter.send(SseEmitter.event() + .id(Long.toString(event.sequence())) + .name(event.type()).data(event)); + sequence = event.sequence(); + } + if (events.size() == 100) { + continue; + } + reconcile = false; + } + Delivery delivery = subscription.await(sequence, + waitDuration(heartbeatAt)); + if (delivery.overflowed()) { + reconcile = true; + continue; + } + for (EventRecord event : delivery.events()) { + WebShellEvent webEvent = agentService.webShellEvent(event); + emitter.send(SseEmitter.event() + .id(Long.toString(webEvent.sequence())) + .name(webEvent.type()).data(webEvent)); + sequence = webEvent.sequence(); + } + if (delivery.events().isEmpty()) { + reconcile = true; + heartbeatAt = heartbeat(emitter, heartbeatAt); + } + } + } catch (IOException error) { + closed.set(true); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + completeWithError(emitter, closed, error); + } catch (RuntimeException error) { + completeWithError(emitter, closed, error); + } + } + + private Duration waitDuration(long heartbeatAt) { + Duration untilHeartbeat = Duration.ofNanos(Math.max(1, + heartbeatAt - System.nanoTime())); + return untilHeartbeat.compareTo(reconciliationInterval) < 0 + ? untilHeartbeat : reconciliationInterval; + } + + private long heartbeat(SseEmitter emitter, long heartbeatAt) + throws IOException { + long now = System.nanoTime(); + if (now >= heartbeatAt) { + emitter.send(SseEmitter.event().comment("keepalive")); + heartbeatAt = now + heartbeatInterval.toNanos(); + } + return heartbeatAt; + } + + private static AtomicBoolean callbacks(SseEmitter emitter) { + AtomicBoolean closed = new AtomicBoolean(); + emitter.onCompletion(() -> closed.set(true)); + emitter.onTimeout(() -> closed.set(true)); + emitter.onError(error -> closed.set(true)); + return closed; + } + + private static void completeWithError(SseEmitter emitter, + AtomicBoolean closed, Throwable error) { + if (closed.compareAndSet(false, true)) { + emitter.completeWithError(error); + } + } +} diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/MessageMaterializer.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/MessageMaterializer.java new file mode 100644 index 00000000000..f6414e807de --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/MessageMaterializer.java @@ -0,0 +1,36 @@ +package com.alibaba.qwen.code.managedagent.service; + +import com.alibaba.qwen.code.managedagent.store.AgentStateStore; +import com.alibaba.qwen.code.managedagent.store.StoreModels.MaterializationTarget; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +public class MessageMaterializer { + private static final Logger LOG = LoggerFactory.getLogger( + MessageMaterializer.class); + private static final int TARGET_LIMIT = 32; + private static final int EVENT_LIMIT = 200; + private final AgentStateStore store; + + public MessageMaterializer(AgentStateStore store) { + this.store = store; + } + + @Scheduled(fixedDelayString = + "${qwen.managed-agent.events.materialize-interval:100ms}") + public void materialize() { + for (MaterializationTarget target : + store.findMaterializationTargets(TARGET_LIMIT)) { + try { + store.materializeNextBatch(target.tenantId(), + target.sessionId(), EVENT_LIMIT); + } catch (RuntimeException error) { + LOG.warn("Failed to materialize Managed Agent session {}", + target.sessionId(), error); + } + } + } +} diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/RequestDigests.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/RequestDigests.java new file mode 100644 index 00000000000..85453412691 --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/RequestDigests.java @@ -0,0 +1,40 @@ +package com.alibaba.qwen.code.managedagent.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.json.JsonMapper; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import org.springframework.stereotype.Component; + +@Component +public class RequestDigests { + private final ObjectMapper canonicalMapper; + + public RequestDigests() { + this.canonicalMapper = JsonMapper.builder() + .enable(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY) + .enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS) + .build(); + } + + public String digest(Object value) { + try { + byte[] encoded = canonicalMapper.writeValueAsBytes(value); + byte[] hashed = MessageDigest.getInstance("SHA-256") + .digest(encoded); + StringBuilder result = new StringBuilder("sha256:"); + for (byte item : hashed) { + result.append(String.format("%02x", item & 0xff)); + } + return result.toString(); + } catch (JsonProcessingException error) { + throw new IllegalArgumentException( + "Request cannot be canonicalized", error); + } catch (NoSuchAlgorithmException error) { + throw new IllegalStateException("SHA-256 is unavailable", error); + } + } +} diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/RuntimeWarmer.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/RuntimeWarmer.java new file mode 100644 index 00000000000..f43e4acf3ef --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/RuntimeWarmer.java @@ -0,0 +1,13 @@ +package com.alibaba.qwen.code.managedagent.service; + +import java.util.concurrent.CompletionStage; + +public interface RuntimeWarmer { + boolean isEnabled(); + + CompletionStage warm(String sessionId); + + CompletionStage drain(String sessionId); + + void resume(String sessionId); +} diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/SessionEventHub.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/SessionEventHub.java new file mode 100644 index 00000000000..e158fd6bb3c --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/service/SessionEventHub.java @@ -0,0 +1,135 @@ +package com.alibaba.qwen.code.managedagent.service; + +import com.alibaba.qwen.code.managedagent.store.CommittedEventPublisher; +import com.alibaba.qwen.code.managedagent.store.StoreModels.EventRecord; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.NavigableMap; +import java.util.TreeMap; +import java.util.concurrent.ConcurrentHashMap; +import org.springframework.stereotype.Component; + +@Component +public class SessionEventHub implements CommittedEventPublisher { + private static final int CAPACITY = 512; + private final Map buffers = + new ConcurrentHashMap<>(); + + @Override + public void publish(List events) { + if (events.isEmpty()) { + return; + } + EventRecord first = events.get(0); + SessionBuffer buffer = buffers.get(new SessionKey(first.tenantId(), + first.sessionId())); + if (buffer != null) { + buffer.publish(events); + } + } + + public Subscription subscribe(String tenantId, String sessionId) { + SessionKey key = new SessionKey(tenantId, sessionId); + SessionBuffer buffer = buffers.compute(key, (ignored, current) -> { + SessionBuffer result = current == null + ? new SessionBuffer() : current; + result.retain(); + return result; + }); + return new Subscription(key, buffer); + } + + public record Delivery(List events, boolean overflowed) { + } + + public final class Subscription implements AutoCloseable { + private final SessionKey key; + private final SessionBuffer buffer; + private boolean closed; + + private Subscription(SessionKey key, SessionBuffer buffer) { + this.key = key; + this.buffer = buffer; + } + + public Delivery await(long afterSequence, Duration timeout) + throws InterruptedException { + if (closed) { + return new Delivery(List.of(), false); + } + return buffer.await(afterSequence, timeout); + } + + @Override + public void close() { + if (closed) { + return; + } + closed = true; + buffers.computeIfPresent(key, (ignored, current) -> + current == buffer && current.release() ? null : current); + } + } + + private static final class SessionBuffer { + private final NavigableMap events = + new TreeMap<>(); + private int references; + private long droppedThrough; + + synchronized void retain() { + references++; + } + + synchronized boolean release() { + references--; + return references == 0; + } + + synchronized void publish(List committed) { + for (EventRecord event : committed) { + events.put(event.sequence(), event); + if (events.size() > CAPACITY) { + droppedThrough = events.pollFirstEntry().getKey(); + } + } + notifyAll(); + } + + synchronized Delivery await(long afterSequence, Duration timeout) + throws InterruptedException { + if (!hasAfter(afterSequence)) { + long millis = timeout.toMillis(); + int nanos = (int) (timeout.minusMillis(millis).toNanos()); + wait(millis, nanos); + } + boolean overflowed = afterSequence < droppedThrough; + List available = new ArrayList<>(); + if (!overflowed) { + long expected = afterSequence + 1; + for (EventRecord event : events.tailMap(expected, true) + .values()) { + if (event.sequence() != expected) { + overflowed = true; + available.clear(); + break; + } + available.add(event); + expected++; + } + } + return new Delivery(List.copyOf(available), overflowed); + } + + private boolean hasAfter(long sequence) { + return droppedThrough > sequence + || !events.isEmpty() + && events.lastKey() > sequence; + } + } + + private record SessionKey(String tenantId, String sessionId) { + } +} diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/AgentStateStore.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/AgentStateStore.java new file mode 100644 index 00000000000..f0c275fe137 --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/AgentStateStore.java @@ -0,0 +1,136 @@ +package com.alibaba.qwen.code.managedagent.store; + +import com.alibaba.qwen.code.managedagent.store.StoreModels.Admission; +import com.alibaba.qwen.code.managedagent.store.StoreModels.CommandRecord; +import com.alibaba.qwen.code.managedagent.store.StoreModels.DispatchTarget; +import com.alibaba.qwen.code.managedagent.store.StoreModels.EventPage; +import com.alibaba.qwen.code.managedagent.store.StoreModels.EventRecord; +import com.alibaba.qwen.code.managedagent.store.StoreModels.HarnessEvent; +import com.alibaba.qwen.code.managedagent.store.StoreModels.MaterializationResult; +import com.alibaba.qwen.code.managedagent.store.StoreModels.MaterializationTarget; +import com.alibaba.qwen.code.managedagent.store.StoreModels.SessionPage; +import com.alibaba.qwen.code.managedagent.store.StoreModels.SessionRecord; +import com.alibaba.qwen.code.managedagent.store.StoreModels.SessionMutationCommand; +import com.alibaba.qwen.code.managedagent.store.StoreModels.SessionMutationKind; +import com.alibaba.qwen.code.managedagent.store.StoreModels.SnapshotRecord; +import com.alibaba.qwen.code.managedagent.store.StoreModels.TurnRecord; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public interface AgentStateStore { + Admission insertSessionCommand(String tenantId, String operation, + String idempotencyKey, String requestDigest, String agentId, + String title, List> input, + String payloadDigest); + + Admission insertTurnCommand(String tenantId, String operation, + String idempotencyKey, String requestDigest, String sessionId, + List> input, String payloadDigest); + + Admission insertCancelCommand(String tenantId, String operation, + String idempotencyKey, String requestDigest, String sessionId, + String turnId); + + SessionMutationCommand beginSessionMutation(String tenantId, + String operation, String idempotencyKey, String requestDigest, + String sessionId, SessionMutationKind kind); + + SessionRecord completeSessionMutation(String tenantId, String operation, + String idempotencyKey, String sessionId, + SessionMutationKind kind, String title, String harnessBootId); + + Admission replayCommand(String tenantId, String operation, + String idempotencyKey, String requestDigest); + + Optional findCommand(String tenantId, String operation, + String idempotencyKey); + + Optional findSessionById(String sessionId); + + SessionPage listSessions(String tenantId, Long beforeUpdatedAt, + String beforeSessionId, int limit); + + Optional findTurn(String tenantId, String sessionId, + String turnId); + + Optional findActiveTurn(String tenantId, String sessionId); + + Optional findLatestTurn(String tenantId, String sessionId); + + List findEvents(String tenantId, String sessionId, + long afterSequence, int limit); + + Optional findLatestEnvironmentEvent(String tenantId, + String sessionId); + + List findControlEvents(String tenantId, String sessionId, + long throughSequence); + + EventPage findTranscriptEvents(String tenantId, String sessionId, + Long beforeSequence, int limit); + + Optional findSnapshot(String tenantId, + String sessionId); + + List findMaterializationTargets(int limit); + + MaterializationResult materializeNextBatch(String tenantId, + String sessionId, int limit); + + List findDispatchable(long now, int limit); + + Optional claimTurn(String tenantId, String sessionId, + String turnId, String owner, Duration leaseDuration); + + boolean renewTurn(String tenantId, String sessionId, String turnId, + String owner, Duration leaseDuration); + + void releaseTurnLease(String tenantId, String sessionId, String turnId, + String owner); + + void scheduleTurnRetry(String tenantId, String sessionId, String turnId, + String owner, long retryAfter); + + boolean bindHarness(String tenantId, String sessionId, String turnId, + String owner, String harnessBootId); + + boolean bindRecoveredHarness(String tenantId, String sessionId, + String turnId, String owner, String expectedHarnessBootId, + String harnessBootId); + + void markSubmissionAttempted(String tenantId, String sessionId, + String turnId, String owner); + + void recordAdmission(String tenantId, String sessionId, String turnId, + String owner, String eventEpoch, long lastEventId); + + void recordRecoveryAdmission(String tenantId, String sessionId, + String turnId, String owner, String expectedEventEpoch, + String eventEpoch, long lastEventId); + + /** + * Clears non-terminal text from a continuation epoch that did not reach + * a public terminal event, so the replacement stream is the only copy. + */ + void retractContinuationOutput(String tenantId, String sessionId, + String turnId, String owner, String harnessBootId, + String eventEpoch); + + void recordHarnessEvents(String tenantId, String sessionId, + String turnId, String owner, String eventEpoch, + List events); + + void cancelBeforeAdmission(String tenantId, String sessionId, + String turnId, String owner); + + void failTurn(String tenantId, String sessionId, String turnId, + String owner, String code, String message); + + void appendPublicEventIfAbsent(String tenantId, String sessionId, + String turnId, String type, Map data, + boolean terminal, String sourceKey); + + SessionRecord requireSession(String tenantId, String sessionId); +} diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/CommittedEventPublisher.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/CommittedEventPublisher.java new file mode 100644 index 00000000000..297dbe7bd8c --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/CommittedEventPublisher.java @@ -0,0 +1,8 @@ +package com.alibaba.qwen.code.managedagent.store; + +import com.alibaba.qwen.code.managedagent.store.StoreModels.EventRecord; +import java.util.List; + +public interface CommittedEventPublisher { + void publish(List events); +} diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedAgentStore.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedAgentStore.java new file mode 100644 index 00000000000..61251bde3de --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedAgentStore.java @@ -0,0 +1,1647 @@ +package com.alibaba.qwen.code.managedagent.store; + +import com.alibaba.qwen.code.managedagent.api.ApiException; +import com.alibaba.qwen.code.managedagent.store.StoreModels.Admission; +import com.alibaba.qwen.code.managedagent.store.StoreModels.CommandRecord; +import com.alibaba.qwen.code.managedagent.store.StoreModels.DispatchTarget; +import com.alibaba.qwen.code.managedagent.store.StoreModels.EventRecord; +import com.alibaba.qwen.code.managedagent.store.StoreModels.EventPage; +import com.alibaba.qwen.code.managedagent.store.StoreModels.HarnessEvent; +import com.alibaba.qwen.code.managedagent.store.StoreModels.ItemPartRecord; +import com.alibaba.qwen.code.managedagent.store.StoreModels.ItemRecord; +import com.alibaba.qwen.code.managedagent.store.StoreModels.MaterializationResult; +import com.alibaba.qwen.code.managedagent.store.StoreModels.MaterializationTarget; +import com.alibaba.qwen.code.managedagent.store.StoreModels.ProjectedEvent; +import com.alibaba.qwen.code.managedagent.store.StoreModels.SessionPage; +import com.alibaba.qwen.code.managedagent.store.StoreModels.SessionRecord; +import com.alibaba.qwen.code.managedagent.store.StoreModels.SessionMutationCommand; +import com.alibaba.qwen.code.managedagent.store.StoreModels.SessionMutationKind; +import com.alibaba.qwen.code.managedagent.store.StoreModels.SnapshotRecord; +import com.alibaba.qwen.code.managedagent.store.StoreModels.TurnRecord; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.http.HttpStatus; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +@Repository +public class ManagedAgentStore implements AgentStateStore { + private static final TypeReference>> INPUT_TYPE = + new TypeReference<>() { + }; + private static final TypeReference> MAP_TYPE = + new TypeReference<>() { + }; + private static final TypeReference> ITEMS_TYPE = + new TypeReference<>() { + }; + private static final String MESSAGE_PROJECTION = "message_projection"; + private static final List ACTIVE_TURN_STATES = List.of( + "ACCEPTED", "RUNNING", "CANCELLING"); + private final JdbcTemplate jdbc; + private final ObjectMapper objectMapper; + private final Clock clock; + private final CommittedEventPublisher eventPublisher; + private final RowMapper sessionMapper = (result, row) -> + new SessionRecord(result.getString("tenant_id"), + result.getString("session_id"), + result.getString("agent_id"), result.getString("title"), + result.getString("status"), + result.getString("harness_boot_id"), + result.getString("harness_event_epoch"), + result.getLong("harness_last_event_id"), + result.getLong("last_sequence"), + result.getLong("created_at"), + result.getLong("updated_at"), + nullableLong(result, "deleted_at"), + result.getLong("version")); + private final RowMapper turnMapper = (result, row) -> + new TurnRecord(result.getString("tenant_id"), + result.getString("session_id"), + result.getString("turn_id"), + result.getString("prompt_id"), + readInput(result.getString("input_json")), + result.getString("payload_digest"), + result.getString("status"), + result.getBoolean("submission_attempted"), + result.getString("harness_event_epoch"), + nullableLong(result, "harness_last_event_id"), + result.getString("dispatch_owner"), + nullableLong(result, "dispatch_lease_until"), + result.getInt("retry_count"), + nullableLong(result, "retry_after"), + result.getString("error_code"), + result.getString("error_message"), + result.getLong("created_at"), + result.getLong("updated_at"), + nullableLong(result, "completed_at"), + result.getLong("version")); + private final RowMapper eventMapper = (result, row) -> + new EventRecord(result.getString("tenant_id"), + result.getString("session_id"), + result.getLong("sequence_id"), + result.getString("event_id"), + result.getString("turn_id"), + result.getString("event_type"), + readMap(result.getString("data_json")), + result.getBoolean("terminal"), + result.getString("source_key"), + result.getLong("created_at")); + private final RowMapper itemMapper = (result, row) -> + new ItemRow(result.getString("tenant_id"), + result.getString("session_id"), + result.getString("item_id"), + result.getString("turn_id"), + result.getString("item_type"), + result.getString("item_role"), + result.getString("item_status"), + readMap(result.getString("attributes_json")), + result.getLong("first_sequence"), + result.getLong("last_sequence"), + result.getLong("created_at"), + result.getLong("updated_at"), + result.getLong("revision")); + private final RowMapper partMapper = (result, row) -> + new ItemPartRow(result.getString("item_id"), + new ItemPartRecord(result.getString("part_id"), + result.getString("part_type"), + result.getString("part_text"), + result.getLong("first_sequence"), + result.getLong("last_sequence"), + result.getLong("created_at"), + result.getLong("updated_at"), + result.getLong("revision"))); + + public ManagedAgentStore(JdbcTemplate jdbc, ObjectMapper objectMapper, + Clock clock, CommittedEventPublisher eventPublisher) { + this.jdbc = jdbc; + this.objectMapper = objectMapper; + this.clock = clock; + this.eventPublisher = eventPublisher; + } + + @Transactional + public Admission insertSessionCommand(String tenantId, String operation, + String idempotencyKey, String requestDigest, String agentId, + String title, List> input, + String payloadDigest) { + long now = clock.millis(); + String sessionId = UUID.randomUUID().toString(); + String turnId = input.isEmpty() ? null : publicId("turn"); + String promptId = input.isEmpty() ? null + : UUID.randomUUID().toString(); + jdbc.update("INSERT INTO managed_agent_session (tenant_id," + + " session_id, agent_id, title, status, created_at," + + " updated_at) VALUES (?, ?, ?, ?, 'ACTIVE', ?, ?)", + tenantId, sessionId, agentId, title, now, now); + jdbc.update("INSERT INTO managed_agent_consumer_progress" + + " (tenant_id, session_id, consumer_name," + + " covered_sequence, updated_at) VALUES" + + " (?, ?, ?, 0, ?)", + tenantId, sessionId, MESSAGE_PROJECTION, now); + if (turnId != null) { + insertTurn(tenantId, sessionId, turnId, promptId, input, + payloadDigest, now); + } + insertCommand(tenantId, operation, idempotencyKey, requestDigest, + sessionId, turnId, now); + appendEvent(tenantId, sessionId, null, "session.created", + Map.of("sessionId", sessionId), false, null, now); + if (turnId != null) { + appendEvent(tenantId, sessionId, turnId, "turn.accepted", + acceptedData(turnId, input), false, null, now); + } + return new Admission(sessionId, turnId, false, true); + } + + @Transactional + public Admission insertTurnCommand(String tenantId, String operation, + String idempotencyKey, String requestDigest, String sessionId, + List> input, String payloadDigest) { + SessionRecord session = requireSessionForUpdate(tenantId, sessionId); + Optional existing = findCommand(tenantId, operation, + idempotencyKey, true); + if (existing.isPresent()) { + return replayCommand(tenantId, operation, idempotencyKey, + requestDigest); + } + if (!"ACTIVE".equals(session.status())) { + throw new ApiException(HttpStatus.CONFLICT, "session_not_active", + "The Session does not accept new Turns."); + } + if (hasActiveTurn(tenantId, sessionId)) { + throw new ApiException(HttpStatus.CONFLICT, "turn_active", + "The Session already has an active Turn."); + } + long now = clock.millis(); + String turnId = publicId("turn"); + insertTurn(tenantId, sessionId, turnId, + UUID.randomUUID().toString(), input, payloadDigest, now); + insertCommand(tenantId, operation, idempotencyKey, requestDigest, + sessionId, turnId, now); + appendEvent(tenantId, sessionId, turnId, "turn.accepted", + acceptedData(turnId, input), false, null, now); + return new Admission(sessionId, turnId, false, true); + } + + @Transactional + public Admission insertCancelCommand(String tenantId, String operation, + String idempotencyKey, String requestDigest, String sessionId, + String turnId) { + requireSessionForUpdate(tenantId, sessionId); + TurnRecord turn = requireTurn(tenantId, sessionId, turnId); + long now = clock.millis(); + insertCommand(tenantId, operation, idempotencyKey, requestDigest, + sessionId, turnId, now); + boolean commandEffect = ACTIVE_TURN_STATES.contains(turn.status()) + && !"CANCELLING".equals(turn.status()); + if (commandEffect) { + int updated = jdbc.update("UPDATE managed_agent_turn SET status =" + + " 'CANCELLING', updated_at = ?, version =" + + " version + 1 WHERE tenant_id = ? AND" + + " session_id = ? AND turn_id = ? AND status IN" + + " ('ACCEPTED', 'RUNNING')", + now, tenantId, sessionId, turnId); + commandEffect = updated == 1; + if (commandEffect) { + appendEvent(tenantId, sessionId, turnId, + "turn.cancel.requested", Map.of("turnId", turnId), + false, null, now); + } + } + return new Admission(sessionId, turnId, false, commandEffect); + } + + @Transactional + public SessionMutationCommand beginSessionMutation(String tenantId, + String operation, String idempotencyKey, String requestDigest, + String sessionId, SessionMutationKind kind) { + SessionRecord session = requireSessionForUpdate(tenantId, sessionId); + Optional existing = findCommand(tenantId, operation, + idempotencyKey, true); + if (existing.isPresent()) { + CommandRecord command = existing.get(); + if (!command.requestDigest().equals(requestDigest) + || !command.sessionId().equals(sessionId)) { + throw new ApiException(HttpStatus.CONFLICT, + "idempotency_conflict", + "The idempotency key was reused with different content."); + } + return new SessionMutationCommand(sessionId, command.status(), + command.sessionStatusBefore(), true); + } + Integer pending = jdbc.queryForObject("SELECT COUNT(*) FROM" + + " managed_agent_command WHERE tenant_id = ? AND" + + " session_id = ? AND command_status = 'PENDING'", + Integer.class, tenantId, sessionId); + if (pending != null && pending > 0) { + throw new ApiException(HttpStatus.CONFLICT, + "session_operation_active", + "The Session already has a lifecycle operation in progress."); + } + validateMutationStart(session, kind); + long now = clock.millis(); + insertCommand(tenantId, operation, idempotencyKey, requestDigest, + sessionId, null, "PENDING", session.status(), now); + String pendingStatus = pendingStatus(kind); + if (pendingStatus != null) { + jdbc.update("UPDATE managed_agent_session SET status = ?," + + " updated_at = ?, version = version + 1 WHERE" + + " tenant_id = ? AND session_id = ?", + pendingStatus, now, tenantId, sessionId); + } + appendEvent(tenantId, sessionId, null, + mutationEvent(kind, "requested"), + Map.of("sessionId", sessionId), false, + mutationSource(operation, idempotencyKey, "requested"), now); + return new SessionMutationCommand(sessionId, "PENDING", + session.status(), false); + } + + @Transactional + public SessionRecord completeSessionMutation(String tenantId, + String operation, String idempotencyKey, String sessionId, + SessionMutationKind kind, String title, String harnessBootId) { + SessionRecord session = requireSessionForUpdate(tenantId, sessionId); + CommandRecord command = findCommand(tenantId, operation, + idempotencyKey, true).orElseThrow(() -> + new IllegalStateException( + "Session mutation command is unavailable")); + if (!command.sessionId().equals(sessionId)) { + throw new ApiException(HttpStatus.CONFLICT, + "idempotency_conflict", + "The idempotency key belongs to another Session."); + } + if ("COMPLETED".equals(command.status())) { + return session; + } + if (!"PENDING".equals(command.status())) { + throw new IllegalStateException( + "Session mutation command has an unknown status"); + } + validateMutationCompletion(session, kind); + long now = clock.millis(); + Map data = Map.of("sessionId", sessionId); + switch (kind) { + case RENAME -> { + jdbc.update("UPDATE managed_agent_session SET title = ?," + + " harness_boot_id =" + + " COALESCE(harness_boot_id, ?)," + + " updated_at = ?, version = version + 1" + + " WHERE tenant_id = ? AND session_id = ?", + title, harnessBootId, now, tenantId, sessionId); + data = Map.of("sessionId", sessionId, + "metadata", Map.of("title", title)); + } + case ARCHIVE -> jdbc.update("UPDATE managed_agent_session SET" + + " status = 'ARCHIVED', harness_event_epoch = NULL," + + " harness_last_event_id = 0, updated_at = ?," + + " version = version + 1 WHERE tenant_id = ? AND" + + " session_id = ?", + now, tenantId, sessionId); + case UNARCHIVE -> jdbc.update("UPDATE managed_agent_session SET" + + " status = 'ACTIVE', updated_at = ?, version =" + + " version + 1 WHERE tenant_id = ? AND" + + " session_id = ?", + now, tenantId, sessionId); + case DELETE -> jdbc.update("UPDATE managed_agent_session SET" + + " status = 'DELETED', harness_boot_id = NULL," + + " harness_event_epoch = NULL," + + " harness_last_event_id = 0, deleted_at = ?," + + " updated_at = ?, version = version + 1 WHERE" + + " tenant_id = ? AND session_id = ?", + now, now, tenantId, sessionId); + } + jdbc.update("UPDATE managed_agent_command SET command_status =" + + " 'COMPLETED', updated_at = ? WHERE tenant_id = ?" + + " AND operation = ? AND idempotency_key = ?", + now, tenantId, operation, idempotencyKey); + appendEvent(tenantId, sessionId, null, + mutationEvent(kind, "completed"), data, + kind == SessionMutationKind.DELETE, + mutationSource(operation, idempotencyKey, + "completed"), now); + return requireSessionForUpdate(tenantId, sessionId); + } + + public Admission replayCommand(String tenantId, String operation, + String idempotencyKey, String requestDigest) { + CommandRecord command = findCommand(tenantId, operation, + idempotencyKey).orElseThrow(() -> new ApiException( + HttpStatus.CONFLICT, "idempotency_conflict", + "The idempotency key is already in use.")); + if (!command.requestDigest().equals(requestDigest)) { + throw new ApiException(HttpStatus.CONFLICT, + "idempotency_conflict", + "The idempotency key was reused with different content."); + } + return new Admission(command.sessionId(), command.turnId(), true, + false); + } + + public Optional findCommand(String tenantId, + String operation, String idempotencyKey) { + return findCommand(tenantId, operation, idempotencyKey, false); + } + + private Optional findCommand(String tenantId, + String operation, String idempotencyKey, boolean forUpdate) { + List rows = jdbc.query( + "SELECT tenant_id, operation, idempotency_key," + + " request_digest, session_id, turn_id," + + " command_status, session_status_before," + + " created_at, updated_at" + + " FROM managed_agent_command WHERE tenant_id = ?" + + " AND operation = ? AND idempotency_key = ?" + + (forUpdate ? " FOR UPDATE" : ""), + (result, row) -> new CommandRecord( + result.getString("tenant_id"), + result.getString("operation"), + result.getString("idempotency_key"), + result.getString("request_digest"), + result.getString("session_id"), + result.getString("turn_id"), + result.getString("command_status"), + result.getString("session_status_before"), + result.getLong("created_at"), + result.getLong("updated_at")), + tenantId, operation, idempotencyKey); + return rows.stream().findFirst(); + } + + public Optional findSession(String tenantId, + String sessionId) { + List rows = jdbc.query( + "SELECT * FROM managed_agent_session WHERE tenant_id = ?" + + " AND session_id = ?", + sessionMapper, tenantId, sessionId); + return rows.stream().findFirst(); + } + + public Optional findSessionById(String sessionId) { + List rows = jdbc.query( + "SELECT * FROM managed_agent_session WHERE" + + " session_id = ?", + sessionMapper, sessionId); + return rows.stream().findFirst(); + } + + public SessionPage listSessions(String tenantId, Long beforeUpdatedAt, + String beforeSessionId, int limit) { + List arguments = new ArrayList<>(); + arguments.add(tenantId); + String cursorClause = ""; + if (beforeUpdatedAt != null && beforeSessionId != null) { + cursorClause = " AND (updated_at < ? OR (updated_at = ?" + + " AND session_id < ?))"; + arguments.add(beforeUpdatedAt); + arguments.add(beforeUpdatedAt); + arguments.add(beforeSessionId); + } + arguments.add(limit + 1); + List rows = jdbc.query( + "SELECT * FROM managed_agent_session WHERE tenant_id = ?" + + " AND status <> 'DELETED'" + + cursorClause + + " ORDER BY updated_at DESC, session_id DESC LIMIT ?", + sessionMapper, arguments.toArray()); + boolean hasMore = rows.size() > limit; + if (hasMore) { + rows = new ArrayList<>(rows.subList(0, limit)); + } + return new SessionPage(List.copyOf(rows), hasMore); + } + + public Optional findTurn(String tenantId, String sessionId, + String turnId) { + List rows = jdbc.query( + "SELECT * FROM managed_agent_turn WHERE tenant_id = ? AND" + + " session_id = ? AND turn_id = ?", + turnMapper, tenantId, sessionId, turnId); + return rows.stream().findFirst(); + } + + public Optional findActiveTurn(String tenantId, + String sessionId) { + List rows = jdbc.query( + "SELECT * FROM managed_agent_turn WHERE tenant_id = ? AND" + + " session_id = ? AND status IN" + + " ('ACCEPTED', 'RUNNING', 'CANCELLING')" + + " ORDER BY created_at DESC LIMIT 1", + turnMapper, tenantId, sessionId); + return rows.stream().findFirst(); + } + + public Optional findLatestTurn(String tenantId, + String sessionId) { + List rows = jdbc.query( + "SELECT * FROM managed_agent_turn WHERE tenant_id = ? AND" + + " session_id = ? ORDER BY created_at DESC," + + " turn_id DESC LIMIT 1", + turnMapper, tenantId, sessionId); + return rows.stream().findFirst(); + } + + public List findEvents(String tenantId, String sessionId, + long afterSequence, int limit) { + requireSession(tenantId, sessionId); + return jdbc.query("SELECT * FROM managed_agent_event WHERE" + + " tenant_id = ? AND session_id = ? AND" + + " sequence_id > ? ORDER BY sequence_id ASC LIMIT ?", + eventMapper, + tenantId, sessionId, afterSequence, limit); + } + + public Optional findLatestEnvironmentEvent(String tenantId, + String sessionId) { + requireSession(tenantId, sessionId); + return jdbc.query("SELECT event.* FROM managed_agent_event event" + + " JOIN managed_agent_turn turn_record ON" + + " turn_record.tenant_id = event.tenant_id AND" + + " turn_record.session_id = event.session_id AND" + + " turn_record.turn_id = event.turn_id WHERE" + + " event.tenant_id = ? AND event.session_id = ? AND" + + " event.event_type IN ('environment.provisioning'," + + " 'environment.ready', 'environment.failed') AND" + + " turn_record.turn_id = (SELECT turn_id FROM" + + " managed_agent_turn WHERE tenant_id = ? AND" + + " session_id = ? ORDER BY created_at DESC, turn_id" + + " DESC LIMIT 1) ORDER BY event.sequence_id DESC" + + " LIMIT 1", + eventMapper, tenantId, sessionId, tenantId, sessionId) + .stream().findFirst(); + } + + public List findControlEvents(String tenantId, + String sessionId, long throughSequence) { + requireSession(tenantId, sessionId); + return jdbc.query("SELECT * FROM managed_agent_event WHERE" + + " tenant_id = ? AND session_id = ? AND" + + " sequence_id <= ? AND event_type NOT IN" + + " ('turn.accepted', 'item.output_text.delta'," + + " 'item.reasoning.delta'," + + " 'item.tool_call.updated') ORDER BY sequence_id" + + " ASC", + eventMapper, tenantId, sessionId, throughSequence); + } + + public EventPage findTranscriptEvents(String tenantId, String sessionId, + Long beforeSequence, int limit) { + requireSession(tenantId, sessionId); + List rows = beforeSequence == null + ? jdbc.query("SELECT * FROM managed_agent_event WHERE" + + " tenant_id = ? AND session_id = ?" + + " ORDER BY sequence_id DESC LIMIT ?", + eventMapper, tenantId, sessionId, limit + 1) + : jdbc.query("SELECT * FROM managed_agent_event WHERE" + + " tenant_id = ? AND session_id = ? AND" + + " sequence_id < ? ORDER BY sequence_id" + + " DESC LIMIT ?", + eventMapper, tenantId, sessionId, beforeSequence, + limit + 1); + boolean hasMore = rows.size() > limit; + if (hasMore) { + rows = new ArrayList<>(rows.subList(0, limit)); + } else { + rows = new ArrayList<>(rows); + } + java.util.Collections.reverse(rows); + return new EventPage(List.copyOf(rows), hasMore); + } + + public Optional findSnapshot(String tenantId, + String sessionId) { + requireSession(tenantId, sessionId); + List rows = jdbc.query( + "SELECT * FROM managed_agent_snapshot WHERE tenant_id = ?" + + " AND session_id = ?", + (result, row) -> new SnapshotRecord( + result.getString("tenant_id"), + result.getString("session_id"), + result.getLong("snapshot_version"), + result.getLong("covered_sequence"), + readItems(result.getString("items_json")), + result.getLong("created_at"), + result.getLong("updated_at")), + tenantId, sessionId); + return rows.stream().findFirst(); + } + + public List findMaterializationTargets(int limit) { + return jdbc.query("SELECT s.tenant_id, s.session_id FROM" + + " managed_agent_session s JOIN" + + " managed_agent_consumer_progress p ON" + + " p.tenant_id = s.tenant_id AND p.session_id =" + + " s.session_id AND p.consumer_name = ? WHERE" + + " s.last_sequence > p.covered_sequence ORDER BY" + + " p.updated_at ASC LIMIT ?", + (result, row) -> new MaterializationTarget( + result.getString("tenant_id"), + result.getString("session_id")), + MESSAGE_PROJECTION, limit); + } + + @Transactional + public MaterializationResult materializeNextBatch(String tenantId, + String sessionId, int limit) { + requireSessionForUpdate(tenantId, sessionId); + Long covered = jdbc.queryForObject("SELECT covered_sequence FROM" + + " managed_agent_consumer_progress WHERE tenant_id" + + " = ? AND session_id = ? AND consumer_name = ?" + + " FOR UPDATE", + Long.class, tenantId, sessionId, MESSAGE_PROJECTION); + if (covered == null) { + throw new IllegalStateException( + "Message projection progress is unavailable"); + } + List events = jdbc.query("SELECT * FROM" + + " managed_agent_event WHERE tenant_id = ? AND" + + " session_id = ? AND sequence_id > ? ORDER BY" + + " sequence_id ASC LIMIT ?", + eventMapper, tenantId, sessionId, covered, limit); + if (events.isEmpty()) { + return new MaterializationResult(false, covered); + } + long expected = covered + 1; + for (EventRecord event : events) { + if (event.sequence() != expected) { + throw new IllegalStateException( + "Message projection event sequence has a gap"); + } + materializeEvent(event); + expected++; + } + long nextCovered = events.get(events.size() - 1).sequence(); + long now = clock.millis(); + jdbc.update("UPDATE managed_agent_consumer_progress SET" + + " covered_sequence = ?, updated_at = ? WHERE" + + " tenant_id = ? AND session_id = ? AND" + + " consumer_name = ?", + nextCovered, now, tenantId, sessionId, MESSAGE_PROJECTION); + List items = allItems(tenantId, sessionId); + List versions = jdbc.query("SELECT snapshot_version FROM" + + " managed_agent_snapshot WHERE tenant_id = ? AND" + + " session_id = ? FOR UPDATE", + (result, row) -> result.getLong("snapshot_version"), + tenantId, sessionId); + if (versions.isEmpty()) { + jdbc.update("INSERT INTO managed_agent_snapshot (tenant_id," + + " session_id, snapshot_version," + + " covered_sequence, items_json, created_at," + + " updated_at) VALUES (?, ?, 1, ?, ?, ?, ?)", + tenantId, sessionId, nextCovered, writeJson(items), now, + now); + } else { + jdbc.update("UPDATE managed_agent_snapshot SET" + + " snapshot_version = ?, covered_sequence = ?," + + " items_json = ?, updated_at = ? WHERE" + + " tenant_id = ? AND session_id = ?", + versions.get(0) + 1, nextCovered, writeJson(items), now, + tenantId, sessionId); + } + return new MaterializationResult(true, nextCovered); + } + + public List findDispatchable(long now, int limit) { + return jdbc.query("SELECT tenant_id, session_id, turn_id FROM" + + " managed_agent_turn WHERE status IN" + + " ('ACCEPTED', 'RUNNING', 'CANCELLING') AND" + + " (dispatch_lease_until IS NULL OR" + + " dispatch_lease_until < ?) AND (retry_after IS" + + " NULL OR retry_after <= ?)" + + " ORDER BY updated_at ASC LIMIT ?", + (result, row) -> new DispatchTarget( + result.getString("tenant_id"), + result.getString("session_id"), + result.getString("turn_id")), now, now, limit); + } + + @Transactional + public Optional claimTurn(String tenantId, String sessionId, + String turnId, String owner, Duration leaseDuration) { + long now = clock.millis(); + int updated = jdbc.update("UPDATE managed_agent_turn SET" + + " dispatch_owner = ?, dispatch_lease_until = ?," + + " updated_at = ?, version = version + 1 WHERE" + + " tenant_id = ? AND session_id = ? AND turn_id = ?" + + " AND status IN ('ACCEPTED', 'RUNNING'," + + " 'CANCELLING') AND (dispatch_lease_until IS NULL" + + " OR dispatch_lease_until < ?) AND (retry_after IS" + + " NULL OR retry_after <= ?)", + owner, now + leaseDuration.toMillis(), now, tenantId, + sessionId, turnId, now, now); + return updated == 0 ? Optional.empty() + : findTurn(tenantId, sessionId, turnId); + } + + public boolean renewTurn(String tenantId, String sessionId, + String turnId, String owner, Duration leaseDuration) { + long now = clock.millis(); + return jdbc.update("UPDATE managed_agent_turn SET" + + " dispatch_lease_until = ?, version = version + 1" + + " WHERE tenant_id = ? AND session_id = ? AND" + + " turn_id = ? AND dispatch_owner = ? AND status IN" + + " ('ACCEPTED', 'RUNNING', 'CANCELLING') AND" + + " dispatch_lease_until >= ?", + now + leaseDuration.toMillis(), tenantId, sessionId, turnId, + owner, now) == 1; + } + + public void releaseTurnLease(String tenantId, String sessionId, + String turnId, String owner) { + jdbc.update("UPDATE managed_agent_turn SET dispatch_owner = NULL," + + " dispatch_lease_until = NULL, version = version +" + + " 1 WHERE tenant_id = ? AND session_id = ? AND" + + " turn_id = ? AND dispatch_owner = ? AND status IN" + + " ('ACCEPTED', 'RUNNING', 'CANCELLING')", + tenantId, sessionId, turnId, owner); + } + + public void scheduleTurnRetry(String tenantId, String sessionId, + String turnId, String owner, long retryAfter) { + jdbc.update("UPDATE managed_agent_turn SET retry_count = retry_count" + + " + 1, retry_after = ?, dispatch_owner = NULL," + + " dispatch_lease_until = NULL, updated_at = ?," + + " version = version + 1 WHERE tenant_id = ? AND" + + " session_id = ? AND turn_id = ? AND dispatch_owner" + + " = ? AND status IN ('ACCEPTED', 'RUNNING'," + + " 'CANCELLING')", + retryAfter, clock.millis(), tenantId, sessionId, turnId, + owner); + } + + @Transactional + public boolean bindHarness(String tenantId, String sessionId, + String turnId, String owner, String harnessBootId) { + SessionRecord session = requireSessionForUpdate(tenantId, sessionId); + TurnRecord turn = requireTurnForUpdate(tenantId, sessionId, turnId); + long now = clock.millis(); + if (!owner.equals(turn.dispatchOwner()) + || turn.dispatchLeaseUntil() == null + || turn.dispatchLeaseUntil() < now + || !ACTIVE_TURN_STATES.contains(turn.status())) { + return false; + } + if (harnessBootId.equals(session.harnessBootId())) { + return true; + } + if (session.harnessBootId() != null + && (turn.submissionAttempted() + || turn.harnessEventEpoch() != null)) { + return false; + } + jdbc.update("UPDATE managed_agent_session SET harness_boot_id = ?," + + " updated_at = ?, version = version + 1 WHERE" + + " tenant_id = ? AND session_id = ?", + harnessBootId, now, tenantId, sessionId); + return true; + } + + @Transactional + public boolean bindRecoveredHarness(String tenantId, String sessionId, + String turnId, String owner, String expectedHarnessBootId, + String harnessBootId) { + SessionRecord session = requireSessionForUpdate(tenantId, sessionId); + TurnRecord turn = requireTurnForUpdate(tenantId, sessionId, turnId); + long now = clock.millis(); + if (!owner.equals(turn.dispatchOwner()) + || turn.dispatchLeaseUntil() == null + || turn.dispatchLeaseUntil() < now + || !ACTIVE_TURN_STATES.contains(turn.status()) + || !turn.submissionAttempted() + || turn.harnessEventEpoch() == null) { + return false; + } + if (harnessBootId.equals(session.harnessBootId())) { + return true; + } + if (!Objects.equals(expectedHarnessBootId, + session.harnessBootId())) { + return false; + } + int updated = jdbc.update("UPDATE managed_agent_session SET" + + " harness_boot_id = ?, updated_at = ?, version =" + + " version + 1 WHERE tenant_id = ? AND session_id" + + " = ? AND harness_boot_id = ?", + harnessBootId, now, tenantId, sessionId, + expectedHarnessBootId); + return updated == 1; + } + + @Transactional + public void markSubmissionAttempted(String tenantId, String sessionId, + String turnId, String owner) { + long now = clock.millis(); + int updated = jdbc.update("UPDATE managed_agent_turn SET" + + " submission_attempted = TRUE, updated_at = ?," + + " version = version + 1 WHERE tenant_id = ? AND" + + " session_id = ? AND turn_id = ? AND" + + " dispatch_owner = ? AND dispatch_lease_until" + + " >= ? AND harness_event_epoch IS NULL", + now, tenantId, sessionId, turnId, owner, now); + if (updated != 1) { + throw new IllegalStateException("Turn dispatch lease was lost"); + } + } + + @Transactional + public void recordAdmission(String tenantId, String sessionId, + String turnId, String owner, String eventEpoch, + long lastEventId) { + long now = clock.millis(); + int updated = jdbc.update("UPDATE managed_agent_turn SET status =" + + " CASE WHEN status = 'CANCELLING' THEN status ELSE" + + " 'RUNNING' END, harness_event_epoch = ?," + + " harness_last_event_id = ?, updated_at = ?," + + " version = version + 1 WHERE tenant_id = ? AND" + + " session_id = ? AND turn_id = ? AND" + + " dispatch_owner = ? AND dispatch_lease_until" + + " >= ?", + eventEpoch, lastEventId, now, tenantId, sessionId, turnId, + owner, now); + if (updated != 1) { + throw new IllegalStateException("Turn dispatch lease was lost"); + } + int sessionUpdated = jdbc.update("UPDATE managed_agent_session SET harness_event_epoch =" + + " ?, harness_last_event_id = ?, updated_at = ?," + + " version = version + 1 WHERE tenant_id = ? AND" + + " session_id = ?", + eventEpoch, lastEventId, now, tenantId, sessionId); + if (sessionUpdated != 1) { + throw new IllegalStateException("Session disappeared"); + } + if (!hasEventType(tenantId, sessionId, turnId, "turn.started")) { + appendEvent(tenantId, sessionId, turnId, "turn.started", + Map.of("turnId", turnId), false, null, now); + } + } + + @Transactional + public void recordRecoveryAdmission(String tenantId, String sessionId, + String turnId, String owner, String expectedEventEpoch, + String eventEpoch, long lastEventId) { + SessionRecord session = requireSessionForUpdate(tenantId, sessionId); + TurnRecord turn = requireTurnForUpdate(tenantId, sessionId, turnId); + long now = clock.millis(); + if (!owner.equals(turn.dispatchOwner()) + || turn.dispatchLeaseUntil() == null + || turn.dispatchLeaseUntil() < now + || !ACTIVE_TURN_STATES.contains(turn.status())) { + throw new IllegalStateException("Turn dispatch lease was lost"); + } + if (eventEpoch.equals(turn.harnessEventEpoch()) + && turn.harnessLastEventId() != null + && turn.harnessLastEventId() >= lastEventId) { + return; + } + if (!turn.submissionAttempted() + || !Objects.equals(expectedEventEpoch, + turn.harnessEventEpoch()) + || !Objects.equals(expectedEventEpoch, + session.harnessEventEpoch())) { + throw new IllegalStateException( + "Hosted Harness recovery epoch changed"); + } + int updated = jdbc.update("UPDATE managed_agent_turn SET status =" + + " CASE WHEN status = 'CANCELLING' THEN status ELSE" + + " 'RUNNING' END, harness_event_epoch = ?," + + " harness_last_event_id = ?, updated_at = ?," + + " version = version + 1 WHERE tenant_id = ? AND" + + " session_id = ? AND turn_id = ? AND" + + " dispatch_owner = ? AND dispatch_lease_until" + + " >= ? AND harness_event_epoch = ?", + eventEpoch, lastEventId, now, tenantId, sessionId, turnId, + owner, now, expectedEventEpoch); + if (updated != 1) { + throw new IllegalStateException("Turn dispatch lease was lost"); + } + int sessionUpdated = jdbc.update("UPDATE managed_agent_session SET harness_event_epoch =" + + " ?, harness_last_event_id = ?, updated_at = ?," + + " version = version + 1 WHERE tenant_id = ? AND" + + " session_id = ? AND harness_event_epoch = ?", + eventEpoch, lastEventId, now, tenantId, sessionId, + expectedEventEpoch); + if (sessionUpdated != 1) { + throw new IllegalStateException( + "Hosted Harness recovery epoch changed"); + } + } + + @Transactional + public void retractContinuationOutput(String tenantId, String sessionId, + String turnId, String owner, String harnessBootId, + String eventEpoch) { + requireSessionForUpdate(tenantId, sessionId); + TurnRecord turn = requireTurnForUpdate(tenantId, sessionId, turnId); + long now = clock.millis(); + if (!owner.equals(turn.dispatchOwner()) + || turn.dispatchLeaseUntil() == null + || turn.dispatchLeaseUntil() < now + || !ACTIVE_TURN_STATES.contains(turn.status())) { + throw new IllegalStateException("Turn dispatch lease was lost"); + } + if (harnessBootId.isBlank() || eventEpoch.isBlank()) { + throw new IllegalArgumentException( + "continuation owner is missing"); + } + String sourcePrefix = harnessBootId + ":" + eventEpoch + ":"; + String reconciliationKey = "reconcile:" + sourcePrefix + turnId; + if (hasSourceEvent(tenantId, sessionId, reconciliationKey)) { + return; + } + jdbc.queryForObject("SELECT covered_sequence FROM" + + " managed_agent_consumer_progress WHERE tenant_id" + + " = ? AND session_id = ? AND consumer_name = ?" + + " FOR UPDATE", + Long.class, tenantId, sessionId, MESSAGE_PROJECTION); + List deltas = jdbc.query("SELECT * FROM" + + " managed_agent_event WHERE tenant_id = ? AND" + + " session_id = ? AND turn_id = ? AND event_type" + + " IN ('item.output_text.delta'," + + " 'item.reasoning.delta') ORDER BY sequence_id ASC", + eventMapper, tenantId, sessionId, turnId); + for (EventRecord event : deltas) { + if (event.sourceKey() == null + || !event.sourceKey().startsWith(sourcePrefix)) { + continue; + } + Map data = new LinkedHashMap<>(event.data()); + data.put("text", ""); + jdbc.update("UPDATE managed_agent_event SET data_json = ?" + + " WHERE tenant_id = ? AND session_id = ?" + + " AND sequence_id = ?", + writeJson(data), tenantId, sessionId, event.sequence()); + } + // Rebuild shared text parts from retained events, including any + // output belonging to other Harness generations. + jdbc.update("DELETE FROM managed_agent_item_part WHERE tenant_id = ?" + + " AND session_id = ?", tenantId, sessionId); + jdbc.update("DELETE FROM managed_agent_item WHERE tenant_id = ?" + + " AND session_id = ?", tenantId, sessionId); + jdbc.update("DELETE FROM managed_agent_snapshot WHERE tenant_id = ?" + + " AND session_id = ?", tenantId, sessionId); + jdbc.update("UPDATE managed_agent_consumer_progress SET" + + " covered_sequence = 0, updated_at = ? WHERE" + + " tenant_id = ? AND session_id = ? AND" + + " consumer_name = ?", + now, tenantId, sessionId, MESSAGE_PROJECTION); + appendEvent(tenantId, sessionId, turnId, "stream.reconciled", Map.of(), + false, reconciliationKey, now); + } + + @Transactional + public void recordHarnessEvents(String tenantId, String sessionId, + String turnId, String owner, String eventEpoch, + List events) { + if (events.isEmpty()) { + return; + } + SessionRecord session = requireSessionForUpdate(tenantId, sessionId); + TurnRecord turn = requireTurnForUpdate(tenantId, sessionId, turnId); + if (!owner.equals(turn.dispatchOwner()) + || turn.dispatchLeaseUntil() == null + || turn.dispatchLeaseUntil() < clock.millis()) { + throw new IllegalStateException("Turn dispatch lease was lost"); + } + if (!eventEpoch.equals(turn.harnessEventEpoch())) { + throw new IllegalStateException( + "Hosted Harness event epoch changed"); + } + long lastSourceId = turn.harnessLastEventId() == null ? 0 + : turn.harnessLastEventId(); + List accepted = new ArrayList<>(); + for (HarnessEvent event : events) { + if (event.sourceId() > lastSourceId) { + accepted.add(event); + lastSourceId = event.sourceId(); + } + } + if (accepted.isEmpty()) { + return; + } + long now = clock.millis(); + HarnessEvent terminal = null; + for (int index = 0; index < accepted.size(); index++) { + HarnessEvent event = accepted.get(index); + if (event.projection() != null + && event.projection().terminal()) { + if (terminal != null || index != accepted.size() - 1) { + throw new IllegalArgumentException( + "Terminal Harness event must end the batch"); + } + terminal = event; + } + } + int updated = terminal == null + ? updateHarnessCursor(tenantId, sessionId, turnId, owner, + eventEpoch, lastSourceId, now) + : completeHarnessTurn(tenantId, sessionId, turnId, owner, + eventEpoch, lastSourceId, terminal.projection(), now); + if (updated != 1) { + throw new IllegalStateException("Turn dispatch lease was lost"); + } + List projected = accepted.stream() + .filter(event -> event.projection() != null).toList(); + List committed = appendEvents(tenantId, sessionId, + turnId, eventEpoch, lastSourceId, session.lastSequence(), + projected, now); + publishAfterCommit(committed); + } + + private int updateHarnessCursor(String tenantId, String sessionId, + String turnId, String owner, String eventEpoch, + long lastSourceId, long now) { + return jdbc.update("UPDATE managed_agent_turn SET" + + " harness_event_epoch = ?," + + " harness_last_event_id = ?, updated_at = ?," + + " version = version + 1 WHERE tenant_id = ? AND" + + " session_id = ? AND turn_id = ? AND" + + " dispatch_owner = ? AND dispatch_lease_until" + + " >= ?", + eventEpoch, lastSourceId, now, tenantId, sessionId, turnId, + owner, now); + } + + private int completeHarnessTurn(String tenantId, String sessionId, + String turnId, String owner, String eventEpoch, + long lastSourceId, ProjectedEvent terminal, long now) { + return jdbc.update("UPDATE managed_agent_turn SET" + + " harness_event_epoch = ?," + + " harness_last_event_id = ?, status = ?," + + " error_code = ?, error_message = ?," + + " completed_at = ?, updated_at = ?," + + " dispatch_owner = NULL," + + " dispatch_lease_until = NULL, version =" + + " version + 1 WHERE tenant_id = ? AND" + + " session_id = ? AND turn_id = ? AND" + + " dispatch_owner = ? AND dispatch_lease_until" + + " >= ?", + eventEpoch, lastSourceId, terminal.terminalStatus(), + terminal.errorCode(), terminal.errorMessage(), now, now, + tenantId, sessionId, turnId, owner, now); + } + + private List appendEvents(String tenantId, + String sessionId, String turnId, String eventEpoch, + long lastSourceId, long sequence, List events, + long now) { + List records = new ArrayList<>(); + long next = sequence; + for (HarnessEvent event : events) { + ProjectedEvent projection = event.projection(); + records.add(new EventRecord(tenantId, sessionId, ++next, + publicId("evt"), turnId, projection.type(), + projection.data(), projection.terminal(), + event.sourceKey(), now)); + } + jdbc.update("UPDATE managed_agent_session SET harness_event_epoch =" + + " ?, harness_last_event_id = ?, last_sequence = ?," + + " updated_at = ?, version = version + 1 WHERE" + + " tenant_id = ? AND session_id = ?", + eventEpoch, lastSourceId, next, now, tenantId, sessionId); + if (!records.isEmpty()) { + jdbc.batchUpdate("INSERT INTO managed_agent_event (tenant_id," + + " session_id, sequence_id, event_id, turn_id," + + " event_type, data_json, terminal, source_key," + + " created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?," + + " ?, ?)", + records, records.size(), (statement, event) -> { + statement.setString(1, event.tenantId()); + statement.setString(2, event.sessionId()); + statement.setLong(3, event.sequence()); + statement.setString(4, event.eventId()); + statement.setString(5, event.turnId()); + statement.setString(6, event.type()); + statement.setString(7, writeJson(event.data())); + statement.setBoolean(8, event.terminal()); + statement.setString(9, event.sourceKey()); + statement.setLong(10, event.createdAt()); + }); + } + return List.copyOf(records); + } + + @Transactional + public void cancelBeforeAdmission(String tenantId, String sessionId, + String turnId, String owner) { + TurnRecord turn = requireTurn(tenantId, sessionId, turnId); + if (!owner.equals(turn.dispatchOwner()) + || turn.harnessEventEpoch() != null + || turn.submissionAttempted()) { + return; + } + long now = clock.millis(); + int updated = jdbc.update("UPDATE managed_agent_turn SET status =" + + " 'CANCELLED'," + + " completed_at = ?, updated_at = ?," + + " dispatch_owner = NULL," + + " dispatch_lease_until = NULL, version =" + + " version + 1 WHERE tenant_id = ? AND" + + " session_id = ? AND turn_id = ? AND" + + " dispatch_owner = ? AND dispatch_lease_until >= ?" + + " AND submission_attempted = FALSE", + now, now, tenantId, sessionId, turnId, owner, now); + if (updated != 1) { + return; + } + appendEvent(tenantId, sessionId, turnId, "turn.cancelled", + Map.of("turnId", turnId), true, null, now); + } + + @Transactional + public void failTurn(String tenantId, String sessionId, String turnId, + String owner, String code, String message) { + TurnRecord turn = requireTurn(tenantId, sessionId, turnId); + if (!owner.equals(turn.dispatchOwner()) + || !ACTIVE_TURN_STATES.contains(turn.status())) { + return; + } + long now = clock.millis(); + int updated = jdbc.update("UPDATE managed_agent_turn SET status =" + + " 'FAILED'," + + " error_code = ?, error_message = ?, completed_at =" + + " ?, updated_at = ?, dispatch_owner = NULL," + + " dispatch_lease_until = NULL, version = version +" + + " 1 WHERE tenant_id = ? AND session_id = ? AND" + + " turn_id = ? AND dispatch_owner = ? AND status IN" + + " ('ACCEPTED', 'RUNNING', 'CANCELLING') AND" + + " dispatch_lease_until >= ?", + code, message, now, now, tenantId, sessionId, turnId, owner, + now); + if (updated != 1) { + return; + } + appendEvent(tenantId, sessionId, turnId, "turn.failed", + Map.of("code", code, "message", message), true, null, now); + } + + @Transactional + public void appendPublicEventIfAbsent(String tenantId, String sessionId, + String turnId, String type, Map data, + boolean terminal, String sourceKey) { + requireSessionForUpdate(tenantId, sessionId); + if (!hasSourceEvent(tenantId, sessionId, sourceKey)) { + appendEvent(tenantId, sessionId, turnId, type, data, terminal, + sourceKey, clock.millis()); + } + } + + public SessionRecord requireSession(String tenantId, String sessionId) { + return findSession(tenantId, sessionId).orElseThrow(() -> + new ApiException(HttpStatus.NOT_FOUND, "session_not_found", + "The Session was not found.")); + } + + private void materializeEvent(EventRecord event) { + switch (event.type()) { + case "turn.accepted" -> materializeInput(event); + case "item.output_text.delta" -> materializeText(event, + "output_text"); + case "item.reasoning.delta" -> materializeText(event, + "reasoning"); + case "item.tool_call.updated" -> materializeTool(event); + case "turn.completed", "turn.failed", "turn.cancelled" -> + settleTurnItems(event); + default -> { + return; + } + } + } + + private void materializeInput(EventRecord event) { + List> input = inputData(event.data().get("input")); + if (input.isEmpty()) { + input = requireTurn(event.tenantId(), event.sessionId(), + event.turnId()).input(); + } + String itemId = string(event.data().get("itemId")); + if (itemId == null) { + itemId = inputItemId(event.turnId()); + } + upsertItem(event, itemId, "message", "user", "completed", + Map.of()); + for (int index = 0; index < input.size(); index++) { + Map block = input.get(index); + String text = string(block.get("text")); + if (text == null) { + continue; + } + replacePart(event, itemId, + "part_" + event.turnId() + "_input_" + index, + "input_text", text); + } + } + + private void materializeText(EventRecord event, String partType) { + String text = string(event.data().get("text")); + if (text == null || text.isEmpty()) { + return; + } + String itemId = string(event.data().get("itemId")); + if (itemId == null) { + itemId = "item_" + event.turnId() + "_assistant"; + } + List preceding = jdbc.query("SELECT part_id FROM" + + " managed_agent_item_part WHERE tenant_id = ?" + + " AND session_id = ? AND item_id = ? AND" + + " part_type = ? AND last_sequence = ?", + (result, row) -> result.getString("part_id"), + event.tenantId(), event.sessionId(), itemId, partType, + event.sequence() - 1); + String partId = preceding.isEmpty() + ? "part_" + event.turnId() + "_" + partType + "_" + + event.sequence() + : preceding.get(0); + upsertItem(event, itemId, "message", "assistant", "in_progress", + Map.of()); + appendPart(event, itemId, partId, partType, text); + } + + private void materializeTool(EventRecord event) { + String itemId = string(event.data().get("itemId")); + if (itemId == null) { + String callId = string(event.data().get("toolCallId")); + if (callId == null) { + callId = string(event.data().get("callId")); + } + String identity = callId == null + ? event.turnId() + ":sequence:" + event.sequence() + : event.turnId() + ":" + callId; + itemId = "item_tool_" + UUID.nameUUIDFromBytes( + identity.getBytes(StandardCharsets.UTF_8)); + } + String sourceStatus = string(event.data().get("status")); + String status = switch (sourceStatus == null ? "" + : sourceStatus.toLowerCase()) { + case "completed", "success" -> "completed"; + case "failed" -> "failed"; + case "cancelled" -> "cancelled"; + default -> "in_progress"; + }; + Map attributes = existingAttributes(event, itemId); + attributes.putAll(event.data()); + attributes.remove("itemId"); + upsertItem(event, itemId, "tool_call", "assistant", status, + Map.copyOf(attributes)); + } + + private Map existingAttributes(EventRecord event, + String itemId) { + List rows = jdbc.query("SELECT attributes_json FROM" + + " managed_agent_item WHERE tenant_id = ? AND" + + " session_id = ? AND item_id = ?", + (result, row) -> result.getString("attributes_json"), + event.tenantId(), event.sessionId(), itemId); + return rows.isEmpty() ? new LinkedHashMap<>() + : new LinkedHashMap<>(readMap(rows.get(0))); + } + + private void settleTurnItems(EventRecord event) { + String status = switch (event.type()) { + case "turn.completed" -> "completed"; + case "turn.cancelled" -> "cancelled"; + default -> "failed"; + }; + jdbc.update("UPDATE managed_agent_item SET item_status = ?," + + " last_sequence = CASE WHEN last_sequence < ?" + + " THEN ? ELSE last_sequence END, updated_at = ?," + + " revision = revision + 1 WHERE tenant_id = ? AND" + + " session_id = ? AND turn_id = ? AND item_status" + + " = 'in_progress'", + status, event.sequence(), event.sequence(), + event.createdAt(), event.tenantId(), event.sessionId(), + event.turnId()); + } + + private void upsertItem(EventRecord event, String itemId, String type, + String role, String status, Map attributes) { + int updated = jdbc.update("UPDATE managed_agent_item SET" + + " item_status = ?, attributes_json = ?," + + " last_sequence = ?, updated_at = ?, revision =" + + " revision + 1 WHERE tenant_id = ? AND session_id" + + " = ? AND item_id = ?", + status, writeJson(attributes), event.sequence(), + event.createdAt(), event.tenantId(), event.sessionId(), + itemId); + if (updated == 0) { + jdbc.update("INSERT INTO managed_agent_item (tenant_id," + + " session_id, item_id, turn_id, item_type," + + " item_role, item_status, attributes_json," + + " first_sequence, last_sequence, created_at," + + " updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?," + + " ?, ?, ?, ?)", + event.tenantId(), event.sessionId(), itemId, + event.turnId(), type, role, status, + writeJson(attributes), event.sequence(), event.sequence(), + event.createdAt(), event.createdAt()); + } + } + + private void replacePart(EventRecord event, String itemId, + String partId, String type, String text) { + int updated = jdbc.update("UPDATE managed_agent_item_part SET" + + " part_text = ?, last_sequence = ?, updated_at = ?," + + " revision = revision + 1 WHERE tenant_id = ? AND" + + " session_id = ? AND item_id = ? AND part_id = ?", + text, event.sequence(), event.createdAt(), event.tenantId(), + event.sessionId(), itemId, partId); + if (updated == 0) { + insertPart(event, itemId, partId, type, text); + } + } + + private void appendPart(EventRecord event, String itemId, + String partId, String type, String text) { + int updated = jdbc.update("UPDATE managed_agent_item_part SET" + + " part_text = CONCAT(part_text, ?)," + + " last_sequence = ?, updated_at = ?, revision =" + + " revision + 1 WHERE tenant_id = ? AND session_id" + + " = ? AND item_id = ? AND part_id = ?", + text, event.sequence(), event.createdAt(), event.tenantId(), + event.sessionId(), itemId, partId); + if (updated == 0) { + insertPart(event, itemId, partId, type, text); + } + } + + private void insertPart(EventRecord event, String itemId, + String partId, String type, String text) { + jdbc.update("INSERT INTO managed_agent_item_part (tenant_id," + + " session_id, item_id, part_id, part_type," + + " part_text, first_sequence, last_sequence," + + " created_at, updated_at) VALUES (?, ?, ?, ?, ?," + + " ?, ?, ?, ?, ?)", + event.tenantId(), event.sessionId(), itemId, partId, type, + text, event.sequence(), event.sequence(), event.createdAt(), + event.createdAt()); + } + + private List allItems(String tenantId, String sessionId) { + return withParts(jdbc.query("SELECT * FROM managed_agent_item WHERE" + + " tenant_id = ? AND session_id = ? ORDER BY" + + " first_sequence ASC", + itemMapper, tenantId, sessionId)); + } + + private List withParts(List rows) { + if (rows.isEmpty()) { + return List.of(); + } + ItemRow first = rows.get(0); + String placeholders = String.join(", ", + Collections.nCopies(rows.size(), "?")); + List arguments = new ArrayList<>(); + arguments.add(first.tenantId()); + arguments.add(first.sessionId()); + rows.forEach(row -> arguments.add(row.itemId())); + List partRows = jdbc.query("SELECT * FROM" + + " managed_agent_item_part WHERE tenant_id = ? AND" + + " session_id = ? AND item_id IN (" + placeholders + + ") ORDER BY first_sequence ASC", + partMapper, arguments.toArray()); + Map> parts = new HashMap<>(); + for (ItemPartRow row : partRows) { + parts.computeIfAbsent(row.itemId(), ignored -> new ArrayList<>()) + .add(row.part()); + } + return rows.stream().map(row -> row.toRecord( + List.copyOf(parts.getOrDefault(row.itemId(), List.of())))) + .toList(); + } + + private static Map acceptedData(String turnId, + List> input) { + return Map.of("turnId", turnId, "itemId", inputItemId(turnId), + "input", input); + } + + private static String inputItemId(String turnId) { + return "item_" + turnId + "_input"; + } + + private static String string(Object value) { + return value instanceof String ? (String) value : null; + } + + private static List> inputData(Object value) { + if (!(value instanceof List values)) { + return List.of(); + } + List> input = new ArrayList<>(); + for (Object item : values) { + if (!(item instanceof Map raw)) { + continue; + } + Map block = new LinkedHashMap<>(); + raw.forEach((key, entry) -> { + if (key instanceof String name) { + block.put(name, entry); + } + }); + input.add(Map.copyOf(block)); + } + return List.copyOf(input); + } + + private SessionRecord requireSessionForUpdate(String tenantId, + String sessionId) { + try { + return jdbc.queryForObject("SELECT * FROM managed_agent_session" + + " WHERE tenant_id = ? AND session_id = ?" + + " FOR UPDATE", + sessionMapper, tenantId, sessionId); + } catch (EmptyResultDataAccessException error) { + throw new ApiException(HttpStatus.NOT_FOUND, + "session_not_found", "The Session was not found."); + } + } + + private TurnRecord requireTurn(String tenantId, String sessionId, + String turnId) { + return findTurn(tenantId, sessionId, turnId).orElseThrow(() -> + new ApiException(HttpStatus.NOT_FOUND, "turn_not_found", + "The Turn was not found.")); + } + + private TurnRecord requireTurnForUpdate(String tenantId, + String sessionId, String turnId) { + try { + return jdbc.queryForObject("SELECT * FROM managed_agent_turn" + + " WHERE tenant_id = ? AND session_id = ? AND" + + " turn_id = ? FOR UPDATE", + turnMapper, tenantId, sessionId, turnId); + } catch (EmptyResultDataAccessException error) { + throw new ApiException(HttpStatus.NOT_FOUND, "turn_not_found", + "The Turn was not found."); + } + } + + private void insertTurn(String tenantId, String sessionId, + String turnId, String promptId, List> input, + String payloadDigest, long now) { + jdbc.update("INSERT INTO managed_agent_turn (tenant_id, session_id," + + " turn_id, prompt_id, input_json, payload_digest," + + " status, created_at, updated_at) VALUES" + + " (?, ?, ?, ?, ?, ?, 'ACCEPTED', ?, ?)", + tenantId, sessionId, turnId, promptId, writeJson(input), + payloadDigest, now, now); + } + + private void insertCommand(String tenantId, String operation, + String idempotencyKey, String requestDigest, String sessionId, + String turnId, long now) { + insertCommand(tenantId, operation, idempotencyKey, requestDigest, + sessionId, turnId, "COMPLETED", now); + } + + private void insertCommand(String tenantId, String operation, + String idempotencyKey, String requestDigest, String sessionId, + String turnId, String status, long now) { + insertCommand(tenantId, operation, idempotencyKey, requestDigest, + sessionId, turnId, status, null, now); + } + + private void insertCommand(String tenantId, String operation, + String idempotencyKey, String requestDigest, String sessionId, + String turnId, String status, String sessionStatusBefore, + long now) { + jdbc.update("INSERT INTO managed_agent_command (tenant_id," + + " operation, idempotency_key, request_digest," + + " session_id, turn_id, command_status," + + " session_status_before, created_at, updated_at)" + + " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + tenantId, operation, idempotencyKey, requestDigest, + sessionId, turnId, status, sessionStatusBefore, now, now); + } + + private void validateMutationStart(SessionRecord session, + SessionMutationKind kind) { + String status = session.status(); + switch (kind) { + case RENAME, ARCHIVE -> requireSessionStatus(status, "ACTIVE"); + case UNARCHIVE -> requireSessionStatus(status, "ARCHIVED"); + case DELETE -> { + if (!"ACTIVE".equals(status) + && !"ARCHIVED".equals(status)) { + throw sessionStateConflict(status); + } + } + } + if ((kind == SessionMutationKind.ARCHIVE + || kind == SessionMutationKind.DELETE) + && hasActiveTurn(session.tenantId(), session.sessionId())) { + throw new ApiException(HttpStatus.CONFLICT, "turn_active", + "The Session has an active Turn."); + } + } + + private static void validateMutationCompletion(SessionRecord session, + SessionMutationKind kind) { + String expected = switch (kind) { + case RENAME -> "ACTIVE"; + case ARCHIVE -> "ARCHIVING"; + case UNARCHIVE -> "ARCHIVED"; + case DELETE -> "DELETING"; + }; + requireSessionStatus(session.status(), expected); + } + + private static String pendingStatus(SessionMutationKind kind) { + return switch (kind) { + case ARCHIVE -> "ARCHIVING"; + case DELETE -> "DELETING"; + case RENAME, UNARCHIVE -> null; + }; + } + + private static String mutationEvent(SessionMutationKind kind, + String phase) { + if ("completed".equals(phase)) { + return switch (kind) { + case RENAME -> "session.updated"; + case ARCHIVE -> "session.archived"; + case UNARCHIVE -> "session.unarchived"; + case DELETE -> "session.deleted"; + }; + } + String operation = switch (kind) { + case RENAME -> "update"; + case ARCHIVE -> "archive"; + case UNARCHIVE -> "unarchive"; + case DELETE -> "delete"; + }; + return "session." + operation + "." + phase; + } + + private static String mutationSource(String operation, + String idempotencyKey, String phase) { + return "control:" + operation + ":" + idempotencyKey + ":" + phase; + } + + private static void requireSessionStatus(String actual, + String expected) { + if (!expected.equals(actual)) { + throw sessionStateConflict(actual); + } + } + + private static ApiException sessionStateConflict(String status) { + return new ApiException(HttpStatus.CONFLICT, + "session_state_conflict", + "The Session is " + status.toLowerCase() + + " and cannot perform this operation."); + } + + private EventRecord appendEvent(String tenantId, String sessionId, + String turnId, String type, Map data, + boolean terminal, String sourceKey, long now) { + Long sequence = jdbc.queryForObject("SELECT last_sequence FROM" + + " managed_agent_session WHERE tenant_id = ? AND" + + " session_id = ? FOR UPDATE", + Long.class, tenantId, sessionId); + if (sequence == null) { + throw new IllegalStateException("Session sequence is unavailable"); + } + long next = sequence + 1; + EventRecord event = new EventRecord(tenantId, sessionId, next, + publicId("evt"), turnId, type, data, terminal, sourceKey, + now); + jdbc.update("UPDATE managed_agent_session SET last_sequence = ?," + + " updated_at = ?, version = version + 1 WHERE" + + " tenant_id = ? AND session_id = ?", + next, now, tenantId, sessionId); + jdbc.update("INSERT INTO managed_agent_event (tenant_id," + + " session_id, sequence_id, event_id, turn_id," + + " event_type, data_json, terminal, source_key," + + " created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + event.tenantId(), event.sessionId(), event.sequence(), + event.eventId(), event.turnId(), event.type(), + writeJson(event.data()), event.terminal(), event.sourceKey(), + event.createdAt()); + publishAfterCommit(List.of(event)); + return event; + } + + private void publishAfterCommit(List events) { + if (events.isEmpty()) { + return; + } + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + eventPublisher.publish(events); + return; + } + TransactionSynchronizationManager.registerSynchronization( + new TransactionSynchronization() { + @Override + public void afterCommit() { + eventPublisher.publish(events); + } + }); + } + + private boolean hasActiveTurn(String tenantId, String sessionId) { + Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM" + + " managed_agent_turn WHERE tenant_id = ? AND" + + " session_id = ? AND status IN" + + " ('ACCEPTED', 'RUNNING', 'CANCELLING')", + Integer.class, tenantId, sessionId); + return count != null && count > 0; + } + + private boolean hasEventType(String tenantId, String sessionId, + String turnId, String type) { + Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM" + + " managed_agent_event WHERE tenant_id = ? AND" + + " session_id = ? AND turn_id = ? AND" + + " event_type = ?", + Integer.class, tenantId, sessionId, turnId, type); + return count != null && count > 0; + } + + private boolean hasSourceEvent(String tenantId, String sessionId, + String sourceKey) { + Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM" + + " managed_agent_event WHERE tenant_id = ? AND" + + " session_id = ? AND source_key = ?", + Integer.class, tenantId, sessionId, sourceKey); + return count != null && count > 0; + } + + private String writeJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException error) { + throw new IllegalArgumentException("Value is not valid JSON", + error); + } + } + + private List> readInput(String value) { + try { + return objectMapper.readValue(value, INPUT_TYPE); + } catch (JsonProcessingException error) { + throw new IllegalStateException("Stored input is invalid", error); + } + } + + private Map readMap(String value) { + try { + return objectMapper.readValue(value, MAP_TYPE); + } catch (JsonProcessingException error) { + throw new IllegalStateException("Stored event is invalid", error); + } + } + + private List readItems(String value) { + try { + return objectMapper.readValue(value, ITEMS_TYPE); + } catch (JsonProcessingException error) { + throw new IllegalStateException("Stored snapshot is invalid", + error); + } + } + + private static Long nullableLong(java.sql.ResultSet result, String name) + throws java.sql.SQLException { + long value = result.getLong(name); + return result.wasNull() ? null : value; + } + + private static String publicId(String prefix) { + return prefix + "_" + UUID.randomUUID().toString() + .replace("-", ""); + } + + private record ItemRow(String tenantId, String sessionId, String itemId, + String turnId, String type, String role, String status, + Map attributes, long firstSequence, + long lastSequence, long createdAt, long updatedAt, + long revision) { + private ItemRecord toRecord(List content) { + return new ItemRecord(tenantId, sessionId, itemId, turnId, type, + role, status, attributes, firstSequence, lastSequence, + createdAt, updatedAt, revision, content); + } + } + + private record ItemPartRow(String itemId, ItemPartRecord part) { + } +} diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedSessionStore.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedSessionStore.java new file mode 100644 index 00000000000..2f69f6c2ebc --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedSessionStore.java @@ -0,0 +1,1151 @@ +package com.alibaba.qwen.code.managedagent.store; + +import com.alibaba.qwen.code.managedagent.api.ApiException; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.AcquireWriterRequest; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.BlockRecoveryRequest; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.CommitReceipt; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.CommitResource; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.CommitTransactionRequest; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.RenewWriterRequest; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.RecoveryStateReceipt; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.RestoreHead; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.SealReceipt; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.SealWriterRequest; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.StoredResource; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.StoredTransaction; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.TransactionPage; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.WriterGrant; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.util.ArrayList; +import java.util.Base64; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.regex.Pattern; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +@Repository +public class ManagedSessionStore { + private static final int STORAGE_VERSION = 1; + private static final int MAX_ID_BYTES = 512; + private static final int MAX_TEXT_BYTES = 4096; + private static final Pattern DIGEST = Pattern.compile("^[0-9a-f]{64}$"); + private static final Pattern WRITER_TOKEN = Pattern.compile( + "^[A-Za-z0-9_-]{" + + ManagedSessionStoreModels.MIN_WRITER_TOKEN_LENGTH + "," + + ManagedSessionStoreModels.MAX_WRITER_TOKEN_LENGTH + "}$"); + private static final Set HEAD_STATES = Set.of( + "ACTIVE", "SEALED", "DELETING", "DELETED"); + private static final Set RECOVERY_STATES = Set.of( + "READY", "BLOCKED_RESOURCE", "BLOCKED_WORKSPACE", + "BLOCKED_EXECUTION"); + private final JdbcTemplate jdbc; + private final RowMapper headMapper = (result, row) -> + new HeadRow(result.getString("tenant_id"), + result.getString("workspace_id"), + result.getString("session_id"), + result.getString("state"), + result.getInt("storage_version"), + result.getLong("writer_generation"), + result.getString("writer_id"), + result.getTimestamp("writer_lease_until"), + result.getString("lease_token_hash"), + result.getLong("journal_revision"), + result.getLong("committed_sequence"), + result.getString("last_commit_digest"), + result.getLong("activation_epoch"), + result.getString("latest_checkpoint_resource_id"), + result.getLong("compacted_through_revision"), + result.getString("recovery_status"), + result.getString("recovery_detail_code")); + private final RowMapper transactionMapper = + (result, row) -> transactionRow(result); + private final RowMapper resourceMapper = (result, row) -> + new ResourceRow(result.getString("tenant_id"), + result.getString("workspace_id"), + result.getString("session_id"), + result.getString("resource_id"), + result.getString("kind"), + result.getInt("schema_version"), + result.getLong("byte_length"), + result.getString("sha256"), + result.getString("storage_kind"), + result.getBytes("inline_bytes"), + result.getString("object_key"), + result.getString("object_version_id"), + result.getString("encryption_key_id"), + result.getString("state")); + + public ManagedSessionStore(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + @Transactional + public WriterGrant acquireWriter(String tenantId, String sessionId, + String writerToken, AcquireWriterRequest request) { + validateScope(tenantId, request.workspaceId(), sessionId); + validateStableId(request.writerId(), "writerId"); + validateLeaseMillis(request.leaseMillis()); + String tokenHash = tokenHash(writerToken); + Timestamp createdAt = databaseNow(); + Timestamp initialLeaseUntil = plusMillis(createdAt, + request.leaseMillis()); + try { + jdbc.update("INSERT INTO qwen_managed_session_journal_head" + + " (tenant_id, workspace_id, session_id," + + " storage_version, state, writer_generation," + + " writer_id, writer_lease_until," + + " lease_token_hash, journal_revision," + + " committed_sequence, activation_epoch," + + " compacted_through_revision, recovery_status," + + " created_at, updated_at) VALUES (?, ?, ?, ?," + + " 'ACTIVE', 1, ?, ?, ?, 0, 0, 0, 0, 'READY'," + + " ?, ?)", + tenantId, request.workspaceId(), sessionId, + STORAGE_VERSION, request.writerId(), initialLeaseUntil, + tokenHash, createdAt, createdAt); + return new WriterGrant(1, initialLeaseUntil.getTime(), 0, 0, + null, 0, false); + } catch (DuplicateKeyException ignored) { + // A prior acquire created the row; lock and inspect it below. + } + HeadRow head = requireHeadForUpdate(tenantId, sessionId); + requireHeadScope(head, tenantId, request.workspaceId(), sessionId); + if ("DELETING".equals(head.state()) + || "DELETED".equals(head.state())) { + throw conflict("managed_session_not_writable", + "The Managed Session does not accept a writer."); + } + if (!"ACTIVE".equals(head.state()) + && !"SEALED".equals(head.state())) { + throw conflict("managed_session_not_writable", + "The Managed Session state is not writable."); + } + Timestamp now = databaseNow(); + if ("ACTIVE".equals(head.state()) + && head.writerLeaseUntil() != null + && head.writerLeaseUntil().after(now)) { + if (!request.writerId().equals(head.writerId()) + || !secureEquals(tokenHash, head.leaseTokenHash())) { + throw writerConflict(); + } + Timestamp leaseUntil = laterOf(head.writerLeaseUntil(), + plusMillis(now, request.leaseMillis())); + jdbc.update("UPDATE qwen_managed_session_journal_head SET" + + " writer_lease_until = ?, updated_at = ?" + + " WHERE tenant_id = ? AND session_id = ?", + leaseUntil, now, tenantId, sessionId); + return grant(head, leaseUntil, true); + } + long generation = increment(head.writerGeneration(), + "writer generation"); + Timestamp leaseUntil = plusMillis(now, request.leaseMillis()); + jdbc.update("UPDATE qwen_managed_session_journal_head SET" + + " state = 'ACTIVE', writer_generation = ?," + + " writer_id = ?, writer_lease_until = ?," + + " lease_token_hash = ?, updated_at = ?" + + " WHERE tenant_id = ? AND session_id = ?", + generation, request.writerId(), leaseUntil, tokenHash, now, + tenantId, sessionId); + return new WriterGrant(generation, leaseUntil.getTime(), + head.journalRevision(), head.committedSequence(), + head.lastCommitDigest(), head.activationEpoch(), false); + } + + @Transactional + public WriterGrant renewWriter(String tenantId, String sessionId, + String writerToken, RenewWriterRequest request) { + validateScope(tenantId, request.workspaceId(), sessionId); + validateStableId(request.writerId(), "writerId"); + validateCounter(request.writerGeneration(), "writerGeneration", 1); + validateLeaseMillis(request.leaseMillis()); + HeadRow head = requireHeadForUpdate(tenantId, sessionId); + requireHeadScope(head, tenantId, request.workspaceId(), sessionId); + Timestamp now = databaseNow(); + requireWriter(head, request.writerId(), request.writerGeneration(), + writerToken, now, true); + Timestamp leaseUntil = laterOf(head.writerLeaseUntil(), + plusMillis(now, request.leaseMillis())); + jdbc.update("UPDATE qwen_managed_session_journal_head SET" + + " writer_lease_until = ?, updated_at = ?" + + " WHERE tenant_id = ? AND session_id = ?", + leaseUntil, now, tenantId, sessionId); + return grant(head, leaseUntil, false); + } + + @Transactional + public SealReceipt sealWriter(String tenantId, String sessionId, + String writerToken, SealWriterRequest request) { + validateScope(tenantId, request.workspaceId(), sessionId); + validateStableId(request.writerId(), "writerId"); + validateCounter(request.writerGeneration(), "writerGeneration", 1); + HeadRow head = requireHeadForUpdate(tenantId, sessionId); + requireHeadScope(head, tenantId, request.workspaceId(), sessionId); + if ("SEALED".equals(head.state()) + && request.writerGeneration() == head.writerGeneration() + && request.writerId().equals(head.writerId()) + && secureEquals(tokenHash(writerToken), + head.leaseTokenHash())) { + return new SealReceipt(head.writerGeneration(), "SEALED", true); + } + Timestamp now = databaseNow(); + requireWriter(head, request.writerId(), request.writerGeneration(), + writerToken, now, false); + jdbc.update("UPDATE qwen_managed_session_journal_head SET" + + " state = 'SEALED', updated_at = ?" + + " WHERE tenant_id = ? AND session_id = ?", + now, tenantId, sessionId); + return new SealReceipt(head.writerGeneration(), "SEALED", false); + } + + @Transactional + public RecoveryStateReceipt blockRecovery(String tenantId, + String sessionId, String writerToken, + BlockRecoveryRequest request) { + validateScope(tenantId, request.workspaceId(), sessionId); + validateStableId(request.writerId(), "writerId"); + validateCounter(request.writerGeneration(), "writerGeneration", 1); + if (!RECOVERY_STATES.contains(request.recoveryStatus()) + || "READY".equals(request.recoveryStatus())) { + throw invalid("recoveryStatus must be a blocked state."); + } + validateText(request.recoveryDetailCode(), "recoveryDetailCode", + MAX_TEXT_BYTES); + HeadRow head = requireHeadForUpdate(tenantId, sessionId); + requireHeadScope(head, tenantId, request.workspaceId(), sessionId); + Timestamp now = databaseNow(); + requireWriter(head, request.writerId(), request.writerGeneration(), + writerToken, now, true); + if (request.recoveryStatus().equals(head.recoveryStatus()) + && request.recoveryDetailCode() + .equals(head.recoveryDetailCode())) { + return new RecoveryStateReceipt(head.writerGeneration(), + head.recoveryStatus(), head.recoveryDetailCode(), true); + } + if (!"READY".equals(head.recoveryStatus())) { + throw conflict( + ManagedSessionStoreModels.ERROR_RECOVERY_CONFLICT, + "The Managed Session already has a different recovery" + + " block."); + } + jdbc.update("UPDATE qwen_managed_session_journal_head SET" + + " recovery_status = ?, recovery_detail_code = ?," + + " updated_at = ? WHERE tenant_id = ? AND" + + " session_id = ?", + request.recoveryStatus(), request.recoveryDetailCode(), now, + tenantId, sessionId); + return new RecoveryStateReceipt(head.writerGeneration(), + request.recoveryStatus(), request.recoveryDetailCode(), + false); + } + + @Transactional + public CommitReceipt commit(String tenantId, String sessionId, + String writerToken, CommitTransactionRequest request) { + validateScope(tenantId, request.workspaceId(), sessionId); + validateStableId(request.writerId(), "writerId"); + ValidatedCommit validated = validateCommit(request); + HeadRow head = requireHeadForUpdate(tenantId, sessionId); + requireHeadScope(head, tenantId, request.workspaceId(), sessionId); + TransactionRow existing = findTransactionByCommand(tenantId, + sessionId, commandKeyHash(request.operation(), + request.commandId())); + if (existing != null) { + requireTransactionWorkspace(existing, request.workspaceId()); + return replay(existing, request, validated.recordDigest(), + writerToken); + } + Timestamp now = databaseNow(); + requireWriter(head, request.writerId(), request.writerGeneration(), + writerToken, now, true); + validateHead(head, request); + long revision = increment(head.journalRevision(), + "journal revision"); + String scopeKey = sessionScopeKey(tenantId, sessionId); + commitResources(scopeKey, tenantId, sessionId, request, revision, + now, validated.resources()); + jdbc.update("INSERT INTO qwen_managed_session_journal_tx" + + " (tenant_id, workspace_id, session_id," + + " journal_revision, command_key_hash," + + " transaction_id, operation, command_id," + + " content_digest, first_sequence, last_sequence," + + " event_count, events_digest," + + " previous_commit_digest, commit_digest," + + " writer_generation, writer_id," + + " writer_token_hash, activation_epoch," + + " latest_checkpoint_resource_id," + + " record_encoding, record_bytes, byte_length," + + " record_digest, created_at) VALUES (?, ?, ?, ?," + + " ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?," + + " ?," + + " 'identity', ?, ?, ?, ?)", + tenantId, request.workspaceId(), sessionId, revision, + commandKeyHash(request.operation(), request.commandId()), + request.transactionId(), request.operation(), + request.commandId(), request.contentDigest(), + request.firstSequence(), request.lastSequence(), + request.eventCount(), request.eventsDigest(), + request.previousCommitDigest(), request.commitDigest(), + request.writerGeneration(), request.writerId(), + tokenHash(writerToken), request.activationEpoch(), + request.latestCheckpointResourceId(), + validated.recordBytes(), validated.recordBytes().length, + validated.recordDigest(), now); + jdbc.update("UPDATE qwen_managed_session_journal_head SET" + + " journal_revision = ?, committed_sequence = ?," + + " last_commit_digest = ?, activation_epoch = ?," + + " latest_checkpoint_resource_id = COALESCE(?," + + " latest_checkpoint_resource_id)," + + " updated_at = ? WHERE tenant_id = ?" + + " AND session_id = ?", + revision, request.lastSequence(), request.commitDigest(), + request.activationEpoch(), + request.latestCheckpointResourceId(), now, tenantId, + sessionId); + return new CommitReceipt(revision, request.transactionId(), + request.commandId(), request.operation(), + request.firstSequence(), request.lastSequence(), + request.lastSequence(), request.commitDigest(), false); + } + + @Transactional(readOnly = true) + public RestoreHead restore(String tenantId, String workspaceId, + String sessionId, String writerToken) { + validateScope(tenantId, workspaceId, sessionId); + HeadRow head = requireHead(tenantId, sessionId); + requireHeadScope(head, tenantId, workspaceId, sessionId); + requireReadGrant(head, writerToken); + return new RestoreHead(head.state(), head.storageVersion(), + head.writerGeneration(), head.journalRevision(), + head.committedSequence(), head.lastCommitDigest(), + head.activationEpoch(), head.latestCheckpointResourceId(), + head.compactedThroughRevision(), head.recoveryStatus(), + head.recoveryDetailCode()); + } + + @Transactional(readOnly = true) + public TransactionPage transactions(String tenantId, String workspaceId, + String sessionId, String writerToken, long afterRevision, + int limit) { + validateScope(tenantId, workspaceId, sessionId); + if (afterRevision < 0 + || afterRevision > ManagedSessionStoreModels.MAX_SAFE_COUNTER + || limit < 1 || limit > 100) { + throw invalid("afterRevision and limit are outside their bounds."); + } + HeadRow head = requireHead(tenantId, sessionId); + requireHeadScope(head, tenantId, workspaceId, sessionId); + requireReadGrant(head, writerToken); + List candidates = jdbc.query( + "SELECT journal_revision, byte_length" + + " FROM qwen_managed_session_journal_tx" + + " WHERE tenant_id = ? AND session_id = ?" + + " AND journal_revision > ?" + + " ORDER BY journal_revision LIMIT ?", + (result, row) -> new TransactionSize( + result.getLong("journal_revision"), + result.getLong("byte_length")), + tenantId, sessionId, afterRevision, + limit + 1); + int selectedCount = 0; + long selectedBytes = 0; + long expectedRevision = afterRevision + 1; + for (TransactionSize candidate : candidates) { + if (candidate.journalRevision() != expectedRevision + || candidate.journalRevision() + > head.journalRevision() + || candidate.byteLength() < 1 || candidate.byteLength() + > ManagedSessionStoreModels.MAX_TRANSACTION_BYTES) { + throw journalCorrupt(); + } + expectedRevision++; + if (selectedCount == limit || selectedCount > 0 + && selectedBytes + candidate.byteLength() + > ManagedSessionStoreModels + .MAX_TRANSACTION_BYTES) { + break; + } + selectedBytes += candidate.byteLength(); + selectedCount++; + } + if (afterRevision < head.journalRevision() + && (candidates.isEmpty() + || candidates.size() < limit + 1 + && candidates.get(candidates.size() - 1) + .journalRevision() + != head.journalRevision())) { + throw journalCorrupt(); + } + List rows = selectedCount == 0 ? List.of() + : jdbc.query( + "SELECT *" + + " FROM qwen_managed_session_journal_tx" + + " WHERE tenant_id = ? AND session_id = ?" + + " AND journal_revision > ?" + + " ORDER BY journal_revision LIMIT ?", + transactionMapper, tenantId, sessionId, + afterRevision, selectedCount); + if (rows.size() != selectedCount) { + throw journalCorrupt(); + } + for (int index = 0; index < rows.size(); index++) { + TransactionRow row = rows.get(index); + TransactionSize candidate = candidates.get(index); + if (row.journalRevision() != candidate.journalRevision() + || row.byteLength() != candidate.byteLength()) { + throw journalCorrupt(); + } + } + boolean hasMore = candidates.size() > selectedCount; + rows.forEach(row -> requireTransactionWorkspace(row, workspaceId)); + List transactions = rows.stream() + .map(this::storedTransaction).toList(); + long nextRevision = transactions.isEmpty() ? afterRevision + : transactions.get(transactions.size() - 1) + .journalRevision(); + return new TransactionPage(transactions, nextRevision, hasMore); + } + + @Transactional + public StoredResource readResource(String tenantId, String workspaceId, + String sessionId, String resourceId, String writerToken) { + validateScope(tenantId, workspaceId, sessionId); + validateStableId(resourceId, "resourceId"); + HeadRow head = requireHead(tenantId, sessionId); + requireHeadScope(head, tenantId, workspaceId, sessionId); + requireReadGrant(head, writerToken); + String scopeKey = sessionScopeKey(tenantId, sessionId); + ResourceRow resource = findResource(scopeKey, resourceId); + if (resource == null) { + throw new ApiException(HttpStatus.NOT_FOUND, + ManagedSessionStoreModels.ERROR_RESOURCE_NOT_FOUND, + "The Managed Session resource does not exist."); + } + requireResourceScope(resource, tenantId, workspaceId, sessionId, + resourceId); + verifyStoredResource(resource); + jdbc.update("UPDATE qwen_managed_session_resource SET" + + " last_verified_at = ? WHERE session_scope_key = ?" + + " AND resource_id = ?", + databaseNow(), scopeKey, resourceId); + return new StoredResource(resource.resourceId(), resource.kind(), + resource.schemaVersion(), resource.byteLength(), + resource.digest(), resource.bytes()); + } + + private void commitResources(String scopeKey, String tenantId, + String sessionId, CommitTransactionRequest request, + long revision, Timestamp now, + List resources) { + Set resourceIds = new HashSet<>(); + for (ValidatedResource input : resources) { + CommitResource resource = input.resource(); + if (!resourceIds.add(resource.resourceId())) { + throw invalid("resources contains a duplicate resourceId."); + } + ResourceRow existing = findResource(scopeKey, + resource.resourceId()); + if (existing == null) { + if (input.bytes() == null) { + throw conflict( + ManagedSessionStoreModels.ERROR_RESOURCE_MISSING, + "A referenced Managed Session resource is" + + " missing."); + } + jdbc.update("INSERT INTO qwen_managed_session_resource" + + " (session_scope_key, tenant_id," + + " workspace_id, session_id, resource_id," + + " kind, schema_version, byte_length, sha256," + + " storage_kind, inline_bytes," + + " publish_command_id, state, created_at," + + " last_verified_at) VALUES (?, ?, ?, ?, ?," + + " ?, ?, ?, ?, 'MYSQL_INLINE', ?, ?," + + " 'REFERENCED', ?, ?)", + scopeKey, tenantId, request.workspaceId(), sessionId, + resource.resourceId(), resource.kind(), + resource.schemaVersion(), resource.byteLength(), + resource.digest(), input.bytes(), request.commandId(), + now, now); + } else { + requireResourceScope(existing, tenantId, + request.workspaceId(), sessionId, + resource.resourceId()); + verifyStoredResource(existing); + if (!resource.kind().equals(existing.kind()) + || resource.schemaVersion() + != existing.schemaVersion() + || resource.byteLength() != existing.byteLength() + || !resource.digest().equals(existing.digest())) { + throw conflict("managed_session_resource_conflict", + "A resourceId was reused with different" + + " content metadata."); + } + } + jdbc.update("INSERT INTO qwen_managed_session_resource_ref" + + " (session_scope_key, tenant_id, workspace_id," + + " session_id, journal_revision, resource_id," + + " created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + scopeKey, tenantId, request.workspaceId(), sessionId, + revision, resource.resourceId(), now); + } + } + + private ValidatedCommit validateCommit( + CommitTransactionRequest request) { + validateStableId(request.transactionId(), "transactionId"); + validateText(request.operation(), "operation", MAX_TEXT_BYTES); + validateStableId(request.commandId(), "commandId"); + validateDigest(request.contentDigest(), "contentDigest", false); + validateDigest(request.eventsDigest(), "eventsDigest", true); + validateDigest(request.previousCommitDigest(), + "previousCommitDigest", true); + validateDigest(request.commitDigest(), "commitDigest", true); + validateDigest(request.recordDigest(), "recordDigest", false); + validateCounter(request.writerGeneration(), "writerGeneration", 1); + validateCounter(request.expectedJournalRevision(), + "expectedJournalRevision", 0); + validateCounter(request.expectedCommittedSequence(), + "expectedCommittedSequence", 0); + validateCounter(request.firstSequence(), "firstSequence", 0); + validateCounter(request.lastSequence(), "lastSequence", 0); + validateCounter(request.activationEpoch(), "activationEpoch", 0); + if (request.latestCheckpointResourceId() != null) { + validateStableId(request.latestCheckpointResourceId(), + "latestCheckpointResourceId"); + } + if (request.eventCount() < 0 + || request.eventCount() + > ManagedSessionStoreModels.MAX_TRANSACTION_EVENTS + || request.recordCount() < 1 + || request.recordCount() + > ManagedSessionStoreModels.MAX_TRANSACTION_EVENTS + + 1) { + throw invalid("eventCount or recordCount is outside its bound."); + } + byte[] recordBytes = decodeBase64(request.recordBytesBase64(), + ManagedSessionStoreModels.MAX_TRANSACTION_BYTES, + "recordBytesBase64"); + if (!sha256(recordBytes).equals(request.recordDigest())) { + throw invalid("recordDigest does not match recordBytesBase64."); + } + validateUtf8JsonLines(recordBytes, request.recordCount()); + if ("session.create".equals(request.operation())) { + if (request.eventCount() != 0 || request.firstSequence() != 0 + || request.lastSequence() != 0 + || request.recordCount() != 2 + || request.eventsDigest() != null + || request.previousCommitDigest() != null + || request.commitDigest() != null + || request.latestCheckpointResourceId() != null) { + throw invalid("The session.create transaction must use" + + " sequence zero and no commit digest chain."); + } + } else { + if (request.eventCount() < 1 + || request.firstSequence() < 1 + || request.lastSequence() < request.firstSequence() + || request.lastSequence() - request.firstSequence() + 1 + != request.eventCount() + || request.eventsDigest() == null + || request.commitDigest() == null + || request.recordCount() != request.eventCount() + 1) { + throw invalid("The transaction event range or record count" + + " is invalid."); + } + } + List resources = new ArrayList<>(); + long inlineBytes = 0; + List requestedResources = request.resources() == null + ? List.of() : request.resources(); + if (requestedResources.size() + > ManagedSessionStoreModels.MAX_RESOURCES_PER_TRANSACTION) { + throw invalid("resources exceeds its item limit."); + } + for (CommitResource resource : requestedResources) { + if (resource == null) { + throw invalid("resources must not contain null entries."); + } + validateStableId(resource.resourceId(), "resourceId"); + validateStableId(resource.kind(), "resource kind"); + validateDigest(resource.digest(), "resource digest", false); + if (resource.schemaVersion() < 1 || resource.byteLength() < 0) { + throw invalid("Resource version or length is invalid."); + } + if (resource.byteLength() + > ManagedSessionStoreModels.MAX_INLINE_RESOURCE_BYTES) { + throw new ApiException(HttpStatus.NOT_IMPLEMENTED, + ManagedSessionStoreModels.ERROR_OSS_DISABLED, + "Resources larger than 64 KiB require the disabled" + + " OSS storage path."); + } + byte[] bytes = resource.bytesBase64() == null ? null + : decodeBase64(resource.bytesBase64(), + ManagedSessionStoreModels + .MAX_INLINE_RESOURCE_BYTES, + "resource bytesBase64"); + if (bytes != null && (bytes.length != resource.byteLength() + || !sha256(bytes).equals(resource.digest()))) { + throw invalid("Resource bytes do not match their length or" + + " digest."); + } + if (bytes != null) { + inlineBytes += bytes.length; + if (inlineBytes + > ManagedSessionStoreModels.MAX_TRANSACTION_BYTES) { + throw payloadTooLarge("Inline resources exceed their" + + " transaction byte limit."); + } + } + resources.add(new ValidatedResource(resource, bytes)); + } + if (request.latestCheckpointResourceId() != null + && resources.stream().map(ValidatedResource::resource) + .noneMatch(resource -> request + .latestCheckpointResourceId() + .equals(resource.resourceId()) + && "managed-checkpoint" + .equals(resource.kind()))) { + throw invalid("latestCheckpointResourceId must identify a" + + " managed-checkpoint resource in this transaction."); + } + return new ValidatedCommit(recordBytes, request.recordDigest(), + resources); + } + + private void validateHead(HeadRow head, + CommitTransactionRequest request) { + if (request.expectedJournalRevision() != head.journalRevision() + || request.expectedCommittedSequence() + != head.committedSequence() + || !Objects.equals(request.previousCommitDigest(), + head.lastCommitDigest())) { + throw conflict("managed_session_head_conflict", + "The Managed Session head changed; reload before" + + " retrying."); + } + if (request.activationEpoch() < head.activationEpoch()) { + throw conflict("managed_session_activation_conflict", + "The activation epoch is stale."); + } + if (!"READY".equals(head.recoveryStatus())) { + throw conflict("managed_session_recovery_blocked", + "The Managed Session is blocked from new commits."); + } + if ("session.create".equals(request.operation())) { + if (head.journalRevision() != 0 + || head.committedSequence() != 0) { + throw conflict("managed_session_head_conflict", + "The Managed Session genesis is already committed."); + } + } else if (head.journalRevision() == 0 + || request.firstSequence() + != increment(head.committedSequence(), + "committed sequence")) { + throw conflict("managed_session_head_conflict", + "The transaction does not extend the committed" + + " sequence."); + } + } + + private CommitReceipt replay(TransactionRow existing, + CommitTransactionRequest request, String recordDigest, + String writerToken) { + if (!existing.operation().equals(request.operation()) + || !existing.commandId().equals(request.commandId()) + || !existing.transactionId() + .equals(request.transactionId()) + || !existing.contentDigest().equals(request.contentDigest()) + || !existing.recordDigest().equals(recordDigest) + || existing.firstSequence() != request.firstSequence() + || existing.lastSequence() != request.lastSequence() + || existing.eventCount() != request.eventCount() + || !Objects.equals(existing.eventsDigest(), + request.eventsDigest()) + || !Objects.equals(existing.previousCommitDigest(), + request.previousCommitDigest()) + || existing.writerGeneration() + != request.writerGeneration() + || !existing.writerId().equals(request.writerId()) + || !secureEquals(existing.writerTokenHash(), + tokenHash(writerToken)) + || existing.activationEpoch() != request.activationEpoch() + || !Objects.equals(existing.latestCheckpointResourceId(), + request.latestCheckpointResourceId()) + || !Objects.equals(existing.commitDigest(), + request.commitDigest())) { + throw conflict( + ManagedSessionStoreModels.ERROR_IDEMPOTENCY_CONFLICT, + "The command key was reused with different content."); + } + return new CommitReceipt(existing.journalRevision(), + existing.transactionId(), existing.commandId(), + existing.operation(), existing.firstSequence(), + existing.lastSequence(), existing.lastSequence(), + existing.commitDigest(), true); + } + + private void requireWriter(HeadRow head, String writerId, + long writerGeneration, String writerToken, Timestamp now, + boolean requireUnexpired) { + if (!"ACTIVE".equals(head.state()) + || writerGeneration != head.writerGeneration() + || !writerId.equals(head.writerId()) + || !secureEquals(tokenHash(writerToken), + head.leaseTokenHash()) + || requireUnexpired && (head.writerLeaseUntil() == null + || !head.writerLeaseUntil().after(now))) { + throw writerConflict(); + } + } + + private void requireReadGrant(HeadRow head, String writerToken) { + Timestamp now = databaseNow(); + if (!"ACTIVE".equals(head.state()) + || !secureEquals(tokenHash(writerToken), + head.leaseTokenHash()) + || head.writerLeaseUntil() == null + || !head.writerLeaseUntil().after(now)) { + throw writerConflict(); + } + } + + private HeadRow findHeadForUpdate(String tenantId, String sessionId) { + List rows = jdbc.query( + "SELECT * FROM qwen_managed_session_journal_head" + + " WHERE tenant_id = ? AND session_id = ?" + + " FOR UPDATE", + headMapper, tenantId, sessionId); + return rows.isEmpty() ? null : rows.get(0); + } + + private HeadRow requireHeadForUpdate(String tenantId, String sessionId) { + HeadRow head = findHeadForUpdate(tenantId, sessionId); + if (head == null) { + throw sessionNotFound(); + } + return head; + } + + private HeadRow requireHead(String tenantId, String sessionId) { + List rows = jdbc.query( + "SELECT * FROM qwen_managed_session_journal_head" + + " WHERE tenant_id = ? AND session_id = ?", + headMapper, tenantId, sessionId); + if (rows.isEmpty()) { + throw sessionNotFound(); + } + return rows.get(0); + } + + private TransactionRow findTransactionByCommand(String tenantId, + String sessionId, String commandKeyHash) { + List rows = jdbc.query( + "SELECT * FROM qwen_managed_session_journal_tx" + + " WHERE tenant_id = ? AND session_id = ?" + + " AND command_key_hash = ?", + transactionMapper, tenantId, sessionId, commandKeyHash); + return rows.isEmpty() ? null : rows.get(0); + } + + private ResourceRow findResource(String scopeKey, String resourceId) { + List rows = jdbc.query( + "SELECT * FROM qwen_managed_session_resource" + + " WHERE session_scope_key = ? AND resource_id = ?", + resourceMapper, scopeKey, resourceId); + return rows.isEmpty() ? null : rows.get(0); + } + + private static TransactionRow transactionRow(ResultSet result) + throws SQLException { + return new TransactionRow(result.getString("workspace_id"), + result.getLong("journal_revision"), + result.getString("transaction_id"), + result.getString("operation"), + result.getString("command_id"), + result.getString("content_digest"), + result.getLong("first_sequence"), + result.getLong("last_sequence"), + result.getInt("event_count"), + result.getString("events_digest"), + result.getString("previous_commit_digest"), + result.getString("commit_digest"), + result.getLong("writer_generation"), + result.getString("writer_id"), + result.getString("writer_token_hash"), + result.getLong("activation_epoch"), + result.getString("latest_checkpoint_resource_id"), + result.getString("record_encoding"), + result.getBytes("record_bytes"), + result.getLong("byte_length"), + result.getString("record_digest")); + } + + private StoredTransaction storedTransaction(TransactionRow row) { + if (!"identity".equals(row.recordEncoding()) + || row.recordBytes() == null + || row.recordBytes().length != row.byteLength() + || !sha256(row.recordBytes()).equals(row.recordDigest())) { + throw journalCorrupt(); + } + return new StoredTransaction(row.journalRevision(), + row.transactionId(), row.operation(), row.commandId(), + row.contentDigest(), row.firstSequence(), + row.lastSequence(), row.eventCount(), row.eventsDigest(), + row.previousCommitDigest(), row.commitDigest(), + row.writerGeneration(), row.activationEpoch(), + row.latestCheckpointResourceId(), + row.recordEncoding(), Base64.getEncoder() + .encodeToString(row.recordBytes()), + row.byteLength(), row.recordDigest()); + } + + private Timestamp databaseNow() { + Timestamp now = jdbc.queryForObject("SELECT CURRENT_TIMESTAMP(6)", + Timestamp.class); + if (now == null) { + throw new IllegalStateException("Database time is unavailable"); + } + return now; + } + + private static WriterGrant grant(HeadRow head, Timestamp leaseUntil, + boolean replayed) { + return new WriterGrant(head.writerGeneration(), leaseUntil.getTime(), + head.journalRevision(), head.committedSequence(), + head.lastCommitDigest(), head.activationEpoch(), replayed); + } + + private static Timestamp plusMillis(Timestamp timestamp, long millis) { + return Timestamp.from(timestamp.toInstant().plusMillis(millis)); + } + + private static Timestamp laterOf(Timestamp left, Timestamp right) { + return left != null && left.after(right) ? left : right; + } + + private static long increment(long value, String label) { + if (value < 0 + || value >= ManagedSessionStoreModels.MAX_SAFE_COUNTER) { + throw conflict("managed_session_counter_exhausted", + "The " + label + " cannot advance."); + } + return value + 1; + } + + private static void validateScope(String tenantId, String workspaceId, + String sessionId) { + validateText(tenantId, "tenantId", 128); + validateKeyComponent(workspaceId, "workspaceId"); + validateKeyComponent(sessionId, "sessionId"); + } + + private static void validateKeyComponent(String value, String label) { + validateStableId(value, label); + if (value.contains("/") || value.contains("\\") + || value.chars().allMatch(character -> character == '.')) { + throw invalid(label + " is not a safe key component."); + } + } + + private static void validateStableId(String value, String label) { + validateText(value, label, MAX_ID_BYTES); + } + + private static void validateText(String value, String label, + int maxBytes) { + if (!isValidText(value, maxBytes)) { + throw invalid(label + " is invalid."); + } + } + + private static boolean isValidText(String value, int maxBytes) { + return value != null && !value.isEmpty() + && value.getBytes(StandardCharsets.UTF_8).length <= maxBytes + && value.chars().noneMatch(character -> character < 32 + || character == 127); + } + + private static void validateDigest(String value, String label, + boolean nullable) { + if (value == null && nullable) { + return; + } + if (value == null || !DIGEST.matcher(value).matches()) { + throw invalid(label + " must be a lowercase SHA-256 digest."); + } + } + + private static byte[] decodeBase64(String value, int maxBytes, + String label) { + if (value == null) { + throw invalid(label + " is required."); + } + byte[] decoded; + try { + decoded = Base64.getDecoder().decode(value); + } catch (IllegalArgumentException error) { + throw invalid(label + " is not valid Base64."); + } + if (decoded.length > maxBytes) { + throw payloadTooLarge(label + " exceeds its byte limit."); + } + return decoded; + } + + private static void verifyStoredResource(ResourceRow resource) { + if (!"MYSQL_INLINE".equals(resource.storageKind()) + || !"REFERENCED".equals(resource.state()) + || resource.bytes() == null + || resource.objectKey() != null + || resource.objectVersionId() != null + || resource.encryptionKeyId() != null + || resource.bytes().length != resource.byteLength() + || !sha256(resource.bytes()).equals(resource.digest())) { + throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, + "managed_session_resource_corrupt", + "The Managed Session resource failed verification."); + } + } + + private static void validateCounter(long value, String label, + long minimum) { + if (value < minimum + || value > ManagedSessionStoreModels.MAX_SAFE_COUNTER) { + throw invalid(label + " is outside its safe integer bound."); + } + } + + private static void validateLeaseMillis(Long leaseMillis) { + if (leaseMillis == null + || leaseMillis < ManagedSessionStoreModels.MIN_LEASE_MILLIS + || leaseMillis > ManagedSessionStoreModels.MAX_LEASE_MILLIS) { + throw invalid("leaseMillis is outside its bound."); + } + } + + private static void validateUtf8JsonLines(byte[] bytes, + int expectedRecords) { + try { + StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(bytes)); + } catch (CharacterCodingException error) { + throw invalid("recordBytesBase64 is not valid UTF-8."); + } + int records = 0; + int lineLength = 0; + for (byte value : bytes) { + if (value == '\n') { + if (lineLength == 0) { + throw invalid("recordBytesBase64 contains a blank line."); + } + if (lineLength > 1024 * 1024) { + throw payloadTooLarge("A Managed Session record exceeds" + + " its byte limit."); + } + records++; + lineLength = 0; + } else { + lineLength++; + } + } + if (lineLength != 0 || records != expectedRecords) { + throw invalid("recordBytesBase64 must end with a newline and" + + " match recordCount."); + } + } + + private static void requireHeadScope(HeadRow head, String tenantId, + String workspaceId, String sessionId) { + if (!tenantId.equals(head.tenantId()) + || !workspaceId.equals(head.workspaceId()) + || !sessionId.equals(head.sessionId())) { + throw sessionNotFound(); + } + if (head.storageVersion() != STORAGE_VERSION) { + throw conflict("managed_session_storage_version_unsupported", + "The Managed Session storage version is unsupported."); + } + if (!HEAD_STATES.contains(head.state()) + || !RECOVERY_STATES.contains(head.recoveryStatus()) + || head.writerGeneration() < 1 + || head.writerGeneration() + > ManagedSessionStoreModels.MAX_SAFE_COUNTER + || head.journalRevision() < 0 + || head.journalRevision() + > ManagedSessionStoreModels.MAX_SAFE_COUNTER + || head.committedSequence() < 0 + || head.committedSequence() + > ManagedSessionStoreModels.MAX_SAFE_COUNTER + || head.activationEpoch() < 0 + || head.activationEpoch() + > ManagedSessionStoreModels.MAX_SAFE_COUNTER + || head.compactedThroughRevision() < 0 + || head.compactedThroughRevision() + > head.journalRevision() + || head.lastCommitDigest() != null + && !DIGEST.matcher(head.lastCommitDigest()).matches() + || head.latestCheckpointResourceId() != null + && !isValidText(head.latestCheckpointResourceId(), + MAX_ID_BYTES) + || ("ACTIVE".equals(head.state()) + || "SEALED".equals(head.state())) + && (!isValidText(head.writerId(), MAX_ID_BYTES) + || head.writerLeaseUntil() == null + || head.leaseTokenHash() == null + || !DIGEST.matcher(head.leaseTokenHash()) + .matches())) { + throw headCorrupt(); + } + } + + private static void requireResourceScope(ResourceRow resource, + String tenantId, String workspaceId, String sessionId, + String resourceId) { + if (!tenantId.equals(resource.tenantId()) + || !workspaceId.equals(resource.workspaceId()) + || !sessionId.equals(resource.sessionId()) + || !resourceId.equals(resource.resourceId())) { + throw sessionNotFound(); + } + } + + private static void requireTransactionWorkspace(TransactionRow row, + String workspaceId) { + if (!workspaceId.equals(row.workspaceId())) { + throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, + ManagedSessionStoreModels.ERROR_JOURNAL_CORRUPT, + "The Managed Session transaction scope is corrupt."); + } + } + + private static String tokenHash(String writerToken) { + if (writerToken == null + || !WRITER_TOKEN.matcher(writerToken).matches()) { + throw new ApiException(HttpStatus.BAD_REQUEST, + "invalid_writer_token", + ManagedSessionStoreModels.WRITER_TOKEN_HEADER + + " must contain 32-512 Base64URL characters."); + } + return sha256(writerToken.getBytes(StandardCharsets.UTF_8)); + } + + private static String sessionScopeKey(String tenantId, + String sessionId) { + return sha256((tenantId + "\u0000" + sessionId) + .getBytes(StandardCharsets.UTF_8)); + } + + private static String commandKeyHash(String operation, + String commandId) { + return sha256((operation + "\u0000" + commandId) + .getBytes(StandardCharsets.UTF_8)); + } + + private static String sha256(byte[] value) { + try { + return HexFormat.of().formatHex( + MessageDigest.getInstance("SHA-256").digest(value)); + } catch (NoSuchAlgorithmException error) { + throw new IllegalStateException("SHA-256 is unavailable", error); + } + } + + private static boolean secureEquals(String left, String right) { + return left != null && right != null && MessageDigest.isEqual( + left.getBytes(StandardCharsets.US_ASCII), + right.getBytes(StandardCharsets.US_ASCII)); + } + + private static ApiException sessionNotFound() { + return new ApiException(HttpStatus.NOT_FOUND, + "managed_session_not_found", + "The Managed Session does not exist."); + } + + private static ApiException writerConflict() { + return conflict(ManagedSessionStoreModels.ERROR_WRITER_CONFLICT, + "The Managed Session writer grant is stale or unavailable."); + } + + private static ApiException journalCorrupt() { + return new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, + ManagedSessionStoreModels.ERROR_JOURNAL_CORRUPT, + "The Managed Session transaction failed verification."); + } + + private static ApiException headCorrupt() { + return new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, + ManagedSessionStoreModels.ERROR_HEAD_CORRUPT, + "The Managed Session head failed verification."); + } + + private static ApiException invalid(String message) { + return new ApiException(HttpStatus.BAD_REQUEST, + ManagedSessionStoreModels.ERROR_INVALID_REQUEST, message); + } + + private static ApiException payloadTooLarge(String message) { + return new ApiException(HttpStatus.PAYLOAD_TOO_LARGE, + ManagedSessionStoreModels.ERROR_PAYLOAD_TOO_LARGE, message); + } + + private static ApiException conflict(String code, String message) { + return new ApiException(HttpStatus.CONFLICT, code, message); + } + + private record HeadRow(String tenantId, String workspaceId, + String sessionId, String state, int storageVersion, + long writerGeneration, String writerId, + Timestamp writerLeaseUntil, String leaseTokenHash, + long journalRevision, long committedSequence, + String lastCommitDigest, long activationEpoch, + String latestCheckpointResourceId, + long compactedThroughRevision, String recoveryStatus, + String recoveryDetailCode) { + } + + private record TransactionRow(String workspaceId, long journalRevision, + String transactionId, String operation, String commandId, + String contentDigest, long firstSequence, long lastSequence, + int eventCount, String eventsDigest, + String previousCommitDigest, String commitDigest, + long writerGeneration, String writerId, String writerTokenHash, + long activationEpoch, String latestCheckpointResourceId, + String recordEncoding, byte[] recordBytes, long byteLength, + String recordDigest) { + } + + private record TransactionSize(long journalRevision, long byteLength) { + } + + private record ResourceRow(String tenantId, String workspaceId, + String sessionId, String resourceId, String kind, + int schemaVersion, long byteLength, String digest, + String storageKind, byte[] bytes, String objectKey, + String objectVersionId, String encryptionKeyId, String state) { + } + + private record ValidatedResource(CommitResource resource, byte[] bytes) { + } + + private record ValidatedCommit(byte[] recordBytes, String recordDigest, + List resources) { + } +} diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedSessionStoreModels.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedSessionStoreModels.java new file mode 100644 index 00000000000..a15e73db8c0 --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedSessionStoreModels.java @@ -0,0 +1,170 @@ +package com.alibaba.qwen.code.managedagent.store; + +import com.fasterxml.jackson.annotation.JsonInclude; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import java.util.List; + +public final class ManagedSessionStoreModels { + public static final String WRITER_TOKEN_HEADER = + "X-Qwen-Managed-Writer-Token"; + public static final int MAX_INLINE_RESOURCE_BYTES = 64 * 1024; + public static final int MAX_RESOURCES_PER_TRANSACTION = 1024; + public static final int MAX_TRANSACTION_BYTES = 8 * 1024 * 1024; + public static final int MAX_TRANSACTION_EVENTS = 256; + public static final int MIN_WRITER_TOKEN_LENGTH = 32; + public static final int MAX_WRITER_TOKEN_LENGTH = 512; + public static final long MIN_LEASE_MILLIS = 1_000; + public static final long MAX_LEASE_MILLIS = 300_000; + public static final long MAX_SAFE_COUNTER = 9_007_199_254_740_990L; + public static final String ERROR_WRITER_CONFLICT = + "managed_session_writer_conflict"; + public static final String ERROR_IDEMPOTENCY_CONFLICT = + "managed_session_idempotency_conflict"; + public static final String ERROR_RESOURCE_MISSING = + "managed_session_resource_missing"; + public static final String ERROR_RESOURCE_NOT_FOUND = + "managed_session_resource_not_found"; + public static final String ERROR_OSS_DISABLED = + "managed_session_oss_disabled"; + public static final String ERROR_INVALID_REQUEST = + "invalid_managed_session_store_request"; + public static final String ERROR_PAYLOAD_TOO_LARGE = + "managed_session_payload_too_large"; + public static final String ERROR_JOURNAL_CORRUPT = + "managed_session_journal_corrupt"; + public static final String ERROR_HEAD_CORRUPT = + "managed_session_head_corrupt"; + public static final String ERROR_RECOVERY_CONFLICT = + "managed_session_recovery_conflict"; + private static final String DIGEST_PATTERN = "^[0-9a-f]{64}$"; + + private ManagedSessionStoreModels() { + } + + public record AcquireWriterRequest( + @NotBlank @Size(max = 512) String workspaceId, + @NotBlank @Size(max = 512) String writerId, + @NotNull @Min(MIN_LEASE_MILLIS) @Max(MAX_LEASE_MILLIS) + Long leaseMillis) { + } + + public record RenewWriterRequest( + @NotBlank @Size(max = 512) String workspaceId, + @NotBlank @Size(max = 512) String writerId, + @Min(1) @Max(MAX_SAFE_COUNTER) long writerGeneration, + @NotNull @Min(MIN_LEASE_MILLIS) @Max(MAX_LEASE_MILLIS) + Long leaseMillis) { + } + + public record SealWriterRequest( + @NotBlank @Size(max = 512) String workspaceId, + @NotBlank @Size(max = 512) String writerId, + @Min(1) @Max(MAX_SAFE_COUNTER) long writerGeneration) { + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + public record WriterGrant(long writerGeneration, long leaseUntil, + long journalRevision, long committedSequence, + String lastCommitDigest, long activationEpoch, + boolean replayed) { + } + + public record SealReceipt(long writerGeneration, String state, + boolean replayed) { + } + + public record BlockRecoveryRequest( + @NotBlank @Size(max = 512) String workspaceId, + @NotBlank @Size(max = 512) String writerId, + @Min(1) @Max(MAX_SAFE_COUNTER) long writerGeneration, + @NotBlank @Pattern(regexp = + "BLOCKED_RESOURCE|BLOCKED_WORKSPACE|BLOCKED_EXECUTION") + String recoveryStatus, + @NotBlank @Size(max = 4096) String recoveryDetailCode) { + } + + public record RecoveryStateReceipt(long writerGeneration, + String recoveryStatus, String recoveryDetailCode, + boolean replayed) { + } + + public record CommitResource( + @NotBlank @Size(max = 512) String resourceId, + @NotBlank @Size(max = 512) String kind, + @Min(1) int schemaVersion, + @Min(0) long byteLength, + @NotBlank @Pattern(regexp = DIGEST_PATTERN) String digest, + @Size(max = 90_000) String bytesBase64) { + } + + public record CommitTransactionRequest( + @NotBlank @Size(max = 512) String workspaceId, + @NotBlank @Size(max = 512) String writerId, + @Min(1) @Max(MAX_SAFE_COUNTER) long writerGeneration, + @Min(0) @Max(MAX_SAFE_COUNTER) long expectedJournalRevision, + @Min(0) @Max(MAX_SAFE_COUNTER) long expectedCommittedSequence, + @NotBlank @Size(max = 512) String transactionId, + @NotBlank @Size(max = 4096) String operation, + @NotBlank @Size(max = 512) String commandId, + @NotBlank @Pattern(regexp = DIGEST_PATTERN) + String contentDigest, + @Min(0) @Max(MAX_SAFE_COUNTER) long firstSequence, + @Min(0) @Max(MAX_SAFE_COUNTER) long lastSequence, + @Min(0) @Max(MAX_TRANSACTION_EVENTS) int eventCount, + @Pattern(regexp = DIGEST_PATTERN) String eventsDigest, + @Pattern(regexp = DIGEST_PATTERN) String previousCommitDigest, + @Pattern(regexp = DIGEST_PATTERN) String commitDigest, + @Min(0) @Max(MAX_SAFE_COUNTER) long activationEpoch, + @Size(max = 512) String latestCheckpointResourceId, + @Min(1) @Max(MAX_TRANSACTION_EVENTS + 1) int recordCount, + @NotBlank @Size(max = 11_184_820) String recordBytesBase64, + @NotBlank @Pattern(regexp = DIGEST_PATTERN) + String recordDigest, + @Size(max = MAX_RESOURCES_PER_TRANSACTION) + List<@Valid CommitResource> resources) { + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + public record CommitReceipt(long journalRevision, + String transactionId, String commandId, String operation, + long firstSequence, long lastSequence, + long committedSequence, String commitDigest, + boolean replayed) { + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + public record RestoreHead(String state, int storageVersion, + long writerGeneration, long journalRevision, + long committedSequence, String lastCommitDigest, + long activationEpoch, String latestCheckpointResourceId, + long compactedThroughRevision, String recoveryStatus, + String recoveryDetailCode) { + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + public record StoredTransaction(long journalRevision, + String transactionId, String operation, String commandId, + String contentDigest, long firstSequence, long lastSequence, + int eventCount, String eventsDigest, + String previousCommitDigest, String commitDigest, + long writerGeneration, long activationEpoch, + String latestCheckpointResourceId, + String recordEncoding, String recordBytesBase64, + long byteLength, String recordDigest) { + } + + public record TransactionPage(List transactions, + long nextRevision, boolean hasMore) { + } + + public record StoredResource(String resourceId, String kind, + int schemaVersion, long byteLength, String digest, + byte[] bytes) { + } +} diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/StoreModels.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/StoreModels.java new file mode 100644 index 00000000000..c5d4845708f --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/StoreModels.java @@ -0,0 +1,98 @@ +package com.alibaba.qwen.code.managedagent.store; + +import java.util.List; +import java.util.Map; + +public final class StoreModels { + private StoreModels() { + } + + public record SessionRecord(String tenantId, String sessionId, + String agentId, String title, String status, + String harnessBootId, String harnessEventEpoch, + long harnessLastEventId, long lastSequence, long createdAt, + long updatedAt, Long deletedAt, long version) { + } + + public record TurnRecord(String tenantId, String sessionId, + String turnId, String promptId, + List> input, String payloadDigest, + String status, boolean submissionAttempted, + String harnessEventEpoch, + Long harnessLastEventId, String dispatchOwner, + Long dispatchLeaseUntil, int retryCount, Long retryAfter, + String errorCode, String errorMessage, long createdAt, + long updatedAt, Long completedAt, long version) { + } + + public record EventRecord(String tenantId, String sessionId, + long sequence, String eventId, String turnId, String type, + Map data, boolean terminal, String sourceKey, + long createdAt) { + } + + public record CommandRecord(String tenantId, String operation, + String idempotencyKey, String requestDigest, String sessionId, + String turnId, String status, String sessionStatusBefore, + long createdAt, long updatedAt) { + } + + public enum SessionMutationKind { + RENAME, + ARCHIVE, + UNARCHIVE, + DELETE + } + + public record SessionMutationCommand(String sessionId, String status, + String sessionStatusBefore, boolean replayed) { + } + + public record Admission(String sessionId, String turnId, + boolean replayed, boolean commandEffect) { + } + + public record SessionPage(List sessions, + boolean hasMore) { + } + + public record EventPage(List events, boolean hasMore) { + } + + public record ItemPartRecord(String partId, String type, String text, + long firstSequence, long lastSequence, long createdAt, + long updatedAt, long revision) { + } + + public record ItemRecord(String tenantId, String sessionId, + String itemId, String turnId, String type, String role, + String status, Map attributes, + long firstSequence, long lastSequence, long createdAt, + long updatedAt, long revision, List content) { + } + + public record SnapshotRecord(String tenantId, String sessionId, + long version, long coveredSequence, List items, + long createdAt, long updatedAt) { + } + + public record MaterializationTarget(String tenantId, String sessionId) { + } + + public record MaterializationResult(boolean advanced, + long coveredSequence) { + } + + public record DispatchTarget(String tenantId, String sessionId, + String turnId) { + } + + public record ProjectedEvent(String type, Map data, + boolean terminal, String terminalStatus, String errorCode, + String errorMessage) { + } + + public record HarnessEvent(long sourceId, String sourceKey, + ProjectedEvent projection) { + } +} diff --git a/packages/sdk-java/managed-agent-server/src/main/resources/application.yml b/packages/sdk-java/managed-agent-server/src/main/resources/application.yml new file mode 100644 index 00000000000..35bd8bc0d0f --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/main/resources/application.yml @@ -0,0 +1,82 @@ +spring: + application: + name: 'qwen-managed-agent-server' + datasource: + url: '${SPRING_DATASOURCE_URL:jdbc:mysql://127.0.0.1:3306/qwen_managed_agent}' + username: '${SPRING_DATASOURCE_USERNAME:qwen}' + password: '${SPRING_DATASOURCE_PASSWORD:}' + hikari: + maximum-pool-size: '${QWEN_MANAGED_AGENT_DB_MAX_POOL_SIZE:20}' + flyway: + enabled: true + lifecycle: + timeout-per-shutdown-phase: '30s' + +server: + address: '${QWEN_MANAGED_AGENT_SERVER_ADDRESS:127.0.0.1}' + shutdown: 'graceful' + +management: + endpoints: + web: + exposure: + include: 'health,info' + +qwen: + managed-agent: + session-store: + enabled: '${QWEN_MANAGED_AGENT_SESSION_STORE_ENABLED:false}' + base-url: '${QWEN_MANAGED_AGENT_SESSION_STORE_BASE_URL:}' + workspace-id: '${QWEN_MANAGED_AGENT_WORKSPACE_ID:}' + writer-lease-duration: '${QWEN_MANAGED_AGENT_SESSION_STORE_WRITER_LEASE_DURATION:60s}' + harness: + enabled: '${QWEN_MANAGED_AGENT_HARNESS_ENABLED:false}' + base-url: '${QWEN_MANAGED_AGENT_HARNESS_BASE_URL:http://127.0.0.1:4170}' + token: '${QWEN_MANAGED_AGENT_HARNESS_TOKEN:}' + capability-digest: '${QWEN_MANAGED_AGENT_CAPABILITY_DIGEST:}' + approval-mode: '${QWEN_MANAGED_AGENT_APPROVAL_MODE:yolo}' + connect-timeout: '5s' + request-timeout: '30s' + heartbeat-interval: '30s' + dispatch: + scan-delay: '1s' + lease-duration: '60s' + lease-renew-interval: '20s' + retry-initial-delay: '1s' + retry-max-delay: '1m' + max-pre-admission-retries: 5 + events: + poll-interval: '5s' + heartbeat-interval: '15s' + stream-timeout: '30m' + batch-interval: '75ms' + materialize-interval: '100ms' + batch-max-events: 64 + batch-max-bytes: 65536 + runtime-broker: + enabled: '${QWEN_MANAGED_AGENT_RUNTIME_BROKER_ENABLED:false}' + host: '${QWEN_MANAGED_AGENT_RUNTIME_BROKER_HOST:127.0.0.1}' + port: '${QWEN_MANAGED_AGENT_RUNTIME_BROKER_PORT:4182}' + token: '${QWEN_MANAGED_AGENT_RUNTIME_BROKER_TOKEN:}' + provisioner: '${QWEN_MANAGED_AGENT_RUNTIME_PROVISIONER:local-process}' + workspace-id: '${QWEN_MANAGED_AGENT_WORKSPACE_ID:}' + workspace-generation: '${QWEN_MANAGED_AGENT_WORKSPACE_GENERATION:1}' + workspace-cwd: '${QWEN_MANAGED_AGENT_WORKSPACE_CWD:}' + isolation-class: '${QWEN_MANAGED_AGENT_RUNTIME_ISOLATION:session}' + state-directory: '${QWEN_MANAGED_AGENT_RUNTIME_STATE_DIRECTORY:}' + credential-key-id: '${QWEN_MANAGED_AGENT_RUNTIME_CREDENTIAL_KEY_ID:}' + credential-key: '${QWEN_MANAGED_AGENT_RUNTIME_CREDENTIAL_KEY:}' + node-executable: '${QWEN_MANAGED_AGENT_NODE_EXECUTABLE:}' + worker-entry: '${QWEN_MANAGED_AGENT_RUNTIME_WORKER_ENTRY:}' + cli-entry: '${QWEN_MANAGED_AGENT_CLI_ENTRY:}' + kubernetes-api-server: '${QWEN_MANAGED_AGENT_KUBERNETES_API_SERVER:https://kubernetes.default.svc}' + kubernetes-token-file: '${QWEN_MANAGED_AGENT_KUBERNETES_TOKEN_FILE:/var/run/secrets/kubernetes.io/serviceaccount/token}' + kubernetes-ca-file: '${QWEN_MANAGED_AGENT_KUBERNETES_CA_FILE:/var/run/secrets/kubernetes.io/serviceaccount/ca.crt}' + kubernetes-cluster-uid: '${QWEN_MANAGED_AGENT_KUBERNETES_CLUSTER_UID:}' + kubernetes-namespace: '${QWEN_MANAGED_AGENT_KUBERNETES_NAMESPACE:qwen-runtimes}' + kubernetes-image: '${QWEN_MANAGED_AGENT_KUBERNETES_IMAGE:}' + kubernetes-port: '${QWEN_MANAGED_AGENT_KUBERNETES_PORT:4190}' + kubernetes-service-account-name: '${QWEN_MANAGED_AGENT_KUBERNETES_SERVICE_ACCOUNT:}' + kubernetes-workspace-claim-name: '${QWEN_MANAGED_AGENT_KUBERNETES_WORKSPACE_CLAIM:}' + static-endpoint: '${QWEN_MANAGED_AGENT_STATIC_RUNTIME_ENDPOINT:}' + static-token: '${QWEN_MANAGED_AGENT_STATIC_RUNTIME_TOKEN:}' diff --git a/packages/sdk-java/managed-agent-server/src/main/resources/db/migration/V1__managed_agent_core.sql b/packages/sdk-java/managed-agent-server/src/main/resources/db/migration/V1__managed_agent_core.sql new file mode 100644 index 00000000000..bec268b7c33 --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/main/resources/db/migration/V1__managed_agent_core.sql @@ -0,0 +1,79 @@ +CREATE TABLE managed_agent_session ( + tenant_id VARCHAR(128) NOT NULL, + session_id VARCHAR(64) NOT NULL, + agent_id VARCHAR(128) NOT NULL, + title VARCHAR(512), + status VARCHAR(32) NOT NULL, + harness_boot_id VARCHAR(36), + harness_event_epoch VARCHAR(64), + harness_last_event_id BIGINT NOT NULL DEFAULT 0, + last_sequence BIGINT NOT NULL DEFAULT 0, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + version BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (tenant_id, session_id), + UNIQUE (session_id) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin; + +CREATE INDEX managed_agent_session_list_idx + ON managed_agent_session (tenant_id, updated_at, session_id); + +CREATE TABLE managed_agent_turn ( + tenant_id VARCHAR(128) NOT NULL, + session_id VARCHAR(64) NOT NULL, + turn_id VARCHAR(64) NOT NULL, + prompt_id VARCHAR(36) NOT NULL, + input_json LONGTEXT NOT NULL, + payload_digest VARCHAR(71) NOT NULL, + status VARCHAR(32) NOT NULL, + submission_attempted BOOLEAN NOT NULL DEFAULT FALSE, + harness_event_epoch VARCHAR(64), + harness_last_event_id BIGINT, + dispatch_owner VARCHAR(128), + dispatch_lease_until BIGINT, + error_code VARCHAR(128), + error_message VARCHAR(2048), + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + completed_at BIGINT, + version BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (tenant_id, session_id, turn_id), + UNIQUE (prompt_id), + FOREIGN KEY (tenant_id, session_id) + REFERENCES managed_agent_session (tenant_id, session_id) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin; + +CREATE INDEX managed_agent_turn_dispatch_idx + ON managed_agent_turn (status, dispatch_lease_until, updated_at); + +CREATE TABLE managed_agent_command ( + tenant_id VARCHAR(128) NOT NULL, + operation VARCHAR(32) NOT NULL, + idempotency_key VARCHAR(128) NOT NULL, + request_digest VARCHAR(71) NOT NULL, + session_id VARCHAR(64) NOT NULL, + turn_id VARCHAR(64), + created_at BIGINT NOT NULL, + PRIMARY KEY (tenant_id, operation, idempotency_key) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin; + +CREATE TABLE managed_agent_event ( + tenant_id VARCHAR(128) NOT NULL, + session_id VARCHAR(64) NOT NULL, + sequence_id BIGINT NOT NULL, + event_id VARCHAR(64) NOT NULL, + turn_id VARCHAR(64), + event_type VARCHAR(128) NOT NULL, + data_json LONGTEXT NOT NULL, + terminal BOOLEAN NOT NULL, + source_key VARCHAR(256), + created_at BIGINT NOT NULL, + PRIMARY KEY (tenant_id, session_id, sequence_id), + UNIQUE (event_id), + UNIQUE (tenant_id, session_id, source_key), + FOREIGN KEY (tenant_id, session_id) + REFERENCES managed_agent_session (tenant_id, session_id) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin; + +CREATE INDEX managed_agent_event_turn_idx + ON managed_agent_event (tenant_id, session_id, turn_id, sequence_id); diff --git a/packages/sdk-java/managed-agent-server/src/main/resources/db/migration/V2__managed_agent_projection.sql b/packages/sdk-java/managed-agent-server/src/main/resources/db/migration/V2__managed_agent_projection.sql new file mode 100644 index 00000000000..ac1adada72b --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/main/resources/db/migration/V2__managed_agent_projection.sql @@ -0,0 +1,68 @@ +CREATE TABLE managed_agent_item ( + tenant_id VARCHAR(128) NOT NULL, + session_id VARCHAR(64) NOT NULL, + item_id VARCHAR(128) NOT NULL, + turn_id VARCHAR(64) NOT NULL, + item_type VARCHAR(32) NOT NULL, + item_role VARCHAR(32), + item_status VARCHAR(32) NOT NULL, + attributes_json LONGTEXT NOT NULL, + first_sequence BIGINT NOT NULL, + last_sequence BIGINT NOT NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + revision BIGINT NOT NULL DEFAULT 1, + PRIMARY KEY (tenant_id, session_id, item_id), + FOREIGN KEY (tenant_id, session_id) + REFERENCES managed_agent_session (tenant_id, session_id) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin; + +CREATE INDEX managed_agent_item_history_idx + ON managed_agent_item (tenant_id, session_id, first_sequence); + +CREATE TABLE managed_agent_item_part ( + tenant_id VARCHAR(128) NOT NULL, + session_id VARCHAR(64) NOT NULL, + item_id VARCHAR(128) NOT NULL, + part_id VARCHAR(128) NOT NULL, + part_type VARCHAR(32) NOT NULL, + part_text LONGTEXT NOT NULL, + first_sequence BIGINT NOT NULL, + last_sequence BIGINT NOT NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + revision BIGINT NOT NULL DEFAULT 1, + PRIMARY KEY (tenant_id, session_id, item_id, part_id), + FOREIGN KEY (tenant_id, session_id, item_id) + REFERENCES managed_agent_item (tenant_id, session_id, item_id) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin; + +CREATE TABLE managed_agent_snapshot ( + tenant_id VARCHAR(128) NOT NULL, + session_id VARCHAR(64) NOT NULL, + snapshot_version BIGINT NOT NULL, + covered_sequence BIGINT NOT NULL, + items_json LONGTEXT NOT NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + PRIMARY KEY (tenant_id, session_id), + FOREIGN KEY (tenant_id, session_id) + REFERENCES managed_agent_session (tenant_id, session_id) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin; + +CREATE TABLE managed_agent_consumer_progress ( + tenant_id VARCHAR(128) NOT NULL, + session_id VARCHAR(64) NOT NULL, + consumer_name VARCHAR(64) NOT NULL, + covered_sequence BIGINT NOT NULL DEFAULT 0, + updated_at BIGINT NOT NULL, + PRIMARY KEY (tenant_id, session_id, consumer_name), + FOREIGN KEY (tenant_id, session_id) + REFERENCES managed_agent_session (tenant_id, session_id) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin; + +INSERT INTO managed_agent_consumer_progress ( + tenant_id, session_id, consumer_name, covered_sequence, updated_at +) +SELECT tenant_id, session_id, 'message_projection', 0, updated_at +FROM managed_agent_session; diff --git a/packages/sdk-java/managed-agent-server/src/main/resources/db/migration/V3__runtime_broker.sql b/packages/sdk-java/managed-agent-server/src/main/resources/db/migration/V3__runtime_broker.sql new file mode 100644 index 00000000000..4b354b28f2c --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/main/resources/db/migration/V3__runtime_broker.sql @@ -0,0 +1,123 @@ +CREATE TABLE qwen_runtime_binding_slot ( + request_key CHAR(64) PRIMARY KEY, + tenant_id VARCHAR(512) NOT NULL, + workspace_id VARCHAR(512) NOT NULL, + workspace_generation VARCHAR(512) NOT NULL, + canonical_cwd VARCHAR(512) NOT NULL, + capability_digest VARCHAR(512) NOT NULL, + isolation_class VARCHAR(32) NOT NULL, + isolation_key VARCHAR(512), + provisioner_kind VARCHAR(128) NOT NULL, + placement_domain VARCHAR(512) NOT NULL, + runtime_template_digest VARCHAR(512) NOT NULL, + last_generation BIGINT NOT NULL, + active_binding_id VARCHAR(512), + CONSTRAINT uq_runtime_binding_isolation UNIQUE (isolation_key) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin; + +CREATE TABLE qwen_runtime_binding ( + binding_id VARCHAR(512) PRIMARY KEY, + request_key CHAR(64) NOT NULL, + scope_key CHAR(64) NOT NULL, + tenant_id VARCHAR(512) NOT NULL, + workspace_id VARCHAR(512) NOT NULL, + workspace_generation VARCHAR(512) NOT NULL, + canonical_cwd VARCHAR(512) NOT NULL, + capability_digest VARCHAR(512) NOT NULL, + isolation_class VARCHAR(32) NOT NULL, + isolation_key VARCHAR(512), + provisioner_kind VARCHAR(128) NOT NULL, + placement_domain VARCHAR(512) NOT NULL, + runtime_template_digest VARCHAR(512) NOT NULL, + runtime_generation BIGINT NOT NULL, + binding_state VARCHAR(32) NOT NULL, + provision_request_id VARCHAR(512), + provision_seed_ciphertext LONGTEXT, + credential_key_id VARCHAR(512), + resource_handle_version INT, + resource_handle_json LONGTEXT, + runtime_instance_id VARCHAR(512), + runtime_endpoint VARCHAR(2048), + runtime_lease_id VARCHAR(512), + runtime_epoch BIGINT, + runtime_credential_ciphertext LONGTEXT, + runtime_credential_key_id VARCHAR(512), + attestation_generation BIGINT NOT NULL, + drain_requested BOOLEAN NOT NULL, + operation_owner VARCHAR(512), + operation_lease_until DATETIME(6), + operation_generation BIGINT NOT NULL, + record_version BIGINT NOT NULL, + last_health_at DATETIME(6), + last_reconciled_at DATETIME(6), + last_active_at DATETIME(6) NOT NULL, + CONSTRAINT uq_runtime_binding_generation + UNIQUE (request_key, runtime_generation) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin; + +CREATE INDEX idx_runtime_binding_scope + ON qwen_runtime_binding (scope_key, isolation_key, binding_state); + +CREATE TABLE qwen_runtime_session ( + runtime_session_id VARCHAR(512) PRIMARY KEY, + scope_key CHAR(64) NOT NULL, + tenant_id VARCHAR(512) NOT NULL, + workspace_id VARCHAR(512) NOT NULL, + workspace_generation VARCHAR(512) NOT NULL, + canonical_cwd VARCHAR(512) NOT NULL, + capability_digest VARCHAR(512) NOT NULL, + isolation_class VARCHAR(32) NOT NULL, + harness_session_id VARCHAR(512) NOT NULL, + turn_kind VARCHAR(32) NOT NULL, + binding_id VARCHAR(512) NOT NULL, + runtime_generation BIGINT NOT NULL, + session_state VARCHAR(32) NOT NULL, + record_version BIGINT NOT NULL, + last_active_at DATETIME(6) NOT NULL +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin; + +CREATE INDEX idx_runtime_session_scope + ON qwen_runtime_session (scope_key); + +CREATE INDEX idx_runtime_session_binding + ON qwen_runtime_session ( + binding_id, runtime_generation, session_state + ); + +CREATE TABLE qwen_tool_execution ( + execution_call_id_hash CHAR(64) NOT NULL, + execution_call_id VARCHAR(512) PRIMARY KEY, + idempotency_key_hash CHAR(64) NOT NULL, + idempotency_key VARCHAR(512) NOT NULL, + binding_id VARCHAR(512) NOT NULL, + runtime_generation BIGINT NOT NULL, + harness_session_id VARCHAR(512) NOT NULL, + runtime_session_id VARCHAR(512) NOT NULL, + runtime_session_key CHAR(64) NOT NULL, + turn_id VARCHAR(512) NOT NULL, + tool_call_id VARCHAR(512) NOT NULL, + request_digest VARCHAR(512) NOT NULL, + reference_json LONGTEXT NOT NULL, + execution_state VARCHAR(32) NOT NULL, + execution_status VARCHAR(32), + result_json LONGTEXT, + last_sequence BIGINT NOT NULL, + cancel_requested BOOLEAN NOT NULL, + dispatch_owner VARCHAR(512), + dispatch_lease_until DATETIME(6), + dispatch_generation BIGINT NOT NULL, + record_version BIGINT NOT NULL, + settled_at DATETIME(6), + CONSTRAINT uq_tool_execution_call_hash + UNIQUE (execution_call_id_hash), + CONSTRAINT uq_tool_execution_idempotency + UNIQUE (idempotency_key_hash) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin; + +CREATE INDEX idx_tool_execution_session + ON qwen_tool_execution (runtime_session_key, execution_state); + +CREATE INDEX idx_tool_execution_binding + ON qwen_tool_execution ( + binding_id, runtime_generation, execution_state + ); diff --git a/packages/sdk-java/managed-agent-server/src/main/resources/db/migration/V4__managed_session_store.sql b/packages/sdk-java/managed-agent-server/src/main/resources/db/migration/V4__managed_session_store.sql new file mode 100644 index 00000000000..c0a06464abc --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/main/resources/db/migration/V4__managed_session_store.sql @@ -0,0 +1,90 @@ +CREATE TABLE qwen_managed_session_journal_head ( + tenant_id VARCHAR(128) NOT NULL, + workspace_id VARCHAR(512) NOT NULL, + session_id VARCHAR(512) NOT NULL, + storage_version INT NOT NULL, + state VARCHAR(32) NOT NULL, + writer_generation BIGINT NOT NULL, + writer_id VARCHAR(512), + writer_lease_until DATETIME(6), + lease_token_hash CHAR(64), + journal_revision BIGINT NOT NULL, + committed_sequence BIGINT NOT NULL, + last_commit_digest CHAR(64), + activation_epoch BIGINT NOT NULL, + latest_checkpoint_resource_id VARCHAR(512), + compacted_through_revision BIGINT NOT NULL, + recovery_status VARCHAR(32) NOT NULL, + recovery_detail_code VARCHAR(128), + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (tenant_id, session_id) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin; + +CREATE TABLE qwen_managed_session_journal_tx ( + tenant_id VARCHAR(128) NOT NULL, + workspace_id VARCHAR(512) NOT NULL, + session_id VARCHAR(512) NOT NULL, + journal_revision BIGINT NOT NULL, + command_key_hash CHAR(64) NOT NULL, + transaction_id VARCHAR(512) NOT NULL, + operation LONGTEXT NOT NULL, + command_id VARCHAR(512) NOT NULL, + content_digest CHAR(64) NOT NULL, + first_sequence BIGINT NOT NULL, + last_sequence BIGINT NOT NULL, + event_count INT NOT NULL, + events_digest CHAR(64), + previous_commit_digest CHAR(64), + commit_digest CHAR(64), + writer_generation BIGINT NOT NULL, + writer_id VARCHAR(512) NOT NULL, + writer_token_hash CHAR(64) NOT NULL, + activation_epoch BIGINT NOT NULL, + latest_checkpoint_resource_id VARCHAR(512), + record_encoding VARCHAR(32) NOT NULL, + record_bytes MEDIUMBLOB NOT NULL, + byte_length BIGINT NOT NULL, + record_digest CHAR(64) NOT NULL, + created_at DATETIME(6) NOT NULL, + PRIMARY KEY (tenant_id, session_id, journal_revision), + CONSTRAINT uq_managed_session_command + UNIQUE (tenant_id, session_id, command_key_hash) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin; + +CREATE TABLE qwen_managed_session_resource ( + session_scope_key CHAR(64) NOT NULL, + tenant_id VARCHAR(128) NOT NULL, + workspace_id VARCHAR(512) NOT NULL, + session_id VARCHAR(512) NOT NULL, + resource_id VARCHAR(512) NOT NULL, + kind VARCHAR(512) NOT NULL, + schema_version INT NOT NULL, + byte_length BIGINT NOT NULL, + sha256 CHAR(64) NOT NULL, + storage_kind VARCHAR(32) NOT NULL, + inline_bytes MEDIUMBLOB, + object_key VARCHAR(2048), + object_version_id VARCHAR(512), + encryption_key_id VARCHAR(512), + publish_command_id VARCHAR(512) NOT NULL, + state VARCHAR(32) NOT NULL, + created_at DATETIME(6) NOT NULL, + last_verified_at DATETIME(6), + retention_until DATETIME(6), + PRIMARY KEY (session_scope_key, resource_id) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin; + +CREATE TABLE qwen_managed_session_resource_ref ( + session_scope_key CHAR(64) NOT NULL, + tenant_id VARCHAR(128) NOT NULL, + workspace_id VARCHAR(512) NOT NULL, + session_id VARCHAR(512) NOT NULL, + journal_revision BIGINT NOT NULL, + resource_id VARCHAR(512) NOT NULL, + created_at DATETIME(6) NOT NULL, + PRIMARY KEY (session_scope_key, journal_revision, resource_id) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin; + +CREATE INDEX idx_managed_session_resource_ref_resource + ON qwen_managed_session_resource_ref (session_scope_key, resource_id); diff --git a/packages/sdk-java/managed-agent-server/src/main/resources/db/migration/V5__managed_session_lifecycle.sql b/packages/sdk-java/managed-agent-server/src/main/resources/db/migration/V5__managed_session_lifecycle.sql new file mode 100644 index 00000000000..dfbe37dc8e1 --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/main/resources/db/migration/V5__managed_session_lifecycle.sql @@ -0,0 +1,19 @@ +ALTER TABLE managed_agent_command + ADD COLUMN command_status VARCHAR(32) NOT NULL DEFAULT 'COMPLETED'; + +ALTER TABLE managed_agent_command + ADD COLUMN updated_at BIGINT NOT NULL DEFAULT 0; + +ALTER TABLE managed_agent_command + ADD COLUMN session_status_before VARCHAR(32); + +ALTER TABLE managed_agent_session + ADD COLUMN deleted_at BIGINT; + +CREATE INDEX managed_agent_command_pending_idx + ON managed_agent_command ( + tenant_id, session_id, command_status + ); + +CREATE INDEX managed_agent_session_status_idx + ON managed_agent_session (tenant_id, status, updated_at, session_id); diff --git a/packages/sdk-java/managed-agent-server/src/main/resources/db/migration/V6__managed_turn_retry.sql b/packages/sdk-java/managed-agent-server/src/main/resources/db/migration/V6__managed_turn_retry.sql new file mode 100644 index 00000000000..e37f82224b5 --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/main/resources/db/migration/V6__managed_turn_retry.sql @@ -0,0 +1,5 @@ +ALTER TABLE managed_agent_turn + ADD COLUMN retry_count INT NOT NULL DEFAULT 0; + +ALTER TABLE managed_agent_turn + ADD COLUMN retry_after BIGINT; diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/EmbeddedRuntimeBrokerTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/EmbeddedRuntimeBrokerTest.java new file mode 100644 index 00000000000..5be912bd708 --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/EmbeddedRuntimeBrokerTest.java @@ -0,0 +1,147 @@ +package com.alibaba.qwen.code.managedagent; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties; +import com.alibaba.qwen.code.managedagent.service.EmbeddedRuntimeBroker; +import com.alibaba.qwen.code.managedagent.store.ManagedAgentStore; +import com.alibaba.qwen.code.managedagent.store.StoreModels.SessionRecord; +import com.alibaba.qwen.code.runtimebroker.InMemoryRuntimeBindingRepository; +import com.alibaba.qwen.code.runtimebroker.InMemoryRuntimeSessionRepository; +import com.alibaba.qwen.code.runtimebroker.InMemoryToolExecutionRepository; +import java.net.HttpURLConnection; +import java.net.URI; +import java.nio.file.Path; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class EmbeddedRuntimeBrokerTest { + private static final String SESSION_ID = + "550e8400-e29b-41d4-a716-446655440000"; + + @Test + void usesFetchCompatibleDefaultBrokerPort() { + assertThat(new ManagedAgentProperties().getRuntimeBroker().getPort()) + .isEqualTo(4182); + } + + @Test + void startsPrivateListenerAndResolvesTenantFromTheSessionStore() + throws Exception { + ManagedAgentStore store = mock(ManagedAgentStore.class); + when(store.findSessionById(SESSION_ID)).thenReturn( + Optional.of(new SessionRecord("tenant-a", SESSION_ID, + "qwen-code", null, "ACTIVE", + null, null, 0, 0, 1, 1, null, 0))); + ManagedAgentProperties properties = properties(); + + try (EmbeddedRuntimeBroker broker = broker(store, properties)) { + broker.warm(SESSION_ID).toCompletableFuture().join(); + verify(store).findSessionById(SESSION_ID); + + URI endpoint = broker.getBaseUri().resolve( + "/internal/runtime-broker/v1/tool-sessions:acquire"); + HttpURLConnection connection = (HttpURLConnection) endpoint + .toURL().openConnection(); + connection.setRequestMethod("POST"); + connection.setRequestProperty("Authorization", "Bearer wrong"); + connection.setDoOutput(true); + connection.getOutputStream().write("{}".getBytes()); + assertThat(connection.getResponseCode()).isEqualTo(401); + } + } + + @Test + void rejectsUnsupportedBrokerRoutesBeforeAnySideEffects() + throws Exception { + ManagedAgentStore store = mock(ManagedAgentStore.class); + try (EmbeddedRuntimeBroker broker = broker(store, properties())) { + for (String route : java.util.List.of("executions:prepare", + "executions/execution-1:start", + "executions/execution-1:resolve")) { + HttpURLConnection connection = (HttpURLConnection) broker + .getBaseUri().resolve("/internal/runtime-broker/v1/" + + route).toURL().openConnection(); + connection.setRequestMethod("POST"); + connection.setRequestProperty("Authorization", + "Bearer broker-token"); + connection.setDoOutput(true); + connection.getOutputStream().write("{}".getBytes()); + assertThat(connection.getResponseCode()).isEqualTo(501); + assertThat(new String(connection.getErrorStream() + .readAllBytes(), java.nio.charset.StandardCharsets.UTF_8)) + .contains("runtime_broker_operation_unsupported"); + connection.disconnect(); + } + org.mockito.Mockito.verifyNoInteractions(store); + } + } + + @Test + void derivesWorkspaceIdWhenItIsNotConfigured() throws Exception { + ManagedAgentProperties properties = properties(); + properties.getRuntimeBroker().setProvisioner("local-process"); + properties.getRuntimeBroker().setWorkspaceId(""); + + assertThatThrownBy(() -> broker(mock(ManagedAgentStore.class), + properties)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("state directory"); + } + + @Test + void rejectsMismatchedLocalProcessWorkspaceId() throws Exception { + ManagedAgentProperties properties = properties(); + properties.getRuntimeBroker().setProvisioner("local-process"); + properties.getRuntimeBroker().setWorkspaceId("wrong-workspace"); + + assertThatThrownBy(() -> broker(mock(ManagedAgentStore.class), + properties)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("canonical workspace path hash"); + } + + @Test + void closingTheListenerAlsoClosesTheBrokerService() throws Exception { + EmbeddedRuntimeBroker broker = broker(mock(ManagedAgentStore.class), + properties()); + + broker.close(); + + assertThatThrownBy(() -> broker.warm(SESSION_ID)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("closed"); + } + + private static ManagedAgentProperties properties() throws Exception { + ManagedAgentProperties properties = new ManagedAgentProperties(); + properties.getHarness().setCapabilityDigest("sha256:" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + ManagedAgentProperties.RuntimeBroker broker = + properties.getRuntimeBroker(); + broker.setHost("127.0.0.1"); + broker.setPort(0); + broker.setToken("broker-token"); + broker.setProvisioner("static"); + broker.setWorkspaceId("workspace"); + broker.setWorkspaceGeneration("generation"); + broker.setWorkspaceCwd(Path.of(".").toRealPath().toString()); + broker.setIsolationClass("workspace"); + broker.setStaticEndpoint("http://127.0.0.1:9"); + broker.setStaticToken("runtime-token"); + return properties; + } + + private static EmbeddedRuntimeBroker broker(ManagedAgentStore store, + ManagedAgentProperties properties) { + return new EmbeddedRuntimeBroker(store, properties, + new InMemoryRuntimeBindingRepository(), + new InMemoryRuntimeSessionRepository(), + new InMemoryToolExecutionRepository()); + } +} diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HarnessEventProjectorTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HarnessEventProjectorTest.java new file mode 100644 index 00000000000..98401fc2ff2 --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/HarnessEventProjectorTest.java @@ -0,0 +1,53 @@ +package com.alibaba.qwen.code.managedagent; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.alibaba.qwen.code.managedagent.harness.HarnessConnector.SourceEvent; +import com.alibaba.qwen.code.managedagent.service.HarnessEventProjector; +import com.alibaba.qwen.code.managedagent.store.StoreModels.ProjectedEvent; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class HarnessEventProjectorTest { + private final HarnessEventProjector projector = + new HarnessEventProjector(); + + @Test + void projectsTextWithoutLeakingOtherUpdateFields() { + ProjectedEvent event = projector.project(new SourceEvent(1L, + "session_update", Map.of("update", Map.of( + "sessionUpdate", "agent_message_chunk", + "content", Map.of("type", "text", "text", "hello"), + "secret", "must-not-leak")), "prompt", Map.of()), + "turn-1"); + + assertThat(event.type()).isEqualTo("item.output_text.delta"); + assertThat(event.data()).containsEntry("text", "hello") + .containsEntry("itemId", "item_turn-1_assistant") + .containsEntry("contentPartId", + "part_turn-1_output_text"); + } + + @Test + void mapsCancellationToATerminalState() { + ProjectedEvent event = projector.project(new SourceEvent(2L, + "turn_complete", Map.of("stopReason", "cancelled"), + "prompt", Map.of()), "turn-1"); + + assertThat(event.type()).isEqualTo("turn.cancelled"); + assertThat(event.terminalStatus()).isEqualTo("CANCELLED"); + assertThat(event.terminal()).isTrue(); + } + + @Test + void redactsHarnessErrorDetails() { + ProjectedEvent event = projector.project(new SourceEvent(3L, + "turn_error", Map.of("code", "model_failed", "message", + "token=secret path=/private/workspace"), + "prompt", Map.of()), "turn-1"); + + assertThat(event.errorCode()).isEqualTo("model_failed"); + assertThat(event.data().toString()).doesNotContain("secret") + .doesNotContain("/private/workspace"); + } +} diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentMySqlIT.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentMySqlIT.java new file mode 100644 index 00000000000..43243e036d2 --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentMySqlIT.java @@ -0,0 +1,436 @@ +package com.alibaba.qwen.code.managedagent; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.alibaba.qwen.code.managedagent.api.ApiException; +import com.alibaba.qwen.code.managedagent.store.ManagedAgentStore; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStore; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.AcquireWriterRequest; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.CommitReceipt; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.CommitResource; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.CommitTransactionRequest; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.SealWriterRequest; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.WriterGrant; +import com.alibaba.qwen.code.managedagent.store.StoreModels.Admission; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.sql.Timestamp; +import java.time.Clock; +import java.util.Base64; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; +import org.flywaydb.core.Flyway; +import org.flywaydb.core.api.MigrationVersion; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class ManagedAgentMySqlIT { + @Test + @Order(1) + void upgradesAndExercisesStoresOnMySql() { + DriverManagerDataSource dataSource = dataSource(); + Flyway.configure().dataSource(dataSource) + .locations("classpath:db/migration") + .target(MigrationVersion.fromVersion("1")).load().migrate(); + JdbcTemplate jdbc = new JdbcTemplate(dataSource); + jdbc.update("INSERT INTO managed_agent_session (tenant_id," + + " session_id, agent_id, status, created_at," + + " updated_at) VALUES (?, ?, ?, ?, ?, ?)", + "mysql-upgrade", "session_upgrade", "qwen-code", "IDLE", + 1L, 1L); + Flyway.configure().dataSource(dataSource) + .locations("classpath:db/migration").load().migrate(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM" + + " managed_agent_consumer_progress WHERE tenant_id = ?" + + " AND session_id = ? AND consumer_name = ?", + Integer.class, "mysql-upgrade", "session_upgrade", + "message_projection")).isEqualTo(1); + ManagedAgentStore store = new ManagedAgentStore( + jdbc, new ObjectMapper(), Clock.systemUTC(), ignored -> { + }); + String tenant = "mysql-projection"; + List> input = List.of(Map.of( + "type", "text", "text", "hello")); + Admission admission = store.insertSessionCommand(tenant, + "CREATE_SESSION", "mysql-create", + "sha256:" + "a".repeat(64), "qwen-code", null, input, + "sha256:" + "b".repeat(64)); + String assistantItem = "item_" + admission.turnId() + "_assistant"; + String part = "part_" + admission.turnId() + "_output_text"; + store.appendPublicEventIfAbsent(tenant, admission.sessionId(), + admission.turnId(), "item.output_text.delta", Map.of( + "itemId", assistantItem, "contentPartId", part, + "text", "hel"), false, "mysql:1"); + store.appendPublicEventIfAbsent(tenant, admission.sessionId(), + admission.turnId(), "item.output_text.delta", Map.of( + "itemId", assistantItem, "contentPartId", part, + "text", "lo"), false, "mysql:2"); + store.appendPublicEventIfAbsent(tenant, admission.sessionId(), + admission.turnId(), "item.tool_call.updated", Map.of( + "toolCallId", "legacy-tool", "name", "read_file", + "status", "completed"), false, "mysql:legacy-tool"); + store.appendPublicEventIfAbsent(tenant, admission.sessionId(), + admission.turnId(), "turn.completed", Map.of(), true, + "mysql:3"); + + assertThat(store.materializeNextBatch(tenant, admission.sessionId(), + 200).advanced()).isTrue(); + assertThat(store.findSnapshot(tenant, admission.sessionId())) + .get().satisfies(snapshot -> { + assertThat(snapshot.coveredSequence()).isEqualTo(6); + assertThat(snapshot.items()).hasSize(3) + .filteredOn(item -> + "assistant".equals(item.role())) + .filteredOn(item -> "message".equals(item.type())) + .singleElement().satisfies(item -> { + assertThat(item.status()) + .isEqualTo("completed"); + assertThat(item.content()).singleElement() + .extracting(content -> content.text()) + .isEqualTo("hello"); + }); + assertThat(snapshot.items()) + .filteredOn(item -> "tool_call".equals(item.type())) + .singleElement().satisfies(item -> { + assertThat(item.status()) + .isEqualTo("completed"); + assertThat(item.attributes()) + .containsEntry("toolCallId", + "legacy-tool"); + }); + }); + assertThat(store.materializeNextBatch(tenant, admission.sessionId(), + 200).advanced()).isFalse(); + + TransactionTemplate transactions = new TransactionTemplate( + new DataSourceTransactionManager(dataSource)); + ManagedSessionStore firstInstance = new ManagedSessionStore(jdbc); + ManagedSessionStore secondInstance = new ManagedSessionStore(jdbc); + String storeTenant = "mysql-private-store"; + String sessionId = "mysql-private-session"; + String workspaceId = "mysql-private-workspace"; + String tokenA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + String tokenB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + WriterGrant firstGrant = inTransaction(transactions, + () -> firstInstance.acquireWriter(storeTenant, sessionId, + tokenA, new AcquireWriterRequest(workspaceId, + "writer-a", 60_000L))); + assertThat(firstGrant.writerGeneration()).isEqualTo(1); + assertThatThrownBy(() -> inTransaction(transactions, + () -> secondInstance.acquireWriter(storeTenant, sessionId, + tokenB, new AcquireWriterRequest(workspaceId, + "writer-b", 60_000L)))) + .isInstanceOfSatisfying(ApiException.class, error -> + assertThat(error.getCode()).isEqualTo( + "managed_session_writer_conflict")); + + String genesisRecords = + "{\"subtype\":\"session_execution_engine\"}\n" + + "{\"subtype\":" + + "\"managed_session_header_v1\"}\n"; + byte[] resourceBytes = "mysql-resource" + .getBytes(StandardCharsets.UTF_8); + CommitTransactionRequest genesis = new CommitTransactionRequest( + workspaceId, "writer-a", 1, 0, 0, + "mysql-genesis-transaction", "session.create", + "mysql-genesis-command", sha256(genesisRecords), 0, 0, 0, + null, null, null, 0, null, 2, + Base64.getEncoder().encodeToString( + genesisRecords.getBytes(StandardCharsets.UTF_8)), + sha256(genesisRecords), List.of(new CommitResource( + "mysql-resource", "managed-context", 1, + resourceBytes.length, sha256("mysql-resource"), + Base64.getEncoder().encodeToString(resourceBytes)))); + CommitReceipt committed = inTransaction(transactions, + () -> firstInstance.commit(storeTenant, sessionId, tokenA, + genesis)); + CommitReceipt replayed = inTransaction(transactions, + () -> secondInstance.commit(storeTenant, sessionId, tokenA, + genesis)); + assertThat(replayed.journalRevision()) + .isEqualTo(committed.journalRevision()); + assertThat(replayed.transactionId()) + .isEqualTo(committed.transactionId()); + assertThat(replayed.replayed()).isTrue(); + assertThat(inTransaction(transactions, + () -> secondInstance.readResource(storeTenant, workspaceId, + sessionId, "mysql-resource", tokenA)).bytes()) + .isEqualTo(resourceBytes); + assertThatThrownBy(() -> inTransaction(transactions, + () -> secondInstance.restore("MYSQL-PRIVATE-STORE", + workspaceId, sessionId, tokenA))) + .isInstanceOfSatisfying(ApiException.class, error -> + assertThat(error.getCode()).isEqualTo( + "managed_session_not_found")); + assertThatThrownBy(() -> inTransaction(transactions, + () -> secondInstance.readResource(storeTenant, workspaceId, + sessionId, "MYSQL-RESOURCE", tokenA))) + .isInstanceOfSatisfying(ApiException.class, error -> + assertThat(error.getCode()).isEqualTo( + ManagedSessionStoreModels + .ERROR_RESOURCE_NOT_FOUND)); + + inTransaction(transactions, () -> firstInstance.sealWriter( + storeTenant, sessionId, tokenA, + new SealWriterRequest(workspaceId, "writer-a", 1))); + WriterGrant secondGrant = inTransaction(transactions, + () -> secondInstance.acquireWriter(storeTenant, sessionId, + tokenB, new AcquireWriterRequest(workspaceId, + "writer-b", 60_000L))); + assertThat(secondGrant.writerGeneration()).isEqualTo(2); + assertThat(inTransaction(transactions, + () -> firstInstance.commit(storeTenant, sessionId, tokenA, + genesis)).replayed()).isTrue(); + + String turnRecords = + "{\"subtype\":\"managed_session_event_v1\"}\n" + + "{\"subtype\":" + + "\"managed_session_commit_v1\"}\n"; + CommitTransactionRequest staleCommit = + new CommitTransactionRequest(workspaceId, "writer-a", 1, + 1, 0, "mysql-turn-transaction", "turn.submit", + "mysql-turn-command", sha256("turn-content"), + 1, 1, 1, "e".repeat(64), null, + "c".repeat(64), 0, null, 2, + Base64.getEncoder().encodeToString(turnRecords + .getBytes(StandardCharsets.UTF_8)), + sha256(turnRecords), List.of()); + assertThatThrownBy(() -> inTransaction(transactions, + () -> firstInstance.commit(storeTenant, sessionId, tokenA, + staleCommit))) + .isInstanceOfSatisfying(ApiException.class, error -> + assertThat(error.getCode()).isEqualTo( + "managed_session_writer_conflict")); + } + + @Test + @Order(2) + void shortWriterLeaseKeepsSubsecondDatabasePrecision() + throws InterruptedException { + DriverManagerDataSource dataSource = dataSource(); + Flyway.configure().dataSource(dataSource) + .locations("classpath:db/migration").load().migrate(); + JdbcTemplate jdbc = new JdbcTemplate(dataSource); + String tenant = "mysql-lease-precision"; + String session = "mysql-lease-precision-session"; + jdbc.update("DELETE FROM qwen_managed_session_journal_head" + + " WHERE tenant_id = ? AND session_id = ?", tenant, + session); + + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3); + boolean precisionWindowReached = false; + while (System.nanoTime() < deadline) { + Integer micros = jdbc.queryForObject( + "SELECT MICROSECOND(CURRENT_TIMESTAMP(6))", + Integer.class); + if (micros != null && micros >= 600_000 && micros <= 700_000) { + precisionWindowReached = true; + break; + } + Thread.sleep(5); + } + assertThat(precisionWindowReached).isTrue(); + + TransactionTemplate transactions = new TransactionTemplate( + new DataSourceTransactionManager(dataSource)); + ManagedSessionStore store = new ManagedSessionStore(jdbc); + WriterGrant grant = inTransaction(transactions, + () -> store.acquireWriter(tenant, session, + "cccccccccccccccccccccccccccccccc", + new AcquireWriterRequest("mysql-lease-workspace", + "mysql-lease-writer", 1_000L))); + Timestamp now = jdbc.queryForObject("SELECT CURRENT_TIMESTAMP(6)", + Timestamp.class); + + assertThat(now).isNotNull(); + assertThat(grant.leaseUntil() - now.getTime()) + .isBetween(700L, 1_000L); + Long persistedLeaseMicros = jdbc.queryForObject( + "SELECT TIMESTAMPDIFF(MICROSECOND, CURRENT_TIMESTAMP(6)," + + " writer_lease_until) FROM" + + " qwen_managed_session_journal_head WHERE tenant_id" + + " = ? AND session_id = ?", + Long.class, tenant, session); + assertThat(persistedLeaseMicros).isPositive(); + } + + @Test + @Order(3) + void independentProcessesRecoverACommittedSessionAfterOwnerLoss() + throws Exception { + DriverManagerDataSource dataSource = dataSource(); + Flyway.configure().dataSource(dataSource) + .locations("classpath:db/migration").load().migrate(); + JdbcTemplate jdbc = new JdbcTemplate(dataSource); + cleanupProcessFixture(jdbc); + + Process crashed = startStoreProcess("commit-and-crash"); + assertProcess(crashed, 23, null); + awaitLeaseExpiry(jdbc); + assertProcess(startStoreProcess("takeover"), 0, + "D1_PROCESS_TAKEOVER_OK"); + assertProcess(startStoreProcess("replay"), 0, + "D1_PROCESS_REPLAY_OK"); + assertProcess(startStoreProcess("stale-write"), 0, + "D1_PROCESS_STALE_WRITER_OK"); + assertProcess(startStoreProcess("restore"), 0, + "D1_PROCESS_RESTORE_OK"); + + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM" + + " qwen_managed_session_journal_tx" + + " WHERE tenant_id = ? AND session_id = ?", + Integer.class, + ManagedSessionStoreProcessFixtureMain.TENANT, + ManagedSessionStoreProcessFixtureMain.SESSION)) + .isEqualTo(1); + } + + @Test + @Order(4) + void isolatesTenantsThatDifferOnlyByCase() { + DriverManagerDataSource dataSource = dataSource(); + Flyway.configure().dataSource(dataSource) + .locations("classpath:db/migration").load().migrate(); + ManagedAgentStore store = new ManagedAgentStore( + new JdbcTemplate(dataSource), new ObjectMapper(), + Clock.systemUTC(), ignored -> { + }); + Admission lower = store.insertSessionCommand("case-tenant", + "CREATE_SESSION", "case-key", "case-digest", "qwen-code", + null, List.of(), null); + Admission upper = store.insertSessionCommand("CASE-TENANT", + "CREATE_SESSION", "case-key", "case-digest", "qwen-code", + null, List.of(), null); + + assertThat(upper.sessionId()).isNotEqualTo(lower.sessionId()); + assertThat(store.findSession("CASE-TENANT", lower.sessionId())) + .isEmpty(); + assertThat(store.findSession("case-tenant", upper.sessionId())) + .isEmpty(); + assertThat(store.listSessions("CASE-TENANT", null, null, 10) + .sessions()).extracting(session -> session.sessionId()) + .containsExactly(upper.sessionId()); + } + + private static void cleanupProcessFixture(JdbcTemplate jdbc) { + Object[] scope = { + ManagedSessionStoreProcessFixtureMain.TENANT, + ManagedSessionStoreProcessFixtureMain.SESSION + }; + jdbc.update("DELETE FROM qwen_managed_session_resource_ref" + + " WHERE tenant_id = ? AND session_id = ?", scope); + jdbc.update("DELETE FROM qwen_managed_session_resource" + + " WHERE tenant_id = ? AND session_id = ?", scope); + jdbc.update("DELETE FROM qwen_managed_session_journal_tx" + + " WHERE tenant_id = ? AND session_id = ?", scope); + jdbc.update("DELETE FROM qwen_managed_session_journal_head" + + " WHERE tenant_id = ? AND session_id = ?", scope); + } + + private static void awaitLeaseExpiry(JdbcTemplate jdbc) + throws InterruptedException { + long deadline = System.nanoTime() + + TimeUnit.SECONDS.toNanos(10); + while (System.nanoTime() < deadline) { + Boolean expired = jdbc.queryForObject( + "SELECT writer_lease_until < CURRENT_TIMESTAMP(6)" + + " FROM qwen_managed_session_journal_head" + + " WHERE tenant_id = ? AND session_id = ?", + Boolean.class, + ManagedSessionStoreProcessFixtureMain.TENANT, + ManagedSessionStoreProcessFixtureMain.SESSION); + if (Boolean.TRUE.equals(expired)) { + return; + } + Thread.sleep(25); + } + throw new AssertionError("the crashed writer lease did not expire"); + } + + private static Process startStoreProcess(String action) + throws IOException { + String java = Path.of(System.getProperty("java.home"), "bin", + isWindows() ? "java.exe" : "java").toString(); + String classpath = System.getProperty("surefire.test.class.path"); + if (classpath == null || classpath.isBlank()) { + classpath = System.getProperty("java.class.path"); + } + ProcessBuilder builder = new ProcessBuilder(java, "-cp", classpath, + ManagedSessionStoreProcessFixtureMain.class.getName()) + .redirectErrorStream(true); + builder.environment().put("D1_MYSQL_URL", required("mysql.url")); + builder.environment().put("D1_MYSQL_USER", required("mysql.user")); + builder.environment().put("D1_MYSQL_PASSWORD", + System.getProperty("mysql.password", "")); + builder.environment().put("D1_PROCESS_ACTION", action); + return builder.start(); + } + + private static void assertProcess(Process process, int exitCode, + String marker) throws Exception { + boolean finished = process.waitFor(30, TimeUnit.SECONDS); + if (!finished) { + process.destroyForcibly(); + process.waitFor(5, TimeUnit.SECONDS); + } + String output = new String(process.getInputStream().readAllBytes(), + StandardCharsets.UTF_8); + assertThat(finished).as("Store process timed out:\n%s", output) + .isTrue(); + assertThat(process.exitValue()).as("Store process failed:\n%s", + output).isEqualTo(exitCode); + if (marker != null) { + assertThat(output).contains(marker); + } + } + + private static boolean isWindows() { + return System.getProperty("os.name", "").toLowerCase() + .contains("win"); + } + + private static DriverManagerDataSource dataSource() { + return new DriverManagerDataSource(required("mysql.url"), + required("mysql.user"), + System.getProperty("mysql.password", "")); + } + + private static T inTransaction(TransactionTemplate transactions, + Supplier operation) { + return transactions.execute(status -> operation.get()); + } + + private static String sha256(String value) { + try { + return HexFormat.of().formatHex(MessageDigest + .getInstance("SHA-256").digest( + value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException error) { + throw new IllegalStateException(error); + } + } + + private static String required(String name) { + String value = System.getProperty(name); + if (value == null || value.isBlank()) { + throw new IllegalStateException(name + " is required"); + } + return value; + } +} diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentServerIntegrationTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentServerIntegrationTest.java new file mode 100644 index 00000000000..891cd826a45 --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentServerIntegrationTest.java @@ -0,0 +1,1418 @@ +package com.alibaba.qwen.code.managedagent; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.awaitility.Awaitility.await; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.alibaba.qwen.code.daemon.HarnessRuntimeRecovery; +import com.alibaba.qwen.code.managedagent.api.ManagedSessionStoreController; +import com.alibaba.qwen.code.managedagent.api.TenantContextFilter; +import com.alibaba.qwen.code.managedagent.harness.HarnessConnector; +import com.alibaba.qwen.code.managedagent.service.SessionEventHub; +import com.alibaba.qwen.code.managedagent.store.ManagedAgentStore; +import com.alibaba.qwen.code.managedagent.store.StoreModels.Admission; +import com.alibaba.qwen.code.managedagent.store.StoreModels.DispatchTarget; +import com.alibaba.qwen.code.managedagent.store.StoreModels.HarnessEvent; +import com.alibaba.qwen.code.managedagent.store.StoreModels.ProjectedEvent; +import com.alibaba.qwen.code.managedagent.store.StoreModels.SessionMutationKind; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.Duration; +import java.util.ArrayDeque; +import java.util.List; +import java.util.Map; +import java.util.Queue; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +@SpringBootTest(properties = { + "spring.datasource.url=jdbc:h2:mem:managed-agent;MODE=MySQL;" + + "DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=", + "qwen.managed-agent.harness.enabled=false", + "qwen.managed-agent.dispatch.scan-delay=50ms", + "qwen.managed-agent.events.poll-interval=10ms", + "qwen.managed-agent.events.materialize-interval=10ms" +}) +@AutoConfigureMockMvc +@Import(ManagedAgentServerIntegrationTest.FixtureConfiguration.class) +class ManagedAgentServerIntegrationTest { + @Autowired + private MockMvc mvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private ApplicationContext applicationContext; + + @Autowired + private FixtureHarness harness; + + @Autowired + private ManagedAgentStore store; + + @Autowired + private JdbcTemplate jdbc; + + @Autowired + private SessionEventHub eventHub; + + @Autowired + private PlatformTransactionManager transactionManager; + + @Test + void allowsRepeatingLifecycleOperationsWithNewCommandKeys() { + String tenant = "tenant-repeat-" + UUID.randomUUID(); + Admission session = store.insertSessionCommand(tenant, + "CREATE_SESSION", "create", "digest-create", "qwen-code", + null, List.of(), null); + for (int cycle = 0; cycle < 2; cycle++) { + for (SessionMutationKind kind : List.of( + SessionMutationKind.ARCHIVE, + SessionMutationKind.UNARCHIVE)) { + String operation = kind.name() + "_SESSION"; + String key = operation + cycle; + String digest = "same-content-" + operation; + store.beginSessionMutation(tenant, operation, key, digest, + session.sessionId(), kind); + store.completeSessionMutation(tenant, operation, key, + session.sessionId(), kind, null, null); + assertThat(store.beginSessionMutation(tenant, operation, + key, digest, session.sessionId(), kind).replayed()) + .isTrue(); + } + } + assertThat(store.findEvents(tenant, session.sessionId(), 0, 100)) + .filteredOn(event -> "session.archived".equals(event.type())) + .hasSize(2); + } + + @Test + void requiresTenantHeader() throws Exception { + mvc.perform(get("/v1/agents/sessions")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error.code").value("invalid_tenant")); + + mvc.perform(post("/v1/agents/sessions") + .header(TenantContextFilter.HEADER, "tenant-header") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"agent_id\":\"qwen-code\",\"input\":[]}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error.code") + .value("invalid_request")); + + assertThat(applicationContext.getBeansOfType( + ManagedSessionStoreController.class)).isEmpty(); + } + + @Test + void missingSessionIdReturnsNotFound() throws Exception { + mvc.perform(get("/v1/agents/sessions/") + .header(TenantContextFilter.HEADER, "tenant-empty-id")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.error.code").value("not_found")); + } + + @Test + void createsReplaysAndStreamsATenantScopedTurn() throws Exception { + String tenant = "tenant-create"; + String body = """ + {"agent_id":"qwen-code","metadata":{"title":"demo"}, + "input":[{"type":"text","text":"hello"}]} + """; + MvcResult first = mvc.perform(post("/v1/agents/sessions") + .header(TenantContextFilter.HEADER, tenant) + .header("Authorization", "Bearer ignored-by-design") + .header("Idempotency-Key", "create-key") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isAccepted()) + .andExpect(header().string("X-Qwen-Idempotent-Replay", + "false")) + .andExpect(jsonPath("$.object").value("agent.session")) + .andExpect(jsonPath("$.metadata.title").value("demo")) + .andReturn(); + String sessionId = objectMapper.readTree( + first.getResponse().getContentAsString()).get("id").asText(); + assertThat(UUID.fromString(sessionId).toString()).isEqualTo(sessionId); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM" + + " INFORMATION_SCHEMA.COLUMNS WHERE" + + " LOWER(TABLE_NAME) = 'managed_agent_session' AND" + + " LOWER(COLUMN_NAME) = 'harness_session_id'", + Integer.class)).isZero(); + + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + MvcResult events = events(tenant, sessionId); + JsonNode data = objectMapper.readTree( + events.getResponse().getContentAsString()).get("data"); + assertThat(data).extracting(node -> node.get("type").asText()) + .contains("item.output_text.delta", "turn.completed"); + assertThat(data.get(data.size() - 1).get("terminal").asBoolean()) + .isTrue(); + }); + assertThat(harness.hasSession(sessionId)).isTrue(); + + JsonNode allEvents = objectMapper.readTree(events(tenant, sessionId) + .getResponse().getContentAsString()).get("data"); + long firstSequence = allEvents.get(0).get("sequence").asLong(); + MvcResult resumed = mvc.perform(get( + "/v1/agents/sessions/{id}/events", sessionId) + .header(TenantContextFilter.HEADER, tenant) + .header("Last-Event-ID", firstSequence) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()).andReturn(); + assertThat(objectMapper.readTree( + resumed.getResponse().getContentAsString()) + .get("data")).allMatch(event -> + event.get("sequence").asLong() > firstSequence); + + int submitsBeforeReplay = harness.submitCount(); + MvcResult replay = mvc.perform(post("/v1/agents/sessions") + .header(TenantContextFilter.HEADER, tenant) + .header("Idempotency-Key", "create-key") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isAccepted()) + .andExpect(header().string("X-Qwen-Idempotent-Replay", + "true")) + .andReturn(); + assertThat(objectMapper.readTree( + replay.getResponse().getContentAsString()).get("id").asText()) + .isEqualTo(sessionId); + assertThat(harness.submitCount()).isEqualTo(submitsBeforeReplay); + + mvc.perform(post("/v1/agents/sessions") + .header(TenantContextFilter.HEADER, tenant) + .header("Idempotency-Key", "create-key") + .contentType(MediaType.APPLICATION_JSON) + .content(body.replace("hello", "changed"))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error.code") + .value("idempotency_conflict")); + + mvc.perform(get("/v1/agents/sessions/{id}", sessionId) + .header(TenantContextFilter.HEADER, "tenant-other")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.error.code") + .value("session_not_found")); + } + + @Test + void blocksUnknownRuntimeRecoveryWithoutSubmittingTheTurn() + throws Exception { + String tenant = "tenant-runtime-recovery"; + HarnessRuntimeRecovery recovery = mock(HarnessRuntimeRecovery.class); + when(recovery.hasUnknownOutcome()).thenReturn(true); + harness.returnRuntimeRecovery(recovery); + int submissions = harness.submitCount(); + + MvcResult created = mvc.perform(post("/v1/agents/sessions") + .header(TenantContextFilter.HEADER, tenant) + .header("Idempotency-Key", "runtime-recovery-create") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"agent_id\":\"qwen-code\"," + + "\"input\":[{\"type\":\"text\"," + + "\"text\":\"do not replay\"}]}")) + .andExpect(status().isAccepted()).andReturn(); + String sessionId = objectMapper.readTree( + created.getResponse().getContentAsString()).get("id") + .asText(); + + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> + mvc.perform(get("/v1/agents/sessions/{id}/events", + sessionId) + .header(TenantContextFilter.HEADER, tenant) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[?(@.type ==" + + " 'turn.failed')].data.code") + .value("managed_runtime_recovery_blocked"))); + assertThat(harness.submitCount()).isEqualTo(submissions); + } + + @Test + void managesThePublicSessionLifecycle() throws Exception { + String tenant = "tenant-lifecycle-" + UUID.randomUUID(); + MvcResult created = mvc.perform(post("/v1/agents/sessions") + .header(TenantContextFilter.HEADER, tenant) + .header("Idempotency-Key", "lifecycle-create") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"agent_id\":\"qwen-code\",\"input\":[]}")) + .andExpect(status().isAccepted()).andReturn(); + String sessionId = objectMapper.readTree( + created.getResponse().getContentAsString()).get("id").asText(); + + int renames = harness.renameCount(); + mvc.perform(patch("/v1/agents/sessions/{id}", sessionId) + .header(TenantContextFilter.HEADER, tenant + "-other") + .header("Idempotency-Key", "lifecycle-other-tenant") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"not allowed\"}")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.error.code") + .value("session_not_found")); + assertThat(harness.renameCount()).isEqualTo(renames); + + mvc.perform(patch("/v1/agents/sessions/{id}", sessionId) + .header(TenantContextFilter.HEADER, tenant) + .header("Idempotency-Key", "lifecycle-rename") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"managed title\"}")) + .andExpect(status().isOk()) + .andExpect(header().string("X-Qwen-Idempotent-Replay", + "false")) + .andExpect(jsonPath("$.metadata.title") + .value("managed title")); + assertThat(harness.renameCount()).isEqualTo(renames + 1); + assertThat(harness.title(sessionId)).isEqualTo("managed title"); + assertThat(store.requireSession(tenant, sessionId).harnessBootId()) + .isNotNull(); + + mvc.perform(patch("/v1/agents/sessions/{id}", sessionId) + .header(TenantContextFilter.HEADER, tenant) + .header("Idempotency-Key", "lifecycle-rename") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"managed title\"}")) + .andExpect(status().isOk()) + .andExpect(header().string("X-Qwen-Idempotent-Replay", + "true")); + assertThat(harness.renameCount()).isEqualTo(renames + 1); + + mvc.perform(patch("/v1/agents/sessions/{id}", sessionId) + .header(TenantContextFilter.HEADER, tenant) + .header("Idempotency-Key", "lifecycle-rename") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"different\"}")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error.code") + .value("idempotency_conflict")); + + JsonNode events = objectMapper.readTree(events(tenant, sessionId) + .getResponse().getContentAsString()).get("data"); + assertThat(events).filteredOn(event -> "session.updated".equals( + event.get("type").asText())) + .hasSize(1); + + int closes = harness.closeCount(); + harness.setAvailable(false); + try { + mvc.perform(post("/v1/agents/sessions/{id}/archive", sessionId) + .header(TenantContextFilter.HEADER, tenant) + .header("Idempotency-Key", "lifecycle-archive")) + .andExpect(status().isServiceUnavailable()) + .andExpect(jsonPath("$.error.code") + .value("hosted_harness_unavailable")); + } finally { + harness.setAvailable(true); + } + mvc.perform(post("/v1/agents/sessions/{id}/archive", sessionId) + .header(TenantContextFilter.HEADER, tenant) + .header("Idempotency-Key", "lifecycle-archive")) + .andExpect(status().isOk()) + .andExpect(header().string("X-Qwen-Idempotent-Replay", + "true")) + .andExpect(jsonPath("$.status").value("archived")); + assertThat(harness.closeCount()).isEqualTo(closes + 1); + + mvc.perform(post("/v1/agents/sessions/{id}/events", sessionId) + .header(TenantContextFilter.HEADER, tenant) + .header("Idempotency-Key", "archived-turn") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"type\":\"agent.session.input.message\"," + + "\"input\":[{\"type\":\"text\"," + + "\"text\":\"blocked\"}]}")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error.code") + .value("session_not_active")); + + mvc.perform(post("/v1/agents/sessions/{id}/unarchive", sessionId) + .header(TenantContextFilter.HEADER, tenant) + .header("Idempotency-Key", "lifecycle-unarchive")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("active")); + + mvc.perform(delete("/v1/agents/sessions/{id}", sessionId) + .header(TenantContextFilter.HEADER, tenant) + .header("Idempotency-Key", "lifecycle-delete")) + .andExpect(status().isOk()) + .andExpect(header().string("X-Qwen-Idempotent-Replay", + "false")) + .andExpect(jsonPath("$.object") + .value("agent.session.deleted")) + .andExpect(jsonPath("$.deleted").value(true)); + + mvc.perform(get("/v1/agents/sessions/{id}", sessionId) + .header(TenantContextFilter.HEADER, tenant)) + .andExpect(status().isNotFound()); + mvc.perform(get("/v1/agents/sessions") + .header(TenantContextFilter.HEADER, tenant)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[?(@.id == '%s')]" + .formatted(sessionId)).isEmpty()); + mvc.perform(delete("/v1/agents/sessions/{id}", sessionId) + .header(TenantContextFilter.HEADER, tenant) + .header("Idempotency-Key", "lifecycle-delete")) + .andExpect(status().isOk()) + .andExpect(header().string("X-Qwen-Idempotent-Replay", + "true")); + } + + @Test + void rejectsArchivalUntilTheActiveTurnSettles() throws Exception { + String tenant = "tenant-archive-active-" + UUID.randomUUID(); + MvcResult created = mvc.perform(post("/v1/agents/sessions") + .header(TenantContextFilter.HEADER, tenant) + .header("Idempotency-Key", "archive-active-create") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"agent_id\":\"qwen-code\",\"input\":[" + + "{\"type\":\"text\",\"text\":\"hold\"}]}")) + .andExpect(status().isAccepted()).andReturn(); + String sessionId = objectMapper.readTree( + created.getResponse().getContentAsString()).get("id").asText(); + await().atMost(Duration.ofSeconds(2)).untilAsserted(() -> + assertThat(harness.hasHeldTurn()).isTrue()); + int closes = harness.closeCount(); + + try { + mvc.perform(post("/v1/agents/sessions/{id}/archive", sessionId) + .header(TenantContextFilter.HEADER, tenant) + .header("Idempotency-Key", "archive-active")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error.code") + .value("turn_active")); + assertThat(harness.closeCount()).isEqualTo(closes); + } finally { + harness.releaseHeldTurns(); + } + + await().atMost(Duration.ofSeconds(2)).untilAsserted(() -> + assertThat(store.findActiveTurn(tenant, sessionId)) + .isEmpty()); + mvc.perform(post("/v1/agents/sessions/{id}/archive", sessionId) + .header(TenantContextFilter.HEADER, tenant) + .header("Idempotency-Key", "archive-active")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("archived")); + assertThat(store.requireSession(tenant, sessionId).harnessBootId()) + .isNotNull(); + } + + @Test + void deletesAnArchivedSessionWhileTheHarnessIsUnavailable() + throws Exception { + String tenant = "tenant-archived-delete-" + UUID.randomUUID(); + MvcResult created = mvc.perform(post("/v1/agents/sessions") + .header(TenantContextFilter.HEADER, tenant) + .header("Idempotency-Key", "archived-delete-create") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"agent_id\":\"qwen-code\",\"input\":[" + + "{\"type\":\"text\",\"text\":\"hello\"}]}")) + .andExpect(status().isAccepted()).andReturn(); + String sessionId = objectMapper.readTree( + created.getResponse().getContentAsString()).get("id").asText(); + await().atMost(Duration.ofSeconds(2)).untilAsserted(() -> { + assertThat(store.findActiveTurn(tenant, sessionId)).isEmpty(); + assertThat(store.requireSession(tenant, sessionId).harnessBootId()) + .isNotNull(); + }); + mvc.perform(post("/v1/agents/sessions/{id}/archive", sessionId) + .header(TenantContextFilter.HEADER, tenant) + .header("Idempotency-Key", "archived-delete-archive")) + .andExpect(status().isOk()); + + harness.setAvailable(false); + try { + mvc.perform(delete("/v1/agents/sessions/{id}", sessionId) + .header(TenantContextFilter.HEADER, tenant) + .header("Idempotency-Key", + "archived-delete-delete")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.deleted").value(true)); + } finally { + harness.setAvailable(true); + } + } + + @Test + void retriesAPendingRenameWithTheSameIdempotencyKey() throws Exception { + String tenant = "tenant-rename-retry-" + UUID.randomUUID(); + MvcResult created = mvc.perform(post("/v1/agents/sessions") + .header(TenantContextFilter.HEADER, tenant) + .header("Idempotency-Key", "rename-retry-create") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"agent_id\":\"qwen-code\",\"input\":[]}")) + .andExpect(status().isAccepted()).andReturn(); + String sessionId = objectMapper.readTree( + created.getResponse().getContentAsString()).get("id").asText(); + harness.failNextRename(); + + mvc.perform(patch("/v1/agents/sessions/{id}", sessionId) + .header(TenantContextFilter.HEADER, tenant) + .header("Idempotency-Key", "rename-retry") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"retry title\"}")) + .andExpect(status().isServiceUnavailable()) + .andExpect(jsonPath("$.error.code") + .value("hosted_harness_unavailable")); + + mvc.perform(patch("/v1/agents/sessions/{id}", sessionId) + .header(TenantContextFilter.HEADER, tenant) + .header("Idempotency-Key", "another-rename") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"blocked\"}")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error.code") + .value("session_operation_active")); + + mvc.perform(patch("/v1/agents/sessions/{id}", sessionId) + .header(TenantContextFilter.HEADER, tenant) + .header("Idempotency-Key", "rename-retry") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"title\":\"retry title\"}")) + .andExpect(status().isOk()) + .andExpect(header().string("X-Qwen-Idempotent-Replay", + "true")) + .andExpect(jsonPath("$.metadata.title") + .value("retry title")); + + JsonNode events = objectMapper.readTree(events(tenant, sessionId) + .getResponse().getContentAsString()).get("data"); + assertThat(events).filteredOn(event -> "session.updated".equals( + event.get("type").asText())) + .hasSize(1); + } + + @Test + void webShellAdapterUsesTheSameDurableCore() throws Exception { + String tenant = "tenant-web"; + MvcResult created = mvc.perform(post( + "/api/agent/web-shell/v1/sessions/create") + .header(TenantContextFilter.HEADER, tenant) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"requestId":"trace-1", + "idempotencyKey":"web-create", + "agentId":"qwen-code", + "title":"web", + "metadata":{"clientId":"browser-1"}, + "input":[]} + """)) + .andExpect(status().isAccepted()) + .andExpect(jsonPath("$.status").value("accepted")) + .andReturn(); + String sessionId = objectMapper.readTree( + created.getResponse().getContentAsString()) + .get("sessionId").asText(); + + MvcResult submitted = mvc.perform(post( + "/api/agent/web-shell/v1/turns/submit") + .header(TenantContextFilter.HEADER, tenant) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"requestId":"trace-2", + "idempotencyKey":"web-turn", + "sessionId":"%s", + "input":[{"type":"text","text":"hi"}]} + """.formatted(sessionId))) + .andExpect(status().isAccepted()) + .andExpect(jsonPath("$.sessionId").value(sessionId)) + .andExpect(jsonPath("$.turnId").isNotEmpty()) + .andReturn(); + String turnId = objectMapper.readTree( + submitted.getResponse().getContentAsString()) + .get("turnId").asText(); + + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> + mvc.perform(post( + "/api/agent/web-shell/v1/transcript/query") + .header(TenantContextFilter.HEADER, tenant) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"sessionId\":\"" + sessionId + + "\",\"limit\":100}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.events[?(@.type ==" + + " 'turn.completed')]").isNotEmpty())); + + store.appendPublicEventIfAbsent(tenant, sessionId, turnId, + "environment.failed", Map.of( + "code", "runtime_warm_failed", + "environmentId", "local-runtime"), false, + "test:environment:failed"); + mvc.perform(post("/api/agent/web-shell/v1/sessions/get") + .header(TenantContextFilter.HEADER, tenant) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"sessionId\":\"" + sessionId + "\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.activeTurn.status") + .value("completed")) + .andExpect(jsonPath("$.environment.state").value("failed")) + .andExpect(jsonPath("$.environment.environmentId") + .value("local-runtime")) + .andExpect(jsonPath("$.environment.errorCode") + .value("runtime_warm_failed")); + + await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + MvcResult transcript = mvc.perform(post( + "/api/agent/web-shell/v1/transcript/query") + .header(TenantContextFilter.HEADER, tenant) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"sessionId\":\"" + sessionId + + "\",\"limit\":2}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.hasMore").value(false)) + .andReturn(); + JsonNode body = objectMapper.readTree( + transcript.getResponse().getContentAsString()); + assertThat(body.get("coveredSequence").asLong()) + .isEqualTo(body.get("lastSequence").asLong()); + assertThat(body.get("items")).hasSize(2); + assertThat(body.get("items").get(0).get("content").get(0) + .get("text").asText()).isEqualTo("hi"); + assertThat(body.get("items").get(1).get("content").get(0) + .get("text").asText()).isEqualTo("hello"); + }); + + MvcResult firstItems = mvc.perform(get( + "/v1/agents/sessions/{id}/items", sessionId) + .param("limit", "1") + .header(TenantContextFilter.HEADER, tenant)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$.has_more").value(true)) + .andExpect(jsonPath("$.next_cursor").isNotEmpty()) + .andExpect(jsonPath("$.snapshot_through_sequence") + .isNumber()).andReturn(); + String after = objectMapper.readTree(firstItems.getResponse() + .getContentAsString()).get("next_cursor").asText(); + mvc.perform(get("/v1/agents/sessions/{id}/items", sessionId) + .param("after", after).param("limit", "1") + .header(TenantContextFilter.HEADER, tenant)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$.has_more").value(false)); + } + + @Test + void replaysASubmitWhileTheOriginalTurnIsStillActive() throws Exception { + String tenant = "tenant-active-replay"; + MvcResult created = mvc.perform(post( + "/api/agent/web-shell/v1/sessions/create") + .header(TenantContextFilter.HEADER, tenant) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"idempotencyKey":"active-create", + "agentId":"qwen-code","input":[]} + """)) + .andExpect(status().isAccepted()).andReturn(); + String sessionId = objectMapper.readTree( + created.getResponse().getContentAsString()) + .get("sessionId").asText(); + String submit = """ + {"idempotencyKey":"active-turn","sessionId":"%s", + "input":[{"type":"text","text":"hold"}]} + """.formatted(sessionId); + + MvcResult first = mvc.perform(post( + "/api/agent/web-shell/v1/turns/submit") + .header(TenantContextFilter.HEADER, tenant) + .contentType(MediaType.APPLICATION_JSON) + .content(submit)) + .andExpect(status().isAccepted()).andReturn(); + String turnId = objectMapper.readTree( + first.getResponse().getContentAsString()) + .get("turnId").asText(); + await().atMost(Duration.ofSeconds(2)).untilAsserted(() -> + assertThat(harness.hasHeldTurn()).isTrue()); + int submitsBeforeReplay = harness.submitCount(); + + mvc.perform(post("/api/agent/web-shell/v1/turns/submit") + .header(TenantContextFilter.HEADER, tenant) + .contentType(MediaType.APPLICATION_JSON) + .content(submit)) + .andExpect(status().isAccepted()) + .andExpect(jsonPath("$.turnId").value(turnId)) + .andExpect(jsonPath("$.replayed").value(true)); + assertThat(harness.submitCount()).isEqualTo(submitsBeforeReplay); + + String cancel = """ + {"idempotencyKey":"active-cancel","sessionId":"%s", + "turnId":"%s"} + """.formatted(sessionId, turnId); + int cancellations = harness.cancelCount(); + mvc.perform(post("/api/agent/web-shell/v1/turns/cancel") + .header(TenantContextFilter.HEADER, tenant) + .contentType(MediaType.APPLICATION_JSON) + .content(cancel)) + .andExpect(status().isAccepted()) + .andExpect(jsonPath("$.replayed").value(false)); + await().atMost(Duration.ofSeconds(2)).untilAsserted(() -> + assertThat(harness.cancelCount()) + .isEqualTo(cancellations + 1)); + mvc.perform(post("/api/agent/web-shell/v1/turns/cancel") + .header(TenantContextFilter.HEADER, tenant) + .contentType(MediaType.APPLICATION_JSON) + .content(cancel)) + .andExpect(status().isAccepted()) + .andExpect(jsonPath("$.replayed").value(true)); + assertThat(harness.cancelCount()).isEqualTo(cancellations + 1); + mvc.perform(post("/api/agent/web-shell/v1/turns/cancel") + .header(TenantContextFilter.HEADER, tenant) + .contentType(MediaType.APPLICATION_JSON) + .content(cancel.replace("active-cancel", + "active-cancel-other"))) + .andExpect(status().isAccepted()) + .andExpect(jsonPath("$.replayed").value(false)); + assertThat(harness.cancelCount()).isEqualTo(cancellations + 1); + harness.releaseHeldTurns(); + await().atMost(Duration.ofSeconds(2)).untilAsserted(() -> + mvc.perform(post( + "/api/agent/web-shell/v1/transcript/query") + .header(TenantContextFilter.HEADER, tenant) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"sessionId\":\"" + sessionId + + "\",\"limit\":100}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.events[?(@.type ==" + + " 'turn.cancelled')]").isNotEmpty())); + } + + @Test + void concurrentSameKeySubmitsCreateOneTurn() throws Exception { + String tenant = "tenant-concurrent-replay"; + MvcResult created = mvc.perform(post( + "/api/agent/web-shell/v1/sessions/create") + .header(TenantContextFilter.HEADER, tenant) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"idempotencyKey":"concurrent-create", + "agentId":"qwen-code","input":[]} + """)) + .andExpect(status().isAccepted()).andReturn(); + String sessionId = objectMapper.readTree( + created.getResponse().getContentAsString()) + .get("sessionId").asText(); + List> input = List.of(Map.of( + "type", "text", "text", "race")); + CyclicBarrier gate = new CyclicBarrier(2); + Callable submit = () -> { + gate.await(); + return store.insertTurnCommand(tenant, "SUBMIT_TURN", + "concurrent-turn", "sha256:" + "a".repeat(64), + sessionId, input, "sha256:" + "b".repeat(64)); + }; + + try (ExecutorService executor = Executors.newFixedThreadPool(2)) { + Future left = executor.submit(submit); + Future right = executor.submit(submit); + Admission first = left.get(5, TimeUnit.SECONDS); + Admission second = right.get(5, TimeUnit.SECONDS); + + assertThat(first.turnId()).isEqualTo(second.turnId()); + assertThat(List.of(first.replayed(), second.replayed())) + .containsExactlyInAnyOrder(false, true); + } + } + + @Test + void commitsHarnessEventsAsOneReplayableBatch() throws Exception { + String tenant = "tenant-batch-" + UUID.randomUUID(); + Admission session = store.insertSessionCommand(tenant, + "CREATE_SESSION", "batch-create", + "sha256:" + "a".repeat(64), "qwen-code", null, + List.of(), null); + List> input = List.of(Map.of( + "type", "text", "text", "batch")); + Admission turn = store.insertTurnCommand(tenant, "SUBMIT_TURN", + "batch-turn", "sha256:" + "b".repeat(64), + session.sessionId(), input, "sha256:" + "c".repeat(64)); + String owner = "batch-owner"; + assertThat(store.claimTurn(tenant, session.sessionId(), + turn.turnId(), owner, Duration.ofMinutes(1))).isPresent(); + store.recordAdmission(tenant, session.sessionId(), turn.turnId(), + owner, "batch-epoch", 0); + long before = store.requireSession(tenant, session.sessionId()) + .lastSequence(); + ProjectedEvent first = new ProjectedEvent( + "item.output_text.delta", Map.of("text", "one"), false, + null, null, null); + ProjectedEvent second = new ProjectedEvent( + "item.output_text.delta", Map.of("text", "two"), false, + null, null, null); + List batch = List.of( + new HarnessEvent(1, "boot:batch-epoch:1", first), + new HarnessEvent(2, "boot:batch-epoch:2", null), + new HarnessEvent(3, "boot:batch-epoch:3", second)); + + try (SessionEventHub.Subscription subscription = eventHub.subscribe( + tenant, session.sessionId())) { + store.recordHarnessEvents(tenant, session.sessionId(), + turn.turnId(), owner, "batch-epoch", batch); + SessionEventHub.Delivery delivery = subscription.await(before, + Duration.ofSeconds(1)); + assertThat(delivery.overflowed()).isFalse(); + assertThat(delivery.events()).extracting(event -> event.sequence()) + .containsExactly(before + 1, before + 2); + } + + store.recordHarnessEvents(tenant, session.sessionId(), turn.turnId(), + owner, "batch-epoch", batch); + assertThat(store.findEvents(tenant, session.sessionId(), before, 100)) + .extracting(event -> event.data().get("text")) + .containsExactly("one", "two"); + assertThat(store.findTurn(tenant, session.sessionId(), turn.turnId())) + .get().extracting(record -> record.harnessLastEventId()) + .isEqualTo(3L); + + ProjectedEvent terminal = new ProjectedEvent("turn.completed", + Map.of(), true, "COMPLETED", null, null); + store.recordHarnessEvents(tenant, session.sessionId(), turn.turnId(), + owner, "batch-epoch", List.of(new HarnessEvent(4, + "boot:batch-epoch:4", terminal))); + assertThat(store.findTurn(tenant, session.sessionId(), turn.turnId())) + .get().extracting(record -> record.status()) + .isEqualTo("COMPLETED"); + assertThat(store.findEvents(tenant, session.sessionId(), before, 100)) + .extracting(event -> event.sequence()) + .containsExactly(before + 1, before + 2, before + 3); + store.materializeNextBatch(tenant, session.sessionId(), 200); + assertThat(store.findSnapshot(tenant, session.sessionId())) + .get().satisfies(snapshot -> { + assertThat(snapshot.coveredSequence()) + .isEqualTo(before + 3); + assertThat(snapshot.items()).filteredOn(item -> + "assistant".equals(item.role())) + .singleElement().satisfies(item -> + assertThat(item.content()) + .singleElement() + .extracting(part -> part.text()) + .isEqualTo("onetwo")); + }); + assertThat(store.materializeNextBatch(tenant, session.sessionId(), + 200).advanced()).isFalse(); + } + + @Test + void preservesTextOrderAcrossToolsAndReasoningInSnapshots() { + String tenant = "tenant-order-" + UUID.randomUUID(); + Admission session = store.insertSessionCommand(tenant, + "CREATE_SESSION", "order-create", "digest-create", + "qwen-code", null, List.of(), null); + String turnId = "turn-order"; + store.appendPublicEventIfAbsent(tenant, session.sessionId(), turnId, + "item.output_text.delta", Map.of("text", "before"), + false, "before"); + store.appendPublicEventIfAbsent(tenant, session.sessionId(), turnId, + "item.tool_call.updated", Map.of("toolCallId", "tool-1"), + false, "tool"); + store.appendPublicEventIfAbsent(tenant, session.sessionId(), turnId, + "item.output_text.delta", Map.of("text", "after"), + false, "after"); + store.appendPublicEventIfAbsent(tenant, session.sessionId(), turnId, + "item.reasoning.delta", Map.of("text", "thought"), + false, "thought"); + store.appendPublicEventIfAbsent(tenant, session.sessionId(), turnId, + "item.output_text.delta", Map.of("text", "final"), + false, "final"); + store.materializeNextBatch(tenant, session.sessionId(), 100); + assertThat(store.findSnapshot(tenant, session.sessionId())) + .get().satisfies(snapshot -> assertThat(snapshot.items()) + .filteredOn(item -> "message".equals(item.type())) + .singleElement().satisfies(item -> + assertThat(item.content()) + .extracting(part -> part.text()) + .containsExactly("before", "after", + "thought", "final"))); + } + + @Test + void ignoresLateEnvironmentResultFromAnOlderTurn() { + String tenant = "tenant-environment-order-" + UUID.randomUUID(); + Admission session = store.insertSessionCommand(tenant, + "CREATE_SESSION", "environment-create", + "sha256:" + "1".repeat(64), "qwen-code", null, + List.of(), null); + Admission first = store.insertTurnCommand(tenant, "SUBMIT_TURN", + "environment-turn-1", "sha256:" + "2".repeat(64), + session.sessionId(), List.of(), + "sha256:" + "3".repeat(64)); + String owner = "environment-owner"; + assertThat(store.claimTurn(tenant, session.sessionId(), first.turnId(), + owner, Duration.ofMinutes(1))).isPresent(); + store.cancelBeforeAdmission(tenant, session.sessionId(), + first.turnId(), owner); + Admission second = store.insertTurnCommand(tenant, "SUBMIT_TURN", + "environment-turn-2", "sha256:" + "4".repeat(64), + session.sessionId(), List.of(), + "sha256:" + "5".repeat(64)); + + store.appendPublicEventIfAbsent(tenant, session.sessionId(), + second.turnId(), "environment.ready", Map.of(), false, + "environment:second:ready"); + store.appendPublicEventIfAbsent(tenant, session.sessionId(), + first.turnId(), "environment.failed", + Map.of("code", "runtime_warm_failed"), false, + "environment:first:failed"); + + assertThat(store.findLatestEnvironmentEvent(tenant, + session.sessionId())).get().satisfies(event -> { + assertThat(event.turnId()).isEqualTo(second.turnId()); + assertThat(event.type()).isEqualTo("environment.ready"); + }); + } + + @Test + void persistsRetryBackoffAcrossClaims() { + String tenant = "tenant-retry-backoff-" + UUID.randomUUID(); + Admission session = store.insertSessionCommand(tenant, + "CREATE_SESSION", "retry-create", + "sha256:" + "1".repeat(64), "qwen-code", null, + List.of(), null); + Admission turn = store.insertTurnCommand(tenant, "SUBMIT_TURN", + "retry-turn", "sha256:" + "2".repeat(64), + session.sessionId(), List.of(), + "sha256:" + "3".repeat(64)); + String firstOwner = "retry-owner-1"; + assertThat(store.claimTurn(tenant, session.sessionId(), turn.turnId(), + firstOwner, Duration.ofMinutes(1))).isPresent(); + long retryAfter = System.currentTimeMillis() + 60_000; + + store.scheduleTurnRetry(tenant, session.sessionId(), turn.turnId(), + firstOwner, retryAfter); + + assertThat(store.findTurn(tenant, session.sessionId(), turn.turnId())) + .get().satisfies(record -> { + assertThat(record.retryCount()).isEqualTo(1); + assertThat(record.retryAfter()).isEqualTo(retryAfter); + assertThat(record.dispatchOwner()).isNull(); + }); + DispatchTarget target = new DispatchTarget(tenant, + session.sessionId(), turn.turnId()); + assertThat(store.findDispatchable(retryAfter - 1, 100)) + .doesNotContain(target); + assertThat(store.claimTurn(tenant, session.sessionId(), turn.turnId(), + "retry-owner-2", Duration.ofMinutes(1))).isEmpty(); + assertThat(store.findDispatchable(retryAfter, 100)).contains(target); + } + + @Test + void transfersHarnessGenerationOnlyBeforeAdmissionUnderDispatchLease() { + String tenant = "tenant-harness-takeover-" + UUID.randomUUID(); + Admission session = store.insertSessionCommand(tenant, + "CREATE_SESSION", "takeover-create", + "sha256:" + "d".repeat(64), "qwen-code", null, + List.of(), null); + Admission turn = store.insertTurnCommand(tenant, "SUBMIT_TURN", + "takeover-turn", "sha256:" + "e".repeat(64), + session.sessionId(), List.of(), + "sha256:" + "f".repeat(64)); + String owner = "takeover-owner"; + assertThat(store.claimTurn(tenant, session.sessionId(), + turn.turnId(), owner, Duration.ofMinutes(1))).isPresent(); + + assertThat(store.bindHarness(tenant, session.sessionId(), + turn.turnId(), owner, "boot-a")).isTrue(); + assertThat(store.bindHarness(tenant, session.sessionId(), + turn.turnId(), owner, "boot-b")).isTrue(); + assertThat(store.requireSession(tenant, session.sessionId()) + .harnessBootId()).isEqualTo("boot-b"); + + store.markSubmissionAttempted(tenant, session.sessionId(), + turn.turnId(), owner); + assertThat(store.bindHarness(tenant, session.sessionId(), + turn.turnId(), owner, "boot-c")).isFalse(); + assertThat(store.requireSession(tenant, session.sessionId()) + .harnessBootId()).isEqualTo("boot-b"); + + store.releaseTurnLease(tenant, session.sessionId(), turn.turnId(), + owner); + assertThat(store.bindHarness(tenant, session.sessionId(), + turn.turnId(), owner, "boot-d")).isFalse(); + assertThat(store.requireSession(tenant, session.sessionId()) + .harnessBootId()).isEqualTo("boot-b"); + } + + @Test + void recoversAdmittedHarnessGenerationAndEventEpochUnderDispatchLease() { + String tenant = "tenant-harness-recovery-" + UUID.randomUUID(); + Admission session = store.insertSessionCommand(tenant, + "CREATE_SESSION", "recovery-create", + "sha256:" + "1".repeat(64), "qwen-code", null, + List.of(), null); + Admission turn = store.insertTurnCommand(tenant, "SUBMIT_TURN", + "recovery-turn", "sha256:" + "2".repeat(64), + session.sessionId(), List.of(Map.of( + "type", "text", "text", "recover")), + "sha256:" + "3".repeat(64)); + String owner = "recovery-owner"; + assertThat(store.claimTurn(tenant, session.sessionId(), + turn.turnId(), owner, Duration.ofMinutes(1))).isPresent(); + assertThat(store.bindHarness(tenant, session.sessionId(), + turn.turnId(), owner, "boot-old")).isTrue(); + store.markSubmissionAttempted(tenant, session.sessionId(), + turn.turnId(), owner); + store.recordAdmission(tenant, session.sessionId(), turn.turnId(), + owner, "epoch-old", 7); + + assertThat(store.bindHarness(tenant, session.sessionId(), + turn.turnId(), owner, "boot-new")).isFalse(); + assertThat(store.bindRecoveredHarness(tenant, session.sessionId(), + turn.turnId(), owner, "boot-wrong", "boot-new")) + .isFalse(); + assertThat(store.bindRecoveredHarness(tenant, session.sessionId(), + turn.turnId(), owner, "boot-old", "boot-new")) + .isTrue(); + assertThat(store.bindRecoveredHarness(tenant, session.sessionId(), + turn.turnId(), owner, "boot-old", "boot-new")) + .isTrue(); + + store.recordRecoveryAdmission(tenant, session.sessionId(), + turn.turnId(), owner, "epoch-old", "epoch-new", 0); + store.recordRecoveryAdmission(tenant, session.sessionId(), + turn.turnId(), owner, "epoch-old", "epoch-new", 0); + store.recordRecoveryAdmission(tenant, session.sessionId(), + turn.turnId(), owner, "epoch-new", "epoch-new", 3); + + assertThat(store.requireSession(tenant, session.sessionId())) + .satisfies(record -> { + assertThat(record.harnessBootId()).isEqualTo("boot-new"); + assertThat(record.harnessEventEpoch()) + .isEqualTo("epoch-new"); + assertThat(record.harnessLastEventId()).isEqualTo(3); + }); + assertThat(store.findTurn(tenant, session.sessionId(), turn.turnId())) + .get().satisfies(record -> { + assertThat(record.submissionAttempted()).isTrue(); + assertThat(record.harnessEventEpoch()) + .isEqualTo("epoch-new"); + assertThat(record.harnessLastEventId()).isEqualTo(3); + assertThat(record.status()).isEqualTo("RUNNING"); + }); + assertThatThrownBy(() -> store.recordRecoveryAdmission(tenant, + session.sessionId(), turn.turnId(), owner, "epoch-old", + "epoch-other", 0)).isInstanceOfSatisfying( + IllegalStateException.class, error -> + assertThat(error.getMessage()).contains( + "recovery epoch changed")); + } + + @Test + void retractsOnlyTheIncompleteContinuationEpoch() { + String tenant = "tenant-retract-" + UUID.randomUUID(); + Admission session = store.insertSessionCommand(tenant, + "CREATE_SESSION", "retract-create", + "sha256:" + "4".repeat(64), "qwen-code", null, + List.of(), null); + Admission turn = store.insertTurnCommand(tenant, "SUBMIT_TURN", + "retract-turn", "sha256:" + "5".repeat(64), + session.sessionId(), List.of(), + "sha256:" + "6".repeat(64)); + String owner = "retract-owner"; + assertThat(store.claimTurn(tenant, session.sessionId(), + turn.turnId(), owner, Duration.ofMinutes(1))).isPresent(); + assertThat(store.bindHarness(tenant, session.sessionId(), + turn.turnId(), owner, "boot_old")).isTrue(); + store.markSubmissionAttempted(tenant, session.sessionId(), + turn.turnId(), owner); + store.recordAdmission(tenant, session.sessionId(), turn.turnId(), + owner, "epoch_old", 1); + store.recordHarnessEvents(tenant, session.sessionId(), turn.turnId(), + owner, "epoch_old", List.of( + new HarnessEvent(2, "boot_old:epoch_old:2", + new ProjectedEvent("item.output_text.delta", + Map.of("text", "partial"), false, + null, null, null)), + new HarnessEvent(3, "boot_old:epoch_old:3", + new ProjectedEvent("item.tool_call.updated", + Map.of(), false, null, null, null)), + new HarnessEvent(4, "boot_kept:epoch_old:4", + new ProjectedEvent("item.output_text.delta", + Map.of("text", "kept"), false, null, + null, null)))); + + store.materializeNextBatch(tenant, session.sessionId(), 100); + assertThat(store.findSnapshot(tenant, session.sessionId())) + .isPresent(); + + store.retractContinuationOutput(tenant, session.sessionId(), + turn.turnId(), owner, "boot_old", "epoch_old"); + + assertThat(store.findEvents(tenant, session.sessionId(), 0, 20)) + .satisfies(events -> { + assertThat(events).extracting(event -> event.type() + + ":" + event.sourceKey()) + .contains( + "item.output_text.delta:boot_old:epoch_old:2", + "item.tool_call.updated:boot_old:epoch_old:3", + "item.output_text.delta:boot_kept:epoch_old:4"); + assertThat(events).filteredOn(event -> + "boot_old:epoch_old:2".equals( + event.sourceKey())) + .singleElement() + .satisfies(event -> assertThat(event.data()) + .containsEntry("text", "")); + assertThat(events).filteredOn(event -> + "boot_kept:epoch_old:4".equals( + event.sourceKey())) + .singleElement() + .satisfies(event -> assertThat(event.data()) + .containsEntry("text", "kept")); + }); + assertThat(store.findEvents(tenant, session.sessionId(), 0, 20)) + .filteredOn(event -> "stream.reconciled".equals(event.type())) + .hasSize(1); + store.materializeNextBatch(tenant, session.sessionId(), 100); + assertThat(store.findSnapshot(tenant, session.sessionId())) + .get().satisfies(snapshot -> assertThat(snapshot.items()) + .filteredOn(item -> "message".equals(item.type()) + && "assistant".equals(item.role())) + .singleElement().satisfies(item -> + assertThat(item.content()).singleElement() + .satisfies(part -> assertThat( + part.text()).isEqualTo("kept")))); + } + + @Test + void doesNotPublishRolledBackEvents() throws Exception { + String tenant = "tenant-rollback-" + UUID.randomUUID(); + Admission session = store.insertSessionCommand(tenant, + "CREATE_SESSION", "rollback-create", + "sha256:" + "d".repeat(64), "qwen-code", null, + List.of(), null); + long before = store.requireSession(tenant, session.sessionId()) + .lastSequence(); + + try (SessionEventHub.Subscription subscription = eventHub.subscribe( + tenant, session.sessionId())) { + TransactionTemplate transaction = new TransactionTemplate( + transactionManager); + assertThatThrownBy(() -> transaction.executeWithoutResult( + ignored -> { + store.appendPublicEventIfAbsent(tenant, + session.sessionId(), null, "test.event", + Map.of(), false, "rollback-source"); + throw new IllegalStateException("roll back"); + })).isInstanceOf(IllegalStateException.class); + + SessionEventHub.Delivery delivery = subscription.await(before, + Duration.ofMillis(20)); + assertThat(delivery.events()).isEmpty(); + assertThat(store.requireSession(tenant, session.sessionId()) + .lastSequence()).isEqualTo(before); + } + } + + @Test + void resolvesAnUncertainSubmitBeforeCancelling() throws Exception { + String tenant = "tenant-uncertain-cancel"; + MvcResult created = mvc.perform(post( + "/api/agent/web-shell/v1/sessions/create") + .header(TenantContextFilter.HEADER, tenant) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"idempotencyKey":"uncertain-create", + "agentId":"qwen-code","input":[]} + """)) + .andExpect(status().isAccepted()).andReturn(); + String sessionId = objectMapper.readTree( + created.getResponse().getContentAsString()) + .get("sessionId").asText(); + MvcResult submitted = mvc.perform(post( + "/api/agent/web-shell/v1/turns/submit") + .header(TenantContextFilter.HEADER, tenant) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"idempotencyKey":"uncertain-turn", + "sessionId":"%s", + "input":[{"type":"text", + "text":"uncertain"}]} + """.formatted(sessionId))) + .andExpect(status().isAccepted()).andReturn(); + String turnId = objectMapper.readTree( + submitted.getResponse().getContentAsString()) + .get("turnId").asText(); + await().atMost(Duration.ofSeconds(3)).untilAsserted(() -> + assertThat(harness.hasUncertainRetry()).isTrue()); + int cancellations = harness.cancelCount(); + + mvc.perform(post("/api/agent/web-shell/v1/turns/cancel") + .header(TenantContextFilter.HEADER, tenant) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"idempotencyKey":"uncertain-cancel", + "sessionId":"%s","turnId":"%s"} + """.formatted(sessionId, turnId))) + .andExpect(status().isAccepted()); + harness.releaseUncertainRetries(); + + await().atMost(Duration.ofSeconds(3)).untilAsserted(() -> + assertThat(harness.cancelCount()) + .isEqualTo(cancellations + 1)); + await().atMost(Duration.ofSeconds(3)).untilAsserted(() -> + mvc.perform(post( + "/api/agent/web-shell/v1/transcript/query") + .header(TenantContextFilter.HEADER, tenant) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"sessionId\":\"" + sessionId + + "\",\"limit\":100}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.events[?(@.type ==" + + " 'turn.cancelled')]").isNotEmpty())); + } + + private MvcResult events(String tenant, String sessionId) + throws Exception { + return mvc.perform(get("/v1/agents/sessions/{id}/events", sessionId) + .header(TenantContextFilter.HEADER, tenant) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andReturn(); + } + + @TestConfiguration + static class FixtureConfiguration { + @Bean + @Primary + FixtureHarness fixtureHarness() { + return new FixtureHarness(); + } + } + + static final class FixtureHarness implements HarnessConnector { + private final Map promptIds = + new ConcurrentHashMap<>(); + private final AtomicInteger submits = new AtomicInteger(); + private final AtomicInteger cancels = new AtomicInteger(); + private final AtomicInteger renames = new AtomicInteger(); + private final AtomicInteger closes = new AtomicInteger(); + private final AtomicInteger renameFailures = new AtomicInteger(); + private final Map titles = new ConcurrentHashMap<>(); + private final Map gates = + new ConcurrentHashMap<>(); + private final Set cancelled = + ConcurrentHashMap.newKeySet(); + private final Set sessions = ConcurrentHashMap.newKeySet(); + private final Map uncertainAttempts = + new ConcurrentHashMap<>(); + private final Map uncertainGates = + new ConcurrentHashMap<>(); + private final Set uncertainRetries = + ConcurrentHashMap.newKeySet(); + private volatile boolean available = true; + private volatile HarnessRuntimeRecovery runtimeRecovery; + + @Override + public boolean isAvailable() { + return available; + } + + @Override + public Attachment createOrLoad(String tenantId, String sessionId, + boolean created) { + sessions.add(sessionId); + HarnessRuntimeRecovery recovery = runtimeRecovery; + runtimeRecovery = null; + return new Attachment( + "11111111-1111-4111-8111-111111111111", recovery); + } + + @Override + public Admission submit(String tenantId, String sessionId, + String promptId, + List> input, String payloadDigest) { + promptIds.put(sessionId, promptId); + boolean held = input.stream().anyMatch(block -> + "hold".equals(block.get("text"))); + if (held) { + gates.put(sessionId, new CountDownLatch(1)); + } + submits.incrementAndGet(); + boolean uncertain = input.stream().anyMatch(block -> + "uncertain".equals(block.get("text"))); + if (uncertain) { + int attempt = uncertainAttempts.computeIfAbsent( + sessionId, ignored -> new AtomicInteger()) + .incrementAndGet(); + if (attempt == 1) { + throw new IllegalStateException( + "fixture submit outcome is unknown"); + } + CountDownLatch gate = uncertainGates.computeIfAbsent( + sessionId, ignored -> new CountDownLatch(1)); + uncertainRetries.add(sessionId); + try { + gate.await(5, TimeUnit.SECONDS); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(error); + } finally { + uncertainRetries.remove(sessionId); + } + } + return new Admission(0, "epoch-1"); + } + + @Override + public SourceStream stream(String tenantId, String sessionId, + long lastEventId, + String eventEpoch) { + String promptId = promptIds.get(sessionId); + CountDownLatch gate = gates.get(sessionId); + Queue events = new ArrayDeque<>(); + if (lastEventId < 1) { + events.add(new SourceEvent(1L, "session_update", Map.of( + "update", Map.of( + "sessionUpdate", "agent_message_chunk", + "content", Map.of("type", "text", "text", + "hello"))), promptId, Map.of())); + } + if (lastEventId < 2) { + events.add(new SourceEvent(2L, "turn_complete", Map.of( + "stopReason", "end_turn"), promptId, Map.of())); + } + return new SourceStream() { + @Override + public String eventEpoch() { + return "epoch-1"; + } + + @Override + public SourceEvent next() { + SourceEvent event = events.poll(); + if (event != null && event.id() == 2L) { + if (gate != null) { + try { + gate.await(5, TimeUnit.SECONDS); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + return null; + } + } + if (cancelled.contains(sessionId)) { + return new SourceEvent(2L, "turn_complete", + Map.of("stopReason", "cancelled"), + promptId, Map.of()); + } + } + return event; + } + + @Override + public void close() { + } + }; + } + + @Override + public void cancel(String tenantId, String sessionId) { + cancelled.add(sessionId); + cancels.incrementAndGet(); + } + + @Override + public void rename(String tenantId, String sessionId, String title) { + renames.incrementAndGet(); + if (renameFailures.getAndUpdate(value -> Math.max(0, + value - 1)) > 0) { + throw new IllegalStateException("fixture rename failure"); + } + titles.put(sessionId, title); + } + + @Override + public void closeSession(String tenantId, String sessionId) { + closes.incrementAndGet(); + sessions.remove(sessionId); + } + + boolean hasSession(String sessionId) { + return sessions.contains(sessionId); + } + + int submitCount() { + return submits.get(); + } + + int renameCount() { + return renames.get(); + } + + int closeCount() { + return closes.get(); + } + + String title(String sessionId) { + return titles.get(sessionId); + } + + void failNextRename() { + renameFailures.incrementAndGet(); + } + + void setAvailable(boolean value) { + available = value; + } + + void returnRuntimeRecovery(HarnessRuntimeRecovery recovery) { + runtimeRecovery = recovery; + } + + int cancelCount() { + return cancels.get(); + } + + boolean hasHeldTurn() { + return !gates.isEmpty(); + } + + void releaseHeldTurns() { + gates.values().forEach(CountDownLatch::countDown); + gates.clear(); + } + + boolean hasUncertainRetry() { + return !uncertainRetries.isEmpty(); + } + + void releaseUncertainRetries() { + uncertainGates.values().forEach(CountDownLatch::countDown); + uncertainGates.clear(); + } + + } +} diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionStoreContractFixtureTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionStoreContractFixtureTest.java new file mode 100644 index 00000000000..35131715241 --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionStoreContractFixtureTest.java @@ -0,0 +1,174 @@ +package com.alibaba.qwen.code.managedagent; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.alibaba.qwen.code.managedagent.api.TenantContextFilter; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; + +class ManagedSessionStoreContractFixtureTest { + private static final ObjectMapper JSON = new ObjectMapper(); + private static final Path FIXTURE = findFixture(); + + @Test + void consumesSharedHeadersAndLimits() throws IOException { + JsonNode contract = read(); + JsonNode headers = contract.required("headers"); + JsonNode limits = contract.required("limits"); + + assertEquals(1, contract.required("contractVersion").intValue()); + assertEquals(TenantContextFilter.HEADER, + headers.required("tenant").textValue()); + assertEquals(ManagedSessionStoreModels.WRITER_TOKEN_HEADER, + headers.required("writerToken").textValue()); + assertEquals(ManagedSessionStoreModels.MAX_INLINE_RESOURCE_BYTES, + limits.required("maxInlineResourceBytes").intValue()); + assertEquals(ManagedSessionStoreModels.MAX_RESOURCES_PER_TRANSACTION, + limits.required("maxResourcesPerTransaction").intValue()); + assertEquals(ManagedSessionStoreModels.MAX_TRANSACTION_BYTES, + limits.required("maxTransactionBytes").intValue()); + assertEquals(ManagedSessionStoreModels.MAX_TRANSACTION_EVENTS, + limits.required("maxTransactionEvents").intValue()); + assertEquals(ManagedSessionStoreModels.MIN_WRITER_TOKEN_LENGTH, + limits.required("minimumWriterTokenLength").intValue()); + assertEquals(ManagedSessionStoreModels.MAX_WRITER_TOKEN_LENGTH, + limits.required("maximumWriterTokenLength").intValue()); + assertEquals(ManagedSessionStoreModels.MIN_LEASE_MILLIS, + limits.required("minimumLeaseDurationMs").longValue()); + assertEquals(ManagedSessionStoreModels.MAX_LEASE_MILLIS, + limits.required("maximumLeaseDurationMs").longValue()); + } + + @Test + void reconstructsSharedExactBytesAndDigests() throws IOException { + JsonNode contract = read(); + for (JsonNode resource : contract.required("resources")) { + byte[] bytes = resource.required("utf8").textValue() + .getBytes(StandardCharsets.UTF_8); + assertEquals(resource.required("bytesBase64").textValue(), + Base64.getEncoder().encodeToString(bytes)); + assertEquals(resource.required("byteLength").longValue(), + bytes.length); + assertEquals(resource.required("digest").textValue(), + sha256(bytes)); + } + + JsonNode transaction = contract.required("genesisTransaction"); + StringBuilder jsonl = new StringBuilder(); + for (JsonNode record : transaction.required("records")) { + jsonl.append(JSON.writeValueAsString(record)).append('\n'); + } + byte[] bytes = jsonl.toString().getBytes(StandardCharsets.UTF_8); + assertEquals(transaction.required("jsonl").textValue(), + jsonl.toString()); + assertEquals(transaction.required("recordBytesBase64").textValue(), + Base64.getEncoder().encodeToString(bytes)); + assertEquals(transaction.required("byteLength").longValue(), + bytes.length); + assertEquals(transaction.required("recordDigest").textValue(), + sha256(bytes)); + assertEquals(transaction.required("recordDigest").textValue(), + transaction.required("expectedRequest") + .required("contentDigest").textValue()); + } + + @Test + void consumesSharedErrorClassifications() throws IOException { + Map expected = new LinkedHashMap<>(); + expected.put("writerConflict", new ExpectedError( + HttpStatus.CONFLICT.value(), + ManagedSessionStoreModels.ERROR_WRITER_CONFLICT)); + expected.put("idempotencyConflict", new ExpectedError( + HttpStatus.CONFLICT.value(), + ManagedSessionStoreModels.ERROR_IDEMPOTENCY_CONFLICT)); + expected.put("recoveryConflict", new ExpectedError( + HttpStatus.CONFLICT.value(), + ManagedSessionStoreModels.ERROR_RECOVERY_CONFLICT)); + expected.put("resourceMissing", new ExpectedError( + HttpStatus.CONFLICT.value(), + ManagedSessionStoreModels.ERROR_RESOURCE_MISSING)); + expected.put("resourceNotFound", new ExpectedError( + HttpStatus.NOT_FOUND.value(), + ManagedSessionStoreModels.ERROR_RESOURCE_NOT_FOUND)); + expected.put("ossDisabled", new ExpectedError( + HttpStatus.NOT_IMPLEMENTED.value(), + ManagedSessionStoreModels.ERROR_OSS_DISABLED)); + expected.put("invalidRequest", new ExpectedError( + HttpStatus.BAD_REQUEST.value(), + ManagedSessionStoreModels.ERROR_INVALID_REQUEST)); + expected.put("payloadTooLarge", new ExpectedError( + HttpStatus.PAYLOAD_TOO_LARGE.value(), + ManagedSessionStoreModels.ERROR_PAYLOAD_TOO_LARGE)); + expected.put("journalCorrupt", new ExpectedError( + HttpStatus.INTERNAL_SERVER_ERROR.value(), + ManagedSessionStoreModels.ERROR_JOURNAL_CORRUPT)); + expected.put("headCorrupt", new ExpectedError( + HttpStatus.INTERNAL_SERVER_ERROR.value(), + ManagedSessionStoreModels.ERROR_HEAD_CORRUPT)); + + JsonNode errors = read().required("errors"); + assertEquals(expected.keySet(), fieldNames(errors).keySet()); + expected.forEach((name, value) -> { + JsonNode fixture = errors.required(name); + assertEquals(value.status(), + fixture.required("status").intValue(), name); + assertEquals(value.code(), + fixture.required("code").textValue(), name); + }); + } + + private static JsonNode read() throws IOException { + assertTrue(Files.isRegularFile(FIXTURE), + () -> "missing shared contract fixture: " + FIXTURE); + return JSON.readTree(FIXTURE.toFile()); + } + + private static Map fieldNames(JsonNode object) { + Map fields = new LinkedHashMap<>(); + object.properties().forEach(field -> fields.put(field.getKey(), + field.getValue())); + return fields; + } + + private static Path findFixture() { + Path current = Path.of(System.getProperty("user.dir")) + .toAbsolutePath(); + for (int depth = 0; depth < 6 && current != null; depth++) { + Path candidate = current.resolve(Path.of("packages", "core", + "src", "managed-runtime", "contracts", + "managed-session-store-v1.fixtures.json")); + if (Files.isRegularFile(candidate)) { + return candidate; + } + current = current.getParent(); + } + throw new AssertionError( + "cannot locate shared Managed Session store fixture"); + } + + private static String sha256(byte[] value) { + try { + return HexFormat.of().formatHex(MessageDigest + .getInstance("SHA-256").digest(value)); + } catch (NoSuchAlgorithmException error) { + throw new IllegalStateException(error); + } + } + + private record ExpectedError(int status, String code) { + } +} diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionStoreIntegrationTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionStoreIntegrationTest.java new file mode 100644 index 00000000000..ee012e6bf69 --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionStoreIntegrationTest.java @@ -0,0 +1,522 @@ +package com.alibaba.qwen.code.managedagent; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.alibaba.qwen.code.managedagent.api.TenantContextFilter; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.Base64; +import java.util.HexFormat; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +@SpringBootTest(properties = { + "spring.datasource.url=jdbc:h2:mem:managed-session-store;MODE=MySQL;" + + "DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=", + "qwen.managed-agent.harness.enabled=false", + "qwen.managed-agent.session-store.enabled=true" +}) +@AutoConfigureMockMvc +class ManagedSessionStoreIntegrationTest { + private static final String TENANT = "tenant-store"; + private static final String WORKSPACE = "workspace-store"; + private static final String SESSION = "session-store"; + private static final String WRITER_A = "writer-a"; + private static final String WRITER_B = "writer-b"; + private static final String TOKEN_A = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + private static final String TOKEN_B = + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + private static final String BASE = + "/internal/managed-session-store/v1/sessions/" + SESSION; + + @Autowired + private MockMvc mvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private JdbcTemplate jdbc; + + @Test + void requiresTrustedTenantAndWriterToken() throws Exception { + mvc.perform(post(BASE + "/writers:acquire") + .header(ManagedSessionStoreModels.WRITER_TOKEN_HEADER, + TOKEN_A) + .contentType(MediaType.APPLICATION_JSON) + .content(writerRequest(WRITER_A).toString())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error.code").value("invalid_tenant")); + + mvc.perform(post(BASE + "/writers:acquire") + .header(TenantContextFilter.HEADER, TENANT) + .contentType(MediaType.APPLICATION_JSON) + .content(writerRequest(WRITER_A).toString())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error.code").value("invalid_request")); + } + + @Test + void fencesWritersAndReplaysExactTransactions() throws Exception { + JsonNode grantA = json(postWithToken("/writers:acquire", TOKEN_A, + writerRequest(WRITER_A)).getResponse() + .getContentAsString()); + assertThat(grantA.get("writerGeneration").asLong()).isEqualTo(1); + assertThat(grantA.get("journalRevision").asLong()).isZero(); + + mvc.perform(get(BASE + "/restore") + .header(TenantContextFilter.HEADER, TENANT) + .header(ManagedSessionStoreModels.WRITER_TOKEN_HEADER, + TOKEN_B) + .param("workspaceId", WORKSPACE)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error.code") + .value("managed_session_writer_conflict")); + + mvc.perform(post(BASE + "/writers:acquire") + .header(TenantContextFilter.HEADER, TENANT) + .header(ManagedSessionStoreModels.WRITER_TOKEN_HEADER, + TOKEN_B) + .contentType(MediaType.APPLICATION_JSON) + .content(writerRequest(WRITER_B).toString())) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error.code") + .value("managed_session_writer_conflict")); + + byte[] resourceBytes = new byte[ + ManagedSessionStoreModels.MAX_INLINE_RESOURCE_BYTES]; + Arrays.fill(resourceBytes, (byte) 'x'); + String genesisBytes = "{\"subtype\":\"session_execution_engine\"}\n" + + "{\"subtype\":\"managed_session_header_v1\"}\n"; + ObjectNode genesis = genesisRequest(genesisBytes, resourceBytes); + MvcResult committed = postWithToken("/transactions:commit", TOKEN_A, + genesis); + JsonNode firstReceipt = json( + committed.getResponse().getContentAsString()); + assertThat(firstReceipt.get("journalRevision").asLong()).isEqualTo(1); + assertThat(firstReceipt.get("replayed").asBoolean()).isFalse(); + + MvcResult replayed = postWithToken("/transactions:commit", TOKEN_A, + genesis); + JsonNode replayReceipt = json( + replayed.getResponse().getContentAsString()); + assertThat(replayReceipt.get("journalRevision").asLong()).isEqualTo(1); + assertThat(replayReceipt.get("transactionId").asText()) + .isEqualTo(firstReceipt.get("transactionId").asText()); + assertThat(replayReceipt.get("replayed").asBoolean()).isTrue(); + + ObjectNode conflictingTransaction = genesis.deepCopy(); + conflictingTransaction.put("transactionId", "other-transaction"); + mvc.perform(post(BASE + "/transactions:commit") + .header(TenantContextFilter.HEADER, TENANT) + .header(ManagedSessionStoreModels.WRITER_TOKEN_HEADER, + TOKEN_A) + .contentType(MediaType.APPLICATION_JSON) + .content(conflictingTransaction.toString())) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error.code") + .value("managed_session_idempotency_conflict")); + + ObjectNode conflictingGenesis = genesis.deepCopy(); + String changedBytes = genesisBytes.replace("header_v1", "changed_v1"); + conflictingGenesis.put("recordBytesBase64", base64(changedBytes)); + conflictingGenesis.put("recordDigest", sha256(changedBytes)); + conflictingGenesis.put("contentDigest", sha256("changed")); + mvc.perform(post(BASE + "/transactions:commit") + .header(TenantContextFilter.HEADER, TENANT) + .header(ManagedSessionStoreModels.WRITER_TOKEN_HEADER, + TOKEN_A) + .contentType(MediaType.APPLICATION_JSON) + .content(conflictingGenesis.toString())) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error.code") + .value("managed_session_idempotency_conflict")); + + mvc.perform(get(BASE + "/transactions") + .header(TenantContextFilter.HEADER, TENANT) + .header(ManagedSessionStoreModels.WRITER_TOKEN_HEADER, + TOKEN_A) + .param("workspaceId", WORKSPACE)) + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.CACHE_CONTROL, + "no-store")) + .andExpect(jsonPath("$.transactions.length()").value(1)) + .andExpect(jsonPath("$.transactions[0].recordBytesBase64") + .value(base64(genesisBytes))) + .andExpect(jsonPath("$.nextRevision").value(1)); + + mvc.perform(get(BASE + "/transactions") + .header(TenantContextFilter.HEADER, TENANT) + .header(ManagedSessionStoreModels.WRITER_TOKEN_HEADER, + TOKEN_A) + .param("workspaceId", WORKSPACE) + .param("afterRevision", "9007199254740991")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error.code") + .value("invalid_managed_session_store_request")); + + mvc.perform(get(BASE + "/resources/resource-context") + .header(TenantContextFilter.HEADER, TENANT) + .header(ManagedSessionStoreModels.WRITER_TOKEN_HEADER, + TOKEN_A) + .param("workspaceId", WORKSPACE)) + .andExpect(status().isOk()) + .andExpect(header().string("X-Qwen-Resource-Digest", + sha256(resourceBytes))) + .andExpect(content().bytes(resourceBytes)); + + ObjectNode renew = objectMapper.createObjectNode() + .put("workspaceId", WORKSPACE) + .put("writerId", WRITER_A) + .put("writerGeneration", 1) + .put("leaseMillis", 60_000); + JsonNode renewed = json(postWithToken("/writers:renew", TOKEN_A, + renew).getResponse().getContentAsString()); + assertThat(renewed.get("writerGeneration").asLong()).isEqualTo(1); + + ObjectNode seal = objectMapper.createObjectNode() + .put("workspaceId", WORKSPACE) + .put("writerId", WRITER_A) + .put("writerGeneration", 1); + postWithToken("/writers:seal", TOKEN_A, seal); + JsonNode sealReplay = json(postWithToken("/writers:seal", TOKEN_A, + seal).getResponse().getContentAsString()); + assertThat(sealReplay.get("replayed").asBoolean()).isTrue(); + JsonNode grantB = json(postWithToken("/writers:acquire", TOKEN_B, + writerRequest(WRITER_B)).getResponse() + .getContentAsString()); + assertThat(grantB.get("writerGeneration").asLong()).isEqualTo(2); + mvc.perform(get(BASE + "/restore") + .header(TenantContextFilter.HEADER, TENANT) + .header(ManagedSessionStoreModels.WRITER_TOKEN_HEADER, + TOKEN_A) + .param("workspaceId", WORKSPACE)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error.code") + .value("managed_session_writer_conflict")); + + String turnBytes = "{\"subtype\":\"managed_session_event_v1\"}\n" + + "{\"subtype\":\"managed_session_commit_v1\"}\n"; + ObjectNode turn = transactionRequest(turnBytes, WRITER_A, 1); + byte[] checkpointBytes = "checkpoint-state" + .getBytes(StandardCharsets.UTF_8); + turn.put("latestCheckpointResourceId", "resource-checkpoint"); + turn.putArray("resources").addObject() + .put("resourceId", "resource-checkpoint") + .put("kind", "managed-checkpoint") + .put("schemaVersion", 1) + .put("byteLength", checkpointBytes.length) + .put("digest", sha256(checkpointBytes)) + .put("bytesBase64", Base64.getEncoder() + .encodeToString(checkpointBytes)); + mvc.perform(post(BASE + "/transactions:commit") + .header(TenantContextFilter.HEADER, TENANT) + .header(ManagedSessionStoreModels.WRITER_TOKEN_HEADER, + TOKEN_A) + .contentType(MediaType.APPLICATION_JSON) + .content(turn.toString())) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error.code") + .value("managed_session_writer_conflict")); + + turn.put("writerId", WRITER_B).put("writerGeneration", 2); + JsonNode secondReceipt = json(postWithToken( + "/transactions:commit", TOKEN_B, turn) + .getResponse().getContentAsString()); + assertThat(secondReceipt.get("journalRevision").asLong()) + .isEqualTo(2); + assertThat(secondReceipt.get("committedSequence").asLong()) + .isEqualTo(1); + + mvc.perform(get(BASE + "/transactions") + .header(TenantContextFilter.HEADER, TENANT) + .header(ManagedSessionStoreModels.WRITER_TOKEN_HEADER, + TOKEN_B) + .param("workspaceId", WORKSPACE) + .param("limit", "1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.transactions.length()") + .value(1)) + .andExpect(jsonPath("$.nextRevision").value(1)) + .andExpect(jsonPath("$.hasMore").value(true)); + mvc.perform(get(BASE + "/transactions") + .header(TenantContextFilter.HEADER, TENANT) + .header(ManagedSessionStoreModels.WRITER_TOKEN_HEADER, + TOKEN_B) + .param("workspaceId", WORKSPACE) + .param("afterRevision", "1") + .param("limit", "1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.transactions[0]" + + ".latestCheckpointResourceId") + .value("resource-checkpoint")) + .andExpect(jsonPath("$.nextRevision").value(2)) + .andExpect(jsonPath("$.hasMore").value(false)); + + JsonNode fencedReplay = json(postWithToken( + "/transactions:commit", TOKEN_A, genesis) + .getResponse().getContentAsString()); + assertThat(fencedReplay.get("replayed").asBoolean()).isTrue(); + mvc.perform(post(BASE + "/transactions:commit") + .header(TenantContextFilter.HEADER, TENANT) + .header(ManagedSessionStoreModels.WRITER_TOKEN_HEADER, + TOKEN_A) + .contentType(MediaType.APPLICATION_JSON) + .content(turn.toString())) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error.code") + .value("managed_session_idempotency_conflict")); + + mvc.perform(get(BASE + "/restore") + .header(TenantContextFilter.HEADER, TENANT) + .header(ManagedSessionStoreModels.WRITER_TOKEN_HEADER, + TOKEN_B) + .param("workspaceId", WORKSPACE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.journalRevision").value(2)) + .andExpect(jsonPath("$.committedSequence").value(1)) + .andExpect(jsonPath("$.lastCommitDigest") + .value("c".repeat(64))) + .andExpect(jsonPath("$.latestCheckpointResourceId") + .value("resource-checkpoint")) + .andExpect(jsonPath("$.recoveryStatus").value("READY")); + + mvc.perform(get(BASE + "/restore") + .header(TenantContextFilter.HEADER, TENANT) + .header(ManagedSessionStoreModels.WRITER_TOKEN_HEADER, + TOKEN_B) + .param("workspaceId", "other-workspace")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.error.code") + .value("managed_session_not_found")); + + ObjectNode oversized = transactionRequest(turnBytes, WRITER_B, 2) + .put("transactionId", "transaction-oversized") + .put("commandId", "command-oversized") + .put("expectedJournalRevision", 2) + .put("expectedCommittedSequence", 1) + .put("firstSequence", 2) + .put("lastSequence", 2) + .put("previousCommitDigest", "c".repeat(64)); + oversized.withArray("resources").addObject() + .put("resourceId", "resource-oversized") + .put("kind", "managed-context") + .put("schemaVersion", 1) + .put("byteLength", + ManagedSessionStoreModels.MAX_INLINE_RESOURCE_BYTES + 1) + .put("digest", "d".repeat(64)); + mvc.perform(post(BASE + "/transactions:commit") + .header(TenantContextFilter.HEADER, TENANT) + .header(ManagedSessionStoreModels.WRITER_TOKEN_HEADER, + TOKEN_B) + .contentType(MediaType.APPLICATION_JSON) + .content(oversized.toString())) + .andExpect(status().isNotImplemented()) + .andExpect(jsonPath("$.error.code") + .value("managed_session_oss_disabled")); + + ObjectNode recoveryBlock = objectMapper.createObjectNode() + .put("workspaceId", WORKSPACE) + .put("writerId", WRITER_B) + .put("writerGeneration", 2) + .put("recoveryStatus", "BLOCKED_EXECUTION") + .put("recoveryDetailCode", + "runtime_execution_outcome_unknown"); + mvc.perform(post(BASE + "/recovery:block") + .header(TenantContextFilter.HEADER, TENANT) + .header(ManagedSessionStoreModels.WRITER_TOKEN_HEADER, + TOKEN_A) + .contentType(MediaType.APPLICATION_JSON) + .content(recoveryBlock.toString())) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error.code") + .value("managed_session_writer_conflict")); + postWithToken("/recovery:block", TOKEN_B, recoveryBlock) + .getResponse(); + mvc.perform(post(BASE + "/recovery:block") + .header(TenantContextFilter.HEADER, TENANT) + .header(ManagedSessionStoreModels.WRITER_TOKEN_HEADER, + TOKEN_B) + .contentType(MediaType.APPLICATION_JSON) + .content(recoveryBlock.toString())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.recoveryStatus") + .value("BLOCKED_EXECUTION")) + .andExpect(jsonPath("$.recoveryDetailCode") + .value("runtime_execution_outcome_unknown")) + .andExpect(jsonPath("$.replayed").value(true)); + ObjectNode conflictingBlock = recoveryBlock.deepCopy() + .put("recoveryStatus", "BLOCKED_WORKSPACE") + .put("recoveryDetailCode", "workspace_snapshot_missing"); + mvc.perform(post(BASE + "/recovery:block") + .header(TenantContextFilter.HEADER, TENANT) + .header(ManagedSessionStoreModels.WRITER_TOKEN_HEADER, + TOKEN_B) + .contentType(MediaType.APPLICATION_JSON) + .content(conflictingBlock.toString())) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error.code") + .value("managed_session_recovery_conflict")); + mvc.perform(get(BASE + "/restore") + .header(TenantContextFilter.HEADER, TENANT) + .header(ManagedSessionStoreModels.WRITER_TOKEN_HEADER, + TOKEN_B) + .param("workspaceId", WORKSPACE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.recoveryStatus") + .value("BLOCKED_EXECUTION")) + .andExpect(jsonPath("$.recoveryDetailCode") + .value("runtime_execution_outcome_unknown")); + jdbc.update("UPDATE qwen_managed_session_journal_head SET" + + " recovery_status = 'READY'," + + " recovery_detail_code = NULL WHERE tenant_id = ?" + + " AND session_id = ?", + TENANT, SESSION); + + jdbc.update("UPDATE qwen_managed_session_journal_head" + + " SET recovery_status = 'UNKNOWN'" + + " WHERE tenant_id = ? AND session_id = ?", + TENANT, SESSION); + mvc.perform(get(BASE + "/restore") + .header(TenantContextFilter.HEADER, TENANT) + .header(ManagedSessionStoreModels.WRITER_TOKEN_HEADER, + TOKEN_B) + .param("workspaceId", WORKSPACE)) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.error.code") + .value("managed_session_head_corrupt")); + jdbc.update("UPDATE qwen_managed_session_journal_head" + + " SET recovery_status = 'READY'" + + " WHERE tenant_id = ? AND session_id = ?", + TENANT, SESSION); + jdbc.update("DELETE FROM qwen_managed_session_journal_tx" + + " WHERE tenant_id = ? AND session_id = ?" + + " AND journal_revision = 1", + TENANT, SESSION); + mvc.perform(get(BASE + "/transactions") + .header(TenantContextFilter.HEADER, TENANT) + .header(ManagedSessionStoreModels.WRITER_TOKEN_HEADER, + TOKEN_B) + .param("workspaceId", WORKSPACE)) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.error.code") + .value("managed_session_journal_corrupt")); + } + + private ObjectNode writerRequest(String writerId) { + return objectMapper.createObjectNode() + .put("workspaceId", WORKSPACE) + .put("writerId", writerId) + .put("leaseMillis", 60_000); + } + + private ObjectNode genesisRequest(String records, byte[] resourceBytes) { + ObjectNode request = objectMapper.createObjectNode() + .put("workspaceId", WORKSPACE) + .put("writerId", WRITER_A) + .put("writerGeneration", 1) + .put("expectedJournalRevision", 0) + .put("expectedCommittedSequence", 0) + .put("transactionId", "transaction-genesis") + .put("operation", "session.create") + .put("commandId", "command-genesis") + .put("contentDigest", sha256(records)) + .put("firstSequence", 0) + .put("lastSequence", 0) + .put("eventCount", 0) + .put("activationEpoch", 0) + .put("recordCount", 2) + .put("recordBytesBase64", base64(records)) + .put("recordDigest", sha256(records)); + ArrayNode resources = request.putArray("resources"); + resources.addObject() + .put("resourceId", "resource-context") + .put("kind", "managed-context") + .put("schemaVersion", 1) + .put("byteLength", resourceBytes.length) + .put("digest", sha256(resourceBytes)) + .put("bytesBase64", Base64.getEncoder() + .encodeToString(resourceBytes)); + return request; + } + + private ObjectNode transactionRequest(String records, String writerId, + long generation) { + return objectMapper.createObjectNode() + .put("workspaceId", WORKSPACE) + .put("writerId", writerId) + .put("writerGeneration", generation) + .put("expectedJournalRevision", 1) + .put("expectedCommittedSequence", 0) + .put("transactionId", "transaction-turn") + .put("operation", "turn.submit") + .put("commandId", "command-turn") + .put("contentDigest", sha256("turn-content")) + .put("firstSequence", 1) + .put("lastSequence", 1) + .put("eventCount", 1) + .put("eventsDigest", "e".repeat(64)) + .put("commitDigest", "c".repeat(64)) + .put("activationEpoch", 0) + .put("recordCount", 2) + .put("recordBytesBase64", base64(records)) + .put("recordDigest", sha256(records)); + } + + private MvcResult postWithToken(String path, String token, + JsonNode body) throws Exception { + return mvc.perform(post(BASE + path) + .header(TenantContextFilter.HEADER, TENANT) + .header(ManagedSessionStoreModels.WRITER_TOKEN_HEADER, + token) + .contentType(MediaType.APPLICATION_JSON) + .content(body.toString())) + .andExpect(status().isOk()).andReturn(); + } + + private JsonNode json(String value) throws Exception { + return objectMapper.readTree(value); + } + + private static String base64(String value) { + return Base64.getEncoder().encodeToString( + value.getBytes(StandardCharsets.UTF_8)); + } + + private static String sha256(String value) { + return sha256(value.getBytes(StandardCharsets.UTF_8)); + } + + private static String sha256(byte[] value) { + try { + return HexFormat.of().formatHex( + MessageDigest.getInstance("SHA-256").digest(value)); + } catch (NoSuchAlgorithmException error) { + throw new IllegalStateException(error); + } + } +} diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionStoreProcessFixtureMain.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionStoreProcessFixtureMain.java new file mode 100644 index 00000000000..e2b67d3c40d --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionStoreProcessFixtureMain.java @@ -0,0 +1,211 @@ +package com.alibaba.qwen.code.managedagent; + +import com.alibaba.qwen.code.managedagent.api.ApiException; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStore; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.AcquireWriterRequest; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.CommitReceipt; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.CommitResource; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.CommitTransactionRequest; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.RestoreHead; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.StoredResource; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.WriterGrant; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.transaction.support.TransactionTemplate; + +/** Child JVM for the Managed Session store process-loss proof. */ +public final class ManagedSessionStoreProcessFixtureMain { + static final String TENANT = "mysql-session-process-tenant"; + static final String SESSION = "mysql-session-process-session"; + static final String WORKSPACE = "mysql-session-process-workspace"; + private static final String WRITER_A = "mysql-session-process-writer-a"; + private static final String WRITER_B = "mysql-session-process-writer-b"; + private static final String TOKEN_A = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + private static final String TOKEN_B = + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + private static final byte[] RESOURCE_BYTES = + "mysql-process-resource".getBytes(StandardCharsets.UTF_8); + + private ManagedSessionStoreProcessFixtureMain() { + } + + public static void main(String[] args) { + DriverManagerDataSource dataSource = new DriverManagerDataSource( + required("D1_MYSQL_URL"), required("D1_MYSQL_USER"), + System.getenv().getOrDefault("D1_MYSQL_PASSWORD", "")); + TransactionTemplate transactions = new TransactionTemplate( + new DataSourceTransactionManager(dataSource)); + ManagedSessionStore store = new ManagedSessionStore( + new JdbcTemplate(dataSource)); + String action = required("D1_PROCESS_ACTION"); + switch (action) { + case "commit-and-crash" -> commitAndCrash(store, transactions); + case "takeover" -> takeover(store, transactions); + case "replay" -> replay(store, transactions); + case "stale-write" -> staleWrite(store, transactions); + case "restore" -> restore(store, transactions); + default -> throw new IllegalArgumentException( + "unknown fixture action: " + action); + } + } + + private static void commitAndCrash(ManagedSessionStore store, + TransactionTemplate transactions) { + WriterGrant grant = inTransaction(transactions, + () -> store.acquireWriter(TENANT, SESSION, TOKEN_A, + new AcquireWriterRequest(WORKSPACE, WRITER_A, + 1_000L))); + require(grant.writerGeneration() == 1, + "the first writer did not receive generation one"); + CommitReceipt receipt; + try { + receipt = inTransaction(transactions, + () -> store.commit(TENANT, SESSION, TOKEN_A, genesis())); + } catch (ApiException error) { + JdbcTemplate jdbc = new JdbcTemplate(new DriverManagerDataSource( + required("D1_MYSQL_URL"), required("D1_MYSQL_USER"), + System.getenv().getOrDefault("D1_MYSQL_PASSWORD", ""))); + Map head = jdbc.queryForMap( + "SELECT state, writer_generation, writer_id," + + " writer_lease_until," + + " CURRENT_TIMESTAMP(6) AS observed_now," + + " lease_token_hash = ? AS token_matches," + + " TIMESTAMPDIFF(MICROSECOND," + + " CURRENT_TIMESTAMP(6), writer_lease_until)" + + " AS lease_remaining_micros FROM" + + " qwen_managed_session_journal_head WHERE" + + " tenant_id = ? AND session_id = ?", + sha256(TOKEN_A.getBytes(StandardCharsets.UTF_8)), + TENANT, SESSION); + System.err.println("Writer diagnostic: " + head); + throw error; + } + require(receipt.journalRevision() == 1 && !receipt.replayed(), + "the genesis transaction did not commit exactly once"); + Runtime.getRuntime().halt(23); + throw new AssertionError("Runtime.halt returned"); + } + + private static void takeover(ManagedSessionStore store, + TransactionTemplate transactions) { + WriterGrant grant = inTransaction(transactions, + () -> store.acquireWriter(TENANT, SESSION, TOKEN_B, + new AcquireWriterRequest(WORKSPACE, WRITER_B, + 60_000L))); + require(grant.writerGeneration() == 2, + "the replacement writer did not advance the generation"); + require(grant.journalRevision() == 1, + "the replacement writer did not observe the durable commit"); + System.out.println("D1_PROCESS_TAKEOVER_OK"); + } + + private static void replay(ManagedSessionStore store, + TransactionTemplate transactions) { + CommitReceipt receipt = inTransaction(transactions, + () -> store.commit(TENANT, SESSION, TOKEN_A, genesis())); + require(receipt.replayed() && receipt.journalRevision() == 1, + "the lost response did not return its original receipt"); + System.out.println("D1_PROCESS_REPLAY_OK"); + } + + private static void staleWrite(ManagedSessionStore store, + TransactionTemplate transactions) { + try { + inTransaction(transactions, + () -> store.commit(TENANT, SESSION, TOKEN_A, + staleTurn())); + } catch (ApiException error) { + require(ManagedSessionStoreModels.ERROR_WRITER_CONFLICT + .equals(error.getCode()), + "the stale writer returned an unexpected error"); + System.out.println("D1_PROCESS_STALE_WRITER_OK"); + return; + } + throw new AssertionError("the stale writer committed new bytes"); + } + + private static void restore(ManagedSessionStore store, + TransactionTemplate transactions) { + RestoreHead head = inTransaction(transactions, + () -> store.restore(TENANT, WORKSPACE, SESSION, TOKEN_B)); + require(head.writerGeneration() == 2 + && head.journalRevision() == 1, + "the replacement writer restored the wrong head"); + StoredResource resource = inTransaction(transactions, + () -> store.readResource(TENANT, WORKSPACE, SESSION, + "mysql-process-resource", TOKEN_B)); + require(MessageDigest.isEqual(RESOURCE_BYTES, resource.bytes()), + "the replacement writer restored different resource bytes"); + System.out.println("D1_PROCESS_RESTORE_OK"); + } + + private static CommitTransactionRequest genesis() { + String records = "{\"subtype\":\"session_execution_engine\"}\n" + + "{\"subtype\":\"managed_session_header_v1\"}\n"; + return new CommitTransactionRequest(WORKSPACE, WRITER_A, 1, 0, 0, + "mysql-process-genesis-transaction", "session.create", + "mysql-process-genesis-command", sha256(records), 0, 0, 0, + null, null, null, 0, null, 2, + Base64.getEncoder().encodeToString( + records.getBytes(StandardCharsets.UTF_8)), + sha256(records), List.of(new CommitResource( + "mysql-process-resource", "managed-context", 1, + RESOURCE_BYTES.length, sha256(RESOURCE_BYTES), + Base64.getEncoder().encodeToString(RESOURCE_BYTES)))); + } + + private static CommitTransactionRequest staleTurn() { + String records = "{\"subtype\":\"managed_session_event_v1\"}\n" + + "{\"subtype\":\"managed_session_commit_v1\"}\n"; + return new CommitTransactionRequest(WORKSPACE, WRITER_A, 1, 1, 0, + "mysql-process-stale-transaction", "turn.submit", + "mysql-process-stale-command", sha256("stale-content"), + 1, 1, 1, "e".repeat(64), null, "c".repeat(64), 0, + null, 2, Base64.getEncoder().encodeToString( + records.getBytes(StandardCharsets.UTF_8)), + sha256(records), List.of()); + } + + private static T inTransaction(TransactionTemplate transactions, + Supplier operation) { + return transactions.execute(status -> operation.get()); + } + + private static String sha256(String value) { + return sha256(value.getBytes(StandardCharsets.UTF_8)); + } + + private static String sha256(byte[] value) { + try { + return HexFormat.of().formatHex(MessageDigest + .getInstance("SHA-256").digest(value)); + } catch (NoSuchAlgorithmException error) { + throw new IllegalStateException(error); + } + } + + private static String required(String name) { + String value = System.getenv(name); + if (value == null || value.isBlank()) { + throw new IllegalStateException(name + " is required"); + } + return value; + } + + private static void require(boolean condition, String message) { + if (!condition) { + throw new AssertionError(message); + } + } +} diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/RuntimeBrokerConfigurationIntegrationTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/RuntimeBrokerConfigurationIntegrationTest.java new file mode 100644 index 00000000000..255773349f8 --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/RuntimeBrokerConfigurationIntegrationTest.java @@ -0,0 +1,79 @@ +package com.alibaba.qwen.code.managedagent; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.alibaba.qwen.code.managedagent.service.EmbeddedRuntimeBroker; +import com.alibaba.qwen.code.managedagent.service.RuntimeWarmer; +import com.alibaba.qwen.code.runtimebroker.JdbcRuntimeBindingRepository; +import com.alibaba.qwen.code.runtimebroker.JdbcRuntimeSessionRepository; +import com.alibaba.qwen.code.runtimebroker.JdbcToolExecutionRepository; +import com.alibaba.qwen.code.runtimebroker.RuntimeBindingRepository; +import com.alibaba.qwen.code.runtimebroker.RuntimeSessionRepository; +import com.alibaba.qwen.code.runtimebroker.SecretProtector; +import com.alibaba.qwen.code.runtimebroker.ToolExecutionRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; + +@SpringBootTest(properties = { + "spring.datasource.url=jdbc:h2:mem:runtime-broker-config;MODE=MySQL;" + + "DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=", + "qwen.managed-agent.harness.enabled=false", + "qwen.managed-agent.harness.capability-digest=sha256:" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "qwen.managed-agent.runtime-broker.enabled=true", + "qwen.managed-agent.runtime-broker.host=127.0.0.1", + "qwen.managed-agent.runtime-broker.port=0", + "qwen.managed-agent.runtime-broker.token=broker-token", + "qwen.managed-agent.runtime-broker.credential-key-id=test-key", + "qwen.managed-agent.runtime-broker.credential-key=" + + "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=", + "qwen.managed-agent.runtime-broker.provisioner=static", + "qwen.managed-agent.runtime-broker.workspace-id=workspace", + "qwen.managed-agent.runtime-broker.workspace-cwd=workspace", + "qwen.managed-agent.runtime-broker.isolation-class=workspace", + "qwen.managed-agent.runtime-broker.static-endpoint=http://127.0.0.1:9", + "qwen.managed-agent.runtime-broker.static-token=runtime-token" +}) +class RuntimeBrokerConfigurationIntegrationTest { + @Autowired + private RuntimeWarmer runtimeWarmer; + @Autowired + private RuntimeBindingRepository bindingRepository; + @Autowired + private RuntimeSessionRepository sessionRepository; + @Autowired + private ToolExecutionRepository executionRepository; + @Autowired + private SecretProtector secretProtector; + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + void selectsTheEmbeddedBrokerAsTheRuntimeWarmer() { + assertThat(runtimeWarmer).isInstanceOf(EmbeddedRuntimeBroker.class); + assertThat(bindingRepository) + .isInstanceOf(JdbcRuntimeBindingRepository.class); + assertThat(sessionRepository) + .isInstanceOf(JdbcRuntimeSessionRepository.class); + assertThat(executionRepository) + .isInstanceOf(JdbcToolExecutionRepository.class); + assertThat(secretProtector).isNotNull(); + assertThat(columnCount("provision_seed_ciphertext")).isEqualTo(1); + assertThat(columnCount("runtime_credential_ciphertext")).isEqualTo(1); + assertThat(columnCount("runtime_token")).isZero(); + } + + private Integer columnCount(String columnName) { + return jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM information_schema.columns " + + "WHERE table_name = 'qwen_runtime_binding' " + + "AND column_name = ?", + Integer.class, columnName); + } +} diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnectorTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnectorTest.java new file mode 100644 index 00000000000..d793b5c1809 --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessConnectorTest.java @@ -0,0 +1,90 @@ +package com.alibaba.qwen.code.managedagent.harness; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.alibaba.qwen.code.daemon.CreateHarnessSession; +import com.alibaba.qwen.code.daemon.DaemonHttpException; +import com.alibaba.qwen.code.daemon.HarnessSessionRef; +import com.alibaba.qwen.code.daemon.HostedHarnessCapabilities; +import com.alibaba.qwen.code.daemon.HostedHarnessClient; +import com.alibaba.qwen.code.daemon.LoadHarnessSession; +import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties; +import java.time.Duration; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +class QwenHostedHarnessConnectorTest { + private static final String SESSION_ID = + "33333333-3333-4333-8333-333333333333"; + private static final String BOOT_ID = + "11111111-1111-4111-8111-111111111111"; + + @Test + void loadsAnExistingSessionWithoutCreatingIt() { + HostedHarnessClient client = mock(HostedHarnessClient.class); + HostedHarnessCapabilities capabilities = + mock(HostedHarnessCapabilities.class); + HarnessSessionRef session = mock(HarnessSessionRef.class); + when(capabilities.getBootId()).thenReturn(BOOT_ID); + when(client.capabilities()).thenReturn(capabilities); + when(client.loadSession(any(LoadHarnessSession.class))) + .thenReturn(session); + when(session.getHarnessBootId()).thenReturn(BOOT_ID); + QwenHostedHarnessConnector connector = connector(client); + + HarnessConnector.Attachment attachment = connector.createOrLoad( + "tenant-a", SESSION_ID, true); + + assertThat(attachment.bootId()).isEqualTo(BOOT_ID); + verify(client).loadSession(any(LoadHarnessSession.class)); + verify(client, never()).createSession(any(CreateHarnessSession.class)); + } + + @Test + void loadsAnExistingAuthorityAfterCreateConflicts() { + HostedHarnessClient client = mock(HostedHarnessClient.class); + HostedHarnessCapabilities capabilities = + mock(HostedHarnessCapabilities.class); + HarnessSessionRef session = mock(HarnessSessionRef.class); + DaemonHttpException conflict = mock(DaemonHttpException.class); + when(conflict.getStatusCode()).thenReturn(409); + when(capabilities.getBootId()).thenReturn(BOOT_ID); + when(client.capabilities()).thenReturn(capabilities); + when(client.loadSession(any(LoadHarnessSession.class))) + .thenReturn(session); + when(client.createSession(any(CreateHarnessSession.class))) + .thenThrow(conflict); + when(session.getHarnessBootId()).thenReturn(BOOT_ID); + QwenHostedHarnessConnector connector = connector(client); + + HarnessConnector.Attachment attachment = connector.createOrLoad( + "tenant-a", SESSION_ID, false); + + assertThat(attachment.bootId()).isEqualTo(BOOT_ID); + verify(client).loadSession(any(LoadHarnessSession.class)); + verify(client).createSession(any(CreateHarnessSession.class)); + } + + private static QwenHostedHarnessConnector connector( + HostedHarnessClient client) { + ManagedAgentProperties properties = new ManagedAgentProperties(); + properties.getHarness().setToken("token"); + properties.getHarness().setCapabilityDigest("sha256:" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + properties.getSessionStore().setEnabled(true); + properties.getSessionStore().setBaseUrl("https://store.example"); + properties.getSessionStore().setWorkspaceId("workspace-a"); + properties.getSessionStore().setWriterLeaseDuration( + Duration.ofSeconds(60)); + QwenHostedHarnessConnector connector = + new QwenHostedHarnessConnector(properties); + ReflectionTestUtils.setField(connector, "client", client); + return connector; + } +} diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessNewSessionRegressionTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessNewSessionRegressionTest.java new file mode 100644 index 00000000000..08b2d4dbf95 --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/harness/QwenHostedHarnessNewSessionRegressionTest.java @@ -0,0 +1,55 @@ +package com.alibaba.qwen.code.managedagent.harness; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.alibaba.qwen.code.daemon.CreateHarnessSession; +import com.alibaba.qwen.code.daemon.HarnessSessionRef; +import com.alibaba.qwen.code.daemon.HostedHarnessCapabilities; +import com.alibaba.qwen.code.daemon.HostedHarnessClient; +import com.alibaba.qwen.code.daemon.LoadHarnessSession; +import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +class QwenHostedHarnessNewSessionRegressionTest { + @Test + void createsNewDurableSessionWithoutFirstLoadingIt() { + String bootId = "11111111-1111-4111-8111-111111111111"; + String sessionId = "33333333-3333-4333-8333-333333333333"; + HostedHarnessClient client = mock(HostedHarnessClient.class); + HostedHarnessCapabilities capabilities = + mock(HostedHarnessCapabilities.class); + HarnessSessionRef session = mock(HarnessSessionRef.class); + when(client.capabilities()).thenReturn(capabilities); + when(capabilities.getBootId()).thenReturn(bootId); + when(client.loadSession(any(LoadHarnessSession.class))) + .thenThrow(new AssertionError("A new durable session must not" + + " be loaded before creation")); + when(client.createSession(any(CreateHarnessSession.class))) + .thenReturn(session); + when(session.getHarnessBootId()).thenReturn(bootId); + ManagedAgentProperties properties = new ManagedAgentProperties(); + properties.getHarness().setToken("token"); + properties.getHarness().setCapabilityDigest("sha256:" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + properties.getSessionStore().setEnabled(true); + properties.getSessionStore().setBaseUrl("https://store.example"); + properties.getSessionStore().setWorkspaceId("workspace-a"); + QwenHostedHarnessConnector connector = + new QwenHostedHarnessConnector(properties); + ReflectionTestUtils.setField(connector, "client", client); + + HarnessConnector.Attachment attachment = connector.createOrLoad( + "tenant-a", sessionId, false); + + assertThat(attachment.bootId()).isEqualTo(bootId); + verify(client).createSession(any(CreateHarnessSession.class)); + verify(client, never()).loadSession(any(LoadHarnessSession.class)); + } +} diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinatorTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinatorTest.java new file mode 100644 index 00000000000..baf145421dd --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/service/HarnessCoordinatorTest.java @@ -0,0 +1,693 @@ +package com.alibaba.qwen.code.managedagent.service; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.alibaba.qwen.code.daemon.HarnessRuntimeRecovery; +import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties; +import com.alibaba.qwen.code.managedagent.harness.HarnessConnector; +import com.alibaba.qwen.code.managedagent.harness.HarnessConnector.Admission; +import com.alibaba.qwen.code.managedagent.harness.HarnessConnector.Attachment; +import com.alibaba.qwen.code.managedagent.harness.HarnessConnector.SourceEvent; +import com.alibaba.qwen.code.managedagent.harness.HarnessConnector.SourceStream; +import com.alibaba.qwen.code.managedagent.store.AgentStateStore; +import com.alibaba.qwen.code.managedagent.store.StoreModels.HarnessEvent; +import com.alibaba.qwen.code.managedagent.store.StoreModels.SessionRecord; +import com.alibaba.qwen.code.managedagent.store.StoreModels.TurnRecord; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; + +class HarnessCoordinatorTest { + @Test + void cancelsKnownSettledRecoveredRuntimeAndStreamsCancellation() { + String tenantId = "tenant-recovery-cancel"; + String sessionId = "session-recovery-cancel"; + String turnId = "turn-recovery-cancel"; + String promptId = "11111111-1111-4111-8111-111111111111"; + SessionRecord session = new SessionRecord(tenantId, sessionId, + "qwen-code", null, "ACTIVE", "boot-old", "epoch-old", 7, + 0, 1, 1, null, 1); + TurnRecord claimed = turn(tenantId, sessionId, turnId, promptId, + "epoch-old", 7, "CANCELLING"); + TurnRecord recovered = turn(tenantId, sessionId, turnId, promptId, + "epoch-new", 4, "CANCELLING"); + + AgentStateStore store = mock(AgentStateStore.class); + HarnessConnector harness = mock(HarnessConnector.class); + RuntimeWarmer runtimeWarmer = mock(RuntimeWarmer.class); + ExecutorService executor = directExecutor(); + HarnessRuntimeRecovery recovery = mock(HarnessRuntimeRecovery.class); + when(recovery.hasUnknownOutcome()).thenReturn(false); + when(recovery.isCancellationReady()).thenReturn(true); + when(recovery.getPhase()).thenReturn("await_runtime"); + when(recovery.getCheckpointId()).thenReturn("checkpoint-1"); + when(recovery.getActivationId()).thenReturn("activation-1"); + when(runtimeWarmer.isEnabled()).thenReturn(true); + when(store.claimTurn(eq(tenantId), eq(sessionId), eq(turnId), + anyString(), any(Duration.class))) + .thenReturn(Optional.of(claimed)); + when(store.requireSession(tenantId, sessionId)).thenReturn(session); + when(harness.createOrLoad(tenantId, sessionId, true, true)) + .thenReturn(new Attachment("boot-new", recovery, 2L, + "epoch-new")); + when(store.bindRecoveredHarness(eq(tenantId), eq(sessionId), + eq(turnId), anyString(), eq("boot-old"), eq("boot-new"))) + .thenReturn(true); + when(store.findTurn(tenantId, sessionId, turnId)) + .thenReturn(Optional.of(claimed), Optional.of(recovered)); + when(harness.cancelManagedRuntime(tenantId, sessionId, promptId, + "checkpoint-1", "activation-1")) + .thenReturn(new Admission(4, "epoch-new")); + when(harness.stream(tenantId, sessionId, 4, "epoch-new")) + .thenReturn(cancelledStream(promptId)); + + HarnessCoordinator coordinator = new HarnessCoordinator(store, + harness, new HarnessEventProjector(), runtimeWarmer, executor, + Clock.systemUTC(), new ManagedAgentProperties()); + try { + coordinator.dispatch(tenantId, sessionId, turnId); + } finally { + coordinator.close(); + } + + verify(harness).cancelManagedRuntime(tenantId, sessionId, promptId, + "checkpoint-1", "activation-1"); + verify(harness, never()).continueManagedRuntime(anyString(), + anyString(), anyString(), anyString(), anyString()); + verify(harness, never()).cancel(anyString(), anyString()); + verify(runtimeWarmer, never()).warm(anyString()); + verify(store, never()).appendPublicEventIfAbsent(eq(tenantId), + eq(sessionId), eq(turnId), eq("environment.provisioning"), + any(), eq(false), anyString()); + InOrder recoveryOrder = inOrder(store, harness); + recoveryOrder.verify(store).recordRecoveryAdmission(eq(tenantId), + eq(sessionId), eq(turnId), anyString(), eq("epoch-old"), + eq("epoch-new"), eq(2L)); + recoveryOrder.verify(harness).cancelManagedRuntime(tenantId, + sessionId, promptId, "checkpoint-1", "activation-1"); + verify(store).recordHarnessEvents(eq(tenantId), eq(sessionId), + eq(turnId), anyString(), eq("epoch-new"), + argThat(events -> { + HarnessEvent event = events.get(0); + return event.projection() != null + && "turn.cancelled".equals( + event.projection().type()) + && "CANCELLED".equals( + event.projection().terminalStatus()); + })); + verify(store, never()).failTurn(anyString(), anyString(), anyString(), + anyString(), anyString(), anyString()); + } + + @Test + void cancelsInitialResultsReadyRecoveryAndStreamsCancellation() { + String tenantId = "tenant-results-ready-cancel"; + String sessionId = "session-results-ready-cancel"; + String turnId = "turn-results-ready-cancel"; + String promptId = "11111111-1111-4111-8111-111111111111"; + SessionRecord session = new SessionRecord(tenantId, sessionId, + "qwen-code", null, "ACTIVE", "boot-old", null, 0, + 0, 1, 1, null, 1); + TurnRecord claimed = turn(tenantId, sessionId, turnId, promptId, + null, 0, "CANCELLING"); + TurnRecord recovered = turn(tenantId, sessionId, turnId, promptId, + "epoch-new", 4, "CANCELLING"); + + AgentStateStore store = mock(AgentStateStore.class); + HarnessConnector harness = mock(HarnessConnector.class); + RuntimeWarmer runtimeWarmer = mock(RuntimeWarmer.class); + ExecutorService executor = directExecutor(); + HarnessRuntimeRecovery recovery = mock(HarnessRuntimeRecovery.class); + when(recovery.hasUnknownOutcome()).thenReturn(false); + when(recovery.isCancellationReady()).thenReturn(true); + when(recovery.getPhase()).thenReturn("results_ready"); + when(recovery.getCheckpointId()).thenReturn("checkpoint-1"); + when(recovery.getActivationId()).thenReturn("activation-1"); + when(runtimeWarmer.isEnabled()).thenReturn(false); + when(store.claimTurn(eq(tenantId), eq(sessionId), eq(turnId), + anyString(), any(Duration.class))) + .thenReturn(Optional.of(claimed)); + when(store.requireSession(tenantId, sessionId)).thenReturn(session); + when(harness.createOrLoad(tenantId, sessionId, true, true)) + .thenReturn(new Attachment("boot-new", recovery, 2L, + "epoch-new")); + when(store.bindRecoveredHarness(eq(tenantId), eq(sessionId), + eq(turnId), anyString(), eq("boot-old"), eq("boot-new"))) + .thenReturn(true); + when(store.findTurn(tenantId, sessionId, turnId)) + .thenReturn(Optional.of(claimed), Optional.of(recovered)); + when(harness.cancelManagedRuntime(tenantId, sessionId, promptId, + "checkpoint-1", "activation-1")) + .thenReturn(new Admission(4, "epoch-new")); + when(harness.stream(tenantId, sessionId, 4, "epoch-new")) + .thenReturn(cancelledStream(promptId)); + + HarnessCoordinator coordinator = new HarnessCoordinator(store, + harness, new HarnessEventProjector(), runtimeWarmer, executor, + Clock.systemUTC(), new ManagedAgentProperties()); + try { + coordinator.dispatch(tenantId, sessionId, turnId); + } finally { + coordinator.close(); + } + + verify(store).recordRecoveryAdmission(eq(tenantId), eq(sessionId), + eq(turnId), anyString(), eq(null), eq("epoch-new"), eq(2L)); + verify(harness).cancelManagedRuntime(tenantId, sessionId, promptId, + "checkpoint-1", "activation-1"); + verify(harness, never()).continueManagedRuntime(anyString(), + anyString(), anyString(), anyString(), anyString()); + verify(harness).stream(tenantId, sessionId, 4, "epoch-new"); + verify(store).recordHarnessEvents(eq(tenantId), eq(sessionId), + eq(turnId), anyString(), eq("epoch-new"), + argThat(events -> "turn.cancelled".equals( + events.get(0).projection().type()))); + } + + @Test + void cancelsSameEpochInitialResultsReadyFromStoredPreOperationCursor() { + String tenantId = "tenant-results-ready-same-epoch"; + String sessionId = "session-results-ready-same-epoch"; + String turnId = "turn-results-ready-same-epoch"; + String promptId = "11111111-1111-4111-8111-111111111111"; + SessionRecord session = new SessionRecord(tenantId, sessionId, + "qwen-code", null, "ACTIVE", "boot-new", "epoch-new", 2, + 0, 1, 1, null, 1); + TurnRecord claimed = turn(tenantId, sessionId, turnId, promptId, + "epoch-new", 2, "CANCELLING"); + + AgentStateStore store = mock(AgentStateStore.class); + HarnessConnector harness = mock(HarnessConnector.class); + RuntimeWarmer runtimeWarmer = mock(RuntimeWarmer.class); + ExecutorService executor = directExecutor(); + HarnessRuntimeRecovery recovery = mock(HarnessRuntimeRecovery.class); + when(recovery.hasUnknownOutcome()).thenReturn(false); + when(recovery.isCancellationReady()).thenReturn(true); + when(recovery.getPhase()).thenReturn("results_ready"); + when(recovery.getCheckpointId()).thenReturn("checkpoint-1"); + when(recovery.getActivationId()).thenReturn("activation-1"); + when(runtimeWarmer.isEnabled()).thenReturn(false); + when(store.claimTurn(eq(tenantId), eq(sessionId), eq(turnId), + anyString(), any(Duration.class))) + .thenReturn(Optional.of(claimed)); + when(store.requireSession(tenantId, sessionId)).thenReturn(session); + when(harness.createOrLoad(tenantId, sessionId, true, true)) + .thenReturn(new Attachment("boot-new", recovery, 5L, + "epoch-new")); + when(store.bindRecoveredHarness(eq(tenantId), eq(sessionId), + eq(turnId), anyString(), eq("boot-new"), eq("boot-new"))) + .thenReturn(true); + when(store.findTurn(tenantId, sessionId, turnId)) + .thenReturn(Optional.of(claimed)); + when(harness.cancelManagedRuntime(tenantId, sessionId, promptId, + "checkpoint-1", "activation-1")) + .thenReturn(new Admission(2, "epoch-new")); + when(harness.stream(tenantId, sessionId, 2, "epoch-new")) + .thenReturn(cancelledStream(promptId)); + + HarnessCoordinator coordinator = new HarnessCoordinator(store, + harness, new HarnessEventProjector(), runtimeWarmer, executor, + Clock.systemUTC(), new ManagedAgentProperties()); + try { + coordinator.dispatch(tenantId, sessionId, turnId); + } finally { + coordinator.close(); + } + + verify(harness).cancelManagedRuntime(tenantId, sessionId, promptId, + "checkpoint-1", "activation-1"); + verify(store, never()).recordRecoveryAdmission(anyString(), anyString(), + anyString(), anyString(), anyString(), anyString(), anyLong()); + verify(harness).stream(tenantId, sessionId, 2, "epoch-new"); + verify(store).recordHarnessEvents(eq(tenantId), eq(sessionId), + eq(turnId), anyString(), eq("epoch-new"), + argThat(events -> "turn.cancelled".equals( + events.get(0).projection().type()))); + } + + @Test + void blocksUnknownRecoveredRuntimeCancellationWithoutForgingTerminal() { + String tenantId = "tenant-recovery-unknown"; + String sessionId = "session-recovery-unknown"; + String turnId = "turn-recovery-unknown"; + String promptId = "11111111-1111-4111-8111-111111111111"; + SessionRecord session = new SessionRecord(tenantId, sessionId, + "qwen-code", null, "ACTIVE", "boot-old", "epoch-old", 7, + 0, 1, 1, null, 1); + TurnRecord claimed = turn(tenantId, sessionId, turnId, promptId, + "epoch-old", 7, "CANCELLING"); + + AgentStateStore store = mock(AgentStateStore.class); + HarnessConnector harness = mock(HarnessConnector.class); + RuntimeWarmer runtimeWarmer = mock(RuntimeWarmer.class); + ExecutorService executor = directExecutor(); + HarnessRuntimeRecovery recovery = mock(HarnessRuntimeRecovery.class); + when(recovery.hasUnknownOutcome()).thenReturn(true); + when(runtimeWarmer.isEnabled()).thenReturn(false); + when(store.claimTurn(eq(tenantId), eq(sessionId), eq(turnId), + anyString(), any(Duration.class))) + .thenReturn(Optional.of(claimed)); + when(store.requireSession(tenantId, sessionId)).thenReturn(session); + when(harness.createOrLoad(tenantId, sessionId, true, true)) + .thenReturn(new Attachment("boot-new", recovery, 2L, + "epoch-new")); + + HarnessCoordinator coordinator = new HarnessCoordinator(store, + harness, new HarnessEventProjector(), runtimeWarmer, executor, + Clock.systemUTC(), new ManagedAgentProperties()); + try { + coordinator.dispatch(tenantId, sessionId, turnId); + } finally { + coordinator.close(); + } + + verify(store).failTurn(eq(tenantId), eq(sessionId), eq(turnId), + anyString(), eq("managed_runtime_recovery_blocked"), + anyString()); + verify(harness, never()).cancelManagedRuntime(anyString(), anyString(), + anyString(), anyString(), anyString()); + verify(harness, never()).continueManagedRuntime(anyString(), + anyString(), anyString(), anyString(), anyString()); + verify(harness, never()).cancel(anyString(), anyString()); + verify(harness, never()).stream(anyString(), anyString(), anyLong(), + anyString()); + verify(store, never()).recordHarnessEvents(anyString(), anyString(), + anyString(), anyString(), anyString(), any()); + } + + @Test + void continuesRecoveredRuntimeWithoutReplayingThePrompt() { + String tenantId = "tenant-recovery"; + String sessionId = "session-recovery"; + String turnId = "turn-recovery"; + String promptId = "11111111-1111-4111-8111-111111111111"; + SessionRecord session = new SessionRecord(tenantId, sessionId, + "qwen-code", null, "ACTIVE", "boot-old", "epoch-old", 7, + 0, 1, 1, null, 1); + TurnRecord claimed = turn(tenantId, sessionId, turnId, promptId, + "epoch-old", 7); + TurnRecord recovered = turn(tenantId, sessionId, turnId, promptId, + "epoch-new", 0); + + AgentStateStore store = mock(AgentStateStore.class); + HarnessConnector harness = mock(HarnessConnector.class); + RuntimeWarmer runtimeWarmer = mock(RuntimeWarmer.class); + ExecutorService executor = directExecutor(); + HarnessRuntimeRecovery recovery = mock(HarnessRuntimeRecovery.class); + when(recovery.hasUnknownOutcome()).thenReturn(false); + when(recovery.isContinuationReady()).thenReturn(true); + when(recovery.getCheckpointId()).thenReturn("checkpoint-1"); + when(recovery.getActivationId()).thenReturn("activation-1"); + when(runtimeWarmer.isEnabled()).thenReturn(false); + when(store.claimTurn(eq(tenantId), eq(sessionId), eq(turnId), + anyString(), any(Duration.class))) + .thenReturn(Optional.of(claimed)); + when(store.requireSession(tenantId, sessionId)).thenReturn(session); + when(harness.createOrLoad(tenantId, sessionId, true)) + .thenReturn(new Attachment("boot-new", recovery, 0L, + "epoch-new")); + when(store.bindRecoveredHarness(eq(tenantId), eq(sessionId), + eq(turnId), anyString(), eq("boot-old"), eq("boot-new"))) + .thenReturn(true); + when(store.findTurn(tenantId, sessionId, turnId)) + .thenReturn(Optional.of(claimed), Optional.of(recovered)); + when(harness.continueManagedRuntime(tenantId, sessionId, promptId, + "checkpoint-1", "activation-1")) + .thenReturn(new Admission(0, "epoch-new")); + when(harness.stream(tenantId, sessionId, 0, "epoch-new")) + .thenReturn(terminalStream(promptId)); + + HarnessCoordinator coordinator = new HarnessCoordinator(store, + harness, new HarnessEventProjector(), runtimeWarmer, executor, + Clock.systemUTC(), new ManagedAgentProperties()); + try { + coordinator.dispatch(tenantId, sessionId, turnId); + } finally { + coordinator.close(); + } + + verify(harness, never()).submit(anyString(), anyString(), anyString(), + any(), anyString()); + InOrder recoveryOrder = inOrder(store, harness); + recoveryOrder.verify(store).recordRecoveryAdmission(eq(tenantId), + eq(sessionId), eq(turnId), anyString(), eq("epoch-old"), + eq("epoch-new"), eq(0L)); + recoveryOrder.verify(harness).continueManagedRuntime(tenantId, + sessionId, promptId, "checkpoint-1", "activation-1"); + recoveryOrder.verify(store).recordRecoveryAdmission(eq(tenantId), + eq(sessionId), eq(turnId), anyString(), eq("epoch-new"), + eq("epoch-new"), eq(0L)); + verify(store).recordHarnessEvents(eq(tenantId), eq(sessionId), + eq(turnId), anyString(), eq("epoch-new"), any()); + verify(store, never()).releaseTurnLease(eq(tenantId), eq(sessionId), + eq(turnId), anyString()); + verify(store).retractContinuationOutput(eq(tenantId), eq(sessionId), + eq(turnId), anyString(), eq("boot-old"), eq("epoch-old")); + } + + @Test + void retractsAdmittedContinuationTextBeforeReplacingTheStream() { + String tenantId = "tenant-recovery"; + String sessionId = "session-recovery"; + String turnId = "turn-recovery"; + String promptId = "11111111-1111-4111-8111-111111111111"; + SessionRecord session = new SessionRecord(tenantId, sessionId, + "qwen-code", null, "ACTIVE", "boot-old", "epoch-old", 7, + 0, 1, 1, null, 1); + TurnRecord claimed = turn(tenantId, sessionId, turnId, promptId, + "epoch-old", 7); + TurnRecord recovered = turn(tenantId, sessionId, turnId, promptId, + "epoch-new", 0); + + AgentStateStore store = mock(AgentStateStore.class); + HarnessConnector harness = mock(HarnessConnector.class); + RuntimeWarmer runtimeWarmer = mock(RuntimeWarmer.class); + ExecutorService executor = directExecutor(); + HarnessRuntimeRecovery recovery = mock(HarnessRuntimeRecovery.class); + when(recovery.hasUnknownOutcome()).thenReturn(false); + when(recovery.isContinuationReady()).thenReturn(true); + when(recovery.isContinuationAdmitted()).thenReturn(true); + when(recovery.getCheckpointId()).thenReturn("checkpoint-1"); + when(recovery.getActivationId()).thenReturn("activation-1"); + when(runtimeWarmer.isEnabled()).thenReturn(false); + when(store.claimTurn(eq(tenantId), eq(sessionId), eq(turnId), + anyString(), any(Duration.class))) + .thenReturn(Optional.of(claimed)); + when(store.requireSession(tenantId, sessionId)).thenReturn(session); + when(harness.createOrLoad(tenantId, sessionId, true)) + .thenReturn(new Attachment("boot-new", recovery, 0L, + "epoch-new")); + when(store.bindRecoveredHarness(eq(tenantId), eq(sessionId), + eq(turnId), anyString(), eq("boot-old"), eq("boot-new"))) + .thenReturn(true); + when(store.findTurn(tenantId, sessionId, turnId)) + .thenReturn(Optional.of(claimed), Optional.of(recovered)); + when(harness.continueManagedRuntime(tenantId, sessionId, promptId, + "checkpoint-1", "activation-1")) + .thenReturn(new Admission(0, "epoch-new")); + when(harness.stream(tenantId, sessionId, 0, "epoch-new")) + .thenReturn(terminalStream(promptId)); + + HarnessCoordinator coordinator = new HarnessCoordinator(store, + harness, new HarnessEventProjector(), runtimeWarmer, executor, + Clock.systemUTC(), new ManagedAgentProperties()); + try { + coordinator.dispatch(tenantId, sessionId, turnId); + } finally { + coordinator.close(); + } + + InOrder recoveryOrder = inOrder(store, harness); + recoveryOrder.verify(store).retractContinuationOutput(eq(tenantId), + eq(sessionId), eq(turnId), anyString(), eq("boot-old"), + eq("epoch-old")); + recoveryOrder.verify(harness).continueManagedRuntime(tenantId, + sessionId, promptId, "checkpoint-1", "activation-1"); + } + + @Test + void schedulesPersistentBackoffForTransientFailure() { + String tenantId = "tenant-retry"; + String sessionId = "session-retry"; + String turnId = "turn-retry"; + TurnRecord claimed = turn(tenantId, sessionId, turnId, + "11111111-1111-4111-8111-111111111111", null, 0, false, + 2); + AgentStateStore store = mock(AgentStateStore.class); + HarnessConnector harness = mock(HarnessConnector.class); + RuntimeWarmer runtimeWarmer = mock(RuntimeWarmer.class); + ExecutorService executor = directExecutor(); + when(store.claimTurn(eq(tenantId), eq(sessionId), eq(turnId), + anyString(), any(Duration.class))) + .thenReturn(Optional.of(claimed)); + when(runtimeWarmer.isEnabled()).thenReturn(false); + when(store.requireSession(tenantId, sessionId)) + .thenThrow(new IllegalStateException("database unavailable")); + ManagedAgentProperties properties = new ManagedAgentProperties(); + properties.getDispatch().setRetryInitialDelay(Duration.ofSeconds(2)); + properties.getDispatch().setRetryMaxDelay(Duration.ofSeconds(30)); + Clock clock = Clock.fixed(Instant.ofEpochMilli(10_000), ZoneOffset.UTC); + + HarnessCoordinator coordinator = new HarnessCoordinator(store, + harness, new HarnessEventProjector(), runtimeWarmer, executor, + clock, properties); + try { + coordinator.dispatch(tenantId, sessionId, turnId); + } finally { + coordinator.close(); + } + + verify(store).scheduleTurnRetry(eq(tenantId), eq(sessionId), + eq(turnId), anyString(), eq(18_000L)); + verify(store, never()).releaseTurnLease(eq(tenantId), eq(sessionId), + eq(turnId), anyString()); + verify(store, never()).failTurn(anyString(), anyString(), anyString(), + anyString(), anyString(), anyString()); + } + + @Test + void doesNotExhaustAfterSubmissionMayHaveBeenAdmitted() { + String tenantId = "tenant-retry-submitted"; + String sessionId = "session-retry-submitted"; + String turnId = "turn-retry-submitted"; + String promptId = "11111111-1111-4111-8111-111111111111"; + SessionRecord session = new SessionRecord(tenantId, sessionId, + "qwen-code", null, "ACTIVE", null, null, 0, + 0, 1, 1, null, 1); + TurnRecord claimed = turn(tenantId, sessionId, turnId, promptId, + null, 0, false, 5); + AgentStateStore store = mock(AgentStateStore.class); + HarnessConnector harness = mock(HarnessConnector.class); + RuntimeWarmer runtimeWarmer = mock(RuntimeWarmer.class); + ExecutorService executor = directExecutor(); + when(store.claimTurn(eq(tenantId), eq(sessionId), eq(turnId), + anyString(), any(Duration.class))) + .thenReturn(Optional.of(claimed)); + when(store.requireSession(tenantId, sessionId)).thenReturn(session); + when(store.bindHarness(eq(tenantId), eq(sessionId), eq(turnId), + anyString(), eq("boot-new"))).thenReturn(true); + when(store.findTurn(tenantId, sessionId, turnId)) + .thenReturn(Optional.of(claimed)); + when(harness.createOrLoad(tenantId, sessionId, false)) + .thenReturn(new Attachment("boot-new", null, null, null)); + when(harness.submit(eq(tenantId), eq(sessionId), eq(promptId), any(), + anyString())).thenThrow( + new IllegalStateException("response lost")); + when(runtimeWarmer.isEnabled()).thenReturn(false); + + HarnessCoordinator coordinator = new HarnessCoordinator(store, + harness, new HarnessEventProjector(), runtimeWarmer, executor, + Clock.systemUTC(), new ManagedAgentProperties()); + try { + coordinator.dispatch(tenantId, sessionId, turnId); + } finally { + coordinator.close(); + } + + verify(store).markSubmissionAttempted(eq(tenantId), eq(sessionId), + eq(turnId), anyString()); + verify(store).scheduleTurnRetry(eq(tenantId), eq(sessionId), + eq(turnId), anyString(), anyLong()); + verify(store, never()).failTurn(anyString(), anyString(), anyString(), + anyString(), anyString(), anyString()); + } + + @Test + void failsPreAdmissionTurnAfterRetryBudgetIsExhausted() { + String tenantId = "tenant-retry"; + String sessionId = "session-retry"; + String turnId = "turn-retry"; + TurnRecord claimed = turn(tenantId, sessionId, turnId, + "11111111-1111-4111-8111-111111111111", null, 0, false, + 5); + AgentStateStore store = mock(AgentStateStore.class); + HarnessConnector harness = mock(HarnessConnector.class); + RuntimeWarmer runtimeWarmer = mock(RuntimeWarmer.class); + ExecutorService executor = directExecutor(); + when(store.claimTurn(eq(tenantId), eq(sessionId), eq(turnId), + anyString(), any(Duration.class))) + .thenReturn(Optional.of(claimed)); + when(runtimeWarmer.isEnabled()).thenReturn(false); + when(store.requireSession(tenantId, sessionId)) + .thenThrow(new IllegalStateException("database unavailable")); + + HarnessCoordinator coordinator = new HarnessCoordinator(store, + harness, new HarnessEventProjector(), runtimeWarmer, executor, + Clock.systemUTC(), new ManagedAgentProperties()); + try { + coordinator.dispatch(tenantId, sessionId, turnId); + } finally { + coordinator.close(); + } + + verify(store).failTurn(eq(tenantId), eq(sessionId), eq(turnId), + anyString(), eq("hosted_harness_unavailable"), anyString()); + verify(store, never()).scheduleTurnRetry(anyString(), anyString(), + anyString(), anyString(), anyLong()); + verify(store, never()).releaseTurnLease(eq(tenantId), eq(sessionId), + eq(turnId), anyString()); + } + + @Test + void rejectsRecoveredRuntimeWithoutAnEventWatermark() { + String tenantId = "tenant-recovery"; + String sessionId = "session-recovery"; + String turnId = "turn-recovery"; + String promptId = "11111111-1111-4111-8111-111111111111"; + SessionRecord session = new SessionRecord(tenantId, sessionId, + "qwen-code", null, "ACTIVE", "boot-old", "epoch-old", 7, + 0, 1, 1, null, 1); + TurnRecord claimed = turn(tenantId, sessionId, turnId, promptId, + "epoch-old", 7); + + AgentStateStore store = mock(AgentStateStore.class); + HarnessConnector harness = mock(HarnessConnector.class); + RuntimeWarmer runtimeWarmer = mock(RuntimeWarmer.class); + ExecutorService executor = directExecutor(); + HarnessRuntimeRecovery recovery = mock(HarnessRuntimeRecovery.class); + when(recovery.hasUnknownOutcome()).thenReturn(false); + when(recovery.isContinuationReady()).thenReturn(true); + when(runtimeWarmer.isEnabled()).thenReturn(false); + when(store.claimTurn(eq(tenantId), eq(sessionId), eq(turnId), + anyString(), any(Duration.class))) + .thenReturn(Optional.of(claimed)); + when(store.requireSession(tenantId, sessionId)).thenReturn(session); + when(harness.createOrLoad(tenantId, sessionId, true)) + .thenReturn(new Attachment("boot-new", recovery)); + + HarnessCoordinator coordinator = new HarnessCoordinator(store, + harness, new HarnessEventProjector(), runtimeWarmer, executor, + Clock.systemUTC(), new ManagedAgentProperties()); + try { + coordinator.dispatch(tenantId, sessionId, turnId); + } finally { + coordinator.close(); + } + + verify(store).failTurn(eq(tenantId), eq(sessionId), eq(turnId), + anyString(), eq("managed_runtime_recovery_watermark_missing"), + anyString()); + verify(store, never()).bindRecoveredHarness(anyString(), anyString(), + anyString(), anyString(), anyString(), anyString()); + verify(harness, never()).continueManagedRuntime(anyString(), + anyString(), anyString(), anyString(), anyString()); + verify(harness, never()).submit(anyString(), anyString(), anyString(), + any(), anyString()); + } + + private static TurnRecord turn(String tenantId, String sessionId, + String turnId, String promptId, String eventEpoch, + long lastEventId) { + return turn(tenantId, sessionId, turnId, promptId, eventEpoch, + lastEventId, true, 0); + } + + private static TurnRecord turn(String tenantId, String sessionId, + String turnId, String promptId, String eventEpoch, + long lastEventId, boolean submissionAttempted, int retryCount) { + return turn(tenantId, sessionId, turnId, promptId, eventEpoch, + lastEventId, "RUNNING", submissionAttempted, retryCount); + } + + private static TurnRecord turn(String tenantId, String sessionId, + String turnId, String promptId, String eventEpoch, + long lastEventId, String status) { + return turn(tenantId, sessionId, turnId, promptId, eventEpoch, + lastEventId, status, true, 0); + } + + private static TurnRecord turn(String tenantId, String sessionId, + String turnId, String promptId, String eventEpoch, + long lastEventId, String status, boolean submissionAttempted, + int retryCount) { + return new TurnRecord(tenantId, sessionId, turnId, promptId, + List.of(Map.of("type", "text", "text", "recover")), + "sha256:" + "a".repeat(64), status, submissionAttempted, + eventEpoch, lastEventId, "previous-owner", Long.MAX_VALUE, + retryCount, null, null, null, 1, 1, null, 1); + } + + private static SourceStream cancelledStream(String promptId) { + return new SourceStream() { + private boolean emitted; + + @Override + public String eventEpoch() { + return "epoch-new"; + } + + @Override + public SourceEvent next() { + if (emitted) { + return null; + } + emitted = true; + return new SourceEvent(5L, "turn_complete", + Map.of("stopReason", "cancelled"), promptId, + Map.of()); + } + + @Override + public void close() { + } + }; + } + + private static SourceStream terminalStream(String promptId) { + return new SourceStream() { + private boolean emitted; + + @Override + public String eventEpoch() { + return "epoch-new"; + } + + @Override + public SourceEvent next() { + if (emitted) { + return null; + } + emitted = true; + return new SourceEvent(1L, "turn_complete", + Map.of("stopReason", "end_turn"), promptId, + Map.of()); + } + + @Override + public void close() { + } + }; + } + + private static ExecutorService directExecutor() { + ExecutorService executor = mock(ExecutorService.class); + Future future = mock(Future.class); + doAnswer(invocation -> { + ((Runnable) invocation.getArgument(0)).run(); + return null; + }).when(executor).execute(any(Runnable.class)); + doAnswer(invocation -> { + ((Runnable) invocation.getArgument(0)).run(); + return future; + }).when(executor).submit(any(Runnable.class)); + return executor; + } +} diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/service/ManagedEventStreamServiceTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/service/ManagedEventStreamServiceTest.java new file mode 100644 index 00000000000..b27431e4822 --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/service/ManagedEventStreamServiceTest.java @@ -0,0 +1,106 @@ +package com.alibaba.qwen.code.managedagent.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.alibaba.qwen.code.managedagent.api.ApiModels.WebShellEvent; +import com.alibaba.qwen.code.managedagent.config.ManagedAgentProperties; +import com.alibaba.qwen.code.managedagent.store.StoreModels.EventRecord; +import java.io.IOException; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.Test; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +class ManagedEventStreamServiceTest { + @Test + void pushesCommittedEventsWithoutWaitingForReconciliation() + throws Exception { + ManagedAgentService agentService = mock(ManagedAgentService.class); + CountDownLatch initialRead = new CountDownLatch(1); + when(agentService.webShellEvents("tenant", "session", 0, 100)) + .thenAnswer(ignored -> { + initialRead.countDown(); + return List.of(); + }); + EventRecord record = new EventRecord("tenant", "session", 1, + "event-1", "turn-1", "item.output_text.delta", + java.util.Map.of("text", "hello"), false, "source-1", 1); + WebShellEvent webEvent = new WebShellEvent(1, "event-1", "session", + "turn-1", "item.output_text.delta", 1, + java.util.Map.of("text", "hello"), false); + when(agentService.webShellEvent(record)).thenReturn(webEvent); + ManagedAgentProperties properties = new ManagedAgentProperties(); + properties.getEvents().setPollInterval(Duration.ofSeconds(5)); + properties.getEvents().setHeartbeatInterval(Duration.ofSeconds(30)); + SessionEventHub eventHub = new SessionEventHub(); + DisconnectingEmitter emitter = new DisconnectingEmitter(); + ExecutorService executor = Executors.newSingleThreadExecutor(); + ManagedEventStreamService service = new ManagedEventStreamService( + agentService, eventHub, executor, properties) { + @Override + SseEmitter emitter() { + return emitter; + } + }; + + service.webShellStream("tenant", "session", 0); + assertThat(initialRead.await(1, TimeUnit.SECONDS)).isTrue(); + eventHub.publish(List.of(record)); + + assertThat(emitter.sendAttempt.await(1, TimeUnit.SECONDS)).isTrue(); + verify(agentService, times(1)).webShellEvents( + "tenant", "session", 0, 100); + executor.shutdown(); + assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue(); + } + + @Test + void treatsClientDisconnectAsACompletedStream() throws Exception { + ManagedAgentService agentService = mock(ManagedAgentService.class); + when(agentService.webShellEvents("tenant", "session", 0, 100)) + .thenReturn(List.of()); + ManagedAgentProperties properties = new ManagedAgentProperties(); + properties.getEvents().setHeartbeatInterval(Duration.ZERO); + DisconnectingEmitter emitter = new DisconnectingEmitter(); + ExecutorService executor = Executors.newSingleThreadExecutor(); + ManagedEventStreamService service = new ManagedEventStreamService( + agentService, new SessionEventHub(), executor, properties) { + @Override + SseEmitter emitter() { + return emitter; + } + }; + + service.webShellStream("tenant", "session", 0); + + assertThat(emitter.sendAttempt.await(5, TimeUnit.SECONDS)).isTrue(); + executor.shutdown(); + assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue(); + assertThat(emitter.completedWithError).isFalse(); + } + + private static final class DisconnectingEmitter extends SseEmitter { + private final CountDownLatch sendAttempt = new CountDownLatch(1); + private final AtomicBoolean completedWithError = new AtomicBoolean(); + + @Override + public void send(SseEventBuilder builder) throws IOException { + sendAttempt.countDown(); + throw new IOException("client disconnected"); + } + + @Override + public void completeWithError(Throwable error) { + completedWithError.set(true); + } + } +} diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/service/SessionEventHubTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/service/SessionEventHubTest.java new file mode 100644 index 00000000000..db95970788a --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/service/SessionEventHubTest.java @@ -0,0 +1,49 @@ +package com.alibaba.qwen.code.managedagent.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.alibaba.qwen.code.managedagent.store.StoreModels.EventRecord; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class SessionEventHubTest { + @Test + void returnsCommittedEventsInSequenceOrder() throws Exception { + SessionEventHub hub = new SessionEventHub(); + try (SessionEventHub.Subscription subscription = hub.subscribe( + "tenant", "session")) { + hub.publish(List.of(event(2))); + hub.publish(List.of(event(1))); + + SessionEventHub.Delivery delivery = subscription.await(0, + Duration.ofMillis(10)); + + assertThat(delivery.overflowed()).isFalse(); + assertThat(delivery.events()).extracting(EventRecord::sequence) + .containsExactly(1L, 2L); + } + } + + @Test + void requestsDurableReplayWhenASequenceIsMissing() throws Exception { + SessionEventHub hub = new SessionEventHub(); + try (SessionEventHub.Subscription subscription = hub.subscribe( + "tenant", "session")) { + hub.publish(List.of(event(2))); + + SessionEventHub.Delivery delivery = subscription.await(0, + Duration.ofMillis(10)); + + assertThat(delivery.overflowed()).isTrue(); + assertThat(delivery.events()).isEmpty(); + } + } + + private static EventRecord event(long sequence) { + return new EventRecord("tenant", "session", sequence, + "event-" + sequence, "turn", "type", Map.of(), false, + "source-" + sequence, 1); + } +} diff --git a/packages/sdk-java/runtime-broker/QWEN.md b/packages/sdk-java/runtime-broker/QWEN.md index 3a412f9b2d0..e906f63a851 100644 --- a/packages/sdk-java/runtime-broker/QWEN.md +++ b/packages/sdk-java/runtime-broker/QWEN.md @@ -25,3 +25,32 @@ Build and test with: mvn test mvn checkstyle:check ``` + +## Workspace binding package + +Keep `com.alibaba.qwen.code.runtimebroker.managedworkspace` on the JDK alone: +no other Broker class, Spring, CLI internals or scheduler. + +Treat every caller-supplied Workspace ID and directory as untrusted input. +Resolution reports one typed error, never lets an explicit selection fall +back to the default Workspace, never falls back to a launch directory, and +never reveals whether another tenant's Workspace exists. Keep missing, foreign and unreadable Workspaces on one throw site, +and have API adapters return only the status, code and message of a +`WorkspaceException`, never its stack trace. The working-directory rule is +lexical; do not add filesystem checks there, because the Runtime verifies the +directory where the files live. + +A decoder that builds a `ContextBinding` from text must accept only the ASCII +form `[1-9][0-9]*`, at most 2^63−1, for the generation and the revision, as +the TypeScript implementation does. `Long.parseLong` alone also accepts a +sign, leading zeros and non-ASCII digits. + +A change to the directory rule or to the `ContextBinding` encoding must +update, in the same change, the shared fixtures in +`packages/cli/src/serve/contracts/managed-workspace-binding-v1.fixtures.json` +and `packages/cli/src/serve/managed-workspace-binding.ts`, so both languages +keep producing the same bytes. The installation requests and receipts in +`packages/cli/src/serve/contracts/managed-context-v1.fixtures.json` carry +context digests computed with the same encoding, and +`ManagedContextEnvelopeConformanceTest` recomputes them, so update them too. +Compute new expected values with an implementation independent of both. diff --git a/packages/sdk-java/runtime-broker/README.md b/packages/sdk-java/runtime-broker/README.md index 6da6c2aed86..6ff7c6edd61 100644 --- a/packages/sdk-java/runtime-broker/README.md +++ b/packages/sdk-java/runtime-broker/README.md @@ -72,3 +72,25 @@ Durable rows alone do not make a stopped local Runtime process recoverable. For a binding without durable identity the embedding service must reconcile a persisted lease before reuse and own the process adoption or reprovisioning policy; a durable binding is reconciled and adopted by the Broker itself. + +## Workspace binding + +The `com.alibaba.qwen.code.runtimebroker.managedworkspace` package holds the +W0a Workspace binding contract; see +[Managed Workspace Binding Contract](../../../docs/design/2026-09-25-managed-workspace-binding-contract.md). +It defines the Workspace Registry record and an immutable snapshot built from +deployment configuration, actor-scoped access with an explicit-grant policy, +a catalog that lists Workspaces and resolves a Session's Workspace selection +to one resolved Workspace or one typed error, the lexical rule for a +Session's working directory, and `ContextBinding` with its `contextDigest`. +The TypeScript implementation in +`packages/cli/src/serve/managed-workspace-binding.ts` produces the same +normalized directories and digests; both run the shared fixtures in +`packages/cli/src/serve/contracts/managed-workspace-binding-v1.fixtures.json`. +The fixtures of the `managed-context/1` envelope, +`packages/cli/src/serve/contracts/managed-context-v1.fixtures.json`, carry +context digests computed with the same encoding, and +`ManagedContextEnvelopeConformanceTest` recomputes them; see +[Managed Context Envelope](../../../docs/design/2026-09-25-managed-context-envelope.md). +The package uses only the JDK and no other Broker class, and nothing wires +it into the Broker service yet. diff --git a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/HttpRuntimeTransport.java b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/HttpRuntimeTransport.java index 5fb534987dd..654b128cbd2 100644 --- a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/HttpRuntimeTransport.java +++ b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/HttpRuntimeTransport.java @@ -25,10 +25,11 @@ * HTTP client for the private Managed Runtime v2 routes. * *

Attestation plus the tool operations execute, status, and cancel, keyed - * by the original call reference. Prepare, acquire, control, and release stay - * on a later transport slice. + * by the original call reference. Status and cancel answers are projected to + * the Broker's closed state and result. Acquire, control, and release are + * not part of the v2 tool contract and fail closed. */ -public final class HttpRuntimeTransport { +public final class HttpRuntimeTransport implements RuntimeTransport { static final int BODY_LIMIT_BYTES = 16 * 1024; static final int TOOL_REQUEST_LIMIT_BYTES = 256 * 1024; static final int TOOL_RESULT_LIMIT_BYTES = 1024 * 1024; @@ -194,7 +195,8 @@ public CompletionStage> status(RuntimeLease lease, body.put("afterSequence", afterSequence); byte[] encoded = encodeToolRequest(body, BODY_LIMIT_BYTES); return post(lease, STATUS_PATH, encoded, TOOL_RESULT_LIMIT_BYTES) - .thenApply(bytes -> parseToolResponse(bytes, "status")); + .thenApply(bytes -> projectClosedStatus( + parseToolResponse(bytes, "status"), "status")); } /** Asks the Runtime to cancel one call by its original reference. */ @@ -209,7 +211,62 @@ public CompletionStage> cancel(RuntimeLease lease, body.put("reference", referenceIdentity(reference)); byte[] encoded = encodeToolRequest(body, BODY_LIMIT_BYTES); return post(lease, CANCEL_PATH, encoded, TOOL_RESULT_LIMIT_BYTES) - .thenApply(bytes -> parseToolResponse(bytes, "cancel")); + .thenApply(bytes -> projectClosedStatus( + parseToolResponse(bytes, "cancel"), "cancel")); + } + + /** + * Session verbs are not on the v2 tool contract. Fail closed instead of + * inventing a route the worker does not serve. + */ + @Override + public CompletionStage acquire(RuntimeLease lease, + RuntimeSession session) { + return unsupportedSessionVerb(); + } + + @Override + public CompletionStage control(RuntimeLease lease, + RuntimeSession session, Map operation) { + return unsupportedSessionVerb(); + } + + @Override + public CompletionStage release(RuntimeLease lease, + RuntimeSession session) { + return unsupportedSessionVerb(); + } + + private static CompletionStage unsupportedSessionVerb() { + return CompletableFuture.failedFuture(new RuntimeBrokerException(501, + "runtime_session_verb_unsupported", + "Runtime transport does not support session verbs.", false)); + } + + /** + * The Broker accepts only {@code state}, plus {@code result} when the + * state is {@code settled}. Wire fields such as {@code protocolVersion} + * and {@code lastSequence} stay on the HTTP response and are validated + * before this projection. + */ + private static Map projectClosedStatus( + Map response, String operation) { + Object state = response.get("state"); + if (!(state instanceof String text) || !TOOL_STATES.contains(text)) { + throw protocol("Managed Runtime " + operation + + " returned an invalid state."); + } + boolean settled = "settled".equals(text); + if (settled != response.containsKey("result")) { + throw protocol("Managed Runtime " + operation + + " result does not match its state."); + } + Map projected = new LinkedHashMap<>(); + projected.put("state", text); + if (settled) { + projected.put("result", response.get("result")); + } + return Map.copyOf(projected); } private static Map referenceIdentity( diff --git a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/LocalProcessRuntimeProvisioner.java b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/LocalProcessRuntimeProvisioner.java index a55fec10bbe..96223f57b2a 100644 --- a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/LocalProcessRuntimeProvisioner.java +++ b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/LocalProcessRuntimeProvisioner.java @@ -12,6 +12,7 @@ import java.util.Base64; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; @@ -29,6 +30,7 @@ */ public final class LocalProcessRuntimeProvisioner implements RuntimeProvisioner { + static final String KIND = "local-process"; private static final Duration READY_TIMEOUT = Duration.ofSeconds(30); private static final int READY_RECORD_LIMIT = 32 * 1024; private static final Pattern CAPABILITY_DIGEST = @@ -44,8 +46,9 @@ public final class LocalProcessRuntimeProvisioner thread.setDaemon(true); return thread; }); - private final ConcurrentMap owned = + private final ConcurrentMap, OwnedProcess> owned = new ConcurrentHashMap<>(); + private final Set> issued = ConcurrentHashMap.newKeySet(); public LocalProcessRuntimeProvisioner(List command, Path workingDirectory, HttpRuntimeTransport transport) { @@ -59,10 +62,55 @@ public LocalProcessRuntimeProvisioner(List command, this.transport = transport; } + @Override + public String kind() { + return KIND; + } + @Override public CompletionStage provision( RuntimeProvisionRequest request) { - return CompletableFuture.supplyAsync(() -> start(request), executor); + return CompletableFuture.supplyAsync(() -> start(request, null), + executor); + } + + @Override + public CompletionStage provision( + RuntimeProvisionRequest request, RuntimeProvisionSeed seed) { + if (seed == null) { + throw new IllegalArgumentException("seed is required"); + } + return CompletableFuture.supplyAsync(() -> start(request, seed), + executor); + } + + @Override + public CompletionStage ensureResource( + RuntimeProvisionRequest request, RuntimeProvisionSeed seed, + RuntimeResourceHandle knownHandle) { + if (knownHandle != null) { + if (!KIND.equals(knownHandle.getKind()) + || knownHandle.getVersion() != 1) { + CompletableFuture failed = + new CompletableFuture<>(); + failed.completeExceptionally(new RuntimeBrokerException(409, + "runtime_broker_resource_conflict", + "Managed Runtime resource identity conflicts.", + false)); + return failed; + } + return CompletableFuture.completedFuture(knownHandle); + } + return CompletableFuture.completedFuture(new RuntimeResourceHandle( + KIND, 1, Map.of("provider", KIND))); + } + + @Override + public CompletionStage reconcile( + RuntimeProvisionRequest request, RuntimeProvisionSeed seed, + RuntimeResourceHandle handle, RuntimeLease lastLease) { + return CompletableFuture.supplyAsync( + () -> observe(request, seed, handle, lastLease), executor); } @Override @@ -81,12 +129,12 @@ public CompletionStage release(RuntimeProvisionRequest request, @Override public boolean isUsable(RuntimeLease lease) { - OwnedProcess process = owned.get(lease.getRuntimeInstanceId()); + OwnedProcess process = owned.get(ownershipKey(lease)); return process != null && process.process.isAlive(); } void stop(RuntimeLease lease) { - OwnedProcess process = owned.remove(lease.getRuntimeInstanceId()); + OwnedProcess process = owned.remove(ownershipKey(lease)); if (process != null) { process.process.destroy(); } @@ -101,18 +149,19 @@ public void close() { executor.shutdownNow(); } - private RuntimeLease start(RuntimeProvisionRequest request) { + private RuntimeLease start(RuntimeProvisionRequest request, + RuntimeProvisionSeed provided) { OwnedProcess ownedProcess = null; boolean adopted = false; try { - String runtimeInstanceId = UUID.randomUUID().toString(); - String runtimeIncarnation = UUID.randomUUID().toString(); - String leaseId = UUID.randomUUID().toString(); - String provisionRequestId = UUID.randomUUID().toString(); - byte[] tokenBytes = new byte[32]; - RANDOM.nextBytes(tokenBytes); - String token = Base64.getUrlEncoder().withoutPadding() - .encodeToString(tokenBytes); + RuntimeProvisionSeed seed = provided != null + ? provided : newSeed(); + String runtimeInstanceId = seed.getProvisionalRuntimeId(); + String runtimeIncarnation = seed.getGatewayIncarnation(); + String leaseId = seed.getLeaseId(); + String provisionRequestId = seed.getProvisionRequestId(); + String token = seed.getToken(); + long epoch = seed.getEpoch(); RuntimeScope scope = request.getScope(); if (!CAPABILITY_DIGEST.matcher(scope.getCapabilityDigest()) .matches()) { @@ -122,7 +171,7 @@ private RuntimeLease start(RuntimeProvisionRequest request) { } JSONObject boot = new JSONObject(); boot.put("capabilityDigest", scope.getCapabilityDigest()); - boot.put("epoch", 1); + boot.put("epoch", epoch); boot.put("isolationClass", scope.getIsolationClass()); boot.put("leaseId", leaseId); boot.put("provisionRequestId", provisionRequestId); @@ -139,10 +188,7 @@ private RuntimeLease start(RuntimeProvisionRequest request) { .directory(workingDirectory.toFile()) .redirectError(ProcessBuilder.Redirect.DISCARD) .start(); - ownedProcess = new OwnedProcess(process, - new RuntimeProvisionSeed(provisionRequestId, - runtimeInstanceId, runtimeIncarnation, leaseId, - 1, token)); + ownedProcess = new OwnedProcess(process, seed); process.getOutputStream().write(boot.toJSONString() .getBytes(StandardCharsets.UTF_8)); process.getOutputStream().close(); @@ -157,7 +203,7 @@ private RuntimeLease start(RuntimeProvisionRequest request) { || !runtimeIncarnation.equals( ready.get("runtimeIncarnation")) || !leaseId.equals(ready.get("leaseId")) - || !Long.valueOf(1L).equals(number(ready.get("epoch")))) { + || !Long.valueOf(epoch).equals(number(ready.get("epoch")))) { throw failed("Managed Runtime ready record is invalid."); } URI endpoint = URI.create(String.valueOf(ready.get("url"))); @@ -166,9 +212,18 @@ private RuntimeLease start(RuntimeProvisionRequest request) { throw failed("Managed Runtime ready record is invalid."); } RuntimeLease lease = new RuntimeLease(runtimeInstanceId, - endpoint, token, leaseId, 1); + endpoint, token, leaseId, epoch); attest(request, ownedProcess.seed, lease); - owned.put(runtimeInstanceId, ownedProcess); + List key = ownershipKey(lease); + // A dead worker's port can be reused, but its old lease may still + // arrive for release. Never issue that identity again. + if (!issued.add(key)) { + throw new RuntimeBrokerException(409, + "runtime_broker_resource_conflict", + "Managed Runtime lease identity was already issued.", + false); + } + owned.put(key, ownedProcess); adopted = true; return lease; } catch (IOException exception) { @@ -181,15 +236,68 @@ private RuntimeLease start(RuntimeProvisionRequest request) { } } + private RuntimeObservation observe(RuntimeProvisionRequest request, + RuntimeProvisionSeed seed, RuntimeResourceHandle handle, + RuntimeLease lastLease) { + if (handle != null && !KIND.equals(handle.getKind())) { + return RuntimeObservation.conflict(handle); + } + if (lastLease == null || seed == null) { + return RuntimeObservation.unknown(handle); + } + OwnedProcess process = owned.get(ownershipKey(lastLease)); + if (process == null) { + return RuntimeObservation.unknown(handle); + } + if (!process.process.isAlive()) { + return RuntimeObservation.notFound(); + } + try { + attest(request, seed, lastLease); + } catch (RuntimeBrokerException failure) { + if ("managed_runtime_identity_conflict".equals(failure.getCode()) + || "managed_runtime_unauthorized".equals( + failure.getCode())) { + return RuntimeObservation.conflict(handle); + } + return RuntimeObservation.unknown(handle); + } + RuntimeResourceHandle readyHandle = handle != null ? handle + : new RuntimeResourceHandle(KIND, 1, Map.of("provider", KIND)); + return RuntimeObservation.ready(readyHandle, lastLease.getEndpoint(), + lastLease.getRuntimeInstanceId(), lastLease.getLeaseId(), + lastLease.getEpoch()); + } + + private static RuntimeProvisionSeed newSeed() { + String runtimeInstanceId = UUID.randomUUID().toString(); + String runtimeIncarnation = UUID.randomUUID().toString(); + String leaseId = UUID.randomUUID().toString(); + String provisionRequestId = UUID.randomUUID().toString(); + byte[] tokenBytes = new byte[32]; + RANDOM.nextBytes(tokenBytes); + String token = Base64.getUrlEncoder().withoutPadding() + .encodeToString(tokenBytes); + return new RuntimeProvisionSeed(provisionRequestId, runtimeInstanceId, + runtimeIncarnation, leaseId, 1, token); + } + private void attestOwned(RuntimeProvisionRequest request, RuntimeLease lease) { - OwnedProcess process = owned.get(lease.getRuntimeInstanceId()); + OwnedProcess process = owned.get(ownershipKey(lease)); if (process == null || !process.process.isAlive()) { throw failed("Managed Runtime process is not alive."); } attest(request, process.seed, lease); } + private static List ownershipKey(RuntimeLease lease) { + // A binding id survives generations; even the same seed can start + // distinct workers. The attested endpoint distinguishes those attempts. + return List.of(lease.getRuntimeInstanceId(), lease.getEndpoint(), + lease.getLeaseId(), lease.getEpoch(), lease.getToken()); + } + private void attest(RuntimeProvisionRequest request, RuntimeProvisionSeed seed, RuntimeLease lease) { try { diff --git a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerHttpServer.java b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerHttpServer.java new file mode 100644 index 00000000000..32d9ed81a06 --- /dev/null +++ b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerHttpServer.java @@ -0,0 +1,493 @@ +package com.alibaba.qwen.code.runtimebroker; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.io.InputStream; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.function.Function; + +/** + * Private HTTP face of the merged Runtime Broker for a Hosted Harness. + * + *

The service supports immediate dispatch. Deferred prepare/start and + * operator resolution are unavailable until they have durable service APIs. + */ +public final class RuntimeBrokerHttpServer implements AutoCloseable { + public static final String ROUTE_PREFIX = "/internal/runtime-broker/v1"; + private static final int MAXIMUM_REQUEST_BYTES = 8 * 1024 * 1024; + private final RuntimeBrokerService service; + private final byte[] authorization; + private final HttpServer server; + private final ExecutorService executor; + + public RuntimeBrokerHttpServer(InetSocketAddress address, String token, + RuntimeBrokerService service) throws IOException { + if (address == null || service == null) { + throw new IllegalArgumentException( + "address and service are required"); + } + this.authorization = ("Bearer " + + BrokerValues.requireId(token, "token")) + .getBytes(StandardCharsets.UTF_8); + this.service = service; + this.server = HttpServer.create(address, 0); + this.executor = Executors.newCachedThreadPool(task -> { + Thread thread = new Thread(task, "runtime-broker-http"); + thread.setDaemon(true); + return thread; + }); + server.setExecutor(executor); + server.createContext(ROUTE_PREFIX, this::handle); + } + + public void start() { + server.start(); + } + + public URI getBaseUri() { + InetSocketAddress address = server.getAddress(); + String host = address.getAddress().getHostAddress(); + if (host.indexOf(':') >= 0) { + host = "[" + host + "]"; + } + return URI.create("http://" + host + ":" + address.getPort() + "/"); + } + + @Override + public void close() { + server.stop(0); + executor.shutdownNow(); + service.close(); + } + + private void handle(HttpExchange exchange) { + try { + authorize(exchange); + String path = exchange.getRequestURI().getRawPath(); + if (!path.startsWith(ROUTE_PREFIX)) { + throw notFound(); + } + String relative = path.substring(ROUTE_PREFIX.length()); + if ("POST".equals(exchange.getRequestMethod()) + && "/tool-sessions:acquire".equals(relative)) { + acquire(exchange); + return; + } + if ("POST".equals(exchange.getRequestMethod()) + && "/executions:prepare".equals(relative)) { + throw unsupported(); + } + if ("POST".equals(exchange.getRequestMethod()) + && "/executions".equals(relative)) { + executionRequest(exchange); + return; + } + if (relative.startsWith("/tool-sessions/")) { + toolSession(exchange, relative.substring( + "/tool-sessions/".length())); + return; + } + if (relative.startsWith("/executions/")) { + execution(exchange, relative.substring("/executions/" + .length())); + return; + } + throw notFound(); + } catch (Throwable error) { + sendError(exchange, error); + } + } + + private void acquire(HttpExchange exchange) throws IOException { + Map body = requestBody(exchange, "acquire request"); + requireProtocol(body); + JsonCodec.requiredString(body, "requestId", "acquire request"); + String harnessSessionId = JsonCodec.requiredString(body, + "harnessSessionId", "acquire request"); + String runtimeSessionId = JsonCodec.requiredString(body, + "runtimeSessionId", "acquire request"); + String turnKind = JsonCodec.requiredString(body, "turnKind", + "acquire request"); + complete(exchange, service.acquire(harnessSessionId, runtimeSessionId, + turnKind), ignored -> envelope(harnessSessionId, + runtimeSessionId, "acquired", true)); + } + + private void toolSession(HttpExchange exchange, String suffix) + throws IOException { + if ("POST".equals(exchange.getRequestMethod()) + && suffix.endsWith("/control")) { + String runtimeSessionId = pathId(suffix.substring(0, + suffix.length() - "/control".length())); + Map body = requestBody(exchange, + "control request"); + requireProtocol(body); + JsonCodec.requiredString(body, "requestId", "control request"); + String harnessSessionId = JsonCodec.requiredString(body, + "harnessSessionId", "control request"); + Map operation = requiredObject(body, "operation", + "control request"); + complete(exchange, service.control(harnessSessionId, + runtimeSessionId, operation), result -> envelope( + harnessSessionId, runtimeSessionId, "result", + result)); + return; + } + if ("POST".equals(exchange.getRequestMethod()) + && suffix.endsWith(":release")) { + String runtimeSessionId = pathId(suffix.substring(0, + suffix.length() - ":release".length())); + Map body = requestBody(exchange, + "release request"); + requireProtocol(body); + JsonCodec.requiredString(body, "requestId", "release request"); + String harnessSessionId = JsonCodec.requiredString(body, + "harnessSessionId", "release request"); + complete(exchange, service.release(harnessSessionId, + runtimeSessionId), released -> envelope(harnessSessionId, + runtimeSessionId, "released", released)); + return; + } + throw notFound(); + } + + private void executionRequest(HttpExchange exchange) throws IOException { + Map body = requestBody(exchange, "execution request"); + requireProtocol(body); + JsonCodec.requiredString(body, "requestId", "execution request"); + String idempotencyKey = JsonCodec.requiredString(body, + "idempotencyKey", "execution request"); + String harnessSessionId = JsonCodec.requiredString(body, + "harnessSessionId", "execution request"); + String runtimeSessionId = JsonCodec.requiredString(body, + "runtimeSessionId", "execution request"); + JsonCodec.requiredString(body, "turnId", "execution request"); + JsonCodec.requiredString(body, "toolCallId", "execution request"); + JsonCodec.requiredString(body, "requestDigest", "execution request"); + requiredObject(body, "reference", "execution request"); + complete(exchange, service.createExecution(harnessSessionId, + runtimeSessionId, idempotencyKey, requiredObject(body, + "reference", "execution request")), + record -> executionEnvelope(harnessSessionId, + runtimeSessionId, record)); + } + + private void execution(HttpExchange exchange, String suffix) + throws IOException { + if ("POST".equals(exchange.getRequestMethod()) + && suffix.endsWith(":start")) { + throw unsupported(); + } + if ("POST".equals(exchange.getRequestMethod()) + && suffix.endsWith(":cancel")) { + String executionCallId = pathId(suffix.substring(0, + suffix.length() - ":cancel".length())); + Map body = requestBody(exchange, + "cancel request"); + requireProtocol(body); + JsonCodec.requiredString(body, "requestId", "cancel request"); + String harnessSessionId = JsonCodec.requiredString(body, + "harnessSessionId", "cancel request"); + String runtimeSessionId = JsonCodec.requiredString(body, + "runtimeSessionId", "cancel request"); + complete(exchange, service.cancelExecution(harnessSessionId, + runtimeSessionId, executionCallId), + record -> executionEnvelope(harnessSessionId, + runtimeSessionId, record)); + return; + } + if ("POST".equals(exchange.getRequestMethod()) + && suffix.endsWith(":resolve")) { + throw unsupported(); + } + if ("GET".equals(exchange.getRequestMethod()) + && suffix.indexOf('/') < 0) { + String executionCallId = pathId(suffix); + Map query = query(exchange.getRequestURI()); + requireQuery(query, "requestId"); + String harnessSessionId = requireQuery(query, "harnessSessionId"); + String runtimeSessionId = requireQuery(query, "runtimeSessionId"); + if (query.containsKey("afterSeq")) { + parseSequence(query.get("afterSeq")); + } + complete(exchange, service.getExecution(harnessSessionId, + runtimeSessionId, executionCallId), + record -> executionEnvelope(harnessSessionId, + runtimeSessionId, record)); + return; + } + throw notFound(); + } + + private void authorize(HttpExchange exchange) { + String supplied = exchange.getRequestHeaders().getFirst( + "Authorization"); + byte[] bytes = supplied == null ? new byte[0] + : supplied.getBytes(StandardCharsets.UTF_8); + if (!MessageDigest.isEqual(authorization, bytes)) { + throw new RuntimeBrokerException(401, + "runtime_broker_unauthorized", + "Runtime Broker authentication failed.", false); + } + } + + private static RuntimeBrokerException unsupported() { + return new RuntimeBrokerException(501, + "runtime_broker_operation_unsupported", + "Deferred execution and operator resolution are not implemented.", + false); + } + + private static void requireProtocol(Map body) { + Object version = body.get("protocolVersion"); + if (!(version instanceof Number) + || ((Number) version).intValue() != 1 + || ((Number) version).doubleValue() != 1.0d) { + throw new RuntimeBrokerException(409, + "runtime_broker_protocol_conflict", + "Runtime Broker protocol version changed.", false); + } + } + + private static Map requiredObject( + Map body, String field, String context) { + Object value = body.get(field); + if (!(value instanceof Map)) { + throw new RuntimeBrokerException(400, + "runtime_broker_invalid_request", + context + "." + field + " must be an object.", false); + } + @SuppressWarnings("unchecked") + Map object = (Map) value; + return object; + } + + private static Map requestBody(HttpExchange exchange, + String context) throws IOException { + String rawLength = exchange.getRequestHeaders().getFirst( + "Content-Length"); + if (rawLength != null) { + int contentLength; + try { + contentLength = Integer.parseInt(rawLength); + } catch (NumberFormatException exception) { + throw new RuntimeBrokerException(400, + "runtime_broker_invalid_request", + "Content-Length is invalid.", false); + } + if (contentLength < 0 || contentLength > MAXIMUM_REQUEST_BYTES) { + throw tooLarge(); + } + } + byte[] bytes; + try (InputStream input = exchange.getRequestBody()) { + bytes = input.readNBytes(MAXIMUM_REQUEST_BYTES + 1); + } + if (bytes.length > MAXIMUM_REQUEST_BYTES) { + throw tooLarge(); + } + return JsonCodec.parseObject(bytes, context); + } + + private static RuntimeBrokerException tooLarge() { + return new RuntimeBrokerException(413, + "runtime_broker_request_too_large", + "Runtime Broker request exceeded its limit.", false); + } + + private static RuntimeBrokerException notFound() { + return new RuntimeBrokerException(404, + "runtime_broker_route_not_found", + "Runtime Broker route was not found.", false); + } + + private static String pathId(String raw) { + String decoded = URLDecoder.decode(raw, StandardCharsets.UTF_8); + if (decoded.indexOf('/') >= 0) { + throw notFound(); + } + return BrokerValues.requireId(decoded, "path id"); + } + + private static Map query(URI uri) { + Map result = new LinkedHashMap<>(); + String raw = uri.getRawQuery(); + if (raw == null || raw.isEmpty()) { + return result; + } + for (String pair : raw.split("&", -1)) { + int separator = pair.indexOf('='); + String key = URLDecoder.decode(separator < 0 ? pair + : pair.substring(0, separator), StandardCharsets.UTF_8); + String value = URLDecoder.decode(separator < 0 ? "" + : pair.substring(separator + 1), StandardCharsets.UTF_8); + if (result.putIfAbsent(key, value) != null) { + throw new RuntimeBrokerException(400, + "runtime_broker_invalid_request", + "Runtime Broker query contains duplicate fields.", + false); + } + } + return result; + } + + private static String requireQuery(Map query, + String field) { + String value = query.get(field); + try { + return BrokerValues.requireId(value, field); + } catch (IllegalArgumentException exception) { + throw new RuntimeBrokerException(400, + "runtime_broker_invalid_request", + "Runtime Broker query is incomplete.", false); + } + } + + private static long parseSequence(String raw) { + try { + long value = Long.parseLong(raw); + if (value < 0) { + throw new NumberFormatException(); + } + return value; + } catch (NumberFormatException exception) { + throw new RuntimeBrokerException(400, + "runtime_broker_invalid_request", + "afterSeq must be a non-negative integer.", false); + } + } + + private static Map envelope(String harnessSessionId, + String runtimeSessionId, String field, Object value) { + Map response = new LinkedHashMap<>(); + response.put("protocolVersion", 1); + response.put("harnessSessionId", harnessSessionId); + response.put("runtimeSessionId", runtimeSessionId); + response.put(field, value); + return response; + } + + private static Map executionEnvelope( + String harnessSessionId, String runtimeSessionId, + ToolExecutionRecord record) { + if (record.getState() == ToolExecutionRecord.State.UNKNOWN) { + throw new RuntimeBrokerException(409, + "runtime_broker_execution_unknown", + "Runtime execution outcome is unknown.", false); + } + Map response = envelope(harnessSessionId, + runtimeSessionId, "executionCallId", + record.getExecutionCallId()); + response.put("status", status(record)); + return response; + } + + private static Map status(ToolExecutionRecord record) { + Map status = new LinkedHashMap<>(); + status.put("state", wireState(record.getState())); + status.put("cancelRequested", record.isCancelRequested()); + status.put("lastSeq", record.getLastSequence()); + boolean gap = record.getLastSequence() > 0; + status.put("firstAvailableSeq", gap ? record.getLastSequence() : 0L); + status.put("progressGap", gap); + status.put("progress", List.of()); + if (record.isSettled()) { + status.put("result", record.getResult()); + } + return status; + } + + private static String wireState(ToolExecutionRecord.State state) { + switch (state) { + case PREPARED: + return "prepared"; + case CANCEL_REQUESTED: + return "cancel_requested"; + case SETTLED: + return "settled"; + case DISPATCHING: + case EXECUTING: + case UNKNOWN: + default: + return "executing"; + } + } + + private static void complete(HttpExchange exchange, + CompletionStage operation, + Function> response) { + operation.whenComplete((value, error) -> { + if (error != null) { + sendError(exchange, error); + return; + } + try { + sendJson(exchange, 200, response.apply(value)); + } catch (Throwable failure) { + sendError(exchange, failure); + } + }); + } + + private static void sendError(HttpExchange exchange, Throwable error) { + Throwable actual = unwrap(error); + RuntimeBrokerException failure; + if (actual instanceof RuntimeBrokerException brokerFailure) { + failure = brokerFailure; + } else if (actual instanceof IllegalArgumentException) { + failure = new RuntimeBrokerException(400, + "runtime_broker_invalid_request", + "Runtime Broker request is invalid.", false); + } else if (actual instanceof IllegalStateException + && actual.getMessage() != null + && actual.getMessage().contains("closed")) { + failure = new RuntimeBrokerException(503, + "runtime_broker_closed", + "Runtime Broker is closed.", false); + } else { + failure = new RuntimeBrokerException(500, + "runtime_broker_internal_error", + "Runtime Broker operation failed.", false); + } + Map body = new LinkedHashMap<>(); + body.put("error", failure.getMessage()); + body.put("code", failure.getCode()); + body.put("retryable", failure.isRetryable()); + try { + sendJson(exchange, failure.getStatusCode(), body); + } catch (IOException ignored) { + exchange.close(); + } + } + + private static Throwable unwrap(Throwable error) { + Throwable current = error; + while (current instanceof CompletionException + && current.getCause() != null) { + current = current.getCause(); + } + return current; + } + + private static void sendJson(HttpExchange exchange, int status, + Map body) throws IOException { + byte[] bytes = JsonCodec.encode(body); + exchange.getResponseHeaders().set("Content-Type", + "application/json; charset=utf-8"); + exchange.sendResponseHeaders(status, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + } +} diff --git a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeProvisioner.java b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeProvisioner.java index d93d5c65e51..d0dc31af20a 100644 --- a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeProvisioner.java +++ b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/RuntimeProvisioner.java @@ -65,7 +65,7 @@ default CompletionStage confirm(RuntimeProvisionRequest request, /** * Tears down the resource behind a lease the caller has decided to - * discard, keyed by the lease's runtime instance so a fenced loser can + * discard, keyed by the complete lease identity so a fenced loser can * never kill the winning resource for the same request. The default * has nothing to release. */ diff --git a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/StaticRuntimeProvisioner.java b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/StaticRuntimeProvisioner.java new file mode 100644 index 00000000000..9c0c8017406 --- /dev/null +++ b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/StaticRuntimeProvisioner.java @@ -0,0 +1,33 @@ +package com.alibaba.qwen.code.runtimebroker; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +/** + * Returns one pre-attested lease. Static placements do not keep a + * recoverable identity, matching {@code requiresDurableIdentity}. + */ +public final class StaticRuntimeProvisioner implements RuntimeProvisioner { + private final RuntimeLease lease; + + public StaticRuntimeProvisioner(RuntimeLease lease) { + if (lease == null) { + throw new IllegalArgumentException("lease is required"); + } + this.lease = lease; + } + + @Override + public CompletionStage provision( + RuntimeProvisionRequest request) { + if (request == null) { + throw new IllegalArgumentException("request is required"); + } + return CompletableFuture.completedFuture(lease); + } + + @Override + public String kind() { + return "static"; + } +} diff --git a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/ConfiguredWorkspaceRegistry.java b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/ConfiguredWorkspaceRegistry.java new file mode 100644 index 00000000000..69743ea9c76 --- /dev/null +++ b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/ConfiguredWorkspaceRegistry.java @@ -0,0 +1,171 @@ +package com.alibaba.qwen.code.runtimebroker.managedworkspace; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.NavigableMap; +import java.util.Optional; +import java.util.TreeMap; + +/** + * Immutable Registry snapshot built from deployment configuration. To apply a + * change, build a new snapshot, check it with + * {@link #requireSuccessorOf(ConfiguredWorkspaceRegistry)}, and swap it in. + */ +public final class ConfiguredWorkspaceRegistry implements WorkspaceRegistry { + static final int MAXIMUM_PAGE_SIZE = 1000; + + // Workspace IDs are ASCII, so String order is byte order. + private final Map> tenants; + private final Map defaults; + + /** + * @param workspaces every registered Workspace, of any tenant + * @param tenantDefaults the default Workspace ID of each tenant that has + * one; it must name a Workspace of that tenant + */ + public ConfiguredWorkspaceRegistry(Collection workspaces, + Map tenantDefaults) { + if (workspaces == null || tenantDefaults == null) { + throw new IllegalArgumentException( + "workspaces and tenantDefaults are required"); + } + Map> byTenant = + new HashMap<>(); + for (WorkspaceRecord workspace : workspaces) { + if (workspace == null) { + throw new IllegalArgumentException( + "workspaces must not contain null"); + } + NavigableMap records = + byTenant.computeIfAbsent(workspace.getTenantId(), + ignored -> new TreeMap<>()); + if (records.putIfAbsent(workspace.getWorkspaceId(), workspace) + != null) { + throw new IllegalArgumentException("Workspace " + + describe(workspace) + " is registered twice"); + } + } + Map checkedDefaults = new HashMap<>(); + for (Map.Entry entry : tenantDefaults.entrySet()) { + String tenantId = WorkspaceValues.requireIdentifier(entry.getKey(), + "tenantId"); + String workspaceId = WorkspaceValues.requireIdentifier( + entry.getValue(), "default workspaceId"); + NavigableMap records = + byTenant.get(tenantId); + if (records == null || !records.containsKey(workspaceId)) { + throw new IllegalArgumentException("The default Workspace of " + + tenantId + " is not registered in that tenant"); + } + if (checkedDefaults.putIfAbsent(tenantId, workspaceId) != null) { + throw new IllegalArgumentException("The default Workspace of " + + tenantId + " is configured twice"); + } + } + Map> frozen = + new HashMap<>(); + byTenant.forEach((tenantId, records) -> frozen.put(tenantId, + Collections.unmodifiableNavigableMap(records))); + this.tenants = Map.copyOf(frozen); + this.defaults = Map.copyOf(checkedDefaults); + } + + @Override + public Optional find(String tenantId, + String workspaceId) { + NavigableMap records = recordsOf(tenantId); + if (records == null || !WorkspaceValues.isIdentifier(workspaceId)) { + return Optional.empty(); + } + return Optional.ofNullable(records.get(workspaceId)); + } + + @Override + public List page(String tenantId, + String afterWorkspaceId, int limit) { + if (limit < 1 || limit > MAXIMUM_PAGE_SIZE) { + throw new IllegalArgumentException("limit must be between 1 and " + + MAXIMUM_PAGE_SIZE); + } + if (afterWorkspaceId != null) { + WorkspaceValues.requireIdentifier(afterWorkspaceId, + "afterWorkspaceId"); + } + NavigableMap records = recordsOf(tenantId); + if (records == null) { + return List.of(); + } + Collection remaining = afterWorkspaceId == null + ? records.values() + : records.tailMap(afterWorkspaceId, false).values(); + List page = new ArrayList<>(); + for (WorkspaceRecord workspace : remaining) { + if (page.size() == limit) { + break; + } + page.add(workspace); + } + return List.copyOf(page); + } + + @Override + public Optional defaultWorkspaceId(String tenantId) { + if (!WorkspaceValues.isIdentifier(tenantId)) { + return Optional.empty(); + } + return Optional.ofNullable(defaults.get(tenantId)); + } + + /** + * Throws unless this snapshot may replace {@code previous}: no Workspace + * is dropped (retire one by moving it to {@code removed}), no workspace + * generation goes down, and a changed storage ID comes with a higher + * generation. + * + * @return this snapshot + */ + public ConfiguredWorkspaceRegistry requireSuccessorOf( + ConfiguredWorkspaceRegistry previous) { + if (previous == null) { + throw new IllegalArgumentException("previous is required"); + } + for (NavigableMap records + : previous.tenants.values()) { + for (WorkspaceRecord before : records.values()) { + WorkspaceRecord after = find(before.getTenantId(), + before.getWorkspaceId()).orElseThrow( + () -> new IllegalArgumentException("Workspace " + + describe(before) + " was dropped;" + + " move it to removed instead")); + if (after.getWorkspaceGeneration() + < before.getWorkspaceGeneration()) { + throw new IllegalArgumentException("Workspace " + + describe(before) + + " must not lower its generation"); + } + if (!after.getStorageId().equals(before.getStorageId()) + && after.getWorkspaceGeneration() + == before.getWorkspaceGeneration()) { + throw new IllegalArgumentException("Workspace " + + describe(before) + " changed its storage" + + " without a new generation"); + } + } + } + return this; + } + + private NavigableMap recordsOf(String tenantId) { + return WorkspaceValues.isIdentifier(tenantId) + ? tenants.get(tenantId) + : null; + } + + private static String describe(WorkspaceRecord workspace) { + return workspace.getTenantId() + "/" + workspace.getWorkspaceId(); + } +} diff --git a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/ContextBinding.java b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/ContextBinding.java new file mode 100644 index 00000000000..cfac14b676f --- /dev/null +++ b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/ContextBinding.java @@ -0,0 +1,151 @@ +package com.alibaba.qwen.code.runtimebroker.managedworkspace; + +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Objects; + +/** + * The Workspace context a Session committed: which Workspace generation and + * storage, which relative directory, which frozen configuration, and at which + * context revision. The context digest is derived from exactly these fields + * and matches the TypeScript implementation byte for byte; the shared + * fixtures in {@code managed-workspace-binding-v1.fixtures.json} pin both. + */ +public final class ContextBinding { + static final String DOMAIN_TAG = "qwen-managed-context-binding-v1"; + + private final String tenantId; + private final String workspaceId; + private final long workspaceGeneration; + private final String storageId; + private final String cwdRelative; + private final String contextConfigRef; + private final long contextRevision; + private final String contextDigest; + + /** + * @param cwdRelative a directory already in normal form + * @param contextConfigRef the frozen configuration descriptor recorded at + * admission; opaque here + */ + public ContextBinding(String tenantId, String workspaceId, + long workspaceGeneration, String storageId, String cwdRelative, + String contextConfigRef, long contextRevision) { + this.tenantId = WorkspaceValues.requireIdentifier(tenantId, + "tenantId"); + this.workspaceId = WorkspaceValues.requireIdentifier(workspaceId, + "workspaceId"); + this.workspaceGeneration = WorkspaceValues.requirePositive( + workspaceGeneration, "workspaceGeneration"); + this.storageId = WorkspaceValues.requirePrintableAscii(storageId, + "storageId", WorkspaceRecord.MAXIMUM_STORAGE_ID_LENGTH); + if (cwdRelative == null || !isNormalized(cwdRelative)) { + throw new IllegalArgumentException( + "cwdRelative must be a normalized relative directory"); + } + this.cwdRelative = cwdRelative; + this.contextConfigRef = WorkspaceValues.requirePrintableAscii( + contextConfigRef, "contextConfigRef", + WorkspaceRecord.MAXIMUM_REFERENCE_LENGTH); + this.contextRevision = WorkspaceValues.requirePositive( + contextRevision, "contextRevision"); + this.contextDigest = "sha256:" + HexFormat.of().formatHex( + sha256(encode())); + } + + public String getTenantId() { + return tenantId; + } + + public String getWorkspaceId() { + return workspaceId; + } + + public long getWorkspaceGeneration() { + return workspaceGeneration; + } + + public String getStorageId() { + return storageId; + } + + public String getCwdRelative() { + return cwdRelative; + } + + public String getContextConfigRef() { + return contextConfigRef; + } + + public long getContextRevision() { + return contextRevision; + } + + /** + * {@code sha256:} and the lowercase hex SHA-256 of the domain tag and the + * seven fields, each as a 4-byte big-endian length and its UTF-8 bytes. + */ + public String getContextDigest() { + return contextDigest; + } + + // Each item is a 4-byte big-endian length and its UTF-8 bytes. Integers + // are decimal strings, so a JavaScript peer never needs a 64-bit Number. + byte[] encode() { + ByteArrayOutputStream encoded = new ByteArrayOutputStream(); + for (String item : new String[] {DOMAIN_TAG, tenantId, workspaceId, + Long.toString(workspaceGeneration), storageId, cwdRelative, + contextConfigRef, Long.toString(contextRevision)}) { + byte[] bytes = item.getBytes(StandardCharsets.UTF_8); + encoded.writeBytes(ByteBuffer.allocate(Integer.BYTES) + .putInt(bytes.length).array()); + encoded.writeBytes(bytes); + } + return encoded.toByteArray(); + } + + private static boolean isNormalized(String value) { + try { + return WorkspaceRelativePath.normalize(value).equals(value); + } catch (WorkspaceException exception) { + return false; + } + } + + private static byte[] sha256(byte[] value) { + try { + return MessageDigest.getInstance("SHA-256").digest(value); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable", + exception); + } + } + + @Override + public boolean equals(Object candidate) { + if (this == candidate) { + return true; + } + if (!(candidate instanceof ContextBinding)) { + return false; + } + ContextBinding other = (ContextBinding) candidate; + return tenantId.equals(other.tenantId) + && workspaceId.equals(other.workspaceId) + && workspaceGeneration == other.workspaceGeneration + && storageId.equals(other.storageId) + && cwdRelative.equals(other.cwdRelative) + && contextConfigRef.equals(other.contextConfigRef) + && contextRevision == other.contextRevision; + } + + @Override + public int hashCode() { + return Objects.hash(tenantId, workspaceId, workspaceGeneration, + storageId, cwdRelative, contextConfigRef, contextRevision); + } +} diff --git a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/GrantedWorkspaceAccessPolicy.java b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/GrantedWorkspaceAccessPolicy.java new file mode 100644 index 00000000000..307601ce691 --- /dev/null +++ b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/GrantedWorkspaceAccessPolicy.java @@ -0,0 +1,66 @@ +package com.alibaba.qwen.code.runtimebroker.managedworkspace; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Explicit per-actor grants, for tests and single-tenant deployments. + * Anything not granted is {@link WorkspaceAccess#NONE}; there is no wildcard. + */ +public final class GrantedWorkspaceAccessPolicy + implements WorkspaceAccessPolicy { + private final Map, WorkspaceAccess> grants; + + private GrantedWorkspaceAccessPolicy( + Map, WorkspaceAccess> grants) { + this.grants = Map.copyOf(grants); + } + + public static Builder builder() { + return new Builder(); + } + + @Override + public WorkspaceAccess accessFor(WorkspaceActor actor, + WorkspaceRecord workspace) { + if (actor == null || workspace == null + || !actor.getTenantId().equals(workspace.getTenantId())) { + return WorkspaceAccess.NONE; + } + return grants.getOrDefault(List.of(actor.getTenantId(), + actor.getActorId(), workspace.getWorkspaceId()), + WorkspaceAccess.NONE); + } + + /** Collects grants; each actor gets at most one grant per Workspace. */ + public static final class Builder { + private final Map, WorkspaceAccess> grants = + new HashMap<>(); + + private Builder() { + } + + public Builder grant(String tenantId, String actorId, + String workspaceId, WorkspaceAccess access) { + List key = List.of( + WorkspaceValues.requireIdentifier(tenantId, "tenantId"), + WorkspaceValues.requireText(actorId, "actorId"), + WorkspaceValues.requireIdentifier(workspaceId, + "workspaceId")); + if (access == null || access == WorkspaceAccess.NONE) { + throw new IllegalArgumentException( + "access must be READ or CREATE"); + } + if (grants.putIfAbsent(key, access) != null) { + throw new IllegalArgumentException( + "The actor already has a grant for this Workspace"); + } + return this; + } + + public GrantedWorkspaceAccessPolicy build() { + return new GrantedWorkspaceAccessPolicy(grants); + } + } +} diff --git a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/ResolvedWorkspace.java b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/ResolvedWorkspace.java new file mode 100644 index 00000000000..df1faf329b2 --- /dev/null +++ b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/ResolvedWorkspace.java @@ -0,0 +1,62 @@ +package com.alibaba.qwen.code.runtimebroker.managedworkspace; + +/** + * The Workspace a new Session binds to, as resolved on first admission. The + * caller persists it with the creation receipt; a retry must reuse the + * persisted result instead of resolving again. + */ +public final class ResolvedWorkspace { + private final String tenantId; + private final String workspaceId; + private final long workspaceGeneration; + private final String storageId; + private final String configRef; + private final String policyRef; + private final String cwdRelative; + private final boolean tenantDefault; + + ResolvedWorkspace(WorkspaceRecord workspace, String cwdRelative, + boolean tenantDefault) { + this.tenantId = workspace.getTenantId(); + this.workspaceId = workspace.getWorkspaceId(); + this.workspaceGeneration = workspace.getWorkspaceGeneration(); + this.storageId = workspace.getStorageId(); + this.configRef = workspace.getConfigRef(); + this.policyRef = workspace.getPolicyRef(); + this.cwdRelative = cwdRelative; + this.tenantDefault = tenantDefault; + } + + public String getTenantId() { + return tenantId; + } + + public String getWorkspaceId() { + return workspaceId; + } + + public long getWorkspaceGeneration() { + return workspaceGeneration; + } + + public String getStorageId() { + return storageId; + } + + public String getConfigRef() { + return configRef; + } + + public String getPolicyRef() { + return policyRef; + } + + public String getCwdRelative() { + return cwdRelative; + } + + /** Whether an omitted selection resolved to the tenant default. */ + public boolean isTenantDefault() { + return tenantDefault; + } +} diff --git a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceAccess.java b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceAccess.java new file mode 100644 index 00000000000..02acd40f7db --- /dev/null +++ b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceAccess.java @@ -0,0 +1,16 @@ +package com.alibaba.qwen.code.runtimebroker.managedworkspace; + +/** An actor's access to one Workspace. {@link #CREATE} implies {@link #READ}. */ +public enum WorkspaceAccess { + NONE, + READ, + CREATE; + + public boolean canRead() { + return this != NONE; + } + + public boolean canCreate() { + return this == CREATE; + } +} diff --git a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceAccessPolicy.java b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceAccessPolicy.java new file mode 100644 index 00000000000..402c79190a9 --- /dev/null +++ b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceAccessPolicy.java @@ -0,0 +1,11 @@ +package com.alibaba.qwen.code.runtimebroker.managedworkspace; + +/** + * Decides an actor's access to a Workspace of the actor's own tenant. The + * catalog asks on every call and never caches the answer, so a revoked grant + * takes effect on the next request. + */ +@FunctionalInterface +public interface WorkspaceAccessPolicy { + WorkspaceAccess accessFor(WorkspaceActor actor, WorkspaceRecord workspace); +} diff --git a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceActor.java b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceActor.java new file mode 100644 index 00000000000..ca5563b9afa --- /dev/null +++ b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceActor.java @@ -0,0 +1,44 @@ +package com.alibaba.qwen.code.runtimebroker.managedworkspace; + +import java.util.Objects; + +/** + * Authenticated caller identity. The embedding service builds it from its own + * authentication; it never comes from a request body. + */ +public final class WorkspaceActor { + private final String tenantId; + private final String actorId; + + public WorkspaceActor(String tenantId, String actorId) { + this.tenantId = WorkspaceValues.requireIdentifier(tenantId, + "tenantId"); + this.actorId = WorkspaceValues.requireText(actorId, "actorId"); + } + + public String getTenantId() { + return tenantId; + } + + public String getActorId() { + return actorId; + } + + @Override + public boolean equals(Object candidate) { + if (this == candidate) { + return true; + } + if (!(candidate instanceof WorkspaceActor)) { + return false; + } + WorkspaceActor other = (WorkspaceActor) candidate; + return tenantId.equals(other.tenantId) + && actorId.equals(other.actorId); + } + + @Override + public int hashCode() { + return Objects.hash(tenantId, actorId); + } +} diff --git a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceCatalog.java b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceCatalog.java new file mode 100644 index 00000000000..b83cfc4497e --- /dev/null +++ b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceCatalog.java @@ -0,0 +1,170 @@ +package com.alibaba.qwen.code.runtimebroker.managedworkspace; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * Lists the Workspaces an actor can read and resolves a Session's Workspace + * selection. It keeps no state: the Registry and the access policy are asked + * on every call, and nothing falls back to a launch directory or to another + * Workspace. + */ +public final class WorkspaceCatalog { + private final WorkspaceRegistry registry; + private final WorkspaceAccessPolicy policy; + + public WorkspaceCatalog(WorkspaceRegistry registry, + WorkspaceAccessPolicy policy) { + if (registry == null || policy == null) { + throw new IllegalArgumentException( + "registry and policy are required"); + } + this.registry = registry; + this.policy = policy; + } + + /** + * Returns up to {@code limit} Workspaces the actor can read, after + * {@code afterWorkspaceId} in byte order (from the first when null). + * {@code hasMore} is exact: after a full page the scan goes on until it + * finds one more readable Workspace or reaches the end of the tenant. + * + * @throws IllegalArgumentException when {@code afterWorkspaceId} is not + * a Workspace ID or {@code limit} is outside 1 to 1000; the API layer + * decodes its opaque cursor before calling this + */ + public WorkspacePage list(WorkspaceActor actor, String afterWorkspaceId, + int limit) { + requireActor(actor); + if (limit < 1 || limit > ConfiguredWorkspaceRegistry.MAXIMUM_PAGE_SIZE) { + throw new IllegalArgumentException("limit must be between 1 and " + + ConfiguredWorkspaceRegistry.MAXIMUM_PAGE_SIZE); + } + if (afterWorkspaceId != null + && !WorkspaceValues.isIdentifier(afterWorkspaceId)) { + throw new IllegalArgumentException( + "afterWorkspaceId must be a Workspace ID"); + } + List views = new ArrayList<>(); + boolean hasMore = false; + String cursor = afterWorkspaceId; + // Full batches regardless of the caller's limit: records the actor + // cannot read are skipped, and small batches would turn a sparse + // listing into one Registry call per record. + int batchSize = ConfiguredWorkspaceRegistry.MAXIMUM_PAGE_SIZE; + scan: + while (true) { + List batch = registry.page(actor.getTenantId(), + cursor, batchSize); + for (WorkspaceRecord workspace : batch) { + if (cursor != null + && workspace.getWorkspaceId().compareTo(cursor) <= 0) { + throw new IllegalStateException( + "The Registry returned a page out of order"); + } + cursor = workspace.getWorkspaceId(); + if (!workspace.getTenantId().equals(actor.getTenantId())) { + continue; + } + WorkspaceAccess access = accessFor(actor, workspace); + if (!access.canRead()) { + continue; + } + if (views.size() == limit) { + hasMore = true; + break scan; + } + views.add(new WorkspaceView(workspace, access)); + } + if (batch.size() < batchSize) { + break; + } + } + WorkspaceView defaultView = usableDefault(actor) + .map(workspace -> new WorkspaceView(workspace, + WorkspaceAccess.CREATE)) + .orElse(null); + return new WorkspacePage(views, hasMore, defaultView); + } + + /** + * Resolves the Workspace for a new Session. An omitted selection uses the + * tenant default only when it is active and the actor may create Sessions + * in it, and otherwise fails with {@code workspace_required}. An explicit + * selection never falls back to the default. + * + * @throws WorkspaceException {@code workspace_required}, + * {@code workspace_not_found}, {@code workspace_forbidden} or + * {@code workspace_unavailable} + */ + public ResolvedWorkspace resolve(WorkspaceActor actor, + WorkspaceSelection selection) { + requireActor(actor); + if (selection == null) { + throw new IllegalArgumentException("selection is required"); + } + if (selection.isOmitted()) { + WorkspaceRecord workspace = usableDefault(actor) + .orElseThrow(WorkspaceException::workspaceRequired); + return new ResolvedWorkspace(workspace, WorkspaceRelativePath.ROOT, + true); + } + Optional found = find(actor, + selection.getWorkspaceId().orElseThrow()); + // Missing, foreign and unreadable Workspaces fail at this one site, + // so not even a stack trace tells them apart. + WorkspaceAccess access = found + .map(workspace -> accessFor(actor, workspace)) + .orElse(WorkspaceAccess.NONE); + if (!access.canRead()) { + throw WorkspaceException.workspaceNotFound(); + } + if (!access.canCreate()) { + throw WorkspaceException.workspaceForbidden(); + } + WorkspaceRecord workspace = found.orElseThrow(); + if (workspace.getState() != WorkspaceState.ACTIVE) { + throw WorkspaceException.workspaceUnavailable(); + } + return new ResolvedWorkspace(workspace, selection.getCwdRelative(), + false); + } + + private Optional usableDefault(WorkspaceActor actor) { + return registry.defaultWorkspaceId(actor.getTenantId()) + .flatMap(workspaceId -> find(actor, workspaceId)) + .filter(workspace -> accessFor(actor, workspace).canCreate()) + .filter(workspace -> workspace.getState() + == WorkspaceState.ACTIVE); + } + + private Optional find(WorkspaceActor actor, + String workspaceId) { + // Caller input that no Registry ID can match is simply not found; + // the Registry never sees it. + if (!WorkspaceValues.isIdentifier(workspaceId)) { + return Optional.empty(); + } + return registry.find(actor.getTenantId(), workspaceId) + .filter(workspace -> workspace.getTenantId() + .equals(actor.getTenantId()) + && workspace.getWorkspaceId().equals(workspaceId)); + } + + private WorkspaceAccess accessFor(WorkspaceActor actor, + WorkspaceRecord workspace) { + WorkspaceAccess access = policy.accessFor(actor, workspace); + if (access == null) { + throw new IllegalStateException( + "The access policy returned no decision"); + } + return access; + } + + private static void requireActor(WorkspaceActor actor) { + if (actor == null) { + throw new IllegalArgumentException("actor is required"); + } + } +} diff --git a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceException.java b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceException.java new file mode 100644 index 00000000000..cf681c8d5c0 --- /dev/null +++ b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceException.java @@ -0,0 +1,49 @@ +package com.alibaba.qwen.code.runtimebroker.managedworkspace; + +/** + * Typed Workspace admission failure that an API adapter maps to its error + * envelope. None of these succeeds on an unchanged retry. + */ +public final class WorkspaceException extends RuntimeException { + private final int statusCode; + private final String code; + + private WorkspaceException(int statusCode, String code, String message) { + super(message); + this.statusCode = statusCode; + this.code = code; + } + + static WorkspaceException workspaceRequired() { + return new WorkspaceException(400, "workspace_required", + "Select a Workspace; the tenant has no usable default"); + } + + static WorkspaceException invalidCwd() { + return new WorkspaceException(400, "invalid_cwd", + "cwdRelative is not a valid Workspace-relative directory"); + } + + static WorkspaceException workspaceNotFound() { + return new WorkspaceException(404, "workspace_not_found", + "Workspace not found"); + } + + static WorkspaceException workspaceForbidden() { + return new WorkspaceException(403, "workspace_forbidden", + "Creating a Session in this Workspace is not allowed"); + } + + static WorkspaceException workspaceUnavailable() { + return new WorkspaceException(409, "workspace_unavailable", + "Workspace does not accept new Sessions"); + } + + public int getStatusCode() { + return statusCode; + } + + public String getCode() { + return code; + } +} diff --git a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspacePage.java b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspacePage.java new file mode 100644 index 00000000000..755c3fa2244 --- /dev/null +++ b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspacePage.java @@ -0,0 +1,35 @@ +package com.alibaba.qwen.code.runtimebroker.managedworkspace; + +import java.util.List; +import java.util.Optional; + +/** One page of the Workspaces an actor can read, plus the tenant default. */ +public final class WorkspacePage { + private final List workspaces; + private final boolean hasMore; + private final WorkspaceView defaultWorkspace; + + WorkspacePage(List workspaces, boolean hasMore, + WorkspaceView defaultWorkspace) { + this.workspaces = List.copyOf(workspaces); + this.hasMore = hasMore; + this.defaultWorkspace = defaultWorkspace; + } + + /** Readable Workspaces in byte order of their IDs. */ + public List getWorkspaces() { + return workspaces; + } + + public boolean hasMore() { + return hasMore; + } + + /** + * The tenant default, whether or not it is on this page. Present only + * when it is active and the actor may create Sessions in it. + */ + public Optional getDefaultWorkspace() { + return Optional.ofNullable(defaultWorkspace); + } +} diff --git a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceRecord.java b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceRecord.java new file mode 100644 index 00000000000..6a09d2ae7aa --- /dev/null +++ b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceRecord.java @@ -0,0 +1,103 @@ +package com.alibaba.qwen.code.runtimebroker.managedworkspace; + +import java.util.Objects; + +/** + * Immutable Registry record for one administrator-registered Workspace. The + * workspace generation grows whenever the Workspace is replaced, for example + * when its storage changes; Sessions pin the generation they were admitted + * with. + */ +public final class WorkspaceRecord { + static final int MAXIMUM_STORAGE_ID_LENGTH = 256; + static final int MAXIMUM_REFERENCE_LENGTH = 512; + + private final String tenantId; + private final String workspaceId; + private final long workspaceGeneration; + private final String storageId; + private final String displayName; + private final WorkspaceState state; + private final String policyRef; + private final String configRef; + + public WorkspaceRecord(String tenantId, String workspaceId, + long workspaceGeneration, String storageId, String displayName, + WorkspaceState state, String policyRef, String configRef) { + this.tenantId = WorkspaceValues.requireIdentifier(tenantId, + "tenantId"); + this.workspaceId = WorkspaceValues.requireIdentifier(workspaceId, + "workspaceId"); + this.workspaceGeneration = WorkspaceValues.requirePositive( + workspaceGeneration, "workspaceGeneration"); + this.storageId = WorkspaceValues.requirePrintableAscii(storageId, + "storageId", MAXIMUM_STORAGE_ID_LENGTH); + this.displayName = WorkspaceValues.requireText(displayName, + "displayName"); + if (state == null) { + throw new IllegalArgumentException("state is required"); + } + this.state = state; + this.policyRef = WorkspaceValues.requirePrintableAscii(policyRef, + "policyRef", MAXIMUM_REFERENCE_LENGTH); + this.configRef = WorkspaceValues.requirePrintableAscii(configRef, + "configRef", MAXIMUM_REFERENCE_LENGTH); + } + + public String getTenantId() { + return tenantId; + } + + public String getWorkspaceId() { + return workspaceId; + } + + public long getWorkspaceGeneration() { + return workspaceGeneration; + } + + public String getStorageId() { + return storageId; + } + + public String getDisplayName() { + return displayName; + } + + public WorkspaceState getState() { + return state; + } + + public String getPolicyRef() { + return policyRef; + } + + public String getConfigRef() { + return configRef; + } + + @Override + public boolean equals(Object candidate) { + if (this == candidate) { + return true; + } + if (!(candidate instanceof WorkspaceRecord)) { + return false; + } + WorkspaceRecord other = (WorkspaceRecord) candidate; + return tenantId.equals(other.tenantId) + && workspaceId.equals(other.workspaceId) + && workspaceGeneration == other.workspaceGeneration + && storageId.equals(other.storageId) + && displayName.equals(other.displayName) + && state == other.state + && policyRef.equals(other.policyRef) + && configRef.equals(other.configRef); + } + + @Override + public int hashCode() { + return Objects.hash(tenantId, workspaceId, workspaceGeneration, + storageId, displayName, state, policyRef, configRef); + } +} diff --git a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceRegistry.java b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceRegistry.java new file mode 100644 index 00000000000..562b95227b4 --- /dev/null +++ b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceRegistry.java @@ -0,0 +1,28 @@ +package com.alibaba.qwen.code.runtimebroker.managedworkspace; + +import java.util.List; +import java.util.Optional; + +/** + * Read contract for administrator-registered Workspaces. Every operation is + * scoped to one tenant and never returns another tenant's record. There is no + * registration operation: Workspaces come from deployment configuration or a + * product directory, never from a caller-supplied path. + */ +public interface WorkspaceRegistry { + /** Returns the record, or empty when this tenant has no such Workspace. */ + Optional find(String tenantId, String workspaceId); + + /** + * Returns up to {@code limit} of the tenant's records in byte order of + * the Workspace ID, starting after {@code afterWorkspaceId}, or from the + * first record when it is null. A result shorter than {@code limit} + * means that no records follow. Implementations accept any limit from 1 + * to 1000. + */ + List page(String tenantId, String afterWorkspaceId, + int limit); + + /** Returns the tenant's configured default Workspace ID, if any. */ + Optional defaultWorkspaceId(String tenantId); +} diff --git a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceRelativePath.java b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceRelativePath.java new file mode 100644 index 00000000000..e07d1c61636 --- /dev/null +++ b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceRelativePath.java @@ -0,0 +1,68 @@ +package com.alibaba.qwen.code.runtimebroker.managedworkspace; + +/** + * Lexical rule and normal form for a Session's working directory relative to + * its Workspace root. The rule never touches the filesystem: the Runtime still + * verifies existence, realpath containment and symlinks before tools run. + */ +public final class WorkspaceRelativePath { + public static final String ROOT = "."; + + private static final int MAXIMUM_CODE_POINTS = 1024; + + private WorkspaceRelativePath() { + } + + /** + * Returns the normal form of {@code value}. Empty and {@code .} segments + * are dropped and nothing else changes: spaces, case and non-ASCII + * characters are kept, with no Unicode normalization or percent-decoding. + * + * @throws WorkspaceException {@code invalid_cwd} when the value is not a + * well-formed relative path of 1 to 1024 code points, contains a + * control character, a backslash, a leading {@code /} or a {@code ..} + * segment, or when its normal form starts with a drive prefix + */ + public static String normalize(String value) { + if (value == null) { + throw new IllegalArgumentException("cwdRelative is required"); + } + if (!WorkspaceValues.isWellFormed(value)) { + throw WorkspaceException.invalidCwd(); + } + int codePoints = value.codePointCount(0, value.length()); + if (codePoints < 1 || codePoints > MAXIMUM_CODE_POINTS + || WorkspaceValues.hasControl(value) + || value.indexOf('\\') >= 0 + || value.startsWith("/")) { + throw WorkspaceException.invalidCwd(); + } + StringBuilder normalized = new StringBuilder(value.length()); + for (String segment : value.split("/", -1)) { + if (segment.equals("..")) { + throw WorkspaceException.invalidCwd(); + } + if (segment.isEmpty() || segment.equals(ROOT)) { + continue; + } + if (normalized.length() > 0) { + normalized.append('/'); + } + normalized.append(segment); + } + // Checked on the normal form: dropping a leading "." segment would + // otherwise turn ./C:x into the drive path C:x. + if (hasDrivePrefix(normalized)) { + throw WorkspaceException.invalidCwd(); + } + return normalized.length() == 0 ? ROOT : normalized.toString(); + } + + private static boolean hasDrivePrefix(CharSequence value) { + if (value.length() < 2 || value.charAt(1) != ':') { + return false; + } + char first = value.charAt(0); + return (first >= 'A' && first <= 'Z') || (first >= 'a' && first <= 'z'); + } +} diff --git a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceSelection.java b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceSelection.java new file mode 100644 index 00000000000..b2457a746da --- /dev/null +++ b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceSelection.java @@ -0,0 +1,94 @@ +package com.alibaba.qwen.code.runtimebroker.managedworkspace; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * A caller's Workspace choice at Session creation: omitted, or an explicit + * Workspace ID with a relative directory. The directory is normalized when + * the selection is built, so an invalid one fails with {@code invalid_cwd} + * before any lookup. The API layer rejects {@code null} and an empty object + * before building a selection; only an omitted one may use a default. + */ +public final class WorkspaceSelection { + private static final WorkspaceSelection OMITTED = + new WorkspaceSelection(null, WorkspaceRelativePath.ROOT); + + private final String workspaceId; + private final String cwdRelative; + + private WorkspaceSelection(String workspaceId, String cwdRelative) { + this.workspaceId = workspaceId; + this.cwdRelative = cwdRelative; + } + + public static WorkspaceSelection omitted() { + return OMITTED; + } + + /** An explicit Workspace with the root directory. */ + public static WorkspaceSelection explicit(String workspaceId) { + return explicit(workspaceId, WorkspaceRelativePath.ROOT); + } + + /** + * An explicit Workspace and relative directory. The ID is caller input: + * an ID that cannot exist later resolves as {@code workspace_not_found}. + * + * @throws WorkspaceException {@code invalid_cwd} for an invalid directory + */ + public static WorkspaceSelection explicit(String workspaceId, + String cwdRelative) { + if (workspaceId == null) { + throw new IllegalArgumentException("workspaceId is required"); + } + return new WorkspaceSelection(workspaceId, + WorkspaceRelativePath.normalize(cwdRelative)); + } + + public boolean isOmitted() { + return workspaceId == null; + } + + public Optional getWorkspaceId() { + return Optional.ofNullable(workspaceId); + } + + /** The normalized directory; {@code .} for an omitted selection. */ + public String getCwdRelative() { + return cwdRelative; + } + + /** + * The values a request digest covers: a fixed omission marker, or the + * Workspace ID with the normalized directory. Spellings of the same + * directory therefore digest alike, and omission never digests like an + * explicit choice of the default. The Workspace ID is unvalidated caller + * input: request validation must reject one that is not well-formed + * Unicode before any digest, because UTF-8 cannot encode it. + */ + public List digestFields() { + return isOmitted() + ? List.of("omitted") + : List.of("explicit", workspaceId, cwdRelative); + } + + @Override + public boolean equals(Object candidate) { + if (this == candidate) { + return true; + } + if (!(candidate instanceof WorkspaceSelection)) { + return false; + } + WorkspaceSelection other = (WorkspaceSelection) candidate; + return Objects.equals(workspaceId, other.workspaceId) + && cwdRelative.equals(other.cwdRelative); + } + + @Override + public int hashCode() { + return Objects.hash(workspaceId, cwdRelative); + } +} diff --git a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceState.java b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceState.java new file mode 100644 index 00000000000..a49bc609970 --- /dev/null +++ b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceState.java @@ -0,0 +1,18 @@ +package com.alibaba.qwen.code.runtimebroker.managedworkspace; + +/** Registry lifecycle state. Only {@link #ACTIVE} accepts new Sessions. */ +public enum WorkspaceState { + ACTIVE("active"), + DRAINING("draining"), + REMOVED("removed"); + + private final String wireName; + + WorkspaceState(String wireName) { + this.wireName = wireName; + } + + public String wireName() { + return wireName; + } +} diff --git a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceValues.java b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceValues.java new file mode 100644 index 00000000000..b4b35fdd314 --- /dev/null +++ b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceValues.java @@ -0,0 +1,84 @@ +package com.alibaba.qwen.code.runtimebroker.managedworkspace; + +import java.util.regex.Pattern; + +final class WorkspaceValues { + static final int MAXIMUM_TEXT_CODE_POINTS = 512; + + private static final Pattern IDENTIFIER = Pattern.compile( + "[A-Za-z0-9._:-]{1,128}"); + + private WorkspaceValues() { + } + + static boolean isIdentifier(String value) { + return value != null && IDENTIFIER.matcher(value).matches(); + } + + static String requireIdentifier(String value, String name) { + if (!isIdentifier(value)) { + throw new IllegalArgumentException(name + + " must match [A-Za-z0-9._:-]{1,128}"); + } + return value; + } + + static String requirePrintableAscii(String value, String name, + int maximumLength) { + boolean valid = value != null && !value.isEmpty() + && value.length() <= maximumLength; + for (int index = 0; valid && index < value.length(); index++) { + char character = value.charAt(index); + valid = character >= 0x21 && character <= 0x7E; + } + if (!valid) { + throw new IllegalArgumentException(name + " must be 1 to " + + maximumLength + " printable ASCII characters"); + } + return value; + } + + static String requireText(String value, String name) { + if (value == null || !isWellFormed(value) || hasControl(value)) { + throw new IllegalArgumentException(name + + " must be well-formed text without control characters"); + } + int codePoints = value.codePointCount(0, value.length()); + if (codePoints < 1 || codePoints > MAXIMUM_TEXT_CODE_POINTS) { + throw new IllegalArgumentException(name + " must be 1 to " + + MAXIMUM_TEXT_CODE_POINTS + " characters"); + } + return value; + } + + static long requirePositive(long value, String name) { + if (value < 1) { + throw new IllegalArgumentException(name + " must be positive"); + } + return value; + } + + static boolean isWellFormed(String value) { + int index = 0; + while (index < value.length()) { + char character = value.charAt(index); + if (Character.isHighSurrogate(character)) { + if (index + 1 == value.length() + || !Character.isLowSurrogate(value.charAt(index + 1))) { + return false; + } + index += 2; + } else if (Character.isLowSurrogate(character)) { + return false; + } else { + index++; + } + } + return true; + } + + // Unicode category Cc: C0 controls, DEL and C1 controls. + static boolean hasControl(String value) { + return value.codePoints().anyMatch(Character::isISOControl); + } +} diff --git a/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceView.java b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceView.java new file mode 100644 index 00000000000..8774ff55a38 --- /dev/null +++ b/packages/sdk-java/runtime-broker/src/main/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceView.java @@ -0,0 +1,62 @@ +package com.alibaba.qwen.code.runtimebroker.managedworkspace; + +import java.util.Objects; + +/** + * What a Workspace list shows about one entry. It carries no storage, + * policy or configuration reference. + */ +public final class WorkspaceView { + private final String workspaceId; + private final String displayName; + private final WorkspaceState state; + private final boolean canCreateSession; + + WorkspaceView(WorkspaceRecord workspace, WorkspaceAccess access) { + this.workspaceId = workspace.getWorkspaceId(); + this.displayName = workspace.getDisplayName(); + this.state = workspace.getState(); + this.canCreateSession = access.canCreate(); + } + + public String getWorkspaceId() { + return workspaceId; + } + + public String getDisplayName() { + return displayName; + } + + public WorkspaceState getState() { + return state; + } + + /** + * Permission hint only: whether the actor may create Sessions here. The + * state is separate, and creation checks both again. + */ + public boolean canCreateSession() { + return canCreateSession; + } + + @Override + public boolean equals(Object candidate) { + if (this == candidate) { + return true; + } + if (!(candidate instanceof WorkspaceView)) { + return false; + } + WorkspaceView other = (WorkspaceView) candidate; + return workspaceId.equals(other.workspaceId) + && displayName.equals(other.displayName) + && state == other.state + && canCreateSession == other.canCreateSession; + } + + @Override + public int hashCode() { + return Objects.hash(workspaceId, displayName, state, + canCreateSession); + } +} diff --git a/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/HttpRuntimeTransportTest.java b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/HttpRuntimeTransportTest.java index b21a17d670f..4d108ed4f1e 100644 --- a/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/HttpRuntimeTransportTest.java +++ b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/HttpRuntimeTransportTest.java @@ -23,6 +23,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.CompletionStage; @@ -397,9 +398,23 @@ void statusAnswersUnknownFromTheSharedFixture() throws Exception { assertEquals(statusSuite.required("canonicalRequest") .required("body"), sent); assertEquals("unknown", answer.get("state")); + assertEquals(Set.of("state"), answer.keySet()); assertNull(answer.get("result")); } + @Test + void sessionVerbsFailClosed() { + assertTrue(transport instanceof RuntimeTransport); + CompletionException thrown = assertThrows(CompletionException.class, + () -> transport.acquire(toolLease(1), toolSession()) + .toCompletableFuture().join()); + RuntimeBrokerException failure = + (RuntimeBrokerException) thrown.getCause(); + assertEquals(501, failure.getStatusCode()); + assertEquals("runtime_session_verb_unsupported", failure.getCode()); + assertFalse(failure.isRetryable()); + } + @Test void cancelSettlesAPreparedExecutionFromTheSharedFixture() throws Exception { @@ -714,7 +729,10 @@ void acceptsLastSequenceOnlyOnStatus() throws Exception { toolLease(server.getAddress().getPort()), toolSession(), toolReference(), 0).toCompletableFuture() .get(2, TimeUnit.SECONDS); - assertEquals(0, ((Number) status.get("lastSequence")).longValue()); + assertFalse(status.containsKey("lastSequence")); + assertFalse(status.containsKey("protocolVersion")); + assertEquals("settled", status.get("state")); + assertTrue(status.containsKey("result")); } @Test @@ -736,8 +754,8 @@ void validatesSequenceNumbersWithoutRoundingOrLongTruncation() toolLease(server.getAddress().getPort()), toolSession(), toolReference(), 0).toCompletableFuture() .get(2, TimeUnit.SECONDS); - assertEquals(0, new BigDecimal(valid).compareTo(new BigDecimal( - answer.get("lastSequence").toString())), valid); + assertEquals(Set.of("state"), answer.keySet(), valid); + assertEquals("unknown", answer.get("state"), valid); } reply.set(json(200, ("{\"protocolVersion\":2.000000000000000001," + "\"state\":\"unknown\"}").getBytes(StandardCharsets.UTF_8))); diff --git a/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/LocalProcessRuntimeProvisionerTest.java b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/LocalProcessRuntimeProvisionerTest.java index 419164bede3..20c9c5ccdf7 100644 --- a/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/LocalProcessRuntimeProvisionerTest.java +++ b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/LocalProcessRuntimeProvisionerTest.java @@ -6,6 +6,9 @@ import static org.junit.jupiter.api.Assumptions.assumeTrue; import java.io.IOException; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; import java.util.Comparator; @@ -20,6 +23,37 @@ class LocalProcessRuntimeProvisionerTest { private static final String DIGEST = "sha256:" + "a".repeat(64); + @Test + void reportsUnknownWhenTheProcessIsNotOwned() throws Exception { + HttpRuntimeTransport transport = new HttpRuntimeTransport(); + try (LocalProcessRuntimeProvisioner provisioner = + new LocalProcessRuntimeProvisioner(List.of("node"), + Path.of("").toAbsolutePath(), transport)) { + assertEquals(LocalProcessRuntimeProvisioner.KIND, + provisioner.kind()); + RuntimeScope scope = new RuntimeScope("tenant-a", "workspace-a", + "7", "/runtime/workspace", DIGEST, "workspace"); + RuntimeProvisionRequest request = new RuntimeProvisionRequest( + scope, null, provisioner.kind()); + RuntimeProvisionSeed seed = RuntimeProvisionSeed.create( + "binding-1", 1); + RuntimeResourceHandle handle = provisioner + .ensureResource(request, seed, null) + .toCompletableFuture().get(2, TimeUnit.SECONDS); + assertEquals(LocalProcessRuntimeProvisioner.KIND, + handle.getKind()); + RuntimeLease missing = new RuntimeLease( + seed.getProvisionalRuntimeId(), + URI.create("http://127.0.0.1:1"), seed.getToken(), + seed.getLeaseId(), seed.getEpoch()); + RuntimeObservation observation = provisioner + .reconcile(request, seed, handle, missing) + .toCompletableFuture().get(5, TimeUnit.SECONDS); + assertEquals(RuntimeObservation.Outcome.UNKNOWN, + observation.getOutcome()); + } + } + @Test void adoptsAWorkerOnlyAfterAttestationAndRejectsToolRoutes() throws Exception { @@ -87,6 +121,137 @@ provisioner, new AcceptingTransport(), bindings, } } + @Test + void releasingAnOlderGenerationPreservesTheWinningWorker() + throws Exception { + assertReleasePreservesOtherAttempt(false); + } + + @Test + void releasingARetriedSeedPreservesTheWinningWorker() + throws Exception { + assertReleasePreservesOtherAttempt(true); + } + + private static void assertReleasePreservesOtherAttempt(boolean sameSeed) + throws Exception { + requireNode(); + Path script = Path.of("src/test/resources/fake-attestation-worker.mjs") + .toAbsolutePath(); + assumeTrue(Files.isRegularFile(script)); + RuntimeScope scope = new RuntimeScope("tenant-a", "workspace-a", "7", + "/runtime/workspace", DIGEST, "workspace"); + RuntimeProvisionRequest request = new RuntimeProvisionRequest( + scope, null, LocalProcessRuntimeProvisioner.KIND); + RuntimeProvisionSeed firstSeed = RuntimeProvisionSeed.create( + "binding-1", 1); + RuntimeProvisionSeed nextSeed = sameSeed ? firstSeed + : RuntimeProvisionSeed.create("binding-1", 2); + Set before = childPids(); + try (LocalProcessRuntimeProvisioner provisioner = + new LocalProcessRuntimeProvisioner( + List.of("node", script.toString()), + Path.of("").toAbsolutePath(), + new HttpRuntimeTransport())) { + RuntimeLease first = provisioner.provision(request, firstSeed) + .toCompletableFuture().get(30, TimeUnit.SECONDS); + RuntimeLease winner = provisioner.provision(request, nextSeed) + .toCompletableFuture().get(30, TimeUnit.SECONDS); + provisioner.confirm(request, winner).toCompletableFuture() + .get(10, TimeUnit.SECONDS); + provisioner.release(request, first).toCompletableFuture() + .get(10, TimeUnit.SECONDS); + assertTrue(provisioner.isUsable(winner), + "releasing a losing attempt must preserve the winner"); + provisioner.confirm(request, winner).toCompletableFuture() + .get(10, TimeUnit.SECONDS); + assertFalse(provisioner.isUsable(first)); + provisioner.release(request, winner).toCompletableFuture() + .get(10, TimeUnit.SECONDS); + assertNoNewChildren(before); + } finally { + ProcessHandle.current().children() + .filter(process -> !before.contains(process.pid())) + .forEach(ProcessHandle::destroyForcibly); + assertNoNewChildren(before); + } + } + + @Test + void rejectsReusingADeadWorkersLeaseIdentity() throws Exception { + assertRejectsReusedWorkerIdentity(false); + } + + @Test + void rejectsReusingAReleasedWorkersLeaseIdentity() throws Exception { + assertRejectsReusedWorkerIdentity(true); + } + + private static void assertRejectsReusedWorkerIdentity(boolean release) + throws Exception { + requireNode(); + Path script = Path.of("src/test/resources/fake-attestation-worker.mjs") + .toAbsolutePath(); + assumeTrue(Files.isRegularFile(script)); + int port; + try (ServerSocket socket = new ServerSocket(0, 0, + InetAddress.getByName("127.0.0.1"))) { + port = socket.getLocalPort(); + } + RuntimeScope scope = new RuntimeScope("tenant-a", "workspace-a", "7", + "/runtime/workspace", DIGEST, "workspace"); + RuntimeProvisionRequest request = new RuntimeProvisionRequest( + scope, null, LocalProcessRuntimeProvisioner.KIND); + RuntimeProvisionSeed seed = RuntimeProvisionSeed.create( + "binding-1", 1); + Set before = childPids(); + try (LocalProcessRuntimeProvisioner provisioner = + new LocalProcessRuntimeProvisioner( + List.of("node", script.toString(), "--port=" + port), + Path.of("").toAbsolutePath(), + new HttpRuntimeTransport())) { + RuntimeLease first = provisioner.provision(request, seed) + .toCompletableFuture().get(30, TimeUnit.SECONDS); + assertEquals(port, first.getEndpoint().getPort()); + ProcessHandle worker = ProcessHandle.current().children() + .filter(process -> !before.contains(process.pid())) + .findFirst().orElseThrow(); + if (release) { + provisioner.release(request, first).toCompletableFuture() + .get(10, TimeUnit.SECONDS); + } else { + worker.destroyForcibly(); + } + worker.onExit().get(10, TimeUnit.SECONDS); + assertFalse(provisioner.isUsable(first)); + if (release) { + provisioner.release(request, first).toCompletableFuture() + .get(10, TimeUnit.SECONDS); + } + ExecutionException failure = org.junit.jupiter.api.Assertions + .assertThrows(ExecutionException.class, + () -> provisioner.provision(request, seed) + .toCompletableFuture() + .get(30, TimeUnit.SECONDS)); + assertTrue(failure.getCause() instanceof RuntimeBrokerException); + RuntimeBrokerException error = + (RuntimeBrokerException) failure.getCause(); + assertEquals(409, error.getStatusCode()); + assertEquals("runtime_broker_resource_conflict", error.getCode()); + assertFalse(error.isRetryable()); + assertNoNewChildren(before); + provisioner.release(request, first).toCompletableFuture() + .get(10, TimeUnit.SECONDS); + assertFalse(provisioner.isUsable(first)); + assertNoNewChildren(before); + } finally { + ProcessHandle.current().children() + .filter(process -> !before.contains(process.pid())) + .forEach(ProcessHandle::destroyForcibly); + assertNoNewChildren(before); + } + } + @Test void rejectsMalformedCapabilityDigestBeforeSpawning() { RuntimeScope scope = new RuntimeScope("tenant-a", "workspace-a", "7", @@ -414,6 +579,17 @@ public RuntimeBindingRecord releaseOperation(String bindingId, } private static final class AcceptingTransport implements RuntimeTransport { + @Override + public java.util.concurrent.CompletionStage attest( + RuntimeLease lease, RuntimeProvisionRequest request, + RuntimeProvisionSeed seed) { + return java.util.concurrent.CompletableFuture.completedFuture( + new RuntimeAttestation(lease.getRuntimeInstanceId(), + seed.getGatewayIncarnation(), lease.getLeaseId(), + lease.getEpoch(), request.getScope(), + seed.getProvisionRequestId())); + } + @Override public java.util.concurrent.CompletionStage acquire( RuntimeLease lease, RuntimeSession session) { diff --git a/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerHttpServerTest.java b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerHttpServerTest.java new file mode 100644 index 00000000000..8df5636a24e --- /dev/null +++ b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/RuntimeBrokerHttpServerTest.java @@ -0,0 +1,153 @@ +package com.alibaba.qwen.code.runtimebroker; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.alibaba.fastjson2.JSON; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Clock; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class RuntimeBrokerHttpServerTest { + @Test + void unsupportedOperationsNeverDispatchOrClaimResolution() throws Exception { + try (Fixture fixture = new Fixture()) { + assertEquals(200, fixture.post("/tool-sessions:acquire", Map.of( + "protocolVersion", 1, "requestId", "acquire", + "harnessSessionId", "harness", "runtimeSessionId", "runtime", + "turnKind", "bootstrap")).statusCode()); + for (String path : new String[] {"/executions:prepare", + "/executions/call:start", "/executions/call:resolve"}) { + HttpResponse response = fixture.post(path, Map.of( + "protocolVersion", 1, "requestId", "request", + "idempotencyKey", "key", "harnessSessionId", "harness", + "runtimeSessionId", "runtime", "turnId", "turn", + "toolCallId", "call", "requestDigest", "digest", + "reference", reference(), "resolution", "accepted_unknown")); + assertEquals(501, response.statusCode(), response.body()); + assertTrue(response.body().contains("runtime_broker_operation_unsupported")); + } + assertEquals(0, fixture.transport.executions.get()); + } + } + + @Test + void unknownExecutionDoesNotBecomeKnownExecuting() throws Exception { + try (Fixture fixture = new Fixture()) { + fixture.service.acquire("harness", "runtime", "bootstrap") + .toCompletableFuture().join(); + ToolExecutionRecord created = fixture.service.createExecution( + "harness", "runtime", "key", reference()) + .toCompletableFuture().join(); + String path = "/executions/" + created.getExecutionCallId() + + "?requestId=read&harnessSessionId=harness&runtimeSessionId=runtime"; + HttpRequest request = HttpRequest.newBuilder(fixture.uri(path)) + .header("Authorization", "Bearer secret").GET().build(); + HttpResponse response = fixture.client.send(request, + HttpResponse.BodyHandlers.ofString()); + assertEquals(409, response.statusCode(), response.body()); + assertTrue(response.body().contains("runtime_broker_execution_unknown")); + assertEquals(1, fixture.transport.executions.get()); + } + } + + @Test + void authenticatesBeforeProcessingUnsupportedOperations() throws Exception { + try (Fixture fixture = new Fixture()) { + HttpRequest request = HttpRequest.newBuilder(fixture.uri("/executions:prepare")) + .POST(HttpRequest.BodyPublishers.ofString("{}")) + .build(); + assertEquals(401, fixture.client.send(request, + HttpResponse.BodyHandlers.ofString()).statusCode()); + assertEquals(0, fixture.transport.executions.get()); + } + } + + private static Map reference() { + return Map.of("sessionId", "runtime", "promptId", "turn", + "callId", "call", "argsDigest", "digest"); + } + + private static final class Fixture implements AutoCloseable { + private final FailingTransport transport = new FailingTransport(); + private final HttpClient client = HttpClient.newHttpClient(); + private final RuntimeBrokerService service; + private final RuntimeBrokerHttpServer server; + + private Fixture() throws Exception { + RuntimeScope scope = new RuntimeScope("tenant", "workspace", + "generation", "/workspace", "capability", "workspace"); + service = new RuntimeBrokerService( + id -> CompletableFuture.completedFuture(scope), + new StaticRuntimeProvisioner(new RuntimeLease("instance", + URI.create("http://127.0.0.1:1234"), "token", "lease", 1)), + transport, new InMemoryRuntimeBindingRepository(), + new InMemoryRuntimeSessionRepository(), + new InMemoryToolExecutionRepository(Clock.systemUTC()), + "broker", Duration.ofMinutes(1), Duration.ofMinutes(1)); + server = new RuntimeBrokerHttpServer(new InetSocketAddress("127.0.0.1", 0), + "secret", service); + server.start(); + } + + private URI uri(String path) { + return server.getBaseUri().resolve(RuntimeBrokerHttpServer.ROUTE_PREFIX + path); + } + + private HttpResponse post(String path, Map body) throws Exception { + return client.send(HttpRequest.newBuilder(uri(path)) + .header("Authorization", "Bearer secret") + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(JSON.toJSONString(body))) + .build(), HttpResponse.BodyHandlers.ofString()); + } + + @Override + public void close() { + server.close(); + client.close(); + } + } + + private static final class FailingTransport implements RuntimeTransport { + private final AtomicInteger executions = new AtomicInteger(); + + @Override + public CompletionStage acquire(RuntimeLease lease, RuntimeSession session) { + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletionStage control(RuntimeLease lease, RuntimeSession session, + Map operation) { + return CompletableFuture.completedFuture(Map.of()); + } + + @Override + public CompletionStage> execute(RuntimeLease lease, + RuntimeSession session, Map reference) { + executions.incrementAndGet(); + return CompletableFuture.failedFuture(new IllegalStateException("connection lost")); + } + + @Override + public CompletionStage> cancel(RuntimeLease lease, + RuntimeSession session, Map reference) { + return CompletableFuture.completedFuture(Map.of("state", "unknown")); + } + + @Override + public CompletionStage release(RuntimeLease lease, RuntimeSession session) { + return CompletableFuture.completedFuture(true); + } + } +} diff --git a/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/ConfiguredWorkspaceRegistryTest.java b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/ConfiguredWorkspaceRegistryTest.java new file mode 100644 index 00000000000..6d420b73a88 --- /dev/null +++ b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/ConfiguredWorkspaceRegistryTest.java @@ -0,0 +1,279 @@ +package com.alibaba.qwen.code.runtimebroker.managedworkspace; + +import static com.alibaba.qwen.code.runtimebroker.managedworkspace.TestWorkspaces.OTHER_TENANT; +import static com.alibaba.qwen.code.runtimebroker.managedworkspace.TestWorkspaces.TENANT; +import static com.alibaba.qwen.code.runtimebroker.managedworkspace.TestWorkspaces.copy; +import static com.alibaba.qwen.code.runtimebroker.managedworkspace.TestWorkspaces.workspace; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class ConfiguredWorkspaceRegistryTest { + @Test + void findsOnlyWithinTheTenant() { + WorkspaceRecord own = workspace(TENANT, "shared", WorkspaceState.ACTIVE); + WorkspaceRecord other = workspace(OTHER_TENANT, "shared", + WorkspaceState.ACTIVE); + WorkspaceRecord onlyOther = workspace(OTHER_TENANT, "private", + WorkspaceState.ACTIVE); + ConfiguredWorkspaceRegistry registry = new ConfiguredWorkspaceRegistry( + List.of(own, other, onlyOther), Map.of()); + + assertEquals(own, registry.find(TENANT, "shared").orElseThrow()); + assertEquals(own, registry.find(copy(TENANT), copy("shared")) + .orElseThrow()); + assertEquals(other, registry.find(OTHER_TENANT, "shared").orElseThrow()); + assertTrue(registry.find(TENANT, "private").isEmpty()); + } + + @Test + void findsNothingForValuesThatCannotBeIdentifiers() { + ConfiguredWorkspaceRegistry registry = new ConfiguredWorkspaceRegistry( + List.of(workspace(TENANT, "ws-a", WorkspaceState.ACTIVE)), + Map.of()); + + assertTrue(registry.find(TENANT, "../ws-a").isEmpty()); + assertTrue(registry.find(TENANT, null).isEmpty()); + assertTrue(registry.find(null, "ws-a").isEmpty()); + assertTrue(registry.defaultWorkspaceId(null).isEmpty()); + assertTrue(registry.page(null, null, 10).isEmpty()); + } + + @Test + void pagesInByteOrderOfTheWorkspaceId() { + List records = new ArrayList<>(); + for (String id : List.of("b", "B", "a", "_x", "0")) { + records.add(workspace(TENANT, id, WorkspaceState.ACTIVE)); + } + ConfiguredWorkspaceRegistry registry = new ConfiguredWorkspaceRegistry( + records, Map.of()); + + assertEquals(List.of("0"), ids(registry.page(TENANT, null, 1))); + assertEquals(List.of("0", "B"), ids(registry.page(TENANT, null, 2))); + assertEquals(List.of("_x", "a"), ids(registry.page(TENANT, "B", 2))); + assertEquals(List.of("b"), ids(registry.page(TENANT, "a", 2))); + assertEquals(List.of(), ids(registry.page(TENANT, "b", 2))); + assertEquals(List.of("_x", "a", "b"), + ids(registry.page(TENANT, "Z", 5))); + assertEquals(List.of(), ids(registry.page(OTHER_TENANT, null, 5))); + } + + @Test + void returnsPagesThatCannotBeChanged() { + ConfiguredWorkspaceRegistry registry = new ConfiguredWorkspaceRegistry( + List.of(workspace(TENANT, "ws-a", WorkspaceState.ACTIVE)), + Map.of()); + + assertThrows(UnsupportedOperationException.class, + () -> registry.page(TENANT, null, 10).clear()); + } + + @Test + void rejectsInvalidPageArguments() { + ConfiguredWorkspaceRegistry registry = new ConfiguredWorkspaceRegistry( + List.of(), Map.of()); + + assertThrows(IllegalArgumentException.class, + () -> registry.page(TENANT, null, 0)); + assertThrows(IllegalArgumentException.class, + () -> registry.page(TENANT, null, 1001)); + assertThrows(IllegalArgumentException.class, + () -> registry.page(TENANT, "a/b", 10)); + assertEquals(List.of(), registry.page(TENANT, null, 1000)); + } + + @Test + void groupsTenantsByContentNotByInstance() { + assertThrows(IllegalArgumentException.class, + () -> new ConfiguredWorkspaceRegistry(List.of( + workspace(TENANT, "ws-a", WorkspaceState.ACTIVE), + workspace(copy(TENANT), "ws-a", WorkspaceState.ACTIVE)), + Map.of())); + ConfiguredWorkspaceRegistry registry = new ConfiguredWorkspaceRegistry( + List.of(workspace(TENANT, "ws-a", WorkspaceState.ACTIVE), + workspace(copy(TENANT), "ws-b", WorkspaceState.ACTIVE)), + Map.of(copy(TENANT), "ws-b")); + + assertEquals(List.of("ws-a", "ws-b"), + ids(registry.page(TENANT, null, 10))); + assertEquals("ws-b", registry.defaultWorkspaceId(TENANT).orElseThrow()); + Map twice = new IdentityHashMap<>(); + twice.put(TENANT, "ws-a"); + twice.put(copy(TENANT), "ws-b"); + assertThrows(IllegalArgumentException.class, + () -> new ConfiguredWorkspaceRegistry(List.of( + workspace(TENANT, "ws-a", WorkspaceState.ACTIVE), + workspace(TENANT, "ws-b", WorkspaceState.ACTIVE)), + twice)); + } + + @Test + void keepsTenantsThatDifferInCaseApart() { + ConfiguredWorkspaceRegistry registry = new ConfiguredWorkspaceRegistry( + List.of(workspace(TENANT, "ws-x", WorkspaceState.ACTIVE), + workspace("TENANT-A", "ws-y", WorkspaceState.ACTIVE)), + Map.of(TENANT, "ws-x", "TENANT-A", "ws-y")); + + assertEquals(List.of("ws-x"), ids(registry.page(TENANT, null, 10))); + assertEquals(List.of("ws-y"), ids(registry.page("TENANT-A", null, 10))); + assertTrue(registry.find("TENANT-A", "ws-y").isPresent()); + assertTrue(registry.find(TENANT, "ws-y").isEmpty()); + assertEquals("ws-y", + registry.defaultWorkspaceId("TENANT-A").orElseThrow()); + } + + @Test + void rejectsAWorkspaceRegisteredTwiceInOneTenant() { + assertThrows(IllegalArgumentException.class, + () -> new ConfiguredWorkspaceRegistry(List.of( + workspace(TENANT, "ws-a", WorkspaceState.ACTIVE), + workspace(TENANT, "ws-a", WorkspaceState.DRAINING)), + Map.of())); + } + + @Test + void requiresEachDefaultToNameAWorkspaceOfItsTenant() { + List records = List.of( + workspace(TENANT, "ws-a", WorkspaceState.ACTIVE), + workspace(OTHER_TENANT, "ws-b", WorkspaceState.ACTIVE)); + + assertThrows(IllegalArgumentException.class, + () -> new ConfiguredWorkspaceRegistry(records, + Map.of(TENANT, "ws-b"))); + assertThrows(IllegalArgumentException.class, + () -> new ConfiguredWorkspaceRegistry(records, + Map.of(TENANT, "missing"))); + assertThrows(IllegalArgumentException.class, + () -> new ConfiguredWorkspaceRegistry(records, + Map.of("bad/tenant", "ws-a"))); + assertThrows(IllegalArgumentException.class, + () -> new ConfiguredWorkspaceRegistry(records, + Map.of("tenant-without-records", "ws-a"))); + assertEquals("ws-a", new ConfiguredWorkspaceRegistry(records, + Map.of(TENANT, "ws-a")).defaultWorkspaceId(TENANT) + .orElseThrow()); + } + + @Test + void allowsADefaultInAnyStateAndLeavesUsabilityToTheCatalog() { + ConfiguredWorkspaceRegistry registry = new ConfiguredWorkspaceRegistry( + List.of(workspace(TENANT, "ws-a", WorkspaceState.DRAINING)), + Map.of(TENANT, "ws-a")); + + assertEquals("ws-a", registry.defaultWorkspaceId(TENANT).orElseThrow()); + assertTrue(registry.defaultWorkspaceId(OTHER_TENANT).isEmpty()); + } + + @Test + void rejectsMissingInputs() { + assertThrows(IllegalArgumentException.class, + () -> new ConfiguredWorkspaceRegistry(null, Map.of())); + assertThrows(IllegalArgumentException.class, + () -> new ConfiguredWorkspaceRegistry(List.of(), null)); + List withNull = new ArrayList<>(); + withNull.add(null); + assertThrows(IllegalArgumentException.class, + () -> new ConfiguredWorkspaceRegistry(withNull, Map.of())); + List records = List.of( + workspace(TENANT, "ws-a", WorkspaceState.ACTIVE)); + Map nullDefault = new HashMap<>(); + nullDefault.put(TENANT, null); + assertThrows(IllegalArgumentException.class, + () -> new ConfiguredWorkspaceRegistry(records, nullDefault)); + Map nullTenant = new HashMap<>(); + nullTenant.put(null, "ws-a"); + assertThrows(IllegalArgumentException.class, + () -> new ConfiguredWorkspaceRegistry(records, nullTenant)); + } + + @Test + void doesNotChangeWhenTheInputsChangeLater() { + List records = new ArrayList<>(List.of( + workspace(TENANT, "ws-a", WorkspaceState.ACTIVE))); + Map defaults = new HashMap<>(Map.of(TENANT, "ws-a")); + ConfiguredWorkspaceRegistry registry = new ConfiguredWorkspaceRegistry( + records, defaults); + + records.add(workspace(TENANT, "ws-b", WorkspaceState.ACTIVE)); + defaults.clear(); + + assertTrue(registry.find(TENANT, "ws-b").isEmpty()); + assertEquals("ws-a", registry.defaultWorkspaceId(TENANT).orElseThrow()); + } + + @Test + void acceptsSuccessorsThatKeepEveryWorkspace() { + ConfiguredWorkspaceRegistry previous = snapshot( + workspace(TENANT, "ws-a", 3, "storage-1", WorkspaceState.ACTIVE)); + + ConfiguredWorkspaceRegistry same = snapshot( + workspace(TENANT, "ws-a", 3, "storage-1", WorkspaceState.ACTIVE)); + assertSame(same, same.requireSuccessorOf(previous)); + snapshot(workspace(TENANT, "ws-a", 3, "storage-1", + WorkspaceState.REMOVED)).requireSuccessorOf(previous); + snapshot(workspace(copy(TENANT), copy("ws-a"), 3, copy("storage-1"), + WorkspaceState.ACTIVE)).requireSuccessorOf(previous); + snapshot(workspace(TENANT, "ws-a", 4, "storage-2", + WorkspaceState.ACTIVE)).requireSuccessorOf(previous); + snapshot(workspace(TENANT, "ws-a", 3, "storage-1", + WorkspaceState.ACTIVE), workspace(TENANT, "ws-b", + WorkspaceState.ACTIVE)).requireSuccessorOf(previous); + } + + @Test + void rejectsSuccessorsThatDropLowerOrSwapStorageSilently() { + ConfiguredWorkspaceRegistry previous = snapshot( + workspace(TENANT, "ws-a", 3, "storage-1", WorkspaceState.ACTIVE)); + + assertThrows(IllegalArgumentException.class, + () -> snapshot().requireSuccessorOf(previous)); + assertThrows(IllegalArgumentException.class, + () -> snapshot(workspace(OTHER_TENANT, "ws-a", 3, "storage-1", + WorkspaceState.ACTIVE)).requireSuccessorOf(previous)); + assertThrows(IllegalArgumentException.class, + () -> snapshot(workspace(TENANT, "ws-a", 2, "storage-1", + WorkspaceState.ACTIVE)).requireSuccessorOf(previous)); + assertThrows(IllegalArgumentException.class, + () -> snapshot(workspace(TENANT, "ws-a", 3, "storage-2", + WorkspaceState.ACTIVE)).requireSuccessorOf(previous)); + assertThrows(IllegalArgumentException.class, + () -> snapshot().requireSuccessorOf(null)); + } + + @Test + void comparesSuccessorsExactly() { + ConfiguredWorkspaceRegistry previous = snapshot( + workspace(TENANT, "ws-a", 3, "storage-1", WorkspaceState.ACTIVE)); + + assertThrows(IllegalArgumentException.class, + () -> snapshot(workspace(TENANT, "ws-a", 3, "Storage-1", + WorkspaceState.ACTIVE)).requireSuccessorOf(previous)); + assertThrows(IllegalArgumentException.class, + () -> snapshot(workspace(TENANT, "WS-A", 3, "storage-1", + WorkspaceState.ACTIVE)).requireSuccessorOf(previous)); + assertThrows(IllegalArgumentException.class, + () -> snapshot(workspace("TENANT-A", "ws-a", 3, "storage-1", + WorkspaceState.ACTIVE)).requireSuccessorOf(previous)); + } + + private static ConfiguredWorkspaceRegistry snapshot( + WorkspaceRecord... records) { + return new ConfiguredWorkspaceRegistry(List.of(records), Map.of()); + } + + private static List ids(List records) { + List ids = new ArrayList<>(); + for (WorkspaceRecord record : records) { + ids.add(record.getWorkspaceId()); + } + return ids; + } +} diff --git a/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/ConstructorVisibilityTest.java b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/ConstructorVisibilityTest.java new file mode 100644 index 00000000000..5472e6e3093 --- /dev/null +++ b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/ConstructorVisibilityTest.java @@ -0,0 +1,24 @@ +package com.alibaba.qwen.code.runtimebroker.managedworkspace; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class ConstructorVisibilityTest { + @Test + void buildsCheckedValuesOnlyThroughTheirFactories() { + // A public constructor would let a caller skip a check: a selection + // with an unnormalized directory, a resolved Workspace, view or page + // the catalog did not produce, a policy without the builder's grant + // checks, or an error with an arbitrary status and code. The + // directory rule is a static utility and is never instantiated. + for (Class type : List.of(WorkspaceSelection.class, + ResolvedWorkspace.class, WorkspaceView.class, + WorkspacePage.class, GrantedWorkspaceAccessPolicy.class, + GrantedWorkspaceAccessPolicy.Builder.class, + WorkspaceException.class, WorkspaceRelativePath.class)) { + assertEquals(0, type.getConstructors().length, type.getName()); + } + } +} diff --git a/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/ContextBindingTest.java b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/ContextBindingTest.java new file mode 100644 index 00000000000..192c67fb7ce --- /dev/null +++ b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/ContextBindingTest.java @@ -0,0 +1,176 @@ +package com.alibaba.qwen.code.runtimebroker.managedworkspace; + +import static com.alibaba.qwen.code.runtimebroker.managedworkspace.TestWorkspaces.copy; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Consumer; +import org.junit.jupiter.api.Test; + +class ContextBindingTest { + @Test + void derivesThePinnedDigestOfTheRootFixture() { + ContextBinding binding = new Values().build(); + + assertEquals("sha256:53806bd3c066c6594c331e02a3a88df2ff50f334a793fa5" + + "79837fb99a5b3d1dd", binding.getContextDigest()); + } + + @Test + void encodesLengthPrefixedUtf8ItemsInFieldOrder() { + Values values = new Values(); + values.cwdRelative = "项目"; + ByteBuffer encoded = ByteBuffer.wrap(values.build().encode()); + + List items = List.of(ContextBinding.DOMAIN_TAG, "tenant-a", + "ws_project_a", "7", "storage://pvc/project-a", "项目", + "config:bundle-3@r12", "1"); + for (String item : items) { + byte[] expected = item.getBytes(StandardCharsets.UTF_8); + byte[] actual = new byte[encoded.getInt()]; + encoded.get(actual); + assertArrayEquals(expected, actual, item); + } + assertEquals(0, encoded.remaining()); + } + + @Test + void writesSixtyFourBitIntegersAsDecimalText() { + Values values = new Values(); + values.generation = Long.MAX_VALUE; + values.revision = 9_007_199_254_740_993L; + String encoded = new String(values.build().encode(), + StandardCharsets.UTF_8); + + assertTrue(encoded.contains("9223372036854775807")); + assertTrue(encoded.contains("9007199254740993")); + } + + @Test + void changesTheDigestWhenAnyFieldChanges() { + List> changes = List.of( + values -> values.tenantId = "tenant-b", + values -> values.workspaceId = "ws_project_b", + values -> values.generation = 8, + values -> values.storageId = "storage://pvc/project-b", + values -> values.cwdRelative = "services", + values -> values.configRef = "config:bundle-4", + values -> values.revision = 2); + Set digests = new HashSet<>(); + digests.add(new Values().build().getContextDigest()); + for (Consumer change : changes) { + Values values = new Values(); + change.accept(values); + digests.add(values.build().getContextDigest()); + } + assertEquals(changes.size() + 1, digests.size()); + } + + @Test + void separatesFieldsSoConcatenationCannotCollide() { + Values first = new Values(); + first.tenantId = "ab"; + first.workspaceId = "c"; + Values second = new Values(); + second.tenantId = "a"; + second.workspaceId = "bc"; + + assertNotEquals(first.build().getContextDigest(), + second.build().getContextDigest()); + } + + @Test + void acceptsOnlyANormalizedDirectory() { + for (String cwd : new String[] {"./services", "services/", "a//b", + "../x", "/abs", "services\n", "", null}) { + Values values = new Values(); + values.cwdRelative = cwd; + assertThrows(IllegalArgumentException.class, values::build, + String.valueOf(cwd)); + } + } + + @Test + void validatesEveryOtherField() { + assertInvalid(values -> values.tenantId = "tenant/a"); + assertInvalid(values -> values.workspaceId = ""); + assertInvalid(values -> values.generation = 0); + assertInvalid(values -> values.generation = -1); + assertInvalid(values -> values.generation = Long.MIN_VALUE); + assertInvalid(values -> values.storageId = "storage a"); + assertInvalid(values -> values.configRef = "config:é"); + assertInvalid(values -> values.configRef = "c".repeat(513)); + assertInvalid(values -> values.revision = 0); + assertInvalid(values -> values.revision = -1); + assertInvalid(values -> values.revision = Long.MIN_VALUE); + } + + @Test + void comparesByEveryField() { + assertEquals(new Values().build(), new Values().build()); + assertEquals(new Values().build().hashCode(), + new Values().build().hashCode()); + List> changes = List.of( + values -> values.tenantId = "tenant-b", + values -> values.workspaceId = "ws_project_b", + values -> values.generation = 8, + values -> values.storageId = "storage://pvc/project-b", + values -> values.cwdRelative = "services", + values -> values.configRef = "config:bundle-4", + values -> values.revision = 2, + values -> values.tenantId = "TENANT-A", + values -> values.workspaceId = "WS_project_a", + values -> values.storageId = "storage://PVC/project-a", + values -> values.configRef = "CONFIG:bundle-3@r12"); + for (Consumer change : changes) { + Values changed = new Values(); + change.accept(changed); + assertNotEquals(new Values().build(), changed.build()); + } + Values lower = new Values(); + lower.cwdRelative = "services"; + Values upper = new Values(); + upper.cwdRelative = "Services"; + assertNotEquals(lower.build(), upper.build()); + Values copied = new Values(); + copied.tenantId = copy(copied.tenantId); + copied.workspaceId = copy(copied.workspaceId); + copied.storageId = copy(copied.storageId); + copied.cwdRelative = copy(copied.cwdRelative); + copied.configRef = copy(copied.configRef); + assertEquals(new Values().build(), copied.build()); + ContextBinding binding = new Values().build(); + assertFalse(binding.equals(null)); + assertFalse(binding.equals(binding.getContextDigest())); + } + + private static void assertInvalid(Consumer change) { + Values values = new Values(); + change.accept(values); + assertThrows(IllegalArgumentException.class, values::build); + } + + private static final class Values { + private String tenantId = "tenant-a"; + private String workspaceId = "ws_project_a"; + private long generation = 7; + private String storageId = "storage://pvc/project-a"; + private String cwdRelative = "."; + private String configRef = "config:bundle-3@r12"; + private long revision = 1; + + ContextBinding build() { + return new ContextBinding(tenantId, workspaceId, generation, + storageId, cwdRelative, configRef, revision); + } + } +} diff --git a/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/GrantedWorkspaceAccessPolicyTest.java b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/GrantedWorkspaceAccessPolicyTest.java new file mode 100644 index 00000000000..d77a9f5d36d --- /dev/null +++ b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/GrantedWorkspaceAccessPolicyTest.java @@ -0,0 +1,129 @@ +package com.alibaba.qwen.code.runtimebroker.managedworkspace; + +import static com.alibaba.qwen.code.runtimebroker.managedworkspace.TestWorkspaces.OTHER_TENANT; +import static com.alibaba.qwen.code.runtimebroker.managedworkspace.TestWorkspaces.TENANT; +import static com.alibaba.qwen.code.runtimebroker.managedworkspace.TestWorkspaces.copy; +import static com.alibaba.qwen.code.runtimebroker.managedworkspace.TestWorkspaces.workspace; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class GrantedWorkspaceAccessPolicyTest { + private static final WorkspaceRecord WORKSPACE = workspace(TENANT, "ws-a", + WorkspaceState.ACTIVE); + + @Test + void grantsOnlyTheExactTenantActorAndWorkspace() { + GrantedWorkspaceAccessPolicy policy = GrantedWorkspaceAccessPolicy + .builder() + .grant(TENANT, "alice", "ws-a", WorkspaceAccess.CREATE) + .grant(TENANT, "bob", "ws-a", WorkspaceAccess.READ) + .build(); + + assertEquals(WorkspaceAccess.CREATE, + policy.accessFor(new WorkspaceActor(TENANT, "alice"), WORKSPACE)); + assertEquals(WorkspaceAccess.CREATE, policy.accessFor( + new WorkspaceActor(copy(TENANT), copy("alice")), + workspace(copy(TENANT), copy("ws-a"), WorkspaceState.ACTIVE))); + assertEquals(WorkspaceAccess.READ, + policy.accessFor(new WorkspaceActor(TENANT, "bob"), WORKSPACE)); + assertEquals(WorkspaceAccess.NONE, + policy.accessFor(new WorkspaceActor(TENANT, "carol"), WORKSPACE)); + assertEquals(WorkspaceAccess.NONE, policy.accessFor( + new WorkspaceActor(TENANT, "alice"), + workspace(TENANT, "ws-b", WorkspaceState.ACTIVE))); + assertEquals(WorkspaceAccess.NONE, + policy.accessFor(new WorkspaceActor(TENANT, "Alice"), WORKSPACE)); + assertEquals(WorkspaceAccess.NONE, policy.accessFor( + new WorkspaceActor(TENANT, "alice"), + workspace(TENANT, "WS-A", WorkspaceState.ACTIVE))); + } + + @Test + void neverCrossesTenantsEvenForTheSameIds() { + GrantedWorkspaceAccessPolicy policy = GrantedWorkspaceAccessPolicy + .builder() + .grant(OTHER_TENANT, "alice", "ws-a", WorkspaceAccess.CREATE) + .build(); + + assertEquals(WorkspaceAccess.NONE, + policy.accessFor(new WorkspaceActor(TENANT, "alice"), WORKSPACE)); + assertEquals(WorkspaceAccess.NONE, policy.accessFor( + new WorkspaceActor(OTHER_TENANT, "alice"), WORKSPACE)); + assertEquals(WorkspaceAccess.NONE, policy.accessFor(null, WORKSPACE)); + assertEquals(WorkspaceAccess.NONE, policy.accessFor( + new WorkspaceActor(OTHER_TENANT, "alice"), null)); + } + + @Test + void treatsATenantThatDiffersInCaseAsAnotherTenant() { + GrantedWorkspaceAccessPolicy policy = GrantedWorkspaceAccessPolicy + .builder() + .grant("TENANT-A", "alice", "ws-a", WorkspaceAccess.CREATE) + .build(); + + assertEquals(WorkspaceAccess.NONE, policy.accessFor( + new WorkspaceActor("TENANT-A", "alice"), WORKSPACE)); + assertEquals(WorkspaceAccess.CREATE, policy.accessFor( + new WorkspaceActor("TENANT-A", "alice"), + workspace("TENANT-A", "ws-a", WorkspaceState.ACTIVE))); + } + + @Test + void rejectsAmbiguousOrEmptyGrants() { + GrantedWorkspaceAccessPolicy.Builder builder = + GrantedWorkspaceAccessPolicy.builder() + .grant(TENANT, "alice", "ws-a", WorkspaceAccess.READ); + + assertThrows(IllegalArgumentException.class, () -> builder.grant( + TENANT, "alice", "ws-a", WorkspaceAccess.CREATE)); + assertThrows(IllegalArgumentException.class, () -> builder.grant( + TENANT, "alice", "ws-b", WorkspaceAccess.NONE)); + assertThrows(IllegalArgumentException.class, + () -> builder.grant(TENANT, "alice", "ws-b", null)); + assertThrows(IllegalArgumentException.class, () -> builder.grant( + TENANT, "alice\n", "ws-b", WorkspaceAccess.READ)); + assertThrows(IllegalArgumentException.class, () -> builder.grant( + TENANT, "alice", "ws/b", WorkspaceAccess.READ)); + assertThrows(IllegalArgumentException.class, () -> builder.grant( + "tenant/a", "alice", "ws-b", WorkspaceAccess.READ)); + } + + @Test + void keepsABuiltPolicyWhenTheBuilderIsReused() { + GrantedWorkspaceAccessPolicy.Builder builder = + GrantedWorkspaceAccessPolicy.builder() + .grant(TENANT, "alice", "ws-a", WorkspaceAccess.READ); + GrantedWorkspaceAccessPolicy policy = builder.build(); + + builder.grant(TENANT, "alice", "ws-b", WorkspaceAccess.CREATE); + + assertEquals(WorkspaceAccess.NONE, policy.accessFor( + new WorkspaceActor(TENANT, "alice"), + workspace(TENANT, "ws-b", WorkspaceState.ACTIVE))); + } + + @Test + void grantsAnActorIdThatIsNotAnIdentifier() { + GrantedWorkspaceAccessPolicy policy = GrantedWorkspaceAccessPolicy + .builder() + .grant(TENANT, "alice@example.com", "ws-a", + WorkspaceAccess.READ) + .build(); + + assertEquals(WorkspaceAccess.READ, policy.accessFor( + new WorkspaceActor(TENANT, "alice@example.com"), WORKSPACE)); + } + + @Test + void createImpliesRead() { + assertTrue(WorkspaceAccess.CREATE.canRead()); + assertTrue(WorkspaceAccess.CREATE.canCreate()); + assertTrue(WorkspaceAccess.READ.canRead()); + assertFalse(WorkspaceAccess.READ.canCreate()); + assertFalse(WorkspaceAccess.NONE.canRead()); + } +} diff --git a/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/ManagedContextEnvelopeConformanceTest.java b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/ManagedContextEnvelopeConformanceTest.java new file mode 100644 index 00000000000..2f19ff40602 --- /dev/null +++ b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/ManagedContextEnvelopeConformanceTest.java @@ -0,0 +1,424 @@ +package com.alibaba.qwen.code.runtimebroker.managedworkspace; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.IntNode; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.regex.Pattern; +import org.junit.jupiter.api.Test; + +/** + * Pins the managed-context/1 envelope (W0a-2) to the language-neutral schema + * and fixtures that the TypeScript implementation replays. Java builds and + * checks these records once W0c wires the protocol; until then this test + * fixes the closed key sets, the routes and the constants, and recomputes + * every installation digest with {@link ContextBinding}. + */ +class ManagedContextEnvelopeConformanceTest { + private static final ObjectMapper JSON = new ObjectMapper(); + private static final Pattern DECIMAL = Pattern.compile("[1-9][0-9]{0,18}"); + private static final String IDENTITY_CONFLICT = + "managed_runtime_identity_conflict"; + // Statuses and versions compare as whole JSON nodes, so 2.5 or 2^32+2 + // cannot pass for 2. + private static final JsonNode OK = IntNode.valueOf(200); + private static final JsonNode BAD_REQUEST = IntNode.valueOf(400); + private static final JsonNode PROTOCOL_VERSION = IntNode.valueOf(3); + + private static final Set BOOT_KEYS = Set.of("type", "version", + "managedContext", "runtimeInstanceId", "runtimeIncarnation", + "leaseId", "provisionRequestId", "token", "epoch", + "capabilityDigest", "isolationClass", "tenantId", "workspaceId", + "workspaceGeneration", "storageId", "mountRoot"); + private static final Set READY_KEYS = Set.of("type", "version", + "managedContext", "runtimeInstanceId", "runtimeIncarnation", + "leaseId", "epoch", "url"); + private static final Set ATTESTATION_KEYS = Set.of( + "protocolVersion", "managedContext", "provisionRequestId", + "tenantId", "workspaceId", "workspaceGeneration", "storageId", + "mountRoot", "capabilityDigest", "isolationClass"); + private static final Set RESPONSE_KEYS = union(ATTESTATION_KEYS, + Set.of("runtimeInstanceId", "runtimeIncarnation", "leaseId", + "epoch")); + private static final Set INSTALLATION_KEYS = Set.of( + "protocolVersion", "managedContext", "operationId", "sessionId", + "binding", "contextDigest"); + private static final Set BINDING_KEYS = Set.of("tenantId", + "workspaceId", "workspaceGeneration", "storageId", "cwdRelative", + "contextConfigRef", "contextRevision"); + private static final Set RECEIPT_KEYS = Set.of("protocolVersion", + "managedContext", "operationId", "sessionId", "runtimeInstanceId", + "runtimeIncarnation", "epoch", "contextDigest", "contextRevision", + "workspaceGeneration"); + /** The binding fields that must match the Workspace the Runtime booted. */ + private static final List WORKSPACE_FIELDS = List.of("tenantId", + "workspaceId", "workspaceGeneration", "storageId"); + + @Test + void pinsTheProtocolTokenVersionsAndRoutes() throws IOException { + JsonNode fixtures = fixtures(); + JsonNode routes = JSON.readTree(""" + [{"key": "attest", "method": "POST", + "path": "/internal/managed-runtime/v3/attest", + "protocolVersion": 3, "requestBodyLimitBytes": 16384, + "responseBodyLimitBytes": 16384, "cacheControl": "no-store"}, + {"key": "context", "method": "POST", + "path": "/internal/managed-runtime/v3/context", + "protocolVersion": 3, "requestBodyLimitBytes": 16384, + "responseBodyLimitBytes": 16384, "cacheControl": "no-store"}] + """); + + // Whole JSON nodes, so 2.5 or 2^32+2 cannot pass for 2. + assertEquals(JSON.readTree("1"), fixtures.required("contractVersion")); + assertEquals(JSON.readTree("\"managed-context/1\""), + fixtures.required("managedContext")); + assertEquals(JSON.readTree("2"), fixtures.required("bootVersion")); + assertEquals(JSON.readTree("2"), fixtures.required("readyVersion")); + assertEquals(routes, fixtures.required("routes")); + JsonNode properties = schema().required("properties"); + assertEquals(routes, properties.required("routes").required("const")); + assertEquals(JSON.readTree("1"), + properties.required("contractVersion").required("const")); + assertEquals(JSON.readTree("2"), + properties.required("bootVersion").required("const")); + assertEquals(JSON.readTree("2"), + properties.required("readyVersion").required("const")); + for (String record : List.of("boot", "ready")) { + JsonNode value = fixtures.required(record); + assertEquals(JSON.readTree("\"" + record + "\""), + value.required("type"), record); + assertEquals(JSON.readTree("2"), value.required("version"), + record); + assertEquals(fixtures.required("managedContext"), + value.required("managedContext"), record); + } + assertEquals(fixtures.required("managedContext"), fixtures + .required("attestationResponse").required("managedContext")); + } + + @Test + void pinsTheSchemaConstants() throws IOException { + JsonNode definitions = schema().required("$defs"); + + assertEquals(JSON.readTree("\"managed-context/1\""), + definitions.required("managedContext").required("const")); + assertEquals(JSON.readTree("\"boot\""), + constant(definitions, "bootV2", "type")); + assertEquals(JSON.readTree("2"), + constant(definitions, "bootV2", "version")); + assertEquals(JSON.readTree("\"ready\""), + constant(definitions, "readyV2", "type")); + assertEquals(JSON.readTree("2"), + constant(definitions, "readyV2", "version")); + for (String name : List.of("attestationRequestV3", + "attestationResponseV3", "installationRequest", "receipt")) { + assertEquals(JSON.readTree("3"), + constant(definitions, name, "protocolVersion"), name); + } + for (String name : List.of("attestationOutcome", + "installationOutcome")) { + JsonNode outcomes = definitions.required(name).required("oneOf"); + assertEquals(JSON.readTree("{\"const\": 200}"), outcomes.required(0) + .required("properties").required("status"), name); + assertEquals(JSON.readTree("{\"enum\": [400, 409]}"), + outcomes.required(1).required("properties") + .required("status"), name); + } + JsonNode reference = JSON.readTree( + "{\"$ref\": \"#/$defs/managedContext\"}"); + for (String name : List.of("bootV2", "readyV2", + "attestationRequestV3", "attestationResponseV3", + "installationRequest", "receipt")) { + assertEquals(reference, definitions.required(name) + .required("properties").required("managedContext"), name); + } + } + + @Test + void pinsTheErrorTable() throws IOException { + JsonNode errors = JSON.readTree(""" + [{"status": 400, "code": "managed_runtime_attestation_invalid", + "classification": "protocol"}, + {"status": 401, "code": "managed_runtime_unauthorized", + "classification": "credentials"}, + {"status": 404, "code": null, + "classification": "incompatible"}, + {"status": 409, "code": "managed_runtime_identity_conflict", + "classification": "identity"}, + {"status": 409, "code": "managed_context_conflict", + "classification": "identity"}, + {"status": 409, "code": "managed_context_unavailable", + "classification": "recovery"}, + {"status": 413, + "code": "managed_runtime_attestation_too_large", + "classification": "protocol"}] + """); + JsonNode fixtures = fixtures(); + + assertEquals(errors, fixtures.required("errors")); + assertEquals(errors, schema().required("properties") + .required("errors").required("const")); + Set listed = new HashSet<>(); + errors.forEach(error -> listed.add(error.required("status") + + " " + error.required("code").textValue())); + int refusals = 0; + for (JsonNode fixture : fixtures.required("attestationCases")) { + refusals += checkListed(listed, fixture.required("expected")); + } + for (JsonNode sequence : fixtures.required("installationSequences")) { + for (JsonNode step : sequence.required("steps")) { + refusals += checkListed(listed, step.required("expected")); + } + } + assertTrue(refusals > 0); + } + + @Test + void pinsEveryClosedKeySet() throws IOException { + Map> keySets = Map.of("bootV2", BOOT_KEYS, + "readyV2", READY_KEYS, "attestationRequestV3", + ATTESTATION_KEYS, "attestationResponseV3", RESPONSE_KEYS, + "installationRequest", INSTALLATION_KEYS, "binding", + BINDING_KEYS, "receipt", RECEIPT_KEYS); + JsonNode definitions = schema().required("$defs"); + for (Map.Entry> keySet : keySets.entrySet()) { + JsonNode definition = definitions.required(keySet.getKey()); + + assertEquals(new TreeSet<>(keySet.getValue()), + strings(definition.required("required")), + keySet.getKey()); + assertEquals(new TreeSet<>(keySet.getValue()), + keys(definition.required("properties")), keySet.getKey()); + JsonNode additional = definition.required("additionalProperties"); + assertTrue(additional.isBoolean() && !additional.booleanValue(), + keySet.getKey()); + // No other keyword, such as patternProperties, may reopen it. + assertEquals(Set.of("additionalProperties", "properties", + "required", "type"), keys(definition), keySet.getKey()); + } + JsonNode fixtures = fixtures(); + assertEquals(new TreeSet<>(BOOT_KEYS), keys(fixtures.required("boot"))); + assertEquals(new TreeSet<>(READY_KEYS), + keys(fixtures.required("ready"))); + assertEquals(new TreeSet<>(RESPONSE_KEYS), + keys(fixtures.required("attestationResponse"))); + } + + @Test + void answersAttestationWithTheBootIdentity() throws IOException { + JsonNode fixtures = fixtures(); + JsonNode boot = fixtures.required("boot"); + JsonNode response = fixtures.required("attestationResponse"); + + assertEquals(PROTOCOL_VERSION, response.required("protocolVersion")); + for (String key : RESPONSE_KEYS) { + if (!key.equals("protocolVersion")) { + assertEquals(boot.required(key), response.required(key), key); + } + } + int answered = 0; + for (JsonNode fixture : fixtures.required("attestationCases")) { + JsonNode expected = fixture.required("expected"); + if (OK.equals(expected.required("status"))) { + String id = fixture.required("id").textValue(); + assertEquals(response, expected.required("body"), id); + checkMarkers(fixtures, fixture.required("body"), id); + answered++; + } + } + assertTrue(answered > 0); + } + + @Test + void recomputesEveryInstallationDigestWithContextBinding() + throws IOException { + JsonNode fixtures = fixtures(); + JsonNode boot = fixtures.required("boot"); + int installed = 0; + int mismatched = 0; + for (JsonNode sequence : fixtures.required("installationSequences")) { + String id = sequence.required("id").textValue(); + for (JsonNode step : sequence.required("steps")) { + JsonNode request = step.required("request"); + JsonNode expected = step.required("expected"); + ContextBinding binding = buildOrNull(request.path("binding")); + if (BAD_REQUEST.equals(expected.required("status"))) { + // A well-formed binding under a 400 is a digest mismatch + // or another field's fault; count the mismatches. + if (binding != null && !binding.getContextDigest().equals( + request.path("contextDigest").textValue())) { + mismatched++; + } + continue; + } + assertNotNull(binding, id); + checkMarkers(fixtures, request, id); + assertEquals(request.required("contextDigest").textValue(), + binding.getContextDigest(), id); + boolean sameWorkspace = WORKSPACE_FIELDS.stream().allMatch( + field -> request.required("binding").required(field) + .equals(boot.required(field))); + assertEquals(!IDENTITY_CONFLICT.equals( + expected.path("code").textValue()), sameWorkspace, id); + if (OK.equals(expected.required("status"))) { + checkReceipt(boot, request, binding, + expected.required("body"), id); + installed++; + } + } + } + assertTrue(installed > 0); + assertTrue(mismatched > 0); + } + + @Test + void usesEachCaseIdOnceInEachList() throws IOException { + JsonNode fixtures = fixtures(); + for (String list : List.of("bootCases", "readyCases", + "attestationCases", "installationSequences")) { + Set ids = new HashSet<>(); + for (JsonNode fixture : fixtures.required(list)) { + String id = fixture.required("id").textValue(); + assertTrue(ids.add(id), () -> "duplicate " + list + " id: " + + id); + } + } + } + + private static JsonNode constant(JsonNode definitions, String definition, + String field) { + return definitions.required(definition).required("properties") + .required(field).required("const"); + } + + /** An accepted request carries protocol version 3 and the token. */ + private static void checkMarkers(JsonNode fixtures, JsonNode request, + String id) { + assertEquals(PROTOCOL_VERSION, request.required("protocolVersion"), + id); + assertEquals(fixtures.required("managedContext"), + request.required("managedContext"), id); + } + + /** Asserts that a refusal is in the error table; counts refusals. */ + private static int checkListed(Set listed, JsonNode outcome) { + JsonNode status = outcome.required("status"); + if (OK.equals(status)) { + return 0; + } + String key = status + " " + outcome.required("code").textValue(); + assertTrue(listed.contains(key), () -> "not in the error table: " + + key); + return 1; + } + + private static void checkReceipt(JsonNode boot, JsonNode request, + ContextBinding binding, JsonNode receipt, String id) { + assertEquals(new TreeSet<>(RECEIPT_KEYS), keys(receipt), id); + assertEquals(PROTOCOL_VERSION, receipt.required("protocolVersion"), id); + for (String key : List.of("managedContext", "runtimeInstanceId", + "runtimeIncarnation", "epoch")) { + assertEquals(boot.required(key), receipt.required(key), id); + } + for (String key : List.of("operationId", "sessionId", + "contextDigest")) { + assertEquals(request.required(key), receipt.required(key), id); + } + assertEquals(binding.getContextRevision(), + decimal(receipt.required("contextRevision").textValue()), id); + assertEquals(binding.getWorkspaceGeneration(), + decimal(receipt.required("workspaceGeneration").textValue()), + id); + } + + /** The binding a request carries, or null when it is not one. */ + private static ContextBinding buildOrNull(JsonNode binding) { + if (!binding.isObject() || !keys(binding).equals( + new TreeSet<>(BINDING_KEYS))) { + return null; + } + try { + return new ContextBinding(binding.get("tenantId").textValue(), + binding.get("workspaceId").textValue(), + decimal(binding.get("workspaceGeneration").textValue()), + binding.get("storageId").textValue(), + binding.get("cwdRelative").textValue(), + binding.get("contextConfigRef").textValue(), + decimal(binding.get("contextRevision").textValue())); + } catch (IllegalArgumentException exception) { + return null; + } + } + + // Java carries generations and revisions as long, so the wire text is + // checked for the canonical decimal form where it is parsed. + private static long decimal(String value) { + if (value == null || !DECIMAL.matcher(value).matches()) { + throw new IllegalArgumentException("not a canonical decimal"); + } + try { + return Long.parseLong(value); + } catch (NumberFormatException exception) { + throw new IllegalArgumentException("decimal exceeds 64 bits", + exception); + } + } + + private static Set keys(JsonNode object) { + Set keys = new TreeSet<>(); + object.fieldNames().forEachRemaining(keys::add); + return keys; + } + + private static Set strings(JsonNode array) { + Set values = new TreeSet<>(); + array.forEach(value -> assertTrue(values.add(value.textValue()), + () -> "duplicate key: " + value)); + return values; + } + + private static Set union(Set first, Set second) { + Set union = new HashSet<>(first); + union.addAll(second); + return Set.copyOf(union); + } + + private static JsonNode fixtures() throws IOException { + return read("managed-context-v1.fixtures.json"); + } + + private static JsonNode schema() throws IOException { + return read("managed-context-v1.schema.json"); + } + + private static JsonNode read(String name) throws IOException { + Path path = contractDirectory().resolve(name); + assertTrue(Files.isRegularFile(path), + () -> "missing shared contract: " + path); + return JSON.readTree(path.toFile()); + } + + private static Path contractDirectory() { + Path current = Path.of(System.getProperty("user.dir")).toAbsolutePath(); + for (int depth = 0; depth < 6 && current != null; depth++) { + Path candidate = current.resolve(Path.of("packages", "cli", "src", + "serve", "contracts")); + if (Files.isDirectory(candidate)) { + return candidate; + } + current = current.getParent(); + } + throw new AssertionError("cannot locate the shared contract fixtures"); + } +} diff --git a/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/ManagedWorkspaceBindingConformanceTest.java b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/ManagedWorkspaceBindingConformanceTest.java new file mode 100644 index 00000000000..8830025e7b4 --- /dev/null +++ b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/ManagedWorkspaceBindingConformanceTest.java @@ -0,0 +1,238 @@ +package com.alibaba.qwen.code.runtimebroker.managedworkspace; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; +import org.junit.jupiter.api.Test; + +/** + * Runs the language-neutral W0a fixtures that the TypeScript implementation + * runs too. The fixtures were generated by an independent implementation of + * the design document, so both languages are checked against a third one. + */ +class ManagedWorkspaceBindingConformanceTest { + private static final ObjectMapper JSON = new ObjectMapper(); + private static final Pattern DECIMAL = Pattern.compile("[1-9][0-9]{0,18}"); + + @Test + void pinsTheDigestConstruction() throws IOException { + JsonNode digest = fixtures().required("digest"); + + assertEquals(1, fixtures().required("contractVersion").intValue()); + assertEquals("sha256", digest.required("algorithm").textValue()); + assertEquals("sha256:", digest.required("prefix").textValue()); + assertEquals(ContextBinding.DOMAIN_TAG, + digest.required("domainTag").textValue()); + List fieldOrder = new ArrayList<>(); + digest.required("fieldOrder").forEach( + field -> fieldOrder.add(field.textValue())); + assertEquals(List.of("tenantId", "workspaceId", "workspaceGeneration", + "storageId", "cwdRelative", "contextConfigRef", + "contextRevision"), fieldOrder); + } + + @Test + void normalizesEverySharedPathCase() throws IOException { + int valid = 0; + for (JsonNode fixture : fixtures().required("paths")) { + String id = fixture.required("id").textValue(); + String input = fixture.required("input").textValue(); + JsonNode expected = fixture.required("expected"); + if (expected.has("cwdRelative")) { + assertEquals(expected.required("cwdRelative").textValue(), + WorkspaceRelativePath.normalize(input), id); + valid++; + } else { + assertEquals("invalid_cwd", + expected.required("error").textValue(), id); + WorkspaceException error = assertThrows( + WorkspaceException.class, + () -> WorkspaceRelativePath.normalize(input), id); + assertEquals("invalid_cwd", error.getCode(), id); + } + } + assertTrue(valid > 0); + } + + @Test + void encodesAndDigestsEverySharedBindingCase() throws IOException { + int valid = 0; + for (JsonNode fixture : fixtures().required("bindings")) { + String id = fixture.required("id").textValue(); + JsonNode binding = fixture.required("binding"); + JsonNode expected = fixture.required("expected"); + if (expected.has("contextDigest")) { + ContextBinding built = build(binding); + assertEquals(expected.required("encodedHex").textValue(), + HexFormat.of().formatHex(built.encode()), id); + assertEquals(expected.required("contextDigest").textValue(), + built.getContextDigest(), id); + valid++; + } else { + assertEquals("invalid_binding", + expected.required("error").textValue(), id); + assertThrows(IllegalArgumentException.class, + () -> build(binding), id); + } + } + assertTrue(valid > 0); + } + + @Test + void acceptsExactlyTheListedCodePointsInEveryProbe() throws IOException { + JsonNode probes = fixtures().required("probes"); + List probed = expand(probes.required("codePoints")); + Set probedSet = new HashSet<>(probed); + // Read the fixture once per probe: the loop runs for every probed + // code point, so it stays on plain strings. + Map base = fields(probes.required("binding")); + for (JsonNode probe : probes.required("cases")) { + String id = probe.required("id").textValue(); + String template = probe.required("template").textValue(); + String field = probe.required("kind").textValue().equals("path") + ? null : probe.required("field").textValue(); + Set accepted = new HashSet<>( + expand(probe.required("accepted"))); + assertTrue(probedSet.containsAll(accepted), id); + List mismatches = new ArrayList<>(); + for (int codePoint : probed) { + String value = template.replace("{c}", + new String(Character.toChars(codePoint))); + String outcome = outcome(field, base, value); + if (!outcome.equals(accepted.contains(codePoint) + ? "accepted" : "rejected")) { + mismatches.add("U+" + Integer.toHexString(codePoint) + + " " + outcome); + } + } + assertEquals(List.of(), mismatches, id); + } + } + + @Test + void usesEachCaseIdOnce() throws IOException { + Set ids = new HashSet<>(); + for (JsonNode list : List.of(fixtures().required("paths"), + fixtures().required("bindings"), + fixtures().required("probes").required("cases"))) { + for (JsonNode fixture : list) { + String id = fixture.required("id").textValue(); + assertTrue(ids.add(id), () -> "duplicate fixture id: " + id); + } + } + } + + /** A null field probes the directory rule; otherwise a binding field. */ + private static String outcome(String field, Map base, + String value) { + if (field == null) { + try { + // Probe templates keep every accepted value in normal form. + return WorkspaceRelativePath.normalize(value).equals(value) + ? "accepted" : "changed"; + } catch (WorkspaceException exception) { + assertEquals("invalid_cwd", exception.getCode()); + return "rejected"; + } + } + Map binding = new HashMap<>(base); + binding.put(field, value); + try { + build(binding); + return "accepted"; + } catch (IllegalArgumentException exception) { + return "rejected"; + } + } + + /** Inclusive ranges, which must be ascending and disjoint. */ + private static List expand(JsonNode ranges) { + List codePoints = new ArrayList<>(); + for (JsonNode range : ranges) { + assertTrue(range.isArray() && range.size() == 2 + && range.get(0).isInt() && range.get(1).isInt(), + () -> "a probe range is two integers: " + range); + int low = range.get(0).intValue(); + int high = range.get(1).intValue(); + assertTrue(low <= high && (codePoints.isEmpty() + || low > codePoints.get(codePoints.size() - 1)), + () -> "probe ranges must be ascending: " + range); + for (int codePoint = low; codePoint <= high; codePoint++) { + codePoints.add(codePoint); + } + } + return codePoints; + } + + // Java carries generations and revisions as long, so a non-canonical + // decimal cannot reach ContextBinding; reject it where the wire text is + // parsed, exactly as a wire decoder would. + private static ContextBinding build(JsonNode binding) { + return build(fields(binding)); + } + + private static ContextBinding build(Map binding) { + return new ContextBinding(binding.get("tenantId"), + binding.get("workspaceId"), + decimal(binding.get("workspaceGeneration")), + binding.get("storageId"), binding.get("cwdRelative"), + binding.get("contextConfigRef"), + decimal(binding.get("contextRevision"))); + } + + /** The text fields of a fixture binding; a non-text value maps to null. */ + private static Map fields(JsonNode binding) { + Map fields = new HashMap<>(); + for (Map.Entry field : binding.properties()) { + fields.put(field.getKey(), field.getValue().textValue()); + } + return fields; + } + + private static long decimal(String value) { + if (value == null || !DECIMAL.matcher(value).matches()) { + throw new IllegalArgumentException("not a canonical decimal"); + } + try { + return Long.parseLong(value); + } catch (NumberFormatException exception) { + throw new IllegalArgumentException("decimal exceeds 64 bits", + exception); + } + } + + private static JsonNode fixtures() throws IOException { + Path path = contractDirectory().resolve( + "managed-workspace-binding-v1.fixtures.json"); + assertTrue(Files.isRegularFile(path), + () -> "missing shared contract: " + path); + return JSON.readTree(path.toFile()); + } + + private static Path contractDirectory() { + Path current = Path.of(System.getProperty("user.dir")).toAbsolutePath(); + for (int depth = 0; depth < 6 && current != null; depth++) { + Path candidate = current.resolve(Path.of("packages", "cli", "src", + "serve", "contracts")); + if (Files.isDirectory(candidate)) { + return candidate; + } + current = current.getParent(); + } + throw new AssertionError("cannot locate the shared contract fixtures"); + } +} diff --git a/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/TestWorkspaces.java b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/TestWorkspaces.java new file mode 100644 index 00000000000..25024cf9f01 --- /dev/null +++ b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/TestWorkspaces.java @@ -0,0 +1,31 @@ +package com.alibaba.qwen.code.runtimebroker.managedworkspace; + +final class TestWorkspaces { + static final String TENANT = "tenant-a"; + static final String OTHER_TENANT = "tenant-b"; + + private TestWorkspaces() { + } + + /** + * An equal String that is a different instance. String literals are + * interned, so only a copy makes a test fail when code compares strings + * with {@code ==} instead of {@code equals}. + */ + static String copy(String value) { + return new String(value); + } + + static WorkspaceRecord workspace(String tenantId, String workspaceId, + WorkspaceState state) { + return workspace(tenantId, workspaceId, 1, "storage-" + workspaceId, + state); + } + + static WorkspaceRecord workspace(String tenantId, String workspaceId, + long generation, String storageId, WorkspaceState state) { + return new WorkspaceRecord(tenantId, workspaceId, generation, + storageId, "Workspace " + workspaceId, state, "policy:default", + "config:" + workspaceId); + } +} diff --git a/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/TextFieldProbeTest.java b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/TextFieldProbeTest.java new file mode 100644 index 00000000000..b7878f9f30c --- /dev/null +++ b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/TextFieldProbeTest.java @@ -0,0 +1,65 @@ +package com.alibaba.qwen.code.runtimebroker.managedworkspace; + +import static com.alibaba.qwen.code.runtimebroker.managedworkspace.TestWorkspaces.TENANT; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import org.junit.jupiter.api.Test; + +/** + * Probes the text rule of display names and actor IDs with every code point + * of the Basic Multilingual Plane and a few astral ones. + */ +class TextFieldProbeTest { + @Test + void acceptsExactlyWellFormedTextWithoutControls() { + List mismatches = new ArrayList<>(); + probe("displayName", mismatches, text -> new WorkspaceRecord(TENANT, + "ws-a", 1, "storage-a", text, WorkspaceState.ACTIVE, + "policy:a", "config:a")); + probe("actorId", mismatches, text -> new WorkspaceActor(TENANT, text)); + probe("grant actorId", mismatches, + text -> GrantedWorkspaceAccessPolicy.builder().grant(TENANT, + text, "ws-a", WorkspaceAccess.READ)); + + assertEquals(List.of(), mismatches); + } + + private static void probe(String field, List mismatches, + Consumer build) { + for (int codePoint : codePoints()) { + boolean accepted; + try { + build.accept("a" + new String(Character.toChars(codePoint)) + + "b"); + accepted = true; + } catch (IllegalArgumentException exception) { + accepted = false; + } + if (accepted != isText(codePoint)) { + mismatches.add(field + " U+" + Integer.toHexString(codePoint)); + } + } + } + + // The rule restated as code point ranges: no C0 control, DEL, C1 control + // or lone surrogate. + private static boolean isText(int codePoint) { + boolean control = codePoint <= 0x1F + || (codePoint >= 0x7F && codePoint <= 0x9F); + boolean surrogate = codePoint >= 0xD800 && codePoint <= 0xDFFF; + return !control && !surrogate; + } + + private static List codePoints() { + List codePoints = new ArrayList<>(); + for (int codePoint = 0; codePoint <= 0xFFFF; codePoint++) { + codePoints.add(codePoint); + } + codePoints.addAll(List.of(0x10000, 0x1D11E, 0x1F600, 0xE0041, + 0x10FFFF)); + return codePoints; + } +} diff --git a/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceActorTest.java b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceActorTest.java new file mode 100644 index 00000000000..01b85971519 --- /dev/null +++ b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceActorTest.java @@ -0,0 +1,71 @@ +package com.alibaba.qwen.code.runtimebroker.managedworkspace; + +import static com.alibaba.qwen.code.runtimebroker.managedworkspace.TestWorkspaces.copy; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +class WorkspaceActorTest { + @Test + void keepsTheAuthenticatedIdentity() { + WorkspaceActor actor = new WorkspaceActor("tenant-a", "alice@example.com"); + + assertEquals("tenant-a", actor.getTenantId()); + assertEquals("alice@example.com", actor.getActorId()); + assertEquals(new WorkspaceActor("tenant-a", "alice@example.com"), actor); + assertEquals(new WorkspaceActor(copy("tenant-a"), + copy("alice@example.com")), actor); + assertEquals(new WorkspaceActor("tenant-a", "alice@example.com") + .hashCode(), actor.hashCode()); + assertNotEquals(new WorkspaceActor("tenant-b", "alice@example.com"), + actor); + assertNotEquals(new WorkspaceActor("tenant-a", "bob@example.com"), + actor); + assertNotEquals(new WorkspaceActor("TENANT-A", "alice@example.com"), + actor); + assertNotEquals(new WorkspaceActor("tenant-a", "Alice@example.com"), + actor); + assertNotEquals(new WorkspaceActor("tenant-a", "alice@example.com "), + actor); + assertFalse(actor.equals(null)); + assertFalse(actor.equals("tenant-a")); + } + + @Test + void acceptsAnActorIdOfUpTo512CodePoints() { + String clef = "\uD834\uDD1E"; + assertEquals(1024, new WorkspaceActor("tenant-a", clef.repeat(512)) + .getActorId().length()); + assertThrows(IllegalArgumentException.class, + () -> new WorkspaceActor("tenant-a", clef.repeat(513))); + // Spaces count and are kept. + assertEquals(" ", new WorkspaceActor("tenant-a", " ").getActorId()); + assertEquals(512, new WorkspaceActor("tenant-a", "x".repeat(511) + " ") + .getActorId().length()); + assertThrows(IllegalArgumentException.class, + () -> new WorkspaceActor("tenant-a", "x".repeat(512) + " ")); + } + + @Test + void rejectsAnInvalidTenant() { + for (String tenantId : new String[] {null, "", "tenant/a", "tenant a", + "t".repeat(129)}) { + assertThrows(IllegalArgumentException.class, + () -> new WorkspaceActor(tenantId, "alice"), + String.valueOf(tenantId)); + } + } + + @Test + void rejectsAnInvalidActorId() { + for (String actorId : new String[] {null, "", "alice\n", "alice\u009b", + "alice\ud800"}) { + assertThrows(IllegalArgumentException.class, + () -> new WorkspaceActor("tenant-a", actorId), + String.valueOf(actorId)); + } + } +} diff --git a/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceCatalogTest.java b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceCatalogTest.java new file mode 100644 index 00000000000..a34e67a8aed --- /dev/null +++ b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceCatalogTest.java @@ -0,0 +1,679 @@ +package com.alibaba.qwen.code.runtimebroker.managedworkspace; + +import static com.alibaba.qwen.code.runtimebroker.managedworkspace.TestWorkspaces.OTHER_TENANT; +import static com.alibaba.qwen.code.runtimebroker.managedworkspace.TestWorkspaces.TENANT; +import static com.alibaba.qwen.code.runtimebroker.managedworkspace.TestWorkspaces.copy; +import static com.alibaba.qwen.code.runtimebroker.managedworkspace.TestWorkspaces.workspace; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +// A listing that stops advancing must fail here, not hang the build. The +// scan never checks for interrupts, so the timeout needs its own thread. +@Timeout(value = 10, threadMode = Timeout.ThreadMode.SEPARATE_THREAD) +class WorkspaceCatalogTest { + private static final WorkspaceActor ALICE = new WorkspaceActor(TENANT, + "alice"); + + private static final List RECORDS = List.of( + workspace(TENANT, "alpha", WorkspaceState.ACTIVE), + workspace(TENANT, "beta", WorkspaceState.ACTIVE), + workspace(TENANT, "draining", WorkspaceState.DRAINING), + workspace(TENANT, "hidden", WorkspaceState.ACTIVE), + workspace(TENANT, "readonly", WorkspaceState.ACTIVE), + workspace(TENANT, "removed", WorkspaceState.REMOVED), + workspace(OTHER_TENANT, "alpha", 5, "storage-other", + WorkspaceState.ACTIVE), + workspace(OTHER_TENANT, "gamma", WorkspaceState.ACTIVE)); + + private static final WorkspaceAccessPolicy GRANTS = + GrantedWorkspaceAccessPolicy.builder() + .grant(TENANT, "alice", "alpha", WorkspaceAccess.CREATE) + .grant(TENANT, "alice", "beta", WorkspaceAccess.CREATE) + .grant(TENANT, "alice", "draining", WorkspaceAccess.CREATE) + .grant(TENANT, "alice", "readonly", WorkspaceAccess.READ) + .grant(TENANT, "alice", "removed", WorkspaceAccess.CREATE) + .grant(OTHER_TENANT, "alice", "alpha", + WorkspaceAccess.CREATE) + .grant(OTHER_TENANT, "alice", "gamma", + WorkspaceAccess.CREATE) + .build(); + + @Test + void resolvesAnExplicitSelectionInTheActorsTenant() { + ResolvedWorkspace resolved = catalog(Map.of()).resolve(ALICE, + WorkspaceSelection.explicit("alpha", "./services//api/")); + + assertEquals(TENANT, resolved.getTenantId()); + assertEquals("alpha", resolved.getWorkspaceId()); + assertEquals(1, resolved.getWorkspaceGeneration()); + assertEquals("storage-alpha", resolved.getStorageId()); + assertEquals("config:alpha", resolved.getConfigRef()); + assertEquals("policy:default", resolved.getPolicyRef()); + assertEquals("services/api", resolved.getCwdRelative()); + assertFalse(resolved.isTenantDefault()); + } + + @Test + void matchesTenantsAndIdsByContentNotByInstance() { + WorkspaceActor alice = new WorkspaceActor(copy(TENANT), "alice"); + WorkspaceCatalog catalog = catalog(Map.of(TENANT, "alpha")); + + assertEquals("alpha", catalog.resolve(alice, + WorkspaceSelection.explicit(copy("alpha"))).getWorkspaceId()); + assertEquals("alpha", catalog.resolve(alice, + WorkspaceSelection.omitted()).getWorkspaceId()); + WorkspacePage page = catalog.list(alice, null, 10); + assertEquals(List.of("alpha", "beta", "draining", "readonly", + "removed"), ids(page)); + assertEquals("alpha", + page.getDefaultWorkspace().orElseThrow().getWorkspaceId()); + } + + @Test + void hidesMissingForeignAndUnreadableWorkspacesAlike() { + WorkspaceCatalog catalog = catalog(Map.of()); + List errors = new ArrayList<>(); + for (String workspaceId : List.of("nope", "gamma", "hidden", "../x", + "", " alpha", "alpha ")) { + errors.add(assertThrows(WorkspaceException.class, + () -> catalog.resolve(ALICE, + WorkspaceSelection.explicit(workspaceId)), + workspaceId)); + } + for (WorkspaceException error : errors) { + assertEquals("workspace_not_found", error.getCode()); + assertEquals(404, error.getStatusCode()); + assertEquals(errors.get(0).getMessage(), error.getMessage()); + assertEquals(throwSite(errors.get(0)), throwSite(error)); + } + } + + @Test + void rejectsAReadOnlyWorkspaceAsForbidden() { + WorkspaceException error = assertThrows(WorkspaceException.class, + () -> catalog(Map.of()).resolve(ALICE, + WorkspaceSelection.explicit("readonly"))); + + assertEquals("workspace_forbidden", error.getCode()); + assertEquals(403, error.getStatusCode()); + } + + @Test + void rejectsAWorkspaceThatIsNotActiveAsUnavailable() { + for (String workspaceId : List.of("draining", "removed")) { + WorkspaceException error = assertThrows(WorkspaceException.class, + () -> catalog(Map.of()).resolve(ALICE, + WorkspaceSelection.explicit(workspaceId)), + workspaceId); + assertEquals("workspace_unavailable", error.getCode()); + assertEquals(409, error.getStatusCode()); + } + } + + @Test + void checksAccessBeforeStateSoTheStateRevealsNothing() { + WorkspaceCatalog catalog = new WorkspaceCatalog( + new ConfiguredWorkspaceRegistry(List.of( + workspace(TENANT, "gated", WorkspaceState.DRAINING), + workspace(TENANT, "peek", WorkspaceState.REMOVED)), + Map.of()), + GrantedWorkspaceAccessPolicy.builder() + .grant(TENANT, "alice", "peek", WorkspaceAccess.READ) + .build()); + + assertEquals("workspace_not_found", assertThrows( + WorkspaceException.class, () -> catalog.resolve(ALICE, + WorkspaceSelection.explicit("gated"))).getCode()); + assertEquals("workspace_forbidden", assertThrows( + WorkspaceException.class, () -> catalog.resolve(ALICE, + WorkspaceSelection.explicit("peek"))).getCode()); + } + + @Test + void checksTheDirectoryBeforeLookingUpTheWorkspace() { + WorkspaceException error = assertThrows(WorkspaceException.class, + () -> catalog(Map.of()).resolve(ALICE, + WorkspaceSelection.explicit("nope", "../x"))); + + assertEquals("invalid_cwd", error.getCode()); + } + + @Test + void neverFallsBackFromAnExplicitSelection() { + WorkspaceException error = assertThrows(WorkspaceException.class, + () -> catalog(Map.of(TENANT, "alpha")).resolve(ALICE, + WorkspaceSelection.explicit("nope"))); + + assertEquals("workspace_not_found", error.getCode()); + } + + @Test + void resolvesAnOmittedSelectionToAUsableDefault() { + ResolvedWorkspace resolved = catalog(Map.of(TENANT, "alpha")) + .resolve(ALICE, WorkspaceSelection.omitted()); + + assertEquals("alpha", resolved.getWorkspaceId()); + assertEquals(".", resolved.getCwdRelative()); + assertTrue(resolved.isTenantDefault()); + } + + @Test + void reportsEveryUnusableDefaultAsWorkspaceRequired() { + List> defaults = List.of(Map.of(), + Map.of(TENANT, "draining"), Map.of(TENANT, "removed"), + Map.of(TENANT, "readonly"), Map.of(TENANT, "hidden"), + Map.of(OTHER_TENANT, "gamma")); + for (Map tenantDefaults : defaults) { + WorkspaceException error = assertThrows(WorkspaceException.class, + () -> catalog(tenantDefaults).resolve(ALICE, + WorkspaceSelection.omitted()), + tenantDefaults.toString()); + assertEquals("workspace_required", error.getCode()); + assertEquals(400, error.getStatusCode()); + } + } + + @Test + void listsReadableWorkspacesInByteOrderWithPermissionHints() { + WorkspacePage page = catalog(Map.of()).list(ALICE, null, 10); + + assertEquals(List.of("alpha", "beta", "draining", "readonly", + "removed"), ids(page)); + assertFalse(page.hasMore()); + assertEquals(List.of(true, true, true, false, true), + page.getWorkspaces().stream() + .map(WorkspaceView::canCreateSession).toList()); + assertEquals(WorkspaceState.DRAINING, + page.getWorkspaces().get(2).getState()); + assertEquals("Workspace alpha", + page.getWorkspaces().get(0).getDisplayName()); + } + + @Test + void pagesWithoutCountingUnreadableWorkspaces() { + WorkspaceCatalog catalog = catalog(Map.of()); + + assertPage(catalog.list(ALICE, null, 2), true, "alpha", "beta"); + assertPage(catalog.list(ALICE, "beta", 2), true, "draining", + "readonly"); + assertPage(catalog.list(ALICE, "draining", 1), true, "readonly"); + assertPage(catalog.list(ALICE, "draining", 2), false, "readonly", + "removed"); + assertPage(catalog.list(ALICE, "readonly", 2), false, "removed"); + assertPage(catalog.list(ALICE, "removed", 2), false); + } + + @Test + void reportsNoMoreWhenOnlyHiddenRecordsFollowAFullPage() { + // A record the actor cannot see must not show up as hasMore. + WorkspaceCatalog catalog = new WorkspaceCatalog( + new ConfiguredWorkspaceRegistry(List.of( + workspace(TENANT, "alpha", WorkspaceState.ACTIVE), + workspace(TENANT, "beta", WorkspaceState.ACTIVE), + workspace(TENANT, "zz-hidden", WorkspaceState.ACTIVE)), + Map.of()), + GrantedWorkspaceAccessPolicy.builder() + .grant(TENANT, "alice", "alpha", WorkspaceAccess.READ) + .grant(TENANT, "alice", "beta", WorkspaceAccess.READ) + .build()); + WorkspaceCatalog faulty = new WorkspaceCatalog( + new FixedRegistry(List.of( + workspace(TENANT, "alpha", WorkspaceState.ACTIVE), + workspace(OTHER_TENANT, "zeta", WorkspaceState.ACTIVE)), + Optional.empty()), + (actor, workspace) -> WorkspaceAccess.READ); + + assertPage(catalog.list(ALICE, null, 2), false, "alpha", "beta"); + assertPage(faulty.list(ALICE, null, 1), false, "alpha"); + } + + @Test + void reportsTheDefaultIndependentlyOfThePage() { + WorkspacePage page = catalog(Map.of(TENANT, "alpha")).list(ALICE, + "beta", 2); + + WorkspaceView defaultWorkspace = page.getDefaultWorkspace() + .orElseThrow(); + assertEquals("alpha", defaultWorkspace.getWorkspaceId()); + assertTrue(defaultWorkspace.canCreateSession()); + assertFalse(ids(page).contains("alpha")); + } + + @Test + void omitsAnUnusableDefaultFromTheList() { + for (String workspaceId : List.of("draining", "removed", "readonly", + "hidden")) { + assertTrue(catalog(Map.of(TENANT, workspaceId)).list(ALICE, null, + 10).getDefaultWorkspace().isEmpty(), workspaceId); + } + } + + @Test + void asksThePolicyOnEveryCall() { + AtomicReference access = new AtomicReference<>( + WorkspaceAccess.CREATE); + WorkspaceCatalog catalog = new WorkspaceCatalog(registry(Map.of()), + (actor, workspace) -> access.get()); + catalog.resolve(ALICE, WorkspaceSelection.explicit("alpha")); + + access.set(WorkspaceAccess.READ); + + assertEquals("workspace_forbidden", assertThrows( + WorkspaceException.class, () -> catalog.resolve(ALICE, + WorkspaceSelection.explicit("alpha"))).getCode()); + assertFalse(catalog.list(ALICE, null, 1).getWorkspaces().get(0) + .canCreateSession()); + } + + @Test + void failsClosedWhenThePolicyGivesNoDecision() { + WorkspaceCatalog catalog = new WorkspaceCatalog(registry(Map.of()), + (actor, workspace) -> null); + + assertThrows(IllegalStateException.class, () -> catalog.resolve(ALICE, + WorkspaceSelection.explicit("alpha"))); + assertThrows(IllegalStateException.class, + () -> catalog.list(ALICE, null, 10)); + // Access comes before state for the default too, so a draining + // default still asks the policy. + WorkspaceCatalog drainingDefault = new WorkspaceCatalog( + registry(Map.of(TENANT, "draining")), + (actor, workspace) -> null); + assertThrows(IllegalStateException.class, + () -> drainingDefault.resolve(ALICE, + WorkspaceSelection.omitted())); + } + + @Test + void ignoresRecordsOfAnotherTenantFromAFaultyRegistry() { + // Tenant IDs compare exactly: a case variant is another tenant. + for (String tenantId : List.of(OTHER_TENANT, "TENANT-A")) { + WorkspaceRecord foreign = workspace(tenantId, "alpha", + WorkspaceState.ACTIVE); + List asked = new ArrayList<>(); + WorkspaceCatalog catalog = new WorkspaceCatalog( + new FixedRegistry(List.of(foreign), Optional.of("alpha")), + (actor, workspace) -> { + asked.add(workspace); + return WorkspaceAccess.CREATE; + }); + + assertEquals("workspace_not_found", assertThrows( + WorkspaceException.class, () -> catalog.resolve(ALICE, + WorkspaceSelection.explicit("alpha")), + tenantId).getCode()); + assertEquals("workspace_required", assertThrows( + WorkspaceException.class, () -> catalog.resolve(ALICE, + WorkspaceSelection.omitted()), tenantId).getCode()); + WorkspacePage page = catalog.list(ALICE, null, 10); + assertTrue(page.getWorkspaces().isEmpty(), tenantId); + assertTrue(page.getDefaultWorkspace().isEmpty(), tenantId); + // The tenant check comes first, so the policy never sees the + // record. + assertEquals(List.of(), asked, tenantId); + } + } + + @Test + void ignoresARecordWhoseIdDiffersFromTheOneAskedFor() { + for (String workspaceId : List.of("alpha2", "ALPHA")) { + WorkspaceCatalog catalog = new WorkspaceCatalog( + new FixedRegistry(List.of(workspace(TENANT, workspaceId, + WorkspaceState.ACTIVE)), Optional.of("alpha")), + (actor, workspace) -> WorkspaceAccess.CREATE); + + assertEquals("workspace_not_found", assertThrows( + WorkspaceException.class, () -> catalog.resolve(ALICE, + WorkspaceSelection.explicit("alpha")), + workspaceId).getCode()); + assertEquals("workspace_required", assertThrows( + WorkspaceException.class, () -> catalog.resolve(ALICE, + WorkspaceSelection.omitted()), + workspaceId).getCode()); + } + } + + @Test + void listsIdsThatDifferInCaseInByteOrder() { + WorkspaceCatalog catalog = new WorkspaceCatalog( + new ConfiguredWorkspaceRegistry(List.of( + workspace(TENANT, "a", WorkspaceState.ACTIVE), + workspace(TENANT, "_x", WorkspaceState.ACTIVE), + workspace(TENANT, "B", WorkspaceState.ACTIVE)), + Map.of()), + (actor, workspace) -> WorkspaceAccess.CREATE); + + assertPage(catalog.list(ALICE, null, 10), false, "B", "_x", "a"); + assertPage(catalog.list(ALICE, "B", 10), false, "_x", "a"); + assertPage(catalog.list(ALICE, "_x", 10), false, "a"); + assertPage(catalog.list(ALICE, "A", 10), false, "B", "_x", "a"); + } + + @Test + void rejectsARegistryPageThatDoesNotMoveForward() { + WorkspaceCatalog catalog = new WorkspaceCatalog( + new FixedRegistry(List.of( + workspace(TENANT, "b", WorkspaceState.ACTIVE), + workspace(TENANT, "a", WorkspaceState.ACTIVE)), + Optional.empty()), + (actor, workspace) -> WorkspaceAccess.CREATE); + + assertThrows(IllegalStateException.class, + () -> catalog.list(ALICE, null, 10)); + // Order is checked over every record, including one the actor's + // tenant does not own. + WorkspaceCatalog mixed = new WorkspaceCatalog( + new FixedRegistry(List.of( + workspace(TENANT, "alpha", WorkspaceState.ACTIVE), + workspace(OTHER_TENANT, "zeta", WorkspaceState.ACTIVE), + workspace(TENANT, "beta", WorkspaceState.ACTIVE)), + Optional.empty()), + (actor, workspace) -> WorkspaceAccess.CREATE); + assertThrows(IllegalStateException.class, + () -> mixed.list(ALICE, null, 10)); + } + + @Test + void readsASparseListingInFullBatches() { + List records = new ArrayList<>(); + for (int index = 0; index < 2500; index++) { + records.add(workspace(TENANT, + String.format(Locale.ROOT, "hidden-%04d", index), + WorkspaceState.ACTIVE)); + } + records.add(workspace(TENANT, "visible", WorkspaceState.ACTIVE)); + CountingRegistry registry = new CountingRegistry( + new ConfiguredWorkspaceRegistry(records, Map.of())); + WorkspaceCatalog catalog = new WorkspaceCatalog(registry, + GrantedWorkspaceAccessPolicy.builder() + .grant(TENANT, "alice", "visible", WorkspaceAccess.READ) + .build()); + + WorkspacePage page = catalog.list(ALICE, null, 1); + + assertEquals(List.of("visible"), ids(page)); + assertFalse(page.hasMore()); + assertEquals(List.of(1000, 1000, 1000), registry.limits); + } + + @Test + void readsPastAFullPageOnlyUntilTheNextReadableRecord() { + List records = new ArrayList<>(); + for (int index = 0; index < 2500; index++) { + records.add(workspace(TENANT, + String.format(Locale.ROOT, "ws-%04d", index), + WorkspaceState.ACTIVE)); + } + CountingRegistry dense = new CountingRegistry( + new ConfiguredWorkspaceRegistry(records, Map.of())); + CountingRegistry sparse = new CountingRegistry( + new ConfiguredWorkspaceRegistry(records, Map.of())); + + // Every record is readable: the third one ends the scan. + assertPage(new WorkspaceCatalog(dense, + (actor, workspace) -> WorkspaceAccess.READ).list(ALICE, null, 2), + true, "ws-0000", "ws-0001"); + assertEquals(List.of(1000), dense.limits); + // Only the first two are readable: an exact hasMore scans to the end. + assertPage(new WorkspaceCatalog(sparse, + (actor, workspace) -> workspace.getWorkspaceId() + .compareTo("ws-0002") < 0 + ? WorkspaceAccess.READ + : WorkspaceAccess.NONE).list(ALICE, null, 2), + false, "ws-0000", "ws-0001"); + assertEquals(List.of(1000, 1000, 1000), sparse.limits); + } + + @Test + void neverPassesAnImpossibleIdToTheRegistry() { + WorkspaceRegistry strict = new ValidatingRegistry(registry(Map.of())); + WorkspaceCatalog catalog = new WorkspaceCatalog(strict, GRANTS); + + for (String workspaceId : List.of("../x", "", "a\n", "x".repeat(129))) { + assertEquals("workspace_not_found", assertThrows( + WorkspaceException.class, () -> catalog.resolve(ALICE, + WorkspaceSelection.explicit(workspaceId)), + workspaceId).getCode()); + } + assertEquals("alpha", catalog.resolve(ALICE, + WorkspaceSelection.explicit("alpha")).getWorkspaceId()); + } + + @Test + void rejectsARegistryThatReturnsTheCursorAgain() { + WorkspaceCatalog catalog = new WorkspaceCatalog( + new InclusiveRegistry(List.of( + workspace(TENANT, "a", WorkspaceState.ACTIVE), + workspace(TENANT, "b", WorkspaceState.ACTIVE))), + (actor, workspace) -> WorkspaceAccess.CREATE); + + assertThrows(IllegalStateException.class, + () -> catalog.list(ALICE, "a", 10)); + } + + @Test + void takesEveryResolvedFieldFromTheRecord() { + WorkspaceRecord zeta = new WorkspaceRecord("tenant-z", "zeta", 42, + "pvc://volume-9f3", "Zeta", WorkspaceState.ACTIVE, + "policy:strict-7", "bundle@r42"); + WorkspaceCatalog catalog = new WorkspaceCatalog( + new ConfiguredWorkspaceRegistry(List.of(zeta), Map.of()), + (actor, workspace) -> WorkspaceAccess.CREATE); + + ResolvedWorkspace resolved = catalog.resolve( + new WorkspaceActor("tenant-z", "zed"), + WorkspaceSelection.explicit("zeta", "src")); + + assertEquals("tenant-z", resolved.getTenantId()); + assertEquals("zeta", resolved.getWorkspaceId()); + assertEquals(42, resolved.getWorkspaceGeneration()); + assertEquals("pvc://volume-9f3", resolved.getStorageId()); + assertEquals("bundle@r42", resolved.getConfigRef()); + assertEquals("policy:strict-7", resolved.getPolicyRef()); + assertEquals("src", resolved.getCwdRelative()); + } + + @Test + void rejectsACursorThatIsNotAWorkspaceId() { + CountingRegistry registry = new CountingRegistry(registry(Map.of())); + WorkspaceCatalog catalog = new WorkspaceCatalog(registry, GRANTS); + + for (String cursor : List.of("../x", "a\nb", "", "x".repeat(129))) { + assertThrows(IllegalArgumentException.class, + () -> catalog.list(ALICE, cursor, 10), cursor); + } + assertEquals(List.of(), registry.limits); + } + + @Test + void returnsAPageThatCannotBeChanged() { + WorkspacePage page = catalog(Map.of()).list(ALICE, null, 10); + + assertThrows(UnsupportedOperationException.class, + () -> page.getWorkspaces().clear()); + } + + @Test + void rejectsInvalidArguments() { + WorkspaceCatalog catalog = catalog(Map.of()); + + assertThrows(IllegalArgumentException.class, + () -> catalog.list(ALICE, null, 0)); + assertThrows(IllegalArgumentException.class, + () -> catalog.list(ALICE, null, 1001)); + assertEquals(5, catalog.list(ALICE, null, 1000).getWorkspaces() + .size()); + assertThrows(IllegalArgumentException.class, + () -> catalog.list(null, null, 10)); + assertThrows(IllegalArgumentException.class, + () -> catalog.resolve(ALICE, null)); + assertThrows(IllegalArgumentException.class, + () -> catalog.resolve(null, WorkspaceSelection.omitted())); + assertThrows(IllegalArgumentException.class, + () -> new WorkspaceCatalog(null, GRANTS)); + assertThrows(IllegalArgumentException.class, + () -> new WorkspaceCatalog(registry(Map.of()), null)); + } + + private static WorkspaceCatalog catalog(Map defaults) { + return new WorkspaceCatalog(registry(defaults), GRANTS); + } + + private static ConfiguredWorkspaceRegistry registry( + Map defaults) { + return new ConfiguredWorkspaceRegistry(RECORDS, defaults); + } + + private static void assertPage(WorkspacePage page, boolean hasMore, + String... ids) { + assertEquals(List.of(ids), ids(page)); + assertEquals(hasMore, page.hasMore()); + } + + private static List ids(WorkspacePage page) { + return page.getWorkspaces().stream() + .map(WorkspaceView::getWorkspaceId).toList(); + } + + /** The frames of the library code that created the error. */ + private static List throwSite(Throwable error) { + List frames = new ArrayList<>(); + for (StackTraceElement frame : error.getStackTrace()) { + if (frame.getClassName().startsWith( + WorkspaceCatalogTest.class.getName())) { + break; + } + frames.add(frame); + } + assertFalse(frames.isEmpty()); + return frames; + } + + /** Returns the same records for every lookup, whatever is asked. */ + private static final class FixedRegistry implements WorkspaceRegistry { + private final List records; + private final Optional defaultWorkspaceId; + + FixedRegistry(List records, + Optional defaultWorkspaceId) { + this.records = records; + this.defaultWorkspaceId = defaultWorkspaceId; + } + + @Override + public Optional find(String tenantId, + String workspaceId) { + return records.stream().findFirst(); + } + + @Override + public List page(String tenantId, + String afterWorkspaceId, int limit) { + return afterWorkspaceId == null ? records : List.of(); + } + + @Override + public Optional defaultWorkspaceId(String tenantId) { + return defaultWorkspaceId; + } + } + + /** Records the limit of each page call a listing makes. */ + private static final class CountingRegistry implements WorkspaceRegistry { + private final WorkspaceRegistry delegate; + private final List limits = new ArrayList<>(); + + CountingRegistry(WorkspaceRegistry delegate) { + this.delegate = delegate; + } + + @Override + public Optional find(String tenantId, + String workspaceId) { + return delegate.find(tenantId, workspaceId); + } + + @Override + public List page(String tenantId, + String afterWorkspaceId, int limit) { + limits.add(limit); + return delegate.page(tenantId, afterWorkspaceId, limit); + } + + @Override + public Optional defaultWorkspaceId(String tenantId) { + return delegate.defaultWorkspaceId(tenantId); + } + } + + /** Rejects a lookup of anything that cannot be a Workspace ID. */ + private static final class ValidatingRegistry + implements WorkspaceRegistry { + private final WorkspaceRegistry delegate; + + ValidatingRegistry(WorkspaceRegistry delegate) { + this.delegate = delegate; + } + + @Override + public Optional find(String tenantId, + String workspaceId) { + if (!workspaceId.matches("[A-Za-z0-9._:-]{1,128}")) { + throw new IllegalArgumentException("invalid Workspace ID"); + } + return delegate.find(tenantId, workspaceId); + } + + @Override + public List page(String tenantId, + String afterWorkspaceId, int limit) { + return delegate.page(tenantId, afterWorkspaceId, limit); + } + + @Override + public Optional defaultWorkspaceId(String tenantId) { + return delegate.defaultWorkspaceId(tenantId); + } + } + + /** Treats the cursor as inclusive, which breaks the page contract. */ + private static final class InclusiveRegistry implements WorkspaceRegistry { + private final List records; + + InclusiveRegistry(List records) { + this.records = records; + } + + @Override + public Optional find(String tenantId, + String workspaceId) { + return Optional.empty(); + } + + @Override + public List page(String tenantId, + String afterWorkspaceId, int limit) { + return records.stream() + .filter(workspace -> afterWorkspaceId == null + || workspace.getWorkspaceId() + .compareTo(afterWorkspaceId) >= 0) + .toList(); + } + + @Override + public Optional defaultWorkspaceId(String tenantId) { + return Optional.empty(); + } + } +} diff --git a/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceRecordTest.java b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceRecordTest.java new file mode 100644 index 00000000000..f236005ae16 --- /dev/null +++ b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceRecordTest.java @@ -0,0 +1,167 @@ +package com.alibaba.qwen.code.runtimebroker.managedworkspace; + +import static com.alibaba.qwen.code.runtimebroker.managedworkspace.TestWorkspaces.copy; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.List; +import java.util.function.Consumer; +import org.junit.jupiter.api.Test; + +class WorkspaceRecordTest { + @Test + void keepsEveryField() { + WorkspaceRecord record = new WorkspaceRecord("tenant.a:1", + "ws_project-a", 7, "pvc://ns/claim-a", "项目 A", + WorkspaceState.DRAINING, "policy:strict", "config:bundle@3"); + + assertEquals("tenant.a:1", record.getTenantId()); + assertEquals("ws_project-a", record.getWorkspaceId()); + assertEquals(7, record.getWorkspaceGeneration()); + assertEquals("pvc://ns/claim-a", record.getStorageId()); + assertEquals("项目 A", record.getDisplayName()); + assertEquals(WorkspaceState.DRAINING, record.getState()); + assertEquals("policy:strict", record.getPolicyRef()); + assertEquals("config:bundle@3", record.getConfigRef()); + assertEquals("draining", record.getState().wireName()); + assertEquals("active", WorkspaceState.ACTIVE.wireName()); + assertEquals("removed", WorkspaceState.REMOVED.wireName()); + } + + @Test + void rejectsInvalidIdentifiers() { + for (String id : new String[] {null, "", "a/b", "a b", "é", + "x".repeat(129)}) { + assertInvalid(values -> values.tenantId = id); + assertInvalid(values -> values.workspaceId = id); + } + } + + @Test + void acceptsIdentifiersAtTheLengthLimit() { + Values values = new Values(); + values.tenantId = "t".repeat(128); + values.workspaceId = "w".repeat(128); + assertEquals(128, values.build().getWorkspaceId().length()); + } + + @Test + void rejectsANonPositiveGeneration() { + assertInvalid(values -> values.generation = 0); + assertInvalid(values -> values.generation = -1); + } + + @Test + void requiresPrintableAsciiReferences() { + assertInvalid(values -> values.storageId = "storage a"); + assertInvalid(values -> values.storageId = "s".repeat(257)); + assertInvalid(values -> values.storageId = ""); + assertInvalid(values -> values.storageId = null); + assertInvalid(values -> values.policyRef = "policy:é"); + assertInvalid(values -> values.policyRef = "p".repeat(513)); + assertInvalid(values -> values.policyRef = null); + assertInvalid(values -> values.configRef = "config\u007f"); + assertInvalid(values -> values.configRef = "c".repeat(513)); + assertInvalid(values -> values.configRef = null); + assertInvalid(values -> values.displayName = null); + } + + @Test + void acceptsEachReferenceAtItsLimit() { + Values values = new Values(); + values.storageId = "s".repeat(256); + values.policyRef = "p".repeat(512); + values.configRef = "c".repeat(512); + WorkspaceRecord record = values.build(); + + assertEquals(256, record.getStorageId().length()); + assertEquals(512, record.getPolicyRef().length()); + assertEquals(512, record.getConfigRef().length()); + } + + @Test + void requiresBoundedDisplayTextWithoutControls() { + assertInvalid(values -> values.displayName = ""); + assertInvalid(values -> values.displayName = "a\nb"); + assertInvalid(values -> values.displayName = "a\u009bb"); + assertInvalid(values -> values.displayName = "a\ud800"); + assertInvalid(values -> values.displayName = "x".repeat(513)); + + Values values = new Values(); + values.displayName = "\uD834\uDD1E".repeat(512); + assertEquals(1024, values.build().getDisplayName().length()); + // Spaces count and are kept. + values.displayName = " "; + assertEquals(" ", values.build().getDisplayName()); + values.displayName = "x".repeat(511) + " "; + assertEquals(512, values.build().getDisplayName().length()); + assertInvalid(changed -> changed.displayName = "x".repeat(512) + " "); + } + + @Test + void requiresAState() { + assertInvalid(values -> values.state = null); + } + + @Test + void comparesByEveryField() { + assertEquals(new Values().build(), new Values().build()); + assertEquals(new Values().build().hashCode(), + new Values().build().hashCode()); + List> changes = List.of( + values -> values.tenantId = "tenant-b", + values -> values.workspaceId = "ws-b", + values -> values.generation = 2, + values -> values.storageId = "storage-b", + values -> values.displayName = "Workspace B", + values -> values.state = WorkspaceState.DRAINING, + values -> values.policyRef = "policy:b", + values -> values.configRef = "config:b", + values -> values.tenantId = "TENANT-A", + values -> values.workspaceId = "WS-A", + values -> values.storageId = "Storage-a", + values -> values.displayName = "workspace a", + values -> values.policyRef = "Policy:a", + values -> values.configRef = "Config:a"); + for (Consumer change : changes) { + Values changed = new Values(); + change.accept(changed); + assertNotEquals(new Values().build(), changed.build()); + } + Values copied = new Values(); + copied.tenantId = copy(copied.tenantId); + copied.workspaceId = copy(copied.workspaceId); + copied.storageId = copy(copied.storageId); + copied.displayName = copy(copied.displayName); + copied.policyRef = copy(copied.policyRef); + copied.configRef = copy(copied.configRef); + assertEquals(new Values().build(), copied.build()); + WorkspaceRecord record = new Values().build(); + assertFalse(record.equals(null)); + assertFalse(record.equals(record.getWorkspaceId())); + } + + private static void assertInvalid(Consumer change) { + Values values = new Values(); + change.accept(values); + assertThrows(IllegalArgumentException.class, values::build); + } + + private static final class Values { + private String tenantId = "tenant-a"; + private String workspaceId = "ws-a"; + private long generation = 1; + private String storageId = "storage-a"; + private String displayName = "Workspace A"; + private WorkspaceState state = WorkspaceState.ACTIVE; + private String policyRef = "policy:a"; + private String configRef = "config:a"; + + WorkspaceRecord build() { + return new WorkspaceRecord(tenantId, workspaceId, generation, + storageId, displayName, state, policyRef, configRef); + } + } +} diff --git a/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceRelativePathTest.java b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceRelativePathTest.java new file mode 100644 index 00000000000..cbb217eecd2 --- /dev/null +++ b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceRelativePathTest.java @@ -0,0 +1,71 @@ +package com.alibaba.qwen.code.runtimebroker.managedworkspace; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class WorkspaceRelativePathTest { + @Test + void normalizesSeparatorsAndDotSegments() { + assertEquals(".", WorkspaceRelativePath.normalize(".")); + assertEquals(".", WorkspaceRelativePath.normalize(".//./")); + assertEquals("services/api", + WorkspaceRelativePath.normalize("./services//./api/")); + } + + @Test + void keepsSpacesCaseAndUnicodeUnchanged() { + assertEquals(" a /B ", WorkspaceRelativePath.normalize(" a /B ")); + assertEquals("cafe\u0301", + WorkspaceRelativePath.normalize("cafe\u0301")); + assertEquals("%2e%2e/..a/...", + WorkspaceRelativePath.normalize("%2e%2e/..a/...")); + } + + @Test + void rejectsEveryLexicalViolationWithoutEchoingTheInput() { + List invalid = List.of("", "/a", "C:x", "z:", "./C:x", + ".//c:/x", "./Z:", "a\\b", "..", "../a", "a/../b", "a/..", + "a\u0000b", "a\nb", "a\u007fb", "a\u009bb", "a\ud800", + "\udc00a", "a".repeat(1025)); + for (String value : invalid) { + WorkspaceException error = assertThrows(WorkspaceException.class, + () -> WorkspaceRelativePath.normalize(value), value); + assertEquals("invalid_cwd", error.getCode(), value); + assertEquals(400, error.getStatusCode(), value); + if (!value.isEmpty()) { + assertFalse(error.getMessage().contains(value), value); + } + } + } + + @Test + void allowsAColonThatIsNotADrivePrefix() { + assertEquals("ab:c", WorkspaceRelativePath.normalize("ab:c")); + assertEquals("a/C:x", WorkspaceRelativePath.normalize("./a/C:x")); + } + + @Test + void countsLengthInCodePoints() { + String clef = "\uD834\uDD1E"; + assertEquals(clef.repeat(1024), + WorkspaceRelativePath.normalize(clef.repeat(1024))); + assertThrows(WorkspaceException.class, + () -> WorkspaceRelativePath.normalize(clef.repeat(1025))); + } + + @Test + void appliesTheLengthLimitBeforeNormalizing() { + assertThrows(WorkspaceException.class, + () -> WorkspaceRelativePath.normalize("./".repeat(513))); + } + + @Test + void treatsNullAsAProgrammingError() { + assertThrows(IllegalArgumentException.class, + () -> WorkspaceRelativePath.normalize(null)); + } +} diff --git a/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceSelectionTest.java b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceSelectionTest.java new file mode 100644 index 00000000000..9be72429e0a --- /dev/null +++ b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceSelectionTest.java @@ -0,0 +1,89 @@ +package com.alibaba.qwen.code.runtimebroker.managedworkspace; + +import static com.alibaba.qwen.code.runtimebroker.managedworkspace.TestWorkspaces.copy; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class WorkspaceSelectionTest { + @Test + void describesAnOmittedSelection() { + WorkspaceSelection omitted = WorkspaceSelection.omitted(); + + assertTrue(omitted.isOmitted()); + assertTrue(omitted.getWorkspaceId().isEmpty()); + assertEquals(".", omitted.getCwdRelative()); + assertEquals(List.of("omitted"), omitted.digestFields()); + } + + @Test + void normalizesTheDirectoryOfAnExplicitSelection() { + WorkspaceSelection selection = WorkspaceSelection.explicit("ws-a", + "./services//api/"); + + assertFalse(selection.isOmitted()); + assertEquals("ws-a", selection.getWorkspaceId().orElseThrow()); + assertEquals("services/api", selection.getCwdRelative()); + assertEquals(List.of("explicit", "ws-a", "services/api"), + selection.digestFields()); + assertEquals(".", WorkspaceSelection.explicit("ws-a") + .getCwdRelative()); + } + + @Test + void digestsSpellingsOfOneDirectoryAlike() { + WorkspaceSelection spelled = WorkspaceSelection.explicit("ws-a", + "./a//b/."); + WorkspaceSelection plain = WorkspaceSelection.explicit("ws-a", "a/b"); + + assertEquals(plain, spelled); + assertEquals(plain.hashCode(), spelled.hashCode()); + assertEquals(plain.digestFields(), spelled.digestFields()); + assertNotEquals(plain, WorkspaceSelection.explicit("ws-a", "a/c")); + assertNotEquals(plain, WorkspaceSelection.explicit("ws-b", "a/b")); + assertEquals(plain, WorkspaceSelection.explicit(copy("ws-a"), + "a/b")); + assertNotEquals(plain, WorkspaceSelection.explicit("WS-A", "a/b")); + assertNotEquals(plain, WorkspaceSelection.explicit("ws-a", "A/b")); + assertFalse(plain.equals(null)); + assertFalse(plain.equals("ws-a")); + assertFalse(WorkspaceSelection.omitted().equals(null)); + } + + @Test + void neverDigestsOmissionLikeAnExplicitChoice() { + assertNotEquals(WorkspaceSelection.omitted().digestFields(), + WorkspaceSelection.explicit("omitted").digestFields()); + assertNotEquals(WorkspaceSelection.omitted(), + WorkspaceSelection.explicit("ws-a")); + } + + @Test + void rejectsAnInvalidDirectoryWhenBuilt() { + WorkspaceException error = assertThrows(WorkspaceException.class, + () -> WorkspaceSelection.explicit("ws-a", "a/../b")); + + assertEquals("invalid_cwd", error.getCode()); + } + + @Test + void keepsAnyWorkspaceIdForResolutionToReject() { + assertEquals("../x", WorkspaceSelection.explicit("../x") + .getWorkspaceId().orElseThrow()); + assertEquals(" ws-a", WorkspaceSelection.explicit(" ws-a") + .getWorkspaceId().orElseThrow()); + } + + @Test + void treatsMissingValuesAsProgrammingErrors() { + assertThrows(IllegalArgumentException.class, + () -> WorkspaceSelection.explicit(null)); + assertThrows(IllegalArgumentException.class, + () -> WorkspaceSelection.explicit("ws-a", null)); + } +} diff --git a/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceViewTest.java b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceViewTest.java new file mode 100644 index 00000000000..5d153717726 --- /dev/null +++ b/packages/sdk-java/runtime-broker/src/test/java/com/alibaba/qwen/code/runtimebroker/managedworkspace/WorkspaceViewTest.java @@ -0,0 +1,65 @@ +package com.alibaba.qwen.code.runtimebroker.managedworkspace; + +import static com.alibaba.qwen.code.runtimebroker.managedworkspace.TestWorkspaces.TENANT; +import static com.alibaba.qwen.code.runtimebroker.managedworkspace.TestWorkspaces.copy; +import static com.alibaba.qwen.code.runtimebroker.managedworkspace.TestWorkspaces.workspace; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class WorkspaceViewTest { + private static final WorkspaceRecord ALPHA = workspace(TENANT, "alpha", + WorkspaceState.ACTIVE); + + @Test + void showsOnlyTheListedFields() { + WorkspaceView view = new WorkspaceView(ALPHA, WorkspaceAccess.CREATE); + + assertEquals("alpha", view.getWorkspaceId()); + assertEquals("Workspace alpha", view.getDisplayName()); + assertEquals(WorkspaceState.ACTIVE, view.getState()); + assertTrue(view.canCreateSession()); + assertFalse(new WorkspaceView(ALPHA, WorkspaceAccess.READ) + .canCreateSession()); + } + + @Test + void comparesByEveryField() { + WorkspaceView view = new WorkspaceView(ALPHA, WorkspaceAccess.CREATE); + + assertEquals(new WorkspaceView(ALPHA, WorkspaceAccess.CREATE), view); + assertEquals(new WorkspaceView(new WorkspaceRecord(copy(TENANT), + copy("alpha"), 1, "storage-alpha", + copy("Workspace alpha"), WorkspaceState.ACTIVE, + "policy:default", "config:alpha"), WorkspaceAccess.CREATE), + view); + assertEquals(new WorkspaceView(ALPHA, WorkspaceAccess.CREATE) + .hashCode(), view.hashCode()); + assertNotEquals(new WorkspaceView(ALPHA, WorkspaceAccess.READ), view); + assertNotEquals(new WorkspaceView(new WorkspaceRecord(TENANT, "beta", + 1, "storage-alpha", "Workspace alpha", WorkspaceState.ACTIVE, + "policy:default", "config:alpha"), WorkspaceAccess.CREATE), + view); + assertNotEquals(new WorkspaceView(workspace(TENANT, "beta", + WorkspaceState.ACTIVE), WorkspaceAccess.CREATE), view); + assertNotEquals(new WorkspaceView(workspace(TENANT, "alpha", + WorkspaceState.DRAINING), WorkspaceAccess.CREATE), view); + assertNotEquals(new WorkspaceView(new WorkspaceRecord(TENANT, "alpha", + 1, "storage-alpha", "Renamed", WorkspaceState.ACTIVE, + "policy:default", "config:alpha"), WorkspaceAccess.CREATE), + view); + assertNotEquals(new WorkspaceView(new WorkspaceRecord(TENANT, "Alpha", + 1, "storage-alpha", "Workspace alpha", WorkspaceState.ACTIVE, + "policy:default", "config:alpha"), WorkspaceAccess.CREATE), + view); + assertNotEquals(new WorkspaceView(new WorkspaceRecord(TENANT, "alpha", + 1, "storage-alpha", "workspace alpha", WorkspaceState.ACTIVE, + "policy:default", "config:alpha"), WorkspaceAccess.CREATE), + view); + assertFalse(view.equals(null)); + assertFalse(view.equals("alpha")); + } +} diff --git a/packages/sdk-java/runtime-broker/src/test/resources/fake-attestation-worker.mjs b/packages/sdk-java/runtime-broker/src/test/resources/fake-attestation-worker.mjs index a0c983f494f..60acf621ba5 100644 --- a/packages/sdk-java/runtime-broker/src/test/resources/fake-attestation-worker.mjs +++ b/packages/sdk-java/runtime-broker/src/test/resources/fake-attestation-worker.mjs @@ -18,6 +18,8 @@ const boot = JSON.parse( const args = process.argv.slice(2); const chatty = args.includes('--chatty'); const foreignUrl = args.includes('--foreign-url'); +const portArg = args.find((arg) => arg.startsWith('--port=')); +const port = portArg ? Number(portArg.slice('--port='.length)) : 0; const probeArg = args.find((arg) => arg.startsWith('--probe=')); const probePath = probeArg ? probeArg.slice('--probe='.length) : ''; if (args.includes('--big-ready')) { @@ -77,7 +79,7 @@ const server = createServer((request, response) => { }); }); -server.listen(0, '127.0.0.1', () => { +server.listen(port, '127.0.0.1', () => { const address = server.address(); const host = foreignUrl ? '172.16.1.234' : '127.0.0.1'; process.stdout.write( diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index bbc7bb1f370..34ee8e27e18 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -4,6 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { + validateStartupConfigRequest, + assertStartupConfigApplied, +} from './session-startup-config.js'; import { MCP_RESTART_SERVER_DEADLINE_MS, MCP_RESTART_CLIENT_HEADROOM_MS, @@ -42,6 +46,7 @@ import type { DaemonSessionContextUsageStatus, DaemonSessionConfigOptionResult, ReasoningSelection, + SessionStartupConfig, BranchSessionRequest, DaemonBranchSessionRequest, DaemonBranchSessionResult, @@ -636,6 +641,7 @@ export function isStaleBranchPointError( } export interface CreateSessionRequest { + startupConfig?: SessionStartupConfig; /** * Workspace path the daemon must have registered. When * omitted, the SDK sends no `cwd` field and the daemon route falls @@ -2948,11 +2954,16 @@ export class DaemonClient { async createStandaloneSession( options: CreateStandaloneSessionOptions = {}, ): Promise { + validateStartupConfigRequest(options); await this.requireCapability(STANDALONE_SESSIONS_CAPABILITY); + if (options.startupConfig !== undefined) { + await this.requireCapability('session_startup_config'); + } const { sessionId: requestedSessionId, ...request } = options; const sessionId = ( requestedSessionId ?? createStandaloneSessionId() ).toLowerCase(); + let session: DaemonStandaloneSession; try { const response = await this.jsonRequest( '/standalone/sessions', @@ -2963,7 +2974,7 @@ export class DaemonClient { mode: 'rest', }, ); - return parseStandaloneSession( + session = parseStandaloneSession( response, 'POST /standalone/sessions', sessionId, @@ -2982,6 +2993,8 @@ export class DaemonClient { error, ); } + assertStartupConfigApplied(session, options.startupConfig); + return session; } async listStandaloneSessions( @@ -3208,6 +3221,10 @@ export class DaemonClient { req: CreateSessionRequest, clientId?: string, ): Promise { + validateStartupConfigRequest(req); + if (req.startupConfig !== undefined) { + await this.requireCapability('session_startup_config'); + } if (req.sessionId !== undefined && req.sessionId !== null) { await this.requireCapability('session_id_override'); } @@ -3233,6 +3250,9 @@ export class DaemonClient { headers: this.headers({ 'Content-Type': 'application/json' }, clientId), body: JSON.stringify({ cwd: req.workspaceCwd, + ...(req.startupConfig !== undefined + ? { startupConfig: req.startupConfig } + : {}), ...(req.sessionId !== undefined ? { sessionId: req.sessionId } : {}), ...(req.modelServiceId ? { modelServiceId: req.modelServiceId } : {}), // `!== undefined` (not truthy) so a buggy caller passing @@ -3258,6 +3278,7 @@ export class DaemonClient { async (res) => { if (!res.ok) throw await this.failOnError(res, 'POST /session'); const session = (await res.json()) as DaemonSession; + assertStartupConfigApplied(session, req.startupConfig); if ( typeof req.sessionId === 'string' && session.sessionId !== req.sessionId.toLowerCase() diff --git a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts index c37f5dfda7e..4a66cc2bd3e 100644 --- a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts @@ -573,14 +573,21 @@ export class DaemonSessionClient { } /** - * Present when this client was created with a `modelServiceId`: `false` - * means the spawn-time model switch failed and the session is running on - * the agent default model. + * Only present on a fresh spawn (`attached: false`) that carried + * `modelServiceId` or `startupConfig`; an attach omits the key or, when + * it coalesced with an in-flight spawn, reports the spawn owner's + * outcome. Startup preparation succeeds only with true; legacy false + * means the switch was rejected (surfaced via `model_switch_failed`) + * and the session uses the agent default model. */ get modelApplied(): DaemonSession['modelApplied'] { return this.session.modelApplied; } + get startupConfigApplied(): DaemonSession['startupConfigApplied'] { + return this.session.startupConfigApplied; + } + get lastEventId(): number | undefined { return this.lastSeenEventId; } diff --git a/packages/sdk-typescript/src/daemon/acpRouteTable.ts b/packages/sdk-typescript/src/daemon/acpRouteTable.ts index 6bc8afc5c86..935a4742683 100644 --- a/packages/sdk-typescript/src/daemon/acpRouteTable.ts +++ b/packages/sdk-typescript/src/daemon/acpRouteTable.ts @@ -122,11 +122,20 @@ export const ROUTE_TABLE: readonly RouteEntry[] = [ _meta, ...rest } = body as Record; + const startupScope = + body.startupConfig !== undefined && _ !== undefined + ? { sessionScope: _ } + : {}; if (sessionId === undefined) { - return { ...rest, ...(_meta !== undefined ? { _meta } : {}) }; + return { + ...rest, + ...startupScope, + ...(_meta !== undefined ? { _meta } : {}), + }; } return { ...rest, + ...startupScope, _meta: { ...(isRecord(_meta) ? _meta : {}), [REQUESTED_SESSION_ID_META_KEY]: sessionId, diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index 57f2b1541d3..47b89d10a23 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -96,6 +96,7 @@ export { type DaemonUnarchiveStandaloneSessionsResult, type RestoreStandaloneSessionRequest, } from './standalone-sessions.js'; +export { DaemonStartupConfigError } from './session-startup-config.js'; export { asKnownDaemonEvent, DAEMON_KNOWN_EVENT_TYPE_VALUES, @@ -844,6 +845,8 @@ export type { SetModelResult, DaemonSessionConfigOptionResult, ReasoningSelection, + SessionStartupConfig, + SessionStartupConfigApplied, SetSessionLanguageResult, SetUserLanguageResult, KnownDaemonSessionArtifactChangeAction, diff --git a/packages/sdk-typescript/src/daemon/session-startup-config.ts b/packages/sdk-typescript/src/daemon/session-startup-config.ts new file mode 100644 index 00000000000..62505069003 --- /dev/null +++ b/packages/sdk-typescript/src/daemon/session-startup-config.ts @@ -0,0 +1,100 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { DAEMON_REASONING_SELECTIONS } from './types.js'; + +import type { + DaemonSession, + ReasoningSelection, + SessionStartupConfig, +} from './types.js'; + +const REASONING_EFFORTS = DAEMON_REASONING_SELECTIONS.filter( + (selection) => selection !== 'none' && selection !== 'default', +); + +/** + * Pre-transport rejection of a malformed `startupConfig`. Stays a + * `TypeError` (an argument-shape violation) while carrying the daemon's + * stable `code`, so one `code`-based catch covers both this local + * rejection and the daemon's `400 invalid_startup_config` response. + * Where that daemon response carries the code depends on the transport: + * REST puts it at `body.code`, while the ACP transports synthesize the + * error body as `{ error, data }` with the code at `body.data.errorKind`. + */ +export class DaemonStartupConfigError extends TypeError { + readonly code = 'invalid_startup_config' as const; +} + +function isSelection(value: unknown): value is ReasoningSelection { + return ( + typeof value === 'string' && + (DAEMON_REASONING_SELECTIONS as readonly string[]).includes(value) + ); +} + +export function validateStartupConfigRequest(request: { + startupConfig?: SessionStartupConfig; + modelServiceId?: string; + sessionScope?: string; +}): void { + const config = request.startupConfig; + if (config === undefined) return; + if ( + config === null || + typeof config !== 'object' || + Array.isArray(config) || + Object.keys(config).some( + (key) => key !== 'modelServiceId' && key !== 'reasoningEffort', + ) || + typeof config.modelServiceId !== 'string' || + !config.modelServiceId.trim() || + config.modelServiceId.length > 256 || + (config.reasoningEffort !== undefined && + !isSelection(config.reasoningEffort)) || + request.modelServiceId !== undefined || + request.sessionScope === 'single' + ) { + throw new DaemonStartupConfigError( + 'Invalid startupConfig: provide modelServiceId (1-256 characters) and an optional valid reasoningEffort without legacy modelServiceId or single session scope.', + ); + } +} + +export function assertStartupConfigApplied( + session: DaemonSession, + requested: SessionStartupConfig | undefined, +): void { + if (!requested) return; + const applied = session.startupConfigApplied; + const effective = applied?.effectiveReasoning; + const effectiveValid = + effective && + (effective.state === 'disabled' || + effective.state === 'provider-default' || + (effective.state === 'enabled' && + (effective.effort === undefined || + REASONING_EFFORTS.includes(effective.effort)))); + if ( + session.modelApplied !== true || + !applied || + typeof applied.modelServiceId !== 'string' || + !applied.modelServiceId.trim() || + applied.reasoningEffort !== requested.reasoningEffort || + ((requested.reasoningEffort !== undefined || effective !== undefined) && + !effectiveValid) || + (requested.reasoningEffort === 'none' && effective?.state !== 'disabled') || + (requested.reasoningEffort !== undefined && + requested.reasoningEffort !== 'default' && + requested.reasoningEffort !== 'none' && + (effective?.state !== 'enabled' || + effective.effort !== requested.reasoningEffort)) + ) { + throw new Error( + 'The daemon did not confirm the requested session startup configuration.', + ); + } +} diff --git a/packages/sdk-typescript/src/daemon/standalone-sessions.ts b/packages/sdk-typescript/src/daemon/standalone-sessions.ts index b373e819259..c55296c938a 100644 --- a/packages/sdk-typescript/src/daemon/standalone-sessions.ts +++ b/packages/sdk-typescript/src/daemon/standalone-sessions.ts @@ -8,6 +8,7 @@ import { DaemonHttpError } from './DaemonHttpError.js'; import { DAEMON_APPROVAL_MODES, DAEMON_ERROR_KINDS } from './types.js'; import type { DaemonApprovalMode, + SessionStartupConfig, DaemonRestoredSession, DaemonSession, DaemonSessionArchiveState, @@ -25,6 +26,7 @@ export type DaemonStandaloneSessionOptions = Omit< >; export interface CreateStandaloneSessionOptions { + startupConfig?: SessionStartupConfig; sessionId?: string; modelServiceId?: string; approvalMode?: DaemonApprovalMode; diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 504642084a0..2cada6322f7 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -1400,6 +1400,7 @@ export function parseDaemonBackgroundTurn( /** Returned from `POST /session`. */ export interface DaemonSession { + startupConfigApplied?: SessionStartupConfigApplied; sessionId: string; /** Immutable runtime ownership root used for daemon routing. */ workspaceCwd: string; @@ -1431,10 +1432,16 @@ export interface DaemonSession { /** True iff supplied source metadata was durably written to the transcript. */ sourcePersisted?: boolean; /** - * Present on a create response when the request carried `modelServiceId`. - * `false` means the spawn-time model switch failed and the session is - * running on the agent default model (also surfaced via the - * `model_switch_failed` session event). + * Only present on a fresh spawn (`attached: false`) that carried + * `modelServiceId` or `startupConfig`. Always true for successful + * startupConfig preparation. For legacy model selection, true confirms + * the model switch; false means the apply failed (surfaced via + * `model_switch_failed`) and the session is running on the agent's + * default model. An attach omits the key or, when it coalesced with an + * in-flight spawn, reports the spawn owner's outcome — on attach the + * `model_switch_failed` event is the caller's signal. Lets create + * callers distinguish a confirmed selection from a silent fallback + * instead of assuming the requested model is live. */ modelApplied?: boolean; /** Present when the session was created with worktree isolation. */ @@ -3721,15 +3728,35 @@ export interface SetModelResult { [key: string]: unknown; } +/** Creation-only selection; does not change shared defaults or later session behavior. */ +export interface SessionStartupConfig { + modelServiceId: string; + reasoningEffort?: ReasoningSelection; +} + +/** Confirmed state after startup preparation, not a lifetime policy. */ +export interface SessionStartupConfigApplied extends SessionStartupConfig { + effectiveReasoning?: + | { + state: 'enabled'; + effort?: Exclude; + } + | { state: 'disabled' } + | { state: 'provider-default' }; +} + /** Returned from `POST /session/:id/config-option`. */ -export type ReasoningSelection = - | 'none' - | 'default' - | 'low' - | 'medium' - | 'high' - | 'xhigh' - | 'max'; +export const DAEMON_REASONING_SELECTIONS = [ + 'none', + 'default', + 'low', + 'medium', + 'high', + 'xhigh', + 'max', +] as const; + +export type ReasoningSelection = (typeof DAEMON_REASONING_SELECTIONS)[number]; export interface DaemonSessionConfigOptionResult { configOptions: unknown[]; diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index 2c2655a8b89..a23d822afd9 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -213,6 +213,8 @@ export { type DaemonProtocolVersions, type DaemonRestoredSession, type DaemonSession, + type SessionStartupConfig, + type SessionStartupConfigApplied, type DaemonSessionClosedReason, type DaemonWorktreeInfo, type DaemonBranchInfo, @@ -414,6 +416,7 @@ export { export { DaemonStandaloneCreationOutcomeUnknownError, DaemonStandaloneProtocolError, + DaemonStartupConfigError, STANDALONE_SESSION_OPTIONS_CAPABILITY, STANDALONE_SESSIONS_CAPABILITY, isStandaloneCreationOutcomeUnknown, diff --git a/packages/sdk-typescript/test/unit/DaemonClient.test.ts b/packages/sdk-typescript/test/unit/DaemonClient.test.ts index bbd9c52a2da..f46a614d7c6 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -19,6 +19,7 @@ import type { DaemonSessionAgentsStatus } from '../../src/daemon/index.js'; import { negotiateTransport } from '../../src/daemon/negotiateTransport.js'; import { DaemonCapabilityMissingError, + DAEMON_REASONING_SELECTIONS, isDaemonContentHash, requireWorkspaceCwd, } from '../../src/daemon/types.js'; @@ -3473,6 +3474,205 @@ describe('DaemonClient', () => { }); describe('createOrAttachSession', () => { + const startupConfig = { + modelServiceId: 'gpt-5.4(openai)', + reasoningEffort: 'high' as const, + }; + const startupConfigApplied = { + ...startupConfig, + effectiveReasoning: { state: 'enabled', effort: 'high' }, + }; + + it.each(DAEMON_REASONING_SELECTIONS)( + 'accepts and confirms the SDK reasoning selection %s', + async (reasoningEffort) => { + const applied = { + ...startupConfig, + reasoningEffort, + effectiveReasoning: + reasoningEffort === 'none' + ? { state: 'disabled' } + : reasoningEffort === 'default' + ? { state: 'provider-default' } + : { state: 'enabled', effort: reasoningEffort }, + }; + const { fetch } = recordingFetch((request) => + request.url.endsWith('/capabilities') + ? jsonResponse(200, { v: 1, features: ['session_startup_config'] }) + : jsonResponse(200, { + sessionId: 'new', + modelApplied: true, + startupConfigApplied: applied, + }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + expect( + ( + await client.createOrAttachSession({ + startupConfig: { ...startupConfig, reasoningEffort }, + }) + ).startupConfigApplied, + ).toEqual(applied); + }, + ); + + it('accepts a toggle-only default and rejects enabled reasoning for a none request', async () => { + for (const reasoningEffort of ['default', 'none'] as const) { + const { fetch } = recordingFetch((request) => + request.url.endsWith('/capabilities') + ? jsonResponse(200, { v: 1, features: ['session_startup_config'] }) + : jsonResponse(200, { + sessionId: 'new', + modelApplied: true, + startupConfigApplied: { + ...startupConfig, + reasoningEffort, + effectiveReasoning: { state: 'enabled' }, + }, + }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + const create = client.createOrAttachSession({ + startupConfig: { ...startupConfig, reasoningEffort }, + }); + if (reasoningEffort === 'none') + await expect(create).rejects.toThrow('did not confirm'); + else + await expect(create).resolves.toMatchObject({ + startupConfigApplied: { effectiveReasoning: { state: 'enabled' } }, + }); + } + }); + + it('preflights and serializes startup configuration and reads its confirmation', async () => { + const { fetch, calls } = recordingFetch((request) => + request.url.endsWith('/capabilities') + ? jsonResponse(200, { + v: 1, + mode: 'serve', + features: ['session_startup_config'], + }) + : jsonResponse(200, { + sessionId: 'new', + attached: false, + modelApplied: true, + startupConfigApplied, + }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + expect( + (await client.createOrAttachSession({ startupConfig })) + .startupConfigApplied, + ).toEqual(startupConfigApplied); + expect(JSON.parse(calls[1]!.body!)).toEqual({ startupConfig }); + }); + + it('serializes model-only startup without inventing a reasoning selection', async () => { + const modelOnly = { modelServiceId: 'gpt-4.1(openai)' }; + const { fetch, calls } = recordingFetch((request) => + request.url.endsWith('/capabilities') + ? jsonResponse(200, { v: 1, features: ['session_startup_config'] }) + : jsonResponse(200, { + sessionId: 'new', + modelApplied: true, + startupConfigApplied: modelOnly, + }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + const created = await client.createOrAttachSession({ + startupConfig: modelOnly, + }); + expect(created.startupConfigApplied).toEqual(modelOnly); + expect( + JSON.parse(calls.find((call) => call.method === 'POST')!.body!), + ).toEqual({ startupConfig: modelOnly }); + }); + + it('does not create when the daemon lacks startup capability', async () => { + const { fetch, calls } = recordingFetch(() => + jsonResponse(200, { v: 1, features: [] }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + await expect( + client.createOrAttachSession({ startupConfig }), + ).rejects.toBeInstanceOf(DaemonCapabilityMissingError); + expect(calls.every((call) => call.method === 'GET')).toBe(true); + }); + + it.each([ + {}, + { + modelApplied: true, + startupConfigApplied: { ...startupConfigApplied, modelServiceId: ' ' }, + }, + { + modelApplied: true, + startupConfigApplied: { ...startupConfigApplied, modelServiceId: 123 }, + }, + { + modelApplied: true, + startupConfigApplied: { + ...startupConfigApplied, + effectiveReasoning: { state: 'enabled', effort: 'medium' }, + }, + }, + { + modelApplied: true, + startupConfigApplied: { + ...startupConfigApplied, + effectiveReasoning: { state: 'provider-default' }, + }, + }, + { modelApplied: false, startupConfigApplied }, + { + modelApplied: true, + startupConfigApplied: { + ...startupConfigApplied, + reasoningEffort: 'low', + }, + }, + { + modelApplied: true, + startupConfigApplied: { + ...startupConfigApplied, + effectiveReasoning: { state: 'disabled' }, + }, + }, + ])('rejects unconfirmed startup result %j', async (result) => { + const { fetch } = recordingFetch((request) => + request.url.endsWith('/capabilities') + ? jsonResponse(200, { v: 1, features: ['session_startup_config'] }) + : jsonResponse(200, { sessionId: 'new', ...result }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + await expect( + client.createOrAttachSession({ startupConfig }), + ).rejects.toThrow('did not confirm'); + }); + + it('rejects startup conflicts before any transport call', async () => { + const { fetch, calls } = recordingFetch(() => jsonResponse(200, {})); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + // The local rejection carries the daemon's stable code, so one + // code-based catch covers both sides of the transport. + await expect( + client.createOrAttachSession({ startupConfig, sessionScope: 'single' }), + ).rejects.toMatchObject({ + code: 'invalid_startup_config', + message: expect.stringContaining('Invalid startupConfig'), + }); + await expect( + client.createOrAttachSession({ + startupConfig, + modelServiceId: 'legacy', + }), + ).rejects.toMatchObject({ + code: 'invalid_startup_config', + message: expect.stringContaining('Invalid startupConfig'), + }); + expect(calls).toEqual([]); + }); + it('gates sessionId before mutation, serializes it, and verifies the response', async () => { const requested = '550E8400-E29B-41D4-A716-446655440000'; const { fetch, calls } = recordingFetch((request) => diff --git a/packages/sdk-typescript/test/unit/DaemonClientStandalone.test.ts b/packages/sdk-typescript/test/unit/DaemonClientStandalone.test.ts index c6797a5b200..7299588ff46 100644 --- a/packages/sdk-typescript/test/unit/DaemonClientStandalone.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClientStandalone.test.ts @@ -139,6 +139,128 @@ describe('DaemonClient standalone sessions', () => { vi.unstubAllGlobals(); }); + it('preflights startup capability outside creation recovery', async () => { + const { fetch, calls } = recordingFetch(() => capabilityResponse()); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + await expect( + client.createStandaloneSession({ + startupConfig: { + modelServiceId: 'gpt-5.4(openai)', + reasoningEffort: 'high', + }, + }), + ).rejects.toMatchObject({ name: 'DaemonCapabilityMissingError' }); + expect(calls.every((call) => call.url.endsWith('/capabilities'))).toBe( + true, + ); + }); + + it('serializes and confirms standalone startup selection', async () => { + const startupConfig = { + modelServiceId: 'gpt-5.4(openai)', + reasoningEffort: 'high' as const, + }; + const startupConfigApplied = { + ...startupConfig, + effectiveReasoning: { state: 'enabled', effort: 'high' }, + }; + const { fetch, calls } = recordingFetch((request) => + request.url.endsWith('/capabilities') + ? jsonResponse(200, { + v: 1, + features: ['standalone_sessions_v1', 'session_startup_config'], + }) + : jsonResponse(200, { + ...standaloneSession(), + modelApplied: true, + startupConfigApplied, + }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + expect( + ( + await client.createStandaloneSession({ + sessionId: SESSION_ID, + startupConfig, + }) + ).startupConfigApplied, + ).toEqual(startupConfigApplied); + expect( + JSON.parse(calls.find((call) => call.method === 'POST')!.body!), + ).toEqual({ + sessionId: SESSION_ID, + startupConfig, + }); + }); + + it('accepts a model-only standalone confirmation without reasoning fields', async () => { + const startupConfig = { modelServiceId: 'gpt-4.1(openai)' }; + const { fetch, calls } = recordingFetch((request) => + request.url.endsWith('/capabilities') + ? jsonResponse(200, { + v: 1, + features: ['standalone_sessions_v1', 'session_startup_config'], + }) + : jsonResponse(200, { + ...standaloneSession(), + modelApplied: true, + startupConfigApplied: startupConfig, + }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + expect( + ( + await client.createStandaloneSession({ + sessionId: SESSION_ID, + startupConfig, + }) + ).startupConfigApplied, + ).toEqual(startupConfig); + expect( + JSON.parse(calls.find((call) => call.method === 'POST')!.body!), + ).toEqual({ sessionId: SESSION_ID, startupConfig }); + }); + + it.each([ + undefined, + { + modelServiceId: 'gpt-5.4(openai)', + reasoningEffort: 'high', + effectiveReasoning: { state: 'enabled', effort: 'medium' }, + }, + ])( + 'rejects a definite unconfirmed standalone result without recovery', + async (startupConfigApplied) => { + const { fetch, calls } = recordingFetch((request) => + request.url.endsWith('/capabilities') + ? jsonResponse(200, { + v: 1, + features: ['standalone_sessions_v1', 'session_startup_config'], + }) + : jsonResponse(200, { + ...standaloneSession(), + modelApplied: true, + startupConfigApplied, + }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + await expect( + client.createStandaloneSession({ + sessionId: SESSION_ID, + startupConfig: { + modelServiceId: 'gpt-5.4(openai)', + reasoningEffort: 'high', + }, + }), + ).rejects.toThrow('did not confirm'); + expect( + calls.filter( + (call) => call.method === 'GET' && call.url.includes('/standalone/'), + ), + ).toEqual([]); + }, + ); + it('gates standalone options with their dedicated capability', async () => { const { fetch, calls } = recordingFetch(() => capabilityResponse(true, false), diff --git a/packages/sdk-typescript/test/unit/acpRouteTable.test.ts b/packages/sdk-typescript/test/unit/acpRouteTable.test.ts index e76f9ef922b..5317b4cf203 100644 --- a/packages/sdk-typescript/test/unit/acpRouteTable.test.ts +++ b/packages/sdk-typescript/test/unit/acpRouteTable.test.ts @@ -57,6 +57,37 @@ describe('acpRouteTable – matchRoute', () => { expect(params).toEqual({ model: 'gpt-4' }); }); + it('preserves startup configuration and explicit scope for daemon validation', () => { + const result = matchRoute('/session', 'POST')!; + const body = { + startupConfig: { + modelServiceId: 'gpt-5.4(openai)', + reasoningEffort: 'high', + }, + sessionScope: 'single', + }; + expect(result.mapping.extractParams(result.segments, body, 'POST')).toEqual( + body, + ); + }); + + it('preserves startup scope when mapping a caller-supplied session id', () => { + const route = matchRoute('/session', 'POST')!; + const startupConfig = { modelServiceId: 'gpt-5.4(openai)' }; + const sessionId = '550E8400-E29B-41D4-A716-446655440000'; + expect( + route.mapping.extractParams( + route.segments, + { sessionId, startupConfig, sessionScope: 'single' }, + 'POST', + ), + ).toEqual({ + startupConfig, + sessionScope: 'single', + _meta: { 'qwen-code/sessionId': sessionId }, + }); + }); + it('POST /session maps sessionId into ACP metadata', () => { const result = matchRoute('/session', 'POST')!; const params = result.mapping.extractParams( diff --git a/packages/web-shell/client/App.module.css b/packages/web-shell/client/App.module.css index ade7c60c71c..f4cee40f6a3 100644 --- a/packages/web-shell/client/App.module.css +++ b/packages/web-shell/client/App.module.css @@ -716,6 +716,10 @@ padding: 16px 20px; } +.managedPanelBody { + overflow: hidden; +} + /* The chat view (message list + composer) stays mounted while a panel is shown, just hidden — so the composer keeps its unsent draft and attachments instead of losing them on unmount. */ diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index b13ad3eb82f..0b1340ca98d 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -196,6 +196,12 @@ import { type ModelDialogMode, } from './components/dialogs/ModelDialog'; import { ModelFallbacksDialog } from './components/dialogs/ModelFallbacksDialog'; +import { ManagedSessionsPage } from './components/managed/ManagedSessionsPage'; +import type { ManagedAgentProvider } from './components/managed/managed-agent-provider'; +import { + managedSelectionFromUrl, + saveManagedSelection, +} from './components/managed/managed-session-storage'; import { AgentsManagerPage } from './components/agents/AgentsManagerPage'; import { MemoryMessage } from './components/messages/MemoryMessage'; import { AuthMessage } from './components/messages/AuthMessage'; @@ -1200,6 +1206,8 @@ export interface WebShellProps { }) => void; /** Called after a new session is created. Session setup waits up to 30 seconds. */ onSessionCreated?: (sessionId: string) => Promise | void; + /** Explicit Managed Agent backend. Omit to keep the daemon provider unchanged. */ + managedAgentProvider?: ManagedAgentProvider; /** Visual theme for the embedded shell. */ theme?: WebShellTheme; /** Called when `/theme` changes the web-shell theme. */ @@ -3161,6 +3169,7 @@ export function App({ onSessionIdChange, onSessionInfoChange, onSessionCreated, + managedAgentProvider, theme: providedTheme, onThemeChange, onThemeResolved, @@ -3841,6 +3850,7 @@ export function App({ ordinaryWorkspaces.length > 0 || (workspace.capabilities?.workspaces === undefined && Boolean(workspace.capabilities?.workspaceCwd)); + const externalManagedAgentAvailable = managedAgentProvider !== undefined; const [pendingSessionContext, setPendingSessionContextState] = useState< DaemonProductSessionContext | undefined >(undefined); @@ -9075,12 +9085,26 @@ export function App({ | 'plugins' | 'agents' | 'channels' + | 'managed' | 'workspaces' | null - >(initialConnectionsSettingsCategory ? 'settings' : null); + >(() => + initialConnectionsSettingsCategory + ? 'settings' + : managedAgentProvider && managedSelectionFromUrl().open + ? 'managed' + : null, + ); + const [managedSessionId, setManagedSessionId] = useState( + () => managedSelectionFromUrl().sessionId, + ); + useEffect(() => { + saveManagedSelection(activePanel === 'managed', managedSessionId); + }, [activePanel, managedSessionId]); const chatActive = !activePanel && mainView === 'chat' && !artifactPanelFullscreen; const navigateToMessage = useMessageNavigation(messageListRef, chatActive); + const activePanelRef = useRef(activePanel); // Deep-link target for the Settings panel (e.g. 'Daemon' from the Local // Control QR popover). Cleared on any panel close/switch, not just @@ -9114,7 +9138,8 @@ export function App({ if ( !projectFeaturesAvailable && activePanel !== 'status' && - !(activePanel === 'settings' && initialConnectionsSettingsCategory) + !(activePanel === 'settings' && initialConnectionsSettingsCategory) && + !(activePanel === 'managed' && externalManagedAgentAvailable) ) { setActivePanel(null); } @@ -9145,6 +9170,7 @@ export function App({ } }, [ activePanel, + externalManagedAgentAvailable, mainView, modelDialogMode, projectFeaturesAvailable, @@ -9179,6 +9205,7 @@ export function App({ | 'plugins' | 'agents' | 'channels' + | 'managed' | 'workspaces', ) => { splitClassificationGenerationRef.current += 1; @@ -19497,6 +19524,14 @@ export function App({ closeMobileDrawer(); openPanel('channels'); }} + onOpenManagedSessions={ + managedAgentProvider + ? () => { + closeMobileDrawer(); + openPanel('managed'); + } + : undefined + } onOpenDaemonStatus={() => { closeMobileDrawer(); openPanel('status'); @@ -19894,7 +19929,10 @@ export function App({ )} {activePanel && - (projectFeaturesAvailable || activePanel === 'status') && ( + (projectFeaturesAvailable || + activePanel === 'status' || + (activePanel === 'managed' && + externalManagedAgentAvailable)) && (
@@ -20004,11 +20044,13 @@ export function App({ ? t('settings.title') : activePanel === 'status' ? t('daemon.title') - : t('sessionsOverview.title')} + : activePanel === 'managed' + ? t('managed.title') + : t('sessionsOverview.title')} )} -
+
+ ) : activePanel === 'managed' ? ( + ) : activePanel === 'agents' ? ( { diff --git a/packages/web-shell/client/ManagedAgentWebShell.test.tsx b/packages/web-shell/client/ManagedAgentWebShell.test.tsx new file mode 100644 index 00000000000..95afa20d31c --- /dev/null +++ b/packages/web-shell/client/ManagedAgentWebShell.test.tsx @@ -0,0 +1,60 @@ +// @vitest-environment jsdom + +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const captured = vi.hoisted(() => ({ props: undefined as unknown })); + +vi.mock('./components/managed/ManagedSessionsPage', () => ({ + ManagedSessionsPage: (props: unknown) => { + captured.props = props; + return
managed-only
; + }, +})); + +import { ManagedAgentWebShell } from './ManagedAgentWebShell'; +import type { ManagedAgentProvider } from './components/managed/managed-agent-provider'; + +describe('ManagedAgentWebShell', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + }); + + it('constructs a Java provider without daemon workspace props', async () => { + await act(async () => { + root.render( + , + ); + }); + + const props = captured.props as { + managedAgentProvider: ManagedAgentProvider; + workspaceCwd?: string; + sessionId?: string; + }; + expect(container.textContent).toBe('managed-only'); + expect( + container.querySelector('[data-web-shell-root]')?.getAttribute('lang'), + ).toBe('zh-CN'); + expect(props.managedAgentProvider.kind).toBe('java'); + expect(props.managedAgentProvider.acceptsWorkspaceCwd).toBe(false); + expect(props.workspaceCwd).toBeUndefined(); + expect(props.sessionId).toBe('session-1'); + }); +}); diff --git a/packages/web-shell/client/ManagedAgentWebShell.tsx b/packages/web-shell/client/ManagedAgentWebShell.tsx new file mode 100644 index 00000000000..999558e558b --- /dev/null +++ b/packages/web-shell/client/ManagedAgentWebShell.tsx @@ -0,0 +1,122 @@ +import './styles/globals.css'; +import { useEffect, useMemo, useState, type CSSProperties } from 'react'; +import { BrandProvider, type WebShellBrand } from './brandContext'; +import { ErrorBoundary } from './components/ErrorBoundary'; +import { ManagedSessionsPage } from './components/managed/ManagedSessionsPage'; +import { + createJavaManagedAgentProvider, + type JavaManagedAgentProviderOptions, +} from './components/managed/java-managed-agent-provider'; +import { RootErrorFallback } from './components/RootErrorFallback'; +import { WebShellCustomizationProvider } from './customization'; +import { I18nProvider, normalizeLanguage, type WebShellLanguage } from './i18n'; +import { WebShellPortalRootContext } from './portalRoot'; +import { + ThemeProvider, + WebShellThemeId, + type WebShellTheme, +} from './themeContext'; +import { CompactModeContext, TodoContextsProvider } from './WebShellContexts'; + +export interface ManagedAgentWebShellProps + extends JavaManagedAgentProviderOptions { + sessionId?: string; + onSessionChange?: (sessionId: string | undefined) => void; + language?: WebShellLanguage; + theme?: WebShellTheme; + brand?: WebShellBrand; + className?: string; + style?: CSSProperties; +} + +/** Managed-only 产品入口,不创建 daemon workspace/session context。 */ +export function ManagedAgentWebShell(props: ManagedAgentWebShellProps) { + const { + sessionId, + onSessionChange, + language, + theme = WebShellThemeId.Dark, + brand = {}, + className, + style, + baseUrl, + credentials, + environmentId, + fetch: fetchImpl, + getHeaders, + agentId, + productScope, + } = props; + const resolvedLanguage = normalizeLanguage(language); + const provider = useMemo( + () => + createJavaManagedAgentProvider({ + baseUrl, + credentials, + environmentId, + fetch: fetchImpl, + getHeaders, + agentId, + productScope, + }), + [ + baseUrl, + credentials, + environmentId, + fetchImpl, + getHeaders, + agentId, + productScope, + ], + ); + const [selectedSessionId, setSelectedSessionId] = useState(sessionId); + const [portalRoot, setPortalRoot] = useState(null); + const emptyMap = useMemo(() => new Map(), []); + useEffect(() => setSelectedSessionId(sessionId), [sessionId]); + + return ( + ( + + )} + > + + + + + + + +
+ { + setSelectedSessionId(next); + onSessionChange?.(next); + }} + managedAgentProvider={provider} + /> +
+
+
+
+
+
+
+
+
+ ); +} diff --git a/packages/web-shell/client/components/managed/ManagedSessionProgress.tsx b/packages/web-shell/client/components/managed/ManagedSessionProgress.tsx new file mode 100644 index 00000000000..9df5843bf86 --- /dev/null +++ b/packages/web-shell/client/components/managed/ManagedSessionProgress.tsx @@ -0,0 +1,53 @@ +import type { ManagedAgentSessionSummary } from './managed-agent-provider'; +import { useSharedNow } from '../../hooks/useSharedNow'; +import { useI18n } from '../../i18n'; +import { Spinner } from '../ui/spinner'; + +export function ManagedSessionProgress({ + summary, + submitting, + loading, +}: { + summary?: ManagedAgentSessionSummary; + submitting: boolean; + loading: boolean; +}) { + const { t } = useI18n(); + const active = + summary && !['completed', 'failed', 'cancelled'].includes(summary.phase); + const now = useSharedNow(Boolean(active)); + if (!submitting && !loading && !active) return null; + + return ( +
+
+
+ {active && !submitting && !loading && ( +

+ {t('managed.runningHint')} +

+ )} +
+ ); +} diff --git a/packages/web-shell/client/components/managed/ManagedSessionsPage.test.tsx b/packages/web-shell/client/components/managed/ManagedSessionsPage.test.tsx new file mode 100644 index 00000000000..e7b4d292f54 --- /dev/null +++ b/packages/web-shell/client/components/managed/ManagedSessionsPage.test.tsx @@ -0,0 +1,660 @@ +// @vitest-environment jsdom + +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { JavaManagedAgentHttpError } from './java-managed-agent-client'; +import { I18nProvider } from '../../i18n'; +import type { + ManagedAgentProvider, + ManagedAgentSessionSummary, + ManagedAgentSessionEvent, + ManagedAgentSessionTranscript, +} from './managed-agent-provider'; + +const mocks = vi.hoisted(() => ({ + useWorkspace: vi.fn(), + client: { + listSessions: vi.fn(), + getSession: vi.fn(), + getTranscript: vi.fn(), + subscribeEvents: vi.fn(), + createSession: vi.fn(), + submitPrompt: vi.fn(), + cancel: vi.fn(), + }, + features: ['managed_sessions', 'managed_session_cancel'], +})); + +vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ + useWorkspace: mocks.useWorkspace, +})); +vi.mock('../MessageList', () => ({ + MessageList: ({ + messages, + hasOlderHistory, + onLoadOlderHistory, + }: { + messages: unknown[]; + hasOlderHistory: boolean; + onLoadOlderHistory: () => Promise; + }) => ( + <> +
{JSON.stringify(messages)}
+ {hasOlderHistory && ( + + )} + + ), +})); + +import { ManagedSessionsPage } from './ManagedSessionsPage'; + +function summary( + sessionId = 's1', + extra: Partial = {}, +): ManagedAgentSessionSummary { + return { + sessionId, + activeTurnId: 'p1', + title: `Task ${sessionId}`, + workspaceCwd: '/workspace', + createdAt: 10, + updatedAt: 20, + admittedAt: 10, + phase: 'completed', + runtimeReady: true, + runtimeState: 'ready', + capabilities: { canSend: true, canCancel: false }, + ...extra, + }; +} + +function event(id: number, text: string): ManagedAgentSessionEvent { + return { + id, + at: id, + type: 'assistant_delta', + sessionId: 's1', + turnId: 'p1', + data: { text }, + }; +} + +async function flush() { + for (let i = 0; i < 8; i++) await Promise.resolve(); +} + +describe('ManagedSessionsPage', () => { + let container: HTMLDivElement; + let root: Root; + let onSelect: ReturnType; + let provider: ManagedAgentProvider; + + beforeEach(() => { + vi.clearAllMocks(); + provider = { + kind: 'java', + storageKey: 'http://managed-test', + canCancel: true, + acceptsWorkspaceCwd: false, + ...mocks.client, + }; + sessionStorage.clear(); + mocks.features = ['managed_sessions', 'managed_session_cancel']; + mocks.useWorkspace.mockImplementation(() => ({ + client: mocks.client, + baseUrl: 'http://managed-test', + capabilities: { features: mocks.features }, + })); + mocks.client.listSessions.mockResolvedValue({ + sessions: [summary()], + }); + mocks.client.getSession.mockImplementation(async (id: string) => + summary(id), + ); + mocks.client.getTranscript.mockResolvedValue({ + events: [ + event(1, 'Persisted answer'), + { ...event(2, ''), type: 'completed' }, + ], + lastEventId: 2, + }); + mocks.client.subscribeEvents.mockImplementation(async function* ( + _id: string, + opts: { signal: AbortSignal }, + ) { + await new Promise((resolve) => { + if (opts.signal.aborted) resolve(); + else + opts.signal.addEventListener('abort', () => resolve(), { + once: true, + }); + }); + yield* []; + }); + mocks.client.createSession.mockResolvedValue({ + sessionId: 'created', + turnId: 'p-new', + }); + mocks.client.submitPrompt.mockResolvedValue({ + sessionId: 's1', + turnId: 'p-next', + }); + mocks.client.cancel.mockResolvedValue({ accepted: true }); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + onSelect = vi.fn(); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.useRealTimers(); + }); + + async function render( + sessionId?: string, + language: 'en' | 'zh-CN' = 'en', + managedAgentProvider: ManagedAgentProvider | null = provider, + ) { + await act(async () => { + root.render( + + + , + ); + await flush(); + }); + } + + it('uses an explicit Java provider without daemon Managed capabilities', async () => { + mocks.features = []; + const listSessions = vi.fn().mockResolvedValue({ + sessions: [summary('java-session')], + }); + const provider: ManagedAgentProvider = { + kind: 'java', + storageKey: 'https://java.example/agent', + canCancel: true, + acceptsWorkspaceCwd: false, + listSessions, + getSession: vi.fn(), + getTranscript: vi.fn(), + createSession: vi.fn(), + submitPrompt: vi.fn(), + cancel: vi.fn(), + async *subscribeEvents() { + yield* []; + }, + }; + + await render(undefined, 'en', provider); + + expect(container.textContent).toContain('Task java-session'); + expect(listSessions).toHaveBeenCalledWith( + expect.objectContaining({ workspaceCwd: undefined }), + ); + expect(mocks.useWorkspace).not.toHaveBeenCalled(); + expect(mocks.client.listSessions).not.toHaveBeenCalled(); + }); + + async function click(label: string) { + const button = [...container.querySelectorAll('button')].find( + (item) => item.textContent === label, + ); + expect(button).toBeDefined(); + await act(async () => { + button!.click(); + await flush(); + }); + } + + async function input(text: string) { + const textarea = container.querySelector('textarea')!; + await act(async () => { + Object.getOwnPropertyDescriptor( + HTMLTextAreaElement.prototype, + 'value', + )!.set!.call(textarea, text); + textarea.dispatchEvent(new Event('input', { bubbles: true })); + }); + } + + it('opens durable history without creating or restoring a Runtime and subscribes after its watermark', async () => { + await render('s1'); + expect(container.textContent).toContain('Persisted answer'); + expect(mocks.client.subscribeEvents).toHaveBeenCalledWith( + 's1', + expect.objectContaining({ lastEventId: 2 }), + ); + expect(mocks.client.getTranscript).toHaveBeenCalledWith( + 's1', + expect.objectContaining({ limit: 100 }), + ); + expect(mocks.client.createSession).not.toHaveBeenCalled(); + expect(mocks.client.submitPrompt).not.toHaveBeenCalled(); + }); + + it('renders Chinese Managed labels and states without English fallback overriding them', async () => { + await render('s1', 'zh-CN'); + expect(container.querySelector('nav')?.getAttribute('aria-label')).toBe( + '托管会话', + ); + expect( + container.querySelector('textarea')?.getAttribute('aria-label'), + ).toBe('向托管 Agent 发送消息'); + expect(container.textContent).toContain('新建托管任务'); + expect(container.textContent).toContain('执行环境: 已就绪'); + expect(container.textContent).toContain('已完成'); + expect(container.textContent).not.toContain('Completed'); + }); + + it('keeps creation payload, correlation and idempotency key on an uncertain retry', async () => { + mocks.client.createSession.mockRejectedValueOnce( + new TypeError('Network failed'), + ); + await render(); + await input('Do the work'); + await click('Send'); + expect(container.textContent).toContain('request outcome is unconfirmed'); + expect(container.querySelector('textarea')?.disabled).toBe(true); + await click('Retry the same request'); + const [first, retry] = mocks.client.createSession.mock.calls; + expect(first?.[0]).toEqual({ + text: 'Do the work', + workspaceCwd: undefined, + }); + expect(retry?.[0]).toEqual(first?.[0]); + expect(retry?.[1].idempotencyKey).toBe(first?.[1].idempotencyKey); + expect(retry?.[1].clientId).toBe(first?.[1].clientId); + expect(onSelect).toHaveBeenLastCalledWith('created'); + }); + + it('gates sending and cancels the captured Prompt without treating acceptance as terminal', async () => { + mocks.client.getSession.mockResolvedValue( + summary('s1', { + phase: 'tool_running', + capabilities: { canSend: false, canCancel: true }, + }), + ); + await render('s1'); + expect(container.querySelector('textarea')?.disabled).toBe(true); + await click('Cancel turn'); + expect(mocks.client.cancel).toHaveBeenCalledWith( + 's1', + 'p1', + expect.objectContaining({ clientId: expect.any(String) }), + ); + expect(container.textContent).toContain('Executing tool'); + expect(container.textContent).not.toContain('Cancelled'); + }); + + it('shows submission and loading feedback before the first model event', async () => { + let accept!: (value: { sessionId: string; turnId: string }) => void; + mocks.client.createSession.mockImplementationOnce( + () => new Promise((resolve) => (accept = resolve)), + ); + await render(); + await input('Inspect the workspace'); + await click('Send'); + expect( + container.querySelector('[data-managed-progress] span[role="status"]') + ?.textContent, + ).toBe('Submitting…'); + + let load!: (value: ManagedAgentSessionTranscript) => void; + mocks.client.getSession.mockResolvedValue( + summary('s1', { + phase: 'admitted', + capabilities: { canSend: false, canCancel: true }, + }), + ); + mocks.client.getTranscript.mockImplementationOnce( + () => new Promise((resolve) => (load = resolve)), + ); + await act(async () => { + accept({ sessionId: 's1', turnId: 'p1' }); + await flush(); + }); + await render('s1'); + expect( + container.querySelector('[data-managed-progress]')?.textContent, + ).toBe('Loading…'); + await act(async () => { + load({ + events: [ + { + ...event(1, ''), + type: 'accepted', + data: { prompt: [{ type: 'text', text: 'Inspect the workspace' }] }, + }, + { ...event(2, ''), type: 'agent_started' }, + ], + lastEventId: 2, + }); + await flush(); + }); + expect( + container.querySelector('[data-managed-progress]')?.textContent, + ).toContain('Accepted'); + expect( + container.querySelector('[data-managed-progress]')?.textContent, + ).toContain('This turn is running'); + expect(container.querySelector('textarea')?.disabled).toBe(true); + expect(mocks.client.createSession).toHaveBeenCalledTimes(1); + }); + + it.each(['completed', 'failed', 'cancelled'] as const)( + 'keeps elapsed progress during silent intervals and removes it on %s', + async (phase) => { + vi.useFakeTimers(); + vi.setSystemTime(10_000); + mocks.client.getSession.mockResolvedValue( + summary('s1', { + admittedAt: 8000, + phase: 'agent_running', + capabilities: { canSend: false, canCancel: true }, + }), + ); + mocks.client.getTranscript.mockResolvedValue({ + events: [], + lastEventId: 0, + }); + await render('s1', 'zh-CN'); + expect( + container.querySelector('[data-managed-progress]')?.textContent, + ).toContain('已用时 2 秒'); + await act(async () => { + await vi.advanceTimersByTimeAsync(5000); + }); + expect( + container.querySelector('[data-managed-progress]')?.textContent, + ).toContain('已用时 7 秒'); + expect( + container.querySelector('[data-managed-progress]')?.textContent, + ).toContain('本轮执行中'); + mocks.client.getSession.mockResolvedValue( + summary('s1', { + phase, + runtimeState: 'starting', + runtimeReady: false, + }), + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + }); + expect(container.querySelector('[data-managed-progress]')).toBeNull(); + expect(container.querySelector('textarea')?.disabled).toBe(false); + }, + ); + + it('preserves an uncertain attempt across a remount and restores its text after a definitive rejection', async () => { + mocks.client.createSession.mockRejectedValueOnce( + new TypeError('Network failed'), + ); + await render(); + await input('Keep this prompt'); + await click('Send'); + const originalKey = + mocks.client.createSession.mock.calls[0]?.[1].idempotencyKey; + await act(async () => root.unmount()); + root = createRoot(container); + mocks.client.createSession.mockRejectedValueOnce( + new JavaManagedAgentHttpError(400, 'invalid_request', 'Invalid request'), + ); + await render(); + expect(container.querySelector('textarea')?.value).toBe('Keep this prompt'); + await click('Retry the same request'); + expect(mocks.client.createSession.mock.calls[1]?.[1].idempotencyKey).toBe( + originalKey, + ); + expect(container.querySelector('textarea')?.value).toBe('Keep this prompt'); + expect(container.querySelector('textarea')?.disabled).toBe(false); + }); + + it('does not fetch Managed endpoints when the feature is unavailable', async () => { + await render('s1', 'en', null); + expect(container.textContent).toContain('unavailable'); + expect(mocks.client.getSession).not.toHaveBeenCalled(); + expect(mocks.client.listSessions).not.toHaveBeenCalled(); + }); + + it.each(['accepted', 'rejected'] as const)( + 'does not apply an old submission to the new selection when it is %s', + async (outcome) => { + mocks.client.listSessions.mockResolvedValue({ + sessions: [summary('s1'), summary('s2')], + }); + let resolveSubmission!: (value: { + sessionId: string; + turnId: string; + }) => void; + let rejectSubmission!: (error: Error) => void; + mocks.client.submitPrompt.mockImplementationOnce( + () => + new Promise((resolve, reject) => { + resolveSubmission = resolve; + rejectSubmission = reject; + }), + ); + await render('s1'); + await input('Old session prompt'); + await click('Send'); + await click('Task s2Completed'); + await render('s2'); + expect(container.querySelector('[data-managed-progress]')).toBeNull(); + await act(async () => { + if (outcome === 'accepted') + resolveSubmission({ sessionId: 's1', turnId: 'p2' }); + else + rejectSubmission( + new JavaManagedAgentHttpError( + 400, + 'invalid_request', + 'Old session rejection', + ), + ); + await flush(); + }); + expect(onSelect).toHaveBeenCalledExactlyOnceWith('s2'); + expect(container.querySelector('textarea')?.value).toBe(''); + expect(container.textContent).not.toContain('Old session rejection'); + expect(container.textContent).toContain('Task s2'); + }, + ); + + it('updates late Runtime failure after a completed turn through detail polling', async () => { + vi.useFakeTimers(); + mocks.client.getSession.mockResolvedValue( + summary('s1', { runtimeState: 'starting', runtimeReady: false }), + ); + await render('s1'); + expect(container.textContent).toContain('Environment: Preparing'); + mocks.client.getSession.mockResolvedValue( + summary('s1', { + updatedAt: 30, + runtimeState: 'failed', + runtimeReady: false, + }), + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + await flush(); + }); + expect(container.textContent).toContain('Environment: Preparation failed'); + expect(container.textContent).toContain('Completed'); + expect(container.textContent).toContain('Persisted answer'); + }); + + it('prepends older transcript pages without losing current messages', async () => { + mocks.client.getTranscript + .mockResolvedValueOnce({ + events: [event(3, 'Recent')], + olderCursor: '3', + lastEventId: 3, + }) + .mockResolvedValue({ + events: [ + { + ...event(1, ''), + type: 'accepted', + data: { prompt: [{ type: 'text', text: 'Original question' }] }, + }, + event(2, 'Earlier '), + ], + lastEventId: 3, + }); + await render('s1'); + await click('Older history'); + expect(container.textContent).toContain('Original question'); + expect(container.textContent).toContain('Earlier Recent'); + expect(mocks.client.getTranscript).toHaveBeenLastCalledWith( + 's1', + expect.objectContaining({ before: '3', limit: 100 }), + ); + }); + + it('ignores delayed snapshot responses after selection switches', async () => { + let resolveFirst!: (value: ManagedAgentSessionTranscript) => void; + mocks.client.getTranscript.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ); + await render('s1'); + mocks.client.getTranscript.mockResolvedValue({ + events: [{ ...event(1, 'Second answer'), sessionId: 's2' }], + lastEventId: 1, + }); + await render('s2'); + await act(async () => { + resolveFirst({ + events: [event(1, 'Stale first answer')], + lastEventId: 1, + }); + await flush(); + }); + expect(container.textContent).toContain('Second answer'); + expect(container.textContent).not.toContain('Stale first answer'); + }); + + it('retries a transient initial history failure before subscribing without resubmitting a prompt', async () => { + vi.useFakeTimers(); + mocks.client.getTranscript.mockRejectedValueOnce( + new TypeError('Temporary history failure'), + ); + await render('s1'); + expect(container.textContent).toContain('Temporary history failure'); + expect(mocks.client.subscribeEvents).not.toHaveBeenCalled(); + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + await flush(); + }); + expect(container.textContent).toContain('Persisted answer'); + expect(container.textContent).not.toContain('Temporary history failure'); + expect(mocks.client.getTranscript).toHaveBeenCalledTimes(2); + expect(mocks.client.subscribeEvents).toHaveBeenCalledWith( + 's1', + expect.objectContaining({ lastEventId: 2 }), + ); + expect(mocks.client.createSession).not.toHaveBeenCalled(); + expect(mocks.client.submitPrompt).not.toHaveBeenCalled(); + }); + + it('aborts a waiting initial snapshot retry when the selection changes', async () => { + vi.useFakeTimers(); + mocks.client.getTranscript.mockRejectedValueOnce( + new TypeError('Temporary history failure'), + ); + await render('s1'); + await render('s2'); + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + await flush(); + }); + expect(mocks.client.getTranscript.mock.calls.map(([id]) => id)).toEqual([ + 's1', + 's2', + ]); + expect(mocks.client.subscribeEvents).toHaveBeenCalledTimes(1); + expect(mocks.client.subscribeEvents).toHaveBeenCalledWith( + 's2', + expect.objectContaining({ lastEventId: 2 }), + ); + }); + + it('backs off when gap recovery fails instead of repeatedly requesting the same missing range', async () => { + vi.useFakeTimers(); + mocks.client.getTranscript + .mockResolvedValueOnce({ + events: [event(1, 'Before gap')], + lastEventId: 1, + }) + .mockRejectedValueOnce(new TypeError('Recovery temporarily unavailable')) + .mockResolvedValue({ + events: [event(1, 'Restored history')], + lastEventId: 1, + }); + const gapStream = async function* () { + yield { ...event(2, ''), type: 'stream_gap' }; + }; + mocks.client.subscribeEvents + .mockImplementationOnce(gapStream) + .mockImplementationOnce(gapStream); + await render('s1'); + expect(container.textContent).toContain('Recovery temporarily unavailable'); + await act(async () => { + await vi.advanceTimersByTimeAsync(2999); + await flush(); + }); + expect(mocks.client.subscribeEvents).toHaveBeenCalledTimes(1); + expect(mocks.client.getTranscript).toHaveBeenCalledTimes(2); + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + await flush(); + }); + expect(container.textContent).toContain('Restored history'); + expect(mocks.client.getTranscript).toHaveBeenCalledTimes(3); + }); + + it('deduplicates replay and replaces a gapped stream with a durable snapshot', async () => { + vi.useFakeTimers(); + mocks.client.getTranscript + .mockResolvedValueOnce({ events: [event(1, 'First')], lastEventId: 1 }) + .mockResolvedValue({ + events: [event(1, 'First'), event(2, ' second'), event(3, ' restored')], + lastEventId: 3, + }); + mocks.client.subscribeEvents.mockImplementationOnce(async function* () { + yield event(1, 'First'); + yield event(2, ' second'); + yield { ...event(3, ''), type: 'stream_gap' }; + }); + await render('s1'); + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + await flush(); + }); + expect( + container.querySelector('[data-testid="messages"]')?.textContent, + ).toContain('First second restored'); + expect(container.textContent).not.toContain('FirstFirst'); + expect(mocks.client.getTranscript).toHaveBeenCalledTimes(2); + for (const [, options] of mocks.client.getTranscript.mock.calls) { + expect(options.limit).toBe(100); + } + expect(mocks.client.subscribeEvents).toHaveBeenLastCalledWith( + 's1', + expect.objectContaining({ lastEventId: 3 }), + ); + expect(mocks.client.submitPrompt).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/web-shell/client/components/managed/ManagedSessionsPage.tsx b/packages/web-shell/client/components/managed/ManagedSessionsPage.tsx new file mode 100644 index 00000000000..446e8d94040 --- /dev/null +++ b/packages/web-shell/client/components/managed/ManagedSessionsPage.tsx @@ -0,0 +1,517 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useI18n } from '../../i18n'; +import { MessageList } from '../MessageList'; +import { Button } from '../ui/button'; +import { Badge } from '../ui/badge'; +import { Textarea } from '../ui/textarea'; +import { managedEventsToMessages } from './managed-session-messages'; +import { + getManagedClientId, + managedRequestId, +} from './managed-session-storage'; +import { useManagedSession } from './use-managed-session'; +import { ManagedSessionProgress } from './ManagedSessionProgress'; +import type { + ManagedAgentProvider, + ManagedAgentSessionSummary, +} from './managed-agent-provider'; + +interface PendingPrompt { + idempotencyKey: string; + text: string; + sessionId?: string; + cwd?: string; +} + +function readPending(key: string): PendingPrompt | undefined { + try { + const value: unknown = JSON.parse(sessionStorage.getItem(key) ?? 'null'); + if (typeof value !== 'object' || value === null) return undefined; + const pending = value as Record; + if ( + typeof pending['idempotencyKey'] !== 'string' || + typeof pending['text'] !== 'string' + ) + return undefined; + if ( + pending['sessionId'] !== undefined && + typeof pending['sessionId'] !== 'string' + ) + return undefined; + if (pending['cwd'] !== undefined && typeof pending['cwd'] !== 'string') + return undefined; + return value as PendingPrompt; + } catch { + return undefined; + } +} + +function persistPending(key: string, value: PendingPrompt | undefined): void { + try { + if (value) sessionStorage.setItem(key, JSON.stringify(value)); + else sessionStorage.removeItem(key); + } catch { + // The in-memory attempt still preserves retries when storage is disabled. + } +} + +function isNonRetryableClientError(error: unknown): boolean { + if (typeof error !== 'object' || error === null || !('status' in error)) { + return false; + } + const status = (error as { status?: unknown }).status; + return ( + typeof status === 'number' && + status >= 400 && + status < 500 && + status !== 408 && + status !== 429 + ); +} + +export function ManagedSessionsPage({ + sessionId, + onSelectSession, + workspaceCwd, + managedAgentProvider, +}: { + sessionId?: string; + onSelectSession: (sessionId: string | undefined) => void; + workspaceCwd?: string; + managedAgentProvider?: ManagedAgentProvider; +}) { + const { t } = useI18n(); + if (managedAgentProvider) { + return ( + + ); + } + return

{t('managed.unavailable')}

; +} + +function ManagedSessionsContent({ + sessionId, + onSelectSession, + workspaceCwd, + provider, + enabled, + cancellationEnabled, +}: { + sessionId?: string; + onSelectSession: (sessionId: string | undefined) => void; + workspaceCwd?: string; + provider: ManagedAgentProvider; + enabled: boolean; + cancellationEnabled: boolean; +}) { + const { t } = useI18n(); + const clientId = useMemo( + () => getManagedClientId(provider.storageKey), + [provider.storageKey], + ); + const detail = useManagedSession( + provider, + clientId, + enabled ? sessionId : undefined, + ); + const [sessions, setSessions] = useState([]); + const [nextCursor, setNextCursor] = useState(); + const [listLoading, setListLoading] = useState(false); + const [listRevision, setListRevision] = useState(0); + const [error, setError] = useState(); + const [text, setText] = useState(''); + const [busy, setBusy] = useState(false); + const pendingKey = `qwen-managed-pending:${provider.storageKey}:${clientId}`; + const [pending, setPending] = useState(() => + readPending(pendingKey), + ); + const pendingRef = useRef(pending); + const lifetime = useRef(undefined); + const listLifetime = useRef(undefined); + const listBusy = useRef(false); + const selection = useRef(sessionId); + selection.current = sessionId; + const messages = useMemo( + () => managedEventsToMessages(detail.events, t('managed.truncated')), + [detail.events, t], + ); + + useEffect(() => { + const abort = new AbortController(); + lifetime.current = abort; + const saved = readPending(pendingKey); + pendingRef.current = saved; + setPending(saved); + setBusy(false); + return () => abort.abort(); + }, [pendingKey]); + + useEffect(() => { + const abort = new AbortController(); + listLifetime.current = abort; + listBusy.current = false; + setSessions([]); + setNextCursor(undefined); + if (!enabled) return () => abort.abort(); + setListLoading(true); + void provider + .listSessions({ + clientId, + workspaceCwd: provider.acceptsWorkspaceCwd ? workspaceCwd : undefined, + limit: 50, + signal: abort.signal, + }) + .then((page) => { + if (abort.signal.aborted) return; + setSessions(page.sessions); + setNextCursor(page.nextCursor); + }) + .catch((failure: unknown) => { + if (!abort.signal.aborted) + setError( + failure instanceof Error ? failure.message : String(failure), + ); + }) + .finally(() => { + if (!abort.signal.aborted) setListLoading(false); + }); + return () => abort.abort(); + }, [provider, clientId, enabled, workspaceCwd, listRevision]); + + const reloadSession = detail.reload; + const refresh = useCallback(() => { + setError(undefined); + setListRevision((current) => current + 1); + reloadSession(); + }, [reloadSession]); + + async function loadMore() { + const abort = listLifetime.current; + if (!nextCursor || listBusy.current || !abort || abort.signal.aborted) + return; + listBusy.current = true; + setListLoading(true); + try { + const page = await provider.listSessions({ + clientId, + workspaceCwd: provider.acceptsWorkspaceCwd ? workspaceCwd : undefined, + cursor: nextCursor, + limit: 50, + signal: abort.signal, + }); + if (abort.signal.aborted) return; + setSessions((current) => [ + ...new Map( + [...current, ...page.sessions].map((item) => [item.sessionId, item]), + ).values(), + ]); + setNextCursor(page.nextCursor); + } catch (failure) { + if (!abort.signal.aborted) + setError(failure instanceof Error ? failure.message : String(failure)); + } finally { + if (!abort.signal.aborted) { + listBusy.current = false; + setListLoading(false); + } + } + } + + async function submit() { + const abort = lifetime.current; + if (!abort || abort.signal.aborted || busy || !enabled) return; + let attempt = pendingRef.current; + if (!attempt) { + if (!text.trim() || (sessionId && !detail.summary?.capabilities.canSend)) + return; + attempt = { + idempotencyKey: managedRequestId(), + text, + sessionId, + cwd: workspaceCwd, + }; + pendingRef.current = attempt; + setPending(attempt); + persistPending(pendingKey, attempt); + } + setBusy(true); + setError(undefined); + const selectedAtSubmit = selection.current; + try { + const opts = { + clientId, + idempotencyKey: attempt.idempotencyKey, + signal: abort.signal, + }; + const admission = attempt.sessionId + ? await provider.submitPrompt( + attempt.sessionId, + { text: attempt.text }, + opts, + ) + : await provider.createSession( + { + text: attempt.text, + workspaceCwd: provider.acceptsWorkspaceCwd + ? attempt.cwd + : undefined, + }, + opts, + ); + if (abort.signal.aborted) return; + pendingRef.current = undefined; + setPending(undefined); + persistPending(pendingKey, undefined); + setListRevision((current) => current + 1); + if (selection.current === selectedAtSubmit) { + setText(''); + onSelectSession(admission.sessionId); + detail.reload(); + } + } catch (failure) { + if (abort.signal.aborted) return; + if (isNonRetryableClientError(failure)) { + pendingRef.current = undefined; + setPending(undefined); + persistPending(pendingKey, undefined); + if (selection.current === selectedAtSubmit) setText(attempt.text); + } + if (selection.current === selectedAtSubmit) + setError(failure instanceof Error ? failure.message : String(failure)); + } finally { + if (!abort.signal.aborted) setBusy(false); + } + } + + async function cancel() { + const summary = detail.summary; + const abort = lifetime.current; + if ( + !summary?.capabilities.canCancel || + !summary.activeTurnId || + !cancellationEnabled || + busy || + !abort || + abort.signal.aborted + ) + return; + setBusy(true); + setError(undefined); + try { + await provider.cancel(summary.sessionId, summary.activeTurnId, { + clientId, + idempotencyKey: managedRequestId(), + signal: abort.signal, + }); + if (!abort.signal.aborted && selection.current === summary.sessionId) + detail.reload(); + } catch (failure) { + if (!abort.signal.aborted && selection.current === summary.sessionId) + setError(failure instanceof Error ? failure.message : String(failure)); + } finally { + if (!abort.signal.aborted) setBusy(false); + } + } + + if (!enabled) return

{t('managed.unavailable')}

; + const summary = detail.summary; + const active = + summary && !['completed', 'failed', 'cancelled'].includes(summary.phase); + return ( +
+
+ + +
+ {(error || detail.error) && ( +

+ {error || detail.error} +

+ )} +
+ +
+ {summary && ( +
+ {summary.title} + + {t(`managed.phase.${summary.phase}`)} + + + {t('managed.runtime')}:{' '} + {t(`managed.runtime.${summary.runtimeState}`)} + +
+ )} + {summary?.failure && ( +

+ {summary.failure.message} +

+ )} +
+ + {t('managed.prompt')} +

+ } + /> +
+ {pending && !busy && ( +

+ {t('managed.uncertain')} +

+ )} + +
{ + event.preventDefault(); + void submit(); + }} + > +