From 2636134cff38ba5ba1bba85fa60a7fd2cc16da42 Mon Sep 17 00:00:00 2001 From: Qiyuanqiii <267806965+Qiyuanqiii@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:52:09 +0800 Subject: [PATCH 1/3] feat(workbench): connect application services to preview --- docs/PROJECT_MEMORY.md | 6 + ...0166-thread-application-service-preview.md | 102 ++++ docs/architecture.md | 6 + docs/convergence/protocol-registry.md | 5 +- docs/openapi.json | 570 ++++++++++++++++++ docs/usage.md | 15 + .../command_runtime_multiplexer.go | 1 + .../thread_application_services.go | 509 ++++++++++++++++ .../thread_application_services_test.go | 123 ++++ internal/httpapi/openapi.go | 18 + internal/httpapi/server.go | 17 + .../httpapi/thread_application_services.go | 161 +++++ ..._application_services_live_runtime_test.go | 535 ++++++++++++++++ .../thread_application_services_test.go | 110 ++++ internal/runner/command_runtime_service.go | 125 ++++ .../runner/command_runtime_service_test.go | 99 +++ internal/store/thread_application_services.go | 129 ++++ protocols/registry.json | 1 + web/src/api/application-services.test.ts | 117 ++++ web/src/api/application-services.ts | 78 +++ web/src/api/client.ts | 29 + web/src/api/schema.d.ts | 251 ++++++++ web/src/api/types.ts | 4 + web/src/v2/application-preview-request.ts | 45 ++ web/src/v2/components/application-preview.css | 14 + .../components/application-preview.test.tsx | 219 ++++++- web/src/v2/components/application-preview.tsx | 207 ++++++- .../conversation-inspector-feedback.test.tsx | 2 +- .../components/conversation-preview.test.tsx | 115 ++++ web/src/v2/components/conversation.tsx | 62 +- 30 files changed, 3636 insertions(+), 39 deletions(-) create mode 100644 docs/adr/0166-thread-application-service-preview.md create mode 100644 internal/application/thread_application_services.go create mode 100644 internal/application/thread_application_services_test.go create mode 100644 internal/httpapi/thread_application_services.go create mode 100644 internal/httpapi/thread_application_services_live_runtime_test.go create mode 100644 internal/httpapi/thread_application_services_test.go create mode 100644 internal/runner/command_runtime_service.go create mode 100644 internal/runner/command_runtime_service_test.go create mode 100644 internal/store/thread_application_services.go create mode 100644 web/src/api/application-services.test.ts create mode 100644 web/src/api/application-services.ts create mode 100644 web/src/v2/application-preview-request.ts create mode 100644 web/src/v2/components/conversation-preview.test.tsx diff --git a/docs/PROJECT_MEMORY.md b/docs/PROJECT_MEMORY.md index d487743f..ff82f210 100644 --- a/docs/PROJECT_MEMORY.md +++ b/docs/PROJECT_MEMORY.md @@ -1,5 +1,11 @@ # Prayu Project Memory +> 2026-10-06 application preview checkpoint (#274): ordinary Thread submissions remain +> the startup path. A Thread-scoped projection connects existing Command Runtime Jobs, +> exact source messages, bounded sanitized output and unverified loopback candidates. +> Browser close and exact process-owned Job cleanup remain separate; no new launch +> endpoint or process manager is introduced. See [ADR 0166](adr/0166-thread-application-service-preview.md). + > 2026-10-06 frontend checkpoint (#260): latest Thread transcript synchronization is > separate from manual history paging; stable narrative rows are reused, and settings, > Inspector and review modules load on first use. Source identities, Go-owned state, diff --git a/docs/adr/0166-thread-application-service-preview.md b/docs/adr/0166-thread-application-service-preview.md new file mode 100644 index 00000000..4158d3fa --- /dev/null +++ b/docs/adr/0166-thread-application-service-preview.md @@ -0,0 +1,102 @@ +# ADR 0166: Thread-owned application services and browser preview + +Status: accepted for issue #274 implementation. + +## Context + +The conversation can draft a request to start a project and can open a managed +Full CDP browser, but those two actions previously had no shared presentation of +the resulting Command Runtime Job, its output or its candidate application URLs. +A browser session being ready does not prove that a development service started, +and closing that browser must not stop a different process or the entire task. + +## Decision + +Keep application startup in the ordinary Thread turn. The operator reviews and +sends the drafted request; the Supervisor uses the existing `command-runtime.v2` +start action, with its normal permission, approval, policy, workspace and runtime +ownership checks. This feature adds no HTTP command-launch endpoint, shell input, +process manager, database migration or new way to activate execution permission. + +Add `thread_application_services.v1` as an additive HTTP/OpenAPI projection: + +- `GET /api/v1/threads/{thread_id}/application-services` lists bounded Job metadata + across that Thread's original and successor Runs. It reports truncation and does + not load every Job's complete stdout/stderr. +- `GET /api/v1/threads/{thread_id}/application-services/{job_id}` reads one exact + Job, its bounded sanitized output and candidate local URLs. Every response + retains the source Thread, Run and Job, rather than assigning the current Run + to an older process. +- `POST /api/v1/threads/{thread_id}/application-services/{job_id}/stop` accepts the + expected Run and the stable `application-stop-{job_id}` idempotency header. It + requires control authentication and the existing Run execution control gate. + It performs cleanup of that exact process-owned Job only. + +Public views contain Job state, timestamps, optional exit code and verified +source-call/message identities. They exclude native PIDs, process groups, host +paths, command intent/environment, permission snapshots, leases, owner tokens, +operation digests and launch authority. The original invocation is matched to +its durable Supervisor start call by the existing Job operation identity, then +to the operator message through the existing delivery ledger. Missing historical +provenance stays absent; creation time or a matching URL is not a substitute. + +The frontend keeps the draft/submission hint separately from server facts. It +distinguishes a drafted request, pending or unknown submission, accepted input +and a sealed execution failure. A Job is associated with that input only when +both its Run and source message match. Other Jobs remain individually selectable. +No Job is an explicit absence of evidence, not proof of startup or failure. + +## Output and preview + +Reuse the current Command Runtime output sanitization and closed-stdin read +boundary. Output retains separate stdout/stderr text, page byte cursors and +truncation information; coalesced streams do not claim interleaved frame ordering. +Interactive output that could echo private stdin is not exposed. +Candidate URLs are bounded HTTP(S) loopback addresses extracted from that output; +they contain no userinfo, query or fragment and are always marked unverified. +Discovery performs no network probe and does not grant network or browser access. +The operator explicitly selects one of multiple candidates before opening it. + +Reuse the existing Full CDP session and preview APIs. The selected service's Run +owns its browser preview; changing the selection does not silently transfer that +browser to the latest Run. The existing live Full activation, browser permission +revision and loopback navigation checks still apply. Exited or failed services do +not enable candidate-driven preview. Manual address entry remains an explicit +action under the currently selected browser Run's existing authority. + +Show service state and browser state separately. Hiding the preview panel is a +presentation action; closing the browser cleans only that session; stopping the +service addresses only its exact Job. A ready browser or text claiming a URL +does not relabel a failed, stopped, interrupted or unobserved service as ready. + +## Cleanup, replay and recovery + +Service stop is a cleanup-only adapter to the existing manager's cancellation +and process-tree ownership. It cannot launch, resume or adopt a Job, write stdin, +cancel its Run or signal a PID read from persistence. The Thread/Run binding and +full immutable Job/owner identity are checked before reaching the native manager. +Revocation or a terminal Run must not create new authority, but must not prevent +cleanup of a process still owned by this host. A cold manager cannot adopt it. + +The stable cleanup key is derived from the immutable Job identity, so uncertain +responses are retried against the same resource. Terminal receipts are readable +and replayable without a new effect. Existing stopping state remains observable; +an unconfirmed or failed stop is not displayed as completed cleanup. Existing +Command Runtime shutdown, heartbeat expiry and crash recovery remain authoritative. + +## Alternatives and verification + +A direct operator-start HTTP endpoint would broaden the execution surface and +duplicate the conversation's project discovery and approval path. Inferring +services from arbitrary transcript URLs would lose process ownership and confuse +failed output with a running server. Both approaches are rejected. + +Verification covers source-message association, cross-Thread and wrong-Run +refusals, cold owners, repeated stop, output bounds/redaction, multiple URL choices, +and independent browser/service actions. Real loopback service fixtures use the +existing authorized Command Runtime, temporary workspaces and owned process +cleanup; browser UI contracts are tested separately from native browser execution. + +Rollback removes the new presentation and HTTP adapters while retaining Job, +output and delivery history and the established Command Runtime cleanup paths. +No durable state is rewritten or downgraded. diff --git a/docs/architecture.md b/docs/architecture.md index 7eb92b64..055e01d2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,6 +2,12 @@ Universal Code is a run-centric, resumable Agent workbench. Go owns execution, policy and persistence; Desktop, Web and CLI use the same application services. +Application preview joins the existing Thread message and Command Runtime Job ledgers +through `thread_application_services.v1`. Its bounded read and exact Job cleanup adapters +retain source Run ownership and never launch a process or infer readiness from a URL. +Managed browser sessions keep their separate lifecycle and authority. See +[ADR 0166](adr/0166-thread-application-service-preview.md). + > **Current scope:** the active product is the general-purpose Agent Harness and Code workflow. CTF-specific solving and offensive automation are optional add-ons with no active implementation schedule. Only generic Provider, Tool, Skill, Analyzer, Sandbox, and Report extension seams remain in the core. User/control entries, backends, integrations, and extensions have separate support tiers; see [Product Scope](PRODUCT_SCOPE.md) and [ADR 0135](adr/0135-pre-1-0-product-convergence.md). ## Design Goals diff --git a/docs/convergence/protocol-registry.md b/docs/convergence/protocol-registry.md index fb0f4152..e15a203b 100644 --- a/docs/convergence/protocol-registry.md +++ b/docs/convergence/protocol-registry.md @@ -23,7 +23,7 @@ This document is generated from [`protocols/registry.json`](../../protocols/regi | [docker-attach-process-session](#docker-attach-process-session) | `ephemeral` | Docker runtime transport maintainers | 1 | false | | [exported-evidence-and-handoff](#exported-evidence-and-handoff) | `external-durable` | Evidence, verification, report, and handoff maintainers | 35 | true | | [extension-package-contracts](#extension-package-contracts) | `external-durable` | Skill, Plugin, Hook, and extension maintainers | 47 | true | -| [http-openapi-contract](#http-openapi-contract) | `external-durable` | HTTP/OpenAPI and generated-client maintainers | 112 | true | +| [http-openapi-contract](#http-openapi-contract) | `external-durable` | HTTP/OpenAPI and generated-client maintainers | 113 | true | | [in-memory-token-session](#in-memory-token-session) | `ephemeral` | Credential and bootstrap maintainers | 1 | false | | [lsp-process-session](#lsp-process-session) | `ephemeral` | Code intelligence maintainers | 1 | false | | [mcp-interchange](#mcp-interchange) | `external-durable` | MCP client/server maintainers | 6 | true | @@ -873,7 +873,7 @@ This document is generated from [`protocols/registry.json`](../../protocols/regi - Readers: - `http-openapi-contract-reader` (`v0, v1, v2`, active) at `web/src/api` -
112 active identifiers +
113 active identifiers - `agent-code-tools.v1` - `agent_browser_close.v1` @@ -968,6 +968,7 @@ This document is generated from [`protocols/registry.json`](../../protocols/regi - `thread_activity_artifact.v1` - `thread_activity_detail.v2` - `thread_activity_summary.v1` +- `thread_application_services.v1` - `thread_creation.v1` - `thread_execution.v1` - `thread_export.v1` diff --git a/docs/openapi.json b/docs/openapi.json index b7b7a4e1..1bfa347f 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -21250,6 +21250,329 @@ "x-cyberagent-read-only": true } }, + "/api/v1/threads/{thread_id}/application-services": { + "get": { + "operationId": "listThreadApplicationServices", + "summary": "List task command service candidates", + "description": "Bounded metadata from Jobs in every Run belonging to this Thread. Source message identity is present only when an exact background start and operator delivery are proven. Does not read command output, start work, probe URLs, or assert readiness.", + "tags": [ + "Threads" + ], + "parameters": [ + { + "name": "thread_id", + "in": "path", + "description": "Thread identity", + "required": true, + "schema": { + "maxLength": 256, + "minLength": 1, + "pattern": "^[^/\\\\\\x00-\\x1f\\x7f]+$", + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "description": "", + "schema": { + "default": 20, + "maximum": 50, + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Successful read", + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/components/schemas/ThreadApplicationServicesView" + }, + "request_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "version": { + "const": "api.v1", + "type": "string" + } + }, + "required": [ + "version", + "request_id", + "data" + ], + "type": "object" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "414": { + "$ref": "#/components/responses/RequestTooLarge" + }, + "429": { + "$ref": "#/components/responses/ResourceExhausted" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/Unavailable" + }, + "504": { + "$ref": "#/components/responses/GatewayTimeout" + } + }, + "x-cyberagent-read-only": true + } + }, + "/api/v1/threads/{thread_id}/application-services/{job_id}": { + "get": { + "operationId": "getThreadApplicationService", + "summary": "Read one exact task command service", + "description": "Reads an exact Thread-owned Run/Job and a bounded sanitized output tail. Loopback URLs are unverified command-output candidates; no network or readiness check is performed.", + "tags": [ + "Threads" + ], + "parameters": [ + { + "name": "thread_id", + "in": "path", + "description": "Thread identity", + "required": true, + "schema": { + "maxLength": 256, + "minLength": 1, + "pattern": "^[^/\\\\\\x00-\\x1f\\x7f]+$", + "type": "string" + } + }, + { + "name": "job_id", + "in": "path", + "description": "Exact command Job identity", + "required": true, + "schema": { + "maxLength": 256, + "minLength": 1, + "pattern": "^[^/\\\\\\x00-\\x1f\\x7f]+$", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful read", + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/components/schemas/ThreadApplicationServiceDetailView" + }, + "request_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "version": { + "const": "api.v1", + "type": "string" + } + }, + "required": [ + "version", + "request_id", + "data" + ], + "type": "object" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "414": { + "$ref": "#/components/responses/RequestTooLarge" + }, + "429": { + "$ref": "#/components/responses/ResourceExhausted" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/Unavailable" + }, + "504": { + "$ref": "#/components/responses/GatewayTimeout" + } + }, + "x-cyberagent-read-only": true + } + }, + "/api/v1/threads/{thread_id}/application-services/{job_id}/stop": { + "post": { + "operationId": "stopThreadApplicationService", + "summary": "Stop one exact owned task command service", + "description": "Cleanup only under Run execution control. Requires the expected original Run and the fixed application-stop-{job_id} Idempotency-Key. Only this process's exact Job owner may cancel; repeated stopping observes its fact without upgrading to Kill. Reaped terminal Jobs replay and cold active Jobs fail closed. No persisted PID is signalled.", + "tags": [ + "Control" + ], + "parameters": [ + { + "name": "thread_id", + "in": "path", + "description": "Thread identity", + "required": true, + "schema": { + "maxLength": 256, + "minLength": 1, + "pattern": "^[^/\\\\\\x00-\\x1f\\x7f]+$", + "type": "string" + } + }, + { + "name": "job_id", + "in": "path", + "description": "Exact command Job identity", + "required": true, + "schema": { + "maxLength": 256, + "minLength": 1, + "pattern": "^[^/\\\\\\x00-\\x1f\\x7f]+$", + "type": "string" + } + }, + { + "name": "Idempotency-Key", + "in": "header", + "description": "", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Control request accepted or idempotently replayed", + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/components/schemas/ThreadApplicationServiceStopView" + }, + "request_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "version": { + "const": "api.v1", + "type": "string" + } + }, + "required": [ + "version", + "request_id", + "data" + ], + "type": "object" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "412": { + "$ref": "#/components/responses/FailedPrecondition" + }, + "413": { + "$ref": "#/components/responses/RequestEntityTooLarge" + }, + "414": { + "$ref": "#/components/responses/RequestTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/ResourceExhausted" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/Unavailable" + }, + "504": { + "$ref": "#/components/responses/GatewayTimeout" + } + }, + "security": [ + { + "ControlBearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ThreadApplicationServiceStopRequestView" + } + } + } + }, + "x-cyberagent-read-only": false + } + }, "/api/v1/threads/{thread_id}/archive": { "post": { "operationId": "archiveThread", @@ -53641,6 +53964,253 @@ ], "type": "object" }, + "ThreadApplicationServiceDetailView": { + "additionalProperties": false, + "properties": { + "candidate_urls": { + "items": { + "$ref": "#/components/schemas/ThreadApplicationServiceURLView" + }, + "type": "array" + }, + "output": { + "$ref": "#/components/schemas/ThreadApplicationServiceOutputView" + }, + "service": { + "$ref": "#/components/schemas/ThreadApplicationServiceView" + }, + "version": { + "enum": [ + "thread_application_services.v1" + ], + "type": "string" + } + }, + "required": [ + "version", + "service", + "output", + "candidate_urls" + ], + "type": "object" + }, + "ThreadApplicationServiceOutputView": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "base_cursor": { + "format": "int64", + "type": "integer" + }, + "dropped": { + "type": "boolean" + }, + "end_cursor": { + "format": "int64", + "type": "integer" + }, + "next_cursor": { + "format": "int64", + "type": "integer" + }, + "stderr": { + "type": "string" + }, + "stdout": { + "type": "string" + }, + "truncation_reason": { + "type": "string" + } + }, + "required": [ + "stdout", + "stderr", + "base_cursor", + "next_cursor", + "end_cursor", + "dropped", + "available" + ], + "type": "object" + }, + "ThreadApplicationServiceStopRequestView": { + "additionalProperties": false, + "properties": { + "expected_run_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "version": { + "enum": [ + "thread_application_services.v1" + ], + "type": "string" + } + }, + "required": [ + "version", + "expected_run_id" + ], + "type": "object" + }, + "ThreadApplicationServiceStopView": { + "additionalProperties": false, + "properties": { + "replayed": { + "type": "boolean" + }, + "service": { + "$ref": "#/components/schemas/ThreadApplicationServiceView" + }, + "version": { + "enum": [ + "thread_application_services.v1" + ], + "type": "string" + } + }, + "required": [ + "version", + "service", + "replayed" + ], + "type": "object" + }, + "ThreadApplicationServiceURLView": { + "additionalProperties": false, + "properties": { + "source": { + "enum": [ + "command_output" + ], + "type": "string" + }, + "url": { + "type": "string" + }, + "verified": { + "type": "boolean" + } + }, + "required": [ + "url", + "source", + "verified" + ], + "type": "object" + }, + "ThreadApplicationServiceView": { + "additionalProperties": false, + "properties": { + "can_stop": { + "type": "boolean" + }, + "completed_at": { + "format": "date-time", + "type": "string" + }, + "created_at": { + "format": "date-time", + "type": "string" + }, + "exit_code": { + "format": "int32", + "type": "integer" + }, + "job_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "run_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "source_call_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "source_message_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "source_turn": { + "format": "int32", + "type": "integer" + }, + "started_at": { + "format": "date-time", + "type": "string" + }, + "state": { + "enum": [ + "prepared", + "running", + "stopping", + "completed", + "failed", + "timed_out", + "cancelled", + "killed", + "interrupted" + ], + "type": "string" + }, + "thread_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "thread_id", + "run_id", + "job_id", + "state", + "created_at", + "can_stop" + ], + "type": "object" + }, + "ThreadApplicationServicesView": { + "additionalProperties": false, + "properties": { + "has_more": { + "type": "boolean" + }, + "services": { + "items": { + "$ref": "#/components/schemas/ThreadApplicationServiceView" + }, + "type": "array" + }, + "thread_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "version": { + "enum": [ + "thread_application_services.v1" + ], + "type": "string" + } + }, + "required": [ + "version", + "thread_id", + "services", + "has_more" + ], + "type": "object" + }, "ThreadCreationControlRequestView": { "additionalProperties": false, "properties": { diff --git a/docs/usage.md b/docs/usage.md index bacaa3cd..21cb0714 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -66,6 +66,21 @@ briefly show a loading message. See the [frontend performance evidence](FRONTEND 或切换任务时保留阅读位置。设置、Inspector 与改动审阅在打开时加载,首次进入可能 短暂显示加载提示。请求量、首屏资源和字体评估见[前端性能验证记录](FRONTEND_PERFORMANCE_260.md)。 +Application preview lists the current task's managed command Jobs with their original +Run and output. “把启动要求放回草稿” preserves the draft for review and sending; it +does not launch a command. After submission, select the associated Job, inspect its +stdout/stderr and explicitly choose a candidate local URL. Browser preview still +requires the existing live Full and browser-control permissions. Closing its browser +and stopping the selected service are separate actions; hiding the panel performs +neither. Output URLs are unverified candidates, and exited services stay visibly exited. +See [ADR 0166](adr/0166-thread-application-service-preview.md). + +“应用预览”列出当前任务所属命令的原始 Run、Job 与输出。“把启动要求放回草稿”会保留 +原稿,确认发送后才进入普通任务执行。提交后选择对应 Job,查看 stdout/stderr,并明确 +选择一个候选本机地址。浏览器仍需要现有的 Full 激活与浏览器控制权限。“关闭浏览器” +和“停止此命令”分别作用于该浏览器会话和选中的 Job;收起面板不执行这两项操作。 +输出地址不等于服务已就绪,失败或退出的命令会保留其真实状态及输出。 + ```powershell cyberagent run create "review this workspace" --workspace demo --profile review --surface code --phase plan cyberagent run create "explain this code" --profile learn --max-turns 40 --max-tokens 20000 --timeout 20m diff --git a/internal/application/command_runtime_multiplexer.go b/internal/application/command_runtime_multiplexer.go index 942fe633..ef7f1486 100644 --- a/internal/application/command_runtime_multiplexer.go +++ b/internal/application/command_runtime_multiplexer.go @@ -18,6 +18,7 @@ import ( // lets a caller choose an adapter; selection remains a consequence of current // Run profile, permission, and installed readiness. type CommandRuntimeRuntime interface { + ThreadApplicationServiceCommandRuntime toolgateway.CommandRuntimeExecutor toolgateway.CommandRuntimeAdvertiser InstalledCommandRuntimeAdapters() []commandruntimeadapter.Identity diff --git a/internal/application/thread_application_services.go b/internal/application/thread_application_services.go new file mode 100644 index 00000000..ed6f5227 --- /dev/null +++ b/internal/application/thread_application_services.go @@ -0,0 +1,509 @@ +package application + +import ( + "context" + "errors" + "net/url" + "regexp" + "strconv" + "strings" + "time" + + "cyberagent-workbench/internal/apperror" + "cyberagent-workbench/internal/domain" + "cyberagent-workbench/internal/redact" + "cyberagent-workbench/internal/runner" + "cyberagent-workbench/internal/session" +) + +const ( + ThreadApplicationServicesProtocolVersion = "thread_application_services.v1" + DefaultThreadApplicationServicesLimit = 20 + MaxThreadApplicationServicesLimit = 50 + MaxThreadApplicationServiceURLs = 8 +) + +type ThreadApplicationServiceStore interface { + GetThread(context.Context, string) (domain.Thread, error) + ListThreadCommandRuntimeServiceMetadata(context.Context, string, int) ([]runner.CommandRuntimeServiceMetadata, error) + GetThreadCommandRuntimeJob(context.Context, string, string) (runner.CommandRuntimeJob, error) + FindThreadCommandRuntimeStartCall(context.Context, string, runner.CommandRuntimeJobIdentity) (domain.SupervisorToolCall, bool, error) + SupervisorOperatorMessageID(context.Context, string, string, int) (string, error) + GetWorkspaceInfo(context.Context, string) (session.WorkspaceInfo, error) +} + +// This bridge can only observe already bound Job metadata and reap its existing +// owner. It has no start, stdin, permission, browser or network operation. +type ThreadApplicationServiceCommandRuntime interface { + ThreadActivityCommandRuntimeSource + ReadOwnedCommandRuntimeServicePrefix(context.Context, runner.CommandRuntimeJobIdentity, int) (string, string, bool, error) + ReadOwnedCommandRuntimeServiceState(context.Context, runner.CommandRuntimeJobIdentity) (runner.CommandRuntimeJobSnapshot, bool, error) + StopThreadApplicationServiceJob(context.Context, runner.CommandRuntimeJob) (runner.CommandRuntimeJobSnapshot, bool, error) +} + +type ThreadApplicationServiceView struct { + ThreadID string `json:"thread_id"` + RunID string `json:"run_id"` + JobID string `json:"job_id"` + State string `json:"state"` + ExitCode *int `json:"exit_code,omitempty"` + CreatedAt time.Time `json:"created_at"` + StartedAt *time.Time `json:"started_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + SourceCallID string `json:"source_call_id,omitempty"` + SourceMessageID string `json:"source_message_id,omitempty"` + SourceTurn int `json:"source_turn,omitempty"` + CanStop bool `json:"can_stop"` +} + +type ThreadApplicationServicesView struct { + Version string `json:"version"` + ThreadID string `json:"thread_id"` + Services []ThreadApplicationServiceView `json:"services"` + HasMore bool `json:"has_more"` +} + +type ThreadApplicationServiceOutputView struct { + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + BaseCursor uint64 `json:"base_cursor"` + NextCursor uint64 `json:"next_cursor"` + EndCursor uint64 `json:"end_cursor"` + Dropped bool `json:"dropped"` + TruncationReason string `json:"truncation_reason,omitempty"` + Available bool `json:"available"` +} + +type ThreadApplicationServiceURLView struct { + URL string `json:"url"` + Source string `json:"source"` + Verified bool `json:"verified"` +} + +type ThreadApplicationServiceDetailView struct { + Version string `json:"version"` + Service ThreadApplicationServiceView `json:"service"` + Output ThreadApplicationServiceOutputView `json:"output"` + CandidateURLs []ThreadApplicationServiceURLView `json:"candidate_urls"` +} + +type ThreadApplicationServiceStopRequest struct { + Version, ExpectedRunID, OperationKey string +} + +type ThreadApplicationServiceStopView struct { + Version string `json:"version"` + Service ThreadApplicationServiceView `json:"service"` + Replayed bool `json:"replayed"` +} + +type ThreadApplicationService struct { + store ThreadApplicationServiceStore + commands ThreadApplicationServiceCommandRuntime +} + +func NewThreadApplicationService(store ThreadApplicationServiceStore) *ThreadApplicationService { + return &ThreadApplicationService{store: store} +} + +func (s *ThreadApplicationService) WithCommandRuntime(runtime ThreadApplicationServiceCommandRuntime) *ThreadApplicationService { + if s != nil { + s.commands = runtime + } + return s +} + +func (s *ThreadApplicationService) checkThread(ctx context.Context, threadID string) error { + if s == nil || s.store == nil || ctx == nil || ctx.Err() != nil { + return apperror.New(apperror.CodeFailedPrecondition, "Thread application services are unavailable") + } + if !domain.ValidAgentID(threadID) { + return apperror.New(apperror.CodeInvalidArgument, "Thread service identity is invalid") + } + _, err := s.store.GetThread(ctx, threadID) + return apperror.Normalize(err) +} + +func (s *ThreadApplicationService) List(ctx context.Context, threadID string, limit int) (ThreadApplicationServicesView, error) { + value := ThreadApplicationServicesView{Version: ThreadApplicationServicesProtocolVersion, ThreadID: threadID, Services: []ThreadApplicationServiceView{}} + if err := s.checkThread(ctx, threadID); err != nil { + return value, err + } + if limit == 0 { + limit = DefaultThreadApplicationServicesLimit + } + if limit < 1 || limit > MaxThreadApplicationServicesLimit { + return value, apperror.New(apperror.CodeInvalidArgument, "Thread service limit is invalid") + } + jobs, err := s.store.ListThreadCommandRuntimeServiceMetadata(ctx, threadID, limit+1) + if err != nil { + return value, apperror.Normalize(err) + } + value.HasMore = len(jobs) > limit + if value.HasMore { + jobs = jobs[:limit] + } + for _, job := range jobs { + view, err := s.project(ctx, threadID, job) + if err != nil { + return value, err + } + value.Services = append(value.Services, view) + } + return value, nil +} + +func (s *ThreadApplicationService) project(ctx context.Context, threadID string, job runner.CommandRuntimeServiceMetadata) (ThreadApplicationServiceView, error) { + id := job.Identity + view := ThreadApplicationServiceView{ThreadID: threadID, RunID: id.RunID, JobID: id.ID, State: string(job.State), + ExitCode: cloneActivityInt(job.ExitCode), CreatedAt: job.CreatedAt, StartedAt: cloneActivityTime(job.StartedAt), CompletedAt: cloneActivityTime(job.CompletedAt)} + if s.commands != nil { + snapshot, owned, err := s.commands.ReadOwnedCommandRuntimeServiceState(ctx, id) + if err != nil { + return view, apperror.Wrap(apperror.CodeFailedPrecondition, "Thread service owner could not be observed", err) + } + if owned { + view.State, view.ExitCode, view.StartedAt, view.CompletedAt = string(snapshot.State), cloneActivityInt(snapshot.ExitCode), cloneActivityTime(snapshot.StartedAt), cloneActivityTime(snapshot.CompletedAt) + view.CanStop = !snapshot.State.Terminal() && snapshot.State != runner.CommandRuntimeJobStopping + } + } + call, found, err := s.store.FindThreadCommandRuntimeStartCall(ctx, threadID, id) + if err != nil { + return view, apperror.Normalize(err) + } + if found { + view.SourceCallID, view.SourceTurn = call.CallID, call.Turn + view.SourceMessageID, err = s.store.SupervisorOperatorMessageID(ctx, call.RunID, call.AttemptID, call.Turn) + if err != nil { + return view, apperror.Normalize(err) + } + } + return view, nil +} + +func serviceMetadata(job runner.CommandRuntimeJob) runner.CommandRuntimeServiceMetadata { + return runner.CommandRuntimeServiceMetadata{Identity: runner.CommandRuntimeIdentity(job), State: job.State, ExitCode: job.ExitCode, CreatedAt: job.CreatedAt, StartedAt: job.StartedAt, CompletedAt: job.CompletedAt} +} + +func (s *ThreadApplicationService) Get(ctx context.Context, threadID, jobID string) (ThreadApplicationServiceDetailView, error) { + value := ThreadApplicationServiceDetailView{Version: ThreadApplicationServicesProtocolVersion, + CandidateURLs: []ThreadApplicationServiceURLView{}} + if err := s.checkThread(ctx, threadID); err != nil { + return value, err + } + if !domain.ValidAgentID(jobID) { + return value, apperror.New(apperror.CodeInvalidArgument, "Thread service Job identity is invalid") + } + job, err := s.store.GetThreadCommandRuntimeJob(ctx, threadID, jobID) + if err != nil { + return value, apperror.Normalize(err) + } + if job.Validate() != nil { + return value, apperror.New(apperror.CodeFailedPrecondition, "Thread service Job is invalid") + } + value.Service, err = s.project(ctx, threadID, serviceMetadata(job)) + if err != nil { + return value, err + } + // Interactive stdin is not eligible for public output: a later private + // input may be echoed, and its plaintext is deliberately not persisted. + if job.StdinPolicy != runner.CommandRuntimeStdinClosed || job.StdinWriteCount > 0 { + return value, nil + } + var page runner.CommandRuntimeOutputPage + var snapshot runner.CommandRuntimeJobSnapshot + found := false + if s.commands != nil { + snapshot, page, found, err = s.commands.ReadCommandRuntimeActivityTail(ctx, job, runner.MaxCommandRuntimeOutputRead) + if err != nil { + return value, apperror.Wrap(apperror.CodeFailedPrecondition, "Thread service output could not be observed", err) + } + } + if !found { + snapshot, page, found, err = runner.ProjectCommandRuntimeStoredActivityTail(job, runner.MaxCommandRuntimeOutputRead) + if err != nil { + return value, apperror.Wrap(apperror.CodeFailedPrecondition, "Thread service saved output is invalid", err) + } + } + if !found { + return value, nil + } + if snapshot.ID != job.ID || page.JobID != job.ID { + return value, apperror.New(apperror.CodeFailedPrecondition, "Thread service output binding is invalid") + } + value.Service.State, value.Service.ExitCode = string(snapshot.State), cloneActivityInt(snapshot.ExitCode) + if snapshot.State.Terminal() || snapshot.State == runner.CommandRuntimeJobStopping { + value.Service.CanStop = false + } + workspace, err := s.store.GetWorkspaceInfo(ctx, job.WorkspaceID) + if err != nil { + return value, apperror.Normalize(err) + } + value.Output = projectThreadServiceOutput(page, workspace.RootPath, activityCommandSecrets(safeThreadCommandSpecFromJob(job))) + secrets := activityCommandSecrets(safeThreadCommandSpecFromJob(job)) + prefixStdout, prefixStderr := job.Stdout, job.Stderr + if s.commands != nil { + stdout, stderr, owned, prefixErr := s.commands.ReadOwnedCommandRuntimeServicePrefix(ctx, runner.CommandRuntimeIdentity(job), runner.MaxCommandRuntimeOutputRead/2) + if prefixErr != nil { + return value, apperror.Wrap(apperror.CodeFailedPrecondition, "Thread service saved prefix could not be observed", prefixErr) + } + if owned { + prefixStdout, prefixStderr = stdout, stderr + } + } + prefixStdout = scrubThreadServiceText(threadServicePrefix(prefixStdout), workspace.RootPath, secrets) + prefixStderr = scrubThreadServiceText(threadServicePrefix(prefixStderr), workspace.RootPath, secrets) + value.CandidateURLs = threadServiceCandidateURLs([]runner.CommandRuntimeFrame{{Text: prefixStdout}, {Text: prefixStderr}, {Text: value.Output.Stdout}, {Text: value.Output.Stderr}}) + return value, nil +} + +// Cursor fields describe the original sanitized ring. Each stream is coalesced +// before the existing public scrubber removes exact env input and host paths, +// including values split across collection chunks. No model paraphrase is used. +func projectThreadServiceOutput(page runner.CommandRuntimeOutputPage, root string, secrets []string) ThreadApplicationServiceOutputView { + value := ThreadApplicationServiceOutputView{BaseCursor: page.BaseCursor, NextCursor: page.NextCursor, EndCursor: page.EndCursor, Dropped: page.Dropped, TruncationReason: page.TruncationReason, Available: true} + for _, stream := range []runner.CommandRuntimeStream{runner.CommandRuntimeStdout, runner.CommandRuntimeStderr} { + var combined strings.Builder + for _, item := range page.Frames { + if item.Stream != stream { + continue + } + combined.WriteString(item.Text) + } + if combined.Len() == 0 { + continue + } + text := scrubThreadServiceText(combined.String(), root, secrets) + if len(text) > runner.MaxCommandRuntimeOutputRead/2 { + text = strings.ToValidUTF8(text[len(text)-runner.MaxCommandRuntimeOutputRead/2:], "") + value.Dropped = true + if value.TruncationReason == "" { + value.TruncationReason = "public_output_limit" + } + } + if stream == runner.CommandRuntimeStdout { + value.Stdout = text + } else { + value.Stderr = text + } + } + return value +} + +func threadServicePrefix(value string) string { + if len(value) <= runner.MaxCommandRuntimeOutputRead/2 { + return value + } + return strings.ToValidUTF8(value[:runner.MaxCommandRuntimeOutputRead/2], "") +} + +var threadServiceURLPattern = regexp.MustCompile(`(?i)https?://[^\s<>"'` + "`" + `]+`) + +// The existing host-path scrubber recognizes the trailing p:/ in http:// as a +// Windows drive. Preserve only already-safe loopback URLs, after checking all +// exact private inputs and workspace-root spellings. Every other byte still +// goes through that same public scrubber, including unsafe or secret URLs. +func scrubThreadServiceText(value, root string, secrets []string) string { + type preservedURL struct{ marker, value string } + var preserved []preservedURL + protected := threadServiceURLPattern.ReplaceAllStringFunc(value, func(raw string) string { + candidate := strings.TrimRight(raw, ".,;)") + if _, ok := canonicalThreadServiceURL(candidate); !ok { + return raw + } + for _, secret := range secrets { + if secret != "" && strings.Contains(candidate, secret) { + return raw + } + } + for _, path := range []string{root, strings.ReplaceAll(root, "\\", "/")} { + if path != "" && strings.Contains(strings.ToLower(candidate), strings.ToLower(path)) { + return raw + } + } + parsed, _ := url.Parse(candidate) + for _, secret := range secrets { + if secret != "" && strings.Contains(parsed.Path, secret) { + return raw + } + } + for _, path := range []string{root, strings.ReplaceAll(root, "\\", "/")} { + if path != "" && strings.Contains(strings.ToLower(parsed.Path), strings.ToLower(path)) { + return raw + } + } + if windowsAbsolutePath.MatchString(parsed.Path) || fileURIAbsolutePath.MatchString(parsed.Path) { + return raw + } + marker := "APPLICATIONURL" + strconv.Itoa(len(preserved)) + "PLACEHOLDER" + for strings.Contains(value, marker) { + marker += "X" + } + preserved = append(preserved, preservedURL{marker: marker, value: candidate}) + return marker + strings.TrimPrefix(raw, candidate) + }) + protected = scrubThreadActivityText(protected, root, secrets...) + for _, item := range preserved { + protected = strings.ReplaceAll(protected, item.marker, item.value) + } + return protected +} + +func canonicalThreadServiceURL(candidate string) (string, bool) { + if len(candidate) > 2048 || strings.ContainsAny(candidate, "\\\r\n\t") { + return "", false + } + parsed, err := url.Parse(candidate) + if err != nil || parsed.User != nil || parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" || parsed.Opaque != "" || redact.String(candidate) != candidate { + return "", false + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "", false + } + host := strings.ToLower(parsed.Hostname()) + if host != "localhost" && host != "127.0.0.1" && host != "::1" { + return "", false + } + port := parsed.Port() + if port != "" { + number, err := strconv.Atoi(port) + if err != nil || number < 1 || number > 65535 { + return "", false + } + } + if host == "localhost" { + parsed.Host = "127.0.0.1" + if port != "" { + parsed.Host += ":" + port + } + } + return parsed.String(), true +} + +func threadServiceCandidateURLs(frames []runner.CommandRuntimeFrame) []ThreadApplicationServiceURLView { + values := make([]ThreadApplicationServiceURLView, 0, MaxThreadApplicationServiceURLs) + seen := make(map[string]bool) + for _, frame := range frames { + for _, match := range threadServiceURLPattern.FindAllString(frame.Text, MaxThreadApplicationServiceURLs*4) { + candidate := strings.TrimRight(match, ".,;)") + canonical, ok := canonicalThreadServiceURL(candidate) + if !ok { + continue + } + if seen[canonical] { + continue + } + seen[canonical] = true + values = append(values, ThreadApplicationServiceURLView{URL: canonical, Source: "command_output", Verified: false}) + if len(values) == MaxThreadApplicationServiceURLs { + return values + } + } + } + return values +} + +func (s *ThreadApplicationService) Stop(ctx context.Context, threadID, jobID string, request ThreadApplicationServiceStopRequest) (ThreadApplicationServiceStopView, error) { + value := ThreadApplicationServiceStopView{Version: ThreadApplicationServicesProtocolVersion} + if err := s.checkThread(ctx, threadID); err != nil { + return value, err + } + if request.Version != ThreadApplicationServicesProtocolVersion || !domain.ValidAgentID(request.ExpectedRunID) || + !domain.ValidAgentID(jobID) || request.OperationKey != "application-stop-"+jobID { + return value, apperror.New(apperror.CodeInvalidArgument, "Thread service stop binding is invalid") + } + job, err := s.store.GetThreadCommandRuntimeJob(ctx, threadID, jobID) + if err != nil { + return value, apperror.Normalize(err) + } + if job.RunID != request.ExpectedRunID { + return value, apperror.New(apperror.CodeConflict, "Thread service belongs to another Run") + } + if job.Validate() != nil { + return value, apperror.New(apperror.CodeFailedPrecondition, "Thread service Job is invalid") + } + if job.State.Terminal() { + if !job.TreeReaped { + return value, apperror.New(apperror.CodeConflict, "Thread service process tree is not reaped") + } + value.Service, err = s.project(ctx, threadID, serviceMetadata(job)) + value.Replayed = true + return value, err + } + if s.commands == nil { + return value, apperror.New(apperror.CodeFailedPrecondition, "Thread service live owner is unavailable") + } + snapshot, replayed, err := s.commands.StopThreadApplicationServiceJob(ctx, job) + if err != nil { + return value, apperror.Wrap(apperror.CodeConflict, "Thread service live ownership is unavailable", err) + } + value.Service, err = s.project(ctx, threadID, serviceMetadata(job)) + if err != nil { + return value, err + } + value.Service.State, value.Service.ExitCode, value.Service.CompletedAt, value.Service.CanStop = string(snapshot.State), cloneActivityInt(snapshot.ExitCode), cloneActivityTime(snapshot.CompletedAt), false + value.Replayed = replayed + return value, nil +} + +func (s *CommandRuntimeService) ReadOwnedCommandRuntimeServiceState(ctx context.Context, id runner.CommandRuntimeJobIdentity) (runner.CommandRuntimeJobSnapshot, bool, error) { + if s == nil || s.manager == nil || !id.Adapter.SameBackend(s.adapter) { + return runner.CommandRuntimeJobSnapshot{}, false, nil + } + return s.manager.ReadOwnedCommandRuntimeServiceState(ctx, id) +} + +func (s *CommandRuntimeService) ReadOwnedCommandRuntimeServicePrefix(ctx context.Context, id runner.CommandRuntimeJobIdentity, maxBytes int) (string, string, bool, error) { + if s == nil || s.manager == nil || !id.Adapter.SameBackend(s.adapter) { + return "", "", false, nil + } + return s.manager.ReadOwnedCommandRuntimeServicePrefix(ctx, id, maxBytes) +} + +func (s *CommandRuntimeService) StopThreadApplicationServiceJob(ctx context.Context, job runner.CommandRuntimeJob) (runner.CommandRuntimeJobSnapshot, bool, error) { + if s == nil || s.manager == nil || !job.Adapter.SameBackend(s.adapter) { + return runner.CommandRuntimeJobSnapshot{}, false, runner.ErrCommandRuntimeUncertain + } + snapshot, replayed, err := s.manager.CancelOwnedCommandRuntimeServiceJob(ctx, job) + if err == nil && snapshot.State.Terminal() { + record, readErr := s.store.GetCommandRuntimeJob(ctx, job.ID) + if readErr == nil { + readErr = s.completeCommandRuntimeJobBoundary(ctx, record) + } + err = errors.Join(err, readErr) + } + return snapshot, replayed, err +} + +func (m *CommandRuntimeMultiplexer) ReadOwnedCommandRuntimeServiceState(ctx context.Context, id runner.CommandRuntimeJobIdentity) (runner.CommandRuntimeJobSnapshot, bool, error) { + if m != nil { + for _, service := range m.adapters { + if id.Adapter.SameBackend(service.adapter) { + return service.ReadOwnedCommandRuntimeServiceState(ctx, id) + } + } + } + return runner.CommandRuntimeJobSnapshot{}, false, nil +} + +func (m *CommandRuntimeMultiplexer) ReadOwnedCommandRuntimeServicePrefix(ctx context.Context, id runner.CommandRuntimeJobIdentity, maxBytes int) (string, string, bool, error) { + if m != nil { + for _, service := range m.adapters { + if id.Adapter.SameBackend(service.adapter) { + return service.ReadOwnedCommandRuntimeServicePrefix(ctx, id, maxBytes) + } + } + } + return "", "", false, nil +} + +func (m *CommandRuntimeMultiplexer) StopThreadApplicationServiceJob(ctx context.Context, job runner.CommandRuntimeJob) (runner.CommandRuntimeJobSnapshot, bool, error) { + if m != nil { + for _, service := range m.adapters { + if job.Adapter.SameBackend(service.adapter) { + return service.StopThreadApplicationServiceJob(ctx, job) + } + } + } + return runner.CommandRuntimeJobSnapshot{}, false, runner.ErrCommandRuntimeUncertain +} diff --git a/internal/application/thread_application_services_test.go b/internal/application/thread_application_services_test.go new file mode 100644 index 00000000..0bc21900 --- /dev/null +++ b/internal/application/thread_application_services_test.go @@ -0,0 +1,123 @@ +package application + +import ( + "context" + "strings" + "testing" + "time" + + "cyberagent-workbench/internal/apperror" + "cyberagent-workbench/internal/domain" + "cyberagent-workbench/internal/runner" + "cyberagent-workbench/internal/session" +) + +type threadApplicationServiceMetadataStore struct { + jobs []runner.CommandRuntimeServiceMetadata + requestedLimit, fullReads int +} + +func (s *threadApplicationServiceMetadataStore) GetThread(context.Context, string) (domain.Thread, error) { + return domain.Thread{ID: "thread-services", ActiveRunID: "successor-run"}, nil +} +func (s *threadApplicationServiceMetadataStore) ListThreadCommandRuntimeServiceMetadata(_ context.Context, _ string, limit int) ([]runner.CommandRuntimeServiceMetadata, error) { + s.requestedLimit = limit + return s.jobs, nil +} +func (s *threadApplicationServiceMetadataStore) GetThreadCommandRuntimeJob(context.Context, string, string) (runner.CommandRuntimeJob, error) { + s.fullReads++ + return runner.CommandRuntimeJob{}, apperror.New(apperror.CodeNotFound, "unavailable") +} +func (s *threadApplicationServiceMetadataStore) FindThreadCommandRuntimeStartCall(_ context.Context, _ string, id runner.CommandRuntimeJobIdentity) (domain.SupervisorToolCall, bool, error) { + if id.ID == "job-from-message" { + return domain.SupervisorToolCall{CallID: "start-call", RunID: id.RunID, AttemptID: "start-attempt", Turn: 4}, true, nil + } + return domain.SupervisorToolCall{}, false, nil +} +func (s *threadApplicationServiceMetadataStore) SupervisorOperatorMessageID(context.Context, string, string, int) (string, error) { + return "explicit-message", nil +} +func (s *threadApplicationServiceMetadataStore) GetWorkspaceInfo(context.Context, string) (session.WorkspaceInfo, error) { + return session.WorkspaceInfo{}, nil +} + +func TestThreadApplicationServicesMetadataPreservesOriginalRunAndUnknownSource(t *testing.T) { + now := time.Now().UTC() + st := &threadApplicationServiceMetadataStore{jobs: []runner.CommandRuntimeServiceMetadata{ + {Identity: runner.CommandRuntimeJobIdentity{ID: "job-from-message", RunID: "predecessor-run"}, State: runner.CommandRuntimeJobRunning, CreatedAt: now}, + {Identity: runner.CommandRuntimeJobIdentity{ID: "job-without-source", RunID: "successor-run"}, State: runner.CommandRuntimeJobFailed, CreatedAt: now}, + {Identity: runner.CommandRuntimeJobIdentity{ID: "job-not-in-page", RunID: "successor-run"}, State: runner.CommandRuntimeJobCompleted, CreatedAt: now}, + }} + service := NewThreadApplicationService(st) + view, err := service.List(t.Context(), "thread-services", 2) + if err != nil || !view.HasMore || len(view.Services) != 2 || st.requestedLimit != 3 || st.fullReads != 0 { + t.Fatalf("metadata list=%+v err=%v reads=%d limit=%d", view, err, st.fullReads, st.requestedLimit) + } + first, second := view.Services[0], view.Services[1] + if first.RunID != "predecessor-run" || first.SourceMessageID != "explicit-message" || first.SourceCallID != "start-call" || first.SourceTurn != 4 || first.CanStop { + t.Fatalf("source binding=%+v", first) + } + if second.SourceMessageID != "" || second.SourceCallID != "" || second.SourceTurn != 0 || second.State != "failed" { + t.Fatalf("unknown source was guessed: %+v", second) + } + for _, limit := range []int{-1, 51} { + if _, err := service.List(t.Context(), "thread-services", limit); apperror.CodeOf(err) != apperror.CodeInvalidArgument { + t.Fatalf("limit %d: %v", limit, err) + } + } +} + +func TestThreadApplicationServiceOutputKeepsLocalURLsAndScrubsPrivateInputsAcrossFrames(t *testing.T) { + root := `D:\private\service` + page := runner.CommandRuntimeOutputPage{BaseCursor: 0, NextCursor: 200, EndCursor: 200, Frames: []runner.CommandRuntimeFrame{ + {Stream: runner.CommandRuntimeStdout, Text: "Local: http://localhost:5173/\nprivate-environ"}, + {Stream: runner.CommandRuntimeStderr, Text: "secondary https://[::1]:3443/\n"}, + {Stream: runner.CommandRuntimeStdout, Text: "ment-value " + root + "\\entry.js\n"}, + }} + output := projectThreadServiceOutput(page, root, []string{"private-environment-value"}) + if !strings.Contains(output.Stdout, "http://localhost:5173/") || !strings.Contains(output.Stderr, "https://[::1]:3443/") || strings.Contains(output.Stdout, "private-environment-value") || strings.Contains(output.Stdout, root) || output.NextCursor != 200 { + t.Fatalf("public output=%+v", output) + } + urls := threadServiceCandidateURLs([]runner.CommandRuntimeFrame{{Text: output.Stdout}, {Text: output.Stderr}}) + if len(urls) != 2 || urls[0].URL != "http://127.0.0.1:5173/" || urls[1].URL != "https://[::1]:3443/" || urls[0].Verified || urls[0].Source != "command_output" { + t.Fatalf("candidates=%+v", urls) + } +} + +func TestThreadApplicationServiceURLCandidatesFailClosed(t *testing.T) { + for _, raw := range []string{ + "http://example.com:5173/", "http://0.0.0.0:5173/", "http://127.0.0.1.example.com/", + "http://127.0.0.1:5173/?token=private", "http://127.0.0.1:5173/?", + "http://user:password@127.0.0.1:5173/", "http://127.0.0.1:5173/#secret", + "http://127.0.0.1:99999/", "http://127.0.0.1:0/", "file:///tmp/preview.html", + "http://127.0.0.1:5173/\\secret", "http://localhost:5173/" + strings.Repeat("x", 2048), + } { + if urls := threadServiceCandidateURLs([]runner.CommandRuntimeFrame{{Text: raw}}); len(urls) != 0 { + t.Fatalf("unsafe candidate %q: %+v", raw, urls) + } + } + for _, private := range []string{"http://localhost:5173/", "5173", "private-route"} { + text := scrubThreadServiceText("http://localhost:5173/private-route", "", []string{private}) + if urls := threadServiceCandidateURLs([]runner.CommandRuntimeFrame{{Text: text}}); len(urls) != 0 { + t.Fatalf("URL shielding bypassed exact env scrub %q: %q", private, text) + } + } + for _, raw := range []string{`http://localhost:5173/D:/private/service/config`, `http://localhost:5173/%2Fhome%2Fprivate%2Fservice/config`} { + text := scrubThreadServiceText(raw, "/home/private/service", nil) + if urls := threadServiceCandidateURLs([]runner.CommandRuntimeFrame{{Text: text}}); len(urls) != 0 { + t.Fatalf("URL shielding bypassed path scrub: %q", text) + } + } + text := scrubThreadServiceText("http://localhost:5173/%70%72ivate-route", "", []string{"private-route"}) + if urls := threadServiceCandidateURLs([]runner.CommandRuntimeFrame{{Text: text}}); len(urls) != 0 { + t.Fatalf("URL shielding bypassed decoded env scrub: %q", text) + } +} + +func TestThreadApplicationServiceOutputBoundsExpandedRedaction(t *testing.T) { + page := runner.CommandRuntimeOutputPage{NextCursor: 8000, EndCursor: 8000, Frames: []runner.CommandRuntimeFrame{{Stream: runner.CommandRuntimeStdout, Text: strings.Repeat("x", 8000)}}} + output := projectThreadServiceOutput(page, "", []string{"x"}) + if len(output.Stdout) > runner.MaxCommandRuntimeOutputRead/2 || !output.Dropped || output.TruncationReason != "public_output_limit" { + t.Fatalf("expanded redaction is unbounded: bytes=%d output=%+v", len(output.Stdout), output) + } +} diff --git a/internal/httpapi/openapi.go b/internal/httpapi/openapi.go index 6101e801..1d54e862 100644 --- a/internal/httpapi/openapi.go +++ b/internal/httpapi/openapi.go @@ -781,6 +781,18 @@ func openAPIOperationSpecs() []openAPIOperationSpec { "maximum": operationreceipt.MaxHistoryItems, "default": application.DefaultOperationReceiptHistoryLimit}}, }}, + {Path: ThreadApplicationServicesPathTemplate, OperationID: "listThreadApplicationServices", Summary: "List task command service candidates", Tag: "Threads", + Description: "Bounded metadata from Jobs in every Run belonging to this Thread. Source message identity is present only when an exact background start and operator delivery are proven. Does not read command output, start work, probe URLs, or assert readiness.", + DataType: reflect.TypeOf(application.ThreadApplicationServicesView{}), NotFound: true, + Parameters: []openAPIParameter{threadID, {Name: "limit", In: "query", Schema: map[string]any{"type": "integer", "minimum": 1, "maximum": application.MaxThreadApplicationServicesLimit, "default": application.DefaultThreadApplicationServicesLimit}}}}, + {Path: ThreadApplicationServicePathTemplate, OperationID: "getThreadApplicationService", Summary: "Read one exact task command service", Tag: "Threads", + Description: "Reads an exact Thread-owned Run/Job and a bounded sanitized output tail. Loopback URLs are unverified command-output candidates; no network or readiness check is performed.", + DataType: reflect.TypeOf(application.ThreadApplicationServiceDetailView{}), NotFound: true, + Parameters: []openAPIParameter{threadID, pathIdentityParameter("job_id", "Exact command Job identity")}}, + {Path: ThreadApplicationServiceStopPathTemplate, Method: http.MethodPost, OperationID: "stopThreadApplicationService", Summary: "Stop one exact owned task command service", Tag: "Control", Control: true, NotFound: true, + Description: "Cleanup only under Run execution control. Requires the expected original Run and the fixed application-stop-{job_id} Idempotency-Key. Only this process's exact Job owner may cancel; repeated stopping observes its fact without upgrading to Kill. Reaped terminal Jobs replay and cold active Jobs fail closed. No persisted PID is signalled.", + DataType: reflect.TypeOf(application.ThreadApplicationServiceStopView{}), RequestType: reflect.TypeOf(ThreadApplicationServiceStopRequestView{}), + Parameters: []openAPIParameter{threadID, pathIdentityParameter("job_id", "Exact command Job identity"), {Name: "Idempotency-Key", In: "header", Required: true, Schema: map[string]any{"type": "string"}}}}, {Path: ThreadCollectionPath, OperationID: "listThreads", Summary: "List Threads", Tag: "Threads", Description: "Returns the canonical stable task projection. A Thread remains addressable while its finite Runs complete, fail, cancel, or are succeeded.", DataType: reflect.TypeOf(ThreadView{}), Collection: true, Paginated: true, @@ -3395,6 +3407,12 @@ var openAPIFieldEnums = map[string][]string{ "FullCDPSessionOpenRequestView.version": {application.FullCDPSessionProtocolVersion}, "FullCDPSessionCloseRequestView.version": {application.FullCDPSessionCloseProtocolVersion}, "FullCDPSessionView.version": {application.FullCDPSessionProtocolVersion}, + "ThreadApplicationServicesView.version": {application.ThreadApplicationServicesProtocolVersion}, + "ThreadApplicationServiceDetailView.version": {application.ThreadApplicationServicesProtocolVersion}, + "ThreadApplicationServiceStopView.version": {application.ThreadApplicationServicesProtocolVersion}, + "ThreadApplicationServiceStopRequestView.version": {application.ThreadApplicationServicesProtocolVersion}, + "ThreadApplicationServiceView.state": {"prepared", "running", "stopping", "completed", "failed", "timed_out", "cancelled", "killed", "interrupted"}, + "ThreadApplicationServiceURLView.source": {"command_output"}, "FullCDPPreviewView.version": {application.FullCDPPreviewProtocolVersion}, "FullCDPPreviewRequestView.version": {application.FullCDPPreviewProtocolVersion}, "FullCDPPreviewActionRequestView.version": {application.FullCDPPreviewActionProtocolVersion}, diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 97f995c0..2ce3de2a 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -359,6 +359,7 @@ type Config struct { ThreadTurnController ThreadTurnController ThreadReviewReader ThreadReviewReader ThreadGitController ThreadGitController + ThreadApplicationServiceController ThreadApplicationServiceController ThreadPullRequestController ThreadPullRequestController StandardCodePresetController StandardCodePresetController StandardCodeDeliveryController StandardCodeDeliveryController @@ -457,6 +458,7 @@ type API struct { threadTurnController ThreadTurnController threadReview ThreadReviewReader threadGitController ThreadGitController + threadApplicationServiceController ThreadApplicationServiceController threadPullRequestController ThreadPullRequestController standardCodePresetController StandardCodePresetController standardCodeDeliveryController StandardCodeDeliveryController @@ -805,6 +807,16 @@ func New(store Store, config Config) (*API, error) { if source, ok := config.CommandRuntimeAdvertiser.(application.ThreadActivityCommandRuntimeSource); ok { commandActivitySource = source } + threadApplicationServices := config.ThreadApplicationServiceController + if threadApplicationServices == nil { + if state, ok := store.(application.ThreadApplicationServiceStore); ok { + service := application.NewThreadApplicationService(state) + if runtime, ok := config.CommandRuntimeAdvertiser.(application.ThreadApplicationServiceCommandRuntime); ok { + service.WithCommandRuntime(runtime) + } + threadApplicationServices = service + } + } version := strings.TrimSpace(config.AppVersion) if version == "" { version = "unknown" @@ -883,6 +895,7 @@ func New(store Store, config Config) (*API, error) { threadTurnController: config.ThreadTurnController, threadReview: config.ThreadReviewReader, threadGitController: config.ThreadGitController, + threadApplicationServiceController: threadApplicationServices, threadPullRequestController: config.ThreadPullRequestController, standardCodePresetController: config.StandardCodePresetController, standardCodeDeliveryController: config.StandardCodeDeliveryController, @@ -1049,6 +1062,10 @@ func (a *API) ServeHTTP(writer http.ResponseWriter, request *http.Request) { a.serveDockerSandbox(tracked, request, requestID) return } + if threadID, jobID, stop, matched := matchThreadApplicationServicesPath(request.URL.Path); matched { + a.serveThreadApplicationServices(tracked, request, requestID, threadID, jobID, stop) + return + } if route, matched := matchUIEvidencePath(request.URL.Path); matched { a.serveUIEvidence(tracked, request, requestID, route) return diff --git a/internal/httpapi/thread_application_services.go b/internal/httpapi/thread_application_services.go new file mode 100644 index 00000000..8231c77a --- /dev/null +++ b/internal/httpapi/thread_application_services.go @@ -0,0 +1,161 @@ +package httpapi + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "strconv" + "strings" + + "cyberagent-workbench/internal/apperror" + "cyberagent-workbench/internal/application" +) + +const ( + ThreadApplicationServicesPathTemplate = "/api/v1/threads/{thread_id}/application-services" + ThreadApplicationServicePathTemplate = ThreadApplicationServicesPathTemplate + "/{job_id}" + ThreadApplicationServiceStopPathTemplate = ThreadApplicationServicePathTemplate + "/stop" +) + +type ThreadApplicationServiceController interface { + List(context.Context, string, int) (application.ThreadApplicationServicesView, error) + Get(context.Context, string, string) (application.ThreadApplicationServiceDetailView, error) + Stop(context.Context, string, string, application.ThreadApplicationServiceStopRequest) (application.ThreadApplicationServiceStopView, error) +} + +type ThreadApplicationServiceStopRequestView struct { + Version string `json:"version"` + ExpectedRunID string `json:"expected_run_id"` +} + +func matchThreadApplicationServicesPath(path string) (threadID, jobID string, stop, matched bool) { + parts := strings.Split(strings.TrimPrefix(path, "/api/v1/"), "/") + if len(parts) < 3 || parts[0] != "threads" || parts[1] == "" || parts[2] != "application-services" { + return + } + if len(parts) == 3 { + return parts[1], "", false, true + } + if len(parts) == 4 && parts[3] != "" { + return parts[1], parts[3], false, true + } + if len(parts) == 5 && parts[3] != "" && parts[4] == "stop" { + return parts[1], parts[3], true, true + } + return +} + +func (a *API) serveThreadApplicationServices(w http.ResponseWriter, r *http.Request, requestID, threadID, jobID string, stop bool) { + if a.threadApplicationServiceController == nil { + a.writeError(w, requestID, apperror.New(apperror.CodeNotFound, "Thread application services are unavailable"), http.StatusNotFound) + return + } + token, method := a.tokenHash, http.MethodGet + if stop { + if !a.runExecutionEnabled { + a.writeError(w, requestID, apperror.New(apperror.CodeNotFound, "Thread application service control is unavailable"), http.StatusNotFound) + return + } + token, method = a.controlTokenHash, http.MethodPost + } + if !a.authorized(r, token) { + w.Header().Set("WWW-Authenticate", `Bearer realm="CyberAgent API"`) + a.writeError(w, requestID, apperror.New(apperror.CodePolicyDenied, "valid bearer authorization is required"), http.StatusUnauthorized) + return + } + if r.Method != method { + w.Header().Set("Allow", method) + a.writeError(w, requestID, apperror.New(apperror.CodeInvalidArgument, "Thread service method is invalid"), http.StatusMethodNotAllowed) + return + } + if err := validatePathIdentity(threadID); err != nil { + a.writeError(w, requestID, err, 0) + return + } + if jobID != "" { + if err := validatePathIdentity(jobID); err != nil { + a.writeError(w, requestID, err, 0) + return + } + } + if !stop { + if r.ContentLength != 0 || len(r.TransferEncoding) > 0 { + a.writeError(w, requestID, apperror.New(apperror.CodeInvalidArgument, "Thread service reads cannot contain a body"), 0) + return + } + var value any + var err error + if jobID == "" { + if err = validateSingleQueryValues(r.URL.Query(), "limit"); err == nil { + limit := application.DefaultThreadApplicationServicesLimit + if raw := r.URL.Query().Get("limit"); raw != "" { + limit, err = strconv.Atoi(raw) + } + if err != nil { + err = apperror.New(apperror.CodeInvalidArgument, "Thread service limit is invalid") + } else { + value, err = a.threadApplicationServiceController.List(r.Context(), threadID, limit) + } + } + if view, ok := value.(application.ThreadApplicationServicesView); ok && !a.runExecutionEnabled { + for i := range view.Services { + view.Services[i].CanStop = false + } + value = view + } + } else { + if err = rejectQuery(r.URL.Query()); err == nil { + value, err = a.threadApplicationServiceController.Get(r.Context(), threadID, jobID) + } + if view, ok := value.(application.ThreadApplicationServiceDetailView); ok && !a.runExecutionEnabled { + view.Service.CanStop = false + value = view + } + } + if err != nil { + a.writeError(w, requestID, err, 0) + return + } + a.writeSuccess(w, requestID, value, nil) + return + } + if err := rejectQuery(r.URL.Query()); err != nil { + a.writeError(w, requestID, err, 0) + return + } + if err := validateJSONContentType(r.Header); err != nil { + a.writeError(w, requestID, err, http.StatusUnsupportedMediaType) + return + } + if len(r.Header.Values("Idempotency-Key")) != 1 || r.Header.Get("Idempotency-Key") != "application-stop-"+jobID { + a.writeError(w, requestID, apperror.New(apperror.CodeInvalidArgument, "Thread service stop key is invalid"), 0) + return + } + body, err := readBoundedRequestBody(r, 4096) + if err != nil { + a.writeError(w, requestID, err, 0) + return + } + if err = rejectDuplicateJSONObjectFields(body, "Thread service stop"); err != nil { + a.writeError(w, requestID, err, 0) + return + } + var input ThreadApplicationServiceStopRequestView + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.DisallowUnknownFields() + if err = decoder.Decode(&input); err == nil { + err = ensureJSONEOF(decoder) + } + if err != nil { + a.writeError(w, requestID, apperror.Wrap(apperror.CodeInvalidArgument, "Thread service stop must be a closed object", err), 0) + return + } + value, err := a.threadApplicationServiceController.Stop(r.Context(), threadID, jobID, application.ThreadApplicationServiceStopRequest{ + Version: input.Version, ExpectedRunID: input.ExpectedRunID, OperationKey: r.Header.Get("Idempotency-Key")}) + if err != nil { + a.writeError(w, requestID, err, 0) + return + } + a.writeSuccess(w, requestID, value, nil) +} diff --git a/internal/httpapi/thread_application_services_live_runtime_test.go b/internal/httpapi/thread_application_services_live_runtime_test.go new file mode 100644 index 00000000..b2313b61 --- /dev/null +++ b/internal/httpapi/thread_application_services_live_runtime_test.go @@ -0,0 +1,535 @@ +package httpapi + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "cyberagent-workbench/internal/application" + "cyberagent-workbench/internal/commandruntimeadapter" + "cyberagent-workbench/internal/domain" + "cyberagent-workbench/internal/idgen" + "cyberagent-workbench/internal/llm" + "cyberagent-workbench/internal/policy" + "cyberagent-workbench/internal/runmutation" + "cyberagent-workbench/internal/runner" + "cyberagent-workbench/internal/store" + "cyberagent-workbench/internal/toolgateway" +) + +const threadApplicationServicesFixturePrivateValue = "test-only-private-output-9d430ea5" + +// The helper is the same pinned native test executable as its parent. All data, +// sockets and Jobs belong to this test's temporary store and workspace. It does +// not need a shell, an installed development runtime, or inherited credentials. +func TestThreadApplicationServicesFixtureProcess(t *testing.T) { + if len(os.Args) < 3 || os.Args[len(os.Args)-2] != "--thread-service-fixture" { + return + } + if os.Args[len(os.Args)-1] == "fail" { + fmt.Fprintln(os.Stdout, "failed-during-startup stdout") + fmt.Fprintln(os.Stderr, "failed-during-startup stderr") + os.Exit(23) + } + first, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(24) + } + second, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + _ = first.Close() + fmt.Fprintln(os.Stderr, err) + os.Exit(25) + } + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain") + _, _ = io.WriteString(w, "native-loopback-service-response") + }) + go func() { _ = http.Serve(first, handler) }() + go func() { _ = http.Serve(second, handler) }() + firstPort := first.Addr().(*net.TCPAddr).Port + fmt.Fprintf(os.Stdout, "Local: http://localhost:%d/\nSecondary: http://%s/\nservice-fixture-stdout: \n", firstPort, second.Addr()) + fmt.Fprintln(os.Stdout, "fixture environment value:", os.Getenv("SERVICE_FIXTURE_PRIVATE_VALUE")) + workingDirectory, err := os.Getwd() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(26) + } + fmt.Fprintln(os.Stderr, "fixture temporary directory:", workingDirectory) + fmt.Fprintln(os.Stderr, "service-fixture-stderr: raw diagnostic") + select {} +} + +type threadApplicationServicesLiveFixture struct { + store *store.SQLiteStore + api *API + runtime *application.CommandRuntimeService + manager *runner.CommandRuntimeManager + caps domain.ExecutionPermissionRuntimeCapabilities + workspace store.WorkspaceRecord +} + +type threadApplicationServicesLiveJob struct { + runID, threadID, jobID, callID string + sourceMessageID string + turn int + admitted runner.CommandRuntimeJob + releasedAt time.Time +} + +func newThreadApplicationServicesLiveFixture(t *testing.T) *threadApplicationServicesLiveFixture { + t.Helper() + st, err := store.Open(filepath.Join(t.TempDir(), "application-services.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + f := &threadApplicationServicesLiveFixture{store: st, + workspace: store.WorkspaceRecord{ID: "thread-service-live-workspace", Name: "loopback fixture", RootPath: root}, + caps: domain.ExecutionPermissionRuntimeCapabilities{OperatorApprovalEnabled: true, DangerFullAccessEnabled: true, + RuntimeAuthority: domain.NewExecutionPermissionRuntimeAuthority()}} + if err := st.SaveWorkspace(t.Context(), f.workspace); err != nil { + t.Fatal(err) + } + f.manager, err = runner.NewPlatformCommandRuntimeManager(st, idgen.New("thread-service-live-owner")) + if errors.Is(err, runner.ErrCommandRuntimeUnavailable) { + t.Skipf("native Command Runtime is unavailable: %v", err) + } + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := f.manager.Shutdown(ctx); err != nil { + t.Errorf("shutdown native fixture Jobs: %v", err) + } + }) + f.runtime, err = application.NewCommandRuntimeService(st, f.manager, f.caps) + if err != nil { + t.Fatal(err) + } + // The existing no-op execution controller satisfies the API startup gate. + // No Run execution endpoint is called: native starts and service stops both + // use the actual authorized CommandRuntimeService below. + f.api, err = New(st, Config{AccessToken: testAccessToken, ControlToken: testControlToken, RunExecutionEnabled: true, + RunExecutionController: runExecutionControllerFake{}, + ExecutionPermissionControlEnabled: true, + ExecutionPermissionCapabilities: f.caps, + ThreadApplicationServiceController: application.NewThreadApplicationService(st).WithCommandRuntime(f.runtime)}) + if err != nil { + t.Fatal(err) + } + return f +} + +func (f *threadApplicationServicesLiveFixture) start(t *testing.T, mode string) threadApplicationServicesLiveJob { + t.Helper() + ctx := t.Context() + runs := application.NewRunService(f.store) + _, run, err := runs.Create(ctx, application.CreateRunRequest{Goal: "observe an owned native loopback service", Profile: "code", + Surface: "code", Phase: "deliver", WorkspaceID: f.workspace.ID, + Budget: domain.Budget{MaxTurns: 4, MaxTokens: 1000, MaxToolCalls: 8}}) + if err != nil { + t.Fatal(err) + } + if _, err := application.NewRunExecutionProfileService(f.store).Change(ctx, + application.ChangeRunExecutionProfileRequest{RunID: run.ID, Profile: "local", + OperationKey: "thread-service-live-profile", RequestedBy: "fixture"}); err != nil { + t.Fatal(err) + } + if _, err := application.NewRunExecutionPermissionService(f.store, f.caps).Change(ctx, + application.ChangeRunExecutionPermissionRequest{RunID: run.ID, Mode: string(domain.RunExecutionPermissionFull), + OperationKey: "thread-service-live-full", RequestedBy: "fixture", ConfirmFull: true}); err != nil { + t.Fatal(err) + } + run, err = runs.Start(ctx, run.ID) + if err != nil { + t.Fatal(err) + } + acquired, err := f.store.AcquireRunExecutionLease(ctx, domain.AcquireRunExecutionLeaseRequest{ + RunID: run.ID, OwnerID: "thread-service-live-worker", TTL: time.Minute}) + if err != nil { + t.Fatal(err) + } + inputText := "start the deterministic loopback fixture" + sourceMessageID := "" + if mode != "fail" { + queued, err := f.store.EnqueueOperatorSteering(ctx, domain.EnqueueOperatorSteeringRequest{ + RunID: run.ID, SessionID: run.SessionID, Content: inputText, + OperationKey: "thread-service-live-input", RequestedBy: "fixture"}) + if err != nil { + t.Fatal(err) + } + sourceMessageID, inputText = queued.Message.ID, "" + } + turn, err := f.store.BeginSupervisorTurn(ctx, acquired.Lease, inputText) + if err != nil { + t.Fatal(err) + } + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + input := toolgateway.CommandRuntimeInput{Version: toolgateway.CommandRuntimeToolProtocolVersion, + Action: toolgateway.CommandRuntimeActionStart, Commands: []runner.CommandRuntimeSpec{{ + Version: runner.CommandRuntimeProtocolVersion, Profile: runner.CommandRuntimeProcess, + Executable: executable, Arguments: []string{"-test.run=^TestThreadApplicationServicesFixtureProcess$", "--", "--thread-service-fixture", mode}, + WorkingDirectory: ".", Environment: []runner.CommandRuntimeEnvironment{{ + Name: "SERVICE_FIXTURE_PRIVATE_VALUE", Value: threadApplicationServicesFixturePrivateValue}}, + StdinPolicy: runner.CommandRuntimeStdinClosed, CloseInitialStdin: true, TimeoutMilliseconds: 60_000, + Output: runner.CommandRuntimeOutputPolicy{InlineBytes: 8192, ArtifactBytes: 8192}, + Network: runner.CommandRuntimeNetworkDisabled, Credentials: runner.CommandRuntimeCredentialsNone, + Purpose: "start a test-only owned loopback HTTP service"}}} + raw, err := json.Marshal(input) + if err != nil { + t.Fatal(err) + } + input, canonical, err := toolgateway.NormalizeCommandRuntimePayload(raw) + if err != nil { + t.Fatal(err) + } + operationKey := runmutation.SupervisorToolOperationKey(run.ID, turn.Checkpoint.NextTurn, + string(toolgateway.CommandRuntimeTool), string(canonical)) + callID, err := runmutation.SupervisorToolCallID(operationKey, 1) + if err != nil { + t.Fatal(err) + } + permission, err := f.store.GetRunExecutionPermission(ctx, run.ID) + if err != nil { + t.Fatal(err) + } + generation, live := f.caps.FullAccessGeneration(permission) + if !live || generation == 0 { + t.Fatal("fixture has no live Full grant") + } + fence, err := f.caps.RuntimeAuthority.IssueRunAuthorizationFence(run.ID) + if err != nil { + t.Fatal(err) + } + adapter, _ := f.manager.AdapterIdentity() + authority, err := commandruntimeadapter.EncodeAuthority(commandruntimeadapter.Authority{ + ProtocolVersion: commandruntimeadapter.OperationAuthorityVersion, RunID: run.ID, Adapter: adapter, + PermissionSnapshotID: permission.ID, PermissionRevision: permission.Revision, PermissionMode: permission.Mode, + PermissionGeneration: generation, PermissionRuntimeEpoch: f.caps.RuntimeAuthority.RuntimeEpoch(), RunAuthorizationFence: fence}) + if err != nil { + t.Fatal(err) + } + authority, err = f.runtime.BindCommandRuntimeAuthority(ctx, authority, canonical) + if errors.Is(err, runner.ErrCommandRuntimeUnavailable) { + t.Skipf("native Command Runtime is unavailable: %v", err) + } + if err != nil { + t.Fatal(err) + } + attempt := llm.ModelAttempt{Number: 1, TransportAttempt: 1, MaxAttempts: 1, Provider: "offline-service-fixture", Model: "fixture"} + if inserted, err := f.store.RecordSupervisorModelStarted(ctx, turn.Checkpoint, attempt); err != nil || !inserted { + t.Fatalf("record fixture model start: inserted=%t err=%v", inserted, err) + } + attempt.Outcome = llm.OutcomeSuccess + checkpoint, err := f.store.RecordSupervisorModelCompleted(ctx, turn.Checkpoint, attempt, + llm.ChatResponse{Provider: attempt.Provider, Model: attempt.Model, + ToolCalls: []llm.ToolCall{{ID: callID, Name: string(toolgateway.CommandRuntimeTool), Arguments: canonical, Authority: authority}}}) + if err != nil { + t.Fatal(err) + } + if inserted, err := f.store.RecordSupervisorToolExecutionStarted(ctx, checkpoint, callID); err != nil || !inserted { + t.Fatalf("record fixture command start: inserted=%t err=%v", inserted, err) + } + scope := toolgateway.CommandRuntimeContext{InvocationID: "thread-service-live-invocation", OperationKey: operationKey, + RunID: run.ID, MissionID: run.MissionID, SessionID: run.SessionID, WorkspaceID: f.workspace.ID, + RootAgentID: turn.Agent.ID, AgentID: turn.Agent.ID, AgentAttemptID: checkpoint.AttemptID, + Surface: turn.Mode.Surface, Phase: turn.Mode.Phase, Profile: turn.Mode.Profile, Role: domain.AgentRoleRoot, ModeRevision: turn.Mode.Revision, + PermissionMode: permission.Mode, PermissionSnapshotID: permission.ID, PermissionRevision: permission.Revision, + PermissionGeneration: generation, PermissionRuntimeEpoch: f.caps.RuntimeAuthority.RuntimeEpoch(), RunAuthorizationFence: fence, + SupervisorToolCallID: callID, CapabilityGeneration: adapter.Generation, + LeaseID: acquired.Lease.LeaseID, LeaseGeneration: acquired.Lease.Generation, RequestedBy: "run_supervisor", Adapter: adapter, + PolicyDecision: toolgateway.Decision{Allowed: true, Approval: toolgateway.ApprovalAutomatic, Risk: "high", Reason: "offline fixture upstream decision"}} + result, err := f.runtime.ExecuteCommandRuntime(ctx, scope, input) + if err != nil || result.Replayed || len(result.Jobs) != 1 { + t.Fatalf("authorized native start: jobs=%+v replayed=%t err=%v", result.Jobs, result.Replayed, err) + } + resultJSON, err := json.Marshal(result) + if err != nil { + t.Fatal(err) + } + if _, replayed, err := f.store.RecordSupervisorToolResult(ctx, checkpoint, domain.SupervisorToolResult{ + CallID: callID, Status: domain.SupervisorToolCompleted, ResultJSON: string(resultJSON), CompletedAt: time.Now().UTC()}); err != nil || replayed { + t.Fatalf("record background start receipt: replayed=%t err=%v", replayed, err) + } + admitted, err := f.store.GetCommandRuntimeJob(ctx, result.Jobs[0].ID) + if err != nil { + t.Fatal(err) + } + releasedAt := f.completeTurnAndReleaseLease(t, checkpoint, acquired.Lease) + thread, err := f.store.GetThreadByRun(ctx, run.ID) + if err != nil { + t.Fatal(err) + } + return threadApplicationServicesLiveJob{runID: run.ID, threadID: thread.ID, jobID: result.Jobs[0].ID, + callID: callID, turn: checkpoint.NextTurn, sourceMessageID: sourceMessageID, admitted: admitted, releasedAt: releasedAt} +} + +func (f *threadApplicationServicesLiveFixture) completeTurnAndReleaseLease(t *testing.T, + checkpoint domain.SupervisorCheckpoint, lease domain.RunExecutionLease, +) time.Time { + t.Helper() + ctx := t.Context() + // Finish the actual model turn using a deterministic final response. The + // background Job must keep its admitted owner after this lease is released. + attempt := llm.ModelAttempt{Number: 2, ToolRound: 1, TransportAttempt: 1, MaxAttempts: 1, + Provider: "offline-service-fixture", Model: "fixture"} + if inserted, err := f.store.RecordSupervisorModelStarted(ctx, checkpoint, attempt); err != nil || !inserted { + t.Fatalf("record final fixture model start: inserted=%t err=%v", inserted, err) + } + action := domain.RootAction{Version: domain.RootLifecycleVersion, Kind: domain.RootActionContinue, + Message: "The owned background fixture was started."} + raw, err := json.Marshal(action) + if err != nil { + t.Fatal(err) + } + attempt.Outcome = llm.OutcomeSuccess + response := llm.ChatResponse{Provider: attempt.Provider, Model: attempt.Model, Text: string(raw)} + checkpoint, err = f.store.RecordSupervisorModelCompleted(ctx, checkpoint, attempt, response) + if err != nil { + t.Fatal(err) + } + if _, _, _, err := f.store.CompleteSupervisorTurn(ctx, checkpoint, response, action, policy.Decision{Allowed: true}, 0); err != nil { + t.Fatal(err) + } + if _, _, err := f.store.ReleaseRunExecutionLease(ctx, lease); err != nil { + t.Fatal(err) + } + return time.Now().UTC() +} + +func (f *threadApplicationServicesLiveFixture) waitOwnerRenewal(t *testing.T, job threadApplicationServicesLiveJob) { + t.Helper() + deadline := time.Now().Add(time.Duration(job.admitted.TimeoutMilliseconds) * time.Millisecond) + for { + current, err := f.store.GetCommandRuntimeJob(t.Context(), job.jobID) + if err != nil || current.State != runner.CommandRuntimeJobRunning { + t.Fatalf("background service lost ownership after model turn ended: state=%s err=%v", current.State, err) + } + if current.OwnerRenewedAt.After(job.releasedAt) { + if current.OwnerID != job.admitted.OwnerID || current.OwnerGeneration != job.admitted.OwnerGeneration || + current.LeaseID != job.admitted.LeaseID || current.LeaseGeneration != job.admitted.LeaseGeneration { + t.Fatal("background continuation changed its admitted owner or original Run lease") + } + return + } + if time.Now().After(deadline) { + t.Fatal("background native owner did not renew after its model turn lease was released") + } + if _, _, err := f.manager.Wait(t.Context(), job.jobID, 100*time.Millisecond, ^uint64(0), runner.MinCommandRuntimeOutputRead); err != nil { + t.Fatal(err) + } + } +} + +type threadApplicationServicesLiveDetail = application.ThreadApplicationServiceDetailView + +func (job threadApplicationServicesLiveJob) path() string { + return "/api/v1/threads/" + job.threadID + "/application-services/" + job.jobID +} + +func (f *threadApplicationServicesLiveFixture) detail(t *testing.T, job threadApplicationServicesLiveJob) threadApplicationServicesLiveDetail { + t.Helper() + response := performRequest(t, f.api, http.MethodGet, job.path(), testAccessToken, "127.0.0.1:8765", "127.0.0.1:45000", nil) + var detail threadApplicationServicesLiveDetail + decodeDataStatus(t, response, http.StatusOK, &detail) + for _, private := range []string{`"pid"`, `"executable"`, `"executable_path"`, `"environment_sha256"`, `"workspace_root"`, `"lease_id"`, `"stdin"`} { + if strings.Contains(response.Body.String(), private) { + t.Fatalf("service detail exposed private runtime field %s: %s", private, response.Body.String()) + } + } + if detail.Version != "thread_application_services.v1" || detail.Service.ThreadID != job.threadID || + detail.Service.RunID != job.runID || detail.Service.JobID != job.jobID { + t.Fatalf("service detail lost exact source identity: %+v", detail) + } + return detail +} + +func (f *threadApplicationServicesLiveFixture) waitDetail(t *testing.T, job threadApplicationServicesLiveJob, + accept func(threadApplicationServicesLiveDetail) bool, +) threadApplicationServicesLiveDetail { + t.Helper() + deadline := time.Now().Add(8 * time.Second) + for { + detail := f.detail(t, job) + if accept(detail) { + return detail + } + if time.Now().After(deadline) { + t.Fatalf("native service detail did not converge: %+v", detail) + } + time.Sleep(20 * time.Millisecond) + } +} + +func threadApplicationServicesLiveOutput(detail threadApplicationServicesLiveDetail, stream string) string { + if stream == "stdout" { + return detail.Output.Stdout + } + return detail.Output.Stderr +} + +func assertThreadApplicationServicesLoopbackResponse(t *testing.T, address string) { + t.Helper() + client := &http.Client{Timeout: time.Second, Transport: &http.Transport{Proxy: nil}} + defer client.CloseIdleConnections() + response, err := client.Get(address) + if err != nil { + t.Fatalf("native loopback service at %s: %v", address, err) + } + defer response.Body.Close() + content, err := io.ReadAll(response.Body) + if err != nil || response.StatusCode != http.StatusOK || string(content) != "native-loopback-service-response" { + t.Fatalf("native loopback response: status=%d body=%q err=%v", response.StatusCode, content, err) + } +} + +func TestThreadApplicationServicesHTTPReadsNativeOutputAndStopsOnlyExactJob(t *testing.T) { + f := newThreadApplicationServicesLiveFixture(t) + selected, other := f.start(t, "serve"), f.start(t, "serve") + f.waitOwnerRenewal(t, selected) + f.waitOwnerRenewal(t, other) + running := f.waitDetail(t, selected, func(detail threadApplicationServicesLiveDetail) bool { + return len(detail.CandidateURLs) == 2 && + strings.Contains(threadApplicationServicesLiveOutput(detail, "stdout"), "service-fixture-stdout: ") && + strings.Contains(threadApplicationServicesLiveOutput(detail, "stderr"), "service-fixture-stderr: raw diagnostic") + }) + otherRunning := f.waitDetail(t, other, func(detail threadApplicationServicesLiveDetail) bool { return len(detail.CandidateURLs) == 2 }) + if running.Service.State != string(runner.CommandRuntimeJobRunning) || !running.Service.CanStop || + running.Service.SourceCallID != selected.callID || running.Service.SourceTurn != selected.turn || + running.Service.SourceMessageID != selected.sourceMessageID || selected.sourceMessageID == "" || + !running.Output.Available || running.Output.Dropped || running.Output.NextCursor != running.Output.EndCursor || + running.Output.EndCursor <= running.Output.BaseCursor { + t.Fatalf("live service projection is incomplete: %+v", running) + } + if strings.Contains(running.Output.Stdout, threadApplicationServicesFixturePrivateValue) || + strings.Contains(running.Output.Stderr, f.workspace.RootPath) { + t.Fatalf("public output exposed test environment or temporary host path: %+v", running.Output) + } + if !strings.Contains(running.Output.Stdout, "http://localhost:") { + t.Fatalf("output scrubber lost the printed localhost port: %q", running.Output.Stdout) + } + printedLocal, err := url.Parse(strings.Split(strings.TrimPrefix(running.Output.Stdout, "Local: "), "\n")[0]) + if err != nil || printedLocal.Port() == "" { + t.Fatalf("invalid printed localhost address: stdout=%q err=%v", running.Output.Stdout, err) + } + localCandidateFound := false + for _, candidate := range running.CandidateURLs { + loopbackURL := strings.HasPrefix(candidate.URL, "http://127.0.0.1:") || strings.HasPrefix(candidate.URL, "http://localhost:") + if candidate.Verified || candidate.Source != "command_output" || !loopbackURL { + t.Fatalf("printed address was incorrectly verified: %+v", candidate) + } + parsed, err := url.Parse(candidate.URL) + if err != nil { + t.Fatal(err) + } + localCandidateFound = localCandidateFound || parsed.Port() == printedLocal.Port() + assertThreadApplicationServicesLoopbackResponse(t, candidate.URL) + } + if !localCandidateFound { + t.Fatalf("candidate extraction lost the printed localhost port: %+v", running.CandidateURLs) + } + assertThreadApplicationServicesLoopbackResponse(t, otherRunning.CandidateURLs[0].URL) + listResponse := performRequest(t, f.api, http.MethodGet, + "/api/v1/threads/"+selected.threadID+"/application-services?limit=20", testAccessToken, + "127.0.0.1:8765", "127.0.0.1:45000", nil) + var list struct { + ThreadID string `json:"thread_id"` + Services []struct { + JobID string `json:"job_id"` + RunID string `json:"run_id"` + } `json:"services"` + } + decodeDataStatus(t, listResponse, http.StatusOK, &list) + if list.ThreadID != selected.threadID || len(list.Services) != 1 || list.Services[0].JobID != selected.jobID || list.Services[0].RunID != selected.runID { + t.Fatalf("Thread service list mixed unrelated Jobs: %s", listResponse.Body.String()) + } + stopBody := func(runID string) *strings.Reader { + body, err := json.Marshal(map[string]string{"version": "thread_application_services.v1", "expected_run_id": runID}) + if err != nil { + t.Fatal(err) + } + return strings.NewReader(string(body)) + } + stopKey := "application-stop-" + selected.jobID + crossThread := performControlPathRequest(t, f.api, strings.Replace(selected.path(), selected.threadID, other.threadID, 1)+"/stop", + stopKey, stopBody(selected.runID)) + assertAPIError(t, crossThread, http.StatusNotFound, "NOT_FOUND") + wrongRun := performControlPathRequest(t, f.api, selected.path()+"/stop", stopKey, stopBody(other.runID)) + assertAPIError(t, wrongRun, http.StatusConflict, "CONFLICT") + readToken := performRequest(t, f.api, http.MethodPost, selected.path()+"/stop", testAccessToken, + "127.0.0.1:8765", "127.0.0.1:45000", stopBody(selected.runID)) + assertAPIError(t, readToken, http.StatusUnauthorized, "POLICY_DENIED") + assertThreadApplicationServicesLoopbackResponse(t, running.CandidateURLs[0].URL) + assertThreadApplicationServicesLoopbackResponse(t, otherRunning.CandidateURLs[0].URL) + stop := performControlPathRequest(t, f.api, selected.path()+"/stop", stopKey, stopBody(selected.runID)) + if stop.Code != http.StatusOK { + t.Fatalf("exact stop: status=%d body=%s", stop.Code, stop.Body.String()) + } + stopped := f.waitDetail(t, selected, func(detail threadApplicationServicesLiveDetail) bool { + return detail.Service.State == string(runner.CommandRuntimeJobCancelled) + }) + if stopped.Service.CanStop || !strings.Contains(threadApplicationServicesLiveOutput(stopped, "stdout"), "") { + t.Fatalf("terminal service lost output or remains stoppable: %+v", stopped) + } + durable, err := f.store.GetCommandRuntimeJob(t.Context(), selected.jobID) + if err != nil || durable.State != runner.CommandRuntimeJobCancelled || !durable.TreeReaped { + t.Fatalf("exact native tree was not reaped: state=%s reaped=%t err=%v", durable.State, durable.TreeReaped, err) + } + replay := performControlPathRequest(t, f.api, selected.path()+"/stop", stopKey, stopBody(selected.runID)) + var replayEnvelope struct { + Replayed bool `json:"replayed"` + } + decodeDataStatus(t, replay, http.StatusOK, &replayEnvelope) + if !replayEnvelope.Replayed { + t.Fatalf("exact stop did not replay: %s", replay.Body.String()) + } + assertThreadApplicationServicesLoopbackResponse(t, otherRunning.CandidateURLs[0].URL) + if otherState := f.detail(t, other); otherState.Service.State != string(runner.CommandRuntimeJobRunning) || !otherState.Service.CanStop { + t.Fatalf("selected stop affected another Job: %+v", otherState) + } + client := &http.Client{Timeout: time.Second, Transport: &http.Transport{Proxy: nil}} + defer client.CloseIdleConnections() + if response, err := client.Get(running.CandidateURLs[0].URL); err == nil { + _ = response.Body.Close() + t.Fatal("selected native loopback service still responds after durable tree reap") + } +} + +func TestThreadApplicationServicesHTTPRetainsNativeNonzeroExit(t *testing.T) { + f := newThreadApplicationServicesLiveFixture(t) + failed := f.start(t, "fail") + detail := f.waitDetail(t, failed, func(detail threadApplicationServicesLiveDetail) bool { + return detail.Service.State == string(runner.CommandRuntimeJobFailed) + }) + if detail.Service.ExitCode == nil || *detail.Service.ExitCode != 23 || detail.Service.CanStop || detail.Service.SourceMessageID != "" || + !detail.Output.Available || len(detail.CandidateURLs) != 0 || + !strings.Contains(threadApplicationServicesLiveOutput(detail, "stdout"), "failed-during-startup stdout") || + !strings.Contains(threadApplicationServicesLiveOutput(detail, "stderr"), "failed-during-startup stderr") { + t.Fatalf("native nonzero startup exit was lost or marked active: %+v", detail) + } + durable, err := f.store.GetCommandRuntimeJob(t.Context(), failed.jobID) + if err != nil || !durable.TreeReaped || durable.ExitCode == nil || *durable.ExitCode != 23 { + t.Fatalf("native failure receipt: job=%+v err=%v", durable, err) + } +} diff --git a/internal/httpapi/thread_application_services_test.go b/internal/httpapi/thread_application_services_test.go new file mode 100644 index 00000000..8803bf5e --- /dev/null +++ b/internal/httpapi/thread_application_services_test.go @@ -0,0 +1,110 @@ +package httpapi + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "cyberagent-workbench/internal/apperror" + "cyberagent-workbench/internal/application" +) + +type threadApplicationServiceControllerStub struct { + stops int + request application.ThreadApplicationServiceStopRequest +} + +func (s *threadApplicationServiceControllerStub) List(_ context.Context, thread string, limit int) (application.ThreadApplicationServicesView, error) { + if limit < 1 || limit > 50 { + return application.ThreadApplicationServicesView{}, apperror.New(apperror.CodeInvalidArgument, "invalid limit") + } + return application.ThreadApplicationServicesView{Version: application.ThreadApplicationServicesProtocolVersion, ThreadID: thread, Services: []application.ThreadApplicationServiceView{serviceControlTestView(thread)}}, nil +} +func (*threadApplicationServiceControllerStub) Get(_ context.Context, thread, job string) (application.ThreadApplicationServiceDetailView, error) { + view := serviceControlTestView(thread) + view.JobID = job + return application.ThreadApplicationServiceDetailView{Version: application.ThreadApplicationServicesProtocolVersion, Service: view, Output: application.ThreadApplicationServiceOutputView{}, CandidateURLs: []application.ThreadApplicationServiceURLView{}}, nil +} +func (s *threadApplicationServiceControllerStub) Stop(_ context.Context, thread, job string, input application.ThreadApplicationServiceStopRequest) (application.ThreadApplicationServiceStopView, error) { + s.stops++ + s.request = input + view := serviceControlTestView(thread) + view.JobID = job + view.State = "cancelled" + view.CanStop = false + return application.ThreadApplicationServiceStopView{Version: application.ThreadApplicationServicesProtocolVersion, Service: view}, nil +} +func serviceControlTestView(thread string) application.ThreadApplicationServiceView { + return application.ThreadApplicationServiceView{ThreadID: thread, RunID: "original-run", JobID: "exact-job", State: "running", CreatedAt: time.Now().UTC(), CanStop: true} +} + +func TestThreadApplicationServicesHTTPControlGateAndClosedStopContract(t *testing.T) { + f := newAPIFixture(t) + controller := &threadApplicationServiceControllerStub{} + api, err := New(f.store, Config{AccessToken: testAccessToken, ControlToken: testControlToken, RunExecutionEnabled: true, RunExecutionController: &runExecutionControllerFake{}, ThreadApplicationServiceController: controller}) + if err != nil { + t.Fatal(err) + } + path := "/api/v1/threads/exact-thread/application-services/exact-job/stop" + valid := `{"version":"thread_application_services.v1","expected_run_id":"original-run"}` + for _, test := range []struct { + name, token, key, body, query string + status int + }{ + {"read token", testAccessToken, "application-stop-exact-job", valid, "", 401}, + {"missing key", testControlToken, "", valid, "", 400}, + {"wrong Job key", testControlToken, "application-stop-another-job", valid, "", 400}, + {"key in body", testControlToken, "application-stop-exact-job", `{"version":"thread_application_services.v1","expected_run_id":"original-run","operation_key":"application-stop-exact-job"}`, "", 400}, + {"unknown body", testControlToken, "application-stop-exact-job", `{"version":"thread_application_services.v1","expected_run_id":"original-run","pid":42}`, "", 400}, + {"duplicate body", testControlToken, "application-stop-exact-job", `{"version":"thread_application_services.v1","expected_run_id":"original-run","expected_run_id":"another-run"}`, "", 400}, + {"extra query", testControlToken, "application-stop-exact-job", valid, "?run_id=another-run", 400}, + } { + t.Run(test.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, "http://127.0.0.1:8765"+path+test.query, strings.NewReader(test.body)) + r.RemoteAddr = "127.0.0.1:45000" + r.Header.Set("Content-Type", "application/json") + r.Header.Set("Authorization", "Bearer "+test.token) + if test.key != "" { + r.Header.Set("Idempotency-Key", test.key) + } + w := httptest.NewRecorder() + api.ServeHTTP(w, r) + if w.Code != test.status { + t.Fatalf("status=%d body=%s", w.Code, w.Body) + } + }) + } + if controller.stops != 0 { + t.Fatal("rejected HTTP request reached cleanup") + } + r := httptest.NewRequest(http.MethodPost, "http://127.0.0.1:8765"+path, strings.NewReader(valid)) + r.RemoteAddr = "127.0.0.1:45000" + r.Header.Set("Content-Type", "application/json") + r.Header.Set("Authorization", "Bearer "+testControlToken) + r.Header.Set("Idempotency-Key", "application-stop-exact-job") + w := httptest.NewRecorder() + api.ServeHTTP(w, r) + if w.Code != 200 || controller.stops != 1 || controller.request.ExpectedRunID != "original-run" || controller.request.OperationKey != "application-stop-exact-job" { + t.Fatalf("stop dispatch status=%d input=%+v body=%s", w.Code, controller.request, w.Body) + } + readonly, err := New(f.store, Config{AccessToken: testAccessToken, ControlToken: testControlToken, ThreadApplicationServiceController: controller}) + if err != nil { + t.Fatal(err) + } + w = httptest.NewRecorder() + readonly.ServeHTTP(w, r) + if w.Code != 404 || controller.stops != 1 { + t.Fatalf("disabled gate reached stop: %d calls=%d", w.Code, controller.stops) + } + get := httptest.NewRequest(http.MethodGet, "http://127.0.0.1:8765/api/v1/threads/exact-thread/application-services", nil) + get.RemoteAddr = "127.0.0.1:45000" + get.Header.Set("Authorization", "Bearer "+testAccessToken) + w = httptest.NewRecorder() + readonly.ServeHTTP(w, get) + if w.Code != 200 || strings.Contains(w.Body.String(), `"can_stop":true`) { + t.Fatalf("read-only metadata projected cleanup: %d %s", w.Code, w.Body) + } +} diff --git a/internal/runner/command_runtime_service.go b/internal/runner/command_runtime_service.go new file mode 100644 index 00000000..fe548704 --- /dev/null +++ b/internal/runner/command_runtime_service.go @@ -0,0 +1,125 @@ +package runner + +import ( + "context" + "strings" + "time" + + "cyberagent-workbench/internal/commandruntimeadapter" +) + +// CommandRuntimeJobIdentity is a metadata-only, private binding to one owned +// Job. It carries no output, intent, executable path, PID, or process handle. +type CommandRuntimeJobIdentity struct { + ID, OperationDigest, RequestFingerprint, InvocationID string + RunID, MissionID, SessionID, WorkspaceID, RootAgentID string + WorkspaceRootSHA256, SpecFingerprint, OwnerID string + OwnerGeneration int64 + Adapter commandruntimeadapter.Identity +} + +func CommandRuntimeIdentity(job CommandRuntimeJob) CommandRuntimeJobIdentity { + return CommandRuntimeJobIdentity{ID: job.ID, OperationDigest: job.OperationDigest, + RequestFingerprint: job.RequestFingerprint, InvocationID: job.InvocationID, + RunID: job.RunID, MissionID: job.MissionID, SessionID: job.SessionID, + WorkspaceID: job.WorkspaceID, RootAgentID: job.RootAgentID, + WorkspaceRootSHA256: job.WorkspaceRootSHA256, SpecFingerprint: job.SpecFingerprint, + OwnerID: job.OwnerID, OwnerGeneration: job.OwnerGeneration, Adapter: job.Adapter} +} + +type CommandRuntimeServiceMetadata struct { + Identity CommandRuntimeJobIdentity + State CommandRuntimeJobState + ExitCode *int + CreatedAt time.Time + StartedAt, CompletedAt *time.Time +} + +// ReadOwnedCommandRuntimeServiceState is deliberately narrower than Get: the +// host must first authorize the complete durable tuple through its Thread. +func (m *CommandRuntimeManager) ReadOwnedCommandRuntimeServiceState(ctx context.Context, + identity CommandRuntimeJobIdentity, +) (CommandRuntimeJobSnapshot, bool, error) { + if ctx == nil || ctx.Err() != nil || m == nil || identity.ID == "" { + return CommandRuntimeJobSnapshot{}, false, ErrCommandRuntimeBoundary + } + entry := m.entry(identity.ID) + if entry == nil { + return CommandRuntimeJobSnapshot{}, false, nil + } + entry.mu.Lock() + defer entry.mu.Unlock() + if identity != CommandRuntimeIdentity(entry.record) || identity.OwnerID != m.ownerID || + identity.OwnerGeneration != m.ownerGeneration || !identity.Adapter.SameBackend(m.adapter) { + return CommandRuntimeJobSnapshot{}, false, ErrCommandRuntimeBoundary + } + return ProjectCommandRuntimeJob(entry.record), true, nil +} + +// The retained prefix belongs to the existing artifact-limit capture. This +// bounded read lets callers recover early address evidence after ring eviction, +// without adding another output buffer or any readiness state. +func (m *CommandRuntimeManager) ReadOwnedCommandRuntimeServicePrefix(ctx context.Context, + identity CommandRuntimeJobIdentity, maxBytes int, +) (string, string, bool, error) { + if ctx == nil || ctx.Err() != nil || m == nil || identity.ID == "" || maxBytes < 1 || maxBytes > MaxCommandRuntimeOutputRead/2 { + return "", "", false, ErrCommandRuntimeBoundary + } + entry := m.entry(identity.ID) + if entry == nil { + return "", "", false, nil + } + entry.mu.Lock() + defer entry.mu.Unlock() + if identity != CommandRuntimeIdentity(entry.record) || identity.OwnerID != m.ownerID || identity.OwnerGeneration != m.ownerGeneration || !identity.Adapter.SameBackend(m.adapter) { + return "", "", false, ErrCommandRuntimeBoundary + } + prefix := func(value string) string { + if len(value) > maxBytes { + value = strings.ToValidUTF8(value[:maxBytes], "") + } + return value + } + return prefix(entry.record.Stdout), prefix(entry.record.Stderr), true, nil +} + +// CancelOwnedCommandRuntimeServiceJob only cleans up an exact process-local +// owner. Repeated cancellation observes stopping rather than escalating to Kill. +// It never adopts a persisted PID or turns a cold record into process authority. +func (m *CommandRuntimeManager) CancelOwnedCommandRuntimeServiceJob(ctx context.Context, + job CommandRuntimeJob, +) (CommandRuntimeJobSnapshot, bool, error) { + if ctx == nil || ctx.Err() != nil || m == nil || job.Validate() != nil { + return CommandRuntimeJobSnapshot{}, false, ErrCommandRuntimeBoundary + } + entry := m.entry(job.ID) + if entry == nil { + if job.State.Terminal() && job.TreeReaped { + return ProjectCommandRuntimeJob(job), true, nil + } + return ProjectCommandRuntimeJob(job), false, ErrCommandRuntimeUncertain + } + entry.mu.Lock() + identity := CommandRuntimeIdentity(job) + if identity != CommandRuntimeIdentity(entry.record) || identity.OwnerID != m.ownerID || + identity.OwnerGeneration != m.ownerGeneration || !identity.Adapter.SameBackend(m.adapter) { + entry.mu.Unlock() + return CommandRuntimeJobSnapshot{}, false, ErrCommandRuntimeBoundary + } + if entry.record.State.Terminal() && !entry.record.TreeReaped { + result := ProjectCommandRuntimeJob(entry.record) + entry.mu.Unlock() + return result, false, ErrCommandRuntimeUncertain + } + if entry.record.State.Terminal() || entry.desired != "" { + result := ProjectCommandRuntimeJob(entry.record) + entry.mu.Unlock() + return result, true, nil + } + entry.desired = CommandRuntimeJobCancelled + entry.record.State = CommandRuntimeJobStopping + result := ProjectCommandRuntimeJob(entry.record) + entry.mu.Unlock() + entry.signal() + return result, false, entry.process.Cancel(0) +} diff --git a/internal/runner/command_runtime_service_test.go b/internal/runner/command_runtime_service_test.go new file mode 100644 index 00000000..9cb16167 --- /dev/null +++ b/internal/runner/command_runtime_service_test.go @@ -0,0 +1,99 @@ +package runner + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" +) + +type serviceCancelProcess struct { + *commandRuntimeFakeProcess + cancels, kills atomic.Int32 +} + +func (p *serviceCancelProcess) Cancel(_ time.Duration) error { p.cancels.Add(1); return nil } +func (p *serviceCancelProcess) Kill() error { + p.kills.Add(1) + return p.commandRuntimeFakeProcess.Kill() +} + +type serviceCancelStarter struct{ process *serviceCancelProcess } + +func (*serviceCancelStarter) Name() string { return "service-cancel-fixture" } +func (*serviceCancelStarter) Available() bool { return true } +func (s *serviceCancelStarter) Start(context.Context, CommandRuntimeScope, CommandRuntimeResolvedSpec) (commandRuntimeProcess, error) { + return s.process, nil +} + +func TestThreadApplicationServiceCancellationNeverEscalatesOrAdoptsColdOwner(t *testing.T) { + st := newCommandRuntimeMemoryStore() + process := &serviceCancelProcess{commandRuntimeFakeProcess: newCommandRuntimeFakeProcess()} + starter := &serviceCancelStarter{process: process} + manager, err := NewCommandRuntimeManager(st, starter, "service-owner") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = manager.Shutdown(context.Background()) }) + snapshot, _, err := manager.Start(t.Context(), commandRuntimeTestRequest(manager, 60_000)) + if err != nil { + t.Fatal(err) + } + job, err := st.GetCommandRuntimeJob(t.Context(), snapshot.ID) + if err != nil { + t.Fatal(err) + } + wrong := job + wrong.SessionID = "another-session" + if _, _, err := manager.CancelOwnedCommandRuntimeServiceJob(t.Context(), wrong); err == nil { + t.Fatal("wrong durable tuple can cancel") + } + cold, err := NewCommandRuntimeManager(st, starter, "cold-service-owner") + if err != nil { + t.Fatal(err) + } + if _, _, err := cold.CancelOwnedCommandRuntimeServiceJob(t.Context(), job); err == nil { + t.Fatal("cold owner adopted durable process") + } + var wg sync.WaitGroup + for range 16 { + wg.Add(1) + go func() { + defer wg.Done() + value, _, err := manager.CancelOwnedCommandRuntimeServiceJob(t.Context(), job) + if err != nil || value.State != CommandRuntimeJobStopping { + t.Errorf("concurrent cancel=%+v %v", value, err) + } + }() + } + wg.Wait() + if process.cancels.Load() != 1 || process.kills.Load() != 0 { + t.Fatalf("repeated cancel escalated: cancels=%d kills=%d", process.cancels.Load(), process.kills.Load()) + } + process.finish(125) + terminal, _ := waitCommandRuntimeTerminal(t, manager, job.ID) + job, err = st.GetCommandRuntimeJob(t.Context(), job.ID) + if err != nil { + t.Fatal(err) + } + if terminal.State != CommandRuntimeJobCancelled || !terminal.TreeReaped { + t.Fatalf("terminal cleanup=%+v", terminal) + } + if replay, replayed, err := cold.CancelOwnedCommandRuntimeServiceJob(t.Context(), job); err != nil || !replayed || replay.State != CommandRuntimeJobCancelled { + t.Fatalf("cold terminal replay=%+v %t %v", replay, replayed, err) + } + unfinishedRecord := job + unfinishedRecord.TreeReaped = false + unfinishedOwner := &CommandRuntimeManager{ + ownerID: job.OwnerID, ownerGeneration: job.OwnerGeneration, adapter: job.Adapter, + entries: map[string]*commandRuntimeEntry{job.ID: {record: unfinishedRecord, process: process}}, + } + if snapshot, replayed, err := unfinishedOwner.CancelOwnedCommandRuntimeServiceJob(t.Context(), job); !errors.Is(err, ErrCommandRuntimeUncertain) || replayed || snapshot.TreeReaped { + t.Fatalf("terminal owner without reaping was replayed: %+v %t %v", snapshot, replayed, err) + } + if process.cancels.Load() != 1 || process.kills.Load() != 0 { + t.Fatal("terminal replay signalled a process") + } +} diff --git a/internal/store/thread_application_services.go b/internal/store/thread_application_services.go new file mode 100644 index 00000000..6ea6e232 --- /dev/null +++ b/internal/store/thread_application_services.go @@ -0,0 +1,129 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + + "cyberagent-workbench/internal/apperror" + "cyberagent-workbench/internal/domain" + "cyberagent-workbench/internal/runmutation" + "cyberagent-workbench/internal/runner" + "cyberagent-workbench/internal/toolgateway" +) + +// ListThreadCommandRuntimeServiceMetadata does not read command intent, output, +// environment values, paths, or process IDs. The extra row proves truncation. +func (s *SQLiteStore) ListThreadCommandRuntimeServiceMetadata(ctx context.Context, + threadID string, limit int, +) ([]runner.CommandRuntimeServiceMetadata, error) { + if !domain.ValidAgentID(threadID) || limit < 1 || limit > 51 { + return nil, apperror.New(apperror.CodeInvalidArgument, "Thread service page is invalid") + } + rows, err := s.db.QueryContext(ctx, `SELECT job.id,job.operation_digest, + job.request_fingerprint,job.invocation_id,job.run_id,job.mission_id, + job.session_id,job.workspace_id,job.root_agent_id,job.workspace_root_sha256, + job.spec_fingerprint,job.owner_id,job.owner_generation, + job.adapter_kind,job.adapter_backend,job.adapter_backend_identity,job.adapter_generation, + job.adapter_isolation_grade,job.adapter_network_policy,job.adapter_credential_policy, + job.state,job.exit_code,job.created_at,job.started_at,job.completed_at + FROM command_runtime_jobs job JOIN thread_runs binding ON binding.run_id=job.run_id + WHERE binding.thread_id=? ORDER BY job.created_at DESC,job.id LIMIT ?`, threadID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + values := make([]runner.CommandRuntimeServiceMetadata, 0, limit) + for rows.Next() { + var value runner.CommandRuntimeServiceMetadata + id := &value.Identity + var exitCode sql.NullInt64 + var created string + var started, completed sql.NullString + err := rows.Scan(&id.ID, &id.OperationDigest, &id.RequestFingerprint, &id.InvocationID, + &id.RunID, &id.MissionID, &id.SessionID, &id.WorkspaceID, &id.RootAgentID, + &id.WorkspaceRootSHA256, &id.SpecFingerprint, &id.OwnerID, &id.OwnerGeneration, + &id.Adapter.Kind, &id.Adapter.Backend, &id.Adapter.BackendIdentity, &id.Adapter.Generation, + &id.Adapter.IsolationGrade, &id.Adapter.NetworkPolicy, &id.Adapter.CredentialPolicy, + &value.State, &exitCode, &created, &started, &completed) + if err != nil { + return nil, err + } + if exitCode.Valid { + code := int(exitCode.Int64) + value.ExitCode = &code + } + value.CreatedAt, value.StartedAt, value.CompletedAt = parseTS(created), parseNullableTS(started), parseNullableTS(completed) + values = append(values, value) + } + return values, rows.Err() +} + +// FindThreadCommandRuntimeStartCall proves source by the existing actor ledger +// and deterministic operation digest. InvocationID is a budget charge identity, +// not a Supervisor call ID. Missing, ambiguous, or truncated source stays unknown. +func (s *SQLiteStore) FindThreadCommandRuntimeStartCall(ctx context.Context, + threadID string, identity runner.CommandRuntimeJobIdentity, +) (domain.SupervisorToolCall, bool, error) { + if !domain.ValidAgentID(threadID) || !domain.ValidAgentID(identity.ID) { + return domain.SupervisorToolCall{}, false, apperror.New(apperror.CodeInvalidArgument, "Thread service source is invalid") + } + rows, err := s.db.QueryContext(ctx, `SELECT call.call_id,call.run_id,call.turn, + call.attempt_id,call.payload_json FROM run_supervisor_tool_calls call + JOIN run_supervisor_tool_call_agents source ON source.run_id=call.run_id + AND source.turn=call.turn AND source.attempt_id=call.attempt_id AND source.call_id=call.call_id + JOIN command_runtime_job_agents actor ON actor.run_id=source.run_id + AND actor.agent_id=source.agent_id AND actor.agent_attempt_id=source.agent_attempt_id + JOIN command_runtime_jobs job ON job.id=actor.job_id AND job.run_id=actor.run_id + JOIN thread_runs binding ON binding.run_id=job.run_id + WHERE binding.thread_id=? AND job.id=? AND actor.agent_id=job.root_agent_id + AND call.tool_name='command_runtime' + AND json_extract(call.payload_json,'$.action')='start' + ORDER BY call.turn,call.round,call.position LIMIT ?`, + threadID, identity.ID, domain.MaxSupervisorToolRounds*domain.MaxSupervisorToolCallsPerRound+1) + if err != nil { + return domain.SupervisorToolCall{}, false, err + } + var candidates []domain.SupervisorToolCall + for rows.Next() { + var call domain.SupervisorToolCall + if err = rows.Scan(&call.CallID, &call.RunID, &call.Turn, &call.AttemptID, &call.PayloadJSON); err != nil { + break + } + candidates = append(candidates, call) + } + readErr := rows.Err() + _ = rows.Close() + if err != nil || readErr != nil { + return domain.SupervisorToolCall{}, false, errors.Join(err, readErr) + } + if len(candidates) > domain.MaxSupervisorToolRounds*domain.MaxSupervisorToolCallsPerRound { + return domain.SupervisorToolCall{}, false, nil + } + var matched domain.SupervisorToolCall + count := 0 + for _, candidate := range candidates { + input, canonical, err := toolgateway.NormalizeCommandRuntimePayload(json.RawMessage(candidate.PayloadJSON)) + if err != nil || input.Action != toolgateway.CommandRuntimeActionStart || string(canonical) != candidate.PayloadJSON { + continue + } + key := runmutation.SupervisorToolOperationKey(candidate.RunID, candidate.Turn, string(toolgateway.CommandRuntimeTool), candidate.PayloadJSON) + digest, jobID := runner.CommandRuntimeOperationIdentity(candidate.RunID, key) + if candidate.RunID == identity.RunID && digest == identity.OperationDigest && jobID == identity.ID { + matched = candidate + count++ + } + } + if count != 1 { + return domain.SupervisorToolCall{}, false, nil + } + call, err := s.GetThreadSupervisorToolCall(ctx, threadID, matched.CallID) + if err != nil { + return domain.SupervisorToolCall{}, false, err + } + if call.RunID != matched.RunID || call.AttemptID != matched.AttemptID || call.Turn != matched.Turn || call.PayloadJSON != matched.PayloadJSON { + return domain.SupervisorToolCall{}, false, nil + } + return call, true, nil +} diff --git a/protocols/registry.json b/protocols/registry.json index d19fb17a..8e4ecc78 100644 --- a/protocols/registry.json +++ b/protocols/registry.json @@ -1549,6 +1549,7 @@ "thread_activity_artifact.v1", "thread_activity_detail.v2", "thread_activity_summary.v1", + "thread_application_services.v1", "thread_creation.v1", "thread_execution.v1", "thread_export.v1", diff --git a/web/src/api/application-services.test.ts b/web/src/api/application-services.test.ts new file mode 100644 index 00000000..60f93796 --- /dev/null +++ b/web/src/api/application-services.test.ts @@ -0,0 +1,117 @@ +import { APIClient } from "./client"; + +const version = "thread_application_services.v1"; +const created = "2026-10-06T00:00:00Z"; +const service = { thread_id: "thread-1", run_id: "run-1", job_id: "job-1", state: "running", + created_at: created, can_stop: true, source_call_id: "call-1", source_message_id: "message-1", source_turn: 2 }; +const text = "Listening on http://127.0.0.1:3000/\n"; +const output = { stdout: text, stderr: "", base_cursor: 0, next_cursor: text.length, end_cursor: text.length, + dropped: false, available: true }; +const candidate = { url: "http://127.0.0.1:3000/", source: "command_output", verified: false }; +const detail = () => ({ version, service: { ...service }, output: structuredClone(output), candidate_urls: [{ ...candidate }] }); +const client = () => new APIClient("read-token", "/api/v1", "control-token"); +const respond = (data: unknown, status = 200) => vi.stubGlobal("fetch", vi.fn().mockImplementation(async () => + new Response(JSON.stringify({ version: "api.v1", request_id: "request-1", ...(status === 200 ? { data } : { error: data }) }), + { status, headers: { "Content-Type": "application/json" } }))); +afterEach(() => vi.unstubAllGlobals()); + +it("reads bounded service metadata with read-only credentials and retains exact request provenance", async () => { + respond({ version, thread_id: "thread-1", services: [service], has_more: true }); + const readOnly = new APIClient("read-token"); + const result = await readOnly.listThreadApplicationServices("thread-1"); + expect(result.services[0]).toEqual(service); + expect(result.has_more).toBe(true); + expect(fetch).toHaveBeenCalledWith(expect.stringContaining("/threads/thread-1/application-services?limit=20"), + expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer read-token" }) })); +}); + +it("returns separately bounded original output and multiple unverified addresses", async () => { + const response = detail(); + response.candidate_urls.push({ ...candidate, url: "http://[::1]:3001/alternate" }); + respond(response); + const controller = new AbortController(); + expect(await client().getThreadApplicationService("thread-1", "job-1", controller.signal)).toEqual(response); + expect(fetch).toHaveBeenCalledWith(expect.stringContaining("/threads/thread-1/application-services/job-1"), + expect.objectContaining({ signal: controller.signal })); +}); + +it.each([ + { ...service, thread_id: "thread-other" }, + { ...service, state: "ready" }, + { ...service, state: "failed", can_stop: true }, + { ...service, source_call_id: undefined }, + { ...service, pid: 123 }, +])("rejects unbound or authority-bearing service metadata: %j", async (row) => { + respond({ version, thread_id: "thread-1", services: [row], has_more: false }); + await expect(client().listThreadApplicationServices("thread-1")).rejects.toMatchObject({ code: "INVALID_RESPONSE" }); +}); + +it("rejects duplicate Job identities instead of letting selection point at two rows", async () => { + respond({ version, thread_id: "thread-1", services: [service, { ...service, run_id: "run-other" }], has_more: false }); + await expect(client().listThreadApplicationServices("thread-1")).rejects.toMatchObject({ code: "INVALID_RESPONSE" }); +}); + +it.each(["thread_id", "job_id"])("rejects a detail response from another %s", async (field) => { + respond({ ...detail(), service: { ...service, [field]: "other-identity" } }); + await expect(client().getThreadApplicationService("thread-1", "job-1")).rejects.toMatchObject({ code: "INVALID_RESPONSE" }); +}); + +it.each(["https://example.com/", "http://127.0.0.1:3000/?token=secret", "http://user:password@127.0.0.1/", + "http://127.0.0.1/#secret", "http://2130706433:3000/", "javascript:alert(1)"])("rejects a noncanonical or credential-bearing output address: %s", async (url) => { + respond({ ...detail(), candidate_urls: [{ ...candidate, url }] }); + await expect(client().getThreadApplicationService("thread-1", "job-1")).rejects.toMatchObject({ code: "INVALID_RESPONSE" }); +}); + +it("does not trust an output URL as verified application readiness", async () => { + respond({ ...detail(), candidate_urls: [{ ...candidate, verified: true }] }); + await expect(client().getThreadApplicationService("thread-1", "job-1")).rejects.toMatchObject({ code: "INVALID_RESPONSE" }); +}); + +it("rejects malformed output cursors and contradictory unavailable output", async () => { + for (const invalidOutput of [{ ...output, next_cursor: -1 }, { ...output, end_cursor: 0 }, + { ...output, available: false }, { ...output, base_cursor: text.length + 1 }]) { + respond({ ...detail(), output: invalidOutput }); + await expect(client().getThreadApplicationService("thread-1", "job-1")).rejects.toMatchObject({ code: "INVALID_RESPONSE" }); + } +}); + +it("counts UTF-8 bytes when bounding retained command output", async () => { + const text = "文".repeat(22_000); + respond({ ...detail(), output: { ...output, next_cursor: 66_000, end_cursor: 66_000, stdout: text } }); + await expect(client().getThreadApplicationService("thread-1", "job-1")).rejects.toMatchObject({ code: "INVALID_RESPONSE" }); +}); + +it("uses exact Thread/Run/Job cleanup with a stable retry key and only the control credential", async () => { + const stopped = { ...service, state: "cancelled", can_stop: false }; + respond({ version, service: stopped, replayed: false }); + await client().stopThreadApplicationService("thread-1", "run-1", "job-1"); + await client().stopThreadApplicationService("thread-1", "run-1", "job-1"); + for (const [url, request] of vi.mocked(fetch).mock.calls) { + expect(String(url)).toContain("/threads/thread-1/application-services/job-1/stop"); + expect(request).toMatchObject({ method: "POST", headers: { + Authorization: "Bearer control-token", "Idempotency-Key": "application-stop-job-1", + } }); + expect(JSON.parse(String(request?.body))).toEqual({ version, expected_run_id: "run-1" }); + } + expect(fetch).toHaveBeenCalledTimes(2); +}); + +it("rejects a stop receipt for a different Run and does not repeat the mutation", async () => { + respond({ version, service: { ...service, state: "cancelled", can_stop: false, run_id: "run-other" }, replayed: false }); + await expect(client().stopThreadApplicationService("thread-1", "run-1", "job-1")).rejects.toMatchObject({ code: "INVALID_RESPONSE" }); + expect(fetch).toHaveBeenCalledTimes(1); +}); + +it("preserves an uncertain stop failure without automatically resending", async () => { + respond({ code: "FAILED_PRECONDITION", message: "Job owner is unavailable" }, 412); + await expect(client().stopThreadApplicationService("thread-1", "run-1", "job-1")).rejects.toMatchObject({ code: "FAILED_PRECONDITION" }); + expect(fetch).toHaveBeenCalledTimes(1); +}); + +it("requires control capability before issuing a service stop", async () => { + respond(null); + await expect(new APIClient("read-token").stopThreadApplicationService("thread-1", "run-1", "job-1")).rejects.toThrow("control access"); + await expect(new APIClient("read-token", "/api/v1", "control-token", { runExecutionEnabled: false }) + .stopThreadApplicationService("thread-1", "run-1", "job-1")).rejects.toThrow("control access"); + expect(fetch).not.toHaveBeenCalled(); +}); diff --git a/web/src/api/application-services.ts b/web/src/api/application-services.ts new file mode 100644 index 00000000..8156efc8 --- /dev/null +++ b/web/src/api/application-services.ts @@ -0,0 +1,78 @@ +import { APIRequestError } from "./client"; +import type { ThreadApplicationServiceDetailView, ThreadApplicationServicesView, + ThreadApplicationServiceStopView, ThreadApplicationServiceView } from "./types"; + +const version = "thread_application_services.v1"; +const states = new Set(["prepared", "running", "stopping", "completed", "failed", "timed_out", "cancelled", "killed", "interrupted"]); +const activeStates = new Set(["prepared", "running", "stopping"]); +const record = (value: unknown): value is Record => + value !== null && typeof value === "object" && !Array.isArray(value); +const keys = (value: unknown, required: string[], optional: string[] = []): value is Record => + record(value) && required.every((key) => Object.hasOwn(value, key)) && + Object.keys(value).every((key) => required.includes(key) || optional.includes(key)); +export const applicationServiceIdentity = (value: unknown): value is string => + typeof value === "string" && value.length > 0 && value.length <= 256 && value.trim() === value && !/[\u0000-\u001f\u007f/\\]/u.test(value); +const integer = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value); +const cursor = (value: unknown): value is number => integer(value) && value >= 0; +const date = (value: unknown) => typeof value === "string" && Number.isFinite(Date.parse(value)); +function invalid(): never { throw new APIRequestError("应用服务结果无法验证,请重新读取任务状态。", "INVALID_RESPONSE", 502); } + +function service(value: unknown, threadID: string, jobID?: string, runID?: string): ThreadApplicationServiceView { + if (!keys(value, ["thread_id", "run_id", "job_id", "state", "created_at", "can_stop"], + ["exit_code", "started_at", "completed_at", "source_call_id", "source_message_id", "source_turn"]) || + value.thread_id !== threadID || !applicationServiceIdentity(value.run_id) || !applicationServiceIdentity(value.job_id) || + (jobID !== undefined && value.job_id !== jobID) || (runID !== undefined && value.run_id !== runID) || + typeof value.state !== "string" || !states.has(value.state) || !date(value.created_at) || + typeof value.can_stop !== "boolean" || (value.can_stop && !activeStates.has(value.state)) || + (value.exit_code !== undefined && !integer(value.exit_code)) || + (value.started_at !== undefined && !date(value.started_at)) || + (value.completed_at !== undefined && !date(value.completed_at)) || + (value.source_call_id !== undefined && !applicationServiceIdentity(value.source_call_id)) || + (value.source_message_id !== undefined && (!applicationServiceIdentity(value.source_message_id) || !value.source_call_id)) || + (value.source_turn !== undefined && (!integer(value.source_turn) || value.source_turn <= 0 || !value.source_call_id))) invalid(); + return value as ThreadApplicationServiceView; +} + +export function parseThreadApplicationServices(value: unknown, threadID: string): ThreadApplicationServicesView { + if (!keys(value, ["version", "thread_id", "services", "has_more"]) || value.version !== version || + value.thread_id !== threadID || typeof value.has_more !== "boolean" || !Array.isArray(value.services) || value.services.length > 50) invalid(); + const services = value.services.map((item) => service(item, threadID)); + if (new Set(services.map((item) => item.job_id)).size !== services.length) invalid(); + return { ...value, services } as ThreadApplicationServicesView; +} + +function candidateURL(value: unknown): boolean { + if (typeof value !== "string" || value.length > 2048 || value.trim() !== value || /[\u0000-\u0020\u007f\\]/u.test(value) || + !/^https?:\/\/(?:127\.0\.0\.1|\[::1\])(?::\d{1,5})?(?:\/|$)/u.test(value)) return false; + try { + const url = new URL(value); + return (url.protocol === "http:" || url.protocol === "https:") && ["127.0.0.1", "[::1]"].includes(url.hostname) && + !url.username && !url.password && !url.search && !url.hash; + } catch { return false; } +} + +export function parseThreadApplicationService(value: unknown, threadID: string, jobID: string): ThreadApplicationServiceDetailView { + if (!keys(value, ["version", "service", "output", "candidate_urls"]) || value.version !== version) invalid(); + service(value.service, threadID, jobID); + const output = value.output; + if (!keys(output, ["stdout", "stderr", "base_cursor", "next_cursor", "end_cursor", "dropped", "available"], ["truncation_reason"]) || + !cursor(output.base_cursor) || !cursor(output.next_cursor) || !cursor(output.end_cursor) || + output.base_cursor > output.next_cursor || output.next_cursor > output.end_cursor || typeof output.dropped !== "boolean" || + typeof output.available !== "boolean" || typeof output.stdout !== "string" || typeof output.stderr !== "string" || + (output.truncation_reason !== undefined && (typeof output.truncation_reason !== "string" || output.truncation_reason.length > 256))) invalid(); + const encoder = new TextEncoder(); + if (encoder.encode(output.stdout).length + encoder.encode(output.stderr).length > 65536) invalid(); + if (!Array.isArray(value.candidate_urls) || value.candidate_urls.length > 8 || + value.candidate_urls.some((candidate) => !keys(candidate, ["url", "source", "verified"]) || + !candidateURL(candidate.url) || candidate.source !== "command_output" || candidate.verified !== false) || + new Set(value.candidate_urls.map((candidate) => candidate.url)).size !== value.candidate_urls.length || + (!output.available && (output.stdout.length > 0 || output.stderr.length > 0 || value.candidate_urls.length > 0))) invalid(); + return value as ThreadApplicationServiceDetailView; +} + +export function parseThreadApplicationServiceStop(value: unknown, threadID: string, runID: string, jobID: string): ThreadApplicationServiceStopView { + if (!keys(value, ["version", "service", "replayed"]) || value.version !== version || typeof value.replayed !== "boolean") invalid(); + const stopped = service(value.service, threadID, jobID, runID); + if (stopped.state === "prepared" || stopped.state === "running") invalid(); + return value as ThreadApplicationServiceStopView; +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 006c2cf4..6794fa13 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1,4 +1,6 @@ import { consumeSSE } from "./sse"; +import { applicationServiceIdentity, parseThreadApplicationServices, parseThreadApplicationService, + parseThreadApplicationServiceStop } from "./application-services"; import type { ProviderModelDiscoveryRequestView, ProviderModelDiscoveryView } from "./types"; import { acceptedImageTypes, maximumImageBytes, validImageAttachment, validImageAttachments, type WorkspaceImageAttachment } from "./image-attachments"; import { maximumFileBytes, validFileAttachment, validFileAttachments, type WorkspaceFileAttachment } from "./file-attachments"; @@ -6412,6 +6414,33 @@ export class APIClient { `/runs/${encodeURIComponent(runID)}/capability-readiness`, {}, signal), runID); } + async listThreadApplicationServices(threadID: string, signal?: AbortSignal, limit = 20) { + if (!applicationServiceIdentity(threadID) || !Number.isInteger(limit) || limit < 1 || limit > 50) { + throw new Error("A valid Thread and bounded application service limit are required"); + } + return parseThreadApplicationServices(await this.get( + `/threads/${encodeURIComponent(threadID)}/application-services`, { limit }, signal), threadID); + } + + async getThreadApplicationService(threadID: string, jobID: string, signal?: AbortSignal) { + if (!applicationServiceIdentity(threadID) || !applicationServiceIdentity(jobID)) { + throw new Error("An exact Thread and application Job are required"); + } + return parseThreadApplicationService(await this.get( + `/threads/${encodeURIComponent(threadID)}/application-services/${encodeURIComponent(jobID)}`, {}, signal), threadID, jobID); + } + + async stopThreadApplicationService(threadID: string, runID: string, jobID: string, signal?: AbortSignal) { + if (!this.hasRunExecution || !applicationServiceIdentity(threadID) || !applicationServiceIdentity(runID) || + !applicationServiceIdentity(jobID)) { + throw new Error("Application service cleanup requires control access and an exact Thread, Run and Job"); + } + return parseThreadApplicationServiceStop(await this.sendControl( + `/threads/${encodeURIComponent(threadID)}/application-services/${encodeURIComponent(jobID)}/stop`, + { version: "thread_application_services.v1", expected_run_id: runID }, + `application-stop-${jobID}`, signal), threadID, runID, jobID); + } + async getFullCDPSession(runID: string, signal?: AbortSignal): Promise { if (!this.hasFullCDPSessionControl || !boundedIdentity(runID) || runID.trim() !== runID) { diff --git a/web/src/api/schema.d.ts b/web/src/api/schema.d.ts index 85778618..fc884169 100644 --- a/web/src/api/schema.d.ts +++ b/web/src/api/schema.d.ts @@ -3664,6 +3664,66 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/threads/{thread_id}/application-services": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List task command service candidates + * @description Bounded metadata from Jobs in every Run belonging to this Thread. Source message identity is present only when an exact background start and operator delivery are proven. Does not read command output, start work, probe URLs, or assert readiness. + */ + get: operations["listThreadApplicationServices"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/threads/{thread_id}/application-services/{job_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Read one exact task command service + * @description Reads an exact Thread-owned Run/Job and a bounded sanitized output tail. Loopback URLs are unverified command-output candidates; no network or readiness check is performed. + */ + get: operations["getThreadApplicationService"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/threads/{thread_id}/application-services/{job_id}/stop": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Stop one exact owned task command service + * @description Cleanup only under Run execution control. Requires the expected original Run and the fixed application-stop-{job_id} Idempotency-Key. Only this process's exact Job owner may cancel; repeated stopping observes its fact without upgrading to Kill. Reaped terminal Jobs replay and cold active Jobs fail closed. No persisted PID is signalled. + */ + post: operations["stopThreadApplicationService"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/threads/{thread_id}/archive": { parameters: { query?: never; @@ -11391,6 +11451,70 @@ export interface components { source_count: number; sources: components["schemas"]["ThreadActivitySearchSourceView"][]; }; + ThreadApplicationServiceDetailView: { + candidate_urls: components["schemas"]["ThreadApplicationServiceURLView"][]; + output: components["schemas"]["ThreadApplicationServiceOutputView"]; + service: components["schemas"]["ThreadApplicationServiceView"]; + /** @enum {string} */ + version: "thread_application_services.v1"; + }; + ThreadApplicationServiceOutputView: { + available: boolean; + /** Format: int64 */ + base_cursor: number; + dropped: boolean; + /** Format: int64 */ + end_cursor: number; + /** Format: int64 */ + next_cursor: number; + stderr: string; + stdout: string; + truncation_reason?: string; + }; + ThreadApplicationServiceStopRequestView: { + expected_run_id: string; + /** @enum {string} */ + version: "thread_application_services.v1"; + }; + ThreadApplicationServiceStopView: { + replayed: boolean; + service: components["schemas"]["ThreadApplicationServiceView"]; + /** @enum {string} */ + version: "thread_application_services.v1"; + }; + ThreadApplicationServiceURLView: { + /** @enum {string} */ + source: "command_output"; + url: string; + verified: boolean; + }; + ThreadApplicationServiceView: { + can_stop: boolean; + /** Format: date-time */ + completed_at?: string; + /** Format: date-time */ + created_at: string; + /** Format: int32 */ + exit_code?: number; + job_id: string; + run_id: string; + source_call_id?: string; + source_message_id?: string; + /** Format: int32 */ + source_turn?: number; + /** Format: date-time */ + started_at?: string; + /** @enum {string} */ + state: "prepared" | "running" | "stopping" | "completed" | "failed" | "timed_out" | "cancelled" | "killed" | "interrupted"; + thread_id: string; + }; + ThreadApplicationServicesView: { + has_more: boolean; + services: components["schemas"]["ThreadApplicationServiceView"][]; + thread_id: string; + /** @enum {string} */ + version: "thread_application_services.v1"; + }; ThreadCreationControlRequestView: { allowed_targets?: string[]; goal: string; @@ -21991,6 +22115,133 @@ export interface operations { 504: components["responses"]["GatewayTimeout"]; }; }; + listThreadApplicationServices: { + parameters: { + query?: { + limit?: number; + }; + header?: never; + path: { + /** @description Thread identity */ + thread_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful read */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: components["schemas"]["ThreadApplicationServicesView"]; + request_id: string; + /** @constant */ + version: "api.v1"; + }; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 414: components["responses"]["RequestTooLarge"]; + 429: components["responses"]["ResourceExhausted"]; + 500: components["responses"]["InternalError"]; + 503: components["responses"]["Unavailable"]; + 504: components["responses"]["GatewayTimeout"]; + }; + }; + getThreadApplicationService: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Thread identity */ + thread_id: string; + /** @description Exact command Job identity */ + job_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful read */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: components["schemas"]["ThreadApplicationServiceDetailView"]; + request_id: string; + /** @constant */ + version: "api.v1"; + }; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 414: components["responses"]["RequestTooLarge"]; + 429: components["responses"]["ResourceExhausted"]; + 500: components["responses"]["InternalError"]; + 503: components["responses"]["Unavailable"]; + 504: components["responses"]["GatewayTimeout"]; + }; + }; + stopThreadApplicationService: { + parameters: { + query?: never; + header: { + "Idempotency-Key": string; + }; + path: { + /** @description Thread identity */ + thread_id: string; + /** @description Exact command Job identity */ + job_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ThreadApplicationServiceStopRequestView"]; + }; + }; + responses: { + /** @description Control request accepted or idempotently replayed */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: components["schemas"]["ThreadApplicationServiceStopView"]; + request_id: string; + /** @constant */ + version: "api.v1"; + }; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 409: components["responses"]["Conflict"]; + 412: components["responses"]["FailedPrecondition"]; + 413: components["responses"]["RequestEntityTooLarge"]; + 414: components["responses"]["RequestTooLarge"]; + 415: components["responses"]["UnsupportedMediaType"]; + 429: components["responses"]["ResourceExhausted"]; + 500: components["responses"]["InternalError"]; + 503: components["responses"]["Unavailable"]; + 504: components["responses"]["GatewayTimeout"]; + }; + }; archiveThread: { parameters: { query?: never; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index a552f71e..b78dd490 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -71,6 +71,10 @@ export type FullCDPSessionCloseRequestView = export type FullCDPSessionControlView = components["schemas"]["FullCDPSessionControlView"]; export type FullCDPSessionView = components["schemas"]["FullCDPSessionView"]; +export type ThreadApplicationServicesView = components["schemas"]["ThreadApplicationServicesView"]; +export type ThreadApplicationServiceView = components["schemas"]["ThreadApplicationServiceView"]; +export type ThreadApplicationServiceDetailView = components["schemas"]["ThreadApplicationServiceDetailView"]; +export type ThreadApplicationServiceStopView = components["schemas"]["ThreadApplicationServiceStopView"]; export type ThreadView = components["schemas"]["ThreadView"]; export type ThreadRunRecoveryView = components["schemas"]["ThreadRunRecoveryView"]; export type ThreadRunRecoveryControlRequestView = diff --git a/web/src/v2/application-preview-request.ts b/web/src/v2/application-preview-request.ts new file mode 100644 index 00000000..c064e14a --- /dev/null +++ b/web/src/v2/application-preview-request.ts @@ -0,0 +1,45 @@ +import type { QueryClient } from "@tanstack/react-query"; + +export const APPLICATION_PREVIEW_START_REQUEST = "请检查当前项目的启动方式,使用受管理的后台命令启动开发服务,等待就绪后给出确切的本机预览地址;保留启动输出和可停止的任务标识。如果启动失败,请报告实际错误。"; + +export interface ApplicationPreviewRequest { + request: string; + phase: "draft" | "submitting" | "accepted" | "failed" | "unconfirmed" | "rejected"; + operationKey?: string; + runID?: string; + messageID?: string; +} + +export const applicationPreviewRequestKey = (threadID: string) => + ["v2", "thread", threadID, "application-preview-request"] as const; +export const applicationServicesQueryKey = (threadID: string) => + ["v2", "thread", threadID, "application-services"] as const; + +// This is a presentation hint for one operator request. Jobs and their source +// messages come from Go; matching this text never establishes process ownership. +export function trackApplicationPreviewSubmission(queries: QueryClient, + input: { threadID: string; content: string; operationKey: string }, + phase: ApplicationPreviewRequest["phase"], + reference?: { runID?: string; messageID?: string }, + bindToDraft = false, +) { + const key = applicationPreviewRequestKey(input.threadID); + const current = queries.getQueryData(key); + if (!current || (current.operationKey !== input.operationKey && + (!bindToDraft || phase !== "submitting" || !["draft", "rejected"].includes(current.phase) || !input.content.includes(current.request)))) return; + queries.setQueryData(key, { + ...current, phase, operationKey: input.operationKey, ...reference, + }); +} + +export function applicationPreviewRequestCopy(request: ApplicationPreviewRequest, hasAssociatedCommand = false): string { + switch (request.phase) { + case "draft": return "启动要求已放回草稿,请确认后发送。"; + case "submitting": return "正在提交启动要求,尚未确认服务是否启动。"; + case "accepted": return hasAssociatedCommand ? "启动要求已受理,已找到关联的任务命令,可查看状态与输出。" + : "启动要求已受理,正在等待受管理的后台服务记录。"; + case "failed": return "启动要求已受理,但本轮执行失败。请查看失败记录和启动输出。"; + case "unconfirmed": return "启动要求的提交结果尚未确认,请核对原提交。"; + case "rejected": return "启动要求未入队,草稿已保留。"; + } +} diff --git a/web/src/v2/components/application-preview.css b/web/src/v2/components/application-preview.css index fd264fef..53350025 100644 --- a/web/src/v2/components/application-preview.css +++ b/web/src/v2/components/application-preview.css @@ -3,6 +3,20 @@ [data-prayu-theme="light"] .v2-app-preview { background:#fff; } .v2-app-preview > header { position:sticky; top:0; z-index:2; display:flex; justify-content:space-between; align-items:center; gap:20px; margin-bottom:18px; padding:8px 0; background:inherit; } .v2-app-preview h2 { font-size:19px; margin:0; } +.v2-app-preview h3 { font-size:16px; margin:0 0 12px; } +.v2-preview-services { padding-bottom:20px; margin-bottom:20px; border-bottom:1px solid var(--v2-border, #d7dbe0); } +.v2-preview-identity { line-height:1.7; overflow-wrap:anywhere; color:var(--v2-muted, #666c73); } +.v2-preview-service-list { list-style:none; margin:12px 0; padding:0; display:grid; gap:8px; } +.v2-preview-service-list button { width:100%; align-items:flex-start; flex-direction:column; text-align:left; overflow-wrap:anywhere; } +.v2-preview-service-list button[aria-pressed="true"], .v2-preview-candidates button[aria-pressed="true"] { border-color:var(--v2-accent, #78a8f8); background:var(--v2-accent-soft, #78a8f815); } +.v2-preview-service-detail { margin-top:12px; padding:14px; border:1px solid var(--v2-border, #d7dbe0); border-radius:8px; } +.v2-preview-output { margin-top:12px; } +.v2-preview-output summary { cursor:pointer; } +.v2-preview-output pre { white-space:pre-wrap; overflow-wrap:anywhere; max-height:320px; overflow:auto; line-height:1.6; font-family:var(--prayu-font-mono, monospace); } +.v2-preview-output pre strong { color:var(--v2-muted, #666c73); } +.v2-preview-candidates { display:flex; flex-wrap:wrap; gap:8px; margin:12px 0; } +.v2-preview-candidates > p { width:100%; margin:0; line-height:1.6; } +.v2-preview-candidates code { overflow-wrap:anywhere; } .v2-app-preview button { display:inline-flex; align-items:center; justify-content:center; gap:6px; padding:8px 12px; border:1px solid var(--v2-border, #d7dbe0); border-radius:8px; background:var(--prayu-panel-strong, #fff); color:inherit; font:inherit; cursor:pointer; } .v2-app-preview button:disabled { opacity:.5; cursor:default; } .v2-preview-address { display:flex; align-items:flex-end; gap:10px; flex-wrap:wrap; } diff --git a/web/src/v2/components/application-preview.test.tsx b/web/src/v2/components/application-preview.test.tsx index 99c832dc..f7057ab9 100644 --- a/web/src/v2/components/application-preview.test.tsx +++ b/web/src/v2/components/application-preview.test.tsx @@ -1,7 +1,9 @@ -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { createRef } from "react"; import type { APIClient } from "../../api/client"; +import type { ThreadApplicationServiceView, ThreadApplicationServiceDetailView } from "../../api/types"; +import type { ApplicationPreviewRequest } from "../application-preview-request"; import type { ApplicationPreview } from "../../api/application-preview"; import { V2ApplicationPreview } from "./application-preview"; import { fullCDPSessionQueryKey } from "./browser-cdp-control"; @@ -15,7 +17,7 @@ const observed: ApplicationPreview = { version: "full_cdp_preview.v1", run_id: " { selector: "#name", tag: "input", type: "text", name: "称呼", disabled: false }, { selector: "#hidden", tag: "input", type: "hidden", name: "内部字段", disabled: false }] } }; -function fixture(cachedClosedSession = false) { +function fixture(cachedClosedSession = false, overrides: Partial = {}, startRequest?: ApplicationPreviewRequest) { let generation = 0; const post = vi.fn(async (path: string) => { if (path.endsWith("preview-action")) throw new Error("响应中断"); @@ -23,19 +25,20 @@ function fixture(cachedClosedSession = false) { return { ...observed, page: { ...observed.page, snapshot_id: String(generation).repeat(64) } }; }); const client = { hasFullCDPSessionControl: true, hasBrowserCDPPermissionControl: true, hasFullCDPDebug: true, - get: vi.fn(async () => ({ execution_permission: { mode: "debug", runtime_gate_available: true, revision: 1 }, + get: vi.fn(async () => ({ run: { id: "run-preview", status: "running" }, execution_permission: { mode: "full", runtime_gate_available: true, revision: 1 }, browser_cdp_permission: { mode: "full_debug", runtime_gate_available: true, revision: 1 } })), getFullCDPSession: vi.fn(async () => ({ session: { run_id: "run-preview", session_id: "browser-preview", state: cachedClosedSession ? "closed" : "ready", target_origin: "http://127.0.0.1:18886", browser: { product: "chrome", channel: "stable" }, process_tree_quiescent: true, profile_cleaned: true } })), postControl: post, downloadVerifiedImage: vi.fn(async () => new Blob(["png"])), closeFullCDPSession: vi.fn(async () => ({ session: { state: "closed", session_id: "browser-preview", process_tree_quiescent: true, profile_cleaned: true } })), + ...overrides, } as unknown as APIClient; const queries = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 5000 }, mutations: { retry: false } } }); if (cachedClosedSession) queries.setQueryData(fullCDPSessionQueryKey("run-preview"), { session: { run_id: "run-preview", session_id: "browser-preview", state: "ready", target_origin: "http://127.0.0.1:18886" }, }); - render( {}} onRequestStart={() => {}} returnFocusRef={createRef()} />); - return { client, post }; + const view = render( {}} onRequestStart={() => {}} startRequest={startRequest} returnFocusRef={createRef()} />); + return { client, post, queries, view }; } beforeEach(() => { vi.stubGlobal("URL", Object.assign(URL, { createObjectURL: vi.fn(() => "blob:verified-preview"), revokeObjectURL: vi.fn() })); @@ -74,7 +77,7 @@ it("sends typed values only to the exact current session and observed selector", it("stops showing the old image when the preview is closed", async () => { const { client } = fixture(); await screen.findByRole("img", { name: "应用页面:独立项目应用" }); - fireEvent.click(screen.getByRole("button", { name: "停止预览" })); + fireEvent.click(screen.getByRole("button", { name: "关闭浏览器" })); await waitFor(() => expect(screen.queryByRole("img")).not.toBeInTheDocument()); expect(client.closeFullCDPSession).toHaveBeenCalledWith("run-preview", expect.objectContaining({ expected_session_id: "browser-preview" }), expect.any(String)); }); @@ -86,3 +89,205 @@ it("rechecks a cached ready session before reading a preview that was closed els expect(screen.queryByRole("img")).not.toBeInTheDocument(); expect(screen.queryByRole("alert")).not.toBeInTheDocument(); }); + +function service(jobID: string, overrides: Partial = {}): ThreadApplicationServiceView { + return { thread_id: "thread-preview", run_id: "run-service", job_id: jobID, state: "running", + created_at: "2026-10-05T00:00:00Z", can_stop: true, ...overrides }; +} + +function serviceDetail(source: ThreadApplicationServiceView): ThreadApplicationServiceDetailView { + return { version: "thread_application_services.v1", service: source, + output: { available: true, stdout: "Local: http://127.0.0.1:3000/\n", + stderr: "Port 3000 was in use. \n", + base_cursor: 0, next_cursor: 20, end_cursor: 20, dropped: false }, + candidate_urls: ["http://127.0.0.1:3000/", "http://127.0.0.1:3001/"].map((url) => + ({ url, source: "command_output", verified: false })) }; +} + +function servicesFixture(initial: ThreadApplicationServiceView[], options?: { + overrides?: Partial; startRequest?: ApplicationPreviewRequest; runStatus?: string; +}) { + let records = initial; + const listThreadApplicationServices = vi.fn(async () => ({ version: "thread_application_services.v1", + thread_id: "thread-preview", services: records, has_more: false })); + const getThreadApplicationService = vi.fn(async (_threadID: string, jobID: string) => + serviceDetail(records.find((record) => record.job_id === jobID)!)); + const stopThreadApplicationService = vi.fn(async (_threadID: string, runID: string, jobID: string) => { + records = records.map((record) => record.run_id === runID && record.job_id === jobID + ? { ...record, state: "cancelled", can_stop: false } : record); + return { version: "thread_application_services.v1", service: records.find((record) => record.job_id === jobID)!, replayed: false }; + }); + const openFullCDPSession = vi.fn(async (runID: string) => ({ session: { + run_id: runID, state: "starting", session_id: "source-browser", target_origin: "http://127.0.0.1:3001", + } })); + const result = fixture(false, { + hasControl: true, listThreadApplicationServices, getThreadApplicationService, stopThreadApplicationService, + get: vi.fn(async (path: string) => ({ run: { id: path.split("/").at(-1), status: options?.runStatus ?? "running" }, + execution_permission: { mode: "full", runtime_gate_available: true, revision: 4 }, + browser_cdp_permission: { mode: "full_debug", runtime_gate_available: true, revision: 7 } })) as APIClient["get"], + getFullCDPSession: vi.fn(async () => ({ session: null })), openFullCDPSession, + ...options?.overrides, + } as unknown as Partial, options?.startRequest); + return { ...result, getThreadApplicationService, listThreadApplicationServices, stopThreadApplicationService, openFullCDPSession }; +} + +it("requires an explicit address choice and opens the existing managed browser for the service's source Run", async () => { + const { getThreadApplicationService, openFullCDPSession } = servicesFixture([service("job-a"), service("job-b")]); + await screen.findByRole("button", { name: /Job job-a/ }); + expect(getThreadApplicationService).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: /Job job-a/ })); + const secondAddress = await screen.findByRole("button", { name: "选择地址 http://127.0.0.1:3001/" }); + expect(screen.getByRole("textbox", { name: "项目应用地址" })).toHaveValue(""); + expect(screen.getByRole("button", { name: "打开应用" })).toBeDisabled(); + fireEvent.click(screen.getByText("查看原始启动输出")); + expect(screen.getByText(/Port 3000 was in use/)).toHaveTextContent(""); + expect(document.querySelector("script")).toBeNull(); + fireEvent.click(secondAddress); + await waitFor(() => expect(screen.getByRole("button", { name: "打开应用" })).toBeEnabled()); + fireEvent.click(screen.getByRole("button", { name: "打开应用" })); + await waitFor(() => expect(openFullCDPSession).toHaveBeenCalledWith("run-service", expect.objectContaining({ + target: "http://127.0.0.1:3001/", expected_execution_permission_revision: 4, + expected_browser_cdp_permission_revision: 7, + }), expect.any(String))); +}); + +it("associates the startup request only with a matching source Run and message", async () => { + const { getThreadApplicationService } = servicesFixture([ + service("job-unrelated", { source_message_id: "old-message" }), + service("job-related", { source_message_id: "startup-message" }), + service("job-other-run", { run_id: "other-run", source_message_id: "startup-message" }), + ], { startRequest: { request: "Start", phase: "accepted", runID: "run-service", messageID: "startup-message" } }); + await screen.findByRole("button", { name: "选择地址 http://127.0.0.1:3001/" }); + expect(getThreadApplicationService).toHaveBeenCalledWith("thread-preview", "job-related", expect.any(AbortSignal)); + expect(getThreadApplicationService.mock.calls.every(([, jobID]) => jobID === "job-related")).toBe(true); + expect(screen.getByRole("button", { name: /Job job-related/ })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByRole("button", { name: /Job job-unrelated/ })).toHaveAttribute("aria-pressed", "false"); +}); + +it("keeps browser readiness independent from a failed service and prevents opening its output addresses", async () => { + const source = service("job-failed", { state: "failed", exit_code: 1, can_stop: false }); + const { openFullCDPSession } = servicesFixture([source], { overrides: { + getFullCDPSession: vi.fn(async (runID: string) => ({ session: { state: "ready", run_id: runID, + session_id: "browser-preview", target_origin: "http://127.0.0.1:18886" } })) as unknown as APIClient["getFullCDPSession"], + } }); + fireEvent.click(await screen.findByRole("button", { name: /Job job-failed/ })); + const address = await screen.findByRole("button", { name: "选择地址 http://127.0.0.1:3001/" }); + expect(address).toBeDisabled(); + expect(screen.getByText("任务命令启动或执行失败 · 退出码 1")).toBeInTheDocument(); + expect(screen.getByText("浏览器已就绪")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "停止此命令" })).not.toBeInTheDocument(); + expect(openFullCDPSession).not.toHaveBeenCalled(); +}); + +it("does not borrow the current Run's browser authority for a terminal source Run", async () => { + const { openFullCDPSession } = servicesFixture([service("job-old")], { runStatus: "completed" }); + fireEvent.click(await screen.findByRole("button", { name: /Job job-old/ })); + const address = await screen.findByRole("button", { name: "选择地址 http://127.0.0.1:3001/" }); + expect(address).toBeDisabled(); + expect(screen.getByRole("button", { name: "打开应用" })).toBeDisabled(); + expect(screen.getByText("服务所属执行已退出或无法确认,不能打开其预览浏览器。")).toBeInTheDocument(); + expect(openFullCDPSession).not.toHaveBeenCalled(); +}); + +it("stops only the selected service while closing the browser leaves both service Jobs alone", async () => { + const { client, stopThreadApplicationService } = servicesFixture([service("job-a"), service("job-b")]); + fireEvent.click(await screen.findByRole("button", { name: /Job job-b/ })); + fireEvent.click(await screen.findByRole("button", { name: "选择地址 http://127.0.0.1:3001/" })); + await waitFor(() => expect(screen.getByRole("button", { name: "打开应用" })).toBeEnabled()); + fireEvent.click(screen.getByRole("button", { name: "打开应用" })); + fireEvent.click(await screen.findByRole("button", { name: "关闭浏览器" })); + await waitFor(() => expect(client.closeFullCDPSession).toHaveBeenCalledWith("run-service", + expect.objectContaining({ expected_session_id: "source-browser" }), expect.any(String))); + expect(stopThreadApplicationService).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "停止此命令" })); + await waitFor(() => expect(stopThreadApplicationService).toHaveBeenCalledExactlyOnceWith("thread-preview", "run-service", "job-b")); + await screen.findByText("任务命令已停止", { selector: "p[role='status']" }); + expect(screen.getByRole("button", { name: /Job job-a/ })).toHaveTextContent("命令进程运行中"); +}); + +it("does not automatically repeat an unconfirmed stop request", async () => { + let rejectStop!: (failure: Error) => void; + const pendingStop = new Promise((_resolve, reject) => { rejectStop = reject; }); + const stop = vi.fn(() => pendingStop); + servicesFixture([service("job-a")], { overrides: { stopThreadApplicationService: stop } }); + fireEvent.click(await screen.findByRole("button", { name: /Job job-a/ })); + await screen.findByRole("button", { name: "选择地址 http://127.0.0.1:3001/" }); + const button = screen.getByRole("button", { name: "停止此命令" }); + fireEvent.click(button); fireEvent.click(button); + await waitFor(() => expect(stop).toHaveBeenCalledTimes(1)); + rejectStop(new Error("响应中断")); + await screen.findByText(/停止服务未确认完成/); + expect(stop).toHaveBeenCalledTimes(1); + expect(screen.queryByText("任务命令已停止")).not.toBeInTheDocument(); +}); + +it("keeps a cold running record unconfirmed and prevents using its saved address", async () => { + servicesFixture([service("job-cold", { can_stop: false })]); + fireEvent.click(await screen.findByRole("button", { name: /Job job-cold/ })); + const address = await screen.findByRole("button", { name: "选择地址 http://127.0.0.1:3001/" }); + expect(address).toBeDisabled(); + expect(screen.getByRole("button", { name: "打开应用" })).toBeDisabled(); + expect(screen.queryByRole("button", { name: "停止此命令" })).not.toBeInTheDocument(); + expect(screen.getByText("保存的运行记录,当前服务状态未确认", { selector: "p" })).toBeInTheDocument(); +}); + +it("keeps late service and browser responses attached to their original Run after selecting another Job", async () => { + const first = service("job-a", { run_id: "run-a" }); + const second = service("job-b", { run_id: "run-b" }); + let resolveDetail!: (value: ThreadApplicationServiceDetailView) => void; + const oldDetail = new Promise((resolve) => { resolveDetail = resolve; }); + let resolveSession!: (value: unknown) => void; + const oldSession = new Promise((resolve) => { resolveSession = resolve; }); + const getFullCDPSession = vi.fn((runID: string) => runID === "run-a" ? oldSession : Promise.resolve({ + session: runID === "run-b" ? { state: "starting", run_id: runID, session_id: "browser-b" } : null, + })); + const { client } = servicesFixture([first, second], { overrides: { + getThreadApplicationService: vi.fn((_threadID, jobID) => jobID === "job-a" ? oldDetail : Promise.resolve(serviceDetail(second))), + getFullCDPSession, + } as unknown as Partial }); + fireEvent.click(await screen.findByRole("button", { name: /Job job-a/ })); + await waitFor(() => expect(getFullCDPSession).toHaveBeenCalledWith("run-a", expect.any(AbortSignal))); + fireEvent.click(screen.getByRole("button", { name: /Job job-b/ })); + await screen.findByRole("button", { name: "选择地址 http://127.0.0.1:3001/" }); + await act(async () => { + resolveDetail({ ...serviceDetail(first), output: { ...serviceDetail(first).output, stdout: "old-run-output" }, + candidate_urls: [{ url: "http://127.0.0.1:8999/", source: "command_output", verified: false }] }); + resolveSession({ session: { state: "ready", run_id: "run-a", session_id: "browser-a", target_origin: "http://127.0.0.1:8999" } }); + await Promise.all([oldDetail, oldSession]); + }); + const browser = screen.getByRole("region", { name: "预览浏览器控制" }); + expect(within(browser).getByText("run-b")).toBeInTheDocument(); + expect(screen.queryByText("old-run-output")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "选择地址 http://127.0.0.1:8999/" })).not.toBeInTheDocument(); + fireEvent.click(within(browser).getByRole("button", { name: "关闭浏览器" })); + await waitFor(() => expect(client.closeFullCDPSession).toHaveBeenCalledWith("run-b", + expect.objectContaining({ expected_session_id: "browser-b" }), expect.any(String))); +}); + +it("loads original tool evidence only on demand and rejects another Run's record", async () => { + const threadActivityDetail = vi.fn().mockResolvedValue({ run_id: "another-run", tools: [] }); + servicesFixture([service("job-a", { source_call_id: "source-call" })], { + overrides: { threadActivityDetail }, + }); + fireEvent.click(await screen.findByRole("button", { name: /Job job-a/ })); + const disclosure = await screen.findByText("查看启动工具记录与已保存输出"); + expect(threadActivityDetail).not.toHaveBeenCalled(); + fireEvent.click(disclosure); + await screen.findByText("启动工具记录读取失败。"); + expect(threadActivityDetail).toHaveBeenCalledWith("thread-preview", "source-call", expect.any(AbortSignal)); +}); + +it("explains incomplete retained output and a bounded service list", async () => { + const source = service("job-output"); + servicesFixture([source], { overrides: { + listThreadApplicationServices: vi.fn().mockResolvedValue({ version: "thread_application_services.v1", + thread_id: "thread-preview", services: [source], has_more: true }), + getThreadApplicationService: vi.fn().mockResolvedValue({ ...serviceDetail(source), output: { + ...serviceDetail(source).output, base_cursor: 10, dropped: true, truncation_reason: "retained_tail", next_cursor: 20, end_cursor: 40, + } }), + } }); + fireEvent.click(await screen.findByRole("button", { name: /Job job-output/ })); + await screen.findByText("启动输出未完整保留(retained_tail)。"); + expect(screen.getByText("当前展示部分保留输出。")).toBeInTheDocument(); + expect(screen.getByText("当前只展示最近一批后台任务,列表尚未完整。")).toBeInTheDocument(); +}); diff --git a/web/src/v2/components/application-preview.tsx b/web/src/v2/components/application-preview.tsx index 59bdd257..220b32b7 100644 --- a/web/src/v2/components/application-preview.tsx +++ b/web/src/v2/components/application-preview.tsx @@ -3,20 +3,171 @@ import { createPortal } from "react-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ExternalLink, LoaderCircle, RefreshCw, X } from "lucide-react"; import { APIRequestError, type APIClient } from "../../api/client"; -import type { RunDetailView } from "../../api/types"; +import type { RunDetailView, ThreadApplicationServiceView, ThreadApplicationServiceDetailView } from "../../api/types"; import { localPreviewURL, parseApplicationPreview, type ApplicationPreview, type ApplicationPreviewElement } from "../../api/application-preview"; import { useModalFocusTrap } from "../../hooks/use-modal-focus-trap"; import { browserCDPQueryKey, fullCDPSessionQueryKey } from "./browser-cdp-control"; import { V2PermissionControl } from "./permission-control"; +import { ThreadActivityToolDetailPanel } from "./activity-detail"; +import { applicationPreviewRequestCopy, applicationServicesQueryKey, type ApplicationPreviewRequest } from "../application-preview-request"; import "./application-preview.css"; -export function V2ApplicationPreview({ client, runID, threadID, onClose, returnFocusRef, onRequestStart }: { +export function V2ApplicationPreview({ client, runID, threadID, onClose, returnFocusRef, onRequestStart, startRequest, canRequestStart = true }: { client: APIClient; runID: string; threadID: string; onClose: () => void; returnFocusRef: RefObject; onRequestStart: () => void; + startRequest?: ApplicationPreviewRequest | null; canRequestStart?: boolean; }) { const queries = useQueryClient(); const closeButton = useRef(null); const dialog = useModalFocusTrap(true, onClose, false, closeButton, { isolateBackground: true, returnFocusRef }); + const [selectedJobID, setSelectedJobID] = useState(null); + const stopping = useRef(false); + const servicesReadable = typeof client.listThreadApplicationServices === "function"; + const services = useQuery({ queryKey: applicationServicesQueryKey(threadID), + queryFn: ({ signal }) => client.listThreadApplicationServices(threadID, signal), + enabled: servicesReadable, retry: false, refetchOnMount: "always", refetchInterval: 1500 }); + const records = services.data?.services ?? []; + const related = startRequest?.runID && startRequest.messageID + ? records.filter((service) => service.run_id === startRequest.runID && service.source_message_id === startRequest.messageID) : []; + const selected = records.find((service) => service.job_id === selectedJobID) ?? + (selectedJobID === null && related.length === 1 ? related[0] : undefined); + const serviceDetail = useQuery({ queryKey: applicationServiceQueryKey(threadID, selected?.job_id ?? ""), + queryFn: ({ signal }) => client.getThreadApplicationService(threadID, selected!.job_id, signal), + enabled: Boolean(selected), retry: false, refetchOnMount: "always", refetchInterval: 1500 }); + const source = serviceDetail.data?.service ?? selected; + const serviceReadable = Boolean(source && !services.isError && !serviceDetail.isError && serviceDetail.data); + const stop = useMutation({ retry: false, + mutationFn: ({ sourceRunID, jobID }: { sourceRunID: string; jobID: string }) => + client.stopThreadApplicationService(threadID, sourceRunID, jobID), + onSuccess: (result, input) => { + queries.setQueryData(applicationServiceQueryKey(threadID, input.jobID), + (currentDetail) => currentDetail ? { ...currentDetail, service: result.service } : currentDetail); + void queries.invalidateQueries({ queryKey: applicationServicesQueryKey(threadID) }); + void queries.invalidateQueries({ queryKey: applicationServiceQueryKey(threadID, input.jobID) }); + }, onSettled: () => { stopping.current = false; }, + }); + const stopSelected = () => { + if (!source || !serviceReadable || !source.can_stop || stopping.current) return; + stopping.current = true; + stop.mutate({ sourceRunID: source.run_id, jobID: source.job_id }); + }; + const browserRunID = source?.run_id ?? runID; + return createPortal(
+
+

应用预览

+
+

项目命令与后台服务

+

启动要求先放回对话草稿,由你确认发送。服务进程状态与浏览器状态分别读取。

+ + {startRequest &&

{applicationPreviewRequestCopy(startRequest, !services.isError && related.length > 0)}

} +

所属 Thread {threadID}

+ {!servicesReadable ?

当前连接未提供后台服务状态。

+ : services.isLoading ?

正在读取后台服务记录…

+ : services.isError ?

无法读取后台服务状态,请重试核对。

+
+ : records.length === 0 ?

尚未发现受管理的后台服务。

: null} + {startRequest && ["accepted", "failed"].includes(startRequest.phase) && related.length === 0 && !services.isLoading && !services.isError && +

尚未发现本次启动要求关联的后台服务,可回对话查看批准或执行结果。

} + {records.length > 0 &&
    + {records.map((service) =>
  • )}
} + {services.data?.has_more &&

当前只展示最近一批后台任务,列表尚未完整。

} + {records.length > 1 && !selected &&

请选择要查看的后台任务;停止只作用于选中的 Run 和 Job。

} + {selectedJobID && !selected &&

所选任务不在当前列表,请重新选择或使用当前执行的手动地址。

} + {records.length > 0 && } + {source &&
+

服务 Run {source.run_id} · Job {source.job_id}

+

{serviceDetail.isError || services.isError ? "当前服务状态未确认" : serviceStateCopy(source.state, source.can_stop)} + {source.exit_code !== undefined && ` · 退出码 ${source.exit_code}`}

+ {source.source_message_id ?

来源消息 {source.source_message_id}

+ :

该后台任务没有可核对的来源消息,不能确定属于本次启动要求。

} + {source.can_stop && } +

收起面板或关闭浏览器不会停止服务。停止此服务只请求清理以上 Run / Job。

+ {stop.isError && stop.variables?.jobID === source.job_id &&

停止服务未确认完成,请核对状态后重试。 + {stop.error.message}

} + {serviceDetail.isLoading &&

正在读取启动输出…

} + {serviceDetail.isError &&

启动输出与当前状态读取失败。

+
} + {serviceDetail.data && } + {source.source_call_id && } +
} +
+ {(!selectedJobID || selected) && } +
+
, document.body); +} + +export const applicationServiceQueryKey = (threadID: string, jobID: string) => + [...applicationServicesQueryKey(threadID), jobID] as const; + +function serviceStateCopy(state: ThreadApplicationServiceView["state"], canStop: boolean): string { + if (state === "running" && !canStop) return "保存的运行记录,当前服务状态未确认"; + switch (state) { + case "prepared": return "任务命令已登记,等待启动"; + case "running": return "命令进程运行中,页面可用性尚未验证"; + case "stopping": return "正在停止任务命令"; + case "completed": return "任务命令已退出"; + case "failed": return "任务命令启动或执行失败"; + case "timed_out": return "任务命令已超时"; + case "cancelled": return "任务命令已停止"; + case "killed": return "任务命令已终止"; + case "interrupted": return "任务命令已中断"; + default: return "后台服务状态未确认"; + } +} + +function ServiceCommandEvidence({ client, source, threadID }: { + client: APIClient; source: ThreadApplicationServiceView; threadID: string; +}) { + const [open, setOpen] = useState(false); + const activityRef = source.source_call_id ?? ""; + const evidence = useQuery({ queryKey: ["v2", "application-service-evidence", threadID, source.run_id, source.job_id, activityRef], + queryFn: async ({ signal }) => { + const detail = await client.threadActivityDetail(threadID, activityRef, signal); + if (detail.run_id !== source.run_id || detail.tools.some((tool) => tool.name !== "command_runtime")) { + throw new Error("启动工具记录与所选命令的来源不匹配。"); + } + return detail; + }, enabled: open && Boolean(activityRef), retry: false }); + return
setOpen(event.currentTarget.open)}> + 查看启动工具记录与已保存输出 +

以下记录属于此命令的原始工具调用;一次工具调用可能包含多条命令。已保存输出按需读取。

+ {evidence.isLoading &&

正在读取启动工具记录…

} + {evidence.isError &&

启动工具记录读取失败。

+
} + {!evidence.isError && evidence.data?.tools.map((tool) => )} +
; +} + +function ServiceOutput({ detail }: { detail: ThreadApplicationServiceDetailView }) { + const output = detail.output; + return
查看原始启动输出 +

输出来自该后台任务,标准输出和标准错误分别展示保留的原始脱敏正文。输出中的地址尚未验证。

+ {!output.available ?

当前运行时无法读取此任务的保留输出。

+ : !output.stdout && !output.stderr ?

尚无启动输出。

+ : <>

标准输出

{output.stdout || "尚无标准输出。"}
+

标准错误

{output.stderr || "尚无标准错误。"}
} + {(output.dropped || output.base_cursor > 0 || output.truncation_reason) && +

启动输出未完整保留{output.truncation_reason ? `(${output.truncation_reason})` : ""}。

} + {output.next_cursor < output.end_cursor &&

当前展示部分保留输出。

} +
; +} + +function V2PreviewBrowser({ client, runID, threadID, currentRunID, sourceService, sourceReadable, candidates }: { + client: APIClient; runID: string; threadID: string; currentRunID: string; + sourceService?: ThreadApplicationServiceView; sourceReadable: boolean; + candidates?: ThreadApplicationServiceDetailView["candidate_urls"]; +}) { + const queries = useQueryClient(); const [target, setTarget] = useState(""); const [product, setProduct] = useState<"edge" | "chrome">("edge"); const [preview, setPreview] = useState(null); @@ -25,7 +176,8 @@ export function V2ApplicationPreview({ client, runID, threadID, onClose, returnF const [values, setValues] = useState>({}); const actionInFlight = useRef(false); const detail = useQuery({ queryKey: browserCDPQueryKey(runID), - queryFn: ({ signal }) => client.get(`/runs/${encodeURIComponent(runID)}`, {}, signal), refetchInterval: 3000 }); + queryFn: ({ signal }) => client.get(`/runs/${encodeURIComponent(runID)}`, {}, signal), + enabled: client.hasFullCDPSessionControl, refetchInterval: 3000 }); const session = useQuery({ queryKey: fullCDPSessionQueryKey(runID), queryFn: ({ signal }) => client.getFullCDPSession(runID, signal), enabled: client.hasFullCDPSessionControl, refetchOnMount: "always", refetchInterval: 1500 }); @@ -34,12 +186,15 @@ export function V2ApplicationPreview({ client, runID, threadID, onClose, returnF const active = ready || current?.state === "starting" || current?.state === "closing"; const permission = detail.data?.execution_permission; const browserPermission = detail.data?.browser_cdp_permission; - const eligible = (permission?.mode === "full_access" || permission?.mode === "debug") && permission.runtime_gate_available; + const eligible = permission?.mode === "full" && permission.runtime_gate_available; const cdpEnabled = browserPermission?.mode === "full_debug" && browserPermission.runtime_gate_available; + const runUsable = Boolean(detail.data?.run && ["created", "preparing", "running", "waiting_approval", "paused"].includes(detail.data.run.status)); + const serviceAllowsOpen = !sourceService || (sourceReadable && sourceService.state === "running" && sourceService.can_stop); + const canOpen = Boolean(!detail.isError && !session.isError && runUsable && serviceAllowsOpen && eligible && cdpEnabled); const refresh = useMutation({ retry: false, mutationFn: async () => { setError(""); setPreview(null); const sessionID = current?.session_id; - if (!sessionID || !ready) throw new Error("预览浏览器尚未就绪。"); + if (!sessionID || !ready || session.isError || detail.isError) throw new Error("预览浏览器尚未就绪或状态未确认。"); return parseApplicationPreview(await client.postControl(`/runs/${encodeURIComponent(runID)}/full-cdp-session/preview`, { version: "full_cdp_preview.v1", expected_session_id: sessionID, }, `v2-preview-${crypto.randomUUID()}`), runID, sessionID); @@ -99,33 +254,46 @@ export function V2ApplicationPreview({ client, runID, threadID, onClose, returnF if (!session.isFetching && !session.isError && ready && current?.session_id && lastSession.current !== current.session_id) { lastSession.current = current.session_id; refresh.mutate(); } - if (!ready) setPreview(null); - }, [ready, current?.session_id, session.isFetching, session.isError]); + if (!ready || session.isError || detail.isError) setPreview(null); + }, [ready, current?.session_id, session.isFetching, session.isError, detail.isError]); const busy = refresh.isPending || open.isPending || close.isPending || act.isPending; const visibleElements = preview?.page.elements.filter((element) => element.type !== "hidden") ?? []; - return createPortal(
-
-

应用预览

+ return
+

浏览器

+

浏览器所属 Run {runID}

{!client.hasFullCDPSessionControl ?

当前服务未提供应用预览,请在支持托管浏览器的桌面端或 API 服务中使用。

: <> + {sourceService && (!runUsable || !serviceAllowsOpen) &&

+ {detail.isLoading ? "正在核对服务所属执行…" : !sourceReadable ? "服务状态尚未确认,请先重试核对。" + : sourceService.state !== "running" ? "此后台任务未在运行,可以查看启动输出。" + : !sourceService.can_stop ? "保存的运行记录,当前服务状态未确认。请回对话核对服务。" + : "服务所属执行已退出或无法确认,不能打开其预览浏览器。"}

} + {candidates && candidates.length > 0 &&
+

从启动输出发现以下地址,尚未验证页面是否可用。请选择要打开的地址。

+ {candidates.map((candidate) => )} +
}
- {!active ? - : } + {!active ? + : }
{!active &&

填写开发服务显示的本机地址。打开后,当前任务可以操作这个独立浏览器;使用临时浏览器资料,关闭或到期后清理。 -

} - {!eligible &&
当前预览需要已激活的完全访问或调试权限。
} +

} + {!eligible &&
当前预览需要已激活的 Full 权限。 + {runID === currentRunID ? : 这是历史执行的权限;请回到对应执行核对,不能使用当前任务的权限替代。}
} {eligible && !cdpEnabled &&
浏览器控制目前关闭。启用后,当前任务能读取、操作此独立浏览器及其网络请求。 -
} -
{session.isError || detail.isError ? "无法读取预览状态" : current?.state === "ready" ? "预览已就绪" : current?.state === "starting" ? "正在启动浏览器…" - : current?.state === "closing" ? "正在清理浏览器…" : current?.state === "failed" ? "浏览器启动失败" : "预览未运行"} + {runID === currentRunID ? + : 请回到服务所属执行核对浏览器权限。}
} +
{session.isError || detail.isError ? "无法读取浏览器状态" : current?.state === "ready" ? "浏览器已就绪" : current?.state === "starting" ? "正在启动浏览器…" + : current?.state === "closing" ? "正在清理浏览器…" : current?.state === "failed" ? "浏览器启动失败" : "浏览器未运行"} {current?.target_origin && {current.target_origin}} {ready && }
{error &&

{error}

} {current?.state === "failed" &&

{current.failure_code || "未能启动,请检查开发服务与浏览器是否可用。"}

} - {ready &&
+ {ready && !session.isError && !detail.isError &&
{busy &&

正在读取页面…

} {url && preview && <>

{preview.page.title || "应用页面"}观察于 {new Date(preview.captured_at).toLocaleTimeString()}

{`应用页面:${preview.page.title @@ -145,6 +313,5 @@ export function V2ApplicationPreview({ client, runID, threadID, onClose, returnF
} {current?.state === "closed" &&

预览已关闭,浏览器{current.process_tree_quiescent && current.profile_cleaned ? "和临时资料已清理。" : "清理状态尚未完整确认。"}

} } -
-
, document.body); + ; } diff --git a/web/src/v2/components/conversation-inspector-feedback.test.tsx b/web/src/v2/components/conversation-inspector-feedback.test.tsx index 191a3408..85212656 100644 --- a/web/src/v2/components/conversation-inspector-feedback.test.tsx +++ b/web/src/v2/components/conversation-inspector-feedback.test.tsx @@ -91,7 +91,7 @@ it.each(["inspector", "conversation"] as const)("reveals the %s draft when reque await user.click(await screen.findByRole("button", { name: "应用预览" })); const dialog = await screen.findByRole("dialog", { name: "应用预览" }); await waitFor(() => expect(client.getFullCDPSession).toHaveBeenCalledWith("run-thread-a", expect.any(AbortSignal))); - await user.click(within(dialog).getByRole("button", { name: "让 Agent 启动项目应用" })); + await user.click(within(dialog).getByRole("button", { name: "把启动要求放回草稿" })); const editor = container.querySelector(".v2-shared-composer textarea")!; await waitFor(() => expect(editor).toHaveFocus()); expect(editor).toBeVisible(); diff --git a/web/src/v2/components/conversation-preview.test.tsx b/web/src/v2/components/conversation-preview.test.tsx new file mode 100644 index 00000000..2c5192af --- /dev/null +++ b/web/src/v2/components/conversation-preview.test.tsx @@ -0,0 +1,115 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { useState } from "react"; +import { APIRequestError, type APIClient } from "../../api/client"; +import type { ThreadDetailView, ThreadMessageControlView, WorkspaceView } from "../../api/types"; +import { APPLICATION_PREVIEW_START_REQUEST, applicationPreviewRequestKey, type ApplicationPreviewRequest } from "../application-preview-request"; +import { V2Conversation } from "./conversation"; + +vi.mock("../../hooks/use-run-event-stream", () => ({ useRunEventStream: () => ({ error: null, frames: [] }) })); +vi.mock("../../hooks/use-public-model-stream", () => ({ usePublicModelStream: () => ({ error: null, snapshot: null, status: "waiting" }) })); +vi.mock("./permission-control", () => ({ V2PermissionControl: () => null })); +vi.mock("./run-network-authority-control", () => ({ V2RunNetworkAuthorityControl: () => null })); +vi.mock("./model-route-control", () => ({ V2ModelRouteControl: () => null })); +vi.mock("./agent-browser", () => ({ V2AgentBrowser: () => null })); + +afterEach(cleanup); + +function detail(threadID: string): ThreadDetailView { + return { thread: { id: threadID, title: `Conversation ${threadID}`, status: "active", + workspace_id: "workspace-1", composer_state: "ready" }, + last_run: { id: `run-${threadID}`, status: "completed" }, runs: [], mission: {}, + } as unknown as ThreadDetailView; +} + +function fixture(submitThreadTurn = vi.fn().mockResolvedValue({ + run_id: "run-started", steering: { id: "startup-message" }, +} as ThreadMessageControlView)) { + const client = { hasThreadControl: true, submitThreadTurn, + get: vi.fn((path: string) => Promise.resolve(detail(path.split("/").at(-1)!))), + getPage: vi.fn().mockResolvedValue({ items: [], page: { limit: 100 }, requestID: "fixture" }), + } as unknown as APIClient; + const queries = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity }, mutations: { retry: false } } }); + function Harness() { + const [threadID, setThreadID] = useState("thread-a"); + const [drafts, setDrafts] = useState>({ "thread-a": "保留我的已有要求" }); + return + + setDrafts((current) => + expected === undefined || current[threadID] === expected ? { ...current, [threadID]: next } : current)} /> + ; + } + render(); + return { queries, submitThreadTurn }; +} + +async function requestStart() { + fireEvent.click(await screen.findByRole("button", { name: "应用预览" })); + fireEvent.click(await screen.findByRole("button", { name: "把启动要求放回草稿" })); + return screen.findByRole("textbox", { name: "继续对话" }); +} + +it("returns the complete startup requirement to the existing draft without submitting or claiming startup", async () => { + const { submitThreadTurn, queries } = fixture(); + const input = await requestStart(); + expect(input).toHaveValue(`保留我的已有要求\n\n${APPLICATION_PREVIEW_START_REQUEST}`); + expect(submitThreadTurn).not.toHaveBeenCalled(); + expect(screen.getByText("启动要求已放回草稿,请确认后发送。")).toBeInTheDocument(); + expect(queries.getQueryData(applicationPreviewRequestKey("thread-a"))?.phase).toBe("draft"); + fireEvent.click(screen.getByRole("button", { name: "查看应用服务" })); + fireEvent.click(await screen.findByRole("button", { name: "把启动要求放回草稿" })); + expect(input).toHaveValue(`保留我的已有要求\n\n${APPLICATION_PREVIEW_START_REQUEST}`); + expect(submitThreadTurn).not.toHaveBeenCalled(); +}); + +it("waits only after the user sends and retains the exact accepted source across Thread changes", async () => { + let resolve!: (result: ThreadMessageControlView) => void; + const pending = new Promise((finish) => { resolve = finish; }); + const submit = vi.fn(() => pending); + const { queries } = fixture(submit); + await requestStart(); + fireEvent.click(screen.getByRole("button", { name: "发送消息" })); + await screen.findByText("正在提交启动要求,尚未确认服务是否启动。"); + expect(submit).toHaveBeenCalledWith("thread-a", { + version: "thread_message_submission.v1", content: `保留我的已有要求\n\n${APPLICATION_PREVIEW_START_REQUEST}`, + }, expect.any(String)); + fireEvent.click(screen.getByRole("button", { name: "切换任务" })); + await screen.findByText("Conversation thread-b"); + await act(async () => { resolve({ run_id: "run-started", steering: { id: "startup-message" } } as ThreadMessageControlView); await pending; }); + await waitFor(() => expect(queries.getQueryData(applicationPreviewRequestKey("thread-a"))).toEqual( + expect.objectContaining({ phase: "accepted", runID: "run-started", messageID: "startup-message" }))); + expect(screen.queryByText("启动要求已受理,正在等待受管理的后台服务记录。")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "切换任务" })); + await screen.findByText("启动要求已受理,正在等待受管理的后台服务记录。"); + fireEvent.click(screen.getByRole("button", { name: "查看应用服务" })); + const panel = await screen.findByRole("dialog", { name: "应用预览" }); + expect(within(panel).getByText("尚未发现本次启动要求关联的后台服务,可回对话查看批准或执行结果。")).toBeInTheDocument(); +}); + +it("keeps a rejected startup requirement in the draft and reports that it was not queued", async () => { + const submit = vi.fn().mockRejectedValue(new APIRequestError("当前执行不接受消息", "CONFLICT", 409, "rejected", false)); + fixture(submit); + const input = await requestStart(); + fireEvent.click(screen.getByRole("button", { name: "发送消息" })); + await screen.findByText("启动要求未入队,草稿已保留。"); + expect(input).toHaveValue(`保留我的已有要求\n\n${APPLICATION_PREVIEW_START_REQUEST}`); + expect(submit).toHaveBeenCalledTimes(1); +}); + +it("keeps an unknown submission unconfirmed with its original key and draft", async () => { + const submit = vi.fn().mockRejectedValue(new Error("响应中断")); + const { queries } = fixture(submit); + const input = await requestStart(); + fireEvent.click(screen.getByRole("button", { name: "发送消息" })); + await screen.findByText("启动要求的提交结果尚未确认,请核对原提交。"); + expect(input).toHaveValue(`保留我的已有要求\n\n${APPLICATION_PREVIEW_START_REQUEST}`); + const request = queries.getQueryData(applicationPreviewRequestKey("thread-a")); + expect(request).toEqual(expect.objectContaining({ phase: "unconfirmed" })); + expect(request?.runID).toBeUndefined(); + expect(request?.messageID).toBeUndefined(); + expect(new Set(submit.mock.calls.map(([, , key]) => key))).toEqual(new Set([request?.operationKey])); + fireEvent.click(screen.getByRole("button", { name: "查看应用服务" })); + expect(await screen.findByRole("button", { name: "把启动要求放回草稿" })).toBeDisabled(); +}); diff --git a/web/src/v2/components/conversation.tsx b/web/src/v2/components/conversation.tsx index 030b09d3..addd3939 100644 --- a/web/src/v2/components/conversation.tsx +++ b/web/src/v2/components/conversation.tsx @@ -23,7 +23,8 @@ import { useV2QueuedMessagesQuery, V2QueuedMessages } from "./queued-messages"; import { V2AgentBrowser } from "./agent-browser"; import { v2AttachmentReferenceKey } from "../attachment-keys"; import { v2ImageReferenceKey } from "./image-input"; -import { V2ApplicationPreview } from "./application-preview"; +import { APPLICATION_PREVIEW_START_REQUEST, applicationPreviewRequestCopy, applicationPreviewRequestKey, applicationServicesQueryKey, + trackApplicationPreviewSubmission, type ApplicationPreviewRequest } from "../application-preview-request"; import type { FileEditReviewTarget } from "../../components/file-edit-panel"; import { V2ThreadContext } from "./thread-context"; import { V2ThreadPlanControl } from "./thread-plan"; @@ -41,6 +42,7 @@ import { useV2ThreadExecution, V2ThreadExecutionControl, V2PausedThreadControl } const V2TaskReview = lazy(() => import("./task-review").then((module) => ({ default: module.V2TaskReview }))); const V2Inspector = lazy(() => import("./inspector").then((module) => ({ default: module.V2Inspector }))); +const V2ApplicationPreview = lazy(() => import("./application-preview").then((module) => ({ default: module.V2ApplicationPreview }))); export function V2Conversation({ client, threadID, workspaces, onArchive, onManageModels, onOpenInspector, draft: legacyDraft, onDraftChange: legacyDraftChange, view = "conversation", onOpenTool, onOpenInspectorHome, onOpenWorktree, onExitInspector }: { @@ -59,6 +61,14 @@ export function V2Conversation({ client, threadID, workspaces, onArchive, onMana onExitInspector?: () => void; }) { const queryClient = useQueryClient(); + const previewRequest = useQuery({ + queryKey: applicationPreviewRequestKey(threadID), queryFn: () => null, + initialData: null, enabled: false, gcTime: Infinity, + }); + // The preview panel owns network reads and polling. This observer retains its + // validated source matches without loading the panel or fetching on arrival. + const previewServices = useQuery({ queryKey: applicationServicesQueryKey(threadID), + queryFn: ({ signal }) => client.listThreadApplicationServices(threadID, signal), enabled: false }); const recovery = useV2RecoveryStore(); const checkedRecovery = useRef(new Set()); const [checking, setChecking] = useState([]); @@ -79,12 +89,27 @@ export function V2Conversation({ client, threadID, workspaces, onArchive, onMana const reviewTrigger = useRef(null); const turn = useV2ThreadTurn(client); const submissions = useV2ThreadSubmissions(threadID); + const submitTrackedTurn = async (input: V2TurnInput) => { + trackApplicationPreviewSubmission(queryClient, input, "submitting"); + try { + const result = await turn.mutateAsync(input); + trackApplicationPreviewSubmission(queryClient, input, "accepted", { + runID: "run_id" in result ? result.run_id : undefined, messageID: result.steering.id, + }); + return result; + } catch (error) { + trackApplicationPreviewSubmission(queryClient, input, + v2TurnFailed(error) ? "failed" : v2TurnWasNotQueued(error) ? "rejected" : "unconfirmed", + error instanceof APIRequestError ? { runID: error.turnFailure?.run_id, messageID: error.turnFailure?.message_id } : undefined); + throw error; + } + }; const confirmSubmission = async (input: V2TurnInput): Promise<"accepted" | "rejected" | "unresolved"> => { // Older connectors without the read contract retain their explicit-key // retry behavior. A durable recovery scope always uses the read-only API. if (!recovery) { try { - await turn.mutateAsync(input); + await submitTrackedTurn(input); setConfirmedSubmission(input); if (draft === (input.draft ?? input.content)) onDraftChange?.("", draft); return "accepted"; @@ -107,6 +132,9 @@ export function V2Conversation({ client, threadID, workspaces, onArchive, onMana } const accepted = result.state !== "rejected" && !(input.deliveryMode === "steer" && result.message_status === "cancelled"); + trackApplicationPreviewSubmission(queryClient, input, + result.state === "failed" ? "failed" : accepted ? "accepted" : "rejected", + { runID: "run_id" in result ? result.run_id : undefined, messageID: result.message_id }); settleRecoveryTurn(recovery, input, accepted); if (accepted && !input.draftVersion) queryClient.setQueryData(v2FileReferenceKey(input.workspaceID, input.threadID), (current) => current?.filter(({ id }) => !input.files?.some((file) => file.id === id))); @@ -398,7 +426,7 @@ export function V2Conversation({ client, threadID, workspaces, onArchive, onMana if (outcome === "rejected") replaced = input.operationKey; if (outcome === "unresolved") { if (recovery) { - if (JSON.stringify([input.deliveryMode ?? "next_turn", input.content, input.files ?? [], imageIdentities(input.images), fileAttachmentIdentities(input.attachments)]) === fingerprint) { await turn.mutateAsync(input); return; } + if (JSON.stringify([input.deliveryMode ?? "next_turn", input.content, input.files ?? [], imageIdentities(input.images), fileAttachmentIdentities(input.attachments)]) === fingerprint) { await submitTrackedTurn(input); return; } throw new V2SubmissionError(input, new Error("原提交尚未确认。新草稿已保留,请先核对或继续提交原消息。")); } replaced = input.operationKey; @@ -415,8 +443,9 @@ export function V2Conversation({ client, threadID, workspaces, onArchive, onMana ...(attachments?.length ? { attachments } : {}), ...(draftVersion ? { draftVersion } : {}), ...(replaced ? { replacesOperationKey: replaced } : {}) }; + trackApplicationPreviewSubmission(queryClient, input, "submitting", undefined, true); try { - await turn.mutateAsync(input); + await submitTrackedTurn(input); // Remember the accepted payload: once the settled mutation leaves the // cache, a matching leftover draft is still recognized as submitted. setConfirmedSubmission(input); @@ -506,11 +535,20 @@ export function V2Conversation({ client, threadID, workspaces, onArchive, onMana } {contextOpen && setContextOpen(false)} onRequestChange={appendDraftAndReveal} returnFocusRef={contextTrigger} />} - {previewOpen && { setPreviewOpen(false); previewTrigger.current?.focus(); }}> + setPreviewOpen(false)} returnFocusRef={previewTrigger} onRequestStart={() => { - const request = "请检查当前项目的启动方式,使用受管理的后台命令启动开发服务,等待就绪后给出确切的本机预览地址;保留启动输出和可停止的任务标识。如果启动失败,请报告实际错误。"; - appendDraftAndReveal(request); - }} />} + if (!onDraftChange) return; + if (!draft?.includes(APPLICATION_PREVIEW_START_REQUEST)) appendDraftAndReveal(APPLICATION_PREVIEW_START_REQUEST); + else appendDraftAndReveal(""); + setDeliveryMode("next_turn"); + queryClient.setQueryData(applicationPreviewRequestKey(threadID), { + request: APPLICATION_PREVIEW_START_REQUEST, phase: "draft", + }); + }} /> + } {reviewOpen && { setReviewOpen(false); (reviewReturnFocus.current?.isConnected ? reviewReturnFocus.current : reviewTrigger.current)?.focus(); }}> {checking.includes(input.operationKey) ? "正在核对…" : "重试核对"}} {recovery && observations[input.operationKey] && !v2TurnOutcomeKnown(error) && } @@ -606,6 +644,12 @@ export function V2Conversation({ client, threadID, workspaces, onArchive, onMana }} type="button">有新内容 · 回到最新}
+ {previewRequest.data &&

+ {applicationPreviewRequestCopy(previewRequest.data, !previewServices.isError && Boolean(previewRequest.data.runID && + previewRequest.data.messageID && previewServices.data?.services.some((service) => + service.run_id === previewRequest.data?.runID && service.source_message_id === previewRequest.data?.messageID)))} + +

} {managedDraft && { managedDraft.document.resolve(managedDraft.scope, token, ref); }} />} {currentRun.status === "paused" && } From c7b6d160a5190a8c25b4b28480caa45a61763455 Mon Sep 17 00:00:00 2001 From: Qiyuanqiii <267806965+Qiyuanqiii@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:55:48 +0800 Subject: [PATCH 2/3] fix(api): enforce application service page bounds --- internal/httpapi/thread_application_services.go | 2 +- internal/httpapi/thread_application_services_test.go | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/internal/httpapi/thread_application_services.go b/internal/httpapi/thread_application_services.go index 8231c77a..25cd350d 100644 --- a/internal/httpapi/thread_application_services.go +++ b/internal/httpapi/thread_application_services.go @@ -92,7 +92,7 @@ func (a *API) serveThreadApplicationServices(w http.ResponseWriter, r *http.Requ if raw := r.URL.Query().Get("limit"); raw != "" { limit, err = strconv.Atoi(raw) } - if err != nil { + if err != nil || limit < 1 || limit > application.MaxThreadApplicationServicesLimit { err = apperror.New(apperror.CodeInvalidArgument, "Thread service limit is invalid") } else { value, err = a.threadApplicationServiceController.List(r.Context(), threadID, limit) diff --git a/internal/httpapi/thread_application_services_test.go b/internal/httpapi/thread_application_services_test.go index 8803bf5e..3520200c 100644 --- a/internal/httpapi/thread_application_services_test.go +++ b/internal/httpapi/thread_application_services_test.go @@ -107,4 +107,12 @@ func TestThreadApplicationServicesHTTPControlGateAndClosedStopContract(t *testin if w.Code != 200 || strings.Contains(w.Body.String(), `"can_stop":true`) { t.Fatalf("read-only metadata projected cleanup: %d %s", w.Code, w.Body) } + for _, limit := range []string{"0", "-1", "51", "invalid"} { + get.URL.RawQuery = "limit=" + limit + w = httptest.NewRecorder() + readonly.ServeHTTP(w, get) + if w.Code != http.StatusBadRequest { + t.Fatalf("invalid metadata limit %q: %d %s", limit, w.Code, w.Body) + } + } } From 326e84e8579b7969f49630db5dac4c543ca559cf Mon Sep 17 00:00:00 2001 From: Qiyuanqiii <267806965+Qiyuanqiii@users.noreply.github.com> Date: Tue, 6 Oct 2026 02:08:40 +0800 Subject: [PATCH 3/3] test(api): verify application service routes and response contracts --- docs/openapi.json | 2 +- internal/httpapi/openapi.go | 2 +- internal/httpapi/openapi_test.go | 47 +++++++++++++++++++++++++++++--- web/src/api/schema.d.ts | 2 +- 4 files changed, 46 insertions(+), 7 deletions(-) diff --git a/docs/openapi.json b/docs/openapi.json index 1bfa347f..16628bfe 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -21485,7 +21485,7 @@ } ], "responses": { - "202": { + "200": { "description": "Control request accepted or idempotently replayed", "content": { "application/json": { diff --git a/internal/httpapi/openapi.go b/internal/httpapi/openapi.go index 1d54e862..3a7df49e 100644 --- a/internal/httpapi/openapi.go +++ b/internal/httpapi/openapi.go @@ -789,7 +789,7 @@ func openAPIOperationSpecs() []openAPIOperationSpec { Description: "Reads an exact Thread-owned Run/Job and a bounded sanitized output tail. Loopback URLs are unverified command-output candidates; no network or readiness check is performed.", DataType: reflect.TypeOf(application.ThreadApplicationServiceDetailView{}), NotFound: true, Parameters: []openAPIParameter{threadID, pathIdentityParameter("job_id", "Exact command Job identity")}}, - {Path: ThreadApplicationServiceStopPathTemplate, Method: http.MethodPost, OperationID: "stopThreadApplicationService", Summary: "Stop one exact owned task command service", Tag: "Control", Control: true, NotFound: true, + {Path: ThreadApplicationServiceStopPathTemplate, Method: http.MethodPost, OperationID: "stopThreadApplicationService", Summary: "Stop one exact owned task command service", Tag: "Control", Control: true, NotFound: true, SuccessStatus: http.StatusOK, Description: "Cleanup only under Run execution control. Requires the expected original Run and the fixed application-stop-{job_id} Idempotency-Key. Only this process's exact Job owner may cancel; repeated stopping observes its fact without upgrading to Kill. Reaped terminal Jobs replay and cold active Jobs fail closed. No persisted PID is signalled.", DataType: reflect.TypeOf(application.ThreadApplicationServiceStopView{}), RequestType: reflect.TypeOf(ThreadApplicationServiceStopRequestView{}), Parameters: []openAPIParameter{threadID, pathIdentityParameter("job_id", "Exact command Job identity"), {Name: "Idempotency-Key", In: "header", Required: true, Schema: map[string]any{"type": "string"}}}}, diff --git a/internal/httpapi/openapi_test.go b/internal/httpapi/openapi_test.go index 42e2ee1a..833ab632 100644 --- a/internal/httpapi/openapi_test.go +++ b/internal/httpapi/openapi_test.go @@ -1229,10 +1229,25 @@ func TestOpenAPIRoutesMatchAuthenticatedLiveHandlers(t *testing.T) { } t.Run(spec.OperationID, func(t *testing.T) { requestAPI := fixture.api + var threadServiceJob threadApplicationServicesLiveJob if spec.Path == StandardCodePresetCreatePath || spec.Path == StandardCodePresetRunPathTemplate || spec.Path == StandardCodePauseAndConfigurePathTemplate { requestAPI = standardCodeAPI + } else if spec.Path == ThreadApplicationServicesPathTemplate || + spec.Path == ThreadApplicationServicePathTemplate || spec.Path == ThreadApplicationServiceStopPathTemplate { + // Bind these routes to an authorized, reaped Command Runtime Job, + // rather than the scheduler's generic job identity. Keep the native + // fixture inside this route subtest: an unavailable native runtime + // may skip this route, but cannot skip the rest of the HTTP catalog. + threadServicesFixture := newThreadApplicationServicesLiveFixture(t) + threadServiceJob = threadServicesFixture.start(t, "fail") + threadServicesFixture.waitDetail(t, threadServiceJob, func(detail threadApplicationServicesLiveDetail) bool { + return detail.Service.State == string(runner.CommandRuntimeJobFailed) + }) + requestPath = strings.ReplaceAll(spec.Path, "{thread_id}", threadServiceJob.threadID) + requestPath = strings.ReplaceAll(requestPath, "{job_id}", threadServiceJob.jobID) + requestAPI = threadServicesFixture.api } var response *httptest.ResponseRecorder expectedStatus := http.StatusOK @@ -1292,6 +1307,8 @@ func TestOpenAPIRoutesMatchAuthenticatedLiveHandlers(t *testing.T) { body := `{"profile":"docker"}` if spec.OperationID == "controlThreadPlan" { body = `{"version":"plan_delivery_control.v1","run_id":"` + openAPIThreadRun.ID + `","action":"enter_plan"}` + } else if spec.OperationID == "stopThreadApplicationService" { + body = `{"version":"thread_application_services.v1","expected_run_id":"` + threadServiceJob.runID + `"}` } else if spec.OperationID == "previewThreadGit" { body = `{"version":"thread_git.v1","run_id":"` + openAPIThreadRun.ID + `","spec":{"operation":"stage","paths":["README.md"]}}` } else if spec.OperationID == "executeThreadGit" { @@ -1714,9 +1731,11 @@ func TestOpenAPIRoutesMatchAuthenticatedLiveHandlers(t *testing.T) { if method == "" { method = http.MethodPost } - response = performControlMethodPathRequest(t, requestAPI, method, requestPath, - "openapi-live-operation-012345-"+spec.OperationID, - strings.NewReader(body)) + operationKey := "openapi-live-operation-012345-" + spec.OperationID + if spec.OperationID == "stopThreadApplicationService" { + operationKey = "application-stop-" + threadServiceJob.jobID + } + response = performControlMethodPathRequest(t, requestAPI, method, requestPath, operationKey, strings.NewReader(body)) status, statusErr := openAPISuccessStatus(spec) if statusErr != nil { t.Fatal(statusErr) @@ -1737,7 +1756,8 @@ func TestOpenAPIRoutesMatchAuthenticatedLiveHandlers(t *testing.T) { expectedStatus = http.StatusConflict } } else { - response = fixture.get(t, requestPath) + response = performRequest(t, requestAPI, http.MethodGet, requestPath, testAccessToken, + "127.0.0.1:8765", "127.0.0.1:45000", nil) } if spec.OperationID == "getBrowserSafeWebReadiness" || spec.OperationID == "getThreadActivityDetail" || @@ -1759,6 +1779,25 @@ func TestOpenAPIRoutesMatchAuthenticatedLiveHandlers(t *testing.T) { requestPath, response.Code, response.Body.String()) } assertSecurityHeaders(t, response) + if spec.OperationID == "listThreadApplicationServices" { + var view application.ThreadApplicationServicesView + decodeDataStatus(t, response, http.StatusOK, &view) + if view.ThreadID != threadServiceJob.threadID || len(view.Services) != 1 || view.Services[0].JobID != threadServiceJob.jobID || view.Services[0].RunID != threadServiceJob.runID { + t.Fatalf("application-service list lost the exact Thread Job: %+v", view) + } + } else if spec.OperationID == "getThreadApplicationService" { + var view application.ThreadApplicationServiceDetailView + decodeDataStatus(t, response, http.StatusOK, &view) + if view.Service.ThreadID != threadServiceJob.threadID || view.Service.JobID != threadServiceJob.jobID || view.Service.RunID != threadServiceJob.runID || view.Service.State != string(runner.CommandRuntimeJobFailed) { + t.Fatalf("application-service detail lost the exact Thread Job: %+v", view) + } + } else if spec.OperationID == "stopThreadApplicationService" { + var view application.ThreadApplicationServiceStopView + decodeDataStatus(t, response, http.StatusOK, &view) + if !view.Replayed || view.Service.ThreadID != threadServiceJob.threadID || view.Service.JobID != threadServiceJob.jobID || view.Service.RunID != threadServiceJob.runID || view.Service.CanStop { + t.Fatalf("application-service stop did not replay the exact reaped Job: %+v", view) + } + } contentType := response.Header().Get("Content-Type") if spec.Streaming { streamEvents := parseSSEEvents(t, response.Body.Bytes()) diff --git a/web/src/api/schema.d.ts b/web/src/api/schema.d.ts index fc884169..ffa1ff2c 100644 --- a/web/src/api/schema.d.ts +++ b/web/src/api/schema.d.ts @@ -22214,7 +22214,7 @@ export interface operations { }; responses: { /** @description Control request accepted or idempotently replayed */ - 202: { + 200: { headers: { [name: string]: unknown; };